Bathing-water exceedance risk — a shared regional anomaly coupling many sites

Methodology card. This is the primary human- and agent-legible description of the model. The runnable stub beside it (stub.go) is the type-checked generative demonstration; this card carries the structure, assumptions, and validity regime that the Go code does not spell out.

System

Pollution-exceedance risk at England’s ~451 designated bathing waters. For each site the statutory question is whether the next routine in-season sample’s E. coli concentration will exceed the classification threshold. Each site’s latent log-concentration has a baseline, a day-of-year seasonal term, and — the mechanism that distinguishes this project — a shared regional “wet-week” anomaly that lifts the whole coastline’s risk together when the weather turns wet. The quantity of interest is the exceedance probability, and how coherently it moves across sites.

The generative core is a shared driver coupled to many sites:

Partition Iteration State Role
anomaly continuous.OrnsteinUhlenbeckIteration [z] Shared regional wet-week anomaly, mean-reverting with temporal persistence
site_0…N BathingConcentrationIteration [mu, p_exceed] Per-site latent log-concentration → exceedance probability, all reading the same z

Anomaly. One Ornstein–Uhlenbeck process z(t) mean-reverts to zero at speed theta with volatility sigma. It carries temporal state — a wet week persists across days — and is read same-step by every site.

Concentration. Each site’s latent log-concentration is μ = baseline + A·sin(2π·t/period + φ) + λ·z(t), and its exceedance probability is Φ((μ − log_threshold) / σ), where σ is the within-site log-normal sample scale (the individual-sample variability, integrated out analytically inside Φ, exactly as in the downstream censored-log-normal likelihood). The loading λ is how strongly the shared anomaly moves this site — set λ high for the whole coast and sites move coherently; set it to zero and a site ignores the region entirely. The step is deterministic given z and t; all cross-site-correlated randomness enters through the shared anomaly.

Partition wiring

The partition dependency graph, derived statically from the stub’s BuildStub wiring by pkg/graph. Solid arrows are within-step params_from_upstream wiring (which imposes a computation order); dashed arrows leaving a shaded past-copy node are lag reads of a partition’s committed state from an earlier step — drawn as separate source nodes so the graph stays a DAG.

flowchart TB
  n0["anomaly"]
  n1["site_0"]
  n2["site_1"]
  n3["site_2"]
  n0 -->|anomaly| n1
  n0 -->|anomaly| n2
  n0 -->|anomaly| n3

Ingests (in the stub: nothing)

The stub is data-free — every input is a literal constant in stub.go, with the anomaly volatility exposed as the one swept driver. In the downstream application the per-site baselines, seasonal coefficients, rainfall slope and sample scale are fitted from Environment Agency bathing-water sample records (heavily left-censored at the reporting limit) by censored maximum likelihood, partially pooled across sites by empirical Bayes; the shared anomaly is inferred from partly-censored observations by a sequential-Monte-Carlo particle filter. The model’s real-world ingests there are EA sample series and antecedent rainfall.

Assumptions

Validity regime

Failure modes

Question answered

Given a regional weather anomaly and a set of bathing sites with their own baselines, seasons and coupling strengths, what exceedance probability does each site carry — how coherently do the sites move together — and how does that risk respond to weather variability, to pollution-reduction and threshold levers, and to the strength of the shared regional signal?

Generative behaviour under test

stub_test.go asserts, beyond “it runs”:

  1. Harness — no NaNs, correct state widths, no params mutation, no statefulness residue across a repeated run (simulator.RunWithHarnesses).
  2. Physical invariants — the anomaly and every site’s latent log-concentration stay finite; every site’s p_exceed is a genuine probability in [0,1] at every step.
  3. Correct direction of parameter response — raising the anomaly volatility raises the ensemble-mean exceedance probability at a below-threshold site (a 16-member ensemble over 400 steps). A stub that merely “runs” would not catch an inverted response.

The expected-behaviour suite (behaviour_test.go) adds named, plain-language response claims, covering both kinds of lever:

The observed number for each claim is emitted by the test run into the generated Observed behaviour table below — never hand-typed.

Observed behaviour

Every row below is one bound object: a plain-language response claim, the test subtest that enforces it, and the number that test produced (ensemble values rounded to 2 dp). Nothing here is hand-written — the claims and their numbers are emitted by TestBathingWaterExpectedBehaviour (via go run ./cmd/model-graphs), so a claim cannot drift from its test or its result. If the model’s behaviour changes, either the binding test fails (a claim’s assertion broke) or TestCardsUpToDate fails (a number moved) — a broken claim cannot reach the card silently.

Response claim Enforced by Observed
Pollution reduction (lower baseline) cuts a site’s exceedance probability TestBathingWaterExpectedBehaviour/pollution_reduction_lowers_exceedance_probability ensemble-mean exceedance probability — base 0.13 · baseline halved 0.04
A stricter statutory threshold raises the flagged exceedance probability TestBathingWaterExpectedBehaviour/stricter_threshold_raises_flagged_exceedance_probability ensemble-mean exceedance probability — base 0.05 · threshold ×0.5 0.18
Higher regional anomaly volatility raises mean exceedance (headline driver) TestBathingWaterExpectedBehaviour/higher_regional_anomaly_volatility_raises_mean_exceedance ensemble-mean exceedance probability — σ=0.3 0.04 · σ=0.8 0.09
Stronger regional coupling raises the cross-site correlation of exceedance TestBathingWaterExpectedBehaviour/stronger_regional_coupling_raises_cross_site_correlation ensemble-mean cross-site correlation — loading=0.05 -0.85 · loading=1.5 0.72
Faster anomaly mean-reversion lowers mean exceedance TestBathingWaterExpectedBehaviour/faster_anomaly_reversion_lowers_mean_exceedance ensemble-mean exceedance probability — θ=0.15 0.07 · θ=0.6 0.05
A larger within-site sample scale raises the exceedance probability TestBathingWaterExpectedBehaviour/higher_sample_scale_raises_exceedance_probability ensemble-mean exceedance probability — base 0.05 · sample_scale=1.4 0.15
A larger seasonal amplitude raises the peak-season exceedance TestBathingWaterExpectedBehaviour/larger_seasonal_amplitude_raises_peak_season_exceedance ensemble-mean peak exceedance probability — amplitude=0.2 0.07 · amplitude=1.2 0.37

Bespoke extensions (staged beside the stub)

BathingConcentrationIteration (concentration.go) is a custom simulator.Iteration lifted from the downstream repo, where the same latent-concentration → exceedance-probability step is an inline closure over a general.ValuesFunctionIteration in the regional partition graph (internal/compose); it is promoted here to a named iteration that computes its own seasonal term so the stub self-drives. The shared anomaly reuses the engine’s own continuous.OrnsteinUhlenbeckIteration. The downstream data-fitting concerns — censored maximum-likelihood fitting, empirical-Bayes pooling, and the particle filter that infers the anomaly — are inference and were left downstream.

This iteration lives here rather than in the engine core because the catalogue is the staging ground for the “should this be promoted into core?” question — a generic “latent linear predictor → tail-exceedance probability” partition recurring across other models would be the signal to promote, but that waits for the recurrence.

Downstream

Data ingestion (EA bathing-water sample records + rainfall), censored-likelihood fitting, empirical-Bayes pooling, the sequential-Monte-Carlo anomaly filter, and the forecast-commitment / scoring decision layer live in the project repo:

https://github.com/umbralcalc/bathing-water-forecaster