From b13e1f077b529be7da2360e68c411e9eaaf123a1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:42:53 +0100 Subject: [PATCH 1/9] feat: Add fix(), unfix(), and fixed to Variable and Variables Add methods to fix variables to values via equality constraints, with: - Automatic rounding (0 decimals for int/binary, configurable for continuous) - Clipping to variable bounds to prevent infeasibility - Optional integrality relaxation (relax=True) for MILP dual extraction - Relaxed state tracked in Model._relaxed_registry and restored by unfix() - Cleanup on variable removal Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/model.py | 12 +++ linopy/variables.py | 140 +++++++++++++++++++++++++++ test/test_fix.py | 223 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 test/test_fix.py diff --git a/linopy/model.py b/linopy/model.py index 06e814c6..5d792597 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -154,6 +154,7 @@ class Model: "_force_dim_names", "_auto_mask", "_solver_dir", + "_relaxed_registry", "solver_model", "solver_name", "matrices", @@ -210,6 +211,7 @@ def __init__( self._chunk: T_Chunks = chunk self._force_dim_names: bool = bool(force_dim_names) self._auto_mask: bool = bool(auto_mask) + self._relaxed_registry: dict[str, str] = {} self._solver_dir: Path = Path( gettempdir() if solver_dir is None else solver_dir ) @@ -937,6 +939,16 @@ def remove_variables(self, name: str) -> None: ------- None. """ + from linopy.variables import FIX_CONSTRAINT_PREFIX + + # Clean up fix constraint if present + fix_name = f"{FIX_CONSTRAINT_PREFIX}{name}" + if fix_name in self.constraints: + self.constraints.remove(fix_name) + + # Clean up relaxed registry if present + self._relaxed_registry.pop(name, None) + labels = self.variables[name].labels self.variables.remove(name) diff --git a/linopy/variables.py b/linopy/variables.py index 4332a037..acc1d639 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -79,6 +79,8 @@ FILL_VALUE = {"labels": -1, "lower": np.nan, "upper": np.nan} +FIX_CONSTRAINT_PREFIX = "__fix__" + def varwrap( method: Callable, *default_args: Any, **new_default_kwargs: Any @@ -1289,6 +1291,89 @@ def equals(self, other: Variable) -> bool: iterate_slices = iterate_slices + def fix( + self, + value: ConstantLike | None = None, + decimals: int = 8, + relax: bool = False, + ) -> None: + """ + Fix the variable to a given value by adding an equality constraint. + + If no value is given, the current solution value is used. + + Parameters + ---------- + value : float/array_like, optional + Value to fix the variable to. If None, the current solution is used. + decimals : int, optional + 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. + """ + if value is None: + value = self.solution + + value = DataArray(value).broadcast_like(self.labels) + + # Round: integers/binaries to 0 decimals, continuous to `decimals` + if self.attrs.get("integer") or self.attrs.get("binary"): + value = value.round(0) + else: + value = value.round(decimals) + + # Clip to bounds + value = value.clip(min=self.lower, max=self.upper) + + constraint_name = f"{FIX_CONSTRAINT_PREFIX}{self.name}" + + # Remove existing fix constraint if present + if constraint_name in self.model.constraints: + self.model.remove_constraints(constraint_name) + + # Add equality constraint: 1 * var == value + self.model.add_constraints(1 * self, "=", value, name=constraint_name) + + # Handle integrality relaxation + 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. + """ + constraint_name = f"{FIX_CONSTRAINT_PREFIX}{self.name}" + if constraint_name in self.model.constraints: + self.model.remove_constraints(constraint_name) + + # Restore integrality if it was relaxed + 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 fixed(self) -> bool: + """ + Return whether the variable is currently fixed. + """ + constraint_name = f"{FIX_CONSTRAINT_PREFIX}{self.name}" + return constraint_name in self.model.constraints + class AtIndexer: __slots__ = ("object",) @@ -1563,6 +1648,61 @@ def sos(self) -> Variables: self.model, ) + def fix( + self, + value: int | float | None = None, + decimals: int = 8, + relax: bool = False, + ) -> None: + """ + Fix all variables in this container to their solution or a scalar value. + + Delegates to each variable's ``fix()`` method. See + :meth:`Variable.fix` for details. + + Parameters + ---------- + value : int/float, optional + Scalar value to fix all variables to. Only scalar values are + accepted to avoid shape mismatches across differently-shaped + 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. + + 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) + + def unfix(self) -> None: + """ + Unfix all variables in this container. + + Delegates to each variable's ``unfix()`` method. + """ + for var in self.data.values(): + var.unfix() + + @property + def fixed(self) -> dict[str, bool]: + """ + Return a dict mapping variable names to whether they are fixed. + """ + return {name: var.fixed for name, var in self.items()} + @property def solution(self) -> Dataset: """ diff --git a/test/test_fix.py b/test/test_fix.py new file mode 100644 index 00000000..d07fa378 --- /dev/null +++ b/test/test_fix.py @@ -0,0 +1,223 @@ +"""Tests for Variable.fix(), Variable.unfix(), and Variable.fixed.""" + +import numpy as np +import pandas as pd +import pytest +from xarray import DataArray + +from linopy import Model +from linopy.variables import FIX_CONSTRAINT_PREFIX + + +@pytest.fixture +def model_with_solution(): + """Create a simple model and simulate a solution.""" + m = Model() + x = m.add_variables(lower=0, upper=10, name="x") + y = m.add_variables(lower=-5, upper=5, coords=[pd.Index([0, 1])], name="y") + z = m.add_variables(binary=True, name="z") + w = m.add_variables(lower=0, upper=100, integer=True, name="w") + + # Simulate solution values + x.solution = 3.14159265 + y.solution = DataArray([2.71828, -1.41421], dims="dim_0") + z.solution = 0.9999999997 + w.solution = 41.9999999998 + m._status = "ok" + m._termination_condition = "optimal" + + return m + + +class TestVariableFix: + def test_fix_uses_solution(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix() + assert m.variables["x"].fixed + assert f"{FIX_CONSTRAINT_PREFIX}x" in m.constraints + + def test_fix_with_explicit_value(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(value=5.0) + assert m.variables["x"].fixed + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] + np.testing.assert_almost_equal(con.rhs.item(), 5.0) + + def test_fix_rounds_binary(self, model_with_solution): + m = model_with_solution + m.variables["z"].fix() + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}z"] + # 0.9999999997 should be rounded to 1.0 + np.testing.assert_equal(con.rhs.item(), 1.0) + + def test_fix_rounds_integer(self, model_with_solution): + m = model_with_solution + m.variables["w"].fix() + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}w"] + # 41.9999999998 should be rounded to 42.0 + np.testing.assert_equal(con.rhs.item(), 42.0) + + def test_fix_rounds_continuous(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(decimals=4) + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] + np.testing.assert_almost_equal(con.rhs.item(), 3.1416, decimal=4) + + def test_fix_clips_to_upper_bound(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(value=10.0000001) + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] + np.testing.assert_almost_equal(con.rhs.item(), 10.0) + + def test_fix_clips_to_lower_bound(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(value=-0.0000001) + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] + np.testing.assert_almost_equal(con.rhs.item(), 0.0) + + def test_fix_overwrites_existing(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(value=3.0) + m.variables["x"].fix(value=5.0) + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] + np.testing.assert_almost_equal(con.rhs.item(), 5.0) + + def test_fix_multidimensional(self, model_with_solution): + m = model_with_solution + m.variables["y"].fix() + assert m.variables["y"].fixed + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}y"] + np.testing.assert_array_almost_equal(con.rhs.values, [2.71828, -1.41421]) + + +class TestVariableUnfix: + def test_unfix_removes_constraint(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(value=5.0) + m.variables["x"].unfix() + assert not m.variables["x"].fixed + assert f"{FIX_CONSTRAINT_PREFIX}x" not in m.constraints + + def test_unfix_noop_if_not_fixed(self, model_with_solution): + m = model_with_solution + # Should not raise + m.variables["x"].unfix() + assert not m.variables["x"].fixed + + +class TestVariableFixRelax: + def test_fix_relax_binary(self, model_with_solution): + 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" + + def test_fix_relax_integer(self, model_with_solution): + 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" + + def test_unfix_restores_binary(self, model_with_solution): + m = model_with_solution + m.variables["z"].fix(relax=True) + m.variables["z"].unfix() + assert m.variables["z"].attrs["binary"] + assert "z" not in m._relaxed_registry + + def test_unfix_restores_integer(self, model_with_solution): + 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 + + def test_fix_relax_continuous_noop(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(relax=True) + # Continuous variable should not be in registry + assert "x" not in m._relaxed_registry + + +class TestVariableFixed: + def test_fixed_false_initially(self, model_with_solution): + m = model_with_solution + assert not m.variables["x"].fixed + + def test_fixed_true_after_fix(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(value=5.0) + assert m.variables["x"].fixed + + def test_fixed_false_after_unfix(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(value=5.0) + m.variables["x"].unfix() + assert not m.variables["x"].fixed + + +class TestVariablesContainerFixUnfix: + def test_fix_all(self, model_with_solution): + m = model_with_solution + m.variables.fix() + for name in m.variables: + assert m.variables[name].fixed + + def test_unfix_all(self, model_with_solution): + m = model_with_solution + m.variables.fix() + m.variables.unfix() + for name in m.variables: + assert not m.variables[name].fixed + + def test_fix_integers_only(self, model_with_solution): + m = model_with_solution + m.variables.integers.fix() + assert m.variables["w"].fixed + assert not m.variables["x"].fixed + + def test_fix_binaries_only(self, model_with_solution): + m = model_with_solution + m.variables.binaries.fix() + assert m.variables["z"].fixed + assert not m.variables["x"].fixed + + def test_fixed_returns_dict(self, model_with_solution): + 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 + + def test_fix_relax_integers(self, model_with_solution): + m = model_with_solution + m.variables.integers.fix(relax=True) + 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 + m.variables["w"].unfix() + assert m.variables["w"].attrs["integer"] + + +class TestRemoveVariablesCleansUpFix: + def test_remove_fixed_variable(self, model_with_solution): + m = model_with_solution + m.variables["x"].fix(value=5.0) + m.remove_variables("x") + assert f"{FIX_CONSTRAINT_PREFIX}x" not in m.constraints + + def test_remove_relaxed_variable(self, model_with_solution): + m = model_with_solution + m.variables["z"].fix(relax=True) + m.remove_variables("z") + assert "z" not in m._relaxed_registry + assert f"{FIX_CONSTRAINT_PREFIX}z" not in m.constraints From ef734acadac2c13fea5a1a6b01c95687f76eacdb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:59:46 +0100 Subject: [PATCH 2/9] Add example to notebook covering fixing and dual extraction --- examples/manipulating-models.ipynb | 189 ++++++++++++++++++++++++++--- 1 file changed, 175 insertions(+), 14 deletions(-) diff --git a/examples/manipulating-models.ipynb b/examples/manipulating-models.ipynb index 1a35cd19..0bcd73fd 100644 --- a/examples/manipulating-models.ipynb +++ b/examples/manipulating-models.ipynb @@ -16,7 +16,12 @@ "cell_type": "code", "execution_count": null, "id": "16a41836", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:53.783005Z", + "start_time": "2026-03-18T07:56:52.758596Z" + } + }, "outputs": [], "source": [ "import pandas as pd\n", @@ -29,7 +34,12 @@ "cell_type": "code", "execution_count": null, "id": "8f4d182f", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.228848Z", + "start_time": "2026-03-18T07:56:53.785977Z" + } + }, "outputs": [], "source": [ "m = linopy.Model()\n", @@ -71,7 +81,12 @@ "cell_type": "code", "execution_count": null, "id": "f7db57f8", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.239585Z", + "start_time": "2026-03-18T07:56:54.235308Z" + } + }, "outputs": [], "source": [ "x.lower = 1" @@ -92,7 +107,12 @@ "cell_type": "code", "execution_count": null, "id": "c37add87", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.362737Z", + "start_time": "2026-03-18T07:56:54.242692Z" + } + }, "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", @@ -104,7 +124,12 @@ "cell_type": "code", "execution_count": null, "id": "b5be8d00", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.383514Z", + "start_time": "2026-03-18T07:56:54.378310Z" + } + }, "outputs": [], "source": [ "sol" @@ -124,7 +149,12 @@ "cell_type": "code", "execution_count": null, "id": "451aba93", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.393966Z", + "start_time": "2026-03-18T07:56:54.391194Z" + } + }, "outputs": [], "source": [ "x.lower = xr.DataArray(range(10, 0, -1), coords=(time,))" @@ -134,7 +164,12 @@ "cell_type": "code", "execution_count": null, "id": "e25f26a1", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.457480Z", + "start_time": "2026-03-18T07:56:54.397667Z" + } + }, "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", @@ -166,7 +201,12 @@ "cell_type": "code", "execution_count": null, "id": "18d1bf4b", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.474622Z", + "start_time": "2026-03-18T07:56:54.465516Z" + } + }, "outputs": [], "source": [ "con1.rhs = 8 * factor" @@ -187,7 +227,12 @@ "cell_type": "code", "execution_count": null, "id": "e4d34142", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.534732Z", + "start_time": "2026-03-18T07:56:54.477434Z" + } + }, "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", @@ -209,7 +254,12 @@ "cell_type": "code", "execution_count": null, "id": "f8e81d20", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.546171Z", + "start_time": "2026-03-18T07:56:54.538512Z" + } + }, "outputs": [], "source": [ "con1.lhs = 3 * x + 8 * y" @@ -239,7 +289,12 @@ "cell_type": "code", "execution_count": null, "id": "9b73250d", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.609576Z", + "start_time": "2026-03-18T07:56:54.550838Z" + } + }, "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", @@ -263,7 +318,12 @@ "cell_type": "code", "execution_count": null, "id": "44689b5b", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.624207Z", + "start_time": "2026-03-18T07:56:54.616784Z" + } + }, "outputs": [], "source": [ "m.objective = x + 3 * y" @@ -273,7 +333,12 @@ "cell_type": "code", "execution_count": null, "id": "2144af8e", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.680101Z", + "start_time": "2026-03-18T07:56:54.626956Z" + } + }, "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", @@ -293,11 +358,107 @@ "cell_type": "code", "execution_count": null, "id": "85cbd60b", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.688147Z", + "start_time": "2026-03-18T07:56:54.684196Z" + } + }, "outputs": [], "source": [ "m.objective" ] + }, + { + "cell_type": "markdown", + "id": "5qohnezrozd", + "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 build a small unit commitment example with binary commitment variables and continuous dispatch variables." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ske7l8391kl", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:58:45.050302Z", + "start_time": "2026-03-18T07:58:44.993758Z" + } + }, + "outputs": [], + "source": [ + "m2 = linopy.Model()\n", + "\n", + "generators = pd.Index([\"cheap\", \"mid\", \"expensive\"], name=\"generator\")\n", + "time = pd.Index(range(5), name=\"time\")\n", + "\n", + "# Binary commitment variables: is the generator on?\n", + "commit = m2.add_variables(binary=True, coords=[generators, time], name=\"commit\")\n", + "\n", + "# Continuous dispatch variables\n", + "dispatch = m2.add_variables(\n", + " lower=0, upper=100, coords=[generators, time], name=\"dispatch\"\n", + ")\n", + "\n", + "# Dispatch only when committed: dispatch <= 100 * commit\n", + "m2.add_constraints(dispatch - 100 * commit <= 0, name=\"dispatch_limit\")\n", + "\n", + "# Must meet demand\n", + "demand = xr.DataArray([50, 80, 120, 90, 60], coords=[time])\n", + "m2.add_constraints(dispatch.sum(\"generator\") >= demand, name=\"demand\")\n", + "\n", + "# Minimize cost: different marginal costs per generator + startup cost\n", + "costs = xr.DataArray([1, 3, 5], coords=[generators])\n", + "m2.add_objective((costs * dispatch).sum(\"generator\") + 10 * commit.sum())\n", + "\n", + "m2.solve(solver_name=\"highs\")" + ] + }, + { + "cell_type": "markdown", + "id": "wrtc3hk1cal", + "metadata": {}, + "source": "Now fix the binary commitment variables to their optimal values and relax their integrality. This converts the MILP into an LP, which allows us to extract dual values." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xtyyswns2we", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:58:04.525923Z", + "start_time": "2026-03-18T07:58:04.465378Z" + } + }, + "outputs": [], + "source": [ + "m2.variables.binaries.fix(relax=True)\n", + "m2.solve(solver_name=\"highs\")\n", + "\n", + "# Dual values on the demand constraint give the marginal cost of serving one more unit\n", + "m2.constraints[\"demand\"].dual" + ] + }, + { + "cell_type": "markdown", + "id": "mnmsgvr40hq", + "metadata": {}, + "source": "Finally, unfix all variables to restore the model to its original state, including the integrality of the binary variables." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b6uoag2xkf", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T07:56:54.869726Z", + "start_time": "2026-03-18T07:56:54.866595Z" + } + }, + "outputs": [], + "source": [ + "m2.variables.unfix()" + ] } ], "metadata": { From 464743a0a582d206f91696099917cd0d936b20a9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:07:22 +0100 Subject: [PATCH 3/9] Add example to notebook covering fixing and dual extraction --- examples/manipulating-models.ipynb | 822 ++++++++++++++++++++++++----- 1 file changed, 700 insertions(+), 122 deletions(-) diff --git a/examples/manipulating-models.ipynb b/examples/manipulating-models.ipynb index 0bcd73fd..6b0e2fad 100644 --- a/examples/manipulating-models.ipynb +++ b/examples/manipulating-models.ipynb @@ -14,33 +14,31 @@ }, { "cell_type": "code", - "execution_count": null, "id": "16a41836", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:53.783005Z", - "start_time": "2026-03-18T07:56:52.758596Z" + "end_time": "2026-03-18T08:06:55.649489Z", + "start_time": "2026-03-18T08:06:55.646926Z" } }, - "outputs": [], "source": [ "import pandas as pd\n", "import xarray as xr\n", "\n", "import linopy" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "id": "8f4d182f", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.228848Z", - "start_time": "2026-03-18T07:56:53.785977Z" + "end_time": "2026-03-18T08:06:55.763153Z", + "start_time": "2026-03-18T08:06:55.660972Z" } }, - "outputs": [], "source": [ "m = linopy.Model()\n", "time = pd.Index(range(10), name=\"time\")\n", @@ -63,7 +61,9 @@ "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", @@ -79,18 +79,18 @@ }, { "cell_type": "code", - "execution_count": null, "id": "f7db57f8", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.239585Z", - "start_time": "2026-03-18T07:56:54.235308Z" + "end_time": "2026-03-18T08:06:55.770316Z", + "start_time": "2026-03-18T08:06:55.766559Z" } }, - "outputs": [], "source": [ "x.lower = 1" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -105,35 +105,35 @@ }, { "cell_type": "code", - "execution_count": null, "id": "c37add87", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.362737Z", - "start_time": "2026-03-18T07:56:54.242692Z" + "end_time": "2026-03-18T08:06:55.831355Z", + "start_time": "2026-03-18T08:06:55.774853Z" } }, - "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", - "execution_count": null, "id": "b5be8d00", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.383514Z", - "start_time": "2026-03-18T07:56:54.378310Z" + "end_time": "2026-03-18T08:06:55.843937Z", + "start_time": "2026-03-18T08:06:55.840099Z" } }, - "outputs": [], "source": [ "sol" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -147,35 +147,35 @@ }, { "cell_type": "code", - "execution_count": null, "id": "451aba93", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.393966Z", - "start_time": "2026-03-18T07:56:54.391194Z" + "end_time": "2026-03-18T08:06:55.856733Z", + "start_time": "2026-03-18T08:06:55.853780Z" } }, - "outputs": [], "source": [ "x.lower = xr.DataArray(range(10, 0, -1), coords=(time,))" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "id": "e25f26a1", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.457480Z", - "start_time": "2026-03-18T07:56:54.397667Z" + "end_time": "2026-03-18T08:06:55.919477Z", + "start_time": "2026-03-18T08:06:55.862247Z" } }, - "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", @@ -199,18 +199,18 @@ }, { "cell_type": "code", - "execution_count": null, "id": "18d1bf4b", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.474622Z", - "start_time": "2026-03-18T07:56:54.465516Z" + "end_time": "2026-03-18T08:06:55.935987Z", + "start_time": "2026-03-18T08:06:55.927123Z" } }, - "outputs": [], "source": [ "con1.rhs = 8 * factor" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -225,20 +225,20 @@ }, { "cell_type": "code", - "execution_count": null, "id": "e4d34142", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.534732Z", - "start_time": "2026-03-18T07:56:54.477434Z" + "end_time": "2026-03-18T08:06:55.992339Z", + "start_time": "2026-03-18T08:06:55.939065Z" } }, - "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", @@ -252,18 +252,18 @@ }, { "cell_type": "code", - "execution_count": null, "id": "f8e81d20", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.546171Z", - "start_time": "2026-03-18T07:56:54.538512Z" + "end_time": "2026-03-18T08:06:56.008559Z", + "start_time": "2026-03-18T08:06:56.000605Z" } }, - "outputs": [], "source": [ "con1.lhs = 3 * x + 8 * y" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -287,20 +287,20 @@ }, { "cell_type": "code", - "execution_count": null, "id": "9b73250d", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.609576Z", - "start_time": "2026-03-18T07:56:54.550838Z" + "end_time": "2026-03-18T08:06:56.063782Z", + "start_time": "2026-03-18T08:06:56.010905Z" } }, - "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", @@ -316,35 +316,35 @@ }, { "cell_type": "code", - "execution_count": null, "id": "44689b5b", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.624207Z", - "start_time": "2026-03-18T07:56:54.616784Z" + "end_time": "2026-03-18T08:06:56.078777Z", + "start_time": "2026-03-18T08:06:56.071457Z" } }, - "outputs": [], "source": [ "m.objective = x + 3 * y" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "id": "2144af8e", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.680101Z", - "start_time": "2026-03-18T07:56:54.626956Z" + "end_time": "2026-03-18T08:06:56.133250Z", + "start_time": "2026-03-18T08:06:56.081080Z" } }, - "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", @@ -356,109 +356,687 @@ }, { "cell_type": "code", - "execution_count": null, "id": "85cbd60b", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.688147Z", - "start_time": "2026-03-18T07:56:54.684196Z" + "end_time": "2026-03-18T08:06:56.144542Z", + "start_time": "2026-03-18T08:06:56.141553Z" } }, - "outputs": [], "source": [ "m.objective" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "id": "5qohnezrozd", "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 build a small unit commitment example with binary commitment variables and continuous dispatch variables." + "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`." }, { "cell_type": "code", - "execution_count": null, "id": "ske7l8391kl", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:58:45.050302Z", - "start_time": "2026-03-18T07:58:44.993758Z" + "end_time": "2026-03-18T08:06:56.202929Z", + "start_time": "2026-03-18T08:06:56.151649Z" } }, - "outputs": [], - "source": [ - "m2 = linopy.Model()\n", - "\n", - "generators = pd.Index([\"cheap\", \"mid\", \"expensive\"], name=\"generator\")\n", - "time = pd.Index(range(5), name=\"time\")\n", - "\n", - "# Binary commitment variables: is the generator on?\n", - "commit = m2.add_variables(binary=True, coords=[generators, time], name=\"commit\")\n", - "\n", - "# Continuous dispatch variables\n", - "dispatch = m2.add_variables(\n", - " lower=0, upper=100, coords=[generators, time], name=\"dispatch\"\n", - ")\n", - "\n", - "# Dispatch only when committed: dispatch <= 100 * commit\n", - "m2.add_constraints(dispatch - 100 * commit <= 0, name=\"dispatch_limit\")\n", - "\n", - "# Must meet demand\n", - "demand = xr.DataArray([50, 80, 120, 90, 60], coords=[time])\n", - "m2.add_constraints(dispatch.sum(\"generator\") >= demand, name=\"demand\")\n", - "\n", - "# Minimize cost: different marginal costs per generator + startup cost\n", - "costs = xr.DataArray([1, 3, 5], coords=[generators])\n", - "m2.add_objective((costs * dispatch).sum(\"generator\") + 10 * commit.sum())\n", - "\n", - "m2.solve(solver_name=\"highs\")" - ] + "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-a7gkxoqa 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-a7gkxoqa\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", + " Solution status feasible\n", + " 197.541666667 (objective)\n", + " 0 (bound viol.)\n", + " 0 (int. viol.)\n", + " 0 (row viol.)\n", + " Timing 0.01\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": 35, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": null }, { "cell_type": "markdown", "id": "wrtc3hk1cal", "metadata": {}, - "source": "Now fix the binary commitment variables to their optimal values and relax their integrality. This converts the MILP 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", - "execution_count": null, "id": "xtyyswns2we", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:58:04.525923Z", - "start_time": "2026-03-18T07:58:04.465378Z" + "end_time": "2026-03-18T08:06:56.254283Z", + "start_time": "2026-03-18T08:06:56.218399Z" } }, - "outputs": [], - "source": [ - "m2.variables.binaries.fix(relax=True)\n", - "m2.solve(solver_name=\"highs\")\n", - "\n", - "# Dual values on the demand constraint give the marginal cost of serving one more unit\n", - "m2.constraints[\"demand\"].dual" - ] + "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" + } + ], + "execution_count": null }, { "cell_type": "markdown", "id": "mnmsgvr40hq", "metadata": {}, - "source": "Finally, unfix all variables to restore the model to its original state, including the integrality of the binary variables." + "source": "Calling `unfix()` on all variables removes the fix constraints and restores the integrality of `z`." }, { "cell_type": "code", - "execution_count": null, "id": "1b6uoag2xkf", "metadata": { "ExecuteTime": { - "end_time": "2026-03-18T07:56:54.869726Z", - "start_time": "2026-03-18T07:56:54.866595Z" + "end_time": "2026-03-18T08:06:56.264976Z", + "start_time": "2026-03-18T08:06:56.262008Z" } }, - "outputs": [], - "source": [ - "m2.variables.unfix()" - ] + "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" + } + ], + "execution_count": null } ], "metadata": { From cc0edda11389566d4a30f3bdb9f5d8e7967c1667 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:45:50 +0100 Subject: [PATCH 4/9] fix: Add type annotations to test_fix.py for mypy Co-Authored-By: Claude Opus 4.6 (1M context) --- test/test_fix.py | 56 ++++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/test/test_fix.py b/test/test_fix.py index d07fa378..4711030b 100644 --- a/test/test_fix.py +++ b/test/test_fix.py @@ -10,7 +10,7 @@ @pytest.fixture -def model_with_solution(): +def model_with_solution() -> Model: """Create a simple model and simulate a solution.""" m = Model() x = m.add_variables(lower=0, upper=10, name="x") @@ -30,59 +30,59 @@ def model_with_solution(): class TestVariableFix: - def test_fix_uses_solution(self, model_with_solution): + def test_fix_uses_solution(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix() assert m.variables["x"].fixed assert f"{FIX_CONSTRAINT_PREFIX}x" in m.constraints - def test_fix_with_explicit_value(self, model_with_solution): + def test_fix_with_explicit_value(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=5.0) assert m.variables["x"].fixed con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] np.testing.assert_almost_equal(con.rhs.item(), 5.0) - def test_fix_rounds_binary(self, model_with_solution): + def test_fix_rounds_binary(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["z"].fix() con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}z"] # 0.9999999997 should be rounded to 1.0 np.testing.assert_equal(con.rhs.item(), 1.0) - def test_fix_rounds_integer(self, model_with_solution): + def test_fix_rounds_integer(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["w"].fix() con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}w"] # 41.9999999998 should be rounded to 42.0 np.testing.assert_equal(con.rhs.item(), 42.0) - def test_fix_rounds_continuous(self, model_with_solution): + def test_fix_rounds_continuous(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(decimals=4) con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] np.testing.assert_almost_equal(con.rhs.item(), 3.1416, decimal=4) - def test_fix_clips_to_upper_bound(self, model_with_solution): + def test_fix_clips_to_upper_bound(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=10.0000001) con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] np.testing.assert_almost_equal(con.rhs.item(), 10.0) - def test_fix_clips_to_lower_bound(self, model_with_solution): + def test_fix_clips_to_lower_bound(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=-0.0000001) con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] np.testing.assert_almost_equal(con.rhs.item(), 0.0) - def test_fix_overwrites_existing(self, model_with_solution): + def test_fix_overwrites_existing(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=3.0) m.variables["x"].fix(value=5.0) con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] np.testing.assert_almost_equal(con.rhs.item(), 5.0) - def test_fix_multidimensional(self, model_with_solution): + def test_fix_multidimensional(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["y"].fix() assert m.variables["y"].fixed @@ -91,14 +91,14 @@ def test_fix_multidimensional(self, model_with_solution): class TestVariableUnfix: - def test_unfix_removes_constraint(self, model_with_solution): + def test_unfix_removes_constraint(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=5.0) m.variables["x"].unfix() assert not m.variables["x"].fixed assert f"{FIX_CONSTRAINT_PREFIX}x" not in m.constraints - def test_unfix_noop_if_not_fixed(self, model_with_solution): + def test_unfix_noop_if_not_fixed(self, model_with_solution: Model) -> None: m = model_with_solution # Should not raise m.variables["x"].unfix() @@ -106,7 +106,7 @@ def test_unfix_noop_if_not_fixed(self, model_with_solution): class TestVariableFixRelax: - def test_fix_relax_binary(self, model_with_solution): + 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 @@ -115,7 +115,7 @@ def test_fix_relax_binary(self, model_with_solution): assert "z" in m._relaxed_registry assert m._relaxed_registry["z"] == "binary" - def test_fix_relax_integer(self, model_with_solution): + def test_fix_relax_integer(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["w"].fix(relax=True) assert not m.variables["w"].attrs["integer"] @@ -123,21 +123,21 @@ def test_fix_relax_integer(self, model_with_solution): assert "w" in m._relaxed_registry assert m._relaxed_registry["w"] == "integer" - def test_unfix_restores_binary(self, model_with_solution): + def test_unfix_restores_binary(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["z"].fix(relax=True) m.variables["z"].unfix() assert m.variables["z"].attrs["binary"] assert "z" not in m._relaxed_registry - def test_unfix_restores_integer(self, model_with_solution): + def test_unfix_restores_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 - def test_fix_relax_continuous_noop(self, model_with_solution): + def test_fix_relax_continuous_noop(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(relax=True) # Continuous variable should not be in registry @@ -145,16 +145,16 @@ def test_fix_relax_continuous_noop(self, model_with_solution): class TestVariableFixed: - def test_fixed_false_initially(self, model_with_solution): + def test_fixed_false_initially(self, model_with_solution: Model) -> None: m = model_with_solution assert not m.variables["x"].fixed - def test_fixed_true_after_fix(self, model_with_solution): + def test_fixed_true_after_fix(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=5.0) assert m.variables["x"].fixed - def test_fixed_false_after_unfix(self, model_with_solution): + def test_fixed_false_after_unfix(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=5.0) m.variables["x"].unfix() @@ -162,32 +162,32 @@ def test_fixed_false_after_unfix(self, model_with_solution): class TestVariablesContainerFixUnfix: - def test_fix_all(self, model_with_solution): + def test_fix_all(self, model_with_solution: Model) -> None: m = model_with_solution m.variables.fix() for name in m.variables: assert m.variables[name].fixed - def test_unfix_all(self, model_with_solution): + def test_unfix_all(self, model_with_solution: Model) -> None: m = model_with_solution m.variables.fix() m.variables.unfix() for name in m.variables: assert not m.variables[name].fixed - def test_fix_integers_only(self, model_with_solution): + def test_fix_integers_only(self, model_with_solution: Model) -> None: m = model_with_solution m.variables.integers.fix() assert m.variables["w"].fixed assert not m.variables["x"].fixed - def test_fix_binaries_only(self, model_with_solution): + def test_fix_binaries_only(self, model_with_solution: Model) -> None: m = model_with_solution m.variables.binaries.fix() assert m.variables["z"].fixed assert not m.variables["x"].fixed - def test_fixed_returns_dict(self, model_with_solution): + def test_fixed_returns_dict(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=5.0) result = m.variables.fixed @@ -195,7 +195,7 @@ def test_fixed_returns_dict(self, model_with_solution): assert result["x"] is True assert result["y"] is False - def test_fix_relax_integers(self, model_with_solution): + def test_fix_relax_integers(self, model_with_solution: Model) -> None: m = model_with_solution m.variables.integers.fix(relax=True) assert not m.variables["w"].attrs["integer"] @@ -209,13 +209,13 @@ def test_fix_relax_integers(self, model_with_solution): class TestRemoveVariablesCleansUpFix: - def test_remove_fixed_variable(self, model_with_solution): + def test_remove_fixed_variable(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=5.0) m.remove_variables("x") assert f"{FIX_CONSTRAINT_PREFIX}x" not in m.constraints - def test_remove_relaxed_variable(self, model_with_solution): + def test_remove_relaxed_variable(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["z"].fix(relax=True) m.remove_variables("z") From dd2b797079d4acaa056f83ddbd376d638ef984cb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:52:56 +0100 Subject: [PATCH 5/9] docs: Add release note for fix/unfix/fixed feature Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/release_notes.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 35b21c67..f63af831 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -23,6 +23,7 @@ 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, bound clipping, and optional integrality relaxation (``relax=True``) for MILP dual extraction. Version 0.6.5 From 724e3f1b57c5246c5e7cbd5d1840c848cea75e56 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:07:25 +0100 Subject: [PATCH 6/9] feat: Persist _relaxed_registry through netCDF IO Serialize the relaxed registry as a JSON string in netCDF attrs so that unfix() can restore integrality after a save/load roundtrip. Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/io.py | 6 ++++++ test/test_fix.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/linopy/io.py b/linopy/io.py index 2213cbb5..51ad1a90 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json import logging import shutil import time @@ -1144,6 +1145,8 @@ def with_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: scalars = {k: getattr(m, k) for k in m.scalar_attrs} ds = xr.merge(vars + cons + obj + params, combine_attrs="drop_conflicts") ds = ds.assign_attrs(scalars) + if m._relaxed_registry: + ds.attrs["_relaxed_registry"] = json.dumps(m._relaxed_registry) ds.attrs = non_bool_dict(ds.attrs) for k in ds: @@ -1238,4 +1241,7 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: for k in m.scalar_attrs: setattr(m, k, ds.attrs.get(k)) + if "_relaxed_registry" in ds.attrs: + m._relaxed_registry = json.loads(ds.attrs["_relaxed_registry"]) + return m diff --git a/test/test_fix.py b/test/test_fix.py index 4711030b..88ff5f68 100644 --- a/test/test_fix.py +++ b/test/test_fix.py @@ -1,5 +1,7 @@ """Tests for Variable.fix(), Variable.unfix(), and Variable.fixed.""" +from pathlib import Path + import numpy as np import pandas as pd import pytest @@ -221,3 +223,52 @@ def test_remove_relaxed_variable(self, model_with_solution: Model) -> None: m.remove_variables("z") assert "z" not in m._relaxed_registry assert f"{FIX_CONSTRAINT_PREFIX}z" not in m.constraints + + +class TestFixIO: + 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) + + path = tmp_path / "model.nc" + m.to_netcdf(path) + + from linopy.io import read_netcdf + + m2 = read_netcdf(path) + assert m2._relaxed_registry == {"z": "binary", "w": "integer"} + # Fix constraints should also survive + assert f"{FIX_CONSTRAINT_PREFIX}z" in m2.constraints + assert f"{FIX_CONSTRAINT_PREFIX}w" in m2.constraints + + def test_empty_registry_netcdf( + self, model_with_solution: Model, tmp_path: Path + ) -> None: + m = model_with_solution + path = tmp_path / "model.nc" + m.to_netcdf(path) + + from linopy.io import read_netcdf + + m2 = read_netcdf(path) + assert m2._relaxed_registry == {} + + def test_unfix_after_roundtrip( + self, model_with_solution: Model, tmp_path: Path + ) -> None: + m = model_with_solution + m.variables["z"].fix(relax=True) + + path = tmp_path / "model.nc" + m.to_netcdf(path) + + from linopy.io import read_netcdf + + m2 = read_netcdf(path) + m2.variables["z"].unfix() + assert m2.variables["z"].attrs["binary"] + assert "z" not in m2._relaxed_registry + assert f"{FIX_CONSTRAINT_PREFIX}z" not in m2.constraints From 08ff6afa2e9488d7fb36c9161bfaac81e3afbe84 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:09:57 +0100 Subject: [PATCH 7/9] refactor: Address PR review comments for fix/unfix feature - Use as_dataarray() instead of DataArray() for value conversion - Remove explanatory inline comments - Move FIX_CONSTRAINT_PREFIX to constants.py - Raise ValueError for out-of-bounds fix values instead of clipping - Add overwrite parameter to fix() (default True) Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/constants.py | 2 ++ linopy/model.py | 2 +- linopy/variables.py | 40 ++++++++++++++++++++++++++++------------ test/test_fix.py | 32 +++++++++++++++++++++++--------- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/linopy/constants.py b/linopy/constants.py index 00bbd705..f3c05a55 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -33,6 +33,8 @@ short_LESS_EQUAL: LESS_EQUAL, } +FIX_CONSTRAINT_PREFIX = "__fix__" + TERM_DIM = "_term" STACKED_TERM_DIM = "_stacked_term" diff --git a/linopy/model.py b/linopy/model.py index 5d792597..65c1223d 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -939,7 +939,7 @@ def remove_variables(self, name: str) -> None: ------- None. """ - from linopy.variables import FIX_CONSTRAINT_PREFIX + from linopy.constants import FIX_CONSTRAINT_PREFIX # Clean up fix constraint if present fix_name = f"{FIX_CONSTRAINT_PREFIX}{name}" diff --git a/linopy/variables.py b/linopy/variables.py index acc1d639..2d17fef8 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -53,7 +53,13 @@ to_polars, ) from linopy.config import options -from linopy.constants import HELPER_DIMS, SOS_DIM_ATTR, SOS_TYPE_ATTR, TERM_DIM +from linopy.constants import ( + FIX_CONSTRAINT_PREFIX, + HELPER_DIMS, + SOS_DIM_ATTR, + SOS_TYPE_ATTR, + TERM_DIM, +) from linopy.solver_capabilities import SolverFeature, solver_supports from linopy.types import ( ConstantLike, @@ -79,8 +85,6 @@ FILL_VALUE = {"labels": -1, "lower": np.nan, "upper": np.nan} -FIX_CONSTRAINT_PREFIX = "__fix__" - def varwrap( method: Callable, *default_args: Any, **new_default_kwargs: Any @@ -1296,6 +1300,7 @@ def fix( value: ConstantLike | None = None, decimals: int = 8, relax: bool = False, + overwrite: bool = True, ) -> None: """ Fix the variable to a given value by adding an equality constraint. @@ -1315,31 +1320,40 @@ def fix( 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. """ if value is None: value = self.solution - value = DataArray(value).broadcast_like(self.labels) + value = as_dataarray(value).broadcast_like(self.labels) - # Round: integers/binaries to 0 decimals, continuous to `decimals` if self.attrs.get("integer") or self.attrs.get("binary"): value = value.round(0) else: value = value.round(decimals) - # Clip to bounds - value = value.clip(min=self.lower, max=self.upper) + if (value < self.lower).any() or (value > self.upper).any(): + msg = ( + f"Fix values for variable '{self.name}' are outside the " + f"variable bounds." + ) + raise ValueError(msg) constraint_name = f"{FIX_CONSTRAINT_PREFIX}{self.name}" - # Remove existing fix constraint if present if constraint_name in self.model.constraints: + if not overwrite: + msg = ( + f"Variable '{self.name}' is already fixed. Use " + f"overwrite=True to replace the existing fix constraint." + ) + raise ValueError(msg) self.model.remove_constraints(constraint_name) - # Add equality constraint: 1 * var == value self.model.add_constraints(1 * self, "=", value, name=constraint_name) - # Handle integrality relaxation 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 @@ -1357,7 +1371,6 @@ def unfix(self) -> None: if constraint_name in self.model.constraints: self.model.remove_constraints(constraint_name) - # Restore integrality if it was relaxed registry = self.model._relaxed_registry if self.name in registry: original_type = registry.pop(self.name) @@ -1653,6 +1666,7 @@ def fix( value: int | float | None = None, decimals: int = 8, relax: bool = False, + overwrite: bool = True, ) -> None: """ Fix all variables in this container to their solution or a scalar value. @@ -1670,6 +1684,8 @@ def fix( 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 ---- @@ -1685,7 +1701,7 @@ def fix( m.variables[names].unfix() """ for var in self.data.values(): - var.fix(value=value, decimals=decimals, relax=relax) + var.fix(value=value, decimals=decimals, relax=relax, overwrite=overwrite) def unfix(self) -> None: """ diff --git a/test/test_fix.py b/test/test_fix.py index 88ff5f68..e2cb10c0 100644 --- a/test/test_fix.py +++ b/test/test_fix.py @@ -8,7 +8,7 @@ from xarray import DataArray from linopy import Model -from linopy.variables import FIX_CONSTRAINT_PREFIX +from linopy.constants import FIX_CONSTRAINT_PREFIX @pytest.fixture @@ -65,22 +65,36 @@ def test_fix_rounds_continuous(self, model_with_solution: Model) -> None: con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] np.testing.assert_almost_equal(con.rhs.item(), 3.1416, decimal=4) - def test_fix_clips_to_upper_bound(self, model_with_solution: Model) -> None: + def test_fix_raises_above_upper_bound(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["x"].fix(value=10.0000001) + with pytest.raises(ValueError, match="outside the variable bounds"): + m.variables["x"].fix(value=11.0) + + def test_fix_raises_below_lower_bound(self, model_with_solution: Model) -> None: + m = model_with_solution + with pytest.raises(ValueError, match="outside the variable bounds"): + m.variables["x"].fix(value=-1.0) + + def test_fix_small_overshoot_rounded_within_bounds( + self, model_with_solution: Model + ) -> None: + m = model_with_solution + m.variables["x"].fix(value=10.0000000001) con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] np.testing.assert_almost_equal(con.rhs.item(), 10.0) - def test_fix_clips_to_lower_bound(self, model_with_solution: Model) -> None: + def test_fix_raises_if_already_fixed_no_overwrite( + self, model_with_solution: Model + ) -> None: m = model_with_solution - m.variables["x"].fix(value=-0.0000001) - con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] - np.testing.assert_almost_equal(con.rhs.item(), 0.0) + m.variables["x"].fix(value=3.0) + with pytest.raises(ValueError, match="already fixed"): + m.variables["x"].fix(value=5.0, overwrite=False) - def test_fix_overwrites_existing(self, model_with_solution: Model) -> None: + def test_fix_overwrite_replaces_existing(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=3.0) - m.variables["x"].fix(value=5.0) + m.variables["x"].fix(value=5.0, overwrite=True) con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] np.testing.assert_almost_equal(con.rhs.item(), 5.0) From c13fe69279991d5deed6af27d72e6d9d8256b91c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:20:44 +0100 Subject: [PATCH 8/9] test: Parametrize fix tests across scalar and array data types Co-Authored-By: Claude Opus 4.6 (1M context) --- test/test_fix.py | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/test/test_fix.py b/test/test_fix.py index e2cb10c0..7e94d717 100644 --- a/test/test_fix.py +++ b/test/test_fix.py @@ -31,32 +31,56 @@ def model_with_solution() -> Model: return m +SCALAR_VALUES: list = [ + pytest.param(5, id="int"), + pytest.param(5.0, id="float"), + pytest.param(np.float64(5.0), id="np.float64"), + pytest.param(np.int64(5), id="np.int64"), + pytest.param(np.array(5.0), id="np.0d-array"), + pytest.param(DataArray(5.0), id="DataArray"), +] + +ARRAY_VALUES: list = [ + pytest.param([2.5, -1.5], id="list"), + pytest.param(np.array([2.5, -1.5]), id="np.array"), + pytest.param(DataArray([2.5, -1.5], dims="dim_0"), id="DataArray"), + pytest.param(pd.Series([2.5, -1.5]), id="pd.Series"), +] + + class TestVariableFix: - def test_fix_uses_solution(self, model_with_solution: Model) -> None: + @pytest.mark.parametrize("value", SCALAR_VALUES) + def test_fix_scalar_dtypes(self, model_with_solution: Model, value: object) -> None: m = model_with_solution - m.variables["x"].fix() + m.variables["x"].fix(value=value) assert m.variables["x"].fixed - assert f"{FIX_CONSTRAINT_PREFIX}x" in m.constraints + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] + np.testing.assert_almost_equal(con.rhs.item(), 5.0) - def test_fix_with_explicit_value(self, model_with_solution: Model) -> None: + @pytest.mark.parametrize("value", ARRAY_VALUES) + def test_fix_array_dtypes(self, model_with_solution: Model, value: object) -> None: m = model_with_solution - m.variables["x"].fix(value=5.0) + m.variables["y"].fix(value=value) + assert m.variables["y"].fixed + con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}y"] + np.testing.assert_array_almost_equal(con.rhs.values, [2.5, -1.5]) + + def test_fix_uses_solution(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["x"].fix() assert m.variables["x"].fixed - con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}x"] - np.testing.assert_almost_equal(con.rhs.item(), 5.0) + assert f"{FIX_CONSTRAINT_PREFIX}x" in m.constraints def test_fix_rounds_binary(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["z"].fix() con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}z"] - # 0.9999999997 should be rounded to 1.0 np.testing.assert_equal(con.rhs.item(), 1.0) def test_fix_rounds_integer(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["w"].fix() con = m.constraints[f"{FIX_CONSTRAINT_PREFIX}w"] - # 41.9999999998 should be rounded to 42.0 np.testing.assert_equal(con.rhs.item(), 42.0) def test_fix_rounds_continuous(self, model_with_solution: Model) -> None: From cb625d6d66d435ef26827a08f44b1c93747d91f9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:43:41 +0100 Subject: [PATCH 9/9] docs: Remove clipping from changelog for new feature --- doc/release_notes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index f63af831..a0828cf7 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -23,7 +23,7 @@ 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, bound clipping, 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 and optional integrality relaxation (``relax=True``) for MILP dual extraction. Version 0.6.5