Handling irregular and flag lots in setback computation
The batch reports 340 parcels with zero buildable area. Spot-checking three of them shows lots that are obviously developable — one has a house on it. What they share is shape: a flag lot with a six-metre access strip, a pie-shaped lot on a cul-de-sac, and an L-shaped lot wrapping a neighbour’s corner. Applying an inward offset to any of them either severs the lot into disconnected pieces or empties it entirely, and both outcomes get reported as “no capacity” by code that expected a polygon back. This guide detects those shapes and reports what the geometry actually permits, extending the offset mechanics in development capacity & buildable area analysis.
Diagnosis: classifying the shapes that break an offset jump to heading
Four shape classes account for nearly all of it, and they are cheap to detect before offsetting.
import math
from shapely.geometry import Polygon
def shape_class(lot: Polygon) -> str:
"""Cheap shape triage from three ratios. Run this BEFORE offsetting so the
result can be interpreted rather than merely reported."""
if lot.is_empty:
return "empty"
hull = lot.convex_hull
solidity = lot.area / hull.area if hull.area else 0.0 # concavity
# Polsby–Popper compactness: 1.0 is a circle, a long thin strip approaches 0.
compactness = 4 * math.pi * lot.area / (lot.length ** 2) if lot.length else 0.0
minx, miny, maxx, maxy = lot.bounds
aspect = max(maxx - minx, maxy - miny) / max(min(maxx - minx, maxy - miny), 1e-9)
if solidity < 0.72 and compactness < 0.35:
return "flag_or_L" # a narrow appendage on a larger body
if aspect > 4.0:
return "deep_strip" # long and thin: side setbacks may consume it
if compactness < 0.45:
return "irregular" # pie, wedge, cul-de-sac frontage
return "regular"
The useful diagnostic is the cross-tab of shape class against the offset outcome:
def outcome_by_shape(lots, std, streets):
from collections import Counter
table = Counter()
for lot in lots:
env, _c, _n = buildable_envelope(lot, std, streets)
if env.is_empty:
outcome = "empty"
elif env.geom_type == "MultiPolygon":
outcome = f"split_{len(env.geoms)}"
else:
outcome = "single"
table[(shape_class(lot), outcome)] += 1
return table
A batch where flag_or_L maps overwhelmingly to empty or split_2 is not a data problem — it is the offset behaving exactly as the geometry demands, and the reporting is what needs fixing.
Step-by-step implementation jump to heading
1. Never assume a polygon comes back jump to heading
from shapely.geometry import MultiPolygon
def normalise_offset_result(result, min_useful_m2=9.0):
"""An inward offset can return empty, a polygon, or a MultiPolygon. Returning a
list makes callers handle all three, and dropping slivers below a useful size
stops a 0.4 m² fragment being reported as a buildable piece."""
if result.is_empty:
return []
geoms = list(result.geoms) if isinstance(result, MultiPolygon) else [result]
kept = [g for g in geoms if g.area >= min_useful_m2]
return sorted(kept, key=lambda g: g.area, reverse=True)
2. Report the pieces rather than picking one silently jump to heading
from dataclasses import dataclass, field
@dataclass
class Envelope:
pieces: list # largest first
shape_class: str
notes: list[str] = field(default_factory=list)
@property
def principal_m2(self) -> float:
"""The largest piece — the only one a principal structure can occupy."""
return self.pieces[0].area if self.pieces else 0.0
@property
def total_m2(self) -> float:
"""All pieces. NOT the same as principal: summing them implies a building
that spans a gap the setbacks created."""
return sum(p.area for p in self.pieces)
def envelope_with_shape(lot, std, streets) -> Envelope:
raw, classes, notes = buildable_envelope(lot, std, streets)
pieces = normalise_offset_result(raw)
cls = shape_class(lot)
env = Envelope(pieces, cls, list(notes))
if not pieces:
env.notes.append(
f"{cls}: setbacks consume the whole lot — this parcel needs a variance, "
f"it is not a data error")
elif len(pieces) > 1:
env.notes.append(
f"{cls}: setbacks split the lot into {len(pieces)} disconnected pieces "
f"({', '.join(f'{p.area:.0f} m²' for p in pieces)}); only the largest can "
f"hold a principal structure")
if cls == "flag_or_L" and pieces:
env.notes.append("access strip likely excluded from the envelope — confirm the "
"ordinance does not require frontage on the buildable area")
return env
3. Treat the access strip as a separate question jump to heading
A flag lot’s stem is usually not buildable and is usually required for access. Both facts matter and neither is captured by an area figure.
def access_strip(lot, envelope_pieces, min_width_m=3.0):
"""What the offset removed that is narrow enough to be an access strip. Its
presence tells you the lot HAS legal access; its width tells you whether the
ordinance's minimum is met."""
if not envelope_pieces:
return None
from shapely.ops import unary_union
removed = lot.difference(unary_union(envelope_pieces))
if removed.is_empty:
return None
parts = list(removed.geoms) if removed.geom_type == "MultiPolygon" else [removed]
strips = [p for p in parts
if p.area > 1.0 and (4 * math.pi * p.area / p.length ** 2) < 0.25]
if not strips:
return None
widest = max(strips, key=lambda p: p.area / max(p.length / 2, 1e-9))
approx_width = widest.area / max(widest.length / 2, 1e-9)
return {"area_m2": round(widest.area, 1),
"approx_width_m": round(approx_width, 2),
"meets_minimum": approx_width >= min_width_m}
4. Use a shape-aware fallback for pie and wedge lots jump to heading
An irregular lot on a cul-de-sac has a curved frontage of many short segments, and classifying each as a separate front edge produces an over-constrained envelope. Merge near-collinear frontage segments first.
def merge_collinear_frontage(edge_classes, coords, angle_tol_deg=12.0):
"""Consecutive frontage edges whose directions differ by less than the tolerance
are one curved frontage, not several. Without this, a cul-de-sac lot takes the
front setback from six directions and the envelope collapses."""
merged, run = [], []
for ec in edge_classes:
if ec.kind != "front":
if run:
merged.append(run[0])
run = []
merged.append(ec)
continue
if not run:
run = [ec]
continue
prev = run[-1]
if _angle_between(coords, prev.index, ec.index) <= angle_tol_deg:
run.append(ec)
else:
merged.append(run[0])
run = [ec]
if run:
merged.append(run[0])
return merged
Verification & testing jump to heading
FLAG = Polygon([(0, 0), (6, 0), (6, 60), (34, 60), (34, 90), (0, 90)])
PIE = Polygon([(0, 0), (30, 4), (34, 30), (26, 40), (8, 34), (0, 18)])
DEEP = box(0, 0, 8, 60)
def test_flag_lot_splits_and_the_split_is_reported():
env = envelope_with_shape(FLAG, STD, STREETS_SOUTH)
assert env.shape_class == "flag_or_L"
assert len(env.pieces) >= 1
assert any("access strip" in n for n in env.notes)
def test_deep_strip_may_be_consumed_and_says_so():
env = envelope_with_shape(DEEP, STD_WIDE_SIDES, STREETS_SOUTH)
assert env.principal_m2 == 0.0
assert any("needs a variance" in n for n in env.notes)
def test_principal_is_not_the_sum_of_pieces():
env = envelope_with_shape(FLAG, STD, STREETS_SOUTH)
if len(env.pieces) > 1:
assert env.principal_m2 < env.total_m2 # summing would imply a gap-spanning building
def test_slivers_are_not_reported_as_buildable():
lot = Polygon([(0, 0), (20, 0), (20, 20), (10.05, 20), (10, 40), (0, 40)])
env = envelope_with_shape(lot, STD, STREETS_SOUTH)
assert all(p.area >= 9.0 for p in env.pieces)
def test_cul_de_sac_frontage_is_merged():
classes = classify_edges(PIE, [CUL_DE_SAC_ARC])
merged = merge_collinear_frontage(classes, list(PIE.exterior.coords))
assert sum(1 for c in merged if c.kind == "front") < \
sum(1 for c in classes if c.kind == "front")
def test_shape_class_is_stable_under_translation():
from shapely import affinity
for lot in (FLAG, PIE, DEEP):
assert shape_class(lot) == shape_class(affinity.translate(lot, 5000, 7000))
Failure recovery jump to heading
340 parcels reported as zero capacity. Cross-tab shape class against outcome first. If they are overwhelmingly flag_or_L and deep_strip, the offsets are correct and the reporting is what is wrong — those lots need a variance note rather than a zero. If regular lots are also empty, the offset direction or the setback units are wrong.
A capacity figure that assumed the sum of pieces. Any downstream figure built on total_m2 for a split lot describes a building spanning a gap the setbacks created. Recompute on principal_m2 and flag the affected parcels, which are exactly those with more than one piece.
Slivers counted as buildable. A 0.4 m² fragment surviving the offset is a geometry artefact, not a building site. Raise min_useful_m2 to something defensible — nine square metres is a small shed — and record the threshold with the figure so it is not mistaken for an ordinance rule.
Frequently asked questions jump to heading
Should the access strip count toward buildable area?
Almost never as buildable, and it usually matters for a different reason: many ordinances require a minimum access width, so the strip’s presence and width are a compliance question in their own right. Report it as a separate finding rather than folding its area into the envelope, where it would inflate the figure and hide the width question.
Why take the largest piece rather than the sum?
Because the pieces are disconnected, so a building cannot span them — the setbacks are precisely the space between. Summing produces a figure describing a structure the ordinance forbids. The largest piece is what a principal structure can occupy; the others may take accessory buildings if the code allows, which is a separate calculation.
Is a zero-capacity result always a bug?
No, and treating it as one is the more common error. A deep strip lot eight metres wide with four-metre side setbacks genuinely has no buildable width, which is a real finding — that parcel needs a variance. What is a bug is reporting it identically to a lot whose envelope failed to compute, which is why the note distinguishes them.
Why merge frontage segments on a curved lot line?
Because a cul-de-sac frontage is one arc digitised as six short segments, and classifying each as frontage applies the front setback from six directions at once, collapsing the envelope. Merging near-collinear consecutive frontage edges restores the single frontage the ordinance means, and the angle tolerance is the one parameter worth tuning per jurisdiction.
Related jump to heading
- Parent topic: Development Capacity & Buildable Area Analysis
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Computing a buildable envelope from setbacks and height limits — the per-edge offset these shapes stress
- Spatial Overlay Analysis — multi-part geometry and the predicates that handle it
- Creating minimal parcel fixtures that reproduce topology bugs — building the flag-lot fixture that pins this behaviour