@saber-usa/node-common 1.7.18 → 1.7.20

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
@@ -848,6 +854,17 @@ const doesLineSegmentIntersectEarth = (start, end) => {
848
854
  {x: 0, y: 0, z: 0}, WGS72_EARTH_EQUATORIAL_RADIUS_KM);
849
855
  };
850
856
 
857
+ const getRIC = (prim, target) => {
858
+ const deltaR = {
859
+ x: prim.p.x - target.p.x,
860
+ y: prim.p.y - target.p.y,
861
+ z: prim.p.z - target.p.z,
862
+ };
863
+ const cartToRICMatrix = cartesianToRIC(prim.p, prim.v);
864
+ const ricP = multiplyVector(deltaR, cartToRICMatrix);
865
+ return ricP;
866
+ };
867
+
851
868
  /**
852
869
  * Given primary and target ephems, calculate the time-based, radial, intrack, and crosstrack data
853
870
  *
@@ -881,13 +898,7 @@ const getTRIC = (pEphem, tEphem) => {
881
898
  datamodes.push(prim.datamode + ", " + target.datamode);
882
899
 
883
900
  // Calculate RIC values.
884
- const deltaR = {
885
- x: prim.p.x - target.p.x,
886
- y: prim.p.y - target.p.y,
887
- z: prim.p.z - target.p.z,
888
- };
889
- const cartToRICMatrix = cartesianToRIC(prim.p, prim.v);
890
- const ricP = multiplyVector(deltaR, cartToRICMatrix);
901
+ const ricP = getRIC(prim, target);
891
902
  r.push(-1 * Math.round(ricP.x * 10000) / 10000);
892
903
  i.push(-1 * Math.round(ricP.y * 10000) / 10000);
893
904
  c.push(-1 * Math.round(ricP.z * 10000) / 10000);
@@ -1725,85 +1736,122 @@ const cartesianToElsetElements = (pv, epoch) => {
1725
1736
  * @param {Integer} endTime, end time of the analysis, Unix milliseconds
1726
1737
  * @return {Array} Array of RPO data objects
1727
1738
  */
