mirror of
https://github.com/acamarata/moon-sighting.git
synced 2026-06-30 19:04:24 +00:00
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:
parent
2ed1a7188a
commit
3b666c6465
12 changed files with 1006 additions and 12 deletions
4
.markdownlint.json
Normal file
4
.markdownlint.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"MD013": false,
|
||||
"MD024": { "siblings_only": true }
|
||||
}
|
||||
|
|
@ -104,6 +104,90 @@ interface MoonSightingReport {
|
|||
|
||||
---
|
||||
|
||||
### `getMoonPosition(date?, lat, lon, elevation?)`
|
||||
|
||||
Compute the Moon's topocentric position. Works without a kernel. Uses Meeus Chapter 47 approximate positions (~0.3° accuracy).
|
||||
|
||||
```ts
|
||||
function getMoonPosition(
|
||||
date: Date | undefined,
|
||||
lat: number,
|
||||
lon: number,
|
||||
elevation?: number,
|
||||
): MoonPosition
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `date` | `Date?` | Date to evaluate. Defaults to now |
|
||||
| `lat` | `number` | Geodetic latitude, degrees (north positive) |
|
||||
| `lon` | `number` | Longitude, degrees (east positive) |
|
||||
| `elevation` | `number?` | Height above ellipsoid, meters. Default: 0 |
|
||||
|
||||
**MoonPosition:**
|
||||
|
||||
```ts
|
||||
interface MoonPosition {
|
||||
azimuth: number // Degrees from North, clockwise (0–360)
|
||||
altitude: number // Apparent altitude, degrees (refraction applied)
|
||||
distance: number // Earth center to Moon center, km
|
||||
parallacticAngle: number // Angle between zenith and north pole as seen from Moon, radians
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```ts
|
||||
import { getMoonPosition } from 'moon-sighting'
|
||||
|
||||
const pos = getMoonPosition(new Date(), 51.5074, -0.1278, 10)
|
||||
console.log(pos.azimuth) // 214.7
|
||||
console.log(pos.altitude) // 38.2
|
||||
console.log(pos.distance) // 384400
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getMoonIllumination(date?)`
|
||||
|
||||
Compute the Moon's illumination fraction and phase angle. Works without a kernel. Uses Meeus Chapters 47 and 48 (~0.5% illumination accuracy).
|
||||
|
||||
```ts
|
||||
function getMoonIllumination(date?: Date): MoonIlluminationResult
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `date` | `Date?` | Date to evaluate. Defaults to now |
|
||||
|
||||
**MoonIlluminationResult:**
|
||||
|
||||
```ts
|
||||
interface MoonIlluminationResult {
|
||||
fraction: number // Illuminated fraction, 0 (new moon) to 1 (full moon)
|
||||
phase: number // Position in 0–1 cycle: 0=new, 0.25=first quarter, 0.5=full, 0.75=last quarter
|
||||
angle: number // Position angle of bright limb midpoint, eastward from north, radians
|
||||
isWaxing: boolean // True when elongation is increasing (new moon toward full moon)
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```ts
|
||||
import { getMoonIllumination } from 'moon-sighting'
|
||||
|
||||
const illum = getMoonIllumination()
|
||||
console.log(illum.fraction) // 0.143
|
||||
console.log(illum.phase) // 0.09
|
||||
console.log(illum.isWaxing) // true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getMoonPhase(date?)`
|
||||
|
||||
Compute moon phase data. Works without a kernel.
|
||||
|
|
@ -117,6 +201,8 @@ function getMoonPhase(date?: Date): MoonPhaseResult
|
|||
```ts
|
||||
interface MoonPhaseResult {
|
||||
phase: MoonPhaseName // 'new-moon' | 'waxing-crescent' | ... | 'waning-crescent'
|
||||
phaseName: string // Display name, e.g. 'Waxing Crescent'
|
||||
phaseSymbol: string // Moon emoji, e.g. '🌒'
|
||||
illumination: number // 0–100 percent
|
||||
age: number // hours since last new moon
|
||||
elongationDeg: number // Moon - Sun ecliptic longitude, [0, 360)
|
||||
|
|
@ -129,6 +215,100 @@ interface MoonPhaseResult {
|
|||
|
||||
---
|
||||
|
||||
### `getMoonVisibilityEstimate(date?, lat, lon, elevation?)`
|
||||
|
||||
Quick kernel-free crescent visibility estimate using the Odeh V-parameter formula. Computes approximate crescent geometry from Meeus Ch. 47 positions at the given observation time.
|
||||
|
||||
Best used at an estimated post-sunset observation time. For precise crescent work, use `getMoonSightingReport()` with the DE442S kernel.
|
||||
|
||||
```ts
|
||||
function getMoonVisibilityEstimate(
|
||||
date?: Date,
|
||||
lat: number,
|
||||
lon: number,
|
||||
elevation?: number,
|
||||
): MoonVisibilityEstimate
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `date` | `Date?` | Observation time. Defaults to now. Use a post-sunset time for meaningful results |
|
||||
| `lat` | `number` | Geodetic latitude, degrees (north positive) |
|
||||
| `lon` | `number` | Longitude, degrees (east positive) |
|
||||
| `elevation` | `number?` | Height above ellipsoid, meters. Default: 0 |
|
||||
|
||||
**MoonVisibilityEstimate:**
|
||||
|
||||
```ts
|
||||
interface MoonVisibilityEstimate {
|
||||
V: number // Odeh V parameter: V = ARCV - f(W). Positive = crescent exceeds threshold
|
||||
zone: OdehZone // 'A' | 'B' | 'C' | 'D'
|
||||
description: string // Human-readable zone description
|
||||
isVisibleNakedEye: boolean // True for zone A
|
||||
isVisibleWithOpticalAid: boolean // True for zones A and B
|
||||
ARCL: number // Arc of light (elongation), degrees
|
||||
ARCV: number // Arc of vision (Moon alt - Sun alt, airless), degrees
|
||||
W: number // Topocentric crescent width, arc minutes
|
||||
moonAboveHorizon: boolean // True when Moon is above the horizon at the given time
|
||||
isApproximate: true // Always true: Meeus approximation, not DE442S
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```ts
|
||||
import { getMoonVisibilityEstimate } from 'moon-sighting'
|
||||
|
||||
// ~40 min after sunset in Mecca, day after new moon
|
||||
const est = getMoonVisibilityEstimate(new Date('2025-03-02T15:30:00Z'), 21.42, 39.83)
|
||||
console.log(est.zone) // 'A' through 'D'
|
||||
console.log(est.V) // Odeh V parameter
|
||||
console.log(est.isVisibleNakedEye) // true/false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getMoon(date?, lat, lon, elevation?)`
|
||||
|
||||
Combined kernel-free snapshot: phase, position, illumination, and visibility estimate in one call.
|
||||
|
||||
```ts
|
||||
function getMoon(
|
||||
date?: Date,
|
||||
lat: number,
|
||||
lon: number,
|
||||
elevation?: number,
|
||||
): MoonSnapshot
|
||||
```
|
||||
|
||||
**MoonSnapshot:**
|
||||
|
||||
```ts
|
||||
interface MoonSnapshot {
|
||||
phase: MoonPhaseResult // getMoonPhase() result
|
||||
position: MoonPosition // getMoonPosition() result
|
||||
illumination: MoonIlluminationResult // getMoonIllumination() result
|
||||
visibility: MoonVisibilityEstimate // getMoonVisibilityEstimate() result
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```ts
|
||||
import { getMoon } from 'moon-sighting'
|
||||
|
||||
const moon = getMoon(new Date(), 51.5074, -0.1278, 10)
|
||||
console.log(moon.phase.phaseName) // 'Waxing Crescent'
|
||||
console.log(moon.phase.phaseSymbol) // '🌒'
|
||||
console.log(moon.position.altitude) // degrees above horizon
|
||||
console.log(moon.illumination.fraction) // 0.0 to 1.0
|
||||
console.log(moon.visibility.zone) // 'A' through 'D'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getSunMoonEvents(date, observer, options?)`
|
||||
|
||||
Rise, set, and twilight times. Requires kernel.
|
||||
|
|
@ -223,6 +403,14 @@ interface OdehResult {
|
|||
}
|
||||
```
|
||||
|
||||
### `MoonVisibilityEstimate`
|
||||
|
||||
See the `getMoonVisibilityEstimate` section above for the full definition.
|
||||
|
||||
### `MoonSnapshot`
|
||||
|
||||
See the `getMoon` section above for the full definition.
|
||||
|
||||
### `AzAlt`
|
||||
|
||||
```ts
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ This downloads two files:
|
|||
- `naif0012.tls` (4 KB): leap-second table
|
||||
|
||||
Default cache location:
|
||||
|
||||
- Linux/macOS: `~/.cache/moon-sighting/`
|
||||
- Windows: `%LOCALAPPDATA%\moon-sighting\`
|
||||
|
||||
|
|
@ -82,19 +83,40 @@ console.log(report.moonPosition)
|
|||
// { azimuth: 258.3, altitude: 7.9 }
|
||||
```
|
||||
|
||||
## Moon phase (no kernel needed)
|
||||
## Kernel-free functions
|
||||
|
||||
Three functions work without a kernel. They use Meeus Chapters 47 and 48 and are suitable for any runtime, including browsers.
|
||||
|
||||
```ts
|
||||
import { getMoonPhase } from 'moon-sighting'
|
||||
import { getMoonPhase, getMoonPosition, getMoonIllumination } from 'moon-sighting'
|
||||
|
||||
// Phase name, illumination percent, and next new/full moon dates
|
||||
const phase = getMoonPhase()
|
||||
console.log(phase.phase) // 'waxing-crescent'
|
||||
console.log(phase.illumination) // 23.4
|
||||
console.log(phase.age) // 4.2 (hours since last new moon)
|
||||
console.log(phase.nextFullMoon) // Date
|
||||
|
||||
// For a specific date
|
||||
// Topocentric position: azimuth, altitude (refraction applied), distance
|
||||
// Accuracy: ~0.3°
|
||||
const pos = getMoonPosition(new Date(), 51.5074, -0.1278, 10)
|
||||
console.log(pos.azimuth) // degrees from North, clockwise
|
||||
console.log(pos.altitude) // degrees above horizon
|
||||
console.log(pos.distance) // km from Earth center to Moon center
|
||||
console.log(pos.parallacticAngle) // radians
|
||||
|
||||
// Illumination fraction and phase cycle position
|
||||
// Accuracy: ~0.5% on fraction
|
||||
const illum = getMoonIllumination()
|
||||
console.log(illum.fraction) // 0–1 (0=new, 1=full)
|
||||
console.log(illum.phase) // 0–1 cycle position (0=new, 0.5=full)
|
||||
console.log(illum.angle) // bright limb position angle, radians
|
||||
console.log(illum.isWaxing) // true when moving toward full moon
|
||||
|
||||
// All three accept an optional Date for historical or future queries
|
||||
const past = getMoonPhase(new Date('2024-01-01'))
|
||||
const pastPos = getMoonPosition(new Date('2024-01-01'), 21.4225, 39.8262)
|
||||
const pastIllum = getMoonIllumination(new Date('2024-01-01'))
|
||||
```
|
||||
|
||||
## Rise and set times
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ It also computes moon phase data, rise/set times, and twilight periods for any l
|
|||
|
||||
**Kernel not bundled.** The DE442S kernel is 31 MB. It is downloaded once to a local cache, verified by checksum, and reused. This keeps the npm package small.
|
||||
|
||||
**Lite mode without kernel.** Moon phase, illumination, and next new/full moon work immediately, no kernel needed. These use Meeus approximations (accurate to ~1°).
|
||||
**Lite mode without kernel.** Five functions work immediately with no kernel: `getMoonPhase()` (phase, illumination, next events), `getMoonPosition()` (topocentric az/alt/distance), `getMoonIllumination()` (fraction, phase cycle, bright limb angle), `getMoonVisibilityEstimate()` (Odeh crescent estimate), and `getMoon()` (all four in one call). These use Meeus approximations (accurate to ~0.3–1°) and are a direct replacement for the equivalent `suncalc` moon functions.
|
||||
|
||||
## Pages
|
||||
|
||||
|
|
|
|||
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -3,6 +3,23 @@
|
|||
All notable changes to moon-sighting are documented here.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [1.1.0] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- `getMoonPosition(date, lat, lon, elevation?)` — topocentric Moon azimuth, altitude, distance,
|
||||
and parallactic angle via Meeus Ch. 47 (no kernel required, ~0.3° accuracy)
|
||||
- `getMoonIllumination(date)` — illumination fraction, phase cycle position, bright limb
|
||||
position angle, and waxing/waning flag via Meeus Ch. 47/48 (no kernel required)
|
||||
- `getMoonVisibilityEstimate(date, lat, lon, elevation?)` — quick kernel-free Odeh crescent
|
||||
visibility estimate using Meeus positions; returns V parameter, zone (A-D), ARCL, ARCV, W
|
||||
- `getMoon(date, lat, lon, elevation?)` — combined convenience wrapper returning phase,
|
||||
position, illumination, and visibility estimate in a single call
|
||||
- `phaseName` and `phaseSymbol` fields on `MoonPhaseResult` — human-readable name
|
||||
(e.g. "Waxing Crescent") and moon phase emoji (e.g. "🌒")
|
||||
- `MoonPosition`, `MoonIlluminationResult`, `MoonVisibilityEstimate`, and `MoonSnapshot`
|
||||
TypeScript types
|
||||
|
||||
## [1.0.0] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
|
|
|||
121
README.md
121
README.md
|
|
@ -103,13 +103,52 @@ Returns a complete moon sighting report.
|
|||
| `moonPosition` | `AzAlt` | Moon azimuth/altitude at best time |
|
||||
| `guidance` | `string` | Plain-language sighting instructions |
|
||||
|
||||
### `getMoonPosition(date?, lat, lon, elevation?)`
|
||||
|
||||
Compute the Moon's topocentric position. Works without a kernel. Uses Meeus Chapter 47 approximate positions (~0.3° accuracy).
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `date` | `Date?` | Date to evaluate (default: now) |
|
||||
| `lat` | `number` | Geodetic latitude, degrees (north positive) |
|
||||
| `lon` | `number` | Longitude, degrees (east positive) |
|
||||
| `elevation` | `number?` | Height above ellipsoid, meters (default: 0) |
|
||||
|
||||
**Returns** `MoonPosition`:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `azimuth` | `number` | Degrees from North, clockwise (0–360) |
|
||||
| `altitude` | `number` | Apparent altitude in degrees (refraction applied) |
|
||||
| `distance` | `number` | Distance from Earth center to Moon center, km |
|
||||
| `parallacticAngle` | `number` | Angle between zenith and north pole as seen from the Moon, radians |
|
||||
|
||||
### `getMoonIllumination(date?)`
|
||||
|
||||
Compute the Moon's illumination. Works without a kernel. Uses Meeus Chapter 47/48 (~0.5% illumination accuracy).
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `date` | `Date?` | Date to evaluate (default: now) |
|
||||
|
||||
**Returns** `MoonIlluminationResult`:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `fraction` | `number` | Illuminated fraction, 0 (new moon) to 1 (full moon) |
|
||||
| `phase` | `number` | Position in the 0–1 lunar cycle: 0=new, 0.25=first quarter, 0.5=full, 0.75=last quarter |
|
||||
| `angle` | `number` | Position angle of the bright limb midpoint, eastward from north, radians |
|
||||
| `isWaxing` | `boolean` | True when elongation is increasing (new moon toward full moon) |
|
||||
|
||||
### `getMoonPhase(date?)`
|
||||
|
||||
Compute the Moon's current phase. Works without a kernel.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `phase` | `string` | Phase name (e.g., `'waxing-crescent'`) |
|
||||
| `phase` | `string` | Phase key (e.g., `'waxing-crescent'`) |
|
||||
| `phaseName` | `string` | Display name (e.g., `'Waxing Crescent'`) |
|
||||
| `phaseSymbol` | `string` | Moon emoji (e.g., `'🌒'`) |
|
||||
| `illumination` | `number` | Illuminated fraction, 0–100 |
|
||||
| `age` | `number` | Hours since last new moon |
|
||||
| `isWaxing` | `boolean` | True when illumination is increasing |
|
||||
|
|
@ -117,6 +156,35 @@ Compute the Moon's current phase. Works without a kernel.
|
|||
| `nextNewMoon` | `Date` | Time of next new moon |
|
||||
| `nextFullMoon` | `Date` | Time of next full moon |
|
||||
|
||||
### `getMoonVisibilityEstimate(date?, lat, lon, elevation?)`
|
||||
|
||||
Quick kernel-free Odeh crescent visibility estimate. Pass an estimated post-sunset observation time. Returns V parameter, zone A–D, ARCL, ARCV, W, and `moonAboveHorizon`. For precise crescent work use `getMoonSightingReport()`.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `zone` | `'A'`–`'D'` | Odeh visibility zone |
|
||||
| `V` | `number` | Odeh V parameter (positive = crescent exceeds threshold) |
|
||||
| `isVisibleNakedEye` | `boolean` | True for zone A |
|
||||
| `isVisibleWithOpticalAid` | `boolean` | True for zones A and B |
|
||||
| `ARCL` | `number` | Elongation, degrees |
|
||||
| `ARCV` | `number` | Moon alt − Sun alt (airless), degrees |
|
||||
| `W` | `number` | Crescent width, arc minutes |
|
||||
| `moonAboveHorizon` | `boolean` | Moon above horizon at given time |
|
||||
| `isApproximate` | `true` | Always true: Meeus approximation |
|
||||
|
||||
### `getMoon(date?, lat, lon, elevation?)`
|
||||
|
||||
Convenience wrapper returning phase, position, illumination, and visibility estimate in one call. Works without a kernel.
|
||||
|
||||
```ts
|
||||
const moon = getMoon(new Date(), 51.5074, -0.1278, 10)
|
||||
moon.phase.phaseName // 'Waxing Crescent'
|
||||
moon.phase.phaseSymbol // '🌒'
|
||||
moon.position.altitude // degrees above horizon
|
||||
moon.illumination.fraction // 0.0 to 1.0
|
||||
moon.visibility.zone // 'A' through 'D'
|
||||
```
|
||||
|
||||
### `getSunMoonEvents(date, observer)`
|
||||
|
||||
Get rise, set, and twilight times. Requires kernel.
|
||||
|
|
@ -161,6 +229,52 @@ Verify cached kernels by SHA-256 checksum.
|
|||
| C | V ≥ -0.96 | Visible with optical aid only |
|
||||
| D | V < -0.96 | Not visible even with optical aid |
|
||||
|
||||
## Kernel-free utilities
|
||||
|
||||
Five functions work without loading any kernel. They use Meeus Chapters 47 and 48. Use them for display, widgets, and any context where JPL-grade accuracy is not required.
|
||||
|
||||
```ts
|
||||
import {
|
||||
getMoonPhase, getMoonPosition, getMoonIllumination,
|
||||
getMoonVisibilityEstimate, getMoon,
|
||||
} from 'moon-sighting'
|
||||
|
||||
// Current phase with display name and emoji
|
||||
const phase = getMoonPhase()
|
||||
console.log(phase.phase) // 'waxing-crescent'
|
||||
console.log(phase.phaseName) // 'Waxing Crescent'
|
||||
console.log(phase.phaseSymbol) // '🌒'
|
||||
console.log(phase.illumination) // 14.3 (percent)
|
||||
console.log(phase.nextFullMoon) // Date
|
||||
|
||||
// Topocentric position (azimuth, altitude, distance)
|
||||
const pos = getMoonPosition(new Date(), 51.5074, -0.1278, 10)
|
||||
console.log(pos.azimuth) // 214.7 (degrees from North)
|
||||
console.log(pos.altitude) // 38.2 (degrees above horizon, refraction applied)
|
||||
console.log(pos.distance) // 384400 (km)
|
||||
|
||||
// Illumination fraction and phase angle
|
||||
const illum = getMoonIllumination()
|
||||
console.log(illum.fraction) // 0.143 (0=new, 1=full)
|
||||
console.log(illum.phase) // 0.09 (0–1 cycle position)
|
||||
console.log(illum.isWaxing) // true
|
||||
|
||||
// Quick Odeh crescent visibility estimate (pass a post-sunset time)
|
||||
const vis = getMoonVisibilityEstimate(new Date('2025-03-02T18:30:00Z'), 51.5074, -0.1278)
|
||||
console.log(vis.zone) // 'A' through 'D'
|
||||
console.log(vis.V) // Odeh V parameter
|
||||
console.log(vis.isVisibleNakedEye) // true/false
|
||||
|
||||
// All four in a single call
|
||||
const moon = getMoon(new Date(), 51.5074, -0.1278, 10)
|
||||
console.log(moon.phase.phaseName) // 'Waxing Crescent'
|
||||
console.log(moon.position.altitude) // degrees
|
||||
console.log(moon.illumination.fraction) // 0.0–1.0
|
||||
console.log(moon.visibility.zone) // 'A'–'D'
|
||||
```
|
||||
|
||||
These are a direct replacement for the equivalent `suncalc` moon functions, with no external dependencies.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
|
|
@ -216,6 +330,11 @@ npx moon-sighting benchmark
|
|||
import type {
|
||||
Observer,
|
||||
MoonSightingReport,
|
||||
MoonPhaseResult,
|
||||
MoonPosition,
|
||||
MoonIlluminationResult,
|
||||
MoonVisibilityEstimate,
|
||||
MoonSnapshot,
|
||||
YallopCategory,
|
||||
OdehZone,
|
||||
KernelConfig,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
254
src/api/index.ts
254
src/api/index.ts
|
|
@ -20,10 +20,16 @@ import type {
|
|||
MoonSightingReport,
|
||||
MoonPhaseResult,
|
||||
MoonPhaseName,
|
||||
MoonPosition,
|
||||
MoonIlluminationResult,
|
||||
MoonVisibilityEstimate,
|
||||
MoonSnapshot,
|
||||
SunMoonEvents,
|
||||
KernelConfig,
|
||||
OdehZone,
|
||||
Vec3,
|
||||
} from '../types.js'
|
||||
import { ODEH_THRESHOLDS, ODEH_DESCRIPTIONS } from '../types.js'
|
||||
import { SpkKernel } from '../spk/index.js'
|
||||
import {
|
||||
computeTimeScales,
|
||||
|
|
@ -42,7 +48,7 @@ import {
|
|||
geodeticToECEF,
|
||||
computeAzAlt,
|
||||
} from '../observer/index.js'
|
||||
import { itrsToGcrs } from '../frames/index.js'
|
||||
import { itrsToGcrs, computeERA } from '../frames/index.js'
|
||||
import {
|
||||
getSunMoonEvents as eventsGetSunMoonEvents,
|
||||
bestTimeHeuristic,
|
||||
|
|
@ -426,6 +432,19 @@ function buildNullReport(
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Phase display lookup ──────────────────────────────────────────────────────
|
||||
|
||||
const PHASE_DISPLAY: Record<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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
97
src/types.ts
97
src/types.ts
|
|
@ -17,6 +17,50 @@ export interface AzAlt {
|
|||
altitude: number
|
||||
}
|
||||
|
||||
// ─── Kernel-free moon results ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Topocentric moon position from getMoonPosition().
|
||||
* Computed via Meeus Ch. 47 (no kernel required).
|
||||
* Accuracy: azimuth/altitude ~0.3°, distance ~300 km.
|
||||
*/
|
||||
export interface MoonPosition {
|
||||
/** Azimuth in degrees from North, measured clockwise (0 = N, 90 = E, 180 = S, 270 = W) */
|
||||
azimuth: number
|
||||
/** Apparent altitude in degrees above the horizon (atmospheric refraction applied) */
|
||||
altitude: number
|
||||
/** Distance from Earth center to Moon center, km */
|
||||
distance: number
|
||||
/**
|
||||
* Parallactic angle in radians.
|
||||
* The angle between the great circle through the Moon and zenith, and the great circle
|
||||
* through the Moon and the north celestial pole. Positive east of the meridian.
|
||||
*/
|
||||
parallacticAngle: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Moon illumination from getMoonIllumination().
|
||||
* Computed via Meeus Ch. 47/48 (no kernel required).
|
||||
* Accuracy: fraction ~0.5%, phase fraction ~0.003.
|
||||
*/
|
||||
export interface MoonIlluminationResult {
|
||||
/** Illuminated fraction of the Moon disk, 0 (new moon) to 1 (full moon) */
|
||||
fraction: number
|
||||
/**
|
||||
* Phase cycle fraction in [0, 1):
|
||||
* 0 = new moon, 0.25 = first quarter, 0.5 = full moon, 0.75 = last quarter
|
||||
*/
|
||||
phase: number
|
||||
/**
|
||||
* Position angle of the midpoint of the bright limb, measured eastward from
|
||||
* the north celestial pole, in radians. Matches the suncalc convention.
|
||||
*/
|
||||
angle: number
|
||||
/** True while elongation is increasing (new moon toward full moon) */
|
||||
isWaxing: boolean
|
||||
}
|
||||
|
||||
// ─── Time ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** All relevant time scale values for a single moment */
|
||||
|
|
@ -182,6 +226,55 @@ export interface OdehResult {
|
|||
isVisibleWithOpticalAid: boolean
|
||||
}
|
||||
|
||||
// ─── Kernel-free visibility estimate ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Kernel-free Odeh-based crescent visibility estimate from getMoonVisibilityEstimate().
|
||||
* Computed via Meeus Ch. 47 approximation at the given observation time.
|
||||
* For DE442S-quality results, use getMoonSightingReport().
|
||||
*/
|
||||
export interface MoonVisibilityEstimate {
|
||||
/**
|
||||
* Odeh V parameter: V = ARCV − f(W).
|
||||
* Positive = crescent exceeds minimum visibility threshold.
|
||||
*/
|
||||
V: number
|
||||
/** Visibility zone A through D */
|
||||
zone: OdehZone
|
||||
/** Human-readable zone description */
|
||||
description: string
|
||||
/** True for zone A */
|
||||
isVisibleNakedEye: boolean
|
||||
/** True for zones A and B */
|
||||
isVisibleWithOpticalAid: boolean
|
||||
/** Arc of light (Sun-Moon elongation) in degrees */
|
||||
ARCL: number
|
||||
/** Arc of vision (Moon airless altitude minus Sun airless altitude) in degrees */
|
||||
ARCV: number
|
||||
/** Topocentric crescent width in arc minutes */
|
||||
W: number
|
||||
/** True when Moon is above the horizon at the given time */
|
||||
moonAboveHorizon: boolean
|
||||
/** Always true: computed via Meeus approximation, not DE442S */
|
||||
isApproximate: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined kernel-free moon snapshot from getMoon().
|
||||
* Bundles phase, position, illumination, and a quick visibility estimate
|
||||
* into a single call.
|
||||
*/
|
||||
export interface MoonSnapshot {
|
||||
/** Phase name, illumination, age, and next events */
|
||||
phase: MoonPhaseResult
|
||||
/** Topocentric az/alt, distance, parallactic angle */
|
||||
position: MoonPosition
|
||||
/** Illumination fraction, phase cycle, bright limb angle, waxing/waning */
|
||||
illumination: MoonIlluminationResult
|
||||
/** Quick Odeh-based crescent visibility estimate */
|
||||
visibility: MoonVisibilityEstimate
|
||||
}
|
||||
|
||||
// ─── Moon phase ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type MoonPhaseName =
|
||||
|
|
@ -197,6 +290,10 @@ export type MoonPhaseName =
|
|||
export interface MoonPhaseResult {
|
||||
/** Named phase based on illumination and waxing/waning state */
|
||||
phase: MoonPhaseName
|
||||
/** Human-readable phase name, e.g. "Waxing Crescent" */
|
||||
phaseName: string
|
||||
/** Moon phase emoji symbol, e.g. "🌒" */
|
||||
phaseSymbol: string
|
||||
/** Illuminated fraction 0-100 (percent) */
|
||||
illumination: number
|
||||
/** Hours since last new moon */
|
||||
|
|
|
|||
76
test-cjs.cjs
76
test-cjs.cjs
|
|
@ -14,6 +14,10 @@ const {
|
|||
ODEH_DESCRIPTIONS,
|
||||
WGS84,
|
||||
getMoonPhase,
|
||||
getMoonPosition,
|
||||
getMoonIllumination,
|
||||
getMoonVisibilityEstimate,
|
||||
getMoon,
|
||||
initKernels,
|
||||
downloadKernels,
|
||||
verifyKernels,
|
||||
|
|
@ -51,6 +55,10 @@ test('WGS84.a is 6378137.0', () => {
|
|||
})
|
||||
test('All API functions are exported', () => {
|
||||
assert.equal(typeof getMoonPhase, 'function')
|
||||
assert.equal(typeof getMoonPosition, 'function')
|
||||
assert.equal(typeof getMoonIllumination, 'function')
|
||||
assert.equal(typeof getMoonVisibilityEstimate, 'function')
|
||||
assert.equal(typeof getMoon, 'function')
|
||||
assert.equal(typeof initKernels, 'function')
|
||||
assert.equal(typeof downloadKernels, 'function')
|
||||
assert.equal(typeof verifyKernels, 'function')
|
||||
|
|
@ -83,6 +91,74 @@ test('getMoonPhase Dates are Date objects', () => {
|
|||
assert.ok(p.nextFullMoon instanceof Date)
|
||||
})
|
||||
|
||||
console.log('\nCJS getMoonPosition + getMoonIllumination:')
|
||||
|
||||
test('getMoonPosition returns valid azimuth/altitude', () => {
|
||||
const pos = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10)
|
||||
assert.ok(pos.azimuth >= 0 && pos.azimuth < 360, `azimuth=${pos.azimuth}`)
|
||||
assert.ok(pos.altitude >= -90 && pos.altitude <= 90, `altitude=${pos.altitude}`)
|
||||
assert.ok(pos.distance > 356000 && pos.distance < 407000, `distance=${pos.distance}`)
|
||||
assert.ok(isFinite(pos.parallacticAngle))
|
||||
})
|
||||
test('getMoonIllumination near full moon: fraction > 0.85', () => {
|
||||
const illum = getMoonIllumination(new Date('2025-03-14T12:00:00Z'))
|
||||
assert.ok(illum.fraction > 0.85, `fraction=${illum.fraction.toFixed(3)}`)
|
||||
assert.ok(illum.phase > 0.4 && illum.phase < 0.6, `phase=${illum.phase.toFixed(3)}`)
|
||||
assert.ok(isFinite(illum.angle))
|
||||
})
|
||||
test('getMoonIllumination waxing: isWaxing = true', () => {
|
||||
const illum = getMoonIllumination(new Date('2025-03-05T12:00:00Z'))
|
||||
assert.equal(illum.isWaxing, true)
|
||||
})
|
||||
|
||||
console.log('\nCJS getMoonPhase phaseName/phaseSymbol:')
|
||||
|
||||
test('getMoonPhase.phaseName is a non-empty string', () => {
|
||||
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
|
||||
assert.ok(typeof p.phaseName === 'string' && p.phaseName.length > 0)
|
||||
})
|
||||
test('getMoonPhase.phaseSymbol is a moon emoji', () => {
|
||||
const SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'])
|
||||
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
|
||||
assert.ok(SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
|
||||
})
|
||||
test('Waxing crescent: phaseName = "Waxing Crescent", phaseSymbol = "🌒"', () => {
|
||||
const p = getMoonPhase(new Date('2025-03-05T12:00:00Z'))
|
||||
assert.equal(p.phaseName, 'Waxing Crescent')
|
||||
assert.equal(p.phaseSymbol, '🌒')
|
||||
})
|
||||
|
||||
console.log('\nCJS getMoonVisibilityEstimate:')
|
||||
|
||||
test('getMoonVisibilityEstimate returns valid zone', () => {
|
||||
const v = getMoonVisibilityEstimate(new Date('2025-03-02T18:30:00Z'), 51.5074, -0.1278, 10)
|
||||
assert.ok(['A', 'B', 'C', 'D'].includes(v.zone), `zone=${v.zone}`)
|
||||
assert.ok(isFinite(v.V))
|
||||
assert.equal(v.isApproximate, true)
|
||||
})
|
||||
test('getMoonVisibilityEstimate near new moon: zone C or D', () => {
|
||||
const v = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262)
|
||||
assert.ok(['C', 'D'].includes(v.zone), `zone=${v.zone}`)
|
||||
})
|
||||
|
||||
console.log('\nCJS getMoon:')
|
||||
|
||||
test('getMoon returns all four sub-results', () => {
|
||||
const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10)
|
||||
assert.ok(typeof m.phase === 'object')
|
||||
assert.ok(typeof m.position === 'object')
|
||||
assert.ok(typeof m.illumination === 'object')
|
||||
assert.ok(typeof m.visibility === 'object')
|
||||
})
|
||||
test('getMoon.phase.phaseName is non-empty', () => {
|
||||
const m = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278)
|
||||
assert.ok(typeof m.phase.phaseName === 'string' && m.phase.phaseName.length > 0)
|
||||
})
|
||||
test('getMoon.visibility.isApproximate is true', () => {
|
||||
const m = getMoon(new Date(), 51.5074, -0.1278)
|
||||
assert.equal(m.visibility.isApproximate, true)
|
||||
})
|
||||
|
||||
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`)
|
||||
|
||||
if (failed > 0) {
|
||||
|
|
|
|||
221
test.mjs
221
test.mjs
|
|
@ -15,6 +15,10 @@ import {
|
|||
WGS84,
|
||||
// API
|
||||
getMoonPhase,
|
||||
getMoonPosition,
|
||||
getMoonIllumination,
|
||||
getMoonVisibilityEstimate,
|
||||
getMoon,
|
||||
initKernels,
|
||||
downloadKernels,
|
||||
verifyKernels,
|
||||
|
|
@ -224,6 +228,223 @@ test('Synodic month duration is ~29.5 days (±0.5)', () => {
|
|||
)
|
||||
})
|
||||
|
||||
// ─── getMoonPosition ─────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\ngetMoonPosition:')
|
||||
|
||||
// London on 2025-03-14 at noon UTC — Moon should be above the horizon during daytime
|
||||
const moonPos_london = getMoonPosition(new Date('2025-03-14T20:00:00Z'), 51.5074, -0.1278, 10)
|
||||
|
||||
test('getMoonPosition returns azimuth in [0, 360)', () => {
|
||||
assert.ok(
|
||||
moonPos_london.azimuth >= 0 && moonPos_london.azimuth < 360,
|
||||
`azimuth=${moonPos_london.azimuth}`,
|
||||
)
|
||||
})
|
||||
test('getMoonPosition returns altitude in [-90, 90]', () => {
|
||||
assert.ok(
|
||||
moonPos_london.altitude >= -90 && moonPos_london.altitude <= 90,
|
||||
`altitude=${moonPos_london.altitude}`,
|
||||
)
|
||||
})
|
||||
test('getMoonPosition returns distance in lunar orbit range [356000, 407000] km', () => {
|
||||
assert.ok(
|
||||
moonPos_london.distance >= 356000 && moonPos_london.distance <= 407000,
|
||||
`distance=${moonPos_london.distance.toFixed(0)} km`,
|
||||
)
|
||||
})
|
||||
test('getMoonPosition returns finite parallacticAngle', () => {
|
||||
assert.ok(
|
||||
isFinite(moonPos_london.parallacticAngle),
|
||||
`parallacticAngle=${moonPos_london.parallacticAngle}`,
|
||||
)
|
||||
})
|
||||
test('getMoonPosition default date (now) returns valid result', () => {
|
||||
const pos = getMoonPosition(new Date(), 21.4225, 39.8262) // Mecca
|
||||
assert.ok(pos.azimuth >= 0 && pos.azimuth < 360)
|
||||
assert.ok(pos.altitude >= -90 && pos.altitude <= 90)
|
||||
assert.ok(pos.distance > 350000 && pos.distance < 410000)
|
||||
})
|
||||
|
||||
// ─── getMoonIllumination ─────────────────────────────────────────────────────
|
||||
|
||||
console.log('\ngetMoonIllumination:')
|
||||
|
||||
// 2025-03-14 was close to full moon
|
||||
const illum_full = getMoonIllumination(new Date('2025-03-14T12:00:00Z'))
|
||||
// 2025-03-29 was close to new moon
|
||||
const illum_new = getMoonIllumination(new Date('2025-03-29T12:00:00Z'))
|
||||
// 2025-03-05 was waxing crescent (~7 days after new moon)
|
||||
const illum_waxing = getMoonIllumination(new Date('2025-03-05T12:00:00Z'))
|
||||
|
||||
test('getMoonIllumination near full moon: fraction > 0.85', () => {
|
||||
assert.ok(illum_full.fraction > 0.85, `fraction=${illum_full.fraction.toFixed(3)}`)
|
||||
})
|
||||
test('getMoonIllumination near full moon: phase close to 0.5', () => {
|
||||
assert.ok(
|
||||
illum_full.phase > 0.4 && illum_full.phase < 0.6,
|
||||
`phase=${illum_full.phase.toFixed(3)}`,
|
||||
)
|
||||
})
|
||||
test('getMoonIllumination near new moon: fraction < 0.05', () => {
|
||||
assert.ok(illum_new.fraction < 0.05, `fraction=${illum_new.fraction.toFixed(3)}`)
|
||||
})
|
||||
test('getMoonIllumination near new moon: phase close to 0 or 1', () => {
|
||||
const p = illum_new.phase
|
||||
assert.ok(p < 0.08 || p > 0.92, `phase=${p.toFixed(3)}`)
|
||||
})
|
||||
test('getMoonIllumination waxing: isWaxing = true', () => {
|
||||
assert.equal(illum_waxing.isWaxing, true)
|
||||
})
|
||||
test('getMoonIllumination fraction in [0, 1]', () => {
|
||||
assert.ok(illum_full.fraction >= 0 && illum_full.fraction <= 1)
|
||||
assert.ok(illum_new.fraction >= 0 && illum_new.fraction <= 1)
|
||||
})
|
||||
test('getMoonIllumination phase in [0, 1)', () => {
|
||||
assert.ok(illum_full.phase >= 0 && illum_full.phase < 1)
|
||||
assert.ok(illum_new.phase >= 0 && illum_new.phase < 1)
|
||||
})
|
||||
test('getMoonIllumination angle is finite', () => {
|
||||
assert.ok(isFinite(illum_full.angle), `angle=${illum_full.angle}`)
|
||||
})
|
||||
test('getMoonIllumination default date (now) returns valid result', () => {
|
||||
const illum = getMoonIllumination()
|
||||
assert.ok(illum.fraction >= 0 && illum.fraction <= 1)
|
||||
assert.ok(illum.phase >= 0 && illum.phase < 1)
|
||||
assert.equal(typeof illum.isWaxing, 'boolean')
|
||||
assert.ok(isFinite(illum.angle))
|
||||
})
|
||||
|
||||
// ─── getMoonPhase phaseName + phaseSymbol ─────────────────────────────────────
|
||||
|
||||
console.log('\ngetMoonPhase — phaseName + phaseSymbol:')
|
||||
|
||||
const PHASE_NAMES = new Set([
|
||||
'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous',
|
||||
'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent',
|
||||
])
|
||||
const PHASE_SYMBOLS = new Set(['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'])
|
||||
|
||||
test('getMoonPhase.phaseName is a valid human-readable name', () => {
|
||||
const p = getMoonPhase(DATE_MARCH_1_2025)
|
||||
assert.ok(PHASE_NAMES.has(p.phaseName), `got: ${p.phaseName}`)
|
||||
})
|
||||
test('getMoonPhase.phaseSymbol is a moon emoji', () => {
|
||||
const p = getMoonPhase(DATE_MARCH_1_2025)
|
||||
assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
|
||||
})
|
||||
test('Near full moon: phaseName is "Full Moon" or gibbous', () => {
|
||||
const valid = new Set(['Full Moon', 'Waxing Gibbous', 'Waning Gibbous'])
|
||||
const p = getMoonPhase(DATE_FULL_MOON)
|
||||
assert.ok(valid.has(p.phaseName), `got: ${p.phaseName}`)
|
||||
})
|
||||
test('Near full moon: phaseSymbol is 🌕 or 🌔 or 🌖', () => {
|
||||
const valid = new Set(['🌕', '🌔', '🌖'])
|
||||
const p = getMoonPhase(DATE_FULL_MOON)
|
||||
assert.ok(valid.has(p.phaseSymbol), `got: ${p.phaseSymbol}`)
|
||||
})
|
||||
test('Waxing crescent: phaseName is "Waxing Crescent"', () => {
|
||||
const p = getMoonPhase(DATE_WAXING)
|
||||
assert.equal(p.phaseName, 'Waxing Crescent')
|
||||
})
|
||||
test('Waxing crescent: phaseSymbol is 🌒', () => {
|
||||
const p = getMoonPhase(DATE_WAXING)
|
||||
assert.equal(p.phaseSymbol, '🌒')
|
||||
})
|
||||
test('phaseName and phaseSymbol are consistent with phase key', () => {
|
||||
// If phase is 'waning-crescent', phaseName should be 'Waning Crescent'
|
||||
const p = getMoonPhase(DATE_WANING)
|
||||
assert.equal(typeof p.phaseName, 'string')
|
||||
assert.ok(p.phaseName.length > 0)
|
||||
assert.ok(PHASE_SYMBOLS.has(p.phaseSymbol))
|
||||
})
|
||||
|
||||
// ─── getMoonVisibilityEstimate ─────────────────────────────────────────────────
|
||||
|
||||
console.log('\ngetMoonVisibilityEstimate:')
|
||||
|
||||
// London, 40 min after nominal sunset on 2025-03-01 (day after new moon)
|
||||
const DATE_VIS_ESTIMATE = new Date('2025-03-02T18:30:00Z')
|
||||
const vis = getMoonVisibilityEstimate(DATE_VIS_ESTIMATE, 51.5074, -0.1278, 10)
|
||||
|
||||
test('getMoonVisibilityEstimate returns an object', () => {
|
||||
assert.ok(vis !== null && typeof vis === 'object')
|
||||
})
|
||||
test('getMoonVisibilityEstimate.zone is A, B, C, or D', () => {
|
||||
assert.ok(['A', 'B', 'C', 'D'].includes(vis.zone), `got: ${vis.zone}`)
|
||||
})
|
||||
test('getMoonVisibilityEstimate.V is finite', () => {
|
||||
assert.ok(isFinite(vis.V), `V=${vis.V}`)
|
||||
})
|
||||
test('getMoonVisibilityEstimate.ARCL is in [0, 180]', () => {
|
||||
assert.ok(vis.ARCL >= 0 && vis.ARCL <= 180, `ARCL=${vis.ARCL}`)
|
||||
})
|
||||
test('getMoonVisibilityEstimate.W >= 0', () => {
|
||||
assert.ok(vis.W >= 0, `W=${vis.W}`)
|
||||
})
|
||||
test('getMoonVisibilityEstimate.isApproximate is true', () => {
|
||||
assert.equal(vis.isApproximate, true)
|
||||
})
|
||||
test('getMoonVisibilityEstimate.moonAboveHorizon is a boolean', () => {
|
||||
assert.equal(typeof vis.moonAboveHorizon, 'boolean')
|
||||
})
|
||||
test('getMoonVisibilityEstimate.isVisibleNakedEye matches zone A', () => {
|
||||
assert.equal(vis.isVisibleNakedEye, vis.zone === 'A')
|
||||
})
|
||||
test('getMoonVisibilityEstimate.isVisibleWithOpticalAid matches zone A or B', () => {
|
||||
assert.equal(vis.isVisibleWithOpticalAid, vis.zone === 'A' || vis.zone === 'B')
|
||||
})
|
||||
test('getMoonVisibilityEstimate.description is a non-empty string', () => {
|
||||
assert.ok(typeof vis.description === 'string' && vis.description.length > 0)
|
||||
})
|
||||
test('getMoonVisibilityEstimate default date works', () => {
|
||||
const v = getMoonVisibilityEstimate(new Date(), 21.4225, 39.8262)
|
||||
assert.ok(['A', 'B', 'C', 'D'].includes(v.zone))
|
||||
assert.ok(isFinite(v.V))
|
||||
assert.equal(v.isApproximate, true)
|
||||
})
|
||||
// Near new moon: elongation small, W small, crescent should be very thin or invisible
|
||||
test('Near new moon: zone is D or C (not visible or marginal)', () => {
|
||||
const nearNew = getMoonVisibilityEstimate(new Date('2025-03-29T18:00:00Z'), 21.4225, 39.8262)
|
||||
assert.ok(['C', 'D'].includes(nearNew.zone), `zone=${nearNew.zone} V=${nearNew.V.toFixed(2)}`)
|
||||
})
|
||||
|
||||
// ─── getMoon ──────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\ngetMoon:')
|
||||
|
||||
const moon = getMoon(new Date('2025-03-05T20:00:00Z'), 51.5074, -0.1278, 10)
|
||||
|
||||
test('getMoon returns an object with phase, position, illumination, visibility', () => {
|
||||
assert.ok(typeof moon === 'object')
|
||||
assert.ok(typeof moon.phase === 'object')
|
||||
assert.ok(typeof moon.position === 'object')
|
||||
assert.ok(typeof moon.illumination === 'object')
|
||||
assert.ok(typeof moon.visibility === 'object')
|
||||
})
|
||||
test('getMoon.phase is consistent with getMoonPhase standalone', () => {
|
||||
const standalone = getMoonPhase(new Date('2025-03-05T20:00:00Z'))
|
||||
assert.equal(moon.phase.phase, standalone.phase)
|
||||
assert.equal(moon.phase.phaseName, standalone.phaseName)
|
||||
})
|
||||
test('getMoon.illumination.isWaxing matches phase.isWaxing', () => {
|
||||
assert.equal(moon.illumination.isWaxing, moon.phase.isWaxing)
|
||||
})
|
||||
test('getMoon.visibility.isApproximate is true', () => {
|
||||
assert.equal(moon.visibility.isApproximate, true)
|
||||
})
|
||||
test('getMoon.position has valid azimuth and altitude', () => {
|
||||
assert.ok(moon.position.azimuth >= 0 && moon.position.azimuth < 360)
|
||||
assert.ok(moon.position.altitude >= -90 && moon.position.altitude <= 90)
|
||||
})
|
||||
test('getMoon default date works', () => {
|
||||
const m = getMoon(new Date(), 21.4225, 39.8262)
|
||||
assert.ok(PHASE_NAMES.has(m.phase.phaseName))
|
||||
assert.ok(isFinite(m.position.azimuth))
|
||||
assert.ok(isFinite(m.illumination.fraction))
|
||||
assert.ok(['A', 'B', 'C', 'D'].includes(m.visibility.zone))
|
||||
})
|
||||
|
||||
// ─── Summary ─────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`)
|
||||
|
|
|
|||
Loading…
Reference in a new issue