Skip to content

Commit 7bc0ac4

Browse files
jirhikerclaude
andcommitted
Collapse source class tables into one SOURCES registry
SOURCE_DICT and the two *_SOURCE_PAIRS tables each repeated the same connector classes, so adding a source meant editing three tables in lockstep (plus the orchestration list, fixed in the prior change). They are now derived from a single `SOURCES` registry of `SourceDef(key, site, waterlevel?, analyte?)`: SOURCE_DICT = {s.key: s.site ...} WATERLEVEL_SOURCE_PAIRS = {s.key: (s.site, s.waterlevel) for s with waterlevel} ANALYTE_SOURCE_PAIRS = {s.key: (s.site, s.analyte) for s with analyte} Adding a source is now one SourceDef entry (plus listing it under the parameters it serves in PARAMETER_SOURCE_MAP, which stays as authored data — it encodes which analytes each agency actually reports). tests/test_source_registry.py ties the registry to PARAMETER_SOURCE_MAP: the waterlevels agency list must equal the set of sources with a waterlevel class, and every analyte agency must have an analyte class — so a source wired in one place but not the other fails a test instead of silently dropping out. Iteration order of water_level_sources()/analyte_sources() is now source-key order (was a hand-curated order); full suite (306) confirms nothing depends on the old order. dg check defs clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 23ded14 commit 7bc0ac4

2 files changed

Lines changed: 109 additions & 34 deletions

File tree

backend/config.py

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# ===============================================================================
1616
import os
1717
import sys
18+
from dataclasses import dataclass
1819
from datetime import datetime, timedelta
1920
import shapely.wkt
2021
import yaml
@@ -96,45 +97,62 @@
9697
TDS: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]},
9798
}
9899

99-
SOURCE_DICT = {
100-
"bernco": BernCoSiteSource,
101-
"bor": BORSiteSource,
102-
"cabq": CABQSiteSource,
103-
"ebid": EBIDSiteSource,
104-
"nmbgmr_amp": NMBGMRSiteSource,
105-
"nmed_dwb": DWBSiteSource,
106-
"nmose_isc_seven_rivers": ISCSevenRiversSiteSource,
107-
"nmose_pod": NMOSEPODSiteSource,
108-
"nmose_roswell": NMOSERoswellSiteSource,
109-
"nwis": NWISSiteSource,
110-
"pvacd": PVACDSiteSource,
111-
"wqp": WQPSiteSource,
112-
}
100+
@dataclass(frozen=True)
101+
class SourceDef:
102+
"""One data source's class wiring, declared in a single place.
103+
104+
``site`` is the site-source class (every source has one). ``waterlevel`` and
105+
``analyte`` are the parameter-source classes for each group, or ``None`` when
106+
the source doesn't serve that group (e.g. ``bor`` is analyte-only; ``nmose_pod``
107+
is site-only). The ``SOURCE_DICT`` / ``*_SOURCE_PAIRS`` lookup tables below are
108+
derived from this, so adding a source is one ``SourceDef`` entry here (plus
109+
listing it under the parameters it serves in ``PARAMETER_SOURCE_MAP``)."""
110+
111+
key: str
112+
site: type
113+
waterlevel: type | None = None
114+
analyte: type | None = None
115+
116+
117+
# The single registry of sources. Order is the source-key order; it drives the
118+
# iteration order of water_level_sources()/analyte_sources(). A consistency test
119+
# (tests/test_source_registry.py) asserts this stays in sync with
120+
# PARAMETER_SOURCE_MAP so a source can't be wired in one place but not the other.
121+
SOURCES = (
122+
SourceDef("bernco", BernCoSiteSource, waterlevel=BernCoWaterLevelSource),
123+
SourceDef("bor", BORSiteSource, analyte=BORAnalyteSource),
124+
SourceDef("cabq", CABQSiteSource, waterlevel=CABQWaterLevelSource),
125+
SourceDef("ebid", EBIDSiteSource, waterlevel=EBIDWaterLevelSource),
126+
SourceDef(
127+
"nmbgmr_amp",
128+
NMBGMRSiteSource,
129+
waterlevel=NMBGMRWaterLevelSource,
130+
analyte=NMBGMRAnalyteSource,
131+
),
132+
SourceDef("nmed_dwb", DWBSiteSource, analyte=DWBAnalyteSource),
133+
SourceDef(
134+
"nmose_isc_seven_rivers",
135+
ISCSevenRiversSiteSource,
136+
waterlevel=ISCSevenRiversWaterLevelSource,
137+
analyte=ISCSevenRiversAnalyteSource,
138+
),
139+
SourceDef("nmose_pod", NMOSEPODSiteSource),
140+
SourceDef("nmose_roswell", NMOSERoswellSiteSource, waterlevel=NMOSERoswellWaterLevelSource),
141+
SourceDef("nwis", NWISSiteSource, waterlevel=NWISWaterLevelSource),
142+
SourceDef("pvacd", PVACDSiteSource, waterlevel=PVACDWaterLevelSource),
143+
SourceDef("wqp", WQPSiteSource, waterlevel=WQPWaterLevelSource, analyte=WQPAnalyteSource),
144+
)
113145

114-
SOURCE_KEYS = sorted(list(SOURCE_DICT.keys()))
146+
# Lookup tables derived from the registry — keep these read-only/derived; edit
147+
# SOURCES (and PARAMETER_SOURCE_MAP) instead.
148+
SOURCE_DICT = {s.key: s.site for s in SOURCES}
149+
SOURCE_KEYS = sorted(SOURCE_DICT)
115150

116-
# Per-source (site_source, parameter_source) class pairs, keyed by source key.
117-
# Insertion order mirrors the historical order of analyte_sources()/
118-
# water_level_sources(). source_pair() and the *_sources() methods build from
119-
# these so per-source unification can resolve a single source by key.
120151
ANALYTE_SOURCE_PAIRS = {
121-
"bor": (BORSiteSource, BORAnalyteSource),
122-
"wqp": (WQPSiteSource, WQPAnalyteSource),
123-
"nmose_isc_seven_rivers": (ISCSevenRiversSiteSource, ISCSevenRiversAnalyteSource),
124-
"nmbgmr_amp": (NMBGMRSiteSource, NMBGMRAnalyteSource),
125-
"nmed_dwb": (DWBSiteSource, DWBAnalyteSource),
152+
s.key: (s.site, s.analyte) for s in SOURCES if s.analyte is not None
126153
}
127-
128154
WATERLEVEL_SOURCE_PAIRS = {
129-
"nmbgmr_amp": (NMBGMRSiteSource, NMBGMRWaterLevelSource),
130-
"nmose_isc_seven_rivers": (ISCSevenRiversSiteSource, ISCSevenRiversWaterLevelSource),
131-
"nwis": (NWISSiteSource, NWISWaterLevelSource),
132-
"nmose_roswell": (NMOSERoswellSiteSource, NMOSERoswellWaterLevelSource),
133-
"pvacd": (PVACDSiteSource, PVACDWaterLevelSource),
134-
"bernco": (BernCoSiteSource, BernCoWaterLevelSource),
135-
"ebid": (EBIDSiteSource, EBIDWaterLevelSource),
136-
"cabq": (CABQSiteSource, CABQWaterLevelSource),
137-
"wqp": (WQPSiteSource, WQPWaterLevelSource),
155+
s.key: (s.site, s.waterlevel) for s in SOURCES if s.waterlevel is not None
138156
}
139157

140158

tests/test_source_registry.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Consistency tests for the source registry (backend.config.SOURCES).
2+
3+
These tie the derived lookup tables and the empirical PARAMETER_SOURCE_MAP back
4+
to the single SOURCES registry, so a source wired in one place but not another
5+
fails fast instead of silently dropping out of a parameter's source list.
6+
"""
7+
from backend.config import (
8+
SOURCES,
9+
SOURCE_DICT,
10+
SOURCE_KEYS,
11+
ANALYTE_SOURCE_PAIRS,
12+
WATERLEVEL_SOURCE_PAIRS,
13+
PARAMETER_SOURCE_MAP,
14+
)
15+
from backend.constants import WATERLEVELS
16+
17+
18+
def test_keys_unique():
19+
keys = [s.key for s in SOURCES]
20+
assert len(keys) == len(set(keys))
21+
22+
23+
def test_derived_tables_match_registry():
24+
assert SOURCE_DICT == {s.key: s.site for s in SOURCES}
25+
assert SOURCE_KEYS == sorted(s.key for s in SOURCES)
26+
assert WATERLEVEL_SOURCE_PAIRS == {
27+
s.key: (s.site, s.waterlevel) for s in SOURCES if s.waterlevel
28+
}
29+
assert ANALYTE_SOURCE_PAIRS == {
30+
s.key: (s.site, s.analyte) for s in SOURCES if s.analyte
31+
}
32+
33+
34+
def test_waterlevel_agencies_match_registry():
35+
# Every source the parameter map lists for waterlevels must have a
36+
# waterlevel source class — and vice versa.
37+
registry_wl = {s.key for s in SOURCES if s.waterlevel}
38+
map_wl = set(PARAMETER_SOURCE_MAP[WATERLEVELS]["agencies"])
39+
assert map_wl == registry_wl
40+
41+
42+
def test_analyte_agencies_have_analyte_source():
43+
# Every agency listed for any analyte must actually have an analyte source
44+
# class in the registry (the map is a subset per analyte; the registry is
45+
# the universe of analyte-capable sources).
46+
analyte_keys = {s.key for s in SOURCES if s.analyte}
47+
for parameter, entry in PARAMETER_SOURCE_MAP.items():
48+
if parameter == WATERLEVELS:
49+
continue
50+
missing = set(entry["agencies"]) - analyte_keys
51+
assert not missing, f"{parameter}: agencies without an analyte source: {missing}"
52+
53+
54+
def test_every_map_agency_is_a_known_source():
55+
for entry in PARAMETER_SOURCE_MAP.values():
56+
for agency in entry["agencies"]:
57+
assert agency in SOURCE_DICT

0 commit comments

Comments
 (0)