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="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-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 class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div>
@@ -270,7 +278,7 @@
<script>
(function () {
'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);
function flowColor(flow) {
@@ -281,6 +289,43 @@
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) {
return String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
@@ -596,13 +641,22 @@
$('thaiwater-count').textContent = `${additional.length} additional Ping basin stations · water level`;
additional.forEach((sensor) => {
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({
className: 'marker-wrap',
html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:22px">+</div>`,
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 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>`)
@@ -643,11 +697,12 @@
$('error').style.display = 'none';
try {
initMap();
const [stationResponse, measurementResponse, riverResponse, thaiWaterResponse] = await Promise.all([
const [stationResponse, measurementResponse, riverResponse, hiiWlResponse, rainResponse] = await Promise.all([
fetch('/stations'),
fetch('/measurements/latest?limit=500'),
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) {
throw new Error(`API returned ${stationResponse.status}/${measurementResponse.status}/${riverResponse.status}`);
@@ -655,11 +710,30 @@
const stations = await stationResponse.json();
const measurements = await measurementResponse.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);
renderMap(stations, readings, riverNetwork);
renderRainLayer(rainRecords);
renderList(stations, readings);
renderThaiWaterSensors(thaiWaterSensors, new Set(stations.map((station) => station.station_code)));
renderThaiWaterSensors(sensors, new Set(stations.map((station) => station.station_code)));
loadCustomMarkers();
renderSummary(stations, readings);
$('loading').style.display = 'none';
@@ -1046,6 +1120,11 @@
$('refresh-button').addEventListener('click', loadDashboard);
$('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);
$('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); });
loadDashboard();
+68
View File
@@ -9,6 +9,7 @@ import secrets
import time
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from decimal import Decimal
from threading import Lock
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")
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}")
async def get_postgres_history(
station_code: str,
+13
View File
@@ -38,6 +38,19 @@ def test_dashboard_loads_additional_thaiwater_sensors():
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():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
+52
View File
@@ -275,6 +275,58 @@ class TestParseGraphRows:
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:
def test_chunk_date_range(self):
chunks = chunk_date_range(