Spaces:
Configuration error
Configuration error
| 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); | |
| }); | |
| }); | |