From 2c6306489df9925c77483bb188b55a5bc2dc25c9 Mon Sep 17 00:00:00 2001 From: Stefan Slavkovsky Date: Sun, 21 Jun 2026 21:28:20 +0200 Subject: [PATCH 1/2] fix(constraints): freeze empty constraint groups without reshape error CSRConstraint.from_mutable reshaped con.vars with an inferred -1 dimension. For an empty constraint group (zero rows and zero terms) the vars array has size 0, and NumPy refuses to infer a (0, -1) reshape, raising "cannot reshape array of size 0 into shape (0,newaxis)". This broke the documented lossless freeze round-trip for legitimately empty groups (e.g. shifted-time difference constraints at n_time == 1). Pass the explicit _term count instead of -1; NumPy accepts a (0, 0) reshape and the rest of the method already handles zero-row input. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/release_notes.rst | 1 + linopy/constraints.py | 5 ++++- test/test_constraint.py | 25 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 7ac12867..cd70a2b7 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -9,6 +9,7 @@ Upcoming Version **Bug fixes** * LP file export now honors bounds tightened below ``[0, 1]`` on a binary variable via the ``.lower``/``.upper`` setters after creation (e.g. ``upper = 0``). Previously such bounds were written only by ``io_api="direct"`` and dropped by ``io_api="lp"``. (https://github.com/PyPSA/linopy/issues/776) +* Freezing an empty constraint group (e.g. an empty ``isel`` slice) no longer raises ``ValueError: cannot reshape array of size 0``. ``Model(freeze_constraints=True)`` and ``Constraint.freeze()`` now round-trip zero-row constraints losslessly. Version 0.8.0 ------------- diff --git a/linopy/constraints.py b/linopy/constraints.py index 96e2a843..2d43e399 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1081,7 +1081,10 @@ def from_mutable( # Build active_mask aligned with con_labels (rows in csr) # Use same filter as to_matrix: label != -1 AND at least one var != -1 labels_flat = con.labels.values.ravel() - vars_flat = con.vars.values.reshape(len(labels_flat), -1) + # Explicit term count, not -1: NumPy can't infer a (0, -1) reshape for + # an empty group (size-0 vars). _term is the trailing dim of con.vars. + nterm = con.vars.sizes.get(TERM_DIM, 0) + vars_flat = con.vars.values.reshape(len(labels_flat), nterm) active_mask = (labels_flat != -1) & (vars_flat != -1).any(axis=1) rhs = con.rhs.values.ravel()[active_mask] sign_vals = con.sign.values.ravel() diff --git a/test/test_constraint.py b/test/test_constraint.py index a684b966..5063c072 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -95,6 +95,31 @@ def test_add_constraints_uses_model_freeze_default() -> None: ) +def test_csr_constraint_handles_empty_rows() -> None: + """ + An empty constraint group must round-trip through freeze. + + Regression test: `CSRConstraint.from_mutable` used to reshape + `con.vars` with an inferred `-1` dimension, which NumPy refuses for a + size-0 array (`cannot reshape array of size 0 into shape (0,newaxis)`). + """ + m = Model(freeze_constraints=True) + x = m.add_variables( + lower=0.0, + coords=[range(3), range(2)], + dims=["time", "product"], + name="x", + ) + empty = x.isel(time=range(1, 1)) + c = m.add_constraints(empty == 0, name="empty") + assert isinstance(c, linopy.constraints.CSRConstraint) + assert c.size == 0 + # Solving a model with only an empty constraint group is also fine. + m.add_objective(x.sum()) + m.solve("highs", io_api="direct", output_flag=False) + assert m.status == "ok" + + def test_constraint_name(c: linopy.constraints.CSRConstraint) -> None: assert c.name == "c" From 520645e5a8bc5be7d500ffd463329bc49a55bf5e Mon Sep 17 00:00:00 2001 From: Stefan Slavkovsky Date: Mon, 22 Jun 2026 10:15:15 +0200 Subject: [PATCH 2/2] chore: code review --- linopy/constraints.py | 5 +---- test/test_constraint.py | 32 ++++++++++++++------------------ 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index 2d43e399..fce1e5a6 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -1081,10 +1081,7 @@ def from_mutable( # Build active_mask aligned with con_labels (rows in csr) # Use same filter as to_matrix: label != -1 AND at least one var != -1 labels_flat = con.labels.values.ravel() - # Explicit term count, not -1: NumPy can't infer a (0, -1) reshape for - # an empty group (size-0 vars). _term is the trailing dim of con.vars. - nterm = con.vars.sizes.get(TERM_DIM, 0) - vars_flat = con.vars.values.reshape(len(labels_flat), nterm) + vars_flat = con.vars.values.reshape(len(labels_flat), con.nterm) active_mask = (labels_flat != -1) & (vars_flat != -1).any(axis=1) rhs = con.rhs.values.ravel()[active_mask] sign_vals = con.sign.values.ravel() diff --git a/test/test_constraint.py b/test/test_constraint.py index 5063c072..b8075edd 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -95,15 +95,20 @@ def test_add_constraints_uses_model_freeze_default() -> None: ) -def test_csr_constraint_handles_empty_rows() -> None: - """ - An empty constraint group must round-trip through freeze. +def test_constraint_name(c: linopy.constraints.CSRConstraint) -> None: + assert c.name == "c" - Regression test: `CSRConstraint.from_mutable` used to reshape - `con.vars` with an inferred `-1` dimension, which NumPy refuses for a - size-0 array (`cannot reshape array of size 0 into shape (0,newaxis)`). - """ - m = Model(freeze_constraints=True) + +def test_empty_constraints_repr() -> None: + # test empty contraints + Model().constraints.__repr__() + + +@pytest.mark.parametrize("freeze_constraints", [True, False]) +def test_constraint_handles_empty_rows(freeze_constraints: bool) -> None: + """An empty constraint group must be accepted and solve cleanly.""" + + m = Model(freeze_constraints=freeze_constraints) x = m.add_variables( lower=0.0, coords=[range(3), range(2)], @@ -112,7 +117,7 @@ def test_csr_constraint_handles_empty_rows() -> None: ) empty = x.isel(time=range(1, 1)) c = m.add_constraints(empty == 0, name="empty") - assert isinstance(c, linopy.constraints.CSRConstraint) + assert isinstance(c, linopy.constraints.ConstraintBase) assert c.size == 0 # Solving a model with only an empty constraint group is also fine. m.add_objective(x.sum()) @@ -120,15 +125,6 @@ def test_csr_constraint_handles_empty_rows() -> None: assert m.status == "ok" -def test_constraint_name(c: linopy.constraints.CSRConstraint) -> None: - assert c.name == "c" - - -def test_empty_constraints_repr() -> None: - # test empty contraints - Model().constraints.__repr__() - - def test_cannot_create_constraint_without_variable() -> None: model = linopy.Model() with pytest.raises(ValueError):