Skip to content

Commit b7c7d4c

Browse files
jirhikerclaude
andcommitted
Add major-chemistry data product (one feature per well)
New ogc_major_chemistry product: nm_major_chemistry. One GeoJSON feature per well, with each major-ion analyte's latest value/units/date plus well depth as properties. - backend/persisters/ogc_features.py: dump_major_chemistry_collection pivots per-(well,analyte) SummaryRecords into one feature per well, keyed (source, id); carries well_depth and geometry. - orchestration/assets/products.py: _MAJOR_CHEMISTRY (classic 8 major ions); products now unify over a list of parameters (single-parameter products run once, major-chemistry runs once per analyte and accumulates); source keys = union of the analytes' agencies; combine picks the dumper by output_type. - die_config.get_config: optional parameter override; treat ogc_major_chemistry as summary mode. - definitions.py: register ogc_major_chemistry as a supported output type (gets its own per-product job + schedule automatically). - products.yaml: nm_major_chemistry entry (all NM sources). - tests: pivot + geometry/required-field coverage for the new dumper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7ce741a commit b7c7d4c

6 files changed

Lines changed: 242 additions & 24 deletions

File tree

backend/persisters/ogc_features.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,103 @@ def dump_summary_collection(path: str, records: list, meta: dict) -> dict:
7777
return collection
7878

7979

