@openwaters/almanac 0.2.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
@@ -22,6 +22,48 @@ future — see the design spec's Conventions section for what that means and why
22
22
  Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/astronomy)
23
23
  (MIT, Don Cross) — see [NOTICE](NOTICE). MIT licensed.
24
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
+
25
67
  ## Usage
26
68
 
27
69
  ### TypeScript
@@ -49,7 +91,7 @@ for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
49
91
  ### Swift
50
92
 
51
93
  ```swift
52
- .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.2.0")
94
+ .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.2.1")
53
95
  ```
54
96
 
55
97
  ```swift
@@ -87,15 +129,3 @@ let eclipses = try lunarEclipses(from: today, to: tomorrow)
87
129
  Ranges include peaks at the start and exclude peaks at the end. Contacts may
88
130
  extend outside the range. Previous/next searches skip peaks within 100 ms of the
89
131
  anchor. Search results are global; apply `lunarEclipseVisibility` for an observer.
90
-
91
- ### Performance
92
-
93
- Event searches (`sunEvents`/`moonEvents`) are linear in window length. The full
94
- 151-year supported interval is a measured cost, not a guess: ~68 s in TypeScript,
95
- ~44 s in Swift release builds. Chunk or worker a full-range call rather than
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.js CHANGED
@@ -108,10 +108,13 @@ function peakEarthShadow(centerUt) {
108
108
  * then averages the two halves, so the contacts this package reports are
109
109
  * symmetric about the peak by construction (as upstream's are).
110
110
  */
111
- 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;
112
115
  const window = windowMinutes / MINUTES_PER_DAY;
113
- const t1 = search(ut => -(earthShadow(ut).r - radiusLimitKm), centerUt - window, centerUt, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'shadow semiduration (before)');
114
- 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)');
115
118
  if (t1 === null || t2 === null)
116
119
  throw new Error('almanac internal: failed to find shadow semiduration');
117
120
  return (t2 - t1) * (MINUTES_PER_DAY / 2.0); // convert days to minutes and average the semi-durations
@@ -209,15 +212,15 @@ function buildEclipse(shadow, peak) {
209
212
  let kind = 'penumbral';
210
213
  let sdTotal = 0.0;
211
214
  let sdPartial = 0.0;
212
- 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);
213
216
  if (shadow.r < shadow.k + MOON_MEAN_RADIUS_KM) {
214
217
  // This is at least a partial eclipse.
215
218
  kind = 'partial';
216
- sdPartial = shadowSemiDurationMinutes(shadow.ut, shadow.k + MOON_MEAN_RADIUS_KM, sdPenum);
219
+ sdPartial = shadowSemiDurationMinutes(shadow, shadow.k + MOON_MEAN_RADIUS_KM, sdPenum);
217
220
  if (shadow.r + MOON_MEAN_RADIUS_KM < shadow.k) {
218
221
  // This is a total eclipse.
219
222
  kind = 'total';
220
- sdTotal = shadowSemiDurationMinutes(shadow.ut, shadow.k - MOON_MEAN_RADIUS_KM, sdPartial);
223
+ sdTotal = shadowSemiDurationMinutes(shadow, shadow.k - MOON_MEAN_RADIUS_KM, sdPartial);
221
224
  }
222
225
  }
223
226
  // Fraction of the Moon's diameter immersed in each shadow. Shadow radii,
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);
@@ -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/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,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwaters/almanac",
3
- "version": "0.2.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" },