diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 7d3a4e19..ff01781b 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -79,7 +79,7 @@ Version 0.9.1 * ``LinearExpression.reindex`` keeps a sparse (CSR-backed) expression sparse for plain label changes — reorder, add, or drop coordinates — instead of expanding to the dense rectangle. New coordinates become absent cells and the result matches the dense reindex, so a ``groupby(sparse=True) → reindex → merge`` chain stays sparse and the build peak stays low (v1 only; other arguments fall back to dense). (`#932 `__) -* ``linopy.merge`` (and ``+`` / ``-`` / ``.add`` / ``.sub`` with an explicit ``join=``) keeps sparse (CSR-backed) expressions sparse when the operands live on different label subsets of the same dimensions, e.g. a nodal balance summing grouped generator, line and load terms. The payloads are aligned row-wise onto the joined grid (``outer`` / ``inner`` / ``left`` / ``right`` / ``override``) instead of falling back to the dense rectangle; the positions the join creates carry the same fill as the dense path (zero, or absent with ``fill_value=linopy.ABSENT``) and the result equals the dense one. Dense operands on a different grid are converted on the fly. (`#749 `__) +* ``linopy.merge`` (and ``+`` / ``-`` / ``.add`` / ``.sub`` with an explicit ``join=``) keeps sparse (CSR-backed) expressions sparse when the operands live on different label subsets of the same dimensions, e.g. a nodal balance summing grouped generator, line and load terms. The CSR expressions are aligned row-wise onto the joined grid (``outer`` / ``inner`` / ``left`` / ``right`` / ``override``) instead of falling back to the dense rectangle; the positions the join creates carry the same fill as the dense path (zero, or absent with ``fill_value=linopy.ABSENT``) and the result equals the dense one. Dense operands on a different grid are converted on the fly. (`#749 `__) **Bug fixes** diff --git a/linopy/constraints.py b/linopy/constraints.py index bcc974f6..20ec381f 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -89,7 +89,7 @@ if TYPE_CHECKING: from linopy.model import Model - from linopy.sparse_expression import CSRPayload + from linopy.sparse_expression import CSRExpression FILL_VALUE = { @@ -1365,35 +1365,33 @@ def from_mutable( ) @classmethod - def from_payload( - cls, payload: CSRPayload, sign: str, rhs: DataArray - ) -> CSRConstraint: + def from_csr(cls, expr: CSRExpression, sign: str, rhs: DataArray) -> CSRConstraint: """ Staple sign and rhs onto a CSR-backed lhs to form an unassigned CSRConstraint. The sparse counterpart of :meth:`from_mutable`: instead of converting a dense :class:`Constraint`, it realizes a - :class:`~linopy.sparse_expression.CSRPayload` directly. The payload's + :class:`~linopy.sparse_expression.CSRExpression` directly. The expression's label columns are kept as they are, its constant moves to the rhs, and rows with a NaN rhs are inactive, as on the dense path. ``rhs`` must come from :func:`csr_rhs`. """ sign = maybe_replace_sign(sign) - rhs_flat = _rhs_grid_values(payload, rhs) - payload.const + rhs_flat = _rhs_grid_values(expr, rhs) - expr.const active = np.flatnonzero(~np.isnan(rhs_flat)) return cls( - payload.csr[active], + expr.csr[active], active, rhs_flat[active], sign, - coords=[payload.indexes[d] for d in payload.grid_dims], - model=payload.model, + coords=[expr.indexes[d] for d in expr.grid_dims], + model=expr.model, ) -def csr_rhs(payload: CSRPayload, rhs: Any) -> DataArray | None: +def csr_rhs(expr: CSRExpression, rhs: Any) -> DataArray | None: """ - Return ``rhs`` as a DataArray on the payload grid, or None if the sparse + Return ``rhs`` as a DataArray on the expression grid, or None if the sparse path cannot take it: a non-constant rhs, one that is no DataArray-like, or one with helper dims or dims outside the grid falls back to the dense path. """ @@ -1403,30 +1401,30 @@ def csr_rhs(payload: CSRPayload, rhs: Any) -> DataArray | None: da = as_dataarray(rhs) except (TypeError, ValueError): return None - if set(da.dims) & set(HELPER_DIMS) or not set(da.dims) <= set(payload.grid_dims): + if set(da.dims) & set(HELPER_DIMS) or not set(da.dims) <= set(expr.grid_dims): return None return da -def _rhs_grid_values(payload: CSRPayload, rhs: DataArray) -> np.ndarray: +def _rhs_grid_values(expr: CSRExpression, rhs: DataArray) -> np.ndarray: """ - Broadcast the rhs onto the payload grid and flatten it, with v1 parity: + Broadcast the rhs onto the expression grid and flatten it, with v1 parity: NaN in the rhs raises (§5) and a reordered or differing index on a shared dim raises (§8), as on the dense path. """ if bool(rhs.isnull().any()): check_user_nan() for d in rhs.dims: - if not rhs.get_index(d).equals(payload.indexes[str(d)]): + if not rhs.get_index(d).equals(expr.indexes[str(d)]): raise ValueError( f"Coordinate mismatch on shared dimension {d!r} between " "the rhs and the grouped result. Align the rhs with " ".sel(...) / .reindex(...) before combining (§8)." ) - missing = {d: payload.indexes[d] for d in payload.grid_dims if d not in rhs.dims} + missing = {d: expr.indexes[d] for d in expr.grid_dims if d not in rhs.dims} if missing: rhs = rhs.expand_dims(missing) - return rhs.transpose(*payload.grid_dims).to_numpy().reshape(-1) + return rhs.transpose(*expr.grid_dims).to_numpy().reshape(-1) class Constraint(ConstraintBase): diff --git a/linopy/expressions.py b/linopy/expressions.py index 0abdadaa..9817ba50 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -610,7 +610,7 @@ def sum( and grouper.index.name in self.data.dims ) if supported: - from linopy.sparse_expression import CSRPayload + from linopy.sparse_expression import CSRExpression expr = LinearExpression(self.data, self.model) stacked = observed or multikey_frame is None @@ -622,10 +622,10 @@ def sum( coord_dims = tuple( str(d) for d in self.data.coeffs.dims if d != TERM_DIM ) - payload = CSRPayload.from_grouper( + csr = CSRExpression.from_grouper( expr, grouper, group_name, stacked, coord_dims ) - return LinearExpression._from_payload(payload, self.model) + return LinearExpression._from_csr(csr, self.model) if explicit_sparse: raise ValueError( "sparse=True supports only a pandas Series or DataFrame, 1-D " @@ -831,7 +831,7 @@ def sum(self, **kwargs: Any) -> LinearExpression: class BaseExpression(ABC): - __slots__ = ("_data", "_model", "_payload") + __slots__ = ("_data", "_model", "_csr") __array_ufunc__ = None __array_priority__ = 10000 __pandas_priority__ = 10000 @@ -906,7 +906,7 @@ def __init__(self, data: Dataset | Any | None, model: Model) -> None: data = data.assign_attrs(name=None) self._model = model self._data = cast(Dataset, data) - self._payload = None + self._csr = None def __repr__(self) -> str: """ @@ -1007,8 +1007,8 @@ def __neg__(self) -> Self: """ Get the negative of the expression. """ - if self._payload is not None: - return self._from_payload(self._payload.scaled(-1.0), self._model) + if self._csr is not None: + return self._from_csr(self._csr.scaled(-1.0), self._model) return self.assign_multiindex_safe(coeffs=-self.coeffs, const=-self.const) def _multiply_by_linear_expression( @@ -1594,18 +1594,18 @@ def name(self) -> str: @property def data(self) -> Dataset: - if self._data is None and self._payload is not None: - self._data = self._payload.materialize().data - self._payload = None + if self._data is None and self._csr is not None: + self._data = self._csr.materialize().data + self._csr = None return self._data @classmethod - def _from_payload(cls, payload: Any, model: Model) -> Self: - """Construct an expression backed by a CSRPayload.""" + def _from_csr(cls, csr: Any, model: Model) -> Self: + """Construct an expression backed by a CSRExpression.""" obj = cls.__new__(cls) obj._model = model obj._data = None # type: ignore[assignment] - obj._payload = payload + obj._csr = csr return obj @property @@ -1619,8 +1619,8 @@ def dims(self) -> tuple[Hashable, ...]: @property def coord_dims(self) -> tuple[Hashable, ...]: - if self._data is None and self._payload is not None: - return tuple(self._payload.grid_dims) + if self._data is None and self._csr is not None: + return tuple(self._csr.grid_dims) return tuple(k for k in self.dims if k not in HELPER_DIMS) @property @@ -1817,12 +1817,10 @@ def to_constraint( Legacy instead keeps a NaN RHS as that auto-mask, restoring the mask after the subtraction filled it with 0. """ - if self._payload is not None and isinstance(sign, str): - rhs_da = constraints.csr_rhs(self._payload, rhs) + if self._csr is not None and isinstance(sign, str): + rhs_da = constraints.csr_rhs(self._csr, rhs) if rhs_da is not None: - return constraints.CSRConstraint.from_payload( - self._payload, sign, rhs_da - ) + return constraints.CSRConstraint.from_csr(self._csr, sign, rhs_da) rhs = as_constant(rhs) if self.is_constant and is_constant(rhs): @@ -1987,13 +1985,13 @@ def fillna( ``to_linexpr``), which still holds the absence labels. """ value = _expr_unwrap(value) - payload = self._payload + csr = self._csr if ( - payload is not None + csr is not None and isinstance(value, np.floating | np.integer | int | float) and not isinstance(value, bool) ): - return type(self)._from_payload(payload.filled(float(value)), self._model) + return type(self)._from_csr(csr.filled(float(value)), self._model) if isinstance(value, DataArray | np.floating | np.integer | int | float): value = {"const": value} return self.__class__(self.data.fillna(value), self.model) @@ -2091,6 +2089,8 @@ def nterm(self) -> int: """ Get the number of terms in the linear expression. """ + if self._csr is not None: + return self._csr.nterm return len(self.data._term) @property @@ -2439,10 +2439,8 @@ def __mul__( """ Multiply the expr by a factor. """ - if self._payload is not None and isinstance(other, int | float | np.number): - return type(self)._from_payload( - self._payload.scaled(float(other)), self._model - ) + if self._csr is not None and isinstance(other, int | float | np.number): + return type(self)._from_csr(self._csr.scaled(float(other)), self._model) other = as_constant(other) if isinstance(other, QuadraticExpression): return other.__rmul__(self) @@ -2539,20 +2537,20 @@ def reindex( expression stays sparse when only labels change. """ indexers = either_dict_or_kwargs(indexers, indexers_kwargs, "reindex") - payload = self._payload + csr = self._csr if ( - payload is not None - and set(indexers) <= set(payload.grid_dims) + csr is not None + and set(indexers) <= set(csr.grid_dims) and method is None and tolerance is None and copy and fill_value is self._fill_value ): indexes = { - d: pd.Index(indexers.get(d, payload.indexes[d]), name=d) - for d in payload.grid_dims + d: pd.Index(indexers.get(d, csr.indexes[d]), name=d) + for d in csr.grid_dims } - return type(self)._from_payload(payload.reindexed(indexes), self._model) + return type(self)._from_csr(csr.reindexed(indexes), self._model) return super().reindex( indexers, method=method, @@ -2571,10 +2569,10 @@ def rename( stays sparse when only grid dims are relabelled. """ name_dict = either_dict_or_kwargs(name_dict, names, "rename") - payload = self._payload - if payload is not None and set(name_dict) <= set(payload.grid_dims): + csr = self._csr + if csr is not None and set(name_dict) <= set(csr.grid_dims): relabel = {str(k): str(v) for k, v in name_dict.items()} - return type(self)._from_payload(payload.renamed(relabel), self._model) + return type(self)._from_csr(csr.renamed(relabel), self._model) return super().rename(name_dict) def to_quadexpr(self) -> QuadraticExpression: diff --git a/linopy/sparse_expression.py b/linopy/sparse_expression.py index 154290d0..bdd2697d 100644 --- a/linopy/sparse_expression.py +++ b/linopy/sparse_expression.py @@ -1,9 +1,9 @@ """ -The sparse payload behind a LinearExpression: ``A @ x + c`` in CSR form. +The sparse backing of a LinearExpression: ``A @ x + c`` in CSR form. ``expr.groupby(g).sum(sparse=True)`` (or ``linopy.options["sparse_groupby"]`` under v1) returns an ordinary :class:`~linopy.expressions.LinearExpression` -backed by a :class:`CSRPayload` instead of the dense dataset — same public +backed by a :class:`CSRExpression` instead of the dense dataset — same public type, different backing, akin to dask-backed xarray objects. The CSR form is canonical (duplicate variables summed, terms label-ordered) and ragged along ``_term``, so the group-size padding of issue #745 has no analog; grouping, @@ -13,8 +13,8 @@ is v1-gated, where term layout is non-contractual. This module covers the expression layer only. Stapling sign and rhs onto a -payload to form a :class:`~linopy.constraints.CSRConstraint` lives in -:meth:`linopy.constraints.CSRConstraint.from_payload`. +CSR expression to form a :class:`~linopy.constraints.CSRConstraint` lives in +:meth:`linopy.constraints.CSRConstraint.from_csr`. """ from __future__ import annotations @@ -37,7 +37,7 @@ @dataclass(frozen=True) -class CSRPayload: +class CSRExpression: """ An expression as ``A @ x + c`` over a fixed coordinate grid. @@ -65,6 +65,10 @@ def shape(self) -> tuple[int, ...]: def n_cells(self) -> int: return self.csr.shape[0] + @property + def nterm(self) -> int: + return max(int(np.diff(self.csr.indptr).max(initial=0)), 1) + @classmethod def from_grouper( cls, @@ -73,7 +77,7 @@ def from_grouper( group_dim: str, stacked: bool, coord_dims: tuple[str, ...], - ) -> CSRPayload: + ) -> CSRExpression: """ Build the grouped sum directly in CSR form (no padded rectangle). @@ -131,7 +135,7 @@ def from_grouper( ) @classmethod - def from_expression(cls, expr: LinearExpression) -> CSRPayload: + def from_expression(cls, expr: LinearExpression) -> CSRExpression: """Convert a dense expression to CSR form on its own coordinate grid.""" grid_dims = tuple(str(d) for d in expr.coord_dims) indexes = {d: expr.data.get_index(d).rename(d) for d in grid_dims} @@ -150,7 +154,7 @@ def _from_scatter( scatter_codes: dict[str, np.ndarray], skipna: bool, coords: dict[str, tuple[str, np.ndarray]], - ) -> CSRPayload: + ) -> CSRExpression: """ Scatter an expression's terms into grid rows (conceptually ``G @ A``): ``member_dim`` lands in the contiguous block of grid dims named by @@ -202,7 +206,7 @@ def _from_scatter( scipy.sparse.csr_array(coo), const, grid_dims, indexes, expr.model, coords ) - def scaled(self, factor: float) -> CSRPayload: + def scaled(self, factor: float) -> CSRExpression: return replace(self, csr=self.csr * factor, const=self.const * factor) def reindexed( @@ -210,7 +214,7 @@ def reindexed( indexes: dict[str, pd.Index], grid_dims: tuple[str, ...] | None = None, fill: float = np.nan, - ) -> CSRPayload: + ) -> CSRExpression: """ Remap rows onto new per-dim indexes, optionally in a new dim order, without the dense rectangle: dropped labels vanish, new labels get @@ -251,12 +255,12 @@ def reindexed( coords=coords, ) - def filled(self, value: float) -> CSRPayload: + def filled(self, value: float) -> CSRExpression: """Resolve absent cells (NaN const) to a constant; terms untouched.""" const = np.where(np.isnan(self.const), value, self.const) return replace(self, const=const) - def renamed(self, names: dict[str, str]) -> CSRPayload: + def renamed(self, names: dict[str, str]) -> CSRExpression: """Relabel grid dims; the CSR row layout is unchanged.""" grid_dims = tuple(names.get(d, d) for d in self.grid_dims) indexes = { @@ -266,12 +270,12 @@ def renamed(self, names: dict[str, str]) -> CSRPayload: coords = {n: (names.get(d, d), v) for n, (d, v) in self.coords.items()} return replace(self, grid_dims=grid_dims, indexes=indexes, coords=coords) - def same_grid(self, other: CSRPayload) -> bool: + def same_grid(self, other: CSRExpression) -> bool: return self.grid_dims == other.grid_dims and all( self.indexes[d].equals(other.indexes[d]) for d in self.grid_dims ) - def add(self, other: CSRPayload) -> CSRPayload: + def add(self, other: CSRExpression) -> CSRExpression: """ Sparse matrix addition == merge along the term dimension. Goes through COO so explicit zero coefficients survive (scipy's ``+`` drops them), @@ -361,38 +365,38 @@ def _flat_cells(axis_positions: list[np.ndarray]) -> np.ndarray: def _aligned( - payloads: list[CSRPayload], join: JoinOptions | None, fill: float -) -> list[CSRPayload] | None: + csrs: list[CSRExpression], join: JoinOptions | None, fill: float +) -> list[CSRExpression] | None: """ - Conform the payloads to the grid an explicit join produces, cells the - join creates carrying ``fill`` as constant. None where the dense path + Conform the CSR expressions to the grid an explicit join produces, the + cells the join creates carrying ``fill`` as constant. None where the dense path owns the semantics: ``exact`` and the auto-detected join raise there on differing grids, ``override`` on differing shapes, any join on non-unique labels. """ - template = payloads[0] + template = csrs[0] dims = template.grid_dims - if any(not p.indexes[d].is_unique for p in payloads for d in dims): + if any(not p.indexes[d].is_unique for p in csrs for d in dims): return None if join == "override": - if any(p.grid_dims != dims or p.shape != template.shape for p in payloads): + if any(p.grid_dims != dims or p.shape != template.shape for p in csrs): return None - return [replace(p, indexes=template.indexes) for p in payloads] + return [replace(p, indexes=template.indexes) for p in csrs] if join == "left": indexes = template.indexes elif join == "right": - indexes = payloads[-1].indexes + indexes = csrs[-1].indexes elif join in ("outer", "inner"): combine = pd.Index.union if join == "outer" else pd.Index.intersection indexes = {} for d in dims: index = template.indexes[d] - for p in payloads[1:]: + for p in csrs[1:]: index = combine(index, p.indexes[d]) indexes[d] = pd.Index(index, name=d) else: return None - return [p.reindexed(indexes, dims, fill) for p in payloads] + return [p.reindexed(indexes, dims, fill) for p in csrs] def try_csr_merge( @@ -417,28 +421,26 @@ def try_csr_merge( return None if not all(type(e) is LinearExpression for e in exprs): return None - if all(e._payload is None for e in exprs): + if all(e._csr is None for e in exprs): return None dims = set(exprs[0].coord_dims) if any(set(e.coord_dims) != dims for e in exprs[1:]): return None for e in exprs: - if e._payload is None and set(e.data.coords) - dims != set( - _aux_coords(e, dims) - ): + if e._csr is None and set(e.data.coords) - dims != set(_aux_coords(e, dims)): return None - payloads = [e._payload or CSRPayload.from_expression(e) for e in exprs] - template = payloads[0] - if not all(template.same_grid(p) for p in payloads[1:]): - if any(p.coords for p in payloads): + csrs = [e._csr or CSRExpression.from_expression(e) for e in exprs] + template = csrs[0] + if not all(template.same_grid(p) for p in csrs[1:]): + if any(p.coords for p in csrs): return None - aligned = _aligned(payloads, join, join_fill(fill_value, 0.0)) + aligned = _aligned(csrs, join, join_fill(fill_value, 0.0)) if aligned is None: return None - payloads = aligned + csrs = aligned - combined = payloads[0] - for payload in payloads[1:]: - combined = combined.add(payload) - return LinearExpression._from_payload(combined, exprs[0].model) + combined = csrs[0] + for csr in csrs[1:]: + combined = combined.add(csr) + return LinearExpression._from_csr(combined, exprs[0].model) diff --git a/test/test_sparse_groupby.py b/test/test_sparse_groupby.py index 4baea2ef..88a7b60f 100644 --- a/test/test_sparse_groupby.py +++ b/test/test_sparse_groupby.py @@ -120,7 +120,7 @@ def test_csr_requires_v1() -> None: res = (c.eff * c.gen_p).groupby(c.gbus).sum() finally: linopy.options["sparse_groupby"] = False - assert res._payload is None + assert res._csr is None def test_csr_is_plain_linear_expression_and_materializes_equivalently() -> None: @@ -144,7 +144,7 @@ def test_scalar_ops_stay_csr() -> None: require_v1() c = base_model() sparse = -2.0 * (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=True) - assert sparse._payload is not None + assert sparse._csr is not None assert_linequal(sparse, -2.0 * (c.eff * c.gen_p).groupby(c.gbus).sum()) @@ -168,7 +168,7 @@ def test_merge_keeps_absent_cell_absent() -> None: sparse = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=True) tot = linopy.merge([sparse, flow], join="outer") - assert tot._payload is not None + assert tot._csr is not None assert_linequal(tot, linopy.merge([dense, flow], join="outer")) con = c.m.add_constraints(tot >= c.load, name="bal", freeze=True) @@ -224,13 +224,13 @@ def test_namelist_sparse_matches_dense(observed: bool, member_first: bool) -> No keys = ["period", "season"] sparse = expr.groupby(keys).sum(sparse=True, observed=observed) dense = expr.groupby(keys).sum(observed=observed) - payload = sparse._payload - assert payload is not None + csr = sparse._csr + assert csr is not None if observed: - assert set(payload.coords) == {"period", "season"} + assert set(csr.coords) == {"period", "season"} else: - assert np.isnan(payload.const).sum() == 2 * 2 - assert payload.grid_dims == dense.coord_dims + assert np.isnan(csr.const).sum() == 2 * 2 + assert csr.grid_dims == dense.coord_dims assert_linequal(sparse, dense) @@ -241,7 +241,7 @@ def test_single_key_sparse_ignores_observed(as_namelist: bool) -> None: expr = (c.eff * c.gen_p).assign_coords(bus=("gen", c.gbus.to_numpy())) grouper = ["bus"] if as_namelist else c.gbus sparse = expr.groupby(grouper).sum(sparse=True, observed=True) - assert sparse._payload is not None + assert sparse._csr is not None assert_linequal(sparse, expr.groupby(grouper).sum()) @@ -261,8 +261,8 @@ def test_dataframe_grouper_sparse_stays_compact() -> None: _, expr, _ = keyed_model() df = expr.data[["period", "season"]].to_dataframe()[["period", "season"]] sparse = expr.groupby(df).sum(sparse=True) - assert sparse._payload is not None - assert sparse._payload.shape == (4, 2) + assert sparse._csr is not None + assert sparse._csr.shape == (4, 2) assert_linequal(sparse, expr.groupby(df).sum()) @@ -309,14 +309,14 @@ def test_namelist_sparse_observed_keeps_aux_coords_through_merge() -> None: sparse = expr.groupby(keys).sum(sparse=True, observed=True) dense = expr.groupby(keys).sum(observed=True) tot = sparse + dense - assert tot._payload is not None - assert set(tot._payload.coords) == {"period", "season"} + assert tot._csr is not None + assert set(tot._csr.coords) == {"period", "season"} assert_linequal(tot, 2.0 * dense) other = dense.assign_coords(region=("group", list("abcd"))) tot = sparse + other - assert tot._payload is not None - assert set(tot._payload.coords) == {"period", "season", "region"} + assert tot._csr is not None + assert set(tot._csr.coords) == {"period", "season", "region"} xr.testing.assert_equal( tot.data.coords.to_dataset(), (dense + other).data.coords.to_dataset() ) @@ -341,8 +341,8 @@ def test_namelist_sparse_grid_warns_and_observed_silences() -> None: with warnings.catch_warnings(): warnings.simplefilter("error") res = expr.groupby(["period", "season"]).sum(sparse=True, observed=True) - assert res._payload is not None - assert res._payload.shape == (n,) + assert res._csr is not None + assert res._csr.shape == (n,) def test_nan_multikey_grouper_raises_eagerly() -> None: @@ -536,7 +536,7 @@ def test_reindex_stays_csr_and_matches_dense(indexers: dict) -> None: require_v1() c = base_model() sparse = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=True).reindex(indexers) - assert sparse._payload is not None + assert sparse._csr is not None dense = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=False).reindex(indexers) assert_linequal(sparse, dense) @@ -546,7 +546,7 @@ def test_reindex_falls_back_to_dense_for_unsupported_kwargs() -> None: c = base_model() sparse = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=True) res = sparse.reindex(bus=["bus3", "bus0"], copy=False) - assert res._payload is None + assert res._csr is None dense = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=False) assert_linequal(res, dense.reindex(bus=["bus3", "bus0"])) @@ -556,7 +556,7 @@ def test_reindex_merge_chain_freezes_csr() -> None: c1, c2 = base_model(), base_model() con1 = c1.m.add_constraints(reindexed_balance(c1, False) == c1.load, name="bal") tot = reindexed_balance(c2, True) - assert tot._payload is not None + assert tot._csr is not None con2 = c2.m.add_constraints(tot == c2.load, name="bal", freeze=True) assert isinstance(con2, CSRConstraint) assert_frozen_equal(con1, con2) @@ -586,7 +586,7 @@ def test_fillna_stays_csr_and_matches_dense(value: float) -> None: wide = {"bus": [f"bus{i}" for i in range(7)]} sparse = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=True).reindex(wide) filled = sparse.fillna(value) - assert filled._payload is not None + assert filled._csr is not None dense = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=False).reindex(wide) assert_linequal(filled, dense.fillna(value)) @@ -597,7 +597,7 @@ def test_fillna_with_array_falls_back_to_dense() -> None: fill = xr.zeros_like(c.load) sparse = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=True) res = sparse.fillna(fill) - assert res._payload is None + assert res._csr is None dense = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=False) assert_linequal(res, dense.fillna(fill)) @@ -606,7 +606,7 @@ def test_rename_stays_csr_and_matches_dense() -> None: require_v1() c = base_model() sparse = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=True).rename(bus="node") - assert sparse._payload is not None + assert sparse._csr is not None dense = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=False).rename(bus="node") assert sparse.coord_dims == ("node", "snapshot") assert_linequal(sparse, dense) @@ -625,7 +625,7 @@ def test_namelist_sparse_observed_keeps_aux_coords_through_op( keys = ["period", "season"] sparse = op(expr.groupby(keys).sum(sparse=True, observed=True)) dense = op(expr.groupby(keys).sum(sparse=False, observed=True)) - assert sparse._payload is not None + assert sparse._csr is not None for name in keys: xr.testing.assert_equal(sparse.coords[name], dense.coords[name]) assert_linequal(sparse, dense) @@ -663,7 +663,7 @@ def test_cross_grid_merge_stays_csr_and_matches_dense( if order == "flow-gen": sparse, dense = sparse[::-1], dense[::-1] res = linopy.merge(sparse, join=join) - assert res._payload is not None + assert res._csr is not None assert res.coord_dims == dense[0].coord_dims assert_terms_equal(res, linopy.merge(dense, join=join)) @@ -676,7 +676,7 @@ def test_three_operand_cross_grid_merge_matches_dense(join: JoinOptions) -> None sparse = cross_grid_parts(c, True) + cross_grid_parts(c, True, third_lines)[1:] dense = cross_grid_parts(c, False) + cross_grid_parts(c, False, third_lines)[1:] res = linopy.merge(sparse, join=join) - assert res._payload is not None + assert res._csr is not None assert_terms_equal(res, linopy.merge(dense, join=join)) @@ -686,9 +686,9 @@ def test_cross_grid_merge_absent_fill_matches_dense() -> None: sparse, dense = cross_grid_parts(c, True), cross_grid_parts(c, False) res = linopy.merge(sparse, join="outer", fill_value=linopy.ABSENT) expected = linopy.merge(dense, join="outer", fill_value=linopy.ABSENT) - assert res._payload is not None + assert res._csr is not None filled = res.fillna(0) - assert filled._payload is not None + assert filled._csr is not None assert_terms_equal(filled, expected.fillna(0)) assert_terms_equal(res, expected) assert res.const.isnull().sum() == 3 * c.load.sizes["snapshot"] @@ -701,7 +701,7 @@ def test_cross_grid_merge_keeps_absent_cell_absent() -> None: mask = xr.DataArray([True, False], coords=[dense[1].indexes["bus"]]) dense[1] = dense[1].where(mask) res = linopy.merge([sparse[0], dense[1]], join="outer") - assert res._payload is not None + assert res._csr is not None assert_terms_equal(res, linopy.merge(dense, join="outer")) @@ -710,7 +710,7 @@ def test_cross_grid_merge_mixed_dense_operand_stays_csr() -> None: c = base_model() sparse, dense = cross_grid_parts(c, True), cross_grid_parts(c, False) res = sparse[0].add(dense[1], join="outer") - assert res._payload is not None + assert res._csr is not None assert_terms_equal(res, dense[0].add(dense[1], join="outer")) @@ -765,7 +765,7 @@ def test_override_merge_same_shape_stays_csr() -> None: gen = (c.eff * c.gen_p).groupby(c.gbus).sum(sparse=True) flow = (1.0 * c.flow).groupby(c.bus1.str.upper()).sum(sparse=True) res = linopy.merge([gen, flow], join="override") - assert res._payload is not None + assert res._csr is not None dense = [ (c.eff * c.gen_p).groupby(c.gbus).sum(), (1.0 * c.flow).groupby(c.bus1.str.upper()).sum(), @@ -779,7 +779,7 @@ def test_cross_grid_balance_freezes_csr() -> None: lhs1 = linopy.merge(cross_grid_parts(c1, False), join="outer") con1 = c1.m.add_constraints(lhs1 == c1.load, name="bal") lhs2 = linopy.merge(cross_grid_parts(c2, True), join="outer") - assert lhs2._payload is not None + assert lhs2._csr is not None con2 = c2.m.add_constraints(lhs2 == c2.load, name="bal", freeze=True) assert isinstance(con2, CSRConstraint) assert_frozen_equal(con1, con2)