File size: 17,361 Bytes
9f21d0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
// 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;
  }
}