@openwaters/almanac 0.4.1 → 0.5.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,6 +1,6 @@
1
1
  # Almanac
2
2
 
3
- Offline sky engine for the Salish Sea and everywhere else: Sun and Moon positions, rise, set, twilight, Moon phase, lunar eclipses, solar eclipses for an observer, and fixed-star altitude and azimuth from catalog positions, computed from pure geometry with zero network and zero runtime data files.
3
+ Offline sky engine for the Salish Sea and everywhere else: Sun and Moon positions, rise, set, twilight, Moon phase, lunar eclipses, solar eclipses for an observer and anywhere on Earth, and fixed-star altitude and azimuth from catalog positions, computed from pure geometry with zero network and zero runtime data files.
4
4
 
5
5
  Twin implementations, one behavior:
6
6
 
@@ -21,36 +21,38 @@ Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/ast
21
21
 
22
22
  ## Performance
23
23
 
24
- Almanac includes a shared performance harness for both ports: **22 workloads**
24
+ Almanac includes a shared performance harness for both ports: **27 workloads**
25
25
  cover positions, a 228-hour sky track, short/year/polar event windows, full-range
26
- moon phases, next/previous/range lunar and solar eclipse searches, including empty
27
- windows, and solar obscuration over a 228-hour track.
26
+ moon phases, next/previous/range lunar, solar, and global solar eclipse searches,
27
+ including empty windows, solar obscuration over a 228-hour track, and the central
28
+ line of the 2024-04-08 eclipse.
28
29
 
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:
30
+ Event searches find altitude extrema with Brent's method and altitude crossings
31
+ with a cosine-seeded secant solver, each root proven inside a half-second
32
+ bracket, and the TypeScript Sun series evaluates its terms as straight-line
33
+ arithmetic. Median query time relative to v0.4.1:
33
34
 
34
35
  | Workload | TypeScript: less time | Swift: less time |
35
36
  | --- | ---: | ---: |
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% |
37
+ | Sun events / year | 73.4% | 62.8% |
38
+ | Moon events / year | 57.6% | 57.4% |
39
+ | Sun events / 228 hours | 73.4% | 61.9% |
40
+ | Moon events / 228 hours | 57.1% | 57.0% |
41
+ | Sun positions / 1,024 hours | 35.5% | — |
42
+ | Lunar eclipses / 1950–2100 | 24.6% | — |
42
43
 
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.
44
+ Swift already evaluated the Sun series efficiently, so its gains are confined to
45
+ 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
46
 
45
47
  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
48
 
47
49
  ```bash
48
50
  mise install
49
51
  mise exec -- npm ci --prefix typescript
50
- mise exec -- node benchmarks/run.mjs --base v0.2.0 --skip '^solar/'
52
+ mise exec -- node benchmarks/run.mjs --base v0.4.1 --skip '^global/'
51
53
  ```
52
54
 
53
- The `--skip` pattern leaves out the solar eclipse workloads, which need a base of 0.4.0 or later.
55
+ The `--skip` pattern leaves out the global solar eclipse workloads, which need a base of 0.5.0 or later. Use `--skip '^(solar|global)/'` when the base predates 0.4.0, which is when the solar eclipse workloads arrived.
54
56
 
55
57
  CI runs the harness on code changes and fails on **median regressions over 20%**.
56
58
  Results include timing tables, raw samples, checksums, and revision/toolchain
@@ -69,7 +71,9 @@ npm install @openwaters/almanac
69
71
  ```
70
72
 
71
73
  ```ts
72
- import { nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, sunEvents, starAltAz } from '@openwaters/almanac';
74
+ import {
75
+ nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, nextGlobalSolarEclipse, sunEvents, starAltAz
76
+ } from '@openwaters/almanac';
73
77
 
74
78
  const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
75
79
 
@@ -81,6 +85,9 @@ const solar = nextSolarEclipse(new Date(), observer);
81
85
  console.log(solar.kind, solar.peak, solar.obscuration, solar.sunAltDeg.peak);
82
86
  console.log(solarObscuration(solar.peak, observer)); // fraction of the Sun's disc covered, 0 to 1
83
87
 
88
+ const anywhere = nextGlobalSolarEclipse(new Date()); // no observer: the next solar eclipse on Earth
89
+ console.log(anywhere.kind, anywhere.peak, anywhere.latitudeDeg, anywhere.longitudeDeg); // where the shadow axis lands; null for a partial
90
+
84
91
  const today = new Date();
85
92
  const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
86
93
  for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
@@ -94,7 +101,7 @@ const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
94
101
  ### Swift
95
102
 
96
103
  ```swift
97
- .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.4.0")
104
+ .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.5.0")
98
105
  ```
99
106
 
