bluegreencity-ki

SWBM Capillary Rise — krigierte Grundwasser-Flurabstand-Oberfläche (#218)

Overview

Capillary rise is the upward movement of water from the groundwater table into the deeper root-zone layers when the unsaturated zone above the water table is thin enough for capillary forces to dominate. In drought-risk modelling it is an important stress buffer: trees standing above a shallow water table keep access to moisture during dry spells, so the same soil-water balance produces less drought stress than for trees above deep groundwater.

The h_da standalone soil-water model (model_v0831/step3_realtime.py) applies capillary rise. The operational port previously disabled it (capillary_rise.enabled: false, model swbm-v0.2). Since model version swbm-v0.3 the process is enabled, and the per-tree depth to groundwater (Flurabstand) is taken from a kriging-interpolated surface over the LHH monitoring wells — not from the nearest single well as in the standalone script.

The physics itself (state-dependent daily flux) was already ported and validated bit-exact against the standalone model before this work: the engine’s CapillaryRiseDemand + _capillary_rise_potential reproduces the standalone step3_realtime.py values exactly (capillary-active tree case 0.13 m³ added, capillary-inactive control case 0.35 m³, day-by-day flux match). What was missing was only the operational wiring: a per-tree groundwater depth. This document records that wiring and the design decisions behind it.

Implementation details

Per-tree depth to groundwater (the new wiring)

The depth to groundwater at a tree (Flurabstand, metres below surface) is computed in bluegreencity_ki/water_balance/groundwater_surface.py:

  1. Well data — the same LHH Grundwasserstand_Flurabstand_*.csv set and column mapping the groundwater risk criterion uses (inputs.groundwater in config/config.yaml). The criterion’s private helpers were promoted to public names so both the criterion and the water balance share a single source of truth:
    • load_flurabstand_csvs(...) — reads all yearly CSVs.
    • aggregate_wells(...) — one row per well with flurabstand_median, flurabstand_latest|max|std etc.
    • kriging_interpolate(...) / idw_interpolate(...) — the interpolation functions.
  2. Well statistic — the multi-year median Flurabstand per well (flurabstand_median), consistent with the criterion’s default scoring_source: median.
  3. Interpolation — Ordinary Kriging (PyKrige, exponential variogram) by default, or IDW when interpolation_method: "idw". If the kriging fit fails (singular variogram) it falls back to IDW, mirroring the criterion.
  4. Coverage gate — a tree needs at least min_neighbors (3) wells within max_distance_m (5000) to receive a value. Outside that gate the value is NaN and the tree simulates without capillary rise. There is no extrapolation beyond the monitoring network.
  5. Plausibility filter — the LHH CSVs contain isolated data-entry typos (one observed record at 54 038 m; a well’s raw multi-year median can reach ~54 km while the city-wide median Flurabstand is 2.5 m). Because kriging fits one global variogram, a single such outlier would poison the entire surface (measured: kriged median 45 m for every tree instead of ~2.9 m). Wells whose median Flurabstand exceeds max_plausible_depth_m (20 m, configurable) are therefore excluded from the fit. Kriged values below 0 m are floored to 0 (the engine rejects negative depths and a 0 m depth produces no rise flux anyway).
  6. Coordinates — all in a planar metre CRS (project: EPSG:25832). The tree feature file and the well coordinates are both in EPSG:25832, so the surface is evaluated directly at the tree points.
  7. Computed once per run — the surface depends only on wells and parameters, so it is evaluated a single time for all tree points and passed down the pipeline: scripts/run_daily_water_balance.py → prepare_hannover_simulation_trees(groundwater_depth_by_tree=...) → TreeForcingContext.groundwater_depth_m → derive_daily_water_fluxes(...).

The capillary_rise config block now carries the surface parameters (source: "kriged_groundwater", interpolation_method, variogram_model, idw_power, max_distance_m, min_neighbors) in addition to the physics parameters (fractions and the 120 cm lookup). An unknown source raises RuntimeError — no silent fallback, per the AGENTS.md hard rule.

The capillary flux (already present, unchanged)

For each simulated day the engine builds a CapillaryRiseDemand from the tree’s groundwater depth and the configured lookup, then _capillary_rise_potential turns it into a volume added to the deepest root-zone layer:

Capillary area — h_da A_soil (age-dependent root expansion)

The rate (mm/day) is multiplied by a soil area to obtain a volume. The h_da standalone uses an age-dependent area that models roots growing beyond the tree pit as the tree matures. The operational port already reproduces this logic in build_tree_soil_profile (profiles.py), and the engine applies the flux onto profile.soil_area_m2, so the capillary area is already the h_da A_soil:

The mapping to the port’s area field (planting_site_area_m2 = the pit area) and the same green/pit marker list as the standalone means no additional area logic was needed for this feature — this is the part that keeps the port as close to the h_da model as practicable.

Divergences from the h_da standalone (documented)

Data sources

Outputs