Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,17 @@ ArcGIS Pro cannot send a bearer token at all and neither desktop client can
refresh an Authentik token. Read **`docs/internal-ogc-desktop-gis.md`** before
changing the credential paths.

### OGC field descriptions

Per-column `title`/`description`/unit for every collection lives in
`core/ogc-field-descriptions.yml`, keyed by backing relation, and is published
on `/schema` and `/queryables` through `core/feature_provider.py` and a wrapper
over pygeoapi's queryables handler. The feature leans on unpinned behaviour of
the pinned pygeoapi version — most sharply, `BaseProvider.fields` returns
`self._fields` and never calls `get_fields()`. Read
**`docs/ogc-field-descriptions.md`** before changing field metadata or
upgrading pygeoapi.

### Database Configuration

The application supports two database modes (configured via `DB_DRIVER` in `.env`):
Expand Down
269 changes: 269 additions & 0 deletions cli/generate_chemistry_field_descriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
# ===============================================================================
# Copyright 2026 ross
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
"""Emit the chemistry blocks of ``core/ogc-field-descriptions.yml``.

``ogc_major_chemistry_results`` and ``ogc_minor_chemistry_wells`` publish one
column per analyte plus a paired units column -- 190 columns between them.
Hand-writing that is error-prone, so this script generates it and the output is
reviewed and committed. Run it again when the analyte lists change:

uv run python -m cli.generate_chemistry_field_descriptions > /tmp/chem.yml

Source of truth is the analyte lists in the migration that builds the two
views, which are the column names themselves. (``core/parameter.json`` holds
only two field parameters, so the lexicon cannot supply this.)

Analytes needing more than a one-line gloss are spelled out in ANALYTE_PROSE;
anything absent falls back to a generated title and a stock description. Prose
here loses to a hand-written entry in the YAML, which wins on merge.
"""

import importlib.util
import sys
import textwrap
from pathlib import Path

MIGRATION = (
Path(__file__).resolve().parents[1]
/ "alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py"
)

