Skip to content

Commit c660b20

Browse files
rschwarzBernardZweersDTMmiguelhisojo
authored
Various improvements to the HiGHS interface (#418)
* highs: fix var_get_index for error case * extend tests for var_by_name * extend test_objective: add & rm terms Before, only coefficients were changed. This now fails on HiGHS, which doesn't properly reset the objective before setting a new one. * fix SolverHighs.set_objective * highs: add comments about confusion between BINARY and INTEGER * add test_verbose * fix SolverHighs._{get,set}_bool_option_value Used incorrect types before. * skip tests with HiGHS based on .gz files * Return -1 if constraint not found * substitute highsbox for highspy * check status on Highs_getColIntegrality (fail some tests) * work around unexpected Highs_getColIntegrality error * upgrade python for doing the math fork to python3.13 * highs.py: formatting with black * pre-commit: update flake8 version to avoid error --------- Co-authored-by: BernardZweers <bernard@doingthemath.nl> Co-authored-by: Miguel Hisojo <miguel.hisojo@alliander.com>
1 parent 45f60c0 commit c660b20

File tree

7 files changed

+91
-36
lines changed

7 files changed

+91
-36
lines changed

.github/workflows/github-ci.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
- name: Set up Python
1616
uses: actions/setup-python@v5
1717
with:
18-
python-version: 3.11
18+
python-version: 3.13
1919

2020
- name: Upgrade pip
2121
run: python -m pip install --upgrade pip
@@ -57,7 +57,15 @@ jobs:
5757
with:
5858
python-version: ${{ matrix.python-version }}
5959
architecture: x64
60-
cache: 'pip'
60+
61+
- name: Cache pip dependencies
62+
uses: actions/cache@v4
63+
with:
64+
path: ~/.cache/pip
65+
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/pyproject.toml') }}
66+
restore-keys: |
67+
${{ runner.os }}-pip-${{ matrix.python-version }}-
68+
${{ runner.os }}-pip-
6169
6270
- name: Check python version
6371
run: python -c "import sys; import platform; print('Python %s implementation %s on %s' % (sys.version, platform.python_implementation(), sys.platform))"

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ repos:
77
- id: black
88
args: [--line-length=89]
99
- repo: https://github.com/pycqa/flake8
10-
rev: 4.0.1
10+
rev: 7.3.0
1111
hooks:
1212
- id: flake8
1313
args: [--select=F401, --exclude=__init__.py]

mip/highs.py

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"Python-MIP interface to the HiGHS solver."
22

3-
import glob
43
import numbers
54
import logging
65
import os.path
@@ -22,30 +21,23 @@
2221
libfile = os.environ[ENV_KEY]
2322
logger.debug("Choosing HiGHS library {libfile} via {ENV_KEY}.")
2423
else:
25-
# try library shipped with highspy packaged
26-
import highspy
24+
# Try library from highsbox, which is optional dependency.
25+
import highsbox
2726

28-
pkg_path = os.path.dirname(highspy.__file__)
27+
root = highsbox.highs_dist_dir()
2928

30-
# need library matching operating system
29+
# Need library matching operating system.
30+
# Following: PyOptInterface/src/pyoptinterface/_src/highs.py
3131
platform = sys.platform.lower()
3232
if "linux" in platform:
33-
patterns = ["highs_bindings.*.so", "_core.*.so"]
33+
libfile = os.path.join(root, "lib", "libhighs.so")
3434
elif platform.startswith("win"):
35-
patterns = ["highs_bindings.*.pyd", "_core.*.pyd"]
35+
libfile = os.path.join(root, "bin", "highs.dll")
3636
elif any(platform.startswith(p) for p in ("darwin", "macos")):
37-
patterns = ["highs_bindings.*.so", "_core.*.so"]
37+
libfile = os.path.join(root, "lib", "libhighs.dylib")
3838
else:
3939
raise NotImplementedError(f"{sys.platform} not supported!")
40-
41-
# there should only be one match
42-
matched_files = []
43-
for pattern in patterns:
44-
matched_files.extend(glob.glob(os.path.join(pkg_path, pattern)))
45-
if len(matched_files) != 1:
46-
raise FileNotFoundError(f"Could not find HiGHS library in {pkg_path}.")
47-
[libfile] = matched_files
48-
logger.debug("Choosing HiGHS library {libfile} via highspy package.")
40+
logger.debug("Choosing HiGHS library {libfile} via highsbox package.")
4941

5042
highslib = ffi.dlopen(libfile)
5143
has_highs = True
@@ -687,6 +679,7 @@
687679
def check(status):
688680
if status == STATUS_ERROR:
689681
raise mip.InterfacingError("Unknown error in call to HiGHS.")
682+
return status
690683

691684

692685
class SolverHighs(mip.Solver):
@@ -720,7 +713,7 @@ def __init__(self, model: mip.Model, name: str, sense: str):
720713
# Buffer string for storing names
721714
self._name_buffer = ffi.new(f"char[{self._lib.kHighsMaximumStringLength}]")
722715

723-
# type conversion maps
716+
# type conversion maps (can not distinguish binary from integer!)
724717
self._var_type_map = {
725718
mip.CONTINUOUS: self._lib.kHighsVarTypeContinuous,
726719
mip.BINARY: self._lib.kHighsVarTypeInteger,
@@ -760,8 +753,8 @@ def _get_double_option_value(self: "SolverHighs", name: str) -> float:
760753
)
761754
return value[0]
762755

763-
def _get_bool_option_value(self: "SolverHighs", name: str) -> float:
764-
value = ffi.new("bool*")
756+
def _get_bool_option_value(self: "SolverHighs", name: str) -> int:
757+
value = ffi.new("int*")
765758
check(
766759
self._lib.Highs_getBoolOptionValue(self._model, name.encode("UTF-8"), value)
767760
)
@@ -779,7 +772,7 @@ def _set_double_option_value(self: "SolverHighs", name: str, value: float):
779772
)
780773
)
781774

