[verified] feat: add live river dashboard

Add mapped river and ThaiWater sensor layers, PostgreSQL history charts, API endpoints, and dashboard tests.
This commit is contained in:
2026-08-09 16:59:25 +07:00
parent e5936d5717
commit ae5d0a13d7
11 changed files with 800 additions and 42 deletions
+2
View File
@@ -48,6 +48,8 @@ MYSQL_CONNECTION_STRING=mysql://user:password@localhost:3306/ping_river_monitori
API_HOST=0.0.0.0 API_HOST=0.0.0.0
API_PORT=8000 API_PORT=8000
API_WORKERS=1 API_WORKERS=1
# Public ThaiWater API key used to add Ping-basin water-level sensors.
THAIWATER_API_KEY=
# Data Collection Settings # Data Collection Settings
SCRAPING_INTERVAL_HOURS=1 SCRAPING_INTERVAL_HOURS=1
+1 -2
View File
@@ -57,8 +57,7 @@ install-dev:
# Testing # Testing
test: test:
uv run python test_integration.py uv run pytest -q
uv run python test_station_management.py
test-cov: test-cov:
uv run pytest --cov=src --cov-report=html --cov-report=term uv run pytest --cov=src --cov-report=html --cov-report=term
+1
View File
@@ -37,6 +37,7 @@ class Config:
# Website settings # Website settings
TARGET_URL = "https://hyd-app-db.rid.go.th/hydro1h.html" TARGET_URL = "https://hyd-app-db.rid.go.th/hydro1h.html"
API_URL = "https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx" API_URL = "https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx"
THAIWATER_API_KEY = os.getenv("THAIWATER_API_KEY")
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30")) REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30"))
USER_AGENT = ( USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+66
View File
@@ -0,0 +1,66 @@
"""Read historical station measurements from PostgreSQL."""
import datetime
import os
from typing import Dict, List, Optional
from sqlalchemy import create_engine, text
class PostgresHistory:
def __init__(self, connection_string: Optional[str] = None, engine=None):
connection_string = connection_string or os.getenv("POSTGRES_CONNECTION_STRING")
if engine is None and not connection_string:
raise RuntimeError("POSTGRES_CONNECTION_STRING is not configured")
self.engine = engine or create_engine(connection_string, pool_pre_ping=True)
def station_history(
self,
station_code: str,
start: datetime.datetime,
end: datetime.datetime,
limit: int = 2000,
) -> List[Dict]:
if not 1 <= limit <= 5000:
raise ValueError("limit must be between 1 and 5000")
if start >= end:
raise ValueError("start must be before end")
query = text(
"""
SELECT m.timestamp, s.station_code, m.water_level,
m.discharge, m.discharge_percent
FROM water_measurements m
JOIN stations s ON m.station_id = s.id
WHERE s.station_code = :station_code
AND m.timestamp >= :start_time
AND m.timestamp <= :end_time
ORDER BY m.timestamp ASC
LIMIT :limit
"""
)
with self.engine.connect() as connection:
rows = connection.execute(
query,
{
"station_code": station_code,
"start_time": start,
"end_time": end,
"limit": limit,
},
)
result = []
for row in rows:
timestamp = row[0]
if isinstance(timestamp, str):
timestamp = datetime.datetime.fromisoformat(timestamp)
result.append(
{
"timestamp": timestamp,
"station_code": row[1],
"water_level": float(row[2]) if row[2] is not None else None,
"discharge": float(row[3]) if row[3] is not None else None,
"discharge_percent": float(row[4]) if row[4] is not None else None,
}
)
return result
+448 -39
View File
@@ -1,50 +1,459 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>Northern Thailand Ping River Monitor</title> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ping River Live Monitor</title>
<link rel="preconnect" href="https://unpkg.com">
<link rel="preconnect" href="https://tile.openstreetmap.org">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha384-sHL9NAb7lN7rfvG5lfHpm643Xkcjzp4jFvuavGOndn6pjVqS6ny56CAt3nsEVT4H" crossorigin="anonymous">
<style> <style>
body { font-family: Arial, sans-serif; margin: 40px; } :root {
.header { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; } --ink: #132b35;
.section { margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 5px; } --muted: #64777d;
.status-healthy { color: #27ae60; } --paper: #f3f7f5;
.status-degraded { color: #f39c12; } --card: #ffffff;
.status-unhealthy { color: #e74c3c; } --river: #087da5;
.endpoint { background: #f8f9fa; padding: 10px; margin: 5px 0; border-radius: 3px; } --river-light: #38b4d5;
.endpoint code { color: #2c3e50; } --mint: #dff4e8;
--green: #1e8b60;
--amber: #d99018;
--red: #cc4b37;
--border: #dce7e3;
--shadow: 0 16px 40px rgba(23, 57, 67, .10);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
color: var(--ink);
background:
radial-gradient(circle at 8% 0%, rgba(56, 180, 213, .12), transparent 25rem),
var(--paper);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.shell { max-width: 1500px; margin: 0 auto; padding: 24px; }
header {
display: flex; align-items: center; justify-content: space-between; gap: 20px;
margin-bottom: 20px;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-mark {
width: 48px; height: 48px; border-radius: 15px; display: grid; place-items: center;
color: white; font-size: 25px; background: linear-gradient(145deg, #0a91b9, #076787);
box-shadow: 0 10px 22px rgba(8, 125, 165, .25);
}
h1 { margin: 0; font-size: clamp(1.4rem, 2.5vw, 2rem); letter-spacing: -.035em; }
.subtitle { margin: 4px 0 0; color: var(--muted); font-size: .92rem; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.live-pill {
display: flex; gap: 8px; align-items: center; padding: 9px 13px; border-radius: 999px;
background: var(--mint); color: #146644; font-weight: 750; font-size: .8rem;
}
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #22a66c; box-shadow: 0 0 0 5px rgba(34,166,108,.12); }
button {
border: 1px solid var(--border); border-radius: 11px; background: white; color: var(--ink);
padding: 10px 14px; cursor: pointer; font-weight: 700; box-shadow: 0 3px 10px rgba(22,52,62,.05);
}
button:hover { border-color: #a9c4bb; transform: translateY(-1px); }
button:disabled { opacity: .55; cursor: wait; transform: none; }
.stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-bottom: 14px; }
.stat {
min-height: 112px; padding: 18px; background: var(--card); border: 1px solid var(--border);
border-radius: 17px; box-shadow: 0 6px 18px rgba(31,61,70,.045);
}
.stat-label { color: var(--muted); text-transform: uppercase; letter-spacing: .09em; font-size: .68rem; font-weight: 800; }
.stat-value { margin-top: 9px; font-size: 1.65rem; font-weight: 800; letter-spacing: -.04em; white-space: nowrap; }
.stat-note { color: var(--muted); margin-top: 3px; font-size: .77rem; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 14px; min-height: 640px; }
.map-card, .side-card { background: var(--card); border: 1px solid var(--border); border-radius: 19px; box-shadow: var(--shadow); overflow: hidden; }
.map-card { position: relative; }
#station-map { height: 640px; width: 100%; background: #dcebea; }
.map-overlay {
position: absolute; z-index: 500; top: 16px; left: 52px; right: 16px;
display: flex; justify-content: space-between; align-items: flex-start; pointer-events: none;
}
.map-heading, .legend {
background: rgba(255,255,255,.93); backdrop-filter: blur(9px); border: 1px solid rgba(207,224,218,.9);
border-radius: 13px; padding: 11px 13px; box-shadow: 0 7px 20px rgba(22,58,68,.12);
}
.map-heading strong { display: block; font-size: .9rem; }
.map-heading span { color: var(--muted); font-size: .72rem; }
.legend { font-size: .7rem; color: var(--muted); }
.legend-title { color: var(--ink); font-weight: 800; margin-bottom: 7px; }
.legend-row { display: flex; align-items: center; gap: 6px; margin: 5px 0; }
.swatch { width: 9px; height: 9px; border-radius: 50%; }
.side-card { display: flex; flex-direction: column; max-height: 640px; }
.side-head { padding: 18px 18px 14px; border-bottom: 1px solid var(--border); }
.side-head h2 { margin: 0; font-size: 1rem; }
.side-head p { margin: 5px 0 0; color: var(--muted); font-size: .75rem; }
.station-list { overflow-y: auto; padding: 7px; }
.station-row {
width: 100%; display: grid; grid-template-columns: 40px minmax(0,1fr) auto; gap: 10px; align-items: center;
padding: 11px; border: 0; border-radius: 12px; box-shadow: none; text-align: left; background: transparent;
}
.station-row:hover { background: #f1f7f5; transform: none; }
.station-code { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 11px; color: white; font-size: .68rem; font-weight: 850; }
.station-name { overflow: hidden; }
.station-name strong, .station-name span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.station-name strong { font-size: .78rem; }
.station-name span { color: var(--muted); font-size: .68rem; margin-top: 3px; }
.flow-value { text-align: right; font-size: .82rem; font-weight: 800; }
.flow-value span { display: block; color: var(--muted); font-size: .61rem; font-weight: 650; margin-top: 2px; }
.loading-panel, .error-panel { position: absolute; z-index: 600; inset: 0; display: grid; place-items: center; background: rgba(243,247,245,.88); }
.loading-card { background: white; padding: 18px 22px; border-radius: 14px; box-shadow: var(--shadow); font-weight: 750; }
.error-panel { display: none; color: #8d2f22; text-align: center; padding: 25px; }
.marker-wrap { background: none; border: 0; }
.flow-marker {
--marker-color: #087da5; --marker-size: 26px;
position: relative; width: var(--marker-size); height: var(--marker-size); display: grid; place-items: center;
border-radius: 50%; background: var(--marker-color); color: white; border: 3px solid white;
box-shadow: 0 4px 12px rgba(5,43,58,.35); font-size: 8px; font-weight: 900;
}
.flow-marker::before {
content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid var(--marker-color);
opacity: .36; animation: pulse 2.2s ease-out infinite;
}
@keyframes pulse { 0% { transform: scale(.72); opacity: .55; } 75%,100% { transform: scale(1.35); opacity: 0; } }
.flow-line { animation: riverMove 1.8s linear infinite; }
@keyframes riverMove { to { stroke-dashoffset: -30; } }
.leaflet-popup-content-wrapper { border-radius: 14px; box-shadow: 0 12px 35px rgba(14,45,54,.2); }
.popup { min-width: 190px; }
.popup-code { font-size: .7rem; color: var(--river); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
.popup h3 { margin: 4px 0 2px; font-size: 1rem; }
.popup-th { color: var(--muted); font-size: .74rem; }
.popup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
.popup-metric { background: #f1f7f5; padding: 8px; border-radius: 9px; }
.popup-metric span { display: block; color: var(--muted); font-size: .62rem; }
.popup-metric strong { display: block; margin-top: 2px; font-size: .86rem; }
.popup-time { margin-top: 9px; color: var(--muted); font-size: .64rem; }
@media (max-width: 900px) {
.stats { grid-template-columns: repeat(2, 1fr); }
.workspace { grid-template-columns: 1fr; }
.side-card { max-height: 400px; }
}
@media (max-width: 560px) {
.shell { padding: 14px; }
header { align-items: flex-start; }
.subtitle, .live-pill { display: none; }
.stats { gap: 8px; }
.stat { min-height: 96px; padding: 14px; }
.stat-value { font-size: 1.25rem; }
#station-map { height: 540px; }
.map-overlay { left: 46px; }
.legend { display: none; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <main class="shell">
<h1>🏔️ Northern Thailand Ping River Monitor API</h1> <header>
<p>Real-time water level monitoring system for the Ping River Basin in Northern Thailand</p> <div class="brand">
</div> <div class="brand-mark"></div>
<div>
<h1>Ping River Live Monitor</h1>
<p class="subtitle">Current water level and discharge across Northern Thailand</p>
</div>
</div>
<div class="header-actions">
<div class="live-pill"><span class="live-dot"></span> LIVE DATA</div>
<button id="refresh-button" type="button">↻ Refresh</button>
</div>
</header>
<div class="section"> <section class="stats" aria-label="River summary">
<h2>📊 Quick Status</h2> <article class="stat"><div class="stat-label">Reporting stations</div><div class="stat-value" id="station-count"></div><div class="stat-note">with current readings</div></article>
<p>API is running and monitoring 16 water stations along the Ping River</p> <article class="stat"><div class="stat-label">Combined discharge</div><div class="stat-value" id="total-flow"></div><div class="stat-note">sum of reported flows · m³/s</div></article>
<p>Coverage: From Chiang Dao to Nakhon Sawan</p> <article class="stat"><div class="stat-label">Strongest flow</div><div class="stat-value" id="peak-flow"></div><div class="stat-note" id="peak-station">Awaiting station data</div></article>
<p>Data collection interval: Every hour</p> <article class="stat"><div class="stat-label">Last updated</div><div class="stat-value" id="last-updated"></div><div class="stat-note" id="data-age">Loading latest readings</div></article>
</div> </section>
<div class="section"> <section class="workspace">
<h2>🔗 API Endpoints</h2> <article class="map-card">
<div class="endpoint"><code>GET /health</code> - System health status</div> <div id="station-map" role="application" aria-label="Interactive map of Ping River monitoring stations"></div>
<div class="endpoint"><code>GET /metrics</code> - Application metrics</div> <div class="map-overlay">
<div class="endpoint"><code>GET /stations</code> - List all monitoring stations</div> <div class="map-heading"><strong>Station flow map</strong><span>Marker size follows current discharge</span></div>
<div class="endpoint"><code>POST /stations</code> - Add new monitoring station</div> <div class="legend">
<div class="endpoint"><code>PUT /stations/{station_id}</code> - Update station information</div> <div class="legend-title">Flow status</div>
<div class="endpoint"><code>GET /measurements/latest</code> - Latest measurements</div> <div class="legend-row"><i class="swatch" style="background:#1e8b60"></i> Low &lt; 25 m³/s</div>
<div class="endpoint"><code>GET /measurements/station/{station_code}</code> - Station-specific data</div> <div class="legend-row"><i class="swatch" style="background:#087da5"></i> Moderate 25100</div>
<div class="endpoint"><code>POST /scrape/trigger</code> - Trigger manual data collection</div> <div class="legend-row"><i class="swatch" style="background:#d99018"></i> High 100250</div>
<div class="endpoint"><code>GET /scraping/status</code> - Scraping status</div> <div class="legend-row"><i class="swatch" style="background:#cc4b37"></i> Very high &gt; 250</div>
<div class="endpoint"><code>GET /docs</code> - Interactive API documentation</div> </div>
</div> </div>
<div class="loading-panel" id="loading"><div class="loading-card">Loading river conditions…</div></div>
<div class="error-panel" id="error"><div><strong>Map data could not be loaded.</strong><br><span id="error-message"></span></div></div>
</article>
<div class="section"> <aside class="side-card">
<h2>📈 Monitoring</h2> <div class="side-head"><h2>Current station flow</h2><p>Select a station to locate it and load PostgreSQL history</p></div>
<p>• Grafana dashboards available for data visualization</p> <div class="station-list" id="river-flow" aria-live="polite"></div>
<p>• Health checks monitor database, API, and system resources</p> <div class="side-head"><h2>Additional ThaiWater sensors</h2><p id="thaiwater-count">Loading Ping basin sensors…</p></div>
<p>• Metrics collection for performance monitoring</p> <div class="station-list" id="thaiwater-sensors" aria-live="polite"></div>
</div> </aside>
</section>
<section class="map-card" id="history-card" style="margin-top:14px;padding:20px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
<div><h2 id="history-title" style="margin:0;font-size:1rem">PostgreSQL history</h2><p id="history-status" class="subtitle">Select a RID flow station to load the last 7 days</p></div>
<select id="history-range" style="padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:white"><option value="24">24 hours</option><option value="168" selected>7 days</option><option value="720">30 days</option><option value="2160">90 days</option></select>
</div>
<div style="height:260px;margin-top:14px"><canvas id="history-chart" aria-label="Historical water level and discharge chart"></canvas></div>
</section>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha384-cxOPjt7s7Iz04uaHJceBmS+qpjv2JkIHNVcuOrM+YHwZOmJGBXI00mdUXEq65HTH" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-vsrfeLOOY6KuIYKDlmVH5UiBmgIdB1oEf7p01YgWHuqmOHfZr374+odEv96n9tNC" crossorigin="anonymous"></script>
<script>
(function () {
'use strict';
const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null };
const $ = (id) => document.getElementById(id);
function flowColor(flow) {
if (flow == null || Number.isNaN(flow)) return '#7b8f94';
if (flow < 25) return '#1e8b60';
if (flow < 100) return '#087da5';
if (flow < 250) return '#d99018';
return '#cc4b37';
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
})[char]);
}
function latestByStation(measurements) {
const latest = new Map();
measurements.forEach((item) => {
const prior = latest.get(item.station_code);
if (!prior || new Date(item.timestamp) > new Date(prior.timestamp)) latest.set(item.station_code, item);
});
return latest;
}
function formatFlow(value) {
return value == null || Number.isNaN(Number(value)) ? 'No data' : `${Number(value).toFixed(1)} m³/s`;
}
function markerSize(flow) {
if (flow == null) return 24;
return Math.max(24, Math.min(42, 22 + Math.sqrt(Math.max(0, flow)) * 1.05));
}
function initMap() {
if (!window.L) throw new Error('The map library did not load. Check the internet connection.');
if (state.map) return;
state.map = L.map('station-map', { zoomControl: true, attributionControl: true }).setView([18.78, 98.98], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '&copy; OpenStreetMap contributors'
}).addTo(state.map);
}
function clearLayers() {
state.layers.forEach((layer) => state.map.removeLayer(layer));
state.layers = [];
state.markers.clear();
}
function buildPopup(station, measurement) {
const flow = measurement ? measurement.discharge : null;
const level = measurement ? measurement.water_level : null;
const time = measurement ? new Date(measurement.timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : 'No reading available';
return `<div class="popup">
<div class="popup-code">${escapeHtml(station.station_code)}</div>
<h3>${escapeHtml(station.english_name)}</h3>
<div class="popup-th">${escapeHtml(station.thai_name)}</div>
<div class="popup-grid">
<div class="popup-metric"><span>Discharge</span><strong>${escapeHtml(formatFlow(flow))}</strong></div>
<div class="popup-metric"><span>Water level</span><strong>${level == null ? 'No data' : `${Number(level).toFixed(2)} m`}</strong></div>
</div>
<div class="popup-time">Reading: ${escapeHtml(time)}</div>
</div>`;
}
function renderRiverNetwork(riverNetwork) {
if (!riverNetwork) return;
const casing = L.geoJSON(riverNetwork, {
style: { color: '#d7f3f5', weight: 6, opacity: .72, lineCap: 'round' }
}).addTo(state.map);
const flow = L.geoJSON(riverNetwork, {
style: { color: '#087da5', weight: 2.5, opacity: .88, dashArray: '5 12', className: 'flow-line' }
}).addTo(state.map);
casing.bringToBack();
flow.bringToBack();
state.layers.push(casing, flow);
}
function renderMap(stations, readings, riverNetwork) {
clearLayers();
renderRiverNetwork(riverNetwork);
const mapped = stations.filter((station) => Number.isFinite(station.latitude) && Number.isFinite(station.longitude));
const bounds = [];
mapped.forEach((station) => {
const measurement = readings.get(station.station_code);
const flow = measurement && measurement.discharge != null ? Number(measurement.discharge) : null;
const color = flowColor(flow);
const size = markerSize(flow);
const icon = L.divIcon({
className: 'marker-wrap',
html: `<div class="flow-marker" style="--marker-color:${color};--marker-size:${size}px">${escapeHtml(station.station_code.replace('P.', ''))}</div>`,
iconSize: [size, size], iconAnchor: [size / 2, size / 2], popupAnchor: [0, -size / 2]
});
const marker = L.marker([station.latitude, station.longitude], { icon, title: `${station.station_code} ${station.english_name}` })
.bindPopup(buildPopup(station, measurement)).addTo(state.map);
state.layers.push(marker);
state.markers.set(station.station_code, marker);
bounds.push([station.latitude, station.longitude]);
});
if (!state.hasFit && bounds.length) {
state.map.fitBounds(bounds, { padding: [35, 35], maxZoom: 9 });
state.hasFit = true;
}
}
async function loadHistory(stationCode) {
state.selectedStation = stationCode;
$('history-title').textContent = `${stationCode} · PostgreSQL history`;
$('history-status').textContent = 'Loading historical measurements…';
try {
const response = await fetch(`/measurements/history/${encodeURIComponent(stationCode)}?hours=${$('history-range').value}`);
if (!response.ok) throw new Error((await response.json()).detail || `HTTP ${response.status}`);
const rows = await response.json();
if (state.historyChart) state.historyChart.destroy();
state.historyChart = new Chart($('history-chart'), {
type: 'line',
data: {
labels: rows.map((row) => new Date(row.timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit' })),
datasets: [
{ label: 'Discharge (m³/s)', data: rows.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25 },
{ label: 'Water level (m)', data: rows.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25 }
]
},
options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { flow: { type: 'linear', position: 'left' }, level: { type: 'linear', position: 'right', grid: { drawOnChartArea: false } }, x: { ticks: { maxTicksLimit: 10 } } } }
});
$('history-status').textContent = rows.length ? `${rows.length} measurements from PostgreSQL` : 'No PostgreSQL measurements in this period';
$('history-card').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (error) {
$('history-status').textContent = `History unavailable: ${error.message}`;
}
}
function renderList(stations, readings) {
const container = $('river-flow');
container.replaceChildren();
const ordered = [...stations].sort((a, b) => {
const af = readings.get(a.station_code)?.discharge;
const bf = readings.get(b.station_code)?.discharge;
return (bf == null ? -1 : Number(bf)) - (af == null ? -1 : Number(af));
});
ordered.forEach((station) => {
const measurement = readings.get(station.station_code);
const flow = measurement?.discharge == null ? null : Number(measurement.discharge);
const row = document.createElement('button');
row.type = 'button'; row.className = 'station-row';
row.innerHTML = `<span class="station-code" style="background:${flowColor(flow)}">${escapeHtml(station.station_code)}</span>
<span class="station-name"><strong>${escapeHtml(station.english_name)}</strong><span>${escapeHtml(station.thai_name)}</span></span>
<span class="flow-value">${flow == null ? '—' : flow.toFixed(1)}<span>m³/s</span></span>`;
row.addEventListener('click', () => {
const marker = state.markers.get(station.station_code);
if (marker) { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); }
loadHistory(station.station_code);
});
container.appendChild(row);
});
}
function renderThaiWaterSensors(sensors, existingCodes) {
const container = $('thaiwater-sensors');
container.replaceChildren();
const additional = sensors.filter((sensor) => !existingCodes.has(sensor.station_code));
$('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';
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 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>`)
.addTo(state.map);
state.layers.push(marker);
state.markers.set(`thaiwater:${sensor.station_code}`, marker);
const row = document.createElement('button');
row.type = 'button'; row.className = 'station-row';
row.innerHTML = `<span class="station-code" style="background:${color}">${escapeHtml(sensor.station_code)}</span><span class="station-name"><strong>${escapeHtml(sensor.station_name)}</strong><span>${escapeHtml(sensor.river_name || 'Ping basin')} · ThaiWater</span></span><span class="flow-value">${percent == null ? '—' : percent.toFixed(0) + '%'}<span>bank level</span></span>`;
row.addEventListener('click', () => { state.map.flyTo(marker.getLatLng(), Math.max(state.map.getZoom(), 11), { duration: .8 }); marker.openPopup(); });
container.appendChild(row);
});
}
function renderSummary(stations, readings) {
const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean);
const flows = current.filter((m) => m.discharge != null).map((m) => ({ code: m.station_code, value: Number(m.discharge) }));
const total = flows.reduce((sum, item) => sum + item.value, 0);
const peak = flows.length ? flows.reduce((max, item) => item.value > max.value ? item : max) : null;
const timestamps = current.map((m) => new Date(m.timestamp)).filter((date) => !Number.isNaN(date.getTime()));
const latest = timestamps.length ? new Date(Math.max(...timestamps.map((date) => date.getTime()))) : null;
$('station-count').textContent = `${current.length} / ${stations.length}`;
$('total-flow').textContent = flows.length ? total.toLocaleString(undefined, { maximumFractionDigits: 1 }) : '—';
$('peak-flow').textContent = peak ? peak.value.toFixed(1) : '—';
$('peak-station').textContent = peak ? `${peak.code} · m³/s` : 'No discharge reported';
$('last-updated').textContent = latest ? latest.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '—';
if (latest) {
const minutes = Math.max(0, Math.round((Date.now() - latest.getTime()) / 60000));
$('data-age').textContent = `${latest.toLocaleDateString([], { day: 'numeric', month: 'short' })} · ${minutes} min ago`;
} else $('data-age').textContent = 'No timestamp available';
}
async function loadDashboard() {
const refresh = $('refresh-button');
refresh.disabled = true;
$('loading').style.display = 'grid';
$('error').style.display = 'none';
try {
initMap();
const [stationResponse, measurementResponse, riverResponse, thaiWaterResponse] = await Promise.all([
fetch('/stations'),
fetch('/measurements/latest?limit=500'),
fetch('/static/ping-river-network.geojson'),
fetch('/sensors/thaiwater')
]);
if (!stationResponse.ok || !measurementResponse.ok || !riverResponse.ok) {
throw new Error(`API returned ${stationResponse.status}/${measurementResponse.status}/${riverResponse.status}`);
}
const stations = await stationResponse.json();
const measurements = await measurementResponse.json();
const riverNetwork = await riverResponse.json();
const thaiWaterSensors = thaiWaterResponse.ok ? await thaiWaterResponse.json() : [];
const readings = latestByStation(measurements);
renderMap(stations, readings, riverNetwork);
renderList(stations, readings);
renderThaiWaterSensors(thaiWaterSensors, new Set(stations.map((station) => station.station_code)));
renderSummary(stations, readings);
$('loading').style.display = 'none';
} catch (error) {
$('loading').style.display = 'none';
$('error').style.display = 'grid';
$('error-message').textContent = error.message;
console.error('Dashboard load failed:', error);
} finally {
refresh.disabled = false;
}
}
$('refresh-button').addEventListener('click', loadDashboard);
$('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); });
loadDashboard();
window.setInterval(loadDashboard, 5 * 60 * 1000);
})();
</script>
</body> </body>
</html> </html>
File diff suppressed because one or more lines are too long
+61
View File
@@ -0,0 +1,61 @@
"""Client for ThaiWater's public water-level sensor feed."""
from typing import Dict, List, Optional
import requests
class ThaiWaterClient:
API_URL = "https://twa-api-public.thaiwater.net/v2/waterlevel"
def __init__(self, session=None, api_key: Optional[str] = None, timeout: int = 30):
self.session = session or requests.Session()
self.api_key = api_key
self.timeout = timeout
def fetch_ping_sensors(self) -> List[Dict]:
if not self.api_key:
raise RuntimeError("THAIWATER_API_KEY is not configured")
response = self.session.get(
self.API_URL,
headers={"Accept-Language": "en", "x-api-key": self.api_key},
timeout=self.timeout,
)
response.raise_for_status()
return self._parse_ping_features(response.json())
@staticmethod
def _parse_ping_features(payload: Dict) -> List[Dict]:
sensors = []
for collection in payload.get("data", {}).values():
for feature in collection.get("features", []):
properties = feature.get("properties") or {}
basin = properties.get("basin") or {}
if basin.get("basin") != "Ping":
continue
geometry = feature.get("geometry") or {}
coordinates = geometry.get("coordinates") or []
if len(coordinates) < 2:
continue
station = properties.get("station") or {}
station_code = station.get("stationCode", "")
sensors.append(
{
"id": f"thaiwater:{properties.get('id')}",
"station_code": station_code.split("-", 1)[-1],
"station_name": station.get("station"),
"latitude": coordinates[1],
"longitude": coordinates[0],
"timestamp": properties.get("waterlevelDatetime"),
"water_level_msl": properties.get("waterlevelMsl"),
"bank_percent": properties.get("storagePercent"),
"distance_to_bank": properties.get("diffWlBank"),
"river_name": properties.get("riverName"),
"agency": (properties.get("agency") or {}).get("agencyShort"),
"source": "ThaiWater",
}
)
return sensors
+50 -1
View File
@@ -9,14 +9,17 @@ from contextlib import asynccontextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List from typing import Any, Dict, List
from fastapi import BackgroundTasks, FastAPI, HTTPException import requests
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from .config import Config from .config import Config
from .health_check import APIHealthCheck, DatabaseHealthCheck, HealthCheckManager, MemoryHealthCheck from .health_check import APIHealthCheck, DatabaseHealthCheck, HealthCheckManager, MemoryHealthCheck
from .logging_config import get_logger, setup_logging from .logging_config import get_logger, setup_logging
from .metrics import get_metrics_collector, increment_counter, set_gauge from .metrics import get_metrics_collector, increment_counter, set_gauge
from .postgres_history import PostgresHistory
from .schemas import ( from .schemas import (
HealthResponse, HealthResponse,
MeasurementResponse, MeasurementResponse,
@@ -26,6 +29,7 @@ from .schemas import (
StationResponse, StationResponse,
StationUpdateModel, StationUpdateModel,
) )
from .thaiwater import ThaiWaterClient
from .water_scraper_v3 import EnhancedWaterMonitorScraper from .water_scraper_v3 import EnhancedWaterMonitorScraper
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -108,6 +112,7 @@ app = FastAPI(
version="3.1.3", version="3.1.3",
lifespan=lifespan, lifespan=lifespan,
) )
app.mount("/static", StaticFiles(directory=os.path.dirname(_DASHBOARD_HTML_PATH)), name="static")
# Add CORS middleware. # Add CORS middleware.
# Origins come from CORS_ALLOW_ORIGINS (comma-separated). When none are configured # Origins come from CORS_ALLOW_ORIGINS (comma-separated). When none are configured
@@ -419,6 +424,50 @@ def _to_measurement_response(measurement: Dict[str, Any]) -> MeasurementResponse
) )
@app.get("/sensors/thaiwater")
async def get_thaiwater_sensors():
"""Get current ThaiWater water-level sensors in the Ping basin."""
increment_counter("api_requests", labels={"endpoint": "thaiwater_sensors"})
try:
client = ThaiWaterClient(
api_key=Config.THAIWATER_API_KEY,
timeout=Config.REQUEST_TIMEOUT,
)
return await asyncio.to_thread(client.fetch_ping_sensors)
except RuntimeError as error:
raise HTTPException(status_code=503, detail=str(error))
except requests.RequestException as error:
logger.error(f"Error fetching ThaiWater sensors: {error}")
raise HTTPException(status_code=502, detail="ThaiWater API unavailable")
@app.get("/measurements/history/{station_code}")
async def get_postgres_history(
station_code: str,
hours: int = Query(168, ge=1, le=24 * 365),
limit: int = Query(2000, ge=1, le=5000),
):
"""Get historical measurements for a station from PostgreSQL."""
try:
db_config = Config.get_database_config()
if db_config["type"] != "postgresql":
raise RuntimeError("PostgreSQL is not configured")
history = PostgresHistory(db_config["connection_string"])
end_time = datetime.now()
return await asyncio.to_thread(
history.station_history,
station_code,
end_time - timedelta(hours=hours),
end_time,
limit,
)
except RuntimeError as error:
raise HTTPException(status_code=503, detail=str(error))
except Exception as error:
logger.error(f"Error fetching PostgreSQL history: {error}")
raise HTTPException(status_code=502, detail="PostgreSQL history unavailable")
@app.get("/measurements/latest", response_model=List[MeasurementResponse]) @app.get("/measurements/latest", response_model=List[MeasurementResponse])
async def get_latest_measurements(limit: int = 100): async def get_latest_measurements(limit: int = 100):
"""Get latest measurements from all stations""" """Get latest measurements from all stations"""
+46
View File
@@ -0,0 +1,46 @@
from pathlib import Path
DASHBOARD_PATH = Path(__file__).parents[1] / "src" / "static" / "dashboard.html"
def test_dashboard_contains_live_map_and_flow_visualization():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "id=\"station-map\"" in html
assert "id=\"river-flow\"" in html
assert "fetch('/stations')" in html or 'fetch("/stations")' in html
assert "fetch('/measurements/latest" in html or 'fetch(\"/measurements/latest' in html
assert "leaflet" in html.lower()
def test_dashboard_explains_flow_legend_and_refresh():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "Flow status" in html
assert "Last updated" in html
assert "Refresh" in html
def test_dashboard_uses_mapped_river_network_instead_of_station_connections():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
river_network = DASHBOARD_PATH.with_name("ping-river-network.geojson")
assert river_network.exists()
assert "fetch('/static/ping-river-network.geojson')" in html
assert "mainBasin.map" not in html
def test_dashboard_loads_additional_thaiwater_sensors():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "fetch('/sensors/thaiwater')" in html
assert "Additional ThaiWater sensor" in html
def test_dashboard_loads_postgresql_history_chart():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "PostgreSQL history" in html
assert "/measurements/history/" in html
assert "history-chart" in html
+49
View File
@@ -0,0 +1,49 @@
import datetime
from sqlalchemy import create_engine, text
from src.postgres_history import PostgresHistory
def test_history_returns_station_series_in_chronological_order(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'history.db'}")
with engine.begin() as connection:
connection.execute(text("CREATE TABLE stations (id INTEGER PRIMARY KEY, station_code TEXT)"))
connection.execute(
text(
"CREATE TABLE water_measurements ("
"timestamp DATETIME, station_id INTEGER, water_level REAL, "
"discharge REAL, discharge_percent REAL)"
)
)
connection.execute(text("INSERT INTO stations VALUES (1, 'P.1'), (2, 'P.20')"))
connection.execute(
text(
"INSERT INTO water_measurements VALUES "
"('2026-08-09 13:00:00', 1, 3.2, 110.0, 40.0),"
"('2026-08-09 14:00:00', 1, 3.4, 120.0, 42.0),"
"('2026-08-09 14:00:00', 2, 2.1, 30.0, 15.0)"
)
)
history = PostgresHistory(engine=engine).station_history(
"P.1",
start=datetime.datetime(2026, 8, 9, 12),
end=datetime.datetime(2026, 8, 9, 15),
limit=100,
)
assert [row["timestamp"].hour for row in history] == [13, 14]
assert [row["discharge"] for row in history] == [110.0, 120.0]
assert all(row["station_code"] == "P.1" for row in history)
def test_history_rejects_excessive_limit():
history = PostgresHistory.__new__(PostgresHistory)
try:
history.station_history("P.1", datetime.datetime.now(), datetime.datetime.now(), 5001)
except ValueError as error:
assert "limit" in str(error)
else:
raise AssertionError("Expected excessive history limit to be rejected")
+75
View File
@@ -0,0 +1,75 @@
from unittest.mock import MagicMock
from src.thaiwater import ThaiWaterClient
SAMPLE_RESPONSE = {
"data": {
"50": {
"type": "FeatureCollection",
"features": [
{
"geometry": {"type": "Point", "coordinates": [98.635262, 19.638411]},
"properties": {
"id": "123",
"waterlevelDatetime": "2026-08-09T15:00:00+07:00",
"waterlevelMsl": 742.34,
"storagePercent": 38.57,
"diffWlBank": 1.87,
"riverName": "Ping River",
"station": {
"stationCode": "G07003-P.65",
"station": "Ban Muang Pok",
},
"agency": {"agencyShort": "RID"},
"basin": {"basin": "Ping"},
},
},
{
"geometry": {"type": "Point", "coordinates": [100.1, 18.1]},
"properties": {
"id": "999",
"station": {"stationCode": "N.1", "station": "Nan station"},
"basin": {"basin": "Nan"},
},
},
],
}
}
}
def test_fetch_ping_sensors_normalizes_and_filters_basin():
session = MagicMock()
response = session.get.return_value
response.json.return_value = SAMPLE_RESPONSE
response.raise_for_status.return_value = None
sensors = ThaiWaterClient(session=session, api_key="public-key").fetch_ping_sensors()
assert sensors == [
{
"id": "thaiwater:123",
"station_code": "P.65",
"station_name": "Ban Muang Pok",
"latitude": 19.638411,
"longitude": 98.635262,
"timestamp": "2026-08-09T15:00:00+07:00",
"water_level_msl": 742.34,
"bank_percent": 38.57,
"distance_to_bank": 1.87,
"river_name": "Ping River",
"agency": "RID",
"source": "ThaiWater",
}
]
session.get.assert_called_once()
assert session.get.call_args.kwargs["headers"]["x-api-key"] == "public-key"
def test_fetch_ping_sensors_skips_features_without_coordinates():
response_data = {"data": {"50": {"features": [{"geometry": None, "properties": {"basin": {"basin": "Ping"}}}]}}}
session = MagicMock()
session.get.return_value.json.return_value = response_data
assert ThaiWaterClient(session=session, api_key="key").fetch_ping_sensors() == []