greenrule 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- greenrule/__init__.py +106 -0
- greenrule/__main__.py +7 -0
- greenrule/_common.py +79 -0
- greenrule/_version.py +1 -0
- greenrule/analysis.py +176 -0
- greenrule/cli.py +318 -0
- greenrule/equity.py +148 -0
- greenrule/exposure.py +90 -0
- greenrule/geometry.py +245 -0
- greenrule/green.py +188 -0
- greenrule/io.py +196 -0
- greenrule/network.py +179 -0
- greenrule/provision.py +113 -0
- greenrule/py.typed +0 -0
- greenrule/raster.py +149 -0
- greenrule/rule333.py +301 -0
- greenrule/who.py +142 -0
- greenrule-0.1.0.dist-info/METADATA +230 -0
- greenrule-0.1.0.dist-info/RECORD +23 -0
- greenrule-0.1.0.dist-info/WHEEL +5 -0
- greenrule-0.1.0.dist-info/entry_points.txt +2 -0
- greenrule-0.1.0.dist-info/licenses/LICENSE +21 -0
- greenrule-0.1.0.dist-info/top_level.txt +1 -0
greenrule/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""greenrule: urban green space provision and exposure indicators.
|
|
2
|
+
|
|
3
|
+
The 3-30-300 rule (Konijnendijk 2023) per home, the WHO Regional Office for Europe (2016)
|
|
4
|
+
Urban Green Space Indicator (300 m linear distance, 0.5 ha core / 1 ha additional), green
|
|
5
|
+
space per capita, NDVI buffer exposure and equity summaries (Gini, population shares).
|
|
6
|
+
Network distances use a small built-in Dijkstra on GeoJSON lines with a Euclidean
|
|
7
|
+
fallback. Pure NumPy; coordinates in projected metres.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._common import M2_PER_HA, GreenError
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .analysis import Assessment, assess
|
|
13
|
+
from .equity import equity_summary, gini, lorenz_curve, share_meeting
|
|
14
|
+
from .exposure import Exposure, ndvi_exposure
|
|
15
|
+
from .geometry import Area, densify_boundary, sampled_overlap
|
|
16
|
+
from .green import GreenDistance, GreenSpaces, euclidean_distance, network_distance
|
|
17
|
+
from .io import (
|
|
18
|
+
PointLayer,
|
|
19
|
+
areas_from_geojson,
|
|
20
|
+
points_from_csv,
|
|
21
|
+
points_from_geojson,
|
|
22
|
+
points_to_geojson,
|
|
23
|
+
read_geojson,
|
|
24
|
+
read_points,
|
|
25
|
+
write_csv,
|
|
26
|
+
write_json,
|
|
27
|
+
)
|
|
28
|
+
from .network import Network
|
|
29
|
+
from .provision import Provision, green_provision
|
|
30
|
+
from .raster import Raster, ndvi
|
|
31
|
+
from .rule333 import (
|
|
32
|
+
MAX_DISTANCE_M,
|
|
33
|
+
MIN_CANOPY,
|
|
34
|
+
MIN_TREES,
|
|
35
|
+
Rule333Result,
|
|
36
|
+
assign_zones,
|
|
37
|
+
canopy_share_buffers,
|
|
38
|
+
canopy_share_zones,
|
|
39
|
+
evaluate_3_30_300,
|
|
40
|
+
trees_within_radius,
|
|
41
|
+
visible_tree_counts,
|
|
42
|
+
)
|
|
43
|
+
from .who import (
|
|
44
|
+
WHO_ADDITIONAL_MIN_AREA_HA,
|
|
45
|
+
WHO_CORE_MIN_AREA_HA,
|
|
46
|
+
WHO_DISTANCE_M,
|
|
47
|
+
ProximityResult,
|
|
48
|
+
who_proximity,
|
|
49
|
+
who_proximity_both,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"__version__",
|
|
54
|
+
"GreenError",
|
|
55
|
+
"M2_PER_HA",
|
|
56
|
+
# geometry and green spaces
|
|
57
|
+
"Area",
|
|
58
|
+
"GreenSpaces",
|
|
59
|
+
"GreenDistance",
|
|
60
|
+
"euclidean_distance",
|
|
61
|
+
"network_distance",
|
|
62
|
+
"densify_boundary",
|
|
63
|
+
"sampled_overlap",
|
|
64
|
+
"Network",
|
|
65
|
+
# 3-30-300
|
|
66
|
+
"MIN_TREES",
|
|
67
|
+
"MIN_CANOPY",
|
|
68
|
+
"MAX_DISTANCE_M",
|
|
69
|
+
"visible_tree_counts",
|
|
70
|
+
"trees_within_radius",
|
|
71
|
+
"canopy_share_zones",
|
|
72
|
+
"canopy_share_buffers",
|
|
73
|
+
"assign_zones",
|
|
74
|
+
"evaluate_3_30_300",
|
|
75
|
+
"Rule333Result",
|
|
76
|
+
# WHO
|
|
77
|
+
"WHO_DISTANCE_M",
|
|
78
|
+
"WHO_CORE_MIN_AREA_HA",
|
|
79
|
+
"WHO_ADDITIONAL_MIN_AREA_HA",
|
|
80
|
+
"who_proximity",
|
|
81
|
+
"who_proximity_both",
|
|
82
|
+
"ProximityResult",
|
|
83
|
+
# provision, exposure, equity
|
|
84
|
+
"green_provision",
|
|
85
|
+
"Provision",
|
|
86
|
+
"Raster",
|
|
87
|
+
"ndvi",
|
|
88
|
+
"ndvi_exposure",
|
|
89
|
+
"Exposure",
|
|
90
|
+
"gini",
|
|
91
|
+
"lorenz_curve",
|
|
92
|
+
"share_meeting",
|
|
93
|
+
"equity_summary",
|
|
94
|
+
# analysis and I/O
|
|
95
|
+
"assess",
|
|
96
|
+
"Assessment",
|
|
97
|
+
"read_geojson",
|
|
98
|
+
"read_points",
|
|
99
|
+
"PointLayer",
|
|
100
|
+
"points_from_geojson",
|
|
101
|
+
"points_from_csv",
|
|
102
|
+
"points_to_geojson",
|
|
103
|
+
"areas_from_geojson",
|
|
104
|
+
"write_csv",
|
|
105
|
+
"write_json",
|
|
106
|
+
]
|
greenrule/__main__.py
ADDED
greenrule/_common.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shared errors, coercion helpers and JSON cleaning.
|
|
2
|
+
|
|
3
|
+
Coordinates are planar (projected) in metres: distances, areas (m2) and hectares assume
|
|
4
|
+
metre units. No reprojection is done.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
FloatArray = NDArray[np.float64]
|
|
15
|
+
BoolArray = NDArray[np.bool_]
|
|
16
|
+
|
|
17
|
+
#: Square metres per hectare.
|
|
18
|
+
M2_PER_HA = 10_000.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GreenError(ValueError):
|
|
22
|
+
"""Raised for invalid inputs or inconsistent parameters."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def as_points(coords: Any, what: str = "points") -> FloatArray:
|
|
26
|
+
"""Return ``coords`` as an ``(n, 2)`` float array (a single pair gives ``(1, 2)``)."""
|
|
27
|
+
try:
|
|
28
|
+
arr = np.asarray(coords, dtype=float)
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
raise GreenError(f"{what}: coordinates are not numeric") from None
|
|
31
|
+
if arr.ndim == 1 and arr.size >= 2:
|
|
32
|
+
arr = arr.reshape(1, -1)
|
|
33
|
+
if arr.ndim != 2 or arr.shape[1] < 2:
|
|
34
|
+
raise GreenError(f"{what}: expected [x, y] pairs")
|
|
35
|
+
arr = np.ascontiguousarray(arr[:, :2])
|
|
36
|
+
if not np.all(np.isfinite(arr)):
|
|
37
|
+
raise GreenError(f"{what}: coordinates contain NaN or infinity")
|
|
38
|
+
return arr
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def as_weights(values: Any, n: int, what: str = "population") -> FloatArray:
|
|
42
|
+
"""Non-negative weights of length ``n``; ``None`` gives ones."""
|
|
43
|
+
if values is None:
|
|
44
|
+
return np.ones(n)
|
|
45
|
+
w = np.asarray(values, dtype=float).ravel()
|
|
46
|
+
if len(w) != n:
|
|
47
|
+
raise GreenError(f"{what}: expected {n} values, got {len(w)}")
|
|
48
|
+
if not np.all(np.isfinite(w)) or np.any(w < 0):
|
|
49
|
+
raise GreenError(f"{what}: values must be finite and >= 0")
|
|
50
|
+
return w
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_positive(value: Any, name: str, allow_zero: bool = False) -> float:
|
|
54
|
+
try:
|
|
55
|
+
v = float(value)
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
raise GreenError(f"{name} must be a number, got {value!r}") from None
|
|
58
|
+
if not np.isfinite(v) or v < 0 or (v == 0 and not allow_zero):
|
|
59
|
+
cond = ">= 0" if allow_zero else "> 0"
|
|
60
|
+
raise GreenError(f"{name} must be finite and {cond}, got {value!r}")
|
|
61
|
+
return v
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def clean(obj: Any) -> Any:
|
|
65
|
+
"""Recursively convert numpy types and non-finite floats to JSON-safe values."""
|
|
66
|
+
if isinstance(obj, dict):
|
|
67
|
+
return {str(k): clean(v) for k, v in obj.items()}
|
|
68
|
+
if isinstance(obj, (list, tuple)):
|
|
69
|
+
return [clean(v) for v in obj]
|
|
70
|
+
if isinstance(obj, np.ndarray):
|
|
71
|
+
return clean(obj.tolist())
|
|
72
|
+
if isinstance(obj, np.bool_):
|
|
73
|
+
return bool(obj)
|
|
74
|
+
if isinstance(obj, np.integer):
|
|
75
|
+
return int(obj)
|
|
76
|
+
if isinstance(obj, (float, np.floating)):
|
|
77
|
+
f = float(obj)
|
|
78
|
+
return f if np.isfinite(f) else None
|
|
79
|
+
return obj
|
greenrule/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
greenrule/analysis.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""One-call assessment of homes: 3-30-300, WHO proximity, NDVI exposure and equity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ._common import GreenError, as_points, as_weights
|
|
11
|
+
from .equity import equity_summary
|
|
12
|
+
from .exposure import Exposure, ndvi_exposure
|
|
13
|
+
from .geometry import Area
|
|
14
|
+
from .green import GreenDistance, GreenSpaces, euclidean_distance, network_distance
|
|
15
|
+
from .network import Network
|
|
16
|
+
from .raster import Raster
|
|
17
|
+
from .rule333 import (
|
|
18
|
+
CanopySource,
|
|
19
|
+
Rule333Result,
|
|
20
|
+
assign_zones,
|
|
21
|
+
canopy_share_buffers,
|
|
22
|
+
canopy_share_zones,
|
|
23
|
+
evaluate_3_30_300,
|
|
24
|
+
trees_within_radius,
|
|
25
|
+
visible_tree_counts,
|
|
26
|
+
)
|
|
27
|
+
from .who import WHO_CORE_MIN_AREA_HA, ProximityResult, who_proximity
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Assessment:
|
|
32
|
+
"""Per-home rows and summaries from :func:`assess`."""
|
|
33
|
+
|
|
34
|
+
rule: Optional[Rule333Result] = None
|
|
35
|
+
who: Optional[ProximityResult] = None
|
|
36
|
+
exposure: Optional[Exposure] = None
|
|
37
|
+
equity: Optional[Dict[str, Any]] = None
|
|
38
|
+
n_homes: int = 0
|
|
39
|
+
notes: List[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def to_rows(self) -> List[Dict[str, Any]]:
|
|
42
|
+
rows: List[Dict[str, Any]] = [{} for _ in range(self.n_homes)]
|
|
43
|
+
if self.rule is not None:
|
|
44
|
+
for r, x in zip(rows, self.rule.to_rows()):
|
|
45
|
+
r.update(x)
|
|
46
|
+
if self.who is not None:
|
|
47
|
+
for r, m, d in zip(rows, self.who.meets, self.who.distance.distance):
|
|
48
|
+
r["who_distance"] = float(d)
|
|
49
|
+
r["who_proximity"] = bool(m)
|
|
50
|
+
if self.exposure is not None:
|
|
51
|
+
for r, x in zip(rows, self.exposure.to_rows()):
|
|
52
|
+
r.update(x)
|
|
53
|
+
return rows
|
|
54
|
+
|
|
55
|
+
def summary(self) -> Dict[str, Any]:
|
|
56
|
+
out: Dict[str, Any] = {"homes": self.n_homes, "notes": list(self.notes)}
|
|
57
|
+
if self.rule is not None:
|
|
58
|
+
out["rule_3_30_300"] = self.rule.summary()
|
|
59
|
+
if self.who is not None:
|
|
60
|
+
out["who_2016"] = self.who.summary()
|
|
61
|
+
if self.exposure is not None:
|
|
62
|
+
m = self.exposure.mean
|
|
63
|
+
out["ndvi"] = {
|
|
64
|
+
"radius_m": self.exposure.radius,
|
|
65
|
+
"mean_of_means": float(np.nanmean(m)) if np.isfinite(m).any() else None,
|
|
66
|
+
"threshold": self.exposure.threshold,
|
|
67
|
+
}
|
|
68
|
+
if self.equity is not None:
|
|
69
|
+
out["equity"] = self.equity
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def assess(
|
|
74
|
+
homes: Any,
|
|
75
|
+
greens: GreenSpaces,
|
|
76
|
+
population: Any = None,
|
|
77
|
+
*,
|
|
78
|
+
tree_counts: Optional[Sequence[float]] = None,
|
|
79
|
+
visible_pairs: Optional[Iterable[Tuple[int, Any]]] = None,
|
|
80
|
+
tree_points: Any = None,
|
|
81
|
+
tree_radius: Optional[float] = None,
|
|
82
|
+
canopy: Optional[CanopySource] = None,
|
|
83
|
+
canopy_radius: Optional[float] = None,
|
|
84
|
+
neighbourhoods: Optional[Sequence[Area]] = None,
|
|
85
|
+
canopy_mode: str = "binary",
|
|
86
|
+
canopy_resolution: Optional[float] = None,
|
|
87
|
+
min_green_area_ha: Optional[float] = None,
|
|
88
|
+
network: Optional[Network] = None,
|
|
89
|
+
max_snap: float = 100.0,
|
|
90
|
+
entrance_spacing: float = 25.0,
|
|
91
|
+
who_min_area_ha: Optional[float] = WHO_CORE_MIN_AREA_HA,
|
|
92
|
+
ndvi: Optional[Raster] = None,
|
|
93
|
+
ndvi_radius: Optional[float] = None,
|
|
94
|
+
ndvi_threshold: Optional[float] = None,
|
|
95
|
+
zones: Optional[Sequence[Any]] = None,
|
|
96
|
+
) -> Assessment:
|
|
97
|
+
"""Assess every home.
|
|
98
|
+
|
|
99
|
+
The 3-30-300 rule is evaluated when tree and canopy inputs are given (see
|
|
100
|
+
:mod:`greenrule.rule333` for the options); the WHO Europe (2016) indicator unless
|
|
101
|
+
``who_min_area_ha`` is None; NDVI exposure when ``ndvi`` and ``ndvi_radius`` are given;
|
|
102
|
+
equity summaries over the computed criteria when ``zones`` (one label per home) are
|
|
103
|
+
given. ``network`` switches the 300 m criterion to network distance (Euclidean
|
|
104
|
+
fallback for homes without a connection); the WHO indicator always uses linear
|
|
105
|
+
distance, as defined.
|
|
106
|
+
"""
|
|
107
|
+
pts = as_points(homes, "homes")
|
|
108
|
+
n = len(pts)
|
|
109
|
+
w = as_weights(population, n)
|
|
110
|
+
res = Assessment(n_homes=n)
|
|
111
|
+
|
|
112
|
+
trees = None
|
|
113
|
+
trees_method = "visibility"
|
|
114
|
+
if tree_counts is not None or visible_pairs is not None:
|
|
115
|
+
trees = visible_tree_counts(n, counts=tree_counts, pairs=visible_pairs)
|
|
116
|
+
elif tree_points is not None:
|
|
117
|
+
if tree_radius is None:
|
|
118
|
+
raise GreenError("tree_points need tree_radius")
|
|
119
|
+
trees = trees_within_radius(pts, tree_points, tree_radius)
|
|
120
|
+
trees_method = f"proxy: trees within {tree_radius:g} m"
|
|
121
|
+
res.notes.append("tree counts are a proximity proxy, not visibility")
|
|
122
|
+
|
|
123
|
+
canopy_share = None
|
|
124
|
+
if canopy is not None:
|
|
125
|
+
if neighbourhoods is not None:
|
|
126
|
+
zl = list(neighbourhoods)
|
|
127
|
+
shares = canopy_share_zones(zl, canopy, mode=canopy_mode, resolution=canopy_resolution)
|
|
128
|
+
idx = assign_zones(pts, zl)
|
|
129
|
+
canopy_share = np.where(idx >= 0, shares[np.maximum(idx, 0)], np.nan)
|
|
130
|
+
if (idx < 0).any():
|
|
131
|
+
res.notes.append(f"{int((idx < 0).sum())} homes outside every neighbourhood")
|
|
132
|
+
elif canopy_radius is not None:
|
|
133
|
+
if not isinstance(canopy, Raster):
|
|
134
|
+
raise GreenError("buffer neighbourhoods need a canopy Raster")
|
|
135
|
+
canopy_share = canopy_share_buffers(pts, canopy, canopy_radius, canopy_mode)
|
|
136
|
+
else:
|
|
137
|
+
raise GreenError("canopy needs neighbourhoods or canopy_radius")
|
|
138
|
+
|
|
139
|
+
if trees is not None and canopy_share is not None:
|
|
140
|
+
qualifying = greens.filter(min_green_area_ha)
|
|
141
|
+
dist: GreenDistance
|
|
142
|
+
if network is not None:
|
|
143
|
+
dist = network_distance(pts, qualifying, network, max_snap, entrance_spacing)
|
|
144
|
+
elif len(qualifying):
|
|
145
|
+
dist = euclidean_distance(pts, qualifying)
|
|
146
|
+
else:
|
|
147
|
+
dist = GreenDistance(np.full(n, np.inf), [None] * n, ["euclidean"] * n)
|
|
148
|
+
res.rule = evaluate_3_30_300(trees, canopy_share, dist, w, trees_method=trees_method)
|
|
149
|
+
elif trees is not None or canopy_share is not None:
|
|
150
|
+
raise GreenError("the 3-30-300 rule needs both tree and canopy inputs")
|
|
151
|
+
|
|
152
|
+
if who_min_area_ha is not None:
|
|
153
|
+
res.who = who_proximity(pts, greens, who_min_area_ha, population=w)
|
|
154
|
+
|
|
155
|
+
if ndvi is not None:
|
|
156
|
+
if ndvi_radius is None:
|
|
157
|
+
raise GreenError("ndvi needs ndvi_radius")
|
|
158
|
+
res.exposure = ndvi_exposure(pts, ndvi, ndvi_radius, threshold=ndvi_threshold)
|
|
159
|
+
|
|
160
|
+
if zones is not None:
|
|
161
|
+
crit: Dict[str, Any] = {}
|
|
162
|
+
if res.rule is not None:
|
|
163
|
+
crit.update(
|
|
164
|
+
{
|
|
165
|
+
"3_trees": res.rule.meets_3,
|
|
166
|
+
"30_canopy": res.rule.meets_30,
|
|
167
|
+
"300_distance": res.rule.meets_300,
|
|
168
|
+
"3_30_300": res.rule.meets_all,
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
if res.who is not None:
|
|
172
|
+
crit["who_2016"] = res.who.meets
|
|
173
|
+
if crit:
|
|
174
|
+
value = res.exposure.mean if res.exposure is not None else None
|
|
175
|
+
res.equity = equity_summary(crit, w, list(zones), value=value)
|
|
176
|
+
return res
|
greenrule/cli.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Command-line interface: ``greenrule who | rule | exposure | provision | equity``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ._common import GreenError
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .equity import equity_summary, gini
|
|
14
|
+
from .green import GreenSpaces
|
|
15
|
+
from .io import (
|
|
16
|
+
PointLayer,
|
|
17
|
+
areas_from_geojson,
|
|
18
|
+
points_to_geojson,
|
|
19
|
+
read_geojson,
|
|
20
|
+
read_points,
|
|
21
|
+
read_table,
|
|
22
|
+
write_csv,
|
|
23
|
+
write_json,
|
|
24
|
+
)
|
|
25
|
+
from .network import Network
|
|
26
|
+
from .provision import green_provision
|
|
27
|
+
from .raster import Raster
|
|
28
|
+
|
|
29
|
+
EXIT_OK = 0
|
|
30
|
+
EXIT_ERROR = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _transform(text: Optional[str]) -> Tuple[float, float, float, float]:
|
|
34
|
+
if not text:
|
|
35
|
+
raise GreenError("--transform x0,y0_top,dx,dy is required with a .npy raster")
|
|
36
|
+
try:
|
|
37
|
+
vals = [float(v) for v in text.split(",")]
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise GreenError(f"bad --transform {text!r}") from None
|
|
40
|
+
if len(vals) != 4:
|
|
41
|
+
raise GreenError("--transform needs four numbers: x0,y0_top,dx,dy")
|
|
42
|
+
return vals[0], vals[1], vals[2], vals[3]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _homes(args: argparse.Namespace) -> Tuple[PointLayer, Optional[np.ndarray]]:
|
|
46
|
+
layer = read_points(args.homes)
|
|
47
|
+
pop = layer.column(args.population_field) if args.population_field else None
|
|
48
|
+
return layer, pop
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _greens(path: str, id_field: Optional[str]) -> GreenSpaces:
|
|
52
|
+
return GreenSpaces.from_geojson(read_geojson(path), id_field=id_field)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _finish(
|
|
56
|
+
args: argparse.Namespace, layer: PointLayer, rows: List[Dict[str, Any]], summary: Any
|
|
57
|
+
) -> None:
|
|
58
|
+
if args.output:
|
|
59
|
+
write_json(points_to_geojson(layer, rows), args.output)
|
|
60
|
+
if args.csv:
|
|
61
|
+
write_csv(
|
|
62
|
+
[{"id": i, **p, **r} for i, p, r in zip(layer.ids, layer.properties, rows)], args.csv
|
|
63
|
+
)
|
|
64
|
+
text = write_json(summary, args.summary)
|
|
65
|
+
if not args.summary:
|
|
66
|
+
sys.stdout.write(text + "\n")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cmd_who(args: argparse.Namespace) -> int:
|
|
70
|
+
from .who import who_proximity, who_proximity_both
|
|
71
|
+
|
|
72
|
+
layer, pop = _homes(args)
|
|
73
|
+
greens = _greens(args.greens, args.green_id)
|
|
74
|
+
if args.both:
|
|
75
|
+
both = who_proximity_both(layer.xy, greens, args.distance, pop)
|
|
76
|
+
rows: List[Dict[str, Any]] = [{} for _ in range(len(layer))]
|
|
77
|
+
for key, res in both.items():
|
|
78
|
+
for r, d, m in zip(rows, res.distance.distance, res.meets):
|
|
79
|
+
r[f"distance_{key}"] = float(d)
|
|
80
|
+
r[f"meets_{key}"] = bool(m)
|
|
81
|
+
_finish(args, layer, rows, {k: v.summary() for k, v in both.items()})
|
|
82
|
+
return EXIT_OK
|
|
83
|
+
res = who_proximity(layer.xy, greens, args.min_area_ha, args.distance, pop)
|
|
84
|
+
_finish(args, layer, res.to_rows(), res.summary())
|
|
85
|
+
return EXIT_OK
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_rule(args: argparse.Namespace) -> int:
|
|
89
|
+
from .analysis import assess
|
|
90
|
+
|
|
91
|
+
layer, pop = _homes(args)
|
|
92
|
+
greens = _greens(args.greens, args.green_id)
|
|
93
|
+
kw: Dict[str, Any] = {}
|
|
94
|
+
if args.trees_field:
|
|
95
|
+
kw["tree_counts"] = layer.column(args.trees_field)
|
|
96
|
+
elif args.trees:
|
|
97
|
+
if args.tree_radius is None:
|
|
98
|
+
raise GreenError("--trees needs --tree-radius (proximity proxy)")
|
|
99
|
+
kw["tree_points"] = read_points(args.trees).xy
|
|
100
|
+
kw["tree_radius"] = args.tree_radius
|
|
101
|
+
else:
|
|
102
|
+
raise GreenError("give --trees-field (visible-tree counts) or --trees + --tree-radius")
|
|
103
|
+
if args.canopy_field:
|
|
104
|
+
if not args.neighbourhoods:
|
|
105
|
+
raise GreenError("--canopy-field needs --neighbourhoods")
|
|
106
|
+
zones = areas_from_geojson(args.neighbourhoods, args.neighbourhood_id)
|
|
107
|
+
kw["neighbourhoods"] = zones
|
|
108
|
+
kw["canopy"] = {z.fid: float(z.properties[args.canopy_field]) for z in zones}
|
|
109
|
+
elif args.canopy_polygons:
|
|
110
|
+
if not args.neighbourhoods:
|
|
111
|
+
raise GreenError("--canopy-polygons needs --neighbourhoods")
|
|
112
|
+
kw["neighbourhoods"] = areas_from_geojson(args.neighbourhoods, args.neighbourhood_id)
|
|
113
|
+
kw["canopy"] = areas_from_geojson(args.canopy_polygons)
|
|
114
|
+
kw["canopy_resolution"] = args.resolution
|
|
115
|
+
elif args.canopy:
|
|
116
|
+
kw["canopy"] = Raster.from_npy(args.canopy, _transform(args.transform), args.nodata)
|
|
117
|
+
kw["canopy_mode"] = args.canopy_mode
|
|
118
|
+
if args.neighbourhoods:
|
|
119
|
+
kw["neighbourhoods"] = areas_from_geojson(args.neighbourhoods, args.neighbourhood_id)
|
|
120
|
+
else:
|
|
121
|
+
kw["canopy_radius"] = args.canopy_radius
|
|
122
|
+
else:
|
|
123
|
+
raise GreenError("give --canopy RASTER.npy, --canopy-polygons or --canopy-field")
|
|
124
|
+
if args.network:
|
|
125
|
+
kw["network"] = Network([read_geojson(args.network)], node_tolerance=args.node_tolerance)
|
|
126
|
+
kw["max_snap"] = args.max_snap
|
|
127
|
+
kw["entrance_spacing"] = args.entrance_spacing
|
|
128
|
+
zones_labels = layer.labels(args.zone_field) if args.zone_field else None
|
|
129
|
+
res = assess(
|
|
130
|
+
layer.xy,
|
|
131
|
+
greens,
|
|
132
|
+
pop,
|
|
133
|
+
min_green_area_ha=args.min_area_ha,
|
|
134
|
+
who_min_area_ha=None,
|
|
135
|
+
zones=zones_labels,
|
|
136
|
+
**kw,
|
|
137
|
+
)
|
|
138
|
+
_finish(args, layer, res.to_rows(), res.summary())
|
|
139
|
+
return EXIT_OK
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _cmd_exposure(args: argparse.Namespace) -> int:
|
|
143
|
+
from .exposure import ndvi_exposure
|
|
144
|
+
|
|
145
|
+
layer, _ = _homes(args)
|
|
146
|
+
ras = Raster.from_npy(args.ndvi, _transform(args.transform), args.nodata)
|
|
147
|
+
res = ndvi_exposure(layer.xy, ras, args.radius, threshold=args.threshold)
|
|
148
|
+
m = res.mean[np.isfinite(res.mean)]
|
|
149
|
+
summary = {
|
|
150
|
+
"radius_m": args.radius,
|
|
151
|
+
"homes": len(layer),
|
|
152
|
+
"ndvi_mean_of_means": float(m.mean()) if len(m) else None,
|
|
153
|
+
"homes_without_data": int((~np.isfinite(res.mean)).sum()),
|
|
154
|
+
}
|
|
155
|
+
_finish(args, layer, res.to_rows(), summary)
|
|
156
|
+
return EXIT_OK
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _cmd_provision(args: argparse.Namespace) -> int:
|
|
160
|
+
zones = areas_from_geojson(args.zones, args.zone_id)
|
|
161
|
+
pop = []
|
|
162
|
+
for z in zones:
|
|
163
|
+
v = z.properties.get(args.population_field)
|
|
164
|
+
if v is None:
|
|
165
|
+
raise GreenError(f"zone {z.fid!r} has no {args.population_field!r}")
|
|
166
|
+
pop.append(float(v))
|
|
167
|
+
greens = _greens(args.greens, args.green_id)
|
|
168
|
+
prov = green_provision(zones, greens, pop, method=args.method, resolution=args.resolution)
|
|
169
|
+
rows = prov.to_rows()
|
|
170
|
+
if args.csv:
|
|
171
|
+
write_csv(rows, args.csv)
|
|
172
|
+
pc = prov.per_capita
|
|
173
|
+
ok = np.isfinite(pc)
|
|
174
|
+
summary: Dict[str, Any] = {"total": prov.total(), "zones": rows, "method": args.method}
|
|
175
|
+
if ok.sum() > 0 and pc[ok].sum() > 0:
|
|
176
|
+
summary["gini_m2_per_capita"] = gini(pc[ok], prov.population[ok])
|
|
177
|
+
text = write_json(summary, args.output)
|
|
178
|
+
if not args.output:
|
|
179
|
+
sys.stdout.write(text + "\n")
|
|
180
|
+
return EXIT_OK
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _truthy(v: str) -> bool:
|
|
184
|
+
s = str(v).strip().lower()
|
|
185
|
+
if s in ("1", "true", "yes", "y", "t"):
|
|
186
|
+
return True
|
|
187
|
+
if s in ("0", "false", "no", "n", "f", ""):
|
|
188
|
+
return False
|
|
189
|
+
raise GreenError(f"not a boolean: {v!r}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _cmd_equity(args: argparse.Namespace) -> int:
|
|
193
|
+
rows = read_table(args.table)
|
|
194
|
+
if not rows:
|
|
195
|
+
raise GreenError("empty table")
|
|
196
|
+
names = [c.strip() for c in (args.criteria or "").split(",") if c.strip()]
|
|
197
|
+
for col in [*names, args.population, args.zone, args.value]:
|
|
198
|
+
if col and col not in rows[0]:
|
|
199
|
+
raise GreenError(f"column {col!r} not found")
|
|
200
|
+
pop = [float(r[args.population]) for r in rows] if args.population else None
|
|
201
|
+
zones = [r[args.zone] for r in rows] if args.zone else None
|
|
202
|
+
if names:
|
|
203
|
+
crit = {c: [_truthy(r[c]) for r in rows] for c in names}
|
|
204
|
+
value = [float(r[args.value]) for r in rows] if args.value else None
|
|
205
|
+
result = equity_summary(crit, pop, zones, value=value)
|
|
206
|
+
elif args.value:
|
|
207
|
+
result = {"value_gini": gini([float(r[args.value]) for r in rows], pop)}
|
|
208
|
+
else:
|
|
209
|
+
raise GreenError("give --criteria and/or --value")
|
|
210
|
+
if args.csv and "zones" in result:
|
|
211
|
+
write_csv(result["zones"], args.csv)
|
|
212
|
+
text = write_json(result, args.output)
|
|
213
|
+
if not args.output:
|
|
214
|
+
sys.stdout.write(text + "\n")
|
|
215
|
+
return EXIT_OK
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _io_opts(p: argparse.ArgumentParser) -> None:
|
|
219
|
+
p.add_argument("-o", "--output", help="per-home GeoJSON output")
|
|
220
|
+
p.add_argument("--csv", help="per-home CSV output")
|
|
221
|
+
p.add_argument("--summary", help="write the summary JSON here (default: stdout)")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _homes_opts(p: argparse.ArgumentParser) -> None:
|
|
225
|
+
p.add_argument("homes", help="homes: GeoJSON points/buildings or CSV with x,y")
|
|
226
|
+
p.add_argument("--population-field", help="residents per home (attribute/column)")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
230
|
+
parser = argparse.ArgumentParser(
|
|
231
|
+
prog="greenrule",
|
|
232
|
+
description="Urban green space provision and exposure indicators (projected metres).",
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
235
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
236
|
+
|
|
237
|
+
p = sub.add_parser("who", help="WHO Europe (2016) 300 m green space indicator")
|
|
238
|
+
_homes_opts(p)
|
|
239
|
+
p.add_argument("greens", help="green space polygons (GeoJSON)")
|
|
240
|
+
p.add_argument("--green-id", help="green space id attribute")
|
|
241
|
+
p.add_argument("--min-area-ha", type=float, default=0.5, help="0.5 (core, default) or 1")
|
|
242
|
+
p.add_argument("--both", action="store_true", help="report the 0.5 ha and 1 ha variants")
|
|
243
|
+
p.add_argument("--distance", type=float, default=300.0, help="linear distance (m)")
|
|
244
|
+
_io_opts(p)
|
|
245
|
+
p.set_defaults(func=_cmd_who)
|
|
246
|
+
|
|
247
|
+
p = sub.add_parser("rule", help="3-30-300 rule per home")
|
|
248
|
+
_homes_opts(p)
|
|
249
|
+
p.add_argument("greens")
|
|
250
|
+
p.add_argument("--green-id")
|
|
251
|
+
p.add_argument("--trees-field", help="visible-tree count attribute of the homes")
|
|
252
|
+
p.add_argument("--trees", help="tree points (proximity proxy, needs --tree-radius)")
|
|
253
|
+
p.add_argument("--tree-radius", type=float)
|
|
254
|
+
p.add_argument("--canopy", help="canopy raster (.npy)")
|
|
255
|
+
p.add_argument("--transform", help="x0,y0_top,dx,dy of the .npy raster")
|
|
256
|
+
p.add_argument("--nodata", type=float)
|
|
257
|
+
p.add_argument("--canopy-mode", choices=("binary", "fraction"), default="binary")
|
|
258
|
+
p.add_argument("--canopy-radius", type=float, help="buffer neighbourhood radius (m)")
|
|
259
|
+
p.add_argument("--neighbourhoods", help="neighbourhood polygons (GeoJSON)")
|
|
260
|
+
p.add_argument("--neighbourhood-id")
|
|
261
|
+
p.add_argument("--canopy-field", help="canopy share attribute of the neighbourhoods")
|
|
262
|
+
p.add_argument("--canopy-polygons", help="canopy polygons (GeoJSON)")
|
|
263
|
+
p.add_argument("--resolution", type=float, help="sampling resolution for polygons (m)")
|
|
264
|
+
p.add_argument("--min-area-ha", type=float, help="minimum green space size (none by default)")
|
|
265
|
+
p.add_argument("--network", help="street centrelines (GeoJSON) for network distance")
|
|
266
|
+
p.add_argument("--node-tolerance", type=float, default=0.01)
|
|
267
|
+
p.add_argument("--max-snap", type=float, default=100.0)
|
|
268
|
+
p.add_argument("--entrance-spacing", type=float, default=25.0)
|
|
269
|
+
p.add_argument("--zone-field", help="zone label attribute for equity summaries")
|
|
270
|
+
_io_opts(p)
|
|
271
|
+
p.set_defaults(func=_cmd_rule)
|
|
272
|
+
|
|
273
|
+
p = sub.add_parser("exposure", help="NDVI statistics in circular buffers")
|
|
274
|
+
_homes_opts(p)
|
|
275
|
+
p.add_argument("--ndvi", required=True, help="NDVI raster (.npy)")
|
|
276
|
+
p.add_argument("--transform", required=True, help="x0,y0_top,dx,dy")
|
|
277
|
+
p.add_argument("--nodata", type=float)
|
|
278
|
+
p.add_argument("--radius", type=float, required=True, help="buffer radius (m)")
|
|
279
|
+
p.add_argument("--threshold", type=float, help="NDVI >= threshold counts as green")
|
|
280
|
+
_io_opts(p)
|
|
281
|
+
p.set_defaults(func=_cmd_exposure)
|
|
282
|
+
|
|
283
|
+
p = sub.add_parser("provision", help="green area and m2 per resident by zone")
|
|
284
|
+
p.add_argument("zones", help="zone polygons with a population attribute (GeoJSON)")
|
|
285
|
+
p.add_argument("greens")
|
|
286
|
+
p.add_argument("--population-field", required=True)
|
|
287
|
+
p.add_argument("--zone-id")
|
|
288
|
+
p.add_argument("--green-id")
|
|
289
|
+
p.add_argument("--method", choices=("centroid", "sample"), default="centroid")
|
|
290
|
+
p.add_argument("--resolution", type=float)
|
|
291
|
+
p.add_argument("-o", "--output", help="summary JSON (default: stdout)")
|
|
292
|
+
p.add_argument("--csv", help="per-zone CSV")
|
|
293
|
+
p.set_defaults(func=_cmd_provision)
|
|
294
|
+
|
|
295
|
+
p = sub.add_parser("equity", help="Gini and shares meeting criteria from a CSV table")
|
|
296
|
+
p.add_argument("table", help="CSV with one row per home or area")
|
|
297
|
+
p.add_argument("--criteria", help="comma-separated boolean columns")
|
|
298
|
+
p.add_argument("--value", help="continuous column for a Gini coefficient")
|
|
299
|
+
p.add_argument("--population", help="weight column")
|
|
300
|
+
p.add_argument("--zone", help="zone label column")
|
|
301
|
+
p.add_argument("-o", "--output")
|
|
302
|
+
p.add_argument("--csv", help="per-zone CSV")
|
|
303
|
+
p.set_defaults(func=_cmd_equity)
|
|
304
|
+
return parser
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
308
|
+
parser = build_parser()
|
|
309
|
+
args = parser.parse_args(argv)
|
|
310
|
+
try:
|
|
311
|
+
return int(args.func(args))
|
|
312
|
+
except (GreenError, OSError, KeyError, ValueError) as exc:
|
|
313
|
+
print(f"greenrule: error: {exc}", file=sys.stderr)
|
|
314
|
+
return EXIT_ERROR
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__": # pragma: no cover
|
|
318
|
+
sys.exit(main())
|