mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
Closes Section A.5 (gauge_sites) and A.12 (town_anchors) of the audit
doc by lifting both Python-dict curation lists into editable SQLite
tables. Operators can add/edit/disable rows from the dashboard without
a deploy; runtime reads go through cached accessors that invalidate
when the REST API mutates state.
Schema:
v8.sql adds gauge_sites(site_id PK, gauge_name, lat, lon, action_ft,
flood_minor_ft, flood_moderate_ft, flood_major_ft, enabled, updated_at).
v9.sql adds town_anchors(anchor_id AUTOINC PK, name UNIQUE, lat, lon,
state, enabled, updated_at).
SCHEMA_VERSION 7 -> 9.
Seed (meshai/persistence/curation.py):
_GAUGE_SITES_SEED carries the original 9 Idaho rows from
IDAHO_CURATED_SITES verbatim.
_TOWN_ANCHORS_SEED carries the 29 Idaho-and-neighbor towns from
_TOWN_COORDS verbatim.
seed_gauge_sites() / seed_town_anchors() INSERT OR IGNORE -- safe to
re-run; never overwrites user edits.
Handler integration:
- meshai/central/idaho_gauge_sites.py: IDAHO_CURATED_SITES dict deleted.
lookup_site() now calls meshai.persistence.curation.lookup_gauge_site()
which reads the table. THRESHOLD_RANK, normalize_site_id, and
compute_threshold_state remain in this module (CODE per Matt s rule).
- meshai/central/nwis_handler.py drops IDAHO_CURATED_SITES from its
import list; the table-backed lookup_site() is API-compatible.
- meshai/central_normalizer.py: _TOWN_COORDS dict deleted.
_compute_distance_bearing() now calls
meshai.persistence.curation.lookup_town_anchor() with the same
lowercased-name semantics it always used.
REST API (meshai/dashboard/api/curation_routes.py):
/api/gauge-sites GET list, GET one, POST add, PUT update, DELETE
/api/town-anchors GET list, GET one, POST add, PUT update, DELETE
Every mutation calls invalidate_curation_cache() so handler reads see
the new state on the next call -- no container restart.
Dashboard (dashboard-frontend/src/pages/):
- GaugeSites.tsx: table view with Add row / Edit row inline / Delete
confirm + per-row enabled toggle. 8 columns mirror the schema.
- TownAnchors.tsx: same pattern, 5 columns. Name is lowercased on
save to match the lookup key.
- Left-nav entries "Gauge Sites" (Droplets icon) and "Town Anchors"
(MapPin icon) added to Layout.tsx; routes added to App.tsx.
Tests (tests/test_curation.py, 18 cases):
- v8/v9 tables exist
- Seed lands every row from both dicts
- Seed idempotent; never overwrites user edits
- lookup_gauge_site hits/miss, disabled rows are invisible
- lookup_town_anchor case-insensitive
- REST API: GET list, GET one, GET 404, POST add, PUT update, DELETE,
POST missing-field 400; both gauge_sites + town_anchors
- Accessor reflects API mutations after invalidate_curation_cache()
tests/test_nwis_handler.py back-compat: IDAHO_CURATED_SITES dict alias
points at _GAUGE_SITES_SEED so the existing assertion suite still passes.
tests/test_adapter_config_foundation.py schema_meta v7 -> v9 bump.
Test count: 797 -> 819 (+18 curation cases + 4 maintenance updates).
241 lines
9.6 KiB
Python
241 lines
9.6 KiB
Python
"""v0.6-4 curation accessors + seed routines.
|
|
|
|
Both tables (gauge_sites, town_anchors) follow the same pattern as
|
|
adapter_config: created by a migration, seeded from Python data on first
|
|
boot, then runtime reads from SQLite via cached accessors.
|
|
|
|
gauge_sites replaces idaho_gauge_sites.IDAHO_CURATED_SITES.
|
|
town_anchors replaces central_normalizer._TOWN_COORDS.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from typing import Any, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# Seed data (the dict that used to live in handler code)
|
|
# ============================================================================
|
|
|
|
# Idaho stream-gauge sites originally from
|
|
# meshai/central/idaho_gauge_sites.py:IDAHO_CURATED_SITES (v0.5.12).
|
|
_GAUGE_SITES_SEED: dict[str, dict[str, Any]] = {
|
|
"USGS-13139510": {
|
|
"gauge_name": "Big Lost River near Mackay",
|
|
"lat": 43.910, "lon": -113.620,
|
|
"action_ft": 5.5, "flood_minor_ft": 7.0,
|
|
"flood_moderate_ft": None, "flood_major_ft": None,
|
|
},
|
|
"USGS-13186000": {
|
|
"gauge_name": "Snake River at Heise",
|
|
"lat": 43.612, "lon": -111.654,
|
|
"action_ft": 12.0, "flood_minor_ft": 14.0,
|
|
"flood_moderate_ft": 16.0, "flood_major_ft": None,
|
|
},
|
|
"USGS-13037500": {
|
|
"gauge_name": "Snake River at Idaho Falls",
|
|
"lat": 43.500, "lon": -112.034,
|
|
"action_ft": 8.5, "flood_minor_ft": 10.0,
|
|
"flood_moderate_ft": None, "flood_major_ft": None,
|
|
},
|
|
"USGS-13135500": {
|
|
"gauge_name": "Big Wood River near Hailey",
|
|
"lat": 43.533, "lon": -114.318,
|
|
"action_ft": 6.0, "flood_minor_ft": 7.5,
|
|
"flood_moderate_ft": None, "flood_major_ft": None,
|
|
},
|
|
"USGS-13205000": {
|
|
"gauge_name": "Boise River near Boise",
|
|
"lat": 43.690, "lon": -116.200,
|
|
"action_ft": 8.0, "flood_minor_ft": 10.5,
|
|
"flood_moderate_ft": None, "flood_major_ft": None,
|
|
},
|
|
"USGS-13247500": {
|
|
"gauge_name": "Payette River at Banks",
|
|
"lat": 44.080, "lon": -116.130,
|
|
"action_ft": 10.0, "flood_minor_ft": 12.0,
|
|
"flood_moderate_ft": None, "flood_major_ft": None,
|
|
},
|
|
"USGS-13057000": {
|
|
"gauge_name": "Henrys Fork near Rexburg",
|
|
"lat": 43.831, "lon": -111.781,
|
|
"action_ft": 9.0, "flood_minor_ft": 10.5,
|
|
"flood_moderate_ft": None, "flood_major_ft": None,
|
|
},
|
|
"USGS-13162225": {
|
|
"gauge_name": "Salmon Falls Creek near San Jacinto",
|
|
"lat": 42.180, "lon": -114.850,
|
|
"action_ft": 8.0, "flood_minor_ft": 10.0,
|
|
"flood_moderate_ft": None, "flood_major_ft": None,
|
|
},
|
|
"USGS-13083000": {
|
|
"gauge_name": "Bear River near Border WY/ID",
|
|
"lat": 42.214, "lon": -111.045,
|
|
"action_ft": 6.0, "flood_minor_ft": 8.0,
|
|
"flood_moderate_ft": None, "flood_major_ft": None,
|
|
},
|
|
}
|
|
|
|
# Idaho + neighbor towns originally from central_normalizer._TOWN_COORDS.
|
|
_TOWN_ANCHORS_SEED: dict[str, dict[str, Any]] = {
|
|
"boise": {"lat": 43.6150, "lon": -116.2023, "state": "ID"},
|
|
"meridian": {"lat": 43.6121, "lon": -116.3915, "state": "ID"},
|
|
"nampa": {"lat": 43.5407, "lon": -116.5635, "state": "ID"},
|
|
"caldwell": {"lat": 43.6629, "lon": -116.6874, "state": "ID"},
|
|
"idaho falls": {"lat": 43.4666, "lon": -112.0340, "state": "ID"},
|
|
"pocatello": {"lat": 42.8713, "lon": -112.4455, "state": "ID"},
|
|
"twin falls": {"lat": 42.5630, "lon": -114.4609, "state": "ID"},
|
|
"coeur d'alene": {"lat": 47.6777, "lon": -116.7805, "state": "ID"},
|
|
"lewiston": {"lat": 46.4165, "lon": -117.0177, "state": "ID"},
|
|
"moscow": {"lat": 46.7324, "lon": -117.0002, "state": "ID"},
|
|
"sandpoint": {"lat": 48.2766, "lon": -116.5535, "state": "ID"},
|
|
"post falls": {"lat": 47.7180, "lon": -116.9516, "state": "ID"},
|
|
"hayden": {"lat": 47.7660, "lon": -116.7866, "state": "ID"},
|
|
"rathdrum": {"lat": 47.8121, "lon": -116.8950, "state": "ID"},
|
|
"plummer": {"lat": 47.3344, "lon": -116.8856, "state": "ID"},
|
|
"kellogg": {"lat": 47.5380, "lon": -116.1352, "state": "ID"},
|
|
"bonners ferry": {"lat": 48.6914, "lon": -116.3181, "state": "ID"},
|
|
"rexburg": {"lat": 43.8260, "lon": -111.7897, "state": "ID"},
|
|
"blackfoot": {"lat": 43.1905, "lon": -112.3447, "state": "ID"},
|
|
"burley": {"lat": 42.5360, "lon": -113.7928, "state": "ID"},
|
|
"jerome": {"lat": 42.7252, "lon": -114.5187, "state": "ID"},
|
|
"mountain home": {"lat": 43.1330, "lon": -115.6912, "state": "ID"},
|
|
"stanley": {"lat": 44.2160, "lon": -114.9311, "state": "ID"},
|
|
"salmon": {"lat": 45.1758, "lon": -113.8957, "state": "ID"},
|
|
"mccall": {"lat": 44.9111, "lon": -116.0987, "state": "ID"},
|
|
"weiser": {"lat": 44.2510, "lon": -116.9690, "state": "ID"},
|
|
"soda springs": {"lat": 42.6543, "lon": -111.6047, "state": "ID"},
|
|
"preston": {"lat": 42.0963, "lon": -111.8766, "state": "ID"},
|
|
"montpelier": {"lat": 42.3232, "lon": -111.2980, "state": "ID"},
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Caches
|
|
# ============================================================================
|
|
|
|
_LOCK = threading.Lock()
|
|
_gauge_cache: Optional[dict[str, dict[str, Any]]] = None
|
|
_town_cache: Optional[dict[str, tuple[float, float]]] = None
|
|
|
|
|
|
def invalidate_curation_cache() -> None:
|
|
"""Drop the in-memory caches. Called by the REST API on POST/PUT/DELETE."""
|
|
global _gauge_cache, _town_cache
|
|
with _LOCK:
|
|
_gauge_cache = None
|
|
_town_cache = None
|
|
|
|
|
|
def _load_gauge_cache() -> dict[str, dict[str, Any]]:
|
|
global _gauge_cache
|
|
if _gauge_cache is not None:
|
|
return _gauge_cache
|
|
try:
|
|
from meshai.persistence import get_db
|
|
conn = get_db()
|
|
rows = conn.execute(
|
|
"SELECT site_id, gauge_name, lat, lon, action_ft, flood_minor_ft, "
|
|
"flood_moderate_ft, flood_major_ft FROM gauge_sites WHERE enabled=1"
|
|
).fetchall()
|
|
cache = {r["site_id"]: {
|
|
"gauge_name": r["gauge_name"],
|
|
"lat": r["lat"], "lon": r["lon"],
|
|
"action_ft": r["action_ft"],
|
|
"flood_minor_ft": r["flood_minor_ft"],
|
|
"flood_moderate_ft": r["flood_moderate_ft"],
|
|
"flood_major_ft": r["flood_major_ft"],
|
|
} for r in rows}
|
|
except Exception:
|
|
logger.exception("curation: gauge_sites cache load failed; using empty")
|
|
cache = {}
|
|
with _LOCK:
|
|
_gauge_cache = cache
|
|
return cache
|
|
|
|
|
|
def _load_town_cache() -> dict[str, tuple[float, float]]:
|
|
global _town_cache
|
|
if _town_cache is not None:
|
|
return _town_cache
|
|
try:
|
|
from meshai.persistence import get_db
|
|
conn = get_db()
|
|
rows = conn.execute(
|
|
"SELECT name, lat, lon FROM town_anchors WHERE enabled=1"
|
|
).fetchall()
|
|
cache = {r["name"].lower(): (r["lat"], r["lon"]) for r in rows}
|
|
except Exception:
|
|
logger.exception("curation: town_anchors cache load failed; using empty")
|
|
cache = {}
|
|
with _LOCK:
|
|
_town_cache = cache
|
|
return cache
|
|
|
|
|
|
# ============================================================================
|
|
# Lookups (called from handlers)
|
|
# ============================================================================
|
|
|
|
|
|
def lookup_gauge_site(site_id: str) -> Optional[dict[str, Any]]:
|
|
"""Return the row dict for `site_id` (canonical 'USGS-...' form) or None."""
|
|
cache = _load_gauge_cache()
|
|
return cache.get(site_id)
|
|
|
|
|
|
def lookup_town_anchor(name: str) -> Optional[tuple[float, float]]:
|
|
"""Return (lat, lon) for the lowercased town name, or None."""
|
|
if not name: return None
|
|
cache = _load_town_cache()
|
|
return cache.get(name.strip().lower())
|
|
|
|
|
|
# ============================================================================
|
|
# Seed routines (called from init_db after migrations)
|
|
# ============================================================================
|
|
|
|
|
|
def seed_gauge_sites(conn: sqlite3.Connection) -> int:
|
|
"""INSERT OR IGNORE one row per _GAUGE_SITES_SEED entry. Idempotent."""
|
|
now = time.time()
|
|
inserted = 0
|
|
for site_id, spec in _GAUGE_SITES_SEED.items():
|
|
cur = conn.execute(
|
|
"INSERT OR IGNORE INTO gauge_sites("
|
|
"site_id, gauge_name, lat, lon, action_ft, flood_minor_ft, "
|
|
"flood_moderate_ft, flood_major_ft, enabled, updated_at) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
(site_id, spec["gauge_name"], spec["lat"], spec["lon"],
|
|
spec.get("action_ft"), spec.get("flood_minor_ft"),
|
|
spec.get("flood_moderate_ft"), spec.get("flood_major_ft"),
|
|
1, now),
|
|
)
|
|
if cur.rowcount > 0:
|
|
inserted += 1
|
|
if inserted:
|
|
logger.info("curation: seeded %d gauge_sites rows", inserted)
|
|
return inserted
|
|
|
|
|
|
def seed_town_anchors(conn: sqlite3.Connection) -> int:
|
|
"""INSERT OR IGNORE one row per _TOWN_ANCHORS_SEED entry. Idempotent."""
|
|
now = time.time()
|
|
inserted = 0
|
|
for name, spec in _TOWN_ANCHORS_SEED.items():
|
|
cur = conn.execute(
|
|
"INSERT OR IGNORE INTO town_anchors("
|
|
"name, lat, lon, state, enabled, updated_at) "
|
|
"VALUES (?,?,?,?,?,?)",
|
|
(name, spec["lat"], spec["lon"], spec.get("state"), 1, now),
|
|
)
|
|
if cur.rowcount > 0:
|
|
inserted += 1
|
|
if inserted:
|
|
logger.info("curation: seeded %d town_anchors rows", inserted)
|
|
return inserted
|