From 21462ed2be62f47e7e487d4c242aca07d296736a Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Thu, 6 Aug 2026 21:29:19 -0600 Subject: [PATCH 01/14] Initial commit --- .gitignore | 151 ++- CHANGELOG.md | 13 + README.md | 30 +- bower.json | 38 - export.js | 6 - jspredict.js | 505 ---------- package-lock.json | 2460 +++++++++++++++++++++++++++++++++++++++++++++ package.js | 18 - package.json | 28 +- src/common.ts | 52 + src/index.ts | 464 +++++++++ src/interfaces.ts | 31 + src/utils.ts | 40 + vite.config.ts | 22 + 14 files changed, 3251 insertions(+), 607 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 bower.json delete mode 100644 export.js delete mode 100644 jspredict.js create mode 100644 package-lock.json delete mode 100644 package.js create mode 100644 src/common.ts create mode 100644 src/index.ts create mode 100644 src/interfaces.ts create mode 100644 src/utils.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index 3320ae5..42a76a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,146 @@ -bower_components/ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories node_modules/ -.npm/ -build/ -test -*~ -.versions -*# +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ + +# Mac Stuff .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b91287 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.2] - 2026-08-06 + +### Changed +- Deprecating legacy baseline version diff --git a/README.md b/README.md index 513188f..449101c 100644 --- a/README.md +++ b/README.md @@ -7,38 +7,14 @@ A Javascript port of the popular `predict` satellite tracking library. - PyPredict: https://github.com/nsat/pypredict - Python-SGP4: https://github.com/brandon-rhodes/python-sgp4 -### Depends on: -- Satellite.js: https://github.com/shashwatak/satellite-js -- Moment.js: https://github.com/moment/moment - ## Installation -JsPredict has been pushed to the `NPM`, `Meteor` (Atmosphere), and `Bower` package registries, and can also be used by including the src file directly. - -### NPM - -``` -npm install jspredict -``` - -### Meteor - -``` -meteor add rosh93:jspredict -``` +JsPredict v2.x is hosted on GitHub Package and can be installed via the following command: -### Bower - -``` -bower install jspredict +```shell +npm install jspredict ``` -### Manual Include - -Download and include `moment.js`: http://momentjs.com/ - -Include both `satellite.js` and `jspredict.js` to get `satellite` and `jspredict` available on the global `window` namespace. - ## API #### Input Types diff --git a/bower.json b/bower.json deleted file mode 100644 index 188e621..0000000 --- a/bower.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "jspredict", - "main": "jspredict.js", - "version": "1.2.0", - "authors": [ - "Roshan Jobanputra " - ], - "description": "javascript port of predict open-source satellite tracking library", - "keywords": [ - "satellite", - "orbit", - "tle", - "qth", - "predict", - "space", - "sgp4", - "norad", - "geostationary", - "leo", - "azimuth", - "elevation", - "spacecraft", - "spire" - ], - "license": "MIT", - "homepage": "https://github.com/nsat/jspredict", - "ignore": [ - "**/.*", - "node_modules", - "bower_components", - "test", - "tests" - ], - "dependencies": { - "satellite.js": "~3.0.1", - "moment": "~2.24.0" - } -} diff --git a/export.js b/export.js deleted file mode 100644 index e79259f..0000000 --- a/export.js +++ /dev/null @@ -1,6 +0,0 @@ -// jspredict.js makes `jspredict` global on the window (or global) object, while Meteor expects a file-scoped global variable -jspredict = this.jspredict; -try { - delete this.jspredict; -} catch (e) { -} diff --git a/jspredict.js b/jspredict.js deleted file mode 100644 index 2f96a92..0000000 --- a/jspredict.js +++ /dev/null @@ -1,505 +0,0 @@ -// jspredict v1.1.1 -// Author: Roshan Jobanputra -// https://github.com/nsat/jspredict - -// Changelog: -// v1.2.0 (rachaelacollins) - Add transitSegment() -// v1.1.1 (cantino) - Update satellite.js dependency -// v1.0.3 (rosh93) - If we cant approximate our aos within max_iterations, return null and dont attempt to return a bad transit object. Fix a few jslint warnings -// v1.0.2 (jotenko) - Added parameter 'maxTransits' to function 'transits' (allows the user to define a maximum number of transits to be calculated, for performance management) -// v1.0.1 (nsat) - First release - -// Copyright (c) 2015, Spire Global Inc -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the Spire Global Inc nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL -// Spire Global Inc BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -// USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -// OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -// SUCH DAMAGE. - -// Based on: -// PREDICT: http://www.qsl.net/kd2bd/predict.html -// PyPredict: https://github.com/nsat/pypredict -// Python-SGP4: https://github.com/brandon-rhodes/python-sgp4 -// Depends on: -// Satellite.js: https://github.com/shashwatak/satellite-js -// Moment.js: https://github.com/moment/moment - -// API - -// jspredict -// -// Inputs: -// tle = 3 line string -// qth = 3 element array [latitude (degrees), longitude (degrees), altitude (km)] -// time/start/end = unix timestamp (ms) or date object (new Date()) - - // observe(tle 'required', qth 'optional', time 'optional') - // - // observes(tle 'required', qth 'optional', start 'optional', end 'required', interval 'optional') - // - // transits(tle 'required', qth 'required', start 'optional', end 'required', minElevation 'optional') - // - // transitSegment(tle 'required', qth 'required', start 'required', end 'required') - -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : - typeof define === 'function' && define.amd ? define(factory) : - global.jspredict = factory() -}(this, function () { - // Meteor includes the moment dependency differently than Npm or Bower, - // so we need this hack (using m_moment) else it gets upset about moment = require('moment'); - var m_moment; - - // Npm - if (typeof require !== 'undefined') { - var satellite = require('satellite.js'); - m_moment = require('moment'); - } - // Meteor - if (this.satellite) { - var satellite = this.satellite; - } - m_moment = m_moment || moment; - - var xkmper = 6.378137E3; // earth radius (km) wgs84 - var astro_unit = 1.49597870691E8; // Astronomical unit - km (IAU 76) - var solar_radius = 6.96000E5; // solar radius - km (IAU 76) - var deg2rad = Math.PI / 180; - var ms2day = 1000 * 60 * 60 * 24; // milliseconds to day - var max_iterations = 250; - var defaultMinElevation = 4; // degrees - - var _jspredict = { - observe: function(tle, qth, start) { - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - - if (this._badSat(satrec, qth, start)) { - return null; - } - - return this._observe(satrec, qth, start) - }, - - observes: function(tle, qth, start, end, interval) { - start = m_moment(start); - end = m_moment(end); - - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - - if (this._badSat(satrec, qth, start)) { - return null; - } - - var observes = [], observed; - var iterations = 0; - while (start < end && iterations < max_iterations) { - observed = this._observe(satrec, qth, start); - if (!observed) { - break; - } - observes.push(observed); - start.add(interval); - iterations += 1; - } - - return observes - }, - - transits: function(tle, qth, start, end, minElevation, maxTransits) { - start = m_moment(start); - end = m_moment(end); - - if (!minElevation) { - minElevation = defaultMinElevation; - } - - if (!maxTransits) { - maxTransits = max_iterations; - } - - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - if (this._badSat(satrec, qth, start)) { - return []; - } - - var time = start.valueOf(); - var transits = []; - var nextTransit; - var iterations = 0; - - while (iterations < max_iterations && transits.length < maxTransits) { - transit = this._quickPredict(satrec, qth, time); - if (!transit) { - break; - } - if (transit.end > end.valueOf()) { - break; - } - if (transit.end > start.valueOf() && transit.maxElevation > minElevation) { - transits.push(transit); - } - time = transit.end + 60 * 1000; - iterations += 1; - } - - return transits - }, - - transitSegment: function(tle, qth, start, end) { - start = m_moment(start); - end = m_moment(end); - - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - if (this._badSat(satrec, qth, start)) { - return []; - } - - return this._quickPredict(satrec, qth, start.valueOf(), end.valueOf()); - }, - - _observe: function(satrec, qth, start) { - start = m_moment(start); - var eci = this._eci(satrec, start); - var gmst = this._gmst(start); - if (!eci.position) { - return null; - } - var geo = satellite.eciToGeodetic(eci.position, gmst); - - var solar_vector = this._calculateSolarPosition(start.valueOf()); - var eclipse = this._satEclipsed(eci.position, solar_vector); - - var track = { - eci: eci, - gmst: gmst, - latitude: geo.latitude / deg2rad, - longitude: this._boundLongitude(geo.longitude / deg2rad), - altitude: geo.height, - footprint: 12756.33 * Math.acos(xkmper / (xkmper + geo.height)), - sunlit: !eclipse.eclipsed, - eclipseDepth: eclipse.depth / deg2rad - } - - // If we have a groundstation let's get those additional observe parameters - if (qth && qth.length == 3) { - var observerGd = { - longitude: qth[1] * deg2rad, - latitude: qth[0] * deg2rad, - height: qth[2] - } - - var positionEcf = satellite.eciToEcf(eci.position, gmst), - velocityEcf = satellite.eciToEcf(eci.velocity, gmst), - observerEcf = satellite.geodeticToEcf(observerGd), - lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf), - doppler = satellite.dopplerFactor(observerEcf, positionEcf, velocityEcf); - - track.azimuth = lookAngles.azimuth / deg2rad; - track.elevation = lookAngles.elevation / deg2rad; - track.rangeSat = lookAngles.rangeSat; - track.doppler = doppler; - } - - return track - }, - - _quickPredict: function(satrec, qth, start, end) { - var transit = {}; - var lastel = 0; - var iterations = 0; - - if (this._badSat(satrec, qth, start)) { - return null; - } - - var daynum = this._findAOS(satrec, qth, start); - if (!daynum) { - return null; - } - transit.start = daynum; - - var observed = this._observe(satrec, qth, daynum); - if (!observed) { - return null; - } - - var iel = Math.round(observed.elevation); - - var maxEl = 0, apexAz = 0, minAz = 360, maxAz = 0; - - while (iel >= 0 && iterations < max_iterations && (!end || daynum < end)) { - lastel = iel; - daynum = daynum + ms2day * Math.cos((observed.elevation-1.0)*deg2rad)*Math.sqrt(observed.altitude)/25000.0; - observed = this._observe(satrec, qth, daynum); - iel = Math.round(observed.elevation); - if (maxEl < observed.elevation) { - maxEl = observed.elevation; - apexAz = observed.azimuth; - } - maxAz = Math.max(maxAz, observed.azimuth); - minAz = Math.min(minAz, observed.azimuth); - iterations += 1; - } - if (lastel !== 0) { - daynum = this._findLOS(satrec, qth, daynum); - } - - transit.end = daynum; - transit.maxElevation = maxEl; - transit.apexAzimuth = apexAz; - transit.maxAzimuth = maxAz; - transit.minAzimuth = minAz; - transit.duration = transit.end - transit.start; - - return transit - }, - - _badSat: function(satrec, qth, start) { - if (qth && !this._aosHappens(satrec, qth)) { - return true - } else if (start && this._decayed(satrec, start)) { - return true - } else { - return false - } - }, - - _aosHappens: function(satrec, qth) { - var lin, sma, apogee; - var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day - if (meanmo === 0) { - return false - } else { - lin = satrec.inclo / deg2rad; - - if (lin >= 90.0) { - lin = 180.0 - lin; - } - - sma = 331.25 * Math.exp(Math.log(1440.0/meanmo)*(2.0/3.0)); - apogee = sma * (1.0 + satrec.ecco) - xkmper; - - if ((Math.acos(xkmper/(apogee+xkmper))+(lin*deg2rad)) > Math.abs(qth[0]*deg2rad)) { - return true - } else { - return false - } - } - }, - - _decayed: function(satrec, start) { - start = m_moment(start); - - var satepoch = m_moment.utc(satrec.epochyr, "YY").add(satrec.epochdays, 'days').valueOf(); - - var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day - var drag = satrec.ndot * 24 * 60 * 24 * 60 / (2 * Math.PI); // convert rev/day^2 - - if (satepoch + ms2day * ((16.666666-meanmo)/(10.0*Math.abs(drag))) < start) { - return true - } else { - return false - } - }, - - _findAOS: function(satrec, qth, start) { - var current = start; - var observed = this._observe(satrec, qth, current); - if (!observed) { - return null; - } - var aostime = 0; - var iterations = 0; - - if (observed.elevation > 0) { - return current - } - while (observed.elevation < -1 && iterations < max_iterations) { - current = current - ms2day * 0.00035*(observed.elevation*((observed.altitude/8400.0)+0.46)-2.0); - observed = this._observe(satrec, qth, current); - if (!observed) { - break; - } - iterations += 1; - } - iterations = 0; - while (aostime === 0 && iterations < max_iterations) { - if (!observed) { - break; - } - if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance - aostime = current; - } else { - current = current - ms2day * observed.elevation * Math.sqrt(observed.altitude)/530000.0; - observed = this._observe(satrec, qth, current); - } - iterations += 1; - } - if (aostime === 0) { - return null; - } - return aostime - }, - - _findLOS: function(satrec, qth, start) { - var current = start; - var observed = this._observe(satrec, qth, current); - var lostime = 0; - var iterations = 0; - - while (lostime === 0 && iterations < max_iterations) { - if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance - lostime = current; - } else { - current = current + ms2day * observed.elevation * Math.sqrt(observed.altitude)/502500.0; - observed = this._observe(satrec, qth, current); - if (!observed) { - break; - } - } - iterations += 1; - } - return lostime - }, - - _eci: function(satrec, date) { - date = new Date(date.valueOf()); - return satellite.propagate( - satrec, - date.getUTCFullYear(), - date.getUTCMonth() + 1, // months range 1-12 - date.getUTCDate(), - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds() - ); - }, - - _gmst: function(date) { - date = new Date(date.valueOf()); - return satellite.gstime( - date.getUTCFullYear(), - date.getUTCMonth() + 1, // months range 1-12 - date.getUTCDate(), - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds() - ); - }, - - _boundLongitude: function(longitude) { - while (longitude < -180) { - longitude += 360; - } - while (longitude > 180) { - longitude -= 360; - } - return longitude - }, - - _satEclipsed: function(pos, sol) { - var sd_earth = Math.asin(xkmper / this._magnitude(pos)); - var rho = this._vecSub(sol, pos); - var sd_sun = Math.asin(solar_radius / rho.w); - var earth = this._scalarMultiply(-1, pos); - var delta = this._angle(sol, earth); - - var eclipseDepth = sd_earth - sd_sun - delta; - var eclipse; - if (sd_earth < sd_sun) { - eclipse = false; - } else if (eclipseDepth >= 0) { - eclipse = true; - } else { - eclipse = false; - } - return { - depth: eclipseDepth, - eclipsed: eclipse - } - }, - - _calculateSolarPosition: function(start) { - var time = start / ms2day + 2444238.5; // jul_utc - - var mjd = time - 2415020.0; - var year = 1900 + mjd / 365.25; - var T = (mjd + this._deltaET(year) / (ms2day / 1000)) / 36525.0; - var M = deg2rad * ((358.47583 + ((35999.04975 * T) % 360) - (0.000150 + 0.0000033 * T) * Math.pow(T, 2)) % 360); - var L = deg2rad * ((279.69668 + ((36000.76892 * T) % 360) + 0.0003025 * Math.pow(T, 2)) % 360); - var e = 0.01675104 - (0.0000418 + 0.000000126 * T) * T; - var C = deg2rad * ((1.919460 - (0.004789 + 0.000014 * T) * T) * Math.sin(M) + (0.020094 - 0.000100 * T) * Math.sin(2 * M) + 0.000293 * Math.sin(3 * M)); - var O = deg2rad * ((259.18 - 1934.142 * T) % 360.0); - var Lsa = (L + C - deg2rad * (0.00569 - 0.00479 * Math.sin(O))) % (2 * Math.PI); - var nu = (M + C) % (2 * Math.PI); - var R = 1.0000002 * (1 - Math.pow(e, 2)) / (1 + e * Math.cos(nu)); - var eps = deg2rad * (23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * T) * T) * T + 0.00256 * Math.cos(O)); - var R = astro_unit * R; - - return { - x: R * Math.cos(Lsa), - y: R * Math.sin(Lsa) * Math.cos(eps), - z: R * Math.sin(Lsa) * Math.sin(eps), - w: R - } - }, - - _deltaET: function(year) { - return 26.465 + 0.747622 * (year - 1950) + 1.886913 * Math.sin((2 * Math.PI) * (year - 1975) / 33) - }, - - _vecSub: function(v1, v2) { - var vec = { - x: v1.x - v2.x, - y: v1.y - v2.y, - z: v1.z - v2.z - } - vec.w = this._magnitude(vec); - return vec - }, - - _scalarMultiply: function(k, v) { - return { - x: k * v.x, - y: k * v.y, - z: k * v.z, - w: v.w ? Math.abs(k) * v.w : undefined - } - }, - - _magnitude: function(v) { - return Math.sqrt(Math.pow(v.x, 2) + Math.pow(v.y, 2) + Math.pow(v.z, 2)) - }, - - _angle: function(v1, v2) { - var dot = (v1.x * v2.x + v1.y * v2.y + v1.z * v2.z); - return Math.acos(dot / (this._magnitude(v1) * this._magnitude(v2))) - } - } - - return _jspredict; -})); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8c238ef --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2460 @@ +{ + "name": "jspredict", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jspredict", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "luxon": "^3.7.2", + "satellite.js": "^7.1.0" + }, + "devDependencies": { + "@typescript/typescript6": "^6.0.2", + "eslint": "^10.8.0", + "typescript": "^7.0.2", + "vite": "^8.2.1", + "vite-plugin-dts": "^5.0.3" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/old": { + "name": "typescript", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript6": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript6/-/typescript6-6.0.2.tgz", + "integrity": "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@typescript/old": "npm:typescript@^6" + }, + "bin": { + "tsc6": "bin/tsc6" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/satellite.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/satellite.js/-/satellite.js-7.1.0.tgz", + "integrity": "sha512-U6nRml9Nb7dV9LJPiMNPyna7U7ry+1nXYkOeCEG7K/YbojQQLHHmcjPisp03VNY+HLcbQHqbt7t4t1vQMaKCLQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-dts": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/unplugin-dts/-/unplugin-dts-1.0.3.tgz", + "integrity": "sha512-/GR887wfG4r1cWyt1UZsLRuMIjsmEbGkS9yJrz+0dsToHAYUD5CTyP3JMGVLv25j9K0mJcwAVvZno/aTuSUvNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "@volar/typescript": "^2.4.26", + "compare-versions": "^6.1.1", + "debug": "^4.4.0", + "kolorist": "^1.8.0", + "local-pkg": "^1.1.1", + "magic-string": "^0.30.17", + "unplugin": "^2.3.2" + }, + "peerDependencies": { + "@microsoft/api-extractor": ">=7", + "@rspack/core": "^1", + "@vue/language-core": "^3.1.5", + "esbuild": "*", + "rolldown": "*", + "rollup": ">=3", + "typescript": ">=4", + "vite": ">=3", + "webpack": "^4 || ^5" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "@vue/language-core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-dts": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-5.0.3.tgz", + "integrity": "sha512-gIth6NdCEHWPiiRMCK3N6C8WjvdsrtEQrmsiG8h6Ov+lFP+b07Y+wcs9H0H7n146l0PDTYK4cQN1vgeG1pMdRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin-dts": "1.0.3" + }, + "peerDependencies": { + "@microsoft/api-extractor": ">=7", + "rollup": ">=3", + "vite": ">=3" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.js b/package.js deleted file mode 100644 index ca729bd..0000000 --- a/package.js +++ /dev/null @@ -1,18 +0,0 @@ -Package.describe({ - summary: "javascript port of predict open-source satellite tracking library", - version: "1.2.0", - name: "nsat:jspredict", - git: "https://github.com/nsat/jspredict" -}); - -Package.onUse(function(api) { - api.use("momentjs:moment@2.24.0"); - api.addFiles('satellite.js', ['client', 'server'], { - bare: true - }); - api.addFiles([ - "jspredict.js", - "export.js" - ]); - api.export("jspredict"); -}); diff --git a/package.json b/package.json index e2f8900..4658a04 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,17 @@ { "name": "jspredict", - "version": "1.2.0", - "description": "javascript port of predict open-source satellite tracking library", - "main": "jspredict.js", + "version": "2.0.0", + "description": "Javascript port of predict open-source satellite tracking library", + "type": "module", + "module": "./dist/jspredict.js", + "exports": { + ".": { + "import": "./dist/jspredict.js" + } + }, + "scripts": { + "build": "vite build" + }, "repository": { "type": "git", "url": "https://github.com/nsat/jspredict" @@ -23,14 +32,21 @@ "spacecraft", "spire" ], - "author": "Roshan Jobanputra ", + "author": "Spire", "license": "MIT", "bugs": { "url": "https://github.com/nsat/jspredict/issues" }, "homepage": "https://github.com/nsat/jspredict", "dependencies": { - "moment": "^2.24.0", - "satellite.js": "^3.0.1" + "luxon": "^3.7.2", + "satellite.js": "^7.1.0" + }, + "devDependencies": { + "@typescript/typescript6": "^6.0.2", + "eslint": "^10.8.0", + "typescript": "^7.0.2", + "vite": "^8.2.1", + "vite-plugin-dts": "^5.0.3" } } diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000..6a091c8 --- /dev/null +++ b/src/common.ts @@ -0,0 +1,52 @@ +// <---------------------------------------------------------------------------> +// TYPES +// <---------------------------------------------------------------------------> + +/** Kilometers */ +export type km = number; + +/** Radians */ +export type radians = number; + +/** Degrees */ +export type degrees = number; + +/** Astonomical Unit (AU) */ +export type au = number; + +/** Milliseconds */ +export type ms = number; + +// <---------------------------------------------------------------------------> +// CONSTANTS +// <---------------------------------------------------------------------------> + +/** Astronomical Unit - km (IAU 76) */ +export const astronomicalUnit: au = 1.49597870691E8; + +/** Solar Radius - km (IAU 76) */ +export const solarRadius: km = 6.96000E5; + +/** Convert degrees to radians */ +export const deg2rad: number = Math.PI / 180.0; + +/** Convert radians to degress */ +export const rad2deg: number = 180.0 / Math.PI + +/** Number of milliseconds in a day */ +export const ms2day: number = 1000 * 60 * 60 * 24; + +/** World Geodetic System 1984 (WGS84) Parameters */ +export const WGS84 = { + /** Semi-Major Axis Raidus (km) */ + a: 6378.137, + + /** Semi-Minor Axis Radius (km) */ + b: 6356.7523142, + + /** Flattening factor */ + f: 0.0033528107, + + /** First eccentricity squared */ + e2: 0.00669437999014 +} as const; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2f28b0a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,464 @@ +// jspredict v2.0.0 +// https://github.com/nsat/jspredict + +// Copyright (c) 2026, Spire Global Inc +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// * Neither the name of the Spire Global Inc nor the +// names of its contributors may be used to endorse or promote products +// derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// Spire Global Inc BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +// USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +// OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +// SUCH DAMAGE. + + + +// <---------------------------------------------------------------------------> +// CONSTANTS +// <---------------------------------------------------------------------------> + +const max_iterations = 250; +const defaultMinElevation = 4; // degrees + + + + +// <---------------------------------------------------------------------------> +// PRIVATE FUNCTIONS +// <---------------------------------------------------------------------------> + +function _observe(satrec, qth, start) { + start = m_moment(start); + + var eci = _eci(satrec, start); + var gmst = _gmst(start); + + if (!eci.position) { + return null; + } + + var geo = satellite.eciToGeodetic(eci.position, gmst); + var solar_vector = _calculateSolarPosition(start.valueOf()); + var eclipse = _satEclipsed(eci.position, solar_vector); + + var track = { + eci: eci, + gmst: gmst, + latitude: geo.latitude / deg2rad, + longitude: _boundLongitude(geo.longitude / deg2rad), + altitude: geo.height, + footprint: 12756.33 * Math.acos(earth_radius / (earth_radius + geo.height)), + sunlit: !eclipse.eclipsed, + eclipseDepth: eclipse.depth / deg2rad + } + + // If we have a groundstation let's get those additional observe parameters + if (qth && qth.length == 3) { + var observerGd = { + longitude: qth[1] * deg2rad, + latitude: qth[0] * deg2rad, + height: qth[2] + } + + var positionEcf = satellite.eciToEcf(eci.position, gmst), + velocityEcf = satellite.eciToEcf(eci.velocity, gmst), + observerEcf = satellite.geodeticToEcf(observerGd), + lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf), + doppler = satellite.dopplerFactor(observerEcf, positionEcf, velocityEcf); + + track.azimuth = lookAngles.azimuth / deg2rad; + track.elevation = lookAngles.elevation / deg2rad; + track.rangeSat = lookAngles.rangeSat; + track.doppler = doppler; + } + + return track +} + +function _quickPredict(satrec, qth, start, end) { + var transit = {}; + var lastel = 0; + var iterations = 0; + + if (_badSat(satrec, qth, start)) { + return null; + } + + var daynum = _findAOS(satrec, qth, start); + if (!daynum) { + return null; + } + transit.start = daynum; + + var observed = _observe(satrec, qth, daynum); + if (!observed) { + return null; + } + + var iel = Math.round(observed.elevation); + + var maxEl = 0, apexAz = 0, minAz = 360, maxAz = 0; + + while (iel >= 0 && iterations < max_iterations && (!end || daynum < end)) { + lastel = iel; + daynum = daynum + ms2day * Math.cos((observed.elevation-1.0)*deg2rad)*Math.sqrt(observed.altitude)/25000.0; + observed = _observe(satrec, qth, daynum); + iel = Math.round(observed.elevation); + if (maxEl < observed.elevation) { + maxEl = observed.elevation; + apexAz = observed.azimuth; + } + maxAz = Math.max(maxAz, observed.azimuth); + minAz = Math.min(minAz, observed.azimuth); + iterations += 1; + } + if (lastel !== 0) { + daynum = _findLOS(satrec, qth, daynum); + } + + transit.end = daynum; + transit.maxElevation = maxEl; + transit.apexAzimuth = apexAz; + transit.maxAzimuth = maxAz; + transit.minAzimuth = minAz; + transit.duration = transit.end - transit.start; + + return transit +} + +function _badSat(satrec, qth, start) { + if (qth && !_aosHappens(satrec, qth)) { + return true + } else if (start && _decayed(satrec, start)) { + return true + } else { + return false + } +} + +function _aosHappens(satrec, qth) { + var lin, sma, apogee; + var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day + if (meanmo === 0) { + return false + } else { + lin = satrec.inclo / deg2rad; + + if (lin >= 90.0) { + lin = 180.0 - lin; + } + + sma = 331.25 * Math.exp(Math.log(1440.0/meanmo)*(2.0/3.0)); + apogee = sma * (1.0 + satrec.ecco) - earth_radius; + + if ((Math.acos(earth_radius/(apogee+earth_radius))+(lin*deg2rad)) > Math.abs(qth[0]*deg2rad)) { + return true + } else { + return false + } + } +} + +function _decayed(satrec, start) { + start = m_moment(start); + + var satepoch = m_moment.utc(satrec.epochyr, "YY").add(satrec.epochdays, 'days').valueOf(); + + var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day + var drag = satrec.ndot * 24 * 60 * 24 * 60 / (2 * Math.PI); // convert rev/day^2 + + if (satepoch + ms2day * ((16.666666-meanmo)/(10.0*Math.abs(drag))) < start) { + return true + } else { + return false + } +} + +function _findAOS(satrec, qth, start) { + var current = start; + var observed = _observe(satrec, qth, current); + if (!observed) { + return null; + } + var aostime = 0; + var iterations = 0; + + if (observed.elevation > 0) { + return current + } + while (observed.elevation < -1 && iterations < max_iterations) { + current = current - ms2day * 0.00035*(observed.elevation*((observed.altitude/8400.0)+0.46)-2.0); + observed = _observe(satrec, qth, current); + if (!observed) { + break; + } + iterations += 1; + } + iterations = 0; + while (aostime === 0 && iterations < max_iterations) { + if (!observed) { + break; + } + if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance + aostime = current; + } else { + current = current - ms2day * observed.elevation * Math.sqrt(observed.altitude)/530000.0; + observed = _observe(satrec, qth, current); + } + iterations += 1; + } + if (aostime === 0) { + return null; + } + return aostime +} + +function _findLOS(satrec, qth, start) { + var current = start; + var observed = _observe(satrec, qth, current); + var lostime = 0; + var iterations = 0; + + while (lostime === 0 && iterations < max_iterations) { + if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance + lostime = current; + } else { + current = current + ms2day * observed.elevation * Math.sqrt(observed.altitude)/502500.0; + observed = _observe(satrec, qth, current); + if (!observed) { + break; + } + } + iterations += 1; + } + return lostime +} + +function _eci(satrec, date) { + date = new Date(date.valueOf()); + return satellite.propagate( + satrec, + date.getUTCFullYear(), + date.getUTCMonth() + 1, // months range 1-12 + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds() + ); +} + +function _gmst(date) { + date = new Date(date.valueOf()); + return satellite.gstime( + date.getUTCFullYear(), + date.getUTCMonth() + 1, // months range 1-12 + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds() + ); +} + +function _boundLongitude(longitude) { + while (longitude < -180) { + longitude += 360; + } + while (longitude > 180) { + longitude -= 360; + } + return longitude +} + +function _satEclipsed(pos, sol) { + var sd_earth = Math.asin(earth_radius / _magnitude(pos)); + var rho = _vecSub(sol, pos); + var sd_sun = Math.asin(solar_radius / rho.w); + var earth = _scalarMultiply(-1, pos); + var delta = _angle(sol, earth); + + var eclipseDepth = sd_earth - sd_sun - delta; + var eclipse; + if (sd_earth < sd_sun) { + eclipse = false; + } else if (eclipseDepth >= 0) { + eclipse = true; + } else { + eclipse = false; + } + return { + depth: eclipseDepth, + eclipsed: eclipse + } +} + +function _calculateSolarPosition(start) { + var time = start / ms2day + 2444238.5; // jul_utc + + var mjd = time - 2415020.0; + var year = 1900 + mjd / 365.25; + var T = (mjd + _deltaET(year) / (ms2day / 1000)) / 36525.0; + var M = deg2rad * ((358.47583 + ((35999.04975 * T) % 360) - (0.000150 + 0.0000033 * T) * Math.pow(T, 2)) % 360); + var L = deg2rad * ((279.69668 + ((36000.76892 * T) % 360) + 0.0003025 * Math.pow(T, 2)) % 360); + var e = 0.01675104 - (0.0000418 + 0.000000126 * T) * T; + var C = deg2rad * ((1.919460 - (0.004789 + 0.000014 * T) * T) * Math.sin(M) + (0.020094 - 0.000100 * T) * Math.sin(2 * M) + 0.000293 * Math.sin(3 * M)); + var O = deg2rad * ((259.18 - 1934.142 * T) % 360.0); + var Lsa = (L + C - deg2rad * (0.00569 - 0.00479 * Math.sin(O))) % (2 * Math.PI); + var nu = (M + C) % (2 * Math.PI); + var R = 1.0000002 * (1 - Math.pow(e, 2)) / (1 + e * Math.cos(nu)); + var eps = deg2rad * (23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * T) * T) * T + 0.00256 * Math.cos(O)); + var R = astro_unit * R; + + return { + x: R * Math.cos(Lsa), + y: R * Math.sin(Lsa) * Math.cos(eps), + z: R * Math.sin(Lsa) * Math.sin(eps), + w: R + } +} + +function _deltaET(year) { + return 26.465 + 0.747622 * (year - 1950) + 1.886913 * Math.sin((2 * Math.PI) * (year - 1975) / 33) +} + +function _vecSub(v1, v2) { + var vec = { + x: v1.x - v2.x, + y: v1.y - v2.y, + z: v1.z - v2.z + } + vec.w = _magnitude(vec); + return vec +} + +function _scalarMultiply(k, v) { + return { + x: k * v.x, + y: k * v.y, + z: k * v.z, + w: v.w ? Math.abs(k) * v.w : undefined + } +} + +function _magnitude(v) { + return Math.sqrt(Math.pow(v.x, 2) + Math.pow(v.y, 2) + Math.pow(v.z, 2)) +} + +function _angle(v1, v2) { + var dot = (v1.x * v2.x + v1.y * v2.y + v1.z * v2.z); + return Math.acos(dot / (_magnitude(v1) * _magnitude(v2))) +} + +// <---------------------------------------------------------------------------> +// PUBLIC FUNCTIONS +// <---------------------------------------------------------------------------> + + + +function observe(tle, qth, start) { + var tles = tle.split('\n'); + var satrec = satellite.twoline2satrec(tles[1], tles[2]); + + if (_badSat(satrec, qth, start)) { + return null; + } + + return _observe(satrec, qth, start) +} + +function observes(tle, qth, start, end, interval) { + start = m_moment(start); + end = m_moment(end); + + var tles = tle.split('\n'); + var satrec = satellite.twoline2satrec(tles[1], tles[2]); + + if (_badSat(satrec, qth, start)) { + return null; + } + + var observes = [], observed; + var iterations = 0; + while (start < end && iterations < max_iterations) { + observed = _observe(satrec, qth, start); + if (!observed) { + break; + } + observes.push(observed); + start.add(interval); + iterations += 1; + } + + return observes +} + +function transits(tle, qth, start, end, minElevation, maxTransits) { + start = m_moment(start); + end = m_moment(end); + + if (!minElevation) { + minElevation = defaultMinElevation; + } + + if (!maxTransits) { + maxTransits = max_iterations; + } + + var tles = tle.split('\n'); + var satrec = satellite.twoline2satrec(tles[1], tles[2]); + if (_badSat(satrec, qth, start)) { + return []; + } + + var time = start.valueOf(); + var transits = []; + var nextTransit; + var iterations = 0; + + while (iterations < max_iterations && transits.length < maxTransits) { + transit = _quickPredict(satrec, qth, time); + if (!transit) { + break; + } + if (transit.end > end.valueOf()) { + break; + } + if (transit.end > start.valueOf() && transit.maxElevation > minElevation) { + transits.push(transit); + } + time = transit.end + 60 * 1000; + iterations += 1; + } + + return transits +} + +function transitSegment(tle, qth, start, end) { + start = m_moment(start); + end = m_moment(end); + + var tles = tle.split('\n'); + var satrec = satellite.twoline2satrec(tles[1], tles[2]); + if (_badSat(satrec, qth, start)) { + return []; + } + + return _quickPredict(satrec, qth, start.valueOf(), end.valueOf()); +} + diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..d9dd417 --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,31 @@ +import { PositionAndVelocity } from "satellite.js" + +export interface SatelliteObservation { + /** Satellite position and velocity in Earth-Centered Interial (ECI) coordinates at the given GMST */ + eci: PositionAndVelocity, + + /** Greenwich Sidereal Time (GMST) */ + gmst: number, + + /** Satellite latitudinal position in degress */ + latitude: number, + + /** Satellite longitudinal position in degrees */ + longitude: number, + + /** Satellite altitude in kilometers (km) */ + altitude: number, + + /** Satellite footprint radius in kilometers (km) */ + footprint: number, + + /** Indicates if the satellite is in eclipse */ + sunlit: boolean, + + /** Eclipse factor (1.0 full sunlight, 0.0 total darkness)*/ + eclipseDepth: number +} + +export interface ObserverSatelliteObservation extends SatelliteObservation { + +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..207a82d --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,40 @@ +import type { radians, km } from "./common.ts"; +import { WGS84 } from "./common.ts"; + +// <---------------------------------------------------------------------------> +// UTILITY FUNCTIONS +// <---------------------------------------------------------------------------> + +/** + * Calculate the radius of Earth's curvature at a given latitude + * based on a WGS84 ellipsoid. + * @param latitude sub-satellite point (SSP) latitude in radians. + */ +export function localEarthRadius(latitude: radians): km { + const re = WGS84.a / Math.sqrt((1 - WGS84.e2 * Math.pow(Math.sin(latitude), 2))); + return re; +} + +/** + * Calculate the Earth central angle for a given radius and altitude. + * @param re earth radius + * @param altitude satellite altitude (kilometers) + * @param epsilon minimum elevation angle (radians) + */ +export function earthCentralAngle(re: km, altitude: km, epsilon: radians = 0.0): radians { + const lambda = Math.acos((re / (re + altitude)) * Math.cos(epsilon)) - epsilon + return lambda +} + +/** + * Calculate the satellite's footprint radius. + * @param lattitude sub-satellite point (SSP) latitude in radians + * @param altitude satellite altitude (kilometers) + * @param epsilon minimum elevation angle (radians) + */ + export function footprintRadius(latitude: radians, altitude: km, epsilon: radians = 0.0): km { + const re = localEarthRadius(latitude); + const lambda = earthCentralAngle(re, altitude, epsilon) + const footprint = re * lambda + return footprint + } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..732c8ce --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + plugins: [ + // Generates type files so consumers get IntelliSense in VS Code + dts({}) + ], + build: { + lib: { + // Defines the entry point of your module + entry: 'src/common.ts', + name: 'JsPredict', + formats: ['es'], + fileName: 'jspredict', + }, + // Ensures your bundle doesn't include code you expect users to provide + rollupOptions: { + external: ['satellite.js'], + }, + }, +}); \ No newline at end of file From 2d21a81f4a580b125621fe6adac951a816d743ce Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Fri, 7 Aug 2026 16:57:43 -0600 Subject: [PATCH 02/14] Implementation updates --- package-lock.json | 390 ++++++++++++++++++++--- package.json | 7 +- src/__tests__/index.test.ts | 149 +++++++++ src/__tests__/utils.test.ts | 197 ++++++++++++ src/common.ts | 52 ---- src/constants.ts | 31 ++ src/index.ts | 599 +++++++++++------------------------- src/interfaces.ts | 135 ++++++-- src/legacy.ts | 456 +++++++++++++++++++++++++++ src/types.ts | 29 ++ src/utils.ts | 190 +++++++++++- 11 files changed, 1680 insertions(+), 555 deletions(-) create mode 100644 src/__tests__/index.test.ts create mode 100644 src/__tests__/utils.test.ts delete mode 100644 src/common.ts create mode 100644 src/constants.ts create mode 100644 src/legacy.ts create mode 100644 src/types.ts diff --git a/package-lock.json b/package-lock.json index 8c238ef..12829e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "license": "MIT", "dependencies": { "luxon": "^3.7.2", - "satellite.js": "^7.1.0" + "satellite.js": "^7.1.0", + "vitest": "^4.1.10" }, "devDependencies": { + "@types/luxon": "^3.7.4", "@typescript/typescript6": "^6.0.2", "eslint": "^10.8.0", "typescript": "^7.0.2", @@ -229,7 +231,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -247,7 +248,6 @@ "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -260,7 +260,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -277,7 +276,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -294,7 +292,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -311,7 +308,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -328,7 +324,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -345,7 +340,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -365,7 +359,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -385,7 +378,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -405,7 +397,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -425,7 +416,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -445,7 +435,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -465,7 +454,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -482,7 +470,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -499,7 +486,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -513,7 +499,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, "license": "MIT" }, "node_modules/@rollup/pluginutils": { @@ -539,6 +524,28 @@ } } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -550,7 +557,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -560,6 +566,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.4.tgz", + "integrity": "sha512-V536ZAd6ZJztrrBlLcDFaaZrXNAL2E5uGmssWf/dpSiLkmkLScXUYhUBnWPmtW+cIqnNHzf6//TCMpIc9SCRRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/old": { "name": "typescript", "version": "6.0.3", @@ -928,6 +941,121 @@ "tsc6": "bin/tsc6" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -997,6 +1125,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1020,6 +1157,15 @@ "node": "20 || >=22" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/compare-versions": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", @@ -1034,6 +1180,12 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1078,12 +1230,17 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1259,6 +1416,15 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", @@ -1291,7 +1457,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1360,7 +1525,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1490,7 +1654,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1523,7 +1686,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1544,7 +1706,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1565,7 +1726,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1586,7 +1746,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1607,7 +1766,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1628,7 +1786,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -1652,7 +1809,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -1676,7 +1832,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -1700,7 +1855,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -1724,7 +1878,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1745,7 +1898,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1806,7 +1958,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1871,7 +2022,6 @@ "version": "3.3.17", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", - "dev": true, "funding": [ { "type": "github", @@ -1893,6 +2043,19 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -1974,21 +2137,18 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2013,7 +2173,6 @@ "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2079,7 +2238,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.143.0", @@ -2137,21 +2295,52 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2164,6 +2353,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2303,7 +2501,6 @@ "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", @@ -2403,6 +2600,95 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -2433,6 +2719,22 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 4658a04..7381143 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ } }, "scripts": { - "build": "vite build" + "build": "vite build", + "test": "vitest" }, "repository": { "type": "git", @@ -43,10 +44,12 @@ "satellite.js": "^7.1.0" }, "devDependencies": { + "@types/luxon": "^3.7.4", "@typescript/typescript6": "^6.0.2", "eslint": "^10.8.0", "typescript": "^7.0.2", "vite": "^8.2.1", - "vite-plugin-dts": "^5.0.3" + "vite-plugin-dts": "^5.0.3", + "vitest": "^4.1.10" } } diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts new file mode 100644 index 0000000..da728e5 --- /dev/null +++ b/src/__tests__/index.test.ts @@ -0,0 +1,149 @@ + +import { describe, expect, test } from 'vitest' +import { + degreesLat, + degreesLong, + dopplerFactor, + ecfToLookAngles, + eciToEcf, + eciToGeodetic, + geodeticToEcf, + gstime, + json2satrec, + propagate, + radiansLat, + radiansLong, +} from 'satellite.js' + +import { observe } from '../index' +import { convertTleToOmm, footprintRadius } from '../utils' + +// <---------------------------------------------------------------------------> +// TEST RESOURCES +// <---------------------------------------------------------------------------> + +const issTle = `0 ISS (ZARYA) +1 25544U 98067A 26219.02141064 .00004539 00000-0 89363-4 0 9992 +2 25544 51.6324 48.5171 0007293 20.5996 339.5285 15.49370096579630` + +const issOmm = { + "CCSDS_OMM_VERS": "3.0", + "COMMENT": "GENERATED VIA SPACE-TRACK.ORG API", + "CREATION_DATE": "2026-08-07T12:46:56", + "ORIGINATOR": "18 SPCS", + "OBJECT_NAME": "ISS (ZARYA)", + "OBJECT_ID": "1998-067A", + "CENTER_NAME": "EARTH", + "REF_FRAME": "TEME", + "TIME_SYSTEM": "UTC", + "MEAN_ELEMENT_THEORY": "SGP4", + "EPOCH": "2026-08-07T00:30:49.879296", + "MEAN_MOTION": "15.49370096", + "ECCENTRICITY": "0.00072933", + "INCLINATION": "51.6324", + "RA_OF_ASC_NODE": "48.5171", + "ARG_OF_PERICENTER": "20.5996", + "MEAN_ANOMALY": "339.5285", + "EPHEMERIS_TYPE": "0", + "CLASSIFICATION_TYPE": "U", + "NORAD_CAT_ID": "25544", + "ELEMENT_SET_NO": "999", + "REV_AT_EPOCH": "57963", + "BSTAR": "0.00008936277000", + "MEAN_MOTION_DOT": "0.00004539", + "MEAN_MOTION_DDOT": "0.0000000000000", + "SEMIMAJOR_AXIS": "6796.705", + "PERIOD": "92.941", + "APOAPSIS": "423.527", + "PERIAPSIS": "413.613", + "OBJECT_TYPE": "PAYLOAD", + "RCS_SIZE": "LARGE", + "COUNTRY_CODE": "CIS", + "LAUNCH_DATE": "1998-11-20", + "SITE": "TTMTR", + "DECAY_DATE": null, + "FILE": "5307055", + "GP_ID": "338621911", + "TLE_LINE0": "0 ISS (ZARYA)", + "TLE_LINE1": "1 25544U 98067A 26219.02141064 .00004539 00000-0 89363-4 0 9992", + "TLE_LINE2": "2 25544 51.6324 48.5171 0007293 20.5996 339.5285 15.49370096579630" +} + +const observationEpoch = '2026-08-07T00:30:49.879296Z' +const laterObservationEpoch = '2026-08-08T00:30:49.879296Z' + +// <---------------------------------------------------------------------------> +// TESTS +// <---------------------------------------------------------------------------> + +describe('observe', () => { + test('returns a ground track with position units converted from satellite.js', () => { + const observed = observe(issTle, observationEpoch) + const date = new Date(observationEpoch) + const satrec = json2satrec(convertTleToOmm(issTle)) + const propagated = propagate(satrec, date) + + if (!propagated) { + throw new Error('Expected propagation result') + } + + const gmst = gstime(date) + const geodetic = eciToGeodetic(propagated.position, gmst) + const ecef = eciToEcf(propagated.position, gmst) + + expect(observed.id).toBe('1998-067A') + expect(observed.name).toBe('ISS (ZARYA)') + expect(observed.noradCatalogId).toBe('25544') + expect(observed.epoch).toBe('2026-08-07T00:30:49.879Z') + expect(observed.decayed).toBe(false) + expect(observed.position?.eci?.x).toBeCloseTo(propagated.position.x, 10) + expect(observed.position?.ecef?.x).toBeCloseTo(ecef.x, 10) + expect(observed.position?.latitude).toBeCloseTo(degreesLat(geodetic.latitude), 10) + expect(observed.position?.longitude).toBeCloseTo(degreesLong(geodetic.longitude), 10) + expect(observed.position?.altitude).toBeCloseTo(geodetic.height, 10) + expect(observed.footprint).toBeCloseTo(footprintRadius(geodetic.latitude, geodetic.height) * 2, 10) + expect(observed.velocity?.eci?.x).toBeCloseTo(propagated.velocity.x, 10) + expect(observed.velocity?.ecef?.x).toBeCloseTo(eciToEcf(propagated.velocity, gmst).x, 10) + expect(observed.orbit?.velocity).toBeCloseTo( + Math.hypot(propagated.velocity.x, propagated.velocity.y, propagated.velocity.z) * 3600, + 10, + ) + }) + + test('returns observer look angles in degrees and slant range in kilometers', () => { + const observer = { latitude: 15, longitude: 130, altitude: 0.1 } + const observed = observe(issOmm, observationEpoch, observer) + const date = new Date(observationEpoch) + const satrec = json2satrec(issOmm) + const propagated = propagate(satrec, date) + + if (!propagated || !('observerPosition' in observed)) { + throw new Error('Expected observed track with observer data') + } + + const gmst = gstime(date) + const observerGeodetic = { + latitude: radiansLat(observer.latitude), + longitude: radiansLong(observer.longitude), + height: observer.altitude, + } + const positionEcf = eciToEcf(propagated.position, gmst) + const velocityEcf = eciToEcf(propagated.velocity, gmst) + const observerEcf = geodeticToEcf(observerGeodetic) + const lookAngles = ecfToLookAngles(observerGeodetic, positionEcf) + + expect(observed.observerPosition.ecef?.x).toBeCloseTo(observerEcf.x, 10) + expect(observed.azimuth).toBeCloseTo((lookAngles.azimuth * 180) / Math.PI, 10) + expect(observed.elevation).toBeCloseTo((lookAngles.elevation * 180) / Math.PI, 10) + expect(observed.slantRange).toBeCloseTo(lookAngles.rangeSat, 10) + expect(observed.dopplerFactor).toBeCloseTo(dopplerFactor(observerEcf, positionEcf, velocityEcf), 12) + expect(observed.hasAos).toBe(false) + expect(observed.visibility).toBe('below-horizon') + }) + + test('predicts revolution count from the observation time', () => { + const observed = observe(issOmm, laterObservationEpoch) + + expect(observed.orbit?.revolutionCount).toBe(57978) + }) +}) diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts new file mode 100644 index 0000000..0c22b9a --- /dev/null +++ b/src/__tests__/utils.test.ts @@ -0,0 +1,197 @@ +import { DateTime } from 'luxon' +import { describe, expect, test } from 'vitest' + +import { WGS84, deg2rad } from '../constants' +import { + convertTleToOmm, + earthCentralAngle, + footprintRadius, + greenwichMeanSiderealTime, + localEarthRadius, + parseDateTime, + predictedRevolutionCount, +} from '../utils' + +// <---------------------------------------------------------------------------> +// TEST RESOURCES +// <---------------------------------------------------------------------------> + +const namedTle = `0 LEMUR-2 JEROEN +1 40934U 15052E 15306.10048119 .00001740 00000-0 15647-3 0 9990 +2 40934 6.0033 141.2190 0010344 133.6141 226.4604 14.76056230 5130` + +const unnamedTle = `1 40934U 15052E 15306.10048119 .00001740 00000-0 15647-3 0 9990 +2 40934 6.0033 141.2190 0010344 133.6141 226.4604 14.76056230 5130` + +const invalidChecksumTle = `0 LEMUR-2 JEROEN +1 40934U 15052E 15306.10048119 .00001740 00000-0 15647-3 0 9991 +2 40934 6.0033 141.2190 0010344 133.6141 226.4604 14.76056230 5130` + +// <---------------------------------------------------------------------------> +// TESTS +// <---------------------------------------------------------------------------> + +describe('utils.localEarthRadius', () => { + test('returns the WGS84 semi-major axis at the equator', () => { + expect(localEarthRadius(0)).toBeCloseTo(WGS84.a, 10) + }) + + test('returns the same value for matching north and south latitudes', () => { + const north = localEarthRadius(45 * deg2rad) + const south = localEarthRadius(-45 * deg2rad) + + expect(north).toBeCloseTo(south, 10) + }) +}) + +describe('utils.earthCentralAngle', () => { + test('matches the horizon-angle formula when minimum elevation is zero', () => { + const re = WGS84.a + const altitude = 550 + const expected = Math.acos(re / (re + altitude)) + + expect(earthCentralAngle(re, altitude)).toBeCloseTo(expected, 12) + }) + + test('decreases as the minimum elevation angle increases', () => { + const re = WGS84.a + const altitude = 550 + const horizonAngle = earthCentralAngle(re, altitude) + const constrainedAngle = earthCentralAngle(re, altitude, 10 * deg2rad) + + expect(constrainedAngle).toBeLessThan(horizonAngle) + }) +}) + +describe('utils.footprintRadius', () => { + test('returns zero when altitude is zero', () => { + expect(footprintRadius(0, 0)).toBeCloseTo(0, 12) + }) + + test('matches the local radius multiplied by the central angle', () => { + const latitude = 30 * deg2rad + const altitude = 550 + const epsilon = 5 * deg2rad + const expected = + localEarthRadius(latitude) * + earthCentralAngle(localEarthRadius(latitude), altitude, epsilon) + + expect(footprintRadius(latitude, altitude, epsilon)).toBeCloseTo(expected, 10) + }) +}) + +describe('utils.parseDateTime', () => { + test('parses datetime strings as UTC when no timezone is provided', () => { + const parsedDate = parseDateTime('2026-07-15T14:30:42.137') + + expect(parsedDate.year).toBe(2026) + expect(parsedDate.month).toBe(7) + expect(parsedDate.day).toBe(15) + expect(parsedDate.hour).toBe(14) + expect(parsedDate.minute).toBe(30) + expect(parsedDate.second).toBe(42) + expect(parsedDate.millisecond).toBe(137) + expect(parsedDate.zoneName).toBe('UTC') + }) + + test('preserves timezone data when the datetime string includes it', () => { + const parsedDate = parseDateTime('2026-07-15T14:30:42.137-04:00') + + expect(parsedDate.year).toBe(2026) + expect(parsedDate.month).toBe(7) + expect(parsedDate.day).toBe(15) + expect(parsedDate.hour).toBe(14) + expect(parsedDate.minute).toBe(30) + expect(parsedDate.second).toBe(42) + expect(parsedDate.millisecond).toBe(137) + expect(parsedDate.offset).toBe(-240) + expect(parsedDate.toISO()).toBe('2026-07-15T14:30:42.137-04:00') + }) + + test('parses unix timestamps', () => { + const timestamp = Date.UTC(2026, 6, 15, 14, 30, 42, 137) + const parsedDate = parseDateTime(timestamp) + + expect(parsedDate.toMillis()).toBe(timestamp) + expect(parsedDate.zoneName).toBe('UTC') + }) + + test('parses JS Date objects', () => { + const date = new Date(2026, 6, 15, 14, 30, 42, 137) + const parsedDate = parseDateTime(date) + const expected = DateTime.fromJSDate(date) + + expect(parsedDate.toMillis()).toBe(date.getTime()) + expect(parsedDate.year).toBe(date.getFullYear()) + expect(parsedDate.month).toBe(date.getMonth() + 1) // luxon doesn't use 0 indexing + expect(parsedDate.day).toBe(date.getDate()) + expect(parsedDate.hour).toBe(date.getHours()) + expect(parsedDate.minute).toBe(date.getMinutes()) + expect(parsedDate.second).toBe(date.getSeconds()) + expect(parsedDate.millisecond).toBe(date.getMilliseconds()) + expect(parsedDate.zoneName).toBe(expected.zoneName) + }) + + test('returns existing luxon DateTime instances unchanged', () => { + const dateTime = DateTime.utc(2026, 7, 15, 14, 30, 42, 137) + + expect(parseDateTime(dateTime)).toBe(dateTime) + }) + + test('throws errors for unsupported types', () => { + expect(() => parseDateTime({} as never)).toThrow('Unsupported datetime type') + }) +}) + +describe('utils.greenwichMeanSiderealTime', () => { + test('returns the expected sidereal time in radians for a fixed UTC instant', () => { + const dateTime = DateTime.fromISO('2026-07-15T14:30:42.137Z') + + expect(greenwichMeanSiderealTime(dateTime)).toBeCloseTo(2.637779678153912, 12) + }) +}) + +describe('utils.predictedRevolutionCount', () => { + test('predicts completed revolutions from epoch, observation time, and mean motion', () => { + const omm = convertTleToOmm(`0 ISS (ZARYA) +1 25544U 98067A 26219.02141064 .00004539 00000-0 89363-4 0 9992 +2 25544 51.6324 48.5171 0007293 20.5996 339.5285 15.49370096579630`) + + expect(predictedRevolutionCount(omm, DateTime.fromISO('2026-08-08T00:30:49.879296Z'))).toBe(57978) + }) +}) + +describe('utils.convertTleToOmm', () => { + test('converts a 3-line TLE and uses line 0 as the object name', () => { + const omm = convertTleToOmm(namedTle) + + expect(omm.OBJECT_NAME).toBe('LEMUR-2 JEROEN') + expect(omm.OBJECT_ID).toBe('2015-052E') + expect(omm.NORAD_CAT_ID).toBe('40934') + expect(omm.CLASSIFICATION_TYPE).toBe('U') + expect(omm.EPOCH).toBe('2015-11-02T02:24:41.574Z') + expect(omm.MEAN_MOTION).toBeCloseTo(14.7605623, 10) + expect(omm.ECCENTRICITY).toBeCloseTo(0.0010344, 10) + expect(omm.INCLINATION).toBeCloseTo(6.0033, 10) + expect(omm.RA_OF_ASC_NODE).toBeCloseTo(141.219, 10) + expect(omm.ARG_OF_PERICENTER).toBeCloseTo(133.6141, 10) + expect(omm.MEAN_ANOMALY).toBeCloseTo(226.4604, 10) + expect(omm.BSTAR).toBeCloseTo(0.00015647, 12) + expect(omm.MEAN_MOTION_DOT).toBeCloseTo(0.0000174, 12) + expect(omm.MEAN_MOTION_DDOT).toBe(0) + expect(omm.ELEMENT_SET_NO).toBe(999) + expect(omm.REV_AT_EPOCH).toBe(513) + }) + + test('converts a 2-line TLE and uses object ID as object name', () => { + const omm = convertTleToOmm(unnamedTle) + + expect(omm.OBJECT_NAME).toBe('2015-052E') + expect(omm.OBJECT_ID).toBe('2015-052E') + expect(omm.NORAD_CAT_ID).toBe('40934') + }) + + test('throws when a TLE checksum is invalid', () => { + expect(() => convertTleToOmm(invalidChecksumTle)).toThrow('Invalid TLE checksum') + }) +}) diff --git a/src/common.ts b/src/common.ts deleted file mode 100644 index 6a091c8..0000000 --- a/src/common.ts +++ /dev/null @@ -1,52 +0,0 @@ -// <---------------------------------------------------------------------------> -// TYPES -// <---------------------------------------------------------------------------> - -/** Kilometers */ -export type km = number; - -/** Radians */ -export type radians = number; - -/** Degrees */ -export type degrees = number; - -/** Astonomical Unit (AU) */ -export type au = number; - -/** Milliseconds */ -export type ms = number; - -// <---------------------------------------------------------------------------> -// CONSTANTS -// <---------------------------------------------------------------------------> - -/** Astronomical Unit - km (IAU 76) */ -export const astronomicalUnit: au = 1.49597870691E8; - -/** Solar Radius - km (IAU 76) */ -export const solarRadius: km = 6.96000E5; - -/** Convert degrees to radians */ -export const deg2rad: number = Math.PI / 180.0; - -/** Convert radians to degress */ -export const rad2deg: number = 180.0 / Math.PI - -/** Number of milliseconds in a day */ -export const ms2day: number = 1000 * 60 * 60 * 24; - -/** World Geodetic System 1984 (WGS84) Parameters */ -export const WGS84 = { - /** Semi-Major Axis Raidus (km) */ - a: 6378.137, - - /** Semi-Minor Axis Radius (km) */ - b: 6356.7523142, - - /** Flattening factor */ - f: 0.0033528107, - - /** First eccentricity squared */ - e2: 0.00669437999014 -} as const; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..3c321a4 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,31 @@ +import { Kilometers, AstronomialUnits } from "./types"; + +/** Astronomical Unit - km (IAU 76) */ +export const astronomicalUnit: AstronomialUnits = 1.49597870691E8; + +/** Solar Radius - km (IAU 76) */ +export const solarRadius: Kilometers = 6.96000E5; + +/** Convert degrees to radians */ +export const deg2rad: number = Math.PI / 180.0; + +/** Convert radians to degress */ +export const rad2deg: number = 180.0 / Math.PI + +/** Number of milliseconds in a day */ +export const day2ms: number = 1000 * 60 * 60 * 24; + +/** World Geodetic System 1984 (WGS84) Parameters */ +export const WGS84 = { + /** Semi-Major Axis Raidus (km) */ + a: 6378.137, + + /** Semi-Minor Axis Radius (km) */ + b: 6356.7523142, + + /** Flattening factor */ + f: 0.0033528107, + + /** First eccentricity squared */ + e2: 0.00669437999014 +} as const; diff --git a/src/index.ts b/src/index.ts index 2f28b0a..3bf5f2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,437 +28,182 @@ // OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF // SUCH DAMAGE. - - -// <---------------------------------------------------------------------------> -// CONSTANTS -// <---------------------------------------------------------------------------> - -const max_iterations = 250; -const defaultMinElevation = 4; // degrees - - - - -// <---------------------------------------------------------------------------> -// PRIVATE FUNCTIONS -// <---------------------------------------------------------------------------> - -function _observe(satrec, qth, start) { - start = m_moment(start); - - var eci = _eci(satrec, start); - var gmst = _gmst(start); - - if (!eci.position) { - return null; - } - - var geo = satellite.eciToGeodetic(eci.position, gmst); - var solar_vector = _calculateSolarPosition(start.valueOf()); - var eclipse = _satEclipsed(eci.position, solar_vector); - - var track = { - eci: eci, - gmst: gmst, - latitude: geo.latitude / deg2rad, - longitude: _boundLongitude(geo.longitude / deg2rad), - altitude: geo.height, - footprint: 12756.33 * Math.acos(earth_radius / (earth_radius + geo.height)), - sunlit: !eclipse.eclipsed, - eclipseDepth: eclipse.depth / deg2rad - } - - // If we have a groundstation let's get those additional observe parameters - if (qth && qth.length == 3) { - var observerGd = { - longitude: qth[1] * deg2rad, - latitude: qth[0] * deg2rad, - height: qth[2] - } - - var positionEcf = satellite.eciToEcf(eci.position, gmst), - velocityEcf = satellite.eciToEcf(eci.velocity, gmst), - observerEcf = satellite.geodeticToEcf(observerGd), - lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf), - doppler = satellite.dopplerFactor(observerEcf, positionEcf, velocityEcf); - - track.azimuth = lookAngles.azimuth / deg2rad; - track.elevation = lookAngles.elevation / deg2rad; - track.rangeSat = lookAngles.rangeSat; - track.doppler = doppler; - } - - return track -} - -function _quickPredict(satrec, qth, start, end) { - var transit = {}; - var lastel = 0; - var iterations = 0; - - if (_badSat(satrec, qth, start)) { - return null; - } - - var daynum = _findAOS(satrec, qth, start); - if (!daynum) { - return null; - } - transit.start = daynum; - - var observed = _observe(satrec, qth, daynum); - if (!observed) { - return null; - } - - var iel = Math.round(observed.elevation); - - var maxEl = 0, apexAz = 0, minAz = 360, maxAz = 0; - - while (iel >= 0 && iterations < max_iterations && (!end || daynum < end)) { - lastel = iel; - daynum = daynum + ms2day * Math.cos((observed.elevation-1.0)*deg2rad)*Math.sqrt(observed.altitude)/25000.0; - observed = _observe(satrec, qth, daynum); - iel = Math.round(observed.elevation); - if (maxEl < observed.elevation) { - maxEl = observed.elevation; - apexAz = observed.azimuth; - } - maxAz = Math.max(maxAz, observed.azimuth); - minAz = Math.min(minAz, observed.azimuth); - iterations += 1; - } - if (lastel !== 0) { - daynum = _findLOS(satrec, qth, daynum); - } - - transit.end = daynum; - transit.maxElevation = maxEl; - transit.apexAzimuth = apexAz; - transit.maxAzimuth = maxAz; - transit.minAzimuth = minAz; - transit.duration = transit.end - transit.start; - - return transit -} - -function _badSat(satrec, qth, start) { - if (qth && !_aosHappens(satrec, qth)) { - return true - } else if (start && _decayed(satrec, start)) { - return true - } else { - return false - } -} - -function _aosHappens(satrec, qth) { - var lin, sma, apogee; - var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day - if (meanmo === 0) { - return false - } else { - lin = satrec.inclo / deg2rad; - - if (lin >= 90.0) { - lin = 180.0 - lin; - } - - sma = 331.25 * Math.exp(Math.log(1440.0/meanmo)*(2.0/3.0)); - apogee = sma * (1.0 + satrec.ecco) - earth_radius; - - if ((Math.acos(earth_radius/(apogee+earth_radius))+(lin*deg2rad)) > Math.abs(qth[0]*deg2rad)) { - return true - } else { - return false - } - } -} - -function _decayed(satrec, start) { - start = m_moment(start); - - var satepoch = m_moment.utc(satrec.epochyr, "YY").add(satrec.epochdays, 'days').valueOf(); - - var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day - var drag = satrec.ndot * 24 * 60 * 24 * 60 / (2 * Math.PI); // convert rev/day^2 - - if (satepoch + ms2day * ((16.666666-meanmo)/(10.0*Math.abs(drag))) < start) { - return true - } else { - return false - } -} - -function _findAOS(satrec, qth, start) { - var current = start; - var observed = _observe(satrec, qth, current); - if (!observed) { - return null; - } - var aostime = 0; - var iterations = 0; - - if (observed.elevation > 0) { - return current - } - while (observed.elevation < -1 && iterations < max_iterations) { - current = current - ms2day * 0.00035*(observed.elevation*((observed.altitude/8400.0)+0.46)-2.0); - observed = _observe(satrec, qth, current); - if (!observed) { - break; - } - iterations += 1; - } - iterations = 0; - while (aostime === 0 && iterations < max_iterations) { - if (!observed) { - break; - } - if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance - aostime = current; - } else { - current = current - ms2day * observed.elevation * Math.sqrt(observed.altitude)/530000.0; - observed = _observe(satrec, qth, current); - } - iterations += 1; - } - if (aostime === 0) { - return null; - } - return aostime -} - -function _findLOS(satrec, qth, start) { - var current = start; - var observed = _observe(satrec, qth, current); - var lostime = 0; - var iterations = 0; - - while (lostime === 0 && iterations < max_iterations) { - if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance - lostime = current; - } else { - current = current + ms2day * observed.elevation * Math.sqrt(observed.altitude)/502500.0; - observed = _observe(satrec, qth, current); - if (!observed) { - break; - } +import { SatelliteGroundTrack, SatelliteGroundTrackObservation, Position } from "./interfaces"; +import { astronomicalUnit, deg2rad } from "./constants"; +import { TwoLineElement, DateTimeTypes, OrbitMeanElementsMessage, Degrees } from "./types"; +import { footprintRadius, greenwichMeanSiderealTime, parseDateTime, parseSatelliteElements, predictedRevolutionCount } from "./utils"; +import { + degreesLat, + degreesLong, + dopplerFactor, + ecfToLookAngles, + eciToEcf, + eciToGeodetic, + geodeticToEcf, + jday, + propagate, + radiansToDegrees, + radiansLat, + radiansLong, + SatRecError, + shadowFraction, + sunPos, +} from "satellite.js"; + +/** + * Calculates the position, velocity, and ground track location of a satellite + * at a given time. + * @param satelliteElements a TLE or OMM of the satellite's orbital elements + * @param dateTime: an ISO datetime string, unix timestamp, or javascript Date object specifying the observation time + * @param observerPosition: an optional position object specifying the location of a satellite observer + */ +export function observe( + satelliteElements: TwoLineElement | OrbitMeanElementsMessage, + dateTime: DateTimeTypes, + observerPosition?: Position, + minElevationAngle?: Degrees = 0 +): SatelliteGroundTrack | SatelliteGroundTrackObservation { + const vectorMagnitude = (vector: { x: number; y: number; z: number }): number => { + return Math.hypot(vector.x, vector.y, vector.z) + } + + const dt = parseDateTime(dateTime) + const gmst = greenwichMeanSiderealTime(dt) + const [omm, satrec] = parseSatelliteElements(satelliteElements) + + // Returns the satellite position and velocity in ECI coordinations + const satEci = propagate(satrec, dt.toJSDate()) + + // Check for errors + if (satEci === null) { + switch (satrec.error) { + + case SatRecError.MeanEccentricityOutOfRange: + throw new Error('Orbit eccentricity is out of range for SGP4 propagation model') + + case SatRecError.MeanMotionBelowZero: + throw new Error('Orbit mean motion is below zero') + + case SatRecError.PerturbedEccentricityOutOfRange: + throw new Error('Predicted orbit eccentricity is out of range for SGP4 propagation model') + + case SatRecError.SemiLatusRectumBelowZero: + throw new Error('Predicted orbit has collapsed due to severe drag perturbations or numerical instability') + + case SatRecError.Decayed: + return { + id: omm.OBJECT_ID, + name: omm.OBJECT_NAME, + noradCatalogId: omm.NORAD_CAT_ID as string, + orbitalModel: omm.MEAN_ELEMENT_THEORY, + epoch: dt.setZone('UTC').toISO(), + decayed: true, + } } - iterations += 1; - } - return lostime -} - -function _eci(satrec, date) { - date = new Date(date.valueOf()); - return satellite.propagate( - satrec, - date.getUTCFullYear(), - date.getUTCMonth() + 1, // months range 1-12 - date.getUTCDate(), - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds() - ); -} - -function _gmst(date) { - date = new Date(date.valueOf()); - return satellite.gstime( - date.getUTCFullYear(), - date.getUTCMonth() + 1, // months range 1-12 - date.getUTCDate(), - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds() - ); -} - -function _boundLongitude(longitude) { - while (longitude < -180) { - longitude += 360; - } - while (longitude > 180) { - longitude -= 360; - } - return longitude -} - -function _satEclipsed(pos, sol) { - var sd_earth = Math.asin(earth_radius / _magnitude(pos)); - var rho = _vecSub(sol, pos); - var sd_sun = Math.asin(solar_radius / rho.w); - var earth = _scalarMultiply(-1, pos); - var delta = _angle(sol, earth); - - var eclipseDepth = sd_earth - sd_sun - delta; - var eclipse; - if (sd_earth < sd_sun) { - eclipse = false; - } else if (eclipseDepth >= 0) { - eclipse = true; - } else { - eclipse = false; - } - return { - depth: eclipseDepth, - eclipsed: eclipse - } -} - -function _calculateSolarPosition(start) { - var time = start / ms2day + 2444238.5; // jul_utc - var mjd = time - 2415020.0; - var year = 1900 + mjd / 365.25; - var T = (mjd + _deltaET(year) / (ms2day / 1000)) / 36525.0; - var M = deg2rad * ((358.47583 + ((35999.04975 * T) % 360) - (0.000150 + 0.0000033 * T) * Math.pow(T, 2)) % 360); - var L = deg2rad * ((279.69668 + ((36000.76892 * T) % 360) + 0.0003025 * Math.pow(T, 2)) % 360); - var e = 0.01675104 - (0.0000418 + 0.000000126 * T) * T; - var C = deg2rad * ((1.919460 - (0.004789 + 0.000014 * T) * T) * Math.sin(M) + (0.020094 - 0.000100 * T) * Math.sin(2 * M) + 0.000293 * Math.sin(3 * M)); - var O = deg2rad * ((259.18 - 1934.142 * T) % 360.0); - var Lsa = (L + C - deg2rad * (0.00569 - 0.00479 * Math.sin(O))) % (2 * Math.PI); - var nu = (M + C) % (2 * Math.PI); - var R = 1.0000002 * (1 - Math.pow(e, 2)) / (1 + e * Math.cos(nu)); - var eps = deg2rad * (23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * T) * T) * T + 0.00256 * Math.cos(O)); - var R = astro_unit * R; - - return { - x: R * Math.cos(Lsa), - y: R * Math.sin(Lsa) * Math.cos(eps), - z: R * Math.sin(Lsa) * Math.sin(eps), - w: R - } -} - -function _deltaET(year) { - return 26.465 + 0.747622 * (year - 1950) + 1.886913 * Math.sin((2 * Math.PI) * (year - 1975) / 33) -} - -function _vecSub(v1, v2) { - var vec = { - x: v1.x - v2.x, - y: v1.y - v2.y, - z: v1.z - v2.z + throw new Error('Satellite propagation failed') + } + + const positionEcf = eciToEcf(satEci.position, gmst) + const velocityEcf = eciToEcf(satEci.velocity, gmst) + const geodeticPosition = eciToGeodetic(satEci.position, gmst) + const sunEci = sunPos(jday(dt.toJSDate())).rsun + const sunEcf = eciToEcf(sunEci, gmst) + const sunGeodeticPosition = eciToGeodetic(sunEci, gmst) + const eclipseFactor = shadowFraction(sunEci, satEci.position) + const revolutionCount = predictedRevolutionCount(omm, dt) + const minElevationAngleRadians = minElevationAngle * deg2rad + + // Calculate the ground track parameters + const groundTrack: SatelliteGroundTrack = { + id: omm.OBJECT_ID, + name: omm.OBJECT_NAME, + noradCatalogId: omm.NORAD_CAT_ID as string, + orbitalModel: omm.MEAN_ELEMENT_THEORY, + epoch: dt.setZone('UTC').toISO(), + gmst: gmst, + position: { + eci: satEci.position, + ecef: positionEcf, + latitude: degreesLat(geodeticPosition.latitude), + longitude: degreesLong(geodeticPosition.longitude), + altitude: geodeticPosition.height, + }, + velocity: { + eci: satEci.velocity, + ecef: velocityEcf, + }, + footprint: footprintRadius(geodeticPosition.latitude, geodeticPosition.height, minElevationAngleRadians) * 2, + orbit: { + revolutionCount, + phase: ((radiansToDegrees(satEci.meanElements.mm) % 360) + 360) % 360, + velocity: vectorMagnitude(satEci.velocity) * 3600, + }, + decayed: false, + geostationary: false, //ToDo: Determine if the satellite is geostationary + sunlit: eclipseFactor < 1, + sunPosition: { + eci: sunEci, + ecef: sunEcf, + latitude: degreesLat(sunGeodeticPosition.latitude), + longitude: degreesLong(sunGeodeticPosition.longitude), + altitude: sunGeodeticPosition.height, + }, + eclipseFactor: eclipseFactor, + } + + if (!observerPosition) { + return groundTrack + } + + // If we have an observer, calculate the look angles of the satellite + + if (observerPosition.latitude === undefined || observerPosition.longitude === undefined) { + throw new Error('Observer position must include latitude and longitude in degrees') + } + + const normalizedObserverPosition = { + latitude: observerPosition.latitude, + longitude: observerPosition.longitude, + altitude: observerPosition.altitude ?? 0, + } + + const observerGeodetic = { + latitude: radiansLat(normalizedObserverPosition.latitude), + longitude: radiansLong(normalizedObserverPosition.longitude), + height: normalizedObserverPosition.altitude, + } + const observerEcf = geodeticToEcf(observerGeodetic) + const lookAngles = ecfToLookAngles(observerGeodetic, positionEcf) + const sunLookAngles = ecfToLookAngles(observerGeodetic, eciToEcf({ + x: sunEci.x * astronomicalUnit, + y: sunEci.y * astronomicalUnit, + z: sunEci.z * astronomicalUnit, + }, gmst)) + const elevation = radiansToDegrees(lookAngles.elevation) + const hasAos = elevation > 0 + + let visibility = 'visible' + + if (!hasAos) { + visibility = 'below-horizon' + } else if (!isSunlit) { + visibility = 'eclipsed' + } else if (radiansToDegrees(sunLookAngles.elevation) > -6) { + visibility = 'daylight' } - vec.w = _magnitude(vec); - return vec -} -function _scalarMultiply(k, v) { return { - x: k * v.x, - y: k * v.y, - z: k * v.z, - w: v.w ? Math.abs(k) * v.w : undefined - } -} - -function _magnitude(v) { - return Math.sqrt(Math.pow(v.x, 2) + Math.pow(v.y, 2) + Math.pow(v.z, 2)) -} - -function _angle(v1, v2) { - var dot = (v1.x * v2.x + v1.y * v2.y + v1.z * v2.z); - return Math.acos(dot / (_magnitude(v1) * _magnitude(v2))) -} - -// <---------------------------------------------------------------------------> -// PUBLIC FUNCTIONS -// <---------------------------------------------------------------------------> - - - -function observe(tle, qth, start) { - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - - if (_badSat(satrec, qth, start)) { - return null; - } - - return _observe(satrec, qth, start) -} - -function observes(tle, qth, start, end, interval) { - start = m_moment(start); - end = m_moment(end); - - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - - if (_badSat(satrec, qth, start)) { - return null; - } - - var observes = [], observed; - var iterations = 0; - while (start < end && iterations < max_iterations) { - observed = _observe(satrec, qth, start); - if (!observed) { - break; - } - observes.push(observed); - start.add(interval); - iterations += 1; - } - - return observes -} - -function transits(tle, qth, start, end, minElevation, maxTransits) { - start = m_moment(start); - end = m_moment(end); - - if (!minElevation) { - minElevation = defaultMinElevation; + ...groundTrack, + observerPosition: { + ...normalizedObserverPosition, + ecef: observerEcf, + }, + azimuth: radiansToDegrees(lookAngles.azimuth), + elevation, + slantRange: lookAngles.rangeSat, + dopplerFactor: dopplerFactor(observerEcf, positionEcf, velocityEcf), + visibility, + hasAos, } - - if (!maxTransits) { - maxTransits = max_iterations; - } - - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - if (_badSat(satrec, qth, start)) { - return []; - } - - var time = start.valueOf(); - var transits = []; - var nextTransit; - var iterations = 0; - - while (iterations < max_iterations && transits.length < maxTransits) { - transit = _quickPredict(satrec, qth, time); - if (!transit) { - break; - } - if (transit.end > end.valueOf()) { - break; - } - if (transit.end > start.valueOf() && transit.maxElevation > minElevation) { - transits.push(transit); - } - time = transit.end + 60 * 1000; - iterations += 1; - } - - return transits -} - -function transitSegment(tle, qth, start, end) { - start = m_moment(start); - end = m_moment(end); - - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - if (_badSat(satrec, qth, start)) { - return []; - } - - return _quickPredict(satrec, qth, start.valueOf(), end.valueOf()); } - diff --git a/src/interfaces.ts b/src/interfaces.ts index d9dd417..0eb55a1 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,31 +1,126 @@ -import { PositionAndVelocity } from "satellite.js" +import { EcfVec3, EciVec3 } from "satellite.js" +import { Radians, Degrees, Kilometers, KilometersPerHour, KilometersPerSecond } from "./types.ts" -export interface SatelliteObservation { - /** Satellite position and velocity in Earth-Centered Interial (ECI) coordinates at the given GMST */ - eci: PositionAndVelocity, +/** Position parameters */ +export interface Position { + /** Position in Earth-Centered Inertial (ECI) coordinates measured in kilometers */ + eci?: EciVec3 - /** Greenwich Sidereal Time (GMST) */ - gmst: number, + /** Position in Earth-Centered Earth-Fixed (ECEF) coordinates measured in kilometers */ + ecef?: EcfVec3 - /** Satellite latitudinal position in degress */ - latitude: number, + /** Latitudinal position in degress */ + latitude?: Degrees - /** Satellite longitudinal position in degrees */ - longitude: number, + /** Longitudinal position in degrees */ + longitude?: Degrees - /** Satellite altitude in kilometers (km) */ - altitude: number, + /** Position altitude in kilometers (km) */ + altitude?: Kilometers +} - /** Satellite footprint radius in kilometers (km) */ - footprint: number, +/** Velocity parameters */ +export interface Velocity { + /** Velocity vector in Earth-Centered Inertial (ECI) coordinates measured in kilometers per second */ + eci?: EciVec3 - /** Indicates if the satellite is in eclipse */ - sunlit: boolean, + /** Velocity vector in Earth-Centered Earth-Fixed (ECEF) coordinates measured in kilometers per second */ + ecef?: EcfVec3 +} + +/** Orbit parameters */ +export interface Orbit { + /** The number of revolutions the satellite has completed in its orbit */ + revolutionCount: number - /** Eclipse factor (1.0 full sunlight, 0.0 total darkness)*/ - eclipseDepth: number + /** The current position of the satellite in its orbit relative to its perigee/ascending node, measured in degrees */ + phase?: Degrees + + /** Satellite velocity relative to the center of the Earth in kilometers per hour (kph) */ + velocity?: KilometersPerHour } -export interface ObserverSatelliteObservation extends SatelliteObservation { +/** + * Satellite ground track parameters + */ +export interface SatelliteGroundTrack { + /** Satellite international designator */ + id: string + + /** Satellite name */ + name: string + + /** NORAD Satellite Catalog Number */ + noradCatalogId: string | number + + /** Prediction UTC timestamp (ISO8601 format) */ + epoch: string | null -} \ No newline at end of file + /** Prediction Greenwich Sidereal Time (GMST) */ + gmst?: Radians + + /** Satellite position coordinates at the specified epoch */ + position?: Position + + /** Satellite velocity vectors at the specified epoch */ + velocity?: Velocity + + /** The diameter of the satellite's ground coverage area (the visible circle on Earth's surface) in kilometers */ + footprint?: Kilometers + + /** Orbit revolution count */ + orbit?: Orbit; + + /** Specifies the orbital model used to predict the satellite's position and velocity */ + orbitalModel?: string + + /** Indicates if the satellite's orbit has decayed and re-entered Earth's atmosphere at the given time */ + decayed: boolean + + /** Indicates if the satellite is geostationary relative to Earth's surface */ + geostationary?: boolean + + /** Indicates if the satellite is in eclipse */ + sunlit?: boolean + + /** Position of the sun */ + sunPosition?: Position + + /** + * The angle between the satellite's orbital plane and the vector pointing directly to the Sun, measured in degrees. + * Used to determine thermal exposure and eclipse duration. + */ + betaAngle?: Degrees + + /** + * The fraction of the Sun’s disc obscured by the Earth as seen from a satellite. + * 0 = fully lit, 1 = umbra, values between 0 and 1 indicate the fraction of the Sun covered by Earth. + */ + eclipseFactor?: number +} + +/** + * Satellite ground observation parameters + * */ +export interface SatelliteGroundTrackObservation extends SatelliteGroundTrack { + /** Satellite observer's position */ + observerPosition: Position + + /** The compass heading to the satellite from the observer's ground location in degrees */ + azimuth: Degrees; + + /** The angle of the satellite above (or below) the observer's horizon in degrees. */ + elevation: Degrees; + + /** The direct line-of-sight distance from the observer to the satellite, measured in kilometers. */ + slantRange: Kilometers; + + /** Satellite frequency shift (i.e doppler factor) relative to observer. */ + dopplerFactor: number; + + /** Indicates if the satellite is optically visible at the observer's location */ + visibility: string + + /** Indicates if the satellite is above the observer's horizon */ + hasAos: boolean +} diff --git a/src/legacy.ts b/src/legacy.ts new file mode 100644 index 0000000..df96508 --- /dev/null +++ b/src/legacy.ts @@ -0,0 +1,456 @@ +// jspredict v2.0.0 +// https://github.com/nsat/jspredict + +// Copyright (c) 2026, Spire Global Inc +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// * Neither the name of the Spire Global Inc nor the +// names of its contributors may be used to endorse or promote products +// derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// Spire Global Inc BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +// USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +// OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +// SUCH DAMAGE. + + + +// <---------------------------------------------------------------------------> +// CONSTANTS +// <---------------------------------------------------------------------------> + +const max_iterations = 250; +const defaultMinElevation = 4; // degrees + + + + +// <---------------------------------------------------------------------------> +// PRIVATE FUNCTIONS +// <---------------------------------------------------------------------------> + +function _observe(satrec, qth, start) { + start = m_moment(start); + + var eci = _eci(satrec, start); + var gmst = _gmst(start); + + if (!eci.position) { + return null; + } + + var geo = satellite.eciToGeodetic(eci.position, gmst); + var solar_vector = _calculateSolarPosition(start.valueOf()); + var eclipse = _satEclipsed(eci.position, solar_vector); + + var track = { + eci: eci, + gmst: gmst, + latitude: geo.latitude / deg2rad, + longitude: _boundLongitude(geo.longitude / deg2rad), + altitude: geo.height, + footprint: 12756.33 * Math.acos(earth_radius / (earth_radius + geo.height)), + sunlit: !eclipse.eclipsed, + eclipseDepth: eclipse.depth / deg2rad + } + + // If we have a groundstation let's get those additional observe parameters + if (qth && qth.length == 3) { + var observerGd = { + longitude: qth[1] * deg2rad, + latitude: qth[0] * deg2rad, + height: qth[2] + } + + var positionEcf = satellite.eciToEcf(eci.position, gmst), + velocityEcf = satellite.eciToEcf(eci.velocity, gmst), + observerEcf = satellite.geodeticToEcf(observerGd), + lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf), + doppler = satellite.dopplerFactor(observerEcf, positionEcf, velocityEcf); + + track.azimuth = lookAngles.azimuth / deg2rad; + track.elevation = lookAngles.elevation / deg2rad; + track.rangeSat = lookAngles.rangeSat; + track.doppler = doppler; + } + + return track +} + + + +function _quickPredict(satrec, qth, start, end) { + var transit = {}; + var lastel = 0; + var iterations = 0; + + if (_badSat(satrec, qth, start)) { + return null; + } + + var daynum = _findAOS(satrec, qth, start); + if (!daynum) { + return null; + } + transit.start = daynum; + + var observed = _observe(satrec, qth, daynum); + if (!observed) { + return null; + } + + var iel = Math.round(observed.elevation); + + var maxEl = 0, apexAz = 0, minAz = 360, maxAz = 0; + + while (iel >= 0 && iterations < max_iterations && (!end || daynum < end)) { + lastel = iel; + daynum = daynum + ms2day * Math.cos((observed.elevation-1.0)*deg2rad)*Math.sqrt(observed.altitude)/25000.0; + observed = _observe(satrec, qth, daynum); + iel = Math.round(observed.elevation); + if (maxEl < observed.elevation) { + maxEl = observed.elevation; + apexAz = observed.azimuth; + } + maxAz = Math.max(maxAz, observed.azimuth); + minAz = Math.min(minAz, observed.azimuth); + iterations += 1; + } + if (lastel !== 0) { + daynum = _findLOS(satrec, qth, daynum); + } + + transit.end = daynum; + transit.maxElevation = maxEl; + transit.apexAzimuth = apexAz; + transit.maxAzimuth = maxAz; + transit.minAzimuth = minAz; + transit.duration = transit.end - transit.start; + + return transit +} + +function _badSat(satrec, qth, start) { + if (qth && !_aosHappens(satrec, qth)) { + return true + } else if (start && _decayed(satrec, start)) { + return true + } else { + return false + } +} + +function _aosHappens(satrec, qth) { + var lin, sma, apogee; + var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day + if (meanmo === 0) { + return false + } else { + lin = satrec.inclo / deg2rad; + + if (lin >= 90.0) { + lin = 180.0 - lin; + } + + sma = 331.25 * Math.exp(Math.log(1440.0/meanmo)*(2.0/3.0)); + apogee = sma * (1.0 + satrec.ecco) - earth_radius; + + if ((Math.acos(earth_radius/(apogee+earth_radius))+(lin*deg2rad)) > Math.abs(qth[0]*deg2rad)) { + return true + } else { + return false + } + } +} + +function _decayed(satrec, start) { + start = m_moment(start); + + var satepoch = m_moment.utc(satrec.epochyr, "YY").add(satrec.epochdays, 'days').valueOf(); + + var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day + var drag = satrec.ndot * 24 * 60 * 24 * 60 / (2 * Math.PI); // convert rev/day^2 + + if (satepoch + ms2day * ((16.666666-meanmo)/(10.0*Math.abs(drag))) < start) { + return true + } else { + return false + } +} + +function _findAOS(satrec, qth, start) { + var current = start; + var observed = _observe(satrec, qth, current); + if (!observed) { + return null; + } + var aostime = 0; + var iterations = 0; + + if (observed.elevation > 0) { + return current + } + while (observed.elevation < -1 && iterations < max_iterations) { + current = current - ms2day * 0.00035*(observed.elevation*((observed.altitude/8400.0)+0.46)-2.0); + observed = _observe(satrec, qth, current); + if (!observed) { + break; + } + iterations += 1; + } + iterations = 0; + while (aostime === 0 && iterations < max_iterations) { + if (!observed) { + break; + } + if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance + aostime = current; + } else { + current = current - ms2day * observed.elevation * Math.sqrt(observed.altitude)/530000.0; + observed = _observe(satrec, qth, current); + } + iterations += 1; + } + if (aostime === 0) { + return null; + } + return aostime +} + +function _findLOS(satrec, qth, start) { + var current = start; + var observed = _observe(satrec, qth, current); + var lostime = 0; + var iterations = 0; + + while (lostime === 0 && iterations < max_iterations) { + if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance + lostime = current; + } else { + current = current + ms2day * observed.elevation * Math.sqrt(observed.altitude)/502500.0; + observed = _observe(satrec, qth, current); + if (!observed) { + break; + } + } + iterations += 1; + } + return lostime +} + +function _eci(satrec, date) { + date = new Date(date.valueOf()); + return satellite.propagate( + satrec, + date.getUTCFullYear(), + date.getUTCMonth() + 1, // months range 1-12 + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds() + ); +} + + + +function _boundLongitude(longitude) { + while (longitude < -180) { + longitude += 360; + } + while (longitude > 180) { + longitude -= 360; + } + return longitude +} + +function _satEclipsed(pos, sol) { + var sd_earth = Math.asin(earth_radius / _magnitude(pos)); + var rho = _vecSub(sol, pos); + var sd_sun = Math.asin(solar_radius / rho.w); + var earth = _scalarMultiply(-1, pos); + var delta = _angle(sol, earth); + + var eclipseDepth = sd_earth - sd_sun - delta; + var eclipse; + if (sd_earth < sd_sun) { + eclipse = false; + } else if (eclipseDepth >= 0) { + eclipse = true; + } else { + eclipse = false; + } + return { + depth: eclipseDepth, + eclipsed: eclipse + } +} + +function _calculateSolarPosition(start) { + var time = start / ms2day + 2444238.5; // jul_utc + + var mjd = time - 2415020.0; + var year = 1900 + mjd / 365.25; + var T = (mjd + _deltaET(year) / (ms2day / 1000)) / 36525.0; + var M = deg2rad * ((358.47583 + ((35999.04975 * T) % 360) - (0.000150 + 0.0000033 * T) * Math.pow(T, 2)) % 360); + var L = deg2rad * ((279.69668 + ((36000.76892 * T) % 360) + 0.0003025 * Math.pow(T, 2)) % 360); + var e = 0.01675104 - (0.0000418 + 0.000000126 * T) * T; + var C = deg2rad * ((1.919460 - (0.004789 + 0.000014 * T) * T) * Math.sin(M) + (0.020094 - 0.000100 * T) * Math.sin(2 * M) + 0.000293 * Math.sin(3 * M)); + var O = deg2rad * ((259.18 - 1934.142 * T) % 360.0); + var Lsa = (L + C - deg2rad * (0.00569 - 0.00479 * Math.sin(O))) % (2 * Math.PI); + var nu = (M + C) % (2 * Math.PI); + var R = 1.0000002 * (1 - Math.pow(e, 2)) / (1 + e * Math.cos(nu)); + var eps = deg2rad * (23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * T) * T) * T + 0.00256 * Math.cos(O)); + var R = astro_unit * R; + + return { + x: R * Math.cos(Lsa), + y: R * Math.sin(Lsa) * Math.cos(eps), + z: R * Math.sin(Lsa) * Math.sin(eps), + w: R + } +} + +function _deltaET(year) { + return 26.465 + 0.747622 * (year - 1950) + 1.886913 * Math.sin((2 * Math.PI) * (year - 1975) / 33) +} + +function _vecSub(v1, v2) { + var vec = { + x: v1.x - v2.x, + y: v1.y - v2.y, + z: v1.z - v2.z + } + vec.w = _magnitude(vec); + return vec +} + +function _scalarMultiply(k, v) { + return { + x: k * v.x, + y: k * v.y, + z: k * v.z, + w: v.w ? Math.abs(k) * v.w : undefined + } +} + +function _magnitude(v) { + return Math.sqrt(Math.pow(v.x, 2) + Math.pow(v.y, 2) + Math.pow(v.z, 2)) +} + +function _angle(v1, v2) { + var dot = (v1.x * v2.x + v1.y * v2.y + v1.z * v2.z); + return Math.acos(dot / (_magnitude(v1) * _magnitude(v2))) +} + +// <---------------------------------------------------------------------------> +// PUBLIC FUNCTIONS +// <---------------------------------------------------------------------------> + + + +function observe(tle, qth, start) { + var tles = tle.split('\n'); + var satrec = satellite.twoline2satrec(tles[1], tles[2]); + + if (_badSat(satrec, qth, start)) { + return null; + } + + return _observe(satrec, qth, start) +} + +function observes(tle, qth, start, end, interval) { + start = m_moment(start); + end = m_moment(end); + + var tles = tle.split('\n'); + var satrec = satellite.twoline2satrec(tles[1], tles[2]); + + if (_badSat(satrec, qth, start)) { + return null; + } + + var observes = [], observed; + var iterations = 0; + while (start < end && iterations < max_iterations) { + observed = _observe(satrec, qth, start); + if (!observed) { + break; + } + observes.push(observed); + start.add(interval); + iterations += 1; + } + + return observes +} + +function transits(tle, qth, start, end, minElevation, maxTransits) { + start = m_moment(start); + end = m_moment(end); + + if (!minElevation) { + minElevation = defaultMinElevation; + } + + if (!maxTransits) { + maxTransits = max_iterations; + } + + var tles = tle.split('\n'); + var satrec = satellite.twoline2satrec(tles[1], tles[2]); + if (_badSat(satrec, qth, start)) { + return []; + } + + var time = start.valueOf(); + var transits = []; + var nextTransit; + var iterations = 0; + + while (iterations < max_iterations && transits.length < maxTransits) { + transit = _quickPredict(satrec, qth, time); + if (!transit) { + break; + } + if (transit.end > end.valueOf()) { + break; + } + if (transit.end > start.valueOf() && transit.maxElevation > minElevation) { + transits.push(transit); + } + time = transit.end + 60 * 1000; + iterations += 1; + } + + return transits +} + +function transitSegment(tle, qth, start, end) { + start = m_moment(start); + end = m_moment(end); + + var tles = tle.split('\n'); + var satrec = satellite.twoline2satrec(tles[1], tles[2]); + if (_badSat(satrec, qth, start)) { + return []; + } + + return _quickPredict(satrec, qth, start.valueOf(), end.valueOf()); +} + diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..352bc04 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,29 @@ +import { DateTime } from "luxon"; +import { OMMJsonObjectV3 } from "satellite.js"; + +/** Kilometers */ +export type Kilometers = number; + +/** Kilometers per second */ +export type KilometersPerSecond = number; + +/** Kilometers per hour */ +export type KilometersPerHour = number; + +/** Radians */ +export type Radians = number; + +/** Degrees */ +export type Degrees = number; + +/** Astonomical Unit (AU) */ +export type AstronomialUnits = number; + +/** Two Line Element */ +export type TwoLineElement = string; + +/** Orbit Mean-Elements Message */ +export type OrbitMeanElementsMessage = OMMJsonObjectV3; + +/** Alias for various datetime types */ +export type DateTimeTypes = string | number | Date | DateTime diff --git a/src/utils.ts b/src/utils.ts index 207a82d..4aa33a2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,9 @@ -import type { radians, km } from "./common.ts"; -import { WGS84 } from "./common.ts"; +import type { Radians, Kilometers, DateTimeTypes } from "./types.ts"; +import { WGS84, day2ms } from "./constants.ts"; +import { DateTime } from "luxon"; +import { gstime, SatRec, json2satrec } from "satellite.js"; +import { TwoLineElement, OrbitMeanElementsMessage } from "./types.ts"; + // <---------------------------------------------------------------------------> // UTILITY FUNCTIONS @@ -10,7 +14,7 @@ import { WGS84 } from "./common.ts"; * based on a WGS84 ellipsoid. * @param latitude sub-satellite point (SSP) latitude in radians. */ -export function localEarthRadius(latitude: radians): km { +export function localEarthRadius(latitude: Radians): Kilometers { const re = WGS84.a / Math.sqrt((1 - WGS84.e2 * Math.pow(Math.sin(latitude), 2))); return re; } @@ -19,10 +23,10 @@ export function localEarthRadius(latitude: radians): km { * Calculate the Earth central angle for a given radius and altitude. * @param re earth radius * @param altitude satellite altitude (kilometers) - * @param epsilon minimum elevation angle (radians) + * @param minElevationAngle minimum elevation angle (radians) */ -export function earthCentralAngle(re: km, altitude: km, epsilon: radians = 0.0): radians { - const lambda = Math.acos((re / (re + altitude)) * Math.cos(epsilon)) - epsilon +export function earthCentralAngle(re: Kilometers, altitude: Kilometers, minElevationAngle: Radians = 0.0): Radians { + const lambda = Math.acos((re / (re + altitude)) * Math.cos(minElevationAngle)) - minElevationAngle return lambda } @@ -30,11 +34,177 @@ export function earthCentralAngle(re: km, altitude: km, epsilon: radians = 0.0): * Calculate the satellite's footprint radius. * @param lattitude sub-satellite point (SSP) latitude in radians * @param altitude satellite altitude (kilometers) - * @param epsilon minimum elevation angle (radians) + * @param minElevationAngle minimum elevation angle (radians) */ - export function footprintRadius(latitude: radians, altitude: km, epsilon: radians = 0.0): km { + export function footprintRadius(latitude: Radians, altitude: Kilometers, minElevationAngle: Radians = 0.0): Kilometers { const re = localEarthRadius(latitude); - const lambda = earthCentralAngle(re, altitude, epsilon) + const lambda = earthCentralAngle(re, altitude, minElevationAngle) const footprint = re * lambda return footprint - } \ No newline at end of file + } + + /** + * Convert the datetime string, unix timestamp (ms), or Date object to a luxon.DateTime object. + */ +export function parseDateTime(dateTime: DateTimeTypes): DateTime { + // 1. Check if object is already a luxon.DateTime + if (dateTime instanceof DateTime) { + return dateTime + } + + // 2. Check for native Date object + if (dateTime instanceof Date) { + return DateTime.fromJSDate(dateTime); + } + + // 3. Check for number (timestamp) + if (typeof dateTime === 'number') { + return DateTime.fromMillis(dateTime, { zone: "UTC"}); + } + + // 4. Fallback to string (ISO or standard format) + if (typeof dateTime === 'string') { + const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(dateTime) + + // Assume UTC only when the string does not already include timezone data. + return hasExplicitTimezone + ? DateTime.fromISO(dateTime, { setZone: true }) + : DateTime.fromISO(dateTime, { zone: "UTC"}) + } + + throw new Error('Unsupported datetime type'); +} + +/** + * Calculates the Greenwich Mean Sidereal Time (GMST) from a luxon.DateTime object measured in radians + */ +export function greenwichMeanSiderealTime(dateTime: DateTime): Radians { + return gstime(dateTime.toJSDate()) +} + +/** + * Predict the completed orbit revolution count at a given observation time. + */ +export function predictedRevolutionCount( + orbitMeanElementsMessage: OrbitMeanElementsMessage, + observationTime: DateTime, +): number { + const elementEpoch = parseDateTime(orbitMeanElementsMessage.EPOCH) + + return Math.floor( + Number(orbitMeanElementsMessage.REV_AT_EPOCH ?? 0) + + ((observationTime.toMillis() - elementEpoch.toMillis()) / day2ms) * Number(orbitMeanElementsMessage.MEAN_MOTION), + ) +} + +/** + * Converts a Two Line Element (TLE) string into a Orbit Mean Elemenets Message (OMM) + * @param twoLineElement a two line element string + * @returns OrbitMeanElementsMessage + */ +export function convertTleToOmm(tle: TwoLineElement): OrbitMeanElementsMessage { + const lines = tle + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + + if (lines.length !== 2 && lines.length !== 3) { + throw new Error('TLE must contain either 2 or 3 non-empty lines') + } + + const [nameLine, line1, line2] = lines.length === 3 ? lines : ['', lines[0], lines[1]] + + if (!line1.startsWith('1 ') || !line2.startsWith('2 ')) { + throw new Error('Invalid TLE format') + } + + const hasValidChecksum = (line: string): boolean => { + if (line.length < 69) { + return false + } + + const checksum = Number(line.substring(68, 69)) + + if (Number.isNaN(checksum)) { + return false + } + + let sum = 0 + + for (const char of line.substring(0, 68)) { + if (char >= '0' && char <= '9') { + sum += Number(char) + } else if (char === '-') { + sum += 1 + } + } + + return sum % 10 === checksum + } + + if (!hasValidChecksum(line1) || !hasValidChecksum(line2)) { + throw new Error('Invalid TLE checksum') + } + + const parseTleExponent = (value: string): number => { + const sign = value.substring(0, 1).trim() || '' + const mantissa = value.substring(1, 6).trim() || '0' + const exponent = value.substring(6, 8).trim() || '0' + + return Number(`${sign}0.${mantissa}e${exponent}`) + } + + const epochYear = Number(line1.substring(18, 20)) + const epochDay = Number(line1.substring(20, 32)) + const fullYear = epochYear < 57 ? epochYear + 2000 : epochYear + 1900 + const compactObjectId = line1.substring(9, 17).trim() + const objectIdMatch = compactObjectId.match(/^(\d{2})(\d{3})([A-Z0-9]+)?$/) + const objectId = objectIdMatch + ? `${Number(objectIdMatch[1]) < 57 ? 2000 + Number(objectIdMatch[1]) : 1900 + Number(objectIdMatch[1])}-${objectIdMatch[2]}${objectIdMatch[3] ?? ''}` + : compactObjectId + const objectName = nameLine.replace(/^0\s+/, '') || objectId + + return { + CCSDS_OMM_VERS: '3.0', + CENTER_NAME: 'EARTH', + REF_FRAME: 'TEME', + TIME_SYSTEM: 'UTC', + MEAN_ELEMENT_THEORY: 'SGP4', + OBJECT_NAME: objectName, + OBJECT_ID: objectId, + CLASSIFICATION_TYPE: line1.substring(7, 8).trim() as 'U' | 'C', + NORAD_CAT_ID: line1.substring(2, 7), + EPOCH: DateTime.utc(fullYear, 1, 1) + .plus({ milliseconds: (epochDay - 1) * day2ms }) + .toISO({ includeOffset: true, suppressMilliseconds: false }) ?? '', + MEAN_MOTION: Number(line2.substring(52, 63)), + ECCENTRICITY: Number(`0.${line2.substring(26, 33).replace(/\s/g, '0')}`), + INCLINATION: Number(line2.substring(8, 16)), + RA_OF_ASC_NODE: Number(line2.substring(17, 25)), + ARG_OF_PERICENTER: Number(line2.substring(34, 42)), + MEAN_ANOMALY: Number(line2.substring(43, 51)), + EPHEMERIS_TYPE: 0, + ELEMENT_SET_NO: Number(line1.substring(64, 68)), + REV_AT_EPOCH: Number(line2.substring(63, 68)), + BSTAR: parseTleExponent(line1.substring(53, 61)), + MEAN_MOTION_DOT: Number(line1.substring(33, 43)), + MEAN_MOTION_DDOT: parseTleExponent(line1.substring(44, 52)), + } +} + +/** + * Parses either a TLE string or OMM object and returns a satellite.js satrec object + * @param satelliteElements a Two Line Element (TLE) string or Orbit Mean Elements Message (OMM) object + * @returns SatRec + */ +export function parseSatelliteElements(satelliteElements: TwoLineElement | OrbitMeanElementsMessage): [OrbitMeanElementsMessage, SatRec] { + let omm: OrbitMeanElementsMessage; + + if (typeof satelliteElements === 'string') { + omm = convertTleToOmm(satelliteElements); + } else { + omm = satelliteElements; + } + + return [omm, json2satrec(omm)] +} From 957eecf70dfa0a27d593541637bba18e44036729 Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Mon, 10 Aug 2026 16:44:55 -0600 Subject: [PATCH 03/14] Fixing observe --- .gitignore | 3 + src/__tests__/index.test.ts | 54 ++++---- src/__tests__/utils.test.ts | 200 ++++++++++++++++++++++++++- src/constants.ts | 19 +++ src/index.ts | 178 +++++++++++------------- src/interfaces.ts | 41 +++--- src/utils.ts | 260 ++++++++++++++++++++++++++++++++++-- vite.config.ts | 2 +- 8 files changed, 584 insertions(+), 173 deletions(-) diff --git a/.gitignore b/.gitignore index 42a76a7..0ae649d 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ vite.config.ts.timestamp-* # Mac Stuff .DS_Store +pypredict/ +_pypredict/ +compare/ diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index da728e5..cd87b35 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from 'vitest' import { - degreesLat, - degreesLong, dopplerFactor, ecfToLookAngles, eciToEcf, @@ -10,13 +8,14 @@ import { geodeticToEcf, gstime, json2satrec, + OMMJsonObjectV3, propagate, radiansLat, radiansLong, } from 'satellite.js' -import { observe } from '../index' -import { convertTleToOmm, footprintRadius } from '../utils' +import { observe, AngularUnits } from '../index' +import { convertTleToOmm, footprintDiameter } from '../utils' // <---------------------------------------------------------------------------> // TEST RESOURCES @@ -78,7 +77,7 @@ const laterObservationEpoch = '2026-08-08T00:30:49.879296Z' describe('observe', () => { test('returns a ground track with position units converted from satellite.js', () => { - const observed = observe(issTle, observationEpoch) + const observed = observe(issTle, observationEpoch, undefined, 0, AngularUnits.Radians) const date = new Date(observationEpoch) const satrec = json2satrec(convertTleToOmm(issTle)) const propagated = propagate(satrec, date) @@ -98,23 +97,33 @@ describe('observe', () => { expect(observed.decayed).toBe(false) expect(observed.position?.eci?.x).toBeCloseTo(propagated.position.x, 10) expect(observed.position?.ecef?.x).toBeCloseTo(ecef.x, 10) - expect(observed.position?.latitude).toBeCloseTo(degreesLat(geodetic.latitude), 10) - expect(observed.position?.longitude).toBeCloseTo(degreesLong(geodetic.longitude), 10) - expect(observed.position?.altitude).toBeCloseTo(geodetic.height, 10) - expect(observed.footprint).toBeCloseTo(footprintRadius(geodetic.latitude, geodetic.height) * 2, 10) + expect(observed.position?.geodetic?.latitude).toBeCloseTo(geodetic.latitude, 10) + expect(observed.position?.geodetic?.longitude).toBeCloseTo(geodetic.longitude, 10) + expect(observed.position?.geodetic?.height).toBeCloseTo(geodetic.height, 10) + expect(observed.footprint).toBeCloseTo(footprintDiameter({ geodetic }, 0), 10) expect(observed.velocity?.eci?.x).toBeCloseTo(propagated.velocity.x, 10) expect(observed.velocity?.ecef?.x).toBeCloseTo(eciToEcf(propagated.velocity, gmst).x, 10) expect(observed.orbit?.velocity).toBeCloseTo( - Math.hypot(propagated.velocity.x, propagated.velocity.y, propagated.velocity.z) * 3600, + Math.hypot(propagated.velocity.x, propagated.velocity.y, propagated.velocity.z), 10, ) }) - test('returns observer look angles in degrees and slant range in kilometers', () => { - const observer = { latitude: 15, longitude: 130, altitude: 0.1 } - const observed = observe(issOmm, observationEpoch, observer) + test('returns observer look angles in radians and slant range in kilometers', () => { + const observerGeodetic = { + latitude: radiansLat(15), + longitude: radiansLong(130), + height: 0.1, + } + const observed = observe( + issOmm as OMMJsonObjectV3, + observationEpoch, + { geodetic: observerGeodetic }, + 0, + AngularUnits.Radians, + ) const date = new Date(observationEpoch) - const satrec = json2satrec(issOmm) + const satrec = json2satrec(issOmm as OMMJsonObjectV3) const propagated = propagate(satrec, date) if (!propagated || !('observerPosition' in observed)) { @@ -122,28 +131,21 @@ describe('observe', () => { } const gmst = gstime(date) - const observerGeodetic = { - latitude: radiansLat(observer.latitude), - longitude: radiansLong(observer.longitude), - height: observer.altitude, - } const positionEcf = eciToEcf(propagated.position, gmst) const velocityEcf = eciToEcf(propagated.velocity, gmst) const observerEcf = geodeticToEcf(observerGeodetic) const lookAngles = ecfToLookAngles(observerGeodetic, positionEcf) - expect(observed.observerPosition.ecef?.x).toBeCloseTo(observerEcf.x, 10) - expect(observed.azimuth).toBeCloseTo((lookAngles.azimuth * 180) / Math.PI, 10) - expect(observed.elevation).toBeCloseTo((lookAngles.elevation * 180) / Math.PI, 10) + expect(observed.observerPosition!.ecef!.x).toBeCloseTo(observerEcf.x, 10) + expect(observed.azimuth).toBeCloseTo(lookAngles.azimuth, 10) + expect(observed.elevation).toBeCloseTo(lookAngles.elevation, 10) expect(observed.slantRange).toBeCloseTo(lookAngles.rangeSat, 10) expect(observed.dopplerFactor).toBeCloseTo(dopplerFactor(observerEcf, positionEcf, velocityEcf), 12) - expect(observed.hasAos).toBe(false) - expect(observed.visibility).toBe('below-horizon') }) test('predicts revolution count from the observation time', () => { - const observed = observe(issOmm, laterObservationEpoch) + const observed = observe(issOmm as OMMJsonObjectV3, laterObservationEpoch) - expect(observed.orbit?.revolutionCount).toBe(57978) + expect(observed.orbit?.revolutionCount).toBe(57979) }) }) diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 0c22b9a..40fea35 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,12 +1,16 @@ import { DateTime } from 'luxon' import { describe, expect, test } from 'vitest' +import { ecfToEci, eciToEcf, eciToGeodetic, geodeticToEcf } from 'satellite.js' import { WGS84, deg2rad } from '../constants' +import { AngularUnits } from '../index' import { convertTleToOmm, earthCentralAngle, - footprintRadius, + footprintDiameter, greenwichMeanSiderealTime, + inferPosition, + inferVelocity, localEarthRadius, parseDateTime, predictedRevolutionCount, @@ -27,6 +31,18 @@ const invalidChecksumTle = `0 LEMUR-2 JEROEN 1 40934U 15052E 15306.10048119 .00001740 00000-0 15647-3 0 9991 2 40934 6.0033 141.2190 0010344 133.6141 226.4604 14.76056230 5130` +// A consistent set of coordinates across every frame for a single point, used to +// exercise inferPosition. Geodetic values feed satellite.js so all frames agree. +const inferGmst = 2.637779678153912 +const inferGeodetic = { longitude: 1.0, latitude: 0.5, height: 550 } +const inferEcef = geodeticToEcf(inferGeodetic) +const inferEci = ecfToEci(inferEcef, inferGmst) + +// A velocity vector expressed in ECI, used to exercise inferVelocity. Its ECEF +// counterpart is derived through satellite.js so both frames stay consistent. +const inferVelocityEci = { x: -5350.5797324924, y: -2896.5995459166475, z: 3303.394953045869 } +const inferVelocityEcef = eciToEcf(inferVelocityEci, inferGmst) + // <---------------------------------------------------------------------------> // TESTS // <---------------------------------------------------------------------------> @@ -63,9 +79,11 @@ describe('utils.earthCentralAngle', () => { }) }) -describe('utils.footprintRadius', () => { +describe('utils.footprintDiameter', () => { test('returns zero when altitude is zero', () => { - expect(footprintRadius(0, 0)).toBeCloseTo(0, 12) + const position = { geodetic: { latitude: 0, longitude: 0, height: 0 } } + + expect(footprintDiameter(position, 0)).toBeCloseTo(0, 12) }) test('matches the local radius multiplied by the central angle', () => { @@ -74,9 +92,11 @@ describe('utils.footprintRadius', () => { const epsilon = 5 * deg2rad const expected = localEarthRadius(latitude) * - earthCentralAngle(localEarthRadius(latitude), altitude, epsilon) + earthCentralAngle(localEarthRadius(latitude), altitude, epsilon) * + 2 + const position = { geodetic: { latitude, longitude: 0, height: altitude } } - expect(footprintRadius(latitude, altitude, epsilon)).toBeCloseTo(expected, 10) + expect(footprintDiameter(position, epsilon)).toBeCloseTo(expected, 10) }) }) @@ -157,7 +177,7 @@ describe('utils.predictedRevolutionCount', () => { 1 25544U 98067A 26219.02141064 .00004539 00000-0 89363-4 0 9992 2 25544 51.6324 48.5171 0007293 20.5996 339.5285 15.49370096579630`) - expect(predictedRevolutionCount(omm, DateTime.fromISO('2026-08-08T00:30:49.879296Z'))).toBe(57978) + expect(predictedRevolutionCount(omm, DateTime.fromISO('2026-08-08T00:30:49.879296Z'))).toBe(57979) }) }) @@ -195,3 +215,171 @@ describe('utils.convertTleToOmm', () => { expect(() => convertTleToOmm(invalidChecksumTle)).toThrow('Invalid TLE checksum') }) }) + +describe('utils.inferPosition', () => { + const expectVec3CloseTo = ( + actual: { x: number; y: number; z: number } | undefined, + expected: { x: number; y: number; z: number }, + ) => { + expect(actual).toBeDefined() + expect(actual!.x).toBeCloseTo(expected.x, 9) + expect(actual!.y).toBeCloseTo(expected.y, 9) + expect(actual!.z).toBeCloseTo(expected.z, 9) + } + + const expectGeodeticCloseTo = (position: { + geodetic?: { latitude: number; longitude: number; height: number } + }) => { + const geodetic = eciToGeodetic(inferEci, inferGmst) + + expect(position.geodetic).toBeDefined() + expect(position.geodetic!.latitude).toBeCloseTo(geodetic.latitude, 9) + expect(position.geodetic!.longitude).toBeCloseTo(geodetic.longitude, 9) + expect(position.geodetic!.height).toBeCloseTo(geodetic.height, 6) + } + + test('infers ECI from ECEF and geodetic coordinates', () => { + const result = inferPosition( + { + ecef: inferEcef, + geodetic: inferGeodetic, + }, + inferGmst, + AngularUnits.Radians, + ) + + expectVec3CloseTo(result.eci, ecfToEci(inferEcef, inferGmst)) + expectVec3CloseTo(result.ecef, inferEcef) + expect(result.geodetic).toBe(inferGeodetic) + }) + + test('infers ECEF and ECI from geodetic coordinates only', () => { + const result = inferPosition({ geodetic: inferGeodetic }, inferGmst, AngularUnits.Radians) + + expectVec3CloseTo(result.ecef, inferEcef) + expectVec3CloseTo(result.eci, inferEci) + expect(result.geodetic).toBe(inferGeodetic) + }) + + test('infers ECI and geodetic from ECEF coordinates only', () => { + const result = inferPosition({ ecef: inferEcef }, inferGmst, AngularUnits.Radians) + + expectVec3CloseTo(result.eci, ecfToEci(inferEcef, inferGmst)) + expectVec3CloseTo(result.ecef, inferEcef) + expectGeodeticCloseTo(result) + }) + + test('infers ECEF from ECI and geodetic coordinates', () => { + const result = inferPosition( + { + eci: inferEci, + geodetic: inferGeodetic, + }, + inferGmst, + AngularUnits.Radians, + ) + + expectVec3CloseTo(result.ecef, eciToEcf(inferEci, inferGmst)) + expectVec3CloseTo(result.eci, inferEci) + expect(result.geodetic).toBe(inferGeodetic) + }) + + test('infers ECEF and geodetic from ECI coordinates only', () => { + const result = inferPosition({ eci: inferEci }, inferGmst, AngularUnits.Radians) + + expectVec3CloseTo(result.ecef, eciToEcf(inferEci, inferGmst)) + expectVec3CloseTo(result.eci, inferEci) + expectGeodeticCloseTo(result) + }) + + test('infers geodetic from ECI and ECEF coordinates', () => { + const result = inferPosition({ eci: inferEci, ecef: inferEcef }, inferGmst, AngularUnits.Radians) + + expectVec3CloseTo(result.eci, inferEci) + expectVec3CloseTo(result.ecef, inferEcef) + expectGeodeticCloseTo(result) + }) + + test('returns fully populated coordinates unchanged when all frames are provided', () => { + const result = inferPosition( + { + eci: inferEci, + ecef: inferEcef, + geodetic: inferGeodetic, + }, + inferGmst, + AngularUnits.Radians, + ) + + expect(result.eci).toBe(inferEci) + expect(result.ecef).toBe(inferEcef) + expect(result.geodetic).toBe(inferGeodetic) + }) + + test('round-trips consistently regardless of which frame is provided', () => { + const fromEci = inferPosition({ eci: inferEci }, inferGmst, AngularUnits.Radians) + const fromEcef = inferPosition({ ecef: inferEcef }, inferGmst, AngularUnits.Radians) + + expectVec3CloseTo(fromEci.ecef, fromEcef.ecef!) + expectVec3CloseTo(fromEcef.eci, fromEci.eci!) + expect(fromEci.geodetic!.latitude).toBeCloseTo(fromEcef.geodetic!.latitude, 9) + expect(fromEci.geodetic!.longitude).toBeCloseTo(fromEcef.geodetic!.longitude, 9) + expect(fromEci.geodetic!.height).toBeCloseTo(fromEcef.geodetic!.height, 6) + }) + + test('throws when no coordinate frame is provided', () => { + expect(() => inferPosition({}, inferGmst, AngularUnits.Radians)).toThrow( + 'At least one set of ECI, ECEF, or Geodetic coordinates must be defined to infer position.', + ) + }) +}) + +describe('utils.inferVelocity', () => { + const expectVec3CloseTo = ( + actual: { x: number; y: number; z: number } | undefined, + expected: { x: number; y: number; z: number }, + ) => { + expect(actual).toBeDefined() + expect(actual!.x).toBeCloseTo(expected.x, 9) + expect(actual!.y).toBeCloseTo(expected.y, 9) + expect(actual!.z).toBeCloseTo(expected.z, 9) + } + + test('infers ECI from ECEF velocity only', () => { + const result = inferVelocity({ ecef: inferVelocityEcef }, inferGmst) + + expectVec3CloseTo(result.eci, ecfToEci(inferVelocityEcef, inferGmst)) + expectVec3CloseTo(result.ecef, inferVelocityEcef) + }) + + test('infers ECEF from ECI velocity only', () => { + const result = inferVelocity({ eci: inferVelocityEci }, inferGmst) + + expectVec3CloseTo(result.ecef, eciToEcf(inferVelocityEci, inferGmst)) + expectVec3CloseTo(result.eci, inferVelocityEci) + }) + + test('returns both velocity frames unchanged when provided', () => { + const result = inferVelocity( + { eci: inferVelocityEci, ecef: inferVelocityEcef }, + inferGmst, + ) + + expect(result.eci).toBe(inferVelocityEci) + expect(result.ecef).toBe(inferVelocityEcef) + }) + + test('round-trips consistently regardless of which frame is provided', () => { + const fromEci = inferVelocity({ eci: inferVelocityEci }, inferGmst) + const fromEcef = inferVelocity({ ecef: inferVelocityEcef }, inferGmst) + + expectVec3CloseTo(fromEci.ecef, fromEcef.ecef!) + expectVec3CloseTo(fromEcef.eci, fromEci.eci!) + }) + + test('throws when no velocity frame is provided', () => { + expect(() => inferVelocity({}, inferGmst)).toThrow( + 'At least one set of ECI or ECEF velocity vectors must be defined to infer velocity.', + ) + }) +}) diff --git a/src/constants.ts b/src/constants.ts index 3c321a4..be18495 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,6 +15,25 @@ export const rad2deg: number = 180.0 / Math.PI /** Number of milliseconds in a day */ export const day2ms: number = 1000 * 60 * 60 * 24; +/** + * Geostationary mean motion in radians per minute. + * A geostationary satellite completes one revolution per sidereal day + * (86164.0905 seconds), matching Earth's rotation. + */ +export const geostationaryMeanMotion: number = (2 * Math.PI) / (86164.0905 / 60); + +/** Tolerances used to classify a satellite as geostationary. */ +export const geostationaryTolerance = { + /** Maximum fractional deviation of mean motion from the sidereal rate. */ + meanMotion: 0.01, + + /** Maximum orbital eccentricity (near-circular orbit). */ + eccentricity: 0.01, + + /** Maximum orbital inclination in radians (~1 degree). */ + inclination: 1.0 * (Math.PI / 180.0), +} as const; + /** World Geodetic System 1984 (WGS84) Parameters */ export const WGS84 = { /** Semi-Major Axis Raidus (km) */ diff --git a/src/index.ts b/src/index.ts index 3bf5f2c..3acbb93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,54 +28,64 @@ // OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF // SUCH DAMAGE. -import { SatelliteGroundTrack, SatelliteGroundTrackObservation, Position } from "./interfaces"; -import { astronomicalUnit, deg2rad } from "./constants"; -import { TwoLineElement, DateTimeTypes, OrbitMeanElementsMessage, Degrees } from "./types"; -import { footprintRadius, greenwichMeanSiderealTime, parseDateTime, parseSatelliteElements, predictedRevolutionCount } from "./utils"; +import { SatelliteObservation, Position } from "./interfaces"; +import { astronomicalUnit, deg2rad, rad2deg } from "./constants"; +import { TwoLineElement, DateTimeTypes, OrbitMeanElementsMessage, Degrees, Radians } from "./types"; +import { + footprintDiameter, + greenwichMeanSiderealTime, + parseDateTime, + parseSatelliteElements, + predictedRevolutionCount, + inferPosition, + inferVelocity, + vectorMagnitude, + convertGeodeticToDegrees, + isGeostationary, + betaAngle +} from "./utils"; import { - degreesLat, - degreesLong, dopplerFactor, ecfToLookAngles, - eciToEcf, - eciToGeodetic, - geodeticToEcf, jday, + Kilometer, propagate, radiansToDegrees, - radiansLat, - radiansLong, SatRecError, shadowFraction, sunPos, } from "satellite.js"; +/** Set the angular units for the inputs and outputs */ +export enum AngularUnits { + Degrees = 'DEGREES', + Radians = 'RADIANS' +} + /** - * Calculates the position, velocity, and ground track location of a satellite - * at a given time. + * Calculates satellite observation parameters such as postion, velocity, and + * observer look angles. * @param satelliteElements a TLE or OMM of the satellite's orbital elements * @param dateTime: an ISO datetime string, unix timestamp, or javascript Date object specifying the observation time * @param observerPosition: an optional position object specifying the location of a satellite observer + * @param minimumElevationAngle: minimum horizon elevation angle used for calculating footprint and acquisition of signal (AOS) */ export function observe( satelliteElements: TwoLineElement | OrbitMeanElementsMessage, dateTime: DateTimeTypes, observerPosition?: Position, - minElevationAngle?: Degrees = 0 -): SatelliteGroundTrack | SatelliteGroundTrackObservation { - const vectorMagnitude = (vector: { x: number; y: number; z: number }): number => { - return Math.hypot(vector.x, vector.y, vector.z) - } - + minimumElevationAngle: Degrees | Radians = 0, + angularUnits: AngularUnits = AngularUnits.Degrees +): SatelliteObservation { const dt = parseDateTime(dateTime) const gmst = greenwichMeanSiderealTime(dt) const [omm, satrec] = parseSatelliteElements(satelliteElements) // Returns the satellite position and velocity in ECI coordinations - const satEci = propagate(satrec, dt.toJSDate()) + const satPropagation = propagate(satrec, dt.toJSDate()) // Check for errors - if (satEci === null) { + if (satPropagation === null) { switch (satrec.error) { case SatRecError.MeanEccentricityOutOfRange: @@ -88,7 +98,7 @@ export function observe( throw new Error('Predicted orbit eccentricity is out of range for SGP4 propagation model') case SatRecError.SemiLatusRectumBelowZero: - throw new Error('Predicted orbit has collapsed due to severe drag perturbations or numerical instability') + throw new Error('Predicted orbit has collapsed mathematically') case SatRecError.Decayed: return { @@ -104,106 +114,68 @@ export function observe( throw new Error('Satellite propagation failed') } - const positionEcf = eciToEcf(satEci.position, gmst) - const velocityEcf = eciToEcf(satEci.velocity, gmst) - const geodeticPosition = eciToGeodetic(satEci.position, gmst) - const sunEci = sunPos(jday(dt.toJSDate())).rsun - const sunEcf = eciToEcf(sunEci, gmst) - const sunGeodeticPosition = eciToGeodetic(sunEci, gmst) - const eclipseFactor = shadowFraction(sunEci, satEci.position) - const revolutionCount = predictedRevolutionCount(omm, dt) - const minElevationAngleRadians = minElevationAngle * deg2rad + // Calculate the satellite's position and velocity in other coordinate frames + const satPosition = inferPosition({ eci: satPropagation.position }, gmst, angularUnits) + const satVelocity = inferVelocity({ eci: satPropagation.velocity }, gmst) + + // Calculate the sun's position in kilometers + const sunEciAU = sunPos(jday(dt.toJSDate())).rsun + const sunEci = { + x: sunEciAU.x * astronomicalUnit, + y: sunEciAU.y * astronomicalUnit, + z: sunEciAU.z * astronomicalUnit + } + const sunPosition = inferPosition({ eci: sunEci }, gmst, angularUnits) + + // Calculate the eclipse factor + const eclipseFactor = shadowFraction(sunEciAU, satPosition.eci!) + + // Calculate the beta angle (radians) between the orbital plane and the Sun + const betaAngleRadians = betaAngle(satPropagation.meanElements, sunEci) + + // Calculate the satellite's footprint + const footprint = (angularUnits === AngularUnits.Degrees) + ? footprintDiameter(satPosition, minimumElevationAngle * deg2rad) + : footprintDiameter(satPosition, minimumElevationAngle) // Calculate the ground track parameters - const groundTrack: SatelliteGroundTrack = { + const observation: SatelliteObservation = { id: omm.OBJECT_ID, name: omm.OBJECT_NAME, noradCatalogId: omm.NORAD_CAT_ID as string, orbitalModel: omm.MEAN_ELEMENT_THEORY, - epoch: dt.setZone('UTC').toISO(), + epoch: dt.toUTC().toISO(), gmst: gmst, - position: { - eci: satEci.position, - ecef: positionEcf, - latitude: degreesLat(geodeticPosition.latitude), - longitude: degreesLong(geodeticPosition.longitude), - altitude: geodeticPosition.height, - }, - velocity: { - eci: satEci.velocity, - ecef: velocityEcf, - }, - footprint: footprintRadius(geodeticPosition.latitude, geodeticPosition.height, minElevationAngleRadians) * 2, + position: (angularUnits === AngularUnits.Degrees) ? convertGeodeticToDegrees(satPosition) : satPosition, + velocity: satVelocity, + footprint: footprint, orbit: { - revolutionCount, - phase: ((radiansToDegrees(satEci.meanElements.mm) % 360) + 360) % 360, - velocity: vectorMagnitude(satEci.velocity) * 3600, + revolutionCount: predictedRevolutionCount(omm, dt), + phase: (angularUnits === AngularUnits.Degrees) ? satPropagation.meanElements.mm * rad2deg : satPropagation.meanElements.mm, + velocity: vectorMagnitude(satVelocity.eci!), }, decayed: false, - geostationary: false, //ToDo: Determine if the satellite is geostationary + geostationary: isGeostationary(satPropagation.meanElements), sunlit: eclipseFactor < 1, - sunPosition: { - eci: sunEci, - ecef: sunEcf, - latitude: degreesLat(sunGeodeticPosition.latitude), - longitude: degreesLong(sunGeodeticPosition.longitude), - altitude: sunGeodeticPosition.height, - }, + sunPosition: (angularUnits === AngularUnits.Degrees) ? convertGeodeticToDegrees(sunPosition) : sunPosition, + betaAngle: (angularUnits === AngularUnits.Degrees) ? betaAngleRadians * rad2deg : betaAngleRadians, eclipseFactor: eclipseFactor, } if (!observerPosition) { - return groundTrack + return observation } // If we have an observer, calculate the look angles of the satellite - - if (observerPosition.latitude === undefined || observerPosition.longitude === undefined) { - throw new Error('Observer position must include latitude and longitude in degrees') - } - - const normalizedObserverPosition = { - latitude: observerPosition.latitude, - longitude: observerPosition.longitude, - altitude: observerPosition.altitude ?? 0, - } - - const observerGeodetic = { - latitude: radiansLat(normalizedObserverPosition.latitude), - longitude: radiansLong(normalizedObserverPosition.longitude), - height: normalizedObserverPosition.altitude, - } - const observerEcf = geodeticToEcf(observerGeodetic) - const lookAngles = ecfToLookAngles(observerGeodetic, positionEcf) - const sunLookAngles = ecfToLookAngles(observerGeodetic, eciToEcf({ - x: sunEci.x * astronomicalUnit, - y: sunEci.y * astronomicalUnit, - z: sunEci.z * astronomicalUnit, - }, gmst)) - const elevation = radiansToDegrees(lookAngles.elevation) - const hasAos = elevation > 0 - - let visibility = 'visible' - - if (!hasAos) { - visibility = 'below-horizon' - } else if (!isSunlit) { - visibility = 'eclipsed' - } else if (radiansToDegrees(sunLookAngles.elevation) > -6) { - visibility = 'daylight' - } + const observerInferedPosition = inferPosition(observerPosition, gmst, angularUnits) + const observerLookAngles = ecfToLookAngles(observerInferedPosition.geodetic!, satPosition.ecef!) return { - ...groundTrack, - observerPosition: { - ...normalizedObserverPosition, - ecef: observerEcf, - }, - azimuth: radiansToDegrees(lookAngles.azimuth), - elevation, - slantRange: lookAngles.rangeSat, - dopplerFactor: dopplerFactor(observerEcf, positionEcf, velocityEcf), - visibility, - hasAos, + ...observation, + observerPosition: (angularUnits === AngularUnits.Degrees) ? convertGeodeticToDegrees(observerInferedPosition) : observerInferedPosition, + azimuth: (angularUnits === AngularUnits.Degrees) ? radiansToDegrees(observerLookAngles.azimuth) : observerLookAngles.azimuth, + elevation: (angularUnits === AngularUnits.Degrees) ? radiansToDegrees(observerLookAngles.elevation) : observerLookAngles.elevation, + slantRange: observerLookAngles.rangeSat, + dopplerFactor: dopplerFactor(observerInferedPosition.ecef!, satPosition.ecef!, satVelocity.ecef!), } } diff --git a/src/interfaces.ts b/src/interfaces.ts index 0eb55a1..58b4ca3 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import { EcfVec3, EciVec3 } from "satellite.js" +import { EcfVec3, EciVec3, GeodeticLocation } from "satellite.js" import { Radians, Degrees, Kilometers, KilometersPerHour, KilometersPerSecond } from "./types.ts" /** Position parameters */ @@ -9,14 +9,8 @@ export interface Position { /** Position in Earth-Centered Earth-Fixed (ECEF) coordinates measured in kilometers */ ecef?: EcfVec3 - /** Latitudinal position in degress */ - latitude?: Degrees - - /** Longitudinal position in degrees */ - longitude?: Degrees - - /** Position altitude in kilometers (km) */ - altitude?: Kilometers + /** Position in Geodetic coordinats measured in radians and kilometers */ + geodetic?: GeodeticLocation } /** Velocity parameters */ @@ -34,16 +28,16 @@ export interface Orbit { revolutionCount: number /** The current position of the satellite in its orbit relative to its perigee/ascending node, measured in degrees */ - phase?: Degrees + phase?: Radians | Degrees - /** Satellite velocity relative to the center of the Earth in kilometers per hour (kph) */ - velocity?: KilometersPerHour + /** Satellite velocity relative to the center of the Earth in kilometers per second */ + velocity?: KilometersPerSecond } /** * Satellite ground track parameters */ -export interface SatelliteGroundTrack { +export interface SatelliteObservation { /** Satellite international designator */ id: string @@ -90,37 +84,32 @@ export interface SatelliteGroundTrack { * The angle between the satellite's orbital plane and the vector pointing directly to the Sun, measured in degrees. * Used to determine thermal exposure and eclipse duration. */ - betaAngle?: Degrees + betaAngle?: Degrees | Radians /** * The fraction of the Sun’s disc obscured by the Earth as seen from a satellite. * 0 = fully lit, 1 = umbra, values between 0 and 1 indicate the fraction of the Sun covered by Earth. */ eclipseFactor?: number -} -/** - * Satellite ground observation parameters - * */ -export interface SatelliteGroundTrackObservation extends SatelliteGroundTrack { /** Satellite observer's position */ - observerPosition: Position + observerPosition?: Position /** The compass heading to the satellite from the observer's ground location in degrees */ - azimuth: Degrees; + azimuth?: Degrees | Radians /** The angle of the satellite above (or below) the observer's horizon in degrees. */ - elevation: Degrees; + elevation?: Degrees | Radians /** The direct line-of-sight distance from the observer to the satellite, measured in kilometers. */ - slantRange: Kilometers; + slantRange?: Kilometers /** Satellite frequency shift (i.e doppler factor) relative to observer. */ - dopplerFactor: number; + dopplerFactor?: number /** Indicates if the satellite is optically visible at the observer's location */ - visibility: string + visibility?: string /** Indicates if the satellite is above the observer's horizon */ - hasAos: boolean + hasAos?: boolean } diff --git a/src/utils.ts b/src/utils.ts index 4aa33a2..f999bf3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,14 +1,106 @@ -import type { Radians, Kilometers, DateTimeTypes } from "./types.ts"; -import { WGS84, day2ms } from "./constants.ts"; import { DateTime } from "luxon"; -import { gstime, SatRec, json2satrec } from "satellite.js"; +import { Position, Velocity } from "./interfaces.ts"; +import { WGS84, day2ms, geostationaryMeanMotion, geostationaryTolerance } from "./constants.ts"; +import type { Radians, Kilometers, DateTimeTypes } from "./types.ts"; import { TwoLineElement, OrbitMeanElementsMessage } from "./types.ts"; - +import { + gstime, + SatRec, + json2satrec, + geodeticToEcf, + ecfToEci, + eciToEcf, + eciToGeodetic, + GeodeticLocation, + radiansLat, + radiansLong, + degreesLat, + EcfPositionCalculator, + degreesLong, + MeanElements +} from "satellite.js"; +import { AngularUnits } from "./index.ts"; // <---------------------------------------------------------------------------> // UTILITY FUNCTIONS // <---------------------------------------------------------------------------> +/** + * Calculate the maginture of the vector + * @param vector + * @returns + */ +export function vectorMagnitude(vector: { x: number; y: number; z: number }): number { + return Math.hypot(vector.x, vector.y, vector.z) +} + +/** + * Determine whether a satellite is in a geostationary orbit from its + * propagated mean elements. + * + * A geostationary orbit is a near-circular, near-equatorial orbit whose + * period matches Earth's sidereal rotation (one revolution per sidereal day). + * The satellite is classified as geostationary when all of the following hold + * within the configured tolerances: + * - Mean motion is close to the sidereal rate (~one revolution per sidereal day). + * - Eccentricity is near zero (near-circular orbit). + * - Inclination is near zero (near-equatorial orbit). + * + * @param meanElements the averaged orbital elements from satellite propagation + * @returns true if the satellite is geostationary, otherwise false + */ +export function isGeostationary(meanElements: MeanElements): boolean { + const meanMotionDeviation = Math.abs(meanElements.nm - geostationaryMeanMotion) / geostationaryMeanMotion + + return ( + meanMotionDeviation <= geostationaryTolerance.meanMotion && + meanElements.em <= geostationaryTolerance.eccentricity && + Math.abs(meanElements.im) <= geostationaryTolerance.inclination + ) +} + +/** + * Calculate the beta angle: the angle between the satellite's orbital plane and + * the vector pointing directly to the Sun. + * + * The orbit normal is derived from the orbit's inclination and right ascension + * of the ascending node (RAAN). The beta angle is the complement of the angle + * between the Sun vector and the orbit normal, computed as + * `asin(dot(sunUnit, orbitNormal))`. Both input vectors and the orbital + * elements are expressed in the Earth-Centered Inertial (ECI) frame. + * + * @param meanElements the averaged orbital elements from satellite propagation + * @param sunEci the Sun's position in ECI coordinates (kilometers) + * @returns the beta angle in radians, ranging from -PI/2 to PI/2 + */ +export function betaAngle( + meanElements: MeanElements, + sunEci: { x: number; y: number; z: number }, +): Radians { + const inclination = meanElements.im + const raan = meanElements.Om + + // Unit normal vector of the orbital plane in the ECI frame. + const orbitNormal = { + x: Math.sin(raan) * Math.sin(inclination), + y: -Math.cos(raan) * Math.sin(inclination), + z: Math.cos(inclination), + } + + // Unit vector pointing from Earth's center to the Sun. + const sunMagnitude = vectorMagnitude(sunEci) + const sunUnit = { + x: sunEci.x / sunMagnitude, + y: sunEci.y / sunMagnitude, + z: sunEci.z / sunMagnitude, + } + + const dot = sunUnit.x * orbitNormal.x + sunUnit.y * orbitNormal.y + sunUnit.z * orbitNormal.z + + // Clamp to guard against floating-point values slightly outside [-1, 1]. + return Math.asin(Math.min(1, Math.max(-1, dot))) +} + /** * Calculate the radius of Earth's curvature at a given latitude * based on a WGS84 ellipsoid. @@ -36,10 +128,10 @@ export function earthCentralAngle(re: Kilometers, altitude: Kilometers, minEleva * @param altitude satellite altitude (kilometers) * @param minElevationAngle minimum elevation angle (radians) */ - export function footprintRadius(latitude: Radians, altitude: Kilometers, minElevationAngle: Radians = 0.0): Kilometers { - const re = localEarthRadius(latitude); - const lambda = earthCentralAngle(re, altitude, minElevationAngle) - const footprint = re * lambda + export function footprintDiameter(satPosition: Position, minElevationAngle: Radians = 0.0): Kilometers { + const re = localEarthRadius(satPosition.geodetic!.latitude); + const lambda = earthCentralAngle(re, satPosition.geodetic!.height, minElevationAngle) + const footprint = re * lambda * 2 return footprint } @@ -47,8 +139,11 @@ export function earthCentralAngle(re: Kilometers, altitude: Kilometers, minEleva * Convert the datetime string, unix timestamp (ms), or Date object to a luxon.DateTime object. */ export function parseDateTime(dateTime: DateTimeTypes): DateTime { - // 1. Check if object is already a luxon.DateTime - if (dateTime instanceof DateTime) { + // 1. Check if object is already a luxon.DateTime. + // Use DateTime.isDateTime instead of `instanceof` so a DateTime created by a + // different copy of the luxon module (e.g. the caller's own install) is still + // recognized, since `instanceof` fails across module boundaries. + if (DateTime.isDateTime(dateTime)) { return dateTime } @@ -91,7 +186,7 @@ export function predictedRevolutionCount( ): number { const elementEpoch = parseDateTime(orbitMeanElementsMessage.EPOCH) - return Math.floor( + return Math.ceil( Number(orbitMeanElementsMessage.REV_AT_EPOCH ?? 0) + ((observationTime.toMillis() - elementEpoch.toMillis()) / day2ms) * Number(orbitMeanElementsMessage.MEAN_MOTION), ) @@ -208,3 +303,146 @@ export function parseSatelliteElements(satelliteElements: TwoLineElement | Orbit return [omm, json2satrec(omm)] } + +/** + * Generate a position in multiple coordinate frames from an initial set of coordinates. + * A caller must provide at least one set of the following: + * - The Earth-Centered Inertial (ECI) coordinates of the position in kilometers. + * - The Earch-Centered Earth-Fixed (ECEF) coordinates of the position in kilometers. + * - The Geodetic coordinates (longitude, latitude in radians and height in kilometers) of the position. + * The function will generate coordinates in the coordinate frames that are not provided. + */ +export function inferPosition(position: Position, gmst: Radians, angularUnits: AngularUnits): Position { + let inferedPosition: Position = {} + + // Missing ECI coordinates + const eciMissing = (!position.eci); + + // Missing ECEF coordinates + const ecefMissing = (!position.ecef); + + // Missing Geodetic coordinates + const geodeticMissing = (!position.geodetic); + + // Convert the geodetic coordinates to radians if necessary + let geodetic: GeodeticLocation | undefined; + + if (!geodeticMissing && angularUnits === AngularUnits.Degrees) { + geodetic = { + latitude: radiansLat(position.geodetic!.latitude), + longitude: radiansLong(position.geodetic!.longitude), + height: position.geodetic!.height + } + } else if (!geodeticMissing && angularUnits == AngularUnits.Radians) { + geodetic = position.geodetic + } + + // Populate available coordinates + inferedPosition.eci = !eciMissing ? position.eci : undefined; + inferedPosition.ecef = !ecefMissing ? position.ecef : undefined; + inferedPosition.geodetic = !geodeticMissing ? geodetic : undefined; + + // Calculate the missing coordinate frames + const decisionFlag = `${eciMissing}:${ecefMissing}:${geodeticMissing}` + + switch (decisionFlag) { + + // ECI is missing, ECEF and Geodetic are available + case 'true:false:false': + inferedPosition.eci = ecfToEci(position.ecef!, gmst); + break; + + // ECI and ECEF are missing, Geodetic is available + case 'true:true:false': + inferedPosition.ecef = geodeticToEcf(geodetic!); + inferedPosition.eci = ecfToEci(inferedPosition.ecef, gmst) + break; + + // ECI and Geodetic are missing, ECEF is available + case 'true:false:true': + inferedPosition.eci = ecfToEci(position.ecef!, gmst) + inferedPosition.geodetic = eciToGeodetic(inferedPosition.eci, gmst) + break; + + // ECEF is missing, ECI and Geodetic are available + case 'false:true:false': + inferedPosition.ecef = eciToEcf(position.eci!, gmst); + break; + + // ECEF and Geodetic are missing, ECI is available + case 'false:true:true': + inferedPosition.ecef = eciToEcf(position.eci!, gmst) + inferedPosition.geodetic = eciToGeodetic(position.eci!, gmst) + break; + + // Geodetic is missing, ECI and ECEF are available + case 'false:false:true': + inferedPosition.geodetic = eciToGeodetic(position.eci!, gmst) + break; + + // All 3 coordinate frames are missing + case 'true:true:true': + throw new Error('At least one set of ECI, ECEF, or Geodetic coordinates must be defined to infer position.'); + } + + return inferedPosition; +} + +/** + * Generate a velocity in multiple coordinate frames from an initial set of vectors. + * A caller must provide at least one set of the following: + * - The Earth-Centered Inertial (ECI) velocity vector in kilometers per second. + * - The Earth-Centered Earth-Fixed (ECEF) velocity vector in kilometers per second. + * The function will generate the velocity vector in the coordinate frame that is not provided. + */ +export function inferVelocity(velocity: Velocity, gmst: Radians): Velocity { + let inferedVelocity: Velocity = {} + + // Missing ECI velocity vector + const eciMissing = (!velocity.eci); + + // Missing ECEF velocity vector + const ecefMissing = (!velocity.ecef); + + // Populate available velocity vectors + inferedVelocity.eci = !eciMissing ? velocity.eci : undefined; + inferedVelocity.ecef = !ecefMissing ? velocity.ecef : undefined; + + // Calculate the missing coordinate frame + const decisionFlag = `${eciMissing}:${ecefMissing}` + + switch (decisionFlag) { + + // ECI is missing, ECEF is available + case 'true:false': + inferedVelocity.eci = ecfToEci(velocity.ecef!, gmst); + break; + + // ECEF is missing, ECI is available + case 'false:true': + inferedVelocity.ecef = eciToEcf(velocity.eci!, gmst); + break; + + // Both coordinate frames are missing + case 'true:true': + throw new Error('At least one set of ECI or ECEF velocity vectors must be defined to infer velocity.'); + } + + return inferedVelocity; +} + +/** + * Convert the geodetic coordinates of the position from radians to degrees. + * @param position the position with geodetic coordinates defined in radians + */ +export function convertGeodeticToDegrees(position: Position): Position { + return { + eci: position.eci, + ecef: position.ecef, + geodetic: { + latitude: degreesLat(position.geodetic!.latitude), + longitude: degreesLong(position.geodetic!.longitude), + height: position.geodetic!.height + } + } +} diff --git a/vite.config.ts b/vite.config.ts index 732c8ce..0bebba8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ build: { lib: { // Defines the entry point of your module - entry: 'src/common.ts', + entry: 'src/index.ts', name: 'JsPredict', formats: ['es'], fileName: 'jspredict', From 2702db1df3dcaa64c5454c61a7a7e9ec9aafbcce Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Mon, 10 Aug 2026 16:45:11 -0600 Subject: [PATCH 04/14] Adding TSConfig --- tsconfig.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tsconfig.json diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3194ad3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "allowImportingTsExtensions": true, + "noEmit": true, + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "src/legacy.ts", "src/__tests__"] +} From 2702de523cdb9b8a5182ee25b6e6b82783ce3cee Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Tue, 11 Aug 2026 13:09:34 -0600 Subject: [PATCH 05/14] Renaming geodetic position properties to "geo" to match abbreviations of other coordinate systems --- src/__tests__/index.test.ts | 10 +++++----- src/__tests__/utils.test.ts | 36 ++++++++++++++++++------------------ src/index.ts | 2 +- src/interfaces.ts | 2 +- src/utils.ts | 31 +++++++++++++++---------------- 5 files changed, 40 insertions(+), 41 deletions(-) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index cd87b35..dffee8f 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -97,10 +97,10 @@ describe('observe', () => { expect(observed.decayed).toBe(false) expect(observed.position?.eci?.x).toBeCloseTo(propagated.position.x, 10) expect(observed.position?.ecef?.x).toBeCloseTo(ecef.x, 10) - expect(observed.position?.geodetic?.latitude).toBeCloseTo(geodetic.latitude, 10) - expect(observed.position?.geodetic?.longitude).toBeCloseTo(geodetic.longitude, 10) - expect(observed.position?.geodetic?.height).toBeCloseTo(geodetic.height, 10) - expect(observed.footprint).toBeCloseTo(footprintDiameter({ geodetic }, 0), 10) + expect(observed.position?.geo?.latitude).toBeCloseTo(geodetic.latitude, 10) + expect(observed.position?.geo?.longitude).toBeCloseTo(geodetic.longitude, 10) + expect(observed.position?.geo?.height).toBeCloseTo(geodetic.height, 10) + expect(observed.footprint).toBeCloseTo(footprintDiameter({ geo: geodetic }, 0), 10) expect(observed.velocity?.eci?.x).toBeCloseTo(propagated.velocity.x, 10) expect(observed.velocity?.ecef?.x).toBeCloseTo(eciToEcf(propagated.velocity, gmst).x, 10) expect(observed.orbit?.velocity).toBeCloseTo( @@ -118,7 +118,7 @@ describe('observe', () => { const observed = observe( issOmm as OMMJsonObjectV3, observationEpoch, - { geodetic: observerGeodetic }, + { geo: observerGeodetic }, 0, AngularUnits.Radians, ) diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 40fea35..38a359b 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -81,7 +81,7 @@ describe('utils.earthCentralAngle', () => { describe('utils.footprintDiameter', () => { test('returns zero when altitude is zero', () => { - const position = { geodetic: { latitude: 0, longitude: 0, height: 0 } } + const position = { geo: { latitude: 0, longitude: 0, height: 0 } } expect(footprintDiameter(position, 0)).toBeCloseTo(0, 12) }) @@ -94,7 +94,7 @@ describe('utils.footprintDiameter', () => { localEarthRadius(latitude) * earthCentralAngle(localEarthRadius(latitude), altitude, epsilon) * 2 - const position = { geodetic: { latitude, longitude: 0, height: altitude } } + const position = { geo: { latitude, longitude: 0, height: altitude } } expect(footprintDiameter(position, epsilon)).toBeCloseTo(expected, 10) }) @@ -228,21 +228,21 @@ describe('utils.inferPosition', () => { } const expectGeodeticCloseTo = (position: { - geodetic?: { latitude: number; longitude: number; height: number } + geo?: { latitude: number; longitude: number; height: number } }) => { const geodetic = eciToGeodetic(inferEci, inferGmst) - expect(position.geodetic).toBeDefined() - expect(position.geodetic!.latitude).toBeCloseTo(geodetic.latitude, 9) - expect(position.geodetic!.longitude).toBeCloseTo(geodetic.longitude, 9) - expect(position.geodetic!.height).toBeCloseTo(geodetic.height, 6) + expect(position.geo).toBeDefined() + expect(position.geo!.latitude).toBeCloseTo(geodetic.latitude, 9) + expect(position.geo!.longitude).toBeCloseTo(geodetic.longitude, 9) + expect(position.geo!.height).toBeCloseTo(geodetic.height, 6) } test('infers ECI from ECEF and geodetic coordinates', () => { const result = inferPosition( { ecef: inferEcef, - geodetic: inferGeodetic, + geo: inferGeodetic, }, inferGmst, AngularUnits.Radians, @@ -250,15 +250,15 @@ describe('utils.inferPosition', () => { expectVec3CloseTo(result.eci, ecfToEci(inferEcef, inferGmst)) expectVec3CloseTo(result.ecef, inferEcef) - expect(result.geodetic).toBe(inferGeodetic) + expect(result.geo).toBe(inferGeodetic) }) test('infers ECEF and ECI from geodetic coordinates only', () => { - const result = inferPosition({ geodetic: inferGeodetic }, inferGmst, AngularUnits.Radians) + const result = inferPosition({ geo: inferGeodetic }, inferGmst, AngularUnits.Radians) expectVec3CloseTo(result.ecef, inferEcef) expectVec3CloseTo(result.eci, inferEci) - expect(result.geodetic).toBe(inferGeodetic) + expect(result.geo).toBe(inferGeodetic) }) test('infers ECI and geodetic from ECEF coordinates only', () => { @@ -273,7 +273,7 @@ describe('utils.inferPosition', () => { const result = inferPosition( { eci: inferEci, - geodetic: inferGeodetic, + geo: inferGeodetic, }, inferGmst, AngularUnits.Radians, @@ -281,7 +281,7 @@ describe('utils.inferPosition', () => { expectVec3CloseTo(result.ecef, eciToEcf(inferEci, inferGmst)) expectVec3CloseTo(result.eci, inferEci) - expect(result.geodetic).toBe(inferGeodetic) + expect(result.geo).toBe(inferGeodetic) }) test('infers ECEF and geodetic from ECI coordinates only', () => { @@ -305,7 +305,7 @@ describe('utils.inferPosition', () => { { eci: inferEci, ecef: inferEcef, - geodetic: inferGeodetic, + geo: inferGeodetic, }, inferGmst, AngularUnits.Radians, @@ -313,7 +313,7 @@ describe('utils.inferPosition', () => { expect(result.eci).toBe(inferEci) expect(result.ecef).toBe(inferEcef) - expect(result.geodetic).toBe(inferGeodetic) + expect(result.geo).toBe(inferGeodetic) }) test('round-trips consistently regardless of which frame is provided', () => { @@ -322,9 +322,9 @@ describe('utils.inferPosition', () => { expectVec3CloseTo(fromEci.ecef, fromEcef.ecef!) expectVec3CloseTo(fromEcef.eci, fromEci.eci!) - expect(fromEci.geodetic!.latitude).toBeCloseTo(fromEcef.geodetic!.latitude, 9) - expect(fromEci.geodetic!.longitude).toBeCloseTo(fromEcef.geodetic!.longitude, 9) - expect(fromEci.geodetic!.height).toBeCloseTo(fromEcef.geodetic!.height, 6) + expect(fromEci.geo!.latitude).toBeCloseTo(fromEcef.geo!.latitude, 9) + expect(fromEci.geo!.longitude).toBeCloseTo(fromEcef.geo!.longitude, 9) + expect(fromEci.geo!.height).toBeCloseTo(fromEcef.geo!.height, 6) }) test('throws when no coordinate frame is provided', () => { diff --git a/src/index.ts b/src/index.ts index 3acbb93..eadf35b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,7 +168,7 @@ export function observe( // If we have an observer, calculate the look angles of the satellite const observerInferedPosition = inferPosition(observerPosition, gmst, angularUnits) - const observerLookAngles = ecfToLookAngles(observerInferedPosition.geodetic!, satPosition.ecef!) + const observerLookAngles = ecfToLookAngles(observerInferedPosition.geo!, satPosition.ecef!) return { ...observation, diff --git a/src/interfaces.ts b/src/interfaces.ts index 58b4ca3..f1d5c3b 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -10,7 +10,7 @@ export interface Position { ecef?: EcfVec3 /** Position in Geodetic coordinats measured in radians and kilometers */ - geodetic?: GeodeticLocation + geo?: GeodeticLocation } /** Velocity parameters */ diff --git a/src/utils.ts b/src/utils.ts index f999bf3..13d1a0c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -15,7 +15,6 @@ import { radiansLat, radiansLong, degreesLat, - EcfPositionCalculator, degreesLong, MeanElements } from "satellite.js"; @@ -129,8 +128,8 @@ export function earthCentralAngle(re: Kilometers, altitude: Kilometers, minEleva * @param minElevationAngle minimum elevation angle (radians) */ export function footprintDiameter(satPosition: Position, minElevationAngle: Radians = 0.0): Kilometers { - const re = localEarthRadius(satPosition.geodetic!.latitude); - const lambda = earthCentralAngle(re, satPosition.geodetic!.height, minElevationAngle) + const re = localEarthRadius(satPosition.geo!.latitude); + const lambda = earthCentralAngle(re, satPosition.geo!.height, minElevationAngle) const footprint = re * lambda * 2 return footprint } @@ -322,25 +321,25 @@ export function inferPosition(position: Position, gmst: Radians, angularUnits: A const ecefMissing = (!position.ecef); // Missing Geodetic coordinates - const geodeticMissing = (!position.geodetic); + const geodeticMissing = (!position.geo); // Convert the geodetic coordinates to radians if necessary let geodetic: GeodeticLocation | undefined; if (!geodeticMissing && angularUnits === AngularUnits.Degrees) { geodetic = { - latitude: radiansLat(position.geodetic!.latitude), - longitude: radiansLong(position.geodetic!.longitude), - height: position.geodetic!.height + latitude: radiansLat(position.geo!.latitude), + longitude: radiansLong(position.geo!.longitude), + height: position.geo!.height } } else if (!geodeticMissing && angularUnits == AngularUnits.Radians) { - geodetic = position.geodetic + geodetic = position.geo } // Populate available coordinates inferedPosition.eci = !eciMissing ? position.eci : undefined; inferedPosition.ecef = !ecefMissing ? position.ecef : undefined; - inferedPosition.geodetic = !geodeticMissing ? geodetic : undefined; + inferedPosition.geo = !geodeticMissing ? geodetic : undefined; // Calculate the missing coordinate frames const decisionFlag = `${eciMissing}:${ecefMissing}:${geodeticMissing}` @@ -361,7 +360,7 @@ export function inferPosition(position: Position, gmst: Radians, angularUnits: A // ECI and Geodetic are missing, ECEF is available case 'true:false:true': inferedPosition.eci = ecfToEci(position.ecef!, gmst) - inferedPosition.geodetic = eciToGeodetic(inferedPosition.eci, gmst) + inferedPosition.geo = eciToGeodetic(inferedPosition.eci, gmst) break; // ECEF is missing, ECI and Geodetic are available @@ -372,12 +371,12 @@ export function inferPosition(position: Position, gmst: Radians, angularUnits: A // ECEF and Geodetic are missing, ECI is available case 'false:true:true': inferedPosition.ecef = eciToEcf(position.eci!, gmst) - inferedPosition.geodetic = eciToGeodetic(position.eci!, gmst) + inferedPosition.geo = eciToGeodetic(position.eci!, gmst) break; // Geodetic is missing, ECI and ECEF are available case 'false:false:true': - inferedPosition.geodetic = eciToGeodetic(position.eci!, gmst) + inferedPosition.geo = eciToGeodetic(position.eci!, gmst) break; // All 3 coordinate frames are missing @@ -439,10 +438,10 @@ export function convertGeodeticToDegrees(position: Position): Position { return { eci: position.eci, ecef: position.ecef, - geodetic: { - latitude: degreesLat(position.geodetic!.latitude), - longitude: degreesLong(position.geodetic!.longitude), - height: position.geodetic!.height + geo: { + latitude: degreesLat(position.geo!.latitude), + longitude: degreesLong(position.geo!.longitude), + height: position.geo!.height } } } From 7c65d02f5142f3c3763bb6218ac4535442603159 Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Tue, 11 Aug 2026 16:36:15 -0600 Subject: [PATCH 06/14] Adding transit interface --- CHANGELOG.md | 16 +- src/__tests__/index.test.ts | 48 +++- src/index.ts | 77 +++++- src/interfaces.ts | 63 ++++- src/legacy.ts | 456 ------------------------------------ src/types.ts | 6 + 6 files changed, 191 insertions(+), 475 deletions(-) delete mode 100644 src/legacy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b91287..72466ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -## [1.2] - 2026-08-06 +## [2.0.0] ### Changed -- Deprecating legacy baseline version +- Migrated library source code from Javascript to Typescript +- Migrated from Webpack to Vite for testing and packaging +- Migrated library build to standard ES modules for frontend and backend use + +### Deprecated +- Removed support for Bower and Meteor package repositories, builds are now hosted on GitHub Packages +- Removed support for CommonJS and UMD style Javascript modules + + +## [1.2.0] - 2026-08-06 +- Deprecated legacy version (no longer supported) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index dffee8f..9334a27 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -14,7 +14,7 @@ import { radiansLong, } from 'satellite.js' -import { observe, AngularUnits } from '../index' +import { satelliteObservation, satelliteObservations, AngularUnits } from '../index' import { convertTleToOmm, footprintDiameter } from '../utils' // <---------------------------------------------------------------------------> @@ -75,9 +75,9 @@ const laterObservationEpoch = '2026-08-08T00:30:49.879296Z' // TESTS // <---------------------------------------------------------------------------> -describe('observe', () => { +describe('satelliteObservation', () => { test('returns a ground track with position units converted from satellite.js', () => { - const observed = observe(issTle, observationEpoch, undefined, 0, AngularUnits.Radians) + const observed = satelliteObservation(issTle, observationEpoch, undefined, 0, AngularUnits.Radians) const date = new Date(observationEpoch) const satrec = json2satrec(convertTleToOmm(issTle)) const propagated = propagate(satrec, date) @@ -115,7 +115,7 @@ describe('observe', () => { longitude: radiansLong(130), height: 0.1, } - const observed = observe( + const observed = satelliteObservation( issOmm as OMMJsonObjectV3, observationEpoch, { geo: observerGeodetic }, @@ -144,8 +144,46 @@ describe('observe', () => { }) test('predicts revolution count from the observation time', () => { - const observed = observe(issOmm as OMMJsonObjectV3, laterObservationEpoch) + const observed = satelliteObservation(issOmm as OMMJsonObjectV3, laterObservationEpoch) expect(observed.orbit?.revolutionCount).toBe(57979) }) }) + +describe('satelliteObservations', () => { + test('returns one observation per datetime, matching individual satelliteObservation calls', () => { + const dateTimes = [observationEpoch, laterObservationEpoch] + const observerPosition = { + geo: { + latitude: radiansLat(15), + longitude: radiansLong(130), + height: 0.1, + }, + } + + const observed = satelliteObservations( + issOmm as OMMJsonObjectV3, + dateTimes, + observerPosition, + 0, + AngularUnits.Radians, + ) + + expect(observed).toHaveLength(dateTimes.length) + + dateTimes.forEach((dateTime, index) => { + const expected = satelliteObservation( + issOmm as OMMJsonObjectV3, + dateTime, + observerPosition, + 0, + AngularUnits.Radians, + ) + expect(observed[index]).toStrictEqual(expected) + }) + }) + + test('returns an empty array when no datetimes are provided', () => { + expect(satelliteObservations(issOmm as OMMJsonObjectV3, [])).toStrictEqual([]) + }) +}) diff --git a/src/index.ts b/src/index.ts index eadf35b..12df057 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,29 @@ import { SatelliteObservation, Position } from "./interfaces"; import { astronomicalUnit, deg2rad, rad2deg } from "./constants"; import { TwoLineElement, DateTimeTypes, OrbitMeanElementsMessage, Degrees, Radians } from "./types"; + +// Re-export the public interfaces so consumers can import them from the module root +export type { + Position, + Velocity, + Orbit, + SatelliteObservation, + SatelliteTransit, +} from "./interfaces"; + +// Re-export the public types so consumers can import them from the module root +export type { + Kilometers, + KilometersPerSecond, + KilometersPerHour, + Radians, + Degrees, + AstronomialUnits, + TwoLineElement, + OrbitMeanElementsMessage, + DateTimeTypes, +} from "./types"; + import { footprintDiameter, greenwichMeanSiderealTime, @@ -48,7 +71,6 @@ import { dopplerFactor, ecfToLookAngles, jday, - Kilometer, propagate, radiansToDegrees, SatRecError, @@ -66,11 +88,18 @@ export enum AngularUnits { * Calculates satellite observation parameters such as postion, velocity, and * observer look angles. * @param satelliteElements a TLE or OMM of the satellite's orbital elements - * @param dateTime: an ISO datetime string, unix timestamp, or javascript Date object specifying the observation time - * @param observerPosition: an optional position object specifying the location of a satellite observer - * @param minimumElevationAngle: minimum horizon elevation angle used for calculating footprint and acquisition of signal (AOS) + * @param dateTime: an ISO datetime string, unix timestamp, Javascript Date + * object, or luxon DateTime object specifying the observation time + * @param observerPosition: (optional) a position object specifying the location + * of a satellite observer + * @param minimumElevationAngle: (optional) minimum horizon elevation angle used + * for calculating footprint and acquisition of signal (AOS), default is 0 + * degrees above the horizon + * @param angularUnits: (optional) specifies which angular units are used for + * inputs and outputs, default is Degrees + * Returns SatelliteObservation object */ -export function observe( +export function satelliteObservation( satelliteElements: TwoLineElement | OrbitMeanElementsMessage, dateTime: DateTimeTypes, observerPosition?: Position, @@ -138,6 +167,13 @@ export function observe( ? footprintDiameter(satPosition, minimumElevationAngle * deg2rad) : footprintDiameter(satPosition, minimumElevationAngle) + // Calculate the orbital phase from the mean anomaly, normalized to [0, 2*PI). This matches + // the phase definition used by the original predict/pypredict libraries, where phase is + // computed as (xlt - xnode - omgadf) which reduces to the mean anomaly plus small + // long-period/secular corrections, measured from perigee. + const twoPi = 2 * Math.PI + const phaseRadians = ((satPropagation.meanElements.mm % twoPi) + twoPi) % twoPi + // Calculate the ground track parameters const observation: SatelliteObservation = { id: omm.OBJECT_ID, @@ -151,7 +187,8 @@ export function observe( footprint: footprint, orbit: { revolutionCount: predictedRevolutionCount(omm, dt), - phase: (angularUnits === AngularUnits.Degrees) ? satPropagation.meanElements.mm * rad2deg : satPropagation.meanElements.mm, + phase: (angularUnits === AngularUnits.Degrees) ? phaseRadians * rad2deg : phaseRadians, + phase256: phaseRadians * (256 / twoPi), velocity: vectorMagnitude(satVelocity.eci!), }, decayed: false, @@ -179,3 +216,31 @@ export function observe( dopplerFactor: dopplerFactor(observerInferedPosition.ecef!, satPosition.ecef!, satVelocity.ecef!), } } + +/** + * Calculates satellite observation parameters such as postion, velocity, and + * observer look angles at the specified datetimes. + * @param satelliteElements a TLE or OMM of the satellite's orbital elements + * @param dateTimes: an array of ISO datetime strings, unix timestamps, + * Javascript Date objects, or luxon DateTime objects specifying the + * observation times + * @param observerPosition: (optional) a position object specifying the location + * of a satellite observer + * @param minimumElevationAngle: (optional) minimum horizon elevation angle used + * for calculating footprint and acquisition of signal (AOS), default is 0 + * degrees above the horizon + * @param angularUnits: (optional) specifies which angular units are used for + * inputs and outputs, default is Degrees + * Returns array of SatelliteObservation objects + */ +export function satelliteObservations( + satelliteElements: TwoLineElement | OrbitMeanElementsMessage, + dateTimes: DateTimeTypes[], + observerPosition?: Position, + minimumElevationAngle: Degrees | Radians = 0, + angularUnits: AngularUnits = AngularUnits.Degrees +): SatelliteObservation[] { + return dateTimes.map((dateTime) => + satelliteObservation(satelliteElements, dateTime, observerPosition, minimumElevationAngle, angularUnits) + ) +} \ No newline at end of file diff --git a/src/interfaces.ts b/src/interfaces.ts index f1d5c3b..9d538d0 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,5 @@ import { EcfVec3, EciVec3, GeodeticLocation } from "satellite.js" -import { Radians, Degrees, Kilometers, KilometersPerHour, KilometersPerSecond } from "./types.ts" +import { Radians, Degrees, Kilometers, KilometersPerSecond, Seconds, Timestamp } from "./types.ts" /** Position parameters */ export interface Position { @@ -27,15 +27,27 @@ export interface Orbit { /** The number of revolutions the satellite has completed in its orbit */ revolutionCount: number - /** The current position of the satellite in its orbit relative to its perigee/ascending node, measured in degrees */ + /** + * The current position of the satellite in its orbit, measured from perigee as the mean + * anomaly (plus small long-period/secular corrections), normalized to a full revolution. + * Reported in radians or degrees depending on the requested angular units. This matches + * the phase definition used by the original predict/pypredict libraries. + */ phase?: Radians | Degrees + + /** + * The current position of the satellite in its orbit expressed on a legacy 0..256 scale, + * where 0 is the start of the orbit and 256 is a full revolution. This matches the + * `orbital_phase` value reported by the original predict/pypredict libraries. + */ + phase256?: number /** Satellite velocity relative to the center of the Earth in kilometers per second */ velocity?: KilometersPerSecond } /** - * Satellite ground track parameters + * Satellite observation parameters */ export interface SatelliteObservation { /** Satellite international designator */ @@ -48,7 +60,7 @@ export interface SatelliteObservation { noradCatalogId: string | number /** Prediction UTC timestamp (ISO8601 format) */ - epoch: string | null + epoch: Timestamp | null /** Prediction Greenwich Sidereal Time (GMST) */ gmst?: Radians @@ -113,3 +125,46 @@ export interface SatelliteObservation { /** Indicates if the satellite is above the observer's horizon */ hasAos?: boolean } + +/** + * Transit event parameters + */ +export interface TransitEvent { + /** Date and time of the event as a ISO8601 timestamp */ + epoch: Timestamp + + /** Satellite compass direction, measured in Degrees or Radians */ + azimuth: Degrees | Radians + + /** Satellite elevation, measured in Degrees or Radians */ + elevation: Degrees | Radians + + /** Straight line range of the satellite from the observer */ + slantRange: Kilometers +} + +/** + * Satellite transit parameters + */ +export interface SatelliteTransit { + /** Transit UTC start time as an ISO8601 timestamp */ + start: Timestamp + + /** Transit UTC stop time as an ISO8601 timestamp */ + stop: Timestamp + + /** Duration of transit from start to stop time, measured in seconds (s) */ + duration: Seconds + + /** Acquisition of Signal (AOS) event parameters */ + aos: TransitEvent + + /** Loss of Signal (LOS) event parameters */ + los: TransitEvent + + /** Time of Closest Approach (TCA) event parameters */ + tca: TransitEvent + + /** Peak elevation event parameters */ + peak: TransitEvent +} \ No newline at end of file diff --git a/src/legacy.ts b/src/legacy.ts deleted file mode 100644 index df96508..0000000 --- a/src/legacy.ts +++ /dev/null @@ -1,456 +0,0 @@ -// jspredict v2.0.0 -// https://github.com/nsat/jspredict - -// Copyright (c) 2026, Spire Global Inc -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the Spire Global Inc nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL -// Spire Global Inc BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -// USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -// OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -// SUCH DAMAGE. - - - -// <---------------------------------------------------------------------------> -// CONSTANTS -// <---------------------------------------------------------------------------> - -const max_iterations = 250; -const defaultMinElevation = 4; // degrees - - - - -// <---------------------------------------------------------------------------> -// PRIVATE FUNCTIONS -// <---------------------------------------------------------------------------> - -function _observe(satrec, qth, start) { - start = m_moment(start); - - var eci = _eci(satrec, start); - var gmst = _gmst(start); - - if (!eci.position) { - return null; - } - - var geo = satellite.eciToGeodetic(eci.position, gmst); - var solar_vector = _calculateSolarPosition(start.valueOf()); - var eclipse = _satEclipsed(eci.position, solar_vector); - - var track = { - eci: eci, - gmst: gmst, - latitude: geo.latitude / deg2rad, - longitude: _boundLongitude(geo.longitude / deg2rad), - altitude: geo.height, - footprint: 12756.33 * Math.acos(earth_radius / (earth_radius + geo.height)), - sunlit: !eclipse.eclipsed, - eclipseDepth: eclipse.depth / deg2rad - } - - // If we have a groundstation let's get those additional observe parameters - if (qth && qth.length == 3) { - var observerGd = { - longitude: qth[1] * deg2rad, - latitude: qth[0] * deg2rad, - height: qth[2] - } - - var positionEcf = satellite.eciToEcf(eci.position, gmst), - velocityEcf = satellite.eciToEcf(eci.velocity, gmst), - observerEcf = satellite.geodeticToEcf(observerGd), - lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf), - doppler = satellite.dopplerFactor(observerEcf, positionEcf, velocityEcf); - - track.azimuth = lookAngles.azimuth / deg2rad; - track.elevation = lookAngles.elevation / deg2rad; - track.rangeSat = lookAngles.rangeSat; - track.doppler = doppler; - } - - return track -} - - - -function _quickPredict(satrec, qth, start, end) { - var transit = {}; - var lastel = 0; - var iterations = 0; - - if (_badSat(satrec, qth, start)) { - return null; - } - - var daynum = _findAOS(satrec, qth, start); - if (!daynum) { - return null; - } - transit.start = daynum; - - var observed = _observe(satrec, qth, daynum); - if (!observed) { - return null; - } - - var iel = Math.round(observed.elevation); - - var maxEl = 0, apexAz = 0, minAz = 360, maxAz = 0; - - while (iel >= 0 && iterations < max_iterations && (!end || daynum < end)) { - lastel = iel; - daynum = daynum + ms2day * Math.cos((observed.elevation-1.0)*deg2rad)*Math.sqrt(observed.altitude)/25000.0; - observed = _observe(satrec, qth, daynum); - iel = Math.round(observed.elevation); - if (maxEl < observed.elevation) { - maxEl = observed.elevation; - apexAz = observed.azimuth; - } - maxAz = Math.max(maxAz, observed.azimuth); - minAz = Math.min(minAz, observed.azimuth); - iterations += 1; - } - if (lastel !== 0) { - daynum = _findLOS(satrec, qth, daynum); - } - - transit.end = daynum; - transit.maxElevation = maxEl; - transit.apexAzimuth = apexAz; - transit.maxAzimuth = maxAz; - transit.minAzimuth = minAz; - transit.duration = transit.end - transit.start; - - return transit -} - -function _badSat(satrec, qth, start) { - if (qth && !_aosHappens(satrec, qth)) { - return true - } else if (start && _decayed(satrec, start)) { - return true - } else { - return false - } -} - -function _aosHappens(satrec, qth) { - var lin, sma, apogee; - var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day - if (meanmo === 0) { - return false - } else { - lin = satrec.inclo / deg2rad; - - if (lin >= 90.0) { - lin = 180.0 - lin; - } - - sma = 331.25 * Math.exp(Math.log(1440.0/meanmo)*(2.0/3.0)); - apogee = sma * (1.0 + satrec.ecco) - earth_radius; - - if ((Math.acos(earth_radius/(apogee+earth_radius))+(lin*deg2rad)) > Math.abs(qth[0]*deg2rad)) { - return true - } else { - return false - } - } -} - -function _decayed(satrec, start) { - start = m_moment(start); - - var satepoch = m_moment.utc(satrec.epochyr, "YY").add(satrec.epochdays, 'days').valueOf(); - - var meanmo = satrec.no * 24 * 60 / (2 * Math.PI); // convert rad/min to rev/day - var drag = satrec.ndot * 24 * 60 * 24 * 60 / (2 * Math.PI); // convert rev/day^2 - - if (satepoch + ms2day * ((16.666666-meanmo)/(10.0*Math.abs(drag))) < start) { - return true - } else { - return false - } -} - -function _findAOS(satrec, qth, start) { - var current = start; - var observed = _observe(satrec, qth, current); - if (!observed) { - return null; - } - var aostime = 0; - var iterations = 0; - - if (observed.elevation > 0) { - return current - } - while (observed.elevation < -1 && iterations < max_iterations) { - current = current - ms2day * 0.00035*(observed.elevation*((observed.altitude/8400.0)+0.46)-2.0); - observed = _observe(satrec, qth, current); - if (!observed) { - break; - } - iterations += 1; - } - iterations = 0; - while (aostime === 0 && iterations < max_iterations) { - if (!observed) { - break; - } - if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance - aostime = current; - } else { - current = current - ms2day * observed.elevation * Math.sqrt(observed.altitude)/530000.0; - observed = _observe(satrec, qth, current); - } - iterations += 1; - } - if (aostime === 0) { - return null; - } - return aostime -} - -function _findLOS(satrec, qth, start) { - var current = start; - var observed = _observe(satrec, qth, current); - var lostime = 0; - var iterations = 0; - - while (lostime === 0 && iterations < max_iterations) { - if (Math.abs(observed.elevation) < 0.50) { // this was 0.03 but switched to 0.50 for performance - lostime = current; - } else { - current = current + ms2day * observed.elevation * Math.sqrt(observed.altitude)/502500.0; - observed = _observe(satrec, qth, current); - if (!observed) { - break; - } - } - iterations += 1; - } - return lostime -} - -function _eci(satrec, date) { - date = new Date(date.valueOf()); - return satellite.propagate( - satrec, - date.getUTCFullYear(), - date.getUTCMonth() + 1, // months range 1-12 - date.getUTCDate(), - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds() - ); -} - - - -function _boundLongitude(longitude) { - while (longitude < -180) { - longitude += 360; - } - while (longitude > 180) { - longitude -= 360; - } - return longitude -} - -function _satEclipsed(pos, sol) { - var sd_earth = Math.asin(earth_radius / _magnitude(pos)); - var rho = _vecSub(sol, pos); - var sd_sun = Math.asin(solar_radius / rho.w); - var earth = _scalarMultiply(-1, pos); - var delta = _angle(sol, earth); - - var eclipseDepth = sd_earth - sd_sun - delta; - var eclipse; - if (sd_earth < sd_sun) { - eclipse = false; - } else if (eclipseDepth >= 0) { - eclipse = true; - } else { - eclipse = false; - } - return { - depth: eclipseDepth, - eclipsed: eclipse - } -} - -function _calculateSolarPosition(start) { - var time = start / ms2day + 2444238.5; // jul_utc - - var mjd = time - 2415020.0; - var year = 1900 + mjd / 365.25; - var T = (mjd + _deltaET(year) / (ms2day / 1000)) / 36525.0; - var M = deg2rad * ((358.47583 + ((35999.04975 * T) % 360) - (0.000150 + 0.0000033 * T) * Math.pow(T, 2)) % 360); - var L = deg2rad * ((279.69668 + ((36000.76892 * T) % 360) + 0.0003025 * Math.pow(T, 2)) % 360); - var e = 0.01675104 - (0.0000418 + 0.000000126 * T) * T; - var C = deg2rad * ((1.919460 - (0.004789 + 0.000014 * T) * T) * Math.sin(M) + (0.020094 - 0.000100 * T) * Math.sin(2 * M) + 0.000293 * Math.sin(3 * M)); - var O = deg2rad * ((259.18 - 1934.142 * T) % 360.0); - var Lsa = (L + C - deg2rad * (0.00569 - 0.00479 * Math.sin(O))) % (2 * Math.PI); - var nu = (M + C) % (2 * Math.PI); - var R = 1.0000002 * (1 - Math.pow(e, 2)) / (1 + e * Math.cos(nu)); - var eps = deg2rad * (23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * T) * T) * T + 0.00256 * Math.cos(O)); - var R = astro_unit * R; - - return { - x: R * Math.cos(Lsa), - y: R * Math.sin(Lsa) * Math.cos(eps), - z: R * Math.sin(Lsa) * Math.sin(eps), - w: R - } -} - -function _deltaET(year) { - return 26.465 + 0.747622 * (year - 1950) + 1.886913 * Math.sin((2 * Math.PI) * (year - 1975) / 33) -} - -function _vecSub(v1, v2) { - var vec = { - x: v1.x - v2.x, - y: v1.y - v2.y, - z: v1.z - v2.z - } - vec.w = _magnitude(vec); - return vec -} - -function _scalarMultiply(k, v) { - return { - x: k * v.x, - y: k * v.y, - z: k * v.z, - w: v.w ? Math.abs(k) * v.w : undefined - } -} - -function _magnitude(v) { - return Math.sqrt(Math.pow(v.x, 2) + Math.pow(v.y, 2) + Math.pow(v.z, 2)) -} - -function _angle(v1, v2) { - var dot = (v1.x * v2.x + v1.y * v2.y + v1.z * v2.z); - return Math.acos(dot / (_magnitude(v1) * _magnitude(v2))) -} - -// <---------------------------------------------------------------------------> -// PUBLIC FUNCTIONS -// <---------------------------------------------------------------------------> - - - -function observe(tle, qth, start) { - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - - if (_badSat(satrec, qth, start)) { - return null; - } - - return _observe(satrec, qth, start) -} - -function observes(tle, qth, start, end, interval) { - start = m_moment(start); - end = m_moment(end); - - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - - if (_badSat(satrec, qth, start)) { - return null; - } - - var observes = [], observed; - var iterations = 0; - while (start < end && iterations < max_iterations) { - observed = _observe(satrec, qth, start); - if (!observed) { - break; - } - observes.push(observed); - start.add(interval); - iterations += 1; - } - - return observes -} - -function transits(tle, qth, start, end, minElevation, maxTransits) { - start = m_moment(start); - end = m_moment(end); - - if (!minElevation) { - minElevation = defaultMinElevation; - } - - if (!maxTransits) { - maxTransits = max_iterations; - } - - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - if (_badSat(satrec, qth, start)) { - return []; - } - - var time = start.valueOf(); - var transits = []; - var nextTransit; - var iterations = 0; - - while (iterations < max_iterations && transits.length < maxTransits) { - transit = _quickPredict(satrec, qth, time); - if (!transit) { - break; - } - if (transit.end > end.valueOf()) { - break; - } - if (transit.end > start.valueOf() && transit.maxElevation > minElevation) { - transits.push(transit); - } - time = transit.end + 60 * 1000; - iterations += 1; - } - - return transits -} - -function transitSegment(tle, qth, start, end) { - start = m_moment(start); - end = m_moment(end); - - var tles = tle.split('\n'); - var satrec = satellite.twoline2satrec(tles[1], tles[2]); - if (_badSat(satrec, qth, start)) { - return []; - } - - return _quickPredict(satrec, qth, start.valueOf(), end.valueOf()); -} - diff --git a/src/types.ts b/src/types.ts index 352bc04..ec49cf4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,3 +27,9 @@ export type OrbitMeanElementsMessage = OMMJsonObjectV3; /** Alias for various datetime types */ export type DateTimeTypes = string | number | Date | DateTime + +/** Seconds */ +export type Seconds = number + +/** ISO8601 timestamp */ +export type Timestamp = string \ No newline at end of file From 5df476be6e46cd225a49941d6e4e09ef4d11eb9e Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Wed, 12 Aug 2026 16:30:58 -0600 Subject: [PATCH 07/14] Adding transit functionality --- src/__tests__/index.test.ts | 189 ---------------- src/__tests__/main.test.ts | 435 ++++++++++++++++++++++++++++++++++++ src/__tests__/utils.test.ts | 18 +- src/enums.ts | 13 ++ src/index.ts | 246 -------------------- src/interfaces.ts | 13 +- src/main.ts | 337 ++++++++++++++++++++++++++++ src/types.ts | 10 +- src/utils.ts | 293 ++++++++++++++++++++++-- tsconfig.json | 2 +- vite.config.ts | 2 +- 11 files changed, 1082 insertions(+), 476 deletions(-) delete mode 100644 src/__tests__/index.test.ts create mode 100644 src/__tests__/main.test.ts create mode 100644 src/enums.ts delete mode 100644 src/index.ts create mode 100644 src/main.ts diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts deleted file mode 100644 index 9334a27..0000000 --- a/src/__tests__/index.test.ts +++ /dev/null @@ -1,189 +0,0 @@ - -import { describe, expect, test } from 'vitest' -import { - dopplerFactor, - ecfToLookAngles, - eciToEcf, - eciToGeodetic, - geodeticToEcf, - gstime, - json2satrec, - OMMJsonObjectV3, - propagate, - radiansLat, - radiansLong, -} from 'satellite.js' - -import { satelliteObservation, satelliteObservations, AngularUnits } from '../index' -import { convertTleToOmm, footprintDiameter } from '../utils' - -// <---------------------------------------------------------------------------> -// TEST RESOURCES -// <---------------------------------------------------------------------------> - -const issTle = `0 ISS (ZARYA) -1 25544U 98067A 26219.02141064 .00004539 00000-0 89363-4 0 9992 -2 25544 51.6324 48.5171 0007293 20.5996 339.5285 15.49370096579630` - -const issOmm = { - "CCSDS_OMM_VERS": "3.0", - "COMMENT": "GENERATED VIA SPACE-TRACK.ORG API", - "CREATION_DATE": "2026-08-07T12:46:56", - "ORIGINATOR": "18 SPCS", - "OBJECT_NAME": "ISS (ZARYA)", - "OBJECT_ID": "1998-067A", - "CENTER_NAME": "EARTH", - "REF_FRAME": "TEME", - "TIME_SYSTEM": "UTC", - "MEAN_ELEMENT_THEORY": "SGP4", - "EPOCH": "2026-08-07T00:30:49.879296", - "MEAN_MOTION": "15.49370096", - "ECCENTRICITY": "0.00072933", - "INCLINATION": "51.6324", - "RA_OF_ASC_NODE": "48.5171", - "ARG_OF_PERICENTER": "20.5996", - "MEAN_ANOMALY": "339.5285", - "EPHEMERIS_TYPE": "0", - "CLASSIFICATION_TYPE": "U", - "NORAD_CAT_ID": "25544", - "ELEMENT_SET_NO": "999", - "REV_AT_EPOCH": "57963", - "BSTAR": "0.00008936277000", - "MEAN_MOTION_DOT": "0.00004539", - "MEAN_MOTION_DDOT": "0.0000000000000", - "SEMIMAJOR_AXIS": "6796.705", - "PERIOD": "92.941", - "APOAPSIS": "423.527", - "PERIAPSIS": "413.613", - "OBJECT_TYPE": "PAYLOAD", - "RCS_SIZE": "LARGE", - "COUNTRY_CODE": "CIS", - "LAUNCH_DATE": "1998-11-20", - "SITE": "TTMTR", - "DECAY_DATE": null, - "FILE": "5307055", - "GP_ID": "338621911", - "TLE_LINE0": "0 ISS (ZARYA)", - "TLE_LINE1": "1 25544U 98067A 26219.02141064 .00004539 00000-0 89363-4 0 9992", - "TLE_LINE2": "2 25544 51.6324 48.5171 0007293 20.5996 339.5285 15.49370096579630" -} - -const observationEpoch = '2026-08-07T00:30:49.879296Z' -const laterObservationEpoch = '2026-08-08T00:30:49.879296Z' - -// <---------------------------------------------------------------------------> -// TESTS -// <---------------------------------------------------------------------------> - -describe('satelliteObservation', () => { - test('returns a ground track with position units converted from satellite.js', () => { - const observed = satelliteObservation(issTle, observationEpoch, undefined, 0, AngularUnits.Radians) - const date = new Date(observationEpoch) - const satrec = json2satrec(convertTleToOmm(issTle)) - const propagated = propagate(satrec, date) - - if (!propagated) { - throw new Error('Expected propagation result') - } - - const gmst = gstime(date) - const geodetic = eciToGeodetic(propagated.position, gmst) - const ecef = eciToEcf(propagated.position, gmst) - - expect(observed.id).toBe('1998-067A') - expect(observed.name).toBe('ISS (ZARYA)') - expect(observed.noradCatalogId).toBe('25544') - expect(observed.epoch).toBe('2026-08-07T00:30:49.879Z') - expect(observed.decayed).toBe(false) - expect(observed.position?.eci?.x).toBeCloseTo(propagated.position.x, 10) - expect(observed.position?.ecef?.x).toBeCloseTo(ecef.x, 10) - expect(observed.position?.geo?.latitude).toBeCloseTo(geodetic.latitude, 10) - expect(observed.position?.geo?.longitude).toBeCloseTo(geodetic.longitude, 10) - expect(observed.position?.geo?.height).toBeCloseTo(geodetic.height, 10) - expect(observed.footprint).toBeCloseTo(footprintDiameter({ geo: geodetic }, 0), 10) - expect(observed.velocity?.eci?.x).toBeCloseTo(propagated.velocity.x, 10) - expect(observed.velocity?.ecef?.x).toBeCloseTo(eciToEcf(propagated.velocity, gmst).x, 10) - expect(observed.orbit?.velocity).toBeCloseTo( - Math.hypot(propagated.velocity.x, propagated.velocity.y, propagated.velocity.z), - 10, - ) - }) - - test('returns observer look angles in radians and slant range in kilometers', () => { - const observerGeodetic = { - latitude: radiansLat(15), - longitude: radiansLong(130), - height: 0.1, - } - const observed = satelliteObservation( - issOmm as OMMJsonObjectV3, - observationEpoch, - { geo: observerGeodetic }, - 0, - AngularUnits.Radians, - ) - const date = new Date(observationEpoch) - const satrec = json2satrec(issOmm as OMMJsonObjectV3) - const propagated = propagate(satrec, date) - - if (!propagated || !('observerPosition' in observed)) { - throw new Error('Expected observed track with observer data') - } - - const gmst = gstime(date) - const positionEcf = eciToEcf(propagated.position, gmst) - const velocityEcf = eciToEcf(propagated.velocity, gmst) - const observerEcf = geodeticToEcf(observerGeodetic) - const lookAngles = ecfToLookAngles(observerGeodetic, positionEcf) - - expect(observed.observerPosition!.ecef!.x).toBeCloseTo(observerEcf.x, 10) - expect(observed.azimuth).toBeCloseTo(lookAngles.azimuth, 10) - expect(observed.elevation).toBeCloseTo(lookAngles.elevation, 10) - expect(observed.slantRange).toBeCloseTo(lookAngles.rangeSat, 10) - expect(observed.dopplerFactor).toBeCloseTo(dopplerFactor(observerEcf, positionEcf, velocityEcf), 12) - }) - - test('predicts revolution count from the observation time', () => { - const observed = satelliteObservation(issOmm as OMMJsonObjectV3, laterObservationEpoch) - - expect(observed.orbit?.revolutionCount).toBe(57979) - }) -}) - -describe('satelliteObservations', () => { - test('returns one observation per datetime, matching individual satelliteObservation calls', () => { - const dateTimes = [observationEpoch, laterObservationEpoch] - const observerPosition = { - geo: { - latitude: radiansLat(15), - longitude: radiansLong(130), - height: 0.1, - }, - } - - const observed = satelliteObservations( - issOmm as OMMJsonObjectV3, - dateTimes, - observerPosition, - 0, - AngularUnits.Radians, - ) - - expect(observed).toHaveLength(dateTimes.length) - - dateTimes.forEach((dateTime, index) => { - const expected = satelliteObservation( - issOmm as OMMJsonObjectV3, - dateTime, - observerPosition, - 0, - AngularUnits.Radians, - ) - expect(observed[index]).toStrictEqual(expected) - }) - }) - - test('returns an empty array when no datetimes are provided', () => { - expect(satelliteObservations(issOmm as OMMJsonObjectV3, [])).toStrictEqual([]) - }) -}) diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts new file mode 100644 index 0000000..1d83123 --- /dev/null +++ b/src/__tests__/main.test.ts @@ -0,0 +1,435 @@ + +import { DateTime } from 'luxon' +import { describe, expect, test } from 'vitest' +import { + dopplerFactor, + ecfToLookAngles, + eciToEcf, + eciToGeodetic, + geodeticToEcf, + gstime, + json2satrec, + OMMJsonObjectV3, + propagate, + radiansToDegrees, + radiansLat, + radiansLong, +} from 'satellite.js' + +import { satelliteObservation, satelliteObservations, satelliteTransits, type UnitOptions } from '../main' +import { AngularUnits, TimestampType } from '../enums' +import { convertTleToOmm, footprintDiameter } from '../utils' + +// <---------------------------------------------------------------------------> +// TEST RESOURCES +// <---------------------------------------------------------------------------> + +const issTle = `0 ISS (ZARYA) +1 25544U 98067A 26219.02141064 .00004539 00000-0 89363-4 0 9992 +2 25544 51.6324 48.5171 0007293 20.5996 339.5285 15.49370096579630` + +const issOmm = { + "CCSDS_OMM_VERS": "3.0", + "COMMENT": "GENERATED VIA SPACE-TRACK.ORG API", + "CREATION_DATE": "2026-08-07T12:46:56", + "ORIGINATOR": "18 SPCS", + "OBJECT_NAME": "ISS (ZARYA)", + "OBJECT_ID": "1998-067A", + "CENTER_NAME": "EARTH", + "REF_FRAME": "TEME", + "TIME_SYSTEM": "UTC", + "MEAN_ELEMENT_THEORY": "SGP4", + "EPOCH": "2026-08-07T00:30:49.879296", + "MEAN_MOTION": "15.49370096", + "ECCENTRICITY": "0.00072933", + "INCLINATION": "51.6324", + "RA_OF_ASC_NODE": "48.5171", + "ARG_OF_PERICENTER": "20.5996", + "MEAN_ANOMALY": "339.5285", + "EPHEMERIS_TYPE": "0", + "CLASSIFICATION_TYPE": "U", + "NORAD_CAT_ID": "25544", + "ELEMENT_SET_NO": "999", + "REV_AT_EPOCH": "57963", + "BSTAR": "0.00008936277000", + "MEAN_MOTION_DOT": "0.00004539", + "MEAN_MOTION_DDOT": "0.0000000000000", + "SEMIMAJOR_AXIS": "6796.705", + "PERIOD": "92.941", + "APOAPSIS": "423.527", + "PERIAPSIS": "413.613", + "OBJECT_TYPE": "PAYLOAD", + "RCS_SIZE": "LARGE", + "COUNTRY_CODE": "CIS", + "LAUNCH_DATE": "1998-11-20", + "SITE": "TTMTR", + "DECAY_DATE": null, + "FILE": "5307055", + "GP_ID": "338621911", + "TLE_LINE0": "0 ISS (ZARYA)", + "TLE_LINE1": "1 25544U 98067A 26219.02141064 .00004539 00000-0 89363-4 0 9992", + "TLE_LINE2": "2 25544 51.6324 48.5171 0007293 20.5996 339.5285 15.49370096579630" +} + +const observationEpoch = '2026-08-07T00:30:49.879296Z' +const laterObservationEpoch = '2026-08-08T00:30:49.879296Z' +const transitWindowStart = '2026-08-07T00:00:00Z' +const transitWindowStop = '2026-08-08T00:00:00Z' + +const unitOptionCases = [undefined, AngularUnits.Degrees, AngularUnits.Radians].flatMap((angular) => + [undefined, TimestampType.ISO8601, TimestampType.Unix, TimestampType.Date, TimestampType.DateTime].map( + (timestamp) => { + const unitOptions: UnitOptions = {} + + if (angular !== undefined) { + unitOptions.angular = angular + } + + if (timestamp !== undefined) { + unitOptions.timestamp = timestamp + } + + return { + name: `angular=${angular ?? 'default'}, timestamp=${timestamp ?? 'default'}`, + unitOptions: Object.keys(unitOptions).length > 0 ? unitOptions : undefined, + angular: angular ?? AngularUnits.Degrees, + timestamp: timestamp ?? TimestampType.ISO8601, + } + }, + ), +) + +function expectTimestampValue(actual: unknown, epoch: string, timestampType: TimestampType): void { + const expected = DateTime.fromISO(epoch, { setZone: true }) + + switch (timestampType) { + case TimestampType.ISO8601: + expect(actual).toBe(expected.toISO()) + break + + case TimestampType.Unix: + expect(actual).toBe(expected.toMillis()) + break + + case TimestampType.Date: + expect(actual).toBeInstanceOf(Date) + expect((actual as Date).toISOString()).toBe(expected.toJSDate().toISOString()) + break + + case TimestampType.DateTime: + expect(DateTime.isDateTime(actual)).toBe(true) + expect((actual as DateTime).toISO()).toBe(expected.toISO()) + break + } +} + +function expectTimestampType(actual: unknown, timestampType: TimestampType): void { + switch (timestampType) { + case TimestampType.ISO8601: + expect(typeof actual).toBe('string') + break + + case TimestampType.Unix: + expect(typeof actual).toBe('number') + break + + case TimestampType.Date: + expect(actual).toBeInstanceOf(Date) + break + + case TimestampType.DateTime: + expect(DateTime.isDateTime(actual)).toBe(true) + break + } +} + +function timestampToMillis(timestamp: unknown): number { + if (DateTime.isDateTime(timestamp)) { + return timestamp.toMillis() + } + + if (timestamp instanceof Date) { + return timestamp.getTime() + } + + if (typeof timestamp === 'string') { + return DateTime.fromISO(timestamp, { setZone: true }).toMillis() + } + + if (typeof timestamp === 'number') { + return timestamp + } + + throw new Error('Unsupported timestamp type') +} + +function expectedAngle(angleRadians: number, angular: AngularUnits): number { + return angular === AngularUnits.Degrees ? radiansToDegrees(angleRadians) : angleRadians +} + +function observerPositionFor(angular: AngularUnits) { + return { + geo: angular === AngularUnits.Degrees + ? { + latitude: 15, + longitude: 130, + height: 0.1, + } + : { + latitude: radiansLat(15), + longitude: radiansLong(130), + height: 0.1, + }, + } +} + +const transitUnitOptionCases = [ + { + name: 'default', + unitOptions: undefined, + angular: AngularUnits.Degrees, + timestamp: TimestampType.ISO8601, + }, + { + name: 'unix timestamps', + unitOptions: { timestamp: TimestampType.Unix }, + angular: AngularUnits.Degrees, + timestamp: TimestampType.Unix, + }, + { + name: 'date timestamps', + unitOptions: { timestamp: TimestampType.Date }, + angular: AngularUnits.Degrees, + timestamp: TimestampType.Date, + }, + { + name: 'radians datetime', + unitOptions: { angular: AngularUnits.Radians, timestamp: TimestampType.DateTime }, + angular: AngularUnits.Radians, + timestamp: TimestampType.DateTime, + }, +] + +// <---------------------------------------------------------------------------> +// TESTS +// <---------------------------------------------------------------------------> + +describe('satelliteObservation', () => { + test.each(unitOptionCases)('returns a ground track for $name', ({ unitOptions, angular, timestamp }) => { + const observed = satelliteObservation(issTle, observationEpoch, undefined, unitOptions) + const date = new Date(observationEpoch) + const satrec = json2satrec(convertTleToOmm(issTle)) + const propagated = propagate(satrec, date) + + if (!propagated) { + throw new Error('Expected propagation result') + } + + const gmst = gstime(date) + const geodetic = eciToGeodetic(propagated.position, gmst) + const ecef = eciToEcf(propagated.position, gmst) + + expect(observed.id).toBe('1998-067A') + expect(observed.name).toBe('ISS (ZARYA)') + expect(observed.noradCatalogId).toBe('25544') + expectTimestampValue(observed.epoch, observationEpoch, timestamp) + expect(observed.decayed).toBe(false) + expect(observed.position?.eci?.x).toBeCloseTo(propagated.position.x, 10) + expect(observed.position?.ecef?.x).toBeCloseTo(ecef.x, 10) + expect(observed.position?.geo?.latitude).toBeCloseTo(expectedAngle(geodetic.latitude, angular), 10) + expect(observed.position?.geo?.longitude).toBeCloseTo(expectedAngle(geodetic.longitude, angular), 10) + expect(observed.position?.geo?.height).toBeCloseTo(geodetic.height, 10) + expect(observed.footprint).toBeCloseTo(footprintDiameter({ geo: geodetic }, 0), 10) + expect(observed.velocity?.eci?.x).toBeCloseTo(propagated.velocity.x, 10) + expect(observed.velocity?.ecef?.x).toBeCloseTo(eciToEcf(propagated.velocity, gmst).x, 10) + expect(observed.orbit?.phase).toBeCloseTo( + expectedAngle(((propagated.meanElements.mm % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI), angular), + 10, + ) + expect(observed.orbit?.velocity).toBeCloseTo( + Math.hypot(propagated.velocity.x, propagated.velocity.y, propagated.velocity.z), + 10, + ) + }) + + test.each(unitOptionCases)('returns observer look angles for $name', ({ unitOptions, angular, timestamp }) => { + const observerPosition = observerPositionFor(angular) + const observerGeodetic = { + latitude: radiansLat(15), + longitude: radiansLong(130), + height: 0.1, + } + const observed = satelliteObservation( + issOmm as OMMJsonObjectV3, + observationEpoch, + observerPosition, + unitOptions, + ) + const date = new Date(observationEpoch) + const satrec = json2satrec(issOmm as OMMJsonObjectV3) + const propagated = propagate(satrec, date) + + if (!propagated || !('observerPosition' in observed)) { + throw new Error('Expected observed track with observer data') + } + + const gmst = gstime(date) + const positionEcf = eciToEcf(propagated.position, gmst) + const velocityEcf = eciToEcf(propagated.velocity, gmst) + const observerEcf = geodeticToEcf(observerGeodetic) + const lookAngles = ecfToLookAngles(observerGeodetic, positionEcf) + + expectTimestampValue(observed.epoch, observationEpoch, timestamp) + expect(observed.observerPosition!.ecef!.x).toBeCloseTo(observerEcf.x, 10) + expect(observed.observerPosition!.geo!.latitude).toBeCloseTo(expectedAngle(observerGeodetic.latitude, angular), 10) + expect(observed.observerPosition!.geo!.longitude).toBeCloseTo(expectedAngle(observerGeodetic.longitude, angular), 10) + expect(observed.azimuth).toBeCloseTo(expectedAngle(lookAngles.azimuth, angular), 10) + expect(observed.elevation).toBeCloseTo(expectedAngle(lookAngles.elevation, angular), 10) + expect(observed.slantRange).toBeCloseTo(lookAngles.rangeSat, 10) + expect(observed.dopplerFactor).toBeCloseTo(dopplerFactor(observerEcf, positionEcf, velocityEcf), 12) + }) + + test('predicts revolution count from the observation time', () => { + const observed = satelliteObservation(issOmm as OMMJsonObjectV3, laterObservationEpoch) + + expect(observed.orbit?.revolutionCount).toBe(57979) + }) +}) + +describe('satelliteObservations', () => { + test.each(unitOptionCases)('returns one observation per datetime for $name', ({ unitOptions, angular, timestamp }) => { + const dateTimes = [observationEpoch, laterObservationEpoch] + const observerPosition = observerPositionFor(angular) + + const observed = satelliteObservations( + issOmm as OMMJsonObjectV3, + dateTimes, + observerPosition, + unitOptions, + ) + + expect(observed).toHaveLength(dateTimes.length) + + dateTimes.forEach((dateTime, index) => { + const expected = satelliteObservation( + issOmm as OMMJsonObjectV3, + dateTime, + observerPosition, + unitOptions, + ) + + const { epoch: actualEpoch, ...actualRest } = observed[index] + const { epoch: expectedEpoch, ...expectedRest } = expected + + expect(actualRest).toStrictEqual(expectedRest) + expectTimestampValue(actualEpoch, dateTime, timestamp) + expectTimestampValue(expectedEpoch, dateTime, timestamp) + }) + }) + + test('returns an empty array when no datetimes are provided', () => { + expect(satelliteObservations(issOmm as OMMJsonObjectV3, [])).toStrictEqual([]) + }) +}) + +describe('satelliteTransits', () => { + test.each(transitUnitOptionCases)('returns ordered transit events for $name', ({ unitOptions, angular, timestamp }) => { + const observerPosition = observerPositionFor(angular) + const transits = satelliteTransits( + issOmm as OMMJsonObjectV3, + observerPosition, + transitWindowStart, + transitWindowStop, + undefined, + unitOptions, + ) + + expect(transits.length).toBeGreaterThan(0) + + transits.forEach((transit) => { + expectTimestampType(transit.start, timestamp) + expectTimestampType(transit.stop, timestamp) + expectTimestampType(transit.aos.epoch, timestamp) + expectTimestampType(transit.los.epoch, timestamp) + expectTimestampType(transit.peak.epoch, timestamp) + expectTimestampType(transit.tca.epoch, timestamp) + + const startMillis = timestampToMillis(transit.start) + const stopMillis = timestampToMillis(transit.stop) + const aosMillis = timestampToMillis(transit.aos.epoch) + const losMillis = timestampToMillis(transit.los.epoch) + const peakMillis = timestampToMillis(transit.peak.epoch) + const tcaMillis = timestampToMillis(transit.tca.epoch) + + expect(startMillis).toBeCloseTo(aosMillis, 0) + expect(stopMillis).toBeCloseTo(losMillis, 0) + expect(startMillis).toBeLessThan(stopMillis) + expect(peakMillis).toBeGreaterThanOrEqual(startMillis) + expect(peakMillis).toBeLessThanOrEqual(stopMillis) + expect(tcaMillis).toBeGreaterThanOrEqual(startMillis) + expect(tcaMillis).toBeLessThanOrEqual(stopMillis) + expect(Math.abs(transit.duration - ((stopMillis - startMillis) / 1000))).toBeLessThan(0.002) + + const aosObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.aos.epoch, observerPosition, unitOptions) + const losObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.los.epoch, observerPosition, unitOptions) + const peakObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.peak.epoch, observerPosition, unitOptions) + const tcaObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.tca.epoch, observerPosition, unitOptions) + + expect(aosObservation.azimuth).toBeCloseTo(transit.aos.azimuth, 10) + expect(aosObservation.elevation).toBeCloseTo(transit.aos.elevation, 10) + expect(aosObservation.slantRange).toBeCloseTo(transit.aos.slantRange, 10) + expect(losObservation.azimuth).toBeCloseTo(transit.los.azimuth, 10) + expect(losObservation.elevation).toBeCloseTo(transit.los.elevation, 10) + expect(losObservation.slantRange).toBeCloseTo(transit.los.slantRange, 10) + expect(peakObservation.azimuth).toBeCloseTo(transit.peak.azimuth, 10) + expect(peakObservation.elevation).toBeCloseTo(transit.peak.elevation, 10) + expect(peakObservation.slantRange).toBeCloseTo(transit.peak.slantRange, 10) + expect(tcaObservation.azimuth).toBeCloseTo(transit.tca.azimuth, 10) + expect(tcaObservation.elevation).toBeCloseTo(transit.tca.elevation, 10) + expect(tcaObservation.slantRange).toBeCloseTo(transit.tca.slantRange, 10) + + expect(transit.peak.elevation).toBeGreaterThanOrEqual(transit.aos.elevation - 1e-6) + expect(transit.peak.elevation).toBeGreaterThanOrEqual(transit.los.elevation - 1e-6) + expect(transit.tca.slantRange).toBeLessThanOrEqual(transit.aos.slantRange + 1e-6) + expect(transit.tca.slantRange).toBeLessThanOrEqual(transit.los.slantRange + 1e-6) + }) + }) + + test('supports a minimum elevation threshold', () => { + const observerPosition = observerPositionFor(AngularUnits.Degrees) + const allTransits = satelliteTransits( + issOmm as OMMJsonObjectV3, + observerPosition, + transitWindowStart, + transitWindowStop, + ) + const filteredTransits = satelliteTransits( + issOmm as OMMJsonObjectV3, + observerPosition, + transitWindowStart, + transitWindowStop, + 20, + ) + + expect(filteredTransits.length).toBeLessThanOrEqual(allTransits.length) + + filteredTransits.forEach((transit) => { + expect(transit.peak.elevation).toBeGreaterThanOrEqual(20) + expect(transit.aos.elevation).toBeCloseTo(20, 2) + expect(transit.los.elevation).toBeCloseTo(20, 2) + }) + }) + + test('returns an empty array when the time range is invalid', () => { + const observerPosition = observerPositionFor(AngularUnits.Degrees) + + expect( + satelliteTransits( + issOmm as OMMJsonObjectV3, + observerPosition, + transitWindowStop, + transitWindowStart, + ), + ).toStrictEqual([]) + }) +}) diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 38a359b..cd1239a 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import { ecfToEci, eciToEcf, eciToGeodetic, geodeticToEcf } from 'satellite.js' import { WGS84, deg2rad } from '../constants' -import { AngularUnits } from '../index' +import { AngularUnits } from '../enums' import { convertTleToOmm, earthCentralAngle, @@ -12,7 +12,7 @@ import { inferPosition, inferVelocity, localEarthRadius, - parseDateTime, + parseTimestamp, predictedRevolutionCount, } from '../utils' @@ -100,9 +100,9 @@ describe('utils.footprintDiameter', () => { }) }) -describe('utils.parseDateTime', () => { +describe('utils.parseTimestamp', () => { test('parses datetime strings as UTC when no timezone is provided', () => { - const parsedDate = parseDateTime('2026-07-15T14:30:42.137') + const parsedDate = parseTimestamp('2026-07-15T14:30:42.137') expect(parsedDate.year).toBe(2026) expect(parsedDate.month).toBe(7) @@ -115,7 +115,7 @@ describe('utils.parseDateTime', () => { }) test('preserves timezone data when the datetime string includes it', () => { - const parsedDate = parseDateTime('2026-07-15T14:30:42.137-04:00') + const parsedDate = parseTimestamp('2026-07-15T14:30:42.137-04:00') expect(parsedDate.year).toBe(2026) expect(parsedDate.month).toBe(7) @@ -130,7 +130,7 @@ describe('utils.parseDateTime', () => { test('parses unix timestamps', () => { const timestamp = Date.UTC(2026, 6, 15, 14, 30, 42, 137) - const parsedDate = parseDateTime(timestamp) + const parsedDate = parseTimestamp(timestamp) expect(parsedDate.toMillis()).toBe(timestamp) expect(parsedDate.zoneName).toBe('UTC') @@ -138,7 +138,7 @@ describe('utils.parseDateTime', () => { test('parses JS Date objects', () => { const date = new Date(2026, 6, 15, 14, 30, 42, 137) - const parsedDate = parseDateTime(date) + const parsedDate = parseTimestamp(date) const expected = DateTime.fromJSDate(date) expect(parsedDate.toMillis()).toBe(date.getTime()) @@ -155,11 +155,11 @@ describe('utils.parseDateTime', () => { test('returns existing luxon DateTime instances unchanged', () => { const dateTime = DateTime.utc(2026, 7, 15, 14, 30, 42, 137) - expect(parseDateTime(dateTime)).toBe(dateTime) + expect(parseTimestamp(dateTime)).toBe(dateTime) }) test('throws errors for unsupported types', () => { - expect(() => parseDateTime({} as never)).toThrow('Unsupported datetime type') + expect(() => parseTimestamp({} as never)).toThrow('Unsupported datetime type') }) }) diff --git a/src/enums.ts b/src/enums.ts new file mode 100644 index 0000000..fbb37c9 --- /dev/null +++ b/src/enums.ts @@ -0,0 +1,13 @@ +/** Set the angular units for the inputs and outputs */ +export enum AngularUnits { + Degrees = 'DEGREES', + Radians = 'RADIANS' +} + +/** Set the time units for the inputs and outputs */ +export enum TimestampType { + Unix = 'UNIX', + ISO8601 = 'ISO8601', + Date = 'DATE', + DateTime = 'DATETIME' +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 12df057..0000000 --- a/src/index.ts +++ /dev/null @@ -1,246 +0,0 @@ -// jspredict v2.0.0 -// https://github.com/nsat/jspredict - -// Copyright (c) 2026, Spire Global Inc -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above copyright -// notice, this list of conditions and the following disclaimer in the -// documentation and/or other materials provided with the distribution. -// * Neither the name of the Spire Global Inc nor the -// names of its contributors may be used to endorse or promote products -// derived from this software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL -// Spire Global Inc BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -// USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -// OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -// SUCH DAMAGE. - -import { SatelliteObservation, Position } from "./interfaces"; -import { astronomicalUnit, deg2rad, rad2deg } from "./constants"; -import { TwoLineElement, DateTimeTypes, OrbitMeanElementsMessage, Degrees, Radians } from "./types"; - -// Re-export the public interfaces so consumers can import them from the module root -export type { - Position, - Velocity, - Orbit, - SatelliteObservation, - SatelliteTransit, -} from "./interfaces"; - -// Re-export the public types so consumers can import them from the module root -export type { - Kilometers, - KilometersPerSecond, - KilometersPerHour, - Radians, - Degrees, - AstronomialUnits, - TwoLineElement, - OrbitMeanElementsMessage, - DateTimeTypes, -} from "./types"; - -import { - footprintDiameter, - greenwichMeanSiderealTime, - parseDateTime, - parseSatelliteElements, - predictedRevolutionCount, - inferPosition, - inferVelocity, - vectorMagnitude, - convertGeodeticToDegrees, - isGeostationary, - betaAngle -} from "./utils"; -import { - dopplerFactor, - ecfToLookAngles, - jday, - propagate, - radiansToDegrees, - SatRecError, - shadowFraction, - sunPos, -} from "satellite.js"; - -/** Set the angular units for the inputs and outputs */ -export enum AngularUnits { - Degrees = 'DEGREES', - Radians = 'RADIANS' -} - -/** - * Calculates satellite observation parameters such as postion, velocity, and - * observer look angles. - * @param satelliteElements a TLE or OMM of the satellite's orbital elements - * @param dateTime: an ISO datetime string, unix timestamp, Javascript Date - * object, or luxon DateTime object specifying the observation time - * @param observerPosition: (optional) a position object specifying the location - * of a satellite observer - * @param minimumElevationAngle: (optional) minimum horizon elevation angle used - * for calculating footprint and acquisition of signal (AOS), default is 0 - * degrees above the horizon - * @param angularUnits: (optional) specifies which angular units are used for - * inputs and outputs, default is Degrees - * Returns SatelliteObservation object - */ -export function satelliteObservation( - satelliteElements: TwoLineElement | OrbitMeanElementsMessage, - dateTime: DateTimeTypes, - observerPosition?: Position, - minimumElevationAngle: Degrees | Radians = 0, - angularUnits: AngularUnits = AngularUnits.Degrees -): SatelliteObservation { - const dt = parseDateTime(dateTime) - const gmst = greenwichMeanSiderealTime(dt) - const [omm, satrec] = parseSatelliteElements(satelliteElements) - - // Returns the satellite position and velocity in ECI coordinations - const satPropagation = propagate(satrec, dt.toJSDate()) - - // Check for errors - if (satPropagation === null) { - switch (satrec.error) { - - case SatRecError.MeanEccentricityOutOfRange: - throw new Error('Orbit eccentricity is out of range for SGP4 propagation model') - - case SatRecError.MeanMotionBelowZero: - throw new Error('Orbit mean motion is below zero') - - case SatRecError.PerturbedEccentricityOutOfRange: - throw new Error('Predicted orbit eccentricity is out of range for SGP4 propagation model') - - case SatRecError.SemiLatusRectumBelowZero: - throw new Error('Predicted orbit has collapsed mathematically') - - case SatRecError.Decayed: - return { - id: omm.OBJECT_ID, - name: omm.OBJECT_NAME, - noradCatalogId: omm.NORAD_CAT_ID as string, - orbitalModel: omm.MEAN_ELEMENT_THEORY, - epoch: dt.setZone('UTC').toISO(), - decayed: true, - } - } - - throw new Error('Satellite propagation failed') - } - - // Calculate the satellite's position and velocity in other coordinate frames - const satPosition = inferPosition({ eci: satPropagation.position }, gmst, angularUnits) - const satVelocity = inferVelocity({ eci: satPropagation.velocity }, gmst) - - // Calculate the sun's position in kilometers - const sunEciAU = sunPos(jday(dt.toJSDate())).rsun - const sunEci = { - x: sunEciAU.x * astronomicalUnit, - y: sunEciAU.y * astronomicalUnit, - z: sunEciAU.z * astronomicalUnit - } - const sunPosition = inferPosition({ eci: sunEci }, gmst, angularUnits) - - // Calculate the eclipse factor - const eclipseFactor = shadowFraction(sunEciAU, satPosition.eci!) - - // Calculate the beta angle (radians) between the orbital plane and the Sun - const betaAngleRadians = betaAngle(satPropagation.meanElements, sunEci) - - // Calculate the satellite's footprint - const footprint = (angularUnits === AngularUnits.Degrees) - ? footprintDiameter(satPosition, minimumElevationAngle * deg2rad) - : footprintDiameter(satPosition, minimumElevationAngle) - - // Calculate the orbital phase from the mean anomaly, normalized to [0, 2*PI). This matches - // the phase definition used by the original predict/pypredict libraries, where phase is - // computed as (xlt - xnode - omgadf) which reduces to the mean anomaly plus small - // long-period/secular corrections, measured from perigee. - const twoPi = 2 * Math.PI - const phaseRadians = ((satPropagation.meanElements.mm % twoPi) + twoPi) % twoPi - - // Calculate the ground track parameters - const observation: SatelliteObservation = { - id: omm.OBJECT_ID, - name: omm.OBJECT_NAME, - noradCatalogId: omm.NORAD_CAT_ID as string, - orbitalModel: omm.MEAN_ELEMENT_THEORY, - epoch: dt.toUTC().toISO(), - gmst: gmst, - position: (angularUnits === AngularUnits.Degrees) ? convertGeodeticToDegrees(satPosition) : satPosition, - velocity: satVelocity, - footprint: footprint, - orbit: { - revolutionCount: predictedRevolutionCount(omm, dt), - phase: (angularUnits === AngularUnits.Degrees) ? phaseRadians * rad2deg : phaseRadians, - phase256: phaseRadians * (256 / twoPi), - velocity: vectorMagnitude(satVelocity.eci!), - }, - decayed: false, - geostationary: isGeostationary(satPropagation.meanElements), - sunlit: eclipseFactor < 1, - sunPosition: (angularUnits === AngularUnits.Degrees) ? convertGeodeticToDegrees(sunPosition) : sunPosition, - betaAngle: (angularUnits === AngularUnits.Degrees) ? betaAngleRadians * rad2deg : betaAngleRadians, - eclipseFactor: eclipseFactor, - } - - if (!observerPosition) { - return observation - } - - // If we have an observer, calculate the look angles of the satellite - const observerInferedPosition = inferPosition(observerPosition, gmst, angularUnits) - const observerLookAngles = ecfToLookAngles(observerInferedPosition.geo!, satPosition.ecef!) - - return { - ...observation, - observerPosition: (angularUnits === AngularUnits.Degrees) ? convertGeodeticToDegrees(observerInferedPosition) : observerInferedPosition, - azimuth: (angularUnits === AngularUnits.Degrees) ? radiansToDegrees(observerLookAngles.azimuth) : observerLookAngles.azimuth, - elevation: (angularUnits === AngularUnits.Degrees) ? radiansToDegrees(observerLookAngles.elevation) : observerLookAngles.elevation, - slantRange: observerLookAngles.rangeSat, - dopplerFactor: dopplerFactor(observerInferedPosition.ecef!, satPosition.ecef!, satVelocity.ecef!), - } -} - -/** - * Calculates satellite observation parameters such as postion, velocity, and - * observer look angles at the specified datetimes. - * @param satelliteElements a TLE or OMM of the satellite's orbital elements - * @param dateTimes: an array of ISO datetime strings, unix timestamps, - * Javascript Date objects, or luxon DateTime objects specifying the - * observation times - * @param observerPosition: (optional) a position object specifying the location - * of a satellite observer - * @param minimumElevationAngle: (optional) minimum horizon elevation angle used - * for calculating footprint and acquisition of signal (AOS), default is 0 - * degrees above the horizon - * @param angularUnits: (optional) specifies which angular units are used for - * inputs and outputs, default is Degrees - * Returns array of SatelliteObservation objects - */ -export function satelliteObservations( - satelliteElements: TwoLineElement | OrbitMeanElementsMessage, - dateTimes: DateTimeTypes[], - observerPosition?: Position, - minimumElevationAngle: Degrees | Radians = 0, - angularUnits: AngularUnits = AngularUnits.Degrees -): SatelliteObservation[] { - return dateTimes.map((dateTime) => - satelliteObservation(satelliteElements, dateTime, observerPosition, minimumElevationAngle, angularUnits) - ) -} \ No newline at end of file diff --git a/src/interfaces.ts b/src/interfaces.ts index 9d538d0..7650196 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,14 @@ import { EcfVec3, EciVec3, GeodeticLocation } from "satellite.js" -import { Radians, Degrees, Kilometers, KilometersPerSecond, Seconds, Timestamp } from "./types.ts" +import { Radians, Degrees, Kilometers, KilometersPerSecond, Timestamp, Seconds } from "./types.ts" +import { AngularUnits, TimestampType } from "./enums.ts" + +export interface UnitOptions { + /** Set the unit type for angular measurements */ + angular?: AngularUnits + + /** Set the output type for timestamps */ + timestamp?: TimestampType +} /** Position parameters */ export interface Position { @@ -60,7 +69,7 @@ export interface SatelliteObservation { noradCatalogId: string | number /** Prediction UTC timestamp (ISO8601 format) */ - epoch: Timestamp | null + epoch?: Timestamp /** Prediction Greenwich Sidereal Time (GMST) */ gmst?: Radians diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..b90ef56 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,337 @@ +// jspredict v2.0.0 +// https://github.com/nsat/jspredict + +// Copyright (c) 2026, Spire Global Inc +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// * Neither the name of the Spire Global Inc nor the +// names of its contributors may be used to endorse or promote products +// derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// Spire Global Inc BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +// USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +// OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +// SUCH DAMAGE. + +import { DateTime } from "luxon" + +import { + Position, + SatelliteObservation, + SatelliteTransit, + UnitOptions, +} from "./interfaces" +import { + Degrees, + OrbitMeanElementsMessage, + Radians, + Timestamp, + TwoLineElement, +} from "./types" + +import { + buildTransitEvent, + computeSatelliteObservation, + defaultUnitOptions, + formatTimestamp, + isVisible, + parseSatelliteElements, + parseTimestamp, + refineHorizonCrossing, + refineTransitExtremum, + toDateTime, + transitObservation, +} from "./utils" + +const transitSearchStepMs = 60 * 1000 +const transitSampleStepMs = 15 * 1000 +const crossingRefineIterations = 24 +const extremumRefineIterations = 24 + +/** + * Calculates satellite observation parameters such as postion, velocity, and + * observer look angles. + * @param satelliteElements a TLE or OMM of the satellite's orbital elements + * @param epoch: the date and time of the satellite observation, expressed as + * either a Unix timestamp, an ISO8601 string, a standard Javascript Date + * object, or luxon DateTime object + * @param observerPosition: (optional) a position object specifying the location + * of a satellite observer + * @param unitOptions: (optional) configure input/output units + * Returns SatelliteObservation object + */ +export function satelliteObservation( + satelliteElements: TwoLineElement | OrbitMeanElementsMessage, + epoch: Timestamp, + observerPosition?: Position, + unitOptions?: UnitOptions, +): SatelliteObservation { + const datetime = parseTimestamp(epoch) + const [omm, satrec] = parseSatelliteElements(satelliteElements) + + return computeSatelliteObservation(omm, satrec, datetime, observerPosition, unitOptions) +} + +/** + * Calculates satellite observation parameters such as postion, velocity, and + * observer look angles at the specified times. + * @param satelliteElements a TLE or OMM of the satellite's orbital elements + * @param epochArray: an array of timestamps specifying the desired observation + * times, the array may contain Unix timestamps, ISO8601 strings, Javascript + * Date objects, or luxon DateTime objects + * @param observerPosition: (optional) a position object specifying the location + * of a satellite observer + * @param unitOptions: (optional) configure input/output units + * Returns array of SatelliteObservation objects + */ +export function satelliteObservations( + satelliteElements: TwoLineElement | OrbitMeanElementsMessage, + epochArray: Timestamp[], + observerPosition?: Position, + unitOptions?: UnitOptions, +): SatelliteObservation[] { + const [omm, satrec] = parseSatelliteElements(satelliteElements) + + return epochArray.map((epoch) => + computeSatelliteObservation(omm, satrec, parseTimestamp(epoch), observerPosition, unitOptions), + ) +} + +/** + * Calculate the satellite transits for a given location over specified time range. + * @param satelliteElements a TLE or OMM of the satellite's orbital elements + * @param observerPosition: a position object specifying the location of the + * satellite observer + * @param startTime: a timestamp representing the start time of the transits + * @param stopTime: a timestamp representing the stop time of the transits + * @param minElevationAngle: (optional) the minimum elevation threshold used to + * define the start and stop of a pass + * @param unitOptions: (optional) configure input/output units + */ +export function satelliteTransits( + satelliteElements: TwoLineElement | OrbitMeanElementsMessage, + observerPosition: Position, + startTime: Timestamp, + stopTime: Timestamp, + minElevationAngle: Degrees | Radians = 0, + unitOptions?: UnitOptions, +): SatelliteTransit[] { + const unitOpts: UnitOptions = { ...defaultUnitOptions, ...(unitOptions ?? {}) } + const startDateTime = parseTimestamp(startTime) + const stopDateTime = parseTimestamp(stopTime) + + if (stopDateTime.toMillis() <= startDateTime.toMillis()) { + return [] + } + + const [omm, satrec] = parseSatelliteElements(satelliteElements) + const observeAt = (datetime: DateTime): SatelliteObservation => + transitObservation(omm, satrec, datetime, observerPosition, unitOpts.angular!) + + const buildTransit = ( + aosTime: DateTime, + aosObservation: SatelliteObservation, + ): { transit: SatelliteTransit; nextTime: DateTime } => { + const samples = [{ time: aosTime, observation: aosObservation }] + let lastTime = aosTime + let lastObservation = aosObservation + let endTime = aosTime + let endObservation = aosObservation + + while (lastTime.toMillis() < stopDateTime.toMillis()) { + const nextTime = toDateTime(Math.min(lastTime.toMillis() + transitSampleStepMs, stopDateTime.toMillis())) + + if (nextTime.toMillis() === lastTime.toMillis()) { + break + } + + const nextObservation = observeAt(nextTime) + + if (nextObservation.decayed) { + endTime = lastTime + endObservation = lastObservation + break + } + + if (!isVisible(nextObservation, minElevationAngle)) { + const los = refineHorizonCrossing( + lastTime, + lastObservation, + nextTime, + nextObservation, + minElevationAngle, + observeAt, + crossingRefineIterations, + ) + endTime = los.time + endObservation = los.observation + break + } + + samples.push({ time: nextTime, observation: nextObservation }) + lastTime = nextTime + lastObservation = nextObservation + endTime = nextTime + endObservation = nextObservation + } + + if (samples[samples.length - 1].time.toMillis() !== endTime.toMillis()) { + samples.push({ time: endTime, observation: endObservation }) + } + + const peakIndex = samples.reduce((bestIndex, sample, index, allSamples) => + (sample.observation.elevation ?? -Infinity) > (allSamples[bestIndex].observation.elevation ?? -Infinity) + ? index + : bestIndex, + 0, + ) + const tcaIndex = samples.reduce((bestIndex, sample, index, allSamples) => + (sample.observation.slantRange ?? Infinity) < (allSamples[bestIndex].observation.slantRange ?? Infinity) + ? index + : bestIndex, + 0, + ) + + const peak = peakIndex > 0 && peakIndex < samples.length - 1 + ? refineTransitExtremum( + samples[peakIndex - 1].time, + samples[peakIndex + 1].time, + observeAt, + (observation) => observation.elevation ?? -Infinity, + true, + extremumRefineIterations, + ) + : samples[peakIndex] + + const tca = tcaIndex > 0 && tcaIndex < samples.length - 1 + ? refineTransitExtremum( + samples[tcaIndex - 1].time, + samples[tcaIndex + 1].time, + observeAt, + (observation) => observation.slantRange ?? Infinity, + false, + extremumRefineIterations, + ) + : samples[tcaIndex] + + return { + transit: { + start: formatTimestamp(aosTime, unitOpts.timestamp!), + stop: formatTimestamp(endTime, unitOpts.timestamp!), + duration: (endTime.toMillis() - aosTime.toMillis()) / 1000, + aos: buildTransitEvent(aosTime, aosObservation, unitOpts.timestamp!), + los: buildTransitEvent(endTime, endObservation, unitOpts.timestamp!), + peak: buildTransitEvent(peak.time, peak.observation, unitOpts.timestamp!), + tca: buildTransitEvent(tca.time, tca.observation, unitOpts.timestamp!), + }, + nextTime: toDateTime(Math.min(endTime.toMillis() + transitSearchStepMs, stopDateTime.toMillis())), + } + } + + const transits: SatelliteTransit[] = [] + let currentTime = startDateTime + let currentObservation = observeAt(currentTime) + + if (currentObservation.decayed) { + return [] + } + + while (currentTime.toMillis() < stopDateTime.toMillis()) { + if (isVisible(currentObservation, minElevationAngle)) { + const { transit, nextTime } = buildTransit(currentTime, currentObservation) + transits.push(transit) + + if (nextTime.toMillis() <= currentTime.toMillis() || nextTime.toMillis() >= stopDateTime.toMillis()) { + break + } + + currentTime = nextTime + currentObservation = observeAt(currentTime) + continue + } + + const nextTime = toDateTime(Math.min(currentTime.toMillis() + transitSearchStepMs, stopDateTime.toMillis())) + + if (nextTime.toMillis() === currentTime.toMillis()) { + break + } + + const nextObservation = observeAt(nextTime) + + if (nextObservation.decayed) { + break + } + + if (isVisible(nextObservation, minElevationAngle)) { + const aos = refineHorizonCrossing( + currentTime, + currentObservation, + nextTime, + nextObservation, + minElevationAngle, + observeAt, + crossingRefineIterations, + ) + const { transit, nextTime: followingTime } = buildTransit(aos.time, aos.observation) + transits.push(transit) + + if (followingTime.toMillis() <= currentTime.toMillis() || followingTime.toMillis() >= stopDateTime.toMillis()) { + break + } + + currentTime = followingTime + currentObservation = observeAt(currentTime) + continue + } + + currentTime = nextTime + currentObservation = nextObservation + } + + return transits +} + +// <---------------------------------------------------------------------------> + +// Re-export the public interfaces so consumers can import them from the module root +export type { + Orbit, + Position, + SatelliteObservation, + SatelliteTransit, + TransitEvent, + UnitOptions, + Velocity, +} from "./interfaces" + +// Re-export the public types so consumers can import them from the module root +export type { + AstronomialUnits, + Degrees, + Kilometers, + KilometersPerSecond, + OrbitMeanElementsMessage, + Radians, + Timestamp, + TwoLineElement, +} from "./types" + +export { + AngularUnits, + TimestampType, +} from './enums' diff --git a/src/types.ts b/src/types.ts index ec49cf4..8f69b91 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,9 +7,6 @@ export type Kilometers = number; /** Kilometers per second */ export type KilometersPerSecond = number; -/** Kilometers per hour */ -export type KilometersPerHour = number; - /** Radians */ export type Radians = number; @@ -25,11 +22,8 @@ export type TwoLineElement = string; /** Orbit Mean-Elements Message */ export type OrbitMeanElementsMessage = OMMJsonObjectV3; -/** Alias for various datetime types */ -export type DateTimeTypes = string | number | Date | DateTime +/** Alias for various timestamp types */ +export type Timestamp = DateTime | Date | string | number /** Seconds */ export type Seconds = number - -/** ISO8601 timestamp */ -export type Timestamp = string \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 13d1a0c..23a8d3c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,11 +1,18 @@ import { DateTime } from "luxon"; -import { Position, Velocity } from "./interfaces.ts"; -import { WGS84, day2ms, geostationaryMeanMotion, geostationaryTolerance } from "./constants.ts"; -import type { Radians, Kilometers, DateTimeTypes } from "./types.ts"; +import { Position, SatelliteObservation, TransitEvent, UnitOptions, Velocity } from "./interfaces.ts"; +import { WGS84, astronomicalUnit, day2ms, geostationaryMeanMotion, geostationaryTolerance, rad2deg } from "./constants.ts"; +import type { Degrees, Kilometers, Radians, Timestamp } from "./types.ts"; import { TwoLineElement, OrbitMeanElementsMessage } from "./types.ts"; +import { AngularUnits, TimestampType } from "./enums.ts"; import { + dopplerFactor, + ecfToLookAngles, gstime, + jday, + propagate, + radiansToDegrees, SatRec, + SatRecError, json2satrec, geodeticToEcf, ecfToEci, @@ -16,9 +23,10 @@ import { radiansLong, degreesLat, degreesLong, - MeanElements + MeanElements, + shadowFraction, + sunPos, } from "satellite.js"; -import { AngularUnits } from "./index.ts"; // <---------------------------------------------------------------------------> // UTILITY FUNCTIONS @@ -135,35 +143,35 @@ export function earthCentralAngle(re: Kilometers, altitude: Kilometers, minEleva } /** - * Convert the datetime string, unix timestamp (ms), or Date object to a luxon.DateTime object. + * Convert the timestamp to a luxon.DateTime object. */ -export function parseDateTime(dateTime: DateTimeTypes): DateTime { +export function parseTimestamp(timestamp: Timestamp): DateTime { // 1. Check if object is already a luxon.DateTime. // Use DateTime.isDateTime instead of `instanceof` so a DateTime created by a // different copy of the luxon module (e.g. the caller's own install) is still // recognized, since `instanceof` fails across module boundaries. - if (DateTime.isDateTime(dateTime)) { - return dateTime + if (DateTime.isDateTime(timestamp)) { + return timestamp } // 2. Check for native Date object - if (dateTime instanceof Date) { - return DateTime.fromJSDate(dateTime); + if (timestamp instanceof Date) { + return DateTime.fromJSDate(timestamp); } // 3. Check for number (timestamp) - if (typeof dateTime === 'number') { - return DateTime.fromMillis(dateTime, { zone: "UTC"}); + if (typeof timestamp === 'number') { + return DateTime.fromMillis(timestamp, { zone: "UTC"}); } // 4. Fallback to string (ISO or standard format) - if (typeof dateTime === 'string') { - const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(dateTime) + if (typeof timestamp === 'string') { + const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(timestamp) // Assume UTC only when the string does not already include timezone data. return hasExplicitTimezone - ? DateTime.fromISO(dateTime, { setZone: true }) - : DateTime.fromISO(dateTime, { zone: "UTC"}) + ? DateTime.fromISO(timestamp, { setZone: true }) + : DateTime.fromISO(timestamp, { zone: "UTC"}) } throw new Error('Unsupported datetime type'); @@ -172,8 +180,8 @@ export function parseDateTime(dateTime: DateTimeTypes): DateTime { /** * Calculates the Greenwich Mean Sidereal Time (GMST) from a luxon.DateTime object measured in radians */ -export function greenwichMeanSiderealTime(dateTime: DateTime): Radians { - return gstime(dateTime.toJSDate()) +export function greenwichMeanSiderealTime(datetime: DateTime): Radians { + return gstime(datetime.toJSDate()) } /** @@ -183,7 +191,7 @@ export function predictedRevolutionCount( orbitMeanElementsMessage: OrbitMeanElementsMessage, observationTime: DateTime, ): number { - const elementEpoch = parseDateTime(orbitMeanElementsMessage.EPOCH) + const elementEpoch = parseTimestamp(orbitMeanElementsMessage.EPOCH) return Math.ceil( Number(orbitMeanElementsMessage.REV_AT_EPOCH ?? 0) + @@ -445,3 +453,248 @@ export function convertGeodeticToDegrees(position: Position): Position { } } } + +/** + * Convert a luxon DateTime object to the format specified by the timestamp type + */ +export function formatTimestamp(datetime: DateTime, timestampType: TimestampType): Timestamp { + switch (timestampType) { + /** Return the luxon DateTime object unmodified */ + case TimestampType.DateTime: + return datetime; + + /** Return standard Javascript Date object */ + case TimestampType.Date: + return datetime.toJSDate() + + /** Return ISO8601 formatted timestamp string */ + case TimestampType.ISO8601: + return datetime.toISO()! + + /** Return the number of milliseconds since Unix epoch */ + case TimestampType.Unix: + return datetime.toMillis() + } +} + +/** Define the default unit options for the propagation functions. */ +export const defaultUnitOptions: UnitOptions = { + angular: AngularUnits.Degrees, + timestamp: TimestampType.ISO8601, +} + +export function computeSatelliteObservation( + omm: OrbitMeanElementsMessage, + satrec: SatRec, + datetime: DateTime, + observerPosition?: Position, + unitOptions?: UnitOptions, +): SatelliteObservation { + const unitOpts: UnitOptions = { ...defaultUnitOptions, ...(unitOptions ?? {}) } + + // Returns the satellite position and velocity in ECI coordinations + const satPropagation = propagate(satrec, datetime.toJSDate()) + + // Check for errors + if (satPropagation === null) { + switch (satrec.error) { + case SatRecError.MeanEccentricityOutOfRange: + throw new Error('Orbit eccentricity is out of range for SGP4 propagation model') + + case SatRecError.MeanMotionBelowZero: + throw new Error('Orbit mean motion is below zero') + + case SatRecError.PerturbedEccentricityOutOfRange: + throw new Error('Predicted orbit eccentricity is out of range for SGP4 propagation model') + + case SatRecError.SemiLatusRectumBelowZero: + throw new Error('Predicted orbit has collapsed mathematically') + + case SatRecError.Decayed: + return { + id: omm.OBJECT_ID, + name: omm.OBJECT_NAME, + noradCatalogId: omm.NORAD_CAT_ID as string, + orbitalModel: omm.MEAN_ELEMENT_THEORY, + epoch: formatTimestamp(datetime, unitOpts.timestamp!), + decayed: true, + } + } + + throw new Error('Satellite propagation failed') + } + + const gmst = greenwichMeanSiderealTime(datetime) + + // Calculate the satellite's position and velocity in other coordinate frames + const satPosition = inferPosition({ eci: satPropagation.position }, gmst, unitOpts.angular!) + const satVelocity = inferVelocity({ eci: satPropagation.velocity }, gmst) + + // Calculate the sun's position in kilometers + const sunEciAU = sunPos(jday(datetime.toJSDate())).rsun + const sunEci = { + x: sunEciAU.x * astronomicalUnit, + y: sunEciAU.y * astronomicalUnit, + z: sunEciAU.z * astronomicalUnit, + } + const sunPosition = inferPosition({ eci: sunEci }, gmst, unitOpts.angular!) + + // Calculate the eclipse factor + const eclipseFactor = shadowFraction(sunEciAU, satPosition.eci!) + + // Calculate the beta angle (radians) between the orbital plane and the Sun + const betaAngleRadians = betaAngle(satPropagation.meanElements, sunEci) + + // Calculate the satellite's footprint diameter, assume a minimum elevation angle of 0 degrees + const footprint = footprintDiameter(satPosition, 0) + + // Calculate the orbital phase from the mean anomaly, normalized to [0, 2*PI). This matches + // the phase definition used by the original predict/pypredict libraries, where phase is + // computed as (xlt - xnode - omgadf) which reduces to the mean anomaly plus small + // long-period/secular corrections, measured from perigee. + const twoPi = 2 * Math.PI + const phaseRadians = ((satPropagation.meanElements.mm % twoPi) + twoPi) % twoPi + + const observation: SatelliteObservation = { + id: omm.OBJECT_ID, + name: omm.OBJECT_NAME, + noradCatalogId: omm.NORAD_CAT_ID as string, + orbitalModel: omm.MEAN_ELEMENT_THEORY, + epoch: formatTimestamp(datetime, unitOpts.timestamp!), + gmst, + position: unitOpts.angular === AngularUnits.Degrees ? convertGeodeticToDegrees(satPosition) : satPosition, + velocity: satVelocity, + footprint, + orbit: { + revolutionCount: predictedRevolutionCount(omm, datetime), + phase: unitOpts.angular === AngularUnits.Degrees ? phaseRadians * rad2deg : phaseRadians, + phase256: phaseRadians * (256 / twoPi), + velocity: vectorMagnitude(satVelocity.eci!), + }, + decayed: false, + geostationary: isGeostationary(satPropagation.meanElements), + sunlit: eclipseFactor < 1, + sunPosition: unitOpts.angular === AngularUnits.Degrees ? convertGeodeticToDegrees(sunPosition) : sunPosition, + betaAngle: unitOpts.angular === AngularUnits.Degrees ? betaAngleRadians * rad2deg : betaAngleRadians, + eclipseFactor, + } + + if (!observerPosition) { + return observation + } + + // If we have an observer, calculate the look angles of the satellite + const observerInferedPosition = inferPosition(observerPosition, gmst, unitOpts.angular!) + const observerLookAngles = ecfToLookAngles(observerInferedPosition.geo!, satPosition.ecef!) + + return { + ...observation, + observerPosition: unitOpts.angular === AngularUnits.Degrees + ? convertGeodeticToDegrees(observerInferedPosition) + : observerInferedPosition, + azimuth: unitOpts.angular === AngularUnits.Degrees + ? radiansToDegrees(observerLookAngles.azimuth) + : observerLookAngles.azimuth, + elevation: unitOpts.angular === AngularUnits.Degrees + ? radiansToDegrees(observerLookAngles.elevation) + : observerLookAngles.elevation, + slantRange: observerLookAngles.rangeSat, + dopplerFactor: dopplerFactor(observerInferedPosition.ecef!, satPosition.ecef!, satVelocity.ecef!), + } +} + +export function transitObservation( + omm: OrbitMeanElementsMessage, + satrec: SatRec, + datetime: DateTime, + observerPosition: Position, + angularUnits: AngularUnits, +): SatelliteObservation { + return computeSatelliteObservation(omm, satrec, datetime, observerPosition, { + angular: angularUnits, + timestamp: TimestampType.DateTime, + }) +} + +export function isVisible(observation: SatelliteObservation, minElevation: Degrees | Radians): boolean { + return !observation.decayed && (observation.elevation ?? -Infinity) >= minElevation +} + +export function toDateTime(milliseconds: number): DateTime { + return DateTime.fromMillis(milliseconds, { zone: 'UTC' }) +} + +export function refineHorizonCrossing( + startTime: DateTime, + startObservation: SatelliteObservation, + endTime: DateTime, + endObservation: SatelliteObservation, + minElevation: Degrees | Radians, + observeAt: (datetime: DateTime) => SatelliteObservation, + iterations: number, +): { time: DateTime; observation: SatelliteObservation } { + let lowTime = startTime + let highTime = endTime + let lowObservation = startObservation + let highObservation = endObservation + const lowVisible = isVisible(startObservation, minElevation) + + for (let iteration = 0; iteration < iterations; iteration += 1) { + const midpoint = toDateTime((lowTime.toMillis() + highTime.toMillis()) / 2) + const midpointObservation = observeAt(midpoint) + + if (isVisible(midpointObservation, minElevation) === lowVisible) { + lowTime = midpoint + lowObservation = midpointObservation + } else { + highTime = midpoint + highObservation = midpointObservation + } + } + + return Math.abs((lowObservation.elevation ?? 0) - minElevation) < Math.abs((highObservation.elevation ?? 0) - minElevation) + ? { time: lowTime, observation: lowObservation } + : { time: highTime, observation: highObservation } +} + +export function refineTransitExtremum( + startTime: DateTime, + endTime: DateTime, + observeAt: (datetime: DateTime) => SatelliteObservation, + score: (observation: SatelliteObservation) => number, + maximize: boolean, + iterations: number, +): { time: DateTime; observation: SatelliteObservation } { + let left = startTime.toMillis() + let right = endTime.toMillis() + + for (let iteration = 0; iteration < iterations; iteration += 1) { + const oneThird = (right - left) / 3 + const firstMid = left + oneThird + const secondMid = right - oneThird + const firstScore = score(observeAt(toDateTime(firstMid))) + const secondScore = score(observeAt(toDateTime(secondMid))) + + if ((maximize && firstScore < secondScore) || (!maximize && firstScore > secondScore)) { + left = firstMid + } else { + right = secondMid + } + } + + const midpoint = toDateTime((left + right) / 2) + return { time: midpoint, observation: observeAt(midpoint) } +} + +export function buildTransitEvent( + time: DateTime, + observation: SatelliteObservation, + timestampType: TimestampType, +): TransitEvent { + return { + epoch: formatTimestamp(time, timestampType), + azimuth: observation.azimuth!, + elevation: observation.elevation!, + slantRange: observation.slantRange!, + } +} diff --git a/tsconfig.json b/tsconfig.json index 3194ad3..a2ec733 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "isolatedModules": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "src/legacy.ts", "src/__tests__"] + "exclude": ["node_modules", "dist", "src/__tests__"] } diff --git a/vite.config.ts b/vite.config.ts index 0bebba8..bc62991 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ build: { lib: { // Defines the entry point of your module - entry: 'src/index.ts', + entry: 'src/main.ts', name: 'JsPredict', formats: ['es'], fileName: 'jspredict', From 2c01851de3f9a6355c89d07707cb903b115d2556 Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Mon, 17 Aug 2026 13:58:50 -0600 Subject: [PATCH 08/14] Refining transit algorithm --- package-lock.json | 578 ++++++++++++++++++++++++++++++++++++- package.json | 4 +- scripts/benchmark.ts | 161 +++++++++++ src/__tests__/main.test.ts | 145 +++++----- src/constants.ts | 5 +- src/enums.ts | 2 +- src/interfaces.ts | 96 ++++-- src/main.ts | 557 ++++++++++++++++++++++------------- src/types.ts | 15 +- src/utils.ts | 570 +++++++++++++++++++++++++++++------- 10 files changed, 1700 insertions(+), 433 deletions(-) create mode 100644 scripts/benchmark.ts diff --git a/package-lock.json b/package-lock.json index 12829e9..1b9f33a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,16 +10,459 @@ "license": "MIT", "dependencies": { "luxon": "^3.7.2", - "satellite.js": "^7.1.0", - "vitest": "^4.1.10" + "satellite.js": "^7.1.0" }, "devDependencies": { "@types/luxon": "^3.7.4", "@typescript/typescript6": "^6.0.2", "eslint": "^10.8.0", + "tsx": "^4.23.12", "typescript": "^7.0.2", "vite": "^8.2.1", - "vite-plugin-dts": "^5.0.3" + "vite-plugin-dts": "^5.0.3", + "vitest": "^4.1.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -231,6 +674,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -248,6 +692,7 @@ "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -260,6 +705,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -276,6 +722,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -292,6 +739,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -308,6 +756,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -324,6 +773,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -340,6 +790,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -359,6 +810,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -378,6 +830,7 @@ "cpu": [ "ppc64" ], + "dev": true, "libc": [ "glibc" ], @@ -397,6 +850,7 @@ "cpu": [ "s390x" ], + "dev": true, "libc": [ "glibc" ], @@ -416,6 +870,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -435,6 +890,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -454,6 +910,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -470,6 +927,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -486,6 +944,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -499,6 +958,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, "license": "MIT" }, "node_modules/@rollup/pluginutils": { @@ -528,12 +988,14 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -544,6 +1006,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, "license": "MIT" }, "node_modules/@types/esrecurse": { @@ -557,6 +1020,7 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -945,6 +1409,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -962,6 +1427,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, "license": "MIT", "dependencies": { "@vitest/spy": "4.1.10", @@ -988,6 +1454,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -997,6 +1464,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, "license": "MIT", "dependencies": { "tinyrainbow": "^3.1.0" @@ -1009,6 +1477,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, "license": "MIT", "dependencies": { "@vitest/utils": "4.1.10", @@ -1022,6 +1491,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.1.10", @@ -1037,6 +1507,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -1046,6 +1517,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.1.10", @@ -1129,6 +1601,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1161,6 +1634,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1184,6 +1658,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -1230,6 +1705,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1239,8 +1715,51 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1420,6 +1939,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -1457,6 +1977,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1525,6 +2046,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1654,6 +2176,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1686,6 +2209,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1706,6 +2230,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1726,6 +2251,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1746,6 +2272,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1766,6 +2293,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1786,6 +2314,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -1809,6 +2338,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -1832,6 +2362,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -1855,6 +2386,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -1878,6 +2410,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1898,6 +2431,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1958,6 +2492,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2022,6 +2557,7 @@ "version": "3.3.17", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, "funding": [ { "type": "github", @@ -2047,6 +2583,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -2137,18 +2674,21 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2173,6 +2713,7 @@ "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2238,6 +2779,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.143.0", @@ -2299,12 +2841,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, "license": "ISC" }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2314,24 +2858,28 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2341,6 +2889,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2357,11 +2906,31 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2501,6 +3070,7 @@ "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", @@ -2604,6 +3174,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, "license": "MIT", "dependencies": { "@vitest/expect": "4.1.10", @@ -2723,6 +3294,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", diff --git a/package.json b/package.json index 7381143..e424f59 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "scripts": { "build": "vite build", - "test": "vitest" + "test": "vitest", + "benchmark": "npx tsx scripts/benchmark.ts" }, "repository": { "type": "git", @@ -47,6 +48,7 @@ "@types/luxon": "^3.7.4", "@typescript/typescript6": "^6.0.2", "eslint": "^10.8.0", + "tsx": "^4.23.12", "typescript": "^7.0.2", "vite": "^8.2.1", "vite-plugin-dts": "^5.0.3", diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts new file mode 100644 index 0000000..191b1f5 --- /dev/null +++ b/scripts/benchmark.ts @@ -0,0 +1,161 @@ +import { satelliteObservation, satelliteTransits, OrbitMeanElementsMessage } from '../src/main'; +import { DateTime, Duration } from 'luxon'; + +/** + * ISS Orbit Mean Elements Message + */ +const omm = { + "CCSDS_OMM_VERS": "3.0", + "COMMENT": "GENERATED VIA SPACE-TRACK.ORG API", + "CREATION_DATE": "2026-08-13T07:47:17", + "ORIGINATOR": "18 SPCS", + "OBJECT_NAME": "ISS (ZARYA)", + "OBJECT_ID": "1998-067A", + "CENTER_NAME": "EARTH", + "REF_FRAME": "TEME", + "TIME_SYSTEM": "UTC", + "MEAN_ELEMENT_THEORY": "SGP4", + "EPOCH": "2026-08-13T03:34:14.082240", + "MEAN_MOTION": "15.49426097", + "ECCENTRICITY": "0.00075330", + "INCLINATION": "51.6324", + "RA_OF_ASC_NODE": "18.1827", + "ARG_OF_PERICENTER": "41.6914", + "MEAN_ANOMALY": "318.4648", + "EPHEMERIS_TYPE": "0", + "CLASSIFICATION_TYPE": "U", + "NORAD_CAT_ID": "25544", + "ELEMENT_SET_NO": "999", + "REV_AT_EPOCH": "58058", + "BSTAR": "0.00007560600000", + "MEAN_MOTION_DOT": "0.00003778", + "MEAN_MOTION_DDOT": "0.0000000000000", + "SEMIMAJOR_AXIS": "6796.541", + "PERIOD": "92.938", + "APOAPSIS": "423.526", + "PERIAPSIS": "413.286", + "OBJECT_TYPE": "PAYLOAD", + "RCS_SIZE": "LARGE", + "COUNTRY_CODE": "CIS", + "LAUNCH_DATE": "1998-11-20", + "SITE": "TTMTR", + "DECAY_DATE": null, + "FILE": "5321051", + "GP_ID": "340390367", + "TLE_LINE0": "0 ISS (ZARYA)", + "TLE_LINE1": "1 25544U 98067A 26225.14877410 .00003778 00000-0 75606-4 0 9992", + "TLE_LINE2": "2 25544 51.6324 18.1827 0007533 41.6914 318.4648 15.49426097580580" +} + +/** + * Observer location + */ +const observer = { + geo: { + latitude: 40.014984, + longitude: -105.270546, + height: 1.655 + } +} + +/** + * Benchmark how long it takes to generate 1440 observations (i.e 1 observation per minute over 24 hours) + */ +const startTime = DateTime.fromISO(omm.EPOCH, { zone: 'utc' }) +const endTime = startTime.plus(Duration.fromObject({days: 30})) + +const stepMs = Duration.fromObject({ minute: 10 }).toMillis() +const observationCount = Math.round((endTime.toMillis() - startTime.toMillis()) / stepMs) + +console.log('=== Satellite Observation Benchmark ===') +console.log('Inputs:') +console.log(` satellite: ${omm.OBJECT_NAME} (NORAD ${omm.NORAD_CAT_ID})`) +console.log(` observer: lat=${observer.geo.latitude}°, lon=${observer.geo.longitude}°, height=${observer.geo.height} km`) +console.log(` startTime: ${startTime.toISO()}`) +console.log(` endTime: ${endTime.toISO()}`) +console.log(` step: ${stepMs / 1000 / 60} min`) +console.log(` samples: ${observationCount}`) +console.log('') + +const benchmarkStart = performance.now() +for (let epoch = startTime.toMillis(); epoch < endTime.toMillis(); epoch += stepMs) { + satelliteObservation(omm as OrbitMeanElementsMessage, epoch, observer) +} +const benchmarkDuration = performance.now() - benchmarkStart + +console.log(`Generated ${observationCount} satellite observations in ${benchmarkDuration.toFixed(2)} ms`) +console.log(`Average: ${(benchmarkDuration / observationCount).toFixed(4)} ms/observation`) +console.log(`Throughput: ${Math.round(observationCount / (benchmarkDuration / 1000)).toLocaleString()} observations/sec`) + +/** + * Benchmark how long it takes to generate 30 days worth of satellite transits + * for the observer location. + */ +const transitStart = DateTime.fromISO(omm.EPOCH, { zone: 'utc' }) +const transitEnd = transitStart.plus(Duration.fromObject({ days: 30 })) +const minElevationAngle = 2 + +console.log('\n=== Satellite Transit Benchmark ===') +console.log('Inputs:') +console.log(` satellite: ${omm.OBJECT_NAME} (NORAD ${omm.NORAD_CAT_ID})`) +console.log(` observer: lat=${observer.geo.latitude}°, lon=${observer.geo.longitude}°, height=${observer.geo.height} km`) +console.log(` startTime: ${transitStart.toISO()}`) +console.log(` stopTime: ${transitEnd.toISO()}`) +console.log(` window: ${transitEnd.diff(transitStart, 'days').days} days`) +console.log(` minElevation: ${minElevationAngle}°`) +console.log('') + +const transitBenchmarkStart = performance.now() +const transits = satelliteTransits( + omm as OrbitMeanElementsMessage, + transitStart, + transitEnd, + observer, + minElevationAngle, +) +const transitBenchmarkDuration = performance.now() - transitBenchmarkStart + +console.log(`\nGenerated ${transits.length} satellite transits over 30 days in ${transitBenchmarkDuration.toFixed(2)} ms`) +console.log(`Average: ${(transitBenchmarkDuration / transits.length).toFixed(4)} ms/transit`) +console.log(`Throughput: ${Math.round(transits.length / (transitBenchmarkDuration / 1000)).toLocaleString()} transits/sec`) + +/** + * Pretty-print a single transit and all of its event metadata. + */ +function describeTransit(label: string, transit: (typeof transits)[number]): void { + const event = (name: string, e: (typeof transit)['aos']) => + ` ${name.padEnd(5)} epoch=${e.epoch} azimuth=${(e.azimuth as number).toFixed(3)}° elevation=${(e.elevation as number).toFixed(3)}° slantRange=${e.slantRange.toFixed(3)} km dopplerFactor=${e.dopplerFactor.toFixed(6)}` + + console.log(`\n${label}:`) + console.log(` start=${transit.start}`) + console.log(` stop=${transit.stop}`) + console.log(` duration=${transit.duration.toFixed(2)} s (${(transit.duration / 60).toFixed(2)} min)`) + console.log(event('aos', transit.aos)) + console.log(event('tca', transit.tca)) + console.log(event('peak', transit.peak)) + console.log(event('los', transit.los)) +} + +if (transits.length > 0) { + // Aggregate metrics across all transits. + const durations = transits.map((t) => t.duration) + const peakElevations = transits.map((t) => t.peak.elevation as number) + + const longestTransit = transits.reduce((longest, t) => (t.duration > longest.duration ? t : longest)) + const shortestTransit = transits.reduce((shortest, t) => (t.duration < shortest.duration ? t : shortest)) + + const totalDuration = durations.reduce((sum, d) => sum + d, 0) + const averageDuration = totalDuration / transits.length + const averagePeakElevation = peakElevations.reduce((sum, e) => sum + e, 0) / transits.length + + console.log(`\n--- Transit Metrics (${transits.length} transits over 30 days) ---`) + console.log(`Total time in view: ${(totalDuration / 60).toFixed(2)} min`) + console.log(`Average duration: ${averageDuration.toFixed(2)} s (${(averageDuration / 60).toFixed(2)} min)`) + console.log(`Average peak elev: ${averagePeakElevation.toFixed(2)}°`) + + describeTransit('Longest transit', longestTransit) + describeTransit('Shortest transit', shortestTransit) +} + + + diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 1d83123..de75956 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -2,7 +2,6 @@ import { DateTime } from 'luxon' import { describe, expect, test } from 'vitest' import { - dopplerFactor, ecfToLookAngles, eciToEcf, eciToGeodetic, @@ -16,9 +15,9 @@ import { radiansLong, } from 'satellite.js' -import { satelliteObservation, satelliteObservations, satelliteTransits, type UnitOptions } from '../main' -import { AngularUnits, TimestampType } from '../enums' -import { convertTleToOmm, footprintDiameter } from '../utils' +import { SatelliteObservation, satelliteObservation, satelliteTransits } from '../main' +import { AngularUnits, TimestampFormat } from '../enums' +import { convertTleToOmm, dopplerFactorEcf, footprintDiameter } from '../utils' // <---------------------------------------------------------------------------> // TEST RESOURCES @@ -73,71 +72,60 @@ const issOmm = { const observationEpoch = '2026-08-07T00:30:49.879296Z' const laterObservationEpoch = '2026-08-08T00:30:49.879296Z' -const transitWindowStart = '2026-08-07T00:00:00Z' -const transitWindowStop = '2026-08-08T00:00:00Z' +const transitWindowStart = '2026-08-07T01:00:00Z' +const transitWindowStop = '2026-08-08T01:00:00Z' const unitOptionCases = [undefined, AngularUnits.Degrees, AngularUnits.Radians].flatMap((angular) => - [undefined, TimestampType.ISO8601, TimestampType.Unix, TimestampType.Date, TimestampType.DateTime].map( + [undefined, TimestampFormat.ISO8601, TimestampFormat.Unix, TimestampFormat.Date, TimestampFormat.DateTime].map( (timestamp) => { - const unitOptions: UnitOptions = {} - - if (angular !== undefined) { - unitOptions.angular = angular - } - - if (timestamp !== undefined) { - unitOptions.timestamp = timestamp - } - return { name: `angular=${angular ?? 'default'}, timestamp=${timestamp ?? 'default'}`, - unitOptions: Object.keys(unitOptions).length > 0 ? unitOptions : undefined, angular: angular ?? AngularUnits.Degrees, - timestamp: timestamp ?? TimestampType.ISO8601, + timestamp: timestamp ?? TimestampFormat.ISO8601, } }, ), ) -function expectTimestampValue(actual: unknown, epoch: string, timestampType: TimestampType): void { +function expectTimestampValue(actual: unknown, epoch: string, format: TimestampFormat): void { const expected = DateTime.fromISO(epoch, { setZone: true }) - switch (timestampType) { - case TimestampType.ISO8601: + switch (format) { + case TimestampFormat.ISO8601: expect(actual).toBe(expected.toISO()) break - case TimestampType.Unix: + case TimestampFormat.Unix: expect(actual).toBe(expected.toMillis()) break - case TimestampType.Date: + case TimestampFormat.Date: expect(actual).toBeInstanceOf(Date) expect((actual as Date).toISOString()).toBe(expected.toJSDate().toISOString()) break - case TimestampType.DateTime: + case TimestampFormat.DateTime: expect(DateTime.isDateTime(actual)).toBe(true) expect((actual as DateTime).toISO()).toBe(expected.toISO()) break } } -function expectTimestampType(actual: unknown, timestampType: TimestampType): void { - switch (timestampType) { - case TimestampType.ISO8601: +function expectTimestampFormat(actual: unknown, format: TimestampFormat): void { + switch (format) { + case TimestampFormat.ISO8601: expect(typeof actual).toBe('string') break - case TimestampType.Unix: + case TimestampFormat.Unix: expect(typeof actual).toBe('number') break - case TimestampType.Date: + case TimestampFormat.Date: expect(actual).toBeInstanceOf(Date) break - case TimestampType.DateTime: + case TimestampFormat.DateTime: expect(DateTime.isDateTime(actual)).toBe(true) break } @@ -186,27 +174,23 @@ function observerPositionFor(angular: AngularUnits) { const transitUnitOptionCases = [ { name: 'default', - unitOptions: undefined, angular: AngularUnits.Degrees, - timestamp: TimestampType.ISO8601, + timestamp: TimestampFormat.ISO8601, }, { name: 'unix timestamps', - unitOptions: { timestamp: TimestampType.Unix }, angular: AngularUnits.Degrees, - timestamp: TimestampType.Unix, + timestamp: TimestampFormat.Unix, }, { name: 'date timestamps', - unitOptions: { timestamp: TimestampType.Date }, angular: AngularUnits.Degrees, - timestamp: TimestampType.Date, + timestamp: TimestampFormat.Date, }, { name: 'radians datetime', - unitOptions: { angular: AngularUnits.Radians, timestamp: TimestampType.DateTime }, angular: AngularUnits.Radians, - timestamp: TimestampType.DateTime, + timestamp: TimestampFormat.DateTime, }, ] @@ -215,8 +199,8 @@ const transitUnitOptionCases = [ // <---------------------------------------------------------------------------> describe('satelliteObservation', () => { - test.each(unitOptionCases)('returns a ground track for $name', ({ unitOptions, angular, timestamp }) => { - const observed = satelliteObservation(issTle, observationEpoch, undefined, unitOptions) + test.each(unitOptionCases)('returns a ground track for $name', ({ angular, timestamp }) => { + const observed = satelliteObservation(issTle, observationEpoch, undefined, angular, timestamp) as SatelliteObservation const date = new Date(observationEpoch) const satrec = json2satrec(convertTleToOmm(issTle)) const propagated = propagate(satrec, date) @@ -252,7 +236,7 @@ describe('satelliteObservation', () => { ) }) - test.each(unitOptionCases)('returns observer look angles for $name', ({ unitOptions, angular, timestamp }) => { + test.each(unitOptionCases)('returns observer look angles for $name', ({ angular, timestamp }) => { const observerPosition = observerPositionFor(angular) const observerGeodetic = { latitude: radiansLat(15), @@ -263,7 +247,8 @@ describe('satelliteObservation', () => { issOmm as OMMJsonObjectV3, observationEpoch, observerPosition, - unitOptions, + angular, + timestamp, ) const date = new Date(observationEpoch) const satrec = json2satrec(issOmm as OMMJsonObjectV3) @@ -286,28 +271,30 @@ describe('satelliteObservation', () => { expect(observed.azimuth).toBeCloseTo(expectedAngle(lookAngles.azimuth, angular), 10) expect(observed.elevation).toBeCloseTo(expectedAngle(lookAngles.elevation, angular), 10) expect(observed.slantRange).toBeCloseTo(lookAngles.rangeSat, 10) - expect(observed.dopplerFactor).toBeCloseTo(dopplerFactor(observerEcf, positionEcf, velocityEcf), 12) + expect(observed.dopplerFactor).toBeCloseTo(dopplerFactorEcf(observerEcf, positionEcf, velocityEcf), 12) }) test('predicts revolution count from the observation time', () => { - const observed = satelliteObservation(issOmm as OMMJsonObjectV3, laterObservationEpoch) + const observed = satelliteObservation(issOmm as OMMJsonObjectV3, laterObservationEpoch) as SatelliteObservation expect(observed.orbit?.revolutionCount).toBe(57979) }) }) -describe('satelliteObservations', () => { - test.each(unitOptionCases)('returns one observation per datetime for $name', ({ unitOptions, angular, timestamp }) => { +describe('satelliteObservation with array input', () => { + test.each(unitOptionCases)('returns one observation per datetime for $name', ({ angular, timestamp }) => { const dateTimes = [observationEpoch, laterObservationEpoch] const observerPosition = observerPositionFor(angular) - const observed = satelliteObservations( + const observed = satelliteObservation( issOmm as OMMJsonObjectV3, dateTimes, observerPosition, - unitOptions, + angular, + timestamp, ) + expect(Array.isArray(observed)).toBe(true) expect(observed).toHaveLength(dateTimes.length) dateTimes.forEach((dateTime, index) => { @@ -315,11 +302,12 @@ describe('satelliteObservations', () => { issOmm as OMMJsonObjectV3, dateTime, observerPosition, - unitOptions, + angular, + timestamp, ) - const { epoch: actualEpoch, ...actualRest } = observed[index] - const { epoch: expectedEpoch, ...expectedRest } = expected + const { epoch: actualEpoch, ...actualRest } = (observed as any)[index] + const { epoch: expectedEpoch, ...expectedRest } = expected as any expect(actualRest).toStrictEqual(expectedRest) expectTimestampValue(actualEpoch, dateTime, timestamp) @@ -328,31 +316,34 @@ describe('satelliteObservations', () => { }) test('returns an empty array when no datetimes are provided', () => { - expect(satelliteObservations(issOmm as OMMJsonObjectV3, [])).toStrictEqual([]) + const result = satelliteObservation(issOmm as OMMJsonObjectV3, []) + expect(Array.isArray(result)).toBe(true) + expect(result).toStrictEqual([]) }) }) describe('satelliteTransits', () => { - test.each(transitUnitOptionCases)('returns ordered transit events for $name', ({ unitOptions, angular, timestamp }) => { + test.each(transitUnitOptionCases)('returns ordered transit events for $name', ({ angular, timestamp }) => { const observerPosition = observerPositionFor(angular) const transits = satelliteTransits( issOmm as OMMJsonObjectV3, - observerPosition, transitWindowStart, transitWindowStop, - undefined, - unitOptions, + observerPosition, + 0, + angular, + timestamp, ) expect(transits.length).toBeGreaterThan(0) transits.forEach((transit) => { - expectTimestampType(transit.start, timestamp) - expectTimestampType(transit.stop, timestamp) - expectTimestampType(transit.aos.epoch, timestamp) - expectTimestampType(transit.los.epoch, timestamp) - expectTimestampType(transit.peak.epoch, timestamp) - expectTimestampType(transit.tca.epoch, timestamp) + expectTimestampFormat(transit.start, timestamp) + expectTimestampFormat(transit.stop, timestamp) + expectTimestampFormat(transit.aos.epoch, timestamp) + expectTimestampFormat(transit.los.epoch, timestamp) + expectTimestampFormat(transit.peak.epoch, timestamp) + expectTimestampFormat(transit.tca.epoch, timestamp) const startMillis = timestampToMillis(transit.start) const stopMillis = timestampToMillis(transit.stop) @@ -370,10 +361,10 @@ describe('satelliteTransits', () => { expect(tcaMillis).toBeLessThanOrEqual(stopMillis) expect(Math.abs(transit.duration - ((stopMillis - startMillis) / 1000))).toBeLessThan(0.002) - const aosObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.aos.epoch, observerPosition, unitOptions) - const losObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.los.epoch, observerPosition, unitOptions) - const peakObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.peak.epoch, observerPosition, unitOptions) - const tcaObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.tca.epoch, observerPosition, unitOptions) + const aosObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.aos.epoch, observerPosition, angular, timestamp) as any + const losObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.los.epoch, observerPosition, angular, timestamp) as any + const peakObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.peak.epoch, observerPosition, angular, timestamp) as any + const tcaObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.tca.epoch, observerPosition, angular, timestamp) as any expect(aosObservation.azimuth).toBeCloseTo(transit.aos.azimuth, 10) expect(aosObservation.elevation).toBeCloseTo(transit.aos.elevation, 10) @@ -399,15 +390,15 @@ describe('satelliteTransits', () => { const observerPosition = observerPositionFor(AngularUnits.Degrees) const allTransits = satelliteTransits( issOmm as OMMJsonObjectV3, - observerPosition, transitWindowStart, transitWindowStop, + observerPosition, ) const filteredTransits = satelliteTransits( issOmm as OMMJsonObjectV3, - observerPosition, transitWindowStart, transitWindowStop, + observerPosition, 20, ) @@ -415,21 +406,25 @@ describe('satelliteTransits', () => { filteredTransits.forEach((transit) => { expect(transit.peak.elevation).toBeGreaterThanOrEqual(20) - expect(transit.aos.elevation).toBeCloseTo(20, 2) - expect(transit.los.elevation).toBeCloseTo(20, 2) + // The default elevation tolerance (1e-3 rad ≈ 0.0573°) matches SGP4's + // inherent angular accuracy, so AOS/LOS converge to within that band of + // the requested 20° threshold. + const toleranceDegrees = 1e-3 * (180 / Math.PI) + expect(Math.abs((transit.aos.elevation as number) - 20)).toBeLessThanOrEqual(toleranceDegrees) + expect(Math.abs((transit.los.elevation as number) - 20)).toBeLessThanOrEqual(toleranceDegrees) }) }) - test('returns an empty array when the time range is invalid', () => { + test('throws when the time range is invalid', () => { const observerPosition = observerPositionFor(AngularUnits.Degrees) - expect( + expect(() => satelliteTransits( issOmm as OMMJsonObjectV3, - observerPosition, transitWindowStop, transitWindowStart, + observerPosition, ), - ).toStrictEqual([]) + ).toThrow('Stop date is less than or equal to start date') }) }) diff --git a/src/constants.ts b/src/constants.ts index be18495..04689ef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,10 +1,11 @@ -import { Kilometers, AstronomialUnits } from "./types"; +import { AstronomialUnits } from "./types"; +import { Kilometer } from "satellite.js"; /** Astronomical Unit - km (IAU 76) */ export const astronomicalUnit: AstronomialUnits = 1.49597870691E8; /** Solar Radius - km (IAU 76) */ -export const solarRadius: Kilometers = 6.96000E5; +export const solarRadius: Kilometer = 6.96000E5; /** Convert degrees to radians */ export const deg2rad: number = Math.PI / 180.0; diff --git a/src/enums.ts b/src/enums.ts index fbb37c9..3a02d5f 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -5,7 +5,7 @@ export enum AngularUnits { } /** Set the time units for the inputs and outputs */ -export enum TimestampType { +export enum TimestampFormat { Unix = 'UNIX', ISO8601 = 'ISO8601', Date = 'DATE', diff --git a/src/interfaces.ts b/src/interfaces.ts index 7650196..08999a9 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,22 +1,13 @@ -import { EcfVec3, EciVec3, GeodeticLocation } from "satellite.js" -import { Radians, Degrees, Kilometers, KilometersPerSecond, Timestamp, Seconds } from "./types.ts" -import { AngularUnits, TimestampType } from "./enums.ts" - -export interface UnitOptions { - /** Set the unit type for angular measurements */ - angular?: AngularUnits - - /** Set the output type for timestamps */ - timestamp?: TimestampType -} +import { EcfVec3, EciVec3, GeodeticLocation, Radians, Degrees, Kilometer, KilometerPerSecond } from "satellite.js" +import { Timestamp, Seconds } from "./types.ts" /** Position parameters */ export interface Position { /** Position in Earth-Centered Inertial (ECI) coordinates measured in kilometers */ - eci?: EciVec3 + eci?: EciVec3 /** Position in Earth-Centered Earth-Fixed (ECEF) coordinates measured in kilometers */ - ecef?: EcfVec3 + ecef?: EcfVec3 /** Position in Geodetic coordinats measured in radians and kilometers */ geo?: GeodeticLocation @@ -25,10 +16,10 @@ export interface Position { /** Velocity parameters */ export interface Velocity { /** Velocity vector in Earth-Centered Inertial (ECI) coordinates measured in kilometers per second */ - eci?: EciVec3 + eci?: EciVec3 /** Velocity vector in Earth-Centered Earth-Fixed (ECEF) coordinates measured in kilometers per second */ - ecef?: EcfVec3 + ecef?: EcfVec3 } /** Orbit parameters */ @@ -38,11 +29,10 @@ export interface Orbit { /** * The current position of the satellite in its orbit, measured from perigee as the mean - * anomaly (plus small long-period/secular corrections), normalized to a full revolution. - * Reported in radians or degrees depending on the requested angular units. This matches - * the phase definition used by the original predict/pypredict libraries. + * anomaly (plus small long-period/secular corrections), normalized to a full revolution + * reported in degrees or radians depending on selected angular units. */ - phase?: Radians | Degrees + phase?: Degrees | Radians /** * The current position of the satellite in its orbit expressed on a legacy 0..256 scale, @@ -51,8 +41,8 @@ export interface Orbit { */ phase256?: number - /** Satellite velocity relative to the center of the Earth in kilometers per second */ - velocity?: KilometersPerSecond + /** Satellite velocity relative to the center of the Earth in kilometers per hour */ + velocity?: KilometerPerSecond } /** @@ -81,7 +71,7 @@ export interface SatelliteObservation { velocity?: Velocity /** The diameter of the satellite's ground coverage area (the visible circle on Earth's surface) in kilometers */ - footprint?: Kilometers + footprint?: Kilometer /** Orbit revolution count */ orbit?: Orbit; @@ -123,7 +113,7 @@ export interface SatelliteObservation { elevation?: Degrees | Radians /** The direct line-of-sight distance from the observer to the satellite, measured in kilometers. */ - slantRange?: Kilometers + slantRange?: Kilometer /** Satellite frequency shift (i.e doppler factor) relative to observer. */ dopplerFactor?: number @@ -149,7 +139,10 @@ export interface TransitEvent { elevation: Degrees | Radians /** Straight line range of the satellite from the observer */ - slantRange: Kilometers + slantRange: Kilometer + + /** Satellite frequency shift (i.e doppler factor) relative to observer. */ + dopplerFactor: number } /** @@ -176,4 +169,57 @@ export interface SatelliteTransit { /** Peak elevation event parameters */ peak: TransitEvent -} \ No newline at end of file +} + +/** + * Tunable precision and convergence controls for the transit search. + * + * The transit search first performs a coarse sampling of the satellite's + * elevation (and elevation rate) over each orbit to bracket candidate events, + * and then refines each event time using the secant method. These options + * control how the coarse sampling is stepped and when the secant refinement is + * considered "converged" or is abandoned. + * + * Crossing events (AOS, LOS, and the start/stop horizon crossings) are found by + * root-finding on the elevation value, so they converge on an angular tolerance + * in radians. Extremum events (the peak elevation and the time of closest + * approach) are found by root-finding on the derivative, so they converge on a + * *rate* tolerance: elevation rate in radians per second, and slant-range rate + * in kilometers per second. + */ +export interface TransitSearchOptions { + /** + * Angular convergence tolerance in radians for the AOS, LOS, and horizon + * crossing events. The refinement stops once the satellite's elevation is + * within this many radians of the target crossing. + */ + elevationToleranceRadians?: Radians + + /** + * Rate convergence tolerance in radians per second for the peak (culmination) + * event. The refinement stops once the elevation rate is within this many + * radians per second of zero. + */ + elevationRateTolerance?: number + + /** + * Rate convergence tolerance in kilometers per second for the time of closest + * approach (TCA). The refinement stops once the slant-range rate is within + * this many kilometers per second of zero. + */ + slantRangeRateTolerance?: number + + /** + * Maximum number of secant iterations allowed per event before the search + * gives up on converging and falls back to the best estimate found so far. + */ + maxIterations?: number + + /** + * Optional override for the coarse-search step size, expressed in seconds. + * When omitted, the step size is derived dynamically from the satellite's + * mean motion (mirroring Skyfield), which yields roughly 20 samples per + * orbital revolution. + */ + coarseStepSeconds?: Seconds +} diff --git a/src/main.ts b/src/main.ts index b90ef56..53e30d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,18 +28,17 @@ // OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF // SUCH DAMAGE. +import { AngularUnits, TimestampFormat } from "./enums" import { DateTime } from "luxon" - +import { deg2rad } from "./constants" import { Position, SatelliteObservation, SatelliteTransit, - UnitOptions, + TransitSearchOptions, } from "./interfaces" import { - Degrees, OrbitMeanElementsMessage, - Radians, Timestamp, TwoLineElement, } from "./types" @@ -47,69 +46,54 @@ import { import { buildTransitEvent, computeSatelliteObservation, - defaultUnitOptions, + dynamicStepSeconds, + elevationAt, + elevationRelativeTo, formatTimestamp, - isVisible, parseSatelliteElements, parseTimestamp, - refineHorizonCrossing, - refineTransitExtremum, - toDateTime, - transitObservation, + secantExtremum, + secantMethod, + findHorizonCrossing } from "./utils" - -const transitSearchStepMs = 60 * 1000 -const transitSampleStepMs = 15 * 1000 -const crossingRefineIterations = 24 -const extremumRefineIterations = 24 +import { + Degrees, + Radians, +} from "satellite.js" /** * Calculates satellite observation parameters such as postion, velocity, and * observer look angles. * @param satelliteElements a TLE or OMM of the satellite's orbital elements - * @param epoch: the date and time of the satellite observation, expressed as - * either a Unix timestamp, an ISO8601 string, a standard Javascript Date - * object, or luxon DateTime object + * @param epoch: accepts either a single timestamp or array of timestamps + * expressed as either a Unix timestamp, an ISO8601 string, a standard + * Javascript Date object, or luxon DateTime object * @param observerPosition: (optional) a position object specifying the location * of a satellite observer - * @param unitOptions: (optional) configure input/output units - * Returns SatelliteObservation object + * @param angularUnits: (optional) configure if angular units are defined in + * Degrees or Radians, default is Degrees + * @param timestampFormat: (optional) sets the format of output timestamps, + * default is ISO8601 + * Returns either a single SatelliteObservation or array of + * SatelliteObservations if an array of epochs is provided */ export function satelliteObservation( satelliteElements: TwoLineElement | OrbitMeanElementsMessage, - epoch: Timestamp, - observerPosition?: Position, - unitOptions?: UnitOptions, -): SatelliteObservation { - const datetime = parseTimestamp(epoch) - const [omm, satrec] = parseSatelliteElements(satelliteElements) - - return computeSatelliteObservation(omm, satrec, datetime, observerPosition, unitOptions) -} - -/** - * Calculates satellite observation parameters such as postion, velocity, and - * observer look angles at the specified times. - * @param satelliteElements a TLE or OMM of the satellite's orbital elements - * @param epochArray: an array of timestamps specifying the desired observation - * times, the array may contain Unix timestamps, ISO8601 strings, Javascript - * Date objects, or luxon DateTime objects - * @param observerPosition: (optional) a position object specifying the location - * of a satellite observer - * @param unitOptions: (optional) configure input/output units - * Returns array of SatelliteObservation objects - */ -export function satelliteObservations( - satelliteElements: TwoLineElement | OrbitMeanElementsMessage, - epochArray: Timestamp[], + epoch: Timestamp | Timestamp[], observerPosition?: Position, - unitOptions?: UnitOptions, -): SatelliteObservation[] { - const [omm, satrec] = parseSatelliteElements(satelliteElements) - - return epochArray.map((epoch) => - computeSatelliteObservation(omm, satrec, parseTimestamp(epoch), observerPosition, unitOptions), - ) + angularUnits: AngularUnits = AngularUnits.Degrees, + timestampFormat: TimestampFormat = TimestampFormat.ISO8601 +): SatelliteObservation | SatelliteObservation[] { + if (Array.isArray(epoch)) { + const [omm, satrec] = parseSatelliteElements(satelliteElements) + return epoch.map((e) => + computeSatelliteObservation(omm, satrec, parseTimestamp(e), observerPosition, angularUnits, timestampFormat) + ) + } else { + const datetime = parseTimestamp(epoch) + const [omm, satrec] = parseSatelliteElements(satelliteElements) + return computeSatelliteObservation(omm, satrec, datetime, observerPosition, angularUnits, timestampFormat) + } } /** @@ -120,187 +104,352 @@ export function satelliteObservations( * @param startTime: a timestamp representing the start time of the transits * @param stopTime: a timestamp representing the stop time of the transits * @param minElevationAngle: (optional) the minimum elevation threshold used to - * define the start and stop of a pass - * @param unitOptions: (optional) configure input/output units + * define the AOS/LOS of a pass (defaults to 0) + * @param angularUnits: (optional) configure if angular units are defined in + * Degrees or Radians, default is Degrees + * @param timestampFormat: (optional) sets the format of output timestamps, + * default is ISO8601 + * @param searchOptions: (optional) tunable precision (secant tolerance) and + * convergence (max iterations) controls plus an optional coarse-step override */ export function satelliteTransits( satelliteElements: TwoLineElement | OrbitMeanElementsMessage, - observerPosition: Position, startTime: Timestamp, stopTime: Timestamp, + observerPosition: Position, minElevationAngle: Degrees | Radians = 0, - unitOptions?: UnitOptions, + angularUnits: AngularUnits = AngularUnits.Degrees, + timestampFormat: TimestampFormat = TimestampFormat.ISO8601, + searchOptions: TransitSearchOptions = {} ): SatelliteTransit[] { - const unitOpts: UnitOptions = { ...defaultUnitOptions, ...(unitOptions ?? {}) } const startDateTime = parseTimestamp(startTime) const stopDateTime = parseTimestamp(stopTime) - if (stopDateTime.toMillis() <= startDateTime.toMillis()) { - return [] + // Check for invalid inputs + if (stopDateTime <= startDateTime) { + throw new Error('Stop date is less than or equal to start date') } - - const [omm, satrec] = parseSatelliteElements(satelliteElements) - const observeAt = (datetime: DateTime): SatelliteObservation => - transitObservation(omm, satrec, datetime, observerPosition, unitOpts.angular!) - - const buildTransit = ( - aosTime: DateTime, - aosObservation: SatelliteObservation, - ): { transit: SatelliteTransit; nextTime: DateTime } => { - const samples = [{ time: aosTime, observation: aosObservation }] - let lastTime = aosTime - let lastObservation = aosObservation - let endTime = aosTime - let endObservation = aosObservation - - while (lastTime.toMillis() < stopDateTime.toMillis()) { - const nextTime = toDateTime(Math.min(lastTime.toMillis() + transitSampleStepMs, stopDateTime.toMillis())) - - if (nextTime.toMillis() === lastTime.toMillis()) { - break - } - const nextObservation = observeAt(nextTime) + // Parse the satellite elements + const [omm, satrec] = parseSatelliteElements(satelliteElements) + const ommEpochDateTime = parseTimestamp(omm.EPOCH) - if (nextObservation.decayed) { - endTime = lastTime - endObservation = lastObservation - break - } + // Warn about propagating transits before OMM epoch + if (startDateTime < ommEpochDateTime) { + console.warn('Propagating satellite transit times prior to TLE/OMM epoch is not recommended') + } - if (!isVisible(nextObservation, minElevationAngle)) { - const los = refineHorizonCrossing( - lastTime, - lastObservation, - nextTime, - nextObservation, - minElevationAngle, - observeAt, - crossingRefineIterations, - ) - endTime = los.time - endObservation = los.observation - break - } + // Propagate the satellite at the start time to see if it is decayed or geostationary + const initialObservation = computeSatelliteObservation(omm, satrec, startDateTime, observerPosition, angularUnits, timestampFormat) - samples.push({ time: nextTime, observation: nextObservation }) - lastTime = nextTime - lastObservation = nextObservation - endTime = nextTime - endObservation = nextObservation - } + // Check if the satellite has decayed + if (initialObservation.decayed) { + console.warn(`Satellite ${initialObservation.id} orbit has decayed as of: ${initialObservation.epoch}`) + return [] + } - if (samples[samples.length - 1].time.toMillis() !== endTime.toMillis()) { - samples.push({ time: endTime, observation: endObservation }) + // Check if the satellite is geostationary + if (initialObservation.geostationary) { + if (initialObservation.elevation! < minElevationAngle) { + console.warn(`Satellite ${initialObservation.id} is geostationary and is out of view of the observer at: ${initialObservation.observerPosition?.geo}`) + return [] } + } - const peakIndex = samples.reduce((bestIndex, sample, index, allSamples) => - (sample.observation.elevation ?? -Infinity) > (allSamples[bestIndex].observation.elevation ?? -Infinity) - ? index - : bestIndex, - 0, - ) - const tcaIndex = samples.reduce((bestIndex, sample, index, allSamples) => - (sample.observation.slantRange ?? Infinity) < (allSamples[bestIndex].observation.slantRange ?? Infinity) - ? index - : bestIndex, - 0, - ) + // <-----------------------------------------------------------------------> + // SEARCH CONFIGURATION + // <-----------------------------------------------------------------------> + + // Resolve the tunable precision/convergence controls, applying defaults. + // + // Crossing events (AOS, LOS, and the start/stop horizon crossings) are found + // by root-finding on the elevation value, so they converge on an angular + // tolerance in radians. Extremum events (peak elevation and the time of + // closest approach) are found by root-finding on the derivative, so they + // converge on a *rate* tolerance: elevation rate in radians per second for the + // peak, and slant-range rate in kilometers per second for TCA. + const elevationToleranceRadians = searchOptions.elevationToleranceRadians ?? 1e-6 + const elevationRateTolerance = searchOptions.elevationRateTolerance ?? 1e-6 + const slantRangeRateTolerance = searchOptions.slantRangeRateTolerance ?? 1e-4 + const maxIterations = searchOptions.maxIterations ?? 100 + + // Convert the caller-supplied minimum elevation to radians. All internal math + // is performed in radians; angular unit conversion happens only at output. + const minElevationRadians: Radians = + angularUnits === AngularUnits.Degrees ? (minElevationAngle as number) * deg2rad : (minElevationAngle as number) + + // The observer's geodetic position expressed in radians. `inferPosition` + // (called inside computeSatelliteObservation) tolerates degree inputs, but the + // low-level elevation helpers require radians, so normalize once here. + const observerGeodeticRadians = + angularUnits === AngularUnits.Degrees + ? { + latitude: observerPosition.geo!.latitude * deg2rad, + longitude: observerPosition.geo!.longitude * deg2rad, + height: observerPosition.geo!.height, + } + : observerPosition.geo! + + // Determine the coarse step size (seconds). Unless the caller overrides it, + // the step is derived dynamically from the satellite's mean motion, mirroring + // Skyfield's heuristic of ~20 samples per orbital revolution + // (step = 0.05 orbits worth of time). This keeps the step comfortably shorter + // than a single pass so every culmination is bracketed, while scaling sensibly + // across orbit regimes. The step is capped at a quarter day so very slow + // (near-geostationary) satellites, which rise and set because the Earth turns + // beneath them rather than from their own motion, are still sampled densely + // enough to catch each pass. + const stepSeconds = searchOptions.coarseStepSeconds ?? dynamicStepSeconds(Number(omm.MEAN_MOTION)) + const stepMs = stepSeconds * 1000 + + // Scalar functions of time used by both the coarse search and the secant + // refinement. Time is carried as milliseconds since the Unix epoch throughout + // the search so no intermediate luxon DateTime objects are created; elevation + // is measured in radians relative to the local horizon. + const elevation = (ms: number): Radians => elevationAt(satrec, observerGeodeticRadians, ms) + + // Numerically estimate the elevation *rate* (d(elevation)/dt) via a small + // central finite difference. The rate is expressed in radians per second (the + // finite difference is taken over a millisecond interval, then scaled to + // per-second units). The sign of this rate distinguishes a rising satellite + // (positive) from a setting one (negative); a culmination occurs where it + // changes from positive to negative. + const rateDeltaMs = 500 + const msPerSecond = 1000 + const elevationRate = (ms: number): number => { + const before = elevation(ms - rateDeltaMs) + const after = elevation(ms + rateDeltaMs) + return ((after - before) / (2 * rateDeltaMs)) * msPerSecond + } - const peak = peakIndex > 0 && peakIndex < samples.length - 1 - ? refineTransitExtremum( - samples[peakIndex - 1].time, - samples[peakIndex + 1].time, - observeAt, - (observation) => observation.elevation ?? -Infinity, - true, - extremumRefineIterations, - ) - : samples[peakIndex] - - const tca = tcaIndex > 0 && tcaIndex < samples.length - 1 - ? refineTransitExtremum( - samples[tcaIndex - 1].time, - samples[tcaIndex + 1].time, - observeAt, - (observation) => observation.slantRange ?? Infinity, - false, - extremumRefineIterations, - ) - : samples[tcaIndex] - - return { - transit: { - start: formatTimestamp(aosTime, unitOpts.timestamp!), - stop: formatTimestamp(endTime, unitOpts.timestamp!), - duration: (endTime.toMillis() - aosTime.toMillis()) / 1000, - aos: buildTransitEvent(aosTime, aosObservation, unitOpts.timestamp!), - los: buildTransitEvent(endTime, endObservation, unitOpts.timestamp!), - peak: buildTransitEvent(peak.time, peak.observation, unitOpts.timestamp!), - tca: buildTransitEvent(tca.time, tca.observation, unitOpts.timestamp!), - }, - nextTime: toDateTime(Math.min(endTime.toMillis() + transitSearchStepMs, stopDateTime.toMillis())), - } + const startMs = startDateTime.toMillis() + const stopMs = stopDateTime.toMillis() + + // <-----------------------------------------------------------------------> + // PHASE 1: COARSE SEARCH + // + // Walk the search window at the coarse step, sampling the elevation angle. + // Candidate passes are detected as *culminations* — local maxima in elevation + // — rather than by requiring a coarse sample to land above the minimum + // elevation threshold. This is what lets short "grazing" passes be found even + // when the coarse step is too large for any single sample to fall inside the + // brief window the satellite spends above the threshold: as long as the step + // brackets the culmination, the pass is caught. A culmination lies between two + // adjacent samples where the elevation rate flips from rising (>= 0) to + // setting (< 0). + // <-----------------------------------------------------------------------> + + type Sample = { ms: number; elevation: Radians } + const samples: Sample[] = [] + for (let ms = startMs; ms <= stopMs; ms += stepMs) { + samples.push({ ms, elevation: elevation(ms) }) + } + // Always include the exact window end so we do not miss an event lingering in + // the final partial step. + if (samples[samples.length - 1].ms < stopMs) { + samples.push({ ms: stopMs, elevation: elevation(stopMs) }) } const transits: SatelliteTransit[] = [] - let currentTime = startDateTime - let currentObservation = observeAt(currentTime) - if (currentObservation.decayed) { - return [] - } + // Iterate over adjacent sample pairs looking for a culmination: an interval + // where the elevation rate changes from rising (>= 0) to setting (< 0). + for (let i = 0; i < samples.length - 1; i++) { + const cursorMs = samples[i].ms + const nextMs = samples[i + 1].ms - while (currentTime.toMillis() < stopDateTime.toMillis()) { - if (isVisible(currentObservation, minElevationAngle)) { - const { transit, nextTime } = buildTransit(currentTime, currentObservation) - transits.push(transit) + // A culmination is bracketed when the elevation rate flips positive to + // negative between the two coarse samples. + const culminates = elevationRate(cursorMs) >= 0 && elevationRate(nextMs) < 0 - if (nextTime.toMillis() <= currentTime.toMillis() || nextTime.toMillis() >= stopDateTime.toMillis()) { - break - } - - currentTime = nextTime - currentObservation = observeAt(currentTime) + if (!culminates) { continue } - const nextTime = toDateTime(Math.min(currentTime.toMillis() + transitSearchStepMs, stopDateTime.toMillis())) + // <-------------------------------------------------------------------> + // PHASE 2: SECANT REFINEMENT + // <-------------------------------------------------------------------> + + // <-------------------------------------------------------------------> + // PEAK (culmination): refine the elevation maximum as the zero of the + // elevation *rate* within the bracketing coarse interval. The peak is + // resolved first because it provides a guaranteed above-threshold time (for + // real passes) that brackets both the AOS (rising) and LOS (setting) + // crossings on their respective sides, preventing either secant search from + // wandering to the opposite crossing. + // <-------------------------------------------------------------------> + + const peakBracketAMs = cursorMs + const peakBracketBMs = nextMs + + const peakMs = secantExtremum( + (ms) => elevationRate(ms), + peakBracketAMs, + peakBracketBMs, + elevationRateTolerance, + maxIterations, + ) - if (nextTime.toMillis() === currentTime.toMillis()) { - break + // Filter out culminations whose peak never reaches the minimum elevation. + // This mirrors Skyfield, which discards maxima below the requested altitude. + // A strict inequality also drops grazing passes whose peak only touches the + // threshold, so a reported transit always genuinely exceeds minElevation. + const peakObservation = computeSatelliteObservation( + omm, satrec, DateTime.fromMillis(peakMs, { zone: "utc" }), observerPosition, angularUnits, timestampFormat, + ) + const peakElevationRadians = elevation(peakMs) + if (peakElevationRadians <= minElevationRadians) { + // Advance past this culmination's coarse interval and keep scanning. + continue } - const nextObservation = observeAt(nextTime) - - if (nextObservation.decayed) { - break + // <-------------------------------------------------------------------> + // AOS: the exact time the elevation rises through the minimum threshold. + // March backward from the peak one coarse step at a time until a sample + // falls below the threshold, bracketing the rising crossing between that + // sample and the peak (which is above the threshold). Then refine. + // <-------------------------------------------------------------------> + + let aosBracketMs = peakMs + for (let ms = cursorMs; ms >= startMs - stepMs; ms -= stepMs) { + const clampedMs = Math.max(ms, startMs) + if (elevation(clampedMs) < minElevationRadians) { + aosBracketMs = clampedMs + break + } + aosBracketMs = clampedMs + if (clampedMs === startMs) break } - if (isVisible(nextObservation, minElevationAngle)) { - const aos = refineHorizonCrossing( - currentTime, - currentObservation, - nextTime, - nextObservation, - minElevationAngle, - observeAt, - crossingRefineIterations, - ) - const { transit, nextTime: followingTime } = buildTransit(aos.time, aos.observation) - transits.push(transit) + const aosMs = secantMethod( + (ms) => elevationRelativeTo(satrec, observerGeodeticRadians, ms, minElevationRadians), + aosBracketMs, + peakMs, + elevationToleranceRadians, + maxIterations, + ) - if (followingTime.toMillis() <= currentTime.toMillis() || followingTime.toMillis() >= stopDateTime.toMillis()) { + // <-------------------------------------------------------------------> + // LOS: the exact time the elevation sets through the minimum threshold. + // March forward from the peak one coarse step at a time until a sample falls + // below the threshold, bracketing the setting crossing between the peak and + // that sample. Then refine. + // <-------------------------------------------------------------------> + + let losBracketMs = peakMs + for (let ms = nextMs; ms <= stopMs + stepMs; ms += stepMs) { + const clampedMs = Math.min(ms, stopMs) + if (elevation(clampedMs) < minElevationRadians) { + losBracketMs = clampedMs break } + losBracketMs = clampedMs + if (clampedMs === stopMs) break + } - currentTime = followingTime - currentObservation = observeAt(currentTime) - continue + const losMs = secantMethod( + (ms) => elevationRelativeTo(satrec, observerGeodeticRadians, ms, minElevationRadians), + peakMs, + losBracketMs, + elevationToleranceRadians, + maxIterations, + ) + + // <-------------------------------------------------------------------> + // START / STOP (0-degree horizon crossings). Per the transit definition, + // the reported start and stop conform to the true horizon (0 rad), while + // AOS/LOS conform to the minimum elevation threshold. When + // minElevationAngle == 0 these are the *same* events, so we reuse the + // already-refined AOS/LOS times directly (guaranteeing start === aos and + // stop === los). When minElevationAngle > 0 the horizon crossings fall + // slightly *outside* the AOS/LOS pair, and we locate them separately. + // + // The horizon search marches outward from AOS (backward in time) and LOS + // (forward in time) one coarse step at a time until the elevation drops + // below the horizon. The bracket for the secant refinement pairs that first + // below-horizon point with the pass's peak (which is always above the + // horizon), so the refined root is guaranteed to be the single horizon + // crossing belonging to this pass rather than one from an adjacent pass. The + // march is bounded by the search window. + // <-------------------------------------------------------------------> + + let startMsEvent: number + let stopMsEvent: number + + if (minElevationRadians === 0) { + // The minimum-elevation crossings are exactly the horizon crossings. + startMsEvent = aosMs + stopMsEvent = losMs + } else { + const rawStartMs = findHorizonCrossing( + satrec, observerGeodeticRadians, aosMs, peakMs, -stepMs, startMs, stopMs, elevationToleranceRadians, maxIterations, + ) + const rawStopMs = findHorizonCrossing( + satrec, observerGeodeticRadians, losMs, peakMs, stepMs, startMs, stopMs, elevationToleranceRadians, maxIterations, + ) + + // The horizon crossings must bound the AOS/LOS pair; clamp so the + // invariant start <= aos <= los <= stop always holds exactly. + startMsEvent = Math.min(rawStartMs, aosMs) + stopMsEvent = Math.max(rawStopMs, losMs) + } + + // <-------------------------------------------------------------------> + // TCA (time of closest approach): the minimum slant range during the pass. + // Because slant range is minimized near the elevation peak, we reuse the + // peak brackets and refine on the range *rate* (a zero of d(range)/dt marks + // the closest approach), estimated by finite difference. Convergence is + // judged on the range rate approaching zero within the rate tolerance. + // <-------------------------------------------------------------------> + + const slantRange = (ms: number): number => + computeSatelliteObservation( + omm, satrec, DateTime.fromMillis(ms, { zone: "utc" }), observerPosition, angularUnits, timestampFormat, + ).slantRange! + const rangeRate = (ms: number): number => { + const before = slantRange(ms - rateDeltaMs) + const after = slantRange(ms + rateDeltaMs) + return ((after - before) / (2 * rateDeltaMs)) * msPerSecond } + const tcaMs = secantExtremum( + (ms) => rangeRate(ms), + peakBracketAMs, + peakBracketBMs, + slantRangeRateTolerance, + maxIterations, + ) - currentTime = nextTime - currentObservation = nextObservation + // <-------------------------------------------------------------------> + // Build the transit record. The refined event times are converted from + // milliseconds to luxon DateTime objects here — the only place in the + // search where DateTime objects are materialized — so azimuth/elevation/ + // slant range are self-consistent with the refined timestamps and the + // requested output units. + // <-------------------------------------------------------------------> + + transits.push({ + start: formatTimestamp(DateTime.fromMillis(startMsEvent, { zone: "utc" }), timestampFormat), + stop: formatTimestamp(DateTime.fromMillis(stopMsEvent, { zone: "utc" }), timestampFormat), + duration: (stopMsEvent - startMsEvent) / 1000, + aos: buildTransitEvent( + computeSatelliteObservation(omm, satrec, DateTime.fromMillis(aosMs, { zone: "utc" }), observerPosition, angularUnits, timestampFormat), + ), + los: buildTransitEvent( + computeSatelliteObservation(omm, satrec, DateTime.fromMillis(losMs, { zone: "utc" }), observerPosition, angularUnits, timestampFormat), + ), + tca: buildTransitEvent( + computeSatelliteObservation(omm, satrec, DateTime.fromMillis(tcaMs, { zone: "utc" }), observerPosition, angularUnits, timestampFormat), + ), + peak: buildTransitEvent(peakObservation), + }) + + // Advance past the coarse samples spanned by this pass (up to LOS) so the + // outer scan does not re-inspect intervals already accounted for. Each pass + // has a single culmination, so this simply skips ahead to where the + // satellite has set below the threshold. + while (i < samples.length - 1 && samples[i + 1].ms < losMs) { + i++ + } } return transits @@ -315,23 +464,27 @@ export type { SatelliteObservation, SatelliteTransit, TransitEvent, - UnitOptions, Velocity, + TransitSearchOptions, } from "./interfaces" -// Re-export the public types so consumers can import them from the module root export type { AstronomialUnits, - Degrees, - Kilometers, - KilometersPerSecond, + Milliseconds, OrbitMeanElementsMessage, - Radians, + Seconds, Timestamp, TwoLineElement, } from "./types" export { AngularUnits, - TimestampType, -} from './enums' + TimestampFormat, +} from "./enums" + +export type { + Degrees, + Radians, + Kilometer, + KilometerPerSecond +} from "satellite.js" diff --git a/src/types.ts b/src/types.ts index 8f69b91..53b7968 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,18 +1,6 @@ import { DateTime } from "luxon"; import { OMMJsonObjectV3 } from "satellite.js"; -/** Kilometers */ -export type Kilometers = number; - -/** Kilometers per second */ -export type KilometersPerSecond = number; - -/** Radians */ -export type Radians = number; - -/** Degrees */ -export type Degrees = number; - /** Astonomical Unit (AU) */ export type AstronomialUnits = number; @@ -27,3 +15,6 @@ export type Timestamp = DateTime | Date | string | number /** Seconds */ export type Seconds = number + +/** Milliseconds */ +export type Milliseconds = number diff --git a/src/utils.ts b/src/utils.ts index 23a8d3c..3af4a88 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,11 +1,10 @@ import { DateTime } from "luxon"; -import { Position, SatelliteObservation, TransitEvent, UnitOptions, Velocity } from "./interfaces.ts"; +import { Position, SatelliteObservation, TransitEvent, Velocity } from "./interfaces.ts"; import { WGS84, astronomicalUnit, day2ms, geostationaryMeanMotion, geostationaryTolerance, rad2deg } from "./constants.ts"; -import type { Degrees, Kilometers, Radians, Timestamp } from "./types.ts"; +import type { Seconds, Timestamp } from "./types.ts"; import { TwoLineElement, OrbitMeanElementsMessage } from "./types.ts"; -import { AngularUnits, TimestampType } from "./enums.ts"; +import { AngularUnits, TimestampFormat } from "./enums.ts"; import { - dopplerFactor, ecfToLookAngles, gstime, jday, @@ -26,6 +25,10 @@ import { MeanElements, shadowFraction, sunPos, + Kilometer, + KilometerPerSecond, + EcfVec3, + Radians, } from "satellite.js"; // <---------------------------------------------------------------------------> @@ -35,12 +38,54 @@ import { /** * Calculate the maginture of the vector * @param vector - * @returns + * @returns number */ export function vectorMagnitude(vector: { x: number; y: number; z: number }): number { return Math.hypot(vector.x, vector.y, vector.z) } +/** + * Convert a Julian date to a luxon DateTime object + * @param julianDate the julian date number + * @returns DateTime + */ +export function dateTimeFromJulianDate(julianDate: number): DateTime { + // 2440587.5 is the Julian Date for 1970-01-01T00:00:00Z (Unix Epoch) + const msSinceEpoch = (julianDate - 2440587.5) * 86400000; + return DateTime.fromMillis(msSinceEpoch, { zone: 'utc' }); +} + +/** + * Calculate the doppler factor between the observer and the satellite + */ + // Calculate doppler factor using ECEF coordinates + export function dopplerFactorEcf( + observerCoordsEcf: EcfVec3, + positionEcf: EcfVec3, + velocityEcf: EcfVec3, + ): number { + const c = 299792.458; // Speed of light in km/s + + // 1. Calculate the line-of-sight range vector from observer to satellite + const rangeX = positionEcf.x - observerCoordsEcf.x; + const rangeY = positionEcf.y - observerCoordsEcf.y; + const rangeZ = positionEcf.z - observerCoordsEcf.z; + + // 2. Calculate slant range distance + const length = Math.sqrt(rangeX ** 2 + rangeY ** 2 + rangeZ ** 2); + + // Avoid division by zero if observer and satellite positions are identical + if (length === 0) return 1; + + // 3. Range rate is the dot product of the range vector and relative ECEF velocity vector + // (Since observer velocity is 0 in ECEF, rangeVel is exactly velocityEcf) + const rangeRate = + (rangeX * velocityEcf.x + rangeY * velocityEcf.y + rangeZ * velocityEcf.z) / length; + + // 4. Return Doppler multiplier factor + return 1 - rangeRate / c; + } + /** * Determine whether a satellite is in a geostationary orbit from its * propagated mean elements. @@ -113,7 +158,7 @@ export function betaAngle( * based on a WGS84 ellipsoid. * @param latitude sub-satellite point (SSP) latitude in radians. */ -export function localEarthRadius(latitude: Radians): Kilometers { +export function localEarthRadius(latitude: Radians): Kilometer { const re = WGS84.a / Math.sqrt((1 - WGS84.e2 * Math.pow(Math.sin(latitude), 2))); return re; } @@ -124,7 +169,7 @@ export function localEarthRadius(latitude: Radians): Kilometers { * @param altitude satellite altitude (kilometers) * @param minElevationAngle minimum elevation angle (radians) */ -export function earthCentralAngle(re: Kilometers, altitude: Kilometers, minElevationAngle: Radians = 0.0): Radians { +export function earthCentralAngle(re: Kilometer, altitude: Kilometer, minElevationAngle: Radians = 0.0): Radians { const lambda = Math.acos((re / (re + altitude)) * Math.cos(minElevationAngle)) - minElevationAngle return lambda } @@ -135,7 +180,7 @@ export function earthCentralAngle(re: Kilometers, altitude: Kilometers, minEleva * @param altitude satellite altitude (kilometers) * @param minElevationAngle minimum elevation angle (radians) */ - export function footprintDiameter(satPosition: Position, minElevationAngle: Radians = 0.0): Kilometers { + export function footprintDiameter(satPosition: Position, minElevationAngle: Radians = 0.0): Kilometer { const re = localEarthRadius(satPosition.geo!.latitude); const lambda = earthCentralAngle(re, satPosition.geo!.height, minElevationAngle) const footprint = re * lambda * 2 @@ -457,41 +502,34 @@ export function convertGeodeticToDegrees(position: Position): Position { /** * Convert a luxon DateTime object to the format specified by the timestamp type */ -export function formatTimestamp(datetime: DateTime, timestampType: TimestampType): Timestamp { - switch (timestampType) { +export function formatTimestamp(datetime: DateTime, timestampFormat: TimestampFormat): Timestamp { + switch (timestampFormat) { /** Return the luxon DateTime object unmodified */ - case TimestampType.DateTime: + case TimestampFormat.DateTime: return datetime; /** Return standard Javascript Date object */ - case TimestampType.Date: + case TimestampFormat.Date: return datetime.toJSDate() /** Return ISO8601 formatted timestamp string */ - case TimestampType.ISO8601: + case TimestampFormat.ISO8601: return datetime.toISO()! /** Return the number of milliseconds since Unix epoch */ - case TimestampType.Unix: + case TimestampFormat.Unix: return datetime.toMillis() } } -/** Define the default unit options for the propagation functions. */ -export const defaultUnitOptions: UnitOptions = { - angular: AngularUnits.Degrees, - timestamp: TimestampType.ISO8601, -} - export function computeSatelliteObservation( omm: OrbitMeanElementsMessage, satrec: SatRec, datetime: DateTime, observerPosition?: Position, - unitOptions?: UnitOptions, + angularUnits: AngularUnits = AngularUnits.Degrees, + timestampFormat: TimestampFormat = TimestampFormat.ISO8601 ): SatelliteObservation { - const unitOpts: UnitOptions = { ...defaultUnitOptions, ...(unitOptions ?? {}) } - // Returns the satellite position and velocity in ECI coordinations const satPropagation = propagate(satrec, datetime.toJSDate()) @@ -508,7 +546,7 @@ export function computeSatelliteObservation( throw new Error('Predicted orbit eccentricity is out of range for SGP4 propagation model') case SatRecError.SemiLatusRectumBelowZero: - throw new Error('Predicted orbit has collapsed mathematically') + throw new Error('Predicted orbit has mathematically collapsed') case SatRecError.Decayed: return { @@ -516,7 +554,7 @@ export function computeSatelliteObservation( name: omm.OBJECT_NAME, noradCatalogId: omm.NORAD_CAT_ID as string, orbitalModel: omm.MEAN_ELEMENT_THEORY, - epoch: formatTimestamp(datetime, unitOpts.timestamp!), + epoch: formatTimestamp(datetime, timestampFormat), decayed: true, } } @@ -527,7 +565,7 @@ export function computeSatelliteObservation( const gmst = greenwichMeanSiderealTime(datetime) // Calculate the satellite's position and velocity in other coordinate frames - const satPosition = inferPosition({ eci: satPropagation.position }, gmst, unitOpts.angular!) + const satPosition = inferPosition({ eci: satPropagation.position }, gmst, angularUnits) const satVelocity = inferVelocity({ eci: satPropagation.velocity }, gmst) // Calculate the sun's position in kilometers @@ -537,7 +575,7 @@ export function computeSatelliteObservation( y: sunEciAU.y * astronomicalUnit, z: sunEciAU.z * astronomicalUnit, } - const sunPosition = inferPosition({ eci: sunEci }, gmst, unitOpts.angular!) + const sunPosition = inferPosition({ eci: sunEci }, gmst, angularUnits) // Calculate the eclipse factor const eclipseFactor = shadowFraction(sunEciAU, satPosition.eci!) @@ -560,22 +598,22 @@ export function computeSatelliteObservation( name: omm.OBJECT_NAME, noradCatalogId: omm.NORAD_CAT_ID as string, orbitalModel: omm.MEAN_ELEMENT_THEORY, - epoch: formatTimestamp(datetime, unitOpts.timestamp!), + epoch: formatTimestamp(datetime, timestampFormat), gmst, - position: unitOpts.angular === AngularUnits.Degrees ? convertGeodeticToDegrees(satPosition) : satPosition, + position: angularUnits === AngularUnits.Degrees ? convertGeodeticToDegrees(satPosition) : satPosition, velocity: satVelocity, footprint, orbit: { revolutionCount: predictedRevolutionCount(omm, datetime), - phase: unitOpts.angular === AngularUnits.Degrees ? phaseRadians * rad2deg : phaseRadians, + phase: angularUnits === AngularUnits.Degrees ? phaseRadians * rad2deg : phaseRadians, phase256: phaseRadians * (256 / twoPi), velocity: vectorMagnitude(satVelocity.eci!), }, decayed: false, geostationary: isGeostationary(satPropagation.meanElements), sunlit: eclipseFactor < 1, - sunPosition: unitOpts.angular === AngularUnits.Degrees ? convertGeodeticToDegrees(sunPosition) : sunPosition, - betaAngle: unitOpts.angular === AngularUnits.Degrees ? betaAngleRadians * rad2deg : betaAngleRadians, + sunPosition: angularUnits === AngularUnits.Degrees ? convertGeodeticToDegrees(sunPosition) : sunPosition, + betaAngle: angularUnits === AngularUnits.Degrees ? betaAngleRadians * rad2deg : betaAngleRadians, eclipseFactor, } @@ -584,117 +622,425 @@ export function computeSatelliteObservation( } // If we have an observer, calculate the look angles of the satellite - const observerInferedPosition = inferPosition(observerPosition, gmst, unitOpts.angular!) + const observerInferedPosition = inferPosition(observerPosition, gmst, angularUnits) const observerLookAngles = ecfToLookAngles(observerInferedPosition.geo!, satPosition.ecef!) return { ...observation, - observerPosition: unitOpts.angular === AngularUnits.Degrees + observerPosition: angularUnits === AngularUnits.Degrees ? convertGeodeticToDegrees(observerInferedPosition) : observerInferedPosition, - azimuth: unitOpts.angular === AngularUnits.Degrees + azimuth: angularUnits === AngularUnits.Degrees ? radiansToDegrees(observerLookAngles.azimuth) : observerLookAngles.azimuth, - elevation: unitOpts.angular === AngularUnits.Degrees + elevation: angularUnits === AngularUnits.Degrees ? radiansToDegrees(observerLookAngles.elevation) : observerLookAngles.elevation, slantRange: observerLookAngles.rangeSat, - dopplerFactor: dopplerFactor(observerInferedPosition.ecef!, satPosition.ecef!, satVelocity.ecef!), + dopplerFactor: dopplerFactorEcf(observerInferedPosition.ecef!, satPosition.ecef!, satVelocity.ecef!), } } -export function transitObservation( - omm: OrbitMeanElementsMessage, - satrec: SatRec, - datetime: DateTime, - observerPosition: Position, - angularUnits: AngularUnits, -): SatelliteObservation { - return computeSatelliteObservation(omm, satrec, datetime, observerPosition, { - angular: angularUnits, - timestamp: TimestampType.DateTime, - }) +/** + * Build and return a satellite transit event + * @param epoch + * @param elevation + * @param azimuch + */ +export function buildTransitEvent( + observation: SatelliteObservation +): TransitEvent { + return { + epoch: observation.epoch!, + elevation: observation.elevation!, + azimuth: observation.azimuth!, + slantRange: observation.slantRange!, + dopplerFactor: observation.dopplerFactor! + } } -export function isVisible(observation: SatelliteObservation, minElevation: Degrees | Radians): boolean { - return !observation.decayed && (observation.elevation ?? -Infinity) >= minElevation +// <---------------------------------------------------------------------------> +// TRANSIT SEARCH HELPERS +// +// The transit search follows the same two-phase strategy used by Python's +// Skyfield library: +// 1. A *coarse* search samples the satellite's look angles at a fixed step +// over each orbit to bracket candidate events (rise/set zero-crossings of +// elevation, and elevation maxima found via the sign of the elevation +// rate). +// 2. A *fine* refinement uses the secant method to converge on the exact +// event time within a configurable tolerance and iteration budget. +// <---------------------------------------------------------------------------> + +/** + * Derive the coarse-search step size (in seconds) from the satellite's mean + * motion, mirroring Skyfield's `find_events` heuristic. + * + * Skyfield samples roughly 20 times per orbital revolution + * (`step_days = 0.05 / orbits_per_day`), which keeps the step well under a + * single pass so every culmination is bracketed by adjacent samples, while + * scaling naturally across orbit regimes (faster LEO -> finer step, slower + * orbits -> coarser step). The step is capped at a quarter day so very slow + * (near-geostationary) satellites — which rise and set because the Earth + * rotates beneath them rather than from their own motion — are still sampled + * often enough to catch each pass. + * + * @param meanMotionRevsPerDay the satellite's mean motion in revolutions per day + * @returns the coarse-search step size in seconds + */ +export function dynamicStepSeconds(meanMotionRevsPerDay: number): Seconds { + const secondsPerDay = 86400 + + // Guard against zero/negative/NaN mean motion so the step never blows up. + const orbitsPerDay = meanMotionRevsPerDay > 0 ? meanMotionRevsPerDay : 1.0 + + // ~20 samples per revolution (0.05 of an orbit per sample). + let stepDays = 0.05 / orbitsPerDay + + // Never step more coarsely than a quarter day, even for slow movers. + if (stepDays > 0.25) { + stepDays = 0.25 + } + + return stepDays * secondsPerDay +} + +/** + * Compute the satellite's elevation angle (radians) above the observer's local + * horizon at a given instant. + * + * This is the fundamental scalar function that the coarse search and the secant + * refinement both evaluate. It propagates the satellite with SGP4, converts the + * ECI position to the Earth-fixed frame using the sidereal time at that + * instant, and returns the topocentric elevation angle relative to the + * observer's geodetic position (which is expressed in radians). + * + * The instant is passed as milliseconds since the Unix epoch so the transit + * search can perform all of its arithmetic in a single linear numeric unit + * without constructing intermediate luxon `DateTime` objects. + * + * @param satrec the initialized SGP4 record for the satellite + * @param observerGeodeticRadians the observer's geodetic location in radians + * @param epochMs the instant at which to evaluate the elevation, in epoch ms + * @returns the elevation angle in radians (negative when below the horizon) + */ +export function elevationAt( + satrec: SatRec, + observerGeodeticRadians: GeodeticLocation, + epochMs: number, +): Radians { + const date = new Date(epochMs) + const propagation = propagate(satrec, date) + + // If SGP4 cannot produce a position (e.g. the orbit has decayed) treat the + // satellite as being infinitely far below the horizon so it never registers + // as a pass. + if (propagation === null) { + return Number.NEGATIVE_INFINITY + } + + const gmst = gstime(date) + const positionEcf = eciToEcf(propagation.position, gmst) + const lookAngles = ecfToLookAngles(observerGeodeticRadians, positionEcf) + + return lookAngles.elevation } -export function toDateTime(milliseconds: number): DateTime { - return DateTime.fromMillis(milliseconds, { zone: 'UTC' }) +/** + * Compute the elevation angle *relative to a reference elevation*, i.e. + * `elevation(t) - referenceElevation`. This shifted function crosses zero + * exactly when the satellite passes through the reference elevation, which lets + * the same secant root-finder locate both horizon (0 rad) crossings and + * minimum-elevation (AOS/LOS) crossings. + * + * @param satrec the initialized SGP4 record for the satellite + * @param observerGeodeticRadians the observer's geodetic location in radians + * @param epochMs the instant at which to evaluate the elevation, in epoch ms + * @param referenceElevation the elevation offset to subtract, in radians + * @returns elevation(epochMs) - referenceElevation, in radians + */ +export function elevationRelativeTo( + satrec: SatRec, + observerGeodeticRadians: GeodeticLocation, + epochMs: number, + referenceElevation: Radians, +): Radians { + return elevationAt(satrec, observerGeodeticRadians, epochMs) - referenceElevation } -export function refineHorizonCrossing( - startTime: DateTime, - startObservation: SatelliteObservation, - endTime: DateTime, - endObservation: SatelliteObservation, - minElevation: Degrees | Radians, - observeAt: (datetime: DateTime) => SatelliteObservation, - iterations: number, -): { time: DateTime; observation: SatelliteObservation } { - let lowTime = startTime - let highTime = endTime - let lowObservation = startObservation - let highObservation = endObservation - const lowVisible = isVisible(startObservation, minElevation) - - for (let iteration = 0; iteration < iterations; iteration += 1) { - const midpoint = toDateTime((lowTime.toMillis() + highTime.toMillis()) / 2) - const midpointObservation = observeAt(midpoint) - - if (isVisible(midpointObservation, minElevation) === lowVisible) { - lowTime = midpoint - lowObservation = midpointObservation +/** + * Refine the time at which a scalar function of time crosses zero, using a + * bracketed secant method (secant steps with a bisection fallback). + * + * The plain secant method draws a line through the two most recent samples and + * takes its x-intercept as the next estimate: + * + * t_{n+1} = t_n - f(t_n) * (t_n - t_{n-1}) / (f(t_n) - f(t_{n-1})) + * + * That extrapolating step can, however, jump far outside the starting interval + * when the function is not locally linear — landing on a completely different + * root (e.g. the rise/set of a neighbouring pass). To stay robust, this routine + * maintains a bracket `[lo, hi]` whose endpoints straddle the crossing + * (`f(lo)` and `f(hi)` have opposite signs) and only accepts a secant iterate + * that falls strictly inside the bracket; otherwise it falls back to the + * bracket midpoint (bisection). The bracket is then tightened using the sign of + * the new sample, so the search can never escape the interval `[aMs, bMs]`. + * + * `f` is the target quantity offset from its crossing value — for example + * `elevation(t) - referenceElevation` for a rise/set/horizon event — so the + * root is exactly the crossing time. All times are milliseconds since the Unix + * epoch, so the arithmetic stays in a single linear unit and no intermediate + * `DateTime` objects are constructed. + * + * The endpoints `aMs` and `bMs` MUST straddle the crossing (their `f` values + * must have opposite signs); this is guaranteed at every call site because each + * bracket pairs a coarse sample below the threshold with the pass peak above it. + * + * Convergence is judged on the *value* of `f`: iteration stops once + * `|f(t)| <= valueTolerance`, meaning the quantity is within `valueTolerance` + * of its crossing value (e.g. within 1e-3 radians of the elevation crossing). + * If `maxIterations` is exhausted the best estimate found so far is returned. + * + * @param f the scalar function whose zero we are seeking, evaluated at epoch ms + * @param aMs one bracketing time, in epoch milliseconds + * @param bMs the other bracketing time, in epoch milliseconds + * @param valueTolerance convergence tolerance on `|f(t)|`, in the units of `f` + * @param maxIterations maximum number of secant iterations before giving up + * @returns the refined crossing time, in epoch milliseconds + */ +export function secantMethod( + f: (epochMs: number) => number, + aMs: number, + bMs: number, + valueTolerance: number, + maxIterations: number, +): number { + // Bracket endpoints and their function values. `lo`/`hi` are ordered in time + // but, more importantly, f(lo) and f(hi) must have opposite signs. + let lo = aMs + let hi = bMs + let fLo = f(lo) + let fHi = f(hi) + + // Either endpoint may already be within tolerance of the crossing. + if (Math.abs(fLo) <= valueTolerance) { + return lo + } + if (Math.abs(fHi) <= valueTolerance) { + return hi + } + + // Track the two most recent samples for the secant step. + let t0 = lo + let t1 = hi + let f0 = fLo + let f1 = fHi + + for (let iteration = 0; iteration < maxIterations; iteration++) { + const denominator = f1 - f0 + + // Secant update, unless the line is flat (denominator 0), in which case the + // step is undefined and we fall straight through to the bisection fallback. + let t2 = denominator === 0 ? NaN : t1 - f1 * (t1 - t0) / denominator + + // Reject secant iterates that leave the bracket (or are non-finite) and + // fall back to bisection, which is guaranteed to stay inside and converge. + const lower = Math.min(lo, hi) + const upper = Math.max(lo, hi) + if (!Number.isFinite(t2) || t2 <= lower || t2 >= upper) { + t2 = (lo + hi) / 2 + } + + const f2 = f(t2) + + // Converged once the target quantity is within tolerance of its crossing. + if (Math.abs(f2) <= valueTolerance) { + return t2 + } + + // Tighten the bracket: replace the endpoint on the same side of the root as + // the new sample, preserving the opposite-sign invariant. + if ((f2 < 0) === (fLo < 0)) { + lo = t2 + fLo = f2 } else { - highTime = midpoint - highObservation = midpointObservation + hi = t2 + fHi = f2 } + + // Advance the secant window to the two newest in-bracket samples. + t0 = t1 + f0 = f1 + t1 = t2 + f1 = f2 } - return Math.abs((lowObservation.elevation ?? 0) - minElevation) < Math.abs((highObservation.elevation ?? 0) - minElevation) - ? { time: lowTime, observation: lowObservation } - : { time: highTime, observation: highObservation } + // Did not converge within the iteration budget; return the latest estimate. + return t1 } -export function refineTransitExtremum( - startTime: DateTime, - endTime: DateTime, - observeAt: (datetime: DateTime) => SatelliteObservation, - score: (observation: SatelliteObservation) => number, - maximize: boolean, - iterations: number, -): { time: DateTime; observation: SatelliteObservation } { - let left = startTime.toMillis() - let right = endTime.toMillis() - - for (let iteration = 0; iteration < iterations; iteration += 1) { - const oneThird = (right - left) / 3 - const firstMid = left + oneThird - const secondMid = right - oneThird - const firstScore = score(observeAt(toDateTime(firstMid))) - const secondScore = score(observeAt(toDateTime(secondMid))) - - if ((maximize && firstScore < secondScore) || (!maximize && firstScore > secondScore)) { - left = firstMid +/** + * Refine the time at which a scalar function of time reaches a local extremum + * (maximum or minimum) using a bracketed secant method on the function's *rate*. + * + * An extremum occurs where the derivative `f'(t) = 0`, so the secant method is + * applied to a finite-difference estimate of the rate to drive it toward zero. + * As with {@link secantMethod}, a plain secant step can extrapolate outside the + * starting interval and converge on a different extremum; to prevent that, this + * routine keeps a bracket `[lo, hi]` whose rate values straddle zero and rejects + * any secant iterate that leaves the bracket, falling back to bisection. The + * bracket is tightened by the sign of each new rate sample so the search stays + * within `[aMs, bMs]`. + * + * The endpoints `aMs` and `bMs` MUST straddle the extremum (their rate values + * must have opposite signs); every call site guarantees this by locating the + * coarse interval where the rate flips sign before refining. + * + * Convergence is judged on the rate itself: iteration stops once + * `|rate(t)| <= rateTolerance`, meaning the derivative is within `rateTolerance` + * of zero (e.g. the elevation rate is within a small rad/s of the peak, or the + * range rate is within a small km/s of the closest approach). If + * `maxIterations` is exhausted the best estimate found so far is returned. + * + * Times are milliseconds since the Unix epoch, while the `rate` callback is + * expected to return the derivative in per-second units. + * + * @param rate a finite-difference estimate of d(value)/dt, evaluated at epoch ms + * @param aMs one bracketing time, in epoch milliseconds + * @param bMs the other bracketing time, in epoch milliseconds + * @param rateTolerance convergence tolerance on `|rate(t)|`, in the units of `rate` + * @param maxIterations maximum number of secant iterations before giving up + * @returns the refined extremum time, in epoch milliseconds + */ +export function secantExtremum( + rate: (epochMs: number) => number, + aMs: number, + bMs: number, + rateTolerance: number, + maxIterations: number, +): number { + // Bracket endpoints whose rate values straddle zero (opposite signs). + let lo = aMs + let hi = bMs + let rLo = rate(lo) + let rHi = rate(hi) + + // Either endpoint may already be within tolerance of the extremum. + if (Math.abs(rLo) <= rateTolerance) { + return lo + } + if (Math.abs(rHi) <= rateTolerance) { + return hi + } + + // Track the two most recent samples for the secant step. + let t0 = lo + let t1 = hi + let r0 = rLo + let r1 = rHi + + for (let iteration = 0; iteration < maxIterations; iteration++) { + const denominator = r1 - r0 + + // Secant update on the rate, unless the line is flat (undefined step). + let t2 = denominator === 0 ? NaN : t1 - r1 * (t1 - t0) / denominator + + // Reject iterates that leave the bracket (or are non-finite); bisect instead. + const lower = Math.min(lo, hi) + const upper = Math.max(lo, hi) + if (!Number.isFinite(t2) || t2 <= lower || t2 >= upper) { + t2 = (lo + hi) / 2 + } + + const r2 = rate(t2) + + // Converged once the rate of change is within tolerance of zero. + if (Math.abs(r2) <= rateTolerance) { + return t2 + } + + // Tighten the bracket, preserving the opposite-sign invariant on the rate. + if ((r2 < 0) === (rLo < 0)) { + lo = t2 + rLo = r2 } else { - right = secondMid + hi = t2 + rHi = r2 } + + // Advance the secant window. + t0 = t1 + r0 = r1 + t1 = t2 + r1 = r2 } - const midpoint = toDateTime((left + right) / 2) - return { time: midpoint, observation: observeAt(midpoint) } + // Did not converge within the iteration budget; return the latest estimate. + return t1 } -export function buildTransitEvent( - time: DateTime, - observation: SatelliteObservation, - timestampType: TimestampType, -): TransitEvent { - return { - epoch: formatTimestamp(time, timestampType), - azimuth: observation.azimuth!, - elevation: observation.elevation!, - slantRange: observation.slantRange!, +/** + * Locate the horizon (0-radian elevation) crossing for a single pass by + * marching outward from a known event time (AOS or LOS) until the elevation + * drops below the horizon, then refining the crossing with the secant method. + * + * At AOS/LOS the elevation equals the (non-negative) minimum elevation, so + * marching *away* from the pass the elevation decreases monotonically toward and + * then below 0 rad. Once a below-horizon sample is found it is paired with the + * pass's peak time (which is guaranteed above the horizon) to form a bracket + * that straddles exactly one crossing — the one belonging to this pass — before + * the secant method refines it. When the minimum elevation is 0 the crossing + * coincides with the anchor to within tolerance. + * + * The march is bounded by the search window; if the horizon is never crossed + * inside the window the anchor time is returned as a safe fallback. + * + * @param satrec initialized SGP4 record + * @param observerGeodeticRadians observer geodetic position in radians + * @param anchorMs the AOS or LOS time to march away from, in epoch milliseconds + * @param peakMs the pass's culmination time (always above the horizon), in epoch ms + * @param stepMs signed march increment in milliseconds (negative marches backward) + * @param startMs lower bound of the search window in epoch milliseconds + * @param stopMs upper bound of the search window in epoch milliseconds + * @param elevationToleranceRadians secant convergence tolerance in radians + * @param maxIterations secant iteration limit + * @returns the refined horizon-crossing time, in epoch milliseconds + */ +export function findHorizonCrossing( + satrec: SatRec, + observerGeodeticRadians: GeodeticLocation, + anchorMs: number, + peakMs: number, + stepMs: number, + startMs: number, + stopMs: number, + elevationToleranceRadians: number, + maxIterations: number, +): number { + let outerMs = anchorMs + + // March outward until the elevation is below the horizon. + while (true) { + const elevationOuter = elevationAt(satrec, observerGeodeticRadians, outerMs) + if (elevationOuter < 0) { + // Bracket the crossing between the peak (above horizon) and this + // below-horizon point, then refine. + return secantMethod( + (ms) => elevationRelativeTo(satrec, observerGeodeticRadians, ms, 0), + peakMs, + outerMs, + elevationToleranceRadians, + maxIterations, + ) + } + + const nextMs = outerMs + stepMs + + // Stop marching at the window edge; return the anchor as a safe fallback. + if (nextMs < startMs || nextMs > stopMs) { + return anchorMs + } + + outerMs = nextMs } } From 9cbe32817d2fbb2fc477724d92362e6098238421 Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Mon, 17 Aug 2026 16:00:36 -0600 Subject: [PATCH 09/14] Adding documentation --- README.md | 679 +++++++++++++++++++++++++++++++------ src/__tests__/main.test.ts | 38 ++- src/constants.ts | 24 ++ src/interfaces.ts | 55 ++- src/main.ts | 83 ++--- src/utils.ts | 34 +- 6 files changed, 718 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index 449101c..baa1add 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,604 @@ # JsPredict -A Javascript port of the popular `predict` satellite tracking library. - -### Based on: -- PREDICT: http://www.qsl.net/kd2bd/predict.html -- PyPredict: https://github.com/nsat/pypredict -- Python-SGP4: https://github.com/brandon-rhodes/python-sgp4 +JavaScript/TypeScript open-source satellite tracking library. JsPredict uses the SGP4/SDP4 +propagation models (via [satellite.js](https://github.com/shashwatak/satellite-js)) +to compute satellite positions, observer look angles, and ground-station passes +from a TLE or OMM element set. ## Installation -JsPredict v2.x is hosted on GitHub Package and can be installed via the following command: +```sh +npm install jspredict +``` + +JsPredict is published as an ES module and ships with TypeScript type +definitions. -```shell -npm install jspredict +```ts +import { satelliteObservation, satelliteTransits } from "jspredict" ``` -## API +## Concepts -#### Input Types +JsPredict exposes two primary functions: -```js -tle = 3 line string with "\n" character line breaks +| Function | Purpose | +| --- | --- | +| `satelliteObservation` | Compute the state of a satellite (position, velocity, orbit, sun geometry, and optional observer look angles) at one or more instants in time. | +| `satelliteTransits` | Find every pass a satellite makes over a fixed ground location within a time window, including AOS, LOS, peak, and time of closest approach. | + +### Satellite element sets + +Both functions accept the satellite's orbital elements as either: + +- A **Two-Line Element (TLE)** string. A leading name line (line 0) is + optional but recommended so the returned observation carries a `name`. +- An **Orbit Mean-Elements Message (OMM)** JSON object (CCSDS OMM v3, the shape + returned by Space-Track's API). -qth = 3 element array [latitude (degrees), longitude (degrees), altitude (km)] +```ts +// TLE (with optional name line) +const issTle = `0 ISS (ZARYA) +1 25544U 98067A 26219.02141064 .00004539 00000-0 89363-4 0 9992 +2 25544 51.6324 48.5171 0007293 20.5996 339.5285 15.49370096579630` -time, start, or end = unix timestamp (ms) or date object "new Date()" +// OMM JSON +const issOmm = { + OBJECT_NAME: "ISS (ZARYA)", + OBJECT_ID: "1998-067A", + NORAD_CAT_ID: "25544", + EPOCH: "2026-08-07T00:30:49.879296", + MEAN_MOTION: "15.49370096", + // ...remaining OMM fields +} ``` -#### Methods +### Timestamps -```js -observe(tle 'required', qth 'optional', time 'optional') +Any field that accepts a time (`epoch`, `startTime`, `stopTime`) accepts a +flexible `Timestamp`. The input format is **auto-detected** — you do **not** +need to tell the library which one you are using, and the `timestampFormat` +option affects **output only** (it has no effect on how inputs are parsed). Any +of the four forms may be passed to either function, and you can even mix forms +across arguments or within an epoch array: + +| Input form | Type | Example | How it's interpreted | +| --- | --- | --- | --- | +| Unix milliseconds | `number` | `1786062649879` | Milliseconds since the Unix epoch, treated as **UTC**. | +| ISO 8601 string | `string` | `"2026-08-07T00:30:49.879Z"` | Parsed as ISO 8601. See the timezone note below. | +| JavaScript `Date` | `Date` | `new Date("2026-08-07T00:30:49.879Z")` | Converted directly from the `Date` instant. | +| Luxon `DateTime` | `DateTime` | `DateTime.utc(2026, 8, 7)` | Used as-is, preserving its zone. | + +> Timezone handling for strings: if the ISO string carries an explicit offset or +> `Z` (e.g. `2026-08-07T00:30:49.879Z` or `...+02:00`), that zone is respected. +> A string **without** any timezone (e.g. `2026-08-07T00:30:49.879`) is assumed +> to be **UTC**. Numeric (Unix ms) inputs are always UTC. + +```ts +import { DateTime } from "luxon" -observes(tle 'required', qth 'optional', start 'optional', end 'required', interval 'optional') +// All four of these refer to the same instant and are accepted interchangeably: +satelliteObservation(issTle, 1786062649879) // number (ms, UTC) +satelliteObservation(issTle, "2026-08-07T00:30:49.879Z") // ISO 8601 string +satelliteObservation(issTle, new Date("2026-08-07T00:30:49.879Z")) // Date +satelliteObservation(issTle, DateTime.fromISO("2026-08-07T00:30:49.879Z")) // DateTime -transits(tle 'required', qth 'required', start 'optional', end 'required', minElevation 'optional', maxTransits 'optional') +// Mixed forms in an epoch array are fine too: +satelliteObservation(issTle, [ + "2026-08-07T00:30:49.879Z", + new Date("2026-08-08T00:30:49.879Z"), + 1786235449879, +]) + +// startTime / stopTime for transits accept the same flexible input: +satelliteTransits( + issOmm, + "2026-08-07T01:00:00Z", // ISO string + new Date("2026-08-08T01:00:00Z"), // Date + observerPosition, +) ``` -## Examples +The **output** timestamp form (`epoch`, `start`, `stop`) is independent of the +input and is controlled entirely by the `timestampFormat` option — see +[Configuration options](#configuration-options). It defaults to ISO 8601 +strings. All times are computed in UTC. -### Observe a Satellite: +### Observer position -```js -> var tle = '0 LEMUR-2 JEROEN\n1 40934U 15052E 15306.10048119 .00001740 00000-0 15647-3 0 9990\n2 40934 6.0033 141.2190 0010344 133.6141 226.4604 14.76056230 5130'; -> var jspredict = require('jspredict'); -> jspredict.observe(tle, null); -{ eci: - { position: - { x: 6780.217861682045, - y: -1754.945569075624, - z: -382.1001487529574 }, - velocity: - { x: 1.8548312182745958, - y: 7.28225574805238, - z: -0.6742937006920255 } }, - gmst: 1.2743405900207918, - latitude: -3.141891992384467, - longitude: -87.52591692501754, - altitude: 635.9975103859342, - footprint: 5474.178485006438 } -``` - -### Observe a Satellite from an Observer at 15 lat, 130, lon, 10m alt: +An observer (ground station) is described by a `Position` object with a `geo` +field holding geodetic coordinates: -```js -> var tle = '0 LEMUR-2 JEROEN\n1 40934U 15052E 15306.10048119 .00001740 00000-0 15647-3 0 9990\n2 40934 6.0033 141.2190 0010344 133.6141 226.4604 14.76056230 5130'; -> var qth = [15, 130, .1]; -> jspredict.observe(tle, qth); -{ eci: - { position: - { x: 6808.890168241923, - y: -1638.1745052042197, - z: -392.83171494347425 }, - velocity: - { x: 1.729088700801128, - y: 7.313653076194647, - z: -0.6671038712037236 } }, - gmst: 1.275507328110315, - latitude: -3.2301661539920232, - longitude: -86.6090669346031, - altitude: 636.124394452163, - footprint: 5474.682764305541, - azimuth: 75.42118188269167, - elevation: -70.0809770796008, - rangeSat: 12666.306550391646, - doppler: 1.0000075435881037 } -``` - -### Get Transits for Satellite and Observer (minimum elevation of 2 degrees; obtain a maximum of 4 transits) +```ts +const observerPosition = { + geo: { + latitude: 15, // degrees by default (see geodeticAngularUnits) + longitude: 130, // degrees by default + height: 0.1, // kilometers above the ellipsoid + }, +} +``` + +By default latitude/longitude are interpreted as **degrees**. Set +`geodeticAngularUnits: AngularUnits.Radians` in the options to supply radians +instead. + +## `satelliteObservation` + +```ts +satelliteObservation( + satelliteElements, // TLE string | OMM object + epoch, // Timestamp | Timestamp[] + observerPosition?, // Position (optional) + satelliteObservationOptions?, // options object (optional) +): SatelliteObservation | SatelliteObservation[] +``` + +Computes the satellite state at the given `epoch`. If `epoch` is an array, an +array of observations is returned (one per timestamp, in order). If an observer +position is supplied, look angles (azimuth, elevation, slant range, Doppler +factor) are included in the result. + +### Basic usage + +```ts +import { satelliteObservation } from "jspredict" + +const observation = satelliteObservation( + issTle, + "2026-08-07T00:30:49.879Z", +) + +console.log(observation.position?.geo) // sub-satellite lat/lon/height +console.log(observation.velocity?.eci) // ECI velocity vector +console.log(observation.orbit?.revolutionCount) +``` + +```console +{ latitude: -0.000020156475434, longitude: 85.25616601107723, height: 414.6648113012516 } +{ x: -3.5688788491717403, y: 3.144142751825821, z: 6.012239210336608 } +57963 +``` + +The full returned object: + +```console +{ + "id": "1998-067A", + "name": "ISS (ZARYA)", + "noradCatalogId": "25544", + "orbitalModel": "SGP4", + "epoch": "2026-08-07T00:30:49.879Z", + "gmst": 5.641967364224406, + "position": { + "eci": { "x": 4499.52949934419, "y": 5088.849647232988, "z": -0.0000023746 }, + "ecef": { "x": 561.7712271405776, "y": 6769.5324458908435, "z": -0.0000023746 }, + "geo": { "latitude": -0.0000201564, "longitude": 85.25616601107723, "height": 414.6648113012516 } + }, + "velocity": { + "eci": { "x": -3.5688788491717403, "y": 3.144142751825821, "z": 6.012239210336608 }, + "ecef": { "x": -4.740722498497987, "y": 0.3848117411920531, "z": 6.012239210336608 } + }, + "footprint": 4480.19986762669, + "orbit": { + "revolutionCount": 57963, + "phase": 5.9258902293575675, + "phase256": 241.4424888888889, + "velocity": 7.666130067135126 + }, + "decayed": false, + "geostationary": false, + "sunlit": true, + "sunPosition": { + "eci": { "x": -106442754.674, "y": 99203886.990, "z": 43003034.578 }, + "ecef": { "x": -144640775.612, "y": 15827736.632, "z": 43003034.578 }, + "geo": { "latitude": 16.464784495, "longitude": 173.755090434, "height": 151719469.100 } + }, + "betaAngle": -0.6134751473121656, + "eclipseFactor": 0 +} +``` + +### With an observer + +```ts +const observation = satelliteObservation( + issOmm, + "2026-08-07T00:30:49.879Z", + { geo: { latitude: 15, longitude: 130, height: 0.1 } }, +) + +console.log(observation.azimuth) // compass heading to the satellite +console.log(observation.elevation) // angle above the horizon +console.log(observation.slantRange) // line-of-sight distance (km) +console.log(observation.dopplerFactor) +``` + +With an observer, the observation additionally carries `observerPosition`, +`azimuth`, `elevation`, `slantRange`, and `dopplerFactor`: + +```console +255.48422012446775 +-19.163421277725885 +5229.152666560456 +1.000019464535455 +``` + +```console + ... + "observerPosition": { + "eci": { "x": -350.5295256508939, "y": 6152.308010923147, "z": 1640.1260220778647 }, + "ecef": { "x": -3961.040882853815, "y": 4720.584702553575, "z": 1640.1260220778647 }, + "geo": { "latitude": 14.999999999999998, "longitude": 130, "height": 0.1 } + }, + "azimuth": 255.48422012446775, + "elevation": -19.163421277725885, + "slantRange": 5229.152666560456, + "dopplerFactor": 1.000019464535455 +``` + +### Multiple epochs + +```ts +const epochs = [ + "2026-08-07T00:30:49.879Z", + "2026-08-08T00:30:49.879Z", +] + +const observations = satelliteObservation(issOmm, epochs) +// observations is a SatelliteObservation[] with one entry per epoch +``` + +### Result shape + +`SatelliteObservation` includes (fields marked optional are omitted when not +applicable, e.g. observer look angles require an observer position): + +| Field | Description | +| --- | --- | +| `id` | International designator (e.g. `1998-067A`). | +| `name` | Satellite name from the element set. | +| `noradCatalogId` | NORAD catalog number. | +| `epoch` | Observation time, formatted per `timestampFormat`. | +| `gmst` | Greenwich Mean Sidereal Time (radians). | +| `position` | `{ eci, ecef, geo }` position vectors. | +| `velocity` | `{ eci, ecef }` velocity vectors. | +| `footprint` | Ground-coverage diameter (km). | +| `orbit` | `{ revolutionCount, phase, phase256, velocity }`. | +| `orbitalModel` | Propagation theory used (e.g. `SGP4`). | +| `decayed` | `true` if the orbit has decayed at this time. | +| `geostationary` | `true` if the satellite is geostationary. | +| `sunlit` | `true` if the satellite is not fully eclipsed. | +| `sunPosition` | Position of the Sun. | +| `betaAngle` | Angle between the orbital plane and the Sun. | +| `eclipseFactor` | Fraction of the Sun's disc obscured by Earth (0 = fully lit, 1 = umbra). | +| `observerPosition` | Observer's position (only with an observer). | +| `azimuth` | Heading to the satellite (only with an observer). | +| `elevation` | Elevation above the horizon (only with an observer). | +| `slantRange` | Observer-to-satellite distance in km (only with an observer). | +| `dopplerFactor` | Frequency shift relative to the observer (only with an observer). | + +> Note: if the propagated orbit has decayed, a minimal observation is returned +> with `decayed: true` and only `id`, `name`, `noradCatalogId`, `orbitalModel`, +> and `epoch` populated. + +## `satelliteTransits` + +```ts +satelliteTransits( + satelliteElements, // TLE string | OMM object + startTime, // Timestamp + stopTime, // Timestamp + observerPosition, // Position (required) + minElevationAngle?, // number, default 0 + satelliteTransitOptions?, // options object (optional) +): SatelliteTransit[] +``` + +Finds all passes of the satellite over `observerPosition` between `startTime` +and `stopTime`. Each pass reports its horizon-to-horizon start/stop, plus AOS, +LOS, peak-elevation, and time-of-closest-approach events. + +### Basic usage + +```ts +import { satelliteTransits } from "jspredict" + +const transits = satelliteTransits( + issOmm, + "2026-08-07T01:00:00Z", + "2026-08-08T01:00:00Z", + { geo: { latitude: 15, longitude: 130, height: 0.1 } }, +) + +for (const pass of transits) { + console.log("start:", pass.start, "stop:", pass.stop) + console.log("duration (s):", pass.duration) + console.log("peak elevation:", pass.peak.elevation) +} +``` + +```console +start: 2026-08-07T07:16:32.212Z stop: 2026-08-07T07:24:51.248Z +duration (s): 499.0366948242187 +peak elevation: 8.548911076956662 +... +``` + +A single element of the returned `SatelliteTransit[]`: + +```console +{ + "start": "2026-08-07T07:16:32.212Z", + "stop": "2026-08-07T07:24:51.248Z", + "duration": 499.0366948242187, + "aos": { + "epoch": "2026-08-07T07:16:32.212Z", + "elevation": -0.00000980804514841618, + "azimuth": 355.96610919847745, + "slantRange": 2354.291268694053, + "dopplerFactor": 1.0000170656489715 + }, + "los": { + "epoch": "2026-08-07T07:24:51.248Z", + "elevation": 0.000004391528749672061, + "azimuth": 96.61179546159609, + "slantRange": 2359.481290918783, + "dopplerFactor": 0.9999814449675171 + }, + "tca": { + "epoch": "2026-08-07T07:20:41.498Z", + "elevation": 8.548888406311306, + "azimuth": 46.293782335832596, + "slantRange": 1592.8295788230319, + "dopplerFactor": 0.9999989284805654 + }, + "peak": { + "epoch": "2026-08-07T07:20:41.760Z", + "elevation": 8.548911076956662, + "azimuth": 46.36393269609339, + "slantRange": 1592.830631176362, + "dopplerFactor": 0.9999989004667391 + } +} +``` + +### Minimum elevation threshold + +The fifth argument sets the minimum elevation that defines AOS/LOS. It defaults +to `0` (true horizon). Passes whose peak elevation never exceeds the threshold +are discarded. + +```ts +// Only report passes that climb above 20 degrees +const transits = satelliteTransits( + issOmm, + "2026-08-07T01:00:00Z", + "2026-08-08T01:00:00Z", + observerPosition, + 20, +) +console.log(transits.length) +``` + +```console +$ node transits-minel.js +2 # vs. 5 passes with the default 0 threshold over the same window +``` + +The units of `minElevationAngle` follow `elevationAngularUnits` (degrees by +default). + +- `start`/`stop` always mark the true-horizon (0°) crossings. +- `aos`/`los` mark the crossings of `minElevationAngle`. +- When `minElevationAngle` is `0`, `start === aos` and `stop === los`. + +### Result shape + +Each `SatelliteTransit` contains: + +| Field | Description | +| --- | --- | +| `start` | Horizon-crossing start time (formatted per `timestampFormat`). | +| `stop` | Horizon-crossing stop time. | +| `duration` | Seconds from `start` to `stop`. | +| `aos` | Acquisition-of-signal event. | +| `los` | Loss-of-signal event. | +| `tca` | Time of closest approach (minimum slant range). | +| `peak` | Peak-elevation (culmination) event. | + +Each event (`aos`, `los`, `tca`, `peak`) is a `TransitEvent`: + +```ts +interface TransitEvent { + epoch: Timestamp + azimuth: number + elevation: number + slantRange: number // km + dopplerFactor: number +} +``` + +### Errors and warnings + +- Throws `Stop date is less than or equal to start date` if + `stopTime <= startTime`. +- Emits a `console.warn` when the search window begins before the element set's + epoch (propagating before epoch is not recommended). +- Returns `[]` and warns if the satellite has decayed, or if it is + geostationary but sits below `minElevationAngle` for the observer. + +## Configuration options + +Both functions accept a trailing options object. +`satelliteObservation` uses `SatelliteObservationOptions`; +`satelliteTransits` uses `SatelliteTransitOptions`, which extends the +observation options with search-tuning controls. + +### Unit and format options (both functions) + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `azimuthAngularUnits` | `AngularUnits` | `Degrees` | Units for output azimuth. | +| `elevationAngularUnits` | `AngularUnits` | `Degrees` | Units for output elevation and for the `minElevationAngle` input. | +| `geodeticAngularUnits` | `AngularUnits` | `Degrees` | Units for geodetic coordinates, both input (observer position) and output. | +| `betaAngleAngularUnits` | `AngularUnits` | `Degrees` | Units for the beta-angle output. | +| `orbitPhaseAngularUnits` | `AngularUnits` | `Degrees` | Units for the orbit `phase` output. | +| `timestampFormat` | `TimestampFormat` | `ISO8601` | Format of all output timestamps. | + +`AngularUnits` and `TimestampFormat` are exported enums: + +```ts +import { AngularUnits, TimestampFormat } from "jspredict" + +enum AngularUnits { + Degrees = "DEGREES", + Radians = "RADIANS", +} + +enum TimestampFormat { + Unix = "UNIX", // milliseconds since the Unix epoch (number) + ISO8601 = "ISO8601", // ISO 8601 string + Date = "DATE", // JavaScript Date + DateTime = "DATETIME", // Luxon DateTime +} +``` + +### Transit search options (`satelliteTransits` only) + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `elevationToleranceRadians` | `number` | `1e-6` | Angular convergence tolerance (radians) for AOS, LOS, and horizon crossings. | +| `elevationRateTolerance` | `number` | `1e-6` | Rate tolerance (rad/s) for locating the peak (culmination). | +| `slantRangeRateTolerance` | `number` | `1e-4` | Rate tolerance (km/s) for locating the time of closest approach. | +| `maxIterations` | `number` | `100` | Maximum secant iterations per event before falling back to the best estimate. | +| `coarseStepSeconds` | `number` | `undefined` | Override for the coarse-search step size. When omitted, the step is derived from the satellite's mean motion (~20 samples per revolution). | + +### Example: radians and Unix timestamps + +```ts +import { satelliteObservation, AngularUnits, TimestampFormat } from "jspredict" + +const observation = satelliteObservation( + issOmm, + "2026-08-07T00:30:49.879Z", + { + geo: { + latitude: 0.2618, // radians (~15°) + longitude: 2.2689, // radians (~130°) + height: 0.1, + }, + }, + { + azimuthAngularUnits: AngularUnits.Radians, + elevationAngularUnits: AngularUnits.Radians, + geodeticAngularUnits: AngularUnits.Radians, + timestampFormat: TimestampFormat.Unix, + }, +) + +console.log(observation.elevation) // radians +console.log(observation.epoch) // number (ms since epoch) +``` + +```console +-0.3344491773360862 +1786062649879 +``` + +### Example: tuning the transit search + +```ts +const transits = satelliteTransits( + issOmm, + "2026-08-07T01:00:00Z", + "2026-08-08T01:00:00Z", + observerPosition, + 10, // minimum elevation in degrees + { + timestampFormat: TimestampFormat.DateTime, + coarseStepSeconds: 30, // finer coarse sampling + elevationToleranceRadians: 1e-7, + maxIterations: 200, + }, +) +``` + +## Default behavior summary + +- Angular outputs (azimuth, elevation, geodetic coordinates, beta angle, orbit + phase) are in **degrees**. +- Geodetic **inputs** (observer position) are interpreted as **degrees**. +- Timestamps are formatted as **ISO 8601** strings. +- All times are treated as **UTC**. +- `satelliteObservation` omits observer look angles unless an observer position + is supplied. +- `satelliteTransits` uses a `minElevationAngle` of **0°** (true horizon) and + derives its coarse search step dynamically from the satellite's mean motion. + +## Migrating from the legacy 1.2 release + +Version 2.0 is a ground-up rewrite in TypeScript (shipped as an ES module) and is **not +backwards compatible**. If you are upgrading, review the changes below. + +### Function names and signatures + +| 1.2 (`main`) | 2.0 | +| --- | --- | +| `observe(tle, qth?, time?)` | `satelliteObservation(elements, epoch, observerPosition?, options?)` | +| `observes(tle, qth?, start?, end, interval?)` | `satelliteObservation(elements, epoch[], observerPosition?, options?)` — pass an array of timestamps | +| `transits(tle, qth, start?, end, minElevation?, maxTransits?)` | `satelliteTransits(elements, startTime, stopTime, observerPosition, minElevationAngle?, options?)` | + +### Key differences + +- **Element sets.** 1.2 accepted only a newline-delimited TLE string. 2.0 accepts either a TLE or OMM JSON object. +- **Observer position.** 1.2 used a `qth` array `[latitude, longitude, altitude]`. + 2.0 uses a `Position` object: `{ geo: { latitude, longitude, height } }`. +- **Batch observations.** The separate `observes()` (fixed `interval` between + `start`/`end`) is gone; pass an explicit array of timestamps to + `satelliteObservation` and receive one observation per timestamp. +- **Configurable units and timestamp formats.** 1.2 always used degrees and Unix + millisecond timestamps. 2.0 lets you choose degrees or radians per output and + select `Unix`, `ISO8601`, `Date`, or `DateTime` timestamps via the options + object. Defaults are degrees and ISO 8601. +- **Structured output.** Flat 1.2 fields were reorganized: + - `eci.position` / `eci.velocity` → `position.eci` / `velocity.eci` + (plus `ecef` and `geo` frames). + - `latitude` / `longitude` / `altitude` → `position.geo.{latitude,longitude,height}`. + - `rangeSat` → `slantRange`; `doppler` → `dopplerFactor`. + - New fields include `orbit`, `sunPosition`, `betaAngle`, `eclipseFactor`, + `geostationary`, and `orbitalModel`. +- **Richer transits.** 1.2 reported `start`, `end`, `maxElevation`, + `apexAzimuth`, `maxAzimuth`, `minAzimuth`, and `duration`. 2.0 reports + `start`, `stop`, `duration`, and four full events — `aos`, `los`, `tca` + (time of closest approach), and `peak` — each with `epoch`, `azimuth`, + `elevation`, `slantRange`, and `dopplerFactor`. +- **No `maxTransits` cap.** 2.0 returns every pass in the requested window; + slice the result array yourself if you need a limit. +- **Invalid ranges throw.** `satelliteTransits` throws when + `stopTime <= startTime` rather than returning silently. +- **Dependencies.** The `moment.js` dependency was replaced with `luxon`, and + `satellite.js` was upgraded to v7.x. + +### Before / after ```js -> var tle = '0 LEMUR-2 JEROEN\n1 40934U 15052E 15306.10048119 .00001740 00000-0 15647-3 0 9990\n2 40934 6.0033 141.2190 0010344 133.6141 226.4604 14.76056230 5130'; -> var qth = [15, 130, .1]; -> jspredict.transits(tle, qth, 1446516345242, 1446545135046, 2, 4); -[ { start: 1446519623929.2715, - end: 1446520436786.1265, - maxElevation: 26.592307317708126, - apexAzimuth: 173.44894443969358, - maxAzimuth: 244.2708297009277, - minAzimuth: 108.07476128814045, - duration: 812856.8549804688 }, - { start: 1446525901933.6611, - end: 1446526693580.5254, - maxElevation: 24.777958881102588, - apexAzimuth: 170.71484739848532, - maxAzimuth: 244.97838417889344, - minAzimuth: 110.85020906380568, - duration: 791646.8642578125 }, - { start: 1446532176864.1306, - end: 1446533027054.9875, - maxElevation: 20.48579856021555, - apexAzimuth: 194.49205827738396, - maxAzimuth: 242.43145831257118, - minAzimuth: 114.97146874644389, - duration: 850190.8569335938 }, - { start: 1446538461828.8735, - end: 1446539183964.2942, - maxElevation: 15.359176537330036, - apexAzimuth: 188.34763284223402, - maxAzimuth: 236.24036969182643, - minAzimuth: 123.49296057832372, - duration: 722135.4206542969 } ] -> +// 1.2 +const qth = [15, 130, 0.1] +jspredict.transits(tle, qth, 1446516345242, 1446545135046, 2, 4) +``` + +```ts +// 2.0 +satelliteTransits( + tle, + 1446516345242, + 1446545135046, + { geo: { latitude: 15, longitude: 130, height: 0.1 } }, + 2, +) ``` + +## License + +MIT. See the license header in the source for details. diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index de75956..08757ce 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -155,6 +155,17 @@ function expectedAngle(angleRadians: number, angular: AngularUnits): number { return angular === AngularUnits.Degrees ? radiansToDegrees(angleRadians) : angleRadians } +function observationOptionsFor(angular: AngularUnits, timestamp: TimestampFormat) { + return { + azimuthAngularUnits: angular, + elevationAngularUnits: angular, + geodeticAngularUnits: angular, + betaAngleAngularUnits: angular, + orbitPhaseAngularUnits: angular, + timestampFormat: timestamp, + } +} + function observerPositionFor(angular: AngularUnits) { return { geo: angular === AngularUnits.Degrees @@ -200,7 +211,12 @@ const transitUnitOptionCases = [ describe('satelliteObservation', () => { test.each(unitOptionCases)('returns a ground track for $name', ({ angular, timestamp }) => { - const observed = satelliteObservation(issTle, observationEpoch, undefined, angular, timestamp) as SatelliteObservation + const observed = satelliteObservation( + issTle, + observationEpoch, + undefined, + observationOptionsFor(angular, timestamp), + ) as SatelliteObservation const date = new Date(observationEpoch) const satrec = json2satrec(convertTleToOmm(issTle)) const propagated = propagate(satrec, date) @@ -247,8 +263,7 @@ describe('satelliteObservation', () => { issOmm as OMMJsonObjectV3, observationEpoch, observerPosition, - angular, - timestamp, + observationOptionsFor(angular, timestamp), ) const date = new Date(observationEpoch) const satrec = json2satrec(issOmm as OMMJsonObjectV3) @@ -290,8 +305,7 @@ describe('satelliteObservation with array input', () => { issOmm as OMMJsonObjectV3, dateTimes, observerPosition, - angular, - timestamp, + observationOptionsFor(angular, timestamp), ) expect(Array.isArray(observed)).toBe(true) @@ -302,8 +316,7 @@ describe('satelliteObservation with array input', () => { issOmm as OMMJsonObjectV3, dateTime, observerPosition, - angular, - timestamp, + observationOptionsFor(angular, timestamp), ) const { epoch: actualEpoch, ...actualRest } = (observed as any)[index] @@ -331,8 +344,7 @@ describe('satelliteTransits', () => { transitWindowStop, observerPosition, 0, - angular, - timestamp, + observationOptionsFor(angular, timestamp), ) expect(transits.length).toBeGreaterThan(0) @@ -361,10 +373,10 @@ describe('satelliteTransits', () => { expect(tcaMillis).toBeLessThanOrEqual(stopMillis) expect(Math.abs(transit.duration - ((stopMillis - startMillis) / 1000))).toBeLessThan(0.002) - const aosObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.aos.epoch, observerPosition, angular, timestamp) as any - const losObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.los.epoch, observerPosition, angular, timestamp) as any - const peakObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.peak.epoch, observerPosition, angular, timestamp) as any - const tcaObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.tca.epoch, observerPosition, angular, timestamp) as any + const aosObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.aos.epoch, observerPosition, observationOptionsFor(angular, timestamp)) as any + const losObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.los.epoch, observerPosition, observationOptionsFor(angular, timestamp)) as any + const peakObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.peak.epoch, observerPosition, observationOptionsFor(angular, timestamp)) as any + const tcaObservation = satelliteObservation(issOmm as OMMJsonObjectV3, transit.tca.epoch, observerPosition, observationOptionsFor(angular, timestamp)) as any expect(aosObservation.azimuth).toBeCloseTo(transit.aos.azimuth, 10) expect(aosObservation.elevation).toBeCloseTo(transit.aos.elevation, 10) diff --git a/src/constants.ts b/src/constants.ts index 04689ef..c5a325f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,7 @@ import { AstronomialUnits } from "./types"; import { Kilometer } from "satellite.js"; +import { SatelliteObservationOptions, SatelliteTransitOptions } from "./interfaces"; +import { TimestampFormat, AngularUnits } from "./enums"; /** Astronomical Unit - km (IAU 76) */ export const astronomicalUnit: AstronomialUnits = 1.49597870691E8; @@ -49,3 +51,25 @@ export const WGS84 = { /** First eccentricity squared */ e2: 0.00669437999014 } as const; + +/** + * Satellite observation default options + */ +export const defaultSatelliteObservationOptions: SatelliteObservationOptions = { + azimuthAngularUnits: AngularUnits.Degrees, + elevationAngularUnits: AngularUnits.Degrees, + geodeticAngularUnits: AngularUnits.Degrees, + timestampFormat: TimestampFormat.ISO8601 +} + +/** + * Satellite transit default options + */ +export const defaultSatelliteTransitOptions: SatelliteTransitOptions = { + ...defaultSatelliteObservationOptions, + elevationToleranceRadians: 1e-6, + elevationRateTolerance: 1e-6, + slantRangeRateTolerance: 1e-4, + maxIterations: 100, + coarseStepSeconds: undefined +} diff --git a/src/interfaces.ts b/src/interfaces.ts index 08999a9..f3e2aca 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,6 @@ import { EcfVec3, EciVec3, GeodeticLocation, Radians, Degrees, Kilometer, KilometerPerSecond } from "satellite.js" import { Timestamp, Seconds } from "./types.ts" +import { AngularUnits, TimestampFormat } from "./enums.ts" /** Position parameters */ export interface Position { @@ -171,23 +172,43 @@ export interface SatelliteTransit { peak: TransitEvent } -/** - * Tunable precision and convergence controls for the transit search. - * - * The transit search first performs a coarse sampling of the satellite's - * elevation (and elevation rate) over each orbit to bracket candidate events, - * and then refines each event time using the secant method. These options - * control how the coarse sampling is stepped and when the secant refinement is - * considered "converged" or is abandoned. - * - * Crossing events (AOS, LOS, and the start/stop horizon crossings) are found by - * root-finding on the elevation value, so they converge on an angular tolerance - * in radians. Extremum events (the peak elevation and the time of closest - * approach) are found by root-finding on the derivative, so they converge on a - * *rate* tolerance: elevation rate in radians per second, and slant-range rate - * in kilometers per second. - */ -export interface TransitSearchOptions { +export interface SatelliteObservationOptions { + /** + * Configures the angular units for azimuth angles, options are Degrees or + * Radians. + */ + azimuthAngularUnits?: AngularUnits + + /** + * Configures the angular units for elevation angles, options are Degrees or + * Radians. + */ + elevationAngularUnits?: AngularUnits + + /** + * Configures the angular units geodetic coordinates are defined in, either + * Degress or Radians. + */ + geodeticAngularUnits?: AngularUnits + + /** + * Confgures the output format of timestamp fields, options are Unix, ISO8601, + * Date, or DateTime objects. + */ + timestampFormat?: TimestampFormat + + /** + * Sets the angular units for the beta angle output. + */ + betaAngleAngularUnits?: AngularUnits + + /** + * Sets the angular units for the orbit phase outuput. + */ + orbitPhaseAngularUnits?: AngularUnits +} + +export interface SatelliteTransitOptions extends SatelliteObservationOptions { /** * Angular convergence tolerance in radians for the AOS, LOS, and horizon * crossing events. The refinement stops once the satellite's elevation is diff --git a/src/main.ts b/src/main.ts index 53e30d0..7c96dc3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,12 +30,13 @@ import { AngularUnits, TimestampFormat } from "./enums" import { DateTime } from "luxon" -import { deg2rad } from "./constants" +import { defaultSatelliteTransitOptions, deg2rad } from "./constants" import { Position, SatelliteObservation, + SatelliteObservationOptions, SatelliteTransit, - TransitSearchOptions, + SatelliteTransitOptions, } from "./interfaces" import { OrbitMeanElementsMessage, @@ -70,10 +71,6 @@ import { * Javascript Date object, or luxon DateTime object * @param observerPosition: (optional) a position object specifying the location * of a satellite observer - * @param angularUnits: (optional) configure if angular units are defined in - * Degrees or Radians, default is Degrees - * @param timestampFormat: (optional) sets the format of output timestamps, - * default is ISO8601 * Returns either a single SatelliteObservation or array of * SatelliteObservations if an array of epochs is provided */ @@ -81,18 +78,17 @@ export function satelliteObservation( satelliteElements: TwoLineElement | OrbitMeanElementsMessage, epoch: Timestamp | Timestamp[], observerPosition?: Position, - angularUnits: AngularUnits = AngularUnits.Degrees, - timestampFormat: TimestampFormat = TimestampFormat.ISO8601 + satelliteObservationOptions: SatelliteObservationOptions = {} ): SatelliteObservation | SatelliteObservation[] { if (Array.isArray(epoch)) { const [omm, satrec] = parseSatelliteElements(satelliteElements) return epoch.map((e) => - computeSatelliteObservation(omm, satrec, parseTimestamp(e), observerPosition, angularUnits, timestampFormat) + computeSatelliteObservation(omm, satrec, parseTimestamp(e), observerPosition, satelliteObservationOptions) ) } else { const datetime = parseTimestamp(epoch) const [omm, satrec] = parseSatelliteElements(satelliteElements) - return computeSatelliteObservation(omm, satrec, datetime, observerPosition, angularUnits, timestampFormat) + return computeSatelliteObservation(omm, satrec, datetime, observerPosition, satelliteObservationOptions) } } @@ -118,10 +114,11 @@ export function satelliteTransits( stopTime: Timestamp, observerPosition: Position, minElevationAngle: Degrees | Radians = 0, - angularUnits: AngularUnits = AngularUnits.Degrees, - timestampFormat: TimestampFormat = TimestampFormat.ISO8601, - searchOptions: TransitSearchOptions = {} + satelliteTransitOptions?: SatelliteTransitOptions ): SatelliteTransit[] { + // Configure input/output options + const options = {...defaultSatelliteTransitOptions, ...satelliteTransitOptions} + const startDateTime = parseTimestamp(startTime) const stopDateTime = parseTimestamp(stopTime) @@ -140,7 +137,7 @@ export function satelliteTransits( } // Propagate the satellite at the start time to see if it is decayed or geostationary - const initialObservation = computeSatelliteObservation(omm, satrec, startDateTime, observerPosition, angularUnits, timestampFormat) + const initialObservation = computeSatelliteObservation(omm, satrec, startDateTime, observerPosition, satelliteTransitOptions) // Check if the satellite has decayed if (initialObservation.decayed) { @@ -159,30 +156,17 @@ export function satelliteTransits( // <-----------------------------------------------------------------------> // SEARCH CONFIGURATION // <-----------------------------------------------------------------------> - - // Resolve the tunable precision/convergence controls, applying defaults. - // - // Crossing events (AOS, LOS, and the start/stop horizon crossings) are found - // by root-finding on the elevation value, so they converge on an angular - // tolerance in radians. Extremum events (peak elevation and the time of - // closest approach) are found by root-finding on the derivative, so they - // converge on a *rate* tolerance: elevation rate in radians per second for the - // peak, and slant-range rate in kilometers per second for TCA. - const elevationToleranceRadians = searchOptions.elevationToleranceRadians ?? 1e-6 - const elevationRateTolerance = searchOptions.elevationRateTolerance ?? 1e-6 - const slantRangeRateTolerance = searchOptions.slantRangeRateTolerance ?? 1e-4 - const maxIterations = searchOptions.maxIterations ?? 100 - + // Convert the caller-supplied minimum elevation to radians. All internal math // is performed in radians; angular unit conversion happens only at output. const minElevationRadians: Radians = - angularUnits === AngularUnits.Degrees ? (minElevationAngle as number) * deg2rad : (minElevationAngle as number) + options.elevationAngularUnits === AngularUnits.Degrees ? (minElevationAngle as number) * deg2rad : (minElevationAngle as number) // The observer's geodetic position expressed in radians. `inferPosition` // (called inside computeSatelliteObservation) tolerates degree inputs, but the // low-level elevation helpers require radians, so normalize once here. const observerGeodeticRadians = - angularUnits === AngularUnits.Degrees + options.geodeticAngularUnits === AngularUnits.Degrees ? { latitude: observerPosition.geo!.latitude * deg2rad, longitude: observerPosition.geo!.longitude * deg2rad, @@ -199,7 +183,7 @@ export function satelliteTransits( // (near-geostationary) satellites, which rise and set because the Earth turns // beneath them rather than from their own motion, are still sampled densely // enough to catch each pass. - const stepSeconds = searchOptions.coarseStepSeconds ?? dynamicStepSeconds(Number(omm.MEAN_MOTION)) + const stepSeconds = options.coarseStepSeconds ?? dynamicStepSeconds(Number(omm.MEAN_MOTION)) const stepMs = stepSeconds * 1000 // Scalar functions of time used by both the coarse search and the secant @@ -286,8 +270,8 @@ export function satelliteTransits( (ms) => elevationRate(ms), peakBracketAMs, peakBracketBMs, - elevationRateTolerance, - maxIterations, + options.elevationRateTolerance!, + options.maxIterations!, ) // Filter out culminations whose peak never reaches the minimum elevation. @@ -295,7 +279,7 @@ export function satelliteTransits( // A strict inequality also drops grazing passes whose peak only touches the // threshold, so a reported transit always genuinely exceeds minElevation. const peakObservation = computeSatelliteObservation( - omm, satrec, DateTime.fromMillis(peakMs, { zone: "utc" }), observerPosition, angularUnits, timestampFormat, + omm, satrec, DateTime.fromMillis(peakMs, { zone: "utc" }), observerPosition, options, ) const peakElevationRadians = elevation(peakMs) if (peakElevationRadians <= minElevationRadians) { @@ -325,8 +309,8 @@ export function satelliteTransits( (ms) => elevationRelativeTo(satrec, observerGeodeticRadians, ms, minElevationRadians), aosBracketMs, peakMs, - elevationToleranceRadians, - maxIterations, + options.elevationToleranceRadians!, + options.maxIterations!, ) // <-------------------------------------------------------------------> @@ -351,8 +335,8 @@ export function satelliteTransits( (ms) => elevationRelativeTo(satrec, observerGeodeticRadians, ms, minElevationRadians), peakMs, losBracketMs, - elevationToleranceRadians, - maxIterations, + options.elevationToleranceRadians!, + options.maxIterations!, ) // <-------------------------------------------------------------------> @@ -382,10 +366,10 @@ export function satelliteTransits( stopMsEvent = losMs } else { const rawStartMs = findHorizonCrossing( - satrec, observerGeodeticRadians, aosMs, peakMs, -stepMs, startMs, stopMs, elevationToleranceRadians, maxIterations, + satrec, observerGeodeticRadians, aosMs, peakMs, -stepMs, startMs, stopMs, options.elevationToleranceRadians!, options.maxIterations!, ) const rawStopMs = findHorizonCrossing( - satrec, observerGeodeticRadians, losMs, peakMs, stepMs, startMs, stopMs, elevationToleranceRadians, maxIterations, + satrec, observerGeodeticRadians, losMs, peakMs, stepMs, startMs, stopMs, options.elevationToleranceRadians!, options.maxIterations!, ) // The horizon crossings must bound the AOS/LOS pair; clamp so the @@ -404,7 +388,7 @@ export function satelliteTransits( const slantRange = (ms: number): number => computeSatelliteObservation( - omm, satrec, DateTime.fromMillis(ms, { zone: "utc" }), observerPosition, angularUnits, timestampFormat, + omm, satrec, DateTime.fromMillis(ms, { zone: "utc" }), observerPosition, options, ).slantRange! const rangeRate = (ms: number): number => { const before = slantRange(ms - rateDeltaMs) @@ -415,8 +399,8 @@ export function satelliteTransits( (ms) => rangeRate(ms), peakBracketAMs, peakBracketBMs, - slantRangeRateTolerance, - maxIterations, + options.slantRangeRateTolerance!, + options.maxIterations!, ) // <-------------------------------------------------------------------> @@ -428,17 +412,17 @@ export function satelliteTransits( // <-------------------------------------------------------------------> transits.push({ - start: formatTimestamp(DateTime.fromMillis(startMsEvent, { zone: "utc" }), timestampFormat), - stop: formatTimestamp(DateTime.fromMillis(stopMsEvent, { zone: "utc" }), timestampFormat), + start: formatTimestamp(DateTime.fromMillis(startMsEvent, { zone: "utc" }), options.timestampFormat!), + stop: formatTimestamp(DateTime.fromMillis(stopMsEvent, { zone: "utc" }), options.timestampFormat!), duration: (stopMsEvent - startMsEvent) / 1000, aos: buildTransitEvent( - computeSatelliteObservation(omm, satrec, DateTime.fromMillis(aosMs, { zone: "utc" }), observerPosition, angularUnits, timestampFormat), + computeSatelliteObservation(omm, satrec, DateTime.fromMillis(aosMs, { zone: "utc" }), observerPosition, options), ), los: buildTransitEvent( - computeSatelliteObservation(omm, satrec, DateTime.fromMillis(losMs, { zone: "utc" }), observerPosition, angularUnits, timestampFormat), + computeSatelliteObservation(omm, satrec, DateTime.fromMillis(losMs, { zone: "utc" }), observerPosition, options), ), tca: buildTransitEvent( - computeSatelliteObservation(omm, satrec, DateTime.fromMillis(tcaMs, { zone: "utc" }), observerPosition, angularUnits, timestampFormat), + computeSatelliteObservation(omm, satrec, DateTime.fromMillis(tcaMs, { zone: "utc" }), observerPosition, options), ), peak: buildTransitEvent(peakObservation), }) @@ -465,7 +449,8 @@ export type { SatelliteTransit, TransitEvent, Velocity, - TransitSearchOptions, + SatelliteObservationOptions, + SatelliteTransitOptions } from "./interfaces" export type { diff --git a/src/utils.ts b/src/utils.ts index 3af4a88..1485433 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { DateTime } from "luxon"; -import { Position, SatelliteObservation, TransitEvent, Velocity } from "./interfaces.ts"; -import { WGS84, astronomicalUnit, day2ms, geostationaryMeanMotion, geostationaryTolerance, rad2deg } from "./constants.ts"; +import { Position, SatelliteObservation, SatelliteObservationOptions, TransitEvent, Velocity } from "./interfaces.ts"; +import { WGS84, astronomicalUnit, day2ms, defaultSatelliteObservationOptions, geostationaryMeanMotion, geostationaryTolerance, rad2deg } from "./constants.ts"; import type { Seconds, Timestamp } from "./types.ts"; import { TwoLineElement, OrbitMeanElementsMessage } from "./types.ts"; import { AngularUnits, TimestampFormat } from "./enums.ts"; @@ -527,9 +527,11 @@ export function computeSatelliteObservation( satrec: SatRec, datetime: DateTime, observerPosition?: Position, - angularUnits: AngularUnits = AngularUnits.Degrees, - timestampFormat: TimestampFormat = TimestampFormat.ISO8601 + satelliteObservationOptions?: SatelliteObservationOptions ): SatelliteObservation { + // Configure input/output options + const options = {...defaultSatelliteObservationOptions, ...satelliteObservationOptions} + // Returns the satellite position and velocity in ECI coordinations const satPropagation = propagate(satrec, datetime.toJSDate()) @@ -554,7 +556,7 @@ export function computeSatelliteObservation( name: omm.OBJECT_NAME, noradCatalogId: omm.NORAD_CAT_ID as string, orbitalModel: omm.MEAN_ELEMENT_THEORY, - epoch: formatTimestamp(datetime, timestampFormat), + epoch: formatTimestamp(datetime, options.timestampFormat!), decayed: true, } } @@ -565,7 +567,7 @@ export function computeSatelliteObservation( const gmst = greenwichMeanSiderealTime(datetime) // Calculate the satellite's position and velocity in other coordinate frames - const satPosition = inferPosition({ eci: satPropagation.position }, gmst, angularUnits) + const satPosition = inferPosition({ eci: satPropagation.position }, gmst, options.geodeticAngularUnits!) const satVelocity = inferVelocity({ eci: satPropagation.velocity }, gmst) // Calculate the sun's position in kilometers @@ -575,7 +577,7 @@ export function computeSatelliteObservation( y: sunEciAU.y * astronomicalUnit, z: sunEciAU.z * astronomicalUnit, } - const sunPosition = inferPosition({ eci: sunEci }, gmst, angularUnits) + const sunPosition = inferPosition({ eci: sunEci }, gmst, options.geodeticAngularUnits!) // Calculate the eclipse factor const eclipseFactor = shadowFraction(sunEciAU, satPosition.eci!) @@ -598,22 +600,22 @@ export function computeSatelliteObservation( name: omm.OBJECT_NAME, noradCatalogId: omm.NORAD_CAT_ID as string, orbitalModel: omm.MEAN_ELEMENT_THEORY, - epoch: formatTimestamp(datetime, timestampFormat), + epoch: formatTimestamp(datetime, options.timestampFormat!), gmst, - position: angularUnits === AngularUnits.Degrees ? convertGeodeticToDegrees(satPosition) : satPosition, + position: options.geodeticAngularUnits === AngularUnits.Degrees ? convertGeodeticToDegrees(satPosition) : satPosition, velocity: satVelocity, footprint, orbit: { revolutionCount: predictedRevolutionCount(omm, datetime), - phase: angularUnits === AngularUnits.Degrees ? phaseRadians * rad2deg : phaseRadians, + phase: options.orbitPhaseAngularUnits === AngularUnits.Degrees ? radiansToDegrees(phaseRadians) : phaseRadians, phase256: phaseRadians * (256 / twoPi), velocity: vectorMagnitude(satVelocity.eci!), }, decayed: false, geostationary: isGeostationary(satPropagation.meanElements), sunlit: eclipseFactor < 1, - sunPosition: angularUnits === AngularUnits.Degrees ? convertGeodeticToDegrees(sunPosition) : sunPosition, - betaAngle: angularUnits === AngularUnits.Degrees ? betaAngleRadians * rad2deg : betaAngleRadians, + sunPosition: options.geodeticAngularUnits === AngularUnits.Degrees ? convertGeodeticToDegrees(sunPosition) : sunPosition, + betaAngle: options.betaAngleAngularUnits === AngularUnits.Degrees ? radiansToDegrees(betaAngleRadians) : betaAngleRadians, eclipseFactor, } @@ -622,18 +624,18 @@ export function computeSatelliteObservation( } // If we have an observer, calculate the look angles of the satellite - const observerInferedPosition = inferPosition(observerPosition, gmst, angularUnits) + const observerInferedPosition = inferPosition(observerPosition, gmst, options.geodeticAngularUnits!) const observerLookAngles = ecfToLookAngles(observerInferedPosition.geo!, satPosition.ecef!) return { ...observation, - observerPosition: angularUnits === AngularUnits.Degrees + observerPosition: options.geodeticAngularUnits === AngularUnits.Degrees ? convertGeodeticToDegrees(observerInferedPosition) : observerInferedPosition, - azimuth: angularUnits === AngularUnits.Degrees + azimuth: options.azimuthAngularUnits === AngularUnits.Degrees ? radiansToDegrees(observerLookAngles.azimuth) : observerLookAngles.azimuth, - elevation: angularUnits === AngularUnits.Degrees + elevation: options.elevationAngularUnits === AngularUnits.Degrees ? radiansToDegrees(observerLookAngles.elevation) : observerLookAngles.elevation, slantRange: observerLookAngles.rangeSat, From 04ac56e940d550ce7178dec741b6040c24b0cb6f Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Mon, 17 Aug 2026 16:08:32 -0600 Subject: [PATCH 10/14] Documentation updates --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index baa1add..b44bf41 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,32 @@ input and is controlled entirely by the `timestampFormat` option — see [Configuration options](#configuration-options). It defaults to ISO 8601 strings. All times are computed in UTC. -### Observer position +### The `Position` object -An observer (ground station) is described by a `Position` object with a `geo` -field holding geodetic coordinates: +A `Position` object describes the location of an observer or celestial object. +A position can be expressed in any of **three coordinate frames**: + +```ts +interface Position { + eci?: { x: number; y: number; z: number } // Earth-Centered Inertial (km) + ecef?: { x: number; y: number; z: number } // Earth-Centered Earth-Fixed (km) + geo?: { latitude: number; longitude: number; height: number } // Geodetic +} +``` + +| Field | Frame | Components | Units | +| --- | --- | --- | --- | +| `eci` | Earth-Centered Inertial (TEME) | `x`, `y`, `z` | kilometers | +| `ecef` | Earth-Centered Earth-Fixed | `x`, `y`, `z` | kilometers | +| `geo` | Geodetic (relative to the WGS84 ellipsoid) | `latitude`, `longitude`, `height` | `latitude`/`longitude` in degrees by default (or radians — see `geodeticAngularUnits`); `height` in kilometers above the ellipsoid | + +You must provide **at least one** frame; supplying none throws +`At least one set of ECI, ECEF, or Geodetic coordinates must be defined to infer +position.` Whichever frame you provide, the library computes the other two, so +the returned `position`, `observerPosition`, and `sunPosition` objects always +provide all three frames. + +#### Geodetic (most common) ```ts const observerPosition = { @@ -119,9 +141,39 @@ const observerPosition = { } ``` -By default latitude/longitude are interpreted as **degrees**. Set -`geodeticAngularUnits: AngularUnits.Radians` in the options to supply radians -instead. +By default `latitude`/`longitude` are interpreted as **degrees**. Set +`geodeticAngularUnits: AngularUnits.Radians` in the options to supply (and +receive) radians instead. `height` is always kilometers. + +#### ECEF or ECI + +Instead of geodetic coordinates you may define a position directly in +Earth-Centered Earth-Fixed or Earth-Centered Inertial coordinates. Both take an +`{ x, y, z }` vector in **kilometers**: + +```ts +// Define the observer in ECEF coordinates +const observerPositionEcef = { + ecef: { x: -3961.04, y: 4720.58, z: 1640.13 }, +} + +// Or in ECI coordinates +const observerPositionEci = { + eci: { x: -350.53, y: 6152.31, z: 1640.13 }, +} + +satelliteObservation(issOmm, "2026-08-07T00:30:49.879Z", observerPositionEcef) +``` + +Notes on the ECI/ECEF frames: + +- ECI and geodetic are time-dependent relative to each other (ECEF rotates with + the Earth), so the conversion between them uses the Greenwich Mean Sidereal + Time at the observation `epoch`. Supply an ECI vector consistent with the + epoch you are querying. +- `geodeticAngularUnits` only affects the `geo` frame. When you supply `ecef` + or `eci`, the derived `geo` output still honors `geodeticAngularUnits` for + its returned units. ## `satelliteObservation` From 3893ab565e8c26a782de5430944a458c98bd3e30 Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Mon, 17 Aug 2026 16:09:52 -0600 Subject: [PATCH 11/14] Update README.md --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index b44bf41..5617764 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,7 @@ interface Position { You must provide **at least one** frame; supplying none throws `At least one set of ECI, ECEF, or Geodetic coordinates must be defined to infer -position.` Whichever frame you provide, the library computes the other two, so -the returned `position`, `observerPosition`, and `sunPosition` objects always -provide all three frames. +position.` Whichever frame you provide, the library computes the other two. #### Geodetic (most common) From d6de8cc2b61e377b34ff310db16ba3033cef04dd Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Tue, 18 Aug 2026 10:48:06 -0600 Subject: [PATCH 12/14] Adding pipeline workflow --- .../workflows/npm-publish-github-packages.yml | 37 +++++ README.md | 141 +++++++++--------- package-lock.json | 6 +- package.json | 6 +- 4 files changed, 117 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/npm-publish-github-packages.yml diff --git a/.github/workflows/npm-publish-github-packages.yml b/.github/workflows/npm-publish-github-packages.yml new file mode 100644 index 0000000..0f20dd9 --- /dev/null +++ b/.github/workflows/npm-publish-github-packages.yml @@ -0,0 +1,37 @@ +# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created +# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages + +name: Node.js Package + +on: + release: + types: [created] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm ci + - run: npm test + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://npm.pkg.github.com/ + - run: npm ci + - run: npm run build + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/README.md b/README.md index 5617764..32e222d 100644 --- a/README.md +++ b/README.md @@ -55,19 +55,15 @@ const issOmm = { ### Timestamps -Any field that accepts a time (`epoch`, `startTime`, `stopTime`) accepts a -flexible `Timestamp`. The input format is **auto-detected** — you do **not** -need to tell the library which one you are using, and the `timestampFormat` -option affects **output only** (it has no effect on how inputs are parsed). Any -of the four forms may be passed to either function, and you can even mix forms -across arguments or within an epoch array: +Function parameters that are typed as `Timestamp` allow the caller to supply +datetime values in any of the following forms: | Input form | Type | Example | How it's interpreted | | --- | --- | --- | --- | | Unix milliseconds | `number` | `1786062649879` | Milliseconds since the Unix epoch, treated as **UTC**. | | ISO 8601 string | `string` | `"2026-08-07T00:30:49.879Z"` | Parsed as ISO 8601. See the timezone note below. | | JavaScript `Date` | `Date` | `new Date("2026-08-07T00:30:49.879Z")` | Converted directly from the `Date` instant. | -| Luxon `DateTime` | `DateTime` | `DateTime.utc(2026, 8, 7)` | Used as-is, preserving its zone. | +| Luxon `DateTime` | `DateTime` | `DateTime.utc(2026, 8, 7)` | Used as-is, preserving its timezone. | > Timezone handling for strings: if the ISO string carries an explicit offset or > `Z` (e.g. `2026-08-07T00:30:49.879Z` or `...+02:00`), that zone is respected. @@ -99,15 +95,15 @@ satelliteTransits( ) ``` -The **output** timestamp form (`epoch`, `start`, `stop`) is independent of the -input and is controlled entirely by the `timestampFormat` option — see -[Configuration options](#configuration-options). It defaults to ISO 8601 -strings. All times are computed in UTC. +To control the **output** timestamp format (i.e. `epoch`, `start`, `stop`, +etc...) — see the `timestampFormat` option in +[Configuration options](#configuration-options). Defaults to an ISO8601 UTC +string if not specified by the caller. ### The `Position` object -A `Position` object describes the location of an observer or celestial object. -A position can be expressed in any of **three coordinate frames**: +A `Position` object describes the location of an observer or satellite relative +to the Earth. A position can be expressed in any of **three coordinate frames**: ```ts interface Position { @@ -123,9 +119,8 @@ interface Position { | `ecef` | Earth-Centered Earth-Fixed | `x`, `y`, `z` | kilometers | | `geo` | Geodetic (relative to the WGS84 ellipsoid) | `latitude`, `longitude`, `height` | `latitude`/`longitude` in degrees by default (or radians — see `geodeticAngularUnits`); `height` in kilometers above the ellipsoid | -You must provide **at least one** frame; supplying none throws -`At least one set of ECI, ECEF, or Geodetic coordinates must be defined to infer -position.` Whichever frame you provide, the library computes the other two. +When specifying the position of an observer, you must define all the parameters +for **at least one** of the coordinate coordinate frames: #### Geodetic (most common) @@ -140,8 +135,8 @@ const observerPosition = { ``` By default `latitude`/`longitude` are interpreted as **degrees**. Set -`geodeticAngularUnits: AngularUnits.Radians` in the options to supply (and -receive) radians instead. `height` is always kilometers. +`geodeticAngularUnits: AngularUnits.Radians` in the function options. +The `height` parameter is always specified in kilometers. #### ECEF or ECI @@ -170,8 +165,8 @@ Notes on the ECI/ECEF frames: Time at the observation `epoch`. Supply an ECI vector consistent with the epoch you are querying. - `geodeticAngularUnits` only affects the `geo` frame. When you supply `ecef` - or `eci`, the derived `geo` output still honors `geodeticAngularUnits` for - its returned units. + or `eci`, the derived `geo` output will honor the `geodeticAngularUnits` + option specified by the caller. ## `satelliteObservation` @@ -186,8 +181,8 @@ satelliteObservation( Computes the satellite state at the given `epoch`. If `epoch` is an array, an array of observations is returned (one per timestamp, in order). If an observer -position is supplied, look angles (azimuth, elevation, slant range, Doppler -factor) are included in the result. +position is supplied, look angles (i.e. `azimuth`, `elevation`, etc..) are +included in the result. ### Basic usage @@ -199,9 +194,9 @@ const observation = satelliteObservation( "2026-08-07T00:30:49.879Z", ) -console.log(observation.position?.geo) // sub-satellite lat/lon/height -console.log(observation.velocity?.eci) // ECI velocity vector -console.log(observation.orbit?.revolutionCount) +console.log(observation.position?.geo) // Satellite position in lat/lon/height +console.log(observation.velocity?.eci) // Satellite velocity vector in ECI +console.log(observation.orbit?.revolutionCount) // Satellite orbit count at epoch ``` ```console @@ -210,9 +205,9 @@ console.log(observation.orbit?.revolutionCount) 57963 ``` -The full returned object: +Example `SatelliteTransit` result: -```console +```javascript { "id": "1998-067A", "name": "ISS (ZARYA)", @@ -258,10 +253,10 @@ const observation = satelliteObservation( { geo: { latitude: 15, longitude: 130, height: 0.1 } }, ) -console.log(observation.azimuth) // compass heading to the satellite -console.log(observation.elevation) // angle above the horizon -console.log(observation.slantRange) // line-of-sight distance (km) -console.log(observation.dopplerFactor) +console.log(observation.azimuth) // Compass heading to the satellite +console.log(observation.elevation) // Angle above the horizon +console.log(observation.slantRange) // Line-of-sight distance (km) +console.log(observation.dopplerFactor) // Signal frequency shift ``` With an observer, the observation additionally carries `observerPosition`, @@ -274,7 +269,7 @@ With an observer, the observation additionally carries `observerPosition`, 1.000019464535455 ``` -```console +```javascript ... "observerPosition": { "eci": { "x": -350.5295256508939, "y": 6152.308010923147, "z": 1640.1260220778647 }, @@ -299,10 +294,9 @@ const observations = satelliteObservation(issOmm, epochs) // observations is a SatelliteObservation[] with one entry per epoch ``` -### Result shape +### Result Schema -`SatelliteObservation` includes (fields marked optional are omitted when not -applicable, e.g. observer look angles require an observer position): +The `SatelliteObservation` object contains the following fields: | Field | Description | | --- | --- | @@ -322,15 +316,26 @@ applicable, e.g. observer look angles require an observer position): | `sunPosition` | Position of the Sun. | | `betaAngle` | Angle between the orbital plane and the Sun. | | `eclipseFactor` | Fraction of the Sun's disc obscured by Earth (0 = fully lit, 1 = umbra). | -| `observerPosition` | Observer's position (only with an observer). | -| `azimuth` | Heading to the satellite (only with an observer). | -| `elevation` | Elevation above the horizon (only with an observer). | -| `slantRange` | Observer-to-satellite distance in km (only with an observer). | -| `dopplerFactor` | Frequency shift relative to the observer (only with an observer). | +| `observerPosition` | Observer's position (only if observerPosition is defined). | +| `azimuth` | Heading to the satellite (only if observerPosition is defined). | +| `elevation` | Elevation above the horizon (only if observerPosition is defined). | +| `slantRange` | Observer-to-satellite distance in km (only if observerPosition is defined). | +| `dopplerFactor` | Frequency shift relative to the observer (only if observerPosition is defined). | > Note: if the propagated orbit has decayed, a minimal observation is returned -> with `decayed: true` and only `id`, `name`, `noradCatalogId`, `orbitalModel`, -> and `epoch` populated. +> with `decayed: true`. + +Decayed satellite example: +```javascript +{ + "id": "1998-067A", + "name": "ISS (ZARYA)", + "noradCatalogId": "25544", + "orbitalModel": "SGP4", + "epoch": "2026-08-07T00:30:49.879Z", + "decayed": true, +} +``` ## `satelliteTransits` @@ -346,8 +351,9 @@ satelliteTransits( ``` Finds all passes of the satellite over `observerPosition` between `startTime` -and `stopTime`. Each pass reports its horizon-to-horizon start/stop, plus AOS, -LOS, peak-elevation, and time-of-closest-approach events. +and `stopTime`. Each pass reports its horizon-to-horizon start/stop times, +transit duration (`stopTime` - `startTime`), acquisition-of-signal (AOS), +loss-of-signal (LOS), peak-elevation, and time-of-closest-approach (TCA) events. ### Basic usage @@ -375,9 +381,9 @@ peak elevation: 8.548911076956662 ... ``` -A single element of the returned `SatelliteTransit[]`: +Example `SatelliteTransit` result: -```console +```javascript { "start": "2026-08-07T07:16:32.212Z", "stop": "2026-08-07T07:24:51.248Z", @@ -415,9 +421,9 @@ A single element of the returned `SatelliteTransit[]`: ### Minimum elevation threshold -The fifth argument sets the minimum elevation that defines AOS/LOS. It defaults -to `0` (true horizon). Passes whose peak elevation never exceeds the threshold -are discarded. +The `minElevationAngle` argument sets the minimum elevation for AOS/LOS, +default is `0` degrees/radians (i.e. true horizon). Transits whose peak +elevation never exceed the minimum elevation threshold are discarded. ```ts // Only report passes that climb above 20 degrees @@ -436,16 +442,16 @@ $ node transits-minel.js 2 # vs. 5 passes with the default 0 threshold over the same window ``` -The units of `minElevationAngle` follow `elevationAngularUnits` (degrees by -default). +The units of `minElevationAngle` can be changed using the `elevationAngularUnits` +option. - `start`/`stop` always mark the true-horizon (0°) crossings. - `aos`/`los` mark the crossings of `minElevationAngle`. - When `minElevationAngle` is `0`, `start === aos` and `stop === los`. -### Result shape +### Result Schema -Each `SatelliteTransit` contains: +The `SatelliteTransit` object contains the following fields: | Field | Description | | --- | --- | @@ -457,33 +463,30 @@ Each `SatelliteTransit` contains: | `tca` | Time of closest approach (minimum slant range). | | `peak` | Peak-elevation (culmination) event. | -Each event (`aos`, `los`, `tca`, `peak`) is a `TransitEvent`: +Where `aos`, `los`, `tca`, and `peak` are `TransitEvent` objects defined as: -```ts -interface TransitEvent { - epoch: Timestamp - azimuth: number - elevation: number - slantRange: number // km - dopplerFactor: number -} -``` +| Field | Description | +| --- | --- | +| `epoch` | The date and time of the event. | +| `azimuth` | The compass heading of the satellite from the observer. | +| `elevation` | The elevation angle of the satellite from the observer. | +| `slantRange` | The straight-line distance of the satellite from the observer. | +| `dopplerFactor` | The frequency shift of the satellite signal relative to the observer. | ### Errors and warnings - Throws `Stop date is less than or equal to start date` if `stopTime <= startTime`. - Emits a `console.warn` when the search window begins before the element set's - epoch (propagating before epoch is not recommended). + epoch (propagating before the satellite element's epoch is not recommended). - Returns `[]` and warns if the satellite has decayed, or if it is geostationary but sits below `minElevationAngle` for the observer. ## Configuration options -Both functions accept a trailing options object. -`satelliteObservation` uses `SatelliteObservationOptions`; -`satelliteTransits` uses `SatelliteTransitOptions`, which extends the -observation options with search-tuning controls. +Both functions accept a "options" object for configuring inputs and outputs: +- `satelliteObservation` uses `SatelliteObservationOptions` +- `satelliteTransits` uses `SatelliteTransitOptions` ### Unit and format options (both functions) @@ -496,7 +499,7 @@ observation options with search-tuning controls. | `orbitPhaseAngularUnits` | `AngularUnits` | `Degrees` | Units for the orbit `phase` output. | | `timestampFormat` | `TimestampFormat` | `ISO8601` | Format of all output timestamps. | -`AngularUnits` and `TimestampFormat` are exported enums: +`AngularUnits` and `TimestampFormat` are exported Typescript enums: ```ts import { AngularUnits, TimestampFormat } from "jspredict" diff --git a/package-lock.json b/package-lock.json index 1b9f33a..464cf30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2554,9 +2554,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index e424f59..1b19463 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "scripts": { "build": "vite build", - "test": "vitest", + "test": "vitest run", "benchmark": "npx tsx scripts/benchmark.ts" }, "repository": { @@ -53,5 +53,9 @@ "vite": "^8.2.1", "vite-plugin-dts": "^5.0.3", "vitest": "^4.1.10" + }, + "allowScripts": { + "esbuild@0.28.2": true, + "fsevents@2.3.3": true } } From bc92f46d88b466448d90cdacc88ff33451e00516 Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Tue, 18 Aug 2026 10:53:06 -0600 Subject: [PATCH 13/14] Update CHANGELOG.md --- CHANGELOG.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72466ab..b3771a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.0] +### Added +- Support for Orbit Mean-Elements Message (OMM) orbital element sets in addition to TLE strings +- `satelliteObservation()` now accepts either a single epoch or an array of epochs, returning a single observation or an array of observations respectively (replacing the separate `observes()` method) +- Configurable angular units (`DEGREES` or `RADIANS`) via the `AngularUnits` enum, applied independently to azimuth, elevation, geodetic coordinates, beta angle, and orbit phase outputs +- Configurable timestamp output formats (`UNIX`, `ISO8601`, `DATE`, `DATETIME`) via the `TimestampFormat` enum +- Flexible timestamp inputs accepting Unix timestamps, ISO8601 strings, JavaScript `Date` objects, and Luxon `DateTime` objects +- Expanded observation output including ECI/ECEF/geodetic position and velocity vectors, GMST, footprint, orbit revolution count and phase (with legacy 0–256 `phase256`), orbital model, decayed/geostationary flags, sunlit state, sun position, beta angle, eclipse factor, doppler factor, slant range, visibility, and AOS state +- Rewritten `satelliteTransits()` transit prediction using a coarse mean-motion-derived search (Skyfield-style) with secant-method refinement of AOS, LOS, peak (culmination), and TCA (time of closest approach) events +- Rich transit events: each transit now reports `aos`, `los`, `tca`, and `peak` events with full azimuth, elevation, slant range, and doppler data, plus `start`/`stop`/`duration` +- Tunable transit search precision and convergence via `SatelliteTransitOptions` (elevation/rate tolerances, max iterations, and optional coarse-step override) +- Published TypeScript type definitions and exported public interfaces, types, and enums from the module root +- Benchmark script (`npm run benchmark`) and Vitest-based test suite (`npm test`) +- GitHub Actions publishing workflow for GitHub Packages + ### Changed -- Migrated library source code from Javascript to Typescript -- Migrated from Webpack to Vite for testing and packaging +- Migrated library source code from JavaScript to TypeScript +- Migrated from Webpack to Vite for testing and packaging - Migrated library build to standard ES modules for frontend and backend use +- Replaced the `moment` dependency with `luxon` +- Upgraded `satellite.js` from 3.x to 7.x +- Renamed and restructured the public API: + - `observe()` → `satelliteObservation()` + - `transits()` → `satelliteTransits()` +- Renamed geodetic position properties to `geo` to align with the abbreviations used by other coordinate systems (`eci`, `ecef`) +- Transit start/stop times now conform to the true horizon (0°) while AOS/LOS conform to the configurable minimum elevation threshold ### Deprecated - Removed support for Bower and Meteor package repositories, builds are now hosted on GitHub Packages -- Removed support for CommonJS and UMD style Javascript modules +- Removed support for CommonJS and UMD style JavaScript modules +- Removed the `observes()` method (functionality merged into `satelliteObservation()`) +- Removed the `transitSegment()` method +- Removed the legacy `bower.json`, `package.js` (Meteor), and `export.js` files ## [1.2.0] - 2026-08-06 From c728e52d16816a520cfab20b09e5584077eaec52 Mon Sep 17 00:00:00 2001 From: Joshua Bourquin Date: Tue, 18 Aug 2026 10:55:02 -0600 Subject: [PATCH 14/14] Create pull_request_template.md --- .github/pull_request_template.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2fc209d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Description + + +## Reviewer(s) +@nsat/sos + + +## Transcript(s) +``` +[please run "make test" and copy output, or include hastebin link to output] +``` + + +## Checklist (if applicable) +- [ ] Documentation updated