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?)` ### `getMoonPhase(date?)`
Compute moon phase data. Works without a kernel. Compute moon phase data. Works without a kernel.
@ -117,6 +201,8 @@ function getMoonPhase(date?: Date): MoonPhaseResult
```ts ```ts
interface MoonPhaseResult { interface MoonPhaseResult {
phase: MoonPhaseName // 'new-moon' | 'waxing-crescent' | ... | 'waning-crescent' 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 illumination: number // 0100 percent
age: number // hours since last new moon age: number // hours since last new moon
elongationDeg: number // Moon - Sun ecliptic longitude, [0, 360) 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?)` ### `getSunMoonEvents(date, observer, options?)`
Rise, set, and twilight times. Requires kernel. 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` ### `AzAlt`
```ts ```ts

View file

@ -30,6 +30,7 @@ This downloads two files:
- `naif0012.tls` (4 KB): leap-second table - `naif0012.tls` (4 KB): leap-second table
Default cache location: Default cache location:
- Linux/macOS: `~/.cache/moon-sighting/` - Linux/macOS: `~/.cache/moon-sighting/`
- Windows: `%LOCALAPPDATA%\moon-sighting\` - Windows: `%LOCALAPPDATA%\moon-sighting\`
@ -82,19 +83,40 @@ console.log(report.moonPosition)
// { azimuth: 258.3, altitude: 7.9 } // { 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 ```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() const phase = getMoonPhase()
console.log(phase.phase) // 'waxing-crescent' console.log(phase.phase) // 'waxing-crescent'
console.log(phase.illumination) // 23.4 console.log(phase.illumination) // 23.4
console.log(phase.age) // 4.2 (hours since last new moon) console.log(phase.age) // 4.2 (hours since last new moon)
console.log(phase.nextFullMoon) // Date 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 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 ## 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. **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 ## Pages

View file

@ -3,6 +3,23 @@
All notable changes to moon-sighting are documented here. All notable changes to moon-sighting are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 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 ## [1.0.0] - 2026-02-25
### Added ### Added

121
README.md
View file

