@openwaters/almanac 0.0.0-bootstrap
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +75 -0
- package/dist/eclipse.d.ts +66 -0
- package/dist/eclipse.js +295 -0
- package/dist/events.d.ts +81 -0
- package/dist/events.js +492 -0
- package/dist/illumination.d.ts +32 -0
- package/dist/illumination.js +88 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/moon.d.ts +38 -0
- package/dist/moon.js +310 -0
- package/dist/nutation.d.ts +65 -0
- package/dist/nutation.js +255 -0
- package/dist/positions.d.ts +34 -0
- package/dist/positions.js +52 -0
- package/dist/sun.d.ts +27 -0
- package/dist/sun.js +184 -0
- package/dist/time.d.ts +28 -0
- package/dist/time.js +144 -0
- package/dist/transforms.d.ts +61 -0
- package/dist/transforms.js +198 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.js +31 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Open Waters
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Almanac contains algorithms translated from Astronomy Engine
|
|
2
|
+
(https://github.com/cosinekitty/astronomy), copyright (c) 2019+ Don Cross,
|
|
3
|
+
released under the MIT License.
|
|
4
|
+
|
|
5
|
+
Astronomy Engine's lunar theory derives from "Astronomy on the Personal
|
|
6
|
+
Computer" by Oliver Montenbruck and Thomas Pfleger; its solar theory from
|
|
7
|
+
the VSOP87 model by Bretagnon and Francou.
|
|
8
|
+
|
|
9
|
+
Translation is pinned to Astronomy Engine commit
|
|
10
|
+
865d3da7d8112bbc7911238052c6af4aaf877181. The complete upstream license:
|
|
11
|
+
|
|
12
|
+
MIT License
|
|
13
|
+
|
|
14
|
+
Copyright (c) 2019-2025 Don Cross <cosinekitty@gmail.com>
|
|
15
|
+
|
|
16
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
17
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
18
|
+
in the Software without restriction, including without limitation the rights
|
|
19
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
20
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
21
|
+
furnished to do so, subject to the following conditions:
|
|
22
|
+
|
|
23
|
+
The above copyright notice and this permission notice shall be included in all
|
|
24
|
+
copies or substantial portions of the Software.
|
|
25
|
+
|
|
26
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
27
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
28
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
29
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
30
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
31
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
32
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Almanac
|
|
2
|
+
|
|
3
|
+
Offline sun & moon engine for the Salish Sea and everywhere else — positions,
|
|
4
|
+
rise/set/twilight, moon phase, and lunar eclipses (solar planned), computed from pure
|
|
5
|
+
geometry with zero network and zero runtime data files.
|
|
6
|
+
|
|
7
|
+
Twin implementations, one behavior:
|
|
8
|
+
|
|
9
|
+
- `swift/` — SwiftPM package `Almanac`
|
|
10
|
+
- `typescript/` — npm `@openwaters/almanac`
|
|
11
|
+
- `fixtures/` — the shared test corpus (JPL Horizons, USNO, Espenak) both suites
|
|
12
|
+
must pass; the contract that keeps the ports identical
|
|
13
|
+
|
|
14
|
+
Supported interval: 1950-01-01T00:00Z ≤ t < 2101-01-01T00:00Z; results outside it
|
|
15
|
+
raise a typed error. All instants are UT1-accurate, not civil-UTC-accurate in the far
|
|
16
|
+
future — see the design spec's Conventions section for what that means and why.
|
|
17
|
+
|
|
18
|
+
Design: [`docs/superpowers/specs/2026-08-28-almanac-v1-design.md`](docs/superpowers/specs/2026-08-28-almanac-v1-design.md)
|
|
19
|
+
|
|
20
|
+
Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
|
21
|
+
(MIT, Don Cross) — see [NOTICE](NOTICE). MIT licensed.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### TypeScript
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install @openwaters/almanac
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { nextLunarEclipse, lunarEclipseVisibility, sunEvents } from '@openwaters/almanac';
|
|
33
|
+
|
|
34
|
+
const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
|
|
35
|
+
|
|
36
|
+
const eclipse = nextLunarEclipse(new Date());
|
|
37
|
+
const visibility = lunarEclipseVisibility(eclipse, observer);
|
|
38
|
+
console.log(eclipse.kind, eclipse.peak, visibility.visibleAtPeak);
|
|
39
|
+
|
|
40
|
+
const today = new Date();
|
|
41
|
+
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
|
|
42
|
+
for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
|
|
43
|
+
console.log(kind, time.toISOString());
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Swift
|
|
48
|
+
|
|
49
|
+
```swift
|
|
50
|
+
.package(url: "https://github.com/openwatersio/almanac.git", exact: "0.1.0")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```swift
|
|
54
|
+
import Almanac
|
|
55
|
+
import Foundation
|
|
56
|
+
|
|
57
|
+
let observer = try Observer(latitudeDeg: 48.5, longitudeDeg: -123.0)
|
|
58
|
+
|
|
59
|
+
let eclipse = try nextLunarEclipse(after: Date())
|
|
60
|
+
let visibility = try lunarEclipseVisibility(eclipse, observer: observer)
|
|
61
|
+
print(eclipse.kind, eclipse.peak, visibility.visibleAtPeak)
|
|
62
|
+
|
|
63
|
+
let today = Date()
|
|
64
|
+
let tomorrow = today.addingTimeInterval(24 * 60 * 60)
|
|
65
|
+
for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
|
|
66
|
+
print(event.kind, event.time)
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Performance
|
|
71
|
+
|
|
72
|
+
Event searches (`sunEvents`/`moonEvents`) are linear in window length. The full
|
|
73
|
+
151-year supported interval is a measured cost, not a guess: ~68 s in TypeScript,
|
|
74
|
+
~44 s in Swift release builds. Chunk or worker a full-range call rather than
|
|
75
|
+
running it inline.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Observer } from './types.js';
|
|
2
|
+
/** A lunar eclipse: peak circumstances plus the contact instants around them. */
|
|
3
|
+
export interface LunarEclipse {
|
|
4
|
+
/** `total` if the Moon is fully inside the umbra, `partial` if it touches
|
|
5
|
+
* the umbra at all, otherwise `penumbral`. */
|
|
6
|
+
kind: 'penumbral' | 'partial' | 'total';
|
|
7
|
+
/** Greatest eclipse — when the Moon's centre passes closest to the shadow axis. */
|
|
8
|
+
peak: Date;
|
|
9
|
+
/** Fraction of the Moon's diameter inside the umbra at peak; negative when the umbra is missed entirely. */
|
|
10
|
+
magUmbral: number;
|
|
11
|
+
/** Fraction of the Moon's diameter inside the penumbra at peak. */
|
|
12
|
+
magPenumbral: number;
|
|
13
|
+
/** First penumbral contact. */
|
|
14
|
+
p1: Date;
|
|
15
|
+
/** First umbral contact — `null` for a penumbral eclipse. */
|
|
16
|
+
u1: Date | null;
|
|
17
|
+
/** Start of totality — `null` unless the eclipse is total. */
|
|
18
|
+
u2: Date | null;
|
|
19
|
+
/** End of totality — `null` unless the eclipse is total. */
|
|
20
|
+
u3: Date | null;
|
|
21
|
+
/** Last umbral contact — `null` for a penumbral eclipse. */
|
|
22
|
+
u4: Date | null;
|
|
23
|
+
/** Last penumbral contact. */
|
|
24
|
+
p4: Date;
|
|
25
|
+
}
|
|
26
|
+
/** Which of an eclipse's contacts happen with the Moon above the observer's horizon. */
|
|
27
|
+
export interface LunarEclipseVisibility {
|
|
28
|
+
/** True when the Moon's centre is geometrically above the horizon at peak. */
|
|
29
|
+
visibleAtPeak: boolean;
|
|
30
|
+
/** Unrefracted topocentric altitude of the Moon's centre at peak, degrees. */
|
|
31
|
+
moonGeometricAltAtPeakDeg: number;
|
|
32
|
+
/** Per-contact horizon test; `null` exactly where the eclipse has no such contact. */
|
|
33
|
+
contactsVisible: {
|
|
34
|
+
p1: boolean;
|
|
35
|
+
u1: boolean | null;
|
|
36
|
+
u2: boolean | null;
|
|
37
|
+
u3: boolean | null;
|
|
38
|
+
u4: boolean | null;
|
|
39
|
+
p4: boolean;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The first lunar eclipse whose peak falls strictly after `after`.
|
|
44
|
+
*
|
|
45
|
+
* Every full moon within the next two years is tested: the Moon's ecliptic
|
|
46
|
+
* latitude prunes the ones no shadow can reach, and the rest get a peak-shadow
|
|
47
|
+
* search. Magnitudes are the fraction of the Moon's diameter inside each
|
|
48
|
+
* shadow at peak, so `magUmbral` goes negative when the umbra is missed and
|
|
49
|
+
* above 1 for a total eclipse.
|
|
50
|
+
*
|
|
51
|
+
* @throws {AlmanacOutOfRangeError} if `after` is outside the supported
|
|
52
|
+
* interval, or if the next eclipse falls at or past the end of it.
|
|
53
|
+
*/
|
|
54
|
+
export declare function nextLunarEclipse(after: Date): LunarEclipse;
|
|
55
|
+
/**
|
|
56
|
+
* Whether an observer can see each phase of a lunar eclipse — purely geometric:
|
|
57
|
+
* the Moon's unrefracted topocentric centre must be above the horizon. A lunar
|
|
58
|
+
* eclipse looks the same from everywhere it is visible at all, so there is
|
|
59
|
+
* nothing else to compute.
|
|
60
|
+
*
|
|
61
|
+
* `contactsVisible` is `null` exactly where `eclipse` has no such contact, so
|
|
62
|
+
* its shape mirrors the eclipse's.
|
|
63
|
+
*
|
|
64
|
+
* @throws {RangeError} if `eclipse` is structurally invalid or `observer` is out of range.
|
|
65
|
+
*/
|
|
66
|
+
export declare function lunarEclipseVisibility(eclipse: LunarEclipse, observer: Observer): LunarEclipseVisibility;
|
package/dist/eclipse.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// L3 lunar eclipses: the Earth's shadow cone against the Moon at every full
|
|
2
|
+
// moon — peak time, contact times, magnitudes, and whether an observer can see
|
|
3
|
+
// any of it.
|
|
4
|
+
//
|
|
5
|
+
// Translated from the cosinekitty/astronomy upstream, pinned sha
|
|
6
|
+
// 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts:
|
|
7
|
+
// CalcShadow (~8458), EarthShadow (~8470), ShadowDistanceSlope (~8535),
|
|
8
|
+
// PeakEarthShadow (~8553), ShadowSemiDurationMinutes (~8603),
|
|
9
|
+
// MoonEclipticLatitudeDegrees (~8616) and SearchLunarEclipse (~8710).
|
|
10
|
+
// Constants and operation order are preserved so the Swift port can be a
|
|
11
|
+
// line-for-line translation.
|
|
12
|
+
//
|
|
13
|
+
// Two deliberate departures from upstream, both spec-driven:
|
|
14
|
+
// - the full-moon probe is this package's `searchMoonPhase`, whose longitudes
|
|
15
|
+
// are apparent rather than upstream's geometric (see events.ts). It only
|
|
16
|
+
// seeds a ±0.03 d peak search, so the ~40 s difference cannot change a
|
|
17
|
+
// result.
|
|
18
|
+
// - upstream reports semi-durations in minutes; the spec's public shape is
|
|
19
|
+
// the contact instants themselves, and `obscuration` is not in it.
|
|
20
|
+
import { assertObserver, assertSupported, AlmanacOutOfRangeError, SUPPORTED_MAX } from './types.js';
|
|
21
|
+
import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
|
|
22
|
+
import { KM_PER_AU, RAD2DEG } from './nutation.js';
|
|
23
|
+
import { calcMoon, moonGeoVectorEqj } from './moon.js';
|
|
24
|
+
import { sunGeoVectorEqj } from './sun.js';
|
|
25
|
+
import { topoAltAzUnrefracted } from './transforms.js';
|
|
26
|
+
import { MOON_MEAN_RADIUS_KM, SUN_RADIUS_KM, search, searchMoonPhase } from './events.js';
|
|
27
|
+
/** UPSTREAM: `EARTH_MEAN_RADIUS_KM` + `EARTH_ATMOSPHERE_KM`, astronomy.ts 142-144.
|
|
28
|
+
* The 88 km of atmosphere is what makes the umbra match observed eclipse
|
|
29
|
+
* magnitudes; it is not the geometric Earth radius. */
|
|
30
|
+
const EARTH_ECLIPSE_RADIUS_KM = 6371.0 + 88.0;
|
|
31
|
+
/** UPSTREAM: `PruneLatitude`, inside `SearchLunarEclipse` — full-Moon ecliptic
|
|
32
|
+
* latitude above which no eclipse is possible. */
|
|
33
|
+
const PRUNE_LATITUDE_DEG = 1.8;
|
|
34
|
+
/** Spec: the search gives up after two years. The catalog's longest gap between
|
|
35
|
+
* consecutive lunar eclipses over 1950-2100 is under a year. */
|
|
36
|
+
const SCAN_LIMIT_DAYS = 730;
|
|
37
|
+
/** Upstream's `PeakEarthShadow` window, in days, either side of the full moon. */
|
|
38
|
+
const PEAK_WINDOW_DAYS = 0.03;
|
|
39
|
+
/** Upstream's outermost `ShadowSemiDurationMinutes` window, in minutes. */
|
|
40
|
+
const PENUMBRAL_WINDOW_MINUTES = 200.0;
|
|
41
|
+
const MINUTES_PER_DAY = 24.0 * 60.0;
|
|
42
|
+
/** Root-finder tolerance for every shadow search, seconds — upstream's `Search` default. */
|
|
43
|
+
const SHADOW_TOL_SECONDS = 1;
|
|
44
|
+
/** UPSTREAM: `Search`'s default `iter_limit`. */
|
|
45
|
+
const SHADOW_ITER_CAP = 20;
|
|
46
|
+
/**
|
|
47
|
+
* How close a candidate peak may sit to `after` and still be judged the same
|
|
48
|
+
* eclipse, milliseconds.
|
|
49
|
+
*
|
|
50
|
+
* A peak is only reproducible to about a millisecond: the full-moon seed comes
|
|
51
|
+
* from a root finder with a 0.1 s tolerance, and the ±0.03 d peak-shadow window
|
|
52
|
+
* built around it therefore differs slightly between two calls that reach the
|
|
53
|
+
* same eclipse from different starting times, which moves the interpolated root
|
|
54
|
+
* by an observed ≤1 ms. A caller walking the catalog by feeding each peak back
|
|
55
|
+
* in would otherwise re-find that eclipse whenever the second evaluation landed
|
|
56
|
+
* a millisecond later.
|
|
57
|
+
*
|
|
58
|
+
* The band cuts both ways, and both are bounded:
|
|
59
|
+
* - it can never swallow a real eclipse — consecutive lunar eclipses are
|
|
60
|
+
* never less than a month apart, 25 million times this window;
|
|
61
|
+
* - it *can* skip an eclipse the caller genuinely wanted: `after` set inside
|
|
62
|
+
* the 100 ms before a peak returns the eclipse after it, not that one.
|
|
63
|
+
* That is the accepted cost, and 100 ms is its ceiling — against a peak
|
|
64
|
+
* model that agrees with the Espenak catalog to ~15 s, a caller cannot have
|
|
65
|
+
* meant a boundary that sharp.
|
|
66
|
+
*/
|
|
67
|
+
const SAME_ECLIPSE_MS = 100;
|
|
68
|
+
/** UPSTREAM: `CalcShadow`, astronomy.ts ~8458. */
|
|
69
|
+
function calcShadow(bodyRadiusKm, ut, target, dir) {
|
|
70
|
+
const u = (dir.x * target.x + dir.y * target.y + dir.z * target.z) / (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
|
|
71
|
+
const dx = (u * dir.x) - target.x;
|
|
72
|
+
const dy = (u * dir.y) - target.y;
|
|
73
|
+
const dz = (u * dir.z) - target.z;
|
|
74
|
+
const r = KM_PER_AU * Math.hypot(dx, dy, dz);
|
|
75
|
+
const k = +SUN_RADIUS_KM - (1.0 + u) * (SUN_RADIUS_KM - bodyRadiusKm);
|
|
76
|
+
const p = -SUN_RADIUS_KM + (1.0 + u) * (SUN_RADIUS_KM + bodyRadiusKm);
|
|
77
|
+
return { ut, u, r, k, p };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* UPSTREAM: `EarthShadow`, astronomy.ts ~8470 — the Earth's shadow cone
|
|
81
|
+
* evaluated where the Moon is. `e = -s` is the path of sunlight through the
|
|
82
|
+
* centre of the Earth; both vectors are EQJ, and `CalcShadow` only ever takes
|
|
83
|
+
* dot products and a norm of them, so the frame cancels.
|
|
84
|
+
*/
|
|
85
|
+
function earthShadow(ut) {
|
|
86
|
+
const tt = ttDaysFromUt(ut);
|
|
87
|
+
// Light-travel and aberration corrected vector from the Earth to the Sun.
|
|
88
|
+
const s = sunGeoVectorEqj(tt);
|
|
89
|
+
const e = { x: -s.x, y: -s.y, z: -s.z };
|
|
90
|
+
// Geocentric moon.
|
|
91
|
+
const m = moonGeoVectorEqj(tt);
|
|
92
|
+
return calcShadow(EARTH_ECLIPSE_RADIUS_KM, ut, m, e);
|
|
93
|
+
}
|
|
94
|
+
/** UPSTREAM: `ShadowDistanceSlope`, astronomy.ts ~8535, bound to `EarthShadow`. */
|
|
95
|
+
function earthShadowSlope(ut) {
|
|
96
|
+
const dt = 1.0 / 86400.0;
|
|
97
|
+
return (earthShadow(ut + dt).r - earthShadow(ut - dt).r) / dt;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* UPSTREAM: `PeakEarthShadow`, astronomy.ts ~8553 — greatest eclipse is where
|
|
101
|
+
* the Moon-to-axis distance stops shrinking, i.e. the ascending zero of its
|
|
102
|
+
* time derivative.
|
|
103
|
+
*/
|
|
104
|
+
function peakEarthShadow(centerUt) {
|
|
105
|
+
const ut = search(earthShadowSlope, centerUt - PEAK_WINDOW_DAYS, centerUt + PEAK_WINDOW_DAYS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'peak earth shadow');
|
|
106
|
+
if (ut === null)
|
|
107
|
+
throw new Error('almanac internal: failed to find peak Earth shadow time');
|
|
108
|
+
return earthShadow(ut);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* UPSTREAM: `ShadowSemiDurationMinutes`, astronomy.ts ~8603 — searches
|
|
112
|
+
* backwards and forwards from the peak for the crossings of `radiusLimitKm`,
|
|
113
|
+
* then averages the two halves, so the contacts this package reports are
|
|
114
|
+
* symmetric about the peak by construction (as upstream's are).
|
|
115
|
+
*/
|
|
116
|
+
function shadowSemiDurationMinutes(centerUt, radiusLimitKm, windowMinutes) {
|
|
117
|
+
const window = windowMinutes / MINUTES_PER_DAY;
|
|
118
|
+
const t1 = search(ut => -(earthShadow(ut).r - radiusLimitKm), centerUt - window, centerUt, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'shadow semiduration (before)');
|
|
119
|
+
const t2 = search(ut => +(earthShadow(ut).r - radiusLimitKm), centerUt, centerUt + window, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'shadow semiduration (after)');
|
|
120
|
+
if (t1 === null || t2 === null)
|
|
121
|
+
throw new Error('almanac internal: failed to find shadow semiduration');
|
|
122
|
+
return (t2 - t1) * (MINUTES_PER_DAY / 2.0); // convert days to minutes and average the semi-durations
|
|
123
|
+
}
|
|
124
|
+
/** UPSTREAM: `MoonEclipticLatitudeDegrees`, astronomy.ts ~8616. */
|
|
125
|
+
function moonEclipticLatitudeDeg(ut) {
|
|
126
|
+
return RAD2DEG * calcMoon(ttDaysFromUt(ut)).geoEclipLat;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The first lunar eclipse whose peak falls strictly after `after`.
|
|
130
|
+
*
|
|
131
|
+
* Every full moon within the next two years is tested: the Moon's ecliptic
|
|
132
|
+
* latitude prunes the ones no shadow can reach, and the rest get a peak-shadow
|
|
133
|
+
* search. Magnitudes are the fraction of the Moon's diameter inside each
|
|
134
|
+
* shadow at peak, so `magUmbral` goes negative when the umbra is missed and
|
|
135
|
+
* above 1 for a total eclipse.
|
|
136
|
+
*
|
|
137
|
+
* @throws {AlmanacOutOfRangeError} if `after` is outside the supported
|
|
138
|
+
* interval, or if the next eclipse falls at or past the end of it.
|
|
139
|
+
*/
|
|
140
|
+
export function nextLunarEclipse(after) {
|
|
141
|
+
assertSupported(after);
|
|
142
|
+
const startUt = utDays(after);
|
|
143
|
+
let fmUt = startUt;
|
|
144
|
+
while (fmUt <= startUt + SCAN_LIMIT_DAYS) {
|
|
145
|
+
// Search for the next full moon. Any eclipse will be near it.
|
|
146
|
+
const fullmoon = searchMoonPhase(180, fmUt, 40);
|
|
147
|
+
if (fullmoon === null)
|
|
148
|
+
throw new Error('almanac internal: cannot find full moon');
|
|
149
|
+
// UPSTREAM `SearchLunarEclipse`: step past this full moon before the
|
|
150
|
+
// next probe, so the same one cannot be found twice.
|
|
151
|
+
fmUt = fullmoon + 10;
|
|
152
|
+
// Pruning: if the full Moon's ecliptic latitude is too large, a lunar
|
|
153
|
+
// eclipse is not possible. Avoid needless work searching for the
|
|
154
|
+
// minimum moon distance.
|
|
155
|
+
if (Math.abs(moonEclipticLatitudeDeg(fullmoon)) >= PRUNE_LATITUDE_DEG)
|
|
156
|
+
continue;
|
|
157
|
+
// Search near the full moon for the time when the center of the Moon
|
|
158
|
+
// is closest to the line passing through the centers of the Sun and Earth.
|
|
159
|
+
const shadow = peakEarthShadow(fullmoon);
|
|
160
|
+
if (shadow.r >= shadow.p + MOON_MEAN_RADIUS_KM)
|
|
161
|
+
continue; // not even penumbral
|
|
162
|
+
// A full moon before `after` can still carry the eclipse the caller
|
|
163
|
+
// already has; the spec's contract is strictly-later peaks.
|
|
164
|
+
const peak = dateFromUt(shadow.ut);
|
|
165
|
+
if (peak.getTime() <= after.getTime() + SAME_ECLIPSE_MS)
|
|
166
|
+
continue;
|
|
167
|
+
if (peak.getTime() >= SUPPORTED_MAX)
|
|
168
|
+
throw new AlmanacOutOfRangeError();
|
|
169
|
+
return buildEclipse(shadow, peak);
|
|
170
|
+
}
|
|
171
|
+
// Not an AlmanacOutOfRangeError: the interval is fine, the sky is not. The
|
|
172
|
+
// longest real gap over 1950-2100 is 178 days, so exhausting 730 means the
|
|
173
|
+
// shadow model is broken — an internal invariant, like every other
|
|
174
|
+
// `almanac internal:` throw in this package.
|
|
175
|
+
throw new Error(`almanac internal: no lunar eclipse within ${SCAN_LIMIT_DAYS} days of ${after.toISOString()}`);
|
|
176
|
+
}
|
|
177
|
+
/** UPSTREAM: the classification and semi-duration block of `SearchLunarEclipse`, astronomy.ts ~8730-8755. */
|
|
178
|
+
function buildEclipse(shadow, peak) {
|
|
179
|
+
// This is at least a penumbral eclipse.
|
|
180
|
+
let kind = 'penumbral';
|
|
181
|
+
let sdTotal = 0.0;
|
|
182
|
+
let sdPartial = 0.0;
|
|
183
|
+
const sdPenum = shadowSemiDurationMinutes(shadow.ut, shadow.p + MOON_MEAN_RADIUS_KM, PENUMBRAL_WINDOW_MINUTES);
|
|
184
|
+
if (shadow.r < shadow.k + MOON_MEAN_RADIUS_KM) {
|
|
185
|
+
// This is at least a partial eclipse.
|
|
186
|
+
kind = 'partial';
|
|
187
|
+
sdPartial = shadowSemiDurationMinutes(shadow.ut, shadow.k + MOON_MEAN_RADIUS_KM, sdPenum);
|
|
188
|
+
if (shadow.r + MOON_MEAN_RADIUS_KM < shadow.k) {
|
|
189
|
+
// This is a total eclipse.
|
|
190
|
+
kind = 'total';
|
|
191
|
+
sdTotal = shadowSemiDurationMinutes(shadow.ut, shadow.k - MOON_MEAN_RADIUS_KM, sdPartial);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Fraction of the Moon's diameter immersed in each shadow. Shadow radii,
|
|
195
|
+
// axis distance and the Moon's radius all live in the same plane at the
|
|
196
|
+
// Moon's distance, so the angles at that distance are these lengths over a
|
|
197
|
+
// common divisor — it cancels, and the ratio is exact in km.
|
|
198
|
+
const s = MOON_MEAN_RADIUS_KM;
|
|
199
|
+
const magPenumbral = (shadow.p + s - shadow.r) / (2 * s);
|
|
200
|
+
const magUmbral = (shadow.k + s - shadow.r) / (2 * s);
|
|
201
|
+
const at = (offsetMinutes) => dateFromUt(shadow.ut + offsetMinutes / MINUTES_PER_DAY);
|
|
202
|
+
const umbral = kind !== 'penumbral';
|
|
203
|
+
const total = kind === 'total';
|
|
204
|
+
return {
|
|
205
|
+
kind,
|
|
206
|
+
peak,
|
|
207
|
+
magUmbral,
|
|
208
|
+
magPenumbral,
|
|
209
|
+
p1: at(-sdPenum),
|
|
210
|
+
u1: umbral ? at(-sdPartial) : null,
|
|
211
|
+
u2: total ? at(-sdTotal) : null,
|
|
212
|
+
u3: total ? at(+sdTotal) : null,
|
|
213
|
+
u4: umbral ? at(+sdPartial) : null,
|
|
214
|
+
p4: at(+sdPenum)
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Trust boundary: `lunarEclipseVisibility` takes a plain object, which callers
|
|
219
|
+
* can hand-build or round-trip through JSON. A contact out of order or missing
|
|
220
|
+
* for its kind would otherwise produce a silently wrong visibility answer, so
|
|
221
|
+
* the shape is checked before any of it is used.
|
|
222
|
+
*/
|
|
223
|
+
function assertLunarEclipse(e) {
|
|
224
|
+
if (e === null || typeof e !== 'object')
|
|
225
|
+
throw new RangeError('eclipse must be an object');
|
|
226
|
+
if (e.kind !== 'penumbral' && e.kind !== 'partial' && e.kind !== 'total')
|
|
227
|
+
throw new RangeError(`unknown lunar eclipse kind: ${String(e.kind)}`);
|
|
228
|
+
const umbral = e.kind !== 'penumbral';
|
|
229
|
+
const total = e.kind === 'total';
|
|
230
|
+
const expected = { u1: umbral, u2: total, u3: total, u4: umbral };
|
|
231
|
+
for (const key of ['u1', 'u2', 'u3', 'u4']) {
|
|
232
|
+
const present = e[key] !== null && e[key] !== undefined;
|
|
233
|
+
if (present !== expected[key])
|
|
234
|
+
throw new RangeError(`${e.kind} lunar eclipse must ${expected[key] ? '' : 'not '}have ${key}`);
|
|
235
|
+
}
|
|
236
|
+
// Strictly ascending, skipping the absent ones. Strict is safe: the closest
|
|
237
|
+
// two contacts of any eclipse over 1950-2100 are five minutes apart. The
|
|
238
|
+
// third element marks the contacts every eclipse has, whatever its kind —
|
|
239
|
+
// absent, they would otherwise slip through as `null` past a `boolean` type.
|
|
240
|
+
const ordered = [
|
|
241
|
+
['p1', e.p1, true], ['u1', e.u1, false], ['u2', e.u2, false], ['peak', e.peak, true],
|
|
242
|
+
['u3', e.u3, false], ['u4', e.u4, false], ['p4', e.p4, true]
|
|
243
|
+
];
|
|
244
|
+
let prevName = '';
|
|
245
|
+
let prevMs = -Infinity;
|
|
246
|
+
for (const [name, d, required] of ordered) {
|
|
247
|
+
if (d == null) {
|
|
248
|
+
if (required)
|
|
249
|
+
throw new RangeError(`lunar eclipse ${name} is required`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const ms = d instanceof Date ? d.getTime() : NaN;
|
|
253
|
+
if (!Number.isFinite(ms))
|
|
254
|
+
throw new RangeError(`lunar eclipse ${name} is not a valid Date`);
|
|
255
|
+
if (ms <= prevMs)
|
|
256
|
+
throw new RangeError(`lunar eclipse contacts out of order: ${prevName} not before ${name}`);
|
|
257
|
+
prevName = name;
|
|
258
|
+
prevMs = ms;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Whether an observer can see each phase of a lunar eclipse — purely geometric:
|
|
263
|
+
* the Moon's unrefracted topocentric centre must be above the horizon. A lunar
|
|
264
|
+
* eclipse looks the same from everywhere it is visible at all, so there is
|
|
265
|
+
* nothing else to compute.
|
|
266
|
+
*
|
|
267
|
+
* `contactsVisible` is `null` exactly where `eclipse` has no such contact, so
|
|
268
|
+
* its shape mirrors the eclipse's.
|
|
269
|
+
*
|
|
270
|
+
* @throws {RangeError} if `eclipse` is structurally invalid or `observer` is out of range.
|
|
271
|
+
*/
|
|
272
|
+
export function lunarEclipseVisibility(eclipse, observer) {
|
|
273
|
+
assertLunarEclipse(eclipse);
|
|
274
|
+
assertObserver(observer);
|
|
275
|
+
const altAt = (d) => {
|
|
276
|
+
const ut = utDays(d);
|
|
277
|
+
return topoAltAzUnrefracted(moonGeoVectorEqj(ttDaysFromUt(ut)), ut, observer).altDeg;
|
|
278
|
+
};
|
|
279
|
+
// `== null` deliberately: the validator counts `undefined` as absent too,
|
|
280
|
+
// so the reader must, or an `undefined` contact would reach `utDays` raw.
|
|
281
|
+
const up = (d) => (d == null ? null : altAt(d) > 0);
|
|
282
|
+
const peakAltDeg = altAt(eclipse.peak);
|
|
283
|
+
return {
|
|
284
|
+
visibleAtPeak: peakAltDeg > 0,
|
|
285
|
+
moonGeometricAltAtPeakDeg: peakAltDeg,
|
|
286
|
+
contactsVisible: {
|
|
287
|
+
p1: up(eclipse.p1),
|
|
288
|
+
u1: up(eclipse.u1),
|
|
289
|
+
u2: up(eclipse.u2),
|
|
290
|
+
u3: up(eclipse.u3),
|
|
291
|
+
u4: up(eclipse.u4),
|
|
292
|
+
p4: up(eclipse.p4)
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Observer } from './types.js';
|
|
2
|
+
/** A Sun event: a horizon or twilight crossing, or upper transit. */
|
|
3
|
+
export type SunEventKind = 'rise' | 'set' | 'civilDawn' | 'civilDusk' | 'nauticalDawn' | 'nauticalDusk' | 'astroDawn' | 'astroDusk' | 'transit';
|
|
4
|
+
export interface SunEvent {
|
|
5
|
+
time: Date;
|
|
6
|
+
kind: SunEventKind;
|
|
7
|
+
}
|
|
8
|
+
/** A Moon event: the upper limb crossing the horizon. */
|
|
9
|
+
export type MoonEventKind = 'rise' | 'set';
|
|
10
|
+
export interface MoonEvent {
|
|
11
|
+
time: Date;
|
|
12
|
+
kind: MoonEventKind;
|
|
13
|
+
}
|
|
14
|
+
/** A quarter lunar phase. */
|
|
15
|
+
export type MoonPhaseName = 'new' | 'firstQuarter' | 'full' | 'lastQuarter';
|
|
16
|
+
export interface MoonPhaseEvent {
|
|
17
|
+
time: Date;
|
|
18
|
+
phase: MoonPhaseName;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Body radii, for the semidiameter that lowers each rise/set target — and, in
|
|
22
|
+
* the L3 eclipse layer, for the Earth's shadow cone and the Moon it falls on.
|
|
23
|
+
* INTERNAL: exported for `eclipse.ts`, not part of the public API.
|
|
24
|
+
*/
|
|
25
|
+
export declare const MOON_MEAN_RADIUS_KM = 1737.4;
|
|
26
|
+
/** UPSTREAM: `SUN_RADIUS_KM`, astronomy.ts line 134. INTERNAL, see above. */
|
|
27
|
+
export declare const SUN_RADIUS_KM = 695700;
|
|
28
|
+
/**
|
|
29
|
+
* Truncates a ut-days root to the same integer-ms precision `dateFromUt`
|
|
30
|
+
* reports it at (TimeClip). A root finder's raw `ut` carries sub-ms
|
|
31
|
+
* precision; comparing that raw value against a half-open window bound can
|
|
32
|
+
* pass while the truncated instant it is actually reported as lands exactly
|
|
33
|
+
* on the excluded end (a pre-1970 negative offset truncates UP, toward the
|
|
34
|
+
* boundary). The half-open filter must judge the reported instant, not the
|
|
35
|
+
* root. Exported for its direct micro-test, mirroring the Swift port's
|
|
36
|
+
* (which relaxes the same function to `internal` for the same reason);
|
|
37
|
+
* no other module consumes it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function quantizedUt(ut: number): number;
|
|
40
|
+
/**
|
|
41
|
+
* Sun rise, set, the three twilights and upper transit within the half-open
|
|
42
|
+
* window `[startUtc, endUtc)`, sorted ascending.
|
|
43
|
+
*
|
|
44
|
+
* Rise and set are the unrefracted geometric centre altitude at −(34′ + the
|
|
45
|
+
* Sun's true semidiameter at distance) — the upper-limb convention with the
|
|
46
|
+
* actual disc, the same rule the Moon gets; the twilights are centre altitude
|
|
47
|
+
* −6°, −12° and −18° with no refraction term; transit is local hour angle
|
|
48
|
+
* zero. An empty list is a valid answer — polar day and polar night drop
|
|
49
|
+
* the crossings — while transit is reported regardless of whether the Sun is
|
|
50
|
+
* above the horizon when it happens.
|
|
51
|
+
*/
|
|
52
|
+
export declare function sunEvents(startUtc: Date, endUtc: Date, observer: Observer): SunEvent[];
|
|
53
|
+
/**
|
|
54
|
+
* Moonrise and moonset within the half-open window `[startUtc, endUtc)`,
|
|
55
|
+
* sorted ascending — the apparent topocentric upper limb crossing the horizon,
|
|
56
|
+
* so refraction (34′), topocentric parallax and the true semidiameter at the
|
|
57
|
+
* Moon's distance are all included — the same upper-limb rule as the Sun's. An empty list is a valid answer.
|
|
58
|
+
*/
|
|
59
|
+
export declare function moonEvents(startUtc: Date, endUtc: Date, observer: Observer): MoonEvent[];
|
|
60
|
+
/**
|
|
61
|
+
* UPSTREAM: `Search`, astronomy.ts ~4634 — bisection with quadratic
|
|
62
|
+
* acceleration for the ascending zero crossing of `f` in `[t1, t2]`. Times are
|
|
63
|
+
* days since J2000 UT rather than upstream's `AstroTime`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function search(f: (ut: number) => number, t1: number, t2: number, dtToleranceSeconds: number, iterLimit?: number, what?: string): number | null;
|
|
66
|
+
/**
|
|
67
|
+
* UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 — forward search only, which
|
|
68
|
+
* is all the quarter walk needs. The phase repeats every synodic month, so the
|
|
69
|
+
* time of the next occurrence is predicted from the current offset and then
|
|
70
|
+
* bracketed ±1.5 days: the Moon's eccentricity has been seen to move a quarter
|
|
71
|
+
* more than 0.9 days off the simple prediction.
|
|
72
|
+
*/
|
|
73
|
+
export declare function searchMoonPhase(targetLonDeg: number, startUt: number, limitDays: number): number | null;
|
|
74
|
+
/**
|
|
75
|
+
* The four quarter moon phases within the half-open window
|
|
76
|
+
* `[startUtc, endUtc)`, sorted ascending.
|
|
77
|
+
*
|
|
78
|
+
* A phase is the instant the Moon's geocentric ecliptic longitude leads the
|
|
79
|
+
* Sun's by 0° (new), 90° (first quarter), 180° (full) or 270° (last quarter).
|
|
80
|
+
*/
|
|
81
|
+
export declare function searchMoonPhases(startUtc: Date, endUtc: Date): MoonPhaseEvent[];
|