1728
- const getLeoRpoData = (line1, line2, sats, startTime, endTime) => {
1739
+ /**
1740
+ * Pure assembly helper for a single LEO RPO comparison row. Takes the primary
1741
+ * and threat ephemerides (already produced by either the JS `prop` path or
1742
+ * the WASM `propBulk` path) and returns the assembled result object. Shared
1743
+ * by `getLeoRpoData` and `getLeoRpoDataBulk` to guarantee the JS and WASM
1744
+ * variants emit byte-identical structures.
1745
+ *
1746
+ * Returns `undefined` if either ephemeris is missing or the lengths do not
1747
+ * agree (which mirrors the original guard, including the case where the
1748
+ * threat re-entered the atmosphere mid-window).
1749
+ *
1750
+ * @param {Object} s threat elset / metadata record
1751
+ * @param {Array<{p:number[],v:number[],t:number}>} pEphem primary ephemeris
1752
+ * @param {Array<{p:number[],v:number[],t:number}>} sEphem threat ephemeris
1753
+ * @return {Object|undefined}
1754
+ */
1755
+ const assembleLeoRpoResult = (s, pEphem, sEphem, includeRIC=false) => {
1756
+ if (!isDefined(pEphem)
1757
+ || !isDefined(sEphem)
1758
+ || pEphem.length === 0
1759
+ || pEphem.length !== sEphem.length) {
1760
+ return undefined;
1761
+ }
1762
+
1763
+ const aResult = {
1764
+ line1: s.Line1,
1765
+ line2: s.Line2,
1766
+ epoch: s.Epoch,
1767
+ name: s.CommonName,
1768
+ rank: s.Rank ?? "",
1769
+ satNo: s.SatNo,
1770
+ inclination: s.Inclination,
1771
+ raan: s.Raan,
1772
+ source: s.Source,
1773
+ datamode: s.DataMode,
1774
+ sma: s.SemiMajorAxis,
1775
+ country: s.CountryId,
1776
+ flag: s.Flag,
1777
+ poca: 9999999999,
1778
+ toca: "",
1779
+ tocaString: "",
1780
+ planeDiff: 0,
1781
+ incDiff: s.incDiff,
1782
+ raanDiff: s.raanDiff,
1783
+ semiMajorDiff: s.semiMajorDiff,
1784
+ raanDrift: s.RaanPrecessionDegreesPerDay,
1785
+ ...(includeRIC ? {
1786
+ r: [],
1787
+ i: [],
1788
+ c: [],
1789
+ d: [],
1790
+ t: [],
1791
+ } : {})
1792
+ };
1793
+
1794
+ const pv1 = {position: pEphem[0].p, velocity: pEphem[0].v};
1795
+ const pv2 = {position: sEphem[0].p, velocity: sEphem[0].v};
1796
+ aResult.di = angleBetweenPlanes(pv1, pv2);
1797
+ const dv = planeChangeDeltaV(pv1, pv2);
1798
+ aResult.dv = {
1799
+ i: Math.round(dv.i * 1000) / 1000,
1800
+ c: Math.round(dv.c * 1000) / 1000,
1801
+ };
1802
+
1803
+ for (let i = 0; i < pEphem.length; i++) {
1804
+ const prim = pEphem[i];
1805
+ const target = sEphem[i];
1806
+
1807
+ const distKm = dist(prim.p, target.p);
1808
+
1809
+ if(includeRIC) {
1810
+ const ricP = getRIC(prim, target);
1811
+
1812
+ aResult.r.push(-1 * ricP.x);
1813
+ aResult.i.push(-1 * ricP.y);
1814
+ aResult.c.push(-1 * ricP.z);
1815
+
1816
+ aResult.t.push(prim.t);
1817
+
1818
+ aResult.d.push(distKm);
1819
+ }
1820
+
1821
+ if (distKm < aResult.poca) {
1822
+ aResult.poca = distKm;
1823
+ aResult.toca = new Date(prim.t).toISOString();
1824
+ aResult.tocaString = getTimeDifference(aResult.toca);
1825
+ }
1826
+ }
1827
+
1828
+ aResult.danger = (aResult.di === 0 || aResult.poca === 0)
1829
+ ? 1000
1830
+ : 1 / (aResult.di * aResult.poca);
1831
+ return aResult;
1832
+ };
1833
+
1834
+ const getLeoRpoData = (line1, line2, sats, startTime, endTime, includeRIC=false) => {
1729
1835
  const results = [];
1730
1836
  const pSatRec = checkTle(line1, line2);
1731
1837
  if (!isDefined(pSatRec)) {return results;}
1732
1838
 
1733
1839
  const start = new Date(startTime).getTime();
1734
1840
  const end = new Date(endTime).getTime();
1735
- const pElset = {
1736
- Line1: line1,
1737
- Line2: line2,
1738
- };
1841
+ const pElset = {Line1: line1, Line2: line2};
1739
1842
  const pEphem = prop(pElset, start, end, 10000);
1740
1843
  if (pEphem.length === 0) {return results;} // Primary may have re-entered the atmosphere
1741
1844
 
1742
- sats.forEach( (s) => {
1845
+ sats.forEach((s) => {
1743
1846
  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);
1847
+ const row = assembleLeoRpoResult(s, pEphem, sEphem, includeRIC);
1848
+ if (isDefined(row)) {results.push(row);}
1803
1849
  });
1804
1850
  return results;
1805
1851
  };
1806
1852
 
1853
+ // `getLeoRpoDataBulk` extracted to `./wasmProp/wasmAstro.js`.
1854
+
1807
1855
 
1808
1856
  /**
1809
1857
  * Get GEO RPO data for a given target satellite and a set of potential threat satellites.
@@ -1815,208 +1863,270 @@ const getLeoRpoData = (line1, line2, sats, startTime, endTime) => {
1815
1863
  * @param {Integer} lonTime, datetime to analyze longitude and lon drift at, Unix milliseconds
1816
1864
  * @return {Array<Objects>}, Array of RPO data objects
1817
1865
  */
1818
- const getGeoRpoData = (line1, line2, sats, startTime, endTime, lonTime) => {
1819
- const start = new Date(startTime).getTime();
1820
- 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
1866
+ /**
1867
+ * GEO-specific signed longitude-difference rate. Encodes whether the two
1868
+ * satellites' longitudes are converging (negative magnitude) or diverging
1869
+ * (positive magnitude) given their respective east-positive drifts. Lifted
1870
+ * verbatim out of `getGeoRpoData`'s nested closure so both the JS and WASM
1871
+ * variants share identical drift accounting.
1872
+ *
1873
+ * @param {Number} lon1 satellite-1 longitude (degrees, signed)
1874
+ * @param {Number} drift1 satellite-1 drift, degrees/day east-positive
1875
+ * @param {Number} lon2 satellite-2 longitude (degrees, signed)
1876
+ * @param {Number} drift2 satellite-2 drift, degrees/day east-positive
1877
+ * @return {Number}
1878
+ */
1879
+ const geoRelativeLonDrift = (lon1, drift1, lon2, drift2) => {
1880
+ let closing = false;
1881
+ if (lon1 > lon2) {
1882
+ closing = (drift1 * drift2 > 0 && drift1 < drift2)
1883
+ || (drift1 * drift2 < 0 && drift1 < 0);
1884
+ } else if (lon2 > lon1) {
1885
+ closing = (drift1 * drift2 > 0 && drift2 < drift1)
1886
+ || (drift1 * drift2 < 0 && drift1 > 0);
1887
+ }
1888
+ const difference = drift1 - drift2;
1889
+ return closing ? -Math.abs(difference) : Math.abs(difference);
1890
+ };
1826
1891
 
1827
- const lonEvalTime = lonTime ? new Date(lonTime) : new Date(end);
1892
+ /**
1893
+ * Pure assembly helper for a single GEO RPO comparison row. Identical math
1894
+ * to the original inline body of `getGeoRpoData`, factored out so the
1895
+ * JS-path (`getGeoRpoData`) and WASM-path (`getGeoRpoDataBulk`) emit
1896
+ * byte-equivalent structures.
1897
+ *
1898
+ * `getLonAndDrift` is JS-only by design (see the `low-value` opportunity
1899
+ * list in `docs/BULK_PROPAGATION.md`); both call sites compute it
1900
+ * upstream and pass the precomputed result in.
1901
+ *
1902
+ * Returns `undefined` if either ephemeris is missing or the lengths do not
1903
+ * match (mirrors the original guard).
1904
+ *
1905
+ * @param {Object} s threat elset / metadata record
1906
+ * @param {Array<{p:number[],v:number[],t:number}>} pEphem primary ephemeris
1907
+ * @param {Array<{p:number[],v:number[],t:number}>} sEphem threat ephemeris
1908
+ * @param {Object} pLonAndDrift output of `getLonAndDrift(primary)`
1909
+ * @param {Object} sLonAndDrift output of `getLonAndDrift(threat)`
1910
+ * @return {Object|undefined}
1911
+ */
1912
+ const assembleGeoRpoResult = (s, pEphem, sEphem, pLonAndDrift, sLonAndDrift, includeRIC=false) => {
1913
+ if (!isDefined(pEphem)
1914
+ || !isDefined(sEphem)
1915
+ || pEphem.length === 0
1916
+ || pEphem.length !== sEphem.length) {
1917
+ return undefined;
1918
+ }
1919
+
1920
+ const aResult = {
1921
+ line1: s.Line1,
1922
+ line2: s.Line2,
1923
+ epoch: s.Epoch,
1924
+ name: s.CommonName,
1925
+ rank: s.Rank ?? "",
1926
+ satNo: s.SatNo,
1927
+ source: s.Source,
1928
+ country: s.CountryId,
1929
+ datamode: s.DataMode,
1930
+ flag: s.Flag,
1931
+ poca: 9999999999,
1932
+ toca: "",
1933
+ tocaString: "",
1934
+ incDiff: s.incDiff,
1935
+ longitude: (sLonAndDrift.longitude + 360) % 360,
1936
+ lonDiff: null,
1937
+ relativeDrift: null,
1938
+ di: null,
1939
+ dv: null,
1940
+ danger: null,
1941
+ ...(includeRIC ? {
1942
+ r: [],
1943
+ i: [],
1944
+ c: [],
1945
+ d: [],
1946
+ t: [],
1947
+ } : {})
1948
+ };
1828
1949
 
1829
- const pLonAndDrift = getLonAndDrift(line1, line2, lonEvalTime);
1950
+ const lonDiff = Math.abs(
1951
+ (pLonAndDrift.longitude + 360) % 360
1952
+ - (sLonAndDrift.longitude + 360) % 360,
1953
+ );
1954
+ aResult.lonDiff = Math.min(lonDiff, 360 - lonDiff);
1955
+
1956
+ aResult.relativeDrift = geoRelativeLonDrift(
1957
+ pLonAndDrift.longitude,
1958
+ pLonAndDrift.lonDriftDegreesPerDay,
1959
+ sLonAndDrift.longitude,
1960
+ sLonAndDrift.lonDriftDegreesPerDay,
1961
+ );
1830
1962
 
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);
1963
+ const pv1 = {position: pEphem[0].p, velocity: pEphem[0].v};
1964
+ const pv2 = {position: sEphem[0].p, velocity: sEphem[0].v};
1965
+ aResult.di = angleBetweenPlanes(pv1, pv2);
1966
+ const dv = planeChangeDeltaV(pv1, pv2);
1967
+ aResult.dv = {
1968
+ i: Math.round(dv.i * 1000) / 1000,
1969
+ c: Math.round(dv.c * 1000) / 1000,
1839
1970
  };
1840
1971
 
1841
- const results = [];
1842
- 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
- };
1972
+ for (let i = 0; i < pEphem.length; i++) {
1973
+ const prim = pEphem[i];
1974
+ const target = sEphem[i];
1975
+
1976
+ const distKm = dist(prim.p, target.p);
1977
+
1978
+ if(includeRIC) {
1979
+ const ricP = getRIC(prim, target);
1865
1980
 
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;
1981
+ aResult.r.push(-1 * ricP.x);
1982
+ aResult.i.push(-1 * ricP.y);
1983
+ aResult.c.push(-1 * ricP.z);
1984
+
1985
+ aResult.t.push(prim.t);
1986
+
1987
+ aResult.d.push(distKm);
1874
1988
  }
1875
- const sLonAndDrift = getLonAndDrift(s.Line1, s.Line2, lonEvalTime);
1876
- aResult.longitude = (sLonAndDrift.longitude + 360) % 360; // Normalize to 0-360
1877
1989
 
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
- }
1990
+ if (distKm < aResult.poca) {
1991
+ aResult.poca = distKm;
1992
+ aResult.toca = new Date(prim.t).toISOString();
1993
+ aResult.tocaString = getTimeDifference(aResult.toca);
1911
1994
  }
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);
1995
+ }
1996
+
1997
+ aResult.danger = (aResult.di === 0 || aResult.poca === 0)
1998
+ ? 1000
1999
+ : 1 / (aResult.di * aResult.poca);
2000
+ return aResult;
2001
+ };
2002
+
2003
+ const getGeoRpoData = (line1, line2, sats, startTime, endTime, lonTime, includeRIC=false) => {
2004
+ const results = [];
2005
+ const start = new Date(startTime).getTime();
2006
+ const end = new Date(endTime).getTime();
2007
+ const pEphem = prop({Line1: line1, Line2: line2}, start, end, 60000);
2008
+ if (!isDefined(pEphem) || pEphem.length === 0) {
2009
+ return results; // Primary may have re-entered the atmosphere
2010
+ }
2011
+
2012
+ const lonEvalTime = lonTime ? new Date(lonTime) : new Date(end);
2013
+ const pLonAndDrift = getLonAndDrift(line1, line2, lonEvalTime);
1915
2014
 
1916
- results.push(aResult);
2015
+ sats.forEach((s) => {
2016
+ const sEphem = prop({Line1: s.Line1, Line2: s.Line2}, start, end, 60000);
2017
+ const sLonAndDrift = getLonAndDrift(s.Line1, s.Line2, lonEvalTime);
2018
+ const row = assembleGeoRpoResult(s, pEphem, sEphem, pLonAndDrift, sLonAndDrift, includeRIC);
2019
+ if (isDefined(row)) {results.push(row);}
1917
2020
  });
1918
2021
 
1919
2022
  return results;
1920
2023
  };
