@openwaters/almanac 0.4.2 → 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,10 +21,11 @@ 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
30
  Event searches find altitude extrema with Brent's method and altitude crossings
30
31
  with a cosine-seeded secant solver, each root proven inside a half-second
@@ -48,10 +49,10 @@ Reproduce the comparison locally from the repository root with mise 2026.9.1 or
48
49
  ```bash
49
50
  mise install
50
51
  mise exec -- npm ci --prefix typescript
51
- mise exec -- node benchmarks/run.mjs --base v0.4.1
52
+ mise exec -- node benchmarks/run.mjs --base v0.4.1 --skip '^global/'
52
53
  ```
53
54
 
54
- Pass `--skip '^solar/'` when the base predates 0.4.0, which is when the solar eclipse workloads arrived.
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.
55
56
 
56
57
  CI runs the harness on code changes and fails on **median regressions over 20%**.
57
58
  Results include timing tables, raw samples, checksums, and revision/toolchain
@@ -70,7 +71,9 @@ npm install @openwaters/almanac
70
71
  ```
71
72
 
72
73
  ```ts
73
- import { nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, sunEvents, starAltAz } from '@openwaters/almanac';
74
+ import {
75
+ nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, nextGlobalSolarEclipse, sunEvents, starAltAz
76
+ } from '@openwaters/almanac';
74
77
 
75
78
  const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
76
79
 
@@ -82,6 +85,9 @@ const solar = nextSolarEclipse(new Date(), observer);
82
85
  console.log(solar.kind, solar.peak, solar.obscuration, solar.sunAltDeg.peak);
83
86
  console.log(solarObscuration(solar.peak, observer)); // fraction of the Sun's disc covered, 0 to 1
84
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
+
85
91
  const today = new Date();
86
92
  const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
87
93
  for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
@@ -95,7 +101,7 @@ const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
95
101
  ### Swift
96
102
 
97
103
  ```swift
98
- .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")
99
105
  ```
100
106
 
101
107
  ```swift
@@ -112,6 +118,12 @@ let solar = try nextSolarEclipse(after: Date(), observer: observer)
112
118
  print(solar.kind, solar.peak, solar.obscuration, solar.sunAltDeg.peak)
113
119
  print(try solarObscuration(at: solar.peak, observer: observer)) // fraction of the Sun's disc covered, 0 to 1
114
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
+
115
127
  let today = Date()
116
128
  let tomorrow = today.addingTimeInterval(24 * 60 * 60)
117
129
  for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
@@ -142,3 +154,22 @@ extend outside the range. Previous/next searches skip peaks within 100 ms of the
142
154
  anchor. Search results are global; apply `lunarEclipseVisibility` for an observer.
143
155
 
144
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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwaters/almanac",
3
- "version": "0.4.2",
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": {