refactor: code quality improvements and fix arcvMinimum constant

Fix critical bug: arcvMinimum polynomial constant was 7.1651 (wrong)
instead of 11.8371 (Odeh 2006) in getMoonVisibilityEstimate. Now
imports the canonical arcvMinimum() from visibility module.

Deduplicate shared code across modules:
- arcvMinimum polynomial: single source in visibility/index.ts
- dot/norm vector helpers: use vdot/vnorm from math/index.ts
- DEG constant: use DEG2RAD from math/index.ts
- jdToJSDate: use jdToDate from time/index.ts

Add input validation to all public API functions (lat/lon range,
valid Date instances). Add ESLint + Prettier with TypeScript support.
Convert tests to node:test runner. Fix package.json exports to use
nested types-first format. Pin devDependencies to caret ranges.
Add noImplicitReturns and noFallthroughCasesInSwitch to tsconfig.
Replace .markdownlint.json with .vscode/settings.json. Update CI
workflow with lint job. Expand .gitignore coverage.
This commit is contained in:
Aric Camarata 2026-03-08 11:39:28 -04:00
parent 3b666c6465
commit 8bf34fb696
21 changed files with 1887 additions and 1031 deletions

View file

@ -22,10 +22,26 @@ jobs:
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: node test.mjs
- run: node test-cjs.cjs
- run: node --test test.mjs
- run: node --test test-cjs.cjs
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run format:check
typecheck:
name: TypeScript
@ -39,7 +55,7 @@ jobs:
with:
node-version: 24
cache: pnpm
- run: pnpm install
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
pack-check:
@ -54,17 +70,16 @@ jobs:
with:
node-version: 24
cache: pnpm
- run: pnpm install
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Verify pack contents
run: |
npm pack --dry-run 2>&1 | tee pack-output.txt
# Verify expected files are present
grep -q "dist/index.cjs" pack-output.txt || (echo "Missing dist/index.cjs" && exit 1)
grep -q "dist/index.mjs" pack-output.txt || (echo "Missing dist/index.mjs" && exit 1)
grep -q "dist/index.d.ts" pack-output.txt || (echo "Missing dist/index.d.ts" && exit 1)
grep -q "dist/index.d.mts" pack-output.txt || (echo "Missing dist/index.d.mts" && exit 1)
grep -q "README.md" pack-output.txt || (echo "Missing README.md" && exit 1)
# Verify no test files or .gitignore items are included
! grep -q "test.mjs" pack-output.txt || (echo "test.mjs should not be in pack" && exit 1)
! grep -q "node_modules" pack-output.txt || (echo "node_modules should not be in pack" && exit 1)
echo "Pack contents verified."

25
.gitignore vendored
View file

