@saber-usa/node-common 1.7.17-alpha.1 → 1.7.18-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1029,4 +1029,4 @@ export {BallisticPropagator, BallisticPropagatorUtils, PosVelVec, EarthConstants
1029
1029
  * console.log('Position after 300s:', result.position);
1030
1030
  * console.log('Velocity after 300s:', result.velocity);
1031
1031
  */
1032
-
1032
+
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ export * from "./transform.js";
4
4
  export * from "./checkNetwork.cjs";
5
5
  export * from "./fixDate.js";
6
6
  export * from "./astro.js";
7
+ export * from "./wasmProp/index.js";
7
8
  export * from "./launchNominal.js";
8
9
  export * from "./LaunchNominalClass.js";
9
10
  export * from "./OrbitUtils.js";
@@ -22,6 +23,7 @@ import * as transformNS from "./transform.js";
22
23
  import * as checkNetworkNS from "./checkNetwork.cjs";
23
24
  import * as fixDateNS from "./fixDate.js";
24
25
  import * as astroNS from "./astro.js";
26
+ import * as wasmPropNS from "./wasmProp/index.js";
25
27
  import * as launchNominalNS from "./launchNominal.js";
26
28
  import * as LaunchNominalClassNS from "./LaunchNominalClass.js";
27
29
  import * as OrbitUtilsNS from "./OrbitUtils.js";
@@ -35,6 +37,7 @@ const aggregate = {
35
37
  ...checkNetworkNS,
36
38
  ...fixDateNS,
37
39
  ...astroNS,
40
+ ...wasmPropNS,
38
41
  ...launchNominalNS,
39
42
  ...LaunchNominalClassNS,
40
43
  ...OrbitUtilsNS,
@@ -0,0 +1,10 @@
1
+ // Barrel for all WASM-backed bulk propagation exports. Kept behind a
2
+ // single entry point so the rest of the codebase (and external consumers
3
+ // via `@saber-usa/node-common`) can discover the bulk API without having
4
+ // to reach into per-feature files.
5
+ //
6
+ // See `docs/BULK_PROPAGATION.md` for the design rationale, lifecycle, and
7
+ // the measured performance characteristics.
8
+ export * from "./runtime.js";
9
+ export * from "./primitives.js";
10
+ export * from "./wasmAstro.js";
@@ -0,0 +1,295 @@
1
+ import {
2
+ twoline2satrec,
3
+ EciBaseCalculator,
4
+ GmstCalculator,
5
+ EcfPositionCalculator,
6
+ GeodeticPositionCalculator,
7
+ LookAnglesCalculator,
8
+ } from "satellite.js";
9
+ import {getBulkRuntime, getOrCreateBulkPropagator} from "./runtime.js";
10
+ import {RAD2DEG} from "../constants.js";
11
+
12
+ // ----------------------------------------------------------------------------
13
+ // WASM-backed bulk primitives
14
+ //
15
+ // Sibling functions to `prop` / `propGeodetic` / the manual LookAngles chain
16
+ // that live in `../astro.js`. The originals stay synchronous; these are
17
+ // async because the per-thread `BulkPropagator` runtime is async-bootstrapped
18
+ // (see `docs/BULK_PROPAGATION.md`).
19
+ //
20
+ // Time-grid semantics are matched exactly to the JS-path siblings to keep
21
+ // parity tests trivial:
22
+ // - `propBulk` mirrors `prop` -> half-open [start, end)
23
+ // - `propGeodeticBulk` mirrors `propGeodetic` -> closed [start, end]
24
+ //
25
+ // Return shapes mirror the JS-path siblings element-wise so callers can swap
26
+ // transparently. `meanElements` is intentionally absent from `propBulk`
27
+ // records: the WASM `EciBaseCalculator` exposes only position/velocity/error,
28
+ // it does not surface SGP4 mean elements. Callers that need them must either
29
+ // stay on `prop` or recompute osculating elements from p/v.
30
+ // ----------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Builds the closed list of evaluation timestamps for a half-open or closed
34
+ * interval, matching the loop semantics of `prop` / `propGeodetic`.
35
+ *
36
+ * @param {number} start
37
+ * @param {number} end
38
+ * @param {number} stepMs
39
+ * @param {boolean} inclusive when true, end is included (propGeodetic).
40
+ * @return {number[]}
41
+ */
42
+ const buildTimeGrid = (start, end, stepMs, inclusive) => {
43
+ const grid = [];
44
+ if (inclusive) {
45
+ for (let t = start; t <= end; t = t + stepMs) {grid.push(t);}
46
+ } else {
47
+ for (let t = start; t < end; t = t + stepMs) {grid.push(t);}
48
+ }
49
+ return grid;
50
+ };
51
+
52
+ /**
53
+ * Bulk-converts an array of `{Line1, Line2}` elsets into satrecs, returning
54
+ * `null` if any single elset fails to convert. Mirrors the silent-skip
55
+ * behavior of `prop` (which returns `[]` on the first invalid pv).
56
+ *
57
+ * @param {Array<{Line1: string, Line2: string}>} elsets
58
+ * @return {Array|null}
59
+ */
60
+ const elsetsToSatrecs = (elsets) => {
61
+ const satrecs = elsets.map((e) => twoline2satrec(e.Line1, e.Line2));
62
+ return satrecs;
63
+ };
64
+
65
+ /**
66
+ * WASM-backed sibling of `prop`. Computes ECI position and velocity for
67
+ * `elsets.length` satellites at every step in the half-open interval
68
+ * `[start, end)` with step `stepMs`.
69
+ *
70
+ * Returns `Array<Array<{p, v, t}>>` indexed `[satIdx][dateIdx]`. Each inner
71
+ * array matches the shape returned by `prop` minus the `meanElements` field
72
+ * (see module-level note). If the WASM SGP4 produces a non-zero error code
73
+ * for any (sat, date) pair, that satellite's entire ephemeris is returned as
74
+ * `[]` — same all-or-nothing semantic as `prop` on `propTo` failure.
75
+ *
76
+ * @param {Array<{Line1: string, Line2: string}>} elsets
77
+ * @param {number} start UNIX milliseconds
78
+ * @param {number} end UNIX milliseconds (exclusive)
79
+ * @param {number} stepMs default 1000
80
+ * @return {Promise<Array<Array<{p: object, v: object, t: number}>>>}
81
+ */
82
+ const propBulk = async (elsets, start, end, stepMs = 1000) => {
83
+ const grid = buildTimeGrid(start, end, stepMs, false);
84
+ if (elsets.length === 0 || grid.length === 0) {
85
+ return elsets.map(() => []);
86
+ }
87
+ const satrecs = elsetsToSatrecs(elsets);
88
+ const dates = grid.map((t) => new Date(t));
89
+
90
+ const runtime = await getBulkRuntime();
91
+ const propagator = getOrCreateBulkPropagator({
92
+ runtime,
93
+ key: "eci",
94
+ makeCalculators: () => [new EciBaseCalculator()],
95
+ satRecsCount: satrecs.length,
96
+ datesCount: dates.length,
97
+ });
98
+ propagator.setSatRecs(satrecs);
99
+ propagator.setDates(dates);
100
+ propagator.run();
101
+
102
+ const {eci} = propagator.getRawOutput();
103
+ // raw layout: [sat0 date0 (x,y,z), sat0 date1 (x,y,z), ..., sat1 date0, ...]
104
+ // error layout: [sat0 date0, sat0 date1, ..., sat1 date0, ...]
105
+ const out = new Array(satrecs.length);
106
+ const m = dates.length;
107
+ for (let s = 0; s < satrecs.length; s++) {
108
+ const ephem = new Array(m);
109
+ let satFailed = false;
110
+ for (let d = 0; d < m; d++) {
111
+ const errIdx = s * m + d;
112
+ if (eci.error[errIdx] !== 0) {
113
+ satFailed = true;
114
+ break;
115
+ }
116
+ const vec = (s * m + d) * 3;
117
+ ephem[d] = {
118
+ p: {
119
+ x: eci.position[vec],
120
+ y: eci.position[vec + 1],
121
+ z: eci.position[vec + 2],
122
+ },
123
+ v: {
124
+ x: eci.velocity[vec],
125
+ y: eci.velocity[vec + 1],
126
+ z: eci.velocity[vec + 2],
127
+ },
128
+ t: grid[d],
129
+ };
130
+ }
131
+ out[s] = satFailed ? [] : ephem;
132
+ }
133
+ return out;
134
+ };
135
+
136
+ /**
137
+ * WASM-backed sibling of `propGeodetic`. Computes geodetic
138
+ * (lat, lon, t) for every satellite at every step in the closed interval
139
+ * `[start, end]`.
140
+ *
141
+ * Lat/Lon are returned in **degrees** to match `propGeodetic`. Returns
142
+ * `Array<Array<{lat, lon, t}>>` indexed `[satIdx][dateIdx]`. On any SGP4
143
+ * error for a satellite the whole ephemeris for that satellite is `[]`,
144
+ * matching `propGeodetic`'s short-circuit behavior.
145
+ *
146
+ * @param {Array<{Line1: string, Line2: string}>} elsets
147
+ * @param {number} start UNIX milliseconds
148
+ * @param {number} end UNIX milliseconds (inclusive)
149
+ * @param {number} stepMs default 60000
150
+ * @return {Promise<Array<Array<{lat: number, lon: number, t: number}>>>}
151
+ */
152
+ const propGeodeticBulk = async (elsets, start, end, stepMs = 60000) => {
153
+ const grid = buildTimeGrid(start, end, stepMs, true);
154
+ if (elsets.length === 0 || grid.length === 0) {
155
+ return elsets.map(() => []);
156
+ }
157
+ const satrecs = elsetsToSatrecs(elsets);
158
+ const dates = grid.map((t) => new Date(t));
159
+
160
+ const runtime = await getBulkRuntime();
161
+ const propagator = getOrCreateBulkPropagator({
162
+ runtime,
163
+ key: "eci+gmst+geo",
164
+ makeCalculators: () => [
165
+ new EciBaseCalculator(),
166
+ new GmstCalculator(),
167
+ new GeodeticPositionCalculator(),
168
+ ],
169
+ satRecsCount: satrecs.length,
170
+ datesCount: dates.length,
171
+ });
172
+ propagator.setSatRecs(satrecs);
173
+ propagator.setDates(dates);
174
+ propagator.run();
175
+
176
+ const {eci, geodeticPosition} = propagator.getRawOutput();
177
+ // KNOWN satellite.js@7.0.0 BUG / docs error: the
178
+ // `GeodeticPositionCalculator` raw layout is documented as
179
+ // `[lat, lon, height, ...]`, and `getFormattedOutput()` exposes fields
180
+ // `{latitude: raw[0], longitude: raw[1], height: raw[2]}` — but the
181
+ // physical values are SWAPPED. Empirically, against the canonical JS
182
+ // `eciToGeodetic` path:
183
+ // raw[3*k + 0] -> physical LONGITUDE (in radians)
184
+ // raw[3*k + 1] -> physical LATITUDE (in radians)
185
+ // raw[3*k + 2] -> height (km)
186
+ // We map raw -> {lat, lon} accordingly so `propGeodeticBulk` is exactly
187
+ // value-equivalent to `propGeodetic`. If a future satellite.js release
188
+ // fixes this upstream, swap the indices back here.
189
+ const out = new Array(satrecs.length);
190
+ const m = dates.length;
191
+ for (let s = 0; s < satrecs.length; s++) {
192
+ const positions = new Array(m);
193
+ let satFailed = false;
194
+ for (let d = 0; d < m; d++) {
195
+ const errIdx = s * m + d;
196
+ if (eci.error[errIdx] !== 0) {
197
+ satFailed = true;
198
+ break;
199
+ }
200
+ const vec = (s * m + d) * 3;
201
+ positions[d] = {
202
+ lat: geodeticPosition[vec + 1] * RAD2DEG,
203
+ lon: geodeticPosition[vec] * RAD2DEG,
204
+ t: grid[d],
205
+ };
206
+ }
207
+ out[s] = satFailed ? [] : positions;
208
+ }
209
+ return out;
210
+ };
211
+
212
+ /**
213
+ * WASM-backed bulk look-angles primitive. Computes (azimuth, elevation,
214
+ * rangeSat) for every satellite at every supplied date relative to a single
215
+ * observer (Geodetic location, **radians + km** as required by satellite.js
216
+ * v7 — `{latitude, longitude, height}`).
217
+ *
218
+ * Unlike `propBulk` and `propGeodeticBulk` this primitive takes an explicit
219
+ * `dates` array (rather than start/end/step) because look-angles consumers
220
+ * typically already hold the date list (e.g. observation timestamps in
221
+ * `GetResiduals`) and synthesizing a regular grid would re-impose JS-side
222
+ * step math the WASM pipeline is meant to avoid.
223
+ *
224
+ * Returns `Array<Array<{azimuth, elevation, rangeSat, t}>>` indexed
225
+ * `[satIdx][dateIdx]`, with angles in **radians** and `rangeSat` in km. On
226
+ * any SGP4 error for a satellite that satellite's entire output is `[]`.
227
+ *
228
+ * @param {Array<{Line1: string, Line2: string}>} elsets
229
+ * @param {Date[]|number[]} dates
230
+ * @param {{latitude: number, longitude: number, height: number}} observerGd
231
+ * Observer position in radians (lat/lon) and km (height).
232
+ * @return {Promise<Array<Array<{azimuth: number, elevation: number, rangeSat: number, t: number}>>>}
233
+ */
234
+ const propLookAnglesBulk = async (elsets, dates, observerGd) => {
235
+ if (elsets.length === 0 || dates.length === 0) {
236
+ return elsets.map(() => []);
237
+ }
238
+ const satrecs = elsetsToSatrecs(elsets);
239
+ const dateObjs = dates.map((d) => (d instanceof Date ? d : new Date(d)));
240
+ const ts = dateObjs.map((d) => d.getTime());
241
+
242
+ const runtime = await getBulkRuntime();
243
+ const propagator = getOrCreateBulkPropagator({
244
+ runtime,
245
+ key: "eci+gmst+ecf+lookAngles",
246
+ makeCalculators: () => [
247
+ new EciBaseCalculator(),
248
+ new GmstCalculator(),
249
+ new EcfPositionCalculator(),
250
+ new LookAnglesCalculator(),
251
+ ],
252
+ satRecsCount: satrecs.length,
253
+ datesCount: dateObjs.length,
254
+ });
255
+ propagator.setSatRecs(satrecs);
256
+ propagator.setDates(dateObjs);
257
+ propagator.run({lookAngles: {observer: observerGd}});
258
+
259
+ const {eci, lookAngles} = propagator.getRawOutput();
260
+ // lookAngles layout: [sat0 date0 (az, el, range), sat0 date1, ...]
261
+ const out = new Array(satrecs.length);
262
+ const m = dateObjs.length;
263
+ for (let s = 0; s < satrecs.length; s++) {
264
+ const samples = new Array(m);
265
+ let satFailed = false;
266
+ for (let d = 0; d < m; d++) {
267
+ const errIdx = s * m + d;
268
+ if (eci.error[errIdx] !== 0) {
269
+ satFailed = true;
270
+ break;
271
+ }
272
+ const vec = (s * m + d) * 3;
273
+ samples[d] = {
274
+ azimuth: lookAngles[vec],
275
+ elevation: lookAngles[vec + 1],
276
+ rangeSat: lookAngles[vec + 2],
277
+ t: ts[d],
278
+ };
279
+ }
280
+ out[s] = satFailed ? [] : samples;
281
+ }
282
+ return out;
283
+ };
284
+
285
+ export {
286
+ // `buildTimeGrid` is intentionally NOT exported: it is consumed only by
287
+ // `propBulk` / `propGeodeticBulk` inside this module. Keeping it private
288
+ // avoids polluting the public `@saber-usa/node-common` surface with a
289
+ // helper that exists purely to bridge the JS-path loop semantics to the
290
+ // WASM `setDates(Date[])` API.
291
+ elsetsToSatrecs, // used by `./wasmAstro.js#getGeoShadowZonesBulk`
292
+ propBulk,
293
+ propGeodeticBulk,
294
+ propLookAnglesBulk,
295
+ };
@@ -0,0 +1,147 @@
1
+ import {createSingleThreadRuntime, BulkPropagator} from "satellite.js";
2
+
3
+ // Module-local state. In Node, ES module instances are per Realm/per worker
4
+ // thread, so this state is naturally per-thread when consumed inside
5
+ // `worker_threads` (e.g. node-pub-sub's workerpool workers).
6
+ //
7
+ // See docs/BULK_PROPAGATION.md sections 6, 7, and 8 for the consumer model,
8
+ // lifecycle, and registry design.
9
+ let runtimePromise = null;
10
+ let registry = new Map();
11
+
12
+ /**
13
+ * Lazily creates (or returns) the per-thread WASM runtime.
14
+ *
15
+ * On first call within a thread, triggers `createSingleThreadRuntime()`
16
+ * (the v7 successor to the blog's `createWasmModule()`) and caches the
17
+ * resulting promise. All subsequent callers in that thread reuse it.
18
+ *
19
+ * Safe to await concurrently: every caller awaits the same in-flight
20
+ * promise, so only one WASM compile + instantiate ever happens.
21
+ *
22
+ * @return {Promise<import("satellite.js").SingleThreadRuntime>}
23
+ */
24
+ const getBulkRuntime = () => {
25
+ if (runtimePromise === null) {
26
+ runtimePromise = createSingleThreadRuntime();
27
+ }
28
+ return runtimePromise;
29
+ };
30
+
31
+ /**
32
+ * Eager bootstrap. Equivalent to `await getBulkRuntime()` but named for the
33
+ * explicit warm-up call site (e.g. top of `dedicated_worker.js`).
34
+ *
35
+ * @return {Promise<import("satellite.js").SingleThreadRuntime>}
36
+ */
37
+ const initBulkRuntime = () => getBulkRuntime();
38
+
39
+ /**
40
+ * Returns true if the runtime has been requested in this thread (regardless
41
+ * of whether it has finished initializing). Used by tests and by the
42
+ * disposal path to decide whether dispose work is necessary.
43
+ *
44
+ * @return {boolean}
45
+ */
46
+ const isBulkRuntimeInitialized = () => runtimePromise !== null;
47
+
48
+ /**
49
+ * Acquires (or lazily constructs) the per-thread, per-pipeline singleton
50
+ * `BulkPropagator` for the given pipeline key.
51
+ *
52
+ * Reuse rules:
53
+ * - The same instance is returned on every call with the same `key`.
54
+ * - If the caller's `satRecsCount` or `datesCount` exceed the current
55
+ * allocation, the propagator's own `setSatRecs` / `setDates` will perform
56
+ * a single `free` + `malloc` grow-realloc internally on the next set.
57
+ * The initial allocation here is sized to the first caller's request, so
58
+ * "warm" subsequent calls of the same shape pay zero allocation cost.
59
+ *
60
+ * Caller responsibilities:
61
+ * - `setSatRecs`, `setDates`, then `run()` between `acquire` and using
62
+ * results. Within a single thread these are synchronous so no other
63
+ * pipeline consumer can interleave (per docs/BULK_PROPAGATION.md §9).
64
+ * - **Do not call `dispose()` on the returned propagator.** Lifecycle is
65
+ * owned by the registry; `disposeBulkRuntime()` is the only sanctioned
66
+ * teardown path.
67
+ *
68
+ * @param {Object} params
69
+ * @param {import("satellite.js").SingleThreadRuntime} params.runtime
70
+ * @param {string} params.key Pipeline signature, e.g. `"eci"`.
71
+ * @param {() => readonly any[]} params.makeCalculators Factory invoked once
72
+ * per (key, thread) to build fresh calculator instances. A factory is
73
+ * required because calculator instances are stateful and bound to a
74
+ * propagator at construction time; they cannot be shared across keys.
75
+ * @param {number} params.satRecsCount Initial satellite-record allocation
76
+ * hint. Used only on first construction for this key in this thread.
77
+ * @param {number} params.datesCount Initial dates allocation hint. Same
78
+ * first-call-only semantics.
79
+ * @return {import("satellite.js").BulkPropagator<any, any>}
80
+ */
81
+ const getOrCreateBulkPropagator = ({
82
+ runtime,
83
+ key,
84
+ makeCalculators,
85
+ satRecsCount,
86
+ datesCount,
87
+ }) => {
88
+ const cached = registry.get(key);
89
+ if (cached) {
90
+ return cached;
91
+ }
92
+
93
+ const propagator = new BulkPropagator({
94
+ runtime,
95
+ calculators: makeCalculators(),
96
+ satRecsCount: Math.max(1, satRecsCount),
97
+ datesCount: Math.max(1, datesCount),
98
+ });
99
+ registry.set(key, propagator);
100
+ return propagator;
101
+ };
102
+
103
+ /**
104
+ * Releases all WASM memory owned by this thread: every registry-held
105
+ * `BulkPropagator` is disposed and the runtime itself is torn down.
106
+ *
107
+ * Idempotent: safe to call when nothing was ever initialized. After this
108
+ * returns, the next `getBulkRuntime()` will lazily re-initialize from
109
+ * scratch.
110
+ */
111
+ const disposeBulkRuntime = async () => {
112
+ for (const propagator of registry.values()) {
113
+ try {
114
+ propagator.dispose();
115
+ } catch (e) {
116
+ // Disposal must never throw out of teardown; we surface the
117
+ // failure to stderr but continue cleaning up the rest of the
118
+ // registry to minimize the leak surface.
119
+ console.error("disposeBulkRuntime: failed to dispose propagator", e);
120
+ }
121
+ }
122
+ registry = new Map();
123
+
124
+ if (runtimePromise !== null) {
125
+ const pending = runtimePromise;
126
+ runtimePromise = null;
127
+ try {
128
+ const runtime = await pending;
129
+ runtime.dispose();
130
+ } catch (e) {
131
+ // Emscripten's `_exit_runtime` (with EXIT_RUNTIME=1) signals clean
132
+ // shutdown by throwing an `ExitStatus` object with `status === 0`.
133
+ // That is the documented success path, not a failure to log.
134
+ if (e?.name !== "ExitStatus" || e?.status !== 0) {
135
+ console.error("disposeBulkRuntime: failed to dispose runtime", e);
136
+ }
137
+ }
138
+ }
139
+ };
140
+
141
+ export {
142
+ getBulkRuntime,
143
+ initBulkRuntime,
144
+ isBulkRuntimeInitialized,
145
+ getOrCreateBulkPropagator,
146
+ disposeBulkRuntime,
147
+ };