@saber-usa/node-common 1.6.207

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.
package/src/astro.js ADDED
@@ -0,0 +1,3169 @@
1
+ const {cross, dot, norm, multiply, subtract, inv, polynomialRoot, add} = require("mathjs");
2
+ const {dateToMySqlDate, dtStrtoJsDt, parseDate} = require("./fixDate.js");
3
+ const solar = require("solar-calculator");
4
+ const {
5
+ twoline2satrec,
6
+ propagate,
7
+ sgp4,
8
+ gstime,
9
+ eciToGeodetic,
10
+ degreesToRadians,
11
+ radiansToDegrees,
12
+ eciToEcf,
13
+ ecfToLookAngles,
14
+ degreesLong,
15
+ degreesLat,
16
+ } = require("satellite.js");
17
+ const {lowerCaseObjectKeys} = require("./transform.js");
18
+ const squid = require("pious-squid");
19
+ const {
20
+ normalize,
21
+ transpose,
22
+ multiplyVector,
23
+ dist,
24
+ julianToGregorian,
25
+ epochToDate,
26
+ isValidDataMode,
27
+ isBoolean,
28
+ isNonEmptyString,
29
+ removeNullUndefined,
30
+ getTimeDifference,
31
+ isDefined,
32
+ getAngle,
33
+ wrapToRange,
34
+ getAngleDiffSigned,
35
+ posToArray,
36
+ } = require("./utils.js");
37
+ const {
38
+ DEG2RAD,
39
+ RAD2DEG,
40
+ SEC2RAD,
41
+ ARCSEC2RAD,
42
+ AU_KM,
43
+ SUN_RADIUS_KM,
44
+ REGIMES,
45
+ GRAV_CONST,
46
+ EARTH_MASS,
47
+ WGS72_EARTH_EQUATORIAL_RADIUS_KM,
48
+ WGS84_EARTH_EQUATORIAL_RADIUS_KM,
49
+ MILLIS_PER_DAY,
50
+ GEO_ALTITUDE_KM,
51
+ } = require("./constants.js");
52
+
53
+ // Solar Terminator
54
+ // Returns sun latitude and longitude as -180/180
55
+ const sunPosAt = (dt) => {
56
+ const day = new Date(+dt).setUTCHours(0, 0, 0, 0);
57
+ const t = solar.century(dt);
58
+ const longitude = ((day - dt) / 864e5) * 360 - 180;
59
+ return [
60
+ solar.declination(t),
61
+ (longitude - solar.equationOfTime(t) / 4) % 360,
62
+ ];
63
+ };
64
+
65
+ const checkTle = (line1, line2) => {
66
+ try {
67
+ // Initialize a satellite record
68
+ const satrec = twoline2satrec(line1, line2);
69
+ satrec.epoch = dateToMySqlDate(julianToGregorian(satrec.jdsatepoch)); // Add the epoch
70
+ const pv = sgp4(satrec, 0);
71
+ if (Number.isNaN(pv.position.x) || Number.isNaN(pv.position.y) || Number.isNaN(pv.position.z)) {
72
+ return false;
73
+ } else {
74
+ return satrec;
75
+ }
76
+ } catch (e) {
77
+ return false; // TLE invalid
78
+ }
79
+ };
80
+
81
+ /**
82
+ * Calculates the semi-major axis in kilometers from a satellite record.
83
+ * @param {Object} satrec The satellite record from satellite.js
84
+ * @return {number} Semi-major axis in kilometers
85
+ */
86
+ const getSemiMajorAxis = (satrec) => {
87
+ // satrec.a is in non-dimensionalized units (Earth radii)
88
+ // Convert to kilometers using WGS72 Earth equatorial radius (SGP4 standard)
89
+ return satrec.a * WGS72_EARTH_EQUATORIAL_RADIUS_KM;
90
+ };
91
+
92
+ const getRaanPrecession = (line1, line2) => {
93
+ try {
94
+ // Initialize a satellite record
95
+ const satrec = twoline2satrec(line1, line2);
96
+ // satrec nodedot is in radians per minute
97
+ // 1400 minutes / day (60 min/hr * 24 hr/day)
98
+ // result is in degrees per day
99
+ return satrec.nodedot * 1440 * RAD2DEG;
100
+ } catch (e) {
101
+ return 0;
102
+ }
103
+ };
104
+
105
+ const getLonAndDrift = (line1, line2, datetime) => {
106
+ try {
107
+ // Initialize a satellite record
108
+ const satrec = twoline2satrec(line1, line2);
109
+
110
+ let pv;
111
+ let pv2;
112
+
113
+ if (datetime) {
114
+ pv = propagate(satrec, datetime);
115
+ pv2 = propagate(
116
+ satrec,
117
+ new Date(datetime.getTime() + 1440 * 60000),
118
+ );
119
+ } else {
120
+ // Propagate satellite using time since epoch (in minutes).
121
+ pv = sgp4(satrec, 0);
122
+ pv2 = sgp4(satrec, 1440); // One day later
123
+ }
124
+
125
+ // You will need GMST for some of the coordinate transforms.
126
+ // http://en.wikipedia.org/wiki/Sidereal_time#Definition
127
+ const gmst = gstime(datetime ?? satrec.jdsatepoch); // GMST at epoch
128
+ const gmst2 = gstime(
129
+ datetime
130
+ ? new Date(datetime.getTime() + 1440 * 60000)
131
+ : satrec.jdsatepoch + 1,
132
+ ); // GMST One day later
133
+
134
+ // You can get ECF, Geodetic, Look Angles, and Doppler Factor.
135
+ const positionGd = eciToGeodetic(pv.position, gmst);
136
+ const positionGd2 = eciToGeodetic(pv2.position, gmst2);
137
+
138
+ // Geodetic coords are accessed via `longitude`, `latitude`, `height`.
139
+ const lon = degreesLong(positionGd.longitude);
140
+ const lon2 = degreesLong(positionGd2.longitude);
141
+ const lat = degreesLat(positionGd.latitude);
142
+
143
+ let diff = lon2 - lon;
144
+ if (diff < -180) {
145
+ diff = (diff % 180) + 180;
146
+ } else if (diff > 180) {
147
+ diff = (diff % 180) - 180;
148
+ }
149
+
150
+ return {
151
+ latitude: lat,
152
+ longitude: lon,
153
+ longitude360: (lon+ 360) % 360,
154
+ lonDriftDegreesPerDay: diff,
155
+ };
156
+ } catch (e) {
157
+ return {
158
+ latitude: 0,
159
+ longitude: 0,
160
+ longitude360: 0,
161
+ lonDriftDegreesPerDay: 0,
162
+ };
163
+ }
164
+ };
165
+
166
+ /**
167
+ * Given eccentricity, inclination, meanmotion, and period
168
+ * Calculates & returns type of orbit to include LEO, MEO, HEO, GEO Drifter, GEO Inclined,GEO Stationary, and undetermined if applicable
169
+ * @param {Object} orbit Object with keys eccentricity, inclination (deg), meanmotion (rev/day) and period (min)
170
+ * @return {number} the regime index
171
+ */
172
+ const calcRegime = (orbit) => {
173
+ if (!isDefined(orbit)) {
174
+ return REGIMES.Undetermined;
175
+ }
176
+
177
+ const {eccentricity, inclination, meanmotion, period}
178
+ = lowerCaseObjectKeys(orbit);
179
+
180
+ if (!isDefined(meanmotion) && (!isDefined(period) || !isDefined(eccentricity))) {
181
+ return REGIMES.Undetermined;
182
+ }
183
+
184
+ if (meanmotion >= 0.99 && meanmotion <= 1.01 && inclination <= 0.01) {
185
+ return REGIMES.GeoStationary;
186
+ }
187
+
188
+ if (meanmotion >= 0.99 && meanmotion <= 1.01 && inclination > 0.01) {
189
+ return REGIMES.GeoInclined;
190
+ }
191
+
192
+ if (meanmotion >= 0.94 && meanmotion <= 1.06) {
193
+ return REGIMES.GeoDrifter;
194
+ }
195
+
196
+ if (period >= 600 && period <= 800 && eccentricity < 0.25) {
197
+ return REGIMES.Meo;
198
+ }
199
+
200
+ if (meanmotion > 11.25 && eccentricity < 0.25) {
201
+ return REGIMES.Leo;
202
+ }
203
+
204
+ return eccentricity > 0.25 ? REGIMES.Heo : REGIMES.Undetermined;
205
+ };
206
+
207
+ /**
208
+ * Given altitude, calculates & returns type of orbit to include LEO, MEO, HEO, GEO Drifter, GEO Inclined,GEO Stationary, and undetermined if applicable
209
+ * @param {Number} alt Altitude in km
210
+ * @param {Number} buffer altitude buffer in km, default 0 km
211
+ * @return {Array} Array of the regime indices
212
+ */
213
+ const altToRegime = (alt, buffer = 0) => {
214
+ const regimes = [];
215
+ if (alt >= 35786 - buffer) {
216
+ regimes.push([
217
+ REGIMES.GeoStationary,
218
+ REGIMES.GeoInclined,
219
+ REGIMES.GeoDrifter,
220
+ ]);
221
+ }
222
+ if (alt < 35786 + buffer && alt >= 2000 - buffer) {
223
+ regimes.push([REGIMES.Meo]);
224
+ }
225
+ if (alt < 2000 + buffer && alt >= 100 - buffer) {
226
+ regimes.push([REGIMES.Leo, REGIMES.Sso, REGIMES.Polar]);
227
+ }
228
+ if (alt < 100) {
229
+ return [REGIMES.Undetermined];
230
+ }
231
+ return regimes.flat();
232
+ };
233
+
234
+ /**
235
+ * Converts cartesian coordinates to polar coordinates
236
+ *
237
+ * @param {Object} r 3D point
238
+ * @param {Object} v 3D vector
239
+ *
240
+ * @return {Object} Corresponding 2D vector (polar coordinates)
241
+ */
242
+ const cartesianToRIC = (r, v) => {
243
+ const rArray = [r.x, r.y, r.z];
244
+ const vArray = [v.x, v.y, v.z];
245
+ const rCrossV = cross(rArray, vArray);
246
+ let R = normalize(r);
247
+ R = [R.x, R.y, R.z];
248
+ let W = normalize({x: rCrossV[0], y: rCrossV[1], z: rCrossV[2]});
249
+ W = [W.x, W.y, W.z];
250
+ const S = cross(W, R);
251
+ const convMatrix = transpose([R, S, W]);
252
+ return inv(convMatrix);
253
+ };
254
+
255
+ /**
256
+ * @param {Object} coord1 1st (3D) coordinate
257
+ * @param {Object} coord2 2nd (3D) coordinate
258
+ *
259
+ * @return {boolean} Whether coordinates are equal
260
+ */
261
+ const areCoordsEqual = (coord1, coord2) => {
262
+ return coord1.x === coord2.x && coord1.y === coord2.y && coord1.z === coord2.z;
263
+ };
264
+
265
+ /**
266
+ * Calculate angle between three points in 3D space.
267
+ * Note: assumes we want one vector to run from coord1 -> coord2, and the other
268
+ * from coord3 -> coord2.
269
+ *
270
+ * @param {Object} coord1 1st (3D) coordinate
271
+ * @param {Object} coord2 2nd (3D) coordinate
272
+ * @param {Object} coord3 3rd (3D) coordinate
273
+ *
274
+ * @return {number} Angle between the 3 points
275
+ */
276
+ const angleBetween3DCoords = (coord1, coord2, coord3) => {
277
+ // Calculate vector between points 1 and 2
278
+ const v1 = {
279
+ x: coord1.x - coord2.x,
280
+ y: coord1.y - coord2.y,
281
+ z: coord1.z - coord2.z,
282
+ };
283
+
284
+ // Calculate vector between points 2 and 3
285
+ const v2 = {
286
+ x: coord3.x - coord2.x,
287
+ y: coord3.y - coord2.y,
288
+ z: coord3.z - coord2.z,
289
+ };
290
+
291
+ // The dot product of vectors v1 & v2 is a function of the cosine of the
292
+ // angle between them (it's scaled by the product of their magnitudes).
293
+
294
+ // Normalize v1
295
+ const v1mag = Math.sqrt(v1.x * v1.x + v1.y * v1.y + v1.z * v1.z);
296
+ const v1norm = {
297
+ x: v1.x / v1mag,
298
+ y: v1.y / v1mag,
299
+ z: v1.z / v1mag,
300
+ };
301
+
302
+ // Normalize v2
303
+ const v2mag = Math.sqrt(v2.x * v2.x + v2.y * v2.y + v2.z * v2.z);
304
+ const v2norm = {
305
+ x: v2.x / v2mag,
306
+ y: v2.y / v2mag,
307
+ z: v2.z / v2mag,
308
+ };
309
+
310
+ // Calculate the dot products of vectors v1 and v2
311
+ const dotProducts
312
+ = v1norm.x * v2norm.x + v1norm.y * v2norm.y + v1norm.z * v2norm.z;
313
+
314
+ // Extract the angle from the dot products
315
+ const angle = (Math.acos(dotProducts) * 180.0) / Math.PI;
316
+
317
+ // Round result to 3 decimal points and return
318
+ return Math.round(angle * 1000) / 1000;
319
+ };
320
+
321
+ /** Calculates the Minimum Delta-V required for sat1 to plane match to sat2,
322
+ * with a single impulsive maneuver at one of the two mutual modal points of
323
+ * sat1.
324
+ *
325
+ * This maneuver, when performed at one of these points, and when its components
326
+ * are properly computed, will only modify the inclination and RAAN of sat1 to
327
+ * match those of sat2 while the position vector and velocity magnitude
328
+ * (but not direction) after the impulsive burn will be the same as
329
+ * pre-maneuver!
330
+ *
331
+ * The intersection of two arbitrary Keplerian orbits is a single line
332
+ * defined by the cross product of the orbits' angular momenta.
333
+ *
334
+ * The Ascending Mutual node of sat1 is the true anomaly of sat1 at which the
335
+ * mutual nodal vector is positive. The Descending Mutual node of sat1 is the
336
+ * true anomaly of the ascending mutual node plus 180 degrees, modulo 360 (to
337
+ * stay within 0 to 360 bounds).
338
+ *
339
+ * This function evaluates the Dv required at the Ascending Mutual Node and
340
+ * Descending Mutual Node, and chooses the one with the smallest Dv.
341
+ *
342
+ * The direction and components of this minimum Dv depend on two factors:
343
+ * (a) The relative inclination of sat1 and sat2 with respect to the equatorial
344
+ * plane.
345
+ * (b) Whether the burn is performed at the ascending or descending mutual node.
346
+ *
347
+ * The Dv magnitude and direction also depends on the flight path angle at each
348
+ * of those mutual node points, which is generally not the same.
349
+ *
350
+ * Therefore, the process steps are:
351
+ * 1. Compute the angular momentum of each orbit from the pos vel vectors.
352
+ * 2. Compute the mutual line of nodes vector from the orbits' angular momenta.
353
+ * 3. Compute the eccentricity vector of sat1 (i.e. the line of apsides with a
354
+ * direction from apo to periapsis), in the inertial frame
355
+ * 4. Compute the true anomalies of ascending and descending mutual node points
356
+ * for sat1, based on the mutual nodal vector and eccentricity vector.
357
+ * 5. Compute the flight path angle at each of these points, using the Keplerian
358
+ * representation of sat1 (requires conversion of pos, vel to Kepler elements)
359
+ * 6. Compute the magnitude of Dv required at each point.
360
+ * 7. By evaluating the relative inclination of start and final orbit, as well
361
+ * as the nodal point on which to burn, compute the exact components.
362
+ *
363
+ * Note: The burn components are expressed in the Radial,Along-Track,Cross-Track
364
+ * frame, known as RSW by Vallado notation. This is distinct from the NTW frame,
365
+ * which uses the In-track,Cross-Track axes to instantiate it.
366
+ * @param {Object} pv1
367
+ * @param {Object} pv2
368
+ * @return {Object} Delta-v in m/s in RSW frame to perform a single impulsive
369
+ * plane match maneuver of sat1 to sat2.
370
+ */
371
+ const planeChangeDeltaV = (pv1, pv2) => {
372
+ const mu = 3.986004418e14; // (m^3)/(s^2) WGS-84 Earth Mu
373
+
374
+ // 1. Compute the angular momentum of each orbit from the pos vel vectors
375
+ const r = multiply([pv1.position.x, pv1.position.y, pv1.position.z],
376
+ 1000.0);
377
+ const v = multiply([pv1.velocity.x, pv1.velocity.y, pv1.velocity.z],
378
+ 1000.0);
379
+ const h1 = cross(r, v);
380
+
381
+ const vMag = norm(v);
382
+
383
+ const r2 = multiply([pv2.position.x, pv2.position.y, pv2.position.z],
384
+ 1000.0);
385
+ const v2 = multiply([pv2.velocity.x, pv2.velocity.y, pv2.velocity.z],
386
+ 1000.0);
387
+ const h2 = cross(r2, v2);
388
+
389
+ // 2. Compute the mutual line of nodes vector
390
+ const n = cross(h1, h2);
391
+
392
+ // 3. Compute the eccentricity vector of sat1
393
+ const e = multiply(
394
+ (1 / mu),
395
+ subtract(
396
+ (multiply(Math.pow(norm(v, 2), 2) - (mu / norm(r, 2)), r)),
397
+ multiply(dot(r, v), v)),
398
+ );
399
+
400
+ // 4. Compute the true anomalies of ascending and descending mutual node
401
+ const vAsc = Math.acos((dot(n, e))/(norm(n)* norm(e)));
402
+ const vDesc = (vAsc + Math.PI) % (2* Math.PI);
403
+
404
+ // 5. Compute the flight path angle at each of these points
405
+ const el = cartesianToKeplerian(r, v);
406
+ const gammaAsc = Math.atan(el.e* Math.sin(vAsc)/(1+ el.e* Math.cos(vAsc)));
407
+ const gammaDesc = Math.atan(el.e* Math.sin(vDesc)/(1+ el.e* Math.cos(vDesc)));
408
+
409
+ // 6. Compute the magnitude of Dv required at each point.
410
+ // The absolute inclination difference
411
+ const dihedralAngle = Math.abs(angleBetweenPlanes(pv1, pv2)*DEG2RAD);
412
+
413
+ const dvAsc = 2* vMag * Math.cos(gammaAsc) * Math.sin(dihedralAngle/2);
414
+ const dvDesc = 2* vMag * Math.cos(gammaDesc) * Math.sin(dihedralAngle/2);
415
+
416
+ const el2 = cartesianToKeplerian(r2, v2);
417
+
418
+ // 7. Choose minimum dv, and compute the exact components.
419
+
420
+ // The Along-track component. We need to perform this slight retrograde burn
421
+ // in order to cancel out the eccentricity a pure cross-track burn
422
+ // would introduce.
423
+ let dvI = 0;
424
+ // The Cross-track component
425
+ let dvC = 0;
426
+
427
+ // If sat 1 is higer inc than sat2
428
+ if (el.i >= el2.i) {
429
+ // In-track component is always retrograde to correct for sma and e increase
430
+ dvI = (-1) * vMag * Math.cos(gammaAsc) * (1 - Math.cos(dihedralAngle));
431
+ if (Math.abs(dvAsc) <= Math.abs(dvDesc)) {
432
+ // Burn at ASC node is opposite of the sat1 cross-track axis!
433
+ dvC = (-1) * vMag * Math.cos(gammaAsc) * Math.sin(dihedralAngle);
434
+ } else {
435
+ // Burn at DESC node is aligned with sat1 cross-track
436
+ dvC = (+1) * vMag * Math.cos(gammaAsc) * Math.sin(dihedralAngle);
437
+ }
438
+ } else {
439
+ // In-track component is always retrograde to correct for sma and e increase
440
+ dvI = (-1) * vMag * Math.cos(gammaDesc) * (1 - Math.cos(dihedralAngle));
441
+ if (Math.abs(dvAsc) <= Math.abs(dvDesc)) {
442
+ // Burn at ASC node is aligned with sat1 cross-track
443
+ dvC = (+1) * vMag * Math.cos(gammaDesc) * Math.sin(dihedralAngle);
444
+ } else {
445
+ // Burn at DESC node is opposite of the sat1 cross-track axis
446
+ dvC = (-1) * vMag * Math.cos(gammaDesc) * Math.sin(dihedralAngle);
447
+ }
448
+ }
449
+
450
+ // 5. Return the RSW components of the required burn!
451
+ // Note that there's no radial component for this type of burn.
452
+ return {
453
+ r: 0,
454
+ i: dvI,
455
+ c: dvC,
456
+ mag: (dvAsc <= dvDesc) ? dvAsc: dvDesc,
457
+ node: (dvAsc <= dvDesc) ? "asc": "desc",
458
+ };
459
+ };
460
+
461
+ /** Calculates the Minimum delta-v required for sat1 to match the inclination of sat2.
462
+ * This will generally not match the plane of sat1 with that of sat2, but only their inclination.
463
+ *
464
+ * For a maneuver that perform a plane match between sat1 and sat2, see the
465
+ * function planeChangeDeltaV, which accounts for the RAAN AND inclination change,
466
+ * to exactly align the orbital plane with the least Dv.
467
+ *
468
+ * Reference: https://ai-solutions.com/_freeflyeruniversityguide/plane_change_maneuver.htm
469
+ *
470
+ * The delta-v calculation is performed under the assumption that it will generate a
471
+ * new orbit for sat1 whose Orbital elements are all the same except for inclination,
472
+ * which will match that of sat2!
473
+ *
474
+ * This assumption is possible only by additionally assuming that such am impulsive
475
+ * maneuver will be performed at a sat1 nodal crossing. This algorithm specifies the node at which
476
+ * the plane change maneuver will require the least amount of fuel.
477
+ *
478
+ * The algorithm is applicable for all closed, planar, elliptical orbits.
479
+ * Note the contribution of the flight path angle in the calculations,
480
+ * which guarantees this algorithm's applicability for eccentic orbits.
481
+ * The flight path angle is separately computed for the ascending and descending node.
482
+ *
483
+ * For more information, consult Vallado's Algorithm 39 and Example 6-4 in Edition 4.
484
+ *
485
+ * @param {Object} pv1 Position and Velocity Vector of Satelltie 1 at Time = x
486
+ * @param {Object} pv2 Position and velocity Vector of Satellite 2 at Time = x
487
+ * @return {Object} Delta-v in m/s to perform a pure inclination plane change at
488
+ * the sat1 asc or desc node.
489
+ */
490
+ const planeChangePureInclinationDeltaV = (pv1, pv2)=>{
491
+ // 1. Get position and velocity vectors and magnitudes.
492
+ // Note that the magnitude of the final velocity for sat1 will be EQUAL
493
+ // to this initial velocity magnitude of sat1!
494
+ const r = multiply([pv1.position.x, pv1.position.y, pv1.position.z], 1000.0);
495
+ const v = multiply([pv1.velocity.x, pv1.velocity.y, pv1.velocity.z], 1000.0);
496
+
497
+ // Velocity Magnitude in m/s
498
+ const vMag = norm(v);
499
+
500
+ const r2 = multiply([pv2.position.x, pv2.position.y, pv2.position.z], 1000.0);
501
+ const v2 = multiply([pv2.velocity.x, pv2.velocity.y, pv2.velocity.z], 1000.0);
502
+
503
+ // 2. Compute the Keplerian Elements of both satellites.
504
+ const el = cartesianToKeplerian(r, v);
505
+ const el2 = cartesianToKeplerian(r2, v2);
506
+
507
+ // 3. Compute the desired inclination change, based on sat1 starting orbit and sat2 orbit
508
+ // CAREFUL: Do not use the function angleBetweenPlanes. It would not be correct
509
+ // in this context, because the result of that function integrates the changes
510
+ // in inclination AND raan, since it simply computes the
511
+ // angular momentum vector angle!!!
512
+ const incRad = Math.abs(el.i - el2.i)*DEG2RAD;
513
+
514
+ // 4a. Compute the flight path angle and DV at ascending node.
515
+ // The flight path angle in this context is the angle between the inertial
516
+ // velocity vector and local horizontal plane.
517
+
518
+ // The true anomaly of a satellite at the ascending node is equal to the negative of the
519
+ // argument of periapsis. We use this trick to compute the flight path angle at that location.
520
+ const fAtAsc = (-1) * Math.abs(el.w);
521
+ const gammaAscending = Math.atan(
522
+ el.e* Math.sin(fAtAsc*DEG2RAD)/(1+ el.e* Math.cos(fAtAsc*DEG2RAD)));
523
+ const dvAsc = 2* vMag * Math.cos(gammaAscending) * Math.sin(incRad/2);
524
+
525
+ // 4b. Compute the flight path angle and DV at descending node
526
+ const fAtDesc = fAtAsc + 180.0;
527
+ const gammaDescending = Math.atan(
528
+ el.e* Math.sin(fAtDesc*DEG2RAD)/(1+ el.e* Math.cos(fAtDesc*DEG2RAD)));
529
+ const dvDesc = 2* vMag * Math.cos(gammaDescending) * Math.sin(incRad/2);
530
+
531
+ // The Along-track component. We need to perform this slight retrograde burn
532
+ // in order to cancel out the eccentricity a pure cross-track burn
533
+ // would introduce.
534
+ let dvI = 0;
535
+ // The Cross-track component
536
+ let dvC = 0;
537
+
538
+ // 5. Compute the cross-track and along-track burn components in the satellite's
539
+ // RSW frame (Radial, Along-Track, Cross-track)
540
+ // Note, in general, Along-Track is NOT the same as In-Track!
541
+
542
+ // The direction of the along and cross-track components will depend on the relative inclination of sat1 to sat2,
543
+ // In addition, we try to find the node with the smaller burn, in each of those cases of relative incliantion.
544
+
545
+ // If sat 1 is higer inc than sat2
546
+ // In-track component is always retrograde to correct for sma and e increase
547
+ if (el.i >= el2.i) {
548
+ if (Math.abs(dvAsc) <= Math.abs(dvDesc)) {
549
+ // Burn at ASC node is opposite of the sat1 cross-track axis!
550
+ dvC = (-1) * vMag * Math.cos(gammaAscending) * Math.sin(incRad);
551
+ dvI = (-1) * vMag * Math.cos(gammaAscending) * (1 - Math.cos(incRad));
552
+ } else {
553
+ // Burn at DESC node is aligned with sat1 cross-track
554
+ dvC = (+1) * vMag * Math.cos(gammaDescending) * Math.sin(incRad);
555
+ dvI = (-1) * vMag * Math.cos(gammaDescending) * (1 - Math.cos(incRad));
556
+ }
557
+ } else {
558
+ if (Math.abs(dvAsc) <= Math.abs(dvDesc)) {
559
+ // Burn at ASC node is aligned with sat1 cross-track
560
+ dvC = (+1) * vMag * Math.cos(gammaAscending) * Math.sin(incRad);
561
+ dvI = (-1) * vMag * Math.cos(gammaAscending) * (1 - Math.cos(incRad));
562
+ } else {
563
+ // Burn at DESC node is opposite of the sat1 cross-track axis
564
+ dvC = (-1) * vMag * Math.cos(gammaDescending) * Math.sin(incRad);
565
+ dvI = (-1) * vMag * Math.cos(gammaDescending) * (1 - Math.cos(incRad));
566
+ }
567
+ }
568
+
569
+ // 5. Return the RSW components of the required burn!
570
+ // Note that there's no radial component for this type of burn.
571
+ return {
572
+ r: 0,
573
+ i: dvI,
574
+ c: dvC,
575
+ mag: (dvAsc <= dvDesc) ? dvAsc: dvDesc,
576
+ node: (dvAsc <= dvDesc) ? "asc": "desc",
577
+ };
578
+ };
579
+
580
+ /**
581
+ * Takes two StateVectors from two satellits at the same time, and calculates the angle between their orbit planes.
582
+ * This angle DOES NOT directly translate to an inclination delta, but a combined effect of inclination AND raan difference between
583
+ * the two states, since an orbit's plane orientation is defined by both of those parameters!
584
+ * To do this, we need to find vector normal to each plane and find the cross product (angle) between them instead.
585
+ * Step 1: Calculate RxV for each satellite.
586
+ * Step 2: Normalize the RxV vectors to a unit vector
587
+ * Step 3: Theta = Perform the dot product between the two RxV normalized vectors
588
+ * Step 4: ArcCos(Theta) = angle between the planes
589
+ * @param {Object} pv1 Position and Velocity Vector of Satelltie 1 at Time = x
590
+ * @param {Object} pv2 Position and velocity Vector of Satellite 2 at Time = x
591
+ * @return {Number} Angle between the two planes in radians
592
+ */
593
+ const angleBetweenPlanes = (pv1, pv2) => {
594
+ // Calculate Cross Products and Normalize
595
+ const cross1 = cross(
596
+ [pv1.position.x, pv1.position.y, pv1.position.z],
597
+ [pv1.velocity.x, pv1.velocity.y, pv1.velocity.z],
598
+ );
599
+ const cross1mag = Math.sqrt(
600
+ cross1[0] * cross1[0]
601
+ + cross1[1] * cross1[1]
602
+ + cross1[2] * cross1[2]);
603
+
604
+ const cross1Norm = {
605
+ x: cross1[0] / cross1mag,
606
+ y: cross1[1] / cross1mag,
607
+ z: cross1[2] / cross1mag,
608
+ };
609
+
610
+ const cross2 = cross(
611
+ [pv2.position.x, pv2.position.y, pv2.position.z],
612
+ [pv2.velocity.x, pv2.velocity.y, pv2.velocity.z],
613
+ );
614
+ const cross2Mag = Math.sqrt(
615
+ cross2[0] * cross2[0]
616
+ + cross2[1] * cross2[1]
617
+ + cross2[2] * cross2[2]);
618
+
619
+ const cross2Norm = {
620
+ x: cross2[0] / cross2Mag,
621
+ y: cross2[1] / cross2Mag,
622
+ z: cross2[2] / cross2Mag,
623
+ };
624
+
625
+ // Calculate the dot products
626
+ const dotProducts = cross1Norm.x * cross2Norm.x
627
+ + cross1Norm.y * cross2Norm.y
628
+ + cross1Norm.z * cross2Norm.z;
629
+
630
+ // If the values are outside -1 to 1, it will clamp it to -1 or 1.
631
+ const clamped = Math.max(-1, Math.min(1, dotProducts));
632
+
633
+ // Extract the angle from the dot products in degrees
634
+ const angle = Math.acos(clamped) * (180.0 / Math.PI);
635
+
636
+ // Round result to 3 decimal points and return
637
+ return Math.round(angle * 1000) / 1000;
638
+ };
639
+
640
+ /**
641
+ * Calculates the position and velocity of a satellite at a point in time using SGP4
642
+ *
643
+ * @param {Object} satRec twoline2satrec
644
+ * @param {Integer} time UNIX time stamp to evaluate at
645
+ * @return {Object} Tracking position and velocity of satellite at given point of time, or null on error
646
+ */
647
+ const propTo = (satRec, time) => {
648
+ const pv = propagate(satRec, new Date(time));
649
+ if ( !isDefined(pv))
650
+ return null;
651
+ return {
652
+ p: pv.position,
653
+ v: pv.velocity,
654
+ t: time,
655
+ meanElements: {
656
+ semimajor: pv.meanElements.am,
657
+ eccentricity: pv.meanElements.em,
658
+ inclination: pv.meanElements.im,
659
+ raan: pv.meanElements.Om,
660
+ argp: pv.meanElements.om,
661
+ meanmotion: pv.meanElements.nm,
662
+ meananomaly: pv.meanElements.mm,
663
+ }
664
+ };
665
+ };
666
+
667
+ /**
668
+ * Calculates the position and velocity of a satellite over a period of time (start, end) using SGP4
669
+ *
670
+ * @param {Object} elset {Line1, Line2}
671
+ * @param {Integer} start UNIX time stamp
672
+ * @param {Integer} end UNIX time stamp
673
+ * @param {Integer} stepMs The step size in ms
674
+ * @return {Array} Array of objects tracking positions and velocities of a satellite at given points of time (stepMs apart)
675
+ */
676
+ const prop = (elset, start, end, stepMs = 1000) => {
677
+ const ephem = [];
678
+ const sat = twoline2satrec(elset.Line1, elset.Line2);
679
+ for (let i = start; i < end; i = i + stepMs) {
680
+ const pv = propTo(sat, i);
681
+ if (!isDefined(pv)) {
682
+ return [];
683
+ }
684
+ ephem.push(pv);
685
+ }
686
+ return ephem; // Ephemeris
687
+ };
688
+
689
+ /**
690
+ * Calculates the geodetic position of a satellite over a period of time (start, end) using SGP4
691
+ * @param {Object} elset {Line1, Line2}
692
+ * @param {Integer} start UNIX time stamp
693
+ * @param {Integer} end UNIX time stamp, inclusive
694
+ * @param {Integer} stepMs The step size in ms
695
+ * @return {Array} Array of objects tracking geodetic positions of a satellite at given points of time (stepMs apart)
696
+ */
697
+ const propGeodetic = (elset, start, end, stepMs = 60000) => {
698
+ const positions = [];
699
+ const sat = twoline2satrec(elset.Line1, elset.Line2);
700
+ for (let t = start; t <= end; t = t + stepMs) {
701
+ const pv = propagate(sat, new Date(t));
702
+ if (!isDefined(pv.position)) return [];
703
+ const gmst = gstime(new Date(t));
704
+ const positionGd = eciToGeodetic(pv.position, gmst);
705
+ positions.push({lat: positionGd.latitude*RAD2DEG, lon: positionGd.longitude*RAD2DEG, t: t});
706
+ }
707
+ return positions;
708
+ };
709
+
710
+ const doesLineSegmentSphereIntersect = (linePoint0, linePoint1, circleCenter, circleRadius) => {
711
+ // From Space Cockpit
712
+ // http://www.codeproject.com/Articles/19799/Simple-Ray-Tracing-in-C-Part-II-Triangles-Intersec
713
+
714
+ // Input validation
715
+ if (circleRadius < 0) {
716
+ return false;
717
+ }
718
+
719
+ const cx = circleCenter.x;
720
+ const cy = circleCenter.y;
721
+ const cz = circleCenter.z;
722
+
723
+ const px = linePoint0.x;
724
+ const py = linePoint0.y;
725
+ const pz = linePoint0.z;
726
+
727
+ const vx = linePoint1.x - px;
728
+ const vy = linePoint1.y - py;
729
+ const vz = linePoint1.z - pz;
730
+
731
+ const a = vx * vx + vy * vy + vz * vz;
732
+
733
+ // Check for zero-length line segment
734
+ if (Math.abs(a) < Number.EPSILON) {
735
+ // If point is on sphere surface, count as intersection
736
+ const distanceSquared
737
+ = (px - cx) * (px - cx) + (py - cy) * (py - cy) + (pz - cz) * (pz - cz);
738
+ return Math.abs(distanceSquared - circleRadius * circleRadius) < Number.EPSILON;
739
+ }
740
+
741
+ const b = 2.0 * (px * vx + py * vy + pz * vz - vx * cx - vy * cy - vz * cz);
742
+ const c = px * px - 2 * px * cx + cx * cx + py * py - 2 * py * cy + cy * cy
743
+ + pz * pz - 2 * pz * cz + cz * cz - circleRadius * circleRadius;
744
+
745
+ // discriminant
746
+ const d = b * b - 4 * a * c;
747
+
748
+ if (d < 0) {
749
+ return false;
750
+ }
751
+
752
+ const sqrtD = Math.sqrt(d);
753
+ const t1 = (-b - sqrtD) / (2.0 * a);
754
+ const t2 = (-b + sqrtD) / (2.0 * a);
755
+
756
+ // Check if either intersection point lies within the line segment bounds [0,1]
757
+ return (t1 >= 0 && t1 <= 1) || (t2 >= 0 && t2 <= 1);
758
+ };
759
+
760
+ const doesLineSegmentIntersectEarth = (start, end) => {
761
+ return doesLineSegmentSphereIntersect(start, end,
762
+ {x: 0, y: 0, z: 0}, WGS72_EARTH_EQUATORIAL_RADIUS_KM);
763
+ };
764
+
765
+ /**
766
+ * Given primary and target ephems, calculate the time-based, radial, intrack, and crosstrack data
767
+ *
768
+ * @param {Array} pEphem primary ephems [{p{x,y,z}, v, t}]
769
+ * @param {Array} tEphem target ephems [{p{x,y,z}, v, t}]
770
+ * @return {Object} Time, Radial, intrack, crosstrack, absolute distances, sun angles
771
+ */
772
+ const getTRIC = (pEphem, tEphem) => {
773
+ // Relative positions in RIC format.
774
+ const t = []; // Time
775
+ const r = []; // Radial (X)
776
+ const i = []; // Intrack (y)
777
+ const c = []; // CrossTrack (z)
778
+ const d = []; // Relative Absolute distance
779
+ const s = []; // Sun Angles
780
+ const targetVisibility = []; // Target visibility from primary
781
+ const primaryVisibility = []; // Primary visibility from target
782
+ const isPrimaryEpoch = []; // Whether point is primary epoch
783
+ const isTargetEpoch = []; // Whether point is target epoch
784
+ const sources = []; // Sources
785
+
786
+ for (let j = 0; j < pEphem.length; j++) {
787
+ const prim = pEphem[j];
788
+ const target = tEphem[j];
789
+
790
+ t.push(prim.t);
791
+ isPrimaryEpoch.push((!!prim.isEpoch)+""); // it can't be a boolean for some reason
792
+ isTargetEpoch.push((!!target.isEpoch)+"");
793
+ sources.push(prim.source + ", " + target.source);
794
+
795
+ // Calculate RIC values.
796
+ const deltaR = {
797
+ x: prim.p.x - target.p.x,
798
+ y: prim.p.y - target.p.y,
799
+ z: prim.p.z - target.p.z,
800
+ };
801
+ const cartToRICMatrix = cartesianToRIC(prim.p, prim.v);
802
+ const ricP = multiplyVector(deltaR, cartToRICMatrix);
803
+ r.push(Math.round(ricP.x * 10000) / 10000);
804
+ i.push(Math.round(ricP.y * 10000) / 10000);
805
+ c.push(Math.round(ricP.z * 10000) / 10000);
806
+
807
+ const time = new Date(prim.t);
808
+ s.push(
809
+ angleBetween3DCoords(
810
+ prim.p,
811
+ target.p,
812
+ getSunDirection(time),
813
+ ),
814
+ );
815
+
816
+ const earthEclipsed = doesLineSegmentIntersectEarth(prim.p, target.p);
817
+ const primaryShadow = getEclipseStatus(time, posToArray(prim.p));
818
+ const targetShadow = getEclipseStatus(time, posToArray(target.p));
819
+ targetVisibility.push(earthEclipsed ? "EARTH ECLIPSED" : targetShadow);
820
+ primaryVisibility.push(earthEclipsed ? "EARTH ECLIPSED" : primaryShadow);
821
+
822
+ d.push(Math.round(dist(prim.p, target.p) * 1000) / 1000);
823
+ }
824
+ return {
825
+ t,
826
+ r,
827
+ i,
828
+ c,
829
+ d,
830
+ s,
831
+ isPrimaryEpoch,
832
+ isTargetEpoch,
833
+ sources,
834
+ targetVisibility,
835
+ primaryVisibility,
836
+ };
837
+ };
838
+
839
+ /**
840
+ * Calculates the sun's direction/distance based on a given time to return position relative to center of earth
841
+ *
842
+ * @param {Object} time new Date() object generated using the time of interest
843
+ *
844
+ * @return {Object} 3 point coordinate in 3D space
845
+ */
846
+ const getSunDirection = (time) => {
847
+ const year = time.getUTCFullYear();
848
+ const month = time.getUTCMonth() + 1; // index 0
849
+ const day = time.getUTCDate();
850
+ const hour = time.getUTCHours();
851
+ const minute = time.getUTCMinutes();
852
+ const seconds = time.getUTCSeconds();
853
+ const JD
854
+ = 367 * year
855
+ - Math.floor((7.0 * (year + Math.floor((month + 9.0) / 12.0))) / 4.0)
856
+ + Math.floor((275.0 * month) / 9.0)
857
+ + day
858
+ + 1721013.5
859
+ + hour / 24.0
860
+ + minute / 1440.0
861
+ + seconds / 86400.0;
862
+ const UT1 = (JD - 2451545) / 36525;
863
+ const longMSUN = 280.4606184 + 36000.77005361 * UT1;
864
+ const mSUN = 357.5277233 + 35999.05034 * UT1;
865
+ const ecliptic
866
+ = longMSUN
867
+ + 1.914666471 * Math.sin(mSUN * DEG2RAD)
868
+ + 0.918994643 * Math.sin(2 * mSUN * DEG2RAD);
869
+ const eccen = 23.439291 - 0.0130042 * UT1;
870
+
871
+ // Direction
872
+ let x = Math.cos(ecliptic * DEG2RAD);
873
+ let y = Math.cos(eccen * DEG2RAD) * Math.sin(ecliptic * DEG2RAD);
874
+ let z = Math.sin(eccen * DEG2RAD) * Math.sin(ecliptic * DEG2RAD);
875
+
876
+ // Distance
877
+ const sunDistance = 0.989 * 1.496e8;
878
+ x = x * sunDistance;
879
+ y = y * sunDistance;
880
+ z = z * sunDistance;
881
+ return {x, y, z};
882
+ };
883
+
884
+ /**
885
+ * Converts a Right Ascension & Declination in Sensor Centered Coordinates
886
+ * into the Latitude and Longitude of a satellite.
887
+ * Slight error as we are not accounting for polar motion of the Earth, it's about 0.01 deg lat and long and 100km range error.
888
+ * @param {String} ObTimeUtc, date-time string of the observation
889
+ * @param {Number} Ra, Right Ascension of the observation
890
+ * @param {Number} Dec, Declination of the observation
891
+ * @param {Number} senLat, Sensor's Latitude
892
+ * @param {Number} senLon, Sensor's Longitude
893
+ * @param {Number} senAltKm, Sensor's Altitude in Km (defaults to sea-level)
894
+ * @param {Number} rangeKm, The Range of the observation in Km from the observer
895
+ * @return {Object} {latitude, longitude, altitude}
896
+ */
897
+ const RaDecToGeodetic = (
898
+ ObTimeUtc,
899
+ Ra,
900
+ Dec,
901
+ senLat,
902
+ senLon,
903
+ senAltKm,
904
+ rangeKm,
905
+ ) => {
906
+ // check for nulls
907
+ if (
908
+ !isDefined(ObTimeUtc)
909
+ || !isDefined(Ra)
910
+ || !isDefined(Dec)
911
+ || !isDefined(senLat)
912
+ || !isDefined(senLon)
913
+ || !isDefined(senAltKm)
914
+ || !isDefined(rangeKm)
915
+ ) {
916
+ return {Altitude: null, Latitude: null, Longitude: null};
917
+ }
918
+
919
+ const time = squid.EpochUTC.fromDateString(
920
+ dtStrtoJsDt(ObTimeUtc).toISOString(),
921
+ );
922
+ const sensorGeo = new squid.Geodetic(
923
+ senLat * DEG2RAD,
924
+ senLon * DEG2RAD,
925
+ senAltKm,
926
+ );
927
+ const sensorItrf = sensorGeo.toITRF(time);
928
+ const sensorGcrf = sensorItrf.toJ2000();
929
+ const sat = new squid.J2000(
930
+ time,
931
+ new squid.Vector3D(
932
+ rangeKm * Math.cos(Ra * DEG2RAD) * Math.cos(Dec * DEG2RAD),
933
+ rangeKm * Math.sin(Ra * DEG2RAD) * Math.cos(Dec * DEG2RAD),
934
+ rangeKm * Math.sin(Dec * DEG2RAD),
935
+ ),
936
+ );
937
+ const satGcrf = new squid.J2000(
938
+ time,
939
+ sensorGcrf.position.add(sat.position),
940
+ );
941
+ const satItrf = satGcrf.toITRF();
942
+ const satLatLng = satItrf.toGeodetic();
943
+ return {
944
+ Altitude: satLatLng.altitude,
945
+ Latitude: satLatLng.latitude * RAD2DEG,
946
+ Longitude: satLatLng.longitude * RAD2DEG,
947
+ };
948
+ };
949
+
950
+ /**
951
+ * This function estimates the slant range from a sensor to an observed satellite in GEO.
952
+ *
953
+ * It uses the sine law to first solve for the sat-sensor-earth angle, and then uses
954
+ * the cosine law to solve for the sat-earth-sensor angle.
955
+ *
956
+ * Finally, it uses the sine law again to get the range from sensor to satellite (i.e. the slant range)
957
+ *
958
+ * @param {String} obTime The ISO string of the observation time
959
+ * @param {Number} ra Topocentric Right Ascension of the observation, in degrees
960
+ * @param {Number} dec Topocentric Declination of the observation, in degrees
961
+ * @param {Number} senLat Sensor Latitude, in degrees
962
+ * @param {Number} senLon Sensor Lontitude, in degrees
963
+ * @param {Number} senAltKm Sensor altitude, in degrees
964
+ * @return {Number} range The range from the sensor to the satellite
965
+ */
966
+ const estimateSlantRange = (obTime, ra, dec, senLat, senLon, senAltKm) => {
967
+ // A very rough initial guess, akso initialization
968
+ let range = 35786;
969
+
970
+ // Convert ra/dec to rectangular coordinates.
971
+ // It is assumed that ra/dec is topocentric, non-null, and well defined.
972
+
973
+ const phi = Math.PI/2 - dec * DEG2RAD;
974
+ const theta = ra* DEG2RAD;
975
+ const x = Math.sin(phi)* Math.cos(theta);
976
+ const y = Math.sin(phi)* Math.sin(theta);
977
+ const z = Math.cos(phi);
978
+
979
+ // A unit vector representing the line-of-site from sensor to sat
980
+ const slantU = [-x, -y, -z];
981
+
982
+ // Sensor coordinates
983
+ const sensorGeo = new squid.Geodetic(
984
+ senLat * DEG2RAD,
985
+ senLon * DEG2RAD,
986
+ senAltKm,
987
+ );
988
+ const time = squid.EpochUTC.fromDateString(obTime);
989
+ const sensorItrf = sensorGeo.toITRF(time);
990
+ const sensorGcrf = sensorItrf.toJ2000();
991
+
992
+ // Compute the distance of the sensor from the Earth's center
993
+ const a = norm([sensorGcrf.position.x, sensorGcrf.position.y, sensorGcrf.position.z], 2);
994
+
995
+ // Normalize the sensor position to a unit vector
996
+ const cVec = normalize(
997
+ {x: sensorGcrf.position.x, y: sensorGcrf.position.y, z: sensorGcrf.position.z});
998
+ // Get the angle between the slant vector and the sensor vector
999
+ const beta = getAngle(slantU, [cVec.x, cVec.y, cVec.z]);
1000
+
1001
+ // Distance of a GEO satellite wrt to the Earth's center.
1002
+ const b = 42164;
1003
+ // Law of Sines ratio
1004
+ const delta = b / Math.sin(beta);
1005
+ // Using Law of cosines, isolate and solve the sensor, earth, satellite angle. Always two solutions exist.
1006
+ const roots = polynomialRoot(
1007
+ (Math.pow(a, 2) + Math.pow(b, 2) - Math.pow(delta, 2)), (-2*a*b), Math.pow(delta, 2));
1008
+
1009
+ // The two solutions, only one is valid. Validity check is based on the fact that the sum of triangle angles is 180.
1010
+ const gamma1 = Math.acos(roots[0]);
1011
+ const gamma2 = Math.acos(roots[1]);
1012
+
1013
+ // Compute the sensor-satellite-earth angle
1014
+ const alpha = Math.sin(delta/a);
1015
+ // alpha + beta + gamma NEEDS to be 180
1016
+ // Choose the gamma that best achieves this
1017
+ const targetGamma = Math.PI-alpha-beta;
1018
+ if (Math.abs(targetGamma-gamma1) < Math.abs(targetGamma-gamma2)) {
1019
+ range = delta* Math.sin(gamma1);
1020
+ } else {
1021
+ range = delta* Math.sin(gamma2);
1022
+ }
1023
+ return range;
1024
+ };
1025
+
1026
+ /**
1027
+ * Converts the Azimuth (az) and Elevation (el) of an object to its Right Ascension
1028
+ * (Ra) and Declination (Declination)
1029
+ * @param {*} ObTimeUtc The datetimestamp in UTC format as a string
1030
+ * @param {*} az Azimuth in degrees
1031
+ * @param {*} el Elevation in degrees
1032
+ * @param {*} lat Latitude of observer in degrees
1033
+ * @param {*} lon Longitude of observer in degrees
1034
+ * @return {Object} {ra, dec} Right Ascension and Declination in degrees.
1035
+ */
1036
+ const AzElToRaDec = (ObTimeUtc, az, el, lat, lon) => {
1037
+ // check for nulls
1038
+ if (!isDefined(ObTimeUtc)
1039
+ || !isDefined(az)
1040
+ || !isDefined(el)
1041
+ || !isDefined(lat)
1042
+ || !isDefined(lon)) {
1043
+ return {ra: null, dec: null};
1044
+ }
1045
+
1046
+ const time = squid.EpochUTC.fromDateString(ObTimeUtc);
1047
+
1048
+ // Convert all angles to radians
1049
+ az = az * DEG2RAD;
1050
+ el = el * DEG2RAD;
1051
+ lat = lat * DEG2RAD;
1052
+ lon = lon * DEG2RAD;
1053
+
1054
+ const dec = Math.asin(
1055
+ Math.sin(el) * Math.sin(lat)
1056
+ + Math.cos(el) * Math.cos(lat) * Math.cos(az),
1057
+ );
1058
+
1059
+ const gmst = time.gmstAngle();
1060
+ const lmst = lon + gmst;
1061
+
1062
+ const H = Math.atan2(
1063
+ -Math.sin(az) * Math.cos(el) * Math.cos(lat),
1064
+ Math.sin(el) - Math.sin(lat) * Math.sin(dec),
1065
+ );
1066
+
1067
+ const ra = (lmst - H) % (Math.PI * 2);
1068
+
1069
+ // Return the right ascension and declination as an object
1070
+ return {ra: ra * RAD2DEG, dec: dec * RAD2DEG};
1071
+ };
1072
+
1073
+ /**
1074
+ * Convert the Right Ascension (RA) and Declination (Dec) of an object in space to its
1075
+ * Azimuth (Az) and Elevation (El) given the object's RaDec, the latitude and longitude of the observer's location,
1076
+ * and the current local sidereal time (LST). The function will return an object with properties az and el representing the
1077
+ * Azimuth and Elevation of the object in degrees.}
1078
+ * @param {*} ObTime The time of the observation in Utc as a string in YYYY-MM-DDThh:mm:ss.ssssssZ
1079
+ * @param {*} Ra Right Ascension in degrees
1080
+ * @param {*} Dec Declination in degrees
1081
+ * @param {*} SenLat Latitude of observer in degrees
1082
+ * @param {*} SenLon Longitude of observer in degrees
1083
+ * @return {Object} {Az, El}
1084
+ */
1085
+ const RaDecToAzEl = (ObTime, Ra, Dec, SenLat, SenLon) => {
1086
+ if (!isDefined(ObTime)
1087
+ || !isDefined(Ra)
1088
+ || !isDefined(Dec)
1089
+ || !isDefined(SenLat)
1090
+ || !isDefined(SenLon)) {
1091
+ return {Az: null, El: null};
1092
+ }
1093
+
1094
+ // senAlt and slantRange are ambiguous
1095
+ const SenAlt = 0;
1096
+ const SlantRangeKm = 1;
1097
+
1098
+ const time = squid.EpochUTC.fromDateString(ObTime);
1099
+ const sensorGeo = new squid.Geodetic(
1100
+ SenLat * DEG2RAD,
1101
+ SenLon * DEG2RAD,
1102
+ SenAlt,
1103
+ );
1104
+ const sensorITRF = sensorGeo.toITRF(time);
1105
+ const sensorGCRF = sensorITRF.toJ2000();
1106
+ const satellite = new squid.J2000(
1107
+ time,
1108
+ new squid.Vector3D(
1109
+ SlantRangeKm * Math.cos(Ra * DEG2RAD) * Math.cos(Dec * DEG2RAD),
1110
+ SlantRangeKm * Math.sin(Ra * DEG2RAD) * Math.cos(Dec * DEG2RAD),
1111
+ SlantRangeKm * Math.sin(Dec * DEG2RAD),
1112
+ ),
1113
+ );
1114
+ const satGCRF = new squid.J2000(
1115
+ time,
1116
+ sensorGCRF.position.add(satellite.position),
1117
+ );
1118
+ const satITRF = satGCRF.toITRF();
1119
+ const result = satITRF.toLookAngle(sensorGeo);
1120
+ return {
1121
+ Az: (result.azimuth * 180) / Math.PI,
1122
+ El: (result.elevation * 180) / Math.PI,
1123
+ };
1124
+ };
1125
+
1126
+ /**
1127
+ * Algorithm to calculate the residual error in Azimuth and Elevation for an array
1128
+ * of Observations from a given TLE.
1129
+ * @param {Array} obs Array of Observations
1130
+ * @param {Object} tle The satellites TLE
1131
+ * @return {Array} Array of residual data objects
1132
+ */
1133
+ const GetResiduals = (obs, tle) => {
1134
+ const sat = twoline2satrec(tle.Line1, tle.Line2);
1135
+ const residuals = [];
1136
+ const epochDate = epochToDate(sat.epochdays, 2000 + sat.epochyr);
1137
+
1138
+ if (sat.error > 0) {
1139
+ console.log(sat.error);
1140
+ return residuals; // Can't propegate TLE
1141
+ }
1142
+
1143
+ for (let i = 0; i < obs.length; i++) {
1144
+ const ob = obs[i];
1145
+ const obTimeUtc = dtStrtoJsDt(ob.ObTime);
1146
+ const dt
1147
+ = (obTimeUtc.getTime() - dtStrtoJsDt(epochDate).getTime())
1148
+ / 1000
1149
+ / 60;
1150
+ const pv = sgp4(sat, dt);
1151
+ const gmst = gstime(obTimeUtc);
1152
+ const observerGd = {
1153
+ latitude: degreesToRadians(ob.SenLat),
1154
+ longitude: degreesToRadians(ob.SenLon),
1155
+ height: ob.SenAlt,
1156
+ };
1157
+
1158
+ const pECEF = eciToEcf(pv.position, gmst);
1159
+ const angles = ecfToLookAngles(observerGd, pECEF);
1160
+ residuals.push({
1161
+ SatNo: ob.SatNo,
1162
+ ElErr: (ob.Elevation!==undefined && ob.Elevation!==null)
1163
+ ? ob.Elevation - radiansToDegrees(angles.elevation): null,
1164
+ AzErr: (ob.Azimuth!==undefined && ob.Azimuth!==null)
1165
+ ? getAngleDiffSigned(radiansToDegrees(angles.azimuth), ob.Azimuth): null,
1166
+ RangeErr: (ob.Range!==undefined && ob.Range!==null) ? ob.Range - angles.rangeSat : null,
1167
+ ObTime: ob.ObTime,
1168
+ Source: ob.Source,
1169
+ SensorId: ob.IdSensor,
1170
+ Type: (ob.Type!==undefined && ob.Type!==null) ? ob.Type : null,
1171
+ });
1172
+ }
1173
+ return residuals;
1174
+ };
1175
+
1176
+ /** Convert a TLE's two lines to a UDL-compatible Elset_Ingested object, ready to be ingested to UDL.
1177
+ *
1178
+ * @param {String} l1 Line 1 of a TLE
1179
+ * @param {String} l2 Line 2 of a TLE
1180
+ * @param {String} dataMode The data mode corresonding to the TLE. Validation is performed,
1181
+ * but it should be one of the allowed UDL enumerated values (REAL, TEST, EXERCISE, SIMULATED).
1182
+ * @param {Boolean} isUct The flag indicating if the object is catalogued or not.
1183
+ * If not sure, leave it empty, UDL allows this to be null, but it is not good practice!
1184
+ * @param {String} descriptor A string with a helpful description message for the consumers.
1185
+ * @return {Object} The Elset json. It should be strictly validated against Elset_Ingested schema.
1186
+ */
1187
+ const GetElsetUdlFromTle = (
1188
+ l1,
1189
+ l2,
1190
+ dataMode,
1191
+ isUct = null,
1192
+ descriptor = null,
1193
+ ) => {
1194
+ try {
1195
+ // Check the TLE lines, or exit early
1196
+ if (!checkTle(l1, l2)) {
1197
+ throw new Error("Input TLE lines have bad format.");
1198
+ }
1199
+
1200
+ const elset = {};
1201
+
1202
+ // Validate and add uct. If not boolean, check if null, since UDL allows it,
1203
+ // and we may be unsure of entering true or false for any reason.
1204
+ if (isBoolean(isUct)) {
1205
+ elset.uct = isUct;
1206
+ } else if (isUct === null || typeof isUct === "undefined") {
1207
+ // Do nothing, do not populate nor put null.
1208
+ } else {
1209
+ throw new Error("Input uct flag is not of type boolean.");
1210
+ }
1211
+
1212
+ // Validate and add dataMode
1213
+ if (isValidDataMode(dataMode)) {
1214
+ elset.dataMode = dataMode;
1215
+ } else {
1216
+ throw new Error("Input dataMode argument is not an allowed type.");
1217
+ }
1218
+
1219
+ // Add U classification marking. Hardcoded Unclassified for now.
1220
+ // Otherwise, CAPCO marking validation shall be performed.
1221
+ // Classification is a required field in the Elset and almost all UDL schemas.
1222
+ elset.classificationMarking = "U";
1223
+
1224
+ // Add the descriptor string, for any comments etc.
1225
+ if (isNonEmptyString(descriptor)) {
1226
+ elset.descriptor = descriptor;
1227
+ }
1228
+
1229
+ // Initialize a satellite record
1230
+ const satrec = twoline2satrec(l1, l2);
1231
+
1232
+ // Now, Populate the elset properties from the satellite record
1233
+
1234
+ // Epoch to ISO-8601
1235
+ const date = julianToGregorian(satrec.jdsatepoch);
1236
+ elset.epoch = date.toISOString();
1237
+
1238
+ elset.satNo = parseInt(satrec.satnum);
1239
+
1240
+ // Eccentricity
1241
+ elset.eccentricity = satrec.ecco;
1242
+
1243
+ // Mean motion conversion from radians per minute to revs per day does not match
1244
+ // the value of mean motion on line 2 exactly! This is an issue of the satellite.js library
1245
+ // So we directly extract this number from line 2.
1246
+ elset.meanMotion = parseFloat(l2.slice(52, 63));
1247
+
1248
+ // According to UDL, period field is the inverse of mean motion, in minutes!
1249
+ // This is added for convenience of the consumer. It is not needed for a successful POST operation.
1250
+ elset.period = (1 / elset.meanMotion) * 24.0 * 60.0;
1251
+
1252
+ // For convenience, we report the ephemeris Type, which is NOT automatically populated by UDL.
1253
+ // Again, this field is not mandatory and POST will succeed if it is ommitted. It is offered for convenience!
1254
+ // UDL suggests to use "SGP4" if orbital period < 225 minutes, and SDP4 otherwise (see ephemType field description in UDL Elset schema)
1255
+ if (elset.period < 225.0) {
1256
+ elset.ephemType = 0;
1257
+ } else {
1258
+ elset.ephemType = 3;
1259
+ }
1260
+
1261
+ // Inclination, converted from radians to degrees
1262
+ elset.inclination = satrec.inclo * RAD2DEG;
1263
+
1264
+ // Source Always saber
1265
+ elset.source = "saber";
1266
+
1267
+ // Raan, converted from radians to degrees
1268
+ elset.raan = satrec.nodeo * RAD2DEG;
1269
+
1270
+ // Argument of perigee, converted from radians to degrees
1271
+ elset.argOfPerigee = satrec.argpo * RAD2DEG;
1272
+
1273
+ // Mean anomaly, converted from radians to degrees
1274
+ elset.meanAnomaly = satrec.mo * RAD2DEG;
1275
+
1276
+ // B-star in inverse earth radii
1277
+ elset.bStar = satrec.bstar;
1278
+
1279
+ // First derivative of mean motion
1280
+ elset.meanMotionDot = satrec.ndot;
1281
+
1282
+ // Second derivative of mean motion
1283
+ elset.meanMotionDDot = satrec.nddot;
1284
+
1285
+ // Revolution number, parsed directly from line 2
1286
+ elset.revNo = parseInt(l2.slice(63, 68));
1287
+
1288
+ // sma
1289
+ elset.semiMajorAxis = (
1290
+ (elset.period * 60)**2 * GRAV_CONST * EARTH_MASS / (4 * Math.PI**2)
1291
+ )**(1/3) / 1000; // km
1292
+
1293
+ // apogee
1294
+ elset.apogee = elset.semiMajorAxis * (1 + elset.eccentricity); // km
1295
+
1296
+ // perigee
1297
+ elset.perigee = elset.semiMajorAxis * (1 - elset.eccentricity); // km
1298
+
1299
+ // Cleanup in the end
1300
+ removeNullUndefined(elset);
1301
+ return elset;
1302
+ } catch (e) {
1303
+ // Return an empty object, which is guaranteed to be invalid
1304
+ // against UDL Elset_Ingested schema.
1305
+ return {};
1306
+ }
1307
+ };
1308
+
1309
+ /** Calculates the distance between two Geodetic coordinates.
1310
+ * @param {Number} lat1 The latitude of the first coordinate in degrees
1311
+ * @param {Number} lon1 The longitude of the first coordinate in degrees
1312
+ * @param {Number} lat2 The latitude of the second coordinate in degrees
1313
+ * @param {Number} lon2 The longitude of the second coordinate in degrees
1314
+ * @return {Number} The distance between the two coordinates in meters
1315
+ *
1316
+ * Source: https://www.movable-type.co.uk/scripts/latlong.html
1317
+ */
1318
+ const distGeodetic = (lat1, lon1, lat2, lon2) => {
1319
+ const R = WGS84_EARTH_EQUATORIAL_RADIUS_KM * 1000.0; // metres
1320
+ const phi1 = lat1 * DEG2RAD; // φ1 in formula
1321
+ const phi2 = lat2 * DEG2RAD; // φ2 in formula
1322
+ const deltaPhi = (lat2 - lat1) * DEG2RAD; // Δφ in formula
1323
+ const deltaLambda = (lon2 - lon1) * DEG2RAD; // Δλ in formula
1324
+ const a
1325
+ = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2)
1326
+ + Math.cos(phi1)
1327
+ * Math.cos(phi2)
1328
+ * Math.sin(deltaLambda / 2)
1329
+ * Math.sin(deltaLambda / 2);
1330
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
1331
+
1332
+ const d = R * c; // in metres
1333
+
1334
+ return d;
1335
+ };
1336
+
1337
+ /** Convert cartesian coordinates in an ECI (J2000, TEME, GCRF) frame to keplerian elements formatted as [ a, e, i, Ω, ω, θ ].
1338
+ * The gravitational parameter is assumed to be Earth's mu.
1339
+ * The function is a translation of Astro Library's OrbitalElementConverter.CartesianToKeplerian method.
1340
+ *
1341
+ * NOTE 1: raan, argument of periapsis, and true anomaly are ALL adjusted to have a domain from 0 to 360!!!
1342
+ * NOTE 2: raan, argument of periapsis, and true anomaly are ALL in degrees!
1343
+ * NOTE 3: SMA (a) is in km
1344
+ *
1345
+ * @param {Array} r Position, in meters
1346
+ * @param {Array} v Velocity, in m/s
1347
+ * @return {Object} An object containing the Keplerian Elements, if successful. Otherwise, an empty object.
1348
+ */
1349
+ const cartesianToKeplerian = (r, v) => {
1350
+ try {
1351
+ const mu = 3.986004418e14; // (m^3)/(s^2) WGS-84 Earth Mu
1352
+ const tol = 1e-9;
1353
+
1354
+ const h = cross(r, v);
1355
+ const K = [0, 0, 1];
1356
+ const n = cross(K, h);
1357
+
1358
+ if (norm(r, 2) === 0) throw new Error("Position vector must not be zero.");
1359
+ if (norm(v, 2) === 0) throw new Error("Velocity vector must not be zero.");
1360
+
1361
+ const e = multiply(
1362
+ (1 / mu),
1363
+ subtract(
1364
+ (multiply(Math.pow(norm(v, 2), 2) - (mu / norm(r, 2)), r)),
1365
+ multiply(dot(r, v), v)),
1366
+ );
1367
+
1368
+ const zeta = 0.5 * Math.pow(norm(v), 2) - (mu / norm(r));
1369
+
1370
+ if (zeta === 0) throw new Error("Zeta cannot be zero.");
1371
+ if (Math.abs(1.0 - norm(e)) <= tol) {
1372
+ throw new Error("Parabolic orbit conversion is not supported.");
1373
+ }
1374
+
1375
+ const a = -mu / zeta / 2;
1376
+
1377
+ if (Math.abs(a * (1 - norm(e))) < 1e-3) {
1378
+ throw new Error(`The state results in a singular conic section with
1379
+ radius of periapsis less than 1 m.`);
1380
+ }
1381
+
1382
+ const i = Math.acos(h[2] / norm(h));
1383
+
1384
+ if (i >= Math.PI - tol) {
1385
+ throw new Error("Cannot convert orbit with inclination of 180 degrees.");
1386
+ }
1387
+
1388
+ let raan = 0;
1389
+ let w = 0;
1390
+ let f = 0;
1391
+
1392
+ // CASE 1: Non-circular, Inclined Orbit
1393
+ if (norm(e) >= 1E-11 && i >= 1E-11 && i <= Math.PI - 1E-11) {
1394
+ if (norm(n) === 0.0) {
1395
+ throw new Error(`Cannot convert from Cartesian to Keplerian,
1396
+ line-of-nodes vector is a zero vector.`);
1397
+ }
1398
+
1399
+ raan = Math.acos(n[0] / norm(n));
1400
+ if (n[1] < 0) raan = 2 * Math.PI - raan;
1401
+
1402
+ w = Math.acos(dot(n, e) / (norm(n) * norm(e)));
1403
+ if (e[2] < 0) w = (2 * Math.PI) - w;
1404
+
1405
+ f = Math.acos(dot(e, r) / (norm(e) * norm(r)));
1406
+ if (dot(r, v) < 0) f = 2 * Math.PI - f;
1407
+ }
1408
+ // CASE 2: Non-circular, Equatorial Orbit
1409
+ if (norm(e) >= 1E-11 && (i < 1E-11 || i > Math.PI - 1E-11)) {
1410
+ if (norm(e) === 0.0) {
1411
+ throw new Error(`Cannot convert from Cartesian to Keplerian,
1412
+ eccentricity is zero.`);
1413
+ }
1414
+ raan = 0;
1415
+ w = Math.acos(e[0] / norm(e));
1416
+ if (e[1] < 0) {
1417
+ w = 2 * Math.PI - w;
1418
+ }
1419
+
1420
+ // For GMT-4446 fix (LOJ: 2014.03.21)
1421
+ if (i > Math.PI - 1E-11) {
1422
+ w *= -1.0;
1423
+ }
1424
+ if (w < 0.0) {
1425
+ w += 2 * Math.PI;
1426
+ }
1427
+
1428
+ f = Math.acos(dot(e, r) / (norm(e) * norm(r)));
1429
+ if (r * v < 0) {
1430
+ f = 2 * Math.PI - f;
1431
+ }
1432
+ }
1433
+ // CASE 3: Circular, Inclined Orbit
1434
+ if (norm(e) < 1E-11 && i >= 1E-11 && i <= Math.PI - 1E-11) {
1435
+ if (norm(n) === 0.0) {
1436
+ throw new Error(`Cannot convert from Cartesian to Keplerian,
1437
+ line-of-nodes vector is a zero vector.`);
1438
+ }
1439
+ raan = Math.acos(n[0] / norm(n));
1440
+ if (n[1] < 0) {
1441
+ raan = 2 * Math.PI - raan;
1442
+ }
1443
+
1444
+ w = 0;
1445
+ f = Math.acos((dot(n, r) / (norm(n) * norm(r))));
1446
+ if (r[3] < 0) {
1447
+ f = 2 * Math.PI - f;
1448
+ }
1449
+ }
1450
+ // CASE 4: Circular, Equatorial Orbit
1451
+ if (norm(e) < 1E-11 && (i < 1E-11 || i > Math.PI - 1E-11)) {
1452
+ raan = 0;
1453
+ w = 0;
1454
+ f = Math.acos(r[0] / norm(r));
1455
+ if (r[1] < 0) f = 2 * Math.PI - f;
1456
+
1457
+ // For GMT-4446 fix (LOJ: 2014.03.21)
1458
+ if (i > Math.PI - 1E-11) f *= -1.0;
1459
+ if (f < 0.0) {
1460
+ f += 2 * Math.PI;
1461
+ }
1462
+ }
1463
+
1464
+ return {
1465
+ a: a /1000.0, // km
1466
+ e: norm(e),
1467
+ i: i * RAD2DEG, // deg
1468
+ raan: raan * RAD2DEG, // deg
1469
+ w: w * RAD2DEG, // deg
1470
+ f: f * RAD2DEG, // deg
1471
+ };
1472
+ } catch (err) {
1473
+ console.error(err);
1474
+ return {};
1475
+ }
1476
+ };
1477
+
1478
+ /** Convert cartesian coordinates in an ECI (J2000, TEME, GCRF) frame to keplerian elements formatted as [ a, e, i, Ω, ω, θ ].
1479
+ * The gravitational parameter is assumed to be Earth's mu.
1480
+ * The function is a translation of Astro Library's OrbitalElementConverter.KeplerianToCartesian method.
1481
+ *
1482
+ * NOTE 1: input raan, argument of periapsis, and true anomaly are ALL adjusted to have a domain from 0 to 360!!!
1483
+ * NOTE 2: raan, argument of periapsis, and true anomaly are ALL in degrees!
1484
+ * NOTE 3: SMA (a) is in km
1485
+ *
1486
+ * @param {Array} elset Keplerian elements
1487
+ * @param {Number} mu The gravitational parameter for the celestial object that we orbit, in km based units
1488
+ * @return {Object} An object containing the position and velocity in *meter* based units
1489
+ */
1490
+ const keplerianToCartesian = (elset, mu = 398600.4418) => {
1491
+ const INFINITE_TOL = 1e-10;
1492
+ const ORBIT_TOL = 1e-10;
1493
+ try {
1494
+ const [a, e, iDeg, raanDeg, wDeg, fDeg] = elset;
1495
+ const i = iDeg * DEG2RAD;
1496
+ const raan = raanDeg * DEG2RAD;
1497
+ const w = wDeg * DEG2RAD;
1498
+ const f = fDeg * DEG2RAD;
1499
+
1500
+ const p = a * (1 - Math.pow(e, 2));
1501
+
1502
+ if (Math.abs(p) < INFINITE_TOL) {
1503
+ throw new Error("Cannot convert parabolic orbit.");
1504
+ }
1505
+
1506
+ const onePlusECos = 1 + e * Math.cos(f);
1507
+
1508
+ if (onePlusECos < ORBIT_TOL) {
1509
+ throw new Error("Orbital radius is large and may cause singularity.");
1510
+ }
1511
+
1512
+ const rad = p / onePlusECos;
1513
+ const cosPerAnom = Math.cos(w + f);
1514
+ const sinPerAnom = Math.sin(w + f);
1515
+ const cosInc = Math.cos(i);
1516
+ const sinInc = Math.sin(i);
1517
+ const cosRaan = Math.cos(raan);
1518
+ const sinRaan = Math.sin(raan);
1519
+ const sqrtGravP = Math.sqrt(mu / p);
1520
+ const cosAnomPlusE = Math.cos(f) + e;
1521
+ const sinAnom = Math.sin(f);
1522
+ const cosPer = Math.cos(w);
1523
+ const sinPer = Math.sin(w);
1524
+
1525
+ const r = {
1526
+ x: 1000.0 * rad * (cosPerAnom * cosRaan - cosInc * sinPerAnom * sinRaan),
1527
+ y: 1000.0 * rad * (cosPerAnom * sinRaan + cosInc * sinPerAnom * cosRaan),
1528
+ z: 1000.0 * rad * sinPerAnom * sinInc,
1529
+ };
1530
+
1531
+ const v = {
1532
+ x: 1000.0 * (sqrtGravP * cosAnomPlusE * (-sinPer * cosRaan - cosInc * sinRaan * cosPer)
1533
+ - sqrtGravP * sinAnom * (cosPer * cosRaan - cosInc * sinRaan * sinPer)),
1534
+ y: 1000.0 * (sqrtGravP * cosAnomPlusE * (-sinPer * sinRaan + cosInc * cosRaan * cosPer)
1535
+ - sqrtGravP * sinAnom * (cosPer * sinRaan + cosInc * cosRaan * sinPer)),
1536
+ z: 1000.0 * (sqrtGravP * (cosAnomPlusE * sinInc * cosPer - sinAnom * sinInc * sinPer)),
1537
+ };
1538
+
1539
+ return {r, v};
1540
+ } catch (err) {
1541
+ return {};
1542
+ }
1543
+ };
1544
+
1545
+ /**
1546
+ * Get LEO RPO data for a given target satellite and a set of potential threat satellites.
1547
+ * @param {String} line1, line 1 of the target satellite
1548
+ * @param {String} line2, line 2 of the target satellite
1549
+ * @param {Array<Objects>} sats, array of potential threat satellites and their Elsets
1550
+ * @param {Integer} startTime, start time of the analysis, Unix milliseconds
1551
+ * @param {Integer} endTime, end time of the analysis, Unix milliseconds
1552
+ * @return {Array} Array of RPO data objects
1553
+ */
1554
+ const getLeoRpoData = (line1, line2, sats, startTime, endTime) => {
1555
+ const results = [];
1556
+ const pSatRec = checkTle(line1, line2);
1557
+ if (!isDefined(pSatRec)) return results;
1558
+
1559
+ const start = new Date(startTime).getTime();
1560
+ const end = new Date(endTime).getTime();
1561
+ const pElset = {
1562
+ Line1: line1,
1563
+ Line2: line2,
1564
+ };
1565
+ const pEphem = prop(pElset, start, end, 10000);
1566
+
1567
+ sats.forEach( (s) => {
1568
+ const sEphem = prop(s, start, end, 10000);
1569
+ if (!isDefined(pEphem)
1570
+ || !isDefined(sEphem)
1571
+ || pEphem.length !== sEphem.length) {
1572
+ return;
1573
+ }
1574
+
1575
+ const aResult = {
1576
+ line1: s.Line1,
1577
+ line2: s.Line2,
1578
+ epoch: s.Epoch,
1579
+ name: s.CommonName,
1580
+ rank: s.Rank ?? "",
1581
+ satNo: s.SatNo,
1582
+ inclination: s.Inclination,
1583
+ raan: s.Raan,
1584
+ source: s.Source,
1585
+ sma: s.SemiMajorAxis,
1586
+ country: s.CountryId,
1587
+ flag: s.Flag,
1588
+ poca: 9999999999,
1589
+ toca: "",
1590
+ tocaString: "",
1591
+ planeDiff: 0,
1592
+ incDiff: s.incDiff,
1593
+ raanDiff: s.raanDiff,
1594
+ semiMajorDiff: s.semiMajorDiff,
1595
+ raanDrift: s.RaanPrecessionDegreesPerDay,
1596
+ };
1597
+
1598
+ const pv1 = {
1599
+ position: pEphem[0].p,
1600
+ velocity: pEphem[0].v,
1601
+ };
1602
+ const pv2 = {
1603
+ position: sEphem[0].p,
1604
+ velocity: sEphem[0].v,
1605
+ };
1606
+ aResult.di = angleBetweenPlanes(pv1, pv2);
1607
+ aResult.dv = planeChangeDeltaV(pv1, pv2);
1608
+ aResult.dv = {
1609
+ i: Math.round(aResult.dv.i*1000)/1000,
1610
+ c: Math.round(aResult.dv.c*1000)/1000,
1611
+ }; // Round to 3 decimals
1612
+
1613
+ // Find the distance at each time step
1614
+ for (let i=0; i<pEphem.length; i++) {
1615
+ const distKm = dist(pEphem[i].p, sEphem[i].p);
1616
+ if (distKm < aResult.poca) {
1617
+ aResult.poca = distKm;
1618
+ aResult.toca = new Date(pEphem[i].t).toISOString();
1619
+ aResult.tocaString = getTimeDifference(aResult.toca);
1620
+ }
1621
+ }
1622
+
1623
+ // Calc danger score (account for di of 0)
1624
+ aResult.danger = (aResult.di === 0
1625
+ || aResult.poca === 0) ? 1000 : 1/(aResult.di*aResult.poca);
1626
+ results.push(aResult);
1627
+ });
1628
+ return results;
1629
+ };
1630
+
1631
+
1632
+ /**
1633
+ * Get GEO RPO data for a given target satellite and a set of potential threat satellites.
1634
+ * @param {String} line1, line 1 of the target satellite
1635
+ * @param {String} line2, line 2 of the target satellite
1636
+ * @param {Array<Objects>} sats, array of potential threat satellites and their Elsets
1637
+ * @param {Integer} startTime, start time of the analysis, Unix milliseconds
1638
+ * @param {Integer} endTime, end time of the analysis, Unix milliseconds
1639
+ * @param {Integer} lonTime, datetime to analyze longitude and lon drift at, Unix milliseconds
1640
+ * @return {Array<Objects>}, Array of RPO data objects
1641
+ */
1642
+ const getGeoRpoData = (line1, line2, sats, startTime, endTime, lonTime) => {
1643
+ const start = new Date(startTime).getTime();
1644
+ const end = new Date(endTime).getTime();
1645
+ const pEphem = prop({
1646
+ Line1: line1,
1647
+ Line2: line2,
1648
+ }, start, end, 60000);
1649
+
1650
+ const lonEvalTime = lonTime ? new Date(lonTime) : new Date(end);
1651
+
1652
+ const pLonAndDrift = getLonAndDrift(line1, line2, lonEvalTime);
1653
+
1654
+ const getLonDiff = (lon1, drift1, lon2, drift2) => {
1655
+ const closing = lon1 > lon2
1656
+ ? (drift1 * drift2 > 0 && drift1 < drift2) || (drift1 * drift2 < 0 && drift1 < 0) // Sat1 is more Eastward
1657
+ : lon2 > lon1
1658
+ ? (drift1 * drift2 > 0 && drift2 < drift1) || (drift1 * drift2 < 0 && drift1 > 0) // Sat2 is more Eastward
1659
+ : false;
1660
+ const difference = drift1 - drift2;
1661
+ return closing ? -1 * Math.abs(difference) : Math.abs(difference);
1662
+ };
1663
+
1664
+ const results = [];
1665
+ sats.forEach((s) => {
1666
+ const aResult = {
1667
+ line1: s.Line1,
1668
+ line2: s.Line2,
1669
+ epoch: s.Epoch,
1670
+ name: s.CommonName,
1671
+ rank: s.Rank ?? "",
1672
+ satNo: s.SatNo,
1673
+ source: s.Source,
1674
+ country: s.CountryId,
1675
+ flag: s.Flag,
1676
+ poca: 9999999999,
1677
+ toca: "",
1678
+ tocaString: "",
1679
+ incDiff: s.incDiff,
1680
+ longitude: null,
1681
+ lonDiff: null,
1682
+ relativeDrift: null,
1683
+ di: null,
1684
+ dv: null,
1685
+ danger: null,
1686
+ };
1687
+
1688
+ const sEphem = prop({
1689
+ Line1: aResult.line1,
1690
+ Line2: aResult.line2,
1691
+ }, start, end, 60000);
1692
+ if (!isDefined(pEphem)
1693
+ || !isDefined(sEphem)
1694
+ || pEphem.length !== sEphem.length) {
1695
+ return;
1696
+ }
1697
+ const sLonAndDrift = getLonAndDrift(s.Line1, s.Line2, lonEvalTime);
1698
+ aResult.longitude = (sLonAndDrift.longitude + 360) % 360; // Normalize to 0-360
1699
+
1700
+ const lonDiff = Math.abs(
1701
+ (pLonAndDrift.longitude + 360.0) % 360
1702
+ - (sLonAndDrift.longitude + 360.0) % 360.0,
1703
+ );
1704
+ // To ensure that the smallest "short-way" lon diff is considered
1705
+ aResult.lonDiff = Math.min(lonDiff, 360 - lonDiff);
1706
+
1707
+ aResult.relativeDrift = getLonDiff(
1708
+ pLonAndDrift.longitude,
1709
+ pLonAndDrift.lonDriftDegreesPerDay,
1710
+ sLonAndDrift.longitude,
1711
+ sLonAndDrift.lonDriftDegreesPerDay);
1712
+ const pv1 = {
1713
+ position: pEphem[0].p,
1714
+ velocity: pEphem[0].v,
1715
+ };
1716
+ const pv2 = {
1717
+ position: sEphem[0].p,
1718
+ velocity: sEphem[0].v,
1719
+ };
1720
+ aResult.di = angleBetweenPlanes(pv1, pv2);
1721
+ aResult.dv = {
1722
+ i: Math.round(planeChangeDeltaV(pv1, pv2).i*1000)/1000,
1723
+ c: Math.round(planeChangeDeltaV(pv1, pv2).c*1000)/1000,
1724
+ }; // Round to 3 decimals
1725
+
1726
+ for (let i=0; i<pEphem.length; i++) {
1727
+ const distKm = dist(pEphem[i].p, sEphem[i].p);
1728
+ if (distKm < aResult.poca) {
1729
+ aResult.poca = distKm;
1730
+ aResult.toca = new Date(pEphem[i].t).toISOString();
1731
+ aResult.tocaString = getTimeDifference(aResult.toca);
1732
+ }
1733
+ }
1734
+ // Calc danger score (account for di of 0)
1735
+ aResult.danger = (aResult.di === 0
1736
+ || aResult.poca === 0) ? 1000 : 1/(aResult.di*aResult.poca);
1737
+
1738
+ results.push(aResult);
1739
+ });
1740
+
1741
+ return results;
1742
+ };
1743
+
1744
+ const getGeoShadowZones = (time, accuracySecondsDeg=0.00416*100) => {
1745
+ // Accuracy is 86400 pooints on a 360 circle (i.e. 0.00416 deg per second)
1746
+
1747
+ // Define the TLE parameters of a template TLE in GEO
1748
+ const tleLine1 = "1 00000U 00000A 24079.98445361 -.00000000 00000-0 -00000-0 0 00000";
1749
+ const tleLine2Temp = "2 00000 000.0000 000.0000 0000000 000.0000 XXX.XXXX 01.00000000000000";
1750
+ // Number of steps for the mean anomaly
1751
+ const steps = 360 / accuracySecondsDeg;
1752
+ // Loop over the range of mean anomalies
1753
+ const res = [];
1754
+ for (let i = 0; i < steps; i++) {
1755
+ // Calculate the mean anomaly for this step
1756
+ const meanAnomaly = i * accuracySecondsDeg;
1757
+ // Format the mean anomaly into the TLE line
1758
+ // Round the mean anomaly to two decimal places
1759
+ const roundedMeanAnomaly = meanAnomaly.toFixed(4);
1760
+ // Format the mean anomaly into the TLE line
1761
+ const tleLine2 = tleLine2Temp.replace("XXX.XXXX", roundedMeanAnomaly.padStart(8, "0"));
1762
+ // Generate the satrec object
1763
+ const p = propagate(twoline2satrec(tleLine1, tleLine2), time).position;
1764
+ res.push({
1765
+ ecl: getEclipseStatus(time, [p.x, p.y, p.z]),
1766
+ geolon: eciToGeodetic(p, gstime(time)).longitude*RAD2DEG,
1767
+ });
1768
+ }
1769
+ // Find the sun zone, umbra zone, and two penumbra zones.
1770
+ const ints = [];
1771
+
1772
+ // Open the first zone. Note that this may not be the start of this zone but somewhere in the middle.
1773
+ // It's just the start of the mean anomaly seen as a circular buffer.
1774
+ ints.push({
1775
+ ecl: res[0].ecl,
1776
+ });
1777
+
1778
+ for (let i=1; i<=res.length-1; i++) {
1779
+ // Compare with the previous eclipse state
1780
+ if (res[i].ecl!==res[i-1].ecl) {
1781
+ // Close the previous zone
1782
+ ints[ints.length-1].stopDeg = res[i-1].geolon;
1783
+
1784
+ // Open a new zone
1785
+ ints.push({
1786
+ ecl: res[i].ecl,
1787
+ startDeg: res[i].geolon,
1788
+ });
1789
+ }
1790
+ }
1791
+ // With this process, the first interval will have a stop lon but not a start lon, and
1792
+ // the final interval will have a start lon but not a stop lon.
1793
+ // Stitch the first and last intervals together.
1794
+
1795
+ // If not dates are pushed on the first interval, it means it never closed, so it's a fully lit GEO belt.
1796
+ // If there's only sun, return null values
1797
+ if ( ints.length===1
1798
+ && ints.filter((x)=>x.ecl === "SUN").length===1) {
1799
+ return {
1800
+ penStartWestLon: null,
1801
+ penStartEastLon: null,
1802
+ };
1803
+ }
1804
+
1805
+ ints[0].startDeg = ints[ints.length-1].startDeg;
1806
+ ints.pop();
1807
+
1808
+ if (
1809
+ ints.filter((x)=>x.ecl === "UMBRA").length===0
1810
+ && ints.filter((x)=>x.ecl === "PENUMBRA").length>0) {
1811
+ // If there's no umbra but penumbra exists, return the perumbra values
1812
+
1813
+ // Extract the penumbra interval. In the absence of umbra, a single penumbra interval should exist.
1814
+ const penumbraInt = ints.filter((x)=>x.ecl === "PENUMBRA")[0];
1815
+ return {
1816
+ penStartWestLon: wrapToRange(penumbraInt.startDeg, 0, 360),
1817
+ penStartEastLon: wrapToRange(penumbraInt.stopDeg, 0, 360),
1818
+ };
1819
+ } else {
1820
+ // If sun, umbra, penumbra intervals exist
1821
+
1822
+ const umbra = ints.filter((x)=>x.ecl === "UMBRA")[0];
1823
+
1824
+ const umbraStart360 = wrapToRange(umbra.startDeg, 0, 360);
1825
+ const umbraStop360 = wrapToRange(umbra.stopDeg, 0, 360);
1826
+ // Assuming a spherical earth for this purpose, the two penumbra intervals should be equal.
1827
+ // Compute the angle of one of the penumbra intervals, the one that does not contain the disconituity.
1828
+ const penumbraInts = ints.filter((x)=>x.ecl === "PENUMBRA");
1829
+ const chosenPenumbraInt = penumbraInts[0].startDeg > penumbraInts[0].stopDeg
1830
+ ? penumbraInts[1] : penumbraInts[0];
1831
+ const penumbraAngle = chosenPenumbraInt.stopDeg - chosenPenumbraInt.startDeg;
1832
+ const penumbraStart = umbraStart360 - penumbraAngle;
1833
+ const penumbraStop = umbraStop360 + penumbraAngle;
1834
+
1835
+ return {
1836
+ penStartWestLon: penumbraStart,
1837
+ penStartEastLon: penumbraStop,
1838
+ };
1839
+ }
1840
+ };
1841
+
1842
+ /** Returns the light intervals of an arbitrary satellite in strict GEO orbit.
1843
+ *
1844
+ * These intervals are generally equal in duration for all other GEO satellites.
1845
+ *
1846
+ * The use-case for this function is to help with the drawing of umbra/penumbra zones in the waterfall plot.
1847
+ *
1848
+ * @param {Date} time The time in UTC
1849
+ * @param {Number} durationSeconds The duration in seconds of the analysis interval. Defaults to 1 day
1850
+ * @param {Number} accuracySeconds The accuracy of the calulations, defaults to 1 sec. The higher, the faster.
1851
+ * @return {Array} An array of objects, each of which represents a contiguous interval of sunlight, penumbra, or umbra.
1852
+ */
1853
+ const getGeoLightIntervals = (time, durationSeconds=86400, accuracySeconds=10.0) => {
1854
+ const endTime = new Date(time.getTime() + 1000.0*durationSeconds);
1855
+ // Create an artificial satellite on GEO
1856
+ const satrec = twoline2satrec(
1857
+ "1 00000U 00000A 24079.98445361 -.00000000 00000-0 -00000-0 0 00000",
1858
+ "2 00000 000.0000 000.0000 0000000 000.0000 000.0000 01.00299372000000",
1859
+ );
1860
+ const res = [];
1861
+ while (time.getTime() <= endTime.getTime()) {
1862
+ const p = propagate(satrec, time).position;
1863
+ res.push({
1864
+ t: time.toISOString(),
1865
+ ecl: getEclipseStatus(time, [p.x, p.y, p.z]),
1866
+ geolon: eciToGeodetic(p, gstime(time)).longitude,
1867
+ });
1868
+ time = new Date(time.getTime() + accuracySeconds*1000);
1869
+ }
1870
+ // Group to intervals
1871
+ const ints = [];
1872
+
1873
+ // Create and open the first interval
1874
+ ints.push({
1875
+ ecl: res[0].ecl,
1876
+ start: res[0].t,
1877
+ });
1878
+
1879
+ for (let i=1; i<=res.length-1; i++) {
1880
+ if (res[i].ecl!==res[i-1].ecl) {
1881
+ // Close the previous interval
1882
+ ints[ints.length-1].end = res[i-1].t;
1883
+ // Create a new open interval
1884
+ ints.push({
1885
+ ecl: res[i].ecl,
1886
+ start: res[i].t,
1887
+ });
1888
+ }
1889
+ }
1890
+ // Close the last interval
1891
+ ints[ints.length-1].end = res[res.length-1].t;
1892
+
1893
+ return {
1894
+ ints: ints,
1895
+ meanLonDeg: res.reduce((acc, obj) => acc + obj.geolon, 0) / res.length,
1896
+ };
1897
+ };
1898
+
1899
+ /** Find if a satellite is in Sun, Umbra, or Penumbra.
1900
+ *
1901
+ * @param {Date} time The time instant of the satellite state
1902
+ * @param {Array} pos The satellite position vector (assumed to be in an inertial frame)
1903
+ * @return {String} SUN if in sunlight, UMBRA if in umbra, PENUMBRA if in penumbra
1904
+ */
1905
+ const getEclipseStatus = (time, pos) => {
1906
+ let shadow = "SUN";
1907
+ const aPen = 0.269007205 * DEG2RAD;
1908
+ const aUmb = 0.264121687 * DEG2RAD;
1909
+ const rp = WGS72_EARTH_EQUATORIAL_RADIUS_KM; // Using WGS72 Earth equatorial radius (SGP4 standard) because this function is predominently used by Elset calculations.
1910
+ const rS = getSunDirection(time);
1911
+ const rSun = [rS.x, rS.y, rS.z];
1912
+ const r = pos;
1913
+ const rMag = norm(r);
1914
+
1915
+ if (dot(rSun, r) < 0) {
1916
+ const rSunN = multiply(rSun, -1);
1917
+ const rSunNnorm = normalize({x: rSunN[0], y: rSunN[1], z: rSunN[2]});
1918
+ const rNorm = normalize({x: r[0], y: r[1], z: r[2]});
1919
+ const angle = getAngle(
1920
+ [rSunNnorm.x, rSunNnorm.y, rSunNnorm.z],
1921
+ [rNorm.x, rNorm.y, rNorm.z],
1922
+ );
1923
+ const satHoriz = rMag * Math.cos(angle);
1924
+ const satVert = rMag * Math.sin(angle);
1925
+ const x = rp / Math.sin(aUmb);
1926
+ const penVert = Math.tan(aPen)*(x+satHoriz);
1927
+ if (satVert <= penVert) {
1928
+ shadow = "PENUMBRA";
1929
+ const y = rp / Math.sin(aUmb);
1930
+ const umbVert = Math.tan(aUmb)*(y-satHoriz);
1931
+ if (satVert <= umbVert) {
1932
+ shadow = "UMBRA";
1933
+ }
1934
+ }
1935
+ }
1936
+ return shadow;
1937
+ };
1938
+
1939
+ /**
1940
+ * Brute-forces GEO crossing points where satellite crosses GEO altitude
1941
+ * @param {*} propagateBetween async propagation function
1942
+ * @param {*} start start time
1943
+ * @param {*} end end time
1944
+ * @param {*} stepMs step in milliseconds
1945
+ * @return {Promise<Array>} array of objects with time (ms), altitude, and direction of crossing
1946
+ */
1947
+ const calculateGeoCrossingTimes = async (propagateBetween, start, end, stepMs = 1000) => {
1948
+ const startTime = new Date(start).getTime();
1949
+ const endTime = new Date(end).getTime();
1950
+
1951
+ let lastAltState = 0; // -1 for below, 0 for none, 1 for above
1952
+
1953
+ const crossings = [];
1954
+ const points = await propagateBetween(startTime, endTime, stepMs);
1955
+
1956
+ for (const pv of points) {
1957
+ if (!isDefined(pv)) continue;
1958
+ const time = pv.time;
1959
+
1960
+ const dist = norm(posToArray(pv.position)); // distance from center of the earth, km
1961
+ const altitude = dist - WGS72_EARTH_EQUATORIAL_RADIUS_KM; // altitude, km
1962
+
1963
+ const newAltState = altitude >= GEO_ALTITUDE_KM ? 1 : -1;
1964
+
1965
+ if (lastAltState !== 0 && lastAltState !== newAltState) { // crossed GEO alt
1966
+ const lon = wrapToRange(
1967
+ eciToGeodetic(
1968
+ pv.position,
1969
+ gstime(new Date(time))).longitude*RAD2DEG,
1970
+ 0, 360);
1971
+
1972
+ crossings.push({
1973
+ t: time,
1974
+ alt: altitude,
1975
+ direction: newAltState > 0 ? 1 : -1, // 1 means we crossed going above, -1 crossed going below1
1976
+ lon,
1977
+ });
1978
+ }
1979
+
1980
+ lastAltState = newAltState;
1981
+ }
1982
+
1983
+ return crossings;
1984
+ };
1985
+
1986
+
1987
+ /** A function that calculates the next time of an apogee and perigee for an orbit,
1988
+ * and the geodetic longitude at which those occur.
1989
+ *
1990
+ * The 'next' times are with respect to an input date.
1991
+ *
1992
+ * Steps:
1993
+ * 1. Propagate to the reference time.
1994
+ * 2. Get the Cartesian and Keplerian state at the reference time.
1995
+ * 3. At the reference time, compute Eccentric and Mean anomalies, and the time since periapsis.
1996
+ * 4. Compute the time of the next periapsis. Repeat for apoapsis.
1997
+ *
1998
+ * @param {Object} pv Initial position/velocity object
1999
+ * @param {Function<Object>} propagateTo async Date -> PV propagation method for SV or TLE
2000
+ * @param {Date} time The time after which to compute the imediately next apogee and perigee times
2001
+ * @param {Boolean} findApogee Propagate for apogee longitude and state
2002
+ * @param {Boolean} findPerigee Propagate for perigee longitude and state
2003
+ * @return {Object} The object with the next apoapsis/periapsis times, and the geodetic longitudes on which they occur.
2004
+ */
2005
+ const calculateNextApogeePerigeeTimesWithPropagation
2006
+ = async (pv, propagateTo, time, findApogee=true, findPerigee=true) => {
2007
+ const r = multiply([pv.position.x, pv.position.y, pv.position.z],
2008
+ 1000.0);
2009
+ const v = multiply([pv.velocity.x, pv.velocity.y, pv.velocity.z],
2010
+ 1000.0);
2011
+ const el = cartesianToKeplerian(r, v);
2012
+
2013
+ // Compute Eccentric Anomaly from True Anomaly and Eccentricity
2014
+ const E = 2 * Math.atan2(Math.tan(el.f/2 * DEG2RAD) * Math.sqrt(1-el.e), Math.sqrt(1+el.e));
2015
+
2016
+ // Compute Mean Anomaly from Eccentric Anomaly and Eccentricity
2017
+ const M = E - (el.e)*Math.sin(E);
2018
+
2019
+ // Mean motion in radians per second
2020
+ const mu = 3.986004418e14; // (m^3)/(s^2) WGS-84 Earth Mu
2021
+ const n = Math.sqrt(mu/Math.pow((el.a*1000.0), 3));
2022
+
2023
+ // Orbit Period
2024
+ const periodSecs = 2*Math.PI/n;
2025
+
2026
+ // Compute Time of Flight (ToF) since last Periapsis
2027
+ const tofSincePeriapsisSecs = M / n;
2028
+
2029
+ // Time until next periapsis
2030
+ let timeToNextPeriapsisSecs = periodSecs - Math.abs(tofSincePeriapsisSecs);
2031
+
2032
+ if (tofSincePeriapsisSecs < 0) {
2033
+ timeToNextPeriapsisSecs = Math.abs(tofSincePeriapsisSecs);
2034
+ }
2035
+
2036
+ // For next apoapsis, we check the true anomaly of the reference date
2037
+ let timeToNextApoapsisSecs = 0;
2038
+ if (el.f >= 180.0) {
2039
+ // Satellite is past the apoapsis and before the next periapsis
2040
+ timeToNextApoapsisSecs = timeToNextPeriapsisSecs + periodSecs/2;
2041
+ } else {
2042
+ // Satellite is past the periapsis and before the next apoapsis
2043
+ timeToNextApoapsisSecs = periodSecs/2 - tofSincePeriapsisSecs;
2044
+ }
2045
+
2046
+ const nextPeriapsisTimeUtc = new Date(time);
2047
+ nextPeriapsisTimeUtc.setSeconds(new Date(time).getSeconds() + timeToNextPeriapsisSecs);
2048
+
2049
+ let periapsisPV = null;
2050
+ let periLon = null;
2051
+ if (findPerigee) {
2052
+ periapsisPV = await propagateTo(nextPeriapsisTimeUtc);
2053
+ periLon = wrapToRange(
2054
+ eciToGeodetic(
2055
+ periapsisPV.position,
2056
+ gstime(nextPeriapsisTimeUtc)).longitude*RAD2DEG,
2057
+ 0, 360);
2058
+ }
2059
+
2060
+ const nextApoapsisTimeUtc
2061
+ = new Date(new Date(time).getTime() + (timeToNextApoapsisSecs * 1000));
2062
+
2063
+ let apoapsisPV = null;
2064
+ let apoLon = null;
2065
+ if (findApogee) {
2066
+ apoapsisPV = await propagateTo(nextApoapsisTimeUtc);
2067
+ apoLon = wrapToRange(
2068
+ eciToGeodetic(
2069
+ apoapsisPV.position,
2070
+ gstime(nextApoapsisTimeUtc)).longitude*RAD2DEG,
2071
+ 0, 360);
2072
+ }
2073
+
2074
+ const res = {
2075
+ nextApoapsisTimeUtc: nextApoapsisTimeUtc.toISOString(),
2076
+ nextApoapsisLonDeg: apoLon,
2077
+ nextApoapsisPV: apoapsisPV,
2078
+ nextPeriapsisTimeUtc: nextPeriapsisTimeUtc.toISOString(),
2079
+ nextPeriapsisLonDeg: periLon,
2080
+ nextPeriapsisPV: periapsisPV,
2081
+ };
2082
+ return res;
2083
+ };
2084
+
2085
+ /** A function that calculates the next time of an apogee and perigee for an orbit,
2086
+ * and the geodetic longitude at which those occur.
2087
+ *
2088
+ * The 'next' times are with respect to an input date.
2089
+ *
2090
+ * Steps:
2091
+ * 1. Propagate the TLE to the reference time.
2092
+ * 2. Get the Cartesian and Keplerian state at the reference time.
2093
+ * 3. At the reference time, compute Eccentric and Mean anomalies, and the time since periapsis.
2094
+ * 4. Compute the time of the next periapsis. Repeat for apoapsis.
2095
+ *
2096
+ * @param {String} line1 TLE line 1
2097
+ * @param {String} line2 TLE line 2
2098
+ * @param {Date} time The time after which to compute the imediately next apogee and perigee times
2099
+ * @param {Boolean} findApogee Propagate for apogee longitude and state
2100
+ * @param {Boolean} findPerigee Propagate for perigee longitude and state
2101
+ * @return {Object} The object with the next apoapsis/periapsis times, and the geodetic longitudes on which they occur.
2102
+ */
2103
+ const calculateNextApogeePerigeeTimes
2104
+ = async (line1, line2, time, findApogee=true, findPerigee=true) => {
2105
+ const satrec = twoline2satrec(line1, line2);
2106
+ const propagateTo = (t) => propagate(satrec, new Date(t));
2107
+ const pv = propagateTo(time);
2108
+ return await calculateNextApogeePerigeeTimesWithPropagation(
2109
+ pv, propagateTo, time, findApogee, findPerigee,
2110
+ );
2111
+ };
2112
+
2113
+ /**
2114
+ * Computes whether target satellite is leading the primary satellite
2115
+ * @param {*} primaryEphem propagated ephemeris for primary satellite
2116
+ * @param {*} targetEphem propagated ephemeris for target satellite
2117
+ * @return {Boolean} true if target is leading primary
2118
+ */
2119
+ const isTargetLeading = (primaryEphem, targetEphem) => {
2120
+ const pPos = posToArray(primaryEphem.p);
2121
+ const tPos = posToArray(targetEphem.p);
2122
+
2123
+ const angularMomentum = cross(pPos, posToArray(primaryEphem.v)); // up if ref moving CCW, down if CW
2124
+ const refTargetCross = cross(pPos, tPos); // up if CCW, down if CW (target from ref)
2125
+
2126
+ if (norm(refTargetCross) === 0) {
2127
+ return 1;
2128
+ }
2129
+
2130
+ // if angular momentum and ref target cross product are in same hemisphere (angle < 90 deg)
2131
+ // then the target is leading the ref, otherwise it is trailing
2132
+ const targetLeading = getAngle(angularMomentum, refTargetCross) <= Math.PI / 2;
2133
+ return targetLeading;
2134
+ };
2135
+
2136
+ /**
2137
+ * Computes phase offset (in degrees) for a given primary (reference) satellite and a target satellite.
2138
+ * Positive phase difference means the target is leading the satellite, negative means trailing
2139
+ * @param {*} pSatRec satRec for primary satellite
2140
+ * @param {*} tSatRec satRec for target satellite
2141
+ * @param {*} time time to evaluate offset at
2142
+ * @return {Number} phase offset in degrees
2143
+ */
2144
+ const computePhaseDiff = (pSatRec, tSatRec, time) => {
2145
+ const pEphem = propTo(pSatRec, time);
2146
+ const tEphem = propTo(tSatRec, time);
2147
+
2148
+ if (!isDefined(pEphem) || !isDefined(tEphem)) {
2149
+ return null;
2150
+ }
2151
+
2152
+ const pPos = posToArray(pEphem.p);
2153
+ const tPos = posToArray(tEphem.p);
2154
+
2155
+ const targetLeading = isTargetLeading(pEphem, tEphem);
2156
+
2157
+ const phaseAngleAbs = getAngle(pPos, tPos) * RAD2DEG;
2158
+ const phaseDiff = phaseAngleAbs * (targetLeading ? 1 : -1); // positive for leading
2159
+
2160
+ return phaseDiff;
2161
+ };
2162
+
2163
+ /**
2164
+ * Computes orbital period in minutes, given TLE line 2
2165
+ * @param {String} line2 TLE line 2 containing mean motion
2166
+ * @return {Number} orbital period in minutes
2167
+ */
2168
+ const getOrbitalPeriod = (line2) => {
2169
+ // apparently mean motion should be obtained through TLE instead of satellite.js SatRec (see getElsetUdlFromTle)
2170
+ const meanMotion = parseFloat(line2.slice(52, 63)); // revs per day
2171
+ const period = (1 / meanMotion) * 24.0 * 60.0; // period in minutes
2172
+ return period;
2173
+ };
2174
+
2175
+ /**
2176
+ * Computes phase offset, delta phase offset, time offset, and delta time offset
2177
+ * data for a given primary (reference) satellite and a target satellite.
2178
+ * Positive phase difference means the target is leading the satellite, negative means trailing
2179
+ * @param {String} pLine1, line 1 of the primary (reference) satellite
2180
+ * @param {String} pLine2, line 2 of the primary (reference) satellite
2181
+ * @param {String} tLine1, line 1 of the target satellite
2182
+ * @param {String} tLine2, line 2 of the target satellite
2183
+ * @param {Integer} time, time of the analysis, Unix milliseconds
2184
+ * @return {Object} Data object
2185
+ */
2186
+ const calculateLeoPhaseDifference = (pLine1, pLine2, tLine1, tLine2, time) => {
2187
+ const pSatRec = twoline2satrec(pLine1, pLine2);
2188
+ const tSatRec = twoline2satrec(tLine1, tLine2);
2189
+
2190
+ const diffNow = computePhaseDiff(pSatRec, tSatRec, time); // degrees
2191
+ const diffFuture = computePhaseDiff(pSatRec, tSatRec, time + MILLIS_PER_DAY);
2192
+
2193
+ if (!isDefined(diffNow) || !isDefined(diffFuture)) {
2194
+ return null;
2195
+ }
2196
+
2197
+ const targetLeading = diffNow > 0;
2198
+
2199
+ const period = getOrbitalPeriod(pLine2) * 60; // period in seconds
2200
+
2201
+ // since time is how long target will take to catch up to reference's current phase,
2202
+ // positive phase (target leading ref) means negative time
2203
+ let timeOffset = -diffNow * (period / 360); // period/360 gives seconds per degree
2204
+ let timeOffsetFuture = -diffFuture * (period / 360);
2205
+
2206
+ let deltaPhase = diffFuture - diffNow;
2207
+
2208
+ // if we go from 170 to 190, it becomes (-170) - 170 = -340 when the actual change is +20
2209
+ // so add 360 if < -180
2210
+ // if we go from 190 to 170, it becomes 170 - (-170) = 340 when the actual change is -20
2211
+ // so subtract 360 if > 180
2212
+ if (deltaPhase < -180) {
2213
+ deltaPhase += 360;
2214
+ timeOffsetFuture = -(diffFuture+360) * (period / 360);
2215
+ } else if (deltaPhase > 180) {
2216
+ deltaPhase -= 360;
2217
+ timeOffset = -(diffNow+360) * (period / 360);
2218
+ }
2219
+
2220
+ const deltaTime = timeOffsetFuture - timeOffset;
2221
+
2222
+ return {
2223
+ phaseOffset: diffNow,
2224
+ deltaPhase,
2225
+ timeOffset,
2226
+ deltaTime,
2227
+ targetLeading,
2228
+ };
2229
+ };
2230
+
2231
+ /**
2232
+ * Get LEO waterfall data for a given primary satellite and a set of secondary satellites.
2233
+ * Takes a list of elsets for each satellite, ordered in increasing epoch
2234
+ * @param {Array<Array<Object>>} elsets, list of satellites, each as a list of elsets, with primary sat as first element
2235
+ * @param {Integer} startTime, start time of the analysis, Unix milliseconds
2236
+ * @param {Integer} endTime, end time of the analysis, Unix milliseconds
2237
+ * @param {Integer} stepMs, step time of the analysis in milliseconds, default 10s
2238
+ * @return {Array} Array of data objects
2239
+ */
2240
+ const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2241
+ const results = [];
2242
+
2243
+ const start = new Date(startTime).getTime();
2244
+ const end = new Date(endTime).getTime();
2245
+
2246
+ const currentIndices = elsets.map((sat) => 0);
2247
+ const breakpoints = [{time: start, satIndex: -1}, {time: end, satIndex: -1}];
2248
+ elsets.forEach((satElsets, i) => {
2249
+ breakpoints.push(...satElsets.map((e, j) => ({
2250
+ time: new Date(e.Epoch).getTime(),
2251
+ satIndex: i,
2252
+ elsetIndex: j,
2253
+ })).filter((b) => b.time > start && b.time < end)); // ensure breakpoints are inside time range
2254
+ });
2255
+ breakpoints.sort((a, b) => a.time - b.time);
2256
+
2257
+ const satEphems = elsets.map((sat) => []);
2258
+
2259
+ for (let i = 0; i < breakpoints.length-1; i++) {
2260
+ const bkpoint = breakpoints[i];
2261
+ // if we start at breakpoints, it's irregular (since the epochs aren't in lockstep)
2262
+ // we can force it to Math.ceil(segmentStart/stepMs)*stepMs for even steps but since the discrepancy
2263
+ // is less than 10s in a plot over multiple days it's rather negligible
2264
+ // also this makes it easier since we can check if its an epoch based on the segmentStart
2265
+ const segmentStart = bkpoint.time;
2266
+ const segmentEnd = breakpoints[i+1].time;
2267
+
2268
+ if (bkpoint.satIndex >= 0) { // this is a satellite's elset's epoch
2269
+ currentIndices[bkpoint.satIndex] = bkpoint.elsetIndex;
2270
+ }
2271
+
2272
+ currentIndices.forEach((elsetIndex, satIndex) => {
2273
+ const elset = elsets[satIndex][elsetIndex];
2274
+ const epoch = new Date(elset.Epoch).getTime();
2275
+ const ephem = prop(elset, segmentStart, segmentEnd, stepMs);
2276
+ satEphems[satIndex].push(...ephem.map((point, pointInd) => {
2277
+ const osculatingElements = cartesianToKeplerian(
2278
+ multiply(posToArray(point.p), 1000.0), // km to m
2279
+ multiply(posToArray(point.v), 1000.0), // km/s to m/s
2280
+ );
2281
+ return {
2282
+ ...point,
2283
+ isEpoch: pointInd === 0 && bkpoint.satIndex === satIndex,
2284
+ epoch: epoch,
2285
+ satNo: elset.SatNo,
2286
+ name: elset.CommonName,
2287
+ source: elset.Source,
2288
+ raan: osculatingElements.raan, // deg
2289
+ raanPrecession: elset.RaanPrecessionDegreesPerDay,
2290
+ inclination: osculatingElements.i, // deg
2291
+ };
2292
+ }));
2293
+ });
2294
+ }
2295
+
2296
+ const pEphem = satEphems[0];
2297
+
2298
+ satEphems.forEach( (sEphem, satIndex) => {
2299
+ // Find the distance at each time step
2300
+ for (let i = 0; i < pEphem.length; i++) {
2301
+ const pEphemPoint = pEphem[i];
2302
+ const sEphemPoint = sEphem[i];
2303
+
2304
+ const direction = isTargetLeading(pEphemPoint, sEphemPoint) ? 1 : -1;
2305
+ const distKm = direction * dist(pEphemPoint.p, sEphemPoint.p);
2306
+
2307
+ const raanDiff = getRaanDiff(sEphemPoint.raan, pEphemPoint.raan, false);
2308
+ const incDiff = getIncDiff(sEphemPoint.inclination, pEphemPoint.inclination, false);
2309
+
2310
+ const time = new Date(pEphemPoint.t);
2311
+ const epoch = new Date(sEphemPoint.epoch).toISOString();
2312
+
2313
+ let catsAngle = null;
2314
+ let targetVisibility = null;
2315
+
2316
+ if (satIndex > 0) { // not the primary
2317
+ if (areCoordsEqual(pEphemPoint.p, sEphemPoint.p)) {
2318
+ catsAngle = 180; // they're in the same spot, so just say secondary can see primary
2319
+ } else {
2320
+ catsAngle = angleBetween3DCoords(
2321
+ pEphemPoint.p,
2322
+ sEphemPoint.p,
2323
+ getSunDirection(time),
2324
+ );
2325
+ }
2326
+
2327
+ const earthEclipsed = doesLineSegmentIntersectEarth(pEphemPoint.p, sEphemPoint.p);
2328
+ const targetShadow = getEclipseStatus(time, posToArray(sEphemPoint.p));
2329
+ targetVisibility = earthEclipsed ? "EARTH ECLIPSED" : targetShadow;
2330
+ }
2331
+
2332
+ results.push({
2333
+ name: sEphemPoint.name,
2334
+ satNo: sEphemPoint.satNo,
2335
+ epoch: epoch,
2336
+ isEpoch: sEphemPoint.isEpoch,
2337
+ distance: distKm,
2338
+ time: time.toISOString(),
2339
+ source: sEphemPoint.source,
2340
+ raan: sEphemPoint.raan,
2341
+ raanDiff: raanDiff,
2342
+ raanPrecession: sEphemPoint.raanPrecession,
2343
+ inclination: sEphemPoint.inclination,
2344
+ incDiff: incDiff,
2345
+ primary: pEphemPoint,
2346
+ catsAngle,
2347
+ targetVisibility,
2348
+ });
2349
+ }
2350
+ });
2351
+ return results;
2352
+ };
2353
+
2354
+ /**
2355
+ * Returns the minimum deltaV for a Lambert maneuver between two satellites,
2356
+ * within the given time range.
2357
+ * @param {*} sat1Tle The initial TLE satrec
2358
+ * @param {*} sat2Tle The final TLE satrec
2359
+ * @param {Date} startTime The start time of the maneuver as unix timestamp in milliseconds
2360
+ * @return {Object} An object containing the maneuver properties
2361
+ */
2362
+ const getInterceptRendezvousMinDv = (sat1Tle, sat2Tle, startTime) =>{
2363
+ const mu = 3.986004415e5; // km based
2364
+
2365
+ let minDv = Infinity;
2366
+ let revNum = 0;
2367
+ let minIntercept = [0, 0, 0]; // The minimum dv to intercept the target.
2368
+ let minDt = 0; // The time of flight to intercept the target.
2369
+ let interceptPvEci = {p: [0, 0, 0], v: [0, 0, 0]}; // The position and velocity of the interceptor and intercept time.
2370
+ const sat1Pv = propagate(sat1Tle, new Date(startTime));
2371
+
2372
+ // Sweep across 1 orbit period
2373
+ const sat1Period = parseInt((120*Math.PI)/(sat1Tle.no));
2374
+ const sat2Period = parseInt((120*Math.PI)/(sat2Tle.no));
2375
+ const periodS = sat1Period > sat2Period ? sat1Period : sat2Period;
2376
+
2377
+ for (let rev=0; rev < 10; rev++) {
2378
+ for (let dt=100; dt<=periodS; dt+=100) { // Seconds
2379
+ const sat2Pv = propagate(sat2Tle, new Date(startTime + (dt*1000)));
2380
+ const r1 = [sat1Pv.position.x, sat1Pv.position.y, sat1Pv.position.z]; // km based
2381
+ const v1Before = [sat1Pv.velocity.x, sat1Pv.velocity.y, sat1Pv.velocity.z]; // km/s based
2382
+ const r2 = [sat2Pv.position.x, sat2Pv.position.y, sat2Pv.position.z]; // km based
2383
+
2384
+
2385
+ const {v1, v2, vH1} = lambertThomsonAlgorithm(r1, r2, dt, rev, 0, v1Before, mu);
2386
+ const deltaV1 = isDefined(v1) ? subtract(v1, v1Before) : [0, 0, 0];
2387
+ const deltaVH1 = isDefined(vH1) ? subtract(vH1, v1Before): [0, 0, 0];
2388
+ const v1Mag = norm(deltaV1);
2389
+ const vH1Mag = norm(deltaVH1);
2390
+
2391
+ const areArraysEqual = deltaV1.every((val, idx) => val === deltaVH1[idx]);
2392
+ const isZeroArray = deltaV1.every((val) => val === 0);
2393
+
2394
+ if (areArraysEqual && isZeroArray) {
2395
+ continue;
2396
+ }
2397
+
2398
+ const currentVector = v1Mag <= vH1Mag ? deltaV1 : deltaVH1;
2399
+ const currentNorm = v1Mag <= vH1Mag ? v1Mag : vH1Mag;
2400
+
2401
+ if (currentNorm < minDv) {
2402
+ revNum = rev;
2403
+ minDv = currentNorm;
2404
+ minIntercept = {
2405
+ x: currentVector[0],
2406
+ y: currentVector[1],
2407
+ z: currentVector[2],
2408
+ };
2409
+ minDt = dt;
2410
+ interceptPvEci = {
2411
+ p: {
2412
+ x: r2[0],
2413
+ y: r2[1],
2414
+ z: r2[2],
2415
+ },
2416
+ v: {
2417
+ x: v2[0],
2418
+ y: v2[1],
2419
+ z: v2[2],
2420
+ }}; // Position and velocity of interceptor and intercept time.
2421
+ }
2422
+ // As N increments, at some point either both or one of the two energy solutions will be NaN.
2423
+ // At that point, the N sweep can safely stop.
2424
+ if (isNaN(v1Mag) || isNaN(vH1Mag)) {
2425
+ break;
2426
+ }
2427
+ }
2428
+ }
2429
+
2430
+ // First Maneuver: Intercept Target
2431
+ const interceptRSW = multiply(
2432
+ cartesianToRIC( sat1Pv.position, sat1Pv.velocity), // Convert the intercept maneuver vector to RSW coordinates.
2433
+ [minIntercept.x, minIntercept.y, minIntercept.z],
2434
+ );
2435
+ const interceptDv = minDv*1000;
2436
+
2437
+ // Second Maneuver: Rendezvous
2438
+ const sat2PvAtIntercept = propagate(sat2Tle, new Date(startTime + (minDt*1000)));
2439
+ const rendezvousECIArray = subtract(
2440
+ [
2441
+ sat2PvAtIntercept.velocity.x,
2442
+ sat2PvAtIntercept.velocity.y,
2443
+ sat2PvAtIntercept.velocity.z,
2444
+ ],
2445
+ [interceptPvEci.v.x, interceptPvEci.v.y, interceptPvEci.v.z],
2446
+ );
2447
+ const rendezvousECI = {
2448
+ x: rendezvousECIArray[0]*1000, // km/s to m/s
2449
+ y: rendezvousECIArray[1]*1000,
2450
+ z: rendezvousECIArray[2]*1000,
2451
+ };
2452
+ const rendezvousDv = norm(rendezvousECIArray)*1000; // km/s to m/s
2453
+ const rendezvousRSW = multiply(
2454
+ cartesianToRIC(interceptPvEci.p, interceptPvEci.v),
2455
+ rendezvousECIArray,
2456
+ );
2457
+ return {
2458
+ startTime: new Date(startTime).toISOString(),
2459
+ revNum,
2460
+ interceptDv_ms: interceptDv,
2461
+ interceptTof_s: minDt,
2462
+ interceptTime: new Date(startTime + (minDt*1000)).toISOString(),
2463
+ interceptManeuverECI_ms: {
2464
+ time: new Date(startTime).toISOString(),
2465
+ x: minIntercept.x*1000, // km/s to m/s
2466
+ y: minIntercept.y*1000,
2467
+ z: minIntercept.z*1000,
2468
+ },
2469
+ interceptManeuverRSW_ms: {
2470
+ time: new Date(startTime).toISOString(),
2471
+ x: interceptRSW[0]*1000,
2472
+ y: interceptRSW[1]*1000,
2473
+ z: interceptRSW[2]*1000,
2474
+ },
2475
+ rendezvousDv_ms: rendezvousDv,
2476
+ rendezvousManeuverECI_ms: rendezvousECI,
2477
+ rendezvousManeuverRSW_ms: {
2478
+ time: new Date(startTime + (minDt*1000)).toISOString(),
2479
+ x: rendezvousRSW[0]*1000,
2480
+ y: rendezvousRSW[1]*1000,
2481
+ z: rendezvousRSW[2]*1000,
2482
+ },
2483
+ totalDv_ms: interceptDv + rendezvousDv,
2484
+ };
2485
+ };
2486
+
2487
+
2488
+ /**
2489
+ * A function that attempts to perform maneuver recovery (i.e. maneuver deltaV computation) between an initial and final TLE
2490
+ * and return the minimum deltaV possible to perform the maneuver.
2491
+ *
2492
+ * The initial TLE should be the pre-maneuver TLE, and the final TLE should be the post-maneuver TLE.
2493
+ * The computation will work as long as the initial TLE is before the final TLE in terms of epoch, and as long as the satNos match.
2494
+ * Otherwise, a zero vector is retuned.
2495
+ *
2496
+ * The function wraps a parameter sweep strategy and solves a series of Lambert problems.
2497
+ * Each Lambert problem is using the positions of the two TLEs at their respective epochs and the time difference as the time of flight
2498
+ * using the robust method suggested by Thomson:
2499
+ *
2500
+ * *AAS 18-074 Complete Solution of the Lambert Problem with Perturbations and Target State Sensitivity*
2501
+ * by Blair F. Thomson, Denise Brown, and Ryan Cobb
2502
+ *
2503
+ * An outer loop solves Lamberts problem for different values of the number of revolutions, starting from 0 and up to 10.
2504
+ * Inside the loop the core Lambert algorithm return a low-energy solution and a high-energy solution, of which the one with the smallest deltaV magnitude is chosen.
2505
+ *
2506
+ * Once the increments of N start producing NaN results for the deltaV, the loop is broken and the lowest deltaV vector thus far is fetched from the sweep results.
2507
+ *
2508
+ * This parameter sweep is performed because different satellites on various orbits and time of flight periods will yield different deltaV for the same positions and time of flight.
2509
+ *
2510
+ * By varying N lineary from 0 to its breaking point, a basic form of convex convergence on the minimum deltaV is guaranteed.
2511
+ *
2512
+ * @param {Object} initialTLE The initial TLE object
2513
+ * @param {Object} finalTLE The final TLE object
2514
+ * @return {number[]} The minimumDeltaV vector in ECI frame, in units of km/s
2515
+ */
2516
+ const detectManeuverMinDv = (initialTLE, finalTLE) => {
2517
+ try {
2518
+ // Silently return zero vector if initial and final TLE have different satNo
2519
+ if (initialTLE.satnum !== finalTLE.satnum) {
2520
+ throw new Error(`Initial satNo ${initialTLE.satnum} and Final satNo ${finalTLE.satnum} do not match. `);
2521
+ }
2522
+
2523
+ // Silently return zero vector if initial TLE is after final TLE
2524
+ if (initialTLE.jdsatepoch > finalTLE.jdsatepoch) {
2525
+ throw new Error(`Initial TLE epoch ${julianToGregorian(initialTLE.jdsatepoch)} is after Final TLE epoch ${julianToGregorian(finalTLE.jdsatepoch)}`);
2526
+ }
2527
+
2528
+ // Propagate initial TLE to its TLE epoch
2529
+ const pvInitial = propagate(initialTLE, julianToGregorian(initialTLE.jdsatepoch));
2530
+
2531
+ // Propagate final TLE to its own TLE epoch
2532
+ const pvFinal = propagate(finalTLE, julianToGregorian(finalTLE.jdsatepoch));
2533
+
2534
+ // Compute the epoch difference between the two TLEs
2535
+ // This will be the used as the time of flight between initial and final state.
2536
+ const dt = Math.abs((julianToGregorian(finalTLE.jdsatepoch)
2537
+ - julianToGregorian(initialTLE.jdsatepoch)) / 1000);
2538
+
2539
+ // Position of initial TLE at its epoch
2540
+ const r1 = [pvInitial.position.x, pvInitial.position.y, pvInitial.position.z]; // km based
2541
+ // Velocity of initial TLE at TLE epoch
2542
+ const v1Before = [pvInitial.velocity.x, pvInitial.velocity.y, pvInitial.velocity.z]; // km/s based
2543
+
2544
+ // Position of final TLE at its epoch
2545
+ const r2 = [pvFinal.position.x, pvFinal.position.y, pvFinal.position.z]; // km based
2546
+
2547
+ // Earth Gravitational Parameter
2548
+ const mu = 3.986004415e5; // km based
2549
+
2550
+ // Loop for N until NaN
2551
+ let minNorm = Infinity;
2552
+ // let minIndex = -1;
2553
+ let minVector = [0, 0, 0];
2554
+ for (let i=0; i<10; i++) { // loop up to 10 revolutions, it is a safe upper limit to capture impulsive maneuvers
2555
+ const {v1, vH1} = lambertThomsonAlgorithm(r1, r2, dt, i, 0, v1Before, mu);
2556
+
2557
+ const deltaV1 = isDefined(v1) ? subtract(v1, v1Before) : [0, 0, 0];
2558
+ const deltaVH1 = isDefined(vH1) ? subtract(vH1, v1Before): [0, 0, 0];
2559
+ const v1Mag = norm(deltaV1);
2560
+ const vH1Mag = norm(deltaVH1);
2561
+
2562
+ const isZeroArray = deltaV1.every((val) => val === 0);
2563
+
2564
+ if (deltaV1 === deltaVH1 && isZeroArray ) {
2565
+ continue;
2566
+ }
2567
+
2568
+ const currentVector = v1Mag <= vH1Mag ? deltaV1 : deltaVH1;
2569
+ const currentNorm = v1Mag <= vH1Mag ? v1Mag : vH1Mag;
2570
+
2571
+ if (currentNorm < minNorm) {
2572
+ minNorm = currentNorm;
2573
+ // minIndex = i;
2574
+ minVector = currentVector;
2575
+ }
2576
+ // As N increments, at some point either both or one of the two energy solutions will be NaN.
2577
+ // At that point, the N sweep can safely stop.
2578
+ if (isNaN(v1Mag) || isNaN(vH1Mag)) {
2579
+ break;
2580
+ }
2581
+ }
2582
+ return minVector;
2583
+ } catch (err) {
2584
+ console.warn(err);
2585
+ return [0, 0, 0];
2586
+ }
2587
+ };
2588
+
2589
+ /** This is an implementation of the Lambert Problem using the algorithm described by Thomson in:
2590
+ *
2591
+ * *AAS 18-074 Complete Solution of the Lambert Problem with Perturbations and Target State Sensitivity*
2592
+ * by Blair F. Thomson, Denise Brown, and Ryan Cobb
2593
+ *
2594
+ * The algorithm is a more numerically stable and robust implementation of the Battin algorithm or the Universal Variable formulation.
2595
+ * It is stable for transfers near or at 180 degrees. All benchmark cases of the algorithm are added as tests for this method.
2596
+ *
2597
+ * The algorithm can accept units in both km or meter based, as long as all of the inputs are consistent.
2598
+ * For example, if the initial position vector is in km, the the final position vector, as well as the initial velocity and gravitational parameter must be in km based units (and km/s respectively).
2599
+ *
2600
+ * @param {Array<Number>} r1 The initial position vector
2601
+ * @param {Array<Number>} r2 The final position vector
2602
+ * @param {Number} t The time of flight between initial and final position in seconds
2603
+ * @param {Integer} N The number of revolutions
2604
+ * @param {Integer} D A flag indicating the direction of the transfer. D = 1 for short way transfers, D = -1 for long way transfers. D = 0 will allow the algorithm to decide.
2605
+ * @param {Array<Number>} v1Minus The initial velocity vector prior to the application of the maneuver.
2606
+ * @param {Number} mu The gravitational parameter of the central body. Defaults to the value of the Earth's gravitational parameter in meter based units (so use meter based vectors if using the default mu!)
2607
+ * @param {Number} outOfPlaneError The out-of-plane error tolerance error to allow if the user wants to restrict the transfer to the initial orbital plane. Defaults to 0 for no initial plane restriction. Tweak this value if you suspect that the resulting deltaV is too large for a relatively small rendevous distance.
2608
+ * @return {Object} An object containing the following properties:
2609
+ * - {Array<Number>} v1: The initial velocity vector after the application of the maneuver.
2610
+ * - {Array<Number>} v2: The final velocity vector after the application of the maneuver.
2611
+ * - {Array<Number>} vH1: The high-energy velocity vector for the first leg of the transfer.
2612
+ * - {Array<Number>} vH2: The high-energy velocity vector for the second leg of the transfer.
2613
+ * - {Number} theta: The transfer angle in radians. This is only for information purposes. It does not consider revolutions.
2614
+ *
2615
+ */
2616
+ const lambertThomsonAlgorithm
2617
+ = (r1, r2, t, N, D = 0, v1Minus, mu = 3.986004415e14, outOfPlaneError = 0) => {
2618
+ // Out-Of-Plane error check
2619
+ const r1Mag = norm(r1);
2620
+ const r2Mag = norm(r2);
2621
+ const r2Scaled = multiply(r2, r1Mag / r2Mag);
2622
+ const r2PlusR1Scaled = add(r2, r2Scaled);
2623
+
2624
+ if (norm(r2PlusR1Scaled) <= outOfPlaneError) {
2625
+ const crossProduct = cross(r1, v1Minus);
2626
+ const nH = normalize({x: crossProduct[0], y: crossProduct[1], z: crossProduct[2]});
2627
+ const f = dot(nH, subtract(r1, r2));
2628
+ r2 = multiply(normalize(add(r2, multiply([nH.x, nH.y, nH.z], f))), r2Mag);
2629
+ }
2630
+
2631
+ // ----- Lines 2-5 -----
2632
+ let A = dot(r1, r2) / (r1Mag * r2Mag);
2633
+ if (A > 1.0) A = 1.0;
2634
+ if (A < -1.0) A = -1.0;
2635
+ let theta = Math.acos(A); // transfer angle
2636
+
2637
+ // ----- Lines 6-8: Determine transfer direction -----
2638
+ const crossR1V1 = cross(r1, v1Minus);
2639
+ const crossR1R2 = cross(r1, r2);
2640
+ if (D === -1 || (D === 0 && dot(crossR1V1, crossR1R2) < 0)) {
2641
+ theta = 2 * Math.PI - theta; // long way transfer
2642
+ }
2643
+
2644
+ // ----- Lines 9-13 -----
2645
+ const c = norm(subtract(r2, r1)); // chord length
2646
+ const s = 0.5 * (r1Mag + r2Mag + c); // semiperimeter
2647
+ const lambda = (1.0 / s) * Math.sqrt(r1Mag * r2Mag) * Math.cos(theta / 2.0);
2648
+ const L = Math.pow((1 - lambda) / (1 + lambda), 2.0);
2649
+ const mVal = 8 * mu * t * t / (Math.pow(s, 3.0) * Math.pow(1 + lambda, 6.0));
2650
+
2651
+ // ----- Line 14: Initial x -----
2652
+ let x = (N > 0) ? (1 + 4 * L) : L;
2653
+ let xdiff = 1.0;
2654
+ let y = 0.0;
2655
+
2656
+ // ----- Lines 15-54: Iteration for x -----
2657
+ while (xdiff > 1e-6) {
2658
+ let h1; let h2;
2659
+ if (N > 0) {
2660
+ // ----- Lines 17-19: Multi-rev (N > 0) branch using Loechler's method -----
2661
+ const numerator = Math.pow(L + x, 2.0);
2662
+ const denominator = 4 * x * x * (1 + 2 * x + L);
2663
+ const sqrtx = Math.sqrt(x);
2664
+ const commonTerm = ((N * Math.PI / 2 + Math.atan(sqrtx)) / sqrtx);
2665
+ // The bracketed term (Loechler p.30 (4.3))
2666
+ const term1 = 3 * Math.pow(1 + x, 2.0) * commonTerm - (3 + 5 * x);
2667
+ h1 = (numerator / denominator) * term1;
2668
+
2669
+ // ----- Line 19: h2 (Loechler p.31 (4.4)) -----
2670
+ const term2 = (x * x - x * (1 + L) - 3 * L) * commonTerm + (3 * L + x);
2671
+ h2 = mVal / denominator * term2;
2672
+ } else {
2673
+ // ----- Lines 20-36: Single-rev branch -----
2674
+ const eta = x / Math.pow(Math.sqrt(1 + x) + 1, 2.0);
2675
+ let bn = 3; let dn = 1; let un = 8 * (Math.sqrt(1 + x) + 1) / bn;
2676
+ let xi = un;
2677
+ let i = 1;
2678
+ while (Math.abs(un) > 1e-12) {
2679
+ const bp = bn; const dp = dn; const up = un;
2680
+ i++;
2681
+ let an;
2682
+ if (i === 2) {
2683
+ an = -1;
2684
+ bn = 5 + eta;
2685
+ } else if (i === 3) {
2686
+ an = -9 * eta / 7;
2687
+ bn = 1;
2688
+ } else {
2689
+ an = -eta * i * i / (4 * i * i - 1);
2690
+ bn = 1;
2691
+ }
2692
+ dn = 1 / (1 - an * dp / bp / bn);
2693
+ un = up * (dn - 1);
2694
+ xi += un;
2695
+ }
2696
+ h1 = (Math.pow(L + x, 2.0) * (1 + 3 * x + xi))
2697
+ / ((1 + 2 * x + L) * (4 * x + xi * (3 + x)));
2698
+ h2 = mVal * (x - L + xi) / ((1 + 2 * x + L) * (4 * x + xi * (3 + x)));
2699
+ }
2700
+
2701
+ // ----- Lines 38-39: Compute B and solve for K -----
2702
+ const BVal = 27.0 * h2 / (4.0 * Math.pow(1 + h1, 3.0));
2703
+ const uVal = BVal / (2.0 * (Math.sqrt(1 + BVal) + 1.0));
2704
+
2705
+ const bn2 = 1.0; let dn2 = 1.0; let un2 = 1.0 / 3.0;
2706
+ let K = un2;
2707
+ let n = 0;
2708
+ let evenflag = 1;
2709
+ while (Math.abs(un2) > 1e-12) {
2710
+ const bp = bn2; const dp = dn2; const up = un2;
2711
+ let an;
2712
+ if (evenflag === 1) {
2713
+ an = -2.0 * uVal * (3 * n + 2) * (6 * n + 1) / (9 * (4 * n + 1) * (4 * n + 3));
2714
+ evenflag = 0;
2715
+ n++;
2716
+ } else {
2717
+ an = -2.0 * uVal * (3 * n + 1) * (6 * n - 1) / 9.0 / (4 * n - 1) / (4 * n + 1);
2718
+ evenflag = 1;
2719
+ }
2720
+ dn2 = 1.0 / (1 - an * dp / bp / bn2);
2721
+ un2 = up * (dn2 - 1.0);
2722
+ K += un2;
2723
+ }
2724
+
2725
+ // ----- Line 50: Compute y -----
2726
+ y = (1 + h1) / 3.0 * (2 + Math.sqrt(1 + BVal) / (1.0 + 2.0 * uVal * K * K));
2727
+ // ----- Line 51: Update xnew (Battin p.335 (7.113))
2728
+ const xnew = Math.sqrt(Math.pow((1.0 - L)/2.0, 2.0) + mVal/(y*y)) - (1.0 + L)/2.0;
2729
+ // ----- Line 52: Compute difference -----
2730
+ xdiff = Math.abs(xnew - x);
2731
+ // ----- Line 53: Update x -----
2732
+ x = xnew;
2733
+ }
2734
+
2735
+ // ----- Lines 55-57: Compute p and eccentricity -----
2736
+ const pVal = 2 * r1Mag * r2Mag * y * y * Math.pow(1 + x, 2.0)
2737
+ * Math.pow(Math.sin(theta / 2), 2.0)
2738
+ / (mVal * s * Math.pow(1 + lambda, 2.0));
2739
+ const eps = (r2Mag - r1Mag) / r1Mag;
2740
+ const eVal = Math.sqrt(
2741
+ (Math.pow(eps, 2.0) + 4.0 * r2Mag / r1Mag
2742
+ * Math.pow(Math.sin(theta / 2.0), 2.0) * Math.pow((L - x) / (L + x), 2.0))
2743
+ / (Math.pow(eps, 2.0) + (4.0 * r2Mag / r1Mag) * Math.pow(Math.sin(theta / 2.0), 2.0)),
2744
+ );
2745
+
2746
+ // ----- Line 58: Call hodograph velocity algorithm -----
2747
+ const {v1, v2} = hodographVelocityAlgorithm(r1, r2, t, v1Minus, theta, pVal, eVal, mu);
2748
+
2749
+ // ----- Lines 59-84: Multi-rev high-energy solution -----
2750
+ let vH1 = [0, 0, 0];
2751
+ let vH2 = [0, 0, 0];
2752
+
2753
+ if (N > 0) {
2754
+ // Line 61:
2755
+ x = 1e-20;
2756
+ xdiff = 1.0;
2757
+ while (xdiff > 1e-6) {
2758
+ // ----- Line 64: Compute h1 for high-energy solution (Loechler p.35 (5.5)) -----
2759
+ const h1He = (L + x) * (1 + 2 * x + L) / (2 * (L - x * x));
2760
+ // ----- Line 65: Compute h2 for high-energy solution (Loechler p.35 (5.6)) -----
2761
+ const h2He = mVal * Math.sqrt(x) / (2 * (L - x * x))
2762
+ * ((L - x * x) * ((N * Math.PI / 2 + Math.atan(Math.sqrt(x)))
2763
+ / Math.sqrt(x)) - (L + x));
2764
+ // ----- Line 66: Compute B for high-energy solution (Loechler p.37 (5.9)) -----
2765
+ const BHe = 27 * h2He / (4 * Math.pow(Math.sqrt(x) * (1 + h1He), 3.0));
2766
+ let F;
2767
+ if (BHe < 0) {
2768
+ // ----- Line 68: Compute F for negative B -----
2769
+ F = 2 * Math.cos(1.0 / 3.0 * Math.acos(Math.sqrt(BHe + 1)));
2770
+ } else {
2771
+ // ----- Lines 70-71: Compute F for nonnegative B -----
2772
+ const AHe = Math.pow(Math.sqrt(BHe) + Math.sqrt(BHe + 1), 1.0 / 3.0);
2773
+ F = AHe + 1 / AHe;
2774
+ }
2775
+ // ----- Line 73: Compute y for high-energy solution (Loechler p.37 (5.10)) -----
2776
+ const yHe = (2.0 / 3.0) * Math.sqrt(x) * (1 + h1He) * (Math.sqrt(BHe+1) / F + 1);
2777
+ // ----- Line 74: Update xnew for high-energy solution (Loechler p.34 (5.3)) -----
2778
+ const temp = (mVal / (yHe * yHe)) - (1 + L);
2779
+ const xnewHe = 0.5 * (temp - Math.sqrt(temp * temp - 4 * L));
2780
+ xdiff = Math.abs(xnewHe - x);
2781
+ x = xnewHe;
2782
+ }
2783
+ // ----- Line 78: Compute semi-major axis a -----
2784
+ const eVal = s * Math.pow(1 + lambda, 2.0) * (1 + x) * (L + x) / (8 * x);
2785
+ // ----- Line 79: Compute p for high-energy solution -----
2786
+ const pHe = 2 * r1Mag * r2Mag * Math.pow(Math.sin(theta / 2), 2.0) * (1 + x)
2787
+ / (s * Math.pow(1 + lambda, 2.0) * (L + x));
2788
+ // ----- Line 80: Compute eccentricity for high-energy solution -----
2789
+ const eHe = Math.sqrt(1 - pHe / eVal);
2790
+ // ----- Line 81: Compute high-energy velocities -----
2791
+ const highEnergyVels = hodographVelocityAlgorithm(r1, r2, t, v1Minus, theta, pHe, eHe, mu);
2792
+ vH1 = highEnergyVels.v1;
2793
+ vH2 = highEnergyVels.v2;
2794
+ }
2795
+
2796
+ return {v1, v2, vH1, vH2, theta};
2797
+ };
2798
+
2799
+ /**
2800
+ * Implements the Hodograph Velocity Algorithm.
2801
+ * @param {Array<number>} r1 Initial position vector
2802
+ * @param {Array<number>} r2 Final position vector
2803
+ * @param {number} t Time of flight
2804
+ * @param {Array<number>} v1Minus Initial velocity vector
2805
+ * @param {number} theta Transfer angle in radians
2806
+ * @param {number} p Semi-latus rectum
2807
+ * @param {number} e Eccentricity
2808
+ * @param {number} mu Gravitational parameter
2809
+ * @return {{v1: Array<number>, v2: Array<number>}} Initial and final velocity vectors
2810
+ */
2811
+ const hodographVelocityAlgorithm = (r1, r2, t, v1Minus, theta, p, e, mu) => {
2812
+ // Line 2: Define L180 (in meters)
2813
+ const L180 = 1.0;
2814
+
2815
+ const r1Mag = norm(r1);
2816
+ const r2Mag = norm(r2);
2817
+
2818
+ // Line 3: A = mu*(1/r1 - 1/p)
2819
+ const eVal = mu * (1.0 / r1Mag - 1.0 / p);
2820
+
2821
+ // Line 4: B = (mu*e/p)^2 - A^2
2822
+ const BVal = Math.pow(mu * e / p, 2.0) - (eVal * eVal);
2823
+
2824
+ // Line 5: if B <= 0 then x1 = 0 else x1 = -sqrt(B)
2825
+ let x1 = (BVal <= 0) ? 0.0 : -Math.sqrt(BVal);
2826
+
2827
+ let nHat; // unit normal vector
2828
+
2829
+ // Line 6: Check if |sin(theta)| < L180 / r2Mag
2830
+ if (Math.abs(Math.sin(theta)) < L180 / r2Mag) {
2831
+ // Line 7: nHat = (r1 x v1Minus) normalized
2832
+ const crossProduct = cross(r1, v1Minus);
2833
+ nHat = normalize({x: crossProduct[0], y: crossProduct[1], z: crossProduct[2]});
2834
+ nHat = [nHat.x, nHat.y, nHat.z];
2835
+
2836
+ // Line 8: If the orbit is elliptical (e < 1)
2837
+ if (e < 1.0) {
2838
+ // Line 9: P = 2π * sqrt( p^3 / [ mu * (1-e^2)^3 ] )
2839
+ const P = 2 * Math.PI * Math.sqrt(Math.pow(p, 3.0) / (mu * Math.pow(1 - e * e, 3.0)));
2840
+ // Line 10: If (t mod P) > (P/2) then reverse x1
2841
+ const tMod = t % P;
2842
+ if (tMod > P / 2) {
2843
+ x1 = -x1;
2844
+ }
2845
+ }
2846
+ } else {
2847
+ // Line 13: nHat = (r1 x r2) normalized
2848
+ const crossProduct = cross(r1, r2);
2849
+ nHat = normalize({x: crossProduct[0], y: crossProduct[1], z: crossProduct[2]});
2850
+ nHat = [nHat.x, nHat.y, nHat.z];
2851
+
2852
+ // Line 14: If (theta mod 2π) > π then reverse nHat
2853
+ const thetaMod = theta % (2 * Math.PI);
2854
+ if (thetaMod > Math.PI) {
2855
+ nHat = nHat.map((x) => -x);
2856
+ }
2857
+ // Line 15: y2a = mu/p - x1*sin(theta) + A*cos(theta)
2858
+ const y2a = mu / p - x1 * Math.sin(theta) + eVal * Math.cos(theta);
2859
+ // Line 16: y2b = mu/p + x1*sin(theta) + A*cos(theta)
2860
+ const y2b = mu / p + x1 * Math.sin(theta) + eVal * Math.cos(theta);
2861
+ // Line 17: if |mu/r2Mag - y2b| < |mu/r2Mag - y2a| then reverse x1
2862
+ if (Math.abs(mu / r2Mag - y2b) < Math.abs(mu / r2Mag - y2a)) {
2863
+ x1 = -x1;
2864
+ }
2865
+ }
2866
+
2867
+ // Line 19
2868
+ const term1V1 = multiply(r1, x1 / mu);
2869
+ const crossProduct1 = cross(nHat, r1);
2870
+ const term2V1 = multiply(crossProduct1, 1/r1Mag);
2871
+ const v1 = multiply(add(term1V1, term2V1), Math.sqrt(mu * p) / r1Mag);
2872
+
2873
+ // Line 20
2874
+ const x2 = x1 * Math.cos(theta) + eVal * Math.sin(theta);
2875
+
2876
+ // Line 21
2877
+ const term1V2 = multiply(r2, x2 / mu);
2878
+ const crossProduct2 = cross(nHat, r2);
2879
+ const term2V2 = multiply(crossProduct2, 1/r2Mag);
2880
+ const v2 = multiply(add(term1V2, term2V2), Math.sqrt(mu * p) / r2Mag);
2881
+
2882
+ return {v1, v2};
2883
+ };
2884
+
2885
+ const wrap360 = (r) => {
2886
+ if (r < 0) return r + 360;
2887
+ if (r >= 360) return r - 360;
2888
+ return r;
2889
+ };
2890
+
2891
+ const inWrappedRange = (val, low, high) => {
2892
+ if (low <= high) {
2893
+ return val >= low && val <= high;
2894
+ }
2895
+
2896
+ // e.g. 350 to 10 -> 350 to 360 OR 0 to 10
2897
+ // since wrapped value cannot be >360 nor <0 this simplifies to >350 or <10
2898
+ return val >= low || val <= high;
2899
+ };
2900
+
2901
+ const subtractWrapped = (a, b, range=360) => { // a - b, range = 360 for example for 0-360 range
2902
+ const diff = a - b;
2903
+ if (diff > range/2) { // 350 - 10 = 340 => 350 - 370 = -20
2904
+ return diff - range;
2905
+ } else if (diff < -range/2) { // 10 - 350 = -340 => 370 - 350 = 20
2906
+ return diff + range;
2907
+ }
2908
+ return diff;
2909
+ };
2910
+
2911
+ const propagateRaan = (raan, raanPrecessionPerDay, currentTime, epoch) => {
2912
+ const epochMs = parseDate(epoch).getTime();
2913
+ const currentTimeMs = new Date(currentTime).getTime();
2914
+ const currentRaan
2915
+ = wrap360(raan + (currentTimeMs - epochMs) / MILLIS_PER_DAY * raanPrecessionPerDay);
2916
+ return currentRaan;
2917
+ };
2918
+
2919
+ const getIncDiff = (incCurrent, incPrimary, absoluteValue=true) => {
2920
+ const diff = subtractWrapped(incCurrent, incPrimary, 180); // Inclination is 0 to 180
2921
+ return absoluteValue ? Math.abs(diff) : diff;
2922
+ };
2923
+
2924
+ const getRaanDiff = (raanCurrent, raanPrimary, absoluteValue=true) => {
2925
+ const diff = subtractWrapped(raanCurrent, raanPrimary, 360); // RAAN is 0 to 360
2926
+ return absoluteValue ? Math.abs(diff) : diff;
2927
+ };
2928
+
2929
+ const getRaanClosureRate = (raanPrecessionCurrent, raanPrecessionPrimary) => {
2930
+ return Math.abs(raanPrecessionCurrent - raanPrecessionPrimary); // closure rate should always be positive
2931
+ };
2932
+
2933
+ const getRaanAlignTime = (raan, raanDrift, raanTarget, raanTargetDrift, currentTime) => {
2934
+ // imagine a circle where positive is counterclockwise
2935
+ const netDrift = raanDrift - raanTargetDrift; // how fast we are drifting along the circle
2936
+ if (netDrift === 0) return null;
2937
+ if (raan === raanTarget) return new Date(currentTime);
2938
+
2939
+ let diff = raanTarget - raan; // the distance along the circle we need to drift to reach the target, might be negative or positive
2940
+
2941
+ if (Math.sign(netDrift) !== Math.sign(diff)) {
2942
+ // we need to transform the distance to be in the same direction as we are drifting
2943
+ if (diff < 0) diff += 360; // target is behind us, e.g. target is 10 and we are 350 (diff -340): get the other part of the circle (+20)
2944
+ else diff -= 360; // diff > 0, so target is ahead of us (+20), but we are going backwards, get the other part of the circle (-340)
2945
+ }
2946
+
2947
+ // diff and netdrift signs should match now
2948
+ const raanAlignMillis = (diff / netDrift) * MILLIS_PER_DAY; // drift is deg/day
2949
+ if (raanAlignMillis > MILLIS_PER_DAY * 365) {
2950
+ return null; // will take over a year to align
2951
+ }
2952
+ return new Date(new Date(currentTime).getTime() + raanAlignMillis);
2953
+ };
2954
+
2955
+ const getRaanAlignTimeStr = (raan, raanDrift, raanTarget, raanTargetDrift, currentTime) => {
2956
+ const alignTime = getRaanAlignTime(raan, raanDrift, raanTarget, raanTargetDrift, currentTime);
2957
+ return alignTime?.toISOString() ?? "Relatively stationary";
2958
+ };
2959
+
2960
+ const getRaanDetails = (raan, raanPrecessionDegreesPerDay, epoch,
2961
+ primaryRaan, primaryRaanPrecessionDegreesPerDay, primaryEpoch, currentTime,
2962
+ absoluteValueDiff=true,
2963
+ ) => {
2964
+ const raanDrift = raanPrecessionDegreesPerDay;
2965
+ const currentRaan = propagateRaan(raan, raanDrift,
2966
+ currentTime, epoch);
2967
+
2968
+ const primaryRaanDrift = primaryRaanPrecessionDegreesPerDay;
2969
+ const currentPrimaryRaan = propagateRaan(primaryRaan, primaryRaanDrift,
2970
+ currentTime, primaryEpoch);
2971
+
2972
+ return {
2973
+ Raan: currentRaan,
2974
+ RaanDrift: raanDrift,
2975
+ RaanDiff: getRaanDiff(currentRaan, currentPrimaryRaan, absoluteValueDiff),
2976
+ RaanClosureRate: getRaanClosureRate(raanDrift, primaryRaanDrift),
2977
+ RaanAlignTime: getRaanAlignTimeStr(currentRaan, raanDrift,
2978
+ currentPrimaryRaan, primaryRaanDrift, currentTime),
2979
+ };
2980
+ };
2981
+
2982
+ /** Calculates if a satellite is in shadow.
2983
+ * Based on Astro Library's implementation of Algorithm 34 SHADOW from Vallado's Fundamentals of Astrodynamics and Applications.
2984
+ * Astrolibrary's implementation is adding up a few frame transformations from the GCRF to the J2000 frame for the sun position.
2985
+ * Those are implemented below.
2986
+ *
2987
+ * August 2025: An attempt was made to use the pious-squid shadow algorithm SunBody.shadow(...), but the function returns true/false and cannot distinguish between partial and full shadow.
2988
+ * Therefore the astrolibrary implementation is used instead.
2989
+ *
2990
+ * Returns a string:
2991
+ * - "None": if the satellite is in sunlight, or
2992
+ * - "Penumbra": if in partial sunlight, or
2993
+ * - "Umbra": if in full shadow
2994
+ *
2995
+ * @param {Date} epoch UTC time epoch of the measurement
2996
+ * @param {Object} satPos satellite position at the specified epoch
2997
+ * @return {String} type of shadow (None, Penumbra, Umbra)
2998
+ */
2999
+ const isSatInShadow = (epoch, satPos) => {
3000
+ // Constants
3001
+ const re = WGS84_EARTH_EQUATORIAL_RADIUS_KM; // Earth radius in km
3002
+ const rs = SUN_RADIUS_KM; // Sun radius in km
3003
+ const arcsec2rad = ARCSEC2RAD;
3004
+ const sec2rad = SEC2RAD;
3005
+ const auDist = AU_KM; // 1 AU in km
3006
+ const alphaUmbra = Math.atan((rs - re) / auDist); // half angle of the umbra
3007
+ const alphaPenumbra = Math.atan((rs + re) / auDist); // half angle of the penumbra
3008
+
3009
+ // Begin with no shadow
3010
+ let shadowType = "None";
3011
+
3012
+ // Getting Sun's apperent longitude and declination at the current epoch
3013
+ const century = solar.century(epoch); // Convert date to Julian centuries since J2000 || equal to var tut1 in GetPosition()
3014
+
3015
+ // Variables to solve for Sun Pos in ___ frame
3016
+ const lamM = (280.460 + 36000.771*century % 360) * DEG2RAD;
3017
+ const M = (357.5291092 + 35999.05034*century % 360) * DEG2RAD;
3018
+ const lamEc = (((lamM * RAD2DEG)
3019
+ + 1.914666471 * Math.sin(M)
3020
+ + 0.019994643 * Math.sin(2*M))) * DEG2RAD;
3021
+ const rMag = 1.000140612
3022
+ - 0.016708617 * Math.cos(M)
3023
+ - 0.000139589 * Math.cos(2*M);
3024
+ const dec = ((23.439291 - 0.0130042 * century) % 360) * DEG2RAD;
3025
+
3026
+ // Mod variable
3027
+ const sunVec = {
3028
+ x: rMag * Math.cos(lamEc) * auDist,
3029
+ y: rMag * Math.cos(dec) * Math.sin(lamEc) * auDist,
3030
+ z: rMag * Math.sin(dec) * Math.sin(lamEc) * auDist,
3031
+ };
3032
+
3033
+ // Convert mod => GCRF using Precession FK5
3034
+ const zeta = (((0.017998 * century + 0.30188) * century + 2306.2181) * century) * sec2rad;
3035
+ const theta = (((-0.041833 * century - 0.42665) * century + 2004.3109) * century) * sec2rad;
3036
+ const z = (((0.018203 * century + 1.09468) * century + 2306.2181) * century) * sec2rad;
3037
+
3038
+ const coszeta = Math.cos(zeta);
3039
+ const sinzeta = Math.sin(zeta);
3040
+ const costheta = Math.cos(theta);
3041
+ const sintheta = Math.sin(theta);
3042
+ const cosz = Math.cos(z);
3043
+ const sinz = Math.sin(z);
3044
+
3045
+ const mod2gcrf = [
3046
+ [coszeta * costheta * cosz - sinzeta * sinz,
3047
+ coszeta * costheta * sinz + sinzeta * cosz,
3048
+ coszeta * sintheta],
3049
+ [-sinzeta * costheta * cosz - coszeta * sinz,
3050
+ -sinzeta * costheta * sinz + coszeta * cosz,
3051
+ -sinzeta * sintheta],
3052
+ [-sintheta * cosz, -sintheta * sinz, costheta],
3053
+ ];
3054
+
3055
+ const sunPosGCRF = multiply(mod2gcrf, posToArray(sunVec)); // Sun Position in GCRF
3056
+ const sunVecGCRF = {
3057
+ x: sunPosGCRF[0],
3058
+ y: sunPosGCRF[1],
3059
+ z: sunPosGCRF[2],
3060
+ };
3061
+
3062
+ // Convert Sun Pos from GCRF to J2000
3063
+ const xi0 = -0.0166170 * arcsec2rad;
3064
+ const eta0 = -0.0068192 * arcsec2rad;
3065
+ const da0 = -0.01460 * arcsec2rad;
3066
+
3067
+ const gcrf2j2000 = [
3068
+ [1.0 - 0.5*(-da0*-da0 + xi0*xi0), da0, -xi0],
3069
+ [-da0, 1.0 - 0.5*(-da0*-da0 + eta0*eta0), -eta0],
3070
+ [xi0, eta0, 1.0 - 0.5*(eta0*eta0 + xi0*xi0)],
3071
+ ];
3072
+
3073
+ const sunVecJ2000 = multiply(gcrf2j2000, posToArray(sunVecGCRF)); // Sun Position in J2000
3074
+ const sunPosVec = sunVecJ2000;
3075
+
3076
+ const sunPos = {
3077
+ x: sunPosVec[0],
3078
+ y: sunPosVec[1],
3079
+ z: sunPosVec[2],
3080
+ }; // create a vector object of the Sun's J2000 position vector
3081
+
3082
+ // perform algorithm 34 from Vallado's Fundamentals of Astrodynamics and Applications
3083
+ if (dot(posToArray(satPos), posToArray(sunPos)) < 0) { // ensure sat is not in the direction of the Sun, which would mean it is opposite the shadow
3084
+ const reverseSunVec = {
3085
+ x: -sunPos.x,
3086
+ y: -sunPos.y,
3087
+ z: -sunPos.z,
3088
+ }; // reverse the Sun vector to obtain the vector to the Umbra cone tip
3089
+
3090
+ // angle between sat position vector and shadow cone vector
3091
+ const theta = Math.acos(
3092
+ dot(posToArray(satPos), posToArray(reverseSunVec))
3093
+ / (norm(posToArray(satPos)) * norm(posToArray(reverseSunVec))));
3094
+
3095
+ const satHorz = norm(posToArray(satPos)) * Math.cos(theta); // horizontal component of sat position
3096
+ const satVert = norm(posToArray(satPos)) * Math.sin(theta); // vertical component of the sat position
3097
+ const x = re / Math.sin(alphaPenumbra);
3098
+ const penumbraVert = Math.tan(alphaPenumbra) * (x + satHorz); // vertical component of the penumbra
3099
+ if (satVert <= penumbraVert) {
3100
+ shadowType = "Penumbra"; // if vertical satellite component < vertical penumbra component, shadow is at least within penumbra
3101
+ const y = re / Math.sin(alphaUmbra);
3102
+ const umbraVert = Math.tan(alphaUmbra) * (y - satHorz); // vertical component of the umbra
3103
+ if (satVert <= umbraVert) {
3104
+ shadowType = "Umbra"; // if vertical satellite component < umbra vertical component, shadow is within umbra
3105
+ }
3106
+ }
3107
+ }
3108
+ return shadowType; // return the shadow type
3109
+ };
3110
+
3111
+ module.exports.CONSTANTS = require("./constants.js");
3112
+ module.exports.REGIMES = REGIMES;
3113
+ module.exports.julianToGregorian = julianToGregorian;
3114
+ module.exports.calcRegime = calcRegime;
3115
+ module.exports.altToRegime = altToRegime;
3116
+ module.exports.cartesianToRIC = cartesianToRIC;
3117
+ module.exports.multiplyVector = multiplyVector;
3118
+ module.exports.dist = dist;
3119
+ module.exports.angleBetween3DCoords = angleBetween3DCoords;
3120
+ module.exports.prop = prop;
3121
+ module.exports.propGeodetic = propGeodetic;
3122
+ module.exports.getTRIC = getTRIC;
3123
+ module.exports.getSunDirection = getSunDirection;
3124
+ module.exports.getLonAndDrift = getLonAndDrift;
3125
+ module.exports.getRaanPrecession = getRaanPrecession;
3126
+ module.exports.checkTle = checkTle;
3127
+ module.exports.sunPosAt = sunPosAt;
3128
+ module.exports.raDecToGeodetic = RaDecToGeodetic;
3129
+ module.exports.getResiduals = GetResiduals;
3130
+ module.exports.raDecToAzEl = RaDecToAzEl;
3131
+ module.exports.azElToRaDec = AzElToRaDec;
3132
+ module.exports.getElsetUdlFromTle = GetElsetUdlFromTle;
3133
+ module.exports.satjs = require("satellite.js");
3134
+ module.exports.distGeodetic = distGeodetic;
3135
+ module.exports.getSemiMajorAxis = getSemiMajorAxis;
3136
+ module.exports.angleBetweenPlanes = angleBetweenPlanes;
3137
+ module.exports.propagate = propagate;
3138
+ module.exports.planeChangeDeltaV = planeChangeDeltaV;
3139
+ module.exports.planeChangePureInclinationDeltaV = planeChangePureInclinationDeltaV;
3140
+ module.exports.cartesianToKeplerian = cartesianToKeplerian;
3141
+ module.exports.keplerianToCartesian = keplerianToCartesian;
3142
+ module.exports.getLeoRpoData = getLeoRpoData;
3143
+ module.exports.getGeoRpoData = getGeoRpoData;
3144
+ module.exports.getGeoShadowZones = getGeoShadowZones;
3145
+ module.exports.getGeoLightIntervals = getGeoLightIntervals;
3146
+ module.exports.getEclipseStatus = getEclipseStatus;
3147
+ module.exports.estimateSlantRange = estimateSlantRange;
3148
+ module.exports.calculateNextApogeePerigeeTimes = calculateNextApogeePerigeeTimes;
3149
+ module.exports.calculateNextApogeePerigeeTimesWithPropagation
3150
+ = calculateNextApogeePerigeeTimesWithPropagation;
3151
+ module.exports.calculateLeoPhaseDifference = calculateLeoPhaseDifference;
3152
+ module.exports.getLeoWaterfallData = getLeoWaterfallData;
3153
+ module.exports.lambertThomsonAlgorithm = lambertThomsonAlgorithm;
3154
+ module.exports.detectManeuverMinDv = detectManeuverMinDv;
3155
+ module.exports.getInterceptRendezvousMinDv = getInterceptRendezvousMinDv;
3156
+
3157
+ module.exports.wrap360 = wrap360;
3158
+ module.exports.inWrappedRange = inWrappedRange;
3159
+ module.exports.subtractWrapped = subtractWrapped;
3160
+ module.exports.propagateRaan = propagateRaan;
3161
+ module.exports.getIncDiff = getIncDiff;
3162
+ module.exports.getRaanDiff = getRaanDiff;
3163
+ module.exports.getRaanClosureRate = getRaanClosureRate;
3164
+ module.exports.getRaanAlignTime = getRaanAlignTime;
3165
+ module.exports.getRaanAlignTimeStr = getRaanAlignTimeStr;
3166
+ module.exports.getRaanDetails = getRaanDetails;
3167
+ module.exports.isSatInShadow = isSatInShadow;
3168
+
3169
+ module.exports.calculateGeoCrossingTimes = calculateGeoCrossingTimes;