The BlueGreenCity-KI system evaluates drought risk for urban trees through a multi-criteria scoring approach. Each tree receives a final drought risk score in the range [0, 1], where:
This final score is computed as a weighted sum of individual criterion scores, each of which evaluates a specific aspect of drought vulnerability (age, vitality, street pollution, planting isolation, air temperature, etc.).
The risk scoring pipeline follows these steps:
┌─────────────────────────────────────────────────────────────────┐
│ 1. Load Data & Context │
│ - Tree inventory (geometry, attributes) │
│ - Context layers (OSM roads, raster data) │
│ - Configuration (weights, thresholds, parameters) │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. Normalize Weights │
│ Input weights: [0.5, 0.5, 0.5, 0.5, 0.5] │
│ Sum = 2.5 │
│ Normalized: [0.2, 0.2, 0.2, 0.2, 0.2] (each weight / sum) │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Compute Individual Criterion Scores │
│ │
│ For each criterion (age, vitality, street_type, ...): │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Criterion.compute(context) → CriterionResult │ │
│ │ - score: Series[tree_id → 0.0..1.0] │ │
│ │ - meta: dict (statistics, parameters) │ │
│ │ - extras: dict (auxiliary columns like group_size) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Example outputs (scores stored as {name}_risk): │
│ age_risk: [0.8, 0.3, 0.9, 0.1, ...] │
│ vitality_risk: [0.66, 1.0, 0.33, 0.0, ...] │
│ street_type_risk: [1.0, 0.5, 0.0, 0.83, ...] │
│ planting_structure_risk: [1.0, 0.33, 0.33, 1.0, ...] │
│ air_temperature_risk: [0.7, 0.9, 0.2, 0.8, ...] │
│ │
│ Raw values (stored as {name}_value): │
│ age_value: [75, 12, 89, 3, ...] (years) │
│ vitality_value: [2, 3, 1, 0, ...] (stage 0-5) │
│ street_type_value: ['motorway', 'residential', ...] │
│ planting_structure_value: [1, 3, 3, 1, ...] (group size) │
│ air_temperature_value: [30.5, 31.8, 28.2, ...] (°C) │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. Aggregate Weighted Risk Score │
│ │
│ Formula: │
│ drought_risk_rulebased = Σ (criterion_score × normalized_weight) │
│ │
│ Example (for tree #1): │
│ drought_risk_rulebased = (0.8 × 0.2) + (0.66 × 0.2) + (1.0 × 0.2) + │
│ (1.0 × 0.2) + (0.7 × 0.2) │
│ = 0.16 + 0.132 + 0.2 + 0.2 + 0.14 │
│ = 0.832 │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. Output Results │
│ - Write to PostGIS (bgcki.analysis_tree_risk_scores) │
│ - Write to GeoPackage (trees_at_risk.gpkg) │
│ - Generate reports (JSON, Markdown with statistics) │
│ - Preserve individual criterion scores for transparency │
└─────────────────────────────────────────────────────────────────┘
The core aggregation logic is in bluegreencity_ki/pipelines/risk_scoring.py:
def run(ctx) -> tuple[pd.DataFrame, dict, Profiler, dict]:
# 1. Normalize weights
wsum = sum(c["weight"] for c in ctx.config["criteria"]) or 1.0
weights = {c["name"]: c["weight"] / wsum for c in ctx.config["criteria"]}
# 2. Compute each criterion
results = {}
for c in ctx.config["criteria"]:
crit = criteria_catalog.create(c["name"])
res = crit.compute(ctx)
results[res.name] = res.score # Series[tree_id → 0..1]
# 3. Aggregate weighted risk
df = pd.DataFrame(results)
df["drought_risk_rulebased"] = sum(df[col] * weights[col] for col in results.keys())
return df, stats, profiler, distributions
Each criterion implements the Criterion protocol and computes a risk score for every tree.
Every criterion returns a CriterionResult object:
@dataclass
class CriterionResult:
name: str # Criterion identifier
score: pd.Series # Series[tree_id → float in [0, 1]]
meta: Dict[str, Any] | None = None # Optional metadata for reporting
extras: Dict[str, pd.Series] | None = None # Optional auxiliary columns
Components:
name: Criterion identifier (e.g., “age”, “vitality”)score: Pandas Series mapping each tree ID to a risk score in [0.0, 1.0]
ctx.schema.tree_id, typically objectid){name}_risk in output (e.g., “age_risk”, “vitality_risk”)meta: Optional metadata dictionary containing:
extras: Dictionary containing raw measurement values
{name}_value (e.g., “age_value”, “air_temperature_value”)All criterion scores must be in the range [0.0, 1.0]:
| Score Range | Risk Level | Interpretation |
|---|---|---|
| 0.0 - 0.3 | Low | Favorable conditions, minimal drought stress |
| 0.3 - 0.7 | Medium | Moderate vulnerability, monitor conditions |
| 0.7 - 1.0 | High | Critical vulnerability, prioritize for irrigation |
Special Cases:
project.neutral_score)Purpose: Older trees typically have deeper root systems but may be more vulnerable during extreme drought.
Scoring Logic (two-stage):
# Stage 1: planting year available
age = reference_year - planting_year
# Stage 2 (fallback): planting year missing → piecewise estimate from
# stem circumference (st_umfang), SGA Hannover classification table (2024)
# Normalize using quantile-based min-max scaling (normalizer_params,
# entry level — see criteria/age.md)
# q_low=0.05 (youngest 5%) → score 0.0
# q_high=0.95 (oldest 5%) → score 1.0
score = quantile_minmax(age, q_low=0.05, q_high=0.95)
Examples:
Configuration:
criteria:
- name: age
weight: 0.5
normalizer_params:
q_low: 0.05 # Youngest 5% → score 0.0
q_high: 0.95 # Oldest 5% → score 1.0
clip: true # Clip scores to [0, 1]
Purpose: Trees with poor vitality are more susceptible to drought stress.
Scoring Logic:
# Map vitality stages (0-5 scale) to risk scores
if vitality in [4, 5]: # Dead or degenerating
score = 0.0 # Watering exclusion
elif vitality == 3: # Poor vitality
score = 1.0 # High risk
elif vitality == 2: # Fair vitality
score = 0.66 # Medium-high risk
elif vitality == 1: # Good vitality
score = 0.33 # Low-medium risk
else: # Stage 0 or missing
score = 0.5 # Neutral (no data)
Examples:
Configuration:
criteria:
- name: vitality
weight: 0.5
params:
exclude_stages: [4, 5] # Dead/degenerating → 0.0
stage0_score: 0.5 # Missing data → neutral
Purpose: Trees along high-traffic roads experience additional stress from pollution, heat island effects, and de-icing salts.
Scoring Logic:
# Join trees to nearest OSM highway (within max_distance)
# Risk is DERIVED from the configured inputs.roads.allowed order:
# risk = (n - rank) / n, where rank is the 0-based position in the list
# (values below hold for the live 6-class allowed list):
highway_risk = {
"motorway": 1.0, # Highest pollution
"trunk": 0.83,
"primary": 0.67,
"secondary": 0.50,
"tertiary": 0.33,
"residential": 0.17, # Lowest pollution
}
score = highway_risk.get(highway_class, 0.0) # No road within max_distance → 0.0
# (0.5 neutral only when the whole highway column is missing — data acquisition failure)
Examples:
Configuration:
criteria:
- name: street_type
weight: 0.5 # example; current calibrated weight in config/config.yaml
inputs:
roads:
source: osm
allowed: ["motorway", "trunk", "primary", "secondary", "tertiary", "residential"]
osm_join:
max_distance: 50.0 # live config (code fallback: 10.0)
Purpose: Isolated individual trees are more vulnerable to drought than trees in groups (which create beneficial microclimates).
Scoring Logic:
# Cluster trees within radius_m into groups
# Map group size to risk score
if mode == "breaks":
# Class-based mapping
if group_size == 1: score = 1.0 # Single tree (high risk)
elif group_size <= 5: score = 0.66 # Small group
elif group_size <= 10: score = 0.33 # Medium group
else: score = 0.0 # Large group (low risk)
elif mode == "linear":
# Linear decrease from single (1.0) to large groups (0.0)
score = 1.0 - (group_size - size_min) / (size_max - size_min)
Examples:
Configuration:
criteria:
- name: planting_structure
weight: 0.5 # example; current calibrated weight in config/config.yaml
params:
radius_m: 25.0 # live config
mode: breaks # or "linear"
neighbor_breaks: [0, 4, 9] # live config: thresholds (isolated, 1..4, 5..9, >=10)
neighbor_scores: [0.8, 0.5, 0.3, 0.1] # live config
Purpose: Trees in hotter microclimates experience higher evapotranspiration and drought stress.
Scoring Logic (two independent criteria, air_temperature_day at 14 h and
air_temperature_night at 04 h; night temperature is scored in the opposite
direction — hot nights increase risk):
# Sample the 14h/04h air-temperature rasters at tree locations
# Map temperature to risk using thresholds
if threshold_method == "tree_percentile":
# Adaptive thresholds from the sampled tree values (live config)
temp_low = percentile(tree_temps, 5)
temp_high = percentile(tree_temps, 95)
elif threshold_method == "spatial_percentile":
temp_low = percentile(raster_temps, 5)
temp_high = percentile(raster_temps, 95)
else: # static
temp_low, temp_high = <configured °C thresholds>
# Linear interpolation between thresholds (direction per criterion)
score = clip((temp - temp_low) / (temp_high - temp_low), 0.0, 1.0)
Examples (tree_percentile mode, live config p5/p95):
Configuration (live values for air_temperature_day shown):
criteria:
- name: air_temperature_day
weight: 0.5 # example; current calibrated weight in config/config.yaml
params:
threshold_method: tree_percentile # live config
low_percentile: 5
high_percentile: 95
Weights in the configuration can be any positive numbers. They are automatically normalized to sum to 1.0 to ensure the final risk score remains in [0, 1].
# Raw weights from config
weights_raw = {
"age": 0.5,
"vitality": 0.5,
"street_type": 0.5,
"planting_structure": 0.5,
"air_temperature": 0.5
}
# Calculate sum
weight_sum = 0.5 + 0.5 + 0.5 + 0.5 + 0.5 = 2.5
# Normalize each weight
weights_normalized = {
"age": 0.5 / 2.5 = 0.2,
"vitality": 0.5 / 2.5 = 0.2,
"street_type": 0.5 / 2.5 = 0.2,
"planting_structure": 0.5 / 2.5 = 0.2,
"air_temperature": 0.5 / 2.5 = 0.2
}
# Verify: sum = 0.2 + 0.2 + 0.2 + 0.2 + 0.2 = 1.0 ✓
The normalized weight represents each criterion’s contribution to the final score.
Example configurations:
# Equal weighting (all criteria equally important)
criteria:
- {name: age, weight: 1.0}
- {name: vitality, weight: 1.0}
- {name: street_type, weight: 1.0}
- {name: planting_structure, weight: 1.0}
- {name: air_temperature, weight: 1.0}
# Normalized: each 0.2 (20% contribution)
# Prioritize vitality and temperature
criteria:
- {name: age, weight: 0.5}
- {name: vitality, weight: 2.0} # 2x importance
- {name: street_type, weight: 0.5}
- {name: planting_structure, weight: 0.5}
- {name: air_temperature, weight: 2.0} # 2x importance
# Normalized: age=0.09, vitality=0.36, street=0.09, planting=0.09, temp=0.36
# Extreme case: focus only on vitality
criteria:
- {name: age, weight: 0.1}
- {name: vitality, weight: 10.0}
- {name: street_type, weight: 0.1}
- {name: planting_structure, weight: 0.1}
- {name: air_temperature, weight: 0.1}
# Normalized: vitality=0.96, others=0.01 each
The final drought risk score is computed as a weighted sum of all criterion scores.
For each tree:
drought_risk_rulebased = Σ (criterion_score_i × normalized_weight_i)
= (age_score × w_age) + (vitality_score × w_vitality) + ... + (temp_score × w_temp)
Where:
criterion_score_i ∈ [0, 1] for criterion inormalized_weight_i is the normalized weight for criterion iConfiguration:
criteria:
- {name: age, weight: 0.5}
- {name: vitality, weight: 0.5}
- {name: street_type, weight: 0.5}
- {name: planting_structure, weight: 0.5}
- {name: air_temperature, weight: 0.5}
Normalized weights:
Tree #12345 criterion scores:
Risk scores (normalized 0-1):
age_risk: 0.80
vitality_risk: 0.66
street_type_risk: 1.00
planting_structure_risk: 1.00
air_temperature_risk: 0.70
Raw values (for context):
age_value: 75 years (old tree)
vitality_value: 2 (stage 2 - fair vitality)
street_type_value: 'motorway'
planting_structure_value: 1 (single isolated tree)
air_temperature_value: 30.5°C (hot location)
Final risk calculation:
drought_risk_rulebased = (0.80 × 0.2) + (0.66 × 0.2) + (1.00 × 0.2) + (1.00 × 0.2) + (0.70 × 0.2)
= 0.16 + 0.132 + 0.20 + 0.20 + 0.14
= 0.832
Interpretation:
# config.yaml
project:
neutral_score: 0.5 # Score for missing/invalid data
criteria:
# Tree age (quantile-based normalization)
- name: age
weight: 1.0
normalizer_params:
q_low: 0.05
q_high: 0.95
clip: true
# Vitality stages (categorical mapping)
- name: vitality
weight: 2.0 # Higher importance
params:
exclude_stages: [4, 5]
stage0_score: 0.5
# Street pollution (OSM highway classification)
- name: street_type
weight: 1.0
# Spatial grouping (isolation risk)
- name: planting_structure
weight: 1.5
params:
radius_m: 15.0
mode: breaks
size_breaks: [1, 5, 10]
size_scores: [1.0, 0.66, 0.33, 0.0]
# Air temperature (raster sampling)
- name: air_temperature
weight: 2.0 # Higher importance
params:
threshold_method: spatial_percentile
low_percentile: 25
high_percentile: 75
inputs:
roads:
source: osm
allowed: ["motorway", "trunk", "primary", "secondary", "tertiary", "residential"]
osm_join:
max_distance: 10.0
airtemp_raster_14h:
type: tif
path: "./data/airtemp_14h.tif"
output:
format: gpkg
path: "./data/output/{run_date_underscore}_{scenario}/trees_at_risk.gpkg"
postgis:
enabled: true
schema: bgcki
table: analysis_tree_risk_scores
1. Equal weighting (exploratory analysis):
criteria:
- {name: age, weight: 1.0}
- {name: vitality, weight: 1.0}
- {name: street_type, weight: 1.0}
- {name: planting_structure, weight: 1.0}
- {name: air_temperature, weight: 1.0}
2. Expert-guided weighting (operational use):
criteria:
- {name: age, weight: 0.5} # Lower priority
- {name: vitality, weight: 2.0} # High priority (direct indicator)
- {name: street_type, weight: 1.0} # Medium priority
- {name: planting_structure, weight: 1.5} # Medium-high priority
- {name: air_temperature, weight: 2.0} # High priority (climate stress)
# Normalized: vitality=29%, temp=29%, planting=21%, street=14%, age=7%
3. Scenario-specific weighting (heatwave focus):
criteria:
- {name: age, weight: 0.3}
- {name: vitality, weight: 1.0}
- {name: street_type, weight: 0.5}
- {name: planting_structure, weight: 0.5}
- {name: air_temperature, weight: 5.0} # Dominant factor
# Normalized: temp=68%, vitality=14%, others <10%
1. GeoPackage (trees_at_risk.gpkg):
Layer: trees_at_risk_points
├── objectid (tree ID)
├── geometry (Point)
├── drought_risk_rulebased (final risk score 0-1)
│
├── Risk Scores (normalized 0-1):
├── age_risk
├── vitality_risk
├── street_type_risk
├── planting_structure_risk
├── air_temperature_risk
│
└── Raw Values (for interpretation):
├── age_value (years)
├── vitality_value (stage 0-4)
├── street_type_value (highway class)
├── planting_structure_value (group size)
└── air_temperature_value (°C)
2. PostGIS (bgcki.analysis_tree_risk_scores):
3. Reports (report/trees_at_risk_run_stats.json / report/trees_at_risk_run_report.md):
{
"run_metadata": {
"date": "2025-10-18",
"n_trees": 51063,
"n_criteria": 5
},
"criteria": {
"age": {
"min": 0.0,
"max": 1.0,
"mean": 0.52,
"weight": 0.2
},
"vitality": { ... },
...
},
"drought_risk_rulebased": {
"min": 0.15,
"max": 0.92,
"mean": 0.58,
"distribution": {
"0.0-0.3": 8123, # Low risk
"0.3-0.7": 31254, # Medium risk
"0.7-1.0": 11686 # High risk
}
}
}
Risk Score Ranges:
| Score | Risk Level | Trees (example) | Action |
|---|---|---|---|
| 0.0 - 0.3 | Low | 8,123 (16%) | Monitor, routine maintenance |
| 0.3 - 0.7 | Medium | 31,254 (61%) | Standard irrigation schedule |
| 0.7 - 1.0 | High | 11,686 (23%) | Priority irrigation, close monitoring |
Prioritization Example:
-- High-risk trees for immediate attention
SELECT objectid, drought_risk_rulebased,
vitality_risk, vitality_value,
street_type_risk, street_type_value,
air_temperature_risk, air_temperature_value
FROM bgcki.analysis_tree_risk_scores
WHERE drought_risk_rulebased >= 0.7
ORDER BY drought_risk_rulebased DESC
LIMIT 100;
Drill-Down Analysis:
import geopandas as gpd
# Load results
gdf = gpd.read_file("./data/output/<run_date>_<scenario>/trees_at_risk.gpkg")
# Identify trees with specific vulnerability patterns
high_temp_high_risk = gdf[
(gdf['air_temperature_risk'] > 0.8) &
(gdf['drought_risk_rulebased'] > 0.7)
]
print(f"Trees in hot microclimates: {len(high_temp_high_risk)}")
print(f"Average temperature: {high_temp_high_risk['air_temperature_value'].mean():.1f}°C")
# Compare contribution of different criteria
import matplotlib.pyplot as plt
criteria_names = ['age', 'vitality', 'street_type', 'planting_structure', 'air_temperature']
risk_cols = [f'{c}_risk' for c in criteria_names]
weights = [0.2, 0.2, 0.2, 0.2, 0.2] # Normalized weights
avg_scores = [gdf[c].mean() for c in risk_cols]
contributions = [s * w for s, w in zip(avg_scores, weights)]
plt.bar(criteria_names, contributions)
plt.ylabel('Average contribution to risk')
plt.title('Criterion Importance (actual contribution)')
plt.show()
QGIS Symbology:
trees_at_risk.gpkg in QGISdrought_risk_rulebased columnCause: Missing data or configuration errors
Solutions:
ctx.vector["trees"])Cause: Bug in weight normalization (should not happen with current implementation)
Solution: Verify weights are being normalized in risk_scoring.py:
wsum = sum(c["weight"] for c in ctx.config["criteria"]) or 1.0
Cause: Weight configuration doesn’t match expectations
Solutions:
Cause: Criterion failed to compute
Solutions:
criteria_catalog.pyCause: Database connection or schema issues
Solutions:
.envCREATE SCHEMA IF NOT EXISTS bgcki;config.yamlLast Updated: 2025-10-18
Version: 1.0
Maintainer: @wanderfunke