@ -1,7 +1,10 @@
node_modules/
dist/
build/
out/
*.tgz
*.log
*.tsbuildinfo
.DS_Store
.env
.env.*
@ -11,8 +14,28 @@ dist/
*.bsp
*.tls
# PnP
.pnp
.pnp.js
# Coverage
coverage/
# IDE
.vscode/
.idea/
*.swp
# AI agent directories
.claude/
.cursor/
.aider/
.copilot/
.aider*
.continue/
.codex/
.gemini/
.vscode/*
.aider/
.aider.chat.history.md
.windsurf/
.codeium/

View file

@ -1,4 +0,0 @@
{
"MD013": false,
"MD024": { "siblings_only": true }
}

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

12
eslint.config.mjs Normal file
View file

@ -0,0 +1,12 @@
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-config-prettier'
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'],
},
)

View file

@ -9,9 +9,14 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
}
},
"bin": {
@ -31,14 +36,22 @@
"build": "tsup",
"typecheck": "tsc --noEmit",
"pretest": "tsup",
"test": "node test.mjs && node test-cjs.cjs",
"test": "node --test test.mjs test-cjs.cjs",
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"prepublishOnly": "tsup",
"cli": "node dist/cli/index.cjs"
},
"devDependencies": {
"@types/node": "latest",
"tsup": "latest",
"typescript": "latest"
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
},
"publishConfig": {
"access": "public",

View file

@ -8,15 +8,30 @@ importers:
.:
devDependencies:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.0.3)
'@types/node':
specifier: latest
specifier: ^25.3.0
version: 25.3.0
eslint:
specifier: ^10.0.3
version: 10.0.3
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@10.0.3)
prettier:
specifier: ^3.8.1
version: 3.8.1
tsup:
specifier: latest
specifier: ^8.5.1
version: 8.5.1(typescript@5.9.3)
typescript:
specifier: latest
specifier: ^5.9.3
version: 5.9.3
typescript-eslint:
specifier: ^8.56.1
version: 8.56.1(eslint@10.0.3)(typescript@5.9.3)
packages:
@ -176,6 +191,61 @@ packages:
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.12.2':
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.23.3':
resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/config-helpers@0.5.3':
resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/core@1.1.1':
resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/js@10.0.1':
resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
peerDependencies:
eslint: ^10.0.0
peerDependenciesMeta:
eslint:
optional: true
'@eslint/object-schema@3.0.3':
resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/plugin-kit@0.6.1':
resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
'@humanfs/node@0.16.7':
resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
engines: {node: '>=18.18.0'}
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
engines: {node: '>=12.22'}
'@humanwhocodes/retry@0.4.3':
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -327,20 +397,101 @@ packages:
cpu: [x64]
os: [win32]
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
'@typescript-eslint/eslint-plugin@8.56.1':
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.56.1
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.56.1':
resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.56.1':
resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.56.1':
resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.56.1':
resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.56.1':
resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.56.1':
resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.56.1':
resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.56.1':
resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.56.1':
resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
brace-expansion@5.0.4:
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
engines: {node: 18 || 20 || >=22}
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -366,6 +517,10 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -375,11 +530,75 @@ packages:
supports-color:
optional: true
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-scope@9.1.2:
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint-visitor-keys@3.4.3:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@10.0.3:
resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
jiti: '*'
peerDependenciesMeta:
jiti:
optional: true
espree@11.2.0:
resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
esquery@1.7.0:
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'}
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
engines: {node: '>=4.0'}
estraverse@5.3.0:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -389,18 +608,76 @@ packages:
picomatch:
optional: true
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
flatted@3.3.4:
resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@ -412,9 +689,17 @@ packages:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@ -424,10 +709,33 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@ -463,6 +771,19 @@ packages:
yaml:
optional: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
engines: {node: '>=14'}
hasBin: true
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@ -476,6 +797,19 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
@ -503,6 +837,12 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@ -525,6 +865,17 @@ packages:
typescript:
optional: true
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
typescript-eslint@8.56.1:
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@ -536,6 +887,22 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
snapshots:
'@esbuild/aix-ppc64@0.27.3':
@ -616,6 +983,51 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.0.3)':
dependencies:
eslint: 10.0.3
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.23.3':
dependencies:
'@eslint/object-schema': 3.0.3
debug: 4.4.3
minimatch: 10.2.4
transitivePeerDependencies:
- supports-color
'@eslint/config-helpers@0.5.3':
dependencies:
'@eslint/core': 1.1.1
'@eslint/core@1.1.1':
dependencies:
'@types/json-schema': 7.0.15
'@eslint/js@10.0.1(eslint@10.0.3)':
optionalDependencies:
eslint: 10.0.3
'@eslint/object-schema@3.0.3': {}
'@eslint/plugin-kit@0.6.1':
dependencies:
'@eslint/core': 1.1.1
levn: 0.4.1
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
dependencies:
'@humanfs/core': 0.19.1
'@humanwhocodes/retry': 0.4.3
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.4.3': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -705,16 +1117,128 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/node@25.3.0':
dependencies:
undici-types: 7.18.2
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/type-utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.56.1
eslint: 10.0.3
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.56.1
debug: 4.4.3
eslint: 10.0.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.56.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
'@typescript-eslint/types': 8.56.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.56.1':
dependencies:
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/visitor-keys': 8.56.1
'@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.56.1(eslint@10.0.3)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
debug: 4.4.3
eslint: 10.0.3
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.56.1': {}
'@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.56.1(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/visitor-keys': 8.56.1
debug: 4.4.3
minimatch: 10.2.4
semver: 7.7.4
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.56.1(eslint@10.0.3)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3)
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
eslint: 10.0.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.56.1':
dependencies:
'@typescript-eslint/types': 8.56.1
eslint-visitor-keys: 5.0.1
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn@8.16.0: {}
ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
json-schema-traverse: 0.4.1
uri-js: 4.4.1
any-promise@1.3.0: {}
balanced-match@4.0.4: {}
brace-expansion@5.0.4:
dependencies:
balanced-match: 4.0.4
bundle-require@5.1.0(esbuild@0.27.3):
dependencies:
esbuild: 0.27.3
@ -732,10 +1256,18 @@ snapshots:
consola@3.4.2: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
debug@4.4.3:
dependencies:
ms: 2.1.3
deep-is@0.1.4: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
@ -765,31 +1297,164 @@ snapshots:
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.0.3):
dependencies:
eslint: 10.0.3
eslint-scope@9.1.2:
dependencies:
'@types/esrecurse': 4.3.1
'@types/estree': 1.0.8
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@3.4.3: {}
eslint-visitor-keys@5.0.1: {}
eslint@10.0.3:
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3)
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.3
'@eslint/config-helpers': 0.5.3
'@eslint/core': 1.1.1
'@eslint/plugin-kit': 0.6.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
cross-spawn: 7.0.6
debug: 4.4.3
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.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
minimatch: 10.2.4
natural-compare: 1.4.0
optionator: 0.9.4
transitivePeerDependencies:
- supports-color
espree@11.2.0:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
eslint-visitor-keys: 5.0.1
esquery@1.7.0:
dependencies:
estraverse: 5.3.0
esrecurse@4.3.0:
dependencies:
estraverse: 5.3.0
estraverse@5.3.0: {}
esutils@2.0.3: {}
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
path-exists: 4.0.0
fix-dts-default-cjs-exports@1.0.1:
dependencies:
magic-string: 0.30.21
mlly: 1.8.0
rollup: 4.59.0
flat-cache@4.0.1:
dependencies:
flatted: 3.3.4
keyv: 4.5.4
flatted@3.3.4: {}
fsevents@2.3.3:
optional: true
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
ignore@5.3.2: {}
ignore@7.0.5: {}
imurmurhash@0.1.4: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
isexe@2.0.0: {}
joycon@3.1.1: {}
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
type-check: 0.4.0
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
load-tsconfig@0.2.5: {}
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
mlly@1.8.0:
dependencies:
acorn: 8.16.0
@ -805,8 +1470,31 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
natural-compare@1.4.0: {}
object-assign@4.1.1: {}
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
fast-levenshtein: 2.0.6
levn: 0.4.1
prelude-ls: 1.2.1
type-check: 0.4.0
word-wrap: 1.2.5
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
path-exists@4.0.0: {}
path-key@3.1.1: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
@ -825,6 +1513,12 @@ snapshots:
dependencies:
lilconfig: 3.1.3
prelude-ls@1.2.1: {}
prettier@3.8.1: {}
punycode@2.3.1: {}
readdirp@4.1.2: {}
resolve-from@5.0.0: {}
@ -860,6 +1554,14 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
semver@7.7.4: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
source-map@0.7.6: {}
sucrase@3.35.1:
@ -889,6 +1591,10 @@ snapshots:
tree-kill@1.2.2: {}
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
ts-interface-checker@0.1.13: {}
tsup@8.5.1(typescript@5.9.3):
@ -918,8 +1624,35 @@ snapshots:
- tsx
- yaml
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
typescript-eslint@8.56.1(eslint@10.0.3)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/parser': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
eslint: 10.0.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {}
ufo@1.6.3: {}
undici-types@7.18.2: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
which@2.0.2:
dependencies:
isexe: 2.0.0
word-wrap@1.2.5: {}
yocto-queue@0.1.0: {}

View file

@ -31,11 +31,7 @@ import type {
} from '../types.js'
import { ODEH_THRESHOLDS, ODEH_DESCRIPTIONS } from '../types.js'
import { SpkKernel } from '../spk/index.js'
import {
computeTimeScales,
jdTTtoET,
J2000,
} from '../time/index.js'
import { computeTimeScales, jdTTtoET, jdToDate, J2000 } from '../time/index.js'
import {
getMoonGeocentricState,
getSunGeocentricState,
@ -44,10 +40,7 @@ import {
getMoonSunApproximate,
nearestNewMoon,
} from '../bodies/index.js'
import {
geodeticToECEF,
computeAzAlt,
} from '../observer/index.js'
import { geodeticToECEF, computeAzAlt } from '../observer/index.js'
import { itrsToGcrs, computeERA } from '../frames/index.js'
import {
getSunMoonEvents as eventsGetSunMoonEvents,
@ -60,7 +53,34 @@ import {
computeYallop,
computeOdeh,
buildGuidanceText,
arcvMinimum,
} from '../visibility/index.js'
import { DEG2RAD } from '../math/index.js'
// ─── Input validation ─────────────────────────────────────────────────────────
function validateDate(date: Date, label: string): void {
if (!(date instanceof Date) || isNaN(date.getTime())) {
throw new RangeError(`${label}: expected a valid Date instance`)
}
}
function validateLatitude(lat: number, label: string): void {
if (!isFinite(lat) || lat < -90 || lat > 90) {
throw new RangeError(`${label}: latitude must be a finite number in [-90, 90], got ${lat}`)
}
}
function validateLongitude(lon: number, label: string): void {
if (!isFinite(lon) || lon < -180 || lon > 180) {
throw new RangeError(`${label}: longitude must be a finite number in [-180, 180], got ${lon}`)
}
}
function validateObserver(observer: Observer, label: string): void {
validateLatitude(observer.lat, label)
validateLongitude(observer.lon, label)
}
// ─── Module-level kernel singleton ─────────────────────────────────────────────
@ -83,7 +103,7 @@ function resolveCacheDir(override?: string): string {
// ─── Download sources ─────────────────────────────────────────────────────────
const NAIF_DE442S_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de442s.bsp'
const NAIF_LSK_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls'
const NAIF_LSK_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls'
// ─── Kernel lifecycle ─────────────────────────────────────────────────────────
@ -109,7 +129,8 @@ export async function initKernels(config?: KernelConfig): Promise<void> {
buffer = source.data
} else if (source.type === 'url') {
const res = await fetch(source.url)
if (!res.ok) throw new Error(`Failed to fetch kernel from ${source.url}: ${res.status} ${res.statusText}`)
if (!res.ok)
throw new Error(`Failed to fetch kernel from ${source.url}: ${res.status} ${res.statusText}`)
buffer = await res.arrayBuffer()
} else {
// auto: download to local cache, then load
@ -146,7 +167,7 @@ export async function downloadKernels(config?: KernelConfig): Promise<{
await mkdir(cacheDir, { recursive: true })
const planetaryPath = join(cacheDir, 'de442s.bsp')
const planetaryPath = join(cacheDir, 'de442s.bsp')
const leapSecondsPath = join(cacheDir, 'naif0012.tls')
if (!existsSync(planetaryPath)) {
@ -206,7 +227,7 @@ export async function verifyKernels(config?: KernelConfig): Promise<{
const { join } = await import('node:path')
const errors: string[] = []
const planetaryPath = join(cacheDir, 'de442s.bsp')
const planetaryPath = join(cacheDir, 'de442s.bsp')
const leapSecondsPath = join(cacheDir, 'naif0012.tls')
if (!existsSync(planetaryPath)) {
@ -251,7 +272,8 @@ async function resolveKernel(config?: KernelConfig): Promise<SpkKernel> {
// auto-init as last resort
await initKernels(config)
if (!activeKernel) throw new Error('Kernel failed to initialize. Call initKernels() before computing.')
if (!activeKernel)
throw new Error('Kernel failed to initialize. Call initKernels() before computing.')
return activeKernel
}
@ -286,6 +308,8 @@ export async function getMoonSightingReport(
observer: Observer,
options?: SightingOptions,
): Promise<MoonSightingReport> {
validateDate(date, 'getMoonSightingReport')
validateObserver(observer, 'getMoonSightingReport')
const kernel = await resolveKernel(options?.kernels)
// Event times (sunset, moonset, twilight, rise)
@ -321,7 +345,7 @@ export async function getMoonSightingReport(
// Body positions in GCRS (geocentric)
const moonGCRS = getMoonGeocentricState(kernel, et).position
const sunGCRS = getSunGeocentricState(kernel, et).position
const sunGCRS = getSunGeocentricState(kernel, et).position
// Observer ITRS position (km) from geodetic coordinates
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation)
@ -333,7 +357,7 @@ export async function getMoonSightingReport(
// Airless alt/az — required by Yallop/Odeh criteria
const moonAirless = computeAzAlt(moonGCRS, observer, ts, true)
const sunAirless = computeAzAlt(sunGCRS, observer, ts, true)
const sunAirless = computeAzAlt(sunGCRS, observer, ts, true)
// Apparent alt/az (with refraction) — for guidance text
const moonApparent = computeAzAlt(moonGCRS, observer, ts, false)
@ -349,11 +373,7 @@ export async function getMoonSightingReport(
moonGCRS[1] - obsGCRS[1],
moonGCRS[2] - obsGCRS[2],
]
const sunTopo: Vec3 = [
sunGCRS[0] - obsGCRS[0],
sunGCRS[1] - obsGCRS[1],
sunGCRS[2] - obsGCRS[2],
]
const sunTopo: Vec3 = [sunGCRS[0] - obsGCRS[0], sunGCRS[1] - obsGCRS[1], sunGCRS[2] - obsGCRS[2]]
const geometry = computeCrescentGeometry(
moonAirless,
@ -366,7 +386,7 @@ export async function getMoonSightingReport(
const { Wprime } = computeCrescentWidth(moonTopo, geometry.ARCL)
const yallop = computeYallop(geometry, Wprime)
const odeh = computeOdeh(geometry)
const odeh = computeOdeh(geometry)
const moonAboveHorizon = moonAirless.altitude > 0
const sightingPossible = moonAboveHorizon && lagMinutes > 0
@ -413,7 +433,7 @@ function buildNullReport(
return {
date,
observer,
sunsetUTC: events.sunsetUTC,
sunsetUTC: events.sunsetUTC,
moonsetUTC: events.moonsetUTC,
lagMinutes: null,
bestTimeUTC: null,
@ -425,7 +445,8 @@ function buildNullReport(
geometry: null,
yallop: null,
odeh: null,
guidance: 'Sighting not possible: sunset or moonset could not be determined for this date and location.',
guidance:
'Sighting not possible: sunset or moonset could not be determined for this date and location.',
ephemerisSource: source,
moonAboveHorizon: null,
sightingPossible,
@ -435,14 +456,14 @@ function buildNullReport(
// ─── Phase display lookup ──────────────────────────────────────────────────────
const PHASE_DISPLAY: Record<MoonPhaseName, { name: string; symbol: string }> = {
'new-moon': { name: 'New Moon', symbol: '🌑' },
'new-moon': { name: 'New Moon', symbol: '🌑' },
'waxing-crescent': { name: 'Waxing Crescent', symbol: '🌒' },
'first-quarter': { name: 'First Quarter', symbol: '🌓' },
'waxing-gibbous': { name: 'Waxing Gibbous', symbol: '🌔' },
'full-moon': { name: 'Full Moon', symbol: '🌕' },
'waning-gibbous': { name: 'Waning Gibbous', symbol: '🌖' },
'last-quarter': { name: 'Last Quarter', symbol: '🌗' },
'waning-crescent': { name: 'Waning Crescent', symbol: '🌘' },
'first-quarter': { name: 'First Quarter', symbol: '🌓' },
'waxing-gibbous': { name: 'Waxing Gibbous', symbol: '🌔' },
'full-moon': { name: 'Full Moon', symbol: '🌕' },
'waning-gibbous': { name: 'Waning Gibbous', symbol: '🌖' },
'last-quarter': { name: 'Last Quarter', symbol: '🌗' },
'waning-crescent': { name: 'Waning Crescent', symbol: '🌘' },
}
/**
@ -464,6 +485,7 @@ const PHASE_DISPLAY: Record<MoonPhaseName, { name: string; symbol: string }> = {
* ```
*/
export function getMoonPhase(date = new Date()): MoonPhaseResult {
validateDate(date, 'getMoonPhase')
const ts = computeTimeScales(date)
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT)
@ -478,7 +500,7 @@ export function getMoonPhase(date = new Date()): MoonPhaseResult {
const phaseKey = elongationToPhase(elongationDeg, isWaxing)
const { name: phaseName, symbol: phaseSymbol } = PHASE_DISPLAY[phaseKey]
const nextNewMoonJD = nearestNewMoon(ts.jdTT + 15)
const nextNewMoonJD = nearestNewMoon(ts.jdTT + 15)
const nextFullMoonJD = nearestFullMoon(ts.jdTT)
return {
@ -489,9 +511,9 @@ export function getMoonPhase(date = new Date()): MoonPhaseResult {
age,
elongationDeg,
isWaxing,
nextNewMoon: jdToJSDate(nextNewMoonJD),
nextFullMoon: jdToJSDate(nextFullMoonJD),
prevNewMoon: jdToJSDate(prevNewMoonJD),
nextNewMoon: jdToDate(nextNewMoonJD),
nextFullMoon: jdToDate(nextFullMoonJD),
prevNewMoon: jdToDate(prevNewMoonJD),
}
}
@ -520,7 +542,9 @@ export function getMoonPosition(
lon: number,
elevation = 0,
): MoonPosition {
const DEG = Math.PI / 180
validateDate(date, 'getMoonPosition')
validateLatitude(lat, 'getMoonPosition')
validateLongitude(lon, 'getMoonPosition')
const ts = computeTimeScales(date)
const { moonGCRS } = getMoonSunApproximate(ts.jdTT)
@ -532,17 +556,17 @@ export function getMoonPosition(
const distance = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2)
// Equatorial coordinates for parallactic angle
const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0])
const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0])
const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / distance)))
// Hour angle: ERA(UT1) + longitude right ascension
const era = computeERA(ts.jdUT1)
const HA = era + lon * DEG - RA_moon
const HA = era + lon * DEG2RAD - RA_moon
// Parallactic angle: signed angle between zenith and north pole as seen from the Moon
const parallacticAngle = Math.atan2(
Math.sin(HA),
Math.cos(lat * DEG) * Math.tan(dec_moon) - Math.sin(lat * DEG) * Math.cos(HA),
Math.cos(lat * DEG2RAD) * Math.tan(dec_moon) - Math.sin(lat * DEG2RAD) * Math.cos(HA),
)
return { azimuth: azAlt.azimuth, altitude: azAlt.altitude, distance, parallacticAngle }
@ -566,6 +590,7 @@ export function getMoonPosition(
* ```
*/
export function getMoonIllumination(date: Date = new Date()): MoonIlluminationResult {
validateDate(date, 'getMoonIllumination')
const ts = computeTimeScales(date)
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT)
@ -578,12 +603,12 @@ export function getMoonIllumination(date: Date = new Date()): MoonIlluminationRe
// PA = atan2(cos(dec_sun) * sin(RA_sun - RA_moon),
// sin(dec_sun) * cos(dec_moon) - cos(dec_sun) * sin(dec_moon) * cos(RA_sun - RA_moon))
const moonDist = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2)
const sunDist = Math.sqrt(sunGCRS[0] ** 2 + sunGCRS[1] ** 2 + sunGCRS[2] ** 2)
const sunDist = Math.sqrt(sunGCRS[0] ** 2 + sunGCRS[1] ** 2 + sunGCRS[2] ** 2)
const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0])
const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0])
const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / moonDist)))
const RA_sun = Math.atan2(sunGCRS[1], sunGCRS[0])
const dec_sun = Math.asin(Math.max(-1, Math.min(1, sunGCRS[2] / sunDist)))
const RA_sun = Math.atan2(sunGCRS[1], sunGCRS[0])
const dec_sun = Math.asin(Math.max(-1, Math.min(1, sunGCRS[2] / sunDist)))
const dRA = RA_sun - RA_moon
const angle = Math.atan2(
@ -625,13 +650,16 @@ export function getMoonVisibilityEstimate(
lon: number,
elevation = 0,
): MoonVisibilityEstimate {
validateDate(date, 'getMoonVisibilityEstimate')
validateLatitude(lat, 'getMoonVisibilityEstimate')
validateLongitude(lon, 'getMoonVisibilityEstimate')
const ts = computeTimeScales(date)
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT)
const observer: Observer = { lat, lon, elevation }
// Airless positions — Odeh uses airless altitudes (no refraction)
const moonAirless = computeAzAlt(moonGCRS, observer, ts, true)
const sunAirless = computeAzAlt(sunGCRS, observer, ts, true)
const sunAirless = computeAzAlt(sunGCRS, observer, ts, true)
// ARCL = elongation (geocentric, degrees)
const { elongationDeg } = computeIllumination(moonGCRS, sunGCRS)
@ -652,14 +680,11 @@ export function getMoonVisibilityEstimate(
const { W } = computeCrescentWidth(moonTopo, ARCL)
// Odeh 2006: V = ARCV - f(W), where f(W) = arcv_minimum polynomial
const arcvMin = -0.1018 * W ** 3 + 0.7319 * W ** 2 - 6.3226 * W + 7.1651
const V = ARCV - arcvMin
// Odeh 2006: V = ARCV - arcv_minimum(W)
const V = ARCV - arcvMinimum(W)
const zone: OdehZone = V >= ODEH_THRESHOLDS.A ? 'A'
: V >= ODEH_THRESHOLDS.B ? 'B'
: V >= ODEH_THRESHOLDS.C ? 'C'
: 'D'
const zone: OdehZone =
V >= ODEH_THRESHOLDS.A ? 'A' : V >= ODEH_THRESHOLDS.B ? 'B' : V >= ODEH_THRESHOLDS.C ? 'C' : 'D'
return {
V,
@ -705,21 +730,19 @@ export function getMoon(
lon: number,
elevation = 0,
): MoonSnapshot {
validateDate(date, 'getMoon')
validateLatitude(lat, 'getMoon')
validateLongitude(lon, 'getMoon')
return {
phase: getMoonPhase(date),
position: getMoonPosition(date, lat, lon, elevation),
phase: getMoonPhase(date),
position: getMoonPosition(date, lat, lon, elevation),
illumination: getMoonIllumination(date),
visibility: getMoonVisibilityEstimate(date, lat, lon, elevation),
visibility: getMoonVisibilityEstimate(date, lat, lon, elevation),
}
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
/** Convert JD to a UTC Date. */
function jdToJSDate(jd: number): Date {
return new Date((jd - 2440587.5) * 86400000)
}
/**
* Approximate the nearest full moon JD using Meeus Ch. 49 (full moon k = n + 0.5).
* Full moon corrections differ from new moon; these are from Meeus Table 49.A.
@ -740,46 +763,46 @@ function nearestFullMoon(jdTT: number): number {
/** Full moon JDE for a half-integer k (Meeus Ch. 49, Table 49.A). */
function fullMoonJDE(k: number): number {
const T = k / 1236.85
const DEG = Math.PI / 180
let JDE = 2451550.09766
+ 29.530588861 * k
+ 0.00015437 * T * T
- 0.000000150 * T * T * T
+ 0.00000000073 * T * T * T * T
let JDE =
2451550.09766 +
29.530588861 * k +
0.00015437 * T * T -
0.00000015 * T * T * T +
0.00000000073 * T * T * T * T
const M = (2.5534 + 29.10535670 * k - 0.0000014 * T * T) * DEG
const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T) * DEG
const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T) * DEG
const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T) * DEG
const E = 1 - 0.002516 * T - 0.0000074 * T * T
const M = (2.5534 + 29.1053567 * k - 0.0000014 * T * T) * DEG2RAD
const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T) * DEG2RAD
const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T) * DEG2RAD
const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T) * DEG2RAD
const E = 1 - 0.002516 * T - 0.0000074 * T * T
JDE +=
-0.40614 * Math.sin(Mp)
+ 0.17302 * E * Math.sin(M)
+ 0.01614 * Math.sin(2 * Mp)
+ 0.01043 * Math.sin(2 * Fc)
+ 0.00734 * E * Math.sin(Mp - M)
- 0.00515 * E * Math.sin(Mp + M)
+ 0.00209 * E * E * Math.sin(2 * M)
- 0.00111 * Math.sin(Mp - 2 * Fc)
- 0.00057 * Math.sin(Mp + 2 * Fc)
+ 0.00056 * E * Math.sin(2 * Mp + M)
- 0.00042 * Math.sin(3 * Mp)
+ 0.00042 * E * Math.sin(M + 2 * Fc)
+ 0.00038 * E * Math.sin(M - 2 * Fc)
- 0.00024 * E * Math.sin(2 * Mp - M)
- 0.00017 * Math.sin(Om)
- 0.00007 * Math.sin(Mp + 2 * M)
+ 0.00004 * Math.sin(2 * Mp - 2 * Fc)
+ 0.00004 * Math.sin(3 * M)
+ 0.00003 * Math.sin(Mp + M - 2 * Fc)
+ 0.00003 * Math.sin(2 * Mp + 2 * Fc)
- 0.00003 * Math.sin(Mp + M + 2 * Fc)
+ 0.00003 * Math.sin(Mp - M + 2 * Fc)
- 0.00002 * Math.sin(Mp - M - 2 * Fc)
- 0.00002 * Math.sin(3 * Mp + M)
+ 0.00002 * Math.sin(4 * Mp)
-0.40614 * Math.sin(Mp) +
0.17302 * E * Math.sin(M) +
0.01614 * Math.sin(2 * Mp) +
0.01043 * Math.sin(2 * Fc) +
0.00734 * E * Math.sin(Mp - M) -
0.00515 * E * Math.sin(Mp + M) +
0.00209 * E * E * Math.sin(2 * M) -
0.00111 * Math.sin(Mp - 2 * Fc) -
0.00057 * Math.sin(Mp + 2 * Fc) +
0.00056 * E * Math.sin(2 * Mp + M) -
0.00042 * Math.sin(3 * Mp) +
0.00042 * E * Math.sin(M + 2 * Fc) +
0.00038 * E * Math.sin(M - 2 * Fc) -
0.00024 * E * Math.sin(2 * Mp - M) -
0.00017 * Math.sin(Om) -
0.00007 * Math.sin(Mp + 2 * M) +
0.00004 * Math.sin(2 * Mp - 2 * Fc) +
0.00004 * Math.sin(3 * M) +
0.00003 * Math.sin(Mp + M - 2 * Fc) +
0.00003 * Math.sin(2 * Mp + 2 * Fc) -
0.00003 * Math.sin(Mp + M + 2 * Fc) +
0.00003 * Math.sin(Mp - M + 2 * Fc) -
0.00002 * Math.sin(Mp - M - 2 * Fc) -
0.00002 * Math.sin(3 * Mp + M) +
0.00002 * Math.sin(4 * Mp)
return JDE
}
@ -790,10 +813,10 @@ function fullMoonJDE(k: number): number {
*/
function elongationToPhase(elongationDeg: number, isWaxing: boolean): MoonPhaseName {
const e = elongationDeg
if (e < 5) return 'new-moon'
if (e < 5) return 'new-moon'
if (e > 175) return 'full-moon'
if (e < 85) return isWaxing ? 'waxing-crescent' : 'waning-crescent'
if (e < 95) return isWaxing ? 'first-quarter' : 'last-quarter'
if (e < 85) return isWaxing ? 'waxing-crescent' : 'waning-crescent'
if (e < 95) return isWaxing ? 'first-quarter' : 'last-quarter'
return isWaxing ? 'waxing-gibbous' : 'waning-gibbous'
}
@ -812,6 +835,8 @@ export async function getSunMoonEvents(
observer: Observer,
options?: Pick<SightingOptions, 'kernels'>,
): Promise<SunMoonEvents> {
validateDate(date, 'getSunMoonEvents')
validateObserver(observer, 'getSunMoonEvents')
const kernel = await resolveKernel(options?.kernels)
return eventsGetSunMoonEvents(date, observer, kernel)
}

View file

@ -20,17 +20,17 @@ import type { StateVector, Vec3 } from '../types.js'
import type { SpkKernel } from '../spk/index.js'
import { NAIF_IDS } from '../spk/index.js'
import { J2000, DAYS_PER_JULIAN_CENTURY } from '../time/index.js'
import { DEG2RAD, vdot, vnorm } from '../math/index.js'
// ─── Constants ────────────────────────────────────────────────────────────────
const DEG = Math.PI / 180
const AU_KM = 149597870.7
/** Mean radius of the Moon in km (IAU 2015 nominal value) */
const MOON_RADIUS_KM = 1737.4
/** Mean radius of the Sun in km */
const SUN_RADIUS_KM = 696000.0
const _SUN_RADIUS_KM = 696000.0
void _SUN_RADIUS_KM // reserved for future solar semi-diameter calculations
// ─── Geocentric state ─────────────────────────────────────────────────────────
@ -85,15 +85,12 @@ export function computeIllumination(
moonGCRS: Vec3,
sunGCRS: Vec3,
): { illumination: number; phaseAngleDeg: number; elongationDeg: number; isWaxing: boolean } {
const dot = (a: Vec3, b: Vec3) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
const norm = (v: Vec3) => Math.sqrt(dot(v, v))
const rMoon = norm(moonGCRS)
const rSun = norm(sunGCRS)
const rMoon = vnorm(moonGCRS)
const rSun = vnorm(sunGCRS)
// Elongation ψ: angle at Earth between Moon and Sun
const cosElong = dot(moonGCRS, sunGCRS) / (rMoon * rSun)
const elongationDeg = Math.acos(Math.max(-1, Math.min(1, cosElong))) / DEG
const cosElong = vdot(moonGCRS, sunGCRS) / (rMoon * rSun)
const elongationDeg = Math.acos(Math.max(-1, Math.min(1, cosElong))) / DEG2RAD
// Phase angle i: angle at Moon between Earth and Sun
// Vector from Moon to Earth: -moonGCRS
@ -104,16 +101,16 @@ export function computeIllumination(
sunGCRS[2] - moonGCRS[2],
]
const moonToEarth: Vec3 = [-moonGCRS[0], -moonGCRS[1], -moonGCRS[2]]
const rMoonToSun = norm(moonToSun)
const rMoonToSun = vnorm(moonToSun)
const cosPhase = dot(moonToEarth, moonToSun) / (rMoon * rMoonToSun)
const phaseAngleDeg = Math.acos(Math.max(-1, Math.min(1, cosPhase))) / DEG
const cosPhase = vdot(moonToEarth, moonToSun) / (rMoon * rMoonToSun)
const phaseAngleDeg = Math.acos(Math.max(-1, Math.min(1, cosPhase))) / DEG2RAD
const illumination = (1 + Math.cos(phaseAngleDeg * DEG)) / 2
const illumination = (1 + Math.cos(phaseAngleDeg * DEG2RAD)) / 2
// Moon is waxing when it is east of the Sun (elongation increasing).
// Cross product sunGCRS × moonGCRS z-component: positive when Moon is east of Sun.
const crossZ = sunGCRS[0]*moonGCRS[1] - sunGCRS[1]*moonGCRS[0]
const crossZ = sunGCRS[0] * moonGCRS[1] - sunGCRS[1] * moonGCRS[0]
const isWaxing = crossZ > 0
return { illumination, phaseAngleDeg, elongationDeg, isWaxing }
@ -143,16 +140,13 @@ export function computeCrescentWidth(
moonTopoVec: Vec3,
ARCL: number,
): { W: number; Wprime: number } {
const rMoon = Math.sqrt(
moonTopoVec[0]**2 + moonTopoVec[1]**2 + moonTopoVec[2]**2,
)
const rMoon = Math.sqrt(moonTopoVec[0] ** 2 + moonTopoVec[1] ** 2 + moonTopoVec[2] ** 2)
// Topocentric semi-diameter in arc minutes
const SDmoon_arcmin = Math.atan(MOON_RADIUS_KM / rMoon) / DEG * 60
const SDmoon_arcmin = (Math.atan(MOON_RADIUS_KM / rMoon) / DEG2RAD) * 60
// Crescent width in arc minutes
const ARCL_rad = ARCL * DEG
const ARCL_rad = ARCL * DEG2RAD
const W = SDmoon_arcmin * (1 - Math.cos(ARCL_rad))
// Wprime ≡ W for both Odeh and Yallop in this formulation
@ -185,30 +179,32 @@ export function getMoonSunApproximate(jdTT: number): {
// Mean longitude L0 and mean anomaly M (degrees)
const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T
const M_sun = 357.52911 + 35999.05029 * T - 0.0001537 * T * T
const M_sun_rad = (M_sun % 360) * DEG
const M_sun_rad = (M_sun % 360) * DEG2RAD
const e_sun = 0.016708634 - 0.000042037 * T - 0.0000001267 * T * T
// Equation of center (degrees)
const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M_sun_rad)
+ (0.019993 - 0.000101 * T) * Math.sin(2 * M_sun_rad)
+ 0.000289 * Math.sin(3 * M_sun_rad)
const C =
(1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M_sun_rad) +
(0.019993 - 0.000101 * T) * Math.sin(2 * M_sun_rad) +
0.000289 * Math.sin(3 * M_sun_rad)
// True longitude and anomaly
const sunLonDeg = L0 + C
const nu_rad = M_sun_rad + C * DEG
const nu_rad = M_sun_rad + C * DEG2RAD
// Geometric distance in AU
const R_AU = 1.000001018 * (1 - e_sun * e_sun) / (1 + e_sun * Math.cos(nu_rad))
const R_AU = (1.000001018 * (1 - e_sun * e_sun)) / (1 + e_sun * Math.cos(nu_rad))
const R_km = R_AU * AU_KM
// Nutation correction for apparent longitude (simplified)
const omega = (125.04 - 1934.136 * T) * DEG
const omega = (125.04 - 1934.136 * T) * DEG2RAD
const sunLonApp = sunLonDeg - 0.00569 - 0.00478 * Math.sin(omega)
const sunLon_rad = sunLonApp * DEG
const sunLon_rad = sunLonApp * DEG2RAD
// Mean obliquity of the ecliptic (IAU 1980 approximation, degrees)
const eps = (23.439291111 - 0.013004167 * T - 0.0000001638 * T * T + 0.0000005036 * T * T * T) * DEG
const eps =
(23.439291111 - 0.013004167 * T - 0.0000001638 * T * T + 0.0000005036 * T * T * T) * DEG2RAD
const sunGCRS: Vec3 = [
R_km * Math.cos(sunLon_rad),
@ -219,121 +215,151 @@ export function getMoonSunApproximate(jdTT: number): {
// ── Moon (Meeus Ch. 47) ─────────────────────────────────────────────────────
// Fundamental arguments (degrees)
const Lp = 218.3164477 + 481267.88123421 * T - 0.0015786 * T * T + T * T * T / 538841 - T * T * T * T / 65194000
const D = 297.8501921 + 445267.1114034 * T - 0.0018819 * T * T + T * T * T / 545868 - T * T * T * T / 113065000
const M = 357.5291092 + 35999.0502909 * T - 0.0001536 * T * T + T * T * T / 24490000
const Mp = 134.9633964 + 477198.8675055 * T + 0.0087414 * T * T + T * T * T / 69699 - T * T * T * T / 14712000
const F = 93.2720950 + 483202.0175233 * T - 0.0036539 * T * T - T * T * T / 3526000 + T * T * T * T / 863310000
const Lp =
218.3164477 +
481267.88123421 * T -
0.0015786 * T * T +
(T * T * T) / 538841 -
(T * T * T * T) / 65194000
const D =
297.8501921 +
445267.1114034 * T -
0.0018819 * T * T +
(T * T * T) / 545868 -
(T * T * T * T) / 113065000
const M = 357.5291092 + 35999.0502909 * T - 0.0001536 * T * T + (T * T * T) / 24490000
const Mp =
134.9633964 +
477198.8675055 * T +
0.0087414 * T * T +
(T * T * T) / 69699 -
(T * T * T * T) / 14712000
const F =
93.272095 +
483202.0175233 * T -
0.0036539 * T * T -
(T * T * T) / 3526000 +
(T * T * T * T) / 863310000
// Additive terms for longitude/latitude
const A1 = (119.75 + 131.849 * T) * DEG
const A2 = ( 53.09 + 479264.290 * T) * DEG
const A3 = (313.45 + 481266.484 * T) * DEG
const A1 = (119.75 + 131.849 * T) * DEG2RAD
const A2 = (53.09 + 479264.29 * T) * DEG2RAD
const A3 = (313.45 + 481266.484 * T) * DEG2RAD
// Convert to radians for accumulation
const D_r = (D % 360) * DEG
const M_r = (M % 360) * DEG
const Mp_r = (Mp % 360) * DEG
const F_r = (F % 360) * DEG
const D_r = (D % 360) * DEG2RAD
const M_r = (M % 360) * DEG2RAD
const Mp_r = (Mp % 360) * DEG2RAD
const F_r = (F % 360) * DEG2RAD
// Eccentricity correction for terms involving M (Earth's orbital eccentricity)
const E = 1 - 0.002516 * T - 0.0000074 * T * T
// Longitude and distance accumulation — 30 main terms from Meeus Table 47.A
// [d, m, mp, f, Σl (0.000001°), Σr (0.001 km)]
const LD: ReadonlyArray<readonly [number,number,number,number,number,number]> = [
[ 0, 0, 1, 0, 6288774, -20905355],
[ 2, 0,-1, 0, 1274027, -3699111],
[ 2, 0, 0, 0, 658314, -2955968],
[ 0, 0, 2, 0, 213618, -569925],
[ 0, 1, 0, 0, -185116, 48888],
[ 0, 0, 0, 2, -114332, -3149],
[ 2, 0,-2, 0, 58793, 246158],
[ 2,-1,-1, 0, 57066, -152138],
[ 2, 0, 1, 0, 53322, -170733],
[ 2,-1, 0, 0, 45758, -204586],
[ 0, 1,-1, 0, -40923, -129620],
[ 1, 0, 0, 0, -34720, 108743],
[ 0, 1, 1, 0, -30383, 104755],
[ 2, 0, 0,-2, 15327, 10321],
[ 0, 0, 1, 2, -12528, 0],
[ 0, 0, 1,-2, 10980, 79661],
[ 4, 0,-1, 0, 10675, -34782],
[ 0, 0, 3, 0, 10034, -23210],
[ 4, 0,-2, 0, 8548, -21636],
[ 2, 1,-1, 0, -7888, 24208],
[ 2, 1, 0, 0, -6766, 30824],
[ 1, 0,-1, 0, -5163, -8379],
[ 1, 1, 0, 0, 4987, -16675],
[ 2,-1, 1, 0, 4036, -12831],
[ 2, 0, 2, 0, 3994, -10445],
[ 4, 0, 0, 0, 3861, -11650],
[ 2, 0,-3, 0, 3665, 14403],
[ 0, 1,-2, 0, -2689, -7003],
[ 2, 0,-1, 2, -2602, 0],
[ 2,-1,-2, 0, 2390, 10056],
const LD: ReadonlyArray<readonly [number, number, number, number, number, number]> = [
[0, 0, 1, 0, 6288774, -20905355],
[2, 0, -1, 0, 1274027, -3699111],
[2, 0, 0, 0, 658314, -2955968],
[0, 0, 2, 0, 213618, -569925],
[0, 1, 0, 0, -185116, 48888],
[0, 0, 0, 2, -114332, -3149],
[2, 0, -2, 0, 58793, 246158],
[2, -1, -1, 0, 57066, -152138],
[2, 0, 1, 0, 53322, -170733],
[2, -1, 0, 0, 45758, -204586],
[0, 1, -1, 0, -40923, -129620],
[1, 0, 0, 0, -34720, 108743],
[0, 1, 1, 0, -30383, 104755],
[2, 0, 0, -2, 15327, 10321],
[0, 0, 1, 2, -12528, 0],
[0, 0, 1, -2, 10980, 79661],
[4, 0, -1, 0, 10675, -34782],
[0, 0, 3, 0, 10034, -23210],
[4, 0, -2, 0, 8548, -21636],
[2, 1, -1, 0, -7888, 24208],
[2, 1, 0, 0, -6766, 30824],
[1, 0, -1, 0, -5163, -8379],
[1, 1, 0, 0, 4987, -16675],
[2, -1, 1, 0, 4036, -12831],
[2, 0, 2, 0, 3994, -10445],
[4, 0, 0, 0, 3861, -11650],
[2, 0, -3, 0, 3665, 14403],
[0, 1, -2, 0, -2689, -7003],
[2, 0, -1, 2, -2602, 0],
[2, -1, -2, 0, 2390, 10056],
]
let Sl = 0, Sr = 0
let Sl = 0,
Sr = 0
for (const [d, m, mp, f, sl, sr] of LD) {
const arg = d*D_r + m*M_r + mp*Mp_r + f*F_r
const eCorr = Math.abs(m) === 2 ? E*E : Math.abs(m) === 1 ? E : 1
const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r
const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1
Sl += sl * eCorr * Math.sin(arg)
Sr += sr * eCorr * Math.cos(arg)
}
// Additive longitude corrections (Meeus §47)
Sl += 3958 * Math.sin(A1) + 1962 * Math.sin((Lp - F) * DEG) + 318 * Math.sin(A2)
Sl += 3958 * Math.sin(A1) + 1962 * Math.sin((Lp - F) * DEG2RAD) + 318 * Math.sin(A2)
// Latitude accumulation — 20 main terms from Meeus Table 47.B
// [d, m, mp, f, Σb (0.000001°)]
const FB: ReadonlyArray<readonly [number,number,number,number,number]> = [
[ 0, 0, 0, 1, 5128122],
[ 0, 0, 1, 1, 280602],
[ 0, 0, 1,-1, 277693],
[ 2, 0, 0,-1, 173237],
[ 2, 0,-1, 1, 55413],
[ 2, 0,-1,-1, 46271],
[ 2, 0, 0, 1, 32573],
[ 0, 0, 2, 1, 17198],
[ 2, 0, 1,-1, 9266],
[ 0, 0, 2,-1, 8822],
[ 2,-1, 0,-1, 8216],
[ 2, 0,-2,-1, 4324],
[ 2, 0, 1, 1, 4200],
[ 2, 1, 0,-1, -3359],
[ 2,-1,-1, 1, 2463],
[ 2,-1, 0, 1, 2211],
[ 2,-1,-1,-1, 2065],
[ 0, 1,-1,-1, -1870],
[ 4, 0,-1,-1, 1828],
[ 0, 1, 0, 1, -1794],
const FB: ReadonlyArray<readonly [number, number, number, number, number]> = [
[0, 0, 0, 1, 5128122],
[0, 0, 1, 1, 280602],
[0, 0, 1, -1, 277693],
[2, 0, 0, -1, 173237],
[2, 0, -1, 1, 55413],
[2, 0, -1, -1, 46271],
[2, 0, 0, 1, 32573],
[0, 0, 2, 1, 17198],
[2, 0, 1, -1, 9266],
[0, 0, 2, -1, 8822],
[2, -1, 0, -1, 8216],
[2, 0, -2, -1, 4324],
[2, 0, 1, 1, 4200],
[2, 1, 0, -1, -3359],
[2, -1, -1, 1, 2463],
[2, -1, 0, 1, 2211],
[2, -1, -1, -1, 2065],
[0, 1, -1, -1, -1870],
[4, 0, -1, -1, 1828],
[0, 1, 0, 1, -1794],
]
let Sb = 0
for (const [d, m, mp, f, sb] of FB) {
const arg = d*D_r + m*M_r + mp*Mp_r + f*F_r
const eCorr = Math.abs(m) === 2 ? E*E : Math.abs(m) === 1 ? E : 1
const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r
const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1
Sb += sb * eCorr * Math.sin(arg)
}
// Additive latitude corrections
Sb += -2235 * Math.sin(Lp * DEG) + 382 * Math.sin(A3) + 175 * Math.sin(A1 - F_r)
+ 175 * Math.sin(A1 + F_r) + 127 * Math.sin((Lp - Mp) * DEG) - 115 * Math.sin((Lp + Mp) * DEG)
Sb +=
-2235 * Math.sin(Lp * DEG2RAD) +
382 * Math.sin(A3) +
175 * Math.sin(A1 - F_r) +
175 * Math.sin(A1 + F_r) +
127 * Math.sin((Lp - Mp) * DEG2RAD) -
115 * Math.sin((Lp + Mp) * DEG2RAD)
// Moon ecliptic coordinates
const moonLonDeg = Lp + Sl * 1e-6
const moonLatDeg = Sb * 1e-6
const moonDistKm = 385000.56 + Sr * 0.001
const moonLon_rad = moonLonDeg * DEG
const moonLat_rad = moonLatDeg * DEG
const moonLon_rad = moonLonDeg * DEG2RAD
const moonLat_rad = moonLatDeg * DEG2RAD
// Ecliptic to equatorial (GCRS ≈ J2000 equatorial for this accuracy level)
const moonGCRS: Vec3 = [
moonDistKm * Math.cos(moonLat_rad) * Math.cos(moonLon_rad),
moonDistKm * (Math.cos(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) - Math.sin(eps) * Math.sin(moonLat_rad)),
moonDistKm * (Math.sin(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) + Math.cos(eps) * Math.sin(moonLat_rad)),
moonDistKm *
(Math.cos(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) -
Math.sin(eps) * Math.sin(moonLat_rad)),
moonDistKm *
(Math.sin(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) +
Math.cos(eps) * Math.sin(moonLat_rad)),
]
return { moonGCRS, sunGCRS }
@ -355,48 +381,49 @@ export function nearestNewMoon(jdTT: number): number {
const T = k / 1236.85
// JDE of mean new moon (Meeus Eq. 49.1)
let JDE = 2451550.09766
+ 29.530588861 * k
+ 0.00015437 * T * T
- 0.000000150 * T * T * T
+ 0.00000000073 * T * T * T * T
let JDE =
2451550.09766 +
29.530588861 * k +
0.00015437 * T * T -
0.00000015 * T * T * T +
0.00000000073 * T * T * T * T
// Fundamental arguments for the corrections (degrees → radians)
const M = (2.5534 + 29.10535670 * k - 0.0000014 * T * T - 0.00000011 * T * T * T) * DEG
const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T + 0.00001238 * T * T * T) * DEG
const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T - 0.00000227 * T * T * T) * DEG
const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T + 0.00000215 * T * T * T) * DEG
const M = (2.5534 + 29.1053567 * k - 0.0000014 * T * T - 0.00000011 * T * T * T) * DEG2RAD
const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T + 0.00001238 * T * T * T) * DEG2RAD
const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T - 0.00000227 * T * T * T) * DEG2RAD
const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T + 0.00000215 * T * T * T) * DEG2RAD
// Eccentricity of Earth's orbit
const E = 1 - 0.002516 * T - 0.0000074 * T * T
// Corrections from Meeus Table 49.A (new moon)
JDE +=
-0.40720 * Math.sin(Mp)
+ 0.17241 * E * Math.sin(M)
+ 0.01608 * Math.sin(2 * Mp)
+ 0.01039 * Math.sin(2 * Fc)
+ 0.00739 * E * Math.sin(Mp - M)
- 0.00514 * E * Math.sin(Mp + M)
+ 0.00208 * E * E * Math.sin(2 * M)
- 0.00111 * Math.sin(Mp - 2 * Fc)
- 0.00057 * Math.sin(Mp + 2 * Fc)
+ 0.00056 * E * Math.sin(2 * Mp + M)
- 0.00042 * Math.sin(3 * Mp)
+ 0.00042 * E * Math.sin(M + 2 * Fc)
+ 0.00038 * E * Math.sin(M - 2 * Fc)
- 0.00024 * E * Math.sin(2 * Mp - M)
- 0.00017 * Math.sin(Om)
- 0.00007 * Math.sin(Mp + 2 * M)
+ 0.00004 * Math.sin(2 * Mp - 2 * Fc)
+ 0.00004 * Math.sin(3 * M)
+ 0.00003 * Math.sin(Mp + M - 2 * Fc)
+ 0.00003 * Math.sin(2 * Mp + 2 * Fc)
- 0.00003 * Math.sin(Mp + M + 2 * Fc)
+ 0.00003 * Math.sin(Mp - M + 2 * Fc)
- 0.00002 * Math.sin(Mp - M - 2 * Fc)
- 0.00002 * Math.sin(3 * Mp + M)
+ 0.00002 * Math.sin(4 * Mp)
-0.4072 * Math.sin(Mp) +
0.17241 * E * Math.sin(M) +
0.01608 * Math.sin(2 * Mp) +
0.01039 * Math.sin(2 * Fc) +
0.00739 * E * Math.sin(Mp - M) -
0.00514 * E * Math.sin(Mp + M) +
0.00208 * E * E * Math.sin(2 * M) -
0.00111 * Math.sin(Mp - 2 * Fc) -
0.00057 * Math.sin(Mp + 2 * Fc) +
0.00056 * E * Math.sin(2 * Mp + M) -
0.00042 * Math.sin(3 * Mp) +
0.00042 * E * Math.sin(M + 2 * Fc) +
0.00038 * E * Math.sin(M - 2 * Fc) -
0.00024 * E * Math.sin(2 * Mp - M) -
0.00017 * Math.sin(Om) -
0.00007 * Math.sin(Mp + 2 * M) +
0.00004 * Math.sin(2 * Mp - 2 * Fc) +
0.00004 * Math.sin(3 * M) +
0.00003 * Math.sin(Mp + M - 2 * Fc) +
0.00003 * Math.sin(2 * Mp + 2 * Fc) -
0.00003 * Math.sin(Mp + M + 2 * Fc) +
0.00003 * Math.sin(Mp - M + 2 * Fc) -
0.00002 * Math.sin(Mp - M - 2 * Fc) -
0.00002 * Math.sin(3 * Mp + M) +
0.00002 * Math.sin(4 * Mp)
return JDE
}

View file

@ -100,7 +100,9 @@ async function cmdSighting(cmdArgs: string[]) {
console.log(`Sunset: ${fmtDate(report.sunsetUTC)}`)
console.log(`Moonset: ${fmtDate(report.moonsetUTC)}`)
console.log(`Best time: ${fmtDate(report.bestTimeUTC)}`)
console.log(`Lag: ${report.lagMinutes !== null ? Math.round(report.lagMinutes) + ' min' : 'N/A'}`)
console.log(
`Lag: ${report.lagMinutes !== null ? Math.round(report.lagMinutes) + ' min' : 'N/A'}`,
)
console.log('')
if (report.geometry) {
@ -151,7 +153,9 @@ async function cmdBenchmark() {
getMoonPhase(new Date(Date.UTC(2025, 2, 1 + (i % 28))))
}
const phaseMs = performance.now() - phaseStart
console.log(`getMoonPhase × ${N_PHASE}: ${phaseMs.toFixed(1)} ms (${(phaseMs / N_PHASE * 1000).toFixed(1)} µs/call)`)
console.log(
`getMoonPhase × ${N_PHASE}: ${phaseMs.toFixed(1)} ms (${((phaseMs / N_PHASE) * 1000).toFixed(1)} µs/call)`,
)
// Benchmark 2: kernel load
const loadStart = performance.now()
@ -170,10 +174,13 @@ async function cmdBenchmark() {
/** Format a nullable Date as a short UTC string. */
function fmtDate(d: Date | null): string {
if (!d) return 'N/A'
return d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')
return d
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, ' UTC')
}
main().catch(err => {
main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err))
process.exit(1)
})

View file

@ -22,7 +22,8 @@
import type { Vec3, Observer, SunMoonEvents, TimeScales } from '../types.js'
import type { SpkKernel } from '../spk/index.js'
import { NAIF_IDS } from '../spk/index.js'
import { brentRoot } from '../math/index.js'
import { brentRoot, vdot, vnorm } from '../math/index.js'
import { arcvMinimum } from '../visibility/index.js'
import {
J2000,
SECONDS_PER_DAY,
@ -33,7 +34,11 @@ import {
getDeltaAT,
TT_MINUS_TAI,
} from '../time/index.js'
import { getMoonGeocentricState, getSunGeocentricState, computeCrescentWidth } from '../bodies/index.js'
import {
getMoonGeocentricState,
getSunGeocentricState,
computeCrescentWidth,
} from '../bodies/index.js'
import { geodeticToECEF, computeAzAlt } from '../observer/index.js'
import { itrsToGcrs } from '../frames/index.js'
@ -51,7 +56,7 @@ export const SUN_ALTITUDE_THRESHOLD = -0.8333
* Accounts for: standard refraction at horizon (34') + lunar semi-diameter (~16')
* Note: Moon's SD varies with distance (14.7'16.8'). Use 0.2725° as mean.
*/
export const MOON_ALTITUDE_THRESHOLD = -0.8333 // refined per actual distance in implementation
export const MOON_ALTITUDE_THRESHOLD = -0.8333 // refined per actual distance in implementation
// ─── Internal helpers ─────────────────────────────────────────────────────────
@ -130,7 +135,7 @@ export function findAltitudeCrossing(
const f = (et: number) => altitudeMinusThreshold(kernel, naifId, observer, et, threshold)
const STEP_S = 600 // 10-minute coarse sampling
const STEP_S = 600 // 10-minute coarse sampling
const nSteps = Math.ceil((endET - startET) / STEP_S)
let prevET = startET
@ -140,11 +145,11 @@ export function findAltitudeCrossing(
const currET = Math.min(startET + i * STEP_S, endET)
const currF = f(currET)
const isRisingCross = rising && prevF < 0 && currF >= 0
const isRisingCross = rising && prevF < 0 && currF >= 0
const isSettingCross = !rising && prevF >= 0 && currF < 0
if (isRisingCross || isSettingCross) {
const etCross = brentRoot(f, prevET, currET, 0.5) // 0.5s precision
const etCross = brentRoot(f, prevET, currET, 0.5) // 0.5s precision
if (etCross !== null) {
const tsCross = etToTS(etCross)
return tsCross.utc
@ -169,45 +174,90 @@ export function findAltitudeCrossing(
* @param kernel - DE442S kernel
* @returns SunMoonEvents with all event times in UTC, or null if event doesn't occur
*/
export function getSunMoonEvents(
date: Date,
observer: Observer,
kernel: SpkKernel,
): SunMoonEvents {
export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKernel): SunMoonEvents {
// Anchor search at UTC midnight of the input date
const midnight = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
const jdMidnight = dateToJD(midnight)
// Approximate ET at midnight
const etStart = jdTTtoET(jdMidnight + 70.0 / SECONDS_PER_DAY) // rough TT≈UTC+70s
const etEnd = etStart + 28 * 3600 // 28-hour window
const etStart = jdTTtoET(jdMidnight + 70.0 / SECONDS_PER_DAY) // rough TT≈UTC+70s
const etEnd = etStart + 28 * 3600 // 28-hour window
const ts0 = computeTimeScales(midnight)
// Sun events
const sunriseUTC = findAltitudeCrossing(
kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, SUN_ALTITUDE_THRESHOLD, true,
kernel,
NAIF_IDS.SUN,
observer,
ts0,
etStart,
etEnd,
SUN_ALTITUDE_THRESHOLD,
true,
)
const sunsetUTC = findAltitudeCrossing(
kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, SUN_ALTITUDE_THRESHOLD, false,
kernel,
NAIF_IDS.SUN,
observer,
ts0,
etStart,
etEnd,
SUN_ALTITUDE_THRESHOLD,
false,
)
// Twilight events (Sun setting below -6°, -12°, -18°)
const civilTwilightEndUTC = findAltitudeCrossing(
kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, -6, false,
kernel,
NAIF_IDS.SUN,
observer,
ts0,
etStart,
etEnd,
-6,
false,
)
const nauticalTwilightEndUTC = findAltitudeCrossing(
kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, -12, false,
kernel,
NAIF_IDS.SUN,
observer,
ts0,
etStart,
etEnd,
-12,
false,
)
const astronomicalTwilightEndUTC = findAltitudeCrossing(
kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, -18, false,
kernel,
NAIF_IDS.SUN,
observer,
ts0,
etStart,
etEnd,
-18,
false,
)
// Moon events
const moonriseUTC = findAltitudeCrossing(
kernel, NAIF_IDS.MOON, observer, ts0, etStart, etEnd, MOON_ALTITUDE_THRESHOLD, true,
kernel,
NAIF_IDS.MOON,
observer,
ts0,
etStart,
etEnd,
MOON_ALTITUDE_THRESHOLD,
true,
)
const moonsetUTC = findAltitudeCrossing(
kernel, NAIF_IDS.MOON, observer, ts0, etStart, etEnd, MOON_ALTITUDE_THRESHOLD, false,
kernel,
NAIF_IDS.MOON,
observer,
ts0,
etStart,
etEnd,
MOON_ALTITUDE_THRESHOLD,
false,
)
return {
@ -241,7 +291,7 @@ export function bestTimeHeuristic(
moonsetUTC: Date,
): { bestTimeUTC: Date; lagMinutes: number } | null {
const lagMs = moonsetUTC.getTime() - sunsetUTC.getTime()
if (lagMs <= 0) return null // Moon sets before Sun — no sighting possible
if (lagMs <= 0) return null // Moon sets before Sun — no sighting possible
const lagMinutes = lagMs / 60000
const bestTimeMs = sunsetUTC.getTime() + (4 / 9) * lagMs
@ -252,14 +302,6 @@ export function bestTimeHeuristic(
}
}
/**
* Odeh arcv minimum polynomial (Odeh 2006, Eq. 1).
* Returns the minimum ARCV needed for visibility at crescent width W (arc minutes).
*/
function odehArcvMin(W: number): number {
return 11.8371 - 6.3226 * W + 0.7319 * W * W - 0.1018 * W * W * W
}
/**
* Find the optimal observation time by maximizing the Odeh V parameter
* over the interval [sunset, moonset].
@ -290,9 +332,6 @@ export function bestTimeOptimized(
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation)
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000]
const dot = (a: Vec3, b: Vec3) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
const norm = (v: Vec3) => Math.sqrt(dot(v, v))
let bestTimeUTC = sunsetUTC
let maxV = -Infinity
@ -302,14 +341,14 @@ export function bestTimeOptimized(
const et = jdTTtoET(ts.jdTT)
const moonGCRS = getMoonGeocentricState(kernel, et).position
const sunGCRS = getSunGeocentricState(kernel, et).position
const sunGCRS = getSunGeocentricState(kernel, et).position
// Convert observer ITRS → GCRS at this timestep (Earth rotation changes per step)
const obsGCRS = itrsToGcrs(obsITRS, ts)
// Airless altitudes via the full pipeline
const moonAzAlt = computeAzAlt(moonGCRS, observer, ts, true)
const sunAzAlt = computeAzAlt(sunGCRS, observer, ts, true)
const sunAzAlt = computeAzAlt(sunGCRS, observer, ts, true)
const ARCV = moonAzAlt.altitude - sunAzAlt.altitude
@ -324,11 +363,11 @@ export function bestTimeOptimized(
sunGCRS[1] - obsGCRS[1],
sunGCRS[2] - obsGCRS[2],
]
const cosARCL = dot(moonTopo, sunTopo) / (norm(moonTopo) * norm(sunTopo))
const cosARCL = vdot(moonTopo, sunTopo) / (vnorm(moonTopo) * vnorm(sunTopo))
const ARCL = Math.acos(Math.max(-1, Math.min(1, cosARCL))) * (180 / Math.PI)
const { W } = computeCrescentWidth(moonTopo, ARCL)
const V = ARCV - odehArcvMin(W)
const V = ARCV - arcvMinimum(W)
if (V > maxV) {
maxV = V
@ -347,13 +386,7 @@ export function bestTimeOptimized(
* @param windowMinutes - Half-width of window in minutes (default 20)
* @returns [start, end] UTC Date pair
*/
export function computeObservationWindow(
bestTimeUTC: Date,
windowMinutes = 20,
): [Date, Date] {
export function computeObservationWindow(bestTimeUTC: Date, windowMinutes = 20): [Date, Date] {
const windowMs = windowMinutes * 60000
return [
new Date(bestTimeUTC.getTime() - windowMs),
new Date(bestTimeUTC.getTime() + windowMs),
]
return [new Date(bestTimeUTC.getTime() - windowMs), new Date(bestTimeUTC.getTime() + windowMs)]
}

View file

@ -50,165 +50,163 @@ const UAS01_TO_ARCSEC = 1e-7
// dpsi += (ps + pst*T)*sin(arg) + pc*cos(arg)
// deps += (ec + ect*T)*cos(arg) + es*sin(arg)
const NUT_2000B: ReadonlyArray<readonly [
number,number,number,number,number,
number,number,number,
number,number,number
]> = [
const NUT_2000B: ReadonlyArray<
readonly [number, number, number, number, number, number, number, number, number, number, number]
> = [
// 1
[ 0, 0, 0, 0, 1, -172064161.0, -174666.0, 33386.0, 92052331.0, 9086.0, 15377.0],
[0, 0, 0, 0, 1, -172064161.0, -174666.0, 33386.0, 92052331.0, 9086.0, 15377.0],
// 2
[ 0, 0, 2,-2, 2, -13170906.0, -13696.0, -13238.0, 5730336.0, -3015.0, -4587.0],
[0, 0, 2, -2, 2, -13170906.0, -13696.0, -13238.0, 5730336.0, -3015.0, -4587.0],
// 3
[ 0, 0, 2, 0, 2, -2276413.0, -2353.0, 2796.0, 978459.0, -619.0, 645.0],
[0, 0, 2, 0, 2, -2276413.0, -2353.0, 2796.0, 978459.0, -619.0, 645.0],
// 4
[ 0, 0, 0, 0, 2, 2074554.0, 2352.0, -2635.0, -897492.0, 307.0, -187.0],
[0, 0, 0, 0, 2, 2074554.0, 2352.0, -2635.0, -897492.0, 307.0, -187.0],
// 5
[ 0, 1, 0, 0, 0, 1475877.0, -11817.0, 11817.0, 73871.0, -184.0, -1924.0],
[0, 1, 0, 0, 0, 1475877.0, -11817.0, 11817.0, 73871.0, -184.0, -1924.0],
// 6
[ 0, 1, 2,-2, 2, -516821.0, 1226.0, -524.0, 224386.0, -677.0, -174.0],
[0, 1, 2, -2, 2, -516821.0, 1226.0, -524.0, 224386.0, -677.0, -174.0],
// 7
[ 1, 0, 0, 0, 0, 711159.0, 73.0, -872.0, -6750.0, 0.0, 358.0],
[1, 0, 0, 0, 0, 711159.0, 73.0, -872.0, -6750.0, 0.0, 358.0],
// 8
[ 0, 0, 2, 0, 1, -387298.0, -367.0, 380.0, 200728.0, 18.0, 318.0],
[0, 0, 2, 0, 1, -387298.0, -367.0, 380.0, 200728.0, 18.0, 318.0],
// 9
[ 1, 0, 2, 0, 2, -301461.0, -36.0, 816.0, 129025.0, -63.0, 367.0],
[1, 0, 2, 0, 2, -301461.0, -36.0, 816.0, 129025.0, -63.0, 367.0],
// 10
[ 0,-1, 2,-2, 2, 215829.0, -494.0, 111.0, -95929.0, 299.0, 132.0],
[0, -1, 2, -2, 2, 215829.0, -494.0, 111.0, -95929.0, 299.0, 132.0],
// 11
[ 0, 0, 2,-2, 1, 128227.0, 137.0, 181.0, -68982.0, -9.0, 39.0],
[0, 0, 2, -2, 1, 128227.0, 137.0, 181.0, -68982.0, -9.0, 39.0],
// 12
[-1, 0, 2, 0, 2, 123457.0, 11.0, 19.0, -53311.0, 32.0, -4.0],
[-1, 0, 2, 0, 2, 123457.0, 11.0, 19.0, -53311.0, 32.0, -4.0],
// 13
[-1, 0, 0, 2, 0, 156994.0, 10.0, -168.0, -1235.0, 0.0, 82.0],
[-1, 0, 0, 2, 0, 156994.0, 10.0, -168.0, -1235.0, 0.0, 82.0],
// 14
[ 1, 0, 0, 0, 1, 63110.0, 63.0, 27.0, -33228.0, 0.0, -9.0],
[1, 0, 0, 0, 1, 63110.0, 63.0, 27.0, -33228.0, 0.0, -9.0],
// 15
[-1, 0, 0, 0, 1, -57976.0, -63.0, -189.0, 31429.0, 0.0, -75.0],
[-1, 0, 0, 0, 1, -57976.0, -63.0, -189.0, 31429.0, 0.0, -75.0],
// 16
[-1, 0, 2, 2, 2, -59641.0, -11.0, 149.0, 25543.0, -11.0, 66.0],
[-1, 0, 2, 2, 2, -59641.0, -11.0, 149.0, 25543.0, -11.0, 66.0],
// 17
[ 1, 0, 2, 0, 1, -51613.0, -42.0, 129.0, 26366.0, 0.0, 78.0],
[1, 0, 2, 0, 1, -51613.0, -42.0, 129.0, 26366.0, 0.0, 78.0],
// 18
[-2, 0, 2, 0, 1, 45893.0, 50.0, 31.0, -24236.0, -10.0, 20.0],
[-2, 0, 2, 0, 1, 45893.0, 50.0, 31.0, -24236.0, -10.0, 20.0],
// 19
[ 0, 0, 0, 2, 0, 63384.0, 11.0, -150.0, -1220.0, 0.0, 29.0],
[0, 0, 0, 2, 0, 63384.0, 11.0, -150.0, -1220.0, 0.0, 29.0],
// 20
[ 0, 0, 2, 2, 2, -38571.0, -1.0, 158.0, 16452.0, -11.0, 68.0],
[0, 0, 2, 2, 2, -38571.0, -1.0, 158.0, 16452.0, -11.0, 68.0],
// 21
[ 0,-2, 2,-2, 2, 32481.0, 0.0, 0.0, -13870.0, 0.0, 0.0],
[0, -2, 2, -2, 2, 32481.0, 0.0, 0.0, -13870.0, 0.0, 0.0],
// 22
[-2, 0, 0, 2, 0, -47722.0, 0.0, -18.0, 477.0, 0.0, -25.0],
[-2, 0, 0, 2, 0, -47722.0, 0.0, -18.0, 477.0, 0.0, -25.0],
// 23
[ 2, 0, 2, 0, 2, -31046.0, -1.0, 131.0, 13238.0, -11.0, 59.0],
[2, 0, 2, 0, 2, -31046.0, -1.0, 131.0, 13238.0, -11.0, 59.0],
// 24
[ 1, 0, 2,-2, 2, 28593.0, 0.0, -1.0, -12338.0, 10.0, -3.0],
[1, 0, 2, -2, 2, 28593.0, 0.0, -1.0, -12338.0, 10.0, -3.0],
// 25
[-1, 0, 2, 0, 1, 20441.0, 21.0, 10.0, -10758.0, 0.0, -3.0],
[-1, 0, 2, 0, 1, 20441.0, 21.0, 10.0, -10758.0, 0.0, -3.0],
// 26
[ 2, 0, 0, 0, 0, 29243.0, 0.0, -74.0, -609.0, 0.0, 13.0],
[2, 0, 0, 0, 0, 29243.0, 0.0, -74.0, -609.0, 0.0, 13.0],
// 27
[ 0, 0, 2, 0, 0, 25887.0, 0.0, -66.0, -550.0, 0.0, 11.0],
[0, 0, 2, 0, 0, 25887.0, 0.0, -66.0, -550.0, 0.0, 11.0],
// 28
[ 0, 1, 0, 0, 1, -14053.0, -25.0, 79.0, 8551.0, -2.0, -45.0],
[0, 1, 0, 0, 1, -14053.0, -25.0, 79.0, 8551.0, -2.0, -45.0],
// 29
[-1, 0, 0, 2, 1, 15164.0, 10.0, 11.0, -8001.0, 0.0, -1.0],
[-1, 0, 0, 2, 1, 15164.0, 10.0, 11.0, -8001.0, 0.0, -1.0],
// 30
[ 0, 2, 2,-2, 2, -15794.0, 72.0, -16.0, 6850.0, -42.0, -5.0],
[0, 2, 2, -2, 2, -15794.0, 72.0, -16.0, 6850.0, -42.0, -5.0],
// 31
[ 0, 0,-2, 2, 0, 21783.0, 0.0, 13.0, -167.0, 0.0, 13.0],
[0, 0, -2, 2, 0, 21783.0, 0.0, 13.0, -167.0, 0.0, 13.0],
// 32
[ 1, 0, 0,-2, 1, -12873.0, -10.0, -37.0, 6953.0, 0.0, -14.0],
[1, 0, 0, -2, 1, -12873.0, -10.0, -37.0, 6953.0, 0.0, -14.0],
// 33
[ 0,-1, 0, 0, 1, -12654.0, 11.0, 63.0, 6415.0, 0.0, 26.0],
[0, -1, 0, 0, 1, -12654.0, 11.0, 63.0, 6415.0, 0.0, 26.0],
// 34
[-1, 0, 2, 2, 1, -10204.0, 0.0, 25.0, 5222.0, 0.0, 15.0],
[-1, 0, 2, 2, 1, -10204.0, 0.0, 25.0, 5222.0, 0.0, 15.0],
// 35
[ 0, 2, 0, 0, 0, 16707.0, -85.0, -10.0, 168.0, -1.0, 10.0],
[0, 2, 0, 0, 0, 16707.0, -85.0, -10.0, 168.0, -1.0, 10.0],
// 36
[ 1, 0, 2, 2, 2, -7691.0, 0.0, 44.0, 3268.0, 0.0, 19.0],
[1, 0, 2, 2, 2, -7691.0, 0.0, 44.0, 3268.0, 0.0, 19.0],
// 37
[-2, 0, 2, 0, 0, -11024.0, 0.0, -14.0, 104.0, 0.0, 2.0],
[-2, 0, 2, 0, 0, -11024.0, 0.0, -14.0, 104.0, 0.0, 2.0],
// 38
[ 0, 1, 2, 0, 2, 7566.0, -21.0, -11.0, -3250.0, 0.0, -5.0],
[0, 1, 2, 0, 2, 7566.0, -21.0, -11.0, -3250.0, 0.0, -5.0],
// 39
[ 0, 0, 2, 2, 1, -6637.0, -11.0, 25.0, 3353.0, 0.0, 14.0],
[0, 0, 2, 2, 1, -6637.0, -11.0, 25.0, 3353.0, 0.0, 14.0],
// 40
[ 0,-1, 2, 0, 2, -7141.0, 21.0, 8.0, 3070.0, 0.0, 4.0],
[0, -1, 2, 0, 2, -7141.0, 21.0, 8.0, 3070.0, 0.0, 4.0],
// 41
[ 0, 0, 0, 2, 1, -6302.0, -11.0, 2.0, 3272.0, 0.0, 4.0],
[0, 0, 0, 2, 1, -6302.0, -11.0, 2.0, 3272.0, 0.0, 4.0],
// 42
[ 1, 0, 2,-2, 1, 5800.0, 10.0, 2.0, -3045.0, 0.0, -1.0],
[1, 0, 2, -2, 1, 5800.0, 10.0, 2.0, -3045.0, 0.0, -1.0],
// 43
[ 2, 0, 2,-2, 2, 6443.0, 0.0, -7.0, -2768.0, 0.0, -4.0],
[2, 0, 2, -2, 2, 6443.0, 0.0, -7.0, -2768.0, 0.0, -4.0],
// 44
[-2, 0, 0, 2, 1, -5774.0, -11.0, -15.0, 3041.0, 0.0, -5.0],
[-2, 0, 0, 2, 1, -5774.0, -11.0, -15.0, 3041.0, 0.0, -5.0],
// 45
[ 2, 0, 2, 0, 1, -5350.0, 0.0, 21.0, 2695.0, 0.0, 12.0],
[2, 0, 2, 0, 1, -5350.0, 0.0, 21.0, 2695.0, 0.0, 12.0],
// 46
[ 0,-1, 2,-2, 1, -4752.0, -11.0, -3.0, 2719.0, 0.0, -3.0],
[0, -1, 2, -2, 1, -4752.0, -11.0, -3.0, 2719.0, 0.0, -3.0],
// 47
[ 0, 0, 0,-2, 1, -4940.0, -11.0, -21.0, 2720.0, 0.0, -9.0],
[0, 0, 0, -2, 1, -4940.0, -11.0, -21.0, 2720.0, 0.0, -9.0],
// 48
[-1,-1, 0, 2, 0, 7350.0, 0.0, -8.0, -51.0, 0.0, 4.0],
[-1, -1, 0, 2, 0, 7350.0, 0.0, -8.0, -51.0, 0.0, 4.0],
// 49
[ 2, 0, 0,-2, 1, 4065.0, 0.0, 6.0, -2206.0, 0.0, 1.0],
[2, 0, 0, -2, 1, 4065.0, 0.0, 6.0, -2206.0, 0.0, 1.0],
// 50
[ 1, 0, 0, 2, 0, 6579.0, 0.0, -24.0, -199.0, 0.0, 2.0],
[1, 0, 0, 2, 0, 6579.0, 0.0, -24.0, -199.0, 0.0, 2.0],
// 51
[ 0, 1, 2,-2, 1, 3579.0, 0.0, 5.0, -1900.0, 0.0, 1.0],
[0, 1, 2, -2, 1, 3579.0, 0.0, 5.0, -1900.0, 0.0, 1.0],
// 52
[ 1,-1, 0, 0, 0, 4725.0, 0.0, -6.0, -41.0, 0.0, 3.0],
[1, -1, 0, 0, 0, 4725.0, 0.0, -6.0, -41.0, 0.0, 3.0],
// 53
[-2, 0, 2, 0, 2, -3075.0, 0.0, -2.0, 1313.0, 0.0, -1.0],
[-2, 0, 2, 0, 2, -3075.0, 0.0, -2.0, 1313.0, 0.0, -1.0],
// 54
[ 3, 0, 2, 0, 2, -2904.0, 0.0, 15.0, 1233.0, 0.0, 7.0],
[3, 0, 2, 0, 2, -2904.0, 0.0, 15.0, 1233.0, 0.0, 7.0],
// 55
[ 0,-1, 0, 2, 0, 4348.0, 0.0, -10.0, -81.0, 0.0, 2.0],
[0, -1, 0, 2, 0, 4348.0, 0.0, -10.0, -81.0, 0.0, 2.0],
// 56
[ 1,-1, 2, 0, 2, -2878.0, 0.0, 8.0, 1232.0, 0.0, 4.0],
[1, -1, 2, 0, 2, -2878.0, 0.0, 8.0, 1232.0, 0.0, 4.0],
// 57
[ 0, 0, 0, 1, 0, -4230.0, 0.0, 5.0, -20.0, 0.0, -2.0],
[0, 0, 0, 1, 0, -4230.0, 0.0, 5.0, -20.0, 0.0, -2.0],
// 58
[-1,-1, 2, 2, 2, -2819.0, 0.0, 7.0, 1207.0, 0.0, 3.0],
[-1, -1, 2, 2, 2, -2819.0, 0.0, 7.0, 1207.0, 0.0, 3.0],
// 59
[-1, 0, 2, 0, 0, -4056.0, 0.0, 5.0, 40.0, 0.0, -2.0],
[-1, 0, 2, 0, 0, -4056.0, 0.0, 5.0, 40.0, 0.0, -2.0],
// 60
[ 0,-1, 2, 2, 2, -2647.0, 0.0, 11.0, 1129.0, 0.0, 5.0],
[0, -1, 2, 2, 2, -2647.0, 0.0, 11.0, 1129.0, 0.0, 5.0],
// 61
[-2, 0, 0, 0, 1, -2294.0, 0.0, -10.0, 1266.0, 0.0, -4.0],
[-2, 0, 0, 0, 1, -2294.0, 0.0, -10.0, 1266.0, 0.0, -4.0],
// 62
[ 1, 1, 2, 0, 2, 2481.0, 0.0, -7.0, -1062.0, 0.0, -3.0],
[1, 1, 2, 0, 2, 2481.0, 0.0, -7.0, -1062.0, 0.0, -3.0],
// 63
[ 2, 0, 0, 0, 1, 2179.0, 0.0, -2.0, -1129.0, 0.0, -2.0],
[2, 0, 0, 0, 1, 2179.0, 0.0, -2.0, -1129.0, 0.0, -2.0],
// 64
[-1, 1, 0, 1, 0, 3276.0, 0.0, 1.0, -9.0, 0.0, 0.0],
[-1, 1, 0, 1, 0, 3276.0, 0.0, 1.0, -9.0, 0.0, 0.0],
// 65
[ 1, 1, 0, 0, 0, -3389.0, 0.0, 5.0, 35.0, 0.0, -2.0],
[1, 1, 0, 0, 0, -3389.0, 0.0, 5.0, 35.0, 0.0, -2.0],
// 66
[ 1, 0, 2, 0, 0, 3339.0, 0.0, -13.0, -107.0, 0.0, 1.0],
[1, 0, 2, 0, 0, 3339.0, 0.0, -13.0, -107.0, 0.0, 1.0],
// 67
[-1, 0, 2,-2, 1, -1987.0, 0.0, -6.0, 1073.0, 0.0, -2.0],
[-1, 0, 2, -2, 1, -1987.0, 0.0, -6.0, 1073.0, 0.0, -2.0],
// 68
[ 1, 0, 0, 0, 2, -1981.0, 0.0, 0.0, 854.0, 0.0, 0.0],
[1, 0, 0, 0, 2, -1981.0, 0.0, 0.0, 854.0, 0.0, 0.0],
// 69
[-1, 0, 0, 1, 0, 4026.0, 0.0, -353.0, -553.0, 0.0, -139.0],
[-1, 0, 0, 1, 0, 4026.0, 0.0, -353.0, -553.0, 0.0, -139.0],
// 70
[ 0, 0, 2, 1, 2, 1660.0, 0.0, -5.0, -710.0, 0.0, -2.0],
[0, 0, 2, 1, 2, 1660.0, 0.0, -5.0, -710.0, 0.0, -2.0],
// 71
[-1, 0, 2, 4, 2, -1521.0, 0.0, 9.0, 647.0, 0.0, 4.0],
[-1, 0, 2, 4, 2, -1521.0, 0.0, 9.0, 647.0, 0.0, 4.0],
// 72
[-1, 1, 0, 1, 1, 1464.0, 0.0, -11.0, -527.0, 0.0, -1.0],
[-1, 1, 0, 1, 1, 1464.0, 0.0, -11.0, -527.0, 0.0, -1.0],
// 73
[ 0,-2, 2,-2, 1, -1389.0, 0.0, 3.0, 656.0, 0.0, 1.0],
[0, -2, 2, -2, 1, -1389.0, 0.0, 3.0, 656.0, 0.0, 1.0],
// 74
[ 1,-1, 2, 2, 2, -1377.0, 0.0, 8.0, 594.0, 0.0, 4.0],
[1, -1, 2, 2, 2, -1377.0, 0.0, 8.0, 594.0, 0.0, 4.0],
// 75
[ 3, 0, 2,-2, 2, 1371.0, 0.0, -2.0, -588.0, 0.0, -1.0],
[3, 0, 2, -2, 2, 1371.0, 0.0, -2.0, -588.0, 0.0, -1.0],
// 76
[ 0, 0, 4,-2, 4, 1341.0, 0.0, 0.0, -577.0, 0.0, 0.0],
[0, 0, 4, -2, 4, 1341.0, 0.0, 0.0, -577.0, 0.0, 0.0],
// 77
[ 0, 0, 2,-2, 4, -1316.0, 0.0, 0.0, 567.0, 0.0, 0.0],
[0, 0, 2, -2, 4, -1316.0, 0.0, 0.0, 567.0, 0.0, 0.0],
]
// ─── Fundamental arguments (Delaunay) ────────────────────────────────────────
@ -224,35 +222,35 @@ function arcsecToRad(arcsec: number): number {
/** Mean anomaly of the Moon l (IAU 2003) */
function fundamentalL(T: number): number {
return arcsecToRad(
485868.249036 + T * (1717915923.2178 + T * (31.8792 + T * (0.051635 + T * (-0.00024470))))
485868.249036 + T * (1717915923.2178 + T * (31.8792 + T * (0.051635 + T * -0.0002447))),
)
}
/** Mean anomaly of the Sun l' (IAU 2003) */
function fundamentalLp(T: number): number {
return arcsecToRad(
1287104.793048 + T * (129596581.0481 + T * (-0.5532 + T * (0.000136 + T * (-0.00001149))))
1287104.793048 + T * (129596581.0481 + T * (-0.5532 + T * (0.000136 + T * -0.00001149))),
)
}
/** Moon's argument of latitude F = L - Ω (IAU 2003) */
function fundamentalF(T: number): number {
return arcsecToRad(
335779.526232 + T * (1739527262.8478 + T * (-12.7512 + T * (-0.001037 + T * 0.00000417)))
335779.526232 + T * (1739527262.8478 + T * (-12.7512 + T * (-0.001037 + T * 0.00000417))),
)
}
/** Mean elongation of the Moon D (IAU 2003) */
function fundamentalD(T: number): number {
return arcsecToRad(
1072260.703692 + T * (1602961601.2090 + T * (-6.3706 + T * (0.006593 + T * (-0.00003169))))
1072260.703692 + T * (1602961601.209 + T * (-6.3706 + T * (0.006593 + T * -0.00003169))),
)
}
/** Longitude of Moon's ascending node Ω (IAU 2003) */
function fundamentalOm(T: number): number {
return arcsecToRad(
450160.398036 + T * (-6962890.5431 + T * (7.4722 + T * (0.007702 + T * (-0.00005939))))
450160.398036 + T * (-6962890.5431 + T * (7.4722 + T * (0.007702 + T * -0.00005939))),
)
}
@ -274,24 +272,21 @@ function fundamentalOm(T: number): number {
* @param jdTT - Julian Date in TT
* @returns { X, Y, s } in radians
*/
export function computeCIPXYs(
jdTT: number,
): { X: number; Y: number; s: number } {
export function computeCIPXYs(jdTT: number): { X: number; Y: number; s: number } {
const T = (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY
// Delaunay fundamental arguments
const l = fundamentalL(T)
const l = fundamentalL(T)
const lp = fundamentalLp(T)
const F = fundamentalF(T)
const D = fundamentalD(T)
const F = fundamentalF(T)
const D = fundamentalD(T)
const Om = fundamentalOm(T)
// Accumulate nutation in longitude (dpsi) and obliquity (deps) — units: 0.1 uas
let dpsi = 0.0
let deps = 0.0
for (const [nl, nlp, nF, nD, nOm, ps, pst, pc, ec, ect, es] of NUT_2000B) {
const arg = nl*l + nlp*lp + nF*F + nD*D + nOm*Om
const arg = nl * l + nlp * lp + nF * F + nD * D + nOm * Om
const sinA = Math.sin(arg)
const cosA = Math.cos(arg)
dpsi += (ps + pst * T) * sinA + pc * cosA
@ -304,34 +299,24 @@ export function computeCIPXYs(
// Mean obliquity eps0 (IAU 2006, arcseconds → radians)
// Reference: IERS Conventions (2010) Table 5.1
const eps0 = (
84381.406
+ T * (-46.836769
+ T * (-0.0001831
+ T * ( 0.00200340
+ T * (-0.000000576
+ T * (-0.0000000434)))))
) * ARCSEC_RAD
const eps0 =
(84381.406 +
T *
(-46.836769 +
T * (-0.0001831 + T * (0.0020034 + T * (-0.000000576 + T * -0.0000000434))))) *
ARCSEC_RAD
// IAU 2006 precession polynomial for X (arcseconds)
// Reference: IERS Conventions (2010) Table 5.2a, polynomial s_X
const Xarcsec =
-0.016617
+ T * ( 2004.191898
+ T * ( -0.4297829
+ T * ( -0.19861834
+ T * ( 0.000007578
+ T * 0.0000059285))))
-0.016617 +
T * (2004.191898 + T * (-0.4297829 + T * (-0.19861834 + T * (0.000007578 + T * 0.0000059285))))
// IAU 2006 precession polynomial for Y (arcseconds)
// Reference: IERS Conventions (2010) Table 5.2a, polynomial s_Y
const Yarcsec =
-0.006951
+ T * ( -0.025896
+ T * ( -22.4072747
+ T * ( 0.00190059
+ T * ( 0.001112526
+ T * 0.0000001358))))
-0.006951 +
T * (-0.025896 + T * (-22.4072747 + T * (0.00190059 + T * (0.001112526 + T * 0.0000001358))))
// CIP X, Y: precession polynomial + first-order nutation correction
const X = Xarcsec * ARCSEC_RAD + dpsiRad * Math.sin(eps0)
@ -340,7 +325,7 @@ export function computeCIPXYs(
// CIO locator s ≈ -X·Y/2 + small polynomial (IERS Conventions 2010 Eq. 5.9)
// Polynomial term: s_poly ≈ -0.041775"·T (arcseconds)
const sPoly = -0.041775 * T * ARCSEC_RAD
const s = -X * Y / 2 + sPoly
const s = (-X * Y) / 2 + sPoly
return { X, Y, s }
}
@ -361,7 +346,7 @@ export function computeCIPXYs(
*/
export function computeERA(jdUT1: number): number {
const Du = jdUT1 - 2451545.0
const era = 2 * Math.PI * (0.7790572732640 + 1.00273781191135448 * Du)
const era = 2 * Math.PI * (0.779057273264 + 1.0027378119113546 * Du)
return ((era % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
}
@ -422,12 +407,7 @@ export function polarMotionMatrix(xp: number, yp: number): Mat3 {
* @param yp - Polar motion y (radians, default 0)
* @returns Vector in ITRS frame (km)
*/
export function gcrsToItrs(
gcrsVec: Vec3,
ts: TimeScales,
xp = 0,
yp = 0,
): Vec3 {
export function gcrsToItrs(gcrsVec: Vec3, ts: TimeScales, xp = 0, yp = 0): Vec3 {
const { X, Y, s } = computeCIPXYs(ts.jdTT)
const Q = celestialMotionMatrix(X, Y, s)
const era = computeERA(ts.jdUT1)
@ -449,12 +429,7 @@ export function gcrsToItrs(
* @param yp - Polar motion y (radians, default 0)
* @returns Vector in GCRS frame (km)
*/
export function itrsToGcrs(
itrsVec: Vec3,
ts: TimeScales,
xp = 0,
yp = 0,
): Vec3 {
export function itrsToGcrs(itrsVec: Vec3, ts: TimeScales, xp = 0, yp = 0): Vec3 {
const { X, Y, s } = computeCIPXYs(ts.jdTT)
const Q = celestialMotionMatrix(X, Y, s)
const era = computeERA(ts.jdUT1)

View file

@ -36,11 +36,7 @@ export function vnorm(a: Vec3): number {
/** Cross product */
export function vcross(a: Vec3, b: Vec3): Vec3 {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/** Unit vector (normalized) */
@ -59,11 +55,7 @@ export function angularSep(a: Vec3, b: Vec3): number {
// ─── 3×3 matrix operations ────────────────────────────────────────────────────
/** 3×3 matrix stored row-major as a 9-element tuple */
export type Mat3 = [
number, number, number,
number, number, number,
number, number, number,
]
export type Mat3 = [number, number, number, number, number, number, number, number, number]
/** Multiply 3×3 matrix by 3-vector */
export function mvmul(m: Mat3, v: Vec3): Vec3 {
@ -77,15 +69,15 @@ export function mvmul(m: Mat3, v: Vec3): Vec3 {
/** Multiply two 3×3 matrices */
export function mmmul(a: Mat3, b: Mat3): Mat3 {
return [
a[0]*b[0] + a[1]*b[3] + a[2]*b[6],
a[0]*b[1] + a[1]*b[4] + a[2]*b[7],
a[0]*b[2] + a[1]*b[5] + a[2]*b[8],
a[3]*b[0] + a[4]*b[3] + a[5]*b[6],
a[3]*b[1] + a[4]*b[4] + a[5]*b[7],
a[3]*b[2] + a[4]*b[5] + a[5]*b[8],
a[6]*b[0] + a[7]*b[3] + a[8]*b[6],
a[6]*b[1] + a[7]*b[4] + a[8]*b[7],
a[6]*b[2] + a[7]*b[5] + a[8]*b[8],
a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
]
}
@ -173,14 +165,18 @@ export function chebyshevEvalWithDerivative(
if (n === 1) return [coeffs[0], 0]
const x2 = 2 * x
let b2 = 0; let b1 = 0
let db2 = 0; let db1 = 0
let b2 = 0
let b1 = 0
let db2 = 0
let db1 = 0
for (let k = n - 1; k >= 1; k--) {
const b0 = coeffs[k] + x2 * b1 - b2
const db0 = 2 * b1 + x2 * db1 - db2
b2 = b1; b1 = b0
db2 = db1; db1 = db0
b2 = b1
b1 = b0
db2 = db1
db1 = db0
}
const value = coeffs[0] + x * b1 - b2
@ -227,7 +223,7 @@ export function brentRoot(
let c = a
let fc = fa
let mflag = true
let s = 0
let s: number
let d = 0
for (let i = 0; i < maxIter; i++) {
@ -289,12 +285,7 @@ export function brentRoot(
* @param steps - Number of initial subdivision steps (default 48 for 30-min resolution over a day)
* @returns Array of root locations
*/
export function findRoots(
f: (t: number) => number,
a: number,
b: number,
steps = 48,
): number[] {
export function findRoots(f: (t: number) => number, a: number, b: number, steps = 48): number[] {
const dt = (b - a) / steps
const roots: number[] = []
let tPrev = a

View file

@ -21,6 +21,7 @@
import type { Vec3, Observer, AzAlt, TimeScales } from '../types.js'
import { WGS84 } from '../types.js'
import { gcrsToItrs } from '../frames/index.js'
import { vdot } from '../math/index.js'
// ─── Geodetic ↔ ECEF ─────────────────────────────────────────────────────────
@ -93,12 +94,14 @@ export function ecefToGeodetic(ecef: Vec3): { lat: number; lon: number; h: numbe
export function computeENUBasis(lat: number, lon: number): { east: Vec3; north: Vec3; up: Vec3 } {
const phi = (lat * Math.PI) / 180
const lam = (lon * Math.PI) / 180
const sinPhi = Math.sin(phi), cosPhi = Math.cos(phi)
const sinLam = Math.sin(lam), cosLam = Math.cos(lam)
const sinPhi = Math.sin(phi),
cosPhi = Math.cos(phi)
const sinLam = Math.sin(lam),
cosLam = Math.cos(lam)
const east: Vec3 = [-sinLam, cosLam, 0]
const north: Vec3 = [-sinPhi * cosLam, -sinPhi * sinLam, cosPhi]
const up: Vec3 = [ cosPhi * cosLam, cosPhi * sinLam, sinPhi]
const up: Vec3 = [cosPhi * cosLam, cosPhi * sinLam, sinPhi]
return { east, north, up }
}
@ -114,8 +117,7 @@ export function computeENUBasis(lat: number, lon: number): { east: Vec3; north:
*/
export function ecefToENU(ecefDelta: Vec3, lat: number, lon: number): Vec3 {
const { east, north, up } = computeENUBasis(lat, lon)
const dot = (a: Vec3, b: Vec3) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
return [dot(ecefDelta, east), dot(ecefDelta, north), dot(ecefDelta, up)]
return [vdot(ecefDelta, east), vdot(ecefDelta, north), vdot(ecefDelta, up)]
}
/**
@ -185,7 +187,6 @@ export function computeAzAlt(
ts: TimeScales,
airless: boolean,
): AzAlt {
// 1. Convert body position from GCRS to ITRS (km)
const bodyITRS = gcrsToItrs(bodyGCRS, ts)
@ -194,11 +195,7 @@ export function computeAzAlt(
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000]
// 3. Displacement vector from observer to body in ITRS (km — magnitude doesn't matter)
const delta: Vec3 = [
bodyITRS[0] - obsITRS[0],
bodyITRS[1] - obsITRS[1],
bodyITRS[2] - obsITRS[2],
]
const delta: Vec3 = [bodyITRS[0] - obsITRS[0], bodyITRS[1] - obsITRS[1], bodyITRS[2] - obsITRS[2]]
// 4. Project onto local ENU basis at the observer's location
const enu = ecefToENU(delta, observer.lat, observer.lon)
@ -208,11 +205,7 @@ export function computeAzAlt(
// 6. Refraction correction
if (!airless) {
azAlt.altitude = applyRefraction(
azAlt.altitude,
observer.pressure,
observer.temperature,
)
azAlt.altitude = applyRefraction(azAlt.altitude, observer.pressure, observer.temperature)
}
return azAlt
@ -261,11 +254,7 @@ export function bennettRefraction(
* Apply refraction correction to an airless altitude.
* Returns the apparent (observed) altitude.
*/
export function applyRefraction(
airlessAlt: number,
pressure = 1013.25,
temperature = 15,
): number {
export function applyRefraction(airlessAlt: number, pressure = 1013.25, temperature = 15): number {
return airlessAlt + bennettRefraction(airlessAlt, pressure, temperature)
}

View file

@ -31,10 +31,10 @@ import { chebyshevEvalWithDerivative } from '../math/index.js'
/** NAIF integer body IDs used in DE442S segment chaining */
export const NAIF_IDS = {
SSB: 0, // Solar System Barycenter
SSB: 0, // Solar System Barycenter
MERCURY_BARYCENTER: 1,
VENUS_BARYCENTER: 2,
EMB: 3, // Earth-Moon Barycenter
EMB: 3, // Earth-Moon Barycenter
MARS_BARYCENTER: 4,
JUPITER_BARYCENTER: 5,
SATURN_BARYCENTER: 6,
@ -107,7 +107,7 @@ export class SpkKernel {
private findSeg(target: number, center: number, et: number): SpkSegment | null {
const candidates = this.index.get(`${target}:${center}`)
if (!candidates) return null
return candidates.find(s => et >= s.startET && et <= s.endET) ?? null
return candidates.find((s) => et >= s.startET && et <= s.endET) ?? null
}
private getChained(target: number, center: number, et: number): StateVector {
@ -174,7 +174,12 @@ export class SpkKernel {
// ─── DAF parsing ──────────────────────────────────────────────────────────────
function parseDafFileRecord(buffer: ArrayBuffer): {
nd: number; ni: number; fward: number; bward: number; free: number; le: boolean
nd: number
ni: number
fward: number
bward: number
free: number
le: boolean
} {
const dv = new DataView(buffer)
@ -214,7 +219,7 @@ function parseSummaryRecords(
const nextRecord = dv.getFloat64(recOffset, le)
const nSummaries = Math.round(dv.getFloat64(recOffset + 16, le))
let offset = recOffset + 24 // skip 3 control doubles (24 bytes)
let offset = recOffset + 24 // skip 3 control doubles (24 bytes)
for (let i = 0; i < nSummaries; i++) {
if (offset + summaryBytes > buffer.byteLength) break
@ -383,8 +388,18 @@ export function parseLsk(text: string): ReadonlyArray<readonly [number, number]>
const block = match[1]
const months: Record<string, number> = {
JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12,
JAN: 1,
FEB: 2,
MAR: 3,
APR: 4,
MAY: 5,
JUN: 6,
JUL: 7,
AUG: 8,
SEP: 9,
OCT: 10,
NOV: 11,
DEC: 12,
}
const pairRe = /(-?\d+(?:\.\d+)?)\s*,\s*@(\d{4})-([A-Z]{3})-(\d{1,2})/g
@ -400,8 +415,14 @@ export function parseLsk(text: string): ReadonlyArray<readonly [number, number]>
const a = Math.floor((14 - month) / 12)
const y = year + 4800 - a
const mo = month + 12 * a - 3
const jdNoon = day + Math.floor((153 * mo + 2) / 5) + 365 * y +
Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045
const jdNoon =
day +
Math.floor((153 * mo + 2) / 5) +
365 * y +
Math.floor(y / 4) -
Math.floor(y / 100) +
Math.floor(y / 400) -
32045
// Midnight = JD - 0.5
results.push([jdNoon - 0.5, deltaAT])
}

View file

@ -202,55 +202,74 @@ export function deltaTPolynomial(jdTT: number): number {
} else if (y < 500) {
const u = y / 100
return (
10583.6 - 1014.41 * u + 33.78311 * u * u - 5.952053 * u * u * u -
0.1798452 * u ** 4 + 0.022174192 * u ** 5 + 0.0090316521 * u ** 6
10583.6 -
1014.41 * u +
33.78311 * u * u -
5.952053 * u * u * u -
0.1798452 * u ** 4 +
0.022174192 * u ** 5 +
0.0090316521 * u ** 6
)
} else if (y < 1600) {
const u = (y - 1000) / 100
return (
1574.2 - 556.01 * u + 71.23472 * u * u + 0.319781 * u ** 3 -
0.8503463 * u ** 4 - 0.005050998 * u ** 5 + 0.0083572073 * u ** 6
1574.2 -
556.01 * u +
71.23472 * u * u +
0.319781 * u ** 3 -
0.8503463 * u ** 4 -
0.005050998 * u ** 5 +
0.0083572073 * u ** 6
)
} else if (y < 1700) {
const t = y - 1600
return 120 - 0.9808 * t - 0.01532 * t * t + t ** 3 / 7129
} else if (y < 1800) {
const t = y - 1700
return (
8.83 + 0.1603 * t - 0.0059285 * t * t + 0.00013336 * t ** 3 - t ** 4 / 1174000
)
return 8.83 + 0.1603 * t - 0.0059285 * t * t + 0.00013336 * t ** 3 - t ** 4 / 1174000
} else if (y < 1860) {
const t = y - 1800
return (
13.72 - 0.332447 * t + 0.0068612 * t * t + 0.0041116 * t ** 3 -
0.00037436 * t ** 4 + 0.0000121272 * t ** 5 -
0.0000001699 * t ** 6 + 0.000000000875 * t ** 7
13.72 -
0.332447 * t +
0.0068612 * t * t +
0.0041116 * t ** 3 -
0.00037436 * t ** 4 +
0.0000121272 * t ** 5 -
0.0000001699 * t ** 6 +
0.000000000875 * t ** 7
)
} else if (y < 1900) {
const t = y - 1860
return (
7.62 + 0.5737 * t - 0.251754 * t * t + 0.01680668 * t ** 3 -
0.0004473624 * t ** 4 + t ** 5 / 233174
7.62 +
0.5737 * t -
0.251754 * t * t +
0.01680668 * t ** 3 -
0.0004473624 * t ** 4 +
t ** 5 / 233174
)
} else if (y < 1920) {
const t = y - 1900
return (
-2.79 + 1.494119 * t - 0.0598939 * t * t + 0.0061966 * t ** 3 - 0.000197 * t ** 4
)
return -2.79 + 1.494119 * t - 0.0598939 * t * t + 0.0061966 * t ** 3 - 0.000197 * t ** 4
} else if (y < 1941) {
const t = y - 1920
return 21.20 + 0.84493 * t - 0.076100 * t * t + 0.0020936 * t ** 3
return 21.2 + 0.84493 * t - 0.0761 * t * t + 0.0020936 * t ** 3
} else if (y < 1961) {
const t = y - 1950
return 29.07 + 0.407 * t - t * t / 233 + t ** 3 / 2547
return 29.07 + 0.407 * t - (t * t) / 233 + t ** 3 / 2547
} else if (y < 1986) {
const t = y - 1975
return 45.45 + 1.067 * t - t * t / 260 - t ** 3 / 718
return 45.45 + 1.067 * t - (t * t) / 260 - t ** 3 / 718
} else if (y < 2005) {
const t = y - 2000
return (
63.86 + 0.3345 * t - 0.060374 * t * t + 0.0017275 * t ** 3 +
0.000651814 * t ** 4 + 0.00002373599 * t ** 5
63.86 +
0.3345 * t -
0.060374 * t * t +
0.0017275 * t ** 3 +
0.000651814 * t ** 4 +
0.00002373599 * t ** 5
)
} else if (y < 2050) {
const t = y - 2000

View file

@ -5,8 +5,8 @@ export type Vec3 = [number, number, number]
/** Position + velocity state vector from the ephemeris */
export interface StateVector {
position: Vec3 // km, in the frame determined by context
velocity: Vec3 // km/s
position: Vec3 // km, in the frame determined by context
velocity: Vec3 // km/s
}
/** Azimuth + altitude in degrees */
@ -154,7 +154,7 @@ export type YallopCategory = 'A' | 'B' | 'C' | 'D' | 'E' | 'F'
export const YALLOP_THRESHOLDS = {
A: 0.216,
B: -0.014,
C: -0.160,
C: -0.16,
D: -0.232,
E: -0.293,
} as const
@ -199,7 +199,7 @@ export type OdehZone = 'A' | 'B' | 'C' | 'D'
*/
export const ODEH_THRESHOLDS = {
A: 5.65,
B: 2.00,
B: 2.0,
C: -0.96,
} as const
@ -390,7 +390,7 @@ export type KernelSource =
| { type: 'file'; path: string }
| { type: 'buffer'; data: ArrayBuffer; name: string }
| { type: 'url'; url: string }
| { type: 'auto' } // auto-download from NAIF, cache in ~/.cache/moon-sighting
| { type: 'auto' } // auto-download from NAIF, cache in ~/.cache/moon-sighting
export interface KernelConfig {
/** Planetary SPK kernel — defaults to de442s.bsp via auto-download */
@ -434,9 +434,13 @@ export const WGS84 = {
/** Flattening */
f: 1 / 298.257223563,
/** Semi-minor axis in meters */
get b() { return this.a * (1 - this.f) },
get b() {
return this.a * (1 - this.f)
},
/** First eccentricity squared */
get e2() { return 2 * this.f - this.f * this.f },
get e2() {
return 2 * this.f - this.f * this.f
},
} as const
// ─── Internal ephemeris types ─────────────────────────────────────────────────

View file

@ -238,7 +238,10 @@ export function buildGuidanceText(
lagMinutes: number,
): string {
const direction = azimuthToCardinal(moonAz)
const timeStr = bestTimeUTC.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')
const timeStr = bestTimeUTC
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, ' UTC')
const lagStr = `${Math.round(lagMinutes)} min after sunset`
let visibility: string
@ -263,8 +266,24 @@ export function buildGuidanceText(
/** Convert azimuth degrees to a cardinal/intercardinal direction label */
function azimuthToCardinal(az: number): string {
const dirs = ['North', 'NNE', 'NE', 'ENE', 'East', 'ESE', 'SE', 'SSE',
'South', 'SSW', 'SW', 'WSW', 'West', 'WNW', 'NW', 'NNW']
const dirs = [
'North',
'NNE',
'NE',
'ENE',
'East',
'ESE',
'SE',
'SSE',
'South',
'SSW',
'SW',
'WSW',
'West',
'WNW',
'NW',
'NNW',
]
const idx = Math.round(az / 22.5) % 16
return dirs[(idx + 16) % 16]
}

View file

@ -2,10 +2,11 @@
/**
* moon-sighting CJS test suite
* Runs with: node test-cjs.cjs
* Runs with: node --test test-cjs.cjs
* Verifies the CommonJS build is functional.
*/
const { describe, it } = require('node:test')
const assert = require('node:assert/strict')
const {
YALLOP_THRESHOLDS,
@ -25,142 +26,122 @@ const {
getSunMoonEvents,
} = require('./dist/index.cjs')
let passed = 0
let failed = 0
function test(name, fn) {
try {
fn()
console.log(` [${name}]... PASS`)
passed++
} catch (err) {
console.error(` [${name}]... FAIL: ${err.message}`)
failed++
}
}
console.log('CJS compatibility:')
test('require() works', () => {
assert.ok(YALLOP_THRESHOLDS !== undefined)
})
test('YALLOP_THRESHOLDS.A is 0.216', () => {
assert.equal(YALLOP_THRESHOLDS.A, 0.216)
})
test('ODEH_THRESHOLDS.A is 5.65', () => {
assert.equal(ODEH_THRESHOLDS.A, 5.65)
})
test('WGS84.a is 6378137.0', () => {
assert.equal(WGS84.a, 6378137.0)
})
test('All API functions are exported', () => {
assert.equal(typeof getMoonPhase, 'function')
assert.equal(typeof getMoonPosition, 'function')
assert.equal(typeof getMoonIllumination, 'function')
assert.equal(typeof getMoonVisibilityEstimate, 'function')
assert.equal(typeof getMoon, 'function')
assert.equal(typeof initKernels, 'function')
assert.equal(typeof downloadKernels, 'function')
assert.equal(typeof verifyKernels, 'function')
assert.equal(typeof getMoonSightingReport, 'function')
assert.equal(typeof getSunMoonEvents, 'function')
describe('CJS compatibility', () => {
it('require() works', () => {
assert.ok(YALLOP_THRESHOLDS !== undefined)
})
it('YALLOP_THRESHOLDS.A is 0.216', () => {
assert.equal(YALLOP_THRESHOLDS.A, 0.216)
})
it('ODEH_THRESHOLDS.A is 5.65', () => {
assert.equal(ODEH_THRESHOLDS.A, 5.65)
})
it('WGS84.a is 6378137.0', () => {
assert.equal(WGS84.a, 6378137.0)
})
it('All API functions are exported', () => {
assert.equal(typeof getMoonPhase, 'function')
assert.equal(typeof getMoonPosition, 'function')
assert.equal(typeof getMoonIllumination, 'function')
assert.equal(typeof getMoonVisibilityEstimate, 'function')
assert.equal(typeof getMoon, 'function')
assert.equal(typeof initKernels, 'function')
assert.equal(typeof downloadKernels, 'function')
assert.equal(typeof verifyKernels, 'function')
assert.equal(typeof getMoonSightingReport, 'function')
assert.equal(typeof getSunMoonEvents, 'function')
})
})
console.log('\nCJS getMoonPhase:')
test('getMoonPhase returns valid phase', () => {
const valid = new Set([
'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous',
'full-moon', 'waning-gibbous', 'last-quarter', 'waning-crescent',
])
const p = getMoonPhase(new Date('2025-03-14T12:00:00Z'))
assert.ok(valid.has(p.phase), `got: ${p.phase}`)
})
test('getMoonPhase illumination in [0, 100]', () => {
const p = getMoonPhase(new Date('2025-03-01T12:00:00Z'))
assert.ok(p.illumination >= 0 && p.illumination <= 100)
})
test('getMoonPhase near full moon has high illumination', () => {
const p = getMoonPhase(new Date('2025-03-14T12:00:00Z'))
assert.ok(p.illumination > 85, `illumination=${p.illumination.toFixed(1)}%`)
})
test('getMoonPhase Dates are Date objects', () => {
const p = getMoonPhase(new Date('2025-03-01T12:00:00Z'))
assert.ok(p.nextNewMoon instanceof Date)
assert.ok(p.prevNewMoon instanceof Date)
assert.ok(p.nextFullMoon instanceof Date)
describe('CJS getMoonPhase', () => {
it('returns valid phase', () => {
const valid = new Set([
'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous',
'full-moon', 'waning-gibbous', 'last-quarter', 'waning-crescent',
])
const p = getMoonPhase(new Date('2025-03-14T12:00:00Z'))
assert.ok(valid.has(p.phase), `got: ${p.phase}`)
})
it('illumination in [0, 100]', () => {
const p = getMoonPhase(new Date('2025-03-01T12:00:00Z'))
assert.ok(p.illumination >= 0 && p.illumination <= 100)
})
it('near full moon has high illumination', () => {
const p = getMoonPhase(new Date('2025-03-14T12:00:00Z'))
assert.ok(p.illumination > 85, `illumination=${p.illumination.toFixed(1)}%`)
})
it('Dates are Date objects', () => {
const p = getMoonPhase(new Date('2025-03-01T12:00:00Z'))
assert.ok(p.nextNewMoon instanceof Date)
assert.ok(p.prevNewMoon instanceof Date)
assert.ok(p.nextFullMoon instanceof Date)
})
})
console.log('\nCJS getMoonPosition + getMoonIllumination:')
test('getMoonPosition returns valid azimuth/altitude', () => {
const pos = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10)
assert.ok(pos.azimuth >= 0 && pos.azimuth < 360, `azimuth=${pos.azimuth}`)
assert.ok(pos.altitude >= -90 && pos.altitude <= 90, `altitude=${pos.altitude}`)
assert.ok(pos.distance > 356000 && pos.distance < 407000, `distance=${pos.distance}`)
assert.ok(isFinite(pos.parallacticAngle))
})
test('getMoonIllumination near full moon: fraction > 0.85', () => {
const illum = getMoonIllumination(new Date('2025-03-14T12:00:00Z'))
assert.ok(illum.fraction > 0.85, `fraction=${illum.fraction.toFixed(3)}`)
assert.ok(illum.phase > 0.4 && illum.phase < 0.6, `phase=${illum.phase.toFixed(3)}`)
assert.ok(isFinite(illum.angle))
})
test('getMoonIllumination waxing: isWaxing = true', () => {
const illum = getMoonIllumination(new Date('2025-03-05T12:00:00Z'))
assert.equal(illum.isWaxing, true)
describe('CJS getMoonPosition and getMoonIllumination', () => {
it('getMoonPosition returns valid azimuth/altitude', () => {
const pos = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10)
assert.ok(pos.azimuth >= 0 && pos.azimuth < 360, `azimuth=${pos.azimuth}`)
assert.ok(pos.altitude >= -90 && pos.altitude <= 90, `altitude=${pos.altitude}`)
assert.ok(pos.distance > 356000 && pos.distance < 407000, `distance=${pos.distance}`)
assert.ok(isFinite(pos.parallacticAngle))
})
it('getMoonIllumination near full moon: fraction > 0.85', () => {
const illum = getMoonIllumination(new Date('2025-03-14T12:00:00Z'))
assert.ok(illum.fraction > 0.85, `fraction=${illum.fraction.toFixed(3)}`)
assert.ok(illum.phase > 0.4 && illum.phase < 0.6, `phase=${illum.phase.toFixed(3)}`)
assert.ok(isFinite(illum.angle))
})
it('getMoonIllumination waxing: isWaxing = true', () => {
const illum = getMoonIllumination(new Date('2025-03-05T12:00:00Z'))
assert.equal(illum.isWaxing, true)
})
})
console.log('\nCJS getMoonPhase phaseName/phaseSymbol:')
test('getMoonPhase.phaseName is a non-empty string', () => {
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
assert.ok(typeof p.phaseName === 'string' && p.phaseName.length > 0)
})
test('getMoonPhase.phaseSymbol is a moon emoji', () => {
const SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'])
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
assert.ok(SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
})
test('Waxing crescent: phaseName = "Waxing Crescent", phaseSymbol = "🌒"', () => {
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
assert.equal(p.phaseName, 'Waxing Crescent')
assert.equal(p.phaseSymbol, '🌒')
describe('CJS getMoonPhase phaseName/phaseSymbol', () => {
it('phaseName is a non-empty string', () => {
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
assert.ok(typeof p.phaseName === 'string' && p.phaseName.length > 0)
})
it('phaseSymbol is a moon emoji', () => {
const SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'])
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
assert.ok(SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
})
it('Waxing crescent: correct phaseName and phaseSymbol', () => {
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
assert.equal(p.phaseName, 'Waxing Crescent')
assert.equal(p.phaseSymbol, '🌒')
})
})
console.log('\nCJS getMoonVisibilityEstimate:')
test('getMoonVisibilityEstimate returns valid zone', () => {
const v = getMoonVisibilityEstimate(new Date('2025-03-02T18:30:00Z'), 51.5074, -0.1278, 10)
assert.ok(['A', 'B', 'C', 'D'].includes(v.zone), `zone=${v.zone}`)
assert.ok(isFinite(v.V))
assert.equal(v.isApproximate, true)
})
test('getMoonVisibilityEstimate near new moon: zone C or D', () => {
const v = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262)
assert.ok(['C', 'D'].includes(v.zone), `zone=${v.zone}`)
describe('CJS getMoonVisibilityEstimate', () => {
it('returns valid zone', () => {
const v = getMoonVisibilityEstimate(new Date('2025-03-02T18:30:00Z'), 51.5074, -0.1278, 10)
assert.ok(['A', 'B', 'C', 'D'].includes(v.zone), `zone=${v.zone}`)
assert.ok(isFinite(v.V))
assert.equal(v.isApproximate, true)
})
it('near new moon: zone C or D', () => {
const v = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262)
assert.ok(['C', 'D'].includes(v.zone), `zone=${v.zone}`)
})
})
console.log('\nCJS getMoon:')
test('getMoon returns all four sub-results', () => {
const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10)
assert.ok(typeof m.phase === 'object')
assert.ok(typeof m.position === 'object')
assert.ok(typeof m.illumination === 'object')
assert.ok(typeof m.visibility === 'object')
describe('CJS getMoon', () => {
it('returns all four sub-results', () => {
const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10)
assert.ok(typeof m.phase === 'object')
assert.ok(typeof m.position === 'object')
assert.ok(typeof m.illumination === 'object')
assert.ok(typeof m.visibility === 'object')
})
it('phase.phaseName is non-empty', () => {
const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278)
assert.ok(typeof m.phase.phaseName === 'string' && m.phase.phaseName.length > 0)
})
it('visibility.isApproximate is true', () => {
const m = getMoon(new Date(), 51.5074, -0.1278)
assert.equal(m.visibility.isApproximate, true)
})
})
test('getMoon.phase.phaseName is non-empty', () => {
const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278)
assert.ok(typeof m.phase.phaseName === 'string' && m.phase.phaseName.length > 0)
})
test('getMoon.visibility.isApproximate is true', () => {
const m = getMoon(new Date(), 51.5074, -0.1278)
assert.equal(m.visibility.isApproximate, true)
})
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`)
if (failed > 0) {
process.exit(1)
}

