mirror of
https://github.com/acamarata/moon-sighting-dart.git
synced 2026-07-01 03:04:36 +00:00
Initial release: moon_sighting v1.0.0
This commit is contained in:
commit
fbb5bb5dc3
16 changed files with 2406 additions and 0 deletions
33
.github/workflows/ci.yml
vendored
Normal file
33
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
sdk: [stable, beta]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
with:
|
||||
sdk: ${{ matrix.sdk }}
|
||||
- run: dart pub get
|
||||
- run: dart analyze --fatal-infos
|
||||
- run: dart test
|
||||
- run: dart format --set-exit-if-changed .
|
||||
|
||||
publish-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
with:
|
||||
sdk: stable
|
||||
- run: dart pub get
|
||||
- run: dart pub publish --dry-run
|
||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
pubspec.lock
|
||||
doc/api/
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
.claude/
|
||||
.env
|
||||
.env.*
|
||||
.vscode/*
|
||||
.codex/
|
||||
.cursor/
|
||||
.aider/
|
||||
.aider.chat.history.md
|
||||
.continue/
|
||||
.windsurf/
|
||||
.gemini/
|
||||
.codeium/
|
||||
28
CHANGELOG.md
Normal file
28
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [1.0.0] - 2026-03-08
|
||||
|
||||
### Added
|
||||
|
||||
- `getMoonPhase()` returns phase name, emoji symbol, illumination percentage, age in hours, elongation, waxing/waning state, and next/previous new moon and full moon dates
|
||||
- `getMoonPosition()` returns topocentric azimuth, altitude (with Bennett refraction), geocentric distance, and parallactic angle for any observer location
|
||||
- `getMoonIllumination()` returns illumination fraction, phase cycle position, bright limb position angle, and waxing/waning indicator
|
||||
- `getMoonVisibilityEstimate()` computes Odeh crescent visibility criterion (V parameter, zones A through D) from approximate Meeus positions at a given observation time
|
||||
- `getMoon()` combined snapshot bundling all four functions in a single call
|
||||
- `arcvMinimum()` the Odeh 2006 polynomial for minimum arc of vision as a function of crescent width
|
||||
- Meeus Ch. 25 Sun position (geocentric, < 0.01 deg accuracy)
|
||||
- Meeus Ch. 47 Moon position with 30 longitude terms and 20 latitude terms (< 0.3 deg accuracy)
|
||||
- Meeus Ch. 49 new moon and full moon time estimation (within ~2 hours)
|
||||
- Full delta-T polynomial (Espenak & Meeus, piecewise by year)
|
||||
- Leap-second table through 2017 Jan 1 (37 seconds)
|
||||
- WGS84 geodetic to ECEF conversion
|
||||
- Bennett (1982) atmospheric refraction with pressure/temperature correction
|
||||
- Simplified GCRS to ITRS frame rotation via Earth Rotation Angle (sufficient for lite-mode ~1 deg accuracy)
|
||||
- Yallop q-test with categories A through F (NAO Technical Note 69)
|
||||
- Odeh V-parameter with zones A through D (Experimental Astronomy 2006)
|
||||
- Input validation for latitude, longitude ranges
|
||||
- Complete type system: `MoonPhaseResult`, `MoonPosition`, `MoonIlluminationResult`, `MoonVisibilityEstimate`, `MoonSnapshot`, `YallopResult`, `OdehResult`, `CrescentGeometry`
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Aric Camarata
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
149
README.md
Normal file
149
README.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# moon_sighting
|
||||
|
||||
[](https://pub.dev/packages/moon_sighting)
|
||||
[](LICENSE)
|
||||
|
||||
Lunar crescent visibility for Dart and Flutter. Computes moon phase, topocentric position, illumination, and Yallop/Odeh crescent visibility criteria using Meeus algorithms. Zero dependencies.
|
||||
|
||||
## Installation
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
moon_sighting: ^1.0.0
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```dart
|
||||
import 'package:moon_sighting/moon_sighting.dart';
|
||||
|
||||
// Current moon phase
|
||||
final phase = getMoonPhase();
|
||||
print('${phase.phaseName} ${phase.phaseSymbol}');
|
||||
print('Illumination: ${phase.illumination.toStringAsFixed(1)}%');
|
||||
print('Age: ${phase.age.toStringAsFixed(1)} hours');
|
||||
|
||||
// Moon position for an observer
|
||||
final pos = getMoonPosition(DateTime.now(), 51.5074, -0.1278, elevation: 10);
|
||||
print('Azimuth: ${pos.azimuth.toStringAsFixed(1)}');
|
||||
print('Altitude: ${pos.altitude.toStringAsFixed(1)}');
|
||||
print('Distance: ${pos.distance.toStringAsFixed(0)} km');
|
||||
|
||||
// Crescent visibility estimate (pass a post-sunset time)
|
||||
final vis = getMoonVisibilityEstimate(
|
||||
DateTime.utc(2025, 3, 31, 18, 30),
|
||||
21.4225, 39.8262, // Mecca
|
||||
);
|
||||
print('Zone: ${vis.zone.label}'); // A through D
|
||||
print('Visible naked eye: ${vis.isVisibleNakedEye}');
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### getMoonPhase([DateTime? date])
|
||||
|
||||
Returns `MoonPhaseResult` with:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `phase` | `MoonPhaseName` | Enum: newMoon, waxingCrescent, firstQuarter, etc. |
|
||||
| `phaseName` | `String` | Human-readable name |
|
||||
| `phaseSymbol` | `String` | Moon emoji |
|
||||
| `illumination` | `double` | Percent illuminated (0-100) |
|
||||
| `age` | `double` | Hours since last new moon |
|
||||
| `elongationDeg` | `double` | Moon-Sun elongation in degrees |
|
||||
| `isWaxing` | `bool` | True when illumination is increasing |
|
||||
| `nextNewMoon` | `DateTime` | Next new moon (UTC) |
|
||||
| `nextFullMoon` | `DateTime` | Next full moon (UTC) |
|
||||
| `prevNewMoon` | `DateTime` | Previous new moon (UTC) |
|
||||
|
||||
### getMoonPosition(DateTime? date, double lat, double lon, {double elevation = 0})
|
||||
|
||||
Returns `MoonPosition` with:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `azimuth` | `double` | Degrees from North, clockwise |
|
||||
| `altitude` | `double` | Degrees above horizon (refraction applied) |
|
||||
| `distance` | `double` | Earth-Moon distance in km |
|
||||
| `parallacticAngle` | `double` | Parallactic angle in radians |
|
||||
|
||||
### getMoonIllumination([DateTime? date])
|
||||
|
||||
Returns `MoonIlluminationResult` with:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `fraction` | `double` | Illuminated fraction (0-1) |
|
||||
| `phase` | `double` | Phase cycle (0=new, 0.25=Q1, 0.5=full, 0.75=Q3) |
|
||||
| `angle` | `double` | Bright limb position angle (radians) |
|
||||
| `isWaxing` | `bool` | True when waxing |
|
||||
|
||||
### getMoonVisibilityEstimate(DateTime? date, double lat, double lon, {double elevation = 0})
|
||||
|
||||
Returns `MoonVisibilityEstimate` with:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `v` | `double` | Odeh V parameter |
|
||||
| `zone` | `OdehZone` | Visibility zone (a through d) |
|
||||
| `description` | `String` | Zone description |
|
||||
| `isVisibleNakedEye` | `bool` | True for zone A |
|
||||
| `isVisibleWithOpticalAid` | `bool` | True for zones A and B |
|
||||
| `arcl` | `double` | Sun-Moon elongation (degrees) |
|
||||
| `arcv` | `double` | Arc of vision (degrees) |
|
||||
| `w` | `double` | Crescent width (arc minutes) |
|
||||
| `moonAboveHorizon` | `bool` | Moon above horizon at given time |
|
||||
|
||||
### getMoon(DateTime? date, double lat, double lon, {double elevation = 0})
|
||||
|
||||
Returns `MoonSnapshot` combining all four results: `phase`, `position`, `illumination`, `visibility`.
|
||||
|
||||
## Visibility Criteria
|
||||
|
||||
The Odeh criterion (2006) classifies crescent visibility into four zones:
|
||||
|
||||
| Zone | V threshold | Meaning |
|
||||
| --- | --- | --- |
|
||||
| A | V >= 5.65 | Visible with naked eye |
|
||||
| B | V >= 2.00 | Visible with optical aid, may be seen naked eye |
|
||||
| C | V >= -0.96 | Visible with optical aid only |
|
||||
| D | V < -0.96 | Not visible even with optical aid |
|
||||
|
||||
The Yallop q-test (1997) uses six categories A through F with different threshold semantics. Both criteria use the same underlying polynomial for the minimum arc of vision as a function of crescent width.
|
||||
|
||||
## Accuracy
|
||||
|
||||
All positions use Meeus (1998) approximations:
|
||||
|
||||
- **Sun:** < 0.01 deg ecliptic longitude error
|
||||
- **Moon:** < 0.3 deg longitude, < 0.2 deg latitude
|
||||
- **Distance:** ~300 km error (~0.08%)
|
||||
- **New/full moon times:** within ~2 hours
|
||||
|
||||
The GCRS-to-topocentric conversion uses a simplified Earth rotation (ERA only, no nutation/precession), adding ~1 deg systematic error. This is acceptable for phase displays, illumination calculations, and approximate visibility estimates.
|
||||
|
||||
For high-precision crescent sighting reports with DE442S ephemeris accuracy, see the TypeScript [moon-sighting](https://github.com/acamarata/moon-sighting) package.
|
||||
|
||||
## Lite Mode vs Full Mode
|
||||
|
||||
This Dart package implements lite mode only. It covers moon phase, position, illumination, and quick visibility estimates using analytical Meeus algorithms with no external data files.
|
||||
|
||||
The full-mode features (DE442S JPL ephemeris, sub-arcsecond accuracy, rise/set event finding, best-time optimization, full sighting reports) are available in the TypeScript package.
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Dart SDK >= 3.7.0
|
||||
- Works in Flutter, Dart CLI, and server-side Dart
|
||||
- Zero runtime dependencies
|
||||
|
||||
## Related Packages
|
||||
|
||||
- [moon-sighting](https://github.com/acamarata/moon-sighting) (TypeScript) - Full-accuracy lunar crescent visibility with DE442S ephemeris
|
||||
- [nrel-spa](https://github.com/acamarata/nrel-spa) (TypeScript) - Pure JS NREL Solar Position Algorithm
|
||||
- [pray-calc](https://github.com/acamarata/pray-calc) (JavaScript) - Islamic prayer time calculation
|
||||
- [luxon-hijri](https://github.com/acamarata/luxon-hijri) (TypeScript) - Hijri/Gregorian calendar conversion
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1
analysis_options.yaml
Normal file
1
analysis_options.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
include: package:lints/recommended.yaml
|
||||
16
lib/moon_sighting.dart
Normal file
16
lib/moon_sighting.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/// Lunar crescent visibility for Dart and Flutter.
|
||||
///
|
||||
/// Moon phase, position, illumination, and Yallop/Odeh visibility criteria
|
||||
/// using Meeus algorithms. Zero dependencies.
|
||||
///
|
||||
/// Five public functions:
|
||||
/// - [getMoonPhase] - phase name, illumination, age, next events
|
||||
/// - [getMoonPosition] - topocentric azimuth, altitude, distance
|
||||
/// - [getMoonIllumination] - illumination fraction, phase cycle, bright limb
|
||||
/// - [getMoonVisibilityEstimate] - Odeh crescent visibility estimate
|
||||
/// - [getMoon] - combined snapshot of all four
|
||||
library;
|
||||
|
||||
export 'src/types.dart';
|
||||
export 'src/api.dart';
|
||||
export 'src/visibility.dart' show arcvMinimum;
|
||||
304
lib/src/api.dart
Normal file
304
lib/src/api.dart
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/// User-facing API: 5 lite-mode functions.
|
||||
///
|
||||
/// All functions work without a kernel (Meeus-based approximations).
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'math.dart';
|
||||
import 'time.dart';
|
||||
import 'types.dart';
|
||||
import 'bodies.dart';
|
||||
import 'observer.dart';
|
||||
import 'visibility.dart';
|
||||
|
||||
// ── Phase display lookup ─────────────────────────────────────────────────────
|
||||
|
||||
const _phaseDisplay = <MoonPhaseName, ({String name, String symbol})>{
|
||||
MoonPhaseName.newMoon: (name: 'New Moon', symbol: '\u{1F311}'),
|
||||
MoonPhaseName.waxingCrescent: (name: 'Waxing Crescent', symbol: '\u{1F312}'),
|
||||
MoonPhaseName.firstQuarter: (name: 'First Quarter', symbol: '\u{1F313}'),
|
||||
MoonPhaseName.waxingGibbous: (name: 'Waxing Gibbous', symbol: '\u{1F314}'),
|
||||
MoonPhaseName.fullMoon: (name: 'Full Moon', symbol: '\u{1F315}'),
|
||||
MoonPhaseName.waningGibbous: (name: 'Waning Gibbous', symbol: '\u{1F316}'),
|
||||
MoonPhaseName.lastQuarter: (name: 'Last Quarter', symbol: '\u{1F317}'),
|
||||
MoonPhaseName.waningCrescent: (name: 'Waning Crescent', symbol: '\u{1F318}'),
|
||||
};
|
||||
|
||||
/// Map elongation and waxing direction to a named phase.
|
||||
MoonPhaseName _elongationToPhase(double elongationDeg, bool isWaxing) {
|
||||
if (elongationDeg < 5) return MoonPhaseName.newMoon;
|
||||
if (elongationDeg > 175) return MoonPhaseName.fullMoon;
|
||||
if (elongationDeg < 85) {
|
||||
return isWaxing
|
||||
? MoonPhaseName.waxingCrescent
|
||||
: MoonPhaseName.waningCrescent;
|
||||
}
|
||||
if (elongationDeg < 95) {
|
||||
return isWaxing ? MoonPhaseName.firstQuarter : MoonPhaseName.lastQuarter;
|
||||
}
|
||||
return isWaxing ? MoonPhaseName.waxingGibbous : MoonPhaseName.waningGibbous;
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
void _validateLatitude(double lat, String label) {
|
||||
if (!lat.isFinite || lat < -90 || lat > 90) {
|
||||
throw RangeError('$label: latitude must be in [-90, 90], got $lat');
|
||||
}
|
||||
}
|
||||
|
||||
void _validateLongitude(double lon, String label) {
|
||||
if (!lon.isFinite || lon < -180 || lon > 180) {
|
||||
throw RangeError('$label: longitude must be in [-180, 180], got $lon');
|
||||
}
|
||||
}
|
||||
|
||||
// ── getMoonPhase ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the Moon's current phase, illumination, and next phase times.
|
||||
///
|
||||
/// Works without a kernel (uses Meeus approximation).
|
||||
///
|
||||
/// ```dart
|
||||
/// final phase = getMoonPhase();
|
||||
/// print(phase.phaseName); // 'Waxing Crescent'
|
||||
/// print(phase.illumination); // 14.3 (percent)
|
||||
/// print(phase.nextFullMoon); // DateTime
|
||||
/// ```
|
||||
MoonPhaseResult getMoonPhase([DateTime? date]) {
|
||||
final d = date ?? DateTime.now();
|
||||
final ts = computeTimeScales(d);
|
||||
final (:moonGCRS, :sunGCRS) = getMoonSunApproximate(ts.jdTT);
|
||||
|
||||
final illum = computeIllumination(moonGCRS, sunGCRS);
|
||||
final illuminationPct = illum.illumination * 100;
|
||||
|
||||
final prevNewMoonJD = nearestNewMoon(ts.jdTT - 15);
|
||||
final age = (ts.jdTT - prevNewMoonJD) * 24;
|
||||
|
||||
final phaseKey = _elongationToPhase(illum.elongationDeg, illum.isWaxing);
|
||||
final display = _phaseDisplay[phaseKey]!;
|
||||
|
||||
final nextNewMoonJD = nearestNewMoon(ts.jdTT + 15);
|
||||
final nextFullMoonJD = nearestFullMoon(ts.jdTT);
|
||||
|
||||
return MoonPhaseResult(
|
||||
phase: phaseKey,
|
||||
phaseName: display.name,
|
||||
phaseSymbol: display.symbol,
|
||||
illumination: illuminationPct,
|
||||
age: age,
|
||||
elongationDeg: illum.elongationDeg,
|
||||
isWaxing: illum.isWaxing,
|
||||
nextNewMoon: jdToDate(nextNewMoonJD),
|
||||
nextFullMoon: jdToDate(nextFullMoonJD),
|
||||
prevNewMoon: jdToDate(prevNewMoonJD),
|
||||
);
|
||||
}
|
||||
|
||||
// ── getMoonPosition ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the Moon's topocentric position for an observer.
|
||||
///
|
||||
/// Works without a kernel (Meeus Ch. 47 approximation).
|
||||
/// Accuracy: azimuth/altitude ~0.3 deg, distance ~300 km.
|
||||
///
|
||||
/// ```dart
|
||||
/// final pos = getMoonPosition(DateTime.now(), 51.5, -0.1);
|
||||
/// print('${pos.azimuth}, ${pos.altitude}');
|
||||
/// ```
|
||||
MoonPosition getMoonPosition(
|
||||
DateTime? date,
|
||||
double lat,
|
||||
double lon, {
|
||||
double elevation = 0,
|
||||
}) {
|
||||
final d = date ?? DateTime.now();
|
||||
_validateLatitude(lat, 'getMoonPosition');
|
||||
_validateLongitude(lon, 'getMoonPosition');
|
||||
|
||||
final ts = computeTimeScales(d);
|
||||
final (:moonGCRS, sunGCRS: _) = getMoonSunApproximate(ts.jdTT);
|
||||
|
||||
final azAlt = computeAzAlt(moonGCRS, lat, lon, elevation, ts);
|
||||
final distance = vnorm(moonGCRS);
|
||||
|
||||
// Equatorial coordinates for parallactic angle
|
||||
final raMoon = math.atan2(moonGCRS.$2, moonGCRS.$1);
|
||||
final decMoon = math.asin((moonGCRS.$3 / distance).clamp(-1.0, 1.0));
|
||||
|
||||
final era = computeERA(ts.jdUT1);
|
||||
final ha = era + lon * deg2rad - raMoon;
|
||||
|
||||
final parallacticAngle = math.atan2(
|
||||
math.sin(ha),
|
||||
math.cos(lat * deg2rad) * math.tan(decMoon) -
|
||||
math.sin(lat * deg2rad) * math.cos(ha),
|
||||
);
|
||||
|
||||
return MoonPosition(
|
||||
azimuth: azAlt.azimuth,
|
||||
altitude: azAlt.altitude,
|
||||
distance: distance,
|
||||
parallacticAngle: parallacticAngle,
|
||||
);
|
||||
}
|
||||
|
||||
// ── getMoonIllumination ──────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the Moon's illumination fraction, phase cycle, and bright limb angle.
|
||||
///
|
||||
/// Works without a kernel (Meeus Ch. 47/48 approximation).
|
||||
/// Accuracy: fraction ~0.5%, phase fraction ~0.003.
|
||||
///
|
||||
/// ```dart
|
||||
/// final illum = getMoonIllumination();
|
||||
/// print(illum.fraction); // e.g. 0.43
|
||||
/// ```
|
||||
MoonIlluminationResult getMoonIllumination([DateTime? date]) {
|
||||
final d = date ?? DateTime.now();
|
||||
final ts = computeTimeScales(d);
|
||||
final (:moonGCRS, :sunGCRS) = getMoonSunApproximate(ts.jdTT);
|
||||
|
||||
final illum = computeIllumination(moonGCRS, sunGCRS);
|
||||
|
||||
// Phase fraction: 0 = new, 0.25 = first quarter, 0.5 = full, 0.75 = last quarter
|
||||
final phase =
|
||||
illum.isWaxing
|
||||
? illum.elongationDeg / 360
|
||||
: 1 - illum.elongationDeg / 360;
|
||||
|
||||
// Bright limb position angle
|
||||
final moonDist = vnorm(moonGCRS);
|
||||
final sunDist = vnorm(sunGCRS);
|
||||
final raMoon = math.atan2(moonGCRS.$2, moonGCRS.$1);
|
||||
final decMoon = math.asin((moonGCRS.$3 / moonDist).clamp(-1.0, 1.0));
|
||||
final raSun = math.atan2(sunGCRS.$2, sunGCRS.$1);
|
||||
final decSun = math.asin((sunGCRS.$3 / sunDist).clamp(-1.0, 1.0));
|
||||
|
||||
final dRA = raSun - raMoon;
|
||||
final angle = math.atan2(
|
||||
math.cos(decSun) * math.sin(dRA),
|
||||
math.sin(decSun) * math.cos(decMoon) -
|
||||
math.cos(decSun) * math.sin(decMoon) * math.cos(dRA),
|
||||
);
|
||||
|
||||
return MoonIlluminationResult(
|
||||
fraction: illum.illumination,
|
||||
phase: phase,
|
||||
angle: angle,
|
||||
isWaxing: illum.isWaxing,
|
||||
);
|
||||
}
|
||||
|
||||
// ── getMoonVisibilityEstimate ────────────────────────────────────────────────
|
||||
|
||||
/// Quick kernel-free crescent visibility estimate using the Odeh criterion.
|
||||
///
|
||||
/// Computes approximate crescent geometry from Meeus positions at the given
|
||||
/// observation time and applies the Odeh V-parameter formula.
|
||||
///
|
||||
/// ```dart
|
||||
/// final est = getMoonVisibilityEstimate(obsTime, 21.42, 39.83);
|
||||
/// print(est.zone.label); // 'A' through 'D'
|
||||
/// ```
|
||||
MoonVisibilityEstimate getMoonVisibilityEstimate(
|
||||
DateTime? date,
|
||||
double lat,
|
||||
double lon, {
|
||||
double elevation = 0,
|
||||
}) {
|
||||
final d = date ?? DateTime.now();
|
||||
_validateLatitude(lat, 'getMoonVisibilityEstimate');
|
||||
_validateLongitude(lon, 'getMoonVisibilityEstimate');
|
||||
|
||||
final ts = computeTimeScales(d);
|
||||
final (:moonGCRS, :sunGCRS) = getMoonSunApproximate(ts.jdTT);
|
||||
|
||||
// Airless positions (no refraction)
|
||||
final moonAirless = computeAzAlt(
|
||||
moonGCRS,
|
||||
lat,
|
||||
lon,
|
||||
elevation,
|
||||
ts,
|
||||
airless: true,
|
||||
);
|
||||
final sunAirless = computeAzAlt(
|
||||
sunGCRS,
|
||||
lat,
|
||||
lon,
|
||||
elevation,
|
||||
ts,
|
||||
airless: true,
|
||||
);
|
||||
|
||||
// ARCL = elongation
|
||||
final illumData = computeIllumination(moonGCRS, sunGCRS);
|
||||
final arcl = illumData.elongationDeg;
|
||||
|
||||
// ARCV = Moon airless alt minus Sun airless alt
|
||||
final arcv = moonAirless.altitude - sunAirless.altitude;
|
||||
|
||||
// Topocentric Moon vector for crescent width
|
||||
final obsECEF = geodeticToECEF(lat, lon, elevation);
|
||||
final obsITRS = (obsECEF.$1 / 1000, obsECEF.$2 / 1000, obsECEF.$3 / 1000);
|
||||
final obsGCRS = itrsToGcrsSimple(obsITRS, ts);
|
||||
final moonTopo = vsub(moonGCRS, obsGCRS);
|
||||
|
||||
final (:w, wprime: _) = computeCrescentWidth(moonTopo, arcl);
|
||||
|
||||
final v = arcv - arcvMinimum(w);
|
||||
|
||||
final zone =
|
||||
v >= odehThresholds[OdehZone.a]!
|
||||
? OdehZone.a
|
||||
: v >= odehThresholds[OdehZone.b]!
|
||||
? OdehZone.b
|
||||
: v >= odehThresholds[OdehZone.c]!
|
||||
? OdehZone.c
|
||||
: OdehZone.d;
|
||||
|
||||
return MoonVisibilityEstimate(
|
||||
v: v,
|
||||
zone: zone,
|
||||
description: odehDescriptions[zone]!,
|
||||
isVisibleNakedEye: zone == OdehZone.a,
|
||||
isVisibleWithOpticalAid: zone == OdehZone.a || zone == OdehZone.b,
|
||||
arcl: arcl,
|
||||
arcv: arcv,
|
||||
w: w,
|
||||
moonAboveHorizon: moonAirless.altitude > 0,
|
||||
);
|
||||
}
|
||||
|
||||
// ── getMoon ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Combined kernel-free moon snapshot for a time and location.
|
||||
///
|
||||
/// Calls [getMoonPhase], [getMoonPosition], [getMoonIllumination], and
|
||||
/// [getMoonVisibilityEstimate] in a single request.
|
||||
///
|
||||
/// ```dart
|
||||
/// final moon = getMoon(DateTime.now(), 51.5, -0.1);
|
||||
/// print(moon.phase.phaseName);
|
||||
/// print(moon.visibility.zone.label);
|
||||
/// ```
|
||||
MoonSnapshot getMoon(
|
||||
DateTime? date,
|
||||
double lat,
|
||||
double lon, {
|
||||
double elevation = 0,
|
||||
}) {
|
||||
final d = date ?? DateTime.now();
|
||||
_validateLatitude(lat, 'getMoon');
|
||||
_validateLongitude(lon, 'getMoon');
|
||||
|
||||
return MoonSnapshot(
|
||||
phase: getMoonPhase(d),
|
||||
position: getMoonPosition(d, lat, lon, elevation: elevation),
|
||||
illumination: getMoonIllumination(d),
|
||||
visibility: getMoonVisibilityEstimate(d, lat, lon, elevation: elevation),
|
||||
);
|
||||
}
|
||||
398
lib/src/bodies.dart
Normal file
398
lib/src/bodies.dart
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
/// Moon and Sun positions using Meeus Ch. 25/47, illumination geometry.
|
||||
///
|
||||
/// References:
|
||||
/// Meeus, J. (1998). Astronomical Algorithms, 2nd ed. Willmann-Bell.
|
||||
/// Odeh, M. (2006). New Criterion for Lunar Crescent Visibility.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'math.dart';
|
||||
import 'time.dart';
|
||||
|
||||
/// AU in km.
|
||||
const double _auKm = 149597870.7;
|
||||
|
||||
/// Mean radius of the Moon in km (IAU 2015).
|
||||
const double moonRadiusKm = 1737.4;
|
||||
|
||||
/// Low-accuracy Sun and Moon positions using Meeus Ch. 25/47.
|
||||
///
|
||||
/// Error budget:
|
||||
/// Sun: < 0.01 deg in ecliptic longitude
|
||||
/// Moon: < 0.3 deg in ecliptic longitude, < 0.2 deg in latitude
|
||||
({Vec3 moonGCRS, Vec3 sunGCRS}) getMoonSunApproximate(double jdTT) {
|
||||
final t = (jdTT - j2000) / daysPerJulianCentury;
|
||||
|
||||
// Sun (Meeus Ch. 25)
|
||||
final l0 = 280.46646 + 36000.76983 * t + 0.0003032 * t * t;
|
||||
final mSun = 357.52911 + 35999.05029 * t - 0.0001537 * t * t;
|
||||
final mSunRad = (mSun % 360) * deg2rad;
|
||||
final eSun = 0.016708634 - 0.000042037 * t - 0.0000001267 * t * t;
|
||||
|
||||
final c =
|
||||
(1.914602 - 0.004817 * t - 0.000014 * t * t) * math.sin(mSunRad) +
|
||||
(0.019993 - 0.000101 * t) * math.sin(2 * mSunRad) +
|
||||
0.000289 * math.sin(3 * mSunRad);
|
||||
|
||||
final sunLonDeg = l0 + c;
|
||||
final nuRad = mSunRad + c * deg2rad;
|
||||
final rAU = (1.000001018 * (1 - eSun * eSun)) / (1 + eSun * math.cos(nuRad));
|
||||
final rKm = rAU * _auKm;
|
||||
|
||||
final omega = (125.04 - 1934.136 * t) * deg2rad;
|
||||
final sunLonApp = sunLonDeg - 0.00569 - 0.00478 * math.sin(omega);
|
||||
final sunLonRad = sunLonApp * deg2rad;
|
||||
|
||||
final eps =
|
||||
(23.439291111 -
|
||||
0.013004167 * t -
|
||||
0.0000001638 * t * t +
|
||||
0.0000005036 * t * t * t) *
|
||||
deg2rad;
|
||||
|
||||
final sunGCRS = (
|
||||
rKm * math.cos(sunLonRad),
|
||||
rKm * math.sin(sunLonRad) * math.cos(eps),
|
||||
rKm * math.sin(sunLonRad) * math.sin(eps),
|
||||
);
|
||||
|
||||
// Moon (Meeus Ch. 47)
|
||||
final lp =
|
||||
218.3164477 +
|
||||
481267.88123421 * t -
|
||||
0.0015786 * t * t +
|
||||
(t * t * t) / 538841 -
|
||||
(t * t * t * t) / 65194000;
|
||||
final d =
|
||||
297.8501921 +
|
||||
445267.1114034 * t -
|
||||
0.0018819 * t * t +
|
||||
(t * t * t) / 545868 -
|
||||
(t * t * t * t) / 113065000;
|
||||
final m =
|
||||
357.5291092 +
|
||||
35999.0502909 * t -
|
||||
0.0001536 * t * t +
|
||||
(t * t * t) / 24490000;
|
||||
final mp =
|
||||
134.9633964 +
|
||||
477198.8675055 * t +
|
||||
0.0087414 * t * t +
|
||||
(t * t * t) / 69699 -
|
||||
(t * t * t * t) / 14712000;
|
||||
final f =
|
||||
93.272095 +
|
||||
483202.0175233 * t -
|
||||
0.0036539 * t * t -
|
||||
(t * t * t) / 3526000 +
|
||||
(t * t * t * t) / 863310000;
|
||||
|
||||
final a1 = (119.75 + 131.849 * t) * deg2rad;
|
||||
final a2 = (53.09 + 479264.29 * t) * deg2rad;
|
||||
final a3 = (313.45 + 481266.484 * t) * deg2rad;
|
||||
|
||||
final dR = (d % 360) * deg2rad;
|
||||
final mR = (m % 360) * deg2rad;
|
||||
final mpR = (mp % 360) * deg2rad;
|
||||
final fR = (f % 360) * deg2rad;
|
||||
|
||||
final e = 1 - 0.002516 * t - 0.0000074 * t * t;
|
||||
|
||||
// Longitude and distance: 30 terms from Meeus Table 47.A
|
||||
// [d, m, mp, f, Sl (1e-6 deg), Sr (1e-3 km)]
|
||||
const ld = <(int, int, int, int, int, int)>[
|
||||
(0, 0, 1, 0, 6288774, -20905355),
|
||||
(2, 0, -1, 0, 1274027, -3699111),
|
||||
(2, 0, 0, 0, 658314, -2955968),
|
||||
(0, 0, 2, 0, 213618, -569925),
|
||||
(0, 1, 0, 0, -185116, 48888),
|
||||
(0, 0, 0, 2, -114332, -3149),
|
||||
(2, 0, -2, 0, 58793, 246158),
|
||||
(2, -1, -1, 0, 57066, -152138),
|
||||
(2, 0, 1, 0, 53322, -170733),
|
||||
(2, -1, 0, 0, 45758, -204586),
|
||||
(0, 1, -1, 0, -40923, -129620),
|
||||
(1, 0, 0, 0, -34720, 108743),
|
||||
(0, 1, 1, 0, -30383, 104755),
|
||||
(2, 0, 0, -2, 15327, 10321),
|
||||
(0, 0, 1, 2, -12528, 0),
|
||||
(0, 0, 1, -2, 10980, 79661),
|
||||
(4, 0, -1, 0, 10675, -34782),
|
||||
(0, 0, 3, 0, 10034, -23210),
|
||||
(4, 0, -2, 0, 8548, -21636),
|
||||
(2, 1, -1, 0, -7888, 24208),
|
||||
(2, 1, 0, 0, -6766, 30824),
|
||||
(1, 0, -1, 0, -5163, -8379),
|
||||
(1, 1, 0, 0, 4987, -16675),
|
||||
(2, -1, 1, 0, 4036, -12831),
|
||||
(2, 0, 2, 0, 3994, -10445),
|
||||
(4, 0, 0, 0, 3861, -11650),
|
||||
(2, 0, -3, 0, 3665, 14403),
|
||||
(0, 1, -2, 0, -2689, -7003),
|
||||
(2, 0, -1, 2, -2602, 0),
|
||||
(2, -1, -2, 0, 2390, 10056),
|
||||
];
|
||||
|
||||
var sl = 0.0;
|
||||
var sr = 0.0;
|
||||
for (final (di, mi, mpi, fi, slc, src) in ld) {
|
||||
final arg = di * dR + mi * mR + mpi * mpR + fi * fR;
|
||||
final eCorr =
|
||||
mi.abs() == 2
|
||||
? e * e
|
||||
: mi.abs() == 1
|
||||
? e
|
||||
: 1.0;
|
||||
sl += slc * eCorr * math.sin(arg);
|
||||
sr += src * eCorr * math.cos(arg);
|
||||
}
|
||||
|
||||
sl +=
|
||||
3958 * math.sin(a1) +
|
||||
1962 * math.sin((lp - f) * deg2rad) +
|
||||
318 * math.sin(a2);
|
||||
|
||||
// Latitude: 20 terms from Meeus Table 47.B
|
||||
const fb = <(int, int, int, int, int)>[
|
||||
(0, 0, 0, 1, 5128122),
|
||||
(0, 0, 1, 1, 280602),
|
||||
(0, 0, 1, -1, 277693),
|
||||
(2, 0, 0, -1, 173237),
|
||||
(2, 0, -1, 1, 55413),
|
||||
(2, 0, -1, -1, 46271),
|
||||
(2, 0, 0, 1, 32573),
|
||||
(0, 0, 2, 1, 17198),
|
||||
(2, 0, 1, -1, 9266),
|
||||
(0, 0, 2, -1, 8822),
|
||||
(2, -1, 0, -1, 8216),
|
||||
(2, 0, -2, -1, 4324),
|
||||
(2, 0, 1, 1, 4200),
|
||||
(2, 1, 0, -1, -3359),
|
||||
(2, -1, -1, 1, 2463),
|
||||
(2, -1, 0, 1, 2211),
|
||||
(2, -1, -1, -1, 2065),
|
||||
(0, 1, -1, -1, -1870),
|
||||
(4, 0, -1, -1, 1828),
|
||||
(0, 1, 0, 1, -1794),
|
||||
];
|
||||
|
||||
var sb = 0.0;
|
||||
for (final (di, mi, mpi, fi, sbc) in fb) {
|
||||
final arg = di * dR + mi * mR + mpi * mpR + fi * fR;
|
||||
final eCorr =
|
||||
mi.abs() == 2
|
||||
? e * e
|
||||
: mi.abs() == 1
|
||||
? e
|
||||
: 1.0;
|
||||
sb += sbc * eCorr * math.sin(arg);
|
||||
}
|
||||
|
||||
sb +=
|
||||
-2235 * math.sin(lp * deg2rad) +
|
||||
382 * math.sin(a3) +
|
||||
175 * math.sin(a1 - fR) +
|
||||
175 * math.sin(a1 + fR) +
|
||||
127 * math.sin((lp - mp) * deg2rad) -
|
||||
115 * math.sin((lp + mp) * deg2rad);
|
||||
|
||||
final moonLonDeg = lp + sl * 1e-6;
|
||||
final moonLatDeg = sb * 1e-6;
|
||||
final moonDistKm = 385000.56 + sr * 0.001;
|
||||
|
||||
final moonLonRad = moonLonDeg * deg2rad;
|
||||
final moonLatRad = moonLatDeg * deg2rad;
|
||||
|
||||
final moonGCRS = (
|
||||
moonDistKm * math.cos(moonLatRad) * math.cos(moonLonRad),
|
||||
moonDistKm *
|
||||
(math.cos(eps) * math.cos(moonLatRad) * math.sin(moonLonRad) -
|
||||
math.sin(eps) * math.sin(moonLatRad)),
|
||||
moonDistKm *
|
||||
(math.sin(eps) * math.cos(moonLatRad) * math.sin(moonLonRad) +
|
||||
math.cos(eps) * math.sin(moonLatRad)),
|
||||
);
|
||||
|
||||
return (moonGCRS: moonGCRS, sunGCRS: sunGCRS);
|
||||
}
|
||||
|
||||
/// Estimate the nearest new moon JD using Meeus Ch. 49.
|
||||
/// Accurate to within ~2 hours.
|
||||
double nearestNewMoon(double jdTT) {
|
||||
final y = 2000.0 + (jdTT - j2000) / 365.25;
|
||||
final k = ((y - 2000.0) * 12.3685).round().toDouble();
|
||||
final t = k / 1236.85;
|
||||
|
||||
var jde =
|
||||
2451550.09766 +
|
||||
29.530588861 * k +
|
||||
0.00015437 * t * t -
|
||||
0.00000015 * t * t * t +
|
||||
0.00000000073 * t * t * t * t;
|
||||
|
||||
final mArg =
|
||||
(2.5534 + 29.1053567 * k - 0.0000014 * t * t - 0.00000011 * t * t * t) *
|
||||
deg2rad;
|
||||
final mpArg =
|
||||
(201.5643 +
|
||||
385.81693528 * k +
|
||||
0.0107582 * t * t +
|
||||
0.00001238 * t * t * t) *
|
||||
deg2rad;
|
||||
final fc =
|
||||
(160.7108 +
|
||||
390.67050284 * k -
|
||||
0.0016118 * t * t -
|
||||
0.00000227 * t * t * t) *
|
||||
deg2rad;
|
||||
final om =
|
||||
(124.7746 - 1.56375588 * k + 0.0020672 * t * t + 0.00000215 * t * t * t) *
|
||||
deg2rad;
|
||||
final eCorr = 1 - 0.002516 * t - 0.0000074 * t * t;
|
||||
|
||||
jde +=
|
||||
-0.4072 * math.sin(mpArg) +
|
||||
0.17241 * eCorr * math.sin(mArg) +
|
||||
0.01608 * math.sin(2 * mpArg) +
|
||||
0.01039 * math.sin(2 * fc) +
|
||||
0.00739 * eCorr * math.sin(mpArg - mArg) -
|
||||
0.00514 * eCorr * math.sin(mpArg + mArg) +
|
||||
0.00208 * eCorr * eCorr * math.sin(2 * mArg) -
|
||||
0.00111 * math.sin(mpArg - 2 * fc) -
|
||||
0.00057 * math.sin(mpArg + 2 * fc) +
|
||||
0.00056 * eCorr * math.sin(2 * mpArg + mArg) -
|
||||
0.00042 * math.sin(3 * mpArg) +
|
||||
0.00042 * eCorr * math.sin(mArg + 2 * fc) +
|
||||
0.00038 * eCorr * math.sin(mArg - 2 * fc) -
|
||||
0.00024 * eCorr * math.sin(2 * mpArg - mArg) -
|
||||
0.00017 * math.sin(om) -
|
||||
0.00007 * math.sin(mpArg + 2 * mArg) +
|
||||
0.00004 * math.sin(2 * mpArg - 2 * fc) +
|
||||
0.00004 * math.sin(3 * mArg) +
|
||||
0.00003 * math.sin(mpArg + mArg - 2 * fc) +
|
||||
0.00003 * math.sin(2 * mpArg + 2 * fc) -
|
||||
0.00003 * math.sin(mpArg + mArg + 2 * fc) +
|
||||
0.00003 * math.sin(mpArg - mArg + 2 * fc) -
|
||||
0.00002 * math.sin(mpArg - mArg - 2 * fc) -
|
||||
0.00002 * math.sin(3 * mpArg + mArg) +
|
||||
0.00002 * math.sin(4 * mpArg);
|
||||
|
||||
return jde;
|
||||
}
|
||||
|
||||
/// Estimate the nearest full moon JD using Meeus Ch. 49.
|
||||
double nearestFullMoon(double jdTT) {
|
||||
final y = 2000 + (jdTT - j2000) / 365.25;
|
||||
final kBase = ((y - 2000.0) * 12.3685).round().toDouble();
|
||||
final k1 = kBase - 0.5;
|
||||
final k2 = kBase + 0.5;
|
||||
final jde1 = _fullMoonJDE(k1);
|
||||
final jde2 = _fullMoonJDE(k2);
|
||||
final d1 = (jde1 - jdTT).abs();
|
||||
final d2 = (jde2 - jdTT).abs();
|
||||
return d1 < d2 ? jde1 : jde2;
|
||||
}
|
||||
|
||||
double _fullMoonJDE(double k) {
|
||||
final t = k / 1236.85;
|
||||
var jde =
|
||||
2451550.09766 +
|
||||
29.530588861 * k +
|
||||
0.00015437 * t * t -
|
||||
0.00000015 * t * t * t +
|
||||
0.00000000073 * t * t * t * t;
|
||||
|
||||
final mArg = (2.5534 + 29.1053567 * k - 0.0000014 * t * t) * deg2rad;
|
||||
final mpArg = (201.5643 + 385.81693528 * k + 0.0107582 * t * t) * deg2rad;
|
||||
final fc = (160.7108 + 390.67050284 * k - 0.0016118 * t * t) * deg2rad;
|
||||
final om = (124.7746 - 1.56375588 * k + 0.0020672 * t * t) * deg2rad;
|
||||
final eCorr = 1 - 0.002516 * t - 0.0000074 * t * t;
|
||||
|
||||
jde +=
|
||||
-0.40614 * math.sin(mpArg) +
|
||||
0.17302 * eCorr * math.sin(mArg) +
|
||||
0.01614 * math.sin(2 * mpArg) +
|
||||
0.01043 * math.sin(2 * fc) +
|
||||
0.00734 * eCorr * math.sin(mpArg - mArg) -
|
||||
0.00515 * eCorr * math.sin(mpArg + mArg) +
|
||||
0.00209 * eCorr * eCorr * math.sin(2 * mArg) -
|
||||
0.00111 * math.sin(mpArg - 2 * fc) -
|
||||
0.00057 * math.sin(mpArg + 2 * fc) +
|
||||
0.00056 * eCorr * math.sin(2 * mpArg + mArg) -
|
||||
0.00042 * math.sin(3 * mpArg) +
|
||||
0.00042 * eCorr * math.sin(mArg + 2 * fc) +
|
||||
0.00038 * eCorr * math.sin(mArg - 2 * fc) -
|
||||
0.00024 * eCorr * math.sin(2 * mpArg - mArg) -
|
||||
0.00017 * math.sin(om) -
|
||||
0.00007 * math.sin(mpArg + 2 * mArg) +
|
||||
0.00004 * math.sin(2 * mpArg - 2 * fc) +
|
||||
0.00004 * math.sin(3 * mArg) +
|
||||
0.00003 * math.sin(mpArg + mArg - 2 * fc) +
|
||||
0.00003 * math.sin(2 * mpArg + 2 * fc) -
|
||||
0.00003 * math.sin(mpArg + mArg + 2 * fc) +
|
||||
0.00003 * math.sin(mpArg - mArg + 2 * fc) -
|
||||
0.00002 * math.sin(mpArg - mArg - 2 * fc) -
|
||||
0.00002 * math.sin(3 * mpArg + mArg) +
|
||||
0.00002 * math.sin(4 * mpArg);
|
||||
|
||||
return jde;
|
||||
}
|
||||
|
||||
/// Compute Moon illumination quantities from geocentric positions.
|
||||
///
|
||||
/// Returns illumination fraction [0-1], phase angle (deg), elongation (deg),
|
||||
/// and whether the Moon is waxing.
|
||||
({
|
||||
double illumination,
|
||||
double phaseAngleDeg,
|
||||
double elongationDeg,
|
||||
bool isWaxing,
|
||||
})
|
||||
computeIllumination(Vec3 moonGCRS, Vec3 sunGCRS) {
|
||||
final rMoon = vnorm(moonGCRS);
|
||||
final rSun = vnorm(sunGCRS);
|
||||
|
||||
final cosElong = (vdot(moonGCRS, sunGCRS) / (rMoon * rSun)).clamp(-1.0, 1.0);
|
||||
final elongationDeg = math.acos(cosElong) / deg2rad;
|
||||
|
||||
final moonToSun = (
|
||||
sunGCRS.$1 - moonGCRS.$1,
|
||||
sunGCRS.$2 - moonGCRS.$2,
|
||||
sunGCRS.$3 - moonGCRS.$3,
|
||||
);
|
||||
final moonToEarth = (-moonGCRS.$1, -moonGCRS.$2, -moonGCRS.$3);
|
||||
final rMoonToSun = vnorm(moonToSun);
|
||||
|
||||
final cosPhase = (vdot(moonToEarth, moonToSun) / (rMoon * rMoonToSun)).clamp(
|
||||
-1.0,
|
||||
1.0,
|
||||
);
|
||||
final phaseAngleDeg = math.acos(cosPhase) / deg2rad;
|
||||
|
||||
final illumination = (1 + math.cos(phaseAngleDeg * deg2rad)) / 2;
|
||||
|
||||
final crossZ = sunGCRS.$1 * moonGCRS.$2 - sunGCRS.$2 * moonGCRS.$1;
|
||||
final isWaxing = crossZ > 0;
|
||||
|
||||
return (
|
||||
illumination: illumination,
|
||||
phaseAngleDeg: phaseAngleDeg,
|
||||
elongationDeg: elongationDeg,
|
||||
isWaxing: isWaxing,
|
||||
);
|
||||
}
|
||||
|
||||
/// Compute topocentric crescent width W in arc minutes.
|
||||
///
|
||||
/// W = SD * (1 - cos(ARCL)) where SD is the topocentric semi-diameter.
|
||||
({double w, double wprime}) computeCrescentWidth(
|
||||
Vec3 moonTopoVec,
|
||||
double arcl,
|
||||
) {
|
||||
final rMoon = vnorm(moonTopoVec);
|
||||
final sdArcmin = (math.atan(moonRadiusKm / rMoon) / deg2rad) * 60;
|
||||
final arclRad = arcl * deg2rad;
|
||||
final w = sdArcmin * (1 - math.cos(arclRad));
|
||||
return (w: w, wprime: w);
|
||||
}
|
||||
65
lib/src/math.dart
Normal file
65
lib/src/math.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/// Vector math and angle utilities.
|
||||
///
|
||||
/// All computation is pure (no I/O, no state).
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Degrees to radians conversion factor.
|
||||
const double deg2rad = math.pi / 180;
|
||||
|
||||
/// Radians to degrees conversion factor.
|
||||
const double rad2deg = 180 / math.pi;
|
||||
|
||||
/// A three-element vector (position or velocity).
|
||||
typedef Vec3 = (double, double, double);
|
||||
|
||||
/// Add two 3-vectors.
|
||||
Vec3 vadd(Vec3 a, Vec3 b) => (a.$1 + b.$1, a.$2 + b.$2, a.$3 + b.$3);
|
||||
|
||||
/// Subtract b from a.
|
||||
Vec3 vsub(Vec3 a, Vec3 b) => (a.$1 - b.$1, a.$2 - b.$2, a.$3 - b.$3);
|
||||
|
||||
/// Scale a 3-vector.
|
||||
Vec3 vscale(Vec3 a, double s) => (a.$1 * s, a.$2 * s, a.$3 * s);
|
||||
|
||||
/// Dot product.
|
||||
double vdot(Vec3 a, Vec3 b) => a.$1 * b.$1 + a.$2 * b.$2 + a.$3 * b.$3;
|
||||
|
||||
/// Euclidean norm.
|
||||
double vnorm(Vec3 a) => math.sqrt(vdot(a, a));
|
||||
|
||||
/// Cross product.
|
||||
Vec3 vcross(Vec3 a, Vec3 b) => (
|
||||
a.$2 * b.$3 - a.$3 * b.$2,
|
||||
a.$3 * b.$1 - a.$1 * b.$3,
|
||||
a.$1 * b.$2 - a.$2 * b.$1,
|
||||
);
|
||||
|
||||
/// Unit vector (normalized). Throws if zero vector.
|
||||
Vec3 vunit(Vec3 a) {
|
||||
final n = vnorm(a);
|
||||
if (n == 0) throw RangeError('Cannot normalize a zero vector');
|
||||
return vscale(a, 1 / n);
|
||||
}
|
||||
|
||||
/// Angular separation between two direction vectors in radians.
|
||||
double angularSep(Vec3 a, Vec3 b) {
|
||||
final cosAngle = (vdot(vunit(a), vunit(b))).clamp(-1.0, 1.0);
|
||||
return math.acos(cosAngle);
|
||||
}
|
||||
|
||||
/// Normalize an angle to [0, 2*pi).
|
||||
double mod2pi(double angle) {
|
||||
const twoPi = 2 * math.pi;
|
||||
return ((angle % twoPi) + twoPi) % twoPi;
|
||||
}
|
||||
|
||||
/// Normalize an angle in degrees to [0, 360).
|
||||
double mod360(double deg) => ((deg % 360) + 360) % 360;
|
||||
|
||||
/// Normalize an angle in degrees to [-180, 180).
|
||||
double normalizeDeg180(double deg) {
|
||||
deg = mod360(deg);
|
||||
return deg >= 180 ? deg - 360 : deg;
|
||||
}
|
||||
171
lib/src/observer.dart
Normal file
171
lib/src/observer.dart
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/// Geodetic transforms, topocentric ENU, atmospheric refraction, az/alt.
|
||||
///
|
||||
/// Observer position chain:
|
||||
/// geodetic (lat, lon, elev) -> ECEF (ITRS) -> GCRS -> topocentric ENU -> az/alt
|
||||
///
|
||||
/// References:
|
||||
/// WGS84: NIMA TR8350.2, 3rd edition
|
||||
/// Bennett (1982), Astronomical Refraction
|
||||
/// Saemundsson (1986), Sky & Telescope 72, 70
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'math.dart';
|
||||
import 'time.dart';
|
||||
import 'types.dart';
|
||||
|
||||
/// Convert geodetic coordinates to ECEF position in meters.
|
||||
Vec3 geodeticToECEF(double lat, double lon, double elev) {
|
||||
final phi = lat * math.pi / 180;
|
||||
final lam = lon * math.pi / 180;
|
||||
final sinPhi = math.sin(phi);
|
||||
final cosPhi = math.cos(phi);
|
||||
final n = WGS84.a / math.sqrt(1 - WGS84.e2 * sinPhi * sinPhi);
|
||||
return (
|
||||
(n + elev) * cosPhi * math.cos(lam),
|
||||
(n + elev) * cosPhi * math.sin(lam),
|
||||
(n * (1 - WGS84.e2) + elev) * sinPhi,
|
||||
);
|
||||
}
|
||||
|
||||
/// Compute ENU basis vectors at a geodetic location.
|
||||
({Vec3 east, Vec3 north, Vec3 up}) computeENUBasis(double lat, double lon) {
|
||||
final phi = lat * math.pi / 180;
|
||||
final lam = lon * math.pi / 180;
|
||||
final sinPhi = math.sin(phi);
|
||||
final cosPhi = math.cos(phi);
|
||||
final sinLam = math.sin(lam);
|
||||
final cosLam = math.cos(lam);
|
||||
|
||||
return (
|
||||
east: (-sinLam, cosLam, 0.0),
|
||||
north: (-sinPhi * cosLam, -sinPhi * sinLam, cosPhi),
|
||||
up: (cosPhi * cosLam, cosPhi * sinLam, sinPhi),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert ECEF displacement to local ENU.
|
||||
Vec3 ecefToENU(Vec3 ecefDelta, double lat, double lon) {
|
||||
final basis = computeENUBasis(lat, lon);
|
||||
return (
|
||||
vdot(ecefDelta, basis.east),
|
||||
vdot(ecefDelta, basis.north),
|
||||
vdot(ecefDelta, basis.up),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert ENU vector to azimuth and altitude.
|
||||
AzAlt enuToAzAlt(Vec3 enu) {
|
||||
final e = enu.$1;
|
||||
final n = enu.$2;
|
||||
final u = enu.$3;
|
||||
final horiz = math.sqrt(e * e + n * n);
|
||||
final altitude = math.atan2(u, horiz) * 180 / math.pi;
|
||||
var azimuth = math.atan2(e, n) * 180 / math.pi;
|
||||
if (azimuth < 0) azimuth += 360;
|
||||
return AzAlt(azimuth: azimuth, altitude: altitude);
|
||||
}
|
||||
|
||||
/// Compute Earth Rotation Angle from JD in UT1.
|
||||
double computeERA(double jdUT1) {
|
||||
final du = jdUT1 - 2451545.0;
|
||||
final era = 2 * math.pi * (0.779057273264 + 1.0027378119113546 * du);
|
||||
return ((era % (2 * math.pi)) + 2 * math.pi) % (2 * math.pi);
|
||||
}
|
||||
|
||||
/// Simplified GCRS to ITRS rotation (Earth rotation only, no nutation/precession).
|
||||
///
|
||||
/// For lite mode: rotate GCRS by -GMST around the z-axis.
|
||||
/// This is accurate to ~1 deg, sufficient for phase/visibility estimates.
|
||||
Vec3 gcrsToItrsSimple(Vec3 gcrs, TimeScales ts) {
|
||||
// Use ERA (Earth Rotation Angle) from UT1 as the rotation angle
|
||||
final era = computeERA(ts.jdUT1);
|
||||
final cosR = math.cos(era);
|
||||
final sinR = math.sin(era);
|
||||
// Rotation around z-axis by +ERA (GCRS -> ITRS)
|
||||
return (
|
||||
cosR * gcrs.$1 + sinR * gcrs.$2,
|
||||
-sinR * gcrs.$1 + cosR * gcrs.$2,
|
||||
gcrs.$3,
|
||||
);
|
||||
}
|
||||
|
||||
/// Simplified ITRS to GCRS rotation (inverse of [gcrsToItrsSimple]).
|
||||
Vec3 itrsToGcrsSimple(Vec3 itrs, TimeScales ts) {
|
||||
final era = computeERA(ts.jdUT1);
|
||||
final cosR = math.cos(era);
|
||||
final sinR = math.sin(era);
|
||||
return (
|
||||
cosR * itrs.$1 - sinR * itrs.$2,
|
||||
sinR * itrs.$1 + cosR * itrs.$2,
|
||||
itrs.$3,
|
||||
);
|
||||
}
|
||||
|
||||
/// Compute topocentric azimuth and altitude for a body.
|
||||
///
|
||||
/// Pipeline:
|
||||
/// 1. body GCRS -> body ITRS (simplified Earth rotation)
|
||||
/// 2. observer geodetic -> observer ECEF (ITRS, m -> km)
|
||||
/// 3. delta_ITRS = body_ITRS - observer_ITRS
|
||||
/// 4. delta_ITRS -> ENU
|
||||
/// 5. ENU -> az/alt
|
||||
/// 6. Apply Bennett refraction if not airless
|
||||
AzAlt computeAzAlt(
|
||||
Vec3 bodyGCRS,
|
||||
double lat,
|
||||
double lon,
|
||||
double elevation,
|
||||
TimeScales ts, {
|
||||
bool airless = false,
|
||||
double pressure = 1013.25,
|
||||
double temperature = 15,
|
||||
}) {
|
||||
final bodyITRS = gcrsToItrsSimple(bodyGCRS, ts);
|
||||
|
||||
final obsECEF = geodeticToECEF(lat, lon, elevation);
|
||||
final obsITRS = (obsECEF.$1 / 1000, obsECEF.$2 / 1000, obsECEF.$3 / 1000);
|
||||
|
||||
final delta = vsub(bodyITRS, obsITRS);
|
||||
final enu = ecefToENU(delta, lat, lon);
|
||||
final azAlt = enuToAzAlt(enu);
|
||||
|
||||
if (!airless) {
|
||||
azAlt.altitude = applyRefraction(azAlt.altitude, pressure, temperature);
|
||||
}
|
||||
|
||||
return azAlt;
|
||||
}
|
||||
|
||||
/// Bennett (1982) atmospheric refraction correction in degrees.
|
||||
///
|
||||
/// Formula: R = cot(h + 7.31/(h + 4.4)) / 60
|
||||
/// with pressure/temperature correction.
|
||||
double bennettRefraction(
|
||||
double altitudeDeg, {
|
||||
double pressure = 1013.25,
|
||||
double temperature = 15,
|
||||
}) {
|
||||
if (altitudeDeg < -1) return 0;
|
||||
final h = altitudeDeg;
|
||||
final argDeg = h + 7.31 / (h + 4.4);
|
||||
final argRad = argDeg * math.pi / 180;
|
||||
final r = 1 / (math.tan(argRad) * 60);
|
||||
final corrected = r * (pressure / 1010) * (283 / (273 + temperature));
|
||||
return math.max(0, corrected);
|
||||
}
|
||||
|
||||
/// Apply refraction correction to an airless altitude.
|
||||
double applyRefraction(
|
||||
double airlessAlt, [
|
||||
double pressure = 1013.25,
|
||||
double temperature = 15,
|
||||
]) {
|
||||
return airlessAlt +
|
||||
bennettRefraction(
|
||||
airlessAlt,
|
||||
pressure: pressure,
|
||||
temperature: temperature,
|
||||
);
|
||||
}
|
||||
262
lib/src/time.dart
Normal file
262
lib/src/time.dart
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/// Julian Date, time scale conversions, and delta-T.
|
||||
///
|
||||
/// Conversion chain: UTC -> TAI -> TT -> TDB
|
||||
/// TAI = UTC + delta_AT (from leap-second table, integer seconds)
|
||||
/// TT = TAI + 32.184s (exact, by definition)
|
||||
/// TDB ~ TT + 0.001658*sin(g) + ... (sub-millisecond correction)
|
||||
///
|
||||
/// References:
|
||||
/// IERS Conventions (2010), Chapter 5
|
||||
/// NAIF LSK kernel (naif0012.tls)
|
||||
/// Espenak & Meeus, delta-T polynomial expressions
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Julian Date of J2000.0 epoch (2000 Jan 1, 12:00 TT).
|
||||
const double j2000 = 2451545.0;
|
||||
|
||||
/// TT - TAI offset in seconds (exact, by definition).
|
||||
const double ttMinusTai = 32.184;
|
||||
|
||||
/// Seconds per day.
|
||||
const double secondsPerDay = 86400.0;
|
||||
|
||||
/// Days per Julian century.
|
||||
const double daysPerJulianCentury = 36525.0;
|
||||
|
||||
/// All relevant time scale values for a single moment.
|
||||
class TimeScales {
|
||||
/// The original UTC [DateTime].
|
||||
final DateTime utc;
|
||||
|
||||
/// Julian Date in UTC.
|
||||
final double jdUTC;
|
||||
|
||||
/// Julian Date in Terrestrial Time.
|
||||
final double jdTT;
|
||||
|
||||
/// Julian Date in Barycentric Dynamical Time.
|
||||
final double jdTDB;
|
||||
|
||||
/// Julian Date in UT1.
|
||||
final double jdUT1;
|
||||
|
||||
/// TT - UT1 in seconds (delta-T).
|
||||
final double deltaT;
|
||||
|
||||
/// TAI - UTC in seconds (leap seconds count).
|
||||
final double deltaAT;
|
||||
|
||||
const TimeScales({
|
||||
required this.utc,
|
||||
required this.jdUTC,
|
||||
required this.jdTT,
|
||||
required this.jdTDB,
|
||||
required this.jdUT1,
|
||||
required this.deltaT,
|
||||
required this.deltaAT,
|
||||
});
|
||||
}
|
||||
|
||||
/// Leap-second table: [JD(UTC), delta_AT].
|
||||
/// Source: NAIF naif0012.tls.
|
||||
const List<(double, double)> leapSecondTable = [
|
||||
(2441317.5, 10), // 1972 Jan 1
|
||||
(2441499.5, 11), // 1972 Jul 1
|
||||
(2441683.5, 12), // 1973 Jan 1
|
||||
(2442048.5, 13), // 1974 Jan 1
|
||||
(2442413.5, 14), // 1975 Jan 1
|
||||
(2442778.5, 15), // 1976 Jan 1
|
||||
(2443144.5, 16), // 1977 Jan 1
|
||||
(2443509.5, 17), // 1978 Jan 1
|
||||
(2443874.5, 18), // 1979 Jan 1
|
||||
(2444239.5, 19), // 1980 Jan 1
|
||||
(2444786.5, 20), // 1981 Jul 1
|
||||
(2445151.5, 21), // 1982 Jul 1
|
||||
(2445516.5, 22), // 1983 Jul 1
|
||||
(2446247.5, 23), // 1985 Jul 1
|
||||
(2447161.5, 24), // 1988 Jan 1
|
||||
(2447892.5, 25), // 1990 Jan 1
|
||||
(2448257.5, 26), // 1991 Jan 1
|
||||
(2448804.5, 27), // 1992 Jul 1
|
||||
(2449169.5, 28), // 1993 Jul 1
|
||||
(2449534.5, 29), // 1994 Jul 1
|
||||
(2450083.5, 30), // 1996 Jan 1
|
||||
(2450630.5, 31), // 1997 Jul 1
|
||||
(2451179.5, 32), // 1999 Jan 1
|
||||
(2453736.5, 33), // 2006 Jan 1
|
||||
(2454832.5, 34), // 2009 Jan 1
|
||||
(2456109.5, 35), // 2012 Jul 1
|
||||
(2457204.5, 36), // 2015 Jul 1
|
||||
(2457754.5, 37), // 2017 Jan 1
|
||||
];
|
||||
|
||||
/// Get the current leap second count (TAI - UTC) for a given JD in UTC.
|
||||
double getDeltaAT(double jdUTC) {
|
||||
double deltaAT = 10;
|
||||
for (final (jd, dat) in leapSecondTable) {
|
||||
if (jdUTC >= jd) {
|
||||
deltaAT = dat;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return deltaAT;
|
||||
}
|
||||
|
||||
/// Convert a [DateTime] (UTC) to Julian Date in UTC.
|
||||
double dateToJD(DateTime date) {
|
||||
return date.toUtc().millisecondsSinceEpoch / 86400000.0 + 2440587.5;
|
||||
}
|
||||
|
||||
/// Convert a Julian Date in UTC to a [DateTime].
|
||||
DateTime jdToDate(double jd) {
|
||||
final ms = ((jd - 2440587.5) * 86400000.0).round();
|
||||
return DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true);
|
||||
}
|
||||
|
||||
/// Julian centuries from J2000.0 (in TT).
|
||||
double jdTTtoT(double jdTT) => (jdTT - j2000) / daysPerJulianCentury;
|
||||
|
||||
/// Compute all relevant time scales for a given UTC [DateTime].
|
||||
TimeScales computeTimeScales(
|
||||
DateTime utc, {
|
||||
double? ut1utcOverride,
|
||||
double? deltaTOverride,
|
||||
}) {
|
||||
final jdUTC = dateToJD(utc);
|
||||
final deltaAT = getDeltaAT(jdUTC);
|
||||
|
||||
final jdTAI = jdUTC + deltaAT / secondsPerDay;
|
||||
final jdTT = jdTAI + ttMinusTai / secondsPerDay;
|
||||
|
||||
final tdbCorrection = tdbMinusTT(jdTT) / secondsPerDay;
|
||||
final jdTDB = jdTT + tdbCorrection;
|
||||
|
||||
double jdUT1;
|
||||
double deltaT;
|
||||
|
||||
if (ut1utcOverride != null) {
|
||||
jdUT1 = jdUTC + ut1utcOverride / secondsPerDay;
|
||||
deltaT = (jdTT - jdUT1) * secondsPerDay;
|
||||
} else if (deltaTOverride != null) {
|
||||
deltaT = deltaTOverride;
|
||||
jdUT1 = jdTT - deltaT / secondsPerDay;
|
||||
} else {
|
||||
deltaT = deltaTPolynomial(jdTT);
|
||||
jdUT1 = jdTT - deltaT / secondsPerDay;
|
||||
}
|
||||
|
||||
return TimeScales(
|
||||
utc: utc,
|
||||
jdUTC: jdUTC,
|
||||
jdTT: jdTT,
|
||||
jdTDB: jdTDB,
|
||||
jdUT1: jdUT1,
|
||||
deltaT: deltaT,
|
||||
deltaAT: deltaAT,
|
||||
);
|
||||
}
|
||||
|
||||
/// Approximate TDB - TT in seconds.
|
||||
///
|
||||
/// TDB - TT = 0.001658 * sin(g) + 0.000014 * sin(2g)
|
||||
/// where g = 357.53 + 0.9856003 * (JD_TT - 2451545.0)
|
||||
double tdbMinusTT(double jdTT) {
|
||||
final d = jdTT - j2000;
|
||||
final gDeg = 357.53 + 0.9856003 * d;
|
||||
final g = gDeg * math.pi / 180;
|
||||
return 0.001658 * math.sin(g) + 0.000014 * math.sin(2 * g);
|
||||
}
|
||||
|
||||
/// Delta-T polynomial: TT - UT1 in seconds.
|
||||
///
|
||||
/// Uses Espenak & Meeus expressions, piecewise by year range.
|
||||
/// Reference: NASA Five Millennium Canon of Solar Eclipses (2009).
|
||||
double deltaTPolynomial(double jdTT) {
|
||||
final y = 2000 + (jdTT - j2000) / 365.25;
|
||||
|
||||
if (y < -500) {
|
||||
final u = (y - 1820) / 100;
|
||||
return -20 + 32 * u * u;
|
||||
} else if (y < 500) {
|
||||
final u = y / 100;
|
||||
return 10583.6 -
|
||||
1014.41 * u +
|
||||
33.78311 * u * u -
|
||||
5.952053 * u * u * u -
|
||||
0.1798452 * math.pow(u, 4) +
|
||||
0.022174192 * math.pow(u, 5) +
|
||||
0.0090316521 * math.pow(u, 6);
|
||||
} else if (y < 1600) {
|
||||
final u = (y - 1000) / 100;
|
||||
return 1574.2 -
|
||||
556.01 * u +
|
||||
71.23472 * u * u +
|
||||
0.319781 * math.pow(u, 3) -
|
||||
0.8503463 * math.pow(u, 4) -
|
||||
0.005050998 * math.pow(u, 5) +
|
||||
0.0083572073 * math.pow(u, 6);
|
||||
} else if (y < 1700) {
|
||||
final t = y - 1600;
|
||||
return 120 - 0.9808 * t - 0.01532 * t * t + math.pow(t, 3) / 7129;
|
||||
} else if (y < 1800) {
|
||||
final t = y - 1700;
|
||||
return 8.83 +
|
||||
0.1603 * t -
|
||||
0.0059285 * t * t +
|
||||
0.00013336 * math.pow(t, 3) -
|
||||
math.pow(t, 4) / 1174000;
|
||||
} else if (y < 1860) {
|
||||
final t = y - 1800;
|
||||
return 13.72 -
|
||||
0.332447 * t +
|
||||
0.0068612 * t * t +
|
||||
0.0041116 * math.pow(t, 3) -
|
||||
0.00037436 * math.pow(t, 4) +
|
||||
0.0000121272 * math.pow(t, 5) -
|
||||
0.0000001699 * math.pow(t, 6) +
|
||||
0.000000000875 * math.pow(t, 7);
|
||||
} else if (y < 1900) {
|
||||
final t = y - 1860;
|
||||
return 7.62 +
|
||||
0.5737 * t -
|
||||
0.251754 * t * t +
|
||||
0.01680668 * math.pow(t, 3) -
|
||||
0.0004473624 * math.pow(t, 4) +
|
||||
math.pow(t, 5) / 233174;
|
||||
} else if (y < 1920) {
|
||||
final t = y - 1900;
|
||||
return -2.79 +
|
||||
1.494119 * t -
|
||||
0.0598939 * t * t +
|
||||
0.0061966 * math.pow(t, 3) -
|
||||
0.000197 * math.pow(t, 4);
|
||||
} else if (y < 1941) {
|
||||
final t = y - 1920;
|
||||
return 21.2 + 0.84493 * t - 0.0761 * t * t + 0.0020936 * math.pow(t, 3);
|
||||
} else if (y < 1961) {
|
||||
final t = y - 1950;
|
||||
return 29.07 + 0.407 * t - (t * t) / 233 + math.pow(t, 3) / 2547;
|
||||
} else if (y < 1986) {
|
||||
final t = y - 1975;
|
||||
return 45.45 + 1.067 * t - (t * t) / 260 - math.pow(t, 3) / 718;
|
||||
} else if (y < 2005) {
|
||||
final t = y - 2000;
|
||||
return 63.86 +
|
||||
0.3345 * t -
|
||||
0.060374 * t * t +
|
||||
0.0017275 * math.pow(t, 3) +
|
||||
0.000651814 * math.pow(t, 4) +
|
||||
0.00002373599 * math.pow(t, 5);
|
||||
} else if (y < 2050) {
|
||||
final t = y - 2000;
|
||||
return 62.92 + 0.32217 * t + 0.005589 * t * t;
|
||||
} else if (y < 2150) {
|
||||
return -20 + 32 * math.pow((y - 1820) / 100, 2) - 0.5628 * (2150 - y);
|
||||
} else {
|
||||
final u = (y - 1820) / 100;
|
||||
return -20 + 32 * u * u;
|
||||
}
|
||||
}
|
||||
370
lib/src/types.dart
Normal file
370
lib/src/types.dart
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
/// All result types for the moon_sighting package.
|
||||
library;
|
||||
|
||||
/// Azimuth + altitude in degrees.
|
||||
class AzAlt {
|
||||
/// Degrees from North, measured clockwise (0=N, 90=E, 180=S, 270=W).
|
||||
double azimuth;
|
||||
|
||||
/// Degrees above the horizon (negative = below).
|
||||
double altitude;
|
||||
|
||||
AzAlt({required this.azimuth, required this.altitude});
|
||||
}
|
||||
|
||||
/// Topocentric moon position.
|
||||
///
|
||||
/// Computed via Meeus Ch. 47 (no kernel required).
|
||||
/// Accuracy: azimuth/altitude ~0.3 deg, distance ~300 km.
|
||||
class MoonPosition {
|
||||
/// Azimuth in degrees from North, clockwise.
|
||||
final double azimuth;
|
||||
|
||||
/// Apparent altitude in degrees (atmospheric refraction applied).
|
||||
final double altitude;
|
||||
|
||||
/// Distance from Earth center to Moon center, km.
|
||||
final double distance;
|
||||
|
||||
/// Parallactic angle in radians.
|
||||
final double parallacticAngle;
|
||||
|
||||
const MoonPosition({
|
||||
required this.azimuth,
|
||||
required this.altitude,
|
||||
required this.distance,
|
||||
required this.parallacticAngle,
|
||||
});
|
||||
}
|
||||
|
||||
/// Moon illumination result.
|
||||
///
|
||||
/// Computed via Meeus Ch. 47/48 (no kernel required).
|
||||
class MoonIlluminationResult {
|
||||
/// Illuminated fraction 0 (new) to 1 (full).
|
||||
final double fraction;
|
||||
|
||||
/// Phase cycle fraction in [0, 1):
|
||||
/// 0 = new, 0.25 = first quarter, 0.5 = full, 0.75 = last quarter.
|
||||
final double phase;
|
||||
|
||||
/// Position angle of the bright limb midpoint in radians.
|
||||
final double angle;
|
||||
|
||||
/// True while elongation is increasing (new toward full).
|
||||
final bool isWaxing;
|
||||
|
||||
const MoonIlluminationResult({
|
||||
required this.fraction,
|
||||
required this.phase,
|
||||
required this.angle,
|
||||
required this.isWaxing,
|
||||
});
|
||||
}
|
||||
|
||||
/// Named moon phase identifiers.
|
||||
enum MoonPhaseName {
|
||||
newMoon('new-moon'),
|
||||
waxingCrescent('waxing-crescent'),
|
||||
firstQuarter('first-quarter'),
|
||||
waxingGibbous('waxing-gibbous'),
|
||||
fullMoon('full-moon'),
|
||||
waningGibbous('waning-gibbous'),
|
||||
lastQuarter('last-quarter'),
|
||||
waningCrescent('waning-crescent');
|
||||
|
||||
final String id;
|
||||
const MoonPhaseName(this.id);
|
||||
}
|
||||
|
||||
/// Moon phase result from [getMoonPhase].
|
||||
class MoonPhaseResult {
|
||||
/// Named phase.
|
||||
final MoonPhaseName phase;
|
||||
|
||||
/// Human-readable name, e.g. "Waxing Crescent".
|
||||
final String phaseName;
|
||||
|
||||
/// Moon phase emoji symbol.
|
||||
final String phaseSymbol;
|
||||
|
||||
/// Illuminated fraction 0-100 (percent).
|
||||
final double illumination;
|
||||
|
||||
/// Hours since last new moon.
|
||||
final double age;
|
||||
|
||||
/// Ecliptic longitude of Moon minus Sun, degrees [0, 360).
|
||||
final double elongationDeg;
|
||||
|
||||
/// True when illumination is increasing.
|
||||
final bool isWaxing;
|
||||
|
||||
/// UTC date of the next new moon.
|
||||
final DateTime nextNewMoon;
|
||||
|
||||
/// UTC date of the next full moon.
|
||||
final DateTime nextFullMoon;
|
||||
|
||||
/// UTC date of the previous new moon.
|
||||
final DateTime prevNewMoon;
|
||||
|
||||
const MoonPhaseResult({
|
||||
required this.phase,
|
||||
required this.phaseName,
|
||||
required this.phaseSymbol,
|
||||
required this.illumination,
|
||||
required this.age,
|
||||
required this.elongationDeg,
|
||||
required this.isWaxing,
|
||||
required this.nextNewMoon,
|
||||
required this.nextFullMoon,
|
||||
required this.prevNewMoon,
|
||||
});
|
||||
}
|
||||
|
||||
/// Yallop q-test visibility category (NAO Technical Note 69).
|
||||
enum YallopCategory {
|
||||
/// Easily visible to the naked eye.
|
||||
a('A'),
|
||||
|
||||
/// Visible under perfect conditions.
|
||||
b('B'),
|
||||
|
||||
/// May need optical aid to find; naked eye possible.
|
||||
c('C'),
|
||||
|
||||
/// Optical aid needed; naked eye not possible.
|
||||
d('D'),
|
||||
|
||||
/// Not visible even with telescope under good conditions.
|
||||
e('E'),
|
||||
|
||||
/// Below Danjon limit.
|
||||
f('F');
|
||||
|
||||
final String label;
|
||||
const YallopCategory(this.label);
|
||||
}
|
||||
|
||||
/// Yallop q-test result.
|
||||
class YallopResult {
|
||||
/// The continuous q parameter (higher = more visible).
|
||||
final double q;
|
||||
|
||||
/// Visibility category A through F.
|
||||
final YallopCategory category;
|
||||
|
||||
/// Human-readable interpretation.
|
||||
final String description;
|
||||
|
||||
/// True for categories A and B.
|
||||
final bool isVisibleNakedEye;
|
||||
|
||||
/// True for categories C and D.
|
||||
final bool requiresOpticalAid;
|
||||
|
||||
/// True for category F.
|
||||
final bool isBelowDanjonLimit;
|
||||
|
||||
/// Topocentric crescent width W' used in the q formula, arc minutes.
|
||||
final double wprime;
|
||||
|
||||
const YallopResult({
|
||||
required this.q,
|
||||
required this.category,
|
||||
required this.description,
|
||||
required this.isVisibleNakedEye,
|
||||
required this.requiresOpticalAid,
|
||||
required this.isBelowDanjonLimit,
|
||||
required this.wprime,
|
||||
});
|
||||
}
|
||||
|
||||
/// Odeh visibility zone (Experimental Astronomy 2006).
|
||||
enum OdehZone {
|
||||
/// Visible with naked eye.
|
||||
a('A'),
|
||||
|
||||
/// Visible with optical aid; may be seen with naked eye.
|
||||
b('B'),
|
||||
|
||||
/// Visible with optical aid only.
|
||||
c('C'),
|
||||
|
||||
/// Not visible even with optical aid.
|
||||
d('D');
|
||||
|
||||
final String label;
|
||||
const OdehZone(this.label);
|
||||
}
|
||||
|
||||
/// Odeh criterion result.
|
||||
class OdehResult {
|
||||
/// Continuous V parameter: V = ARCV - f(W). Positive = exceeds threshold.
|
||||
final double v;
|
||||
|
||||
/// Visibility zone A through D.
|
||||
final OdehZone zone;
|
||||
|
||||
/// Human-readable interpretation.
|
||||
final String description;
|
||||
|
||||
/// True for zone A.
|
||||
final bool isVisibleNakedEye;
|
||||
|
||||
/// True for zones A and B.
|
||||
final bool isVisibleWithOpticalAid;
|
||||
|
||||
const OdehResult({
|
||||
required this.v,
|
||||
required this.zone,
|
||||
required this.description,
|
||||
required this.isVisibleNakedEye,
|
||||
required this.isVisibleWithOpticalAid,
|
||||
});
|
||||
}
|
||||
|
||||
/// Crescent geometry quantities.
|
||||
class CrescentGeometry {
|
||||
/// Arc of light: Sun-Moon angular separation (elongation), degrees.
|
||||
final double arcl;
|
||||
|
||||
/// Arc of vision: Moon airless alt minus Sun airless alt, degrees.
|
||||
final double arcv;
|
||||
|
||||
/// Relative azimuth: Sun az minus Moon az, degrees.
|
||||
final double daz;
|
||||
|
||||
/// Topocentric crescent width in arc minutes.
|
||||
final double w;
|
||||
|
||||
const CrescentGeometry({
|
||||
required this.arcl,
|
||||
required this.arcv,
|
||||
required this.daz,
|
||||
required this.w,
|
||||
});
|
||||
}
|
||||
|
||||
/// Kernel-free Odeh-based crescent visibility estimate.
|
||||
class MoonVisibilityEstimate {
|
||||
/// Odeh V parameter.
|
||||
final double v;
|
||||
|
||||
/// Visibility zone A through D.
|
||||
final OdehZone zone;
|
||||
|
||||
/// Human-readable zone description.
|
||||
final String description;
|
||||
|
||||
/// True for zone A.
|
||||
final bool isVisibleNakedEye;
|
||||
|
||||
/// True for zones A and B.
|
||||
final bool isVisibleWithOpticalAid;
|
||||
|
||||
/// Arc of light (Sun-Moon elongation) in degrees.
|
||||
final double arcl;
|
||||
|
||||
/// Arc of vision (Moon airless alt minus Sun airless alt) in degrees.
|
||||
final double arcv;
|
||||
|
||||
/// Topocentric crescent width in arc minutes.
|
||||
final double w;
|
||||
|
||||
/// True when Moon is above the horizon at the given time.
|
||||
final bool moonAboveHorizon;
|
||||
|
||||
/// Always true: computed via Meeus approximation.
|
||||
final bool isApproximate = true;
|
||||
|
||||
MoonVisibilityEstimate({
|
||||
required this.v,
|
||||
required this.zone,
|
||||
required this.description,
|
||||
required this.isVisibleNakedEye,
|
||||
required this.isVisibleWithOpticalAid,
|
||||
required this.arcl,
|
||||
required this.arcv,
|
||||
required this.w,
|
||||
required this.moonAboveHorizon,
|
||||
});
|
||||
}
|
||||
|
||||
/// Combined kernel-free moon snapshot.
|
||||
class MoonSnapshot {
|
||||
/// Phase name, illumination, age, and next events.
|
||||
final MoonPhaseResult phase;
|
||||
|
||||
/// Topocentric az/alt, distance, parallactic angle.
|
||||
final MoonPosition position;
|
||||
|
||||
/// Illumination fraction, phase cycle, bright limb angle.
|
||||
final MoonIlluminationResult illumination;
|
||||
|
||||
/// Quick Odeh-based crescent visibility estimate.
|
||||
final MoonVisibilityEstimate visibility;
|
||||
|
||||
const MoonSnapshot({
|
||||
required this.phase,
|
||||
required this.position,
|
||||
required this.illumination,
|
||||
required this.visibility,
|
||||
});
|
||||
}
|
||||
|
||||
/// Published Yallop q thresholds.
|
||||
const Map<YallopCategory, double> yallopThresholds = {
|
||||
YallopCategory.a: 0.216,
|
||||
YallopCategory.b: -0.014,
|
||||
YallopCategory.c: -0.16,
|
||||
YallopCategory.d: -0.232,
|
||||
YallopCategory.e: -0.293,
|
||||
};
|
||||
|
||||
/// Yallop category descriptions.
|
||||
const Map<YallopCategory, String> yallopDescriptions = {
|
||||
YallopCategory.a: 'Easily visible to the naked eye',
|
||||
YallopCategory.b: 'Visible under perfect conditions',
|
||||
YallopCategory.c: 'May need optical aid to find; naked eye possible',
|
||||
YallopCategory.d: 'Optical aid needed; naked eye not possible',
|
||||
YallopCategory.e: 'Not visible even with telescope under good conditions',
|
||||
YallopCategory.f: 'Below Danjon limit — crescent cannot form',
|
||||
};
|
||||
|
||||
/// Published Odeh V thresholds.
|
||||
const Map<OdehZone, double> odehThresholds = {
|
||||
OdehZone.a: 5.65,
|
||||
OdehZone.b: 2.0,
|
||||
OdehZone.c: -0.96,
|
||||
};
|
||||
|
||||
/// Odeh zone descriptions.
|
||||
const Map<OdehZone, String> odehDescriptions = {
|
||||
OdehZone.a: 'Visible with naked eye',
|
||||
OdehZone.b:
|
||||
'Visible with optical aid; may be seen with naked eye under excellent conditions',
|
||||
OdehZone.c: 'Visible with optical aid only',
|
||||
OdehZone.d: 'Not visible even with optical aid',
|
||||
};
|
||||
|
||||
/// WGS84 reference ellipsoid parameters.
|
||||
class WGS84 {
|
||||
WGS84._();
|
||||
|
||||
/// Semi-major axis in meters.
|
||||
static const double a = 6378137.0;
|
||||
|
||||
/// Inverse flattening.
|
||||
static const double invF = 298.257223563;
|
||||
|
||||
/// Flattening.
|
||||
static const double f = 1 / invF;
|
||||
|
||||
/// Semi-minor axis in meters.
|
||||
static final double b = a * (1 - f);
|
||||
|
||||
/// First eccentricity squared.
|
||||
static final double e2 = 2 * f - f * f;
|
||||
}
|
||||
106
lib/src/visibility.dart
Normal file
106
lib/src/visibility.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/// Crescent visibility criteria: Yallop and Odeh.
|
||||
///
|
||||
/// Both criteria transform the geometric quantities (ARCL, ARCV, DAZ, W)
|
||||
/// into a single score that maps to a visibility category.
|
||||
///
|
||||
/// References:
|
||||
/// Yallop (1997), NAO Technical Note No. 69
|
||||
/// Odeh (2006), Experimental Astronomy 18(1), 39-64
|
||||
library;
|
||||
|
||||
import 'math.dart';
|
||||
import 'bodies.dart';
|
||||
import 'types.dart';
|
||||
|
||||
/// The polynomial ARCV minimum as a function of crescent width W (arc minutes).
|
||||
///
|
||||
/// arcv_min(W) = 11.8371 - 6.3226*W + 0.7319*W^2 - 0.1018*W^3
|
||||
///
|
||||
/// Represents the minimum arc of vision required for detection, derived
|
||||
/// empirically from historical observations.
|
||||
double arcvMinimum(double w) {
|
||||
return 11.8371 - 6.3226 * w + 0.7319 * w * w - 0.1018 * w * w * w;
|
||||
}
|
||||
|
||||
/// Compute the Yallop q parameter.
|
||||
///
|
||||
/// q = (ARCV - arcv_min(W')) / 10
|
||||
double computeYallopQ(double arcv, double wprime) {
|
||||
return (arcv - arcvMinimum(wprime)) / 10;
|
||||
}
|
||||
|
||||
/// Map a q value to Yallop category.
|
||||
YallopCategory yallopCategoryFromQ(double q) {
|
||||
if (q > yallopThresholds[YallopCategory.a]!) return YallopCategory.a;
|
||||
if (q > yallopThresholds[YallopCategory.b]!) return YallopCategory.b;
|
||||
if (q > yallopThresholds[YallopCategory.c]!) return YallopCategory.c;
|
||||
if (q > yallopThresholds[YallopCategory.d]!) return YallopCategory.d;
|
||||
if (q > yallopThresholds[YallopCategory.e]!) return YallopCategory.e;
|
||||
return YallopCategory.f;
|
||||
}
|
||||
|
||||
/// Compute the full Yallop result from crescent geometry.
|
||||
YallopResult computeYallop(CrescentGeometry geometry, double wprime) {
|
||||
final q = computeYallopQ(geometry.arcv, wprime);
|
||||
final category = yallopCategoryFromQ(q);
|
||||
|
||||
return YallopResult(
|
||||
q: q,
|
||||
category: category,
|
||||
description: yallopDescriptions[category]!,
|
||||
isVisibleNakedEye:
|
||||
category == YallopCategory.a || category == YallopCategory.b,
|
||||
requiresOpticalAid:
|
||||
category == YallopCategory.c || category == YallopCategory.d,
|
||||
isBelowDanjonLimit: category == YallopCategory.f,
|
||||
wprime: wprime,
|
||||
);
|
||||
}
|
||||
|
||||
/// Compute the Odeh V parameter.
|
||||
///
|
||||
/// V = ARCV - arcv_min(W)
|
||||
double computeOdehV(double arcv, double w) {
|
||||
return arcv - arcvMinimum(w);
|
||||
}
|
||||
|
||||
/// Map a V value to the Odeh zone.
|
||||
OdehZone odehZoneFromV(double v) {
|
||||
if (v >= odehThresholds[OdehZone.a]!) return OdehZone.a;
|
||||
if (v >= odehThresholds[OdehZone.b]!) return OdehZone.b;
|
||||
if (v >= odehThresholds[OdehZone.c]!) return OdehZone.c;
|
||||
return OdehZone.d;
|
||||
}
|
||||
|
||||
/// Compute the full Odeh result from crescent geometry.
|
||||
OdehResult computeOdeh(CrescentGeometry geometry) {
|
||||
final v = computeOdehV(geometry.arcv, geometry.w);
|
||||
final zone = odehZoneFromV(v);
|
||||
|
||||
return OdehResult(
|
||||
v: v,
|
||||
zone: zone,
|
||||
description: odehDescriptions[zone]!,
|
||||
isVisibleNakedEye: zone == OdehZone.a,
|
||||
isVisibleWithOpticalAid: zone == OdehZone.a || zone == OdehZone.b,
|
||||
);
|
||||
}
|
||||
|
||||
/// Compute crescent geometry quantities from airless topocentric positions.
|
||||
CrescentGeometry computeCrescentGeometry({
|
||||
required AzAlt moonAirless,
|
||||
required AzAlt sunAirless,
|
||||
required Vec3 moonTopo,
|
||||
required Vec3 sunTopo,
|
||||
}) {
|
||||
final arcv = moonAirless.altitude - sunAirless.altitude;
|
||||
|
||||
var daz = sunAirless.azimuth - moonAirless.azimuth;
|
||||
if (daz > 180) daz -= 360;
|
||||
if (daz < -180) daz += 360;
|
||||
|
||||
final arcl = angularSep(moonTopo, sunTopo) * (180 / 3.141592653589793);
|
||||
final (:w, wprime: _) = computeCrescentWidth(moonTopo, arcl);
|
||||
|
||||
return CrescentGeometry(arcl: arcl, arcv: arcv, daz: daz, w: w);
|
||||
}
|
||||
21
pubspec.yaml
Normal file
21
pubspec.yaml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name: moon_sighting
|
||||
description: >
|
||||
Lunar crescent visibility for Dart and Flutter. Moon phase, position,
|
||||
illumination, and Yallop/Odeh visibility criteria using Meeus algorithms.
|
||||
Zero dependencies.
|
||||
version: 1.0.0
|
||||
repository: https://github.com/acamarata/moon-sighting-dart
|
||||
issue_tracker: https://github.com/acamarata/moon-sighting-dart/issues
|
||||
topics:
|
||||
- moon
|
||||
- lunar
|
||||
- crescent
|
||||
- astronomy
|
||||
- islamic
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.8
|
||||
441
test/moon_sighting_test.dart
Normal file
441
test/moon_sighting_test.dart
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
import 'package:moon_sighting/moon_sighting.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
group('Constants', () {
|
||||
test('Yallop threshold A is 0.216', () {
|
||||
expect(yallopThresholds[YallopCategory.a], equals(0.216));
|
||||
});
|
||||
|
||||
test('Yallop threshold E is -0.293', () {
|
||||
expect(yallopThresholds[YallopCategory.e], equals(-0.293));
|
||||
});
|
||||
|
||||
test('All Yallop thresholds are defined', () {
|
||||
for (final cat in [
|
||||
YallopCategory.a,
|
||||
YallopCategory.b,
|
||||
YallopCategory.c,
|
||||
YallopCategory.d,
|
||||
YallopCategory.e,
|
||||
]) {
|
||||
expect(yallopThresholds[cat], isNotNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('Yallop thresholds descend A > B > C > D > E', () {
|
||||
expect(
|
||||
yallopThresholds[YallopCategory.a]!,
|
||||
greaterThan(yallopThresholds[YallopCategory.b]!),
|
||||
);
|
||||
expect(
|
||||
yallopThresholds[YallopCategory.b]!,
|
||||
greaterThan(yallopThresholds[YallopCategory.c]!),
|
||||
);
|
||||
expect(
|
||||
yallopThresholds[YallopCategory.c]!,
|
||||
greaterThan(yallopThresholds[YallopCategory.d]!),
|
||||
);
|
||||
expect(
|
||||
yallopThresholds[YallopCategory.d]!,
|
||||
greaterThan(yallopThresholds[YallopCategory.e]!),
|
||||
);
|
||||
});
|
||||
|
||||
test('Odeh threshold A is 5.65', () {
|
||||
expect(odehThresholds[OdehZone.a], equals(5.65));
|
||||
});
|
||||
|
||||
test('Odeh threshold C is -0.96', () {
|
||||
expect(odehThresholds[OdehZone.c], equals(-0.96));
|
||||
});
|
||||
|
||||
test('Odeh thresholds descend A > B > C', () {
|
||||
expect(
|
||||
odehThresholds[OdehZone.a]!,
|
||||
greaterThan(odehThresholds[OdehZone.b]!),
|
||||
);
|
||||
expect(
|
||||
odehThresholds[OdehZone.b]!,
|
||||
greaterThan(odehThresholds[OdehZone.c]!),
|
||||
);
|
||||
});
|
||||
|
||||
test('WGS84.a is 6378137.0', () {
|
||||
expect(WGS84.a, equals(6378137.0));
|
||||
});
|
||||
|
||||
test('WGS84.e2 is positive and < 1', () {
|
||||
expect(WGS84.e2, greaterThan(0));
|
||||
expect(WGS84.e2, lessThan(1));
|
||||
});
|
||||
|
||||
test('WGS84.b < WGS84.a (oblate spheroid)', () {
|
||||
expect(WGS84.b, lessThan(WGS84.a));
|
||||
});
|
||||
|
||||
test('Yallop descriptions are non-empty strings', () {
|
||||
for (final cat in YallopCategory.values) {
|
||||
expect(yallopDescriptions[cat], isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('Odeh descriptions are non-empty strings', () {
|
||||
for (final zone in OdehZone.values) {
|
||||
expect(odehDescriptions[zone], isNotEmpty);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMoonPhase ───────────────────────────────────────────────────────
|
||||
|
||||
final dateMarch1 = DateTime.utc(2025, 3, 1, 12);
|
||||
final phaseMarch1 = getMoonPhase(dateMarch1);
|
||||
|
||||
group('getMoonPhase structure', () {
|
||||
test('illumination is in [0, 100]', () {
|
||||
expect(phaseMarch1.illumination, greaterThanOrEqualTo(0));
|
||||
expect(phaseMarch1.illumination, lessThanOrEqualTo(100));
|
||||
});
|
||||
|
||||
test('age is >= 0', () {
|
||||
expect(phaseMarch1.age, greaterThanOrEqualTo(0));
|
||||
});
|
||||
|
||||
test('elongationDeg is in [0, 180]', () {
|
||||
expect(phaseMarch1.elongationDeg, greaterThanOrEqualTo(0));
|
||||
expect(phaseMarch1.elongationDeg, lessThanOrEqualTo(180));
|
||||
});
|
||||
|
||||
test('nextNewMoon is a DateTime', () {
|
||||
expect(phaseMarch1.nextNewMoon, isA<DateTime>());
|
||||
});
|
||||
|
||||
test('prevNewMoon is before reference date', () {
|
||||
expect(phaseMarch1.prevNewMoon.isBefore(dateMarch1), isTrue);
|
||||
});
|
||||
|
||||
test('nextNewMoon is after prevNewMoon', () {
|
||||
expect(phaseMarch1.nextNewMoon.isAfter(phaseMarch1.prevNewMoon), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('getMoonPhase phase boundaries', () {
|
||||
test('near full moon: illumination > 85%', () {
|
||||
final phaseFull = getMoonPhase(DateTime.utc(2025, 3, 14, 12));
|
||||
expect(phaseFull.illumination, greaterThan(85));
|
||||
});
|
||||
|
||||
test('near full moon: elongation > 120 deg', () {
|
||||
final phaseFull = getMoonPhase(DateTime.utc(2025, 3, 14, 12));
|
||||
expect(phaseFull.elongationDeg, greaterThan(120));
|
||||
});
|
||||
|
||||
test('near new moon: illumination < 10%', () {
|
||||
final phaseNew = getMoonPhase(DateTime.utc(2025, 3, 29, 12));
|
||||
expect(phaseNew.illumination, lessThan(10));
|
||||
});
|
||||
|
||||
test('near new moon: elongation < 30 deg', () {
|
||||
final phaseNew = getMoonPhase(DateTime.utc(2025, 3, 29, 12));
|
||||
expect(phaseNew.elongationDeg, lessThan(30));
|
||||
});
|
||||
});
|
||||
|
||||
group('getMoonPhase consistency', () {
|
||||
test('5 days after new moon: isWaxing = true', () {
|
||||
expect(getMoonPhase(DateTime.utc(2025, 3, 5, 12)).isWaxing, isTrue);
|
||||
});
|
||||
|
||||
test('6 days after full moon: isWaxing = false', () {
|
||||
expect(getMoonPhase(DateTime.utc(2025, 3, 20, 12)).isWaxing, isFalse);
|
||||
});
|
||||
|
||||
test('default date (now) returns valid result', () {
|
||||
final nowPhase = getMoonPhase();
|
||||
expect(nowPhase.illumination, greaterThanOrEqualTo(0));
|
||||
expect(nowPhase.illumination, lessThanOrEqualTo(100));
|
||||
});
|
||||
|
||||
test('synodic month duration is ~29.5 days', () {
|
||||
final synodicMs =
|
||||
phaseMarch1.nextNewMoon
|
||||
.difference(phaseMarch1.prevNewMoon)
|
||||
.inMilliseconds;
|
||||
final synodicDays = synodicMs / 86400000;
|
||||
expect(synodicDays, greaterThan(29.0));
|
||||
expect(synodicDays, lessThan(30.1));
|
||||
});
|
||||
});
|
||||
|
||||
group('getMoonPhase phaseName and phaseSymbol', () {
|
||||
test('waxing crescent: phaseName is Waxing Crescent', () {
|
||||
final p = getMoonPhase(DateTime.utc(2025, 3, 5, 12));
|
||||
expect(p.phaseName, equals('Waxing Crescent'));
|
||||
});
|
||||
|
||||
test('phaseName is a valid string', () {
|
||||
final validNames = {
|
||||
'New Moon',
|
||||
'Waxing Crescent',
|
||||
'First Quarter',
|
||||
'Waxing Gibbous',
|
||||
'Full Moon',
|
||||
'Waning Gibbous',
|
||||
'Last Quarter',
|
||||
'Waning Crescent',
|
||||
};
|
||||
final p = getMoonPhase(dateMarch1);
|
||||
expect(validNames.contains(p.phaseName), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMoonPosition ────────────────────────────────────────────────────
|
||||
|
||||
group('getMoonPosition', () {
|
||||
final moonPos = getMoonPosition(
|
||||
DateTime.utc(2025, 3, 14, 20),
|
||||
51.5074,
|
||||
-0.1278,
|
||||
elevation: 10,
|
||||
);
|
||||
|
||||
test('azimuth in [0, 360)', () {
|
||||
expect(moonPos.azimuth, greaterThanOrEqualTo(0));
|
||||
expect(moonPos.azimuth, lessThan(360));
|
||||
});
|
||||
|
||||
test('altitude in [-90, 90]', () {
|
||||
expect(moonPos.altitude, greaterThanOrEqualTo(-90));
|
||||
expect(moonPos.altitude, lessThanOrEqualTo(90));
|
||||
});
|
||||
|
||||
test('distance in lunar orbit range [356000, 407000] km', () {
|
||||
expect(moonPos.distance, greaterThanOrEqualTo(356000));
|
||||
expect(moonPos.distance, lessThanOrEqualTo(407000));
|
||||
});
|
||||
|
||||
test('parallacticAngle is finite', () {
|
||||
expect(moonPos.parallacticAngle.isFinite, isTrue);
|
||||
});
|
||||
|
||||
test('default date returns valid result', () {
|
||||
final pos = getMoonPosition(null, 21.4225, 39.8262);
|
||||
expect(pos.azimuth, greaterThanOrEqualTo(0));
|
||||
expect(pos.azimuth, lessThan(360));
|
||||
expect(pos.distance, greaterThan(350000));
|
||||
expect(pos.distance, lessThan(410000));
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMoonIllumination ────────────────────────────────────────────────
|
||||
|
||||
group('getMoonIllumination', () {
|
||||
final illumFull = getMoonIllumination(DateTime.utc(2025, 3, 14, 12));
|
||||
final illumNew = getMoonIllumination(DateTime.utc(2025, 3, 29, 12));
|
||||
final illumWaxing = getMoonIllumination(DateTime.utc(2025, 3, 5, 12));
|
||||
|
||||
test('near full moon: fraction > 0.85', () {
|
||||
expect(illumFull.fraction, greaterThan(0.85));
|
||||
});
|
||||
|
||||
test('near full moon: phase close to 0.5', () {
|
||||
expect(illumFull.phase, greaterThan(0.4));
|
||||
expect(illumFull.phase, lessThan(0.6));
|
||||
});
|
||||
|
||||
test('near new moon: fraction < 0.05', () {
|
||||
expect(illumNew.fraction, lessThan(0.05));
|
||||
});
|
||||
|
||||
test('near new moon: phase close to 0 or 1', () {
|
||||
expect(illumNew.phase < 0.08 || illumNew.phase > 0.92, isTrue);
|
||||
});
|
||||
|
||||
test('waxing: isWaxing = true', () {
|
||||
expect(illumWaxing.isWaxing, isTrue);
|
||||
});
|
||||
|
||||
test('fraction in [0, 1]', () {
|
||||
expect(illumFull.fraction, greaterThanOrEqualTo(0));
|
||||
expect(illumFull.fraction, lessThanOrEqualTo(1));
|
||||
expect(illumNew.fraction, greaterThanOrEqualTo(0));
|
||||
expect(illumNew.fraction, lessThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
test('phase in [0, 1)', () {
|
||||
expect(illumFull.phase, greaterThanOrEqualTo(0));
|
||||
expect(illumFull.phase, lessThan(1));
|
||||
});
|
||||
|
||||
test('angle is finite', () {
|
||||
expect(illumFull.angle.isFinite, isTrue);
|
||||
});
|
||||
|
||||
test('default date returns valid result', () {
|
||||
final illum = getMoonIllumination();
|
||||
expect(illum.fraction, greaterThanOrEqualTo(0));
|
||||
expect(illum.fraction, lessThanOrEqualTo(1));
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMoonVisibilityEstimate ──────────────────────────────────────────
|
||||
|
||||
group('getMoonVisibilityEstimate', () {
|
||||
final vis = getMoonVisibilityEstimate(
|
||||
DateTime.utc(2025, 3, 2, 18, 30),
|
||||
51.5074,
|
||||
-0.1278,
|
||||
elevation: 10,
|
||||
);
|
||||
|
||||
test('zone is A, B, C, or D', () {
|
||||
expect(OdehZone.values.contains(vis.zone), isTrue);
|
||||
});
|
||||
|
||||
test('V is finite', () {
|
||||
expect(vis.v.isFinite, isTrue);
|
||||
});
|
||||
|
||||
test('ARCL is in [0, 180]', () {
|
||||
expect(vis.arcl, greaterThanOrEqualTo(0));
|
||||
expect(vis.arcl, lessThanOrEqualTo(180));
|
||||
});
|
||||
|
||||
test('W >= 0', () {
|
||||
expect(vis.w, greaterThanOrEqualTo(0));
|
||||
});
|
||||
|
||||
test('isApproximate is true', () {
|
||||
expect(vis.isApproximate, isTrue);
|
||||
});
|
||||
|
||||
test('isVisibleNakedEye matches zone A', () {
|
||||
expect(vis.isVisibleNakedEye, equals(vis.zone == OdehZone.a));
|
||||
});
|
||||
|
||||
test('isVisibleWithOpticalAid matches zone A or B', () {
|
||||
expect(
|
||||
vis.isVisibleWithOpticalAid,
|
||||
equals(vis.zone == OdehZone.a || vis.zone == OdehZone.b),
|
||||
);
|
||||
});
|
||||
|
||||
test('near new moon: zone is D or C', () {
|
||||
final nearNew = getMoonVisibilityEstimate(
|
||||
DateTime.utc(2025, 3, 29, 18),
|
||||
21.4225,
|
||||
39.8262,
|
||||
);
|
||||
expect(nearNew.zone == OdehZone.c || nearNew.zone == OdehZone.d, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMoon ────────────────────────────────────────────────────────────
|
||||
|
||||
group('getMoon', () {
|
||||
final moon = getMoon(
|
||||
DateTime.utc(2025, 3, 5, 20),
|
||||
51.5074,
|
||||
-0.1278,
|
||||
elevation: 10,
|
||||
);
|
||||
|
||||
test('returns object with phase, position, illumination, visibility', () {
|
||||
expect(moon.phase, isNotNull);
|
||||
expect(moon.position, isNotNull);
|
||||
expect(moon.illumination, isNotNull);
|
||||
expect(moon.visibility, isNotNull);
|
||||
});
|
||||
|
||||
test('phase is consistent with getMoonPhase standalone', () {
|
||||
final standalone = getMoonPhase(DateTime.utc(2025, 3, 5, 20));
|
||||
expect(moon.phase.phase, equals(standalone.phase));
|
||||
expect(moon.phase.phaseName, equals(standalone.phaseName));
|
||||
});
|
||||
|
||||
test('illumination.isWaxing matches phase.isWaxing', () {
|
||||
expect(moon.illumination.isWaxing, equals(moon.phase.isWaxing));
|
||||
});
|
||||
|
||||
test('visibility.isApproximate is true', () {
|
||||
expect(moon.visibility.isApproximate, isTrue);
|
||||
});
|
||||
|
||||
test('position has valid azimuth and altitude', () {
|
||||
expect(moon.position.azimuth, greaterThanOrEqualTo(0));
|
||||
expect(moon.position.azimuth, lessThan(360));
|
||||
expect(moon.position.altitude, greaterThanOrEqualTo(-90));
|
||||
expect(moon.position.altitude, lessThanOrEqualTo(90));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Input validation ──────────────────────────────────────────────────
|
||||
|
||||
group('Input validation', () {
|
||||
test('getMoonPosition rejects latitude out of range', () {
|
||||
expect(() => getMoonPosition(DateTime.now(), 91, 0), throwsRangeError);
|
||||
});
|
||||
|
||||
test('getMoonPosition rejects longitude out of range', () {
|
||||
expect(() => getMoonPosition(DateTime.now(), 0, 181), throwsRangeError);
|
||||
});
|
||||
|
||||
test('getMoonPosition rejects NaN latitude', () {
|
||||
expect(
|
||||
() => getMoonPosition(DateTime.now(), double.nan, 0),
|
||||
throwsRangeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('getMoonVisibilityEstimate rejects invalid coordinates', () {
|
||||
expect(
|
||||
() => getMoonVisibilityEstimate(DateTime.now(), -91, 0),
|
||||
throwsRangeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('getMoon rejects invalid coordinates', () {
|
||||
expect(() => getMoon(DateTime.now(), 0, 200), throwsRangeError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── arcvMinimum polynomial ─────────────────────────────────────────────
|
||||
|
||||
group('arcvMinimum polynomial', () {
|
||||
test('arcvMinimum(0) = 11.8371', () {
|
||||
expect(arcvMinimum(0), closeTo(11.8371, 0.0001));
|
||||
});
|
||||
|
||||
test('arcvMinimum decreases with moderate W', () {
|
||||
expect(arcvMinimum(1), lessThan(arcvMinimum(0)));
|
||||
});
|
||||
});
|
||||
|
||||
// ── nearestNewMoon accuracy ────────────────────────────────────────────
|
||||
|
||||
group('nearestNewMoon accuracy', () {
|
||||
test('2024-04-08 solar eclipse near new moon', () {
|
||||
// Known new moon: 2024-04-08 ~18:21 UTC
|
||||
final knownNewMoon = DateTime.utc(2024, 4, 8, 18, 21);
|
||||
final phase = getMoonPhase(knownNewMoon);
|
||||
// Near new moon: illumination should be very low
|
||||
expect(phase.illumination, lessThan(1.0));
|
||||
expect(phase.elongationDeg, lessThan(5));
|
||||
});
|
||||
});
|
||||
|
||||
// ── distanceKm range ──────────────────────────────────────────────────
|
||||
|
||||
group('distanceKm', () {
|
||||
test('distance stays within lunar orbit bounds over a month', () {
|
||||
for (var day = 1; day <= 30; day++) {
|
||||
final pos = getMoonPosition(DateTime.utc(2025, 3, day, 12), 0, 0);
|
||||
expect(pos.distance, greaterThan(355000));
|
||||
expect(pos.distance, lessThan(410000));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue