Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion linopy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 1 addition & 43 deletions linopy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
108 changes: 0 additions & 108 deletions test/test_assign_coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions test/test_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading