⚠️ Legacy snapshot (2025-10-13). This document describes the initial (v1) system state. The criteria count, output file names, model directory layout, tree-ID field, and several sample values below have since changed. For the current operational state see configuration.md, workflow.md, and the ML documentation. The core design patterns (vector-first processing, pluggable criteria, backend abstraction, configuration-driven behavior) remain valid.
This document provides a comprehensive overview of the BlueGreenCity-KI system architecture, design patterns, and extension points.
BlueGreenCity-KI is a comprehensive, modular urban tree management system designed to support climate-resilient green infrastructure. The system evaluates drought risk for urban trees by computing and aggregating multiple criteria scores (each in 0–1 range) into a weighted risk score. Beyond risk assessment, the tool is designed to calculate precise water demands for individual trees and identify alternative water sources for irrigation during drought periods, helping maintain and enhance the ecosystem services that urban trees provide.
The system employs a vector-first architecture for efficient spatial processing and is built to integrate machine learning and deep learning methods in certain criteria and additional modules, enabling data-driven predictions and optimizations for urban forest management.
┌─────────────────────────────────────────────────────────────┐
│ CLI Runner │
│ (bluegreencity_ki/runners) │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Pipeline Layer │
│ (bluegreencity_ki/pipelines) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ risk_scoring.run(ctx) │ │
│ │ - Load criteria from catalog │ │
│ │ - Execute each criterion │ │
│ │ - Aggregate weighted risk │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ Criteria │ │ Core │ │ IO │
│ Registry │ │ Context │ │ Writers │
└───────────────┘ └──────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ Criterion │ │ Schema │ │ Reports │
│ Instances │ │ Profiler │ │ │
└───────────────┘ └──────────────┘ └──────────────┘
│ │
└──────────┬───────┘
▼
┌────────────────┐
│ Backend │
│ Adapters │
├────────────────┤
│ OSGeo Backend │
│ ArcPy Backend │
└────────────────┘
│
▼
┌────────────────┐
│ Data Sources │
│ - Vector │
│ - Raster │
│ - PostGIS │
└────────────────┘
flowchart TD
A[CLI Runner<br/>bluegreencity_ki/runners] --> B[Pipeline Layer<br/>bluegreencity_ki/pipelines]
B --> C[Criteria Registry]
B --> D[Core Context]
B --> E[IO Writers]
C --> F[Criterion Instances]
D --> G[Schema and Profiler]
E --> H[Reports]
F --> I[Backend Adapters<br/>OSGeo or ArcPy]
G --> I
I --> J[Data Sources<br/>Vector, Raster, PostGIS]
Each module has a clear, single responsibility:
The criteria system uses a registry pattern that allows new criteria to be added without modifying existing code. See Criteria System for details.
Data loading is abstracted through backend adapters, allowing the system to work with different GIS libraries (GeoPandas/GDAL or ArcPy) transparently.
A Context object carries all necessary data and configuration through the pipeline. Criteria receive the context as input and return results without side effects.
System behavior (weights, thresholds, data sources) is controlled through YAML configuration files rather than hardcoded values.
bluegreencity_ki/core/context.py)The Context class is the central data container that flows through the entire pipeline:
@dataclass
class Context:
config: dict # Full configuration from YAML
run_date: date # Execution date for age calculations
schema: Schema # Field name mappings
backend: str # 'osgeo' or 'arcpy'
scenario: ScenarioProvider # Configured scenario inputs
vector: dict # Loaded vector layers (trees, roads)
raster: dict # Loaded raster data (landuse, temperature)
Creation: Context.from_yaml(config_path) loads configuration, data sources, and initializes all components.
bluegreencity_ki/core/schema.py)Defines field name mappings to decouple code from specific column names in input data:
class Schema:
tree_id = "objectid"
pflanzjahr = "pflanzjahr" # planting year
vitality = "vitalitaet"
# ... more field mappings
This allows the system to work with datasets that use different column names.
bluegreencity_ki/core/profiler.py)Tracks performance metrics for each pipeline step:
with timer("criterion:age", profiler):
result = criterion.compute(ctx)
Profiling data is included in output reports for performance monitoring.
bluegreencity_ki/pipelines/risk_scoring.py)The main pipeline orchestrates the risk assessment workflow:
def run(ctx) -> tuple[pd.DataFrame, dict, Profiler, dict]:
# 1. Normalize criterion weights
# 2. For each configured criterion:
# - Create criterion instance from catalog
# - Execute criterion.compute(ctx)
# - Collect score and metadata
# 3. Aggregate weighted risk score
# 4. Return results + statistics
Raster-backed criteria use the shared OSGeo raster sampling utilities instead of
opening and reading rasters criterion-by-criterion. The utilities normalize raw
ctx.raster entries to their concrete dataset, cache point or point+zonal tree
samples on the pipeline context, and cache raster band reads on the open dataset
object. In addition, a persistent on-disk cache can be enabled via
project.raster_sampling_cache so repeated development runs can reuse prior
sampling results when raster signature, tree geometry/index, CRS, and sampling
mode remain unchanged. This keeps output fields and scoring logic
criterion-specific while avoiding repeated full-band reads when a criterion
needs both zonal statistics and point fallback, or when multiple criteria reuse
the same raster samples (for example day/night temperature and UHI).
Threshold strategies remain explicit in each criterion. Static and sample-based
thresholds avoid full-raster statistics; spatial percentile thresholds still use
compute_raster_statistics() only when configured because they intentionally
need the raster-wide distribution. The existing criterion:<name> profiler
timers continue to measure runtime per raster criterion, including cached reads
and sampling.
┌─────────────────────────────────────────────────────────┐
│ 1. Load Context │
│ - Parse YAML config │
│ - Load vector/raster data via adapters │
│ - Initialize schema and scenario provider │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 2. Normalize Weights │
│ - Sum all criterion weights │
│ - Normalize to sum=1.0 │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 3. Execute Criteria (loop) │
│ For each criterion in config: │
│ ┌─────────────────────────────────────────────┐ │
│ │ a. Create criterion from catalog │ │
│ │ b. Call criterion.compute(ctx) → Result │ │
│ │ c. Extract score series (index=tree_id) │ │
│ │ d. Collect extras (e.g., group_size) │ │
│ │ e. Compute statistics (min/max/mean) │ │
│ │ f. Generate histogram │ │
│ └─────────────────────────────────────────────┘ │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 4. Aggregate Risk │
│ - Combine all criterion scores into DataFrame │
│ - Compute: overall_drought_risk = Σ(score[i] * weight[i]) │
│ - Attach auxiliary columns (extras) │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 5. Write Outputs │
│ - Save results to GPKG/CSV │
│ - Generate run report (JSON/Markdown) │
│ - Log sustainability metrics │
└─────────────────────────────────────────────────────────┘
flowchart TD
A["1. Load Context<br/>Parse YAML config<br/>Load vector and raster data<br/>Initialize schema and scenario provider"]
B["2. Normalize Weights<br/>Sum criterion weights<br/>Normalize to sum = 1.0"]
C["3. Execute Criteria Loop<br/>Create criterion from catalog<br/>Run criterion.compute(ctx)<br/>Collect scores, extras, and statistics"]
D["4. Aggregate Risk<br/>Combine criterion scores<br/>Compute overall_drought_risk<br/>Attach auxiliary columns"]
E["5. Write Outputs<br/>Save GPKG and CSV results<br/>Generate reports<br/>Log sustainability metrics"]
A --> B --> C --> D --> E
timer context managergroup_size) that gets attached to output📖 For a detailed explanation of the risk scoring workflow, including weight normalization, aggregation formulas, and worked examples, see risk_scoring_guide.md.
The criteria system implements a plugin architecture that allows new risk criteria to be added dynamically.
All criteria implement the Criterion protocol defined in bluegreencity_ki/criteria/base.py:
class Criterion(Protocol):
name: str
def compute(self, ctx: Context) -> CriterionResult:
"""Compute risk score for all trees in context.
Returns:
CriterionResult with:
- name: criterion identifier
- score: Series[tree_id -> float] in [0,1]
- meta: optional metadata for reporting
- extras: optional auxiliary data columns
"""
...
@dataclass
class CriterionResult:
name: str # Criterion identifier (e.g., "age", "vitality")
score: pd.Series # index=tree_id, values in [0,1]
# Stored as {name}_risk in output
meta: Dict[str, Any] | None = None # Optional metadata
extras: Dict[str, pd.Series] | None = None # Raw values and auxiliary data
# Key format: {name}_value
Field Naming Convention:
{criterion_name}_risk (e.g., “age_risk”, “vitality_risk”)
{criterion_name}_value (e.g., “age_value”, “air_temperature_value”)
extras dictionaryScore Convention: All risk scores are normalized to [0, 1] where:
The catalog (bluegreencity_ki/criteria/criteria_catalog.py) is a registry mapping criterion names to builder functions:
CATALOG: Dict[str, Callable[[], Criterion]] = {
"age": age.build,
"street_type": street_type.build,
"vitality": vitality.build,
"planting_structure": planting_structure.build,
}
def create(name: str) -> Criterion:
return CATALOG[name]()
The registry now holds 25 criteria (see criteria/README.md); the four below are the initial v1 set kept as examples.
age.py)pflanzjahr (planting year)vitality.py)vitalitaet (vitality stage 0-5)exclude_stages, stage0_scorestreet_type.py)street_type_value (raw highway classification string)planting_structure.py)linear: Linear scaling between minimum and maximum neighbor countbreaks: Score bins by neighbor count thresholdsplanting_structure_value (raw neighbor count as integer)To add a new criterion (e.g., land_use):
bluegreencity_ki/criteria/land_use.py):from .base import Criterion, CriterionResult
import pandas as pd
class LandUse(Criterion):
name = "land_use"
def compute(self, ctx) -> CriterionResult:
trees = ctx.vector["trees"]
# Your scoring logic here
raw_landuse = sample_landuse_raster(trees) # e.g., sealing percentage
score = normalize_to_risk(raw_landuse) # Convert to 0-1 risk score
# Store raw values in extras using {name}_value convention
extras = {"land_use_value": raw_landuse}
return CriterionResult(self.name, score, extras=extras)
def build():
return LandUse()
criteria_catalog.py):from . import land_use
CATALOG = {
# ... existing entries
"land_use": land_use.build,
}
config/config.yaml):criteria:
- name: land_use
weight: 0.15
params:
# criterion-specific parameters
No changes to pipeline or other code are required!
The system supports two backend implementations for data loading, abstracted through adapter modules.
bluegreencity_ki/adapters/
├── osgeo_backend/
│ ├── vector.py # GeoPandas/Fiona-based loading
│ └── raster.py # Rasterio-based loading
└── arcpy_backend/
├── vector.py # ArcPy-based loading
└── raster.py # ArcPy raster handling
Backend is configured in config.yaml:
project:
backend: "osgeo" # or "arcpy"
The readers.load_inputs() function dynamically imports the appropriate adapter:
def load_inputs(cfg) -> Tuple[dict, dict]:
backend = cfg["project"]["backend"]
if backend == "arcpy":
from ..adapters.arcpy_backend import vector as v, raster as r
elif backend in ("osgeo", "qgis", "geopandas"):
from ..adapters.osgeo_backend import vector as v, raster as r
# ... use v.load_layer() and r.load_raster()
Advantages:
Data Sources:
Key Functions (osgeo_backend/vector.py):
load_layer(spec, id_field): Loads vector data from various sourcessave_gpkg_layer(gdf, path, layer): Writes to GeoPackageget_bbox(gdf): Extracts bounding box for spatial queriesPostGIS Support:
inputs:
trees:
type: postgis
schema: public
table: trees
filter: "district = 'Mitte'"
id_field: objectid
Use Cases:
Limitations:
Key Functions (arcpy_backend/vector.py):
load_layer(spec, id_field): Loads via arcpy.da.SearchCursorConfiguration File (config.yaml)
↓
Context.from_yaml()
↓
┌───────┴────────┐
▼ ▼
load_inputs() ScenarioProvider
↓ ↓
Backend Adapter ↓
↓ ↓
┌───┴────┐ ↓
▼ ▼ ▼
Vector Raster ↓
Data Data ↓
↓ ↓ ↓
└───┬───┴────────┘
▼
Context Object
↓
Pipeline Execution
inputs:
trees:
type: postgis
table: trees
trees = v.load_layer(trees_spec, id_field="objectid")
# Result: GeoDataFrame with geometry + attributes
enrichment.py):
trees = enrich_trees(trees, cfg)
# Spatial join with OBSkH dataset → adds obskh_* columns
# Merges crown diameter with QA tracking
See Enrichment documentation for details.
ctx.vector = {"trees": trees, "roads": roads}
trees = ctx.vector["trees"]
score = compute_score(trees[ctx.schema.tree_id])
inputs:
landuse_raster:
type: raster
path: data/landuse.tif
raster = r.load_raster(spec)
# Result: Rasterio dataset or numpy array
# Sample raster values at tree locations
values = sample_raster_at_points(raster, tree_geometries)
Pipeline Results (DataFrame)
↓
┌───┴────┐
▼ ▼
Writers Reports
↓ ↓
┌───┴─┐ ┌──┴───┐
▼ ▼ ▼ ▼
GPKG CSV JSON Markdown
Output Files (current naming; results.* was the v1 name):
trees_at_risk.gpkg (or trees_at_risk_<area>.gpkg): spatial data with risk scorestrees_at_risk.csv (or trees_at_risk_<area>.csv): tabular exportreport/trees_at_risk_run_stats.json: statistics and profilingreport/trees_at_risk_run_report.md: human-readable reportLocation: bluegreencity_ki/criteria/
Steps:
my_criterion.py with Criterion implementationcriteria_catalog.pyconfig.yamlExample Use Cases:
Location: Backend adapters (adapters/*/vector.py, raster.py)
Extension Points:
load_layer() (e.g., “wfs”, “s3”)Example:
def load_layer(spec, id_field=None):
t = spec["type"]
if t == "wfs":
return load_from_wfs(spec["url"], spec["layer"])
elif t == "postgis":
# existing PostGIS implementation
Location: bluegreencity_ki/adapters/
Steps:
new_backend/ directoryvector.py and raster.py with standard interfacereaders.load_inputs() to recognize new backendExample: QGIS backend using PyQGIS
Location: bluegreencity_ki/pipelines/
Use Cases:
Example:
# pipelines/irrigation_demand.py
def run(ctx, risk_results):
# Compute watering needs based on risk scores
return demand_schedule
Location: bluegreencity_ki/core/normalizers.py
Current: quantile_minmax() (quantile-anchored min–max, optional clipping and inversion)
Extension: Add domain-specific normalization functions
Location: bluegreencity_ki/scenarios/
Current: Baseline scenario provider
Possible extensions:
Location: bluegreencity_ki/io/writers.py
Extension Points:
project:
backend: "osgeo" # Backend selection
run_date: "auto" # or ISO date string
neutral_score: 0.5 # Default for missing data
scenario: "baseline" # Scenario identifier
study_areas:
active: "hannover_gesamt"
definitions:
hannover_gesamt:
name: "City of Hannover (complete)"
bbox_source: "auto"
trees: &hannover_tree_inventory
type: "csv_inventory"
path: "./data/input/trees/Baum-2010-2025-v6_historisch_prod.csv"
delimiter: ";"
hannover_ml_training_2010:
name: "Hannover ML Training (2010 Snapshot)"
bbox_source: "auto"
trees:
<<: *hannover_tree_inventory
timestamp_filter: "2010-12-31"
inputs:
trees: # Legacy: single data source
type: postgis
table: trees
roads:
type: postgis
table: osm_highways
criteria:
- name: age
weight: 0.2 # Normalized automatically
normalizer_params:
q_low: 0.1
q_high: 0.9
- name: vitality
weight: 0.3
params:
exclude_stages: [4, 5]
stage0_score: 0.5
- name: planting_structure
weight: 0.25
params:
mode: "breaks"
radius_m: 20
neighbor_breaks: [0, 4, 9]
neighbor_scores: [0.8, 0.5, 0.3, 0.1]
output:
path: "./data/output/{run_date_underscore}_{scenario}/trees_at_risk.gpkg"
from bluegreencity_ki.core.config import load_config
cfg = load_config("config/config.yaml")
# Result: Parsed dict with validation
Allows running analysis on different geographic areas with area-specific data sources:
project:
study_areas:
active: "hannover_gesamt"
definitions:
hannover_gesamt:
name: "City of Hannover (complete)"
bbox_source: "auto"
trees: &hannover_tree_inventory
type: "csv_inventory"
path: "./data/input/trees/Baum-2010-2025-v6_historisch_prod.csv"
delimiter: ";"
The system includes an optional ML-based drought risk prediction pipeline using AutoGluon, running in parallel with the weighted-sum scoring.
┌──────────────────────────────────────────────────────────────────┐
│ ML Pipeline (parallel path) │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 1. Label Extraction (offline / one-time) │
│ bluegreencity_ki/ml/labels/label_extractor.py │
│ → Crown diagnosis (Wipfeldürre, Totholz, Belaubung) │
│ → drought_damage_binary (combined OR label) │
│ │
│ 2. Training (offline / periodic) │
│ bluegreencity_ki/ml/training/trainer.py │
│ → AutoGluonTrainer wraps TabularPredictor.fit() │
│ → bagging/stacking, roc_auc metric │
│ → Models: data/models/autogluon/{operational, │
│ annual_validation}/<YYYY_MM>/train_…/ │
│ │
│ 3. Validation (offline / after training) │
│ bluegreencity_ki/validation/validator.py │
│ → ModelValidator: ROC-AUC, Brier, F1, calibration │
│ → Extreme-group analysis (Top/Bottom 5% lift) │
│ → Per-species metrics, baseline comparison │
│ │
│ 4. Inference (within pipeline, if ml.enabled=true) │
│ bluegreencity_ki/ml/inference/predictor.py │
│ → DroughtRiskPredictor loads model, scores trees │
│ → Output: drought_risk_ml (P), drought_risk_ml_class │
│ │
└──────────────────────────────────────────────────────────────────┘
When ml.enabled: true in config.yaml, risk_scoring.run() executes the ML block after the weighted-sum aggregation:
<model_path>/<label>/drought_risk_ml (probability) and drought_risk_ml_class (categorical) to the outputBoth scoring paths are independent — the weighted sum (drought_risk_rulebased) and the ML prediction (drought_risk_ml) are computed separately and both included in the output.
tabular[all] (requirements pin >=1.2; automated model stacking, Apache 2.0)roc_auc (handles imbalanced binary classification)For significant architectural decisions, we recommend creating ADRs in docs/adr/:
# ADR-001: Decision Title
## Status
Accepted | Proposed | Deprecated
## Context
What is the issue we're trying to solve?
## Decision
What did we decide?
## Consequences
What are the positive and negative outcomes?
## Alternatives Considered
What other options did we evaluate?
Note: number ADR-002 is already taken by the spatial-database evaluation (see ../research/sedonadb-duckdb/); new ADRs should continue from ADR-003. See the ADR index.
Document Version: 1.1 (legacy snapshot with corrected references)
Last Updated: 2026-10-12
Maintainer: BlueGreenCity-KI Team