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

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,12 +793,6 @@ 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
-
802
796
  const doesLineSegmentSphereIntersect = (linePoint0, linePoint1, circleCenter, circleRadius) => {
803
797
  // From Space Cockpit
804
798
  // http://www.codeproject.com/Articles/19799/Simple-Ray-Tracing-in-C-Part-II-Triangles-Intersec
@@ -1731,78 +1725,6 @@ const cartesianToElsetElements = (pv, epoch) => {
1731
1725
  * @param {Integer} endTime, end time of the analysis, Unix milliseconds
1732
1726
  * @return {Array} Array of RPO data objects
1733
1727
  */
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
-
1806
1728
  const getLeoRpoData = (line1, line2, sats, startTime, endTime) => {
1807
1729
  const results = [];
1808
1730
  const pSatRec = checkTle(line1, line2);
@@ -1810,20 +1732,78 @@ const getLeoRpoData = (line1, line2, sats, startTime, endTime) => {
1810
1732
 
1811
1733
  const start = new Date(startTime).getTime();
1812
1734
  const end = new Date(endTime).getTime();
1813
- const pElset = {Line1: line1, Line2: line2};
1735
+ const pElset = {
1736
+ Line1: line1,
1737
+ Line2: line2,
1738
+ };
1814
1739
  const pEphem = prop(pElset, start, end, 10000);
1815
1740
  if (pEphem.length === 0) {return results;} // Primary may have re-entered the atmosphere
1816
1741
 
1817
- sats.forEach((s) => {
1742
+ sats.forEach( (s) => {
1818
1743
  const sEphem = prop(s, start, end, 10000);
1819
- const row = assembleLeoRpoResult(s, pEphem, sEphem);
1820
- if (isDefined(row)) {results.push(row);}
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);
1821
1803
  });
1822
1804
  return results;
1823
1805
  };
1824
1806
 
1825
- // `getLeoRpoDataBulk` extracted to `./wasmProp/wasmAstro.js`.
1826
-
1827
1807
 
1828
1808
  /**
1829
1809
  * Get GEO RPO data for a given target satellite and a set of potential threat satellites.
@@ -1835,247 +1815,208 @@ const getLeoRpoData = (line1, line2, sats, startTime, endTime) => {
1835
1815
  * @param {Integer} lonTime, datetime to analyze longitude and lon drift at, Unix milliseconds
1836
1816
  * @return {Array<Objects>}, Array of RPO data objects
1837
1817
  */
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
-
1952
1818
  const getGeoRpoData = (line1, line2, sats, startTime, endTime, lonTime) => {
1953
- const results = [];
1954
1819
  const start = new Date(startTime).getTime();
1955
1820
  const end = new Date(endTime).getTime();
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
- }
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
1960
1826
 
1961
1827
  const lonEvalTime = lonTime ? new Date(lonTime) : new Date(end);
1828
+
1962
1829
  const pLonAndDrift = getLonAndDrift(line1, line2, lonEvalTime);
1963
1830
 
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 = [];
1964
1842
  sats.forEach((s) => {
1965
- const sEphem = prop({Line1: s.Line1, Line2: s.Line2}, start, end, 60000);
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
+ }
1966
1875
  const sLonAndDrift = getLonAndDrift(s.Line1, s.Line2, lonEvalTime);
1967
- const row = assembleGeoRpoResult(s, pEphem, sEphem, pLonAndDrift, sLonAndDrift);
1968
- if (isDefined(row)) {results.push(row);}
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);
1969
1917
  });
1970
1918
 
1971
1919
  return results;
1972
1920
  };
1973
1921
 
1974
- // `getGeoRpoDataBulk` extracted to `./wasmProp/wasmAstro.js`.
1922
+ const getGeoShadowZones = (time, accuracySecondsDeg=0.00416*100) => {
1923
+ // Accuracy is 86400 pooints on a 360 circle (i.e. 0.00416 deg per second)
1975
1924
 
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);
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 = [];
1999
1932
  for (let i = 0; i < steps; i++) {
1933
+ // Calculate the mean anomaly for this step
2000
1934
  const meanAnomaly = i * accuracySecondsDeg;
1935
+ // Format the mean anomaly into the TLE line
1936
+ // Round the mean anomaly to two decimal places
2001
1937
  const roundedMeanAnomaly = meanAnomaly.toFixed(4);
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};
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
+ });
2006
1946
  }
2007
- return out;
2008
- };
1947
+ // Find the sun zone, umbra zone, and two penumbra zones.
1948
+ const ints = [];
2009
1949
 
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}];
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
+ });
2026
1955
 
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});
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;
1961
+
1962
+ // Open a new zone
1963
+ ints.push({
1964
+ ecl: res[i].ecl,
1965
+ startDeg: res[i].geolon,
1966
+ });
2031
1967
  }
2032
1968
  }
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.
2033
1972
 
