Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

pipekit-evaluate

Report 14 · Multidimensional evaluation, checked against what the stack already scores

Abstract

A single RMSE cannot tell a correctly shaped but displaced methane plume from a featureless field, cannot see that a forecast has lost its small scales, and cannot score whether the right plumes were detected. The scoping report names these failures, organises evaluation along three axes (the Unit scored, the Lens of critique and the lifecycle Stage) and proposes pipekit-evaluate to own every metric. The pinned code tells a different story. pipekit-evaluate 0.0.2 ships a benchmark ladder (levels, rungs, an oracle rule and a runner), and calls the Unit × Lens × Stage taxonomy “still planned”. Meanwhile xrtoolz.metrics already ships most of the matrix the scoping report marks as missing: spectral skill, SSIM, phase-shift error, instance matching, geostrophic-balance and budget residuals, and wrappers by lead time, region and frequency band. The capability index hid this, because it lists only four xrtoolz names. This report keeps the framework, places each missing metric in the library that already reserves its name, and gives pipekit-evaluate the taxonomy tags, the report artifact and the ladder. Every number below is from a real run.

Keywords:evaluationpipekit-evaluatexrtoolz.metricsdouble penaltyspectral skillbenchmark ladder

Summary

Decision. Metrics live with their carriers: xrtoolz.metrics for xarray fields, statistics, events and physical checks; geotoolz for GeoTensor detection building blocks; pipekit-train for differentiable losses. pipekit-evaluate owns what no carrier library can: the taxonomy as tags on scorers, a scorer adapter over any metric operator, the evaluation report, and the benchmark ladder it already ships. There is no pipekit-array.metrics.classification and no third catalog type.

End state. A user picks scorers by unit, lens and stage, wraps them by lead, region, scale, season, event or member, and gets one report that records which model, which data and which configuration it scored, and can be compared with the last one. The metrics come from the carrier libraries; pipekit-evaluate composes and records them, and its ladder scores estimators rung by rung against an oracle.

The framework, as the scoping report put it

This section restates the scoping report’s framework, which this page adopts unchanged except where a box says otherwise.

Three failure modes. A point-wise loss penalises a correctly shaped but displaced feature twice, once where it is missing and once where it is spurious (the double penalty). Aggregate scores are dominated by large, high-variance scales, so a forecast can lose its small scales at little cost (scale-blind aggregation). And agreement on gridded fields says nothing about how parcels move through them (Eulerian blindness).

Three axes. An evaluation is a choice on each of three axes (Table 1):

Table 1:The three axes. Units and lenses cross into the matrix of Section ??; stages say where a metric may run.

AxisValuesQuestion it answers
UnitField, Statistic, Trajectory, Event, Budgetwhat is scored
Lenspoint-wise, probabilistic, spectral, structural, detection, physical-constraintwhat kind of failure the score can see
Stagetraining, validation, final evaluation, monitoringhow cheap and differentiable the score must be

A metric’s signature follows its unit: a field metric maps (x^,x)↦R(\hat{x}, x) \mapsto \mathbb{R}; an event metric maps a predicted and a reference set of events to a contingency table; a trajectory metric maps a velocity field and drifters to dispersion statistics. Decomposition (by region, scale, lead time, season, event or member) is a wrapper that applies a metric on parts of the data and collects the results; it is not a lens.

Ownership rules

A metric lives with the carrier it reads; pipekit-evaluate composes metrics and records what they found (Figure 1).

Scripts and benchmarks call pipekit-evaluate, which ships Cell, ReferenceRule, Benchmark, Scorer and Oracle, and would add unit, lens and stage tags, MetricScorer and EvaluationReport. Below it are xrtoolz.metrics, geotoolz and geocatalog, which hold the metrics and matchups. pipekit-cycle, pipekit-train and pipekit-experiment are planned links.

Figure 1:Ownership of evaluation. The dashed chips are the only new surface in pipekit-evaluate; the metrics stay where they are.

pipekit-evaluate owns the taxonomy tags, the scorer adapter, the evaluation report and the benchmark ladder. It depends on pipekit and imports carrier libraries only through extras.

Neighbours follow the stack rules. For this page that means the metrics stay in xrtoolz (xarray carriers, with the decomposition wrappers) and geotoolz (GeoTensor detection blocks), neither of which imports pipekit-evaluate, and events become geocatalog rows (workstream E).

What the stack scores today

The scoping report’s matrix, filled with the names that ship at the pinned revisions (Table 2), and the decomposition wrappers beside it (Table 3). Every cell not listed below is empty.

Table 2:The Lens × Unit matrix at the pinned revisions, one row per non-empty cell. All xrtoolz.metrics operators take (prediction, reference) datasets; “stub” is a reserved name that raises.

LensUnitShipped
Point-wiseFieldRMSE, MSE, MAE, Bias, NRMSE, Correlation, R2Score, MaskedMetric, DieboldMariano
Point-wiseStatisticBinnedResiduals2D, RegionScores
ProbabilisticFieldCRPS, SpreadSkillRatio, RankHistogram, EnsembleCoverage, ReliabilityCurve
ProbabilisticStatisticEnergyDistance, Wasserstein1
SpectralFieldscores: PSDScore, psd_score, resolved_scale, WaveletPSDScore, FrequencyBandSkill, BandLimitedRMSE; tools in xrtoolz.transforms: power_spectrum, isotropic_power_spectrum, fit_spectral_slope, KESpectralFlux
StructuralFieldSSIM, GradientDifference, PhaseShiftError
StructuralEventMaskIoU, CentroidDisplacement (pairs by label id); IntersectionOverUnion stub
DetectionEventmatch_instances, InstanceF1AtIoU, AveragePrecisionMatched; stubs: ProbabilityOfDetection, FalseAlarmRatio, CriticalSuccessIndex, DurationError, IntensityBias
Physical-constraintFieldGeostrophicBalanceError, GeostrophicImbalance, DivergenceError, DensityInversionFraction
Physical-constraintTrajectoryPVConservationError (consumes trajectories nothing produces)
Physical-constraintBudgetxrtoolz.budgets: BudgetResidual, HeatBudgetResidual, SaltBudgetResidual, VolumeBudgetResidual, KineticEnergyBudgetResidual

Table 3:Decomposition wrappers at the pinned revisions.

WrapperShipped asState
By lead timexrtoolz.metrics.SkillByLeadTime(metric, lead_dim="lead_time")shipped (Report 13)
By regionEvaluateByRegion(metric, *, regions); masks from xrtoolz.geo (REGIONS, RegionSpec, AddLandMask, eddy_regions)shipped
By scaleFrequencyBandSkill(variable, dims, bands, metric=RMSE), evaluate_by_frequency_band; geo.BandpassWavelength as a pre-filtershipped
By season or monthonly viz.validation.seasonal_groupby, for plotsmissing as a metric wrapper
By eventmissing
By memberinside each probabilistic metric’s ensemble_dimmissing as a wrapper

The scoping report’s gap analysis, claim by claim, at the pinned revisions:

The running example: one methane plume, three ways to get it wrong

The stages score the stack’s recurring object, a methane plume, against its truth, using the shipped operators wherever they exist and marking hand-written code as user. The data are synthetic so that every score has a known right answer.

Stage 1 · The double penalty (Field × point-wise, structural, spectral)

TL;DR. Score a correctly shaped plume that is displaced, and a flat field that has no plume at all, and see which scores prefer which.

Problem. The truth x(s)x(s) is a Gaussian plume of peak 50 ppb and width σ=3\sigma = 3 cells on a periodic transect of N=40N = 40 cells (and a 64×6464 \times 64 grid in 2-D). Prediction A is the same plume shifted by d=6d = 6 cells; prediction B is the flat field at the truth’s mean. For a shift dd, the point-wise error of A is

RMSE(A)2=2⟨x2⟩(1−e−d2/4σ2),\mathrm{RMSE}(A)^2 = 2 \langle x^2 \rangle \left( 1 - e^{-d^2 / 4 \sigma^2} \right) ,

which overtakes the flat field’s RMSE once d≥5d \ge 5. A score that sees the plume should prefer A.

1
2
3
4
5
6
7
8
9
10
11
12
13
# Shapes: 1-D N = 40 cells; 2-D 64 × 64; A = plume shifted 6 cells, B = flat mean
from xrtoolz.metrics import RMSE, SSIM, PhaseShiftError, psd_score

RMSE("ch4", dims="x")(A, truth)            # → 20.50   (B: 15.62)            today
PhaseShiftError("ch4", "x", periodic=True)(A, truth)
# → shift_x = 6, residual_rmse = 0.0       (B: shift 0, residual 15.62)     today
psd_score(A, truth, psd_dims=["x"])        # mean over k > 0: −1.105 (B: 0.0) today
fss(A, truth, threshold=10, window=9)      # → 0.625   (B: 0.0)              user
# relative spectrum difference Σ|P_A − P_x| / Σ P_x over k > 0
spectrum_gap(A, truth)                     # → 0.000   (B: 1.000)            user
# 2-D 64 × 64:
SSIM("ch4", ["y", "x"])(A2, truth2)        # → 0.885   (B: 0.336)            today
RMSE("ch4", dims=["y", "x"])(A2, truth2)   # → 4.671   (B: 4.096)            today

Point-wise, structural and spectral scores on a displaced plume and a flat field, from a real run.

Table 4:Which scores prefer the displaced plume A over the flat field B.

ScoreSourceABPrefers
RMSE (1-D)xrtoolz.metrics.RMSE20.5015.62B
RMSE (2-D)xrtoolz.metrics.RMSE4.6714.096B
psd_score, mean over k>0k > 0xrtoolz.metrics.psd_score−1.1050.000B
SSIM (2-D)xrtoolz.metrics.SSIM0.8850.336A
Phase-shift residualxrtoolz.metrics.PhaseShiftError0.0 (shift 6)15.62A
FSS, threshold 10, window 9user0.6250.000A
Relative spectrum differenceuser, over power_spectrum0.0001.000A

The double penalty is real and the stack can already see past it (Table 4): SSIM and PhaseShiftError both prefer the plume, and PhaseShiftError even reports the 6-cell displacement. Two lenses need code. FSS is not shipped (B1). And the shipped spectral score is not the lens the scoping report meant: psd_score compares the spectrum of the error with the spectrum of the truth, 1−Px^−x/Px1 - P_{\hat{x} - x} / P_x, so it is phase-sensitive and suffers the double penalty too. A comparison of the two spectra, which ignores phase, prefers A perfectly; it is a few lines over the shipped power_spectrum and belongs beside psd_score (B1).

At the shift the scoping report’s examples suggest, d=4d = 4, RMSE still prefers the plume (15.44 against 15.62): the flip needs d≥5d \ge 5 for this width, as (1) predicts.

Stage 2 · Scale-blind aggregation (Statistic × spectral, by scale)

TL;DR. Blur a methane enhancement map and see how much the RMSE notices, then look at the spectrum and at the error scale by scale.

Problem. The truth is a 128×128128 \times 128 field with an energy spectrum E(k)∝k−3E(k) \propto k^{-3}, normalised to unit standard deviation. The prediction is the truth blurred by a Gaussian of σ=3\sigma = 3 pixels. A blur multiplies the spectrum by e−(2πkσ)2e^{-(2 \pi k \sigma)^2}, so the scale at which the error’s power reaches half the truth’s, the resolved scale, is known analytically: 12.03 pixels.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Shapes: 128 × 128 pixels; k in cycles per pixel
from xrtoolz.metrics import FrequencyBandSkill, RMSE, SSIM, psd_score, resolved_scale
from xrtoolz.transforms import fit_spectral_slope, isotropic_power_spectrum

RMSE("z", dims=["y", "x"])(blur, truth)     # → 0.098 of the truth's std     today
SSIM("z", ["y", "x"])(blur, truth)          # → 0.885                         today
# slope of E(k) over k ∈ [0.05, 0.15] cycles / px
fit_spectral_slope(isotropic_power_spectrum(truth.z, ["y", "x"]),
                   k_min=0.05, k_max=0.15)  # → −3.10  (blur: −9.39)          today
score = psd_score(blur.z, truth.z, psd_dims=["y", "x"], isotropic=True)
resolved_scale(score, "freq_r", level=0.5)  # frequency coordinate, 50 % level
# → 11.98 px  (analytic 12.03 px)                                            today
bands = {"large": (0, 0.05), "meso": (0.05, 0.125), "small": (0.125, 0.5)}
FrequencyBandSkill("z", ["y", "x"], bands)(blur, truth)
# → RMSE 0.053, 0.067, 0.049; divided by each band's truth std: 0.05, 0.60, 0.99

The blur scored point-wise, spectrally and by frequency band, from a real run.

The RMSE is under 10 % of the signal and SSIM is 0.885, yet the prediction has lost almost all of its variance below 8 pixels and its spectral slope is −9.4 where the truth’s is −3.1. The shipped tools see this: resolved_scale recovers the analytic 12-pixel scale to 0.4 %, and FrequencyBandSkill shows the small-scale error at 99 % of that band’s signal. The scoping report’s spectral lens and “by scale” wrapper exist; the one missing piece is a score that compares the two slopes directly (B1).

Stage 3 · Did it find the plumes? (Event × detection)

TL;DR. Detect plumes in a predicted and a reference map, match them, and count hits, misses and false alarms.

Problem. The reference map has two plumes. The prediction has the first one shifted by 3 pixels, misses the second, and adds one false alarm. With aa hits, bb false alarms and cc misses, the detection scores are

POD=aa+c,FAR=ba+b,CSI=aa+b+c.\begin{aligned} \mathrm{POD} &= \frac{a}{a + c} , & \mathrm{FAR} &= \frac{b}{a + b} , & \mathrm{CSI} &= \frac{a}{a + b + c} . \end{aligned}
1
2
3
4
5
6
7
8
9
10
11
12
13
# Shapes: two label images; masks (n_objects, H, W)
from xrtoolz.metrics import instance_f1_at_iou, mask_iou_matrix, match_instances
labels_ref, labels_pred = label(ref > 0.3), label(pred > 0.3)    # scipy   user
mask_iou_matrix(pred_masks, ref_masks)       # → [[0.438, 0], [0, 0]]       today
match_instances(pred_masks, ref_masks, iou_threshold=0.3)
# → tp = 1, fp = 1, fn = 1                                                  today
instance_f1_at_iou(pred_masks, ref_masks, iou_threshold=0.3)  # → F1 = 0.5   today
# POD = a / (a + c) = 0.5;  FAR = b / (a + b) = 0.5               user
# CSI = a / (a + b + c) = 0.333                                  user
CriticalSuccessIndex()
# → NotImplementedError: … a name reservation; implementation lands with V5
centroid_displacement(pred_objects, ref_objects)
# → pairs object 2 with object 2: 30.6 px between unrelated plumes

Event detection and matching with what ships, from a real run.

Matching works: match_instances counts the hit, the miss and the false alarm correctly, and the scores in (2) follow from its counts in one line each. They are not shipped, although their names are reserved in xrtoolz.metrics.object (B2). CentroidDisplacement pairs objects by label id rather than by matching, so it compares unrelated plumes when the labels are not aligned; it should take match_instances’ pairing (B2). Detection itself is assembled from geotoolz (segment.Threshold, measure.LabelConnectedComponents, plume.PlumeMask) and xrtoolz (remove_small_objects_2d); what does not exist is an event as an object with an id, a time span and attributes (E).

Stage 4 · The benchmark ladder (pipekit-evaluate)

TL;DR. Score three estimators of the same linear-Gaussian retrieval with the ladder pipekit-evaluate ships: an exact oracle, a prior-only baseline and an over-confident shortcut.

Problem. The state x∈R40x \in \mathbb{R}^{40} is drawn from N(0,B)\mathcal{N}(0, B) with a squared-exponential covariance of length 3 cells; every fourth cell is observed, y=Hx+εy = H x + \varepsilon with ε∼N(0,0.32I)\varepsilon \sim \mathcal{N}(0, 0.3^2 I). The oracle is the exact posterior mean m=BH⊤(HBH⊤+R)−1ym = B H^\top (H B H^\top + R)^{-1} y and covariance. The ladder places each estimator in a cell (level, rung, complexity) and scores it against the reference its rung calls for: the known truth for model-based inference, the oracle’s posterior for an amortized predictor.

1
2
3
4
5
6
7
8
9
10
11
12
13
# Shapes: x (40,); y (10,)
import pipekit_evaluate as pev

