feat: NWRFC textPlot pe=TW temperature, and gauge fork-order rules for LAPO3 - #227
Conversation
The textPlot column-header map knew stage/discharge/inflow but not Temperature, so a pe=TW page fell through `_infer_value_columns`' 1-column fallback and every reading was stored as `flow`. Fetching LAPO3's TW page today would have published its 69.4 °F as 69.4 cfs on a river running 51 cfs — a silent, plausible-looking wrong number rather than a visible failure. No dataset row uses pe=TW yet, so nothing is mis-stored today; this lands ahead of the LAPO3 gauge, whose temperature is only available from that page (NWPS carries LAPO3 stage and flow but its pedts.observed is HGIRG — no TW element). textPlot serves temperature in °F, already the unit the pipeline stores, so the mapping is the whole fix — no conversion. The fixture is captured live from LAPO3 and keeps the shape that makes pe=TW distinct: an observed-only page with no forecast half, hence a single Date/Time header cell and trailing spacer cells on each data row. Same shape as the deferred usace_cda `Temp-Water` mapping noted in docs/PLAN_add_gauges_reaches.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi
gauges.html sorts alphabetically on `sort_name`, whose first field is the
basin. "Little X" rivers landed in a basin of their own ("little sandy"),
which put them next to their parent only by luck of the alphabet: l < s
sorts Little Sandy before Sandy, but l > d would have stranded the
incoming Little Deschutes *after* every Deschutes gauge.
Peel a leading "Little" for the rivers that actually feed their namesake,
so they take the parent's basin and sort ahead of the mainstem on the
existing fork_rank 0 — the same mechanism the directional forks use.
The set is an allowlist because the relation is neither in the name nor in
the HUC, and does not generalize: the Little White Salmon shares HUC8
17070105 with the White Salmon yet reaches the Columbia on its own, while
the Little Salmon (17060210) and Little Deschutes (17070302) each sit in a
different HUC8 from the river they join. A blanket rule would be wrong in
both directions, so an unlisted "Little X" keeps today's behavior.
"Little" stays out of _DIRECTIONS: only the directions are real fork
constructs, and the "Fork of (the) {Direction} Fork" normalization must not
start matching a nonexistent "Little Fork".
Affects exactly four existing rows (98, 100, 110, 235), verified against
the dataset by diffing old rule vs new on identical inputs; Little White
Salmon and Little are untouched. The CSV values follow in kayak_data.
basin_and_fork had no direct coverage before this.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi
Adding "Temperature" to the label map fixed one instance of a bug the fallback keeps re-arming. `_infer_value_columns` treats a matched-but- unrecognised header the same as no header at all and guesses one `flow` column — so any pe= we don't map republishes its values as discharge. That is not hypothetical. NWRFC serves pe=HP today: DETO3 (Detroit Lake) returns a "Pool Height" header, and at the previous commit its pool elevation of 1543.71 ft parsed into 1031 records of 1543.71 cfs. pe=TW was equally latent right up until this branch wired a gauge to it. The fallback also corrupts columns we *do* understand. Given a real EUGO3 pe=HG page whose second label is unmapped, the 1-column fallback re-captures column 1 under the wrong type, turning a 9.73 ft stage into 9.73 cfs — an unknown column poisoning a known neighbour. The docstring already described the intent correctly: the heuristic exists for bodies with *no* header — truncated responses, error pages, the simplified unit fixtures. A header that parsed is the page stating its schema; an unmapped label there is a known-unknown, and guessing is how a temperature becomes a discharge. So return nothing and log the label. A stale gauge is visible and recoverable; a plausible wrong number is neither. Verified against live pages: DETO3/FOSO3 pe=HP now store nothing and log, while all five wired textPlot sources still parse unchanged (APLO3 QI 1034, CRSO3 HG 2050, EUGO3 HG 2028, FALO3 QI 1031, GPRO3 QR 259) plus LAPO3 TW 974. Found by adversarial review of the parent commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi
The fork label sorts alphabetically inside a basin, which carries no
geography — it only reads well where the alphabet agrees. "yankee" sorted
behind "south", so the Yankee Fork landed after the Middle Fork instead of
upstream of it.
Give a basin an explicit fork order and prefix its labels (a-yankee,
b-middle, …) so the existing alphabetical sort yields the curated sequence.
Only _FORK_ORDER's basins are affected; everywhere else, including Santiam,
stays alphabetical and keeps each fork's gauges contiguous. Ordering forks by
elevation instead was the obvious alternative and is why this isn't that: it
scatters the four North Santiam gauges and interleaves Little North Santiam
between them.
The sequence is the club's own, from reach.csv's curated "Salmon ag NN"
(ag 01 Yankee, ag 03 MF, ag 04 EF of SF, ag 06-08 SF, ag 09-10 Little) rather
than anything this module invents. Gauge elevation is emphatically not the
authority: EFSF's gauge is the basin's highest at 6466 ft, yet it is a fork
of the *South* Fork and joins the mainstem last of the four. Ranking on
elevation put it first, three slots off the club's curation.
Yankee Fork Salmon reaches its basin through _NAMED_FORKS, a peel for forks
named after something other than a direction. It's an allowlist, like
_LITTLE_TRIBUTARIES: a general "{Word} Fork {Basin}" rule would also pull in
Coast Fork Willamette — true of the river, but a reorder nobody asked for.
A named entry requires the literal "Fork", since "North Umpqua" is a fork but
"Yankee Creek" is a creek; without that, a future entry like "Bear" would
read "Bear Creek" as ("Creek", "bear").
_FORK_ORDER is asserted against the reach.csv citation, not against a list
mirroring the constant — that shape passes for whatever order ships and is
how the elevation error survived its own test.
Known limit: basin_and_fork keeps only the first modifier, so EF Salmon
(ag 02) and EF of SF (ag 04) both reduce to "east". Only the latter has a
gauge; adding an EF Salmon gauge means splitting the label, not reordering.
Changes six Salmon rows; CSV values follow in kayak_data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi
…228) The build lock pinned hatchling 1.30.1; 1.31.0 has since been released. `build-system.requires` is unpinned (`["hatchling"]`), so CI's drift check recompiles it, resolves the new version, and diffs against the committed lock — which now fails on every PR against main, regardless of content. PR #227 is simply the next one opened. Regenerated with the command the check itself prints. Wheel builds clean on 1.31.0 (kayak-1.2.0-py3-none-any.whl), and the recompile is now a no-op. This recurs by construction: an unpinned requirement plus a hashed lock means each hatchling release reds the repo until someone regenerates. Worth either letting Dependabot own this file or pinning the requires floor — out of scope here, where the goal is just to unblock. Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial reviewI tried to break the three substantive claims here and mostly couldn't. What I checked independently, then two one-liners I'd take before merge. Verified, not taken on faithBlast radius of §2 is zero, and the PR undersells that. I grepped °F is genuinely the storage convention. Not asserted-and-hoped: 54 tests pass on the branch ( Finding 1 (minor): the "a parsed header is never guessed at" invariant has a hole
FORECAST_ONLY = '''<table>
<tr><td>Date/Time (PDT)</td><td>Date/Time (PDT)</td><td>Pool Height</td></tr>
<tr><td>2024-06-15 08:15</td><td>1543.71</td></tr></table>'''
P._infer_value_columns(FORECAST_ONLY) # -> [DataType.flow]The header parsed, - if dtypes:
- return dtypes
+ return dtypes
Finding 2 (minor):
|
Both are the same mistake in different clothes: a rule that reads as
structural but actually leans on the alphabet, guarded by a test that picks
the one input where the alphabet cooperates.
**A parsed header could still be guessed at.** `_infer_value_columns`
returned `[]` for an unmapped label, but the return sat behind `if dtypes:`,
so an *empty* observed half (forecast_split == 0) fell through to the flow
heuristic — the exact shape the previous commit exists to prevent. Return
unconditionally once a header has parsed; the heuristic is now reachable only
when there is no header at all, which is what its docstring always claimed.
Harmless in practice (the row regex won't match a datetime cell, so 0 records
either way), but it was the one path where a page that stated its schema was
still second-guessed.
**An unlisted fork's placement was luck.** `_rank_fork` prefixed only ranked
labels; an unranked one kept a bare label and landed after them solely
because `north`/`west` start after `e`. Add a named fork with an early letter
— the edit `_NAMED_FORKS` exists to invite — and it breaks: `0coast` sorts
ahead of `0d-south`, so a Coast Fork Salmon would lead the basin. Now every
fork in a ranked basin is prefixed, unranked ones with a rank above any real
index, so the placement is by construction. Ranks are numeric (`00-yankee`),
which also retires `chr(ord('a') + i)` walking past `z` into `|`, the field
delimiter, at 26 forks. Unranked basins are untouched and stay alphabetical.
The old test asserted only `west` — passing by the very luck the rule was
supposed to remove. It now parametrizes `coast`/`bear` and asserts against
every ranked fork rather than one.
Sort keys change shape but not order; kayak_data#77 re-keyed to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi
|
Both findings taken — pushed in Finding 1. Fixed by deleting the Finding 2. Fixed, and your The test now parametrizes One correction to a detail: Finding 3 → #232, with your analysis of why DST straddle → #231. Included the marker-less-page corollary you flagged. Next bites 2026-11-01. On the Full gate green: 2030 tests, ruff, mypy. kayak_data#77 re-keyed to the numeric ranks — order byte-identical. — Claude · Opus-4.8(1M) · x-high |
The docstring called gauge 26 "Deschutes at Wickiup" — a name that appears nowhere in the data. Its display_name (the string the page actually renders, and the thing this test is about) is "Deschutes at La Pine"; "Wickiup" survives only in the internal name Deschutes_Wickiup_merge and the cbtt_id WICO. Caught while replaying build/gauges.py's sort over the full synced gauge set: the rendered first mainstem Deschutes row read "at La Pine", not "at Wickiup". The asserted elevation/DA (4257.41 / 483) were already correct, so this is the prose only — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi
Engine half of adding the LAPO3 gauge (Little Deschutes at La Pine). The dataset half is mousebrains/kayak_data#77.
1.
pe=TWtemperature (9a3c1cf)NWPS serves LAPO3's stage and flow, but its
pedts.observedisHGIRG— no TW element — so temperature exists only on the textPlotpe=TWpage. The parser's label map didn't knowTemperature, so that page fell through_infer_value_columns' 1-column fallback and every reading was stored as flow: 69.4 °F posted as 69.4 cfs on a river running 51. Nothing was ever mis-stored, because no dataset row usedpe=TW— LAPO3 would have been the first.textPlot serves °F already — the unit the pipeline stores — so the mapping is the whole fix. Verified across six live stations (LAPO3 72.3, EUGO3 62.2, HCRO3 56.66 …); the 2-decimal values show °F is native, not a rounded °C conversion. Same shape as the USACE
Temp-Watermapping predicted indocs/PLAN_add_gauges_reaches.md.2. Refuse unmapped columns (
5a65eb6)Adding the map key fixed one instance of a bug the fallback keeps re-arming. Nothing is broken on the site today — all five wired textPlot sources are
pe=HG/QI/QR, all mapped, and nope=HProw exists in the dataset. This is about the next reservoir gauge, not a live defect.The trap is real though: NWRFC serves
pe=HPnow, and DETO3 (Detroit Lake) returns aPool Heightheader that at the parent commit parsed into 1031 records of 1543.71 cfs — its pool elevation.pe=TWsat equally latent until this branch wired a gauge to it, and three of the five wired sources are already reservoirs (Green Peter, Applegate, Fall Creek), where pool elevation is the obvious next thing to want.Worse, an unmapped label corrupts columns we do understand: given a real EUGO3
pe=HGpage with one unknown label, the 1-column fallback re-captures column 1 under the wrong type — a 9.73 ft stage emitted as 9.73 cfs.The docstring already had the intent right: the heuristic is for bodies with no header (truncated responses, error pages, unit fixtures). A header that parsed is the page stating its schema; an unmapped label there is a known-unknown, and guessing is how a temperature becomes a discharge. Now it returns nothing and logs the label — a stale gauge is visible and recoverable, a plausible wrong number is neither.
Verified live: DETO3/FOSO3
pe=HPstore nothing and log, while all five wired sources parse unchanged (APLO3 QI 1034, CRSO3 HG 2050, EUGO3 HG 2028, FALO3 QI 1031, GPRO3 QR 259) plus LAPO3 TW 974.3. "Little X" basin grouping (
96fdd0b)gauges.htmlsorts onsort_name, whose first field is the basin. "Little X" rivers formed their own basin, which put them beside their parent only by luck of the alphabet —l < ssorts Little Sandy before Sandy, butl > dwould have stranded the incoming Little Deschutes after every Deschutes gauge. Allowlisted "Little X" now peel to the parent basin and sort ahead of the mainstem on the existingfork_rank0.An allowlist because the relation is in neither the name nor the HUC, and doesn't generalise: Little White Salmon shares HUC8 17070105 with the White Salmon yet reaches the Columbia independently, while Little Salmon (17060210) and Little Deschutes (17070302) each sit in a different HUC8 from the river they join. A blanket rule is wrong in both directions.
Affects exactly four rows (98, 100, 110, 235), verified by diffing old rule vs new on identical inputs. Little White Salmon and Little are untouched, per owner.
4. Salmon forks by confluence (
1aa623f)Fork labels sort alphabetically, which carries no geography —
yankeesorted behindsouth, so the Yankee Fork landed after the Middle Fork instead of upstream of it._FORK_ORDERprefixes a basin's labels so the existing sort yields the curated sequence. Only listed basins change; Santiam stays alphabetical with each fork contiguous.The sequence is this repo's own, from
reach.csv's curatedSalmon ag NN— not anything the module invents. Ordering by gauge elevation was the intuitive alternative and is wrong twice over: it scatters the four North Santiam gauges and interleaves Little North Santiam between them, and EFSF's gauge is the basin's highest at 6466 ft yet joins the mainstem last of the four, via the South Fork.Known limit, documented in code:
basin_and_forkkeeps only the first modifier, so EF Salmon (ag 02) and EF of SF (ag 04) both reduce toeast. Only the latter has a gauge.5. Review fixes (
a05e0be)Two holes found in adversarial review, both the same mistake — a rule that reads as structural but leans on the alphabet, guarded by a test that picks the one input where the alphabet cooperates:
_infer_value_columnsreturned[]for an unmapped label, but behindif dtypes:— so an empty observed half (forecast_split == 0) fell through to the flow heuristic, the exact shape §2 argues against. Now returns unconditionally once a header parses.north/weststart aftere. Add a named fork with an early letter — the edit_NAMED_FORKSinvites — and0coastsorts ahead of0d-south, leading the basin. Every fork in a ranked basin is now prefixed, unranked with a rank above any real index. Ranks are numeric (00-yankee), which also retireschr(ord('a')+i)walking pastzinto|, the field delimiter, at 26 forks. The old test asserted onlywest— passing by the very luck the rule was meant to remove; it now parametrizescoast/bearagainst every ranked fork.Sort keys change shape, not order. kayak_data#77 re-keyed to match.
Verification
Full gate green — 2030 tests, ruff, mypy. Each change exercised against live NWRFC/NWPS pages, and the whole thing end-to-end on a scratch DB:
sync-metadata+orphan-checkclean, fetch stored 2655 flow / 2655 stage / 974 temperature, and the builtgauges.htmlrow files under letter D withstate=Oregon,huc8=17070302, temp 72.3.Follow-ups filed, not fixed here
(PDT)/(PST)DST straddle. Pre-existing, affects allpe, next bites 2026-11-01._FORK_ORDERentry.— Claude · Opus-4.8(1M) · x-high