FAILURE MAP
← Case archive

FA-93506 / Solar tracker geometry / Open access

Heliostat mirror normal aiming: bisector normalisation · case 01

Mirror elevations are wrong whenever sun and receiver are not aligned.

Verified by executionVariant 1 · 7 checks per implementationDownload source bundle ↓JSON ↗

ROOT CAUSE

The sum vector is used without normalising it to unit length.

VERIFIED REPAIR

Normalise the bisector by its Euclidean length.

Unsuccessful approach: Halving the sum is only a unit vector when both inputs coincide.

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 = 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'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [270, 60, [6, -1, 1], [0, 5, 0]], [300.29, 28.278]],
  ['regression: bisector normalisation (partial repair)', [270, 80, [-16, -19, 1], [0, 0, 0]],
   [31.588, 46.473]],
  ['control 1', [90, -3, [-12, 43, 1], [0, 0, 0]], None],
  ['control 2', [180, -3, [-22, 14, 2], [0, 5, 0]], None],
  ['control 3', [90, 40, [1, -24, 3], [0, -5, 0]], [35.903, 21.798]]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [270, 10, [38, -21, 0], [0, 0, 0]], [284.576, 5.163]],
  ['regression: bisector normalisation (partial repair)', [90, 10, [31, -7, 0], [-10, 0, 30]],
   [53.628, 73.122]],
  ['control 1', [225, -3, [-22, -29, 0], [0, 5, 30]], None],
  ['control 2', [225, 80, [-26, 5, 3], [10, 5, 0]], [97.999, 45.624]],
  ['control 3', [225, 80, [-17, -20, 3], [0, 0, 0]], [39.385, 46.716]]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [315, 10, [-37, 26, 0], [0, -5, 60]], [323.691, 69.061]],
  ['regression: bisector normalisation (partial repair)', [90, 25, [-2, -18, 2], [-10, 5, 0]],
   [31.59, 17.138]],
  ['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]],
  ['control 3', [315, 80, [34, -46, 2], [0, 0, 0]], [322.271, 39.038]]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [180, 60, [10, 45, 1], [0, 0, 0]], [188.359, 29.509]],
  ['regression: bisector normalisation (partial repair)', [315, 10, [22, 3, 2], [-10, 0, 0]],
   [289.642, 3.557]],
  ['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],
  ['control 3', [45, 0, [36, -15, 0], [-10, -5, 30]], None]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [200, 10, [41, -47, 2], [0, 0, 30]], [255.744, 31.113]],
  ['regression: bisector normalisation (partial repair)', [250, 80, [39, -38, 2], [10, 5, 0]],
   [316.812, 41.896]],
  ['control 1', [250, -3, [-49, 36, 3], [-10, 5, 60]], None],
  ['control 2', [180, 60, [10, 45, 1], [0, 0, 0]], [188.359, 29.509]],
  ['control 3', [315, 10, [22, 3, 2], [-10, 0, 0]], [289.642, 3.557]]]]
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 fixtureActualExpectedOutcome
boundary: receiver at mirrorinvalidinvalidPassed
boundary: nightNoneNonePassed
regression: bisector normalisation[300.29, 48.502][300.29, 28.278]Failed
regression: bisector normalisation (partial repair)[31.588, 70.836][31.588, 46.473]Failed
control 1NoneNonePassed
control 2NoneNonePassed
control 3[35.903, 29.146][35.903, 21.798]Failed

SHA-256 / 2d7cfdcaf541887f190f53a729b239cbf511aad60adb22dccfcf172c16e6be35

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 / 2 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'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [270, 60, [6, -1, 1], [0, 5, 0]], [300.29, 28.278]],
  ['regression: bisector normalisation (partial repair)', [270, 80, [-16, -19, 1], [0, 0, 0]],
   [31.588, 46.473]],
  ['control 1', [90, -3, [-12, 43, 1], [0, 0, 0]], None],
  ['control 2', [180, -3, [-22, 14, 2], [0, 5, 0]], None],
  ['control 3', [90, 40, [1, -24, 3], [0, -5, 0]], [35.903, 21.798]]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [270, 10, [38, -21, 0], [0, 0, 0]], [284.576, 5.163]],
  ['regression: bisector normalisation (partial repair)', [90, 10, [31, -7, 0], [-10, 0, 30]],
   [53.628, 73.122]],
  ['control 1', [225, -3, [-22, -29, 0], [0, 5, 30]], None],
  ['control 2', [225, 80, [-26, 5, 3], [10, 5, 0]], [97.999, 45.624]],
  ['control 3', [225, 80, [-17, -20, 3], [0, 0, 0]], [39.385, 46.716]]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [315, 10, [-37, 26, 0], [0, -5, 60]], [323.691, 69.061]],
  ['regression: bisector normalisation (partial repair)', [90, 25, [-2, -18, 2], [-10, 5, 0]],
   [31.59, 17.138]],
  ['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]],
  ['control 3', [315, 80, [34, -46, 2], [0, 0, 0]], [322.271, 39.038]]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [180, 60, [10, 45, 1], [0, 0, 0]], [188.359, 29.509]],
  ['regression: bisector normalisation (partial repair)', [315, 10, [22, 3, 2], [-10, 0, 0]],
   [289.642, 3.557]],
  ['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],
  ['control 3', [45, 0, [36, -15, 0], [-10, -5, 30]], None]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [200, 10, [41, -47, 2], [0, 0, 30]], [255.744, 31.113]],
  ['regression: bisector normalisation (partial repair)', [250, 80, [39, -38, 2], [10, 5, 0]],
   [316.812, 41.896]],
  ['control 1', [250, -3, [-49, 36, 3], [-10, 5, 60]], None],
  ['control 2', [180, 60, [10, 45, 1], [0, 0, 0]], [188.359, 29.509]],
  ['control 3', [315, 10, [22, 3, 2], [-10, 0, 0]], [289.642, 3.557]]]]
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 fixtureActualExpectedOutcome
boundary: receiver at mirrorinvalidinvalidPassed
boundary: nightNoneNonePassed
regression: bisector normalisation[300.29, 21.993][300.29, 28.278]Failed
regression: bisector normalisation (partial repair)[31.588, 28.183][31.588, 46.473]Failed
control 1NoneNonePassed
control 2NoneNonePassed
control 3[35.903, 14.094][35.903, 21.798]Failed

SHA-256 / d2fb72e074493270fd8902e0b3016ccdf13fc2f77d788758c2ef1254a7b60204

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'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [270, 60, [6, -1, 1], [0, 5, 0]], [300.29, 28.278]],
  ['regression: bisector normalisation (partial repair)', [270, 80, [-16, -19, 1], [0, 0, 0]],
   [31.588, 46.473]],
  ['control 1', [90, -3, [-12, 43, 1], [0, 0, 0]], None],
  ['control 2', [180, -3, [-22, 14, 2], [0, 5, 0]], None],
  ['control 3', [90, 40, [1, -24, 3], [0, -5, 0]], [35.903, 21.798]]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [270, 10, [38, -21, 0], [0, 0, 0]], [284.576, 5.163]],
  ['regression: bisector normalisation (partial repair)', [90, 10, [31, -7, 0], [-10, 0, 30]],
   [53.628, 73.122]],
  ['control 1', [225, -3, [-22, -29, 0], [0, 5, 30]], None],
  ['control 2', [225, 80, [-26, 5, 3], [10, 5, 0]], [97.999, 45.624]],
  ['control 3', [225, 80, [-17, -20, 3], [0, 0, 0]], [39.385, 46.716]]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [315, 10, [-37, 26, 0], [0, -5, 60]], [323.691, 69.061]],
  ['regression: bisector normalisation (partial repair)', [90, 25, [-2, -18, 2], [-10, 5, 0]],
   [31.59, 17.138]],
  ['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]],
  ['control 3', [315, 80, [34, -46, 2], [0, 0, 0]], [322.271, 39.038]]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [180, 60, [10, 45, 1], [0, 0, 0]], [188.359, 29.509]],
  ['regression: bisector normalisation (partial repair)', [315, 10, [22, 3, 2], [-10, 0, 0]],
   [289.642, 3.557]],
  ['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],
  ['control 3', [45, 0, [36, -15, 0], [-10, -5, 30]], None]],
 [['boundary: receiver at mirror', [180, 40, [0, 0, 0], [0, 0, 0]], 'invalid'],
  ['boundary: night', [90, 0, [10, 10, 0], [0, 0, 50]], None],
  ['regression: bisector normalisation', [200, 10, [41, -47, 2], [0, 0, 30]], [255.744, 31.113]],
  ['regression: bisector normalisation (partial repair)', [250, 80, [39, -38, 2], [10, 5, 0]],
   [316.812, 41.896]],
  ['control 1', [250, -3, [-49, 36, 3], [-10, 5, 60]], None],
  ['control 2', [180, 60, [10, 45, 1], [0, 0, 0]], [188.359, 29.509]],
  ['control 3', [315, 10, [22, 3, 2], [-10, 0, 0]], [289.642, 3.557]]]]
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 fixtureActualExpectedOutcome
boundary: receiver at mirrorinvalidinvalidPassed
boundary: nightNoneNonePassed
regression: bisector normalisation[300.29, 28.278][300.29, 28.278]Passed
regression: bisector normalisation (partial repair)[31.588, 46.473][31.588, 46.473]Passed
control 1NoneNonePassed
control 2NoneNonePassed
control 3[35.903, 21.798][35.903, 21.798]Passed

SHA-256 / b74b64b641a9fdbbe19789799af5584eb382765176bd99ebf3d21038c0f0f1cd

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.715986+00:00.

Case digest / 2bb80e2c7eb735e3056a364888c007e09882bf7663b0c0d32b73f27b2345f27e