Proposed.
This ADR proposes adopting the OGC API - Environmental Data Retrieval (EDR) standard as the delivery interface for the Bureau's core observational datasets: groundwater-level measurements (both manual readings and instrument/transducer time series) and water-chemistry analyses.
These datasets are already modeled in this repository as point-located,
time-stamped, parameterized observations tied to a Location geometry. That
shape is exactly what EDR was designed to serve. Adopting EDR gives external
consumers (agencies, researchers, dashboards, other data systems) a single,
standardized, spatiotemporal query interface instead of bespoke per-dataset
REST endpoints, and aligns the project's stated goal of a unified,
interoperable data system (see ADR1).
The recommendation is to add EDR collections to the pygeoapi service that is
already mounted at /ogcapi (see core/pygeoapi.py),
backing them with read-only, publication-filtered database views — the same
pattern the existing OGC API - Features collections already use. The FastAPI
application remains the system of record and the write/QC path.
OGC API - EDR is an OpenAPI-based standard for retrieving environmental data at a position, within an area, at named locations, or over a time span. A consumer does not need to understand the underlying storage. They ask questions like:
- "Give me depth-to-water at this well, from 2020 to 2024."
- "Give me all pH analyses within this polygon."
- "List the transducer deployments recording at this well."
EDR standardizes these as a small set of query patterns over named collections:
/position— data at a point (optionally with a datetime range)/area— data within a polygon/radius— data within a distance of a point/locations— data at named, discrete sites (the natural fit for wells)/instances— sub-series of a collection (the natural fit for a transducer deployment)/cube,/trajectory,/corridor— additional patterns we can defer
Each collection advertises its parameter-names (the measured variables), its spatial and temporal extents, and its output formats. EDR responses are CoverageJSON.
pygeoapi is already running in this application, mounted at /ogcapi by
core/pygeoapi.py. It currently serves OGC API - Features
collections (water_wells, springs, perennial_streams, …), each backed by
an ogc_<id> PostgreSQL view filtered to release_status = 'public'. So the
standards server, the publication-gating pattern, and the config-generation
machinery all exist today.
What is missing is EDR. Features answers "where are the wells?" and returns point geometries with summary attributes; it does not answer "what is the depth-to-water time series at this well between two dates?" as a coverage. EDR is the query model built for that, and this ADR adds it alongside the existing Features collections on the same mount.
The mapping is close to one-to-one, which is the main reason EDR is attractive here rather than in the abstract:
| EDR concept | This repository (staging schema) |
|---|---|
| Location / platform | Thing (thing_type = "water well") sited via Location.point (db/thing.py, db/location.py) |
waterlevels — manual |
Observation where parameter = "groundwater level" (db/observation.py) |
waterlevels — transducer |
TransducerObservation, grouped by TransducerObservationBlock, per Deployment (db/transducer.py, db/deployment.py) |
water_chemistry collection |
Observation tied to a Sample, keyed by Parameter analyte (db/sample.py, db/parameter.py) |
| EDR instance | Deployment — a Sensor install bounded by installation_date/removal_date (db/sensor.py) |
| parameter-names | Parameter.parameter_name (e.g. "groundwater level", "pH", chemistry analytes) |
| datetime axis | Observation.observation_datetime; TransducerObservation.observation_datetime |
| result value + units | Observation.value / TransducerObservation.value; units from Parameter.default_unit (e.g. "ft") |
| publication gate | release_status (ReleaseMixin), exposed only where = 'public' via ogc_* views |
Water levels are a single-parameter (depth-to-water) time series per well. Water
chemistry is multi-parameter: a Sample collected at a well has many
Observation rows, each carrying one Parameter analyte, a value, an
analysis_method, and a unit from Parameter.default_unit. Both fold cleanly
into EDR collections whose primary query pattern is /locations (discrete
wells) with /area and /radius as secondary patterns.
Groundwater levels arrive two ways, from two different tables:
- Manual measurements —
Observationrows (parameter "groundwater level"), optionally linked to theSensor/Sample/AnalysisMethodused; periodic hand readings. - Transducer observations —
TransducerObservationrows: continuous, high-frequency readings from a deployed pressure transducer or logger. Each row references aDeployment(deployment_id) and aParameter, and is grouped for review by aTransducerObservationBlock(start_datetime,end_datetime,review_status,reviewer). ADeploymentrecords theSensor,installation_date,removal_date, andrecording_interval.
The two differ mainly in density and provenance, not in physical quantity —
both are depth-to-water. EDR models this cleanly with instances: each
transducer Deployment becomes an EDR instance of the waterlevels
collection. That preserves per-deployment temporal extent
(installation_date/removal_date), resolution (recording_interval), and
instrument metadata (Sensor.model, Sensor.serial_no) while keeping a single
collection and parameter-name. Consumers can query the whole well series or
drill into one deployment. The dense transducer axis is also the primary
motivation for supporting the /cube and datetime-ranged /position patterns,
not just /locations.
- Interoperability — a published OGC standard beats bespoke endpoints for cross-agency and cross-system consumption. Directly serves the ADR1 goal.
- Fit to data — the data is already point + time + parameter; EDR is built for exactly that. Minimal impedance mismatch.
- Reuse existing infrastructure — pygeoapi, the
/ogcapimount, theogc_*publication-view pattern, and the config generator are already in production for Features. EDR extends them rather than standing up something new. - Separation of concerns — keep FastAPI as the authoritative write/QC path; expose a read-only, cacheable query surface for delivery.
- Incremental adoption — start with two collections and the most useful query patterns; expand later without breaking the contract.
Extend core/pygeoapi.py with EDR collection definitions
(alongside THING_COLLECTIONS) for waterlevels and water_chemistry, each
using an EDR provider over publication-filtered ogc_* views/materialized
views. Same server, same mount, same gating pattern as Features today.
- Pros: standards-compliant EDR (query patterns, CoverageJSON, OpenAPI,
conformance) with no new service; reuses the deployment, config generation,
and
release_status='public'view convention already in place; keeps the write path untouched. - Cons: pygeoapi's built-in EDR providers target gridded/xarray data, so an
observational PostgreSQL-backed EDR provider (or a thin custom provider)
is needed to serve point/time-series coverages from the relational schema;
bridging
Thing/Observation/TransducerObservation/Deploymentinto EDR collections and instances requires purpose-built read views.
Implement the EDR query patterns directly as FastAPI routes and hand-roll CoverageJSON serialization.
- Pros: full control over query translation; reuse of existing SQLAlchemy models and helpers; no dependence on pygeoapi's EDR provider maturity.
- Cons: reimplements a spec pygeoapi already largely provides; ongoing
burden to stay conformant (query-parameter parsing, CoverageJSON, OpenAPI /
conformance docs, edge cases); a second OGC surface to keep consistent with
the
/ogcapiFeatures mount. Highest long-term maintenance cost.
Publish observations as feature collections and let consumers filter.
- Pros: already deployed; no new work.
- Cons: Features is not EDR — no position/area/time query semantics, no parameter/coverage model, no CoverageJSON. Poor fit for time-series retrieval; pushes filtering and reshaping onto every client. Rejected as the primary delivery mechanism for observations.
Continue serving observations through the existing FastAPI observation routes.
- Pros: zero new work.
- Cons: no standardization, no interoperability, every consumer integrates against a custom contract. Fails the ADR1 unification goal.
Adopt Option A: expose water-level and water-chemistry data through OGC
API - EDR collections added to the existing pygeoapi /ogcapi mount, backed
by read-only, publication-filtered database views.
Scope for the first iteration:
- Collections:
waterlevels(manual + transducer) andwater_chemistry, registered next to the current Features collections in core/pygeoapi.py. - Backing views:
ogc_waterlevelsandogc_water_chemistry(Alembic-managed, following the existingogc_<id>convention), each filtered torelease_status = 'public'. - Query patterns:
/locations(primary),/areaand/radius(secondary), plus/collectionsmetadata./instancesfor transducer deployments, and datetime-ranged/position+/cubefor dense transducer series. - Manual + transducer merge: a collection-level query at a well returns the
merged depth-to-water series —
Observation(manual) andTransducerObservation(instrument) readings on a single time axis. Transducer data is visible without the consumer needing to know instances exist. - Instances: each transducer
Deploymentis also exposed as an EDR instance ofwaterlevels, carrying its temporal extent (installation_date/removal_date), resolution (recording_interval), and instrument metadata (Sensor.model,Sensor.serial_no). Instances are the drill-down path to isolate one deployment; they do not hide data from the merged series. - Parameter-names: taken from
Parameter.parameter_name.waterlevelsexposes the single "groundwater level" parameter (manual and transducer share it; measurement method is carried as metadata / instance, not a separate parameter).water_chemistryexposes one parameter per analyte present. - Output format: CoverageJSON.
- CRS / units: CRS84 / EPSG:4326 (consistent with
Location.pointand the bbox the mount already advertises); units declared per parameter fromParameter.default_unit. - Boundary: EDR is read-only. All writes, validation, and QC stay in the
FastAPI application. Only
release_status = 'public'records are published, enforced at theogc_*view layer so it cannot be bypassed.
FastAPI remains the system of record. pygeoapi owns the OGC read surface — Features today, plus EDR after this ADR.
- One standardized, self-describing spatiotemporal interface for the two most requested observational datasets, on infrastructure already in production.
- Consumers use off-the-shelf EDR clients; no custom SDK required.
- Publication gating reuses the proven
ogc_*/release_status='public'view pattern, so public/private handling is consistent with Features. - Clean separation: authoritative write path (FastAPI) vs. cacheable read path (pygeoapi/EDR), which also reinforces the read/write split discussed in ADR2.
- Extensible: further collections (e.g. geothermal, geochronology) can follow the same pattern later.
- An observational PostgreSQL-backed EDR provider is likely required, since pygeoapi's bundled EDR providers target gridded data rather than relational point/time-series.
- Purpose-built
ogc_*read views/materialized views are needed to bridge the normalized schema (Thing→Observation/TransducerObservation/Deployment;Sample→Observation) into EDR collections and instances. - More surface area in the generated pygeoapi config and its Alembic-managed backing views to maintain as the schema evolves.
- EDR provider choice — confirm whether a community/relational EDR provider
can be configured, or whether a thin custom provider must be written to emit
CoverageJSON from the
ogc_*views. - Schema bridging —
Thing → Observation,Thing → TransducerObservation (via Deployment/Block), andSample → Observationare joins, not flat tables. Decide the exactogc_waterlevels/ogc_water_chemistryview shape (plain vs. materialized); materialized views likely for the dense transducer data. - Chemistry parameter cardinality — the number of
Parameteranalytes with chemistryObservations drives the parameter list; confirm it is bounded and lexicon-governed before exposing every analyte as a parameter-name. - Transducer volume and cadence — continuous
TransducerObservationseries can be large and dense. Confirm response paging/limits, decide default vs. maximum datetime windows, and consider server-side decimation/aggregation for wide/cubequeries. High-frequency reads are the strongest case for caching. - Manual vs. transducer disambiguation — manual readings come from
Observation, transducer readings fromTransducerObservation; the mergedogc_waterlevelsview unions the two. Decided: a collection-level query at a well returns the merged series (manual + transducer on one time axis); instances remain available to isolate a singleDeployment(see Decision). - Depth-to-water and
measuring_point_height—Observation.valueplusmeasuring_point_heightdetermine reported depth/elevation; nulls need a documented policy (tracked alongside the OGC water-level view work). The EDR view must apply the same convention as the Features water-level layers. - Units and vocabularies — declare EDR parameter units and definitions from
Parameter.default_unit/ the lexicon so the standard's parameter metadata stays truthful.
GET /ogcapi/collectionslistswaterlevelsandwater_chemistryalongside the existing Features collections, with correct spatial extents, temporal extents, and parameter-names.GET /ogcapi/collections/waterlevels/locations/{thingId}?datetime=...returns CoverageJSON depth-to-water for a real well over a bounded time range, covering both manual and transducer readings.GET /ogcapi/collections/waterlevels/instanceslists each transducer deployment as an EDR instance. (Note: pygeoapi 0.23.4's Starlette app routes the generic.../area|locations|...query patterns ahead of their.../instances/{id}/...counterparts becausecollection_idis matched as a greedy path, so instance-scoped data queries are not currently served; instance discovery is. Tracked as a follow-up / upstream limitation.)GET /ogcapi/collections/water_chemistry/area?coords=...¶meter-name=...returns the expected analyses for a polygon, filtered by analyte.- Only
release_status = 'public'records appear in EDR responses. GET /ogcapi/conformanceadvertises the OGC API - EDR conformance classes.- The EDR collections pass a read-only smoke test against production-shaped data.
These criteria are pinned as an executable spec in
tests/features/edr-water-data.feature
(tagged @edr @wip until the collections are implemented).
- Related: ADR1 (unification goal) and ADR2 (API concurrency — the read/write split here reinforces that direction).
- This ADR decides direction and boundaries, not a file-by-file implementation
plan. The EDR provider choice, exact
ogc_*view definitions, and config wiring in core/pygeoapi.py are follow-up work.