Projection Math Fundamentals for Cadastral Surveys
Turning ellipsoidal survey observations into legally defensible plane coordinates is the projection-math sub-task at the heart of the Core Transformation Fundamentals & Standards reference: a parcel corner, a monument, or a boundary bearing only becomes a record-of-survey coordinate once a conformal map projection has mapped it from latitude and longitude onto a grid. This page narrows the standards framework to one operation — computing Transverse Mercator eastings and northings, the point scale factor, and the combined grid-to-ground factor to survey-grade precision — and shows the deterministic arithmetic that keeps that operation auditable under ISO 19111 coordinate-operation semantics. Unlike thematic mapping or web visualization, cadastral projection cannot tolerate truncated series, implicit ellipsoid defaults, or floating-point drift: a one-part-per-million scale error is roughly 1 mm per kilometre, which compounds into a metre across a municipal dataset and into a contested boundary in court.
The arithmetic below is the foundation that the grid-shift and datum stages build on. Before a coordinate is projected it must already sit in the correct datum realization — the choice of correction method is covered in NADCON vs NTv2: choosing the right datum shift, and the explicit reference-frame resolution that pins down axis order and units is covered in setting up high-precision coordinate reference systems.
The Conformal Projection Specification
Transverse Mercator ™ and Lambert Conformal Conic dominate cadastral practice because they are conformal: they preserve local angles, so a measured bearing maps to a grid bearing with only a small, computable convergence correction. Conformality is purchased at the cost of scale distortion that grows with distance from the central meridian ™ or standard parallels (Lambert). For boundary retracement the surveyor therefore needs three quantities at every point, not just the coordinate pair: the easting
Figure — point-scale distortion computed from the transverse Mercator series, k0 = 0.9996.
The exact TM forward mapping is a series in the ellipsoidal eccentricity. Writing
where
The plane coordinates then follow, with
The point scale factor is the companion series
Finally, survey deliverables work at ground distance, so the grid scale must be combined with an elevation (sea-level) reduction. With ellipsoidal height
Retaining the
Step-by-Step Implementation
The reference implementation keeps every ellipsoid and zone parameter explicit, performs the trigonometric series in IEEE 754 float64, and applies deterministic round-half-to-even only at the final commit so that no intermediate rounding biases the result. There are no hidden library defaults; the zone is declared in full, satisfying the ISO 19111 requirement that a coordinate operation name its source CRS, target CRS, and every method parameter.
Figure — the four quantities a cadastral projection routine must return together.
Step 1 — Declare the zone and ellipsoid explicitly
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class TMZone:
"""A fully specified Transverse Mercator zone (ISO 19111 conversion params).
No implicit defaults: every parameter that affects the coordinate is named,
so a pipeline can hash this object into its audit record.
"""
a: float # semi-major axis, metres (e.g. GRS80 = 6378137.0)
inv_f: float # inverse flattening 1/f (GRS80 = 298.257222101)
lat0_deg: float # latitude of origin, degrees
lon0_deg: float # central meridian, degrees
k0: float # scale factor on the central meridian
false_easting: float
false_northing: float
@property
def f(self) -> float:
return 1.0 / self.inv_f
@property
def e2(self) -> float:
# First eccentricity squared, derived — never assumed.
return 2.0 * self.f - self.f * self.f
@property
def ep2(self) -> float:
# Second eccentricity squared e'^2 = e^2 / (1 - e^2).
return self.e2 / (1.0 - self.e2)
Step 2 — Compute the meridional arc
def meridian_arc(a: float, e2: float, lat_rad: float) -> float:
"""Meridional arc length M from the equator to lat_rad (Snyder 3-21).
The e^6 term is retained so the arc is accurate to sub-millimetre at any
latitude — required for survey-grade northings.
"""
e4 = e2 * e2
e6 = e4 * e2
return a * (
(1 - e2 / 4 - 3 * e4 / 64 - 5 * e6 / 256) * lat_rad
- (3 * e2 / 8 + 3 * e4 / 32 + 45 * e6 / 1024) * math.sin(2 * lat_rad)
+ (15 * e4 / 256 + 45 * e6 / 1024) * math.sin(4 * lat_rad)
- (35 * e6 / 3072) * math.sin(6 * lat_rad)
)
Step 3 — Forward Transverse Mercator projection
def tm_forward(zone: TMZone, lat_deg: float, lon_deg: float) -> tuple[float, float, float]:
"""Project geodetic (lat, lon) to grid (E, N) and the point scale factor k.
Returns (easting_m, northing_m, k_point). Implements Snyder 8-9..8-11 with
the A^5/A^6 terms kept (sub-millimetre to ~5 deg from the central meridian).
"""
lat = math.radians(lat_deg)
lon = math.radians(lon_deg)
lat0 = math.radians(zone.lat0_deg)
lon0 = math.radians(zone.lon0_deg)
e2 = zone.e2
ep2 = zone.ep2
sin_lat = math.sin(lat)
cos_lat = math.cos(lat)
tan_lat = math.tan(lat)
nu = zone.a / math.sqrt(1.0 - e2 * sin_lat * sin_lat) # prime-vertical radius
t = tan_lat * tan_lat # T = tan^2(phi)
c = ep2 * cos_lat * cos_lat # C = e'^2 cos^2(phi)
a_term = (lon - lon0) * cos_lat # A = dlon * cos(phi)
m = meridian_arc(zone.a, e2, lat)
m0 = meridian_arc(zone.a, e2, lat0)
easting = zone.false_easting + zone.k0 * nu * (
a_term
+ (1 - t + c) * a_term ** 3 / 6
+ (5 - 18 * t + t * t + 72 * c - 58 * ep2) * a_term ** 5 / 120
)
northing = zone.false_northing + zone.k0 * (
m - m0
+ nu * tan_lat * (
a_term ** 2 / 2
+ (5 - t + 9 * c + 4 * c * c) * a_term ** 4 / 24
+ (61 - 58 * t + t * t + 600 * c - 330 * ep2) * a_term ** 6 / 720
)
)
k_point = zone.k0 * (
1
+ (1 + c) * a_term ** 2 / 2
+ (5 - 4 * t + 42 * c + 13 * c * c - 28 * ep2) * a_term ** 4 / 24
+ (61 - 148 * t + 16 * t * t) * a_term ** 6 / 720
)
return easting, northing, k_point
Step 4 — Resolve the combined grid-to-ground scale factor
def combined_scale_factor(
zone: TMZone, lat_deg: float, ellipsoidal_height_m: float, k_point: float
) -> tuple[float, float]:
"""Combine the projection (grid) factor with the elevation reduction.
Returns (k_combined, R) where R is the geometric-mean radius of curvature.
k_combined converts a ground distance to a grid distance and back.
"""
lat = math.radians(lat_deg)
e2 = zone.e2
sin2 = math.sin(lat) ** 2
meridian_radius = zone.a * (1.0 - e2) / (1.0 - e2 * sin2) ** 1.5
prime_vertical = zone.a / math.sqrt(1.0 - e2 * sin2)
r_mean = math.sqrt(meridian_radius * prime_vertical) # R = sqrt(M_rho * nu)
k_elev = r_mean / (r_mean + ellipsoidal_height_m) # sea-level reduction
return k_point * k_elev, r_mean
Parameter and Return-Value Reference
| Symbol / field | Type | Units | Valid range | Cadastral significance |
|---|---|---|---|---|
a |
float |
metres | 6 370 000–6 380 000 | Ellipsoid semi-major axis; GRS80 = 6378137.0 for NAD83 work |
inv_f |
float |
dimensionless | 290–300 | Inverse flattening; fixes ellipsoid shape (GRS80 = 298.257222101) |
lat0_deg, lon0_deg |
float |
degrees | −90…90 / −180…180 | Origin and central meridian of the published zone |
k0 |
float |
dimensionless | 0.9996–1.0 | Central-meridian scale; UTM = 0.9996, varies per SPCS zone |
false_easting, false_northing |
float |
metres | ≥ 0 | Offsets that keep grid coordinates positive |
lat_deg, lon_deg |
float |
degrees | within zone extent | The geodetic point to be projected |
ellipsoidal_height_m |
float |
metres | −500…9000 | Height above the ellipsoid; drives the elevation reduction |
easting_m, northing_m |
float |
metres | zone-dependent | Plane coordinates committed to the record of survey |
k_point |
float |
dimensionless | ≈ k0…1.0004 | Grid scale; multiply geodesic distance to get grid distance |
k_combined |
float |
dimensionless | ≈ 0.9994…1.0004 | Grid-to-ground factor; the value the field crew actually applies |
r_mean (R) |
float |
metres | ≈ 6 370 000–6 400 000 | Geometric-mean radius used in the elevation reduction |
Worked Example: a Vermont State Plane Monument
Vermont uses a single Transverse Mercator zone, EPSG:32145 (NAD83 / Vermont, GRS80), with central meridian −72.5°, latitude of origin 42.5°,
vermont = TMZone(
a=6378137.0,
inv_f=298.257222101,
lat0_deg=42.5,
lon0_deg=-72.5,
k0=0.999964285714286,
false_easting=500000.0,
false_northing=0.0,
)
easting, northing, k_point = tm_forward(vermont, 44.26, -72.58)
k_combined, r_mean = combined_scale_factor(vermont, 44.26, 350.0, k_point)
print(f"E = {easting:.4f} m") # E = 493611.8194 m
print(f"N = {northing:.4f} m") # N = 195532.3917 m
print(f"k_point = {k_point:.9f}") # k_point = 0.999964787
print(f"k_combined = {k_combined:.9f}")# k_combined = 0.999909912
The point sits about 6.4 km west of the central meridian, so the grid scale (
Verification and Residual Analysis
A projected coordinate is only trustworthy once it has been compared against an independently published value for the same monument. The verification step computes the horizontal residual, tests it against the survey-grade tolerance, and emits a structured record that an auditor can replay. Tolerance budgeting itself is treated in depth in tuning transformation thresholds for survey-grade work; here it is the gate that decides commit versus reject.
import json
from dataclasses import dataclass
@dataclass(frozen=True)
class Residual:
d_east_m: float
d_north_m: float
horizontal_m: float
within_tolerance: bool
def verify_against_control(
computed_en: tuple[float, float],
published_en: tuple[float, float],
tolerance_m: float = 0.025, # +/-25 mm, 95% CI: typical urban cadastral budget
) -> Residual:
"""Compare a computed (E, N) against a published control coordinate."""
d_e = computed_en[0] - published_en[0]
d_n = computed_en[1] - published_en[1]
horizontal = math.hypot(d_e, d_n)
return Residual(d_e, d_n, horizontal, horizontal <= tolerance_m)
def audit_record(epsg: int, residual: Residual) -> str:
"""Emit a deterministic JSON log line for the audit trail."""
return json.dumps(
{
"epsg": epsg,
"d_east_mm": round(residual.d_east_m * 1000, 2),
"d_north_mm": round(residual.d_north_m * 1000, 2),
"horizontal_mm": round(residual.horizontal_m * 1000, 2),
"status": "PASS" if residual.within_tolerance else "FAIL",
},
sort_keys=True,
)
# Published monument coordinate for the worked example above:
published = (493611.804, 195532.402)
res = verify_against_control((493611.8194, 195532.3917), published)
print(audit_record(32145, res))
# {"d_east_mm": 15.4, "d_north_mm": -10.3, "epsg": 32145, "horizontal_mm": 18.53, "status": "PASS"}
assert res.within_tolerance, "Residual exceeds survey-grade tolerance — reject and review"
The 18.5 mm horizontal residual clears the ±25 mm gate, so the coordinate commits with a PASS record. A failure here is never silently absorbed; it routes to manual review, exactly as a missing grid routes through the fallback routing strategies for missing grid files. Aggregating these residuals across a control network — RMSE, residual histograms, and outlier flagging — is the subject of validating datum alignment with control points.
Troubleshooting and Gotchas
- Wrong ellipsoid for the datum. Projecting NAD83 coordinates with WGS84 parameters (or vice versa) looks harmless because
differs by only ~0.1 mm, but pairing the wrong ellipsoid with the wrong datum realization injects up to a 2 m horizontal shift. Always derive e2from the zone’s declaredinv_f; never hard-code an eccentricity. - Truncating the projection series. Many lightweight projection routines drop the
/ terms. Inside a few kilometres of the central meridian the error is invisible, but at the longitude edge of a state-plane or UTM zone it reaches decimetres — past any cadastral tolerance. Keep the full series shown in Step 3. - Confusing grid scale with combined scale. The point scale factor
reduces to the ellipsoid only; it does not account for terrain height. Applying instead of leaves the entire elevation reduction (90 ppm at 350 m, far more in the mountains) unmodelled. Field distances always use the combined factor. - Orthometric height vs ellipsoidal height. The elevation reduction needs height above the ellipsoid (
), not the geoid (orthometric ). Feeding a levelling-derived orthometric height skips the geoid undulation , biasing the combined factor by the local separation — often 20–30 m of height, i.e. several ppm of scale. - Projecting before the datum shift. Projection assumes the coordinate is already in the target datum. If the input is still in a legacy realization, project after the shift, not before; reconciling those realizations is covered in handling CRS mismatches in cadastral datasets.
Frequently Asked Questions
Why does a grid distance differ from the distance I measured on the ground?
Because a projected coordinate system is a flattened model of a curved surface, and flattening always distorts. The point scale factor tells you by how much at a given position: multiply a ground distance by the mean point scale along the line to get the grid distance, and divide to go back. On a transverse Mercator zone with a central-meridian scale factor of 0.9996 the grid is 400 parts per million short on the central meridian and roughly 570 parts per million long at the zone edge — a 40 centimetre disagreement over a kilometre in either direction. A cadastral traverse that reduces its distances without applying the scale factor will close beautifully against itself and disagree with every neighbouring survey.
Is the grid convergence the same as magnetic declination?
No, and conflating them is a classic source of rotated parcels. Grid convergence is the angle between grid north and geodetic (true) north at a point, and it is a purely geometric consequence of the projection — it depends only on your position relative to the central meridian and can be computed exactly. Magnetic declination is the angle between magnetic north and true north, is a property of the Earth’s magnetic field, changes measurably from year to year, and can only be looked up in a model. Converting a geodetic azimuth to a grid bearing needs convergence; converting a compass bearing needs both.
Can I just use a local plane approximation over a small site?
Over a site of a few hundred metres the curvature error is genuinely negligible, and a local plane is defensible for internal geometry. It stops being defensible the moment the coordinates leave the site: a local plane has no datum, no scale factor and no convergence, so nobody downstream can relate it to anything. If you work in a local plane, publish the transformation that maps it to the national grid alongside it — that is exactly the job the affine and similarity transformations in the workflows section exist to do.
Which ellipsoid constants should a projection routine use?
The ones belonging to the geographic CRS that the coordinates are actually in, read from the CRS definition rather than from a constant in your source file. GRS80 and WGS84 differ in the flattening term at about the tenth decimal place, which is under a millimetre and usually ignorable; Clarke 1866 and GRS80 differ by hundreds of metres in the semi-major axis and are absolutely not interchangeable. A projection routine that hard-codes its ellipsoid works perfectly until the day someone hands it NAD27 data.
Related
- Core Transformation Fundamentals & Standards — parent reference for the full ISO 19111 transformation framework.
- Handling CRS mismatches in cadastral datasets — resolving legacy state-plane and UTM realizations before projection.
- NADCON vs NTv2: choosing the right datum shift — selecting the correction kernel that precedes the projection step.
- Setting up high-precision coordinate reference systems — pinning down axis order, units, and zone parameters.
- Validating datum alignment with control points — network-level residual analysis and RMSE reporting.
- Geodetic conversion math: ellipsoid to Cartesian — the companion forward/inverse geodetic transforms.