Detecting US Survey Foot vs International Foot Errors Jump to heading

Two feet exist, and they differ by exactly 2 parts per million. The US survey foot is 1200/3937 metres; the international foot is 0.3048 metres exactly. That 2 ppm gap is invisible near a State Plane origin and grows to roughly 0.6 metres at a two-million-foot northing — enough to push a parcel corner across a boundary or fail a survey-grade tolerance. The error enters when data tagged as one foot is treated as the other, most often because a legacy .prj omits the distinction or a tool defaults to the international foot. This procedure detects the mismatch from CRS metadata and coordinate magnitude, corrects it, and accounts for the National Geodetic Survey’s 2022 deprecation of the US survey foot. It is a focused build within Unit Conversion & Tolerance Thresholds, the parent stage inside CRS Normalization & Sync that owns the numeric budgets a coordinate must satisfy.

The steps follow configure (Step 1, unit inspection), execute (Steps 2-3, magnitude check and correction), validate (Step 4, control-point check), and log (Step 5, unit lineage). The whole discipline hinges on one fact: a 2 ppm scale error is below most naive sanity checks yet above a survey tolerance, so it must be caught structurally.

Prerequisites checklist Jump to heading

Step 1: Inspect the declared linear unit Jump to heading

Start from the CRS metadata. pyproj exposes the axis unit name and its conversion factor to metres; the US survey foot reports 0.30480060... while the international foot reports exactly 0.3048. A CRS whose unit is metre but whose coordinates are in the hundred-thousands is a red flag handled in Step 2.

python
# pyproj >= 3.6 — Python 3.10+
from enum import Enum
from pyproj import CRS

US_SURVEY_FOOT_M = 1200.0 / 3937.0   # 0.3048006096012192
INTL_FOOT_M = 0.3048                 # exact


class LinearUnit(Enum):
    US_SURVEY_FOOT = "us_survey_foot"
    INTERNATIONAL_FOOT = "international_foot"
    METRE = "metre"
    UNKNOWN = "unknown"


def classify_unit(crs_input) -> tuple[LinearUnit, float]:
    """Return the linear unit class and its metre conversion factor from CRS metadata."""
    crs = CRS.from_user_input(crs_input)
    axis = crs.axis_info[0]
    factor = axis.unit_conversion_factor
    if abs(factor - US_SURVEY_FOOT_M) < 1e-9:
        return LinearUnit.US_SURVEY_FOOT, factor
    if abs(factor - INTL_FOOT_M) < 1e-9:
        return LinearUnit.INTERNATIONAL_FOOT, factor
    if abs(factor - 1.0) < 1e-9:
        return LinearUnit.METRE, factor
    return LinearUnit.UNKNOWN, factor

Inspection rules:

  • Distinguish the two feet by conversion factor, not by unit name — legacy .prj files label both simply “Foot”.
  • Treat LinearUnit.UNKNOWN as a quarantine condition; an unrecognized factor is an unresolved CRS, per Projection Normalization Workflows.
  • Never assume the international foot as a default — for US State Plane data the survey foot is the historical norm.

Step 2: Cross-check coordinate magnitude Jump to heading

Metadata can lie; coordinates cannot. Each State Plane zone has a published false easting/northing, so real coordinates cluster in a known range. If a layer is declared in metres but its magnitudes match the foot-scale false-origin range (or vice versa), the unit is mislabeled — the classic symptom of the survey-vs-international mixup and of a metre/foot swap.

python
# geopandas >= 0.14, numpy >= 1.26 — Python 3.10+
import numpy as np
import geopandas as gpd


def magnitude_consistent(gdf: gpd.GeoDataFrame, expected_unit: "LinearUnit") -> bool:
    """Flag when coordinate magnitude contradicts the declared linear unit."""
    xs = np.abs(gdf.geometry.x.to_numpy())
    ys = np.abs(gdf.geometry.y.to_numpy())
    median_mag = float(np.median(np.concatenate([xs, ys])))

    # Foot-based State Plane easting/northing commonly run 1e5–1e7; metre-based 1e5–1e6.
    looks_like_feet = median_mag > 1.5e6
    if expected_unit is LinearUnit.METRE and looks_like_feet:
        return False   # declared metre but magnitude is foot-scale
    if expected_unit in (LinearUnit.US_SURVEY_FOOT, LinearUnit.INTERNATIONAL_FOOT) and median_mag < 1e5:
        return False   # declared feet but magnitude is too small
    return True

Magnitude rules:

  • Compare against the zone’s published false origin, not a global constant; each State Plane zone differs.
  • A metre/foot swap shows as a ~3.28x magnitude error; a survey/international swap shows as a subtle 2 ppm scale error only detectable at Step 4.
  • Route any magnitude/metadata contradiction to quarantine before correcting — never silently rescale.

Step 3: Correct the unit and reproject Jump to heading

Once the true unit is known, correct it by assigning the correct EPSG code rather than multiplying coordinates by a scale factor. Post-2022, EPSG publishes explicit metre-based State Plane codes and separately retains the survey-foot variants; pick the code that matches the data’s real unit, then reproject to the canonical target.

python
# pyproj >= 3.6, geopandas >= 0.14 — Python 3.10+
import geopandas as gpd