713
test.mjs
View file

@ -1,9 +1,9 @@
/**
* moon-sighting ESM test suite
* Runs with: node test.mjs
* All tests use plain assert no test framework.
* Runs with: node --test test.mjs
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
@ -26,429 +26,392 @@ import {
getSunMoonEvents,
} from './dist/index.mjs'
let passed = 0
let failed = 0
function test(name, fn) {
try {
fn()
console.log(` [${name}]... PASS`)
passed++
} catch (err) {
console.error(` [${name}]... FAIL: ${err.message}`)
failed++
}
}
// ─── Constants ────────────────────────────────────────────────────────────────
console.log('Constants:')
test('YALLOP_THRESHOLDS.A is 0.216', () => {
assert.equal(YALLOP_THRESHOLDS.A, 0.216)
})
test('YALLOP_THRESHOLDS.E is -0.293', () => {
assert.equal(YALLOP_THRESHOLDS.E, -0.293)
})
test('All Yallop thresholds are defined', () => {
for (const key of ['A', 'B', 'C', 'D', 'E']) {
assert.ok(typeof YALLOP_THRESHOLDS[key] === 'number', `${key} should be a number`)
}
})
test('Yallop thresholds descend A > B > C > D > E', () => {
assert.ok(YALLOP_THRESHOLDS.A > YALLOP_THRESHOLDS.B)
assert.ok(YALLOP_THRESHOLDS.B > YALLOP_THRESHOLDS.C)
assert.ok(YALLOP_THRESHOLDS.C > YALLOP_THRESHOLDS.D)
assert.ok(YALLOP_THRESHOLDS.D > YALLOP_THRESHOLDS.E)
})
test('ODEH_THRESHOLDS.A is 5.65', () => {
assert.equal(ODEH_THRESHOLDS.A, 5.65)
})
test('ODEH_THRESHOLDS.C is -0.96', () => {
assert.equal(ODEH_THRESHOLDS.C, -0.96)
})
test('Odeh thresholds descend A > B > C', () => {
assert.ok(ODEH_THRESHOLDS.A > ODEH_THRESHOLDS.B)
assert.ok(ODEH_THRESHOLDS.B > ODEH_THRESHOLDS.C)
})
test('WGS84.a is 6378137.0', () => {
assert.equal(WGS84.a, 6378137.0)
})
test('WGS84.invF is 298.257223563', () => {
assert.equal(WGS84.invF, 298.257223563)
})
test('WGS84.e2 is positive and < 1', () => {
assert.ok(WGS84.e2 > 0 && WGS84.e2 < 1, `e2=${WGS84.e2}`)
})
test('WGS84.b < WGS84.a (oblate spheroid)', () => {
assert.ok(WGS84.b < WGS84.a)
})
test('Yallop descriptions are non-empty strings', () => {
for (const cat of ['A', 'B', 'C', 'D', 'E', 'F']) {
assert.ok(typeof YALLOP_DESCRIPTIONS[cat] === 'string' && YALLOP_DESCRIPTIONS[cat].length > 0)
}
})
test('Odeh descriptions are non-empty strings', () => {
for (const zone of ['A', 'B', 'C', 'D']) {
assert.ok(typeof ODEH_DESCRIPTIONS[zone] === 'string' && ODEH_DESCRIPTIONS[zone].length > 0)
}
describe('Constants', () => {
it('YALLOP_THRESHOLDS.A is 0.216', () => {
assert.equal(YALLOP_THRESHOLDS.A, 0.216)
})
it('YALLOP_THRESHOLDS.E is -0.293', () => {
assert.equal(YALLOP_THRESHOLDS.E, -0.293)
})
it('All Yallop thresholds are defined', () => {
for (const key of ['A', 'B', 'C', 'D', 'E']) {
assert.ok(typeof YALLOP_THRESHOLDS[key] === 'number', `${key} should be a number`)
}
})
it('Yallop thresholds descend A > B > C > D > E', () => {
assert.ok(YALLOP_THRESHOLDS.A > YALLOP_THRESHOLDS.B)
assert.ok(YALLOP_THRESHOLDS.B > YALLOP_THRESHOLDS.C)
assert.ok(YALLOP_THRESHOLDS.C > YALLOP_THRESHOLDS.D)
assert.ok(YALLOP_THRESHOLDS.D > YALLOP_THRESHOLDS.E)
})
it('ODEH_THRESHOLDS.A is 5.65', () => {
assert.equal(ODEH_THRESHOLDS.A, 5.65)
})
it('ODEH_THRESHOLDS.C is -0.96', () => {
assert.equal(ODEH_THRESHOLDS.C, -0.96)
})
it('Odeh thresholds descend A > B > C', () => {
assert.ok(ODEH_THRESHOLDS.A > ODEH_THRESHOLDS.B)
assert.ok(ODEH_THRESHOLDS.B > ODEH_THRESHOLDS.C)
})
it('WGS84.a is 6378137.0', () => {
assert.equal(WGS84.a, 6378137.0)
})
it('WGS84.invF is 298.257223563', () => {
assert.equal(WGS84.invF, 298.257223563)
})
it('WGS84.e2 is positive and < 1', () => {
assert.ok(WGS84.e2 > 0 && WGS84.e2 < 1, `e2=${WGS84.e2}`)
})
it('WGS84.b < WGS84.a (oblate spheroid)', () => {
assert.ok(WGS84.b < WGS84.a)
})
it('Yallop descriptions are non-empty strings', () => {
for (const cat of ['A', 'B', 'C', 'D', 'E', 'F']) {
assert.ok(typeof YALLOP_DESCRIPTIONS[cat] === 'string' && YALLOP_DESCRIPTIONS[cat].length > 0)
}
})
it('Odeh descriptions are non-empty strings', () => {
for (const zone of ['A', 'B', 'C', 'D']) {
assert.ok(typeof ODEH_DESCRIPTIONS[zone] === 'string' && ODEH_DESCRIPTIONS[zone].length > 0)
}
})
})
// ─── API function exports ──────────────────────────────────────────────────────
console.log('\nAPI exports:')
test('getMoonPhase is a function', () => {
assert.equal(typeof getMoonPhase, 'function')
})
test('initKernels is a function', () => {
assert.equal(typeof initKernels, 'function')
})
test('downloadKernels is a function', () => {
assert.equal(typeof downloadKernels, 'function')
})
test('verifyKernels is a function', () => {
assert.equal(typeof verifyKernels, 'function')
})
test('getMoonSightingReport is a function', () => {
assert.equal(typeof getMoonSightingReport, 'function')
})
test('getSunMoonEvents is a function', () => {
assert.equal(typeof getSunMoonEvents, 'function')
describe('API exports', () => {
it('getMoonPhase is a function', () => {
assert.equal(typeof getMoonPhase, 'function')
})
it('initKernels is a function', () => {
assert.equal(typeof initKernels, 'function')
})
it('downloadKernels is a function', () => {
assert.equal(typeof downloadKernels, 'function')
})
it('verifyKernels is a function', () => {
assert.equal(typeof verifyKernels, 'function')
})
it('getMoonSightingReport is a function', () => {
assert.equal(typeof getMoonSightingReport, 'function')
})
it('getSunMoonEvents is a function', () => {
assert.equal(typeof getSunMoonEvents, 'function')
})
})
// ─── getMoonPhase (synchronous, no kernel) ─────────────────────────────────────
console.log('\ngetMoonPhase — structure:')
const VALID_PHASES = new Set([
'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous',
'full-moon', 'waning-gibbous', 'last-quarter', 'waning-crescent',
])
// Test with a known reference date: 2025-03-01 UTC
// At this date the Moon was a waxing crescent (~2 days after new moon Feb 28)
const DATE_MARCH_1_2025 = new Date('2025-03-01T12:00:00Z')
const phase_march1 = getMoonPhase(DATE_MARCH_1_2025)
test('getMoonPhase returns an object', () => {
assert.ok(phase_march1 !== null && typeof phase_march1 === 'object')
})
test('getMoonPhase.phase is a valid phase name', () => {
assert.ok(VALID_PHASES.has(phase_march1.phase), `got: ${phase_march1.phase}`)
})
test('getMoonPhase.illumination is in [0, 100]', () => {
assert.ok(phase_march1.illumination >= 0 && phase_march1.illumination <= 100,
`illumination=${phase_march1.illumination}`)
})
test('getMoonPhase.age is >= 0', () => {
assert.ok(phase_march1.age >= 0, `age=${phase_march1.age}`)
})
test('getMoonPhase.elongationDeg is in [0, 180]', () => {
assert.ok(phase_march1.elongationDeg >= 0 && phase_march1.elongationDeg <= 180,
`elongationDeg=${phase_march1.elongationDeg}`)
})
test('getMoonPhase.isWaxing is a boolean', () => {
assert.equal(typeof phase_march1.isWaxing, 'boolean')
})
test('getMoonPhase.nextNewMoon is a Date', () => {
assert.ok(phase_march1.nextNewMoon instanceof Date)
})
test('getMoonPhase.prevNewMoon is a Date', () => {
assert.ok(phase_march1.prevNewMoon instanceof Date)
})
test('getMoonPhase.nextFullMoon is a Date', () => {
assert.ok(phase_march1.nextFullMoon instanceof Date)
})
test('getMoonPhase.prevNewMoon is before reference date', () => {
assert.ok(phase_march1.prevNewMoon < DATE_MARCH_1_2025,
`prevNewMoon=${phase_march1.prevNewMoon.toISOString()}`)
})
test('getMoonPhase.nextNewMoon is after prevNewMoon', () => {
assert.ok(phase_march1.nextNewMoon > phase_march1.prevNewMoon)
describe('getMoonPhase structure', () => {
it('returns an object', () => {
assert.ok(phase_march1 !== null && typeof phase_march1 === 'object')
})
it('phase is a valid phase name', () => {
assert.ok(VALID_PHASES.has(phase_march1.phase), `got: ${phase_march1.phase}`)
})
it('illumination is in [0, 100]', () => {
assert.ok(phase_march1.illumination >= 0 && phase_march1.illumination <= 100,
`illumination=${phase_march1.illumination}`)
})
it('age is >= 0', () => {
assert.ok(phase_march1.age >= 0, `age=${phase_march1.age}`)
})
it('elongationDeg is in [0, 180]', () => {
assert.ok(phase_march1.elongationDeg >= 0 && phase_march1.elongationDeg <= 180,
`elongationDeg=${phase_march1.elongationDeg}`)
})
it('isWaxing is a boolean', () => {
assert.equal(typeof phase_march1.isWaxing, 'boolean')
})
it('nextNewMoon is a Date', () => {
assert.ok(phase_march1.nextNewMoon instanceof Date)
})
it('prevNewMoon is a Date', () => {
assert.ok(phase_march1.prevNewMoon instanceof Date)
})
it('nextFullMoon is a Date', () => {
assert.ok(phase_march1.nextFullMoon instanceof Date)
})
it('prevNewMoon is before reference date', () => {
assert.ok(phase_march1.prevNewMoon < DATE_MARCH_1_2025,
`prevNewMoon=${phase_march1.prevNewMoon.toISOString()}`)
})
it('nextNewMoon is after prevNewMoon', () => {
assert.ok(phase_march1.nextNewMoon > phase_march1.prevNewMoon)
})
})
console.log('\ngetMoonPhase — phase boundaries:')
describe('getMoonPhase phase boundaries', () => {
const DATE_FULL_MOON = new Date('2025-03-14T12:00:00Z')
const phase_full = getMoonPhase(DATE_FULL_MOON)
// 2025-03-14 was close to full moon (illumination should be high)
const DATE_FULL_MOON = new Date('2025-03-14T12:00:00Z')
const phase_full = getMoonPhase(DATE_FULL_MOON)
it('near full moon: illumination > 85%', () => {
assert.ok(phase_full.illumination > 85,
`illumination at full moon=${phase_full.illumination.toFixed(1)}%`)
})
it('near full moon: phase is full-moon or waxing/waning gibbous', () => {
const valid = new Set(['full-moon', 'waxing-gibbous', 'waning-gibbous'])
assert.ok(valid.has(phase_full.phase), `got: ${phase_full.phase}`)
})
it('near full moon: elongation > 120 deg', () => {
assert.ok(phase_full.elongationDeg > 120, `elongation=${phase_full.elongationDeg}`)
})
test('Near full moon: illumination > 85%', () => {
assert.ok(phase_full.illumination > 85,
`illumination at full moon=${phase_full.illumination.toFixed(1)}%`)
})
test('Near full moon: phase is full-moon or waxing/waning gibbous', () => {
const valid = new Set(['full-moon', 'waxing-gibbous', 'waning-gibbous'])
assert.ok(valid.has(phase_full.phase), `got: ${phase_full.phase}`)
})
test('Near full moon: elongation > 120°', () => {
assert.ok(phase_full.elongationDeg > 120, `elongation=${phase_full.elongationDeg}`)
const DATE_NEW_MOON = new Date('2025-03-29T12:00:00Z')
const phase_new = getMoonPhase(DATE_NEW_MOON)
it('near new moon: illumination < 10%', () => {
assert.ok(phase_new.illumination < 10,
`illumination at new moon=${phase_new.illumination.toFixed(1)}%`)
})
it('near new moon: elongation < 30 deg', () => {
assert.ok(phase_new.elongationDeg < 30, `elongation=${phase_new.elongationDeg}`)
})
})
// 2025-03-29 is close to new moon (illumination should be low)
const DATE_NEW_MOON = new Date('2025-03-29T12:00:00Z')
const phase_new = getMoonPhase(DATE_NEW_MOON)
describe('getMoonPhase consistency', () => {
const DATE_WAXING = new Date('2025-03-05T12:00:00Z')
const DATE_WANING = new Date('2025-03-20T12:00:00Z')
test('Near new moon: illumination < 10%', () => {
assert.ok(phase_new.illumination < 10,
`illumination at new moon=${phase_new.illumination.toFixed(1)}%`)
})
test('Near new moon: elongation < 30°', () => {
assert.ok(phase_new.elongationDeg < 30, `elongation=${phase_new.elongationDeg}`)
})
console.log('\ngetMoonPhase — consistency:')
// Two dates: one clearly waxing, one clearly waning
const DATE_WAXING = new Date('2025-03-05T12:00:00Z') // ~7 days after new moon
const DATE_WANING = new Date('2025-03-20T12:00:00Z') // ~6 days after full moon
const phase_waxing = getMoonPhase(DATE_WAXING)
const phase_waning = getMoonPhase(DATE_WANING)
test('5 days after new moon: isWaxing = true', () => {
assert.equal(phase_waxing.isWaxing, true)
})
test('6 days after full moon: isWaxing = false', () => {
assert.equal(phase_waning.isWaxing, false)
})
test('getMoonPhase with default date (now) returns valid result', () => {
const nowPhase = getMoonPhase()
assert.ok(VALID_PHASES.has(nowPhase.phase))
assert.ok(nowPhase.illumination >= 0 && nowPhase.illumination <= 100)
})
// Synodic month duration check: nextNewMoon - prevNewMoon ≈ 29.53 days
test('Synodic month duration is ~29.5 days (±0.5)', () => {
const synodicMs = phase_march1.nextNewMoon.getTime() - phase_march1.prevNewMoon.getTime()
const synodicDays = synodicMs / 86400000
assert.ok(
synodicDays > 29.0 && synodicDays < 30.1,
`synodic month=${synodicDays.toFixed(2)} days`,
)
it('5 days after new moon: isWaxing = true', () => {
assert.equal(getMoonPhase(DATE_WAXING).isWaxing, true)
})
it('6 days after full moon: isWaxing = false', () => {
assert.equal(getMoonPhase(DATE_WANING).isWaxing, false)
})
it('default date (now) returns valid result', () => {
const nowPhase = getMoonPhase()
assert.ok(VALID_PHASES.has(nowPhase.phase))
assert.ok(nowPhase.illumination >= 0 && nowPhase.illumination <= 100)
})
it('synodic month duration is ~29.5 days', () => {
const synodicMs = phase_march1.nextNewMoon.getTime() - phase_march1.prevNewMoon.getTime()
const synodicDays = synodicMs / 86400000
assert.ok(
synodicDays > 29.0 && synodicDays < 30.1,
`synodic month=${synodicDays.toFixed(2)} days`,
)
})
})
// ─── getMoonPosition ─────────────────────────────────────────────────────────
console.log('\ngetMoonPosition:')
describe('getMoonPosition', () => {
const moonPos_london = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10)
// London on 2025-03-14 at noon UTC — Moon should be above the horizon during daytime
const moonPos_london = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10)
test('getMoonPosition returns azimuth in [0, 360)', () => {
assert.ok(
moonPos_london.azimuth >= 0 && moonPos_london.azimuth < 360,
`azimuth=${moonPos_london.azimuth}`,
)
})
test('getMoonPosition returns altitude in [-90, 90]', () => {
assert.ok(
moonPos_london.altitude >= -90 && moonPos_london.altitude <= 90,
`altitude=${moonPos_london.altitude}`,
)
})
test('getMoonPosition returns distance in lunar orbit range [356000, 407000] km', () => {
assert.ok(
moonPos_london.distance >= 356000 && moonPos_london.distance <= 407000,
`distance=${moonPos_london.distance.toFixed(0)} km`,
)
})
test('getMoonPosition returns finite parallacticAngle', () => {
assert.ok(
isFinite(moonPos_london.parallacticAngle),
`parallacticAngle=${moonPos_london.parallacticAngle}`,
)
})
test('getMoonPosition default date (now) returns valid result', () => {
const pos = getMoonPosition(new Date(), 21.4225, 39.8262) // Mecca
assert.ok(pos.azimuth >= 0 && pos.azimuth < 360)
assert.ok(pos.altitude >= -90 && pos.altitude <= 90)
assert.ok(pos.distance > 350000 && pos.distance < 410000)
it('azimuth in [0, 360)', () => {
assert.ok(moonPos_london.azimuth >= 0 && moonPos_london.azimuth < 360,
`azimuth=${moonPos_london.azimuth}`)
})
it('altitude in [-90, 90]', () => {
assert.ok(moonPos_london.altitude >= -90 && moonPos_london.altitude <= 90,
`altitude=${moonPos_london.altitude}`)
})
it('distance in lunar orbit range [356000, 407000] km', () => {
assert.ok(moonPos_london.distance >= 356000 && moonPos_london.distance <= 407000,
`distance=${moonPos_london.distance.toFixed(0)} km`)
})
it('finite parallacticAngle', () => {
assert.ok(isFinite(moonPos_london.parallacticAngle),
`parallacticAngle=${moonPos_london.parallacticAngle}`)
})
it('default date (now) returns valid result', () => {
const pos = getMoonPosition(new Date(), 21.4225, 39.8262)
assert.ok(pos.azimuth >= 0 && pos.azimuth < 360)
assert.ok(pos.altitude >= -90 && pos.altitude <= 90)
assert.ok(pos.distance > 350000 && pos.distance < 410000)
})
})
// ─── getMoonIllumination ─────────────────────────────────────────────────────
console.log('\ngetMoonIllumination:')
describe('getMoonIllumination', () => {
const illum_full = getMoonIllumination(new Date('2025-03-14T12:00:00Z'))
const illum_new = getMoonIllumination(new Date('2025-03-29T12:00:00Z'))
const illum_waxing = getMoonIllumination(new Date('2025-03-05T12:00:00Z'))
// 2025-03-14 was close to full moon
const illum_full = getMoonIllumination(new Date('2025-03-14T12:00:00Z'))
// 2025-03-29 was close to new moon
const illum_new = getMoonIllumination(new Date('2025-03-29T12:00:00Z'))
// 2025-03-05 was waxing crescent (~7 days after new moon)
const illum_waxing = getMoonIllumination(new Date('2025-03-05T12:00:00Z'))
test('getMoonIllumination near full moon: fraction > 0.85', () => {
assert.ok(illum_full.fraction > 0.85, `fraction=${illum_full.fraction.toFixed(3)}`)
})
test('getMoonIllumination near full moon: phase close to 0.5', () => {
assert.ok(
illum_full.phase > 0.4 && illum_full.phase < 0.6,
`phase=${illum_full.phase.toFixed(3)}`,
)
})
test('getMoonIllumination near new moon: fraction < 0.05', () => {
assert.ok(illum_new.fraction < 0.05, `fraction=${illum_new.fraction.toFixed(3)}`)
})
test('getMoonIllumination near new moon: phase close to 0 or 1', () => {
const p = illum_new.phase
assert.ok(p < 0.08 || p > 0.92, `phase=${p.toFixed(3)}`)
})
test('getMoonIllumination waxing: isWaxing = true', () => {
assert.equal(illum_waxing.isWaxing, true)
})
test('getMoonIllumination fraction in [0, 1]', () => {
assert.ok(illum_full.fraction >= 0 && illum_full.fraction <= 1)
assert.ok(illum_new.fraction >= 0 && illum_new.fraction <= 1)
})
test('getMoonIllumination phase in [0, 1)', () => {
assert.ok(illum_full.phase >= 0 && illum_full.phase < 1)
assert.ok(illum_new.phase >= 0 && illum_new.phase < 1)
})
test('getMoonIllumination angle is finite', () => {
assert.ok(isFinite(illum_full.angle), `angle=${illum_full.angle}`)
})
test('getMoonIllumination default date (now) returns valid result', () => {
const illum = getMoonIllumination()
assert.ok(illum.fraction >= 0 && illum.fraction <= 1)
assert.ok(illum.phase >= 0 && illum.phase < 1)
assert.equal(typeof illum.isWaxing, 'boolean')
assert.ok(isFinite(illum.angle))
it('near full moon: fraction > 0.85', () => {
assert.ok(illum_full.fraction > 0.85, `fraction=${illum_full.fraction.toFixed(3)}`)
})
it('near full moon: phase close to 0.5', () => {
assert.ok(illum_full.phase > 0.4 && illum_full.phase < 0.6,
`phase=${illum_full.phase.toFixed(3)}`)
})
it('near new moon: fraction < 0.05', () => {
assert.ok(illum_new.fraction < 0.05, `fraction=${illum_new.fraction.toFixed(3)}`)
})
it('near new moon: phase close to 0 or 1', () => {
const p = illum_new.phase
assert.ok(p < 0.08 || p > 0.92, `phase=${p.toFixed(3)}`)
})
it('waxing: isWaxing = true', () => {
assert.equal(illum_waxing.isWaxing, true)
})
it('fraction in [0, 1]', () => {
assert.ok(illum_full.fraction >= 0 && illum_full.fraction <= 1)
assert.ok(illum_new.fraction >= 0 && illum_new.fraction <= 1)
})
it('phase in [0, 1)', () => {
assert.ok(illum_full.phase >= 0 && illum_full.phase < 1)
assert.ok(illum_new.phase >= 0 && illum_new.phase < 1)
})
it('angle is finite', () => {
assert.ok(isFinite(illum_full.angle), `angle=${illum_full.angle}`)
})
it('default date (now) returns valid result', () => {
const illum = getMoonIllumination()
assert.ok(illum.fraction >= 0 && illum.fraction <= 1)
assert.ok(illum.phase >= 0 && illum.phase < 1)
assert.equal(typeof illum.isWaxing, 'boolean')
assert.ok(isFinite(illum.angle))
})
})
// ─── getMoonPhase phaseName + phaseSymbol ─────────────────────────────────────
console.log('\ngetMoonPhase — phaseName + phaseSymbol:')
describe('getMoonPhase phaseName and phaseSymbol', () => {
const PHASE_NAMES = new Set([
'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous',
'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent',
])
const PHASE_SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'])
const PHASE_NAMES = new Set([
'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous',
'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent',
])
const PHASE_SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'])
test('getMoonPhase.phaseName is a valid human-readable name', () => {
const p = getMoonPhase(DATE_MARCH_1_2025)
assert.ok(PHASE_NAMES.has(p.phaseName), `got: ${p.phaseName}`)
})
test('getMoonPhase.phaseSymbol is a moon emoji', () => {
const p = getMoonPhase(DATE_MARCH_1_2025)
assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
})
test('Near full moon: phaseName is "Full Moon" or gibbous', () => {
const valid = new Set(['Full Moon', 'Waxing Gibbous', 'Waning Gibbous'])
const p = getMoonPhase(DATE_FULL_MOON)
assert.ok(valid.has(p.phaseName), `got: ${p.phaseName}`)
})
test('Near full moon: phaseSymbol is 🌕 or 🌔 or 🌖', () => {
const valid = new Set(['🌕', '🌔', '🌖'])
const p = getMoonPhase(DATE_FULL_MOON)
assert.ok(valid.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
})
test('Waxing crescent: phaseName is "Waxing Crescent"', () => {
const p = getMoonPhase(DATE_WAXING)
assert.equal(p.phaseName, 'Waxing Crescent')
})
test('Waxing crescent: phaseSymbol is 🌒', () => {
const p = getMoonPhase(DATE_WAXING)
assert.equal(p.phaseSymbol, '🌒')
})
test('phaseName and phaseSymbol are consistent with phase key', () => {
// If phase is 'waning-crescent', phaseName should be 'Waning Crescent'
const p = getMoonPhase(DATE_WANING)
assert.equal(typeof p.phaseName, 'string')
assert.ok(p.phaseName.length > 0)
assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol))
it('phaseName is a valid human-readable name', () => {
const p = getMoonPhase(DATE_MARCH_1_2025)
assert.ok(PHASE_NAMES.has(p.phaseName), `got: ${p.phaseName}`)
})
it('phaseSymbol is a moon emoji', () => {
const p = getMoonPhase(DATE_MARCH_1_2025)
assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
})
it('near full moon: phaseName is Full Moon or gibbous', () => {
const valid = new Set(['Full Moon', 'Waxing Gibbous', 'Waning Gibbous'])
const p = getMoonPhase(new Date('2025-03-14T12:00:00Z'))
assert.ok(valid.has(p.phaseName), `got: ${p.phaseName}`)
})
it('waxing crescent: phaseName is Waxing Crescent', () => {
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
assert.equal(p.phaseName, 'Waxing Crescent')
})
it('waxing crescent: phaseSymbol is correct', () => {
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
assert.equal(p.phaseSymbol, '🌒')
})
})
// ─── getMoonVisibilityEstimate ─────────────────────────────────────────────────
console.log('\ngetMoonVisibilityEstimate:')
describe('getMoonVisibilityEstimate', () => {
const DATE_VIS_ESTIMATE = new Date('2025-03-02T18:30:00Z')
const vis = getMoonVisibilityEstimate(DATE_VIS_ESTIMATE, 51.5074, -0.1278, 10)
// London, 40 min after nominal sunset on 2025-03-01 (day after new moon)
const DATE_VIS_ESTIMATE = new Date('2025-03-02T18:30:00Z')
const vis = getMoonVisibilityEstimate(DATE_VIS_ESTIMATE, 51.5074, -0.1278, 10)
test('getMoonVisibilityEstimate returns an object', () => {
assert.ok(vis !== null && typeof vis === 'object')
})
test('getMoonVisibilityEstimate.zone is A, B, C, or D', () => {
assert.ok(['A', 'B', 'C', 'D'].includes(vis.zone), `got: ${vis.zone}`)
})
test('getMoonVisibilityEstimate.V is finite', () => {
assert.ok(isFinite(vis.V), `V=${vis.V}`)
})
test('getMoonVisibilityEstimate.ARCL is in [0, 180]', () => {
assert.ok(vis.ARCL >= 0 && vis.ARCL <= 180, `ARCL=${vis.ARCL}`)
})
test('getMoonVisibilityEstimate.W >= 0', () => {
assert.ok(vis.W >= 0, `W=${vis.W}`)
})
test('getMoonVisibilityEstimate.isApproximate is true', () => {
assert.equal(vis.isApproximate, true)
})
test('getMoonVisibilityEstimate.moonAboveHorizon is a boolean', () => {
assert.equal(typeof vis.moonAboveHorizon, 'boolean')
})
test('getMoonVisibilityEstimate.isVisibleNakedEye matches zone A', () => {
assert.equal(vis.isVisibleNakedEye, vis.zone === 'A')
})
test('getMoonVisibilityEstimate.isVisibleWithOpticalAid matches zone A or B', () => {
assert.equal(vis.isVisibleWithOpticalAid, vis.zone === 'A' || vis.zone === 'B')
})
test('getMoonVisibilityEstimate.description is a non-empty string', () => {
assert.ok(typeof vis.description === 'string' && vis.description.length > 0)
})
test('getMoonVisibilityEstimate default date works', () => {
const v = getMoonVisibilityEstimate(new Date(), 21.4225, 39.8262)
assert.ok(['A', 'B', 'C', 'D'].includes(v.zone))
assert.ok(isFinite(v.V))
assert.equal(v.isApproximate, true)
})
// Near new moon: elongation small, W small, crescent should be very thin or invisible
test('Near new moon: zone is D or C (not visible or marginal)', () => {
const nearNew = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262)
assert.ok(['C', 'D'].includes(nearNew.zone), `zone=${nearNew.zone} V=${nearNew.V.toFixed(2)}`)
it('returns an object', () => {
assert.ok(vis !== null && typeof vis === 'object')
})
it('zone is A, B, C, or D', () => {
assert.ok(['A', 'B', 'C', 'D'].includes(vis.zone), `got: ${vis.zone}`)
})
it('V is finite', () => {
assert.ok(isFinite(vis.V), `V=${vis.V}`)
})
it('ARCL is in [0, 180]', () => {
assert.ok(vis.ARCL >= 0 && vis.ARCL <= 180, `ARCL=${vis.ARCL}`)
})
it('W >= 0', () => {
assert.ok(vis.W >= 0, `W=${vis.W}`)
})
it('isApproximate is true', () => {
assert.equal(vis.isApproximate, true)
})
it('moonAboveHorizon is a boolean', () => {
assert.equal(typeof vis.moonAboveHorizon, 'boolean')
})
it('isVisibleNakedEye matches zone A', () => {
assert.equal(vis.isVisibleNakedEye, vis.zone === 'A')
})
it('isVisibleWithOpticalAid matches zone A or B', () => {
assert.equal(vis.isVisibleWithOpticalAid, vis.zone === 'A' || vis.zone === 'B')
})
it('description is a non-empty string', () => {
assert.ok(typeof vis.description === 'string' && vis.description.length > 0)
})
it('default date works', () => {
const v = getMoonVisibilityEstimate(new Date(), 21.4225, 39.8262)
assert.ok(['A', 'B', 'C', 'D'].includes(v.zone))
assert.ok(isFinite(v.V))
assert.equal(v.isApproximate, true)
})
it('near new moon: zone is D or C', () => {
const nearNew = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262)
assert.ok(['C', 'D'].includes(nearNew.zone), `zone=${nearNew.zone} V=${nearNew.V.toFixed(2)}`)
})
})
// ─── getMoon ──────────────────────────────────────────────────────────────────
console.log('\ngetMoon:')
describe('getMoon', () => {
const moon = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10)
const moon = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10)
test('getMoon returns an object with phase, position, illumination, visibility', () => {
assert.ok(typeof moon === 'object')
assert.ok(typeof moon.phase === 'object')
assert.ok(typeof moon.position === 'object')
assert.ok(typeof moon.illumination === 'object')
assert.ok(typeof moon.visibility === 'object')
})
test('getMoon.phase is consistent with getMoonPhase standalone', () => {
const standalone = getMoonPhase(new Date('2025-03-05T20:00:00Z'))
assert.equal(moon.phase.phase, standalone.phase)
assert.equal(moon.phase.phaseName, standalone.phaseName)
})
test('getMoon.illumination.isWaxing matches phase.isWaxing', () => {
assert.equal(moon.illumination.isWaxing, moon.phase.isWaxing)
})
test('getMoon.visibility.isApproximate is true', () => {
assert.equal(moon.visibility.isApproximate, true)
})
test('getMoon.position has valid azimuth and altitude', () => {
assert.ok(moon.position.azimuth >= 0 && moon.position.azimuth < 360)
assert.ok(moon.position.altitude >= -90 && moon.position.altitude <= 90)
})
test('getMoon default date works', () => {
const m = getMoon(new Date(), 21.4225, 39.8262)
assert.ok(PHASE_NAMES.has(m.phase.phaseName))
assert.ok(isFinite(m.position.azimuth))
assert.ok(isFinite(m.illumination.fraction))
assert.ok(['A', 'B', 'C', 'D'].includes(m.visibility.zone))
it('returns object with phase, position, illumination, visibility', () => {
assert.ok(typeof moon === 'object')
assert.ok(typeof moon.phase === 'object')
assert.ok(typeof moon.position === 'object')
assert.ok(typeof moon.illumination === 'object')
assert.ok(typeof moon.visibility === 'object')
})
it('phase is consistent with getMoonPhase standalone', () => {
const standalone = getMoonPhase(new Date('2025-03-05T20:00:00Z'))
assert.equal(moon.phase.phase, standalone.phase)
assert.equal(moon.phase.phaseName, standalone.phaseName)
})
it('illumination.isWaxing matches phase.isWaxing', () => {
assert.equal(moon.illumination.isWaxing, moon.phase.isWaxing)
})
it('visibility.isApproximate is true', () => {
assert.equal(moon.visibility.isApproximate, true)
})
it('position has valid azimuth and altitude', () => {
assert.ok(moon.position.azimuth >= 0 && moon.position.azimuth < 360)
assert.ok(moon.position.altitude >= -90 && moon.position.altitude <= 90)
})
it('default date works', () => {
const m = getMoon(new Date(), 21.4225, 39.8262)
assert.ok(isFinite(m.position.azimuth))
assert.ok(isFinite(m.illumination.fraction))
assert.ok(['A', 'B', 'C', 'D'].includes(m.visibility.zone))
})
})
// ─── Summary ─────────────────────────────────────────────────────────────────
// ─── Input validation ─────────────────────────────────────────────────────────
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`)
if (failed > 0) {
process.exit(1)
}
describe('Input validation', () => {
it('getMoonPhase rejects invalid date', () => {
assert.throws(() => getMoonPhase(new Date('invalid')), /valid Date/)
})
it('getMoonPosition rejects latitude out of range', () => {
assert.throws(() => getMoonPosition(new Date(), 91, 0), /latitude/)
})
it('getMoonPosition rejects longitude out of range', () => {
assert.throws(() => getMoonPosition(new Date(), 0, 181), /longitude/)
})
it('getMoonPosition rejects NaN latitude', () => {
assert.throws(() => getMoonPosition(new Date(), NaN, 0), /latitude/)
})
it('getMoonVisibilityEstimate rejects invalid coordinates', () => {
assert.throws(() => getMoonVisibilityEstimate(new Date(), -91, 0), /latitude/)
})
it('getMoon rejects invalid coordinates', () => {
assert.throws(() => getMoon(new Date(), 0, 200), /longitude/)
})
})

View file

@ -4,6 +4,9 @@
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"declaration": true,