mirror of
https://github.com/acamarata/moon-sighting.git
synced 2026-06-30 19:04:24 +00:00
ci: fix eslint parser devDeps, add files patterns, format src
- Add @typescript-eslint/parser and @typescript-eslint/eslint-plugin as explicit devDependencies so pnpm hoists them for eslint.config.mjs - Add files: ['**/*.ts'] to eslint config entries so ESLint 10 processes TS sources instead of ignoring them - Add parserOptions.project for typed-lint rules - Run prettier --write to fix pre-existing format issues in 12 src files
This commit is contained in:
parent
371d1773e7
commit
df4dbfe53e
15 changed files with 1194 additions and 1167 deletions
|
|
@ -5,10 +5,17 @@ import { typescript } from '@acamarata/eslint-config';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
plugins: { '@typescript-eslint': tsPlugin },
|
plugins: { '@typescript-eslint': tsPlugin },
|
||||||
languageOptions: { parser: tsParser },
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
project: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
...typescript,
|
...typescript.map((c) => ({ ...c, files: ['**/*.ts'] })),
|
||||||
eslintConfigPrettier,
|
eslintConfigPrettier,
|
||||||
{
|
{
|
||||||
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'],
|
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'],
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@
|
||||||
"@acamarata/tsconfig": "^0.1.0",
|
"@acamarata/tsconfig": "^0.1.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
"eslint": "^10.0.3",
|
"eslint": "^10.0.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ importers:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.3.0
|
specifier: ^25.3.0
|
||||||
version: 25.3.0
|
version: 25.3.0
|
||||||
|
'@typescript-eslint/eslint-plugin':
|
||||||
|
specifier: ^8.0.0
|
||||||
|
version: 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':
|
||||||
|
specifier: ^8.0.0
|
||||||
|
version: 8.56.1(eslint@10.0.3)(typescript@5.9.3)
|
||||||
c8:
|
c8:
|
||||||
specifier: ^10.1.3
|
specifier: ^10.1.3
|
||||||
version: 10.1.3
|
version: 10.1.3
|
||||||
|
|
|
||||||
489
src/api/index.ts
489
src/api/index.ts
|
|
@ -28,10 +28,10 @@ import type {
|
||||||
KernelConfig,
|
KernelConfig,
|
||||||
OdehZone,
|
OdehZone,
|
||||||
Vec3,
|
Vec3,
|
||||||
} from '../types.js'
|
} from "../types.js";
|
||||||
import { ODEH_THRESHOLDS, ODEH_DESCRIPTIONS } from '../types.js'
|
import { ODEH_THRESHOLDS, ODEH_DESCRIPTIONS } from "../types.js";
|
||||||
import { SpkKernel } from '../spk/index.js'
|
import { SpkKernel } from "../spk/index.js";
|
||||||
import { computeTimeScales, jdTTtoET, jdToDate, J2000 } from '../time/index.js'
|
import { computeTimeScales, jdTTtoET, jdToDate, J2000 } from "../time/index.js";
|
||||||
import {
|
import {
|
||||||
getMoonGeocentricState,
|
getMoonGeocentricState,
|
||||||
getSunGeocentricState,
|
getSunGeocentricState,
|
||||||
|
|
@ -39,52 +39,52 @@ import {
|
||||||
computeCrescentWidth,
|
computeCrescentWidth,
|
||||||
getMoonSunApproximate,
|
getMoonSunApproximate,
|
||||||
nearestNewMoon,
|
nearestNewMoon,
|
||||||
} from '../bodies/index.js'
|
} 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 { itrsToGcrs, computeERA } from "../frames/index.js";
|
||||||
import {
|
import {
|
||||||
getSunMoonEvents as eventsGetSunMoonEvents,
|
getSunMoonEvents as eventsGetSunMoonEvents,
|
||||||
bestTimeHeuristic,
|
bestTimeHeuristic,
|
||||||
bestTimeOptimized,
|
bestTimeOptimized,
|
||||||
computeObservationWindow,
|
computeObservationWindow,
|
||||||
} from '../events/index.js'
|
} from "../events/index.js";
|
||||||
import {
|
import {
|
||||||
computeCrescentGeometry,
|
computeCrescentGeometry,
|
||||||
computeYallop,
|
computeYallop,
|
||||||
computeOdeh,
|
computeOdeh,
|
||||||
buildGuidanceText,
|
buildGuidanceText,
|
||||||
arcvMinimum,
|
arcvMinimum,
|
||||||
} from '../visibility/index.js'
|
} from "../visibility/index.js";
|
||||||
import { DEG2RAD } from '../math/index.js'
|
import { DEG2RAD } from "../math/index.js";
|
||||||
|
|
||||||
// ─── Input validation ─────────────────────────────────────────────────────────
|
// ─── Input validation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function validateDate(date: Date, label: string): void {
|
function validateDate(date: Date, label: string): void {
|
||||||
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
||||||
throw new RangeError(`${label}: expected a valid Date instance`)
|
throw new RangeError(`${label}: expected a valid Date instance`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateLatitude(lat: number, label: string): void {
|
function validateLatitude(lat: number, label: string): void {
|
||||||
if (!isFinite(lat) || lat < -90 || lat > 90) {
|
if (!isFinite(lat) || lat < -90 || lat > 90) {
|
||||||
throw new RangeError(`${label}: latitude must be a finite number in [-90, 90], got ${lat}`)
|
throw new RangeError(`${label}: latitude must be a finite number in [-90, 90], got ${lat}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateLongitude(lon: number, label: string): void {
|
function validateLongitude(lon: number, label: string): void {
|
||||||
if (!isFinite(lon) || lon < -180 || lon > 180) {
|
if (!isFinite(lon) || lon < -180 || lon > 180) {
|
||||||
throw new RangeError(`${label}: longitude must be a finite number in [-180, 180], got ${lon}`)
|
throw new RangeError(`${label}: longitude must be a finite number in [-180, 180], got ${lon}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateObserver(observer: Observer, label: string): void {
|
function validateObserver(observer: Observer, label: string): void {
|
||||||
validateLatitude(observer.lat, label)
|
validateLatitude(observer.lat, label);
|
||||||
validateLongitude(observer.lon, label)
|
validateLongitude(observer.lon, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Module-level kernel singleton ─────────────────────────────────────────────
|
// ─── Module-level kernel singleton ─────────────────────────────────────────────
|
||||||
|
|
||||||
let activeKernel: SpkKernel | null = null
|
let activeKernel: SpkKernel | null = null;
|
||||||
|
|
||||||
// ─── Cache directory resolution ────────────────────────────────────────────────
|
// ─── Cache directory resolution ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -92,18 +92,18 @@ let activeKernel: SpkKernel | null = null
|
||||||
* Resolve the platform-appropriate kernel cache directory.
|
* Resolve the platform-appropriate kernel cache directory.
|
||||||
*/
|
*/
|
||||||
function resolveCacheDir(override?: string): string {
|
function resolveCacheDir(override?: string): string {
|
||||||
if (override) return override
|
if (override) return override;
|
||||||
const { platform, env } = process
|
const { platform, env } = process;
|
||||||
if (platform === 'win32') {
|
if (platform === "win32") {
|
||||||
return `${env['LOCALAPPDATA'] ?? env['APPDATA'] ?? 'C:\\Users\\Public\\AppData\\Local'}\\moon-sighting`
|
return `${env["LOCALAPPDATA"] ?? env["APPDATA"] ?? "C:\\Users\\Public\\AppData\\Local"}\\moon-sighting`;
|
||||||
}
|
}
|
||||||
return `${env['HOME'] ?? '/tmp'}/.cache/moon-sighting`
|
return `${env["HOME"] ?? "/tmp"}/.cache/moon-sighting`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Download sources ─────────────────────────────────────────────────────────
|
// ─── Download sources ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NAIF_DE442S_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de442s.bsp'
|
const NAIF_DE442S_URL = "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de442s.bsp";
|
||||||
const NAIF_LSK_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls'
|
const NAIF_LSK_URL = "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls";
|
||||||
|
|
||||||
// ─── Kernel lifecycle ─────────────────────────────────────────────────────────
|
// ─── Kernel lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -119,33 +119,33 @@ const NAIF_LSK_URL = 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/nai
|
||||||
* @param config - Kernel source configuration. Defaults to auto-download.
|
* @param config - Kernel source configuration. Defaults to auto-download.
|
||||||
*/
|
*/
|
||||||
export async function initKernels(config?: KernelConfig): Promise<void> {
|
export async function initKernels(config?: KernelConfig): Promise<void> {
|
||||||
const source = config?.planetary ?? { type: 'auto' as const }
|
const source = config?.planetary ?? { type: "auto" as const };
|
||||||
|
|
||||||
let buffer: ArrayBuffer
|
let buffer: ArrayBuffer;
|
||||||
|
|
||||||
if (source.type === 'file') {
|
if (source.type === "file") {
|
||||||
buffer = await readFileAsBuffer(source.path)
|
buffer = await readFileAsBuffer(source.path);
|
||||||
} else if (source.type === 'buffer') {
|
} else if (source.type === "buffer") {
|
||||||
buffer = source.data
|
buffer = source.data;
|
||||||
} else if (source.type === 'url') {
|
} else if (source.type === "url") {
|
||||||
const res = await fetch(source.url)
|
const res = await fetch(source.url);
|
||||||
if (!res.ok)
|
if (!res.ok)
|
||||||
throw new Error(`Failed to fetch kernel from ${source.url}: ${res.status} ${res.statusText}`)
|
throw new Error(`Failed to fetch kernel from ${source.url}: ${res.status} ${res.statusText}`);
|
||||||
buffer = await res.arrayBuffer()
|
buffer = await res.arrayBuffer();
|
||||||
} else {
|
} else {
|
||||||
// auto: download to local cache, then load
|
// auto: download to local cache, then load
|
||||||
const paths = await downloadKernels(config)
|
const paths = await downloadKernels(config);
|
||||||
buffer = await readFileAsBuffer(paths.planetaryPath)
|
buffer = await readFileAsBuffer(paths.planetaryPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
activeKernel = SpkKernel.fromBuffer(buffer)
|
activeKernel = SpkKernel.fromBuffer(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read a file into an ArrayBuffer (Node.js only). */
|
/** Read a file into an ArrayBuffer (Node.js only). */
|
||||||
async function readFileAsBuffer(filePath: string): Promise<ArrayBuffer> {
|
async function readFileAsBuffer(filePath: string): Promise<ArrayBuffer> {
|
||||||
const { readFile } = await import('node:fs/promises')
|
const { readFile } = await import("node:fs/promises");
|
||||||
const buf = await readFile(filePath)
|
const buf = await readFile(filePath);
|
||||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
|
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -157,59 +157,60 @@ async function readFileAsBuffer(filePath: string): Promise<ArrayBuffer> {
|
||||||
* @returns Paths where kernels were saved
|
* @returns Paths where kernels were saved
|
||||||
*/
|
*/
|
||||||
export async function downloadKernels(config?: KernelConfig): Promise<{
|
export async function downloadKernels(config?: KernelConfig): Promise<{
|
||||||
planetaryPath: string
|
planetaryPath: string;
|
||||||
leapSecondsPath: string
|
leapSecondsPath: string;
|
||||||
}> {
|
}> {
|
||||||
const cacheDir = resolveCacheDir(config?.cacheDir)
|
const cacheDir = resolveCacheDir(config?.cacheDir);
|
||||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
const { mkdir, writeFile } = await import("node:fs/promises");
|
||||||
const { existsSync } = await import('node:fs')
|
const { existsSync } = await import("node:fs");
|
||||||
const { join } = await import('node:path')
|
const { join } = await import("node:path");
|
||||||
|
|
||||||
await mkdir(cacheDir, { recursive: true })
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
|
||||||
const planetaryPath = join(cacheDir, 'de442s.bsp')
|
const planetaryPath = join(cacheDir, "de442s.bsp");
|
||||||
const leapSecondsPath = join(cacheDir, 'naif0012.tls')
|
const leapSecondsPath = join(cacheDir, "naif0012.tls");
|
||||||
|
|
||||||
if (!existsSync(planetaryPath)) {
|
if (!existsSync(planetaryPath)) {
|
||||||
process.stdout.write('Downloading de442s.bsp from NAIF... ')
|
process.stdout.write("Downloading de442s.bsp from NAIF... ");
|
||||||
const res = await fetch(NAIF_DE442S_URL)
|
const res = await fetch(NAIF_DE442S_URL);
|
||||||
if (!res.ok) throw new Error(`Failed to download de442s.bsp: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new Error(`Failed to download de442s.bsp: ${res.status} ${res.statusText}`);
|
||||||
const buf = await res.arrayBuffer()
|
const buf = await res.arrayBuffer();
|
||||||
await writeFile(planetaryPath, Buffer.from(buf))
|
await writeFile(planetaryPath, Buffer.from(buf));
|
||||||
console.log(`done (${(buf.byteLength / 1048576).toFixed(1)} MB)`)
|
console.log(`done (${(buf.byteLength / 1048576).toFixed(1)} MB)`);
|
||||||
|
|
||||||
if (config?.checksumOverride) {
|
if (config?.checksumOverride) {
|
||||||
const actual = await sha256File(planetaryPath)
|
const actual = await sha256File(planetaryPath);
|
||||||
if (actual !== config.checksumOverride.toLowerCase()) {
|
if (actual !== config.checksumOverride.toLowerCase()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`de442s.bsp checksum mismatch.\n Expected: ${config.checksumOverride}\n Got: ${actual}`,
|
`de442s.bsp checksum mismatch.\n Expected: ${config.checksumOverride}\n Got: ${actual}`,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('de442s.bsp already cached.')
|
console.log("de442s.bsp already cached.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existsSync(leapSecondsPath)) {
|
if (!existsSync(leapSecondsPath)) {
|
||||||
process.stdout.write('Downloading naif0012.tls from NAIF... ')
|
process.stdout.write("Downloading naif0012.tls from NAIF... ");
|
||||||
const res = await fetch(NAIF_LSK_URL)
|
const res = await fetch(NAIF_LSK_URL);
|
||||||
if (!res.ok) throw new Error(`Failed to download naif0012.tls: ${res.status} ${res.statusText}`)
|
if (!res.ok)
|
||||||
const text = await res.text()
|
throw new Error(`Failed to download naif0012.tls: ${res.status} ${res.statusText}`);
|
||||||
await writeFile(leapSecondsPath, text, 'utf8')
|
const text = await res.text();
|
||||||
console.log('done.')
|
await writeFile(leapSecondsPath, text, "utf8");
|
||||||
|
console.log("done.");
|
||||||
} else {
|
} else {
|
||||||
console.log('naif0012.tls already cached.')
|
console.log("naif0012.tls already cached.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { planetaryPath, leapSecondsPath }
|
return { planetaryPath, leapSecondsPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute the SHA-256 hex digest of a local file. */
|
/** Compute the SHA-256 hex digest of a local file. */
|
||||||
async function sha256File(filePath: string): Promise<string> {
|
async function sha256File(filePath: string): Promise<string> {
|
||||||
const { createHash } = await import('node:crypto')
|
const { createHash } = await import("node:crypto");
|
||||||
const { readFile } = await import('node:fs/promises')
|
const { readFile } = await import("node:fs/promises");
|
||||||
const buf = await readFile(filePath)
|
const buf = await readFile(filePath);
|
||||||
return createHash('sha256').update(buf).digest('hex')
|
return createHash("sha256").update(buf).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -219,33 +220,33 @@ async function sha256File(filePath: string): Promise<string> {
|
||||||
* @returns { ok, errors[] } — ok is true when all checks pass
|
* @returns { ok, errors[] } — ok is true when all checks pass
|
||||||
*/
|
*/
|
||||||
export async function verifyKernels(config?: KernelConfig): Promise<{
|
export async function verifyKernels(config?: KernelConfig): Promise<{
|
||||||
ok: boolean
|
ok: boolean;
|
||||||
errors: string[]
|
errors: string[];
|
||||||
}> {
|
}> {
|
||||||
const cacheDir = resolveCacheDir(config?.cacheDir)
|
const cacheDir = resolveCacheDir(config?.cacheDir);
|
||||||
const { existsSync } = await import('node:fs')
|
const { existsSync } = await import("node:fs");
|
||||||
const { join } = await import('node:path')
|
const { join } = await import("node:path");
|
||||||
const errors: string[] = []
|
const errors: string[] = [];
|
||||||
|
|
||||||
const planetaryPath = join(cacheDir, 'de442s.bsp')
|
const planetaryPath = join(cacheDir, "de442s.bsp");
|
||||||
const leapSecondsPath = join(cacheDir, 'naif0012.tls')
|
const leapSecondsPath = join(cacheDir, "naif0012.tls");
|
||||||
|
|
||||||
if (!existsSync(planetaryPath)) {
|
if (!existsSync(planetaryPath)) {
|
||||||
errors.push(`de442s.bsp not found at ${planetaryPath}. Run downloadKernels() first.`)
|
errors.push(`de442s.bsp not found at ${planetaryPath}. Run downloadKernels() first.`);
|
||||||
} else if (config?.checksumOverride) {
|
} else if (config?.checksumOverride) {
|
||||||
const actual = await sha256File(planetaryPath)
|
const actual = await sha256File(planetaryPath);
|
||||||
if (actual !== config.checksumOverride.toLowerCase()) {
|
if (actual !== config.checksumOverride.toLowerCase()) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`de442s.bsp checksum mismatch.\n Expected: ${config.checksumOverride}\n Got: ${actual}`,
|
`de442s.bsp checksum mismatch.\n Expected: ${config.checksumOverride}\n Got: ${actual}`,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existsSync(leapSecondsPath)) {
|
if (!existsSync(leapSecondsPath)) {
|
||||||
errors.push(`naif0012.tls not found at ${leapSecondsPath}. Run downloadKernels() first.`)
|
errors.push(`naif0012.tls not found at ${leapSecondsPath}. Run downloadKernels() first.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: errors.length === 0, errors }
|
return { ok: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Kernel resolution ─────────────────────────────────────────────────────────
|
// ─── Kernel resolution ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -256,25 +257,25 @@ export async function verifyKernels(config?: KernelConfig): Promise<{
|
||||||
*/
|
*/
|
||||||
async function resolveKernel(config?: KernelConfig): Promise<SpkKernel> {
|
async function resolveKernel(config?: KernelConfig): Promise<SpkKernel> {
|
||||||
if (config?.planetary) {
|
if (config?.planetary) {
|
||||||
const source = config.planetary
|
const source = config.planetary;
|
||||||
if (source.type === 'file') {
|
if (source.type === "file") {
|
||||||
return SpkKernel.fromBuffer(await readFileAsBuffer(source.path))
|
return SpkKernel.fromBuffer(await readFileAsBuffer(source.path));
|
||||||
} else if (source.type === 'buffer') {
|
} else if (source.type === "buffer") {
|
||||||
return SpkKernel.fromBuffer(source.data)
|
return SpkKernel.fromBuffer(source.data);
|
||||||
} else if (source.type === 'url') {
|
} else if (source.type === "url") {
|
||||||
const res = await fetch(source.url)
|
const res = await fetch(source.url);
|
||||||
if (!res.ok) throw new Error(`Failed to fetch kernel: ${res.status}`)
|
if (!res.ok) throw new Error(`Failed to fetch kernel: ${res.status}`);
|
||||||
return SpkKernel.fromBuffer(await res.arrayBuffer())
|
return SpkKernel.fromBuffer(await res.arrayBuffer());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeKernel) return activeKernel
|
if (activeKernel) return activeKernel;
|
||||||
|
|
||||||
// auto-init as last resort
|
// auto-init as last resort
|
||||||
await initKernels(config)
|
await initKernels(config);
|
||||||
if (!activeKernel)
|
if (!activeKernel)
|
||||||
throw new Error('Kernel failed to initialize. Call initKernels() before computing.')
|
throw new Error("Kernel failed to initialize. Call initKernels() before computing.");
|
||||||
return activeKernel
|
return activeKernel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Primary API ──────────────────────────────────────────────────────────────
|
// ─── Primary API ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -308,72 +309,72 @@ export async function getMoonSightingReport(
|
||||||
observer: Observer,
|
observer: Observer,
|
||||||
options?: SightingOptions,
|
options?: SightingOptions,
|
||||||
): Promise<MoonSightingReport> {
|
): Promise<MoonSightingReport> {
|
||||||
validateDate(date, 'getMoonSightingReport')
|
validateDate(date, "getMoonSightingReport");
|
||||||
validateObserver(observer, 'getMoonSightingReport')
|
validateObserver(observer, "getMoonSightingReport");
|
||||||
const kernel = await resolveKernel(options?.kernels)
|
const kernel = await resolveKernel(options?.kernels);
|
||||||
|
|
||||||
// Event times (sunset, moonset, twilight, rise)
|
// Event times (sunset, moonset, twilight, rise)
|
||||||
const events = eventsGetSunMoonEvents(date, observer, kernel)
|
const events = eventsGetSunMoonEvents(date, observer, kernel);
|
||||||
const { sunsetUTC, moonsetUTC } = events
|
const { sunsetUTC, moonsetUTC } = events;
|
||||||
|
|
||||||
if (!sunsetUTC || !moonsetUTC) {
|
if (!sunsetUTC || !moonsetUTC) {
|
||||||
return buildNullReport(date, observer, events, 'DE442S', false)
|
return buildNullReport(date, observer, events, "DE442S", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best observation time
|
// Best observation time
|
||||||
const method = options?.bestTimeMethod ?? 'heuristic'
|
const method = options?.bestTimeMethod ?? "heuristic";
|
||||||
let bestTimeResult: { bestTimeUTC: Date; lagMinutes: number } | null = null
|
let bestTimeResult: { bestTimeUTC: Date; lagMinutes: number } | null = null;
|
||||||
|
|
||||||
if (method === 'optimized') {
|
if (method === "optimized") {
|
||||||
const opt = bestTimeOptimized(sunsetUTC, moonsetUTC, kernel, observer)
|
const opt = bestTimeOptimized(sunsetUTC, moonsetUTC, kernel, observer);
|
||||||
if (opt) bestTimeResult = { bestTimeUTC: opt.bestTimeUTC, lagMinutes: opt.lagMinutes }
|
if (opt) bestTimeResult = { bestTimeUTC: opt.bestTimeUTC, lagMinutes: opt.lagMinutes };
|
||||||
}
|
}
|
||||||
if (!bestTimeResult) {
|
if (!bestTimeResult) {
|
||||||
bestTimeResult = bestTimeHeuristic(sunsetUTC, moonsetUTC)
|
bestTimeResult = bestTimeHeuristic(sunsetUTC, moonsetUTC);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bestTimeResult) {
|
if (!bestTimeResult) {
|
||||||
return buildNullReport(date, observer, events, 'DE442S', false)
|
return buildNullReport(date, observer, events, "DE442S", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bestTimeUTC, lagMinutes } = bestTimeResult
|
const { bestTimeUTC, lagMinutes } = bestTimeResult;
|
||||||
const bestTimeWindowUTC = computeObservationWindow(bestTimeUTC)
|
const bestTimeWindowUTC = computeObservationWindow(bestTimeUTC);
|
||||||
|
|
||||||
// Time scales and ephemeris time at best time
|
// Time scales and ephemeris time at best time
|
||||||
const ts = computeTimeScales(bestTimeUTC, observer.ut1utc, observer.deltaT)
|
const ts = computeTimeScales(bestTimeUTC, observer.ut1utc, observer.deltaT);
|
||||||
const et = jdTTtoET(ts.jdTT)
|
const et = jdTTtoET(ts.jdTT);
|
||||||
|
|
||||||
// Body positions in GCRS (geocentric)
|
// Body positions in GCRS (geocentric)
|
||||||
const moonGCRS = getMoonGeocentricState(kernel, et).position
|
const moonGCRS = getMoonGeocentricState(kernel, et).position;
|
||||||
const sunGCRS = getSunGeocentricState(kernel, et).position
|
const sunGCRS = getSunGeocentricState(kernel, et).position;
|
||||||
|
|
||||||
// Observer ITRS position (km) from geodetic coordinates
|
// Observer ITRS position (km) from geodetic coordinates
|
||||||
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation)
|
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation);
|
||||||
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000]
|
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000];
|
||||||
|
|
||||||
// Convert to GCRS (inertial frame) — required for correct topocentric subtraction
|
// Convert to GCRS (inertial frame) — required for correct topocentric subtraction
|
||||||
// GCRS body vectors (from SPK) and observer must be in the same frame before subtracting
|
// GCRS body vectors (from SPK) and observer must be in the same frame before subtracting
|
||||||
const obsGCRS = itrsToGcrs(obsITRS, ts)
|
const obsGCRS = itrsToGcrs(obsITRS, ts);
|
||||||
|
|
||||||
// Airless alt/az — required by Yallop/Odeh criteria
|
// Airless alt/az — required by Yallop/Odeh criteria
|
||||||
const moonAirless = computeAzAlt(moonGCRS, observer, ts, true)
|
const moonAirless = computeAzAlt(moonGCRS, observer, ts, true);
|
||||||
const sunAirless = computeAzAlt(sunGCRS, observer, ts, true)
|
const sunAirless = computeAzAlt(sunGCRS, observer, ts, true);
|
||||||
// Apparent alt/az (with refraction) — for guidance text
|
// Apparent alt/az (with refraction) — for guidance text
|
||||||
const moonApparent = computeAzAlt(moonGCRS, observer, ts, false)
|
const moonApparent = computeAzAlt(moonGCRS, observer, ts, false);
|
||||||
|
|
||||||
// Illumination and moon age
|
// Illumination and moon age
|
||||||
const illumData = computeIllumination(moonGCRS, sunGCRS)
|
const illumData = computeIllumination(moonGCRS, sunGCRS);
|
||||||
const illumination = illumData.illumination * 100
|
const illumination = illumData.illumination * 100;
|
||||||
const prevNewMoonJD = nearestNewMoon(ts.jdTT - 15)
|
const prevNewMoonJD = nearestNewMoon(ts.jdTT - 15);
|
||||||
const moonAgeHours = (ts.jdTT - prevNewMoonJD) * 24
|
const moonAgeHours = (ts.jdTT - prevNewMoonJD) * 24;
|
||||||
|
|
||||||
// Topocentric vectors for crescent geometry (GCRS - observer GCRS)
|
// Topocentric vectors for crescent geometry (GCRS - observer GCRS)
|
||||||
const moonTopo: Vec3 = [
|
const moonTopo: Vec3 = [
|
||||||
moonGCRS[0] - obsGCRS[0],
|
moonGCRS[0] - obsGCRS[0],
|
||||||
moonGCRS[1] - obsGCRS[1],
|
moonGCRS[1] - obsGCRS[1],
|
||||||
moonGCRS[2] - obsGCRS[2],
|
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(
|
const geometry = computeCrescentGeometry(
|
||||||
moonAirless,
|
moonAirless,
|
||||||
|
|
@ -382,14 +383,14 @@ export async function getMoonSightingReport(
|
||||||
sunTopo,
|
sunTopo,
|
||||||
sunsetUTC,
|
sunsetUTC,
|
||||||
moonsetUTC,
|
moonsetUTC,
|
||||||
)
|
);
|
||||||
|
|
||||||
const { Wprime } = computeCrescentWidth(moonTopo, geometry.ARCL)
|
const { Wprime } = computeCrescentWidth(moonTopo, geometry.ARCL);
|
||||||
const yallop = computeYallop(geometry, Wprime)
|
const yallop = computeYallop(geometry, Wprime);
|
||||||
const odeh = computeOdeh(geometry)
|
const odeh = computeOdeh(geometry);
|
||||||
|
|
||||||
const moonAboveHorizon = moonAirless.altitude > 0
|
const moonAboveHorizon = moonAirless.altitude > 0;
|
||||||
const sightingPossible = moonAboveHorizon && lagMinutes > 0
|
const sightingPossible = moonAboveHorizon && lagMinutes > 0;
|
||||||
|
|
||||||
const guidance = buildGuidanceText(
|
const guidance = buildGuidanceText(
|
||||||
yallop,
|
yallop,
|
||||||
|
|
@ -398,7 +399,7 @@ export async function getMoonSightingReport(
|
||||||
moonApparent.altitude,
|
moonApparent.altitude,
|
||||||
bestTimeUTC,
|
bestTimeUTC,
|
||||||
lagMinutes,
|
lagMinutes,
|
||||||
)
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
|
|
@ -416,10 +417,10 @@ export async function getMoonSightingReport(
|
||||||
yallop,
|
yallop,
|
||||||
odeh,
|
odeh,
|
||||||
guidance,
|
guidance,
|
||||||
ephemerisSource: 'DE442S',
|
ephemerisSource: "DE442S",
|
||||||
moonAboveHorizon,
|
moonAboveHorizon,
|
||||||
sightingPossible,
|
sightingPossible,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a null report for cases where sighting geometry cannot be computed. */
|
/** Build a null report for cases where sighting geometry cannot be computed. */
|
||||||
|
|
@ -427,7 +428,7 @@ function buildNullReport(
|
||||||
date: Date,
|
date: Date,
|
||||||
observer: Observer,
|
observer: Observer,
|
||||||
events: SunMoonEvents,
|
events: SunMoonEvents,
|
||||||
source: 'DE442S' | 'approximate',
|
source: "DE442S" | "approximate",
|
||||||
sightingPossible: boolean,
|
sightingPossible: boolean,
|
||||||
): MoonSightingReport {
|
): MoonSightingReport {
|
||||||
return {
|
return {
|
||||||
|
|
@ -446,25 +447,25 @@ function buildNullReport(
|
||||||
yallop: null,
|
yallop: null,
|
||||||
odeh: null,
|
odeh: null,
|
||||||
guidance:
|
guidance:
|
||||||
'Sighting not possible: sunset or moonset could not be determined for this date and location.',
|
"Sighting not possible: sunset or moonset could not be determined for this date and location.",
|
||||||
ephemerisSource: source,
|
ephemerisSource: source,
|
||||||
moonAboveHorizon: null,
|
moonAboveHorizon: null,
|
||||||
sightingPossible,
|
sightingPossible,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Phase display lookup ──────────────────────────────────────────────────────
|
// ─── Phase display lookup ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PHASE_DISPLAY: Record<MoonPhaseName, { name: string; symbol: string }> = {
|
const PHASE_DISPLAY: Record<MoonPhaseName, { name: string; symbol: string }> = {
|
||||||
'new-moon': { name: 'New Moon', symbol: '🌑' },
|
"new-moon": { name: "New Moon", symbol: "🌑" },
|
||||||
'waxing-crescent': { name: 'Waxing Crescent', symbol: '🌒' },
|
"waxing-crescent": { name: "Waxing Crescent", symbol: "🌒" },
|
||||||
'first-quarter': { name: 'First Quarter', symbol: '🌓' },
|
"first-quarter": { name: "First Quarter", symbol: "🌓" },
|
||||||
'waxing-gibbous': { name: 'Waxing Gibbous', symbol: '🌔' },
|
"waxing-gibbous": { name: "Waxing Gibbous", symbol: "🌔" },
|
||||||
'full-moon': { name: 'Full Moon', symbol: '🌕' },
|
"full-moon": { name: "Full Moon", symbol: "🌕" },
|
||||||
'waning-gibbous': { name: 'Waning Gibbous', symbol: '🌖' },
|
"waning-gibbous": { name: "Waning Gibbous", symbol: "🌖" },
|
||||||
'last-quarter': { name: 'Last Quarter', symbol: '🌗' },
|
"last-quarter": { name: "Last Quarter", symbol: "🌗" },
|
||||||
'waning-crescent': { name: 'Waning Crescent', symbol: '🌘' },
|
"waning-crescent": { name: "Waning Crescent", symbol: "🌘" },
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the Moon's current phase, illumination, and next phase times.
|
* Compute the Moon's current phase, illumination, and next phase times.
|
||||||
|
|
@ -485,23 +486,23 @@ const PHASE_DISPLAY: Record<MoonPhaseName, { name: string; symbol: string }> = {
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function getMoonPhase(date = new Date()): MoonPhaseResult {
|
export function getMoonPhase(date = new Date()): MoonPhaseResult {
|
||||||
validateDate(date, 'getMoonPhase')
|
validateDate(date, "getMoonPhase");
|
||||||
const ts = computeTimeScales(date)
|
const ts = computeTimeScales(date);
|
||||||
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT)
|
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT);
|
||||||
|
|
||||||
const { illumination, elongationDeg, isWaxing } = computeIllumination(moonGCRS, sunGCRS)
|
const { illumination, elongationDeg, isWaxing } = computeIllumination(moonGCRS, sunGCRS);
|
||||||
const illuminationPct = illumination * 100
|
const illuminationPct = illumination * 100;
|
||||||
|
|
||||||
// Age in hours since previous new moon
|
// Age in hours since previous new moon
|
||||||
// Search 15 days back/forward to ensure we clear the current lunation boundary
|
// Search 15 days back/forward to ensure we clear the current lunation boundary
|
||||||
const prevNewMoonJD = nearestNewMoon(ts.jdTT - 15)
|
const prevNewMoonJD = nearestNewMoon(ts.jdTT - 15);
|
||||||
const age = (ts.jdTT - prevNewMoonJD) * 24
|
const age = (ts.jdTT - prevNewMoonJD) * 24;
|
||||||
|
|
||||||
const phaseKey = elongationToPhase(elongationDeg, isWaxing)
|
const phaseKey = elongationToPhase(elongationDeg, isWaxing);
|
||||||
const { name: phaseName, symbol: phaseSymbol } = PHASE_DISPLAY[phaseKey]
|
const { name: phaseName, symbol: phaseSymbol } = PHASE_DISPLAY[phaseKey];
|
||||||
|
|
||||||
const nextNewMoonJD = nearestNewMoon(ts.jdTT + 15)
|
const nextNewMoonJD = nearestNewMoon(ts.jdTT + 15);
|
||||||
const nextFullMoonJD = nearestFullMoon(ts.jdTT)
|
const nextFullMoonJD = nearestFullMoon(ts.jdTT);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phase: phaseKey,
|
phase: phaseKey,
|
||||||
|
|
@ -514,7 +515,7 @@ export function getMoonPhase(date = new Date()): MoonPhaseResult {
|
||||||
nextNewMoon: jdToDate(nextNewMoonJD),
|
nextNewMoon: jdToDate(nextNewMoonJD),
|
||||||
nextFullMoon: jdToDate(nextFullMoonJD),
|
nextFullMoon: jdToDate(nextFullMoonJD),
|
||||||
prevNewMoon: jdToDate(prevNewMoonJD),
|
prevNewMoon: jdToDate(prevNewMoonJD),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -542,34 +543,34 @@ export function getMoonPosition(
|
||||||
lon: number,
|
lon: number,
|
||||||
elevation = 0,
|
elevation = 0,
|
||||||
): MoonPosition {
|
): MoonPosition {
|
||||||
validateDate(date, 'getMoonPosition')
|
validateDate(date, "getMoonPosition");
|
||||||
validateLatitude(lat, 'getMoonPosition')
|
validateLatitude(lat, "getMoonPosition");
|
||||||
validateLongitude(lon, 'getMoonPosition')
|
validateLongitude(lon, "getMoonPosition");
|
||||||
const ts = computeTimeScales(date)
|
const ts = computeTimeScales(date);
|
||||||
const { moonGCRS } = getMoonSunApproximate(ts.jdTT)
|
const { moonGCRS } = getMoonSunApproximate(ts.jdTT);
|
||||||
|
|
||||||
// Apparent az/alt with Bennett refraction — uses existing observer pipeline
|
// Apparent az/alt with Bennett refraction — uses existing observer pipeline
|
||||||
const observer: Observer = { lat, lon, elevation }
|
const observer: Observer = { lat, lon, elevation };
|
||||||
const azAlt = computeAzAlt(moonGCRS, observer, ts, false)
|
const azAlt = computeAzAlt(moonGCRS, observer, ts, false);
|
||||||
|
|
||||||
// Distance in km
|
// Distance in km
|
||||||
const distance = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2)
|
const distance = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2);
|
||||||
|
|
||||||
// Equatorial coordinates for parallactic angle
|
// Equatorial coordinates for parallactic angle
|
||||||
const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0])
|
const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0]);
|
||||||
const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / distance)))
|
const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / distance)));
|
||||||
|
|
||||||
// Hour angle: ERA(UT1) + longitude − right ascension
|
// Hour angle: ERA(UT1) + longitude − right ascension
|
||||||
const era = computeERA(ts.jdUT1)
|
const era = computeERA(ts.jdUT1);
|
||||||
const HA = era + lon * DEG2RAD - RA_moon
|
const HA = era + lon * DEG2RAD - RA_moon;
|
||||||
|
|
||||||
// Parallactic angle: signed angle between zenith and north pole as seen from the Moon
|
// Parallactic angle: signed angle between zenith and north pole as seen from the Moon
|
||||||
const parallacticAngle = Math.atan2(
|
const parallacticAngle = Math.atan2(
|
||||||
Math.sin(HA),
|
Math.sin(HA),
|
||||||
Math.cos(lat * DEG2RAD) * Math.tan(dec_moon) - Math.sin(lat * DEG2RAD) * Math.cos(HA),
|
Math.cos(lat * DEG2RAD) * Math.tan(dec_moon) - Math.sin(lat * DEG2RAD) * Math.cos(HA),
|
||||||
)
|
);
|
||||||
|
|
||||||
return { azimuth: azAlt.azimuth, altitude: azAlt.altitude, distance, parallacticAngle }
|
return { azimuth: azAlt.azimuth, altitude: azAlt.altitude, distance, parallacticAngle };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -590,33 +591,33 @@ export function getMoonPosition(
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function getMoonIllumination(date: Date = new Date()): MoonIlluminationResult {
|
export function getMoonIllumination(date: Date = new Date()): MoonIlluminationResult {
|
||||||
validateDate(date, 'getMoonIllumination')
|
validateDate(date, "getMoonIllumination");
|
||||||
const ts = computeTimeScales(date)
|
const ts = computeTimeScales(date);
|
||||||
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT)
|
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT);
|
||||||
|
|
||||||
const { illumination, elongationDeg, isWaxing } = computeIllumination(moonGCRS, sunGCRS)
|
const { illumination, elongationDeg, isWaxing } = computeIllumination(moonGCRS, sunGCRS);
|
||||||
|
|
||||||
// Phase fraction: 0 = new moon, 0.25 = first quarter, 0.5 = full moon, 0.75 = last quarter
|
// Phase fraction: 0 = new moon, 0.25 = first quarter, 0.5 = full moon, 0.75 = last quarter
|
||||||
const phase = isWaxing ? elongationDeg / 360 : 1 - elongationDeg / 360
|
const phase = isWaxing ? elongationDeg / 360 : 1 - elongationDeg / 360;
|
||||||
|
|
||||||
// Position angle of the bright limb midpoint, measured eastward from north celestial pole.
|
// Position angle of the bright limb midpoint, measured eastward from north celestial pole.
|
||||||
// PA = atan2(cos(dec_sun) * sin(RA_sun - RA_moon),
|
// PA = atan2(cos(dec_sun) * sin(RA_sun - RA_moon),
|
||||||
// sin(dec_sun) * cos(dec_moon) - cos(dec_sun) * sin(dec_moon) * cos(RA_sun - RA_moon))
|
// sin(dec_sun) * cos(dec_moon) - cos(dec_sun) * sin(dec_moon) * cos(RA_sun - RA_moon))
|
||||||
const moonDist = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2)
|
const moonDist = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2);
|
||||||
const sunDist = Math.sqrt(sunGCRS[0] ** 2 + sunGCRS[1] ** 2 + sunGCRS[2] ** 2)
|
const sunDist = Math.sqrt(sunGCRS[0] ** 2 + sunGCRS[1] ** 2 + sunGCRS[2] ** 2);
|
||||||
|
|
||||||
const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0])
|
const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0]);
|
||||||
const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / moonDist)))
|
const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / moonDist)));
|
||||||
const RA_sun = Math.atan2(sunGCRS[1], sunGCRS[0])
|
const RA_sun = Math.atan2(sunGCRS[1], sunGCRS[0]);
|
||||||
const dec_sun = Math.asin(Math.max(-1, Math.min(1, sunGCRS[2] / sunDist)))
|
const dec_sun = Math.asin(Math.max(-1, Math.min(1, sunGCRS[2] / sunDist)));
|
||||||
|
|
||||||
const dRA = RA_sun - RA_moon
|
const dRA = RA_sun - RA_moon;
|
||||||
const angle = Math.atan2(
|
const angle = Math.atan2(
|
||||||
Math.cos(dec_sun) * Math.sin(dRA),
|
Math.cos(dec_sun) * Math.sin(dRA),
|
||||||
Math.sin(dec_sun) * Math.cos(dec_moon) - Math.cos(dec_sun) * Math.sin(dec_moon) * Math.cos(dRA),
|
Math.sin(dec_sun) * Math.cos(dec_moon) - Math.cos(dec_sun) * Math.sin(dec_moon) * Math.cos(dRA),
|
||||||
)
|
);
|
||||||
|
|
||||||
return { fraction: illumination, phase, angle, isWaxing }
|
return { fraction: illumination, phase, angle, isWaxing };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -650,54 +651,60 @@ export function getMoonVisibilityEstimate(
|
||||||
lon: number,
|
lon: number,
|
||||||
elevation = 0,
|
elevation = 0,
|
||||||
): MoonVisibilityEstimate {
|
): MoonVisibilityEstimate {
|
||||||
validateDate(date, 'getMoonVisibilityEstimate')
|
validateDate(date, "getMoonVisibilityEstimate");
|
||||||
validateLatitude(lat, 'getMoonVisibilityEstimate')
|
validateLatitude(lat, "getMoonVisibilityEstimate");
|
||||||
validateLongitude(lon, 'getMoonVisibilityEstimate')
|
validateLongitude(lon, "getMoonVisibilityEstimate");
|
||||||
const ts = computeTimeScales(date)
|
const ts = computeTimeScales(date);
|
||||||
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT)
|
const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT);
|
||||||
const observer: Observer = { lat, lon, elevation }
|
const observer: Observer = { lat, lon, elevation };
|
||||||
|
|
||||||
// Airless positions — Odeh uses airless altitudes (no refraction)
|
// Airless positions — Odeh uses airless altitudes (no refraction)
|
||||||
const moonAirless = computeAzAlt(moonGCRS, observer, ts, true)
|
const moonAirless = computeAzAlt(moonGCRS, observer, ts, true);
|
||||||
const sunAirless = computeAzAlt(sunGCRS, observer, ts, true)
|
const sunAirless = computeAzAlt(sunGCRS, observer, ts, true);
|
||||||
|
|
||||||
// ARCL = elongation (geocentric, degrees)
|
// ARCL = elongation (geocentric, degrees)
|
||||||
const { elongationDeg } = computeIllumination(moonGCRS, sunGCRS)
|
const { elongationDeg } = computeIllumination(moonGCRS, sunGCRS);
|
||||||
const ARCL = elongationDeg
|
const ARCL = elongationDeg;
|
||||||
|
|
||||||
// ARCV = Moon airless altitude minus Sun airless altitude
|
// ARCV = Moon airless altitude minus Sun airless altitude
|
||||||
const ARCV = moonAirless.altitude - sunAirless.altitude
|
const ARCV = moonAirless.altitude - sunAirless.altitude;
|
||||||
|
|
||||||
// Topocentric Moon vector for crescent width
|
// Topocentric Moon vector for crescent width
|
||||||
const obsECEF = geodeticToECEF(lat, lon, elevation)
|
const obsECEF = geodeticToECEF(lat, lon, elevation);
|
||||||
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000]
|
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000];
|
||||||
const obsGCRS = itrsToGcrs(obsITRS, ts)
|
const obsGCRS = itrsToGcrs(obsITRS, ts);
|
||||||
const moonTopo: Vec3 = [
|
const moonTopo: Vec3 = [
|
||||||
moonGCRS[0] - obsGCRS[0],
|
moonGCRS[0] - obsGCRS[0],
|
||||||
moonGCRS[1] - obsGCRS[1],
|
moonGCRS[1] - obsGCRS[1],
|
||||||
moonGCRS[2] - obsGCRS[2],
|
moonGCRS[2] - obsGCRS[2],
|
||||||
]
|
];
|
||||||
|
|
||||||
const { W } = computeCrescentWidth(moonTopo, ARCL)
|
const { W } = computeCrescentWidth(moonTopo, ARCL);
|
||||||
|
|
||||||
// Odeh 2006: V = ARCV - arcv_minimum(W)
|
// Odeh 2006: V = ARCV - arcv_minimum(W)
|
||||||
const V = ARCV - arcvMinimum(W)
|
const V = ARCV - arcvMinimum(W);
|
||||||
|
|
||||||
const zone: OdehZone =
|
const zone: OdehZone =
|
||||||
V >= ODEH_THRESHOLDS.A ? 'A' : V >= ODEH_THRESHOLDS.B ? 'B' : V >= ODEH_THRESHOLDS.C ? 'C' : 'D'
|
V >= ODEH_THRESHOLDS.A
|
||||||
|
? "A"
|
||||||
|
: V >= ODEH_THRESHOLDS.B
|
||||||
|
? "B"
|
||||||
|
: V >= ODEH_THRESHOLDS.C
|
||||||
|
? "C"
|
||||||
|
: "D";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
V,
|
V,
|
||||||
zone,
|
zone,
|
||||||
description: ODEH_DESCRIPTIONS[zone],
|
description: ODEH_DESCRIPTIONS[zone],
|
||||||
isVisibleNakedEye: zone === 'A',
|
isVisibleNakedEye: zone === "A",
|
||||||
isVisibleWithOpticalAid: zone === 'A' || zone === 'B',
|
isVisibleWithOpticalAid: zone === "A" || zone === "B",
|
||||||
ARCL,
|
ARCL,
|
||||||
ARCV,
|
ARCV,
|
||||||
W,
|
W,
|
||||||
moonAboveHorizon: moonAirless.altitude > 0,
|
moonAboveHorizon: moonAirless.altitude > 0,
|
||||||
isApproximate: true,
|
isApproximate: true,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -730,15 +737,15 @@ export function getMoon(
|
||||||
lon: number,
|
lon: number,
|
||||||
elevation = 0,
|
elevation = 0,
|
||||||
): MoonSnapshot {
|
): MoonSnapshot {
|
||||||
validateDate(date, 'getMoon')
|
validateDate(date, "getMoon");
|
||||||
validateLatitude(lat, 'getMoon')
|
validateLatitude(lat, "getMoon");
|
||||||
validateLongitude(lon, 'getMoon')
|
validateLongitude(lon, "getMoon");
|
||||||
return {
|
return {
|
||||||
phase: getMoonPhase(date),
|
phase: getMoonPhase(date),
|
||||||
position: getMoonPosition(date, lat, lon, elevation),
|
position: getMoonPosition(date, lat, lon, elevation),
|
||||||
illumination: getMoonIllumination(date),
|
illumination: getMoonIllumination(date),
|
||||||
visibility: getMoonVisibilityEstimate(date, lat, lon, elevation),
|
visibility: getMoonVisibilityEstimate(date, lat, lon, elevation),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -748,34 +755,34 @@ export function getMoon(
|
||||||
* Full moon corrections differ from new moon; these are from Meeus Table 49.A.
|
* Full moon corrections differ from new moon; these are from Meeus Table 49.A.
|
||||||
*/
|
*/
|
||||||
function nearestFullMoon(jdTT: number): number {
|
function nearestFullMoon(jdTT: number): number {
|
||||||
const Y = 2000 + (jdTT - J2000) / 365.25
|
const Y = 2000 + (jdTT - J2000) / 365.25;
|
||||||
const kBase = Math.round((Y - 2000.0) * 12.3685)
|
const kBase = Math.round((Y - 2000.0) * 12.3685);
|
||||||
// Check the full moons on either side of the nearest new moon (k ± 0.5)
|
// Check the full moons on either side of the nearest new moon (k ± 0.5)
|
||||||
const k1 = kBase - 0.5
|
const k1 = kBase - 0.5;
|
||||||
const k2 = kBase + 0.5
|
const k2 = kBase + 0.5;
|
||||||
const jde1 = fullMoonJDE(k1)
|
const jde1 = fullMoonJDE(k1);
|
||||||
const jde2 = fullMoonJDE(k2)
|
const jde2 = fullMoonJDE(k2);
|
||||||
const d1 = Math.abs(jde1 - jdTT)
|
const d1 = Math.abs(jde1 - jdTT);
|
||||||
const d2 = Math.abs(jde2 - jdTT)
|
const d2 = Math.abs(jde2 - jdTT);
|
||||||
return d1 < d2 ? jde1 : jde2
|
return d1 < d2 ? jde1 : jde2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full moon JDE for a half-integer k (Meeus Ch. 49, Table 49.A). */
|
/** Full moon JDE for a half-integer k (Meeus Ch. 49, Table 49.A). */
|
||||||
function fullMoonJDE(k: number): number {
|
function fullMoonJDE(k: number): number {
|
||||||
const T = k / 1236.85
|
const T = k / 1236.85;
|
||||||
|
|
||||||
let JDE =
|
let JDE =
|
||||||
2451550.09766 +
|
2451550.09766 +
|
||||||
29.530588861 * k +
|
29.530588861 * k +
|
||||||
0.00015437 * T * T -
|
0.00015437 * T * T -
|
||||||
0.00000015 * T * T * T +
|
0.00000015 * T * T * T +
|
||||||
0.00000000073 * T * T * T * T
|
0.00000000073 * T * T * T * T;
|
||||||
|
|
||||||
const M = (2.5534 + 29.1053567 * k - 0.0000014 * T * T) * DEG2RAD
|
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 Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T * T) * DEG2RAD;
|
||||||
const Fc = (160.7108 + 390.67050284 * k - 0.0016118 * 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 Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T) * DEG2RAD;
|
||||||
const E = 1 - 0.002516 * T - 0.0000074 * T * T
|
const E = 1 - 0.002516 * T - 0.0000074 * T * T;
|
||||||
|
|
||||||
JDE +=
|
JDE +=
|
||||||
-0.40614 * Math.sin(Mp) +
|
-0.40614 * Math.sin(Mp) +
|
||||||
|
|
@ -802,9 +809,9 @@ function fullMoonJDE(k: number): number {
|
||||||
0.00003 * Math.sin(Mp - M + 2 * Fc) -
|
0.00003 * Math.sin(Mp - M + 2 * Fc) -
|
||||||
0.00002 * Math.sin(Mp - M - 2 * Fc) -
|
0.00002 * Math.sin(Mp - M - 2 * Fc) -
|
||||||
0.00002 * Math.sin(3 * Mp + M) +
|
0.00002 * Math.sin(3 * Mp + M) +
|
||||||
0.00002 * Math.sin(4 * Mp)
|
0.00002 * Math.sin(4 * Mp);
|
||||||
|
|
||||||
return JDE
|
return JDE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -812,12 +819,12 @@ function fullMoonJDE(k: number): number {
|
||||||
* Boundaries: new (<5°), crescent (5-85°), quarter (85-95°), gibbous (95-175°), full (>175°).
|
* Boundaries: new (<5°), crescent (5-85°), quarter (85-95°), gibbous (95-175°), full (>175°).
|
||||||
*/
|
*/
|
||||||
function elongationToPhase(elongationDeg: number, isWaxing: boolean): MoonPhaseName {
|
function elongationToPhase(elongationDeg: number, isWaxing: boolean): MoonPhaseName {
|
||||||
const e = elongationDeg
|
const e = elongationDeg;
|
||||||
if (e < 5) return 'new-moon'
|
if (e < 5) return "new-moon";
|
||||||
if (e > 175) return 'full-moon'
|
if (e > 175) return "full-moon";
|
||||||
if (e < 85) return isWaxing ? 'waxing-crescent' : 'waning-crescent'
|
if (e < 85) return isWaxing ? "waxing-crescent" : "waning-crescent";
|
||||||
if (e < 95) return isWaxing ? 'first-quarter' : 'last-quarter'
|
if (e < 95) return isWaxing ? "first-quarter" : "last-quarter";
|
||||||
return isWaxing ? 'waxing-gibbous' : 'waning-gibbous'
|
return isWaxing ? "waxing-gibbous" : "waning-gibbous";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -833,10 +840,10 @@ function elongationToPhase(elongationDeg: number, isWaxing: boolean): MoonPhaseN
|
||||||
export async function getSunMoonEvents(
|
export async function getSunMoonEvents(
|
||||||
date: Date,
|
date: Date,
|
||||||
observer: Observer,
|
observer: Observer,
|
||||||
options?: Pick<SightingOptions, 'kernels'>,
|
options?: Pick<SightingOptions, "kernels">,
|
||||||
): Promise<SunMoonEvents> {
|
): Promise<SunMoonEvents> {
|
||||||
validateDate(date, 'getSunMoonEvents')
|
validateDate(date, "getSunMoonEvents");
|
||||||
validateObserver(observer, 'getSunMoonEvents')
|
validateObserver(observer, "getSunMoonEvents");
|
||||||
const kernel = await resolveKernel(options?.kernels)
|
const kernel = await resolveKernel(options?.kernels);
|
||||||
return eventsGetSunMoonEvents(date, observer, kernel)
|
return eventsGetSunMoonEvents(date, observer, kernel);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,21 +16,21 @@
|
||||||
* New Crescent Moon. NAO Technical Note 69. HM Nautical Almanac Office.
|
* New Crescent Moon. NAO Technical Note 69. HM Nautical Almanac Office.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { StateVector, Vec3 } from '../types.js'
|
import type { StateVector, Vec3 } from "../types.js";
|
||||||
import type { SpkKernel } from '../spk/index.js'
|
import type { SpkKernel } from "../spk/index.js";
|
||||||
import { NAIF_IDS } from '../spk/index.js'
|
import { NAIF_IDS } from "../spk/index.js";
|
||||||
import { J2000, DAYS_PER_JULIAN_CENTURY } from '../time/index.js'
|
import { J2000, DAYS_PER_JULIAN_CENTURY } from "../time/index.js";
|
||||||
import { DEG2RAD, vdot, vnorm } from '../math/index.js'
|
import { DEG2RAD, vdot, vnorm } from "../math/index.js";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
const AU_KM = 149597870.7
|
const AU_KM = 149597870.7;
|
||||||
|
|
||||||
/** Mean radius of the Moon in km (IAU 2015 nominal value) */
|
/** Mean radius of the Moon in km (IAU 2015 nominal value) */
|
||||||
const MOON_RADIUS_KM = 1737.4
|
const MOON_RADIUS_KM = 1737.4;
|
||||||
|
|
||||||
/** Mean radius of the Sun in km */
|
/** Mean radius of the Sun in km */
|
||||||
const _SUN_RADIUS_KM = 696000.0
|
const _SUN_RADIUS_KM = 696000.0;
|
||||||
void _SUN_RADIUS_KM // reserved for future solar semi-diameter calculations
|
void _SUN_RADIUS_KM; // reserved for future solar semi-diameter calculations
|
||||||
|
|
||||||
// ─── Geocentric state ─────────────────────────────────────────────────────────
|
// ─── Geocentric state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ void _SUN_RADIUS_KM // reserved for future solar semi-diameter calculations
|
||||||
* @returns Moon state vector relative to Earth center, km and km/s, GCRS
|
* @returns Moon state vector relative to Earth center, km and km/s, GCRS
|
||||||
*/
|
*/
|
||||||
export function getMoonGeocentricState(kernel: SpkKernel, et: number): StateVector {
|
export function getMoonGeocentricState(kernel: SpkKernel, et: number): StateVector {
|
||||||
return kernel.getState(NAIF_IDS.MOON, NAIF_IDS.EARTH, et)
|
return kernel.getState(NAIF_IDS.MOON, NAIF_IDS.EARTH, et);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,7 +59,7 @@ export function getMoonGeocentricState(kernel: SpkKernel, et: number): StateVect
|
||||||
* @returns Sun state vector relative to Earth center, km and km/s, GCRS
|
* @returns Sun state vector relative to Earth center, km and km/s, GCRS
|
||||||
*/
|
*/
|
||||||
export function getSunGeocentricState(kernel: SpkKernel, et: number): StateVector {
|
export function getSunGeocentricState(kernel: SpkKernel, et: number): StateVector {
|
||||||
return kernel.getState(NAIF_IDS.SUN, NAIF_IDS.EARTH, et)
|
return kernel.getState(NAIF_IDS.SUN, NAIF_IDS.EARTH, et);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Moon illumination ────────────────────────────────────────────────────────
|
// ─── Moon illumination ────────────────────────────────────────────────────────
|
||||||
|
|
@ -85,12 +85,12 @@ export function computeIllumination(
|
||||||
moonGCRS: Vec3,
|
moonGCRS: Vec3,
|
||||||
sunGCRS: Vec3,
|
sunGCRS: Vec3,
|
||||||
): { illumination: number; phaseAngleDeg: number; elongationDeg: number; isWaxing: boolean } {
|
): { illumination: number; phaseAngleDeg: number; elongationDeg: number; isWaxing: boolean } {
|
||||||
const rMoon = vnorm(moonGCRS)
|
const rMoon = vnorm(moonGCRS);
|
||||||
const rSun = vnorm(sunGCRS)
|
const rSun = vnorm(sunGCRS);
|
||||||
|
|
||||||
// Elongation ψ: angle at Earth between Moon and Sun
|
// Elongation ψ: angle at Earth between Moon and Sun
|
||||||
const cosElong = vdot(moonGCRS, sunGCRS) / (rMoon * rSun)
|
const cosElong = vdot(moonGCRS, sunGCRS) / (rMoon * rSun);
|
||||||
const elongationDeg = Math.acos(Math.max(-1, Math.min(1, cosElong))) / DEG2RAD
|
const elongationDeg = Math.acos(Math.max(-1, Math.min(1, cosElong))) / DEG2RAD;
|
||||||
|
|
||||||
// Phase angle i: angle at Moon between Earth and Sun
|
// Phase angle i: angle at Moon between Earth and Sun
|
||||||
// Vector from Moon to Earth: -moonGCRS
|
// Vector from Moon to Earth: -moonGCRS
|
||||||
|
|
@ -99,21 +99,21 @@ export function computeIllumination(
|
||||||
sunGCRS[0] - moonGCRS[0],
|
sunGCRS[0] - moonGCRS[0],
|
||||||
sunGCRS[1] - moonGCRS[1],
|
sunGCRS[1] - moonGCRS[1],
|
||||||
sunGCRS[2] - moonGCRS[2],
|
sunGCRS[2] - moonGCRS[2],
|
||||||
]
|
];
|
||||||
const moonToEarth: Vec3 = [-moonGCRS[0], -moonGCRS[1], -moonGCRS[2]]
|
const moonToEarth: Vec3 = [-moonGCRS[0], -moonGCRS[1], -moonGCRS[2]];
|
||||||
const rMoonToSun = vnorm(moonToSun)
|
const rMoonToSun = vnorm(moonToSun);
|
||||||
|
|
||||||
const cosPhase = vdot(moonToEarth, moonToSun) / (rMoon * rMoonToSun)
|
const cosPhase = vdot(moonToEarth, moonToSun) / (rMoon * rMoonToSun);
|
||||||
const phaseAngleDeg = Math.acos(Math.max(-1, Math.min(1, cosPhase))) / DEG2RAD
|
const phaseAngleDeg = Math.acos(Math.max(-1, Math.min(1, cosPhase))) / DEG2RAD;
|
||||||
|
|
||||||
const illumination = (1 + Math.cos(phaseAngleDeg * DEG2RAD)) / 2
|
const illumination = (1 + Math.cos(phaseAngleDeg * DEG2RAD)) / 2;
|
||||||
|
|
||||||
// Moon is waxing when it is east of the Sun (elongation increasing).
|
// Moon is waxing when it is east of the Sun (elongation increasing).
|
||||||
// Cross product sunGCRS × moonGCRS z-component: positive when Moon is east of Sun.
|
// Cross product sunGCRS × moonGCRS z-component: positive when Moon is east of Sun.
|
||||||
const crossZ = sunGCRS[0] * moonGCRS[1] - sunGCRS[1] * moonGCRS[0]
|
const crossZ = sunGCRS[0] * moonGCRS[1] - sunGCRS[1] * moonGCRS[0];
|
||||||
const isWaxing = crossZ > 0
|
const isWaxing = crossZ > 0;
|
||||||
|
|
||||||
return { illumination, phaseAngleDeg, elongationDeg, isWaxing }
|
return { illumination, phaseAngleDeg, elongationDeg, isWaxing };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -140,17 +140,17 @@ export function computeCrescentWidth(
|
||||||
moonTopoVec: Vec3,
|
moonTopoVec: Vec3,
|
||||||
ARCL: number,
|
ARCL: number,
|
||||||
): { W: number; Wprime: number } {
|
): { W: number; Wprime: number } {
|
||||||
const rMoon = Math.sqrt(moonTopoVec[0] ** 2 + moonTopoVec[1] ** 2 + moonTopoVec[2] ** 2)
|
const rMoon = Math.sqrt(moonTopoVec[0] ** 2 + moonTopoVec[1] ** 2 + moonTopoVec[2] ** 2);
|
||||||
|
|
||||||
// Topocentric semi-diameter in arc minutes
|
// Topocentric semi-diameter in arc minutes
|
||||||
const SDmoon_arcmin = (Math.atan(MOON_RADIUS_KM / rMoon) / DEG2RAD) * 60
|
const SDmoon_arcmin = (Math.atan(MOON_RADIUS_KM / rMoon) / DEG2RAD) * 60;
|
||||||
|
|
||||||
// Crescent width in arc minutes
|
// Crescent width in arc minutes
|
||||||
const ARCL_rad = ARCL * DEG2RAD
|
const ARCL_rad = ARCL * DEG2RAD;
|
||||||
const W = SDmoon_arcmin * (1 - Math.cos(ARCL_rad))
|
const W = SDmoon_arcmin * (1 - Math.cos(ARCL_rad));
|
||||||
|
|
||||||
// Wprime ≡ W for both Odeh and Yallop in this formulation
|
// Wprime ≡ W for both Odeh and Yallop in this formulation
|
||||||
return { W, Wprime: W }
|
return { W, Wprime: W };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Approximate positions (no kernel) ────────────────────────────────────────
|
// ─── Approximate positions (no kernel) ────────────────────────────────────────
|
||||||
|
|
@ -169,48 +169,48 @@ export function computeCrescentWidth(
|
||||||
* @returns Geocentric GCRS positions in km (approximate, light-time not corrected)
|
* @returns Geocentric GCRS positions in km (approximate, light-time not corrected)
|
||||||
*/
|
*/
|
||||||
export function getMoonSunApproximate(jdTT: number): {
|
export function getMoonSunApproximate(jdTT: number): {
|
||||||
moonGCRS: Vec3
|
moonGCRS: Vec3;
|
||||||
sunGCRS: Vec3
|
sunGCRS: Vec3;
|
||||||
} {
|
} {
|
||||||
const T = (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY
|
const T = (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY;
|
||||||
|
|
||||||
// ── Sun (Meeus Ch. 25) ──────────────────────────────────────────────────────
|
// ── Sun (Meeus Ch. 25) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Mean longitude L0 and mean anomaly M (degrees)
|
// Mean longitude L0 and mean anomaly M (degrees)
|
||||||
const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T
|
const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T;
|
||||||
const M_sun = 357.52911 + 35999.05029 * T - 0.0001537 * T * T
|
const M_sun = 357.52911 + 35999.05029 * T - 0.0001537 * T * T;
|
||||||
const M_sun_rad = (M_sun % 360) * DEG2RAD
|
const M_sun_rad = (M_sun % 360) * DEG2RAD;
|
||||||
|
|
||||||
const e_sun = 0.016708634 - 0.000042037 * T - 0.0000001267 * T * T
|
const e_sun = 0.016708634 - 0.000042037 * T - 0.0000001267 * T * T;
|
||||||
|
|
||||||
// Equation of center (degrees)
|
// Equation of center (degrees)
|
||||||
const C =
|
const C =
|
||||||
(1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M_sun_rad) +
|
(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.019993 - 0.000101 * T) * Math.sin(2 * M_sun_rad) +
|
||||||
0.000289 * Math.sin(3 * M_sun_rad)
|
0.000289 * Math.sin(3 * M_sun_rad);
|
||||||
|
|
||||||
// True longitude and anomaly
|
// True longitude and anomaly
|
||||||
const sunLonDeg = L0 + C
|
const sunLonDeg = L0 + C;
|
||||||
const nu_rad = M_sun_rad + C * DEG2RAD
|
const nu_rad = M_sun_rad + C * DEG2RAD;
|
||||||
|
|
||||||
// Geometric distance in AU
|
// Geometric distance in AU
|
||||||
const R_AU = (1.000001018 * (1 - e_sun * e_sun)) / (1 + e_sun * Math.cos(nu_rad))
|
const R_AU = (1.000001018 * (1 - e_sun * e_sun)) / (1 + e_sun * Math.cos(nu_rad));
|
||||||
const R_km = R_AU * AU_KM
|
const R_km = R_AU * AU_KM;
|
||||||
|
|
||||||
// Nutation correction for apparent longitude (simplified)
|
// Nutation correction for apparent longitude (simplified)
|
||||||
const omega = (125.04 - 1934.136 * T) * DEG2RAD
|
const omega = (125.04 - 1934.136 * T) * DEG2RAD;
|
||||||
const sunLonApp = sunLonDeg - 0.00569 - 0.00478 * Math.sin(omega)
|
const sunLonApp = sunLonDeg - 0.00569 - 0.00478 * Math.sin(omega);
|
||||||
const sunLon_rad = sunLonApp * DEG2RAD
|
const sunLon_rad = sunLonApp * DEG2RAD;
|
||||||
|
|
||||||
// Mean obliquity of the ecliptic (IAU 1980 approximation, degrees)
|
// Mean obliquity of the ecliptic (IAU 1980 approximation, degrees)
|
||||||
const eps =
|
const eps =
|
||||||
(23.439291111 - 0.013004167 * T - 0.0000001638 * T * T + 0.0000005036 * T * T * T) * DEG2RAD
|
(23.439291111 - 0.013004167 * T - 0.0000001638 * T * T + 0.0000005036 * T * T * T) * DEG2RAD;
|
||||||
|
|
||||||
const sunGCRS: Vec3 = [
|
const sunGCRS: Vec3 = [
|
||||||
R_km * Math.cos(sunLon_rad),
|
R_km * Math.cos(sunLon_rad),
|
||||||
R_km * Math.sin(sunLon_rad) * Math.cos(eps),
|
R_km * Math.sin(sunLon_rad) * Math.cos(eps),
|
||||||
R_km * Math.sin(sunLon_rad) * Math.sin(eps),
|
R_km * Math.sin(sunLon_rad) * Math.sin(eps),
|
||||||
]
|
];
|
||||||
|
|
||||||
// ── Moon (Meeus Ch. 47) ─────────────────────────────────────────────────────
|
// ── Moon (Meeus Ch. 47) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -220,40 +220,40 @@ export function getMoonSunApproximate(jdTT: number): {
|
||||||
481267.88123421 * T -
|
481267.88123421 * T -
|
||||||
0.0015786 * T * T +
|
0.0015786 * T * T +
|
||||||
(T * T * T) / 538841 -
|
(T * T * T) / 538841 -
|
||||||
(T * T * T * T) / 65194000
|
(T * T * T * T) / 65194000;
|
||||||
const D =
|
const D =
|
||||||
297.8501921 +
|
297.8501921 +
|
||||||
445267.1114034 * T -
|
445267.1114034 * T -
|
||||||
0.0018819 * T * T +
|
0.0018819 * T * T +
|
||||||
(T * T * T) / 545868 -
|
(T * T * T) / 545868 -
|
||||||
(T * T * T * T) / 113065000
|
(T * T * T * T) / 113065000;
|
||||||
const M = 357.5291092 + 35999.0502909 * T - 0.0001536 * T * T + (T * T * T) / 24490000
|
const M = 357.5291092 + 35999.0502909 * T - 0.0001536 * T * T + (T * T * T) / 24490000;
|
||||||
const Mp =
|
const Mp =
|
||||||
134.9633964 +
|
134.9633964 +
|
||||||
477198.8675055 * T +
|
477198.8675055 * T +
|
||||||
0.0087414 * T * T +
|
0.0087414 * T * T +
|
||||||
(T * T * T) / 69699 -
|
(T * T * T) / 69699 -
|
||||||
(T * T * T * T) / 14712000
|
(T * T * T * T) / 14712000;
|
||||||
const F =
|
const F =
|
||||||
93.272095 +
|
93.272095 +
|
||||||
483202.0175233 * T -
|
483202.0175233 * T -
|
||||||
0.0036539 * T * T -
|
0.0036539 * T * T -
|
||||||
(T * T * T) / 3526000 +
|
(T * T * T) / 3526000 +
|
||||||
(T * T * T * T) / 863310000
|
(T * T * T * T) / 863310000;
|
||||||
|
|
||||||
// Additive terms for longitude/latitude
|
// Additive terms for longitude/latitude
|
||||||
const A1 = (119.75 + 131.849 * T) * DEG2RAD
|
const A1 = (119.75 + 131.849 * T) * DEG2RAD;
|
||||||
const A2 = (53.09 + 479264.29 * T) * DEG2RAD
|
const A2 = (53.09 + 479264.29 * T) * DEG2RAD;
|
||||||
const A3 = (313.45 + 481266.484 * T) * DEG2RAD
|
const A3 = (313.45 + 481266.484 * T) * DEG2RAD;
|
||||||
|
|
||||||
// Convert to radians for accumulation
|
// Convert to radians for accumulation
|
||||||
const D_r = (D % 360) * DEG2RAD
|
const D_r = (D % 360) * DEG2RAD;
|
||||||
const M_r = (M % 360) * DEG2RAD
|
const M_r = (M % 360) * DEG2RAD;
|
||||||
const Mp_r = (Mp % 360) * DEG2RAD
|
const Mp_r = (Mp % 360) * DEG2RAD;
|
||||||
const F_r = (F % 360) * DEG2RAD
|
const F_r = (F % 360) * DEG2RAD;
|
||||||
|
|
||||||
// Eccentricity correction for terms involving M (Earth's orbital eccentricity)
|
// Eccentricity correction for terms involving M (Earth's orbital eccentricity)
|
||||||
const E = 1 - 0.002516 * T - 0.0000074 * T * T
|
const E = 1 - 0.002516 * T - 0.0000074 * T * T;
|
||||||
|
|
||||||
// Longitude and distance accumulation — 30 main terms from Meeus Table 47.A
|
// Longitude and distance accumulation — 30 main terms from Meeus Table 47.A
|
||||||
// [d, m, mp, f, Σl (0.000001°), Σr (0.001 km)]
|
// [d, m, mp, f, Σl (0.000001°), Σr (0.001 km)]
|
||||||
|
|
@ -288,19 +288,19 @@ export function getMoonSunApproximate(jdTT: number): {
|
||||||
[0, 1, -2, 0, -2689, -7003],
|
[0, 1, -2, 0, -2689, -7003],
|
||||||
[2, 0, -1, 2, -2602, 0],
|
[2, 0, -1, 2, -2602, 0],
|
||||||
[2, -1, -2, 0, 2390, 10056],
|
[2, -1, -2, 0, 2390, 10056],
|
||||||
]
|
];
|
||||||
|
|
||||||
let Sl = 0,
|
let Sl = 0,
|
||||||
Sr = 0
|
Sr = 0;
|
||||||
for (const [d, m, mp, f, sl, sr] of LD) {
|
for (const [d, m, mp, f, sl, sr] of LD) {
|
||||||
const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r
|
const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r;
|
||||||
const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1
|
const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1;
|
||||||
Sl += sl * eCorr * Math.sin(arg)
|
Sl += sl * eCorr * Math.sin(arg);
|
||||||
Sr += sr * eCorr * Math.cos(arg)
|
Sr += sr * eCorr * Math.cos(arg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additive longitude corrections (Meeus §47)
|
// Additive longitude corrections (Meeus §47)
|
||||||
Sl += 3958 * Math.sin(A1) + 1962 * Math.sin((Lp - F) * DEG2RAD) + 318 * Math.sin(A2)
|
Sl += 3958 * Math.sin(A1) + 1962 * Math.sin((Lp - F) * DEG2RAD) + 318 * Math.sin(A2);
|
||||||
|
|
||||||
// Latitude accumulation — 20 main terms from Meeus Table 47.B
|
// Latitude accumulation — 20 main terms from Meeus Table 47.B
|
||||||
// [d, m, mp, f, Σb (0.000001°)]
|
// [d, m, mp, f, Σb (0.000001°)]
|
||||||
|
|
@ -325,13 +325,13 @@ export function getMoonSunApproximate(jdTT: number): {
|
||||||
[0, 1, -1, -1, -1870],
|
[0, 1, -1, -1, -1870],
|
||||||
[4, 0, -1, -1, 1828],
|
[4, 0, -1, -1, 1828],
|
||||||
[0, 1, 0, 1, -1794],
|
[0, 1, 0, 1, -1794],
|
||||||
]
|
];
|
||||||
|
|
||||||
let Sb = 0
|
let Sb = 0;
|
||||||
for (const [d, m, mp, f, sb] of FB) {
|
for (const [d, m, mp, f, sb] of FB) {
|
||||||
const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r
|
const arg = d * D_r + m * M_r + mp * Mp_r + f * F_r;
|
||||||
const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1
|
const eCorr = Math.abs(m) === 2 ? E * E : Math.abs(m) === 1 ? E : 1;
|
||||||
Sb += sb * eCorr * Math.sin(arg)
|
Sb += sb * eCorr * Math.sin(arg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additive latitude corrections
|
// Additive latitude corrections
|
||||||
|
|
@ -341,15 +341,15 @@ export function getMoonSunApproximate(jdTT: number): {
|
||||||
175 * Math.sin(A1 - F_r) +
|
175 * Math.sin(A1 - F_r) +
|
||||||
175 * Math.sin(A1 + F_r) +
|
175 * Math.sin(A1 + F_r) +
|
||||||
127 * Math.sin((Lp - Mp) * DEG2RAD) -
|
127 * Math.sin((Lp - Mp) * DEG2RAD) -
|
||||||
115 * Math.sin((Lp + Mp) * DEG2RAD)
|
115 * Math.sin((Lp + Mp) * DEG2RAD);
|
||||||
|
|
||||||
// Moon ecliptic coordinates
|
// Moon ecliptic coordinates
|
||||||
const moonLonDeg = Lp + Sl * 1e-6
|
const moonLonDeg = Lp + Sl * 1e-6;
|
||||||
const moonLatDeg = Sb * 1e-6
|
const moonLatDeg = Sb * 1e-6;
|
||||||
const moonDistKm = 385000.56 + Sr * 0.001
|
const moonDistKm = 385000.56 + Sr * 0.001;
|
||||||
|
|
||||||
const moonLon_rad = moonLonDeg * DEG2RAD
|
const moonLon_rad = moonLonDeg * DEG2RAD;
|
||||||
const moonLat_rad = moonLatDeg * DEG2RAD
|
const moonLat_rad = moonLatDeg * DEG2RAD;
|
||||||
|
|
||||||
// Ecliptic to equatorial (GCRS ≈ J2000 equatorial for this accuracy level)
|
// Ecliptic to equatorial (GCRS ≈ J2000 equatorial for this accuracy level)
|
||||||
const moonGCRS: Vec3 = [
|
const moonGCRS: Vec3 = [
|
||||||
|
|
@ -360,9 +360,9 @@ export function getMoonSunApproximate(jdTT: number): {
|
||||||
moonDistKm *
|
moonDistKm *
|
||||||
(Math.sin(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) +
|
(Math.sin(eps) * Math.cos(moonLat_rad) * Math.sin(moonLon_rad) +
|
||||||
Math.cos(eps) * Math.sin(moonLat_rad)),
|
Math.cos(eps) * Math.sin(moonLat_rad)),
|
||||||
]
|
];
|
||||||
|
|
||||||
return { moonGCRS, sunGCRS }
|
return { moonGCRS, sunGCRS };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -374,11 +374,11 @@ export function getMoonSunApproximate(jdTT: number): {
|
||||||
*/
|
*/
|
||||||
export function nearestNewMoon(jdTT: number): number {
|
export function nearestNewMoon(jdTT: number): number {
|
||||||
// Convert JD to approximate decimal year
|
// Convert JD to approximate decimal year
|
||||||
const Y = 2000.0 + (jdTT - J2000) / 365.25
|
const Y = 2000.0 + (jdTT - J2000) / 365.25;
|
||||||
|
|
||||||
// k = approximate lunation number (0 = Jan 6, 2000 new moon)
|
// k = approximate lunation number (0 = Jan 6, 2000 new moon)
|
||||||
const k = Math.round((Y - 2000.0) * 12.3685)
|
const k = Math.round((Y - 2000.0) * 12.3685);
|
||||||
const T = k / 1236.85
|
const T = k / 1236.85;
|
||||||
|
|
||||||
// JDE of mean new moon (Meeus Eq. 49.1)
|
// JDE of mean new moon (Meeus Eq. 49.1)
|
||||||
let JDE =
|
let JDE =
|
||||||
|
|
@ -386,16 +386,16 @@ export function nearestNewMoon(jdTT: number): number {
|
||||||
29.530588861 * k +
|
29.530588861 * k +
|
||||||
0.00015437 * T * T -
|
0.00015437 * T * T -
|
||||||
0.00000015 * T * T * T +
|
0.00000015 * T * T * T +
|
||||||
0.00000000073 * T * T * T * T
|
0.00000000073 * T * T * T * T;
|
||||||
|
|
||||||
// Fundamental arguments for the corrections (degrees → radians)
|
// Fundamental arguments for the corrections (degrees → radians)
|
||||||
const M = (2.5534 + 29.1053567 * k - 0.0000014 * T * T - 0.00000011 * T * T * T) * DEG2RAD
|
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 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 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
|
const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T * T + 0.00000215 * T * T * T) * DEG2RAD;
|
||||||
|
|
||||||
// Eccentricity of Earth's orbit
|
// Eccentricity of Earth's orbit
|
||||||
const E = 1 - 0.002516 * T - 0.0000074 * T * T
|
const E = 1 - 0.002516 * T - 0.0000074 * T * T;
|
||||||
|
|
||||||
// Corrections from Meeus Table 49.A (new moon)
|
// Corrections from Meeus Table 49.A (new moon)
|
||||||
JDE +=
|
JDE +=
|
||||||
|
|
@ -423,7 +423,7 @@ export function nearestNewMoon(jdTT: number): number {
|
||||||
0.00003 * Math.sin(Mp - M + 2 * Fc) -
|
0.00003 * Math.sin(Mp - M + 2 * Fc) -
|
||||||
0.00002 * Math.sin(Mp - M - 2 * Fc) -
|
0.00002 * Math.sin(Mp - M - 2 * Fc) -
|
||||||
0.00002 * Math.sin(3 * Mp + M) +
|
0.00002 * Math.sin(3 * Mp + M) +
|
||||||
0.00002 * Math.sin(4 * Mp)
|
0.00002 * Math.sin(4 * Mp);
|
||||||
|
|
||||||
return JDE
|
return JDE;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
178
src/cli/index.ts
178
src/cli/index.ts
|
|
@ -15,31 +15,31 @@ import {
|
||||||
verifyKernels,
|
verifyKernels,
|
||||||
getMoonSightingReport,
|
getMoonSightingReport,
|
||||||
getMoonPhase,
|
getMoonPhase,
|
||||||
} from '../api/index.js'
|
} from "../api/index.js";
|
||||||
|
|
||||||
const args = process.argv.slice(2)
|
const args = process.argv.slice(2);
|
||||||
const command = args[0]
|
const command = args[0];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'download-kernels':
|
case "download-kernels":
|
||||||
await cmdDownloadKernels()
|
await cmdDownloadKernels();
|
||||||
break
|
break;
|
||||||
case 'verify-kernels':
|
case "verify-kernels":
|
||||||
await cmdVerifyKernels()
|
await cmdVerifyKernels();
|
||||||
break
|
break;
|
||||||
case 'sighting':
|
case "sighting":
|
||||||
await cmdSighting(args.slice(1))
|
await cmdSighting(args.slice(1));
|
||||||
break
|
break;
|
||||||
case 'phase':
|
case "phase":
|
||||||
cmdPhase(args[1])
|
cmdPhase(args[1]);
|
||||||
break
|
break;
|
||||||
case 'benchmark':
|
case "benchmark":
|
||||||
await cmdBenchmark()
|
await cmdBenchmark();
|
||||||
break
|
break;
|
||||||
default:
|
default:
|
||||||
printHelp()
|
printHelp();
|
||||||
process.exit(command ? 1 : 0)
|
process.exit(command ? 1 : 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,130 +57,130 @@ Examples:
|
||||||
moon-sighting download-kernels
|
moon-sighting download-kernels
|
||||||
moon-sighting sighting 51.5 -0.1 2025-03-29
|
moon-sighting sighting 51.5 -0.1 2025-03-29
|
||||||
moon-sighting sighting 21.4 39.8 # Mecca
|
moon-sighting sighting 21.4 39.8 # Mecca
|
||||||
moon-sighting phase 2025-03-01`)
|
moon-sighting phase 2025-03-01`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cmdDownloadKernels() {
|
async function cmdDownloadKernels() {
|
||||||
await downloadKernels()
|
await downloadKernels();
|
||||||
console.log('Kernels ready.')
|
console.log("Kernels ready.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cmdVerifyKernels() {
|
async function cmdVerifyKernels() {
|
||||||
const result = await verifyKernels()
|
const result = await verifyKernels();
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
console.log('Kernels OK.')
|
console.log("Kernels OK.");
|
||||||
} else {
|
} else {
|
||||||
for (const err of result.errors) console.error(err)
|
for (const err of result.errors) console.error(err);
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cmdSighting(cmdArgs: string[]) {
|
async function cmdSighting(cmdArgs: string[]) {
|
||||||
const lat = parseFloat(cmdArgs[0] ?? '')
|
const lat = parseFloat(cmdArgs[0] ?? "");
|
||||||
const lon = parseFloat(cmdArgs[1] ?? '')
|
const lon = parseFloat(cmdArgs[1] ?? "");
|
||||||
const dateStr = cmdArgs[2] ?? new Date().toISOString().slice(0, 10)
|
const dateStr = cmdArgs[2] ?? new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
if (isNaN(lat) || isNaN(lon)) {
|
if (isNaN(lat) || isNaN(lon)) {
|
||||||
console.error('Usage: moon-sighting sighting <lat> <lon> [YYYY-MM-DD]')
|
console.error("Usage: moon-sighting sighting <lat> <lon> [YYYY-MM-DD]");
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(`${dateStr}T00:00:00Z`)
|
const date = new Date(`${dateStr}T00:00:00Z`);
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
console.error(`Invalid date: ${dateStr}. Use YYYY-MM-DD format.`)
|
console.error(`Invalid date: ${dateStr}. Use YYYY-MM-DD format.`);
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Computing sighting report for ${lat}°N ${lon}°E on ${dateStr}...`)
|
console.log(`Computing sighting report for ${lat}°N ${lon}°E on ${dateStr}...`);
|
||||||
await initKernels()
|
await initKernels();
|
||||||
|
|
||||||
const report = await getMoonSightingReport(date, { lat, lon, elevation: 0 })
|
const report = await getMoonSightingReport(date, { lat, lon, elevation: 0 });
|
||||||
|
|
||||||
console.log('')
|
console.log("");
|
||||||
console.log(`Sunset: ${fmtDate(report.sunsetUTC)}`)
|
console.log(`Sunset: ${fmtDate(report.sunsetUTC)}`);
|
||||||
console.log(`Moonset: ${fmtDate(report.moonsetUTC)}`)
|
console.log(`Moonset: ${fmtDate(report.moonsetUTC)}`);
|
||||||
console.log(`Best time: ${fmtDate(report.bestTimeUTC)}`)
|
console.log(`Best time: ${fmtDate(report.bestTimeUTC)}`);
|
||||||
console.log(
|
console.log(
|
||||||
`Lag: ${report.lagMinutes !== null ? Math.round(report.lagMinutes) + ' min' : 'N/A'}`,
|
`Lag: ${report.lagMinutes !== null ? Math.round(report.lagMinutes) + " min" : "N/A"}`,
|
||||||
)
|
);
|
||||||
console.log('')
|
console.log("");
|
||||||
|
|
||||||
if (report.geometry) {
|
if (report.geometry) {
|
||||||
const g = report.geometry
|
const g = report.geometry;
|
||||||
console.log(`ARCL: ${g.ARCL.toFixed(2)}°`)
|
console.log(`ARCL: ${g.ARCL.toFixed(2)}°`);
|
||||||
console.log(`ARCV: ${g.ARCV.toFixed(2)}°`)
|
console.log(`ARCV: ${g.ARCV.toFixed(2)}°`);
|
||||||
console.log(`DAZ: ${g.DAZ.toFixed(2)}°`)
|
console.log(`DAZ: ${g.DAZ.toFixed(2)}°`);
|
||||||
console.log(`W: ${g.W.toFixed(3)} arcmin`)
|
console.log(`W: ${g.W.toFixed(3)} arcmin`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (report.yallop && report.odeh) {
|
if (report.yallop && report.odeh) {
|
||||||
console.log('')
|
console.log("");
|
||||||
console.log(`Yallop: ${report.yallop.category} — ${report.yallop.description}`)
|
console.log(`Yallop: ${report.yallop.category} — ${report.yallop.description}`);
|
||||||
console.log(`Odeh: ${report.odeh.zone} — ${report.odeh.description}`)
|
console.log(`Odeh: ${report.odeh.zone} — ${report.odeh.description}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('')
|
console.log("");
|
||||||
console.log(report.guidance)
|
console.log(report.guidance);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmdPhase(dateStr?: string) {
|
function cmdPhase(dateStr?: string) {
|
||||||
const date = dateStr ? new Date(`${dateStr}T00:00:00Z`) : new Date()
|
const date = dateStr ? new Date(`${dateStr}T00:00:00Z`) : new Date();
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
console.error(`Invalid date: ${dateStr}. Use YYYY-MM-DD format.`)
|
console.error(`Invalid date: ${dateStr}. Use YYYY-MM-DD format.`);
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase = getMoonPhase(date)
|
const phase = getMoonPhase(date);
|
||||||
|
|
||||||
console.log(`Moon phase for ${date.toISOString().slice(0, 10)}:`)
|
console.log(`Moon phase for ${date.toISOString().slice(0, 10)}:`);
|
||||||
console.log(` Phase: ${phase.phase}`)
|
console.log(` Phase: ${phase.phase}`);
|
||||||
console.log(` Illumination: ${phase.illumination.toFixed(1)}%`)
|
console.log(` Illumination: ${phase.illumination.toFixed(1)}%`);
|
||||||
console.log(` Age: ${phase.age.toFixed(1)} hours`)
|
console.log(` Age: ${phase.age.toFixed(1)} hours`);
|
||||||
console.log(` Elongation: ${phase.elongationDeg.toFixed(1)}°`)
|
console.log(` Elongation: ${phase.elongationDeg.toFixed(1)}°`);
|
||||||
console.log(` Waxing: ${phase.isWaxing}`)
|
console.log(` Waxing: ${phase.isWaxing}`);
|
||||||
console.log(` Prev new: ${phase.prevNewMoon.toISOString().slice(0, 16)} UTC`)
|
console.log(` Prev new: ${phase.prevNewMoon.toISOString().slice(0, 16)} UTC`);
|
||||||
console.log(` Next new: ${phase.nextNewMoon.toISOString().slice(0, 16)} UTC`)
|
console.log(` Next new: ${phase.nextNewMoon.toISOString().slice(0, 16)} UTC`);
|
||||||
console.log(` Next full: ${phase.nextFullMoon.toISOString().slice(0, 16)} UTC`)
|
console.log(` Next full: ${phase.nextFullMoon.toISOString().slice(0, 16)} UTC`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cmdBenchmark() {
|
async function cmdBenchmark() {
|
||||||
console.log('moon-sighting benchmark\n')
|
console.log("moon-sighting benchmark\n");
|
||||||
|
|
||||||
// Benchmark 1: getMoonPhase (no kernel needed)
|
// Benchmark 1: getMoonPhase (no kernel needed)
|
||||||
const N_PHASE = 10000
|
const N_PHASE = 10000;
|
||||||
const phaseStart = performance.now()
|
const phaseStart = performance.now();
|
||||||
for (let i = 0; i < N_PHASE; i++) {
|
for (let i = 0; i < N_PHASE; i++) {
|
||||||
getMoonPhase(new Date(Date.UTC(2025, 2, 1 + (i % 28))))
|
getMoonPhase(new Date(Date.UTC(2025, 2, 1 + (i % 28))));
|
||||||
}
|
}
|
||||||
const phaseMs = performance.now() - phaseStart
|
const phaseMs = performance.now() - phaseStart;
|
||||||
console.log(
|
console.log(
|
||||||
`getMoonPhase × ${N_PHASE}: ${phaseMs.toFixed(1)} ms (${((phaseMs / N_PHASE) * 1000).toFixed(1)} µs/call)`,
|
`getMoonPhase × ${N_PHASE}: ${phaseMs.toFixed(1)} ms (${((phaseMs / N_PHASE) * 1000).toFixed(1)} µs/call)`,
|
||||||
)
|
);
|
||||||
|
|
||||||
// Benchmark 2: kernel load
|
// Benchmark 2: kernel load
|
||||||
const loadStart = performance.now()
|
const loadStart = performance.now();
|
||||||
await initKernels()
|
await initKernels();
|
||||||
const loadMs = performance.now() - loadStart
|
const loadMs = performance.now() - loadStart;
|
||||||
console.log(`initKernels (cold/cached): ${loadMs.toFixed(1)} ms`)
|
console.log(`initKernels (cold/cached): ${loadMs.toFixed(1)} ms`);
|
||||||
|
|
||||||
// Benchmark 3: single sighting report
|
// Benchmark 3: single sighting report
|
||||||
const observer = { lat: 51.5074, lon: -0.1278, elevation: 10 }
|
const observer = { lat: 51.5074, lon: -0.1278, elevation: 10 };
|
||||||
const reportStart = performance.now()
|
const reportStart = performance.now();
|
||||||
await getMoonSightingReport(new Date('2025-03-29T00:00:00Z'), observer)
|
await getMoonSightingReport(new Date("2025-03-29T00:00:00Z"), observer);
|
||||||
const reportMs = performance.now() - reportStart
|
const reportMs = performance.now() - reportStart;
|
||||||
console.log(`getMoonSightingReport (single): ${reportMs.toFixed(1)} ms`)
|
console.log(`getMoonSightingReport (single): ${reportMs.toFixed(1)} ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a nullable Date as a short UTC string. */
|
/** Format a nullable Date as a short UTC string. */
|
||||||
function fmtDate(d: Date | null): string {
|
function fmtDate(d: Date | null): string {
|
||||||
if (!d) return 'N/A'
|
if (!d) return "N/A";
|
||||||
return d
|
return d
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.replace('T', ' ')
|
.replace("T", " ")
|
||||||
.replace(/\.\d+Z$/, ' UTC')
|
.replace(/\.\d+Z$/, " UTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error(err instanceof Error ? err.message : String(err))
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,11 @@
|
||||||
* Reference: Yallop (1997) NAO TN 69 §2.4; Odeh (2006) §3
|
* Reference: Yallop (1997) NAO TN 69 §2.4; Odeh (2006) §3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Vec3, Observer, SunMoonEvents, TimeScales } from '../types.js'
|
import type { Vec3, Observer, SunMoonEvents, TimeScales } from "../types.js";
|
||||||
import type { SpkKernel } from '../spk/index.js'
|
import type { SpkKernel } from "../spk/index.js";
|
||||||
import { NAIF_IDS } from '../spk/index.js'
|
import { NAIF_IDS } from "../spk/index.js";
|
||||||
import { brentRoot, vdot, vnorm } from '../math/index.js'
|
import { brentRoot, vdot, vnorm } from "../math/index.js";
|
||||||
import { arcvMinimum } from '../visibility/index.js'
|
import { arcvMinimum } from "../visibility/index.js";
|
||||||
import {
|
import {
|
||||||
J2000,
|
J2000,
|
||||||
SECONDS_PER_DAY,
|
SECONDS_PER_DAY,
|
||||||
|
|
@ -33,14 +33,14 @@ import {
|
||||||
jdTTtoET,
|
jdTTtoET,
|
||||||
getDeltaAT,
|
getDeltaAT,
|
||||||
TT_MINUS_TAI,
|
TT_MINUS_TAI,
|
||||||
} from '../time/index.js'
|
} from "../time/index.js";
|
||||||
import {
|
import {
|
||||||
getMoonGeocentricState,
|
getMoonGeocentricState,
|
||||||
getSunGeocentricState,
|
getSunGeocentricState,
|
||||||
computeCrescentWidth,
|
computeCrescentWidth,
|
||||||
} from '../bodies/index.js'
|
} from "../bodies/index.js";
|
||||||
import { geodeticToECEF, computeAzAlt } from '../observer/index.js'
|
import { geodeticToECEF, computeAzAlt } from "../observer/index.js";
|
||||||
import { itrsToGcrs } from '../frames/index.js'
|
import { itrsToGcrs } from "../frames/index.js";
|
||||||
|
|
||||||
// ─── Altitude threshold constants ─────────────────────────────────────────────
|
// ─── Altitude threshold constants ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -49,14 +49,14 @@ import { itrsToGcrs } from '../frames/index.js'
|
||||||
* Accounts for: standard refraction at horizon (34') + solar semi-diameter (16')
|
* Accounts for: standard refraction at horizon (34') + solar semi-diameter (16')
|
||||||
* Total: −50' = −0.8333°
|
* Total: −50' = −0.8333°
|
||||||
*/
|
*/
|
||||||
export const SUN_ALTITUDE_THRESHOLD = -0.8333
|
export const SUN_ALTITUDE_THRESHOLD = -0.8333;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard threshold altitude for moonset/moonrise.
|
* Standard threshold altitude for moonset/moonrise.
|
||||||
* Accounts for: standard refraction at horizon (34') + lunar semi-diameter (~16')
|
* Accounts for: standard refraction at horizon (34') + lunar semi-diameter (~16')
|
||||||
* Note: Moon's SD varies with distance (14.7'–16.8'). Use 0.2725° as mean.
|
* Note: Moon's SD varies with distance (14.7'–16.8'). Use 0.2725° as mean.
|
||||||
*/
|
*/
|
||||||
export const MOON_ALTITUDE_THRESHOLD = -0.8333 // refined per actual distance in implementation
|
export const MOON_ALTITUDE_THRESHOLD = -0.8333; // refined per actual distance in implementation
|
||||||
|
|
||||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -66,14 +66,14 @@ export const MOON_ALTITUDE_THRESHOLD = -0.8333 // refined per actual distance in
|
||||||
*/
|
*/
|
||||||
function etToTS(et: number): TimeScales {
|
function etToTS(et: number): TimeScales {
|
||||||
// ET ≈ (jdTT - J2000) × 86400, so jdTT ≈ J2000 + et / 86400
|
// ET ≈ (jdTT - J2000) × 86400, so jdTT ≈ J2000 + et / 86400
|
||||||
const jdTT = J2000 + et / SECONDS_PER_DAY
|
const jdTT = J2000 + et / SECONDS_PER_DAY;
|
||||||
// Approximate UTC: TT - UTC ≈ deltaAT + 32.184s (typically ~69s currently)
|
// Approximate UTC: TT - UTC ≈ deltaAT + 32.184s (typically ~69s currently)
|
||||||
// Use a rough correction; getDeltaAT needs a UTC JD, so iterate once
|
// Use a rough correction; getDeltaAT needs a UTC JD, so iterate once
|
||||||
const jdUTC_est = jdTT - 70.0 / SECONDS_PER_DAY
|
const jdUTC_est = jdTT - 70.0 / SECONDS_PER_DAY;
|
||||||
const deltaAT = getDeltaAT(jdUTC_est)
|
const deltaAT = getDeltaAT(jdUTC_est);
|
||||||
const jdUTC = jdTT - (deltaAT + TT_MINUS_TAI) / SECONDS_PER_DAY
|
const jdUTC = jdTT - (deltaAT + TT_MINUS_TAI) / SECONDS_PER_DAY;
|
||||||
const utc = jdToDate(jdUTC)
|
const utc = jdToDate(jdUTC);
|
||||||
return computeTimeScales(utc)
|
return computeTimeScales(utc);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,9 +81,9 @@ function etToTS(et: number): TimeScales {
|
||||||
*/
|
*/
|
||||||
function bodyPositionAt(kernel: SpkKernel, naifId: number, et: number): Vec3 {
|
function bodyPositionAt(kernel: SpkKernel, naifId: number, et: number): Vec3 {
|
||||||
if (naifId === NAIF_IDS.SUN) {
|
if (naifId === NAIF_IDS.SUN) {
|
||||||
return getSunGeocentricState(kernel, et).position
|
return getSunGeocentricState(kernel, et).position;
|
||||||
}
|
}
|
||||||
return getMoonGeocentricState(kernel, et).position
|
return getMoonGeocentricState(kernel, et).position;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,10 +97,10 @@ function altitudeMinusThreshold(
|
||||||
et: number,
|
et: number,
|
||||||
threshold: number,
|
threshold: number,
|
||||||
): number {
|
): number {
|
||||||
const ts = etToTS(et)
|
const ts = etToTS(et);
|
||||||
const bodyGCRS = bodyPositionAt(kernel, naifId, et)
|
const bodyGCRS = bodyPositionAt(kernel, naifId, et);
|
||||||
const azAlt = computeAzAlt(bodyGCRS, observer, ts, true)
|
const azAlt = computeAzAlt(bodyGCRS, observer, ts, true);
|
||||||
return azAlt.altitude - threshold
|
return azAlt.altitude - threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Event finding ────────────────────────────────────────────────────────────
|
// ─── Event finding ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -131,36 +131,36 @@ export function findAltitudeCrossing(
|
||||||
threshold: number,
|
threshold: number,
|
||||||
rising: boolean,
|
rising: boolean,
|
||||||
): Date | null {
|
): Date | null {
|
||||||
void ts // ts not needed — etToTS computes from ET directly
|
void ts; // ts not needed — etToTS computes from ET directly
|
||||||
|
|
||||||
const f = (et: number) => altitudeMinusThreshold(kernel, naifId, observer, et, threshold)
|
const f = (et: number) => altitudeMinusThreshold(kernel, naifId, observer, et, threshold);
|
||||||
|
|
||||||
const STEP_S = 600 // 10-minute coarse sampling
|
const STEP_S = 600; // 10-minute coarse sampling
|
||||||
const nSteps = Math.ceil((endET - startET) / STEP_S)
|
const nSteps = Math.ceil((endET - startET) / STEP_S);
|
||||||
|
|
||||||
let prevET = startET
|
let prevET = startET;
|
||||||
let prevF = f(prevET)
|
let prevF = f(prevET);
|
||||||
|
|
||||||
for (let i = 1; i <= nSteps; i++) {
|
for (let i = 1; i <= nSteps; i++) {
|
||||||
const currET = Math.min(startET + i * STEP_S, endET)
|
const currET = Math.min(startET + i * STEP_S, endET);
|
||||||
const currF = f(currET)
|
const currF = f(currET);
|
||||||
|
|
||||||
const isRisingCross = rising && prevF < 0 && currF >= 0
|
const isRisingCross = rising && prevF < 0 && currF >= 0;
|
||||||
const isSettingCross = !rising && prevF >= 0 && currF < 0
|
const isSettingCross = !rising && prevF >= 0 && currF < 0;
|
||||||
|
|
||||||
if (isRisingCross || isSettingCross) {
|
if (isRisingCross || isSettingCross) {
|
||||||
const etCross = brentRoot(f, prevET, currET, 0.5) // 0.5s precision
|
const etCross = brentRoot(f, prevET, currET, 0.5); // 0.5s precision
|
||||||
if (etCross !== null) {
|
if (etCross !== null) {
|
||||||
const tsCross = etToTS(etCross)
|
const tsCross = etToTS(etCross);
|
||||||
return tsCross.utc
|
return tsCross.utc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prevET = currET
|
prevET = currET;
|
||||||
prevF = currF
|
prevF = currF;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -176,13 +176,13 @@ export function findAltitudeCrossing(
|
||||||
*/
|
*/
|
||||||
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
|
// Anchor search at UTC midnight of the input date
|
||||||
const midnight = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
const midnight = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||||
const jdMidnight = dateToJD(midnight)
|
const jdMidnight = dateToJD(midnight);
|
||||||
// Approximate ET at midnight
|
// Approximate ET at midnight
|
||||||
const etStart = jdTTtoET(jdMidnight + 70.0 / SECONDS_PER_DAY) // rough TT≈UTC+70s
|
const etStart = jdTTtoET(jdMidnight + 70.0 / SECONDS_PER_DAY); // rough TT≈UTC+70s
|
||||||
const etEnd = etStart + 28 * 3600 // 28-hour window
|
const etEnd = etStart + 28 * 3600; // 28-hour window
|
||||||
|
|
||||||
const ts0 = computeTimeScales(midnight)
|
const ts0 = computeTimeScales(midnight);
|
||||||
|
|
||||||
// Sun events
|
// Sun events
|
||||||
const sunriseUTC = findAltitudeCrossing(
|
const sunriseUTC = findAltitudeCrossing(
|
||||||
|
|
@ -194,7 +194,7 @@ export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKern
|
||||||
etEnd,
|
etEnd,
|
||||||
SUN_ALTITUDE_THRESHOLD,
|
SUN_ALTITUDE_THRESHOLD,
|
||||||
true,
|
true,
|
||||||
)
|
);
|
||||||
const sunsetUTC = findAltitudeCrossing(
|
const sunsetUTC = findAltitudeCrossing(
|
||||||
kernel,
|
kernel,
|
||||||
NAIF_IDS.SUN,
|
NAIF_IDS.SUN,
|
||||||
|
|
@ -204,7 +204,7 @@ export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKern
|
||||||
etEnd,
|
etEnd,
|
||||||
SUN_ALTITUDE_THRESHOLD,
|
SUN_ALTITUDE_THRESHOLD,
|
||||||
false,
|
false,
|
||||||
)
|
);
|
||||||
|
|
||||||
// Twilight events (Sun setting below -6°, -12°, -18°)
|
// Twilight events (Sun setting below -6°, -12°, -18°)
|
||||||
const civilTwilightEndUTC = findAltitudeCrossing(
|
const civilTwilightEndUTC = findAltitudeCrossing(
|
||||||
|
|
@ -216,7 +216,7 @@ export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKern
|
||||||
etEnd,
|
etEnd,
|
||||||
-6,
|
-6,
|
||||||
false,
|
false,
|
||||||
)
|
);
|
||||||
const nauticalTwilightEndUTC = findAltitudeCrossing(
|
const nauticalTwilightEndUTC = findAltitudeCrossing(
|
||||||
kernel,
|
kernel,
|
||||||
NAIF_IDS.SUN,
|
NAIF_IDS.SUN,
|
||||||
|
|
@ -226,7 +226,7 @@ export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKern
|
||||||
etEnd,
|
etEnd,
|
||||||
-12,
|
-12,
|
||||||
false,
|
false,
|
||||||
)
|
);
|
||||||
const astronomicalTwilightEndUTC = findAltitudeCrossing(
|
const astronomicalTwilightEndUTC = findAltitudeCrossing(
|
||||||
kernel,
|
kernel,
|
||||||
NAIF_IDS.SUN,
|
NAIF_IDS.SUN,
|
||||||
|
|
@ -236,7 +236,7 @@ export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKern
|
||||||
etEnd,
|
etEnd,
|
||||||
-18,
|
-18,
|
||||||
false,
|
false,
|
||||||
)
|
);
|
||||||
|
|
||||||
// Moon events
|
// Moon events
|
||||||
const moonriseUTC = findAltitudeCrossing(
|
const moonriseUTC = findAltitudeCrossing(
|
||||||
|
|
@ -248,7 +248,7 @@ export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKern
|
||||||
etEnd,
|
etEnd,
|
||||||
MOON_ALTITUDE_THRESHOLD,
|
MOON_ALTITUDE_THRESHOLD,
|
||||||
true,
|
true,
|
||||||
)
|
);
|
||||||
const moonsetUTC = findAltitudeCrossing(
|
const moonsetUTC = findAltitudeCrossing(
|
||||||
kernel,
|
kernel,
|
||||||
NAIF_IDS.MOON,
|
NAIF_IDS.MOON,
|
||||||
|
|
@ -258,7 +258,7 @@ export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKern
|
||||||
etEnd,
|
etEnd,
|
||||||
MOON_ALTITUDE_THRESHOLD,
|
MOON_ALTITUDE_THRESHOLD,
|
||||||
false,
|
false,
|
||||||
)
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sunriseUTC,
|
sunriseUTC,
|
||||||
|
|
@ -268,7 +268,7 @@ export function getSunMoonEvents(date: Date, observer: Observer, kernel: SpkKern
|
||||||
civilTwilightEndUTC,
|
civilTwilightEndUTC,
|
||||||
nauticalTwilightEndUTC,
|
nauticalTwilightEndUTC,
|
||||||
astronomicalTwilightEndUTC,
|
astronomicalTwilightEndUTC,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Best-time computation ────────────────────────────────────────────────────
|
// ─── Best-time computation ────────────────────────────────────────────────────
|
||||||
|
|
@ -290,16 +290,16 @@ export function bestTimeHeuristic(
|
||||||
sunsetUTC: Date,
|
sunsetUTC: Date,
|
||||||
moonsetUTC: Date,
|
moonsetUTC: Date,
|
||||||
): { bestTimeUTC: Date; lagMinutes: number } | null {
|
): { bestTimeUTC: Date; lagMinutes: number } | null {
|
||||||
const lagMs = moonsetUTC.getTime() - sunsetUTC.getTime()
|
const lagMs = moonsetUTC.getTime() - sunsetUTC.getTime();
|
||||||
if (lagMs <= 0) return null // Moon sets before Sun — no sighting possible
|
if (lagMs <= 0) return null; // Moon sets before Sun — no sighting possible
|
||||||
|
|
||||||
const lagMinutes = lagMs / 60000
|
const lagMinutes = lagMs / 60000;
|
||||||
const bestTimeMs = sunsetUTC.getTime() + (4 / 9) * lagMs
|
const bestTimeMs = sunsetUTC.getTime() + (4 / 9) * lagMs;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bestTimeUTC: new Date(bestTimeMs),
|
bestTimeUTC: new Date(bestTimeMs),
|
||||||
lagMinutes,
|
lagMinutes,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -323,59 +323,59 @@ export function bestTimeOptimized(
|
||||||
observer: Observer,
|
observer: Observer,
|
||||||
steps = 90,
|
steps = 90,
|
||||||
): { bestTimeUTC: Date; lagMinutes: number; maxV: number } | null {
|
): { bestTimeUTC: Date; lagMinutes: number; maxV: number } | null {
|
||||||
const lagMs = moonsetUTC.getTime() - sunsetUTC.getTime()
|
const lagMs = moonsetUTC.getTime() - sunsetUTC.getTime();
|
||||||
if (lagMs <= 0) return null
|
if (lagMs <= 0) return null;
|
||||||
|
|
||||||
const lagMinutes = lagMs / 60000
|
const lagMinutes = lagMs / 60000;
|
||||||
|
|
||||||
// Observer ITRS position (km) — fixed on Earth, computed once outside the loop
|
// Observer ITRS position (km) — fixed on Earth, computed once outside the loop
|
||||||
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation)
|
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation);
|
||||||
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000]
|
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000];
|
||||||
|
|
||||||
let bestTimeUTC = sunsetUTC
|
let bestTimeUTC = sunsetUTC;
|
||||||
let maxV = -Infinity
|
let maxV = -Infinity;
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
for (let i = 0; i <= steps; i++) {
|
||||||
const t = new Date(sunsetUTC.getTime() + (lagMs * i) / steps)
|
const t = new Date(sunsetUTC.getTime() + (lagMs * i) / steps);
|
||||||
const ts = computeTimeScales(t)
|
const ts = computeTimeScales(t);
|
||||||
const et = jdTTtoET(ts.jdTT)
|
const et = jdTTtoET(ts.jdTT);
|
||||||
|
|
||||||
const moonGCRS = getMoonGeocentricState(kernel, et).position
|
const moonGCRS = getMoonGeocentricState(kernel, et).position;
|
||||||
const sunGCRS = getSunGeocentricState(kernel, et).position
|
const sunGCRS = getSunGeocentricState(kernel, et).position;
|
||||||
|
|
||||||
// Convert observer ITRS → GCRS at this timestep (Earth rotation changes per step)
|
// Convert observer ITRS → GCRS at this timestep (Earth rotation changes per step)
|
||||||
const obsGCRS = itrsToGcrs(obsITRS, ts)
|
const obsGCRS = itrsToGcrs(obsITRS, ts);
|
||||||
|
|
||||||
// Airless altitudes via the full pipeline
|
// Airless altitudes via the full pipeline
|
||||||
const moonAzAlt = computeAzAlt(moonGCRS, observer, ts, true)
|
const moonAzAlt = computeAzAlt(moonGCRS, observer, ts, true);
|
||||||
const sunAzAlt = computeAzAlt(sunGCRS, observer, ts, true)
|
const sunAzAlt = computeAzAlt(sunGCRS, observer, ts, true);
|
||||||
|
|
||||||
const ARCV = moonAzAlt.altitude - sunAzAlt.altitude
|
const ARCV = moonAzAlt.altitude - sunAzAlt.altitude;
|
||||||
|
|
||||||
// Topocentric ARCL (Sun-Moon angular separation — all vectors in GCRS)
|
// Topocentric ARCL (Sun-Moon angular separation — all vectors in GCRS)
|
||||||
const moonTopo: Vec3 = [
|
const moonTopo: Vec3 = [
|
||||||
moonGCRS[0] - obsGCRS[0],
|
moonGCRS[0] - obsGCRS[0],
|
||||||
moonGCRS[1] - obsGCRS[1],
|
moonGCRS[1] - obsGCRS[1],
|
||||||
moonGCRS[2] - obsGCRS[2],
|
moonGCRS[2] - obsGCRS[2],
|
||||||
]
|
];
|
||||||
const sunTopo: Vec3 = [
|
const sunTopo: Vec3 = [
|
||||||
sunGCRS[0] - obsGCRS[0],
|
sunGCRS[0] - obsGCRS[0],
|
||||||
sunGCRS[1] - obsGCRS[1],
|
sunGCRS[1] - obsGCRS[1],
|
||||||
sunGCRS[2] - obsGCRS[2],
|
sunGCRS[2] - obsGCRS[2],
|
||||||
]
|
];
|
||||||
const cosARCL = vdot(moonTopo, sunTopo) / (vnorm(moonTopo) * vnorm(sunTopo))
|
const cosARCL = vdot(moonTopo, sunTopo) / (vnorm(moonTopo) * vnorm(sunTopo));
|
||||||
const ARCL = Math.acos(Math.max(-1, Math.min(1, cosARCL))) * (180 / Math.PI)
|
const ARCL = Math.acos(Math.max(-1, Math.min(1, cosARCL))) * (180 / Math.PI);
|
||||||
|
|
||||||
const { W } = computeCrescentWidth(moonTopo, ARCL)
|
const { W } = computeCrescentWidth(moonTopo, ARCL);
|
||||||
const V = ARCV - arcvMinimum(W)
|
const V = ARCV - arcvMinimum(W);
|
||||||
|
|
||||||
if (V > maxV) {
|
if (V > maxV) {
|
||||||
maxV = V
|
maxV = V;
|
||||||
bestTimeUTC = t
|
bestTimeUTC = t;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bestTimeUTC, lagMinutes, maxV }
|
return { bestTimeUTC, lagMinutes, maxV };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -387,6 +387,6 @@ export function bestTimeOptimized(
|
||||||
* @returns [start, end] UTC Date pair
|
* @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
|
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)];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,18 +21,18 @@
|
||||||
* Capitaine et al. (2003), Astronomy & Astrophysics 412, 567-586
|
* Capitaine et al. (2003), Astronomy & Astrophysics 412, 567-586
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Vec3, TimeScales } from '../types.js'
|
import type { Vec3, TimeScales } from "../types.js";
|
||||||
import type { Mat3 } from '../math/index.js'
|
import type { Mat3 } from "../math/index.js";
|
||||||
import { mvmul, mmmul, mtranspose, rotX, rotY, rotZ } from '../math/index.js'
|
import { mvmul, mmmul, mtranspose, rotX, rotY, rotZ } from "../math/index.js";
|
||||||
import { J2000, DAYS_PER_JULIAN_CENTURY } from '../time/index.js'
|
import { J2000, DAYS_PER_JULIAN_CENTURY } from "../time/index.js";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Arcseconds to radians */
|
/** Arcseconds to radians */
|
||||||
const ARCSEC_RAD = Math.PI / (180 * 3600)
|
const ARCSEC_RAD = Math.PI / (180 * 3600);
|
||||||
|
|
||||||
/** 0.1 microarcseconds to arcseconds (units of nutation coefficients) */
|
/** 0.1 microarcseconds to arcseconds (units of nutation coefficients) */
|
||||||
const UAS01_TO_ARCSEC = 1e-7
|
const UAS01_TO_ARCSEC = 1e-7;
|
||||||
|
|
||||||
// ─── IAU 2000B Nutation Series ────────────────────────────────────────────────
|
// ─── IAU 2000B Nutation Series ────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|
@ -207,7 +207,7 @@ const NUT_2000B: ReadonlyArray<
|
||||||
[0, 0, 4, -2, 4, 1341.0, 0.0, 0.0, -577.0, 0.0, 0.0],
|
[0, 0, 4, -2, 4, 1341.0, 0.0, 0.0, -577.0, 0.0, 0.0],
|
||||||
// 77
|
// 77
|
||||||
[0, 0, 2, -2, 4, -1316.0, 0.0, 0.0, 567.0, 0.0, 0.0],
|
[0, 0, 2, -2, 4, -1316.0, 0.0, 0.0, 567.0, 0.0, 0.0],
|
||||||
]
|
];
|
||||||
|
|
||||||
// ─── Fundamental arguments (Delaunay) ────────────────────────────────────────
|
// ─── Fundamental arguments (Delaunay) ────────────────────────────────────────
|
||||||
// Source: SOFA iauFal03, iauFalp03, iauFaf03, iauFad03, iauFaom03
|
// Source: SOFA iauFal03, iauFalp03, iauFaf03, iauFad03, iauFaom03
|
||||||
|
|
@ -215,43 +215,43 @@ const NUT_2000B: ReadonlyArray<
|
||||||
|
|
||||||
/** Reduce arcseconds to [0, 2π) radians */
|
/** Reduce arcseconds to [0, 2π) radians */
|
||||||
function arcsecToRad(arcsec: number): number {
|
function arcsecToRad(arcsec: number): number {
|
||||||
const r = (arcsec * ARCSEC_RAD) % (2 * Math.PI)
|
const r = (arcsec * ARCSEC_RAD) % (2 * Math.PI);
|
||||||
return r >= 0 ? r : r + 2 * Math.PI
|
return r >= 0 ? r : r + 2 * Math.PI;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mean anomaly of the Moon l (IAU 2003) */
|
/** Mean anomaly of the Moon l (IAU 2003) */
|
||||||
function fundamentalL(T: number): number {
|
function fundamentalL(T: number): number {
|
||||||
return arcsecToRad(
|
return arcsecToRad(
|
||||||
485868.249036 + T * (1717915923.2178 + T * (31.8792 + T * (0.051635 + T * -0.0002447))),
|
485868.249036 + T * (1717915923.2178 + T * (31.8792 + T * (0.051635 + T * -0.0002447))),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mean anomaly of the Sun l' (IAU 2003) */
|
/** Mean anomaly of the Sun l' (IAU 2003) */
|
||||||
function fundamentalLp(T: number): number {
|
function fundamentalLp(T: number): number {
|
||||||
return arcsecToRad(
|
return arcsecToRad(
|
||||||
1287104.793048 + T * (129596581.0481 + T * (-0.5532 + T * (0.000136 + T * -0.00001149))),
|
1287104.793048 + T * (129596581.0481 + T * (-0.5532 + T * (0.000136 + T * -0.00001149))),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moon's argument of latitude F = L - Ω (IAU 2003) */
|
/** Moon's argument of latitude F = L - Ω (IAU 2003) */
|
||||||
function fundamentalF(T: number): number {
|
function fundamentalF(T: number): number {
|
||||||
return arcsecToRad(
|
return arcsecToRad(
|
||||||
335779.526232 + T * (1739527262.8478 + T * (-12.7512 + T * (-0.001037 + T * 0.00000417))),
|
335779.526232 + T * (1739527262.8478 + T * (-12.7512 + T * (-0.001037 + T * 0.00000417))),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mean elongation of the Moon D (IAU 2003) */
|
/** Mean elongation of the Moon D (IAU 2003) */
|
||||||
function fundamentalD(T: number): number {
|
function fundamentalD(T: number): number {
|
||||||
return arcsecToRad(
|
return arcsecToRad(
|
||||||
1072260.703692 + T * (1602961601.209 + T * (-6.3706 + T * (0.006593 + T * -0.00003169))),
|
1072260.703692 + T * (1602961601.209 + T * (-6.3706 + T * (0.006593 + T * -0.00003169))),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Longitude of Moon's ascending node Ω (IAU 2003) */
|
/** Longitude of Moon's ascending node Ω (IAU 2003) */
|
||||||
function fundamentalOm(T: number): number {
|
function fundamentalOm(T: number): number {
|
||||||
return arcsecToRad(
|
return arcsecToRad(
|
||||||
450160.398036 + T * (-6962890.5431 + T * (7.4722 + T * (0.007702 + T * -0.00005939))),
|
450160.398036 + T * (-6962890.5431 + T * (7.4722 + T * (0.007702 + T * -0.00005939))),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CIP coordinates ─────────────────────────────────────────────────────────
|
// ─── CIP coordinates ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -273,29 +273,29 @@ function fundamentalOm(T: number): number {
|
||||||
* @returns { X, Y, s } in radians
|
* @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
|
const T = (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY;
|
||||||
|
|
||||||
// Delaunay fundamental arguments
|
// Delaunay fundamental arguments
|
||||||
const l = fundamentalL(T)
|
const l = fundamentalL(T);
|
||||||
const lp = fundamentalLp(T)
|
const lp = fundamentalLp(T);
|
||||||
const F = fundamentalF(T)
|
const F = fundamentalF(T);
|
||||||
const D = fundamentalD(T)
|
const D = fundamentalD(T);
|
||||||
const Om = fundamentalOm(T)
|
const Om = fundamentalOm(T);
|
||||||
|
|
||||||
// Accumulate nutation in longitude (dpsi) and obliquity (deps) — units: 0.1 uas
|
// Accumulate nutation in longitude (dpsi) and obliquity (deps) — units: 0.1 uas
|
||||||
let dpsi = 0.0
|
let dpsi = 0.0;
|
||||||
let deps = 0.0
|
let deps = 0.0;
|
||||||
for (const [nl, nlp, nF, nD, nOm, ps, pst, pc, ec, ect, es] of NUT_2000B) {
|
for (const [nl, nlp, nF, nD, nOm, ps, pst, pc, ec, ect, es] of NUT_2000B) {
|
||||||
const arg = nl * l + nlp * lp + nF * F + nD * D + nOm * Om
|
const arg = nl * l + nlp * lp + nF * F + nD * D + nOm * Om;
|
||||||
const sinA = Math.sin(arg)
|
const sinA = Math.sin(arg);
|
||||||
const cosA = Math.cos(arg)
|
const cosA = Math.cos(arg);
|
||||||
dpsi += (ps + pst * T) * sinA + pc * cosA
|
dpsi += (ps + pst * T) * sinA + pc * cosA;
|
||||||
deps += (ec + ect * T) * cosA + es * sinA
|
deps += (ec + ect * T) * cosA + es * sinA;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert 0.1 uas → arcseconds → radians
|
// Convert 0.1 uas → arcseconds → radians
|
||||||
const dpsiRad = dpsi * UAS01_TO_ARCSEC * ARCSEC_RAD
|
const dpsiRad = dpsi * UAS01_TO_ARCSEC * ARCSEC_RAD;
|
||||||
const depsRad = deps * UAS01_TO_ARCSEC * ARCSEC_RAD
|
const depsRad = deps * UAS01_TO_ARCSEC * ARCSEC_RAD;
|
||||||
|
|
||||||
// Mean obliquity eps0 (IAU 2006, arcseconds → radians)
|
// Mean obliquity eps0 (IAU 2006, arcseconds → radians)
|
||||||
// Reference: IERS Conventions (2010) Table 5.1
|
// Reference: IERS Conventions (2010) Table 5.1
|
||||||
|
|
@ -304,30 +304,30 @@ export function computeCIPXYs(jdTT: number): { X: number; Y: number; s: number }
|
||||||
T *
|
T *
|
||||||
(-46.836769 +
|
(-46.836769 +
|
||||||
T * (-0.0001831 + T * (0.0020034 + T * (-0.000000576 + T * -0.0000000434))))) *
|
T * (-0.0001831 + T * (0.0020034 + T * (-0.000000576 + T * -0.0000000434))))) *
|
||||||
ARCSEC_RAD
|
ARCSEC_RAD;
|
||||||
|
|
||||||
// IAU 2006 precession polynomial for X (arcseconds)
|
// IAU 2006 precession polynomial for X (arcseconds)
|
||||||
// Reference: IERS Conventions (2010) Table 5.2a, polynomial s_X
|
// Reference: IERS Conventions (2010) Table 5.2a, polynomial s_X
|
||||||
const Xarcsec =
|
const Xarcsec =
|
||||||
-0.016617 +
|
-0.016617 +
|
||||||
T * (2004.191898 + T * (-0.4297829 + T * (-0.19861834 + T * (0.000007578 + T * 0.0000059285))))
|
T * (2004.191898 + T * (-0.4297829 + T * (-0.19861834 + T * (0.000007578 + T * 0.0000059285))));
|
||||||
|
|
||||||
// IAU 2006 precession polynomial for Y (arcseconds)
|
// IAU 2006 precession polynomial for Y (arcseconds)
|
||||||
// Reference: IERS Conventions (2010) Table 5.2a, polynomial s_Y
|
// Reference: IERS Conventions (2010) Table 5.2a, polynomial s_Y
|
||||||
const Yarcsec =
|
const Yarcsec =
|
||||||
-0.006951 +
|
-0.006951 +
|
||||||
T * (-0.025896 + T * (-22.4072747 + T * (0.00190059 + T * (0.001112526 + T * 0.0000001358))))
|
T * (-0.025896 + T * (-22.4072747 + T * (0.00190059 + T * (0.001112526 + T * 0.0000001358))));
|
||||||
|
|
||||||
// CIP X, Y: precession polynomial + first-order nutation correction
|
// CIP X, Y: precession polynomial + first-order nutation correction
|
||||||
const X = Xarcsec * ARCSEC_RAD + dpsiRad * Math.sin(eps0)
|
const X = Xarcsec * ARCSEC_RAD + dpsiRad * Math.sin(eps0);
|
||||||
const Y = Yarcsec * ARCSEC_RAD - depsRad
|
const Y = Yarcsec * ARCSEC_RAD - depsRad;
|
||||||
|
|
||||||
// CIO locator s ≈ -X·Y/2 + small polynomial (IERS Conventions 2010 Eq. 5.9)
|
// CIO locator s ≈ -X·Y/2 + small polynomial (IERS Conventions 2010 Eq. 5.9)
|
||||||
// Polynomial term: s_poly ≈ -0.041775"·T (arcseconds)
|
// Polynomial term: s_poly ≈ -0.041775"·T (arcseconds)
|
||||||
const sPoly = -0.041775 * T * ARCSEC_RAD
|
const sPoly = -0.041775 * T * ARCSEC_RAD;
|
||||||
const s = (-X * Y) / 2 + sPoly
|
const s = (-X * Y) / 2 + sPoly;
|
||||||
|
|
||||||
return { X, Y, s }
|
return { X, Y, s };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Earth Rotation Angle ────────────────────────────────────────────────────
|
// ─── Earth Rotation Angle ────────────────────────────────────────────────────
|
||||||
|
|
@ -345,9 +345,9 @@ export function computeCIPXYs(jdTT: number): { X: number; Y: number; s: number }
|
||||||
* Reference: IAU 2000 Resolution B1.8; IERS Conventions (2010) §5.4.4
|
* Reference: IAU 2000 Resolution B1.8; IERS Conventions (2010) §5.4.4
|
||||||
*/
|
*/
|
||||||
export function computeERA(jdUT1: number): number {
|
export function computeERA(jdUT1: number): number {
|
||||||
const Du = jdUT1 - 2451545.0
|
const Du = jdUT1 - 2451545.0;
|
||||||
const era = 2 * Math.PI * (0.779057273264 + 1.0027378119113546 * Du)
|
const era = 2 * Math.PI * (0.779057273264 + 1.0027378119113546 * Du);
|
||||||
return ((era % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
|
return ((era % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Frame rotation matrices ──────────────────────────────────────────────────
|
// ─── Frame rotation matrices ──────────────────────────────────────────────────
|
||||||
|
|
@ -363,10 +363,10 @@ export function computeERA(jdUT1: number): number {
|
||||||
* Reference: SOFA iauC2ixys; IERS Conventions (2010) Eq. 5.7
|
* Reference: SOFA iauC2ixys; IERS Conventions (2010) Eq. 5.7
|
||||||
*/
|
*/
|
||||||
export function celestialMotionMatrix(X: number, Y: number, s: number): Mat3 {
|
export function celestialMotionMatrix(X: number, Y: number, s: number): Mat3 {
|
||||||
const r2 = X * X + Y * Y
|
const r2 = X * X + Y * Y;
|
||||||
const e = r2 > 0 ? Math.atan2(Y, X) : 0
|
const e = r2 > 0 ? Math.atan2(Y, X) : 0;
|
||||||
const d = Math.asin(Math.sqrt(r2))
|
const d = Math.asin(Math.sqrt(r2));
|
||||||
return mmmul(rotZ(-(e + s)), mmmul(rotY(d), rotZ(e)))
|
return mmmul(rotZ(-(e + s)), mmmul(rotY(d), rotZ(e)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -374,7 +374,7 @@ export function celestialMotionMatrix(X: number, Y: number, s: number): Mat3 {
|
||||||
* Simple rotation about the CIP pole (z-axis) by ERA.
|
* Simple rotation about the CIP pole (z-axis) by ERA.
|
||||||
*/
|
*/
|
||||||
export function earthRotationMatrix(era: number): Mat3 {
|
export function earthRotationMatrix(era: number): Mat3 {
|
||||||
return rotZ(era)
|
return rotZ(era);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -391,7 +391,7 @@ export function earthRotationMatrix(era: number): Mat3 {
|
||||||
* @param yp - Pole y-offset in radians (from IERS Bulletin A)
|
* @param yp - Pole y-offset in radians (from IERS Bulletin A)
|
||||||
*/
|
*/
|
||||||
export function polarMotionMatrix(xp: number, yp: number): Mat3 {
|
export function polarMotionMatrix(xp: number, yp: number): Mat3 {
|
||||||
return mmmul(rotY(xp), rotX(-yp))
|
return mmmul(rotY(xp), rotX(-yp));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Full transformation ──────────────────────────────────────────────────────
|
// ─── Full transformation ──────────────────────────────────────────────────────
|
||||||
|
|
@ -408,14 +408,14 @@ export function polarMotionMatrix(xp: number, yp: number): Mat3 {
|
||||||
* @returns Vector in ITRS frame (km)
|
* @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 { X, Y, s } = computeCIPXYs(ts.jdTT);
|
||||||
const Q = celestialMotionMatrix(X, Y, s)
|
const Q = celestialMotionMatrix(X, Y, s);
|
||||||
const era = computeERA(ts.jdUT1)
|
const era = computeERA(ts.jdUT1);
|
||||||
const R = earthRotationMatrix(era)
|
const R = earthRotationMatrix(era);
|
||||||
const W = polarMotionMatrix(xp, yp)
|
const W = polarMotionMatrix(xp, yp);
|
||||||
// Apply Q first (GCRS→CIRS), then R (CIRS→TIRS), then W (TIRS→ITRS)
|
// Apply Q first (GCRS→CIRS), then R (CIRS→TIRS), then W (TIRS→ITRS)
|
||||||
const combined = mmmul(W, mmmul(R, Q))
|
const combined = mmmul(W, mmmul(R, Q));
|
||||||
return mvmul(combined, gcrsVec)
|
return mvmul(combined, gcrsVec);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -430,12 +430,12 @@ export function gcrsToItrs(gcrsVec: Vec3, ts: TimeScales, xp = 0, yp = 0): Vec3
|
||||||
* @returns Vector in GCRS frame (km)
|
* @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 { X, Y, s } = computeCIPXYs(ts.jdTT);
|
||||||
const Q = celestialMotionMatrix(X, Y, s)
|
const Q = celestialMotionMatrix(X, Y, s);
|
||||||
const era = computeERA(ts.jdUT1)
|
const era = computeERA(ts.jdUT1);
|
||||||
const R = earthRotationMatrix(era)
|
const R = earthRotationMatrix(era);
|
||||||
const W = polarMotionMatrix(xp, yp)
|
const W = polarMotionMatrix(xp, yp);
|
||||||
// [GCRS] = Qᵀ · Rᵀ · Wᵀ · [ITRS]
|
// [GCRS] = Qᵀ · Rᵀ · Wᵀ · [ITRS]
|
||||||
const combined = mmmul(mtranspose(Q), mmmul(mtranspose(R), mtranspose(W)))
|
const combined = mmmul(mtranspose(Q), mmmul(mtranspose(R), mtranspose(W)));
|
||||||
return mvmul(combined, itrsVec)
|
return mvmul(combined, itrsVec);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export {
|
||||||
initKernels,
|
initKernels,
|
||||||
downloadKernels,
|
downloadKernels,
|
||||||
verifyKernels,
|
verifyKernels,
|
||||||
} from './api/index.js'
|
} from "./api/index.js";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ export type {
|
||||||
// Ephemeris internals (for advanced use)
|
// Ephemeris internals (for advanced use)
|
||||||
SpkSegment,
|
SpkSegment,
|
||||||
ChebRecord,
|
ChebRecord,
|
||||||
} from './types.js'
|
} from "./types.js";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -71,4 +71,4 @@ export {
|
||||||
ODEH_THRESHOLDS,
|
ODEH_THRESHOLDS,
|
||||||
ODEH_DESCRIPTIONS,
|
ODEH_DESCRIPTIONS,
|
||||||
WGS84,
|
WGS84,
|
||||||
} from './types.js'
|
} from "./types.js";
|
||||||
|
|
|
||||||
|
|
@ -5,57 +5,57 @@
|
||||||
* Uses Float64Array for coefficient storage to match JS engine optimization paths.
|
* Uses Float64Array for coefficient storage to match JS engine optimization paths.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Vec3 } from '../types.js'
|
import type { Vec3 } from "../types.js";
|
||||||
|
|
||||||
// ─── Vector operations ────────────────────────────────────────────────────────
|
// ─── Vector operations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Add two 3-vectors */
|
/** Add two 3-vectors */
|
||||||
export function vadd(a: Vec3, b: Vec3): Vec3 {
|
export function vadd(a: Vec3, b: Vec3): Vec3 {
|
||||||
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subtract b from a */
|
/** Subtract b from a */
|
||||||
export function vsub(a: Vec3, b: Vec3): Vec3 {
|
export function vsub(a: Vec3, b: Vec3): Vec3 {
|
||||||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scale a 3-vector */
|
/** Scale a 3-vector */
|
||||||
export function vscale(a: Vec3, s: number): Vec3 {
|
export function vscale(a: Vec3, s: number): Vec3 {
|
||||||
return [a[0] * s, a[1] * s, a[2] * s]
|
return [a[0] * s, a[1] * s, a[2] * s];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dot product */
|
/** Dot product */
|
||||||
export function vdot(a: Vec3, b: Vec3): number {
|
export function vdot(a: Vec3, b: Vec3): number {
|
||||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Euclidean norm */
|
/** Euclidean norm */
|
||||||
export function vnorm(a: Vec3): number {
|
export function vnorm(a: Vec3): number {
|
||||||
return Math.sqrt(vdot(a, a))
|
return Math.sqrt(vdot(a, a));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cross product */
|
/** Cross product */
|
||||||
export function vcross(a: Vec3, b: Vec3): Vec3 {
|
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) */
|
/** Unit vector (normalized) */
|
||||||
export function vunit(a: Vec3): Vec3 {
|
export function vunit(a: Vec3): Vec3 {
|
||||||
const n = vnorm(a)
|
const n = vnorm(a);
|
||||||
if (n === 0) throw new RangeError('Cannot normalize a zero vector')
|
if (n === 0) throw new RangeError("Cannot normalize a zero vector");
|
||||||
return vscale(a, 1 / n)
|
return vscale(a, 1 / n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Angular separation between two direction vectors in radians */
|
/** Angular separation between two direction vectors in radians */
|
||||||
export function angularSep(a: Vec3, b: Vec3): number {
|
export function angularSep(a: Vec3, b: Vec3): number {
|
||||||
const cosAngle = Math.max(-1, Math.min(1, vdot(vunit(a), vunit(b))))
|
const cosAngle = Math.max(-1, Math.min(1, vdot(vunit(a), vunit(b))));
|
||||||
return Math.acos(cosAngle)
|
return Math.acos(cosAngle);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 3×3 matrix operations ────────────────────────────────────────────────────
|
// ─── 3×3 matrix operations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** 3×3 matrix stored row-major as a 9-element tuple */
|
/** 3×3 matrix stored row-major as a 9-element tuple */
|
||||||
export type Mat3 = [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 */
|
/** Multiply 3×3 matrix by 3-vector */
|
||||||
export function mvmul(m: Mat3, v: Vec3): Vec3 {
|
export function mvmul(m: Mat3, v: Vec3): Vec3 {
|
||||||
|
|
@ -63,7 +63,7 @@ export function mvmul(m: Mat3, v: Vec3): Vec3 {
|
||||||
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
|
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
|
||||||
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
|
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
|
||||||
m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
|
m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Multiply two 3×3 matrices */
|
/** Multiply two 3×3 matrices */
|
||||||
|
|
@ -78,12 +78,12 @@ export function mmmul(a: Mat3, b: Mat3): Mat3 {
|
||||||
a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
|
a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
|
||||||
a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
|
a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
|
||||||
a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
|
a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Transpose a 3×3 matrix */
|
/** Transpose a 3×3 matrix */
|
||||||
export function mtranspose(m: Mat3): Mat3 {
|
export function mtranspose(m: Mat3): Mat3 {
|
||||||
return [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]]
|
return [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -91,27 +91,27 @@ export function mtranspose(m: Mat3): Mat3 {
|
||||||
* Follows right-hand rule.
|
* Follows right-hand rule.
|
||||||
*/
|
*/
|
||||||
export function rotX(theta: number): Mat3 {
|
export function rotX(theta: number): Mat3 {
|
||||||
const c = Math.cos(theta)
|
const c = Math.cos(theta);
|
||||||
const s = Math.sin(theta)
|
const s = Math.sin(theta);
|
||||||
return [1, 0, 0, 0, c, s, 0, -s, c]
|
return [1, 0, 0, 0, c, s, 0, -s, c];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotation matrix around the Y axis by angle θ (radians).
|
* Rotation matrix around the Y axis by angle θ (radians).
|
||||||
*/
|
*/
|
||||||
export function rotY(theta: number): Mat3 {
|
export function rotY(theta: number): Mat3 {
|
||||||
const c = Math.cos(theta)
|
const c = Math.cos(theta);
|
||||||
const s = Math.sin(theta)
|
const s = Math.sin(theta);
|
||||||
return [c, 0, -s, 0, 1, 0, s, 0, c]
|
return [c, 0, -s, 0, 1, 0, s, 0, c];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotation matrix around the Z axis by angle θ (radians).
|
* Rotation matrix around the Z axis by angle θ (radians).
|
||||||
*/
|
*/
|
||||||
export function rotZ(theta: number): Mat3 {
|
export function rotZ(theta: number): Mat3 {
|
||||||
const c = Math.cos(theta)
|
const c = Math.cos(theta);
|
||||||
const s = Math.sin(theta)
|
const s = Math.sin(theta);
|
||||||
return [c, s, 0, -s, c, 0, 0, 0, 1]
|
return [c, s, 0, -s, c, 0, 0, 0, 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Chebyshev polynomial evaluation ─────────────────────────────────────────
|
// ─── Chebyshev polynomial evaluation ─────────────────────────────────────────
|
||||||
|
|
@ -128,22 +128,22 @@ export function rotZ(theta: number): Mat3 {
|
||||||
* @returns Polynomial value at x
|
* @returns Polynomial value at x
|
||||||
*/
|
*/
|
||||||
export function chebyshevEval(coeffs: Float64Array, x: number): number {
|
export function chebyshevEval(coeffs: Float64Array, x: number): number {
|
||||||
const n = coeffs.length
|
const n = coeffs.length;
|
||||||
if (n === 0) return 0
|
if (n === 0) return 0;
|
||||||
if (n === 1) return coeffs[0]! // Float64Array length checked above; index 0 is valid
|
if (n === 1) return coeffs[0]!; // Float64Array length checked above; index 0 is valid
|
||||||
|
|
||||||
// Double-x for Clenshaw efficiency
|
// Double-x for Clenshaw efficiency
|
||||||
const x2 = 2 * x
|
const x2 = 2 * x;
|
||||||
let b2 = 0
|
let b2 = 0;
|
||||||
let b1 = 0
|
let b1 = 0;
|
||||||
|
|
||||||
for (let k = n - 1; k >= 1; k--) {
|
for (let k = n - 1; k >= 1; k--) {
|
||||||
const b0 = coeffs[k]! + x2 * b1 - b2 // k in [1, n-1]; all valid indices
|
const b0 = coeffs[k]! + x2 * b1 - b2; // k in [1, n-1]; all valid indices
|
||||||
b2 = b1
|
b2 = b1;
|
||||||
b1 = b0
|
b1 = b0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return coeffs[0]! + x * b1 - b2 // index 0 valid; n >= 2 at this point
|
return coeffs[0]! + x * b1 - b2; // index 0 valid; n >= 2 at this point
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -160,29 +160,29 @@ export function chebyshevEvalWithDerivative(
|
||||||
x: number,
|
x: number,
|
||||||
radius: number,
|
radius: number,
|
||||||
): [number, number] {
|
): [number, number] {
|
||||||
const n = coeffs.length
|
const n = coeffs.length;
|
||||||
if (n === 0) return [0, 0]
|
if (n === 0) return [0, 0];
|
||||||
if (n === 1) return [coeffs[0]!, 0] // length checked; index 0 valid
|
if (n === 1) return [coeffs[0]!, 0]; // length checked; index 0 valid
|
||||||
|
|
||||||
const x2 = 2 * x
|
const x2 = 2 * x;
|
||||||
let b2 = 0
|
let b2 = 0;
|
||||||
let b1 = 0
|
let b1 = 0;
|
||||||
let db2 = 0
|
let db2 = 0;
|
||||||
let db1 = 0
|
let db1 = 0;
|
||||||
|
|
||||||
for (let k = n - 1; k >= 1; k--) {
|
for (let k = n - 1; k >= 1; k--) {
|
||||||
const b0 = coeffs[k]! + x2 * b1 - b2 // k in [1, n-1]; all valid indices
|
const b0 = coeffs[k]! + x2 * b1 - b2; // k in [1, n-1]; all valid indices
|
||||||
const db0 = 2 * b1 + x2 * db1 - db2
|
const db0 = 2 * b1 + x2 * db1 - db2;
|
||||||
b2 = b1
|
b2 = b1;
|
||||||
b1 = b0
|
b1 = b0;
|
||||||
db2 = db1
|
db2 = db1;
|
||||||
db1 = db0
|
db1 = db0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = coeffs[0]! + x * b1 - b2 // index 0 valid; n >= 2 at this point
|
const value = coeffs[0]! + x * b1 - b2; // index 0 valid; n >= 2 at this point
|
||||||
const dvalue = b1 + x * db1 - db2
|
const dvalue = b1 + x * db1 - db2;
|
||||||
// Scale derivative from normalized domain back to seconds
|
// Scale derivative from normalized domain back to seconds
|
||||||
return [value, dvalue / radius]
|
return [value, dvalue / radius];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Root finding ─────────────────────────────────────────────────────────────
|
// ─── Root finding ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -208,71 +208,71 @@ export function brentRoot(
|
||||||
tol = 1e-9,
|
tol = 1e-9,
|
||||||
maxIter = 64,
|
maxIter = 64,
|
||||||
): number | null {
|
): number | null {
|
||||||
let fa = f(a)
|
let fa = f(a);
|
||||||
let fb = f(b)
|
let fb = f(b);
|
||||||
|
|
||||||
// No sign change in bracket
|
// No sign change in bracket
|
||||||
if (fa * fb > 0) return null
|
if (fa * fb > 0) return null;
|
||||||
|
|
||||||
// Swap so |f(b)| <= |f(a)|
|
// Swap so |f(b)| <= |f(a)|
|
||||||
if (Math.abs(fa) < Math.abs(fb)) {
|
if (Math.abs(fa) < Math.abs(fb)) {
|
||||||
;[a, b] = [b, a]
|
[a, b] = [b, a];
|
||||||
;[fa, fb] = [fb, fa]
|
[fa, fb] = [fb, fa];
|
||||||
}
|
}
|
||||||
|
|
||||||
let c = a
|
let c = a;
|
||||||
let fc = fa
|
let fc = fa;
|
||||||
let mflag = true
|
let mflag = true;
|
||||||
let s: number
|
let s: number;
|
||||||
let d = 0
|
let d = 0;
|
||||||
|
|
||||||
for (let i = 0; i < maxIter; i++) {
|
for (let i = 0; i < maxIter; i++) {
|
||||||
if (Math.abs(b - a) < tol) return b
|
if (Math.abs(b - a) < tol) return b;
|
||||||
|
|
||||||
if (fa !== fc && fb !== fc) {
|
if (fa !== fc && fb !== fc) {
|
||||||
// Inverse quadratic interpolation
|
// Inverse quadratic interpolation
|
||||||
s =
|
s =
|
||||||
(a * fb * fc) / ((fa - fb) * (fa - fc)) +
|
(a * fb * fc) / ((fa - fb) * (fa - fc)) +
|
||||||
(b * fa * fc) / ((fb - fa) * (fb - fc)) +
|
(b * fa * fc) / ((fb - fa) * (fb - fc)) +
|
||||||
(c * fa * fb) / ((fc - fa) * (fc - fb))
|
(c * fa * fb) / ((fc - fa) * (fc - fb));
|
||||||
} else {
|
} else {
|
||||||
// Secant method
|
// Secant method
|
||||||
s = b - fb * ((b - a) / (fb - fa))
|
s = b - fb * ((b - a) / (fb - fa));
|
||||||
}
|
}
|
||||||
|
|
||||||
const cond1 = s < (3 * a + b) / 4 || s > b
|
const cond1 = s < (3 * a + b) / 4 || s > b;
|
||||||
const cond2 = mflag && Math.abs(s - b) >= Math.abs(b - c) / 2
|
const cond2 = mflag && Math.abs(s - b) >= Math.abs(b - c) / 2;
|
||||||
const cond3 = !mflag && Math.abs(s - b) >= Math.abs(c - d) / 2
|
const cond3 = !mflag && Math.abs(s - b) >= Math.abs(c - d) / 2;
|
||||||
const cond4 = mflag && Math.abs(b - c) < tol
|
const cond4 = mflag && Math.abs(b - c) < tol;
|
||||||
const cond5 = !mflag && Math.abs(c - d) < tol
|
const cond5 = !mflag && Math.abs(c - d) < tol;
|
||||||
|
|
||||||
if (cond1 || cond2 || cond3 || cond4 || cond5) {
|
if (cond1 || cond2 || cond3 || cond4 || cond5) {
|
||||||
s = (a + b) / 2
|
s = (a + b) / 2;
|
||||||
mflag = true
|
mflag = true;
|
||||||
} else {
|
} else {
|
||||||
mflag = false
|
mflag = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fs = f(s)
|
const fs = f(s);
|
||||||
d = c
|
d = c;
|
||||||
c = b
|
c = b;
|
||||||
fc = fb
|
fc = fb;
|
||||||
|
|
||||||
if (fa * fs < 0) {
|
if (fa * fs < 0) {
|
||||||
b = s
|
b = s;
|
||||||
fb = fs
|
fb = fs;
|
||||||
} else {
|
} else {
|
||||||
a = s
|
a = s;
|
||||||
fa = fs
|
fa = fs;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(fa) < Math.abs(fb)) {
|
if (Math.abs(fa) < Math.abs(fb)) {
|
||||||
;[a, b] = [b, a]
|
[a, b] = [b, a];
|
||||||
;[fa, fb] = [fb, fa]
|
[fa, fb] = [fb, fa];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return b
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -286,54 +286,55 @@ export function brentRoot(
|
||||||
* @returns Array of root locations
|
* @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 dt = (b - a) / steps;
|
||||||
const roots: number[] = []
|
const roots: number[] = [];
|
||||||
let tPrev = a
|
let tPrev = a;
|
||||||
let fPrev = f(a)
|
let fPrev = f(a);
|
||||||
|
|
||||||
for (let i = 1; i <= steps; i++) {
|
for (let i = 1; i <= steps; i++) {
|
||||||
const t = a + i * dt
|
const t = a + i * dt;
|
||||||
const ft = f(t)
|
const ft = f(t);
|
||||||
|
|
||||||
if (fPrev * ft <= 0) {
|
if (fPrev * ft <= 0) {
|
||||||
// Sign change in [tPrev, t] — apply Brent's method
|
// Sign change in [tPrev, t] — apply Brent's method
|
||||||
const root = brentRoot(f, tPrev, t, 1e-9, 64)
|
const root = brentRoot(f, tPrev, t, 1e-9, 64);
|
||||||
if (root !== null) {
|
if (root !== null) {
|
||||||
// Deduplicate roots that are too close together
|
// Deduplicate roots that are too close together
|
||||||
if (roots.length === 0 || Math.abs(root - roots[roots.length - 1]!) > 1e-6) { // length > 0 checked via ||
|
if (roots.length === 0 || Math.abs(root - roots[roots.length - 1]!) > 1e-6) {
|
||||||
roots.push(root)
|
// length > 0 checked via ||
|
||||||
|
roots.push(root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tPrev = t
|
tPrev = t;
|
||||||
fPrev = ft
|
fPrev = ft;
|
||||||
}
|
}
|
||||||
|
|
||||||
return roots
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Angle utilities ─────────────────────────────────────────────────────────
|
// ─── Angle utilities ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Convert degrees to radians */
|
/** Convert degrees to radians */
|
||||||
export const DEG2RAD = Math.PI / 180
|
export const DEG2RAD = Math.PI / 180;
|
||||||
|
|
||||||
/** Convert radians to degrees */
|
/** Convert radians to degrees */
|
||||||
export const RAD2DEG = 180 / Math.PI
|
export const RAD2DEG = 180 / Math.PI;
|
||||||
|
|
||||||
/** Normalize an angle to [0, 2π) */
|
/** Normalize an angle to [0, 2π) */
|
||||||
export function mod2pi(angle: number): number {
|
export function mod2pi(angle: number): number {
|
||||||
const twoPi = 2 * Math.PI
|
const twoPi = 2 * Math.PI;
|
||||||
return ((angle % twoPi) + twoPi) % twoPi
|
return ((angle % twoPi) + twoPi) % twoPi;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize an angle in degrees to [0, 360) */
|
/** Normalize an angle in degrees to [0, 360) */
|
||||||
export function mod360(deg: number): number {
|
export function mod360(deg: number): number {
|
||||||
return ((deg % 360) + 360) % 360
|
return ((deg % 360) + 360) % 360;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize an angle in degrees to [-180, 180) */
|
/** Normalize an angle in degrees to [-180, 180) */
|
||||||
export function normalizeDeg180(deg: number): number {
|
export function normalizeDeg180(deg: number): number {
|
||||||
deg = mod360(deg)
|
deg = mod360(deg);
|
||||||
return deg >= 180 ? deg - 360 : deg
|
return deg >= 180 ? deg - 360 : deg;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@
|
||||||
* Saemundsson (1986), Sky & Telescope 72, 70
|
* Saemundsson (1986), Sky & Telescope 72, 70
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Vec3, Observer, AzAlt, TimeScales } from '../types.js'
|
import type { Vec3, Observer, AzAlt, TimeScales } from "../types.js";
|
||||||
import { WGS84 } from '../types.js'
|
import { WGS84 } from "../types.js";
|
||||||
import { gcrsToItrs } from '../frames/index.js'
|
import { gcrsToItrs } from "../frames/index.js";
|
||||||
import { vdot } from '../math/index.js'
|
import { vdot } from "../math/index.js";
|
||||||
|
|
||||||
// ─── Geodetic ↔ ECEF ─────────────────────────────────────────────────────────
|
// ─── Geodetic ↔ ECEF ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -35,17 +35,17 @@ import { vdot } from '../math/index.js'
|
||||||
* @returns ECEF position vector in meters
|
* @returns ECEF position vector in meters
|
||||||
*/
|
*/
|
||||||
export function geodeticToECEF(lat: number, lon: number, elev: number): Vec3 {
|
export function geodeticToECEF(lat: number, lon: number, elev: number): Vec3 {
|
||||||
const phi = (lat * Math.PI) / 180
|
const phi = (lat * Math.PI) / 180;
|
||||||
const lam = (lon * Math.PI) / 180
|
const lam = (lon * Math.PI) / 180;
|
||||||
const sinPhi = Math.sin(phi)
|
const sinPhi = Math.sin(phi);
|
||||||
const cosPhi = Math.cos(phi)
|
const cosPhi = Math.cos(phi);
|
||||||
// Prime vertical radius of curvature
|
// Prime vertical radius of curvature
|
||||||
const N = WGS84.a / Math.sqrt(1 - WGS84.e2 * sinPhi * sinPhi)
|
const N = WGS84.a / Math.sqrt(1 - WGS84.e2 * sinPhi * sinPhi);
|
||||||
return [
|
return [
|
||||||
(N + elev) * cosPhi * Math.cos(lam),
|
(N + elev) * cosPhi * Math.cos(lam),
|
||||||
(N + elev) * cosPhi * Math.sin(lam),
|
(N + elev) * cosPhi * Math.sin(lam),
|
||||||
(N * (1 - WGS84.e2) + elev) * sinPhi,
|
(N * (1 - WGS84.e2) + elev) * sinPhi,
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,27 +55,27 @@ export function geodeticToECEF(lat: number, lon: number, elev: number): Vec3 {
|
||||||
* @returns { lat, lon, h } — latitude/longitude in degrees, height in meters
|
* @returns { lat, lon, h } — latitude/longitude in degrees, height in meters
|
||||||
*/
|
*/
|
||||||
export function ecefToGeodetic(ecef: Vec3): { lat: number; lon: number; h: number } {
|
export function ecefToGeodetic(ecef: Vec3): { lat: number; lon: number; h: number } {
|
||||||
const [X, Y, Z] = ecef
|
const [X, Y, Z] = ecef;
|
||||||
const p = Math.sqrt(X * X + Y * Y)
|
const p = Math.sqrt(X * X + Y * Y);
|
||||||
const lon = Math.atan2(Y, X)
|
const lon = Math.atan2(Y, X);
|
||||||
|
|
||||||
// Bowring iteration for geodetic latitude
|
// Bowring iteration for geodetic latitude
|
||||||
let lat = Math.atan2(Z, p * (1 - WGS84.e2))
|
let lat = Math.atan2(Z, p * (1 - WGS84.e2));
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
const sinLat = Math.sin(lat)
|
const sinLat = Math.sin(lat);
|
||||||
const N = WGS84.a / Math.sqrt(1 - WGS84.e2 * sinLat * sinLat)
|
const N = WGS84.a / Math.sqrt(1 - WGS84.e2 * sinLat * sinLat);
|
||||||
lat = Math.atan2(Z + WGS84.e2 * N * sinLat, p)
|
lat = Math.atan2(Z + WGS84.e2 * N * sinLat, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sinLat = Math.sin(lat)
|
const sinLat = Math.sin(lat);
|
||||||
const N = WGS84.a / Math.sqrt(1 - WGS84.e2 * sinLat * sinLat)
|
const N = WGS84.a / Math.sqrt(1 - WGS84.e2 * sinLat * sinLat);
|
||||||
const h = p / Math.cos(lat) - N
|
const h = p / Math.cos(lat) - N;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lat: (lat * 180) / Math.PI,
|
lat: (lat * 180) / Math.PI,
|
||||||
lon: (lon * 180) / Math.PI,
|
lon: (lon * 180) / Math.PI,
|
||||||
h,
|
h,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Topocentric ENU ─────────────────────────────────────────────────────────
|
// ─── Topocentric ENU ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -92,18 +92,18 @@ export function ecefToGeodetic(ecef: Vec3): { lat: number; lon: number; h: numbe
|
||||||
* @param lon - Longitude in degrees
|
* @param lon - Longitude in degrees
|
||||||
*/
|
*/
|
||||||
export function computeENUBasis(lat: number, lon: number): { east: Vec3; north: Vec3; up: Vec3 } {
|
export function computeENUBasis(lat: number, lon: number): { east: Vec3; north: Vec3; up: Vec3 } {
|
||||||
const phi = (lat * Math.PI) / 180
|
const phi = (lat * Math.PI) / 180;
|
||||||
const lam = (lon * Math.PI) / 180
|
const lam = (lon * Math.PI) / 180;
|
||||||
const sinPhi = Math.sin(phi),
|
const sinPhi = Math.sin(phi),
|
||||||
cosPhi = Math.cos(phi)
|
cosPhi = Math.cos(phi);
|
||||||
const sinLam = Math.sin(lam),
|
const sinLam = Math.sin(lam),
|
||||||
cosLam = Math.cos(lam)
|
cosLam = Math.cos(lam);
|
||||||
|
|
||||||
const east: Vec3 = [-sinLam, cosLam, 0]
|
const east: Vec3 = [-sinLam, cosLam, 0];
|
||||||
const north: Vec3 = [-sinPhi * cosLam, -sinPhi * sinLam, cosPhi]
|
const north: Vec3 = [-sinPhi * cosLam, -sinPhi * sinLam, cosPhi];
|
||||||
const up: Vec3 = [cosPhi * cosLam, cosPhi * sinLam, sinPhi]
|
const up: Vec3 = [cosPhi * cosLam, cosPhi * sinLam, sinPhi];
|
||||||
|
|
||||||
return { east, north, up }
|
return { east, north, up };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -116,8 +116,8 @@ export function computeENUBasis(lat: number, lon: number): { east: Vec3; north:
|
||||||
* @returns ENU vector [east, north, up] in the same units as input
|
* @returns ENU vector [east, north, up] in the same units as input
|
||||||
*/
|
*/
|
||||||
export function ecefToENU(ecefDelta: Vec3, lat: number, lon: number): Vec3 {
|
export function ecefToENU(ecefDelta: Vec3, lat: number, lon: number): Vec3 {
|
||||||
const { east, north, up } = computeENUBasis(lat, lon)
|
const { east, north, up } = computeENUBasis(lat, lon);
|
||||||
return [vdot(ecefDelta, east), vdot(ecefDelta, north), vdot(ecefDelta, up)]
|
return [vdot(ecefDelta, east), vdot(ecefDelta, north), vdot(ecefDelta, up)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -130,13 +130,13 @@ export function ecefToENU(ecefDelta: Vec3, lat: number, lon: number): Vec3 {
|
||||||
* @returns Azimuth (degrees, [0, 360)) and altitude (degrees)
|
* @returns Azimuth (degrees, [0, 360)) and altitude (degrees)
|
||||||
*/
|
*/
|
||||||
export function enuToAzAlt(enu: Vec3): AzAlt {
|
export function enuToAzAlt(enu: Vec3): AzAlt {
|
||||||
const [e, n, u] = enu
|
const [e, n, u] = enu;
|
||||||
const horiz = Math.sqrt(e * e + n * n)
|
const horiz = Math.sqrt(e * e + n * n);
|
||||||
const altitude = (Math.atan2(u, horiz) * 180) / Math.PI
|
const altitude = (Math.atan2(u, horiz) * 180) / Math.PI;
|
||||||
// atan2(east, north) gives bearing from North; convert to [0, 360)
|
// atan2(east, north) gives bearing from North; convert to [0, 360)
|
||||||
let azimuth = (Math.atan2(e, n) * 180) / Math.PI
|
let azimuth = (Math.atan2(e, n) * 180) / Math.PI;
|
||||||
if (azimuth < 0) azimuth += 360
|
if (azimuth < 0) azimuth += 360;
|
||||||
return { azimuth, altitude }
|
return { azimuth, altitude };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Topocentric parallax ─────────────────────────────────────────────────────
|
// ─── Topocentric parallax ─────────────────────────────────────────────────────
|
||||||
|
|
@ -158,7 +158,7 @@ export function topocentricPosition(bodyGCRS: Vec3, observerGCRS: Vec3): Vec3 {
|
||||||
bodyGCRS[0] - observerGCRS[0],
|
bodyGCRS[0] - observerGCRS[0],
|
||||||
bodyGCRS[1] - observerGCRS[1],
|
bodyGCRS[1] - observerGCRS[1],
|
||||||
bodyGCRS[2] - observerGCRS[2],
|
bodyGCRS[2] - observerGCRS[2],
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Full pipeline: GCRS → az/alt ────────────────────────────────────────────
|
// ─── Full pipeline: GCRS → az/alt ────────────────────────────────────────────
|
||||||
|
|
@ -188,27 +188,31 @@ export function computeAzAlt(
|
||||||
airless: boolean,
|
airless: boolean,
|
||||||
): AzAlt {
|
): AzAlt {
|
||||||
// 1. Convert body position from GCRS to ITRS (km)
|
// 1. Convert body position from GCRS to ITRS (km)
|
||||||
const bodyITRS = gcrsToItrs(bodyGCRS, ts)
|
const bodyITRS = gcrsToItrs(bodyGCRS, ts);
|
||||||
|
|
||||||
// 2. Observer position in ITRS: geodeticToECEF returns meters → convert to km
|
// 2. Observer position in ITRS: geodeticToECEF returns meters → convert to km
|
||||||
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation)
|
const obsECEF = geodeticToECEF(observer.lat, observer.lon, observer.elevation);
|
||||||
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000]
|
const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000];
|
||||||
|
|
||||||
// 3. Displacement vector from observer to body in ITRS (km — magnitude doesn't matter)
|
// 3. Displacement vector from observer to body in ITRS (km — magnitude doesn't matter)
|
||||||
const delta: Vec3 = [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
|
// 4. Project onto local ENU basis at the observer's location
|
||||||
const enu = ecefToENU(delta, observer.lat, observer.lon)
|
const enu = ecefToENU(delta, observer.lat, observer.lon);
|
||||||
|
|
||||||
// 5. Convert ENU to azimuth + altitude
|
// 5. Convert ENU to azimuth + altitude
|
||||||
const azAlt = enuToAzAlt(enu)
|
const azAlt = enuToAzAlt(enu);
|
||||||
|
|
||||||
// 6. Refraction correction
|
// 6. Refraction correction
|
||||||
if (!airless) {
|
if (!airless) {
|
||||||
azAlt.altitude = applyRefraction(azAlt.altitude, observer.pressure, observer.temperature)
|
azAlt.altitude = applyRefraction(azAlt.altitude, observer.pressure, observer.temperature);
|
||||||
}
|
}
|
||||||
|
|
||||||
return azAlt
|
return azAlt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Atmospheric refraction ───────────────────────────────────────────────────
|
// ─── Atmospheric refraction ───────────────────────────────────────────────────
|
||||||
|
|
@ -237,17 +241,17 @@ export function bennettRefraction(
|
||||||
temperature = 15,
|
temperature = 15,
|
||||||
): number {
|
): number {
|
||||||
// No refraction below the geometric horizon (Bennett formula diverges below ~−1°)
|
// No refraction below the geometric horizon (Bennett formula diverges below ~−1°)
|
||||||
if (altitudeDeg < -1) return 0
|
if (altitudeDeg < -1) return 0;
|
||||||
|
|
||||||
// Convert altitude argument to radians for the cot computation
|
// Convert altitude argument to radians for the cot computation
|
||||||
const h = altitudeDeg
|
const h = altitudeDeg;
|
||||||
const argDeg = h + 7.31 / (h + 4.4)
|
const argDeg = h + 7.31 / (h + 4.4);
|
||||||
const argRad = (argDeg * Math.PI) / 180
|
const argRad = (argDeg * Math.PI) / 180;
|
||||||
const R = 1 / (Math.tan(argRad) * 60) // degrees
|
const R = 1 / (Math.tan(argRad) * 60); // degrees
|
||||||
|
|
||||||
// Pressure and temperature correction
|
// Pressure and temperature correction
|
||||||
const corrected = R * (pressure / 1010) * (283 / (273 + temperature))
|
const corrected = R * (pressure / 1010) * (283 / (273 + temperature));
|
||||||
return Math.max(0, corrected)
|
return Math.max(0, corrected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -255,7 +259,7 @@ export function bennettRefraction(
|
||||||
* Returns the apparent (observed) 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)
|
return airlessAlt + bennettRefraction(airlessAlt, pressure, temperature);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -268,9 +272,9 @@ export function removeRefraction(
|
||||||
temperature = 15,
|
temperature = 15,
|
||||||
): number {
|
): number {
|
||||||
// Start from the apparent altitude and iterate
|
// Start from the apparent altitude and iterate
|
||||||
let airless = apparentAlt
|
let airless = apparentAlt;
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
airless = apparentAlt - bennettRefraction(airless, pressure, temperature)
|
airless = apparentAlt - bennettRefraction(airless, pressure, temperature);
|
||||||
}
|
}
|
||||||
return airless
|
return airless;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
328
src/spk/index.ts
328
src/spk/index.ts
|
|
@ -24,8 +24,8 @@
|
||||||
* SPICE source: SPKE02.f, DAFRA.f
|
* SPICE source: SPKE02.f, DAFRA.f
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SpkSegment, StateVector } from '../types.js'
|
import type { SpkSegment, StateVector } from "../types.js";
|
||||||
import { chebyshevEvalWithDerivative } from '../math/index.js'
|
import { chebyshevEvalWithDerivative } from "../math/index.js";
|
||||||
|
|
||||||
// ─── NAIF body IDs ────────────────────────────────────────────────────────────
|
// ─── NAIF body IDs ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -44,14 +44,14 @@ export const NAIF_IDS = {
|
||||||
SUN: 10,
|
SUN: 10,
|
||||||
MOON: 301,
|
MOON: 301,
|
||||||
EARTH: 399,
|
EARTH: 399,
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
/** Frame code for ICRF/J2000 (the inertial reference frame used by DE442S) */
|
/** Frame code for ICRF/J2000 (the inertial reference frame used by DE442S) */
|
||||||
export const FRAME_J2000 = 1
|
export const FRAME_J2000 = 1;
|
||||||
|
|
||||||
/** DAF record size in bytes */
|
/** DAF record size in bytes */
|
||||||
const DAF_RECORD_SIZE = 1024
|
const DAF_RECORD_SIZE = 1024;
|
||||||
const BYTES_PER_DOUBLE = 8
|
const BYTES_PER_DOUBLE = 8;
|
||||||
|
|
||||||
// ─── SPK Kernel ───────────────────────────────────────────────────────────────
|
// ─── SPK Kernel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -59,39 +59,39 @@ const BYTES_PER_DOUBLE = 8
|
||||||
* A loaded SPK kernel with segment index.
|
* A loaded SPK kernel with segment index.
|
||||||
*/
|
*/
|
||||||
export class SpkKernel {
|
export class SpkKernel {
|
||||||
private readonly buffer: ArrayBuffer
|
private readonly buffer: ArrayBuffer;
|
||||||
private readonly segments: SpkSegment[]
|
private readonly segments: SpkSegment[];
|
||||||
private readonly index: Map<string, SpkSegment[]>
|
private readonly index: Map<string, SpkSegment[]>;
|
||||||
private readonly le: boolean
|
private readonly le: boolean;
|
||||||
|
|
||||||
private constructor(buffer: ArrayBuffer, segments: SpkSegment[], le: boolean) {
|
private constructor(buffer: ArrayBuffer, segments: SpkSegment[], le: boolean) {
|
||||||
this.buffer = buffer
|
this.buffer = buffer;
|
||||||
this.segments = segments
|
this.segments = segments;
|
||||||
this.le = le
|
this.le = le;
|
||||||
this.index = new Map()
|
this.index = new Map();
|
||||||
|
|
||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
const key = `${seg.target}:${seg.center}`
|
const key = `${seg.target}:${seg.center}`;
|
||||||
const list = this.index.get(key) ?? []
|
const list = this.index.get(key) ?? [];
|
||||||
list.push(seg)
|
list.push(seg);
|
||||||
this.index.set(key, list)
|
this.index.set(key, list);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load a kernel from a binary ArrayBuffer. */
|
/** Load a kernel from a binary ArrayBuffer. */
|
||||||
static fromBuffer(buffer: ArrayBuffer): SpkKernel {
|
static fromBuffer(buffer: ArrayBuffer): SpkKernel {
|
||||||
const { nd, ni, fward, le } = parseDafFileRecord(buffer)
|
const { nd, ni, fward, le } = parseDafFileRecord(buffer);
|
||||||
const segments = parseSummaryRecords(buffer, fward, nd, ni, le)
|
const segments = parseSummaryRecords(buffer, fward, nd, ni, le);
|
||||||
return new SpkKernel(buffer, segments, le)
|
return new SpkKernel(buffer, segments, le);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load a kernel from a file path (Node.js only). */
|
/** Load a kernel from a file path (Node.js only). */
|
||||||
static fromFile(path: string): SpkKernel {
|
static fromFile(path: string): SpkKernel {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const fs = require('fs') as typeof import('fs')
|
const fs = require("fs") as typeof import("fs");
|
||||||
const buf = fs.readFileSync(path)
|
const buf = fs.readFileSync(path);
|
||||||
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||||
return SpkKernel.fromBuffer(ab as ArrayBuffer)
|
return SpkKernel.fromBuffer(ab as ArrayBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -99,104 +99,104 @@ export class SpkKernel {
|
||||||
* Uses segment chaining when no direct segment exists.
|
* Uses segment chaining when no direct segment exists.
|
||||||
*/
|
*/
|
||||||
getState(target: number, center: number, et: number): StateVector {
|
getState(target: number, center: number, et: number): StateVector {
|
||||||
const direct = this.findSeg(target, center, et)
|
const direct = this.findSeg(target, center, et);
|
||||||
if (direct) return evaluateSegment(this.buffer, direct, et, this.le)
|
if (direct) return evaluateSegment(this.buffer, direct, et, this.le);
|
||||||
return this.getChained(target, center, et)
|
return this.getChained(target, center, et);
|
||||||
}
|
}
|
||||||
|
|
||||||
private findSeg(target: number, center: number, et: number): SpkSegment | null {
|
private findSeg(target: number, center: number, et: number): SpkSegment | null {
|
||||||
const candidates = this.index.get(`${target}:${center}`)
|
const candidates = this.index.get(`${target}:${center}`);
|
||||||
if (!candidates) return null
|
if (!candidates) return null;
|
||||||
return candidates.find((s) => et >= s.startET && et <= s.endET) ?? null
|
return candidates.find((s) => et >= s.startET && et <= s.endET) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getChained(target: number, center: number, et: number): StateVector {
|
private getChained(target: number, center: number, et: number): StateVector {
|
||||||
const ssb = NAIF_IDS.SSB
|
const ssb = NAIF_IDS.SSB;
|
||||||
const emb = NAIF_IDS.EMB
|
const emb = NAIF_IDS.EMB;
|
||||||
|
|
||||||
// Moon relative to Earth: Moon-EMB minus Earth-EMB
|
// Moon relative to Earth: Moon-EMB minus Earth-EMB
|
||||||
if (target === NAIF_IDS.MOON && center === NAIF_IDS.EARTH) {
|
if (target === NAIF_IDS.MOON && center === NAIF_IDS.EARTH) {
|
||||||
const s1 = this.findSeg(NAIF_IDS.MOON, emb, et)
|
const s1 = this.findSeg(NAIF_IDS.MOON, emb, et);
|
||||||
const s2 = this.findSeg(NAIF_IDS.EARTH, emb, et)
|
const s2 = this.findSeg(NAIF_IDS.EARTH, emb, et);
|
||||||
if (s1 && s2) {
|
if (s1 && s2) {
|
||||||
return subtractSV(
|
return subtractSV(
|
||||||
evaluateSegment(this.buffer, s1, et, this.le),
|
evaluateSegment(this.buffer, s1, et, this.le),
|
||||||
evaluateSegment(this.buffer, s2, et, this.le),
|
evaluateSegment(this.buffer, s2, et, this.le),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Earth relative to Moon (inverse)
|
// Earth relative to Moon (inverse)
|
||||||
if (target === NAIF_IDS.EARTH && center === NAIF_IDS.MOON) {
|
if (target === NAIF_IDS.EARTH && center === NAIF_IDS.MOON) {
|
||||||
const s1 = this.findSeg(NAIF_IDS.EARTH, emb, et)
|
const s1 = this.findSeg(NAIF_IDS.EARTH, emb, et);
|
||||||
const s2 = this.findSeg(NAIF_IDS.MOON, emb, et)
|
const s2 = this.findSeg(NAIF_IDS.MOON, emb, et);
|
||||||
if (s1 && s2) {
|
if (s1 && s2) {
|
||||||
return subtractSV(
|
return subtractSV(
|
||||||
evaluateSegment(this.buffer, s1, et, this.le),
|
evaluateSegment(this.buffer, s1, et, this.le),
|
||||||
evaluateSegment(this.buffer, s2, et, this.le),
|
evaluateSegment(this.buffer, s2, et, this.le),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sun relative to Earth
|
// Sun relative to Earth
|
||||||
if (target === NAIF_IDS.SUN && center === NAIF_IDS.EARTH) {
|
if (target === NAIF_IDS.SUN && center === NAIF_IDS.EARTH) {
|
||||||
const sSunSsb = this.findSeg(NAIF_IDS.SUN, ssb, et)
|
const sSunSsb = this.findSeg(NAIF_IDS.SUN, ssb, et);
|
||||||
const sEmbSsb = this.findSeg(emb, ssb, et)
|
const sEmbSsb = this.findSeg(emb, ssb, et);
|
||||||
const sEarthEmb = this.findSeg(NAIF_IDS.EARTH, emb, et)
|
const sEarthEmb = this.findSeg(NAIF_IDS.EARTH, emb, et);
|
||||||
if (sSunSsb && sEmbSsb && sEarthEmb) {
|
if (sSunSsb && sEmbSsb && sEarthEmb) {
|
||||||
const svSunSsb = evaluateSegment(this.buffer, sSunSsb, et, this.le)
|
const svSunSsb = evaluateSegment(this.buffer, sSunSsb, et, this.le);
|
||||||
const svEmbSsb = evaluateSegment(this.buffer, sEmbSsb, et, this.le)
|
const svEmbSsb = evaluateSegment(this.buffer, sEmbSsb, et, this.le);
|
||||||
const svEarthEmb = evaluateSegment(this.buffer, sEarthEmb, et, this.le)
|
const svEarthEmb = evaluateSegment(this.buffer, sEarthEmb, et, this.le);
|
||||||
// Earth/SSB = EMB/SSB - Earth/EMB
|
// Earth/SSB = EMB/SSB - Earth/EMB
|
||||||
const earthSsb = subtractSV(svEmbSsb, svEarthEmb)
|
const earthSsb = subtractSV(svEmbSsb, svEarthEmb);
|
||||||
return subtractSV(svSunSsb, earthSsb)
|
return subtractSV(svSunSsb, earthSsb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic two-hop via SSB
|
// Generic two-hop via SSB
|
||||||
const sTargetSsb = this.findSeg(target, ssb, et)
|
const sTargetSsb = this.findSeg(target, ssb, et);
|
||||||
const sCenterSsb = this.findSeg(center, ssb, et)
|
const sCenterSsb = this.findSeg(center, ssb, et);
|
||||||
if (sTargetSsb && sCenterSsb) {
|
if (sTargetSsb && sCenterSsb) {
|
||||||
return subtractSV(
|
return subtractSV(
|
||||||
evaluateSegment(this.buffer, sTargetSsb, et, this.le),
|
evaluateSegment(this.buffer, sTargetSsb, et, this.le),
|
||||||
evaluateSegment(this.buffer, sCenterSsb, et, this.le),
|
evaluateSegment(this.buffer, sCenterSsb, et, this.le),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`SpkKernel: no path for target=${target} center=${center} et=${et}`)
|
throw new Error(`SpkKernel: no path for target=${target} center=${center} et=${et}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSegments(): ReadonlyArray<SpkSegment> {
|
getSegments(): ReadonlyArray<SpkSegment> {
|
||||||
return this.segments
|
return this.segments;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DAF parsing ──────────────────────────────────────────────────────────────
|
// ─── DAF parsing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function parseDafFileRecord(buffer: ArrayBuffer): {
|
function parseDafFileRecord(buffer: ArrayBuffer): {
|
||||||
nd: number
|
nd: number;
|
||||||
ni: number
|
ni: number;
|
||||||
fward: number
|
fward: number;
|
||||||
bward: number
|
bward: number;
|
||||||
free: number
|
free: number;
|
||||||
le: boolean
|
le: boolean;
|
||||||
} {
|
} {
|
||||||
const dv = new DataView(buffer)
|
const dv = new DataView(buffer);
|
||||||
|
|
||||||
// Detect endianness by reading ND (should be 2 for DE442S SPK)
|
// Detect endianness by reading ND (should be 2 for DE442S SPK)
|
||||||
let le = true
|
let le = true;
|
||||||
let nd = dv.getInt32(8, true)
|
let nd = dv.getInt32(8, true);
|
||||||
if (nd < 1 || nd > 100) {
|
if (nd < 1 || nd > 100) {
|
||||||
nd = dv.getInt32(8, false)
|
nd = dv.getInt32(8, false);
|
||||||
le = false
|
le = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ni = dv.getInt32(12, le)
|
const ni = dv.getInt32(12, le);
|
||||||
const fward = dv.getInt32(256, le)
|
const fward = dv.getInt32(256, le);
|
||||||
const bward = dv.getInt32(260, le)
|
const bward = dv.getInt32(260, le);
|
||||||
const free = dv.getInt32(264, le)
|
const free = dv.getInt32(264, le);
|
||||||
|
|
||||||
return { nd, ni, fward, bward, free, le }
|
return { nd, ni, fward, bward, free, le };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSummaryRecords(
|
function parseSummaryRecords(
|
||||||
|
|
@ -206,46 +206,46 @@ function parseSummaryRecords(
|
||||||
ni: number,
|
ni: number,
|
||||||
le: boolean,
|
le: boolean,
|
||||||
): SpkSegment[] {
|
): SpkSegment[] {
|
||||||
const dv = new DataView(buffer)
|
const dv = new DataView(buffer);
|
||||||
const segments: SpkSegment[] = []
|
const segments: SpkSegment[] = [];
|
||||||
const summaryBytes = nd * BYTES_PER_DOUBLE + ni * 4
|
const summaryBytes = nd * BYTES_PER_DOUBLE + ni * 4;
|
||||||
|
|
||||||
let recordNum = fward
|
let recordNum = fward;
|
||||||
|
|
||||||
while (recordNum !== 0) {
|
while (recordNum !== 0) {
|
||||||
const recOffset = (recordNum - 1) * DAF_RECORD_SIZE
|
const recOffset = (recordNum - 1) * DAF_RECORD_SIZE;
|
||||||
|
|
||||||
// Control area: 3 doubles at start of record
|
// Control area: 3 doubles at start of record
|
||||||
const nextRecord = dv.getFloat64(recOffset, le)
|
const nextRecord = dv.getFloat64(recOffset, le);
|
||||||
const nSummaries = Math.round(dv.getFloat64(recOffset + 16, le))
|
const nSummaries = Math.round(dv.getFloat64(recOffset + 16, le));
|
||||||
|
|
||||||
let offset = recOffset + 24 // skip 3 control doubles (24 bytes)
|
let offset = recOffset + 24; // skip 3 control doubles (24 bytes)
|
||||||
|
|
||||||
for (let i = 0; i < nSummaries; i++) {
|
for (let i = 0; i < nSummaries; i++) {
|
||||||
if (offset + summaryBytes > buffer.byteLength) break
|
if (offset + summaryBytes > buffer.byteLength) break;
|
||||||
|
|
||||||
const startET = dv.getFloat64(offset, le)
|
const startET = dv.getFloat64(offset, le);
|
||||||
const endET = dv.getFloat64(offset + 8, le)
|
const endET = dv.getFloat64(offset + 8, le);
|
||||||
offset += nd * BYTES_PER_DOUBLE
|
offset += nd * BYTES_PER_DOUBLE;
|
||||||
|
|
||||||
const target = dv.getInt32(offset, le)
|
const target = dv.getInt32(offset, le);
|
||||||
const center = dv.getInt32(offset + 4, le)
|
const center = dv.getInt32(offset + 4, le);
|
||||||
const frame = dv.getInt32(offset + 8, le)
|
const frame = dv.getInt32(offset + 8, le);
|
||||||
const dataType = dv.getInt32(offset + 12, le) as 2 | 3
|
const dataType = dv.getInt32(offset + 12, le) as 2 | 3;
|
||||||
const beginAddr = dv.getInt32(offset + 16, le)
|
const beginAddr = dv.getInt32(offset + 16, le);
|
||||||
const endAddr = dv.getInt32(offset + 20, le)
|
const endAddr = dv.getInt32(offset + 20, le);
|
||||||
offset += ni * 4
|
offset += ni * 4;
|
||||||
|
|
||||||
const dataOffset = (beginAddr - 1) * BYTES_PER_DOUBLE
|
const dataOffset = (beginAddr - 1) * BYTES_PER_DOUBLE;
|
||||||
const dataSize = endAddr - beginAddr + 1
|
const dataSize = endAddr - beginAddr + 1;
|
||||||
|
|
||||||
segments.push({ target, center, frame, dataType, startET, endET, dataOffset, dataSize })
|
segments.push({ target, center, frame, dataType, startET, endET, dataOffset, dataSize });
|
||||||
}
|
}
|
||||||
|
|
||||||
recordNum = Math.round(nextRecord)
|
recordNum = Math.round(nextRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Segment evaluation ───────────────────────────────────────────────────────
|
// ─── Segment evaluation ───────────────────────────────────────────────────────
|
||||||
|
|
@ -256,9 +256,9 @@ function evaluateSegment(
|
||||||
et: number,
|
et: number,
|
||||||
le: boolean,
|
le: boolean,
|
||||||
): StateVector {
|
): StateVector {
|
||||||
if (seg.dataType === 2) return evaluateType2(buffer, seg, et, le)
|
if (seg.dataType === 2) return evaluateType2(buffer, seg, et, le);
|
||||||
if (seg.dataType === 3) return evaluateType3(buffer, seg, et, le)
|
if (seg.dataType === 3) return evaluateType3(buffer, seg, et, le);
|
||||||
throw new Error(`Unsupported SPK segment type: ${seg.dataType}`)
|
throw new Error(`Unsupported SPK segment type: ${seg.dataType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -270,36 +270,36 @@ export function evaluateType2(
|
||||||
et: number,
|
et: number,
|
||||||
le = true,
|
le = true,
|
||||||
): StateVector {
|
): StateVector {
|
||||||
const dv = new DataView(buffer)
|
const dv = new DataView(buffer);
|
||||||
const endOffset = seg.dataOffset + seg.dataSize * BYTES_PER_DOUBLE
|
const endOffset = seg.dataOffset + seg.dataSize * BYTES_PER_DOUBLE;
|
||||||
|
|
||||||
// Directory at end of data (4 doubles before the data end)
|
// Directory at end of data (4 doubles before the data end)
|
||||||
const N = dv.getFloat64(endOffset - BYTES_PER_DOUBLE, le)
|
const N = dv.getFloat64(endOffset - BYTES_PER_DOUBLE, le);
|
||||||
const rsize = dv.getFloat64(endOffset - 2 * BYTES_PER_DOUBLE, le)
|
const rsize = dv.getFloat64(endOffset - 2 * BYTES_PER_DOUBLE, le);
|
||||||
const intlen = dv.getFloat64(endOffset - 3 * BYTES_PER_DOUBLE, le)
|
const intlen = dv.getFloat64(endOffset - 3 * BYTES_PER_DOUBLE, le);
|
||||||
const init = dv.getFloat64(endOffset - 4 * BYTES_PER_DOUBLE, le)
|
const init = dv.getFloat64(endOffset - 4 * BYTES_PER_DOUBLE, le);
|
||||||
|
|
||||||
// degree = (rsize - 2) / 3 (Type 2 stores 3 components)
|
// degree = (rsize - 2) / 3 (Type 2 stores 3 components)
|
||||||
const degree = Math.round((rsize - 2) / 3)
|
const degree = Math.round((rsize - 2) / 3);
|
||||||
const nCoeffs = degree + 1
|
const nCoeffs = degree + 1;
|
||||||
|
|
||||||
let recIdx = Math.floor((et - init) / intlen)
|
let recIdx = Math.floor((et - init) / intlen);
|
||||||
recIdx = Math.max(0, Math.min(Math.round(N) - 1, recIdx))
|
recIdx = Math.max(0, Math.min(Math.round(N) - 1, recIdx));
|
||||||
|
|
||||||
const recOffset = seg.dataOffset + recIdx * rsize * BYTES_PER_DOUBLE
|
const recOffset = seg.dataOffset + recIdx * rsize * BYTES_PER_DOUBLE;
|
||||||
const mid = dv.getFloat64(recOffset, le)
|
const mid = dv.getFloat64(recOffset, le);
|
||||||
const radius = dv.getFloat64(recOffset + BYTES_PER_DOUBLE, le)
|
const radius = dv.getFloat64(recOffset + BYTES_PER_DOUBLE, le);
|
||||||
const x = (et - mid) / radius
|
const x = (et - mid) / radius;
|
||||||
|
|
||||||
const xC = readCoeffs(dv, recOffset + 2 * BYTES_PER_DOUBLE, nCoeffs, le)
|
const xC = readCoeffs(dv, recOffset + 2 * BYTES_PER_DOUBLE, nCoeffs, le);
|
||||||
const yC = readCoeffs(dv, recOffset + (2 + nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le)
|
const yC = readCoeffs(dv, recOffset + (2 + nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le);
|
||||||
const zC = readCoeffs(dv, recOffset + (2 + 2 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le)
|
const zC = readCoeffs(dv, recOffset + (2 + 2 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le);
|
||||||
|
|
||||||
const [px, vx] = chebyshevEvalWithDerivative(xC, x, radius)
|
const [px, vx] = chebyshevEvalWithDerivative(xC, x, radius);
|
||||||
const [py, vy] = chebyshevEvalWithDerivative(yC, x, radius)
|
const [py, vy] = chebyshevEvalWithDerivative(yC, x, radius);
|
||||||
const [pz, vz] = chebyshevEvalWithDerivative(zC, x, radius)
|
const [pz, vz] = chebyshevEvalWithDerivative(zC, x, radius);
|
||||||
|
|
||||||
return { position: [px, py, pz], velocity: [vx, vy, vz] }
|
return { position: [px, py, pz], velocity: [vx, vy, vz] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -312,51 +312,51 @@ export function evaluateType3(
|
||||||
et: number,
|
et: number,
|
||||||
le = true,
|
le = true,
|
||||||
): StateVector {
|
): StateVector {
|
||||||
const dv = new DataView(buffer)
|
const dv = new DataView(buffer);
|
||||||
const endOffset = seg.dataOffset + seg.dataSize * BYTES_PER_DOUBLE
|
const endOffset = seg.dataOffset + seg.dataSize * BYTES_PER_DOUBLE;
|
||||||
|
|
||||||
const N = dv.getFloat64(endOffset - BYTES_PER_DOUBLE, le)
|
const N = dv.getFloat64(endOffset - BYTES_PER_DOUBLE, le);
|
||||||
const rsize = dv.getFloat64(endOffset - 2 * BYTES_PER_DOUBLE, le)
|
const rsize = dv.getFloat64(endOffset - 2 * BYTES_PER_DOUBLE, le);
|
||||||
const intlen = dv.getFloat64(endOffset - 3 * BYTES_PER_DOUBLE, le)
|
const intlen = dv.getFloat64(endOffset - 3 * BYTES_PER_DOUBLE, le);
|
||||||
const init = dv.getFloat64(endOffset - 4 * BYTES_PER_DOUBLE, le)
|
const init = dv.getFloat64(endOffset - 4 * BYTES_PER_DOUBLE, le);
|
||||||
|
|
||||||
const degree = Math.round((rsize - 2) / 6)
|
const degree = Math.round((rsize - 2) / 6);
|
||||||
const nCoeffs = degree + 1
|
const nCoeffs = degree + 1;
|
||||||
|
|
||||||
let recIdx = Math.floor((et - init) / intlen)
|
let recIdx = Math.floor((et - init) / intlen);
|
||||||
recIdx = Math.max(0, Math.min(Math.round(N) - 1, recIdx))
|
recIdx = Math.max(0, Math.min(Math.round(N) - 1, recIdx));
|
||||||
|
|
||||||
const recOffset = seg.dataOffset + recIdx * rsize * BYTES_PER_DOUBLE
|
const recOffset = seg.dataOffset + recIdx * rsize * BYTES_PER_DOUBLE;
|
||||||
const mid = dv.getFloat64(recOffset, le)
|
const mid = dv.getFloat64(recOffset, le);
|
||||||
const radius = dv.getFloat64(recOffset + BYTES_PER_DOUBLE, le)
|
const radius = dv.getFloat64(recOffset + BYTES_PER_DOUBLE, le);
|
||||||
const x = (et - mid) / radius
|
const x = (et - mid) / radius;
|
||||||
|
|
||||||
const xPC = readCoeffs(dv, recOffset + 2 * BYTES_PER_DOUBLE, nCoeffs, le)
|
const xPC = readCoeffs(dv, recOffset + 2 * BYTES_PER_DOUBLE, nCoeffs, le);
|
||||||
const yPC = readCoeffs(dv, recOffset + (2 + nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le)
|
const yPC = readCoeffs(dv, recOffset + (2 + nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le);
|
||||||
const zPC = readCoeffs(dv, recOffset + (2 + 2 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le)
|
const zPC = readCoeffs(dv, recOffset + (2 + 2 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le);
|
||||||
const xVC = readCoeffs(dv, recOffset + (2 + 3 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le)
|
const xVC = readCoeffs(dv, recOffset + (2 + 3 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le);
|
||||||
const yVC = readCoeffs(dv, recOffset + (2 + 4 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le)
|
const yVC = readCoeffs(dv, recOffset + (2 + 4 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le);
|
||||||
const zVC = readCoeffs(dv, recOffset + (2 + 5 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le)
|
const zVC = readCoeffs(dv, recOffset + (2 + 5 * nCoeffs) * BYTES_PER_DOUBLE, nCoeffs, le);
|
||||||
|
|
||||||
const px = chebyshevEvalWithDerivative(xPC, x, radius)[0]
|
const px = chebyshevEvalWithDerivative(xPC, x, radius)[0];
|
||||||
const py = chebyshevEvalWithDerivative(yPC, x, radius)[0]
|
const py = chebyshevEvalWithDerivative(yPC, x, radius)[0];
|
||||||
const pz = chebyshevEvalWithDerivative(zPC, x, radius)[0]
|
const pz = chebyshevEvalWithDerivative(zPC, x, radius)[0];
|
||||||
// Type 3: velocity polynomial evaluated at x gives km/s directly
|
// Type 3: velocity polynomial evaluated at x gives km/s directly
|
||||||
const vx = chebyshevEvalWithDerivative(xVC, x, radius)[0]
|
const vx = chebyshevEvalWithDerivative(xVC, x, radius)[0];
|
||||||
const vy = chebyshevEvalWithDerivative(yVC, x, radius)[0]
|
const vy = chebyshevEvalWithDerivative(yVC, x, radius)[0];
|
||||||
const vz = chebyshevEvalWithDerivative(zVC, x, radius)[0]
|
const vz = chebyshevEvalWithDerivative(zVC, x, radius)[0];
|
||||||
|
|
||||||
return { position: [px, py, pz], velocity: [vx, vy, vz] }
|
return { position: [px, py, pz], velocity: [vx, vy, vz] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function readCoeffs(dv: DataView, offset: number, n: number, le: boolean): Float64Array {
|
function readCoeffs(dv: DataView, offset: number, n: number, le: boolean): Float64Array {
|
||||||
const arr = new Float64Array(n)
|
const arr = new Float64Array(n);
|
||||||
for (let k = 0; k < n; k++) {
|
for (let k = 0; k < n; k++) {
|
||||||
arr[k] = dv.getFloat64(offset + k * BYTES_PER_DOUBLE, le)
|
arr[k] = dv.getFloat64(offset + k * BYTES_PER_DOUBLE, le);
|
||||||
}
|
}
|
||||||
return arr
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function subtractSV(a: StateVector, b: StateVector): StateVector {
|
function subtractSV(a: StateVector, b: StateVector): StateVector {
|
||||||
|
|
@ -371,7 +371,7 @@ function subtractSV(a: StateVector, b: StateVector): StateVector {
|
||||||
a.velocity[1] - b.velocity[1],
|
a.velocity[1] - b.velocity[1],
|
||||||
a.velocity[2] - b.velocity[2],
|
a.velocity[2] - b.velocity[2],
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Leap-second kernel ───────────────────────────────────────────────────────
|
// ─── Leap-second kernel ───────────────────────────────────────────────────────
|
||||||
|
|
@ -381,12 +381,12 @@ function subtractSV(a: StateVector, b: StateVector): StateVector {
|
||||||
* Extracts DELTET/DELTA_AT pairs and converts to (JD_UTC, deltaAT) pairs.
|
* Extracts DELTET/DELTA_AT pairs and converts to (JD_UTC, deltaAT) pairs.
|
||||||
*/
|
*/
|
||||||
export function parseLsk(text: string): ReadonlyArray<readonly [number, number]> {
|
export function parseLsk(text: string): ReadonlyArray<readonly [number, number]> {
|
||||||
const results: [number, number][] = []
|
const results: [number, number][] = [];
|
||||||
|
|
||||||
const match = text.match(/DELTET\/DELTA_AT\s*=\s*\(\s*([\s\S]*?)\)/m)
|
const match = text.match(/DELTET\/DELTA_AT\s*=\s*\(\s*([\s\S]*?)\)/m);
|
||||||
if (!match) return results
|
if (!match) return results;
|
||||||
|
|
||||||
const block = match[1]! // regex has one capture group; match[1] is present when match succeeds
|
const block = match[1]!; // regex has one capture group; match[1] is present when match succeeds
|
||||||
const months: Record<string, number> = {
|
const months: Record<string, number> = {
|
||||||
JAN: 1,
|
JAN: 1,
|
||||||
FEB: 2,
|
FEB: 2,
|
||||||
|
|
@ -400,22 +400,22 @@ export function parseLsk(text: string): ReadonlyArray<readonly [number, number]>
|
||||||
OCT: 10,
|
OCT: 10,
|
||||||
NOV: 11,
|
NOV: 11,
|
||||||
DEC: 12,
|
DEC: 12,
|
||||||
}
|
};
|
||||||
|
|
||||||
const pairRe = /(-?\d+(?:\.\d+)?)\s*,\s*@(\d{4})-([A-Z]{3})-(\d{1,2})/g
|
const pairRe = /(-?\d+(?:\.\d+)?)\s*,\s*@(\d{4})-([A-Z]{3})-(\d{1,2})/g;
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((m = pairRe.exec(block)) !== null) {
|
while ((m = pairRe.exec(block)) !== null) {
|
||||||
// m[1]..m[4] are capture groups; regex guarantees they are present when exec matches
|
// m[1]..m[4] are capture groups; regex guarantees they are present when exec matches
|
||||||
const deltaAT = parseFloat(m[1]!)
|
const deltaAT = parseFloat(m[1]!);
|
||||||
const year = parseInt(m[2]!)
|
const year = parseInt(m[2]!);
|
||||||
const month = months[m[3]!] ?? 1
|
const month = months[m[3]!] ?? 1;
|
||||||
const day = parseInt(m[4]!)
|
const day = parseInt(m[4]!);
|
||||||
|
|
||||||
// Gregorian to JD (noon = integer JD)
|
// Gregorian to JD (noon = integer JD)
|
||||||
const a = Math.floor((14 - month) / 12)
|
const a = Math.floor((14 - month) / 12);
|
||||||
const y = year + 4800 - a
|
const y = year + 4800 - a;
|
||||||
const mo = month + 12 * a - 3
|
const mo = month + 12 * a - 3;
|
||||||
const jdNoon =
|
const jdNoon =
|
||||||
day +
|
day +
|
||||||
Math.floor((153 * mo + 2) / 5) +
|
Math.floor((153 * mo + 2) / 5) +
|
||||||
|
|
@ -423,10 +423,10 @@ export function parseLsk(text: string): ReadonlyArray<readonly [number, number]>
|
||||||
Math.floor(y / 4) -
|
Math.floor(y / 4) -
|
||||||
Math.floor(y / 100) +
|
Math.floor(y / 100) +
|
||||||
Math.floor(y / 400) -
|
Math.floor(y / 400) -
|
||||||
32045
|
32045;
|
||||||
// Midnight = JD - 0.5
|
// Midnight = JD - 0.5
|
||||||
results.push([jdNoon - 0.5, deltaAT])
|
results.push([jdNoon - 0.5, deltaAT]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,21 +19,21 @@
|
||||||
* Espenak & Meeus — ΔT polynomial expressions
|
* Espenak & Meeus — ΔT polynomial expressions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TimeScales } from '../types.js'
|
import type { TimeScales } from "../types.js";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Julian Date of J2000.0 epoch (2000 Jan 1, 12:00 TT) */
|
/** Julian Date of J2000.0 epoch (2000 Jan 1, 12:00 TT) */
|
||||||
export const J2000 = 2451545.0
|
export const J2000 = 2451545.0;
|
||||||
|
|
||||||
/** TT - TAI offset in seconds (exact, by definition) */
|
/** TT - TAI offset in seconds (exact, by definition) */
|
||||||
export const TT_MINUS_TAI = 32.184
|
export const TT_MINUS_TAI = 32.184;
|
||||||
|
|
||||||
/** Seconds per day */
|
/** Seconds per day */
|
||||||
export const SECONDS_PER_DAY = 86400.0
|
export const SECONDS_PER_DAY = 86400.0;
|
||||||
|
|
||||||
/** Days per Julian century */
|
/** Days per Julian century */
|
||||||
export const DAYS_PER_JULIAN_CENTURY = 36525.0
|
export const DAYS_PER_JULIAN_CENTURY = 36525.0;
|
||||||
|
|
||||||
// ─── Leap-second table ────────────────────────────────────────────────────────
|
// ─── Leap-second table ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -73,19 +73,19 @@ export const LEAP_SECOND_TABLE: ReadonlyArray<readonly [number, number]> = [
|
||||||
[2456109.5, 35], // 2012 Jul 1
|
[2456109.5, 35], // 2012 Jul 1
|
||||||
[2457204.5, 36], // 2015 Jul 1
|
[2457204.5, 36], // 2015 Jul 1
|
||||||
[2457754.5, 37], // 2017 Jan 1
|
[2457754.5, 37], // 2017 Jan 1
|
||||||
] as const
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current leap second count (TAI - UTC) for a given JD in UTC.
|
* Get the current leap second count (TAI - UTC) for a given JD in UTC.
|
||||||
* Returns 10 for dates before 1972 (the first leap second era).
|
* Returns 10 for dates before 1972 (the first leap second era).
|
||||||
*/
|
*/
|
||||||
export function getDeltaAT(jdUTC: number): number {
|
export function getDeltaAT(jdUTC: number): number {
|
||||||
let deltaAT = 10
|
let deltaAT = 10;
|
||||||
for (const [jd, dat] of LEAP_SECOND_TABLE) {
|
for (const [jd, dat] of LEAP_SECOND_TABLE) {
|
||||||
if (jdUTC >= jd) deltaAT = dat
|
if (jdUTC >= jd) deltaAT = dat;
|
||||||
else break
|
else break;
|
||||||
}
|
}
|
||||||
return deltaAT
|
return deltaAT;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Julian Date ─────────────────────────────────────────────────────────────
|
// ─── Julian Date ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -95,14 +95,14 @@ export function getDeltaAT(jdUTC: number): number {
|
||||||
* Uses the standard formula; valid for dates after the Gregorian reform.
|
* Uses the standard formula; valid for dates after the Gregorian reform.
|
||||||
*/
|
*/
|
||||||
export function dateToJD(date: Date): number {
|
export function dateToJD(date: Date): number {
|
||||||
return date.getTime() / 86400000 + 2440587.5
|
return date.getTime() / 86400000 + 2440587.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a Julian Date in UTC to a JavaScript Date.
|
* Convert a Julian Date in UTC to a JavaScript Date.
|
||||||
*/
|
*/
|
||||||
export function jdToDate(jd: number): Date {
|
export function jdToDate(jd: number): Date {
|
||||||
return new Date((jd - 2440587.5) * 86400000)
|
return new Date((jd - 2440587.5) * 86400000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -110,7 +110,7 @@ export function jdToDate(jd: number): Date {
|
||||||
* Used as the standard argument for precession and nutation polynomials.
|
* Used as the standard argument for precession and nutation polynomials.
|
||||||
*/
|
*/
|
||||||
export function jdTTtoT(jdTT: number): number {
|
export function jdTTtoT(jdTT: number): number {
|
||||||
return (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY
|
return (jdTT - J2000) / DAYS_PER_JULIAN_CENTURY;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Time scale conversions ───────────────────────────────────────────────────
|
// ─── Time scale conversions ───────────────────────────────────────────────────
|
||||||
|
|
@ -129,33 +129,33 @@ export function computeTimeScales(
|
||||||
ut1utcOverride?: number,
|
ut1utcOverride?: number,
|
||||||
deltaTOverride?: number,
|
deltaTOverride?: number,
|
||||||
): TimeScales {
|
): TimeScales {
|
||||||
const jdUTC = dateToJD(utc)
|
const jdUTC = dateToJD(utc);
|
||||||
const deltaAT = getDeltaAT(jdUTC)
|
const deltaAT = getDeltaAT(jdUTC);
|
||||||
|
|
||||||
// UTC → TAI → TT
|
// UTC → TAI → TT
|
||||||
const jdTAI = jdUTC + deltaAT / SECONDS_PER_DAY
|
const jdTAI = jdUTC + deltaAT / SECONDS_PER_DAY;
|
||||||
const jdTT = jdTAI + TT_MINUS_TAI / SECONDS_PER_DAY
|
const jdTT = jdTAI + TT_MINUS_TAI / SECONDS_PER_DAY;
|
||||||
|
|
||||||
// TT → TDB (periodic correction, sub-millisecond)
|
// TT → TDB (periodic correction, sub-millisecond)
|
||||||
const tdbCorrection = tdbMinusTT(jdTT) / SECONDS_PER_DAY
|
const tdbCorrection = tdbMinusTT(jdTT) / SECONDS_PER_DAY;
|
||||||
const jdTDB = jdTT + tdbCorrection
|
const jdTDB = jdTT + tdbCorrection;
|
||||||
|
|
||||||
// UT1
|
// UT1
|
||||||
let jdUT1: number
|
let jdUT1: number;
|
||||||
let deltaT: number
|
let deltaT: number;
|
||||||
|
|
||||||
if (ut1utcOverride !== undefined) {
|
if (ut1utcOverride !== undefined) {
|
||||||
jdUT1 = jdUTC + ut1utcOverride / SECONDS_PER_DAY
|
jdUT1 = jdUTC + ut1utcOverride / SECONDS_PER_DAY;
|
||||||
deltaT = (jdTT - jdUT1) * SECONDS_PER_DAY
|
deltaT = (jdTT - jdUT1) * SECONDS_PER_DAY;
|
||||||
} else if (deltaTOverride !== undefined) {
|
} else if (deltaTOverride !== undefined) {
|
||||||
deltaT = deltaTOverride
|
deltaT = deltaTOverride;
|
||||||
jdUT1 = jdTT - deltaT / SECONDS_PER_DAY
|
jdUT1 = jdTT - deltaT / SECONDS_PER_DAY;
|
||||||
} else {
|
} else {
|
||||||
deltaT = deltaTPolynomial(jdTT)
|
deltaT = deltaTPolynomial(jdTT);
|
||||||
jdUT1 = jdTT - deltaT / SECONDS_PER_DAY
|
jdUT1 = jdTT - deltaT / SECONDS_PER_DAY;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { utc, jdUTC, jdTT, jdTDB, jdUT1, deltaT, deltaAT }
|
return { utc, jdUTC, jdTT, jdTDB, jdUT1, deltaT, deltaAT };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -165,8 +165,8 @@ export function computeTimeScales(
|
||||||
* gives the proper SPICE-compatible ET value.
|
* gives the proper SPICE-compatible ET value.
|
||||||
*/
|
*/
|
||||||
export function jdTTtoET(jdTT: number): number {
|
export function jdTTtoET(jdTT: number): number {
|
||||||
const tdbCorr = tdbMinusTT(jdTT)
|
const tdbCorr = tdbMinusTT(jdTT);
|
||||||
return (jdTT - J2000) * SECONDS_PER_DAY + tdbCorr
|
return (jdTT - J2000) * SECONDS_PER_DAY + tdbCorr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -179,11 +179,11 @@ export function jdTTtoET(jdTT: number): number {
|
||||||
* Maximum error: ~30 microseconds (acceptable for crescent work).
|
* Maximum error: ~30 microseconds (acceptable for crescent work).
|
||||||
*/
|
*/
|
||||||
export function tdbMinusTT(jdTT: number): number {
|
export function tdbMinusTT(jdTT: number): number {
|
||||||
const d = jdTT - J2000
|
const d = jdTT - J2000;
|
||||||
// Mean anomaly of the Sun (degrees)
|
// Mean anomaly of the Sun (degrees)
|
||||||
const gDeg = 357.53 + 0.9856003 * d
|
const gDeg = 357.53 + 0.9856003 * d;
|
||||||
const g = (gDeg * Math.PI) / 180
|
const g = (gDeg * Math.PI) / 180;
|
||||||
return 0.001658 * Math.sin(g) + 0.000014 * Math.sin(2 * g)
|
return 0.001658 * Math.sin(g) + 0.000014 * Math.sin(2 * g);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -194,13 +194,13 @@ export function tdbMinusTT(jdTT: number): number {
|
||||||
*/
|
*/
|
||||||
export function deltaTPolynomial(jdTT: number): number {
|
export function deltaTPolynomial(jdTT: number): number {
|
||||||
// Convert JD to decimal year
|
// Convert JD to decimal year
|
||||||
const y = 2000 + (jdTT - J2000) / 365.25
|
const y = 2000 + (jdTT - J2000) / 365.25;
|
||||||
|
|
||||||
if (y < -500) {
|
if (y < -500) {
|
||||||
const u = (y - 1820) / 100
|
const u = (y - 1820) / 100;
|
||||||
return -20 + 32 * u * u
|
return -20 + 32 * u * u;
|
||||||
} else if (y < 500) {
|
} else if (y < 500) {
|
||||||
const u = y / 100
|
const u = y / 100;
|
||||||
return (
|
return (
|
||||||
10583.6 -
|
10583.6 -
|
||||||
1014.41 * u +
|
1014.41 * u +
|
||||||
|
|
@ -209,9 +209,9 @@ export function deltaTPolynomial(jdTT: number): number {
|
||||||
0.1798452 * u ** 4 +
|
0.1798452 * u ** 4 +
|
||||||
0.022174192 * u ** 5 +
|
0.022174192 * u ** 5 +
|
||||||
0.0090316521 * u ** 6
|
0.0090316521 * u ** 6
|
||||||
)
|
);
|
||||||
} else if (y < 1600) {
|
} else if (y < 1600) {
|
||||||
const u = (y - 1000) / 100
|
const u = (y - 1000) / 100;
|
||||||
return (
|
return (
|
||||||
1574.2 -
|
1574.2 -
|
||||||
556.01 * u +
|
556.01 * u +
|
||||||
|
|
@ -220,15 +220,15 @@ export function deltaTPolynomial(jdTT: number): number {
|
||||||
0.8503463 * u ** 4 -
|
0.8503463 * u ** 4 -
|
||||||
0.005050998 * u ** 5 +
|
0.005050998 * u ** 5 +
|
||||||
0.0083572073 * u ** 6
|
0.0083572073 * u ** 6
|
||||||
)
|
);
|
||||||
} else if (y < 1700) {
|
} else if (y < 1700) {
|
||||||
const t = y - 1600
|
const t = y - 1600;
|
||||||
return 120 - 0.9808 * t - 0.01532 * t * t + t ** 3 / 7129
|
return 120 - 0.9808 * t - 0.01532 * t * t + t ** 3 / 7129;
|
||||||
} else if (y < 1800) {
|
} else if (y < 1800) {
|
||||||
const t = y - 1700
|
const t = y - 1700;
|
||||||
return 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) {
|
} else if (y < 1860) {
|
||||||
const t = y - 1800
|
const t = y - 1800;
|
||||||
return (
|
return (
|
||||||
13.72 -
|
13.72 -
|
||||||
0.332447 * t +
|
0.332447 * t +
|
||||||
|
|
@ -238,9 +238,9 @@ export function deltaTPolynomial(jdTT: number): number {
|
||||||
0.0000121272 * t ** 5 -
|
0.0000121272 * t ** 5 -
|
||||||
0.0000001699 * t ** 6 +
|
0.0000001699 * t ** 6 +
|
||||||
0.000000000875 * t ** 7
|
0.000000000875 * t ** 7
|
||||||
)
|
);
|
||||||
} else if (y < 1900) {
|
} else if (y < 1900) {
|
||||||
const t = y - 1860
|
const t = y - 1860;
|
||||||
return (
|
return (
|
||||||
7.62 +
|
7.62 +
|
||||||
0.5737 * t -
|
0.5737 * t -
|
||||||
|
|
@ -248,21 +248,21 @@ export function deltaTPolynomial(jdTT: number): number {
|
||||||
0.01680668 * t ** 3 -
|
0.01680668 * t ** 3 -
|
||||||
0.0004473624 * t ** 4 +
|
0.0004473624 * t ** 4 +
|
||||||
t ** 5 / 233174
|
t ** 5 / 233174
|
||||||
)
|
);
|
||||||
} else if (y < 1920) {
|
} else if (y < 1920) {
|
||||||
const t = y - 1900
|
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) {
|
} else if (y < 1941) {
|
||||||
const t = y - 1920
|
const t = y - 1920;
|
||||||
return 21.2 + 0.84493 * t - 0.0761 * t * t + 0.0020936 * t ** 3
|
return 21.2 + 0.84493 * t - 0.0761 * t * t + 0.0020936 * t ** 3;
|
||||||
} else if (y < 1961) {
|
} else if (y < 1961) {
|
||||||
const t = y - 1950
|
const t = y - 1950;
|
||||||
return 29.07 + 0.407 * t - (t * t) / 233 + t ** 3 / 2547
|
return 29.07 + 0.407 * t - (t * t) / 233 + t ** 3 / 2547;
|
||||||
} else if (y < 1986) {
|
} else if (y < 1986) {
|
||||||
const t = y - 1975
|
const t = y - 1975;
|
||||||
return 45.45 + 1.067 * t - (t * t) / 260 - t ** 3 / 718
|
return 45.45 + 1.067 * t - (t * t) / 260 - t ** 3 / 718;
|
||||||
} else if (y < 2005) {
|
} else if (y < 2005) {
|
||||||
const t = y - 2000
|
const t = y - 2000;
|
||||||
return (
|
return (
|
||||||
63.86 +
|
63.86 +
|
||||||
0.3345 * t -
|
0.3345 * t -
|
||||||
|
|
@ -270,14 +270,14 @@ export function deltaTPolynomial(jdTT: number): number {
|
||||||
0.0017275 * t ** 3 +
|
0.0017275 * t ** 3 +
|
||||||
0.000651814 * t ** 4 +
|
0.000651814 * t ** 4 +
|
||||||
0.00002373599 * t ** 5
|
0.00002373599 * t ** 5
|
||||||
)
|
);
|
||||||
} else if (y < 2050) {
|
} else if (y < 2050) {
|
||||||
const t = y - 2000
|
const t = y - 2000;
|
||||||
return 62.92 + 0.32217 * t + 0.005589 * t * t
|
return 62.92 + 0.32217 * t + 0.005589 * t * t;
|
||||||
} else if (y < 2150) {
|
} else if (y < 2150) {
|
||||||
return -20 + 32 * ((y - 1820) / 100) ** 2 - 0.5628 * (2150 - y)
|
return -20 + 32 * ((y - 1820) / 100) ** 2 - 0.5628 * (2150 - y);
|
||||||
} else {
|
} else {
|
||||||
const u = (y - 1820) / 100
|
const u = (y - 1820) / 100;
|
||||||
return -20 + 32 * u * u
|
return -20 + 32 * u * u;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
286
src/types.ts
286
src/types.ts
|
|
@ -1,20 +1,20 @@
|
||||||
// ─── Primitive geometry ──────────────────────────────────────────────────────
|
// ─── Primitive geometry ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** 3-element position or velocity vector in km or km/s */
|
/** 3-element position or velocity vector in km or km/s */
|
||||||
export type Vec3 = [number, number, number]
|
export type Vec3 = [number, number, number];
|
||||||
|
|
||||||
/** Position + velocity state vector from the ephemeris */
|
/** Position + velocity state vector from the ephemeris */
|
||||||
export interface StateVector {
|
export interface StateVector {
|
||||||
position: Vec3 // km, in the frame determined by context
|
position: Vec3; // km, in the frame determined by context
|
||||||
velocity: Vec3 // km/s
|
velocity: Vec3; // km/s
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Azimuth + altitude in degrees */
|
/** Azimuth + altitude in degrees */
|
||||||
export interface AzAlt {
|
export interface AzAlt {
|
||||||
/** Degrees from North, measured clockwise (0 = N, 90 = E, 180 = S, 270 = W) */
|
/** Degrees from North, measured clockwise (0 = N, 90 = E, 180 = S, 270 = W) */
|
||||||
azimuth: number
|
azimuth: number;
|
||||||
/** Degrees above the horizon (negative = below) */
|
/** Degrees above the horizon (negative = below) */
|
||||||
altitude: number
|
altitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Kernel-free moon results ─────────────────────────────────────────────────
|
// ─── Kernel-free moon results ─────────────────────────────────────────────────
|
||||||
|
|
@ -26,17 +26,17 @@ export interface AzAlt {
|
||||||
*/
|
*/
|
||||||
export interface MoonPosition {
|
export interface MoonPosition {
|
||||||
/** Azimuth in degrees from North, measured clockwise (0 = N, 90 = E, 180 = S, 270 = W) */
|
/** Azimuth in degrees from North, measured clockwise (0 = N, 90 = E, 180 = S, 270 = W) */
|
||||||
azimuth: number
|
azimuth: number;
|
||||||
/** Apparent altitude in degrees above the horizon (atmospheric refraction applied) */
|
/** Apparent altitude in degrees above the horizon (atmospheric refraction applied) */
|
||||||
altitude: number
|
altitude: number;
|
||||||
/** Distance from Earth center to Moon center, km */
|
/** Distance from Earth center to Moon center, km */
|
||||||
distance: number
|
distance: number;
|
||||||
/**
|
/**
|
||||||
* Parallactic angle in radians.
|
* Parallactic angle in radians.
|
||||||
* The angle between the great circle through the Moon and zenith, and the great circle
|
* The angle between the great circle through the Moon and zenith, and the great circle
|
||||||
* through the Moon and the north celestial pole. Positive east of the meridian.
|
* through the Moon and the north celestial pole. Positive east of the meridian.
|
||||||
*/
|
*/
|
||||||
parallacticAngle: number
|
parallacticAngle: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,38 +46,38 @@ export interface MoonPosition {
|
||||||
*/
|
*/
|
||||||
export interface MoonIlluminationResult {
|
export interface MoonIlluminationResult {
|
||||||
/** Illuminated fraction of the Moon disk, 0 (new moon) to 1 (full moon) */
|
/** Illuminated fraction of the Moon disk, 0 (new moon) to 1 (full moon) */
|
||||||
fraction: number
|
fraction: number;
|
||||||
/**
|
/**
|
||||||
* Phase cycle fraction in [0, 1):
|
* Phase cycle fraction in [0, 1):
|
||||||
* 0 = new moon, 0.25 = first quarter, 0.5 = full moon, 0.75 = last quarter
|
* 0 = new moon, 0.25 = first quarter, 0.5 = full moon, 0.75 = last quarter
|
||||||
*/
|
*/
|
||||||
phase: number
|
phase: number;
|
||||||
/**
|
/**
|
||||||
* Position angle of the midpoint of the bright limb, measured eastward from
|
* Position angle of the midpoint of the bright limb, measured eastward from
|
||||||
* the north celestial pole, in radians. Matches the suncalc convention.
|
* the north celestial pole, in radians. Matches the suncalc convention.
|
||||||
*/
|
*/
|
||||||
angle: number
|
angle: number;
|
||||||
/** True while elongation is increasing (new moon toward full moon) */
|
/** True while elongation is increasing (new moon toward full moon) */
|
||||||
isWaxing: boolean
|
isWaxing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Time ────────────────────────────────────────────────────────────────────
|
// ─── Time ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** All relevant time scale values for a single moment */
|
/** All relevant time scale values for a single moment */
|
||||||
export interface TimeScales {
|
export interface TimeScales {
|
||||||
utc: Date
|
utc: Date;
|
||||||
/** Julian Date in UTC */
|
/** Julian Date in UTC */
|
||||||
jdUTC: number
|
jdUTC: number;
|
||||||
/** Julian Date in Terrestrial Time (TT = TAI + 32.184s) */
|
/** Julian Date in Terrestrial Time (TT = TAI + 32.184s) */
|
||||||
jdTT: number
|
jdTT: number;
|
||||||
/** Julian Date in Barycentric Dynamical Time (used by JPL ephemerides) */
|
/** Julian Date in Barycentric Dynamical Time (used by JPL ephemerides) */
|
||||||
jdTDB: number
|
jdTDB: number;
|
||||||
/** Julian Date in UT1 (Earth rotation time) */
|
/** Julian Date in UT1 (Earth rotation time) */
|
||||||
jdUT1: number
|
jdUT1: number;
|
||||||
/** TT - UT1 in seconds (delta-T) */
|
/** TT - UT1 in seconds (delta-T) */
|
||||||
deltaT: number
|
deltaT: number;
|
||||||
/** TAI - UTC in seconds (leap seconds count) */
|
/** TAI - UTC in seconds (leap seconds count) */
|
||||||
deltaAT: number
|
deltaAT: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Observer ────────────────────────────────────────────────────────────────
|
// ─── Observer ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -85,28 +85,28 @@ export interface TimeScales {
|
||||||
/** Observer location and environmental parameters */
|
/** Observer location and environmental parameters */
|
||||||
export interface Observer {
|
export interface Observer {
|
||||||
/** Geodetic latitude in degrees (north positive) */
|
/** Geodetic latitude in degrees (north positive) */
|
||||||
lat: number
|
lat: number;
|
||||||
/** Longitude in degrees (east positive) */
|
/** Longitude in degrees (east positive) */
|
||||||
lon: number
|
lon: number;
|
||||||
/** Height above WGS84 ellipsoid in meters */
|
/** Height above WGS84 ellipsoid in meters */
|
||||||
elevation: number
|
elevation: number;
|
||||||
/** Optional label for the location */
|
/** Optional label for the location */
|
||||||
name?: string
|
name?: string;
|
||||||
/**
|
/**
|
||||||
* Override TT - UT1 in seconds.
|
* Override TT - UT1 in seconds.
|
||||||
* When provided, used directly. Otherwise the built-in polynomial is used.
|
* When provided, used directly. Otherwise the built-in polynomial is used.
|
||||||
* For maximum accuracy, supply the current IERS value (typically within ±0.9s).
|
* For maximum accuracy, supply the current IERS value (typically within ±0.9s).
|
||||||
*/
|
*/
|
||||||
deltaT?: number
|
deltaT?: number;
|
||||||
/**
|
/**
|
||||||
* Override UT1 - UTC in seconds (from IERS Bulletin A).
|
* Override UT1 - UTC in seconds (from IERS Bulletin A).
|
||||||
* Takes precedence over deltaT when both are provided.
|
* Takes precedence over deltaT when both are provided.
|
||||||
*/
|
*/
|
||||||
ut1utc?: number
|
ut1utc?: number;
|
||||||
/** Atmospheric pressure in millibars (default 1013.25) */
|
/** Atmospheric pressure in millibars (default 1013.25) */
|
||||||
pressure?: number
|
pressure?: number;
|
||||||
/** Ambient temperature in Celsius (default 15) */
|
/** Ambient temperature in Celsius (default 15) */
|
||||||
temperature?: number
|
temperature?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Crescent geometry ───────────────────────────────────────────────────────
|
// ─── Crescent geometry ───────────────────────────────────────────────────────
|
||||||
|
|
@ -117,31 +117,31 @@ export interface Observer {
|
||||||
*/
|
*/
|
||||||
export interface CrescentGeometry {
|
export interface CrescentGeometry {
|
||||||
/** Arc of light: topocentric Sun-Moon angular separation (elongation), degrees */
|
/** Arc of light: topocentric Sun-Moon angular separation (elongation), degrees */
|
||||||
ARCL: number
|
ARCL: number;
|
||||||
/**
|
/**
|
||||||
* Arc of vision: Moon airless altitude minus Sun airless altitude, degrees.
|
* Arc of vision: Moon airless altitude minus Sun airless altitude, degrees.
|
||||||
* Used as the primary visibility discriminant in both Yallop and Odeh.
|
* Used as the primary visibility discriminant in both Yallop and Odeh.
|
||||||
*/
|
*/
|
||||||
ARCV: number
|
ARCV: number;
|
||||||
/**
|
/**
|
||||||
* Relative azimuth: Sun azimuth minus Moon azimuth, normalized to [-180, 180], degrees.
|
* Relative azimuth: Sun azimuth minus Moon azimuth, normalized to [-180, 180], degrees.
|
||||||
* Positive = Moon north of Sun.
|
* Positive = Moon north of Sun.
|
||||||
*/
|
*/
|
||||||
DAZ: number
|
DAZ: number;
|
||||||
/**
|
/**
|
||||||
* Topocentric crescent width in arc minutes.
|
* Topocentric crescent width in arc minutes.
|
||||||
* Used directly in Odeh's polynomial V expression.
|
* Used directly in Odeh's polynomial V expression.
|
||||||
*/
|
*/
|
||||||
W: number
|
W: number;
|
||||||
/** Moonset minus sunset in minutes. Negative = Moon sets before Sun (no sighting possible). */
|
/** Moonset minus sunset in minutes. Negative = Moon sets before Sun (no sighting possible). */
|
||||||
lag: number
|
lag: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Yallop q-test ───────────────────────────────────────────────────────────
|
// ─── Yallop q-test ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Yallop q-test visibility category (NAO Technical Note 69) */
|
/** Yallop q-test visibility category (NAO Technical Note 69) */
|
||||||
/** Yallop visibility category (A = easily visible, F = below Danjon limit). */
|
/** Yallop visibility category (A = easily visible, F = below Danjon limit). */
|
||||||
export type YallopCategory = 'A' | 'B' | 'C' | 'D' | 'E' | 'F'
|
export type YallopCategory = "A" | "B" | "C" | "D" | "E" | "F";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Published q thresholds (Yallop 1997, NAO TN 69):
|
* Published q thresholds (Yallop 1997, NAO TN 69):
|
||||||
|
|
@ -158,43 +158,43 @@ export const YALLOP_THRESHOLDS = {
|
||||||
C: -0.16,
|
C: -0.16,
|
||||||
D: -0.232,
|
D: -0.232,
|
||||||
E: -0.293,
|
E: -0.293,
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable descriptions for each Yallop visibility category (A–F).
|
* Human-readable descriptions for each Yallop visibility category (A–F).
|
||||||
* Sourced from Yallop (NAO TN 69, 1997).
|
* Sourced from Yallop (NAO TN 69, 1997).
|
||||||
*/
|
*/
|
||||||
export const YALLOP_DESCRIPTIONS: Record<YallopCategory, string> = {
|
export const YALLOP_DESCRIPTIONS: Record<YallopCategory, string> = {
|
||||||
A: 'Easily visible to the naked eye',
|
A: "Easily visible to the naked eye",
|
||||||
B: 'Visible under perfect conditions',
|
B: "Visible under perfect conditions",
|
||||||
C: 'May need optical aid to find; naked eye possible',
|
C: "May need optical aid to find; naked eye possible",
|
||||||
D: 'Optical aid needed; naked eye not possible',
|
D: "Optical aid needed; naked eye not possible",
|
||||||
E: 'Not visible even with telescope under good conditions',
|
E: "Not visible even with telescope under good conditions",
|
||||||
F: 'Below Danjon limit — crescent cannot form',
|
F: "Below Danjon limit — crescent cannot form",
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface YallopResult {
|
export interface YallopResult {
|
||||||
/** The continuous q parameter (higher = more visible) */
|
/** The continuous q parameter (higher = more visible) */
|
||||||
q: number
|
q: number;
|
||||||
/** Visibility category A through F */
|
/** Visibility category A through F */
|
||||||
category: YallopCategory
|
category: YallopCategory;
|
||||||
/** Human-readable interpretation */
|
/** Human-readable interpretation */
|
||||||
description: string
|
description: string;
|
||||||
/** True for categories A and B */
|
/** True for categories A and B */
|
||||||
isVisibleNakedEye: boolean
|
isVisibleNakedEye: boolean;
|
||||||
/** True for categories C and D */
|
/** True for categories C and D */
|
||||||
requiresOpticalAid: boolean
|
requiresOpticalAid: boolean;
|
||||||
/** True for category F */
|
/** True for category F */
|
||||||
isBelowDanjonLimit: boolean
|
isBelowDanjonLimit: boolean;
|
||||||
/** Topocentric crescent width W' used in the q formula, arc minutes */
|
/** Topocentric crescent width W' used in the q formula, arc minutes */
|
||||||
Wprime: number
|
Wprime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Odeh criterion ──────────────────────────────────────────────────────────
|
// ─── Odeh criterion ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Odeh visibility zone (Experimental Astronomy 2006) */
|
/** Odeh visibility zone (Experimental Astronomy 2006) */
|
||||||
/** Odeh visibility zone (A = naked eye visible, D = not visible with any aid). */
|
/** Odeh visibility zone (A = naked eye visible, D = not visible with any aid). */
|
||||||
export type OdehZone = 'A' | 'B' | 'C' | 'D'
|
export type OdehZone = "A" | "B" | "C" | "D";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Published V thresholds (Odeh 2006):
|
* Published V thresholds (Odeh 2006):
|
||||||
|
|
@ -207,33 +207,33 @@ export const ODEH_THRESHOLDS = {
|
||||||
A: 5.65,
|
A: 5.65,
|
||||||
B: 2.0,
|
B: 2.0,
|
||||||
C: -0.96,
|
C: -0.96,
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable descriptions for each Odeh visibility zone (A–D).
|
* Human-readable descriptions for each Odeh visibility zone (A–D).
|
||||||
* Sourced from Odeh (Experimental Astronomy, 2006).
|
* Sourced from Odeh (Experimental Astronomy, 2006).
|
||||||
*/
|
*/
|
||||||
export const ODEH_DESCRIPTIONS: Record<OdehZone, string> = {
|
export const ODEH_DESCRIPTIONS: Record<OdehZone, string> = {
|
||||||
A: 'Visible with naked eye',
|
A: "Visible with naked eye",
|
||||||
B: 'Visible with optical aid; may be seen with naked eye under excellent conditions',
|
B: "Visible with optical aid; may be seen with naked eye under excellent conditions",
|
||||||
C: 'Visible with optical aid only',
|
C: "Visible with optical aid only",
|
||||||
D: 'Not visible even with optical aid',
|
D: "Not visible even with optical aid",
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface OdehResult {
|
export interface OdehResult {
|
||||||
/**
|
/**
|
||||||
* Continuous visibility parameter V = ARCV - (arcv_minimum(W)).
|
* Continuous visibility parameter V = ARCV - (arcv_minimum(W)).
|
||||||
* Positive = crescent exceeds minimum visibility threshold.
|
* Positive = crescent exceeds minimum visibility threshold.
|
||||||
*/
|
*/
|
||||||
V: number
|
V: number;
|
||||||
/** Visibility zone A through D */
|
/** Visibility zone A through D */
|
||||||
zone: OdehZone
|
zone: OdehZone;
|
||||||
/** Human-readable interpretation */
|
/** Human-readable interpretation */
|
||||||
description: string
|
description: string;
|
||||||
/** True for zone A */
|
/** True for zone A */
|
||||||
isVisibleNakedEye: boolean
|
isVisibleNakedEye: boolean;
|
||||||
/** True for zones A and B */
|
/** True for zones A and B */
|
||||||
isVisibleWithOpticalAid: boolean
|
isVisibleWithOpticalAid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Kernel-free visibility estimate ─────────────────────────────────────────
|
// ─── Kernel-free visibility estimate ─────────────────────────────────────────
|
||||||
|
|
@ -248,25 +248,25 @@ export interface MoonVisibilityEstimate {
|
||||||
* Odeh V parameter: V = ARCV − f(W).
|
* Odeh V parameter: V = ARCV − f(W).
|
||||||
* Positive = crescent exceeds minimum visibility threshold.
|
* Positive = crescent exceeds minimum visibility threshold.
|
||||||
*/
|
*/
|
||||||
V: number
|
V: number;
|
||||||
/** Visibility zone A through D */
|
/** Visibility zone A through D */
|
||||||
zone: OdehZone
|
zone: OdehZone;
|
||||||
/** Human-readable zone description */
|
/** Human-readable zone description */
|
||||||
description: string
|
description: string;
|
||||||
/** True for zone A */
|
/** True for zone A */
|
||||||
isVisibleNakedEye: boolean
|
isVisibleNakedEye: boolean;
|
||||||
/** True for zones A and B */
|
/** True for zones A and B */
|
||||||
isVisibleWithOpticalAid: boolean
|
isVisibleWithOpticalAid: boolean;
|
||||||
/** Arc of light (Sun-Moon elongation) in degrees */
|
/** Arc of light (Sun-Moon elongation) in degrees */
|
||||||
ARCL: number
|
ARCL: number;
|
||||||
/** Arc of vision (Moon airless altitude minus Sun airless altitude) in degrees */
|
/** Arc of vision (Moon airless altitude minus Sun airless altitude) in degrees */
|
||||||
ARCV: number
|
ARCV: number;
|
||||||
/** Topocentric crescent width in arc minutes */
|
/** Topocentric crescent width in arc minutes */
|
||||||
W: number
|
W: number;
|
||||||
/** True when Moon is above the horizon at the given time */
|
/** True when Moon is above the horizon at the given time */
|
||||||
moonAboveHorizon: boolean
|
moonAboveHorizon: boolean;
|
||||||
/** Always true: computed via Meeus approximation, not DE442S */
|
/** Always true: computed via Meeus approximation, not DE442S */
|
||||||
isApproximate: true
|
isApproximate: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -276,118 +276,118 @@ export interface MoonVisibilityEstimate {
|
||||||
*/
|
*/
|
||||||
export interface MoonSnapshot {
|
export interface MoonSnapshot {
|
||||||
/** Phase name, illumination, age, and next events */
|
/** Phase name, illumination, age, and next events */
|
||||||
phase: MoonPhaseResult
|
phase: MoonPhaseResult;
|
||||||
/** Topocentric az/alt, distance, parallactic angle */
|
/** Topocentric az/alt, distance, parallactic angle */
|
||||||
position: MoonPosition
|
position: MoonPosition;
|
||||||
/** Illumination fraction, phase cycle, bright limb angle, waxing/waning */
|
/** Illumination fraction, phase cycle, bright limb angle, waxing/waning */
|
||||||
illumination: MoonIlluminationResult
|
illumination: MoonIlluminationResult;
|
||||||
/** Quick Odeh-based crescent visibility estimate */
|
/** Quick Odeh-based crescent visibility estimate */
|
||||||
visibility: MoonVisibilityEstimate
|
visibility: MoonVisibilityEstimate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Moon phase ──────────────────────────────────────────────────────────────
|
// ─── Moon phase ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type MoonPhaseName =
|
export type MoonPhaseName =
|
||||||
| 'new-moon'
|
| "new-moon"
|
||||||
| 'waxing-crescent'
|
| "waxing-crescent"
|
||||||
| 'first-quarter'
|
| "first-quarter"
|
||||||
| 'waxing-gibbous'
|
| "waxing-gibbous"
|
||||||
| 'full-moon'
|
| "full-moon"
|
||||||
| 'waning-gibbous'
|
| "waning-gibbous"
|
||||||
| 'last-quarter'
|
| "last-quarter"
|
||||||
| 'waning-crescent'
|
| "waning-crescent";
|
||||||
|
|
||||||
export interface MoonPhaseResult {
|
export interface MoonPhaseResult {
|
||||||
/** Named phase based on illumination and waxing/waning state */
|
/** Named phase based on illumination and waxing/waning state */
|
||||||
phase: MoonPhaseName
|
phase: MoonPhaseName;
|
||||||
/** Human-readable phase name, e.g. "Waxing Crescent" */
|
/** Human-readable phase name, e.g. "Waxing Crescent" */
|
||||||
phaseName: string
|
phaseName: string;
|
||||||
/** Moon phase emoji symbol, e.g. "🌒" */
|
/** Moon phase emoji symbol, e.g. "🌒" */
|
||||||
phaseSymbol: string
|
phaseSymbol: string;
|
||||||
/** Illuminated fraction 0-100 (percent) */
|
/** Illuminated fraction 0-100 (percent) */
|
||||||
illumination: number
|
illumination: number;
|
||||||
/** Hours since last new moon */
|
/** Hours since last new moon */
|
||||||
age: number
|
age: number;
|
||||||
/** Ecliptic longitude of the Moon minus the Sun, degrees [0, 360) */
|
/** Ecliptic longitude of the Moon minus the Sun, degrees [0, 360) */
|
||||||
elongationDeg: number
|
elongationDeg: number;
|
||||||
/** True when Moon is moving away from the Sun (illumination increasing) */
|
/** True when Moon is moving away from the Sun (illumination increasing) */
|
||||||
isWaxing: boolean
|
isWaxing: boolean;
|
||||||
/** UTC date of the next new moon */
|
/** UTC date of the next new moon */
|
||||||
nextNewMoon: Date
|
nextNewMoon: Date;
|
||||||
/** UTC date of the next full moon */
|
/** UTC date of the next full moon */
|
||||||
nextFullMoon: Date
|
nextFullMoon: Date;
|
||||||
/** UTC date of the previous new moon */
|
/** UTC date of the previous new moon */
|
||||||
prevNewMoon: Date
|
prevNewMoon: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Event times ─────────────────────────────────────────────────────────────
|
// ─── Event times ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SunMoonEvents {
|
export interface SunMoonEvents {
|
||||||
/** UTC time of sunset for the given date at the observer's location */
|
/** UTC time of sunset for the given date at the observer's location */
|
||||||
sunsetUTC: Date | null
|
sunsetUTC: Date | null;
|
||||||
/** UTC time of moonset for the given date at the observer's location */
|
/** UTC time of moonset for the given date at the observer's location */
|
||||||
moonsetUTC: Date | null
|
moonsetUTC: Date | null;
|
||||||
/** UTC time of sunrise */
|
/** UTC time of sunrise */
|
||||||
sunriseUTC: Date | null
|
sunriseUTC: Date | null;
|
||||||
/** UTC time of moonrise */
|
/** UTC time of moonrise */
|
||||||
moonriseUTC: Date | null
|
moonriseUTC: Date | null;
|
||||||
/** UTC time when civil twilight ends (Sun at -6°) */
|
/** UTC time when civil twilight ends (Sun at -6°) */
|
||||||
civilTwilightEndUTC: Date | null
|
civilTwilightEndUTC: Date | null;
|
||||||
/** UTC time when nautical twilight ends (Sun at -12°) */
|
/** UTC time when nautical twilight ends (Sun at -12°) */
|
||||||
nauticalTwilightEndUTC: Date | null
|
nauticalTwilightEndUTC: Date | null;
|
||||||
/** UTC time when astronomical twilight ends (Sun at -18°) */
|
/** UTC time when astronomical twilight ends (Sun at -18°) */
|
||||||
astronomicalTwilightEndUTC: Date | null
|
astronomicalTwilightEndUTC: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Full moon sighting report ────────────────────────────────────────────────
|
// ─── Full moon sighting report ────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MoonSightingReport {
|
export interface MoonSightingReport {
|
||||||
/** Date for which the sighting report was computed */
|
/** Date for which the sighting report was computed */
|
||||||
date: Date
|
date: Date;
|
||||||
/** Observer location used */
|
/** Observer location used */
|
||||||
observer: Observer
|
observer: Observer;
|
||||||
|
|
||||||
// Event times
|
// Event times
|
||||||
sunsetUTC: Date | null
|
sunsetUTC: Date | null;
|
||||||
moonsetUTC: Date | null
|
moonsetUTC: Date | null;
|
||||||
/** Moonset minus sunset, in minutes. Null if either event is null. */
|
/** Moonset minus sunset, in minutes. Null if either event is null. */
|
||||||
lagMinutes: number | null
|
lagMinutes: number | null;
|
||||||
/** Best observation time (Odeh/Yallop: T_s + 4/9 * Lag) */
|
/** Best observation time (Odeh/Yallop: T_s + 4/9 * Lag) */
|
||||||
bestTimeUTC: Date | null
|
bestTimeUTC: Date | null;
|
||||||
/** Conservative observation window [bestTime - 20min, bestTime + 20min] */
|
/** Conservative observation window [bestTime - 20min, bestTime + 20min] */
|
||||||
bestTimeWindowUTC: [Date, Date] | null
|
bestTimeWindowUTC: [Date, Date] | null;
|
||||||
|
|
||||||
// At best time
|
// At best time
|
||||||
/** Topocentric Moon position at best time */
|
/** Topocentric Moon position at best time */
|
||||||
moonPosition: AzAlt | null
|
moonPosition: AzAlt | null;
|
||||||
/** Topocentric Sun position at best time */
|
/** Topocentric Sun position at best time */
|
||||||
sunPosition: AzAlt | null
|
sunPosition: AzAlt | null;
|
||||||
/** Moon illumination percent at best time */
|
/** Moon illumination percent at best time */
|
||||||
illumination: number | null
|
illumination: number | null;
|
||||||
/** Hours since conjunction (new moon) */
|
/** Hours since conjunction (new moon) */
|
||||||
moonAge: number | null
|
moonAge: number | null;
|
||||||
|
|
||||||
// Crescent geometry at best time
|
// Crescent geometry at best time
|
||||||
geometry: CrescentGeometry | null
|
geometry: CrescentGeometry | null;
|
||||||
|
|
||||||
// Visibility criteria results
|
// Visibility criteria results
|
||||||
yallop: YallopResult | null
|
yallop: YallopResult | null;
|
||||||
odeh: OdehResult | null
|
odeh: OdehResult | null;
|
||||||
|
|
||||||
// Sighting guidance
|
// Sighting guidance
|
||||||
/**
|
/**
|
||||||
* Plain-language direction for observers.
|
* Plain-language direction for observers.
|
||||||
* Includes where to look (azimuth, altitude), when (best time), and what to expect.
|
* Includes where to look (azimuth, altitude), when (best time), and what to expect.
|
||||||
*/
|
*/
|
||||||
guidance: string
|
guidance: string;
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
/** Source ephemeris used for this calculation */
|
/** Source ephemeris used for this calculation */
|
||||||
ephemerisSource: 'DE442S' | 'approximate'
|
ephemerisSource: "DE442S" | "approximate";
|
||||||
/** Whether the Moon is even above the horizon at best time */
|
/** Whether the Moon is even above the horizon at best time */
|
||||||
moonAboveHorizon: boolean | null
|
moonAboveHorizon: boolean | null;
|
||||||
/** Whether sighting is geometrically possible (lag > 0, Moon above horizon at best time) */
|
/** Whether sighting is geometrically possible (lag > 0, Moon above horizon at best time) */
|
||||||
sightingPossible: boolean
|
sightingPossible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Kernel configuration ─────────────────────────────────────────────────────
|
// ─── Kernel configuration ─────────────────────────────────────────────────────
|
||||||
|
|
@ -397,40 +397,40 @@ export interface MoonSightingReport {
|
||||||
* Used for both the planetary SPK (de442s.bsp) and leap-second kernel (naif0012.tls).
|
* Used for both the planetary SPK (de442s.bsp) and leap-second kernel (naif0012.tls).
|
||||||
*/
|
*/
|
||||||
export type KernelSource =
|
export type KernelSource =
|
||||||
| { type: 'file'; path: string }
|
| { type: "file"; path: string }
|
||||||
| { type: 'buffer'; data: ArrayBuffer; name: string }
|
| { type: "buffer"; data: ArrayBuffer; name: string }
|
||||||
| { type: 'url'; url: string }
|
| { type: "url"; url: string }
|
||||||
| { type: 'auto' } // auto-download from NAIF, cache in ~/.cache/moon-sighting
|
| { type: "auto" }; // auto-download from NAIF, cache in ~/.cache/moon-sighting
|
||||||
|
|
||||||
export interface KernelConfig {
|
export interface KernelConfig {
|
||||||
/** Planetary SPK kernel — defaults to de442s.bsp via auto-download */
|
/** Planetary SPK kernel — defaults to de442s.bsp via auto-download */
|
||||||
planetary?: KernelSource
|
planetary?: KernelSource;
|
||||||
/** Leap-second kernel — defaults to naif0012.tls via auto-download */
|
/** Leap-second kernel — defaults to naif0012.tls via auto-download */
|
||||||
leapSeconds?: KernelSource
|
leapSeconds?: KernelSource;
|
||||||
/**
|
/**
|
||||||
* Directory for the download cache.
|
* Directory for the download cache.
|
||||||
* Defaults to ~/.cache/moon-sighting on POSIX, %LOCALAPPDATA%\moon-sighting on Windows.
|
* Defaults to ~/.cache/moon-sighting on POSIX, %LOCALAPPDATA%\moon-sighting on Windows.
|
||||||
*/
|
*/
|
||||||
cacheDir?: string
|
cacheDir?: string;
|
||||||
/**
|
/**
|
||||||
* SHA-256 checksum of de442s.bsp for download verification.
|
* SHA-256 checksum of de442s.bsp for download verification.
|
||||||
* Bundled default matches the NAIF distribution as of 2024.
|
* Bundled default matches the NAIF distribution as of 2024.
|
||||||
*/
|
*/
|
||||||
checksumOverride?: string
|
checksumOverride?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Top-level options ────────────────────────────────────────────────────────
|
// ─── Top-level options ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SightingOptions {
|
export interface SightingOptions {
|
||||||
/** Kernel acquisition configuration. Defaults to auto-download. */
|
/** Kernel acquisition configuration. Defaults to auto-download. */
|
||||||
kernels?: KernelConfig
|
kernels?: KernelConfig;
|
||||||
/**
|
/**
|
||||||
* Best-time computation method.
|
* Best-time computation method.
|
||||||
* 'heuristic' — T_b = T_sunset + 4/9 * Lag (Odeh/Yallop approximation, fast)
|
* 'heuristic' — T_b = T_sunset + 4/9 * Lag (Odeh/Yallop approximation, fast)
|
||||||
* 'optimized' — scan sunset-to-moonset interval, maximize Odeh V parameter
|
* 'optimized' — scan sunset-to-moonset interval, maximize Odeh V parameter
|
||||||
* Default: 'heuristic'
|
* Default: 'heuristic'
|
||||||
*/
|
*/
|
||||||
bestTimeMethod?: 'heuristic' | 'optimized'
|
bestTimeMethod?: "heuristic" | "optimized";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── WGS84 constants ─────────────────────────────────────────────────────────
|
// ─── WGS84 constants ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -445,44 +445,44 @@ export const WGS84 = {
|
||||||
f: 1 / 298.257223563,
|
f: 1 / 298.257223563,
|
||||||
/** Semi-minor axis in meters */
|
/** Semi-minor axis in meters */
|
||||||
get b() {
|
get b() {
|
||||||
return this.a * (1 - this.f)
|
return this.a * (1 - this.f);
|
||||||
},
|
},
|
||||||
/** First eccentricity squared */
|
/** First eccentricity squared */
|
||||||
get e2() {
|
get e2() {
|
||||||
return 2 * this.f - this.f * this.f
|
return 2 * this.f - this.f * this.f;
|
||||||
},
|
},
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
// ─── Internal ephemeris types ─────────────────────────────────────────────────
|
// ─── Internal ephemeris types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A segment in a JPL SPK (DAF) kernel file */
|
/** A segment in a JPL SPK (DAF) kernel file */
|
||||||
export interface SpkSegment {
|
export interface SpkSegment {
|
||||||
/** NAIF body ID of the target body */
|
/** NAIF body ID of the target body */
|
||||||
target: number
|
target: number;
|
||||||
/** NAIF body ID of the center body */
|
/** NAIF body ID of the center body */
|
||||||
center: number
|
center: number;
|
||||||
/** Reference frame code */
|
/** Reference frame code */
|
||||||
frame: number
|
frame: number;
|
||||||
/** SPK data type (2 = Chebyshev position only, 3 = Chebyshev position + velocity) */
|
/** SPK data type (2 = Chebyshev position only, 3 = Chebyshev position + velocity) */
|
||||||
dataType: 2 | 3
|
dataType: 2 | 3;
|
||||||
/** Segment start time in ET seconds past J2000 */
|
/** Segment start time in ET seconds past J2000 */
|
||||||
startET: number
|
startET: number;
|
||||||
/** Segment end time in ET seconds past J2000 */
|
/** Segment end time in ET seconds past J2000 */
|
||||||
endET: number
|
endET: number;
|
||||||
/** Byte offset of the data array in the file */
|
/** Byte offset of the data array in the file */
|
||||||
dataOffset: number
|
dataOffset: number;
|
||||||
/** Number of double-precision values in the data array */
|
/** Number of double-precision values in the data array */
|
||||||
dataSize: number
|
dataSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A decoded Chebyshev record from a Type 2 or Type 3 SPK segment */
|
/** A decoded Chebyshev record from a Type 2 or Type 3 SPK segment */
|
||||||
export interface ChebRecord {
|
export interface ChebRecord {
|
||||||
/** Midpoint of the record interval in ET seconds past J2000 */
|
/** Midpoint of the record interval in ET seconds past J2000 */
|
||||||
mid: number
|
mid: number;
|
||||||
/** Half-width of the record interval in seconds */
|
/** Half-width of the record interval in seconds */
|
||||||
radius: number
|
radius: number;
|
||||||
/** Chebyshev coefficients for X, Y, Z [3][degree+1] */
|
/** Chebyshev coefficients for X, Y, Z [3][degree+1] */
|
||||||
coeffs: Float64Array[]
|
coeffs: Float64Array[];
|
||||||
/** Degree of the polynomial */
|
/** Degree of the polynomial */
|
||||||
degree: number
|
degree: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,15 @@ import type {
|
||||||
YallopCategory,
|
YallopCategory,
|
||||||
OdehResult,
|
OdehResult,
|
||||||
OdehZone,
|
OdehZone,
|
||||||
} from '../types.js'
|
} from "../types.js";
|
||||||
import {
|
import {
|
||||||
YALLOP_THRESHOLDS,
|
YALLOP_THRESHOLDS,
|
||||||
YALLOP_DESCRIPTIONS,
|
YALLOP_DESCRIPTIONS,
|
||||||
ODEH_THRESHOLDS,
|
ODEH_THRESHOLDS,
|
||||||
ODEH_DESCRIPTIONS,
|
ODEH_DESCRIPTIONS,
|
||||||
} from '../types.js'
|
} from "../types.js";
|
||||||
import { angularSep } from '../math/index.js'
|
import { angularSep } from "../math/index.js";
|
||||||
import { computeCrescentWidth } from '../bodies/index.js'
|
import { computeCrescentWidth } from "../bodies/index.js";
|
||||||
|
|
||||||
// ─── Shared polynomial ────────────────────────────────────────────────────────
|
// ─── Shared polynomial ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ import { computeCrescentWidth } from '../bodies/index.js'
|
||||||
* @returns Minimum ARCV required for detection, in degrees
|
* @returns Minimum ARCV required for detection, in degrees
|
||||||
*/
|
*/
|
||||||
export function arcvMinimum(W: number): number {
|
export function arcvMinimum(W: number): number {
|
||||||
return 11.8371 - 6.3226 * W + 0.7319 * W * W - 0.1018 * W * W * W
|
return 11.8371 - 6.3226 * W + 0.7319 * W * W - 0.1018 * W * W * W;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Yallop q-test ────────────────────────────────────────────────────────────
|
// ─── Yallop q-test ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -74,7 +74,7 @@ export function arcvMinimum(W: number): number {
|
||||||
* @returns q parameter (continuous)
|
* @returns q parameter (continuous)
|
||||||
*/
|
*/
|
||||||
export function computeYallopQ(ARCV: number, Wprime: number): number {
|
export function computeYallopQ(ARCV: number, Wprime: number): number {
|
||||||
return (ARCV - arcvMinimum(Wprime)) / 10
|
return (ARCV - arcvMinimum(Wprime)) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -89,12 +89,12 @@ export function computeYallopQ(ARCV: number, Wprime: number): number {
|
||||||
* F: q <= -0.293
|
* F: q <= -0.293
|
||||||
*/
|
*/
|
||||||
export function yallopCategory(q: number): YallopCategory {
|
export function yallopCategory(q: number): YallopCategory {
|
||||||
if (q > YALLOP_THRESHOLDS.A) return 'A'
|
if (q > YALLOP_THRESHOLDS.A) return "A";
|
||||||
if (q > YALLOP_THRESHOLDS.B) return 'B'
|
if (q > YALLOP_THRESHOLDS.B) return "B";
|
||||||
if (q > YALLOP_THRESHOLDS.C) return 'C'
|
if (q > YALLOP_THRESHOLDS.C) return "C";
|
||||||
if (q > YALLOP_THRESHOLDS.D) return 'D'
|
if (q > YALLOP_THRESHOLDS.D) return "D";
|
||||||
if (q > YALLOP_THRESHOLDS.E) return 'E'
|
if (q > YALLOP_THRESHOLDS.E) return "E";
|
||||||
return 'F'
|
return "F";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -104,18 +104,18 @@ export function yallopCategory(q: number): YallopCategory {
|
||||||
* @param Wprime - Topocentric crescent width in arc minutes (may differ from geometry.W)
|
* @param Wprime - Topocentric crescent width in arc minutes (may differ from geometry.W)
|
||||||
*/
|
*/
|
||||||
export function computeYallop(geometry: CrescentGeometry, Wprime: number): YallopResult {
|
export function computeYallop(geometry: CrescentGeometry, Wprime: number): YallopResult {
|
||||||
const q = computeYallopQ(geometry.ARCV, Wprime)
|
const q = computeYallopQ(geometry.ARCV, Wprime);
|
||||||
const category = yallopCategory(q)
|
const category = yallopCategory(q);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
q,
|
q,
|
||||||
category,
|
category,
|
||||||
description: YALLOP_DESCRIPTIONS[category],
|
description: YALLOP_DESCRIPTIONS[category],
|
||||||
isVisibleNakedEye: category === 'A' || category === 'B',
|
isVisibleNakedEye: category === "A" || category === "B",
|
||||||
requiresOpticalAid: category === 'C' || category === 'D',
|
requiresOpticalAid: category === "C" || category === "D",
|
||||||
isBelowDanjonLimit: category === 'F',
|
isBelowDanjonLimit: category === "F",
|
||||||
Wprime,
|
Wprime,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Odeh criterion ───────────────────────────────────────────────────────────
|
// ─── Odeh criterion ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -135,7 +135,7 @@ export function computeYallop(geometry: CrescentGeometry, Wprime: number): Yallo
|
||||||
* @param W - Topocentric crescent width in arc minutes
|
* @param W - Topocentric crescent width in arc minutes
|
||||||
*/
|
*/
|
||||||
export function computeOdehV(ARCV: number, W: number): number {
|
export function computeOdehV(ARCV: number, W: number): number {
|
||||||
return ARCV - arcvMinimum(W)
|
return ARCV - arcvMinimum(W);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -148,10 +148,10 @@ export function computeOdehV(ARCV: number, W: number): number {
|
||||||
* D: V < -0.96 — Not visible even with optical aid
|
* D: V < -0.96 — Not visible even with optical aid
|
||||||
*/
|
*/
|
||||||
export function odehZone(V: number): OdehZone {
|
export function odehZone(V: number): OdehZone {
|
||||||
if (V >= ODEH_THRESHOLDS.A) return 'A'
|
if (V >= ODEH_THRESHOLDS.A) return "A";
|
||||||
if (V >= ODEH_THRESHOLDS.B) return 'B'
|
if (V >= ODEH_THRESHOLDS.B) return "B";
|
||||||
if (V >= ODEH_THRESHOLDS.C) return 'C'
|
if (V >= ODEH_THRESHOLDS.C) return "C";
|
||||||
return 'D'
|
return "D";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -159,16 +159,16 @@ export function odehZone(V: number): OdehZone {
|
||||||
* Uses geometry.W directly as the Odeh topocentric crescent width.
|
* Uses geometry.W directly as the Odeh topocentric crescent width.
|
||||||
*/
|
*/
|
||||||
export function computeOdeh(geometry: CrescentGeometry): OdehResult {
|
export function computeOdeh(geometry: CrescentGeometry): OdehResult {
|
||||||
const V = computeOdehV(geometry.ARCV, geometry.W)
|
const V = computeOdehV(geometry.ARCV, geometry.W);
|
||||||
const zone = odehZone(V)
|
const zone = odehZone(V);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
V,
|
V,
|
||||||
zone,
|
zone,
|
||||||
description: ODEH_DESCRIPTIONS[zone],
|
description: ODEH_DESCRIPTIONS[zone],
|
||||||
isVisibleNakedEye: zone === 'A',
|
isVisibleNakedEye: zone === "A",
|
||||||
isVisibleWithOpticalAid: zone === 'A' || zone === 'B',
|
isVisibleWithOpticalAid: zone === "A" || zone === "B",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Geometry computation ─────────────────────────────────────────────────────
|
// ─── Geometry computation ─────────────────────────────────────────────────────
|
||||||
|
|
@ -190,30 +190,30 @@ export function computeOdeh(geometry: CrescentGeometry): OdehResult {
|
||||||
export function computeCrescentGeometry(
|
export function computeCrescentGeometry(
|
||||||
moonAirlessAzAlt: { azimuth: number; altitude: number },
|
moonAirlessAzAlt: { azimuth: number; altitude: number },
|
||||||
sunAirlessAzAlt: { azimuth: number; altitude: number },
|
sunAirlessAzAlt: { azimuth: number; altitude: number },
|
||||||
moonGCRS: import('../types.js').Vec3,
|
moonGCRS: import("../types.js").Vec3,
|
||||||
sunGCRS: import('../types.js').Vec3,
|
sunGCRS: import("../types.js").Vec3,
|
||||||
sunsetUTC: Date,
|
sunsetUTC: Date,
|
||||||
moonsetUTC: Date,
|
moonsetUTC: Date,
|
||||||
): CrescentGeometry {
|
): CrescentGeometry {
|
||||||
// ARCV: airless arc of vision (Moon altitude minus Sun altitude)
|
// ARCV: airless arc of vision (Moon altitude minus Sun altitude)
|
||||||
const ARCV = moonAirlessAzAlt.altitude - sunAirlessAzAlt.altitude
|
const ARCV = moonAirlessAzAlt.altitude - sunAirlessAzAlt.altitude;
|
||||||
|
|
||||||
// DAZ: Sun azimuth minus Moon azimuth, normalized to (−180, 180]
|
// DAZ: Sun azimuth minus Moon azimuth, normalized to (−180, 180]
|
||||||
let DAZ = sunAirlessAzAlt.azimuth - moonAirlessAzAlt.azimuth
|
let DAZ = sunAirlessAzAlt.azimuth - moonAirlessAzAlt.azimuth;
|
||||||
if (DAZ > 180) DAZ -= 360
|
if (DAZ > 180) DAZ -= 360;
|
||||||
if (DAZ < -180) DAZ += 360
|
if (DAZ < -180) DAZ += 360;
|
||||||
|
|
||||||
// ARCL: topocentric Sun-Moon angular separation in degrees
|
// ARCL: topocentric Sun-Moon angular separation in degrees
|
||||||
// angularSep returns radians; both vectors must be topocentric for accurate ARCL
|
// angularSep returns radians; both vectors must be topocentric for accurate ARCL
|
||||||
const ARCL = angularSep(moonGCRS, sunGCRS) * (180 / Math.PI)
|
const ARCL = angularSep(moonGCRS, sunGCRS) * (180 / Math.PI);
|
||||||
|
|
||||||
// W: topocentric crescent width in arc minutes
|
// W: topocentric crescent width in arc minutes
|
||||||
const { W } = computeCrescentWidth(moonGCRS, ARCL)
|
const { W } = computeCrescentWidth(moonGCRS, ARCL);
|
||||||
|
|
||||||
// lag: moonset minus sunset in minutes (negative = Moon sets before Sun)
|
// lag: moonset minus sunset in minutes (negative = Moon sets before Sun)
|
||||||
const lag = (moonsetUTC.getTime() - sunsetUTC.getTime()) / 60000
|
const lag = (moonsetUTC.getTime() - sunsetUTC.getTime()) / 60000;
|
||||||
|
|
||||||
return { ARCL, ARCV, DAZ, W, lag }
|
return { ARCL, ARCV, DAZ, W, lag };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Guidance text ────────────────────────────────────────────────────────────
|
// ─── Guidance text ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -237,22 +237,22 @@ export function buildGuidanceText(
|
||||||
bestTimeUTC: Date,
|
bestTimeUTC: Date,
|
||||||
lagMinutes: number,
|
lagMinutes: number,
|
||||||
): string {
|
): string {
|
||||||
const direction = azimuthToCardinal(moonAz)
|
const direction = azimuthToCardinal(moonAz);
|
||||||
const timeStr = bestTimeUTC
|
const timeStr = bestTimeUTC
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.replace('T', ' ')
|
.replace("T", " ")
|
||||||
.replace(/\.\d+Z$/, ' UTC')
|
.replace(/\.\d+Z$/, " UTC");
|
||||||
const lagStr = `${Math.round(lagMinutes)} min after sunset`
|
const lagStr = `${Math.round(lagMinutes)} min after sunset`;
|
||||||
|
|
||||||
let visibility: string
|
let visibility: string;
|
||||||
if (yallop.isVisibleNakedEye && odeh.isVisibleNakedEye) {
|
if (yallop.isVisibleNakedEye && odeh.isVisibleNakedEye) {
|
||||||
visibility = 'should be visible to the naked eye'
|
visibility = "should be visible to the naked eye";
|
||||||
} else if (odeh.isVisibleWithOpticalAid) {
|
} else if (odeh.isVisibleWithOpticalAid) {
|
||||||
visibility = 'may require binoculars or a telescope to spot'
|
visibility = "may require binoculars or a telescope to spot";
|
||||||
} else if (yallop.isBelowDanjonLimit) {
|
} else if (yallop.isBelowDanjonLimit) {
|
||||||
visibility = 'is too close to the Sun to form a visible crescent (below Danjon limit)'
|
visibility = "is too close to the Sun to form a visible crescent (below Danjon limit)";
|
||||||
} else {
|
} else {
|
||||||
visibility = 'is not expected to be visible even with optical aid'
|
visibility = "is not expected to be visible even with optical aid";
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -261,29 +261,29 @@ export function buildGuidanceText(
|
||||||
`The crescent ${visibility}. ` +
|
`The crescent ${visibility}. ` +
|
||||||
`Yallop: ${yallop.category} (${yallop.description}). ` +
|
`Yallop: ${yallop.category} (${yallop.description}). ` +
|
||||||
`Odeh: ${odeh.zone} (${odeh.description}).`
|
`Odeh: ${odeh.zone} (${odeh.description}).`
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert azimuth degrees to a cardinal/intercardinal direction label */
|
/** Convert azimuth degrees to a cardinal/intercardinal direction label */
|
||||||
function azimuthToCardinal(az: number): string {
|
function azimuthToCardinal(az: number): string {
|
||||||
const dirs = [
|
const dirs = [
|
||||||
'North',
|
"North",
|
||||||
'NNE',
|
"NNE",
|
||||||
'NE',
|
"NE",
|
||||||
'ENE',
|
"ENE",
|
||||||
'East',
|
"East",
|
||||||
'ESE',
|
"ESE",
|
||||||
'SE',
|
"SE",
|
||||||
'SSE',
|
"SSE",
|
||||||
'South',
|
"South",
|
||||||
'SSW',
|
"SSW",
|
||||||
'SW',
|
"SW",
|
||||||
'WSW',
|
"WSW",
|
||||||
'West',
|
"West",
|
||||||
'WNW',
|
"WNW",
|
||||||
'NW',
|
"NW",
|
||||||
'NNW',
|
"NNW",
|
||||||
]
|
];
|
||||||
const idx = Math.round(az / 22.5) % 16
|
const idx = Math.round(az / 22.5) % 16;
|
||||||
return dirs[(idx + 16) % 16]! // dirs has 16 elements; (idx+16)%16 is always 0..15
|
return dirs[(idx + 16) % 16]!; // dirs has 16 elements; (idx+16)%16 is always 0..15
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue