Skip to content

Commit f507406

Browse files
Add support for ipopt solver (#142)
* Adding Ipopt as a supported solver in the array of solvers, 37 tests to fix for it to work properly * Ipopt does not support VariableDomains as the type signature in poi is different * Ipopt does not support maximization problems, implicitly change sign of objective and set to minimize * Add a tolerance function since Ipopt has some numerical differences when comparing to integers and run all tests with that new function * Raise not implemented exception when trying to write files using Ipopt since it's not supported by the optimizer * Parametrize IO tests to not use Ipopt when testing writing files * Skip MIP tests * Skip arithmetic test of binary variables * Fix quadrative objective, specially for ipopt since it needs to be negated when maximizing * Adding portfolio optimization example to test QPs for IPOPT and Gurobi * Add PyOptInterface dependendcy and update docs to include the binaries installation as well * Small typo fix * Run ruff format and ruff check * Clarify installation instructions and fix CI pipeline * Cleanup code * Simplify testing * Minor cleanup * Fix errors and move to new cache * Swap order of steps * Install dependencies for ipopt * Improve coverage and documentation * Increase test coverage * Fix tests that were being missed * Include tests that were being skipped --------- Co-authored-by: Martin Staadecker <[email protected]>
1 parent ba71b4c commit f507406

38 files changed

+779
-3082
lines changed

.github/actions/setup_optimizers_linux/action.yml

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
name: "Install optimizers on linux"
66

77
inputs:
8-
GUROBI_WLS:
8+
GUROBI_WLS:
99
description: "..."
1010
required: true
1111
# COPT_CLIENT_INI:
@@ -17,7 +17,7 @@ inputs:
1717
# GITHUB_TOKEN:
1818
# description: "..."
1919
# required: true
20-
CHECK_LICENSE:
20+
CHECK_LICENSE:
2121
description: "..."
2222
required: true
2323

@@ -33,18 +33,22 @@ runs:
3333
id: cache-installers-linux
3434
uses: actions/cache@v4
3535
env:
36-
cache-name: cache-installers-linux
36+
cache-name: cache-installers-linux-v2
3737
with:
3838
path: ~/installers
39-
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('optimizer_version.toml') }}
39+
key: ${{ runner.os }}-build-${{ env.cache-name }}-v2-${{ hashFiles('optimizer_version.toml') }}
4040
restore-keys: |
41-
${{ runner.os }}-build-${{ env.cache-name }}-
41+
${{ runner.os }}-build-${{ env.cache-name }}-v2-
4242
43-
- if: ${{ steps.cache-installers-linux.outputs.cache-hit != 'true' }}
43+
- if: ${{ steps.cache-installers-linux-v2.outputs.cache-hit != 'true' }}
4444
shell: bash
4545
name: Download Installers
4646
run: |
4747
curl -L -o ~/installers/gurobi.tar.gz https://packages.gurobi.com/12.0/gurobi12.0.2_linux64.tar.gz
48+
curl -L -o ~/installers/idaes-solvers.tar.gz https://github.com/IDAES/idaes-ext/releases/download/3.4.2/idaes-solvers-ubuntu2204-x86_64.tar.gz
49+
50+
# curl -L -o ~/installers/copt.tar.gz https://pub.shanshu.ai/download/copt/7.2.8/linux64/CardinalOptimizer-7.2.8-lnx64.tar.gz
51+
# curl -L -o ~/installers/mosek.tar.bz2 https://download.mosek.com/stable/10.2.0/mosektoolslinux64x86.tar.bz2
4852

4953
- name: Setup Gurobi Installation
5054
shell: bash
@@ -59,13 +63,13 @@ runs:
5963
echo "PATH=${PATH}:${GUROBI_HOME}/bin" >> $GITHUB_ENV
6064
echo "LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:${GUROBI_HOME}/lib" >> $GITHUB_ENV
6165
echo $GUROBI_HOME
62-
66+
6367
# setup license using secrets
6468
echo "$GUROBI_WLS" > ~/gurobi.lic
6569
echo "GRB_LICENSE_FILE=${HOME}/gurobi.lic" >> $GITHUB_ENV
6670
- name: Test Gurobi
6771
if: ${{ inputs.CHECK_LICENSE == 'true' }}
68-
uses: nick-fields/retry@v3 # wait 30 seconds (5x) if all licenses are already in use
72+
uses: nick-fields/retry@v3 # wait 30 seconds (5x) if all licenses are already in use
6973
with:
7074
max_attempts: 5
7175
retry_wait_seconds: 30
@@ -118,15 +122,16 @@ runs:
118122
# run: |
119123
# msktestlic
120124