1921
2024
 
1922
- const getGeoShadowZones = (time, accuracySecondsDeg=0.00416*100) => {
1923
- // Accuracy is 86400 pooints on a 360 circle (i.e. 0.00416 deg per second)
2025
+ // `getGeoRpoDataBulk` extracted to `./wasmProp/wasmAstro.js`.
1924
2026
 
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 = [];
2027
+ /** GEO mean-anomaly TLE template used by the shadow-zone scan. The
2028
+ * `XXX.XXXX` token is substituted with each candidate mean anomaly.
2029
+ * TLE lines are fixed-length by definition, hence the disable-next-line. */
2030
+ // eslint-disable-next-line max-len
2031
+ const SHADOW_ZONE_TLE_LINE_1 = "1 00000U 00000A 24079.98445361 -.00000000 00000-0 -00000-0 0 00000";
2032
+ // eslint-disable-next-line max-len
2033
+ const SHADOW_ZONE_TLE_LINE_2_TEMPLATE = "2 00000 000.0000 000.0000 0000000 000.0000 XXX.XXXX 01.00000000000000";
2034
+
2035
+ /**
2036
+ * Build the array of GEO mean-anomaly elsets the shadow-zone scan
2037
+ * exercises. Pure; no propagation. Produces `360 / accuracySecondsDeg`
2038
+ * elsets, one for each mean anomaly slot, in increasing order.
2039
+ *
2040
+ * @param {Number} accuracySecondsDeg degrees per slot
2041
+ * @return {Array<{Line1:string, Line2:string}>}
2042
+ */
2043
+ const buildShadowZoneElsets = (accuracySecondsDeg) => {
2044
+ // Use Math.floor so fractional `360 / accuracySecondsDeg` (e.g.
2045
+ // accuracy values that don't divide 360 exactly) still produce a
2046
+ // valid integer step count. Matches the original loop's `i < steps`
2047
+ // behavior, which also truncated.
2048
+ const steps = Math.floor(360 / accuracySecondsDeg);
2049
+ const out = new Array(steps);
1932
2050
  for (let i = 0; i < steps; i++) {
1933
- // Calculate the mean anomaly for this step
1934
2051
  const meanAnomaly = i * accuracySecondsDeg;
1935
- // Format the mean anomaly into the TLE line
1936
- // Round the mean anomaly to two decimal places
1937
2052
  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
- });
2053
+ const tleLine2 = SHADOW_ZONE_TLE_LINE_2_TEMPLATE.replace(
2054
+ "XXX.XXXX", roundedMeanAnomaly.padStart(8, "0"),
2055
+ );
2056
+ out[i] = {Line1: SHADOW_ZONE_TLE_LINE_1, Line2: tleLine2};
1946
2057
  }
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
- });
2058
+ return out;
2059
+ };
1955
2060
 
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;
2061
+ /**
2062
+ * Pure zone-analysis pass. Takes the per-slot
2063
+ * `[{ecl, geolon}, ...]` series produced by either the JS or WASM scan
2064
+ * and returns `{penStartWestLon, penStartEastLon}` exactly as the
2065
+ * original `getGeoShadowZones` does.
2066
+ *
2067
+ * Lifted out of the original function body without semantic changes:
2068
+ * - identical interval seam-stitching across the 360°/0° wraparound,
2069
+ * - identical "fully lit GEO belt" early return,
2070
+ * - identical penumbra-only vs. umbra+penumbra branching.
2071
+ *
2072
+ * @param {Array<{ecl:string, geolon:number}>} res
2073
+ * @return {{penStartWestLon:?number, penStartEastLon:?number}}
2074
+ */
2075
+ const analyzeShadowZoneSeries = (res) => {
2076
+ const ints = [{ecl: res[0].ecl}];
1961
2077
 
1962
- // Open a new zone
1963
- ints.push({
1964
- ecl: res[i].ecl,
1965
- startDeg: res[i].geolon,
1966
- });
2078
+ for (let i = 1; i <= res.length - 1; i++) {
2079
+ if (res[i].ecl !== res[i - 1].ecl) {
2080
+ ints.at(-1).stopDeg = res[i - 1].geolon;
2081
+ ints.push({ecl: res[i].ecl, startDeg: res[i].geolon});
1967
2082
  }
1968
2083
  }
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
2084
 
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
- };
2085
+ if (ints.length === 1
2086
+ && ints.filter((x) => x.ecl === "SUN").length === 1) {
2087
+ return {penStartWestLon: null, penStartEastLon: null};
1981
2088
  }
