parcelform 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.
- parcelform/__init__.py +130 -0
- parcelform/__main__.py +7 -0
- parcelform/_common.py +211 -0
- parcelform/_version.py +1 -0
- parcelform/analysis.py +217 -0
- parcelform/cli.py +190 -0
- parcelform/envelope.py +306 -0
- parcelform/frontage.py +485 -0
- parcelform/geometry.py +382 -0
- parcelform/io.py +155 -0
- parcelform/py.typed +0 -0
- parcelform/shape.py +179 -0
- parcelform/subdivision.py +286 -0
- parcelform-0.1.0.dist-info/METADATA +273 -0
- parcelform-0.1.0.dist-info/RECORD +19 -0
- parcelform-0.1.0.dist-info/WHEEL +5 -0
- parcelform-0.1.0.dist-info/entry_points.txt +2 -0
- parcelform-0.1.0.dist-info/licenses/LICENSE +21 -0
- parcelform-0.1.0.dist-info/top_level.txt +1 -0
parcelform/__init__.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""parcelform: parcel and lot geometry metrics from GeoJSON polygons in projected coordinates.
|
|
2
|
+
|
|
3
|
+
Area, perimeter, convex hull, minimum-area rectangle (rotating calipers) and orientation;
|
|
4
|
+
shape indices (Polsby-Popper, Schwartzberg, Reock, rectangularity, elongation, convexity,
|
|
5
|
+
fractal dimension); frontage against street centrelines or blocks, lot depth and width,
|
|
6
|
+
corner / through / interior classification; buildable envelopes by setback; lot-rule checks
|
|
7
|
+
and simple subdivision feasibility. Pure NumPy.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._common import Geometry, ParcelError, Polygon, to_geometry, to_polygon
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .analysis import (
|
|
13
|
+
ParcelReport,
|
|
14
|
+
analyze,
|
|
15
|
+
analyze_parcel,
|
|
16
|
+
envelopes,
|
|
17
|
+
load_streets,
|
|
18
|
+
subdivide_collection,
|
|
19
|
+
)
|
|
20
|
+
from .envelope import Envelope, buildable_envelope, edge_roles, resolve_setbacks
|
|
21
|
+
from .frontage import (
|
|
22
|
+
CORNER,
|
|
23
|
+
INTERIOR,
|
|
24
|
+
LANDLOCKED,
|
|
25
|
+
LOT_TYPES,
|
|
26
|
+
THROUGH,
|
|
27
|
+
Frontage,
|
|
28
|
+
FrontagePiece,
|
|
29
|
+
LotDimensions,
|
|
30
|
+
StreetSegments,
|
|
31
|
+
as_streets,
|
|
32
|
+
frontage,
|
|
33
|
+
lot_dimensions,
|
|
34
|
+
)
|
|
35
|
+
from .geometry import (
|
|
36
|
+
Circle,
|
|
37
|
+
Rectangle,
|
|
38
|
+
area,
|
|
39
|
+
centroid,
|
|
40
|
+
convex_hull,
|
|
41
|
+
min_area_rectangle,
|
|
42
|
+
min_enclosing_circle,
|
|
43
|
+
perimeter,
|
|
44
|
+
points_in_polygon,
|
|
45
|
+
)
|
|
46
|
+
from .io import read_geojson, read_rules, write_csv, write_geojson
|
|
47
|
+
from .shape import (
|
|
48
|
+
ShapeIndices,
|
|
49
|
+
fractal_dimension,
|
|
50
|
+
orientation_difference,
|
|
51
|
+
polsby_popper,
|
|
52
|
+
schwartzberg,
|
|
53
|
+
schwartzberg_ratio,
|
|
54
|
+
shape_indices,
|
|
55
|
+
)
|
|
56
|
+
from .subdivision import (
|
|
57
|
+
LotRules,
|
|
58
|
+
RuleCheck,
|
|
59
|
+
SubdivisionResult,
|
|
60
|
+
check_lot,
|
|
61
|
+
evaluate_rules,
|
|
62
|
+
lot_count_bound,
|
|
63
|
+
split_parcel,
|
|
64
|
+
subdivision_feasibility,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"__version__",
|
|
69
|
+
"ParcelError",
|
|
70
|
+
"Polygon",
|
|
71
|
+
"Geometry",
|
|
72
|
+
"to_geometry",
|
|
73
|
+
"to_polygon",
|
|
74
|
+
# geometry
|
|
75
|
+
"area",
|
|
76
|
+
"perimeter",
|
|
77
|
+
"centroid",
|
|
78
|
+
"convex_hull",
|
|
79
|
+
"min_area_rectangle",
|
|
80
|
+
"min_enclosing_circle",
|
|
81
|
+
"points_in_polygon",
|
|
82
|
+
"Rectangle",
|
|
83
|
+
"Circle",
|
|
84
|
+
# shape
|
|
85
|
+
"shape_indices",
|
|
86
|
+
"ShapeIndices",
|
|
87
|
+
"polsby_popper",
|
|
88
|
+
"schwartzberg",
|
|
89
|
+
"schwartzberg_ratio",
|
|
90
|
+
"fractal_dimension",
|
|
91
|
+
"orientation_difference",
|
|
92
|
+
# frontage
|
|
93
|
+
"frontage",
|
|
94
|
+
"Frontage",
|
|
95
|
+
"FrontagePiece",
|
|
96
|
+
"StreetSegments",
|
|
97
|
+
"as_streets",
|
|
98
|
+
"lot_dimensions",
|
|
99
|
+
"LotDimensions",
|
|
100
|
+
"CORNER",
|
|
101
|
+
"THROUGH",
|
|
102
|
+
"INTERIOR",
|
|
103
|
+
"LANDLOCKED",
|
|
104
|
+
"LOT_TYPES",
|
|
105
|
+
# envelope
|
|
106
|
+
"buildable_envelope",
|
|
107
|
+
"Envelope",
|
|
108
|
+
"edge_roles",
|
|
109
|
+
"resolve_setbacks",
|
|
110
|
+
# subdivision
|
|
111
|
+
"LotRules",
|
|
112
|
+
"RuleCheck",
|
|
113
|
+
"SubdivisionResult",
|
|
114
|
+
"check_lot",
|
|
115
|
+
"evaluate_rules",
|
|
116
|
+
"lot_count_bound",
|
|
117
|
+
"split_parcel",
|
|
118
|
+
"subdivision_feasibility",
|
|
119
|
+
# analysis and I/O
|
|
120
|
+
"analyze",
|
|
121
|
+
"analyze_parcel",
|
|
122
|
+
"envelopes",
|
|
123
|
+
"subdivide_collection",
|
|
124
|
+
"load_streets",
|
|
125
|
+
"ParcelReport",
|
|
126
|
+
"read_geojson",
|
|
127
|
+
"read_rules",
|
|
128
|
+
"write_geojson",
|
|
129
|
+
"write_csv",
|
|
130
|
+
]
|
parcelform/__main__.py
ADDED
parcelform/_common.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Shared types, validation and small ring helpers.
|
|
2
|
+
|
|
3
|
+
All coordinates are planar (projected) and in one length unit, typically metres. Rings are
|
|
4
|
+
``(n, 2)`` float arrays stored *open* (the closing vertex of GeoJSON is dropped).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, List, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
|
|
15
|
+
FloatArray = NDArray[np.float64]
|
|
16
|
+
|
|
17
|
+
#: Relative tolerance used for degenerate-geometry checks.
|
|
18
|
+
EPS = 1e-12
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ParcelError(ValueError):
|
|
22
|
+
"""Raised for invalid geometry, inconsistent parameters or unsupported cases."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def as_ring(coords: Any, what: str = "ring") -> FloatArray:
|
|
26
|
+
"""Return ``coords`` as an open ``(n, 2)`` float ring.
|
|
27
|
+
|
|
28
|
+
The closing vertex (if equal to the first) and consecutive duplicate vertices are
|
|
29
|
+
removed. Extra ordinates (z, m) are dropped. At least three distinct vertices and a
|
|
30
|
+
non-zero area are required.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
arr = np.asarray(coords, dtype=float)
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
raise ParcelError(f"{what}: coordinates are not numeric") from None
|
|
36
|
+
if arr.ndim != 2 or arr.shape[1] < 2:
|
|
37
|
+
raise ParcelError(f"{what}: expected a sequence of [x, y] pairs")
|
|
38
|
+
arr = np.ascontiguousarray(arr[:, :2])
|
|
39
|
+
if not np.all(np.isfinite(arr)):
|
|
40
|
+
raise ParcelError(f"{what}: coordinates contain NaN or infinity")
|
|
41
|
+
if len(arr) > 1:
|
|
42
|
+
keep = np.ones(len(arr), dtype=bool)
|
|
43
|
+
keep[1:] = np.any(arr[1:] != arr[:-1], axis=1)
|
|
44
|
+
arr = arr[keep]
|
|
45
|
+
if len(arr) > 1 and np.array_equal(arr[0], arr[-1]):
|
|
46
|
+
arr = arr[:-1]
|
|
47
|
+
if len(arr) < 3:
|
|
48
|
+
raise ParcelError(f"{what}: needs at least three distinct vertices")
|
|
49
|
+
if abs(signed_area(arr)) <= EPS * max(1.0, float(np.ptp(arr, axis=0).max()) ** 2):
|
|
50
|
+
raise ParcelError(f"{what}: ring has zero area")
|
|
51
|
+
return arr
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def as_points(coords: Any, what: str = "points") -> FloatArray:
|
|
55
|
+
"""Return ``coords`` as an ``(n, 2)`` float array (a single point gives ``(1, 2)``)."""
|
|
56
|
+
try:
|
|
57
|
+
arr = np.asarray(coords, dtype=float)
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
raise ParcelError(f"{what}: coordinates are not numeric") from None
|
|
60
|
+
if arr.ndim == 1:
|
|
61
|
+
arr = arr.reshape(1, -1)
|
|
62
|
+
if arr.ndim != 2 or arr.shape[1] < 2 or len(arr) == 0:
|
|
63
|
+
raise ParcelError(f"{what}: expected [x, y] pairs")
|
|
64
|
+
arr = np.ascontiguousarray(arr[:, :2])
|
|
65
|
+
if not np.all(np.isfinite(arr)):
|
|
66
|
+
raise ParcelError(f"{what}: coordinates contain NaN or infinity")
|
|
67
|
+
return arr
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def signed_area(ring: FloatArray) -> float:
|
|
71
|
+
"""Shoelace signed area: positive for counter-clockwise rings."""
|
|
72
|
+
x = ring[:, 0] - ring[0, 0]
|
|
73
|
+
y = ring[:, 1] - ring[0, 1]
|
|
74
|
+
return 0.5 * float(np.dot(x, np.roll(y, -1)) - np.dot(np.roll(x, -1), y))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ccw(ring: FloatArray) -> FloatArray:
|
|
78
|
+
"""Return the ring in counter-clockwise order."""
|
|
79
|
+
return ring if signed_area(ring) > 0 else ring[::-1].copy()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ring_edges(ring: FloatArray) -> Tuple[FloatArray, FloatArray]:
|
|
83
|
+
"""Start and end points of every edge of an open ring."""
|
|
84
|
+
return ring, np.roll(ring, -1, axis=0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def drop_collinear(ring: FloatArray, tol: float = 1e-9) -> FloatArray:
|
|
88
|
+
"""Remove vertices whose neighbours make them collinear (within ``tol`` x edge scale)."""
|
|
89
|
+
pts = ring
|
|
90
|
+
changed = True
|
|
91
|
+
while changed and len(pts) > 3:
|
|
92
|
+
changed = False
|
|
93
|
+
prev = np.roll(pts, 1, axis=0)
|
|
94
|
+
nxt = np.roll(pts, -1, axis=0)
|
|
95
|
+
u = pts - prev
|
|
96
|
+
v = nxt - pts
|
|
97
|
+
cross = u[:, 0] * v[:, 1] - u[:, 1] * v[:, 0]
|
|
98
|
+
dot = np.einsum("ij,ij->i", u, v)
|
|
99
|
+
scale = np.linalg.norm(u, axis=1) * np.linalg.norm(v, axis=1)
|
|
100
|
+
flat = (np.abs(cross) <= tol * np.maximum(scale, EPS)) & (dot > 0)
|
|
101
|
+
if flat.any():
|
|
102
|
+
idx = int(np.argmax(flat))
|
|
103
|
+
pts = np.delete(pts, idx, axis=0)
|
|
104
|
+
changed = True
|
|
105
|
+
return pts
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Polygon:
|
|
110
|
+
"""A polygon with one exterior ring and optional holes (open rings, any orientation)."""
|
|
111
|
+
|
|
112
|
+
exterior: FloatArray
|
|
113
|
+
holes: List[FloatArray] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_coords(cls, coords: Sequence[Any]) -> Polygon:
|
|
117
|
+
"""Build from GeoJSON ``Polygon`` coordinates ``[exterior, hole, ...]``."""
|
|
118
|
+
if len(coords) == 0:
|
|
119
|
+
raise ParcelError("polygon has no rings")
|
|
120
|
+
ext = ccw(as_ring(coords[0], "exterior ring"))
|
|
121
|
+
holes = [ccw(as_ring(h, "hole"))[::-1].copy() for h in coords[1:]]
|
|
122
|
+
return cls(ext, holes)
|
|
123
|
+
|
|
124
|
+
def rings(self) -> List[FloatArray]:
|
|
125
|
+
return [self.exterior, *self.holes]
|
|
126
|
+
|
|
127
|
+
def to_coords(self) -> List[List[List[float]]]:
|
|
128
|
+
"""GeoJSON coordinates with closed rings (exterior CCW, holes CW)."""
|
|
129
|
+
out = []
|
|
130
|
+
for r in self.rings():
|
|
131
|
+
closed = np.vstack([r, r[:1]])
|
|
132
|
+
out.append(closed.tolist())
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Geometry:
|
|
138
|
+
"""A polygonal geometry made of one or more :class:`Polygon` parts."""
|
|
139
|
+
|
|
140
|
+
parts: List[Polygon]
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def is_multi(self) -> bool:
|
|
144
|
+
return len(self.parts) > 1
|
|
145
|
+
|
|
146
|
+
def single(self, what: str = "operation") -> Polygon:
|
|
147
|
+
"""Return the only part or raise for multi-part geometries."""
|
|
148
|
+
if len(self.parts) != 1:
|
|
149
|
+
raise ParcelError(f"{what} needs a single polygon, got {len(self.parts)} parts")
|
|
150
|
+
return self.parts[0]
|
|
151
|
+
|
|
152
|
+
def all_vertices(self) -> FloatArray:
|
|
153
|
+
return np.vstack([p.exterior for p in self.parts])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def to_geometry(obj: Any) -> Geometry:
|
|
157
|
+
"""Coerce a GeoJSON geometry dict, :class:`Polygon`, :class:`Geometry` or ring."""
|
|
158
|
+
if isinstance(obj, Geometry):
|
|
159
|
+
return obj
|
|
160
|
+
if isinstance(obj, Polygon):
|
|
161
|
+
return Geometry([obj])
|
|
162
|
+
if isinstance(obj, dict):
|
|
163
|
+
if obj.get("type") == "Feature":
|
|
164
|
+
return to_geometry(obj.get("geometry"))
|
|
165
|
+
gtype = obj.get("type")
|
|
166
|
+
coords = obj.get("coordinates")
|
|
167
|
+
if gtype == "Polygon":
|
|
168
|
+
return Geometry([Polygon.from_coords(coords or [])])
|
|
169
|
+
if gtype == "MultiPolygon":
|
|
170
|
+
parts = [Polygon.from_coords(c) for c in (coords or [])]
|
|
171
|
+
if not parts:
|
|
172
|
+
raise ParcelError("empty MultiPolygon")
|
|
173
|
+
return Geometry(parts)
|
|
174
|
+
raise ParcelError(f"expected a Polygon or MultiPolygon, got {gtype!r}")
|
|
175
|
+
# bare ring of coordinates
|
|
176
|
+
return Geometry([Polygon(ccw(as_ring(obj)))])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def to_polygon(obj: Any, what: str = "operation") -> Polygon:
|
|
180
|
+
"""Coerce to a single :class:`Polygon`."""
|
|
181
|
+
return to_geometry(obj).single(what)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def check_positive(value: float, name: str, allow_zero: bool = False) -> float:
|
|
185
|
+
v = float(value)
|
|
186
|
+
if not np.isfinite(v) or v < 0 or (v == 0 and not allow_zero):
|
|
187
|
+
cond = ">= 0" if allow_zero else "> 0"
|
|
188
|
+
raise ParcelError(f"{name} must be finite and {cond}, got {value!r}")
|
|
189
|
+
return v
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def finite_or_none(x: float) -> Any:
|
|
193
|
+
"""JSON-safe float: NaN/inf become ``None``."""
|
|
194
|
+
return float(x) if np.isfinite(x) else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def clean(obj: Any) -> Any:
|
|
198
|
+
"""Recursively convert numpy scalars/arrays and non-finite floats to JSON-safe values."""
|
|
199
|
+
if isinstance(obj, dict):
|
|
200
|
+
return {str(k): clean(v) for k, v in obj.items()}
|
|
201
|
+
if isinstance(obj, (list, tuple)):
|
|
202
|
+
return [clean(v) for v in obj]
|
|
203
|
+
if isinstance(obj, np.ndarray):
|
|
204
|
+
return clean(obj.tolist())
|
|
205
|
+
if isinstance(obj, (np.bool_,)):
|
|
206
|
+
return bool(obj)
|
|
207
|
+
if isinstance(obj, (np.integer,)):
|
|
208
|
+
return int(obj)
|
|
209
|
+
if isinstance(obj, (float, np.floating)):
|
|
210
|
+
return finite_or_none(float(obj))
|
|
211
|
+
return obj
|
parcelform/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
parcelform/analysis.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""One-call parcel analysis and batch processing of FeatureCollections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
8
|
+
|
|
9
|
+
from ._common import Geometry, ParcelError, clean, to_geometry
|
|
10
|
+
from .envelope import Envelope, Setbacks, buildable_envelope
|
|
11
|
+
from .frontage import Frontage, LotDimensions, StreetSegments, as_streets, frontage, lot_dimensions
|
|
12
|
+
from .geometry import centroid
|
|
13
|
+
from .io import check_projected, feature, feature_collection, read_geojson
|
|
14
|
+
from .shape import ShapeIndices, shape_indices
|
|
15
|
+
from .subdivision import (
|
|
16
|
+
LotRules,
|
|
17
|
+
RuleCheck,
|
|
18
|
+
SubdivisionResult,
|
|
19
|
+
evaluate_rules,
|
|
20
|
+
subdivision_feasibility,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_streets(streets: Any) -> Optional[StreetSegments]:
|
|
25
|
+
"""Streets from a GeoJSON path, a FeatureCollection/geometry dict or a list of lines."""
|
|
26
|
+
if streets is None:
|
|
27
|
+
return None
|
|
28
|
+
if isinstance(streets, (str, Path)):
|
|
29
|
+
return as_streets(read_geojson(streets))
|
|
30
|
+
return as_streets(streets)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ParcelReport:
|
|
35
|
+
"""Everything measured for one parcel."""
|
|
36
|
+
|
|
37
|
+
shape: ShapeIndices
|
|
38
|
+
centroid: tuple
|
|
39
|
+
frontage: Optional[Frontage] = None
|
|
40
|
+
dimensions: Optional[LotDimensions] = None
|
|
41
|
+
envelope: Optional[Envelope] = None
|
|
42
|
+
rules: Optional[RuleCheck] = None
|
|
43
|
+
subdivision: Optional[SubdivisionResult] = None
|
|
44
|
+
error: Optional[str] = None
|
|
45
|
+
|
|
46
|
+
def properties(self) -> Dict[str, Any]:
|
|
47
|
+
"""Flat, JSON-safe properties for a GeoJSON Feature or a CSV row."""
|
|
48
|
+
p: Dict[str, Any] = {}
|
|
49
|
+
s = self.shape.to_dict()
|
|
50
|
+
p.update({k: s[k] for k in s})
|
|
51
|
+
p["centroid_x"], p["centroid_y"] = self.centroid
|
|
52
|
+
if self.frontage is not None:
|
|
53
|
+
p["frontage"] = self.frontage.total
|
|
54
|
+
p["lot_type"] = self.frontage.lot_type
|
|
55
|
+
p["is_corner"] = self.frontage.is_corner
|
|
56
|
+
p["is_through"] = self.frontage.is_through
|
|
57
|
+
p["n_streets"] = self.frontage.n_streets
|
|
58
|
+
p["street_ids"] = [str(k) for k in self.frontage.by_street]
|
|
59
|
+
if self.dimensions is not None:
|
|
60
|
+
d = self.dimensions.to_dict()
|
|
61
|
+
p.update({f"lot_{k}": v for k, v in d.items()})
|
|
62
|
+
if self.envelope is not None:
|
|
63
|
+
p["envelope_area"] = self.envelope.area
|
|
64
|
+
p["envelope_share"] = self.envelope.share
|
|
65
|
+
p["envelope_method"] = self.envelope.method
|
|
66
|
+
if self.rules is not None:
|
|
67
|
+
p["rules_passed"] = self.rules.passed
|
|
68
|
+
p["rules_failures"] = "; ".join(self.rules.failures)
|
|
69
|
+
if self.subdivision is not None:
|
|
70
|
+
p["max_lots"] = self.subdivision.n_lots
|
|
71
|
+
p["subdividable"] = self.subdivision.feasible
|
|
72
|
+
if self.error:
|
|
73
|
+
p["error"] = self.error
|
|
74
|
+
return dict(clean(p))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def analyze_parcel(
|
|
78
|
+
parcel: Any,
|
|
79
|
+
streets: Any = None,
|
|
80
|
+
tolerance: Optional[float] = None,
|
|
81
|
+
setbacks: Optional[Setbacks] = None,
|
|
82
|
+
rules: Optional[LotRules] = None,
|
|
83
|
+
subdivide: bool = False,
|
|
84
|
+
by: str = "frontage",
|
|
85
|
+
**frontage_kw: Any,
|
|
86
|
+
) -> ParcelReport:
|
|
87
|
+
"""Shape indices plus, when inputs allow, frontage, lot dimensions, envelope, rule
|
|
88
|
+
check and subdivision feasibility for one parcel."""
|
|
89
|
+
g: Geometry = to_geometry(parcel)
|
|
90
|
+
report = ParcelReport(shape=shape_indices(g), centroid=centroid(g))
|
|
91
|
+
if g.is_multi:
|
|
92
|
+
return report
|
|
93
|
+
poly = g.parts[0]
|
|
94
|
+
segs: Optional[StreetSegments] = None
|
|
95
|
+
if streets is not None:
|
|
96
|
+
if tolerance is None:
|
|
97
|
+
raise ParcelError("tolerance is required with streets")
|
|
98
|
+
segs = as_streets(streets)
|
|
99
|
+
report.frontage = frontage(poly, segs, tolerance, **frontage_kw)
|
|
100
|
+
report.dimensions = lot_dimensions(poly, report.frontage)
|
|
101
|
+
if setbacks is not None:
|
|
102
|
+
report.envelope = buildable_envelope(poly, setbacks, report.frontage)
|
|
103
|
+
if rules is not None:
|
|
104
|
+
report.rules = evaluate_rules(
|
|
105
|
+
rules,
|
|
106
|
+
report.shape.area,
|
|
107
|
+
report.dimensions.depth,
|
|
108
|
+
report.dimensions.width,
|
|
109
|
+
None if report.frontage is None else report.frontage.total,
|
|
110
|
+
)
|
|
111
|
+
if subdivide:
|
|
112
|
+
if segs is None or tolerance is None:
|
|
113
|
+
raise ParcelError("subdivision needs streets and tolerance")
|
|
114
|
+
report.subdivision = subdivision_feasibility(
|
|
115
|
+
poly, rules, segs, tolerance, by=by, **frontage_kw
|
|
116
|
+
)
|
|
117
|
+
return report
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def analyze(
|
|
121
|
+
parcels: Any,
|
|
122
|
+
streets: Any = None,
|
|
123
|
+
tolerance: Optional[float] = None,
|
|
124
|
+
setbacks: Optional[Setbacks] = None,
|
|
125
|
+
rules: Optional[LotRules] = None,
|
|
126
|
+
subdivide: bool = False,
|
|
127
|
+
by: str = "frontage",
|
|
128
|
+
keep_properties: bool = True,
|
|
129
|
+
allow_geographic: bool = False,
|
|
130
|
+
on_error: str = "raise",
|
|
131
|
+
**frontage_kw: Any,
|
|
132
|
+
) -> Dict[str, Any]:
|
|
133
|
+
"""Analyse every parcel of a FeatureCollection; returns a new FeatureCollection.
|
|
134
|
+
|
|
135
|
+
The original geometry is kept; measured values are added to (or replace) the
|
|
136
|
+
properties. With ``on_error="record"`` a failing parcel gets an ``error`` property
|
|
137
|
+
instead of stopping the run.
|
|
138
|
+
"""
|
|
139
|
+
if on_error not in ("raise", "record"):
|
|
140
|
+
raise ParcelError("on_error must be 'raise' or 'record'")
|
|
141
|
+
fc = read_geojson(parcels)
|
|
142
|
+
if not allow_geographic:
|
|
143
|
+
check_projected(fc)
|
|
144
|
+
segs = load_streets(streets)
|
|
145
|
+
out: List[Dict[str, Any]] = []
|
|
146
|
+
for f in fc["features"]:
|
|
147
|
+
props = dict(f.get("properties") or {}) if keep_properties else {}
|
|
148
|
+
try:
|
|
149
|
+
rep = analyze_parcel(
|
|
150
|
+
f.get("geometry"), segs, tolerance, setbacks, rules, subdivide, by, **frontage_kw
|
|
151
|
+
)
|
|
152
|
+
props.update(rep.properties())
|
|
153
|
+
except ParcelError as exc:
|
|
154
|
+
if on_error == "raise":
|
|
155
|
+
raise
|
|
156
|
+
props["error"] = str(exc)
|
|
157
|
+
out.append(feature(f.get("geometry"), props, f.get("id")))
|
|
158
|
+
return feature_collection(out)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def envelopes(
|
|
162
|
+
parcels: Any,
|
|
163
|
+
setbacks: Setbacks,
|
|
164
|
+
streets: Any = None,
|
|
165
|
+
tolerance: Optional[float] = None,
|
|
166
|
+
**frontage_kw: Any,
|
|
167
|
+
) -> Dict[str, Any]:
|
|
168
|
+
"""FeatureCollection of buildable envelopes (empty envelopes get a null geometry)."""
|
|
169
|
+
fc = read_geojson(parcels)
|
|
170
|
+
segs = load_streets(streets)
|
|
171
|
+
if isinstance(setbacks, Mapping) and segs is None:
|
|
172
|
+
raise ParcelError("role-based setbacks need streets and tolerance")
|
|
173
|
+
out = []
|
|
174
|
+
for i, f in enumerate(fc["features"]):
|
|
175
|
+
g = to_geometry(f.get("geometry"))
|
|
176
|
+
poly = g.single("envelopes")
|
|
177
|
+
front = None
|
|
178
|
+
if segs is not None:
|
|
179
|
+
if tolerance is None:
|
|
180
|
+
raise ParcelError("tolerance is required with streets")
|
|
181
|
+
front = frontage(poly, segs, tolerance, **frontage_kw)
|
|
182
|
+
env = buildable_envelope(poly, setbacks, front)
|
|
183
|
+
props = {"parcel": f.get("id", i), **env.to_dict()}
|
|
184
|
+
out.append(feature(env.to_geojson(), dict(clean(props)), f.get("id")))
|
|
185
|
+
return feature_collection(out)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def subdivide_collection(
|
|
189
|
+
parcels: Any,
|
|
190
|
+
rules: LotRules,
|
|
191
|
+
streets: Any,
|
|
192
|
+
tolerance: float,
|
|
193
|
+
by: str = "frontage",
|
|
194
|
+
max_lots: Optional[int] = None,
|
|
195
|
+
**frontage_kw: Any,
|
|
196
|
+
) -> Dict[str, Any]:
|
|
197
|
+
"""Run :func:`subdivision_feasibility` on every parcel; returns a FeatureCollection of
|
|
198
|
+
the resulting lots (a conforming parcel that cannot be split is returned whole; a
|
|
199
|
+
non-conforming parcel is returned with ``n_lots = 0``)."""
|
|
200
|
+
fc = read_geojson(parcels)
|
|
201
|
+
segs = load_streets(streets)
|
|
202
|
+
out = []
|
|
203
|
+
for i, f in enumerate(fc["features"]):
|
|
204
|
+
pid = f.get("id", i)
|
|
205
|
+
poly = to_geometry(f.get("geometry")).single("subdivision")
|
|
206
|
+
res = subdivision_feasibility(
|
|
207
|
+
poly, rules, segs, tolerance, by=by, max_lots=max_lots, **frontage_kw
|
|
208
|
+
)
|
|
209
|
+
if res.n_lots == 0:
|
|
210
|
+
props = {"parcel": pid, "lot": 0, "n_lots": 0, **res.parent.to_dict()}
|
|
211
|
+
out.append(feature(f.get("geometry"), dict(clean(props))))
|
|
212
|
+
continue
|
|
213
|
+
for k, (lot, chk) in enumerate(zip(res.lots, res.checks), start=1):
|
|
214
|
+
props = {"parcel": pid, "lot": k, "n_lots": res.n_lots, **chk.to_dict()}
|
|
215
|
+
geom = {"type": "Polygon", "coordinates": lot.to_coords()}
|
|
216
|
+
out.append(feature(geom, dict(clean(props))))
|
|
217
|
+
return feature_collection(out)
|