121-
# - name: Setup IPOPT Installation
122-
# shell: bash
123-
# run: |
124-
# mkdir -p ~/ipopt
125-
# tar xfz ~/installers/idaes-solvers.tar.gz -C ~/ipopt
126-
# echo "PATH=${PATH}:${HOME}/ipopt" >> $GITHUB_ENV
127-
# echo "LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:${HOME}/ipopt" >> $GITHUB_ENV
128-
# ls ~/ipopt
129-
# - name: Test IPOPT
130-
# shell: bash
131-
# run: |
132-
# ipopt -v
125+
- name: Setup IPOPT Installation
126+
shell: bash
127+
run: |
128+
sudo apt-get install -y libopenblas-dev liblapack3 libgfortran5
129+
mkdir -p ~/ipopt
130+
tar xfz ~/installers/idaes-solvers.tar.gz -C ~/ipopt
131+
echo "PATH=${PATH}:${HOME}/ipopt" >> $GITHUB_ENV
132+
echo "LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:${HOME}/ipopt" >> $GITHUB_ENV
133+
ls ~/ipopt
134+
- name: Test IPOPT
135+
shell: bash
136+
run: |
137+
ipopt -v

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,13 @@ jobs:
7575
with:
7676
python-version: ${{ matrix.python-version }}
7777
cache: "pip"
78+
- name: Install dependencies
79+
run: |
80+
pip install .[dev]
7881
- uses: ./.github/actions/setup_optimizers_linux
7982
with:
8083
GUROBI_WLS: ${{ secrets.GUROBI_WLS }}
8184
CHECK_LICENSE: true
82-
- name: Install dependencies
83-
run: |
84-
pip install --editable .[dev]
8585
- name: Run tests and collect coverage
8686
run: pytest --cov
8787
- name: Upload coverage to Codecov

.vscode/settings.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,11 @@
33
"--markdown-docs", "--markdown-docs-syntax=superfences"
44
],
55
"python.testing.unittestEnabled": false,
6-
"python.testing.pytestEnabled": true
6+
"python.testing.pytestEnabled": true,
7+
"cSpell.words": [
8+
"Gurobi",
9+
"Pyoframe",
10+
"ipopt",
11+
"highs"
12+
]
713
}