report = pev.Benchmark(
    task=LGTask(),                                   # 40 cells, every 4th  user
    estimators=[ExactOI(), ZeroPrior(), WrongRidge()],                    # user
    scorers=[RMSE(), GaussCoverage()],               # pev.Scorer protocol  user
    level="L4",
).run()                                                                 # today
# L4/r2/c1  model_based_inference  ref=known_theta       rmse 0.406  cov90 0.825
# L4/r2/c0  model_based_inference  ref=known_theta       rmse 0.569  cov90 0.975
# L4/r5/c0  amortized_predictor    ref=oracle_posterior  rmse 0.597
report.to_dict()           # {cell label: {metric: value}}; drops reference and notes

A benchmark on the shipped ladder, from a real run. The task, estimators and scorers are user code.

The ladder runs and resolves references as its design says: the exact oracle beats the prior-only baseline against the truth, and the over-confident shortcut is scored against the oracle’s posterior, not the truth. It took about 80 lines of glue, and the run exposed six frictions, all in Section ??:

Report 17 runs the same ladder on rungs 2 to 5 of one retrieval, including the emulator rungs 3 and 4 this stage leaves empty.

Stage 5 · What cannot be scored yet: trajectories

TL;DR. The Lagrangian unit has no producer in the stack.

Problem. For a velocity field u(s,t)u(s, t), a particle path solves s˙=u(s,t)\dot{s} = u(s, t); dispersion is the mean squared separation of initially close pairs, and the finite-time Lyapunov exponent is the growth rate of that separation. Scoring a forecast’s transport skill needs particles advected through both the forecast and the reference velocity.

Nothing in the pinned stack advects a particle. xrtoolz.metrics.lagrangian is an empty module reserved for “V3”; PVConservationError takes trajectories as input but nothing produces them; and viz.validation.EulerianLagrangianPanel plots them. Workstream D adds the producer in xrtoolz and the scores beside the reserved module. No run here.

The scoping report’s names, checked

Table 5 checks the stack names the scoping report’s examples use.

Table 5:The stack names the scoping report’s examples use, at the pinned revisions.

Name in the reportAt the pinned revisions
pe.Pipeline([...]).run(predictions, reference), run_on_matcheddo not exist; pipekit_evaluate.Benchmark(task, estimators, scorers, ...).run()
pe.metrics.pointwise.RMSE(), pe.metrics.probabilistic.CRPS(ensemble_dim=...)in xrtoolz: xrtoolz.metrics.RMSE(variable, dims), CRPS(variable, *, ensemble_dim, dims)
pe.ByLeadTime, pe.ByRegion(regions=..., metric=...)in xrtoolz: SkillByLeadTime(metric, lead_dim), EvaluateByRegion(metric, *, regions)
pe.metrics.spectral.PSDCompare(expected_slope=-3.0, tolerance=0.5)does not exist; fit_spectral_slope and psd_score do (stage 2)
pe.metrics.physical.MassClosure(tolerance=0.01)does not exist; xrtoolz.budgets.BudgetResidual and the per-quantity residuals do
pe.ByEvent, pe.events.MarineHeatwaveDetector, pe.metrics.detection.CSI()do not exist; CriticalSuccessIndex is a reserved stub in xrtoolz.metrics.object
pe.adapters.train.MetricAsLoss, EvaluationCallback, pe.MetricObserverdo not exist
registry.attach_evaluation(model_hash=..., report=...)does not exist (Report 12)
sc.DuckDBStateCatalog, state_cat.queries.forecast_chain, latest_analysisnot built; Report 13 proposes geocatalog.states.forecast_chain and latest_analysis
gc.queries.matchup(primary, secondary, time_tolerance, space_tolerance_km)geocatalog.matchup.matchup(primary, secondary, *, spatial, temporal, ...) with strategies such as NearestInTime(dt)
pipekit_evaluate.presets.weatherbench(), oceanbench()do not exist; the four pilots are design docs
pe as the import aliasthese reports use pev for pipekit-evaluate and px for pipekit-experiment

Current-state audit

Of fourteen pieces checked, eight ship and work, three ship with defects or gaps, and three are missing (Table 6); Figure 2 counts the page’s calls by marker. The audit read the pinned revisions on 13 October 2026.

A grid with one row per stage of the running example and per workstream, and one square per stack call in that row's sketches, coloured by its marker. Of 27 calls, 13 run today, 6 are proposed by this report's workstreams and 8 are code the reader writes.

