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 @@ -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 <https://github.com/PyPSA/linopy/issues/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 <https://github.com/PyPSA/linopy/issues/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 <https://github.com/PyPSA/linopy/issues/749>`__)

**Bug fixes**

Expand Down
32 changes: 15 additions & 17 deletions linopy/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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):
Expand Down
70 changes: 34 additions & 36 deletions linopy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 "
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
Loading