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: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: pnpm cache: pnpm
- run: pnpm install - run: pnpm install --frozen-lockfile
- run: pnpm build - run: pnpm build
- run: node test.mjs - run: node --test test.mjs
- run: node test-cjs.cjs - 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: typecheck:
name: TypeScript name: TypeScript
@ -39,7 +55,7 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- run: pnpm install - run: pnpm install --frozen-lockfile
- run: pnpm run typecheck - run: pnpm run typecheck
pack-check: pack-check:
@ -54,17 +70,16 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- run: pnpm install - run: pnpm install --frozen-lockfile
- run: pnpm build - run: pnpm build
- name: Verify pack contents - name: Verify pack contents
run: | run: |
npm pack --dry-run 2>&1 | tee pack-output.txt 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.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.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.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) 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 "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) ! grep -q "node_modules" pack-output.txt || (echo "node_modules should not be in pack" && exit 1)
echo "Pack contents verified." echo "Pack contents verified."

25
.gitignore vendored
View file

@ -1,7 +1,10 @@
node_modules/ node_modules/
dist/ dist/
build/
out/
*.tgz *.tgz
*.log *.log
*.tsbuildinfo
.DS_Store .DS_Store
.env .env
.env.* .env.*
@ -11,8 +14,28 @@ dist/
*.bsp *.bsp
*.tls *.tls
# PnP
.pnp
.pnp.js
# Coverage
coverage/
# IDE
.vscode/
.idea/
*.swp
# AI agent directories # AI agent directories
.claude/ .claude/
.cursor/ .cursor/
.aider/ .copilot/
.aider*
.continue/ .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", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "import": {
"import": "./dist/index.mjs", "types": "./dist/index.d.mts",
"require": "./dist/index.cjs" "default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
} }
}, },
"bin": { "bin": {
@ -31,14 +36,22 @@
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"pretest": "tsup", "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", "prepublishOnly": "tsup",
"cli": "node dist/cli/index.cjs" "cli": "node dist/cli/index.cjs"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "latest", "@eslint/js": "^10.0.1",
"tsup": "latest", "@types/node": "^25.3.0",
"typescript": "latest" "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": { "publishConfig": {
"access": "public", "access": "public",

View file

@ -8,15 +8,30 @@ importers:
.: .:
devDependencies: devDependencies:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.0.3)
'@types/node': '@types/node':
specifier: latest specifier: ^25.3.0
version: 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: tsup:
specifier: latest specifier: ^8.5.1
version: 8.5.1(typescript@5.9.3) version: 8.5.1(typescript@5.9.3)
typescript: typescript:
specifier: latest specifier: ^5.9.3
version: 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: packages:
@ -176,6 +191,61 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -327,20 +397,101 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@25.3.0': '@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} 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: acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
any-promise@1.3.0: any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} 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: bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -366,6 +517,10 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0} engines: {node: ^14.18.0 || >=16.10.0}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@ -375,11 +530,75 @@ packages:
supports-color: supports-color:
optional: true optional: true
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
esbuild@0.27.3: esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true 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: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -389,18 +608,76 @@ packages:
picomatch: picomatch:
optional: true 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: fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} 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: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] 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: joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} 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: lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -412,9 +689,17 @@ packages:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 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: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 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: mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@ -424,10 +709,33 @@ packages:
mz@2.7.0: mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} 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: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@ -463,6 +771,19 @@ packages:
yaml: yaml:
optional: true 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: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@ -476,6 +797,19 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true 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: source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@ -503,6 +837,12 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true 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: ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@ -525,6 +865,17 @@ packages:
typescript: typescript:
optional: true 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: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@ -536,6 +887,22 @@ packages:
undici-types@7.18.2: undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} 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: snapshots:
'@esbuild/aix-ppc64@0.27.3': '@esbuild/aix-ppc64@0.27.3':
@ -616,6 +983,51 @@ snapshots:
'@esbuild/win32-x64@0.27.3': '@esbuild/win32-x64@0.27.3':
optional: true 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': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@ -705,16 +1117,128 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.59.0': '@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true optional: true
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/node@25.3.0': '@types/node@25.3.0':
dependencies: dependencies:
undici-types: 7.18.2 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: {} 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: {} 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): bundle-require@5.1.0(esbuild@0.27.3):
dependencies: dependencies:
esbuild: 0.27.3 esbuild: 0.27.3
@ -732,10 +1256,18 @@ snapshots:
consola@3.4.2: {} 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: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
deep-is@0.1.4: {}
esbuild@0.27.3: esbuild@0.27.3:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3 '@esbuild/aix-ppc64': 0.27.3
@ -765,31 +1297,164 @@ snapshots:
'@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 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): fdir@6.5.0(picomatch@4.0.3):
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 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: fix-dts-default-cjs-exports@1.0.1:
dependencies: dependencies:
magic-string: 0.30.21 magic-string: 0.30.21
mlly: 1.8.0 mlly: 1.8.0
rollup: 4.59.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: fsevents@2.3.3:
optional: true 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: {} 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: {} lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
load-tsconfig@0.2.5: {} load-tsconfig@0.2.5: {}
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
mlly@1.8.0: mlly@1.8.0:
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
@ -805,8 +1470,31 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
thenify-all: 1.6.0 thenify-all: 1.6.0
natural-compare@1.4.0: {}
object-assign@4.1.1: {} 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: {} pathe@2.0.3: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
@ -825,6 +1513,12 @@ snapshots:
dependencies: dependencies:
lilconfig: 3.1.3 lilconfig: 3.1.3
prelude-ls@1.2.1: {}
prettier@3.8.1: {}
punycode@2.3.1: {}
readdirp@4.1.2: {} readdirp@4.1.2: {}
resolve-from@5.0.0: {} resolve-from@5.0.0: {}
@ -860,6 +1554,14 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.59.0 '@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3 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: {} source-map@0.7.6: {}
sucrase@3.35.1: sucrase@3.35.1:
@ -889,6 +1591,10 @@ snapshots:
tree-kill@1.2.2: {} 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: {} ts-interface-checker@0.1.13: {}
tsup@8.5.1(typescript@5.9.3): tsup@8.5.1(typescript@5.9.3):
@ -918,8 +1624,35 @@ snapshots:
- tsx - tsx
- yaml - 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: {} typescript@5.9.3: {}
ufo@1.6.3: {} ufo@1.6.3: {}
undici-types@7.18.2: {} 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' } from '../types.js'
import { ODEH_THRESHOLDS, ODEH_DESCRIPTIONS } from '../types.js' import { ODEH_THRESHOLDS, ODEH_DESCRIPTIONS } from '../types.js'
import { SpkKernel } from '../spk/index.js' import { SpkKernel } from '../spk/index.js'
import { import { computeTimeScales, jdTTtoET, jdToDate, J2000 } from '../time/index.js'
computeTimeScales,
jdTTtoET,
J2000,
} from '../time/index.js'
import { import {
getMoonGeocentricState, getMoonGeocentricState,
getSunGeocentricState, getSunGeocentricState,
@ -44,10 +40,7 @@ import {
getMoonSunApproximate, getMoonSunApproximate,
nearestNewMoon, nearestNewMoon,
} from '../bodies/index.js' } from '../bodies/index.js'
import { import { geodeticToECEF, computeAzAlt } from '../observer/index.js'
geodeticToECEF,
computeAzAlt,
} from '../observer/index.js'
import { itrsToGcrs, computeERA } from '../frames/index.js' import { itrsToGcrs, computeERA } from '../frames/index.js'
import { import {
getSunMoonEvents as eventsGetSunMoonEvents, getSunMoonEvents as eventsGetSunMoonEvents,
@ -60,7 +53,34 @@ import {
computeYallop, computeYallop,
computeOdeh, computeOdeh,
buildGuidanceText, buildGuidanceText,
arcvMinimum,
} from '../visibility/index.js' } 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 ───────────────────────────────────────────── // ─── Module-level kernel singleton ─────────────────────────────────────────────
@ -83,7 +103,7 @@ function resolveCacheDir(override?: string): string {
// ─── Download sources ───────────────────────────────────────────────────────── // ─── Download sources ─────────────────────────────────────────────────────────
const NAIF_DE442S_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de442s.bsp' 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 ───────────────────────────────────────────────────────── // ─── Kernel lifecycle ─────────────────────────────────────────────────────────
@ -109,7 +129,8 @@ export async function initKernels(config?: KernelConfig): Promise<void> {
buffer = source.data buffer = source.data
} else if (source.type === 'url') { } else if (source.type === 'url') {
const res = await fetch(source.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() buffer = await res.arrayBuffer()
} else { } else {
// auto: download to local cache, then load // auto: download to local cache, then load
@ -146,7 +167,7 @@ export async function downloadKernels(config?: KernelConfig): Promise<{
await mkdir(cacheDir, { recursive: true }) await mkdir(cacheDir, { recursive: true })
const planetaryPath = join(cacheDir, 'de442s.bsp') const planetaryPath = join(cacheDir, 'de442s.bsp')
const leapSecondsPath = join(cacheDir, 'naif0012.tls') const leapSecondsPath = join(cacheDir, 'naif0012.tls')
if (!existsSync(planetaryPath)) { if (!existsSync(planetaryPath)) {
@ -206,7 +227,7 @@ export async function verifyKernels(config?: KernelConfig): Promise<{
const { join } = await import('node:path') const { join } = await import('node:path')
const errors: string[] = [] const errors: string[] = []
const planetaryPath = join(cacheDir, 'de442s.bsp') const planetaryPath = join(cacheDir, 'de442s.bsp')
const leapSecondsPath = join(cacheDir, 'naif0012.tls') const leapSecondsPath = join(cacheDir, 'naif0012.tls')
if (!existsSync(planetaryPath)) { if (!existsSync(planetaryPath)) {
@ -251,7 +272,8 @@ async function resolveKernel(config?: KernelConfig): Promise<SpkKernel> {
// auto-init as last resort // auto-init as last resort
await initKernels(config) 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 return activeKernel
} }
@ -286,6 +308,8 @@ export async function getMoonSightingReport(
observer: Observer, observer: Observer,
options?: SightingOptions, options?: SightingOptions,
): Promise<MoonSightingReport> { ): Promise<MoonSightingReport> {
validateDate(date, 'getMoonSightingReport')
validateObserver(observer, 'getMoonSightingReport')
const kernel = await resolveKernel(options?.kernels) const kernel = await resolveKernel(options?.kernels)
// Event times (sunset, moonset, twilight, rise) // Event times (sunset, moonset, twilight, rise)
@ -321,7 +345,7 @@ export async function getMoonSightingReport(
// Body positions in GCRS (geocentric) // Body positions in GCRS (geocentric)
const moonGCRS = getMoonGeocentricState(kernel, et).position 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 // Observer ITRS position (km) from geodetic coordinates
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation) 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 // Airless alt/az — required by Yallop/Odeh criteria
const moonAirless = computeAzAlt(moonGCRS, observer, ts, true) 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 // Apparent alt/az (with refraction) — for guidance text
const moonApparent = computeAzAlt(moonGCRS, observer, ts, false) const moonApparent = computeAzAlt(moonGCRS, observer, ts, false)
@ -349,11 +373,7 @@ export async function getMoonSightingReport(
moonGCRS[1] - obsGCRS[1], moonGCRS[1] - obsGCRS[1],
moonGCRS[2] - obsGCRS[2], moonGCRS[2] - obsGCRS[2],
] ]
const sunTopo: Vec3 = [ const sunTopo: Vec3 = [sunGCRS[0] - obsGCRS[0], sunGCRS[1] - obsGCRS[1], sunGCRS[2] - obsGCRS[2]]
sunGCRS[0] - obsGCRS[0],
sunGCRS[1] - obsGCRS[1],
sunGCRS[2] - obsGCRS[2],
]
const geometry = computeCrescentGeometry( const geometry = computeCrescentGeometry(
moonAirless, moonAirless,
@ -366,7 +386,7 @@ export async function getMoonSightingReport(
const { Wprime } = computeCrescentWidth(moonTopo, geometry.ARCL) const { Wprime } = computeCrescentWidth(moonTopo, geometry.ARCL)
const yallop = computeYallop(geometry, Wprime) const yallop = computeYallop(geometry, Wprime)
const odeh = computeOdeh(geometry) const odeh = computeOdeh(geometry)
const moonAboveHorizon = moonAirless.altitude > 0 const moonAboveHorizon = moonAirless.altitude > 0
const sightingPossible = moonAboveHorizon && lagMinutes > 0 const sightingPossible = moonAboveHorizon && lagMinutes > 0
@ -413,7 +433,7 @@ function buildNullReport(
return { return {
date, date,
observer, observer,
sunsetUTC: events.sunsetUTC, sunsetUTC: events.sunsetUTC,
moonsetUTC: events.moonsetUTC, moonsetUTC: events.moonsetUTC,
lagMinutes: null, lagMinutes: null,
bestTimeUTC: null, bestTimeUTC: null,
@ -425,7 +445,8 @@ function buildNullReport(
geometry: null, geometry: null,
yallop: null, yallop: null,
odeh: 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, ephemerisSource: source,
moonAboveHorizon: null, moonAboveHorizon: null,
sightingPossible, sightingPossible,
@ -435,14 +456,14 @@ function buildNullReport(
// ─── Phase display lookup ────────────────────────────────────────────────────── // ─── Phase display lookup ──────────────────────────────────────────────────────
const PHASE_DISPLAY: Record<MoonPhaseName, { name: string; symbol: string }> = { 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: '🌒' }, 'waxing-crescent': { name: 'Waxing Crescent', symbol: '🌒' },
'first-quarter': { name: 'First Quarter', symbol: '🌓' }, 'first-quarter': { name: 'First Quarter', symbol: '🌓' },
'waxing-gibbous': { name: 'Waxing Gibbous', symbol: '🌔' }, 'waxing-gibbous': { name: 'Waxing Gibbous', symbol: '🌔' },
'full-moon': { name: 'Full Moon', symbol: '🌕' }, 'full-moon': { name: 'Full Moon', symbol: '🌕' },
'waning-gibbous': { name: 'Waning Gibbous', symbol: '🌖' }, 'waning-gibbous': { name: 'Waning Gibbous', symbol: '🌖' },
'last-quarter': { name: 'Last Quarter', symbol: '🌗' }, 'last-quarter': { name: 'Last Quarter', symbol: '🌗' },
'waning-crescent': { name: 'Waning Crescent', 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 { export function getMoonPhase(date = new Date()): MoonPhaseResult {
validateDate(date, 'getMoonPhase')
const ts = computeTimeScales(date) const ts = computeTimeScales(date)
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT) const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT)
@ -478,7 +500,7 @@ export function getMoonPhase(date = new Date()): MoonPhaseResult {
const phaseKey = elongationToPhase(elongationDeg, isWaxing) const phaseKey = elongationToPhase(elongationDeg, isWaxing)
const { name: phaseName, symbol: phaseSymbol } = PHASE_DISPLAY[phaseKey] 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) const nextFullMoonJD = nearestFullMoon(ts.jdTT)
return { return {
@ -489,9 +511,9 @@ export function getMoonPhase(date = new Date()): MoonPhaseResult {
age, age,
elongationDeg, elongationDeg,
isWaxing, isWaxing,
nextNewMoon: jdToJSDate(nextNewMoonJD), nextNewMoon: jdToDate(nextNewMoonJD),
nextFullMoon: jdToJSDate(nextFullMoonJD), nextFullMoon: jdToDate(nextFullMoonJD),
prevNewMoon: jdToJSDate(prevNewMoonJD), prevNewMoon: jdToDate(prevNewMoonJD),
} }
} }
@ -520,7 +542,9 @@ export function getMoonPosition(
lon: number, lon: number,
elevation = 0, elevation = 0,
): MoonPosition { ): MoonPosition {
const DEG = Math.PI / 180 validateDate(date, 'getMoonPosition')
validateLatitude(lat, 'getMoonPosition')
validateLongitude(lon, 'getMoonPosition')
const ts = computeTimeScales(date) const ts = computeTimeScales(date)
const { moonGCRS } = getMoonSunApproximate(ts.jdTT) 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) const distance = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2)
// Equatorial coordinates for parallactic angle // 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))) const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / distance)))
// Hour angle: ERA(UT1) + longitude right ascension // Hour angle: ERA(UT1) + longitude right ascension
const era = computeERA(ts.jdUT1) 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 // Parallactic angle: signed angle between zenith and north pole as seen from the Moon
const parallacticAngle = Math.atan2( const parallacticAngle = Math.atan2(
Math.sin(HA), 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 } return { azimuth: azAlt.azimuth, altitude: azAlt.altitude, distance, parallacticAngle }
@ -566,6 +590,7 @@ export function getMoonPosition(
* ``` * ```
*/ */
export function getMoonIllumination(date: Date = new Date()): MoonIlluminationResult { export function getMoonIllumination(date: Date = new Date()): MoonIlluminationResult {
validateDate(date, 'getMoonIllumination')
const ts = computeTimeScales(date) const ts = computeTimeScales(date)
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT) 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), // 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)) // 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 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 dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / moonDist)))
const RA_sun = Math.atan2(sunGCRS[1], sunGCRS[0]) const RA_sun = Math.atan2(sunGCRS[1], sunGCRS[0])
const dec_sun = Math.asin(Math.max(-1, Math.min(1, sunGCRS[2] / sunDist))) const dec_sun = Math.asin(Math.max(-1, Math.min(1, sunGCRS[2] / sunDist)))
const dRA = RA_sun - RA_moon const dRA = RA_sun - RA_moon
const angle = Math.atan2( const angle = Math.atan2(
@ -625,13 +650,16 @@ export function getMoonVisibilityEstimate(
lon: number, lon: number,
elevation = 0, elevation = 0,
): MoonVisibilityEstimate { ): MoonVisibilityEstimate {
validateDate(date, 'getMoonVisibilityEstimate')
validateLatitude(lat, 'getMoonVisibilityEstimate')
validateLongitude(lon, 'getMoonVisibilityEstimate')
const ts = computeTimeScales(date) const ts = computeTimeScales(date)
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT) const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT)
const observer: Observer = { lat, lon, elevation } const observer: Observer = { lat, lon, elevation }
// Airless positions — Odeh uses airless altitudes (no refraction) // Airless positions — Odeh uses airless altitudes (no refraction)
const moonAirless = computeAzAlt(moonGCRS, observer, ts, true) 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) // ARCL = elongation (geocentric, degrees)
const { elongationDeg } = computeIllumination(moonGCRS, sunGCRS) const { elongationDeg } = computeIllumination(moonGCRS, sunGCRS)
@ -652,14 +680,11 @@ export function getMoonVisibilityEstimate(
const { W } = computeCrescentWidth(moonTopo, ARCL) const { W } = computeCrescentWidth(moonTopo, ARCL)
// Odeh 2006: V = ARCV - f(W), where f(W) = arcv_minimum polynomial // Odeh 2006: V = ARCV - arcv_minimum(W)
const arcvMin = -0.1018 * W ** 3 + 0.7319 * W ** 2 - 6.3226 * W + 7.1651 const V = ARCV - arcvMinimum(W)
const V = ARCV - arcvMin
const zone: OdehZone = V >= ODEH_THRESHOLDS.A ? 'A' const zone: OdehZone =
: V >= ODEH_THRESHOLDS.B ? 'B' V >= ODEH_THRESHOLDS.A ? 'A' : V >= ODEH_THRESHOLDS.B ? 'B' : V >= ODEH_THRESHOLDS.C ? 'C' : 'D'
: V >= ODEH_THRESHOLDS.C ? 'C'
: 'D'
return { return {
V, V,
@ -705,21 +730,19 @@ export function getMoon(
lon: number, lon: number,
elevation = 0, elevation = 0,
): MoonSnapshot { ): MoonSnapshot {
validateDate(date, 'getMoon')
validateLatitude(lat, 'getMoon')
validateLongitude(lon, 'getMoon')
return { return {
phase: getMoonPhase(date), phase: getMoonPhase(date),
position: getMoonPosition(date, lat, lon, elevation), position: getMoonPosition(date, lat, lon, elevation),
illumination: getMoonIllumination(date), illumination: getMoonIllumination(date),
visibility: getMoonVisibilityEstimate(date, lat, lon, elevation), visibility: getMoonVisibilityEstimate(date, lat, lon, elevation),
} }
} }
// ─── Internal helpers ───────────────────────────────────────────────────────── // ─── 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). * 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. * 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). */ /** Full moon JDE for a half-integer k (Meeus Ch. 49, Table 49.A). */
function fullMoonJDE(k: number): number { function fullMoonJDE(k: number): number {
const T = k / 1236.85 const T = k / 1236.85
const DEG = Math.PI / 180
let JDE = 2451550.09766 let JDE =
+ 29.530588861 * k 2451550.09766 +
+ 0.00015437 * T * T 29.530588861 * k +
- 0.000000150 * T * T * T 0.00015437 * T * T -
+ 0.00000000073 * T * T * 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 M = (2.5534 + 29.1053567 * k - 0.0000014 * T * T) * DEG2RAD
const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T) * DEG const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T) * DEG2RAD
const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T) * DEG const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T) * DEG2RAD
const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T) * DEG const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T) * DEG2RAD
const E = 1 - 0.002516 * T - 0.0000074 * T * T const E = 1 - 0.002516 * T - 0.0000074 * T * T
JDE += JDE +=
-0.40614 * Math.sin(Mp) -0.40614 * Math.sin(Mp) +
+ 0.17302 * E * Math.sin(M) 0.17302 * E * Math.sin(M) +
+ 0.01614 * Math.sin(2 * Mp) 0.01614 * Math.sin(2 * Mp) +
+ 0.01043 * Math.sin(2 * Fc) 0.01043 * Math.sin(2 * Fc) +
+ 0.00734 * E * Math.sin(Mp - M) 0.00734 * E * Math.sin(Mp - M) -
- 0.00515 * E * Math.sin(Mp + M) 0.00515 * E * Math.sin(Mp + M) +
+ 0.00209 * E * E * Math.sin(2 * M) 0.00209 * E * E * Math.sin(2 * M) -
- 0.00111 * Math.sin(Mp - 2 * Fc) 0.00111 * Math.sin(Mp - 2 * Fc) -
- 0.00057 * Math.sin(Mp + 2 * Fc) 0.00057 * Math.sin(Mp + 2 * Fc) +
+ 0.00056 * E * Math.sin(2 * Mp + M) 0.00056 * E * Math.sin(2 * Mp + M) -
- 0.00042 * Math.sin(3 * Mp) 0.00042 * Math.sin(3 * Mp) +
+ 0.00042 * E * Math.sin(M + 2 * Fc) 0.00042 * E * Math.sin(M + 2 * Fc) +
+ 0.00038 * E * Math.sin(M - 2 * Fc) 0.00038 * E * Math.sin(M - 2 * Fc) -
- 0.00024 * E * Math.sin(2 * Mp - M) 0.00024 * E * Math.sin(2 * Mp - M) -
- 0.00017 * Math.sin(Om) 0.00017 * Math.sin(Om) -
- 0.00007 * Math.sin(Mp + 2 * M) 0.00007 * Math.sin(Mp + 2 * M) +
+ 0.00004 * Math.sin(2 * Mp - 2 * Fc) 0.00004 * Math.sin(2 * Mp - 2 * Fc) +
+ 0.00004 * Math.sin(3 * M) 0.00004 * Math.sin(3 * M) +
+ 0.00003 * Math.sin(Mp + M - 2 * Fc) 0.00003 * Math.sin(Mp + M - 2 * Fc) +
+ 0.00003 * Math.sin(2 * Mp + 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.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(Mp - M - 2 * Fc) -
- 0.00002 * Math.sin(3 * Mp + M) 0.00002 * Math.sin(3 * Mp + M) +
+ 0.00002 * Math.sin(4 * Mp) 0.00002 * Math.sin(4 * Mp)
return JDE return JDE
} }
@ -790,10 +813,10 @@ function fullMoonJDE(k: number): number {
*/ */
function elongationToPhase(elongationDeg: number, isWaxing: boolean): MoonPhaseName { function elongationToPhase(elongationDeg: number, isWaxing: boolean): MoonPhaseName {
const e = elongationDeg const e = elongationDeg
if (e < 5) return 'new-moon' if (e < 5) return 'new-moon'
if (e > 175) return 'full-moon' if (e > 175) return 'full-moon'
if (e < 85) return isWaxing ? 'waxing-crescent' : 'waning-crescent' if (e < 85) return isWaxing ? 'waxing-crescent' : 'waning-crescent'
if (e < 95) return isWaxing ? 'first-quarter' : 'last-quarter' if (e < 95) return isWaxing ? 'first-quarter' : 'last-quarter'
return isWaxing ? 'waxing-gibbous' : 'waning-gibbous' return isWaxing ? 'waxing-gibbous' : 'waning-gibbous'
} }
@ -812,6 +835,8 @@ export async function getSunMoonEvents(
observer: Observer, observer: Observer,
options?: Pick<SightingOptions, 'kernels'>, options?: Pick<SightingOptions, 'kernels'>,
): Promise<SunMoonEvents> { ): Promise<SunMoonEvents> {
validateDate(date, 'getSunMoonEvents')
validateObserver(observer, 'getSunMoonEvents')
const kernel = await resolveKernel(options?.kernels) const kernel = await resolveKernel(options?.kernels)
return eventsGetSunMoonEvents(date, observer, kernel) 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 type { SpkKernel } from '../spk/index.js'
import { NAIF_IDS } from '../spk/index.js' import { NAIF_IDS } from '../spk/index.js'
import { J2000, DAYS_PER_JULIAN_CENTURY } from '../time/index.js' import { J2000, DAYS_PER_JULIAN_CENTURY } from '../time/index.js'
import { DEG2RAD, vdot, vnorm } from '../math/index.js'
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
const DEG = Math.PI / 180
const AU_KM = 149597870.7 const AU_KM = 149597870.7
/** Mean radius of the Moon in km (IAU 2015 nominal value) */ /** Mean radius of the Moon in km (IAU 2015 nominal value) */
const MOON_RADIUS_KM = 1737.4 const MOON_RADIUS_KM = 1737.4
/** Mean radius of the Sun in km */ /** 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 ───────────────────────────────────────────────────────── // ─── Geocentric state ─────────────────────────────────────────────────────────
@ -85,15 +85,12 @@ export function computeIllumination(
moonGCRS: Vec3, moonGCRS: Vec3,
sunGCRS: Vec3, sunGCRS: Vec3,
): { illumination: number; phaseAngleDeg: number; elongationDeg: number; isWaxing: boolean } { ): { 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 rMoon = vnorm(moonGCRS)
const norm = (v: Vec3) => Math.sqrt(dot(v, v)) const rSun = vnorm(sunGCRS)
const rMoon = norm(moonGCRS)
const rSun = norm(sunGCRS)
// Elongation ψ: angle at Earth between Moon and Sun // Elongation ψ: angle at Earth between Moon and Sun
const cosElong = dot(moonGCRS, sunGCRS) / (rMoon * rSun) const cosElong = vdot(moonGCRS, sunGCRS) / (rMoon * rSun)
const elongationDeg = Math.acos(Math.max(-1, Math.min(1, cosElong))) / DEG const elongationDeg = Math.acos(Math.max(-1, Math.min(1, cosElong))) / DEG2RAD
// Phase angle i: angle at Moon between Earth and Sun // Phase angle i: angle at Moon between Earth and Sun
// Vector from Moon to Earth: -moonGCRS // Vector from Moon to Earth: -moonGCRS
@ -104,16 +101,16 @@ export function computeIllumination(
sunGCRS[2] - moonGCRS[2], sunGCRS[2] - moonGCRS[2],
] ]
const moonToEarth: Vec3 = [-moonGCRS[0], -moonGCRS[1], -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 cosPhase = vdot(moonToEarth, moonToSun) / (rMoon * rMoonToSun)
const phaseAngleDeg = Math.acos(Math.max(-1, Math.min(1, cosPhase))) / DEG 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). // 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. // 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 const isWaxing = crossZ > 0
return { illumination, phaseAngleDeg, elongationDeg, isWaxing } return { illumination, phaseAngleDeg, elongationDeg, isWaxing }
@ -143,16 +140,13 @@ export function computeCrescentWidth(
moonTopoVec: Vec3, moonTopoVec: Vec3,
ARCL: number, ARCL: number,
): { W: number; Wprime: 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 // 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 // Crescent width in arc minutes
const ARCL_rad = ARCL * DEG const ARCL_rad = ARCL * DEG2RAD
const W = SDmoon_arcmin * (1 - Math.cos(ARCL_rad)) const W = SDmoon_arcmin * (1 - Math.cos(ARCL_rad))
// Wprime ≡ W for both Odeh and Yallop in this formulation // 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) // Mean longitude L0 and mean anomaly M (degrees)
const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T 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 = 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 const e_sun = 0.016708634 - 0.000042037 * T - 0.0000001267 * T * T
// Equation of center (degrees) // Equation of center (degrees)
const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M_sun_rad) const C =
+ (0.019993 - 0.000101 * T) * Math.sin(2 * M_sun_rad) (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M_sun_rad) +
+ 0.000289 * Math.sin(3 * 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 // True longitude and anomaly
const sunLonDeg = L0 + C const sunLonDeg = L0 + C
const nu_rad = M_sun_rad + C * DEG const nu_rad = M_sun_rad + C * DEG2RAD
// Geometric distance in AU // 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 const R_km = R_AU * AU_KM
// Nutation correction for apparent longitude (simplified) // 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 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) // 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 = [ const sunGCRS: Vec3 = [
R_km * Math.cos(sunLon_rad), R_km * Math.cos(sunLon_rad),
@ -219,121 +215,151 @@ export function getMoonSunApproximate(jdTT: number): {
// ── Moon (Meeus Ch. 47) ───────────────────────────────────────────────────── // ── Moon (Meeus Ch. 47) ─────────────────────────────────────────────────────
// Fundamental arguments (degrees) // Fundamental arguments (degrees)
const Lp = 218.3164477 + 481267.88123421 * T - 0.0015786 * T * T + T * T * T / 538841 - T * T * T * T / 65194000 const Lp =
const D = 297.8501921 + 445267.1114034 * T - 0.0018819 * T * T + T * T * T / 545868 - T * T * T * T / 113065000 218.3164477 +
const M = 357.5291092 + 35999.0502909 * T - 0.0001536 * T * T + T * T * T / 24490000 481267.88123421 * T -
const Mp = 134.9633964 + 477198.8675055 * T + 0.0087414 * T * T + T * T * T / 69699 - T * T * T * T / 14712000 0.0015786 * T * T +
const F = 93.2720950 + 483202.0175233 * T - 0.0036539 * T * T - T * T * T / 3526000 + T * T * T * T / 863310000 (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 // Additive terms for longitude/latitude
const A1 = (119.75 + 131.849 * T) * DEG const A1 = (119.75 + 131.849 * T) * DEG2RAD
const A2 = ( 53.09 + 479264.290 * T) * DEG const A2 = (53.09 + 479264.29 * T) * DEG2RAD
const A3 = (313.45 + 481266.484 * T) * DEG const A3 = (313.45 + 481266.484 * T) * DEG2RAD
// Convert to radians for accumulation // Convert to radians for accumulation
const D_r = (D % 360) * DEG const D_r = (D % 360) * DEG2RAD
const M_r = (M % 360) * DEG const M_r = (M % 360) * DEG2RAD
const Mp_r = (Mp % 360) * DEG const Mp_r = (Mp % 360) * DEG2RAD
const F_r = (F % 360) * DEG const F_r = (F % 360) * DEG2RAD
// Eccentricity correction for terms involving M (Earth's orbital eccentricity) // Eccentricity correction for terms involving M (Earth's orbital eccentricity)
const E = 1 - 0.002516 * T - 0.0000074 * T * T const E = 1 - 0.002516 * T - 0.0000074 * T * T
// Longitude and distance accumulation — 30 main terms from Meeus Table 47.A // Longitude and distance accumulation — 30 main terms from Meeus Table 47.A
// [d, m, mp, f, Σl (0.000001°), Σr (0.001 km)] // [d, m, mp, f, Σl (0.000001°), Σr (0.001 km)]
const LD: ReadonlyArray<readonly [number,number,number,number,number,number]> = [ const LD: ReadonlyArray<readonly [number, number, number, number, number, number]> = [
[ 0, 0, 1, 0, 6288774, -20905355], [0, 0, 1, 0, 6288774, -20905355],
[ 2, 0,-1, 0, 1274027, -3699111], [2, 0, -1, 0, 1274027, -3699111],
[ 2, 0, 0, 0, 658314, -2955968], [2, 0, 0, 0, 658314, -2955968],
[ 0, 0, 2, 0, 213618, -569925], [0, 0, 2, 0, 213618, -569925],
[ 0, 1, 0, 0, -185116, 48888], [0, 1, 0, 0, -185116, 48888],
[ 0, 0, 0, 2, -114332, -3149], [0, 0, 0, 2, -114332, -3149],
[ 2, 0,-2, 0, 58793, 246158], [2, 0, -2, 0, 58793, 246158],
[ 2,-1,-1, 0, 57066, -152138], [2, -1, -1, 0, 57066, -152138],
[ 2, 0, 1, 0, 53322, -170733], [2, 0, 1, 0, 53322, -170733],
[ 2,-1, 0, 0, 45758, -204586], [2, -1, 0, 0, 45758, -204586],
[ 0, 1,-1, 0, -40923, -129620], [0, 1, -1, 0, -40923, -129620],
[ 1, 0, 0, 0, -34720, 108743], [1, 0, 0, 0, -34720, 108743],
[ 0, 1, 1, 0, -30383, 104755], [0, 1, 1, 0, -30383, 104755],
[ 2, 0, 0,-2, 15327, 10321], [2, 0, 0, -2, 15327, 10321],
[ 0, 0, 1, 2, -12528, 0], [0, 0, 1, 2, -12528, 0],
[ 0, 0, 1,-2, 10980, 79661], [0, 0, 1, -2, 10980, 79661],
[ 4, 0,-1, 0, 10675, -34782], [4, 0, -1, 0, 10675, -34782],
[ 0, 0, 3, 0, 10034, -23210], [0, 0, 3, 0, 10034, -23210],
[ 4, 0,-2, 0, 8548, -21636], [4, 0, -2, 0, 8548, -21636],
[ 2, 1,-1, 0, -7888, 24208], [2, 1, -1, 0, -7888, 24208],
[ 2, 1, 0, 0, -6766, 30824], [2, 1, 0, 0, -6766, 30824],
[ 1, 0,-1, 0, -5163, -8379], [1, 0, -1, 0, -5163, -8379],
[ 1, 1, 0, 0, 4987, -16675], [1, 1, 0, 0, 4987, -16675],
[ 2,-1, 1, 0, 4036, -12831], [2, -1, 1, 0, 4036, -12831],
[ 2, 0, 2, 0, 3994, -10445], [2, 0, 2, 0, 3994, -10445],
[ 4, 0, 0, 0, 3861, -11650], [4, 0, 0, 0, 3861, -11650],
[ 2, 0,-3, 0, 3665, 14403], [2, 0, -3, 0, 3665, 14403],
[ 0, 1,-2, 0, -2689, -7003], [0, 1, -2, 0, -2689, -7003],
[ 2, 0,-1, 2, -2602, 0], [2, 0, -1, 2, -2602, 0],
[ 2,-1,-2, 0, 2390, 10056], [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) { 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 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 eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1
Sl += sl * eCorr * Math.sin(arg) Sl += sl * eCorr * Math.sin(arg)
Sr += sr * eCorr * Math.cos(arg) Sr += sr * eCorr * Math.cos(arg)
} }
// Additive longitude corrections (Meeus §47) // 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 // Latitude accumulation — 20 main terms from Meeus Table 47.B
// [d, m, mp, f, Σb (0.000001°)] // [d, m, mp, f, Σb (0.000001°)]
const FB: ReadonlyArray<readonly [number,number,number,number,number]> = [ const FB: ReadonlyArray<readonly [number, number, number, number, number]> = [
[ 0, 0, 0, 1, 5128122], [0, 0, 0, 1, 5128122],
[ 0, 0, 1, 1, 280602], [0, 0, 1, 1, 280602],
[ 0, 0, 1,-1, 277693], [0, 0, 1, -1, 277693],
[ 2, 0, 0,-1, 173237], [2, 0, 0, -1, 173237],
[ 2, 0,-1, 1, 55413], [2, 0, -1, 1, 55413],
[ 2, 0,-1,-1, 46271], [2, 0, -1, -1, 46271],
[ 2, 0, 0, 1, 32573], [2, 0, 0, 1, 32573],
[ 0, 0, 2, 1, 17198], [0, 0, 2, 1, 17198],
[ 2, 0, 1,-1, 9266], [2, 0, 1, -1, 9266],
[ 0, 0, 2,-1, 8822], [0, 0, 2, -1, 8822],
[ 2,-1, 0,-1, 8216], [2, -1, 0, -1, 8216],
[ 2, 0,-2,-1, 4324], [2, 0, -2, -1, 4324],
[ 2, 0, 1, 1, 4200], [2, 0, 1, 1, 4200],
[ 2, 1, 0,-1, -3359], [2, 1, 0, -1, -3359],
[ 2,-1,-1, 1, 2463], [2, -1, -1, 1, 2463],
[ 2,-1, 0, 1, 2211], [2, -1, 0, 1, 2211],
[ 2,-1,-1,-1, 2065], [2, -1, -1, -1, 2065],
[ 0, 1,-1,-1, -1870], [0, 1, -1, -1, -1870],
[ 4, 0,-1,-1, 1828], [4, 0, -1, -1, 1828],
[ 0, 1, 0, 1, -1794], [0, 1, 0, 1, -1794],
] ]
let Sb = 0 let Sb = 0
for (const [d, m, mp, f, sb] of FB) { for (const [d, m, mp, f, sb] of FB) {
const arg = d*D_r + m*M_r + mp*Mp_r + f*F_r 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 eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1
Sb += sb * eCorr * Math.sin(arg) Sb += sb * eCorr * Math.sin(arg)
} }
// Additive latitude corrections // Additive latitude corrections
Sb += -2235 * Math.sin(Lp * DEG) + 382 * Math.sin(A3) + 175 * Math.sin(A1 - F_r) Sb +=
+ 175 * Math.sin(A1 + F_r) + 127 * Math.sin((Lp - Mp) * DEG) - 115 * Math.sin((Lp + Mp) * DEG) -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 // Moon ecliptic coordinates
const moonLonDeg = Lp + Sl * 1e-6 const moonLonDeg = Lp + Sl * 1e-6
const moonLatDeg = Sb * 1e-6 const moonLatDeg = Sb * 1e-6
const moonDistKm = 385000.56 + Sr * 0.001 const moonDistKm = 385000.56 + Sr * 0.001
const moonLon_rad = moonLonDeg * DEG const moonLon_rad = moonLonDeg * DEG2RAD
const moonLat_rad = moonLatDeg * DEG const moonLat_rad = moonLatDeg * DEG2RAD
// Ecliptic to equatorial (GCRS ≈ J2000 equatorial for this accuracy level) // Ecliptic to equatorial (GCRS ≈ J2000 equatorial for this accuracy level)
const moonGCRS: Vec3 = [ const moonGCRS: Vec3 = [
moonDistKm * Math.cos(moonLat_rad) * Math.cos(moonLon_rad), 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 *
moonDistKm * (Math.sin(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) + Math.cos(eps) * Math.sin(moonLat_rad)), (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 } return { moonGCRS, sunGCRS }
@ -355,48 +381,49 @@ export function nearestNewMoon(jdTT: number): number {
const T = k / 1236.85 const T = k / 1236.85
// JDE of mean new moon (Meeus Eq. 49.1) // JDE of mean new moon (Meeus Eq. 49.1)
let JDE = 2451550.09766 let JDE =
+ 29.530588861 * k 2451550.09766 +
+ 0.00015437 * T * T 29.530588861 * k +
- 0.000000150 * T * T * T 0.00015437 * T * T -
+ 0.00000000073 * T * T * T * T 0.00000015 * T * T * T +
0.00000000073 * T * T * T * T
// Fundamental arguments for the corrections (degrees → radians) // 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 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) * DEG 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) * DEG 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) * DEG const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T + 0.00000215 * T * T * T) * DEG2RAD
// Eccentricity of Earth's orbit // Eccentricity of Earth's orbit
const E = 1 - 0.002516 * T - 0.0000074 * T * T const E = 1 - 0.002516 * T - 0.0000074 * T * T
// Corrections from Meeus Table 49.A (new moon) // Corrections from Meeus Table 49.A (new moon)
JDE += JDE +=
-0.40720 * Math.sin(Mp) -0.4072 * Math.sin(Mp) +
+ 0.17241 * E * Math.sin(M) 0.17241 * E * Math.sin(M) +
+ 0.01608 * Math.sin(2 * Mp) 0.01608 * Math.sin(2 * Mp) +
+ 0.01039 * Math.sin(2 * Fc) 0.01039 * Math.sin(2 * Fc) +
+ 0.00739 * E * Math.sin(Mp - M) 0.00739 * E * Math.sin(Mp - M) -
- 0.00514 * E * Math.sin(Mp + M) 0.00514 * E * Math.sin(Mp + M) +
+ 0.00208 * E * E * Math.sin(2 * M) 0.00208 * E * E * Math.sin(2 * M) -
- 0.00111 * Math.sin(Mp - 2 * Fc) 0.00111 * Math.sin(Mp - 2 * Fc) -
- 0.00057 * Math.sin(Mp + 2 * Fc) 0.00057 * Math.sin(Mp + 2 * Fc) +
+ 0.00056 * E * Math.sin(2 * Mp + M) 0.00056 * E * Math.sin(2 * Mp + M) -
- 0.00042 * Math.sin(3 * Mp) 0.00042 * Math.sin(3 * Mp) +
+ 0.00042 * E * Math.sin(M + 2 * Fc) 0.00042 * E * Math.sin(M + 2 * Fc) +
+ 0.00038 * E * Math.sin(M - 2 * Fc) 0.00038 * E * Math.sin(M - 2 * Fc) -
- 0.00024 * E * Math.sin(2 * Mp - M) 0.00024 * E * Math.sin(2 * Mp - M) -
- 0.00017 * Math.sin(Om) 0.00017 * Math.sin(Om) -
- 0.00007 * Math.sin(Mp + 2 * M) 0.00007 * Math.sin(Mp + 2 * M) +
+ 0.00004 * Math.sin(2 * Mp - 2 * Fc) 0.00004 * Math.sin(2 * Mp - 2 * Fc) +
+ 0.00004 * Math.sin(3 * M) 0.00004 * Math.sin(3 * M) +
+ 0.00003 * Math.sin(Mp + M - 2 * Fc) 0.00003 * Math.sin(Mp + M - 2 * Fc) +
+ 0.00003 * Math.sin(2 * Mp + 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.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(Mp - M - 2 * Fc) -
- 0.00002 * Math.sin(3 * Mp + M) 0.00002 * Math.sin(3 * Mp + M) +
+ 0.00002 * Math.sin(4 * Mp) 0.00002 * Math.sin(4 * Mp)
return JDE return JDE
} }

View file

@ -100,7 +100,9 @@ async function cmdSighting(cmdArgs: string[]) {
console.log(`Sunset: ${fmtDate(report.sunsetUTC)}`) console.log(`Sunset: ${fmtDate(report.sunsetUTC)}`)
console.log(`Moonset: ${fmtDate(report.moonsetUTC)}`) console.log(`Moonset: ${fmtDate(report.moonsetUTC)}`)
console.log(`Best time: ${fmtDate(report.bestTimeUTC)}`) 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('') console.log('')
if (report.geometry) { if (report.geometry) {
@ -151,7 +153,9 @@ async function cmdBenchmark() {
getMoonPhase(new Date(Date.UTC(2025, 2, 1 + (i % 28)))) getMoonPhase(new Date(Date.UTC(2025, 2, 1 + (i % 28))))
} }
const phaseMs = performance.now() - phaseStart 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 // Benchmark 2: kernel load
const loadStart = performance.now() const loadStart = performance.now()
@ -170,10 +174,13 @@ async function cmdBenchmark() {
/** Format a nullable Date as a short UTC string. */ /** Format a nullable Date as a short UTC string. */
function fmtDate(d: Date | null): string { function fmtDate(d: Date | null): string {
if (!d) return 'N/A' 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)) console.error(err instanceof Error ? err.message : String(err))
process.exit(1) process.exit(1)
}) })

View file

@ -22,7 +22,8 @@
import type { Vec3, Observer, SunMoonEvents, TimeScales } from '../types.js' import type { Vec3, Observer, SunMoonEvents, TimeScales } from '../types.js'
import type { SpkKernel } from '../spk/index.js' import type { SpkKernel } from '../spk/index.js'
import { NAIF_IDS } 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 { import {
J2000, J2000,
SECONDS_PER_DAY, SECONDS_PER_DAY,
@ -33,7 +34,11 @@ import {
getDeltaAT, getDeltaAT,
TT_MINUS_TAI, TT_MINUS_TAI,
} from '../time/index.js' } 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 { geodeticToECEF, computeAzAlt } from '../observer/index.js'
import { itrsToGcrs } from '../frames/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') * 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. * 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 ───────────────────────────────────────────────────────── // ─── Internal helpers ─────────────────────────────────────────────────────────
@ -130,7 +135,7 @@ export function findAltitudeCrossing(
const f = (et: number) => altitudeMinusThreshold(kernel, naifId, observer, et, threshold) 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) const nSteps = Math.ceil((endET - startET) / STEP_S)
let prevET = startET let prevET = startET
@ -140,11 +145,11 @@ export function findAltitudeCrossing(
const currET = Math.min(startET + i * STEP_S, endET) const currET = Math.min(startET + i * STEP_S, endET)
const currF = f(currET) 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 const isSettingCross = !rising && prevF >= 0 && currF < 0
if (isRisingCross || isSettingCross) { 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) { if (etCross !== null) {
const tsCross = etToTS(etCross) const tsCross = etToTS(etCross)
return tsCross.utc return tsCross.utc
@ -169,45 +174,90 @@ export function findAltitudeCrossing(
* @param kernel - DE442S kernel * @param kernel - DE442S kernel
* @returns SunMoonEvents with all event times in UTC, or null if event doesn't occur * @returns SunMoonEvents with all event times in UTC, or null if event doesn't occur
*/ */
export function getSunMoonEvents( export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKernel): SunMoonEvents {
date: Date,
observer: Observer,
kernel: SpkKernel,
): SunMoonEvents {
// Anchor search at UTC midnight of the input date // Anchor search at UTC midnight of the input date
const midnight = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())) const midnight = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
const jdMidnight = dateToJD(midnight) const jdMidnight = dateToJD(midnight)
// Approximate ET at midnight // Approximate ET at midnight
const etStart = jdTTtoET(jdMidnight + 70.0 / SECONDS_PER_DAY) // rough TT≈UTC+70s const etStart = jdTTtoET(jdMidnight + 70.0 / SECONDS_PER_DAY) // rough TT≈UTC+70s
const etEnd = etStart + 28 * 3600 // 28-hour window const etEnd = etStart + 28 * 3600 // 28-hour window
const ts0 = computeTimeScales(midnight) const ts0 = computeTimeScales(midnight)
// Sun events // Sun events
const sunriseUTC = findAltitudeCrossing( 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( 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°) // Twilight events (Sun setting below -6°, -12°, -18°)
const civilTwilightEndUTC = findAltitudeCrossing( 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( 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( 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 // Moon events
const moonriseUTC = findAltitudeCrossing( 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( 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 { return {
@ -241,7 +291,7 @@ export function bestTimeHeuristic(
moonsetUTC: Date, moonsetUTC: Date,
): { bestTimeUTC: Date; lagMinutes: number } | null { ): { bestTimeUTC: Date; lagMinutes: number } | null {
const lagMs = moonsetUTC.getTime() - sunsetUTC.getTime() 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 lagMinutes = lagMs / 60000
const bestTimeMs = sunsetUTC.getTime() + (4 / 9) * lagMs 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 * Find the optimal observation time by maximizing the Odeh V parameter
* over the interval [sunset, moonset]. * over the interval [sunset, moonset].
@ -290,9 +332,6 @@ export function bestTimeOptimized(
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation) const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation)
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000] 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 bestTimeUTC = sunsetUTC
let maxV = -Infinity let maxV = -Infinity
@ -302,14 +341,14 @@ export function bestTimeOptimized(
const et = jdTTtoET(ts.jdTT) const et = jdTTtoET(ts.jdTT)
const moonGCRS = getMoonGeocentricState(kernel, et).position 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) // Convert observer ITRS → GCRS at this timestep (Earth rotation changes per step)
const obsGCRS = itrsToGcrs(obsITRS, ts) const obsGCRS = itrsToGcrs(obsITRS, ts)
// Airless altitudes via the full pipeline // Airless altitudes via the full pipeline
const moonAzAlt = computeAzAlt(moonGCRS, observer, ts, true) 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 const ARCV = moonAzAlt.altitude - sunAzAlt.altitude
@ -324,11 +363,11 @@ export function bestTimeOptimized(
sunGCRS[1] - obsGCRS[1], sunGCRS[1] - obsGCRS[1],
sunGCRS[2] - obsGCRS[2], 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 ARCL = Math.acos(Math.max(-1, Math.min(1, cosARCL))) * (180 / Math.PI)
const { W } = computeCrescentWidth(moonTopo, ARCL) const { W } = computeCrescentWidth(moonTopo, ARCL)
const V = ARCV - odehArcvMin(W) const V = ARCV - arcvMinimum(W)
if (V > maxV) { if (V > maxV) {
maxV = V maxV = V
@ -347,13 +386,7 @@ export function bestTimeOptimized(
* @param windowMinutes - Half-width of window in minutes (default 20) * @param windowMinutes - Half-width of window in minutes (default 20)
* @returns [start, end] UTC Date pair * @returns [start, end] UTC Date pair
*/ */
export function computeObservationWindow( export function computeObservationWindow(bestTimeUTC: Date, windowMinutes = 20): [Date, Date] {
bestTimeUTC: Date,
windowMinutes = 20,
): [Date, Date] {
const windowMs = windowMinutes * 60000 const windowMs = windowMinutes * 60000
return [ return [new Date(bestTimeUTC.getTime() - windowMs), new Date(bestTimeUTC.getTime() + windowMs)]
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) // dpsi += (ps + pst*T)*sin(arg) + pc*cos(arg)
// deps += (ec + ect*T)*cos(arg) + es*sin(arg) // deps += (ec + ect*T)*cos(arg) + es*sin(arg)
const NUT_2000B: ReadonlyArray<readonly [ const NUT_2000B: ReadonlyArray<
number,number,number,number,number, readonly [number, number, number, number, number, number, number, number, number, number, number]
number,number,number, > = [
number,number,number
]> = [
// 1 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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) ──────────────────────────────────────── // ─── Fundamental arguments (Delaunay) ────────────────────────────────────────
@ -224,35 +222,35 @@ function arcsecToRad(arcsec: number): number {
/** Mean anomaly of the Moon l (IAU 2003) */ /** Mean anomaly of the Moon l (IAU 2003) */
function fundamentalL(T: number): number { function fundamentalL(T: number): number {
return arcsecToRad( 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) */ /** Mean anomaly of the Sun l' (IAU 2003) */
function fundamentalLp(T: number): number { function fundamentalLp(T: number): number {
return arcsecToRad( 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) */ /** Moon's argument of latitude F = L - Ω (IAU 2003) */
function fundamentalF(T: number): number { function fundamentalF(T: number): number {
return arcsecToRad( 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) */ /** Mean elongation of the Moon D (IAU 2003) */
function fundamentalD(T: number): number { function fundamentalD(T: number): number {
return arcsecToRad( 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) */ /** Longitude of Moon's ascending node Ω (IAU 2003) */
function fundamentalOm(T: number): number { function fundamentalOm(T: number): number {
return arcsecToRad( 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 * @param jdTT - Julian Date in TT
* @returns { X, Y, s } in radians * @returns { X, Y, s } in radians
*/ */
export function computeCIPXYs( export function computeCIPXYs(jdTT: number): { X: number; Y: number; s: number } {
jdTT: number,
): { X: number; Y: number; s: number } {
const T = (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY const T = (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY
// Delaunay fundamental arguments // Delaunay fundamental arguments
const l = fundamentalL(T) const l = fundamentalL(T)
const lp = fundamentalLp(T) const lp = fundamentalLp(T)
const F = fundamentalF(T) const F = fundamentalF(T)
const D = fundamentalD(T) const D = fundamentalD(T)
const Om = fundamentalOm(T) const Om = fundamentalOm(T)
// Accumulate nutation in longitude (dpsi) and obliquity (deps) — units: 0.1 uas // Accumulate nutation in longitude (dpsi) and obliquity (deps) — units: 0.1 uas
let dpsi = 0.0 let dpsi = 0.0
let deps = 0.0 let deps = 0.0
for (const [nl, nlp, nF, nD, nOm, ps, pst, pc, ec, ect, es] of NUT_2000B) { 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 sinA = Math.sin(arg)
const cosA = Math.cos(arg) const cosA = Math.cos(arg)
dpsi += (ps + pst * T) * sinA + pc * cosA dpsi += (ps + pst * T) * sinA + pc * cosA
@ -304,34 +299,24 @@ export function computeCIPXYs(
// Mean obliquity eps0 (IAU 2006, arcseconds → radians) // Mean obliquity eps0 (IAU 2006, arcseconds → radians)
// Reference: IERS Conventions (2010) Table 5.1 // Reference: IERS Conventions (2010) Table 5.1
const eps0 = ( const eps0 =
84381.406 (84381.406 +
+ T * (-46.836769 T *
+ T * (-0.0001831 (-46.836769 +
+ T * ( 0.00200340 T * (-0.0001831 + T * (0.0020034 + T * (-0.000000576 + T * -0.0000000434))))) *
+ T * (-0.000000576 ARCSEC_RAD
+ T * (-0.0000000434)))))
) * ARCSEC_RAD
// IAU 2006 precession polynomial for X (arcseconds) // IAU 2006 precession polynomial for X (arcseconds)
// Reference: IERS Conventions (2010) Table 5.2a, polynomial s_X // Reference: IERS Conventions (2010) Table 5.2a, polynomial s_X
const Xarcsec = const Xarcsec =
-0.016617 -0.016617 +
+ T * ( 2004.191898 T * (2004.191898 + T * (-0.4297829 + T * (-0.19861834 + T * (0.000007578 + T * 0.0000059285))))
+ T * ( -0.4297829
+ T * ( -0.19861834
+ T * ( 0.000007578
+ T * 0.0000059285))))
// IAU 2006 precession polynomial for Y (arcseconds) // IAU 2006 precession polynomial for Y (arcseconds)
// Reference: IERS Conventions (2010) Table 5.2a, polynomial s_Y // Reference: IERS Conventions (2010) Table 5.2a, polynomial s_Y
const Yarcsec = const Yarcsec =
-0.006951 -0.006951 +
+ T * ( -0.025896 T * (-0.025896 + T * (-22.4072747 + T * (0.00190059 + T * (0.001112526 + T * 0.0000001358))))
+ T * ( -22.4072747
+ T * ( 0.00190059
+ T * ( 0.001112526
+ T * 0.0000001358))))
// CIP X, Y: precession polynomial + first-order nutation correction // CIP X, Y: precession polynomial + first-order nutation correction
const X = Xarcsec * ARCSEC_RAD + dpsiRad * Math.sin(eps0) 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) // CIO locator s ≈ -X·Y/2 + small polynomial (IERS Conventions 2010 Eq. 5.9)
// Polynomial term: s_poly ≈ -0.041775"·T (arcseconds) // Polynomial term: s_poly ≈ -0.041775"·T (arcseconds)
const sPoly = -0.041775 * T * ARCSEC_RAD const sPoly = -0.041775 * T * ARCSEC_RAD
const s = -X * Y / 2 + sPoly const s = (-X * Y) / 2 + sPoly
return { X, Y, s } return { X, Y, s }
} }
@ -361,7 +346,7 @@ export function computeCIPXYs(
*/ */
export function computeERA(jdUT1: number): number { export function computeERA(jdUT1: number): number {
const Du = jdUT1 - 2451545.0 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) 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) * @param yp - Polar motion y (radians, default 0)
* @returns Vector in ITRS frame (km) * @returns Vector in ITRS frame (km)
*/ */
export function gcrsToItrs( export function gcrsToItrs(gcrsVec: Vec3, ts: TimeScales, xp = 0, yp = 0): Vec3 {
gcrsVec: Vec3,
ts: TimeScales,
xp = 0,
yp = 0,
): Vec3 {
const { X, Y, s } = computeCIPXYs(ts.jdTT) const { X, Y, s } = computeCIPXYs(ts.jdTT)
const Q = celestialMotionMatrix(X, Y, s) const Q = celestialMotionMatrix(X, Y, s)
const era = computeERA(ts.jdUT1) const era = computeERA(ts.jdUT1)
@ -449,12 +429,7 @@ export function gcrsToItrs(
* @param yp - Polar motion y (radians, default 0) * @param yp - Polar motion y (radians, default 0)
* @returns Vector in GCRS frame (km) * @returns Vector in GCRS frame (km)
*/ */
export function itrsToGcrs( export function itrsToGcrs(itrsVec: Vec3, ts: TimeScales, xp = 0, yp = 0): Vec3 {
itrsVec: Vec3,
ts: TimeScales,
xp = 0,
yp = 0,
): Vec3 {
const { X, Y, s } = computeCIPXYs(ts.jdTT) const { X, Y, s } = computeCIPXYs(ts.jdTT)
const Q = celestialMotionMatrix(X, Y, s) const Q = celestialMotionMatrix(X, Y, s)
const era = computeERA(ts.jdUT1) const era = computeERA(ts.jdUT1)

View file

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

View file

@ -21,6 +21,7 @@
import type { Vec3, Observer, AzAlt, TimeScales } from '../types.js' import type { Vec3, Observer, AzAlt, TimeScales } from '../types.js'
import { WGS84 } from '../types.js' import { WGS84 } from '../types.js'
import { gcrsToItrs } from '../frames/index.js' import { gcrsToItrs } from '../frames/index.js'
import { vdot } from '../math/index.js'
// ─── Geodetic ↔ ECEF ───────────────────────────────────────────────────────── // ─── 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 } { export function computeENUBasis(lat: number, lon: number): { east: Vec3; north: Vec3; up: Vec3 } {
const phi = (lat * Math.PI) / 180 const phi = (lat * Math.PI) / 180
const lam = (lon * Math.PI) / 180 const lam = (lon * Math.PI) / 180
const sinPhi = Math.sin(phi), cosPhi = Math.cos(phi) const sinPhi = Math.sin(phi),
const sinLam = Math.sin(lam), cosLam = Math.cos(lam) cosPhi = Math.cos(phi)
const sinLam = Math.sin(lam),
cosLam = Math.cos(lam)
const east: Vec3 = [-sinLam, cosLam, 0] const east: Vec3 = [-sinLam, cosLam, 0]
const north: Vec3 = [-sinPhi * cosLam, -sinPhi * sinLam, cosPhi] 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 } 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 { export function ecefToENU(ecefDelta: Vec3, lat: number, lon: number): Vec3 {
const { east, north, up } = computeENUBasis(lat, lon) 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 [vdot(ecefDelta, east), vdot(ecefDelta, north), vdot(ecefDelta, up)]
return [dot(ecefDelta, east), dot(ecefDelta, north), dot(ecefDelta, up)]
} }
/** /**
@ -185,7 +187,6 @@ export function computeAzAlt(
ts: TimeScales, ts: TimeScales,
airless: boolean, airless: boolean,
): AzAlt { ): AzAlt {
// 1. Convert body position from GCRS to ITRS (km) // 1. Convert body position from GCRS to ITRS (km)
const bodyITRS = gcrsToItrs(bodyGCRS, ts) const bodyITRS = gcrsToItrs(bodyGCRS, ts)
@ -194,11 +195,7 @@ export function computeAzAlt(
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000] 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) // 3. Displacement vector from observer to body in ITRS (km — magnitude doesn't matter)
const delta: Vec3 = [ const delta: Vec3 = [bodyITRS[0] - obsITRS[0], bodyITRS[1] - obsITRS[1], bodyITRS[2] - obsITRS[2]]
bodyITRS[0] - obsITRS[0],
bodyITRS[1] - obsITRS[1],
bodyITRS[2] - obsITRS[2],
]
// 4. Project onto local ENU basis at the observer's location // 4. Project onto local ENU basis at the observer's location
const enu = ecefToENU(delta, observer.lat, observer.lon) const enu = ecefToENU(delta, observer.lat, observer.lon)
@ -208,11 +205,7 @@ export function computeAzAlt(
// 6. Refraction correction // 6. Refraction correction
if (!airless) { if (!airless) {
azAlt.altitude = applyRefraction( azAlt.altitude = applyRefraction(azAlt.altitude, observer.pressure, observer.temperature)
azAlt.altitude,
observer.pressure,
observer.temperature,
)
} }
return azAlt return azAlt
@ -261,11 +254,7 @@ export function bennettRefraction(
* Apply refraction correction to an airless altitude. * Apply refraction correction to an airless altitude.
* Returns the apparent (observed) altitude. * Returns the apparent (observed) altitude.
*/ */
export function applyRefraction( export function applyRefraction(airlessAlt: number, pressure = 1013.25, temperature = 15): number {
airlessAlt: number,
pressure = 1013.25,
temperature = 15,
): number {
return airlessAlt + bennettRefraction(airlessAlt, pressure, temperature) 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 */ /** NAIF integer body IDs used in DE442S segment chaining */
export const NAIF_IDS = { export const NAIF_IDS = {
SSB: 0, // Solar System Barycenter SSB: 0, // Solar System Barycenter
MERCURY_BARYCENTER: 1, MERCURY_BARYCENTER: 1,
VENUS_BARYCENTER: 2, VENUS_BARYCENTER: 2,
EMB: 3, // Earth-Moon Barycenter EMB: 3, // Earth-Moon Barycenter
MARS_BARYCENTER: 4, MARS_BARYCENTER: 4,
JUPITER_BARYCENTER: 5, JUPITER_BARYCENTER: 5,
SATURN_BARYCENTER: 6, SATURN_BARYCENTER: 6,
@ -107,7 +107,7 @@ export class SpkKernel {
private findSeg(target: number, center: number, et: number): SpkSegment | null { private findSeg(target: number, center: number, et: number): SpkSegment | null {
const candidates = this.index.get(`${target}:${center}`) const candidates = this.index.get(`${target}:${center}`)
if (!candidates) return null 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 { private getChained(target: number, center: number, et: number): StateVector {
@ -174,7 +174,12 @@ export class SpkKernel {
// ─── DAF parsing ────────────────────────────────────────────────────────────── // ─── DAF parsing ──────────────────────────────────────────────────────────────
function parseDafFileRecord(buffer: ArrayBuffer): { 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) const dv = new DataView(buffer)
@ -214,7 +219,7 @@ function parseSummaryRecords(
const nextRecord = dv.getFloat64(recOffset, le) const nextRecord = dv.getFloat64(recOffset, le)
const nSummaries = Math.round(dv.getFloat64(recOffset + 16, 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++) { for (let i = 0; i < nSummaries; i++) {
if (offset + summaryBytes > buffer.byteLength) break if (offset + summaryBytes > buffer.byteLength) break
@ -383,8 +388,18 @@ export function parseLsk(text: string): ReadonlyArray<readonly [number, number]>
const block = match[1] const block = match[1]
const months: Record<string, number> = { const months: Record<string, number> = {
JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JAN: 1,
JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12, 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 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 a = Math.floor((14 - month) / 12)
const y = year + 4800 - a const y = year + 4800 - a
const mo = month + 12 * a - 3 const mo = month + 12 * a - 3
const jdNoon = day + Math.floor((153 * mo + 2) / 5) + 365 * y + const jdNoon =
Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045 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 // Midnight = JD - 0.5
results.push([jdNoon - 0.5, deltaAT]) results.push([jdNoon - 0.5, deltaAT])
} }

View file

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

View file

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

View file

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

View file

@ -2,10 +2,11 @@
/** /**
* moon-sighting CJS test suite * moon-sighting CJS test suite
* Runs with: node test-cjs.cjs * Runs with: node --test test-cjs.cjs
* Verifies the CommonJS build is functional. * Verifies the CommonJS build is functional.
*/ */
const { describe, it } = require('node:test')
const assert = require('node:assert/strict') const assert = require('node:assert/strict')
const { const {
YALLOP_THRESHOLDS, YALLOP_THRESHOLDS,
@ -25,142 +26,122 @@ const {
getSunMoonEvents, getSunMoonEvents,
} = require('./dist/index.cjs') } = require('./dist/index.cjs')
let passed = 0 describe('CJS compatibility', () => {
let failed = 0 it('require() works', () => {
assert.ok(YALLOP_THRESHOLDS !== undefined)
function test(name, fn) { })
try { it('YALLOP_THRESHOLDS.A is 0.216', () => {
fn() assert.equal(YALLOP_THRESHOLDS.A, 0.216)
console.log(` [${name}]... PASS`) })
passed++ it('ODEH_THRESHOLDS.A is 5.65', () => {
} catch (err) { assert.equal(ODEH_THRESHOLDS.A, 5.65)
console.error(` [${name}]... FAIL: ${err.message}`) })
failed++ it('WGS84.a is 6378137.0', () => {
} assert.equal(WGS84.a, 6378137.0)
} })
it('All API functions are exported', () => {
console.log('CJS compatibility:') assert.equal(typeof getMoonPhase, 'function')
assert.equal(typeof getMoonPosition, 'function')
test('require() works', () => { assert.equal(typeof getMoonIllumination, 'function')
assert.ok(YALLOP_THRESHOLDS !== undefined) assert.equal(typeof getMoonVisibilityEstimate, 'function')
}) assert.equal(typeof getMoon, 'function')
test('YALLOP_THRESHOLDS.A is 0.216', () => { assert.equal(typeof initKernels, 'function')
assert.equal(YALLOP_THRESHOLDS.A, 0.216) assert.equal(typeof downloadKernels, 'function')
}) assert.equal(typeof verifyKernels, 'function')
test('ODEH_THRESHOLDS.A is 5.65', () => { assert.equal(typeof getMoonSightingReport, 'function')
assert.equal(ODEH_THRESHOLDS.A, 5.65) assert.equal(typeof getSunMoonEvents, 'function')
}) })
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')
}) })
console.log('\nCJS getMoonPhase:') describe('CJS getMoonPhase', () => {
it('returns valid phase', () => {
test('getMoonPhase returns valid phase', () => { const valid = new Set([
const valid = new Set([ 'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous',
'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous', 'full-moon', 'waning-gibbous', 'last-quarter', 'waning-crescent',
'full-moon', 'waning-gibbous', 'last-quarter', 'waning-crescent', ])
]) const p = getMoonPhase(new Date('2025-03-14T12:00:00Z'))
const p = getMoonPhase(new Date('2025-03-14T12:00:00Z')) assert.ok(valid.has(p.phase), `got: ${p.phase}`)
assert.ok(valid.has(p.phase), `got: ${p.phase}`) })
}) it('illumination in [0, 100]', () => {
test('getMoonPhase illumination in [0, 100]', () => { const p = getMoonPhase(new Date('2025-03-01T12:00:00Z'))
const p = getMoonPhase(new Date('2025-03-01T12:00:00Z')) assert.ok(p.illumination >= 0 && p.illumination <= 100)
assert.ok(p.illumination >= 0 && p.illumination <= 100) })
}) it('near full moon has high illumination', () => {
test('getMoonPhase near full moon has high illumination', () => { const p = getMoonPhase(new Date('2025-03-14T12:00:00Z'))
const p = getMoonPhase(new Date('2025-03-14T12:00:00Z')) assert.ok(p.illumination > 85, `illumination=${p.illumination.toFixed(1)}%`)
assert.ok(p.illumination > 85, `illumination=${p.illumination.toFixed(1)}%`) })
}) it('Dates are Date objects', () => {
test('getMoonPhase Dates are Date objects', () => { const p = getMoonPhase(new Date('2025-03-01T12:00:00Z'))
const p = getMoonPhase(new Date('2025-03-01T12:00:00Z')) assert.ok(p.nextNewMoon instanceof Date)
assert.ok(p.nextNewMoon instanceof Date) assert.ok(p.prevNewMoon instanceof Date)
assert.ok(p.prevNewMoon instanceof Date) assert.ok(p.nextFullMoon instanceof Date)
assert.ok(p.nextFullMoon instanceof Date) })
}) })
console.log('\nCJS getMoonPosition + getMoonIllumination:') describe('CJS getMoonPosition and getMoonIllumination', () => {
it('getMoonPosition returns valid azimuth/altitude', () => {
test('getMoonPosition returns valid azimuth/altitude', () => { const pos = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10)
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.azimuth >= 0 && pos.azimuth < 360, `azimuth=${pos.azimuth}`) assert.ok(pos.altitude >= -90 && pos.altitude <= 90, `altitude=${pos.altitude}`)
assert.ok(pos.altitude >= -90 && pos.altitude <= 90, `altitude=${pos.altitude}`) assert.ok(pos.distance > 356000 && pos.distance < 407000, `distance=${pos.distance}`)
assert.ok(pos.distance > 356000 && pos.distance < 407000, `distance=${pos.distance}`) assert.ok(isFinite(pos.parallacticAngle))
assert.ok(isFinite(pos.parallacticAngle)) })
}) it('getMoonIllumination near full moon: fraction > 0.85', () => {
test('getMoonIllumination near full moon: fraction > 0.85', () => { const illum = getMoonIllumination(new Date('2025-03-14T12:00:00Z'))
const illum = getMoonIllumination(new Date('2025-03-14T12:00:00Z')) assert.ok(illum.fraction > 0.85, `fraction=${illum.fraction.toFixed(3)}`)
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(illum.phase > 0.4 && illum.phase < 0.6, `phase=${illum.phase.toFixed(3)}`) assert.ok(isFinite(illum.angle))
assert.ok(isFinite(illum.angle)) })
}) it('getMoonIllumination waxing: isWaxing = true', () => {
test('getMoonIllumination waxing: isWaxing = true', () => { const illum = getMoonIllumination(new Date('2025-03-05T12:00:00Z'))
const illum = getMoonIllumination(new Date('2025-03-05T12:00:00Z')) assert.equal(illum.isWaxing, true)
assert.equal(illum.isWaxing, true) })
}) })
console.log('\nCJS getMoonPhase phaseName/phaseSymbol:') describe('CJS getMoonPhase phaseName/phaseSymbol', () => {
it('phaseName is a non-empty string', () => {
test('getMoonPhase.phaseName is a non-empty string', () => { const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) assert.ok(typeof p.phaseName === 'string' && p.phaseName.length > 0)
assert.ok(typeof p.phaseName === 'string' && p.phaseName.length > 0) })
}) it('phaseSymbol is a moon emoji', () => {
test('getMoonPhase.phaseSymbol is a moon emoji', () => { const SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'])
const SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']) const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) assert.ok(SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
assert.ok(SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) })
}) it('Waxing crescent: correct phaseName and phaseSymbol', () => {
test('Waxing crescent: phaseName = "Waxing Crescent", phaseSymbol = "🌒"', () => { const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) assert.equal(p.phaseName, 'Waxing Crescent')
assert.equal(p.phaseName, 'Waxing Crescent') assert.equal(p.phaseSymbol, '🌒')
assert.equal(p.phaseSymbol, '🌒') })
}) })
console.log('\nCJS getMoonVisibilityEstimate:') describe('CJS getMoonVisibilityEstimate', () => {
it('returns valid zone', () => {
test('getMoonVisibilityEstimate returns valid zone', () => { const v = getMoonVisibilityEstimate(new Date('2025-03-02T18:30:00Z'), 51.5074, -0.1278, 10)
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(['A', 'B', 'C', 'D'].includes(v.zone), `zone=${v.zone}`) assert.ok(isFinite(v.V))
assert.ok(isFinite(v.V)) assert.equal(v.isApproximate, true)
assert.equal(v.isApproximate, true) })
}) it('near new moon: zone C or D', () => {
test('getMoonVisibilityEstimate near new moon: zone C or D', () => { const v = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262)
const v = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262) assert.ok(['C', 'D'].includes(v.zone), `zone=${v.zone}`)
assert.ok(['C', 'D'].includes(v.zone), `zone=${v.zone}`) })
}) })
console.log('\nCJS getMoon:') describe('CJS getMoon', () => {
it('returns all four sub-results', () => {
test('getMoon returns all four sub-results', () => { const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10)
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.phase === 'object') assert.ok(typeof m.position === 'object')
assert.ok(typeof m.position === 'object') assert.ok(typeof m.illumination === 'object')
assert.ok(typeof m.illumination === 'object') assert.ok(typeof m.visibility === '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 * moon-sighting ESM test suite
* Runs with: node test.mjs * Runs with: node --test test.mjs
* All tests use plain assert no test framework.
*/ */
import { describe, it } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { import {
@ -26,429 +26,392 @@ import {
getSunMoonEvents, getSunMoonEvents,
} from './dist/index.mjs' } 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 ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
console.log('Constants:') describe('Constants', () => {
it('YALLOP_THRESHOLDS.A is 0.216', () => {
test('YALLOP_THRESHOLDS.A is 0.216', () => { assert.equal(YALLOP_THRESHOLDS.A, 0.216)
assert.equal(YALLOP_THRESHOLDS.A, 0.216) })
}) it('YALLOP_THRESHOLDS.E is -0.293', () => {
test('YALLOP_THRESHOLDS.E is -0.293', () => { assert.equal(YALLOP_THRESHOLDS.E, -0.293)
assert.equal(YALLOP_THRESHOLDS.E, -0.293) })
}) it('All Yallop thresholds are defined', () => {
test('All Yallop thresholds are defined', () => { for (const key of ['A', 'B', 'C', 'D', 'E']) {
for (const key of ['A', 'B', 'C', 'D', 'E']) { assert.ok(typeof YALLOP_THRESHOLDS[key] === 'number', `${key} should be a number`)
assert.ok(typeof YALLOP_THRESHOLDS[key] === 'number', `${key} should be a number`) }
} })
}) it('Yallop thresholds descend A > B > C > D > E', () => {
test('Yallop thresholds descend A > B > C > D > E', () => { assert.ok(YALLOP_THRESHOLDS.A > YALLOP_THRESHOLDS.B)
assert.ok(YALLOP_THRESHOLDS.A > YALLOP_THRESHOLDS.B) assert.ok(YALLOP_THRESHOLDS.B > YALLOP_THRESHOLDS.C)
assert.ok(YALLOP_THRESHOLDS.B > YALLOP_THRESHOLDS.C) assert.ok(YALLOP_THRESHOLDS.C > YALLOP_THRESHOLDS.D)
assert.ok(YALLOP_THRESHOLDS.C > YALLOP_THRESHOLDS.D) assert.ok(YALLOP_THRESHOLDS.D > YALLOP_THRESHOLDS.E)
assert.ok(YALLOP_THRESHOLDS.D > YALLOP_THRESHOLDS.E) })
}) it('ODEH_THRESHOLDS.A is 5.65', () => {
test('ODEH_THRESHOLDS.A is 5.65', () => { assert.equal(ODEH_THRESHOLDS.A, 5.65)
assert.equal(ODEH_THRESHOLDS.A, 5.65) })
}) it('ODEH_THRESHOLDS.C is -0.96', () => {
test('ODEH_THRESHOLDS.C is -0.96', () => { assert.equal(ODEH_THRESHOLDS.C, -0.96)
assert.equal(ODEH_THRESHOLDS.C, -0.96) })
}) it('Odeh thresholds descend A > B > C', () => {
test('Odeh thresholds descend A > B > C', () => { assert.ok(ODEH_THRESHOLDS.A > ODEH_THRESHOLDS.B)
assert.ok(ODEH_THRESHOLDS.A > ODEH_THRESHOLDS.B) assert.ok(ODEH_THRESHOLDS.B > ODEH_THRESHOLDS.C)
assert.ok(ODEH_THRESHOLDS.B > ODEH_THRESHOLDS.C) })
}) it('WGS84.a is 6378137.0', () => {
test('WGS84.a is 6378137.0', () => { assert.equal(WGS84.a, 6378137.0)
assert.equal(WGS84.a, 6378137.0) })
}) it('WGS84.invF is 298.257223563', () => {
test('WGS84.invF is 298.257223563', () => { assert.equal(WGS84.invF, 298.257223563)
assert.equal(WGS84.invF, 298.257223563) })
}) it('WGS84.e2 is positive and < 1', () => {
test('WGS84.e2 is positive and < 1', () => { assert.ok(WGS84.e2 > 0 && WGS84.e2 < 1, `e2=${WGS84.e2}`)
assert.ok(WGS84.e2 > 0 && WGS84.e2 < 1, `e2=${WGS84.e2}`) })
}) it('WGS84.b < WGS84.a (oblate spheroid)', () => {
test('WGS84.b < WGS84.a (oblate spheroid)', () => { assert.ok(WGS84.b < WGS84.a)
assert.ok(WGS84.b < WGS84.a) })
}) it('Yallop descriptions are non-empty strings', () => {
test('Yallop descriptions are non-empty strings', () => { for (const cat of ['A', 'B', 'C', 'D', 'E', 'F']) {
for (const cat of ['A', 'B', 'C', 'D', 'E', 'F']) { assert.ok(typeof YALLOP_DESCRIPTIONS[cat] === 'string' && YALLOP_DESCRIPTIONS[cat].length > 0)
assert.ok(typeof YALLOP_DESCRIPTIONS[cat] === 'string' && YALLOP_DESCRIPTIONS[cat].length > 0) }
} })
}) it('Odeh descriptions are non-empty strings', () => {
test('Odeh descriptions are non-empty strings', () => { for (const zone of ['A', 'B', 'C', 'D']) {
for (const zone of ['A', 'B', 'C', 'D']) { assert.ok(typeof ODEH_DESCRIPTIONS[zone] === 'string' && ODEH_DESCRIPTIONS[zone].length > 0)
assert.ok(typeof ODEH_DESCRIPTIONS[zone] === 'string' && ODEH_DESCRIPTIONS[zone].length > 0) }
} })
}) })
// ─── API function exports ────────────────────────────────────────────────────── // ─── API function exports ──────────────────────────────────────────────────────
console.log('\nAPI exports:') describe('API exports', () => {
it('getMoonPhase is a function', () => {
test('getMoonPhase is a function', () => { assert.equal(typeof getMoonPhase, 'function')
assert.equal(typeof getMoonPhase, 'function') })
}) it('initKernels is a function', () => {
test('initKernels is a function', () => { assert.equal(typeof initKernels, 'function')
assert.equal(typeof initKernels, 'function') })
}) it('downloadKernels is a function', () => {
test('downloadKernels is a function', () => { assert.equal(typeof downloadKernels, 'function')
assert.equal(typeof downloadKernels, 'function') })
}) it('verifyKernels is a function', () => {
test('verifyKernels is a function', () => { assert.equal(typeof verifyKernels, 'function')
assert.equal(typeof verifyKernels, 'function') })
}) it('getMoonSightingReport is a function', () => {
test('getMoonSightingReport is a function', () => { assert.equal(typeof getMoonSightingReport, 'function')
assert.equal(typeof getMoonSightingReport, 'function') })
}) it('getSunMoonEvents is a function', () => {
test('getSunMoonEvents is a function', () => { assert.equal(typeof getSunMoonEvents, 'function')
assert.equal(typeof getSunMoonEvents, 'function') })
}) })
// ─── getMoonPhase (synchronous, no kernel) ───────────────────────────────────── // ─── getMoonPhase (synchronous, no kernel) ─────────────────────────────────────
console.log('\ngetMoonPhase — structure:')
const VALID_PHASES = new Set([ const VALID_PHASES = new Set([
'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous', 'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous',
'full-moon', 'waning-gibbous', 'last-quarter', 'waning-crescent', '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 DATE_MARCH_1_2025 = new Date('2025-03-01T12:00:00Z')
const phase_march1 = getMoonPhase(DATE_MARCH_1_2025) const phase_march1 = getMoonPhase(DATE_MARCH_1_2025)
test('getMoonPhase returns an object', () => { describe('getMoonPhase structure', () => {
assert.ok(phase_march1 !== null && typeof phase_march1 === 'object') it('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}`) it('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, it('illumination is in [0, 100]', () => {
`illumination=${phase_march1.illumination}`) 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}`) it('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, it('elongationDeg is in [0, 180]', () => {
`elongationDeg=${phase_march1.elongationDeg}`) 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') it('isWaxing is a boolean', () => {
}) assert.equal(typeof phase_march1.isWaxing, 'boolean')
test('getMoonPhase.nextNewMoon is a Date', () => { })
assert.ok(phase_march1.nextNewMoon instanceof Date) it('nextNewMoon is a Date', () => {
}) assert.ok(phase_march1.nextNewMoon instanceof Date)
test('getMoonPhase.prevNewMoon is a Date', () => { })
assert.ok(phase_march1.prevNewMoon instanceof Date) it('prevNewMoon is a Date', () => {
}) assert.ok(phase_march1.prevNewMoon instanceof Date)
test('getMoonPhase.nextFullMoon is a Date', () => { })
assert.ok(phase_march1.nextFullMoon instanceof Date) it('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, it('prevNewMoon is before reference date', () => {
`prevNewMoon=${phase_march1.prevNewMoon.toISOString()}`) 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) 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) it('near full moon: illumination > 85%', () => {
const DATE_FULL_MOON = new Date('2025-03-14T12:00:00Z') assert.ok(phase_full.illumination > 85,
const phase_full = getMoonPhase(DATE_FULL_MOON) `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%', () => { const DATE_NEW_MOON = new Date('2025-03-29T12:00:00Z')
assert.ok(phase_full.illumination > 85, const phase_new = getMoonPhase(DATE_NEW_MOON)
`illumination at full moon=${phase_full.illumination.toFixed(1)}%`)
}) it('near new moon: illumination < 10%', () => {
test('Near full moon: phase is full-moon or waxing/waning gibbous', () => { assert.ok(phase_new.illumination < 10,
const valid = new Set(['full-moon', 'waxing-gibbous', 'waning-gibbous']) `illumination at new moon=${phase_new.illumination.toFixed(1)}%`)
assert.ok(valid.has(phase_full.phase), `got: ${phase_full.phase}`) })
}) it('near new moon: elongation < 30 deg', () => {
test('Near full moon: elongation > 120°', () => { assert.ok(phase_new.elongationDeg < 30, `elongation=${phase_new.elongationDeg}`)
assert.ok(phase_full.elongationDeg > 120, `elongation=${phase_full.elongationDeg}`) })
}) })
// 2025-03-29 is close to new moon (illumination should be low) describe('getMoonPhase consistency', () => {
const DATE_NEW_MOON = new Date('2025-03-29T12:00:00Z') const DATE_WAXING = new Date('2025-03-05T12:00:00Z')
const phase_new = getMoonPhase(DATE_NEW_MOON) const DATE_WANING = new Date('2025-03-20T12:00:00Z')
test('Near new moon: illumination < 10%', () => { it('5 days after new moon: isWaxing = true', () => {
assert.ok(phase_new.illumination < 10, assert.equal(getMoonPhase(DATE_WAXING).isWaxing, true)
`illumination at new moon=${phase_new.illumination.toFixed(1)}%`) })
}) it('6 days after full moon: isWaxing = false', () => {
test('Near new moon: elongation < 30°', () => { assert.equal(getMoonPhase(DATE_WANING).isWaxing, false)
assert.ok(phase_new.elongationDeg < 30, `elongation=${phase_new.elongationDeg}`) })
}) it('default date (now) returns valid result', () => {
const nowPhase = getMoonPhase()
console.log('\ngetMoonPhase — consistency:') assert.ok(VALID_PHASES.has(nowPhase.phase))
assert.ok(nowPhase.illumination >= 0 && nowPhase.illumination <= 100)
// Two dates: one clearly waxing, one clearly waning })
const DATE_WAXING = new Date('2025-03-05T12:00:00Z') // ~7 days after new moon it('synodic month duration is ~29.5 days', () => {
const DATE_WANING = new Date('2025-03-20T12:00:00Z') // ~6 days after full moon const synodicMs = phase_march1.nextNewMoon.getTime() - phase_march1.prevNewMoon.getTime()
const phase_waxing = getMoonPhase(DATE_WAXING) const synodicDays = synodicMs / 86400000
const phase_waning = getMoonPhase(DATE_WANING) assert.ok(
synodicDays > 29.0 && synodicDays < 30.1,
test('5 days after new moon: isWaxing = true', () => { `synodic month=${synodicDays.toFixed(2)} days`,
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`,
)
}) })
// ─── getMoonPosition ───────────────────────────────────────────────────────── // ─── 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 it('azimuth in [0, 360)', () => {
const moonPos_london = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10) assert.ok(moonPos_london.azimuth >= 0 && moonPos_london.azimuth < 360,
`azimuth=${moonPos_london.azimuth}`)
test('getMoonPosition returns azimuth in [0, 360)', () => { })
assert.ok( it('altitude in [-90, 90]', () => {
moonPos_london.azimuth >= 0 && moonPos_london.azimuth < 360, assert.ok(moonPos_london.altitude >= -90 && moonPos_london.altitude <= 90,
`azimuth=${moonPos_london.azimuth}`, `altitude=${moonPos_london.altitude}`)
) })
}) it('distance in lunar orbit range [356000, 407000] km', () => {
test('getMoonPosition returns altitude in [-90, 90]', () => { assert.ok(moonPos_london.distance >= 356000 && moonPos_london.distance <= 407000,
assert.ok( `distance=${moonPos_london.distance.toFixed(0)} km`)
moonPos_london.altitude >= -90 && moonPos_london.altitude <= 90, })
`altitude=${moonPos_london.altitude}`, it('finite parallacticAngle', () => {
) assert.ok(isFinite(moonPos_london.parallacticAngle),
}) `parallacticAngle=${moonPos_london.parallacticAngle}`)
test('getMoonPosition returns distance in lunar orbit range [356000, 407000] km', () => { })
assert.ok( it('default date (now) returns valid result', () => {
moonPos_london.distance >= 356000 && moonPos_london.distance <= 407000, const pos = getMoonPosition(new Date(), 21.4225, 39.8262)
`distance=${moonPos_london.distance.toFixed(0)} km`, assert.ok(pos.azimuth >= 0 && pos.azimuth < 360)
) assert.ok(pos.altitude >= -90 && pos.altitude <= 90)
}) assert.ok(pos.distance > 350000 && pos.distance < 410000)
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)
}) })
// ─── getMoonIllumination ───────────────────────────────────────────────────── // ─── 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 it('near full moon: fraction > 0.85', () => {
const illum_full = getMoonIllumination(new Date('2025-03-14T12:00:00Z')) assert.ok(illum_full.fraction > 0.85, `fraction=${illum_full.fraction.toFixed(3)}`)
// 2025-03-29 was close to new moon })
const illum_new = getMoonIllumination(new Date('2025-03-29T12:00:00Z')) it('near full moon: phase close to 0.5', () => {
// 2025-03-05 was waxing crescent (~7 days after new moon) assert.ok(illum_full.phase > 0.4 && illum_full.phase < 0.6,
const illum_waxing = getMoonIllumination(new Date('2025-03-05T12:00:00Z')) `phase=${illum_full.phase.toFixed(3)}`)
})
test('getMoonIllumination near full moon: fraction > 0.85', () => { it('near new moon: fraction < 0.05', () => {
assert.ok(illum_full.fraction > 0.85, `fraction=${illum_full.fraction.toFixed(3)}`) assert.ok(illum_new.fraction < 0.05, `fraction=${illum_new.fraction.toFixed(3)}`)
}) })
test('getMoonIllumination near full moon: phase close to 0.5', () => { it('near new moon: phase close to 0 or 1', () => {
assert.ok( const p = illum_new.phase
illum_full.phase > 0.4 && illum_full.phase < 0.6, assert.ok(p < 0.08 || p > 0.92, `phase=${p.toFixed(3)}`)
`phase=${illum_full.phase.toFixed(3)}`, })
) it('waxing: isWaxing = true', () => {
}) assert.equal(illum_waxing.isWaxing, true)
test('getMoonIllumination near new moon: fraction < 0.05', () => { })
assert.ok(illum_new.fraction < 0.05, `fraction=${illum_new.fraction.toFixed(3)}`) it('fraction in [0, 1]', () => {
}) assert.ok(illum_full.fraction >= 0 && illum_full.fraction <= 1)
test('getMoonIllumination near new moon: phase close to 0 or 1', () => { assert.ok(illum_new.fraction >= 0 && illum_new.fraction <= 1)
const p = illum_new.phase })
assert.ok(p < 0.08 || p > 0.92, `phase=${p.toFixed(3)}`) it('phase in [0, 1)', () => {
}) assert.ok(illum_full.phase >= 0 && illum_full.phase < 1)
test('getMoonIllumination waxing: isWaxing = true', () => { assert.ok(illum_new.phase >= 0 && illum_new.phase < 1)
assert.equal(illum_waxing.isWaxing, true) })
}) it('angle is finite', () => {
test('getMoonIllumination fraction in [0, 1]', () => { assert.ok(isFinite(illum_full.angle), `angle=${illum_full.angle}`)
assert.ok(illum_full.fraction >= 0 && illum_full.fraction <= 1) })
assert.ok(illum_new.fraction >= 0 && illum_new.fraction <= 1) it('default date (now) returns valid result', () => {
}) const illum = getMoonIllumination()
test('getMoonIllumination phase in [0, 1)', () => { assert.ok(illum.fraction >= 0 && illum.fraction <= 1)
assert.ok(illum_full.phase >= 0 && illum_full.phase < 1) assert.ok(illum.phase >= 0 && illum.phase < 1)
assert.ok(illum_new.phase >= 0 && illum_new.phase < 1) assert.equal(typeof illum.isWaxing, 'boolean')
}) assert.ok(isFinite(illum.angle))
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))
}) })
// ─── getMoonPhase phaseName + phaseSymbol ───────────────────────────────────── // ─── 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([ it('phaseName is a valid human-readable name', () => {
'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', const p = getMoonPhase(DATE_MARCH_1_2025)
'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent', assert.ok(PHASE_NAMES.has(p.phaseName), `got: ${p.phaseName}`)
]) })
const PHASE_SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']) it('phaseSymbol is a moon emoji', () => {
const p = getMoonPhase(DATE_MARCH_1_2025)
test('getMoonPhase.phaseName is a valid human-readable name', () => { assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
const p = getMoonPhase(DATE_MARCH_1_2025) })
assert.ok(PHASE_NAMES.has(p.phaseName), `got: ${p.phaseName}`) it('near full moon: phaseName is Full Moon or gibbous', () => {
}) const valid = new Set(['Full Moon', 'Waxing Gibbous', 'Waning Gibbous'])
test('getMoonPhase.phaseSymbol is a moon emoji', () => { const p = getMoonPhase(new Date('2025-03-14T12:00:00Z'))
const p = getMoonPhase(DATE_MARCH_1_2025) assert.ok(valid.has(p.phaseName), `got: ${p.phaseName}`)
assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) })
}) it('waxing crescent: phaseName is Waxing Crescent', () => {
test('Near full moon: phaseName is "Full Moon" or gibbous', () => { const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
const valid = new Set(['Full Moon', 'Waxing Gibbous', 'Waning Gibbous']) assert.equal(p.phaseName, 'Waxing Crescent')
const p = getMoonPhase(DATE_FULL_MOON) })
assert.ok(valid.has(p.phaseName), `got: ${p.phaseName}`) it('waxing crescent: phaseSymbol is correct', () => {
}) const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
test('Near full moon: phaseSymbol is 🌕 or 🌔 or 🌖', () => { assert.equal(p.phaseSymbol, '🌒')
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))
}) })
// ─── getMoonVisibilityEstimate ───────────────────────────────────────────────── // ─── 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) it('returns an object', () => {
const DATE_VIS_ESTIMATE = new Date('2025-03-02T18:30:00Z') assert.ok(vis !== null && typeof vis === 'object')
const vis = getMoonVisibilityEstimate(DATE_VIS_ESTIMATE, 51.5074, -0.1278, 10) })
it('zone is A, B, C, or D', () => {
test('getMoonVisibilityEstimate returns an object', () => { assert.ok(['A', 'B', 'C', 'D'].includes(vis.zone), `got: ${vis.zone}`)
assert.ok(vis !== null && typeof vis === 'object') })
}) it('V is finite', () => {
test('getMoonVisibilityEstimate.zone is A, B, C, or D', () => { assert.ok(isFinite(vis.V), `V=${vis.V}`)
assert.ok(['A', 'B', 'C', 'D'].includes(vis.zone), `got: ${vis.zone}`) })
}) it('ARCL is in [0, 180]', () => {
test('getMoonVisibilityEstimate.V is finite', () => { assert.ok(vis.ARCL >= 0 && vis.ARCL <= 180, `ARCL=${vis.ARCL}`)
assert.ok(isFinite(vis.V), `V=${vis.V}`) })
}) it('W >= 0', () => {
test('getMoonVisibilityEstimate.ARCL is in [0, 180]', () => { assert.ok(vis.W >= 0, `W=${vis.W}`)
assert.ok(vis.ARCL >= 0 && vis.ARCL <= 180, `ARCL=${vis.ARCL}`) })
}) it('isApproximate is true', () => {
test('getMoonVisibilityEstimate.W >= 0', () => { assert.equal(vis.isApproximate, true)
assert.ok(vis.W >= 0, `W=${vis.W}`) })
}) it('moonAboveHorizon is a boolean', () => {
test('getMoonVisibilityEstimate.isApproximate is true', () => { assert.equal(typeof vis.moonAboveHorizon, 'boolean')
assert.equal(vis.isApproximate, true) })
}) it('isVisibleNakedEye matches zone A', () => {
test('getMoonVisibilityEstimate.moonAboveHorizon is a boolean', () => { assert.equal(vis.isVisibleNakedEye, vis.zone === 'A')
assert.equal(typeof vis.moonAboveHorizon, 'boolean') })
}) it('isVisibleWithOpticalAid matches zone A or B', () => {
test('getMoonVisibilityEstimate.isVisibleNakedEye matches zone A', () => { assert.equal(vis.isVisibleWithOpticalAid, vis.zone === 'A' || vis.zone === 'B')
assert.equal(vis.isVisibleNakedEye, vis.zone === 'A') })
}) it('description is a non-empty string', () => {
test('getMoonVisibilityEstimate.isVisibleWithOpticalAid matches zone A or B', () => { assert.ok(typeof vis.description === 'string' && vis.description.length > 0)
assert.equal(vis.isVisibleWithOpticalAid, vis.zone === 'A' || vis.zone === 'B') })
}) it('default date works', () => {
test('getMoonVisibilityEstimate.description is a non-empty string', () => { const v = getMoonVisibilityEstimate(new Date(), 21.4225, 39.8262)
assert.ok(typeof vis.description === 'string' && vis.description.length > 0) assert.ok(['A', 'B', 'C', 'D'].includes(v.zone))
}) assert.ok(isFinite(v.V))
test('getMoonVisibilityEstimate default date works', () => { assert.equal(v.isApproximate, true)
const v = getMoonVisibilityEstimate(new Date(), 21.4225, 39.8262) })
assert.ok(['A', 'B', 'C', 'D'].includes(v.zone)) it('near new moon: zone is D or C', () => {
assert.ok(isFinite(v.V)) const nearNew = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262)
assert.equal(v.isApproximate, true) assert.ok(['C', 'D'].includes(nearNew.zone), `zone=${nearNew.zone} V=${nearNew.V.toFixed(2)}`)
}) })
// 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)}`)
}) })
// ─── getMoon ────────────────────────────────────────────────────────────────── // ─── 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) it('returns object with phase, position, illumination, visibility', () => {
assert.ok(typeof moon === 'object')
test('getMoon returns an object with phase, position, illumination, visibility', () => { assert.ok(typeof moon.phase === 'object')
assert.ok(typeof moon === 'object') assert.ok(typeof moon.position === 'object')
assert.ok(typeof moon.phase === 'object') assert.ok(typeof moon.illumination === 'object')
assert.ok(typeof moon.position === 'object') assert.ok(typeof moon.visibility === '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'))
test('getMoon.phase is consistent with getMoonPhase standalone', () => { assert.equal(moon.phase.phase, standalone.phase)
const standalone = getMoonPhase(new Date('2025-03-05T20:00:00Z')) assert.equal(moon.phase.phaseName, standalone.phaseName)
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)
test('getMoon.illumination.isWaxing matches phase.isWaxing', () => { })
assert.equal(moon.illumination.isWaxing, moon.phase.isWaxing) it('visibility.isApproximate is true', () => {
}) assert.equal(moon.visibility.isApproximate, true)
test('getMoon.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)
test('getMoon.position has valid azimuth and altitude', () => { assert.ok(moon.position.altitude >= -90 && moon.position.altitude <= 90)
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)
test('getMoon default date works', () => { assert.ok(isFinite(m.position.azimuth))
const m = getMoon(new Date(), 21.4225, 39.8262) assert.ok(isFinite(m.illumination.fraction))
assert.ok(PHASE_NAMES.has(m.phase.phaseName)) assert.ok(['A', 'B', 'C', 'D'].includes(m.visibility.zone))
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`) describe('Input validation', () => {
it('getMoonPhase rejects invalid date', () => {
if (failed > 0) { assert.throws(() => getMoonPhase(new Date('invalid')), /valid Date/)
process.exit(1) })
} 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", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"esModuleInterop": true, "esModuleInterop": true,
"declaration": true, "declaration": true,