@ -103,13 +103,52 @@ Returns a complete moon sighting report.
| `moonPosition` | `AzAlt` | Moon azimuth/altitude at best time | | `moonPosition` | `AzAlt` | Moon azimuth/altitude at best time |
| `guidance` | `string` | Plain-language sighting instructions | | `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?)` ### `getMoonPhase(date?)`
Compute the Moon's current phase. Works without a kernel. Compute the Moon's current phase. Works without a kernel.
| Field | Type | Description | | 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 | | `illumination` | `number` | Illuminated fraction, 0100 |
| `age` | `number` | Hours since last new moon | | `age` | `number` | Hours since last new moon |
| `isWaxing` | `boolean` | True when illumination is increasing | | `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 | | `nextNewMoon` | `Date` | Time of next new moon |
| `nextFullMoon` | `Date` | Time of next full 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)` ### `getSunMoonEvents(date, observer)`
Get rise, set, and twilight times. Requires kernel. 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 | | C | V ≥ -0.96 | Visible with optical aid only |
| D | V < -0.96 | Not visible even with optical aid | | 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 ## Architecture
```text ```text
@ -216,6 +330,11 @@ npx moon-sighting benchmark
import type { import type {
Observer, Observer,
MoonSightingReport, MoonSightingReport,
MoonPhaseResult,
MoonPosition,
MoonIlluminationResult,
MoonVisibilityEstimate,
MoonSnapshot,
YallopCategory, YallopCategory,
OdehZone, OdehZone,
KernelConfig, KernelConfig,

View file

@ -1,6 +1,6 @@
{ {
"name": "moon-sighting", "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.", "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", "author": "Aric Camarata",
"license": "MIT", "license": "MIT",

View file

@ -20,10 +20,16 @@ import type {
MoonSightingReport, MoonSightingReport,
MoonPhaseResult, MoonPhaseResult,
MoonPhaseName, MoonPhaseName,
MoonPosition,
MoonIlluminationResult,
MoonVisibilityEstimate,
MoonSnapshot,
SunMoonEvents, SunMoonEvents,
KernelConfig, KernelConfig,
OdehZone,
Vec3, Vec3,
} from '../types.js' } from '../types.js'
import { ODEH_THRESHOLDS, ODEH_DESCRIPTIONS } from '../types.js'
import { SpkKernel } from '../spk/index.js' import { SpkKernel } from '../spk/index.js'
import { import {
computeTimeScales, computeTimeScales,
@ -42,7 +48,7 @@ import {
geodeticToECEF, geodeticToECEF,
computeAzAlt, computeAzAlt,
} from '../observer/index.js' } from '../observer/index.js'
import { itrsToGcrs } from '../frames/index.js' import { itrsToGcrs, computeERA } from '../frames/index.js'
import { import {
getSunMoonEvents as eventsGetSunMoonEvents, getSunMoonEvents as eventsGetSunMoonEvents,
bestTimeHeuristic, 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. * Compute the Moon's current phase, illumination, and next phase times.
* *
@ -437,9 +456,11 @@ function buildNullReport(
* @example * @example
* ```ts * ```ts
* const phase = getMoonPhase(new Date()) * const phase = getMoonPhase(new Date())
* console.log(phase.phase) // 'waxing-crescent' * console.log(phase.phase) // 'waxing-crescent'
* console.log(phase.illumination) // 14.3 (percent) * console.log(phase.phaseName) // 'Waxing Crescent'
* console.log(phase.nextFullMoon) // Date object * console.log(phase.phaseSymbol) // '🌒'
* console.log(phase.illumination)// 14.3 (percent)
* console.log(phase.nextFullMoon)// Date object
* ``` * ```
*/ */
export function getMoonPhase(date = new Date()): MoonPhaseResult { 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 prevNewMoonJD = nearestNewMoon(ts.jdTT - 15)
const age = (ts.jdTT - prevNewMoonJD) * 24 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 nextNewMoonJD = nearestNewMoon(ts.jdTT + 15)
const nextFullMoonJD = nearestFullMoon(ts.jdTT) const nextFullMoonJD = nearestFullMoon(ts.jdTT)
return { return {
phase: phaseName, phase: phaseKey,
phaseName,
phaseSymbol,
illumination: illuminationPct, illumination: illuminationPct,
age, age,
elongationDeg, 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 ───────────────────────────────────────────────────────── // ─── Internal helpers ─────────────────────────────────────────────────────────
/** Convert JD to a UTC Date. */ /** Convert JD to a UTC Date. */

View file

@ -19,6 +19,10 @@
export { export {
getMoonSightingReport, getMoonSightingReport,
getMoonPhase, getMoonPhase,
getMoonPosition,
getMoonIllumination,
getMoonVisibilityEstimate,
getMoon,
getSunMoonEvents, getSunMoonEvents,
initKernels, initKernels,
downloadKernels, downloadKernels,
@ -34,6 +38,10 @@ export type {
MoonSightingReport, MoonSightingReport,
MoonPhaseResult, MoonPhaseResult,
MoonPhaseName, MoonPhaseName,
MoonPosition,
MoonIlluminationResult,
MoonVisibilityEstimate,
MoonSnapshot,
SunMoonEvents, SunMoonEvents,
CrescentGeometry, CrescentGeometry,
YallopResult, YallopResult,

View file

@ -17,6 +17,50 @@ export interface AzAlt {
altitude: number 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 ──────────────────────────────────────────────────────────────────── // ─── Time ────────────────────────────────────────────────────────────────────
/** All relevant time scale values for a single moment */ /** All relevant time scale values for a single moment */
@ -182,6 +226,55 @@ export interface OdehResult {
isVisibleWithOpticalAid: boolean 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 ────────────────────────────────────────────────────────────── // ─── Moon phase ──────────────────────────────────────────────────────────────
export type MoonPhaseName = export type MoonPhaseName =
@ -197,6 +290,10 @@ export type MoonPhaseName =
export interface MoonPhaseResult { export interface MoonPhaseResult {
/** Named phase based on illumination and waxing/waning state */ /** Named phase based on illumination and waxing/waning state */
phase: MoonPhaseName phase: MoonPhaseName
/** Human-readable phase name, e.g. "Waxing Crescent" */
phaseName: string
/** Moon phase emoji symbol, e.g. "🌒" */
phaseSymbol: string
/** Illuminated fraction 0-100 (percent) */ /** Illuminated fraction 0-100 (percent) */
illumination: number illumination: number
/** Hours since last new moon */ /** Hours since last new moon */

View file

@ -14,6 +14,10 @@ const {
ODEH_DESCRIPTIONS, ODEH_DESCRIPTIONS,
WGS84, WGS84,
getMoonPhase, getMoonPhase,
getMoonPosition,
getMoonIllumination,
getMoonVisibilityEstimate,
getMoon,
initKernels, initKernels,
downloadKernels, downloadKernels,
verifyKernels, verifyKernels,
@ -51,6 +55,10 @@ test('WGS84.a is 6378137.0', () => {
}) })
test('All API functions are exported', () => { test('All API functions are exported', () => {
assert.equal(typeof getMoonPhase, 'function') 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 initKernels, 'function')
assert.equal(typeof downloadKernels, 'function') assert.equal(typeof downloadKernels, 'function')
assert.equal(typeof verifyKernels, 'function') assert.equal(typeof verifyKernels, 'function')
@ -83,6 +91,74 @@ test('getMoonPhase Dates are Date objects', () => {
assert.ok(p.nextFullMoon instanceof Date) assert.ok(p.nextFullMoon instanceof Date)
}) })
console.log('\nCJS getMoonPosition + getMoonIllumination:')
test('getMoonPosition returns valid azimuth/altitude', () => {
const pos = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10)
assert.ok(pos.azimuth >= 0 && pos.azimuth < 360, `azimuth=${pos.azimuth}`)
assert.ok(pos.altitude >= -90 && pos.altitude <= 90, `altitude=${pos.altitude}`)
assert.ok(pos.distance > 356000 && pos.distance < 407000, `distance=${pos.distance}`)
assert.ok(isFinite(pos.parallacticAngle))
})
test('getMoonIllumination near full moon: fraction > 0.85', () => {
const illum = getMoonIllumination(new Date('2025-03-14T12:00:00Z'))
assert.ok(illum.fraction > 0.85, `fraction=${illum.fraction.toFixed(3)}`)
assert.ok(illum.phase > 0.4 && illum.phase < 0.6, `phase=${illum.phase.toFixed(3)}`)
assert.ok(isFinite(illum.angle))
})
test('getMoonIllumination waxing: isWaxing = true', () => {
const illum = getMoonIllumination(new Date('2025-03-05T12:00:00Z'))
assert.equal(illum.isWaxing, true)
})
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`) console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`)
if (failed > 0) { if (failed > 0) {

221
test.mjs
View file

@ -15,6 +15,10 @@ import {
WGS84, WGS84,
// API // API
getMoonPhase, getMoonPhase,
getMoonPosition,
getMoonIllumination,
getMoonVisibilityEstimate,
getMoon,
initKernels, initKernels,
downloadKernels, downloadKernels,
verifyKernels, 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 ───────────────────────────────────────────────────────────────── // ─── Summary ─────────────────────────────────────────────────────────────────
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`) console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`)