orbit-studio / src /modules /SampledTrajectory.test.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
23.4 kB
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);
});
});
});