Skip to content

Commit ad37f78

Browse files
authored
Merge pull request #827 from DataIntegrationGroup/fix/edr-water-chemistry-legacy-source
fix(edr): source water-chemistry EDR from the legacy NMA tables
2 parents 395a63c + a102ad4 commit ad37f78

5 files changed

Lines changed: 405 additions & 3 deletions

File tree

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
"""rebuild the EDR water-chemistry views on the legacy NMA chemistry tables
2+
3+
ogc_water_chemistry (z9a0b1c2d3e4) and its internal mirror (2d3c3a268652) read
4+
the normalized chain -- observation -> sample -> field_activity -> field_event
5+
-> thing. Nothing populates that chain with analyte data: per
6+
docs/chemistry-ingestion-runbook.md, the live ingestion path
7+
(services/chemistry_lims.py, services/chemistry_drive.py, `oco water-chemistry
8+
bulk-upload`) writes only to the legacy NMA_* tables. So the EDR collection is
9+
advertised in /ogcapi/collections and returns an empty FeatureCollection, while
10+
ogc_major_chemistry_results and ogc_minor_chemistry_wells -- both built on the
11+
same legacy tables -- serve thousands of rows.
12+
13+
This revision repoints both EDR chemistry views at the legacy tables, at the
14+
per-result grain EDR needs (one row per analyte measurement, not the per-well
15+
summary the pivot views produce). Four families are unioned, all sharing the
16+
same shape via NMA_Chemistry_SampleInfo:
17+
18+
NMA_MajorChemistry "Analyte"/"Symbol", "SampleValue", "Units"
19+
NMA_MinorTraceChemistry analyte/symbol, sample_value, units
20+
NMA_Radionuclides "Analyte"/"Symbol", "SampleValue", "Units"
21+
NMA_FieldParameters "FieldParameter", "SampleValue", "Units"
22+
23+
This is interim. When chemistry lands in the normalized Sample/Observation
24+
model, the views move back and the EDR contract does not change -- consumers
25+
see the same collection, parameter-names, and CoverageJSON either way.
26+
27+
Three deliberate differences from the pivot views, each of which would
28+
otherwise be a silent surprise:
29+
30+
* No thing_type filter. ogc_major_chemistry_results restricts to
31+
thing_type = 'water well' because it is a wells layer; this is a chemistry
32+
collection, so chemistry collected at a spring belongs in it. thing_type is
33+
carried as a column instead, so a consumer can tell a well from a spring
34+
rather than having the distinction silently dropped -- the EDR provider
35+
surfaces it on /locations features when the backing view has the column.
36+
* Publication is gated on thing.release_status = 'public' (the convention
37+
f4a5b6c7d8e9 established for the legacy-backed views) AND on
38+
NMA_Chemistry_SampleInfo."PublicRelease" not being explicitly false. The
39+
pivot views ignore PublicRelease; honouring it here errs toward
40+
withholding, and NULL is treated as "not suppressed" so the two layers stay
41+
consistent on the rows that carry no opinion.
42+
* parameter_name is the raw trimmed legacy analyte text, falling back to the
43+
symbol. The pivot views canonicalize analytes through long CASE blocks, but
44+
those cover only the subset they expose as columns. Raw text keeps every
45+
analyte reachable at the cost of aliases appearing as separate
46+
parameter-names ("Ca" and "Calcium" both surface). That is ADR3's open
47+
"chemistry parameter cardinality" question; canonicalizing is follow-up work
48+
and changes only the parameter-name vocabulary, not this plumbing.
49+
50+
Rows without a usable timestamp are dropped: EDR needs a time axis, and
51+
COALESCE(analysis date, collection date) is the best available. Field
52+
parameters carry no analysis date of their own, so they ride on the sample's
53+
CollectionDate.
54+
55+
Both are MATERIALIZED views, matching ogc_major_chemistry_results and
56+
ogc_minor_chemistry_wells. A plain view would be re-planned on every request
57+
across a four-way UNION of the full legacy result tables, and the provider's
58+
get_fields() runs SELECT DISTINCT parameter_name, unit at provider
59+
construction -- a full scan per request, against tables that already hold far
60+
more than the pivot views' per-well row counts suggest. Indexes cover the
61+
provider's three filter columns (thing_id, datetime, parameter_name), and the
62+
unique index on id is what allows CONCURRENTLY refreshes.
63+
64+
The cost is staleness: the nightly pg_cron job discovers every materialized
65+
view from the catalog (x2y3z4a5b6c7), so these refresh with the rest, and
66+
services/materialized_views.py lists them for `oco refresh-materialized-views`
67+
after an ad-hoc chemistry ingestion. That is the same freshness contract the
68+
existing chemistry layers already have.
69+
70+
Revision ID: d9e0f1a2b3c4
71+
Revises: b7c8d9e0f1a2
72+
Create Date: 2026-08-13 15:40:00.000000
73+
"""
74+
75+
import importlib.util
76+
from pathlib import Path
77+
from typing import Sequence, Union
78+
79+
from alembic import op
80+
from sqlalchemy import inspect, text
81+
82+
# revision identifiers, used by Alembic.
83+
revision: str = "d9e0f1a2b3c4"
84+
down_revision: Union[str, Sequence[str], None] = "b7c8d9e0f1a2"
85+
branch_labels: Union[str, Sequence[str], None] = None
86+
depends_on: Union[str, Sequence[str], None] = None
87+
88+
REQUIRED_TABLES = {
89+
"NMA_Chemistry_SampleInfo",
90+
"NMA_MajorChemistry",
91+
"NMA_MinorTraceChemistry",
92+
"NMA_Radionuclides",
93+
"NMA_FieldParameters",
94+
"thing",
95+
"location",
96+
"location_thing_association",
97+
}
98+
99+
PUBLIC_VIEW = "ogc_water_chemistry"
100+
INTERNAL_VIEW = "ogc_internal_water_chemistry"
101+
102+
VIEW_COMMENTS = {
103+
PUBLIC_VIEW: (
104+
"Public water-chemistry analyses (by analyte) for EDR, sourced from "
105+
"the legacy NMA chemistry tables."
106+
),
107+
INTERNAL_VIEW: (
108+
"All water-chemistry analyses (by analyte) for internal EDR, sourced "
109+
"from the legacy NMA chemistry tables."
110+
),
111+
}
112+
113+
# Same latest-location shape the other ogc_* views use (d5e6f7a8b9c0).
114+
_LATEST_LOCATION_CTE = """
115+
SELECT DISTINCT ON (lta.thing_id)
116+
lta.thing_id,
117+
lta.location_id,
118+
lta.effective_start
119+
FROM location_thing_association AS lta
120+
WHERE lta.effective_end IS NULL
121+
ORDER BY lta.thing_id, lta.effective_start DESC
122+
"""
123+
124+
125+
def _result_family(
126+
*,
127+
id_prefix: str,
128+
table: str,
129+
analyte_column: str,
130+
value_column: str,
131+
unit_column: str,
132+
date_column: str | None,
133+
) -> str:
134+
"""One SELECT over a legacy chemistry table, normalized to a common shape.
135+
136+
``date_column`` is None for NMA_FieldParameters, which has no analysis
137+
date of its own and falls back to the sample's CollectionDate.
138+
"""
139+
observed_at = (
140+
f'COALESCE(r.{date_column}, csi."CollectionDate")'
141+
if date_column
142+
else 'csi."CollectionDate"'
143+
)
144+
return f"""
145+
SELECT
146+
'{id_prefix}-' || r.id AS id,
147+
csi.id AS sample_id,
148+
csi.thing_id AS thing_id,
149+
csi."PublicRelease" AS sample_public_release,
150+
{observed_at} AS datetime,
151+
r.{value_column}::double precision AS value,
152+
r.{unit_column} AS unit,
153+
NULLIF(trim({analyte_column}), '') AS parameter_name
154+
FROM "{table}" AS r
155+
JOIN "NMA_Chemistry_SampleInfo" AS csi
156+
ON csi.id = r.chemistry_sample_info_id
157+
WHERE r.{value_column} IS NOT NULL
158+
"""
159+
160+
161+
def _result_families() -> str:
162+
families = [
163+
_result_family(
164+
id_prefix="maj",
165+
table="NMA_MajorChemistry",
166+
analyte_column='COALESCE(r."Analyte", r."Symbol")',
167+
value_column='"SampleValue"',
168+
unit_column='"Units"',
169+
date_column='"AnalysisDate"',
170+
),
171+
_result_family(
172+
id_prefix="min",
173+
table="NMA_MinorTraceChemistry",
174+
analyte_column="COALESCE(r.analyte, r.symbol)",
175+
value_column="sample_value",
176+
unit_column="units",
177+
date_column="analysis_date",
178+
),
179+
_result_family(
180+
id_prefix="rad",
181+
table="NMA_Radionuclides",
182+
analyte_column='COALESCE(r."Analyte", r."Symbol")',
183+
value_column='"SampleValue"',
184+
unit_column='"Units"',
185+
date_column='"AnalysisDate"',
186+
),
187+
_result_family(
188+
id_prefix="fld",
189+
table="NMA_FieldParameters",
190+
analyte_column='r."FieldParameter"',
191+
value_column='"SampleValue"',
192+
unit_column='"Units"',
193+
date_column=None,
194+
),
195+
]
196+
return "\n UNION ALL\n".join(families)
197+
198+
199+
def _create_water_chemistry_view(view_name: str, public_only: bool) -> str:
200+
release_filter = (
201+
"""
202+
AND t.release_status = 'public'
203+
AND results.sample_public_release IS NOT FALSE"""
204+
if public_only
205+
else ""
206+
)
207+
return f"""
208+
CREATE MATERIALIZED VIEW {view_name} AS
209+
WITH latest_location AS (
210+
{_LATEST_LOCATION_CTE}
211+
),
212+
results AS (
213+
{_result_families()}
214+
)
215+
SELECT
216+
results.id AS id,
217+
t.id AS thing_id,
218+
t.name AS station_name,
219+
t.thing_type AS thing_type,
220+
ST_X(l.point) AS longitude,
221+
ST_Y(l.point) AS latitude,
222+
results.datetime AS datetime,
223+
results.value AS value,
224+
results.unit AS unit,
225+
results.parameter_name AS parameter_name,
226+
results.sample_id AS sample_id,
227+
t.release_status AS release_status
228+
FROM results
229+
JOIN thing AS t ON t.id = results.thing_id
230+
JOIN latest_location AS ll ON ll.thing_id = t.id
231+
JOIN location AS l ON l.id = ll.location_id
232+
WHERE results.parameter_name IS NOT NULL
233+
AND results.datetime IS NOT NULL{release_filter}
234+
"""
235+
236+
237+
def _load_revision_module(filename: str, module_name: str):
238+
path = Path(__file__).with_name(filename)
239+
if not path.exists():
240+
raise RuntimeError(
241+
f"Cannot restore the previous EDR chemistry views: {filename} is "
242+
"missing from alembic/versions."
243+
)
244+
spec = importlib.util.spec_from_file_location(module_name, path)
245+
module = importlib.util.module_from_spec(spec)
246+
spec.loader.exec_module(module)
247+
return module
248+
249+
250+
def _drop_view_or_materialized_view(view_name: str) -> None:
251+
# DROP VIEW IF EXISTS only suppresses "relation does not exist" -- Postgres
252+
# still raises WrongObjectType if the relation is a materialized view, so
253+
# check the actual kind first.
254+
bind = op.get_bind()
255+
relkind = bind.execute(
256+
text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"),
257+
{"name": view_name},
258+
).scalar()
259+
if relkind == "m":
260+
op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}"))
261+
elif relkind == "v":
262+
op.execute(text(f"DROP VIEW IF EXISTS {view_name}"))
263+
264+
265+
def _check_required_tables() -> None:
266+
bind = op.get_bind()
267+
inspector = inspect(bind)
268+
existing = set(inspector.get_table_names(schema="public"))
269+
missing = REQUIRED_TABLES - existing
270+
if missing:
271+
raise RuntimeError(
272+
"Cannot rebuild the EDR water-chemistry views. Missing required "
273+
f"tables: {sorted(missing)}"
274+
)
275+
276+
277+
def _create_indexes(view_name: str) -> None:
278+
# The unique index is what lets REFRESH MATERIALIZED VIEW CONCURRENTLY run
279+
# (`oco refresh-materialized-views --concurrently`); Postgres refuses
280+
# without one. id is unique by construction -- each family prefixes its own
281+
# primary key.
282+
op.execute(text(f"CREATE UNIQUE INDEX ux_{view_name}_id ON {view_name} (id)"))
283+
# The provider filters on thing_id (locations / position), datetime
284+
# (interval), and parameter_name (parameter-name), so each gets an index.
285+
op.execute(text(f"CREATE INDEX ix_{view_name}_thing_id ON {view_name} (thing_id)"))
286+
op.execute(text(f"CREATE INDEX ix_{view_name}_datetime ON {view_name} (datetime)"))
287+
op.execute(
288+
text(
289+
f"CREATE INDEX ix_{view_name}_parameter_name "
290+
f"ON {view_name} (parameter_name)"
291+
)
292+
)
293+
294+
295+
def upgrade() -> None:
296+
_check_required_tables()
297+
298+
for view_name, public_only in ((PUBLIC_VIEW, True), (INTERNAL_VIEW, False)):
299+
_drop_view_or_materialized_view(view_name)
300+
op.execute(text(_create_water_chemistry_view(view_name, public_only)))
301+
_create_indexes(view_name)
302+
op.execute(
303+
text(
304+
f"COMMENT ON MATERIALIZED VIEW {view_name} IS "
305+
f"'{VIEW_COMMENTS[view_name]}'"
306+
)
307+
)
308+
309+
310+
def downgrade() -> None:
311+
# Restore the normalized-model definitions from the revisions that own
312+
# them, rather than a copy that could drift from those files.
313+
edr = _load_revision_module(
314+
"z9a0b1c2d3e4_add_edr_water_views.py", "_edr_water_views"
315+
)
316+
internal = _load_revision_module(
317+
"2d3c3a268652_create_internal_ogc_views.py", "_internal_ogc_views"
318+
)
319+
320+
_drop_view_or_materialized_view(PUBLIC_VIEW)
321+
op.execute(text(edr._create_water_chemistry_view()))
322+
op.execute(
323+
text(
324+
"COMMENT ON VIEW ogc_water_chemistry IS "
325+
"'Public water-chemistry analyses (by analyte) for EDR.'"
326+
)
327+
)
328+
329+
_drop_view_or_materialized_view(INTERNAL_VIEW)
330+
op.execute(text(internal._create_internal_water_chemistry_view()))

0 commit comments

Comments
 (0)