|
| 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