Skip to content

Commit 28c8ef8

Browse files
jirhikerclaude
andcommitted
docs: record needs and assumptions for vertical observations
Every observation the schema stores today is indexed by time. Depth-indexed data -- temperature-depth profiles, downhole sonde runs, geophysical logs, core interval properties -- has no home, and the one vertical dataset that reaches users (ogc_geothermal_wells_temperature_profile) is a materialized view over a legacy mirror that bypasses the observation model entirely. This documents what such data needs (datum/direction/unit, dated depth-to-elevation conversion, a profile container analogous to TransducerObservationBlock, measured depth vs TVD, log-scale row counts, point vs interval semantics) and the working assumptions, so the design discussion starts from a written baseline rather than re-deriving it. No storage decision is made here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 30bac8a commit 28c8ef8

1 file changed

Lines changed: 190 additions & 0 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# Vertical observations: needs and assumptions
2+
3+
**Status:** Draft — needs and assumptions only. No storage decision is made here.
4+
**Date:** 2026-09-01
5+
6+
## What this is about
7+
8+
Every observation the schema stores today is indexed by **time**. `Observation`
9+
carries `observation_datetime` and no depth; `TransducerObservation` carries
10+
`observation_datetime` and no depth. A well's hydrograph is a value read against
11+
a time axis, and both the manual path (`Sample``Observation`) and the
12+
continuous path (`Deployment``TransducerObservation`) are built around that.
13+
14+
A **vertical observation** is one where the domain axis is **depth**: a series of
15+
values collected at one visit, at many depths in one borehole, where the
16+
interesting variation is with depth and the timestamp is effectively constant
17+
across the series. Temperature-depth profiles, downhole sonde profiles
18+
(temperature/SC/DO down the water column), geophysical logs, and interval
19+
properties from core all have this shape.
20+
21+
The schema has no home for it. This document records what such data needs and
22+
what we are assuming, so that whoever designs the tables is not re-deriving it.
23+
24+
## What exists now
25+
26+
Depth appears in five places, none of them an observation axis:
27+
28+
| Where | Columns | Meaning |
29+
|---|---|---|
30+
| `db/thing.py` | `well_depth`, `hole_depth`, `well_casing_depth`, `well_pump_depth`, `screen_depth_top/bottom` | Construction geometry, one value per well |
31+
| `db/sample.py` | `depth_top`, `depth_bottom` | Interval a discrete sample came from |
32+
| `db/thing_geologic_formation_association.py` | `top_depth`, `bottom_depth` | Lithology picks, "from ground surface" |
33+
| `db/nmw_legacy.py` | `NMW_GtTempDepths.depth` / `.temp` | 1:1 mirror of NM_Wells `tbl_gt_temp_depths` — a real temp-vs-depth profile, readable only as legacy |
34+
| `db/geothermal.py` | `GeothermalTemperatureProfile*` | Commented out in full. An earlier attempt at exactly this problem |
35+
36+
Published today: `ogc_geothermal_wells_temperature_profile`, a materialized view
37+
over the legacy mirror, one row per depth. That is the only vertical dataset
38+
reaching users, and it bypasses the observation model entirely.
39+
40+
`schemas/validators.py:DepthIntervalMixin` is the only shared depth rule:
41+
non-negative, bottom strictly greater than top. It assumes intervals, so it does
42+
not apply to point-depth readings.
43+
44+
## Needs
45+
46+
### 1. Depth is meaningless without a reference
47+
48+
A stored number needs three things fixed before it can be compared to anything:
49+
50+
- **Datum** — ground surface, top of casing, measuring point, or an elevation
51+
datum (NAVD88). The existing depth columns say "from ground surface" in
52+
comments only; nothing enforces or records it per row.
53+
- **Direction** — positive down (depth) or positive up (elevation). Mixing these
54+
silently in one column is the classic failure.
55+
- **Unit** — the existing depth columns are feet; `Location.elevation` is
56+
**meters**, NAVD88. Any conversion from depth to elevation crosses a unit
57+
boundary, and nothing in the schema flags that.
58+
59+
### 2. Depth must be convertible to elevation, and that conversion is dated
60+
61+
To turn a bgs depth into an elevation the reader needs `Location.elevation` (m,
62+
NAVD88) and, if the depth is measured from the measuring point, the MP height in
63+
effect **on the observation date**`MeasuringPointHistory` already models MP
64+
height as a dated interval, precisely because it changes.
65+
66+
Consequence: the depth as measured must be stored as measured, against the
67+
reference in effect at the time, and never rewritten when the datum changes. A
68+
re-survey or a new wellhead changes the conversion, not the field reading.
69+
`docs/measuring-point-height-null-handling.md` already sets the convention for
70+
the null case (missing MP height means ground surface, i.e. zero).
71+
72+
### 3. A profile is a thing, not a bag of readings
73+
74+
The N readings of one logging run belong together: same instrument, same trip
75+
down the hole, same QC verdict, same source file. That is the same requirement
76+
`TransducerObservationBlock` solves for the time axis — a container carrying
77+
`review_status`, `source_file`, `corrections`, and a reviewer, so a run can be
78+
approved, annotated, or deleted as a unit. Vertical data needs the equivalent;
79+
without it there is no way to say "this log is bad" except row by row.
80+
81+
### 4. Measured depth is not always true vertical depth
82+
83+
NM_Wells carries `From_TVD`/`To_TVD` alongside `From_Depth`/`To_Depth` because
84+
deviated boreholes exist. Most NM monitoring wells are vertical and the two are
85+
equal, but the distinction has to be representable or the deviated ones are
86+
silently wrong.
87+
88+
### 5. Row counts are a different order of magnitude
89+
90+
A manual water level is one `Observation` per visit. A geophysical log at 0.1 ft
91+
spacing in a 1,000 ft hole is 10,000 rows per run. Whatever holds these needs an
92+
index that supports "all readings for this profile, ordered by depth" and a
93+
loader that upserts idempotently — the lesson `TransducerObservation` encodes in
94+
its `UniqueConstraint`, and the lesson behind the ~391k silently dropped rows in
95+
the continuous transfer.
96+
97+
### 6. Point depths and intervals are both real
98+
99+
A sonde reading at 42.0 ft is a point. A thermal-conductivity measurement over
100+
120–135 ft is an interval, and so is a core-derived chemistry value. These are
101+
different enough that one nullable pair of columns handles them only by
102+
convention (`depth_top == depth_bottom` for a point, or `depth_bottom IS NULL`).
103+
Whichever convention is chosen has to be stated and validated, and
104+
`DepthIntervalMixin` does not currently permit either — it requires
105+
`bottom > top` strictly.
106+
107+
Overlap rules also need stating. Lithology picks must not overlap; sample
108+
intervals from different runs legitimately can.
109+
110+
### 7. The `Sample` hierarchy forces a choice
111+
112+
`Location → Thing → FieldEvent → FieldActivity → Sample → Observation` puts
113+
depth on `Sample`. Taken literally, a 10,000-point log is 10,000 `Sample` rows,
114+
each with a unique `sample_name` (a `NOT NULL UNIQUE` column). That is not
115+
tenable for logs. Either depth moves onto the observation, or vertical data gets
116+
its own path beside the manual one — which is what the continuous data already
117+
did.
118+
119+
### 8. Publication has no obvious shape
120+
121+
OGC API - Features serves 2D point features. A profile is not a feature; it is a
122+
series hanging off one. The existing answer — a materialized view with one row
123+
per depth, repeating the well's geometry — works and is already shipped for
124+
geothermal, but it makes every depth a separate "feature". Anything new should
125+
either follow that precedent deliberately or say why not. Field titles and units
126+
for whatever lands go in `core/ogc-field-descriptions.yml`; see
127+
`docs/ogc-field-descriptions.md`.
128+
129+
### 9. Nulls carry reasons; maturity is orthogonal to release
130+
131+
Two existing patterns apply unchanged. A null value needs a reason in the same
132+
row (`Observation.groundwater_level_reason`). And review state
133+
(`data_maturity`: provisional / in review / approved) is separate from who may
134+
see the row (`release_status`) — a reading can be public and provisional at
135+
once, which is why `TransducerObservation` has both.
136+
137+
## Assumptions
138+
139+
These are the working assumptions. Each is a candidate to be confirmed or
140+
overturned before any table is built.
141+
142+
1. **Depth is positive downward.** A larger number is deeper. Elevation, where
143+
needed, is derived, not stored alongside as a second authority.
144+
2. **Ground surface is the default datum**, matching every existing depth column
145+
and the San Acacia ingestion decision. Rows measured from anything else must
146+
say so explicitly; the datum is not inferable from the parameter.
147+
3. **Feet are the stored unit for depth**, matching existing depth columns, even
148+
though `Location.elevation` is meters. The depth unit is recorded per row (or
149+
per profile) as a lexicon term rather than assumed, following how
150+
`Observation.unit` is handled.
151+
4. **One timestamp per profile.** A run is treated as instantaneous. A log where
152+
per-reading time genuinely matters (e.g. a slow thermal equilibration) is
153+
out of scope for this shape and belongs on the time axis.
154+
5. **Measured depth equals TVD unless a deviation survey says otherwise.** No
155+
deviation data is stored today; assuming equality is the honest default for
156+
vertical NM wells, but the column pair should exist rather than be added later.
157+
6. **Depth values are never recomputed.** Datum changes, re-surveys, and well
158+
deepening change the interpretation, not the recorded number.
159+
7. **Vertical data gets its own path**, parallel to `TransducerObservation`,
160+
rather than being forced through `Sample`. The `Sample` path stays correct for
161+
discrete samples that happen to have a depth interval — a bailer at 200 ft is
162+
a sample, not a profile.
163+
8. **The existing geothermal temp-depth data is the first migration target.**
164+
`NMW_GtTempDepths` is real, already published, and already the right shape.
165+
Any design that cannot absorb it is the wrong design.
166+
167+
## Open questions
168+
169+
- Does depth go on `Observation` (nullable columns, one table for both axes) or
170+
on a separate `VerticalObservation` table? The transducer precedent argues
171+
separate; the parameter/unit/lexicon plumbing argues shared.
172+
- Is the profile container a new entity, or is `FieldActivity` already it? A
173+
logging run is an activity during a field event, which is close.
174+
- Do intervals and points share a table, or split?
175+
- Does a profile need per-reading QC, or is run-level `review_status` enough?
176+
Transducer data needed both.
177+
- Which datasets beyond geothermal are actually queued — downhole sonde
178+
profiles, geophysical logs, core interval chemistry? The answer changes the
179+
row-count and interval requirements sharply.
180+
- How are profiles exposed in the REST API, given that the OGC path is already
181+
settled by precedent?
182+
183+
## Related
184+
185+
- `docs/measuring-point-height-null-handling.md` — null MP height defaults to
186+
ground surface
187+
- `docs/hydrograph-correction-publish.md` — the block/QC pattern on the time axis
188+
- `docs/nm_wells-migration.md` — where the geothermal profile data comes from
189+
- `docs/ogc-field-descriptions.md` — field titles/units for anything published
190+
- `ADR4.md` — depth/datum conversion rules belong in `domain/`, not `services/`

0 commit comments

Comments
 (0)