FA-70021 / Map projection transforms / Open access
Spherical transverse mercator in a UTM grid: grid hemisphere flag · case 01
Points just north of the equator in a southern grid jump by ten thousand kilometres.
ROOT CAUSE
The false northing is chosen from the sign of the point latitude instead of the requested grid hemisphere.
VERIFIED REPAIR
At the grid hemisphere flag step restore `(10000000.0 if hemi == 'S' else 0.0)`, leaving the rest of the model unchanged.
Unsuccessful approach: Requiring both the S flag and a negative latitude still drops the false northing for northern points in a southern grid.
Case contract
Input [lon, lat, zone, hemisphere] with |lat| <= 80 and lon within 4 degrees of the zone central meridian 6*zone-183. On a sphere R = 6371000 with k0 = 0.9996: B = cos(phi)*sin(dl); x = 0.5*k0*R*ln((1+B)/(1-B)); y = k0*R*atan2(tan(phi), cos(dl)). Easting = 500000 + x. Northing = y plus 10000000 when the requested grid hemisphere is "S" (the caller may deliberately project points just north of the equator in a southern grid). Return [easting, northing] rounded to 2 decimals.
Why this case matters
Survey exports and GPS receivers express positions as UTM eastings and northings; a wrong false origin or meridian moves points by hundreds of kilometres.
1 / The failure
Exit 1"""Failure Map reference implementation. Python standard library only."""
import json
import math
N = 1
observations = []
def solve(x):
lon, lat, zone, hemi = x
R = 6371000.0
k0 = 0.9996
lon0 = zone * 6 - 183
dl = math.radians(lon - lon0)
phi = math.radians(lat)
B = math.cos(phi) * math.sin(dl)
xm = 0.5 * k0 * R * math.log((1 + B) / (1 - B))
ym = k0 * R * math.atan2(math.tan(phi), math.cos(dl))
easting = 500000.0 + xm
northing = ym + (10000000.0 if lat < 0 else 0.0)
return [round(easting, 2), round(northing, 2)]
def check(label, actual, expected):
observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[('control #0', [-146.456, 65.451, 6, 'S'], [525121.58, 17275016.5]), ('control #1', [-24.305, 48.206, 26, 'S'], [699628.48, 15361620.03]), ('control #2', [-3.252, -34.222, 30, 'N'], [476839.56, -3803819.3]), ('control #3', [140.223, -1.339, 54, 'S'], [413657.04, 9851155.87]), ('control #4', [-99.681, -57.299, 14, 'S'], [459106.63, 3630985.93]), ('control #5', [131.956, -7.201, 52, 'S'], [826109.36, 9198550.44]), ('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #7', [-31.602, 39.795, 25, 'S'], [619393.11, 14424164.48])], [('control #1', [-24.305, 48.206, 26, 'S'], [699628.48, 15361620.03]), ('control #4', [-99.681, -57.299, 14, 'S'], [459106.63, 3630985.93]), ('control #5', [131.956, -7.201, 52, 'S'], [826109.36, 9198550.44]), ('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #7', [-31.602, 39.795, 25, 'S'], [619393.11, 14424164.48]), ('control #8', [87.283, -45.722, 45, 'N'], [521960.41, -5082059.64]), ('control #9', [74.243, 54.044, 43, 'S'], [450595.96, 16007279.04]), ('control #12', [-107.58, 5.652, 13, 'N'], [214531.37, 628855.65])], [('control #2', [-3.252, -34.222, 30, 'N'], [476839.56, -3803819.3]), ('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #8', [87.283, -45.722, 45, 'N'], [521960.41, -5082059.64]), ('control #9', [74.243, 54.044, 43, 'S'], [450595.96, 16007279.04]), ('control #10', [-96.557, 39.544, 14, 'S'], [709406.66, 14398176.63]), ('control #12', [-107.58, 5.652, 13, 'N'], [214531.37, 628855.65]), ('boundary #16', [15.0, 0.0, 33, 'N'], [500000.0, 0.0]), ('boundary #18', [3.0, 45.0, 31, 'N'], [500000.0, 5001770.19])], [('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #9', [74.243, 54.044, 43, 'S'], [450595.96, 16007279.04]), ('control #11', [162.356, -15.269, 58, 'N'], [216405.53, -1698880.3]), ('control #12', [-107.58, 5.652, 13, 'N'], [214531.37, 628855.65]), ('control #13', [100.982, 3.293, 47, 'S'], [719980.03, 10366237.05]), ('regression #14', [9.0, 0.5, 32, 'S'], [500000.0, 10055575.22]), ('boundary #16', [15.0, 0.0, 33, 'N'], [500000.0, 0.0]), ('boundary #18', [3.0, 45.0, 31, 'N'], [500000.0, 5001770.19])], [('control #7', [-31.602, 39.795, 25, 'S'], [619393.11, 14424164.48]), ('control #10', [-96.557, 39.544, 14, 'S'], [709406.66, 14398176.63]), ('regression #14', [9.0, 0.5, 32, 'S'], [500000.0, 10055575.22]), ('regression #15', [-75.5, 0.2, 18, 'S'], [444424.41, 10022230.94]), ('boundary #16', [15.0, 0.0, 33, 'N'], [500000.0, 0.0]), ('regression #17', [30.0, -0.3, 36, 'N'], [166400.77, -33390.89]), ('boundary #18', [3.0, 45.0, 31, 'N'], [500000.0, 5001770.19]), ('control #19', [12.3, -35.0, 33, 'S'], [254136.28, 6106410.11])]]
for label, args, expected in fixtures[N-1]:
check(label, solve(args), expected)
print(json.dumps({"observations": observations, "passed": all(x["passed"] for x in observations)}, ensure_ascii=False))
raise SystemExit(0 if all(x["passed"] for x in observations) else 1)
| Boundary fixture | Actual | Expected | Outcome |
|---|---|---|---|
| control #0 | [525121.58, 7275016.5] | [525121.58, 17275016.5] | Failed |
| control #1 | [699628.48, 5361620.03] | [699628.48, 15361620.03] | Failed |
| control #2 | [476839.56, 6196180.7] | [476839.56, -3803819.3] | Failed |
| control #3 | [413657.04, 9851155.87] | [413657.04, 9851155.87] | Passed |
| control #4 | [459106.63, 3630985.93] | [459106.63, 3630985.93] | Passed |
| control #5 | [826109.36, 9198550.44] | [826109.36, 9198550.44] | Passed |
| control #6 | [441683.41, 4425121.91] | [441683.41, 14425121.91] | Failed |
| control #7 | [619393.11, 4424164.48] | [619393.11, 14424164.48] | Failed |
SHA-256 / 85dd9867c9da49290ebc5a97b2c53f36226bbdd105c91eec09c3413f61550675
2 / The unsuccessful fix
Exit 1"""Failure Map reference implementation. Python standard library only."""
import json
import math
N = 1
observations = []
def solve(x):
lon, lat, zone, hemi = x
R = 6371000.0
k0 = 0.9996
lon0 = zone * 6 - 183
dl = math.radians(lon - lon0)
phi = math.radians(lat)
B = math.cos(phi) * math.sin(dl)
xm = 0.5 * k0 * R * math.log((1 + B) / (1 - B))
ym = k0 * R * math.atan2(math.tan(phi), math.cos(dl))
easting = 500000.0 + xm
northing = ym + (10000000.0 if hemi == 'S' and lat < 0 else 0.0)
return [round(easting, 2), round(northing, 2)]
def check(label, actual, expected):
observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[('control #0', [-146.456, 65.451, 6, 'S'], [525121.58, 17275016.5]), ('control #1', [-24.305, 48.206, 26, 'S'], [699628.48, 15361620.03]), ('control #2', [-3.252, -34.222, 30, 'N'], [476839.56, -3803819.3]), ('control #3', [140.223, -1.339, 54, 'S'], [413657.04, 9851155.87]), ('control #4', [-99.681, -57.299, 14, 'S'], [459106.63, 3630985.93]), ('control #5', [131.956, -7.201, 52, 'S'], [826109.36, 9198550.44]), ('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #7', [-31.602, 39.795, 25, 'S'], [619393.11, 14424164.48])], [('control #1', [-24.305, 48.206, 26, 'S'], [699628.48, 15361620.03]), ('control #4', [-99.681, -57.299, 14, 'S'], [459106.63, 3630985.93]), ('control #5', [131.956, -7.201, 52, 'S'], [826109.36, 9198550.44]), ('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #7', [-31.602, 39.795, 25, 'S'], [619393.11, 14424164.48]), ('control #8', [87.283, -45.722, 45, 'N'], [521960.41, -5082059.64]), ('control #9', [74.243, 54.044, 43, 'S'], [450595.96, 16007279.04]), ('control #12', [-107.58, 5.652, 13, 'N'], [214531.37, 628855.65])], [('control #2', [-3.252, -34.222, 30, 'N'], [476839.56, -3803819.3]), ('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #8', [87.283, -45.722, 45, 'N'], [521960.41, -5082059.64]), ('control #9', [74.243, 54.044, 43, 'S'], [450595.96, 16007279.04]), ('control #10', [-96.557, 39.544, 14, 'S'], [709406.66, 14398176.63]), ('control #12', [-107.58, 5.652, 13, 'N'], [214531.37, 628855.65]), ('boundary #16', [15.0, 0.0, 33, 'N'], [500000.0, 0.0]), ('boundary #18', [3.0, 45.0, 31, 'N'], [500000.0, 5001770.19])], [('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #9', [74.243, 54.044, 43, 'S'], [450595.96, 16007279.04]), ('control #11', [162.356, -15.269, 58, 'N'], [216405.53, -1698880.3]), ('control #12', [-107.58, 5.652, 13, 'N'], [214531.37, 628855.65]), ('control #13', [100.982, 3.293, 47, 'S'], [719980.03, 10366237.05]), ('regression #14', [9.0, 0.5, 32, 'S'], [500000.0, 10055575.22]), ('boundary #16', [15.0, 0.0, 33, 'N'], [500000.0, 0.0]), ('boundary #18', [3.0, 45.0, 31, 'N'], [500000.0, 5001770.19])], [('control #7', [-31.602, 39.795, 25, 'S'], [619393.11, 14424164.48]), ('control #10', [-96.557, 39.544, 14, 'S'], [709406.66, 14398176.63]), ('regression #14', [9.0, 0.5, 32, 'S'], [500000.0, 10055575.22]), ('regression #15', [-75.5, 0.2, 18, 'S'], [444424.41, 10022230.94]), ('boundary #16', [15.0, 0.0, 33, 'N'], [500000.0, 0.0]), ('regression #17', [30.0, -0.3, 36, 'N'], [166400.77, -33390.89]), ('boundary #18', [3.0, 45.0, 31, 'N'], [500000.0, 5001770.19]), ('control #19', [12.3, -35.0, 33, 'S'], [254136.28, 6106410.11])]]
for label, args, expected in fixtures[N-1]:
check(label, solve(args), expected)
print(json.dumps({"observations": observations, "passed": all(x["passed"] for x in observations)}, ensure_ascii=False))
raise SystemExit(0 if all(x["passed"] for x in observations) else 1)
| Boundary fixture | Actual | Expected | Outcome |
|---|---|---|---|
| control #0 | [525121.58, 7275016.5] | [525121.58, 17275016.5] | Failed |
| control #1 | [699628.48, 5361620.03] | [699628.48, 15361620.03] | Failed |
| control #2 | [476839.56, -3803819.3] | [476839.56, -3803819.3] | Passed |
| control #3 | [413657.04, 9851155.87] | [413657.04, 9851155.87] | Passed |
| control #4 | [459106.63, 3630985.93] | [459106.63, 3630985.93] | Passed |
| control #5 | [826109.36, 9198550.44] | [826109.36, 9198550.44] | Passed |
| control #6 | [441683.41, 4425121.91] | [441683.41, 14425121.91] | Failed |
| control #7 | [619393.11, 4424164.48] | [619393.11, 14424164.48] | Failed |
SHA-256 / d3dbb5c90f7aad33a52c117fad93300bdc70cc7159c6f9231056808acf870351
3 / The verified repair
Exit 0"""Failure Map reference implementation. Python standard library only."""
import json
import math
N = 1
observations = []
def solve(x):
lon, lat, zone, hemi = x
R = 6371000.0
k0 = 0.9996
lon0 = zone * 6 - 183
dl = math.radians(lon - lon0)
phi = math.radians(lat)
B = math.cos(phi) * math.sin(dl)
xm = 0.5 * k0 * R * math.log((1 + B) / (1 - B))
ym = k0 * R * math.atan2(math.tan(phi), math.cos(dl))
easting = 500000.0 + xm
northing = ym + (10000000.0 if hemi == 'S' else 0.0)
return [round(easting, 2), round(northing, 2)]
def check(label, actual, expected):
observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[('control #0', [-146.456, 65.451, 6, 'S'], [525121.58, 17275016.5]), ('control #1', [-24.305, 48.206, 26, 'S'], [699628.48, 15361620.03]), ('control #2', [-3.252, -34.222, 30, 'N'], [476839.56, -3803819.3]), ('control #3', [140.223, -1.339, 54, 'S'], [413657.04, 9851155.87]), ('control #4', [-99.681, -57.299, 14, 'S'], [459106.63, 3630985.93]), ('control #5', [131.956, -7.201, 52, 'S'], [826109.36, 9198550.44]), ('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #7', [-31.602, 39.795, 25, 'S'], [619393.11, 14424164.48])], [('control #1', [-24.305, 48.206, 26, 'S'], [699628.48, 15361620.03]), ('control #4', [-99.681, -57.299, 14, 'S'], [459106.63, 3630985.93]), ('control #5', [131.956, -7.201, 52, 'S'], [826109.36, 9198550.44]), ('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #7', [-31.602, 39.795, 25, 'S'], [619393.11, 14424164.48]), ('control #8', [87.283, -45.722, 45, 'N'], [521960.41, -5082059.64]), ('control #9', [74.243, 54.044, 43, 'S'], [450595.96, 16007279.04]), ('control #12', [-107.58, 5.652, 13, 'N'], [214531.37, 628855.65])], [('control #2', [-3.252, -34.222, 30, 'N'], [476839.56, -3803819.3]), ('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #8', [87.283, -45.722, 45, 'N'], [521960.41, -5082059.64]), ('control #9', [74.243, 54.044, 43, 'S'], [450595.96, 16007279.04]), ('control #10', [-96.557, 39.544, 14, 'S'], [709406.66, 14398176.63]), ('control #12', [-107.58, 5.652, 13, 'N'], [214531.37, 628855.65]), ('boundary #16', [15.0, 0.0, 33, 'N'], [500000.0, 0.0]), ('boundary #18', [3.0, 45.0, 31, 'N'], [500000.0, 5001770.19])], [('control #6', [-63.683, 39.81, 20, 'S'], [441683.41, 14425121.91]), ('control #9', [74.243, 54.044, 43, 'S'], [450595.96, 16007279.04]), ('control #11', [162.356, -15.269, 58, 'N'], [216405.53, -1698880.3]), ('control #12', [-107.58, 5.652, 13, 'N'], [214531.37, 628855.65]), ('control #13', [100.982, 3.293, 47, 'S'], [719980.03, 10366237.05]), ('regression #14', [9.0, 0.5, 32, 'S'], [500000.0, 10055575.22]), ('boundary #16', [15.0, 0.0, 33, 'N'], [500000.0, 0.0]), ('boundary #18', [3.0, 45.0, 31, 'N'], [500000.0, 5001770.19])], [('control #7', [-31.602, 39.795, 25, 'S'], [619393.11, 14424164.48]), ('control #10', [-96.557, 39.544, 14, 'S'], [709406.66, 14398176.63]), ('regression #14', [9.0, 0.5, 32, 'S'], [500000.0, 10055575.22]), ('regression #15', [-75.5, 0.2, 18, 'S'], [444424.41, 10022230.94]), ('boundary #16', [15.0, 0.0, 33, 'N'], [500000.0, 0.0]), ('regression #17', [30.0, -0.3, 36, 'N'], [166400.77, -33390.89]), ('boundary #18', [3.0, 45.0, 31, 'N'], [500000.0, 5001770.19]), ('control #19', [12.3, -35.0, 33, 'S'], [254136.28, 6106410.11])]]
for label, args, expected in fixtures[N-1]:
check(label, solve(args), expected)
print(json.dumps({"observations": observations, "passed": all(x["passed"] for x in observations)}, ensure_ascii=False))
raise SystemExit(0 if all(x["passed"] for x in observations) else 1)
| Boundary fixture | Actual | Expected | Outcome |
|---|---|---|---|
| control #0 | [525121.58, 17275016.5] | [525121.58, 17275016.5] | Passed |
| control #1 | [699628.48, 15361620.03] | [699628.48, 15361620.03] | Passed |
| control #2 | [476839.56, -3803819.3] | [476839.56, -3803819.3] | Passed |
| control #3 | [413657.04, 9851155.87] | [413657.04, 9851155.87] | Passed |
| control #4 | [459106.63, 3630985.93] | [459106.63, 3630985.93] | Passed |
| control #5 | [826109.36, 9198550.44] | [826109.36, 9198550.44] | Passed |
| control #6 | [441683.41, 14425121.91] | [441683.41, 14425121.91] | Passed |
| control #7 | [619393.11, 14424164.48] | [619393.11, 14424164.48] | Passed |
SHA-256 / 20beae4d0c9b3ef4c1d95554606546155931632289ec474266f27a39689a7b53
Verification & scope
Stipulated deterministic toy contract on a bounded input domain; results are rounded as stated and no conformance with any published standard or library is claimed. This reproducer isolates one failure mechanism. Results cover the supplied fixtures. Variants within a family share a test contract and should remain grouped when constructing evaluation splits. Related mechanisms with a shared evaluation_group must also remain together; these controlled models are not independent production incidents.
Observations recorded using Python 3.12.14 at 2026-09-29T14:48:16.703025+00:00.
Case digest / 4a545ff61aba3d968740002aac3329c8933104493c36ba89b4149319daad9096