# Analyte key -> (title, description). Everything else gets a generated title
# and the stock "dissolved concentration" line.
ANALYTE_PROSE = {
"tds": (
"Total dissolved solids",
"Total mass of dissolved mineral matter in the water -- in plain terms, "
"how salty it is. Drinking-water guidance sits around 500 mg/L.",
),
"ph": (
"pH",
"Acidity of the water on the 0-14 scale, where 7 is neutral. Unitless. "
"Most New Mexico groundwater falls between 7 and 8.5.",
),
"specific_conductance": (
"Specific conductance",
"How well the water conducts electricity, which rises with dissolved "
"mineral content. Used as a fast field proxy for total dissolved solids.",
),
"hardness": (
"Hardness",
"Combined calcium and magnesium content, reported as an equivalent mass "
"of calcium carbonate. What determines whether water is 'hard'.",
),
"alkalinity": (
"Alkalinity",
"The water's capacity to neutralise acid, reported as an equivalent mass "
"of calcium carbonate. Mostly supplied by bicarbonate and carbonate.",
),
"ion_balance": (
"Ion balance",
"Percentage difference between the total positive and total negative "
"charge in the analysis. Charge must balance in reality, so a figure far "
"from zero means the analysis is incomplete or in error.",
),
"total_cations": (
"Total cations",
"Sum of the positively charged dissolved constituents in the analysis.",
),
"total_anions": (
"Total anions",
"Sum of the negatively charged dissolved constituents in the analysis.",
),
"sodium_plus_potassium": (
"Sodium plus potassium",
"Combined sodium and potassium concentration, reported together where the "
"laboratory did not separate them.",
),
"nitrate": (
"Nitrate",
"Dissolved nitrate concentration, usually from fertiliser, septic systems, "
"or livestock. The drinking-water limit is 10 mg/L as nitrogen.",
),
"nitrate_as_n": (
"Nitrate as nitrogen",
"Nitrate concentration expressed as the mass of nitrogen alone, which is "
"how the 10 mg/L drinking-water limit is written. Roughly a quarter of the "
"same sample reported as nitrate.",
),
"nitrite": (
"Nitrite",
"Dissolved nitrite concentration, an intermediate stage in the breakdown of "
"nitrogen compounds.",
),
"silica": (
"Silica",
"Dissolved silica concentration, weathered out of silicate rock. Useful for "
"estimating the temperature water last equilibrated at.",
),
"arsenic": (
"Arsenic",
"Dissolved arsenic concentration. Naturally elevated in parts of New Mexico "
"and regulated in drinking water at 0.010 mg/L.",
),
"uranium": (
"Uranium",
"Dissolved uranium concentration. Naturally present near uranium-bearing "
"rock and regulated in drinking water at 0.030 mg/L.",
),
"fluoride": (
"Fluoride",
"Dissolved fluoride concentration. Beneficial in small amounts; the "
"drinking-water limit is 4 mg/L.",
),
"h2r": (
"Deuterium ratio",
"Ratio of heavy to ordinary hydrogen in the water, reported as per-mil "
"difference from ocean water. Fingerprints where the water fell as "
"precipitation.",
),
"o18r": (
"Oxygen-18 ratio",
"Ratio of heavy to ordinary oxygen in the water, reported as per-mil "
"difference from ocean water. Read with the deuterium ratio to trace the "
"water's origin and evaporation history.",
),
"c13r": (
"Carbon-13 ratio",
"Ratio of carbon-13 to carbon-12 in the water's dissolved carbon, reported "
"as per-mil difference from a standard. Helps identify where the carbon "
"came from, which is needed to correct a carbon-14 age.",
),
"c14": (
"Carbon-14",
"Carbon-14 remaining in the water's dissolved carbon, as a percentage of "
"the modern atmospheric level. The basis for dating groundwater up to "
"roughly 40,000 years old.",
),
"c14_years": (
"Carbon-14 age",
"Apparent age of the water in years, calculated from its carbon-14 content. "
"Uncorrected for carbon picked up from rock, so treat it as an upper bound.",
),
"bromide": (
"Bromide",
"Dissolved bromide concentration. Read against chloride, it distinguishes "
"seawater-derived salinity from dissolved halite.",
),
}

# Elements whose column name is not the plain element name.
ELEMENT_NAMES = {
"silicon": "silicon",
"molybdenum": "molybdenum",
"strontium": "strontium",
}

STOCK_DESCRIPTION = (
"Dissolved {name} concentration in the most recent sample analysed for it."
)
TOTAL_DESCRIPTION = (
"Total {name} concentration -- the unfiltered determination, which counts "
"{name} bound to suspended particles as well as the dissolved fraction."
)
UNITS_DESCRIPTION = (
"Units the {title_lower} value is reported in, as the laboratory recorded them."
)


def _load_analyte_lists():
"""Import the migration module by path and read its analyte column lists."""
spec = importlib.util.spec_from_file_location("_ogc_filter_migration", MIGRATION)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return (
[key for key, _ in module.STATIC_ANALYTE_COLUMNS_MAJOR],
[key for key, _ in module.STATIC_ANALYTE_COLUMNS_MINOR],
)


def _entry(analyte_key: str):
if analyte_key in ANALYTE_PROSE:
return ANALYTE_PROSE[analyte_key]

if analyte_key.endswith("_total"):
base = analyte_key[: -len("_total")]
name = ELEMENT_NAMES.get(base, base).replace("_", " ")
title = f"{name.capitalize()} (total)"
return title, TOTAL_DESCRIPTION.format(name=name)

name = ELEMENT_NAMES.get(analyte_key, analyte_key).replace("_", " ")
return name.capitalize(), STOCK_DESCRIPTION.format(name=name)


def _yaml_block(field: str, title: str, description: str) -> str:
body = textwrap.fill(
description,
width=74,
initial_indent=" " * 6,
subsequent_indent=" " * 6,
break_on_hyphens=False,
break_long_words=False,
)
return f" {field}:\n title: {title}\n description: >-\n{body}\n"