docs/contribute/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ We use [Material Docs](https://squidfunk.github.io/mkdocs-material/) for documen
2828
- `python ./tests/test_examples.py`: Regenerate the files in the `results` folder of an example (e.g. `tests/examples/sudoku/results/**`). You should only run this if the result files need to be regenerated, for example, if model variable names have changed.
2929
- `ruff check`: Ensures all the linter tests pass
3030
- `ruff format`: Ensures the code is properly formatted (this is run upon commit if you've installed the pre-commit hooks)
31+
- `python -m tests/test_examples.py` to regenerate the example result datasets.
3132

3233
## Details for repository maintainers
3334

docs/learn/01_getting-started/01_installation.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,51 @@
1-
## Install Pyoframe
1+
## 1. Install Pyoframe
22

33
```cmd
44
pip install pyoframe
55
```
66

7-
## Install a solver
7+
## 2. Install a solver
88

9-
*[solver]: Solvers like HiGHS and Gurobi do the actual solving of your model. Pyoframe is a layer on top of the solver that makes it easy to build models and switch between solvers.
9+
Pyoframe makes it easy to build models but the actual solving of your model is done by a solver. You'll need to install one of the following solvers:
1010

11-
=== "HiGHS (free)"
11+
=== "HiGHS (open-source)"
12+
13+
To install [HiGHS](https://highs.dev/) run:
1214

1315
```cmd
1416
pip install pyoframe[highs]
1517
```
1618

19+
!!! warning "No support for quadratics in HiGHS"
20+
Pyoframe does not support quadratic constraints when using HiGHS due to limitations in pyoptinterface, the library we use to communicate with HiGHS.
21+
22+
1723
=== "Gurobi (commercial)"
1824

19-
1. [Install Gurobi](https://www.gurobi.com/downloads/gurobi-software/) from their website.
25+
To install Gurobi:
26+
27+
1. [Download Gurobi](https://www.gurobi.com/downloads/gurobi-software/) from their website (login required) and follow the installation instructions.
2028
2. Ensure you have a valid Gurobi license installed on your machine.
2129

22-
Note: installing Gurobi via pip will not work since we access Gurobi through its C API not through Python.
30+
!!! note "`pip` installation not possible"
31+
Installing Gurobi via `pip` will not work. We use Gurobi's C API which is not available in the Python version of Gurobi.
32+
33+
=== "Ipopt (free, nonlinear)"
34+
35+
To install [ipopt](https://coin-or.github.io/Ipopt/):
36+
37+
1. Run: `pip install pyoframe[ipopt]`
38+
2. Download the [Ipopt binaries](https://github.com/coin-or/Ipopt/releases) from GitHub. Version 3.14.x is recommended since it is the latest version that we've tested.
39+
3. On Windows, unpack the zip and add the `bin` folder to your Path variable. If not on Windows, you may have to build the solver from source, see further details [here](https://metab0t.github.io/PyOptInterface/getting_started.html#ipopt).
40+
41+
!!! warning "Continuous variables only"
42+
Ipopt is a nonlinear solver for continuous variables only. Use another solver if you need to use binary or integer variables.
43+
44+
=== "Other solvers"
45+
46+
We'd be glad to consider adding more solvers. Create a [new issue](https://github.com/Bravos-Power/pyoframe/issues/new) or up-vote an existing one to show interest:
47+
48+
- Issue tracking interest in [COPT solver](https://github.com/Bravos-Power/pyoframe/issues/143)
49+
- Issue tracking interest in [Mosek solver](https://github.com/Bravos-Power/pyoframe/issues/144)
2350

24-
=== "Other Solvers"
2551

26-
We'd be glad to add more solvers! Just [let us know](https://github.com/Bravos-Power/pyoframe/pull/79) what you'd like :)

mkdocs.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ plugins:
6565
handlers:
6666
python:
6767
options:
68+
show_if_no_docstring: true
69+
summary: true
70+
extensions:
71+
- dataclasses
6872
merge_init_into_class: true
6973
docstring_options:
7074
ignore_init_summary: true

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ dev = [
4747
"mkdocs-literate-nav",
4848
"mkdocs-table-reader-plugin",
4949
"markdown-hide-code>=0.1.1",
50+
"pyoptinterface[nlp]"
5051
]
5152
highs = ["highsbox"]
53+
ipopt = ["pyoptinterface[nlp]"]
5254

5355
[tool.ruff]
5456
lint.select = ["E4", "E7", "E9", "F", "I", "W292", "W291"]

src/pyoframe/constants.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
File containing shared constants used across the package.
33
"""
44

5+
from __future__ import annotations
6+
57
import typing
8+
from dataclasses import dataclass
69
from enum import Enum
710
from typing import Literal, Optional
811

@@ -15,10 +18,45 @@
1518
CONSTRAINT_KEY = "__constraint_id"
1619
SOLUTION_KEY = "solution"
1720
DUAL_KEY = "dual"
18-
SUPPORTED_SOLVERS = ["gurobi", "highs"]
19-
SUPPORTED_SOLVER_TYPES = Literal["gurobi", "highs"]
21+
2022
KEY_TYPE = pl.UInt32
2123

24+
25+
@dataclass
26+
class Solver:
27+
name: SUPPORTED_SOLVER_TYPES
28+
supports_integer_variables: bool = True
29+
supports_quadratics: bool = True
30+
supports_duals: bool = True
31+
supports_objective_sense: bool = True
32+
supports_write: bool = True
33+
34+
def check_supports_integer_variables(self):
35+
if not self.supports_integer_variables:
36+
raise ValueError(
37+
f"Solver {self.name} does not support integer or binary variables."
38+
)
39+
40+
def check_supports_write(self):
41+
if not self.supports_write:
42+
raise ValueError(f"Solver {self.name} does not support .write()")
43+
44+
def __repr__(self):
45+
return self.name
46+
47+
48+
SUPPORTED_SOLVERS = [
49+
Solver("gurobi"),
50+
Solver("highs", supports_quadratics=False, supports_duals=False),
51+
Solver(
52+
"ipopt",
53+
supports_integer_variables=False,
54+
supports_objective_sense=False,
55+
supports_write=False,
56+
),
57+
]
58+
59+
2260
# Variable ID for constant terms. This variable ID is reserved.
2361
CONST_TERM = 0
2462

@@ -49,7 +87,16 @@ class Config(metaclass=_ConfigMeta):
4987
Configuration options that apply to the entire library.
5088
"""
5189

52-
default_solver: Optional[SUPPORTED_SOLVER_TYPES] = None
90+
default_solver: SUPPORTED_SOLVER_TYPES | Solver | None = None
91+
"""
92+
The solver to use when `pf.Model()` is called without specifying a solver.
93+
If default_solver is not set (`None`),
94+
Pyoframe will choose the first solver in SUPPORTED_SOLVERS that doesn't produce an error.
95+
96+
There is no reason why you set the solver here instead of passing it to the Model constructor.
97+
This is mainly used for testing purposes.
98+
"""
99+
53100
disable_unmatched_checks: bool = False
54101
float_to_str_precision: Optional[int] = 5
55102
print_uses_variable_names: bool = True
@@ -135,6 +182,11 @@ class UnmatchedStrategy(Enum):
135182
for enum, type in [(ObjSense, ObjSenseValue), (VType, VTypeValue)]:
136183
assert set(typing.get_args(type)) == {vtype.value for vtype in enum}
137184

185+
SUPPORTED_SOLVER_TYPES = Literal["gurobi", "highs", "ipopt"]
186+
assert set(typing.get_args(SUPPORTED_SOLVER_TYPES)) == {
187+
s.name for s in SUPPORTED_SOLVERS
188+
}
189+
138190

139191
class PyoframeError(Exception):
140192
pass

src/pyoframe/core.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,13 +1132,20 @@ def __init__(self, lhs: Expression, sense: ConstraintSense):
11321132
self._model = lhs._model
11331133
self.sense = sense
11341134
self.to_relax: Optional[FuncArgs] = None
1135-
self.attr = Container(self._set_attribute, self._get_attribute)
1135+
self._attr = Container(self._set_attribute, self._get_attribute)
11361136

11371137
dims = self.lhs.dimensions
11381138
data = pl.DataFrame() if dims is None else self.lhs.data.select(dims).unique()
11391139

11401140
super().__init__(data)
11411141

1142+
@property
1143+
def attr(self) -> Container:
1144+
"""
1145+
Allows reading and writing constraint attributes similarly to [Model.attr][pyoframe.Model.attr].
1146+
"""
1147+
return self._attr
1148+
11421149
def _set_attribute(self, name, value):
11431150
self._assert_has_ids()
11441151
col_name = name
@@ -1504,7 +1511,7 @@ def __init__(
15041511
super().__init__(data)
15051512

15061513
self.vtype: VType = VType(vtype)
1507-
self.attr = Container(self._set_attribute, self._get_attribute)
1514+
self._attr = Container(self._set_attribute, self._get_attribute)
15081515
self._equals = equals
15091516

15101517
if lb is not None and not isinstance(lb, (float, int)):
@@ -1516,6 +1523,13 @@ def __init__(
15161523
else:
15171524
self._ub_expr, self.ub = None, ub
15181525

1526+
@property
1527+
def attr(self) -> Container:
1528+
"""
1529+
Allows reading and writing variable attributes similarly to [Model.attr][pyoframe.Model.attr].
1530+
"""
1531+
return self._attr
1532+
15191533
def _set_attribute(self, name, value):
15201534
self._assert_has_ids()
15211535
col_name = name
@@ -1559,11 +1573,16 @@ def _get_attribute(self, name):
15591573
).select(self.dimensions_unsafe + [col_name])
15601574

15611575
def _assign_ids(self):
1562-
kwargs = dict(domain=self.vtype.to_poi())
1576+
assert self._model is not None
1577+
1578+
kwargs = {}
15631579
if self.lb is not None:
1564-
kwargs["lb"] = self.lb
1580+
kwargs["lb"] = float(self.lb)
15651581
if self.ub is not None:
1566-
kwargs["ub"] = self.ub
1582+
kwargs["ub"] = float(self.ub)
1583+
if self.vtype != VType.CONTINUOUS:
1584+
self._model.solver.check_supports_integer_variables()
1585+
kwargs["domain"] = self.vtype.to_poi()
15671586

15681587
if self.dimensions is not None and self._model.use_var_names:
15691588
df = (

0 commit comments

Comments
 (0)