FA-93496 / Solar tracker geometry / Open access
Heliostat mirror normal aiming: normal azimuth convention · case 01
Mirror azimuths are reported counterclockwise from east.
ROOT CAUSE
atan2 receives north before east.
VERIFIED REPAIR
Azimuth = atan2(east, north) wrapped to 0..360.
Unsuccessful approach: Without the wrap, westward normals are reported as negative azimuths.
Case contract
mirror and receiver are [east, north, up] positions in metres. The mirror normal bisects the unit sun vector (east=cos(el)sin(az), north=cos(el)cos(az), up=sin(el)) and the unit vector from the mirror to the receiver. Return [normal azimuth 0..360 clockwise from north, normal elevation], rounded to 3; None when sun_el <= 0; 'invalid' when receiver equals mirror.
Why this case matters
Single-axis and dual-axis solar trackers turn a sun direction into actuator commands; a sign, frame or limit mistake points a whole plant away from the sun or into a mechanical stop.
1 / The failure
Exit 1"""Failure Map reference implementation. Python standard library only."""
import json
import math
N = 1
observations = []
def solve(sun_az, sun_el, mirror, receiver):
if sun_el <= 0:
return None
dx = [receiver[i] - mirror[i] for i in range(3)]
d = math.sqrt(sum(v * v for v in dx))
if d == 0:
return 'invalid'
t = [v / d for v in dx]
az = math.radians(sun_az)
el = math.radians(sun_el)
s = [math.cos(el) * math.sin(az), math.cos(el) * math.cos(az), math.sin(el)]
h = [s[i] + t[i] for i in range(3)]
m = math.sqrt(sum(v * v for v in h))
h = [v / m for v in h]
n_az = math.degrees(math.atan2(h[1], h[0])) % 360
n_el = math.degrees(math.asin(h[2]))
return [round(n_az, 3), round(n_el, 3)]
def check(label, actual, expected):
observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [270, 60, [6, -1, 1], [0, 5, 0]], [300.29, 28.278]],
['regression: normal azimuth convention (partial repair)', [60, 40, [47, -49, 1], [0, -5, 0]],
[356.431, 30.416]],
['control 1', [90, -3, [-12, 43, 1], [0, 0, 0]], None],
['control 2', [200, 60, [-10, 12, 0], [0, 0, 60]], [180.839, 70.11]]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [315, 25, [47, -28, 0], [0, 0, 60]], [308.943, 36.527]],
['regression: normal azimuth convention (partial repair)', [270, 10, [38, -21, 0], [0, 0, 0]],
[284.576, 5.163]],
['control 1', [135, 60, [31, 24, 2], [10, 0, 60]], [177.1, 67.63]],
['control 2', [225, -3, [-22, -29, 0], [0, 5, 30]], None]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [60, 40, [7, -5, 0], [0, 0, 60]], [49.649, 66.233]],
['regression: normal azimuth convention (partial repair)', [315, 10, [-37, 26, 0], [0, -5, 60]],
[323.691, 69.061]],
['control 1', [90, 10, [-6, -40, 0], [-10, 0, 30]], [48.617, 32.61]],
['control 2', [30, 10, [-44, 5, 1], [-10, 0, 0]], [64.473, 5.033]]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [315, 60, [-28, -33, 2], [-10, 5, 60]],
[353.399, 62.028]],
['regression: normal azimuth convention (partial repair)', [200, 80, [9, 42, 3], [-10, 0, 30]],
[203.614, 55.19]],
['control 1', [150, 40, [16, -12, 0], [0, 0, 0]], [261.353, 56.729]],
['control 2', [90, 0, [46, -4, 0], [0, -5, 0]], None]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [225, 40, [-5, 13, 2], [-10, -5, 30]],
[212.668, 49.07]],
['regression: normal azimuth convention (partial repair)', [200, 10, [41, -47, 2], [0, 0, 30]],
[255.744, 31.113]],
['control 1', [250, -3, [-49, 36, 3], [-10, 5, 60]], None],
['control 2', [300, 80, [-27, -17, 1], [-10, 0, 60]], [18.225, 78.974]]]]
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 |
|---|---|---|---|
| boundary: receiver at mirror | invalid | invalid | Passed |
| normal: receiver due north on tower | [270.0, 87.5] | [180.0, 87.5] | Failed |
| boundary: night | None | None | Passed |
| regression: normal azimuth convention | [149.71, 28.278] | [300.29, 28.278] | Failed |
| regression: normal azimuth convention (partial repair) | [93.569, 30.416] | [356.431, 30.416] | Failed |
| control 1 | None | None | Passed |
| control 2 | [269.161, 70.11] | [180.839, 70.11] | Failed |
SHA-256 / aff314d35a5cdbfa95f5d37a6b585c035e2204ce4b02121ea6ee14b1e15e331e
2 / The unsuccessful fix
Exit 1"""Failure Map reference implementation. Python standard library only."""
import json
import math
N = 1
observations = []
def solve(sun_az, sun_el, mirror, receiver):
if sun_el <= 0:
return None
dx = [receiver[i] - mirror[i] for i in range(3)]
d = math.sqrt(sum(v * v for v in dx))
if d == 0:
return 'invalid'
t = [v / d for v in dx]
az = math.radians(sun_az)
el = math.radians(sun_el)
s = [math.cos(el) * math.sin(az), math.cos(el) * math.cos(az), math.sin(el)]
h = [s[i] + t[i] for i in range(3)]
m = math.sqrt(sum(v * v for v in h))
h = [v / m for v in h]
n_az = math.degrees(math.atan2(h[0], h[1]))
n_el = math.degrees(math.asin(h[2]))
return [round(n_az, 3), round(n_el, 3)]
def check(label, actual, expected):
observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [270, 60, [6, -1, 1], [0, 5, 0]], [300.29, 28.278]],
['regression: normal azimuth convention (partial repair)', [60, 40, [47, -49, 1], [0, -5, 0]],
[356.431, 30.416]],
['control 1', [90, -3, [-12, 43, 1], [0, 0, 0]], None],
['control 2', [200, 60, [-10, 12, 0], [0, 0, 60]], [180.839, 70.11]]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [315, 25, [47, -28, 0], [0, 0, 60]], [308.943, 36.527]],
['regression: normal azimuth convention (partial repair)', [270, 10, [38, -21, 0], [0, 0, 0]],
[284.576, 5.163]],
['control 1', [135, 60, [31, 24, 2], [10, 0, 60]], [177.1, 67.63]],
['control 2', [225, -3, [-22, -29, 0], [0, 5, 30]], None]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [60, 40, [7, -5, 0], [0, 0, 60]], [49.649, 66.233]],
['regression: normal azimuth convention (partial repair)', [315, 10, [-37, 26, 0], [0, -5, 60]],
[323.691, 69.061]],
['control 1', [90, 10, [-6, -40, 0], [-10, 0, 30]], [48.617, 32.61]],
['control 2', [30, 10, [-44, 5, 1], [-10, 0, 0]], [64.473, 5.033]]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [315, 60, [-28, -33, 2], [-10, 5, 60]],
[353.399, 62.028]],
['regression: normal azimuth convention (partial repair)', [200, 80, [9, 42, 3], [-10, 0, 30]],
[203.614, 55.19]],
['control 1', [150, 40, [16, -12, 0], [0, 0, 0]], [261.353, 56.729]],
['control 2', [90, 0, [46, -4, 0], [0, -5, 0]], None]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [225, 40, [-5, 13, 2], [-10, -5, 30]],
[212.668, 49.07]],
['regression: normal azimuth convention (partial repair)', [200, 10, [41, -47, 2], [0, 0, 30]],
[255.744, 31.113]],
['control 1', [250, -3, [-49, 36, 3], [-10, 5, 60]], None],
['control 2', [300, 80, [-27, -17, 1], [-10, 0, 60]], [18.225, 78.974]]]]
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 |
|---|---|---|---|
| boundary: receiver at mirror | invalid | invalid | Passed |
| normal: receiver due north on tower | [180.0, 87.5] | [180.0, 87.5] | Passed |
| boundary: night | None | None | Passed |
| regression: normal azimuth convention | [-59.71, 28.278] | [300.29, 28.278] | Failed |
| regression: normal azimuth convention (partial repair) | [-3.569, 30.416] | [356.431, 30.416] | Failed |
| control 1 | None | None | Passed |
| control 2 | [-179.161, 70.11] | [180.839, 70.11] | Failed |
SHA-256 / cb2924dc37c4069c6a8f3c29dcc1d1edc111ab8ea40eb3220f6cc1b2e9e1771f
3 / The verified repair
Exit 0"""Failure Map reference implementation. Python standard library only."""
import json
import math
N = 1
observations = []
def solve(sun_az, sun_el, mirror, receiver):
if sun_el <= 0:
return None
dx = [receiver[i] - mirror[i] for i in range(3)]
d = math.sqrt(sum(v * v for v in dx))
if d == 0:
return 'invalid'
t = [v / d for v in dx]
az = math.radians(sun_az)
el = math.radians(sun_el)
s = [math.cos(el) * math.sin(az), math.cos(el) * math.cos(az), math.sin(el)]
h = [s[i] + t[i] for i in range(3)]
m = math.sqrt(sum(v * v for v in h))
h = [v / m for v in h]
n_az = math.degrees(math.atan2(h[0], h[1])) % 360
n_el = math.degrees(math.asin(h[2]))
return [round(n_az, 3), round(n_el, 3)]
def check(label, actual, expected):
observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [270, 60, [6, -1, 1], [0, 5, 0]], [300.29, 28.278]],
['regression: normal azimuth convention (partial repair)', [60, 40, [47, -49, 1], [0, -5, 0]],
[356.431, 30.416]],
['control 1', [90, -3, [-12, 43, 1], [0, 0, 0]], None],
['control 2', [200, 60, [-10, 12, 0], [0, 0, 60]], [180.839, 70.11]]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [315, 25, [47, -28, 0], [0, 0, 60]], [308.943, 36.527]],
['regression: normal azimuth convention (partial repair)', [270, 10, [38, -21, 0], [0, 0, 0]],
[284.576, 5.163]],
['control 1', [135, 60, [31, 24, 2], [10, 0, 60]], [177.1, 67.63]],
['control 2', [225, -3, [-22, -29, 0], [0, 5, 30]], None]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [60, 40, [7, -5, 0], [0, 0, 60]], [49.649, 66.233]],
['regression: normal azimuth convention (partial repair)', [315, 10, [-37, 26, 0], [0, -5, 60]],
[323.691, 69.061]],
['control 1', [90, 10, [-6, -40, 0], [-10, 0, 30]], [48.617, 32.61]],
['control 2', [30, 10, [-44, 5, 1], [-10, 0, 0]], [64.473, 5.033]]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [315, 60, [-28, -33, 2], [-10, 5, 60]],
[353.399, 62.028]],
['regression: normal azimuth convention (partial repair)', [200, 80, [9, 42, 3], [-10, 0, 30]],
[203.614, 55.19]],
['control 1', [150, 40, [16, -12, 0], [0, 0, 0]], [261.353, 56.729]],
['control 2', [90, 0, [46, -4, 0], [0, -5, 0]], None]],
[['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
['normal: receiver due north on tower', [180, 40, [0, -40, 0], [0, 0, 40]], [180.0, 87.5]],
['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
['regression: normal azimuth convention', [225, 40, [-5, 13, 2], [-10, -5, 30]],
[212.668, 49.07]],
['regression: normal azimuth convention (partial repair)', [200, 10, [41, -47, 2], [0, 0, 30]],
[255.744, 31.113]],
['control 1', [250, -3, [-49, 36, 3], [-10, 5, 60]], None],
['control 2', [300, 80, [-27, -17, 1], [-10, 0, 60]], [18.225, 78.974]]]]
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 |
|---|---|---|---|
| boundary: receiver at mirror | invalid | invalid | Passed |
| normal: receiver due north on tower | [180.0, 87.5] | [180.0, 87.5] | Passed |
| boundary: night | None | None | Passed |
| regression: normal azimuth convention | [300.29, 28.278] | [300.29, 28.278] | Passed |
| regression: normal azimuth convention (partial repair) | [356.431, 30.416] | [356.431, 30.416] | Passed |
| control 1 | None | None | Passed |
| control 2 | [180.839, 70.11] | [180.839, 70.11] | Passed |
SHA-256 / 38fc7fe89b45fea16c996718073ab2046c612553ed7f18944f0298f36dc5628f
Verification & scope
Deterministic stipulated toy contract for teaching; no claim of conformance with any standard, vendor protocol or production controller. 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:51:55.628953+00:00.
Case digest / 4ec1f6ee25e31c39ed30566760cac5241a0a7239187cce17e2cf5c1975e98888