File size: 5,271 Bytes
9f21d0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import { Cartesian3, JulianDate, Matrix3, Transforms } from "@cesium/engine";
import { describe, expect, test } from "vitest";

import { fixedRotationAt, greenwichHourAngle } from "./temeToFixed";

// The whole point of this module is that it reproduces Cesium's
// `computeTemeToPseudoFixedMatrix` rather than approximating it, so every test here
// is a comparison against Cesium itself. Tolerances are in metres at orbital radius,
// which is the only unit in which "close enough" means anything.

const LEO_RADIUS = 6_871_000;
const GEO_RADIUS = 42_164_000;

/** Cesium's own answer for the same instant, as a fixed-frame position. */
function cesiumRotate(epochMs: number, teme: Cartesian3): Cartesian3 {
  const matrix = Transforms.computeTemeToPseudoFixedMatrix(JulianDate.fromDate(new Date(epochMs)), new Matrix3());
  return Matrix3.multiplyByVector(matrix, teme, new Cartesian3());
}

function ourRotate(epochMs: number, teme: Cartesian3): Cartesian3 {
  const rotation = fixedRotationAt(epochMs, { cos: 1, sin: 0 });
  return new Cartesian3(rotation.cos * teme.x + rotation.sin * teme.y, rotation.cos * teme.y - rotation.sin * teme.x, teme.z);
}

function deviationMetres(epochMs: number, teme: Cartesian3): number {
  return Cartesian3.magnitude(Cartesian3.subtract(ourRotate(epochMs, teme), cesiumRotate(epochMs, teme), new Cartesian3()));
}

describe("greenwichHourAngle", () => {
  test("agrees with Cesium's transform to well under a millimetre, across three years", () => {
    // A fixed pseudo-random spread rather than a handful of round numbers: the
    // failure this guards against is a branch or a rounding term that only bites at
    // particular times of day.
    let seed = 1_234_567;
    const next = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
    const teme = new Cartesian3(LEO_RADIUS * 0.7, LEO_RADIUS * 0.5, LEO_RADIUS * 0.51);

    let worst = 0;
    for (let i = 0; i < 500; i += 1) {
      worst = Math.max(worst, deviationMetres(Date.UTC(2026, 0, 1) + Math.floor(next() * 3 * 365 * 86_400_000), teme));
    }
    expect(worst).toBeLessThan(1e-5);
  });

  test("holds across the midnight and noon boundaries where the tabulation switches branch", () => {
    const teme = new Cartesian3(GEO_RADIUS * 0.9, GEO_RADIUS * 0.4, 0);
    // 43200 s into the Julian day is where the +/- half-day term flips, and midnight
    // is where secondsSinceMidnight wraps. Straddle both by a millisecond.
    for (const boundary of [Date.UTC(2026, 5, 17, 0, 0, 0), Date.UTC(2026, 5, 17, 12, 0, 0)]) {
      for (const offsetMs of [-1, 0, 1, -1000, 1000]) {
        expect(deviationMetres(boundary + offsetMs, teme)).toBeLessThan(1e-5);
      }
    }
  });

  test("is continuous across those boundaries", () => {
    // A branch that picked the wrong tabulation would show up as a step here rather
    // than as an outright wrong answer.
    const midnight = Date.UTC(2026, 5, 17, 0, 0, 0);
    const before = greenwichHourAngle(midnight - 1);
    const after = greenwichHourAngle(midnight + 1);
    const step = Math.abs(((after - before + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
    // Two milliseconds of Earth rotation, generously bounded.
    expect(step).toBeLessThan(1e-6);
  });

  test("advances at Earth's rotation rate", () => {
    const t0 = Date.UTC(2026, 2, 3, 4, 5, 6);
    const perSecond = greenwichHourAngle(t0 + 1000) - greenwichHourAngle(t0);
    expect(perSecond).toBeCloseTo(7.2921158553e-5, 12);
  });

  test("keeps full resolution rather than losing it to a large julian float", () => {
    // The naive `2440587.5 + ms / 86400000` loses about 47 us of Earth rotation to
    // the ulp of a ~2.4e6 double, which measured as a 9.5 mm error. One millisecond
    // apart must therefore still be distinguishable, and by the right amount.
    const t0 = Date.UTC(2026, 8, 21, 18, 42, 11);
    const delta = greenwichHourAngle(t0 + 1) - greenwichHourAngle(t0);
    expect(delta).toBeGreaterThan(7.2e-8);
    expect(delta).toBeLessThan(7.4e-8);
  });

  test("matches Cesium at GEO radius, where the same angle error costs six times more", () => {
    const teme = new Cartesian3(GEO_RADIUS, 0, 0);
    expect(deviationMetres(Date.UTC(2027, 10, 2, 7, 13, 29), teme)).toBeLessThan(1e-5);
  });
});

describe("leap seconds", () => {
  // This module reads UTC epoch milliseconds directly; Cesium carries TAI and
  // subtracts taiMinusUtc. The two agree exactly while no leap second falls in
  // between, which has been the case since 2017 and is expected to remain so - the
  // IERS has resolved to stop inserting them. This test exists to pin what happens
  // if that changes rather than to assert it never will: the failure mode is a
  // one-second rotation offset, and it is worth knowing that it stays bounded to
  // that instead of corrupting the whole window.
  test("agrees on both sides of a historical leap second", () => {
    const teme = new Cartesian3(LEO_RADIUS, 0, 0);
    // 2016-12-31T23:59:60Z was the most recent insertion.
    const wellBefore = Date.UTC(2016, 11, 31, 12, 0, 0);
    const wellAfter = Date.UTC(2017, 0, 1, 12, 0, 0);
    expect(deviationMetres(wellBefore, teme)).toBeLessThan(1e-5);
    expect(deviationMetres(wellAfter, teme)).toBeLessThan(1e-5);
  });
});