Skip to content

Commit 7fdd086

Browse files
jirhikerclaude
andcommitted
Add waterlevel-trends data product (one feature per well)
New ogc_waterlevel_trend product: nm_waterlevel_trends. One GeoJSON feature per well with a depth-to-water trend computed from the well's water-level timeseries, across all NM sources. Algorithm ported from the Ocotillo API ogc_depth_to_water_trend_wells materialized view: - least-squares slope (REGR_SLOPE) of depth-to-water (ft) vs observation time, scaled to ft/year (365.25-day year); - classify only with >=10 records, or >=4 records spanning >=2 years; slope > 0.25 ft/yr "increasing" (deeper/declining), < -0.25 "decreasing", else "stable"; otherwise "not enough data". The product includes a trend_method description of how the trend was calculated (TREND_METHOD_DESCRIPTION). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 78e55ca commit 7fdd086

5 files changed

Lines changed: 277 additions & 3 deletions

File tree

backend/persisters/ogc_features.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,87 @@
1111
from datetime import datetime, timezone
1212
from typing import Optional
1313

14+
# Seconds per Julian year (365.25 days) — matches Ocotillo's trend MV, which
15+
# divides the per-second regression slope by 31557600 to get ft/year.
16+
_SECONDS_PER_YEAR = 31557600.0
17+
18+
# Trend classification thresholds, ported verbatim from the Ocotillo
19+
# ogc_depth_to_water_trend_wells materialized view.
20+
_TREND_SLOPE_THRESHOLD = 0.25 # ft/year
21+
_TREND_MIN_RECORDS = 10
22+
_TREND_MIN_RECORDS_WITH_SPAN = 4
23+
_TREND_MIN_SPAN_YEARS = 2.0
24+
25+
# Human-readable description of the trend method, embedded in the product so
26+
# consumers know how the classification was derived.
27+
TREND_METHOD_DESCRIPTION = (
28+
"Depth-to-water trend per well. A least-squares linear regression "
29+
"(equivalent to PostgreSQL REGR_SLOPE) is fit to depth-to-water-below-"
30+
"ground-surface (feet) against observation time; the per-second slope is "
31+
"scaled by 31,557,600 s/yr (a 365.25-day year) to slope_ft_per_year. A well "
32+
"is classified only when it has at least 10 measurements, or at least 4 "
33+
"measurements spanning at least 2 years; otherwise 'not enough data'. When "
34+
"classified: slope > 0.25 ft/yr is 'increasing' (water level getting "
35+
"DEEPER, i.e. a declining water table), slope < -0.25 ft/yr is 'decreasing' "
36+
"(water level getting SHALLOWER), otherwise 'stable'. Ported from the "
37+
"Ocotillo API ogc_depth_to_water_trend_wells materialized view."
38+
)
39+
1440

1541
def _timestamp_now() -> str:
1642
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1743

1844

