Skip to content

Commit 63bf502

Browse files
jirhikerclaude
andcommitted
feat(transducer): publish and range-delete for corrected hydrographs
The hydrograph corrector in OcotilloUI could only download its corrected series as CSV -- there was no POST for transducer observations at all. This adds the two write endpoints its upload contract specifies. POST /observation/transducer-groundwater-level/block publishes one corrected logger file as one block plus all of its readings, in one transaction. The block's span is derived from the measurements rather than sent: nothing links the observation table to the block table, so the reader pairs them by time and a client-supplied span wider than the data would make the block claim readings it does not contain. `deployment_id` is optional and resolved from the deployments covering that span; zero or more than one match is a 422 rather than a guess, because guessing attributes readings to hardware that did not record them. An existing block sharing any instant with the new one is a 409 listing the collisions. `?replace_overlapping=true` deletes those blocks and their readings. The readings have to go with the block -- keeping them would leave rows the reader cannot show that still occupy the deployment/parameter/instant the new series is about to claim, so a "replace" that kept them would fail on the very insert it was asked to make room for. Readings orphaned by a hand-deleted block are caught separately and reported with the earliest colliding timestamp, rather than letting the insert abort on a constraint name. Overlap is inclusive on both bounds, unlike TransducerObservationBlock.overlaps, which is half-open. The reader matches with `start <= t <= end`, so two blocks sharing an endpoint both claim a reading at that instant -- exactly the ambiguity the check exists to prevent. DELETE /observation/transducer-groundwater-level removes every reading for a well inside a closed range and reconciles the blocks that covered them: one left empty is deleted, one left partial has its span narrowed to the survivors. All three parameters are required; there is deliberately no unbounded form. Scope matches the GET on the same path, so the set a client previews is the set this removes. Schema changes: provenance on the block (source_file, source_kind, and an ordered corrections list), and a per-reading `note` set only where a correction moved the value, so NULL reads as "as measured" rather than "unknown". A corrected block is derived data, and a reviewer who cannot see that a series was snapped to a manual measurement cannot review it. The block time-order check is relaxed to `end >= start`: a block covering a single instant is legitimate, either published that way or narrowed to it by a delete. Both write routes are gated on AMP.Staging, a standalone group -- AMPAdmin does not satisfy it and it satisfies nothing else -- so they ship dark while the workbench is validated against real logger files. Two bugs fixed in passing: - The read route called get_transducer_observations positionally, and the helper's fourth positional parameter is `sensor_id`. `start_time` landed in `sensor_id` (unused, dropped), `end_time` landed in `start_time`, and `end_time` was never set, so a requested upper bound was ignored and the lower bound came from the wrong argument. - `sort`/`order` on that route were accepted and ignored. They now work over a whitelist; an unrecognised field is a 422 rather than a silently differently ordered page. Wellntel support from the contract is deferred: both the readings proxy and the sensor_type filter on /thing are blocked on where the API key and the wellname/PointID mapping should live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ef38ef2 commit 63bf502

13 files changed

Lines changed: 1988 additions & 15 deletions

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ Authentik groups granted.
164164
**Role families are orthogonal**: general `Admin` confers nothing in the AMP or
165165
Lexicon families. Only tiers *within* a family nest.
166166

