FAILURE MAP
← Case archive

FA-93691 / Solar tracker geometry / Open access

Tilted single-axis tracker frame: reference normal tilt direction · case 01

Tilted rows report rotations as if the axis sloped the other way.

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

ROOT CAUSE

The zero-rotation normal leans toward the rising end of the axis.

VERIFIED REPAIR

Lean the reference normal away from the rising end.

Unsuccessful approach: Using the vertical ignores the axis tilt and is not perpendicular to the axis.

Case contract

The axis points toward axis_az rising by axis_tilt: A = (sin a cos b, cos a cos b, sin b). The zero-rotation panel normal is N0 = (-sin a sin b, -cos a sin b, cos b) and positive rotation turns toward R = (cos a, -sin a, 0). With unit sun vector s, rotation = atan2(s.R, s.N0) and the incidence at ideal tracking is asin(|s.A|). Return [rotation, incidence] rounded to 3, or None when sun_el <= 0.

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, axis_tilt):
    if sun_el <= 0:
        return None
    az = math.radians(sun_az)
    el = math.radians(sun_el)
    a = math.radians(axis_az)
    b = math.radians(axis_tilt)
    s = [math.cos(el) * math.sin(az), math.cos(el) * math.cos(az), math.sin(el)]
    A = [math.sin(a) * math.cos(b), math.cos(a) * math.cos(b), math.sin(b)]
    N0 = [math.sin(a) * math.sin(b), math.cos(a) * math.sin(b), math.cos(b)]
    R = [math.cos(a), -math.sin(a), 0.0]
    dot = lambda u, v: sum(p * q for p, q in zip(u, v))
    theta = math.degrees(math.atan2(dot(s, R), dot(s, N0)))
    aoi = math.degrees(math.asin(min(1.0, abs(dot(s, A)))))
    return [round(theta, 3), round(aoi, 3)]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [90, 75, 170, 10], [-15.118, 12.239]],
  ['regression: reference normal tilt direction (partial repair)', [315, 45, 0, 10],
   [-39.362, 37.966]],
  ['control 1', [300, 0, 180, 0], None], ['control 2', [225, 3, 180, 20], [105.236, 42.957]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [225, 89, 180, 5], [0.711, 5.707]],
  ['regression: reference normal tilt direction (partial repair)', [135, 3, 0, 20],
   [67.625, 40.215]],
  ['control 1', [45, 60, 170, 10], [-24.406, 7.588]],
  ['control 2', [135, 75, 170, 20], [-10.079, 31.978]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [225, 30, 180, 10], [57.771, 43.622]],
  ['regression: reference normal tilt direction (partial repair)', [315, 10, 170, 10],
   [61.157, 49.845]],
  ['control 1', [340, 45, 170, 20], [7.747, 24.363]],
  ['control 2', [340, 10, 180, 20], [35.076, 54.117]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [180, 10, 180, 10], [180.0, 90.0]],
  ['regression: reference normal tilt direction (partial repair)', [90, 60, 0, 20],
   [31.567, 17.229]],
  ['control 1', [340, 20, 180, 0], [43.219, 62.009]],
  ['control 2', [30, 3, 0, 20], [116.285, 56.159]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [135, 20, 170, 5], [-63.084, 52.81]],
  ['regression: reference normal tilt direction (partial repair)', [225, 10, 0, 10],
   [-67.255, 40.968]],
  ['control 1', [100, 30, 0, 5], [59.062, 6.098]],
  ['control 2', [135, 75, 180, 10], [-11.257, 20.363]]]]
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: horizontal axis at noon[0.0, 30.0][0.0, 30.0]Passed
normal: tilted axis morning[-68.466, 12.714][-71.564, 12.714]Failed
boundary: nightNoneNonePassed
regression: reference normal tilt direction[-14.883, 12.239][-15.118, 12.239]Failed
regression: reference normal tilt direction (partial repair)[-32.555, 37.966][-39.362, 37.966]Failed
control 1NoneNonePassed
control 2[67.625, 42.957][105.236, 42.957]Failed

