mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
feat(v0.6-4): gauge_sites + town_anchors curation tables + GUI CRUD
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).
This commit is contained in:
parent
eb84f27941
commit
e3bf53ade4
20 changed files with 1322 additions and 272 deletions
|
|
@ -8,6 +8,8 @@ import Alerts from './pages/Alerts'
|
||||||
import Notifications from './pages/Notifications'
|
import Notifications from './pages/Notifications'
|
||||||
import Reference from './pages/Reference'
|
import Reference from './pages/Reference'
|
||||||
import AdapterConfig from './pages/AdapterConfig'
|
import AdapterConfig from './pages/AdapterConfig'
|
||||||
|
import GaugeSites from './pages/GaugeSites'
|
||||||
|
import TownAnchors from './pages/TownAnchors'
|
||||||
import { ToastProvider } from './components/ToastProvider'
|
import { ToastProvider } from './components/ToastProvider'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -23,6 +25,8 @@ function App() {
|
||||||
<Route path="/notifications" element={<Notifications />} />
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
<Route path="/reference" element={<Reference />} />
|
<Route path="/reference" element={<Reference />} />
|
||||||
<Route path="/adapter-config" element={<AdapterConfig />} />
|
<Route path="/adapter-config" element={<AdapterConfig />} />
|
||||||
|
<Route path="/gauge-sites" element={<GaugeSites />} />
|
||||||
|
<Route path="/town-anchors" element={<TownAnchors />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
BellRing,
|
BellRing,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Sliders,
|
Sliders,
|
||||||
|
Droplets,
|
||||||
|
MapPin,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||||
|
|
@ -27,6 +29,8 @@ const navItems = [
|
||||||
{ path: '/notifications', label: 'Notifications', icon: BellRing },
|
{ path: '/notifications', label: 'Notifications', icon: BellRing },
|
||||||
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
||||||
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
|
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
|
||||||
|
{ path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets },
|
||||||
|
{ path: '/town-anchors', label: 'Town Anchors', icon: MapPin },
|
||||||
]
|
]
|
||||||
|
|
||||||
function formatUptime(seconds: number): string {
|
function formatUptime(seconds: number): string {
|
||||||
|
|
|
||||||
198
dashboard-frontend/src/pages/GaugeSites.tsx
Normal file
198
dashboard-frontend/src/pages/GaugeSites.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
// v0.6-4 GaugeSites table editor.
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Loader2, Plus, Trash2, Check, X, Droplets } from 'lucide-react'
|
||||||
|
|
||||||
|
interface GaugeSite {
|
||||||
|
site_id: string
|
||||||
|
gauge_name: string
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
action_ft: number | null
|
||||||
|
flood_minor_ft: number | null
|
||||||
|
flood_moderate_ft: number | null
|
||||||
|
flood_major_ft: number | null
|
||||||
|
enabled: boolean
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DRAFT: GaugeSite = {
|
||||||
|
site_id: '', gauge_name: '', lat: 0, lon: 0,
|
||||||
|
action_ft: null, flood_minor_ft: null, flood_moderate_ft: null, flood_major_ft: null,
|
||||||
|
enabled: true, updated_at: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GaugeSites() {
|
||||||
|
const [rows, setRows] = useState<GaugeSite[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
|
const [draft, setDraft] = useState<GaugeSite>(EMPTY_DRAFT)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gauge-sites')
|
||||||
|
if (!res.ok) throw new Error(`GET: ${res.status}`)
|
||||||
|
setRows(await res.json())
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { refresh() }, [refresh])
|
||||||
|
|
||||||
|
const beginEdit = (r: GaugeSite) => { setEditing(r.site_id); setDraft({ ...r }); setAdding(false) }
|
||||||
|
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) }
|
||||||
|
const cancel = () => { setEditing(null); setAdding(false); setDraft(EMPTY_DRAFT) }
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
const url = adding ? '/api/gauge-sites' : `/api/gauge-sites/${editing}`
|
||||||
|
const method = adding ? 'POST' : 'PUT'
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(draft),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const b = await res.json().catch(() => ({}))
|
||||||
|
alert(`save failed: ${b.detail || res.statusText}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
refresh()
|
||||||
|
} catch (e) {
|
||||||
|
alert(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (siteId: string) => {
|
||||||
|
if (!confirm(`Delete ${siteId}?`)) return
|
||||||
|
const res = await fetch(`/api/gauge-sites/${siteId}`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) { alert(`delete failed: ${res.status}`); return }
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="p-6 text-slate-400"><Loader2 className="w-5 h-5 animate-spin inline mr-2" />Loading…</div>
|
||||||
|
if (error) return <div className="p-6 text-red-400">Load failed: {error}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Droplets className="w-5 h-5 text-cyan-400" />
|
||||||
|
<h1 className="text-xl font-semibold text-slate-100">Gauge Sites</h1>
|
||||||
|
<span className="text-xs text-slate-500 ml-2">{rows.length} sites</span>
|
||||||
|
<button onClick={beginAdd}
|
||||||
|
className="ml-auto flex items-center gap-1 px-3 py-1 bg-cyan-700 hover:bg-cyan-600 rounded text-white text-sm">
|
||||||
|
<Plus className="w-4 h-4" /> Add site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 max-w-3xl">
|
||||||
|
NWS-AHPS stream gauge thresholds curated for the nwis_handler. Disabled rows are
|
||||||
|
ignored at envelope time. Changes propagate to the handler on the next event.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />}
|
||||||
|
|
||||||
|
<div className="bg-slate-800/60 border border-slate-700 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-slate-200">
|
||||||
|
<thead className="bg-slate-900 text-xs text-slate-400 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left">Site ID</th>
|
||||||
|
<th className="px-3 py-2 text-left">Name</th>
|
||||||
|
<th className="px-3 py-2 text-right">Lat,Lon</th>
|
||||||
|
<th className="px-3 py-2 text-right">Action</th>
|
||||||
|
<th className="px-3 py-2 text-right">Minor</th>
|
||||||
|
<th className="px-3 py-2 text-right">Moderate</th>
|
||||||
|
<th className="px-3 py-2 text-right">Major</th>
|
||||||
|
<th className="px-3 py-2 text-center">On</th>
|
||||||
|
<th className="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-700/60">
|
||||||
|
{rows.map(r => editing === r.site_id ? (
|
||||||
|
<tr key={r.site_id} className="bg-slate-900/40">
|
||||||
|
<td colSpan={9} className="px-3 py-2">
|
||||||
|
<RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={r.site_id} className="hover:bg-slate-800/50">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{r.site_id}</td>
|
||||||
|
<td className="px-3 py-2">{r.gauge_name}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-xs">{r.lat.toFixed(3)},{r.lon.toFixed(3)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{r.action_ft ?? '-'}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{r.flood_minor_ft ?? '-'}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{r.flood_moderate_ft ?? '-'}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{r.flood_major_ft ?? '-'}</td>
|
||||||
|
<td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button onClick={() => beginEdit(r)} className="text-cyan-400 hover:text-cyan-300 text-xs mr-3">Edit</button>
|
||||||
|
<button onClick={() => remove(r.site_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function RowEditor({ draft, setDraft, onSave, onCancel, adding }: {
|
||||||
|
draft: GaugeSite, setDraft: (g: GaugeSite) => void,
|
||||||
|
onSave: () => void, onCancel: () => void, adding?: boolean,
|
||||||
|
}) {
|
||||||
|
const upd = (k: keyof GaugeSite, v: unknown) => setDraft({ ...draft, [k]: v })
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-slate-900/50 rounded">
|
||||||
|
<label className="text-xs text-slate-400 col-span-2">
|
||||||
|
Site ID
|
||||||
|
<input className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100 font-mono text-xs"
|
||||||
|
value={draft.site_id} onChange={e => upd('site_id', e.target.value)} disabled={!adding} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400 col-span-2">
|
||||||
|
Gauge name
|
||||||
|
<input className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.gauge_name} onChange={e => upd('gauge_name', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400">Lat
|
||||||
|
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.lat} onChange={e => upd('lat', parseFloat(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400">Lon
|
||||||
|
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.lon} onChange={e => upd('lon', parseFloat(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400">Action ft
|
||||||
|
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.action_ft ?? ''} onChange={e => upd('action_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400">Minor flood ft
|
||||||
|
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.flood_minor_ft ?? ''} onChange={e => upd('flood_minor_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400">Moderate flood ft
|
||||||
|
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.flood_moderate_ft ?? ''} onChange={e => upd('flood_moderate_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400">Major flood ft
|
||||||
|
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.flood_major_ft ?? ''} onChange={e => upd('flood_major_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-300 col-span-2 flex items-center gap-2 mt-2">
|
||||||
|
<input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-cyan-500" />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div className="col-span-2 flex items-center justify-end gap-2 mt-2">
|
||||||
|
<button onClick={onCancel} className="px-3 py-1 text-slate-300 hover:bg-slate-700 rounded text-sm">Cancel</button>
|
||||||
|
<button onClick={onSave} className="px-3 py-1 bg-cyan-700 hover:bg-cyan-600 text-white rounded text-sm">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
dashboard-frontend/src/pages/TownAnchors.tsx
Normal file
153
dashboard-frontend/src/pages/TownAnchors.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
// v0.6-4 TownAnchors table editor.
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Loader2, Plus, Trash2, Check, X, MapPin } from 'lucide-react'
|
||||||
|
|
||||||
|
interface TownAnchor {
|
||||||
|
anchor_id: number
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
state: string | null
|
||||||
|
enabled: boolean
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DRAFT: TownAnchor = {
|
||||||
|
anchor_id: 0, name: '', lat: 0, lon: 0, state: 'ID', enabled: true, updated_at: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TownAnchors() {
|
||||||
|
const [rows, setRows] = useState<TownAnchor[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [editing, setEditing] = useState<number | null>(null)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [draft, setDraft] = useState<TownAnchor>(EMPTY_DRAFT)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/town-anchors')
|
||||||
|
if (!res.ok) throw new Error(`GET: ${res.status}`)
|
||||||
|
setRows(await res.json())
|
||||||
|
} catch (e) { setError(String(e)) } finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { refresh() }, [refresh])
|
||||||
|
|
||||||
|
const beginEdit = (r: TownAnchor) => { setEditing(r.anchor_id); setDraft({ ...r }); setAdding(false) }
|
||||||
|
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) }
|
||||||
|
const cancel = () => { setEditing(null); setAdding(false); setDraft(EMPTY_DRAFT) }
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const url = adding ? '/api/town-anchors' : `/api/town-anchors/${editing}`
|
||||||
|
const method = adding ? 'POST' : 'PUT'
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const b = await res.json().catch(() => ({}))
|
||||||
|
alert(`save failed: ${b.detail || res.statusText}`); return
|
||||||
|
}
|
||||||
|
cancel(); refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number) => {
|
||||||
|
if (!confirm(`Delete anchor ${id}?`)) return
|
||||||
|
const res = await fetch(`/api/town-anchors/${id}`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) { alert(`delete failed: ${res.status}`); return }
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="p-6 text-slate-400"><Loader2 className="w-5 h-5 animate-spin inline mr-2" />Loading…</div>
|
||||||
|
if (error) return <div className="p-6 text-red-400">Load failed: {error}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-cyan-400" />
|
||||||
|
<h1 className="text-xl font-semibold text-slate-100">Town Anchors</h1>
|
||||||
|
<span className="text-xs text-slate-500 ml-2">{rows.length} towns</span>
|
||||||
|
<button onClick={beginAdd}
|
||||||
|
className="ml-auto flex items-center gap-1 px-3 py-1 bg-cyan-700 hover:bg-cyan-600 rounded text-white text-sm">
|
||||||
|
<Plus className="w-4 h-4" /> Add town
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 max-w-3xl">
|
||||||
|
Lookup table for the "X mi <bearing> of <town>" anchor in wire-string rendering.
|
||||||
|
Disabled rows fall through to the generic anchor chain.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />}
|
||||||
|
|
||||||
|
<div className="bg-slate-800/60 border border-slate-700 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-slate-200">
|
||||||
|
<thead className="bg-slate-900 text-xs text-slate-400 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left">Name</th>
|
||||||
|
<th className="px-3 py-2 text-right">Lat</th>
|
||||||
|
<th className="px-3 py-2 text-right">Lon</th>
|
||||||
|
<th className="px-3 py-2 text-center">State</th>
|
||||||
|
<th className="px-3 py-2 text-center">On</th>
|
||||||
|
<th className="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-700/60">
|
||||||
|
{rows.map(r => editing === r.anchor_id ? (
|
||||||
|
<tr key={r.anchor_id} className="bg-slate-900/40">
|
||||||
|
<td colSpan={6} className="px-3 py-2"><RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} /></td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={r.anchor_id} className="hover:bg-slate-800/50">
|
||||||
|
<td className="px-3 py-2 capitalize">{r.name}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-xs">{r.lat.toFixed(4)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-xs">{r.lon.toFixed(4)}</td>
|
||||||
|
<td className="px-3 py-2 text-center text-xs">{r.state || '-'}</td>
|
||||||
|
<td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button onClick={() => beginEdit(r)} className="text-cyan-400 hover:text-cyan-300 text-xs mr-3">Edit</button>
|
||||||
|
<button onClick={() => remove(r.anchor_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function RowEditor({ draft, setDraft, onSave, onCancel, adding }: {
|
||||||
|
draft: TownAnchor, setDraft: (t: TownAnchor) => void,
|
||||||
|
onSave: () => void, onCancel: () => void, adding?: boolean,
|
||||||
|
}) {
|
||||||
|
const upd = (k: keyof TownAnchor, v: unknown) => setDraft({ ...draft, [k]: v })
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-slate-900/50 rounded">
|
||||||
|
<label className="text-xs text-slate-400 col-span-2">Name (lowercased on save)
|
||||||
|
<input className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.name} onChange={e => upd('name', e.target.value)} disabled={!adding} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400">State
|
||||||
|
<input className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.state ?? ''} onChange={e => upd('state', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400 flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-cyan-500 mt-4" />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400">Lat
|
||||||
|
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.lat} onChange={e => upd('lat', parseFloat(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-slate-400">Lon
|
||||||
|
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
|
||||||
|
value={draft.lon} onChange={e => upd('lon', parseFloat(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<div className="col-span-2 flex items-center justify-end gap-2 mt-2">
|
||||||
|
<button onClick={onCancel} className="px-3 py-1 text-slate-300 hover:bg-slate-700 rounded text-sm">Cancel</button>
|
||||||
|
<button onClick={onSave} className="px-3 py-1 bg-cyan-700 hover:bg-cyan-600 text-white rounded text-sm">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,122 +1,50 @@
|
||||||
"""v0.5.12 Idaho gauge-site curation (STARTER SUBSET).
|
"""v0.6-4 Idaho gauge-site lookups (now backed by gauge_sites table).
|
||||||
|
|
||||||
9 high-priority Magic Valley + Treasure Valley + Salmon-Challis + Snake River
|
The IDAHO_CURATED_SITES Python dict was migrated to the gauge_sites SQLite
|
||||||
system gauges. Threshold values (action / flood_minor / flood_moderate /
|
table in v8.sql. seed_gauge_sites() (called from init_db on first boot)
|
||||||
flood_major) sourced from NWS-AHPS pages for each site, in feet (gage
|
populates the table with the original 9 sites. The lookup helpers below
|
||||||
height, parameter_code 00065).
|
read from the table via meshai.persistence.curation.
|
||||||
|
|
||||||
**STARTER SUBSET** -- expand via NWS-AHPS curation in v0.6.x. If a site is
|
Module exports retained for backward-compat with existing tests:
|
||||||
missing here, the handler ignores it (no broadcast). v0.6.x will likely
|
lookup_site, normalize_site_id, THRESHOLD_RANK, compute_threshold_state.
|
||||||
migrate this dict to a `gauge_sites` table so non-engineers can curate via
|
The IDAHO_CURATED_SITES name itself is gone -- new code should call
|
||||||
the GUI.
|
lookup_site() (DB-backed) or the curation accessor directly.
|
||||||
|
|
||||||
Convention for site_id keys:
|
|
||||||
USGS-prefixed, zero-padded as USGS publishes them (e.g. 'USGS-13139510').
|
|
||||||
The handler normalizes incoming envelope site IDs to this form before
|
|
||||||
lookup so both 'USGS-13139510' and '13139510' resolve.
|
|
||||||
|
|
||||||
Threshold values that the gauge doesn't have (e.g. flood_major above the
|
|
||||||
top observed historic crest) are left as None -- the handler treats None as
|
|
||||||
'this threshold doesn't apply at this site' so a reading can never enter
|
|
||||||
that band.
|
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
# site_id -> {gauge_name, lat, lon, action_ft, flood_minor_ft,
|
|
||||||
# flood_moderate_ft, flood_major_ft}
|
|
||||||
IDAHO_CURATED_SITES: dict = {
|
|
||||||
"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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_site_id(raw: Optional[str]) -> Optional[str]:
|
|
||||||
"""Accept 'USGS-13139510', 'USGS:13139510', '13139510', etc. Return the
|
|
||||||
canonical 'USGS-<id>' form so the curation dict lookups succeed."""
|
|
||||||
if not raw: return None
|
|
||||||
s = str(raw).strip()
|
|
||||||
# Already canonical -- return as-is for the fast path.
|
|
||||||
if s in IDAHO_CURATED_SITES: return s
|
|
||||||
# Strip common prefix variants.
|
|
||||||
for prefix in ("USGS-", "USGS:", "USGS_", "usgs-", "usgs:", "usgs_"):
|
|
||||||
if s.startswith(prefix): s = s[len(prefix):]; break
|
|
||||||
canonical = f"USGS-{s}"
|
|
||||||
return canonical
|
|
||||||
|
|
||||||
|
|
||||||
def lookup_site(raw_site_id: str) -> Optional[dict]:
|
|
||||||
"""Return the curated-site dict for a raw envelope site_id, or None when
|
|
||||||
the site is not in the curated subset."""
|
|
||||||
sid = normalize_site_id(raw_site_id)
|
|
||||||
if sid is None: return None
|
|
||||||
return IDAHO_CURATED_SITES.get(sid)
|
|
||||||
|
|
||||||
|
|
||||||
# Ordered list of threshold names from low to high. Used to compare
|
# Ordered list of threshold names from low to high. Used to compare
|
||||||
# "is current threshold higher than prior" (upward crossing detection).
|
# "is current threshold higher than prior" (upward crossing detection).
|
||||||
THRESHOLD_RANK = ["normal", "action", "flood_minor", "flood_moderate", "flood_major"]
|
THRESHOLD_RANK = ["normal", "action", "flood_minor", "flood_moderate", "flood_major"]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_site_id(raw: Optional[str]) -> Optional[str]:
|
||||||
|
"""Accept 'USGS-13139510', 'USGS:13139510', '13139510', etc. Return the
|
||||||
|
canonical 'USGS-<id>' form so the curation table lookups succeed."""
|
||||||
|
if not raw: return None
|
||||||
|
s = str(raw).strip()
|
||||||
|
for prefix in ("USGS-", "USGS:", "USGS_", "usgs-", "usgs:", "usgs_"):
|
||||||
|
if s.startswith(prefix): s = s[len(prefix):]; break
|
||||||
|
return f"USGS-{s}"
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_site(raw_site_id: str) -> Optional[dict]:
|
||||||
|
"""Return the curated-site dict for a raw envelope site_id, or None when
|
||||||
|
the site is not in the curated subset (or is disabled).
|
||||||
|
|
||||||
|
v0.6-4: reads from the gauge_sites SQLite table via the curation accessor."""
|
||||||
|
sid = normalize_site_id(raw_site_id)
|
||||||
|
if sid is None: return None
|
||||||
|
from meshai.persistence.curation import lookup_gauge_site
|
||||||
|
return lookup_gauge_site(sid)
|
||||||
|
|
||||||
|
|
||||||
def compute_threshold_state(value_ft: float, site_thresholds: dict) -> str:
|
def compute_threshold_state(value_ft: float, site_thresholds: dict) -> str:
|
||||||
"""Bucket a gage_height reading (ft) into a NWS-AHPS threshold state."""
|
"""Bucket a gage_height reading (ft) into a NWS-AHPS threshold state."""
|
||||||
a = site_thresholds.get("action_ft")
|
a = site_thresholds.get("action_ft")
|
||||||
mn = site_thresholds.get("flood_minor_ft")
|
mn = site_thresholds.get("flood_minor_ft")
|
||||||
md = site_thresholds.get("flood_moderate_ft")
|
md = site_thresholds.get("flood_moderate_ft")
|
||||||
mj = site_thresholds.get("flood_major_ft")
|
mj = site_thresholds.get("flood_major_ft")
|
||||||
# Higher thresholds win first.
|
|
||||||
if mj is not None and value_ft >= mj: return "flood_major"
|
if mj is not None and value_ft >= mj: return "flood_major"
|
||||||
if md is not None and value_ft >= md: return "flood_moderate"
|
if md is not None and value_ft >= md: return "flood_moderate"
|
||||||
if mn is not None and value_ft >= mn: return "flood_minor"
|
if mn is not None and value_ft >= mn: return "flood_minor"
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from meshai.central.idaho_gauge_sites import (
|
from meshai.central.idaho_gauge_sites import (
|
||||||
IDAHO_CURATED_SITES,
|
|
||||||
THRESHOLD_RANK,
|
THRESHOLD_RANK,
|
||||||
compute_threshold_state,
|
compute_threshold_state,
|
||||||
lookup_site,
|
lookup_site,
|
||||||
|
|
|
||||||
|
|
@ -164,42 +164,8 @@ def _clean_description(raw: Optional[str]) -> Optional[str]:
|
||||||
|
|
||||||
# ---------- distance / bearing --------------------------------------------
|
# ---------- distance / bearing --------------------------------------------
|
||||||
|
|
||||||
# Small lookup of Idaho (+ a few neighbor) towns -> (lat, lon). Used to
|
# v0.6-4: town_anchors moved to a GUI-editable SQLite table. Lookups go
|
||||||
# render "X mi <bearing> of <town>" when the reverse-geocoder picked a
|
# through meshai.persistence.curation.lookup_town_anchor() now.
|
||||||
# city we know. Built from the geocoder.city values seen across 60-sample
|
|
||||||
# probe + major Idaho cities the operator's mesh is most likely to care
|
|
||||||
# about. Misses fall through silently: distance_mi/bearing stay None.
|
|
||||||
_TOWN_COORDS: dict[str, tuple[float, float]] = {
|
|
||||||
"boise": (43.6150, -116.2023),
|
|
||||||
"meridian": (43.6121, -116.3915),
|
|
||||||
"nampa": (43.5407, -116.5635),
|
|
||||||
"caldwell": (43.6629, -116.6874),
|
|
||||||
"idaho falls": (43.4666, -112.0340),
|
|
||||||
"pocatello": (42.8713, -112.4455),
|
|
||||||
"twin falls": (42.5630, -114.4609),
|
|
||||||
"coeur d'alene": (47.6777, -116.7805),
|
|
||||||
"lewiston": (46.4165, -117.0177),
|
|
||||||
"moscow": (46.7324, -117.0002),
|
|
||||||
"sandpoint": (48.2766, -116.5535),
|
|
||||||
"post falls": (47.7180, -116.9516),
|
|
||||||
"hayden": (47.7660, -116.7866),
|
|
||||||
"rathdrum": (47.8121, -116.8950),
|
|
||||||
"plummer": (47.3344, -116.8856),
|
|
||||||
"kellogg": (47.5380, -116.1352),
|
|
||||||
"bonners ferry": (48.6914, -116.3181),
|
|
||||||
"rexburg": (43.8260, -111.7897),
|
|
||||||
"blackfoot": (43.1905, -112.3447),
|
|
||||||
"burley": (42.5360, -113.7928),
|
|
||||||
"jerome": (42.7252, -114.5187),
|
|
||||||
"mountain home": (43.1330, -115.6912),
|
|
||||||
"stanley": (44.2160, -114.9311),
|
|
||||||
"salmon": (45.1758, -113.8957),
|
|
||||||
"mccall": (44.9111, -116.0987),
|
|
||||||
"weiser": (44.2510, -116.9690),
|
|
||||||
"soda springs": (42.6543, -111.6047),
|
|
||||||
"preston": (42.0963, -111.8766),
|
|
||||||
"montpelier": (42.3232, -111.2980),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
def _haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
|
@ -230,7 +196,8 @@ def _compute_distance_bearing(
|
||||||
if event_lat is None or event_lon is None or not town:
|
if event_lat is None or event_lon is None or not town:
|
||||||
return None, None
|
return None, None
|
||||||
key = str(town).strip().lower()
|
key = str(town).strip().lower()
|
||||||
coords = _TOWN_COORDS.get(key)
|
from meshai.persistence.curation import lookup_town_anchor
|
||||||
|
coords = lookup_town_anchor(key)
|
||||||
if coords is None:
|
if coords is None:
|
||||||
return None, None
|
return None, None
|
||||||
tlat, tlon = coords
|
tlat, tlon = coords
|
||||||
|
|
|
||||||
280
meshai/dashboard/api/curation_routes.py
Normal file
280
meshai/dashboard/api/curation_routes.py
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
"""v0.6-4 REST API for gauge_sites + town_anchors curation tables.
|
||||||
|
|
||||||
|
Endpoints (mirrors the adapter_config CRUD shape):
|
||||||
|
|
||||||
|
gauge_sites:
|
||||||
|
GET /api/gauge-sites list (enabled and disabled)
|
||||||
|
GET /api/gauge-sites/{site_id} single row
|
||||||
|
POST /api/gauge-sites add new row
|
||||||
|
PUT /api/gauge-sites/{site_id} partial update
|
||||||
|
DELETE /api/gauge-sites/{site_id} remove row
|
||||||
|
|
||||||
|
town_anchors:
|
||||||
|
GET /api/town-anchors list
|
||||||
|
GET /api/town-anchors/{anchor_id} single
|
||||||
|
POST /api/town-anchors add new
|
||||||
|
PUT /api/town-anchors/{anchor_id} partial update
|
||||||
|
DELETE /api/town-anchors/{anchor_id} remove
|
||||||
|
|
||||||
|
Every mutating call invalidates the in-process curation cache so
|
||||||
|
handler-side reads (lookup_gauge_site, lookup_town_anchor) see the new
|
||||||
|
state on the next call -- no container restart.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from meshai.persistence.curation import invalidate_curation_cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["curation"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn():
|
||||||
|
from meshai.persistence import get_db
|
||||||
|
return get_db()
|
||||||
|
|
||||||
|
|
||||||
|
def _gauge_row_to_dict(r) -> dict:
|
||||||
|
return {
|
||||||
|
"site_id": 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"],
|
||||||
|
"enabled": bool(r["enabled"]),
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _town_row_to_dict(r) -> dict:
|
||||||
|
return {
|
||||||
|
"anchor_id": r["anchor_id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"lat": r["lat"],
|
||||||
|
"lon": r["lon"],
|
||||||
|
"state": r["state"],
|
||||||
|
"enabled": bool(r["enabled"]),
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# gauge_sites
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gauge-sites")
|
||||||
|
async def list_gauges(request: Request) -> list[dict]:
|
||||||
|
conn = _get_conn()
|
||||||
|
return [_gauge_row_to_dict(r) for r in conn.execute(
|
||||||
|
"SELECT * FROM gauge_sites ORDER BY site_id"
|
||||||
|
).fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gauge-sites/{site_id}")
|
||||||
|
async def get_gauge(site_id: str, request: Request) -> dict:
|
||||||
|
conn = _get_conn()
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT * FROM gauge_sites WHERE site_id=?", (site_id,)
|
||||||
|
).fetchone()
|
||||||
|
if r is None:
|
||||||
|
raise HTTPException(404, f"gauge_sites: {site_id} not found")
|
||||||
|
return _gauge_row_to_dict(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/gauge-sites")
|
||||||
|
async def add_gauge(request: Request) -> dict:
|
||||||
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict): raise HTTPException(400, "body must be a JSON object")
|
||||||
|
required = ("site_id", "gauge_name", "lat", "lon")
|
||||||
|
for f in required:
|
||||||
|
if f not in body:
|
||||||
|
raise HTTPException(400, f"missing required field: {f}")
|
||||||
|
if not isinstance(body["site_id"], str) or not body["site_id"].strip():
|
||||||
|
raise HTTPException(400, "site_id must be a non-empty string")
|
||||||
|
try:
|
||||||
|
lat = float(body["lat"]); lon = float(body["lon"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "lat/lon must be numeric")
|
||||||
|
|
||||||
|
conn = _get_conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO gauge_sites(site_id, gauge_name, lat, lon, action_ft, "
|
||||||
|
"flood_minor_ft, flood_moderate_ft, flood_major_ft, enabled, updated_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(body["site_id"].strip(),
|
||||||
|
str(body["gauge_name"]).strip(),
|
||||||
|
lat, lon,
|
||||||
|
body.get("action_ft"),
|
||||||
|
body.get("flood_minor_ft"),
|
||||||
|
body.get("flood_moderate_ft"),
|
||||||
|
body.get("flood_major_ft"),
|
||||||
|
1 if body.get("enabled", True) else 0,
|
||||||
|
time.time()),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"insert failed: {e}")
|
||||||
|
invalidate_curation_cache()
|
||||||
|
return await get_gauge(body["site_id"].strip(), request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/gauge-sites/{site_id}")
|
||||||
|
async def update_gauge(site_id: str, request: Request) -> dict:
|
||||||
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict): raise HTTPException(400, "body must be a JSON object")
|
||||||
|
|
||||||
|
conn = _get_conn()
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT * FROM gauge_sites WHERE site_id=?", (site_id,)
|
||||||
|
).fetchone()
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(404, f"gauge_sites: {site_id} not found")
|
||||||
|
|
||||||
|
fields = []
|
||||||
|
args: list[Any] = []
|
||||||
|
for col, validator in (
|
||||||
|
("gauge_name", lambda v: str(v).strip()),
|
||||||
|
("lat", float), ("lon", float),
|
||||||
|
("action_ft", lambda v: float(v) if v is not None else None),
|
||||||
|
("flood_minor_ft", lambda v: float(v) if v is not None else None),
|
||||||
|
("flood_moderate_ft", lambda v: float(v) if v is not None else None),
|
||||||
|
("flood_major_ft", lambda v: float(v) if v is not None else None),
|
||||||
|
("enabled", lambda v: 1 if bool(v) else 0),
|
||||||
|
):
|
||||||
|
if col in body:
|
||||||
|
try: args.append(validator(body[col]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, f"invalid value for {col}: {body[col]!r}")
|
||||||
|
fields.append(f"{col}=?")
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(400, "no editable fields provided")
|
||||||
|
|
||||||
|
fields.append("updated_at=?")
|
||||||
|
args.append(time.time())
|
||||||
|
args.append(site_id)
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE gauge_sites SET {', '.join(fields)} WHERE site_id=?",
|
||||||
|
tuple(args),
|
||||||
|
)
|
||||||
|
invalidate_curation_cache()
|
||||||
|
return await get_gauge(site_id, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/gauge-sites/{site_id}")
|
||||||
|
async def delete_gauge(site_id: str, request: Request) -> dict:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.execute("DELETE FROM gauge_sites WHERE site_id=?", (site_id,))
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(404, f"gauge_sites: {site_id} not found")
|
||||||
|
invalidate_curation_cache()
|
||||||
|
return {"deleted": site_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# town_anchors
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/town-anchors")
|
||||||
|
async def list_towns(request: Request) -> list[dict]:
|
||||||
|
conn = _get_conn()
|
||||||
|
return [_town_row_to_dict(r) for r in conn.execute(
|
||||||
|
"SELECT * FROM town_anchors ORDER BY name"
|
||||||
|
).fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/town-anchors/{anchor_id}")
|
||||||
|
async def get_town(anchor_id: int, request: Request) -> dict:
|
||||||
|
conn = _get_conn()
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT * FROM town_anchors WHERE anchor_id=?", (anchor_id,)
|
||||||
|
).fetchone()
|
||||||
|
if r is None:
|
||||||
|
raise HTTPException(404, f"town_anchors: {anchor_id} not found")
|
||||||
|
return _town_row_to_dict(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/town-anchors")
|
||||||
|
async def add_town(request: Request) -> dict:
|
||||||
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict): raise HTTPException(400, "body must be a JSON object")
|
||||||
|
for f in ("name", "lat", "lon"):
|
||||||
|
if f not in body: raise HTTPException(400, f"missing required field: {f}")
|
||||||
|
name = str(body["name"]).strip().lower()
|
||||||
|
if not name: raise HTTPException(400, "name must be non-empty")
|
||||||
|
try:
|
||||||
|
lat = float(body["lat"]); lon = float(body["lon"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "lat/lon must be numeric")
|
||||||
|
|
||||||
|
conn = _get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO town_anchors(name, lat, lon, state, enabled, updated_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(name, lat, lon, body.get("state"),
|
||||||
|
1 if body.get("enabled", True) else 0, time.time()),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"insert failed: {e}")
|
||||||
|
invalidate_curation_cache()
|
||||||
|
return await get_town(cur.lastrowid, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/town-anchors/{anchor_id}")
|
||||||
|
async def update_town(anchor_id: int, request: Request) -> dict:
|
||||||
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict): raise HTTPException(400, "body must be a JSON object")
|
||||||
|
|
||||||
|
conn = _get_conn()
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT * FROM town_anchors WHERE anchor_id=?", (anchor_id,)
|
||||||
|
).fetchone()
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(404, f"town_anchors: {anchor_id} not found")
|
||||||
|
|
||||||
|
fields = []
|
||||||
|
args: list[Any] = []
|
||||||
|
for col, validator in (
|
||||||
|
("name", lambda v: str(v).strip().lower()),
|
||||||
|
("lat", float), ("lon", float),
|
||||||
|
("state", lambda v: str(v).strip() if v else None),
|
||||||
|
("enabled", lambda v: 1 if bool(v) else 0),
|
||||||
|
):
|
||||||
|
if col in body:
|
||||||
|
try: args.append(validator(body[col]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, f"invalid value for {col}: {body[col]!r}")
|
||||||
|
fields.append(f"{col}=?")
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(400, "no editable fields provided")
|
||||||
|
fields.append("updated_at=?")
|
||||||
|
args.append(time.time())
|
||||||
|
args.append(anchor_id)
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE town_anchors SET {', '.join(fields)} WHERE anchor_id=?",
|
||||||
|
tuple(args),
|
||||||
|
)
|
||||||
|
invalidate_curation_cache()
|
||||||
|
return await get_town(anchor_id, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/town-anchors/{anchor_id}")
|
||||||
|
async def delete_town(anchor_id: int, request: Request) -> dict:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.execute("DELETE FROM town_anchors WHERE anchor_id=?", (anchor_id,))
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(404, f"town_anchors: {anchor_id} not found")
|
||||||
|
invalidate_curation_cache()
|
||||||
|
return {"deleted": anchor_id}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""FastAPI server for MeshAI dashboard."""
|
"""FastAPI server for MeshAI dashboard."""
|
||||||
from meshai.dashboard.api.adapter_config_routes import router as adapter_config_router
|
from meshai.dashboard.api.adapter_config_routes import router as adapter_config_router
|
||||||
|
from meshai.dashboard.api.curation_routes import router as curation_router
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -57,6 +58,7 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
app.include_router(system_router, prefix="/api")
|
app.include_router(system_router, prefix="/api")
|
||||||
app.include_router(adapter_config_router, prefix="/api")
|
app.include_router(adapter_config_router, prefix="/api")
|
||||||
|
app.include_router(curation_router, prefix="/api")
|
||||||
app.include_router(config_router, prefix="/api")
|
app.include_router(config_router, prefix="/api")
|
||||||
app.include_router(mesh_router, prefix="/api")
|
app.include_router(mesh_router, prefix="/api")
|
||||||
app.include_router(env_router, prefix="/api")
|
app.include_router(env_router, prefix="/api")
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-Bj-HMHAO.css
Normal file
1
meshai/dashboard/static/assets/index-Bj-HMHAO.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-B7WUE5ni.js"></script>
|
<script type="module" crossorigin src="/assets/index-Dc1UcqB9.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B1y0CpOn.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bj-HMHAO.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
241
meshai/persistence/curation.py
Normal file
241
meshai/persistence/curation.py
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
"""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
|
||||||
|
|
@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
||||||
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
||||||
SCHEMA_VERSION = 7
|
SCHEMA_VERSION = 9
|
||||||
SCHEMA_META_TABLE = "schema_meta"
|
SCHEMA_META_TABLE = "schema_meta"
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
@ -134,6 +134,12 @@ def init_db(path: Optional[str] = None) -> sqlite3.Connection:
|
||||||
prune_orphans(conn)
|
prune_orphans(conn)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("init_db: adapter_config seed/prune failed")
|
logger.exception("init_db: adapter_config seed/prune failed")
|
||||||
|
try:
|
||||||
|
from meshai.persistence.curation import seed_gauge_sites, seed_town_anchors
|
||||||
|
seed_gauge_sites(conn)
|
||||||
|
seed_town_anchors(conn)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("init_db: curation seed failed")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
28
meshai/persistence/migrations/v8.sql
Normal file
28
meshai/persistence/migrations/v8.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- v0.6-4 gauge_sites curation table.
|
||||||
|
--
|
||||||
|
-- Replaces the hardcoded IDAHO_CURATED_SITES dict in
|
||||||
|
-- meshai/central/idaho_gauge_sites.py with a GUI-editable SQLite table.
|
||||||
|
-- The Python dict is removed in this commit; the lookup helpers in
|
||||||
|
-- idaho_gauge_sites.py now read from this table via
|
||||||
|
-- meshai.persistence.curation.lookup_gauge_site().
|
||||||
|
--
|
||||||
|
-- Per-site NWS-AHPS thresholds (action / minor / moderate / major) are
|
||||||
|
-- nullable so sites without a published value for a band can sit on the
|
||||||
|
-- table without a fake value. Disabled rows (enabled=0) are skipped at
|
||||||
|
-- read time so the operator can pause a site without deleting it.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gauge_sites (
|
||||||
|
site_id TEXT PRIMARY KEY, -- 'USGS-13139510' canonical
|
||||||
|
gauge_name TEXT NOT NULL,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL,
|
||||||
|
action_ft REAL,
|
||||||
|
flood_minor_ft REAL,
|
||||||
|
flood_moderate_ft REAL,
|
||||||
|
flood_major_ft REAL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gauge_sites_enabled
|
||||||
|
ON gauge_sites(enabled);
|
||||||
24
meshai/persistence/migrations/v9.sql
Normal file
24
meshai/persistence/migrations/v9.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- v0.6-4 town_anchors curation table.
|
||||||
|
--
|
||||||
|
-- Replaces the hardcoded _TOWN_COORDS dict in central_normalizer.py.
|
||||||
|
-- The Python dict is removed in this commit; the lookup helpers in
|
||||||
|
-- central_normalizer.py now read from this table via
|
||||||
|
-- meshai.persistence.curation.lookup_town_anchor().
|
||||||
|
--
|
||||||
|
-- name TEXT is the canonical lookup key (lowercased) -- the existing
|
||||||
|
-- _compute_distance_bearing() already lowercases its town input. The
|
||||||
|
-- adapter accepting both 'Boise' and 'boise' is preserved because the
|
||||||
|
-- accessor lowercases before query.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS town_anchors (
|
||||||
|
anchor_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL,
|
||||||
|
state TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_town_anchors_enabled
|
||||||
|
ON town_anchors(enabled);
|
||||||
|
|
@ -54,11 +54,11 @@ def test_v6_tables_exist(fresh_db):
|
||||||
assert "adapter_meta" in tables
|
assert "adapter_meta" in tables
|
||||||
|
|
||||||
|
|
||||||
def test_schema_meta_at_v7(fresh_db):
|
def test_schema_meta_at_v9(fresh_db):
|
||||||
v = fresh_db.execute(
|
v = fresh_db.execute(
|
||||||
"SELECT value FROM schema_meta WHERE key='version'"
|
"SELECT value FROM schema_meta WHERE key='version'"
|
||||||
).fetchone()["value"]
|
).fetchone()["value"]
|
||||||
assert int(v) == 7
|
assert int(v) == 9
|
||||||
|
|
||||||
|
|
||||||
def test_adapter_config_type_check_constrains_vocabulary(fresh_db):
|
def test_adapter_config_type_check_constrains_vocabulary(fresh_db):
|
||||||
|
|
|
||||||
214
tests/test_curation.py
Normal file
214
tests/test_curation.py
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
"""v0.6-4 curation tests: schema, seed, accessors, REST API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from meshai.persistence.curation import (
|
||||||
|
invalidate_curation_cache,
|
||||||
|
lookup_gauge_site,
|
||||||
|
lookup_town_anchor,
|
||||||
|
seed_gauge_sites,
|
||||||
|
seed_town_anchors,
|
||||||
|
_GAUGE_SITES_SEED,
|
||||||
|
_TOWN_ANCHORS_SEED,
|
||||||
|
)
|
||||||
|
from meshai.persistence import get_db
|
||||||
|
from meshai.dashboard.api.curation_routes import router as curation_router
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Schema + seed
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_v8_v9_tables_present():
|
||||||
|
conn = get_db()
|
||||||
|
tables = {r["name"] for r in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
assert "gauge_sites" in tables
|
||||||
|
assert "town_anchors" in tables
|
||||||
|
|
||||||
|
|
||||||
|
def test_gauge_sites_seeded_at_init():
|
||||||
|
conn = get_db()
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM gauge_sites").fetchone()[0]
|
||||||
|
assert n == len(_GAUGE_SITES_SEED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_town_anchors_seeded_at_init():
|
||||||
|
conn = get_db()
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM town_anchors").fetchone()[0]
|
||||||
|
assert n == len(_TOWN_ANCHORS_SEED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_idempotent():
|
||||||
|
conn = get_db()
|
||||||
|
a = seed_gauge_sites(conn)
|
||||||
|
b = seed_town_anchors(conn)
|
||||||
|
assert a == 0 and b == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_does_not_overwrite_user_edits():
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE gauge_sites SET action_ft=99 WHERE site_id='USGS-13186000'"
|
||||||
|
)
|
||||||
|
seed_gauge_sites(conn)
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT action_ft FROM gauge_sites WHERE site_id='USGS-13186000'"
|
||||||
|
).fetchone()
|
||||||
|
assert r["action_ft"] == 99
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Accessors
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_gauge_site_hits():
|
||||||
|
invalidate_curation_cache()
|
||||||
|
r = lookup_gauge_site("USGS-13186000")
|
||||||
|
assert r is not None
|
||||||
|
assert r["gauge_name"] == "Snake River at Heise"
|
||||||
|
assert r["action_ft"] == 12.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_gauge_site_miss():
|
||||||
|
invalidate_curation_cache()
|
||||||
|
assert lookup_gauge_site("USGS-99999") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_gauge_disabled_row_invisible():
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("UPDATE gauge_sites SET enabled=0 WHERE site_id='USGS-13186000'")
|
||||||
|
invalidate_curation_cache()
|
||||||
|
assert lookup_gauge_site("USGS-13186000") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_town_anchor_hits():
|
||||||
|
invalidate_curation_cache()
|
||||||
|
coord = lookup_town_anchor("Boise")
|
||||||
|
assert coord is not None
|
||||||
|
assert coord[0] == pytest.approx(43.6150)
|
||||||
|
assert coord[1] == pytest.approx(-116.2023)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_town_anchor_case_insensitive():
|
||||||
|
invalidate_curation_cache()
|
||||||
|
a = lookup_town_anchor("MERIDIAN")
|
||||||
|
b = lookup_town_anchor("meridian")
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_town_anchor_miss():
|
||||||
|
invalidate_curation_cache()
|
||||||
|
assert lookup_town_anchor("xxxx") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# REST API: gauge_sites
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(curation_router, prefix="/api")
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_list_gauges(client):
|
||||||
|
r = client.get("/api/gauge-sites")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert len(body) == len(_GAUGE_SITES_SEED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_get_one_gauge(client):
|
||||||
|
r = client.get("/api/gauge-sites/USGS-13186000")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["gauge_name"] == "Snake River at Heise"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_get_404(client):
|
||||||
|
r = client.get("/api/gauge-sites/USGS-99999")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_post_add_gauge(client):
|
||||||
|
r = client.post("/api/gauge-sites", json={
|
||||||
|
"site_id": "USGS-NEW1", "gauge_name": "Test Gauge",
|
||||||
|
"lat": 44.0, "lon": -115.0, "action_ft": 5.0,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["site_id"] == "USGS-NEW1"
|
||||||
|
# Accessor sees it.
|
||||||
|
invalidate_curation_cache()
|
||||||
|
assert lookup_gauge_site("USGS-NEW1") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_put_updates_gauge(client):
|
||||||
|
r = client.put("/api/gauge-sites/USGS-13186000",
|
||||||
|
json={"action_ft": 15.5})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["action_ft"] == 15.5
|
||||||
|
invalidate_curation_cache()
|
||||||
|
assert lookup_gauge_site("USGS-13186000")["action_ft"] == 15.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_delete_gauge(client):
|
||||||
|
r = client.delete("/api/gauge-sites/USGS-13186000")
|
||||||
|
assert r.status_code == 200
|
||||||
|
invalidate_curation_cache()
|
||||||
|
assert lookup_gauge_site("USGS-13186000") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_post_missing_field_400(client):
|
||||||
|
r = client.post("/api/gauge-sites", json={"gauge_name": "X"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# REST API: town_anchors
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_list_towns(client):
|
||||||
|
r = client.get("/api/town-anchors")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()) == len(_TOWN_ANCHORS_SEED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_post_add_town(client):
|
||||||
|
r = client.post("/api/town-anchors", json={
|
||||||
|
"name": "Bellevue", "lat": 43.4670, "lon": -114.2557, "state": "ID",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["name"] == "bellevue"
|
||||||
|
invalidate_curation_cache()
|
||||||
|
coord = lookup_town_anchor("bellevue")
|
||||||
|
assert coord is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_put_town(client):
|
||||||
|
# Find Boise's anchor_id first.
|
||||||
|
r = client.get("/api/town-anchors")
|
||||||
|
boise = next(t for t in r.json() if t["name"] == "boise")
|
||||||
|
r2 = client.put(f"/api/town-anchors/{boise['anchor_id']}",
|
||||||
|
json={"enabled": False})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["enabled"] is False
|
||||||
|
invalidate_curation_cache()
|
||||||
|
assert lookup_town_anchor("boise") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_delete_town(client):
|
||||||
|
r = client.get("/api/town-anchors")
|
||||||
|
nampa = next(t for t in r.json() if t["name"] == "nampa")
|
||||||
|
r2 = client.delete(f"/api/town-anchors/{nampa['anchor_id']}")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
invalidate_curation_cache()
|
||||||
|
assert lookup_town_anchor("nampa") is None
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""Tests for v0.5.12 usgs_nwis handler."""
|
"""Tests for v0.5.12 usgs_nwis handler."""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from meshai.central.idaho_gauge_sites import IDAHO_CURATED_SITES
|
# v0.6-4: IDAHO_CURATED_SITES dict moved to gauge_sites SQLite table;
|
||||||
|
# import the seed data from the curation module as a back-compat alias.
|
||||||
|
from meshai.persistence.curation import _GAUGE_SITES_SEED as IDAHO_CURATED_SITES
|
||||||
from meshai.central.nwis_handler import handle_nwis
|
from meshai.central.nwis_handler import handle_nwis
|
||||||
from meshai.persistence import close_thread_connection, init_db
|
from meshai.persistence import close_thread_connection, init_db
|
||||||
from meshai.persistence import db as persistence_db
|
from meshai.persistence import db as persistence_db
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue