feat: backfill hii_waterlevel from the HII waterlevel_graph archive
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 31s
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
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 0s
Documentation / Documentation Summary (push) Successful in 2s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 13s
Documentation / Validate Documentation (push) Failing after 9s
Documentation / Generate API Documentation (push) Successful in 9s
Documentation / Build Sphinx Documentation (push) Successful in 15s

scripts/backfill_hii_waterlevel.py walks the api-v3 waterlevel_graph
endpoint (hourly wl_msl + discharge, archive back to ~2019) in full-year
windows per station and upserts into hii_waterlevel. Defaults to the
RID-mirror and key stations; --stations/--all/--start/--end/--chunk-days
override. History upserts touch only wl_msl and discharge so colliding
live-snapshot rows keep storage_percent/situation_level. Idempotent and
safe to re-run.
This commit is contained in:
2026-08-11 15:11:08 +07:00
parent 33d8baa8dd
commit d72496f404
5 changed files with 383 additions and 5 deletions
+103
View File
@@ -4,6 +4,7 @@ import datetime
import pytest
from src.hii_backfill import chunk_date_range, parse_graph_rows, select_stations
from src.hii_collector import (
HiiStore,
parse_rain_records,
@@ -207,3 +208,105 @@ class TestHiiStore:
def test_save_empty(self, store):
assert store.save_rain([]) == 0
def test_history_upsert_preserves_snapshot_columns(self, store):
# A live snapshot row exists with extra columns populated
store.save_waterlevel(parse_waterlevel_records(_waterlevel_payload()))
# Backfill collides on the same (station, timestamp) with new values
rows = [
{
"timestamp": datetime.datetime(2026, 8, 11, 13, 0),
"wl_msl": 303.30,
"discharge": 340.0,
},
{
"timestamp": datetime.datetime(2019, 8, 1, 1, 0),
"wl_msl": 301.71,
"discharge": 11.7,
},
]
assert store.save_waterlevel_history(3226, rows) == 2
from sqlalchemy import text
with store.engine.connect() as conn:
collided = conn.execute(
text(
"SELECT wl_msl, discharge, storage_percent, situation_level "
"FROM hii_waterlevel WHERE station_id = 3226 "
"AND timestamp = '2026-08-11 13:00:00'"
)
).fetchone()
historical = conn.execute(
text(
"SELECT wl_msl FROM hii_waterlevel WHERE station_id = 3226 "
"AND timestamp = '2019-08-01 01:00:00'"
)
).fetchone()
# wl_msl/discharge updated, snapshot-only columns untouched
assert float(collided[0]) == 303.30
assert float(collided[1]) == 340.0
assert float(collided[2]) == 81.21
assert collided[3] == 4
assert float(historical[0]) == 301.71
class TestParseGraphRows:
def test_parses_and_skips_empty(self):
payload = {
"data": {
"graph_data": [
{"datetime": "2024-10-05 12:00", "value": 305.8, "discharge": 656},
{"datetime": "2024-10-05 13:00", "value": None, "discharge": None},
{"datetime": None, "value": 300.0, "discharge": 1},
]
}
}
rows = parse_graph_rows(payload)
assert rows == [
{
"timestamp": datetime.datetime(2024, 10, 5, 12, 0),
"wl_msl": 305.8,
"discharge": 656.0,
}
]
def test_empty_payload(self):
assert parse_graph_rows({}) == []
class TestBackfillHelpers:
def test_chunk_date_range(self):
chunks = chunk_date_range(
datetime.date(2024, 1, 1), datetime.date(2024, 3, 1), 31
)
assert chunks[0] == (datetime.date(2024, 1, 1), datetime.date(2024, 1, 31))
assert chunks[-1][1] == datetime.date(2024, 3, 1)
# Contiguous, no overlap
for (_, prev_end), (next_start, _) in zip(chunks, chunks[1:]):
assert next_start == prev_end + datetime.timedelta(days=1)
def test_chunk_single_day(self):
d = datetime.date(2024, 1, 1)
assert chunk_date_range(d, d, 365) == [(d, d)]
def test_select_default_keeps_rid_and_key_stations(self):
records = [
{"station_id": 1, "rid_code": "P.1", "is_key_station": True},
{"station_id": 2, "rid_code": None, "is_key_station": False},
{"station_id": 3, "rid_code": None, "is_key_station": True},
]
assert [r["station_id"] for r in select_stations(records)] == [1, 3]
def test_select_by_code_matches_rid_code_and_oldcode(self):
records = [
{"station_id": 1, "rid_code": "P.1", "oldcode": "ridhydro_P.1"},
{"station_id": 2, "rid_code": None, "oldcode": "CHM004"},
{"station_id": 3, "rid_code": "P.67", "oldcode": "P.67"},
]
selected = select_stations(records, codes=["p.1", "chm004"])
assert [r["station_id"] for r in selected] == [1, 2]
def test_select_all(self):
records = [{"station_id": 1}, {"station_id": 2}]
assert select_stations(records, all_stations=True) == records