Skip to content

Commit 9fe3ad2

Browse files
committed
fix(common): reject unnamed-MultiIndex inputs in strict validation
validate_alignment unwrapped only bare pd.MultiIndex coord entries, so Coordinates-backed (DataArray) MI dims read as non-MI and skipped the equality check. Use _as_multiindex on both sides to catch mismatches regardless of level names.
1 parent a868798 commit 9fe3ad2

3 files changed

Lines changed: 31 additions & 10 deletions

File tree

doc/release_notes.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Most users should keep calling ``model.solve(...)``. If you want more control, y
5656
**Bug Fixes**
5757

5858
* ``add_variables`` / ``add_constraints``: extends 0.7.0's coords-as-truth rule to ``lower``, ``upper`` and ``mask`` for every bound type and dim order. Pandas ``Series`` / ``DataFrame`` bounds or masks missing a dimension are broadcast to ``coords`` instead of being silently dropped (`#709 <https://github.com/PyPSA/linopy/issues/709>`__); the variable's dimension order always follows ``coords`` (`#706 <https://github.com/PyPSA/linopy/issues/706>`__); bare-tuple coord entries (``coords=[(0, 1, 2)]``) now behave like lists. Mismatched values or extra dims raise ``ValueError`` with a labelled message; sparse-coord masks (formerly a v0.6.3 ``FutureWarning``, #580) raise ``ValueError``, and masks with dims not in the data raise ``ValueError`` instead of ``AssertionError``.
59-
* Pandas inputs whose index names *levels* of a stacked-``MultiIndex`` ``coords`` dimension are now projected onto that dimension: a level subset broadcasts across the others, the full set aligns element-wise. This fixes PyPSA multi-investment arithmetic (e.g. an expression over a ``(period, timestep)`` ``snapshot`` MultiIndex times a ``period``-indexed weighting). In ``add_variables`` / ``add_constraints`` the input must provide a value for every level combination of the MultiIndex or a ``ValueError`` is raised (the error lists the missing combinations). **Implicit level projections are deprecated**: they emit an ``EvolvingAPIWarning`` everywhere — in arithmetic *and* in ``add_variables`` / ``add_constraints`` — and will raise under the upcoming v1 convention. Project the input onto the dimension explicitly (select with the dimension's level values) to keep current behavior. Aligning the full level set with full coverage stays silent.
59+
* Pandas inputs whose index names *levels* of a stacked-``MultiIndex`` ``coords`` dimension are now projected onto that dimension: a level subset broadcasts across the others, the full set aligns element-wise. This fixes PyPSA multi-investment arithmetic (e.g. an expression over a ``(period, timestep)`` ``snapshot`` MultiIndex times a ``period``-indexed weighting). In ``add_variables`` / ``add_constraints`` the input must provide a value for every level combination of the MultiIndex or a ``ValueError`` is raised (the error lists the missing combinations). **Implicit level projections are deprecated**: they emit an ``EvolvingAPIWarning`` everywhere — in arithmetic *and* in ``add_variables`` / ``add_constraints`` — and will raise under the upcoming v1 convention. Project the input onto the dimension explicitly (select with the dimension's level values) to keep current behavior. Aligning the full level set with full coverage stays silent. Strict validation also rejects a ``MultiIndex`` input with *unnamed* levels whose combinations don't match ``coords`` (previously a silent bypass, as such inputs can't be projected by level name).
6060
* ``add_piecewise_formulation`` now produces a reproducible dimension order in the broadcast breakpoint array. The previous set-based expansion gave a hash-randomized order that varied between processes.
6161
* SOS constraints on masked variables no longer cause solver-specific failures (Gurobi ``IndexError``, Xpress ``?404 Invalid column number``, LP parse errors, silent set corruption). ``Model.solve()`` and ``Model.to_file()`` now raise a clear ``NotImplementedError`` referring users to `#688 <https://github.com/PyPSA/linopy/issues/688>`__; pass ``reformulate_sos=True`` as a workaround.
6262
* ``Model.solve(..., reformulate_sos=True)`` now actually reformulates SOS constraints even when the solver supports them natively. Previously it was silently ignored with a warning.

linopy/common.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -708,15 +708,18 @@ def validate_alignment(
708708
for dim, coord_values in expected.items():
709709
if dim not in arr.dims:
710710
continue
711-
expected_is_mi = isinstance(coord_values, pd.MultiIndex)
712-
actual_is_mi = isinstance(arr.indexes.get(dim), pd.MultiIndex)
713-
if expected_is_mi or actual_is_mi:
714-
if expected_is_mi and actual_is_mi:
715-
if not arr.indexes[dim].equals(coord_values):
716-
raise ValueError(
717-
f"{subject}: MultiIndex for dimension {dim!r} does not "
718-
f"match coords."
719-
)
711+
expected_mi = _as_multiindex(coord_values)
712+
actual_mi = _as_multiindex(arr.indexes.get(dim))
713+
if expected_mi is not None or actual_mi is not None:
714+
if (
715+
expected_mi is None
716+
or actual_mi is None
717+
or not actual_mi.equals(expected_mi)
718+
):
719+
raise ValueError(
720+
f"{subject}: MultiIndex for dimension {dim!r} does not "
721+
f"match coords."
722+
)
720723
continue
721724
expected_idx = _as_index(coord_values)
722725
actual_idx = arr.coords[dim].to_index()

test/test_common.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,24 @@ def test_broadcast_to_coords_rejects_multiindex_coverage_gap() -> None:
704704
broadcast_to_coords(weights, coords, dims=["dim_3"], label="lower bound")
705705

706706

707+
def test_broadcast_to_coords_rejects_unnamed_multiindex_mismatch() -> None:
708+
"""
709+
A MultiIndex input with unnamed levels cannot be projected by level name,
710+
so it keeps its own index under the coords dim. The strict rung must still
711+
reject it when its level combinations don't cover coords, just as the
712+
named-level coverage-gap case does.
713+
"""
714+
idx = pd.MultiIndex.from_product([[2020, 2030], ["t1", "t2"]], names=("p", "t"))
715+
idx.name = "snapshot"
716+
coords = xr.Coordinates.from_pandas_multiindex(idx, "snapshot")
717+
sparse_unnamed = pd.Series({(2020, "t1"): 1.0, (2030, "t2"): 2.0})
718+
719+
with pytest.raises(ValueError, match=r"MultiIndex for dimension 'snapshot'"):
720+
broadcast_to_coords(
721+
sparse_unnamed, coords, dims=["snapshot"], label="lower bound"
722+
)
723+
724+
707725
def test_broadcast_to_coords_strict_partial_level_warns() -> None:
708726
"""
709727
Per-level bounds broadcast across the MI dim, with the deprecation warning.

0 commit comments

Comments
 (0)