Fix grouped probability and durational exceedance statistics - #1
Merged
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
A monthly-grouped
range_probabilitywas needed for the NZ Parent hindcast stats pipeline (config/hindcast/prax/stats/stats_nzpar_cawthron.yml). Addinggroup: monthto the config appeared to work but silently produced the wrong numbers, which turned up three further defects in the neighbouringexceedancecode.What's here
Three commits, split by kind of change. Each passes the suite on its own, so the history bisects cleanly.
1.
range_probabilityhonoursgroup(feature)groupis a first-class field onCallConfigand the pipeline always forwards it, butrange_probabilityabsorbed it into**kwargsand ignored it. The call still succeeded, andpipeline._applystill appended the group to the output variable name — so agroup: monthconfig wrote the ungrouped all-record probability under a name likehs_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 booleanmean(), so all-NaN land cells stay NaN instead of becoming a real0.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
hsbuilds one grouped count instead of three.2. Durational exceedance is actually grouped (bug fix)
With both
durationandgroupset, the run-length reduction happened insideapply_ufuncbeforegroupwas consulted, and the ungrouped whole-record value was thenexpand_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 amonthgrouper 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
exceedancedocstring, 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:
scipy.signal.find_peakswithplateau_size, which only reports plateaus bounded by lower values on both sides — so a record wholly above the threshold returned0.0instead of1.0. Checked against the old implementation directly: for a run at the start, old0.000vs new0.667; only mid-record runs agreed. Now based on padded-diff run boundaries, which is exact at the edges and drops thescipyimport from this module. Existing durational exceedance values may increase slightly.float(np.diff(time)[0]) / 3.6e12raisesTypeErrorunder NumPy 2 for any otherdatetime64resolution, and would silently mis-scale if it did not. Now goes throughpd.Timedelta.3. Fractional thresholds named
1p5, not1.5(breaking){var}_{threshold:g}producedhs_1.5, and with the pipeline suffixeshs_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 (
:galready drops trailing zeros, so2.0has always producedhs_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 bothhs_1.5_exceedanceandhs_1p5_exceedancein place.Negative thresholds still emit a minus sign (
hs_-1), which has the same identifier problem; that convention is left open deliberately.Testing
range_probabilityverified against manual per-month computation, NaN-aware, numpy- and dask-backed paths identical.monthdimension added, consolidated metadata picks up everything.Notes for the reviewer
CHANGELOG.mdgets anUnreleasedsection 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.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.exceedancereturns 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.