From 8bf34fb6969414b6c3e02fe3f7afdd4326e29fd6 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 8 Mar 2026 11:39:28 -0400 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 29 +- .gitignore | 25 +- .markdownlint.json | 4 - .prettierrc | 7 + eslint.config.mjs | 12 + package.json | 27 +- pnpm-lock.yaml | 739 ++++++++++++++++++++++++++++++++++++++- src/api/index.ts | 221 ++++++------ src/bodies/index.ts | 307 ++++++++-------- src/cli/index.ts | 15 +- src/events/index.ts | 121 ++++--- src/frames/index.ts | 233 ++++++------ src/math/index.ts | 51 ++- src/observer/index.ts | 31 +- src/spk/index.ts | 39 ++- src/time/index.ts | 59 ++-- src/types.ts | 18 +- src/visibility/index.ts | 25 +- test-cjs.cjs | 239 ++++++------- test.mjs | 713 ++++++++++++++++++------------------- tsconfig.json | 3 + 21 files changed, 1887 insertions(+), 1031 deletions(-) delete mode 100644 .markdownlint.json create mode 100644 .prettierrc create mode 100644 eslint.config.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0cb8b2..c280377 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,26 @@ jobs: with: node-version: ${{ matrix.node }} cache: pnpm - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm build - - run: node test.mjs - - run: node test-cjs.cjs + - run: node --test test.mjs + - run: node --test test-cjs.cjs + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run lint + - run: pnpm run format:check typecheck: name: TypeScript @@ -39,7 +55,7 @@ jobs: with: node-version: 24 cache: pnpm - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm run typecheck pack-check: @@ -54,17 +70,16 @@ jobs: with: node-version: 24 cache: pnpm - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm build - name: Verify pack contents run: | npm pack --dry-run 2>&1 | tee pack-output.txt - # Verify expected files are present grep -q "dist/index.cjs" pack-output.txt || (echo "Missing dist/index.cjs" && exit 1) grep -q "dist/index.mjs" pack-output.txt || (echo "Missing dist/index.mjs" && exit 1) grep -q "dist/index.d.ts" pack-output.txt || (echo "Missing dist/index.d.ts" && exit 1) + grep -q "dist/index.d.mts" pack-output.txt || (echo "Missing dist/index.d.mts" && exit 1) grep -q "README.md" pack-output.txt || (echo "Missing README.md" && exit 1) - # Verify no test files or .gitignore items are included ! grep -q "test.mjs" pack-output.txt || (echo "test.mjs should not be in pack" && exit 1) ! grep -q "node_modules" pack-output.txt || (echo "node_modules should not be in pack" && exit 1) echo "Pack contents verified." diff --git a/.gitignore b/.gitignore index 639f17c..8287090 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ node_modules/ dist/ +build/ +out/ *.tgz *.log +*.tsbuildinfo .DS_Store .env .env.* @@ -11,8 +14,28 @@ dist/ *.bsp *.tls +# PnP +.pnp +.pnp.js + +# Coverage +coverage/ + +# IDE +.vscode/ +.idea/ +*.swp + # AI agent directories .claude/ .cursor/ -.aider/ +.copilot/ +.aider* .continue/ +.codex/ +.gemini/ +.vscode/* +.aider/ +.aider.chat.history.md +.windsurf/ +.codeium/ diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index 60cdd14..0000000 --- a/.markdownlint.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "MD013": false, - "MD024": { "siblings_only": true } -} diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..c03d068 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..e67747c --- /dev/null +++ b/eslint.config.mjs @@ -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'], + }, +) diff --git a/package.json b/package.json index 4544600..fc34d45 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,14 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.cjs" + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.cjs" + } } }, "bin": { @@ -31,14 +36,22 @@ "build": "tsup", "typecheck": "tsc --noEmit", "pretest": "tsup", - "test": "node test.mjs && node test-cjs.cjs", + "test": "node --test test.mjs test-cjs.cjs", + "lint": "eslint src/", + "format": "prettier --write src/", + "format:check": "prettier --check src/", "prepublishOnly": "tsup", "cli": "node dist/cli/index.cjs" }, "devDependencies": { - "@types/node": "latest", - "tsup": "latest", - "typescript": "latest" + "@eslint/js": "^10.0.1", + "@types/node": "^25.3.0", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.8.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.1" }, "publishConfig": { "access": "public", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90c4209..314488d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,30 @@ importers: .: devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.0.3) '@types/node': - specifier: latest + specifier: ^25.3.0 version: 25.3.0 + eslint: + specifier: ^10.0.3 + version: 10.0.3 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.0.3) + prettier: + specifier: ^3.8.1 + version: 3.8.1 tsup: - specifier: latest + specifier: ^8.5.1 version: 8.5.1(typescript@5.9.3) typescript: - specifier: latest + specifier: ^5.9.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.56.1 + version: 8.56.1(eslint@10.0.3)(typescript@5.9.3) packages: @@ -176,6 +191,61 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.3': + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.3': + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.1.1': + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.3': + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -327,20 +397,101 @@ packages: cpu: [x64] os: [win32] + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -366,6 +517,10 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -375,11 +530,75 @@ packages: supports-color: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.0.3: + resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -389,18 +608,76 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.4: + resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -412,9 +689,17 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -424,10 +709,33 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -463,6 +771,19 @@ packages: yaml: optional: true + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -476,6 +797,19 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -503,6 +837,12 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -525,6 +865,17 @@ packages: typescript: optional: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.56.1: + resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -536,6 +887,22 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + snapshots: '@esbuild/aix-ppc64@0.27.3': @@ -616,6 +983,51 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.3)': + dependencies: + eslint: 10.0.3 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.3': + dependencies: + '@eslint/object-schema': 3.0.3 + debug: 4.4.3 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.3': + dependencies: + '@eslint/core': 1.1.1 + + '@eslint/core@1.1.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.0.3)': + optionalDependencies: + eslint: 10.0.3 + + '@eslint/object-schema@3.0.3': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -705,16 +1117,128 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} + '@types/json-schema@7.0.15': {} + '@types/node@25.3.0': dependencies: undici-types: 7.18.2 + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 10.0.3 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + eslint: 10.0.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.1(eslint@10.0.3)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.0.3 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.1': {} + + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@10.0.3)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 10.0.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + any-promise@1.3.0: {} + balanced-match@4.0.4: {} + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + bundle-require@5.1.0(esbuild@0.27.3): dependencies: esbuild: 0.27.3 @@ -732,10 +1256,18 @@ snapshots: consola@3.4.2: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 + deep-is@0.1.4: {} + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -765,31 +1297,164 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.0.3): + dependencies: + eslint: 10.0.3 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.0.3: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.0 rollup: 4.59.0 + flat-cache@4.0.1: + dependencies: + flatted: 3.3.4 + keyv: 4.5.4 + + flatted@3.3.4: {} + fsevents@2.3.3: optional: true + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + joycon@3.1.1: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} load-tsconfig@0.2.5: {} + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + mlly@1.8.0: dependencies: acorn: 8.16.0 @@ -805,8 +1470,31 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + natural-compare@1.4.0: {} + object-assign@4.1.1: {} + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -825,6 +1513,12 @@ snapshots: dependencies: lilconfig: 3.1.3 + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + + punycode@2.3.1: {} + readdirp@4.1.2: {} resolve-from@5.0.0: {} @@ -860,6 +1554,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + source-map@0.7.6: {} sucrase@3.35.1: @@ -889,6 +1591,10 @@ snapshots: tree-kill@1.2.2: {} + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-interface-checker@0.1.13: {} tsup@8.5.1(typescript@5.9.3): @@ -918,8 +1624,35 @@ snapshots: - tsx - yaml + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.56.1(eslint@10.0.3)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + eslint: 10.0.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} ufo@1.6.3: {} undici-types@7.18.2: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/src/api/index.ts b/src/api/index.ts index 438123d..f586a71 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -31,11 +31,7 @@ import type { } from '../types.js' import { ODEH_THRESHOLDS, ODEH_DESCRIPTIONS } from '../types.js' import { SpkKernel } from '../spk/index.js' -import { - computeTimeScales, - jdTTtoET, - J2000, -} from '../time/index.js' +import { computeTimeScales, jdTTtoET, jdToDate, J2000 } from '../time/index.js' import { getMoonGeocentricState, getSunGeocentricState, @@ -44,10 +40,7 @@ import { getMoonSunApproximate, nearestNewMoon, } from '../bodies/index.js' -import { - geodeticToECEF, - computeAzAlt, -} from '../observer/index.js' +import { geodeticToECEF, computeAzAlt } from '../observer/index.js' import { itrsToGcrs, computeERA } from '../frames/index.js' import { getSunMoonEvents as eventsGetSunMoonEvents, @@ -60,7 +53,34 @@ import { computeYallop, computeOdeh, buildGuidanceText, + arcvMinimum, } from '../visibility/index.js' +import { DEG2RAD } from '../math/index.js' + +// ─── Input validation ───────────────────────────────────────────────────────── + +function validateDate(date: Date, label: string): void { + if (!(date instanceof Date) || isNaN(date.getTime())) { + throw new RangeError(`${label}: expected a valid Date instance`) + } +} + +function validateLatitude(lat: number, label: string): void { + if (!isFinite(lat) || lat < -90 || lat > 90) { + throw new RangeError(`${label}: latitude must be a finite number in [-90, 90], got ${lat}`) + } +} + +function validateLongitude(lon: number, label: string): void { + if (!isFinite(lon) || lon < -180 || lon > 180) { + throw new RangeError(`${label}: longitude must be a finite number in [-180, 180], got ${lon}`) + } +} + +function validateObserver(observer: Observer, label: string): void { + validateLatitude(observer.lat, label) + validateLongitude(observer.lon, label) +} // ─── Module-level kernel singleton ───────────────────────────────────────────── @@ -83,7 +103,7 @@ function resolveCacheDir(override?: string): string { // ─── Download sources ───────────────────────────────────────────────────────── const NAIF_DE442S_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de442s.bsp' -const NAIF_LSK_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls' +const NAIF_LSK_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls' // ─── Kernel lifecycle ───────────────────────────────────────────────────────── @@ -109,7 +129,8 @@ export async function initKernels(config?: KernelConfig): Promise { buffer = source.data } else if (source.type === 'url') { const res = await fetch(source.url) - if (!res.ok) throw new Error(`Failed to fetch kernel from ${source.url}: ${res.status} ${res.statusText}`) + if (!res.ok) + throw new Error(`Failed to fetch kernel from ${source.url}: ${res.status} ${res.statusText}`) buffer = await res.arrayBuffer() } else { // auto: download to local cache, then load @@ -146,7 +167,7 @@ export async function downloadKernels(config?: KernelConfig): Promise<{ await mkdir(cacheDir, { recursive: true }) - const planetaryPath = join(cacheDir, 'de442s.bsp') + const planetaryPath = join(cacheDir, 'de442s.bsp') const leapSecondsPath = join(cacheDir, 'naif0012.tls') if (!existsSync(planetaryPath)) { @@ -206,7 +227,7 @@ export async function verifyKernels(config?: KernelConfig): Promise<{ const { join } = await import('node:path') const errors: string[] = [] - const planetaryPath = join(cacheDir, 'de442s.bsp') + const planetaryPath = join(cacheDir, 'de442s.bsp') const leapSecondsPath = join(cacheDir, 'naif0012.tls') if (!existsSync(planetaryPath)) { @@ -251,7 +272,8 @@ async function resolveKernel(config?: KernelConfig): Promise { // auto-init as last resort await initKernels(config) - if (!activeKernel) throw new Error('Kernel failed to initialize. Call initKernels() before computing.') + if (!activeKernel) + throw new Error('Kernel failed to initialize. Call initKernels() before computing.') return activeKernel } @@ -286,6 +308,8 @@ export async function getMoonSightingReport( observer: Observer, options?: SightingOptions, ): Promise { + validateDate(date, 'getMoonSightingReport') + validateObserver(observer, 'getMoonSightingReport') const kernel = await resolveKernel(options?.kernels) // Event times (sunset, moonset, twilight, rise) @@ -321,7 +345,7 @@ export async function getMoonSightingReport( // Body positions in GCRS (geocentric) const moonGCRS = getMoonGeocentricState(kernel, et).position - const sunGCRS = getSunGeocentricState(kernel, et).position + const sunGCRS = getSunGeocentricState(kernel, et).position // Observer ITRS position (km) from geodetic coordinates const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation) @@ -333,7 +357,7 @@ export async function getMoonSightingReport( // Airless alt/az — required by Yallop/Odeh criteria const moonAirless = computeAzAlt(moonGCRS, observer, ts, true) - const sunAirless = computeAzAlt(sunGCRS, observer, ts, true) + const sunAirless = computeAzAlt(sunGCRS, observer, ts, true) // Apparent alt/az (with refraction) — for guidance text const moonApparent = computeAzAlt(moonGCRS, observer, ts, false) @@ -349,11 +373,7 @@ export async function getMoonSightingReport( moonGCRS[1] - obsGCRS[1], moonGCRS[2] - obsGCRS[2], ] - const sunTopo: Vec3 = [ - sunGCRS[0] - obsGCRS[0], - sunGCRS[1] - obsGCRS[1], - sunGCRS[2] - obsGCRS[2], - ] + const sunTopo: Vec3 = [sunGCRS[0] - obsGCRS[0], sunGCRS[1] - obsGCRS[1], sunGCRS[2] - obsGCRS[2]] const geometry = computeCrescentGeometry( moonAirless, @@ -366,7 +386,7 @@ export async function getMoonSightingReport( const { Wprime } = computeCrescentWidth(moonTopo, geometry.ARCL) const yallop = computeYallop(geometry, Wprime) - const odeh = computeOdeh(geometry) + const odeh = computeOdeh(geometry) const moonAboveHorizon = moonAirless.altitude > 0 const sightingPossible = moonAboveHorizon && lagMinutes > 0 @@ -413,7 +433,7 @@ function buildNullReport( return { date, observer, - sunsetUTC: events.sunsetUTC, + sunsetUTC: events.sunsetUTC, moonsetUTC: events.moonsetUTC, lagMinutes: null, bestTimeUTC: null, @@ -425,7 +445,8 @@ function buildNullReport( geometry: null, yallop: null, odeh: null, - guidance: 'Sighting not possible: sunset or moonset could not be determined for this date and location.', + guidance: + 'Sighting not possible: sunset or moonset could not be determined for this date and location.', ephemerisSource: source, moonAboveHorizon: null, sightingPossible, @@ -435,14 +456,14 @@ function buildNullReport( // ─── Phase display lookup ────────────────────────────────────────────────────── const PHASE_DISPLAY: Record = { - 'new-moon': { name: 'New Moon', symbol: '🌑' }, + 'new-moon': { name: 'New Moon', symbol: '🌑' }, 'waxing-crescent': { name: 'Waxing Crescent', symbol: '🌒' }, - 'first-quarter': { name: 'First Quarter', symbol: '🌓' }, - 'waxing-gibbous': { name: 'Waxing Gibbous', symbol: '🌔' }, - 'full-moon': { name: 'Full Moon', symbol: '🌕' }, - 'waning-gibbous': { name: 'Waning Gibbous', symbol: '🌖' }, - 'last-quarter': { name: 'Last Quarter', symbol: '🌗' }, - 'waning-crescent': { name: 'Waning Crescent', symbol: '🌘' }, + 'first-quarter': { name: 'First Quarter', symbol: '🌓' }, + 'waxing-gibbous': { name: 'Waxing Gibbous', symbol: '🌔' }, + 'full-moon': { name: 'Full Moon', symbol: '🌕' }, + 'waning-gibbous': { name: 'Waning Gibbous', symbol: '🌖' }, + 'last-quarter': { name: 'Last Quarter', symbol: '🌗' }, + 'waning-crescent': { name: 'Waning Crescent', symbol: '🌘' }, } /** @@ -464,6 +485,7 @@ const PHASE_DISPLAY: Record = { * ``` */ export function getMoonPhase(date = new Date()): MoonPhaseResult { + validateDate(date, 'getMoonPhase') const ts = computeTimeScales(date) const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT) @@ -478,7 +500,7 @@ export function getMoonPhase(date = new Date()): MoonPhaseResult { const phaseKey = elongationToPhase(elongationDeg, isWaxing) const { name: phaseName, symbol: phaseSymbol } = PHASE_DISPLAY[phaseKey] - const nextNewMoonJD = nearestNewMoon(ts.jdTT + 15) + const nextNewMoonJD = nearestNewMoon(ts.jdTT + 15) const nextFullMoonJD = nearestFullMoon(ts.jdTT) return { @@ -489,9 +511,9 @@ export function getMoonPhase(date = new Date()): MoonPhaseResult { age, elongationDeg, isWaxing, - nextNewMoon: jdToJSDate(nextNewMoonJD), - nextFullMoon: jdToJSDate(nextFullMoonJD), - prevNewMoon: jdToJSDate(prevNewMoonJD), + nextNewMoon: jdToDate(nextNewMoonJD), + nextFullMoon: jdToDate(nextFullMoonJD), + prevNewMoon: jdToDate(prevNewMoonJD), } } @@ -520,7 +542,9 @@ export function getMoonPosition( lon: number, elevation = 0, ): MoonPosition { - const DEG = Math.PI / 180 + validateDate(date, 'getMoonPosition') + validateLatitude(lat, 'getMoonPosition') + validateLongitude(lon, 'getMoonPosition') const ts = computeTimeScales(date) const { moonGCRS } = getMoonSunApproximate(ts.jdTT) @@ -532,17 +556,17 @@ export function getMoonPosition( const distance = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2) // Equatorial coordinates for parallactic angle - const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0]) + const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0]) const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / distance))) // Hour angle: ERA(UT1) + longitude − right ascension const era = computeERA(ts.jdUT1) - const HA = era + lon * DEG - RA_moon + const HA = era + lon * DEG2RAD - RA_moon // Parallactic angle: signed angle between zenith and north pole as seen from the Moon const parallacticAngle = Math.atan2( Math.sin(HA), - Math.cos(lat * DEG) * Math.tan(dec_moon) - Math.sin(lat * DEG) * Math.cos(HA), + Math.cos(lat * DEG2RAD) * Math.tan(dec_moon) - Math.sin(lat * DEG2RAD) * Math.cos(HA), ) return { azimuth: azAlt.azimuth, altitude: azAlt.altitude, distance, parallacticAngle } @@ -566,6 +590,7 @@ export function getMoonPosition( * ``` */ export function getMoonIllumination(date: Date = new Date()): MoonIlluminationResult { + validateDate(date, 'getMoonIllumination') const ts = computeTimeScales(date) const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT) @@ -578,12 +603,12 @@ export function getMoonIllumination(date: Date = new Date()): MoonIlluminationRe // PA = atan2(cos(dec_sun) * sin(RA_sun - RA_moon), // sin(dec_sun) * cos(dec_moon) - cos(dec_sun) * sin(dec_moon) * cos(RA_sun - RA_moon)) const moonDist = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2) - const sunDist = Math.sqrt(sunGCRS[0] ** 2 + sunGCRS[1] ** 2 + sunGCRS[2] ** 2) + const sunDist = Math.sqrt(sunGCRS[0] ** 2 + sunGCRS[1] ** 2 + sunGCRS[2] ** 2) - const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0]) + const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0]) const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / moonDist))) - const RA_sun = Math.atan2(sunGCRS[1], sunGCRS[0]) - const dec_sun = Math.asin(Math.max(-1, Math.min(1, sunGCRS[2] / sunDist))) + const RA_sun = Math.atan2(sunGCRS[1], sunGCRS[0]) + const dec_sun = Math.asin(Math.max(-1, Math.min(1, sunGCRS[2] / sunDist))) const dRA = RA_sun - RA_moon const angle = Math.atan2( @@ -625,13 +650,16 @@ export function getMoonVisibilityEstimate( lon: number, elevation = 0, ): MoonVisibilityEstimate { + validateDate(date, 'getMoonVisibilityEstimate') + validateLatitude(lat, 'getMoonVisibilityEstimate') + validateLongitude(lon, 'getMoonVisibilityEstimate') const ts = computeTimeScales(date) const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT) const observer: Observer = { lat, lon, elevation } // Airless positions — Odeh uses airless altitudes (no refraction) const moonAirless = computeAzAlt(moonGCRS, observer, ts, true) - const sunAirless = computeAzAlt(sunGCRS, observer, ts, true) + const sunAirless = computeAzAlt(sunGCRS, observer, ts, true) // ARCL = elongation (geocentric, degrees) const { elongationDeg } = computeIllumination(moonGCRS, sunGCRS) @@ -652,14 +680,11 @@ export function getMoonVisibilityEstimate( const { W } = computeCrescentWidth(moonTopo, ARCL) - // Odeh 2006: V = ARCV - f(W), where f(W) = arcv_minimum polynomial - const arcvMin = -0.1018 * W ** 3 + 0.7319 * W ** 2 - 6.3226 * W + 7.1651 - const V = ARCV - arcvMin + // Odeh 2006: V = ARCV - arcv_minimum(W) + const V = ARCV - arcvMinimum(W) - const zone: OdehZone = V >= ODEH_THRESHOLDS.A ? 'A' - : V >= ODEH_THRESHOLDS.B ? 'B' - : V >= ODEH_THRESHOLDS.C ? 'C' - : 'D' + const zone: OdehZone = + V >= ODEH_THRESHOLDS.A ? 'A' : V >= ODEH_THRESHOLDS.B ? 'B' : V >= ODEH_THRESHOLDS.C ? 'C' : 'D' return { V, @@ -705,21 +730,19 @@ export function getMoon( lon: number, elevation = 0, ): MoonSnapshot { + validateDate(date, 'getMoon') + validateLatitude(lat, 'getMoon') + validateLongitude(lon, 'getMoon') return { - phase: getMoonPhase(date), - position: getMoonPosition(date, lat, lon, elevation), + phase: getMoonPhase(date), + position: getMoonPosition(date, lat, lon, elevation), illumination: getMoonIllumination(date), - visibility: getMoonVisibilityEstimate(date, lat, lon, elevation), + visibility: getMoonVisibilityEstimate(date, lat, lon, elevation), } } // ─── Internal helpers ───────────────────────────────────────────────────────── -/** Convert JD to a UTC Date. */ -function jdToJSDate(jd: number): Date { - return new Date((jd - 2440587.5) * 86400000) -} - /** * Approximate the nearest full moon JD using Meeus Ch. 49 (full moon k = n + 0.5). * Full moon corrections differ from new moon; these are from Meeus Table 49.A. @@ -740,46 +763,46 @@ function nearestFullMoon(jdTT: number): number { /** Full moon JDE for a half-integer k (Meeus Ch. 49, Table 49.A). */ function fullMoonJDE(k: number): number { const T = k / 1236.85 - const DEG = Math.PI / 180 - let JDE = 2451550.09766 - + 29.530588861 * k - + 0.00015437 * T * T - - 0.000000150 * T * T * T - + 0.00000000073 * T * T * T * T + let JDE = + 2451550.09766 + + 29.530588861 * k + + 0.00015437 * T * T - + 0.00000015 * T * T * T + + 0.00000000073 * T * T * T * T - const M = (2.5534 + 29.10535670 * k - 0.0000014 * T * T) * DEG - const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T) * DEG - const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T) * DEG - const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T) * DEG - const E = 1 - 0.002516 * T - 0.0000074 * T * T + const M = (2.5534 + 29.1053567 * k - 0.0000014 * T * T) * DEG2RAD + const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T) * DEG2RAD + const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T) * DEG2RAD + const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T) * DEG2RAD + const E = 1 - 0.002516 * T - 0.0000074 * T * T JDE += - -0.40614 * Math.sin(Mp) - + 0.17302 * E * Math.sin(M) - + 0.01614 * Math.sin(2 * Mp) - + 0.01043 * Math.sin(2 * Fc) - + 0.00734 * E * Math.sin(Mp - M) - - 0.00515 * E * Math.sin(Mp + M) - + 0.00209 * E * E * Math.sin(2 * M) - - 0.00111 * Math.sin(Mp - 2 * Fc) - - 0.00057 * Math.sin(Mp + 2 * Fc) - + 0.00056 * E * Math.sin(2 * Mp + M) - - 0.00042 * Math.sin(3 * Mp) - + 0.00042 * E * Math.sin(M + 2 * Fc) - + 0.00038 * E * Math.sin(M - 2 * Fc) - - 0.00024 * E * Math.sin(2 * Mp - M) - - 0.00017 * Math.sin(Om) - - 0.00007 * Math.sin(Mp + 2 * M) - + 0.00004 * Math.sin(2 * Mp - 2 * Fc) - + 0.00004 * Math.sin(3 * M) - + 0.00003 * Math.sin(Mp + M - 2 * Fc) - + 0.00003 * Math.sin(2 * Mp + 2 * Fc) - - 0.00003 * Math.sin(Mp + M + 2 * Fc) - + 0.00003 * Math.sin(Mp - M + 2 * Fc) - - 0.00002 * Math.sin(Mp - M - 2 * Fc) - - 0.00002 * Math.sin(3 * Mp + M) - + 0.00002 * Math.sin(4 * Mp) + -0.40614 * Math.sin(Mp) + + 0.17302 * E * Math.sin(M) + + 0.01614 * Math.sin(2 * Mp) + + 0.01043 * Math.sin(2 * Fc) + + 0.00734 * E * Math.sin(Mp - M) - + 0.00515 * E * Math.sin(Mp + M) + + 0.00209 * E * E * Math.sin(2 * M) - + 0.00111 * Math.sin(Mp - 2 * Fc) - + 0.00057 * Math.sin(Mp + 2 * Fc) + + 0.00056 * E * Math.sin(2 * Mp + M) - + 0.00042 * Math.sin(3 * Mp) + + 0.00042 * E * Math.sin(M + 2 * Fc) + + 0.00038 * E * Math.sin(M - 2 * Fc) - + 0.00024 * E * Math.sin(2 * Mp - M) - + 0.00017 * Math.sin(Om) - + 0.00007 * Math.sin(Mp + 2 * M) + + 0.00004 * Math.sin(2 * Mp - 2 * Fc) + + 0.00004 * Math.sin(3 * M) + + 0.00003 * Math.sin(Mp + M - 2 * Fc) + + 0.00003 * Math.sin(2 * Mp + 2 * Fc) - + 0.00003 * Math.sin(Mp + M + 2 * Fc) + + 0.00003 * Math.sin(Mp - M + 2 * Fc) - + 0.00002 * Math.sin(Mp - M - 2 * Fc) - + 0.00002 * Math.sin(3 * Mp + M) + + 0.00002 * Math.sin(4 * Mp) return JDE } @@ -790,10 +813,10 @@ function fullMoonJDE(k: number): number { */ function elongationToPhase(elongationDeg: number, isWaxing: boolean): MoonPhaseName { const e = elongationDeg - if (e < 5) return 'new-moon' + if (e < 5) return 'new-moon' if (e > 175) return 'full-moon' - if (e < 85) return isWaxing ? 'waxing-crescent' : 'waning-crescent' - if (e < 95) return isWaxing ? 'first-quarter' : 'last-quarter' + if (e < 85) return isWaxing ? 'waxing-crescent' : 'waning-crescent' + if (e < 95) return isWaxing ? 'first-quarter' : 'last-quarter' return isWaxing ? 'waxing-gibbous' : 'waning-gibbous' } @@ -812,6 +835,8 @@ export async function getSunMoonEvents( observer: Observer, options?: Pick, ): Promise { + validateDate(date, 'getSunMoonEvents') + validateObserver(observer, 'getSunMoonEvents') const kernel = await resolveKernel(options?.kernels) return eventsGetSunMoonEvents(date, observer, kernel) } diff --git a/src/bodies/index.ts b/src/bodies/index.ts index 8950884..5dd9f22 100644 --- a/src/bodies/index.ts +++ b/src/bodies/index.ts @@ -20,17 +20,17 @@ import type { StateVector, Vec3 } from '../types.js' import type { SpkKernel } from '../spk/index.js' import { NAIF_IDS } from '../spk/index.js' import { J2000, DAYS_PER_JULIAN_CENTURY } from '../time/index.js' +import { DEG2RAD, vdot, vnorm } from '../math/index.js' // ─── Constants ──────────────────────────────────────────────────────────────── - -const DEG = Math.PI / 180 const AU_KM = 149597870.7 /** Mean radius of the Moon in km (IAU 2015 nominal value) */ const MOON_RADIUS_KM = 1737.4 /** Mean radius of the Sun in km */ -const SUN_RADIUS_KM = 696000.0 +const _SUN_RADIUS_KM = 696000.0 +void _SUN_RADIUS_KM // reserved for future solar semi-diameter calculations // ─── Geocentric state ───────────────────────────────────────────────────────── @@ -85,15 +85,12 @@ export function computeIllumination( moonGCRS: Vec3, sunGCRS: Vec3, ): { illumination: number; phaseAngleDeg: number; elongationDeg: number; isWaxing: boolean } { - const dot = (a: Vec3, b: Vec3) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] - const norm = (v: Vec3) => Math.sqrt(dot(v, v)) - - const rMoon = norm(moonGCRS) - const rSun = norm(sunGCRS) + const rMoon = vnorm(moonGCRS) + const rSun = vnorm(sunGCRS) // Elongation ψ: angle at Earth between Moon and Sun - const cosElong = dot(moonGCRS, sunGCRS) / (rMoon * rSun) - const elongationDeg = Math.acos(Math.max(-1, Math.min(1, cosElong))) / DEG + const cosElong = vdot(moonGCRS, sunGCRS) / (rMoon * rSun) + const elongationDeg = Math.acos(Math.max(-1, Math.min(1, cosElong))) / DEG2RAD // Phase angle i: angle at Moon between Earth and Sun // Vector from Moon to Earth: -moonGCRS @@ -104,16 +101,16 @@ export function computeIllumination( sunGCRS[2] - moonGCRS[2], ] const moonToEarth: Vec3 = [-moonGCRS[0], -moonGCRS[1], -moonGCRS[2]] - const rMoonToSun = norm(moonToSun) + const rMoonToSun = vnorm(moonToSun) - const cosPhase = dot(moonToEarth, moonToSun) / (rMoon * rMoonToSun) - const phaseAngleDeg = Math.acos(Math.max(-1, Math.min(1, cosPhase))) / DEG + const cosPhase = vdot(moonToEarth, moonToSun) / (rMoon * rMoonToSun) + const phaseAngleDeg = Math.acos(Math.max(-1, Math.min(1, cosPhase))) / DEG2RAD - const illumination = (1 + Math.cos(phaseAngleDeg * DEG)) / 2 + const illumination = (1 + Math.cos(phaseAngleDeg * DEG2RAD)) / 2 // Moon is waxing when it is east of the Sun (elongation increasing). // Cross product sunGCRS × moonGCRS z-component: positive when Moon is east of Sun. - const crossZ = sunGCRS[0]*moonGCRS[1] - sunGCRS[1]*moonGCRS[0] + const crossZ = sunGCRS[0] * moonGCRS[1] - sunGCRS[1] * moonGCRS[0] const isWaxing = crossZ > 0 return { illumination, phaseAngleDeg, elongationDeg, isWaxing } @@ -143,16 +140,13 @@ export function computeCrescentWidth( moonTopoVec: Vec3, ARCL: number, ): { W: number; Wprime: number } { - - const rMoon = Math.sqrt( - moonTopoVec[0]**2 + moonTopoVec[1]**2 + moonTopoVec[2]**2, - ) + const rMoon = Math.sqrt(moonTopoVec[0] ** 2 + moonTopoVec[1] ** 2 + moonTopoVec[2] ** 2) // Topocentric semi-diameter in arc minutes - const SDmoon_arcmin = Math.atan(MOON_RADIUS_KM / rMoon) / DEG * 60 + const SDmoon_arcmin = (Math.atan(MOON_RADIUS_KM / rMoon) / DEG2RAD) * 60 // Crescent width in arc minutes - const ARCL_rad = ARCL * DEG + const ARCL_rad = ARCL * DEG2RAD const W = SDmoon_arcmin * (1 - Math.cos(ARCL_rad)) // Wprime ≡ W for both Odeh and Yallop in this formulation @@ -185,30 +179,32 @@ export function getMoonSunApproximate(jdTT: number): { // Mean longitude L0 and mean anomaly M (degrees) const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T const M_sun = 357.52911 + 35999.05029 * T - 0.0001537 * T * T - const M_sun_rad = (M_sun % 360) * DEG + const M_sun_rad = (M_sun % 360) * DEG2RAD const e_sun = 0.016708634 - 0.000042037 * T - 0.0000001267 * T * T // Equation of center (degrees) - const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M_sun_rad) - + (0.019993 - 0.000101 * T) * Math.sin(2 * M_sun_rad) - + 0.000289 * Math.sin(3 * M_sun_rad) + const C = + (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M_sun_rad) + + (0.019993 - 0.000101 * T) * Math.sin(2 * M_sun_rad) + + 0.000289 * Math.sin(3 * M_sun_rad) // True longitude and anomaly const sunLonDeg = L0 + C - const nu_rad = M_sun_rad + C * DEG + const nu_rad = M_sun_rad + C * DEG2RAD // Geometric distance in AU - const R_AU = 1.000001018 * (1 - e_sun * e_sun) / (1 + e_sun * Math.cos(nu_rad)) + const R_AU = (1.000001018 * (1 - e_sun * e_sun)) / (1 + e_sun * Math.cos(nu_rad)) const R_km = R_AU * AU_KM // Nutation correction for apparent longitude (simplified) - const omega = (125.04 - 1934.136 * T) * DEG + const omega = (125.04 - 1934.136 * T) * DEG2RAD const sunLonApp = sunLonDeg - 0.00569 - 0.00478 * Math.sin(omega) - const sunLon_rad = sunLonApp * DEG + const sunLon_rad = sunLonApp * DEG2RAD // Mean obliquity of the ecliptic (IAU 1980 approximation, degrees) - const eps = (23.439291111 - 0.013004167 * T - 0.0000001638 * T * T + 0.0000005036 * T * T * T) * DEG + const eps = + (23.439291111 - 0.013004167 * T - 0.0000001638 * T * T + 0.0000005036 * T * T * T) * DEG2RAD const sunGCRS: Vec3 = [ R_km * Math.cos(sunLon_rad), @@ -219,121 +215,151 @@ export function getMoonSunApproximate(jdTT: number): { // ── Moon (Meeus Ch. 47) ───────────────────────────────────────────────────── // Fundamental arguments (degrees) - const Lp = 218.3164477 + 481267.88123421 * T - 0.0015786 * T * T + T * T * T / 538841 - T * T * T * T / 65194000 - const D = 297.8501921 + 445267.1114034 * T - 0.0018819 * T * T + T * T * T / 545868 - T * T * T * T / 113065000 - const M = 357.5291092 + 35999.0502909 * T - 0.0001536 * T * T + T * T * T / 24490000 - const Mp = 134.9633964 + 477198.8675055 * T + 0.0087414 * T * T + T * T * T / 69699 - T * T * T * T / 14712000 - const F = 93.2720950 + 483202.0175233 * T - 0.0036539 * T * T - T * T * T / 3526000 + T * T * T * T / 863310000 + const Lp = + 218.3164477 + + 481267.88123421 * T - + 0.0015786 * T * T + + (T * T * T) / 538841 - + (T * T * T * T) / 65194000 + const D = + 297.8501921 + + 445267.1114034 * T - + 0.0018819 * T * T + + (T * T * T) / 545868 - + (T * T * T * T) / 113065000 + const M = 357.5291092 + 35999.0502909 * T - 0.0001536 * T * T + (T * T * T) / 24490000 + const Mp = + 134.9633964 + + 477198.8675055 * T + + 0.0087414 * T * T + + (T * T * T) / 69699 - + (T * T * T * T) / 14712000 + const F = + 93.272095 + + 483202.0175233 * T - + 0.0036539 * T * T - + (T * T * T) / 3526000 + + (T * T * T * T) / 863310000 // Additive terms for longitude/latitude - const A1 = (119.75 + 131.849 * T) * DEG - const A2 = ( 53.09 + 479264.290 * T) * DEG - const A3 = (313.45 + 481266.484 * T) * DEG + const A1 = (119.75 + 131.849 * T) * DEG2RAD + const A2 = (53.09 + 479264.29 * T) * DEG2RAD + const A3 = (313.45 + 481266.484 * T) * DEG2RAD // Convert to radians for accumulation - const D_r = (D % 360) * DEG - const M_r = (M % 360) * DEG - const Mp_r = (Mp % 360) * DEG - const F_r = (F % 360) * DEG + const D_r = (D % 360) * DEG2RAD + const M_r = (M % 360) * DEG2RAD + const Mp_r = (Mp % 360) * DEG2RAD + const F_r = (F % 360) * DEG2RAD // Eccentricity correction for terms involving M (Earth's orbital eccentricity) const E = 1 - 0.002516 * T - 0.0000074 * T * T // Longitude and distance accumulation — 30 main terms from Meeus Table 47.A // [d, m, mp, f, Σl (0.000001°), Σr (0.001 km)] - const LD: ReadonlyArray = [ - [ 0, 0, 1, 0, 6288774, -20905355], - [ 2, 0,-1, 0, 1274027, -3699111], - [ 2, 0, 0, 0, 658314, -2955968], - [ 0, 0, 2, 0, 213618, -569925], - [ 0, 1, 0, 0, -185116, 48888], - [ 0, 0, 0, 2, -114332, -3149], - [ 2, 0,-2, 0, 58793, 246158], - [ 2,-1,-1, 0, 57066, -152138], - [ 2, 0, 1, 0, 53322, -170733], - [ 2,-1, 0, 0, 45758, -204586], - [ 0, 1,-1, 0, -40923, -129620], - [ 1, 0, 0, 0, -34720, 108743], - [ 0, 1, 1, 0, -30383, 104755], - [ 2, 0, 0,-2, 15327, 10321], - [ 0, 0, 1, 2, -12528, 0], - [ 0, 0, 1,-2, 10980, 79661], - [ 4, 0,-1, 0, 10675, -34782], - [ 0, 0, 3, 0, 10034, -23210], - [ 4, 0,-2, 0, 8548, -21636], - [ 2, 1,-1, 0, -7888, 24208], - [ 2, 1, 0, 0, -6766, 30824], - [ 1, 0,-1, 0, -5163, -8379], - [ 1, 1, 0, 0, 4987, -16675], - [ 2,-1, 1, 0, 4036, -12831], - [ 2, 0, 2, 0, 3994, -10445], - [ 4, 0, 0, 0, 3861, -11650], - [ 2, 0,-3, 0, 3665, 14403], - [ 0, 1,-2, 0, -2689, -7003], - [ 2, 0,-1, 2, -2602, 0], - [ 2,-1,-2, 0, 2390, 10056], + const LD: ReadonlyArray = [ + [0, 0, 1, 0, 6288774, -20905355], + [2, 0, -1, 0, 1274027, -3699111], + [2, 0, 0, 0, 658314, -2955968], + [0, 0, 2, 0, 213618, -569925], + [0, 1, 0, 0, -185116, 48888], + [0, 0, 0, 2, -114332, -3149], + [2, 0, -2, 0, 58793, 246158], + [2, -1, -1, 0, 57066, -152138], + [2, 0, 1, 0, 53322, -170733], + [2, -1, 0, 0, 45758, -204586], + [0, 1, -1, 0, -40923, -129620], + [1, 0, 0, 0, -34720, 108743], + [0, 1, 1, 0, -30383, 104755], + [2, 0, 0, -2, 15327, 10321], + [0, 0, 1, 2, -12528, 0], + [0, 0, 1, -2, 10980, 79661], + [4, 0, -1, 0, 10675, -34782], + [0, 0, 3, 0, 10034, -23210], + [4, 0, -2, 0, 8548, -21636], + [2, 1, -1, 0, -7888, 24208], + [2, 1, 0, 0, -6766, 30824], + [1, 0, -1, 0, -5163, -8379], + [1, 1, 0, 0, 4987, -16675], + [2, -1, 1, 0, 4036, -12831], + [2, 0, 2, 0, 3994, -10445], + [4, 0, 0, 0, 3861, -11650], + [2, 0, -3, 0, 3665, 14403], + [0, 1, -2, 0, -2689, -7003], + [2, 0, -1, 2, -2602, 0], + [2, -1, -2, 0, 2390, 10056], ] - let Sl = 0, Sr = 0 + let Sl = 0, + Sr = 0 for (const [d, m, mp, f, sl, sr] of LD) { - const arg = d*D_r + m*M_r + mp*Mp_r + f*F_r - const eCorr = Math.abs(m) === 2 ? E*E : Math.abs(m) === 1 ? E : 1 + const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r + const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1 Sl += sl * eCorr * Math.sin(arg) Sr += sr * eCorr * Math.cos(arg) } // Additive longitude corrections (Meeus §47) - Sl += 3958 * Math.sin(A1) + 1962 * Math.sin((Lp - F) * DEG) + 318 * Math.sin(A2) + Sl += 3958 * Math.sin(A1) + 1962 * Math.sin((Lp - F) * DEG2RAD) + 318 * Math.sin(A2) // Latitude accumulation — 20 main terms from Meeus Table 47.B // [d, m, mp, f, Σb (0.000001°)] - const FB: ReadonlyArray = [ - [ 0, 0, 0, 1, 5128122], - [ 0, 0, 1, 1, 280602], - [ 0, 0, 1,-1, 277693], - [ 2, 0, 0,-1, 173237], - [ 2, 0,-1, 1, 55413], - [ 2, 0,-1,-1, 46271], - [ 2, 0, 0, 1, 32573], - [ 0, 0, 2, 1, 17198], - [ 2, 0, 1,-1, 9266], - [ 0, 0, 2,-1, 8822], - [ 2,-1, 0,-1, 8216], - [ 2, 0,-2,-1, 4324], - [ 2, 0, 1, 1, 4200], - [ 2, 1, 0,-1, -3359], - [ 2,-1,-1, 1, 2463], - [ 2,-1, 0, 1, 2211], - [ 2,-1,-1,-1, 2065], - [ 0, 1,-1,-1, -1870], - [ 4, 0,-1,-1, 1828], - [ 0, 1, 0, 1, -1794], + const FB: ReadonlyArray = [ + [0, 0, 0, 1, 5128122], + [0, 0, 1, 1, 280602], + [0, 0, 1, -1, 277693], + [2, 0, 0, -1, 173237], + [2, 0, -1, 1, 55413], + [2, 0, -1, -1, 46271], + [2, 0, 0, 1, 32573], + [0, 0, 2, 1, 17198], + [2, 0, 1, -1, 9266], + [0, 0, 2, -1, 8822], + [2, -1, 0, -1, 8216], + [2, 0, -2, -1, 4324], + [2, 0, 1, 1, 4200], + [2, 1, 0, -1, -3359], + [2, -1, -1, 1, 2463], + [2, -1, 0, 1, 2211], + [2, -1, -1, -1, 2065], + [0, 1, -1, -1, -1870], + [4, 0, -1, -1, 1828], + [0, 1, 0, 1, -1794], ] let Sb = 0 for (const [d, m, mp, f, sb] of FB) { - const arg = d*D_r + m*M_r + mp*Mp_r + f*F_r - const eCorr = Math.abs(m) === 2 ? E*E : Math.abs(m) === 1 ? E : 1 + const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r + const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1 Sb += sb * eCorr * Math.sin(arg) } // Additive latitude corrections - Sb += -2235 * Math.sin(Lp * DEG) + 382 * Math.sin(A3) + 175 * Math.sin(A1 - F_r) - + 175 * Math.sin(A1 + F_r) + 127 * Math.sin((Lp - Mp) * DEG) - 115 * Math.sin((Lp + Mp) * DEG) + Sb += + -2235 * Math.sin(Lp * DEG2RAD) + + 382 * Math.sin(A3) + + 175 * Math.sin(A1 - F_r) + + 175 * Math.sin(A1 + F_r) + + 127 * Math.sin((Lp - Mp) * DEG2RAD) - + 115 * Math.sin((Lp + Mp) * DEG2RAD) // Moon ecliptic coordinates const moonLonDeg = Lp + Sl * 1e-6 const moonLatDeg = Sb * 1e-6 const moonDistKm = 385000.56 + Sr * 0.001 - const moonLon_rad = moonLonDeg * DEG - const moonLat_rad = moonLatDeg * DEG + const moonLon_rad = moonLonDeg * DEG2RAD + const moonLat_rad = moonLatDeg * DEG2RAD // Ecliptic to equatorial (GCRS ≈ J2000 equatorial for this accuracy level) const moonGCRS: Vec3 = [ moonDistKm * Math.cos(moonLat_rad) * Math.cos(moonLon_rad), - moonDistKm * (Math.cos(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) - Math.sin(eps) * Math.sin(moonLat_rad)), - moonDistKm * (Math.sin(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) + Math.cos(eps) * Math.sin(moonLat_rad)), + moonDistKm * + (Math.cos(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) - + Math.sin(eps) * Math.sin(moonLat_rad)), + moonDistKm * + (Math.sin(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) + + Math.cos(eps) * Math.sin(moonLat_rad)), ] return { moonGCRS, sunGCRS } @@ -355,48 +381,49 @@ export function nearestNewMoon(jdTT: number): number { const T = k / 1236.85 // JDE of mean new moon (Meeus Eq. 49.1) - let JDE = 2451550.09766 - + 29.530588861 * k - + 0.00015437 * T * T - - 0.000000150 * T * T * T - + 0.00000000073 * T * T * T * T + let JDE = + 2451550.09766 + + 29.530588861 * k + + 0.00015437 * T * T - + 0.00000015 * T * T * T + + 0.00000000073 * T * T * T * T // Fundamental arguments for the corrections (degrees → radians) - const M = (2.5534 + 29.10535670 * k - 0.0000014 * T * T - 0.00000011 * T * T * T) * DEG - const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T + 0.00001238 * T * T * T) * DEG - const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T - 0.00000227 * T * T * T) * DEG - const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T + 0.00000215 * T * T * T) * DEG + const M = (2.5534 + 29.1053567 * k - 0.0000014 * T * T - 0.00000011 * T * T * T) * DEG2RAD + const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T + 0.00001238 * T * T * T) * DEG2RAD + const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * T * T - 0.00000227 * T * T * T) * DEG2RAD + const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T + 0.00000215 * T * T * T) * DEG2RAD // Eccentricity of Earth's orbit const E = 1 - 0.002516 * T - 0.0000074 * T * T // Corrections from Meeus Table 49.A (new moon) JDE += - -0.40720 * Math.sin(Mp) - + 0.17241 * E * Math.sin(M) - + 0.01608 * Math.sin(2 * Mp) - + 0.01039 * Math.sin(2 * Fc) - + 0.00739 * E * Math.sin(Mp - M) - - 0.00514 * E * Math.sin(Mp + M) - + 0.00208 * E * E * Math.sin(2 * M) - - 0.00111 * Math.sin(Mp - 2 * Fc) - - 0.00057 * Math.sin(Mp + 2 * Fc) - + 0.00056 * E * Math.sin(2 * Mp + M) - - 0.00042 * Math.sin(3 * Mp) - + 0.00042 * E * Math.sin(M + 2 * Fc) - + 0.00038 * E * Math.sin(M - 2 * Fc) - - 0.00024 * E * Math.sin(2 * Mp - M) - - 0.00017 * Math.sin(Om) - - 0.00007 * Math.sin(Mp + 2 * M) - + 0.00004 * Math.sin(2 * Mp - 2 * Fc) - + 0.00004 * Math.sin(3 * M) - + 0.00003 * Math.sin(Mp + M - 2 * Fc) - + 0.00003 * Math.sin(2 * Mp + 2 * Fc) - - 0.00003 * Math.sin(Mp + M + 2 * Fc) - + 0.00003 * Math.sin(Mp - M + 2 * Fc) - - 0.00002 * Math.sin(Mp - M - 2 * Fc) - - 0.00002 * Math.sin(3 * Mp + M) - + 0.00002 * Math.sin(4 * Mp) + -0.4072 * Math.sin(Mp) + + 0.17241 * E * Math.sin(M) + + 0.01608 * Math.sin(2 * Mp) + + 0.01039 * Math.sin(2 * Fc) + + 0.00739 * E * Math.sin(Mp - M) - + 0.00514 * E * Math.sin(Mp + M) + + 0.00208 * E * E * Math.sin(2 * M) - + 0.00111 * Math.sin(Mp - 2 * Fc) - + 0.00057 * Math.sin(Mp + 2 * Fc) + + 0.00056 * E * Math.sin(2 * Mp + M) - + 0.00042 * Math.sin(3 * Mp) + + 0.00042 * E * Math.sin(M + 2 * Fc) + + 0.00038 * E * Math.sin(M - 2 * Fc) - + 0.00024 * E * Math.sin(2 * Mp - M) - + 0.00017 * Math.sin(Om) - + 0.00007 * Math.sin(Mp + 2 * M) + + 0.00004 * Math.sin(2 * Mp - 2 * Fc) + + 0.00004 * Math.sin(3 * M) + + 0.00003 * Math.sin(Mp + M - 2 * Fc) + + 0.00003 * Math.sin(2 * Mp + 2 * Fc) - + 0.00003 * Math.sin(Mp + M + 2 * Fc) + + 0.00003 * Math.sin(Mp - M + 2 * Fc) - + 0.00002 * Math.sin(Mp - M - 2 * Fc) - + 0.00002 * Math.sin(3 * Mp + M) + + 0.00002 * Math.sin(4 * Mp) return JDE } diff --git a/src/cli/index.ts b/src/cli/index.ts index b5cefe0..d5f0cdf 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -100,7 +100,9 @@ async function cmdSighting(cmdArgs: string[]) { console.log(`Sunset: ${fmtDate(report.sunsetUTC)}`) console.log(`Moonset: ${fmtDate(report.moonsetUTC)}`) console.log(`Best time: ${fmtDate(report.bestTimeUTC)}`) - console.log(`Lag: ${report.lagMinutes !== null ? Math.round(report.lagMinutes) + ' min' : 'N/A'}`) + console.log( + `Lag: ${report.lagMinutes !== null ? Math.round(report.lagMinutes) + ' min' : 'N/A'}`, + ) console.log('') if (report.geometry) { @@ -151,7 +153,9 @@ async function cmdBenchmark() { getMoonPhase(new Date(Date.UTC(2025, 2, 1 + (i % 28)))) } const phaseMs = performance.now() - phaseStart - console.log(`getMoonPhase × ${N_PHASE}: ${phaseMs.toFixed(1)} ms (${(phaseMs / N_PHASE * 1000).toFixed(1)} µs/call)`) + console.log( + `getMoonPhase × ${N_PHASE}: ${phaseMs.toFixed(1)} ms (${((phaseMs / N_PHASE) * 1000).toFixed(1)} µs/call)`, + ) // Benchmark 2: kernel load const loadStart = performance.now() @@ -170,10 +174,13 @@ async function cmdBenchmark() { /** Format a nullable Date as a short UTC string. */ function fmtDate(d: Date | null): string { if (!d) return 'N/A' - return d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC') + return d + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ' UTC') } -main().catch(err => { +main().catch((err) => { console.error(err instanceof Error ? err.message : String(err)) process.exit(1) }) diff --git a/src/events/index.ts b/src/events/index.ts index 5cb660e..8997998 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -22,7 +22,8 @@ import type { Vec3, Observer, SunMoonEvents, TimeScales } from '../types.js' import type { SpkKernel } from '../spk/index.js' import { NAIF_IDS } from '../spk/index.js' -import { brentRoot } from '../math/index.js' +import { brentRoot, vdot, vnorm } from '../math/index.js' +import { arcvMinimum } from '../visibility/index.js' import { J2000, SECONDS_PER_DAY, @@ -33,7 +34,11 @@ import { getDeltaAT, TT_MINUS_TAI, } from '../time/index.js' -import { getMoonGeocentricState, getSunGeocentricState, computeCrescentWidth } from '../bodies/index.js' +import { + getMoonGeocentricState, + getSunGeocentricState, + computeCrescentWidth, +} from '../bodies/index.js' import { geodeticToECEF, computeAzAlt } from '../observer/index.js' import { itrsToGcrs } from '../frames/index.js' @@ -51,7 +56,7 @@ export const SUN_ALTITUDE_THRESHOLD = -0.8333 * Accounts for: standard refraction at horizon (34') + lunar semi-diameter (~16') * Note: Moon's SD varies with distance (14.7'–16.8'). Use 0.2725° as mean. */ -export const MOON_ALTITUDE_THRESHOLD = -0.8333 // refined per actual distance in implementation +export const MOON_ALTITUDE_THRESHOLD = -0.8333 // refined per actual distance in implementation // ─── Internal helpers ───────────────────────────────────────────────────────── @@ -130,7 +135,7 @@ export function findAltitudeCrossing( const f = (et: number) => altitudeMinusThreshold(kernel, naifId, observer, et, threshold) - const STEP_S = 600 // 10-minute coarse sampling + const STEP_S = 600 // 10-minute coarse sampling const nSteps = Math.ceil((endET - startET) / STEP_S) let prevET = startET @@ -140,11 +145,11 @@ export function findAltitudeCrossing( const currET = Math.min(startET + i * STEP_S, endET) const currF = f(currET) - const isRisingCross = rising && prevF < 0 && currF >= 0 + const isRisingCross = rising && prevF < 0 && currF >= 0 const isSettingCross = !rising && prevF >= 0 && currF < 0 if (isRisingCross || isSettingCross) { - const etCross = brentRoot(f, prevET, currET, 0.5) // 0.5s precision + const etCross = brentRoot(f, prevET, currET, 0.5) // 0.5s precision if (etCross !== null) { const tsCross = etToTS(etCross) return tsCross.utc @@ -169,45 +174,90 @@ export function findAltitudeCrossing( * @param kernel - DE442S kernel * @returns SunMoonEvents with all event times in UTC, or null if event doesn't occur */ -export function getSunMoonEvents( - date: Date, - observer: Observer, - kernel: SpkKernel, -): SunMoonEvents { +export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKernel): SunMoonEvents { // Anchor search at UTC midnight of the input date const midnight = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())) const jdMidnight = dateToJD(midnight) // Approximate ET at midnight - const etStart = jdTTtoET(jdMidnight + 70.0 / SECONDS_PER_DAY) // rough TT≈UTC+70s - const etEnd = etStart + 28 * 3600 // 28-hour window + const etStart = jdTTtoET(jdMidnight + 70.0 / SECONDS_PER_DAY) // rough TT≈UTC+70s + const etEnd = etStart + 28 * 3600 // 28-hour window const ts0 = computeTimeScales(midnight) // Sun events const sunriseUTC = findAltitudeCrossing( - kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, SUN_ALTITUDE_THRESHOLD, true, + kernel, + NAIF_IDS.SUN, + observer, + ts0, + etStart, + etEnd, + SUN_ALTITUDE_THRESHOLD, + true, ) const sunsetUTC = findAltitudeCrossing( - kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, SUN_ALTITUDE_THRESHOLD, false, + kernel, + NAIF_IDS.SUN, + observer, + ts0, + etStart, + etEnd, + SUN_ALTITUDE_THRESHOLD, + false, ) // Twilight events (Sun setting below -6°, -12°, -18°) const civilTwilightEndUTC = findAltitudeCrossing( - kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, -6, false, + kernel, + NAIF_IDS.SUN, + observer, + ts0, + etStart, + etEnd, + -6, + false, ) const nauticalTwilightEndUTC = findAltitudeCrossing( - kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, -12, false, + kernel, + NAIF_IDS.SUN, + observer, + ts0, + etStart, + etEnd, + -12, + false, ) const astronomicalTwilightEndUTC = findAltitudeCrossing( - kernel, NAIF_IDS.SUN, observer, ts0, etStart, etEnd, -18, false, + kernel, + NAIF_IDS.SUN, + observer, + ts0, + etStart, + etEnd, + -18, + false, ) // Moon events const moonriseUTC = findAltitudeCrossing( - kernel, NAIF_IDS.MOON, observer, ts0, etStart, etEnd, MOON_ALTITUDE_THRESHOLD, true, + kernel, + NAIF_IDS.MOON, + observer, + ts0, + etStart, + etEnd, + MOON_ALTITUDE_THRESHOLD, + true, ) const moonsetUTC = findAltitudeCrossing( - kernel, NAIF_IDS.MOON, observer, ts0, etStart, etEnd, MOON_ALTITUDE_THRESHOLD, false, + kernel, + NAIF_IDS.MOON, + observer, + ts0, + etStart, + etEnd, + MOON_ALTITUDE_THRESHOLD, + false, ) return { @@ -241,7 +291,7 @@ export function bestTimeHeuristic( moonsetUTC: Date, ): { bestTimeUTC: Date; lagMinutes: number } | null { const lagMs = moonsetUTC.getTime() - sunsetUTC.getTime() - if (lagMs <= 0) return null // Moon sets before Sun — no sighting possible + if (lagMs <= 0) return null // Moon sets before Sun — no sighting possible const lagMinutes = lagMs / 60000 const bestTimeMs = sunsetUTC.getTime() + (4 / 9) * lagMs @@ -252,14 +302,6 @@ export function bestTimeHeuristic( } } -/** - * Odeh arcv minimum polynomial (Odeh 2006, Eq. 1). - * Returns the minimum ARCV needed for visibility at crescent width W (arc minutes). - */ -function odehArcvMin(W: number): number { - return 11.8371 - 6.3226 * W + 0.7319 * W * W - 0.1018 * W * W * W -} - /** * Find the optimal observation time by maximizing the Odeh V parameter * over the interval [sunset, moonset]. @@ -290,9 +332,6 @@ export function bestTimeOptimized( const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation) const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000] - const dot = (a: Vec3, b: Vec3) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] - const norm = (v: Vec3) => Math.sqrt(dot(v, v)) - let bestTimeUTC = sunsetUTC let maxV = -Infinity @@ -302,14 +341,14 @@ export function bestTimeOptimized( const et = jdTTtoET(ts.jdTT) const moonGCRS = getMoonGeocentricState(kernel, et).position - const sunGCRS = getSunGeocentricState(kernel, et).position + const sunGCRS = getSunGeocentricState(kernel, et).position // Convert observer ITRS → GCRS at this timestep (Earth rotation changes per step) const obsGCRS = itrsToGcrs(obsITRS, ts) // Airless altitudes via the full pipeline const moonAzAlt = computeAzAlt(moonGCRS, observer, ts, true) - const sunAzAlt = computeAzAlt(sunGCRS, observer, ts, true) + const sunAzAlt = computeAzAlt(sunGCRS, observer, ts, true) const ARCV = moonAzAlt.altitude - sunAzAlt.altitude @@ -324,11 +363,11 @@ export function bestTimeOptimized( sunGCRS[1] - obsGCRS[1], sunGCRS[2] - obsGCRS[2], ] - const cosARCL = dot(moonTopo, sunTopo) / (norm(moonTopo) * norm(sunTopo)) + const cosARCL = vdot(moonTopo, sunTopo) / (vnorm(moonTopo) * vnorm(sunTopo)) const ARCL = Math.acos(Math.max(-1, Math.min(1, cosARCL))) * (180 / Math.PI) const { W } = computeCrescentWidth(moonTopo, ARCL) - const V = ARCV - odehArcvMin(W) + const V = ARCV - arcvMinimum(W) if (V > maxV) { maxV = V @@ -347,13 +386,7 @@ export function bestTimeOptimized( * @param windowMinutes - Half-width of window in minutes (default 20) * @returns [start, end] UTC Date pair */ -export function computeObservationWindow( - bestTimeUTC: Date, - windowMinutes = 20, -): [Date, Date] { +export function computeObservationWindow(bestTimeUTC: Date, windowMinutes = 20): [Date, Date] { const windowMs = windowMinutes * 60000 - return [ - new Date(bestTimeUTC.getTime() - windowMs), - new Date(bestTimeUTC.getTime() + windowMs), - ] + return [new Date(bestTimeUTC.getTime() - windowMs), new Date(bestTimeUTC.getTime() + windowMs)] } diff --git a/src/frames/index.ts b/src/frames/index.ts index 45df5fd..1bc09e8 100644 --- a/src/frames/index.ts +++ b/src/frames/index.ts @@ -50,165 +50,163 @@ const UAS01_TO_ARCSEC = 1e-7 // dpsi += (ps + pst*T)*sin(arg) + pc*cos(arg) // deps += (ec + ect*T)*cos(arg) + es*sin(arg) -const NUT_2000B: ReadonlyArray = [ +const NUT_2000B: ReadonlyArray< + readonly [number, number, number, number, number, number, number, number, number, number, number] +> = [ // 1 - [ 0, 0, 0, 0, 1, -172064161.0, -174666.0, 33386.0, 92052331.0, 9086.0, 15377.0], + [0, 0, 0, 0, 1, -172064161.0, -174666.0, 33386.0, 92052331.0, 9086.0, 15377.0], // 2 - [ 0, 0, 2,-2, 2, -13170906.0, -13696.0, -13238.0, 5730336.0, -3015.0, -4587.0], + [0, 0, 2, -2, 2, -13170906.0, -13696.0, -13238.0, 5730336.0, -3015.0, -4587.0], // 3 - [ 0, 0, 2, 0, 2, -2276413.0, -2353.0, 2796.0, 978459.0, -619.0, 645.0], + [0, 0, 2, 0, 2, -2276413.0, -2353.0, 2796.0, 978459.0, -619.0, 645.0], // 4 - [ 0, 0, 0, 0, 2, 2074554.0, 2352.0, -2635.0, -897492.0, 307.0, -187.0], + [0, 0, 0, 0, 2, 2074554.0, 2352.0, -2635.0, -897492.0, 307.0, -187.0], // 5 - [ 0, 1, 0, 0, 0, 1475877.0, -11817.0, 11817.0, 73871.0, -184.0, -1924.0], + [0, 1, 0, 0, 0, 1475877.0, -11817.0, 11817.0, 73871.0, -184.0, -1924.0], // 6 - [ 0, 1, 2,-2, 2, -516821.0, 1226.0, -524.0, 224386.0, -677.0, -174.0], + [0, 1, 2, -2, 2, -516821.0, 1226.0, -524.0, 224386.0, -677.0, -174.0], // 7 - [ 1, 0, 0, 0, 0, 711159.0, 73.0, -872.0, -6750.0, 0.0, 358.0], + [1, 0, 0, 0, 0, 711159.0, 73.0, -872.0, -6750.0, 0.0, 358.0], // 8 - [ 0, 0, 2, 0, 1, -387298.0, -367.0, 380.0, 200728.0, 18.0, 318.0], + [0, 0, 2, 0, 1, -387298.0, -367.0, 380.0, 200728.0, 18.0, 318.0], // 9 - [ 1, 0, 2, 0, 2, -301461.0, -36.0, 816.0, 129025.0, -63.0, 367.0], + [1, 0, 2, 0, 2, -301461.0, -36.0, 816.0, 129025.0, -63.0, 367.0], // 10 - [ 0,-1, 2,-2, 2, 215829.0, -494.0, 111.0, -95929.0, 299.0, 132.0], + [0, -1, 2, -2, 2, 215829.0, -494.0, 111.0, -95929.0, 299.0, 132.0], // 11 - [ 0, 0, 2,-2, 1, 128227.0, 137.0, 181.0, -68982.0, -9.0, 39.0], + [0, 0, 2, -2, 1, 128227.0, 137.0, 181.0, -68982.0, -9.0, 39.0], // 12 - [-1, 0, 2, 0, 2, 123457.0, 11.0, 19.0, -53311.0, 32.0, -4.0], + [-1, 0, 2, 0, 2, 123457.0, 11.0, 19.0, -53311.0, 32.0, -4.0], // 13 - [-1, 0, 0, 2, 0, 156994.0, 10.0, -168.0, -1235.0, 0.0, 82.0], + [-1, 0, 0, 2, 0, 156994.0, 10.0, -168.0, -1235.0, 0.0, 82.0], // 14 - [ 1, 0, 0, 0, 1, 63110.0, 63.0, 27.0, -33228.0, 0.0, -9.0], + [1, 0, 0, 0, 1, 63110.0, 63.0, 27.0, -33228.0, 0.0, -9.0], // 15 - [-1, 0, 0, 0, 1, -57976.0, -63.0, -189.0, 31429.0, 0.0, -75.0], + [-1, 0, 0, 0, 1, -57976.0, -63.0, -189.0, 31429.0, 0.0, -75.0], // 16 - [-1, 0, 2, 2, 2, -59641.0, -11.0, 149.0, 25543.0, -11.0, 66.0], + [-1, 0, 2, 2, 2, -59641.0, -11.0, 149.0, 25543.0, -11.0, 66.0], // 17 - [ 1, 0, 2, 0, 1, -51613.0, -42.0, 129.0, 26366.0, 0.0, 78.0], + [1, 0, 2, 0, 1, -51613.0, -42.0, 129.0, 26366.0, 0.0, 78.0], // 18 - [-2, 0, 2, 0, 1, 45893.0, 50.0, 31.0, -24236.0, -10.0, 20.0], + [-2, 0, 2, 0, 1, 45893.0, 50.0, 31.0, -24236.0, -10.0, 20.0], // 19 - [ 0, 0, 0, 2, 0, 63384.0, 11.0, -150.0, -1220.0, 0.0, 29.0], + [0, 0, 0, 2, 0, 63384.0, 11.0, -150.0, -1220.0, 0.0, 29.0], // 20 - [ 0, 0, 2, 2, 2, -38571.0, -1.0, 158.0, 16452.0, -11.0, 68.0], + [0, 0, 2, 2, 2, -38571.0, -1.0, 158.0, 16452.0, -11.0, 68.0], // 21 - [ 0,-2, 2,-2, 2, 32481.0, 0.0, 0.0, -13870.0, 0.0, 0.0], + [0, -2, 2, -2, 2, 32481.0, 0.0, 0.0, -13870.0, 0.0, 0.0], // 22 - [-2, 0, 0, 2, 0, -47722.0, 0.0, -18.0, 477.0, 0.0, -25.0], + [-2, 0, 0, 2, 0, -47722.0, 0.0, -18.0, 477.0, 0.0, -25.0], // 23 - [ 2, 0, 2, 0, 2, -31046.0, -1.0, 131.0, 13238.0, -11.0, 59.0], + [2, 0, 2, 0, 2, -31046.0, -1.0, 131.0, 13238.0, -11.0, 59.0], // 24 - [ 1, 0, 2,-2, 2, 28593.0, 0.0, -1.0, -12338.0, 10.0, -3.0], + [1, 0, 2, -2, 2, 28593.0, 0.0, -1.0, -12338.0, 10.0, -3.0], // 25 - [-1, 0, 2, 0, 1, 20441.0, 21.0, 10.0, -10758.0, 0.0, -3.0], + [-1, 0, 2, 0, 1, 20441.0, 21.0, 10.0, -10758.0, 0.0, -3.0], // 26 - [ 2, 0, 0, 0, 0, 29243.0, 0.0, -74.0, -609.0, 0.0, 13.0], + [2, 0, 0, 0, 0, 29243.0, 0.0, -74.0, -609.0, 0.0, 13.0], // 27 - [ 0, 0, 2, 0, 0, 25887.0, 0.0, -66.0, -550.0, 0.0, 11.0], + [0, 0, 2, 0, 0, 25887.0, 0.0, -66.0, -550.0, 0.0, 11.0], // 28 - [ 0, 1, 0, 0, 1, -14053.0, -25.0, 79.0, 8551.0, -2.0, -45.0], + [0, 1, 0, 0, 1, -14053.0, -25.0, 79.0, 8551.0, -2.0, -45.0], // 29 - [-1, 0, 0, 2, 1, 15164.0, 10.0, 11.0, -8001.0, 0.0, -1.0], + [-1, 0, 0, 2, 1, 15164.0, 10.0, 11.0, -8001.0, 0.0, -1.0], // 30 - [ 0, 2, 2,-2, 2, -15794.0, 72.0, -16.0, 6850.0, -42.0, -5.0], + [0, 2, 2, -2, 2, -15794.0, 72.0, -16.0, 6850.0, -42.0, -5.0], // 31 - [ 0, 0,-2, 2, 0, 21783.0, 0.0, 13.0, -167.0, 0.0, 13.0], + [0, 0, -2, 2, 0, 21783.0, 0.0, 13.0, -167.0, 0.0, 13.0], // 32 - [ 1, 0, 0,-2, 1, -12873.0, -10.0, -37.0, 6953.0, 0.0, -14.0], + [1, 0, 0, -2, 1, -12873.0, -10.0, -37.0, 6953.0, 0.0, -14.0], // 33 - [ 0,-1, 0, 0, 1, -12654.0, 11.0, 63.0, 6415.0, 0.0, 26.0], + [0, -1, 0, 0, 1, -12654.0, 11.0, 63.0, 6415.0, 0.0, 26.0], // 34 - [-1, 0, 2, 2, 1, -10204.0, 0.0, 25.0, 5222.0, 0.0, 15.0], + [-1, 0, 2, 2, 1, -10204.0, 0.0, 25.0, 5222.0, 0.0, 15.0], // 35 - [ 0, 2, 0, 0, 0, 16707.0, -85.0, -10.0, 168.0, -1.0, 10.0], + [0, 2, 0, 0, 0, 16707.0, -85.0, -10.0, 168.0, -1.0, 10.0], // 36 - [ 1, 0, 2, 2, 2, -7691.0, 0.0, 44.0, 3268.0, 0.0, 19.0], + [1, 0, 2, 2, 2, -7691.0, 0.0, 44.0, 3268.0, 0.0, 19.0], // 37 - [-2, 0, 2, 0, 0, -11024.0, 0.0, -14.0, 104.0, 0.0, 2.0], + [-2, 0, 2, 0, 0, -11024.0, 0.0, -14.0, 104.0, 0.0, 2.0], // 38 - [ 0, 1, 2, 0, 2, 7566.0, -21.0, -11.0, -3250.0, 0.0, -5.0], + [0, 1, 2, 0, 2, 7566.0, -21.0, -11.0, -3250.0, 0.0, -5.0], // 39 - [ 0, 0, 2, 2, 1, -6637.0, -11.0, 25.0, 3353.0, 0.0, 14.0], + [0, 0, 2, 2, 1, -6637.0, -11.0, 25.0, 3353.0, 0.0, 14.0], // 40 - [ 0,-1, 2, 0, 2, -7141.0, 21.0, 8.0, 3070.0, 0.0, 4.0], + [0, -1, 2, 0, 2, -7141.0, 21.0, 8.0, 3070.0, 0.0, 4.0], // 41 - [ 0, 0, 0, 2, 1, -6302.0, -11.0, 2.0, 3272.0, 0.0, 4.0], + [0, 0, 0, 2, 1, -6302.0, -11.0, 2.0, 3272.0, 0.0, 4.0], // 42 - [ 1, 0, 2,-2, 1, 5800.0, 10.0, 2.0, -3045.0, 0.0, -1.0], + [1, 0, 2, -2, 1, 5800.0, 10.0, 2.0, -3045.0, 0.0, -1.0], // 43 - [ 2, 0, 2,-2, 2, 6443.0, 0.0, -7.0, -2768.0, 0.0, -4.0], + [2, 0, 2, -2, 2, 6443.0, 0.0, -7.0, -2768.0, 0.0, -4.0], // 44 - [-2, 0, 0, 2, 1, -5774.0, -11.0, -15.0, 3041.0, 0.0, -5.0], + [-2, 0, 0, 2, 1, -5774.0, -11.0, -15.0, 3041.0, 0.0, -5.0], // 45 - [ 2, 0, 2, 0, 1, -5350.0, 0.0, 21.0, 2695.0, 0.0, 12.0], + [2, 0, 2, 0, 1, -5350.0, 0.0, 21.0, 2695.0, 0.0, 12.0], // 46 - [ 0,-1, 2,-2, 1, -4752.0, -11.0, -3.0, 2719.0, 0.0, -3.0], + [0, -1, 2, -2, 1, -4752.0, -11.0, -3.0, 2719.0, 0.0, -3.0], // 47 - [ 0, 0, 0,-2, 1, -4940.0, -11.0, -21.0, 2720.0, 0.0, -9.0], + [0, 0, 0, -2, 1, -4940.0, -11.0, -21.0, 2720.0, 0.0, -9.0], // 48 - [-1,-1, 0, 2, 0, 7350.0, 0.0, -8.0, -51.0, 0.0, 4.0], + [-1, -1, 0, 2, 0, 7350.0, 0.0, -8.0, -51.0, 0.0, 4.0], // 49 - [ 2, 0, 0,-2, 1, 4065.0, 0.0, 6.0, -2206.0, 0.0, 1.0], + [2, 0, 0, -2, 1, 4065.0, 0.0, 6.0, -2206.0, 0.0, 1.0], // 50 - [ 1, 0, 0, 2, 0, 6579.0, 0.0, -24.0, -199.0, 0.0, 2.0], + [1, 0, 0, 2, 0, 6579.0, 0.0, -24.0, -199.0, 0.0, 2.0], // 51 - [ 0, 1, 2,-2, 1, 3579.0, 0.0, 5.0, -1900.0, 0.0, 1.0], + [0, 1, 2, -2, 1, 3579.0, 0.0, 5.0, -1900.0, 0.0, 1.0], // 52 - [ 1,-1, 0, 0, 0, 4725.0, 0.0, -6.0, -41.0, 0.0, 3.0], + [1, -1, 0, 0, 0, 4725.0, 0.0, -6.0, -41.0, 0.0, 3.0], // 53 - [-2, 0, 2, 0, 2, -3075.0, 0.0, -2.0, 1313.0, 0.0, -1.0], + [-2, 0, 2, 0, 2, -3075.0, 0.0, -2.0, 1313.0, 0.0, -1.0], // 54 - [ 3, 0, 2, 0, 2, -2904.0, 0.0, 15.0, 1233.0, 0.0, 7.0], + [3, 0, 2, 0, 2, -2904.0, 0.0, 15.0, 1233.0, 0.0, 7.0], // 55 - [ 0,-1, 0, 2, 0, 4348.0, 0.0, -10.0, -81.0, 0.0, 2.0], + [0, -1, 0, 2, 0, 4348.0, 0.0, -10.0, -81.0, 0.0, 2.0], // 56 - [ 1,-1, 2, 0, 2, -2878.0, 0.0, 8.0, 1232.0, 0.0, 4.0], + [1, -1, 2, 0, 2, -2878.0, 0.0, 8.0, 1232.0, 0.0, 4.0], // 57 - [ 0, 0, 0, 1, 0, -4230.0, 0.0, 5.0, -20.0, 0.0, -2.0], + [0, 0, 0, 1, 0, -4230.0, 0.0, 5.0, -20.0, 0.0, -2.0], // 58 - [-1,-1, 2, 2, 2, -2819.0, 0.0, 7.0, 1207.0, 0.0, 3.0], + [-1, -1, 2, 2, 2, -2819.0, 0.0, 7.0, 1207.0, 0.0, 3.0], // 59 - [-1, 0, 2, 0, 0, -4056.0, 0.0, 5.0, 40.0, 0.0, -2.0], + [-1, 0, 2, 0, 0, -4056.0, 0.0, 5.0, 40.0, 0.0, -2.0], // 60 - [ 0,-1, 2, 2, 2, -2647.0, 0.0, 11.0, 1129.0, 0.0, 5.0], + [0, -1, 2, 2, 2, -2647.0, 0.0, 11.0, 1129.0, 0.0, 5.0], // 61 - [-2, 0, 0, 0, 1, -2294.0, 0.0, -10.0, 1266.0, 0.0, -4.0], + [-2, 0, 0, 0, 1, -2294.0, 0.0, -10.0, 1266.0, 0.0, -4.0], // 62 - [ 1, 1, 2, 0, 2, 2481.0, 0.0, -7.0, -1062.0, 0.0, -3.0], + [1, 1, 2, 0, 2, 2481.0, 0.0, -7.0, -1062.0, 0.0, -3.0], // 63 - [ 2, 0, 0, 0, 1, 2179.0, 0.0, -2.0, -1129.0, 0.0, -2.0], + [2, 0, 0, 0, 1, 2179.0, 0.0, -2.0, -1129.0, 0.0, -2.0], // 64 - [-1, 1, 0, 1, 0, 3276.0, 0.0, 1.0, -9.0, 0.0, 0.0], + [-1, 1, 0, 1, 0, 3276.0, 0.0, 1.0, -9.0, 0.0, 0.0], // 65 - [ 1, 1, 0, 0, 0, -3389.0, 0.0, 5.0, 35.0, 0.0, -2.0], + [1, 1, 0, 0, 0, -3389.0, 0.0, 5.0, 35.0, 0.0, -2.0], // 66 - [ 1, 0, 2, 0, 0, 3339.0, 0.0, -13.0, -107.0, 0.0, 1.0], + [1, 0, 2, 0, 0, 3339.0, 0.0, -13.0, -107.0, 0.0, 1.0], // 67 - [-1, 0, 2,-2, 1, -1987.0, 0.0, -6.0, 1073.0, 0.0, -2.0], + [-1, 0, 2, -2, 1, -1987.0, 0.0, -6.0, 1073.0, 0.0, -2.0], // 68 - [ 1, 0, 0, 0, 2, -1981.0, 0.0, 0.0, 854.0, 0.0, 0.0], + [1, 0, 0, 0, 2, -1981.0, 0.0, 0.0, 854.0, 0.0, 0.0], // 69 - [-1, 0, 0, 1, 0, 4026.0, 0.0, -353.0, -553.0, 0.0, -139.0], + [-1, 0, 0, 1, 0, 4026.0, 0.0, -353.0, -553.0, 0.0, -139.0], // 70 - [ 0, 0, 2, 1, 2, 1660.0, 0.0, -5.0, -710.0, 0.0, -2.0], + [0, 0, 2, 1, 2, 1660.0, 0.0, -5.0, -710.0, 0.0, -2.0], // 71 - [-1, 0, 2, 4, 2, -1521.0, 0.0, 9.0, 647.0, 0.0, 4.0], + [-1, 0, 2, 4, 2, -1521.0, 0.0, 9.0, 647.0, 0.0, 4.0], // 72 - [-1, 1, 0, 1, 1, 1464.0, 0.0, -11.0, -527.0, 0.0, -1.0], + [-1, 1, 0, 1, 1, 1464.0, 0.0, -11.0, -527.0, 0.0, -1.0], // 73 - [ 0,-2, 2,-2, 1, -1389.0, 0.0, 3.0, 656.0, 0.0, 1.0], + [0, -2, 2, -2, 1, -1389.0, 0.0, 3.0, 656.0, 0.0, 1.0], // 74 - [ 1,-1, 2, 2, 2, -1377.0, 0.0, 8.0, 594.0, 0.0, 4.0], + [1, -1, 2, 2, 2, -1377.0, 0.0, 8.0, 594.0, 0.0, 4.0], // 75 - [ 3, 0, 2,-2, 2, 1371.0, 0.0, -2.0, -588.0, 0.0, -1.0], + [3, 0, 2, -2, 2, 1371.0, 0.0, -2.0, -588.0, 0.0, -1.0], // 76 - [ 0, 0, 4,-2, 4, 1341.0, 0.0, 0.0, -577.0, 0.0, 0.0], + [0, 0, 4, -2, 4, 1341.0, 0.0, 0.0, -577.0, 0.0, 0.0], // 77 - [ 0, 0, 2,-2, 4, -1316.0, 0.0, 0.0, 567.0, 0.0, 0.0], + [0, 0, 2, -2, 4, -1316.0, 0.0, 0.0, 567.0, 0.0, 0.0], ] // ─── Fundamental arguments (Delaunay) ──────────────────────────────────────── @@ -224,35 +222,35 @@ function arcsecToRad(arcsec: number): number { /** Mean anomaly of the Moon l (IAU 2003) */ function fundamentalL(T: number): number { return arcsecToRad( - 485868.249036 + T * (1717915923.2178 + T * (31.8792 + T * (0.051635 + T * (-0.00024470)))) + 485868.249036 + T * (1717915923.2178 + T * (31.8792 + T * (0.051635 + T * -0.0002447))), ) } /** Mean anomaly of the Sun l' (IAU 2003) */ function fundamentalLp(T: number): number { return arcsecToRad( - 1287104.793048 + T * (129596581.0481 + T * (-0.5532 + T * (0.000136 + T * (-0.00001149)))) + 1287104.793048 + T * (129596581.0481 + T * (-0.5532 + T * (0.000136 + T * -0.00001149))), ) } /** Moon's argument of latitude F = L - Ω (IAU 2003) */ function fundamentalF(T: number): number { return arcsecToRad( - 335779.526232 + T * (1739527262.8478 + T * (-12.7512 + T * (-0.001037 + T * 0.00000417))) + 335779.526232 + T * (1739527262.8478 + T * (-12.7512 + T * (-0.001037 + T * 0.00000417))), ) } /** Mean elongation of the Moon D (IAU 2003) */ function fundamentalD(T: number): number { return arcsecToRad( - 1072260.703692 + T * (1602961601.2090 + T * (-6.3706 + T * (0.006593 + T * (-0.00003169)))) + 1072260.703692 + T * (1602961601.209 + T * (-6.3706 + T * (0.006593 + T * -0.00003169))), ) } /** Longitude of Moon's ascending node Ω (IAU 2003) */ function fundamentalOm(T: number): number { return arcsecToRad( - 450160.398036 + T * (-6962890.5431 + T * (7.4722 + T * (0.007702 + T * (-0.00005939)))) + 450160.398036 + T * (-6962890.5431 + T * (7.4722 + T * (0.007702 + T * -0.00005939))), ) } @@ -274,24 +272,21 @@ function fundamentalOm(T: number): number { * @param jdTT - Julian Date in TT * @returns { X, Y, s } in radians */ -export function computeCIPXYs( - jdTT: number, -): { X: number; Y: number; s: number } { - +export function computeCIPXYs(jdTT: number): { X: number; Y: number; s: number } { const T = (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY // Delaunay fundamental arguments - const l = fundamentalL(T) + const l = fundamentalL(T) const lp = fundamentalLp(T) - const F = fundamentalF(T) - const D = fundamentalD(T) + const F = fundamentalF(T) + const D = fundamentalD(T) const Om = fundamentalOm(T) // Accumulate nutation in longitude (dpsi) and obliquity (deps) — units: 0.1 uas let dpsi = 0.0 let deps = 0.0 for (const [nl, nlp, nF, nD, nOm, ps, pst, pc, ec, ect, es] of NUT_2000B) { - const arg = nl*l + nlp*lp + nF*F + nD*D + nOm*Om + const arg = nl * l + nlp * lp + nF * F + nD * D + nOm * Om const sinA = Math.sin(arg) const cosA = Math.cos(arg) dpsi += (ps + pst * T) * sinA + pc * cosA @@ -304,34 +299,24 @@ export function computeCIPXYs( // Mean obliquity eps0 (IAU 2006, arcseconds → radians) // Reference: IERS Conventions (2010) Table 5.1 - const eps0 = ( - 84381.406 - + T * (-46.836769 - + T * (-0.0001831 - + T * ( 0.00200340 - + T * (-0.000000576 - + T * (-0.0000000434))))) - ) * ARCSEC_RAD + const eps0 = + (84381.406 + + T * + (-46.836769 + + T * (-0.0001831 + T * (0.0020034 + T * (-0.000000576 + T * -0.0000000434))))) * + ARCSEC_RAD // IAU 2006 precession polynomial for X (arcseconds) // Reference: IERS Conventions (2010) Table 5.2a, polynomial s_X const Xarcsec = - -0.016617 - + T * ( 2004.191898 - + T * ( -0.4297829 - + T * ( -0.19861834 - + T * ( 0.000007578 - + T * 0.0000059285)))) + -0.016617 + + T * (2004.191898 + T * (-0.4297829 + T * (-0.19861834 + T * (0.000007578 + T * 0.0000059285)))) // IAU 2006 precession polynomial for Y (arcseconds) // Reference: IERS Conventions (2010) Table 5.2a, polynomial s_Y const Yarcsec = - -0.006951 - + T * ( -0.025896 - + T * ( -22.4072747 - + T * ( 0.00190059 - + T * ( 0.001112526 - + T * 0.0000001358)))) + -0.006951 + + T * (-0.025896 + T * (-22.4072747 + T * (0.00190059 + T * (0.001112526 + T * 0.0000001358)))) // CIP X, Y: precession polynomial + first-order nutation correction const X = Xarcsec * ARCSEC_RAD + dpsiRad * Math.sin(eps0) @@ -340,7 +325,7 @@ export function computeCIPXYs( // CIO locator s ≈ -X·Y/2 + small polynomial (IERS Conventions 2010 Eq. 5.9) // Polynomial term: s_poly ≈ -0.041775"·T (arcseconds) const sPoly = -0.041775 * T * ARCSEC_RAD - const s = -X * Y / 2 + sPoly + const s = (-X * Y) / 2 + sPoly return { X, Y, s } } @@ -361,7 +346,7 @@ export function computeCIPXYs( */ export function computeERA(jdUT1: number): number { const Du = jdUT1 - 2451545.0 - const era = 2 * Math.PI * (0.7790572732640 + 1.00273781191135448 * Du) + const era = 2 * Math.PI * (0.779057273264 + 1.0027378119113546 * Du) return ((era % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI) } @@ -422,12 +407,7 @@ export function polarMotionMatrix(xp: number, yp: number): Mat3 { * @param yp - Polar motion y (radians, default 0) * @returns Vector in ITRS frame (km) */ -export function gcrsToItrs( - gcrsVec: Vec3, - ts: TimeScales, - xp = 0, - yp = 0, -): Vec3 { +export function gcrsToItrs(gcrsVec: Vec3, ts: TimeScales, xp = 0, yp = 0): Vec3 { const { X, Y, s } = computeCIPXYs(ts.jdTT) const Q = celestialMotionMatrix(X, Y, s) const era = computeERA(ts.jdUT1) @@ -449,12 +429,7 @@ export function gcrsToItrs( * @param yp - Polar motion y (radians, default 0) * @returns Vector in GCRS frame (km) */ -export function itrsToGcrs( - itrsVec: Vec3, - ts: TimeScales, - xp = 0, - yp = 0, -): Vec3 { +export function itrsToGcrs(itrsVec: Vec3, ts: TimeScales, xp = 0, yp = 0): Vec3 { const { X, Y, s } = computeCIPXYs(ts.jdTT) const Q = celestialMotionMatrix(X, Y, s) const era = computeERA(ts.jdUT1) diff --git a/src/math/index.ts b/src/math/index.ts index 2c50259..3904cd4 100644 --- a/src/math/index.ts +++ b/src/math/index.ts @@ -36,11 +36,7 @@ export function vnorm(a: Vec3): number { /** Cross product */ export function vcross(a: Vec3, b: Vec3): Vec3 { - return [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], - ] + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] } /** Unit vector (normalized) */ @@ -59,11 +55,7 @@ export function angularSep(a: Vec3, b: Vec3): number { // ─── 3×3 matrix operations ──────────────────────────────────────────────────── /** 3×3 matrix stored row-major as a 9-element tuple */ -export type Mat3 = [ - number, number, number, - number, number, number, - number, number, number, -] +export type Mat3 = [number, number, number, number, number, number, number, number, number] /** Multiply 3×3 matrix by 3-vector */ export function mvmul(m: Mat3, v: Vec3): Vec3 { @@ -77,15 +69,15 @@ export function mvmul(m: Mat3, v: Vec3): Vec3 { /** Multiply two 3×3 matrices */ export function mmmul(a: Mat3, b: Mat3): Mat3 { return [ - a[0]*b[0] + a[1]*b[3] + a[2]*b[6], - a[0]*b[1] + a[1]*b[4] + a[2]*b[7], - a[0]*b[2] + a[1]*b[5] + a[2]*b[8], - a[3]*b[0] + a[4]*b[3] + a[5]*b[6], - a[3]*b[1] + a[4]*b[4] + a[5]*b[7], - a[3]*b[2] + a[4]*b[5] + a[5]*b[8], - a[6]*b[0] + a[7]*b[3] + a[8]*b[6], - a[6]*b[1] + a[7]*b[4] + a[8]*b[7], - a[6]*b[2] + a[7]*b[5] + a[8]*b[8], + a[0] * b[0] + a[1] * b[3] + a[2] * b[6], + a[0] * b[1] + a[1] * b[4] + a[2] * b[7], + a[0] * b[2] + a[1] * b[5] + a[2] * b[8], + a[3] * b[0] + a[4] * b[3] + a[5] * b[6], + a[3] * b[1] + a[4] * b[4] + a[5] * b[7], + a[3] * b[2] + a[4] * b[5] + a[5] * b[8], + a[6] * b[0] + a[7] * b[3] + a[8] * b[6], + a[6] * b[1] + a[7] * b[4] + a[8] * b[7], + a[6] * b[2] + a[7] * b[5] + a[8] * b[8], ] } @@ -173,14 +165,18 @@ export function chebyshevEvalWithDerivative( if (n === 1) return [coeffs[0], 0] const x2 = 2 * x - let b2 = 0; let b1 = 0 - let db2 = 0; let db1 = 0 + let b2 = 0 + let b1 = 0 + let db2 = 0 + let db1 = 0 for (let k = n - 1; k >= 1; k--) { const b0 = coeffs[k] + x2 * b1 - b2 const db0 = 2 * b1 + x2 * db1 - db2 - b2 = b1; b1 = b0 - db2 = db1; db1 = db0 + b2 = b1 + b1 = b0 + db2 = db1 + db1 = db0 } const value = coeffs[0] + x * b1 - b2 @@ -227,7 +223,7 @@ export function brentRoot( let c = a let fc = fa let mflag = true - let s = 0 + let s: number let d = 0 for (let i = 0; i < maxIter; i++) { @@ -289,12 +285,7 @@ export function brentRoot( * @param steps - Number of initial subdivision steps (default 48 for 30-min resolution over a day) * @returns Array of root locations */ -export function findRoots( - f: (t: number) => number, - a: number, - b: number, - steps = 48, -): number[] { +export function findRoots(f: (t: number) => number, a: number, b: number, steps = 48): number[] { const dt = (b - a) / steps const roots: number[] = [] let tPrev = a diff --git a/src/observer/index.ts b/src/observer/index.ts index a4c7841..4dbdf79 100644 --- a/src/observer/index.ts +++ b/src/observer/index.ts @@ -21,6 +21,7 @@ import type { Vec3, Observer, AzAlt, TimeScales } from '../types.js' import { WGS84 } from '../types.js' import { gcrsToItrs } from '../frames/index.js' +import { vdot } from '../math/index.js' // ─── Geodetic ↔ ECEF ───────────────────────────────────────────────────────── @@ -93,12 +94,14 @@ export function ecefToGeodetic(ecef: Vec3): { lat: number; lon: number; h: numbe export function computeENUBasis(lat: number, lon: number): { east: Vec3; north: Vec3; up: Vec3 } { const phi = (lat * Math.PI) / 180 const lam = (lon * Math.PI) / 180 - const sinPhi = Math.sin(phi), cosPhi = Math.cos(phi) - const sinLam = Math.sin(lam), cosLam = Math.cos(lam) + const sinPhi = Math.sin(phi), + cosPhi = Math.cos(phi) + const sinLam = Math.sin(lam), + cosLam = Math.cos(lam) const east: Vec3 = [-sinLam, cosLam, 0] const north: Vec3 = [-sinPhi * cosLam, -sinPhi * sinLam, cosPhi] - const up: Vec3 = [ cosPhi * cosLam, cosPhi * sinLam, sinPhi] + const up: Vec3 = [cosPhi * cosLam, cosPhi * sinLam, sinPhi] return { east, north, up } } @@ -114,8 +117,7 @@ export function computeENUBasis(lat: number, lon: number): { east: Vec3; north: */ export function ecefToENU(ecefDelta: Vec3, lat: number, lon: number): Vec3 { const { east, north, up } = computeENUBasis(lat, lon) - const dot = (a: Vec3, b: Vec3) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] - return [dot(ecefDelta, east), dot(ecefDelta, north), dot(ecefDelta, up)] + return [vdot(ecefDelta, east), vdot(ecefDelta, north), vdot(ecefDelta, up)] } /** @@ -185,7 +187,6 @@ export function computeAzAlt( ts: TimeScales, airless: boolean, ): AzAlt { - // 1. Convert body position from GCRS to ITRS (km) const bodyITRS = gcrsToItrs(bodyGCRS, ts) @@ -194,11 +195,7 @@ export function computeAzAlt( const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000] // 3. Displacement vector from observer to body in ITRS (km — magnitude doesn't matter) - const delta: Vec3 = [ - bodyITRS[0] - obsITRS[0], - bodyITRS[1] - obsITRS[1], - bodyITRS[2] - obsITRS[2], - ] + const delta: Vec3 = [bodyITRS[0] - obsITRS[0], bodyITRS[1] - obsITRS[1], bodyITRS[2] - obsITRS[2]] // 4. Project onto local ENU basis at the observer's location const enu = ecefToENU(delta, observer.lat, observer.lon) @@ -208,11 +205,7 @@ export function computeAzAlt( // 6. Refraction correction if (!airless) { - azAlt.altitude = applyRefraction( - azAlt.altitude, - observer.pressure, - observer.temperature, - ) + azAlt.altitude = applyRefraction(azAlt.altitude, observer.pressure, observer.temperature) } return azAlt @@ -261,11 +254,7 @@ export function bennettRefraction( * Apply refraction correction to an airless altitude. * Returns the apparent (observed) altitude. */ -export function applyRefraction( - airlessAlt: number, - pressure = 1013.25, - temperature = 15, -): number { +export function applyRefraction(airlessAlt: number, pressure = 1013.25, temperature = 15): number { return airlessAlt + bennettRefraction(airlessAlt, pressure, temperature) } diff --git a/src/spk/index.ts b/src/spk/index.ts index 00d784e..2991849 100644 --- a/src/spk/index.ts +++ b/src/spk/index.ts @@ -31,10 +31,10 @@ import { chebyshevEvalWithDerivative } from '../math/index.js' /** NAIF integer body IDs used in DE442S segment chaining */ export const NAIF_IDS = { - SSB: 0, // Solar System Barycenter + SSB: 0, // Solar System Barycenter MERCURY_BARYCENTER: 1, VENUS_BARYCENTER: 2, - EMB: 3, // Earth-Moon Barycenter + EMB: 3, // Earth-Moon Barycenter MARS_BARYCENTER: 4, JUPITER_BARYCENTER: 5, SATURN_BARYCENTER: 6, @@ -107,7 +107,7 @@ export class SpkKernel { private findSeg(target: number, center: number, et: number): SpkSegment | null { const candidates = this.index.get(`${target}:${center}`) if (!candidates) return null - return candidates.find(s => et >= s.startET && et <= s.endET) ?? null + return candidates.find((s) => et >= s.startET && et <= s.endET) ?? null } private getChained(target: number, center: number, et: number): StateVector { @@ -174,7 +174,12 @@ export class SpkKernel { // ─── DAF parsing ────────────────────────────────────────────────────────────── function parseDafFileRecord(buffer: ArrayBuffer): { - nd: number; ni: number; fward: number; bward: number; free: number; le: boolean + nd: number + ni: number + fward: number + bward: number + free: number + le: boolean } { const dv = new DataView(buffer) @@ -214,7 +219,7 @@ function parseSummaryRecords( const nextRecord = dv.getFloat64(recOffset, le) const nSummaries = Math.round(dv.getFloat64(recOffset + 16, le)) - let offset = recOffset + 24 // skip 3 control doubles (24 bytes) + let offset = recOffset + 24 // skip 3 control doubles (24 bytes) for (let i = 0; i < nSummaries; i++) { if (offset + summaryBytes > buffer.byteLength) break @@ -383,8 +388,18 @@ export function parseLsk(text: string): ReadonlyArray const block = match[1] const months: Record = { - JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, - JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12, + JAN: 1, + FEB: 2, + MAR: 3, + APR: 4, + MAY: 5, + JUN: 6, + JUL: 7, + AUG: 8, + SEP: 9, + OCT: 10, + NOV: 11, + DEC: 12, } const pairRe = /(-?\d+(?:\.\d+)?)\s*,\s*@(\d{4})-([A-Z]{3})-(\d{1,2})/g @@ -400,8 +415,14 @@ export function parseLsk(text: string): ReadonlyArray const a = Math.floor((14 - month) / 12) const y = year + 4800 - a const mo = month + 12 * a - 3 - const jdNoon = day + Math.floor((153 * mo + 2) / 5) + 365 * y + - Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045 + const jdNoon = + day + + Math.floor((153 * mo + 2) / 5) + + 365 * y + + Math.floor(y / 4) - + Math.floor(y / 100) + + Math.floor(y / 400) - + 32045 // Midnight = JD - 0.5 results.push([jdNoon - 0.5, deltaAT]) } diff --git a/src/time/index.ts b/src/time/index.ts index 9666915..a31b484 100644 --- a/src/time/index.ts +++ b/src/time/index.ts @@ -202,55 +202,74 @@ export function deltaTPolynomial(jdTT: number): number { } else if (y < 500) { const u = y / 100 return ( - 10583.6 - 1014.41 * u + 33.78311 * u * u - 5.952053 * u * u * u - - 0.1798452 * u ** 4 + 0.022174192 * u ** 5 + 0.0090316521 * u ** 6 + 10583.6 - + 1014.41 * u + + 33.78311 * u * u - + 5.952053 * u * u * u - + 0.1798452 * u ** 4 + + 0.022174192 * u ** 5 + + 0.0090316521 * u ** 6 ) } else if (y < 1600) { const u = (y - 1000) / 100 return ( - 1574.2 - 556.01 * u + 71.23472 * u * u + 0.319781 * u ** 3 - - 0.8503463 * u ** 4 - 0.005050998 * u ** 5 + 0.0083572073 * u ** 6 + 1574.2 - + 556.01 * u + + 71.23472 * u * u + + 0.319781 * u ** 3 - + 0.8503463 * u ** 4 - + 0.005050998 * u ** 5 + + 0.0083572073 * u ** 6 ) } else if (y < 1700) { const t = y - 1600 return 120 - 0.9808 * t - 0.01532 * t * t + t ** 3 / 7129 } else if (y < 1800) { const t = y - 1700 - return ( - 8.83 + 0.1603 * t - 0.0059285 * t * t + 0.00013336 * t ** 3 - t ** 4 / 1174000 - ) + return 8.83 + 0.1603 * t - 0.0059285 * t * t + 0.00013336 * t ** 3 - t ** 4 / 1174000 } else if (y < 1860) { const t = y - 1800 return ( - 13.72 - 0.332447 * t + 0.0068612 * t * t + 0.0041116 * t ** 3 - - 0.00037436 * t ** 4 + 0.0000121272 * t ** 5 - - 0.0000001699 * t ** 6 + 0.000000000875 * t ** 7 + 13.72 - + 0.332447 * t + + 0.0068612 * t * t + + 0.0041116 * t ** 3 - + 0.00037436 * t ** 4 + + 0.0000121272 * t ** 5 - + 0.0000001699 * t ** 6 + + 0.000000000875 * t ** 7 ) } else if (y < 1900) { const t = y - 1860 return ( - 7.62 + 0.5737 * t - 0.251754 * t * t + 0.01680668 * t ** 3 - - 0.0004473624 * t ** 4 + t ** 5 / 233174 + 7.62 + + 0.5737 * t - + 0.251754 * t * t + + 0.01680668 * t ** 3 - + 0.0004473624 * t ** 4 + + t ** 5 / 233174 ) } else if (y < 1920) { const t = y - 1900 - return ( - -2.79 + 1.494119 * t - 0.0598939 * t * t + 0.0061966 * t ** 3 - 0.000197 * t ** 4 - ) + return -2.79 + 1.494119 * t - 0.0598939 * t * t + 0.0061966 * t ** 3 - 0.000197 * t ** 4 } else if (y < 1941) { const t = y - 1920 - return 21.20 + 0.84493 * t - 0.076100 * t * t + 0.0020936 * t ** 3 + return 21.2 + 0.84493 * t - 0.0761 * t * t + 0.0020936 * t ** 3 } else if (y < 1961) { const t = y - 1950 - return 29.07 + 0.407 * t - t * t / 233 + t ** 3 / 2547 + return 29.07 + 0.407 * t - (t * t) / 233 + t ** 3 / 2547 } else if (y < 1986) { const t = y - 1975 - return 45.45 + 1.067 * t - t * t / 260 - t ** 3 / 718 + return 45.45 + 1.067 * t - (t * t) / 260 - t ** 3 / 718 } else if (y < 2005) { const t = y - 2000 return ( - 63.86 + 0.3345 * t - 0.060374 * t * t + 0.0017275 * t ** 3 + - 0.000651814 * t ** 4 + 0.00002373599 * t ** 5 + 63.86 + + 0.3345 * t - + 0.060374 * t * t + + 0.0017275 * t ** 3 + + 0.000651814 * t ** 4 + + 0.00002373599 * t ** 5 ) } else if (y < 2050) { const t = y - 2000 diff --git a/src/types.ts b/src/types.ts index b7ae00f..a4e4d56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,8 +5,8 @@ export type Vec3 = [number, number, number] /** Position + velocity state vector from the ephemeris */ export interface StateVector { - position: Vec3 // km, in the frame determined by context - velocity: Vec3 // km/s + position: Vec3 // km, in the frame determined by context + velocity: Vec3 // km/s } /** Azimuth + altitude in degrees */ @@ -154,7 +154,7 @@ export type YallopCategory = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' export const YALLOP_THRESHOLDS = { A: 0.216, B: -0.014, - C: -0.160, + C: -0.16, D: -0.232, E: -0.293, } as const @@ -199,7 +199,7 @@ export type OdehZone = 'A' | 'B' | 'C' | 'D' */ export const ODEH_THRESHOLDS = { A: 5.65, - B: 2.00, + B: 2.0, C: -0.96, } as const @@ -390,7 +390,7 @@ export type KernelSource = | { type: 'file'; path: string } | { type: 'buffer'; data: ArrayBuffer; name: string } | { type: 'url'; url: string } - | { type: 'auto' } // auto-download from NAIF, cache in ~/.cache/moon-sighting + | { type: 'auto' } // auto-download from NAIF, cache in ~/.cache/moon-sighting export interface KernelConfig { /** Planetary SPK kernel — defaults to de442s.bsp via auto-download */ @@ -434,9 +434,13 @@ export const WGS84 = { /** Flattening */ f: 1 / 298.257223563, /** Semi-minor axis in meters */ - get b() { return this.a * (1 - this.f) }, + get b() { + return this.a * (1 - this.f) + }, /** First eccentricity squared */ - get e2() { return 2 * this.f - this.f * this.f }, + get e2() { + return 2 * this.f - this.f * this.f + }, } as const // ─── Internal ephemeris types ───────────────────────────────────────────────── diff --git a/src/visibility/index.ts b/src/visibility/index.ts index 9b88a7d..01536f6 100644 --- a/src/visibility/index.ts +++ b/src/visibility/index.ts @@ -238,7 +238,10 @@ export function buildGuidanceText( lagMinutes: number, ): string { const direction = azimuthToCardinal(moonAz) - const timeStr = bestTimeUTC.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC') + const timeStr = bestTimeUTC + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ' UTC') const lagStr = `${Math.round(lagMinutes)} min after sunset` let visibility: string @@ -263,8 +266,24 @@ export function buildGuidanceText( /** Convert azimuth degrees to a cardinal/intercardinal direction label */ function azimuthToCardinal(az: number): string { - const dirs = ['North', 'NNE', 'NE', 'ENE', 'East', 'ESE', 'SE', 'SSE', - 'South', 'SSW', 'SW', 'WSW', 'West', 'WNW', 'NW', 'NNW'] + const dirs = [ + 'North', + 'NNE', + 'NE', + 'ENE', + 'East', + 'ESE', + 'SE', + 'SSE', + 'South', + 'SSW', + 'SW', + 'WSW', + 'West', + 'WNW', + 'NW', + 'NNW', + ] const idx = Math.round(az / 22.5) % 16 return dirs[(idx + 16) % 16] } diff --git a/test-cjs.cjs b/test-cjs.cjs index 1fe466f..54ea32e 100644 --- a/test-cjs.cjs +++ b/test-cjs.cjs @@ -2,10 +2,11 @@ /** * moon-sighting CJS test suite - * Runs with: node test-cjs.cjs + * Runs with: node --test test-cjs.cjs * Verifies the CommonJS build is functional. */ +const { describe, it } = require('node:test') const assert = require('node:assert/strict') const { YALLOP_THRESHOLDS, @@ -25,142 +26,122 @@ const { getSunMoonEvents, } = require('./dist/index.cjs') -let passed = 0 -let failed = 0 - -function test(name, fn) { - try { - fn() - console.log(` [${name}]... PASS`) - passed++ - } catch (err) { - console.error(` [${name}]... FAIL: ${err.message}`) - failed++ - } -} - -console.log('CJS compatibility:') - -test('require() works', () => { - assert.ok(YALLOP_THRESHOLDS !== undefined) -}) -test('YALLOP_THRESHOLDS.A is 0.216', () => { - assert.equal(YALLOP_THRESHOLDS.A, 0.216) -}) -test('ODEH_THRESHOLDS.A is 5.65', () => { - assert.equal(ODEH_THRESHOLDS.A, 5.65) -}) -test('WGS84.a is 6378137.0', () => { - assert.equal(WGS84.a, 6378137.0) -}) -test('All API functions are exported', () => { - assert.equal(typeof getMoonPhase, 'function') - assert.equal(typeof getMoonPosition, 'function') - assert.equal(typeof getMoonIllumination, 'function') - assert.equal(typeof getMoonVisibilityEstimate, 'function') - assert.equal(typeof getMoon, 'function') - assert.equal(typeof initKernels, 'function') - assert.equal(typeof downloadKernels, 'function') - assert.equal(typeof verifyKernels, 'function') - assert.equal(typeof getMoonSightingReport, 'function') - assert.equal(typeof getSunMoonEvents, 'function') +describe('CJS compatibility', () => { + it('require() works', () => { + assert.ok(YALLOP_THRESHOLDS !== undefined) + }) + it('YALLOP_THRESHOLDS.A is 0.216', () => { + assert.equal(YALLOP_THRESHOLDS.A, 0.216) + }) + it('ODEH_THRESHOLDS.A is 5.65', () => { + assert.equal(ODEH_THRESHOLDS.A, 5.65) + }) + it('WGS84.a is 6378137.0', () => { + assert.equal(WGS84.a, 6378137.0) + }) + it('All API functions are exported', () => { + assert.equal(typeof getMoonPhase, 'function') + assert.equal(typeof getMoonPosition, 'function') + assert.equal(typeof getMoonIllumination, 'function') + assert.equal(typeof getMoonVisibilityEstimate, 'function') + assert.equal(typeof getMoon, 'function') + assert.equal(typeof initKernels, 'function') + assert.equal(typeof downloadKernels, 'function') + assert.equal(typeof verifyKernels, 'function') + assert.equal(typeof getMoonSightingReport, 'function') + assert.equal(typeof getSunMoonEvents, 'function') + }) }) -console.log('\nCJS getMoonPhase:') - -test('getMoonPhase returns valid phase', () => { - const valid = new Set([ - 'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous', - 'full-moon', 'waning-gibbous', 'last-quarter', 'waning-crescent', - ]) - const p = getMoonPhase(new Date('2025-03-14T12:00:00Z')) - assert.ok(valid.has(p.phase), `got: ${p.phase}`) -}) -test('getMoonPhase illumination in [0, 100]', () => { - const p = getMoonPhase(new Date('2025-03-01T12:00:00Z')) - assert.ok(p.illumination >= 0 && p.illumination <= 100) -}) -test('getMoonPhase near full moon has high illumination', () => { - const p = getMoonPhase(new Date('2025-03-14T12:00:00Z')) - assert.ok(p.illumination > 85, `illumination=${p.illumination.toFixed(1)}%`) -}) -test('getMoonPhase Dates are Date objects', () => { - const p = getMoonPhase(new Date('2025-03-01T12:00:00Z')) - assert.ok(p.nextNewMoon instanceof Date) - assert.ok(p.prevNewMoon instanceof Date) - assert.ok(p.nextFullMoon instanceof Date) +describe('CJS getMoonPhase', () => { + it('returns valid phase', () => { + const valid = new Set([ + 'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous', + 'full-moon', 'waning-gibbous', 'last-quarter', 'waning-crescent', + ]) + const p = getMoonPhase(new Date('2025-03-14T12:00:00Z')) + assert.ok(valid.has(p.phase), `got: ${p.phase}`) + }) + it('illumination in [0, 100]', () => { + const p = getMoonPhase(new Date('2025-03-01T12:00:00Z')) + assert.ok(p.illumination >= 0 && p.illumination <= 100) + }) + it('near full moon has high illumination', () => { + const p = getMoonPhase(new Date('2025-03-14T12:00:00Z')) + assert.ok(p.illumination > 85, `illumination=${p.illumination.toFixed(1)}%`) + }) + it('Dates are Date objects', () => { + const p = getMoonPhase(new Date('2025-03-01T12:00:00Z')) + assert.ok(p.nextNewMoon instanceof Date) + assert.ok(p.prevNewMoon instanceof Date) + assert.ok(p.nextFullMoon instanceof Date) + }) }) -console.log('\nCJS getMoonPosition + getMoonIllumination:') - -test('getMoonPosition returns valid azimuth/altitude', () => { - const pos = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10) - assert.ok(pos.azimuth >= 0 && pos.azimuth < 360, `azimuth=${pos.azimuth}`) - assert.ok(pos.altitude >= -90 && pos.altitude <= 90, `altitude=${pos.altitude}`) - assert.ok(pos.distance > 356000 && pos.distance < 407000, `distance=${pos.distance}`) - assert.ok(isFinite(pos.parallacticAngle)) -}) -test('getMoonIllumination near full moon: fraction > 0.85', () => { - const illum = getMoonIllumination(new Date('2025-03-14T12:00:00Z')) - assert.ok(illum.fraction > 0.85, `fraction=${illum.fraction.toFixed(3)}`) - assert.ok(illum.phase > 0.4 && illum.phase < 0.6, `phase=${illum.phase.toFixed(3)}`) - assert.ok(isFinite(illum.angle)) -}) -test('getMoonIllumination waxing: isWaxing = true', () => { - const illum = getMoonIllumination(new Date('2025-03-05T12:00:00Z')) - assert.equal(illum.isWaxing, true) +describe('CJS getMoonPosition and getMoonIllumination', () => { + it('getMoonPosition returns valid azimuth/altitude', () => { + const pos = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10) + assert.ok(pos.azimuth >= 0 && pos.azimuth < 360, `azimuth=${pos.azimuth}`) + assert.ok(pos.altitude >= -90 && pos.altitude <= 90, `altitude=${pos.altitude}`) + assert.ok(pos.distance > 356000 && pos.distance < 407000, `distance=${pos.distance}`) + assert.ok(isFinite(pos.parallacticAngle)) + }) + it('getMoonIllumination near full moon: fraction > 0.85', () => { + const illum = getMoonIllumination(new Date('2025-03-14T12:00:00Z')) + assert.ok(illum.fraction > 0.85, `fraction=${illum.fraction.toFixed(3)}`) + assert.ok(illum.phase > 0.4 && illum.phase < 0.6, `phase=${illum.phase.toFixed(3)}`) + assert.ok(isFinite(illum.angle)) + }) + it('getMoonIllumination waxing: isWaxing = true', () => { + const illum = getMoonIllumination(new Date('2025-03-05T12:00:00Z')) + assert.equal(illum.isWaxing, true) + }) }) -console.log('\nCJS getMoonPhase phaseName/phaseSymbol:') - -test('getMoonPhase.phaseName is a non-empty string', () => { - const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) - assert.ok(typeof p.phaseName === 'string' && p.phaseName.length > 0) -}) -test('getMoonPhase.phaseSymbol is a moon emoji', () => { - const SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']) - const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) - assert.ok(SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) -}) -test('Waxing crescent: phaseName = "Waxing Crescent", phaseSymbol = "🌒"', () => { - const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) - assert.equal(p.phaseName, 'Waxing Crescent') - assert.equal(p.phaseSymbol, '🌒') +describe('CJS getMoonPhase phaseName/phaseSymbol', () => { + it('phaseName is a non-empty string', () => { + const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) + assert.ok(typeof p.phaseName === 'string' && p.phaseName.length > 0) + }) + it('phaseSymbol is a moon emoji', () => { + const SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']) + const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) + assert.ok(SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) + }) + it('Waxing crescent: correct phaseName and phaseSymbol', () => { + const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) + assert.equal(p.phaseName, 'Waxing Crescent') + assert.equal(p.phaseSymbol, '🌒') + }) }) -console.log('\nCJS getMoonVisibilityEstimate:') - -test('getMoonVisibilityEstimate returns valid zone', () => { - const v = getMoonVisibilityEstimate(new Date('2025-03-02T18:30:00Z'), 51.5074, -0.1278, 10) - assert.ok(['A', 'B', 'C', 'D'].includes(v.zone), `zone=${v.zone}`) - assert.ok(isFinite(v.V)) - assert.equal(v.isApproximate, true) -}) -test('getMoonVisibilityEstimate near new moon: zone C or D', () => { - const v = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262) - assert.ok(['C', 'D'].includes(v.zone), `zone=${v.zone}`) +describe('CJS getMoonVisibilityEstimate', () => { + it('returns valid zone', () => { + const v = getMoonVisibilityEstimate(new Date('2025-03-02T18:30:00Z'), 51.5074, -0.1278, 10) + assert.ok(['A', 'B', 'C', 'D'].includes(v.zone), `zone=${v.zone}`) + assert.ok(isFinite(v.V)) + assert.equal(v.isApproximate, true) + }) + it('near new moon: zone C or D', () => { + const v = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262) + assert.ok(['C', 'D'].includes(v.zone), `zone=${v.zone}`) + }) }) -console.log('\nCJS getMoon:') - -test('getMoon returns all four sub-results', () => { - const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10) - assert.ok(typeof m.phase === 'object') - assert.ok(typeof m.position === 'object') - assert.ok(typeof m.illumination === 'object') - assert.ok(typeof m.visibility === 'object') +describe('CJS getMoon', () => { + it('returns all four sub-results', () => { + const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10) + assert.ok(typeof m.phase === 'object') + assert.ok(typeof m.position === 'object') + assert.ok(typeof m.illumination === 'object') + assert.ok(typeof m.visibility === 'object') + }) + it('phase.phaseName is non-empty', () => { + const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278) + assert.ok(typeof m.phase.phaseName === 'string' && m.phase.phaseName.length > 0) + }) + it('visibility.isApproximate is true', () => { + const m = getMoon(new Date(), 51.5074, -0.1278) + assert.equal(m.visibility.isApproximate, true) + }) }) -test('getMoon.phase.phaseName is non-empty', () => { - const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278) - assert.ok(typeof m.phase.phaseName === 'string' && m.phase.phaseName.length > 0) -}) -test('getMoon.visibility.isApproximate is true', () => { - const m = getMoon(new Date(), 51.5074, -0.1278) - assert.equal(m.visibility.isApproximate, true) -}) - -console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`) - -if (failed > 0) { - process.exit(1) -} diff --git a/test.mjs b/test.mjs index fa4ea54..1737102 100644 --- a/test.mjs +++ b/test.mjs @@ -1,9 +1,9 @@ /** * moon-sighting ESM test suite - * Runs with: node test.mjs - * All tests use plain assert — no test framework. + * Runs with: node --test test.mjs */ +import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { @@ -26,429 +26,392 @@ import { getSunMoonEvents, } from './dist/index.mjs' -let passed = 0 -let failed = 0 - -function test(name, fn) { - try { - fn() - console.log(` [${name}]... PASS`) - passed++ - } catch (err) { - console.error(` [${name}]... FAIL: ${err.message}`) - failed++ - } -} - // ─── Constants ──────────────────────────────────────────────────────────────── -console.log('Constants:') - -test('YALLOP_THRESHOLDS.A is 0.216', () => { - assert.equal(YALLOP_THRESHOLDS.A, 0.216) -}) -test('YALLOP_THRESHOLDS.E is -0.293', () => { - assert.equal(YALLOP_THRESHOLDS.E, -0.293) -}) -test('All Yallop thresholds are defined', () => { - for (const key of ['A', 'B', 'C', 'D', 'E']) { - assert.ok(typeof YALLOP_THRESHOLDS[key] === 'number', `${key} should be a number`) - } -}) -test('Yallop thresholds descend A > B > C > D > E', () => { - assert.ok(YALLOP_THRESHOLDS.A > YALLOP_THRESHOLDS.B) - assert.ok(YALLOP_THRESHOLDS.B > YALLOP_THRESHOLDS.C) - assert.ok(YALLOP_THRESHOLDS.C > YALLOP_THRESHOLDS.D) - assert.ok(YALLOP_THRESHOLDS.D > YALLOP_THRESHOLDS.E) -}) -test('ODEH_THRESHOLDS.A is 5.65', () => { - assert.equal(ODEH_THRESHOLDS.A, 5.65) -}) -test('ODEH_THRESHOLDS.C is -0.96', () => { - assert.equal(ODEH_THRESHOLDS.C, -0.96) -}) -test('Odeh thresholds descend A > B > C', () => { - assert.ok(ODEH_THRESHOLDS.A > ODEH_THRESHOLDS.B) - assert.ok(ODEH_THRESHOLDS.B > ODEH_THRESHOLDS.C) -}) -test('WGS84.a is 6378137.0', () => { - assert.equal(WGS84.a, 6378137.0) -}) -test('WGS84.invF is 298.257223563', () => { - assert.equal(WGS84.invF, 298.257223563) -}) -test('WGS84.e2 is positive and < 1', () => { - assert.ok(WGS84.e2 > 0 && WGS84.e2 < 1, `e2=${WGS84.e2}`) -}) -test('WGS84.b < WGS84.a (oblate spheroid)', () => { - assert.ok(WGS84.b < WGS84.a) -}) -test('Yallop descriptions are non-empty strings', () => { - for (const cat of ['A', 'B', 'C', 'D', 'E', 'F']) { - assert.ok(typeof YALLOP_DESCRIPTIONS[cat] === 'string' && YALLOP_DESCRIPTIONS[cat].length > 0) - } -}) -test('Odeh descriptions are non-empty strings', () => { - for (const zone of ['A', 'B', 'C', 'D']) { - assert.ok(typeof ODEH_DESCRIPTIONS[zone] === 'string' && ODEH_DESCRIPTIONS[zone].length > 0) - } +describe('Constants', () => { + it('YALLOP_THRESHOLDS.A is 0.216', () => { + assert.equal(YALLOP_THRESHOLDS.A, 0.216) + }) + it('YALLOP_THRESHOLDS.E is -0.293', () => { + assert.equal(YALLOP_THRESHOLDS.E, -0.293) + }) + it('All Yallop thresholds are defined', () => { + for (const key of ['A', 'B', 'C', 'D', 'E']) { + assert.ok(typeof YALLOP_THRESHOLDS[key] === 'number', `${key} should be a number`) + } + }) + it('Yallop thresholds descend A > B > C > D > E', () => { + assert.ok(YALLOP_THRESHOLDS.A > YALLOP_THRESHOLDS.B) + assert.ok(YALLOP_THRESHOLDS.B > YALLOP_THRESHOLDS.C) + assert.ok(YALLOP_THRESHOLDS.C > YALLOP_THRESHOLDS.D) + assert.ok(YALLOP_THRESHOLDS.D > YALLOP_THRESHOLDS.E) + }) + it('ODEH_THRESHOLDS.A is 5.65', () => { + assert.equal(ODEH_THRESHOLDS.A, 5.65) + }) + it('ODEH_THRESHOLDS.C is -0.96', () => { + assert.equal(ODEH_THRESHOLDS.C, -0.96) + }) + it('Odeh thresholds descend A > B > C', () => { + assert.ok(ODEH_THRESHOLDS.A > ODEH_THRESHOLDS.B) + assert.ok(ODEH_THRESHOLDS.B > ODEH_THRESHOLDS.C) + }) + it('WGS84.a is 6378137.0', () => { + assert.equal(WGS84.a, 6378137.0) + }) + it('WGS84.invF is 298.257223563', () => { + assert.equal(WGS84.invF, 298.257223563) + }) + it('WGS84.e2 is positive and < 1', () => { + assert.ok(WGS84.e2 > 0 && WGS84.e2 < 1, `e2=${WGS84.e2}`) + }) + it('WGS84.b < WGS84.a (oblate spheroid)', () => { + assert.ok(WGS84.b < WGS84.a) + }) + it('Yallop descriptions are non-empty strings', () => { + for (const cat of ['A', 'B', 'C', 'D', 'E', 'F']) { + assert.ok(typeof YALLOP_DESCRIPTIONS[cat] === 'string' && YALLOP_DESCRIPTIONS[cat].length > 0) + } + }) + it('Odeh descriptions are non-empty strings', () => { + for (const zone of ['A', 'B', 'C', 'D']) { + assert.ok(typeof ODEH_DESCRIPTIONS[zone] === 'string' && ODEH_DESCRIPTIONS[zone].length > 0) + } + }) }) // ─── API function exports ────────────────────────────────────────────────────── -console.log('\nAPI exports:') - -test('getMoonPhase is a function', () => { - assert.equal(typeof getMoonPhase, 'function') -}) -test('initKernels is a function', () => { - assert.equal(typeof initKernels, 'function') -}) -test('downloadKernels is a function', () => { - assert.equal(typeof downloadKernels, 'function') -}) -test('verifyKernels is a function', () => { - assert.equal(typeof verifyKernels, 'function') -}) -test('getMoonSightingReport is a function', () => { - assert.equal(typeof getMoonSightingReport, 'function') -}) -test('getSunMoonEvents is a function', () => { - assert.equal(typeof getSunMoonEvents, 'function') +describe('API exports', () => { + it('getMoonPhase is a function', () => { + assert.equal(typeof getMoonPhase, 'function') + }) + it('initKernels is a function', () => { + assert.equal(typeof initKernels, 'function') + }) + it('downloadKernels is a function', () => { + assert.equal(typeof downloadKernels, 'function') + }) + it('verifyKernels is a function', () => { + assert.equal(typeof verifyKernels, 'function') + }) + it('getMoonSightingReport is a function', () => { + assert.equal(typeof getMoonSightingReport, 'function') + }) + it('getSunMoonEvents is a function', () => { + assert.equal(typeof getSunMoonEvents, 'function') + }) }) // ─── getMoonPhase (synchronous, no kernel) ───────────────────────────────────── -console.log('\ngetMoonPhase — structure:') - const VALID_PHASES = new Set([ 'new-moon', 'waxing-crescent', 'first-quarter', 'waxing-gibbous', 'full-moon', 'waning-gibbous', 'last-quarter', 'waning-crescent', ]) -// Test with a known reference date: 2025-03-01 UTC -// At this date the Moon was a waxing crescent (~2 days after new moon Feb 28) const DATE_MARCH_1_2025 = new Date('2025-03-01T12:00:00Z') const phase_march1 = getMoonPhase(DATE_MARCH_1_2025) -test('getMoonPhase returns an object', () => { - assert.ok(phase_march1 !== null && typeof phase_march1 === 'object') -}) -test('getMoonPhase.phase is a valid phase name', () => { - assert.ok(VALID_PHASES.has(phase_march1.phase), `got: ${phase_march1.phase}`) -}) -test('getMoonPhase.illumination is in [0, 100]', () => { - assert.ok(phase_march1.illumination >= 0 && phase_march1.illumination <= 100, - `illumination=${phase_march1.illumination}`) -}) -test('getMoonPhase.age is >= 0', () => { - assert.ok(phase_march1.age >= 0, `age=${phase_march1.age}`) -}) -test('getMoonPhase.elongationDeg is in [0, 180]', () => { - assert.ok(phase_march1.elongationDeg >= 0 && phase_march1.elongationDeg <= 180, - `elongationDeg=${phase_march1.elongationDeg}`) -}) -test('getMoonPhase.isWaxing is a boolean', () => { - assert.equal(typeof phase_march1.isWaxing, 'boolean') -}) -test('getMoonPhase.nextNewMoon is a Date', () => { - assert.ok(phase_march1.nextNewMoon instanceof Date) -}) -test('getMoonPhase.prevNewMoon is a Date', () => { - assert.ok(phase_march1.prevNewMoon instanceof Date) -}) -test('getMoonPhase.nextFullMoon is a Date', () => { - assert.ok(phase_march1.nextFullMoon instanceof Date) -}) -test('getMoonPhase.prevNewMoon is before reference date', () => { - assert.ok(phase_march1.prevNewMoon < DATE_MARCH_1_2025, - `prevNewMoon=${phase_march1.prevNewMoon.toISOString()}`) -}) -test('getMoonPhase.nextNewMoon is after prevNewMoon', () => { - assert.ok(phase_march1.nextNewMoon > phase_march1.prevNewMoon) +describe('getMoonPhase structure', () => { + it('returns an object', () => { + assert.ok(phase_march1 !== null && typeof phase_march1 === 'object') + }) + it('phase is a valid phase name', () => { + assert.ok(VALID_PHASES.has(phase_march1.phase), `got: ${phase_march1.phase}`) + }) + it('illumination is in [0, 100]', () => { + assert.ok(phase_march1.illumination >= 0 && phase_march1.illumination <= 100, + `illumination=${phase_march1.illumination}`) + }) + it('age is >= 0', () => { + assert.ok(phase_march1.age >= 0, `age=${phase_march1.age}`) + }) + it('elongationDeg is in [0, 180]', () => { + assert.ok(phase_march1.elongationDeg >= 0 && phase_march1.elongationDeg <= 180, + `elongationDeg=${phase_march1.elongationDeg}`) + }) + it('isWaxing is a boolean', () => { + assert.equal(typeof phase_march1.isWaxing, 'boolean') + }) + it('nextNewMoon is a Date', () => { + assert.ok(phase_march1.nextNewMoon instanceof Date) + }) + it('prevNewMoon is a Date', () => { + assert.ok(phase_march1.prevNewMoon instanceof Date) + }) + it('nextFullMoon is a Date', () => { + assert.ok(phase_march1.nextFullMoon instanceof Date) + }) + it('prevNewMoon is before reference date', () => { + assert.ok(phase_march1.prevNewMoon < DATE_MARCH_1_2025, + `prevNewMoon=${phase_march1.prevNewMoon.toISOString()}`) + }) + it('nextNewMoon is after prevNewMoon', () => { + assert.ok(phase_march1.nextNewMoon > phase_march1.prevNewMoon) + }) }) -console.log('\ngetMoonPhase — phase boundaries:') +describe('getMoonPhase phase boundaries', () => { + const DATE_FULL_MOON = new Date('2025-03-14T12:00:00Z') + const phase_full = getMoonPhase(DATE_FULL_MOON) -// 2025-03-14 was close to full moon (illumination should be high) -const DATE_FULL_MOON = new Date('2025-03-14T12:00:00Z') -const phase_full = getMoonPhase(DATE_FULL_MOON) + it('near full moon: illumination > 85%', () => { + assert.ok(phase_full.illumination > 85, + `illumination at full moon=${phase_full.illumination.toFixed(1)}%`) + }) + it('near full moon: phase is full-moon or waxing/waning gibbous', () => { + const valid = new Set(['full-moon', 'waxing-gibbous', 'waning-gibbous']) + assert.ok(valid.has(phase_full.phase), `got: ${phase_full.phase}`) + }) + it('near full moon: elongation > 120 deg', () => { + assert.ok(phase_full.elongationDeg > 120, `elongation=${phase_full.elongationDeg}`) + }) -test('Near full moon: illumination > 85%', () => { - assert.ok(phase_full.illumination > 85, - `illumination at full moon=${phase_full.illumination.toFixed(1)}%`) -}) -test('Near full moon: phase is full-moon or waxing/waning gibbous', () => { - const valid = new Set(['full-moon', 'waxing-gibbous', 'waning-gibbous']) - assert.ok(valid.has(phase_full.phase), `got: ${phase_full.phase}`) -}) -test('Near full moon: elongation > 120°', () => { - assert.ok(phase_full.elongationDeg > 120, `elongation=${phase_full.elongationDeg}`) + const DATE_NEW_MOON = new Date('2025-03-29T12:00:00Z') + const phase_new = getMoonPhase(DATE_NEW_MOON) + + it('near new moon: illumination < 10%', () => { + assert.ok(phase_new.illumination < 10, + `illumination at new moon=${phase_new.illumination.toFixed(1)}%`) + }) + it('near new moon: elongation < 30 deg', () => { + assert.ok(phase_new.elongationDeg < 30, `elongation=${phase_new.elongationDeg}`) + }) }) -// 2025-03-29 is close to new moon (illumination should be low) -const DATE_NEW_MOON = new Date('2025-03-29T12:00:00Z') -const phase_new = getMoonPhase(DATE_NEW_MOON) +describe('getMoonPhase consistency', () => { + const DATE_WAXING = new Date('2025-03-05T12:00:00Z') + const DATE_WANING = new Date('2025-03-20T12:00:00Z') -test('Near new moon: illumination < 10%', () => { - assert.ok(phase_new.illumination < 10, - `illumination at new moon=${phase_new.illumination.toFixed(1)}%`) -}) -test('Near new moon: elongation < 30°', () => { - assert.ok(phase_new.elongationDeg < 30, `elongation=${phase_new.elongationDeg}`) -}) - -console.log('\ngetMoonPhase — consistency:') - -// Two dates: one clearly waxing, one clearly waning -const DATE_WAXING = new Date('2025-03-05T12:00:00Z') // ~7 days after new moon -const DATE_WANING = new Date('2025-03-20T12:00:00Z') // ~6 days after full moon -const phase_waxing = getMoonPhase(DATE_WAXING) -const phase_waning = getMoonPhase(DATE_WANING) - -test('5 days after new moon: isWaxing = true', () => { - assert.equal(phase_waxing.isWaxing, true) -}) -test('6 days after full moon: isWaxing = false', () => { - assert.equal(phase_waning.isWaxing, false) -}) -test('getMoonPhase with default date (now) returns valid result', () => { - const nowPhase = getMoonPhase() - assert.ok(VALID_PHASES.has(nowPhase.phase)) - assert.ok(nowPhase.illumination >= 0 && nowPhase.illumination <= 100) -}) - -// Synodic month duration check: nextNewMoon - prevNewMoon ≈ 29.53 days -test('Synodic month duration is ~29.5 days (±0.5)', () => { - const synodicMs = phase_march1.nextNewMoon.getTime() - phase_march1.prevNewMoon.getTime() - const synodicDays = synodicMs / 86400000 - assert.ok( - synodicDays > 29.0 && synodicDays < 30.1, - `synodic month=${synodicDays.toFixed(2)} days`, - ) + it('5 days after new moon: isWaxing = true', () => { + assert.equal(getMoonPhase(DATE_WAXING).isWaxing, true) + }) + it('6 days after full moon: isWaxing = false', () => { + assert.equal(getMoonPhase(DATE_WANING).isWaxing, false) + }) + it('default date (now) returns valid result', () => { + const nowPhase = getMoonPhase() + assert.ok(VALID_PHASES.has(nowPhase.phase)) + assert.ok(nowPhase.illumination >= 0 && nowPhase.illumination <= 100) + }) + it('synodic month duration is ~29.5 days', () => { + const synodicMs = phase_march1.nextNewMoon.getTime() - phase_march1.prevNewMoon.getTime() + const synodicDays = synodicMs / 86400000 + assert.ok( + synodicDays > 29.0 && synodicDays < 30.1, + `synodic month=${synodicDays.toFixed(2)} days`, + ) + }) }) // ─── getMoonPosition ───────────────────────────────────────────────────────── -console.log('\ngetMoonPosition:') +describe('getMoonPosition', () => { + const moonPos_london = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10) -// London on 2025-03-14 at noon UTC — Moon should be above the horizon during daytime -const moonPos_london = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10) - -test('getMoonPosition returns azimuth in [0, 360)', () => { - assert.ok( - moonPos_london.azimuth >= 0 && moonPos_london.azimuth < 360, - `azimuth=${moonPos_london.azimuth}`, - ) -}) -test('getMoonPosition returns altitude in [-90, 90]', () => { - assert.ok( - moonPos_london.altitude >= -90 && moonPos_london.altitude <= 90, - `altitude=${moonPos_london.altitude}`, - ) -}) -test('getMoonPosition returns distance in lunar orbit range [356000, 407000] km', () => { - assert.ok( - moonPos_london.distance >= 356000 && moonPos_london.distance <= 407000, - `distance=${moonPos_london.distance.toFixed(0)} km`, - ) -}) -test('getMoonPosition returns finite parallacticAngle', () => { - assert.ok( - isFinite(moonPos_london.parallacticAngle), - `parallacticAngle=${moonPos_london.parallacticAngle}`, - ) -}) -test('getMoonPosition default date (now) returns valid result', () => { - const pos = getMoonPosition(new Date(), 21.4225, 39.8262) // Mecca - assert.ok(pos.azimuth >= 0 && pos.azimuth < 360) - assert.ok(pos.altitude >= -90 && pos.altitude <= 90) - assert.ok(pos.distance > 350000 && pos.distance < 410000) + it('azimuth in [0, 360)', () => { + assert.ok(moonPos_london.azimuth >= 0 && moonPos_london.azimuth < 360, + `azimuth=${moonPos_london.azimuth}`) + }) + it('altitude in [-90, 90]', () => { + assert.ok(moonPos_london.altitude >= -90 && moonPos_london.altitude <= 90, + `altitude=${moonPos_london.altitude}`) + }) + it('distance in lunar orbit range [356000, 407000] km', () => { + assert.ok(moonPos_london.distance >= 356000 && moonPos_london.distance <= 407000, + `distance=${moonPos_london.distance.toFixed(0)} km`) + }) + it('finite parallacticAngle', () => { + assert.ok(isFinite(moonPos_london.parallacticAngle), + `parallacticAngle=${moonPos_london.parallacticAngle}`) + }) + it('default date (now) returns valid result', () => { + const pos = getMoonPosition(new Date(), 21.4225, 39.8262) + assert.ok(pos.azimuth >= 0 && pos.azimuth < 360) + assert.ok(pos.altitude >= -90 && pos.altitude <= 90) + assert.ok(pos.distance > 350000 && pos.distance < 410000) + }) }) // ─── getMoonIllumination ───────────────────────────────────────────────────── -console.log('\ngetMoonIllumination:') +describe('getMoonIllumination', () => { + const illum_full = getMoonIllumination(new Date('2025-03-14T12:00:00Z')) + const illum_new = getMoonIllumination(new Date('2025-03-29T12:00:00Z')) + const illum_waxing = getMoonIllumination(new Date('2025-03-05T12:00:00Z')) -// 2025-03-14 was close to full moon -const illum_full = getMoonIllumination(new Date('2025-03-14T12:00:00Z')) -// 2025-03-29 was close to new moon -const illum_new = getMoonIllumination(new Date('2025-03-29T12:00:00Z')) -// 2025-03-05 was waxing crescent (~7 days after new moon) -const illum_waxing = getMoonIllumination(new Date('2025-03-05T12:00:00Z')) - -test('getMoonIllumination near full moon: fraction > 0.85', () => { - assert.ok(illum_full.fraction > 0.85, `fraction=${illum_full.fraction.toFixed(3)}`) -}) -test('getMoonIllumination near full moon: phase close to 0.5', () => { - assert.ok( - illum_full.phase > 0.4 && illum_full.phase < 0.6, - `phase=${illum_full.phase.toFixed(3)}`, - ) -}) -test('getMoonIllumination near new moon: fraction < 0.05', () => { - assert.ok(illum_new.fraction < 0.05, `fraction=${illum_new.fraction.toFixed(3)}`) -}) -test('getMoonIllumination near new moon: phase close to 0 or 1', () => { - const p = illum_new.phase - assert.ok(p < 0.08 || p > 0.92, `phase=${p.toFixed(3)}`) -}) -test('getMoonIllumination waxing: isWaxing = true', () => { - assert.equal(illum_waxing.isWaxing, true) -}) -test('getMoonIllumination fraction in [0, 1]', () => { - assert.ok(illum_full.fraction >= 0 && illum_full.fraction <= 1) - assert.ok(illum_new.fraction >= 0 && illum_new.fraction <= 1) -}) -test('getMoonIllumination phase in [0, 1)', () => { - assert.ok(illum_full.phase >= 0 && illum_full.phase < 1) - assert.ok(illum_new.phase >= 0 && illum_new.phase < 1) -}) -test('getMoonIllumination angle is finite', () => { - assert.ok(isFinite(illum_full.angle), `angle=${illum_full.angle}`) -}) -test('getMoonIllumination default date (now) returns valid result', () => { - const illum = getMoonIllumination() - assert.ok(illum.fraction >= 0 && illum.fraction <= 1) - assert.ok(illum.phase >= 0 && illum.phase < 1) - assert.equal(typeof illum.isWaxing, 'boolean') - assert.ok(isFinite(illum.angle)) + it('near full moon: fraction > 0.85', () => { + assert.ok(illum_full.fraction > 0.85, `fraction=${illum_full.fraction.toFixed(3)}`) + }) + it('near full moon: phase close to 0.5', () => { + assert.ok(illum_full.phase > 0.4 && illum_full.phase < 0.6, + `phase=${illum_full.phase.toFixed(3)}`) + }) + it('near new moon: fraction < 0.05', () => { + assert.ok(illum_new.fraction < 0.05, `fraction=${illum_new.fraction.toFixed(3)}`) + }) + it('near new moon: phase close to 0 or 1', () => { + const p = illum_new.phase + assert.ok(p < 0.08 || p > 0.92, `phase=${p.toFixed(3)}`) + }) + it('waxing: isWaxing = true', () => { + assert.equal(illum_waxing.isWaxing, true) + }) + it('fraction in [0, 1]', () => { + assert.ok(illum_full.fraction >= 0 && illum_full.fraction <= 1) + assert.ok(illum_new.fraction >= 0 && illum_new.fraction <= 1) + }) + it('phase in [0, 1)', () => { + assert.ok(illum_full.phase >= 0 && illum_full.phase < 1) + assert.ok(illum_new.phase >= 0 && illum_new.phase < 1) + }) + it('angle is finite', () => { + assert.ok(isFinite(illum_full.angle), `angle=${illum_full.angle}`) + }) + it('default date (now) returns valid result', () => { + const illum = getMoonIllumination() + assert.ok(illum.fraction >= 0 && illum.fraction <= 1) + assert.ok(illum.phase >= 0 && illum.phase < 1) + assert.equal(typeof illum.isWaxing, 'boolean') + assert.ok(isFinite(illum.angle)) + }) }) // ─── getMoonPhase phaseName + phaseSymbol ───────────────────────────────────── -console.log('\ngetMoonPhase — phaseName + phaseSymbol:') +describe('getMoonPhase phaseName and phaseSymbol', () => { + const PHASE_NAMES = new Set([ + 'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', + 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent', + ]) + const PHASE_SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']) -const PHASE_NAMES = new Set([ - 'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', - 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent', -]) -const PHASE_SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']) - -test('getMoonPhase.phaseName is a valid human-readable name', () => { - const p = getMoonPhase(DATE_MARCH_1_2025) - assert.ok(PHASE_NAMES.has(p.phaseName), `got: ${p.phaseName}`) -}) -test('getMoonPhase.phaseSymbol is a moon emoji', () => { - const p = getMoonPhase(DATE_MARCH_1_2025) - assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) -}) -test('Near full moon: phaseName is "Full Moon" or gibbous', () => { - const valid = new Set(['Full Moon', 'Waxing Gibbous', 'Waning Gibbous']) - const p = getMoonPhase(DATE_FULL_MOON) - assert.ok(valid.has(p.phaseName), `got: ${p.phaseName}`) -}) -test('Near full moon: phaseSymbol is 🌕 or 🌔 or 🌖', () => { - const valid = new Set(['🌕', '🌔', '🌖']) - const p = getMoonPhase(DATE_FULL_MOON) - assert.ok(valid.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) -}) -test('Waxing crescent: phaseName is "Waxing Crescent"', () => { - const p = getMoonPhase(DATE_WAXING) - assert.equal(p.phaseName, 'Waxing Crescent') -}) -test('Waxing crescent: phaseSymbol is 🌒', () => { - const p = getMoonPhase(DATE_WAXING) - assert.equal(p.phaseSymbol, '🌒') -}) -test('phaseName and phaseSymbol are consistent with phase key', () => { - // If phase is 'waning-crescent', phaseName should be 'Waning Crescent' - const p = getMoonPhase(DATE_WANING) - assert.equal(typeof p.phaseName, 'string') - assert.ok(p.phaseName.length > 0) - assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol)) + it('phaseName is a valid human-readable name', () => { + const p = getMoonPhase(DATE_MARCH_1_2025) + assert.ok(PHASE_NAMES.has(p.phaseName), `got: ${p.phaseName}`) + }) + it('phaseSymbol is a moon emoji', () => { + const p = getMoonPhase(DATE_MARCH_1_2025) + assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) + }) + it('near full moon: phaseName is Full Moon or gibbous', () => { + const valid = new Set(['Full Moon', 'Waxing Gibbous', 'Waning Gibbous']) + const p = getMoonPhase(new Date('2025-03-14T12:00:00Z')) + assert.ok(valid.has(p.phaseName), `got: ${p.phaseName}`) + }) + it('waxing crescent: phaseName is Waxing Crescent', () => { + const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) + assert.equal(p.phaseName, 'Waxing Crescent') + }) + it('waxing crescent: phaseSymbol is correct', () => { + const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) + assert.equal(p.phaseSymbol, '🌒') + }) }) // ─── getMoonVisibilityEstimate ───────────────────────────────────────────────── -console.log('\ngetMoonVisibilityEstimate:') +describe('getMoonVisibilityEstimate', () => { + const DATE_VIS_ESTIMATE = new Date('2025-03-02T18:30:00Z') + const vis = getMoonVisibilityEstimate(DATE_VIS_ESTIMATE, 51.5074, -0.1278, 10) -// London, 40 min after nominal sunset on 2025-03-01 (day after new moon) -const DATE_VIS_ESTIMATE = new Date('2025-03-02T18:30:00Z') -const vis = getMoonVisibilityEstimate(DATE_VIS_ESTIMATE, 51.5074, -0.1278, 10) - -test('getMoonVisibilityEstimate returns an object', () => { - assert.ok(vis !== null && typeof vis === 'object') -}) -test('getMoonVisibilityEstimate.zone is A, B, C, or D', () => { - assert.ok(['A', 'B', 'C', 'D'].includes(vis.zone), `got: ${vis.zone}`) -}) -test('getMoonVisibilityEstimate.V is finite', () => { - assert.ok(isFinite(vis.V), `V=${vis.V}`) -}) -test('getMoonVisibilityEstimate.ARCL is in [0, 180]', () => { - assert.ok(vis.ARCL >= 0 && vis.ARCL <= 180, `ARCL=${vis.ARCL}`) -}) -test('getMoonVisibilityEstimate.W >= 0', () => { - assert.ok(vis.W >= 0, `W=${vis.W}`) -}) -test('getMoonVisibilityEstimate.isApproximate is true', () => { - assert.equal(vis.isApproximate, true) -}) -test('getMoonVisibilityEstimate.moonAboveHorizon is a boolean', () => { - assert.equal(typeof vis.moonAboveHorizon, 'boolean') -}) -test('getMoonVisibilityEstimate.isVisibleNakedEye matches zone A', () => { - assert.equal(vis.isVisibleNakedEye, vis.zone === 'A') -}) -test('getMoonVisibilityEstimate.isVisibleWithOpticalAid matches zone A or B', () => { - assert.equal(vis.isVisibleWithOpticalAid, vis.zone === 'A' || vis.zone === 'B') -}) -test('getMoonVisibilityEstimate.description is a non-empty string', () => { - assert.ok(typeof vis.description === 'string' && vis.description.length > 0) -}) -test('getMoonVisibilityEstimate default date works', () => { - const v = getMoonVisibilityEstimate(new Date(), 21.4225, 39.8262) - assert.ok(['A', 'B', 'C', 'D'].includes(v.zone)) - assert.ok(isFinite(v.V)) - assert.equal(v.isApproximate, true) -}) -// Near new moon: elongation small, W small, crescent should be very thin or invisible -test('Near new moon: zone is D or C (not visible or marginal)', () => { - const nearNew = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262) - assert.ok(['C', 'D'].includes(nearNew.zone), `zone=${nearNew.zone} V=${nearNew.V.toFixed(2)}`) + it('returns an object', () => { + assert.ok(vis !== null && typeof vis === 'object') + }) + it('zone is A, B, C, or D', () => { + assert.ok(['A', 'B', 'C', 'D'].includes(vis.zone), `got: ${vis.zone}`) + }) + it('V is finite', () => { + assert.ok(isFinite(vis.V), `V=${vis.V}`) + }) + it('ARCL is in [0, 180]', () => { + assert.ok(vis.ARCL >= 0 && vis.ARCL <= 180, `ARCL=${vis.ARCL}`) + }) + it('W >= 0', () => { + assert.ok(vis.W >= 0, `W=${vis.W}`) + }) + it('isApproximate is true', () => { + assert.equal(vis.isApproximate, true) + }) + it('moonAboveHorizon is a boolean', () => { + assert.equal(typeof vis.moonAboveHorizon, 'boolean') + }) + it('isVisibleNakedEye matches zone A', () => { + assert.equal(vis.isVisibleNakedEye, vis.zone === 'A') + }) + it('isVisibleWithOpticalAid matches zone A or B', () => { + assert.equal(vis.isVisibleWithOpticalAid, vis.zone === 'A' || vis.zone === 'B') + }) + it('description is a non-empty string', () => { + assert.ok(typeof vis.description === 'string' && vis.description.length > 0) + }) + it('default date works', () => { + const v = getMoonVisibilityEstimate(new Date(), 21.4225, 39.8262) + assert.ok(['A', 'B', 'C', 'D'].includes(v.zone)) + assert.ok(isFinite(v.V)) + assert.equal(v.isApproximate, true) + }) + it('near new moon: zone is D or C', () => { + const nearNew = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262) + assert.ok(['C', 'D'].includes(nearNew.zone), `zone=${nearNew.zone} V=${nearNew.V.toFixed(2)}`) + }) }) // ─── getMoon ────────────────────────────────────────────────────────────────── -console.log('\ngetMoon:') +describe('getMoon', () => { + const moon = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10) -const moon = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10) - -test('getMoon returns an object with phase, position, illumination, visibility', () => { - assert.ok(typeof moon === 'object') - assert.ok(typeof moon.phase === 'object') - assert.ok(typeof moon.position === 'object') - assert.ok(typeof moon.illumination === 'object') - assert.ok(typeof moon.visibility === 'object') -}) -test('getMoon.phase is consistent with getMoonPhase standalone', () => { - const standalone = getMoonPhase(new Date('2025-03-05T20:00:00Z')) - assert.equal(moon.phase.phase, standalone.phase) - assert.equal(moon.phase.phaseName, standalone.phaseName) -}) -test('getMoon.illumination.isWaxing matches phase.isWaxing', () => { - assert.equal(moon.illumination.isWaxing, moon.phase.isWaxing) -}) -test('getMoon.visibility.isApproximate is true', () => { - assert.equal(moon.visibility.isApproximate, true) -}) -test('getMoon.position has valid azimuth and altitude', () => { - assert.ok(moon.position.azimuth >= 0 && moon.position.azimuth < 360) - assert.ok(moon.position.altitude >= -90 && moon.position.altitude <= 90) -}) -test('getMoon default date works', () => { - const m = getMoon(new Date(), 21.4225, 39.8262) - assert.ok(PHASE_NAMES.has(m.phase.phaseName)) - assert.ok(isFinite(m.position.azimuth)) - assert.ok(isFinite(m.illumination.fraction)) - assert.ok(['A', 'B', 'C', 'D'].includes(m.visibility.zone)) + it('returns object with phase, position, illumination, visibility', () => { + assert.ok(typeof moon === 'object') + assert.ok(typeof moon.phase === 'object') + assert.ok(typeof moon.position === 'object') + assert.ok(typeof moon.illumination === 'object') + assert.ok(typeof moon.visibility === 'object') + }) + it('phase is consistent with getMoonPhase standalone', () => { + const standalone = getMoonPhase(new Date('2025-03-05T20:00:00Z')) + assert.equal(moon.phase.phase, standalone.phase) + assert.equal(moon.phase.phaseName, standalone.phaseName) + }) + it('illumination.isWaxing matches phase.isWaxing', () => { + assert.equal(moon.illumination.isWaxing, moon.phase.isWaxing) + }) + it('visibility.isApproximate is true', () => { + assert.equal(moon.visibility.isApproximate, true) + }) + it('position has valid azimuth and altitude', () => { + assert.ok(moon.position.azimuth >= 0 && moon.position.azimuth < 360) + assert.ok(moon.position.altitude >= -90 && moon.position.altitude <= 90) + }) + it('default date works', () => { + const m = getMoon(new Date(), 21.4225, 39.8262) + assert.ok(isFinite(m.position.azimuth)) + assert.ok(isFinite(m.illumination.fraction)) + assert.ok(['A', 'B', 'C', 'D'].includes(m.visibility.zone)) + }) }) -// ─── Summary ───────────────────────────────────────────────────────────────── +// ─── Input validation ───────────────────────────────────────────────────────── -console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`) - -if (failed > 0) { - process.exit(1) -} +describe('Input validation', () => { + it('getMoonPhase rejects invalid date', () => { + assert.throws(() => getMoonPhase(new Date('invalid')), /valid Date/) + }) + it('getMoonPosition rejects latitude out of range', () => { + assert.throws(() => getMoonPosition(new Date(), 91, 0), /latitude/) + }) + it('getMoonPosition rejects longitude out of range', () => { + assert.throws(() => getMoonPosition(new Date(), 0, 181), /longitude/) + }) + it('getMoonPosition rejects NaN latitude', () => { + assert.throws(() => getMoonPosition(new Date(), NaN, 0), /latitude/) + }) + it('getMoonVisibilityEstimate rejects invalid coordinates', () => { + assert.throws(() => getMoonVisibilityEstimate(new Date(), -91, 0), /latitude/) + }) + it('getMoon rejects invalid coordinates', () => { + assert.throws(() => getMoon(new Date(), 0, 200), /longitude/) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 3b7b85d..1972e57 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,9 @@ "module": "ESNext", "moduleResolution": "bundler", "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "esModuleInterop": true, "declaration": true,