@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 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.1.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
- * How close a candidate peak may sit to `after` and still be judged the same
48
- * eclipse, milliseconds.
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
- * 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.
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
- const ut = search(earthShadowSlope, centerUt - PEAK_WINDOW_DAYS, centerUt + PEAK_WINDOW_DAYS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'peak earth shadow');
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
- 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);
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
- throw new Error('almanac internal: cannot find full moon');
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() <= after.getTime() + SAME_ECLIPSE_MS)
198
+ if (peak.getTime() < startMs || peak.getTime() >= endMs)
166
199
  continue;
167
- if (peak.getTime() >= SUPPORTED_MAX)
168
- throw new AlmanacOutOfRangeError();
169
- return buildEclipse(shadow, peak);
200
+ found.push(buildEclipse(shadow, peak));
201
+ if (firstOnly)
202
+ break;
170
203
  }
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()}`);
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 — forward search only, which
68
- * is all the quarter walk needs. The phase repeats every synodic month, so the
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 — forward search only, which
438
- * is all the quarter walk needs. The phase repeats every synodic month, so the
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
- if (ya > 0)
448
- ya -= 360;
449
- const estDt = -(MEAN_SYNODIC_MONTH * ya) / 360;
450
- const dt1 = estDt - uncertainty;
451
- if (dt1 > limitDays)
452
- return null;
453
- const dt2 = Math.min(limitDays, estDt + uncertainty);
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.1.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",