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.
| 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.
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.
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.
Related jump to heading
- Parent topic: Development Capacity & Buildable Area Analysis
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Calculating FAR and density across split-zoned parcels — where the floor-area input comes from when a lot spans districts
- Zoning Change Alerting — attaching the delta to the change event a subscriber receives
- Overlay District Modeling — the resolved standards, with the citations the statement quotes