From 343e9ab2026c50fdcdf58868a366b42767b5c5a5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:01:43 +0100 Subject: [PATCH 1/9] feat: Add relax() and unrelax() to Variable and Variables Add methods to relax integrality of binary/integer variables to continuous, enabling LP relaxation of MILP models. Supports partial relaxation of individual variables or filtered views (e.g. m.variables.integers.relax()). Semi-continuous variables raise NotImplementedError since their relaxation requires bound changes. Refactors fix(relax=True) to delegate to relax(), removing the relax parameter from fix() to avoid redundancy. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/manipulating-models.ipynb | 537 +---------------------------- linopy/variables.py | 120 +++++-- test/test_fix.py | 197 ++++++++--- 3 files changed, 240 insertions(+), 614 deletions(-) diff --git a/examples/manipulating-models.ipynb b/examples/manipulating-models.ipynb index 6b0e2fad..ef8dc2b4 100644 --- a/examples/manipulating-models.ipynb +++ b/examples/manipulating-models.ipynb @@ -471,541 +471,8 @@ "start_time": "2026-03-18T08:06:56.218399Z" } }, - "source": "m.variables.binaries.fix(relax=True)\nm.solve(solver_name=\"highs\")\n\n# Dual values are now available on the constraints\nm.constraints[\"con1\"].dual", - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms\n", - "LP linopy-problem-s5776woy has 40 rows; 30 cols; 70 nonzeros\n", - "Coefficient ranges:\n", - " Matrix [1e+00, 1e+02]\n", - " Cost [1e+00, 1e+01]\n", - " Bound [1e+00, 1e+01]\n", - " RHS [1e+00, 7e+01]\n", - "Presolving model\n", - "17 rows, 14 cols, 27 nonzeros 0s\n", - "6 rows, 8 cols, 12 nonzeros 0s\n", - "Presolve reductions: rows 6(-34); columns 8(-22); nonzeros 12(-58) \n", - "Solving the presolved LP\n", - "Using EKK dual simplex solver - serial\n", - " Iteration Objective Infeasibilities num(sum)\n", - " 0 1.4512504460e+02 Pr: 6(180) 0s\n", - " 4 1.9754166667e+02 Pr: 0(0) 0s\n", - "\n", - "Performed postsolve\n", - "Solving the original LP from the solution after postsolve\n", - "\n", - "Model name : linopy-problem-s5776woy\n", - "Model status : Optimal\n", - "Simplex iterations: 4\n", - "Objective value : 1.9754166667e+02\n", - "P-D objective error : 7.1756893155e-17\n", - "HiGHS run time : 0.00\n" - ] - }, - { - "data": { - "text/plain": [ - " Size: 80B\n", - "array([-0. , -0. , -0. , 0.33333333, 0.33333333,\n", - " 0.375 , 0.375 , 0.375 , 0.375 , 0.375 ])\n", - "Coordinates:\n", - " * time (time) int64 80B 0 1 2 3 4 5 6 7 8 9" - ], - "text/html": [ - "
\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
<xarray.DataArray 'dual' (time: 10)> Size: 80B\n",
-       "array([-0.        , -0.        , -0.        ,  0.33333333,  0.33333333,\n",
-       "        0.375     ,  0.375     ,  0.375     ,  0.375     ,  0.375     ])\n",
-       "Coordinates:\n",
-       "  * time     (time) int64 80B 0 1 2 3 4 5 6 7 8 9
" - ] - }, - "execution_count": 36, - "metadata": {}, - "output_type": "execute_result" - } - ], + "source": "m.variables.binaries.fix()\nm.variables.binaries.relax()\nm.solve(solver_name=\"highs\")\n\n# Dual values are now available on the constraints\nm.constraints[\"con1\"].dual", + "outputs": [], "execution_count": null }, { diff --git a/linopy/variables.py b/linopy/variables.py index 2d17fef8..7a348697 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -1295,11 +1295,61 @@ def equals(self, other: Variable) -> bool: iterate_slices = iterate_slices + def relax(self) -> None: + """ + Relax the integrality of this variable. + + Converts binary or integer variables to continuous. The original type + is stored in the model's ``_relaxed_registry`` so that + :meth:`unrelax` can restore it. + + Semi-continuous variables are not supported and will raise a + ``NotImplementedError``. + + For binary variables, the existing [0, 1] bounds are preserved, + which is the correct LP relaxation. For integer variables, the + existing bounds are preserved as-is. + """ + if self.attrs.get("semi_continuous"): + msg = ( + f"Relaxation of semi-continuous variable '{self.name}' is not " + f"supported. The LP relaxation of a semi-continuous variable " + f"requires changing bounds, which is not handled by relax()." + ) + raise NotImplementedError(msg) + + if self.attrs.get("binary") or self.attrs.get("integer"): + original_type = "binary" if self.attrs.get("binary") else "integer" + self.model._relaxed_registry[self.name] = original_type + self.attrs["binary"] = False + self.attrs["integer"] = False + + def unrelax(self) -> None: + """ + Restore the original integrality type of a relaxed variable. + + Reverses the effect of :meth:`relax`. If the variable was not + previously relaxed, this is a no-op. + """ + registry = self.model._relaxed_registry + if self.name in registry: + original_type = registry.pop(self.name) + if original_type == "binary": + self.attrs["binary"] = True + elif original_type == "integer": + self.attrs["integer"] = True + + @property + def relaxed(self) -> bool: + """ + Return whether the variable is currently relaxed. + """ + return self.name in self.model._relaxed_registry + def fix( self, value: ConstantLike | None = None, decimals: int = 8, - relax: bool = False, overwrite: bool = True, ) -> None: """ @@ -1315,11 +1365,6 @@ def fix( Number of decimal places to round continuous variables to. Integer and binary variables are always rounded to 0 decimal places. Default is 8. - relax : bool, optional - If True, relax the integrality of integer/binary variables by - temporarily treating them as continuous. The original type is stored - in the model's ``_relaxed_registry`` and restored by ``unfix()``. - Default is False. overwrite : bool, optional If True, overwrite an existing fix constraint for this variable. If False (default), raise an error if the variable is already fixed. @@ -1354,30 +1399,18 @@ def fix( self.model.add_constraints(1 * self, "=", value, name=constraint_name) - if relax and (self.attrs.get("integer") or self.attrs.get("binary")): - original_type = "binary" if self.attrs.get("binary") else "integer" - self.model._relaxed_registry[self.name] = original_type - self.attrs["integer"] = False - self.attrs["binary"] = False - def unfix(self) -> None: """ Remove the fix constraint for this variable. - If the variable was relaxed during ``fix(relax=True)``, the original - integrality type (integer or binary) is restored. + If the variable was previously relaxed via :meth:`relax`, the original + integrality type is also restored. """ constraint_name = f"{FIX_CONSTRAINT_PREFIX}{self.name}" if constraint_name in self.model.constraints: self.model.remove_constraints(constraint_name) - registry = self.model._relaxed_registry - if self.name in registry: - original_type = registry.pop(self.name) - if original_type == "binary": - self.attrs["binary"] = True - elif original_type == "integer": - self.attrs["integer"] = True + self.unrelax() @property def fixed(self) -> bool: @@ -1665,7 +1698,6 @@ def fix( self, value: int | float | None = None, decimals: int = 8, - relax: bool = False, overwrite: bool = True, ) -> None: """ @@ -1682,26 +1714,11 @@ def fix( variables. If None, each variable is fixed to its current solution. decimals : int, optional Number of decimal places to round continuous variables to. - relax : bool, optional - If True, relax integrality of integer/binary variables. overwrite : bool, optional If True, overwrite existing fix constraints. - - Note - ---- - When using ``relax=True`` on a filtered view like - ``m.variables.integers``, the variables will no longer appear in that - view after relaxation. Call ``m.variables.unfix()`` to restore all - fixed variables. If other variables are also fixed and should stay - fixed, save the names before fixing to selectively unfix:: - - names = list(m.variables.integers) - m.variables.integers.fix(relax=True) - ... - m.variables[names].unfix() """ for var in self.data.values(): - var.fix(value=value, decimals=decimals, relax=relax, overwrite=overwrite) + var.fix(value=value, decimals=decimals, overwrite=overwrite) def unfix(self) -> None: """ @@ -1719,6 +1736,33 @@ def fixed(self) -> dict[str, bool]: """ return {name: var.fixed for name, var in self.items()} + def relax(self) -> None: + """ + Relax integrality of all integer/binary variables in this container. + + Delegates to each variable's :meth:`Variable.relax` method. + Semi-continuous variables will raise ``NotImplementedError``. + """ + for var in self.data.values(): + var.relax() + + def unrelax(self) -> None: + """ + Restore integrality of all previously relaxed variables in this + container. + + Delegates to each variable's :meth:`Variable.unrelax` method. + """ + for var in self.data.values(): + var.unrelax() + + @property + def relaxed(self) -> dict[str, bool]: + """ + Return a dict mapping variable names to whether they are relaxed. + """ + return {name: var.relaxed for name, var in self.items()} + @property def solution(self) -> Dataset: """ diff --git a/test/test_fix.py b/test/test_fix.py index 7e94d717..6e27a612 100644 --- a/test/test_fix.py +++ b/test/test_fix.py @@ -145,43 +145,42 @@ def test_unfix_noop_if_not_fixed(self, model_with_solution: Model) -> None: assert not m.variables["x"].fixed -class TestVariableFixRelax: - def test_fix_relax_binary(self, model_with_solution: Model) -> None: - m = model_with_solution - m.variables["z"].fix(relax=True) - # Should be relaxed to continuous - assert not m.variables["z"].attrs["binary"] - assert not m.variables["z"].attrs["integer"] - assert "z" in m._relaxed_registry - assert m._relaxed_registry["z"] == "binary" +class TestFixThenRelax: + """Test the combined fix() + relax() workflow (fix first, then relax).""" - def test_fix_relax_integer(self, model_with_solution: Model) -> None: + def test_fix_then_relax_binary(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["w"].fix(relax=True) - assert not m.variables["w"].attrs["integer"] - assert not m.variables["w"].attrs["binary"] - assert "w" in m._relaxed_registry - assert m._relaxed_registry["w"] == "integer" + m.variables["z"].fix() + m.variables["z"].relax() + assert not m.variables["z"].attrs["binary"] + assert m.variables["z"].fixed + assert m.variables["z"].relaxed - def test_unfix_restores_binary(self, model_with_solution: Model) -> None: + def test_unfix_restores_relaxed_binary(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["z"].fix(relax=True) + m.variables["z"].fix() + m.variables["z"].relax() m.variables["z"].unfix() assert m.variables["z"].attrs["binary"] - assert "z" not in m._relaxed_registry + assert not m.variables["z"].fixed + assert not m.variables["z"].relaxed - def test_unfix_restores_integer(self, model_with_solution: Model) -> None: + def test_fix_then_relax_integer(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["w"].fix(relax=True) - m.variables["w"].unfix() - assert m.variables["w"].attrs["integer"] - assert "w" not in m._relaxed_registry + m.variables["w"].fix() + m.variables["w"].relax() + assert not m.variables["w"].attrs["integer"] + assert m.variables["w"].fixed + assert m.variables["w"].relaxed - def test_fix_relax_continuous_noop(self, model_with_solution: Model) -> None: + def test_unfix_restores_relaxed_integer(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["x"].fix(relax=True) - # Continuous variable should not be in registry - assert "x" not in m._relaxed_registry + m.variables["w"].fix() + m.variables["w"].relax() + m.variables["w"].unfix() + assert m.variables["w"].attrs["integer"] + assert not m.variables["w"].fixed + assert not m.variables["w"].relaxed class TestVariableFixed: @@ -235,19 +234,133 @@ def test_fixed_returns_dict(self, model_with_solution: Model) -> None: assert result["x"] is True assert result["y"] is False - def test_fix_relax_integers(self, model_with_solution: Model) -> None: + def test_fix_then_relax_integers(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables.integers.fix(relax=True) + m.variables.integers.fix() + m.variables.integers.relax() assert not m.variables["w"].attrs["integer"] - m.variables.integers.unfix() - # After unfix from the integers view, the variable should be restored - # but we need to unfix from the actual variable since integers view - # won't contain it anymore after relaxation - # Let's unfix via the model variables directly + assert m.variables["w"].fixed m.variables["w"].unfix() assert m.variables["w"].attrs["integer"] +class TestVariableRelax: + def test_relax_binary(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + assert not m.variables["z"].attrs["binary"] + assert not m.variables["z"].attrs["integer"] + assert m.variables["z"].relaxed + assert m._relaxed_registry["z"] == "binary" + + def test_relax_integer(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["w"].relax() + assert not m.variables["w"].attrs["integer"] + assert not m.variables["w"].attrs["binary"] + assert m.variables["w"].relaxed + assert m._relaxed_registry["w"] == "integer" + + def test_relax_continuous_noop(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["x"].relax() + assert "x" not in m._relaxed_registry + assert not m.variables["x"].relaxed + + def test_relax_semi_continuous_raises(self) -> None: + m = Model() + m.add_variables(lower=1, upper=10, semi_continuous=True, name="sc") + with pytest.raises(NotImplementedError, match="semi-continuous"): + m.variables["sc"].relax() + + def test_unrelax_binary(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + m.variables["z"].unrelax() + assert m.variables["z"].attrs["binary"] + assert not m.variables["z"].relaxed + assert "z" not in m._relaxed_registry + + def test_unrelax_integer(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["w"].relax() + m.variables["w"].unrelax() + assert m.variables["w"].attrs["integer"] + assert not m.variables["w"].relaxed + assert "w" not in m._relaxed_registry + + def test_unrelax_noop_if_not_relaxed(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["x"].unrelax() + assert not m.variables["x"].relaxed + + def test_relax_preserves_binary_bounds(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + assert float(m.variables["z"].lower) == 0.0 + assert float(m.variables["z"].upper) == 1.0 + + def test_relax_preserves_integer_bounds(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["w"].relax() + assert float(m.variables["w"].lower) == 0.0 + assert float(m.variables["w"].upper) == 100.0 + + +class TestVariablesContainerRelax: + def test_relax_all(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.relax() + assert not m.variables["z"].attrs["binary"] + assert not m.variables["w"].attrs["integer"] + assert m.variables["z"].relaxed + assert m.variables["w"].relaxed + # Continuous variables unaffected + assert not m.variables["x"].relaxed + + def test_unrelax_all(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.relax() + m.variables.unrelax() + assert m.variables["z"].attrs["binary"] + assert m.variables["w"].attrs["integer"] + assert not m.variables["z"].relaxed + assert not m.variables["w"].relaxed + + def test_relax_integers_only(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.integers.relax() + assert not m.variables["w"].attrs["integer"] + assert m.variables["w"].relaxed + # Binary should be untouched + assert m.variables["z"].attrs["binary"] + assert not m.variables["z"].relaxed + + def test_relax_binaries_only(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.binaries.relax() + assert not m.variables["z"].attrs["binary"] + assert m.variables["z"].relaxed + # Integer should be untouched + assert m.variables["w"].attrs["integer"] + assert not m.variables["w"].relaxed + + def test_relaxed_returns_dict(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + result = m.variables.relaxed + assert isinstance(result, dict) + assert result["z"] is True + assert result["x"] is False + + def test_relax_with_semi_continuous_raises(self) -> None: + m = Model() + m.add_variables(lower=0, upper=10, name="x") + m.add_variables(lower=1, upper=10, semi_continuous=True, name="sc") + with pytest.raises(NotImplementedError, match="semi-continuous"): + m.variables.relax() + + class TestRemoveVariablesCleansUpFix: def test_remove_fixed_variable(self, model_with_solution: Model) -> None: m = model_with_solution @@ -257,7 +370,8 @@ def test_remove_fixed_variable(self, model_with_solution: Model) -> None: def test_remove_relaxed_variable(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["z"].fix(relax=True) + m.variables["z"].fix() + m.variables["z"].relax() m.remove_variables("z") assert "z" not in m._relaxed_registry assert f"{FIX_CONSTRAINT_PREFIX}z" not in m.constraints @@ -268,8 +382,10 @@ def test_relaxed_registry_survives_netcdf( self, model_with_solution: Model, tmp_path: Path ) -> None: m = model_with_solution - m.variables["z"].fix(relax=True) - m.variables["w"].fix(relax=True) + m.variables["z"].fix() + m.variables["z"].relax() + m.variables["w"].fix() + m.variables["w"].relax() path = tmp_path / "model.nc" m.to_netcdf(path) @@ -294,11 +410,11 @@ def test_empty_registry_netcdf( m2 = read_netcdf(path) assert m2._relaxed_registry == {} - def test_unfix_after_roundtrip( + def test_unrelax_after_roundtrip( self, model_with_solution: Model, tmp_path: Path ) -> None: m = model_with_solution - m.variables["z"].fix(relax=True) + m.variables["z"].relax() path = tmp_path / "model.nc" m.to_netcdf(path) @@ -306,7 +422,6 @@ def test_unfix_after_roundtrip( from linopy.io import read_netcdf m2 = read_netcdf(path) - m2.variables["z"].unfix() + m2.variables["z"].unrelax() assert m2.variables["z"].attrs["binary"] assert "z" not in m2._relaxed_registry - assert f"{FIX_CONSTRAINT_PREFIX}z" not in m2.constraints From 72618237a13b0b908da8ffc434e1a6c1e9abe664 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:03:20 +0100 Subject: [PATCH 2/9] chore: Rename test_fix.py to test_fix_relax.py and update release notes Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/release_notes.rst | 3 ++- test/{test_fix.py => test_fix_relax.py} | 0 2 files changed, 2 insertions(+), 1 deletion(-) rename test/{test_fix.py => test_fix_relax.py} (100%) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index ffbfe20f..f1afa98a 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -24,7 +24,8 @@ Upcoming Version * Improve handling of CPLEX solver quality attributes to ensure metrics such are extracted correctly when available. * Fix Xpress IIS label mapping for masked constraints and add a regression test for matching infeasible coordinates. * Enable quadratic problems with SCIP on windows. -* Add ``fix()``, ``unfix()``, and ``fixed`` to ``Variable`` and ``Variables`` for fixing variables to values via equality constraints. Supports automatic rounding and optional integrality relaxation (``relax=True``) for MILP dual extraction. +* Add ``fix()``, ``unfix()``, and ``fixed`` to ``Variable`` and ``Variables`` for fixing variables to values via equality constraints. Supports automatic rounding for integer/binary variables. +* Add ``relax()``, ``unrelax()``, and ``relaxed`` to ``Variable`` and ``Variables`` for LP relaxation of integer/binary variables. Supports partial relaxation via filtered views (e.g. ``m.variables.integers.relax()``). Semi-continuous variables raise ``NotImplementedError``. Version 0.6.5 diff --git a/test/test_fix.py b/test/test_fix_relax.py similarity index 100% rename from test/test_fix.py rename to test/test_fix_relax.py From 79d7b3f12ce79344446de2a9327a7797c54d1129 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:05:55 +0100 Subject: [PATCH 3/9] test: Add test that relaxing all variables converts MILP to LP Co-Authored-By: Claude Opus 4.6 (1M context) --- test/test_fix_relax.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/test_fix_relax.py b/test/test_fix_relax.py index 6e27a612..57e9094e 100644 --- a/test/test_fix_relax.py +++ b/test/test_fix_relax.py @@ -360,6 +360,14 @@ def test_relax_with_semi_continuous_raises(self) -> None: with pytest.raises(NotImplementedError, match="semi-continuous"): m.variables.relax() + def test_relax_all_converts_milp_to_lp(self, model_with_solution: Model) -> None: + m = model_with_solution + assert m.type == "MILP" + m.variables.relax() + assert m.type == "LP" + m.variables.unrelax() + assert m.type == "MILP" + class TestRemoveVariablesCleansUpFix: def test_remove_fixed_variable(self, model_with_solution: Model) -> None: From 2c7d71e888e73b3e021aeeeea60e473f62fbd804 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:08:40 +0100 Subject: [PATCH 4/9] fix notebook --- examples/manipulating-models.ipynb | 634 ++++++++++++++++++++++++++--- 1 file changed, 581 insertions(+), 53 deletions(-) diff --git a/examples/manipulating-models.ipynb b/examples/manipulating-models.ipynb index ef8dc2b4..1af1b2e9 100644 --- a/examples/manipulating-models.ipynb +++ b/examples/manipulating-models.ipynb @@ -17,8 +17,8 @@ "id": "16a41836", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.649489Z", - "start_time": "2026-03-18T08:06:55.646926Z" + "end_time": "2026-03-25T21:08:30.866952Z", + "start_time": "2026-03-25T21:08:30.864425Z" } }, "source": [ @@ -35,8 +35,8 @@ "id": "8f4d182f", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.763153Z", - "start_time": "2026-03-18T08:06:55.660972Z" + "end_time": "2026-03-25T21:08:31.102484Z", + "start_time": "2026-03-25T21:08:30.872111Z" } }, "source": [ @@ -82,8 +82,8 @@ "id": "f7db57f8", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.770316Z", - "start_time": "2026-03-18T08:06:55.766559Z" + "end_time": "2026-03-25T21:08:31.121880Z", + "start_time": "2026-03-25T21:08:31.117827Z" } }, "source": [ @@ -108,8 +108,8 @@ "id": "c37add87", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.831355Z", - "start_time": "2026-03-18T08:06:55.774853Z" + "end_time": "2026-03-25T21:08:31.248327Z", + "start_time": "2026-03-25T21:08:31.128416Z" } }, "source": [ @@ -125,8 +125,8 @@ "id": "b5be8d00", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.843937Z", - "start_time": "2026-03-18T08:06:55.840099Z" + "end_time": "2026-03-25T21:08:31.263353Z", + "start_time": "2026-03-25T21:08:31.255578Z" } }, "source": [ @@ -150,8 +150,8 @@ "id": "451aba93", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.856733Z", - "start_time": "2026-03-18T08:06:55.853780Z" + "end_time": "2026-03-25T21:08:31.283220Z", + "start_time": "2026-03-25T21:08:31.278211Z" } }, "source": [ @@ -165,8 +165,8 @@ "id": "e25f26a1", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.919477Z", - "start_time": "2026-03-18T08:06:55.862247Z" + "end_time": "2026-03-25T21:08:31.446266Z", + "start_time": "2026-03-25T21:08:31.296776Z" } }, "source": [ @@ -202,8 +202,8 @@ "id": "18d1bf4b", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.935987Z", - "start_time": "2026-03-18T08:06:55.927123Z" + "end_time": "2026-03-25T21:08:31.481137Z", + "start_time": "2026-03-25T21:08:31.456974Z" } }, "source": [ @@ -228,8 +228,8 @@ "id": "e4d34142", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.992339Z", - "start_time": "2026-03-18T08:06:55.939065Z" + "end_time": "2026-03-25T21:08:31.651430Z", + "start_time": "2026-03-25T21:08:31.495604Z" } }, "source": [ @@ -255,8 +255,8 @@ "id": "f8e81d20", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.008559Z", - "start_time": "2026-03-18T08:06:56.000605Z" + "end_time": "2026-03-25T21:08:31.689607Z", + "start_time": "2026-03-25T21:08:31.670331Z" } }, "source": [ @@ -290,8 +290,8 @@ "id": "9b73250d", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.063782Z", - "start_time": "2026-03-18T08:06:56.010905Z" + "end_time": "2026-03-25T21:08:31.848635Z", + "start_time": "2026-03-25T21:08:31.696248Z" } }, "source": [ @@ -319,8 +319,8 @@ "id": "44689b5b", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.078777Z", - "start_time": "2026-03-18T08:06:56.071457Z" + "end_time": "2026-03-25T21:08:31.878465Z", + "start_time": "2026-03-25T21:08:31.856151Z" } }, "source": [ @@ -334,8 +334,8 @@ "id": "2144af8e", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.133250Z", - "start_time": "2026-03-18T08:06:56.081080Z" + "end_time": "2026-03-25T21:08:32.031063Z", + "start_time": "2026-03-25T21:08:31.886472Z" } }, "source": [ @@ -359,8 +359,8 @@ "id": "85cbd60b", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.144542Z", - "start_time": "2026-03-18T08:06:56.141553Z" + "end_time": "2026-03-25T21:08:32.046132Z", + "start_time": "2026-03-25T21:08:32.039654Z" } }, "source": [ @@ -380,8 +380,8 @@ "id": "ske7l8391kl", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.202929Z", - "start_time": "2026-03-18T08:06:56.151649Z" + "end_time": "2026-03-25T21:08:32.192134Z", + "start_time": "2026-03-25T21:08:32.073588Z" } }, "source": "z = m.add_variables(binary=True, coords=[time], name=\"z\")\n\n# x can only exceed 5 when z is active: x <= 5 + 100 * z\nm.add_constraints(x <= 5 + 100 * z, name=\"capacity\")\n\n# Penalize activation of z in the objective\nm.objective = x + 3 * y + 10 * z\n\nm.solve(solver_name=\"highs\")", @@ -391,7 +391,7 @@ "output_type": "stream", "text": [ "Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms\n", - "MIP linopy-problem-a7gkxoqa has 30 rows; 30 cols; 60 nonzeros; 10 integer variables (10 binary)\n", + "MIP linopy-problem-wrw0rbro has 30 rows; 30 cols; 60 nonzeros; 10 integer variables (10 binary)\n", "Coefficient ranges:\n", " Matrix [1e+00, 1e+02]\n", " Cost [1e+00, 1e+01]\n", @@ -422,18 +422,18 @@ " 1 0 1 100.00% 197.5416667 197.5416667 0.00% 5 5 0 17 0.0s\n", "\n", "Solving report\n", - " Model linopy-problem-a7gkxoqa\n", + " Model linopy-problem-wrw0rbro\n", " Status Optimal\n", " Primal bound 197.541666667\n", " Dual bound 197.541666667\n", " Gap 0% (tolerance: 0.01%)\n", - " P-D integral 0.000945765823549\n", + " P-D integral 0.0020379549257\n", " Solution status feasible\n", " 197.541666667 (objective)\n", " 0 (bound viol.)\n", " 0 (int. viol.)\n", " 0 (row viol.)\n", - " Timing 0.01\n", + " Timing 0.02\n", " Max sub-MIP depth 1\n", " Nodes 1\n", " Repair LPs 0\n", @@ -449,7 +449,7 @@ "('ok', 'optimal')" ] }, - "execution_count": 35, + "execution_count": 51, "metadata": {}, "output_type": "execute_result" } @@ -467,42 +467,570 @@ "id": "xtyyswns2we", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.254283Z", - "start_time": "2026-03-18T08:06:56.218399Z" + "end_time": "2026-03-25T21:08:32.280876Z", + "start_time": "2026-03-25T21:08:32.208754Z" } }, "source": "m.variables.binaries.fix()\nm.variables.binaries.relax()\nm.solve(solver_name=\"highs\")\n\n# Dual values are now available on the constraints\nm.constraints[\"con1\"].dual", - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms\n", + "LP linopy-problem-4uxt4npx has 40 rows; 30 cols; 70 nonzeros\n", + "Coefficient ranges:\n", + " Matrix [1e+00, 1e+02]\n", + " Cost [1e+00, 1e+01]\n", + " Bound [1e+00, 1e+01]\n", + " RHS [1e+00, 7e+01]\n", + "Presolving model\n", + "17 rows, 14 cols, 27 nonzeros 0s\n", + "6 rows, 8 cols, 12 nonzeros 0s\n", + "Presolve reductions: rows 6(-34); columns 8(-22); nonzeros 12(-58) \n", + "Solving the presolved LP\n", + "Using EKK dual simplex solver - serial\n", + " Iteration Objective Infeasibilities num(sum)\n", + " 0 1.4512504460e+02 Pr: 6(180) 0s\n", + " 4 1.9754166667e+02 Pr: 0(0) 0s\n", + "\n", + "Performed postsolve\n", + "Solving the original LP from the solution after postsolve\n", + "\n", + "Model name : linopy-problem-4uxt4npx\n", + "Model status : Optimal\n", + "Simplex iterations: 4\n", + "Objective value : 1.9754166667e+02\n", + "P-D objective error : 7.1756893155e-17\n", + "HiGHS run time : 0.00\n" + ] + }, + { + "data": { + "text/plain": [ + " Size: 80B\n", + "array([-0. , -0. , -0. , 0.33333333, 0.33333333,\n", + " 0.375 , 0.375 , 0.375 , 0.375 , 0.375 ])\n", + "Coordinates:\n", + " * time (time) int64 80B 0 1 2 3 4 5 6 7 8 9" + ], + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.DataArray 'dual' (time: 10)> Size: 80B\n",
+       "array([-0.        , -0.        , -0.        ,  0.33333333,  0.33333333,\n",
+       "        0.375     ,  0.375     ,  0.375     ,  0.375     ,  0.375     ])\n",
+       "Coordinates:\n",
+       "  * time     (time) int64 80B 0 1 2 3 4 5 6 7 8 9
" + ] + }, + "execution_count": 52, + "metadata": {}, + "output_type": "execute_result" + } + ], "execution_count": null }, { "cell_type": "markdown", "id": "mnmsgvr40hq", "metadata": {}, - "source": "Calling `unfix()` on all variables removes the fix constraints and restores the integrality of `z`." + "source": "Calling `unfix()` on all variables removes the fix constraints and `unrelax()` restores the integrality of `z`." }, { "cell_type": "code", "id": "1b6uoag2xkf", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.264976Z", - "start_time": "2026-03-18T08:06:56.262008Z" + "end_time": "2026-03-25T21:08:32.292504Z", + "start_time": "2026-03-25T21:08:32.288552Z" } }, - "source": "m.variables.unfix()\n\n# z is binary again\nm.variables[\"z\"].attrs[\"binary\"]", - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 37, - "metadata": {}, - "output_type": "execute_result" - } + "source": [ + "m.variables.unfix()\n", + "m.variables.unrelax()\n", + "\n", + "# z is binary again\n", + "m.variables[\"z\"].attrs[\"binary\"]" ], + "outputs": [], "execution_count": null } ], From 63523853f22d7f5ca7db19f49944264ebe7137e3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:17:03 +0100 Subject: [PATCH 5/9] chore: Strip notebook output metadata Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/manipulating-models.ipynb | 919 ++++------------------------- 1 file changed, 130 insertions(+), 789 deletions(-) diff --git a/examples/manipulating-models.ipynb b/examples/manipulating-models.ipynb index 1af1b2e9..81106ab3 100644 --- a/examples/manipulating-models.ipynb +++ b/examples/manipulating-models.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "37a85c22", + "id": "0", "metadata": {}, "source": [ "# Modifying Models\n", @@ -14,31 +14,23 @@ }, { "cell_type": "code", - "id": "16a41836", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:30.866952Z", - "start_time": "2026-03-25T21:08:30.864425Z" - } - }, + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], "source": [ "import pandas as pd\n", "import xarray as xr\n", "\n", "import linopy" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", - "id": "8f4d182f", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.102484Z", - "start_time": "2026-03-25T21:08:30.872111Z" - } - }, + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], "source": [ "m = linopy.Model()\n", "time = pd.Index(range(10), name=\"time\")\n", @@ -61,13 +53,11 @@ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "d3f4b966", + "id": "3", "metadata": {}, "source": [ "The figure above shows the optimal values of `x(t)` and `y(t)`. \n", @@ -79,22 +69,17 @@ }, { "cell_type": "code", - "id": "f7db57f8", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.121880Z", - "start_time": "2026-03-25T21:08:31.117827Z" - } - }, + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], "source": [ "x.lower = 1" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "66b8be86", + "id": "5", "metadata": {}, "source": [ ".. note::\n", @@ -105,39 +90,29 @@ }, { "cell_type": "code", - "id": "c37add87", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.248327Z", - "start_time": "2026-03-25T21:08:31.128416Z" - } - }, + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", - "id": "b5be8d00", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.263353Z", - "start_time": "2026-03-25T21:08:31.255578Z" - } - }, + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], "source": [ "sol" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "d35e3309", + "id": "8", "metadata": {}, "source": [ "We see that the new lower bound of x is binding across all time steps.\n", @@ -147,39 +122,29 @@ }, { "cell_type": "code", - "id": "451aba93", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.283220Z", - "start_time": "2026-03-25T21:08:31.278211Z" - } - }, + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], "source": [ "x.lower = xr.DataArray(range(10, 0, -1), coords=(time,))" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", - "id": "e25f26a1", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.446266Z", - "start_time": "2026-03-25T21:08:31.296776Z" - } - }, + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "4d991939", + "id": "11", "metadata": {}, "source": [ "You can manipulate the upper bound of a variable in the same way." @@ -187,7 +152,7 @@ }, { "cell_type": "markdown", - "id": "de29c28e", + "id": "12", "metadata": {}, "source": [ "## Varying Constraints\n", @@ -199,22 +164,17 @@ }, { "cell_type": "code", - "id": "18d1bf4b", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.481137Z", - "start_time": "2026-03-25T21:08:31.456974Z" - } - }, + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], "source": [ "con1.rhs = 8 * factor" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "5499b3b4", + "id": "14", "metadata": {}, "source": [ ".. note::\n", @@ -225,24 +185,19 @@ }, { "cell_type": "code", - "id": "e4d34142", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.651430Z", - "start_time": "2026-03-25T21:08:31.495604Z" - } - }, + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "bc683e13", + "id": "16", "metadata": {}, "source": [ "In contrast to previous figure, we now see that the optimal value of `y` does not reach values above 10 in the end. \n", @@ -252,22 +207,17 @@ }, { "cell_type": "code", - "id": "f8e81d20", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.689607Z", - "start_time": "2026-03-25T21:08:31.670331Z" - } - }, + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], "source": [ "con1.lhs = 3 * x + 8 * y" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "cc377d95", + "id": "18", "metadata": {}, "source": [ "**Note:**\n", @@ -279,7 +229,7 @@ }, { "cell_type": "markdown", - "id": "633d463b", + "id": "19", "metadata": {}, "source": [ "which leads to" @@ -287,24 +237,19 @@ }, { "cell_type": "code", - "id": "9b73250d", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.848635Z", - "start_time": "2026-03-25T21:08:31.696248Z" - } - }, + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "e509d5d7", + "id": "21", "metadata": {}, "source": [ "## Varying the objective \n", @@ -316,39 +261,29 @@ }, { "cell_type": "code", - "id": "44689b5b", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:31.878465Z", - "start_time": "2026-03-25T21:08:31.856151Z" - } - }, + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], "source": [ "m.objective = x + 3 * y" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", - "id": "2144af8e", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:32.031063Z", - "start_time": "2026-03-25T21:08:31.886472Z" - } - }, + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "f1faa095", + "id": "24", "metadata": {}, "source": [ "As a consequence, `y` stays at zero for all time steps." @@ -356,682 +291,88 @@ }, { "cell_type": "code", - "id": "85cbd60b", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:32.046132Z", - "start_time": "2026-03-25T21:08:32.039654Z" - } - }, + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], "source": [ "m.objective" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "5qohnezrozd", + "id": "26", "metadata": {}, - "source": "## Fixing Variables and Extracting MILP Duals\n\nA common workflow in mixed-integer programming is to solve the MILP, then fix the integer/binary variables to their optimal values and re-solve as an LP to obtain dual values (shadow prices).\n\nLet's extend our model with a binary variable `z` that activates an additional capacity constraint on `x`." + "source": [ + "## Fixing Variables and Extracting MILP Duals\n", + "\n", + "A common workflow in mixed-integer programming is to solve the MILP, then fix the integer/binary variables to their optimal values and re-solve as an LP to obtain dual values (shadow prices).\n", + "\n", + "Let's extend our model with a binary variable `z` that activates an additional capacity constraint on `x`." + ] }, { "cell_type": "code", - "id": "ske7l8391kl", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:32.192134Z", - "start_time": "2026-03-25T21:08:32.073588Z" - } - }, - "source": "z = m.add_variables(binary=True, coords=[time], name=\"z\")\n\n# x can only exceed 5 when z is active: x <= 5 + 100 * z\nm.add_constraints(x <= 5 + 100 * z, name=\"capacity\")\n\n# Penalize activation of z in the objective\nm.objective = x + 3 * y + 10 * z\n\nm.solve(solver_name=\"highs\")", - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms\n", - "MIP linopy-problem-wrw0rbro has 30 rows; 30 cols; 60 nonzeros; 10 integer variables (10 binary)\n", - "Coefficient ranges:\n", - " Matrix [1e+00, 1e+02]\n", - " Cost [1e+00, 1e+01]\n", - " Bound [1e+00, 1e+01]\n", - " RHS [3e+00, 7e+01]\n", - "Presolving model\n", - "20 rows, 19 cols, 32 nonzeros 0s\n", - "15 rows, 19 cols, 30 nonzeros 0s\n", - "Presolve reductions: rows 15(-15); columns 19(-11); nonzeros 30(-30) \n", - "\n", - "Solving MIP model with:\n", - " 15 rows\n", - " 19 cols (5 binary, 0 integer, 0 implied int., 14 continuous, 0 domain fixed)\n", - " 30 nonzeros\n", - "\n", - "Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;\n", - " I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;\n", - " S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;\n", - " Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero\n", - "\n", - " Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work \n", - "Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time\n", - "\n", - " 0 0 0 0.00% 105 inf inf 0 0 0 0 0.0s\n", - " S 0 0 0 0.00% 105 239 56.07% 0 0 0 0 0.0s\n", - " 0 0 0 0.00% 195.8333333 239 18.06% 0 0 0 11 0.0s\n", - " L 0 0 0 0.00% 197.5416667 197.5416667 0.00% 5 5 0 16 0.0s\n", - " 1 0 1 100.00% 197.5416667 197.5416667 0.00% 5 5 0 17 0.0s\n", - "\n", - "Solving report\n", - " Model linopy-problem-wrw0rbro\n", - " Status Optimal\n", - " Primal bound 197.541666667\n", - " Dual bound 197.541666667\n", - " Gap 0% (tolerance: 0.01%)\n", - " P-D integral 0.0020379549257\n", - " Solution status feasible\n", - " 197.541666667 (objective)\n", - " 0 (bound viol.)\n", - " 0 (int. viol.)\n", - " 0 (row viol.)\n", - " Timing 0.02\n", - " Max sub-MIP depth 1\n", - " Nodes 1\n", - " Repair LPs 0\n", - " LP iterations 17\n", - " 0 (strong br.)\n", - " 5 (separation)\n", - " 1 (heuristics)\n" - ] - }, - { - "data": { - "text/plain": [ - "('ok', 'optimal')" - ] - }, - "execution_count": 51, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": null + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "z = m.add_variables(binary=True, coords=[time], name=\"z\")\n", + "\n", + "# x can only exceed 5 when z is active: x <= 5 + 100 * z\n", + "m.add_constraints(x <= 5 + 100 * z, name=\"capacity\")\n", + "\n", + "# Penalize activation of z in the objective\n", + "m.objective = x + 3 * y + 10 * z\n", + "\n", + "m.solve(solver_name=\"highs\")" + ] }, { "cell_type": "markdown", - "id": "wrtc3hk1cal", + "id": "28", "metadata": {}, - "source": "Now fix the binary variable `z` to its optimal values and relax its integrality. This converts the model into an LP, which allows us to extract dual values." + "source": [ + "Now fix the binary variable `z` to its optimal values and relax its integrality. This converts the model into an LP, which allows us to extract dual values." + ] }, { "cell_type": "code", - "id": "xtyyswns2we", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:32.280876Z", - "start_time": "2026-03-25T21:08:32.208754Z" - } - }, - "source": "m.variables.binaries.fix()\nm.variables.binaries.relax()\nm.solve(solver_name=\"highs\")\n\n# Dual values are now available on the constraints\nm.constraints[\"con1\"].dual", - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms\n", - "LP linopy-problem-4uxt4npx has 40 rows; 30 cols; 70 nonzeros\n", - "Coefficient ranges:\n", - " Matrix [1e+00, 1e+02]\n", - " Cost [1e+00, 1e+01]\n", - " Bound [1e+00, 1e+01]\n", - " RHS [1e+00, 7e+01]\n", - "Presolving model\n", - "17 rows, 14 cols, 27 nonzeros 0s\n", - "6 rows, 8 cols, 12 nonzeros 0s\n", - "Presolve reductions: rows 6(-34); columns 8(-22); nonzeros 12(-58) \n", - "Solving the presolved LP\n", - "Using EKK dual simplex solver - serial\n", - " Iteration Objective Infeasibilities num(sum)\n", - " 0 1.4512504460e+02 Pr: 6(180) 0s\n", - " 4 1.9754166667e+02 Pr: 0(0) 0s\n", - "\n", - "Performed postsolve\n", - "Solving the original LP from the solution after postsolve\n", - "\n", - "Model name : linopy-problem-4uxt4npx\n", - "Model status : Optimal\n", - "Simplex iterations: 4\n", - "Objective value : 1.9754166667e+02\n", - "P-D objective error : 7.1756893155e-17\n", - "HiGHS run time : 0.00\n" - ] - }, - { - "data": { - "text/plain": [ - " Size: 80B\n", - "array([-0. , -0. , -0. , 0.33333333, 0.33333333,\n", - " 0.375 , 0.375 , 0.375 , 0.375 , 0.375 ])\n", - "Coordinates:\n", - " * time (time) int64 80B 0 1 2 3 4 5 6 7 8 9" - ], - "text/html": [ - "
\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
<xarray.DataArray 'dual' (time: 10)> Size: 80B\n",
-       "array([-0.        , -0.        , -0.        ,  0.33333333,  0.33333333,\n",
-       "        0.375     ,  0.375     ,  0.375     ,  0.375     ,  0.375     ])\n",
-       "Coordinates:\n",
-       "  * time     (time) int64 80B 0 1 2 3 4 5 6 7 8 9
" - ] - }, - "execution_count": 52, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": null + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "m.variables.binaries.fix()\n", + "m.variables.binaries.relax()\n", + "m.solve(solver_name=\"highs\")\n", + "\n", + "# Dual values are now available on the constraints\n", + "m.constraints[\"con1\"].dual" + ] }, { "cell_type": "markdown", - "id": "mnmsgvr40hq", + "id": "30", "metadata": {}, - "source": "Calling `unfix()` on all variables removes the fix constraints and `unrelax()` restores the integrality of `z`." + "source": [ + "Calling `unfix()` on all variables removes the fix constraints and `unrelax()` restores the integrality of `z`." + ] }, { "cell_type": "code", - "id": "1b6uoag2xkf", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-25T21:08:32.292504Z", - "start_time": "2026-03-25T21:08:32.288552Z" - } - }, + "execution_count": null, + "id": "31", + "metadata": {}, + "outputs": [], "source": [ "m.variables.unfix()\n", "m.variables.unrelax()\n", "\n", "# z is binary again\n", "m.variables[\"z\"].attrs[\"binary\"]" - ], - "outputs": [], - "execution_count": null + ] } ], "metadata": { From a01fa05243fd172b663c7f743075fc9da98fb47c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:40:20 +0100 Subject: [PATCH 6/9] fix: Address review feedback on relax/fix API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix docstring: overwrite default is True, not False - Cache attrs lookups in relax() to avoid redundant property calls - Guard fix() when no solution is available (clear ValueError) - Decouple unfix() from unrelax() — they are independent operations - Use `self` instead of `1 * self` in add_constraints - Clean up unnecessary f-string prefixes - Add no-op docstring note for continuous variables in relax() - Add tests for fix-without-solution and independent relax/unfix Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/variables.py | 47 +++++++++++++++++++++++++----------------- test/test_fix_relax.py | 36 +++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/linopy/variables.py b/linopy/variables.py index 7a348697..13e07024 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -1309,20 +1309,26 @@ def relax(self) -> None: For binary variables, the existing [0, 1] bounds are preserved, which is the correct LP relaxation. For integer variables, the existing bounds are preserved as-is. + + If the variable is already continuous, this method is a no-op. """ - if self.attrs.get("semi_continuous"): + attrs = self.attrs + if attrs.get("semi_continuous"): msg = ( f"Relaxation of semi-continuous variable '{self.name}' is not " - f"supported. The LP relaxation of a semi-continuous variable " - f"requires changing bounds, which is not handled by relax()." + "supported. The LP relaxation of a semi-continuous variable " + "requires changing bounds, which is not handled by relax()." ) raise NotImplementedError(msg) - if self.attrs.get("binary") or self.attrs.get("integer"): - original_type = "binary" if self.attrs.get("binary") else "integer" - self.model._relaxed_registry[self.name] = original_type - self.attrs["binary"] = False - self.attrs["integer"] = False + is_binary = attrs.get("binary") + is_integer = attrs.get("integer") + if is_binary or is_integer: + self.model._relaxed_registry[self.name] = ( + "binary" if is_binary else "integer" + ) + attrs["binary"] = False + attrs["integer"] = False def unrelax(self) -> None: """ @@ -1366,11 +1372,19 @@ def fix( Integer and binary variables are always rounded to 0 decimal places. Default is 8. overwrite : bool, optional - If True, overwrite an existing fix constraint for this variable. - If False (default), raise an error if the variable is already fixed. + If True (default), overwrite an existing fix constraint for this + variable. If False, raise an error if the variable is already fixed. """ if value is None: - value = self.solution + try: + value = self.solution + except AttributeError: + msg = ( + f"Cannot fix variable '{self.name}': no solution value " + "available. Solve the model first or provide an explicit " + "value." + ) + raise ValueError(msg) from None value = as_dataarray(value).broadcast_like(self.labels) @@ -1382,7 +1396,7 @@ def fix( if (value < self.lower).any() or (value > self.upper).any(): msg = ( f"Fix values for variable '{self.name}' are outside the " - f"variable bounds." + "variable bounds." ) raise ValueError(msg) @@ -1392,26 +1406,21 @@ def fix( if not overwrite: msg = ( f"Variable '{self.name}' is already fixed. Use " - f"overwrite=True to replace the existing fix constraint." + "overwrite=True to replace the existing fix constraint." ) raise ValueError(msg) self.model.remove_constraints(constraint_name) - self.model.add_constraints(1 * self, "=", value, name=constraint_name) + self.model.add_constraints(self, "=", value, name=constraint_name) def unfix(self) -> None: """ Remove the fix constraint for this variable. - - If the variable was previously relaxed via :meth:`relax`, the original - integrality type is also restored. """ constraint_name = f"{FIX_CONSTRAINT_PREFIX}{self.name}" if constraint_name in self.model.constraints: self.model.remove_constraints(constraint_name) - self.unrelax() - @property def fixed(self) -> bool: """ diff --git a/test/test_fix_relax.py b/test/test_fix_relax.py index 57e9094e..b40a2d31 100644 --- a/test/test_fix_relax.py +++ b/test/test_fix_relax.py @@ -145,6 +145,25 @@ def test_unfix_noop_if_not_fixed(self, model_with_solution: Model) -> None: assert not m.variables["x"].fixed +class TestFixNoSolution: + def test_fix_without_solution_raises(self) -> None: + m = Model() + m.add_variables(lower=0, upper=10, name="x") + with pytest.raises(ValueError, match="no solution value available"): + m.variables["x"].fix() + + +class TestUnfixDoesNotUnrelaxIndependently: + def test_unfix_on_relaxed_only_variable(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + # unfix should be a no-op — no fix constraint exists + m.variables["z"].unfix() + # relaxation should still be in effect + assert m.variables["z"].relaxed + assert not m.variables["z"].attrs["binary"] + + class TestFixThenRelax: """Test the combined fix() + relax() workflow (fix first, then relax).""" @@ -156,13 +175,18 @@ def test_fix_then_relax_binary(self, model_with_solution: Model) -> None: assert m.variables["z"].fixed assert m.variables["z"].relaxed - def test_unfix_restores_relaxed_binary(self, model_with_solution: Model) -> None: + def test_unfix_does_not_unrelax(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["z"].fix() m.variables["z"].relax() m.variables["z"].unfix() - assert m.variables["z"].attrs["binary"] assert not m.variables["z"].fixed + # relaxation is independent — still in effect + assert m.variables["z"].relaxed + assert not m.variables["z"].attrs["binary"] + # explicit unrelax needed + m.variables["z"].unrelax() + assert m.variables["z"].attrs["binary"] assert not m.variables["z"].relaxed def test_fix_then_relax_integer(self, model_with_solution: Model) -> None: @@ -173,14 +197,14 @@ def test_fix_then_relax_integer(self, model_with_solution: Model) -> None: assert m.variables["w"].fixed assert m.variables["w"].relaxed - def test_unfix_restores_relaxed_integer(self, model_with_solution: Model) -> None: + def test_unfix_does_not_unrelax_integer(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["w"].fix() m.variables["w"].relax() m.variables["w"].unfix() - assert m.variables["w"].attrs["integer"] assert not m.variables["w"].fixed - assert not m.variables["w"].relaxed + assert m.variables["w"].relaxed + assert not m.variables["w"].attrs["integer"] class TestVariableFixed: @@ -241,6 +265,8 @@ def test_fix_then_relax_integers(self, model_with_solution: Model) -> None: assert not m.variables["w"].attrs["integer"] assert m.variables["w"].fixed m.variables["w"].unfix() + assert not m.variables["w"].attrs["integer"] # still relaxed + m.variables["w"].unrelax() assert m.variables["w"].attrs["integer"] From 91f21c12933b7516888972f3eaae65c47f2e2e59 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:42:29 +0100 Subject: [PATCH 7/9] refactor: Simplify relax/unrelax with lookup pattern Store the attr name ("binary"/"integer") directly in the registry and use it as the key for both clearing and restoring, eliminating conditional branches. Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/variables.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/linopy/variables.py b/linopy/variables.py index 13e07024..d70fd393 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -1321,14 +1321,11 @@ def relax(self) -> None: ) raise NotImplementedError(msg) - is_binary = attrs.get("binary") - is_integer = attrs.get("integer") - if is_binary or is_integer: - self.model._relaxed_registry[self.name] = ( - "binary" if is_binary else "integer" - ) - attrs["binary"] = False - attrs["integer"] = False + for attr in ("binary", "integer"): + if attrs.get(attr): + self.model._relaxed_registry[self.name] = attr + attrs[attr] = False + return def unrelax(self) -> None: """ @@ -1338,12 +1335,9 @@ def unrelax(self) -> None: previously relaxed, this is a no-op. """ registry = self.model._relaxed_registry - if self.name in registry: - original_type = registry.pop(self.name) - if original_type == "binary": - self.attrs["binary"] = True - elif original_type == "integer": - self.attrs["integer"] = True + original_type = registry.pop(self.name, None) + if original_type is not None: + self.attrs[original_type] = True @property def relaxed(self) -> bool: From 48d3337f8b48fc5e187ee5ad0c68efdf9d7b1669 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:45:21 +0100 Subject: [PATCH 8/9] refactor: Return Variables containers from .fixed and .relaxed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistent with .binaries, .integers, .continuous — enables chaining like m.variables.relaxed.unrelax() and m.variables.fixed.unfix(). Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/variables.py | 18 ++++++++++++------ test/test_fix_relax.py | 14 ++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/linopy/variables.py b/linopy/variables.py index d70fd393..dc343840 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -1733,11 +1733,14 @@ def unfix(self) -> None: var.unfix() @property - def fixed(self) -> dict[str, bool]: + def fixed(self) -> Variables: """ - Return a dict mapping variable names to whether they are fixed. + Get all currently fixed variables. """ - return {name: var.fixed for name, var in self.items()} + return self.__class__( + {name: self.data[name] for name in self if self[name].fixed}, + self.model, + ) def relax(self) -> None: """ @@ -1760,11 +1763,14 @@ def unrelax(self) -> None: var.unrelax() @property - def relaxed(self) -> dict[str, bool]: + def relaxed(self) -> Variables: """ - Return a dict mapping variable names to whether they are relaxed. + Get all currently relaxed variables. """ - return {name: var.relaxed for name, var in self.items()} + return self.__class__( + {name: self.data[name] for name in self if self[name].relaxed}, + self.model, + ) @property def solution(self) -> Dataset: diff --git a/test/test_fix_relax.py b/test/test_fix_relax.py index b40a2d31..cc91a5cb 100644 --- a/test/test_fix_relax.py +++ b/test/test_fix_relax.py @@ -250,13 +250,12 @@ def test_fix_binaries_only(self, model_with_solution: Model) -> None: assert m.variables["z"].fixed assert not m.variables["x"].fixed - def test_fixed_returns_dict(self, model_with_solution: Model) -> None: + def test_fixed_returns_container(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=5.0) result = m.variables.fixed - assert isinstance(result, dict) - assert result["x"] is True - assert result["y"] is False + assert "x" in result + assert "y" not in result def test_fix_then_relax_integers(self, model_with_solution: Model) -> None: m = model_with_solution @@ -371,13 +370,12 @@ def test_relax_binaries_only(self, model_with_solution: Model) -> None: assert m.variables["w"].attrs["integer"] assert not m.variables["w"].relaxed - def test_relaxed_returns_dict(self, model_with_solution: Model) -> None: + def test_relaxed_returns_container(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["z"].relax() result = m.variables.relaxed - assert isinstance(result, dict) - assert result["z"] is True - assert result["x"] is False + assert "z" in result + assert "x" not in result def test_relax_with_semi_continuous_raises(self) -> None: m = Model() From 47c0abf7b6da67ec821246bc957c0720652a4ede Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:54:39 +0100 Subject: [PATCH 9/9] test: Add tests for view chaining and double-relax idempotency - m.variables.relaxed.unrelax() chain - m.variables.fixed.unfix() chain - Double relax preserves original type in registry Co-Authored-By: Claude Opus 4.6 (1M context) --- test/test_fix_relax.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/test_fix_relax.py b/test/test_fix_relax.py index cc91a5cb..2b968a30 100644 --- a/test/test_fix_relax.py +++ b/test/test_fix_relax.py @@ -384,6 +384,31 @@ def test_relax_with_semi_continuous_raises(self) -> None: with pytest.raises(NotImplementedError, match="semi-continuous"): m.variables.relax() + def test_relaxed_view_unrelax(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.relax() + assert len(m.variables.relaxed) == 2 + m.variables.relaxed.unrelax() + assert len(m.variables.relaxed) == 0 + assert m.variables["z"].attrs["binary"] + assert m.variables["w"].attrs["integer"] + + def test_fixed_view_unfix(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["x"].fix(value=5.0) + m.variables["z"].fix() + assert len(m.variables.fixed) == 2 + m.variables.fixed.unfix() + assert len(m.variables.fixed) == 0 + + def test_double_relax_is_idempotent(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + m.variables["z"].relax() + assert m._relaxed_registry["z"] == "binary" + m.variables["z"].unrelax() + assert m.variables["z"].attrs["binary"] + def test_relax_all_converts_milp_to_lp(self, model_with_solution: Model) -> None: m = model_with_solution assert m.type == "MILP"