bluegreencity-ki

Drought Risk Scoring Guide

Table of Contents


Overview

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.).

Key Concepts

  1. Individual Criteria: Each criterion (age, vitality, street_type, etc.) independently computes a risk score in [0, 1] for every tree
  2. Weights: Each criterion has a configured weight reflecting its importance in the overall risk assessment
  3. Normalization: Weights are normalized to sum to 1.0 to ensure the final score remains in [0, 1]
  4. Aggregation: The final risk score is the weighted sum of all criterion scores

Design Principles


Scoring Workflow

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       │
└─────────────────────────────────────────────────────────────────┘

Implementation

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

Individual Criteria Computation

Each criterion implements the Criterion protocol and computes a risk score for every tree.

Criterion Output Format

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:

Score Range and Interpretation

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:

Example Criteria

1. Age Criterion

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]

2. Vitality Criterion

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

3. Street Type Criterion

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)

4. Planting Structure Criterion

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

5. Air Temperature Criterion

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

Weight Normalization

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].

Normalization Formula

# 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 ✓

Why Normalize?

Weight Interpretation

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

Final Risk Aggregation

The final drought risk score is computed as a weighted sum of all criterion scores.

Aggregation Formula

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:

Worked Example

Configuration:

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:

Mathematical Properties

  1. Bounded output: Since all scores ∈ [0, 1] and weights sum to 1, the final score is always in [0, 1]
  2. Linear combination: The formula is a convex combination (weighted average)
  3. Sensitivity: A tree with high scores in all criteria will have high final risk; low scores → low risk
  4. Compensation: High risk in one criterion can be partially offset by low risk in others

Configuration

Complete Configuration Example

# 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

Weight Configuration Strategies

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%

Output and Interpretation

Output Files

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
    }
  }
}

Interpreting Results

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()

Visualizing Results

QGIS Symbology:

  1. Open trees_at_risk.gpkg in QGIS
  2. Symbolize by drought_risk_rulebased column
  3. Use graduated colors:
    • 0.0 - 0.3: Green (low risk)
    • 0.3 - 0.7: Yellow (medium risk)
    • 0.7 - 1.0: Red (high risk)
  4. Layer individual criteria to identify spatial patterns

Troubleshooting

Issue: All scores are 0.5 (neutral)

Cause: Missing data or configuration errors

Solutions:

  1. Check that input data is loaded correctly (ctx.vector["trees"])
  2. Verify column names match schema definitions
  3. Check logs for warnings about missing fields
  4. Ensure raster data is available for raster-based criteria

Issue: Final risk score exceeds 1.0

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

Issue: Unexpected risk patterns

Cause: Weight configuration doesn’t match expectations

Solutions:

  1. Check normalized weights in report metadata
  2. Review individual criterion scores to identify which criteria are driving risk
  3. Adjust weights in configuration to reflect intended priorities
  4. Use equal weights initially to understand baseline behavior

Issue: Missing criterion scores in output

Cause: Criterion failed to compute

Solutions:

  1. Check logs for error messages from specific criteria
  2. Verify required data sources are available
  3. Test criteria individually using unit tests
  4. Ensure all criteria are registered in criteria_catalog.py

Issue: PostGIS output errors

Cause: Database connection or schema issues

Solutions:

  1. Verify PostGIS credentials in .env
  2. Check that schema exists: CREATE SCHEMA IF NOT EXISTS bgcki;
  3. Review output configuration in config.yaml
  4. Check database logs for constraint violations

Core Documentation

Individual Criteria

Configuration & Operations

Development


Last Updated: 2025-10-18
Version: 1.0
Maintainer: @wanderfunke