Skip to content

feat(chemistry): serve legacy water chemistry over REST - #870

Merged
jirhiker merged 5 commits into
stagingfrom
feat/bdms-1189-legacy-chemistry-results
Aug 21, 2026
Merged

feat(chemistry): serve legacy water chemistry over REST#870
jirhiker merged 5 commits into
stagingfrom
feat/bdms-1189-legacy-chemistry-results

Conversation

@jirhiker

Copy link
Copy Markdown
Member

What

Four commits, three shipped earlier in the branch plus one from this session:

  • 97d7f9fb serve legacy water chemistry over RESTservices/legacy_chemistry.py reads NMA_MajorChemistry / NMA_MinorTraceChemistry and exposes results over the API.
  • 67b522ca report which legacy table a result came from — callers can tell a major-ion result from a minor/trace one.
  • bff7faa9 a well with no location no longer 500s the listingschemas/thing.py, covered by tests/test_thing_without_location.py.
  • 8e55d1d4 seed the test DB with real NMA legacy chemistryscripts/seed_nma_chemistry.py (new, this session).

Why the seeder

Working on the legacy chemistry routes means having legacy chemistry to read, and the only ways to get it into ocotilloapi_test were a SQL Server connection or hand-written rows. Tests build single records inline — right for unit tests, but nothing there exercises the normalized chemistry views, the LIMS path, or a list endpoint against realistic analyte / unit / detection-limit distributions.

The script copies a bounded subset out of a local clone of another database (default ocotillo_prod) into ocotilloapi_test, walking the dependency closure:

thing -> location + location_thing_association
      -> NMA_Chemistry_SampleInfo
           -> NMA_MajorChemistry
           -> NMA_MinorTraceChemistry
uv run python -m scripts.seed_nma_chemistry --samples 60

Reviewer notes

  • Association rows matter as much as the locations. thing.nma_pk_location is a legacy audit column, not the live link — the model reaches a location through location_thing_association (Thing.location_associations). Seeding a thing without association rows produces exactly the location-less well that bff7faa9 in this same branch had to stop 500ing on. First cut of the script got this wrong; caught by inspecting the seeded rows, fixed before commit.
  • PKs are not preserved. The target holds unrelated rows at low ids, so copying source ids verbatim would silently reparent sample infos onto pre-existing test things where the ranges overlap. Rows insert without an id; children are repointed at the new parent id.
  • Re-runs reconcile on legacy natural keys, not ids: nma_SamplePtID for sample infos, nma_pk_location / nma_pk_welldata for location and thing. A second run picks up the next unseeded batch rather than duplicating the last.
  • thing.thing_type is a NOT NULL lexicon FK, so a thing whose type the target lexicon lacks disqualifies its sample infos instead of being patched. Nullable lexicon-backed columns are nulled with a count printed.
  • Column sets are intersected per run, so drift between the two databases degrades to a printed skip list rather than a crash. The clone here sat at b6c7d8e9f0a1 against the test DB's c3d4e5f6a7b8 with no drift in the five tables involved.
  • thing.search_vector is skipped (trigger-maintained); location.point round-trips as EWKT because pg8000 has no geometry codec.
  • Writes are refused unless the target database name contains test, or --force is passed.

The seed is transient

tests/conftest.py has a session-scoped autouse fixture that drops and re-migrates the schema, so any pytest run wipes the seeded rows and the script has to be re-run. The flip side is that it cannot perturb the suite — pytest always starts from a clean schema. Noted in the module docstring.

Testing

Seeded 65 sample infos across two runs — 764 major and 1223 minor/trace rows, 22 and 79 distinct analytes, 437 censored (Symbol non-null) values:

  • second run reported 60 already-seeded candidates skipped
  • natural keys unique: 65/65 sample infos, 764/764 major GlobalIDs
  • zero orphaned chemistry rows
  • all 60 chemistry things resolve a location through the association table, zero orphaned associations
  • every copied location kept its geometry

test_major_chemistry_legacy, test_nma_chemistry_lineage, test_chemistry_lims (57 passed) and test_ogc (22 passed, 2 skipped) are green. Note those runs reset the schema first, so they show the suite is unaffected — they do not exercise the seeded rows.

The seeder depends on a local database clone, so it is a dev-box tool and does not run in CI.

🤖 Generated with Claude Code

jirhiker and others added 5 commits August 21, 2026 11:50
Adds GET /chemistry/results, one row per analyte, and registers the
chemistry router -- it existed with every route commented out, so the API
served no chemistry at all.

The water chemistry is in the legacy NMA tables, not in the refactored
`observation` table, which holds none of it. Rather than read those four
tables directly, this serves `ogc_water_chemistry`, the view d9e0f1a2b3c4
already built by unioning them for the OGC EDR mount. Same rows, one
definition of what a chemistry result is. Only the public view is served,
so an unreleased thing or a sample flagged PublicRelease = false is not
reachable here regardless of who asks.

Analytes are stored as legacy symbols (`As`, `SO4`, `pHf`) and are
translated to the lexicon's parameter names on the way out, because that
is what callers key on to match a result to a drinking water standard.
Doing it per caller means each one gets to be wrong separately.

Ambiguous symbols are deliberately left untranslated so nothing can act on
a guess. `NO3` maps to the as-NO3 name rather than the as-N one: the
nitrate MCL is 10 mg/L as N, about 45 mg/L as NO3, so collapsing the two
would flag wells that are nowhere near the limit. The legacy data records
`NO3(N)` separately and that is what carries the as-N name.

`start_time` is inclusive and `end_time` exclusive so a calendar year is
expressible without picking up New Year's Day of the next one, and paging
breaks ties on id so analytes sharing a timestamp cannot be served twice
or skipped.

Refs BDMS-1189

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `result_kind` (major, minor, radionuclide, field) to the chemistry
results response.

Whether a result was read at the wellhead or by a laboratory is a
distinction an owner-facing report has to draw, and the legacy tables are
the only record of it -- the refactored `parameter_type` it used to come
from is not populated for this data. The view keeps that provenance only
in its text id prefix, so the prefix is translated here into a field a
client can read instead of every client learning to parse an id.

Refs BDMS-1189

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GET /thing/water-well returned 500 for any page containing a thing with no
current location: `current_location` was a required field, so the GeoJSON
validator was handed None, reached for `__table__` on it, and raised
AttributeError. One unlocated well made every well on its page unreadable.
The dev database has 49 of them, so the listing failed at any page size
that reached one.

A thing is associated with a location over an effective period, and that
period can be closed or never opened, so having no current location is a
state the schema has to be able to say. The field is now optional and the
validator hands None back for the annotation to resolve.

Found while pointing the chemistry report at this endpoint; the bug is
older than that and affects every consumer of the wells listing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Working on the legacy chemistry REST routes means having legacy chemistry to
read, and until now the only way to get it into ocotilloapi_test was a SQL Server
connection or hand-written rows. Tests build their own single records inline,
which is right for unit tests but leaves nothing to exercise the normalized
chemistry views, the LIMS ingestion path, or a list endpoint against realistic
analyte, unit and detection-limit distributions.

This copies a bounded subset out of a local clone of another database (default
ocotillo_prod) into ocotilloapi_test, walking the dependency closure:

    thing -> location + location_thing_association
          -> NMA_Chemistry_SampleInfo
               -> NMA_MajorChemistry
               -> NMA_MinorTraceChemistry

The association rows matter as much as the locations. thing.nma_pk_location is a
legacy audit column, not the live link: the model reaches a location through
location_thing_association (Thing.location_associations), so seeding a thing
without association rows produces exactly the location-less well that bff7faa
had to stop 500ing on.

Primary keys are deliberately not preserved. The target already holds unrelated
rows at low ids, so copying source ids verbatim would silently reparent sample
infos onto pre-existing test things wherever the ranges overlap. Rows are
inserted without an id and children are repointed at the new parent id.

Re-runs reconcile on the legacy natural keys rather than on ids: a sample info
whose nma_SamplePtID is already present is skipped, and location/thing are reused
via nma_pk_location/nma_pk_welldata. So a second run picks up the next unseeded
batch instead of duplicating the last one.

Candidates must have both a major and a minor/trace row, so every seeded sample
exercises both tables. thing.thing_type is a NOT NULL lexicon FK, so a thing
whose type the target lexicon lacks disqualifies its sample infos rather than
being patched; nullable lexicon-backed columns are nulled with a count reported.
Column sets are intersected per run, so schema drift between the two databases
degrades to a printed skip list instead of a crash. thing.search_vector is
skipped as trigger-maintained, and location.point round-trips as EWKT because
pg8000 carries no geometry codec.

The target name must contain 'test' unless --force is passed.

The seed is transient by design of the test suite: the session-scoped autouse
fixture in tests/conftest.py drops and re-migrates the schema, so any pytest run
wipes it and the script has to be re-run afterwards. That also means it cannot
perturb the suite -- pytest always starts from a clean schema. Documented in the
module docstring.

Verified by seeding 65 sample infos across two runs (764 major and 1223
minor/trace rows, 22 and 79 distinct analytes, 437 censored values): the second
run reported 60 already-seeded candidates skipped, natural keys stayed unique
(65/65 sample infos, 764/764 major GlobalIDs), no chemistry row was orphaned, all
60 chemistry things resolved a location through the association table, and every
copied location kept its geometry.

Depends on a local database clone, so it is a dev-box seeder and does not run in
CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Coverage

79.12% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
api/chemisty.py 26 13 50% 75-112
core/initializers.py 139 20 86% 67-69, 73-82, 184, 188, 206, 273-286, 290-291
db/chemistry_views.py 19 0 100%
schemas/chemistry.py 23 4 83% 60-62, 66
schemas/location.py 119 4 97% 40, 225-228
schemas/thing.py 228 9 96% 287, 298, 323-326, 369, 374-376, 381
services/legacy_chemistry.py 19 0 100%
TOTAL 573 50 91%

@jirhiker
jirhiker merged commit dcb0cb1 into staging Aug 21, 2026
9 checks passed
@jirhiker
jirhiker deleted the feat/bdms-1189-legacy-chemistry-results branch August 21, 2026 19:41
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