80+
def dump_major_chemistry_collection(path: str, records: list, meta: dict) -> dict:
81+
"""
82+
Write an OGC FeatureCollection of wells with major-chemistry analytes as
83+
properties to *path*. One Feature per well.
84+
85+
*records* is a flat list of SummaryRecord — one per (well, analyte). They are
86+
pivoted by well: each analyte contributes ``<analyte>`` (latest value),
87+
``<analyte>_units``, and ``<analyte>_date`` properties. Well identity is
88+
``(source, id)``; well_depth and geometry come from any of the well's
89+
records.
90+
91+
Returns the collection dict (for testing).
92+
§V: MUST include top-level id, type, numberReturned, timeStamp.
93+
§V: Each Feature MUST have top-level id.
94+
"""
95+
collection_id = meta.get("id", "collection")
96+
97+
wells: dict = {}
98+
for r in records:
99+
source = getattr(r, "source", "") or ""
100+
rid = getattr(r, "id", "") or ""
101+
key = (source, rid)
102+
well = wells.get(key)
103+
if well is None:
104+
well = {
105+
"source": source,
106+
"id": rid,
107+
"name": getattr(r, "name", None),
108+
"latitude": getattr(r, "latitude", None),
109+
"longitude": getattr(r, "longitude", None),
110+
"elevation": getattr(r, "elevation", None),
111+
"well_depth": getattr(r, "well_depth", None),
112+
"well_depth_units": getattr(r, "well_depth_units", None),
113+
"analytes": {},
114+
}
115+
wells[key] = well
116+
# well_depth can be absent on some analyte records; keep first non-null.
117+
if well["well_depth"] is None and getattr(r, "well_depth", None) is not None:
118+
well["well_depth"] = getattr(r, "well_depth", None)
119+
well["well_depth_units"] = getattr(r, "well_depth_units", None)
120+
121+
analyte = getattr(r, "parameter_name", None)
122+
if analyte:
123+
well["analytes"][analyte] = {
124+
"value": getattr(r, "latest_value", None),
125+
"units": getattr(r, "latest_units", None),
126+
"date": getattr(r, "latest_date", None),
127+
}
128+
129+
features = []
130+
for (source, rid), well in wells.items():
131+
feature_id = f"{source}:{rid}" if source and rid else str(rid)
132+
props = {
133+
"source": source,
134+
"id": rid,
135+
"name": well["name"],
136+
"well_depth": well["well_depth"],
137+
"well_depth_units": well["well_depth_units"],
138+
}
139+
for analyte, vals in well["analytes"].items():
140+
props[analyte] = vals["value"]
141+
props[f"{analyte}_units"] = vals["units"]
142+
props[f"{analyte}_date"] = vals["date"]
143+
144+
lat, lon, elev = well["latitude"], well["longitude"], well["elevation"]
145+
coords = [lon, lat] if elev is None else [lon, lat, elev]
146+
features.append({
147+
"type": "Feature",
148+
"id": feature_id,
149+
"geometry": {"type": "Point", "coordinates": coords},
150+
"properties": props,
151+
})
152+
153+
collection = {
154+
"type": "FeatureCollection",
155+
"id": collection_id,
156+
"title": meta.get("title", collection_id),
157+
"description": meta.get("description", ""),
158+
"timeStamp": _timestamp_now(),
159+
"numberMatched": len(features),
160+
"numberReturned": len(features),
161+
"links": [
162+
{
163+
"href": meta.get("href", ""),
164+
"rel": "self",
165+
"type": "application/geo+json",
166+
}
167+
],
168+
"features": features,
169+
}
170+
171+
with open(path, "w", encoding="utf-8") as f:
172+
json.dump(collection, f, indent=2, default=str)
173+
174+
return collection
175+
176+
80177
def dump_timeseries_collection(
81178
path: str,
82179
site_records: list,

orchestration/assets/products.py

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
from backend.config import PARAMETER_SOURCE_MAP, WATERLEVELS
3636
from backend.persisters.ogc_features import (
37+
dump_major_chemistry_collection,
3738
dump_summary_collection,
3839
dump_timeseries_collection,
3940
)
@@ -47,17 +48,42 @@
4748
_CHECK_NAME = "returned_data"
4849
_GEOSERVER_CHECK_NAME = "registered"
4950

51+
# Classic major-ion suite for the ogc_major_chemistry product. One feature per
52+
# well, with each analyte's latest value/units/date as properties.
53+
_MAJOR_CHEMISTRY = [
54+
"calcium",
55+
"magnesium",
56+
"sodium",
57+
"potassium",
58+
"bicarbonate",
59+
"carbonate",
60+
"chloride",
61+
"sulfate",
62+
]
63+
64+
65+
def _product_params(product: dict) -> list:
66+
"""The DIE parameter(s) a product unifies. Single-parameter products yield
67+
one; the major-chemistry product yields the major-ion suite."""
68+
if product.get("output_type") == "ogc_major_chemistry":
69+
return list(_MAJOR_CHEMISTRY)
70+
return [product["parameter"]]
71+
5072

5173
def _product_source_keys(product: dict) -> list:
52-
"""Source keys that apply to this product: the parameter's agencies,
53-
filtered by the product's include/exclude list."""
54-
agencies = PARAMETER_SOURCE_MAP[product["parameter"]]["agencies"]
74+
"""Source keys that apply to this product: the union of its parameters'
75+
agencies, filtered by the product's include/exclude list."""
76+
agencies: list = []
77+
for param in _product_params(product):
78+
for a in PARAMETER_SOURCE_MAP[param]["agencies"]:
79+
if a not in agencies:
80+
agencies.append(a)
5581
spec = product.get("sources", {}) or {}
5682
if spec.get("include"):
5783
return [a for a in agencies if a in spec["include"]]
5884
if spec.get("exclude"):
5985
return [a for a in agencies if a not in spec["exclude"]]
60-
return list(agencies)
86+
return agencies
6187

6288

6389
def _in_name(source_key: str) -> str:
@@ -75,24 +101,32 @@ def _build_source_asset(product: dict, source_key: str, group: str):
75101
plain ``_payload`` dicts for IO-manager pickling (see module docstring)."""
76102
pid = product["id"]
77103
src_key = dg.AssetKey([pid, "sources", source_key])
104+
params = _product_params(product)
78105

79106
@dg.asset(
80107
key=src_key,
81108
group_name=group,
82109
check_specs=[dg.AssetCheckSpec(name=_CHECK_NAME, asset=src_key)],
83110
)
84111
def _source_asset(context: dg.AssetExecutionContext, die_config: DIEConfigResource):
85-
config = die_config.get_config(product)
86-
87112
error = ""
88113
records, sites, timeseries = [], [], []
89114
try:
115+
# One unification pass per parameter. Single-parameter products run
116+
# once; the major-chemistry product runs once per analyte and
117+
# accumulates summary records (analyte identity lives in each
118+
# record's parameter_name). A source that doesn't provide a given
119+
# parameter is skipped by unify_source (source_pair → None).
90120
with forward_die_logs(context):
91-
persister = unify_source(config, source_key)
92-
# Ship plain dicts across the IO manager; rebuild in combine.
93-
records = [r._payload for r in persister.records]
94-
sites = [s._payload for s in persister.sites]
95-
timeseries = [[o._payload for o in site_ts] for site_ts in persister.timeseries]
121+
for param in params:
122+
config = die_config.get_config(product, parameter=param)
123+
persister = unify_source(config, source_key)
124+
# Ship plain dicts across the IO manager; rebuild in combine.
125+
records.extend(r._payload for r in persister.records)
126+
sites.extend(s._payload for s in persister.sites)
127+
timeseries.extend(
128+
[o._payload for o in site_ts] for site_ts in persister.timeseries
129+
)
96130
except Exception:
97131
error = traceback.format_exc()
98132
context.log.error(f"Source {source_key} failed:\n{error}")
@@ -132,10 +166,11 @@ def _build_combine_asset(product: dict, source_keys: list, source_asset_keys: li
132166
"""Build the combine asset (keyed ``[product_id]``) for *product*.
133167
134168
Depends on every source asset (wired via ``ins``), merges their
135-
records/sites/timeseries, writes the OGC GeoJSON collection — summary or
136-
timeseries depending on ``output_type`` — and uploads it to GCS."""
169+
records/sites/timeseries, writes the OGC GeoJSON collection — summary,
170+
timeseries, or major-chemistry depending on ``output_type`` — and uploads it
171+
to GCS."""
137172
pid = product["id"]
138-
is_summary = product["output_type"] == "ogc_summary"
173+
output_type = product["output_type"]
139174
ins = {
140175
_in_name(k): dg.AssetIn(key=ak)
141176
for k, ak in zip(source_keys, source_asset_keys)
@@ -161,7 +196,12 @@ def _combine_asset(
161196

162197
with tempfile.TemporaryDirectory() as tmpdir:
163198
out = Path(tmpdir) / "collection.geojson"
164-
if is_summary:
199+
if output_type == "ogc_major_chemistry":
200+
# All summary records (one per well+analyte); the dumper pivots
201+
# to one feature per well with analytes as properties.
202+
records = [SummaryRecord(p) for p in all_records]
203+
dump_major_chemistry_collection(str(out), records, meta)
204+
elif output_type == "ogc_summary":
165205
records = [SummaryRecord(p) for p in all_records]
166206
dump_summary_collection(str(out), records, meta)
167207
else:
@@ -275,8 +315,9 @@ def build_product_assets(product: dict) -> list:
275315
"""Return the full asset list for *product*: one source asset per applicable
276316
source, the combine asset, and the geoserver publish asset (see module
277317
docstring for the graph shape). Assets are grouped ``waterlevels`` or
278-
``analytes`` by parameter."""
279-
group = "waterlevels" if product["parameter"] == WATERLEVELS else "analytes"
318+
``analytes`` by parameter (major-chemistry products group under
319+
``analytes``)."""
320+
group = "waterlevels" if product.get("parameter") == WATERLEVELS else "analytes"
280321
source_keys = _product_source_keys(product)
281322

282323
source_assets = []

orchestration/config/products.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,16 @@ products:
5555
state: NM
5656
sources:
5757
exclude: []
58+
59+
# One feature per well; each major-ion analyte's latest value/units/date,
60+
# plus well depth, stored as properties. Analyte set is fixed in
61+
# assets/products.py (_MAJOR_CHEMISTRY), so no `parameter` here.
62+
- id: nm_major_chemistry
63+
output_type: ogc_major_chemistry
64+
title: "NM Major Chemistry"
65+
description: "Wells with major-ion chemistry (latest value per analyte), well depth, all NM sources"
66+
schedule: "0 11 * * *"
67+
spatial_filter:
68+
state: NM
69+
sources:
70+
exclude: []

orchestration/definitions.py

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

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

14-
_SUPPORTED_OUTPUT_TYPES = {"ogc_summary", "ogc_timeseries"}
14+
_SUPPORTED_OUTPUT_TYPES = {"ogc_summary", "ogc_timeseries", "ogc_major_chemistry"}
1515

1616

1717
def _load_products() -> dict:

orchestration/resources/die_config.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,36 @@ class DIEConfigResource(dg.ConfigurableResource):
88

99
usgs_api_key: Optional[str] = None
1010

11-
def get_config(self, product: dict) -> Config:
11+
def get_config(self, product: dict, parameter: Optional[str] = None) -> Config:
1212
"""Translate a products.yaml entry into a finalized DIE ``Config``.
1313
1414
Mapping:
15-
- ``output_type`` → ``output_summary`` / ``output_format``.
15+
- ``output_type`` → ``output_summary`` / ``output_format``. Both
16+
``ogc_summary`` and ``ogc_major_chemistry`` run in summary mode (the
17+
latter pivots per-analyte summaries into one feature per well).
1618
- ``spatial_filter.county`` → ``county``. ``spatial_filter.state`` sets
1719
``wkt = None`` (statewide; DIE applies the NM extent downstream).
1820
- ``sources.include`` → enable only those sources (all others off).
1921
``sources.exclude`` → disable those, leave the rest at their defaults.
2022
- ``parameter`` is set on the Config, then ``finalize()`` validates and
2123
resolves output units/paths.
24+
25+
*parameter* overrides ``product["parameter"]`` — used by the
26+
major-chemistry product, which has no single parameter and calls this
27+
once per analyte.
2228
"""
2329
spatial = product.get("spatial_filter", {})
2430
sources_spec = product.get("sources", {})
2531

32+
output_type = product.get("output_type", "ogc_summary")
33+
is_summary = output_type in ("ogc_summary", "ogc_major_chemistry")
34+
2635
payload: dict = {
2736
"yes": True,
28-
"output_summary": product.get("output_type") == "ogc_summary",
29-
"output_format": product.get("output_type", "ogc_summary"),
37+
"output_summary": is_summary,
38+
# backend only distinguishes summary vs timeseries; major-chemistry
39+
# is a summary variant as far as unification is concerned.
40+
"output_format": "ogc_summary" if is_summary else output_type,
3041
}
3142

3243
if spatial.get("county"):
@@ -49,6 +60,6 @@ def get_config(self, product: dict) -> Config:
4960
payload[f"use_source_{s}"] = False
5061

5162
config = Config(payload=payload)
52-
config.parameter = product["parameter"]
63+
config.parameter = parameter or product["parameter"]
5364
config.finalize()
5465
return config

tests/test_persisters/test_ogc_features.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
import os
33
import tempfile
44

5-
from backend.persisters.ogc_features import dump_summary_collection, dump_timeseries_collection
5+
from backend.persisters.ogc_features import (
6+
dump_summary_collection,
7+
dump_timeseries_collection,
8+
dump_major_chemistry_collection,
9+
)
610
from backend.record import SummaryRecord, SiteRecord, ParameterRecord
711

812

@@ -172,3 +176,55 @@ def test_ogc_required_fields(self, tmp_path):
172176
assert result["id"] == "nm_ts"
173177
assert "timeStamp" in result
174178
assert "numberReturned" in result
179+
180+
181+
def _make_chem_record(source, rid, analyte, value, units="mg/L", date="2024-05-01", well_depth=None):
182+
return SummaryRecord({
183+
"source": source,
184+
"id": rid,
185+
"name": f"Well {rid}",
186+
"latitude": 34.0,
187+
"longitude": -106.0,
188+
"elevation": None,
189+
"well_depth": well_depth,
190+
"well_depth_units": "ft",
191+
"parameter_name": analyte,
192+
"latest_value": value,
193+
"latest_units": units,
194+
"latest_date": date,
195+
})
196+
197+
198+
class TestMajorChemistryCollection:
199+
def test_pivots_analytes_into_one_feature_per_well(self, tmp_path):
200+
records = [
201+
_make_chem_record("NMBGMR", "W1", "calcium", 42.0, well_depth=120.0),
202+
_make_chem_record("NMBGMR", "W1", "chloride", 15.0),
203+
_make_chem_record("WQP", "W2", "calcium", 55.0),
204+
]
205+
out = tmp_path / "mc.geojson"
206+
result = dump_major_chemistry_collection(str(out), records, {"id": "nm_major_chemistry"})
207+
208+
assert result["numberReturned"] == 2 # two distinct wells
209+
by_id = {f["id"]: f for f in result["features"]}
210+
211+
w1 = by_id["NMBGMR:W1"]["properties"]
212+
assert w1["calcium"] == 42.0
213+
assert w1["calcium_units"] == "mg/L"
214+
assert w1["calcium_date"] == "2024-05-01"
215+
assert w1["chloride"] == 15.0
216+
assert w1["well_depth"] == 120.0 # carried from the record that had it
217+
218+
w2 = by_id["WQP:W2"]["properties"]
219+
assert w2["calcium"] == 55.0
220+
assert "chloride" not in w2 # missing analyte omitted
221+
222+
def test_geometry_and_required_fields(self, tmp_path):
223+
out = tmp_path / "mc.geojson"
224+
result = dump_major_chemistry_collection(
225+
str(out), [_make_chem_record("NMBGMR", "W1", "sodium", 30.0)], {"id": "nm_major_chemistry"}
226+
)
227+
assert result["type"] == "FeatureCollection"
228+
assert "timeStamp" in result
229+
feat = result["features"][0]
230+
assert feat["geometry"]["coordinates"] == [-106.0, 34.0]

0 commit comments

Comments
 (0)