782-
def _set_bool_option_value(self: "SolverHighs", name: str, value: float):
775+
def _set_bool_option_value(self: "SolverHighs", name: str, value: int):
783776
check(
784777
self._lib.Highs_setBoolOptionValue(self._model, name.encode("UTF-8"), value)
785778
)
@@ -815,6 +808,8 @@ def add_var(
815808
if name:
816809
check(self._lib.Highs_passColName(self._model, col, name.encode("utf-8")))
817810
if var_type != mip.CONTINUOUS:
811+
# Note that HiGHS doesn't distinguish binary and integer variables
812+
# by type. There is only a boolean flag for "integrality".
818813
self._num_int_vars += 1
819814
check(
820815
self._lib.Highs_changeColIntegrality(
@@ -1035,6 +1030,18 @@ def set_start(self: "SolverHighs", start: List[Tuple["mip.Var", numbers.Real]]):
10351030
self._lib.Highs_setSolution(self._model, cval, ffi.NULL, ffi.NULL, ffi.NULL)
10361031

10371032
def set_objective(self: "SolverHighs", lin_expr: "mip.LinExpr", sense: str = ""):
1033+
# first reset old objective (all 0)
1034+
n = self.num_cols()
1035+
costs = ffi.new("double[]", n) # initialized to 0
1036+
check(
1037+
self._lib.Highs_changeColsCostByRange(
1038+
self._model,
1039+
0, # from_col
1040+
n - 1, # to_col
1041+
costs,
1042+
)
1043+
)
1044+
10381045
# set coefficients
10391046
for var, coef in lin_expr.expr.items():
10401047
check(self._lib.Highs_changeColCost(self._model, var.idx, coef))
@@ -1323,7 +1330,11 @@ def remove_constrs(self: "SolverHighs", constrsList: List[int]):
13231330

13241331
def constr_get_index(self: "SolverHighs", name: str) -> int:
13251332
idx = ffi.new("int *")
1326-
self._lib.Highs_getRowByName(self._model, name.encode("utf-8"), idx)
1333+
status = self._lib.Highs_getRowByName(self._model, name.encode("utf-8"), idx)
1334+
if status == STATUS_ERROR:
1335+
# This means that no constraint with that name was found. Unfortunately,
1336+
# Highs: getRowByName doesn't assign a value to idx in that case.
1337+
return -1
13271338
return idx[0]
13281339

13291340
# Variable-related getters/setters
@@ -1422,12 +1433,15 @@ def var_set_obj(self: "SolverHighs", var: "mip.Var", value: numbers.Real):
14221433
check(self._lib.Highs_changeColCost(self._model, var.idx, value))
14231434

14241435
def var_get_var_type(self: "SolverHighs", var: "mip.Var") -> str:
1436+
# Highs_getColIntegrality only works if some variable is not continuous.
1437+
# Since we want this method to always work, we need to catch this case first.
1438+
if self._num_int_vars == 0:
1439+
return mip.CONTINUOUS
1440+
14251441
var_type = ffi.new("int*")
1426-
ret = self._lib.Highs_getColIntegrality(self._model, var.idx, var_type)
1442+
check(self._lib.Highs_getColIntegrality(self._model, var.idx, var_type))
14271443
if var_type[0] not in self._highs_type_map:
1428-
raise ValueError(
1429-
f"Invalid variable type returned by HiGHS: {var_type[0]} (ret={ret})"
1430-
)
1444+
raise ValueError(f"Invalid variable type returned by HiGHS: {var_type[0]}.")
14311445
return self._highs_type_map[var_type[0]]
14321446

14331447
def var_set_var_type(self: "SolverHighs", var: "mip.Var", value: str):
@@ -1518,7 +1532,11 @@ def remove_vars(self: "SolverHighs", varsList: List[int]):
15181532

15191533
def var_get_index(self: "SolverHighs", name: str) -> int:
15201534
idx = ffi.new("int *")
1521-
self._lib.Highs_getColByName(self._model, name.encode("utf-8"), idx)
1535+
status = self._lib.Highs_getColByName(self._model, name.encode("utf-8"), idx)
1536+
if status == STATUS_ERROR:
1537+
# This means that no var with that name was found. Unfortunately,
1538+
# HiGHS::getColByName doesn't assign a value to idx in that case.
1539+
return -1
15221540
return idx[0]
15231541

15241542
def get_problem_name(self: "SolverHighs") -> str:

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ classifiers = [
2222
"Development Status :: 5 - Production/Stable",
2323
"License :: OSI Approved :: Eclipse Public License 2.0 (EPL-2.0)",
2424
"Operating System :: OS Independent",
25-
"Programming Language :: Python :: 3",
25+
"Programming Language :: Python :: 3.13",
2626
"Programming Language :: Python :: Implementation :: CPython",
2727
"Programming Language :: Python :: Implementation :: PyPy",
2828
"Topic :: Scientific/Engineering :: Mathematics"
@@ -38,7 +38,7 @@ numpy = [
3838
"numpy==1.21.*; python_version=='3.7'"
3939
]
4040
gurobi = ["gurobipy>=8"]
41-
highs = ["highspy>=1.5.3; python_version<='3.11'"]
41+
highs = ["highsbox>=1.10.0"]
4242
test = [
4343
"pytest>=7.4",
4444
"networkx==2.8.8; python_version>='3.8'",

test/mip_files_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ def test_mip_file(solver: str, instance: str):
139139

140140
max_dif = max(max(abs(ub), abs(lb)) * 0.01, TOL)
141141

142+
if solver == HIGHS and instance.endswith(".gz"):
143+
pytest.skip("HiGHS does not support .gz files.")
144+
142145
m.read(instance)
143146
if bas_file:
144147
m.verbose = True

test/mip_test.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,11 +556,21 @@ def test_constr_by_name_rhs(self, solver):
556556
assert model.constr_by_name("row0").rhs == val
557557

558558
@pytest.mark.parametrize("solver", SOLVERS)
559-
def test_var_by_name_rhs(self, solver):
559+
def test_var_by_name_valid(self, solver):
560560
n, model = self.build_model(solver)
561561

562-
v = model.var_by_name("x({},{})".format(0, 0))
562+
name = "x({},{})".format(0, 0)
563+
v = model.var_by_name(name)
563564
assert v is not None
565+
assert isinstance(v, mip.Var)
566+
assert v.name == name
567+
568+
@pytest.mark.parametrize("solver", SOLVERS)
569+
def test_var_by_name_invalid(self, solver):
570+
n, model = self.build_model(solver)
571+
572+
v = model.var_by_name("xyz_invalid_name")
573+
assert v is None
564574

565575
@pytest.mark.parametrize("solver", SOLVERS)
566576
def test_obj_const1(self, solver: str):

test/test_model.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,21 @@ def test_copy(solver):
12941294
assert id(term.expr) != id(term_copy.expr)
12951295

12961296

1297+
@skip_on(NotImplementedError)
1298+
@pytest.mark.parametrize("solver", SOLVERS)
1299+
def test_verbose(solver):
1300+
# set and get verbose flag
1301+
m = Model(solver_name=solver)
1302+
1303+
# active
1304+
m.verbose = 1
1305+
assert m.verbose == 1
1306+
1307+
# inactive
1308+
m.verbose = 0
1309+
assert m.verbose == 0
1310+
1311+
12971312
@skip_on(NotImplementedError)
12981313
@pytest.mark.parametrize("solver", SOLVERS)
12991314
def test_constraint_with_lin_expr_and_lin_expr(solver):
@@ -1356,6 +1371,7 @@ def test_objective(solver):
13561371
m = Model(solver_name=solver, sense=MAXIMIZE)
13571372
x = m.add_var(name="x", lb=0, ub=1)
13581373
y = m.add_var(name="y", lb=0, ub=1)
1374+
z = m.add_var(name="z", lb=0, ub=1)
13591375

13601376
m.objective = x - y + 0.5
13611377
assert m.objective.x is None
@@ -1374,13 +1390,13 @@ def test_objective(solver):
13741390

13751391

13761392
# Test changing the objective
1377-
m.objective = x + y + 1.5
1393+
m.objective = y + 2*z + 1.5
13781394
m.sense = MINIMIZE
13791395
# TODO: assert m.objective.sense == MINIMIZE
13801396

13811397
assert len(m.objective.expr) == 2
1382-
assert m.objective.expr[x] == 1
13831398
assert m.objective.expr[y] == 1
1399+
assert m.objective.expr[z] == 2
13841400
assert m.objective.const == 1.5
13851401

13861402
status = m.optimize()

0 commit comments

Comments
 (0)