Skip to content

fix(io): write LP bounds for tightened binary variables#777

Open
FBumann wants to merge 2 commits into
masterfrom
fix/lp-export-binary-bounds
Open

fix(io): write LP bounds for tightened binary variables#777
FBumann wants to merge 2 commits into
masterfrom
fix/lp-export-binary-bounds

Conversation

@FBumann

@FBumann FBumann commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Note

The following content was generated by AI.

Summary

Closes #776.

Per-element bounds set below the implied [0, 1] on a binary variable (e.g. masking out a subset of entries with upper = 0) were silently dropped by LP file export. Such binaries appeared only in the binary section, which implies [0, 1]. The direct/matrix API honored the bounds (matrices._build_vars reads var.lower/var.upper unconditionally), so the same model had a different feasible set depending on io_api — a silent correctness trap where a model correct under io_api="direct" relaxed when solved through an LP file.

#773 had already added bound rows for fully-fixed binaries; this extends the same treatment to any binary whose bounds differ from (0, 1) anywhere.

Change

bounds_to_file now selects a binary for the bounds section when it has any non-default bound, via a small helper:

def _binary_has_nondefault_bounds(var):
    return bool((var.lower.values != 0).any() or (var.upper.values != 1).any())
  • All-default binaries (the common case) still emit no bounds rows — one cheap vectorised .any() per binary, no change to output.
  • Masked slots are tolerated: a false positive only routes the variable through the bounds loop, where masked labels (-1) are already dropped before writing.
  • The matrix/direct path is intentionally left unchanged: it builds dense per-column bound arrays with no implicit [0, 1] default to skip, and it is already correct.

Before / after (repro from the issue)

m = linopy.Model()
x = m.add_variables(binary=True, coords=[pd.RangeIndex(4, name="t")], name="x")
x.upper = pd.Series([1, 1, 0, 0], index=pd.RangeIndex(4, name="t"))
m.add_constraints(x.sum() >= 2, name="atleast2")
m.add_objective(-1 * x.sum())
  • Before: LP file had no bounds section → optimum -4; direct → -2.
  • After: both paths → -2.

Tests

  • test_to_file_lp_binary_default_bounds_omitted — default binary emits no bounds section.
  • test_to_file_lp_binary_tightened_bounds — tightened entries reach the LP bounds section.
  • test_lp_and_direct_agree_on_binary_bounds — the two paths agree on the optimum.

Follow-up

A separate PR will relax add_variables(binary=True, ...) to accept {0, 1} bounds at construction (currently raises), per the issue comment. Kept separate because that feature depends on this export fix being in place.

FBumann and others added 2 commits June 13, 2026 15:57
Per-element bounds set below the implied [0, 1] on a binary variable
(e.g. masking out entries with upper = 0) were silently dropped by the LP
file export: binaries appeared only in the `binary` section. The direct
API honored them, so the same model relaxed when solved through
io_api="lp". bounds_to_file now emits a bounds row for any binary whose
bounds differ from (0, 1) anywhere, matching the direct path.

Closes #776

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.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.

LP file export silently drops per-element bounds on binary variables, diverging from direct API

1 participant