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_sizecriterion (#93), and above-ground tree pit condition by thetree_disccriterion (#106).
bluegreencity_ki/criteria/planting_structure.pytests/test_planting_structure.pyradius_m. Candidate features are retrieved via the spatial index and then filtered by exact geometry distance.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.breaks or linear mode.planting_structure_neighbor_count, planting_site_tree_count, and planting_structure_method.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]
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.
The criterion supports two implemented mapping modes.
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:
0 neighbors -> 0.81 to 4 neighbors -> 0.55 to 9 neighbors -> 0.310 or more neighbors -> 0.1Higher neighbor counts therefore reduce the current drought-risk score.
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.
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`.
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.
params:
radius_m: 30.0 # Trees up to 30m apart count as neighbors
Use for park or forest settings where canopy overlap is significant.
params:
mode: "breaks"
neighbor_breaks: [0, 2, 6] # Different breakpoints
neighbor_scores: [1.0, 0.7, 0.4, 0.1] # 4 classes
| 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) |
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.
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:
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")
The planting structure criterion complements:
Group benefits for drought resilience:
Vulnerability of isolated trees:
Run tests with:
conda activate bgcki_wesemann
python -m pytest tests/test_planting_structure.py -v
Tests cover:
planting_structure_neighbor_count, planting_site_tree_count, and planting_structure_method)After running the pipeline, validate results by:
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())
size_breaks/size_scores → neighbor_breaks/neighbor_scores.planting_sites; it separates the scoring basis (planting_structure_neighbor_count) from the inclusive planting-site group size (planting_site_tree_count).