You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Support non-scalar aggregation outputs (fixed-length vectors + variable-length/ragged per-cell payloads)
Summary
zagg currently reduces every aggregation variable to one scalar per cell. We want to emit vectors (fixed-length per cell) and variable-length / sparse encodings (per-cell payload
whose size varies by cell) so a cell can hold a mergeable quantile sketch (t-digest centroids,
KLL/REQ) or a raw value tier instead of a lone float. The only hard requirement is that the
per-cell output is representable in Zarr v3.
Motivation (empirical, from the waveform_benchmarks prototype)
Prototyping on real ATL03 (RGT 1336, region 05, E. Greenland, 29-cycle time series; see waveform_benchmarks/agg_benchmark.py + aggregation_review.ipynb) established:
Scalar quantiles are not mergeable. Stored per-(season,year) q02/q50/q98 scalars cannot
be correctly re-pooled across the temporal/spatial merge axis (averaging percentiles is wrong).
Mergeability is the whole reason for a per-cell summary, and it requires a sketch payload.
A t-digest is two short vectors(means, weights) shape (k, 2). On real data it
reconstructs every quantile within ~1 cm of empirical, and two independently-built digests combine() into one whose quantiles match the pooled raw — the mergeable object to store per cell.
Density regime: per 10 m cell per overpass, signal photons are p50 16 / max 138; pooled
across cycles the busiest cell ≈ 670. So leaf cells are small (store raw or near-raw) and the
t-digest pays off at the merge level. Either way the per-cell payload is non-scalar and its
length varies by cell.
A dense fixed-pad layout is infeasible (~115 GB for one box). The workable layouts are a fixed trailing-dimension vector or a ragged/CSR encoding (values + offsets + cell_ids), both Zarr-v3-native. (zarr v3 dropped the v2 object_codec path, so CSR is the
portable ragged representation.)
Relationship to recently merged work
Design doc for broadening input/output data structures #11 (generalize output grids — merged, docs/design/generalized_grids.md) generalizes the grid (HEALPix/H3/rectilinear) but explicitly keeps the aggregation variables grid-agnostic and one scalar array per variable. This issue is the orthogonal axis: generalize the per-cell value (scalar → vector/ragged). It plugs into the surface Design doc for broadening input/output data structures #11 established — the shared
aggregation-variable block consumed by every OutputGrid.emit_template(schema).
As of main (@ 83b92ab), the scalar assumption is baked into the whole cell→Zarr path:
Aggregation eval — processing.calculate_cell_statistics: result[name] = float(func(values, **resolved_params)) hard-casts each variable to a single float per cell.
Per-cell accumulator — processing.process_shard: stats_arrays[name] = np.full(n_cells, ...) is 1-D, filled with stats_arrays[key][i] = value.
Cell→Zarr handoff — df_out = pd.DataFrame({var: stats_arrays[var]}), one row per cell,
scalar per cell. (pandas today; catalog layer already Arrow — see above.)
Write path — processing.write_dataframe_to_zarr asserts len(df_out) == prod(grid.chunk_shape), reshapes each column to grid.chunk_shape, array.set_block_selection(chunk_idx, values). No room for a payload axis or ragged data.
Template — per-grid OutputGrid.emit_template(schema), built from a shared
aggregation-variable block via pydantic-zarr (HEALPix path = schema.xdggs_spec / xdggs_zarr_template; rectilinear = grids/rectilinear.py_spec). Each agg field becomes one ArraySpec of shape = array_shape (grid shape), scalar data_type. No payload dimension.
Schema/config — get_agg_fields (config.py) describes a field with function/expression/source/params/dtype/fill_value; no notion of output rank or variable length.
Proposed capability (two tiers)
Tier 1 — fixed-length vector per cell. Field declares width C (and optional inner dim, e.g. (C,2) for t-digest (mean,weight)). Zarr array array_shape + (C,...), chunks chunk_shape + (C,...), sentinel for unused slots. Covers fixed quantile vectors and a
padded/bounded t-digest. Rectangular but pads to worst case.
Tier 2 — variable-length / ragged (CSR) per cell. Per-cell length varies (true t-digest
centroid count, or a raw tier). Represent as a small group of arrays — values (flat) + offsets
cell_ids — instead of one grid-shaped array (validated in agg_benchmark.pywrite_csr).
Alternative to evaluate: Zarr v3 VLenBytes (simpler API; verify reader round-trip first).
calculate_cell_statistics: drop the unconditional float(...); allow ndarray/bytes
returns, dispatched on field kind.
process_shard accumulation: build the right container per kind (N×C array vs
list-of-payloads + lengths).
cell→table handoff: the row-per-cell scalar model holds for scalar/fixed-vector but breaks for ragged. Generalize the handoff (currently pd.DataFrame) — an Arrow table with FixedSizeList/List columns carries vector/ragged natively; otherwise object-column or a
dedicated non-DataFrame writer.
write_dataframe_to_zarr / new writer: trailing payload dim (vector) + CSR/vlen writer
keyed by cell_id (ragged). The len == prod(chunk_shape) invariant only applies to
dense/scalar/vector.
template emission — extend the shared aggregation-variable block (pydantic-zarr
GroupSpec) consumed by every OutputGrid.emit_template(schema), so all grids inherit
non-scalar support; touches schema.xdggs_spec (HEALPix) and grids/rectilinear.py_spec.
Where sketches are built: prototype concludes raw at leaf, t-digest at merge. Leaf-emit
only here; merge in the temporal work?
Sentinel/fill for vectors: weight-0 padding + NaN/inf means so reducers don't read padding.
Signature coverage: exactly what of the output kind/shape goes into signature() / nests_with() so scalar and vector runs are correctly judged incompatible.
Backend portability (Backend support (i.e., local processing support) #20): the output contract must hold across backends (Lambda, local
numpy, vaex, BYO cluster). A scalar reduction maps onto any groupby-agg; vector/ragged/custom
sketch does not map cleanly onto vaex built-ins and may need per-group apply. Define the
field-kind contract backend-agnostically; validate on local-numpy first (the waveform_benchmarks prototype is that reference). An Arrow handoff also eases this — Arrow is
the common currency across numpy/vaex/cluster backends.
Scope / non-goals
In: per-cell non-scalar output representation (config → eval → template → write → read) in
the shared aggregation-variable + emit_template surface, so all OutputGrid impls inherit
it; validated on the rectilinear grid.
Out (separate): choosing the final sketch (t-digest vs KLL/REQ), tuning CMAX/compression,
new grid types (Design doc for broadening input/output data structures #11), crossover-cell density studies, and the full pandas→Arrow handoff migration
(this issue should not depend on it, only stay compatible).
Acceptance criteria
A config can declare a vector aggregation → correctly-shaped Zarr array, round-tripped in a test.
A config can declare a ragged aggregation → CSR (or vlen) payload, round-tripped, with a reader
reconstructing a cell.
The grid signature() distinguishes scalar/vector/ragged runs (mismatch is rejected).
Support non-scalar aggregation outputs (fixed-length vectors + variable-length/ragged per-cell payloads)
Summary
zagg currently reduces every aggregation variable to one scalar per cell. We want to emit
vectors (fixed-length per cell) and variable-length / sparse encodings (per-cell payload
whose size varies by cell) so a cell can hold a mergeable quantile sketch (t-digest centroids,
KLL/REQ) or a raw value tier instead of a lone float. The only hard requirement is that the
per-cell output is representable in Zarr v3.
Motivation (empirical, from the
waveform_benchmarksprototype)Prototyping on real ATL03 (RGT 1336, region 05, E. Greenland, 29-cycle time series; see
waveform_benchmarks/agg_benchmark.py+aggregation_review.ipynb) established:q02/q50/q98scalars cannotbe correctly re-pooled across the temporal/spatial merge axis (averaging percentiles is wrong).
Mergeability is the whole reason for a per-cell summary, and it requires a sketch payload.
(means, weights)shape(k, 2). On real data itreconstructs every quantile within ~1 cm of empirical, and two independently-built digests
combine()into one whose quantiles match the pooled raw — the mergeable object to store per cell.across cycles the busiest cell ≈ 670. So leaf cells are small (store raw or near-raw) and the
t-digest pays off at the merge level. Either way the per-cell payload is non-scalar and its
length varies by cell.
fixed trailing-dimension vector or a ragged/CSR encoding (
values+offsets+cell_ids), both Zarr-v3-native. (zarr v3 dropped the v2object_codecpath, so CSR is theportable ragged representation.)
Relationship to recently merged work
docs/design/generalized_grids.md) generalizes thegrid (HEALPix/H3/rectilinear) but explicitly keeps the aggregation variables grid-agnostic and
one scalar array per variable. This issue is the orthogonal axis: generalize the per-cell
value (scalar → vector/ragged). It plugs into the surface Design doc for broadening input/output data structures #11 established — the shared
aggregation-variable block consumed by every
OutputGrid.emit_template(schema).OutputGridnow exposessignature()/nests_with(); a ShardMap records the grid signature and run-time refuses a mismatch. Anon-scalar payload changes array structure, so the field's output kind/shape must be folded into
the signature (else a scalar shardmap could pair with a vector run).
catalog/sources.pyis pyarrow/stac-geoparquet, but theper-cell aggregation→Zarr handoff in
processing.pyis still pandas. No impact today; aneventual Arrow handoff is synergistic (FixedSizeList for vectors, List/LargeList for ragged).
Current behavior — the scalar-per-cell contract
As of
main(@ 83b92ab), the scalar assumption is baked into the whole cell→Zarr path:processing.calculate_cell_statistics:result[name] = float(func(values, **resolved_params))hard-casts each variable to a single float per cell.processing.process_shard:stats_arrays[name] = np.full(n_cells, ...)is 1-D, filled withstats_arrays[key][i] = value.df_out = pd.DataFrame({var: stats_arrays[var]}), one row per cell,scalar per cell. (pandas today; catalog layer already Arrow — see above.)
processing.write_dataframe_to_zarrassertslen(df_out) == prod(grid.chunk_shape), reshapes each column togrid.chunk_shape,array.set_block_selection(chunk_idx, values). No room for a payload axis or ragged data.OutputGrid.emit_template(schema), built from a sharedaggregation-variable block via
pydantic-zarr(HEALPix path =schema.xdggs_spec/xdggs_zarr_template; rectilinear =grids/rectilinear.py_spec). Each agg field becomes oneArraySpecofshape = array_shape(grid shape), scalardata_type. No payload dimension.get_agg_fields(config.py) describes a field withfunction/expression/source/params/dtype/fill_value; no notion of output rank or variable length.Proposed capability (two tiers)
Tier 1 — fixed-length vector per cell. Field declares width
C(and optional inner dim, e.g.(C,2)for t-digest(mean,weight)). Zarr arrayarray_shape + (C,...), chunkschunk_shape + (C,...), sentinel for unused slots. Covers fixed quantile vectors and apadded/bounded t-digest. Rectangular but pads to worst case.
Tier 2 — variable-length / ragged (CSR) per cell. Per-cell length varies (true t-digest
centroid count, or a raw tier). Represent as a small group of arrays —
values(flat) +offsetscell_ids— instead of one grid-shaped array (validated inagg_benchmark.pywrite_csr).Alternative to evaluate: Zarr v3
VLenBytes(simpler API; verify reader round-trip first).Where the changes land (touch-point checklist)
config.py): declare output kind —scalar(default),vector(withlength/inner shape),ragged. Scalar stays default so existing configs are unchanged.calculate_cell_statistics: drop the unconditionalfloat(...); allow ndarray/bytesreturns, dispatched on field kind.
process_shardaccumulation: build the right container per kind (N×C array vslist-of-payloads + lengths).
breaks for ragged. Generalize the handoff (currently
pd.DataFrame) — an Arrow table withFixedSizeList/Listcolumns carries vector/ragged natively; otherwise object-column or adedicated non-DataFrame writer.
write_dataframe_to_zarr/ new writer: trailing payload dim (vector) + CSR/vlen writerkeyed by
cell_id(ragged). Thelen == prod(chunk_shape)invariant only applies todense/scalar/vector.
GroupSpec) consumed by every
OutputGrid.emit_template(schema), so all grids inheritnon-scalar support; touches
schema.xdggs_spec(HEALPix) andgrids/rectilinear.py_spec.OutputGrid.signature(): include the per-field output kind/shape so a non-scalar runcan't be silently paired with a scalar ShardMap (Reconcile catalog API + validate grid redesign: Catalog fetch/shard-map split, odc.geo grids, signature-enforced runs #23/Add bring-your-own-role path for IAM-constrained deploys; creds handling for external s3 bucket writes #27).
reconstruct/merge — needed for the merge step and tests.
Design questions to settle first
field-kind abstraction once; implement Tier 1, then Tier 2.)
VLenBytes— quick spike.only here; merge in the temporal work?
signature()/nests_with()so scalar and vector runs are correctly judged incompatible.numpy, vaex, BYO cluster). A scalar reduction maps onto any groupby-agg; vector/ragged/custom
sketch does not map cleanly onto vaex built-ins and may need per-group
apply. Define thefield-kind contract backend-agnostically; validate on local-numpy first (the
waveform_benchmarksprototype is that reference). An Arrow handoff also eases this — Arrow isthe common currency across numpy/vaex/cluster backends.
Scope / non-goals
the shared aggregation-variable +
emit_templatesurface, so allOutputGridimpls inheritit; validated on the rectilinear grid.
CMAX/compression,new grid types (Design doc for broadening input/output data structures #11), crossover-cell density studies, and the full pandas→Arrow handoff migration
(this issue should not depend on it, only stay compatible).
Acceptance criteria
vectoraggregation → correctly-shaped Zarr array, round-tripped in a test.raggedaggregation → CSR (or vlen) payload, round-tripped, with a readerreconstructing a cell.
signature()distinguishes scalar/vector/ragged runs (mismatch is rejected).waveform_benchmarksATL03 case.References
waveform_benchmarks/agg_benchmark.py,aggregation_review.ipynb,DESIGN_NOTES.md(CSR layout, t-digest merge demo, density).src/zagg/processing.py(calculate_cell_statistics,process_shard,write_dataframe_to_zarr),src/zagg/schema.py(xdggs_spec/xdggs_zarr_template),src/zagg/grids/rectilinear.py(_spec/emit_template),src/zagg/grids/base.py(
OutputGrid,signature,nests_with),src/zagg/config.py(get_agg_fields).docs/design/generalized_grids.md), signature-enforcedruns (Reconcile catalog API + validate grid redesign: Catalog fetch/shard-map split, odc.geo grids, signature-enforced runs #23/Add bring-your-own-role path for IAM-constrained deploys; creds handling for external s3 bucket writes #27), multi-backend support (Backend support (i.e., local processing support) #20 — output contract must be backend-agnostic),
catalog Arrow migration (
catalog/sources.py).