167+
**`AMP.Staging`** is a standalone group, not a fourth AMP tier — `AMPAdmin`
168+
does not satisfy it. It gates the hydrograph corrector's publish and range-delete
169+
routes while the workbench is being validated against real logger files, so they
170+
ship dark. Read **`docs/hydrograph-correction-publish.md`** before changing
171+
them.
172+
167173
**Authorization is opt-in per endpoint** — a `user: <role>_dependency` parameter
168174
in the signature, not a router-level `dependencies=[...]`. Omitting it produces a
169175
fully public endpoint with no error. `tests/test_authorization.py` holds the
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""publish provenance for corrected transducer blocks
2+
3+
Revision ID: c3d4e5f6a7b8
4+
Revises: b2c3d4e5f6a7
5+
Create Date: 2026-08-19
6+
7+
The hydrograph corrector publishes a *derived* series: water head converted to
8+
depth below ground surface against manual anchors, then shifted, snapped, and
9+
drift-corrected. None of those numbers are what the instrument recorded, so the
10+
database has to carry enough to tell a reviewer what happened to them.
11+
12+
Three columns on the block cover the batch: the file it came from, whether that
13+
file held water head or depth to water, and the ordered list of corrections
14+
applied. `comment` already exists and takes the publisher's free-text note.
15+
16+
One column on the observation covers the row: `note`, set only on readings a
17+
correction actually moved. NULL therefore means "as measured", which is the
18+
distinction review needs. The legacy `nma_waterlevelscontinuous_*_notes`
19+
columns cannot serve -- each is scoped to one legacy source table.
20+
21+
The block time-order check is relaxed from `>` to `>=`. A block spanning a
22+
single instant is legitimate: a published file with one reading, or a block
23+
narrowed by a range delete until one observation survives. The block reader
24+
matches observations inclusively on both bounds, so a zero-width block still
25+
covers its reading. Loosening a check constraint cannot invalidate existing
26+
rows.
27+
"""
28+
29+
import sqlalchemy as sa
30+
from alembic import op
31+
from sqlalchemy.dialects import postgresql
32+
33+
revision = "c3d4e5f6a7b8"
34+
down_revision = "b2c3d4e5f6a7"
35+
branch_labels = None
36+
depends_on = None
37+
38+
# Spelled as it exists in the database, typo included -- renaming it here would
39+
# leave deployed environments with a constraint this migration cannot find.
40+
TIME_ORDER_CONSTRAINT = "check_transuder_block_time_order"
41+
42+
43+
def upgrade() -> None:
44+
op.add_column(
45+
"transducer_observation_block",
46+
sa.Column(
47+
"source_file",
48+
sa.String(length=255),
49+
nullable=True,
50+
comment="Name of the logger file the corrected series was derived from",
51+
),
52+
)
53+
op.add_column(
54+
"transducer_observation_block",
55+
sa.Column(
56+
"source_kind",
57+
sa.String(length=50),
58+
nullable=True,
59+
comment="What the source file measured: water_head or depth_to_water",
60+
),
61+
)
62+
op.add_column(
63+
"transducer_observation_block",
64+
sa.Column(
65+
"corrections",
66+
postgresql.JSONB(astext_type=sa.Text()),
67+
nullable=True,
68+
comment="Corrections applied to the source series, in applied order",
69+
),
70+
)
71+
op.add_column(
72+
"transducer_observation",
73+
sa.Column(
74+
"note",
75+
sa.Text(),
76+
nullable=True,
77+
comment=(
78+
"Per-reading correction annotation; NULL means the value is as "
79+
"measured"
80+
),
81+
),
82+
)
83+
84+
op.drop_constraint(
85+
TIME_ORDER_CONSTRAINT, "transducer_observation_block", type_="check"
86+
)
87+
op.create_check_constraint(
88+
TIME_ORDER_CONSTRAINT,
89+
"transducer_observation_block",
90+
"end_datetime >= start_datetime",
91+
)
92+
93+
94+
def downgrade() -> None:
95+
# Zero-width blocks may have been created while the loosened constraint was
96+
# in force, so widen them by a second rather than let the stricter
97+
# constraint fail to validate. A one-second span on a block that covered an
98+
# instant is a smaller lie than a failed downgrade.
99+
op.execute(
100+
"UPDATE transducer_observation_block "
101+
"SET end_datetime = start_datetime + interval '1 second' "
102+
"WHERE end_datetime = start_datetime"
103+
)
104+
op.drop_constraint(
105+
TIME_ORDER_CONSTRAINT, "transducer_observation_block", type_="check"
106+
)
107+
op.create_check_constraint(
108+
TIME_ORDER_CONSTRAINT,
109+
"transducer_observation_block",
110+
"end_datetime > start_datetime",
111+
)
112+
113+
op.drop_column("transducer_observation", "note")
114+
op.drop_column("transducer_observation_block", "corrections")
115+
op.drop_column("transducer_observation_block", "source_kind")
116+
op.drop_column("transducer_observation_block", "source_file")

api/observation.py

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
session_dependency,
2929
amp_admin_dependency,
3030
amp_editor_dependency,
31+
amp_staging_dependency,
3132
amp_viewer_dependency,
3233
)
3334
from db import Observation, Parameter
@@ -40,7 +41,12 @@
4041
UpdateGroundwaterLevelObservation,
4142
UpdateWaterChemistryObservation,
4243
)
43-
from schemas.transducer import TransducerObservationWithBlockResponse
44+
from schemas.transducer import (
45+
DeletedTransducerObservationsResponse,
46+
PublishedTransducerBlockResponse,
47+
PublishTransducerBlock,
48+
TransducerObservationWithBlockResponse,
49+
)
4450
from schemas.water_level_csv import WaterLevelBulkUploadResponse
4551
from services.crud_helper import model_deleter, model_adder
4652
from services.observation_helper import (
@@ -50,10 +56,30 @@
5056
get_transducer_observations,
5157
)
5258
from services.query_helper import simple_get_by_id
59+
from services.transducer_helper import (
60+
delete_transducer_observations,
61+
publish_transducer_block,
62+
)
5363
from services.water_level_csv import bulk_upload_water_levels
5464

5565
router = APIRouter(prefix="/observation", tags=["observation"])
5666

67+
68+
def _groundwater_level_parameter_id(session) -> int:
69+
"""
70+
The lexicon id the transducer routes work in.
71+
72+
Looked up rather than configured so the publish, read, and delete routes
73+
cannot drift onto different parameters.
74+
"""
75+
return (
76+
session.query(Parameter)
77+
.filter(Parameter.parameter_name == "groundwater level")
78+
.one()
79+
.id
80+
)
81+
82+
5783
"""
5884
TODO
5985
@@ -88,6 +114,33 @@ def add_water_chemistry_observation(
88114
return model_adder(session, Observation, obs_data, user=user)
89115

90116

117+
@router.post(
118+
"/transducer-groundwater-level/block",
119+
status_code=HTTP_201_CREATED,
120+
summary="Publish a corrected transducer series as one block",
121+
)
122+
def publish_transducer_groundwater_level_block(
123+
payload: PublishTransducerBlock,
124+
session: session_dependency,
125+
user: amp_staging_dependency,
126+
replace_overlapping: bool = False,
127+
) -> PublishedTransducerBlockResponse:
128+
"""
129+
Publish one corrected logger file as a single observation block.
130+
131+
The block's time span is derived from the measurements; the client does not
132+
send it. Overlapping an existing block is a 409 listing the collisions --
133+
pass `replace_overlapping=true` to supersede them, which deletes those
134+
blocks and their readings in the same transaction.
135+
136+
Written by the hydrograph corrector in OcotilloUI. See
137+
`docs/hydrograph-correction-publish.md`.
138+
"""
139+
return publish_transducer_block(
140+
session, payload, user=user, replace_overlapping=replace_overlapping
141+
)
142+
143+
91144
@router.post(
92145
"/groundwater-level/bulk-upload",
93146
response_model=WaterLevelBulkUploadResponse,
@@ -155,17 +208,30 @@ def get_transducer_groundwater_level_observations(
155208
thing_id: int | None = None,
156209
start_time: datetime | None = None,
157210
end_time: datetime | None = None,
211+
sort: str | None = None,
212+
order: str | None = None,
158213
) -> CustomPage[TransducerObservationWithBlockResponse]:
214+
"""
215+
Retrieve transducer groundwater level observations paired with the block
216+
that covers them.
159217
160-
groundwater_parameter_id = (
161-
session.query(Parameter)
162-
.filter(Parameter.parameter_name == "groundwater level")
163-
.one()
164-
.id
165-
)
166-
218+
`sort` accepts `observation_datetime`, `value`, or `id`; `order` accepts
219+
`asc` or `desc`. The default is newest first, so a client that wants the
220+
latest stored reading for a well can ask for size 1.
221+
"""
222+
# Keyword arguments deliberately: the helper's fourth positional parameter
223+
# is `sensor_id`, so the previous positional call passed `start_time` as a
224+
# sensor id (unused, silently dropped), `end_time` as `start_time`, and
225+
# nothing as `end_time` -- an upper bound the caller asked for was ignored
226+
# and the lower bound came from the wrong argument.
167227
return get_transducer_observations(
168-
session, thing_id, groundwater_parameter_id, start_time, end_time
228+
session,
229+
thing_id=thing_id,
230+
parameter_id=_groundwater_level_parameter_id(session),
231+
start_time=start_time,
232+
end_time=end_time,
233+
sort=sort,
234+
order=order,
169235
)
170236

171237

@@ -302,6 +368,40 @@ def get_observation_by_id(
302368
# DELETE =======================================================================
303369

304370

371+
@router.delete(
372+
"/transducer-groundwater-level",
373+
status_code=HTTP_200_OK,
374+
summary="Delete transducer groundwater level observations in a time range",
375+
)
376+
def delete_transducer_groundwater_level_observations(
377+
session: session_dependency,
378+
user: amp_staging_dependency,
379+
thing_id: int,
380+
start_time: datetime,
381+
end_time: datetime,
382+
) -> DeletedTransducerObservationsResponse:
383+
"""
384+
Delete every transducer groundwater level reading for a well inside a
385+
closed time range, and reconcile the blocks that covered them: a block left
386+
with no readings is deleted, one left with some has its span narrowed to
387+
the survivors.
388+
389+
All three parameters are required -- there is deliberately no form of this
390+
request that deletes everything for a well. Scoped exactly like the `GET`
391+
on this path, so the set previewed there is the set removed here.
392+
393+
Irreversible, and it leaves the `transducer_daily_data` materialized view
394+
stale until its next refresh.
395+
"""
396+
return delete_transducer_observations(
397+
session,
398+
thing_id=thing_id,
399+
parameter_id=_groundwater_level_parameter_id(session),
400+
start_time=start_time,
401+
end_time=end_time,
402+
)
403+
404+
305405
@router.delete(
306406
"/{observation_id}",
307407
summary="Delete an observation",

core/dependencies.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@
5959
amp_viewer_function = authenticated(any_of=["AMPAdmin", "AMPEditor", "AMPViewer"])
6060

6161

62+
# Hydrograph-Corrector Staging Permissions -------------------------------------
63+
# The hydrograph corrector's publish and range-delete routes write and destroy
64+
# transducer records, and the workbench driving them is still being validated
65+
# against real logger files. `AMP.Staging` is its own group with no tier below
66+
# it and no AMP tier above it -- an AMPAdmin does not satisfy it. Nobody holds
67+
# it until it is granted in Authentik, so the routes ship dark and reachable
68+
# only by whoever is testing them.
69+
#
70+
# This is deliberately not a fourth rung on the AMP ladder. When the workbench
71+
# is trusted, these routes move to `amp_admin_dependency` and the group goes
72+
# away; leaving it as a tier would make that a schema change instead of a
73+
# one-line edit.
74+
amp_staging_function = authenticated(any_of=["AMP.Staging"])
75+
76+
6277
# Lexicon-Specific Authentication/Permissions ----------------------------------
6378

6479
lexicon_admin_function = authenticated(any_of=["LexiconAdmin"])
@@ -89,5 +104,7 @@
89104
amp_editor_dependency: TypeAlias = Annotated[dict, Depends(amp_editor_function)]
90105
amp_viewer_dependency: TypeAlias = Annotated[dict, Depends(amp_viewer_function)]
91106

107+
amp_staging_dependency: TypeAlias = Annotated[dict, Depends(amp_staging_function)]
108+
92109
no_permission_dependency: TypeAlias = Annotated[dict, Depends(no_permission_function)]
93110
# ============= EOF =============================================

0 commit comments

Comments
 (0)