@openwaters/almanac 0.4.0 → 0.4.2

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
@@ -26,31 +26,32 @@ cover positions, a 228-hour sky track, short/year/polar event windows, full-rang
26
26
  moon phases, next/previous/range lunar and solar eclipse searches, including empty
27
27
  windows, and solar obscuration over a 228-hour track.
28
28
 
29
- The v0.2.1 optimizations reuse Moon and shadow geometry, flatten scratch arrays,
30
- and refine altitude crossings with fewer position evaluations. In the
31
- [hosted CI comparison](https://github.com/openwatersio/almanac/actions/runs/34119038860),
32
- median query time fell relative to the v0.2.0 astronomy code:
29
+ Event searches find altitude extrema with Brent's method and altitude crossings
30
+ with a cosine-seeded secant solver, each root proven inside a half-second
31
+ bracket, and the TypeScript Sun series evaluates its terms as straight-line
32
+ arithmetic. Median query time relative to v0.4.1:
33
33
 
34
34
  | Workload | TypeScript: less time | Swift: less time |
35
35
  | --- | ---: | ---: |
36
- | Moon positions / 1,024 hours | 29.8% | 22.1% |
37
- | Sky track / 228 hours | 40.0% | 42.4% |
38
- | Sun events / 228 hours | 39.3% | 40.2% |
39
- | Moon events / 228 hours | 39.1% | 35.1% |
40
- | Previous lunar eclipse | 26.0% | 36.6% |
41
- | Lunar eclipses / 1950–2100 | 26.4% | 23.4% |
36
+ | Sun events / year | 73.4% | 62.8% |
37
+ | Moon events / year | 57.6% | 57.4% |
38
+ | Sun events / 228 hours | 73.4% | 61.9% |
39
+ | Moon events / 228 hours | 57.1% | 57.0% |
40
+ | Sun positions / 1,024 hours | 35.5% | — |
41
+ | Lunar eclipses / 1950–2100 | 24.6% | — |
42
42
 
43
- TypeScript was measured on Ubuntu; Swift used release builds on macOS. Each comparison builds both revisions with the same harness and toolchain, then takes seven interleaved process pairs with 300 ms warmup per process. Build and startup time are excluded. Timings vary by machine; the shared correctness fixtures and parity tolerances remain the accuracy gates.
43
+ Swift already evaluated the Sun series efficiently, so its gains are confined to
44
+ the searches. Each comparison builds both revisions with the same harness and toolchain, then takes seven interleaved process pairs with 300 ms warmup per process. Build and startup time are excluded. Timings vary by machine; the shared correctness fixtures and parity tolerances remain the accuracy gates.
44
45
 
45
46
  Reproduce the comparison locally from the repository root with mise 2026.9.1 or newer after installing the configured Node and Swift versions in `mise.toml`:
46
47
 
47
48
  ```bash
48
49
  mise install
49
50
  mise exec -- npm ci --prefix typescript
50
- mise exec -- node benchmarks/run.mjs --base v0.2.0 --skip '^solar/'
51
+ mise exec -- node benchmarks/run.mjs --base v0.4.1
51
52
  ```
52
53
 
53
- The `--skip` pattern leaves out the solar eclipse workloads, which need a base of 0.4.0 or later.
54
+ Pass `--skip '^solar/'` when the base predates 0.4.0, which is when the solar eclipse workloads arrived.
54
55
 
55
56
  CI runs the harness on code changes and fails on **median regressions over 20%**.
56
57
  Results include timing tables, raw samples, checksums, and revision/toolchain
package/dist/events.js CHANGED
@@ -8,8 +8,9 @@
8
8
  // a grid can straddle both halves of a grazing pair (the polar-onset day where
9
9
  // the Sun sets at 08:00 and rises again at 08:16) and report neither. Here each
10
10
  // daily cycle's altitude extrema are located numerically and every monotonic
11
- // segment between consecutive extrema is bisected, so a pair of crossings
12
- // minutes apart is bracketed by construction rather than by luck of the grid.
11
+ // segment between consecutive extrema is solved for each target level, so a
12
+ // pair of crossings minutes apart is bracketed by construction rather than by
13
+ // luck of the grid.
13
14
  // Hour angle supplies only the initial bracket: HA 0/12h are not the exact
14
15
  // extrema, because declination, distance and lunar parallax all drift within a
15
16
  // cycle, and toward the poles the daily altitude cycle flattens until the
@@ -22,7 +23,7 @@
22
23
  // and `NextMoonQuarter` (~5362).
23
24
  import { assertObserver, assertSupported, assertSupportedWindowEnd, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
24
25
  import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
25
- import { KM_PER_AU, RAD2DEG } from './nutation.js';
26
+ import { DEG2RAD, KM_PER_AU, RAD2DEG } from './nutation.js';
26
27
  import { topoAltAzUnrefracted } from './transforms.js';
27
28
  import { sunGeoVectorEqj } from './sun.js';
28
29
  import { moonGeoVectorEqj } from './moon.js';
@@ -57,7 +58,7 @@ const SUN_CYCLE_DAYS = 1;
57
58
  const MOON_CYCLE_DAYS = 1.035050;
58
59
  const MOON_HA_RATE_DEG_PER_DAY = 360 / MOON_CYCLE_DAYS;
59
60
  const SECOND_DAYS = 1 / 86400;
60
- /** Bisection and golden-section both stop under a second; events are reported to the ms. */
61
+ /** Every search stops under a second; events are reported to the ms. */
61
62
  const TIME_TOL_DAYS = SECOND_DAYS;
62
63
  /** An extremum this close to the target grazes it without crossing: no event. */
63
64
  const TANGENT_EPS_DEG = 1e-6;
@@ -76,6 +77,7 @@ const EXTREMUM_HALF_WIDTH_DAYS = 2 / 24;
76
77
  const FLAT_CYCLE_LATITUDE_DEG = 85;
77
78
  const GOLDEN_SECTION_CAP = 100;
78
79
  const BISECTION_CAP = 60;
80
+ const SECANT_CAP = 8;
79
81
  const HOUR_ANGLE_ITER_CAP = 20;
80
82
  const MOON_PHASE_ITER_CAP = 50;
81
83
  const INV_PHI = 0.6180339887498949;
@@ -171,8 +173,7 @@ function nextHourAngle(sample, afterUt, targetHaDeg, rateDegPerDay) {
171
173
  return ut;
172
174
  }
173
175
  /**
174
- * Golden-section refinement of the altitude extremum bracketed around
175
- * `centreUt`. Evaluating the true extremum is what makes a grazing pair
176
+ * Refinement of the altitude extremum bracketed around `centreUt`. Evaluating the true extremum is what makes a grazing pair
176
177
  * findable: the segment on either side of it is monotonic, so bisection can
177
178
  * only miss a crossing if the extremum itself was never seen.
178
179
  *
@@ -182,9 +183,105 @@ function nextHourAngle(sample, afterUt, targetHaDeg, rateDegPerDay) {
182
183
  * declination's slow annual drift, unclamped golden section will happily walk
183
184
  * backwards and break the chain's ordering.
184
185
  */
185
- function refineExtremum(sample, centreUt, wantMax, halfWidthDays, afterUt) {
186
- let a = Math.max(clampUt(centreUt - halfWidthDays), afterUt);
187
- let b = clampUt(centreUt + halfWidthDays);
186
+ function refineExtremum(sample, centreUt, wantMax, halfWidthDays, afterUt, flatCycle) {
187
+ const a = Math.max(clampUt(centreUt - halfWidthDays), afterUt);
188
+ const b = clampUt(centreUt + halfWidthDays);
189
+ if (!flatCycle) {
190
+ const best = brentExtremum(sample, a, b, wantMax);
191
+ if (best !== null)
192
+ return best;
193
+ }
194
+ return goldenExtremum(sample, a, b, wantMax);
195
+ }
196
+ /**
197
+ * Brent's parabolic-plus-golden minimizer on `[a, b]`, scored for a maximum
198
+ * or minimum. Each step evaluates one sample; a parabola through the three
199
+ * best points supplies the step whenever it is finite, lands strictly inside
200
+ * the bracket, and contracts faster than the step before last, and golden
201
+ * section takes over otherwise. Returns `null` when the search hits its cap or
202
+ * settles within a tolerance of the original bracket edge, where the flat,
203
+ * asymmetric altitude curve has made the parabola unreliable; the caller then
204
+ * runs the golden search unchanged.
205
+ */
206
+ function brentExtremum(sample, a0, b0, wantMax) {
207
+ const cost = (s) => (wantMax ? -s.altDeg : s.altDeg);
208
+ let a = a0;
209
+ let b = b0;
210
+ let x = a + (1 - INV_PHI) * (b - a);
211
+ let w = x;
212
+ let v = x;
213
+ let sx = sample(x);
214
+ let fx = cost(sx);
215
+ let fw = fx;
216
+ let fv = fx;
217
+ let d = 0; // the last step taken
218
+ let e = 0; // the step before last, which the parabolic step must beat
219
+ for (let i = 0; i < GOLDEN_SECTION_CAP; i++) {
220
+ if (b - a < TIME_TOL_DAYS) {
221
+ return x - a0 < TIME_TOL_DAYS || b0 - x < TIME_TOL_DAYS ? null : sx;
222
+ }
223
+ const tol1 = Math.max(TIME_TOL_DAYS / 16, 4 * Number.EPSILON * Math.abs(x));
224
+ const xm = (a + b) / 2;
225
+ let golden = true;
226
+ if (Math.abs(e) > tol1) {
227
+ const r = (x - w) * (fx - fv);
228
+ let q = (x - v) * (fx - fw);
229
+ let p = (x - v) * q - (x - w) * r;
230
+ q = 2 * (q - r);
231
+ if (q > 0)
232
+ p = -p;
233
+ q = Math.abs(q);
234
+ const eTemp = e;
235
+ e = d;
236
+ if (Number.isFinite(p / q) && Math.abs(p) < Math.abs(0.5 * q * eTemp) && p > q * (a - x) && p < q * (b - x)) {
237
+ d = p / q;
238
+ // Near an edge the parabola only re-proposes the best point; step across it instead.
239
+ if (x + d - a < 2 * tol1 || b - (x + d) < 2 * tol1)
240
+ d = xm >= x ? tol1 : -tol1;
241
+ golden = false;
242
+ }
243
+ }
244
+ if (golden) {
245
+ e = x >= xm ? a - x : b - x;
246
+ d = (1 - INV_PHI) * e;
247
+ }
248
+ const u = Math.abs(d) >= tol1 ? x + d : x + (d >= 0 ? tol1 : -tol1);
249
+ const su = sample(u);
250
+ const fu = cost(su);
251
+ if (fu <= fx) {
252
+ if (u >= x)
253
+ a = x;
254
+ else
255
+ b = x;
256
+ v = w;
257
+ fv = fw;
258
+ w = x;
259
+ fw = fx;
260
+ x = u;
261
+ fx = fu;
262
+ sx = su;
263
+ }
264
+ else {
265
+ if (u < x)
266
+ a = u;
267
+ else
268
+ b = u;
269
+ if (fu <= fw || w === x) {
270
+ v = w;
271
+ fv = fw;
272
+ w = u;
273
+ fw = fu;
274
+ }
275
+ else if (fu <= fv || v === x || v === w) {
276
+ v = u;
277
+ fv = fu;
278
+ }
279
+ }
280
+ }
281
+ return null;
282
+ }
283
+ /** Golden-section search of the extremum on `[a, b]`: slow, and safe on any curve shape. */
284
+ function goldenExtremum(sample, a, b, wantMax) {
188
285
  let x1 = b - INV_PHI * (b - a);
189
286
  let x2 = a + INV_PHI * (b - a);
190
287
  let s1 = sample(x1);
@@ -215,16 +312,27 @@ function refineExtremum(sample, centreUt, wantMax, halfWidthDays, afterUt) {
215
312
  return score(s1) > score(s2) ? s1 : s2;
216
313
  }
217
314
  /**
218
- * Quadratically accelerated search for the single crossing of `level` on a
219
- * monotonic segment, with bisection if the estimate cannot be verified.
315
+ * The single crossing of `level` on a monotonic segment between two extrema,
316
+ * reported from inside a verified half-second bracket.
317
+ *
318
+ * Between extrema the altitude offset is close to a half cosine, so the cosine
319
+ * through the two endpoints predicts the crossing to within a minute for the
320
+ * Sun and a few minutes for the Moon, with no probe at all. Secant steps from
321
+ * that estimate reach the second in one or two probes, and one more probe on
322
+ * the side the last sign points to proves the bracket. Anything that escapes
323
+ * the segment, stalls, or fails the proof falls back to the quadratically
324
+ * accelerated bracketed search with bisection behind it.
220
325
  */
221
- function bisectCrossing(sample, level, s0, s1) {
326
+ function solveCrossing(sample, level, s0, s1) {
222
327
  let a = s0.ut;
223
328
  let b = s1.ut;
224
329
  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.
330
+ // Normalized to an ascending crossing. Endpoint geometry has already been evaluated.
227
331
  const f = (ut) => (belowAtA ? 1 : -1) * offsetDeg(ut === s0.ut ? s0 : ut === s1.ut ? s1 : sample(ut), level);
332
+ const targetDeg = level === null ? (s0.riseSetAltDeg + s1.riseSetAltDeg) / 2 : level;
333
+ const secant = secantCrossing(f, a, b, s0, s1, targetDeg);
334
+ if (secant !== null)
335
+ return secant;
228
336
  const root = search(f, a, b, 0.25, BISECTION_CAP, 'altitude crossing');
229
337
  if (root !== null) {
230
338
  // A derivative-based estimate alone does not establish a time bound.
@@ -246,6 +354,51 @@ function bisectCrossing(sample, level, s0, s1) {
246
354
  assertReached(false, 'crossing bisection');
247
355
  return (a + b) / 2;
248
356
  }
357
+ /**
358
+ * Secant refinement of the ascending crossing of `f` on `[a, b]`, started from
359
+ * the half cosine that the sine of altitude follows between two extrema.
360
+ * Returns the root inside a proven half-second bracket, or `null` when the
361
+ * caller should run the bracketed search.
362
+ */
363
+ function secantCrossing(f, a, b, s0, s1, targetDeg) {
364
+ // sin(alt) ≈ c + amp·cos(π (t − a) / (b − a)) passes through both extrema; a
365
+ // constant declination and no parallax would make it exact.
366
+ const g0 = Math.sin(s0.altDeg * DEG2RAD);
367
+ const g1 = Math.sin(s1.altDeg * DEG2RAD);
368
+ const c = (g0 + g1) / 2;
369
+ const amp = (g0 - g1) / 2;
370
+ const ratio = (Math.sin(targetDeg * DEG2RAD) - c) / amp;
371
+ if (!(Math.abs(ratio) < 1))
372
+ return null;
373
+ const theta = Math.acos(ratio);
374
+ let t = a + theta * (b - a) / Math.PI;
375
+ let slope = Math.abs(amp) * Math.sin(theta) * Math.PI / (b - a) / Math.cos(targetDeg * DEG2RAD) * RAD2DEG;
376
+ let ft = f(t);
377
+ for (let i = 0; i < SECANT_CAP; i++) {
378
+ const step = -ft / slope;
379
+ if (!Number.isFinite(step))
380
+ return null;
381
+ if (Math.abs(step) < TIME_TOL_DAYS / 4) {
382
+ // The last sign says which side the root is on: prove a half-second
383
+ // bracket there, then report the secant root itself, held inside the
384
+ // bracket, so the answer does not jump with the side the probe picked.
385
+ if (ft < 0) {
386
+ const right = Math.min(b, t + TIME_TOL_DAYS / 2);
387
+ return f(right) >= 0 ? Math.min(t + step, right) : null;
388
+ }
389
+ const left = Math.max(a, t - TIME_TOL_DAYS / 2);
390
+ return f(left) < 0 ? Math.max(left, t + step) : null;
391
+ }
392
+ const next = t + step;
393
+ if (next <= a || next >= b)
394
+ return null;
395
+ const fNext = f(next);
396
+ slope = (fNext - ft) / (next - t);
397
+ t = next;
398
+ ft = fNext;
399
+ }
400
+ return null;
401
+ }
249
402
  /**
250
403
  * Walk the window one half-cycle at a time, refining each altitude extremum and
251
404
  * solving every monotonic segment between consecutive extrema for every target
@@ -253,7 +406,8 @@ function bisectCrossing(sample, level, s0, s1) {
253
406
  * containing the window's first instant is covered, and runs one extremum past
254
407
  * `endUt` so an event just inside the window's end is too.
255
408
  */
256
- function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRateDegPerDay, cycleDays, extremumHalfWidthDays) {
409
+ function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRateDegPerDay, cycleDays, flatCycle) {
410
+ const extremumHalfWidthDays = flatCycle ? cycleDays / 2 : EXTREMUM_HALF_WIDTH_DAYS;
257
411
  const found = [];
258
412
  const emit = (ut, kind) => {
259
413
  const q = quantizedUt(ut);
@@ -276,29 +430,16 @@ function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRat
276
430
  // extremum we are not allowed to probe past. The segment up to it is
277
431
  // still monotonic, so any crossing inside the window is still found.
278
432
  const s1 = atRangeEnd ? sample(MAX_UT)
279
- : refineExtremum(sample, nextEst, nextIsMax, extremumHalfWidthDays, s0.ut);
433
+ : refineExtremum(sample, nextEst, nextIsMax, extremumHalfWidthDays, s0.ut, flatCycle);
280
434
  assertReached(s1.ut > s0.ut, 'extremum chain ordering');
281
435
  // Earlier extrema still establish the chain, but their crossings cannot be emitted.
282
436
  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
437
  for (const spec of levels) {
297
438
  const g0 = crossingSign(s0, spec.level);
298
439
  const g1 = crossingSign(s1, spec.level);
299
440
  if (g0 * g1 >= 0)
300
441
  continue; // no crossing, or a graze that only touches
301
- emit(bisectCrossing(crossingSample, spec.level, s0, s1), g1 > 0 ? spec.rising : spec.falling);
442
+ emit(solveCrossing(sample, spec.level, s0, s1), g1 > 0 ? spec.rising : spec.falling);
302
443
  }
303
444
  }
304
445
  if (nextIsMax && transitKind !== null && !atRangeEnd)
@@ -321,10 +462,8 @@ const SUN_LEVELS = [
321
462
  const MOON_LEVELS = [
322
463
  { level: null, rising: 'rise', falling: 'set' },
323
464
  ];
324
- function extremumHalfWidth(observer, cycleDays) {
325
- return Math.abs(observer.latitudeDeg) >= FLAT_CYCLE_LATITUDE_DEG
326
- ? cycleDays / 2
327
- : EXTREMUM_HALF_WIDTH_DAYS;
465
+ function flatCycle(observer) {
466
+ return Math.abs(observer.latitudeDeg) >= FLAT_CYCLE_LATITUDE_DEG;
328
467
  }
329
468
  /**
330
469
  * Sun rise, set, the three twilights and upper transit within the half-open
@@ -344,7 +483,7 @@ export function sunEvents(startUtc, endUtc, observer) {
344
483
  assertObserver(observer);
345
484
  if (startUtc.getTime() >= endUtc.getTime())
346
485
  return [];
347
- return searchAltitudeEvents(sunSampler(observer), SUN_LEVELS, 'transit', utDays(startUtc), utDays(endUtc), SUN_HA_RATE_DEG_PER_DAY, SUN_CYCLE_DAYS, extremumHalfWidth(observer, SUN_CYCLE_DAYS));
486
+ return searchAltitudeEvents(sunSampler(observer), SUN_LEVELS, 'transit', utDays(startUtc), utDays(endUtc), SUN_HA_RATE_DEG_PER_DAY, SUN_CYCLE_DAYS, flatCycle(observer));
348
487
  }
349
488
  /**
350
489
  * Moonrise and moonset within the half-open window `[startUtc, endUtc)`,
@@ -358,7 +497,7 @@ export function moonEvents(startUtc, endUtc, observer) {
358
497
  assertObserver(observer);
359
498
  if (startUtc.getTime() >= endUtc.getTime())
360
499
  return [];
361
- return searchAltitudeEvents(moonSampler(observer), MOON_LEVELS, null, utDays(startUtc), utDays(endUtc), MOON_HA_RATE_DEG_PER_DAY, MOON_CYCLE_DAYS, extremumHalfWidth(observer, MOON_CYCLE_DAYS));
500
+ return searchAltitudeEvents(moonSampler(observer), MOON_LEVELS, null, utDays(startUtc), utDays(endUtc), MOON_HA_RATE_DEG_PER_DAY, MOON_CYCLE_DAYS, flatCycle(observer));
362
501
  }
363
502
  // -------------------------------------------------------------- moon phases
364
503
  /**