@openwaters/almanac 0.1.0 → 0.2.0
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/README.md +28 -2
- package/dist/eclipse.d.ts +12 -0
- package/dist/eclipse.js +68 -39
- package/dist/events.d.ts +2 -2
- package/dist/events.js +24 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -15,7 +15,9 @@ Supported interval: 1950-01-01T00:00Z ≤ t < 2101-01-01T00:00Z; results outside
|
|
|
15
15
|
raise a typed error. All instants are UT1-accurate, not civil-UTC-accurate in the far
|
|
16
16
|
future — see the design spec's Conventions section for what that means and why.
|
|
17
17
|
|
|
18
|
-
Design: [`docs/superpowers/specs/2026-08-28-almanac-v1-design.md`](docs/superpowers/specs/2026-08-28-almanac-v1-design.md)
|
|
18
|
+
- Design: [`docs/superpowers/specs/2026-08-28-almanac-v1-design.md`](docs/superpowers/specs/2026-08-28-almanac-v1-design.md)
|
|
19
|
+
- Roadmap: [`docs/ROADMAP.md`](docs/ROADMAP.md) — what ships next, and what will not.
|
|
20
|
+
- Landing page: [openwaters.io/sky](https://openwaters.io/sky) — the library running live in a browser.
|
|
19
21
|
|
|
20
22
|
Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
|
21
23
|
(MIT, Don Cross) — see [NOTICE](NOTICE). MIT licensed.
|
|
@@ -47,7 +49,7 @@ for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
|
|
|
47
49
|
### Swift
|
|
48
50
|
|
|
49
51
|
```swift
|
|
50
|
-
.package(url: "https://github.com/openwatersio/almanac.git", exact: "0.
|
|
52
|
+
.package(url: "https://github.com/openwatersio/almanac.git", exact: "0.2.0")
|
|
51
53
|
```
|
|
52
54
|
|
|
53
55
|
```swift
|
|
@@ -67,9 +69,33 @@ for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
|
|
|
67
69
|
}
|
|
68
70
|
```
|
|
69
71
|
|
|
72
|
+
### Eclipse searches
|
|
73
|
+
|
|
74
|
+
Search for a previous eclipse or all eclipses in a time window:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { previousLunarEclipse, lunarEclipses } from '@openwaters/almanac';
|
|
78
|
+
const last = previousLunarEclipse(new Date());
|
|
79
|
+
const eclipses = lunarEclipses(new Date('2026-08-24T00:00:00Z'), new Date('2026-09-02T12:00:00Z'));
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```swift
|
|
83
|
+
let last = try previousLunarEclipse(before: Date())
|
|
84
|
+
let eclipses = try lunarEclipses(from: today, to: tomorrow)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Ranges include peaks at the start and exclude peaks at the end. Contacts may
|
|
88
|
+
extend outside the range. Previous/next searches skip peaks within 100 ms of the
|
|
89
|
+
anchor. Search results are global; apply `lunarEclipseVisibility` for an observer.
|
|
90
|
+
|
|
70
91
|
### Performance
|
|
71
92
|
|
|
72
93
|
Event searches (`sunEvents`/`moonEvents`) are linear in window length. The full
|
|
73
94
|
151-year supported interval is a measured cost, not a guess: ~68 s in TypeScript,
|
|
74
95
|
~44 s in Swift release builds. Chunk or worker a full-range call rather than
|
|
75
96
|
running it inline.
|
|
97
|
+
|
|
98
|
+
Run `node benchmarks/run.mjs --base origin/main` from the repository root to
|
|
99
|
+
compare both ports against a baseline. CI reports timings and fails on median
|
|
100
|
+
regressions over 20%; see [the performance harness](CONTRIBUTING.md#performance)
|
|
101
|
+
for workloads, results and local options.
|
package/dist/eclipse.d.ts
CHANGED
|
@@ -52,6 +52,18 @@ export interface LunarEclipseVisibility {
|
|
|
52
52
|
* interval, or if the next eclipse falls at or past the end of it.
|
|
53
53
|
*/
|
|
54
54
|
export declare function nextLunarEclipse(after: Date): LunarEclipse;
|
|
55
|
+
/**
|
|
56
|
+
* The last lunar eclipse whose peak falls strictly before `before`, skipping
|
|
57
|
+
* peaks within 100 ms of it, just as `nextLunarEclipse` does on the other side.
|
|
58
|
+
*
|
|
59
|
+
* @throws {AlmanacOutOfRangeError} if `before` is outside the supported
|
|
60
|
+
* interval, or if the previous eclipse falls before its beginning.
|
|
61
|
+
*/
|
|
62
|
+
export declare function previousLunarEclipse(before: Date): LunarEclipse;
|
|
63
|
+
/** Lunar eclipses with peaks in `[startUtc, endUtc)`, sorted ascending.
|
|
64
|
+
* Contacts may fall outside the window. Visibility is observer-dependent;
|
|
65
|
+
* use `lunarEclipseVisibility` on each result to check it. */
|
|
66
|
+
export declare function lunarEclipses(startUtc: Date, endUtc: Date): LunarEclipse[];
|
|
55
67
|
/**
|
|
56
68
|
* Whether an observer can see each phase of a lunar eclipse — purely geometric:
|
|
57
69
|
* the Moon's unrefracted topocentric centre must be above the horizon. A lunar
|
package/dist/eclipse.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// result.
|
|
18
18
|
// - upstream reports semi-durations in minutes; the spec's public shape is
|
|
19
19
|
// the contact instants themselves, and `obscuration` is not in it.
|
|
20
|
-
import { assertObserver, assertSupported, AlmanacOutOfRangeError, SUPPORTED_MAX } from './types.js';
|
|
20
|
+
import { assertObserver, assertSupported, assertSupportedWindowEnd, AlmanacOutOfRangeError, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
|
|
21
21
|
import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
|
|
22
22
|
import { KM_PER_AU, RAD2DEG } from './nutation.js';
|
|
23
23
|
import { calcMoon, moonGeoVectorEqj } from './moon.js';
|
|
@@ -44,25 +44,13 @@ const SHADOW_TOL_SECONDS = 1;
|
|
|
44
44
|
/** UPSTREAM: `Search`'s default `iter_limit`. */
|
|
45
45
|
const SHADOW_ITER_CAP = 20;
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
* eclipse
|
|
47
|
+
* Peaks within 100 ms of a next/previous search anchor count as the same
|
|
48
|
+
* eclipse. The fixed full-moon seed below removes within-runtime drift; keep
|
|
49
|
+
* the band for peaks saved by older versions or computed on another runtime.
|
|
49
50
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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.
|
|
51
|
+
* This can skip a wanted peak when the anchor is within 100 ms of it, but
|
|
52
|
+
* cannot skip a distinct eclipse (the catalog's minimum gap is 29 days).
|
|
53
|
+
* Range searches use exact half-open bounds without this band.
|
|
66
54
|
*/
|
|
67
55
|
const SAME_ECLIPSE_MS = 100;
|
|
68
56
|
/** UPSTREAM: `CalcShadow`, astronomy.ts ~8458. */
|
|
@@ -102,7 +90,14 @@ function earthShadowSlope(ut) {
|
|
|
102
90
|
* time derivative.
|
|
103
91
|
*/
|
|
104
92
|
function peakEarthShadow(centerUt) {
|
|
105
|
-
|
|
93
|
+
// Use the same full-moon seed regardless of search direction or window.
|
|
94
|
+
// Otherwise a ~1 ms root shift can lose/duplicate a peak when a caller
|
|
95
|
+
// splits a range at a previously returned peak. Only unpruned moons pay
|
|
96
|
+
// for this second phase search; its start is a fixed whole UT day.
|
|
97
|
+
const fullmoon = searchMoonPhase(180, Math.floor(centerUt) - 1, 4);
|
|
98
|
+
if (fullmoon === null)
|
|
99
|
+
throw new Error('almanac internal: cannot refine full moon');
|
|
100
|
+
const ut = search(earthShadowSlope, fullmoon - PEAK_WINDOW_DAYS, fullmoon + PEAK_WINDOW_DAYS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'peak earth shadow');
|
|
106
101
|
if (ut === null)
|
|
107
102
|
throw new Error('almanac internal: failed to find peak Earth shadow time');
|
|
108
103
|
return earthShadow(ut);
|
|
@@ -138,17 +133,57 @@ function moonEclipticLatitudeDeg(ut) {
|
|
|
138
133
|
* interval, or if the next eclipse falls at or past the end of it.
|
|
139
134
|
*/
|
|
140
135
|
export function nextLunarEclipse(after) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
136
|
+
return nearestLunarEclipse(after, 1);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The last lunar eclipse whose peak falls strictly before `before`, skipping
|
|
140
|
+
* peaks within 100 ms of it, just as `nextLunarEclipse` does on the other side.
|
|
141
|
+
*
|
|
142
|
+
* @throws {AlmanacOutOfRangeError} if `before` is outside the supported
|
|
143
|
+
* interval, or if the previous eclipse falls before its beginning.
|
|
144
|
+
*/
|
|
145
|
+
export function previousLunarEclipse(before) {
|
|
146
|
+
return nearestLunarEclipse(before, -1);
|
|
147
|
+
}
|
|
148
|
+
/** Lunar eclipses with peaks in `[startUtc, endUtc)`, sorted ascending.
|
|
149
|
+
* Contacts may fall outside the window. Visibility is observer-dependent;
|
|
150
|
+
* use `lunarEclipseVisibility` on each result to check it. */
|
|
151
|
+
export function lunarEclipses(startUtc, endUtc) {
|
|
152
|
+
assertSupported(startUtc);
|
|
153
|
+
assertSupportedWindowEnd(endUtc);
|
|
154
|
+
return scanLunarEclipses(startUtc.getTime(), endUtc.getTime(), 1, false);
|
|
155
|
+
}
|
|
156
|
+
function nearestLunarEclipse(anchor, direction) {
|
|
157
|
+
assertSupported(anchor);
|
|
158
|
+
const ms = anchor.getTime();
|
|
159
|
+
const startMs = direction > 0 ? ms + SAME_ECLIPSE_MS + 1 : Math.max(SUPPORTED_MIN, ms - SCAN_LIMIT_DAYS * 86400000);
|
|
160
|
+
const endMs = direction > 0 ? Math.min(SUPPORTED_MAX, ms + SCAN_LIMIT_DAYS * 86400000) : ms - SAME_ECLIPSE_MS;
|
|
161
|
+
const found = scanLunarEclipses(startMs, endMs, direction, true);
|
|
162
|
+
if (found.length)
|
|
163
|
+
return found[0];
|
|
164
|
+
if (direction > 0 ? endMs === SUPPORTED_MAX : startMs === SUPPORTED_MIN)
|
|
165
|
+
throw new AlmanacOutOfRangeError();
|
|
166
|
+
// The longest catalog gap is 178 days: exhausting 730 inside the supported
|
|
167
|
+
// interval is a broken shadow model, not a normal out-of-range result.
|
|
168
|
+
throw new Error(`almanac internal: no lunar eclipse within ${SCAN_LIMIT_DAYS} days of ${anchor.toISOString()}`);
|
|
169
|
+
}
|
|
170
|
+
function scanLunarEclipses(startMs, endMs, direction, firstOnly) {
|
|
171
|
+
const found = [];
|
|
172
|
+
if (startMs >= endMs)
|
|
173
|
+
return found;
|
|
174
|
+
// Peak and full moon differ. Include the entire peak-search margin at
|
|
175
|
+
// both ends, then apply the caller's bounds to the reported peak below.
|
|
176
|
+
const startUt = utDays(new Date(startMs)) - PEAK_WINDOW_DAYS;
|
|
177
|
+
const endUt = utDays(new Date(endMs)) + PEAK_WINDOW_DAYS;
|
|
178
|
+
let fmUt = direction > 0 ? startUt : endUt;
|
|
179
|
+
const limitUt = direction > 0 ? endUt : startUt;
|
|
180
|
+
while (direction * (limitUt - fmUt) > 0) {
|
|
181
|
+
const fullmoon = searchMoonPhase(180, fmUt, direction * Math.min(40, Math.abs(limitUt - fmUt)));
|
|
147
182
|
if (fullmoon === null)
|
|
148
|
-
|
|
183
|
+
break;
|
|
149
184
|
// UPSTREAM `SearchLunarEclipse`: step past this full moon before the
|
|
150
185
|
// next probe, so the same one cannot be found twice.
|
|
151
|
-
fmUt = fullmoon + 10;
|
|
186
|
+
fmUt = fullmoon + direction * 10;
|
|
152
187
|
// Pruning: if the full Moon's ecliptic latitude is too large, a lunar
|
|
153
188
|
// eclipse is not possible. Avoid needless work searching for the
|
|
154
189
|
// minimum moon distance.
|
|
@@ -159,20 +194,14 @@ export function nextLunarEclipse(after) {
|
|
|
159
194
|
const shadow = peakEarthShadow(fullmoon);
|
|
160
195
|
if (shadow.r >= shadow.p + MOON_MEAN_RADIUS_KM)
|
|
161
196
|
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
197
|
const peak = dateFromUt(shadow.ut);
|
|
165
|
-
if (peak.getTime()
|
|
198
|
+
if (peak.getTime() < startMs || peak.getTime() >= endMs)
|
|
166
199
|
continue;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
200
|
+
found.push(buildEclipse(shadow, peak));
|
|
201
|
+
if (firstOnly)
|
|
202
|
+
break;
|
|
170
203
|
}
|
|
171
|
-
|
|
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()}`);
|
|
204
|
+
return found;
|
|
176
205
|
}
|
|
177
206
|
/** UPSTREAM: the classification and semi-duration block of `SearchLunarEclipse`, astronomy.ts ~8730-8755. */
|
|
178
207
|
function buildEclipse(shadow, peak) {
|
package/dist/events.d.ts
CHANGED
|
@@ -64,8 +64,8 @@ export declare function moonEvents(startUtc: Date, endUtc: Date, observer: Obser
|
|
|
64
64
|
*/
|
|
65
65
|
export declare function search(f: (ut: number) => number, t1: number, t2: number, dtToleranceSeconds: number, iterLimit?: number, what?: string): number | null;
|
|
66
66
|
/**
|
|
67
|
-
* UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 —
|
|
68
|
-
*
|
|
67
|
+
* UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 — a negative limit searches
|
|
68
|
+
* backward. The phase repeats every synodic month, so the
|
|
69
69
|
* time of the next occurrence is predicted from the current offset and then
|
|
70
70
|
* bracketed ±1.5 days: the Moon's eccentricity has been seen to move a quarter
|
|
71
71
|
* more than 0.9 days off the simple prediction.
|
package/dist/events.js
CHANGED
|
@@ -390,8 +390,10 @@ export function search(f, t1, t2, dtToleranceSeconds, iterLimit = MOON_PHASE_ITE
|
|
|
390
390
|
assertReached(iter < iterLimit, what);
|
|
391
391
|
const tmid = (t1 + t2) / 2;
|
|
392
392
|
const dt = tmid - t1;
|
|
393
|
+
// A caller may clip the initial bracket to a short range. Small
|
|
394
|
+
// width alone does not prove that it contains an ascending root.
|
|
393
395
|
if (Math.abs(dt) < dtDays)
|
|
394
|
-
return tmid;
|
|
396
|
+
return f1 <= 0 && f2 >= 0 ? tmid : null;
|
|
395
397
|
if (calcFmid)
|
|
396
398
|
fmid = f(tmid);
|
|
397
399
|
else
|
|
@@ -434,8 +436,8 @@ export function search(f, t1, t2, dtToleranceSeconds, iterLimit = MOON_PHASE_ITE
|
|
|
434
436
|
}
|
|
435
437
|
}
|
|
436
438
|
/**
|
|
437
|
-
* UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 —
|
|
438
|
-
*
|
|
439
|
+
* UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 — a negative limit searches
|
|
440
|
+
* backward. The phase repeats every synodic month, so the
|
|
439
441
|
* time of the next occurrence is predicted from the current offset and then
|
|
440
442
|
* bracketed ±1.5 days: the Moon's eccentricity has been seen to move a quarter
|
|
441
443
|
* more than 0.9 days off the simple prediction.
|
|
@@ -444,13 +446,25 @@ export function searchMoonPhase(targetLonDeg, startUt, limitDays) {
|
|
|
444
446
|
const moonOffset = (ut) => angleOffset(moonPhaseDeg(ttDaysFromUt(ut)) - targetLonDeg);
|
|
445
447
|
const uncertainty = 1.5;
|
|
446
448
|
let ya = moonOffset(startUt);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
449
|
+
let dt1, dt2;
|
|
450
|
+
if (limitDays < 0) {
|
|
451
|
+
if (ya < 0)
|
|
452
|
+
ya += 360;
|
|
453
|
+
const estDt = -(MEAN_SYNODIC_MONTH * ya) / 360;
|
|
454
|
+
dt2 = estDt + uncertainty;
|
|
455
|
+
if (dt2 < limitDays)
|
|
456
|
+
return null;
|
|
457
|
+
dt1 = Math.max(limitDays, estDt - uncertainty);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
if (ya > 0)
|
|
461
|
+
ya -= 360;
|
|
462
|
+
const estDt = -(MEAN_SYNODIC_MONTH * ya) / 360;
|
|
463
|
+
dt1 = estDt - uncertainty;
|
|
464
|
+
if (dt1 > limitDays)
|
|
465
|
+
return null;
|
|
466
|
+
dt2 = Math.min(limitDays, estDt + uncertainty);
|
|
467
|
+
}
|
|
454
468
|
return search(moonOffset, startUt + dt1, startUt + dt2, 0.1);
|
|
455
469
|
}
|
|
456
470
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -9,4 +9,4 @@ export { moonIllumination } from './illumination.js';
|
|
|
9
9
|
export type { SunEvent, SunEventKind, MoonEvent, MoonEventKind, MoonPhaseEvent, MoonPhaseName } from './events.js';
|
|
10
10
|
export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
|
|
11
11
|
export type { LunarEclipse, LunarEclipseVisibility } from './eclipse.js';
|
|
12
|
-
export { nextLunarEclipse, lunarEclipseVisibility } from './eclipse.js';
|
|
12
|
+
export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
|
package/dist/index.js
CHANGED
|
@@ -3,4 +3,4 @@ export { sunPosition, moonPosition } from './positions.js';
|
|
|
3
3
|
export { sunAltAz, moonAltAz } from './transforms.js';
|
|
4
4
|
export { moonIllumination } from './illumination.js';
|
|
5
5
|
export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
|
|
6
|
-
export { nextLunarEclipse, lunarEclipseVisibility } from './eclipse.js';
|
|
6
|
+
export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwaters/almanac",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Offline sun & moon engine: positions, rise/set, moon phase, lunar eclipses.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": { "type": "git", "url": "git+https://github.com/openwatersio/almanac.git", "directory": "typescript" },
|
|
7
|
+
"homepage": "https://openwaters.io/sky",
|
|
7
8
|
"type": "module",
|
|
8
9
|
"main": "./dist/index.js",
|
|
9
10
|
"types": "./dist/index.d.ts",
|