Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 52 additions & 34 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# ===============================================================================
import os
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta
import shapely.wkt
import yaml
Expand Down Expand Up @@ -96,45 +97,62 @@
TDS: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]},
}

SOURCE_DICT = {
"bernco": BernCoSiteSource,
"bor": BORSiteSource,
"cabq": CABQSiteSource,
"ebid": EBIDSiteSource,
"nmbgmr_amp": NMBGMRSiteSource,
"nmed_dwb": DWBSiteSource,
"nmose_isc_seven_rivers": ISCSevenRiversSiteSource,
"nmose_pod": NMOSEPODSiteSource,
"nmose_roswell": NMOSERoswellSiteSource,
"nwis": NWISSiteSource,
"pvacd": PVACDSiteSource,
"wqp": WQPSiteSource,
}
@dataclass(frozen=True)
class SourceDef:
"""One data source's class wiring, declared in a single place.

``site`` is the site-source class (every source has one). ``waterlevel`` and
``analyte`` are the parameter-source classes for each group, or ``None`` when
the source doesn't serve that group (e.g. ``bor`` is analyte-only; ``nmose_pod``
is site-only). The ``SOURCE_DICT`` / ``*_SOURCE_PAIRS`` lookup tables below are
derived from this, so adding a source is one ``SourceDef`` entry here (plus
listing it under the parameters it serves in ``PARAMETER_SOURCE_MAP``)."""

key: str
site: type
waterlevel: type | None = None
analyte: type | None = None


# The single registry of sources. Order is the source-key order; it drives the
# iteration order of water_level_sources()/analyte_sources(). A consistency test
# (tests/test_source_registry.py) asserts this stays in sync with
# PARAMETER_SOURCE_MAP so a source can't be wired in one place but not the other.
SOURCES = (
SourceDef("bernco", BernCoSiteSource, waterlevel=BernCoWaterLevelSource),
SourceDef("bor", BORSiteSource, analyte=BORAnalyteSource),
SourceDef("cabq", CABQSiteSource, waterlevel=CABQWaterLevelSource),
SourceDef("ebid", EBIDSiteSource, waterlevel=EBIDWaterLevelSource),
SourceDef(
"nmbgmr_amp",
NMBGMRSiteSource,
waterlevel=NMBGMRWaterLevelSource,
analyte=NMBGMRAnalyteSource,
),
SourceDef("nmed_dwb", DWBSiteSource, analyte=DWBAnalyteSource),
SourceDef(
"nmose_isc_seven_rivers",
ISCSevenRiversSiteSource,
waterlevel=ISCSevenRiversWaterLevelSource,
analyte=ISCSevenRiversAnalyteSource,
),
SourceDef("nmose_pod", NMOSEPODSiteSource),
SourceDef("nmose_roswell", NMOSERoswellSiteSource, waterlevel=NMOSERoswellWaterLevelSource),
SourceDef("nwis", NWISSiteSource, waterlevel=NWISWaterLevelSource),
SourceDef("pvacd", PVACDSiteSource, waterlevel=PVACDWaterLevelSource),
SourceDef("wqp", WQPSiteSource, waterlevel=WQPWaterLevelSource, analyte=WQPAnalyteSource),
)

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

# Per-source (site_source, parameter_source) class pairs, keyed by source key.
# Insertion order mirrors the historical order of analyte_sources()/
# water_level_sources(). source_pair() and the *_sources() methods build from
# these so per-source unification can resolve a single source by key.
ANALYTE_SOURCE_PAIRS = {
"bor": (BORSiteSource, BORAnalyteSource),
"wqp": (WQPSiteSource, WQPAnalyteSource),
"nmose_isc_seven_rivers": (ISCSevenRiversSiteSource, ISCSevenRiversAnalyteSource),
"nmbgmr_amp": (NMBGMRSiteSource, NMBGMRAnalyteSource),
"nmed_dwb": (DWBSiteSource, DWBAnalyteSource),
s.key: (s.site, s.analyte) for s in SOURCES if s.analyte is not None
}

WATERLEVEL_SOURCE_PAIRS = {
"nmbgmr_amp": (NMBGMRSiteSource, NMBGMRWaterLevelSource),
"nmose_isc_seven_rivers": (ISCSevenRiversSiteSource, ISCSevenRiversWaterLevelSource),
"nwis": (NWISSiteSource, NWISWaterLevelSource),
"nmose_roswell": (NMOSERoswellSiteSource, NMOSERoswellWaterLevelSource),
"pvacd": (PVACDSiteSource, PVACDWaterLevelSource),
"bernco": (BernCoSiteSource, BernCoWaterLevelSource),
"ebid": (EBIDSiteSource, EBIDWaterLevelSource),
"cabq": (CABQSiteSource, CABQWaterLevelSource),
"wqp": (WQPSiteSource, WQPWaterLevelSource),
s.key: (s.site, s.waterlevel) for s in SOURCES if s.waterlevel is not None
}


Expand Down
57 changes: 57 additions & 0 deletions tests/test_source_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Consistency tests for the source registry (backend.config.SOURCES).

These tie the derived lookup tables and the empirical PARAMETER_SOURCE_MAP back
to the single SOURCES registry, so a source wired in one place but not another
fails fast instead of silently dropping out of a parameter's source list.
"""
from backend.config import (
SOURCES,
SOURCE_DICT,
SOURCE_KEYS,
ANALYTE_SOURCE_PAIRS,
WATERLEVEL_SOURCE_PAIRS,
PARAMETER_SOURCE_MAP,
)
from backend.constants import WATERLEVELS


def test_keys_unique():
keys = [s.key for s in SOURCES]
assert len(keys) == len(set(keys))


def test_derived_tables_match_registry():
assert SOURCE_DICT == {s.key: s.site for s in SOURCES}
assert SOURCE_KEYS == sorted(s.key for s in SOURCES)
assert WATERLEVEL_SOURCE_PAIRS == {
s.key: (s.site, s.waterlevel) for s in SOURCES if s.waterlevel
}
assert ANALYTE_SOURCE_PAIRS == {
s.key: (s.site, s.analyte) for s in SOURCES if s.analyte
}


def test_waterlevel_agencies_match_registry():
# Every source the parameter map lists for waterlevels must have a
# waterlevel source class — and vice versa.
registry_wl = {s.key for s in SOURCES if s.waterlevel}
map_wl = set(PARAMETER_SOURCE_MAP[WATERLEVELS]["agencies"])
assert map_wl == registry_wl


def test_analyte_agencies_have_analyte_source():
# Every agency listed for any analyte must actually have an analyte source
# class in the registry (the map is a subset per analyte; the registry is
# the universe of analyte-capable sources).
analyte_keys = {s.key for s in SOURCES if s.analyte}
for parameter, entry in PARAMETER_SOURCE_MAP.items():
if parameter == WATERLEVELS:
continue
missing = set(entry["agencies"]) - analyte_keys
assert not missing, f"{parameter}: agencies without an analyte source: {missing}"


def test_every_map_agency_is_a_known_source():
for entry in PARAMETER_SOURCE_MAP.values():
for agency in entry["agencies"]:
assert agency in SOURCE_DICT
Loading