From 8e749648cbe5befc16065cb3f5bc487cb2aba7d0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:55:11 +0200 Subject: [PATCH 1/3] test: xfail sum() over a dim when a sibling dim is empty (#906) Co-Authored-By: Claude Fable 5 --- test/test_linear_expression.py | 37 +++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index 454245aa..855e54e2 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -30,7 +30,7 @@ merge, options, ) -from linopy.constants import HELPER_DIMS, TERM_DIM +from linopy.constants import FACTOR_DIM, HELPER_DIMS, TERM_DIM from linopy.expressions import ScalarLinearExpression from linopy.testing import assert_linequal, assert_quadequal from linopy.variables import ScalarVariable @@ -455,6 +455,41 @@ def test_linear_expression_sum( assert len(expr.coords["dim_2"]) == 10 +@pytest.mark.parametrize( + "dim", + [ + pytest.param( + "line", + marks=pytest.mark.xfail( + strict=True, + raises=ValueError, + reason="https://github.com/PyPSA/linopy/issues/906", + ), + ), + ["line", "cycle"], + None, + ], +) +def test_linear_expression_sum_with_empty_sibling_dim( + m: Model, dim: str | list[str] | None +) -> None: + coords = [ + pd.RangeIndex(4, name="t"), + pd.Index([], name="cycle"), + pd.Index(["a", "b"], name="line"), + ] + x = m.add_variables(coords=coords, name="xe") + expr = 2 * x + + summed = expr.sum(dim) + reduced = expr.const.sum(dim) + assert summed.sizes == {**reduced.sizes, TERM_DIM: summed.nterm} + assert_linequal(summed, LinearExpression.from_constant(m, reduced)) + quad = (x * x).sum(dim) + assert quad.sizes == {**reduced.sizes, FACTOR_DIM: 2, TERM_DIM: quad.nterm} + assert quad.nterm == summed.nterm + + @pytest.mark.legacy def test_linear_expression_sum_with_const( x: Variable, y: Variable, z: Variable, v: Variable From a9906671543c9437ea76ba2f36aacd3bcfc1d787 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:53:17 +0200 Subject: [PATCH 2/3] refactor: extract _stack_into_term_dim from BaseExpression._sum Co-Authored-By: Claude Fable 5 --- linopy/expressions.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index b7e05205..94b41df7 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -181,6 +181,11 @@ def _expr_unwrap( logger = logging.getLogger(__name__) +def _stack_into_term_dim(ds: Dataset, dims: list[Hashable]) -> Dataset: + """Stack ``dims`` into the term dimension.""" + return ds.stack({TERM_DIM: dims}, create_index=False) + + def _drop_coords_on_dims(ds: Dataset, dims: Iterable[Hashable]) -> Dataset: """ Drop every coordinate touching the given dimensions. @@ -2135,11 +2140,10 @@ def _sum( ds = xr.Dataset({"vars": vars, "coeffs": coeffs, "const": const}) else: dim = [d for d in dim if d != TERM_DIM] - ds = ( - _drop_coords_on_dims(data[["coeffs", "vars"]], dim) - .rename({TERM_DIM: STACKED_TERM_DIM}) - .stack({TERM_DIM: [STACKED_TERM_DIM] + dim}, create_index=False) + ds = _drop_coords_on_dims(data[["coeffs", "vars"]], dim).rename( + {TERM_DIM: STACKED_TERM_DIM} ) + ds = _stack_into_term_dim(ds, [STACKED_TERM_DIM] + dim) ds = assign_multiindex_safe(ds, const=data.const.sum(dim)) return ds From 653160d31a7cd7f9efbacbe6642c77876fa7eefa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:53:18 +0200 Subject: [PATCH 3/3] fix: sum() over a dim when a sibling dim is empty (#906) Co-Authored-By: Claude Fable 5 --- doc/release_notes.rst | 1 + linopy/expressions.py | 17 +++++++++++++++-- test/test_linear_expression.py | 16 +--------------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 693293eb..cb2784da 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -53,6 +53,7 @@ Upcoming Version **Bug fixes** +* ``sum()`` over a dimension no longer raises when another dimension of the expression has size 0; it returns an expression without terms over the kept coordinates, as summing over the empty dimension itself already did. (https://github.com/PyPSA/linopy/issues/906) * A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``. diff --git a/linopy/expressions.py b/linopy/expressions.py index 94b41df7..f4b93823 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -182,8 +182,21 @@ def _expr_unwrap( def _stack_into_term_dim(ds: Dataset, dims: list[Hashable]) -> Dataset: - """Stack ``dims`` into the term dimension.""" - return ds.stack({TERM_DIM: dims}, create_index=False) + """ + Stack ``dims`` into the term dimension. + + ``Dataset.stack`` reshapes with an inferred ``-1`` that numpy cannot + resolve on zero-element data (https://github.com/PyPSA/linopy/issues/906), + so an empty dataset is stacked onto a term dimension of size 0 instead. + """ + if ds.vars.size: + return ds.stack({TERM_DIM: dims}, create_index=False) + + for name, da in ds.data_vars.items(): + kept = [d for d in da.dims if d not in dims] + shape = [da.sizes[d] for d in kept] + [0] + ds[name] = DataArray(da.values.reshape(shape), dims=kept + [TERM_DIM]) + return ds def _drop_coords_on_dims(ds: Dataset, dims: Iterable[Hashable]) -> Dataset: diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index 855e54e2..7550bb6b 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -455,21 +455,7 @@ def test_linear_expression_sum( assert len(expr.coords["dim_2"]) == 10 -@pytest.mark.parametrize( - "dim", - [ - pytest.param( - "line", - marks=pytest.mark.xfail( - strict=True, - raises=ValueError, - reason="https://github.com/PyPSA/linopy/issues/906", - ), - ), - ["line", "cycle"], - None, - ], -) +@pytest.mark.parametrize("dim", ["line", ["line", "cycle"], None]) def test_linear_expression_sum_with_empty_sibling_dim( m: Model, dim: str | list[str] | None ) -> None: