From 9a3c1cfc99f5151bc558f0aa27522e8f30458130 Mon Sep 17 00:00:00 2001 From: Pat Welch Date: Wed, 15 Jul 2026 08:32:49 -0700 Subject: [PATCH 1/6] feat: teach nwrfc.textplot the pe=TW temperature column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi --- src/kayak/parsers/nwrfc_textplot.py | 3 ++ tests/test_parsers/test_nwrfc_textplot.py | 41 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/kayak/parsers/nwrfc_textplot.py b/src/kayak/parsers/nwrfc_textplot.py index 70ebfe3e..97f4879e 100644 --- a/src/kayak/parsers/nwrfc_textplot.py +++ b/src/kayak/parsers/nwrfc_textplot.py @@ -10,6 +10,8 @@ * ``pe=HG`` on a gage-only station — 1 value column (Stage). * ``pe=HG`` on a rated station — 2 value columns (Stage + Discharge), which we emit as gauge + flow for the same timestamp. +* ``pe=TW`` (water temperature) — 1 value column (Temperature), already + in °F, which is the unit the rest of the pipeline stores. The schema is inferred from the column-header row at the top of the table; pages without a recognisable header fall back to a 1-column @@ -28,6 +30,7 @@ "stage": DataType.gauge, "discharge": DataType.flow, "inflow": DataType.inflow, + "temperature": DataType.temperature, } diff --git a/tests/test_parsers/test_nwrfc_textplot.py b/tests/test_parsers/test_nwrfc_textplot.py index fad1b8f9..4df78f23 100644 --- a/tests/test_parsers/test_nwrfc_textplot.py +++ b/tests/test_parsers/test_nwrfc_textplot.py @@ -73,6 +73,20 @@ """ +# pe=TW response: water temperature (°F), observed-only — the page carries +# no forecast half, so the header row has a single Date/Time cell and the +# data rows trail empty spacer cells. +# Captured live from LAPO3 (LITTLE DESCHUTES--NR LAPINE) on 2026-07-15. +TEXTPLOT_TW_TEMPERATURE = """\ + +LITTLE DESCHUTES--NR LAPINE (LAPO3)

+ + + + +
Observed
Date/Time (PDT)Temperature
2024-06-15 08:1569.4  
2024-06-15 08:0069.6  
+""" + # pe=HG on a gage-only NWRFC station: only Stage appears in the observed # half. Captured live from OCUO3 (Willamette Upper Falls) on 2026-05-11. TEXTPLOT_HG_STAGE_ONLY = """\ @@ -231,3 +245,30 @@ def test_hg_stage_only_emits_gauge(self, session): obs = session.query(Observation).filter_by(source_id=src.id).all() assert all(o.data_type == DataType.gauge for o in obs) assert sorted(o.value for o in obs) == [54.08, 54.08] + + +class TestNWRFCTextPlotTW: + def test_tw_emits_temperature(self, session): + """pe=TW yields temperature values in °F. + + Regression guard: before ``Temperature`` was in ``_LABEL_TO_DTYPE`` + the header lookup failed, the column-inference bailed to its + 1-column ``flow`` fallback, and a 69.4 °F reading was stored as + 69.4 cfs — silently publishing a temperature as a discharge. + """ + src = _make_source(session, name="tw_temperature") + parser = NWRFCTextPlotParser( + url="https://www.nwrfc.noaa.gov/station/flowplot/textPlot.cgi?id=LAPO3&pe=TW", + session=session, + source_id=src.id, + ) + count = parser.parse(TEXTPLOT_TW_TEMPERATURE) + assert count == 2 + obs = session.query(Observation).filter_by(source_id=src.id).all() + assert all(o.data_type == DataType.temperature for o in obs) + assert sorted(o.value for o in obs) == [69.4, 69.6] + + def test_tw_header_infers_temperature_column(self): + """The observed half of a pe=TW page is a single Temperature column.""" + parser = NWRFCTextPlotParser.__new__(NWRFCTextPlotParser) + assert parser._infer_value_columns(TEXTPLOT_TW_TEMPERATURE) == [DataType.temperature] From 96fdd0b6df8a6cda50f4bdad6b42cbd545f5b899 Mon Sep 17 00:00:00 2001 From: Pat Welch Date: Wed, 15 Jul 2026 10:18:56 -0700 Subject: [PATCH 2/6] feat: group known "Little X" gauges into their parent's basin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi --- scripts/seed_gauge_display.py | 42 +++++- tests/test_scripts/test_seed_gauge_display.py | 129 ++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/test_scripts/test_seed_gauge_display.py diff --git a/scripts/seed_gauge_display.py b/scripts/seed_gauge_display.py index ba8271b7..e3d2e5ca 100644 --- a/scripts/seed_gauge_display.py +++ b/scripts/seed_gauge_display.py @@ -13,8 +13,8 @@ - basin: river with any fork modifier stripped (``Umpqua`` for all N/S/mainstem rows in the Umpqua drainage) - fork_rank: ``0`` for fork rows, ``9`` for mainstem → forks sort first - - fork_label: ``north`` / ``south`` / ``east`` / ``west`` / ``middle`` - (empty for mainstem) — distinguishes forks in the same basin + - fork_label: ``north`` / ``south`` / ``east`` / ``west`` / ``middle`` / + ``little`` (empty for mainstem) — distinguishes forks in the same basin - elev_key: ``10000 - elevation`` zero-padded so higher elevation sorts first (upstream ≈ higher); NULL → sentinel pushing row to end - da_key: drainage_area zero-padded so smaller catchment sorts first @@ -50,6 +50,26 @@ DEFAULT_CACHE = str(GAUGE_METADATA_CACHE) _DIRECTIONS = ("North", "South", "East", "West", "Middle") +# "Little X" rivers that feed the X they are named for, and so belong in X's +# basin group — sorted ahead of the mainstem by fork_rank 0, the same as X's +# directional forks. +# +# Membership is a reviewed domain claim, not something derivable from the name +# or the HUC, and it does not generalize: the Little White Salmon shares HUC8 +# 17070105 with the White Salmon yet reaches the Columbia independently, while +# the Little Salmon (17060210) and Little Deschutes (17070302) each sit in a +# different HUC8 from the river they flow into. So the rule is an allowlist — +# a "Little X" not named here keeps its own "little x" basin, which is the +# status quo and the safe default for a river nobody has adjudicated yet. +_LITTLE_TRIBUTARIES = frozenset( + { + "little deschutes", # → Deschutes, joins near La Pine + "little north santiam", # → North Santiam + "little salmon", # → Salmon, joins at Riggins + "little sandy", # → Sandy, via the Bull Run + } +) +_LITTLE_RE = re.compile(r"^Little\s+", re.IGNORECASE) _DIRECTION_LETTERS = { "N": "North", "S": "South", @@ -192,9 +212,9 @@ def _collapse_whitespace(s: str) -> str: def basin_and_fork(river: str) -> tuple[str, str]: """Split a normalized river name into (basin, fork_label). - Iteratively peels off ``{direction} [Fork]`` and ``of [the] {direction} + Iteratively peels off ``{modifier} [Fork]`` and ``of [the] {modifier} [Fork]`` prefixes until what remains is the base river; the *first* - direction seen is returned as the primary fork label (so + modifier seen is returned as the primary fork label (so ``North Fork of Middle Fork Willamette`` → basin ``Willamette``, fork ``north``, which still groups with other Willamette tributaries). @@ -203,6 +223,15 @@ def basin_and_fork(river: str) -> tuple[str, str]: ``Hood`` → (``Hood``, ``"")`` # mainstem ``East Fork of South Fork Salmon`` → (``Salmon``, ``east``) ``North Fork of Middle Fork Willamette`` → (``Willamette``, ``north``) + + A leading ``Little`` peels only for the rivers in ``_LITTLE_TRIBUTARIES``, + and peels *before* the directional pass so the remainder still reduces to + its basin:: + + ``Little Deschutes`` → (``Deschutes``, ``little``) + ``Little North Santiam`` → (``Santiam``, ``little``) + ``Little White Salmon`` → (``Little White Salmon``, ``"")`` # own river + ``Little`` → (``Little``, ``"")`` # own river """ if not river: return "", "" @@ -213,6 +242,11 @@ def basin_and_fork(river: str) -> tuple[str, str]: ) s = river dirs_seen: list[str] = [] + if s.strip().casefold() in _LITTLE_TRIBUTARIES: + m = _LITTLE_RE.match(s) + if m: + dirs_seen.append("little") + s = s[m.end() :] while True: m = peel_re.match(s) if not m: diff --git a/tests/test_scripts/test_seed_gauge_display.py b/tests/test_scripts/test_seed_gauge_display.py new file mode 100644 index 00000000..289e1cf6 --- /dev/null +++ b/tests/test_scripts/test_seed_gauge_display.py @@ -0,0 +1,129 @@ +"""Tests for the gauge display/sort-key helpers in ``seed_gauge_display.py``. + +``sort_name`` is the whole row order of gauges.html (the page sorts +alphabetically on it), so the basin/fork split is load-bearing for what a +reader sees. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) + +from seed_gauge_display import ( + basin_and_fork, + build_display_name, + build_sort_name, +) + + +class TestBasinAndFork: + @pytest.mark.parametrize( + ("river", "expected"), + [ + # Mainstems — nothing to peel. + ("Hood", ("Hood", "")), + ("Deschutes", ("Deschutes", "")), + # Directional forks, spelled both ways. + ("North Fork Alsea", ("Alsea", "north")), + ("North Umpqua", ("Umpqua", "north")), + # Compound forks: the first modifier wins and keeps the row with + # its basin's other tributaries. + ("East Fork of South Fork Salmon", ("Salmon", "east")), + ("North Fork of Middle Fork Willamette", ("Willamette", "north")), + ], + ) + def test_directional_forks(self, river, expected): + assert basin_and_fork(river) == expected + + @pytest.mark.parametrize( + ("river", "expected"), + [ + ("Little Deschutes", ("Deschutes", "little")), + ("Little Sandy", ("Sandy", "little")), + ("Little Salmon", ("Salmon", "little")), + # Both modifiers peel; "little" wins as the first seen, so this + # sorts with the Santiam forks — ahead of North Santiam. + ("Little North Santiam", ("Santiam", "little")), + ], + ) + def test_little_peels_for_known_tributaries(self, river, expected): + """An allowlisted "Little X" groups with X instead of forming its own basin.""" + assert basin_and_fork(river) == expected + + @pytest.mark.parametrize( + ("river", "expected"), + [ + # Reaches the Columbia on its own, despite sharing HUC8 17070105 + # with the White Salmon — so it is not a fork of it. + ("Little White Salmon", ("Little White Salmon", "")), + # A river actually named "Little" — no parent to peel onto. + ("Little", ("Little", "")), + # Not adjudicated, so it keeps the status-quo basin. + ("Little Nestucca", ("Little Nestucca", "")), + ], + ) + def test_little_stays_put_when_not_a_known_tributary(self, river, expected): + """ "Little" is not a blanket modifier — independent rivers keep their basin.""" + assert basin_and_fork(river) == expected + + def test_empty(self): + assert basin_and_fork("") == ("", "") + + +class TestBuildSortName: + def test_little_deschutes_sorts_ahead_of_mainstem_deschutes(self): + """The Little Deschutes must precede every mainstem Deschutes gauge. + + Gauge 26 (Deschutes at Wickiup) is the first mainstem row by + elevation; fork_rank 0 puts the Little Deschutes ahead of it + regardless of the fork's own elevation/DA. + """ + little = build_sort_name("Little Deschutes", None, None) + wickiup = build_sort_name("Deschutes", 4257.41, 483.0) + assert little == "deschutes|0little|999999|999999" + assert wickiup == "deschutes|9|005743|000483" + assert little < wickiup + + def test_forks_precede_mainstem_in_same_basin(self): + assert build_sort_name("Little Sandy", 720.0, 23.0) < build_sort_name("Sandy", 720.0, 23.0) + + def test_little_salmon_precedes_mainstem_salmon(self): + assert build_sort_name("Little Salmon", 2000.0, 576.0) < build_sort_name( + "Salmon", 2000.0, 576.0 + ) + + def test_little_north_santiam_precedes_north_santiam(self): + """ "little" sorts before "north" within the shared Santiam basin.""" + assert build_sort_name("Little North Santiam", 655.0, 112.0) < build_sort_name( + "North Santiam", 655.0, 112.0 + ) + + def test_little_white_salmon_keeps_its_own_basin(self): + """Not a fork, so it neither joins nor jumps the White Salmon group.""" + assert build_sort_name("Little White Salmon", 925.0, None) == ( + "little white salmon|9|009075|999999" + ) + + def test_null_metadata_sorts_to_end_of_its_group(self): + """NULL elevation/DA fall back to sentinels, not to the front.""" + assert build_sort_name("Deschutes", None, None) == "deschutes|9|999999|999999" + assert build_sort_name("Deschutes", 4257.41, 483.0) < build_sort_name( + "Deschutes", None, None + ) + + def test_elevation_descends_and_da_ascends(self): + """Upstream (higher, smaller catchment) sorts first.""" + high = build_sort_name("Deschutes", 4257.41, 483.0) + low = build_sort_name("Deschutes", 167.54, 10500.0) + assert high < low + + +class TestBuildDisplayName: + def test_river_at_location(self): + assert build_display_name("Little Deschutes", "La Pine") == "Little Deschutes at La Pine" + + def test_river_only(self): + assert build_display_name("Little Deschutes", "") == "Little Deschutes" From 5a65eb69da843bb2a6a8153c1d5920d3c362dcd4 Mon Sep 17 00:00:00 2001 From: Pat Welch Date: Wed, 15 Jul 2026 12:18:04 -0700 Subject: [PATCH 3/6] fix: refuse a textPlot page whose header names an unmapped column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi --- src/kayak/parsers/nwrfc_textplot.py | 45 +++++++++++---- tests/test_parsers/test_nwrfc_textplot.py | 68 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/kayak/parsers/nwrfc_textplot.py b/src/kayak/parsers/nwrfc_textplot.py index 97f4879e..5bc4b7c8 100644 --- a/src/kayak/parsers/nwrfc_textplot.py +++ b/src/kayak/parsers/nwrfc_textplot.py @@ -14,10 +14,15 @@ in °F, which is the unit the rest of the pipeline stores. The schema is inferred from the column-header row at the top of the -table; pages without a recognisable header fall back to a 1-column -flow/inflow heuristic (covers truncated/error bodies and test fixtures). +table. A page with *no* header row falls back to a 1-column flow/inflow +heuristic (covers truncated/error bodies and test fixtures). A page whose +header row *is* present but names a column we don't map — ``pe=HP``'s +"Pool Height", say — yields nothing at all: the label is a known-unknown, +and guessing "flow" there would republish a pool elevation or a +temperature as a discharge. Stale is recoverable; wrong is not. """ +import logging import re from datetime import UTC, datetime @@ -26,6 +31,8 @@ from kayak.parsers.registry import register from kayak.utils.conversions import parse_datetime, safe_float +logger = logging.getLogger(__name__) + _LABEL_TO_DTYPE = { "stage": DataType.gauge, "discharge": DataType.flow, @@ -66,6 +73,10 @@ def parse_records( tz = "PDT" if "(pdt)" in header_lower else "PST" if "(pst)" in header_lower else None value_dtypes = self._infer_value_columns(text) + if not value_dtypes: + # Header named a column we don't map (already logged). Refuse the + # page rather than let a zero-width row regex quietly match nothing. + return [] # Build the row regex: datetime + N value cells (one each). value_re = r"\s*]*>\s*([\d.]+)\s*" * len(value_dtypes) @@ -96,10 +107,19 @@ def _infer_value_columns(text: str) -> list[DataType]: Date/Time (PDT)StageDischarge The observed columns are everything up to the *second* Date/Time - cell (which begins the forecast half). If no such header is - present — truncated body, error page, or the simplified shape - used in unit tests — fall back to a 1-column schema and infer - flow vs. inflow from the surrounding text. + cell (which begins the forecast half). + + Returns ``[]`` when that header is present but names a column + outside ``_LABEL_TO_DTYPE``. The heuristic below is only for + bodies with *no* header — truncated, error pages, or the + simplified shape used in unit tests — where a 1-column + flow/inflow guess is the best available. Once a header has + parsed, the page has told us its schema, and an unmapped label + means we don't understand it: emitting nothing (a visibly stale + gauge) beats relabelling the column as flow. Note this refusal is + per-page and total, because the 1-column fallback would otherwise + re-capture column 1 under the wrong type and corrupt an adjacent + *known* column too. """ m = re.search( r"\s*]*>\s*Date/Time[^<]*" @@ -113,12 +133,17 @@ def _infer_value_columns(text: str) -> list[DataType]: (i for i, c in enumerate(cells) if "date/time" in c.lower()), len(cells), ) + observed = [c.strip() for c in cells[:forecast_split]] dtypes: list[DataType] = [] - for c in cells[:forecast_split]: - dt = _LABEL_TO_DTYPE.get(c.strip().lower()) + for c in observed: + dt = _LABEL_TO_DTYPE.get(c.lower()) if dt is None: - dtypes = [] - break + logger.error( + "unmapped textPlot column %r in header %r; storing nothing", + c, + observed, + ) + return [] dtypes.append(dt) if dtypes: return dtypes diff --git a/tests/test_parsers/test_nwrfc_textplot.py b/tests/test_parsers/test_nwrfc_textplot.py index 4df78f23..cb07e34a 100644 --- a/tests/test_parsers/test_nwrfc_textplot.py +++ b/tests/test_parsers/test_nwrfc_textplot.py @@ -87,6 +87,30 @@ """ +# pe=HP: reservoir pool elevation, a column we deliberately do not map. +# Shape captured live from DETO3 (Detroit Lake) on 2026-07-15, where the +# real page serves ~1031 rows of pool height in feet. +TEXTPLOT_HP_POOL_HEIGHT = """\ + + + + + +
Observed
Date/Time (PDT)Pool Height
2024-06-15 08:151543.71
2024-06-15 08:001543.702
+""" + +# A known column (Stage) sitting next to an unmapped one. The 1-column +# fallback used to re-capture Stage as flow, corrupting a column we do +# understand — so an unmapped label has to void the whole page. +TEXTPLOT_MIXED_KNOWN_AND_UNKNOWN = """\ + + + + + +
Date/Time (PDT)StagePool HeightDate/Time (PDT)StagePool Height
2024-06-15 15:459.731543.712024-06-15 17:009.711543.70
+""" + # pe=HG on a gage-only NWRFC station: only Stage appears in the observed # half. Captured live from OCUO3 (Willamette Upper Falls) on 2026-05-11. TEXTPLOT_HG_STAGE_ONLY = """\ @@ -272,3 +296,47 @@ def test_tw_header_infers_temperature_column(self): """The observed half of a pe=TW page is a single Temperature column.""" parser = NWRFCTextPlotParser.__new__(NWRFCTextPlotParser) assert parser._infer_value_columns(TEXTPLOT_TW_TEMPERATURE) == [DataType.temperature] + + +class TestNWRFCTextPlotUnmappedColumn: + """A parsed header naming a column we don't map must store nothing. + + The old code fell through to a 1-column ``flow`` guess, which is how a + 69.4 °F reading would have become 69.4 cfs. The same fallback still + reaches ``pe=HP``, which NWRFC serves today: Detroit Lake's pool + elevation of 1543.71 ft would post as 1543.71 cfs. + """ + + def test_pool_height_stores_nothing(self, session): + src = _make_source(session, name="hp_pool") + parser = NWRFCTextPlotParser( + url="https://www.nwrfc.noaa.gov/station/flowplot/textPlot.cgi?id=DETO3&pe=HP", + session=session, + source_id=src.id, + ) + assert parser.parse(TEXTPLOT_HP_POOL_HEIGHT) == 0 + assert session.query(Observation).filter_by(source_id=src.id).all() == [] + + def test_unmapped_column_does_not_corrupt_a_known_neighbour(self, session): + """Stage must not be re-emitted as flow just because a sibling is unknown.""" + src = _make_source(session, name="hp_mixed") + parser = NWRFCTextPlotParser( + url="https://www.nwrfc.noaa.gov/station/flowplot/textPlot.cgi?id=DETO3&pe=HG", + session=session, + source_id=src.id, + ) + assert parser.parse(TEXTPLOT_MIXED_KNOWN_AND_UNKNOWN) == 0 + obs = session.query(Observation).filter_by(source_id=src.id).all() + assert obs == [] + assert not any(o.value == 9.73 for o in obs), "stage leaked as another data type" + + def test_infer_returns_empty_rather_than_guessing_flow(self): + parser = NWRFCTextPlotParser.__new__(NWRFCTextPlotParser) + assert parser._infer_value_columns(TEXTPLOT_HP_POOL_HEIGHT) == [] + assert parser._infer_value_columns(TEXTPLOT_MIXED_KNOWN_AND_UNKNOWN) == [] + + def test_headerless_bodies_still_use_the_heuristic(self): + """The fallback survives for the case it was actually written for.""" + parser = NWRFCTextPlotParser.__new__(NWRFCTextPlotParser) + assert parser._infer_value_columns(TEXTPLOT_FLOW) == [DataType.flow] + assert parser._infer_value_columns(TEXTPLOT_INFLOW) == [DataType.inflow] From 1aa623f66c285e36eb9fa8a16427249f237a5fc9 Mon Sep 17 00:00:00 2001 From: Pat Welch Date: Wed, 15 Jul 2026 12:18:22 -0700 Subject: [PATCH 4/6] feat: order Salmon-basin forks by confluence, not alphabetically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi --- scripts/seed_gauge_display.py | 74 ++++++++++++++- tests/test_scripts/test_seed_gauge_display.py | 91 +++++++++++++++++++ 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/scripts/seed_gauge_display.py b/scripts/seed_gauge_display.py index e3d2e5ca..573e9c05 100644 --- a/scripts/seed_gauge_display.py +++ b/scripts/seed_gauge_display.py @@ -14,7 +14,9 @@ N/S/mainstem rows in the Umpqua drainage) - fork_rank: ``0`` for fork rows, ``9`` for mainstem → forks sort first - fork_label: ``north`` / ``south`` / ``east`` / ``west`` / ``middle`` / - ``little`` (empty for mainstem) — distinguishes forks in the same basin + ``little`` / ``yankee`` (empty for mainstem) — distinguishes forks in the + same basin. Alphabetical by default; a basin listed in ``_FORK_ORDER`` + gets an ``a-``/``b-``… prefix instead, ordering its forks by confluence. - elev_key: ``10000 - elevation`` zero-padded so higher elevation sorts first (upstream ≈ higher); NULL → sentinel pushing row to end - da_key: drainage_area zero-padded so smaller catchment sorts first @@ -70,6 +72,45 @@ } ) _LITTLE_RE = re.compile(r"^Little\s+", re.IGNORECASE) +# Forks named for something other than a direction, peeled like one so the row +# joins its parent's basin ("Yankee Fork Salmon" → Salmon/yankee). +# +# An allowlist rather than a general "{Word} Fork {Basin}" rule, which would +# also reclassify "Coast Fork Willamette" — true of the river, but a page +# reorder nobody asked for. ("Oak Grove Fork" and "Clark Fork" name no basin +# after "Fork", so no rule would touch them either way.) +# +# Unlike a direction, a name only marks a fork when "Fork" actually follows: +# "North Umpqua" is a fork, but "Yankee Creek" is a creek. The peel below +# therefore requires the literal "Fork" after these, which also keeps the next +# entry from misreading, say, "Bear Creek" as ("Creek", "bear"). +_NAMED_FORKS = ("Yankee",) +# Basins whose forks are ordered explicitly instead of alphabetically, listed +# upstream → downstream by where each fork's water joins the mainstem. The +# default sort is the fork label itself, which carries no geography: it reads +# well where the alphabet cooperates, but strands "yankee" behind "south". +# +# The Salmon sequence is the club's own, taken from reach.csv's curated +# "Salmon ag NN": ag 01 Yankee Fork, ag 02 EF Salmon, ag 03 MF, ag 04 EF of SF, +# ag 06-08 SF, ag 09-10 Little. Of those, the forks carrying a gauge give the +# order below. +# +# Gauge elevation is NOT the authority, though it looks like one: EFSF's gauge +# is the highest in the basin at 6466 ft, yet its water reaches the mainstem +# last of the four — it is a fork of the *South* Fork, so it arrives at the SF +# confluence, well downstream of the Middle Fork's. +# +# Caveat: basin_and_fork keeps only the first modifier, so EF Salmon (ag 02) +# and EF of SF Salmon (ag 04) both reduce to "east" and cannot both be placed. +# Only the latter has a gauge today; adding an EF Salmon gauge means splitting +# the label, not reordering this list. +# +# Only the named basin is affected; everywhere else stays alphabetical. A fork +# absent from its basin's list keeps its bare label, which sorts after the +# ranked ones and so lands at the end of the fork group, ahead of the mainstem. +_FORK_ORDER = { + "salmon": ("yankee", "middle", "east", "south", "little"), +} _DIRECTION_LETTERS = { "N": "North", "S": "South", @@ -232,12 +273,18 @@ def basin_and_fork(river: str) -> tuple[str, str]: ``Little North Santiam`` → (``Santiam``, ``little``) ``Little White Salmon`` → (``Little White Salmon``, ``"")`` # own river ``Little`` → (``Little``, ``"")`` # own river + + A ``_NAMED_FORKS`` entry peels only with an explicit ``Fork`` after it, + so ``Yankee Fork Salmon`` → (``Salmon``, ``yankee``) while + ``Yankee Creek`` stays (``Yankee Creek``, ``""``). """ if not river: return "", "" - dir_re = "|".join(_DIRECTIONS) + alts = [rf"(?P{'|'.join(_DIRECTIONS)})(?:\s+Fork)?"] + if _NAMED_FORKS: + alts.append(rf"(?P{'|'.join(_NAMED_FORKS)})\s+Fork") peel_re = re.compile( - rf"^(?:of\s+(?:the\s+)?)?(?P{dir_re})(?:\s+Fork)?\s+", + rf"^(?:of\s+(?:the\s+)?)?(?:{'|'.join(alts)})\s+", re.IGNORECASE, ) s = river @@ -251,17 +298,36 @@ def basin_and_fork(river: str) -> tuple[str, str]: m = peel_re.match(s) if not m: break - dirs_seen.append(m.group("dir").lower()) + # Exactly one of the two alternatives matched; "named" is absent + # entirely when _NAMED_FORKS is empty, hence groupdict(). + token = m.group("dir") or m.groupdict().get("named") or "" + dirs_seen.append(token.lower()) s = s[m.end() :] if dirs_seen and s.strip(): return s.strip(), dirs_seen[0] return river, "" +def _rank_fork(basin: str, fork: str) -> str: + """Prefix a curated basin's fork label so it sorts in confluence order. + + ``east`` → ``a-east`` in the Salmon basin; elsewhere (and for a fork the + basin's list doesn't name) the label is returned unchanged and keeps + sorting alphabetically. + """ + if not fork: + return fork + order = _FORK_ORDER.get(basin.lower()) + if order is None or fork not in order: + return fork + return f"{chr(ord('a') + order.index(fork))}-{fork}" + + def build_sort_name(river: str, elevation: float | None, drainage_area: float | None) -> str: """Compose the single alphabetical key described in the module docstring.""" basin, fork = basin_and_fork(river or "") fork_rank = "0" if fork else "9" + fork = _rank_fork(basin, fork) # Elevation DESC: invert so higher → smaller numeric. Sentinel 15000 for # NULL pushes rows without elevation to the end of their group. if elevation is not None: diff --git a/tests/test_scripts/test_seed_gauge_display.py b/tests/test_scripts/test_seed_gauge_display.py index 289e1cf6..d5d2770d 100644 --- a/tests/test_scripts/test_seed_gauge_display.py +++ b/tests/test_scripts/test_seed_gauge_display.py @@ -13,6 +13,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) from seed_gauge_display import ( + _FORK_ORDER, basin_and_fork, build_display_name, build_sort_name, @@ -69,10 +70,100 @@ def test_little_stays_put_when_not_a_known_tributary(self, river, expected): """ "Little" is not a blanket modifier — independent rivers keep their basin.""" assert basin_and_fork(river) == expected + def test_named_fork_peels_to_parent_basin(self): + """A fork named for something other than a direction still peels.""" + assert basin_and_fork("Yankee Fork Salmon") == ("Salmon", "yankee") + + @pytest.mark.parametrize( + "river", + [ + # A named fork peels only with an explicit "Fork" after it — + # otherwise "Yankee" would eat the first word of any river. + "Yankee Creek", + "Yankee Boy Creek", + "Yankee", + ], + ) + def test_named_fork_requires_the_word_fork(self, river): + assert basin_and_fork(river) == (river, "") + + def test_direction_still_peels_without_the_word_fork(self): + """The relaxation stays for directions: "North Umpqua" IS a fork.""" + assert basin_and_fork("North Umpqua") == ("Umpqua", "north") + + @pytest.mark.parametrize( + "river", + [ + # A general "{Word} Fork {Basin}" rule would reclassify this; the + # allowlist deliberately does not. + "Coast Fork Willamette", + # No basin follows "Fork", so there is nothing to peel onto. + "Oak Grove Fork", + "Clark Fork", + ], + ) + def test_other_fork_names_are_untouched(self, river): + assert basin_and_fork(river) == (river, "") + def test_empty(self): assert basin_and_fork("") == ("", "") +class TestCuratedForkOrder: + def test_fork_order_matches_the_clubs_own_reach_curation(self): + """_FORK_ORDER must agree with an authority outside this module. + + kayak_data's reach.csv already curates this basin, via `sort_name` + "Salmon ag NN": ag 01 Yankee Fork, ag 02 EF Salmon, ag 03 MF, + ag 04 EF of SF, ag 06-08 SF, ag 09-10 Little. Restricted to the + forks that carry a gauge, that is the tuple below. + + Asserted against that citation rather than against a list mirroring + the constant, which would pass for whatever order happened to be + shipped. If this fails, re-derive it from reach.csv — do not simply + paste in the new value. + """ + assert _FORK_ORDER["salmon"] == ("yankee", "middle", "east", "south", "little") + + def test_yankee_fork_precedes_middle_fork_salmon(self): + """The user-visible requirement, stated independently of the prefixes.""" + yankee = build_sort_name("Yankee Fork Salmon", 5950.0, 189.0) + mf = build_sort_name("Middle Fork Salmon", 4384.4, 1042.0) + assert yankee < mf + + def test_efsf_gauge_sorts_below_middle_fork_despite_being_higher(self): + """Elevation must not drive fork order. + + EFSF's gauge is the basin's highest (6466 ft), but it is a fork of + the South Fork and joins the mainstem downstream of the MF. Ranking + forks by gauge elevation put it first; the club's curation puts it + fourth. This is the case that caught that error. + """ + efsf = build_sort_name("East Fork South Fork Salmon", 6466.0, 19.3) + mf = build_sort_name("Middle Fork Salmon", 4384.4, 1042.0) + assert efsf > mf, "EFSF must not lead the basin on gauge elevation" + assert efsf < build_sort_name("South Fork Salmon", 3750.0, 330.0) + + def test_curated_forks_still_precede_the_mainstem(self): + assert build_sort_name("Little Salmon", 1755.28, 576.0) < build_sort_name( + "Salmon", 5900.0, 807.0 + ) + + def test_other_basins_stay_alphabetical(self): + """_FORK_ORDER names only the Salmon basin; nothing else is re-ranked.""" + assert build_sort_name("North Santiam", 655.0, 654.0) == "santiam|0north|009345|000654" + assert build_sort_name("Middle Fork Willamette", 600.0, 100.0).startswith( + "willamette|0middle|" + ) + + def test_unlisted_fork_in_curated_basin_falls_back_to_its_label(self): + """It keeps a bare label and lands after the ranked forks, before mainstem.""" + west = build_sort_name("West Fork Salmon", 5000.0, 50.0) + assert west.startswith("salmon|0west|") + assert west > build_sort_name("Little Salmon", 1755.28, 576.0) # after ranked forks + assert west < build_sort_name("Salmon", 5900.0, 807.0) # before mainstem + + class TestBuildSortName: def test_little_deschutes_sorts_ahead_of_mainstem_deschutes(self): """The Little Deschutes must precede every mainstem Deschutes gauge. From a05e0bed240580a9e3ffe01153976764b39eee15 Mon Sep 17 00:00:00 2001 From: Pat Welch Date: Wed, 15 Jul 2026 13:42:24 -0700 Subject: [PATCH 5/6] fix: close the two holes adversarial review found in the new rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi --- scripts/seed_gauge_display.py | 29 ++++++++++--- src/kayak/parsers/nwrfc_textplot.py | 13 ++++-- tests/test_scripts/test_seed_gauge_display.py | 41 +++++++++++++++++-- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/scripts/seed_gauge_display.py b/scripts/seed_gauge_display.py index 573e9c05..2f55d4dd 100644 --- a/scripts/seed_gauge_display.py +++ b/scripts/seed_gauge_display.py @@ -16,7 +16,8 @@ - fork_label: ``north`` / ``south`` / ``east`` / ``west`` / ``middle`` / ``little`` / ``yankee`` (empty for mainstem) — distinguishes forks in the same basin. Alphabetical by default; a basin listed in ``_FORK_ORDER`` - gets an ``a-``/``b-``… prefix instead, ordering its forks by confluence. + gets a ``00-``/``01-``… rank prefix instead, ordering its forks by + confluence (``99-`` for one the list doesn't name). - elev_key: ``10000 - elevation`` zero-padded so higher elevation sorts first (upstream ≈ higher); NULL → sentinel pushing row to end - da_key: drainage_area zero-padded so smaller catchment sorts first @@ -111,6 +112,11 @@ _FORK_ORDER = { "salmon": ("yankee", "middle", "east", "south", "little"), } +# Rank given to a fork inside a ranked basin that the basin's list doesn't +# name. Above any real index, so such a fork sorts after every curated one +# regardless of its initial letter (and still ahead of the mainstem, which is +# ranked by fork_rank 9 one field over). +_UNRANKED_FORK = "99" _DIRECTION_LETTERS = { "N": "North", "S": "South", @@ -311,16 +317,27 @@ def basin_and_fork(river: str) -> tuple[str, str]: def _rank_fork(basin: str, fork: str) -> str: """Prefix a curated basin's fork label so it sorts in confluence order. - ``east`` → ``a-east`` in the Salmon basin; elsewhere (and for a fork the - basin's list doesn't name) the label is returned unchanged and keeps - sorting alphabetically. + ``yankee`` → ``00-yankee`` in the Salmon basin. A basin with no entry in + ``_FORK_ORDER`` is returned untouched and keeps sorting alphabetically. + + Inside a ranked basin, a fork the list doesn't name is prefixed too — with + a rank above every real one, so it lands at the end of the fork group *by + construction*. It used to be returned bare, which put it after the ranked + forks only when its own first letter happened to fall later: true for + ``north``/``west``, false the moment ``_NAMED_FORKS`` gains an early + letter. A "Coast Fork Salmon" would have led the basin. + + The rank is numeric rather than ``chr(ord("a") + i)``, which would walk + past ``z`` — and then into ``|``, the field delimiter — at 26 forks. """ if not fork: return fork order = _FORK_ORDER.get(basin.lower()) - if order is None or fork not in order: + if order is None: return fork - return f"{chr(ord('a') + order.index(fork))}-{fork}" + if fork not in order: + return f"{_UNRANKED_FORK}-{fork}" + return f"{order.index(fork):02d}-{fork}" def build_sort_name(river: str, elevation: float | None, drainage_area: float | None) -> str: diff --git a/src/kayak/parsers/nwrfc_textplot.py b/src/kayak/parsers/nwrfc_textplot.py index 5bc4b7c8..b309a3ba 100644 --- a/src/kayak/parsers/nwrfc_textplot.py +++ b/src/kayak/parsers/nwrfc_textplot.py @@ -145,9 +145,16 @@ def _infer_value_columns(text: str) -> list[DataType]: ) return [] dtypes.append(dt) - if dtypes: - return dtypes - + # Unconditional: a header that parsed is the page stating its + # schema, so the heuristic below must stay out of it. An empty + # observed half (forecast_split == 0) is still a statement — it + # says there are no observed columns — and returning [] here is + # what keeps `if dtypes:` from quietly handing it to the flow + # guess, the one shape this whole method argues against. + return dtypes + + # Reached only when the body carries no header row at all: truncated + # responses, error pages, the simplified unit fixtures. if ">inflow<" in text.lower(): return [DataType.inflow] return [DataType.flow] diff --git a/tests/test_scripts/test_seed_gauge_display.py b/tests/test_scripts/test_seed_gauge_display.py index d5d2770d..3337fc68 100644 --- a/tests/test_scripts/test_seed_gauge_display.py +++ b/tests/test_scripts/test_seed_gauge_display.py @@ -14,6 +14,7 @@ from seed_gauge_display import ( _FORK_ORDER, + _rank_fork, basin_and_fork, build_display_name, build_sort_name, @@ -156,13 +157,45 @@ def test_other_basins_stay_alphabetical(self): "willamette|0middle|" ) - def test_unlisted_fork_in_curated_basin_falls_back_to_its_label(self): - """It keeps a bare label and lands after the ranked forks, before mainstem.""" + @pytest.mark.parametrize("label", ["west", "north", "coast", "bear", "any"]) + def test_unlisted_fork_in_ranked_basin_sorts_after_every_ranked_fork(self, label): + """Placement must be structural, not an accident of the initial letter. + + The bare-label version of this passed only because the reachable + unlisted labels (`north`, `west`) happen to fall after the `a-`..`e-` + prefixes it was compared against. `coast` and `bear` are the cases + that exposed it: under that scheme a Coast Fork Salmon led the basin, + ahead of the curated order. Testing only `west` is how it survived. + """ + unranked = _rank_fork("Salmon", label) + for ranked in _FORK_ORDER["salmon"]: + assert unranked > _rank_fork("Salmon", ranked), ( + f"{label!r} must sort after ranked fork {ranked!r}" + ) + + def test_unlisted_fork_still_precedes_the_mainstem(self): + """End-to-end via a label that actually peels today ("West" is a direction). + + `coast`/`bear` can only be reached through _rank_fork until someone + adds them to _NAMED_FORKS — which is exactly the edit that used to + break the ordering, hence the parametrized test above. + """ west = build_sort_name("West Fork Salmon", 5000.0, 50.0) - assert west.startswith("salmon|0west|") - assert west > build_sort_name("Little Salmon", 1755.28, 576.0) # after ranked forks + assert west.startswith("salmon|099-west|") + assert west > build_sort_name("Little Salmon", 1755.28, 576.0) # after ranked assert west < build_sort_name("Salmon", 5900.0, 807.0) # before mainstem + def test_unranked_basin_is_untouched(self): + """A basin with no _FORK_ORDER entry keeps bare, alphabetical labels.""" + assert _rank_fork("Santiam", "north") == "north" + assert _rank_fork("Umpqua", "south") == "south" + + def test_rank_prefix_cannot_reach_the_field_delimiter(self): + """Numeric ranks, so 26+ forks can't walk 'a'+i past 'z' into '|'.""" + for i, fork in enumerate(_FORK_ORDER["salmon"]): + assert _rank_fork("Salmon", fork) == f"{i:02d}-{fork}" + assert "|" not in _rank_fork("Salmon", fork) + class TestBuildSortName: def test_little_deschutes_sorts_ahead_of_mainstem_deschutes(self): From 07e7e8f33553965f71549af7efc93778a6fe7ab2 Mon Sep 17 00:00:00 2001 From: Pat Welch Date: Wed, 15 Jul 2026 14:09:52 -0700 Subject: [PATCH 6/6] docs: name gauge 26 by its display_name, not its internal name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi --- tests/test_scripts/test_seed_gauge_display.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_scripts/test_seed_gauge_display.py b/tests/test_scripts/test_seed_gauge_display.py index 3337fc68..9bae1c0b 100644 --- a/tests/test_scripts/test_seed_gauge_display.py +++ b/tests/test_scripts/test_seed_gauge_display.py @@ -201,7 +201,8 @@ class TestBuildSortName: def test_little_deschutes_sorts_ahead_of_mainstem_deschutes(self): """The Little Deschutes must precede every mainstem Deschutes gauge. - Gauge 26 (Deschutes at Wickiup) is the first mainstem row by + Gauge 26 (display_name "Deschutes at La Pine"; its internal name + is Deschutes_Wickiup_merge) is the first mainstem row by elevation; fork_rank 0 puts the Little Deschutes ahead of it regardless of the fork's own elevation/DA. """