# Example: NY Long Island State Plane. 2263 is US survey foot; 32118 is metre.
STATE_PLANE_BY_UNIT = {
    LinearUnit.US_SURVEY_FOOT: "EPSG:2263",   # survey-foot definition (legacy, still valid)
    LinearUnit.INTERNATIONAL_FOOT: "EPSG:2263",  # verify: intl-foot variants have distinct codes
    LinearUnit.METRE: "EPSG:32118",
}


def correct_unit(gdf: gpd.GeoDataFrame, true_unit: "LinearUnit", target_epsg: int = 4326) -> gpd.GeoDataFrame:
    """Re-stamp the correct source EPSG for the real unit, then reproject to the target."""
    correct_src = STATE_PLANE_BY_UNIT[true_unit]
    fixed = gdf.set_crs(correct_src, allow_override=True)  # fix the label, not the numbers
    return fixed.to_crs(epsg=target_epsg)

Correction rules:

  • Fix the CRS label to the correct EPSG code and let PROJ apply the exact foot definition; do not hand-multiply by 0.3048 and lose the 2 ppm.
  • Confirm the chosen EPSG code’s unit conversion factor with Step 1 before committing — a wrong code reintroduces the error.
  • Reproject through the canonical target the same way Multi-CRS Dataset Harmonization resolves every source, so the corrected layer joins the harmonized output cleanly.

Step 4: Validate against a known control point Jump to heading

The 2 ppm error is too small for a magnitude check to catch, so validate against a surveyed control point with a published coordinate. After correction, the control point must land within survey tolerance; if it is off by ~0.6 m at a two-million-foot northing, the foot definition is still wrong.

python
# pyproj >= 3.6 — Python 3.10+
from pyproj import Transformer


def validate_control_point(
    src_epsg: str, x: float, y: float, known_lon: float, known_lat: float, tol_m: float
) -> float:
    """Return the residual (metres) between a corrected control point and its published position."""
    tf = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True)
    lon, lat = tf.transform(x, y)
    geod = __import__("pyproj").Geod(ellps="GRS80")
    _, _, residual_m = geod.inv(lon, lat, known_lon, known_lat)
    if residual_m > tol_m:
        raise ValueError(f"control point off by {residual_m:.3f} m > {tol_m} m — unit still wrong")
    return residual_m

Validation rules:

  • Use a control point at a large coordinate value; near the origin the 2 ppm error vanishes and hides the defect.
  • Gate the residual against the survey tolerance from Unit Conversion & Tolerance Thresholds, not a loose default.
  • A residual near 0.6 m at ~2e6 ft is the fingerprint of a swapped foot definition, not a datum problem.

Step 5: Log the unit lineage Jump to heading

Record the detected unit, the correction, and the EPSG code so a reviewer can confirm the fix and so the 2022 deprecation transition is traceable.

python
# Python 3.10+
import json
from pathlib import Path


def log_unit_lineage(path: Path, detected: "LinearUnit", corrected_epsg: str, residual_m: float) -> None:
    record = {
        "detected_unit": detected.value,
        "corrected_source_epsg": corrected_epsg,
        "control_residual_m": round(residual_m, 4),
        "note": "US survey foot deprecated by NGS effective 2022; metre-based codes preferred for new data",
    }
    with path.open("a") as fh:
        fh.write(json.dumps(record) + "\n")

Route these rows to the append-only store consumed by Geospatial Compliance Reporting & Audit Trails. Because NGS retired the US survey foot at the end of 2022 in favor of the international foot, new datasets should migrate to metre-based State Plane codes; the lineage record documents which legacy foot a historical layer actually used so a later audit can reconcile it.

Verification Jump to heading

Confirm the correction removed the 2 ppm error, not just relabeled it:

python
unit, factor = classify_unit(gdf.crs)

# 1. The declared unit is one of the three known classes, not UNKNOWN.
assert unit is not LinearUnit.UNKNOWN, f"unrecognized unit factor {factor}"

# 2. Metadata and magnitude agree after classification.
assert magnitude_consistent(gdf, unit), "coordinate magnitude contradicts declared unit"

# 3. A far-from-origin control point lands within tolerance post-correction.
residual = validate_control_point("EPSG:2263", 1_009_900.0, 202_500.0, -73.98, 40.74, tol_m=0.03)
assert residual <= 0.03, f"unit correction failed: residual {residual} m"

On the CLI, projinfo EPSG:2263 | grep -i unit reports the survey-foot conversion factor 0.30480060..., distinguishing it from an international-foot code’s exact 0.3048; a control point transformed with the correct code matches its published latitude/longitude to millimetres.

Troubleshooting Jump to heading

Symptom Likely cause Fix
Parcel corners off by ~0.6 m at large northings Survey foot data treated as international foot (2 ppm scale error) Re-classify the unit by conversion factor and re-stamp the correct EPSG (Steps 1, 3).
Coordinates ~3.28x too large or small Metre/foot swap, not survey/international Cross-check magnitude against the zone false origin and correct the unit (Step 2).
LinearUnit.UNKNOWN returned Non-standard or corrupt unit definition in the .prj Quarantine and resolve via projection normalization before correcting (Step 1).
Error vanishes in testing but appears in production Control point sampled near the origin where 2 ppm is negligible Validate with a far-from-origin control point (Step 4).
New data still uses survey-foot codes Pipeline not updated for the 2022 NGS deprecation Migrate new layers to metre-based State Plane codes; log the transition (Step 5).