Unit Conversion Pitfalls in Setback Thresholds
A setback threshold is a number and a unit, and pipelines routinely store only the number. The unit then lives in the CRS, in a variable name, in a comment, or nowhere — and because a compliance pipeline compares a measured distance against a threshold, a unit mismatch does not crash. It returns a verdict, confidently, that is wrong by a factor of 3.28. This guide covers the three unit failures that actually occur in zoning work — feet against metres, the US survey foot against the international foot, and area units that do not follow from linear ones — and how to encode a threshold so none of them can happen quietly. It is the units half of spatial threshold configuration.
Prerequisites
Step-by-step
Step 1: Make the threshold carry its unit
The fix for most of this is structural rather than arithmetic: a bare float cannot be checked, and a threshold that carries its unit can be.
from dataclasses import dataclass
LINEAR_TO_M = {
"m": 1.0,
"ft": 0.3048, # international foot, exact by definition
"us-ft": 1200.0 / 3937.0, # US survey foot, exact by definition
}
@dataclass(frozen=True)
class Threshold:
value: float
unit: str
def to(self, unit: str) -> float:
"""Convert, refusing units that are not linear measures."""
if self.unit not in LINEAR_TO_M or unit not in LINEAR_TO_M:
raise ValueError(f"cannot convert {self.unit} to {unit}")
return self.value * LINEAR_TO_M[self.unit] / LINEAR_TO_M[unit]
FRONT_SETBACK = Threshold(25.0, "ft")
Now a comparison against a measurement in a layer whose unit is metres is a conversion the code performs deliberately, and a comparison against a measurement in unknown units is an exception rather than a verdict.
Step 2: Know which foot your state plane zone uses
This is the failure that costs the most and is noticed the least. The US survey foot is 1200/3937 metres; the international foot is exactly 0.3048 metres. They differ by two parts per million, which sounds harmless until you remember that state plane coordinates are large numbers: at a northing of 2,000,000 feet, the two definitions place the same point four feet — about 1.2 metres — apart.
from pyproj import CRS
def linear_unit(crs) -> str:
"""The layer's linear unit, distinguishing the two feet by name."""
axis = CRS.from_user_input(crs).axis_info[0]
name = axis.unit_name.lower()
if "us survey" in name or "ussurvey" in name:
return "us-ft"
if "foot" in name or "feet" in name:
return "ft"
if "metre" in name or "meter" in name:
return "m"
raise ValueError(f"unrecognised linear unit: {axis.unit_name}")
Two things make this worse than it should be. The US survey foot was formally deprecated at the end of 2022, so newer EPSG definitions and newer PyProj releases increasingly resolve to the international foot for codes that historically meant the survey foot — meaning the same EPSG code can produce coordinates over a metre apart on two machines with different PyProj versions. And a distance of twenty-five feet is the same under both definitions to within 0.00005 feet, so a setback check will never reveal the problem. It surfaces as a parcel that has moved, not as a threshold that is wrong.
Pin the PyProj and PROJ versions in the environment, record them in the run manifest, and check the axis unit at load time rather than trusting the EPSG code to mean what it meant when the layer was made. The related trap is covered in validating CRS metadata before a compliance run.
Step 3: Convert the threshold, never the geometry
There are two ways to reconcile a threshold in feet with a layer in metres, and only one of them is safe. Converting the threshold is a single multiplication on a single number. Reprojecting the layer to match the threshold’s unit moves every vertex, changes every area, and introduces a second CRS into the run for no benefit.
def check_setback(gdf, threshold: Threshold, measured_col="front_setback"):
"""Compare in the layer's own unit; convert the number, not the geometry."""
unit = linear_unit(gdf.crs)
limit = threshold.to(unit)
out = gdf.assign(threshold_native=limit, threshold_unit=unit)
out["compliant"] = out[measured_col] >= limit
return out
Recording threshold_native and threshold_unit on the output is what makes the conversion auditable. A reviewer who sees 25.0 ft become 7.62 m can confirm it in their head; a reviewer who sees only compliant: False cannot.
Step 4: Derive area thresholds rather than typing them
Area units are the second-order version of the same problem, and hand-conversion is where it goes wrong. One acre is 43,560 square feet, which people remember, and 4046.8564224 square metres, which they do not — so the constant gets rounded, and a density threshold acquires a fifth-decimal-place error that only matters on the parcels closest to the line.
def area_limit(value: float, unit: str, target_unit: str) -> float:
"""Area conversions are the square of the linear factor. Derive, do not type."""
factor = LINEAR_TO_M[unit] / LINEAR_TO_M[target_unit]
return value * factor ** 2
SQFT_PER_ACRE = 43560.0
min_lot_sqm = area_limit(SQFT_PER_ACRE * 0.25, "ft", "m") # quarter-acre minimum
Deriving the area factor as the square of the linear one is not merely tidier — it means there is one table of constants in the codebase, and it is the exact one. It also makes the survey-foot difference propagate correctly: in square feet the two definitions differ by four parts per million, which on a forty-acre site is about seven square feet, and on a threshold expressed to the nearest square foot that is occasionally decisive.
Step 5: Assert the unit at the boundary of every stage
Units go wrong when data crosses a boundary — a file read, a database round-trip, a handoff between two steps. Assert at each one and the class of bug disappears.
def require_unit(gdf, expected: str, stage: str):
got = linear_unit(gdf.crs)
if got != expected:
raise ValueError(f"{stage}: expected {expected}, layer is in {got}")
return gdf
parcels = require_unit(read_parcels(path), "us-ft", "setback evaluation")
This costs microseconds and catches the case that no amount of reviewing catches: a layer that was correct last month and is in a different projection this month because an upstream agency republished it.
Verification
The check that actually proves the pipeline’s units are right is a round-trip against a parcel with a hand-confirmed answer, plus an assertion that the conversion table itself is exact.
# The two feet are defined exactly; assert the definitions, not approximations.
assert LINEAR_TO_M["ft"] == 0.3048
assert abs(LINEAR_TO_M["us-ft"] - 0.3048006096012192) < 1e-15
# A 25 ft threshold is 7.62 m exactly.
assert abs(Threshold(25.0, "ft").to("m") - 7.62) < 1e-12
# The survey-foot difference is invisible at threshold scale and large at coordinate scale.
northing_usft = 2_000_000.0
delta_ft = northing_usft * (LINEAR_TO_M["us-ft"] - LINEAR_TO_M["ft"]) / LINEAR_TO_M["ft"]
assert 3.99 < delta_ft < 4.01, "the two feet differ by ~4 ft at state plane northings"
That last assertion is the one to keep in the suite. It states, executably, why the distinction matters, and it fails loudly if someone ever “simplifies” the constants table.
Common Pitfalls
- Storing thresholds as bare floats. The unit then lives in a variable name, and variable names do not survive a refactor or a serialisation round-trip.
- Assuming an EPSG code fixes the unit forever. Survey-foot deprecation means the same code can resolve differently across PROJ versions. Read the axis unit from the loaded CRS.
- Reprojecting the layer to match the threshold. It moves every vertex to avoid one multiplication, and it introduces a second CRS whose provenance now has to be tracked.
- Typing area conversion constants. Derive them by squaring the linear factor. A rounded 4046.86 is a real error on large parcels.
- Converting twice. A threshold converted at load and again at comparison is off by the square of the factor — 10.76× for feet to metres, which is large enough that somebody notices, unlike most of these.
Frequently Asked Questions
Which foot should a new project use?
The international foot, unless the authoritative parcel fabric is published in the survey foot — in which case match the fabric and record that you did. The survey foot is deprecated but a great deal of existing state plane data uses it, and converting an authoritative layer to align with a preference is a worse decision than adopting its unit.
Does the survey-foot difference ever change a verdict?
Not through the threshold, which is unaffected at two parts per million. It changes verdicts through position: a parcel boundary displaced by four feet relative to a district boundary can cross into another district, which changes the applicable rule entirely. That is why the check belongs at CRS validation rather than at threshold comparison.
How should the unit appear in reports?
As adopted. A twenty-five foot setback should read as twenty-five feet in the certificate even if the pipeline compared metres internally, because the reader is reconciling against the ordinance. Carry both — the native comparison and the adopted expression — as covered in capturing CRS provenance in validation logs.
What about thresholds expressed as ratios?
Floor-area ratios, lot coverage percentages and similar are dimensionless, so they convert trivially — but only if the numerator and denominator are in the same unit system. The failure there is a floor area in square feet over a lot area in square metres, which yields a ratio wrong by a factor of about 10.76 and looks like a wildly non-compliant parcel. Assert that both inputs share a unit before dividing. Implementing FAR checks with Shapely and GeoPandas covers the rest of that calculation.
Do vertical units need the same treatment?
Yes, and they are worse, because a layer’s vertical unit is frequently different from its horizontal one — a state plane zone in feet paired with elevations in metres is common in lidar-derived products. Height thresholds should carry their unit the same way, and the vertical datum needs recording too; computing building height from lidar-derived surfaces goes into that.
Is there a way to catch unit errors in testing rather than in production?
The golden-file approach works well here: fix a small parcel set, record the expected distances and verdicts with their units, and assert the whole table. A unit error changes every number in the table by the same factor, which makes it one of the easiest failures to spot in a diff — see writing golden-file tests for zoning rule packs.
Should the pipeline ever convert automatically?
Converting a threshold automatically is fine and desirable — it is one multiplication with a known factor. Converting a layer automatically is not, because it hides a CRS change from the audit trail. The rule that has held up well is: numbers convert silently, geometry converts loudly.
Related
Part of: Spatial threshold configuration
- Validating CRS metadata before a compliance run — where the axis unit gets checked.
- Choosing a projected CRS for a municipal compliance project — picking the frame whose unit you then live with.
- Translating ordinance text into machine-readable predicates — where the threshold and its unit are first recorded.
- Handling datum shifts NAD83 to WGS84 in compliance pipelines — the other multi-metre displacement nobody sees.
- Computing building height from lidar-derived surfaces — the same problem in the vertical.