Skip to content

feat: fold single-variable constraints into column bounds (opt-in, with dual recovery) - #1121

Draft
FabianHofmann wants to merge 3 commits into
masterfrom
feat/singleton-rows-as-bounds
Draft

FabianHofmann wants to merge 3 commits into
masterfrom
feat/singleton-rows-as-bounds

Conversation

@FabianHofmann

Copy link
Copy Markdown
Collaborator

Closes # (if applicable).

Changes proposed in this Pull Request

Note

The following content was generated by AI.

Adds an opt-in Model.add_constraints(..., as_bound=True). Every active row of such a constraint must hold exactly one variable term c * x with a nonzero coefficient. If a row does not, add_constraints raises, and so does lowering if the constraint was changed after it was added. When the model is lowered, the row is not emitted as a matrix row. Instead it tightens the bounds of x to rhs / c: the direction flips for c < 0, and = sets both bounds. Lowering covers Model.matrices, and so every direct backend, the MPS writer and restrict, plus the LP writer, which writes from the constraints directly. Several rows on one column and the variable's own bounds combine to the tightest bound. Matrices.bound_rows records which row sets each side. Default behaviour is unchanged.

Motivation: in PyPSA models most rows are bounds written as constraints (83 % of the rows on SciGrid-DE, 52 % on model-energy). PyPSA needs their duals, e.g. for mu_upper.

Dual recovery. The solver reports no dual for folded rows. In Model.assign_result, linopy rebuilds the reduced costs from the duals of the remaining rows, d = c + Q x - A^T y. This is computed in solver units and converted to user units with var_scaling / objective_scaling. The row that sets the active bound gets d_j / c, with the side taken from sign(d) and the objective sense. Every other folded row gets 0. This is the dual the same row gets as an ordinary constraint, under linopy's convention that c - A^T y = z, with z >= 0 at a lower bound for min and z <= 0 for max. The method is backend-agnostic and needs no reduced-cost API, but it only holds if a backend's dual signs follow that convention.

Supported / verified: HiGHS and Gurobi (the BOUND_ROW_DUAL_SOLVERS set), with io_api direct, lp and mps, for min and max, LP and QP objectives, and with and without variable, constraint and objective scaling. In every case the folded duals match the duals of the same model with ordinary rows to 1e-7 (1e-5 for QP). The cases cover <=, >=, =, a negative coefficient, and two bound rows on one column where only the binding one gets the dual.

Not verified: every other solver. Its folded rows get NaN duals and a UserWarning. MILP is fine and reports no duals, as usual.

Interactions:

  • Persistent updates: track_updates always rebuilds (RebuildReason.BOUND_ROWS), because the snapshot buffers do not track folded rows.
  • dualize(): treats the folded constraints as ordinary rows.
  • Rejected with a ValueError: penalty= / soften(), and rows on semi-continuous variables. Indicator constraints cannot use the flag, since it exists only on add_constraints.
  • Matrices.take: remaps bound_rows.
  • restrict windows: the rows act as bounds, but their duals are not recovered.
  • Constraints modified or removed later: modified ones are re-validated at lowering, removed ones simply stop folding.
  • IIS (compute_infeasibilities): only reports matrix rows.
  • netCDF: round-trips the flag.

Open design questions

  1. Name: as_bound. Alternatives are fold_bounds or bound=True.
  2. Dual recovery rebuilds m.matrices once in assign_result, which costs one extra matrix build per solve when folded constraints exist. Alternatives: cache the matrices from the build, or read native reduced costs (HiGHS col_dual, Gurobi RC) through a new Solution.reduced_cost field.
  3. Ties: a row wins over an equal variable bound, and the first row wins between equal rows. In degenerate optima this can attribute the dual differently from what the solver would pick for an ordinary row.
  4. Should other solvers be added to BOUND_ROW_DUAL_SOLVERS once verified in CI (e.g. Xpress, CPLEX, MOSEK)?
  5. ConstraintLabelIndex.clabels now excludes folded constraints, so they are not matrix rows anywhere: the solver, the IIS mapping, persistent and restrict. Constraints.to_matrix() still includes them.
