From e3fff12bd094a1a97018e9f5a4fbbbeb16c47bb4 Mon Sep 17 00:00:00 2001 From: Ignacia Date: Sat, 22 Aug 2026 23:38:08 +0200 Subject: [PATCH 01/26] feat(constraints.py): add scaffold for 'soften' method --- linopy/constraints.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/linopy/constraints.py b/linopy/constraints.py index b6f55f10..7fdb5c11 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1724,6 +1724,44 @@ def from_rule(cls, model: Model, rule: Callable, coords: CoordsLike) -> Constrai data = lhs.data.assign(sign=sign, rhs=rhs) return cls(data, model=model) + def soften(self, + penalty: ConstantLike, + *, + max_violation: ConstantLike | None = None, + name: str | None = None, + ) -> VariableLike | tuple[VariableLike, VariableLike]: + """ + Soften a constraint, adding a slack variable and a penalty to the objective function. + + Parameters + ---------- + penalty : constant-like + The penalty that will match the slack variable inside the objective function. + max_violation: constant-like + The max violation possible that caps the slack (upper bound). If None, the slack will be unbounded. + name: string + The name for the slack variable. If None, it well reuse the constraint name and add a '_slack'. + + Returns + ------- + variable-like, or a tuple of variables for constraints of the type 'equality'. + + Examples + -------- + >>> from linopy import Model + >>> import pandas as pd + + >>> m = Model() + >>> investments = pd.Index(["A", "B", "C"], name="investments") + >>> budget_penalty = 2 + + >>> w = m.add_variables(lower=0, upper=1, coords=[investments], name="weights") + >>> budget_constraint = m.add_constraints(w.sum() == 1, name="budget") + >>> budget_slack = budget_constraint.soften(penalty=budget_penalty) + """ + pass + + def to_polars(self) -> pl.DataFrame: """ Convert the constraint to a polars DataFrame. From 2aab285ddf6741edbbd64b03c45bd1c5e07e20db Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:30:06 +0200 Subject: [PATCH 02/26] feat: first commit for soften method --- linopy/constraints.py | 54 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index 7fdb5c11..e8a23126 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1736,7 +1736,7 @@ def soften(self, Parameters ---------- penalty : constant-like - The penalty that will match the slack variable inside the objective function. + The penalty that will match the slack variable inside the objective function. Must be bigger than 0. max_violation: constant-like The max violation possible that caps the slack (upper bound). If None, the slack will be unbounded. name: string @@ -1744,7 +1744,7 @@ def soften(self, Returns ------- - variable-like, or a tuple of variables for constraints of the type 'equality'. + variable-like, or a tuple of variables for constraints of type 'equality'. Examples -------- @@ -1753,13 +1753,57 @@ def soften(self, >>> m = Model() >>> investments = pd.Index(["A", "B", "C"], name="investments") + >>> expected_return = pd.Series([0.08, 0.03, 0.1], index=investments, name="expected_return") + >>> w = m.add_variables(lower=0, upper=1, coords=[investments], name="weights") + >>> m.add_objective((expected_return * w).sum(), sense="max") >>> budget_penalty = 2 - >>> w = m.add_variables(lower=0, upper=1, coords=[investments], name="weights") >>> budget_constraint = m.add_constraints(w.sum() == 1, name="budget") - >>> budget_slack = budget_constraint.soften(penalty=budget_penalty) + >>> budget_positive_slack, budget_negative_slack = budget_constraint.soften(penalty=budget_penalty) """ - pass + # Verify valid penalty to continue: + assert (penalty >= 0).all() if hasattr(penalty, "all") else penalty >= 0, "Penalty is not positive." + + # Assert the existence of the objective function before using soften method (this is to avoid + # `add_objective` overwriting the penalty term added below, since it replaces rather than merges): + model = self.model + assert not model.objective.expression.empty, ( + "Objective must be defined via `model.add_objective` before calling `soften` on constraints." + ) + + name = name or f"{self.name}_slack" + upper = np.inf if max_violation is None else max_violation + + positive_slack = model.add_variables(lower=0, + upper=upper, + coords=self.lhs.coords, + mask=self.mask, + name=f"{name}_pos", + ) + slack = positive_slack + + # Update left hand side depending on the sign of the constraint: + if self.sign == "<=": + self.lhs = self.lhs - positive_slack + elif self.sign == ">=": + self.lhs = self.lhs + positive_slack + else: + negative_slack = model.add_variables(lower=0, upper=upper, coords=self.lhs.coords, mask=self.mask, + name=f"{name}_neg") + self.lhs = self.lhs - positive_slack + negative_slack + slack = (positive_slack, negative_slack) + + # Update objective function: + constraint_violation = slack[0] + slack[1] if isinstance(slack, tuple) else slack + direction = 1 if model.sense == "min" else -1 + model.objective += direction * (penalty * constraint_violation).sum() + + return slack + + + + + def to_polars(self) -> pl.DataFrame: From 462d5686345a98d17f3722e99bb1fc9f079e50a6 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:40:31 +0200 Subject: [PATCH 03/26] refactor(constraints.py): return Slack NamedTuple from soften() to avoid alternating between a bare Variable and a tuple of Variables depending on the constraint's sign. Negative is now None for inequality constraints instead of being absent from the return. --- linopy/constraints.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index e8a23126..e01f65c4 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -15,6 +15,7 @@ from typing import ( TYPE_CHECKING, Any, + NamedTuple, overload, ) from warnings import warn @@ -1151,6 +1152,18 @@ def from_mutable( ) +class Slack(NamedTuple): + """ + Slack variable(s) added by :meth:`Constraint.soften`. + + `negative` is None for inequality constraints, since those only + need one slack variable to absorb a violation in a single direction. + """ + + positive: VariableLike + negative: VariableLike | None + + class Constraint(ConstraintBase): """ Constraint backed by an xarray Dataset. @@ -1729,7 +1742,7 @@ def soften(self, *, max_violation: ConstantLike | None = None, name: str | None = None, - ) -> VariableLike | tuple[VariableLike, VariableLike]: + ) -> Slack: """ Soften a constraint, adding a slack variable and a penalty to the objective function. @@ -1744,7 +1757,9 @@ def soften(self, Returns ------- - variable-like, or a tuple of variables for constraints of type 'equality'. + Slack + Named tuple with the ``positive`` slack variable, and the ``negative`` one + for equality constraints (``None`` for inequality constraints). Examples -------- @@ -1759,7 +1774,7 @@ def soften(self, >>> budget_penalty = 2 >>> budget_constraint = m.add_constraints(w.sum() == 1, name="budget") - >>> budget_positive_slack, budget_negative_slack = budget_constraint.soften(penalty=budget_penalty) + >>> slack = budget_constraint.soften(penalty=budget_penalty) """ # Verify valid penalty to continue: assert (penalty >= 0).all() if hasattr(penalty, "all") else penalty >= 0, "Penalty is not positive." @@ -1780,7 +1795,7 @@ def soften(self, mask=self.mask, name=f"{name}_pos", ) - slack = positive_slack + negative_slack = None # Update left hand side depending on the sign of the constraint: if self.sign == "<=": @@ -1791,14 +1806,13 @@ def soften(self, negative_slack = model.add_variables(lower=0, upper=upper, coords=self.lhs.coords, mask=self.mask, name=f"{name}_neg") self.lhs = self.lhs - positive_slack + negative_slack - slack = (positive_slack, negative_slack) # Update objective function: - constraint_violation = slack[0] + slack[1] if isinstance(slack, tuple) else slack + constraint_violation = positive_slack + negative_slack if negative_slack is not None else positive_slack direction = 1 if model.sense == "min" else -1 model.objective += direction * (penalty * constraint_violation).sum() - return slack + return Slack(positive=positive_slack, negative=negative_slack) From 93eeb04de0eb909d2d4961c7b4ca39db200e234b Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:54:11 +0200 Subject: [PATCH 04/26] fix(objective.py): widen Objective.__add__(which only declared int as a scalar operand) to accept ConstantLike. The narrow annotation caused mypy to flag valid code --- linopy/objective.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linopy/objective.py b/linopy/objective.py index 67d141c8..6fa522bb 100644 --- a/linopy/objective.py +++ b/linopy/objective.py @@ -252,7 +252,7 @@ def to_matrix(self, *args: Any, **kwargs: Any) -> csc_matrix: sel = objwrap(expressions.LinearExpression.sel) def __add__( - self, expr: int | QuadraticExpression | LinearExpression | Objective + self, expr: ConstantLike | QuadraticExpression | LinearExpression | Objective ) -> Objective: if isinstance(expr, Objective): expr = expr.expression From 570f754aa3ab8f6170a8b87c9506856d89aef39a Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:45:18 +0200 Subject: [PATCH 05/26] delete spaces --- linopy/constraints.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index e01f65c4..570c9001 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1813,13 +1813,7 @@ def soften(self, model.objective += direction * (penalty * constraint_violation).sum() return Slack(positive=positive_slack, negative=negative_slack) - - - - - - - + def to_polars(self) -> pl.DataFrame: """ Convert the constraint to a polars DataFrame. From f09407796d4463da39e97829f4e598083b78a142 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:45:59 +0000 Subject: [PATCH 06/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- linopy/constraints.py | 51 ++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index 570c9001..d8833f82 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1737,12 +1737,13 @@ def from_rule(cls, model: Model, rule: Callable, coords: CoordsLike) -> Constrai data = lhs.data.assign(sign=sign, rhs=rhs) return cls(data, model=model) - def soften(self, - penalty: ConstantLike, - *, - max_violation: ConstantLike | None = None, - name: str | None = None, - ) -> Slack: + def soften( + self, + penalty: ConstantLike, + *, + max_violation: ConstantLike | None = None, + name: str | None = None, + ) -> Slack: """ Soften a constraint, adding a slack variable and a penalty to the objective function. @@ -1768,7 +1769,9 @@ def soften(self, >>> m = Model() >>> investments = pd.Index(["A", "B", "C"], name="investments") - >>> expected_return = pd.Series([0.08, 0.03, 0.1], index=investments, name="expected_return") + >>> expected_return = pd.Series( + ... [0.08, 0.03, 0.1], index=investments, name="expected_return" + ... ) >>> w = m.add_variables(lower=0, upper=1, coords=[investments], name="weights") >>> m.add_objective((expected_return * w).sum(), sense="max") >>> budget_penalty = 2 @@ -1777,7 +1780,9 @@ def soften(self, >>> slack = budget_constraint.soften(penalty=budget_penalty) """ # Verify valid penalty to continue: - assert (penalty >= 0).all() if hasattr(penalty, "all") else penalty >= 0, "Penalty is not positive." + assert (penalty >= 0).all() if hasattr(penalty, "all") else penalty >= 0, ( + "Penalty is not positive." + ) # Assert the existence of the objective function before using soften method (this is to avoid # `add_objective` overwriting the penalty term added below, since it replaces rather than merges): @@ -1789,12 +1794,13 @@ def soften(self, name = name or f"{self.name}_slack" upper = np.inf if max_violation is None else max_violation - positive_slack = model.add_variables(lower=0, - upper=upper, - coords=self.lhs.coords, - mask=self.mask, - name=f"{name}_pos", - ) + positive_slack = model.add_variables( + lower=0, + upper=upper, + coords=self.lhs.coords, + mask=self.mask, + name=f"{name}_pos", + ) negative_slack = None # Update left hand side depending on the sign of the constraint: @@ -1803,17 +1809,26 @@ def soften(self, elif self.sign == ">=": self.lhs = self.lhs + positive_slack else: - negative_slack = model.add_variables(lower=0, upper=upper, coords=self.lhs.coords, mask=self.mask, - name=f"{name}_neg") + negative_slack = model.add_variables( + lower=0, + upper=upper, + coords=self.lhs.coords, + mask=self.mask, + name=f"{name}_neg", + ) self.lhs = self.lhs - positive_slack + negative_slack # Update objective function: - constraint_violation = positive_slack + negative_slack if negative_slack is not None else positive_slack + constraint_violation = ( + positive_slack + negative_slack + if negative_slack is not None + else positive_slack + ) direction = 1 if model.sense == "min" else -1 model.objective += direction * (penalty * constraint_violation).sum() return Slack(positive=positive_slack, negative=negative_slack) - + def to_polars(self) -> pl.DataFrame: """ Convert the constraint to a polars DataFrame. From a56163a488e16b739e20168c6249bd00dc9d2ccb Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:41:06 +0200 Subject: [PATCH 07/26] fix(constraints.py): guard Constraint.soften() against non-scalar sign arrays (FOR NOW) --- linopy/constraints.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index d8833f82..4ab19f8b 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1759,8 +1759,8 @@ def soften( Returns ------- Slack - Named tuple with the ``positive`` slack variable, and the ``negative`` one - for equality constraints (``None`` for inequality constraints). + Named tuple with the `positive` slack variable, and the `negative` one for equality + constraints (`None` for inequality constraints). Examples -------- @@ -1804,9 +1804,16 @@ def soften( negative_slack = None # Update left hand side depending on the sign of the constraint: - if self.sign == "<=": + sign_values = pd.unique(self.sign.values.ravel()) + if len(sign_values) > 1: + raise NotImplementedError( + "Constraint.soften does not support constraints with mixed signs." + ) + sign = sign_values.item() + + if sign == "<=": self.lhs = self.lhs - positive_slack - elif self.sign == ">=": + elif sign == ">=": self.lhs = self.lhs + positive_slack else: negative_slack = model.add_variables( From a94180295fda4ebdba06302d095a2b7f5d2422eb Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:32:04 +0200 Subject: [PATCH 08/26] fix(constraints.py): make Constraint.soften() raise on detached constraints & add unit test --- linopy/constraints.py | 9 +++++++++ test/test_constraint.py | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/linopy/constraints.py b/linopy/constraints.py index 4ab19f8b..cab45533 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1791,6 +1791,15 @@ def soften( "Objective must be defined via `model.add_objective` before calling `soften` on constraints." ) + # A detached copy of the constraint (e.g. from `.mutable()`, `.sel()`, `.isel()`) isn't in + # `model.constraints`, so .soften would silently do nothing on the real model. This check is to avoid that: + if model.constraints.data.get(self.name) is not self: + raise ValueError( + f"Constraint {self.name!r} is not the constraint registered in the model, so " + "`soften` would not affect it (it may be a detached copy from `.mutable()`, " + "`.sel()`, or `.isel()`). Call `soften` on `model.constraints[name]` directly." + ) + name = name or f"{self.name}_slack" upper = np.inf if max_violation is None else max_violation diff --git a/test/test_constraint.py b/test/test_constraint.py index 7fe50a57..c4ca9cfd 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -1059,3 +1059,15 @@ def bound(m: Model, i: int) -> AnonymousScalarConstraint: r = repr(con) assert "≥" in r assert "=" in r + + +def test_constraint_soften_raises_on_detached_mutable_constraint( + m: Model, x: linopy.Variable, mc: linopy.constraints.Constraint +) -> None: + """`.mutable()` on a frozen constraint returns a detached copy that is not + registered in `model.constraints`; softening it would silently fail to + affect the actual model, so `soften` must raise instead.""" + m.add_objective(x.sum(), sense="min") + + with pytest.raises(ValueError, match="not the constraint registered"): + mc.soften(penalty=10) # mc := m.constraints["c"].mutable(), detached from the model From 8e362990a30f904775f1771455d63162879e030e Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:47:27 +0200 Subject: [PATCH 09/26] fix(soften): adress deprecation warning --- linopy/constraints.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index cab45533..78101f2c 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1821,9 +1821,9 @@ def soften( sign = sign_values.item() if sign == "<=": - self.lhs = self.lhs - positive_slack + self.update(lhs=self.lhs - positive_slack) elif sign == ">=": - self.lhs = self.lhs + positive_slack + self.update(lhs=self.lhs + positive_slack) else: negative_slack = model.add_variables( lower=0, @@ -1832,7 +1832,7 @@ def soften( mask=self.mask, name=f"{name}_neg", ) - self.lhs = self.lhs - positive_slack + negative_slack + self.update(lhs=self.lhs - positive_slack + negative_slack) # Update objective function: constraint_violation = ( From b122c66f2d5ac84b852b4ffd27874f4163a10363 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:50:42 +0200 Subject: [PATCH 10/26] tests(soften): add test for type of slack and existence --- test/test_constraint.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/test_constraint.py b/test/test_constraint.py index c4ca9cfd..ae417198 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -29,6 +29,7 @@ Constraint, ConstraintBase, Constraints, + Slack, ) @@ -1061,6 +1062,36 @@ def bound(m: Model, i: int) -> AnonymousScalarConstraint: assert "=" in r +# Constraint.soften method's tests + +def test_constraint_soften_returns_slack_for_le_and_ge(m: Model, y: linopy.Variable) -> None: + """Checks that a constraint of type 'less or equal' or 'great and equal' returns one slack each.""" + z = m.variables["z"] + m.add_objective((z + y).sum(), sense="min") + + # create new constraint, and add slack for 'greater or equal' + z_constraint = m.add_constraints(z >= -10, name="constraint_over_z") + z_slack = z_constraint.soften(penalty=10) + assert isinstance(z_slack.positive, linopy.Variable) + assert z_slack.negative is None + + # create new constraint, and add slack for 'less or equal': + y_constraint = m.add_constraints(y <= 0, name="constraint_over_y") + y_slack = y_constraint.soften(penalty=10) + assert isinstance(y_slack.positive, linopy.Variable) + assert y_slack.negative is None + + +def test_constraint_soften_returns_slack_for_eq(m: Model, y: linopy.Variable) -> None: + """Checks that a constraint of type 'equal'' returns two slack variables""" + m.add_objective(y.sum(), sense="min") + y_contraint = m.add_constraints(y == 10, name="equality_contraint") + y_slack = y_contraint.soften(penalty=10) + + assert isinstance(y_slack.positive, linopy.Variable) + assert isinstance(y_slack.negative, linopy.Variable) + + def test_constraint_soften_raises_on_detached_mutable_constraint( m: Model, x: linopy.Variable, mc: linopy.constraints.Constraint ) -> None: From 82df5c8c9f6fc4f98a51064ded320947983e9982 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:22:25 +0200 Subject: [PATCH 11/26] tests(soften): add tests for a correct update of the lhs on all type of constraints --- test/test_constraint.py | 80 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/test/test_constraint.py b/test/test_constraint.py index ae417198..161d701e 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -1064,8 +1064,10 @@ def bound(m: Model, i: int) -> AnonymousScalarConstraint: # Constraint.soften method's tests -def test_constraint_soften_returns_slack_for_le_and_ge(m: Model, y: linopy.Variable) -> None: - """Checks that a constraint of type 'less or equal' or 'great and equal' returns one slack each.""" +def test_constraint_soften_returns_slack_for_le_and_ge(m: Model, + y: linopy.Variable) -> None: + """Checks that a constraint of type 'less or equal' or 'great and equal' returns + one slack each.""" z = m.variables["z"] m.add_objective((z + y).sum(), sense="min") @@ -1101,4 +1103,76 @@ def test_constraint_soften_raises_on_detached_mutable_constraint( m.add_objective(x.sum(), sense="min") with pytest.raises(ValueError, match="not the constraint registered"): - mc.soften(penalty=10) # mc := m.constraints["c"].mutable(), detached from the model + # mc := m.constraints["c"].mutable(), detached from the model + mc.soften(penalty=10) + + +def test_constraint_soften_updates_lhs(m: Model, y: linopy.Variable) -> None: + """Tests that the left hand side gains the slack term(s) with the expected sign + per constraint direction.""" + m.add_objective(y.sum(), sense="min") + + # '<=' : lhs -> lhs - positive_slack + le_constraint = m.add_constraints(y <= 10, name="le_constraint") + le_slack = le_constraint.soften(penalty=10) + + # Assert that the new lhs has 1 extra term: + assert le_constraint.lhs.nterm == 2 + + # Assert tht the label for the constraint's new variable is the same as the slack: + assert_equal( + le_constraint.vars.isel({le_constraint.term_dim: -1}), + le_slack.positive.labels, + ) + + # Since the it's less or equal, we expect the coeff of the variable to be -1 + assert (le_constraint.coeffs.isel({le_constraint.term_dim: -1}) == -1).all() + + # '>=' : lhs -> lhs + positive_slack + ge_constraint = m.add_constraints(y >= -10, name="ge_constraint") + ge_slack = ge_constraint.soften(penalty=10) + + assert ge_constraint.lhs.nterm == 2 + + assert_equal( + ge_constraint.vars.isel({ge_constraint.term_dim: -1}), + ge_slack.positive.labels, + ) + assert (ge_constraint.coeffs.isel({ge_constraint.term_dim: -1}) == 1).all() + + # '==' : lhs -> lhs - positive_slack + negative_slack + eq_constraint = m.add_constraints(y == 0, name="eq_constraint") + eq_slack = eq_constraint.soften(penalty=10) + + # Assert there's two extra terms: + assert eq_constraint.lhs.nterm == 3 + + # The positive slack is added first to the lhs, therefore, it should be on position + # -2 of the lhs: + assert_equal( + eq_constraint.vars.isel({eq_constraint.term_dim: -2}), + eq_slack.positive.labels, + ) + + # The negative slack is added next to the lhs, therefore, it should be on position + # -1 of the lhs: + assert_equal( + eq_constraint.vars.isel({eq_constraint.term_dim: -1}), + eq_slack.negative.labels, + ) + + assert (eq_constraint.coeffs.isel({eq_constraint.term_dim: -2}) == -1).all() + assert (eq_constraint.coeffs.isel({eq_constraint.term_dim: -1}) == 1).all() + + +def test_constraint_soften_updates_objective_min_sense( + m: Model, x: linopy.Variable +) -> None: + """Penalty * slack is added to the objective with the correct sign for sense='min'.""" + + +def test_constraint_soften_updates_objective_max_sense( + m: Model, x: linopy.Variable +) -> None: + """Penalty * slack is added to the objective with the correct sign for sense='max'.""" + From 43abe49b142cf7ac048cfdf667f93dd5530be7de Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:38:50 +0000 Subject: [PATCH 12/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_constraint.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/test/test_constraint.py b/test/test_constraint.py index 161d701e..a806a336 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -29,7 +29,6 @@ Constraint, ConstraintBase, Constraints, - Slack, ) @@ -1064,10 +1063,14 @@ def bound(m: Model, i: int) -> AnonymousScalarConstraint: # Constraint.soften method's tests -def test_constraint_soften_returns_slack_for_le_and_ge(m: Model, - y: linopy.Variable) -> None: - """Checks that a constraint of type 'less or equal' or 'great and equal' returns - one slack each.""" + +def test_constraint_soften_returns_slack_for_le_and_ge( + m: Model, y: linopy.Variable +) -> None: + """ + Checks that a constraint of type 'less or equal' or 'great and equal' returns + one slack each. + """ z = m.variables["z"] m.add_objective((z + y).sum(), sense="min") @@ -1097,9 +1100,11 @@ def test_constraint_soften_returns_slack_for_eq(m: Model, y: linopy.Variable) -> def test_constraint_soften_raises_on_detached_mutable_constraint( m: Model, x: linopy.Variable, mc: linopy.constraints.Constraint ) -> None: - """`.mutable()` on a frozen constraint returns a detached copy that is not + """ + `.mutable()` on a frozen constraint returns a detached copy that is not registered in `model.constraints`; softening it would silently fail to - affect the actual model, so `soften` must raise instead.""" + affect the actual model, so `soften` must raise instead. + """ m.add_objective(x.sum(), sense="min") with pytest.raises(ValueError, match="not the constraint registered"): @@ -1108,8 +1113,10 @@ def test_constraint_soften_raises_on_detached_mutable_constraint( def test_constraint_soften_updates_lhs(m: Model, y: linopy.Variable) -> None: - """Tests that the left hand side gains the slack term(s) with the expected sign - per constraint direction.""" + """ + Tests that the left hand side gains the slack term(s) with the expected sign + per constraint direction. + """ m.add_objective(y.sum(), sense="min") # '<=' : lhs -> lhs - positive_slack @@ -1175,4 +1182,3 @@ def test_constraint_soften_updates_objective_max_sense( m: Model, x: linopy.Variable ) -> None: """Penalty * slack is added to the objective with the correct sign for sense='max'.""" - From 5c64746a6d65156c7c8a87ddedbfc95dad2b6118 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:25:32 +0200 Subject: [PATCH 13/26] test(soften): add tests for update of the objective function --- test/test_constraint.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/test/test_constraint.py b/test/test_constraint.py index a806a336..fde7dd11 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -30,6 +30,7 @@ ConstraintBase, Constraints, ) +from linopy.testing import assert_linequal @pytest.fixture @@ -1098,7 +1099,7 @@ def test_constraint_soften_returns_slack_for_eq(m: Model, y: linopy.Variable) -> def test_constraint_soften_raises_on_detached_mutable_constraint( - m: Model, x: linopy.Variable, mc: linopy.constraints.Constraint + m: Model, x: linopy.Variable, mc: linopy.Constraint ) -> None: """ `.mutable()` on a frozen constraint returns a detached copy that is not @@ -1173,12 +1174,35 @@ def test_constraint_soften_updates_lhs(m: Model, y: linopy.Variable) -> None: def test_constraint_soften_updates_objective_min_sense( - m: Model, x: linopy.Variable + m: Model, y: linopy.Variable ) -> None: - """Penalty * slack is added to the objective with the correct sign for sense='min'.""" + penalty_coeff = 10 + original_objective = y.sum() + m.add_objective(original_objective, sense="min") + constraint = m.add_constraints(y >= 10, name="constraint") + slack = constraint.soften(penalty=penalty_coeff) + + # For sense='min', the penalty must added with positive sign: + expected_objective = original_objective + penalty_coeff * slack.positive.sum() + assert_linequal(m.objective.expression, expected_objective) + def test_constraint_soften_updates_objective_max_sense( + m: Model, y: linopy.Variable +) -> None: + penalty_coeff = 10 + original_objective = y.sum() + m.add_objective(original_objective, sense="max") + constraint = m.add_constraints(y <= 10, name="constraint") + slack = constraint.soften(penalty=penalty_coeff) + + # For sense='max', the penalty must added with negative sign: + expected_objective = original_objective - penalty_coeff * slack.positive.sum() + assert_linequal(m.objective.expression, expected_objective) + + +def test_constraint_soften_max_violation_bounds_slack( m: Model, x: linopy.Variable ) -> None: """Penalty * slack is added to the objective with the correct sign for sense='max'.""" From 24e099987ff74221113be1912115c70e1d73945a Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:04:20 +0200 Subject: [PATCH 14/26] tests(soften): test for max violation, negative penalty, no objective and mask application --- test/test_constraint.py | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/test/test_constraint.py b/test/test_constraint.py index fde7dd11..9607fc39 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -1187,7 +1187,6 @@ def test_constraint_soften_updates_objective_min_sense( assert_linequal(m.objective.expression, expected_objective) - def test_constraint_soften_updates_objective_max_sense( m: Model, y: linopy.Variable ) -> None: @@ -1205,4 +1204,39 @@ def test_constraint_soften_updates_objective_max_sense( def test_constraint_soften_max_violation_bounds_slack( m: Model, x: linopy.Variable ) -> None: - """Penalty * slack is added to the objective with the correct sign for sense='max'.""" + """max_violation sets the slack variable's upper bound; default is unbounded (inf).""" + m.add_objective(x.sum(), sense="min") + + bounded_constraint = m.add_constraints(x >= 0, name="bounded_constraint") + bounded_slack = bounded_constraint.soften(penalty=10, max_violation=5) + assert (bounded_slack.positive.upper == 5).all() + + unbounded_constraint = m.add_constraints(x >= 0, name="unbounded_constraint") + unbounded_slack = unbounded_constraint.soften(penalty=10) + assert np.isinf(unbounded_slack.positive.upper).all() + + +def test_constraint_soften_negative_penalty_raises(m: Model, y: linopy.Variable) -> None: + """Setting penalty < 0 raises AssertionError.""" + m.add_objective(y.sum(), sense="min") + constraint = m.add_constraints(y >= 10, name="constraint") + with pytest.raises(AssertionError): + constraint.soften(penalty=-10) + + +def test_constraint_soften_without_objective_raises(m: Model, y: linopy.Variable) -> None: + """Calling soften before model.add_objective raises AssertionError.""" + constraint = m.add_constraints(y <= 10, name="constraint") + with pytest.raises(AssertionError): + constraint.soften(penalty=10) + + +def test_constraint_soften_respects_mask(m: Model, x: linopy.Variable) -> None: + """Masked constraint entries produce masked slack variables (mask propagated).""" + m.add_objective(x.sum(), sense="min") + mask = pd.Series([False] * 5 + [True] * 5) + constraint = m.add_constraints(x >= 0, name="masked_constraint", mask=mask) + slack = constraint.soften(penalty=10) + + assert_equal(slack.positive.mask, constraint.mask) + assert (constraint.mask.values == mask.values).all() From 9036ea4b24dd1619b91530bd09bc652b62c81667 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:42:25 +0200 Subject: [PATCH 15/26] fix(soften): raise ValueError instead of assert for penalty/objective checks Also fixes a mypy union-attr error on the hasattr-guarded penalty check by normalizing via np.asarray before reducing with np.all. --- linopy/constraints.py | 14 +++++++------- test/test_constraint.py | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index f9d560a6..0f49e572 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1968,16 +1968,16 @@ def soften( >>> slack = budget_constraint.soften(penalty=budget_penalty) """ # Verify valid penalty to continue: - assert (penalty >= 0).all() if hasattr(penalty, "all") else penalty >= 0, ( - "Penalty is not positive." - ) + if not bool(np.all(np.asarray(penalty) >= 0)): + raise ValueError("Penalty is not positive.") - # Assert the existence of the objective function before using soften method (this is to avoid + # Require the objective function to exist before using soften method (this is to avoid # `add_objective` overwriting the penalty term added below, since it replaces rather than merges): model = self.model - assert not model.objective.expression.empty, ( - "Objective must be defined via `model.add_objective` before calling `soften` on constraints." - ) + if model.objective.expression.empty: + raise ValueError( + "Objective must be defined via `model.add_objective` before calling `soften` on constraints." + ) # A detached copy of the constraint (e.g. from `.mutable()`, `.sel()`, `.isel()`) isn't in # `model.constraints`, so .soften would silently do nothing on the real model. This check is to avoid that: diff --git a/test/test_constraint.py b/test/test_constraint.py index 9607fc39..41d74ab9 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -1217,17 +1217,17 @@ def test_constraint_soften_max_violation_bounds_slack( def test_constraint_soften_negative_penalty_raises(m: Model, y: linopy.Variable) -> None: - """Setting penalty < 0 raises AssertionError.""" + """Setting penalty < 0 raises ValueError.""" m.add_objective(y.sum(), sense="min") constraint = m.add_constraints(y >= 10, name="constraint") - with pytest.raises(AssertionError): + with pytest.raises(ValueError, match="Penalty is not positive"): constraint.soften(penalty=-10) def test_constraint_soften_without_objective_raises(m: Model, y: linopy.Variable) -> None: - """Calling soften before model.add_objective raises AssertionError.""" + """Calling soften before model.add_objective raises ValueError.""" constraint = m.add_constraints(y <= 10, name="constraint") - with pytest.raises(AssertionError): + with pytest.raises(ValueError, match="Objective must be defined"): constraint.soften(penalty=10) From adf09e6cf6fa16fe76b348f76e262e281ccd900e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:43:38 +0000 Subject: [PATCH 16/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_constraint.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/test_constraint.py b/test/test_constraint.py index 41d74ab9..172e0f68 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -1216,7 +1216,9 @@ def test_constraint_soften_max_violation_bounds_slack( assert np.isinf(unbounded_slack.positive.upper).all() -def test_constraint_soften_negative_penalty_raises(m: Model, y: linopy.Variable) -> None: +def test_constraint_soften_negative_penalty_raises( + m: Model, y: linopy.Variable +) -> None: """Setting penalty < 0 raises ValueError.""" m.add_objective(y.sum(), sense="min") constraint = m.add_constraints(y >= 10, name="constraint") @@ -1224,7 +1226,9 @@ def test_constraint_soften_negative_penalty_raises(m: Model, y: linopy.Variable) constraint.soften(penalty=-10) -def test_constraint_soften_without_objective_raises(m: Model, y: linopy.Variable) -> None: +def test_constraint_soften_without_objective_raises( + m: Model, y: linopy.Variable +) -> None: """Calling soften before model.add_objective raises ValueError.""" constraint = m.add_constraints(y <= 10, name="constraint") with pytest.raises(ValueError, match="Objective must be defined"): From a1cbca85fed25af99919cc43557a16f2b4cbd601 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:57:08 +0200 Subject: [PATCH 17/26] fix(soften): narrow Slack fields to Variable, fixing mypy union-attr errors Slack.positive/negative were typed as VariableLike (ScalarVariable | Variable), but add_variables() always returns Variable, never ScalarVariable. --- linopy/constraints.py | 4 ++-- test/test_constraint.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index 0f49e572..f84bcb28 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1310,8 +1310,8 @@ class Slack(NamedTuple): need one slack variable to absorb a violation in a single direction. """ - positive: VariableLike - negative: VariableLike | None + positive: variables.Variable + negative: variables.Variable | None class Constraint(ConstraintBase): diff --git a/test/test_constraint.py b/test/test_constraint.py index 172e0f68..d349cd85 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -1151,6 +1151,7 @@ def test_constraint_soften_updates_lhs(m: Model, y: linopy.Variable) -> None: # '==' : lhs -> lhs - positive_slack + negative_slack eq_constraint = m.add_constraints(y == 0, name="eq_constraint") eq_slack = eq_constraint.soften(penalty=10) + assert eq_slack.negative is not None # Assert there's two extra terms: assert eq_constraint.lhs.nterm == 3 @@ -1243,4 +1244,5 @@ def test_constraint_soften_respects_mask(m: Model, x: linopy.Variable) -> None: slack = constraint.soften(penalty=10) assert_equal(slack.positive.mask, constraint.mask) + assert constraint.mask is not None assert (constraint.mask.values == mask.values).all() From 78714101bc761c1945b6bdf38967afeb34264825 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:09:32 +0200 Subject: [PATCH 18/26] docs(Constraint.soften): add note to let the user know that this method is not supported for frozen constraints. --- linopy/constraints.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index f84bcb28..62a536e9 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1947,8 +1947,15 @@ def soften( Returns ------- Slack - Named tuple with the `positive` slack variable, and the `negative` one for equality - constraints (`None` for inequality constraints). + Named tuple with the `positive` slack variable, and the `negative` one for equality constraints (`None` + for inequality constraints). + + Notes + ----- + Not supported on frozen constraints (e.g. a CSRConstraint from add_constraints(..., freeze=True) or + Model(freeze_constraints=True)). This method is only defined on Constraint and calling it on a CSRConstraint + raises AttributeError. Calling .mutable() first does not help either, since the resulting Constraint is a + detached copy not registered in model.constraints, so soften raises ValueError on it instead. Examples -------- From e53369509916208d792f6b93d8ccc8fcdd0a303a Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:37:32 +0200 Subject: [PATCH 19/26] feat(add_constraints): add penalty option to soften constraint on creation (model.add_constraints) Adds a `penalty` kwarg to `Model.add_constraints` that calls `.soften()` on the newly registered constraint, saving a follow-up call. Raises ValueError when combined with freeze=True (explicit or via the model's freeze_constraints default), since soften is not supported on frozen constraints. --- linopy/model.py | 20 +++++++++++++++++++- test/test_constraint.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/linopy/model.py b/linopy/model.py index 600051e5..12b8dcde 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1181,6 +1181,7 @@ def add_constraints( mask: MaskLike | None = ..., freeze: Literal[False] = ..., scaling: ConstantLike = ..., + penalty: ConstantLike | None = ..., ) -> Constraint: ... @overload @@ -1198,6 +1199,7 @@ def add_constraints( mask: MaskLike | None = ..., freeze: Literal[True] = ..., scaling: ConstantLike = ..., + penalty: None = ..., ) -> CSRConstraint: ... @overload @@ -1215,6 +1217,7 @@ def add_constraints( mask: MaskLike | None = ..., freeze: bool | None = ..., scaling: ConstantLike = ..., + penalty: ConstantLike | None = ..., ) -> ConstraintBase: ... def add_constraints( @@ -1231,6 +1234,7 @@ def add_constraints( mask: MaskLike | None = None, freeze: bool | None = None, scaling: ConstantLike = 1, + penalty: ConstantLike | None = None, ) -> ConstraintBase: """ Assign a new, possibly multi-dimensional array of constraints to the @@ -1273,6 +1277,12 @@ def add_constraints( Positive finite scaling factor(s) for constraint rows. Solver-side left-hand-side coefficients and right-hand-side values are multiplied by this factor. The default is 1. + penalty : constant-like, optional + If given, soften the constraint right away by calling + :meth:`Constraint.soften` with this penalty, adding a slack variable + and a penalty term to the objective. Not allowed together with + ``freeze=True`` (or a model default of ``freeze_constraints=True``), + since softening requires a mutable, registered ``Constraint``. Returns ------- @@ -1283,6 +1293,11 @@ def add_constraints( name = self._resolve_constraint_name(name) resolved_freeze = self.freeze_constraints if freeze is None else freeze + if penalty is not None and resolved_freeze: + raise ValueError( + "`penalty` cannot be combined with `freeze=True` (or a model default of `freeze_constraints=True`)," + "since `soften` is not supported on frozen constraints." + ) if resolved_freeze and mask is None and not self.chunk: from linopy.constraints import CSRConstraint, extract_csr_pending @@ -1372,7 +1387,10 @@ def add_constraints( constraint = Constraint(data, name=name, model=self, skip_broadcast=True) if freeze is None: freeze = self.freeze_constraints - return self.constraints.add(constraint, freeze=freeze and not self.chunk) + added = self.constraints.add(constraint, freeze=freeze and not self.chunk) + if penalty is not None: + constraint.soften(penalty=penalty) + return added def add_indicator_constraints( self, diff --git a/test/test_constraint.py b/test/test_constraint.py index d349cd85..e5b2fd45 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -96,6 +96,41 @@ def test_add_constraints_uses_model_freeze_default() -> None: ) +def test_add_constraints_penalty_softens_constraint( + m: Model, y: linopy.Variable +) -> None: + """`penalty=` on add_constraints is a shortcut for calling `.soften()`.""" + m.add_objective(y.sum(), sense="min") + penalty_coeff = 10 + constraint = m.add_constraints(y >= 10, name="constraint", penalty=penalty_coeff) + + assert isinstance(constraint, linopy.constraints.Constraint) + # The slack term was added to the lhs, same effect as calling .soften() directly: + assert constraint.lhs.nterm == 2 + expected_objective = ( + y.sum() + penalty_coeff * m.variables["constraint_slack_pos"].sum() + ) + assert_linequal(m.objective.expression, expected_objective) + + +def test_add_constraints_penalty_with_freeze_true_raises( + m: Model, x: linopy.Variable +) -> None: + with pytest.raises(ValueError, match="`penalty` cannot be combined"): + m.add_constraints(x >= 0, name="frozen_penalized", freeze=True, penalty=10) + + +def test_add_constraints_penalty_with_model_freeze_default_raises() -> None: + """ + `freeze=None` resolves to the model's `freeze_constraints` default, which must + also be checked against `penalty`, not just an explicit `freeze=True`. + """ + m = Model(freeze_constraints=True) + x = m.add_variables(coords=[pd.RangeIndex(10, name="first")], name="x") + with pytest.raises(ValueError, match="`penalty` cannot be combined"): + m.add_constraints(x >= 0, name="frozen_by_default_penalized", penalty=10) + + def test_constraint_name(c: linopy.constraints.CSRConstraint) -> None: assert c.name == "c" From 0c5a5f15af4700e0dc9a048b5fc5debb4e077a48 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:49:38 +0200 Subject: [PATCH 20/26] fix: Raise error before adding the variable when there's mixed signs, to avoid orphan slack variable --- linopy/constraints.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index f034d8cf..96f67556 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -2101,6 +2101,12 @@ def soften( name = name or f"{self.name}_slack" upper = np.inf if max_violation is None else max_violation + sign_values = pd.unique(self.sign.values.ravel()) + if len(sign_values) > 1: + raise NotImplementedError( + "Constraint.soften does not support constraints with mixed signs." + ) + positive_slack = model.add_variables( lower=0, upper=upper, @@ -2111,13 +2117,7 @@ def soften( negative_slack = None # Update left hand side depending on the sign of the constraint: - sign_values = pd.unique(self.sign.values.ravel()) - if len(sign_values) > 1: - raise NotImplementedError( - "Constraint.soften does not support constraints with mixed signs." - ) sign = sign_values.item() - if sign == "<=": self.update(lhs=self.lhs - positive_slack) elif sign == ">=": From a67d3f52cac7215a170a0ddaca445645feba7cad Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:51:37 +0200 Subject: [PATCH 21/26] docs-fix: add some more docstrings and align code with docstrings --- linopy/constraints.py | 2 +- linopy/model.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index 96f67556..754b2d9b 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -2078,7 +2078,7 @@ def soften( >>> slack = budget_constraint.soften(penalty=budget_penalty) """ # Verify valid penalty to continue: - if not bool(np.all(np.asarray(penalty) >= 0)): + if not bool(np.all(np.asarray(penalty) > 0)): raise ValueError("Penalty is not positive.") # Require the objective function to exist before using soften method (this is to avoid diff --git a/linopy/model.py b/linopy/model.py index b87590b0..bf6f4c00 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1292,6 +1292,10 @@ def add_constraints( and a penalty term to the objective. Not allowed together with ``freeze=True`` (or a model default of ``freeze_constraints=True``), since softening requires a mutable, registered ``Constraint``. + The resulting Slack is not returned by this shortcut; retrieve + the slack variable(s) from model.variables using the derived + name f"{name}_slack_pos" (and f"{name}_slack_neg" for + equality constraints). Returns ------- From 45935a96a7692583617dde48228c5d1066b1cbf9 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:12:46 +0200 Subject: [PATCH 22/26] feat(constraints): add Constraint.slack to look up soften()'s slack variable soften() previously returned the slack Variable(s) but discarded them, so there was no way to retrieve a constraint's slack after the fact (e.g. once the model is loaded from disk). The new `slack` property resolves the positive/negative slack variable names, persisted as Dataset attrs on soften(), back into live Variable objects via the model. Co-Authored-By: Claude Sonnet 5 --- linopy/constraints.py | 20 +++++++++++++ test/test_constraint.py | 62 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index 754b2d9b..5931a2ae 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1628,6 +1628,21 @@ def lhs(self, value: ExpressionLike | VariableLike | ConstantLike) -> None: ) self.update(lhs=value) + @property + def slack(self) -> Slack | None: + """ + Slack variable(s) added via :meth:`soften`, or ``None`` if the + constraint has never been softened. + """ + positive = self.data.attrs.get("slack_positive") + if positive is None: + return None + negative = self.data.attrs.get("slack_negative", "") + return Slack( + positive=self.model.variables[positive], + negative=self.model.variables[negative] if negative else None, + ) + def _assign_lhs( self, expr: expressions.LinearExpression, rhs: DataArray | None = None ) -> None: @@ -2141,6 +2156,11 @@ def soften( direction = 1 if model.sense == "min" else -1 model.objective += direction * (penalty * constraint_violation).sum() + self._data = self._data.assign_attrs( + slack_positive=positive_slack.name, + slack_negative=negative_slack.name if negative_slack is not None else "", + ) + return Slack(positive=positive_slack, negative=negative_slack) def to_polars(self) -> pl.DataFrame: diff --git a/test/test_constraint.py b/test/test_constraint.py index 767cac73..0c544118 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -30,7 +30,7 @@ ConstraintBase, Constraints, ) -from linopy.testing import assert_linequal +from linopy.testing import assert_linequal, assert_varequal @pytest.fixture @@ -1341,3 +1341,63 @@ def test_constraint_soften_respects_mask(m: Model, x: linopy.Variable) -> None: assert_equal(slack.positive.mask, constraint.mask) assert constraint.mask is not None assert (constraint.mask.values == mask.values).all() + + +def test_constraint_slack_none_before_soften(m: Model, x: linopy.Variable) -> None: + """A constraint that was never softened exposes no slack variable.""" + constraint = m.add_constraints(x >= 0, name="constraint") + assert constraint.slack is None + + +def test_constraint_slack_matches_returned_slack_for_le_and_ge( + m: Model, x: linopy.Variable, y: linopy.Variable +) -> None: + """ + `.slack` resolves to the same variables `soften` returned, for both + inequality directions (no negative slack). + """ + m.add_objective((x + y).sum(), sense="min") + + le_constraint = m.add_constraints(y <= 0, name="le_constraint") + le_slack = le_constraint.soften(penalty=10) + assert le_constraint.slack is not None + assert_varequal(le_constraint.slack.positive, le_slack.positive) + assert le_constraint.slack.negative is None + + ge_constraint = m.add_constraints(x >= -10, name="ge_constraint") + ge_slack = ge_constraint.soften(penalty=10) + assert ge_constraint.slack is not None + assert_varequal(ge_constraint.slack.positive, ge_slack.positive) + assert ge_constraint.slack.negative is None + + +def test_constraint_slack_matches_returned_slack_for_eq( + m: Model, y: linopy.Variable +) -> None: + """ + For an equality constraint, `.slack` carries both the positive and the + negative slack variable. + """ + m.add_objective(y.sum(), sense="min") + constraint = m.add_constraints(y == 10, name="eq_constraint") + slack = constraint.soften(penalty=10) + + resolved = constraint.slack + assert resolved is not None + assert_varequal(resolved.positive, slack.positive) + assert slack.negative is not None + assert_varequal(resolved.negative, slack.negative) + + +def test_constraint_slack_reflects_latest_soften_call( + m: Model, y: linopy.Variable +) -> None: + """Softening the same constraint again replaces the tracked slack.""" + m.add_objective(y.sum(), sense="min") + constraint = m.add_constraints(y >= 10, name="constraint") + + constraint.soften(penalty=10, name="first_slack") + second_slack = constraint.soften(penalty=5, name="second_slack") + + assert constraint.slack is not None + assert_varequal(constraint.slack.positive, second_slack.positive) From 9cab5fd2d4917ecb7fd3759f5021e6be273f4472 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:49:50 +0200 Subject: [PATCH 23/26] feat: raise an error when the user tries to soften a constraint for a second time --- linopy/constraints.py | 9 +++++++++ test/test_constraint.py | 36 ++++++++++++++++++++++++------------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index 5931a2ae..747377df 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -2075,6 +2075,9 @@ def soften( raises AttributeError. Calling .mutable() first does not help either, since the resulting Constraint is a detached copy not registered in model.constraints, so soften raises ValueError on it instead. + Softening an already-softened constraint raises ValueError instead of stacking a second, redundant slack term + onto the same lhs. + Examples -------- >>> from linopy import Model @@ -2113,6 +2116,12 @@ def soften( "`.sel()`, or `.isel()`). Call `soften` on `model.constraints[name]` directly." ) + if self.slack is not None: + raise ValueError( + f"Constraint {self.name!r} was already softened (existing slack " + f"variable {self.slack.positive.name!r})" + ) + name = name or f"{self.name}_slack" upper = np.inf if max_violation is None else max_violation diff --git a/test/test_constraint.py b/test/test_constraint.py index 0c544118..6201428e 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -1208,6 +1208,30 @@ def test_constraint_soften_raises_on_detached_mutable_constraint( mc.soften(penalty=10) +def test_constraint_soften_twice_raises(m: Model, y: linopy.Variable) -> None: + """ + Softening an already-softened constraint must raise instead of silently + stacking a second, redundant slack term onto the same lhs. + """ + m.add_objective(y.sum(), sense="min") + constraint = m.add_constraints(y >= 10, name="constraint") + constraint.soften(penalty=10, name="first_slack") + + with pytest.raises(ValueError, match="already softened"): + constraint.soften(penalty=5, name="second_slack") + + +def test_constraint_soften_twice_raises_via_add_constraints_penalty( + m: Model, y: linopy.Variable +) -> None: + """The same guard applies when the first soften came from `add_constraints(penalty=...)`.""" + m.add_objective(y.sum(), sense="min") + constraint = m.add_constraints(y >= 10, name="constraint", penalty=10) + + with pytest.raises(ValueError, match="already softened"): + constraint.soften(penalty=5, name="second") + + def test_constraint_soften_updates_lhs(m: Model, y: linopy.Variable) -> None: """ Tests that the left hand side gains the slack term(s) with the expected sign @@ -1389,15 +1413,3 @@ def test_constraint_slack_matches_returned_slack_for_eq( assert_varequal(resolved.negative, slack.negative) -def test_constraint_slack_reflects_latest_soften_call( - m: Model, y: linopy.Variable -) -> None: - """Softening the same constraint again replaces the tracked slack.""" - m.add_objective(y.sum(), sense="min") - constraint = m.add_constraints(y >= 10, name="constraint") - - constraint.soften(penalty=10, name="first_slack") - second_slack = constraint.soften(penalty=5, name="second_slack") - - assert constraint.slack is not None - assert_varequal(constraint.slack.positive, second_slack.positive) From 2148b771af3721c22a4f37f749dcf5934d4bce2b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:50:15 +0000 Subject: [PATCH 24/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_constraint.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/test_constraint.py b/test/test_constraint.py index 6201428e..607d9a86 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -1411,5 +1411,3 @@ def test_constraint_slack_matches_returned_slack_for_eq( assert_varequal(resolved.positive, slack.positive) assert slack.negative is not None assert_varequal(resolved.negative, slack.negative) - - From c2db804b975e1ac0d62bb20f53f5f8de199f008e Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:57:31 +0200 Subject: [PATCH 25/26] fix(test): satisfy mypy narrowing for resolved.negative in slack eq test assert_varequal expects a Variable, but resolved.negative is typed Variable | None; narrow it explicitly instead of relying on the sibling slack.negative check. Co-Authored-By: Claude Sonnet 5 --- test/test_constraint.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_constraint.py b/test/test_constraint.py index 607d9a86..c0f0e41b 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -1410,4 +1410,5 @@ def test_constraint_slack_matches_returned_slack_for_eq( assert resolved is not None assert_varequal(resolved.positive, slack.positive) assert slack.negative is not None + assert resolved.negative is not None assert_varequal(resolved.negative, slack.negative) From 99908a10992d79d6daaaed38f5fe36587e8e4773 Mon Sep 17 00:00:00 2001 From: Ignacia <81649278+isanchez-ng@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:06:21 +0200 Subject: [PATCH 26/26] update release notes --- doc/release_notes.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index dae3a6dd..626bf1cf 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -29,6 +29,11 @@ Upcoming Version * Added support for the GPU-accelerated `NVIDIA cuOpt `__ solver for linear, mixed-integer and convex quadratic problems, via ``model.solve("cuopt", io_api="direct")``. Install it with ``pip install "linopy[gpu]"`` — Linux only, and requires an NVIDIA GPU of compute capability 7.0 or higher with a CUDA 12 driver (525.60.13 or newer). See :doc:`gpu-acceleration` for the supported problem classes and the known limitations. +*New feature: constraint softening* + +* A constraint can now be softened with ``Constraint.soften(penalty, max_violation=None, name=None)``, which adds a slack variable (a positive/negative pair for equality constraints) to the constraint's ``lhs`` and a penalty term to the objective, returning a ``Slack`` named tuple. It is not supported on frozen constraints or on detached copies from ``.mutable()``, ``.sel()``, or ``.isel()``. ``model.add_constraints(..., penalty=...)`` is a shortcut that softens the constraint right after creation and cannot be combined with ``freeze=True``. +* The slack variable(s) created by ``soften()`` can be retrieved afterwards via the new ``Constraint.slack`` property. + *Other* * ``add_piecewise_formulation`` gained a ``mask`` parameter declaring which breakpoint slots hold a real breakpoint. It is needed for **ragged** curves — entities with different numbers of breakpoints — which are stored densely with the surplus slots left absent. Under v1 that absence must be declared (``mask=x_pts.notnull()``) rather than read off the NaN padding. (https://github.com/PyPSA/linopy/issues/884)