SHA-256 / 67f45229622089ea13081f6697c786e2387533a45adcd397a0120e0318338b38

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, axis_tilt):
    if sun_el <= 0:
        return None
    az = math.radians(sun_az)
    el = math.radians(sun_el)
    a = math.radians(axis_az)
    b = math.radians(axis_tilt)
    s = [math.cos(el) * math.sin(az), math.cos(el) * math.cos(az), math.sin(el)]
    A = [math.sin(a) * math.cos(b), math.cos(a) * math.cos(b), math.sin(b)]
    N0 = [0.0, 0.0, 1.0]
    R = [math.cos(a), -math.sin(a), 0.0]
    dot = lambda u, v: sum(p * q for p, q in zip(u, v))
    theta = math.degrees(math.atan2(dot(s, R), dot(s, N0)))
    aoi = math.degrees(math.asin(min(1.0, abs(dot(s, A)))))
    return [round(theta, 3), round(aoi, 3)]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [90, 75, 170, 10], [-15.118, 12.239]],
  ['regression: reference normal tilt direction (partial repair)', [315, 45, 0, 10],
   [-39.362, 37.966]],
  ['control 1', [300, 0, 180, 0], None], ['control 2', [225, 3, 180, 20], [105.236, 42.957]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [225, 89, 180, 5], [0.711, 5.707]],
  ['regression: reference normal tilt direction (partial repair)', [135, 3, 0, 20],
   [67.625, 40.215]],
  ['control 1', [45, 60, 170, 10], [-24.406, 7.588]],
  ['control 2', [135, 75, 170, 20], [-10.079, 31.978]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [225, 30, 180, 10], [57.771, 43.622]],
  ['regression: reference normal tilt direction (partial repair)', [315, 10, 170, 10],
   [61.157, 49.845]],
  ['control 1', [340, 45, 170, 20], [7.747, 24.363]],
  ['control 2', [340, 10, 180, 20], [35.076, 54.117]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [180, 10, 180, 10], [180.0, 90.0]],
  ['regression: reference normal tilt direction (partial repair)', [90, 60, 0, 20],
   [31.567, 17.229]],
  ['control 1', [340, 20, 180, 0], [43.219, 62.009]],
  ['control 2', [30, 3, 0, 20], [116.285, 56.159]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [135, 20, 170, 5], [-63.084, 52.81]],
  ['regression: reference normal tilt direction (partial repair)', [225, 10, 0, 10],
   [-67.255, 40.968]],
  ['control 1', [100, 30, 0, 5], [59.062, 6.098]],
  ['control 2', [135, 75, 180, 10], [-11.257, 20.363]]]]
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: horizontal axis at noon[0.0, 30.0][0.0, 30.0]Passed
normal: tilted axis morning[-69.716, 12.714][-71.564, 12.714]Failed
boundary: nightNoneNonePassed
regression: reference normal tilt direction[-14.782, 12.239][-15.118, 12.239]Failed
regression: reference normal tilt direction (partial repair)[-35.264, 37.966][-39.362, 37.966]Failed
control 1NoneNonePassed
control 2[85.761, 42.957][105.236, 42.957]Failed

SHA-256 / 4a635070b29286ba3d54a20a490dd9b59efe0798927777e0bfba066dd2a97766

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, axis_tilt):
    if sun_el <= 0:
        return None
    az = math.radians(sun_az)
    el = math.radians(sun_el)
    a = math.radians(axis_az)
    b = math.radians(axis_tilt)
    s = [math.cos(el) * math.sin(az), math.cos(el) * math.cos(az), math.sin(el)]
    A = [math.sin(a) * math.cos(b), math.cos(a) * math.cos(b), math.sin(b)]
    N0 = [-math.sin(a) * math.sin(b), -math.cos(a) * math.sin(b), math.cos(b)]
    R = [math.cos(a), -math.sin(a), 0.0]
    dot = lambda u, v: sum(p * q for p, q in zip(u, v))
    theta = math.degrees(math.atan2(dot(s, R), dot(s, N0)))
    aoi = math.degrees(math.asin(min(1.0, abs(dot(s, A)))))
    return [round(theta, 3), round(aoi, 3)]
def check(label, actual, expected):
    observations.append({"check": label, "actual": actual, "expected": expected, "passed": actual == expected})
fixtures = [[['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [90, 75, 170, 10], [-15.118, 12.239]],
  ['regression: reference normal tilt direction (partial repair)', [315, 45, 0, 10],
   [-39.362, 37.966]],
  ['control 1', [300, 0, 180, 0], None], ['control 2', [225, 3, 180, 20], [105.236, 42.957]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [225, 89, 180, 5], [0.711, 5.707]],
  ['regression: reference normal tilt direction (partial repair)', [135, 3, 0, 20],
   [67.625, 40.215]],
  ['control 1', [45, 60, 170, 10], [-24.406, 7.588]],
  ['control 2', [135, 75, 170, 20], [-10.079, 31.978]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [225, 30, 180, 10], [57.771, 43.622]],
  ['regression: reference normal tilt direction (partial repair)', [315, 10, 170, 10],
   [61.157, 49.845]],
  ['control 1', [340, 45, 170, 20], [7.747, 24.363]],
  ['control 2', [340, 10, 180, 20], [35.076, 54.117]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [180, 10, 180, 10], [180.0, 90.0]],
  ['regression: reference normal tilt direction (partial repair)', [90, 60, 0, 20],
   [31.567, 17.229]],
  ['control 1', [340, 20, 180, 0], [43.219, 62.009]],
  ['control 2', [30, 3, 0, 20], [116.285, 56.159]]],
 [['normal: horizontal axis at noon', [180, 60, 180, 0], [0.0, 30.0]],
  ['normal: tilted axis morning', [100, 20, 180, 10], [-71.564, 12.714]],
  ['boundary: night', [270, 0, 180, 10], None],
  ['regression: reference normal tilt direction', [135, 20, 170, 5], [-63.084, 52.81]],
  ['regression: reference normal tilt direction (partial repair)', [225, 10, 0, 10],
   [-67.255, 40.968]],
  ['control 1', [100, 30, 0, 5], [59.062, 6.098]],
  ['control 2', [135, 75, 180, 10], [-11.257, 20.363]]]]
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: horizontal axis at noon[0.0, 30.0][0.0, 30.0]Passed
normal: tilted axis morning[-71.564, 12.714][-71.564, 12.714]Passed
boundary: nightNoneNonePassed
regression: reference normal tilt direction[-15.118, 12.239][-15.118, 12.239]Passed
regression: reference normal tilt direction (partial repair)[-39.362, 37.966][-39.362, 37.966]Passed
control 1NoneNonePassed
control 2[105.236, 42.957][105.236, 42.957]Passed

SHA-256 / 2e58695ddd69d8dcbd693beff0576a41dc34a28517f0153eef1ba011ab8ccf9c

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

Case digest / 1c27fcc89075ef8dfb717fccce339f073b17427a3077b169631b7914d60df24d