Files and tests
  • linopy/matrices.py: BoundRows, _fold_bound_rows, bound_row_duals, folded_bounds, BOUND_ROW_DUAL_SOLVERS. Matrices.bound_rows is a new optional field.
  • linopy/model.py: the as_bound argument, and _assign_bound_row_duals called from assign_result.
  • linopy/constraints.py: Constraint.as_bound, Constraint.check_bound_rows, Constraints.as_bound, and the soften guard.
  • linopy/common.py: clabels excludes folded constraints.
  • linopy/io.py: the LP writer skips folded rows and writes the folded bounds, including on binaries.
  • linopy/persistent/diff.py: RebuildReason.BOUND_ROWS.
  • linopy/dualization.py: dualizes folded constraints as ordinary rows.
  • Docs: doc/bound-rows.rst, the index, the user guide, and a release note under Version 1.0.0 / Performance.
  • test/test_bound_rows.py: 49 tests.
  • Full suite: pytest test -q gives 5306 passed, 117 skipped. ruff check, ruff format --check and mypy linopy are clean.

No benchmark on PyPSA-sized models was run in this PR.

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).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

Generated by Claude Code

Add `Model.add_constraints(..., as_bound=True)`. Every active row of such a
constraint must hold exactly one variable term `c * x`; when the model is
lowered (Model.matrices, direct solver APIs, LP and MPS writers) the row
tightens the bounds of `x` to `rhs / c` instead of becoming a matrix row.
Several rows on a column and the variable's own bounds combine to the
tightest; `Matrices.bound_rows` records which row sets each side.

After the solve, the duals of these rows are recovered from the reduced
costs `d = c + Q x - A^T y` (user units): the row setting the active bound
gets `d / c`, all others 0. Verified against ordinary rows for HiGHS and
Gurobi (direct, LP, MPS; min/max; LP/QP; with variable, constraint and
objective scaling). Other solvers get NaN duals with a warning.

Persistent updates rebuild (RebuildReason.BOUND_ROWS), dualize treats the
rows as ordinary rows, penalty/soften and semi-continuous variables are
rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4nx8GJ8mSNvkTagja9dMj
@codspeed

codspeed Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 note

ℹ️ 181 benchmarks have no flamegraph

The flamegraph wasn't generated: the symbols of the profiling trace could not be resolved, because no information about the language frames was collected.

See affected benchmarks

✅ 181 untouched benchmarks
⏩ 181 skipped benchmarks1


Comparing feat/singleton-rows-as-bounds (6ca29f2) with master (22a1882)

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. ↩

- Skip dual recovery for mixed-integer models (NaN, no warning); HiGHS
  returned c/coeff on folded rows of a MILP.
- Skip folded constraints in the generic dual loop.
- Verify dual recovery for CPLEX, Xpress and COPT too; tests derive
  their solvers from BOUND_ROW_DUAL_SOLVERS.
- Drop BoundRows.take, Constraints.as_bound and the unreachable
  indicator branch; reuse _row_block and one validation path.
- Fold bounds in user units and scale them only in Matrices, so the LP
  writer no longer scales and unscales them.
- Simpler _tightest; exclude folded rows from ncons; trim docs.
- Tests for constant lhs, row scaling, masked rows, infeasibility and
  tie rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4nx8KhK8QzSzHhpYKvcSx
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4nx8GJ8mSNvkTagja9dMj
@FabianHofmann

Copy link
Copy Markdown
Collaborator Author

Note: this is quite an experimental feature and I am not sure whether we want to have that. it is nice, but also feels "hacky". we should make some research for a) alternatives and b) whether other communities use such a feature

This branch has not been deployed

No deployments
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.

2 participants