diff --git a/doc/release_notes.rst b/doc/release_notes.rst index ffbfe20f..f1afa98a 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -24,7 +24,8 @@ Upcoming Version * Improve handling of CPLEX solver quality attributes to ensure metrics such are extracted correctly when available. * Fix Xpress IIS label mapping for masked constraints and add a regression test for matching infeasible coordinates. * Enable quadratic problems with SCIP on windows. -* Add ``fix()``, ``unfix()``, and ``fixed`` to ``Variable`` and ``Variables`` for fixing variables to values via equality constraints. Supports automatic rounding and optional integrality relaxation (``relax=True``) for MILP dual extraction. +* Add ``fix()``, ``unfix()``, and ``fixed`` to ``Variable`` and ``Variables`` for fixing variables to values via equality constraints. Supports automatic rounding for integer/binary variables. +* Add ``relax()``, ``unrelax()``, and ``relaxed`` to ``Variable`` and ``Variables`` for LP relaxation of integer/binary variables. Supports partial relaxation via filtered views (e.g. ``m.variables.integers.relax()``). Semi-continuous variables raise ``NotImplementedError``. Version 0.6.5 diff --git a/examples/manipulating-models.ipynb b/examples/manipulating-models.ipynb index 6b0e2fad..81106ab3 100644 --- a/examples/manipulating-models.ipynb +++ b/examples/manipulating-models.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "37a85c22", + "id": "0", "metadata": {}, "source": [ "# Modifying Models\n", @@ -14,31 +14,23 @@ }, { "cell_type": "code", - "id": "16a41836", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.649489Z", - "start_time": "2026-03-18T08:06:55.646926Z" - } - }, + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], "source": [ "import pandas as pd\n", "import xarray as xr\n", "\n", "import linopy" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", - "id": "8f4d182f", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.763153Z", - "start_time": "2026-03-18T08:06:55.660972Z" - } - }, + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], "source": [ "m = linopy.Model()\n", "time = pd.Index(range(10), name=\"time\")\n", @@ -61,13 +53,11 @@ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "d3f4b966", + "id": "3", "metadata": {}, "source": [ "The figure above shows the optimal values of `x(t)` and `y(t)`. \n", @@ -79,22 +69,17 @@ }, { "cell_type": "code", - "id": "f7db57f8", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.770316Z", - "start_time": "2026-03-18T08:06:55.766559Z" - } - }, + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], "source": [ "x.lower = 1" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "66b8be86", + "id": "5", "metadata": {}, "source": [ ".. note::\n", @@ -105,39 +90,29 @@ }, { "cell_type": "code", - "id": "c37add87", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.831355Z", - "start_time": "2026-03-18T08:06:55.774853Z" - } - }, + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", - "id": "b5be8d00", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.843937Z", - "start_time": "2026-03-18T08:06:55.840099Z" - } - }, + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], "source": [ "sol" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "d35e3309", + "id": "8", "metadata": {}, "source": [ "We see that the new lower bound of x is binding across all time steps.\n", @@ -147,39 +122,29 @@ }, { "cell_type": "code", - "id": "451aba93", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.856733Z", - "start_time": "2026-03-18T08:06:55.853780Z" - } - }, + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], "source": [ "x.lower = xr.DataArray(range(10, 0, -1), coords=(time,))" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", - "id": "e25f26a1", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.919477Z", - "start_time": "2026-03-18T08:06:55.862247Z" - } - }, + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "4d991939", + "id": "11", "metadata": {}, "source": [ "You can manipulate the upper bound of a variable in the same way." @@ -187,7 +152,7 @@ }, { "cell_type": "markdown", - "id": "de29c28e", + "id": "12", "metadata": {}, "source": [ "## Varying Constraints\n", @@ -199,22 +164,17 @@ }, { "cell_type": "code", - "id": "18d1bf4b", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.935987Z", - "start_time": "2026-03-18T08:06:55.927123Z" - } - }, + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], "source": [ "con1.rhs = 8 * factor" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "5499b3b4", + "id": "14", "metadata": {}, "source": [ ".. note::\n", @@ -225,24 +185,19 @@ }, { "cell_type": "code", - "id": "e4d34142", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:55.992339Z", - "start_time": "2026-03-18T08:06:55.939065Z" - } - }, + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "bc683e13", + "id": "16", "metadata": {}, "source": [ "In contrast to previous figure, we now see that the optimal value of `y` does not reach values above 10 in the end. \n", @@ -252,22 +207,17 @@ }, { "cell_type": "code", - "id": "f8e81d20", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.008559Z", - "start_time": "2026-03-18T08:06:56.000605Z" - } - }, + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], "source": [ "con1.lhs = 3 * x + 8 * y" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "cc377d95", + "id": "18", "metadata": {}, "source": [ "**Note:**\n", @@ -279,7 +229,7 @@ }, { "cell_type": "markdown", - "id": "633d463b", + "id": "19", "metadata": {}, "source": [ "which leads to" @@ -287,24 +237,19 @@ }, { "cell_type": "code", - "id": "9b73250d", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.063782Z", - "start_time": "2026-03-18T08:06:56.010905Z" - } - }, + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "e509d5d7", + "id": "21", "metadata": {}, "source": [ "## Varying the objective \n", @@ -316,39 +261,29 @@ }, { "cell_type": "code", - "id": "44689b5b", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.078777Z", - "start_time": "2026-03-18T08:06:56.071457Z" - } - }, + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], "source": [ "m.objective = x + 3 * y" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", - "id": "2144af8e", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.133250Z", - "start_time": "2026-03-18T08:06:56.081080Z" - } - }, + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], "source": [ "m.solve(solver_name=\"highs\")\n", "sol = m.solution.to_dataframe()\n", "sol.plot(grid=True, ylabel=\"Optimal Value\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "f1faa095", + "id": "24", "metadata": {}, "source": [ "As a consequence, `y` stays at zero for all time steps." @@ -356,687 +291,88 @@ }, { "cell_type": "code", - "id": "85cbd60b", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.144542Z", - "start_time": "2026-03-18T08:06:56.141553Z" - } - }, + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], "source": [ "m.objective" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "id": "5qohnezrozd", + "id": "26", "metadata": {}, - "source": "## Fixing Variables and Extracting MILP Duals\n\nA common workflow in mixed-integer programming is to solve the MILP, then fix the integer/binary variables to their optimal values and re-solve as an LP to obtain dual values (shadow prices).\n\nLet's extend our model with a binary variable `z` that activates an additional capacity constraint on `x`." + "source": [ + "## Fixing Variables and Extracting MILP Duals\n", + "\n", + "A common workflow in mixed-integer programming is to solve the MILP, then fix the integer/binary variables to their optimal values and re-solve as an LP to obtain dual values (shadow prices).\n", + "\n", + "Let's extend our model with a binary variable `z` that activates an additional capacity constraint on `x`." + ] }, { "cell_type": "code", - "id": "ske7l8391kl", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-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 + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "z = m.add_variables(binary=True, coords=[time], name=\"z\")\n", + "\n", + "# x can only exceed 5 when z is active: x <= 5 + 100 * z\n", + "m.add_constraints(x <= 5 + 100 * z, name=\"capacity\")\n", + "\n", + "# Penalize activation of z in the objective\n", + "m.objective = x + 3 * y + 10 * z\n", + "\n", + "m.solve(solver_name=\"highs\")" + ] }, { "cell_type": "markdown", - "id": "wrtc3hk1cal", + "id": "28", "metadata": {}, - "source": "Now fix the binary variable `z` to its optimal values and relax its integrality. This converts the model into an LP, which allows us to extract dual values." + "source": [ + "Now fix the binary variable `z` to its optimal values and relax its integrality. This converts the model into an LP, which allows us to extract dual values." + ] }, { "cell_type": "code", - "id": "xtyyswns2we", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-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 + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "m.variables.binaries.fix()\n", + "m.variables.binaries.relax()\n", + "m.solve(solver_name=\"highs\")\n", + "\n", + "# Dual values are now available on the constraints\n", + "m.constraints[\"con1\"].dual" + ] }, { "cell_type": "markdown", - "id": "mnmsgvr40hq", + "id": "30", "metadata": {}, - "source": "Calling `unfix()` on all variables removes the fix constraints and restores the integrality of `z`." + "source": [ + "Calling `unfix()` on all variables removes the fix constraints and `unrelax()` restores the integrality of `z`." + ] }, { "cell_type": "code", - "id": "1b6uoag2xkf", - "metadata": { - "ExecuteTime": { - "end_time": "2026-03-18T08:06:56.264976Z", - "start_time": "2026-03-18T08:06:56.262008Z" - } - }, - "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 + "execution_count": null, + "id": "31", + "metadata": {}, + "outputs": [], + "source": [ + "m.variables.unfix()\n", + "m.variables.unrelax()\n", + "\n", + "# z is binary again\n", + "m.variables[\"z\"].attrs[\"binary\"]" + ] } ], "metadata": { diff --git a/linopy/variables.py b/linopy/variables.py index 2d17fef8..dc343840 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -1295,11 +1295,61 @@ def equals(self, other: Variable) -> bool: iterate_slices = iterate_slices + def relax(self) -> None: + """ + Relax the integrality of this variable. + + Converts binary or integer variables to continuous. The original type + is stored in the model's ``_relaxed_registry`` so that + :meth:`unrelax` can restore it. + + Semi-continuous variables are not supported and will raise a + ``NotImplementedError``. + + For binary variables, the existing [0, 1] bounds are preserved, + which is the correct LP relaxation. For integer variables, the + existing bounds are preserved as-is. + + If the variable is already continuous, this method is a no-op. + """ + attrs = self.attrs + if attrs.get("semi_continuous"): + msg = ( + f"Relaxation of semi-continuous variable '{self.name}' is not " + "supported. The LP relaxation of a semi-continuous variable " + "requires changing bounds, which is not handled by relax()." + ) + raise NotImplementedError(msg) + + for attr in ("binary", "integer"): + if attrs.get(attr): + self.model._relaxed_registry[self.name] = attr + attrs[attr] = False + return + + def unrelax(self) -> None: + """ + Restore the original integrality type of a relaxed variable. + + Reverses the effect of :meth:`relax`. If the variable was not + previously relaxed, this is a no-op. + """ + registry = self.model._relaxed_registry + original_type = registry.pop(self.name, None) + if original_type is not None: + self.attrs[original_type] = True + + @property + def relaxed(self) -> bool: + """ + Return whether the variable is currently relaxed. + """ + return self.name in self.model._relaxed_registry + def fix( self, value: ConstantLike | None = None, decimals: int = 8, - relax: bool = False, overwrite: bool = True, ) -> None: """ @@ -1315,17 +1365,20 @@ def fix( Number of decimal places to round continuous variables to. Integer and binary variables are always rounded to 0 decimal places. Default is 8. - relax : bool, optional - If True, relax the integrality of integer/binary variables by - temporarily treating them as continuous. The original type is stored - in the model's ``_relaxed_registry`` and restored by ``unfix()``. - Default is False. overwrite : bool, optional - If True, overwrite an existing fix constraint for this variable. - If False (default), raise an error if the variable is already fixed. + If True (default), overwrite an existing fix constraint for this + variable. If False, raise an error if the variable is already fixed. """ if value is None: - value = self.solution + try: + value = self.solution + except AttributeError: + msg = ( + f"Cannot fix variable '{self.name}': no solution value " + "available. Solve the model first or provide an explicit " + "value." + ) + raise ValueError(msg) from None value = as_dataarray(value).broadcast_like(self.labels) @@ -1337,7 +1390,7 @@ def fix( if (value < self.lower).any() or (value > self.upper).any(): msg = ( f"Fix values for variable '{self.name}' are outside the " - f"variable bounds." + "variable bounds." ) raise ValueError(msg) @@ -1347,38 +1400,21 @@ def fix( if not overwrite: msg = ( f"Variable '{self.name}' is already fixed. Use " - f"overwrite=True to replace the existing fix constraint." + "overwrite=True to replace the existing fix constraint." ) raise ValueError(msg) self.model.remove_constraints(constraint_name) - self.model.add_constraints(1 * self, "=", value, name=constraint_name) - - 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 + self.model.add_constraints(self, "=", value, name=constraint_name) 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) - 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: """ @@ -1665,7 +1701,6 @@ def fix( self, value: int | float | None = None, decimals: int = 8, - relax: bool = False, overwrite: bool = True, ) -> None: """ @@ -1682,26 +1717,11 @@ def fix( variables. If None, each variable is fixed to its current solution. decimals : int, optional Number of decimal places to round continuous variables to. - relax : bool, optional - If True, relax integrality of integer/binary variables. overwrite : bool, optional If True, overwrite existing fix constraints. - - Note - ---- - When using ``relax=True`` on a filtered view like - ``m.variables.integers``, the variables will no longer appear in that - view after relaxation. Call ``m.variables.unfix()`` to restore all - fixed variables. If other variables are also fixed and should stay - fixed, save the names before fixing to selectively unfix:: - - names = list(m.variables.integers) - m.variables.integers.fix(relax=True) - ... - m.variables[names].unfix() """ for var in self.data.values(): - var.fix(value=value, decimals=decimals, relax=relax, overwrite=overwrite) + var.fix(value=value, decimals=decimals, overwrite=overwrite) def unfix(self) -> None: """ @@ -1713,11 +1733,44 @@ def unfix(self) -> None: var.unfix() @property - def fixed(self) -> dict[str, bool]: + def fixed(self) -> Variables: + """ + Get all currently fixed variables. + """ + return self.__class__( + {name: self.data[name] for name in self if self[name].fixed}, + self.model, + ) + + def relax(self) -> None: """ - Return a dict mapping variable names to whether they are fixed. + Relax integrality of all integer/binary variables in this container. + + Delegates to each variable's :meth:`Variable.relax` method. + Semi-continuous variables will raise ``NotImplementedError``. """ - return {name: var.fixed for name, var in self.items()} + for var in self.data.values(): + var.relax() + + def unrelax(self) -> None: + """ + Restore integrality of all previously relaxed variables in this + container. + + Delegates to each variable's :meth:`Variable.unrelax` method. + """ + for var in self.data.values(): + var.unrelax() + + @property + def relaxed(self) -> Variables: + """ + Get all currently relaxed variables. + """ + return self.__class__( + {name: self.data[name] for name in self if self[name].relaxed}, + self.model, + ) @property def solution(self) -> Dataset: diff --git a/test/test_fix.py b/test/test_fix_relax.py similarity index 55% rename from test/test_fix.py rename to test/test_fix_relax.py index 7e94d717..2b968a30 100644 --- a/test/test_fix.py +++ b/test/test_fix_relax.py @@ -145,43 +145,66 @@ def test_unfix_noop_if_not_fixed(self, model_with_solution: Model) -> None: assert not m.variables["x"].fixed -class TestVariableFixRelax: - def test_fix_relax_binary(self, model_with_solution: Model) -> None: +class TestFixNoSolution: + def test_fix_without_solution_raises(self) -> None: + m = Model() + m.add_variables(lower=0, upper=10, name="x") + with pytest.raises(ValueError, match="no solution value available"): + m.variables["x"].fix() + + +class TestUnfixDoesNotUnrelaxIndependently: + def test_unfix_on_relaxed_only_variable(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["z"].fix(relax=True) - # Should be relaxed to continuous + m.variables["z"].relax() + # unfix should be a no-op — no fix constraint exists + m.variables["z"].unfix() + # relaxation should still be in effect + assert m.variables["z"].relaxed assert not m.variables["z"].attrs["binary"] - 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: + +class TestFixThenRelax: + """Test the combined fix() + relax() workflow (fix first, then relax).""" + + def test_fix_then_relax_binary(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["w"].fix(relax=True) - assert not m.variables["w"].attrs["integer"] - assert not m.variables["w"].attrs["binary"] - assert "w" in m._relaxed_registry - assert m._relaxed_registry["w"] == "integer" + m.variables["z"].fix() + m.variables["z"].relax() + assert not m.variables["z"].attrs["binary"] + assert m.variables["z"].fixed + assert m.variables["z"].relaxed - def test_unfix_restores_binary(self, model_with_solution: Model) -> None: + def test_unfix_does_not_unrelax(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["z"].fix(relax=True) + m.variables["z"].fix() + m.variables["z"].relax() m.variables["z"].unfix() + assert not m.variables["z"].fixed + # relaxation is independent — still in effect + assert m.variables["z"].relaxed + assert not m.variables["z"].attrs["binary"] + # explicit unrelax needed + m.variables["z"].unrelax() assert m.variables["z"].attrs["binary"] - assert "z" not in m._relaxed_registry + assert not m.variables["z"].relaxed - def test_unfix_restores_integer(self, model_with_solution: Model) -> None: + def test_fix_then_relax_integer(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["w"].fix(relax=True) - m.variables["w"].unfix() - assert m.variables["w"].attrs["integer"] - assert "w" not in m._relaxed_registry + m.variables["w"].fix() + m.variables["w"].relax() + assert not m.variables["w"].attrs["integer"] + assert m.variables["w"].fixed + assert m.variables["w"].relaxed - def test_fix_relax_continuous_noop(self, model_with_solution: Model) -> None: + def test_unfix_does_not_unrelax_integer(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["x"].fix(relax=True) - # Continuous variable should not be in registry - assert "x" not in m._relaxed_registry + m.variables["w"].fix() + m.variables["w"].relax() + m.variables["w"].unfix() + assert not m.variables["w"].fixed + assert m.variables["w"].relaxed + assert not m.variables["w"].attrs["integer"] class TestVariableFixed: @@ -227,25 +250,172 @@ def test_fix_binaries_only(self, model_with_solution: Model) -> None: assert m.variables["z"].fixed assert not m.variables["x"].fixed - def test_fixed_returns_dict(self, model_with_solution: Model) -> None: + def test_fixed_returns_container(self, model_with_solution: Model) -> None: m = model_with_solution m.variables["x"].fix(value=5.0) result = m.variables.fixed - assert isinstance(result, dict) - assert result["x"] is True - assert result["y"] is False + assert "x" in result + assert "y" not in result - def test_fix_relax_integers(self, model_with_solution: Model) -> None: + def test_fix_then_relax_integers(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables.integers.fix(relax=True) + m.variables.integers.fix() + m.variables.integers.relax() assert not m.variables["w"].attrs["integer"] - m.variables.integers.unfix() - # After unfix from the integers view, the variable should be restored - # but we need to unfix from the actual variable since integers view - # won't contain it anymore after relaxation - # Let's unfix via the model variables directly + assert m.variables["w"].fixed m.variables["w"].unfix() + assert not m.variables["w"].attrs["integer"] # still relaxed + m.variables["w"].unrelax() + assert m.variables["w"].attrs["integer"] + + +class TestVariableRelax: + def test_relax_binary(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + assert not m.variables["z"].attrs["binary"] + assert not m.variables["z"].attrs["integer"] + assert m.variables["z"].relaxed + assert m._relaxed_registry["z"] == "binary" + + def test_relax_integer(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["w"].relax() + assert not m.variables["w"].attrs["integer"] + assert not m.variables["w"].attrs["binary"] + assert m.variables["w"].relaxed + assert m._relaxed_registry["w"] == "integer" + + def test_relax_continuous_noop(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["x"].relax() + assert "x" not in m._relaxed_registry + assert not m.variables["x"].relaxed + + def test_relax_semi_continuous_raises(self) -> None: + m = Model() + m.add_variables(lower=1, upper=10, semi_continuous=True, name="sc") + with pytest.raises(NotImplementedError, match="semi-continuous"): + m.variables["sc"].relax() + + def test_unrelax_binary(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + m.variables["z"].unrelax() + assert m.variables["z"].attrs["binary"] + assert not m.variables["z"].relaxed + assert "z" not in m._relaxed_registry + + def test_unrelax_integer(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["w"].relax() + m.variables["w"].unrelax() + assert m.variables["w"].attrs["integer"] + assert not m.variables["w"].relaxed + assert "w" not in m._relaxed_registry + + def test_unrelax_noop_if_not_relaxed(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["x"].unrelax() + assert not m.variables["x"].relaxed + + def test_relax_preserves_binary_bounds(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + assert float(m.variables["z"].lower) == 0.0 + assert float(m.variables["z"].upper) == 1.0 + + def test_relax_preserves_integer_bounds(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["w"].relax() + assert float(m.variables["w"].lower) == 0.0 + assert float(m.variables["w"].upper) == 100.0 + + +class TestVariablesContainerRelax: + def test_relax_all(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.relax() + assert not m.variables["z"].attrs["binary"] + assert not m.variables["w"].attrs["integer"] + assert m.variables["z"].relaxed + assert m.variables["w"].relaxed + # Continuous variables unaffected + assert not m.variables["x"].relaxed + + def test_unrelax_all(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.relax() + m.variables.unrelax() + assert m.variables["z"].attrs["binary"] assert m.variables["w"].attrs["integer"] + assert not m.variables["z"].relaxed + assert not m.variables["w"].relaxed + + def test_relax_integers_only(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.integers.relax() + assert not m.variables["w"].attrs["integer"] + assert m.variables["w"].relaxed + # Binary should be untouched + assert m.variables["z"].attrs["binary"] + assert not m.variables["z"].relaxed + + def test_relax_binaries_only(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.binaries.relax() + assert not m.variables["z"].attrs["binary"] + assert m.variables["z"].relaxed + # Integer should be untouched + assert m.variables["w"].attrs["integer"] + assert not m.variables["w"].relaxed + + def test_relaxed_returns_container(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + result = m.variables.relaxed + assert "z" in result + assert "x" not in result + + def test_relax_with_semi_continuous_raises(self) -> None: + m = Model() + m.add_variables(lower=0, upper=10, name="x") + m.add_variables(lower=1, upper=10, semi_continuous=True, name="sc") + with pytest.raises(NotImplementedError, match="semi-continuous"): + m.variables.relax() + + def test_relaxed_view_unrelax(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables.relax() + assert len(m.variables.relaxed) == 2 + m.variables.relaxed.unrelax() + assert len(m.variables.relaxed) == 0 + assert m.variables["z"].attrs["binary"] + assert m.variables["w"].attrs["integer"] + + def test_fixed_view_unfix(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["x"].fix(value=5.0) + m.variables["z"].fix() + assert len(m.variables.fixed) == 2 + m.variables.fixed.unfix() + assert len(m.variables.fixed) == 0 + + def test_double_relax_is_idempotent(self, model_with_solution: Model) -> None: + m = model_with_solution + m.variables["z"].relax() + m.variables["z"].relax() + assert m._relaxed_registry["z"] == "binary" + m.variables["z"].unrelax() + assert m.variables["z"].attrs["binary"] + + def test_relax_all_converts_milp_to_lp(self, model_with_solution: Model) -> None: + m = model_with_solution + assert m.type == "MILP" + m.variables.relax() + assert m.type == "LP" + m.variables.unrelax() + assert m.type == "MILP" class TestRemoveVariablesCleansUpFix: @@ -257,7 +427,8 @@ def test_remove_fixed_variable(self, model_with_solution: Model) -> None: def test_remove_relaxed_variable(self, model_with_solution: Model) -> None: m = model_with_solution - m.variables["z"].fix(relax=True) + m.variables["z"].fix() + m.variables["z"].relax() m.remove_variables("z") assert "z" not in m._relaxed_registry assert f"{FIX_CONSTRAINT_PREFIX}z" not in m.constraints @@ -268,8 +439,10 @@ def test_relaxed_registry_survives_netcdf( self, model_with_solution: Model, tmp_path: Path ) -> None: m = model_with_solution - m.variables["z"].fix(relax=True) - m.variables["w"].fix(relax=True) + m.variables["z"].fix() + m.variables["z"].relax() + m.variables["w"].fix() + m.variables["w"].relax() path = tmp_path / "model.nc" m.to_netcdf(path) @@ -294,11 +467,11 @@ def test_empty_registry_netcdf( m2 = read_netcdf(path) assert m2._relaxed_registry == {} - def test_unfix_after_roundtrip( + def test_unrelax_after_roundtrip( self, model_with_solution: Model, tmp_path: Path ) -> None: m = model_with_solution - m.variables["z"].fix(relax=True) + m.variables["z"].relax() path = tmp_path / "model.nc" m.to_netcdf(path) @@ -306,7 +479,6 @@ def test_unfix_after_roundtrip( from linopy.io import read_netcdf m2 = read_netcdf(path) - m2.variables["z"].unfix() + m2.variables["z"].unrelax() assert m2.variables["z"].attrs["binary"] assert "z" not in m2._relaxed_registry - assert f"{FIX_CONSTRAINT_PREFIX}z" not in m2.constraints