FAILURE MAP
← Case archive

FA-93836 / Solar tracker geometry / Open access

Dual-axis pointing error: azimuth difference units · case 01

Pointing errors swing wildly for small azimuth offsets.

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

ROOT CAUSE

The azimuth difference in degrees is passed to cos as radians.

VERIFIED REPAIR

Convert the azimuth difference to radians.

Unsuccessful approach: Folding the difference modulo 180 treats opposite azimuths as aligned.

Case contract

Pointing error is the great-circle angle between the commanded direction and the sun: cos(err) = sin e1 sin e2 + cos e1 cos e2 cos(az1 - az2), clamped to [-1, 1]. Return [err degrees rounded 3, err <= tol].

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(cmd_az, cmd_el, sun_az, sun_el, tol):
    e1 = math.radians(cmd_el)
    e2 = math.radians(sun_el)
    d = cmd_az - sun_az
    c = math.sin(e1) * math.sin(e2) + math.cos(e1) * math.cos(e2) * math.cos(d)
    c = max(-1.0, min(1.0, c))
    err = math.degrees(math.acos(c))
    return [round(err, 3), err <= tol]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [340, 45, 60, 80, 0.5], [44.136, False]],
  ['regression: azimuth difference units (partial repair)', [30, 30, 340, 45, 2], [41.653, False]],
  ['control 1', [90, 10, 225, 30, 0.5], [121.081, False]],
  ['control 2', [340, 80, 225, 45, 0.5], [49.874, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [200, 60, 60, 45, 5], [70.03, False]],
  ['regression: azimuth difference units (partial repair)', [180, 60, 0, 60, 0.5], [60.0, False]],
  ['control 1', [180, 80, 200, 10, 5], [70.628, False]],
  ['control 2', [45, 60, 60, 80, 1], [20.49, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [135, 60, 45, 45, 5], [52.239, False]],
  ['regression: azimuth difference units (partial repair)', [135, 10, 315, 45, 1], [125.0, False]],
  ['control 1', [135, 80, 60, 60, 5], [28.915, False]],
  ['control 2', [60, 30, 315, 10, 0.5], [97.696, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [200, 30, 60, 80, 0.5], [67.839, False]],
  ['regression: azimuth difference units (partial repair)', [0, 45, 250, 60, 2], [60.564, False]],
  ['control 1', [180, 45, 45, 60, 1], [68.754, False]],
  ['control 2', [300, 10, 135, 45, 5], [123.357, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [300, 30, 135, 60, 1], [89.155, False]],
  ['regression: azimuth difference units (partial repair)', [270, 45, 45, 10, 5], [111.692, False]],
  ['control 1', [270, 10, 250, 60, 0.5], [52.187, False]],
  ['control 2', [300, 30, 180, 30, 2], [97.181, False]]]]
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
normal: on target[0.0, True][0.0, True]Passed
boundary: across north[87.864, False][8.658, False]Failed
normal: elevation only[10.0, False][10.0, False]Passed
regression: azimuth difference units[54.325, False][44.136, False]Failed
regression: azimuth difference units (partial repair)[88.175, False][41.653, False]Failed
control 1[139.704, False][121.081, False]Failed
control 2[48.977, False][49.874, False]Failed

SHA-256 / 73f2418b6fd3ee533baa4ef61ab7d4c1277db6acee4607b38549d0e839a5928a

2 / The unsuccessful fix

Exit 1
"""Failure Map reference implementation. Python standard library only."""
import json
import math
N = 1
observations = []
def solve(cmd_az, cmd_el, sun_az, sun_el, tol):
    e1 = math.radians(cmd_el)
    e2 = math.radians(sun_el)
    d = math.radians(abs(cmd_az - sun_az) % 180)
    c = math.sin(e1) * math.sin(e2) + math.cos(e1) * math.cos(e2) * math.cos(d)
    c = max(-1.0, min(1.0, c))
    err = math.degrees(math.acos(c))
    return [round(err, 3), err <= tol]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [340, 45, 60, 80, 0.5], [44.136, False]],
  ['regression: azimuth difference units (partial repair)', [30, 30, 340, 45, 2], [41.653, False]],
  ['control 1', [90, 10, 225, 30, 0.5], [121.081, False]],
  ['control 2', [340, 80, 225, 45, 0.5], [49.874, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [200, 60, 60, 45, 5], [70.03, False]],
  ['regression: azimuth difference units (partial repair)', [180, 60, 0, 60, 0.5], [60.0, False]],
  ['control 1', [180, 80, 200, 10, 5], [70.628, False]],
  ['control 2', [45, 60, 60, 80, 1], [20.49, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [135, 60, 45, 45, 5], [52.239, False]],
  ['regression: azimuth difference units (partial repair)', [135, 10, 315, 45, 1], [125.0, False]],
  ['control 1', [135, 80, 60, 60, 5], [28.915, False]],
  ['control 2', [60, 30, 315, 10, 0.5], [97.696, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [200, 30, 60, 80, 0.5], [67.839, False]],
  ['regression: azimuth difference units (partial repair)', [0, 45, 250, 60, 2], [60.564, False]],
  ['control 1', [180, 45, 45, 60, 1], [68.754, False]],
  ['control 2', [300, 10, 135, 45, 5], [123.357, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [300, 30, 135, 60, 1], [89.155, False]],
  ['regression: azimuth difference units (partial repair)', [270, 45, 45, 10, 5], [111.692, False]],
  ['control 1', [270, 10, 250, 60, 0.5], [52.187, False]],
  ['control 2', [300, 30, 180, 30, 2], [97.181, False]]]]
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
normal: on target[0.0, True][0.0, True]Passed
boundary: across north[119.249, False][8.658, False]Failed
normal: elevation only[10.0, False][10.0, False]Passed
regression: azimuth difference units[47.543, False][44.136, False]Failed
regression: azimuth difference units (partial repair)[92.297, False][41.653, False]Failed
control 1[121.081, False][121.081, False]Passed
control 2[49.874, False][49.874, False]Passed

SHA-256 / 0df9e87d97df686d93bfe0d457a2247fd8e4d54f5374901fefcf1e33e271fd5f

3 / The verified repair

Exit 0
"""Failure Map reference implementation. Python standard library only."""
import json
import math
N = 1
observations = []
def solve(cmd_az, cmd_el, sun_az, sun_el, tol):
    e1 = math.radians(cmd_el)
    e2 = math.radians(sun_el)
    d = math.radians(cmd_az - sun_az)
    c = math.sin(e1) * math.sin(e2) + math.cos(e1) * math.cos(e2) * math.cos(d)
    c = max(-1.0, min(1.0, c))
    err = math.degrees(math.acos(c))
    return [round(err, 3), err <= tol]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [340, 45, 60, 80, 0.5], [44.136, False]],
  ['regression: azimuth difference units (partial repair)', [30, 30, 340, 45, 2], [41.653, False]],
  ['control 1', [90, 10, 225, 30, 0.5], [121.081, False]],
  ['control 2', [340, 80, 225, 45, 0.5], [49.874, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [200, 60, 60, 45, 5], [70.03, False]],
  ['regression: azimuth difference units (partial repair)', [180, 60, 0, 60, 0.5], [60.0, False]],
  ['control 1', [180, 80, 200, 10, 5], [70.628, False]],
  ['control 2', [45, 60, 60, 80, 1], [20.49, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [135, 60, 45, 45, 5], [52.239, False]],
  ['regression: azimuth difference units (partial repair)', [135, 10, 315, 45, 1], [125.0, False]],
  ['control 1', [135, 80, 60, 60, 5], [28.915, False]],
  ['control 2', [60, 30, 315, 10, 0.5], [97.696, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [200, 30, 60, 80, 0.5], [67.839, False]],
  ['regression: azimuth difference units (partial repair)', [0, 45, 250, 60, 2], [60.564, False]],
  ['control 1', [180, 45, 45, 60, 1], [68.754, False]],
  ['control 2', [300, 10, 135, 45, 5], [123.357, False]]],
 [['normal: on target', [180, 45, 180, 45, 1], [0.0, True]],
  ['boundary: across north', [355, 30, 5, 30, 1], [8.658, False]],
  ['normal: elevation only', [90, 30, 90, 40, 5], [10.0, False]],
  ['regression: azimuth difference units', [300, 30, 135, 60, 1], [89.155, False]],
  ['regression: azimuth difference units (partial repair)', [270, 45, 45, 10, 5], [111.692, False]],
  ['control 1', [270, 10, 250, 60, 0.5], [52.187, False]],
  ['control 2', [300, 30, 180, 30, 2], [97.181, False]]]]
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
normal: on target[0.0, True][0.0, True]Passed
boundary: across north[8.658, False][8.658, False]Passed
normal: elevation only[10.0, False][10.0, False]Passed
regression: azimuth difference units[44.136, False][44.136, False]Passed
regression: azimuth difference units (partial repair)[41.653, False][41.653, False]Passed
control 1[121.081, False][121.081, False]Passed
control 2[49.874, False][49.874, False]Passed

SHA-256 / ba42dce9d2f47261b41291f60183a3b1a044a81a9483b7db04a97a7565fc8bfe

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

Case digest / 880de09ea8829671579bfd16700dcfebce61ac0cb6dbbcaf6ddc471858448d01