1982
2089
 
1983
2090
  ints[0].startDeg = ints.at(-1).startDeg;
1984
2091
  ints.pop();
1985
2092
 
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");
2093
+ if (ints.filter((x) => x.ecl === "UMBRA").length === 0
2094
+ && ints.some((x) => x.ecl === "PENUMBRA")) {
2095
+ const penumbraInt = ints.find((x) => x.ecl === "PENUMBRA");
1993
2096
  return {
1994
2097
  penStartWestLon: wrapToRange(penumbraInt.startDeg, 0, 360),
1995
2098
  penStartEastLon: wrapToRange(penumbraInt.stopDeg, 0, 360),
1996
2099
  };
1997
- } else {
1998
- // If sun, umbra, penumbra intervals exist
1999
-
2000
- const umbra = ints.find((x)=>x.ecl === "UMBRA");
2100
+ }
2001
2101
 
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;
2102
+ const umbra = ints.find((x) => x.ecl === "UMBRA");
2103
+ const umbraStart360 = wrapToRange(umbra.startDeg, 0, 360);
2104
+ const umbraStop360 = wrapToRange(umbra.stopDeg, 0, 360);
2105
+ const penumbraInts = ints.filter((x) => x.ecl === "PENUMBRA");
2106
+ const chosenPenumbraInt = penumbraInts[0].startDeg > penumbraInts[0].stopDeg
2107
+ ? penumbraInts[1] : penumbraInts[0];
2108
+ const penumbraAngle = chosenPenumbraInt.stopDeg - chosenPenumbraInt.startDeg;
2109
+ return {
2110
+ penStartWestLon: umbraStart360 - penumbraAngle,
2111
+ penStartEastLon: umbraStop360 + penumbraAngle,
2112
+ };
2113
+ };
2012
2114
 
