Estimating unit yield under a proposed rezone

A rezone from R-1 to MU-2 is on next month’s agenda and somebody wants the number: how many units could be built. The tempting answer is one figure — “about 96 units” — and it will be quoted in a memo, pasted into a model, and treated as if it came from the ordinance. It did not. Floor area comes from the ordinance; unit yield comes from floor area divided by an average unit size, adjusted for circulation, and capped by whatever parking the site can hold — three or four assumptions zoning is silent about, any one of which moves the answer by a third. This guide produces a yield estimate that carries its own assumptions, as the most assumption-heavy calculation in development capacity & buildable area analysis.

Diagnosis: where the number actually comes from jump to heading

Trace one figure back to its inputs and the shape of the problem is obvious.

Eight inputs behind one unit-yield figure An eight-row by three-column matrix of the inputs behind a unit-yield estimate. Rows are lot area, the resolved standards, storey count, gross floor area, net saleable area, average unit size, the parking-constrained cap, and the density cap. Columns are the source of each input, whether zoning states it, and whether it is measured, derived or assumed. Four of the eight are assumptions that zoning is silent about: storey height, efficiency, average unit size and achievable parking spaces. Eight inputs behind one unit-yield figure source stated by zoning? kind Lot area parcel layer measured measured Setbacks, height, FAR, coverage resolved standards yes ordinance Storeys (height ÷ storey height) height ÷ assumption height only ASSUMED Gross floor area footprint × storeys derived derived Net saleable area (efficiency) gross × efficiency no ASSUMED Average unit size market / product type no ASSUMED Parking-constrained cap stalls required ÷ achievable requirement only ASSUMED Density cap units per acre sometimes ordinance from the ordinance or measured partly assumed
Four of eight inputs are assumed, and zoning is silent on every one of them. That is not a reason to refuse the question — it is the reason a single number is the wrong shape for the answer.
Input Source Stated by zoning?
Lot area the parcel layer measured
Setbacks, height, FAR, coverage resolved standards yes
Storeys height ÷ storey height height yes, storey height no
Gross floor area footprint × storeys, capped by FAR derived
Net saleable area gross × efficiency factor no
Average unit size market and product type no
Parking-constrained cap spaces required ÷ spaces achievable requirement yes, achievable no
Density cap units per acre sometimes yes

Four of the eight inputs are assumptions. That is not a reason to refuse the question — the question is legitimate and useful — but it is the reason a single number is the wrong shape for the answer.

def trace_yield_inputs(parcel, std, assumptions):
    """Every input, with whether it is measured, derived, or assumed. Emitting this
    alongside the figure is what stops the figure being read as an ordinance fact."""
    return [
        {"input": "lot_area_m2", "value": round(parcel.geometry.area, 1), "kind": "measured"},
        {"input": "far", "value": std.numeric["far"], "kind": "ordinance",
         "cite": std.cite("far")},
        {"input": "height_ft", "value": std.numeric["height_ft"], "kind": "ordinance",
         "cite": std.cite("height_ft")},
        {"input": "storey_height_ft", "value": assumptions.storey_height_ft,
         "kind": "ASSUMED"},
        {"input": "efficiency", "value": assumptions.efficiency, "kind": "ASSUMED"},
        {"input": "avg_unit_m2", "value": assumptions.avg_unit_m2, "kind": "ASSUMED"},
        {"input": "parking_per_unit", "value": std.numeric.get("parking_spaces_min"),
         "kind": "ordinance", "cite": std.cite("parking_spaces_min")},
        {"input": "spaces_per_level", "value": assumptions.spaces_per_level,
         "kind": "ASSUMED"},
    ]

Step-by-step implementation jump to heading

1. Make the assumptions a named, versioned object jump to heading

from dataclasses import dataclass


@dataclass(frozen=True)
class YieldAssumptions:
    """Named so a figure can cite WHICH assumption set produced it. Two teams using
    different unit sizes are not disagreeing about zoning."""
    name: str
    storey_height_ft: float = 11.0     # ground floors are often taller
    efficiency: float = 0.82           # net saleable ÷ gross floor area
    avg_unit_m2: float = 85.0          # ~915 sq ft
    spaces_per_level: int = 0          # 0 = surface parking only
    surface_space_m2: float = 30.0     # a stall plus its share of aisle

    def bounds(self) -> tuple["YieldAssumptions", "YieldAssumptions"]:
        """A low and a high variant. The RANGE is the deliverable, not the midpoint."""
        low = YieldAssumptions(f"{self.name}:low", self.storey_height_ft + 1.5,
                               self.efficiency - 0.06, self.avg_unit_m2 * 1.18,
                               self.spaces_per_level, self.surface_space_m2)
        high = YieldAssumptions(f"{self.name}:high", self.storey_height_ft - 0.5,
                                self.efficiency + 0.04, self.avg_unit_m2 * 0.85,
                                self.spaces_per_level, self.surface_space_m2)
        return low, high


