feat(constraint.py): Auto-convert hard to soft constraints through soften method - #904
isanchez-ng wants to merge 30 commits into
Conversation
…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
for more information, see https://pre-commit.ci
Merging this PR will regress 3 benchmarks
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | test_to_lp[nodal_balance_sparse-severity=50] |
2.8 MB | 3.7 MB | -24.14% |
| ❌ | test_to_lp[merge_balance-severity=0] |
2.7 MB | 3.3 MB | -18.3% |
| ❌ | test_to_lp[nodal_balance-severity=50] |
3.3 MB | 3.7 MB | -10.48% |
| ⚡ | test_to_lp[expression_arithmetic-n=10] |
1,338.6 KB | 733.7 KB | +82.45% |
| ⚡ | test_to_lp[expression_arithmetic-n=250] |
46.7 MB | 41.1 MB | +13.64% |
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 (99908a1) with master (718c0c1)
Footnotes
-
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. ↩
|
@isanchez-ng wonderful initiative and I like the approach! let me know as soon as I should review |
…n arrays (FOR NOW)
…raints & add unit test
for more information, see https://pre-commit.ci
… and mask application
… checks Also fixes a mypy union-attr error on the hasattr-guarded penalty check by normalizing via np.asarray before reducing with np.all.
for more information, see https://pre-commit.ci
…errors Slack.positive/negative were typed as VariableLike (ScalarVariable | Variable), but add_variables() always returns Variable, never ScalarVariable.
…od is not supported for frozen constraints.
…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.
|
@codspeedbot fix this regression |
|
@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 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). 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 lateSo 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). [F3] The add_constraints(..., penalty=...) shortcut throws away the Slack return (API gap, minor). Beyond that: [F4] should we add a Then this is good to go! |
|
@FabianHofmann sorry for the late response, I'll address this now! Also, there's another question that I left there: Should I create an example of this new feature in one of the notebooks, or create a new notebook? |
# Conflicts: # linopy/model.py
… to avoid orphan slack variable
…ariable 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 <noreply@anthropic.com>
for more information, see https://pre-commit.ci
|
@FabianHofmann I was addressing what you mentioned, and now I have another question: Do you think we should support adding more than one pair of slack variable on a constraint? What I mean is something like this: # this line adds the first 2 slacks (positive and negative, because it's an equality)
budget_constraint = m.add_constraints(w.sum() == 1, name="first", penalty=budget_penalty)
# then this other line, adds a second pair of slacks:
budget_constraint.soften(penalty=100, name='second')What finally gives you a constraint with 4 slack variables (see the image below). I'm not sure if this is something used in the field of optimization (maybe to model two types of costs?). I would say it's redundant, and the code shouldn't allow it, but maybe I'm missing something. Let me know your thoughts on this to correctly address this situation on the code. For now I will explicitly raise an error if the user tries to soften twice.
|
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 <noreply@anthropic.com>
|
no worry about the release note, I can improve them if necessary. about the notebook, this is up to you. and the second order slack, Idk honestly. probably we can leave it and the user and decide whether they want to use it :) |
Thinking of it, i think there will be two trade offs if we decide to allow the possibility to add more than one pair of slacks for a constraint:
For these reasons, I think it would be better to leave this feature for another issue (if the community asks for it on the first place). If you agree with this, the PR is ready for review! :) |

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?
Open discussions:
isinstance(var_name, tuple)through their own codes. Instead of that, I proposed a NamedTuple.soften()if the model's objective hasn't been defined yet, sincesoften()adds a penalty term to the existing objective rather than replacing it.soften()run beforeadd_objective(). But doing so creates a weird condition where the linemodel.objective += penalty thingsasserts the objective is still empty, so calling a secondmodel.add_objectiveaftersoften()would raise an error telling the user to passoverwrite=True. Doing that, however, replaces the whole objective expression, and silently discards the penalty termsoften()had already added.soften()also relies onmodel.senseto pick the correct sign for the penalty term. Sincesensedefaults to"min"untilmodel.add_objective()is called with a different value, callingsoften()first risks silently penalizing in the wrong direction if the user later setssense="max".I'm open to discussion on these bullet points if someone else has a better proposal.
Questions that still have to be answered:
doc/release_notes.rstof 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:
.softenmethodsofteninsidemodel.add_constraintChecklist
AGENTS.md).doc.