2034
- if (ints.length === 1
2035
- && ints.filter((x) => x.ecl === "SUN").length === 1) {
2036
- return {penStartWestLon: null, penStartEastLon: null};
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
+ };
2037
1981
  }
2038
1982
 
2039
1983
  ints[0].startDeg = ints.at(-1).startDeg;
2040
1984
  ints.pop();
2041
1985
 
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");
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");
2045
1993
  return {
2046
1994
  penStartWestLon: wrapToRange(penumbraInt.startDeg, 0, 360),
2047
1995
  penStartEastLon: wrapToRange(penumbraInt.stopDeg, 0, 360),
2048
1996
  };
2049
- }
1997
+ } else {
1998
+ // If sun, umbra, penumbra intervals exist
2050
1999
 
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
- };
2000
+ const umbra = ints.find((x)=>x.ecl === "UMBRA");
2001
+
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;
2063
2012
 
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;
2069
2013
  return {
2070
- ecl: getEclipseStatus(time, [p.x, p.y, p.z]),
2071
- geolon: eciToGeodetic(p, gstime(time)).longitude * RAD2DEG,
2014
+ penStartWestLon: penumbraStart,
2015
+ penStartEastLon: penumbraStop,
2072
2016
  };
2073
- });
2074
- return analyzeShadowZoneSeries(res);
2017
+ }
2075
2018
  };
2076
2019
 
2077
- // `getGeoShadowZonesBulk` extracted to `./wasmProp/wasmAstro.js`.
2078
-
2079
2020
  /** Returns the light intervals of an arbitrary satellite in strict GEO orbit.
2080
2021
  *
2081
2022
  * These intervals are generally equal in duration for all other GEO satellites.
@@ -2474,85 +2415,66 @@ const calculateLeoPhaseDifference = (pLine1, pLine2, tLine1, tLine2, time) => {
2474
2415
  * @param {Integer} stepMs, step time of the analysis in milliseconds, default 10s
2475
2416
  * @return {Array} Array of data objects
2476
2417
  */
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) => {
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);
2493
2425
  const breakpoints = [{time: start, satIndex: -1}, {time: end, satIndex: -1}];
2494
2426
  elsets.forEach((satElsets, i) => {
2495
2427
  breakpoints.push(...satElsets.map((e, j) => ({
2496
2428
  time: new Date(e.Epoch).getTime(),
2497
2429
  satIndex: i,
2498
2430
  elsetIndex: j,
2499
- })).filter((b) => b.time > start && b.time < end));
2431
+ })).filter((b) => b.time > start && b.time < end)); // ensure breakpoints are inside time range
2500
2432
  });
2501
2433
  breakpoints.sort((a, b) => a.time - b.time);
2502
- return breakpoints;
2503
- };
2504
2434
 
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
- };
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
+ }
2541
2473
 
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 = [];
2553
2474
  const pEphem = satEphems[0];
2554
2475
 
2555
- satEphems.forEach((sEphem, satIndex) => {
2476
+ satEphems.forEach( (sEphem, satIndex) => {
2477
+ // Find the distance at each time step
2556
2478
  for (let i = 0; i < pEphem.length; i++) {
2557
2479
  const pEphemPoint = pEphem[i];
2558
2480
  const sEphemPoint = sEphem[i];
@@ -2571,7 +2493,7 @@ const assembleWaterfallRows = (satEphems) => {
2571
2493
 
2572
2494
  if (satIndex > 0) { // not the primary
2573
2495
  if (areCoordsEqual(pEphemPoint.p, sEphemPoint.p)) {
2574
- catsAngle = 180;
2496
+ catsAngle = 180; // they're in the same spot, so just say secondary can see primary
2575
2497
  } else {
2576
2498
  catsAngle = angleBetween3DCoords(
2577
2499
  pEphemPoint.p,
@@ -2579,6 +2501,7 @@ const assembleWaterfallRows = (satEphems) => {
2579
2501
  getSunDirection(time),
2580
2502
  );
2581
2503
  }
2504
+
2582
2505
  const earthEclipsed = doesLineSegmentIntersectEarth(pEphemPoint.p, sEphemPoint.p);
2583
2506
  const targetShadow = getEclipseStatus(time, posToArray(sEphemPoint.p));
2584
2507
  targetVisibility = earthEclipsed ? "EARTH ECLIPSED" : targetShadow;
@@ -2606,40 +2529,6 @@ const assembleWaterfallRows = (satEphems) => {
2606
2529
  return results;
2607
2530
  };
2608
2531
 
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
-
2643
2532
  /**
2644
2533
  * Returns the minimum deltaV for a Lambert maneuver between two satellites,
2645
2534
  * within the given time range.
@@ -3436,18 +3325,6 @@ export {
3436
3325
  getGeoRpoData,
3437
3326
  getGeoShadowZones,
3438
3327
  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,
3451
3328
  getEclipseStatus,
3452
3329
  estimateSlantRange,
3453
3330
  calculateNextApogeePerigeeTimes,