Spaces:
Configuration error
Configuration error
| import { Cartesian3, JulianDate, Matrix3, Transforms } from "@cesium/engine"; | |
| import dayjs from "dayjs"; | |
| import { propagate } from "satellite.js"; | |
| import { beforeEach, describe, expect, test, vi } from "vitest"; | |
| import Orbit from "./Orbit"; | |
| import { SampledTrajectory } from "./SampledTrajectory"; | |
| import { drawablePositions } from "./util/drawablePositions"; | |
| import { createSatrec, parseGpPayload, type GpRecord } from "./util/gp"; | |
| import { InlineSampleSource, type SampleChunk, type TrajectorySampler } from "./util/sampleSource"; | |
| const TLE = "ISS (ZARYA)\n1 25544U 98067A 18342.69352573 .00002284 00000-0 41838-4 0 9992\n2 25544 51.6407 229.0798 0005166 124.8351 329.3296 15.54069892145658"; | |
| // Time near the TLE epoch so SGP4 propagation stays meaningful. | |
| const T0 = JulianDate.fromDate(dayjs("2018-12-08").toDate()); | |
| const issRecord = (): GpRecord => parseGpPayload(TLE)[0] as GpRecord; | |
| /** | |
| * A trajectory sampling inline. The unit tests are the second implementation's | |
| * reason for existing: they exercise the same code the worker runs, on this | |
| * thread, with no transport in the way. | |
| */ | |
| function issTrajectory(): { orbit: Orbit; trajectory: SampledTrajectory; sampler: TrajectorySampler; source: InlineSampleSource; periodSeconds: number } { | |
| const record = issRecord(); | |
| const orbit = new Orbit("ISS", record); | |
| const source = new InlineSampleSource(); | |
| const sampler = source.samplerFor("25544", record); | |
| return { orbit, trajectory: new SampledTrajectory(orbit, sampler), sampler, source, periodSeconds: orbit.orbitalPeriod * 60 }; | |
| } | |
| /** `start` only needs a clock and a tick event off the viewer. */ | |
| const fakeViewer = () => ({ clock: { currentTime: T0, onTick: { addEventListener: () => () => {} } } }) as unknown as Parameters<SampledTrajectory["start"]>[0]; | |
| beforeEach(() => { | |
| // The ICRF transform needs async-loaded IAU data that is unavailable in | |
| // Node; pin it to identity — these tests cover the window bookkeeping, not | |
| // the frame conversion itself. | |
| vi.spyOn(Transforms, "computeFixedToIcrfMatrix").mockImplementation(() => Matrix3.clone(Matrix3.IDENTITY)); | |
| }); | |
| describe("SampledTrajectory", () => { | |
| test("is empty before the first update", async () => { | |
| const { trajectory } = issTrajectory(); | |
| expect(trajectory.valid).toBe(false); | |
| expect(trajectory.fixed).toBeUndefined(); | |
| expect(trajectory.interval).toBeUndefined(); | |
| expect(trajectory.position(T0)).toBeUndefined(); | |
| expect(trajectory.positionsForNextOrbit(T0)).toHaveLength(0); | |
| }); | |
| test("update covers half an orbit back and 1.5 orbits forward", async () => { | |
| const { trajectory, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| expect(trajectory.valid).toBe(true); | |
| const interval = trajectory.interval!; | |
| expect(JulianDate.secondsDifference(T0, interval.start)).toBeCloseTo(periodSeconds / 2, 5); | |
| expect(JulianDate.secondsDifference(interval.stop, T0)).toBeCloseTo(periodSeconds * 1.5, 5); | |
| // Interpolated fixed-frame position sits at a plausible LEO radius. | |
| const position = trajectory.position(T0); | |
| expect(position).toBeDefined(); | |
| expect(Cartesian3.magnitude(position!) / 1000).toBeGreaterThan(6600); | |
| expect(Cartesian3.magnitude(position!) / 1000).toBeLessThan(6900); | |
| }); | |
| test("window slides forward as time advances", async () => { | |
| const { trajectory, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| const later = JulianDate.addSeconds(T0, periodSeconds, new JulianDate()); | |
| await trajectory.ensure(later); | |
| const interval = trajectory.interval!; | |
| expect(JulianDate.secondsDifference(later, interval.start)).toBeCloseTo(periodSeconds / 2, 5); | |
| expect(JulianDate.secondsDifference(interval.stop, later)).toBeCloseTo(periodSeconds * 1.5, 5); | |
| expect(trajectory.position(later)).toBeDefined(); | |
| }); | |
| test("positionsForNextOrbit returns one orbit of raw samples closed into a loop", async () => { | |
| const { trajectory } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| const positions = trajectory.positionsForNextOrbit(T0); | |
| // ~120 samples per orbit plus the repeated first sample closing the loop. | |
| expect(positions.length).toBeGreaterThan(100); | |
| expect(positions.at(-1)).toBe(positions[0]); | |
| const open = trajectory.positionsForNextOrbit(T0, "inertial", false); | |
| expect(open.length).toBe(positions.length - 1); | |
| }); | |
| test("groundTrack samples positions around the given time", async () => { | |
| const { trajectory } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| const track = trajectory.groundTrack(T0, 2, 1); | |
| expect(track).toHaveLength(4); | |
| expect(track.every((position) => position !== undefined)).toBe(true); | |
| }); | |
| // Outside the sampled window there is no track, and the callers have to see that | |
| // from the position count alone. A degenerate pair instead is what stopped | |
| // Cesium's render loop — see `drawablePositions` for the chain. | |
| test("a clock far outside the window yields no drawable track", async () => { | |
| const { trajectory, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| const far = JulianDate.addSeconds(T0, periodSeconds * 100, new JulianDate()); | |
| expect(trajectory.positionsForTrack(far).length).toBeLessThan(2); | |
| // What the ground track's corridor is built from, via #groundTrackPositions. | |
| expect(drawablePositions(trajectory.groundTrack(far)).length).toBeLessThan(2); | |
| }); | |
| test("the inertial frame is not sampled until something asks for it", async () => { | |
| const { trajectory } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| // A scene drawing points and nothing else pays for one sample set, not three: | |
| // the grid, and neither irregular-capable property. | |
| expect(trajectory.entityPosition).toBeDefined(); | |
| expect(trajectory.sampleCount).toBeGreaterThan(0); | |
| expect(trajectory.fixed).toBeUndefined(); | |
| expect(trajectory.inertial).toBeUndefined(); | |
| }); | |
| test("requireInertial backfills the window already sampled", async () => { | |
| const { trajectory } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| const samples = trajectory.sampleCount; | |
| expect(samples).toBeGreaterThan(0); | |
| trajectory.requireInertial(); | |
| // Backfilled from the samples already held rather than re-propagated, so the | |
| // two frames cover exactly the same instants. | |
| expect(trajectory.inertial?.length()).toBe(samples); | |
| }); | |
| test("requireInertial before the first update samples both frames from the start", async () => { | |
| const { trajectory } = issTrajectory(); | |
| trajectory.requireInertial(); | |
| await trajectory.ensure(T0); | |
| expect(trajectory.inertial?.length()).toBe(trajectory.sampleCount); | |
| }); | |
| test("requireInertial is idempotent and keeps the same property instance", async () => { | |
| const { trajectory } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| trajectory.requireInertial(); | |
| const first = trajectory.inertial; | |
| trajectory.requireInertial(); | |
| // Entities bind to this object, so replacing it would silently strand them. | |
| expect(trajectory.inertial).toBe(first); | |
| }); | |
| test("the inertial window keeps sliding once required", async () => { | |
| const { trajectory, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| trajectory.requireInertial(); | |
| await trajectory.ensure(JulianDate.addSeconds(T0, periodSeconds, new JulianDate())); | |
| // Not just backfilled once: the flag has to make every later refresh sample | |
| // both frames, or the orbit would freeze a window behind the satellite. | |
| expect(trajectory.inertial?.length()).toBe(trajectory.sampleCount); | |
| }); | |
| test("positionsForNextOrbit asking for the inertial frame requires it implicitly", async () => { | |
| const { trajectory } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| expect(trajectory.inertial).toBeUndefined(); | |
| const positions = trajectory.positionsForNextOrbit(T0, "inertial", false); | |
| expect(positions.length).toBeGreaterThan(0); | |
| expect(trajectory.inertial).toBeDefined(); | |
| }); | |
| describe("sampling through a source", () => { | |
| test("propagates nowhere on this thread — every sample comes from the source", async () => { | |
| const { orbit, trajectory, source } = issTrajectory(); | |
| const sgp4 = vi.spyOn(orbit, "positionECI"); | |
| await trajectory.ensure(T0); | |
| // Orbit still owns propagation for pass prediction and the info panel, but | |
| // the sampling path no longer touches it. | |
| expect(sgp4).not.toHaveBeenCalled(); | |
| expect(source.stats.chunks).toBe(1); | |
| expect(source.stats.samples).toBeGreaterThan(200); | |
| }); | |
| test("a slid window asks only for what it is missing", async () => { | |
| const { trajectory, source, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| const openingSamples = source.stats.samples; | |
| await trajectory.ensure(JulianDate.addSeconds(T0, periodSeconds / 4, new JulianDate())); | |
| // A quarter revolution on is a quarter revolution of new samples, not | |
| // another whole window. | |
| const added = source.stats.samples - openingSamples; | |
| expect(added).toBeGreaterThan(0); | |
| expect(added).toBeLessThan(openingSamples / 2); | |
| }); | |
| test("samples from consecutive requests land on one evenly spaced grid", async () => { | |
| const { trajectory, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| await trajectory.ensure(JulianDate.addSeconds(T0, periodSeconds / 4, new JulianDate())); | |
| // Through the sampled property, whose stored times are the thing under test; | |
| // asking for it backfills them from the grid. | |
| trajectory.requireSampled(); | |
| const { times } = trajectory.fixed!.getRawSamples(); | |
| expect(times.length).toBeGreaterThan(200); | |
| const gaps = times.slice(1).map((time, index) => JulianDate.secondsDifference(time, times[index] as JulianDate)); | |
| const expected = periodSeconds / 120; | |
| // The grid is anchored to the element set's epoch, so the seam between two | |
| // chunks is indistinguishable from anywhere else in the window. Without | |
| // that anchor this is where a duplicated or short gap would show up. | |
| for (const gap of gaps) { | |
| expect(gap).toBeCloseTo(expected, 3); | |
| } | |
| }); | |
| test("the grid entities read from agrees with the sampled property", async () => { | |
| const { trajectory, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| await trajectory.ensure(JulianDate.addSeconds(T0, periodSeconds / 4, new JulianDate())); | |
| // Not the same property, so not the same answer: a four-point cubic against | |
| // Cesium's six-point quintic over identical samples. The bound is what makes | |
| // the swap safe — a bug in the grid's index arithmetic would put the | |
| // satellite a whole sample step away, which is tens of kilometres. | |
| trajectory.requireSampled(); | |
| expect(trajectory.entityPosition).not.toBe(trajectory.fixed); | |
| for (let offset = 0; offset < periodSeconds; offset += periodSeconds / 40) { | |
| const time = JulianDate.addSeconds(T0, periodSeconds / 4 + offset, new JulianDate()); | |
| const grid = trajectory.entityPosition!.getValue(time) as Cartesian3; | |
| const sampled = trajectory.fixed!.getValue(time) as Cartesian3; | |
| expect(Cartesian3.distance(grid, sampled)).toBeLessThan(50); | |
| } | |
| }); | |
| test("requireSampled backfills the window already held, without re-propagating", async () => { | |
| const { orbit, trajectory, source } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| const requests = source.stats.requests; | |
| const sgp4 = vi.spyOn(orbit, "positionECI"); | |
| trajectory.requireSampled(); | |
| // The samples are already in hand; only the times have to be rebuilt, and | |
| // those come from the grid's anchor. | |
| expect(trajectory.fixed?.length()).toBe(trajectory.sampleCount); | |
| expect(source.stats.requests).toBe(requests); | |
| expect(sgp4).not.toHaveBeenCalled(); | |
| }); | |
| test("requireSampled keeps the window sliding once asked for", async () => { | |
| const { trajectory, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| trajectory.requireSampled(); | |
| const property = trajectory.fixed; | |
| await trajectory.ensure(JulianDate.addSeconds(T0, periodSeconds, new JulianDate())); | |
| // A path graphic binds to this object, so it has to keep being fed rather | |
| // than freeze at the window it was created from. | |
| expect(trajectory.fixed).toBe(property); | |
| expect(trajectory.fixed?.length()).toBe(trajectory.sampleCount); | |
| expect(trajectory.fixed?.getValue(JulianDate.addSeconds(T0, periodSeconds, new JulianDate()))).toBeDefined(); | |
| }); | |
| test("a trajectory re-initialised after asking still gets the sampled property", async () => { | |
| const { trajectory, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| trajectory.requireSampled(); | |
| // A clock jump clear of the window re-inits, which must honour what the | |
| // trajectory was already committed to sampling. | |
| await trajectory.ensure(JulianDate.addSeconds(T0, periodSeconds * 50, new JulianDate())); | |
| expect(trajectory.fixed?.length()).toBe(trajectory.sampleCount); | |
| }); | |
| test("a gap in a chunk falls back to the sampled property rather than closing it", async () => { | |
| const { trajectory, sampler } = issTrajectory(); | |
| vi.spyOn(sampler, "samples").mockImplementation(async (from, to) => { | |
| const chunk = (await new InlineSampleSource().samplerFor("25544", issRecord()).samples(from, to)) as SampleChunk; | |
| return { ...chunk, refusedIndices: [5, 6, 7] }; | |
| }); | |
| await trajectory.ensure(T0); | |
| // The grid read assumes no holes, so a chunk with any abandons the grid and | |
| // brings the sampled property into being unasked — that is the whole point of | |
| // the fallback, and without it a gapped satellite would have nowhere to read. | |
| expect(trajectory.fixed).toBeDefined(); | |
| expect(trajectory.entityPosition).toBe(trajectory.fixed); | |
| expect(trajectory.sampleCount).toBeGreaterThan(0); | |
| expect(trajectory.position(T0)).toBeDefined(); | |
| }); | |
| test("an abandoned grid does not go on answering with the window it stopped at", async () => { | |
| const { periodSeconds } = issTrajectory(); | |
| const inline = new InlineSampleSource().samplerFor("25544", issRecord()); | |
| let gap = false; | |
| // A gap only on the second fill, so the grid is populated and then abandoned. | |
| const sampler: TrajectorySampler = { | |
| samples: async (from, to) => { | |
| const chunk = (await inline.samples(from, to)) as SampleChunk; | |
| return gap ? { ...chunk, refusedIndices: [2] } : chunk; | |
| }, | |
| }; | |
| const subject = new SampledTrajectory(new Orbit("ISS", issRecord()), sampler); | |
| await subject.ensure(T0); | |
| // Captured from the grid, before the gap, so it is an independent value to | |
| // compare against rather than the same store asked twice — comparing | |
| // `position()` with `fixed.getValue()` after the fallback is comparing one | |
| // property to itself, which passes at distance 0 however broken the handover. | |
| const gridCount = subject.sampleCount; | |
| // A quarter orbit on, so it is comfortably inside the window both before and | |
| // after the refill. T0 itself is no good: the new window starts exactly there, | |
| // and a read at a window's first sample HOLDs rather than interpolates, which | |
| // is a legitimate ~350 km at one grid step and says nothing about the handover. | |
| const probe = JulianDate.addSeconds(T0, periodSeconds / 4, new JulianDate()); | |
| const atProbe = subject.position(probe)!; | |
| expect(gridCount).toBeGreaterThan(200); | |
| gap = true; | |
| const later = JulianDate.addSeconds(T0, periodSeconds / 2, new JulianDate()); | |
| await subject.ensure(later); | |
| // The window the grid held has to survive into the sampled property. If the | |
| // backfill came up empty the property would hold only the gapped chunk, and | |
| // HOLD would answer T0 with that chunk's first sample — most of an orbit away. | |
| expect(subject.entityPosition).toBe(subject.fixed); | |
| // A whole window, not just the gapped chunk — eviction trims the far end, so | |
| // the count lands near the pre-gap one rather than above it. | |
| expect(subject.sampleCount).toBeGreaterThan(gridCount / 2); | |
| // If the backfill came up empty, `fixed` would hold only the gapped chunk and | |
| // HOLD would answer this with a sample 1.25 revolutions away. | |
| expect(Cartesian3.distance(subject.position(probe)!, atProbe)).toBeLessThan(50); | |
| expect(subject.position(later)).toBeDefined(); | |
| // And the raw-sample readers follow the same store, or the track would be cut | |
| // from a window the satellite has already left. | |
| expect(subject.positionsForTrack(later).length).toBeGreaterThan(1); | |
| }); | |
| test("refused samples are skipped rather than re-propagated", async () => { | |
| const { orbit, trajectory, sampler } = issTrajectory(); | |
| const sgp4 = vi.spyOn(orbit, "positionECI"); | |
| vi.spyOn(sampler, "samples").mockImplementation(async (from, to) => { | |
| const chunk = (await new InlineSampleSource().samplerFor("25544", issRecord()).samples(from, to)) as SampleChunk; | |
| // Blank three samples the way the propagator would when it refuses them. | |
| return { ...chunk, refusedIndices: [5, 6, 7] }; | |
| }); | |
| await trajectory.ensure(T0); | |
| // Retrying would run the same propagator on the same instants and fail the | |
| // same way, so the samples are simply absent. | |
| expect(sgp4).not.toHaveBeenCalled(); | |
| expect(trajectory.valid).toBe(true); | |
| expect(trajectory.position(T0)).toBeDefined(); | |
| }); | |
| test("a source that cannot answer leaves the trajectory empty rather than wrong", async () => { | |
| const { trajectory, sampler } = issTrajectory(); | |
| vi.spyOn(sampler, "samples").mockResolvedValue(undefined); | |
| await trajectory.ensure(T0); | |
| expect(trajectory.valid).toBe(false); | |
| expect(trajectory.position(T0)).toBeUndefined(); | |
| }); | |
| test("only one request is outstanding, however many ticks arrive", async () => { | |
| const { trajectory, sampler, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| let outstanding = 0; | |
| let peak = 0; | |
| const inline = new InlineSampleSource().samplerFor("25544", issRecord()); | |
| vi.spyOn(sampler, "samples").mockImplementation(async (from, to) => { | |
| outstanding += 1; | |
| peak = Math.max(peak, outstanding); | |
| await Promise.resolve(); | |
| outstanding -= 1; | |
| return inline.samples(from, to); | |
| }); | |
| // Four ticks in one turn, the way a fast clock delivers them. | |
| const later = (n: number) => JulianDate.addSeconds(T0, (periodSeconds / 4) * n, new JulianDate()); | |
| await Promise.all([trajectory.ensure(later(1)), trajectory.ensure(later(2)), trajectory.ensure(later(3)), trajectory.ensure(later(4))]); | |
| // Without coalescing each of these computes the same missing range against | |
| // the same un-updated interval and asks for it again. | |
| expect(peak).toBe(1); | |
| }); | |
| test("a tick coalesced away is honoured once the fill lands", async () => { | |
| const { trajectory, sampler, periodSeconds } = issTrajectory(); | |
| await trajectory.ensure(T0); | |
| const asked: Array<[number, number]> = []; | |
| const inline = new InlineSampleSource().samplerFor("25544", issRecord()); | |
| vi.spyOn(sampler, "samples").mockImplementation(async (from, to) => { | |
| asked.push([from, to]); | |
| return inline.samples(from, to); | |
| }); | |
| const first = trajectory.ensure(JulianDate.addSeconds(T0, periodSeconds / 4, new JulianDate())); | |
| // Arrives while the first is still out, and asks about a much later time. | |
| const jumped = JulianDate.addSeconds(T0, periodSeconds * 4, new JulianDate()); | |
| const second = trajectory.ensure(jumped); | |
| await Promise.all([first, second]); | |
| // The re-run is scheduled from a `finally`, so let it settle. | |
| await new Promise((resolve) => setTimeout(resolve, 0)); | |
| await new Promise((resolve) => setTimeout(resolve, 0)); | |
| // The jump is not lost: the window ends up covering where the clock went. | |
| expect(asked.length).toBeGreaterThan(1); | |
| expect(trajectory.position(jumped)).toBeDefined(); | |
| }); | |
| test("start only arranges the top-ups; it does not fill", async () => { | |
| const { trajectory, source } = issTrajectory(); | |
| let notified = 0; | |
| const teardown = trajectory.start(fakeViewer(), () => { | |
| notified += 1; | |
| }); | |
| expect(notified).toBe(1); | |
| expect(source.stats.requests).toBe(0); | |
| teardown(); | |
| }); | |
| }); | |
| describe("fixed-frame samples", () => { | |
| // The rotation out of TEME is the sampler's own arithmetic rather than a Cesium | |
| // call (see temeToFixed, sgp4Worker). temeToFixed.test.ts pins the angle against | |
| // Cesium; this pins the whole path end to end, from a satrec this test propagates | |
| // itself to what a consumer reads back — so neither a sign flip nor a chunk | |
| // handled at the wrong instant can pass by agreeing with itself. | |
| // | |
| // Deliberately independent of where the rotation happens: it reconstructs the | |
| // expected position from the element set, not from anything the chunk carries. | |
| test("are Cesium's own rotation of an independently propagated TEME", async () => { | |
| const { trajectory, sampler } = issTrajectory(); | |
| const startMs = JulianDate.toDate(T0).getTime(); | |
| const chunk = (await sampler.samples(startMs, startMs + 30 * 60_000)) as SampleChunk; | |
| expect(chunk).toBeDefined(); | |
| const samples = Math.floor(chunk.positionsFixed.length / 3); | |
| expect(samples).toBeGreaterThan(10); | |
| trajectory.adopt(chunk); | |
| const satrec = createSatrec(issRecord()); | |
| const { anchorEpochMs, firstIndex, startEpochMs, stepSeconds } = chunk; | |
| const stepMs = stepSeconds * 1000; | |
| // Truncated for the same reason the sampler truncates: this is where the grid | |
| // files sample zero, whatever the anchor's fractional millisecond says. | |
| const anchor = JulianDate.fromDate(new Date(anchorEpochMs)); | |
| let worst = 0; | |
| for (let index = 0; index < samples; index += 1) { | |
| const propagated = propagate(satrec, new Date(startEpochMs + index * stepMs)); | |
| const teme = propagated?.position; | |
| expect(teme).toBeTruthy(); | |
| const temeMetres = new Cartesian3((teme as { x: number }).x * 1000, (teme as { y: number }).y * 1000, (teme as { z: number }).z * 1000); | |
| const time = JulianDate.addSeconds(anchor, (firstIndex + index) * stepSeconds, new JulianDate()); | |
| const expected = Matrix3.multiplyByVector(Transforms.computeTemeToPseudoFixedMatrix(time, new Matrix3()), temeMetres, new Cartesian3()); | |
| const actual = trajectory.position(time); | |
| expect(actual).toBeDefined(); | |
| worst = Math.max(worst, Cartesian3.distance(expected, actual as Cartesian3)); | |
| } | |
| // Millimetres, against positions in the millions of metres. A wrong rotation | |
| // is kilometres out, so this is a wide margin around an exact expectation. | |
| expect(worst).toBeLessThan(1e-3); | |
| }); | |
| }); | |
| }); | |