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