Figure 2:What this page’s sketches call: of 27 stack calls, 13 run today, 6 are proposed by this report’s workstreams and 8 are code the reader writes (how to read it).

Table 6:Evaluation in the stack, piece by piece.

SymbolsDefined inUsed byVerdict
Cell, RUNG_NAMES, LEVEL_NAMESpipekit_evaluate/benchmark/cube.pythe ladderHealthy. Levels L0 to L4, six rungs
ReferenceRule, ReferenceSource, Axisbenchmark/reference.pyBenchmarkSplit ownership. One source per cell; PREVIOUS_RUNG unused
Prior, Estimate, Task, Estimator, Oracle, Scorerbenchmark/protocols.pystage 4Split ownership. No carriers ship; Oracle is “not yet consumed”; Estimate has no covariance
Benchmark, BenchmarkReport, CellResultbenchmark/run.pystage 4Split ownership. to_dict only; docstring and code disagree on the oracle
Unit × Lens × Stage taxonomypipekit_evaluate/__init__.py:3, READMEMissing. “still planned”
point-wise and probabilistic field metricsxrtoolz.metricsReport 13 stage 4Healthy. NumPy, xskillscore
spectral scores and toolsxrtoolz.metrics.spectral, xrtoolz.transformsstage 2Healthy. resolved_scale within 0.4 % of the analytic value
structural field metricsxrtoolz.metrics (SSIM, PhaseShiftError)stage 1Healthy. FSS missing
instance matchingxrtoolz.metrics (match_instances, InstanceF1AtIoU)stage 3Healthy.
event detection scoresxrtoolz.metrics.objectMissing. Reserved stubs
physical-constraint and budget residualsxrtoolz.metrics.physical, xrtoolz.budgetsHealthy. Not run here; residuals, not prediction-against-reference scores
decomposition wrappersxrtoolz.metrics (SkillByLeadTime, EvaluateByRegion, FrequencyBandSkill)stage 2, Report 13Healthy. Season, event, member missing
trajectoriesxrtoolz.metrics.lagrangianMissing. Empty module
the capability indexgeostack scripts/capabilities.pyevery scoping reportHealthy as code, misleading in scope: four xrtoolz names

Smaller findings:

Workstream A · The taxonomy and the report in pipekit-evaluate

Decision. pipekit-evaluate’s Scorer carries the taxonomy as data, any metric operator becomes a scorer through one adapter, and the ladder’s report becomes the scoping report’s EvaluationReport (Table 7).

Table 7:Workstream A’s moves.

SymbolDestinationReason
unit, lens, stages, differentiable attributesScorer protocol (optional, with defaults)the scoping report’s Metric protocol, as metadata on the seam that already exists
MetricScorer(metric, *, unit, lens, stages, reference="truth")pipekit_evaluate.scorerswraps any (prediction, reference) → Dataset operator (every xrtoolz.metrics operator) as a Scorer
EvaluationReport with ReportEntry(metric, lens_path, value, unit, stage)pipekit_evaluate.reportBenchmarkReport plus model_ref, dataset_ref, config, timestamp; content_hash, to_pandas, filter, diff
several references per cell; covariance on Estimate; complexity-axis wording; oracle docstringbenchmark/stage 4’s frictions
1
2
3
4
5
6
7
8
9
10
11
12
# Shapes: as stage 1; xm is xrtoolz.metrics
scorers = [
    pev.MetricScorer(xm.RMSE("ch4", dims="x"), unit="field", lens="pointwise",
                     stages={"validation", "final"}),          # proposed (A2)
    pev.MetricScorer(xm.SSIM("ch4", ["y", "x"]), unit="field", lens="structural",
                     stages={"final"}),                         # proposed (A2)
    pev.MetricScorer(xm.FSS("ch4", threshold=10, window=9), unit="field",
                     lens="structural", stages={"final"}),      # proposed (A2, B1)
]
report = pev.evaluate(prediction, truth, scorers, model_ref=h)  # proposed (A3)
report.filter(lens="structural").to_pandas()                   # proposed (A3)
report.diff(last_week)                  # per-entry changes      proposed (A3)

Stage 1’s scores as tagged scorers in one report, after workstream A.

Workstream B · The missing metrics

Decision. Each missing metric goes into the xrtoolz module that already holds its lens, under the name it reserves where one is reserved (Table 8).

Table 8:Workstream B’s moves.

SymbolDestinationReason
FSS(variable, *, threshold, window)xrtoolz.metrics (structural)stage 1; the scoping report’s main structural score
SpectrumDistance, SpectralSlopeError(k_min, k_max)xrtoolz.metrics.spectralstage 1 and 2: compares two spectra, ignoring phase
ProbabilityOfDetection, FalseAlarmRatio, CriticalSuccessIndex, ContingencyTablexrtoolz.metrics.object (the V5 stubs)stage 3; built on match_instances
CentroidDisplacement on matched pairsxrtoolz.metrics.objectstage 3: pairs by label id today
NSE, IndexOfAgreement, BrierScore, EnergyScorexrtoolz.metricsmissing point-wise and probabilistic cells
HausdorffDistance, StructureFunctionxrtoolz.metricsmissing structural and spectral cells

Workstream C · The missing wrappers

Decision. Three more wrappers join SkillByLeadTime, EvaluateByRegion and FrequencyBandSkill, with the same shape: a metric in, a dataset with one more dimension out (Table 9).

Table 9:Workstream C’s moves.

SymbolDestinationReason
EvaluateByGroup(metric, *, by="time.season")xrtoolz.metricsby season or month; only a plotting groupby exists
EvaluateByEvent(metric, *, events)xrtoolz.metricsrestricts a metric to event masks or matched pairs
EvaluateByMember(metric, *, ensemble_dim)xrtoolz.metricsper-member scores for point-wise metrics (Report 13’s C2 broadcasting is the same need)

Workstream D · The trajectory unit

Decision. Particle advection is a transform over velocity fields and lives in xrtoolz; trajectory scores live in the module xrtoolz reserves for them (Table 10).

Table 10:Workstream D’s moves.

SymbolDestinationReason
AdvectParticles, PairDispersion, FTLExrtoolz.lagrangian (new), with an optional diffrax backendnothing advects particles today; the scoping report’s §3.1
TrajectoryRMSE, SeparationDistance, DispersionCRPSxrtoolz.metrics.lagrangian (reserved “V3”)the trajectory column of the matrix; PVConservationError gains a producer

Workstream E · The event unit

Decision. Events are detected with the stack’s segmentation tools and stored as catalog rows with an event profile, not in a third catalog type (Table 11).

Table 11:Workstream E’s moves.

SymbolDestinationReason
ThresholdEventDetector, MarineHeatwaveDetector (percentile, minimum duration)xrtoolz.events (new) over geotoolz.segment and xrtoolz.geo.pot_thresholdstage 3’s labels are scipy calls; pot_exceedances has no ids or durations
event rows: event_id, kind, start, end, footprint, attributes, source_uria geocatalog event profile (geocatalog.events), like Report 13’s state profilethe scoping report’s EventCatalog, without a parallel index; matching through geocatalog.matchup (IouAtLeast, NearestInTime)

Workstream F · Integration

Decision. Evaluation connects to training, cycles and the registry through the seams those packages already have or that their own reports propose (Table 12).

Table 12:Workstream F’s moves.

SymbolDestinationReason
a scorer with differentiable=True as a pipekit-train Losspipekit-evaluate adapter over pipekit_train.lossthe scoping report’s MetricAsLoss; refuses non-differentiable scorers, which today means every xrtoolz metric
per-step scoring in cyclesthe on_step(k, carriers, state) hook of Report 13’s open question 3the scoping report’s MetricObserver; a step inside a Sequential is rejected by cycles today (Report 10, workstream A)
a report linked to its modelregistry metadata (Report 12, workstream C)the scoping report’s attach_evaluation
presets.plume(), presets.ocean(), presets.land_extremes(), presets.fusion()pipekit-evaluatethe four pilots’ scorer lists, in place of weatherbench() and oceanbench()

Testing gates

Each workstream merges only when its gates pass; the existing gates are checks the code passes today (Table 13).

Table 13:Gates for the proposals and for what ships.

