Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
121 commits
Select commit Hold shift + click to select a range
8c908af
docs: update benchmark readme
FBumann May 27, 2026
413f1c6
benchmarks: reusable model registry, new model types, new phases, CI …
FBumann May 28, 2026
a6cc83b
benchmarks: add --long flag, gate super-long sizes by default
FBumann May 28, 2026
300abb5
benchmarks: make --quick truly quick (35s → 18s)
FBumann May 28, 2026
c725c68
benchmarks: add registry-usage notebook + execute in CI
FBumann May 28, 2026
99483f8
benchmarks: switch walkthrough to .ipynb, add reprs to ModelSpec
FBumann May 28, 2026
751aa78
benchmarks: typer-based CLI as the single entry point
FBumann May 28, 2026
8b124e2
benchmarks: pin typer==0.26.2, use ctx.args for pytest pass-through
FBumann May 28, 2026
86fd036
benchmarks: pin test infra + add transitive lockfile
FBumann May 28, 2026
9be18e1
benchmarks: add ``sweep`` subcommand for cross-version perf runs
FBumann May 28, 2026
51f418d
benchmarks: collapse README to a pointer, kill duplication
FBumann May 28, 2026
c0f3fee
benchmarks: make pypsa optional, expand notebook into proper guide
FBumann May 28, 2026
0522a75
benchmarks: sweep gains --phase / --model / --filter + pytest pass-th…
FBumann May 28, 2026
7bb464e
benchmarks: add ``compare`` subcommand wrapping pytest-benchmark compare
FBumann May 28, 2026
83bdeda
benchmarks: compare lists snapshots as relative paths (easier to copy…
FBumann May 28, 2026
8e378b5
benchmarks: tighter defaults for ``compare`` (median/iqr, sorted by n…
FBumann May 28, 2026
f67721b
benchmarks: compare gains ``--group-by=fullname`` default + ctx.args …
FBumann May 28, 2026
3ac333b
benchmarks: revert compare to manual arg-split + acknowledge typer wart
FBumann May 28, 2026
919e061
benchmarks: add ``plot`` subcommand (compare / sweep / scaling views)
FBumann May 28, 2026
c921b78
benchmarks: move plotting to benchmarks/plotting.py + text_auto + hov…
FBumann May 28, 2026
4c6f328
benchmarks: switch primary metric to ``min``, allow ``--metric`` over…
FBumann May 28, 2026
d703cb1
benchmarks: plot compare sorts/bars by absolute time delta by default
FBumann May 28, 2026
69693c0
benchmarks: add ``scatter`` plot view for two-snapshot exploration
FBumann May 28, 2026
2f08aa6
benchmarks: scatter view handles N snapshots via plotly animation_frame
FBumann May 28, 2026
321d2d9
benchmarks: scatter — include baseline as frame 0, clip colour to p95…
FBumann May 28, 2026
a0d4b7a
benchmarks: scatter — center y-axis symmetrically around 1.0
FBumann May 28, 2026
45700e7
benchmarks: address review — row height, scaling-from-params, mismatc…
FBumann May 28, 2026
ad7aa53
benchmarks: plot returns Figure, default output → .benchmarks/plots/<…
FBumann May 28, 2026
7c7bab2
benchmarks: plot renderers return (Figure, n_tests) — drop trace intr…
FBumann May 28, 2026
6a8a16d
benchmarks: notebook plot demo uses the CLI + tqdm progress
FBumann May 28, 2026
09dad9d
benchmarks: notebook plot demo accepts the full CLI command string
FBumann May 28, 2026
2ece2c1
benchmarks: memory tracks all phases via memray.Tracker; README accur…
FBumann May 28, 2026
ea4bc76
benchmarks: plot subcommand auto-detects memory snapshots alongside t…
FBumann May 28, 2026
cccd476
benchmarks: compare view drops unchanged tests (esp. memory)
FBumann May 28, 2026
d34824a
benchmarks: fix compare y-axis collision; revert unchanged-row filter
FBumann May 28, 2026
d88f235
benchmarks: compare view renders value text outside bars
FBumann May 28, 2026
abb3f14
benchmarks: compare bars keep alphabetical test-id order
FBumann May 28, 2026
914efbf
benchmarks: plot gains ``--facets {phase,model}`` for compare + scatter
FBumann May 28, 2026
eb687f1
benchmarks: faceted compare/scatter share one x + y axis label
FBumann May 28, 2026
5a08e79
benchmarks: notebook showcases ``--facets phase`` after compare/scatter
FBumann May 28, 2026
e24451a
benchmarks: faceted compare — per-facet rows, shared y-tick labels pe…
FBumann May 28, 2026
f4917dd
benchmarks: scatter as default compare view + expose load_long_df
FBumann May 28, 2026
2993b95
benchmarks: memory sweep + --rounds/--repeats overrides + centralized…
FBumann May 28, 2026
ac1df53
benchmarks: CodSpeed CI + Dependabot perf attribution loop
FBumann May 28, 2026
0e6ec41
benchmarks: drop lockfile, relocate walkthrough, Jupytext --build flow
FBumann May 28, 2026
3981cad
benchmarks: add CLI walkthrough as Jupytext MyST notebook
FBumann May 28, 2026
59eadb3
benchmarks: bump pinned jupytext to 1.19.3 (matches installed)
FBumann May 28, 2026
cbf517a
benchmarks: sweep --smoke for cross-version sanity checks
FBumann May 28, 2026
4ba6fb4
benchmarks: small cleanups (dead __iter__, naming, stale comments)
FBumann May 28, 2026
d86b111
benchmarks: delete unused SOLVER_BUILD phase + collapse models re-exp…
FBumann May 28, 2026
b153239
benchmarks: share phase verbs via benchmarks/phases.py + guard the seam
FBumann May 28, 2026
754e0ec
benchmarks: extract _provision_venvs helper to dedupe sweep plumbing
FBumann May 28, 2026
7d3e474
benchmarks: bump pinned numpy 1.26.4 → 2.4.6
FBumann May 29, 2026
2621a7b
benchmarks: relax numpy pin to <2.0 for wider sweep coverage
FBumann May 29, 2026
2656178
benchmarks: pin numpy back to ==1.26.4 (last 1.x)
FBumann May 29, 2026
e7f9c5b
benchmarks: fix sweep silently measuring dev linopy + getattr SOLVER_…
FBumann May 29, 2026
b35fafe
benchmarks: pin xarray to 2025.1.2 to extend sweep coverage to 0.4.4
FBumann May 29, 2026
11f56d2
benchmarks: shim write_lp for linopy <0.4.1, extending sweep floor to…
FBumann May 29, 2026
3091c64
benchmarks: add --as-of <DATE> for cross-time-reproducible sweeps
FBumann May 29, 2026
e74ae1e
benchmarks: harden the sweep isolation seam (preflight + no bytecode)
FBumann May 29, 2026
55612f5
benchmarks: copy harness into sweep venvs instead of symlinking
FBumann May 29, 2026
c031153
benchmarks: add ad-hoc `bench` helper for arbitrary callables
FBumann May 29, 2026
3df647c
benchmarks: make the suite mypy-clean
FBumann May 29, 2026
c5f23ec
benchmarks: extract snapshot.py + calibrate bench.time
FBumann May 29, 2026
2839145
benchmarks: split sweep orchestration out of cli.py
FBumann May 29, 2026
4502fed
benchmarks: drop the "Other CLI surfaces" table from the walkthrough
FBumann May 29, 2026
99f4f56
benchmarks: show load_long_df from-file diff in the walkthrough
FBumann May 29, 2026
927750f
benchmarks: label sweep snapshots by ref/sha for git/file specs
FBumann May 29, 2026
63c30f8
perf: scatter groupby-sum terms directly instead of unstacking
FBumann Jun 3, 2026
ee8d89a
feat(benchmarks): add user-pattern specs swept over a severity axis
FBumann Jun 4, 2026
cfcd4b2
feat(benchmarks): end-to-end pipeline memory + CLI/docs for patterns
FBumann Jun 4, 2026
9063ec8
refactor(benchmarks): one spec selector (--filter) + spec_param_id he…
FBumann Jun 4, 2026
8a4a8a6
fix(benchmarks): make the suite mypy-clean + repair the plot dependen…
FBumann Jun 4, 2026
abe6329
feat(benchmarks): add merge_balance and flow_sum patterns
FBumann Jun 4, 2026
f2e63a2
feat(benchmarks): add storage model + rolling pattern (intertemporal …
FBumann Jun 4, 2026
816a5ce
feat(benchmarks): add cumsum pattern
FBumann Jun 5, 2026
ac1fda4
ci(mypy): type-check benchmarks/ (anchor the legacy benchmark/ exclude)
FBumann Jun 5, 2026
7f2585b
benchmarks: harden pypsa example fetch + CodSpeed continue-on-error
FBumann Jun 5, 2026
827a947
Merge branch 'master' into benchmark-suite-charter
FBumann Jun 5, 2026
919e766
Merge remote-tracking branch 'origin/benchmark-suite-charter' into fe…
FBumann Jun 5, 2026
be65b12
refactor(benchmarks): split cli.py into a typer sub-app package
FBumann Jun 5, 2026
f07fa3c
test(benchmarks): group harness unit tests under _tests/
FBumann Jun 5, 2026
14b6445
docs(benchmarks): trim verbose inline comments in CI/config
FBumann Jun 5, 2026
e9934f0
feat(benchmarks): drop flow_sum pattern (irreducible, not a sparsity …
FBumann Jun 5, 2026
1db88ec
perf(benchmarks): size pattern dims so peak-RSS cliffs clear the nois…
FBumann Jun 5, 2026
8daaa2d
fix: read tuple coords entries as xarray's (dim_name, values)
FBumann Jun 5, 2026
049a940
test: parameterize and reorganize alignment coords tests
FBumann Jun 5, 2026
e08b44f
fix: correct coords-entry TypeError to not list tuple as a bare sequence
FBumann Jun 5, 2026
03beba4
fix(benchmarks): clearer scaling-plot axis labels via a per-axis table
FBumann Jun 5, 2026
942dec8
refactor(benchmarks): rename the spec-name field model -> spec
FBumann Jun 5, 2026
ac3a6d1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 5, 2026
fb4a8bd
ci: split CodSpeed into its own master-only workflow
FBumann Jun 5, 2026
1ddba73
merge: benchmark suite onto master (sparsity/memory baseline)
FBumann Jun 5, 2026
f235cc4
ci(codspeed): instrumentation on PRs-to-master, add walltime macro job
FBumann Jun 5, 2026
d0ca7d3
merge: tuple coords-entry regression fix (#766)
FBumann Jun 5, 2026
0ef08c0
fix(benchmarks): walkthrough groups by spec, not the renamed-away model
FBumann Jun 5, 2026
dc8d404
ci(codspeed): authenticate via OIDC instead of a token secret
FBumann Jun 5, 2026
ce449ce
ci(codspeed): set mode=simulation (required input in action v4)
FBumann Jun 5, 2026
ec473c8
ci(codspeed): add memory instrument job (heap allocations)
FBumann Jun 5, 2026
755085a
perf(benchmarks): scope CodSpeed runs, drop netcdf disk I/O
FBumann Jun 5, 2026
b031fc1
Merge branch 'master' into perf/groupby-sum-scatter
FBumann Jun 5, 2026
ab61e27
perf(benchmarks): thin default severity sweep 5->3 (0,50,100)
FBumann Jun 5, 2026
1e2f49d
refactor(benchmarks): rename phases to to_/from_ scheme; drop matrice…
FBumann Jun 5, 2026
0478494
perf(benchmarks): per-instrument CodSpeed subsets via --codspeed-set
FBumann Jun 5, 2026
d0d0c52
perf(benchmarks): simplify --codspeed-set to full|simulation
FBumann Jun 5, 2026
7c6bf8b
Merge branch 'master' into perf/groupby-sum-scatter
FBumann Jun 5, 2026
8593377
perf(benchmarks): scope walkthrough run/memory cells to one model
FBumann Jun 5, 2026
56eab94
Merge branch 'master' into perf/groupby-sum-scatter
FBumann Jun 5, 2026
e2276dc
ci(codspeed): combine simulation+memory upload; per-PR memory; label-…
FBumann Jun 5, 2026
bfdfbc8
Merge branch 'master' into perf/groupby-sum-scatter
FBumann Jun 5, 2026
f37c8be
refactor(benchmarks): lean up — trim docstrings/comments, dedupe plot…
FBumann Jun 5, 2026
520bb43
ci(codspeed): drop simulation (cachegrind); memory is the always-on b…
FBumann Jun 5, 2026
3fdb9b4
feat(benchmarks): log₂ sweep colouring + --clip colour clamp (#30)
FBumann Jun 6, 2026
0dc9c0a
Benchmark selection rework + time/memory CLI unification (#31)
FBumann Jun 6, 2026
4db3c76
fix(benchmarks): sweep colourbar keeps several round fold labels at s…
FBumann Jun 6, 2026
d0e12ec
Merge remote-tracking branch 'fork/master' into pr25-merge
FBumann Jun 6, 2026
6d47dfa
refactor(benchmarks): explicit QUICK_SIZES/LONG_SIZES per spec (drop …
FBumann Jun 6, 2026
9808c9a
Merge branch 'master' into perf/groupby-sum-scatter
FBumann Jun 7, 2026
a64c0f2
Merge branch 'PyPSA:master' into master
FBumann Jun 7, 2026
85c103c
pre-commit
FBumann Jun 7, 2026
88b50eb
Merge branch 'master' into perf/groupby-sum-scatter
FBumann Jun 29, 2026
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
138 changes: 126 additions & 12 deletions linopy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,18 +249,13 @@ def sum(self, use_fallback: bool = False, **kwargs: Any) -> LinearExpression:

# At this point, group is always a pandas Series
assert isinstance(group, pd.Series)
group_dim = group.index.name

arrays = [group, group.groupby(group).cumcount()]
idx = pd.MultiIndex.from_arrays(arrays, names=[GROUP_DIM, GROUPED_TERM_DIM])
new_coords = Coordinates.from_pandas_multiindex(idx, group_dim)
coords = self.data.indexes[group_dim]
names_to_drop = [coords.name]
if isinstance(coords, pd.MultiIndex):
names_to_drop += list(coords.names)
ds = self.data.drop_vars(names_to_drop).assign_coords(new_coords)
ds = ds.unstack(group_dim, fill_value=LinearExpression._fill_value)
ds = LinearExpression._sum(ds, dim=GROUPED_TERM_DIM)

if self._can_sum_by_scatter(group):
ds = self._sum_by_scatter(group)
else:
# chunked (e.g. dask-backed) data or exotic coordinates on the
# grouped dimension: use xarray's unstack machinery
ds = self._sum_by_unstack(group)

if int_map is not None:
index = ds.indexes[GROUP_DIM].map({v: k for k, v in int_map.items()})
Expand All @@ -279,6 +274,125 @@ def func(ds: Dataset) -> Dataset:

return self.map(func, **kwargs, shortcut=True)

def _can_sum_by_scatter(self, group: pd.Series) -> bool:
"""
Whether :meth:`_sum_by_scatter` covers the structure of the data.

The scatter kernel requires numpy-backed arrays (chunked data cannot be
scattered into preallocated numpy arrays) and no coordinates tied to
the grouped dimension besides its own index. Everything else falls
back to :meth:`_sum_by_unstack`.
"""
data = self.data
group_dim = group.index.name

numpy_backed = all(
isinstance(data[k].data, np.ndarray) for k in ("coeffs", "vars", "const")
)
if not numpy_backed:
return False

index = data.indexes.get(group_dim)
index_names = {group_dim, *(index.names if index is not None else ())}
return all(
coord.dims == (group_dim,) and name in index_names
for name, coord in data.coords.items()
if group_dim in coord.dims
)

def _sum_by_scatter(self, group: pd.Series) -> Dataset:
"""
Sum groups by scattering all terms directly into the final padded arrays.

Every group member keeps its block of ``nterm`` terms, so the resulting
term dimension has size ``max_group_size * nterm`` and smaller groups are
padded with fill values. In contrast to :meth:`_sum_by_unstack` only the
result arrays are allocated, without intermediate copies of that size.

Only the term and constant values are computed with numpy; the result
structure (dimensions, coordinates and their order) is assembled by
xarray. :meth:`_can_sum_by_scatter` decides whether the data is simple
enough for this kernel.
"""
data = self.data
group_dim = group.index.name
fill_value = LinearExpression._fill_value

codes, unique_groups = pd.factorize(group, sort=True)
if (codes == -1).any():
raise ValueError(
"Cannot group by a pandas object containing NaN values. "
"Drop or fill the corresponding entries before grouping."
)

n_groups = len(unique_groups)
sizes = np.bincount(codes, minlength=n_groups)
max_size = int(sizes.max()) if n_groups else 0

# position of each element within its group (order of appearance)
positions = pd.Series(codes).groupby(codes).cumcount().to_numpy()

def scatter(
da: DataArray, fill: Any
) -> tuple[tuple[Hashable, ...], np.ndarray]:
"""Scatter one term-array into its padded (group x term) layout."""
rest_dims = [d for d in da.dims if d not in (group_dim, TERM_DIM)]
values = da.transpose(group_dim, *rest_dims, TERM_DIM).values
rest_shape = values.shape[1:-1]
nterm = values.shape[-1]

out = np.full(
(n_groups, *rest_shape, nterm, max_size), fill, dtype=values.dtype
)
locs = (codes, *(slice(None),) * (len(rest_shape) + 1), positions)
out[locs] = values
# collapsing (nterm, max_size) into one axis keeps all terms of one
# group member together, with padding at the end of each block
out = out.reshape((n_groups, *rest_shape, nterm * max_size))
return (GROUP_DIM, *rest_dims, TERM_DIM), out

coeffs_dims, coeffs = scatter(data.coeffs, fill_value["coeffs"])
vars_dims, vars = scatter(data.vars, fill_value["vars"])

# constants are summed up within each group, skipping NaN values
const_dims = [d for d in data.const.dims if d != group_dim]
const_values = data.const.transpose(group_dim, *const_dims).values
const = np.zeros((n_groups, *const_values.shape[1:]), dtype=const_values.dtype)
np.add.at(const, codes, np.where(np.isnan(const_values), 0, const_values))

# only the values above are computed with numpy, the result structure
# (dimensions, coordinates and their order) is assembled by xarray
# itself and thereby matches a result of unstacking the group dimension
structure = data.drop_vars(["coeffs", "vars", "const"])
structure = structure.drop_dims(group_dim)
structure = structure.expand_dims({GROUP_DIM: unique_groups})

return structure.assign(
coeffs=(coeffs_dims, coeffs),
vars=(vars_dims, vars),
const=((GROUP_DIM, *const_dims), const),
)

def _sum_by_unstack(self, group: pd.Series) -> Dataset:
"""
Sum groups by unstacking the group dimension into a padded helper
dimension and summing over it.

Equivalent to :meth:`_sum_by_scatter` but goes through xarray's
unstack/stack machinery, which also supports chunked (dask) data.
"""
group_dim = group.index.name
arrays = [group, group.groupby(group).cumcount()]
idx = pd.MultiIndex.from_arrays(arrays, names=[GROUP_DIM, GROUPED_TERM_DIM])
new_coords = Coordinates.from_pandas_multiindex(idx, group_dim)
coords = self.data.indexes[group_dim]
names_to_drop = [coords.name]
if isinstance(coords, pd.MultiIndex):
names_to_drop += list(coords.names)
ds = self.data.drop_vars(names_to_drop).assign_coords(new_coords)
ds = ds.unstack(group_dim, fill_value=LinearExpression._fill_value)
return LinearExpression._sum(ds, dim=GROUPED_TERM_DIM)

def roll(self, **kwargs: Any) -> LinearExpression:
"""
Roll the groupby object.
Expand Down
124 changes: 124 additions & 0 deletions test/test_linear_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,130 @@ def test_linear_expression_groupby_from_variable(v: Variable) -> None:
assert grouped.nterm == 10


def test_linear_expression_groupby_skewed_unsorted_groups(v: Variable) -> None:
"""
The scatter-based fast path must match the xarray fallback for groups that
are unsorted, non-contiguous and of very different sizes.
"""
expr = 2 * v + 5
# 'b' appears 14 times, 'c' 5 times, 'a' once, scattered over the dimension
labels = ["b"] * 4 + ["c", "a"] + ["b"] * 5 + ["c"] * 4 + ["b"] * 5
groups = pd.Series(labels, index=v.indexes["dim_2"], name="letter")

grouped = expr.groupby(groups).sum()
fallback = expr.groupby(groups.to_xarray()).sum(use_fallback=True)

assert list(grouped.data.letter) == ["a", "b", "c"]
# padded to the largest group times the number of terms of the input
assert grouped.nterm == 14 * expr.nterm
assert_linequal(grouped, fallback)

# every group must carry exactly the variables of its members, the rest is fill
for letter in ["a", "b", "c"]:
members = np.where(np.array(labels) == letter)[0]
vars_of_group = grouped.data.vars.sel(letter=letter).values
assert set(vars_of_group[vars_of_group >= 0]) == set(v.labels.values[members])
assert (vars_of_group >= 0).sum() == len(members) * expr.nterm
assert grouped.const.sel(letter=letter).item() == 5 * len(members)


def test_linear_expression_groupby_chunked(v: Variable) -> None:
"""Chunked (dask-backed) expressions group via xarray's unstack machinery."""
pytest.importorskip("dask")
expr = 2 * v + 5
groups = pd.Series([1] * 12 + [2] * 8, index=v.indexes["dim_2"], name="group")

chunked = LinearExpression(expr.data.chunk({"dim_2": 5}), expr.model)
grouped_chunked = chunked.groupby(groups).sum()
grouped = expr.groupby(groups).sum()

assert grouped_chunked.nterm == grouped.nterm
assert_linequal(
LinearExpression(grouped_chunked.data.compute(), expr.model), grouped
)


def test_linear_expression_groupby_with_nan_groups(v: Variable) -> None:
expr = 1 * v
groups = pd.Series([1.0, np.nan] * 10, index=v.indexes["dim_2"], name="with_nans")
with pytest.raises(ValueError, match="NaN"):
expr.groupby(groups).sum()


@pytest.mark.parametrize(
"case",
[
"skewed_int_groups",
"multidim_with_const",
"nan_const",
"masked_vars",
"quadratic",
"single_group",
"identity_groups",
],
)
def test_linear_expression_groupby_scatter_equals_unstack(case: str) -> None:
"""
Lock the two groupby-sum kernels together.

The fast path of groupby(...).sum() scatters terms into numpy arrays
(_sum_by_scatter); the xarray unstack implementation (_sum_by_unstack) is
kept for chunked data and exotic coordinates. Both must stay
interchangeable — if an xarray/pandas update changes the unstack output or
an edge case diverges, this fails.
"""
m = Model()
rng = np.random.default_rng(0)
idx = pd.RangeIndex(60, name="elem")
skewed = pd.Series(rng.choice(8, 60, p=[0.5] + [0.5 / 7] * 7), index=idx, name="g")
groups = skewed

if case == "skewed_int_groups":
x = m.add_variables(coords=[idx], name="x")
expr: LinearExpression | QuadraticExpression = 3 * x - 2 * x + 7
elif case == "multidim_with_const":
other = pd.Index(list("abc"), name="other")
y = m.add_variables(coords=[other, idx], name="y")
const = xr.DataArray(rng.normal(size=(3, 60)), coords=[other, idx])
expr = 2 * y + 1 * y + const
elif case == "nan_const":
x = m.add_variables(coords=[idx], name="x")
expr = 1 * x + np.where(np.arange(60) % 3, np.nan, 5.0)
elif case == "masked_vars":
mask = xr.DataArray(np.arange(60) % 4 != 0, coords=[idx])
x = m.add_variables(coords=[idx], name="x", mask=mask)
expr = 1 * x
elif case == "quadratic":
x = m.add_variables(coords=[idx], name="x")
expr = x * x + 2 * x
elif case == "single_group":
x = m.add_variables(coords=[idx], name="x")
expr = 1 * x
groups = pd.Series(1, index=idx, name="g")
else: # identity_groups
x = m.add_variables(coords=[idx], name="x")
expr = 1 * x
groups = pd.Series(np.arange(60), index=idx, name="g")

gb = expr.groupby(groups)
assert gb._can_sum_by_scatter(groups)
scatter = LinearExpression(gb._sum_by_scatter(groups).rename(_group="g"), m)
unstack = LinearExpression(gb._sum_by_unstack(groups).rename(_group="g"), m)

# identical structure: dims, dim order, coordinates
assert scatter.data.coeffs.dims == unstack.data.coeffs.dims
assert scatter.data.const.dims == unstack.data.const.dims
assert list(scatter.data.coords) == list(unstack.data.coords)
for name in scatter.data.coords:
assert_equal(scatter.data[name], unstack.data[name])

# identical values: vars and coeffs bit-exact, including padding positions
np.testing.assert_array_equal(scatter.vars.values, unstack.vars.values)
np.testing.assert_array_equal(scatter.coeffs.values, unstack.coeffs.values)
# constants may differ by floating-point summation order
np.testing.assert_allclose(scatter.const.values, unstack.const.values, rtol=1e-12)


def test_linear_expression_rolling(v: Variable) -> None:
expr = 1 * v
rolled = expr.rolling(dim_2=2).sum()
Expand Down
Loading