2115
+ const getGeoShadowZones = (time, accuracySecondsDeg = 0.00416 * 100) => {
2116
+ // Default accuracy: 86400 points on the 360° circle (~0.00416 deg/sec).
2117
+ const elsets = buildShadowZoneElsets(accuracySecondsDeg);
2118
+ const res = elsets.map(({Line1, Line2}) => {
2119
+ const p = propagate(twoline2satrec(Line1, Line2), time).position;
2013
2120
  return {
2014
- penStartWestLon: penumbraStart,
2015
- penStartEastLon: penumbraStop,
2121
+ ecl: getEclipseStatus(time, [p.x, p.y, p.z]),
2122
+ geolon: eciToGeodetic(p, gstime(time)).longitude * RAD2DEG,
2016
2123
  };
2017
- }
2124
+ });
2125
+ return analyzeShadowZoneSeries(res);
2018
2126
  };
2019
2127
 
2128
+ // `getGeoShadowZonesBulk` extracted to `./wasmProp/wasmAstro.js`.
2129
+
2020
2130
  /** Returns the light intervals of an arbitrary satellite in strict GEO orbit.
2021
2131
  *
2022
2132
  * These intervals are generally equal in duration for all other GEO satellites.
@@ -2415,66 +2525,85 @@ const calculateLeoPhaseDifference = (pLine1, pLine2, tLine1, tLine2, time) => {
2415
2525
  * @param {Integer} stepMs, step time of the analysis in milliseconds, default 10s
2416
2526
  * @return {Array} Array of data objects
2417
2527
  */
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);
2528
+ /**
2529
+ * Build the breakpoint schedule for `getLeoWaterfallData` /
2530
+ * `getLeoWaterfallDataBulk`. The waterfall covers `[start, end]` divided
2531
+ * into segments at every per-satellite elset epoch that falls strictly
2532
+ * inside that range. Pure; no propagation.
2533
+ *
2534
+ * Each breakpoint is `{time, satIndex, elsetIndex?}`. The two synthetic
2535
+ * sentinel breakpoints at `start` / `end` carry `satIndex: -1` so the
2536
+ * segment loop can distinguish "edge of window" from "satellite epoch".
2537
+ *
2538
+ * @param {Array<Array<Object>>} elsets per-satellite elset arrays
2539
+ * @param {Number} start Unix ms
2540
+ * @param {Number} end Unix ms
2541
+ * @return {Array<{time:number, satIndex:number, elsetIndex?:number}>}
2542
+ */
2543
+ const buildWaterfallBreakpoints = (elsets, start, end) => {
2425
2544
  const breakpoints = [{time: start, satIndex: -1}, {time: end, satIndex: -1}];
2426
2545
  elsets.forEach((satElsets, i) => {
2427
2546
  breakpoints.push(...satElsets.map((e, j) => ({
2428
2547
  time: new Date(e.Epoch).getTime(),
2429
2548
  satIndex: i,
2430
2549
  elsetIndex: j,
2431
- })).filter((b) => b.time > start && b.time < end)); // ensure breakpoints are inside time range
2550
+ })).filter((b) => b.time > start && b.time < end));
2432
2551
  });
2433
2552
  breakpoints.sort((a, b) => a.time - b.time);
2553
+ return breakpoints;
2554
+ };
2434
2555
 
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
- }
2556
+ /**
2557
+ * Decorate a per-segment ephemeris with osculating elements and elset
2558
+ * metadata. Identical post-processing as the original inline `.map()` in
2559
+ * `getLeoWaterfallData`. Used by both the JS and WASM waterfall variants
2560
+ * so the two emit byte-equivalent rows.
2561
+ *
2562
+ * `isEpoch` is `true` for the first point of the segment if-and-only-if
2563
+ * the segment was opened by *this* satellite's elset epoch (matches the
2564
+ * original `pointInd === 0 && bkpoint.satIndex === satIndex` check).
2565
+ *
2566
+ * @param {Array<{p:number[],v:number[],t:number}>} ephem segment ephemeris
2567
+ * @param {Object} elset active elset for this satellite in this segment
2568
+ * @param {{satIndex:number}} bkpoint segment-opening breakpoint
2569
+ * @param {Number} satIndex index of the satellite in the original `elsets`
2570
+ * @return {Array<Object>}
2571
+ */
2572
+ const decorateWaterfallSegment = (ephem, elset, bkpoint, satIndex) => {
2573
+ const epoch = new Date(elset.Epoch).getTime();
2574
+ return ephem.map((point, pointInd) => {
2575
+ const osculatingElements = cartesianToKeplerian(
2576
+ multiply(posToArray(point.p), 1000), // km -> m
2577
+ multiply(posToArray(point.v), 1000), // km/s -> m/s
2578
+ );
2579
+ return {
2580
+ ...point,
2581
+ isEpoch: pointInd === 0 && bkpoint.satIndex === satIndex,
2582
+ epoch: epoch,
2583
+ satNo: elset.SatNo,
2584
+ name: elset.CommonName,
2585
+ source: elset.Source,
2586
+ raan: osculatingElements.raan, // deg
2587
+ raanPrecession: elset.RaanPrecessionDegreesPerDay,
2588
+ inclination: osculatingElements.i, // deg
2589
+ };
2590
+ });
2591
+ };
2473
2592
 
