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
1 change: 1 addition & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Upcoming Version
* ``@``/``dot`` against a sparse constant no longer scales with the total number of variables in the model: each chunk of the sparse product now runs on only the variables it uses, which removes several seconds of allocation overhead on models with millions of variables. (`#990 <https://github.com/PyPSA/linopy/pull/990>`__)
* Deprecated in favour of ``Model(sparse=True)``, each with a ``FutureWarning`` and to be removed with the legacy semantics: ``Model(freeze_constraints=...)`` and the ``Model.freeze_constraints`` setter, ``groupby(...).sum(sparse=...)`` and ``linopy.options["sparse_groupby"]``. They keep their current behaviour until then, except that ``@`` ignores ``sparse_groupby``, and netcdf files that store ``freeze_constraints`` still load. (`#976 <https://github.com/PyPSA/linopy/issues/976>`__)
* Adding a frozen constraint from a sparse expression no longer copies the lhs matrix when every row stays active, and picks the mask and the row scaling at the active rows without expanding them over the full coordinate grid. This roughly halves the peak memory of ``add_constraints`` on a sparse model. (`#977 <https://github.com/PyPSA/linopy/issues/977>`__)
* ``linopy.merge`` of several sparse expressions on the same coordinates now adds them in one pass instead of one pairwise sum per operand, and a sparse ``reindex`` that only reorders the existing cells gathers the rows directly instead of rebuilding the matrix. Merging three sparse operands is about 30% faster and such a reindex about 4x faster. (`#1010 <https://github.com/PyPSA/linopy/issues/1010>`__)
* Building ``Model.matrices`` no longer allocates label-sized scaling lookups and skips ``eliminate_zeros`` for frozen constraints. On a model with 2M variables this makes each build of a sparse or frozen model about 20 to 35 ms faster. (`#1008 <https://github.com/PyPSA/linopy/issues/1008>`__)
* Freezing a dense constraint builds the CSR matrix directly from the term rectangle and sorts it only when a row has more than one term. On a model with 2M variables this cuts the freeze of the nodal balance from about 465 to 185 ms and its peak memory from 1.74 GB to 0.25 GB. The export of a mutable constraint also no longer copies a strided term array whose terms are mostly zero. (`#1009 <https://github.com/PyPSA/linopy/issues/1009>`__)

Expand Down
26 changes: 19 additions & 7 deletions linopy/csr.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
count per row; grouping, ``sum``, ``merge``/``+``/``-``, scaling and
``@``/``dot`` (:meth:`contracted`) are sparse linear algebra. Zero policy: the
structural operations (grouping and ``sum`` via :meth:`aggregated`, merge via
:meth:`added`, scaling, reindexing) go through COO and keep explicit zero
coefficients; only the product with a constant matrix, ``@``/``dot``, prunes
them. Either way cell activeness is carried by ``const`` alone, independent of
term layout.
:meth:`added`, scaling, reindexing) keep explicit zero coefficients, going
through COO or, for a reindex that only permutes cells, gathering rows; only
the product with a constant matrix, ``@``/``dot``, prunes them. Either way
cell activeness is carried by ``const`` alone, independent of term layout.
Any operation without a sparse branch expands the expression through
``.data`` to the mathematically identical dense rectangle in canonical term
layout; this is valid because v1 semantics do not fix the term layout.
Expand Down Expand Up @@ -451,7 +451,8 @@ def aggregated(self, grid: Grid, rows: np.ndarray) -> CSRLinearExpression:
"""
coo = self.csr.tocoo()
shape = (grid.size, self.csr.shape[1])
rows_ = rows[coo.coords[0]]
dtype = index_dtype(coo.nnz, shape, self.model)
rows_ = rows.astype(dtype, copy=False)[coo.coords[0]]
csr = coo_to_csr(coo.data, rows_, coo.coords[1], shape, self.model)
weights = np.nan_to_num(self.const)
const = np.bincount(rows, weights=weights, minlength=grid.size).astype(float)
Expand Down Expand Up @@ -539,10 +540,21 @@ def reindexed(self, grid: Grid, fill: float = np.nan) -> CSRLinearExpression:
"""
Remap rows onto a new grid, possibly in a new dim order: dropped
labels vanish, new labels get ``fill`` as their constant (NaN: absent
cells). Auxiliary coordinates follow the rows; those of ``grid`` are
ignored.
cells). A target that only permutes the cells gathers the rows
directly, otherwise the remap goes through COO. Auxiliary coordinates
follow the rows; those of ``grid`` are ignored.
"""
row_map, valid = grid.indexer(self.grid)
if valid.all() and grid.size == self.n_cells:
source = np.full(grid.size, -1, dtype=row_map.dtype)
source[row_map] = np.arange(grid.size)
if (source >= 0).all():
return replace(
self,
csr=self.csr[source],
const=self.const[source],
grid=self.grid.conformed(grid),
)

coo = self.csr.tocoo()
keep = valid[coo.coords[0]]
Expand Down
105 changes: 86 additions & 19 deletions test/test_csr.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,25 +822,6 @@ def test_lp_files_identical(tmp_path: Path) -> None:
assert canon_lp(f1.read_text()) == canon_lp(f2.read_text())


@pytest.mark.parametrize(
"indexers",
[
{"bus": ["bus3", "bus0", "bus1", "bus2", "bus4"]},
{"bus": ["bus0", "bus1", "bus2", "bus3", "bus4", "bus9"]},
{"bus": ["bus3", "bus0"]},
{"bus": ["bus4", "bus0", "bus7"], "snapshot": [2, 0, 5]},
],
ids=["reorder", "add", "drop", "multi_dim"],
)
def test_reindex_stays_csr_and_matches_dense(indexers: dict) -> None:
require_v1()
c1, c2 = twin_models()
sparse = c2.gen_sum().reindex(indexers)
assert sparse._csr is not None
dense = c1.gen_sum().reindex(indexers)
assert_linequal(sparse, dense)


def case_twins(
build: Callable[[Case], LinearExpression],
) -> Callable[[], tuple[LinearExpression, LinearExpression]]:
Expand Down Expand Up @@ -2286,6 +2267,92 @@ def test_selection_stays_csr_and_matches_dense(build: str, select: str) -> None:
assert_sparse_matches(res, func(dense))


def dim_last(e: LinearExpression) -> str:
return str(e.coord_dims[-1])


def fresh_last(e: LinearExpression) -> list[Any]:
"""The last dimension's labels with one new label appended."""
labels = e.indexes[dim_last(e)]
return [*labels, labels.max() + 1]


