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 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
@@ -0,0 +1,7 @@
1
+ """``python -m greenrule``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
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())