Skip to content

Commit 53e2edf

Browse files
jirhikerclaude
andauthored
Add analyte-trend, MCL-exceedance, and monitoring-recency products (#91)
* Add analyte-trend, MCL-exceedance, and monitoring-recency products Three new data products built on the per-source asset graph: - ogc_analyte_trend: per-well analyte concentration trend (Mann-Kendall + Theil-Sen, daily mean). One product per analyte; seeds nm_arsenic_trend and nm_nitrate_trend. - ogc_mcl_exceedance (nm_mcl_exceedance): one feature per well flagging drinking-water MCL exceedances. Thresholds read at run time from gs://<bucket>/config/mcl.json (source of truth); see mcl.sample.json. - ogc_monitoring_recency (nm_monitoring_recency): one feature per well with last-observation date, days_since_last, and active/stale status (water levels, stale > 365d). Implementation: - Generalize the trend dumper: dump_waterlevel_trend_collection -> dump_trend_collection(slope_units, reducer, method, parameter_name); _daily_min_series -> _daily_series(reducer min|max|mean). slope_ft_per_year -> slope_per_year + slope_units. - New dumpers dump_mcl_exceedance_collection (pivot + threshold compare) and dump_monitoring_recency_collection. - GCSResource.read_json for the MCL file; die_config treats MCL as summary mode; definitions registers the three output types (each gets a job + schedule). Offline tests cover all three. Run nm_mcl_exceedance only after uploading config/mcl.json to the products bucket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Generate config/mcl.json from EPA standards Replace the sample with the real EPA-sourced MCL file. Values in mg/L: - arsenic 0.01, nitrate(as N) 10, fluoride 4.0, uranium 0.03 (primary) - chloride 250, sulfate 250, tds 500 (secondary) pH (6.5-8.5) omitted (a range, not a single MCL). Provenance + EPA source URLs recorded in the file. Add uranium to the nm_mcl_exceedance analyte list. Upload this file to gs://<products_bucket>/config/mcl.json. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Make mcl.json self-documenting Add a _schema block (explains every field), structured _source with EPA URLs + retrieved date, an _omitted note (pH is a range), and per-analyte units/basis/label/note. The product reads only mcl/type per analyte; _-prefixed keys and extra fields are ignored, and the whole dict travels into the output collection's mcl_thresholds as provenance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Note nitrate as-N vs as-NO3 hazard at the MCL comparison The exceedance test is a direct magnitude comparison, so MCL and value must share units and basis. Document the nitrate pitfall (EPA MCL is as N; data may be as NO3, ~4.43x) at the comparison site. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Extract trend statistics into backend/trend_stats.py ogc_features.py had grown to ~628 lines mixing three concerns; the statistics cluster is the one that isn't serialization. Move the daily aggregation, qualification gate, Mann-Kendall + Theil-Sen test, the thresholds, and the method-description text into backend/trend_stats.py (pure analysis, lazily importing scipy/pymannkendall). ogc_features re-exports them so importers and dump_trend_collection's default arg keep working. ogc_features 628 -> 506 lines (serialization only); trend_stats 143. Add direct unit tests for the extracted module. 27 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add dagster-dg-cli as an orchestration dependency dg kept disappearing from the project venv on env re-resolves because it was never declared, breaking the AGENTS.md-recommended `dg check defs`. Declare it so `uv run dg ...` always works. Dev/CLI only — not in the serverless requirements.txt, so the deploy PEX is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c3e6a88 commit 53e2edf

11 files changed

Lines changed: 676 additions & 164 deletions

File tree

backend/persisters/ogc_features.py

Lines changed: 197 additions & 144 deletions
Large diffs are not rendered by default.

backend/trend_stats.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# ===============================================================================
2+
# Trend statistics for DIE products.
3+
#
4+
# Pure analysis (no serialization / I/O): daily aggregation, the qualification
5+
# gate, and the Mann-Kendall + Theil-Sen trend test. Kept separate from
6+
# backend/persisters/ogc_features.py (which only builds GeoJSON) because this is
7+
# statistics, is independently testable, and pulls heavier deps (scipy,
8+
# pymannkendall) lazily.
9+
# ===============================================================================
10+
from datetime import datetime, timezone
11+
from typing import Optional
12+
13+
# Seconds per Julian year (365.25 days) — used to express the Theil-Sen slope
14+
# per year.
15+
_SECONDS_PER_YEAR = 31557600.0
16+
17+
# A well is classified only when it has enough data for a meaningful
18+
# Mann-Kendall test: at least 10 daily points, or at least 4 spanning >= 2 years.
19+
_TREND_MIN_RECORDS = 10
20+
_TREND_MIN_RECORDS_WITH_SPAN = 4
21+
_TREND_MIN_SPAN_YEARS = 2.0
22+
_TREND_ALPHA = 0.05 # significance level for the Mann-Kendall test
23+
24+
_MK_COMMON = (
25+
"Monotonic trend is tested with the non-parametric Mann-Kendall test "
26+
"(pymannkendall.original_test, alpha=0.05) on the daily series ordered by "
27+
"time; the rate is the Theil-Sen slope vs time. A well is classified only "
28+
"when it has at least 10 daily points, or at least 4 daily points spanning "
29+
"at least 2 years; otherwise 'not enough data'."
30+
)
31+
32+
# Human-readable description of each trend product's method, embedded in the
33+
# collection so consumers know how the classification was derived.
34+
TREND_METHOD_DESCRIPTION = (
35+
"Depth-to-water trend per well. Observations are downsampled to one point "
36+
"per calendar day, keeping the daily MINIMUM depth-to-water (shallowest "
37+
f"reading). {_MK_COMMON} A significant increasing slope is 'increasing' "
38+
"(water level getting DEEPER, i.e. a declining water table), a significant "
39+
"decreasing slope is 'decreasing' (water level getting SHALLOWER), else "
40+
"'stable'. Mirrors the Ocotillo API ogc_depth_to_water_trend_wells "
41+
"materialized view, using Mann-Kendall + Theil-Sen instead of OLS."
42+
)
43+
44+
ANALYTE_TREND_METHOD_DESCRIPTION = (
45+
"Analyte concentration trend per well. Observations are downsampled to one "
46+
f"point per calendar day, keeping the daily MEAN concentration. {_MK_COMMON} "
47+
"A significant increasing slope is 'increasing' (concentration rising), a "
48+
"significant decreasing slope is 'decreasing', else 'stable'."
49+
)
50+
51+
52+
def parse_epoch_seconds(date, time) -> Optional[float]:
53+
"""Best-effort parse of a DIE date (+optional time) to POSIX seconds (UTC)."""
54+
if not date:
55+
return None
56+
text = f"{date}T{time}" if time else str(date)
57+
text = text.replace("Z", "")
58+
try:
59+
dt = datetime.fromisoformat(text)
60+
if dt.tzinfo is None:
61+
dt = dt.replace(tzinfo=timezone.utc)
62+
return dt.timestamp()
63+
except (ValueError, TypeError):
64+
pass
65+
try:
66+
dt = datetime.fromisoformat(str(date)).replace(tzinfo=timezone.utc)
67+
return dt.timestamp()
68+
except (ValueError, TypeError):
69+
return None
70+
71+
72+
def daily_series(obs_list: list, reducer: str = "min") -> tuple[int, list]:
73+
"""Reduce a well's observations to one point per calendar day, keyed at the
74+
day's UTC midnight epoch. *reducer* selects the daily aggregate: "min"
75+
(shallowest depth-to-water), "max", or "mean" (e.g. analyte concentration).
76+
77+
*obs_list* is a list of observation payload dicts (parameter_value,
78+
date_measured, time_measured). Operating on dicts avoids rebuilding
79+
ParameterRecord objects for what can be millions of observations.
80+
81+
Downsampling bounds the O(n^2) Mann-Kendall cost for high-frequency wells
82+
(e.g. continuous loggers) and removes within-day sampling noise. Returns
83+
(raw_observation_count, [(day_epoch_seconds, value), ...] sorted by day).
84+
"""
85+
raw_count = 0
86+
buckets: dict = {} # date -> (day_epoch, [values])
87+
for obs in obs_list:
88+
value = obs.get("parameter_value")
89+
epoch = parse_epoch_seconds(obs.get("date_measured"), obs.get("time_measured"))
90+
if value is None or epoch is None:
91+
continue
92+
try:
93+
v = float(value)
94+
except (TypeError, ValueError):
95+
continue
96+
raw_count += 1
97+
day = datetime.fromtimestamp(epoch, tz=timezone.utc).date()
98+
day_epoch = datetime(
99+
day.year, day.month, day.day, tzinfo=timezone.utc
100+
).timestamp()
101+
if day not in buckets:
102+
buckets[day] = (day_epoch, [])
103+
buckets[day][1].append(v)
104+
105+
reduce_fn = {
106+
"min": min,
107+
"max": max,
108+
"mean": lambda vs: sum(vs) / len(vs),
109+
}[reducer]
110+
111+
pairs = sorted(
112+
((day_epoch, reduce_fn(vals)) for day_epoch, vals in buckets.values()),
113+
key=lambda p: p[0],
114+
)
115+
return raw_count, pairs
116+
117+
118+
def qualifies_for_trend(record_count, span_years) -> bool:
119+
return record_count >= _TREND_MIN_RECORDS or (
120+
record_count >= _TREND_MIN_RECORDS_WITH_SPAN
121+
and span_years >= _TREND_MIN_SPAN_YEARS
122+
)
123+
124+
125+
def mann_kendall_trend(years: list, values: list):
126+
"""Run the Mann-Kendall trend test + Theil-Sen slope.
127+
128+
Returns (trend_category, slope_per_year, p_value, tau). *years* are decimal
129+
years, *values* the measured quantity, both ordered by time. trend_category
130+
is one of 'increasing' / 'decreasing' / 'stable'.
131+
"""
132+
import pymannkendall as mk
133+
from scipy.stats import theilslopes
134+
135+
result = mk.original_test(values, alpha=_TREND_ALPHA)
136+
# Time-aware Theil-Sen slope (per year) — robust and correct for the
137+
# irregular sampling typical of these records, unlike MK's index-based slope
138+
# which assumes unit spacing.
139+
slope_per_year = float(theilslopes(values, years)[0])
140+
141+
# mk trend is 'increasing' / 'decreasing' / 'no trend'.
142+
category = "stable" if result.trend == "no trend" else result.trend
143+
return category, slope_per_year, float(result.p), float(result.Tau)

orchestration/assets/products.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,21 @@
2828
import tempfile
2929
import traceback
3030
from collections.abc import Iterator
31+
from datetime import datetime, timezone
3132
from pathlib import Path
3233

3334
import dagster as dg
3435
import geopandas as gpd
3536

3637
from backend.config import PARAMETER_SOURCE_MAP, WATERLEVELS
3738
from backend.persisters.ogc_features import (
39+
ANALYTE_TREND_METHOD_DESCRIPTION,
3840
dump_major_chemistry_collection,
41+
dump_mcl_exceedance_collection,
42+
dump_monitoring_recency_collection,
3943
dump_summary_collection,
4044
dump_timeseries_collection,
41-
dump_waterlevel_trend_collection,
45+
dump_trend_collection,
4246
)
4347
from backend.record import ParameterRecord, SiteRecord, SummaryRecord
4448
from backend.unifier import unify_source
@@ -50,6 +54,13 @@
5054
_CHECK_NAME = "returned_data"
5155
_GEOSERVER_CHECK_NAME = "registered"
5256

57+
# GCS key (within the products bucket) of the MCL threshold file — the source of
58+
# truth for the ogc_mcl_exceedance product.
59+
_MCL_KEY = "config/mcl.json"
60+
61+
# Multi-analyte products that gather one summary record per analyte per well.
62+
_MULTI_ANALYTE_OUTPUT_TYPES = ("ogc_major_chemistry", "ogc_mcl_exceedance")
63+
5364
# Classic major-ion suite for the ogc_major_chemistry product. One feature per
5465
# well, with each analyte's latest value/units/date as properties.
5566
_MAJOR_CHEMISTRY = [
@@ -66,9 +77,14 @@
6677

6778
def _product_params(product: dict) -> list[str]:
6879
"""The DIE parameter(s) a product unifies. Single-parameter products yield
69-
one; the major-chemistry product yields the major-ion suite."""
70-
if product.get("output_type") == "ogc_major_chemistry":
80+
one; multi-analyte products yield their analyte list."""
81+
output_type = product.get("output_type")
82+
if output_type == "ogc_major_chemistry":
7183
return list(_MAJOR_CHEMISTRY)
84+
if output_type == "ogc_mcl_exceedance":
85+
# Candidate analytes for the static asset graph; the MCL JSON is the
86+
# source of truth for which actually have thresholds.
87+
return list(product["analytes"])
7288
return [product["parameter"]]
7389

7490

@@ -214,16 +230,35 @@ def _combine_asset(
214230
# to one feature per well with analytes as properties.
215231
records = [SummaryRecord(p) for p in all_records]
216232
dump_major_chemistry_collection(str(out), records, meta)
233+
elif output_type == "ogc_mcl_exceedance":
234+
# Compare each well's latest analyte values to the MCL JSON
235+
# (source of truth) read from GCS.
236+
thresholds = gcs.read_json(_MCL_KEY)
237+
records = [SummaryRecord(p) for p in all_records]
238+
dump_mcl_exceedance_collection(str(out), records, meta, thresholds)
217239
elif output_type == "ogc_summary":
218240
records = [SummaryRecord(p) for p in all_records]
219241
dump_summary_collection(str(out), records, meta)
220242
elif output_type == "ogc_waterlevel_trend":
221-
# all_sites and all_timeseries are index-aligned payload dicts
222-
# (see source asset). The trend dumper consumes dicts directly —
223-
# no ParameterRecord/SiteRecord rebuild — to keep memory bounded
224-
# for statewide, high-frequency water-level data.
225-
dump_waterlevel_trend_collection(
226-
str(out), all_sites, all_timeseries, meta
243+
# all_sites/all_timeseries are index-aligned payload dicts (see
244+
# source asset); consumed as dicts to keep memory bounded.
245+
dump_trend_collection(
246+
str(out), all_sites, all_timeseries, meta,
247+
slope_units="ft/year", reducer="min",
248+
)
249+
elif output_type == "ogc_analyte_trend":
250+
dump_trend_collection(
251+
str(out), all_sites, all_timeseries, meta,
252+
slope_units="mg/L/year", reducer="mean",
253+
method=ANALYTE_TREND_METHOD_DESCRIPTION,
254+
parameter_name=product.get("parameter"),
255+
)
256+
elif output_type == "ogc_monitoring_recency":
257+
run_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
258+
dump_monitoring_recency_collection(
259+
str(out), all_sites, all_timeseries, meta,
260+
run_date=run_date,
261+
stale_days=int(product.get("stale_days", 365)),
227262
)
228263
else:
229264
site_records = [SiteRecord(p) for p in all_sites]

orchestration/config/mcl.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"_readme": "Drinking-water Maximum Contaminant Levels (MCLs) for the DIE nm_mcl_exceedance product. This file is the source of truth; upload it to gs://<products_bucket>/config/mcl.json. Each top-level key (except those starting with '_') is a DIE analyte name; the product compares each well's latest value to that analyte's 'mcl' and flags value > mcl. Analytes absent here are reported without a flag.",
3+
"_schema": {
4+
"mcl": "number — the threshold, in 'units'. Used by the product (value > mcl => exceedance).",
5+
"units": "string — units of mcl; MUST match DIE's normalized output units for the analyte (mg/L).",
6+
"type": "string — 'primary' (enforceable health-based NPDWR) or 'secondary' (non-mandatory aesthetic SMCL). Used by the product.",
7+
"basis": "string — what the value is measured as (e.g. nitrate 'as N'); the data MUST be on the same basis for the comparison to be valid.",
8+
"label": "string — human-readable contaminant name.",
9+
"note": "string — clarifications/caveats."
10+
},
11+
"_source": {
12+
"primary": "EPA National Primary Drinking Water Regulations — https://www.epa.gov/ground-water-and-drinking-water/national-primary-drinking-water-regulations",
13+
"secondary": "EPA Secondary Drinking Water Standards — https://www.epa.gov/sdwa/secondary-drinking-water-standards-guidance-nuisance-chemicals",
14+
"retrieved": "2026-06-28"
15+
},
16+
"arsenic": {
17+
"mcl": 0.01,
18+
"units": "mg/L",
19+
"type": "primary",
20+
"label": "Arsenic",
21+
"note": "EPA NPDWR MCL 0.010 mg/L (10 ug/L)."
22+
},
23+
"nitrate": {
24+
"mcl": 10.0,
25+
"units": "mg/L",
26+
"type": "primary",
27+
"basis": "as N",
28+
"label": "Nitrate (as nitrogen)",
29+
"note": "EPA MCL is 10 mg/L measured as nitrogen (N). If DIE reports nitrate as NO3, convert (as-NO3 MCL ~= 44.3 mg/L) or this flag is wrong."
30+
},
31+
"fluoride": {
32+
"mcl": 4.0,
33+
"units": "mg/L",
34+
"type": "primary",
35+
"label": "Fluoride",
36+
"note": "Primary (health) MCL 4.0 mg/L. A secondary aesthetic SMCL of 2.0 mg/L also exists; this file uses the enforceable primary MCL."
37+
},
38+
"uranium": {
39+
"mcl": 0.03,
40+
"units": "mg/L",
41+
"type": "primary",
42+
"label": "Uranium",
43+
"note": "EPA MCL 30 ug/L = 0.030 mg/L (effective 2003)."
44+
},
45+
"chloride": {
46+
"mcl": 250.0,
47+
"units": "mg/L",
48+
"type": "secondary",
49+
"label": "Chloride",
50+
"note": "Secondary (aesthetic) SMCL 250 mg/L."
51+
},
52+
"sulfate": {
53+
"mcl": 250.0,
54+
"units": "mg/L",
55+
"type": "secondary",
56+
"label": "Sulfate",
57+
"note": "Secondary (aesthetic) SMCL 250 mg/L."
58+
},
59+
"tds": {
60+
"mcl": 500.0,
61+
"units": "mg/L",
62+
"type": "secondary",
63+
"label": "Total dissolved solids",
64+
"note": "Secondary (aesthetic) SMCL 500 mg/L."
65+
},
66+
"_omitted": {
67+
"ph": "EPA secondary standard is a RANGE (6.5-8.5), not a single MCL, so it does not fit the value > mcl test and is excluded."
68+
}
69+
}

orchestration/config/products.yaml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,55 @@ products:
8383
state: NM
8484
sources:
8585
exclude: []
86+
87+
# Per-well analyte concentration trend (Mann-Kendall + Theil-Sen, daily mean).
88+
# One product per analyte, driven by `parameter`.
89+
- id: nm_arsenic_trend
90+
parameter: arsenic
91+
output_type: ogc_analyte_trend
92+
title: "NM Arsenic Trends"
93+
description: "Per-well arsenic concentration trend (slope and category), all NM sources"
94+
schedule: "0 13 * * *"
95+
spatial_filter:
96+
state: NM
97+
sources:
98+
exclude: []
99+
100+
- id: nm_nitrate_trend
101+
parameter: nitrate
102+
output_type: ogc_analyte_trend
103+
title: "NM Nitrate Trends"
104+
description: "Per-well nitrate concentration trend (slope and category), all NM sources"
105+
schedule: "0 14 * * *"
106+
spatial_filter:
107+
state: NM
108+
sources:
109+
exclude: []
110+
111+
# One feature per well flagging drinking-water MCL exceedances. Thresholds are
112+
# read at run time from gs://<bucket>/config/mcl.json (source of truth);
113+
# `analytes` lists the candidates gathered for comparison.
114+
- id: nm_mcl_exceedance
115+
output_type: ogc_mcl_exceedance
116+
title: "NM MCL Exceedances"
117+
description: "Wells flagged against drinking-water MCLs, all NM sources"
118+
schedule: "0 15 * * *"
119+
analytes: [arsenic, nitrate, fluoride, uranium, sulfate, chloride, tds]
120+
spatial_filter:
121+
state: NM
122+
sources:
123+
exclude: []
124+
125+
# One feature per well: how recently it was measured (water levels), to surface
126+
# dead/lagging monitoring points. status = active/stale at stale_days.
127+
- id: nm_monitoring_recency
128+
parameter: waterlevels
129+
output_type: ogc_monitoring_recency
130+
title: "NM Monitoring Recency"
131+
description: "Per-well water-level monitoring recency and status, all NM sources"
132+
schedule: "0 16 * * *"
133+
stale_days: 365
134+
spatial_filter:
135+
state: NM
136+
sources:
137+
exclude: []

orchestration/definitions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
"ogc_timeseries",
2525
"ogc_major_chemistry",
2626
"ogc_waterlevel_trend",
27+
"ogc_analyte_trend",
28+
"ogc_mcl_exceedance",
29+
"ogc_monitoring_recency",
2730
}
2831

2932

orchestration/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ requires-python = ">=3.14"
1010
dependencies = [
1111
"dagster>=1.8",
1212
"dagster-cloud",
13+
"dagster-dg-cli",
1314
"dagster-gcp>=0.24",
1415
"dagster-webserver>=1.8",
1516
"google-cloud-storage",

orchestration/resources/die_config.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ def get_config(self, product: dict, parameter: Optional[str] = None) -> Config:
4242
sources_spec = product.get("sources", {})
4343

4444
output_type = product.get("output_type", "ogc_summary")
45-
is_summary = output_type in ("ogc_summary", "ogc_major_chemistry")
45+
is_summary = output_type in (
46+
"ogc_summary",
47+
"ogc_major_chemistry",
48+
"ogc_mcl_exceedance",
49+
)
4650

4751
payload: dict = {
4852
"yes": True,

0 commit comments

Comments
 (0)