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
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:
@@ -216,6 +216,14 @@
|
|||||||
<div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high > 250</div>
|
<div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high > 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.1–10 mm</div>
|
||||||
|
<div class="legend-row"><i class="swatch" style="background:#2f96c4;border-radius:50%"></i> Moderate 10–35</div>
|
||||||
|
<div class="legend-row"><i class="swatch" style="background:#086b96;border-radius:50%"></i> Heavy 35–90</div>
|
||||||
|
<div class="legend-row"><i class="swatch" style="background:#d99018;border-radius:50%"></i> Very heavy 90–150</div>
|
||||||
|
<div class="legend-row"><i class="swatch" style="background:#cc4b37;border-radius:50%"></i> Extreme > 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) => ({
|
||||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 35–90" in html
|
||||||
|
assert "Extreme > 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")
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user