bluegreencity-ki

Soil-Water-Balance Integration

Status

Phase 1, the technical integration for local daily observation, is complete. The SWBM remains separate from the rule-based/ML risk pipeline and does not yet issue irrigation work orders.

Implemented:

Pending:

The daily Windows task is registered and running (see below), and capillary rise is enabled in operational forcing as of swbm-v0.3 (see docs/water_balance/capillary_rise.md).

Prototype parameter conflict (resolved)

The prototype files were not parameter-identical. In particular, run.py uses field capacity 0.29 and a capillary-rise lookup extending to 200 cm, while script/step_historical.py / step3_realtime.py use field capacity 0.26 and a different lookup ending at 120 cm. The standalone maintainer confirmed that only the 0.26 / 120 cm combination has ever been used in the historical and real-time scripts; the run.py variant is a different, older version. The integration therefore follows field capacity 0.26 and the 120 cm lookup, which is the scientifically authoritative variant.

Capillary rise is enabled in operational forcing as of model version swbm-v0.3 (climate.water_balance.capillary_rise.enabled: true in config/config.yaml). The per-tree depth to groundwater (Flurabstand) is the multi-year median per LHH monitoring well, kriging-interpolated (with an IDW fallback) to each tree using the same well data set and coverage gate as the groundwater risk criterion. Trees with fewer than min_neighbors wells within max_distance_m simulate without capillary rise (no extrapolation beyond the monitoring network). The resolved design decisions (area, time basis, coverage gate, production activation) and the divergence from the h_da standalone A_soil nearest-well logic are documented in docs/water_balance/capillary_rise.md. The 120 cm lookup is also consumed by the standalone regression validator for comparison.

Standalone Numerical Regression

The historical files ergebnisse_historisch_2020.csv through ergebnisse_historisch_2025.csv (standalone v3, German column names) contain one daily row per simulated tree with per-tree metadata (Blattnummer, Baumart, Pflanzjahr, Kronendurchmesser_m), total and per-layer SWC, total and per-layer nFK, theoretical water need (Wasserbedarf_theorie_L), practical irrigation (Bewaesserung_praktisch_L), ET0, and raw precipitation. Each tree has a complete 365- or 366-day sequence.

scripts/validate_water_balance_against_standalone.py streams selected tree blocks without loading a multi-gigabyte file into memory. It normalizes the v3 German column names, reconstructs the exact layer geometry from SWC/nFK, reuses the recorded ET0 and precipitation, and replays the integrated soil-water core. For later years it can replay the previous year to recover the hidden surface-wetness state. An optional groundwater CSV assigns the same nearest observation point and enables the historical capillary-rise lookup.

Two-layer overflow (confirmed scientific correction)

The standalone v2 discarded infiltration that exceeded the field capacity of both active layers for young (two-layer) trees, because residual infiltration was assigned exclusively to a third layer. The standalone maintainer confirmed this was an unintended bug and fixed it in v3: excess water now enters the deepest active layer and drains gradually via k_drain. The operational port already behaved this way, so the two implementations are now identical and the regression validator no longer emulates the historical loss. This model change is a scientific correction and is reflected in the model version (swbm-v0.2).

Irrigation: dual-layer trigger and practical footprint

Standalone v3 changed the irrigation trigger to require both layer 1 and layer 2 below 40 % nFK (previously layer 1 only) and split the output into a theoretical need (full root-zone area, legacy formula) and a practical recommendation (tree-pit radius + 30 cm buffer, capped at the crown radius; layer 1 only). The port implements the same rule via calculate_irrigation_demand_liters_v3 and practical_irrigation_area_m2, with the buffer configurable as soil.practical_pit_buffer_m. The validator compares the theoretical need, which depends only on the reconstructed profile. The practical footprint was validated separately against the standalone Bewaesserung_praktisch_L column: the baumumfeld polygons (Baumumfeld_zusammengefasst.gpkg, area <= 10 m2, spatial join within) were joined to the 2025 inventory and the port’s practical_irrigation_area_m2 + calculate_irrigation_demand_liters_v3 reproduced the standalone practical irrigation exactly (0.0000 L difference across 15 trees, including trees with up to 87 irrigation days).

Validation results (standalone v2, pre-fix):

Tree 427 separately verified an active capillary-rise path. Its groundwater was 1.19 m below the root zone, within the historical lookup range. With capillary rise enabled, all 366 days matched with a maximum total-SWC error of 7.1e-15 m3. The control replay without capillary rise diverged by up to 0.681 m3 total SWC and 1,125 l modeled irrigation demand, confirming that the exact result exercises this process rather than a zero-rate lookup.

A previously observed deviation of roughly 15.7 m3 for a single tree was traced to an initial-state artifact, not a model error: the comparison had replayed the previous year through the port starting from field capacity instead of seeding the exact standalone end-of-year state. Re-seeding with the exact standalone 2024 end state reduced the full-year 2025 deviation to 0.35 m3 (capillary-inactive tree) and 0.13 m3 (capillary-active tree, groundwater 0.06 m below the root zone, lookup rate 5 mm/day). Day-by-day capillary fluxes matched the standalone formula exactly in both cases.

The comparison validates the ported soil-water dynamics, including irrigation and optional capillary rise. Because ET0 and precipitation are reused from the standalone output, it does not independently validate DWD preprocessing or the ET0 calculation; those paths have separate tests and real-data smoke runs.

Weather-pipeline comparison (2025, 10 trees, full year): precipitation matched the standalone CSV exactly (0.0000 mm difference) because both read the same DWD daily cache. ET0 matched to a mean absolute error of approximately 0.04-0.2 mm per tree; a single-day intermediate-value comparison (extraterrestrial radiation, clear-sky radiation, daylight hours, shadow factor, clearness index, diffuse fraction, effective radiation) showed all values identical to floating-point precision when the same DWD cell, SVF, and sunlit-hour anchors are used. The small residual ET0 difference is attributed to slightly different SVF/sunlit TIF versions (the standalone ran against a colleague’s local TIF directory), not to a code difference.

Systematic v0831 sample validation (2026-09-22, issue #238)

Ahead of the SWBM history dashboard, the port was systematically re-checked against the standalone v0831 outputs (2026-08-31 release). Sample: 10 trees — five young two-layer and five mature three-layer trees, green strips (Baumstreifen), tree pits (Baumscheibe), and two trees without a root-zone classification; crown areas 3–154 m²; groundwater depths 1.1–5.1 m. Forcing: the standalone’s own ET0 and precipitation (identical DWD inputs), full year 2025 plus July 2025. Initial state: the exact standalone 2024 end state per tree (SWC layers; the hidden surface-wetness state fw is not exported and defaults to 0.3).

Per-tree groundwater depth came from the nearest LHH monitoring well (multi-year median 2017–2025). Because the standalone’s own well file (h_da’s Grundwasserstände_Flurabstände) is not available locally, the effective groundwater depth was additionally inverted from the standalone output by grid search (0.2 m steps) on the full-year SWC deviation.

Results (max |ΔSWC total|, full year 2025, port swbm-v0.3 incl. the fidelity fixes below vs. v0831):

Tree Genus Layers Site LHH well (m) ΔSWC m³ Inverted well (m) ΔSWC m³
139858 Quercus 2 green 1.38 0.032 0.8 0.032
33512 Quercus 3 green 2.01 4.07 1.8 2.05
119657 Tilia 2 green 2.18 0.259 0.0 (off) 0.259
181734 Carpinus 3 — 1.36 0.002 0.0 (off) 0.002
33971 Acer 3 pit 2.51 0.002 0.0 (off) 0.002
124671 Corylus 2 green 3.41 0.061 0.0 (off) 0.061
15386 Platanus 3 green 5.14 0.202 0.0 (off) 0.202
67472 Fraxinus 2 green 1.10 0.142 0.8 0.142
177103 Acer 3 — 1.80 3.67 2.2 0.42
35291 Quercus 3 pit 1.31 0.349 1.6 0.296

Reading:

v0831 fidelity fixes applied in place to swbm-v0.3 (2026-09-22, issue #238): code review against v0831 identified and fixed two porting deviations. No version bump: the fixes change simulated states by far less than the documented deviation budget (≤0.001 m³ in the sample), and swbm-v0.3 is intended to represent the v0831-faithful port. Consequence: swbm_daily_state rows written before 2026-09-22 (including the 2025 backfill) were produced by the pre-fix engine; they remain valid within the deviation budget above and will be superseded by the re-backfill under swbm-v0.4 — the version was bumped in place on 2026-09-22 (issue #239) so pre-fix and post-fix rows no longer share a label (re-backfill plan: issues #239/#243).

History extended to 2017 (completed 2026-09-24, issues #239/#243/#245): the re-backfill under swbm-v0.4 used per-year historical tree sets with fixed ages (2022–2026 re-backfill chain, then the 2017–2021 chain). swbm_daily_state therefore covers 2017-01-01 onwards (observation days plus the rolling 7-day forecast rows), and the SWBM history dashboards reflect the same range.

  1. Soil evaporation was capped at the available water of the entire layer 1; v0831 caps it at the water stored in the top 0.15 m of layer 1 (evaporable_depth_m, new field of SoilEvaporationDemand).
  2. The evaporation area of non-green sites is a fixed 2.0 m² in v0831, even for root zones smaller than 2 m²; the port used min(2.0, A_soil).

Both changes restore exact v0831 behaviour in edge cases (very dry layer 1, small pits) with a negligible impact on the 2025 sample (≤0.001 m³).

Irrigation trigger (issue #238 point 1): the port carries the v0831 dual-layer trigger (both layer 1 and layer 2 below 0.4 nFK) since swbm-v0.2 (2026-09-13, #199) via calculate_irrigation_demand_liters_v3; the config key trigger_soil_water_fraction_layer_1 is a legacy name and is passed as the v3 trigger threshold. The two-layer infiltration fix (v0831 notes item 2) is present: the engine models an adaptive layer count and routes excess infiltration into the deepest active layer.

2026 soil-moisture bias (issue #238 point 3): the bias mentioned in the v0831 notes.txt is not quantified anywhere in the standalone documentation. It concerns the standalone real-time chain (2026 bootstrap from the historical checkpoint plus Open-Meteo-filled days and parameter drift). It does not affect the port: the 2025 backfill and the operational run consume DWD cache data (quality 1.0); Open-Meteo serves gap-fill and the 7-day forecast only. States are versioned per model_version, so a re-initialisation from a refreshed backfill is a clean, self-contained operation.

Data Contract

DailyWaterBalance stores one model version and date per tree. Fractions use the prototype SWBM convention where 0 is the wilting-point boundary, 1 is field capacity, and values up to 1.2 represent temporary excess.

For exact continuation it also stores total and per-layer water volumes in m3 plus surface_wet_fraction. Fractions alone are not treated as sufficient state because profile geometry and uncertainty scenarios can differ.

Persisted model versions include a fingerprint of layer geometry and hydraulic parameters. A changed crown, planting-site area, soil parameter, or root-zone scenario therefore starts a new state series instead of reusing an incompatible previous state.

IrrigationEvent stores occurrence and amount provenance. A missing source amount remains null. It is never silently converted into an observation.

For an event without litres, sensitivity runs use:

Scenario Amount
Lower bound 0 l
Central SWBM deficit to the configured target
Alternative Configured typical amount

The stable event_key makes repeated CSV or WFS imports idempotent.

The three amount assumptions are propagated through separate multi-day runs. Their model versions receive the suffixes :irrigation-lower, :irrigation-central, and :irrigation-typical, so PostGIS can store and compare them without overwriting another scenario.

If multiple source rows report irrigation for the same tree and day but no amount, they provide evidence for one occurrence. The central deficit or typical amount is therefore estimated once, not multiplied by the number of rows. Exact duplicate source events are additionally removed by event_key. Known observed litres remain fixed in every scenario. If observed and unknown records coexist, the central estimate fills only the remaining modeled deficit.

Database Tables

Apply sql/migrations/2026-08-03_create_water_balance_tables.sql before enabling PostGIS writes. It creates:

The migration was applied to the configured project database on 2026-08-03. Verification found 20 columns and primary key (tree_id, state_date, model_version) on swbm_daily_state, 10 columns and primary key event_key on irrigation_event, plus all four expected date, trigger, tree/date, and source/event indexes.

The additive 2026-08-03_add_swbm_meteorology_source.sql migration was also applied and verified. meteorology_source is text NOT NULL DEFAULT 'unknown' and records whether each daily state used dwd, openmeteo, or another explicit source.

PostGIS is the system of record. Parquet snapshots under data/derived/water_balance/ provide portable analysis artefacts.

Irrigation Event Import

Configure climate.water_balance.irrigation_event_import with the actual source path and columns, then enable the import block. Column names are not hardcoded because the current CSV and future WFS may use different schemas.

conda run -n bgcki_wesemann python scripts/import_irrigation_events.py `
    --config config/config.yaml

The default configuration is disabled and performs no database or file write.

Daily CLI

The incremental local runner is available as:

conda run -n bgcki_wesemann python scripts/run_daily_water_balance.py `
  --config config/config.yaml `
  --tree-features <prepared-pipeline-output.gpkg> `
  --year 2026 `
  --start-date 2026-08-01 `
  --end-date 2026-08-10

Use --no-postgis for a local Parquet-only run and --limit N for a small development sample. The CLI requires the feature file to contain canonical tree/profile fields plus m_svf_value and seasonal sunlit-hour extras.

For the current operational state, warm up the model over the complete previous calendar year and then continue from 1 January of the current year through the configured seven-day forecast horizon:

.\scripts\run_swbm_warmup.ps1 -StartDetached

The warm-up keeps the current inventory year as --profile-reference-year, so tree geometry and hydraulic profiles remain stable across both simulation years. Seasonal sunlit hours are generated separately for each simulation year. --latest-only executes every daily transition but persists only the final state per profile/scenario, avoiding a full-city intermediate history. The detached script writes progress and failures to logs/water-balance/swbm_warmup_*.log.

For year YYYY, climate.water_balance.execution.dwd_cache_dir must contain:

Create these files with:

conda run -n bgcki_wesemann python scripts/preprocess_water_balance_dwd.py `
  --config config/config.yaml `
  --year YYYY

The preprocessor downloads HYRAS mean/min/max temperature and precipitation, plus monthly HOSTRADA wind, radiation, and relative humidity. It clips NetCDF grids to the configured Hannover WGS84 bbox before creating the four Parquets. Hourly wind is averaged and converted from 10 m to FAO-56 2 m; radiation is summed and converted from W/m2 hours to MJ/m2/day; humidity retains daily minimum and maximum. Current unavailable HOSTRADA months are skipped, but a year without any complete month fails explicitly.

Raw NetCDF downloads can require several gigabytes and substantial runtime. They are cached under dwd_cache_dir/raw/ and are not started automatically by the daily task.

No such caches were present in either workspace during the 2026-08-03 integration check. The complete CLI path is covered by a synthetic end-to-end test and a real one-tree Open-Meteo run, but has not yet been claimed as a real DWD Hannover run. Missing files or weather days cause explicit fallback/error/ skip behavior; they are never silently interpolated.

Real DWD validation run

The DWD preprocessor was subsequently run for 2025:

A full-city SWBM run for 2025-07-15 completed successfully:

Metric Result
Unique trees 61,193
Profile/forcing scenarios 91,585
Missing-weather scenarios 0
Written daily states 91,585
Duplicate canonical keys 0
Parquet size 9.64 MB
Runtime including process startup 401.36 s
Daily stress range 0.0000 to 0.3405
Positive irrigation demand 0 (wet reference date)

A separate three-day smoke run persisted meteorology_source = dwd for every state. DWD provenance quality is 1.0; lower final data_quality values in the full-city output reflect tree/profile forcing uncertainty, not weather quality.

The same three states for stable tree ID 2 were written to the configured PostGIS database and read back successfully. The live rows cover 2025-07-15 through 2025-07-17, use the expected profile/model version, retain meteorology_source = dwd, and are mirrored to the standard Parquet history.

Tree identity must be the stable Baum ID. The consolidated CSV also contains a year-coded observation column ID; it is mapped to inventory_record_id, not objectid. Pipeline outputs created before this correction may contain values such as 2024000000002 as objectid and cannot be repaired after raw source columns were dropped. The SWBM CLI rejects these artefacts and requires a fresh pipeline output. A one-tree Open-Meteo smoke run exposed this issue; its physical execution succeeded, but its old tree identity is not accepted as a valid persisted result.

If the configured irrigation-event Parquet exists, the CLI applies its dated events. Observed litres stay fixed; unknown amounts follow the selected --irrigation-scenario. Without PostGIS, the Parquet state history is also the continuation source for the next run. With PostGIS enabled, PostGIS is primary and Parquet receives identical mirrored records.

Daily Windows Task

After producing a fresh pipeline output with stable Baum ID, register the local daily task under the current Windows user:

pwsh -NoProfile -File scripts/register_water_balance_task.ps1 `
  -TreeFeatures "data/output/<run>/hannover_gesamt/trees_at_risk_hannover_gesamt.gpkg" `
  -At "04:00"

The task runs scripts/run_scheduled_water_balance.ps1, which selects the current calendar day, invokes the bgcki_wesemann Conda environment, and writes a timestamped log under logs/water-balance/. The Python CLI loads the project .env before connecting to PostGIS.

The task uses the current Windows user with S4U rather than SYSTEM, preserving the project/Tailscale context without storing an interactive password in the task definition. Registration requires an existing feature file and config and rejects missing paths before changing Task Scheduler.

Remove the task with:

pwsh -NoProfile -File scripts/unregister_water_balance_task.ps1

The task has been registered (“BlueGreenCity-KI daily soil water balance”, 04:00 S4U) and has been running daily since 2026-09-12 after a fresh pipeline output provided stable Baum ID mapping.

Focused feature preparation

A full ML/SHAP run is not required to refresh the SWBM input. Run:

conda run -n bgcki_wesemann python scripts/prepare_water_balance_features.py `
  --config config/config.yaml

The focused workflow loads the current stable-ID inventory, applies OBSkH and Citree enrichment, joins planting-site polygons, and samples SVF plus all four seasonal sunlit-hour rasters. It writes only the canonical SWBM columns and geometry, avoiding source-field collisions in GeoPackage.

Crown diameter provenance (matters for root-zone area): the SWBM does not use the raw Baumkataster value. kr_durchm carries the enriched value: the inventory measurement wins on any deviation >= 10 % versus OBSkH (field values are considered more accurate), OBSkH fills missing inventory values, close matches (< 10 %) are averaged, and values above the 35 m plausibility limit are set to NaN and later imputed by the species/global crown regression in the SWBM adapter. See enrichment.md. The h_da standalone uses the raw inventory value without the cap or OBSkH fill — a known source of input divergence in cross-validation (issue #243).

On 2026-08-03 it produced data/derived/water_balance/tree_features.gpkg with 61,193 rows, 61,193 unique stable IDs (2 to 185490), and zero year-coded IDs. Missing values were 6,779 for SVF and 12 for each sunlit anchor; these are handled by the documented explicit scenarios/fallbacks.

A subsequent one-tree, one-day CLI run used stable tree_id=2, live Open-Meteo weather, and wrote a valid local Parquet state with data quality 0.7. The earlier invalid-ID smoke record was deleted.

Tree-set hygiene (fingerprint guard, issue #230)

The GPKG is a derived artifact of the operative (current) inventory, not of the historical panel spec. Because the active study-area trees spec in config/config.yaml points at the historical panel file, the preparation workflow resolves the trees_current sub-spec (the operative WFS-style extract) as its source whenever present (resolve_swbm_tree_spec).

Every build stamps the source identity (file name, mtime, size, unique IDs) as a fingerprint in the sidecar tree_features.gpkg.meta.json. The daily SWBM run calls ensure_current_tree_features before reading the GPKG: when the fingerprint of the current operative source no longer matches the persisted one (a Baumkataster version switch, a WFS re-delivery), the daily run rebuilds the GPKG in place and proceeds — so a stale tree set can never be simulated silently again. Up-to-date artifacts pass through unchanged.

To verify consistency without running the simulation (e.g. before backfills, before planning runs, or in CI):

conda run -n bgcki_wesemann python scripts/check_swbm_tree_set.py --config config/config.yaml

Exit codes: 0 = aligned, 1 = stale/missing metadata (rebuild required), 2 = artifact not present. The raw source-vs-artifact ID overlap is printed as a diagnostic only — the artifact intentionally holds active trees only.

Rule of thumb (see AGENTS.md): after any Baumkataster switch or retraining, run scripts/prepare_water_balance_features.py (or the full run_ml_refresh_overnight.ps1, whose step 5 does it) and re-check with check_swbm_tree_set.py.

Prototype Rule Ported

The first pure calculation preserves the current prototype semantics:

  1. Calculate plant-available water in layer 1 relative to wilting point and field capacity.
  2. Trigger when the fraction is below 0.4.
  3. Calculate litres required to reach 0.8 of available field capacity.

Both fractions are configuration values. They are starting assumptions, not validated production thresholds.

Daily precipitation interception and site-dependent infiltration are also ported as a pure calculation. Their runoff and surrounding-surface fractions are configuration values and remain subject to local calibration.

Meteorology Contract

Both prototype sources are normalized to one daily record containing:

DWD/Parquet observations currently receive quality 1.0; Open-Meteo gap-fill/forecast rows receive 0.7; unknown sources receive 0.5. These are configurable provenance weights, not statistical forecast confidence.

Open-Meteo requests are cached in memory by rounded location and date range. The default spatial precision is two decimals, allowing nearby trees and all profile scenarios of one tree to share weather responses. This reduces API load but does not replace the preferred DWD cache for full-city runs.

Reference evapotranspiration uses the MIT-licensed pyet implementation of FAO-56 Penman-Monteith. The prototype correction separates direct and diffuse radiation and applies sunlit hours plus Sky View Factor before ET0 is calculated. Input validation rejects duplicate dates and inconsistent humidity or temperature bounds.

Seasonal Canopy and Transpiration

LAI allometry, winter branch-area index, transpiration fractions, and leaf on/off windows are stored under climate.water_balance.vegetation. The Python code contains no hidden genus table. Unknown genera use the explicit configured defaults and are marked with parameter_source = default.

The prototype transpiration demand is retained: reference ET0 is converted to volume over crown area, multiplied by the seasonal transpiring fraction, and reduced according to available root-zone water after accounting for the daily ET demand and infiltration. Consequently, a sufficiently large ET demand can already reduce transpiration when the day starts at field capacity. This is a tested model property and should be assessed during sensor calibration.

Soil evaporation is evaluated after transpiration from the updated layer-1 water content. The prototype surface-wetting fraction fw is retained across days and persisted as surface_wet_fraction. Throughfall of at least 10 mm sets it to 1.0; at least 4 mm raises it to 0.6; otherwise it carries over.

The weather-to-flux adapter now produces complete reviewed daily fluxes from a weather record and current tree state. Capillary rise is enabled in operational forcing as of swbm-v0.3, using the h_da 120 cm lookup and a kriged groundwater surface (see docs/water_balance/capillary_rise.md).

Safety and Calibration

The full daily engine is ported and running in production (swbm-v0.3, incl. the 2026-09-22 v0831 fidelity fixes). Before operational recommendations, validate water states and demand against sensor measurements, precipitation, and documented irrigation dates. Unknown amounts must be evaluated as scenarios rather than imputed as facts.

Current Tree Profile Coverage

The enriched Hannover 2024 snapshot was checked end to end on 2026-08-03:

Metric Count
Analysable unique trees 61,193
Trees with a resolved age profile 54,558
Trees with unknown age 6,635
Unique trees covered by at least one SWBM profile 61,193
Total profiles including age scenarios 74,463

Age provenance note (issue #245, 2026-09-23): the LHH v6 “wie WFS” inventory extract encodes unknown hauptdaten_standalter / hauptdaten_pflanzjahr as 0 (the historical extract leaves them blank). prepare_water_balance_features therefore normalizes 0 to missing before the feature GPKG is written, so age resolution falls back to planting year and then to stem-circumference estimation instead of building age-0 sapling pit profiles. A new LHH extract must be checked for this missing-value encoding before it goes into service.

Unknown age is not replaced by one arbitrary value. Each affected tree gets three explicit profiles:

The resolved or scenario name must become part of the simulation model version when daily states are persisted. Downstream consumers must not average these profiles without displaying their uncertainty range.

Crown diameter provenance in this smoke test included 3,893 species-regression profiles, 148 global-regression profiles, and 6,415 age-class fallback scenario profiles. PreparedTreeProfile.data_quality is reduced for each derived age, crown, or default planting-site area. This gives full computational coverage, not full observational certainty.

Hannover forcing coverage

The complete 2024 pipeline GeoPackage was checked on 2026-08-03 after binding SVF, stem size, coordinates, and seasonal sunlit hours:

Metric Count
Unique pipeline trees 61,193
Unique trees with at least one complete forcing context 61,193
Combined profile/forcing scenarios 91,585
Excluded forcing contexts 0
Scenarios caused by unknown SVF 25,683

This is full computational coverage, not full measurement coverage. If both stem diameter and circumference are missing, the explicit prototype default of 30 cm is used with quality 0.3. If SVF is missing, three scenarios are computed (0.3, 0.6, 0.9) rather than storing one invented value. Missing seasonal sunlit anchors use visible config defaults; the current output lacks a winter extra, so all current forcing contexts are capped at quality 0.5 even when the other inputs are measured.

The profile scenario becomes part of model_version, keeping unknown-age and unknown-SVF states separate in PostGIS. Consumers must summarize uncertainty across scenarios explicitly and must not count scenario rows as independent trees.