REINDEXERS: dict[str, Callable[[LinearExpression], dict[str, Any]]] = {
"rotate": lambda e: {dim0(e): np.roll(labels0(e), 1)},
"rotate-all": lambda e: {str(d): np.roll(e.indexes[d], 1) for d in e.coord_dims},
"drop": lambda e: {dim0(e): labels0(e)[[2, 0]]},
"add": lambda e: {dim_last(e): fresh_last(e)},
"swap": lambda e: {dim_last(e): fresh_last(e)[1:]},
"multi-dim": lambda e: {
dim0(e): labels0(e)[[2, 0]],
dim_last(e): fresh_last(e)[::-1],
},
}


@pytest.mark.parametrize("scale", [1.0, 0.0], ids=["coeffs", "zeros"])
@pytest.mark.parametrize("reindexer", list(REINDEXERS))
@pytest.mark.parametrize("build", ["grouped", "aux", "absent"])
def test_reindex_matches_dense(build: str, reindexer: str, scale: float) -> None:
require_v1()
sparse, dense = sparse_and_dense(build)
indexers = REINDEXERS[reindexer](sparse)
with no_densify():
res = (scale * sparse).reindex(indexers)
want = (scale * dense).reindex(indexers)
xr.testing.assert_identical(res.has_terms, want.has_terms)
assert_sparse_matches(res, want)


@pytest.mark.parametrize("build", ["grouped", "aux", "absent"])
def test_reindexed_onto_transposed_dims_matches_dense(build: str) -> None:
require_v1()
sparse, dense = sparse_and_dense(build)
csr = sparse._csr
assert csr is not None
dims = csr.grid.dims[::-1]
assert_contracted_equal(csr.reindexed(csr.grid.reordered(dims)), dense, dims)


def merge_operands(e: LinearExpression, n: int) -> list[LinearExpression]:
"""Explicit zeros everywhere, terms on every other cell, absent cells."""
parts = [0.0 * e, e.where(alternating(e), 0.0), e.where(grid_operand(e) > 1.2)]
return parts + [k * e.where(alternating(e), 1.0) for k in range(2, n - 1)]


@pytest.mark.parametrize("n", [3, 5])
@pytest.mark.parametrize("build", ["grouped", "aux", "absent"])
def test_n_ary_merge_keeps_absent_cells_and_explicit_zeros(build: str, n: int) -> None:
require_v1()
sparse, dense = sparse_and_dense(build)
with no_densify():
res = linopy.merge(merge_operands(sparse, n), cls=LinearExpression)
csr = res._csr
assert csr is not None
assert (np.diff(csr.csr.indptr)[np.isnan(csr.const)] == 0).all()
want = linopy.merge(merge_operands(dense, n), cls=LinearExpression)
xr.testing.assert_identical(res.has_terms, want.has_terms)
assert_sparse_matches(res, want)


def tagged_by(c: Case, **aux: pd.Series) -> LinearExpression:
grouper = pd.DataFrame({"bus": c.gbus, **aux})
return (1.0 * c.gen_p).groupby(grouper).sum(observed=True)


def test_n_ary_merge_unites_aux_coords_and_raises_on_conflict() -> None:
require_v1()
c = base_model(sparse=True)
tagged, extra = tagged_by(c, tag=c.gbus), tagged_by(c, extra=c.gbus + "e")
parts = [tagged, extra, 2 * tagged]
with no_densify():
res = linopy.merge(parts, cls=LinearExpression)
want = linopy.merge([densified(p) for p in parts], cls=LinearExpression)
assert_sparse_matches(res, want)
with pytest.raises(ValueError, match="conflicting values"):
linopy.merge([*parts, tagged_by(c, tag=c.gbus + "z")], cls=LinearExpression)


ABSENT_KEEPING_OPS: dict[str, Callable[[LinearExpression], LinearExpression]] = {
"elementwise": lambda e: e * grid_operand(e) + 1.0,
"selection": lambda e: e.where(alternating(e), 3.0).isel(season=[1, 0, 1]),
Expand Down
Loading