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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/astro.js CHANGED
@@ -793,6 +793,12 @@ const propGeodetic = (elset, start, end, stepMs = 60000) => {
793
793
  return positions;
794
794
  };
795
795
 
796
+ // WASM-backed bulk primitives (`propBulk`, `propGeodeticBulk`,
797
+ // `propLookAnglesBulk`) live in `./wasmProp/primitives.js`. The sync primitives
798
+ // above remain the canonical path for single-satellite / small-N callers;
799
+ // the bulk siblings are exposed through the barrel at `./wasmProp/index.js` and
800
+ // re-exported from `./index.js`. See `docs/BULK_PROPAGATION.md`.
801
+
796
802
  const doesLineSegmentSphereIntersect = (linePoint0, linePoint1, circleCenter, circleRadius) => {
797
803
  // From Space Cockpit
798
804
  // http://www.codeproject.com/Articles/19799/Simple-Ray-Tracing-in-C-Part-II-Triangles-Intersec
@@ -1725,6 +1731,78 @@ const cartesianToElsetElements = (pv, epoch) => {
1725
1731
  * @param {Integer} endTime, end time of the analysis, Unix milliseconds
1726
1732
  * @return {Array} Array of RPO data objects
1727
1733
  */
1734
+ /**
1735
+ * Pure assembly helper for a single LEO RPO comparison row. Takes the primary
1736
+ * and threat ephemerides (already produced by either the JS `prop` path or
1737
+ * the WASM `propBulk` path) and returns the assembled result object. Shared
1738
+ * by `getLeoRpoData` and `getLeoRpoDataBulk` to guarantee the JS and WASM
1739
+ * variants emit byte-identical structures.
1740
+ *
1741
+ * Returns `undefined` if either ephemeris is missing or the lengths do not
1742
+ * agree (which mirrors the original guard, including the case where the
1743
+ * threat re-entered the atmosphere mid-window).
1744
+ *
1745
+ * @param {Object} s threat elset / metadata record
1746
+ * @param {Array<{p:number[],v:number[],t:number}>} pEphem primary ephemeris
1747
+ * @param {Array<{p:number[],v:number[],t:number}>} sEphem threat ephemeris
1748
+ * @return {Object|undefined}
1749
+ */
1750
+ const assembleLeoRpoResult = (s, pEphem, sEphem) => {
1751
+ if (!isDefined(pEphem)
1752
+ || !isDefined(sEphem)
1753
+ || pEphem.length === 0
1754
+ || pEphem.length !== sEphem.length) {
1755
+ return undefined;
1756
+ }
1757
+
1758
+ const aResult = {
1759
+ line1: s.Line1,
1760
+ line2: s.Line2,
1761
+ epoch: s.Epoch,
1762
+ name: s.CommonName,
1763
+ rank: s.Rank ?? "",
1764
+ satNo: s.SatNo,
1765
+ inclination: s.Inclination,
1766
+ raan: s.Raan,
1767
+ source: s.Source,
1768
+ datamode: s.DataMode,
1769
+ sma: s.SemiMajorAxis,
1770
+ country: s.CountryId,
1771
+ flag: s.Flag,
1772
+ poca: 9999999999,
1773
+ toca: "",
1774
+ tocaString: "",
1775
+ planeDiff: 0,
1776
+ incDiff: s.incDiff,
1777
+ raanDiff: s.raanDiff,
1778
+ semiMajorDiff: s.semiMajorDiff,
1779
+ raanDrift: s.RaanPrecessionDegreesPerDay,
1780
+ };
1781
+
1782
+ const pv1 = {position: pEphem[0].p, velocity: pEphem[0].v};
1783
+ const pv2 = {position: sEphem[0].p, velocity: sEphem[0].v};
1784
+ aResult.di = angleBetweenPlanes(pv1, pv2);
1785
+ const dv = planeChangeDeltaV(pv1, pv2);
1786
+ aResult.dv = {
1787
+ i: Math.round(dv.i * 1000) / 1000,
1788
+ c: Math.round(dv.c * 1000) / 1000,
1789
+ };
1790
+
1791
+ for (let i = 0; i < pEphem.length; i++) {
1792
+ const distKm = dist(pEphem[i].p, sEphem[i].p);
1793
+ if (distKm < aResult.poca) {
1794
+ aResult.poca = distKm;
1795
+ aResult.toca = new Date(pEphem[i].t).toISOString();
1796
+ aResult.tocaString = getTimeDifference(aResult.toca);
1797
+ }
1798
+ }
1799
+
1800
+ aResult.danger = (aResult.di === 0 || aResult.poca === 0)
1801
+ ? 1000
1802
+ : 1 / (aResult.di * aResult.poca);
1803
+ return aResult;
1804
+ };
1805
+
1728
1806
  const getLeoRpoData = (line1, line2, sats, startTime, endTime) => {
1729
1807
  const results = [];
1730
1808
  const pSatRec = checkTle(line1, line2);
@@ -1732,78 +1810,20 @@ const getLeoRpoData = (line1, line2, sats, startTime, endTime) => {
1732
1810
 
1733
1811
  const start = new Date(startTime).getTime();
1734
1812
  const end = new Date(endTime).getTime();
1735
- const pElset = {
1736
- Line1: line1,
1737
- Line2: line2,
1738
- };
1813
+ const pElset = {Line1: line1, Line2: line2};
1739
1814
  const pEphem = prop(pElset, start, end, 10000);
1740
1815
  if (pEphem.length === 0) {return results;} // Primary may have re-entered the atmosphere
1741
1816
 
1742
- sats.forEach( (s) => {
1817
+ sats.forEach((s) => {
1743
1818
  const sEphem = prop(s, start, end, 10000);
1744
- if (!isDefined(pEphem)
1745
- || !isDefined(sEphem)
1746
- || pEphem.length !== sEphem.length) {
1747
- return;
1748
- }
1749
-
1750
- const aResult = {
1751
- line1: s.Line1,
1752
- line2: s.Line2,
1753
- epoch: s.Epoch,
1754
- name: s.CommonName,
1755
- rank: s.Rank ?? "",
1756
- satNo: s.SatNo,
1757
- inclination: s.Inclination,
1758
- raan: s.Raan,
1759
- source: s.Source,
1760
- datamode: s.DataMode,
1761
- sma: s.SemiMajorAxis,
1762
- country: s.CountryId,
1763
- flag: s.Flag,
1764
- poca: 9999999999,
1765
- toca: "",
1766
- tocaString: "",
1767
- planeDiff: 0,
1768
- incDiff: s.incDiff,
1769
- raanDiff: s.raanDiff,
1770
- semiMajorDiff: s.semiMajorDiff,
1771
- raanDrift: s.RaanPrecessionDegreesPerDay,
1772
- };
1773
-
1774
- const pv1 = {
1775
- position: pEphem[0].p,
1776
- velocity: pEphem[0].v,
1777
- };
1778
- const pv2 = {
1779
- position: sEphem[0].p,
1780
- velocity: sEphem[0].v,
1781
- };
1782
- aResult.di = angleBetweenPlanes(pv1, pv2);
1783
- aResult.dv = planeChangeDeltaV(pv1, pv2);
1784
- aResult.dv = {
1785
- i: Math.round(aResult.dv.i*1000)/1000,
1786
- c: Math.round(aResult.dv.c*1000)/1000,
1787
- }; // Round to 3 decimals
1788
-
1789
- // Find the distance at each time step
1790
- for (let i=0; i<pEphem.length; i++) {
1791
- const distKm = dist(pEphem[i].p, sEphem[i].p);
1792
- if (distKm < aResult.poca) {
1793
- aResult.poca = distKm;
1794
- aResult.toca = new Date(pEphem[i].t).toISOString();
1795
- aResult.tocaString = getTimeDifference(aResult.toca);
1796
- }
1797
- }
1798
-
1799
- // Calc danger score (account for di of 0)
1800
- aResult.danger = (aResult.di === 0
1801
- || aResult.poca === 0) ? 1000 : 1/(aResult.di*aResult.poca);
1802
- results.push(aResult);
1819
+ const row = assembleLeoRpoResult(s, pEphem, sEphem);
1820
+ if (isDefined(row)) {results.push(row);}
1803
1821
  });
1804
1822
  return results;
1805
1823
  };
1806
1824
 
1825
+ // `getLeoRpoDataBulk` extracted to `./wasmProp/wasmAstro.js`.
1826
+
1807
1827
 
1808
1828
  /**
1809
1829
  * Get GEO RPO data for a given target satellite and a set of potential threat satellites.
@@ -1815,208 +1835,247 @@ const getLeoRpoData = (line1, line2, sats, startTime, endTime) => {
1815
1835
  * @param {Integer} lonTime, datetime to analyze longitude and lon drift at, Unix milliseconds
1816
1836
  * @return {Array<Objects>}, Array of RPO data objects
1817
1837
  */
1838
+ /**
1839
+ * GEO-specific signed longitude-difference rate. Encodes whether the two
1840
+ * satellites' longitudes are converging (negative magnitude) or diverging
1841
+ * (positive magnitude) given their respective east-positive drifts. Lifted
1842
+ * verbatim out of `getGeoRpoData`'s nested closure so both the JS and WASM
1843
+ * variants share identical drift accounting.
1844
+ *
1845
+ * @param {Number} lon1 satellite-1 longitude (degrees, signed)
1846
+ * @param {Number} drift1 satellite-1 drift, degrees/day east-positive
1847
+ * @param {Number} lon2 satellite-2 longitude (degrees, signed)
1848
+ * @param {Number} drift2 satellite-2 drift, degrees/day east-positive
1849
+ * @return {Number}
1850
+ */
1851
+ const geoRelativeLonDrift = (lon1, drift1, lon2, drift2) => {
1852
+ let closing = false;
1853
+ if (lon1 > lon2) {
1854
+ closing = (drift1 * drift2 > 0 && drift1 < drift2)
1855
+ || (drift1 * drift2 < 0 && drift1 < 0);
1856
+ } else if (lon2 > lon1) {
1857
+ closing = (drift1 * drift2 > 0 && drift2 < drift1)
1858
+ || (drift1 * drift2 < 0 && drift1 > 0);
1859
+ }
1860
+ const difference = drift1 - drift2;
1861
+ return closing ? -Math.abs(difference) : Math.abs(difference);
1862
+ };
1863
+
1864
+ /**
1865
+ * Pure assembly helper for a single GEO RPO comparison row. Identical math
1866
+ * to the original inline body of `getGeoRpoData`, factored out so the
1867
+ * JS-path (`getGeoRpoData`) and WASM-path (`getGeoRpoDataBulk`) emit
1868
+ * byte-equivalent structures.
1869
+ *
1870
+ * `getLonAndDrift` is JS-only by design (see the `low-value` opportunity
1871
+ * list in `docs/BULK_PROPAGATION.md`); both call sites compute it
1872
+ * upstream and pass the precomputed result in.
1873
+ *
1874
+ * Returns `undefined` if either ephemeris is missing or the lengths do not
1875
+ * match (mirrors the original guard).
1876
+ *
1877
+ * @param {Object} s threat elset / metadata record
1878
+ * @param {Array<{p:number[],v:number[],t:number}>} pEphem primary ephemeris
1879
+ * @param {Array<{p:number[],v:number[],t:number}>} sEphem threat ephemeris
1880
+ * @param {Object} pLonAndDrift output of `getLonAndDrift(primary)`
1881
+ * @param {Object} sLonAndDrift output of `getLonAndDrift(threat)`
1882
+ * @return {Object|undefined}
1883
+ */
1884
+ const assembleGeoRpoResult = (s, pEphem, sEphem, pLonAndDrift, sLonAndDrift) => {
1885
+ if (!isDefined(pEphem)
1886
+ || !isDefined(sEphem)
1887
+ || pEphem.length === 0
1888
+ || pEphem.length !== sEphem.length) {
1889
+ return undefined;
1890
+ }
1891
+
1892
+ const aResult = {
1893
+ line1: s.Line1,
1894
+ line2: s.Line2,
1895
+ epoch: s.Epoch,
1896
+ name: s.CommonName,
1897
+ rank: s.Rank ?? "",
1898
+ satNo: s.SatNo,
1899
+ source: s.Source,
1900
+ country: s.CountryId,
1901
+ datamode: s.DataMode,
1902
+ flag: s.Flag,
1903
+ poca: 9999999999,
1904
+ toca: "",
1905
+ tocaString: "",
1906
+ incDiff: s.incDiff,
1907
+ longitude: (sLonAndDrift.longitude + 360) % 360,
1908
+ lonDiff: null,
1909
+ relativeDrift: null,
1910
+ di: null,
1911
+ dv: null,
1912
+ danger: null,
1913
+ };
1914
+
1915
+ const lonDiff = Math.abs(
1916
+ (pLonAndDrift.longitude + 360) % 360
1917
+ - (sLonAndDrift.longitude + 360) % 360,
1918
+ );
1919
+ aResult.lonDiff = Math.min(lonDiff, 360 - lonDiff);
1920
+
1921
+ aResult.relativeDrift = geoRelativeLonDrift(
1922
+ pLonAndDrift.longitude,
1923
+ pLonAndDrift.lonDriftDegreesPerDay,
1924
+ sLonAndDrift.longitude,
1925
+ sLonAndDrift.lonDriftDegreesPerDay,
1926
+ );
1927
+
1928
+ const pv1 = {position: pEphem[0].p, velocity: pEphem[0].v};
1929
+ const pv2 = {position: sEphem[0].p, velocity: sEphem[0].v};
1930
+ aResult.di = angleBetweenPlanes(pv1, pv2);
1931
+ const dv = planeChangeDeltaV(pv1, pv2);
1932
+ aResult.dv = {
1933
+ i: Math.round(dv.i * 1000) / 1000,
1934
+ c: Math.round(dv.c * 1000) / 1000,
1935
+ };
1936
+
1937
+ for (let i = 0; i < pEphem.length; i++) {
1938
+ const distKm = dist(pEphem[i].p, sEphem[i].p);
1939
+ if (distKm < aResult.poca) {
1940
+ aResult.poca = distKm;
1941
+ aResult.toca = new Date(pEphem[i].t).toISOString();
1942
+ aResult.tocaString = getTimeDifference(aResult.toca);
1943
+ }
1944
+ }
1945
+
1946
+ aResult.danger = (aResult.di === 0 || aResult.poca === 0)
1947
+ ? 1000
1948
+ : 1 / (aResult.di * aResult.poca);
1949
+ return aResult;
1950
+ };
1951
+
1818
1952
  const getGeoRpoData = (line1, line2, sats, startTime, endTime, lonTime) => {
1953
+ const results = [];
1819
1954
  const start = new Date(startTime).getTime();
1820
1955
  const end = new Date(endTime).getTime();
1821
- const pEphem = prop({
1822
- Line1: line1,
1823
- Line2: line2,
1824
- }, start, end, 60000);
1825
- if (pEphem.length === 0) {return results;} // Primary may have re-entered the atmosphere
1956
+ const pEphem = prop({Line1: line1, Line2: line2}, start, end, 60000);
1957
+ if (!isDefined(pEphem) || pEphem.length === 0) {
1958
+ return results; // Primary may have re-entered the atmosphere
1959
+ }
1826
1960
 
1827
1961
  const lonEvalTime = lonTime ? new Date(lonTime) : new Date(end);
1828
-
1829
1962
  const pLonAndDrift = getLonAndDrift(line1, line2, lonEvalTime);
1830
1963
 
1831
- const getLonDiff = (lon1, drift1, lon2, drift2) => {
1832
- const closing = lon1 > lon2
1833
- ? (drift1 * drift2 > 0 && drift1 < drift2) || (drift1 * drift2 < 0 && drift1 < 0) // Sat1 is more Eastward
1834
- : lon2 > lon1
1835
- ? (drift1 * drift2 > 0 && drift2 < drift1) || (drift1 * drift2 < 0 && drift1 > 0) // Sat2 is more Eastward
1836
- : false;
1837
- const difference = drift1 - drift2;
1838
- return closing ? -1 * Math.abs(difference) : Math.abs(difference);
1839
- };
1840
-
1841
- const results = [];
1842
1964
  sats.forEach((s) => {
1843
- const aResult = {
1844
- line1: s.Line1,
1845
- line2: s.Line2,
1846
- epoch: s.Epoch,
1847
- name: s.CommonName,
1848
- rank: s.Rank ?? "",
1849
- satNo: s.SatNo,
1850
- source: s.Source,
1851
- country: s.CountryId,
1852
- datamode: s.DataMode,
1853
- flag: s.Flag,
1854
- poca: 9999999999,
1855
- toca: "",
1856
- tocaString: "",
1857
- incDiff: s.incDiff,
1858
- longitude: null,
1859
- lonDiff: null,
1860
- relativeDrift: null,
1861
- di: null,
1862
- dv: null,
1863
- danger: null,
1864
- };
1865
-
1866
- const sEphem = prop({
1867
- Line1: aResult.line1,
1868
- Line2: aResult.line2,
1869
- }, start, end, 60000);
1870
- if (!isDefined(pEphem)
1871
- || !isDefined(sEphem)
1872
- || pEphem.length !== sEphem.length) {
1873
- return;
1874
- }
1965
+ const sEphem = prop({Line1: s.Line1, Line2: s.Line2}, start, end, 60000);
1875
1966
  const sLonAndDrift = getLonAndDrift(s.Line1, s.Line2, lonEvalTime);
1876
- aResult.longitude = (sLonAndDrift.longitude + 360) % 360; // Normalize to 0-360
1877
-
1878
- const lonDiff = Math.abs(
1879
- (pLonAndDrift.longitude + 360) % 360
1880
- - (sLonAndDrift.longitude + 360) % 360,
1881
- );
1882
- // To ensure that the smallest "short-way" lon diff is considered
1883
- aResult.lonDiff = Math.min(lonDiff, 360 - lonDiff);
1884
-
1885
- aResult.relativeDrift = getLonDiff(
1886
- pLonAndDrift.longitude,
1887
- pLonAndDrift.lonDriftDegreesPerDay,
1888
- sLonAndDrift.longitude,
1889
- sLonAndDrift.lonDriftDegreesPerDay);
1890
- const pv1 = {
1891
- position: pEphem[0].p,
1892
- velocity: pEphem[0].v,
1893
- };
1894
- const pv2 = {
1895
- position: sEphem[0].p,
1896
- velocity: sEphem[0].v,
1897
- };
1898
- aResult.di = angleBetweenPlanes(pv1, pv2);
1899
- aResult.dv = {
1900
- i: Math.round(planeChangeDeltaV(pv1, pv2).i*1000)/1000,
1901
- c: Math.round(planeChangeDeltaV(pv1, pv2).c*1000)/1000,
1902
- }; // Round to 3 decimals
1903
-
1904
- for (let i=0; i<pEphem.length; i++) {
1905
- const distKm = dist(pEphem[i].p, sEphem[i].p);
1906
- if (distKm < aResult.poca) {
1907
- aResult.poca = distKm;
1908
- aResult.toca = new Date(pEphem[i].t).toISOString();
1909
- aResult.tocaString = getTimeDifference(aResult.toca);
1910
- }
1911
- }
1912
- // Calc danger score (account for di of 0)
1913
- aResult.danger = (aResult.di === 0
1914
- || aResult.poca === 0) ? 1000 : 1/(aResult.di*aResult.poca);
1915
-
1916
- results.push(aResult);
1967
+ const row = assembleGeoRpoResult(s, pEphem, sEphem, pLonAndDrift, sLonAndDrift);
1968
+ if (isDefined(row)) {results.push(row);}
1917
1969
  });
1918
1970
 
1919
1971
  return results;
1920
1972
  };
1921
1973
 
1922
- const getGeoShadowZones = (time, accuracySecondsDeg=0.00416*100) => {
1923
- // Accuracy is 86400 pooints on a 360 circle (i.e. 0.00416 deg per second)
1974
+ // `getGeoRpoDataBulk` extracted to `./wasmProp/wasmAstro.js`.
1924
1975
 
1925
- // Define the TLE parameters of a template TLE in GEO
1926
- const tleLine1 = "1 00000U 00000A 24079.98445361 -.00000000 00000-0 -00000-0 0 00000";
1927
- const tleLine2Temp = "2 00000 000.0000 000.0000 0000000 000.0000 XXX.XXXX 01.00000000000000";
1928
- // Number of steps for the mean anomaly
1929
- const steps = 360 / accuracySecondsDeg;
1930
- // Loop over the range of mean anomalies
1931
- const res = [];
1976
+ /** GEO mean-anomaly TLE template used by the shadow-zone scan. The
1977
+ * `XXX.XXXX` token is substituted with each candidate mean anomaly.
1978
+ * TLE lines are fixed-length by definition, hence the disable-next-line. */
1979
+ // eslint-disable-next-line max-len
1980
+ const SHADOW_ZONE_TLE_LINE_1 = "1 00000U 00000A 24079.98445361 -.00000000 00000-0 -00000-0 0 00000";
1981
+ // eslint-disable-next-line max-len
1982
+ const SHADOW_ZONE_TLE_LINE_2_TEMPLATE = "2 00000 000.0000 000.0000 0000000 000.0000 XXX.XXXX 01.00000000000000";
1983
+
1984
+ /**
1985
+ * Build the array of GEO mean-anomaly elsets the shadow-zone scan
1986
+ * exercises. Pure; no propagation. Produces `360 / accuracySecondsDeg`
1987
+ * elsets, one for each mean anomaly slot, in increasing order.
1988
+ *
1989
+ * @param {Number} accuracySecondsDeg degrees per slot
1990
+ * @return {Array<{Line1:string, Line2:string}>}
1991
+ */
1992
+ const buildShadowZoneElsets = (accuracySecondsDeg) => {
1993
+ // Use Math.floor so fractional `360 / accuracySecondsDeg` (e.g.
1994
+ // accuracy values that don't divide 360 exactly) still produce a
1995
+ // valid integer step count. Matches the original loop's `i < steps`
1996
+ // behavior, which also truncated.
1997
+ const steps = Math.floor(360 / accuracySecondsDeg);
1998
+ const out = new Array(steps);
1932
1999
  for (let i = 0; i < steps; i++) {
1933
- // Calculate the mean anomaly for this step
1934
2000
  const meanAnomaly = i * accuracySecondsDeg;
1935
- // Format the mean anomaly into the TLE line
1936
- // Round the mean anomaly to two decimal places
1937
2001
  const roundedMeanAnomaly = meanAnomaly.toFixed(4);
1938
- // Format the mean anomaly into the TLE line
1939
- const tleLine2 = tleLine2Temp.replace("XXX.XXXX", roundedMeanAnomaly.padStart(8, "0"));
1940
- // Generate the satrec object
1941
- const p = propagate(twoline2satrec(tleLine1, tleLine2), time).position;
1942
- res.push({
1943
- ecl: getEclipseStatus(time, [p.x, p.y, p.z]),
1944
- geolon: eciToGeodetic(p, gstime(time)).longitude*RAD2DEG,
1945
- });
2002
+ const tleLine2 = SHADOW_ZONE_TLE_LINE_2_TEMPLATE.replace(
2003
+ "XXX.XXXX", roundedMeanAnomaly.padStart(8, "0"),
2004
+ );
2005
+ out[i] = {Line1: SHADOW_ZONE_TLE_LINE_1, Line2: tleLine2};
1946
2006
  }
1947
- // Find the sun zone, umbra zone, and two penumbra zones.
1948
- const ints = [];
1949
-
1950
- // Open the first zone. Note that this may not be the start of this zone but somewhere in the middle.
1951
- // It's just the start of the mean anomaly seen as a circular buffer.
1952
- ints.push({
1953
- ecl: res[0].ecl,
1954
- });
2007
+ return out;
2008
+ };
1955
2009
 
1956
- for (let i=1; i<=res.length-1; i++) {
1957
- // Compare with the previous eclipse state
1958
- if (res[i].ecl!==res[i-1].ecl) {
1959
- // Close the previous zone
1960
- ints.at(-1).stopDeg = res[i-1].geolon;
2010
+ /**
2011
+ * Pure zone-analysis pass. Takes the per-slot
2012
+ * `[{ecl, geolon}, ...]` series produced by either the JS or WASM scan
2013
+ * and returns `{penStartWestLon, penStartEastLon}` exactly as the
2014
+ * original `getGeoShadowZones` does.
2015
+ *
2016
+ * Lifted out of the original function body without semantic changes:
2017
+ * - identical interval seam-stitching across the 360°/0° wraparound,
2018
+ * - identical "fully lit GEO belt" early return,
2019
+ * - identical penumbra-only vs. umbra+penumbra branching.
2020
+ *
2021
+ * @param {Array<{ecl:string, geolon:number}>} res
2022
+ * @return {{penStartWestLon:?number, penStartEastLon:?number}}
2023
+ */
2024
+ const analyzeShadowZoneSeries = (res) => {
2025
+ const ints = [{ecl: res[0].ecl}];
1961
2026
 
1962
- // Open a new zone
1963
- ints.push({
1964
- ecl: res[i].ecl,
1965
- startDeg: res[i].geolon,
1966
- });
2027
+ for (let i = 1; i <= res.length - 1; i++) {
2028
+ if (res[i].ecl !== res[i - 1].ecl) {
2029
+ ints.at(-1).stopDeg = res[i - 1].geolon;
2030
+ ints.push({ecl: res[i].ecl, startDeg: res[i].geolon});
1967
2031
  }
1968
2032
  }
1969
- // With this process, the first interval will have a stop lon but not a start lon, and
1970
- // the final interval will have a start lon but not a stop lon.
1971
- // Stitch the first and last intervals together.
1972
2033
 
1973
- // If not dates are pushed on the first interval, it means it never closed, so it's a fully lit GEO belt.
1974
- // If there's only sun, return null values
1975
- if ( ints.length===1
1976
- && ints.filter((x)=>x.ecl === "SUN").length===1) {
1977
- return {
1978
- penStartWestLon: null,
1979
- penStartEastLon: null,
1980
- };
2034
+ if (ints.length === 1
2035
+ && ints.filter((x) => x.ecl === "SUN").length === 1) {
2036
+ return {penStartWestLon: null, penStartEastLon: null};
1981
2037
  }
1982
2038
 
1983
2039
  ints[0].startDeg = ints.at(-1).startDeg;
1984
2040
  ints.pop();
1985
2041
 
1986
- if (
1987
- ints.filter((x)=>x.ecl === "UMBRA").length===0
1988
- && ints.some((x)=>x.ecl === "PENUMBRA")) {
1989
- // If there's no umbra but penumbra exists, return the perumbra values
1990
-
1991
- // Extract the penumbra interval. In the absence of umbra, a single penumbra interval should exist.
1992
- const penumbraInt = ints.find((x)=>x.ecl === "PENUMBRA");
2042
+ if (ints.filter((x) => x.ecl === "UMBRA").length === 0
2043
+ && ints.some((x) => x.ecl === "PENUMBRA")) {
2044
+ const penumbraInt = ints.find((x) => x.ecl === "PENUMBRA");
1993
2045
  return {
1994
2046
  penStartWestLon: wrapToRange(penumbraInt.startDeg, 0, 360),
1995
2047
  penStartEastLon: wrapToRange(penumbraInt.stopDeg, 0, 360),
1996
2048
  };
1997
- } else {
1998
- // If sun, umbra, penumbra intervals exist
1999
-
2000
- const umbra = ints.find((x)=>x.ecl === "UMBRA");
2049
+ }
2001
2050
 
2002
- const umbraStart360 = wrapToRange(umbra.startDeg, 0, 360);
2003
- const umbraStop360 = wrapToRange(umbra.stopDeg, 0, 360);
2004
- // Assuming a spherical earth for this purpose, the two penumbra intervals should be equal.
2005
- // Compute the angle of one of the penumbra intervals, the one that does not contain the disconituity.
2006
- const penumbraInts = ints.filter((x)=>x.ecl === "PENUMBRA");
2007
- const chosenPenumbraInt = penumbraInts[0].startDeg > penumbraInts[0].stopDeg
2008
- ? penumbraInts[1] : penumbraInts[0];
2009
- const penumbraAngle = chosenPenumbraInt.stopDeg - chosenPenumbraInt.startDeg;
2010
- const penumbraStart = umbraStart360 - penumbraAngle;
2011
- const penumbraStop = umbraStop360 + penumbraAngle;
2051
+ const umbra = ints.find((x) => x.ecl === "UMBRA");
2052
+ const umbraStart360 = wrapToRange(umbra.startDeg, 0, 360);
2053
+ const umbraStop360 = wrapToRange(umbra.stopDeg, 0, 360);
2054
+ const penumbraInts = ints.filter((x) => x.ecl === "PENUMBRA");
2055
+ const chosenPenumbraInt = penumbraInts[0].startDeg > penumbraInts[0].stopDeg
2056
+ ? penumbraInts[1] : penumbraInts[0];
2057
+ const penumbraAngle = chosenPenumbraInt.stopDeg - chosenPenumbraInt.startDeg;
2058
+ return {
2059
+ penStartWestLon: umbraStart360 - penumbraAngle,
2060
+ penStartEastLon: umbraStop360 + penumbraAngle,
2061
+ };
2062
+ };
2012
2063
 
2064
+ const getGeoShadowZones = (time, accuracySecondsDeg = 0.00416 * 100) => {
2065
+ // Default accuracy: 86400 points on the 360° circle (~0.00416 deg/sec).
2066
+ const elsets = buildShadowZoneElsets(accuracySecondsDeg);
2067
+ const res = elsets.map(({Line1, Line2}) => {
2068
+ const p = propagate(twoline2satrec(Line1, Line2), time).position;
2013
2069
  return {
2014
- penStartWestLon: penumbraStart,
2015
- penStartEastLon: penumbraStop,
2070
+ ecl: getEclipseStatus(time, [p.x, p.y, p.z]),
2071
+ geolon: eciToGeodetic(p, gstime(time)).longitude * RAD2DEG,
2016
2072
  };
2017
- }
2073
+ });
2074
+ return analyzeShadowZoneSeries(res);
2018
2075
  };
2019
2076
 
2077
+ // `getGeoShadowZonesBulk` extracted to `./wasmProp/wasmAstro.js`.
2078
+
2020
2079
  /** Returns the light intervals of an arbitrary satellite in strict GEO orbit.
2021
2080
  *
2022
2081
  * These intervals are generally equal in duration for all other GEO satellites.
@@ -2415,66 +2474,85 @@ const calculateLeoPhaseDifference = (pLine1, pLine2, tLine1, tLine2, time) => {
2415
2474
  * @param {Integer} stepMs, step time of the analysis in milliseconds, default 10s
2416
2475
  * @return {Array} Array of data objects
2417
2476
  */
2418
- const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2419
- const results = [];
2420
-
2421
- const start = new Date(startTime).getTime();
2422
- const end = new Date(endTime).getTime();
2423
-
2424
- const currentIndices = elsets.map((sat) => 0);
2477
+ /**
2478
+ * Build the breakpoint schedule for `getLeoWaterfallData` /
2479
+ * `getLeoWaterfallDataBulk`. The waterfall covers `[start, end]` divided
2480
+ * into segments at every per-satellite elset epoch that falls strictly
2481
+ * inside that range. Pure; no propagation.
2482
+ *
2483
+ * Each breakpoint is `{time, satIndex, elsetIndex?}`. The two synthetic
2484
+ * sentinel breakpoints at `start` / `end` carry `satIndex: -1` so the
2485
+ * segment loop can distinguish "edge of window" from "satellite epoch".
2486
+ *
2487
+ * @param {Array<Array<Object>>} elsets per-satellite elset arrays
2488
+ * @param {Number} start Unix ms
2489
+ * @param {Number} end Unix ms
2490
+ * @return {Array<{time:number, satIndex:number, elsetIndex?:number}>}
2491
+ */
2492
+ const buildWaterfallBreakpoints = (elsets, start, end) => {
2425
2493
  const breakpoints = [{time: start, satIndex: -1}, {time: end, satIndex: -1}];
2426
2494
  elsets.forEach((satElsets, i) => {
2427
2495
  breakpoints.push(...satElsets.map((e, j) => ({
2428
2496
  time: new Date(e.Epoch).getTime(),
2429
2497
  satIndex: i,
2430
2498
  elsetIndex: j,
2431
- })).filter((b) => b.time > start && b.time < end)); // ensure breakpoints are inside time range
2499
+ })).filter((b) => b.time > start && b.time < end));
2432
2500
  });
2433
2501
  breakpoints.sort((a, b) => a.time - b.time);
2502
+ return breakpoints;
2503
+ };
2434
2504
 
2435
- const satEphems = elsets.map((sat) => []);
2436
-
2437
- for (let i = 0; i < breakpoints.length-1; i++) {
2438
- const bkpoint = breakpoints[i];
2439
- // if we start at breakpoints, it's irregular (since the epochs aren't in lockstep)
2440
- // we can force it to Math.ceil(segmentStart/stepMs)*stepMs for even steps but since the discrepancy
2441
- // is less than 10s in a plot over multiple days it's rather negligible
2442
- // also this makes it easier since we can check if its an epoch based on the segmentStart
2443
- const segmentStart = bkpoint.time;
2444
- const segmentEnd = breakpoints[i+1].time;
2445
-
2446
- if (bkpoint.satIndex >= 0) { // this is a satellite's elset's epoch
2447
- currentIndices[bkpoint.satIndex] = bkpoint.elsetIndex;
2448
- }
2449
-
2450
- currentIndices.forEach((elsetIndex, satIndex) => {
2451
- const elset = elsets[satIndex][elsetIndex];
2452
- const epoch = new Date(elset.Epoch).getTime();
2453
- const ephem = prop(elset, segmentStart, segmentEnd, stepMs);
2454
- satEphems[satIndex].push(...ephem.map((point, pointInd) => {
2455
- const osculatingElements = cartesianToKeplerian(
2456
- multiply(posToArray(point.p), 1000), // km to m
2457
- multiply(posToArray(point.v), 1000), // km/s to m/s
2458
- );
2459
- return {
2460
- ...point,
2461
- isEpoch: pointInd === 0 && bkpoint.satIndex === satIndex,
2462
- epoch: epoch,
2463
- satNo: elset.SatNo,
2464
- name: elset.CommonName,
2465
- source: elset.Source,
2466
- raan: osculatingElements.raan, // deg
2467
- raanPrecession: elset.RaanPrecessionDegreesPerDay,
2468
- inclination: osculatingElements.i, // deg
2469
- };
2470
- }));
2471
- });
2472
- }
2505
+ /**
2506
+ * Decorate a per-segment ephemeris with osculating elements and elset
2507
+ * metadata. Identical post-processing as the original inline `.map()` in
2508
+ * `getLeoWaterfallData`. Used by both the JS and WASM waterfall variants
2509
+ * so the two emit byte-equivalent rows.
2510
+ *
2511
+ * `isEpoch` is `true` for the first point of the segment if-and-only-if
2512
+ * the segment was opened by *this* satellite's elset epoch (matches the
2513
+ * original `pointInd === 0 && bkpoint.satIndex === satIndex` check).
2514
+ *
2515
+ * @param {Array<{p:number[],v:number[],t:number}>} ephem segment ephemeris
2516
+ * @param {Object} elset active elset for this satellite in this segment
2517
+ * @param {{satIndex:number}} bkpoint segment-opening breakpoint
2518
+ * @param {Number} satIndex index of the satellite in the original `elsets`
2519
+ * @return {Array<Object>}
2520
+ */
2521
+ const decorateWaterfallSegment = (ephem, elset, bkpoint, satIndex) => {
2522
+ const epoch = new Date(elset.Epoch).getTime();
2523
+ return ephem.map((point, pointInd) => {
2524
+ const osculatingElements = cartesianToKeplerian(
2525
+ multiply(posToArray(point.p), 1000), // km -> m
2526
+ multiply(posToArray(point.v), 1000), // km/s -> m/s
2527
+ );
2528
+ return {
2529
+ ...point,
2530
+ isEpoch: pointInd === 0 && bkpoint.satIndex === satIndex,
2531
+ epoch: epoch,
2532
+ satNo: elset.SatNo,
2533
+ name: elset.CommonName,
2534
+ source: elset.Source,
2535
+ raan: osculatingElements.raan, // deg
2536
+ raanPrecession: elset.RaanPrecessionDegreesPerDay,
2537
+ inclination: osculatingElements.i, // deg
2538
+ };
2539
+ });
2540
+ };
2473
2541
 
2542
+ /**
2543
+ * Build the per-(time, satellite) waterfall result rows from the
2544
+ * decorated ephemerides produced by {@link decorateWaterfallSegment}.
2545
+ * Identical math to the second loop of the original `getLeoWaterfallData`.
2546
+ * Pure; deterministic; shared by both variants.
2547
+ *
2548
+ * @param {Array<Array<Object>>} satEphems primary at index 0
2549
+ * @return {Array<Object>}
2550
+ */
2551
+ const assembleWaterfallRows = (satEphems) => {
2552
+ const results = [];
2474
2553
  const pEphem = satEphems[0];
2475
2554
 
2476
- satEphems.forEach( (sEphem, satIndex) => {
2477
- // Find the distance at each time step
2555
+ satEphems.forEach((sEphem, satIndex) => {
2478
2556
  for (let i = 0; i < pEphem.length; i++) {
2479
2557
  const pEphemPoint = pEphem[i];
2480
2558
  const sEphemPoint = sEphem[i];
@@ -2493,7 +2571,7 @@ const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2493
2571
 
2494
2572
  if (satIndex > 0) { // not the primary
2495
2573
  if (areCoordsEqual(pEphemPoint.p, sEphemPoint.p)) {
2496
- catsAngle = 180; // they're in the same spot, so just say secondary can see primary
2574
+ catsAngle = 180;
2497
2575
  } else {
2498
2576
  catsAngle = angleBetween3DCoords(
2499
2577
  pEphemPoint.p,
@@ -2501,7 +2579,6 @@ const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2501
2579
  getSunDirection(time),
2502
2580
  );
2503
2581
  }
2504
-
2505
2582
  const earthEclipsed = doesLineSegmentIntersectEarth(pEphemPoint.p, sEphemPoint.p);
2506
2583
  const targetShadow = getEclipseStatus(time, posToArray(sEphemPoint.p));
2507
2584
  targetVisibility = earthEclipsed ? "EARTH ECLIPSED" : targetShadow;
@@ -2529,6 +2606,40 @@ const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2529
2606
  return results;
2530
2607
  };
2531
2608
 
2609
+ const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2610
+ const start = new Date(startTime).getTime();
2611
+ const end = new Date(endTime).getTime();
2612
+
2613
+ const breakpoints = buildWaterfallBreakpoints(elsets, start, end);
2614
+ const currentIndices = elsets.map(() => 0);
2615
+ const satEphems = elsets.map(() => []);
2616
+
2617
+ for (let i = 0; i < breakpoints.length - 1; i++) {
2618
+ const bkpoint = breakpoints[i];
2619
+ // Segment boundaries align with elset epochs, not multiples of `stepMs`.
2620
+ // The intra-segment ephemeris drift (<= one step) is negligible at the
2621
+ // multi-day plot resolution this function targets.
2622
+ const segmentStart = bkpoint.time;
2623
+ const segmentEnd = breakpoints[i + 1].time;
2624
+
2625
+ if (bkpoint.satIndex >= 0) {
2626
+ currentIndices[bkpoint.satIndex] = bkpoint.elsetIndex;
2627
+ }
2628
+
2629
+ currentIndices.forEach((elsetIndex, satIndex) => {
2630
+ const elset = elsets[satIndex][elsetIndex];
2631
+ const ephem = prop(elset, segmentStart, segmentEnd, stepMs);
2632
+ satEphems[satIndex].push(
2633
+ ...decorateWaterfallSegment(ephem, elset, bkpoint, satIndex),
2634
+ );
2635
+ });
2636
+ }
2637
+
2638
+ return assembleWaterfallRows(satEphems);
2639
+ };
2640
+
2641
+ // `getLeoWaterfallDataBulk` extracted to `./wasmProp/wasmAstro.js`.
2642
+
2532
2643
  /**
2533
2644
  * Returns the minimum deltaV for a Lambert maneuver between two satellites,
2534
2645
  * within the given time range.
@@ -3325,6 +3436,18 @@ export {
3325
3436
  getGeoRpoData,
3326
3437
  getGeoShadowZones,
3327
3438
  getGeoLightIntervals,
3439
+ // Shared pure assembly helpers, exported so `./wasmProp/wasmAstro.js` can
3440
+ // consume them without duplicating logic. These are cross-path
3441
+ // building blocks (used by both the JS-sync and WASM-async siblings)
3442
+ // and are safe to call as ordinary utilities.
3443
+ assembleLeoRpoResult,
3444
+ geoRelativeLonDrift,
3445
+ assembleGeoRpoResult,
3446
+ buildShadowZoneElsets,
3447
+ analyzeShadowZoneSeries,
3448
+ buildWaterfallBreakpoints,
3449
+ decorateWaterfallSegment,
3450
+ assembleWaterfallRows,
3328
3451
  getEclipseStatus,
3329
3452
  estimateSlantRange,
3330
3453
  calculateNextApogeePerigeeTimes,