bluegreencity-ki

Soil Water Balance (SWBM) — 7-Tage-Prognose

This directory documents the standalone Soil Water Balance module (bluegreencity_ki/water_balance/). The operational model now simulates a real 7-day forecast into the future, mirroring the standalone h_da reference (step3_realtime.py). The change closes the gap where the forecast column was computed as a rolling window over already-simulated days instead of a true prediction of the coming week.

Overview

The operational daily run (scripts/run_scheduled_water_balance.ps1) simulates from a short look-back window through today + forecast_days (default 7). Two kinds of rows are produced:

Both are stored as ordinary rows in bgcki.swbm_daily_state (Parquet mirror: data/derived/water_balance/daily_state.parquet), keyed by (tree_id, state_date, model_version). Forecast rows are re-simulated every day with the current forecast and overwritten via ON CONFLICT … DO UPDATE, so no historical drift accumulates and no separate table is needed.

The --latest-only warm-up (scripts/run_swbm_warmup.ps1) persists only a single continuation state per tree; for windows reaching today that anchor is the latest observed day (on-or-before today), never a future-dated forecast row.

Implementation details

Forecast semantics (soil_water_stress_forecast_max_7d)

forward_forecast_max_stress(stress_series, index, forecast_days) returns the maximum layer-1 stress over the strictly forward window (index + 1 … index + forecast_days) — the day’s own stress is excluded. This is a property of the simulated sequence, not of the calendar: for the observed today row the window covers the 7 simulated future days, which is the real forecast. For final rows of a shorter window the window is shortened to the remaining days; if no future day exists, the day’s own stress is returned. Values remain within [0, 1].

Continuation / resume anchoring

The batch runner anchors continuation on the last observed state, not the last persisted row:

Stores (ParquetWaterBalanceStore, WaterBalanceStore, MirroredWaterBalanceStore) accept an up_to bound in load_latest_states(model_versions, up_to=…).

Consumers read the observed row, not the future row

Current-state consumers cap at today so the forecast rows never leak into the “current” snapshot:

Sunlit hours: day-of-year keying

Sunlit hours are interpolated from four seasonal anchors into a day-of-year (1..365) mapping (build_daily_sunlit_hours returns dict[int, float]), mirroring the h_da sunlit_dict.get(DOY, 8.0) lookup with a 8 h fallback. Keying by DOY instead of calendar date makes future days and December windows crossing into January resolve without additional preparation.

Data sources

Outputs

Rows in swbm_daily_state (one per tree, day, model version) include:

surface_wet_fraction is a throughfall-rain-evaporation state variable: throughfall ≥ 10 mm sets it to 1.0; ≥ 4 mm raises it to at least 0.6; otherwise it carries forward. It gates the potential soil evaporation.

Verification queries

-- Row counts observed vs. forecast rows for the current model version
SELECT state_date, COUNT(*)
FROM bgcki.swbm_daily_state
WHERE model_version LIKE '%:irrigation-lower'
GROUP BY 1 ORDER BY 1 DESC LIMIT 12;

-- Latest forecast rows carry today's forward stress
SELECT s.soil_water_stress_today, s.soil_water_stress_forecast_max_7d
FROM bgcki.swbm_daily_state s
WHERE s.model_version LIKE '%:irrigation-lower'
  AND s.state_date = CURRENT_DATE
  AND s.tree_id = '<objectid>';

Performance (issue #243)

The per-tree/day loop previously called pyet.pm_fao56 once per day with single-day pandas Series (~95 % of backfill runtime: 38 M pyet calls for one full city-year, measured 25–37 h/year). shaded_reference_et0_series_mm now precomputes the shaded ET0 series for the whole window with one vectorized pyet call per tree; it is element-identical to the per-day computation (unit-tested against shaded_reference_et0_mm) and resolves the standalone sunlit_dict day-of-year lookup with the 8.0 h fallback. The engine time for a full city-year dropped to ~40 min single-threaded.

Model version and known deviations from the standalone

Operational model version: swbm-v0.4 (bumped 2026-09-22, issue #239). The port tracks the standalone v0831 reference (model_v0831, 2026-08-31).

swbm-v0.4 is the first version written by the v0831-faithful engine. swbm-v0.3 had restored two exact v0831 behaviours in place on 2026-09-22 (issue #238, no version bump — the deviation is below the documented budget); the bump to swbm-v0.4 separates the pre-fix rows (written before 2026-09-22, including the first 2025 backfill) from the post-fix rows so they no longer share a version label. The re-backfill 2021–2026 under swbm-v0.4 (issues #239/#243) supersedes the pre-fix rows:

  1. Soil evaporation is capped at the water in the top 0.15 m of layer 1 (standalone Ze zone), not at the whole layer.
  2. Non-green sites evaporate from a fixed 2.0 m² surface, even for root zones smaller than 2 m².

Known, documented deviations from the standalone (intentional or data-driven, not engine bugs):

Systematic sample validation (10 trees, full year 2025, issue #238): four trees match the standalone bit-exact with capillary rise inactive; five deviate by ≤0.06 m³; the largest residual (tree 33512, ~170 m³ root zone) is 2.05–4.07 m³ and is attributed to the groundwater input and the unknown fw start state. Details: Soil_Water_Balance_Integration.md.