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.
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:
today − forecast_past_days (5) onward,
i.e. the tail of the re-simulated window plus future days): served by the
Open-Meteo forecast API; for future days no DWD data exists yet, for the
recent tail DWD data may still be arriving.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.
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].
The batch runner anchors continuation on the last observed state, not the last persisted row:
state_date ≤ today − 1
(guarantees the window covering today is always re-simulated from the
observed chain and current forecast).state_date ≤ today — only relevant
in the single-row handover state a --latest-only warm-up leaves behind
(one row dated exactly today). Without this the operational run would
reinitialise trees at field capacity until the next full-day row existed.today + 7 + 1
(the furthest persisted forecast row) and skip the whole tree, since
profile_start > end_date.Stores (ParquetWaterBalanceStore, WaterBalanceStore,
MirroredWaterBalanceStore) accept an up_to bound in
load_latest_states(model_versions, up_to=…).
Current-state consumers cap at today so the forecast rows never leak into the “current” snapshot:
build_operational_tree_products — latest state ≤ today per tree
(the observed row already carries the 7-day forward stress).prepare_planning_candidates (irrigation planning) — latest state
≤ planning_date.bgcki.wfs_trees_current WFS view — the LATERAL join now adds
AND d.state_date <= CURRENT_DATE. The production view had drifted from
r.* to an explicit multi-column projection, so the fix is a surgical patch
of the live definition (rename → create → drop, atomic) and
sql/migrations/2026-09-16_fix_wfs_trees_view_forecast_rows.sql now
carries the exact patched view body rather than a fresh r.* definition.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.
climate.water_balance.execution.dwd_cache_dir).PriorityWeatherProvider +
HistoricalForecastWeatherProvider (split at
today − forecast_past_days).irrigation_event table).Rows in swbm_daily_state (one per tree, day, model version) include:
soil_water_m3_total, soil_water_m3_layer_1…_3
(m³, raw available soil water), surface_wet_fraction.soil_water_fraction_* (available-water fraction),
soil_water_stress_today (layer-1 deficit fraction),
soil_water_stress_forecast_max_7d (true forward 7-day max after this
change), irrigation_demand_liters, irrigation_trigger_date,
irrigation_avoidable_by_rain (trigger day + forecast rain inside the
7-day window recharges the soil above the trigger), data_quality,
meteorology_source.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.
-- 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>';
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.
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:
Ze zone), not at the whole layer.Known, documented deviations from the standalone (intentional or data-driven, not engine bugs):
et0_source); the standalone v0831 historical runs
used DWD + local SVF/sunlit TIF versions. Residual ET0 difference with
identical DWD inputs: 0.04–0.2 mm/day.fw is not exported;
historical comparisons seed it with the default 0.3.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.
swbm-v0.3):
capillary_rise.md