2593
+ /**
2594
+ * Build the per-(time, satellite) waterfall result rows from the
2595
+ * decorated ephemerides produced by {@link decorateWaterfallSegment}.
2596
+ * Identical math to the second loop of the original `getLeoWaterfallData`.
2597
+ * Pure; deterministic; shared by both variants.
2598
+ *
2599
+ * @param {Array<Array<Object>>} satEphems primary at index 0
2600
+ * @return {Array<Object>}
2601
+ */
2602
+ const assembleWaterfallRows = (satEphems) => {
2603
+ const results = [];
2474
2604
  const pEphem = satEphems[0];
2475
2605
 
2476
- satEphems.forEach( (sEphem, satIndex) => {
2477
- // Find the distance at each time step
2606
+ satEphems.forEach((sEphem, satIndex) => {
2478
2607
  for (let i = 0; i < pEphem.length; i++) {
2479
2608
  const pEphemPoint = pEphem[i];
2480
2609
  const sEphemPoint = sEphem[i];
@@ -2493,7 +2622,7 @@ const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2493
2622
 
2494
2623
  if (satIndex > 0) { // not the primary
2495
2624
  if (areCoordsEqual(pEphemPoint.p, sEphemPoint.p)) {
2496
- catsAngle = 180; // they're in the same spot, so just say secondary can see primary
2625
+ catsAngle = 180;
2497
2626
  } else {
2498
2627
  catsAngle = angleBetween3DCoords(
2499
2628
  pEphemPoint.p,
@@ -2501,7 +2630,6 @@ const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2501
2630
  getSunDirection(time),
2502
2631
  );
2503
2632
  }
2504
-
2505
2633
  const earthEclipsed = doesLineSegmentIntersectEarth(pEphemPoint.p, sEphemPoint.p);
2506
2634
  const targetShadow = getEclipseStatus(time, posToArray(sEphemPoint.p));
2507
2635
  targetVisibility = earthEclipsed ? "EARTH ECLIPSED" : targetShadow;
@@ -2529,6 +2657,40 @@ const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2529
2657
  return results;
2530
2658
  };
2531
2659
 
2660
+ const getLeoWaterfallData = (elsets, startTime, endTime, stepMs = 10000) => {
2661
+ const start = new Date(startTime).getTime();
2662
+ const end = new Date(endTime).getTime();
2663
+
2664
+ const breakpoints = buildWaterfallBreakpoints(elsets, start, end);
2665
+ const currentIndices = elsets.map(() => 0);
2666
+ const satEphems = elsets.map(() => []);
2667
+
2668
+ for (let i = 0; i < breakpoints.length - 1; i++) {
2669
+ const bkpoint = breakpoints[i];
2670
+ // Segment boundaries align with elset epochs, not multiples of `stepMs`.
2671
+ // The intra-segment ephemeris drift (<= one step) is negligible at the
2672
+ // multi-day plot resolution this function targets.
2673
+ const segmentStart = bkpoint.time;
2674
+ const segmentEnd = breakpoints[i + 1].time;
2675
+
2676
+ if (bkpoint.satIndex >= 0) {
2677
+ currentIndices[bkpoint.satIndex] = bkpoint.elsetIndex;
2678
+ }
2679
+
2680
+ currentIndices.forEach((elsetIndex, satIndex) => {
2681
+ const elset = elsets[satIndex][elsetIndex];
2682
+ const ephem = prop(elset, segmentStart, segmentEnd, stepMs);
2683
+ satEphems[satIndex].push(
2684
+ ...decorateWaterfallSegment(ephem, elset, bkpoint, satIndex),
2685
+ );
2686
+ });
2687
+ }
2688
+
2689
+ return assembleWaterfallRows(satEphems);
2690
+ };
2691
+
2692
+ // `getLeoWaterfallDataBulk` extracted to `./wasmProp/wasmAstro.js`.
2693
+
2532
2694
  /**
2533
2695
  * Returns the minimum deltaV for a Lambert maneuver between two satellites,
2534
2696
  * within the given time range.
@@ -3325,6 +3487,18 @@ export {
3325
3487
  getGeoRpoData,
3326
3488
  getGeoShadowZones,
3327
3489
  getGeoLightIntervals,
3490
+ // Shared pure assembly helpers, exported so `./wasmProp/wasmAstro.js` can
3491
+ // consume them without duplicating logic. These are cross-path
3492
+ // building blocks (used by both the JS-sync and WASM-async siblings)
3493
+ // and are safe to call as ordinary utilities.
3494
+ assembleLeoRpoResult,
3495
+ geoRelativeLonDrift,
3496
+ assembleGeoRpoResult,
3497
+ buildShadowZoneElsets,
3498
+ analyzeShadowZoneSeries,
3499
+ buildWaterfallBreakpoints,
3500
+ decorateWaterfallSegment,
3501
+ assembleWaterfallRows,
3328
3502
  getEclipseStatus,
3329
3503
  estimateSlantRange,
3330
3504
  calculateNextApogeePerigeeTimes,