@openwaters/almanac 0.2.0 → 0.3.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
@@ -1,8 +1,9 @@
1
1
  # Almanac
2
2
 
3
3
  Offline sun & moon engine for the Salish Sea and everywhere else — positions,
4
- rise/set/twilight, moon phase, and lunar eclipses (solar planned), computed from pure
5
- geometry with zero network and zero runtime data files.
4
+ rise/set/twilight, moon phase, lunar eclipses (solar planned), and where any fixed
5
+ star stands from a catalog position, computed from pure geometry with zero network
6
+ and zero runtime data files.
6
7
 
7
8
  Twin implementations, one behavior:
8
9
 
@@ -22,6 +23,48 @@ future — see the design spec's Conventions section for what that means and why
22
23
  Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/astronomy)
23
24
  (MIT, Don Cross) — see [NOTICE](NOTICE). MIT licensed.
24
25
 
26
+ ## Performance
27
+
28
+ Almanac includes a shared performance harness for both ports: **15 workloads**
29
+ cover positions, a 228-hour sky track, short/year/polar event windows, full-range
30
+ moon phases, and next/previous/range eclipse searches, including empty windows.
31
+
32
+ The v0.2.1 optimizations reuse Moon and shadow geometry, flatten scratch arrays,
33
+ and refine altitude crossings with fewer position evaluations. In the
34
+ [hosted CI comparison](https://github.com/openwatersio/almanac/actions/runs/34119038860),
35
+ median query time fell relative to the v0.2.0 astronomy code:
36
+
37
+ | Workload | TypeScript: less time | Swift: less time |
38
+ | --- | ---: | ---: |
39
+ | Moon positions / 1,024 hours | 29.8% | 22.1% |
40
+ | Sky track / 228 hours | 40.0% | 42.4% |
41
+ | Sun events / 228 hours | 39.3% | 40.2% |
42
+ | Moon events / 228 hours | 39.1% | 35.1% |
43
+ | Previous lunar eclipse | 26.0% | 36.6% |
44
+ | Lunar eclipses / 1950–2100 | 26.4% | 23.4% |
45
+
46
+ TypeScript was measured on Ubuntu with Node 22; Swift used release builds on
47
+ macOS 15. Each comparison builds both revisions with the same harness and
48
+ toolchain, then takes seven interleaved process pairs with 300 ms warmup per
49
+ process. Build and startup time are excluded. Timings vary by machine; the shared
50
+ correctness fixtures and parity tolerances remain the accuracy gates.
51
+
52
+ Reproduce the comparison locally from the repository root (Node 22+ and Swift
53
+ 5.9+):
54
+
55
+ ```bash
56
+ npm ci --prefix typescript
57
+ node benchmarks/run.mjs --base v0.2.0
58
+ ```
59
+
60
+ CI runs the harness on code changes and fails on **median regressions over 20%**.
61
+ Results include timing tables, raw samples, checksums, and revision/toolchain
62
+ metadata. See [the harness guide](CONTRIBUTING.md#performance) for choosing a
63
+ baseline, running one port, and inspecting reports.
64
+
65
+ Event searches scale with window length. Run a full 151-year sweep in a worker or
66
+ background task; use shorter windows for interactive queries.
67
+
25
68
  ## Usage
26
69
 
27
70
  ### TypeScript
@@ -31,7 +74,7 @@ npm install @openwaters/almanac
31
74
  ```
32
75
 
33
76
  ```ts
34
- import { nextLunarEclipse, lunarEclipseVisibility, sunEvents } from '@openwaters/almanac';
77
+ import { nextLunarEclipse, lunarEclipseVisibility, sunEvents, starAltAz } from '@openwaters/almanac';
35
78
 
36
79
  const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
37
80
 
@@ -44,12 +87,15 @@ const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
44
87
  for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
45
88
  console.log(kind, time.toISOString());
46
89
  }
90
+
91
+ // Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
92
+ const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
47
93
  ```
48
94
 
49
95
  ### Swift
50
96
 
51
97
  ```swift
52
- .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.2.0")
98
+ .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.3.0")
53
99
  ```
54
100
 
55
101
  ```swift
@@ -67,6 +113,9 @@ let tomorrow = today.addingTimeInterval(24 * 60 * 60)
67
113
  for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
68
114
  print(event.kind, event.time)
69
115
  }
