Skip to content

Fix grouped probability and durational exceedance statistics - #1

Merged
rafa-guedes merged 5 commits into
mainfrom
fix/grouped-probability-and-exceedance
Aug 12, 2026
Merged

Fix grouped probability and durational exceedance statistics#1
rafa-guedes merged 5 commits into
mainfrom
fix/grouped-probability-and-exceedance

Conversation

@rafa-guedes

Copy link
Copy Markdown
Contributor

Motivation

A monthly-grouped range_probability was needed for the NZ Parent hindcast stats pipeline (config/hindcast/prax/stats/stats_nzpar_cawthron.yml). Adding group: month to the config appeared to work but silently produced the wrong numbers, which turned up three further defects in the neighbouring exceedance code.

What's here

Three commits, split by kind of change. Each passes the suite on its own, so the history bisects cleanly.

1. range_probability honours group (feature)

group is a first-class field on CallConfig and the pipeline always forwards it, but range_probability absorbed it into **kwargs and ignored it. The call still succeeded, and pipeline._apply still appended the group to the output variable name — so a group: month config wrote the ungrouped all-record probability under a name like hs_gt_1p5_range_probability_month. Wrong result, convincing name, no error.

NaN semantics are preserved by taking the denominator from a grouped count() rather than a boolean mean(), so all-NaN land cells stay NaN instead of becoming a real 0.0. That matters here: in the target store 14% of the domain is NaN land while 126 wet cells legitimately hold exactly 0.0, and a boolean mean would merge the two.

The denominator depends only on the variable, so it is now computed once and shared across every range defined on it — a config with three ranges on hs builds one grouped count instead of three.

2. Durational exceedance is actually grouped (bug fix)

With both duration and group set, the run-length reduction happened inside apply_ufunc before group was consulted, and the ungrouped whole-record value was then expand_dims'd across the group keys. Every month received the identical annual number.

Run detection now returns a per-timestep mask computed on the continuous record, with grouping as a plain groupby().mean() afterwards. Detecting before grouping is deliberate: grouping first would both truncate an event at the period boundary (a 72h storm split across two months fails a 72h threshold in both and disappears) and splice non-adjacent periods, since a month grouper places every January end to end and would invent multi-year storms at each year boundary. There are regression tests for both.

The attribution rule — each qualifying sample counts towards the group it actually falls in — is now documented in the exceedance docstring, along with its consequences. It matches DHI's published persistence methodology for fraction-of-time metrics and is the only convention under which the group values recompose to the ungrouped result (verified exact to 0.00e+00 over 20 years of synthetic 3-hourly data at durations from 0h to 48h).

Two further defects fixed in the same machinery:

  • Runs touching either end of the record were dropped. Detection used scipy.signal.find_peaks with plateau_size, which only reports plateaus bounded by lower values on both sides — so a record wholly above the threshold returned 0.0 instead of 1.0. Checked against the old implementation directly: for a run at the start, old 0.000 vs new 0.667; only mid-record runs agreed. Now based on padded-diff run boundaries, which is exact at the edges and drops the scipy import from this module. Existing durational exceedance values may increase slightly.
  • The timestep assumed nanosecond time. float(np.diff(time)[0]) / 3.6e12 raises TypeError under NumPy 2 for any other datetime64 resolution, and would silently mis-scale if it did not. Now goes through pd.Timedelta.

3. Fractional thresholds named 1p5, not 1.5 (breaking)

{var}_{threshold:g} produced hs_1.5, and with the pipeline suffixes hs_1.5_exceedance_month — a dot makes the name unreachable by attribute access and awkward in path-like contexts.

BREAKING: configs with fractional thresholds will write new variable names. Integral thresholds are unaffected (:g already drops trailing zeros, so 2.0 has always produced hs_2), and no config in this repo changes behaviour — the Douglas climatology uses integer thresholds. Note that when appending to an existing Zarr store the old dotted variables are not removed automatically, so a re-run would leave both hs_1.5_exceedance and hs_1p5_exceedance in place.

Negative thresholds still emit a minus sign (hs_-1), which has the same identifier problem; that convention is left open deliberately.

Testing

  • Full suite: 207 passed (200 before this branch; 7 tests added).
  • Per commit: 201 → 206 → 207, each green in isolation.
  • Grouped range_probability verified against manual per-month computation, NaN-aware, numpy- and dask-backed paths identical.
  • End-to-end append into a Zarr store mirroring the production one: pre-existing variables byte-identical, new month dimension added, consolidated metadata picks up everything.

