[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
+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() == []