Skip to content

Fix variables via bound collapse instead of equality constraint#773

Merged
FabianHofmann merged 6 commits into
fix/fix-value-coord-alignmentfrom
feat/fix-bound-collapse
Jun 9, 2026
Merged

Fix variables via bound collapse instead of equality constraint#773
FabianHofmann merged 6 commits into
fix/fix-value-coord-alignmentfrom
feat/fix-bound-collapse

Conversation

@FBumann

@FBumann FBumann commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Note

AI-assisted implementation.

Closes #769.

Important

Stacked on #774 (the Variable.fix() value-alignment bug fix). This PR's base is fix/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

How

  • fix(value) stashes the pre-fix lower/upper as _stashed_lower / _stashed_upper data variables on the variable's own Dataset, then collapses the bounds. unfix() restores them; fixed checks for the stash.
  • The stash rides on var.data, so it round-trips through netcdf for free — no separate registry to serialize. (relax / _relaxed_registry is left untouched.)
  • A fix value outside the current bounds warns and overrides (avoiding the old fix >= ub infeasibility); fixing a binary to anything other than 0/1 raises.
  • Removes FIX_CONSTRAINT_PREFIX and 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:

  • LP writer — binaries were emitted with no bounds; now a fixed binary's explicit bounds are written.
  • HiGHS direct — dropped a changeColsBounds(..., 0, 1) override.
  • Mosek direct — dropped a 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)

  • A fix value outside the current bounds warns and overrides instead of raising.
  • The marginal of a fix appears as the variable's reduced cost instead of a constraint dual.
  • model.constraints no 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

@FBumann FBumann force-pushed the feat/fix-bound-collapse branch from 4f90913 to 0f7a039 Compare June 8, 2026 17:19
@FBumann FBumann changed the base branch from master to fix/fix-value-coord-alignment June 8, 2026 17:19
@FBumann FBumann force-pushed the fix/fix-value-coord-alignment branch from da634cc to 7b657f1 Compare June 8, 2026 20:00
@FBumann FBumann force-pushed the feat/fix-bound-collapse branch from 0f7a039 to f974087 Compare June 8, 2026 20:01
@FBumann FBumann force-pushed the fix/fix-value-coord-alignment branch from 7b657f1 to 21e97dc Compare June 8, 2026 20:02
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>
@FBumann FBumann force-pushed the feat/fix-bound-collapse branch from f974087 to 453b89d Compare June 8, 2026 20:03
FabianHofmann and others added 3 commits June 9, 2026 08:03
…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.
@FabianHofmann FabianHofmann marked this pull request as ready for review June 9, 2026 06:10
@FabianHofmann FabianHofmann merged commit d984785 into fix/fix-value-coord-alignment Jun 9, 2026
2 checks passed
@FabianHofmann FabianHofmann deleted the feat/fix-bound-collapse branch June 9, 2026 06:46
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>
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.

Variable.fix: move from equality-constraint to bound-collapse semantics

2 participants