Reductions¶
pypic.reduce collapses a FieldDataset along one or more axes
using a chosen reduction operation. Column densities, line-of-sight
integrals, slab averages, projected-max diagnostics, and peak-position
maps all compose from this single verb — paired with an optional
BoxSelection or SphereSelection for region restriction.
The verb is named reduce (not project) to avoid colliding with
Three.js Vector3.project(camera) (camera/screen-space projection) on
the webpic client side. The pypic server reduces; the viewer projects.
Reductions table¶
| Reduction | xarray dispatch | Multi-axis? | Metadata behavior |
|---|---|---|---|
integrate |
ds.integrate(coord=ax) looped over axes |
yes (sequential trapezoidal) | preserved |
sum |
ds.sum(dim=axis, skipna=...) |
yes | preserved |
mean |
ds.mean(dim=axis, skipna=...) |
yes | preserved |
median |
ds.median(dim=axis, skipna=...) |
yes | preserved |
max / min |
ds.{max,min}(dim=axis, skipna=...) |
yes | preserved |
std / var |
ds.{std,var}(dim=axis, skipna=...) |
yes | preserved |
argmax / argmin |
ds.{idxmax,idxmin}(dim=axis, ...) |
single axis only | overridden to quantity_type="length" |
argmax / argmin use xarray's idxmax / idxmin to return the
coordinate value of the extremum (e.g. the z-position where |J|
peaks) — not a raw integer index. Multi-axis search has no single
coord-value to return, so reject with ValueError.
Single-axis vs multi-axis¶
# Single axis: 3D → 2D
column = pypic.reduce(ds, "z", reduction="integrate")
# Tuple: 3D → 1D in one call (sequential trapezoidal under the hood)
line_out = pypic.reduce(ds, ("y", "z"), reduction="mean")
The single-axis and tuple forms are functionally identical for the
non-integrate reductions; for integrate the multi-axis form runs a
sequential trapezoidal that matches the chained two-call form to
machine precision.
Unit handling after integrate¶
Unweighted integrate shifts the SI unit dimension by one length
factor per reduced axis (number density m⁻³ → column density m⁻²,
energy density J/m³ → areal energy J/m²). reduce stamps the
running count on attrs["reduction"]["length_axes"] so
FieldDataset.in_si applies the extra length_ref**n factor at the
boundary — column densities come out in the correct C/m² (or m⁻²,
etc.) without manual length-unit bookkeeping.
column = pypic.reduce(ds, "z", reduction="integrate")
column.in_si("rho_c") # C/m^2 — correct SI
column.xr["rho_c"].attrs["reduction"]
# {"axis": "z", "op": "integrate", "length_axes": 1}
Chained integrates accumulate: reduce(reduce(ds, "y", "integrate"),
"z", "integrate") stamps length_axes=2, equivalent to
reduce(ds, ("y","z"), "integrate"). Non-integrate reductions
(mean/sum/max/...) are unit-preserving and carry the count
forward unchanged. Weighted integrate does not stamp
length_axes because the length factor cancels in
∫ f w dx / ∫ w dx.
Post-reduction si_unit strings and openPMD 7-tuples are not yet
generalized; the length_axes mechanism here is the interim fix that gets
in_si() returning the right number today.
Weight semantics¶
weight=<field-name> produces yt-style density- or emission-weighted
averages. Only reduction="mean" and reduction="integrate" accept a
weight — other reductions raise ValueError.
- Weighted mean: \(\langle f \rangle_w = \sum_i f_i w_i / \sum_i w_i\) over the reduced axes.
- Weighted integrate: \(\int f w \, dx / \int w \, dx\) — the column
weighted average along the line of sight (yt's
weight_fieldconvention).
# Density-weighted temperature column average
T_avg = pypic.reduce(ds, "z", reduction="integrate",
weight="rho_c", fields=["T_s0"])
Provenance: T_avg.xr["T_s0"].attrs["reduction"] carries
{"axis": "z", "op": "integrate", "weight": "rho_c"}.
NaN handling. Under nan_policy="omit" (default) the weighted
path uses joint masking: cells where the field or weight is NaN
contribute zero to both numerator and denominator, skipping them
consistently. Under "propagate", NaN flows through naturally. Under
"raise", both field and weight are pre-checked for NaN.
Selection composition¶
selection= runs first, then the reduction operates on the
selected region:
# Mean over a sub-volume
box = pypic.BoxSelection(ranges={"x": (10, 30), "y": (10, 30)})
slab = pypic.reduce(ds, "z", reduction="mean", selection=box)
# Line of sight through a sphere — NaN-mask outside, integrate inside
ball = pypic.SphereSelection(center=(0.0, 0.0, 0.0), radius=5.0,
keep="inside")
los = pypic.reduce(ds, "z", reduction="integrate", selection=ball)
SphereSelection pairs naturally with nan_policy="omit": outside
cells become NaN and are dropped from the reduction.
Plot composition pattern¶
A reduced FieldDataset is just a smaller-dimension FieldDataset —
all existing plotting helpers work unchanged:
from pypic.plotting import plot_field_slice
fig, ax = plot_field_slice(
ds.reduce("z", reduction="integrate"),
"rho_c",
title="Column density",
)
reduce → plot_field_slice is the canonical pattern for column
density / LOS imaging.
Time-axis reduction¶
reduce accepts any dimension on the underlying xarray dataset —
not just the spatial grid.surviving_axis_names. For time-series
stores written by to_zarr_timeseries, every field has time as
the leading dim, so a time-mean / time-integrate works directly:
ts = pypic.from_zarr("run.zarr") # fields shaped (nt, nx, ny, nz)
B_mean = pypic.reduce(ts, "time", reduction="mean")
Pure non-spatial reductions bypass the Cartesian-grid gate — the
Jacobian only matters when integrating over a spatial axis. A
mixed ("time", "r") reduction on a spherical grid still raises
GeometryUnsupportedError, pending Jacobian-aware integration.
Worked examples¶
Three first-line plasma diagnostics expressible in the current API.
EDR localization via the Zenitani localizer¶
The electron-frame dissipation \(D_e\) (Zenitani EDR localizer) peaks inside the electron diffusion region. Per-column z-position of the peak gives an EDR centroid map:
ds_with_De = ds.with_derived("D_e")
# argmax returns the *coordinate value* of the maximum along z,
# not an integer index — the result is one z-position per (x, y).
z_peak = pypic.reduce(ds_with_De, "z", reduction="argmax", fields=["D_e"])
# z_peak.field_info("D_e").quantity_type == "length"
For the value at the peak (rather than the location), swap to
reduction="max".
Density-weighted column temperature (synthetic LOS observable)¶
The line-of-sight temperature that an external observer would
measure is the emission/density-weighted average — the yt
"weight_field" convention:
T_col = pypic.reduce(ds, "z", reduction="integrate",
weight="rho_c", fields=["T_s0"])
# T_col["T_s0"] == ∫ T_s0 ρ_c dz / ∫ ρ_c dz
The provenance attr records the weight:
T_col.xr["T_s0"].attrs["reduction"] →
{"axis": "z", "op": "integrate", "weight": "rho_c"}.
Energy-budget conservation¶
For each timestep, integrate the energy densities over the full volume to get total magnetic, kinetic, and thermal energy:
totals_by_step: list[tuple[float, dict[str, float]]] = []
for step in sim.steps:
# compute() returns one array; with_derived() attaches several.
ds = sim.read(step).with_derived("e_B", "e_k", "e_th")
totals = pypic.reduce(ds, ("x", "y", "z"), reduction="integrate")
totals_by_step.append(
(step * sim.grid.dt, {
"E_B": float(totals["e_B"]),
"E_k": float(totals["e_k"]),
"E_th": float(totals["e_th"]),
})
)
# Plot E_B(t) + E_k(t) + E_th(t) — should be conserved in ideal MHD
# runs and slowly evolve in dissipative ones.
CLI¶
pypic reduce apply <sim-dir> --output out.zarr --axis z \
--reduction mean --weight rho_c --fields T_s0
Supports multi-step batch writes (--step all → time-series Zarr),
selection composition (--box, --plane), Icechunk versioning
(--backend icechunk --tag v1), and --dry-run for plan preview.
API reference¶
reductions
¶
Axis reductions: collapse a FieldDataset along one or more dimensions.
The reduce function collapses a FieldDataset
along one axis (axis="z") or several (axis=("y", "z")) using a
chosen reduction. Supported reductions: "integrate", "sum",
"mean", "median", "max", "min", "std", "var",
"argmax", "argmin". Column densities, line-of-sight integrals,
slab averages, projected-max diagnostics, and peak-position maps all
compose from this single primitive paired with an optional
BoxSelection or
SphereSelection.
There is deliberately no SlabSelection: selections describe regions,
not data, and a slab would fuse "pick a thick slice" with "reduce along
it". Kept separate, BoxSelection stays reusable outside reductions
and a viewer request is just {selection, axis, reduction}.
After an unweighted integrate the quantity_type and si_unit
strings are preserved unchanged, and FieldDataset.in_si corrects the
value via length_ref ** length_axes from attrs. The unit strings
themselves do not shift by the reduced length factors.
Reduction = Literal['integrate', 'sum', 'mean', 'median', 'max', 'min', 'std', 'var', 'argmax', 'argmin']
¶
reduce(data, axis, *, reduction='integrate', selection=None, fields=None, weight=None, nan_policy='omit')
¶
Reduce a FieldDataset along one or more axes.
Apply selection (if given) to crop or NaN-mask, then collapse the
axis dimension(s) using reduction. Returns a FieldDataset with
one fewer surviving axis per name passed — the natural input to
plot_field_slice, compute(...), in_si(...), and other
dataset-consuming APIs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
FieldDataset
|
Input dataset (1-, 2-, or 3-D — must contain every name in axis). |
required |
axis
|
str or tuple of str
|
Dimension name(s) to reduce away (e.g. |
required |
reduction
|
str
|
How to collapse the axis. Choices:
|
'integrate'
|
selection
|
BoxSelection | SphereSelection | None
|
Optional region selector applied before reduction. |
None
|
fields
|
Iterable[str] | None
|
Optional subset of field names (canonical or alias) to reduce.
|
None
|
weight
|
str | None
|
Name of a field to weight the reduction by. Only supported for
|
None
|
nan_policy
|
('omit', 'propagate', 'raise')
|
|
"omit"
|
Returns:
| Type | Description |
|---|---|
FieldDataset
|
Same metadata (normalization, species, frame, transforms) with
the named axis (or axes) removed from the grid and dimensions.
Each surviving DataArray gains an |
Raises:
| Type | Description |
|---|---|
GeometryUnsupportedError
|
Non-Cartesian geometry combined with a spatial-axis reduction.
Spherical / cylindrical Jacobian-aware integration is not yet
implemented. Pure non-spatial reductions (e.g. along |
ValueError
|
Unknown axis name, invalid reduction or nan_policy,
multi-axis input passed with |
KeyError
|
Any name in fields fails to resolve via
|
Notes
Unit shift after integrate. After unweighted
reduction="integrate" the SI unit dimension shifts by one
length factor along each reduced axis (e.g. number density
m\ :sup:-3 → column density m\ :sup:-2). Per-field
quantity_type and si_unit strings are preserved
unchanged — but the numeric value returned by in_si() is
correct: the length_axes provenance stamp here lets
FieldDataset.in_si apply an extra length_ref **
length_axes factor at the boundary. Weighted integrate
does not stamp length_axes because the length factor
cancels between numerator and denominator. The displayed
unit string and the openPMD 7-tuple do not yet shift with the
reduction.
Examples:
>>> import numpy as np
>>> from pypic.coordinates import CARTESIAN
>>> from pypic.dataset import FieldDataset
>>> from pypic.grid import GridInfo
>>> from pypic.reductions import reduce
>>> from pypic.units import Normalization
>>> grid = GridInfo(dimensions=(4, 3, 2), spacing=(1.0, 1.0, 1.0),
... origin=(0.0, 0.0, 0.0), geometry=CARTESIAN)
>>> ds = FieldDataset.from_arrays(
... {"B_1": np.ones((4, 3, 2))}, grid, Normalization.identity())
>>> column = reduce(ds, "z", reduction="integrate")
>>> column.grid.dimensions
(4, 3)
>>> column.grid.surviving_axis_names
('x', 'y')
>>> line = reduce(ds, ("y", "z"), reduction="mean")
>>> line.grid.dimensions
(4,)
Source code in src/pypic/reductions.py
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 | |