import { Cartesian3, ExtrapolationType, JulianDate, LagrangePolynomialApproximation, Matrix3, ReferenceFrame, SampledPositionProperty, TimeInterval, Transforms, defined, } from "@cesium/engine"; import type { InterpolationAlgorithm } from "@cesium/engine"; import type { Viewer } from "@cesium/widgets"; import type Orbit from "./Orbit"; import "./util/CesiumSampledPositionRawValueAccess"; import { CesiumCallbackHelper } from "./util/CesiumCallbackHelper"; import { drawablePositions } from "./util/drawablePositions"; import { GridPositionProperty } from "./util/GridPositionProperty"; import type { SampleChunk, TrajectorySampler } from "./util/sampleSource"; import { trajectoryWindow } from "./util/trajectoryWindow"; // Cesium 1.143 widened the InterpolationAlgorithm interface (type/interpolate) without // updating the LagrangePolynomialApproximation namespace declaration; the runtime object // satisfies the interface, so bridge the upstream typings gap with a cast. const lagrangeInterpolation = LagrangePolynomialApproximation as unknown as InterpolationAlgorithm; interface SampledPositionData { interval: TimeInterval; /** * Absent until a path graphic asks for it, exactly like `inertial`. The grid is * what everything else reads, and a `SampledPositionProperty` over the same * window is expensive in a way that is easy to miss: a `JulianDate` object per * sample, and 241 of those per satellite. Measured at 13.2 KB a satellite — 66 MB * across five thousand. See `requireSampled`. */ fixed: SampledPositionProperty | undefined; /** * Absent until something asks for it. Only the Orbit component reads the * inertial frame, and carrying a second full sample set for every satellite in * a scene that never draws one measured 8.7 KB a satellite — 43 MB across five * thousand. See `requireInertial`. */ inertial: SampledPositionProperty | undefined; valid: boolean; } /** * The single owner of the sampled position for one satellite: the sliding * sample window (half an orbit back, 1.5 forward), gap-filling and eviction * as time advances, and the fixed/inertial frame duality. * * Consumers subscribe via `start()` and read positions through the accessors; * nothing outside this module touches the sample bookkeeping. */ export class SampledTrajectory { #orbit: Orbit; #data: SampledPositionData | undefined; /** See requireInertial. */ #wantsInertial = false; /** See requireSampled. */ #wantsSampled = false; /** * Where samples come from. Injected rather than reached for, so this class has * no opinion about whether propagation happens on a worker — see sampleSource. */ readonly #sampler: TrajectorySampler; /** * The same fixed-frame samples again, on a uniform grid, for entities to read. * * Entities are evaluated once each per frame and that evaluation was the largest * single cost in a large scene — measured at 5,000 satellites, halving * `dataSourceDisplay.update` from 12.0 ms to 5.1 and taking the frame rate from * 71 to 97. See GridPositionProperty for why, and for the caveats. * * The authoritative store, and the only one most satellites have: `fixed` and * `inertial` are both built on demand from this. It is also the smaller of the * two, because it derives sample times from the anchor instead of keeping a * `JulianDate` object per sample — GridPositionProperty has the figures. */ #gridFixed = new GridPositionProperty(); /** * False when a chunk arrived with samples the propagator refused. The grid read * depends on there being no holes in it, so such a satellite falls back to the * sampled property — correct, merely slower. Measured across the live catalog * this has never fired. */ #gridUsable = true; /** The fill in flight, if any. At most one — see `ensure`. */ #filling: Promise | undefined; /** The time a coalesced tick asked about, to be honoured once the fill lands. */ #pendingTime: JulianDate | undefined; /** * Set once `start`'s teardown has run. * * A separate flag rather than `!this.#data`, because the branch that needs it — * the whole-window fill — is entered precisely when `#data` is already undefined, * so that test cannot tell "never had a window" from "window taken away". */ #stopped = false; constructor(orbit: Orbit, sampler: TrajectorySampler) { this.#orbit = orbit; this.#sampler = sampler; } /** Whether samples exist and propagation has not failed. */ get valid(): boolean { return this.#data?.valid ?? false; } /** * Fixed-frame samples, irregular-capable. What path graphics need: Cesium's * PathVisualizer sub-samples a `SampledPositionProperty` at its stored sample * times and anything else at `resolution`, which would be far coarser. * * Call `requireSampled` first — like `inertial`, this returns undefined on a * trajectory nothing has asked for it on rather than quietly building one. */ get fixed(): SampledPositionProperty | undefined { return this.#data?.fixed; } /** * Declare that the irregular-capable property is needed, and make it so. * * The same shape as `requireInertial`: a flag so every later refresh feeds it, * and a backfill from the grid so a window already up is not re-propagated. The * backfill is the grid's own samples with times derived from the anchor, so it * costs no SGP4 and no frame transforms. */ requireSampled(): void { if (this.#wantsSampled) { return; } this.#wantsSampled = true; this.#backfillSampled(); } #backfillSampled(): void { const data = this.#data; if (!data || data.fixed) { return; } const fixed = SampledTrajectory.#createProperty(ReferenceFrame.FIXED); const { times, positions } = this.#windowSamples(); if (times.length > 0) { fixed.addSamples(times, positions); } data.fixed = fixed; } /** * The window's samples, from whichever store currently owns them. * * Normally the grid. Once a gap has made the grid unusable it is abandoned and * cleared, and the sampled property is the only complete record — reading the * grid then would hand back the window it happened to stop at, which is a * position for the wrong time rather than a missing one. */ #windowSamples(): { times: JulianDate[]; positions: Cartesian3[] } { if (this.#gridUsable && this.#gridFixed.length > 0) { return this.#gridFixed.allSamples(); } const fixed = this.#data?.fixed; if (!fixed) { return { times: [], positions: [] }; } const { times, values } = fixed.getRawSamples(); return { times, positions: values as Cartesian3[] }; } /** * How many samples the window holds, whichever store owns them. * * Exists so a caller can ask about the window without first working out which * property is live — and because the two stores spell it differently (`length` a * getter here, `length()` a method on Cesium's). */ get sampleCount(): number { if (this.#gridUsable && this.#gridFixed.length > 0) { return this.#gridFixed.length; } return this.#data?.fixed?.length() ?? 0; } /** Positions between two instants, from whichever store owns them. See `#windowSamples`. */ #positionsBetween(start: JulianDate, end: JulianDate): Cartesian3[] { if (this.#gridUsable && this.#gridFixed.length > 0) { return this.#gridFixed.samplesBetween(start, end).positions; } const fixed = this.#data?.fixed; return fixed ? (fixed.getRawValues(start, end) as Cartesian3[]) : []; } /** * What an entity should bind its position to — the grid property where usable. * * Everything that only ever asks "where is it now" goes through here. Path * graphics deliberately do not; see `fixed`. */ get entityPosition(): GridPositionProperty | SampledPositionProperty | undefined { if (!this.#data) { return undefined; } return this.#gridUsable && this.#gridFixed.length > 0 ? this.#gridFixed : this.#data.fixed; } /** * Inertial-frame (ICRF) sampled position for orbit visualization. * * Call `requireInertial` first. Reading this without doing so returns undefined * on a trajectory that has never been asked for the inertial frame, rather than * quietly building one — the point of the flag is that the cost is opted into. */ get inertial(): SampledPositionProperty | undefined { return this.#data?.inertial; } /** * Declare that the inertial frame is needed, and make it so. * * Idempotent, and safe to call before or after `start`: the flag makes every * later refresh sample both frames, and if a window is already up its inertial * half is backfilled from the fixed samples already in it. That backfill is a * frame transform per sample and no SGP4 — the propagation has already been * paid for, and only the rotation into ICRF is missing. */ requireInertial(): void { if (this.#wantsInertial) { return; } this.#wantsInertial = true; this.#backfillInertial(); } #backfillInertial(): void { const data = this.#data; if (!data || data.inertial) { return; } const inertial = SampledTrajectory.#createProperty(ReferenceFrame.INERTIAL); // From the grid, which always holds the window; the sampled property may not // exist at all, and when it does it holds the same samples anyway. const { times, positions: values } = this.#windowSamples(); const positions: Cartesian3[] = []; const kept: JulianDate[] = []; for (const [index, time] of times.entries()) { const fixedToIcrf = Transforms.computeFixedToIcrfMatrix(time); if (!defined(fixedToIcrf)) { continue; } kept.push(time); positions.push(Matrix3.multiplyByVector(fixedToIcrf, values[index] as Cartesian3, new Cartesian3())); } if (kept.length > 0) { inertial.addSamples(kept, positions); } data.inertial = inertial; } /** The time interval currently covered by samples. */ get interval(): TimeInterval | undefined { return this.#data?.interval; } /** * Fixed-frame position at `time`, interpolated from the samples. * * Through `entityPosition`, because the callers are the same shape as an entity: * the sky HUD and the sensor cone's orientation both ask this once per frame. */ position(time: JulianDate): Cartesian3 | undefined { return this.entityPosition?.getValue(time); } positionsForNextOrbit(start: JulianDate, reference: "inertial" | "fixed" = "inertial", loop = true): unknown[] { if (!this.#data) return []; const end = JulianDate.addSeconds(start, this.#orbit.orbitalPeriod * 60, new JulianDate()); let positions: unknown[]; if (reference === "fixed") { // The grid holds the same samples and always exists, so asking for the // Earth-relative path does not drag a sampled property into being. positions = this.#positionsBetween(start, end); } else { // Asking for the inertial frame is the declaration itself. this.requireInertial(); const inertial = this.#data.inertial; if (!inertial) return []; positions = inertial.getRawValues(start, end); } if (positions.length === 0) return []; if (loop) { // Repeating the first sample is what closes the orbit, rather than leaving // a gap at the seam. return [...positions, positions[0]]; } return positions; } /** * The Earth-relative path one full orbit ahead of `start`, for the Orbit track. * * The raw stored samples rather than a resampling: they are already there, and * at 120 a revolution they draw a track no coarser than the position the * satellite is itself interpolated from. Only the head is computed, because * the first stored sample can sit up to a sampling interval (about 45 s, some * 350 km) ahead of the satellite, and a gold line that visibly starts in front * of the point it belongs to is the one artefact of batching that a viewer * would read as a bug rather than as a level of detail. */ positionsForTrack(start: JulianDate): Cartesian3[] { if (!this.#data) return []; const end = JulianDate.addSeconds(start, this.#orbit.orbitalPeriod * 60, new JulianDate()); const head = this.position(start); const samples = this.#positionsBetween(start, end); // A head with nothing behind it — a clock jump, or the moment after a gap // abandoned the grid — is one point, not a track, so the caller's below-two // check skips the component until the next re-cut, once the refill lands. // // Not `[head, head]` to get past `PolylineGeometry`'s below-two-positions // throw: the duplicate collapses to `undefined` geometry, which costs more // than the missing component. See `drawablePositions`. return drawablePositions(head ? [head, ...samples] : samples); } groundTrack(julianDate: JulianDate, samplesFwd = 1, samplesBwd = 0, interval = 300): (Cartesian3 | undefined)[] { const groundTrack: (Cartesian3 | undefined)[] = []; const startTime = -samplesBwd * interval; const stopTime = samplesFwd * interval; for (let time = startTime; time <= stopTime; time += interval) { const timestamp = JulianDate.addSeconds(julianDate, time, new JulianDate()); groundTrack.push(this.position(timestamp)); } return groundTrack; } /** * Take an opening window fetched before this trajectory existed. * * The build fetches it, because a satellite is only worth constructing once its * samples are in hand — and constructing it is `sgp4init`, which belongs inside * the build's frame budget rather than in a loop over the whole activation. * * The interval is the chunk's own extent rather than the policy window: the * first `ensure` computes that and tops up the difference. */ adopt(chunk: SampleChunk): void { const sampleCount = Math.floor(chunk.positionsFixed.length / 3); if (this.#data || sampleCount === 0) { return; } const start = SampledTrajectory.#sampleTime(chunk, 0); const stop = SampledTrajectory.#sampleTime(chunk, sampleCount - 1); this.#init(start); this.#applyChunk(chunk); this.#setInterval(new TimeInterval({ start, stop })); } /** * Make sure the window covers `time`, requesting whatever is missing. * * The single way samples ever enter this class. Awaitable because the samples * come from somewhere else now: the build awaits the first call so a satellite * is only shown once it has a position, and the periodic top-up does not await * at all — the window runs one and a half revolutions ahead of the clock, so a * reply arriving a few frames late is invisible. * * The requested bounds are deliberately approximate. They come from this * satellite's own period, which differs slightly from the one the sampler * derives, and it does not matter: the sampler answers on a grid anchored to the * element set's epoch, so a window a few seconds wider or narrower changes which * samples come back but never where they sit in time. * * At most one request is outstanding at a time. Without that, a tick arriving * before the previous reply computed the same missing range and asked for it * again: measured at 5,000 satellites and ×10000, the sampler was producing * 2.5 million samples a second where the window needs about 1.1 million. */ ensure(time: JulianDate): Promise { if (this.#filling) { // A tick arrived while a request was already out. Neither queue it — at a // fast clock the ticks outrun the replies and the queue only grows — nor // drop it, which would lose a clock that jumped mid-request. Remember the // latest time and re-run once, when the current fill lands. this.#pendingTime = time; return this.#filling; } this.#filling = this.#fill(time).finally(() => { this.#filling = undefined; const pending = this.#pendingTime; this.#pendingTime = undefined; if (pending) { void this.ensure(pending); } }); return this.#filling; } async #fill(time: JulianDate): Promise { const window = trajectoryWindow(this.#orbit.orbitalPeriod); if (window.sampleCount === 0) { return; } const request = new TimeInterval({ start: JulianDate.addSeconds(time, window.offsetSeconds, new JulianDate()), stop: JulianDate.addSeconds(time, window.offsetSeconds + window.spanSeconds, new JulianDate()), }); // Nothing yet, or the clock has jumped clear of what is held: one request for // the whole window rather than two for its edges. if (!this.#data || !TimeInterval.contains(this.#data.interval, time)) { const chunk = await this.#sampler.samples(JulianDate.toDate(request.start).getTime(), JulianDate.toDate(request.stop).getTime()); // Torn down while the request was in flight — the same check the two-chunk // path below makes. Without it `#init` rebuilt `#data` after `start`'s // teardown had cleared it, leaving a disposed trajectory reporting itself // valid, with a fresh grid buffer and nothing left to refresh it. if (!chunk || chunk.positionsFixed.length === 0 || this.#stopped) { return; } this.#init(request.start); this.#applyChunk(chunk); this.#setInterval(request); return; } const held = this.#data.interval; const missingBefore = JulianDate.secondsDifference(held.start, request.start) > 0; const missingAfter = JulianDate.secondsDifference(request.stop, held.stop) > 0; const chunks = await Promise.all([ missingBefore ? this.#sampler.samples(JulianDate.toDate(request.start).getTime(), JulianDate.toDate(held.start).getTime()) : undefined, missingAfter ? this.#sampler.samples(JulianDate.toDate(held.stop).getTime(), JulianDate.toDate(request.stop).getTime()) : undefined, ]); // Torn down while the request was in flight. if (!this.#data) { return; } for (const chunk of chunks) { if (chunk) this.#applyChunk(chunk); } this.#evict(request); this.#data.interval = request; } /** * File one chunk of fixed-frame samples. * * The chunk arrives already rotated — the sampler does that leg, because it needs * no Cesium (see sgp4Worker and temeToFixed) — so for a satellite with neither * sampled property this method is a typed-array copy into the grid and nothing * else: no allocation, no per-sample arithmetic, no Cesium call. That is the case * almost every satellite is in. * * The rest exists for the two properties built on demand. ICRF is still Cesium's, * because `computeFixedToIcrfMatrix` rests on IAU data that only the main thread * holds, so it is charged to the trajectories that draw an orbit. * * Refused samples are skipped, not re-propagated — retrying would run the same * propagator on the same instant and fail the same way, and a sampled position * property interpolates across the gap. */ #applyChunk(chunk: SampleChunk): void { const data = this.#data; if (!data) { return; } const arrived = chunk.positionsFixed; const sampleCount = Math.floor(arrived.length / 3); const refused = chunk.refusedIndices.length > 0 ? new Set(chunk.refusedIndices) : undefined; const inertialProperty = data.inertial; // Refusals take the slow path too, because the gap branch below hands this // chunk to a sampled property it has just created, which needs the pairs. if (inertialProperty === undefined && data.fixed === undefined && refused === undefined) { if (sampleCount > 0) { this.#addToGrid(chunk, arrived, false); } return; } const anchor = SampledTrajectory.#chunkAnchor(chunk); const sampledTimes: JulianDate[] = []; const sampledPositions: Cartesian3[] = []; const sampledInertial: Cartesian3[] = []; const keptPositions = new Float64Array(sampleCount * 3); let kept = 0; for (let index = 0; index < sampleCount; index += 1) { if (refused?.has(index)) { continue; } const offset = index * 3; const time = SampledTrajectory.#sampleTimeFrom(anchor, chunk, index); const position = new Cartesian3(arrived[offset] as number, arrived[offset + 1] as number, arrived[offset + 2] as number); if (inertialProperty) { const fixedToIcrf = Transforms.computeFixedToIcrfMatrix(time); if (!defined(fixedToIcrf)) { // Reported once per trajectory rather than once per sample: a window is // a couple of hundred of these and the cause is the same for all of them. if (data.valid) { console.error("Reference frame transformation data failed to load"); data.valid = false; } continue; } sampledInertial.push(Matrix3.multiplyByVector(fixedToIcrf, position, new Cartesian3())); } sampledTimes.push(time); sampledPositions.push(position); const at = kept * 3; keptPositions[at] = position.x; keptPositions[at + 1] = position.y; keptPositions[at + 2] = position.z; kept += 1; } if (kept === 0) { return; } // The grid first, and only then the sampled property: a gap here forces the // sampled property into being, and its backfill reads the grid as it was // before this chunk. Any shortfall counts as a gap, not just a refusal — a // missing ICRF transform drops a sample the same way, and the grid's indices // only line up with the chunk's when nothing was dropped. // // `keptPositions` is handed over whole rather than sliced to `kept`: a // shortfall is a gap, and the gap branch abandons the grid without reading the // buffer at all, so the only call that reads it is the one where the two are // the same length. this.#addToGrid(chunk, keptPositions, kept !== sampleCount); // Added at once: a sorted array avoids a search per sample. this.#data?.fixed?.addSamples(sampledTimes, sampledPositions); inertialProperty?.addSamples(sampledTimes, sampledInertial); } /** * Mirror a chunk into the grid property. * * A gap makes the grid unusable rather than approximated: the grid read assumes * no holes, and closing one by interpolating across a 45 s gap would put the * satellite kilometres out at that instant. Such a satellite reads from the * sampled property instead, which is slower and correct — and unusable is * permanent, because the samples that would have filled the hole are not coming. * The next refresh rebinds the entities (see updatedSampledPositionForComponents). */ #addToGrid(chunk: SampleChunk, fixedFlat: Float64Array, hadGaps: boolean): void { if (!this.#gridUsable) { return; } if (hadGaps) { // Order is the whole of it. The grid is still the authoritative record at // this instant and the backfill reads whichever store is authoritative, so // asking for the sampled property has to happen *before* the grid is // disowned — flipping the flag first made `#windowSamples` skip the grid, // find a `fixed` that did not exist yet, and hand back nothing, leaving the // new property holding only this chunk. Then let the buffer go: an abandoned // grid that keeps its samples is a window frozen where it was abandoned. this.requireSampled(); this.#gridUsable = false; this.#gridFixed.clear(); return; } if (!this.#gridFixed.isOnGrid(chunk.anchorEpochMs, chunk.stepSeconds)) { this.#gridFixed.reset(chunk.anchorEpochMs, chunk.stepSeconds); } if (!this.#gridFixed.add(chunk.firstIndex, fixedFlat)) { // Not contiguous with what is held — a clock jump landing between windows. // Start the grid again from this chunk rather than leave a hole in it. this.#gridFixed.reset(chunk.anchorEpochMs, chunk.stepSeconds); this.#gridFixed.add(chunk.firstIndex, fixedFlat); } } /** Its own method so the caller's narrowing of `#data` does not reach in here. */ #setInterval(interval: TimeInterval): void { if (this.#data) { this.#data.interval = interval; } } /** * The chunk's grid origin as a JulianDate. * * Hoisted out of the per-sample path deliberately. It is one value for the whole * chunk, and rebuilding it per sample meant a `Date` and a `JulianDate` allocated * for each of 241 samples per satellite — 1.2 million of each across a * 5,000-satellite build, which was the largest single slice of the window * handling. */ static #chunkAnchor(chunk: SampleChunk): JulianDate { return JulianDate.fromDate(new Date(chunk.anchorEpochMs)); } /** * The instant of one sample, from the grid rather than from the chunk's start. * * Every chunk for a satellite carries the same anchor, so the same grid index * always yields the same JulianDate — which is what stops two chunks from * placing one grid instant at two times a fraction of a millisecond apart. See * Sgp4Chunk.anchorEpochMs. */ static #sampleTimeFrom(anchor: JulianDate, chunk: SampleChunk, index: number): JulianDate { return JulianDate.addSeconds(anchor, (chunk.firstIndex + index) * chunk.stepSeconds, new JulianDate()); } /** The same instant, for the two callers that want one sample and not a run of them. */ static #sampleTime(chunk: SampleChunk, index: number): JulianDate { return SampledTrajectory.#sampleTimeFrom(SampledTrajectory.#chunkAnchor(chunk), chunk, index); } #evict(keep: TimeInterval): void { const data = this.#data; if (!data) { return; } const before = new TimeInterval({ start: JulianDate.fromIso8601("1957"), stop: keep.start, isStartIncluded: false, isStopIncluded: false }); const after = new TimeInterval({ start: keep.stop, stop: JulianDate.fromIso8601("2100"), isStartIncluded: false, isStopIncluded: false }); data.fixed?.removeSamples(before); data.fixed?.removeSamples(after); data.inertial?.removeSamples(before); data.inertial?.removeSamples(after); if (this.#gridFixed.length > 0) { this.#gridFixed.dropBefore(this.#gridFixed.indexAtOrAfter(keep.start)); this.#gridFixed.dropAfter(this.#gridFixed.indexAtOrAfter(keep.stop)); } } /** * Keep the window fresh, and hand back the teardown. * * The opening window is not filled here — the build awaits `ensure` before the * satellite is shown, so by the time this runs there is one. All this does is * arrange for the top-ups. */ start(viewer: Viewer, callback: () => void): () => void { callback(); const samplingRefreshRate = (this.#orbit.orbitalPeriod * 60) / 4; const removeCallback = CesiumCallbackHelper.createPeriodicTimeCallback(viewer, samplingRefreshRate, (time) => { void this.ensure(time).then(() => { // Torn down while the top-up was in flight. if (this.#data) callback(); }); }); return () => { removeCallback(); this.#stopped = true; this.#data = undefined; // So a fill still in flight does not schedule another one after teardown. this.#pendingTime = undefined; }; } /** Both frames want the same extrapolation and interpolation; only the frame differs. */ static #createProperty(referenceFrame?: ReferenceFrame): SampledPositionProperty { const property = new SampledPositionProperty(referenceFrame); property.backwardExtrapolationType = ExtrapolationType.HOLD; property.forwardExtrapolationType = ExtrapolationType.HOLD; property.setInterpolationOptions({ interpolationDegree: 5, interpolationAlgorithm: lagrangeInterpolation, }); return property; } #init(currentTime: JulianDate): void { this.#gridUsable = true; this.#data = { interval: new TimeInterval({ start: currentTime, stop: currentTime, isStartIncluded: false, isStopIncluded: false, }), // Both only if something has already asked. A re-init mid-life keeps // whatever the trajectory was already committed to sampling. fixed: this.#wantsSampled ? SampledTrajectory.#createProperty() : undefined, inertial: this.#wantsInertial ? SampledTrajectory.#createProperty(ReferenceFrame.INERTIAL) : undefined, valid: true, }; } }