Notes for the reviewer

  • CHANGELOG.md gets an Unreleased section rather than a version bump — the changelog is already behind (__version__ is 2.5.0, top entry is 2.3.0), so the version call is yours.
  • The conventions in commit 2 were chosen after reviewing how xclim (resample_before_rl), icclim, CDO, climdex and ECA&D handle spells crossing a period boundary, and the metocean persistence literature (DNV-RP-H103 §8.5.1.4, DHI's persistence methodology). The short version: the correct convention follows from the metric — fraction-of-time metrics time-share, event-count metrics assign the whole event to its start period. exceedance returns a fraction of time. An event-based weather-window product would want the DNV rule and should be a separate stat, not an option on this one.

`group` is a first-class field on CallConfig and the pipeline always
forwards it, but range_probability absorbed it into **kwargs and ignored
it. The call still succeeded and the pipeline still appended the group to
the output variable name, so a `group: month` config produced the
ungrouped all-record probability under a name like
`hs_gt_1p5_range_probability_month` — a wrong result that looked right.

Probabilities are now computed within each group, following the _groupby
idiom in ops/aggregations.py, and the output gains the group dimension.
NaN semantics are preserved by taking the denominator from a grouped
count() rather than a boolean mean(), so all-NaN land cells stay NaN
instead of becoming 0.0.

The denominator depends only on the variable, so it is computed once and
shared by every range defined on that variable: a config with three
ranges on `hs` now builds one grouped count instead of three.
With both `duration` and `group` set, the run-length reduction happened
inside apply_ufunc before `group` was ever consulted, and the ungrouped
whole-record value was then expand_dims'd across the group keys. Every
month received the identical annual number.

Run detection now returns a per-timestep mask ("this sample belongs to a
qualifying run") computed on the continuous record, and grouping is a
plain groupby().mean() afterwards. This unifies the duration=0h and
duration>0 paths into one expression and gives each group its own value.

Detecting runs before grouping is deliberate. Grouping first would both
truncate an event at the period boundary — a 72h storm split across two
months fails a 72h threshold in both and disappears — and splice
non-adjacent periods, since a `month` grouper places every January end to
end and would invent multi-year storms at each year boundary. Qualifying
samples are instead attributed to the group they actually fall in, which
matches DHI's published persistence methodology for fraction-of-time
metrics and keeps the group values recomposing to the ungrouped result.

Two further defects in the same machinery:

- Run detection used scipy.signal.find_peaks with plateau_size, which
  only reports plateaus bounded by lower values on both sides, so a
  qualifying spell touching either end of the record was dropped
  entirely: a record wholly above the threshold returned 0.0 instead of
  1.0. Detection is now based on padded-diff run boundaries, which is
  exact at the edges and drops the scipy dependency from this module.
  Durational exceedance values may increase slightly as a result.

- The timestep was derived as float(np.diff(time)[0]) / 3.6e12, which
  assumes nanosecond datetime64 and raises TypeError under NumPy 2 for
  any other resolution. It now goes through pd.Timedelta.
Output variables were named `{var}_{threshold:g}`, so a fractional
threshold produced `hs_1.5` and, once the pipeline appended the stat and
group, `hs_1.5_exceedance_month`. A dot makes the name unusable as an
identifier — it cannot be reached by attribute access on the dataset —
and is awkward in any path-like or query context. The decimal point is
now written as `p` for "point": `hs_1p5_exceedance_month`.

BREAKING CHANGE: configs using fractional thresholds will write new
variable names. Integral thresholds are unaffected, since `:g` already
drops trailing zeros (`2.0` has always produced `hs_2`), so no config in
this repo changes behaviour. Note that when appending to an existing Zarr
store the old dotted variables are not removed automatically — a re-run
leaves both `hs_1.5_exceedance` and `hs_1p5_exceedance` in place.

Negative thresholds still emit a minus sign (`hs_-1`), which has the same
identifier problem; that convention is left to the maintainer to choose.
…bility stats

`set_variable_attributes` derived a variable's attributes solely from the
parent variable, so any statistic whose output units differ from those of its
parent was written with the wrong units:

- `hs_pcount` got `units: m`, but it is a percentage in [0, 100]. Both name
  lookups succeeded, so the attrs were replaced wholesale with the parent's and
  only `standard_name`/`long_name` were rewritten; units were never touched.
- `hs_gt_1p5_range_probability` got `units: m` plus the `standard_name` and
  `long_name` of a wave height, but it is a dimensionless fraction in [0, 1].
  The trailing token `probability` is not a known stat, so the lookup raised
  and the fallback left the attrs the DataArray had inherited from its parent
  through xarray arithmetic.

`attributes.yml` gains a `stat_units` section declaring the units of statistics
that do not preserve those of their parent, merged from the `metadata` config
like `coords`/`data_vars`/`stats`. It is resolved against every `_`-separated
token of the output variable name so suffixed names such as
`hs_gt_1p5_range_probability_month` resolve too, and applied on both the
successful-lookup and the fallback path; on the fallback path descriptive names
recognised as the parent's are dropped rather than left describing the wrong
quantity. `pcount` and `range_probability` also set correct attributes at the
source, the latter with a `long_name` spelling out the range bounds.

Metadata only; no numerical results change.
The evaluation point is a height above the seabed, `z_bed`, and with
`reference="surface"` it is derived as `depth - z`, so `z=15` in 3 m of water
asks for a point 12 m below the bed. The transfer function
`cosh(k z_bed)/sinh(k h)` is even in `z_bed`, so no error surfaced: the point
silently returned the value for its mirror image 12 m *above* the bed, which in
3 m of water is well clear of the free surface and therefore larger than the
true surface value. With Hs = 2 m and Tm02 = 9 s in 5 m of water it returned
2.04 m/s where the surface value is 1.46 m/s. The same applied to
`reference="bed"` with `z` greater than the depth.

Points with `z_bed < 0` or `z_bed > h` are now NaN. The test is applied
per element, so with varying bathymetry the mask follows the depth field rather
than dropping the whole array, and with a time-varying depth a cell may be valid
at some timesteps and not others. The inequalities are strict about the bounds
themselves: the seabed (`z_bed = 0`) and the still-water surface
(`z_bed = h`) are valid points and are kept.

No opt-out is provided; the unmasked value has no physical reading. Values
inside the water column are unchanged. `uorb_sfc_*` fields already computed at
a fixed depth below the surface are affected in water shallower than that depth
and should be regenerated.
@rafa-guedes
rafa-guedes merged commit 7ac8b2c into main Aug 12, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant