FAILURE MAP
← Case archive

FA-93351 / Solar tracker geometry / Open access

Horizontal single-axis true tracking: sun vector azimuth convention · case 01

Rotation angles are wrong for every azimuth except the diagonals.

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

ROOT CAUSE

East and north components are built as if azimuth were measured counterclockwise from east.

VERIFIED REPAIR

Use east = cos(el)sin(az) and north = cos(el)cos(az).

Unsuccessful approach: Negating the north component instead converts to a south-referenced azimuth.

Case contract

Sun azimuth clockwise from north and elevation in degrees. The horizontal axis points to axis_az; rotation is positive toward azimuth axis_az+90. Sun vector east=cos(el)sin(az), north=cos(el)cos(az), up=sin(el). Rotation = atan2(component along axis_az+90, up). Clamp to +-max_angle (status 'limited', else 'tracking'); sun_el <= 0 returns [0.0, 'night']. Round to 3 decimals.

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, axis_az, max_angle):
    if sun_el <= 0:
        return [0.0, 'night']
    az = math.radians(sun_az)
    el = math.radians(sun_el)
    e = math.cos(el) * math.cos(az)
    n = math.cos(el) * math.sin(az)
    u = math.sin(el)
    pa = math.radians(axis_az + 90)
    perp = e * math.sin(pa) + n * math.cos(pa)
    rot = math.degrees(math.atan2(perp, u))
    status = 'tracking'
    if abs(rot) > max_angle:
        rot = math.copysign(max_angle, rot)
        status = 'limited'
    return [round(rot, 3), status]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [315, 75, 20, 45], [-13.65, 'tracking']],
  ['regression: sun vector azimuth convention (partial repair)', [180, 60, 190, 45],
   [-5.725, 'tracking']],
  ['control 1', [150, 10, 190, 45], [-45.0, 'limited']],
  ['control 2', [180, 89, 0, 55], [0.0, 'tracking']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [100, 3, 20, 60], [60.0, 'limited']],
  ['regression: sun vector azimuth convention (partial repair)', [200, 10, 190, 55],
   [44.561, 'tracking']],
  ['control 1', [60, 0, 20, 45], [0.0, 'night']],
  ['control 2', [340, 60, 170, 45], [5.725, 'tracking']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [0, 10, 190, 60], [44.561, 'tracking']],
  ['regression: sun vector azimuth convention (partial repair)', [300, 45, 20, 45],
   [-44.561, 'tracking']],
  ['control 1', [135, 0, 0, 60], [0.0, 'night']],
  ['control 2', [60, 20, 170, 55], [-55.0, 'limited']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [270, 75, 0, 60], [-15.0, 'tracking']],
  ['regression: sun vector azimuth convention (partial repair)', [150, 45, 170, 55],
   [-18.882, 'tracking']],
  ['control 1', [90, 20, 0, 60], [60.0, 'limited']],
  ['control 2', [30, 3, 190, 45], [-45.0, 'limited']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [340, 3, 20, 55], [-55.0, 'limited']],
  ['regression: sun vector azimuth convention (partial repair)', [30, 20, 170, 45],
   [-45.0, 'limited']],
  ['control 1', [100, 3, 190, 60], [-60.0, 'limited']],
  ['control 2', [90, 75, 170, 55], [-14.782, 'tracking']]]]
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: sun on the horizon[0.0, 'night'][0.0, 'night']Passed
normal: solar noon on N-S axis[30.0, 'tracking'][0.0, 'tracking']Failed
boundary: low morning sun hits east limit[-0.0, 'tracking'][-60.0, 'limited']Failed
regression: sun vector azimuth convention[13.65, 'tracking'][-13.65, 'tracking']Failed
regression: sun vector azimuth convention (partial repair)[29.622, 'tracking'][-5.725, 'tracking']Failed
control 1[45.0, 'limited'][-45.0, 'limited']Failed
control 2[-1.0, 'tracking'][0.0, 'tracking']Failed

SHA-256 / 147a8fbf261ba01caa2d712e85bc32cfd315d2771f470a3af5346bc6a3fd1c15

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, axis_az, max_angle):
    if sun_el <= 0:
        return [0.0, 'night']
    az = math.radians(sun_az)
    el = math.radians(sun_el)
    e = math.cos(el) * math.sin(az)
    n = -math.cos(el) * math.cos(az)
    u = math.sin(el)
    pa = math.radians(axis_az + 90)
    perp = e * math.sin(pa) + n * math.cos(pa)
    rot = math.degrees(math.atan2(perp, u))
    status = 'tracking'
    if abs(rot) > max_angle:
        rot = math.copysign(max_angle, rot)
        status = 'limited'
    return [round(rot, 3), status]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [315, 75, 20, 45], [-13.65, 'tracking']],
  ['regression: sun vector azimuth convention (partial repair)', [180, 60, 190, 45],
   [-5.725, 'tracking']],
  ['control 1', [150, 10, 190, 45], [-45.0, 'limited']],
  ['control 2', [180, 89, 0, 55], [0.0, 'tracking']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [100, 3, 20, 60], [60.0, 'limited']],
  ['regression: sun vector azimuth convention (partial repair)', [200, 10, 190, 55],
   [44.561, 'tracking']],
  ['control 1', [60, 0, 20, 45], [0.0, 'night']],
  ['control 2', [340, 60, 170, 45], [5.725, 'tracking']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [0, 10, 190, 60], [44.561, 'tracking']],
  ['regression: sun vector azimuth convention (partial repair)', [300, 45, 20, 45],
   [-44.561, 'tracking']],
  ['control 1', [135, 0, 0, 60], [0.0, 'night']],
  ['control 2', [60, 20, 170, 55], [-55.0, 'limited']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [270, 75, 0, 60], [-15.0, 'tracking']],
  ['regression: sun vector azimuth convention (partial repair)', [150, 45, 170, 55],
   [-18.882, 'tracking']],
  ['control 1', [90, 20, 0, 60], [60.0, 'limited']],
  ['control 2', [30, 3, 190, 45], [-45.0, 'limited']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [340, 3, 20, 55], [-55.0, 'limited']],
  ['regression: sun vector azimuth convention (partial repair)', [30, 20, 170, 45],
   [-45.0, 'limited']],
  ['control 1', [100, 3, 190, 60], [-60.0, 'limited']],
  ['control 2', [90, 75, 170, 55], [-14.782, 'tracking']]]]
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: sun on the horizon[0.0, 'night'][0.0, 'night']Passed
normal: solar noon on N-S axis[-0.0, 'tracking'][0.0, 'tracking']Passed
boundary: low morning sun hits east limit[-60.0, 'limited'][-60.0, 'limited']Passed
regression: sun vector azimuth convention[-6.461, 'tracking'][-13.65, 'tracking']Failed
regression: sun vector azimuth convention (partial repair)[5.725, 'tracking'][-5.725, 'tracking']Failed
control 1[-45.0, 'limited'][-45.0, 'limited']Passed
control 2[0.0, 'tracking'][0.0, 'tracking']Passed

SHA-256 / 0ebc39ecc51b92964a76a666d6f8cb683d8284c466a380d08b65960503899b46

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, axis_az, max_angle):
    if sun_el <= 0:
        return [0.0, 'night']
    az = math.radians(sun_az)
    el = math.radians(sun_el)
    e = math.cos(el) * math.sin(az)
    n = math.cos(el) * math.cos(az)
    u = math.sin(el)
    pa = math.radians(axis_az + 90)
    perp = e * math.sin(pa) + n * math.cos(pa)
    rot = math.degrees(math.atan2(perp, u))
    status = 'tracking'
    if abs(rot) > max_angle:
        rot = math.copysign(max_angle, rot)
        status = 'limited'
    return [round(rot, 3), status]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [315, 75, 20, 45], [-13.65, 'tracking']],
  ['regression: sun vector azimuth convention (partial repair)', [180, 60, 190, 45],
   [-5.725, 'tracking']],
  ['control 1', [150, 10, 190, 45], [-45.0, 'limited']],
  ['control 2', [180, 89, 0, 55], [0.0, 'tracking']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [100, 3, 20, 60], [60.0, 'limited']],
  ['regression: sun vector azimuth convention (partial repair)', [200, 10, 190, 55],
   [44.561, 'tracking']],
  ['control 1', [60, 0, 20, 45], [0.0, 'night']],
  ['control 2', [340, 60, 170, 45], [5.725, 'tracking']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [0, 10, 190, 60], [44.561, 'tracking']],
  ['regression: sun vector azimuth convention (partial repair)', [300, 45, 20, 45],
   [-44.561, 'tracking']],
  ['control 1', [135, 0, 0, 60], [0.0, 'night']],
  ['control 2', [60, 20, 170, 55], [-55.0, 'limited']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [270, 75, 0, 60], [-15.0, 'tracking']],
  ['regression: sun vector azimuth convention (partial repair)', [150, 45, 170, 55],
   [-18.882, 'tracking']],
  ['control 1', [90, 20, 0, 60], [60.0, 'limited']],
  ['control 2', [30, 3, 190, 45], [-45.0, 'limited']]],
 [['boundary: sun on the horizon', [90, 0, 180, 60], [0.0, 'night']],
  ['normal: solar noon on N-S axis', [180, 60, 180, 60], [0.0, 'tracking']],
  ['boundary: low morning sun hits east limit', [90, 10, 180, 60], [-60.0, 'limited']],
  ['regression: sun vector azimuth convention', [340, 3, 20, 55], [-55.0, 'limited']],
  ['regression: sun vector azimuth convention (partial repair)', [30, 20, 170, 45],
   [-45.0, 'limited']],
  ['control 1', [100, 3, 190, 60], [-60.0, 'limited']],
  ['control 2', [90, 75, 170, 55], [-14.782, 'tracking']]]]
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: sun on the horizon[0.0, 'night'][0.0, 'night']Passed
normal: solar noon on N-S axis[0.0, 'tracking'][0.0, 'tracking']Passed
boundary: low morning sun hits east limit[-60.0, 'limited'][-60.0, 'limited']Passed
regression: sun vector azimuth convention[-13.65, 'tracking'][-13.65, 'tracking']Passed
regression: sun vector azimuth convention (partial repair)[-5.725, 'tracking'][-5.725, 'tracking']Passed
control 1[-45.0, 'limited'][-45.0, 'limited']Passed
control 2[0.0, 'tracking'][0.0, 'tracking']Passed

SHA-256 / 423b6838350e13c74b0c62c3191e618b75504c2c9bc8a1528648b58034584688

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

Case digest / 42f1f18ef0ccafeb732a899a230cb32f28b7d3559242fffd92d60259c2890fc3