45+
def _parse_epoch_seconds(date, time) -> Optional[float]:
46+
"""Best-effort parse of a DIE date (+optional time) to POSIX seconds (UTC)."""
47+
if not date:
48+
return None
49+
text = f"{date}T{time}" if time else str(date)
50+
text = text.replace("Z", "")
51+
for parse in (datetime.fromisoformat,):
52+
try:
53+
dt = parse(text)
54+
if dt.tzinfo is None:
55+
dt = dt.replace(tzinfo=timezone.utc)
56+
return dt.timestamp()
57+
except (ValueError, TypeError):
58+
pass
59+
try:
60+
dt = datetime.fromisoformat(str(date)).replace(tzinfo=timezone.utc)
61+
return dt.timestamp()
62+
except (ValueError, TypeError):
63+
return None
64+
65+
66+
def _regr_slope(xs: list, ys: list) -> Optional[float]:
67+
"""Least-squares slope of ys on xs (PostgreSQL REGR_SLOPE). None if x has no
68+
variance (fewer than 2 distinct x values)."""
69+
n = len(xs)
70+
if n < 2:
71+
return None
72+
mx = sum(xs) / n
73+
my = sum(ys) / n
74+
sxx = sum((x - mx) ** 2 for x in xs)
75+
if sxx == 0:
76+
return None
77+
sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
78+
return sxy / sxx
79+
80+
81+
def _classify_trend(slope_ft_per_year, record_count, span_years) -> str:
82+
qualifies = record_count >= _TREND_MIN_RECORDS or (
83+
record_count >= _TREND_MIN_RECORDS_WITH_SPAN
84+
and span_years >= _TREND_MIN_SPAN_YEARS
85+
)
86+
if not qualifies or slope_ft_per_year is None:
87+
return "not enough data"
88+
if slope_ft_per_year > _TREND_SLOPE_THRESHOLD:
89+
return "increasing"
90+
if slope_ft_per_year < -_TREND_SLOPE_THRESHOLD:
91+
return "decreasing"
92+
return "stable"
93+
94+
1995
def _make_feature(record, collection_id: str) -> dict:
2096
"""Build one OGC-compliant Feature from a SummaryRecord or SiteRecord."""
2197
source = getattr(record, "source", "")
@@ -174,6 +250,125 @@ def dump_major_chemistry_collection(path: str, records: list, meta: dict) -> dic
174250
return collection
175251

176252

