Compare commits

...
4 Commits
Author SHA1 Message Date
grabowski 382daa7d86 perf: backfill one dam per request via the api/dam range endpoint
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 1m6s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
Documentation / Generate API Documentation (push) Successful in 12s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 4s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 34s
Documentation / Validate Documentation (push) Failing after 10s
Documentation / Build Sphinx Documentation (push) Successful in 22s
GET app.rid.go.th/reservoir/api/dam?dam_id&date_start&date_end returns a
single dam's whole date range in one response — Mae Ngat's 2009-today
archive is ~4 chunked requests instead of the ~2,900 one-day POSTs the
all-dams path needs. Field names differ from api/dams and are mapped in
parse_dam_range_records, verified equal on spot-checked dates; the range
endpoint also carries DMD_ULevel, the reservoir level in m MSL that
api/dams stopped publishing after ~2013.

scripts/backfill_rid_reservoir.py defaults to the fast per-dam path
(--all-dams keeps the full-fleet crawl, --refresh rewrites stored days).
Already-stored dates are still skipped, junk values are still bounded,
and the consecutive-failure abort still applies.
2026-08-13 23:10:36 +07:00
grabowski b02e815d72 feat: Thai localisation and portrait-phone layout for the dashboard
Thai is the default unless the browser prefers English, chosen by first
match in navigator.languages order and remembered in localStorage. A
STRINGS table carries both languages (interpolated strings as functions),
applyTranslations() drives static markup via data-i18n attributes, and
setLang() rebuilds everything the JS renders — including map layers, so
popups render in the current language and sensor markers are replaced
rather than stacked. Thai dates use the Buddhist era, matching the
replay label; station names lead with the reader's language.

Portrait phones: the header overflowed a 412 px Android viewport by
64 px, so the page scrolled sideways and the Refresh button sat off
screen. The header now wraps into two deliberate rows (DOM order matches
visual order, so focus order is unaffected), the map description box is
hidden on phones, map height is capped by viewport — including a
height-gated rule for landscape phones, whose 850-960 px widths never
matched the width breakpoints — and the forecast grid goes single
column. Verified in a real browser at 412x915, 360x800, 915x412 and
1440x900: zero horizontal overflow, no desktop change.

Review-swarm fixes: a language switch no longer relabels a pinned
SIMULATION or the 2024 replay as LIVE DATA (it kept the mode from
state); Thai wording corrected where it asserted a rising trend the code
never checks, labelled every gauge 'critical', or used a malformed
compound; aria-labels, the Leaflet load failure and the flood-stage
chips are translated; the language toggle states its action instead of
an aria-pressed value that contradicted its label; and Thai font
families sit after the Latin stack so they cannot restyle English text.
2026-08-13 23:10:34 +07:00
grabowski 5dc5850df6 docs: source sweep — P.75 already is the Mae Ngat release signal
Verified from a Thai ISP that lsim.rid.go.th is unreachable (not
geo-blocked), then swept for any better-than-daily Mae Ngat source.

Key finding: P.75 sits 3.8 km below the dam, reports hourly, and has
been a model feature since v1 — the model has always read the dam's
actual outflow, hourly and directly. That is the likelier reason the
daily reservoir table adds nothing, beyond its publication lag.

Intraday reservoir feeds do exist and are open (bigdata-api.rid.go.th
SWOC, ThaiWater ridhydro_TUP.16 at the dam) but are snapshot-only with
no archive, so they cannot retrain against past events; the HII
collector accumulates them from 2026-08-11 for a post-monsoon revisit.
Also documents api/dam (whole per-dam range in one request, vs the
~2,900-request per-day loop) and several cross-check mirrors.
2026-08-13 21:58:47 +07:00
grabowski 70e4da07a0 docs: lsim.rid.go.th is unreachable, not geo-blocked
Probed from a Thai consumer ISP (AIS Fibre): DNS resolves but ICMP and
ports 80/443/8080 are filtered, while app.rid.go.th answers in 0.27 s
over the same connection. Correct the earlier note that assumed the
timeout was a foreign-network block, and stop pointing the dam-feature
follow-up at a host that cannot be reached.
2026-08-13 21:00:24 +07:00
7 changed files with 1558 additions and 219 deletions
+8 -1
View File
@@ -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) |
+22 -2
View File
@@ -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
+68 -11
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+101 -13
View File
@@ -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 3590" in html assert 'data-i18n="legend.rain.heavy"' in body
assert "Extreme &gt; 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
+377
View File
@@ -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