CONSERVATIVE = YieldAssumptions("mid-market-apartment")

2. Compute the yield, and record which constraint bound it jump to heading

ACRE_M2 = 4046.86


def unit_yield(capacity, std, lot_area_m2, a: YieldAssumptions) -> dict:
    """Three independent caps: floor area, density, and parking. The binding one is
    the answer, and naming it is what makes the figure actionable."""
    storeys = max(1, int(std.numeric["height_ft"] // a.storey_height_ft))
    by_height = capacity.max_footprint_m2 * storeys
    by_far = lot_area_m2 * std.numeric.get("far", float("inf"))
    gross = min(by_height, by_far)

    net = gross * a.efficiency
    from_floor_area = int(net // a.avg_unit_m2)

    density = std.numeric.get("units_per_acre")
    from_density = int(lot_area_m2 / ACRE_M2 * density) if density else None

    required = std.numeric.get("parking_spaces_min")
    from_parking = None
    if required:
        # Surface parking competes with the building for the same footprint.
        surface_area = max(0.0, capacity.after_encumbrances_m2 - capacity.max_footprint_m2)
        surface_spaces = int(surface_area // a.surface_space_m2)
        structured = a.spaces_per_level * storeys
        from_parking = int((surface_spaces + structured) // required)

    caps = {"floor_area": from_floor_area, "density": from_density,
            "parking": from_parking}
    binding = min((k for k, v in caps.items() if v is not None), key=lambda k: caps[k])

    return {"units": caps[binding], "binding": binding, "caps": caps,
            "gross_floor_area_m2": round(gross, 1), "storeys": storeys,
            "assumptions": a.name}

3. Publish a range, and put the divisor in the sentence jump to heading

def yield_statement(capacity, std, lot_area_m2, a=CONSERVATIVE) -> str:
    low_a, high_a = a.bounds()
    lo = unit_yield(capacity, std, lot_area_m2, low_a)
    mid = unit_yield(capacity, std, lot_area_m2, a)
    hi = unit_yield(capacity, std, lot_area_m2, high_a)

    return (
        f"{lo['units']}–{hi['units']} units (midpoint {mid['units']}), "
        f"bound by {mid['binding']}. "
        f"Assumes {a.avg_unit_m2:.0f} m² average units at {a.efficiency:.0%} efficiency "
        f"and {a.storey_height_ft:.1f} ft storeys; "
        f"{mid['gross_floor_area_m2']:.0f} m² gross floor area over {mid['storeys']} "
        f"storeys derives from the ordinance ({std.cite('far')}, "
        f"{std.cite('height_ft')})."
    )

For the worked case — a 4 000 m² lot rezoned to MU-2 at FAR 2.0, 45 feet, 40% coverage — that produces something like “71–112 units (midpoint 96), bound by floor_area. Assumes 85 m² average units at 82% efficiency and 11.0 ft storeys; 8 000 m² gross floor area over 4 storeys derives from the ordinance (§14-402, §14-207).” The range is wide because the assumptions are genuinely uncertain, and the sentence separates what the ordinance says from what you assumed.

The same parcel under three assumption sets Lollipop chart of unit yield for one 4 000 square metre parcel rezoned to MU-2, under three assumption sets. The conservative set gives 71 units, the mid set 96, and the optimistic set 112. The spread is 58 per cent of the midpoint, and it comes entirely from the assumed unit size, efficiency and storey height — the ordinance-derived floor area is identical in all three. The same parcel under three assumption sets 0 25 50 75 100 125 optimistic assumptions 112 units mid assumptions (published midpoint) 96 units conservative assumptions 71 units Dwelling units Same lot, same standards; only unit size, efficiency and storey height differ.
A 58% spread from assumptions alone, with the ordinance-derived floor area identical across all three. Publishing the midpoint on its own transfers that uncertainty to the reader silently, and they will attribute it to the ordinance rather than to you.

4. Report the delta, not the absolute, when the question is about a rezone jump to heading

def rezone_delta(parcel, before_std, after_std, streets, a=CONSERVATIVE):
    """A rezone question is a difference question. The absolute figures carry all the
    assumption uncertainty; the DELTA carries much less, because the same assumptions
    apply on both sides and largely cancel."""
    cap_b = buildable_envelope_capacity(parcel, before_std, streets)
    cap_a = buildable_envelope_capacity(parcel, after_std, streets)
    yb = unit_yield(cap_b, before_std, parcel.geometry.area, a)
    ya = unit_yield(cap_a, after_std, parcel.geometry.area, a)
    return {
        "units_before": yb["units"], "units_after": ya["units"],
        "delta_units": ya["units"] - yb["units"],
        "floor_area_delta_m2": round(ya["gross_floor_area_m2"]
                                     - yb["gross_floor_area_m2"], 1),
        "binding_before": yb["binding"], "binding_after": ya["binding"],
        "assumptions": a.name,
    }

The delta is the more defensible number and usually the one actually wanted. Both sides share the unit-size and efficiency assumptions, so an error in them largely cancels — whereas it does not cancel at all in an absolute figure.

Absolute yield against the rezone delta, under two assumption sets Grouped bar chart comparing yield figures before and after a rezone, plus the delta, computed under a conservative and an optimistic assumption set. The before figures are 9 and 11 units; the after figures are 71 and 112. The delta is 62 and 101. Expressed as a ratio between the two assumption sets, the absolute after-figure varies by 1.58 times while the delta varies by 1.63 — but the before-and-after pair moves together, so the direction and rough magnitude of the change are stable. Absolute yield against the rezone delta, under two assumption sets 0 50 100 150 9 11 units before 71 112 units after 62 101 delta Dwelling units conservative assumptions optimistic assumptions R-1 → MU-2 on a 4 000 m² lot; the floor-area delta is identical in both sets.
Both sides share the same assumptions, so they move together — which is why a rezone question is better answered with the delta and the floor-area change than with an absolute unit count. The direction and rough magnitude survive an assumption disagreement that would swamp the absolute.

Verification & testing jump to heading

def test_the_range_is_wide_because_the_assumptions_are_uncertain():
    lo, hi = CONSERVATIVE.bounds()
    y_lo = unit_yield(CAP, MU2, 4000.0, lo)
    y_hi = unit_yield(CAP, MU2, 4000.0, hi)
    assert y_hi["units"] > y_lo["units"] * 1.4      # not a false-precision band


def test_binding_constraint_is_named_and_can_be_parking():
    y = unit_yield(CAP_NO_SURFACE_ROOM, MU2_WITH_PARKING, 4000.0, CONSERVATIVE)
    assert y["binding"] == "parking"
    assert y["caps"]["parking"] < y["caps"]["floor_area"]


def test_the_statement_names_the_divisor():
    s = yield_statement(CAP, MU2, 4000.0)
    assert "85 m² average units" in s and "82% efficiency" in s
    assert "derives from the ordinance" in s        # separates fact from assumption


def test_delta_is_less_assumption_sensitive_than_the_absolute():
    lo, hi = CONSERVATIVE.bounds()
    d_lo = rezone_delta(PARCEL, R1, MU2, STREETS, lo)
    d_hi = rezone_delta(PARCEL, R1, MU2, STREETS, hi)
    abs_spread = d_hi["units_after"] / max(d_lo["units_after"], 1)
    delta_spread = d_hi["delta_units"] / max(d_lo["delta_units"], 1)
    assert delta_spread < abs_spread                # the point of reporting the delta


def test_storey_count_never_falls_below_one():
    y = unit_yield(CAP, LOW_HEIGHT_STD, 4000.0, CONSERVATIVE)
    assert y["storeys"] >= 1

Failure recovery jump to heading

A single number already published and quoted. Re-issue as a range with the assumption set named, and say plainly which inputs were assumed. Expect the range to be wider than the audience expects; that is the correction, not a hedge.

Two teams disagreeing about a parcel’s yield. Compare assumption sets before comparing figures. Almost always they differ on average unit size or efficiency, which means they are not disagreeing about zoning at all — and naming the sets turns an argument into a one-line reconciliation.

Yield figures that survived an ordinance amendment. They are stale in two independent ways: the standards changed and the assumption set may have too. Re-run and stamp both the standards’ effective date and the assumption-set name, so the next reader can tell which part moved.

Frequently asked questions jump to heading

Why not just publish floor area and let the client do the division?

That is the better answer whenever the client will accept it, and it should be the default. Floor area follows from the ordinance and can be defended line by line; unit yield cannot. Publish the yield only when it is genuinely required, and when you do, keep the divisor in the same sentence so the reader can substitute their own.

How wide should the range be?

As wide as the assumptions genuinely are — typically plus or minus a quarter to a third of the midpoint. A narrow range implies precision the inputs do not support, and a reader who sees 71–112 asks about the assumptions, which is exactly the conversation worth having. Narrowing it to look authoritative is the failure mode.

Should parking really cap the unit count?

On a surface-parked site, frequently — parking and the building compete for the same envelope, so a high floor-area allowance is unusable if the required stalls will not fit. When parking binds, that is the most useful thing in the report, because a parking reduction is a far more common entitlement ask than a height variance.

Why is the delta more defensible than the absolute?

Because the same assumptions apply before and after the rezone, so an error in unit size or efficiency largely cancels in the difference while it applies at full strength to each absolute figure. For the question “what does this rezone change?”, the delta is both the more accurate answer and the one being asked.