v1.1.0: kernel-free moon position, illumination, visibility, and convenience API

Adds five new kernel-free functions using Meeus Ch. 47/48 approximations,
replacing suncalc as the moon data source in the acamarata stack.

- getMoonPosition(date, lat, lon, elevation?) — topocentric az/alt/distance +
  parallactic angle via WGS84 geodetic model and Bennett refraction
- getMoonIllumination(date) — illumination fraction, phase cycle position,
  bright limb angle, isWaxing via Meeus Ch. 47/48
- getMoonPhase: adds phaseName ("Waxing Crescent") and phaseSymbol ("🌒")
  fields to MoonPhaseResult
- getMoonVisibilityEstimate(date, lat, lon, elevation?) — kernel-free Odeh
  V-parameter crescent visibility estimate; returns zone A-D, V, ARCL, ARCV, W
- getMoon(date, lat, lon, elevation?) — convenience wrapper combining all four
  kernel-free functions into a single MoonSnapshot result

New types: MoonPosition, MoonIlluminationResult, MoonVisibilityEstimate, MoonSnapshot
98 tests (78 ESM + 20 CJS), typecheck clean, zero build warnings
This commit is contained in:
Aric Camarata 2026-02-25 16:41:03 -05:00
parent 2ed1a7188a
commit 3b666c6465
12 changed files with 1006 additions and 12 deletions

4
.markdownlint.json Normal file
View file

@ -0,0 +1,4 @@
{
"MD013": false,
"MD024": { "siblings_only": true }
}

View file

@ -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 (0360)
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 01 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 // 0100 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

View file

@ -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) // 01 (0=new, 1=full)
console.log(illum.phase) // 01 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

View file

@ -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.31°) and are a direct replacement for the equivalent `suncalc` moon functions.
## Pages

View file

@ -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

121
README.md
View file

@ -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 (0360) |
| `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 01 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, 0100 |
| `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 AD, 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 (01 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.01.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,

View file

@ -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",

View file

@ -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<MoonPhaseName, { name: string; symbol: string }> = {
'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. */

View file

@ -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,

View file

@ -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 */

View file

@ -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) {

221
test.mjs
View file

@ -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`)