orbit-studio / src /modules /util /GridPositionProperty.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
17.4 kB
// A position property that interpolates over a uniform grid held in a flat
// typed array.
//
// It exists because evaluating a position is the single largest per-frame cost in
// a large scene, and almost none of that cost is the interpolation itself.
// Cesium's `SampledPositionProperty` supports irregular sample times, so every
// read does a bracket search, JulianDate arithmetic, a copy into interpolation
// scratch tables and a Cartesian3 allocation. Our samples are not irregular —
// they sit on a grid anchored to the element set's epoch (see sgp4Worker) — so
// finding the bracket is one `Math.floor` and the rest is arithmetic on a
// Float64Array.
//
// Measured at 5,000 satellites drawing points, swapping only `entity.position` on
// one live scene and putting it back again — an A/B/A, because a whole benchmark
// sweep could not resolve this: its own drift check fired at ±23-49% on `cpuMs`,
// and build times for identical work varied by 3x between runs.
//
// this Cesium this again
// dataSourceDisplay.update 6.10 12.02 5.03 ms
// fps 99.9 71.1 96.8
//
// Across separate matched sweeps the propagation-and-update half of the frame,
// `tickMs` at 5,000, went 11.08 -> 4.91 ms; that one is worth quoting because it
// reproduced (11.08/11.44/9.31 before, 4.91/4.63 after) far outside the noise the
// other columns carry.
//
// It also *saves* memory, which was not the point but is the larger effect: 25.7
// against 38.7 KB a satellite for everything a satellite owns, some 74 MB less at
// 5,000. A `SampledPositionProperty` keeps a `JulianDate` object per sample and
// there are 241 of them per satellite; deriving the times from the anchor instead
// means the samples are three doubles each and nothing else. See SampledTrajectory,
// which now builds the irregular-capable properties on demand from this one.
//
// Accuracy is checked against an independent SGP4 — python-sgp4, Vallado's
// reference implementation — given the same element sets and the same instants,
// comparing the two quantities a rotation about the Earth's axis leaves alone
// (radius and the axial component), so no frame code of the checker's own enters
// the comparison. Worst case over the regimes in the catalog, against what Cesium's
// own degree-5 interpolation produces from the same samples:
//
// Starlink 94 min 4.7 m (Cesium: 4.3 m)
// Meteosat 1436 min 0.1 m (Cesium: 0.0 m)
// MMS 1 5114 min 37.9 m (Cesium: 38.5 m)
//
// The stencil is six for that last row and no other reason; see STENCIL, which
// also says why re-checking this has to reach past the head of the catalog.
//
// Note what this is *not* an optimisation of. Lowering Cesium's
// `interpolationDegree` from 5 to 1 saves 0.88 ms of 12 (7%) and costs 2.5 km of
// accuracy; degree 3 costs 1 m and saves 0.4 ms. The degree was never the
// problem, which is why this replaces the machinery rather than tuning it.
import { Cartesian3, Event, JulianDate, PositionProperty, ReferenceFrame } from "@cesium/engine";
/**
* `PositionProperty.convertToReferenceFrame` is a real static on the runtime class
* but is missing from the published typings, so it is reached through a cast in
* one place rather than with an `any` at the call site.
*/
const convertToReferenceFrame = (
PositionProperty as unknown as {
convertToReferenceFrame(time: JulianDate, value: Cartesian3, inputFrame: ReferenceFrame, outputFrame: ReferenceFrame, result: Cartesian3): Cartesian3 | undefined;
}
).convertToReferenceFrame;
/**
* Samples the interpolation reads. Six gives a quintic, matching the degree Cesium's
* `SampledPositionProperty` was configured with.
*
* Four — a cubic — was enough for every orbit anyone had looked at, and wrong for
* the ones nobody had. Truncation error goes as `h^(n+1)`, so dropping from degree 5
* to 3 costs a factor of `h^2`, and `h` here is a fixed fraction of the *period*:
* 120 samples an orbit is 45 s for the ISS and 43 minutes for a magnetospheric
* orbit. Checked against the reference SGP4 on MMS 1 (period 3.5 days, highly
* eccentric), a cubic was 3.7 km out where the quintic is 13 m — the error is almost
* entirely radial, which is what a low-order fit through a fast-changing radius
* looks like. LEO was unaffected either way, which is why sampling only Starlink
* missed it.
*/
const STENCIL = 6;
/** Spare capacity a grow leaves behind. See `#ensure`. */
const GROWTH_HEADROOM = 1.25;
/**
* Positions on a uniform time grid, readable as a Cesium position property.
*
* The grid is defined by an anchor and a step, and a sample's index is its
* position on that grid — so two batches of samples computed at different times
* describe the same instants as long as they agree on the anchor, which is what
* makes appending safe without any matching of times.
*/
export class GridPositionProperty {
readonly #frame: ReferenceFrame;
/**
* Underscore-prefixed, and public, because Cesium reads it that way.
*
* `VelocityVectorProperty`'s position setter subscribes via
* `value._definitionChanged.addEventListener` rather than through the
* `definitionChanged` getter its own interface documents, so a property that
* only has the getter throws the moment an entity is given a
* `VelocityOrientationProperty` over it. Every Cesium property happens to keep
* the event in a field of this name, which is why nothing upstream notices.
*/
readonly _definitionChanged = new Event();
#anchorEpochMs = 0;
#stepSeconds = 0;
/** Grid index of the first sample held. */
#firstIndex = 0;
#count = 0;
/** x, y, z per sample, in `#frame`. Grown as needed, never shrunk. */
#positions = new Float64Array(0);
/** The anchor as a JulianDate, made once: every read would otherwise rebuild it. */
#anchor = new JulianDate();
constructor(referenceFrame: ReferenceFrame = ReferenceFrame.FIXED) {
this.#frame = referenceFrame;
}
get referenceFrame(): ReferenceFrame {
return this.#frame;
}
get isConstant(): boolean {
return this.#count === 0;
}
get definitionChanged(): Event {
return this._definitionChanged;
}
get length(): number {
return this.#count;
}
get stepSeconds(): number {
return this.#stepSeconds;
}
/** Grid index of the first and last samples held, for the caller's own bookkeeping. */
get firstIndex(): number {
return this.#firstIndex;
}
/** The instant of a grid index. Derived from the anchor, never from a stored time. */
timeAt(gridIndex: number, result?: JulianDate): JulianDate {
return JulianDate.addSeconds(this.#anchor, gridIndex * this.#stepSeconds, result ?? new JulianDate());
}
/**
* Adopt a grid. Discards anything held, because a different anchor or step means
* the indices no longer mean the same instants.
*/
reset(anchorEpochMs: number, stepSeconds: number): void {
this.#anchorEpochMs = anchorEpochMs;
this.#stepSeconds = stepSeconds;
this.#anchor = JulianDate.fromDate(new Date(anchorEpochMs));
this.#firstIndex = 0;
this.#count = 0;
this._definitionChanged.raiseEvent(this);
}
/** Drop everything and release the buffer, for a grid that is being abandoned. */
clear(): void {
this.#firstIndex = 0;
this.#count = 0;
this.#stepSeconds = 0;
this.#positions = new Float64Array(0);
this._definitionChanged.raiseEvent(this);
}
isOnGrid(anchorEpochMs: number, stepSeconds: number): boolean {
return this.#count > 0 && this.#anchorEpochMs === anchorEpochMs && this.#stepSeconds === stepSeconds;
}
/**
* Add samples starting at `gridIndex`.
*
* Contiguity is the one requirement, and it is checked rather than assumed: a
* uniform grid is the whole basis of the fast read, so a batch that would leave
* a hole is rejected instead of silently shifting every sample after it. The
* caller — which knows what it asked for — treats that as a failed fill.
*/
add(gridIndex: number, xyz: Float64Array): boolean {
const incoming = Math.floor(xyz.length / 3);
if (incoming === 0) {
return true;
}
if (this.#count === 0) {
this.#firstIndex = gridIndex;
this.#ensure(incoming);
this.#positions.set(xyz.subarray(0, incoming * 3), 0);
this.#count = incoming;
this._definitionChanged.raiseEvent(this);
return true;
}
const end = this.#firstIndex + this.#count;
if (gridIndex > end || gridIndex + incoming < this.#firstIndex) {
// Disjoint from what is held; nothing sensible to splice.
return false;
}
if (gridIndex >= this.#firstIndex && gridIndex + incoming <= end) {
// Wholly inside: an overlap, which is legitimate — the caller may re-ask for
// an interval it already has. Overwrite in place.
this.#positions.set(xyz.subarray(0, incoming * 3), (gridIndex - this.#firstIndex) * 3);
return true;
}
if (gridIndex < this.#firstIndex) {
// Extends the front. Shift what is held up rather than reallocate, then write
// the batch whole — where it overlaps, it overwrites with the same values,
// which is cheaper than working out where the overlap starts.
const prepend = this.#firstIndex - gridIndex;
const total = Math.max(end, gridIndex + incoming) - gridIndex;
this.#ensure(total);
this.#positions.copyWithin(prepend * 3, 0, this.#count * 3);
this.#positions.set(xyz.subarray(0, incoming * 3), 0);
this.#firstIndex = gridIndex;
this.#count = total;
this._definitionChanged.raiseEvent(this);
return true;
}
const total = gridIndex - this.#firstIndex + incoming;
this.#ensure(total);
this.#positions.set(xyz.subarray(0, incoming * 3), (gridIndex - this.#firstIndex) * 3);
this.#count = total;
this._definitionChanged.raiseEvent(this);
return true;
}
/** How the window slides. */
dropBefore(gridIndex: number): void {
const drop = gridIndex - this.#firstIndex;
if (drop <= 0 || this.#count === 0) {
return;
}
if (drop >= this.#count) {
this.#firstIndex = gridIndex;
this.#count = 0;
return;
}
this.#positions.copyWithin(0, drop * 3, this.#count * 3);
this.#firstIndex = gridIndex;
this.#count -= drop;
}
dropAfter(gridIndex: number): void {
const keep = gridIndex - this.#firstIndex + 1;
if (keep < 0) {
this.#count = 0;
return;
}
if (keep < this.#count) {
this.#count = keep;
}
}
#ensure(samples: number): void {
if (this.#positions.length >= samples * 3) {
return;
}
// Headroom, so a window that slides for hours stops reallocating after the
// first top-up: a refresh appends before the eviction that makes room for it,
// so the peak is a window plus one refresh's worth and the buffer settles
// there. Not doubling — that would settle at twice a window and the whole
// point of the flat array is that it is small.
const grown = new Float64Array(Math.max(Math.ceil(samples * GROWTH_HEADROOM), STENCIL) * 3);
grown.set(this.#positions.subarray(0, this.#count * 3));
this.#positions = grown;
}
/**
* A six-point quintic through the samples bracketing `time`.
*
* Outside the window it holds the end sample rather than extrapolating, matching
* the HOLD the sampled property is configured with. That matters for one case:
* the clock scrubbed clear of the window, where for the frame or two before the
* refill lands a free-running polynomial half an orbit past its last node does not
* return a slightly stale position but a meaningless one.
*/
#interpolate(time: JulianDate, result: Cartesian3): Cartesian3 | undefined {
if (this.#count === 0 || this.#stepSeconds <= 0) {
return undefined;
}
if (this.#count < STENCIL) {
// Too few to fit the stencil through: hold the nearest.
const nearest = Math.min(this.#count - 1, Math.max(0, Math.round(JulianDate.secondsDifference(time, this.#anchor) / this.#stepSeconds) - this.#firstIndex));
const at = nearest * 3;
return Cartesian3.fromElements(this.#positions[at] as number, this.#positions[at + 1] as number, this.#positions[at + 2] as number, result);
}
const gridPosition = JulianDate.secondsDifference(time, this.#anchor) / this.#stepSeconds - this.#firstIndex;
// Centred: two nodes behind the interval for a stencil of six, so the requested
// instant sits between nodes 2 and 3 wherever the window allows it.
let base = Math.floor(gridPosition) - 2;
if (base < 0) {
base = 0;
}
if (base > this.#count - STENCIL) {
base = this.#count - STENCIL;
}
// Clamped to the stencil's own span, which is what makes the ends HOLD: at
// u = 0 the basis is (1,0,0,0) and at u = 3 it is (0,0,0,1), so reading outside
// the window returns the edge sample exactly.
let u = gridPosition - base;
if (u < 0) {
u = 0;
}
if (u > STENCIL - 1) {
u = STENCIL - 1;
}
// Lagrange basis for nodes 0..5 at u, expanded rather than looped. The
// denominators are the products of node separations and so are constants.
const d0 = u;
const d1 = u - 1;
const d2 = u - 2;
const d3 = u - 3;
const d4 = u - 4;
const d5 = u - 5;
const b0 = -(d1 * d2 * d3 * d4 * d5) / 120;
const b1 = (d0 * d2 * d3 * d4 * d5) / 24;
const b2 = -(d0 * d1 * d3 * d4 * d5) / 12;
const b3 = (d0 * d1 * d2 * d4 * d5) / 12;
const b4 = -(d0 * d1 * d2 * d3 * d5) / 24;
const b5 = (d0 * d1 * d2 * d3 * d4) / 120;
const p = base * 3;
const a = this.#positions;
result.x = (a[p] as number) * b0 + (a[p + 3] as number) * b1 + (a[p + 6] as number) * b2 + (a[p + 9] as number) * b3 + (a[p + 12] as number) * b4 + (a[p + 15] as number) * b5;
result.y =
(a[p + 1] as number) * b0 + (a[p + 4] as number) * b1 + (a[p + 7] as number) * b2 + (a[p + 10] as number) * b3 + (a[p + 13] as number) * b4 + (a[p + 16] as number) * b5;
result.z =
(a[p + 2] as number) * b0 + (a[p + 5] as number) * b1 + (a[p + 8] as number) * b2 + (a[p + 11] as number) * b3 + (a[p + 14] as number) * b4 + (a[p + 17] as number) * b5;
return result;
}
getValue(time: JulianDate, result?: Cartesian3): Cartesian3 | undefined {
return this.getValueInReferenceFrame(time, ReferenceFrame.FIXED, result);
}
getValueInReferenceFrame(time: JulianDate, referenceFrame: ReferenceFrame, result?: Cartesian3): Cartesian3 | undefined {
const target = result ?? new Cartesian3();
if (!this.#interpolate(time, target)) {
return undefined;
}
if (referenceFrame === this.#frame) {
return target;
}
return convertToReferenceFrame(time, target, this.#frame, referenceFrame, target);
}
/** For the callers that draw a line through the samples rather than reading one. */
rawPositions(fromGridIndex = this.#firstIndex, toGridIndex = this.#firstIndex + this.#count - 1): Cartesian3[] {
const from = Math.max(fromGridIndex, this.#firstIndex);
const to = Math.min(toGridIndex, this.#firstIndex + this.#count - 1);
const out: Cartesian3[] = [];
for (let index = from; index <= to; index += 1) {
const at = (index - this.#firstIndex) * 3;
out.push(new Cartesian3(this.#positions[at] as number, this.#positions[at + 1] as number, this.#positions[at + 2] as number));
}
return out;
}
/** The grid index at or after `time`, for turning an instant into a range. */
indexAtOrAfter(time: JulianDate): number {
return Math.ceil(JulianDate.secondsDifference(time, this.#anchor) / this.#stepSeconds);
}
/**
* Every sample held between two instants, with the times they sit at.
*
* The times are derived from the anchor rather than stored, which is the point:
* a caller that needs `(time, position)` pairs — drawing a track, transforming a
* window into another frame — can have them without this keeping a JulianDate
* per sample, and a JulianDate per sample is most of what a
* `SampledPositionProperty` costs.
*/
samplesBetween(from: JulianDate, to: JulianDate): { times: JulianDate[]; positions: Cartesian3[] } {
const times: JulianDate[] = [];
const positions: Cartesian3[] = [];
if (this.#count === 0 || this.#stepSeconds <= 0) {
return { times, positions };
}
const first = Math.max(this.indexAtOrAfter(from), this.#firstIndex);
const last = Math.min(Math.floor(JulianDate.secondsDifference(to, this.#anchor) / this.#stepSeconds), this.#firstIndex + this.#count - 1);
for (let index = first; index <= last; index += 1) {
const at = (index - this.#firstIndex) * 3;
times.push(this.timeAt(index));
positions.push(new Cartesian3(this.#positions[at] as number, this.#positions[at + 1] as number, this.#positions[at + 2] as number));
}
return { times, positions };
}
allSamples(): { times: JulianDate[]; positions: Cartesian3[] } {
return this.samplesBetween(this.timeAt(this.#firstIndex), this.timeAt(this.#firstIndex + Math.max(0, this.#count - 1)));
}
equals(other?: GridPositionProperty): boolean {
return this === other;
}
}