diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..60cdd14 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": false, + "MD024": { "siblings_only": true } +} diff --git a/.wiki/API-Reference.md b/.wiki/API-Reference.md index b2a24e0..a94b5e1 100644 --- a/.wiki/API-Reference.md +++ b/.wiki/API-Reference.md @@ -104,6 +104,90 @@ interface MoonSightingReport { --- +### `getMoonPosition(date?, lat, lon, elevation?)` + +Compute the Moon's topocentric position. Works without a kernel. Uses Meeus Chapter 47 approximate positions (~0.3° accuracy). + +```ts +function getMoonPosition( + date: Date | undefined, + lat: number, + lon: number, + elevation?: number, +): MoonPosition +``` + +**Parameters:** + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `date` | `Date?` | Date to evaluate. Defaults to now | +| `lat` | `number` | Geodetic latitude, degrees (north positive) | +| `lon` | `number` | Longitude, degrees (east positive) | +| `elevation` | `number?` | Height above ellipsoid, meters. Default: 0 | + +**MoonPosition:** + +```ts +interface MoonPosition { + azimuth: number // Degrees from North, clockwise (0–360) + altitude: number // Apparent altitude, degrees (refraction applied) + distance: number // Earth center to Moon center, km + parallacticAngle: number // Angle between zenith and north pole as seen from Moon, radians +} +``` + +**Example:** + +```ts +import { getMoonPosition } from 'moon-sighting' + +const pos = getMoonPosition(new Date(), 51.5074, -0.1278, 10) +console.log(pos.azimuth) // 214.7 +console.log(pos.altitude) // 38.2 +console.log(pos.distance) // 384400 +``` + +--- + +### `getMoonIllumination(date?)` + +Compute the Moon's illumination fraction and phase angle. Works without a kernel. Uses Meeus Chapters 47 and 48 (~0.5% illumination accuracy). + +```ts +function getMoonIllumination(date?: Date): MoonIlluminationResult +``` + +**Parameters:** + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `date` | `Date?` | Date to evaluate. Defaults to now | + +**MoonIlluminationResult:** + +```ts +interface MoonIlluminationResult { + fraction: number // Illuminated fraction, 0 (new moon) to 1 (full moon) + phase: number // Position in 0–1 cycle: 0=new, 0.25=first quarter, 0.5=full, 0.75=last quarter + angle: number // Position angle of bright limb midpoint, eastward from north, radians + isWaxing: boolean // True when elongation is increasing (new moon toward full moon) +} +``` + +**Example:** + +```ts +import { getMoonIllumination } from 'moon-sighting' + +const illum = getMoonIllumination() +console.log(illum.fraction) // 0.143 +console.log(illum.phase) // 0.09 +console.log(illum.isWaxing) // true +``` + +--- + ### `getMoonPhase(date?)` Compute moon phase data. Works without a kernel. @@ -117,6 +201,8 @@ function getMoonPhase(date?: Date): MoonPhaseResult ```ts interface MoonPhaseResult { phase: MoonPhaseName // 'new-moon' | 'waxing-crescent' | ... | 'waning-crescent' + phaseName: string // Display name, e.g. 'Waxing Crescent' + phaseSymbol: string // Moon emoji, e.g. '🌒' illumination: number // 0–100 percent age: number // hours since last new moon elongationDeg: number // Moon - Sun ecliptic longitude, [0, 360) @@ -129,6 +215,100 @@ interface MoonPhaseResult { --- +### `getMoonVisibilityEstimate(date?, lat, lon, elevation?)` + +Quick kernel-free crescent visibility estimate using the Odeh V-parameter formula. Computes approximate crescent geometry from Meeus Ch. 47 positions at the given observation time. + +Best used at an estimated post-sunset observation time. For precise crescent work, use `getMoonSightingReport()` with the DE442S kernel. + +```ts +function getMoonVisibilityEstimate( + date?: Date, + lat: number, + lon: number, + elevation?: number, +): MoonVisibilityEstimate +``` + +**Parameters:** + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `date` | `Date?` | Observation time. Defaults to now. Use a post-sunset time for meaningful results | +| `lat` | `number` | Geodetic latitude, degrees (north positive) | +| `lon` | `number` | Longitude, degrees (east positive) | +| `elevation` | `number?` | Height above ellipsoid, meters. Default: 0 | + +**MoonVisibilityEstimate:** + +```ts +interface MoonVisibilityEstimate { + V: number // Odeh V parameter: V = ARCV - f(W). Positive = crescent exceeds threshold + zone: OdehZone // 'A' | 'B' | 'C' | 'D' + description: string // Human-readable zone description + isVisibleNakedEye: boolean // True for zone A + isVisibleWithOpticalAid: boolean // True for zones A and B + ARCL: number // Arc of light (elongation), degrees + ARCV: number // Arc of vision (Moon alt - Sun alt, airless), degrees + W: number // Topocentric crescent width, arc minutes + moonAboveHorizon: boolean // True when Moon is above the horizon at the given time + isApproximate: true // Always true: Meeus approximation, not DE442S +} +``` + +**Example:** + +```ts +import { getMoonVisibilityEstimate } from 'moon-sighting' + +// ~40 min after sunset in Mecca, day after new moon +const est = getMoonVisibilityEstimate(new Date('2025-03-02T15:30:00Z'), 21.42, 39.83) +console.log(est.zone) // 'A' through 'D' +console.log(est.V) // Odeh V parameter +console.log(est.isVisibleNakedEye) // true/false +``` + +--- + +### `getMoon(date?, lat, lon, elevation?)` + +Combined kernel-free snapshot: phase, position, illumination, and visibility estimate in one call. + +```ts +function getMoon( + date?: Date, + lat: number, + lon: number, + elevation?: number, +): MoonSnapshot +``` + +**MoonSnapshot:** + +```ts +interface MoonSnapshot { + phase: MoonPhaseResult // getMoonPhase() result + position: MoonPosition // getMoonPosition() result + illumination: MoonIlluminationResult // getMoonIllumination() result + visibility: MoonVisibilityEstimate // getMoonVisibilityEstimate() result +} +``` + +**Example:** + +```ts +import { getMoon } from 'moon-sighting' + +const moon = getMoon(new Date(), 51.5074, -0.1278, 10) +console.log(moon.phase.phaseName) // 'Waxing Crescent' +console.log(moon.phase.phaseSymbol) // '🌒' +console.log(moon.position.altitude) // degrees above horizon +console.log(moon.illumination.fraction) // 0.0 to 1.0 +console.log(moon.visibility.zone) // 'A' through 'D' +``` + +--- + ### `getSunMoonEvents(date, observer, options?)` Rise, set, and twilight times. Requires kernel. @@ -223,6 +403,14 @@ interface OdehResult { } ``` +### `MoonVisibilityEstimate` + +See the `getMoonVisibilityEstimate` section above for the full definition. + +### `MoonSnapshot` + +See the `getMoon` section above for the full definition. + ### `AzAlt` ```ts diff --git a/.wiki/Getting-Started.md b/.wiki/Getting-Started.md index a7b2abe..2182a08 100644 --- a/.wiki/Getting-Started.md +++ b/.wiki/Getting-Started.md @@ -30,6 +30,7 @@ This downloads two files: - `naif0012.tls` (4 KB): leap-second table Default cache location: + - Linux/macOS: `~/.cache/moon-sighting/` - Windows: `%LOCALAPPDATA%\moon-sighting\` @@ -82,19 +83,40 @@ console.log(report.moonPosition) // { azimuth: 258.3, altitude: 7.9 } ``` -## Moon phase (no kernel needed) +## Kernel-free functions + +Three functions work without a kernel. They use Meeus Chapters 47 and 48 and are suitable for any runtime, including browsers. ```ts -import { getMoonPhase } from 'moon-sighting' +import { getMoonPhase, getMoonPosition, getMoonIllumination } from 'moon-sighting' +// Phase name, illumination percent, and next new/full moon dates const phase = getMoonPhase() console.log(phase.phase) // 'waxing-crescent' console.log(phase.illumination) // 23.4 console.log(phase.age) // 4.2 (hours since last new moon) console.log(phase.nextFullMoon) // Date -// For a specific date +// Topocentric position: azimuth, altitude (refraction applied), distance +// Accuracy: ~0.3° +const pos = getMoonPosition(new Date(), 51.5074, -0.1278, 10) +console.log(pos.azimuth) // degrees from North, clockwise +console.log(pos.altitude) // degrees above horizon +console.log(pos.distance) // km from Earth center to Moon center +console.log(pos.parallacticAngle) // radians + +// Illumination fraction and phase cycle position +// Accuracy: ~0.5% on fraction +const illum = getMoonIllumination() +console.log(illum.fraction) // 0–1 (0=new, 1=full) +console.log(illum.phase) // 0–1 cycle position (0=new, 0.5=full) +console.log(illum.angle) // bright limb position angle, radians +console.log(illum.isWaxing) // true when moving toward full moon + +// All three accept an optional Date for historical or future queries const past = getMoonPhase(new Date('2024-01-01')) +const pastPos = getMoonPosition(new Date('2024-01-01'), 21.4225, 39.8262) +const pastIllum = getMoonIllumination(new Date('2024-01-01')) ``` ## Rise and set times diff --git a/.wiki/Home.md b/.wiki/Home.md index 6e97f6f..d1ae198 100644 --- a/.wiki/Home.md +++ b/.wiki/Home.md @@ -16,7 +16,7 @@ It also computes moon phase data, rise/set times, and twilight periods for any l **Kernel not bundled.** The DE442S kernel is 31 MB. It is downloaded once to a local cache, verified by checksum, and reused. This keeps the npm package small. -**Lite mode without kernel.** Moon phase, illumination, and next new/full moon work immediately, no kernel needed. These use Meeus approximations (accurate to ~1°). +**Lite mode without kernel.** Five functions work immediately with no kernel: `getMoonPhase()` (phase, illumination, next events), `getMoonPosition()` (topocentric az/alt/distance), `getMoonIllumination()` (fraction, phase cycle, bright limb angle), `getMoonVisibilityEstimate()` (Odeh crescent estimate), and `getMoon()` (all four in one call). These use Meeus approximations (accurate to ~0.3–1°) and are a direct replacement for the equivalent `suncalc` moon functions. ## Pages diff --git a/CHANGELOG.md b/CHANGELOG.md index 7157fe5..0628f0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to moon-sighting are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.1.0] - 2026-02-25 + +### Added + +- `getMoonPosition(date, lat, lon, elevation?)` — topocentric Moon azimuth, altitude, distance, + and parallactic angle via Meeus Ch. 47 (no kernel required, ~0.3° accuracy) +- `getMoonIllumination(date)` — illumination fraction, phase cycle position, bright limb + position angle, and waxing/waning flag via Meeus Ch. 47/48 (no kernel required) +- `getMoonVisibilityEstimate(date, lat, lon, elevation?)` — quick kernel-free Odeh crescent + visibility estimate using Meeus positions; returns V parameter, zone (A-D), ARCL, ARCV, W +- `getMoon(date, lat, lon, elevation?)` — combined convenience wrapper returning phase, + position, illumination, and visibility estimate in a single call +- `phaseName` and `phaseSymbol` fields on `MoonPhaseResult` — human-readable name + (e.g. "Waxing Crescent") and moon phase emoji (e.g. "🌒") +- `MoonPosition`, `MoonIlluminationResult`, `MoonVisibilityEstimate`, and `MoonSnapshot` + TypeScript types + ## [1.0.0] - 2026-02-25 ### Added diff --git a/README.md b/README.md index c918baa..e963d3f 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,52 @@ Returns a complete moon sighting report. | `moonPosition` | `AzAlt` | Moon azimuth/altitude at best time | | `guidance` | `string` | Plain-language sighting instructions | +### `getMoonPosition(date?, lat, lon, elevation?)` + +Compute the Moon's topocentric position. Works without a kernel. Uses Meeus Chapter 47 approximate positions (~0.3° accuracy). + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `date` | `Date?` | Date to evaluate (default: now) | +| `lat` | `number` | Geodetic latitude, degrees (north positive) | +| `lon` | `number` | Longitude, degrees (east positive) | +| `elevation` | `number?` | Height above ellipsoid, meters (default: 0) | + +**Returns** `MoonPosition`: + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `azimuth` | `number` | Degrees from North, clockwise (0–360) | +| `altitude` | `number` | Apparent altitude in degrees (refraction applied) | +| `distance` | `number` | Distance from Earth center to Moon center, km | +| `parallacticAngle` | `number` | Angle between zenith and north pole as seen from the Moon, radians | + +### `getMoonIllumination(date?)` + +Compute the Moon's illumination. Works without a kernel. Uses Meeus Chapter 47/48 (~0.5% illumination accuracy). + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `date` | `Date?` | Date to evaluate (default: now) | + +**Returns** `MoonIlluminationResult`: + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `fraction` | `number` | Illuminated fraction, 0 (new moon) to 1 (full moon) | +| `phase` | `number` | Position in the 0–1 lunar cycle: 0=new, 0.25=first quarter, 0.5=full, 0.75=last quarter | +| `angle` | `number` | Position angle of the bright limb midpoint, eastward from north, radians | +| `isWaxing` | `boolean` | True when elongation is increasing (new moon toward full moon) | + ### `getMoonPhase(date?)` Compute the Moon's current phase. Works without a kernel. | Field | Type | Description | | ----- | ---- | ----------- | -| `phase` | `string` | Phase name (e.g., `'waxing-crescent'`) | +| `phase` | `string` | Phase key (e.g., `'waxing-crescent'`) | +| `phaseName` | `string` | Display name (e.g., `'Waxing Crescent'`) | +| `phaseSymbol` | `string` | Moon emoji (e.g., `'🌒'`) | | `illumination` | `number` | Illuminated fraction, 0–100 | | `age` | `number` | Hours since last new moon | | `isWaxing` | `boolean` | True when illumination is increasing | @@ -117,6 +156,35 @@ Compute the Moon's current phase. Works without a kernel. | `nextNewMoon` | `Date` | Time of next new moon | | `nextFullMoon` | `Date` | Time of next full moon | +### `getMoonVisibilityEstimate(date?, lat, lon, elevation?)` + +Quick kernel-free Odeh crescent visibility estimate. Pass an estimated post-sunset observation time. Returns V parameter, zone A–D, ARCL, ARCV, W, and `moonAboveHorizon`. For precise crescent work use `getMoonSightingReport()`. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `zone` | `'A'`–`'D'` | Odeh visibility zone | +| `V` | `number` | Odeh V parameter (positive = crescent exceeds threshold) | +| `isVisibleNakedEye` | `boolean` | True for zone A | +| `isVisibleWithOpticalAid` | `boolean` | True for zones A and B | +| `ARCL` | `number` | Elongation, degrees | +| `ARCV` | `number` | Moon alt − Sun alt (airless), degrees | +| `W` | `number` | Crescent width, arc minutes | +| `moonAboveHorizon` | `boolean` | Moon above horizon at given time | +| `isApproximate` | `true` | Always true: Meeus approximation | + +### `getMoon(date?, lat, lon, elevation?)` + +Convenience wrapper returning phase, position, illumination, and visibility estimate in one call. Works without a kernel. + +```ts +const moon = getMoon(new Date(), 51.5074, -0.1278, 10) +moon.phase.phaseName // 'Waxing Crescent' +moon.phase.phaseSymbol // '🌒' +moon.position.altitude // degrees above horizon +moon.illumination.fraction // 0.0 to 1.0 +moon.visibility.zone // 'A' through 'D' +``` + ### `getSunMoonEvents(date, observer)` Get rise, set, and twilight times. Requires kernel. @@ -161,6 +229,52 @@ Verify cached kernels by SHA-256 checksum. | C | V ≥ -0.96 | Visible with optical aid only | | D | V < -0.96 | Not visible even with optical aid | +## Kernel-free utilities + +Five functions work without loading any kernel. They use Meeus Chapters 47 and 48. Use them for display, widgets, and any context where JPL-grade accuracy is not required. + +```ts +import { + getMoonPhase, getMoonPosition, getMoonIllumination, + getMoonVisibilityEstimate, getMoon, +} from 'moon-sighting' + +// Current phase with display name and emoji +const phase = getMoonPhase() +console.log(phase.phase) // 'waxing-crescent' +console.log(phase.phaseName) // 'Waxing Crescent' +console.log(phase.phaseSymbol) // '🌒' +console.log(phase.illumination) // 14.3 (percent) +console.log(phase.nextFullMoon) // Date + +// Topocentric position (azimuth, altitude, distance) +const pos = getMoonPosition(new Date(), 51.5074, -0.1278, 10) +console.log(pos.azimuth) // 214.7 (degrees from North) +console.log(pos.altitude) // 38.2 (degrees above horizon, refraction applied) +console.log(pos.distance) // 384400 (km) + +// Illumination fraction and phase angle +const illum = getMoonIllumination() +console.log(illum.fraction) // 0.143 (0=new, 1=full) +console.log(illum.phase) // 0.09 (0–1 cycle position) +console.log(illum.isWaxing) // true + +// Quick Odeh crescent visibility estimate (pass a post-sunset time) +const vis = getMoonVisibilityEstimate(new Date('2025-03-02T18:30:00Z'), 51.5074, -0.1278) +console.log(vis.zone) // 'A' through 'D' +console.log(vis.V) // Odeh V parameter +console.log(vis.isVisibleNakedEye) // true/false + +// All four in a single call +const moon = getMoon(new Date(), 51.5074, -0.1278, 10) +console.log(moon.phase.phaseName) // 'Waxing Crescent' +console.log(moon.position.altitude) // degrees +console.log(moon.illumination.fraction) // 0.0–1.0 +console.log(moon.visibility.zone) // 'A'–'D' +``` + +These are a direct replacement for the equivalent `suncalc` moon functions, with no external dependencies. + ## Architecture ```text @@ -216,6 +330,11 @@ npx moon-sighting benchmark import type { Observer, MoonSightingReport, + MoonPhaseResult, + MoonPosition, + MoonIlluminationResult, + MoonVisibilityEstimate, + MoonSnapshot, YallopCategory, OdehZone, KernelConfig, diff --git a/package.json b/package.json index 21a3276..4544600 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moon-sighting", - "version": "1.0.0", + "version": "1.1.0", "description": "High-accuracy lunar crescent visibility and moon sighting calculations using JPL DE442S ephemerides. Implements Yallop and Odeh criteria for Islamic crescent sighting workflows.", "author": "Aric Camarata", "license": "MIT", diff --git a/src/api/index.ts b/src/api/index.ts index 587fb63..438123d 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -20,10 +20,16 @@ import type { MoonSightingReport, MoonPhaseResult, MoonPhaseName, + MoonPosition, + MoonIlluminationResult, + MoonVisibilityEstimate, + MoonSnapshot, SunMoonEvents, KernelConfig, + OdehZone, Vec3, } from '../types.js' +import { ODEH_THRESHOLDS, ODEH_DESCRIPTIONS } from '../types.js' import { SpkKernel } from '../spk/index.js' import { computeTimeScales, @@ -42,7 +48,7 @@ import { geodeticToECEF, computeAzAlt, } from '../observer/index.js' -import { itrsToGcrs } from '../frames/index.js' +import { itrsToGcrs, computeERA } from '../frames/index.js' import { getSunMoonEvents as eventsGetSunMoonEvents, bestTimeHeuristic, @@ -426,6 +432,19 @@ function buildNullReport( } } +// ─── Phase display lookup ────────────────────────────────────────────────────── + +const PHASE_DISPLAY: Record = { + 'new-moon': { name: 'New Moon', symbol: '🌑' }, + 'waxing-crescent': { name: 'Waxing Crescent', symbol: '🌒' }, + 'first-quarter': { name: 'First Quarter', symbol: '🌓' }, + 'waxing-gibbous': { name: 'Waxing Gibbous', symbol: '🌔' }, + 'full-moon': { name: 'Full Moon', symbol: '🌕' }, + 'waning-gibbous': { name: 'Waning Gibbous', symbol: '🌖' }, + 'last-quarter': { name: 'Last Quarter', symbol: '🌗' }, + 'waning-crescent': { name: 'Waning Crescent', symbol: '🌘' }, +} + /** * Compute the Moon's current phase, illumination, and next phase times. * @@ -437,9 +456,11 @@ function buildNullReport( * @example * ```ts * const phase = getMoonPhase(new Date()) - * console.log(phase.phase) // 'waxing-crescent' - * console.log(phase.illumination) // 14.3 (percent) - * console.log(phase.nextFullMoon) // Date object + * console.log(phase.phase) // 'waxing-crescent' + * console.log(phase.phaseName) // 'Waxing Crescent' + * console.log(phase.phaseSymbol) // '🌒' + * console.log(phase.illumination)// 14.3 (percent) + * console.log(phase.nextFullMoon)// Date object * ``` */ export function getMoonPhase(date = new Date()): MoonPhaseResult { @@ -454,13 +475,16 @@ export function getMoonPhase(date = new Date()): MoonPhaseResult { const prevNewMoonJD = nearestNewMoon(ts.jdTT - 15) const age = (ts.jdTT - prevNewMoonJD) * 24 - const phaseName = elongationToPhase(elongationDeg, isWaxing) + const phaseKey = elongationToPhase(elongationDeg, isWaxing) + const { name: phaseName, symbol: phaseSymbol } = PHASE_DISPLAY[phaseKey] const nextNewMoonJD = nearestNewMoon(ts.jdTT + 15) const nextFullMoonJD = nearestFullMoon(ts.jdTT) return { - phase: phaseName, + phase: phaseKey, + phaseName, + phaseSymbol, illumination: illuminationPct, age, elongationDeg, @@ -471,6 +495,224 @@ export function getMoonPhase(date = new Date()): MoonPhaseResult { } } +/** + * Compute the Moon's topocentric position (azimuth, altitude, distance) for an observer. + * + * Works WITHOUT a kernel (uses Meeus Ch. 47 approximation). + * Accuracy: azimuth/altitude ~0.3°, distance ~300 km. + * For precision crescent work, use getMoonSightingReport() with the DE442S kernel. + * + * @param date - Date and time to compute position for (default: now) + * @param lat - Observer geodetic latitude in degrees (north positive) + * @param lon - Observer longitude in degrees (east positive) + * @param elevation - Observer height above WGS84 ellipsoid in meters (default: 0) + * @returns Topocentric az/alt (degrees), distance (km), parallactic angle (radians) + * + * @example + * ```ts + * const pos = getMoonPosition(new Date(), 51.5, -0.1) + * console.log(pos.azimuth, pos.altitude) // e.g. 212.4, 38.1 + * ``` + */ +export function getMoonPosition( + date: Date = new Date(), + lat: number, + lon: number, + elevation = 0, +): MoonPosition { + const DEG = Math.PI / 180 + const ts = computeTimeScales(date) + const { moonGCRS } = getMoonSunApproximate(ts.jdTT) + + // Apparent az/alt with Bennett refraction — uses existing observer pipeline + const observer: Observer = { lat, lon, elevation } + const azAlt = computeAzAlt(moonGCRS, observer, ts, false) + + // Distance in km + const distance = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2) + + // Equatorial coordinates for parallactic angle + const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0]) + const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / distance))) + + // Hour angle: ERA(UT1) + longitude − right ascension + const era = computeERA(ts.jdUT1) + const HA = era + lon * DEG - RA_moon + + // Parallactic angle: signed angle between zenith and north pole as seen from the Moon + const parallacticAngle = Math.atan2( + Math.sin(HA), + Math.cos(lat * DEG) * Math.tan(dec_moon) - Math.sin(lat * DEG) * Math.cos(HA), + ) + + return { azimuth: azAlt.azimuth, altitude: azAlt.altitude, distance, parallacticAngle } +} + +/** + * Compute the Moon's illumination fraction, phase cycle position, and bright limb angle. + * + * Works WITHOUT a kernel (uses Meeus Ch. 47/48 approximation). + * Accuracy: illumination fraction ~0.5%, phase fraction ~0.003. + * Drop-in replacement for suncalc.getMoonIllumination() — same field names and conventions. + * + * @param date - Date to compute illumination for (default: now) + * @returns fraction (0-1), phase (0-1 cycle), angle (bright limb position angle, radians), isWaxing + * + * @example + * ```ts + * const illum = getMoonIllumination(new Date()) + * console.log(illum.fraction) // e.g. 0.43 (43% illuminated) + * console.log(illum.phase) // e.g. 0.18 (waxing crescent territory) + * ``` + */ +export function getMoonIllumination(date: Date = new Date()): MoonIlluminationResult { + const ts = computeTimeScales(date) + const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT) + + const { illumination, elongationDeg, isWaxing } = computeIllumination(moonGCRS, sunGCRS) + + // 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 + + // Position angle of the bright limb midpoint, measured eastward from north celestial pole. + // PA = atan2(cos(dec_sun) * sin(RA_sun - RA_moon), + // sin(dec_sun) * cos(dec_moon) - cos(dec_sun) * sin(dec_moon) * cos(RA_sun - RA_moon)) + const moonDist = Math.sqrt(moonGCRS[0] ** 2 + moonGCRS[1] ** 2 + moonGCRS[2] ** 2) + const sunDist = Math.sqrt(sunGCRS[0] ** 2 + sunGCRS[1] ** 2 + sunGCRS[2] ** 2) + + const RA_moon = Math.atan2(moonGCRS[1], moonGCRS[0]) + const dec_moon = Math.asin(Math.max(-1, Math.min(1, moonGCRS[2] / moonDist))) + const RA_sun = Math.atan2(sunGCRS[1], sunGCRS[0]) + const dec_sun = Math.asin(Math.max(-1, Math.min(1, sunGCRS[2] / sunDist))) + + const dRA = RA_sun - RA_moon + const angle = Math.atan2( + 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), + ) + + return { fraction: illumination, phase, angle, isWaxing } +} + +/** + * Quick kernel-free crescent visibility estimate using the Odeh criterion. + * + * Computes approximate crescent geometry (ARCL, ARCV, W) from Meeus Ch. 47 + * positions at the given observation time and applies the Odeh V-parameter formula. + * Accuracy is limited by the Meeus approximation (~0.3°) and the fact that + * "best time" is not computed — pass your estimated observation time. + * + * For precise crescent work, use getMoonSightingReport() with the DE442S kernel. + * + * @param date - Observation time (default: now). Use a post-sunset time for best results. + * @param lat - Observer geodetic latitude in degrees (north positive) + * @param lon - Observer longitude in degrees (east positive) + * @param elevation - Observer height above WGS84 ellipsoid in meters (default: 0) + * @returns MoonVisibilityEstimate with Odeh V, zone, and geometry values + * + * @example + * ```ts + * // Estimate crescent visibility at sunset + 40 min in Mecca + * const obs = new Date('2025-03-01T15:30:00Z') // ~sunset + 40 min in Mecca + * const est = getMoonVisibilityEstimate(obs, 21.42, 39.83) + * console.log(est.zone) // 'A' through 'D' + * console.log(est.isVisibleNakedEye) // true/false + * ``` + */ +export function getMoonVisibilityEstimate( + date: Date = new Date(), + lat: number, + lon: number, + elevation = 0, +): MoonVisibilityEstimate { + const ts = computeTimeScales(date) + const { moonGCRS, sunGCRS } = getMoonSunApproximate(ts.jdTT) + const observer: Observer = { lat, lon, elevation } + + // Airless positions — Odeh uses airless altitudes (no refraction) + const moonAirless = computeAzAlt(moonGCRS, observer, ts, true) + const sunAirless = computeAzAlt(sunGCRS, observer, ts, true) + + // ARCL = elongation (geocentric, degrees) + const { elongationDeg } = computeIllumination(moonGCRS, sunGCRS) + const ARCL = elongationDeg + + // ARCV = Moon airless altitude minus Sun airless altitude + const ARCV = moonAirless.altitude - sunAirless.altitude + + // Topocentric Moon vector for crescent width + const obsECEF = geodeticToECEF(lat, lon, elevation) + const obsITRS: Vec3 = [obsECEF[0] / 1000, obsECEF[1] / 1000, obsECEF[2] / 1000] + const obsGCRS = itrsToGcrs(obsITRS, ts) + const moonTopo: Vec3 = [ + moonGCRS[0] - obsGCRS[0], + moonGCRS[1] - obsGCRS[1], + moonGCRS[2] - obsGCRS[2], + ] + + const { W } = computeCrescentWidth(moonTopo, ARCL) + + // Odeh 2006: V = ARCV - f(W), where f(W) = arcv_minimum polynomial + const arcvMin = -0.1018 * W ** 3 + 0.7319 * W ** 2 - 6.3226 * W + 7.1651 + const V = ARCV - arcvMin + + const zone: OdehZone = V >= ODEH_THRESHOLDS.A ? 'A' + : V >= ODEH_THRESHOLDS.B ? 'B' + : V >= ODEH_THRESHOLDS.C ? 'C' + : 'D' + + return { + V, + zone, + description: ODEH_DESCRIPTIONS[zone], + isVisibleNakedEye: zone === 'A', + isVisibleWithOpticalAid: zone === 'A' || zone === 'B', + ARCL, + ARCV, + W, + moonAboveHorizon: moonAirless.altitude > 0, + isApproximate: true, + } +} + +/** + * Combined kernel-free moon snapshot for a time and location. + * + * Calls getMoonPhase(), getMoonPosition(), getMoonIllumination(), and + * getMoonVisibilityEstimate() in a single request. Convenient for dashboards + * and apps that need all four values together. + * + * Works WITHOUT a kernel (all Meeus-based approximations). + * + * @param date - Date and time (default: now) + * @param lat - Observer geodetic latitude in degrees (north positive) + * @param lon - Observer longitude in degrees (east positive) + * @param elevation - Observer height above WGS84 ellipsoid in meters (default: 0) + * @returns MoonSnapshot with phase, position, illumination, and visibility estimate + * + * @example + * ```ts + * const moon = getMoon(new Date(), 51.5, -0.1) + * console.log(moon.phase.phaseName) // 'Waxing Crescent' + * console.log(moon.position.altitude) // degrees above horizon + * console.log(moon.illumination.fraction) // 0.0 to 1.0 + * console.log(moon.visibility.zone) // 'A' through 'D' + * ``` + */ +export function getMoon( + date: Date = new Date(), + lat: number, + lon: number, + elevation = 0, +): MoonSnapshot { + return { + phase: getMoonPhase(date), + position: getMoonPosition(date, lat, lon, elevation), + illumination: getMoonIllumination(date), + visibility: getMoonVisibilityEstimate(date, lat, lon, elevation), + } +} + // ─── Internal helpers ───────────────────────────────────────────────────────── /** Convert JD to a UTC Date. */ diff --git a/src/index.ts b/src/index.ts index 2d3b90a..7d09b93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,10 @@ export { getMoonSightingReport, getMoonPhase, + getMoonPosition, + getMoonIllumination, + getMoonVisibilityEstimate, + getMoon, getSunMoonEvents, initKernels, downloadKernels, @@ -34,6 +38,10 @@ export type { MoonSightingReport, MoonPhaseResult, MoonPhaseName, + MoonPosition, + MoonIlluminationResult, + MoonVisibilityEstimate, + MoonSnapshot, SunMoonEvents, CrescentGeometry, YallopResult, diff --git a/src/types.ts b/src/types.ts index 9db2f80..b7ae00f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,50 @@ export interface AzAlt { altitude: number } +// ─── Kernel-free moon results ───────────────────────────────────────────────── + +/** + * Topocentric moon position from getMoonPosition(). + * Computed via Meeus Ch. 47 (no kernel required). + * Accuracy: azimuth/altitude ~0.3°, distance ~300 km. + */ +export interface MoonPosition { + /** Azimuth in degrees from North, measured clockwise (0 = N, 90 = E, 180 = S, 270 = W) */ + azimuth: number + /** Apparent altitude in degrees above the horizon (atmospheric refraction applied) */ + altitude: number + /** Distance from Earth center to Moon center, km */ + distance: number + /** + * Parallactic angle in radians. + * 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. + */ + parallacticAngle: number +} + +/** + * Moon illumination from getMoonIllumination(). + * Computed via Meeus Ch. 47/48 (no kernel required). + * Accuracy: fraction ~0.5%, phase fraction ~0.003. + */ +export interface MoonIlluminationResult { + /** Illuminated fraction of the Moon disk, 0 (new moon) to 1 (full moon) */ + fraction: number + /** + * Phase cycle fraction in [0, 1): + * 0 = new moon, 0.25 = first quarter, 0.5 = full moon, 0.75 = last quarter + */ + phase: number + /** + * Position angle of the midpoint of the bright limb, measured eastward from + * the north celestial pole, in radians. Matches the suncalc convention. + */ + angle: number + /** True while elongation is increasing (new moon toward full moon) */ + isWaxing: boolean +} + // ─── Time ──────────────────────────────────────────────────────────────────── /** All relevant time scale values for a single moment */ @@ -182,6 +226,55 @@ export interface OdehResult { isVisibleWithOpticalAid: boolean } +// ─── Kernel-free visibility estimate ───────────────────────────────────────── + +/** + * Kernel-free Odeh-based crescent visibility estimate from getMoonVisibilityEstimate(). + * Computed via Meeus Ch. 47 approximation at the given observation time. + * For DE442S-quality results, use getMoonSightingReport(). + */ +export interface MoonVisibilityEstimate { + /** + * Odeh V parameter: V = ARCV − f(W). + * Positive = crescent exceeds minimum visibility threshold. + */ + V: number + /** Visibility zone A through D */ + zone: OdehZone + /** Human-readable zone description */ + description: string + /** True for zone A */ + isVisibleNakedEye: boolean + /** True for zones A and B */ + isVisibleWithOpticalAid: boolean + /** Arc of light (Sun-Moon elongation) in degrees */ + ARCL: number + /** Arc of vision (Moon airless altitude minus Sun airless altitude) in degrees */ + ARCV: number + /** Topocentric crescent width in arc minutes */ + W: number + /** True when Moon is above the horizon at the given time */ + moonAboveHorizon: boolean + /** Always true: computed via Meeus approximation, not DE442S */ + isApproximate: true +} + +/** + * Combined kernel-free moon snapshot from getMoon(). + * Bundles phase, position, illumination, and a quick visibility estimate + * into a single call. + */ +export interface MoonSnapshot { + /** Phase name, illumination, age, and next events */ + phase: MoonPhaseResult + /** Topocentric az/alt, distance, parallactic angle */ + position: MoonPosition + /** Illumination fraction, phase cycle, bright limb angle, waxing/waning */ + illumination: MoonIlluminationResult + /** Quick Odeh-based crescent visibility estimate */ + visibility: MoonVisibilityEstimate +} + // ─── Moon phase ────────────────────────────────────────────────────────────── export type MoonPhaseName = @@ -197,6 +290,10 @@ export type MoonPhaseName = export interface MoonPhaseResult { /** Named phase based on illumination and waxing/waning state */ phase: MoonPhaseName + /** Human-readable phase name, e.g. "Waxing Crescent" */ + phaseName: string + /** Moon phase emoji symbol, e.g. "🌒" */ + phaseSymbol: string /** Illuminated fraction 0-100 (percent) */ illumination: number /** Hours since last new moon */ diff --git a/test-cjs.cjs b/test-cjs.cjs index cce76c3..1fe466f 100644 --- a/test-cjs.cjs +++ b/test-cjs.cjs @@ -14,6 +14,10 @@ const { ODEH_DESCRIPTIONS, WGS84, getMoonPhase, + getMoonPosition, + getMoonIllumination, + getMoonVisibilityEstimate, + getMoon, initKernels, downloadKernels, verifyKernels, @@ -51,6 +55,10 @@ test('WGS84.a is 6378137.0', () => { }) test('All API functions are exported', () => { assert.equal(typeof getMoonPhase, 'function') + assert.equal(typeof getMoonPosition, 'function') + assert.equal(typeof getMoonIllumination, 'function') + assert.equal(typeof getMoonVisibilityEstimate, 'function') + assert.equal(typeof getMoon, 'function') assert.equal(typeof initKernels, 'function') assert.equal(typeof downloadKernels, 'function') assert.equal(typeof verifyKernels, 'function') @@ -83,6 +91,74 @@ test('getMoonPhase Dates are Date objects', () => { assert.ok(p.nextFullMoon instanceof Date) }) +console.log('\nCJS getMoonPosition + getMoonIllumination:') + +test('getMoonPosition returns valid azimuth/altitude', () => { + const pos = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10) + assert.ok(pos.azimuth >= 0 && pos.azimuth < 360, `azimuth=${pos.azimuth}`) + assert.ok(pos.altitude >= -90 && pos.altitude <= 90, `altitude=${pos.altitude}`) + assert.ok(pos.distance > 356000 && pos.distance < 407000, `distance=${pos.distance}`) + assert.ok(isFinite(pos.parallacticAngle)) +}) +test('getMoonIllumination near full moon: fraction > 0.85', () => { + const illum = getMoonIllumination(new Date('2025-03-14T12:00:00Z')) + assert.ok(illum.fraction > 0.85, `fraction=${illum.fraction.toFixed(3)}`) + assert.ok(illum.phase > 0.4 && illum.phase < 0.6, `phase=${illum.phase.toFixed(3)}`) + assert.ok(isFinite(illum.angle)) +}) +test('getMoonIllumination waxing: isWaxing = true', () => { + const illum = getMoonIllumination(new Date('2025-03-05T12:00:00Z')) + assert.equal(illum.isWaxing, true) +}) + +console.log('\nCJS getMoonPhase phaseName/phaseSymbol:') + +test('getMoonPhase.phaseName is a non-empty string', () => { + const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) + assert.ok(typeof p.phaseName === 'string' && p.phaseName.length > 0) +}) +test('getMoonPhase.phaseSymbol is a moon emoji', () => { + const SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']) + const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) + assert.ok(SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) +}) +test('Waxing crescent: phaseName = "Waxing Crescent", phaseSymbol = "🌒"', () => { + const p = getMoonPhase(new Date('2025-03-05T12:00:00Z')) + assert.equal(p.phaseName, 'Waxing Crescent') + assert.equal(p.phaseSymbol, '🌒') +}) + +console.log('\nCJS getMoonVisibilityEstimate:') + +test('getMoonVisibilityEstimate returns valid zone', () => { + const v = getMoonVisibilityEstimate(new Date('2025-03-02T18:30:00Z'), 51.5074, -0.1278, 10) + assert.ok(['A', 'B', 'C', 'D'].includes(v.zone), `zone=${v.zone}`) + assert.ok(isFinite(v.V)) + assert.equal(v.isApproximate, true) +}) +test('getMoonVisibilityEstimate near new moon: zone C or D', () => { + const v = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262) + assert.ok(['C', 'D'].includes(v.zone), `zone=${v.zone}`) +}) + +console.log('\nCJS getMoon:') + +test('getMoon returns all four sub-results', () => { + const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10) + assert.ok(typeof m.phase === 'object') + assert.ok(typeof m.position === 'object') + assert.ok(typeof m.illumination === 'object') + assert.ok(typeof m.visibility === 'object') +}) +test('getMoon.phase.phaseName is non-empty', () => { + const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278) + assert.ok(typeof m.phase.phaseName === 'string' && m.phase.phaseName.length > 0) +}) +test('getMoon.visibility.isApproximate is true', () => { + const m = getMoon(new Date(), 51.5074, -0.1278) + assert.equal(m.visibility.isApproximate, true) +}) + console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`) if (failed > 0) { diff --git a/test.mjs b/test.mjs index 3b8b840..fa4ea54 100644 --- a/test.mjs +++ b/test.mjs @@ -15,6 +15,10 @@ import { WGS84, // API getMoonPhase, + getMoonPosition, + getMoonIllumination, + getMoonVisibilityEstimate, + getMoon, initKernels, downloadKernels, verifyKernels, @@ -224,6 +228,223 @@ test('Synodic month duration is ~29.5 days (±0.5)', () => { ) }) +// ─── getMoonPosition ───────────────────────────────────────────────────────── + +console.log('\ngetMoonPosition:') + +// London on 2025-03-14 at noon UTC — Moon should be above the horizon during daytime +const moonPos_london = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10) + +test('getMoonPosition returns azimuth in [0, 360)', () => { + assert.ok( + moonPos_london.azimuth >= 0 && moonPos_london.azimuth < 360, + `azimuth=${moonPos_london.azimuth}`, + ) +}) +test('getMoonPosition returns altitude in [-90, 90]', () => { + assert.ok( + moonPos_london.altitude >= -90 && moonPos_london.altitude <= 90, + `altitude=${moonPos_london.altitude}`, + ) +}) +test('getMoonPosition returns distance in lunar orbit range [356000, 407000] km', () => { + assert.ok( + moonPos_london.distance >= 356000 && moonPos_london.distance <= 407000, + `distance=${moonPos_london.distance.toFixed(0)} km`, + ) +}) +test('getMoonPosition returns finite parallacticAngle', () => { + assert.ok( + isFinite(moonPos_london.parallacticAngle), + `parallacticAngle=${moonPos_london.parallacticAngle}`, + ) +}) +test('getMoonPosition default date (now) returns valid result', () => { + const pos = getMoonPosition(new Date(), 21.4225, 39.8262) // Mecca + assert.ok(pos.azimuth >= 0 && pos.azimuth < 360) + assert.ok(pos.altitude >= -90 && pos.altitude <= 90) + assert.ok(pos.distance > 350000 && pos.distance < 410000) +}) + +// ─── getMoonIllumination ───────────────────────────────────────────────────── + +console.log('\ngetMoonIllumination:') + +// 2025-03-14 was close to full moon +const illum_full = getMoonIllumination(new Date('2025-03-14T12:00:00Z')) +// 2025-03-29 was close to new moon +const illum_new = getMoonIllumination(new Date('2025-03-29T12:00:00Z')) +// 2025-03-05 was waxing crescent (~7 days after new moon) +const illum_waxing = getMoonIllumination(new Date('2025-03-05T12:00:00Z')) + +test('getMoonIllumination near full moon: fraction > 0.85', () => { + assert.ok(illum_full.fraction > 0.85, `fraction=${illum_full.fraction.toFixed(3)}`) +}) +test('getMoonIllumination near full moon: phase close to 0.5', () => { + assert.ok( + illum_full.phase > 0.4 && illum_full.phase < 0.6, + `phase=${illum_full.phase.toFixed(3)}`, + ) +}) +test('getMoonIllumination near new moon: fraction < 0.05', () => { + assert.ok(illum_new.fraction < 0.05, `fraction=${illum_new.fraction.toFixed(3)}`) +}) +test('getMoonIllumination near new moon: phase close to 0 or 1', () => { + const p = illum_new.phase + assert.ok(p < 0.08 || p > 0.92, `phase=${p.toFixed(3)}`) +}) +test('getMoonIllumination waxing: isWaxing = true', () => { + assert.equal(illum_waxing.isWaxing, true) +}) +test('getMoonIllumination fraction in [0, 1]', () => { + assert.ok(illum_full.fraction >= 0 && illum_full.fraction <= 1) + assert.ok(illum_new.fraction >= 0 && illum_new.fraction <= 1) +}) +test('getMoonIllumination phase in [0, 1)', () => { + assert.ok(illum_full.phase >= 0 && illum_full.phase < 1) + assert.ok(illum_new.phase >= 0 && illum_new.phase < 1) +}) +test('getMoonIllumination angle is finite', () => { + assert.ok(isFinite(illum_full.angle), `angle=${illum_full.angle}`) +}) +test('getMoonIllumination default date (now) returns valid result', () => { + const illum = getMoonIllumination() + assert.ok(illum.fraction >= 0 && illum.fraction <= 1) + assert.ok(illum.phase >= 0 && illum.phase < 1) + assert.equal(typeof illum.isWaxing, 'boolean') + assert.ok(isFinite(illum.angle)) +}) + +// ─── getMoonPhase phaseName + phaseSymbol ───────────────────────────────────── + +console.log('\ngetMoonPhase — phaseName + phaseSymbol:') + +const PHASE_NAMES = new Set([ + 'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', + 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent', +]) +const PHASE_SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']) + +test('getMoonPhase.phaseName is a valid human-readable name', () => { + const p = getMoonPhase(DATE_MARCH_1_2025) + assert.ok(PHASE_NAMES.has(p.phaseName), `got: ${p.phaseName}`) +}) +test('getMoonPhase.phaseSymbol is a moon emoji', () => { + const p = getMoonPhase(DATE_MARCH_1_2025) + assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) +}) +test('Near full moon: phaseName is "Full Moon" or gibbous', () => { + const valid = new Set(['Full Moon', 'Waxing Gibbous', 'Waning Gibbous']) + const p = getMoonPhase(DATE_FULL_MOON) + assert.ok(valid.has(p.phaseName), `got: ${p.phaseName}`) +}) +test('Near full moon: phaseSymbol is 🌕 or 🌔 or 🌖', () => { + const valid = new Set(['🌕', '🌔', '🌖']) + const p = getMoonPhase(DATE_FULL_MOON) + assert.ok(valid.has(p.phaseSymbol), `got: ${p.phaseSymbol}`) +}) +test('Waxing crescent: phaseName is "Waxing Crescent"', () => { + const p = getMoonPhase(DATE_WAXING) + assert.equal(p.phaseName, 'Waxing Crescent') +}) +test('Waxing crescent: phaseSymbol is 🌒', () => { + const p = getMoonPhase(DATE_WAXING) + assert.equal(p.phaseSymbol, '🌒') +}) +test('phaseName and phaseSymbol are consistent with phase key', () => { + // If phase is 'waning-crescent', phaseName should be 'Waning Crescent' + const p = getMoonPhase(DATE_WANING) + assert.equal(typeof p.phaseName, 'string') + assert.ok(p.phaseName.length > 0) + assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol)) +}) + +// ─── getMoonVisibilityEstimate ───────────────────────────────────────────────── + +console.log('\ngetMoonVisibilityEstimate:') + +// London, 40 min after nominal sunset on 2025-03-01 (day after new moon) +const DATE_VIS_ESTIMATE = new Date('2025-03-02T18:30:00Z') +const vis = getMoonVisibilityEstimate(DATE_VIS_ESTIMATE, 51.5074, -0.1278, 10) + +test('getMoonVisibilityEstimate returns an object', () => { + assert.ok(vis !== null && typeof vis === 'object') +}) +test('getMoonVisibilityEstimate.zone is A, B, C, or D', () => { + assert.ok(['A', 'B', 'C', 'D'].includes(vis.zone), `got: ${vis.zone}`) +}) +test('getMoonVisibilityEstimate.V is finite', () => { + assert.ok(isFinite(vis.V), `V=${vis.V}`) +}) +test('getMoonVisibilityEstimate.ARCL is in [0, 180]', () => { + assert.ok(vis.ARCL >= 0 && vis.ARCL <= 180, `ARCL=${vis.ARCL}`) +}) +test('getMoonVisibilityEstimate.W >= 0', () => { + assert.ok(vis.W >= 0, `W=${vis.W}`) +}) +test('getMoonVisibilityEstimate.isApproximate is true', () => { + assert.equal(vis.isApproximate, true) +}) +test('getMoonVisibilityEstimate.moonAboveHorizon is a boolean', () => { + assert.equal(typeof vis.moonAboveHorizon, 'boolean') +}) +test('getMoonVisibilityEstimate.isVisibleNakedEye matches zone A', () => { + assert.equal(vis.isVisibleNakedEye, vis.zone === 'A') +}) +test('getMoonVisibilityEstimate.isVisibleWithOpticalAid matches zone A or B', () => { + assert.equal(vis.isVisibleWithOpticalAid, vis.zone === 'A' || vis.zone === 'B') +}) +test('getMoonVisibilityEstimate.description is a non-empty string', () => { + assert.ok(typeof vis.description === 'string' && vis.description.length > 0) +}) +test('getMoonVisibilityEstimate default date works', () => { + const v = getMoonVisibilityEstimate(new Date(), 21.4225, 39.8262) + assert.ok(['A', 'B', 'C', 'D'].includes(v.zone)) + assert.ok(isFinite(v.V)) + assert.equal(v.isApproximate, true) +}) +// Near new moon: elongation small, W small, crescent should be very thin or invisible +test('Near new moon: zone is D or C (not visible or marginal)', () => { + const nearNew = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262) + assert.ok(['C', 'D'].includes(nearNew.zone), `zone=${nearNew.zone} V=${nearNew.V.toFixed(2)}`) +}) + +// ─── getMoon ────────────────────────────────────────────────────────────────── + +console.log('\ngetMoon:') + +const moon = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10) + +test('getMoon returns an object with phase, position, illumination, visibility', () => { + assert.ok(typeof moon === 'object') + assert.ok(typeof moon.phase === 'object') + assert.ok(typeof moon.position === 'object') + assert.ok(typeof moon.illumination === 'object') + assert.ok(typeof moon.visibility === 'object') +}) +test('getMoon.phase is consistent with getMoonPhase standalone', () => { + const standalone = getMoonPhase(new Date('2025-03-05T20:00:00Z')) + assert.equal(moon.phase.phase, standalone.phase) + assert.equal(moon.phase.phaseName, standalone.phaseName) +}) +test('getMoon.illumination.isWaxing matches phase.isWaxing', () => { + assert.equal(moon.illumination.isWaxing, moon.phase.isWaxing) +}) +test('getMoon.visibility.isApproximate is true', () => { + assert.equal(moon.visibility.isApproximate, true) +}) +test('getMoon.position has valid azimuth and altitude', () => { + assert.ok(moon.position.azimuth >= 0 && moon.position.azimuth < 360) + assert.ok(moon.position.altitude >= -90 && moon.position.altitude <= 90) +}) +test('getMoon default date works', () => { + const m = getMoon(new Date(), 21.4225, 39.8262) + assert.ok(PHASE_NAMES.has(m.phase.phaseName)) + assert.ok(isFinite(m.position.azimuth)) + assert.ok(isFinite(m.illumination.fraction)) + assert.ok(['A', 'B', 'C', 'D'].includes(m.visibility.zone)) +}) + // ─── Summary ───────────────────────────────────────────────────────────────── console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`)