116
+
117
+ // Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
118
+ let star = try starAltAz(raDeg: 88.792939, decDeg: 7.407064, at: today, observer: observer)
70
119
  ```
71
120
 
72
121
  ### Eclipse searches
@@ -87,15 +136,3 @@ let eclipses = try lunarEclipses(from: today, to: tomorrow)
87
136
  Ranges include peaks at the start and exclude peaks at the end. Contacts may
88
137
  extend outside the range. Previous/next searches skip peaks within 100 ms of the
89
138
  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/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { AlmanacOutOfRangeError } from './types.js';
3
3
  export type { SunPosition, MoonPosition } from './positions.js';
4
4
  export { sunPosition, moonPosition } from './positions.js';
5
5
  export type { AltAz } from './transforms.js';
6
- export { sunAltAz, moonAltAz } from './transforms.js';
6
+ export { sunAltAz, moonAltAz, starAltAz } from './transforms.js';
7
7
  export type { MoonIllumination } from './illumination.js';
8
8
  export { moonIllumination } from './illumination.js';
9
9
  export type { SunEvent, SunEventKind, MoonEvent, MoonEventKind, MoonPhaseEvent, MoonPhaseName } from './events.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { AlmanacOutOfRangeError } from './types.js';
2
2
  export { sunPosition, moonPosition } from './positions.js';
3
- export { sunAltAz, moonAltAz } from './transforms.js';
3
+ export { sunAltAz, moonAltAz, starAltAz } from './transforms.js';
4
4
  export { moonIllumination } from './illumination.js';
5
5
  export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
6
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);
@@ -59,3 +59,15 @@ export declare function sunAltAz(time: Date, observer: Observer): AltAz;
59
59
  * refraction (upstream `'normal'` model) both applied.
60
60
  */
61
61
  export declare function moonAltAz(time: Date, observer: Observer): AltAz;
62
+ /**
63
+ * Topocentric azimuth/altitude of a fixed star from its J2000 (ICRS)
64
+ * catalog right ascension and declination, both in degrees.
65
+ *
66
+ * Applied: precession and nutation to the equator of date, Greenwich
67
+ * apparent sidereal time, and atmospheric refraction (upstream `'normal'`
68
+ * model, the same as sunAltAz/moonAltAz). Not applied: annual aberration
69
+ * (at most 20.5″ — see the roadmap), proper motion (pass catalog
70
+ * coordinates; fast movers such as Arcturus drift ~1′ in 26 years), and
71
+ * stellar parallax (negligible at the 1000 ly the star is placed at).
72
+ */
73
+ export declare function starAltAz(raDeg: number, decDeg: number, time: Date, observer: Observer): AltAz;
@@ -196,3 +196,41 @@ export function moonAltAz(time, observer) {
196
196
  const ut = utDays(time);
197
197
  return refract(topoAltAzUnrefracted(moonGeoVectorEqj(ttDaysFromUt(ut)), ut, observer));
198
198
  }
199
+ /**
200
+ * UPSTREAM: the user-defined star path — `HelioVector` for a `DefineStar`
201
+ * body (astronomy.ts ~4097: `VectorFromSphere(Spherical(dec, 15*ra, dist))`)
202
+ * into `Equator(body, date, observer, ofdate=true, aberration=false)` (~2803)
203
+ * and `Horizon` (~2565): the same topocentric path sunAltAz/moonAltAz take.
204
+ * Upstream floors a star's distance at 1 light-year and suggests 1000 when
205
+ * unknown; the star sits at 1000 ly here (`AU_PER_LY`, astronomy.ts line 52).
206
+ * Upstream's geocentric step (star vector minus Earth's heliocentric
207
+ * position) is skipped: at 1000 ly it moves the star 0.003″.
208
+ */
209
+ const STAR_DISTANCE_AU = 1000 * 63241.07708807546;
210
+ /**
211
+ * Topocentric azimuth/altitude of a fixed star from its J2000 (ICRS)
212
+ * catalog right ascension and declination, both in degrees.
213
+ *
214
+ * Applied: precession and nutation to the equator of date, Greenwich
215
+ * apparent sidereal time, and atmospheric refraction (upstream `'normal'`
216
+ * model, the same as sunAltAz/moonAltAz). Not applied: annual aberration
217
+ * (at most 20.5″ — see the roadmap), proper motion (pass catalog
218
+ * coordinates; fast movers such as Arcturus drift ~1′ in 26 years), and
219
+ * stellar parallax (negligible at the 1000 ly the star is placed at).
220
+ */
221
+ export function starAltAz(raDeg, decDeg, time, observer) {
222
+ assertSupported(time);
223
+ assertObserver(observer);
224
+ if (!(raDeg >= 0 && raDeg < 360))
225
+ throw new RangeError(`raDeg out of range: ${raDeg}`);
226
+ if (!(decDeg >= -90 && decDeg <= 90))
227
+ throw new RangeError(`decDeg out of range: ${decDeg}`);
228
+ const ut = utDays(time);
229
+ const rCosDec = STAR_DISTANCE_AU * Math.cos(decDeg * DEG2RAD);
230
+ const eqj = {
231
+ x: rCosDec * Math.cos(raDeg * DEG2RAD),
232
+ y: rCosDec * Math.sin(raDeg * DEG2RAD),
233
+ z: STAR_DISTANCE_AU * Math.sin(decDeg * DEG2RAD),
234
+ };
235
+ return refract(topoAltAzUnrefracted(eqj, ut, observer));
236
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwaters/almanac",
3
- "version": "0.2.0",
3
+ "version": "0.3.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" },