From 05754a6870b3e16dd16a794a97732ca9919b0bf8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Nov 2025 08:07:13 +0100 Subject: [PATCH 1/3] Add guard against mps export with quadratic constraints --- linopy/io.py | 8 +++++++- test/test_quadratic_constraint.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/linopy/io.py b/linopy/io.py index f64c1eb5..4c31dc23 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -634,7 +634,13 @@ def to_file( elif io_api == "mps": if "highs" not in solvers.available_solvers: raise RuntimeError( - "Package highspy not installed. This is required to exporting to MPS file." + "Package highspy not installed. This is required for exporting to MPS file." + ) + + if m.has_quadratic_constraints: + raise ValueError( + "MPS export does not support quadratic constraints. " + "Use LP format instead: model.to_file('model.lp')" ) # Use very fast highspy implementation diff --git a/test/test_quadratic_constraint.py b/test/test_quadratic_constraint.py index 5e2a386c..4658931a 100644 --- a/test/test_quadratic_constraint.py +++ b/test/test_quadratic_constraint.py @@ -307,6 +307,24 @@ def test_lp_file_with_multidimensional_constraint(self) -> None: # Clean up fn.unlink() + def test_mps_export_rejects_quadratic_constraints( + self, m: Model, x: linopy.Variable, y: linopy.Variable + ) -> None: + """Test that MPS export raises an error for quadratic constraints.""" + if "highs" not in linopy.available_solvers: + pytest.skip("HiGHS not available for MPS export") + + m.add_objective(x + y) + m.add_quadratic_constraints(x * x + y * y, "<=", 100, name="qc1") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".mps", delete=False) as f: + fn = Path(f.name) + + with pytest.raises(ValueError, match="MPS export does not support quadratic"): + m.to_file(fn, progress=False) + + fn.unlink(missing_ok=True) + class TestSolverValidation: """Tests for solver validation with quadratic constraints.""" From d841a03dd01efe797ed6bcbc91bdabd2ed2efee0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Nov 2025 08:12:24 +0100 Subject: [PATCH 2/3] Add mps export support with gurobi --- linopy/io.py | 30 ++++++++++++++++-------------- test/test_quadratic_constraint.py | 16 ++++++++++------ 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/linopy/io.py b/linopy/io.py index 4c31dc23..6fdf3d54 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -632,21 +632,23 @@ def to_file( ) elif io_api == "mps": - if "highs" not in solvers.available_solvers: - raise RuntimeError( - "Package highspy not installed. This is required for exporting to MPS file." - ) - if m.has_quadratic_constraints: - raise ValueError( - "MPS export does not support quadratic constraints. " - "Use LP format instead: model.to_file('model.lp')" - ) - - # Use very fast highspy implementation - # Might be replaced by custom writer, however needs C/Rust bindings for performance - h = m.to_highspy(explicit_coordinate_names=explicit_coordinate_names) - h.writeModel(str(fn)) + # MPS with quadratic constraints requires Gurobi + if "gurobi" not in solvers.available_solvers: + raise RuntimeError( + "Package Gurobipy not installed. This is requiredd for MPS export with quadratic constraints. " + "Use LP format instead" + ) + gm = m.to_gurobipy(explicit_coordinate_names=explicit_coordinate_names) + gm.write(str(fn)) + else: + # Use fast HiGHS implementation for models without QC + if "highs" not in solvers.available_solvers: + raise RuntimeError( + "Package highspy not installed. This is required for exporting to MPS file." + ) + h = m.to_highspy(explicit_coordinate_names=explicit_coordinate_names) + h.writeModel(str(fn)) else: raise ValueError( f"Invalid io_api '{io_api}'. Choose from 'lp', 'lp-polars' or 'mps'." diff --git a/test/test_quadratic_constraint.py b/test/test_quadratic_constraint.py index 4658931a..8a0e934e 100644 --- a/test/test_quadratic_constraint.py +++ b/test/test_quadratic_constraint.py @@ -307,12 +307,12 @@ def test_lp_file_with_multidimensional_constraint(self) -> None: # Clean up fn.unlink() - def test_mps_export_rejects_quadratic_constraints( + def test_mps_export_with_quadratic_constraints( self, m: Model, x: linopy.Variable, y: linopy.Variable ) -> None: - """Test that MPS export raises an error for quadratic constraints.""" - if "highs" not in linopy.available_solvers: - pytest.skip("HiGHS not available for MPS export") + """Test that MPS export works with quadratic constraints (using Gurobi).""" + if "gurobi" not in linopy.available_solvers: + pytest.skip("Gurobi not available for MPS export with QC") m.add_objective(x + y) m.add_quadratic_constraints(x * x + y * y, "<=", 100, name="qc1") @@ -320,8 +320,12 @@ def test_mps_export_rejects_quadratic_constraints( with tempfile.NamedTemporaryFile(mode="w", suffix=".mps", delete=False) as f: fn = Path(f.name) - with pytest.raises(ValueError, match="MPS export does not support quadratic"): - m.to_file(fn, progress=False) + m.to_file(fn, progress=False) + content = fn.read_text() + + # Check that QCMATRIX section is present + assert "QCMATRIX" in content + assert "qc" in content.lower() fn.unlink(missing_ok=True) From 912a068acbe6601e2db6651ca223cc6ae28ceeec Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 26 Nov 2025 08:14:54 +0100 Subject: [PATCH 3/3] Add roundtrip tests --- test/test_quadratic_constraint.py | 180 ++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/test/test_quadratic_constraint.py b/test/test_quadratic_constraint.py index 8a0e934e..c32abfff 100644 --- a/test/test_quadratic_constraint.py +++ b/test/test_quadratic_constraint.py @@ -330,6 +330,186 @@ def test_mps_export_with_quadratic_constraints( fn.unlink(missing_ok=True) +class TestFileRoundtrip: + """Tests for export/import/solve roundtrip with quadratic constraints.""" + + def test_lp_roundtrip_solve_compare(self) -> None: + """Test LP export -> read with Gurobi -> solve -> compare solutions.""" + if "gurobi" not in linopy.available_solvers: + pytest.skip("Gurobi not available") + + # Create model with QC + m = Model() + x = m.add_variables(lower=0, name="x") + y = m.add_variables(lower=0, name="y") + m.add_quadratic_constraints(x * x + y * y, "<=", 25, name="circle") + m.add_objective(x + 2 * y, sense="max") + + # Solve directly + m.solve("gurobi", io_api="direct") + direct_x = float(m.solution["x"].values) + direct_y = float(m.solution["y"].values) + direct_obj = m.objective.value + + # Export to LP, read back with gurobipy, solve + import gurobipy + + with tempfile.NamedTemporaryFile(suffix=".lp", delete=False) as f: + fn = Path(f.name) + + m.to_file(fn) + gm = gurobipy.read(str(fn)) + gm.optimize() + + file_x = gm.getVarByName("x0").X + file_y = gm.getVarByName("x1").X + file_obj = gm.ObjVal + + fn.unlink() + + # Compare solutions + assert np.isclose(direct_x, file_x, atol=0.01) + assert np.isclose(direct_y, file_y, atol=0.01) + assert np.isclose(direct_obj, file_obj, atol=0.01) + + def test_mps_roundtrip_solve_compare(self) -> None: + """Test MPS export -> read with Gurobi -> solve -> compare solutions.""" + if "gurobi" not in linopy.available_solvers: + pytest.skip("Gurobi not available") + + # Create model with QC + m = Model() + x = m.add_variables(lower=0, name="x") + y = m.add_variables(lower=0, name="y") + m.add_quadratic_constraints(x * x + y * y, "<=", 25, name="circle") + m.add_objective(x + 2 * y, sense="max") + + # Solve directly + m.solve("gurobi", io_api="direct") + direct_x = float(m.solution["x"].values) + direct_y = float(m.solution["y"].values) + direct_obj = m.objective.value + + # Export to MPS, read back with gurobipy, solve + import gurobipy + + with tempfile.NamedTemporaryFile(suffix=".mps", delete=False) as f: + fn = Path(f.name) + + m.to_file(fn) + gm = gurobipy.read(str(fn)) + gm.optimize() + + file_x = gm.getVarByName("x0").X + file_y = gm.getVarByName("x1").X + file_obj = gm.ObjVal + + fn.unlink() + + # Compare solutions + assert np.isclose(direct_x, file_x, atol=0.01) + assert np.isclose(direct_y, file_y, atol=0.01) + assert np.isclose(direct_obj, file_obj, atol=0.01) + + def test_lp_roundtrip_multidim_qc(self) -> None: + """Test LP roundtrip with multi-dimensional quadratic constraints.""" + if "gurobi" not in linopy.available_solvers: + pytest.skip("Gurobi not available") + + # Create model with multi-dim QC + m = Model() + x = m.add_variables(lower=0, coords=[range(3)], name="x") + y = m.add_variables(lower=0, coords=[range(3)], name="y") + m.add_quadratic_constraints(x * x + y * y, "<=", 25, name="circles") + m.add_objective((x + 2 * y).sum(), sense="max") + + # Solve directly + m.solve("gurobi", io_api="direct") + direct_obj = m.objective.value + + # Export to LP, read back, solve + import gurobipy + + with tempfile.NamedTemporaryFile(suffix=".lp", delete=False) as f: + fn = Path(f.name) + + m.to_file(fn) + gm = gurobipy.read(str(fn)) + gm.optimize() + + file_obj = gm.ObjVal + fn.unlink() + + # Compare objective values (3 independent problems, each with obj ≈ 11.18) + assert np.isclose(direct_obj, file_obj, atol=0.05) + assert np.isclose(direct_obj, 3 * 11.18, atol=0.1) + + def test_lp_roundtrip_mixed_linear_qc(self) -> None: + """Test LP roundtrip with both linear and quadratic constraints.""" + if "gurobi" not in linopy.available_solvers: + pytest.skip("Gurobi not available") + + # Create model with both linear and quadratic constraints + m = Model() + x = m.add_variables(lower=0, name="x") + y = m.add_variables(lower=0, name="y") + m.add_constraints(x + y <= 10, name="linear") + m.add_quadratic_constraints(x * x + y * y, "<=", 25, name="circle") + m.add_objective(x + 2 * y, sense="max") + + # Solve directly + m.solve("gurobi", io_api="direct") + direct_obj = m.objective.value + + # Export to LP, read back, solve + import gurobipy + + with tempfile.NamedTemporaryFile(suffix=".lp", delete=False) as f: + fn = Path(f.name) + + m.to_file(fn) + gm = gurobipy.read(str(fn)) + gm.optimize() + + file_obj = gm.ObjVal + fn.unlink() + + # Compare objective values + assert np.isclose(direct_obj, file_obj, atol=0.01) + + def test_mps_roundtrip_with_linear_terms_in_qc(self) -> None: + """Test MPS roundtrip with QC that has linear terms.""" + if "gurobi" not in linopy.available_solvers: + pytest.skip("Gurobi not available") + + # Create model: min x s.t. (x-1)² <= 0 + m = Model() + x = m.add_variables(lower=0, name="x") + m.add_quadratic_constraints(x * x - 2 * x + 1, "<=", 0, name="qc") + m.add_objective(x, sense="min") + + # Solve directly + m.solve("gurobi", io_api="direct") + direct_x = float(m.solution["x"].values) + + # Export to MPS, read back, solve + import gurobipy + + with tempfile.NamedTemporaryFile(suffix=".mps", delete=False) as f: + fn = Path(f.name) + + m.to_file(fn) + gm = gurobipy.read(str(fn)) + gm.optimize() + + file_x = gm.getVarByName("x0").X + fn.unlink() + + # Solution should be x = 1 + assert np.isclose(direct_x, 1.0, atol=0.01) + assert np.isclose(file_x, 1.0, atol=0.01) + + class TestSolverValidation: """Tests for solver validation with quadratic constraints."""