fix(edr): source water-chemistry EDR from the legacy NMA tables - #827
Merged
Conversation
The water_chemistry EDR collection is advertised in /ogcapi/collections and returns an empty FeatureCollection on staging. The views behind it (z9a0b1c2d3e4, mirrored by 2d3c3a268652) read the normalized chain -- observation -> sample -> field_activity -> field_event -> thing -- and nothing populates that chain with analyte data. Per docs/chemistry-ingestion-runbook.md the live ingestion path writes only to the legacy NMA_* tables, which is why ogc_major_chemistry_results and ogc_minor_chemistry_wells serve thousands of rows from the same database. This repoints both EDR chemistry views at the legacy tables at the per-result grain EDR needs, unioning the four families that hang off NMA_Chemistry_SampleInfo: major, minor/trace, radionuclides, and field parameters. Field parameters carry no analysis date of their own and ride on the sample's CollectionDate; rows that end up with no timestamp are dropped, since EDR needs a time axis. Interim by design. When chemistry reaches the normalized model the views move back and the EDR contract does not change -- same collection, same parameter-names, same CoverageJSON. downgrade() restores the normalized definitions by importing them from the revisions that own them rather than copying, so they cannot drift. Three deliberate differences from the pivot views, documented in the revision: no thing_type filter (chemistry at a spring is still chemistry); publication gated on thing.release_status plus NMA_Chemistry_SampleInfo."PublicRelease" not being explicitly false; and parameter_name taken as raw trimmed analyte text rather than canonicalized, which leaves ADR3's chemistry-cardinality question open but reachable. Verified against ocotilloapi_test with seeded rows across all four families: public view returns the public well and the spring, excludes a draft thing and a PublicRelease = false sample, and drops a NULL-analyte row; internal mirror returns those two extra rows. The provider's own queries (_read projection, get_fields DISTINCT, bbox/WKT/datetime predicates) all run against the view, and a downgrade/upgrade cycle restores each definition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two follow-ups to the legacy-backed chemistry views. thing_type is now a column on both views and, when the backing relation has it, a property on the /locations features the provider returns. Since these views deliberately carry no thing_type filter -- chemistry collected at a spring is still chemistry -- a consumer otherwise had no way to tell a well from a spring. ogc_waterlevels has no such column; the provider detects it rather than assuming, so that collection is untouched. Both views become MATERIALIZED, matching ogc_major_chemistry_results and ogc_minor_chemistry_wells. As plain views, every request re-planned a four-way UNION over the full legacy result tables, and get_fields() runs SELECT DISTINCT parameter_name, unit at provider construction -- a full scan per request. The staging and production tables are already well past the point where that is affordable. Indexes cover the provider's three filter columns, and the unique index on id allows CONCURRENTLY refreshes. Freshness now matches the other chemistry layers: the nightly pg_cron job discovers materialized views from the catalog, and both are registered in services/materialized_views.py for `oco refresh-materialized-views` after an ad-hoc ingestion. Column detection reads pg_attribute, not information_schema.columns, which does not list materialized views -- detection silently returned False against the materialized views until this was caught end-to-end. Verified against ocotilloapi_test: both relations are relkind 'm' with the four expected indexes; CONCURRENTLY refresh succeeds and picks up new rows; the provider reports thing_type on the chemistry matview, omits it on the ogc_waterlevels view, and returns False rather than raising for a missing relation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
water_chemistryis advertised in/ogcapi/collectionsand returns nothing on staging:Not a missing view this time — the view exists and the query runs. It reads a model the data isn't in.
ogc_water_chemistrysources the normalized chain (observation → sample → field_activity → field_event → thing), but per docs/chemistry-ingestion-runbook.md the live ingestion path writes only to the legacy tables:Which is why the sibling layers, on the same database, are full:
major_chemistry_resultsNMA_MajorChemistryminor_chemistry_wellsNMA_MinorTraceChemistrywater_chemistry(EDR)observation+sampleApproach
Repoint both EDR chemistry relations — public and internal mirror — at the legacy tables, at the per-result grain EDR needs. The existing pivot views are per-well summaries (one row per thing,
analyte_count, latest date), so they aren't reusable; this goes to the base tables and unions the four families hanging offNMA_Chemistry_SampleInfo:NMA_MajorChemistry"Analyte"/"Symbol""SampleValue""Units""AnalysisDate"NMA_MinorTraceChemistryanalyte/symbolsample_valueunitsanalysis_dateNMA_Radionuclides"Analyte"/"Symbol""SampleValue""Units""AnalysisDate"NMA_FieldParameters"FieldParameter""SampleValue""Units"CollectionDate)Radionuclides and field parameters aren't in any current layer; they're the same shape, so including them brings pH, temperature, and conductivity into EDR at no extra cost. Rows that end up with no timestamp are dropped; EDR needs a time axis.
Interim by design. When chemistry reaches the normalized model, the relations move back and the EDR contract doesn't change — same collection, parameter-names, CoverageJSON.
downgrade()restores the normalized definitions by importing them from the revisions that own them (z9a0b1c2d3e4,2d3c3a268652) rather than copying, so they can't drift.Materialized, not plain views
Both are
MATERIALIZED, matchingogc_major_chemistry_resultsandogc_minor_chemistry_wells. As plain views, every request re-planned a four-way UNION over the full legacy result tables — andget_fields()runsSELECT DISTINCT parameter_name, unitat provider construction, i.e. a full scan per request. Staging and production are already well past the size where that's affordable.id→REFRESH MATERIALIZED VIEW CONCURRENTLYworks.thing_id,datetime,parameter_name→ the provider's three filter columns.services/materialized_views.pyforoco refresh-materialized-viewsafter an ad-hoc ingestion.thing_type
These relations deliberately carry no
thing_typefilter, sothing_typeis exposed as a column and surfaces as a property on/locationsfeatures — otherwise a consumer couldn't tell a well from a spring.ogc_waterlevelshas no such column, so the provider detects it instead of assuming; that collection is unchanged.Detection reads
pg_attribute, notinformation_schema.columns— the latter doesn't list materialized views at all, and silently reported "no thing_type" until this was caught end-to-end.Three deliberate differences from the pivot views
thing_typefilter.ogc_major_chemistry_resultsrestricts to'water well'because it's a wells layer. This is a chemistry collection — chemistry collected at a spring belongs in it.thing.release_status = 'public'(the conventionf4a5b6c7d8e9set for legacy-backed views) plusNMA_Chemistry_SampleInfo."PublicRelease"not being explicitly false. The pivot views ignorePublicRelease; honouring it errs toward withholding, and NULL counts as "not suppressed" so the layers agree on rows carrying no opinion.parameter_nameis raw trimmed analyte text, falling back to symbol. The pivot views canonicalize through long CASE blocks that only cover the analytes they expose as columns. Raw text keeps every analyte reachable, at the cost of aliases surfacing separately (CaandCalciumboth appear). That's ADR3's open chemistry-cardinality question — canonicalizing is follow-up and changes only the vocabulary, not this plumbing.Verification
Seeded
ocotilloapi_testacross all four families, then removed the fixtures:thingPublicRelease = falseField-parameter row correctly picked up the sample's
CollectionDateas its timestamp.Also confirmed: both relations are
relkind 'm'with the four expected indexes;REFRESH ... CONCURRENTLYsucceeds and picks up newly inserted rows; the provider's own SQL runs against them (_readprojection,get_fieldsDISTINCT, bbox /ST_Intersects/ datetime predicates);thing_typeappears on chemistry/locationsfeatures and is absent — without error — onogc_waterlevels; a missing relation yieldsFalserather than raising; and adowngrade→upgradecycle restores each definition.tests/test_edr_provider.py(new),test_cli_commands.py,test_migration_view_parity.py,test_pygeoapi_mount.py— 34 passed.After deploy
Chemistry EDR is materialized, so it is populated by the migration and refreshed nightly:
🤖 Generated with Claude Code