bluegreencity-ki

Planting Structure Criterion

Overview

The planting_structure criterion evaluates drought risk based on local tree density. Isolated trees face higher drought stress due to increased exposure, wind, and heat, while trees with many neighbors benefit from mutual shading, wind protection, and microclimate buffering. The criterion counts direct neighbors within a configurable radius and maps this neighbor count to risk scores.

The current implementation is a direct-neighbor-count approach, not a graph-based clustering or connected-components model. It measures local spatial embedding through the number of other trees within radius_m.

Where the LHH planting-site dataset (the “Baumumfeld” polygons) covers a tree, trees in the same polygon form one planting group (common pit). planting_structure_neighbor_count always stores the number of other trees used for scoring; planting_site_tree_count separately stores the inclusive group size. planting_structure_method records the method per tree.

Note: This criterion measures spatial isolation only. Below-ground root space quality is handled by the pit_size criterion (#93), and above-ground tree pit condition by the tree_disc criterion (#106).

Implementation

Location

How It Works

  1. Backend and geometry check: Requires a GeoPandas-compatible vector backend and tree geometries. If no suitable geometry is available, the criterion falls back to the neutral score defined in the project configuration.
  2. CRS validation and reprojection: Ensures a projected CRS in meters for distance calculations. If the tree layer uses a geographic CRS, it is reprojected to the configured project CRS or, by default, to EPSG:25832.
  3. Direct neighbor counting: For each tree, counts the number of other trees within radius_m. Candidate features are retrieved via the spatial index and then filtered by exact geometry distance.
  4. Planting-site group size (issue #194): Trees are spatially joined onto planting-site polygons (inputs.planting_sites in config, read fresh every run, never cached). For covered trees, the number of other trees in the same polygon replaces the radius-based count; inclusive group size is retained separately.
  5. Risk mapping: Maps the direct neighbor count to a risk score using either breaks or linear mode.
  6. Output generation: Returns the normalized risk score, planting_structure_neighbor_count, planting_site_tree_count, and planting_structure_method.

Process Diagram

flowchart TD
  A[Trees with geometry] --> B[Validate CRS and backend]
  B --> C[Count direct neighbors within radius_m]
  C --> D[Map neighbor count to risk score]
  D --> E[Write score and planting_structure_neighbor_count]

Why Density Instead of Connected Components?

The previous approach used connected components (graph-based clustering), which caused transitive chaining: street trees in rows formed giant clusters of 1,600+ trees because A↔B↔C↔…↔Z all ended up in one group. This gave 85% of trees the lowest possible risk score, which is not realistic.

The density-based approach counts only direct neighbors — no transitive chaining. A tree at the end of a row has fewer neighbors than one in the middle, which better reflects actual microclimate buffering.

Scoring Logic

The criterion supports two implemented mapping modes.

Mode 1: Breaks (Default)

In breaks mode, the direct neighbor count is assigned to one of four configurable classes.

criteria:
  - name: "planting_structure"
    weight: 0.2  # example; current calibrated weight in config/config.yaml
    params:
      radius_m: 25.0
      mode: "breaks"
      neighbor_breaks: [0, 4, 9]
      neighbor_scores: [0.8, 0.5, 0.3, 0.1]

With the current default configuration, the mapping is:

Higher neighbor counts therefore reduce the current drought-risk score.

Mode 2: Linear

In linear mode, risk decreases linearly with increasing neighbor count. The scaling range is controlled by neighbor_min and neighbor_max, either explicitly from the configuration or implicitly from the observed data.

Linear Mode Configuration

criteria:
  - name: "planting_structure"
    weight: 0.2  # example; current calibrated weight in config/config.yaml
    params:
      radius_m: 25.0
      mode: "linear"
      neighbor_min: 0              # Override auto-detection
      neighbor_max: 20             # Override auto-detection
The implementation computes an inverse linear scaling between `neighbor_min` and `neighbor_max`. Trees near the minimum receive high risk scores, while trees near the maximum receive low risk scores. If both values are identical, all trees receive a score of `1.0`.

Conservative Radius (Tight)

params:
  radius_m: 10.0                 # Only trees <10m apart count as neighbors

Use for dense urban settings where only very close trees provide microclimate benefits.

Liberal Radius (Wide)

params:
  radius_m: 30.0                 # Trees up to 30m apart count as neighbors

Use for park or forest settings where canopy overlap is significant.

Custom Neighbor Classes

params:
  mode: "breaks"
  neighbor_breaks: [0, 2, 6]       # Different breakpoints
  neighbor_scores: [1.0, 0.7, 0.4, 0.1]  # 4 classes

Parameters Reference

Parameter Type Default Description
radius_m float 25.0 Proximity radius for neighbor counting (meters)
mode string “breaks” Mapping mode: “breaks” or “linear”
neighbor_breaks list[int] [0, 4, 9] Neighbor count boundaries (breaks mode)
neighbor_scores list[float] [0.8, 0.5, 0.3, 0.1] Risk scores per class (breaks mode)
neighbor_min int auto Minimum neighbor count (linear mode)
neighbor_max int auto Maximum neighbor count (linear mode)

Data Requirements

Required Fields

CRS Handling

Geometry Validation

Output Metadata

The criterion returns metadata for quality assurance:

{
    "mode": "breaks",                # Mapping mode used
    "radius_m": 25.0,                # Proximity radius used
    "n_shared_planting_site_trees": 3120, # Trees assigned to a shared planting site (#194)
    "neighbor_breaks": [0, 4, 9],    # Breakpoints (breaks mode only)
    "neighbor_scores": [0.8, 0.5, 0.3, 0.1],  # Scores (breaks mode only)
    "neighbor_min": 0,               # Minimum neighbor count (linear mode only)
    "neighbor_max": 18               # Maximum neighbor count (linear mode only)
}

Only the fields relevant to the selected mode are populated. In breaks mode, the metadata records the applied breakpoints and class scores. In linear mode, it records the minimum and maximum neighbor count used for scaling.

Extras Output

The criterion returns auxiliary data in the extras dictionary:

extras = {
  "planting_structure_neighbor_count": pd.Series([0, 2, 0, ...], index=tree_ids),
  "planting_site_tree_count": pd.Series([None, 3, 1, ...], index=tree_ids),
  "planting_structure_method": pd.Series(["radius_neighbor_count", "shared_planting_site", ...], index=tree_ids),
}

planting_structure_neighbor_count is always the raw count of other trees used for scoring. planting_site_tree_count is the inclusive group size in a matched planting-site polygon and is NaN outside coverage. planting_structure_method is either "radius_neighbor_count" or "shared_planting_site". This allows downstream analysis to:

Using Extras in Output File

The extras are automatically included in the final GeoPackage output:

gdf_output = gdf.merge(result.extras['planting_structure_neighbor_count'], left_on='gisid', right_index=True)
gdf_output.to_file("output.gpkg", layer="trees_at_risk_points")

Integration with Other Criteria

The planting structure criterion complements:

Interaction Rationale

Group benefits for drought resilience:

Vulnerability of isolated trees:

Validation & Testing

Unit Tests

Run tests with:

conda activate bgcki_wesemann
python -m pytest tests/test_planting_structure.py -v

Tests cover:

Visual Validation

After running the pipeline, validate results by:

  1. Spatial density: Map trees colored by neighbor count
  2. Risk distribution: Plot histogram of planting_structure scores
  3. Neighbor count distribution: Histogram of neighbor counts
  4. Scatter plot: Neighbor count vs. risk score (should show expected mapping)

Example Validation Code

import geopandas as gpd
import matplotlib.pyplot as plt
import contextily as ctx

# Load results
gdf = gpd.read_file("./data/output/<study_area>/trees_at_risk_<area>.gpkg", layer="trees_at_risk_points")

# Create figure with subplots
fig, ax = plt.subplots(2, 2, figsize=(15, 15))

# 1. Map: Neighbor count
ax_map = ax[0, 0]
gdf.to_crs(epsg=3857).plot(
    column='planting_structure_neighbor_count',
    cmap='viridis',
    legend=True,
    ax=ax_map,
    markersize=5,
    alpha=0.7,
    vmin=0,
    vmax=20
)
ctx.add_basemap(ax_map, source=ctx.providers.OpenStreetMap.Mapnik)
ax_map.set_title('Neighbor Count (Local Density)')
ax_map.set_axis_off()

# 2. Map: Risk score
ax_risk = ax[0, 1]
gdf.to_crs(epsg=3857).plot(
    column='planting_structure_risk',
    cmap='RdYlGn_r',
    legend=True,
    ax=ax_risk,
    markersize=5,
    alpha=0.7
)
ctx.add_basemap(ax_risk, source=ctx.providers.OpenStreetMap.Mapnik)
ax_risk.set_title('Planting Structure Risk Score')
ax_risk.set_axis_off()

# 3. Neighbor count distribution
ax[1, 0].hist(gdf['planting_structure_neighbor_count'], bins=range(0, gdf['planting_structure_neighbor_count'].max()+2), alpha=0.7)
ax[1, 0].set_xlabel('Neighbor Count')
ax[1, 0].set_ylabel('Frequency')
ax[1, 0].set_title('Neighbor Count Distribution')
ax[1, 0].set_yscale('log')

# 4. Neighbor count vs risk score
ax[1, 1].scatter(gdf['planting_structure_neighbor_count'], gdf['planting_structure_risk'], alpha=0.3)
ax[1, 1].set_xlabel('Neighbor Count')
ax[1, 1].set_ylabel('Risk Score')
ax[1, 1].set_title('Neighbor Count vs. Risk Score')
ax[1, 1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Summary statistics
print("Neighbor Count Statistics:")
print(gdf['planting_structure_neighbor_count'].describe())
print("\nIsolated trees (0 neighbors):", (gdf['planting_structure_neighbor_count'] == 0).sum())
print("Trees with neighbors (>0):", (gdf['planting_structure_neighbor_count'] > 0).sum())
print("Max neighbor count:", gdf['planting_structure_neighbor_count'].max())

Known Limitations

  1. Static radius: A single radius value may not fit all contexts equally well.
  2. Euclidean distance: The current method uses direct geometric distance only and does not account for barriers such as buildings or roads.
  3. No canopy or height model: All neighbors are treated equally; crown diameter and tree height are not part of the current calculation.
  4. No temporal dynamics: Neighbor composition is evaluated as a static snapshot.
  5. No root interaction model: Spatial proximity is used as a proxy for microclimate buffering, not as direct evidence of below-ground interaction.

References

Changelog