mirror of
https://github.com/acamarata/moon-sighting.git
synced 2026-06-30 19:04:24 +00:00
Fix critical bug: arcvMinimum polynomial constant was 7.1651 (wrong) instead of 11.8371 (Odeh 2006) in getMoonVisibilityEstimate. Now imports the canonical arcvMinimum() from visibility module. Deduplicate shared code across modules: - arcvMinimum polynomial: single source in visibility/index.ts - dot/norm vector helpers: use vdot/vnorm from math/index.ts - DEG constant: use DEG2RAD from math/index.ts - jdToJSDate: use jdToDate from time/index.ts Add input validation to all public API functions (lat/lon range, valid Date instances). Add ESLint + Prettier with TypeScript support. Convert tests to node:test runner. Fix package.json exports to use nested types-first format. Pin devDependencies to caret ranges. Add noImplicitReturns and noFallthroughCasesInSwitch to tsconfig. Replace .markdownlint.json with .vscode/settings.json. Update CI workflow with lint job. Expand .gitignore coverage.
289 lines
9.8 KiB
TypeScript
289 lines
9.8 KiB
TypeScript
/**
|
||
* visibility — Crescent visibility criteria: Yallop and Odeh.
|
||
*
|
||
* Both criteria transform the five geometric quantities (ARCL, ARCV, DAZ, W, Lag)
|
||
* into a single score that maps to a visibility category.
|
||
*
|
||
* Yallop (NAO Technical Note 69, 1997):
|
||
* q = (ARCV - (11.8371 - 6.3226*W' + 0.7319*W'^2 - 0.1018*W'^3)) / 10
|
||
* where W' is the topocentric crescent width in arc minutes.
|
||
* Categories: A (q > 0.216) through F (q <= -0.293)
|
||
*
|
||
* Odeh (Experimental Astronomy 2006):
|
||
* V = ARCV - arcv_min(W)
|
||
* where arcv_min(W) = 11.8371 - 6.3226*W + 0.7319*W^2 - 0.1018*W^3 (same polynomial as Yallop)
|
||
* Zones: A (V >= 5.65) through D (V < -0.96)
|
||
*
|
||
* The geometric quantities must be computed at best time T_b using AIRLESS
|
||
* (refraction-free) topocentric altitudes for both models.
|
||
*
|
||
* References:
|
||
* Yallop (1997), A Method for Predicting the First Sighting of the New Crescent Moon,
|
||
* NAO Technical Note No. 69, Royal Greenwich Observatory
|
||
* Odeh (2006), New Criterion for Lunar Crescent Visibility,
|
||
* Experimental Astronomy 18(1), 39-64
|
||
*/
|
||
|
||
import type {
|
||
CrescentGeometry,
|
||
YallopResult,
|
||
YallopCategory,
|
||
OdehResult,
|
||
OdehZone,
|
||
} from '../types.js'
|
||
import {
|
||
YALLOP_THRESHOLDS,
|
||
YALLOP_DESCRIPTIONS,
|
||
ODEH_THRESHOLDS,
|
||
ODEH_DESCRIPTIONS,
|
||
} from '../types.js'
|
||
import { angularSep } from '../math/index.js'
|
||
import { computeCrescentWidth } from '../bodies/index.js'
|
||
|
||
// ─── Shared polynomial ────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The polynomial ARCV minimum as a function of crescent width W (arc minutes).
|
||
* This expression appears in both Yallop (as the denominator basis) and Odeh
|
||
* (as arcv_min directly).
|
||
*
|
||
* arcv_min(W) = 11.8371 - 6.3226*W + 0.7319*W^2 - 0.1018*W^3
|
||
*
|
||
* Represents the minimum arc of vision required for detection as a function
|
||
* of crescent width, derived empirically from historical observations.
|
||
*
|
||
* @param W - Topocentric crescent width in arc minutes
|
||
* @returns Minimum ARCV required for detection, in degrees
|
||
*/
|
||
export function arcvMinimum(W: number): number {
|
||
return 11.8371 - 6.3226 * W + 0.7319 * W * W - 0.1018 * W * W * W
|
||
}
|
||
|
||
// ─── Yallop q-test ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Compute the Yallop q parameter.
|
||
*
|
||
* q = (ARCV - arcv_min(W')) / 10
|
||
*
|
||
* A positive q means the actual ARCV exceeds the minimum required, scaled
|
||
* so that thresholds A–F correspond to intuitive distance from the boundary.
|
||
*
|
||
* @param ARCV - Topocentric airless arc of vision in degrees
|
||
* @param Wprime - Topocentric crescent width W' in arc minutes (Yallop's topocentric form)
|
||
* @returns q parameter (continuous)
|
||
*/
|
||
export function computeYallopQ(ARCV: number, Wprime: number): number {
|
||
return (ARCV - arcvMinimum(Wprime)) / 10
|
||
}
|
||
|
||
/**
|
||
* Map a q value to the Yallop category (A–F).
|
||
*
|
||
* Thresholds (Yallop 1997 Table 1):
|
||
* A: q > +0.216
|
||
* B: q > -0.014
|
||
* C: q > -0.160
|
||
* D: q > -0.232
|
||
* E: q > -0.293
|
||
* F: q <= -0.293
|
||
*/
|
||
export function yallopCategory(q: number): YallopCategory {
|
||
if (q > YALLOP_THRESHOLDS.A) return 'A'
|
||
if (q > YALLOP_THRESHOLDS.B) return 'B'
|
||
if (q > YALLOP_THRESHOLDS.C) return 'C'
|
||
if (q > YALLOP_THRESHOLDS.D) return 'D'
|
||
if (q > YALLOP_THRESHOLDS.E) return 'E'
|
||
return 'F'
|
||
}
|
||
|
||
/**
|
||
* Compute the full Yallop result from crescent geometry.
|
||
*
|
||
* @param geometry - CrescentGeometry (W is assumed to be Wprime for Yallop purposes)
|
||
* @param Wprime - Topocentric crescent width in arc minutes (may differ from geometry.W)
|
||
*/
|
||
export function computeYallop(geometry: CrescentGeometry, Wprime: number): YallopResult {
|
||
const q = computeYallopQ(geometry.ARCV, Wprime)
|
||
const category = yallopCategory(q)
|
||
|
||
return {
|
||
q,
|
||
category,
|
||
description: YALLOP_DESCRIPTIONS[category],
|
||
isVisibleNakedEye: category === 'A' || category === 'B',
|
||
requiresOpticalAid: category === 'C' || category === 'D',
|
||
isBelowDanjonLimit: category === 'F',
|
||
Wprime,
|
||
}
|
||
}
|
||
|
||
// ─── Odeh criterion ───────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Compute the Odeh V parameter.
|
||
*
|
||
* V = ARCV - arcv_min(W)
|
||
*
|
||
* Where W is the topocentric crescent width in arc minutes (Odeh's formulation
|
||
* uses W directly, not the Yallop W' correction).
|
||
*
|
||
* A positive V indicates the observed ARCV exceeds the minimum threshold for
|
||
* visibility at that crescent width.
|
||
*
|
||
* @param ARCV - Topocentric airless arc of vision in degrees
|
||
* @param W - Topocentric crescent width in arc minutes
|
||
*/
|
||
export function computeOdehV(ARCV: number, W: number): number {
|
||
return ARCV - arcvMinimum(W)
|
||
}
|
||
|
||
/**
|
||
* Map a V value to the Odeh zone (A–D).
|
||
*
|
||
* Thresholds (Odeh 2006 Table 1):
|
||
* A: V >= 5.65 — Visible with naked eye
|
||
* B: V >= 2.00 — Visible with optical aid; may be seen naked eye
|
||
* C: V >= -0.96 — Visible with optical aid only
|
||
* D: V < -0.96 — Not visible even with optical aid
|
||
*/
|
||
export function odehZone(V: number): OdehZone {
|
||
if (V >= ODEH_THRESHOLDS.A) return 'A'
|
||
if (V >= ODEH_THRESHOLDS.B) return 'B'
|
||
if (V >= ODEH_THRESHOLDS.C) return 'C'
|
||
return 'D'
|
||
}
|
||
|
||
/**
|
||
* Compute the full Odeh result from crescent geometry.
|
||
* Uses geometry.W directly as the Odeh topocentric crescent width.
|
||
*/
|
||
export function computeOdeh(geometry: CrescentGeometry): OdehResult {
|
||
const V = computeOdehV(geometry.ARCV, geometry.W)
|
||
const zone = odehZone(V)
|
||
|
||
return {
|
||
V,
|
||
zone,
|
||
description: ODEH_DESCRIPTIONS[zone],
|
||
isVisibleNakedEye: zone === 'A',
|
||
isVisibleWithOpticalAid: zone === 'A' || zone === 'B',
|
||
}
|
||
}
|
||
|
||
// ─── Geometry computation ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Compute all five crescent geometry quantities (ARCL, ARCV, DAZ, W, Lag)
|
||
* at a given best time.
|
||
*
|
||
* All angular quantities use AIRLESS topocentric positions as required by
|
||
* both Yallop and Odeh criteria.
|
||
*
|
||
* @param moonAirlessAzAlt - Moon topocentric airless az/alt at best time
|
||
* @param sunAirlessAzAlt - Sun topocentric airless az/alt at best time
|
||
* @param moonGCRS - Topocentric Moon position vector (km) — for ARCL and W
|
||
* @param sunGCRS - Topocentric Sun position vector (km) — for ARCL
|
||
* @param sunsetUTC - UTC time of sunset
|
||
* @param moonsetUTC - UTC time of moonset
|
||
*/
|
||
export function computeCrescentGeometry(
|
||
moonAirlessAzAlt: { azimuth: number; altitude: number },
|
||
sunAirlessAzAlt: { azimuth: number; altitude: number },
|
||
moonGCRS: import('../types.js').Vec3,
|
||
sunGCRS: import('../types.js').Vec3,
|
||
sunsetUTC: Date,
|
||
moonsetUTC: Date,
|
||
): CrescentGeometry {
|
||
// ARCV: airless arc of vision (Moon altitude minus Sun altitude)
|
||
const ARCV = moonAirlessAzAlt.altitude - sunAirlessAzAlt.altitude
|
||
|
||
// DAZ: Sun azimuth minus Moon azimuth, normalized to (−180, 180]
|
||
let DAZ = sunAirlessAzAlt.azimuth - moonAirlessAzAlt.azimuth
|
||
if (DAZ > 180) DAZ -= 360
|
||
if (DAZ < -180) DAZ += 360
|
||
|
||
// ARCL: topocentric Sun-Moon angular separation in degrees
|
||
// angularSep returns radians; both vectors must be topocentric for accurate ARCL
|
||
const ARCL = angularSep(moonGCRS, sunGCRS) * (180 / Math.PI)
|
||
|
||
// W: topocentric crescent width in arc minutes
|
||
const { W } = computeCrescentWidth(moonGCRS, ARCL)
|
||
|
||
// lag: moonset minus sunset in minutes (negative = Moon sets before Sun)
|
||
const lag = (moonsetUTC.getTime() - sunsetUTC.getTime()) / 60000
|
||
|
||
return { ARCL, ARCV, DAZ, W, lag }
|
||
}
|
||
|
||
// ─── Guidance text ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Generate human-readable sighting guidance based on the crescent report.
|
||
*
|
||
* @param yallop - Yallop result
|
||
* @param odeh - Odeh result
|
||
* @param moonAz - Moon azimuth at best time (degrees from North)
|
||
* @param moonAlt - Moon altitude at best time (degrees)
|
||
* @param bestTimeUTC - Best observation time
|
||
* @param lagMinutes - Lag in minutes
|
||
* @returns Guidance string for observers
|
||
*/
|
||
export function buildGuidanceText(
|
||
yallop: YallopResult,
|
||
odeh: OdehResult,
|
||
moonAz: number,
|
||
moonAlt: number,
|
||
bestTimeUTC: Date,
|
||
lagMinutes: number,
|
||
): string {
|
||
const direction = azimuthToCardinal(moonAz)
|
||
const timeStr = bestTimeUTC
|
||
.toISOString()
|
||
.replace('T', ' ')
|
||
.replace(/\.\d+Z$/, ' UTC')
|
||
const lagStr = `${Math.round(lagMinutes)} min after sunset`
|
||
|
||
let visibility: string
|
||
if (yallop.isVisibleNakedEye && odeh.isVisibleNakedEye) {
|
||
visibility = 'should be visible to the naked eye'
|
||
} else if (odeh.isVisibleWithOpticalAid) {
|
||
visibility = 'may require binoculars or a telescope to spot'
|
||
} else if (yallop.isBelowDanjonLimit) {
|
||
visibility = 'is too close to the Sun to form a visible crescent (below Danjon limit)'
|
||
} else {
|
||
visibility = 'is not expected to be visible even with optical aid'
|
||
}
|
||
|
||
return (
|
||
`Best time to look: ${timeStr} (${lagStr}). ` +
|
||
`Look ${direction} at ${Math.round(moonAlt)}° above the horizon. ` +
|
||
`The crescent ${visibility}. ` +
|
||
`Yallop: ${yallop.category} (${yallop.description}). ` +
|
||
`Odeh: ${odeh.zone} (${odeh.description}).`
|
||
)
|
||
}
|
||
|
||
/** Convert azimuth degrees to a cardinal/intercardinal direction label */
|
||
function azimuthToCardinal(az: number): string {
|
||
const dirs = [
|
||
'North',
|
||
'NNE',
|
||
'NE',
|
||
'ENE',
|
||
'East',
|
||
'ESE',
|
||
'SE',
|
||
'SSE',
|
||
'South',
|
||
'SSW',
|
||
'SW',
|
||
'WSW',
|
||
'West',
|
||
'WNW',
|
||
'NW',
|
||
'NNW',
|
||
]
|
||
const idx = Math.round(az / 22.5) % 16
|
||
return dirs[(idx + 16) % 16]
|
||
}
|