Skip to content

chore(ogc): rich layer and field-level metadata - #872

Merged
jirhiker merged 8 commits into
chore/bdms-977-979-hide-ogc-layersfrom
chore/ogc-rich-layer-metadata
Aug 22, 2026
Merged

chore(ogc): rich layer and field-level metadata#872
jirhiker merged 8 commits into
chore/bdms-977-979-hide-ogc-layersfrom
chore/ogc-rich-layer-metadata

Conversation

@jirhiker

@jirhiker jirhiker commented Aug 22, 2026

Copy link
Copy Markdown
Member

Stacked on #871 — base is chore/bdms-977-979-hide-ogc-layers, so this diff excludes the layer-hiding work. GitHub retargets it to staging once #871 merges.

Two layers of metadata, in one branch because the second is meaningless without the first.

1. Collection-level prose

Every collection carried a one-line description that mostly restated its title. "Trend classification for depth to water based on slope in feet per year" tells a consumer nothing about which measurements went in, how the slope was fitted, or when the answer can be trusted. Keywords were often three tokens echoing the layer id.

All 27 collection entries rewritten — 14 in core/pygeoapi-config.yml, 11 in core/pygeoapi-config-internal.yml, 11 thing-type + 2 EDR in core/pygeoapi.py — each saying how the layer is derived and what it is for, written against the view SQL rather than paraphrased from the old copy.

Things a consumer could not previously discover: that an increasing depth-to-water trend means the water table is falling; that the chemistry pivots keep the latest result per analyte, so one well's analytes can carry different dates; that actively_monitored_wells needs both network membership and a current status; that the geothermal source records mix Fahrenheit and Celsius.

2. Field-level descriptions

Below the collection there was nothing at all — a client asking what depth_to_water_bgs or hours_since_circulation means got back a column name and a JSON type.

core/ogc-field-descriptions.yml now holds a title, description, and unit for every column of every published collection, served through the standard endpoints:

Endpoint Carries
GET /collections/{id}/schema title, description, x-ogc-unit, x-ogc-unitLang, enum
GET /collections/{id}/queryables the same
EDR CoverageJSON parameters observedProperty.label and description
"well_depth": {
  "type": "number",
  "title": "Well depth",
  "description": "Total depth of the finished well, from ground surface to the bottom of the well.",
  "x-ogc-unit": "https://qudt.org/vocab/unit/FT",
  "x-ogc-unitLang": "QUDT"
}

Keyed by backing relation with the ogc_/ogc_internal_ prefix stripped, so both mounts share one entry per view. A _defaults block covers the shared columns — the 11 thing-type views have one identical 18-column signature between them. The 190 chemistry analyte columns are generated by cli/generate_chemistry_field_descriptions.py and hand-reviewed.

Enumerated values

pygeoapi's HTML schema view renders a Values column from each property's enum, and it was empty for every collection — the SQL provider reports only type and format and implements no get_domains(). Entries can now set it either literally (for values the view's own SQL decides, like trend_category's four CASE outcomes) or via enum-lexicon:, naming a category in core/lexicon.json that is expanded at read time so the vocabulary is never copied.

Deliberately not populated from SELECT DISTINCT: enum is a JSON Schema constraint, not a sample, and a value absent from today's data is not thereby invalid.

What this leans on inside pygeoapi

The feature depends on five unpinned behaviours of the pinned 0.24.0 (which is the current PyPI latest — there is no upgrade available). All are written down with file and line in docs/ogc-field-descriptions.md. Two bit during implementation:

  • BaseProvider.fields returns self._fields and never calls get_fields(), while GenericSQLProvider.__init__ populates it at construction. A get_fields() override that only returned an annotated copy would have been silently discarded and shipped nothing. The provider writes back into _fields.
  • get_collection_schema mutates the provider's field dicts in place while building its response. Both providers therefore hand out fresh dicts; the EDR one did not, and its cache was accumulating x-ogc-role keys across requests — fixed here.

/schema needs no patch because pygeoapi copies provider field entries wholesale. /queryables rebuilds each property and hardcodes 'title': k, so core/pygeoapi_patches.py wraps it and merges the documentation back in, yielding to any enum pygeoapi produced itself so ?profile=actual-domain still wins where a provider supports it.

Tests

tests/test_ogc_field_metadata.py (unit, no database) and tests/test_ogc_field_descriptions.py (both endpoints, both mounts, JSON and HTML, the internal-only collection, EDR, fallback). Two guards earn their keep:

  • Drift guard — fails when a published column has no YAML entry, so a matview column rename breaks CI instead of quietly degrading the API. Currently zero gaps. EDR collections are exempt: their fields are analyte names read from data, not columns.
  • Upgrade guard — asserts pygeoapi still assigns the provider's field entry into the schema response. A bump that rebuilds the dict instead, the way get_collection_queryables already does, would silently drop every description.

geometry is excluded from the "every property is documented" assertions: pygeoapi injects it after the provider's fields with only a format and a role.

Notes for review

  • The three water-level layers state that a reading with no recorded measuring-point height is treated as taken at ground level. That is what the SQL does today — this PR describes it and does not decide the policy, which is BDMS-980 (A22).
  • core/pygeoapi-config-internal.yml gets the same collection prose, and the two internal-only layers say why they are not public: avg_tds_wells warns its mean rests on ~1.9 analyses per well, latest_depth_to_water_wells points at water_well_summary.
  • No Jira ticket covers layer or field metadata; happy to file one if it should be tracked.

Verification

  • uv run pytest --ignore=tests/transfers → 1094 passed, 81 skipped, 6 xpassed
  • uv run behave tests/features/ogc-cleanup-sprint1.feature --tags="@backend and @production and not @skip" → 20 passed, 0 failed
  • pre-commit (black, flake8) clean
  • OpenAPI generation confirmed working against an unreachable database, so the app still starts before the views exist
  • Verified live in the local Docker stack on both mounts

🤖 Generated with Claude Code

jirhiker and others added 8 commits August 22, 2026 08:56
Every collection carried a one-line description that mostly restated its
title -- "Trend classification for depth to water based on slope in feet
per year" tells a consumer nothing about which measurements went in, how
the slope was fitted, or when the answer is trustworthy. Keywords were
similarly thin, often three tokens echoing the layer id.

Each description now says, in plain language, how the layer is derived
and what it is for. The derivation text was written against the view SQL,
so the derived layers state the things a consumer cannot otherwise see:

* depth_to_water_trend_wells classifies at +/- 0.25 ft/yr and reports
  "not enough data" below 10 readings (or 4 readings under two years),
  and an increasing trend means the water table is falling.
* water_elevation_wells subtracts depth-to-water from surveyed ground
  elevation, after converting metric readings to feet.
* The chemistry pivots normalize inconsistent legacy analyte names onto
  one canonical set and keep the latest result per analyte, so analytes
  at one well can carry different sampling dates.
* actively_monitored_wells requires both Water Level Network membership
  and a current "Currently monitored" status.
* The three water-level layers state that a reading with no recorded
  measuring-point height is treated as taken at ground level. That is
  what the SQL does today; whether it is the right policy is BDMS-980
  (A22) and is not decided here.
* The geothermal layers explain BHT, temperature-depth profiles, heat
  flow and drill stem tests for readers who have not met the terms, and
  note the Fahrenheit/Celsius mixing the source records carry.

The internal mount's copy of each layer gets the same text, and the two
internal-only layers say why they are not public: avg_tds_wells warns
that its mean rests on ~1.9 analyses per well, and
latest_depth_to_water_wells points at water_well_summary.

test_every_collection_description_explains_the_layer holds the floor:
substantive length, no placeholder wording, complete sentences, and
lowercase hyphenated keyword tokens. The token check is not cosmetic --
YAML folds a line break into a space, so a hyphenated word wrapped
across lines reaches consumers as "measuring- point".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collection-level prose landed in the previous commit; below it there is
still nothing. A client asking what depth_to_water_bgs or
hours_since_circulation means gets back a column name and a JSON type.

core/ogc-field-descriptions.yml holds a title, a description, and a unit
where one applies, for every column of every published collection. It is
keyed by backing relation with the ogc_/ogc_internal_ prefix stripped, so
the two mounts share one entry per view and the provider can look itself
up from self.table with no new plumbing. A _defaults block covers the
columns shared across views -- the 11 thing-type views have one identical
18-column signature between them.

Rejected: COMMENT ON COLUMN on the ogc_* views. It would put the prose
next to the data, but every wording fix would need an Alembic revision
and a matview rebuild, and pygeoapi's reflection does not read comments,
so a catalog query would be needed regardless.

describe_fields() returns fresh dicts rather than references into the
cached YAML. That is load-bearing: pygeoapi's get_collection_schema
assigns the provider's own field dict into its response and then mutates
it in place, so shared references would let one request's x-ogc-role
assignment leak into every later response.

A missing entry is a generated title and a logged warning, never an
error. Drift is caught by tests, not at runtime.

The 190 chemistry analyte columns are generated by
cli/generate_chemistry_field_descriptions.py and reviewed by hand. It
reads the analyte lists out of the view migration rather than the
parameter lexicon, which holds only two field parameters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Routes all 25 feature collections through a PostgreSQLProvider subclass
that annotates the reflected columns from ogc-field-descriptions.yml.
pygeoapi's get_collection_schema copies each provider field entry into
its response wholesale, so title, description and x-ogc-unit reach the
client with no patching.

The subclass is four lines longer than it looks like it should be.
BaseProvider.fields -- what both /schema and /queryables actually read --
returns self._fields directly and never calls get_fields(), and
GenericSQLProvider.__init__ populates _fields with the raw reflection at
construction. An override that only returned an annotated copy would be
silently discarded, so this one writes back into _fields, with a flag
because the SQL implementation short-circuits on a populated cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/schema needed no patch because pygeoapi copies provider field entries
wholesale. get_collection_queryables does the opposite: it builds a fresh
dict per property and hardcodes 'title': k, the raw column name, so the
documentation the provider attaches never survives.

Rather than fork the 130-line handler, this wraps it and merges the
provider's title, description and unit into the JSON it returned. HTML
renders, error statuses and unparseable bodies pass straight through.
The cost is a JSON round trip and one extra table reflection on an
endpoint that is queried rarely; the benefit is that property filtering,
domains, enums and roles stay pygeoapi's code rather than ours.

The patch is applied once in _load_pygeoapi_app before the module is
executed. It is deliberately not config-dependent: pygeoapi.api.itemtypes
is a single module object shared by both mounts, and starlette_app
resolves the handler off it per request, so one patch covers both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The EDR provider already returned a title and unit per parameter; it now
routes them through the same YAML the feature collections use, and the
CoverageJSON parameters block carries the documentation where a client
looks for it -- observedProperty.label for the display name, description
for the explanation. Both were the raw parameter name before.

EDR fields are keyed by parameter name rather than column name because
that is what the provider reports. ogc_waterlevels stamps one literal,
'groundwater level', which is documented here. ogc_water_chemistry
carries the analyte text exactly as the laboratory recorded it -- an
open-ended set that is only knowable from the data -- so most chemistry
parameters take a generated title, which is the designed fallback rather
than a gap.

tests/test_ogc_field_descriptions.py covers both endpoints on both
mounts, the internal-only collection, JSON and HTML rendering, and the
fallback path. Two guards earn their keep:

* the drift guard fails when a matview column has no YAML entry, so a
  column rename breaks CI instead of quietly degrading the API. EDR
  collections are exempt -- their fields are data, not columns.
* the upgrade guard asserts pygeoapi still assigns the provider's field
  entry into the schema response. A bump that rebuilds the dict instead,
  the way get_collection_queryables already does, would silently drop
  every description; this turns that into a red test.

geometry is excluded from the "every property is documented" assertions:
pygeoapi injects it itself, after the provider's fields, carrying only a
format and a role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records where the YAML lives, how to add a field, and why the copy is
keyed by backing relation rather than collection id.

The load-bearing section is the pygeoapi one. Five behaviours of 0.24.0
hold this feature up, none of them part of any public contract -- the
schema pass-through, the in-place mutation of the provider's field dicts,
the queryables rebuild, BaseProvider.fields returning _fields without
calling get_fields(), and starlette_app resolving handlers off a shared
module. Each is written down with its file and line so an upgrade has
something to check against. Also records the COMMENT ON COLUMN rejection,
so the next person does not relitigate it from scratch.

CLAUDE.md gets a pointer in the existing style.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cleanup pass on the field-description change.

WaterEDRProvider caches its fields and handed the cached dicts straight
back, so pygeoapi's in-place mutation of what a provider returns -- it
pops format and assigns x-ogc-role while building /schema -- accumulated
on the cache and reached the next response. The feature provider was
already safe because describe_fields copies; this closes the same hole on
the EDR side.

The EDR schema test is now data-independent. Parameter fields are read
out of the data rather than reflected from columns, so it passed alone
and failed in the full suite, where earlier tests had removed the
water-level rows it was reading. It skips with a reason when there is
nothing to assert against; the CoverageJSON test covers the same lookup
without needing any rows.

Also confirmed by hand: OpenAPI generation still succeeds against an
unreachable database, so the app starts before the views exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pygeoapi's HTML schema table renders a Values column from each property's
`enum`, and it was empty for every collection: the SQL provider reports
only type and format, and implements no get_domains(), so
?profile=actual-domain has nothing to offer either. Nothing anywhere
filled the key in.

Field entries can now set it two ways, and the distinction is the point:

* `enum:` literally, for values the view's own SQL decides --
  trend_category's four CASE outcomes, and the 'C' the geothermal views
  stamp after converting temperatures.
* `enum-lexicon:` naming a category in core/lexicon.json, expanded on the
  way out, for anything the lexicon governs -- thing_type,
  release_status, well_construction_method, well_pump_type, and
  elevation_method. The vocabulary is never copied, so it cannot drift
  from the file that seeds it, and a category with no terms fails
  validation at load rather than publishing an empty column.

Not populated from SELECT DISTINCT. `enum` is a JSON Schema constraint,
not a sample: a value absent from today's data is not thereby invalid.

The queryables patch carries `enum` too, but yields to one pygeoapi
produced itself, so ?profile=actual-domain still reports the live domain
wherever a provider supports it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jirhiker jirhiker changed the title chore(ogc): explain what each layer is and how it was built chore(ogc): rich layer and field-level metadata Aug 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

79.19% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
cli/generate_chemistry_field_descriptions.py 46 46 0% 34-269
core/edr_provider.py 169 66 61% 72, 110-112, 142-144, 158, 168, 186-193, 197, 216-241, 267-273, 279-285, 291-297, 303-329, 340-351, 366, 441-446, 449
core/feature_provider.py 11 0 100%
core/ogc_field_metadata.py 82 5 94% 97, 101, 104, 108, 111
core/pygeoapi.py 160 20 88% 318, 322, 331, 336, 355, 362, 387-390, 535, 540, 628, 664, 682, 689, 691, 711, 713, 725
core/pygeoapi_patches.py 56 10 82% 66-68, 75, 86, 113, 119, 123-125
TOTAL 524 147 72%

@jirhiker
jirhiker merged commit 12ec0b3 into staging Aug 22, 2026
11 checks passed
@jirhiker
jirhiker deleted the chore/ogc-rich-layer-metadata branch August 22, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant