Calculating FAR and density across split-zoned parcels
Parcel 0714-22-9 is 4 000 m². Sixty per cent of it is zoned MU-2 with a floor-area ratio of 2.0; the remaining forty per cent is R-1 at 0.5. What is the allowable floor area? The plausible answers are 6 400 m² (each portion at its own FAR), 8 000 m² (the whole lot at the higher FAR), 2 000 m² (the whole lot at the lower), and “it depends on the ordinance” — which is the correct one, and the reason a pipeline must not pick silently. This guide sets out which standards are genuinely per-portion, which apply to the whole lot, and how to record the choice, extending development capacity & buildable area analysis.
Diagnosis: which standards are per-portion and which are not jump to heading
The distinction is not stylistic. It follows from what each standard is measured against, and getting it wrong changes the answer by a factor of three on real parcels.
| Standard | Measured against | On a split-zoned lot |
|---|---|---|
| Setbacks | the lot’s boundary | per portion — each district’s setbacks apply where it applies |
| Height | a point on the ground | per portion — the limit changes at the district line |
| Lot coverage | total lot area | usually whole-lot; a per-portion reading double-counts the denominator |
| Floor-area ratio | total lot area | ordinance-dependent — this is the ambiguous one |
| Density (units/acre) | total lot area | ordinance-dependent, and often explicitly addressed |
| Minimum lot area | the lot | whole-lot; a portion is not a lot |
The two ambiguous rows are ambiguous because FAR and density are ratios against lot area, so applying them per portion requires deciding whether “lot area” means the portion or the parcel. Many ordinances say; some do not.
def split_zoning_report(parcel, districts_gdf):
"""Portions, their areas, and whether the ordinance addresses the split."""
parts = overlay_portions(parcel, districts_gdf) # one row per district overlap
total = parcel.geometry.area
return [{
"district": p.zoning_code,
"area_m2": round(p.geometry.area, 1),
"share": round(p.geometry.area / total, 4),
"far": p.standards.numeric.get("far"),
"density": p.standards.numeric.get("units_per_acre"),
# If the ordinance has a split-lot provision, it is authoritative and this
# whole question is already answered.
"split_rule_cited": p.standards.split_lot_rule, # e.g. '§14-118(c)' or None
} for p in parts]
If split_rule_cited is populated for any portion, stop: the ordinance has answered, and the pipeline’s job is to apply it and cite it. The rest of this page is about the case where it has not.
Step-by-step implementation jump to heading
1. Model the four candidate rules explicitly jump to heading
from dataclasses import dataclass
from enum import Enum
class SplitRule(str, Enum):
PRORATED = "prorated" # each portion contributes portion_area × its own FAR
WHOLE_AT_PREDOMINANT = "whole_predominant" # whole lot at the majority district's FAR
WHOLE_AT_MOST_RESTRICTIVE = "whole_restrictive"
PER_PORTION_INDEPENDENT = "per_portion" # each portion treated as its own site
@dataclass(frozen=True)
class SplitResult:
floor_area_m2: float
rule: SplitRule
authority: str # ordinance section, or 'policy:…'
detail: dict
def floor_area(portions, total_area_m2, rule: SplitRule, authority: str) -> SplitResult:
if rule is SplitRule.PRORATED:
fa = sum(p["area_m2"] * p["far"] for p in portions)
detail = {p["district"]: round(p["area_m2"] * p["far"], 1) for p in portions}
elif rule is SplitRule.WHOLE_AT_PREDOMINANT:
dom = max(portions, key=lambda p: p["area_m2"])
fa = total_area_m2 * dom["far"]
detail = {"predominant_district": dom["district"], "far": dom["far"]}
elif rule is SplitRule.WHOLE_AT_MOST_RESTRICTIVE:
low = min(portions, key=lambda p: p["far"])
fa = total_area_m2 * low["far"]
detail = {"most_restrictive_district": low["district"], "far": low["far"]}
elif rule is SplitRule.PER_PORTION_INDEPENDENT:
# Each portion is its own site, which usually means each must independently
# satisfy setbacks and minimum lot area — often it cannot.
fa = sum(p["area_m2"] * p["far"] for p in portions)
detail = {"warning": "each portion must independently meet minimum lot area "
"and setbacks; verify before using this rule"}
else:
raise ValueError(f"unhandled split rule {rule!r}")
return SplitResult(round(fa, 1), rule, authority, detail)
2. Refuse to choose a rule silently jump to heading
class SplitRuleUndeclared(Exception):
"""The ordinance is silent and no policy has been recorded. Any of the four
candidate answers differs from the others by a factor of three on a typical
split, so the pipeline must not pick one."""
def resolve_split(portions, total_area_m2, policy_registry, jurisdiction):
cited = next((p["split_rule_cited"] for p in portions if p["split_rule_cited"]), None)
if cited:
rule = policy_registry.rule_for_citation(cited)
return floor_area(portions, total_area_m2, rule, cited)
policy = policy_registry.default_for(jurisdiction)
if policy is None:
raise SplitRuleUndeclared(
f"{jurisdiction}: no split-lot provision cited and no recorded policy. "
f"Candidate answers range "
f"{min(total_area_m2 * p['far'] for p in portions):.0f}–"
f"{sum(p['area_m2'] * p['far'] for p in portions):.0f} m².")
return floor_area(portions, total_area_m2, policy.rule,
f"policy:{policy.rule.value}({policy.decided_on})")
3. Do the same for density, and watch the rounding jump to heading
Density compounds the ambiguity with a rounding question: 0.6 of a dwelling is not a dwelling, and rounding each portion separately gives a different total from rounding once.
ACRE_M2 = 4046.86
def unit_count(portions, total_area_m2, rule: SplitRule) -> dict:
"""Rounding per portion and rounding once give different answers. Ordinances
almost always round DOWN, and usually once, on the total."""
if rule is SplitRule.PRORATED:
exact_per_portion = [p["area_m2"] / ACRE_M2 * p["density"] for p in portions]
return {
"rounded_once": int(sum(exact_per_portion)), # the usual reading
"rounded_per_portion": sum(int(v) for v in exact_per_portion),
"exact": round(sum(exact_per_portion), 3),
}
dom = max(portions, key=lambda p: p["area_m2"])
return {"rounded_once": int(total_area_m2 / ACRE_M2 * dom["density"]),
"rounded_per_portion": None,
"exact": round(total_area_m2 / ACRE_M2 * dom["density"], 3)}
On the worked parcel — 2 400 m² at 30 units/acre plus 1 600 m² at 4 — rounding once gives 19 units and rounding per portion gives 18. One unit is not a rounding curiosity when it decides whether a project pencils.
4. Keep the portions in the record jump to heading
def capacity_record(parcel_id, portions, result, envelope_by_portion):
return {
"parcel_id": parcel_id,
"split_zoned": len(portions) > 1,
"portions": portions, # district, area, share, FAR, density
"floor_area_m2": result.floor_area_m2,
"rule": result.rule.value,
"authority": result.authority, # the part that makes it checkable
"detail": result.detail,
# Setbacks and height ARE per portion, so the envelope is too.
"envelope_by_portion_m2": {k: round(v.area, 1)
for k, v in envelope_by_portion.items()},
}
Verification & testing jump to heading
PORTIONS = [
{"district": "MU-2", "area_m2": 2400.0, "share": 0.6, "far": 2.0, "density": 30,
"split_rule_cited": None},
{"district": "R-1", "area_m2": 1600.0, "share": 0.4, "far": 0.5, "density": 4,
"split_rule_cited": None},
]
TOTAL = 4000.0
def test_the_four_rules_give_materially_different_answers():
answers = {r: floor_area(PORTIONS, TOTAL, r, "test").floor_area_m2
for r in (SplitRule.PRORATED, SplitRule.WHOLE_AT_PREDOMINANT,
SplitRule.WHOLE_AT_MOST_RESTRICTIVE)}
assert answers[SplitRule.PRORATED] == 5600.0
assert answers[SplitRule.WHOLE_AT_PREDOMINANT] == 8000.0
assert answers[SplitRule.WHOLE_AT_MOST_RESTRICTIVE] == 2000.0
assert max(answers.values()) / min(answers.values()) == 4.0 # a factor of four
def test_undeclared_rule_refuses_with_the_range():
with pytest.raises(SplitRuleUndeclared, match="2000–5600"):
resolve_split(PORTIONS, TOTAL, EMPTY_REGISTRY, "larimer")
def test_cited_ordinance_beats_policy():
portions = [dict(p) for p in PORTIONS]
portions[0]["split_rule_cited"] = "§14-118(c)"
result = resolve_split(portions, TOTAL, REGISTRY_WITH_CITATION, "larimer")
assert result.authority == "§14-118(c)"
def test_rounding_once_differs_from_rounding_per_portion():
counts = unit_count(PORTIONS, TOTAL, SplitRule.PRORATED)
assert counts["rounded_once"] == 19
assert counts["rounded_per_portion"] == 18
def test_setbacks_are_always_per_portion():
"""Unlike FAR, setbacks are unambiguous: they apply where their district applies."""
envs = envelope_by_portion(PARCEL, PORTIONS, STREETS)
assert set(envs) == {"MU-2", "R-1"}
assert envs["MU-2"].area != envs["R-1"].area
The first test is the one to show anybody who thinks the pipeline can pick a default: the same parcel yields 2 000, 5 600 or 8 000 square metres depending on a reading of the ordinance.
Failure recovery jump to heading
Capacity published with an unrecorded rule. Every split-zoned parcel’s figure is unattributable. Identify them by split_zoned and re-issue with the rule and authority attached; the set is usually a small percentage of parcels and disproportionately the valuable ones, since split zoning clusters on corridor edges.
A rule applied that the ordinance contradicts. Correct the registry, keep the superseded row with its dates, and re-resolve. Figures issued earlier were correct under the recorded rule at the time, which the authority string and date are there to show.
Per-portion rounding used for density. Recompute with a single rounding on the total and diff. The difference is at most one or two units per parcel and it moves in one direction, so a portfolio total can shift noticeably.
Frequently asked questions jump to heading
Can the pipeline just prorate and be done?
Proration is the most common ordinance reading and it is still a choice — on the worked parcel it gives 5 600 m² where a whole-lot reading of the predominant district gives 8 000. Prorating and recording that you prorated is fine; prorating silently means the figure cannot be defended when a jurisdiction turns out to read its own code differently.
Are setbacks ambiguous on a split-zoned lot too?
No, and that is the useful asymmetry. Setbacks and height are measured against the boundary and the ground, so each district’s standards apply where that district applies — there is nothing to apportion. FAR and density are ratios against lot area, which is what makes “which lot area?” a real question.
Should each portion be treated as an independent site?
Rarely, and check before doing it. Independent treatment usually requires each portion to satisfy minimum lot area and its own setbacks, which a 1 600 m² remainder frequently cannot. When it does hold, it is the most generous reading and needs the firmest citation.
How should the unit count be rounded?
Down, and once, on the total — that is the common ordinance reading. Rounding each portion down separately discards a fraction from every portion and understates the total, which on the worked parcel costs a unit. Whichever you use, record it: a single unit changes project feasibility often enough to be worth a line in the record.
Related jump to heading
- Parent topic: Development Capacity & Buildable Area Analysis
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Spatial Overlay Analysis — the overlay that produces the portions in the first place
- Overlay District Modeling — a partly-covering overlay poses the same apportionment question
- Computing a buildable envelope from setbacks and height limits — per-portion envelopes, which are unambiguous