feat: rainfall + HII water-level layers on the dashboard
Documentation / Build Sphinx Documentation (push) Successful in 1m3s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 1m4s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
Documentation / Generate API Documentation (push) Successful in 23s
Documentation / Validate Documentation (push) Failing after 18s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Failing after 11m16s

New endpoints GET /api/hii/rainfall/latest and /api/hii/waterlevel/latest
serve the latest per-station rows from the hii_* tables. The map gains a
TWA-style rain layer: circle markers binned by TMD 24-h classes (blues
for light/moderate/heavy, site amber/red for very-heavy/extreme), with a
legend block and show/hide toggle. The ThaiWater sensor panel now
prefers the DB-backed HII feed (no API key required, 97+ stations,
ThaiWater storage-percent situation colors, gauge-datum conversion via
offset_msl) and falls back to the live /sensors/thaiwater passthrough.
This commit is contained in:
2026-08-11 15:26:58 +07:00
parent d72496f404
commit 4f3f19f6db
4 changed files with 219 additions and 7 deletions
+86 -7
View File
@@ -216,6 +216,14 @@
<div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high &gt; 250</div> <div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high &gt; 250</div>
<div class="legend-row"><i class="line-swatch" style="background:#69b7d0"></i> River · no nearby gauge</div> <div class="legend-row"><i class="line-swatch" style="background:#69b7d0"></i> River · no nearby gauge</div>
<div class="legend-row"><i class="line-swatch" style="background:linear-gradient(90deg,#1e8b60,#087da5,#d99018,#cc4b37)"></i> River · gauge colour, dashes = flow</div> <div class="legend-row"><i class="line-swatch" style="background:linear-gradient(90deg,#1e8b60,#087da5,#d99018,#cc4b37)"></i> River · gauge colour, dashes = flow</div>
<div class="legend-title" style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px">Rainfall · 24 h
<label style="font-weight:650;color:var(--muted);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="rain-toggle" checked style="accent-color:#086b96">show</label>
</div>
<div class="legend-row"><i class="swatch" style="background:#74c1e4;border-radius:50%"></i> Light 0.110 mm</div>
<div class="legend-row"><i class="swatch" style="background:#2f96c4;border-radius:50%"></i> Moderate 1035</div>
<div class="legend-row"><i class="swatch" style="background:#086b96;border-radius:50%"></i> Heavy 3590</div>
<div class="legend-row"><i class="swatch" style="background:#d99018;border-radius:50%"></i> Very heavy 90150</div>
<div class="legend-row"><i class="swatch" style="background:#cc4b37;border-radius:50%"></i> Extreme &gt; 150</div>
</div> </div>
</div> </div>
<div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div> <div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div>
@@ -270,7 +278,7 @@
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null, historyRequestId: 0, p1Stages: null, p1Now: null }; const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null, historyRequestId: 0, p1Stages: null, p1Now: null, rainLayer: null };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
function flowColor(flow) { function flowColor(flow) {
@@ -281,6 +289,43 @@
return '#cc4b37'; return '#cc4b37';
} }
// 24-h rain bins follow the TMD/ThaiWater classes: blues for ordinary rain,
// the site's warning amber/red once totals become flood-relevant.
function rainBin(mm) {
if (mm == null || Number.isNaN(mm) || mm <= 0) return { color: '#90a4a9', radius: 2.5, label: 'No rain', opacity: .45 };
if (mm <= 10) return { color: '#74c1e4', radius: 4.5, label: 'Light rain' };
if (mm <= 35) return { color: '#2f96c4', radius: 6, label: 'Moderate rain' };
if (mm <= 90) return { color: '#086b96', radius: 7.5, label: 'Heavy rain' };
if (mm <= 150) return { color: '#d99018', radius: 9.5, label: 'Very heavy rain' };
return { color: '#cc4b37', radius: 11.5, label: 'Extreme rain' };
}
function renderRainLayer(records) {
if (state.rainLayer) { state.map.removeLayer(state.rainLayer); state.rainLayer = null; }
if (!records || !records.length) return;
const group = L.layerGroup();
records.forEach((r) => {
if (!Number.isFinite(r.latitude) || !Number.isFinite(r.longitude)) return;
const mm = r.rain_24h == null ? null : Number(r.rain_24h);
const bin = rainBin(mm);
const name = r.name_en || r.name_th || r.oldcode || `Station ${r.station_id}`;
const time = r.timestamp ? new Date(r.timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : 'No reading';
L.circleMarker([r.latitude, r.longitude], {
radius: bin.radius, color: '#ffffff', weight: 1.5,
fillColor: bin.color, fillOpacity: bin.opacity ?? .85
}).bindPopup(`<div class="popup"><div class="popup-code">${escapeHtml(r.oldcode || 'Rain gauge')} · ${escapeHtml(bin.label)}</div>
<h3>${escapeHtml(name)}</h3><div class="popup-th">${escapeHtml(r.agency || 'HII')} rain gauge · ThaiWater</div>
<div class="popup-grid">
<div class="popup-metric"><span>Last 24 h</span><strong>${mm == null ? 'No data' : mm.toFixed(1) + ' mm'}</strong></div>
<div class="popup-metric"><span>Last hour</span><strong>${r.rain_1h == null ? '—' : Number(r.rain_1h).toFixed(1) + ' mm'}</strong></div>
</div>
<div class="popup-time">Reading: ${escapeHtml(time)}</div></div>`).addTo(group);
});
state.rainLayer = group;
const toggle = $('rain-toggle');
if (!toggle || toggle.checked) group.addTo(state.map);
}
function escapeHtml(value) { function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({ return String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
@@ -596,13 +641,22 @@
$('thaiwater-count').textContent = `${additional.length} additional Ping basin stations · water level`; $('thaiwater-count').textContent = `${additional.length} additional Ping basin stations · water level`;
additional.forEach((sensor) => { additional.forEach((sensor) => {
const percent = sensor.bank_percent == null ? null : Number(sensor.bank_percent); const percent = sensor.bank_percent == null ? null : Number(sensor.bank_percent);
const color = percent == null ? '#7b8f94' : percent >= 100 ? '#cc4b37' : percent >= 80 ? '#d99018' : '#6c73b8'; // ThaiWater situation bands by % of bank capacity: over-bank red,
// high amber, normal green, low/critically-low ochre tones.
const color = percent == null ? '#7b8f94'
: percent > 100 ? '#cc4b37'
: percent > 70 ? '#d99018'
: percent > 30 ? '#1e8b60'
: percent > 10 ? '#b3973f'
: '#8a5a2b';
const icon = L.divIcon({ const icon = L.divIcon({
className: 'marker-wrap', className: 'marker-wrap',
html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:22px">+</div>`, html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:22px">+</div>`,
iconSize: [22, 22], iconAnchor: [11, 11], popupAnchor: [0, -11] iconSize: [22, 22], iconAnchor: [11, 11], popupAnchor: [0, -11]
}); });
const level = sensor.water_level_msl == null ? 'No data' : `${Number(sensor.water_level_msl).toFixed(2)} m MSL`; const gauge = sensor.offset_msl != null && sensor.water_level_msl != null
? ` (${(Number(sensor.water_level_msl) - Number(sensor.offset_msl)).toFixed(2)} m gauge)` : '';
const level = sensor.water_level_msl == null ? 'No data' : `${Number(sensor.water_level_msl).toFixed(2)} m MSL${gauge}`;
const bank = sensor.distance_to_bank == null ? 'Unknown' : `${Number(sensor.distance_to_bank).toFixed(2)} m below bank`; const bank = sensor.distance_to_bank == null ? 'Unknown' : `${Number(sensor.distance_to_bank).toFixed(2)} m below bank`;
const marker = L.marker([sensor.latitude, sensor.longitude], { icon, title: `${sensor.station_code} ${sensor.station_name}` }) const marker = L.marker([sensor.latitude, sensor.longitude], { icon, title: `${sensor.station_code} ${sensor.station_name}` })
.bindPopup(`<div class="popup"><div class="popup-code">${escapeHtml(sensor.station_code)} · ThaiWater</div><h3>${escapeHtml(sensor.station_name)}</h3><div class="popup-th">${escapeHtml(sensor.river_name || 'Ping basin')} · ${escapeHtml(sensor.agency || '')}</div><div class="popup-grid"><div class="popup-metric"><span>Water level</span><strong>${escapeHtml(level)}</strong></div><div class="popup-metric"><span>Bank status</span><strong>${escapeHtml(bank)}</strong></div></div></div>`) .bindPopup(`<div class="popup"><div class="popup-code">${escapeHtml(sensor.station_code)} · ThaiWater</div><h3>${escapeHtml(sensor.station_name)}</h3><div class="popup-th">${escapeHtml(sensor.river_name || 'Ping basin')} · ${escapeHtml(sensor.agency || '')}</div><div class="popup-grid"><div class="popup-metric"><span>Water level</span><strong>${escapeHtml(level)}</strong></div><div class="popup-metric"><span>Bank status</span><strong>${escapeHtml(bank)}</strong></div></div></div>`)
@@ -643,11 +697,12 @@
$('error').style.display = 'none'; $('error').style.display = 'none';
try { try {
initMap(); initMap();
const [stationResponse, measurementResponse, riverResponse, thaiWaterResponse] = await Promise.all([ const [stationResponse, measurementResponse, riverResponse, hiiWlResponse, rainResponse] = await Promise.all([
fetch('/stations'), fetch('/stations'),
fetch('/measurements/latest?limit=500'), fetch('/measurements/latest?limit=500'),
fetch('/static/ping-river-network.geojson'), fetch('/static/ping-river-network.geojson'),
fetch('/sensors/thaiwater') fetch('/api/hii/waterlevel/latest').catch(() => null),
fetch('/api/hii/rainfall/latest').catch(() => null)
]); ]);
if (!stationResponse.ok || !measurementResponse.ok || !riverResponse.ok) { if (!stationResponse.ok || !measurementResponse.ok || !riverResponse.ok) {
throw new Error(`API returned ${stationResponse.status}/${measurementResponse.status}/${riverResponse.status}`); throw new Error(`API returned ${stationResponse.status}/${measurementResponse.status}/${riverResponse.status}`);
@@ -655,11 +710,30 @@
const stations = await stationResponse.json(); const stations = await stationResponse.json();
const measurements = await measurementResponse.json(); const measurements = await measurementResponse.json();
const riverNetwork = await riverResponse.json(); const riverNetwork = await riverResponse.json();
const thaiWaterSensors = thaiWaterResponse.ok ? await thaiWaterResponse.json() : []; const rainRecords = rainResponse && rainResponse.ok ? await rainResponse.json() : [];
// Prefer the DB-backed HII feed (no API key needed); fall back to the
// live ThaiWater passthrough when HII collection has no data yet.
let sensors = [];
const hiiRecords = hiiWlResponse && hiiWlResponse.ok ? await hiiWlResponse.json() : [];
if (hiiRecords.length) {
sensors = hiiRecords.map((r) => ({
station_code: r.rid_code || r.oldcode || `HII-${r.station_id}`,
station_name: r.name_en || r.name_th || r.oldcode || `Station ${r.station_id}`,
latitude: r.latitude, longitude: r.longitude,
water_level_msl: r.wl_msl, bank_percent: r.storage_percent,
distance_to_bank: r.diff_wl_bank, river_name: r.river_name,
agency: r.agency, situation_level: r.situation_level,
offset_msl: r.offset_msl, timestamp: r.timestamp
}));
} else {
const thaiWaterResponse = await fetch('/sensors/thaiwater').catch(() => null);
sensors = thaiWaterResponse && thaiWaterResponse.ok ? await thaiWaterResponse.json() : [];
}
const readings = latestByStation(measurements); const readings = latestByStation(measurements);
renderMap(stations, readings, riverNetwork); renderMap(stations, readings, riverNetwork);
renderRainLayer(rainRecords);
renderList(stations, readings); renderList(stations, readings);
renderThaiWaterSensors(thaiWaterSensors, new Set(stations.map((station) => station.station_code))); renderThaiWaterSensors(sensors, new Set(stations.map((station) => station.station_code)));
loadCustomMarkers(); loadCustomMarkers();
renderSummary(stations, readings); renderSummary(stations, readings);
$('loading').style.display = 'none'; $('loading').style.display = 'none';
@@ -1046,6 +1120,11 @@
$('refresh-button').addEventListener('click', loadDashboard); $('refresh-button').addEventListener('click', loadDashboard);
$('zones-toggle').addEventListener('click', toggleFloodZones); $('zones-toggle').addEventListener('click', toggleFloodZones);
$('rain-toggle').addEventListener('change', (event) => {
if (!state.rainLayer) return;
if (event.target.checked) state.rainLayer.addTo(state.map);
else state.map.removeLayer(state.rainLayer);
});
$('replay-2024').addEventListener('click', replayFlood2024); $('replay-2024').addEventListener('click', replayFlood2024);
$('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); }); $('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); });
loadDashboard(); loadDashboard();
+68
View File
@@ -9,6 +9,7 @@ import secrets
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal
from threading import Lock from threading import Lock
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -536,6 +537,73 @@ async def get_thaiwater_sensors():
raise HTTPException(status_code=502, detail="ThaiWater API unavailable") raise HTTPException(status_code=502, detail="ThaiWater API unavailable")
def _hii_engine():
"""Engine of the HII store, or None when collection is disabled."""
collector = app_state.get("hii_collector")
if not collector:
return None
store = collector.store
if not store.engine and not store.connect():
return None
return store.engine
def _hii_rows(sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Run a read query against the hii_* tables, JSON-normalizing numerics."""
engine = _hii_engine()
if engine is None:
return []
from sqlalchemy import text
with engine.connect() as conn:
rows = [dict(row._mapping) for row in conn.execute(text(sql), params)]
for row in rows:
for key, value in row.items():
if isinstance(value, Decimal):
row[key] = float(value)
return rows
@app.get("/api/hii/rainfall/latest")
async def get_hii_rainfall_latest(hours: int = Query(26, ge=1, le=168)):
"""Latest rainfall reading per HII station (Ping basin, collected hourly)."""
increment_counter("api_requests", labels={"endpoint": "hii_rainfall"})
sql = """
SELECT s.id AS station_id, s.oldcode, s.name_en, s.name_th,
s.latitude, s.longitude, s.agency,
m.timestamp, m.rain_1h, m.rain_24h
FROM hii_rainfall m
JOIN hii_rain_stations s ON s.id = m.station_id
JOIN (SELECT station_id, MAX(timestamp) AS latest_ts
FROM hii_rainfall WHERE timestamp >= :cutoff
GROUP BY station_id) latest
ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp
"""
cutoff = datetime.now() - timedelta(hours=hours)
return await asyncio.to_thread(_hii_rows, sql, {"cutoff": cutoff})
@app.get("/api/hii/waterlevel/latest")
async def get_hii_waterlevel_latest(hours: int = Query(26, ge=1, le=168)):
"""Latest water-level reading per HII station (Ping basin, m MSL)."""
increment_counter("api_requests", labels={"endpoint": "hii_waterlevel"})
sql = """
SELECT s.id AS station_id, s.oldcode, s.rid_code, s.name_en, s.name_th,
s.latitude, s.longitude, s.agency, s.river_name,
s.offset_msl, s.min_bank_msl, s.is_key_station,
m.timestamp, m.wl_msl, m.discharge, m.storage_percent,
m.situation_level, m.diff_wl_bank
FROM hii_waterlevel m
JOIN hii_wl_stations s ON s.id = m.station_id
JOIN (SELECT station_id, MAX(timestamp) AS latest_ts
FROM hii_waterlevel WHERE timestamp >= :cutoff
GROUP BY station_id) latest
ON latest.station_id = m.station_id AND latest.latest_ts = m.timestamp
"""
cutoff = datetime.now() - timedelta(hours=hours)
return await asyncio.to_thread(_hii_rows, sql, {"cutoff": cutoff})
@app.get("/measurements/history/{station_code}") @app.get("/measurements/history/{station_code}")
async def get_postgres_history( async def get_postgres_history(
station_code: str, station_code: str,
+13
View File
@@ -38,6 +38,19 @@ def test_dashboard_loads_additional_thaiwater_sensors():
assert "Additional ThaiWater sensor" in html assert "Additional ThaiWater sensor" in html
def test_dashboard_shows_hii_rainfall_layer():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "fetch('/api/hii/rainfall/latest')" in html
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "renderRainLayer" in html
assert "rain-toggle" in html
assert "Rainfall · 24 h" in html
# TMD rain classes on the legend
assert "Heavy 3590" in html
assert "Extreme &gt; 150" in html
def test_dashboard_loads_postgresql_history_chart(): def test_dashboard_loads_postgresql_history_chart():
html = DASHBOARD_PATH.read_text(encoding="utf-8") html = DASHBOARD_PATH.read_text(encoding="utf-8")
+52
View File
@@ -275,6 +275,58 @@ class TestParseGraphRows:
assert parse_graph_rows({}) == [] assert parse_graph_rows({}) == []
class TestHiiApiEndpoints:
"""Call the endpoint coroutines directly (the venv's httpx/starlette
combination is incompatible with TestClient)."""
@pytest.fixture
def web_api(self, tmp_path, monkeypatch):
from src import web_api
from src.hii_collector import HiiCollector
collector = HiiCollector(
{"type": "sqlite", "connection_string": f"sqlite:///{tmp_path}/api.db"}
)
now = datetime.datetime.now().replace(microsecond=0)
rain = parse_rain_records(_rain_payload())
wl = parse_waterlevel_records(_waterlevel_payload())
for record in rain + wl:
record["timestamp"] = now
assert collector.store.save_rain(rain) == 1
assert collector.store.save_waterlevel(wl) == 2
monkeypatch.setitem(web_api.app_state, "hii_collector", collector)
return web_api
def test_rainfall_latest(self, web_api):
import asyncio
rows = asyncio.run(web_api.get_hii_rainfall_latest(hours=26))
assert len(rows) == 1
assert rows[0]["oldcode"] == "CHM005"
assert rows[0]["rain_24h"] == 49.6
assert rows[0]["latitude"] == pytest.approx(19.12207)
def test_waterlevel_latest(self, web_api):
import asyncio
rows = asyncio.run(web_api.get_hii_waterlevel_latest(hours=26))
assert len(rows) == 2
p1 = next(r for r in rows if r["oldcode"] == "P.1")
assert p1["rid_code"] == "P.1"
assert p1["wl_msl"] == 303.27
assert p1["offset_msl"] == 300.5
assert p1["situation_level"] == 4
def test_empty_when_collector_disabled(self, monkeypatch):
import asyncio
from src import web_api
monkeypatch.setitem(web_api.app_state, "hii_collector", None)
assert asyncio.run(web_api.get_hii_rainfall_latest(hours=26)) == []
assert asyncio.run(web_api.get_hii_waterlevel_latest(hours=26)) == []
class TestBackfillHelpers: class TestBackfillHelpers:
def test_chunk_date_range(self): def test_chunk_date_range(self):
chunks = chunk_date_range( chunks = chunk_date_range(