@openwaters/almanac 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -7
- package/dist/eclipse.d.ts +12 -0
- package/dist/eclipse.js +77 -45
- package/dist/events.d.ts +2 -2
- package/dist/events.js +61 -19
- package/dist/illumination.js +5 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/moon.js +7 -29
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -15,11 +15,55 @@ Supported interval: 1950-01-01T00:00Z ≤ t < 2101-01-01T00:00Z; results outside
|
|
|
15
15
|
raise a typed error. All instants are UT1-accurate, not civil-UTC-accurate in the far
|
|
16
16
|
future — see the design spec's Conventions section for what that means and why.
|
|
17
17
|
|
|
18
|
-
Design: [`docs/superpowers/specs/2026-08-28-almanac-v1-design.md`](docs/superpowers/specs/2026-08-28-almanac-v1-design.md)
|
|
18
|
+
- Design: [`docs/superpowers/specs/2026-08-28-almanac-v1-design.md`](docs/superpowers/specs/2026-08-28-almanac-v1-design.md)
|
|
19
|
+
- Roadmap: [`docs/ROADMAP.md`](docs/ROADMAP.md) — what ships next, and what will not.
|
|
20
|
+
- Landing page: [openwaters.io/sky](https://openwaters.io/sky) — the library running live in a browser.
|
|
19
21
|
|
|
20
22
|
Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
|
21
23
|
(MIT, Don Cross) — see [NOTICE](NOTICE). MIT licensed.
|
|
22
24
|
|
|
25
|
+
## Performance
|
|
26
|
+
|
|
27
|
+
Almanac includes a shared performance harness for both ports: **15 workloads**
|
|
28
|
+
cover positions, a 228-hour sky track, short/year/polar event windows, full-range
|
|
29
|
+
moon phases, and next/previous/range eclipse searches, including empty windows.
|
|
30
|
+
|
|
31
|
+
The v0.2.1 optimizations reuse Moon and shadow geometry, flatten scratch arrays,
|
|
32
|
+
and refine altitude crossings with fewer position evaluations. In the
|
|
33
|
+
[hosted CI comparison](https://github.com/openwatersio/almanac/actions/runs/34119038860),
|
|
34
|
+
median query time fell relative to the v0.2.0 astronomy code:
|
|
35
|
+
|
|
36
|
+
| Workload | TypeScript: less time | Swift: less time |
|
|
37
|
+
| --- | ---: | ---: |
|
|
38
|
+
| Moon positions / 1,024 hours | 29.8% | 22.1% |
|
|
39
|
+
| Sky track / 228 hours | 40.0% | 42.4% |
|
|
40
|
+
| Sun events / 228 hours | 39.3% | 40.2% |
|
|
41
|
+
| Moon events / 228 hours | 39.1% | 35.1% |
|
|
42
|
+
| Previous lunar eclipse | 26.0% | 36.6% |
|
|
43
|
+
| Lunar eclipses / 1950–2100 | 26.4% | 23.4% |
|
|
44
|
+
|
|
45
|
+
TypeScript was measured on Ubuntu with Node 22; Swift used release builds on
|
|
46
|
+
macOS 15. Each comparison builds both revisions with the same harness and
|
|
47
|
+
toolchain, then takes seven interleaved process pairs with 300 ms warmup per
|
|
48
|
+
process. Build and startup time are excluded. Timings vary by machine; the shared
|
|
49
|
+
correctness fixtures and parity tolerances remain the accuracy gates.
|
|
50
|
+
|
|
51
|
+
Reproduce the comparison locally from the repository root (Node 22+ and Swift
|
|
52
|
+
5.9+):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm ci --prefix typescript
|
|
56
|
+
node benchmarks/run.mjs --base v0.2.0
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
CI runs the harness on code changes and fails on **median regressions over 20%**.
|
|
60
|
+
Results include timing tables, raw samples, checksums, and revision/toolchain
|
|
61
|
+
metadata. See [the harness guide](CONTRIBUTING.md#performance) for choosing a
|
|
62
|
+
baseline, running one port, and inspecting reports.
|
|
63
|
+
|
|
64
|
+
Event searches scale with window length. Run a full 151-year sweep in a worker or
|
|
65
|
+
background task; use shorter windows for interactive queries.
|
|
66
|
+
|
|
23
67
|
## Usage
|
|
24
68
|
|
|
25
69
|
### TypeScript
|
|
@@ -47,7 +91,7 @@ for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
|
|
|
47
91
|
### Swift
|
|
48
92
|
|
|
49
93
|
```swift
|
|
50
|
-
.package(url: "https://github.com/openwatersio/almanac.git", exact: "0.1
|
|
94
|
+
.package(url: "https://github.com/openwatersio/almanac.git", exact: "0.2.1")
|
|
51
95
|
```
|
|
52
96
|
|
|
53
97
|
```swift
|
|
@@ -67,9 +111,21 @@ for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
|
|
|
67
111
|
}
|
|
68
112
|
```
|
|
69
113
|
|
|
70
|
-
###
|
|
114
|
+
### Eclipse searches
|
|
115
|
+
|
|
116
|
+
Search for a previous eclipse or all eclipses in a time window:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { previousLunarEclipse, lunarEclipses } from '@openwaters/almanac';
|
|
120
|
+
const last = previousLunarEclipse(new Date());
|
|
121
|
+
const eclipses = lunarEclipses(new Date('2026-08-24T00:00:00Z'), new Date('2026-09-02T12:00:00Z'));
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```swift
|
|
125
|
+
let last = try previousLunarEclipse(before: Date())
|
|
126
|
+
let eclipses = try lunarEclipses(from: today, to: tomorrow)
|
|
127
|
+
```
|
|
71
128
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
running it inline.
|
|
129
|
+
Ranges include peaks at the start and exclude peaks at the end. Contacts may
|
|
130
|
+
extend outside the range. Previous/next searches skip peaks within 100 ms of the
|
|
131
|
+
anchor. Search results are global; apply `lunarEclipseVisibility` for an observer.
|
package/dist/eclipse.d.ts
CHANGED
|
@@ -52,6 +52,18 @@ export interface LunarEclipseVisibility {
|
|
|
52
52
|
* interval, or if the next eclipse falls at or past the end of it.
|
|
53
53
|
*/
|
|
54
54
|
export declare function nextLunarEclipse(after: Date): LunarEclipse;
|
|
55
|
+
/**
|
|
56
|
+
* The last lunar eclipse whose peak falls strictly before `before`, skipping
|
|
57
|
+
* peaks within 100 ms of it, just as `nextLunarEclipse` does on the other side.
|
|
58
|
+
*
|
|
59
|
+
* @throws {AlmanacOutOfRangeError} if `before` is outside the supported
|
|
60
|
+
* interval, or if the previous eclipse falls before its beginning.
|
|
61
|
+
*/
|
|
62
|
+
export declare function previousLunarEclipse(before: Date): LunarEclipse;
|
|
63
|
+
/** Lunar eclipses with peaks in `[startUtc, endUtc)`, sorted ascending.
|
|
64
|
+
* Contacts may fall outside the window. Visibility is observer-dependent;
|
|
65
|
+
* use `lunarEclipseVisibility` on each result to check it. */
|
|
66
|
+
export declare function lunarEclipses(startUtc: Date, endUtc: Date): LunarEclipse[];
|
|
55
67
|
/**
|
|
56
68
|
* Whether an observer can see each phase of a lunar eclipse — purely geometric:
|
|
57
69
|
* the Moon's unrefracted topocentric centre must be above the horizon. A lunar
|
package/dist/eclipse.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// result.
|
|
18
18
|
// - upstream reports semi-durations in minutes; the spec's public shape is
|
|
19
19
|
// the contact instants themselves, and `obscuration` is not in it.
|
|
20
|
-
import { assertObserver, assertSupported, AlmanacOutOfRangeError, SUPPORTED_MAX } from './types.js';
|
|
20
|
+
import { assertObserver, assertSupported, assertSupportedWindowEnd, AlmanacOutOfRangeError, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
|
|
21
21
|
import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
|
|
22
22
|
import { KM_PER_AU, RAD2DEG } from './nutation.js';
|
|
23
23
|
import { calcMoon, moonGeoVectorEqj } from './moon.js';
|
|
@@ -44,25 +44,13 @@ const SHADOW_TOL_SECONDS = 1;
|
|
|
44
44
|
/** UPSTREAM: `Search`'s default `iter_limit`. */
|
|
45
45
|
const SHADOW_ITER_CAP = 20;
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
* eclipse
|
|
47
|
+
* Peaks within 100 ms of a next/previous search anchor count as the same
|
|
48
|
+
* eclipse. The fixed full-moon seed below removes within-runtime drift; keep
|
|
49
|
+
* the band for peaks saved by older versions or computed on another runtime.
|
|
49
50
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* same eclipse from different starting times, which moves the interpolated root
|
|
54
|
-
* by an observed ≤1 ms. A caller walking the catalog by feeding each peak back
|
|
55
|
-
* in would otherwise re-find that eclipse whenever the second evaluation landed
|
|
56
|
-
* a millisecond later.
|
|
57
|
-
*
|
|
58
|
-
* The band cuts both ways, and both are bounded:
|
|
59
|
-
* - it can never swallow a real eclipse — consecutive lunar eclipses are
|
|
60
|
-
* never less than a month apart, 25 million times this window;
|
|
61
|
-
* - it *can* skip an eclipse the caller genuinely wanted: `after` set inside
|
|
62
|
-
* the 100 ms before a peak returns the eclipse after it, not that one.
|
|
63
|
-
* That is the accepted cost, and 100 ms is its ceiling — against a peak
|
|
64
|
-
* model that agrees with the Espenak catalog to ~15 s, a caller cannot have
|
|
65
|
-
* meant a boundary that sharp.
|
|
51
|
+
* This can skip a wanted peak when the anchor is within 100 ms of it, but
|
|
52
|
+
* cannot skip a distinct eclipse (the catalog's minimum gap is 29 days).
|
|
53
|
+
* Range searches use exact half-open bounds without this band.
|
|
66
54
|
*/
|
|
67
55
|
const SAME_ECLIPSE_MS = 100;
|
|
68
56
|
/** UPSTREAM: `CalcShadow`, astronomy.ts ~8458. */
|
|
@@ -102,7 +90,14 @@ function earthShadowSlope(ut) {
|
|
|
102
90
|
* time derivative.
|
|
103
91
|
*/
|
|
104
92
|
function peakEarthShadow(centerUt) {
|
|
105
|
-
|
|
93
|
+
// Use the same full-moon seed regardless of search direction or window.
|
|
94
|
+
// Otherwise a ~1 ms root shift can lose/duplicate a peak when a caller
|
|
95
|
+
// splits a range at a previously returned peak. Only unpruned moons pay
|
|
96
|
+
// for this second phase search; its start is a fixed whole UT day.
|
|
97
|
+
const fullmoon = searchMoonPhase(180, Math.floor(centerUt) - 1, 4);
|
|
98
|
+
if (fullmoon === null)
|
|
99
|
+
throw new Error('almanac internal: cannot refine full moon');
|
|
100
|
+
const ut = search(earthShadowSlope, fullmoon - PEAK_WINDOW_DAYS, fullmoon + PEAK_WINDOW_DAYS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'peak earth shadow');
|
|
106
101
|
if (ut === null)
|
|
107
102
|
throw new Error('almanac internal: failed to find peak Earth shadow time');
|
|
108
103
|
return earthShadow(ut);
|
|
@@ -113,10 +108,13 @@ function peakEarthShadow(centerUt) {
|
|
|
113
108
|
* then averages the two halves, so the contacts this package reports are
|
|
114
109
|
* symmetric about the peak by construction (as upstream's are).
|
|
115
110
|
*/
|
|
116
|
-
function shadowSemiDurationMinutes(
|
|
111
|
+
function shadowSemiDurationMinutes(shadow, radiusLimitKm, windowMinutes) {
|
|
112
|
+
const centerUt = shadow.ut;
|
|
113
|
+
// Both contact searches sample the peak, whose geometry is already known.
|
|
114
|
+
const distance = (ut) => ut === centerUt ? shadow.r : earthShadow(ut).r;
|
|
117
115
|
const window = windowMinutes / MINUTES_PER_DAY;
|
|
118
|
-
const t1 = search(ut => -(
|
|
119
|
-
const t2 = search(ut => +(
|
|
116
|
+
const t1 = search(ut => -(distance(ut) - radiusLimitKm), centerUt - window, centerUt, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'shadow semiduration (before)');
|
|
117
|
+
const t2 = search(ut => +(distance(ut) - radiusLimitKm), centerUt, centerUt + window, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'shadow semiduration (after)');
|
|
120
118
|
if (t1 === null || t2 === null)
|
|
121
119
|
throw new Error('almanac internal: failed to find shadow semiduration');
|
|
122
120
|
return (t2 - t1) * (MINUTES_PER_DAY / 2.0); // convert days to minutes and average the semi-durations
|
|
@@ -138,17 +136,57 @@ function moonEclipticLatitudeDeg(ut) {
|
|
|
138
136
|
* interval, or if the next eclipse falls at or past the end of it.
|
|
139
137
|
*/
|
|
140
138
|
export function nextLunarEclipse(after) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
139
|
+
return nearestLunarEclipse(after, 1);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The last lunar eclipse whose peak falls strictly before `before`, skipping
|
|
143
|
+
* peaks within 100 ms of it, just as `nextLunarEclipse` does on the other side.
|
|
144
|
+
*
|
|
145
|
+
* @throws {AlmanacOutOfRangeError} if `before` is outside the supported
|
|
146
|
+
* interval, or if the previous eclipse falls before its beginning.
|
|
147
|
+
*/
|
|
148
|
+
export function previousLunarEclipse(before) {
|
|
149
|
+
return nearestLunarEclipse(before, -1);
|
|
150
|
+
}
|
|
151
|
+
/** Lunar eclipses with peaks in `[startUtc, endUtc)`, sorted ascending.
|
|
152
|
+
* Contacts may fall outside the window. Visibility is observer-dependent;
|
|
153
|
+
* use `lunarEclipseVisibility` on each result to check it. */
|
|
154
|
+
export function lunarEclipses(startUtc, endUtc) {
|
|
155
|
+
assertSupported(startUtc);
|
|
156
|
+
assertSupportedWindowEnd(endUtc);
|
|
157
|
+
return scanLunarEclipses(startUtc.getTime(), endUtc.getTime(), 1, false);
|
|
158
|
+
}
|
|
159
|
+
function nearestLunarEclipse(anchor, direction) {
|
|
160
|
+
assertSupported(anchor);
|
|
161
|
+
const ms = anchor.getTime();
|
|
162
|
+
const startMs = direction > 0 ? ms + SAME_ECLIPSE_MS + 1 : Math.max(SUPPORTED_MIN, ms - SCAN_LIMIT_DAYS * 86400000);
|
|
163
|
+
const endMs = direction > 0 ? Math.min(SUPPORTED_MAX, ms + SCAN_LIMIT_DAYS * 86400000) : ms - SAME_ECLIPSE_MS;
|
|
164
|
+
const found = scanLunarEclipses(startMs, endMs, direction, true);
|
|
165
|
+
if (found.length)
|
|
166
|
+
return found[0];
|
|
167
|
+
if (direction > 0 ? endMs === SUPPORTED_MAX : startMs === SUPPORTED_MIN)
|
|
168
|
+
throw new AlmanacOutOfRangeError();
|
|
169
|
+
// The longest catalog gap is 178 days: exhausting 730 inside the supported
|
|
170
|
+
// interval is a broken shadow model, not a normal out-of-range result.
|
|
171
|
+
throw new Error(`almanac internal: no lunar eclipse within ${SCAN_LIMIT_DAYS} days of ${anchor.toISOString()}`);
|
|
172
|
+
}
|
|
173
|
+
function scanLunarEclipses(startMs, endMs, direction, firstOnly) {
|
|
174
|
+
const found = [];
|
|
175
|
+
if (startMs >= endMs)
|
|
176
|
+
return found;
|
|
177
|
+
// Peak and full moon differ. Include the entire peak-search margin at
|
|
178
|
+
// both ends, then apply the caller's bounds to the reported peak below.
|
|
179
|
+
const startUt = utDays(new Date(startMs)) - PEAK_WINDOW_DAYS;
|
|
180
|
+
const endUt = utDays(new Date(endMs)) + PEAK_WINDOW_DAYS;
|
|
181
|
+
let fmUt = direction > 0 ? startUt : endUt;
|
|
182
|
+
const limitUt = direction > 0 ? endUt : startUt;
|
|
183
|
+
while (direction * (limitUt - fmUt) > 0) {
|
|
184
|
+
const fullmoon = searchMoonPhase(180, fmUt, direction * Math.min(40, Math.abs(limitUt - fmUt)));
|
|
147
185
|
if (fullmoon === null)
|
|
148
|
-
|
|
186
|
+
break;
|
|
149
187
|
// UPSTREAM `SearchLunarEclipse`: step past this full moon before the
|
|
150
188
|
// next probe, so the same one cannot be found twice.
|
|
151
|
-
fmUt = fullmoon + 10;
|
|
189
|
+
fmUt = fullmoon + direction * 10;
|
|
152
190
|
// Pruning: if the full Moon's ecliptic latitude is too large, a lunar
|
|
153
191
|
// eclipse is not possible. Avoid needless work searching for the
|
|
154
192
|
// minimum moon distance.
|
|
@@ -159,20 +197,14 @@ export function nextLunarEclipse(after) {
|
|
|
159
197
|
const shadow = peakEarthShadow(fullmoon);
|
|
160
198
|
if (shadow.r >= shadow.p + MOON_MEAN_RADIUS_KM)
|
|
161
199
|
continue; // not even penumbral
|
|
162
|
-
// A full moon before `after` can still carry the eclipse the caller
|
|
163
|
-
// already has; the spec's contract is strictly-later peaks.
|
|
164
200
|
const peak = dateFromUt(shadow.ut);
|
|
165
|
-
if (peak.getTime()
|
|
201
|
+
if (peak.getTime() < startMs || peak.getTime() >= endMs)
|
|
166
202
|
continue;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
203
|
+
found.push(buildEclipse(shadow, peak));
|
|
204
|
+
if (firstOnly)
|
|
205
|
+
break;
|
|
170
206
|
}
|
|
171
|
-
|
|
172
|
-
// longest real gap over 1950-2100 is 178 days, so exhausting 730 means the
|
|
173
|
-
// shadow model is broken — an internal invariant, like every other
|
|
174
|
-
// `almanac internal:` throw in this package.
|
|
175
|
-
throw new Error(`almanac internal: no lunar eclipse within ${SCAN_LIMIT_DAYS} days of ${after.toISOString()}`);
|
|
207
|
+
return found;
|
|
176
208
|
}
|
|
177
209
|
/** UPSTREAM: the classification and semi-duration block of `SearchLunarEclipse`, astronomy.ts ~8730-8755. */
|
|
178
210
|
function buildEclipse(shadow, peak) {
|
|
@@ -180,15 +212,15 @@ function buildEclipse(shadow, peak) {
|
|
|
180
212
|
let kind = 'penumbral';
|
|
181
213
|
let sdTotal = 0.0;
|
|
182
214
|
let sdPartial = 0.0;
|
|
183
|
-
const sdPenum = shadowSemiDurationMinutes(shadow
|
|
215
|
+
const sdPenum = shadowSemiDurationMinutes(shadow, shadow.p + MOON_MEAN_RADIUS_KM, PENUMBRAL_WINDOW_MINUTES);
|
|
184
216
|
if (shadow.r < shadow.k + MOON_MEAN_RADIUS_KM) {
|
|
185
217
|
// This is at least a partial eclipse.
|
|
186
218
|
kind = 'partial';
|
|
187
|
-
sdPartial = shadowSemiDurationMinutes(shadow
|
|
219
|
+
sdPartial = shadowSemiDurationMinutes(shadow, shadow.k + MOON_MEAN_RADIUS_KM, sdPenum);
|
|
188
220
|
if (shadow.r + MOON_MEAN_RADIUS_KM < shadow.k) {
|
|
189
221
|
// This is a total eclipse.
|
|
190
222
|
kind = 'total';
|
|
191
|
-
sdTotal = shadowSemiDurationMinutes(shadow
|
|
223
|
+
sdTotal = shadowSemiDurationMinutes(shadow, shadow.k - MOON_MEAN_RADIUS_KM, sdPartial);
|
|
192
224
|
}
|
|
193
225
|
}
|
|
194
226
|
// Fraction of the Moon's diameter immersed in each shadow. Shadow radii,
|
package/dist/events.d.ts
CHANGED
|
@@ -64,8 +64,8 @@ export declare function moonEvents(startUtc: Date, endUtc: Date, observer: Obser
|
|
|
64
64
|
*/
|
|
65
65
|
export declare function search(f: (ut: number) => number, t1: number, t2: number, dtToleranceSeconds: number, iterLimit?: number, what?: string): number | null;
|
|
66
66
|
/**
|
|
67
|
-
* UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 —
|
|
68
|
-
*
|
|
67
|
+
* UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 — a negative limit searches
|
|
68
|
+
* backward. The phase repeats every synodic month, so the
|
|
69
69
|
* time of the next occurrence is predicted from the current offset and then
|
|
70
70
|
* bracketed ±1.5 days: the Moon's eccentricity has been seen to move a quarter
|
|
71
71
|
* more than 0.9 days off the simple prediction.
|
package/dist/events.js
CHANGED
|
@@ -215,13 +215,25 @@ function refineExtremum(sample, centreUt, wantMax, halfWidthDays, afterUt) {
|
|
|
215
215
|
return score(s1) > score(s2) ? s1 : s2;
|
|
216
216
|
}
|
|
217
217
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
218
|
+
* Quadratically accelerated search for the single crossing of `level` on a
|
|
219
|
+
* monotonic segment, with bisection if the estimate cannot be verified.
|
|
220
220
|
*/
|
|
221
221
|
function bisectCrossing(sample, level, s0, s1) {
|
|
222
222
|
let a = s0.ut;
|
|
223
223
|
let b = s1.ut;
|
|
224
|
-
|
|
224
|
+
const belowAtA = offsetDeg(s0, level) < 0;
|
|
225
|
+
// Reuse the phase search's quadratic acceleration, normalized to an
|
|
226
|
+
// ascending crossing. Endpoint geometry has already been evaluated.
|
|
227
|
+
const f = (ut) => (belowAtA ? 1 : -1) * offsetDeg(ut === s0.ut ? s0 : ut === s1.ut ? s1 : sample(ut), level);
|
|
228
|
+
const root = search(f, a, b, 0.25, BISECTION_CAP, 'altitude crossing');
|
|
229
|
+
if (root !== null) {
|
|
230
|
+
// A derivative-based estimate alone does not establish a time bound.
|
|
231
|
+
// Accept only a verified half-second bracket, including near grazes.
|
|
232
|
+
const left = Math.max(a, root - TIME_TOL_DAYS / 4);
|
|
233
|
+
const right = Math.min(b, root + TIME_TOL_DAYS / 4);
|
|
234
|
+
if (f(left) < 0 && f(right) >= 0)
|
|
235
|
+
return (left + right) / 2;
|
|
236
|
+
}
|
|
225
237
|
for (let i = 0; i < BISECTION_CAP; i++) {
|
|
226
238
|
if (b - a < TIME_TOL_DAYS)
|
|
227
239
|
return (a + b) / 2;
|
|
@@ -266,12 +278,28 @@ function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRat
|
|
|
266
278
|
const s1 = atRangeEnd ? sample(MAX_UT)
|
|
267
279
|
: refineExtremum(sample, nextEst, nextIsMax, extremumHalfWidthDays, s0.ut);
|
|
268
280
|
assertReached(s1.ut > s0.ut, 'extremum chain ordering');
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
|
|
274
|
-
|
|
281
|
+
// Earlier extrema still establish the chain, but their crossings cannot be emitted.
|
|
282
|
+
if (quantizedUt(s1.ut) >= startUt) {
|
|
283
|
+
let crossingSample = sample;
|
|
284
|
+
if (levels.length > 1) {
|
|
285
|
+
// Twilight levels share midpoint probes; retain them only for this segment.
|
|
286
|
+
const samples = new Map();
|
|
287
|
+
crossingSample = (ut) => {
|
|
288
|
+
const previous = samples.get(ut);
|
|
289
|
+
if (previous)
|
|
290
|
+
return previous;
|
|
291
|
+
const current = sample(ut);
|
|
292
|
+
samples.set(ut, current);
|
|
293
|
+
return current;
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
for (const spec of levels) {
|
|
297
|
+
const g0 = crossingSign(s0, spec.level);
|
|
298
|
+
const g1 = crossingSign(s1, spec.level);
|
|
299
|
+
if (g0 * g1 >= 0)
|
|
300
|
+
continue; // no crossing, or a graze that only touches
|
|
301
|
+
emit(bisectCrossing(crossingSample, spec.level, s0, s1), g1 > 0 ? spec.rising : spec.falling);
|
|
302
|
+
}
|
|
275
303
|
}
|
|
276
304
|
if (nextIsMax && transitKind !== null && !atRangeEnd)
|
|
277
305
|
emit(nextEst, transitKind);
|
|
@@ -390,8 +418,10 @@ export function search(f, t1, t2, dtToleranceSeconds, iterLimit = MOON_PHASE_ITE
|
|
|
390
418
|
assertReached(iter < iterLimit, what);
|
|
391
419
|
const tmid = (t1 + t2) / 2;
|
|
392
420
|
const dt = tmid - t1;
|
|
421
|
+
// A caller may clip the initial bracket to a short range. Small
|
|
422
|
+
// width alone does not prove that it contains an ascending root.
|
|
393
423
|
if (Math.abs(dt) < dtDays)
|
|
394
|
-
return tmid;
|
|
424
|
+
return f1 <= 0 && f2 >= 0 ? tmid : null;
|
|
395
425
|
if (calcFmid)
|
|
396
426
|
fmid = f(tmid);
|
|
397
427
|
else
|
|
@@ -434,8 +464,8 @@ export function search(f, t1, t2, dtToleranceSeconds, iterLimit = MOON_PHASE_ITE
|
|
|
434
464
|
}
|
|
435
465
|
}
|
|
436
466
|
/**
|
|
437
|
-
* UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 —
|
|
438
|
-
*
|
|
467
|
+
* UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 — a negative limit searches
|
|
468
|
+
* backward. The phase repeats every synodic month, so the
|
|
439
469
|
* time of the next occurrence is predicted from the current offset and then
|
|
440
470
|
* bracketed ±1.5 days: the Moon's eccentricity has been seen to move a quarter
|
|
441
471
|
* more than 0.9 days off the simple prediction.
|
|
@@ -444,13 +474,25 @@ export function searchMoonPhase(targetLonDeg, startUt, limitDays) {
|
|
|
444
474
|
const moonOffset = (ut) => angleOffset(moonPhaseDeg(ttDaysFromUt(ut)) - targetLonDeg);
|
|
445
475
|
const uncertainty = 1.5;
|
|
446
476
|
let ya = moonOffset(startUt);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
477
|
+
let dt1, dt2;
|
|
478
|
+
if (limitDays < 0) {
|
|
479
|
+
if (ya < 0)
|
|
480
|
+
ya += 360;
|
|
481
|
+
const estDt = -(MEAN_SYNODIC_MONTH * ya) / 360;
|
|
482
|
+
dt2 = estDt + uncertainty;
|
|
483
|
+
if (dt2 < limitDays)
|
|
484
|
+
return null;
|
|
485
|
+
dt1 = Math.max(limitDays, estDt - uncertainty);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
if (ya > 0)
|
|
489
|
+
ya -= 360;
|
|
490
|
+
const estDt = -(MEAN_SYNODIC_MONTH * ya) / 360;
|
|
491
|
+
dt1 = estDt - uncertainty;
|
|
492
|
+
if (dt1 > limitDays)
|
|
493
|
+
return null;
|
|
494
|
+
dt2 = Math.min(limitDays, estDt + uncertainty);
|
|
495
|
+
}
|
|
454
496
|
return search(moonOffset, startUt + dt1, startUt + dt2, 0.1);
|
|
455
497
|
}
|
|
456
498
|
/**
|
package/dist/illumination.js
CHANGED
|
@@ -63,7 +63,10 @@ function normalizeLongitude(lon) {
|
|
|
63
63
|
* that far off the USNO catalogue.
|
|
64
64
|
*/
|
|
65
65
|
export function moonPhaseDeg(tt) {
|
|
66
|
-
return
|
|
66
|
+
return moonPhaseFromVector(tt, moonGeoVectorEqj(tt));
|
|
67
|
+
}
|
|
68
|
+
function moonPhaseFromVector(tt, moon) {
|
|
69
|
+
return normalizeLongitude(eclipticLonOfDateDeg(moon, tt) - eclipticLonOfDateDeg(sunGeoVectorEqj(tt), tt));
|
|
67
70
|
}
|
|
68
71
|
/** INTERNAL: moon illumination for a TT instant. See {@link moonIllumination}. */
|
|
69
72
|
export function moonIlluminationAtTT(tt) {
|
|
@@ -74,7 +77,7 @@ export function moonIlluminationAtTT(tt) {
|
|
|
74
77
|
const hc = { x: earth.x + gc.x, y: earth.y + gc.y, z: earth.z + gc.z };
|
|
75
78
|
const phaseAngleDeg = angleBetweenDeg(gc, hc);
|
|
76
79
|
const fraction = (1 + Math.cos(DEG2RAD * phaseAngleDeg)) / 2;
|
|
77
|
-
const phase =
|
|
80
|
+
const phase = moonPhaseFromVector(tt, gc) / 360;
|
|
78
81
|
return { fraction, phaseAngleDeg, phase, waxing: phase < 0.5 };
|
|
79
82
|
}
|
|
80
83
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -9,4 +9,4 @@ export { moonIllumination } from './illumination.js';
|
|
|
9
9
|
export type { SunEvent, SunEventKind, MoonEvent, MoonEventKind, MoonPhaseEvent, MoonPhaseName } from './events.js';
|
|
10
10
|
export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
|
|
11
11
|
export type { LunarEclipse, LunarEclipseVisibility } from './eclipse.js';
|
|
12
|
-
export { nextLunarEclipse, lunarEclipseVisibility } from './eclipse.js';
|
|
12
|
+
export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
|
package/dist/index.js
CHANGED
|
@@ -3,4 +3,4 @@ export { sunPosition, moonPosition } from './positions.js';
|
|
|
3
3
|
export { sunAltAz, moonAltAz } from './transforms.js';
|
|
4
4
|
export { moonIllumination } from './illumination.js';
|
|
5
5
|
export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
|
|
6
|
-
export { nextLunarEclipse, lunarEclipseVisibility } from './eclipse.js';
|
|
6
|
+
export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
|
package/dist/moon.js
CHANGED
|
@@ -14,43 +14,21 @@ import { ARC, EARTH_EQUATORIAL_RADIUS_AU, PI2, PrecessDirection, eclipticToEquat
|
|
|
14
14
|
*/
|
|
15
15
|
export function calcMoon(tt) {
|
|
16
16
|
const T = tt / 36525;
|
|
17
|
-
function DeclareArray1(xmin, xmax) {
|
|
18
|
-
const array = [];
|
|
19
|
-
let i;
|
|
20
|
-
for (i = 0; i <= xmax - xmin; ++i) {
|
|
21
|
-
array.push(0);
|
|
22
|
-
}
|
|
23
|
-
return { min: xmin, array: array };
|
|
24
|
-
}
|
|
25
|
-
function DeclareArray2(xmin, xmax, ymin, ymax) {
|
|
26
|
-
const array = [];
|
|
27
|
-
for (let i = 0; i <= xmax - xmin; ++i) {
|
|
28
|
-
array.push(DeclareArray1(ymin, ymax));
|
|
29
|
-
}
|
|
30
|
-
return { min: xmin, array: array };
|
|
31
|
-
}
|
|
32
|
-
function ArrayGet2(a, x, y) {
|
|
33
|
-
const m = a.array[x - a.min];
|
|
34
|
-
return m.array[y - m.min];
|
|
35
|
-
}
|
|
36
|
-
function ArraySet2(a, x, y, v) {
|
|
37
|
-
const m = a.array[x - a.min];
|
|
38
|
-
m.array[y - m.min] = v;
|
|
39
|
-
}
|
|
40
17
|
let S, MAX, ARG, FAC, I, J, T2, DGAM, DLAM, N, GAM1C, SINPI, L0, L, LS, F, D, DL0, DL, DLS, DF, DD, DS;
|
|
41
|
-
|
|
42
|
-
|
|
18
|
+
// CO/SI tables: x in -6...6, y in 1...4, stored in flat 13x4 arrays.
|
|
19
|
+
const coArray = new Array(52).fill(0);
|
|
20
|
+
const siArray = new Array(52).fill(0);
|
|
43
21
|
function CO(x, y) {
|
|
44
|
-
return
|
|
22
|
+
return coArray[(x + 6) * 4 + y - 1];
|
|
45
23
|
}
|
|
46
24
|
function SI(x, y) {
|
|
47
|
-
return
|
|
25
|
+
return siArray[(x + 6) * 4 + y - 1];
|
|
48
26
|
}
|
|
49
27
|
function SetCO(x, y, v) {
|
|
50
|
-
|
|
28
|
+
coArray[(x + 6) * 4 + y - 1] = v;
|
|
51
29
|
}
|
|
52
30
|
function SetSI(x, y, v) {
|
|
53
|
-
|
|
31
|
+
siArray[(x + 6) * 4 + y - 1] = v;
|
|
54
32
|
}
|
|
55
33
|
function AddThe(c1, s1, c2, s2, func) {
|
|
56
34
|
func(c1 * c2 - s1 * s2, s1 * c2 + c1 * s2);
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwaters/almanac",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Offline sun & moon engine: positions, rise/set, moon phase, lunar eclipses.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": { "type": "git", "url": "git+https://github.com/openwatersio/almanac.git", "directory": "typescript" },
|
|
7
|
+
"homepage": "https://openwaters.io/sky",
|
|
7
8
|
"type": "module",
|
|
8
9
|
"main": "./dist/index.js",
|
|
9
10
|
"types": "./dist/index.d.ts",
|