diff --git a/doc/api.rst b/doc/api.rst index f0afc322..707ba610 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -136,9 +136,14 @@ Attributes Modification ------------ +``Variable.update`` is the canonical mutation API. The legacy ``lower`` / +``upper`` setters still forward to ``update`` but emit a +``DeprecationWarning`` and will be removed in a future release. + .. autosummary:: :toctree: generated/ + variables.Variable.update variables.Variable.fix variables.Variable.unfix variables.Variable.relax @@ -330,6 +335,19 @@ Structure constraints.Constraint.coeffs constraints.Constraint.vars +Modification +------------ + +``Constraint.update`` is the canonical mutation API. The legacy ``lhs`` / +``sign`` / ``rhs`` / ``coeffs`` / ``vars`` setters still forward to +``update`` but emit a ``DeprecationWarning`` and will be removed in a +future release. + +.. autosummary:: + :toctree: generated/ + + constraints.Constraint.update + Post-solve access ----------------- diff --git a/doc/release_notes.rst b/doc/release_notes.rst index edd4ed07..6165d5d5 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -55,6 +55,7 @@ Most users should keep calling ``model.solve(...)``. If you want more control, y **Deprecations** * ``Solver.solve_problem``, ``Solver.solve_problem_from_model``, and ``Solver.solve_problem_from_file`` still work but emit a ``DeprecationWarning``. Use ``Solver.from_name(...).solve()`` (or simply ``model.solve(...)``) instead. They will be removed in a future release. +* Mutation via assignment to ``Variable.lower`` / ``Variable.upper`` / ``Constraint.coeffs`` / ``Constraint.vars`` / ``Constraint.lhs`` / ``Constraint.sign`` / ``Constraint.rhs`` is deprecated and emits a ``DeprecationWarning``. Use ``Variable.update(...)`` / ``Constraint.update(...)`` instead — the canonical mutation API with one validation path and one place that flips the persistent-solver dirty flag. Read access to these properties is unchanged. The setters will be removed in a future release. **Bug Fixes** diff --git a/examples/creating-constraints.ipynb b/examples/creating-constraints.ipynb index 1b792b14..d504deb3 100644 --- a/examples/creating-constraints.ipynb +++ b/examples/creating-constraints.ipynb @@ -348,7 +348,7 @@ "\n", "`CSRConstraint` deliberately exposes a narrower API than the xarray-backed `Constraint`:\n", "\n", - "- **No in-place mutation.** Setters such as `con.coeffs = ...`, `con.vars = ...`, `con.sign = ...`, `con.rhs = ...`, and `con.lhs = ...` are only available on `Constraint`.\n", + "- **No in-place mutation.** `Constraint.update(...)` is only available on `Constraint`. (The legacy setters — `con.coeffs = ...`, `con.vars = ...`, `con.sign = ...`, `con.rhs = ...`, `con.lhs = ...` — still forward to `update` on `Constraint` but emit a `DeprecationWarning` and will be removed in a future release.)\n", "- **No label-based indexing.** `con.loc[...]` is only available on `Constraint`.\n", "- **Accessing `.coeffs` / `.vars` triggers reconstruction.** On a `CSRConstraint` these properties rebuild the full xarray `Dataset` on demand and emit a `PerformanceWarning`. For solver-oriented workflows prefer `con.to_matrix()` or work with the CSR data directly.\n", "\n", @@ -356,8 +356,8 @@ "\n", "```python\n", "con = m.constraints[\"my_constraint\"].mutable()\n", - "con.loc[{\"time\": 0}] # label-based indexing now available\n", - "con.rhs = 5 # mutation now available\n", + "con.loc[{\"time\": 0}] # label-based indexing now available\n", + "con.update(rhs=5) # mutation now available\n", "```" ] }, diff --git a/examples/manipulating-models.ipynb b/examples/manipulating-models.ipynb index 6903386b..eb1097ab 100644 --- a/examples/manipulating-models.ipynb +++ b/examples/manipulating-models.ipynb @@ -74,7 +74,7 @@ "metadata": {}, "outputs": [], "source": [ - "x.lower = 1" + "x.update(lower=1)" ] }, { @@ -83,7 +83,10 @@ "metadata": {}, "source": [ ".. note::\n", - " The same could have been achieved by calling `m.variables.x.lower = 1`\n", + " Assignment via the ``x.lower = 1`` setter still works but is\n", + " deprecated and will be removed in a future release. Use\n", + " ``Variable.update`` instead — it is the canonical mutation API\n", + " with a single validation path.\n", "\n", "Let's solve it again!" ] @@ -127,7 +130,7 @@ "metadata": {}, "outputs": [], "source": [ - "x.lower = xr.DataArray(range(10, 0, -1), coords=(time,))" + "x.update(lower=xr.DataArray(range(10, 0, -1), coords=(time,)))" ] }, { @@ -157,9 +160,12 @@ "source": [ "## Varying Constraints\n", "\n", - "A similar functionality is implemented for constraints. Here we can modify the left-hand-side, the sign and the right-hand-side.\n", + "A similar functionality is implemented for constraints. We use\n", + "``Constraint.update`` to change the left-hand-side, the sign,\n", + "and the right-hand-side.\n", "\n", - "Assume we want to relax the right-hand-side of the first constraint `con1` to `8 * factor`. This would translate to:" + "Assume we want to relax the right-hand-side of the first constraint\n", + "``con1`` to ``8 * factor``. This translates to:" ] }, { @@ -169,7 +175,7 @@ "metadata": {}, "outputs": [], "source": [ - "con1.rhs = 8 * factor" + "con1.update(rhs=8 * factor)" ] }, { @@ -178,7 +184,10 @@ "metadata": {}, "source": [ ".. note::\n", - " The same could have been achieved by calling `m.constraints.con1.rhs = 8 * factor`\n", + " Assignment via the ``con1.rhs = 8 * factor`` setter still works\n", + " but is deprecated and will be removed in a future release. Use\n", + " ``Constraint.update`` instead — it is the canonical mutation API\n", + " with a single validation path.\n", "\n", "Let's solve it again!" ] @@ -212,7 +221,7 @@ "metadata": {}, "outputs": [], "source": [ - "con1.lhs = 3 * x + 8 * y" + "con1.update(lhs=3 * x + 8 * y)" ] }, { @@ -221,9 +230,15 @@ "metadata": {}, "source": [ "**Note:**\n", - "The same could have been achieved by calling \n", - "```python \n", - "m.constraints['con1'].lhs = 3 * x + 8 * y\n", + "Assignment via the ``con1.lhs = 3 * x + 8 * y`` setter still works\n", + "but is deprecated and will be removed in a future release. Use\n", + "``Constraint.update`` instead — it is the canonical mutation API\n", + "with a single validation path.\n", + "\n", + "``Constraint.update`` also accepts a full constraint expression in one call:\n", + "\n", + "```python\n", + "con1.update(3 * x + 8 * y <= 8 * factor) # replaces lhs / sign / rhs at once\n", "```" ] }, diff --git a/linopy/constraints.py b/linopy/constraints.py index 6f11b137..1dd14dd9 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -55,7 +55,6 @@ maybe_group_terms_polars, maybe_replace_signs, replace_by_map, - require_constant, save_join, to_dataframe, to_polars, @@ -72,6 +71,7 @@ ) from linopy.types import ( ConstantLike, + ConstraintLike, CoordsLike, ExpressionLike, SignLike, @@ -1120,9 +1120,14 @@ def coeffs(self) -> DataArray: @coeffs.setter def coeffs(self, value: ConstantLike) -> None: - value = DataArray(value).broadcast_like(self.vars, exclude=[self.term_dim]) - self._data = assign_multiindex_safe(self.data, coeffs=value) - self._coef_dirty = True + """Syntactic sugar for :meth:`Constraint.update`. Do not add logic here; mutate via ``update`` so the contract stays single-sourced.""" + warn( + "Constraint.coeffs setter is deprecated and will be removed in a " + "future release; use Constraint.update(coeffs=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + self.update(coeffs=value) @property def vars(self) -> DataArray: @@ -1130,23 +1135,29 @@ def vars(self) -> DataArray: @vars.setter def vars(self, value: variables.Variable | DataArray) -> None: - if isinstance(value, variables.Variable): - value = value.labels - if not isinstance(value, DataArray): - raise TypeError("Expected value to be of type DataArray or Variable") - value = value.broadcast_like(self.coeffs, exclude=[self.term_dim]) - self._data = assign_multiindex_safe(self.data, vars=value) - self._coef_dirty = True + """Syntactic sugar for :meth:`Constraint.update`. Do not add logic here; mutate via ``update`` so the contract stays single-sourced.""" + warn( + "Constraint.vars setter is deprecated and will be removed in a " + "future release; use Constraint.update(variables=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + self.update(variables=value) @property def sign(self) -> DataArray: return self.data.sign @sign.setter - @require_constant def sign(self, value: SignLike) -> None: - value = maybe_replace_signs(DataArray(value)).broadcast_like(self.sign) - self._data = assign_multiindex_safe(self.data, sign=value) + """Syntactic sugar for :meth:`Constraint.update`. Do not add logic here; mutate via ``update`` so the contract stays single-sourced.""" + warn( + "Constraint.sign setter is deprecated and will be removed in a " + "future release; use Constraint.update(sign=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + self.update(sign=value) @property def rhs(self) -> DataArray: @@ -1154,15 +1165,14 @@ def rhs(self) -> DataArray: @rhs.setter def rhs(self, value: ExpressionLike | VariableLike | ConstantLike) -> None: - value = expressions.as_expression( - value, self.model, coords=self.coords, dims=self.coord_dims + """Syntactic sugar for :meth:`Constraint.update`. Do not add logic here; mutate via ``update`` so the contract stays single-sourced.""" + warn( + "Constraint.rhs setter is deprecated and will be removed in a " + "future release; use Constraint.update(rhs=...) instead.", + DeprecationWarning, + stacklevel=2, ) - residual = value.reset_const() - if residual.nterm == 0: - self._data = assign_multiindex_safe(self.data, rhs=value.const) - return - self.lhs = self.lhs - residual - self._data = assign_multiindex_safe(self.data, rhs=value.const) + self.update(rhs=value) @property def lhs(self) -> expressions.LinearExpression: @@ -1171,14 +1181,188 @@ def lhs(self) -> expressions.LinearExpression: @lhs.setter def lhs(self, value: ExpressionLike | VariableLike | ConstantLike) -> None: - value = expressions.as_expression( - value, self.model, coords=self.coords, dims=self.coord_dims + """Syntactic sugar for :meth:`Constraint.update`. Do not add logic here; mutate via ``update`` so the contract stays single-sourced.""" + warn( + "Constraint.lhs setter is deprecated and will be removed in a " + "future release; use Constraint.update(lhs=...) instead.", + DeprecationWarning, + stacklevel=2, ) + self.update(lhs=value) + + def _assign_lhs_expr( + self, expr: expressions.LinearExpression, rhs: DataArray | None = None + ) -> None: + """ + Internal: replace coeffs/vars from ``expr``, adjusting rhs for + the expression's constant part. Sets ``_coef_dirty``. + """ + base_rhs = self.rhs if rhs is None else rhs self._data = self.data.drop_vars(["coeffs", "vars"]).assign( - coeffs=value.coeffs, vars=value.vars, rhs=self.rhs - value.const + coeffs=expr.coeffs, + vars=expr.vars, + rhs=base_rhs - expr.const, ) self._coef_dirty = True + def update( + self, + constraint: ConstraintLike | None = None, + *, + lhs: ExpressionLike | VariableLike | ConstantLike | None = None, + rhs: ExpressionLike | VariableLike | ConstantLike | None = None, + sign: SignLike | None = None, + coeffs: ConstantLike | None = None, + variables: variables.Variable | DataArray | None = None, + ) -> Constraint: + """ + Update the constraint in place. + + The only mutation API; setters forward here. Two call shapes: + + * ``c.update(x + 5 <= 3)`` — pass a complete constraint + expression (mirroring ``add_constraints``). Replaces lhs, + sign, and rhs at once. + * ``c.update(lhs=, rhs=, sign=, coeffs=, variables=)`` — pass + only what you want to change. + + Use the keyword form for targeted changes — it skips the + unchanged attributes entirely. The positional form always + rewrites lhs / sign / rhs (and flips ``_coef_dirty``), so it + is the wrong shape for hot loops that only touch one part: + + .. code-block:: python + + # Hot loop, rhs is the only thing changing per iteration: + for k in scenarios: + c.update(rhs=rhs_k) # ← targeted, cheap + + # Same loop written positionally rebuilds lhs every + # iteration even though it never changes: + for k in scenarios: + c.update(big_lhs_expr <= rhs_k) # ← avoid + + Parameters + ---------- + constraint : ConstraintLike, optional + A complete constraint expression (e.g. ``x + 5 <= 3``). + Mutually exclusive with the keyword arguments below. + lhs : ExpressionLike / VariableLike / ConstantLike, optional + Replace the LHS expression. Any constant part is moved to + ``rhs`` so ``c.lhs`` stays pure-variable. Cannot be combined + with ``coeffs`` / ``variables``. Sets the internal + ``_coef_dirty`` flag. + rhs : ExpressionLike / VariableLike / ConstantLike, optional + New right-hand side. + + * Constant rhs (scalar, array, DataArray) → assigned directly + to ``c.rhs``; ``c.lhs`` is untouched. + * Variable / Expression rhs → rearranged onto the lhs to + preserve the invariant that ``c.rhs`` is constant-only, + matching ``add_constraints``. **This rewrites ``c.lhs``.** + + Example — the two calls below produce the same final state:: + + # Form A: explicit, only changes rhs + c.update(rhs=5) + + # Form B: rhs carries a variable, so lhs is rewritten too. + # Starting from `2*x <= 3`, this gives `2*x - y <= 5`: + c.update(rhs=y + 5) + + If you want the rewrite to be loud, use the positional form + (``c.update(2*x - y <= 5)``) which makes both sides explicit. + sign : SignLike, optional + New sign. One of ``"<=" / "==" / ">="`` (or their ``< > =`` + aliases). + coeffs : ConstantLike, optional + Replace coefficient values (same sparsity / term structure). + Lower-level than ``lhs=``; sets ``_coef_dirty``. + variables : Variable / DataArray, optional + Replace variable label array (same sparsity / term + structure). Lower-level than ``lhs=``; sets ``_coef_dirty``. + Mirrors the ``c.vars`` attribute; spelled out here to avoid + shadowing Python's ``vars()`` builtin in the kwarg name. + + Returns + ------- + Constraint + ``self`` for chaining. + """ + if constraint is not None: + if any(x is not None for x in (lhs, rhs, sign, coeffs, variables)): + raise TypeError( + "Constraint.update: positional `constraint` argument " + "cannot be combined with keyword arguments." + ) + if isinstance(constraint, AnonymousScalarConstraint): + con = constraint.to_constraint() + elif isinstance(constraint, ConstraintBase): + con = constraint + else: + raise TypeError( + "Constraint.update: positional argument must be a " + "ConstraintLike (e.g. `x + 5 <= 3`); got " + f"{type(constraint).__name__}." + ) + lhs, sign, rhs = con.lhs, con.sign, con.rhs + + if all(v is None for v in (lhs, rhs, sign, coeffs, variables)): + return self + + if lhs is not None and (coeffs is not None or variables is not None): + raise TypeError( + "Constraint.update: pass either `lhs=` (replace the whole " + "expression) or `coeffs=` / `variables=` (partial array " + "replacement), not both." + ) + + # 1. lhs replacement first so subsequent rhs= rearrangement sees the new lhs. + if lhs is not None: + self._assign_lhs_expr( + expressions.as_expression( + lhs, self.model, coords=self.coords, dims=self.coord_dims + ) + ) + + # 2. rhs (rearranges non-constant part onto lhs). + if rhs is not None: + expr = expressions.as_expression( + rhs, self.model, coords=self.coords, dims=self.coord_dims + ) + residual = expr.reset_const() + if residual.nterm != 0: + self._assign_lhs_expr(self.lhs - residual, rhs=expr.const) + else: + self._data = assign_multiindex_safe(self.data, rhs=expr.const) + + # 3. coeffs / variables partial updates (only valid without lhs=). + if coeffs is not None: + new_coeffs = DataArray(coeffs).broadcast_like( + self.vars, exclude=[self.term_dim] + ) + self._data = assign_multiindex_safe(self.data, coeffs=new_coeffs) + self._coef_dirty = True + if variables is not None: + from linopy.variables import Variable as _Variable + + v = variables.labels if isinstance(variables, _Variable) else variables + if not isinstance(v, DataArray): + raise TypeError( + "Constraint.update(variables=...) expects a DataArray or " + f"Variable; got {type(variables).__name__}." + ) + new_vars = v.broadcast_like(self.coeffs, exclude=[self.term_dim]) + self._data = assign_multiindex_safe(self.data, vars=new_vars) + self._coef_dirty = True + + # 4. sign last so it composes cleanly with the rest. + if sign is not None: + new_sign = maybe_replace_signs(DataArray(sign)).broadcast_like(self.sign) + self._data = assign_multiindex_safe(self.data, sign=new_sign) + + return self + @property @has_optimized_model def dual(self) -> DataArray: @@ -1281,9 +1465,10 @@ def to_matrix_with_rhs( def sanitize_zeros(self) -> Constraint: """Remove terms with zero or near-zero coefficients.""" not_zero = abs(self.coeffs) > 1e-10 - self.vars = self.vars.where(not_zero, -1) - self.coeffs = self.coeffs.where(not_zero) - return self + return self.update( + variables=self.vars.where(not_zero, -1), + coeffs=self.coeffs.where(not_zero), + ) def sanitize_missings(self) -> Constraint: """Mask out rows where all variables are missing (-1).""" diff --git a/linopy/persistent/diff.py b/linopy/persistent/diff.py index 46a866f2..f100c75e 100644 --- a/linopy/persistent/diff.py +++ b/linopy/persistent/diff.py @@ -343,7 +343,7 @@ def from_snapshot( cls, snapshot: ModelSnapshot, model: Model, - same_model: bool = True, + same_model: bool = False, ignore_dims: Iterable[str] = (), ) -> ModelDiff: """ @@ -353,6 +353,14 @@ def from_snapshot( a mismatch triggers ``RebuildReason.COORD_REINDEX``. Pass ``ignore_dims={"snapshot"}`` for rolling-horizon use cases where the snapshot coord legitimately shifts between solves. + + ``same_model`` is a perf hint, **default False**. When True, the + diff trusts ``Constraint._coef_dirty`` to short-circuit the CSR + walk for unchanged containers (`skip_coef_compare`). That's only + safe if every coefficient mutation went through ``Constraint.update`` + (or the setters that forward there) — direct ``c.coeffs.values[...]`` + writes bypass the flag and would silently miss changes. Pass + ``same_model=True`` only when you own the mutation contract. """ ignored = frozenset(ignore_dims) check_coords = True diff --git a/linopy/variables.py b/linopy/variables.py index cbf2fb87..0f0826b8 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -48,7 +48,6 @@ get_label_position, has_optimized_model, iterate_slices, - require_constant, save_join, set_int_index, to_dataframe, @@ -891,18 +890,18 @@ def upper(self) -> DataArray: return self.data.upper @upper.setter - @require_constant def upper(self, value: ConstantLike) -> None: """ - Set the upper bounds of the variables. - - The function raises an error in case no model is set as a - reference. + Syntactic sugar for :meth:`Variable.update`. Do not add logic + here; mutate via ``update`` so the contract stays single-sourced. """ - value = DataArray(value).broadcast_like(self.upper) - if not set(value.dims).issubset(self.model.variables[self.name].dims): - raise ValueError("Cannot assign new dimensions to existing variable.") - self._data = assign_multiindex_safe(self.data, upper=value) + warn( + "Variable.upper setter is deprecated and will be removed in a " + "future release; use Variable.update(upper=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + self.update(upper=value) @property def lower(self) -> DataArray: @@ -915,18 +914,96 @@ def lower(self) -> DataArray: return self.data.lower @lower.setter - @require_constant def lower(self, value: ConstantLike) -> None: """ - Set the lower bounds of the variables. + Syntactic sugar for :meth:`Variable.update`. Do not add logic + here; mutate via ``update`` so the contract stays single-sourced. + """ + warn( + "Variable.lower setter is deprecated and will be removed in a " + "future release; use Variable.update(lower=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + self.update(lower=value) - The function raises an error in case no model is set as a - reference. + def update( + self, + *, + lower: ConstantLike | None = None, + upper: ConstantLike | None = None, + ) -> Variable: + """ + Update variable bounds in place. + + Canonical mutation API. Validation and coord alignment live here. + Single-attribute setters (`var.lower = …`) forward to this method. + + Parameters + ---------- + lower : ConstantLike, optional + New lower bound. Accepts any constant — scalars, numpy + arrays, pandas Series / DataFrame, xarray DataArray (e.g. + time-varying bounds). Aligned via xarray broadcast against + the variable's existing shape; new dims are rejected. + Decision variables / linear expressions are not accepted. + upper : ConstantLike, optional + New upper bound. Same. + + Returns + ------- + Variable + ``self`` for chaining. + + Raises + ------ + TypeError + If either bound is a Variable / Expression (bounds must be + numeric, not symbolic). + ValueError + If the new bound introduces dimensions not in the variable's + coords, or if the resulting ``lower > upper`` anywhere. """ - value = DataArray(value).broadcast_like(self.lower) - if not set(value.dims).issubset(self.model.variables[self.name].dims): - raise ValueError("Cannot assign new dimensions to existing variable.") - self._data = assign_multiindex_safe(self.data, lower=value) + if lower is None and upper is None: + return self + + from linopy import expressions + + non_constant = ( + Variable, + ScalarVariable, + expressions.LinearExpression, + expressions.QuadraticExpression, + ) + for name, val in (("lower", lower), ("upper", upper)): + if val is not None and isinstance(val, non_constant): + raise TypeError( + f"Variable.update({name}=...) must be a constant; " + f"got {type(val).__name__}." + ) + + updates: dict[str, DataArray] = {} + own_dims = self.model.variables[self.name].dims + if lower is not None: + new_lower = DataArray(lower).broadcast_like(self.lower) + if not set(new_lower.dims).issubset(own_dims): + raise ValueError("Cannot assign new dimensions to existing variable.") + updates["lower"] = new_lower + if upper is not None: + new_upper = DataArray(upper).broadcast_like(self.upper) + if not set(new_upper.dims).issubset(own_dims): + raise ValueError("Cannot assign new dimensions to existing variable.") + updates["upper"] = new_upper + + final_lower = updates.get("lower", self.lower) + final_upper = updates.get("upper", self.upper) + if bool((final_lower > final_upper).any()): + raise ValueError( + "Variable.update would leave lower > upper at one or more coordinates." + ) + + self._data = assign_multiindex_safe(self.data, **updates) + return self @property @has_optimized_model diff --git a/test/test_constraint.py b/test/test_constraint.py index 690da8f6..27f2ff7f 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -426,6 +426,115 @@ def test_constraint_rhs_setter(mc: linopy.constraints.Constraint) -> None: assert mc.sizes == sizes +def test_constraint_update_rhs_and_sign(mc: linopy.constraints.Constraint) -> None: + mc.update(rhs=5, sign=EQUAL) + assert (mc.rhs == 5).all() + assert (mc.sign == EQUAL).all() + + +def test_constraint_update_no_kwargs_is_noop( + mc: linopy.constraints.Constraint, +) -> None: + old_rhs = mc.rhs.copy() + old_sign = mc.sign.copy() + mc.update() + assert (mc.rhs == old_rhs).all() + assert (mc.sign == old_sign).all() + + +def test_constraint_update_rearranges_variable_rhs( + mc: linopy.constraints.Constraint, x: linopy.Variable +) -> None: + """ + Variable / Expression rhs is moved onto lhs; only the constant + part lands on rhs (mirrors add_constraints and the .rhs setter). + """ + mc.update(rhs=x + 3) + assert (mc.rhs == 3).all() + assert mc.lhs.nterm == 2 # original term + the rearranged -x + + +def test_constraint_update_returns_self( + mc: linopy.constraints.Constraint, +) -> None: + out = mc.update(rhs=7) + assert out is mc + + +def test_constraint_update_positional_constraint_expression( + mc: linopy.constraints.Constraint, x: linopy.Variable, y: linopy.Variable +) -> None: + """``c.update(x + 5 <= 3)`` replaces lhs / sign / rhs in one call.""" + mc.update(x + y <= 7) + assert (mc.rhs == 7).all() + assert (mc.sign == LESS_EQUAL).all() + assert mc.lhs.nterm == 2 + + +def test_constraint_update_positional_rejects_mixing_kwargs( + mc: linopy.constraints.Constraint, x: linopy.Variable +) -> None: + """Positional constraint can't be combined with keyword updates.""" + with pytest.raises(TypeError, match="cannot be combined with keyword"): + mc.update(x <= 3, sign=EQUAL) + + +def test_constraint_update_positional_rejects_non_constraint( + mc: linopy.constraints.Constraint, +) -> None: + """Random objects are rejected with a clear error.""" + with pytest.raises(TypeError, match="must be a ConstraintLike"): + mc.update("not a constraint") # type: ignore + + +def test_constraint_update_lhs_only( + mc: linopy.constraints.Constraint, x: linopy.Variable, y: linopy.Variable +) -> None: + """lhs= alone replaces the expression; rhs and sign untouched.""" + old_rhs = mc.rhs.copy() + old_sign = mc.sign.copy() + mc.update(lhs=5 * x + 7 * y) + assert (mc.rhs == old_rhs).all() + assert (mc.sign == old_sign).all() + assert mc.lhs.nterm == 2 + + +def test_constraint_update_coeffs_only_keeps_values( + mc: linopy.constraints.Constraint, +) -> None: + """coeffs= alone replaces the coef array element-wise; vars untouched.""" + old_vars = mc.vars.copy() + mc.update(coeffs=mc.coeffs * 10) + assert (mc.vars == old_vars).all() + # original was mc.lhs with leading coeff; *10 → all coeffs *10 + assert mc.coeffs.max() >= 10 + + +def test_constraint_update_lhs_and_sign_together( + mc: linopy.constraints.Constraint, x: linopy.Variable +) -> None: + """Compound updates compose: lhs replacement + sign flip in one call.""" + mc.update(lhs=2 * x, sign=EQUAL) + assert (mc.sign == EQUAL).all() + assert mc.lhs.nterm == 1 + + +def test_constraint_update_lhs_and_coeffs_rejected( + mc: linopy.constraints.Constraint, x: linopy.Variable +) -> None: + """lhs= (full replacement) and coeffs= (partial) are mutually exclusive.""" + with pytest.raises(TypeError, match="lhs.*coeffs.*variables"): + mc.update(lhs=2 * x, coeffs=mc.coeffs * 2) + + +def test_constraint_update_lhs_and_variables_rejected( + mc: linopy.constraints.Constraint, x: linopy.Variable +) -> None: + """lhs= (full replacement) and variables= (partial) are mutually exclusive.""" + with pytest.raises(TypeError, match="lhs.*coeffs.*variables"): + mc.update(lhs=2 * x, variables=mc.vars) + + def test_constraint_rhs_setter_with_variable( mc: linopy.constraints.Constraint, x: linopy.Variable ) -> None: diff --git a/test/test_constraint_coef_dirty.py b/test/test_constraint_coef_dirty.py index 682eb6d8..83b956b7 100644 --- a/test/test_constraint_coef_dirty.py +++ b/test/test_constraint_coef_dirty.py @@ -19,55 +19,72 @@ def test_initial_coef_dirty_false(m_with_c: tuple[Model, str]) -> None: assert m.constraints[name]._coef_dirty is False -def test_coeffs_setter_sets_dirty(m_with_c: tuple[Model, str]) -> None: +def test_update_coeffs_sets_dirty(m_with_c: tuple[Model, str]) -> None: m, name = m_with_c c = m.constraints[name] - c.coeffs = c.coeffs * 2 + c.update(coeffs=c.coeffs * 2) assert c._coef_dirty is True -def test_vars_setter_sets_dirty(m_with_c: tuple[Model, str]) -> None: +def test_update_variables_sets_dirty(m_with_c: tuple[Model, str]) -> None: m, name = m_with_c c = m.constraints[name] - c.vars = c.vars + c.update(variables=c.vars) assert c._coef_dirty is True -def test_lhs_setter_sets_dirty(m_with_c: tuple[Model, str]) -> None: +def test_update_lhs_sets_dirty(m_with_c: tuple[Model, str]) -> None: m, name = m_with_c c = m.constraints[name] x = m.variables["x"] - c.lhs = 3 * x + c.update(lhs=3 * x) assert c._coef_dirty is True -def test_pure_constant_rhs_short_circuits(m_with_c: tuple[Model, str]) -> None: +def test_update_pure_constant_rhs_short_circuits(m_with_c: tuple[Model, str]) -> None: m, name = m_with_c c = m.constraints[name] coeffs_buf = c.data["coeffs"].values vars_buf = c.data["vars"].values - c.rhs = 9 + c.update(rhs=9) assert c._coef_dirty is False assert c.data["coeffs"].values is coeffs_buf assert c.data["vars"].values is vars_buf -def test_rhs_with_variable_sets_dirty(m_with_c: tuple[Model, str]) -> None: +def test_update_rhs_with_variable_sets_dirty(m_with_c: tuple[Model, str]) -> None: m, name = m_with_c c = m.constraints[name] x = m.variables["x"] - c.rhs = x + 3 + c.update(rhs=x + 3) assert c._coef_dirty is True -def test_sign_setter_does_not_set_dirty(m_with_c: tuple[Model, str]) -> None: +def test_update_sign_does_not_set_dirty(m_with_c: tuple[Model, str]) -> None: m, name = m_with_c c = m.constraints[name] - c.sign = "<=" + c.update(sign="<=") assert c._coef_dirty is False def test_flag_persists_across_container_access(m_with_c: tuple[Model, str]) -> None: m, name = m_with_c - m.constraints[name].coeffs = m.constraints[name].coeffs * 2 + m.constraints[name].update(coeffs=m.constraints[name].coeffs * 2) assert m.constraints[name]._coef_dirty is True + + +def test_update_positional_constraint_sets_dirty(m_with_c: tuple[Model, str]) -> None: + """Positional ``c.update(expr <= rhs)`` rewrites lhs and must flip the flag.""" + m, name = m_with_c + c = m.constraints[name] + x = m.variables["x"] + c.update(4 * x >= 7) + assert c._coef_dirty is True + + +def test_update_noop_does_not_set_dirty(m_with_c: tuple[Model, str]) -> None: + """``c.update()`` with no args is a no-op and must not flip the flag.""" + m, name = m_with_c + c = m.constraints[name] + c.update() + assert c._coef_dirty is False diff --git a/test/test_variable.py b/test/test_variable.py index b14b746e..110cf31c 100644 --- a/test/test_variable.py +++ b/test/test_variable.py @@ -186,6 +186,60 @@ def test_variable_lower_setter_with_array_invalid_dim(x: linopy.Variable) -> Non x.lower = lower +def test_variable_update_bounds(z: linopy.Variable) -> None: + z.update(lower=2, upper=20) + assert z.lower.item() == 2 + assert z.upper.item() == 20 + + +def test_variable_update_lower_only(z: linopy.Variable) -> None: + z.update(lower=3) + assert z.lower.item() == 3 + assert z.upper.item() == 10 # unchanged from fixture default + + +def test_variable_update_no_kwargs_is_noop(z: linopy.Variable) -> None: + old_lower, old_upper = z.lower.item(), z.upper.item() + z.update() + assert z.lower.item() == old_lower + assert z.upper.item() == old_upper + + +def test_variable_update_rejects_inverted_bounds(z: linopy.Variable) -> None: + with pytest.raises(ValueError, match="lower > upper"): + z.update(lower=20, upper=5) + + +def test_variable_update_rejects_non_constant(z: linopy.Variable) -> None: + with pytest.raises(TypeError, match="must be a constant"): + z.update(upper=z) + + +def test_variable_update_returns_self(z: linopy.Variable) -> None: + out = z.update(lower=1) + assert out is z + + +def test_variable_update_array_invalid_dim(x: linopy.Variable) -> None: + with pytest.raises(ValueError): + x.update(lower=pd.Series(range(15, 25))) + + +def test_variable_update_upper_only(z: linopy.Variable) -> None: + """upper= alone changes upper; lower untouched.""" + old_lower = z.lower.copy() + z.update(upper=25) + assert (z.upper == 25).all() + assert (z.lower == old_lower).all() + + +def test_variable_update_with_array(x: linopy.Variable) -> None: + """Array bound that aligns on the variable's coord is accepted.""" + lower = pd.Series(range(10, 20), index=pd.RangeIndex(10, name="first")) + x.update(lower=lower) + np.testing.assert_array_equal(x.lower.values, lower.values) + + def test_variable_sum(x: linopy.Variable) -> None: res = x.sum() assert res.nterm == 10