100
107
  ```swift
@@ -111,6 +118,12 @@ let solar = try nextSolarEclipse(after: Date(), observer: observer)
111
118
  print(solar.kind, solar.peak, solar.obscuration, solar.sunAltDeg.peak)
112
119
  print(try solarObscuration(at: solar.peak, observer: observer)) // fraction of the Sun's disc covered, 0 to 1
113
120
 
121
+ let anywhere = try nextGlobalSolarEclipse(after: Date()) // no observer: the next solar eclipse on Earth
122
+ print(anywhere.kind, anywhere.peak, anywhere.axisDistanceKm)
123
+ if let lat = anywhere.latitudeDeg, let lon = anywhere.longitudeDeg {
124
+ print(lat, lon) // where the shadow axis lands; nil for a partial
125
+ }
126
+
114
127
  let today = Date()
115
128
  let tomorrow = today.addingTimeInterval(24 * 60 * 60)
116
129
  for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
@@ -141,3 +154,22 @@ extend outside the range. Previous/next searches skip peaks within 100 ms of the
141
154
  anchor. Search results are global; apply `lunarEclipseVisibility` for an observer.
142
155
 
143
156
  Solar eclipses are searched for an observer, because their contacts only exist for a place: `nextSolarEclipse(after, observer)`, `previousSolarEclipse(before, observer)`, and `solarEclipses(startUtc, endUtc, observer)`. An eclipse whose Sun is below the horizon at C1, the peak, and C4 is not returned.
157
+
158
+ To ask about the whole Earth instead, `nextGlobalSolarEclipse(after)`, `previousGlobalSolarEclipse(before)`, and `globalSolarEclipses(startUtc, endUtc)` need no observer. Each eclipse reports its greatest eclipse, the shadow axis's distance from the Earth's center, and where the axis meets the ground, with the kind and obscuration seen there. When the axis misses the Earth the eclipse is partial and has no ground point.
159
+
160
+ `solarEclipseCentralLine(peak, stepSeconds)` samples where the axis meets the ground from its first contact to its last, and `solarEclipseAxisPoint(time)` gives that point at any instant. How far the next totality passes from a place is a distance to each point of a line:
161
+
162
+ ```ts
163
+ import { nextGlobalSolarEclipse, solarEclipseCentralLine } from '@openwaters/almanac';
164
+ let next = nextGlobalSolarEclipse(new Date());
165
+ while (next.kind !== 'total') next = nextGlobalSolarEclipse(next.peak);
166
+ const line = solarEclipseCentralLine(next.peak); // every whole minute of the path, and its two ends
167
+ ```
168
+
169
+ ```swift
170
+ var next = try nextGlobalSolarEclipse(after: Date())
171
+ while next.kind != .total { next = try nextGlobalSolarEclipse(after: next.peak) }
172
+ let line = try solarEclipseCentralLine(peak: next.peak) // every whole minute of the path, and its two ends
173
+ ```
174
+
175
+ Points are a median 47 km apart at the default step but hundreds apart beside an end, where the shadow races along the horizon; pass a smaller `stepSeconds` for a finer line.
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
  /**
@@ -0,0 +1,76 @@
1
+ import { SolarEclipseKind } from './solar.js';
2
+ /** A solar eclipse as the whole Earth sees it: greatest eclipse, and where the Moon's shadow axis meets the ground then. */
3
+ export interface GlobalSolarEclipse {
4
+ /** Kind at the point where the shadow axis meets the ground; `partial` when the axis misses the Earth. */
5
+ kind: SolarEclipseKind;
6
+ /** Greatest eclipse: the instant the Moon's shadow axis passes closest to the Earth's center. */
7
+ peak: Date;
8
+ /** Distance from the shadow axis to the Earth's center at the peak, km. */
9
+ axisDistanceKm: number;
10
+ /** Geodetic latitude where the axis meets the ground at the peak, degrees; `null` for a partial eclipse. */
11
+ latitudeDeg: number | null;
12
+ /** Longitude where the axis meets the ground at the peak, degrees east in (−180, 180]; `null` for a partial eclipse. */
13
+ longitudeDeg: number | null;
14
+ /** Fraction of the Sun's disc area covered at that point; exactly 1 for a total eclipse; `null` for a partial eclipse. */
15
+ obscuration: number | null;
16
+ }
17
+ /** A point on a solar eclipse's central line: where the Moon's shadow axis meets the ground at one instant. */
18
+ export interface SolarEclipseAxisPoint {
19
+ time: Date;
20
+ /** Geodetic latitude, degrees. */
21
+ latitudeDeg: number;
22
+ /** Longitude, degrees east in (−180, 180]. */
23
+ longitudeDeg: number;
24
+ /** `total` or `annular` at this point; the kind can change along a hybrid path. */
25
+ kind: 'total' | 'annular';
26
+ /** Fraction of the Sun's disc area covered at this point; exactly 1 for a total eclipse. */
27
+ obscuration: number;
28
+ }
29
+ /**
30
+ * The first solar eclipse anywhere on Earth whose greatest eclipse falls
31
+ * strictly after `after`.
32
+ *
33
+ * Every new moon up to the end of the supported interval is tested: the
34
+ * Moon's ecliptic latitude prunes the ones no shadow can reach, and the rest
35
+ * get a peak-shadow search against the Earth's center. A new moon is an
36
+ * eclipse when the Moon's penumbra reaches the Earth.
37
+ *
38
+ * @throws {AlmanacOutOfRangeError} if `after` is outside the supported
39
+ * interval, or if no eclipse remains before the end of it.
40
+ * @throws {RangeError} if `after` is invalid.
41
+ */
42
+ export declare function nextGlobalSolarEclipse(after: Date): GlobalSolarEclipse;
43
+ /**
44
+ * The last solar eclipse anywhere on Earth whose greatest eclipse falls
45
+ * strictly before `before`, skipping peaks within 100 ms of it, just as
46
+ * `nextGlobalSolarEclipse` does on the other side.
47
+ *
48
+ * @throws {AlmanacOutOfRangeError} if `before` is outside the supported
49
+ * interval, or if no eclipse remains after the start of it.
50
+ * @throws {RangeError} if `before` is invalid.
51
+ */
52
+ export declare function previousGlobalSolarEclipse(before: Date): GlobalSolarEclipse;
53
+ /** Solar eclipses anywhere on Earth with greatest eclipse in `[startUtc, endUtc)`, sorted ascending. */
54
+ export declare function globalSolarEclipses(startUtc: Date, endUtc: Date): GlobalSolarEclipse[];
55
+ /**
56
+ * Where the Moon's shadow axis meets the ground at `time`, with the kind and
57
+ * obscuration a person standing there sees, or `null` when the axis misses
58
+ * the Earth. This is the point `nextGlobalSolarEclipse` reports at greatest
59
+ * eclipse, at any instant.
60
+ *
61
+ * @throws {AlmanacOutOfRangeError} if `time` is outside the supported interval.
62
+ * @throws {RangeError} if `time` is invalid.
63
+ */
64
+ export declare function solarEclipseAxisPoint(time: Date): SolarEclipseAxisPoint | null;
65
+ /**
66
+ * The central line of the solar eclipse in progress at `peak`, normally its
67
+ * greatest eclipse: the axis point at every whole multiple of `stepSeconds`
68
+ * between the first and last ground contact, with the points where the axis
69
+ * grazes the Earth at those two contacts as its ends. Empty when the axis
70
+ * misses the Earth at `peak`, as it does for a partial eclipse.
71
+ *
72
+ * @throws {AlmanacOutOfRangeError} if `peak` is outside the supported interval.
73
+ * @throws {RangeError} if `peak` is invalid, or `stepSeconds` is not a whole
74
+ * number from 1 to 3600.
75
+ */
76
+ export declare function solarEclipseCentralLine(peak: Date, stepSeconds?: number): SolarEclipseAxisPoint[];
@@ -0,0 +1,357 @@
1
+ // L3 solar eclipses anywhere on Earth: the Moon's shadow cone against the
2
+ // Earth's center at every new moon — greatest eclipse, how far the shadow
3
+ // axis passes from the center then, and where the axis meets the ground, with
4
+ // the kind and covered fraction a person standing there sees. The same
5
+ // intersection at any instant gives the axis point, and between the axis's
6
+ // first and last ground contact, the central line.
7
+ //
8
+ // Translated from the cosinekitty/astronomy upstream, pinned sha
9
+ // 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts:
10
+ // EARTH_MEAN_RADIUS_KM (142), MoonShadow (~8481), PeakMoonShadow (~8564),
11
+ // GeoidIntersect (~8842) and SearchGlobalSolarEclipse (~8975).
12
+ // Constants and operation order are preserved so the Swift port can be a
13
+ // line-for-line translation.
14
+ //
15
+ // Four deliberate departures from upstream, all spec-driven:
16
+ // - the new-moon probe is this package's `searchMoonPhase`, whose longitudes
17
+ // are apparent rather than upstream's geometric (see events.ts). It only
18
+ // seeds a ±0.03 d peak search, so the ~40 s difference cannot change a
19
+ // result.
20
+ // - previous and range searches, the fixed whole-UT-day new-moon seed and
21
+ // the 100 ms same-eclipse band come from the lunar port (eclipse.ts);
22
+ // upstream searches forward only.
23
+ // - no twelve-new-moon scan limit: next/previous walk new moons to the
24
+ // supported boundary, as the local search does (solar.ts).
25
+ // - upstream's `Rotation_EQJ_EQD` matrix and its inverse are `gyration` in
26
+ // each direction: the same precession and nutation, applied as two
27
+ // rotations rather than one combined matrix.
28
+ //
29
+ // The central line is not upstream's: its ends are roots of the intersection
30
+ // quadratic's discriminant, and an axis point rejects the crossing on the
31
+ // Sun's side of the Moon that upstream, asking only at greatest eclipse,
32
+ // never meets.
33
+ import { assertSupported, assertSupportedWindowEnd, AlmanacOutOfRangeError, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
34
+ import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
35
+ import { EARTH_EQUATORIAL_RADIUS_KM, EARTH_FLATTENING, EARTH_FLATTENING_SQUARED, KM_PER_AU, RAD2DEG, PrecessDirection, gyration } from './nutation.js';
36
+ import { moonGeoVectorEqj } from './moon.js';
37
+ import { sunGeoVectorEqj } from './sun.js';
38
+ import { siderealDeg } from './transforms.js';
39
+ import { MOON_MEAN_RADIUS_KM, search, searchMoonPhase } from './events.js';
40
+ import { calcShadow, moonEclipticLatitudeDeg, PRUNE_LATITUDE_DEG, SAME_ECLIPSE_MS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP } from './eclipse.js';
41
+ import { MOON_POLAR_RADIUS_KM, eclipseKindFromUmbra, solarEclipseObscuration } from './solar.js';
42
+ /**
43
+ * UPSTREAM: `EARTH_MEAN_RADIUS_KM`, astronomy.ts 142 — the geoid's mean
44
+ * radius. A new moon is an eclipse when the Moon's penumbra reaches this
45
+ * sphere. The lunar port's `EARTH_ECLIPSE_RADIUS_KM` (eclipse.ts) is a
46
+ * different constant: it adds 88 km of atmosphere for the Earth's own shadow.
47
+ */
48
+ const EARTH_MEAN_RADIUS_KM = 6371.0;
49
+ /** Upstream's `PeakMoonShadow` window, in days, either side of the new moon. */
50
+ const PEAK_WINDOW_DAYS = 0.03;
51
+ /** How far either side of a ground point the search for the central line's ends reaches, days: longer than any path lasts. */
52
+ const CONTACT_WINDOW_DAYS = 0.25;
53
+ /** The central line's ends are found to a millisecond: the crossing races along the horizon there. */
54
+ const CONTACT_TOL_SECONDS = 0.001;
55
+ /** Bisection alone would need 25 iterations to take the window down to the tolerance. */
56
+ const CONTACT_ITER_CAP = 50;
57
+ /** The central line's default spacing, seconds: at most 235 points on a path from 1950 through 2100. */
58
+ const DEFAULT_STEP_SECONDS = 60;
59
+ /** The widest spacing a central line accepts, seconds; the narrowest is 1. */
60
+ const MAX_STEP_SECONDS = 3600;
61
+ /**
62
+ * UPSTREAM: `MoonShadow`, astronomy.ts ~8481 — the Moon's shadow cone
63
+ * evaluated at the Earth's center: the lunacentric Earth measured against the
64
+ * heliocentric Moon. Both vectors are EQJ, and `calcShadow` only ever takes
65
+ * dot products and norms of them, so the frame cancels.
66
+ */
67
+ function moonShadow(ut) {
68
+ const tt = ttDaysFromUt(ut);
69
+ // Light-travel and aberration corrected Sun.
70
+ const s = sunGeoVectorEqj(tt);
71
+ // Geocentric Moon.
72
+ const m = moonGeoVectorEqj(tt);
73
+ // Lunacentric Earth.
74
+ const e = { x: -m.x, y: -m.y, z: -m.z };
75
+ // Convert geocentric moon to heliocentric Moon.
76
+ const hm = { x: m.x - s.x, y: m.y - s.y, z: m.z - s.z };
77
+ return calcShadow(MOON_MEAN_RADIUS_KM, ut, e, hm);
78
+ }
79
+ /** UPSTREAM: `ShadowDistanceSlope`, astronomy.ts ~8535, bound to `MoonShadow`. */
80
+ function moonShadowSlope(ut) {
81
+ const dt = 1.0 / 86400.0;
82
+ return (moonShadow(ut + dt).r - moonShadow(ut - dt).r) / dt;
83
+ }
84
+ /**
85
+ * UPSTREAM: `PeakMoonShadow`, astronomy.ts ~8564 — greatest eclipse: the time
86
+ * near the new moon when the Moon's shadow axis passes closest to the Earth's
87
+ * center, i.e. the ascending zero of the axis distance's time derivative.
88
+ */
89
+ function peakMoonShadow(centerUt) {
90
+ // Use the same new-moon seed regardless of search direction or window.
91
+ // Otherwise a ~1 ms root shift can lose/duplicate a peak when a caller
92
+ // splits a range at a previously returned peak. Only unpruned moons pay
93
+ // for this second phase search; its start is a fixed whole UT day.
94
+ const newmoon = searchMoonPhase(0, Math.floor(centerUt) - 1, 4);
95
+ if (newmoon === null)
96
+ throw new Error('almanac internal: cannot refine new moon');
97
+ const ut = search(moonShadowSlope, newmoon - PEAK_WINDOW_DAYS, newmoon + PEAK_WINDOW_DAYS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'peak moon shadow');
98
+ if (ut === null)
99
+ throw new Error('almanac internal: failed to find peak Moon shadow time');
100
+ return moonShadow(ut);
101
+ }
102
+ function dilatedAxis(shadow) {
103
+ // We want to calculate the intersection of the shadow axis with the Earth's geoid.
104
+ // First we must convert EQJ (equator of J2000) coordinates to EQD (equator of date)
105
+ // coordinates that are perfectly aligned with the Earth's equator at this
106
+ // moment in time.
107
+ const tt = ttDaysFromUt(shadow.ut);
108
+ const vd = gyration(shadow.dir, tt, PrecessDirection.From2000); // shadow-axis vector in equator-of-date coordinates
109
+ const ed = gyration(shadow.target, tt, PrecessDirection.From2000); // lunacentric Earth in equator-of-date coordinates
110
+ // Convert all distances from AU to km.
111
+ // But dilate the z-coordinates so that the Earth becomes a perfect sphere.
112
+ // Then find the intersection of the vector with the sphere.
113
+ // See p 184 in Montenbruck & Pfleger's "Astronomy on the Personal Computer", second edition.
114
+ const v = { x: vd.x * KM_PER_AU, y: vd.y * KM_PER_AU, z: vd.z * (KM_PER_AU / EARTH_FLATTENING) };
115
+ const e = { x: ed.x * KM_PER_AU, y: ed.y * KM_PER_AU, z: ed.z * (KM_PER_AU / EARTH_FLATTENING) };
116
+ // Solve the quadratic equation that finds whether and where
117
+ // the shadow axis intersects with the Earth in the dilated coordinate system.
118
+ const R = EARTH_EQUATORIAL_RADIUS_KM;
119
+ const A = v.x * v.x + v.y * v.y + v.z * v.z;
120
+ const B = -2.0 * (v.x * e.x + v.y * e.y + v.z * e.z);
121
+ const C = (e.x * e.x + e.y * e.y + e.z * e.z) - R * R;
122
+ const radic = B * B - 4 * A * C;
123
+ return { tt, v, e, A, B, radic };
124
+ }
125
+ /**
126
+ * UPSTREAM: the second half of `GeoidIntersect`, astronomy.ts ~8842 — the
127
+ * geodetic point at parameter `u` along the dilated axis, and the kind and
128
+ * covered fraction a person standing there sees.
129
+ */
130
+ function axisGround(shadow, axis, u) {
131
+ const { tt, v, e } = axis;
132
+ // Convert lunacentric dilated coordinates to geocentric coordinates.
133
+ const px = u * v.x - e.x;
134
+ const py = u * v.y - e.y;
135
+ const pz = (u * v.z - e.z) * EARTH_FLATTENING;
136
+ // Convert cartesian coordinates into geodetic latitude/longitude.
137
+ const proj = Math.hypot(px, py) * EARTH_FLATTENING_SQUARED;
138
+ let latitudeDeg;
139
+ if (proj == 0.0)
140
+ latitudeDeg = (pz > 0.0) ? +90.0 : -90.0;
141
+ else
142
+ latitudeDeg = RAD2DEG * Math.atan(pz / proj);
143
+ // Adjust longitude for Earth's rotation at the given UT. `siderealDeg`
144
+ // is upstream's `15 * sidereal_time`, already in degrees.
145
+ let longitudeDeg = (RAD2DEG * Math.atan2(py, px) - siderealDeg(shadow.ut)) % 360.0;
146
+ if (longitudeDeg <= -180.0)
147
+ longitudeDeg += 360.0;
148
+ else if (longitudeDeg > +180.0)
149
+ longitudeDeg -= 360.0;
150
+ // We want to determine whether the observer sees a total eclipse or an annular eclipse.
151
+ // Put the EQD geocentric coordinates of the observer back into AU, and
152
+ // rotate them back to the EQJ system.
153
+ const og = gyration({ x: px / KM_PER_AU, y: py / KM_PER_AU, z: pz / KM_PER_AU }, tt, PrecessDirection.Into2000);
154
+ // Convert geocentric vector to lunacentric vector.
155
+ const o = { x: og.x + shadow.target.x, y: og.y + shadow.target.y, z: og.z + shadow.target.z };
156
+ // Recalculate the shadow using a vector from the Moon's center toward the observer.
157
+ const surface = calcShadow(MOON_POLAR_RADIUS_KM, shadow.ut, o, shadow.dir);
158
+ // If we did everything right, the shadow distance should be very close to zero.
159
+ // That's because we already determined the observer 'o' is on the shadow axis!
160
+ // Upstream's bound is 1e-9 km; exceeding it is an implementation failure.
161
+ if (surface.r > 1.0e-9 || surface.r < 0.0)
162
+ throw new Error(`almanac internal: unexpected shadow distance from geoid intersection = ${surface.r}`);
163
+ const kind = eclipseKindFromUmbra(surface.k);
164
+ const obscuration = (kind === 'total') ? 1.0 : solarEclipseObscuration(shadow.dir, o);
165
+ return { latitudeDeg, longitudeDeg, kind, obscuration };
166
+ }
167
+ /**
168
+ * UPSTREAM: `GeoidIntersect`'s choice of root — where the axis first meets
169
+ * the geoid on its way from the Moon, on the day side of the Earth, or null
170
+ * when it misses. Upstream only asks at a new moon's greatest eclipse. At an
171
+ * arbitrary instant the line through the Sun and Moon can also cross the
172
+ * Earth on the Sun's side of the Moon, near a full moon, where the Moon casts
173
+ * no shadow on it: both roots are then negative.
174
+ */
175
+ function nearAxisGround(shadow) {
176
+ const axis = dilatedAxis(shadow);
177
+ if (!(axis.radic > 0.0))
178
+ return null;
179
+ // Calculate the closer of the two intersection points.
180
+ // This will be on the day side of the Earth.
181
+ const u = (-axis.B - Math.sqrt(axis.radic)) / (2 * axis.A);
182
+ if (!(u > 0.0))
183
+ return null;
184
+ return axisGround(shadow, axis, u);
185
+ }
186
+ /**
187
+ * Where the axis grazes the geoid at a ground contact: its closest approach to
188
+ * the geoid's center, which moves smoothly through the contact while the
189
+ * crossing itself races along the horizon.
190
+ */
191
+ function grazingGround(shadow) {
192
+ const axis = dilatedAxis(shadow);
193
+ return axisGround(shadow, axis, -axis.B / (2 * axis.A));
194
+ }
195
+ /**
196
+ * UPSTREAM: `GeoidIntersect`, astronomy.ts ~8842 — where the shadow axis at
197
+ * greatest eclipse meets the Earth's oblate geoid, and the kind and covered
198
+ * fraction a person standing there sees. An axis that misses the geoid is a
199
+ * partial eclipse with no ground point.
200
+ */
201
+ function geoidIntersect(shadow, peak) {
202
+ const ground = nearAxisGround(shadow);
203
+ if (ground === null) {
204
+ // This is a partial solar eclipse, and an obscuration needs a place:
205
+ // `solarEclipses` and `solarObscuration` take an observer.
206
+ return { kind: 'partial', peak, axisDistanceKm: shadow.r, latitudeDeg: null, longitudeDeg: null, obscuration: null };
207
+ }
208
+ return {
209
+ kind: ground.kind, peak, axisDistanceKm: shadow.r,
210
+ latitudeDeg: ground.latitudeDeg, longitudeDeg: ground.longitudeDeg, obscuration: ground.obscuration
211
+ };
212
+ }
213
+ /**
214
+ * The first solar eclipse anywhere on Earth whose greatest eclipse falls
215
+ * strictly after `after`.
216
+ *
217
+ * Every new moon up to the end of the supported interval is tested: the
218
+ * Moon's ecliptic latitude prunes the ones no shadow can reach, and the rest
219
+ * get a peak-shadow search against the Earth's center. A new moon is an
220
+ * eclipse when the Moon's penumbra reaches the Earth.
221
+ *
222
+ * @throws {AlmanacOutOfRangeError} if `after` is outside the supported
223
+ * interval, or if no eclipse remains before the end of it.
224
+ * @throws {RangeError} if `after` is invalid.
225
+ */
226
+ export function nextGlobalSolarEclipse(after) {
227
+ return nearestGlobalSolarEclipse(after, 1);
228
+ }
229
+ /**
230
+ * The last solar eclipse anywhere on Earth whose greatest eclipse falls
231
+ * strictly before `before`, skipping peaks within 100 ms of it, just as
232
+ * `nextGlobalSolarEclipse` does on the other side.
233
+ *
234
+ * @throws {AlmanacOutOfRangeError} if `before` is outside the supported
235
+ * interval, or if no eclipse remains after the start of it.
236
+ * @throws {RangeError} if `before` is invalid.
237
+ */
238
+ export function previousGlobalSolarEclipse(before) {
239
+ return nearestGlobalSolarEclipse(before, -1);
240
+ }
241
+ /** Solar eclipses anywhere on Earth with greatest eclipse in `[startUtc, endUtc)`, sorted ascending. */
242
+ export function globalSolarEclipses(startUtc, endUtc) {
243
+ assertSupported(startUtc);
244
+ assertSupportedWindowEnd(endUtc);
245
+ return scanGlobalSolarEclipses(startUtc.getTime(), endUtc.getTime(), 1, false);
246
+ }
247
+ function nearestGlobalSolarEclipse(anchor, direction) {
248
+ assertSupported(anchor);
249
+ const ms = anchor.getTime();
250
+ // No scan limit: walk to the supported boundary, as the local search does.
251
+ // At least two solar eclipses happen every year, so only the last and
252
+ // first few months of the interval walk far, and a pruned new moon is cheap.
253
+ const startMs = direction > 0 ? ms + SAME_ECLIPSE_MS + 1 : SUPPORTED_MIN;
254
+ const endMs = direction > 0 ? SUPPORTED_MAX : ms - SAME_ECLIPSE_MS;
255
+ const found = scanGlobalSolarEclipses(startMs, endMs, direction, true);
256
+ if (found.length)
257
+ return found[0];
258
+ throw new AlmanacOutOfRangeError();
259
+ }
260
+ function scanGlobalSolarEclipses(startMs, endMs, direction, firstOnly) {
261
+ const found = [];
262
+ if (startMs >= endMs)
263
+ return found;
264
+ // Peak and new moon differ by up to the peak window. Include the entire
265
+ // margin at both ends, then apply the caller's bounds to the reported peak.
266
+ const startUt = utDays(new Date(startMs)) - PEAK_WINDOW_DAYS;
267
+ const endUt = utDays(new Date(endMs)) + PEAK_WINDOW_DAYS;
268
+ let nmUt = direction > 0 ? startUt : endUt;
269
+ const limitUt = direction > 0 ? endUt : startUt;
270
+ while (direction * (limitUt - nmUt) > 0) {
271
+ const newmoon = searchMoonPhase(0, nmUt, direction * Math.min(40, Math.abs(limitUt - nmUt)));
272
+ if (newmoon === null)
273
+ break;
274
+ // UPSTREAM `SearchGlobalSolarEclipse`: step past this new moon before
275
+ // the next probe, so the same one cannot be found twice.
276
+ nmUt = newmoon + direction * 10;
277
+ // Pruning: if the new moon's ecliptic latitude is too large, a solar
278
+ // eclipse is not possible.
279
+ if (Math.abs(moonEclipticLatitudeDeg(newmoon)) >= PRUNE_LATITUDE_DEG)
280
+ continue;
281
+ // Search near the new moon for the time when the center of the Earth
282
+ // is closest to the line passing through the centers of the Sun and Moon.
283
+ const shadow = peakMoonShadow(newmoon);
284
+ if (shadow.r >= shadow.p + EARTH_MEAN_RADIUS_KM)
285
+ continue; // the penumbra misses the Earth
286
+ const peak = dateFromUt(shadow.ut);
287
+ if (peak.getTime() < startMs || peak.getTime() >= endMs)
288
+ continue;
289
+ // This is at least a partial solar eclipse visible somewhere on Earth.
290
+ // Try to find an intersection between the shadow axis and the Earth's oblate geoid.
291
+ found.push(geoidIntersect(shadow, peak));
292
+ if (firstOnly)
293
+ break;
294
+ }
295
+ return found;
296
+ }
297
+ /**
298
+ * Where the Moon's shadow axis meets the ground at `time`, with the kind and
299
+ * obscuration a person standing there sees, or `null` when the axis misses
300
+ * the Earth. This is the point `nextGlobalSolarEclipse` reports at greatest
301
+ * eclipse, at any instant.
302
+ *
303
+ * @throws {AlmanacOutOfRangeError} if `time` is outside the supported interval.
304
+ * @throws {RangeError} if `time` is invalid.
305
+ */
306
+ export function solarEclipseAxisPoint(time) {
307
+ assertSupported(time);
308
+ const ground = nearAxisGround(moonShadow(utDays(time)));
309
+ return ground === null ? null : { time: new Date(time.getTime()), ...ground };
310
+ }
311
+ /**
312
+ * The central line of the solar eclipse in progress at `peak`, normally its
313
+ * greatest eclipse: the axis point at every whole multiple of `stepSeconds`
314
+ * between the first and last ground contact, with the points where the axis
315
+ * grazes the Earth at those two contacts as its ends. Empty when the axis
316
+ * misses the Earth at `peak`, as it does for a partial eclipse.
317
+ *
318
+ * @throws {AlmanacOutOfRangeError} if `peak` is outside the supported interval.
319
+ * @throws {RangeError} if `peak` is invalid, or `stepSeconds` is not a whole
320
+ * number from 1 to 3600.
321
+ */
322
+ export function solarEclipseCentralLine(peak, stepSeconds = DEFAULT_STEP_SECONDS) {
323
+ assertSupported(peak);
324
+ if (!(Number.isInteger(stepSeconds) && stepSeconds >= 1 && stepSeconds <= MAX_STEP_SECONDS))
325
+ throw new RangeError(`stepSeconds must be a whole number from 1 to ${MAX_STEP_SECONDS}: ${stepSeconds}`);
326
+ const peakUt = utDays(peak);
327
+ if (nearAxisGround(moonShadow(peakUt)) === null)
328
+ return [];
329
+ const first = dateFromUt(groundContactUt(peakUt, -1));
330
+ const last = dateFromUt(groundContactUt(peakUt, +1));
331
+ const point = (time, ground) => ({ time, ...ground });
332
+ const line = [point(first, grazingGround(moonShadow(utDays(first))))];
333
+ const stepMs = stepSeconds * 1000;
334
+ for (let ms = (Math.floor(first.getTime() / stepMs) + 1) * stepMs; ms < last.getTime(); ms += stepMs) {
335
+ const time = new Date(ms);
336
+ const ground = nearAxisGround(moonShadow(utDays(time)));
337
+ // A step within the contacts' millisecond tolerance can fall just outside the path.
338
+ if (ground !== null)
339
+ line.push(point(time, ground));
340
+ }
341
+ if (last.getTime() > first.getTime())
342
+ line.push(point(last, grazingGround(moonShadow(utDays(last)))));
343
+ return line;
344
+ }
345
+ /**
346
+ * The ground contact before (`direction` −1) or after (+1) `ut`: where the
347
+ * discriminant of the intersection quadratic crosses zero, so the axis
348
+ * touches the geoid. Every central path is shorter than the search window.
349
+ */
350
+ function groundContactUt(ut, direction) {
351
+ const t1 = direction < 0 ? ut - CONTACT_WINDOW_DAYS : ut;
352
+ const t2 = direction < 0 ? ut : ut + CONTACT_WINDOW_DAYS;
353
+ const contact = search(u => -direction * dilatedAxis(moonShadow(u)).radic, t1, t2, CONTACT_TOL_SECONDS, CONTACT_ITER_CAP, 'central line contact');
354
+ if (contact === null)
355
+ throw new Error('almanac internal: failed to find a ground contact of the shadow axis');
356
+ return contact;
357
+ }
package/dist/index.d.ts CHANGED
@@ -12,3 +12,5 @@ export type { LunarEclipse, LunarEclipseVisibility } from './eclipse.js';
12
12
  export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
13
13
  export type { SolarEclipse, SolarEclipseKind, SolarEclipseSunAltitudes } from './solar.js';
14
14
  export { nextSolarEclipse, previousSolarEclipse, solarEclipses, solarObscuration } from './solar.js';
15
+ export type { GlobalSolarEclipse, SolarEclipseAxisPoint } from './globalSolar.js';
16
+ export { nextGlobalSolarEclipse, previousGlobalSolarEclipse, globalSolarEclipses, solarEclipseAxisPoint, solarEclipseCentralLine } from './globalSolar.js';
package/dist/index.js CHANGED
@@ -5,3 +5,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';
7
7
  export { nextSolarEclipse, previousSolarEclipse, solarEclipses, solarObscuration } from './solar.js';
8
+ export { nextGlobalSolarEclipse, previousGlobalSolarEclipse, globalSolarEclipses, solarEclipseAxisPoint, solarEclipseCentralLine } from './globalSolar.js';
package/dist/solar.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Observer } from './types.js';
2
+ import { Vec3 } from './nutation.js';
2
3
  /** How much of the Sun an observer sees covered: `partial` when the Moon never covers it, `annular` when the Moon sits inside the Sun's disc, `total` when it covers it. */
3
4
  export type SolarEclipseKind = 'partial' | 'annular' | 'total';
4
5
  /** The Sun's refracted topocentric altitude at each contact, degrees; `null` exactly where the eclipse has no such contact. */
@@ -27,6 +28,7 @@ export interface SolarEclipse {
27
28
  /** The number `sunAltAz` reports at each contact instant, so "above the horizon" agrees with the Sun a consumer draws. */
28
29
  sunAltDeg: SolarEclipseSunAltitudes;
29
30
  }
31
+ export declare const MOON_POLAR_RADIUS_KM = 1736;
30
32
  /**
31
33
  * The fraction of the Sun's disc area the Moon covers for `observer` at
32
34
  * `time`: 0 when the discs are apart, 1 in totality, the ratio of the disc
@@ -38,6 +40,20 @@ export interface SolarEclipse {
38
40
  * @throws {RangeError} if `time` is invalid or `observer` is out of range.
39
41
  */
40
42
  export declare function solarObscuration(time: Date, observer: Observer): number;
43
+ /**
44
+ * UPSTREAM: `SolarEclipseObscuration`, astronomy.ts ~8670, with its clamp:
45
+ * "in marginal cases, we need to clamp obscuration to less than 1.0. This
46
+ * function is never called for total eclipses, so it should never return 1.0."
47
+ * INTERNAL: shared with globalSolar.ts.
48
+ */
49
+ export declare function solarEclipseObscuration(hm: Vec3, lo: Vec3): number;
50
+ /**
51
+ * UPSTREAM: `EclipseKindFromUmbra`, astronomy.ts ~8834 — a positive umbra
52
+ * radius at the observer is a total eclipse, otherwise annular. The 14 m
53
+ * bias is upstream's, added to match Espenak's classifications.
54
+ * INTERNAL: shared with globalSolar.ts.
55
+ */
56
+ export declare function eclipseKindFromUmbra(k: number): 'total' | 'annular';
41
57
  /**
42
58
  * The first solar eclipse `observer` can see whose peak falls strictly after
43
59
  * `after`.
package/dist/solar.js CHANGED
@@ -35,9 +35,10 @@ import { sunGeoVectorEqj } from './sun.js';
35
35
  import { observerGeoVectorEqj, refractionDeg, topoAltAzUnrefracted } from './transforms.js';
36
36
  import { MOON_MEAN_RADIUS_KM, SUN_RADIUS_KM, search, searchMoonPhase } from './events.js';
37
37
  import { calcShadow, moonEclipticLatitudeDeg, PRUNE_LATITUDE_DEG, SAME_ECLIPSE_MS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP } from './eclipse.js';
38
- /** UPSTREAM: `SUN_RADIUS_AU` and `MOON_POLAR_RADIUS_AU`, astronomy.ts 135 and 149-150. */
38
+ /** UPSTREAM: `SUN_RADIUS_AU` and `MOON_POLAR_RADIUS_AU`, astronomy.ts 135 and 149-150.
39
+ * INTERNAL: `MOON_POLAR_RADIUS_KM` is shared with globalSolar.ts. */
39
40
  const SUN_RADIUS_AU = SUN_RADIUS_KM / KM_PER_AU;
40
- const MOON_POLAR_RADIUS_KM = 1736.0;
41
+ export const MOON_POLAR_RADIUS_KM = 1736.0;
41
42
  const MOON_POLAR_RADIUS_AU = MOON_POLAR_RADIUS_KM / KM_PER_AU;
42
43
  /** Upstream's `PeakLocalMoonShadow` window, in days, either side of the new moon. */
43
44
  const PEAK_WINDOW_DAYS = 0.2;
@@ -154,8 +155,9 @@ export function solarObscuration(time, observer) {
154
155
  * UPSTREAM: `SolarEclipseObscuration`, astronomy.ts ~8670, with its clamp:
155
156
  * "in marginal cases, we need to clamp obscuration to less than 1.0. This
156
157
  * function is never called for total eclipses, so it should never return 1.0."
158
+ * INTERNAL: shared with globalSolar.ts.
157
159
  */
158
- function solarEclipseObscuration(hm, lo) {
160
+ export function solarEclipseObscuration(hm, lo) {
159
161
  return Math.min(0.9999, discObscuration(hm, lo));
160
162
  }
161
163
  /** UPSTREAM: `ShadowDistanceSlope`, astronomy.ts ~8535, bound to `localMoonShadow`. */
@@ -185,8 +187,9 @@ function peakLocalMoonShadow(centerUt, observer) {
185
187
  * UPSTREAM: `EclipseKindFromUmbra`, astronomy.ts ~8834 — a positive umbra
186
188
  * radius at the observer is a total eclipse, otherwise annular. The 14 m
187
189
  * bias is upstream's, added to match Espenak's classifications.
190
+ * INTERNAL: shared with globalSolar.ts.
188
191
  */
189
- function eclipseKindFromUmbra(k) {
192
+ export function eclipseKindFromUmbra(k) {
190
193
  return (k > 0.014) ? 'total' : 'annular';
191
194
  }
192
195
  /** UPSTREAM: `local_partial_distance`, astronomy.ts ~9126. */
package/dist/sun.d.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  import { Vec3 } from './nutation.js';
2
+ export type VsopFormula = number[][][];
3
+ type VsopModel = [VsopFormula, VsopFormula, VsopFormula];
4
+ /**
5
+ * UPSTREAM: `vsop.Earth`, astronomy.ts lines 555-628 (longitude, latitude,
6
+ * radius). The reference the unrolled `earthLon`/`earthLat`/`earthRad`
7
+ * below are checked against; exported for that test only.
8
+ */
9
+ export declare const vsopEarth: VsopModel;
2
10
  /**
3
11
  * UPSTREAM: `CalcVsop`, astronomy.ts lines 3255-3262, specialised to
4
12
  * `vsop.Earth` — i.e. `HelioVector(Body.Earth, time)`. J2000 mean equator (EQJ).
@@ -25,3 +33,4 @@ export declare function sunGeoVectorEqj(tt: number): Vec3;
25
33
  * @returns geocentric position in AU, true equator & equinox of date.
26
34
  */
27
35
  export declare function sunGeoVector(tt: number): Vec3;
36
+ export {};
package/dist/sun.js CHANGED
@@ -8,8 +8,12 @@
8
8
  //
9
9
  // INTERNAL: not part of the curated public API (see index.ts).
10
10
  import { C_AUDAY, PI2, PrecessDirection, gyration } from './nutation.js';
11
- /** UPSTREAM: `vsop.Earth`, astronomy.ts lines 555-628 (longitude, latitude, radius). */
12
- const vsopEarth = [
11
+ /**
12
+ * UPSTREAM: `vsop.Earth`, astronomy.ts lines 555-628 (longitude, latitude,
13
+ * radius). The reference the unrolled `earthLon`/`earthLat`/`earthRad`
14
+ * below are checked against; exported for that test only.
15
+ */
16
+ export const vsopEarth = [
13
17
  [
14
18
  [
15
19
  [1.75347045673, 0.00000000000, 0.00000000000],
@@ -85,23 +89,118 @@ const vsopEarth = [
85
89
  ];
86
90
  /** UPSTREAM: astronomy.ts lines 3229-3232. */
87
91
  const DAYS_PER_MILLENNIUM = 365250;
88
- const LON_INDEX = 0;
89
- const LAT_INDEX = 1;
90
- const RAD_INDEX = 2;
91
- /** UPSTREAM: `VsopFormula`, astronomy.ts lines 3191-3205. */
92
- function vsopFormula(formula, t, clamp_angle) {
92
+ /**
93
+ * UPSTREAM: `VsopFormula`, astronomy.ts lines 3191-3205, written out term by
94
+ * term for `vsop.Earth` because the event searches evaluate it tens of
95
+ * thousands of times. Term order, per-series scaling, clamping and
96
+ * accumulation match upstream's loop, so the result is bit-for-bit the same;
97
+ * `test/positions.test.ts` checks that against the table above.
98
+ */
99
+ function earthLon(t) {
93
100
  let tpower = 1;
94
101
  let coord = 0;
95
- for (let series of formula) {
96
- let sum = 0;
97
- for (let [ampl, phas, freq] of series)
98
- sum += ampl * Math.cos(phas + (t * freq));
99
- let incr = tpower * sum;
100
- if (clamp_angle)
101
- incr %= PI2; // improve precision for longitudes: they can be hundreds of radians
102
- coord += incr;
103
- tpower *= t;
104
- }
102
+ let sum;
103
+ let incr;
104
+ sum = 0;
105
+ sum += 1.75347045673; // cos(0) is 1 exactly
106
+ sum += 0.03341656453 * Math.cos(4.66925680415 + (t * 6283.07584999140));
107
+ sum += 0.00034894275 * Math.cos(4.62610242189 + (t * 12566.15169998280));
108
+ sum += 0.00003417572 * Math.cos(2.82886579754 + (t * 3.52311834900));
109
+ sum += 0.00003497056 * Math.cos(2.74411783405 + (t * 5753.38488489680));
110
+ sum += 0.00003135899 * Math.cos(3.62767041756 + (t * 77713.77146812050));
111
+ sum += 0.00002676218 * Math.cos(4.41808345438 + (t * 7860.41939243920));
112
+ sum += 0.00002342691 * Math.cos(6.13516214446 + (t * 3930.20969621960));
113
+ sum += 0.00001273165 * Math.cos(2.03709657878 + (t * 529.69096509460));
114
+ sum += 0.00001324294 * Math.cos(0.74246341673 + (t * 11506.76976979360));
115
+ sum += 0.00000901854 * Math.cos(2.04505446477 + (t * 26.29831979980));
116
+ sum += 0.00001199167 * Math.cos(1.10962946234 + (t * 1577.34354244780));
117
+ sum += 0.00000857223 * Math.cos(3.50849152283 + (t * 398.14900340820));
118
+ sum += 0.00000779786 * Math.cos(1.17882681962 + (t * 5223.69391980220));
119
+ sum += 0.00000990250 * Math.cos(5.23268072088 + (t * 5884.92684658320));
120
+ sum += 0.00000753141 * Math.cos(2.53339052847 + (t * 5507.55323866740));
121
+ sum += 0.00000505267 * Math.cos(4.58292599973 + (t * 18849.22754997420));
122
+ sum += 0.00000492392 * Math.cos(4.20505711826 + (t * 775.52261132400));
123
+ sum += 0.00000356672 * Math.cos(2.91954114478 + (t * 0.06731030280));
124
+ sum += 0.00000284125 * Math.cos(1.89869240932 + (t * 796.29800681640));
125
+ sum += 0.00000242879 * Math.cos(0.34481445893 + (t * 5486.77784317500));
126
+ sum += 0.00000317087 * Math.cos(5.84901948512 + (t * 11790.62908865880));
127
+ sum += 0.00000271112 * Math.cos(0.31486255375 + (t * 10977.07880469900));
128
+ sum += 0.00000206217 * Math.cos(4.80646631478 + (t * 2544.31441988340));
129
+ sum += 0.00000205478 * Math.cos(1.86953770281 + (t * 5573.14280143310));
130
+ sum += 0.00000202318 * Math.cos(2.45767790232 + (t * 6069.77675455340));
131
+ sum += 0.00000126225 * Math.cos(1.08295459501 + (t * 20.77539549240));
132
+ sum += 0.00000155516 * Math.cos(0.83306084617 + (t * 213.29909543800));
133
+ incr = tpower * sum;
134
+ incr %= PI2;
135
+ coord += incr;
136
+ tpower *= t;
137
+ sum = 0;
138
+ sum += 6283.07584999140; // cos(0) is 1 exactly
139
+ sum += 0.00206058863 * Math.cos(2.67823455808 + (t * 6283.07584999140));
140
+ sum += 0.00004303419 * Math.cos(2.63512233481 + (t * 12566.15169998280));
141
+ incr = tpower * sum;
142
+ incr %= PI2;
143
+ coord += incr;
144
+ tpower *= t;
145
+ sum = 0;
146
+ sum += 0.00008721859 * Math.cos(1.07253635559 + (t * 6283.07584999140));
147
+ incr = tpower * sum;
148
+ incr %= PI2;
149
+ coord += incr;
150
+ tpower *= t;
151
+ return coord;
152
+ }
153
+ function earthLat(t) {
154
+ let tpower = 1;
155
+ let coord = 0;
156
+ let sum;
157
+ let incr;
158
+ sum = 0;
159
+ incr = tpower * sum;
160
+ coord += incr;
161
+ tpower *= t;
162
+ sum = 0;
163
+ sum += 0.00227777722 * Math.cos(3.41376620530 + (t * 6283.07584999140));
164
+ sum += 0.00003805678 * Math.cos(3.37063423795 + (t * 12566.15169998280));
165
+ incr = tpower * sum;
166
+ coord += incr;
167
+ tpower *= t;
168
+ return coord;
169
+ }
170
+ function earthRad(t) {
171
+ let tpower = 1;
172
+ let coord = 0;
173
+ let sum;
174
+ let incr;
175
+ sum = 0;
176
+ sum += 1.00013988784; // cos(0) is 1 exactly
177
+ sum += 0.01670699632 * Math.cos(3.09846350258 + (t * 6283.07584999140));
178
+ sum += 0.00013956024 * Math.cos(3.05524609456 + (t * 12566.15169998280));
179
+ sum += 0.00003083720 * Math.cos(5.19846674381 + (t * 77713.77146812050));
180
+ sum += 0.00001628463 * Math.cos(1.17387558054 + (t * 5753.38488489680));
181
+ sum += 0.00001575572 * Math.cos(2.84685214877 + (t * 7860.41939243920));
182
+ sum += 0.00000924799 * Math.cos(5.45292236722 + (t * 11506.76976979360));
183
+ sum += 0.00000542439 * Math.cos(4.56409151453 + (t * 3930.20969621960));
184
+ sum += 0.00000472110 * Math.cos(3.66100022149 + (t * 5884.92684658320));
185
+ sum += 0.00000085831 * Math.cos(1.27079125277 + (t * 161000.68573767410));
186
+ sum += 0.00000057056 * Math.cos(2.01374292245 + (t * 83996.84731811189));
187
+ sum += 0.00000055736 * Math.cos(5.24159799170 + (t * 71430.69561812909));
188
+ sum += 0.00000174844 * Math.cos(3.01193636733 + (t * 18849.22754997420));
189
+ sum += 0.00000243181 * Math.cos(4.27349530790 + (t * 11790.62908865880));
190
+ incr = tpower * sum;
191
+ coord += incr;
192
+ tpower *= t;
193
+ sum = 0;
194
+ sum += 0.00103018607 * Math.cos(1.10748968172 + (t * 6283.07584999140));
195
+ sum += 0.00001721238 * Math.cos(1.06442300386 + (t * 12566.15169998280));
196
+ incr = tpower * sum;
197
+ coord += incr;
198
+ tpower *= t;
199
+ sum = 0;
200
+ sum += 0.00004359385 * Math.cos(5.78455133808 + (t * 6283.07584999140));
201
+ incr = tpower * sum;
202
+ coord += incr;
203
+ tpower *= t;
105
204
  return coord;
106
205
  }
107
206
  /** UPSTREAM: `VsopRotate`, astronomy.ts lines 3234-3241 — VSOP ecliptic to J2000 equatorial. */
@@ -130,9 +229,9 @@ function vsopSphereToRect(lon, lat, radius) {
130
229
  */
131
230
  export function earthHelioVector(tt) {
132
231
  const t = tt / DAYS_PER_MILLENNIUM; // millennia since 2000
133
- const lon = vsopFormula(vsopEarth[LON_INDEX], t, true);
134
- const lat = vsopFormula(vsopEarth[LAT_INDEX], t, false);
135
- const rad = vsopFormula(vsopEarth[RAD_INDEX], t, false);
232
+ const lon = earthLon(t);
233
+ const lat = earthLat(t);
234
+ const rad = earthRad(t);
136
235
  const eclip = vsopSphereToRect(lon, lat, rad);
137
236
  return vsopRotate(eclip);
138
237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwaters/almanac",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Offline sun & moon engine: positions, rise/set, moon phase, lunar and solar eclipses.",
5
5
  "license": "MIT",
6
6
  "repository": {