Fix variables via bound collapse instead of equality constraint#773
Merged
FabianHofmann merged 6 commits intoJun 9, 2026
Merged
Conversation
4f90913 to
0f7a039
Compare
da634cc to
7b657f1
Compare
0f7a039 to
f974087
Compare
7b657f1 to
21e97dc
Compare
Variable.fix() now collapses lower = upper = value (the JuMP/Pyomo/gurobipy meaning of "fix") instead of adding a __fix__ equality constraint. Pre-fix bounds are stashed as _stashed_lower / _stashed_upper data variables on the variable's own Dataset, so unfix() restores them and the state round-trips through netcdf for free. Fixing is now a pure value change, so fix/re-solve loops stay on the persistent in-place update path instead of forcing a solver rebuild, and model.constraints is no longer polluted with __fix__ entries. A fix value outside the current bounds warns and overrides; fixing a binary to anything other than 0/1 raises. Removes FIX_CONSTRAINT_PREFIX and the __fix__ cleanup in remove_variables. Honoring a fixed binary that stays binary required fixing binary-bound export, which several paths hardcoded to [0, 1]: the LP writer now emits explicit bounds for fixed binaries, and the HiGHS and Mosek direct backends no longer override binary bounds (they already come from M.lb/M.ub). This also fixes the latent bug that a binary's bounds could not be restricted at all. Tests: bound-collapse unit coverage in test_fix_relax.py, and a cross-solver, cross-io integration test (test_fixed_variable_is_held) asserting the fix is honored when solving for continuous, integer and binary, in both bound directions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
f974087 to
453b89d
Compare
…eneralized STASHED_ATTRS fix() rejects non-0/1 binary values (np.isclose) before rounding; flat/to_polars drop internal stash vars; bound checks use direct attrs subscript and hoisted locals.
for more information, see https://pre-commit.ci
FabianHofmann
added a commit
that referenced
this pull request
Jun 9, 2026
* fix: align Variable.fix() value to the variable's coordinates fix() converted the value with as_dataarray().broadcast_like(self.labels), which aligns only by dimension name and so worked solely for the default `dim_0`. On a named dimension, a positional value (list/array) gained a spurious `dim_0` and broadcast across the real dimension instead of onto it, silently building a wrong fix constraint (one fixing every entry to every value). Use broadcast_to_coords against the variable's own coords — the same coords- aware alignment add_variables uses for lower/upper: scalars broadcast, positional inputs land on the right dimension, named pandas/xarray inputs align by coordinate value, and a mismatch raises an error naming the variable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: parametrize fix() value alignment over dtypes and rejection cases * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix types * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix variables via bound collapse instead of equality constraint (#773) * feat: fix variables via bound collapse, honoring bounds at export Variable.fix() now collapses lower = upper = value (the JuMP/Pyomo/gurobipy meaning of "fix") instead of adding a __fix__ equality constraint. Pre-fix bounds are stashed as _stashed_lower / _stashed_upper data variables on the variable's own Dataset, so unfix() restores them and the state round-trips through netcdf for free. Fixing is now a pure value change, so fix/re-solve loops stay on the persistent in-place update path instead of forcing a solver rebuild, and model.constraints is no longer polluted with __fix__ entries. A fix value outside the current bounds warns and overrides; fixing a binary to anything other than 0/1 raises. Removes FIX_CONSTRAINT_PREFIX and the __fix__ cleanup in remove_variables. Honoring a fixed binary that stays binary required fixing binary-bound export, which several paths hardcoded to [0, 1]: the LP writer now emits explicit bounds for fixed binaries, and the HiGHS and Mosek direct backends no longer override binary bounds (they already come from M.lb/M.ub). This also fixes the latent bug that a binary's bounds could not be restricted at all. Tests: bound-collapse unit coverage in test_fix_relax.py, and a cross-solver, cross-io integration test (test_fixed_variable_is_held) asserting the fix is honored when solving for continuous, integer and binary, in both bound directions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(fix): strict binary validation, no stash-var leak in flat, generalized STASHED_ATTRS fix() rejects non-0/1 binary values (np.isclose) before rounding; flat/to_polars drop internal stash vars; bound checks use direct attrs subscript and hoisted locals. * refact comments and tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Fabian <fab.hof@gmx.de> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * fix: remove unnneded import in test file * test: assert fix() collapses bounds instead of adding a constraint --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Fabian <fab.hof@gmx.de> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
AI-assisted implementation.
Closes #769.
Important
Stacked on #774 (the
Variable.fix()value-alignment bug fix). This PR's base isfix/fix-value-coord-alignment; review/merge #774 first, then this diff shows only the bound-collapse + binary-export work.What
Variable.fix()now fixes a variable by collapsing its bounds (lower = upper = value) — the meaning of "fix" in JuMP, Pyomo and gurobipy — instead of adding a__fix__equality constraint.Why
__fix__constraint row changes the active label set → full solver rebuild; a bound change is a pure value update and stays in-place. This is the path Design: Model.restrict / advance — slice a model along a coordinate and condition on boundary values (rolling horizon) #768 (restrict/advance) depends on.__fix__namespace pollution, noremove_variablescleanup hook.How
fix(value)stashes the pre-fixlower/upperas_stashed_lower/_stashed_upperdata variables on the variable's own Dataset, then collapses the bounds.unfix()restores them;fixedchecks for the stash.var.data, so it round-trips through netcdf for free — no separate registry to serialize. (relax/_relaxed_registryis left untouched.)fix >= ubinfeasibility); fixing a binary to anything other than 0/1 raises.FIX_CONSTRAINT_PREFIXand all__fix__handling.Binary bounds at export (latent bug)
Fixing a binary that stays binary requires the solver to see its collapsed bounds. Several export paths hardcoded
[0, 1]for binaries and silently freed a fixed binary:changeColsBounds(..., 0, 1)override.putvarboundlistconst([0, 1])override.Gurobi / Xpress / cuPDLPx direct and the MPS path were already correct. This also fixes the standalone bug that a binary's bounds could not be restricted at all.
Behavioral changes (release note)
model.constraintsno longer lists__fix__*entries.No deprecation shim — the API is recent (v0.7.0) and unused downstream (PyPSA does not use it).
Tests
test_fix_relax.py— unit coverage of collapse / stash / unfix-restore / netcdf round-trip / binary-domain raise / overwrite-keeps-original-stash.test_optimization.py::test_fixed_variable_is_held— cross-solver, cross-io integration test that solves and asserts the fix is honored, for continuous / integer / binary and both bound directions. Verified locally on cbc, cplex, gurobi, highs, mosek, scip, xpress across lp / lp-polars / mps / direct.🤖 Generated with Claude Code