Skip to content

feat(constraint.py): Auto-convert hard to soft constraints through soften method - #904

Open
isanchez-ng wants to merge 22 commits into
PyPSA:masterfrom
isanchez-ng:master
Open

feat(constraint.py): Auto-convert hard to soft constraints through soften method#904
isanchez-ng wants to merge 22 commits into
PyPSA:masterfrom
isanchez-ng:master

Conversation

@isanchez-ng

@isanchez-ng isanchez-ng commented Aug 23, 2026

Copy link
Copy Markdown

Closes #782
Hi! This is my first contribution. Let me know if there's something I should work on!


Context: Building soft constraints requires a lot of manual work that can be avoided through a method that modifies both the variables (to add the slacks) and the objective function (to add the penalty term). This PR intents to built this new feature.

Changes proposed in this Pull Request

Implementation

I followed the proposed structure for the method, but changed some details that I mention on the next section.
Also, I changed slightly the typehints for objective.__add__. to avoid some mypy errors that weren't actually errors.

How did I test this new feature?

  • Built unit tests for both the feature and the wrapper
  • Used some manual inspection using this code here:
import linopy
import pandas as pd

add_budget_slack = True
add_risk_slack = False
budget_penalty = 2
penalty_risk = 0

# --- Toy data: just 3 investments ---
investments = pd.Index(["Investment A", "Investment B", "Investment C"], name="investments")
risk = pd.Series([0.5, 0.2, 0.8], index=investments, name="risk")
maximum_risk = 0.1 # 0.1 forces the slack on the budget to act.
expected_return = pd.Series([0.08, 0.03, 0.1], index=investments, name="expected_return")

# --- Model ---
m = linopy.Model()

# --- Variables ---
# Fraction of the portfolio allocated to each asset, between 0 and 1
w = m.add_variables(lower=0, upper=1, coords=[investments], name="weights")
m.add_objective((expected_return * w).sum(), sense="max")


# ---- Constraints ----
# Budget constraint: allocations must sum to 1 (fully invested). Option with and without slack.
if add_budget_slack:
    # new easy version:
    # budget_constraint = m.add_constraints(w.sum() == 1, name="budget")
    # budget_slack = budget_constraint.soften(penalty=budget_penalty)

    # OR fastest version:
    budget_constraint = m.add_constraints(w.sum() == 1, name="budget_constraint", penalty=budget_penalty)
else:
    budget_constraint = m.add_constraints(w.sum() == 1, name="budget_constraint")

# Risk constraint: keep the risk to be lower than 0.5. Option with and without slack.
if add_risk_slack:
    risk_constraint = m.add_constraints((w * risk).sum() <= maximum_risk, name="total_risk", penalty=penalty_risk)
else:
    risk_constraint = m.add_constraints((w * risk).sum() <= maximum_risk, name="total_risk")

m.solve(solver_name="highs", output_flag=False)

w.solution

Open discussions:

  • I decided to change the proposed behaviour of returning either a Variable or a tuple of variables, because in my experience this behaviour gets messy when the codebase grows (for example, people would have to manage the doubled behaviour through isinstance(var_name, tuple) through their own codes. Instead of that, I proposed a NamedTuple.
  • I decided to raise an assertion at the start of soften() if the model's objective hasn't been defined yet, since soften() adds a penalty term to the existing objective rather than replacing it.
    • It would be technically possible to let soften() run before add_objective(). But doing so creates a weird condition where the line model.objective += penalty things asserts the objective is still empty, so calling a second model.add_objective after soften() would raise an error telling the user to pass overwrite=True. Doing that, however, replaces the whole objective expression, and silently discards the penalty term soften() had already added.
    • soften() also relies on model.sense to pick the correct sign for the penalty term. Since sense defaults to "min" until model.add_objective() is called with a different value, calling soften() first risks silently penalizing in the wrong direction if the user later sets sense="max".
  • The .soften method doesn't work for freeze constraints! I propose handling this on another PR if it's strongly needed.

I'm open to discussion on these bullet points if someone else has a better proposal.

Questions that still have to be answered:

  • Shoudld I create an example of this in one of the notebooks, or create a new notebook? I don't know exactly what to do here so, any guidance from the mantainers will be appreciated!
  • One of the "to-dos" says: A note for the release notes doc/release_notes.rst of the upcoming release is included.. I don't know exactly what they mean with this 🤔. Does this just mean to add a small phrase of what's this doing? (it's my first time colllaborating on this repo). I'd appreaciate a small guidance.

To-Dos:

  • Build unit tests for .soften method
  • Build wrapper of soften inside model.add_constraint

Checklist

  • AI-generated content is marked (see AGENTS.md).
  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • I consent to the release of this PR's code under the MIT license.

Ignacia and others added 6 commits August 22, 2026 23:38
…oid 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.
… a scalar operand) to accept ConstantLike.

The narrow annotation caused mypy to flag valid code
@codspeed-hq

codspeed-hq Bot commented Aug 23, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 22.69%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 180 untouched benchmarks
⏩ 181 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_to_lp[qp-n=1000] 2 MB 2.6 MB -22.69%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing isanchez-ng:master (e533695) with master (b71e9a9)

Open in CodSpeed

Footnotes

  1. 181 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@FabianHofmann

Copy link
Copy Markdown
Collaborator

@isanchez-ng wonderful initiative and I like the approach! let me know as soon as I should review

FabianHofmann and others added 16 commits August 27, 2026 14:17
… checks

Also fixes a mypy union-attr error on the hasattr-guarded penalty
check by normalizing via np.asarray before reducing with np.all.
…errors

Slack.positive/negative were typed as VariableLike (ScalarVariable |
Variable), but add_variables() always returns Variable, never
ScalarVariable.
…ation (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.
@isanchez-ng

Copy link
Copy Markdown
Author

@codspeedbot fix this regression

@isanchez-ng

Copy link
Copy Markdown
Author

@FabianHofmann I think this is ready for review! The one thing I couldn't fix was the performance analysis. If you could guide me a little bit with this I'd be happy to fix them as well.

Heads up that it's a fair amount of new code (+370 lines). Happy to hop on a quick 10-min call to walk you through it if that helps for a faster review :)

@isanchez-ng
isanchez-ng marked this pull request as ready for review September 5, 2026 11:14
@FabianHofmann

FabianHofmann commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

@isanchez-ng great job, the changes look totally reviewable! here is what my agent found (easy to tackle):


[F1] Orphan slack variable on mixed-sign constraints (correctness, should fix).
In soften, positive_slack is created via model.add_variables(...) before the mixed-sign check runs:

positive_slack = model.add_variables(...)   # side effect on the model
negative_slack = None
sign_values = pd.unique(self.sign.values.ravel())
if len(sign_values) > 1:
    raise NotImplementedError(...)           # too late

So softening a mixed-sign constraint raises, but leaves a dangling {name}_pos variable registered in model.variables, with the constraint and objective untouched. That breaks fail-fast: it fails and pollutes the model. Fix: read and validate sign_values at the very top of the method, before creating any variable. Cheap and complete.

[F2] penalty=0 is allowed but the docstring and message say it must be positive (minor).
The check is np.all(np.asarray(penalty) >= 0), so 0 passes. But the docstring says "Must be bigger than 0" and the error says "Penalty is not positive." A zero penalty adds a free slack, which silently lets the solver break the constraint at no cost. That is a footgun. Pick one: either forbid 0 with > 0 (matches the docs), or allow it and fix the docs and message. I recommend > 0.

[F3] The add_constraints(..., penalty=...) shortcut throws away the Slack return (API gap, minor).
model.py calls constraint.soften(penalty=penalty) and discards the result, then returns the constraint. So a user of the shortcut cannot get the slack variables except by guessing the derived name f"{name}_slack_pos". That is acceptable for the "fast path", but the derived name is not documented in the add_constraints docstring. Add one sentence naming the convention, so the slacks are reachable.


Beyond that:

[F4] should we add a .slack property returning a hidden _slack attribute to Constraint?
[F5] pleas also add release notes :)

Then this is good to go!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-convert hard to soft constraints

2 participants