Compare commits
4
Commits
28b62e5a36
...
382daa7d86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
382daa7d86 | ||
|
|
b02e815d72 | ||
|
|
5dc5850df6 | ||
|
|
70e4da07a0 |
@@ -184,7 +184,14 @@ Oct 2024 flood). Mae Kuang Udom Thara is the second upstream reservoir.
|
|||||||
| Source | What | Access |
|
| Source | What | Access |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `https://app.rid.go.th/reservoir/api/dams` | **INGESTED** — daily snapshot of all ~35 large dams (storage/inflow/outflow MCM, % of usable). `POST` with form field `date=YYYY-MM-DD` (empty = today); GET returns 404 "Unknown method." Archive ≥ 2009; `level_msl` (`DMD_Q`) populated in older years only. Mae Ngat = `DAM_ID 200103` — hit 113% usable capacity, ~19 MCM/day inflow, in Oct 2024. Collected daily by `src/rid_reservoir.py` into `rid_dams` + `rid_reservoir_daily`; backfill via `scripts/backfill_rid_reservoir.py` | Open, no auth |
|
| `https://app.rid.go.th/reservoir/api/dams` | **INGESTED** — daily snapshot of all ~35 large dams (storage/inflow/outflow MCM, % of usable). `POST` with form field `date=YYYY-MM-DD` (empty = today); GET returns 404 "Unknown method." Archive ≥ 2009; `level_msl` (`DMD_Q`) populated in older years only. Mae Ngat = `DAM_ID 200103` — hit 113% usable capacity, ~19 MCM/day inflow, in Oct 2024. Collected daily by `src/rid_reservoir.py` into `rid_dams` + `rid_reservoir_daily`; backfill via `scripts/backfill_rid_reservoir.py` | Open, no auth |
|
||||||
| `https://lsim.rid.go.th/ForeCast?reservoirid=22` | Mae Ngat daily status/forecast (RID) | Open, scrape — timed out from outside RID network when probed 2026-08-13 |
|
| `https://lsim.rid.go.th/ForeCast?reservoirid=22` | Mae Ngat daily status/forecast (RID) | **UNREACHABLE — do not plan around it.** Probed 2026-08-13 from a Thai consumer ISP (AIS Fibre, TH) *and* from abroad: DNS resolves (122.154.18.207) but ICMP is 100% loss and ports 80/443/8080 are filtered, while `app.rid.go.th` answers in 0.27 s over the same connection. Down or RID-internal-only — not a geo-block |
|
||||||
|
| `https://app.rid.go.th/reservoir/api/dam` | **Per-dam daily series in ONE request** — `GET` with `dam_id=200103&date_start=YYYY-MM-DD&date_end=YYYY-MM-DD&percent=`. Archive to 2009 (scattered single-day gaps). Far cheaper than the per-day `api/dams` loop the backfill used (one request vs ~2,900); prefer it for gap repair and for adding other dams. Sibling `api/damgraph` takes the same params | Open, no auth |
|
||||||
|
| `https://bigdata-api.rid.go.th` (SWOC) | **Intraday reservoir state** — RID SWOC telemetry, hourly with an explicit `hourly_time_utc` stamp; includes Mae Ngat (`TUP.16`) reservoir level m MSL and % capacity. **Snapshot-only — no archive**, so it can only be accumulated forward | Open, no auth |
|
||||||
|
| ThaiWater `public/waterlevel_load` stations `ridhydro_TUP.16` (at the dam) / `ridhydro_TUP.11` (dam outlet) | Hourly Mae Ngat reservoir level and outlet stage/flow. **Snapshot-only — `waterlevel_graph` returns empty grids for these ids at every era** (verified 2026-08-13). Collected hourly by our HII collector since 2026-08-11; accumulating forward | Open, no auth |
|
||||||
|
| ThaiWater `public/waterlevel_graph` station `P.75` (id 3253) | **The practical dam-release signal**: hourly stage+discharge 3.8 km below the Mae Ngat dam, history to 2019. Already ingested as a core RID station and a model feature since v1 | Open, no auth |
|
||||||
|
| ThaiWater `public/waterlevel_graph` station `MOU301` "สะพานน้ำแม่งัด" (id 1475118) | 10-minute stage on the Mae Ngat *above* the reservoir (inflow arm). History only from ~mid-2025; level only, no discharge | Open, no auth |
|
||||||
|
| ThaiWater `api-v3 .../analyst/dam` (dam.id 53) | EGAT-sourced copy of Mae Ngat carrying reservoir **level in m MSL historically** — the field RID's own API stopped populating (`DMD_Q`) after ~2013. Daily, no observation time | Open, no auth |
|
||||||
|
| `https://tiwrm.hii.or.th/DATA/REPORT/php/rid_bigcm_raw.php?sdate=YYYY-MM-DD` | HII HTML mirror of the RID large-dam daily table. Daily and *intermittent* (2026 YTD publishes ~108 of 225 days) — a cross-check, not a primary source | Open, no auth |
|
||||||
| `https://water.egat.co.th` | EGAT dams (Bhumibol/Sirikit) hourly+daily inflow/outflow/level | Endpoint catalog not public; contact EGAT (0-2436-8186). Only relevant downstream of Bhumibol |
|
| `https://water.egat.co.th` | EGAT dams (Bhumibol/Sirikit) hourly+daily inflow/outflow/level | Endpoint catalog not public; contact EGAT (0-2436-8186). Only relevant downstream of Bhumibol |
|
||||||
| ThaiWater `/v2/large-dam/*`, `dam_rulecurve/graph` | All large/medium dams incl. hourly | Requires HII API key (§2.2) |
|
| ThaiWater `/v2/large-dam/*`, `dam_rulecurve/graph` | All large/medium dams incl. hourly | Requires HII API key (§2.2) |
|
||||||
|
|
||||||
|
|||||||
@@ -509,8 +509,28 @@ event-peak amplitude (−0.11 → −0.03 m on the Sep 2024 event), and `rise_da
|
|||||||
opt-in via `--dam` on the training CLI, `scripts/backtest_render.py --dam`,
|
opt-in via `--dam` on the training CLI, `scripts/backtest_render.py --dam`,
|
||||||
and the `rise_rain_dam` / `rise_dam` harness variants). The collector keeps
|
and the `rise_rain_dam` / `rise_dam` harness variants). The collector keeps
|
||||||
accruing daily rows; revisit post-monsoon when the 2026 season adds dam-era
|
accruing daily rows; revisit post-monsoon when the 2026 season adds dam-era
|
||||||
flood events — an intraday scrape (the lsim.rid.go.th source, reachable only
|
flood events.
|
||||||
from Thai networks) would remove the publication-lag objection entirely.
|
|
||||||
|
**Why the lag is probably not the whole story — P.75 already *is* the dam
|
||||||
|
signal.** A 2026-08-13 source sweep put the negative result on firmer
|
||||||
|
ground: **P.75 "บ้านช่อแล" sits 3.8 km downstream of the Mae Ngat dam** on
|
||||||
|
the Mae Ngat river (nearest other station: P.4A at 10.9 km), it reports
|
||||||
|
hourly, and it has been a model input since v1 with a 12 h routed lead into
|
||||||
|
P.1. Whatever the reservoir releases flows past P.75 within the hour and the
|
||||||
|
model already reads it. The daily reservoir table therefore offers a stale,
|
||||||
|
coarser proxy of a signal the features capture hourly and directly — which
|
||||||
|
is the more likely reason it adds nothing and costs alarm responsiveness.
|
||||||
|
|
||||||
|
That reframes what a future intraday source would have to beat: not "no dam
|
||||||
|
information", but "hourly observed dam *outflow*". Genuine intraday
|
||||||
|
reservoir-state feeds do exist and are open (`bigdata-api.rid.go.th` SWOC
|
||||||
|
telemetry, and ThaiWater station `ridhydro_TUP.16` *at the dam*), but both
|
||||||
|
are **snapshot-only — no archive** (verified: the history endpoint returns
|
||||||
|
empty grids for them at every era, including the current one). They can only
|
||||||
|
be accumulated forward, so they cannot retrain against 2024/2025 events.
|
||||||
|
The HII collector already captures both hourly as of 2026-08-11; revisit
|
||||||
|
after the 2026 monsoon, when a season of true intraday reservoir state
|
||||||
|
exists alongside its flood events.
|
||||||
|
|
||||||
**Shipped from the same work:** the HII gap-fill merge in the data loader
|
**Shipped from the same work:** the HII gap-fill merge in the data loader
|
||||||
(`fill_from_hii`, +9,341 h at P.81, +682 h at P.92, +810 h at P.20) is
|
(`fill_from_hii`, +9,341 h at P.81, +682 h at P.92, +810 h at P.20) is
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Backfill rid_reservoir_daily with RID large-dam history (Mae Ngat et al.).
|
"""Backfill rid_reservoir_daily with RID large-dam history (Mae Ngat et al.).
|
||||||
|
|
||||||
One request per day against app.rid.go.th/reservoir/api/dams (archive reaches
|
Two paths, both idempotent and both skipping what is already stored, so a
|
||||||
back to at least 2009). Days already stored are skipped, so reruns only fetch
|
rerun repairs holes left by transient failures and is safe alongside the
|
||||||
what is missing — safe alongside the hourly live collector, and a rerun
|
hourly live collector:
|
||||||
repairs holes left by transient failures.
|
|
||||||
|
--dam-id (default: Mae Ngat) one dam, whole range, via api/dam — a handful
|
||||||
|
of requests for the entire 2009-today archive
|
||||||
|
--all-dams all ~35 dams, one request per calendar day via
|
||||||
|
api/dams — thousands of requests, ~25 minutes
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/backfill_rid_reservoir.py # missing days since 2018-08-01
|
uv run scripts/backfill_rid_reservoir.py # Mae Ngat since 2018-08-01
|
||||||
uv run scripts/backfill_rid_reservoir.py --start 2015-01-01
|
uv run scripts/backfill_rid_reservoir.py --start 2009-01-01 # full archive
|
||||||
|
uv run scripts/backfill_rid_reservoir.py --refresh # rewrite stored days too
|
||||||
|
uv run scripts/backfill_rid_reservoir.py --all-dams --start 2015-01-01
|
||||||
uv run scripts/backfill_rid_reservoir.py --db-url postgresql://...
|
uv run scripts/backfill_rid_reservoir.py --db-url postgresql://...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -21,7 +27,12 @@ import sys
|
|||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
from src.config import Config
|
from src.config import Config
|
||||||
from src.rid_reservoir import RidReservoirStore, backfill
|
from src.rid_reservoir import (
|
||||||
|
MAE_NGAT_DAM_ID,
|
||||||
|
RidReservoirStore,
|
||||||
|
backfill,
|
||||||
|
backfill_dam,
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_START = datetime.date(2018, 8, 1) # start of the water_measurements grid
|
DEFAULT_START = datetime.date(2018, 8, 1) # start of the water_measurements grid
|
||||||
|
|
||||||
@@ -34,6 +45,27 @@ def main(argv=None) -> int:
|
|||||||
parser.add_argument("--end", type=datetime.date.fromisoformat, default=None)
|
parser.add_argument("--end", type=datetime.date.fromisoformat, default=None)
|
||||||
parser.add_argument("--db-url", default=None)
|
parser.add_argument("--db-url", default=None)
|
||||||
parser.add_argument("--throttle", type=float, default=0.4)
|
parser.add_argument("--throttle", type=float, default=0.4)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dam-id",
|
||||||
|
default=MAE_NGAT_DAM_ID,
|
||||||
|
help="dam to backfill via the fast range endpoint (default Mae Ngat)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all-dams",
|
||||||
|
action="store_true",
|
||||||
|
help="every dam, one request per calendar day (slow full-fleet path)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--chunk-days",
|
||||||
|
type=int,
|
||||||
|
default=1830,
|
||||||
|
help="days per range request; the endpoint imposes no limit of its own",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--refresh",
|
||||||
|
action="store_true",
|
||||||
|
help="re-fetch days already stored (adds level_msl to api/dams rows)",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -59,10 +91,35 @@ def main(argv=None) -> int:
|
|||||||
return 1
|
return 1
|
||||||
end = args.end or datetime.date.today()
|
end = args.end or datetime.date.today()
|
||||||
span_days = (end - args.start).days + 1
|
span_days = (end - args.start).days + 1
|
||||||
missing = span_days - len(store.present_dates(args.start, end))
|
dam_id = None if args.all_dams else args.dam_id
|
||||||
saved = backfill(store, args.start, end, throttle_seconds=args.throttle)
|
missing = span_days - len(store.present_dates(args.start, end, dam_id=dam_id))
|
||||||
print(f"backfilled {saved} dam-day rows ({missing} days were missing)")
|
if args.all_dams:
|
||||||
return 0 if saved or missing == 0 else 1
|
saved = backfill(store, args.start, end, throttle_seconds=args.throttle)
|
||||||
|
print(f"backfilled {saved} dam-day rows ({missing} days were missing)")
|
||||||
|
return 0 if saved or missing == 0 else 1
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
saved = backfill_dam(
|
||||||
|
store,
|
||||||
|
dam_id=args.dam_id,
|
||||||
|
start=args.start,
|
||||||
|
end=end,
|
||||||
|
chunk_days=args.chunk_days,
|
||||||
|
throttle_seconds=max(args.throttle, 1.0),
|
||||||
|
skip_present=not args.refresh,
|
||||||
|
stats=stats,
|
||||||
|
)
|
||||||
|
still_missing = span_days - len(
|
||||||
|
store.present_dates(args.start, end, dam_id=args.dam_id)
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"backfilled {saved} dam-day rows for dam {args.dam_id} "
|
||||||
|
f"(requests: {stats.get('requests', 0)}, {missing} days were missing, "
|
||||||
|
f"{still_missing} never published by the source)"
|
||||||
|
)
|
||||||
|
# A rerun saves nothing once the archive is complete — only a real
|
||||||
|
# transport/database failure is an error here.
|
||||||
|
return 1 if stats.get("aborted") or stats.get("failures") else 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+260
-18
@@ -7,6 +7,13 @@ dam in Thailand — storage, inflow and outflow in MCM — with archive depth
|
|||||||
back to at least 2009. GET returns 404 ("Unknown method."); the POST body
|
back to at least 2009. GET returns 404 ("Unknown method."); the POST body
|
||||||
may be empty but must carry a Content-Length.
|
may be empty but must carry a Content-Length.
|
||||||
|
|
||||||
|
A sibling endpoint transposes that axis: ``GET .../api/dam`` with
|
||||||
|
``dam_id``/``date_start``/``date_end`` returns ONE dam over a whole date
|
||||||
|
range. It is GET-only (POST answers 404 "Unknown method.") and served the
|
||||||
|
full 2009-01-01..today archive — 6,362 rows, 4.7 MB — in a single ~6 s
|
||||||
|
response, so backfilling one dam costs one request instead of one per
|
||||||
|
calendar day. Field names differ from api/dams; see parse_dam_range_records.
|
||||||
|
|
||||||
The reservoir that matters for P.1 flood forecasting is Mae Ngat Somboon
|
The reservoir that matters for P.1 flood forecasting is Mae Ngat Somboon
|
||||||
Chon (DAM_ID 200103), the only large dam upstream of Chiang Mai: during the
|
Chon (DAM_ID 200103), the only large dam upstream of Chiang Mai: during the
|
||||||
Oct 2024 record flood it reached 113% of usable capacity with inflow spikes
|
Oct 2024 record flood it reached 113% of usable capacity with inflow spikes
|
||||||
@@ -26,7 +33,9 @@ import requests
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
RID_DAMS_URL = "https://app.rid.go.th/reservoir/api/dams"
|
RID_DAMS_URL = "https://app.rid.go.th/reservoir/api/dams"
|
||||||
|
RID_DAM_RANGE_URL = "https://app.rid.go.th/reservoir/api/dam"
|
||||||
MAE_NGAT_DAM_ID = "200103"
|
MAE_NGAT_DAM_ID = "200103"
|
||||||
|
RID_ARCHIVE_START = datetime.date(2009, 1, 1) # earliest date api/dam serves
|
||||||
|
|
||||||
# Per-column NUMERIC capacity; source junk beyond these becomes NULL instead
|
# Per-column NUMERIC capacity; source junk beyond these becomes NULL instead
|
||||||
# of overflowing the insert and discarding the whole daily batch.
|
# of overflowing the insert and discarding the whole daily batch.
|
||||||
@@ -91,6 +100,62 @@ def parse_dam_records(payload: Dict) -> List[Dict]:
|
|||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dam_range_records(payload: Dict) -> List[Dict]:
|
||||||
|
"""Flatten one api/dam single-dam range payload into the same rows as
|
||||||
|
parse_dam_records, so both endpoints feed one store.
|
||||||
|
|
||||||
|
api/dam names its columns differently and suffixes each measurement
|
||||||
|
``_curr`` / ``_prev``; ``_prev`` is the SAME calendar date one year
|
||||||
|
earlier (confirmed against api/dams' DMD_Date_prev) and is dropped —
|
||||||
|
those days are rows of their own. Mapping, verified equal to api/dams
|
||||||
|
on 2019-01-05, 2024-09-24, 2024-10-05 and 2026-08-08:
|
||||||
|
|
||||||
|
DMD_QUse_curr -> storage_mcm (identical)
|
||||||
|
PERCENT_DMD_QUse_curr -> storage_pct (2 dp; api/dams rounds to
|
||||||
|
whole percent, 112.62 vs 113)
|
||||||
|
DMD_Inflow_curr -> inflow_mcm (identical)
|
||||||
|
DMD_Outflow_curr -> outflow_mcm (identical)
|
||||||
|
DMD_ULevel_curr -> level_msl (only source of the level:
|
||||||
|
api/dams' DMD_Q is ' - ' for
|
||||||
|
all 35 dams, and api/dam's
|
||||||
|
DMD_Q_curr is a constant 0.00)
|
||||||
|
|
||||||
|
Dam metadata (name, region, capacities) and coordinates are carried too,
|
||||||
|
so the range path never has to blank rid_dams.
|
||||||
|
"""
|
||||||
|
coords = payload.get("dam_coordinates") or {}
|
||||||
|
payload_dam_id = payload.get("dam_id")
|
||||||
|
records = []
|
||||||
|
for row in payload.get("dam_data") or []:
|
||||||
|
dam_id = row.get("DAM_ID") or payload_dam_id
|
||||||
|
try:
|
||||||
|
date = datetime.date.fromisoformat(row.get("DATE_curr") or "")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not dam_id:
|
||||||
|
continue
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"dam_id": dam_id,
|
||||||
|
"region": row.get("DAM_Region") or payload.get("dam_region"),
|
||||||
|
"name_th": row.get("DAM_Name") or payload.get("dam_name"),
|
||||||
|
"latitude": _to_float(coords.get("lat")),
|
||||||
|
"longitude": _to_float(coords.get("lng")),
|
||||||
|
"capacity_max_mcm": _to_float(row.get("DAM_QMax")),
|
||||||
|
"capacity_normal_mcm": _to_float(row.get("DAM_QStore")),
|
||||||
|
"date": date,
|
||||||
|
"storage_mcm": _to_float(row.get("DMD_QUse_curr")),
|
||||||
|
"storage_pct": _to_float(row.get("PERCENT_DMD_QUse_curr")),
|
||||||
|
"inflow_mcm": _to_float(row.get("DMD_Inflow_curr")),
|
||||||
|
"outflow_mcm": _to_float(row.get("DMD_Outflow_curr")),
|
||||||
|
# An MSL elevation of exactly 0 is "not published", not a
|
||||||
|
# reading — these dams sit between 45 and 400 m.
|
||||||
|
"level_msl": _to_float(row.get("DMD_ULevel_curr")) or None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
class RidReservoirClient:
|
class RidReservoirClient:
|
||||||
"""HTTP client for the RID reservoir daily-status API."""
|
"""HTTP client for the RID reservoir daily-status API."""
|
||||||
|
|
||||||
@@ -98,9 +163,11 @@ class RidReservoirClient:
|
|||||||
self,
|
self,
|
||||||
url: str = RID_DAMS_URL,
|
url: str = RID_DAMS_URL,
|
||||||
session: Optional[requests.Session] = None,
|
session: Optional[requests.Session] = None,
|
||||||
timeout: int = 60,
|
timeout: int = 120,
|
||||||
|
range_url: str = RID_DAM_RANGE_URL,
|
||||||
):
|
):
|
||||||
self.url = url
|
self.url = url
|
||||||
|
self.range_url = range_url
|
||||||
self.session = session or requests.Session()
|
self.session = session or requests.Session()
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
@@ -116,6 +183,36 @@ class RidReservoirClient:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return parse_dam_records(response.json())
|
return parse_dam_records(response.json())
|
||||||
|
|
||||||
|
def fetch_dam_range(
|
||||||
|
self, dam_id: str, start: datetime.date, end: datetime.date
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Every published day in [start, end] for one dam, in one request.
|
||||||
|
|
||||||
|
GET only — api/dam answers POST with 404 "Unknown method.", the exact
|
||||||
|
opposite of api/dams. An unrecognised dam_id still returns HTTP 200
|
||||||
|
but with a PHP notice page instead of JSON, so a decode failure is
|
||||||
|
reported as a bad request rather than a transport error.
|
||||||
|
"""
|
||||||
|
response = self.session.get(
|
||||||
|
self.range_url,
|
||||||
|
params={
|
||||||
|
"dam_id": dam_id,
|
||||||
|
"date_start": start.isoformat(),
|
||||||
|
"date_end": end.isoformat(),
|
||||||
|
"percent": "",
|
||||||
|
},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"api/dam returned non-JSON for dam_id '{dam_id}' "
|
||||||
|
f"({start}..{end}) — unknown dam_id?"
|
||||||
|
) from e
|
||||||
|
return parse_dam_range_records(payload)
|
||||||
|
|
||||||
|
|
||||||
class RidReservoirStore:
|
class RidReservoirStore:
|
||||||
"""SQL persistence for dam metadata + daily measurements.
|
"""SQL persistence for dam metadata + daily measurements.
|
||||||
@@ -205,20 +302,53 @@ class RidReservoirStore:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"rid_reservoir_daily migration skipped: {e}")
|
logger.warning(f"rid_reservoir_daily migration skipped: {e}")
|
||||||
|
|
||||||
def _upsert(self, table: str, key_cols: List[str], value_cols: List[str]) -> str:
|
def _upsert(
|
||||||
|
self,
|
||||||
|
table: str,
|
||||||
|
key_cols: List[str],
|
||||||
|
value_cols: List[str],
|
||||||
|
preserve_cols: "tuple[str, ...]" = (),
|
||||||
|
) -> str:
|
||||||
|
"""Build an upsert; columns in `preserve_cols` keep their stored value
|
||||||
|
when the incoming one is NULL.
|
||||||
|
|
||||||
|
Dam metadata needs that: any payload that omits a name or coordinate
|
||||||
|
would otherwise blank a good rid_dams row on every later write.
|
||||||
|
"""
|
||||||
cols = key_cols + value_cols
|
cols = key_cols + value_cols
|
||||||
col_list = ", ".join(cols)
|
col_list = ", ".join(cols)
|
||||||
params = ", ".join(f":{c}" for c in cols)
|
params = ", ".join(f":{c}" for c in cols)
|
||||||
|
conflict = ", ".join(key_cols)
|
||||||
if self.db_type == "sqlite":
|
if self.db_type == "sqlite":
|
||||||
return f"INSERT OR REPLACE INTO {table} ({col_list}) VALUES ({params})"
|
if not preserve_cols:
|
||||||
if self.db_type == "postgresql":
|
return f"INSERT OR REPLACE INTO {table} ({col_list}) VALUES ({params})"
|
||||||
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in value_cols)
|
updates = ", ".join(
|
||||||
conflict = ", ".join(key_cols)
|
f"{c} = COALESCE(excluded.{c}, {table}.{c})"
|
||||||
|
if c in preserve_cols
|
||||||
|
else f"{c} = excluded.{c}"
|
||||||
|
for c in value_cols
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"INSERT INTO {table} ({col_list}) VALUES ({params}) "
|
f"INSERT INTO {table} ({col_list}) VALUES ({params}) "
|
||||||
f"ON CONFLICT ({conflict}) DO UPDATE SET {updates}"
|
f"ON CONFLICT ({conflict}) DO UPDATE SET {updates}"
|
||||||
)
|
)
|
||||||
updates = ", ".join(f"{c} = VALUES({c})" for c in value_cols)
|
if self.db_type == "postgresql":
|
||||||
|
updates = ", ".join(
|
||||||
|
f"{c} = COALESCE(EXCLUDED.{c}, {table}.{c})"
|
||||||
|
if c in preserve_cols
|
||||||
|
else f"{c} = EXCLUDED.{c}"
|
||||||
|
for c in value_cols
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"INSERT INTO {table} ({col_list}) VALUES ({params}) "
|
||||||
|
f"ON CONFLICT ({conflict}) DO UPDATE SET {updates}"
|
||||||
|
)
|
||||||
|
updates = ", ".join(
|
||||||
|
f"{c} = COALESCE(VALUES({c}), {c})"
|
||||||
|
if c in preserve_cols
|
||||||
|
else f"{c} = VALUES({c})"
|
||||||
|
for c in value_cols
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"INSERT INTO {table} ({col_list}) VALUES ({params}) "
|
f"INSERT INTO {table} ({col_list}) VALUES ({params}) "
|
||||||
f"ON DUPLICATE KEY UPDATE {updates}"
|
f"ON DUPLICATE KEY UPDATE {updates}"
|
||||||
@@ -247,9 +377,20 @@ class RidReservoirStore:
|
|||||||
"outflow_mcm",
|
"outflow_mcm",
|
||||||
"level_msl",
|
"level_msl",
|
||||||
]
|
]
|
||||||
dam_sql = self._upsert("rid_dams", ["dam_id"], dam_cols + ["updated_at"])
|
dam_sql = self._upsert(
|
||||||
|
"rid_dams",
|
||||||
|
["dam_id"],
|
||||||
|
dam_cols + ["updated_at"],
|
||||||
|
preserve_cols=tuple(dam_cols), # never blank metadata we already have
|
||||||
|
)
|
||||||
measure_sql = self._upsert(
|
measure_sql = self._upsert(
|
||||||
"rid_reservoir_daily", ["dam_id", "date"], measure_cols
|
"rid_reservoir_daily",
|
||||||
|
["dam_id", "date"],
|
||||||
|
measure_cols,
|
||||||
|
# Only api/dam carries a level (api/dams' DMD_Q is ' - ' for every
|
||||||
|
# dam), so the hourly collector would blank the backfilled level
|
||||||
|
# of today and yesterday on every cycle.
|
||||||
|
preserve_cols=("level_msl",),
|
||||||
)
|
)
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now()
|
||||||
dams = {}
|
dams = {}
|
||||||
@@ -275,21 +416,31 @@ class RidReservoirStore:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def present_dates(
|
def present_dates(
|
||||||
self, start: datetime.date, end: datetime.date
|
self,
|
||||||
|
start: datetime.date,
|
||||||
|
end: datetime.date,
|
||||||
|
dam_id: Optional[str] = None,
|
||||||
) -> "set[datetime.date]":
|
) -> "set[datetime.date]":
|
||||||
"""Dates in [start, end] that already have rows, for backfill skipping."""
|
"""Dates in [start, end] that already have rows, for backfill skipping.
|
||||||
|
|
||||||
|
`dam_id` narrows the answer to one dam: the per-day fleet backfill can
|
||||||
|
treat any stored date as done, but a per-dam backfill must not skip a
|
||||||
|
date merely because some other dam published it.
|
||||||
|
"""
|
||||||
if not self.engine and not self.connect():
|
if not self.engine and not self.connect():
|
||||||
return set()
|
return set()
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
sql = (
|
||||||
|
"SELECT DISTINCT date FROM rid_reservoir_daily "
|
||||||
|
"WHERE date >= :start AND date <= :end"
|
||||||
|
)
|
||||||
|
params = {"start": start, "end": end}
|
||||||
|
if dam_id is not None:
|
||||||
|
sql += " AND dam_id = :dam_id"
|
||||||
|
params["dam_id"] = dam_id
|
||||||
with self.engine.begin() as conn:
|
with self.engine.begin() as conn:
|
||||||
values = conn.execute(
|
values = conn.execute(text(sql), params).fetchall()
|
||||||
text(
|
|
||||||
"SELECT DISTINCT date FROM rid_reservoir_daily "
|
|
||||||
"WHERE date >= :start AND date <= :end"
|
|
||||||
),
|
|
||||||
{"start": start, "end": end},
|
|
||||||
).fetchall()
|
|
||||||
dates = set()
|
dates = set()
|
||||||
for (value,) in values:
|
for (value,) in values:
|
||||||
if isinstance(value, str): # sqlite returns ISO strings
|
if isinstance(value, str): # sqlite returns ISO strings
|
||||||
@@ -374,6 +525,97 @@ def backfill(
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_dam(
|
||||||
|
store: RidReservoirStore,
|
||||||
|
dam_id: str = MAE_NGAT_DAM_ID,
|
||||||
|
start: datetime.date = RID_ARCHIVE_START,
|
||||||
|
end: Optional[datetime.date] = None,
|
||||||
|
client: Optional[RidReservoirClient] = None,
|
||||||
|
chunk_days: int = 1830,
|
||||||
|
throttle_seconds: float = 1.0,
|
||||||
|
skip_present: bool = True,
|
||||||
|
stats: Optional[Dict] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Backfill ONE dam over [start, end] using the range endpoint.
|
||||||
|
|
||||||
|
Costs one request per chunk instead of one per calendar day: Mae Ngat's
|
||||||
|
whole 2009-today archive is ~4 requests here versus ~6,400 with
|
||||||
|
`backfill`. No server-side range limit was observed (2009-01-01..today
|
||||||
|
answered in full), so `chunk_days` exists only to bound the response size
|
||||||
|
and the time a single request can hang, not to satisfy the API.
|
||||||
|
|
||||||
|
Chunks whose dates are already stored are skipped without a request, and
|
||||||
|
returned rows are filtered to the missing dates, so a rerun repairs holes
|
||||||
|
rather than rewriting the archive. `skip_present=False` re-fetches
|
||||||
|
everything, which is how rows first written by the api/dams collector
|
||||||
|
gain a level_msl and two-decimal storage_pct.
|
||||||
|
|
||||||
|
Returns rows saved. Some dates stay missing however often this runs —
|
||||||
|
the source simply never published them (72 days of Mae Ngat's archive,
|
||||||
|
absent from api/dams too) — so a 0-row rerun is normal and callers must
|
||||||
|
not read it as failure; pass `stats` to get the request/failure counts
|
||||||
|
that actually distinguish an outage.
|
||||||
|
"""
|
||||||
|
client = client or RidReservoirClient()
|
||||||
|
end = end or datetime.date.today()
|
||||||
|
counters = {"requests": 0, "failures": 0, "aborted": False}
|
||||||
|
if stats is not None:
|
||||||
|
stats.update(counters)
|
||||||
|
counters = stats
|
||||||
|
if not store.engine and not store.connect():
|
||||||
|
logger.error("backfill_dam aborted: database connection failed")
|
||||||
|
counters["aborted"] = True
|
||||||
|
return 0
|
||||||
|
span_days = (end - start).days + 1
|
||||||
|
if span_days <= 0:
|
||||||
|
return 0
|
||||||
|
present = store.present_dates(start, end, dam_id=dam_id) if skip_present else set()
|
||||||
|
logger.info(
|
||||||
|
f"backfill_dam {dam_id}: {span_days - len(present)} of {span_days} "
|
||||||
|
f"days missing in [{start}, {end}]"
|
||||||
|
)
|
||||||
|
total = 0
|
||||||
|
failures = 0
|
||||||
|
chunk_start = start
|
||||||
|
while chunk_start <= end:
|
||||||
|
chunk_end = min(chunk_start + datetime.timedelta(days=chunk_days - 1), end)
|
||||||
|
wanted = {
|
||||||
|
chunk_start + datetime.timedelta(days=i)
|
||||||
|
for i in range((chunk_end - chunk_start).days + 1)
|
||||||
|
} - present
|
||||||
|
if not wanted:
|
||||||
|
chunk_start = chunk_end + datetime.timedelta(days=1)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
counters["requests"] += 1
|
||||||
|
records = client.fetch_dam_range(dam_id, chunk_start, chunk_end)
|
||||||
|
records = [r for r in records if r["date"] in wanted]
|
||||||
|
saved = store.save(records)
|
||||||
|
if records and not saved:
|
||||||
|
raise RuntimeError("database save persisted 0 rows")
|
||||||
|
total += saved
|
||||||
|
failures = 0
|
||||||
|
logger.info(
|
||||||
|
f"backfill_dam {dam_id} [{chunk_start}, {chunk_end}]: "
|
||||||
|
f"{saved} rows ({total} total)"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
failures += 1
|
||||||
|
counters["failures"] += 1
|
||||||
|
logger.warning(
|
||||||
|
f"backfill_dam {dam_id} [{chunk_start}, {chunk_end}] failed "
|
||||||
|
f"({failures} in a row): {e}"
|
||||||
|
)
|
||||||
|
if failures >= 5:
|
||||||
|
logger.error("5 consecutive failures — aborting backfill_dam")
|
||||||
|
counters["aborted"] = True
|
||||||
|
break
|
||||||
|
chunk_start = chunk_end + datetime.timedelta(days=1)
|
||||||
|
if chunk_start <= end:
|
||||||
|
time.sleep(throttle_seconds)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
def create_collector_from_config() -> Optional[RidReservoirCollector]:
|
def create_collector_from_config() -> Optional[RidReservoirCollector]:
|
||||||
"""Build a collector from app Config; None when disabled or non-SQL DB."""
|
"""Build a collector from app Config; None when disabled or non-SQL DB."""
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
|||||||
+722
-174
File diff suppressed because it is too large
Load Diff
+101
-13
@@ -14,12 +14,18 @@ def test_dashboard_contains_live_map_and_flow_visualization():
|
|||||||
assert "leaflet" in html.lower()
|
assert "leaflet" in html.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_explains_flow_legend_and_refresh():
|
def _body(html: str) -> str:
|
||||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
"""Markup only. The STRINGS table repeats every English phrase, so a
|
||||||
|
whole-file search would pass even if an element were deleted."""
|
||||||
|
return html[html.index("<body>"):html.index("<script src=")]
|
||||||
|
|
||||||
assert "Flow status" in html
|
|
||||||
assert "Last updated" in html
|
def test_dashboard_explains_flow_legend_and_refresh():
|
||||||
assert "Refresh" in html
|
body = _body(DASHBOARD_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
assert 'data-i18n="legend.flow"' in body
|
||||||
|
assert 'data-i18n="stat.updated"' in body
|
||||||
|
assert 'data-i18n="action.refresh"' in body
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_uses_mapped_river_network_instead_of_station_connections():
|
def test_dashboard_uses_mapped_river_network_instead_of_station_connections():
|
||||||
@@ -35,7 +41,7 @@ def test_dashboard_loads_additional_thaiwater_sensors():
|
|||||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "fetch('/sensors/thaiwater')" in html
|
assert "fetch('/sensors/thaiwater')" in html
|
||||||
assert "Additional basin stations" in html
|
assert 'data-i18n="sensors.title"' in _body(html)
|
||||||
assert 'id="station-search"' in html
|
assert 'id="station-search"' in html
|
||||||
assert "applyStationSearch" in html
|
assert "applyStationSearch" in html
|
||||||
|
|
||||||
@@ -67,23 +73,105 @@ def test_dashboard_shows_hii_rainfall_layer():
|
|||||||
assert "fetch('/api/hii/waterlevel/latest')" in html
|
assert "fetch('/api/hii/waterlevel/latest')" in html
|
||||||
assert "renderRainLayer" in html
|
assert "renderRainLayer" in html
|
||||||
assert "rain-toggle" in html
|
assert "rain-toggle" in html
|
||||||
assert "Rainfall · 24 h" in html
|
body = _body(html)
|
||||||
|
assert 'data-i18n="legend.rain"' in body
|
||||||
# TMD rain classes on the legend
|
# TMD rain classes on the legend
|
||||||
assert "Heavy 35–90" in html
|
assert 'data-i18n="legend.rain.heavy"' in body
|
||||||
assert "Extreme > 150" in html
|
assert 'data-i18n="legend.rain.extreme"' in body
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_loads_station_history_chart():
|
def test_dashboard_loads_station_history_chart():
|
||||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "Station history" in html
|
assert 'id="history-card"' in html
|
||||||
assert "/api/forecast/history/" in html
|
assert "/api/forecast/history/" in html
|
||||||
assert "Model 24 h peak (as issued)" in html
|
assert "'chart.model'" in html
|
||||||
assert "PostgreSQL" not in html
|
assert "PostgreSQL" not in html
|
||||||
assert "/measurements/history/" in html
|
assert "/measurements/history/" in html
|
||||||
assert "history-chart" in html
|
assert "history-chart" in html
|
||||||
# Date-range picker alongside the quick-range dropdown
|
# Date-range picker alongside the quick-range dropdown
|
||||||
assert 'id="history-start"' in html
|
assert 'id="history-start"' in html
|
||||||
assert 'id="history-end"' in html
|
assert 'id="history-end"' in html
|
||||||
assert "Last 7 days" in html
|
assert 'data-i18n="range.7d"' in _body(html)
|
||||||
assert "Last 90 days" in html
|
assert 'data-i18n="range.90d"' in _body(html)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_lang_tables(html: str) -> dict:
|
||||||
|
"""Pull the `en:` / `th:` key sets out of the STRINGS literal.
|
||||||
|
|
||||||
|
Parsing the JS with a regex is crude, but it is enough to catch the failure
|
||||||
|
that matters: a key added to one language and forgotten in the other, which
|
||||||
|
silently falls back to English for Thai readers.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
start = html.index("const STRINGS = {")
|
||||||
|
end = html.index("// Thai unless the visitor's browser", start)
|
||||||
|
block = html[start:end]
|
||||||
|
tables = {}
|
||||||
|
for lang in ("en", "th"):
|
||||||
|
section = re.search(rf"\n {lang}: {{\n(.*?)\n }},\n", block, re.S)
|
||||||
|
assert section, f"{lang} table not found in STRINGS"
|
||||||
|
tables[lang] = set(re.findall(r"^\s{12}'([^']+)':", section.group(1), re.M))
|
||||||
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_translations_cover_both_languages():
|
||||||
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
tables = _extract_lang_tables(html)
|
||||||
|
|
||||||
|
assert len(tables["en"]) > 100, "expected the full English string table"
|
||||||
|
missing_th = tables["en"] - tables["th"]
|
||||||
|
missing_en = tables["th"] - tables["en"]
|
||||||
|
assert not missing_th, f"keys missing a Thai translation: {sorted(missing_th)}"
|
||||||
|
assert not missing_en, f"Thai-only keys with no English fallback: {sorted(missing_en)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_i18n_markup_keys_exist():
|
||||||
|
"""Every data-i18n attribute must resolve to a real string key."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
keys = _extract_lang_tables(html)["en"]
|
||||||
|
used = set(re.findall(r'data-i18n(?:-placeholder|-title|-aria)?="([^"]+)"', html))
|
||||||
|
unknown = used - keys
|
||||||
|
assert not unknown, f"markup references undefined string keys: {sorted(unknown)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_is_mobile_portrait_safe():
|
||||||
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# The header row overflowed a 412 px Android viewport by 64 px until it wrapped
|
||||||
|
assert "flex-wrap: wrap" in html
|
||||||
|
assert "overflow-x: hidden" in html
|
||||||
|
assert 'id="lang-toggle"' in html
|
||||||
|
assert "ping-monitor-lang" in html # remembered language choice
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_translates_user_visible_aria_labels():
|
||||||
|
"""A Thai page must not hand screen-reader users English landmarks."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
body = _body(html)
|
||||||
|
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', body):
|
||||||
|
tag = match.group(0)
|
||||||
|
if "data-i18n-aria" in tag or "id=\"lang-toggle\"" in tag:
|
||||||
|
continue # the toggle sets its own label per language in JS
|
||||||
|
raise AssertionError(f"aria-label without a translation key: {tag[:120]}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_keeps_simulation_and_replay_labels_on_language_switch():
|
||||||
|
"""Relabelling a pinned simulation as LIVE would present fake flood data as real."""
|
||||||
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "setLiveIndicator(state.liveMode || 'live', state.liveLabelKey)" in html
|
||||||
|
assert "if (!state.replayTimer) setLiveIndicator('live');" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_default_language_respects_browser_order():
|
||||||
|
"""navigator.languages = ['th-TH','en-US'] must resolve to Thai, not English."""
|
||||||
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "langs.some" not in html # the old any-English-wins test
|
||||||
|
assert "if (code.startsWith('th')) return 'th';" in html
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from src.rid_reservoir import (
|
|||||||
RidReservoirCollector,
|
RidReservoirCollector,
|
||||||
RidReservoirStore,
|
RidReservoirStore,
|
||||||
backfill,
|
backfill,
|
||||||
|
backfill_dam,
|
||||||
|
parse_dam_range_records,
|
||||||
parse_dam_records,
|
parse_dam_records,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,6 +58,47 @@ def _dams_payload(date="2026-08-13"):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _range_row(date, **overrides):
|
||||||
|
"""One api/dam row, shaped like the real 2024-09-24 Mae Ngat response."""
|
||||||
|
row = {
|
||||||
|
"DAM_ID": "200103",
|
||||||
|
"DATE_curr": date,
|
||||||
|
"DAM_Name": "เขื่อนแม่งัดสมบูรณ์ชล",
|
||||||
|
"DAM_Region": "เหนือ",
|
||||||
|
"DAM_QMax": "323.00",
|
||||||
|
"DAM_QStore": "265.00",
|
||||||
|
"DAM_QUsage": "253.00",
|
||||||
|
"DUL_Useless": "12.00",
|
||||||
|
"DMD_ULevel_curr": "395.91",
|
||||||
|
"DMD_Q_curr": "0.00",
|
||||||
|
"DMD_Inflow_curr": "19.06",
|
||||||
|
"DMD_Outflow_curr": "0.13",
|
||||||
|
"VAL_DMD_Q_curr": "242.87",
|
||||||
|
"DMD_QUse_curr": "254.87",
|
||||||
|
"PERCENT_DMD_QUse_curr": "96.18",
|
||||||
|
# Same calendar date one year earlier -> must be ignored, not stored
|
||||||
|
"DMD_ULevel_prev": "391.52",
|
||||||
|
"DMD_QUse_prev": "195.64",
|
||||||
|
"PERCENT_DMD_QUse_prev": "73.83",
|
||||||
|
"DMD_Inflow_prev": "1.64",
|
||||||
|
"DMD_Outflow_prev": "3.79",
|
||||||
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _range_payload(dates=("2024-09-24",), rows=None):
|
||||||
|
return {
|
||||||
|
"dam_id": "200103",
|
||||||
|
"dam_name": "เขื่อนแม่งัดสมบูรณ์ชล",
|
||||||
|
"dam_region": "เหนือ",
|
||||||
|
"dam_coordinates": {"lat": 19.16138, "lng": 99.04011},
|
||||||
|
"dam_data": rows if rows is not None else [_range_row(d) for d in dates],
|
||||||
|
"max": {"max_DMD_QUse_curr": "254.87"},
|
||||||
|
"min": {"min_DMD_QUse_curr": "254.87"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def __init__(self, payload=None, fail_dates=()):
|
def __init__(self, payload=None, fail_dates=()):
|
||||||
self.payload = payload or _dams_payload()
|
self.payload = payload or _dams_payload()
|
||||||
@@ -71,6 +114,28 @@ class FakeClient:
|
|||||||
return parse_dam_records(self.payload)
|
return parse_dam_records(self.payload)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRangeClient:
|
||||||
|
"""Serves every day in the requested window, minus `gaps` (the real
|
||||||
|
endpoint omits scattered days rather than returning empty rows)."""
|
||||||
|
|
||||||
|
def __init__(self, gaps=(), fail_chunks=()):
|
||||||
|
self.gaps = {datetime.date.fromisoformat(d) for d in gaps}
|
||||||
|
self.fail_chunks = set(fail_chunks) # (start, end) tuples that raise
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def fetch_dam_range(self, dam_id, start, end):
|
||||||
|
self.calls.append((dam_id, start, end))
|
||||||
|
if (start, end) in self.fail_chunks:
|
||||||
|
raise ConnectionError("boom")
|
||||||
|
days = [
|
||||||
|
start + datetime.timedelta(days=i) for i in range((end - start).days + 1)
|
||||||
|
]
|
||||||
|
return parse_dam_range_records(
|
||||||
|
_range_payload(rows=[_range_row(d.isoformat()) for d in days
|
||||||
|
if d not in self.gaps])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestParsing:
|
class TestParsing:
|
||||||
def test_parse_dam_records(self):
|
def test_parse_dam_records(self):
|
||||||
records = parse_dam_records(_dams_payload())
|
records = parse_dam_records(_dams_payload())
|
||||||
@@ -88,6 +153,46 @@ class TestParsing:
|
|||||||
def test_parse_empty_payload(self):
|
def test_parse_empty_payload(self):
|
||||||
assert parse_dam_records({}) == []
|
assert parse_dam_records({}) == []
|
||||||
|
|
||||||
|
def test_parse_dam_range_records(self):
|
||||||
|
records = parse_dam_range_records(_range_payload(("2024-09-24",)))
|
||||||
|
assert len(records) == 1 # the _prev columns are last year, not a row
|
||||||
|
row = records[0]
|
||||||
|
assert row["dam_id"] == MAE_NGAT_DAM_ID
|
||||||
|
assert row["date"] == datetime.date(2024, 9, 24)
|
||||||
|
assert row["region"] == "เหนือ"
|
||||||
|
assert row["latitude"] == 19.16138
|
||||||
|
assert row["longitude"] == 99.04011
|
||||||
|
assert row["capacity_max_mcm"] == 323.0
|
||||||
|
assert row["capacity_normal_mcm"] == 265.0
|
||||||
|
# Agrees with api/dams for this date, at higher percent precision
|
||||||
|
assert row["storage_mcm"] == 254.87
|
||||||
|
assert row["storage_pct"] == 96.18
|
||||||
|
assert row["inflow_mcm"] == 19.06
|
||||||
|
assert row["outflow_mcm"] == 0.13
|
||||||
|
assert row["level_msl"] == 395.91 # DMD_ULevel, absent from api/dams
|
||||||
|
|
||||||
|
def test_parse_dam_range_records_produces_same_keys_as_dams(self):
|
||||||
|
assert set(parse_dam_range_records(_range_payload())[0]) == set(
|
||||||
|
parse_dam_records(_dams_payload())[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_dam_range_zero_level_is_missing_not_a_reading(self):
|
||||||
|
payload = _range_payload(rows=[_range_row("2024-09-24", DMD_ULevel_curr="0.00")])
|
||||||
|
assert parse_dam_range_records(payload)[0]["level_msl"] is None
|
||||||
|
|
||||||
|
def test_parse_dam_range_skips_broken_rows(self):
|
||||||
|
payload = _range_payload(
|
||||||
|
rows=[
|
||||||
|
_range_row("n/a"), # unparseable date
|
||||||
|
_range_row("2024-09-24"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(parse_dam_range_records(payload)) == 1
|
||||||
|
|
||||||
|
def test_parse_dam_range_empty_payload(self):
|
||||||
|
assert parse_dam_range_records({}) == []
|
||||||
|
assert parse_dam_range_records(_range_payload(rows=[])) == []
|
||||||
|
|
||||||
|
|
||||||
class TestStore:
|
class TestStore:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -160,6 +265,55 @@ class TestStore:
|
|||||||
# Outside the window -> excluded
|
# Outside the window -> excluded
|
||||||
assert store.present_dates(lo, datetime.date(2026, 8, 12)) == set()
|
assert store.present_dates(lo, datetime.date(2026, 8, 12)) == set()
|
||||||
|
|
||||||
|
def test_daily_collector_does_not_blank_a_backfilled_level(self, store):
|
||||||
|
date = "2026-08-13"
|
||||||
|
assert store.save(parse_dam_range_records(_range_payload((date,)))) == 1
|
||||||
|
# api/dams has no level column at all; its rewrite must not clear one
|
||||||
|
dams_row = [r for r in parse_dam_records(_dams_payload(date))
|
||||||
|
if r["dam_id"] == MAE_NGAT_DAM_ID]
|
||||||
|
assert dams_row[0]["level_msl"] is None
|
||||||
|
store.save(dams_row)
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
with store.engine.begin() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT level_msl, storage_mcm FROM rid_reservoir_daily "
|
||||||
|
"WHERE dam_id = :d"
|
||||||
|
),
|
||||||
|
{"d": MAE_NGAT_DAM_ID},
|
||||||
|
).fetchone()
|
||||||
|
assert float(row[0]) == 395.91 # kept
|
||||||
|
assert float(row[1]) == 222.01 # published columns still overwritten
|
||||||
|
|
||||||
|
def test_present_dates_per_dam(self, store):
|
||||||
|
lo, hi = datetime.date(2026, 8, 1), datetime.date(2026, 8, 31)
|
||||||
|
store.save(parse_dam_records(_dams_payload()))
|
||||||
|
day = datetime.date(2026, 8, 13)
|
||||||
|
assert store.present_dates(lo, hi, dam_id=MAE_NGAT_DAM_ID) == {day}
|
||||||
|
# Another dam having the date must not mark this one done
|
||||||
|
assert store.present_dates(lo, hi, dam_id="999999") == set()
|
||||||
|
|
||||||
|
def test_null_metadata_does_not_wipe_known_dam_details(self, store):
|
||||||
|
store.save(parse_dam_records(_dams_payload()))
|
||||||
|
blank = parse_dam_records(_dams_payload("2026-08-14"))
|
||||||
|
for record in blank: # a payload that omits metadata
|
||||||
|
record.update({"name_th": None, "latitude": None, "region": None})
|
||||||
|
assert store.save(blank) == 2
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
with store.engine.begin() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT name_th, latitude, region FROM rid_dams "
|
||||||
|
"WHERE dam_id = :d"
|
||||||
|
),
|
||||||
|
{"d": MAE_NGAT_DAM_ID},
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == "เขื่อนแม่งัดสมบูรณ์ชล"
|
||||||
|
assert float(row[1]) == 19.16138
|
||||||
|
assert row[2] == "เหนือ"
|
||||||
|
|
||||||
|
|
||||||
class TestCollectorAndBackfill:
|
class TestCollectorAndBackfill:
|
||||||
def test_run_cycle_today_and_yesterday(self, tmp_path):
|
def test_run_cycle_today_and_yesterday(self, tmp_path):
|
||||||
@@ -245,3 +399,226 @@ class TestCollectorAndBackfill:
|
|||||||
)
|
)
|
||||||
assert saved == 4 # first 2 days succeeded, then 5 failures -> abort
|
assert saved == 4 # first 2 days succeeded, then 5 failures -> abort
|
||||||
assert len(client.calls) == 7
|
assert len(client.calls) == 7
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackfillDam:
|
||||||
|
@pytest.fixture
|
||||||
|
def store(self, tmp_path):
|
||||||
|
store = RidReservoirStore(f"sqlite:///{tmp_path}/range.db", "sqlite")
|
||||||
|
assert store.connect()
|
||||||
|
return store
|
||||||
|
|
||||||
|
def test_whole_range_in_one_request(self, store):
|
||||||
|
client = FakeRangeClient()
|
||||||
|
saved = backfill_dam(
|
||||||
|
store,
|
||||||
|
start=datetime.date(2024, 9, 24),
|
||||||
|
end=datetime.date(2024, 10, 6),
|
||||||
|
client=client,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
assert saved == 13
|
||||||
|
assert client.calls == [
|
||||||
|
(MAE_NGAT_DAM_ID, datetime.date(2024, 9, 24), datetime.date(2024, 10, 6))
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_chunks_long_ranges(self, store):
|
||||||
|
client = FakeRangeClient()
|
||||||
|
saved = backfill_dam(
|
||||||
|
store,
|
||||||
|
start=datetime.date(2024, 1, 1),
|
||||||
|
end=datetime.date(2024, 1, 10),
|
||||||
|
client=client,
|
||||||
|
chunk_days=4,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
assert saved == 10
|
||||||
|
assert client.calls == [
|
||||||
|
(MAE_NGAT_DAM_ID, datetime.date(2024, 1, 1), datetime.date(2024, 1, 4)),
|
||||||
|
(MAE_NGAT_DAM_ID, datetime.date(2024, 1, 5), datetime.date(2024, 1, 8)),
|
||||||
|
(MAE_NGAT_DAM_ID, datetime.date(2024, 1, 9), datetime.date(2024, 1, 10)),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_source_gaps_are_tolerated(self, store):
|
||||||
|
client = FakeRangeClient(gaps=("2024-01-03",))
|
||||||
|
saved = backfill_dam(
|
||||||
|
store,
|
||||||
|
start=datetime.date(2024, 1, 1),
|
||||||
|
end=datetime.date(2024, 1, 5),
|
||||||
|
client=client,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
assert saved == 4 # the day the source never published stays absent
|
||||||
|
assert store.present_dates(
|
||||||
|
datetime.date(2024, 1, 1), datetime.date(2024, 1, 5)
|
||||||
|
) == {
|
||||||
|
datetime.date(2024, 1, d) for d in (1, 2, 4, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_stored_days_are_skipped_and_whole_chunks_cost_no_request(self, store):
|
||||||
|
client = FakeRangeClient()
|
||||||
|
backfill_dam(
|
||||||
|
store,
|
||||||
|
start=datetime.date(2024, 1, 1),
|
||||||
|
end=datetime.date(2024, 1, 4),
|
||||||
|
client=client,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
# Rerun over a wider window: the stored chunk is not re-requested and
|
||||||
|
# only the missing days are written
|
||||||
|
saved = backfill_dam(
|
||||||
|
store,
|
||||||
|
start=datetime.date(2024, 1, 1),
|
||||||
|
end=datetime.date(2024, 1, 8),
|
||||||
|
client=client,
|
||||||
|
chunk_days=4,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
assert saved == 4
|
||||||
|
assert client.calls[1:] == [
|
||||||
|
(MAE_NGAT_DAM_ID, datetime.date(2024, 1, 5), datetime.date(2024, 1, 8))
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_partly_stored_chunk_saves_only_missing_days(self, store):
|
||||||
|
client = FakeRangeClient()
|
||||||
|
backfill_dam(
|
||||||
|
store,
|
||||||
|
start=datetime.date(2024, 1, 3),
|
||||||
|
end=datetime.date(2024, 1, 3),
|
||||||
|
client=client,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
saved = backfill_dam(
|
||||||
|
store,
|
||||||
|
start=datetime.date(2024, 1, 1),
|
||||||
|
end=datetime.date(2024, 1, 5),
|
||||||
|
client=client,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
assert saved == 4 # day 3 already present, requested but not rewritten
|
||||||
|
|
||||||
|
def test_refresh_rewrites_stored_days(self, store):
|
||||||
|
client = FakeRangeClient()
|
||||||
|
window = dict(
|
||||||
|
start=datetime.date(2024, 1, 1),
|
||||||
|
end=datetime.date(2024, 1, 3),
|
||||||
|
client=client,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
assert backfill_dam(store, **window) == 3
|
||||||
|
assert backfill_dam(store, skip_present=False, **window) == 3
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
|
||||||
|
def test_junk_values_are_nulled_not_fatal(self, store):
|
||||||
|
client = FakeRangeClient()
|
||||||
|
client.fetch_dam_range = lambda dam_id, start, end: parse_dam_range_records(
|
||||||
|
_range_payload(
|
||||||
|
rows=[
|
||||||
|
_range_row(
|
||||||
|
"2019-01-05",
|
||||||
|
DMD_QUse_curr="343292.00",
|
||||||
|
PERCENT_DMD_QUse_curr="87798.47",
|
||||||
|
DMD_Outflow_curr="1e12", # beyond NUMERIC(10,2) -> NULL
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
backfill_dam(
|
||||||
|
store,
|
||||||
|
start=datetime.date(2019, 1, 5),
|
||||||
|
end=datetime.date(2019, 1, 5),
|
||||||
|
client=client,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
with store.engine.begin() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT storage_pct, outflow_mcm FROM rid_reservoir_daily "
|
||||||
|
"WHERE date = '2019-01-05'"
|
||||||
|
)
|
||||||
|
).fetchone()
|
||||||
|
assert float(row[0]) == 87798.47
|
||||||
|
assert row[1] is None
|
||||||
|
|
||||||
|
def test_stats_separate_an_empty_rerun_from_an_outage(self, store):
|
||||||
|
window = dict(
|
||||||
|
start=datetime.date(2024, 1, 1),
|
||||||
|
end=datetime.date(2024, 1, 3),
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
backfill_dam(store, client=FakeRangeClient(), **window)
|
||||||
|
# Everything already stored: no request, no failure, still a success
|
||||||
|
stats = {}
|
||||||
|
assert backfill_dam(store, client=FakeRangeClient(), stats=stats, **window) == 0
|
||||||
|
assert stats == {"requests": 0, "failures": 0, "aborted": False}
|
||||||
|
# A source gap keeps requesting, but still reports no failure
|
||||||
|
gapped = FakeRangeClient(gaps=("2024-01-05",))
|
||||||
|
stats = {}
|
||||||
|
assert (
|
||||||
|
backfill_dam(
|
||||||
|
store,
|
||||||
|
client=gapped,
|
||||||
|
stats=stats,
|
||||||
|
start=datetime.date(2024, 1, 1),
|
||||||
|
end=datetime.date(2024, 1, 5),
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
assert stats["requests"] == 1 and not stats["failures"]
|
||||||
|
|
||||||
|
def test_stats_record_failures(self, store):
|
||||||
|
chunk = (datetime.date(2024, 1, 1), datetime.date(2024, 1, 3))
|
||||||
|
client = FakeRangeClient(fail_chunks=[chunk])
|
||||||
|
stats = {}
|
||||||
|
assert (
|
||||||
|
backfill_dam(
|
||||||
|
store,
|
||||||
|
client=client,
|
||||||
|
stats=stats,
|
||||||
|
start=chunk[0],
|
||||||
|
end=chunk[1],
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
assert stats["failures"] == 1 and not stats["aborted"]
|
||||||
|
|
||||||
|
def test_aborts_after_consecutive_failures(self, store):
|
||||||
|
start = datetime.date(2024, 1, 1)
|
||||||
|
chunks = [
|
||||||
|
(start + datetime.timedelta(days=i), start + datetime.timedelta(days=i))
|
||||||
|
for i in range(30)
|
||||||
|
]
|
||||||
|
client = FakeRangeClient(fail_chunks=chunks[1:])
|
||||||
|
stats = {}
|
||||||
|
saved = backfill_dam(
|
||||||
|
store,
|
||||||
|
start=start,
|
||||||
|
end=start + datetime.timedelta(days=29),
|
||||||
|
client=client,
|
||||||
|
chunk_days=1,
|
||||||
|
throttle_seconds=0,
|
||||||
|
stats=stats,
|
||||||
|
)
|
||||||
|
assert saved == 1 # first chunk succeeded, then 5 failures -> abort
|
||||||
|
assert len(client.calls) == 6
|
||||||
|
assert stats["aborted"] and stats["failures"] == 5
|
||||||
|
|
||||||
|
def test_aborts_when_db_saves_nothing(self, store, monkeypatch):
|
||||||
|
monkeypatch.setattr(store, "save", lambda records: 0) # broken DB
|
||||||
|
client = FakeRangeClient()
|
||||||
|
backfill_dam(
|
||||||
|
store,
|
||||||
|
start=datetime.date(2024, 1, 1),
|
||||||
|
end=datetime.date(2024, 3, 1),
|
||||||
|
client=client,
|
||||||
|
chunk_days=1,
|
||||||
|
throttle_seconds=0,
|
||||||
|
)
|
||||||
|
assert len(client.calls) == 5 # aborted, not one request per chunk
|
||||||
|
|||||||
Reference in New Issue
Block a user