def render(table: str, analyte_keys) -> str:
lines = [f"{table}:"]
lines.append(
_yaml_block(
"location_id",
"Location ID",
"Identifier of the location record the well's coordinates came from.",
)
)
lines.append(
_yaml_block(
"analyte_count",
"Analyte count",
"Number of distinct analytes with a value in this row. A low count "
"means the well has only been analysed for part of the suite.",
)
)
lines.append(
_yaml_block(
"latest_chemistry_date",
"Latest analysis date",
"Date of the most recent result in this row. Analytes are carried "
"forward independently, so an individual value may be older than "
"this date.",
)
)
for key in analyte_keys:
title, description = _entry(key)
lines.append(_yaml_block(key, title, description))
lines.append(
_yaml_block(
f"{key}_units",
f"{title} units",
UNITS_DESCRIPTION.format(title_lower=title.lower()),
)
)
return "\n".join(lines)


def main() -> int:
major, minor = _load_analyte_lists()
print(
"# Generated by cli/generate_chemistry_field_descriptions.py -- review before committing."
)
print(render("major_chemistry_results", major))
print(render("minor_chemistry_wells", minor))
return 0


if __name__ == "__main__":
sys.exit(main())
34 changes: 29 additions & 5 deletions core/edr_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
)
from pygeoapi.provider.base_edr import BaseEDRProvider

from core.ogc_field_metadata import describe_fields, table_entries

LOGGER = logging.getLogger(__name__)

GEOGRAPHIC_CRS = {
Expand Down Expand Up @@ -144,9 +146,16 @@ def _has_column(self, column):

# -------------------------------------------------------------- fields
def get_fields(self):
"""Return the parameter-name fields present in the backing view."""
"""Return the parameter-name fields present in the backing view.

Each call hands back fresh per-field dicts. pygeoapi's
get_collection_schema mutates what a provider returns in place --
popping ``format``, assigning ``x-ogc-role`` -- so returning the
cached dicts themselves would let one request's edits accumulate on
the next one's response.
"""
if self._fields:
return self._fields
return {name: dict(field) for name, field in self._fields.items()}
try:
rows = self._fetch(
f"SELECT DISTINCT parameter_name, unit " # noqa: S608 (trusted table)
Expand All @@ -161,7 +170,11 @@ def get_fields(self):
"title": row["parameter_name"],
"x-ogc-unit": row["unit"],
}
return self._fields
# Same prose source as the feature collections, keyed by parameter
# name rather than column name. Parameter names are read out of the
# data, so an undocumented analyte keeps its generated title.
self._fields = describe_fields(self.table, self._fields)
return {name: dict(field) for name, field in self._fields.items()}

@property
def fields(self):
Expand Down Expand Up @@ -344,6 +357,10 @@ def _read(
)

# ------------------------------------------------------- coveragejson
def _parameter_documentation(self, parameter_name):
"""Documented title/description for one EDR parameter, or ``{}``."""
return table_entries(self.table).get(parameter_name, {})

def _coverage_collection(self, rows):
if not rows:
raise ProviderNoDataError("No data found")
Expand All @@ -355,10 +372,17 @@ def _coverage_collection(self, rows):
stations.setdefault(row["thing_id"], []).append(row)
name = row["parameter_name"]
if name not in parameters:
# A CoverageJSON client reads observedProperty.label for the
# display name and description for the explanation; both were
# the raw parameter name before the field metadata existed.
entry = self._parameter_documentation(name)
parameters[name] = {
"type": "Parameter",
"description": {"en": name},
"observedProperty": {"id": name, "label": {"en": name}},
"description": {"en": entry.get("description", name)},
"observedProperty": {
"id": name,
"label": {"en": entry.get("title", name)},
},
"unit": {"symbol": row["unit"], "label": {"en": row["unit"]}},
}

Expand Down
Loading
Loading