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:

Four shape classes, their signatures, and how the offset behaves A four-row by four-column matrix of lot shape classes. Rows are a regular lot, a flag or L-shaped lot, a deep strip, and an irregular pie or wedge lot. Columns are the solidity ratio, the compactness ratio, the usual offset outcome, and whether a zero result is a genuine finding. A flag lot has low solidity and low compactness and usually splits into two pieces. A deep strip has a high aspect ratio and is often consumed entirely, which is a real finding rather than a bug. Four shape classes, their signatures, and how the offset behaves solidity compactness usual offset outcome is zero a real finding? Regular > 0.9 > 0.45 one piece no — a bug Flag or L-shaped < 0.72 < 0.35 splits into 2+ sometimes Deep strip > 0.9 aspect > 4 often empty yes — needs a variance Irregular (pie, wedge) 0.72–0.9 < 0.45 over-constrained sometimes expected needs handling the signal
The last column is the operational point: a zero envelope on a deep strip is a genuine finding — that parcel needs a variance — while a zero on a regular lot is a bug. Reporting both identically is what produced the 340 mystery parcels.
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
Offset outcomes by shape class across 4 200 parcels Stacked bar chart of offset outcomes for 4 200 parcels grouped into four shape classes. Regular lots return a single piece in almost every case. Flag and L-shaped lots split into two or more pieces in the majority of cases and are empty in a minority. Deep strips are empty in most cases. Irregular lots are mixed. The 340 parcels originally reported as having zero capacity are concentrated entirely in the flag and deep-strip columns. Offset outcomes by shape class across 4 200 parcels 0 1000 2000 3000 4000 2810 regular flag / L deep strip 462 irregular Parcels (4 200 total) empty (needs a variance) split into 2+ pieces single piece "Empty" is a finding on a deep strip and a bug on a regular lot.
The 340 "zero capacity" parcels are concentrated in two shape classes, which is what turned a mystery into a reporting fix. Almost none of them were bugs: the offsets were behaving exactly as the geometry demanded, and the code was describing the result as an absence of data.

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.

What a flag lot’s numbers actually are Lollipop chart of the areas involved in one flag lot: the platted lot at 1 380 square metres, the largest envelope piece at 402, the second piece at 46, the access strip at 360, and the sum of all envelope pieces at 448. A dashed rule marks the largest piece, which is the only figure a principal structure can use. The sum exceeds it and describes a building spanning a gap the setbacks created. What a flag lot’s numbers actually are 0 500 1000 1500 above the principal piece — do not sum platted lot 1380 m² largest envelope piece 402 m² all pieces summed 448 m² access strip (not buildable) 360 m² second piece 46 m² Area (m²)
Five numbers, and only one of them answers "how much can be built here": the largest piece at 402 m². The sum of pieces is 448 and describes a building spanning the gap the setbacks made; the access strip is 360 m² of land that matters for legal access and not for capacity.
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.