253+
def dump_waterlevel_trend_collection(
254+
path: str,
255+
site_records: list,
256+
timeseries_records: list,
257+
meta: dict,
258+
) -> dict:
259+
"""
260+
Write an OGC FeatureCollection of per-well depth-to-water trends to *path*.
261+
One Feature per well.
262+
263+
*site_records* and *timeseries_records* are index-aligned: ``site_records[i]``
264+
is the well and ``timeseries_records[i]`` is its list of ParameterRecord
265+
observations (DIE water-level values are already depth-to-water below ground
266+
surface in feet, so no measuring-point adjustment is applied here).
267+
268+
Each feature carries: record_count, first/last_observation_datetime,
269+
span_years, slope_ft_per_year, trend_category, well_depth(+units). The
270+
collection carries ``trend_method`` describing the calculation. See
271+
TREND_METHOD_DESCRIPTION.
272+
273+
§V: MUST include top-level id, type, numberReturned, timeStamp.
274+
§V: Each Feature MUST have top-level id.
275+
"""
276+
collection_id = meta.get("id", "collection")
277+
278+
features = []
279+
for site, obs_list in zip(site_records, timeseries_records):
280+
pairs = []
281+
for obs in obs_list:
282+
value = getattr(obs, "parameter_value", None)
283+
epoch = _parse_epoch_seconds(
284+
getattr(obs, "date_measured", None), getattr(obs, "time_measured", None)
285+
)
286+
if value is None or epoch is None:
287+
continue
288+
try:
289+
pairs.append((epoch, float(value)))
290+
except (TypeError, ValueError):
291+
continue
292+
293+
pairs.sort(key=lambda p: p[0])
294+
record_count = len(pairs)
295+
xs = [p[0] for p in pairs]
296+
ys = [p[1] for p in pairs]
297+
298+
if record_count >= 2:
299+
span_years = (xs[-1] - xs[0]) / _SECONDS_PER_YEAR
300+
slope = _regr_slope(xs, ys)
301+
slope_ft_per_year = None if slope is None else slope * _SECONDS_PER_YEAR
302+
else:
303+
span_years = 0.0
304+
slope_ft_per_year = None
305+
306+
trend_category = _classify_trend(slope_ft_per_year, record_count, span_years)
307+
308+
source = getattr(site, "source", "") or ""
309+
rid = getattr(site, "id", "") or ""
310+
feature_id = f"{source}:{rid}" if source and rid else str(rid)
311+
312+
def _iso(epoch):
313+
if epoch is None:
314+
return None
315+
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime(
316+
"%Y-%m-%dT%H:%M:%SZ"
317+
)
318+
319+
props = {
320+
"source": source,
321+
"id": rid,
322+
"name": getattr(site, "name", None),
323+
"well_depth": getattr(site, "well_depth", None),
324+
"well_depth_units": getattr(site, "well_depth_units", None),
325+
"record_count": record_count,
326+
"first_observation_datetime": _iso(xs[0]) if record_count else None,
327+
"last_observation_datetime": _iso(xs[-1]) if record_count else None,
328+
"span_years": round(span_years, 3),
329+
"slope_ft_per_year": (
330+
None if slope_ft_per_year is None else round(slope_ft_per_year, 4)
331+
),
332+
"trend_category": trend_category,
333+
}
334+
335+
lat = getattr(site, "latitude", None)
336+
lon = getattr(site, "longitude", None)
337+
elev = getattr(site, "elevation", None)
338+
coords = [lon, lat] if elev is None else [lon, lat, elev]
339+
340+
features.append({
341+
"type": "Feature",
342+
"id": feature_id,
343+
"geometry": {"type": "Point", "coordinates": coords},
344+
"properties": props,
345+
})
346+
347+
collection = {
348+
"type": "FeatureCollection",
349+
"id": collection_id,
350+
"title": meta.get("title", collection_id),
351+
"description": meta.get("description", ""),
352+
"trend_method": TREND_METHOD_DESCRIPTION,
353+
"timeStamp": _timestamp_now(),
354+
"numberMatched": len(features),
355+
"numberReturned": len(features),
356+
"links": [
357+
{
358+
"href": meta.get("href", ""),
359+
"rel": "self",
360+
"type": "application/geo+json",
361+
}
362+
],
363+
"features": features,
364+
}
365+
366+
with open(path, "w", encoding="utf-8") as f:
367+
json.dump(collection, f, indent=2, default=str)
368+
369+
return collection
370+
371+
177372
def dump_timeseries_collection(
178373
path: str,
179374
site_records: list,

orchestration/assets/products.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
dump_major_chemistry_collection,
3838
dump_summary_collection,
3939
dump_timeseries_collection,
40+
dump_waterlevel_trend_collection,
4041
)
4142
from backend.record import ParameterRecord, SiteRecord, SummaryRecord
4243
from backend.unifier import unify_source
@@ -167,8 +168,8 @@ def _build_combine_asset(product: dict, source_keys: list, source_asset_keys: li
167168
168169
Depends on every source asset (wired via ``ins``), merges their
169170
records/sites/timeseries, writes the OGC GeoJSON collection — summary,
170-
timeseries, or major-chemistry depending on ``output_type`` — and uploads it
171-
to GCS."""
171+
timeseries, major-chemistry, or waterlevel-trend depending on
172+
``output_type`` — and uploads it to GCS."""
172173
pid = product["id"]
173174
output_type = product["output_type"]
174175
ins = {
@@ -204,6 +205,14 @@ def _combine_asset(
204205
elif output_type == "ogc_summary":
205206
records = [SummaryRecord(p) for p in all_records]
206207
dump_summary_collection(str(out), records, meta)
208+
elif output_type == "ogc_waterlevel_trend":
209+
# site_records and the per-site observation lists are index
210+
# aligned (see source asset); compute one trend per well.
211+
site_records = [SiteRecord(p) for p in all_sites]
212+
series = [
213+
[ParameterRecord(o) for o in site_ts] for site_ts in all_timeseries
214+
]
215+
dump_waterlevel_trend_collection(str(out), site_records, series, meta)
207216
else:
208217
site_records = [SiteRecord(p) for p in all_sites]
209218
flat = [

orchestration/config/products.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,18 @@ products:
6868
state: NM
6969
sources:
7070
exclude: []
71+
72+
# One feature per well with a depth-to-water trend (slope_ft_per_year +
73+
# category) computed from each well's water-level timeseries. The collection
74+
# carries a trend_method description. Algorithm ported from the Ocotillo API
75+
# ogc_depth_to_water_trend_wells materialized view (see ogc_features.py).
76+
- id: nm_waterlevel_trends
77+
parameter: waterlevels
78+
output_type: ogc_waterlevel_trend
79+
title: "NM Water Level Trends"
80+
description: "Per-well depth-to-water trend (slope and category), all NM sources"
81+
schedule: "0 12 * * *"
82+
spatial_filter:
83+
state: NM
84+
sources:
85+
exclude: []

orchestration/definitions.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111

1212
_PRODUCTS_PATH = Path(__file__).parent / "config" / "products.yaml"
1313

14-
_SUPPORTED_OUTPUT_TYPES = {"ogc_summary", "ogc_timeseries", "ogc_major_chemistry"}
14+
_SUPPORTED_OUTPUT_TYPES = {
15+
"ogc_summary",
16+
"ogc_timeseries",
17+
"ogc_major_chemistry",
18+
"ogc_waterlevel_trend",
19+
}
1520

1621

1722
def _load_products() -> dict:

tests/test_persisters/test_ogc_features.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,53 @@ def test_geometry_and_required_fields(self, tmp_path):
228228
assert "timeStamp" in result
229229
feat = result["features"][0]
230230
assert feat["geometry"]["coordinates"] == [-106.0, 34.0]
231+
232+
233+
from backend.persisters.ogc_features import dump_waterlevel_trend_collection
234+
235+
236+
def _trend_site(source="NMBGMR", rid="W1", well_depth=100.0):
237+
return SiteRecord({
238+
"source": source, "id": rid, "name": f"Well {rid}",
239+
"latitude": 34.0, "longitude": -106.0, "elevation": None,
240+
"well_depth": well_depth, "well_depth_units": "ft",
241+
})
242+
243+
244+
def _trend_obs(date, value):
245+
return ParameterRecord({"parameter_value": value, "date_measured": date, "time_measured": None})
246+
247+
248+
class TestWaterLevelTrendCollection:
249+
def test_classifies_trends_and_carries_method(self, tmp_path):
250+
increasing = [_trend_obs(f"{2010 + i}-01-01", 50.0 + 0.5 * i) for i in range(12)]
251+
stable = [_trend_obs(f"{2010 + i}-01-01", 50.0) for i in range(12)]
252+
sparse = [_trend_obs("2010-01-01", 50.0), _trend_obs("2011-01-01", 51.0), _trend_obs("2012-01-01", 52.0)]
253+
decreasing = [_trend_obs(f"{2010 + i}-01-01", 60.0 - 1.0 * i) for i in range(5)]
254+
255+
sites = [_trend_site(rid="A"), _trend_site(rid="B"), _trend_site("NWIS", "C"), _trend_site("PVACD", "D")]
256+
series = [increasing, stable, sparse, decreasing]
257+
258+
out = tmp_path / "tr.geojson"
259+
result = dump_waterlevel_trend_collection(str(out), sites, series, {"id": "nm_waterlevel_trends"})
260+
261+
assert result["numberReturned"] == 4
262+
assert "trend_method" in result and result["trend_method"]
263+
by_id = {f["id"]: f["properties"] for f in result["features"]}
264+
265+
assert by_id["NMBGMR:A"]["trend_category"] == "increasing"
266+
assert round(by_id["NMBGMR:A"]["slope_ft_per_year"], 2) == 0.5
267+
assert by_id["NMBGMR:B"]["trend_category"] == "stable"
268+
assert by_id["NWIS:C"]["trend_category"] == "not enough data" # only 3 records
269+
assert by_id["PVACD:D"]["trend_category"] == "decreasing" # 5 records / 4 yr span
270+
271+
def test_required_fields_and_geometry(self, tmp_path):
272+
sites = [_trend_site(rid="W1")]
273+
series = [[_trend_obs("2010-01-01", 50.0)]]
274+
out = tmp_path / "tr.geojson"
275+
result = dump_waterlevel_trend_collection(str(out), sites, series, {"id": "nm_waterlevel_trends"})
276+
assert result["type"] == "FeatureCollection"
277+
assert "timeStamp" in result
278+
feat = result["features"][0]
279+
assert feat["geometry"]["coordinates"] == [-106.0, 34.0]
280+
assert feat["properties"]["trend_category"] == "not enough data" # single record

0 commit comments

Comments
 (0)