Spaces:
Configuration error
Configuration error
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;
}
}
|