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 diff --git a/examples/manipulating-models.ipynb b/examples/manipulating-models.ipynb index 1a35cd19..6b0e2fad 100644 --- a/examples/manipulating-models.ipynb +++ b/examples/manipulating-models.ipynb @@ -14,23 +14,31 @@ }, { "cell_type": "code", - "execution_count": null, "id": "16a41836", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:55.649489Z", + "start_time": "2026-03-18T08:06:55.646926Z" + } + }, "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": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:55.763153Z", + "start_time": "2026-03-18T08:06:55.660972Z" + } + }, "source": [ "m = linopy.Model()\n", "time = pd.Index(range(10), name=\"time\")\n", @@ -53,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", @@ -69,13 +79,18 @@ }, { "cell_type": "code", - "execution_count": null, "id": "f7db57f8", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:55.770316Z", + "start_time": "2026-03-18T08:06:55.766559Z" + } + }, "source": [ "x.lower = 1" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -90,25 +105,35 @@ }, { "cell_type": "code", - "execution_count": null, "id": "c37add87", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:55.831355Z", + "start_time": "2026-03-18T08:06:55.774853Z" + } + }, "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": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:55.843937Z", + "start_time": "2026-03-18T08:06:55.840099Z" + } + }, "source": [ "sol" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -122,25 +147,35 @@ }, { "cell_type": "code", - "execution_count": null, "id": "451aba93", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:55.856733Z", + "start_time": "2026-03-18T08:06:55.853780Z" + } + }, "source": [ "x.lower = xr.DataArray(range(10, 0, -1), coords=(time,))" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "id": "e25f26a1", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:55.919477Z", + "start_time": "2026-03-18T08:06:55.862247Z" + } + }, "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", @@ -164,13 +199,18 @@ }, { "cell_type": "code", - "execution_count": null, "id": "18d1bf4b", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:55.935987Z", + "start_time": "2026-03-18T08:06:55.927123Z" + } + }, "source": [ "con1.rhs = 8 * factor" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -185,15 +225,20 @@ }, { "cell_type": "code", - "execution_count": null, "id": "e4d34142", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:55.992339Z", + "start_time": "2026-03-18T08:06:55.939065Z" + } + }, "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", @@ -207,13 +252,18 @@ }, { "cell_type": "code", - "execution_count": null, "id": "f8e81d20", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:56.008559Z", + "start_time": "2026-03-18T08:06:56.000605Z" + } + }, "source": [ "con1.lhs = 3 * x + 8 * y" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -237,15 +287,20 @@ }, { "cell_type": "code", - "execution_count": null, "id": "9b73250d", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:56.063782Z", + "start_time": "2026-03-18T08:06:56.010905Z" + } + }, "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", @@ -261,25 +316,35 @@ }, { "cell_type": "code", - "execution_count": null, "id": "44689b5b", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:56.078777Z", + "start_time": "2026-03-18T08:06:56.071457Z" + } + }, "source": [ "m.objective = x + 3 * y" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "id": "2144af8e", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:56.133250Z", + "start_time": "2026-03-18T08:06:56.081080Z" + } + }, "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", @@ -291,13 +356,687 @@ }, { "cell_type": "code", - "execution_count": null, "id": "85cbd60b", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:56.144542Z", + "start_time": "2026-03-18T08:06:56.141553Z" + } + }, "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 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-18T08:06:56.202929Z", + "start_time": "2026-03-18T08:06:56.151649Z" + } + }, + "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 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-18T08:06:56.254283Z", + "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" + } + ], + "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`." + }, + { + "cell_type": "code", + "id": "1b6uoag2xkf", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-18T08:06:56.264976Z", + "start_time": "2026-03-18T08:06:56.262008Z" + } + }, + "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": { 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/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..88ff5f68 --- /dev/null +++ b/test/test_fix.py @@ -0,0 +1,274 @@ +"""Tests for Variable.fix(), Variable.unfix(), and Variable.fixed.""" + +from pathlib import Path + +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() -> Model: + """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: 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: 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: 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: + 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: 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: 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: 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: Model) -> None: + 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: 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: Model) -> None: + 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: 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" + + 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"] + 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: 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: 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: 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 + + +class TestVariableFixed: + 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: 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: Model) -> None: + 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: 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: 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: 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: 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: 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 + + 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"] + 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: 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: Model) -> None: + 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 + + +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