Skip to content

Support non-scalar aggregation outputs (vectors + ragged per-cell payloads) #29

Description

@espg

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

Current behavior — the scalar-per-cell contract

As of main (@ 83b92ab), the scalar assumption is baked into the whole cell→Zarr path:

  1. Aggregation evalprocessing.calculate_cell_statistics: result[name] = float(func(values, **resolved_params)) hard-casts each variable to a single float per cell.
  2. Per-cell accumulatorprocessing.process_shard: stats_arrays[name] = np.full(n_cells, ...) is 1-D, filled with stats_arrays[key][i] = value.
  3. Cell→Zarr handoffdf_out = pd.DataFrame({var: stats_arrays[var]}), one row per cell,
    scalar per cell. (pandas today; catalog layer already Arrow — see above.)
  4. Write pathprocessing.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.
  5. 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.
  6. Schema/configget_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.py write_csr).
    Alternative to evaluate: Zarr v3 VLenBytes (simpler API; verify reader round-trip first).

Where the changes land (touch-point checklist)

  • config/schema (config.py): declare output kind — scalar (default), vector (with
    length/inner shape), ragged. Scalar stays default so existing configs are unchanged.
  • 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.
  • OutputGrid.signature(): include the per-field output kind/shape so a non-scalar run
    can'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).
  • reader/round-trip: helper to read back a cell vector/sketch and (for sketches)
    reconstruct/merge — needed for the merge step and tests.

Design questions to settle first

  1. Scope: Tier 1 first + Tier 2 follow-up, or design both together? (Lean: design the
    field-kind abstraction once; implement Tier 1, then Tier 2.)
  2. Ragged mechanism: CSR triple vs Zarr v3 VLenBytes — quick spike.
  3. Where sketches are built: prototype concludes raw at leaf, t-digest at merge. Leaf-emit
    only here; merge in the temporal work?
  4. Sentinel/fill for vectors: weight-0 padding + NaN/inf means so reducers don't read padding.
  5. Signature coverage: exactly what of the output kind/shape goes into signature() /
    nests_with() so scalar and vector runs are correctly judged incompatible.
  6. 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).
  • Existing scalar configs unchanged (scalar default path); HEALPix/ATL06 path byte-for-byte stable.
  • Works end-to-end on the rectilinear grid for the waveform_benchmarks ATL03 case.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions