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.
The depth to groundwater at a tree (Flurabstand, metres below surface) is
computed in bluegreencity_ki/water_balance/groundwater_surface.py:
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.flurabstand_median), consistent with the criterion’s default
scoring_source: median.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.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.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).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.
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:
0.05 m below the root zone;lookup_mm_per_day against the depth below the root zone, floored to 0.0
beyond the last lookup point;maximum_available_fraction and trigger_available_fraction
bands;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:
Acr;Aeff = min(pit area, crown area);Aeff (just the pit);Aeff → Acr;Acr.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.
cKDTree.query). The port uses a kriging
surface over all wells (with the median statistic and a min-neighbours
coverage gate). This is a deliberate scientific upgrade: the kriged surface
reflects the spatial field rather than a nearest-point estimate, and the
coverage gate prevents unsupported extrapolation. The groundwater risk
criterion uses exactly the same surface and gate, so the risk model and the
water-balance forcing now tell the same groundwater story.Median_Flurabstand_m, falling back to 2025). The port uses the median
over all available years. Capillary rise responds on seasonal-to-annual
timescales, so a single year would add random noise without signal; the
multi-year median is more robust to one-year extremes and reuses the exact
value the groundwater criterion scores on.inputs.groundwater.path, file_pattern), columns
ID_Messstelle, Rechtswert, Hochwert,
Mittlerer_Flurabstand_m, Jahr (EPSG:25832).climate.water_balance.execution.tree_features_path, EPSG:25832).climate.water_balance.capillary_rise in
config/config.yaml (lookup, fractions, interpolation params).swbm_daily_state outputs: a shallower
water table (smaller Flurabstand) raises the deepest-layer water storage and
lowers soil_water_stress_today / soil_water_stress_forecast_max_7d on
dry spells. Covered vs. uncovered trees differ accordingly.swbm-v0.3 in config/config.yaml. Every
swbm_daily_state row is stamped with this version, so
swbm-v0.3 ≡ capillary ON with kriged groundwater is machine-readable in the
storage and in Grafana.Capillary rise: 88,400 of 89,280 trees have a kriged groundwater depth,
plus the well count and whether the IDW fallback was used.