@openwaters/almanac 0.1.0 → 0.2.1

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,11 +15,55 @@ 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.
22
24
 
25
+ ## Performance
26
+
27
+ Almanac includes a shared performance harness for both ports: **15 workloads**
28
+ cover positions, a 228-hour sky track, short/year/polar event windows, full-range
29
+ moon phases, and next/previous/range eclipse searches, including empty windows.
30
+
31
+ The v0.2.1 optimizations reuse Moon and shadow geometry, flatten scratch arrays,
32
+ and refine altitude crossings with fewer position evaluations. In the
33
+ [hosted CI comparison](https://github.com/openwatersio/almanac/actions/runs/34119038860),
34
+ median query time fell relative to the v0.2.0 astronomy code:
35
+
36
+ | Workload | TypeScript: less time | Swift: less time |
37
+ | --- | ---: | ---: |
38
+ | Moon positions / 1,024 hours | 29.8% | 22.1% |
39
+ | Sky track / 228 hours | 40.0% | 42.4% |
40
+ | Sun events / 228 hours | 39.3% | 40.2% |
41
+ | Moon events / 228 hours | 39.1% | 35.1% |
42
+ | Previous lunar eclipse | 26.0% | 36.6% |
43
+ | Lunar eclipses / 1950–2100 | 26.4% | 23.4% |
44
+
45
+ TypeScript was measured on Ubuntu with Node 22; Swift used release builds on
46
+ macOS 15. Each comparison builds both revisions with the same harness and
47
+ toolchain, then takes seven interleaved process pairs with 300 ms warmup per
48
+ process. Build and startup time are excluded. Timings vary by machine; the shared
49
+ correctness fixtures and parity tolerances remain the accuracy gates.
50
+
51
+ Reproduce the comparison locally from the repository root (Node 22+ and Swift
52
+ 5.9+):
53
+
54
+ ```bash
55
+ npm ci --prefix typescript
56
+ node benchmarks/run.mjs --base v0.2.0
57
+ ```
58
+
59
+ CI runs the harness on code changes and fails on **median regressions over 20%**.
60
+ Results include timing tables, raw samples, checksums, and revision/toolchain
61
+ metadata. See [the harness guide](CONTRIBUTING.md#performance) for choosing a
62
+ baseline, running one port, and inspecting reports.
63
+
64
+ Event searches scale with window length. Run a full 151-year sweep in a worker or
65
+ background task; use shorter windows for interactive queries.
66
+
23
67
  ## Usage
24
68
 
25
69
  ### TypeScript
@@ -47,7 +91,7 @@ for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
47
91
  ### Swift
48
92
 
49
93
  ```swift
50
- .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.1.0")
94
+ .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.2.1")
51
95
  ```
52
96
 
53
97
  ```swift
@@ -67,9 +111,21 @@ for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
67
111
  }
68
112
  ```
69
113
 
70
- ### Performance
114
+ ### Eclipse searches
115
+
116
+ Search for a previous eclipse or all eclipses in a time window:
117
+
118
+ ```ts
119
+ import { previousLunarEclipse, lunarEclipses } from '@openwaters/almanac';
120
+ const last = previousLunarEclipse(new Date());
121
+ const eclipses = lunarEclipses(new Date('2026-08-24T00:00:00Z'), new Date('2026-09-02T12:00:00Z'));
122
+ ```
123
+
124
+ ```swift
125
+ let last = try previousLunarEclipse(before: Date())
126
+ let eclipses = try lunarEclipses(from: today, to: tomorrow)
127
+ ```
71
128
 
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.
129
+ Ranges include peaks at the start and exclude peaks at the end. Contacts may
130
+ extend outside the range. Previous/next searches skip peaks within 100 ms of the
131
+ anchor. Search results are global; apply `lunarEclipseVisibility` for an observer.
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);
@@ -113,10 +108,13 @@ function peakEarthShadow(centerUt) {
113
108
  * then averages the two halves, so the contacts this package reports are
114
109
  * symmetric about the peak by construction (as upstream's are).
115
110
  */
116
- function shadowSemiDurationMinutes(centerUt, radiusLimitKm, windowMinutes) {
111
+ function shadowSemiDurationMinutes(shadow, radiusLimitKm, windowMinutes) {
112
+ const centerUt = shadow.ut;
113
+ // Both contact searches sample the peak, whose geometry is already known.
114
+ const distance = (ut) => ut === centerUt ? shadow.r : earthShadow(ut).r;
117
115
  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)');
116
+ const t1 = search(ut => -(distance(ut) - radiusLimitKm), centerUt - window, centerUt, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'shadow semiduration (before)');
117
+ const t2 = search(ut => +(distance(ut) - radiusLimitKm), centerUt, centerUt + window, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'shadow semiduration (after)');
120
118
  if (t1 === null || t2 === null)
121
119
  throw new Error('almanac internal: failed to find shadow semiduration');
122
120
  return (t2 - t1) * (MINUTES_PER_DAY / 2.0); // convert days to minutes and average the semi-durations
@@ -138,17 +136,57 @@ function moonEclipticLatitudeDeg(ut) {
138
136
  * interval, or if the next eclipse falls at or past the end of it.
139
137
  */
140
138
  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);
139
+ return nearestLunarEclipse(after, 1);
140
+ }
141
+ /**
142
+ * The last lunar eclipse whose peak falls strictly before `before`, skipping
143
+ * peaks within 100 ms of it, just as `nextLunarEclipse` does on the other side.
144
+ *
145
+ * @throws {AlmanacOutOfRangeError} if `before` is outside the supported
146
+ * interval, or if the previous eclipse falls before its beginning.
147
+ */
148
+ export function previousLunarEclipse(before) {
149
+ return nearestLunarEclipse(before, -1);
150
+ }
151
+ /** Lunar eclipses with peaks in `[startUtc, endUtc)`, sorted ascending.
152
+ * Contacts may fall outside the window. Visibility is observer-dependent;
153
+ * use `lunarEclipseVisibility` on each result to check it. */
154
+ export function lunarEclipses(startUtc, endUtc) {
155
+ assertSupported(startUtc);
156
+ assertSupportedWindowEnd(endUtc);
157
+ return scanLunarEclipses(startUtc.getTime(), endUtc.getTime(), 1, false);
158
+ }
159
+ function nearestLunarEclipse(anchor, direction) {
160
+ assertSupported(anchor);
161
+ const ms = anchor.getTime();
162
+ const startMs = direction > 0 ? ms + SAME_ECLIPSE_MS + 1 : Math.max(SUPPORTED_MIN, ms - SCAN_LIMIT_DAYS * 86400000);
163
+ const endMs = direction > 0 ? Math.min(SUPPORTED_MAX, ms + SCAN_LIMIT_DAYS * 86400000) : ms - SAME_ECLIPSE_MS;
164
+ const found = scanLunarEclipses(startMs, endMs, direction, true);
165
+ if (found.length)
166
+ return found[0];
167
+ if (direction > 0 ? endMs === SUPPORTED_MAX : startMs === SUPPORTED_MIN)
168
+ throw new AlmanacOutOfRangeError();
169
+ // The longest catalog gap is 178 days: exhausting 730 inside the supported
170
+ // interval is a broken shadow model, not a normal out-of-range result.
171
+ throw new Error(`almanac internal: no lunar eclipse within ${SCAN_LIMIT_DAYS} days of ${anchor.toISOString()}`);
172
+ }
173
+ function scanLunarEclipses(startMs, endMs, direction, firstOnly) {
174
+ const found = [];
175
+ if (startMs >= endMs)
176
+ return found;
177
+ // Peak and full moon differ. Include the entire peak-search margin at
178
+ // both ends, then apply the caller's bounds to the reported peak below.
179
+ const startUt = utDays(new Date(startMs)) - PEAK_WINDOW_DAYS;
180
+ const endUt = utDays(new Date(endMs)) + PEAK_WINDOW_DAYS;
181
+ let fmUt = direction > 0 ? startUt : endUt;
182
+ const limitUt = direction > 0 ? endUt : startUt;
183
+ while (direction * (limitUt - fmUt) > 0) {
184
+ const fullmoon = searchMoonPhase(180, fmUt, direction * Math.min(40, Math.abs(limitUt - fmUt)));
147
185
  if (fullmoon === null)
148
- throw new Error('almanac internal: cannot find full moon');
186
+ break;
149
187
  // UPSTREAM `SearchLunarEclipse`: step past this full moon before the
150
188
  // next probe, so the same one cannot be found twice.
151
- fmUt = fullmoon + 10;
189
+ fmUt = fullmoon + direction * 10;
152
190
  // Pruning: if the full Moon's ecliptic latitude is too large, a lunar
153
191
  // eclipse is not possible. Avoid needless work searching for the
154
192
  // minimum moon distance.
@@ -159,20 +197,14 @@ export function nextLunarEclipse(after) {
159
197
  const shadow = peakEarthShadow(fullmoon);
160
198
  if (shadow.r >= shadow.p + MOON_MEAN_RADIUS_KM)
161
199
  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
200
  const peak = dateFromUt(shadow.ut);
165
- if (peak.getTime() <= after.getTime() + SAME_ECLIPSE_MS)
201
+ if (peak.getTime() < startMs || peak.getTime() >= endMs)
166
202
  continue;
167
- if (peak.getTime() >= SUPPORTED_MAX)
168
- throw new AlmanacOutOfRangeError();
169
- return buildEclipse(shadow, peak);
203
+ found.push(buildEclipse(shadow, peak));
204
+ if (firstOnly)
205
+ break;
170
206
  }
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()}`);
207
+ return found;
176
208
  }
177
209
  /** UPSTREAM: the classification and semi-duration block of `SearchLunarEclipse`, astronomy.ts ~8730-8755. */
178
210
  function buildEclipse(shadow, peak) {
@@ -180,15 +212,15 @@ function buildEclipse(shadow, peak) {
180
212
  let kind = 'penumbral';
181
213
  let sdTotal = 0.0;
182
214
  let sdPartial = 0.0;
183
- const sdPenum = shadowSemiDurationMinutes(shadow.ut, shadow.p + MOON_MEAN_RADIUS_KM, PENUMBRAL_WINDOW_MINUTES);
215
+ const sdPenum = shadowSemiDurationMinutes(shadow, shadow.p + MOON_MEAN_RADIUS_KM, PENUMBRAL_WINDOW_MINUTES);
184
216
  if (shadow.r < shadow.k + MOON_MEAN_RADIUS_KM) {
185
217
  // This is at least a partial eclipse.
186
218
  kind = 'partial';
187
- sdPartial = shadowSemiDurationMinutes(shadow.ut, shadow.k + MOON_MEAN_RADIUS_KM, sdPenum);
219
+ sdPartial = shadowSemiDurationMinutes(shadow, shadow.k + MOON_MEAN_RADIUS_KM, sdPenum);
188
220
  if (shadow.r + MOON_MEAN_RADIUS_KM < shadow.k) {
189
221
  // This is a total eclipse.
190
222
  kind = 'total';
191
- sdTotal = shadowSemiDurationMinutes(shadow.ut, shadow.k - MOON_MEAN_RADIUS_KM, sdPartial);
223
+ sdTotal = shadowSemiDurationMinutes(shadow, shadow.k - MOON_MEAN_RADIUS_KM, sdPartial);
192
224
  }
193
225
  }
194
226
  // Fraction of the Moon's diameter immersed in each shadow. Shadow radii,
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
@@ -215,13 +215,25 @@ function refineExtremum(sample, centreUt, wantMax, halfWidthDays, afterUt) {
215
215
  return score(s1) > score(s2) ? s1 : s2;
216
216
  }
217
217
  /**
218
- * Bisection for the single crossing of `level` on the monotonic segment
219
- * between two consecutive extrema.
218
+ * Quadratically accelerated search for the single crossing of `level` on a
219
+ * monotonic segment, with bisection if the estimate cannot be verified.
220
220
  */
221
221
  function bisectCrossing(sample, level, s0, s1) {
222
222
  let a = s0.ut;
223
223
  let b = s1.ut;
224
- let belowAtA = offsetDeg(s0, level) < 0;
224
+ const belowAtA = offsetDeg(s0, level) < 0;
225
+ // Reuse the phase search's quadratic acceleration, normalized to an
226
+ // ascending crossing. Endpoint geometry has already been evaluated.
227
+ const f = (ut) => (belowAtA ? 1 : -1) * offsetDeg(ut === s0.ut ? s0 : ut === s1.ut ? s1 : sample(ut), level);
228
+ const root = search(f, a, b, 0.25, BISECTION_CAP, 'altitude crossing');
229
+ if (root !== null) {
230
+ // A derivative-based estimate alone does not establish a time bound.
231
+ // Accept only a verified half-second bracket, including near grazes.
232
+ const left = Math.max(a, root - TIME_TOL_DAYS / 4);
233
+ const right = Math.min(b, root + TIME_TOL_DAYS / 4);
234
+ if (f(left) < 0 && f(right) >= 0)
235
+ return (left + right) / 2;
236
+ }
225
237
  for (let i = 0; i < BISECTION_CAP; i++) {
226
238
  if (b - a < TIME_TOL_DAYS)
227
239
  return (a + b) / 2;
@@ -266,12 +278,28 @@ function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRat
266
278
  const s1 = atRangeEnd ? sample(MAX_UT)
267
279
  : refineExtremum(sample, nextEst, nextIsMax, extremumHalfWidthDays, s0.ut);
268
280
  assertReached(s1.ut > s0.ut, 'extremum chain ordering');
269
- for (const spec of levels) {
270
- const g0 = crossingSign(s0, spec.level);
271
- const g1 = crossingSign(s1, spec.level);
272
- if (g0 * g1 >= 0)
273
- continue; // no crossing, or a graze that only touches
274
- emit(bisectCrossing(sample, spec.level, s0, s1), g1 > 0 ? spec.rising : spec.falling);
281
+ // Earlier extrema still establish the chain, but their crossings cannot be emitted.
282
+ if (quantizedUt(s1.ut) >= startUt) {
283
+ let crossingSample = sample;
284
+ if (levels.length > 1) {
285
+ // Twilight levels share midpoint probes; retain them only for this segment.
286
+ const samples = new Map();
287
+ crossingSample = (ut) => {
288
+ const previous = samples.get(ut);
289
+ if (previous)
290
+ return previous;
291
+ const current = sample(ut);
292
+ samples.set(ut, current);
293
+ return current;
294
+ };
295
+ }
296
+ for (const spec of levels) {
297
+ const g0 = crossingSign(s0, spec.level);
298
+ const g1 = crossingSign(s1, spec.level);
299
+ if (g0 * g1 >= 0)
300
+ continue; // no crossing, or a graze that only touches
301
+ emit(bisectCrossing(crossingSample, spec.level, s0, s1), g1 > 0 ? spec.rising : spec.falling);
302
+ }
275
303
  }
276
304
  if (nextIsMax && transitKind !== null && !atRangeEnd)
277
305
  emit(nextEst, transitKind);
@@ -390,8 +418,10 @@ export function search(f, t1, t2, dtToleranceSeconds, iterLimit = MOON_PHASE_ITE
390
418
  assertReached(iter < iterLimit, what);
391
419
  const tmid = (t1 + t2) / 2;
392
420
  const dt = tmid - t1;
421
+ // A caller may clip the initial bracket to a short range. Small
422
+ // width alone does not prove that it contains an ascending root.
393
423
  if (Math.abs(dt) < dtDays)
394
- return tmid;
424
+ return f1 <= 0 && f2 >= 0 ? tmid : null;
395
425
  if (calcFmid)
396
426
  fmid = f(tmid);
397
427
  else
@@ -434,8 +464,8 @@ export function search(f, t1, t2, dtToleranceSeconds, iterLimit = MOON_PHASE_ITE
434
464
  }
435
465
  }
436
466
  /**
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
467
+ * UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 — a negative limit searches
468
+ * backward. The phase repeats every synodic month, so the
439
469
  * time of the next occurrence is predicted from the current offset and then
440
470
  * bracketed ±1.5 days: the Moon's eccentricity has been seen to move a quarter
441
471
  * more than 0.9 days off the simple prediction.
@@ -444,13 +474,25 @@ export function searchMoonPhase(targetLonDeg, startUt, limitDays) {
444
474
  const moonOffset = (ut) => angleOffset(moonPhaseDeg(ttDaysFromUt(ut)) - targetLonDeg);
445
475
  const uncertainty = 1.5;
446
476
  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);
477
+ let dt1, dt2;
478
+ if (limitDays < 0) {
479
+ if (ya < 0)
480
+ ya += 360;
481
+ const estDt = -(MEAN_SYNODIC_MONTH * ya) / 360;
482
+ dt2 = estDt + uncertainty;
483
+ if (dt2 < limitDays)
484
+ return null;
485
+ dt1 = Math.max(limitDays, estDt - uncertainty);
486
+ }
487
+ else {
488
+ if (ya > 0)
489
+ ya -= 360;
490
+ const estDt = -(MEAN_SYNODIC_MONTH * ya) / 360;
491
+ dt1 = estDt - uncertainty;
492
+ if (dt1 > limitDays)
493
+ return null;
494
+ dt2 = Math.min(limitDays, estDt + uncertainty);
495
+ }
454
496
  return search(moonOffset, startUt + dt1, startUt + dt2, 0.1);
455
497
  }
456
498
  /**
@@ -63,7 +63,10 @@ function normalizeLongitude(lon) {
63
63
  * that far off the USNO catalogue.
64
64
  */
65
65
  export function moonPhaseDeg(tt) {
66
- return normalizeLongitude(eclipticLonOfDateDeg(moonGeoVectorEqj(tt), tt) - eclipticLonOfDateDeg(sunGeoVectorEqj(tt), tt));
66
+ return moonPhaseFromVector(tt, moonGeoVectorEqj(tt));
67
+ }
68
+ function moonPhaseFromVector(tt, moon) {
69
+ return normalizeLongitude(eclipticLonOfDateDeg(moon, tt) - eclipticLonOfDateDeg(sunGeoVectorEqj(tt), tt));
67
70
  }
68
71
  /** INTERNAL: moon illumination for a TT instant. See {@link moonIllumination}. */
69
72
  export function moonIlluminationAtTT(tt) {
@@ -74,7 +77,7 @@ export function moonIlluminationAtTT(tt) {
74
77
  const hc = { x: earth.x + gc.x, y: earth.y + gc.y, z: earth.z + gc.z };
75
78
  const phaseAngleDeg = angleBetweenDeg(gc, hc);
76
79
  const fraction = (1 + Math.cos(DEG2RAD * phaseAngleDeg)) / 2;
77
- const phase = moonPhaseDeg(tt) / 360;
80
+ const phase = moonPhaseFromVector(tt, gc) / 360;
78
81
  return { fraction, phaseAngleDeg, phase, waxing: phase < 0.5 };
79
82
  }
80
83
  /**
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/dist/moon.js CHANGED
@@ -14,43 +14,21 @@ import { ARC, EARTH_EQUATORIAL_RADIUS_AU, PI2, PrecessDirection, eclipticToEquat
14
14
  */
15
15
  export function calcMoon(tt) {
16
16
  const T = tt / 36525;
17
- function DeclareArray1(xmin, xmax) {
18
- const array = [];
19
- let i;
20
- for (i = 0; i <= xmax - xmin; ++i) {
21
- array.push(0);
22
- }
23
- return { min: xmin, array: array };
24
- }
25
- function DeclareArray2(xmin, xmax, ymin, ymax) {
26
- const array = [];
27
- for (let i = 0; i <= xmax - xmin; ++i) {
28
- array.push(DeclareArray1(ymin, ymax));
29
- }
30
- return { min: xmin, array: array };
31
- }
32
- function ArrayGet2(a, x, y) {
33
- const m = a.array[x - a.min];
34
- return m.array[y - m.min];
35
- }
36
- function ArraySet2(a, x, y, v) {
37
- const m = a.array[x - a.min];
38
- m.array[y - m.min] = v;
39
- }
40
17
  let S, MAX, ARG, FAC, I, J, T2, DGAM, DLAM, N, GAM1C, SINPI, L0, L, LS, F, D, DL0, DL, DLS, DF, DD, DS;
41
- let coArray = DeclareArray2(-6, 6, 1, 4);
42
- let siArray = DeclareArray2(-6, 6, 1, 4);
18
+ // CO/SI tables: x in -6...6, y in 1...4, stored in flat 13x4 arrays.
19
+ const coArray = new Array(52).fill(0);
20
+ const siArray = new Array(52).fill(0);
43
21
  function CO(x, y) {
44
- return ArrayGet2(coArray, x, y);
22
+ return coArray[(x + 6) * 4 + y - 1];
45
23
  }
46
24
  function SI(x, y) {
47
- return ArrayGet2(siArray, x, y);
25
+ return siArray[(x + 6) * 4 + y - 1];
48
26
  }
49
27
  function SetCO(x, y, v) {
50
- return ArraySet2(coArray, x, y, v);
28
+ coArray[(x + 6) * 4 + y - 1] = v;
51
29
  }
52
30
  function SetSI(x, y, v) {
53
- return ArraySet2(siArray, x, y, v);
31
+ siArray[(x + 6) * 4 + y - 1] = v;
54
32
  }
55
33
  function AddThe(c1, s1, c2, s2, func) {
56
34
  func(c1 * c2 - s1 * s2, s1 * c2 + c1 * s2);
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@openwaters/almanac",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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",