diff --git a/doc/release_notes.rst b/doc/release_notes.rst index ea9416ad..d8f48962 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -43,7 +43,7 @@ Upcoming Version *Other* -* New method :meth:`linopy.Model.assign_coords` reassigns coordinate values across an existing model — variables, constraints (dense and CSR-backed), expressions and parameters — without changing the model's shape: ``m.assign_coords(snapshot=new_snapshots)``. Values-only: the new values must match the length of the dimension's full-index container, and containers holding subsets of the dimension are mapped by label, preserving the subset relation. Dataset variable order is preserved. Under v1 semantics, ``Model.solve()`` raises when containers carry labels on a shared dimension that are neither equal nor subsets of one another. Typical use is advancing the window in rolling-horizon optimization with the persistent solver interface. (https://github.com/PyPSA/linopy/issues/767) +* New method :meth:`linopy.Model.assign_coords` reassigns coordinate values across an existing model — variables, constraints (dense and CSR-backed), expressions and parameters — without changing the model's shape: ``m.assign_coords(snapshot=new_snapshots)``. Values-only: the new values must match the length of the dimension's full-index container, and containers holding subsets of the dimension are mapped by label, preserving the subset relation. Dataset variable order is preserved. Typical use is advancing the window in rolling-horizon optimization with the persistent solver interface. (https://github.com/PyPSA/linopy/issues/767) * ``add_piecewise_formulation`` gained a ``mask`` parameter declaring which breakpoint slots hold a real breakpoint. It is needed for **ragged** curves — entities with different numbers of breakpoints — which are stored densely with the surplus slots left absent. Under v1 that absence must be declared (``mask=x_pts.notnull()``) rather than read off the NaN padding. (https://github.com/PyPSA/linopy/issues/884) * ``Model.compute_infeasibilities`` now also supports HiGHS, via ``Highs.getIis``. (https://github.com/PyPSA/linopy/issues/956) diff --git a/linopy/constants.py b/linopy/constants.py index 77ef5ff4..c07676b9 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -72,7 +72,7 @@ class PerformanceWarning(UserWarning): PWL_CONVEXITIES: frozenset[str] = frozenset(get_args(PWL_CONVEXITY)) """Set of valid :data:`~linopy.constants.PWL_CONVEXITY` values.""" # All internal dims are underscore-prefixed — user-facing dims never start -# with "_". ``Model._check_coord_consistency`` relies on this to exempt them. +# with "_". BREAKPOINT_DIM = "_breakpoint" SEGMENT_DIM = "_segment" LP_PIECE_DIM = f"{BREAKPOINT_DIM}_piece" diff --git a/linopy/model.py b/linopy/model.py index 1e4f005b..badf8c2c 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -10,7 +10,7 @@ import os import re import warnings -from collections.abc import Callable, Hashable, Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from pathlib import Path from tempfile import NamedTemporaryFile, gettempdir from types import MappingProxyType @@ -2194,8 +2194,6 @@ def solve( sanitize_zeros=sanitize_zeros, sanitize_infinities=sanitize_infinities ) - self._check_coord_consistency() - # check io_api if io_api is not None and io_api not in IO_APIS: raise ValueError( @@ -2466,46 +2464,6 @@ def _coordinate_carriers(self) -> list[Any]: *self.expressions.data.values(), ] - def _check_coord_consistency(self) -> None: - """ - Raise if containers carry incompatible labels on a shared dimension. - - v1-only guard: under v1 semantics (convention §8) shared dimensions - must carry identical labels, and models can only diverge through - internal-state corruption. Under legacy, non-aligned containers are - documented positional behavior and the check is a no-op. - - Allowed are label sets that nest by inclusion (a container may hold a - subset of the dimension, like a piecewise commitment gate); what - cannot be aligned by subset — same-length relabelings, disjoint or - partially overlapping labels — raises. Internal dims - (underscore-prefixed, like ``_term`` or ``_breakpoint_piece``) are - exempt: they are per-container bookkeeping which the piecewise - machinery labels differently on purpose. - """ - if not is_v1(): - return - indexes_by_dim: dict[Hashable, list[tuple[str, pd.Index]]] = {} - for item in self._coordinate_carriers(): - for dim, index in item.indexes.items(): - if str(dim).startswith("_"): - continue - indexes_by_dim.setdefault(dim, []).append((str(item.name), index)) - - for dim, entries in indexes_by_dim.items(): - master_name, master_index = max(entries, key=lambda e: len(e[1])) - for other_name, other_index in entries: - if other_index.isin(master_index).all(): - continue - raise ValueError( - f"Coordinates for dimension '{dim}' are incompatible " - f"across the model: '{other_name}' carries labels not " - f"contained in the largest carrier '{master_name}', so " - "they are neither equal nor subsets of a shared index. " - "Use Model.assign_coords to relabel the model, or align " - "the containers with `.sel`." - ) - def assign_result( self, result: Result, diff --git a/test/test_assign_coords.py b/test/test_assign_coords.py index 22045612..0f71908c 100644 --- a/test/test_assign_coords.py +++ b/test/test_assign_coords.py @@ -318,114 +318,6 @@ def test_assign_coords_rejects_ambiguous_masters(m: Model) -> None: m.assign_coords(snapshot=sns1) -@pytest.mark.v1 -def test_solve_rejects_diverged_coords(m: Model) -> None: - """ - v1 solving raises when containers carry incompatible labels on a dim. - - Same length, different values — the mislabeling trap no construction-time - §8 check catches (and which the public API cannot build); the pre-solve - guard is the backstop. A re-aligned model passes again. - """ - shifted = sns0 + pd.Timedelta("5h") - m.variables["x"]._assign_coords(snapshot=shifted) - - with pytest.raises(ValueError, match="incompatible"): - m._check_coord_consistency() - - # a consistent model passes - m.variables["x"]._assign_coords(snapshot=sns0) - m._check_coord_consistency() - - -@pytest.mark.legacy -def test_check_coord_consistency_legacy_noop(m: Model) -> None: - """ - Under legacy, non-aligned containers are documented positional - behavior (convention §8), so the guard is a no-op. - """ - shifted = sns0 + pd.Timedelta("5h") - m.variables["x"]._assign_coords(snapshot=shifted) - - m._check_coord_consistency() - - -@pytest.mark.v1 -def test_check_coord_consistency_allows_helper_dim_divergence(m: Model) -> None: - """ - Internal (underscore-prefixed) dims are exempt from the guard. - - The piecewise machinery labels ``_breakpoint_piece`` differently on each - container on purpose — that must not trip the guard. - """ - delta = m.add_variables( - binary=True, - coords=[sns0, [0, 1, 2]], - dims=["snapshot", "_breakpoint_piece"], - name="delta", - ) - delta_hi = delta.isel(_breakpoint_piece=slice(1, None), drop=True) - delta_hi._assign_coords(_breakpoint_piece=[0, 1]) - - m._check_coord_consistency() - - -@pytest.mark.v1 -def test_check_coord_consistency_allows_subset_labels(m: Model) -> None: - """Containers nesting by inclusion (a gate on a gen subset) pass the guard.""" - m.add_variables( - binary=True, - coords=[pd.Index(["a", "c"], name="gen")], - name="u", - ) - m.add_variables( - coords=[pd.Index(["a", "b", "c"], name="gen")], - name="x_gen", - ) - - m._check_coord_consistency() - - -@pytest.mark.v1 -def test_check_coord_consistency_rejects_partial_overlap(m: Model) -> None: - """Labels neither equal nor nested (partial overlap) raise under v1.""" - m.add_variables(coords=[pd.Index(["a", "c"], name="gen")], name="u") - m.add_variables(coords=[pd.Index(["b", "c"], name="gen")], name="v") - - with pytest.raises(ValueError, match="incompatible"): - m._check_coord_consistency() - - -@pytest.mark.v1 -def test_check_coord_consistency_subset_order_independent() -> None: - """ - Two valid subsets of a master pass regardless of definition order. - - The guard compares every carrier against the largest (the master), not - against whichever container is defined first: a subset defined before the - master must not trip the check. - """ - model = Model() - model.add_variables( - binary=True, coords=[pd.Index(["a", "b"], name="gen")], name="u" - ) - model.add_variables(coords=[pd.Index(["a", "b", "c"], name="gen")], name="x") - model.add_variables( - binary=True, coords=[pd.Index(["b", "c"], name="gen")], name="v" - ) - - model._check_coord_consistency() - - -@pytest.mark.v1 -def test_solve_rejects_diverged_coords_end_to_end(m: Model) -> None: - """Model.solve() itself raises on diverged coords, before any solver runs.""" - m.variables["x"]._assign_coords(snapshot=sns0 + pd.Timedelta("5h")) - - with pytest.raises(ValueError, match="incompatible"): - m.solve() - - def test_assign_coords_rejects_duplicate_master_labels() -> None: """A carrier with duplicate labels makes values-only reassignment ambiguous.""" model = Model() diff --git a/test/test_optimization.py b/test/test_optimization.py index 79a80db8..8f4b1478 100644 --- a/test/test_optimization.py +++ b/test/test_optimization.py @@ -517,6 +517,27 @@ def test_mock_solve_clears_existing_solver_state(model: Model) -> None: assert model.solver_name is None +def test_solve_disjoint_labels_on_shared_dim() -> None: + """ + Containers sharing a dim name with disjoint labels solve as long as they + are never combined element-wise (PyPSA's shared ``name`` dim, #986). + """ + m = Model() + gen = m.add_variables( + lower=0, coords=[pd.Index(["gen1", "gen2"], name="name")], name="gen" + ) + store = m.add_variables( + lower=0, coords=[pd.Index(["store1"], name="name")], name="store" + ) + m.add_constraints(gen.sum() + store.sum() >= 1, name="balance") + m.add_objective(gen.sum() + 2 * store.sum()) + + status, _ = m.solve(solver_name="highs") + + assert status == "ok" + assert m.objective.value == pytest.approx(1.0) + + @pytest.mark.parametrize("solver,io_api,explicit_coordinate_names", params) def test_default_settings_chunked( model_chunked: Model, solver: str, io_api: str, explicit_coordinate_names: bool