GateWorkstreamPasses when
A1 TagsA1every scorer in stage 4 and A2’s example reports its unit, lens and stages; an untagged scorer defaults to unit “field”, lens “pointwise”
A2 Adapter parityA2MetricScorer(xm.RMSE(...)) returns stage 1’s 20.50 and 15.62 to 1e-12
A3 ReportA3content_hash is equal for two runs of stage 1 and differs when one value changes; filter(lens="structural") returns exactly the structural entries; diff against itself is empty
A4 LadderA4stage 4’s rung 5 gets both oracle and truth scores in one run, and to_dict keeps the reference source
B1 FSS and spectraB1FSS equals stage 1’s 0.625 (1-D, window 9) and 0.566 (2-D); SpectrumDistance is 0 for any circular shift and 1.0 for the flat field
B2 DetectionB2on stage 3, POD = 0.5, FAR = 0.5, CSI = 1/3 exactly, and CentroidDisplacement reports 3 px for the matched pair only
B3 ScoresB3NSE of a perfect prediction is 1 and of the reference mean is 0; Brier of a perfect probability forecast is 0
B4 Shape scoresB4Hausdorff distance of a plume and its 6-cell shift is 6 cells
C1 to C3 WrappersCeach wrapper’s per-group values equal the metric applied by hand to each group, to 1e-12
D1 AdvectionD1particles in a uniform flow uu move utu t to 1e-6; pair dispersion in a linear strain grows as e2λte^{2 \lambda t} to 1 %
D2 Trajectory scoresD2TrajectoryRMSE of identical paths is 0
E1 DetectorE1a synthetic heatwave of known start, end and peak is detected with those values
E2 Event rowsE2stage 3’s events round-trip through GeoParquet, and matchup(IouAtLeast(0.3)) reproduces match_instances’ pairing
F1 to F4 IntegrationFa differentiable scorer trains Report 11’s emulator to the same weights as MSE to 1e-6; a preset runs on its pilot’s toy task
S1 Resolved scale (existing)—stage 2’s resolved_scale within 1 % of the analytic 12.03 px. Passes today: 11.98
S2 Phase shift (existing)—stage 1’s PhaseShiftError returns the true shift with zero residual. Passes today
S3 Matching (existing)—stage 3’s match_instances returns tp = fp = fn = 1. Passes today

Sequencing and risks

The taxonomy and report come first, because they need no new metrics and make every later metric land in a structured place; metrics fill the matrix next, then the two new units, then the integrations (Figure 3).

Phase 1: A1 to A4, the tags, MetricScorer, EvaluationReport and ladder fixes. Phase 2: B1 to B4 metrics and C1 to C3 wrappers side by side. Phase 3: D1 and D2 trajectories and E1 and E2 events side by side. Phase 4: F1 to F4 integration. A gate after each phase.

Figure 3:The taxonomy and report first, then the metrics, then the new units, then the integrations.

Open questions

  1. Does the taxonomy belong on the metric or on the scorer? This page puts it on the scorer (A1). If xrtoolz operators carried it too, a catalogue of the whole matrix could be generated from the code.

  2. One report type or two? BenchmarkReport (rung by rung) and the scoping report’s EvaluationReport (lens by lens) could be one class with two views, or two classes.

  3. Should events live in geocatalog? The event profile follows Report 13’s decision for states. Events also carry lineage to the field they were detected in; is a source_uri column enough?

  4. Where do differentiable metrics come from? JAX versions of xrtoolz kernels, a pipekit-jax metric set, or pipekit-train losses only?

  5. What should the capability index cover? Indexing every xrtoolz subpackage adds hundreds of names. All of them, or the public namespaces only (metrics, transforms, geo, ocn, budgets)?

Upcoming work

Table 14 lists the follow-ups.

Table 14:Follow-ups from checking the scoping report against the code.

Follow-upWhereWhy
Index xrtoolz’s and geocatalog’s subpackagesgeostack scripts/capabilities.py (UPSTREAM), then make capabilitiesthe index lists four xrtoolz names, so scoping from it misses most of this matrix
Fix the oracle docstring and the step-0 notepipekit-evaluate benchmark/run.py:77, :205stage 4
Give BenchmarkReport.to_dict the reference, note and axispipekit-evaluatestage 4
Document psd_score as an error-spectrum scorexrtoolz.metrics.spectralstage 1: it is phase-sensitive, unlike a spectrum comparison
Remove or implement the xskill and proper extraspipekit-evaluate pyproject.tomlno code uses them

References

Sources