@openwaters/almanac 0.0.0-bootstrap

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/dist/events.js ADDED
@@ -0,0 +1,492 @@
1
+ // L3 event searches: sun rise/set, the three twilights, solar transit,
2
+ // moonrise/moonset, and the four quarter moon phases.
3
+ //
4
+ // Two different algorithms live here, for two different reasons.
5
+ //
6
+ // The altitude searches are NOT translated from upstream. Upstream's
7
+ // `SearchRiseSet` walks a fixed 0.42-day grid guarded by a maximum-slope prune;
8
+ // a grid can straddle both halves of a grazing pair (the polar-onset day where
9
+ // the Sun sets at 08:00 and rises again at 08:16) and report neither. Here each
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.
13
+ // Hour angle supplies only the initial bracket: HA 0/12h are not the exact
14
+ // extrema, because declination, distance and lunar parallax all drift within a
15
+ // cycle, and toward the poles the daily altitude cycle flattens until the
16
+ // extremum wanders hours away from transit.
17
+ //
18
+ // `searchMoonPhases` IS translated, from the cosinekitty/astronomy upstream at
19
+ // pinned sha 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts:
20
+ // `SearchMoonPhase` (~5260) and the `Search` (~4634) / `QuadInterp` (~4528)
21
+ // root finder it calls, plus the quarter walk of `SearchMoonQuarter` (~5339)
22
+ // and `NextMoonQuarter` (~5362).
23
+ import { assertObserver, assertSupported, assertSupportedWindowEnd, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
24
+ import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
25
+ import { KM_PER_AU, RAD2DEG } from './nutation.js';
26
+ import { topoAltAzUnrefracted } from './transforms.js';
27
+ import { sunGeoVectorEqj } from './sun.js';
28
+ import { moonGeoVectorEqj } from './moon.js';
29
+ import { moonPhaseDeg } from './illumination.js';
30
+ // ---------------------------------------------------------------- constants
31
+ const CIVIL_ALT_DEG = -6;
32
+ const NAUTICAL_ALT_DEG = -12;
33
+ const ASTRO_ALT_DEG = -18;
34
+ /**
35
+ * Body radii, for the semidiameter that lowers each rise/set target — and, in
36
+ * the L3 eclipse layer, for the Earth's shadow cone and the Moon it falls on.
37
+ * INTERNAL: exported for `eclipse.ts`, not part of the public API.
38
+ */
39
+ export const MOON_MEAN_RADIUS_KM = 1737.4;
40
+ /** UPSTREAM: `SUN_RADIUS_KM`, astronomy.ts line 134. INTERNAL, see above. */
41
+ export const SUN_RADIUS_KM = 695700;
42
+ const HORIZON_REFRACTION_DEG = 34 / 60;
43
+ /**
44
+ * The upper-limb rise/set target: unrefracted centre altitude at
45
+ * −(34′ + the body's true semidiameter at the distance the observer sees).
46
+ * Spec, Conventions: the same rule for both bodies — a fixed 16′ for the Sun
47
+ * sits ~10″ off USNO near aphelion and perihelion, which a high-latitude graze
48
+ * turns into a minute.
49
+ */
50
+ function upperLimbTargetDeg(radiusKm, distanceAu) {
51
+ return -HORIZON_REFRACTION_DEG - RAD2DEG * Math.asin(radiusKm / (distanceAu * KM_PER_AU));
52
+ }
53
+ /** Mean hour-angle advance. The Sun's is a mean solar day by definition. */
54
+ const SUN_HA_RATE_DEG_PER_DAY = 360;
55
+ const SUN_CYCLE_DAYS = 1;
56
+ /** Mean lunar day, 24h50m28s. */
57
+ const MOON_CYCLE_DAYS = 1.035050;
58
+ const MOON_HA_RATE_DEG_PER_DAY = 360 / MOON_CYCLE_DAYS;
59
+ const SECOND_DAYS = 1 / 86400;
60
+ /** Bisection and golden-section both stop under a second; events are reported to the ms. */
61
+ const TIME_TOL_DAYS = SECOND_DAYS;
62
+ /** An extremum this close to the target grazes it without crossing: no event. */
63
+ const TANGENT_EPS_DEG = 1e-6;
64
+ /** Initial half-bracket around the hour-angle estimate of an extremum. */
65
+ const EXTREMUM_HALF_WIDTH_DAYS = 2 / 24;
66
+ /**
67
+ * Where the daily cycle flattens the extremum drifts hours off transit — the
68
+ * offset scales as 1/cos φ — so there the bracket is the whole half-cycle
69
+ * instead. The threshold is 85°, not the 89° where the drift becomes dramatic:
70
+ * the Moon's fast declination rate already puts the extremum 2.28 h off transit
71
+ * at 88°N and 5.75 h at 88.9°N, past the ±2 h bracket, and golden section then
72
+ * converges to a bracket edge and the monotonic-segment invariant breaks. The
73
+ * cost of the wider threshold is a few extra golden-section steps at latitudes
74
+ * almost nobody queries.
75
+ */
76
+ const FLAT_CYCLE_LATITUDE_DEG = 85;
77
+ const GOLDEN_SECTION_CAP = 100;
78
+ const BISECTION_CAP = 60;
79
+ const HOUR_ANGLE_ITER_CAP = 20;
80
+ const MOON_PHASE_ITER_CAP = 50;
81
+ const INV_PHI = 0.6180339887498949;
82
+ const MIN_UT = utDays(new Date(SUPPORTED_MIN));
83
+ const MAX_UT = utDays(new Date(SUPPORTED_MAX));
84
+ /**
85
+ * Clamps a probe into the supported interval. The altitude-search chain
86
+ * (searchAltitudeEvents) applies this to every probe it samples, so it never
87
+ * evaluates the model outside [MIN_UT, MAX_UT]. The phase-search walk
88
+ * (searchMoonPhases) only clamps its first probe — later quarter-to-quarter
89
+ * probes can land past MAX_UT and evaluate the model there harmlessly; it is
90
+ * the half-open window filter on emitted results, not this clamp, that keeps
91
+ * reported answers inside the interval.
92
+ */
93
+ function clampUt(ut) {
94
+ return ut < MIN_UT ? MIN_UT : (ut > MAX_UT ? MAX_UT : ut);
95
+ }
96
+ /**
97
+ * Truncates a ut-days root to the same integer-ms precision `dateFromUt`
98
+ * reports it at (TimeClip). A root finder's raw `ut` carries sub-ms
99
+ * precision; comparing that raw value against a half-open window bound can
100
+ * pass while the truncated instant it is actually reported as lands exactly
101
+ * on the excluded end (a pre-1970 negative offset truncates UP, toward the
102
+ * boundary). The half-open filter must judge the reported instant, not the
103
+ * root. Exported for its direct micro-test, mirroring the Swift port's
104
+ * (which relaxes the same function to `internal` for the same reason);
105
+ * no other module consumes it.
106
+ */
107
+ export function quantizedUt(ut) {
108
+ return utDays(dateFromUt(ut));
109
+ }
110
+ /** Hitting an iteration cap is a bug, not a runtime condition — see the spec. */
111
+ function assertReached(condition, what) {
112
+ if (!condition)
113
+ throw new Error(`almanac internal: ${what} did not converge`);
114
+ }
115
+ function sunSampler(observer) {
116
+ return (ut) => {
117
+ const p = topoAltAzUnrefracted(sunGeoVectorEqj(ttDaysFromUt(ut)), ut, observer);
118
+ return {
119
+ ut, altDeg: p.altDeg, hourAngleDeg: p.hourAngleDeg,
120
+ riseSetAltDeg: upperLimbTargetDeg(SUN_RADIUS_KM, p.distanceAu)
121
+ };
122
+ };
123
+ }
124
+ function moonSampler(observer) {
125
+ return (ut) => {
126
+ const p = topoAltAzUnrefracted(moonGeoVectorEqj(ttDaysFromUt(ut)), ut, observer);
127
+ return {
128
+ ut, altDeg: p.altDeg, hourAngleDeg: p.hourAngleDeg,
129
+ riseSetAltDeg: upperLimbTargetDeg(MOON_MEAN_RADIUS_KM, p.distanceAu)
130
+ };
131
+ };
132
+ }
133
+ function offsetDeg(s, level) {
134
+ return s.altDeg - (level === null ? s.riseSetAltDeg : level);
135
+ }
136
+ /** −1 below, +1 above, 0 grazing (within {@link TANGENT_EPS_DEG}: touches, never crosses). */
137
+ function crossingSign(s, level) {
138
+ const f = offsetDeg(s, level);
139
+ return Math.abs(f) <= TANGENT_EPS_DEG ? 0 : Math.sign(f);
140
+ }
141
+ // ----------------------------------------------------------- root findings
142
+ /** Wrap a hour-angle difference into (−180, 180]. */
143
+ function angleOffset(diff) {
144
+ let offset = diff % 360;
145
+ if (offset <= -180)
146
+ offset += 360;
147
+ else if (offset > 180)
148
+ offset -= 360;
149
+ return offset;
150
+ }
151
+ /**
152
+ * Time of the next local hour angle `targetHaDeg` strictly after `afterUt`:
153
+ * one advance at the body's mean rate, then fixed-point refinement at the same
154
+ * rate. The rate is right to a few percent, so each step cuts the error by ~20×
155
+ * and the loop settles in three or four iterations. This is both the bracket
156
+ * centre for an altitude extremum and — for `targetHaDeg = 0` — the transit
157
+ * event itself.
158
+ */
159
+ function nextHourAngle(sample, afterUt, targetHaDeg, rateDegPerDay) {
160
+ let deficit = ((targetHaDeg - sample(afterUt).hourAngleDeg) % 360 + 360) % 360;
161
+ if (deficit === 0)
162
+ deficit = 360; // already there: take the next one, not this one
163
+ let ut = afterUt + deficit / rateDegPerDay;
164
+ for (let i = 0; i < HOUR_ANGLE_ITER_CAP; i++) {
165
+ const step = angleOffset(targetHaDeg - sample(ut).hourAngleDeg) / rateDegPerDay;
166
+ ut += step;
167
+ if (Math.abs(step) < TIME_TOL_DAYS)
168
+ return ut;
169
+ }
170
+ assertReached(false, 'hour-angle iteration');
171
+ return ut;
172
+ }
173
+ /**
174
+ * Golden-section refinement of the altitude extremum bracketed around
175
+ * `centreUt`. Evaluating the true extremum is what makes a grazing pair
176
+ * findable: the segment on either side of it is monotonic, so bisection can
177
+ * only miss a crossing if the extremum itself was never seen.
178
+ *
179
+ * `afterUt` is the previous extremum. It matters only under the widened
180
+ * high-latitude bracket, where the half-cycle window would otherwise reach back
181
+ * past it — and at the pole, where the daily cycle degenerates into the
182
+ * declination's slow annual drift, unclamped golden section will happily walk
183
+ * backwards and break the chain's ordering.
184
+ */
185
+ function refineExtremum(sample, centreUt, wantMax, halfWidthDays, afterUt) {
186
+ let a = Math.max(clampUt(centreUt - halfWidthDays), afterUt);
187
+ let b = clampUt(centreUt + halfWidthDays);
188
+ let x1 = b - INV_PHI * (b - a);
189
+ let x2 = a + INV_PHI * (b - a);
190
+ let s1 = sample(x1);
191
+ let s2 = sample(x2);
192
+ const score = (s) => (wantMax ? s.altDeg : -s.altDeg);
193
+ let converged = false;
194
+ for (let i = 0; i < GOLDEN_SECTION_CAP; i++) {
195
+ if (b - a < TIME_TOL_DAYS) {
196
+ converged = true;
197
+ break;
198
+ }
199
+ if (score(s1) > score(s2)) {
200
+ b = x2;
201
+ x2 = x1;
202
+ s2 = s1;
203
+ x1 = b - INV_PHI * (b - a);
204
+ s1 = sample(x1);
205
+ }
206
+ else {
207
+ a = x1;
208
+ x1 = x2;
209
+ s1 = s2;
210
+ x2 = a + INV_PHI * (b - a);
211
+ s2 = sample(x2);
212
+ }
213
+ }
214
+ assertReached(converged, 'golden-section extremum refinement');
215
+ return score(s1) > score(s2) ? s1 : s2;
216
+ }
217
+ /**
218
+ * Bisection for the single crossing of `level` on the monotonic segment
219
+ * between two consecutive extrema.
220
+ */
221
+ function bisectCrossing(sample, level, s0, s1) {
222
+ let a = s0.ut;
223
+ let b = s1.ut;
224
+ let belowAtA = offsetDeg(s0, level) < 0;
225
+ for (let i = 0; i < BISECTION_CAP; i++) {
226
+ if (b - a < TIME_TOL_DAYS)
227
+ return (a + b) / 2;
228
+ const mid = (a + b) / 2;
229
+ if ((offsetDeg(sample(mid), level) < 0) === belowAtA)
230
+ a = mid;
231
+ else
232
+ b = mid;
233
+ }
234
+ assertReached(false, 'crossing bisection');
235
+ return (a + b) / 2;
236
+ }
237
+ /**
238
+ * Walk the window one half-cycle at a time, refining each altitude extremum and
239
+ * solving every monotonic segment between consecutive extrema for every target
240
+ * level. The chain starts a full cycle before `startUt` so the segment
241
+ * containing the window's first instant is covered, and runs one extremum past
242
+ * `endUt` so an event just inside the window's end is too.
243
+ */
244
+ function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRateDegPerDay, cycleDays, extremumHalfWidthDays) {
245
+ const found = [];
246
+ const emit = (ut, kind) => {
247
+ const q = quantizedUt(ut);
248
+ if (q >= startUt && q < endUt)
249
+ found.push({ ut, kind }); // half-open [start, end), judged on the reported instant
250
+ };
251
+ // The chain's first point need not be an extremum: between any instant and
252
+ // the next extremum after it the altitude is already monotonic.
253
+ let s0 = sample(clampUt(startUt - cycleDays));
254
+ const firstMax = nextHourAngle(sample, s0.ut, 0, haRateDegPerDay);
255
+ const firstMin = nextHourAngle(sample, s0.ut, 180, haRateDegPerDay);
256
+ let nextIsMax = firstMax <= firstMin;
257
+ let nextEst = nextIsMax ? firstMax : firstMin;
258
+ const stepCap = Math.ceil(2 * (endUt - s0.ut) / cycleDays) + 8;
259
+ let steps = 0;
260
+ while (s0.ut < endUt && s0.ut < MAX_UT) {
261
+ assertReached(steps++ < stepCap, 'extremum chain');
262
+ const atRangeEnd = nextEst >= MAX_UT;
263
+ // At the supported-interval edge the boundary instant stands in for the
264
+ // extremum we are not allowed to probe past. The segment up to it is
265
+ // still monotonic, so any crossing inside the window is still found.
266
+ const s1 = atRangeEnd ? sample(MAX_UT)
267
+ : refineExtremum(sample, nextEst, nextIsMax, extremumHalfWidthDays, s0.ut);
268
+ assertReached(s1.ut > s0.ut, 'extremum chain ordering');
269
+ for (const spec of levels) {
270
+ const g0 = crossingSign(s0, spec.level);
271
+ const g1 = crossingSign(s1, spec.level);
272
+ if (g0 * g1 >= 0)
273
+ continue; // no crossing, or a graze that only touches
274
+ emit(bisectCrossing(sample, spec.level, s0, s1), g1 > 0 ? spec.rising : spec.falling);
275
+ }
276
+ if (nextIsMax && transitKind !== null && !atRangeEnd)
277
+ emit(nextEst, transitKind);
278
+ s0 = s1;
279
+ if (atRangeEnd)
280
+ break;
281
+ nextIsMax = !nextIsMax;
282
+ nextEst = nextHourAngle(sample, nextEst, nextIsMax ? 0 : 180, haRateDegPerDay);
283
+ }
284
+ found.sort((a, b) => a.ut - b.ut);
285
+ return found.map((e) => ({ time: dateFromUt(e.ut), kind: e.kind }));
286
+ }
287
+ const SUN_LEVELS = [
288
+ { level: null, rising: 'rise', falling: 'set' },
289
+ { level: CIVIL_ALT_DEG, rising: 'civilDawn', falling: 'civilDusk' },
290
+ { level: NAUTICAL_ALT_DEG, rising: 'nauticalDawn', falling: 'nauticalDusk' },
291
+ { level: ASTRO_ALT_DEG, rising: 'astroDawn', falling: 'astroDusk' },
292
+ ];
293
+ const MOON_LEVELS = [
294
+ { level: null, rising: 'rise', falling: 'set' },
295
+ ];
296
+ function extremumHalfWidth(observer, cycleDays) {
297
+ return Math.abs(observer.latitudeDeg) >= FLAT_CYCLE_LATITUDE_DEG
298
+ ? cycleDays / 2
299
+ : EXTREMUM_HALF_WIDTH_DAYS;
300
+ }
301
+ /**
302
+ * Sun rise, set, the three twilights and upper transit within the half-open
303
+ * window `[startUtc, endUtc)`, sorted ascending.
304
+ *
305
+ * Rise and set are the unrefracted geometric centre altitude at −(34′ + the
306
+ * Sun's true semidiameter at distance) — the upper-limb convention with the
307
+ * actual disc, the same rule the Moon gets; the twilights are centre altitude
308
+ * −6°, −12° and −18° with no refraction term; transit is local hour angle
309
+ * zero. An empty list is a valid answer — polar day and polar night drop
310
+ * the crossings — while transit is reported regardless of whether the Sun is
311
+ * above the horizon when it happens.
312
+ */
313
+ export function sunEvents(startUtc, endUtc, observer) {
314
+ assertSupported(startUtc);
315
+ assertSupportedWindowEnd(endUtc);
316
+ assertObserver(observer);
317
+ if (startUtc.getTime() >= endUtc.getTime())
318
+ return [];
319
+ 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));
320
+ }
321
+ /**
322
+ * Moonrise and moonset within the half-open window `[startUtc, endUtc)`,
323
+ * sorted ascending — the apparent topocentric upper limb crossing the horizon,
324
+ * so refraction (34′), topocentric parallax and the true semidiameter at the
325
+ * Moon's distance are all included — the same upper-limb rule as the Sun's. An empty list is a valid answer.
326
+ */
327
+ export function moonEvents(startUtc, endUtc, observer) {
328
+ assertSupported(startUtc);
329
+ assertSupportedWindowEnd(endUtc);
330
+ assertObserver(observer);
331
+ if (startUtc.getTime() >= endUtc.getTime())
332
+ return [];
333
+ 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));
334
+ }
335
+ // -------------------------------------------------------------- moon phases
336
+ /**
337
+ * UPSTREAM: `MEAN_SYNODIC_MONTH`, astronomy.ts line 129. The elongation it
338
+ * searches is {@link moonPhaseDeg}, apparent on both sides per the spec's
339
+ * moon-phase definition — upstream's `PairLongitude` uses geometric longitudes,
340
+ * which sit a flat ~40 s off the USNO catalogue.
341
+ */
342
+ const MEAN_SYNODIC_MONTH = 29.530588;
343
+ const PHASE_NAMES = ['new', 'firstQuarter', 'full', 'lastQuarter'];
344
+ /** UPSTREAM: `QuadInterp`, astronomy.ts ~4528. */
345
+ function quadInterp(tm, dt, fa, fm, fb) {
346
+ const Q = (fb + fa) / 2 - fm;
347
+ const R = (fb - fa) / 2;
348
+ const S = fm;
349
+ let x;
350
+ if (Q === 0) {
351
+ if (R === 0)
352
+ return null; // horizontal line: no progress possible
353
+ x = -S / R;
354
+ if (x < -1 || x > +1)
355
+ return null;
356
+ }
357
+ else {
358
+ const u = R * R - 4 * Q * S;
359
+ if (u <= 0)
360
+ return null;
361
+ const ru = Math.sqrt(u);
362
+ const x1 = (-R + ru) / (2 * Q);
363
+ const x2 = (-R - ru) / (2 * Q);
364
+ if (-1 <= x1 && x1 <= +1) {
365
+ if (-1 <= x2 && x2 <= +1)
366
+ return null;
367
+ x = x1;
368
+ }
369
+ else if (-1 <= x2 && x2 <= +1) {
370
+ x = x2;
371
+ }
372
+ else {
373
+ return null;
374
+ }
375
+ }
376
+ return { t: tm + x * dt, dfdt: (2 * Q * x + R) / dt };
377
+ }
378
+ /**
379
+ * UPSTREAM: `Search`, astronomy.ts ~4634 — bisection with quadratic
380
+ * acceleration for the ascending zero crossing of `f` in `[t1, t2]`. Times are
381
+ * days since J2000 UT rather than upstream's `AstroTime`.
382
+ */
383
+ export function search(f, t1, t2, dtToleranceSeconds, iterLimit = MOON_PHASE_ITER_CAP, what = 'moon-phase search') {
384
+ const dtDays = Math.abs(dtToleranceSeconds * SECOND_DAYS);
385
+ let f1 = f(t1);
386
+ let f2 = f(t2);
387
+ let fmid = NaN;
388
+ let calcFmid = true;
389
+ for (let iter = 0;; iter++) {
390
+ assertReached(iter < iterLimit, what);
391
+ const tmid = (t1 + t2) / 2;
392
+ const dt = tmid - t1;
393
+ if (Math.abs(dt) < dtDays)
394
+ return tmid;
395
+ if (calcFmid)
396
+ fmid = f(tmid);
397
+ else
398
+ calcFmid = true;
399
+ const q = quadInterp(tmid, t2 - tmid, f1, fmid, f2);
400
+ if (q && q.dfdt !== 0) {
401
+ const fq = f(q.t);
402
+ if (Math.abs(fq / q.dfdt) < dtDays)
403
+ return q.t;
404
+ const dtGuess = 1.2 * Math.abs(fq / q.dfdt);
405
+ if (dtGuess < dt / 10) {
406
+ const tleft = q.t - dtGuess;
407
+ const tright = q.t + dtGuess;
408
+ if ((tleft - t1) * (tleft - t2) < 0 && (tright - t1) * (tright - t2) < 0) {
409
+ const fleft = f(tleft);
410
+ const fright = f(tright);
411
+ if (fleft < 0 && fright >= 0) {
412
+ f1 = fleft;
413
+ f2 = fright;
414
+ t1 = tleft;
415
+ t2 = tright;
416
+ fmid = fq;
417
+ calcFmid = false;
418
+ continue;
419
+ }
420
+ }
421
+ }
422
+ }
423
+ if (f1 < 0 && fmid >= 0) {
424
+ t2 = tmid;
425
+ f2 = fmid;
426
+ continue;
427
+ }
428
+ if (fmid < 0 && f2 >= 0) {
429
+ t1 = tmid;
430
+ f1 = fmid;
431
+ continue;
432
+ }
433
+ return null; // no ascending crossing here, or the window is too wide
434
+ }
435
+ }
436
+ /**
437
+ * UPSTREAM: `SearchMoonPhase`, astronomy.ts ~5260 — forward search only, which
438
+ * is all the quarter walk needs. The phase repeats every synodic month, so the
439
+ * time of the next occurrence is predicted from the current offset and then
440
+ * bracketed ±1.5 days: the Moon's eccentricity has been seen to move a quarter
441
+ * more than 0.9 days off the simple prediction.
442
+ */
443
+ export function searchMoonPhase(targetLonDeg, startUt, limitDays) {
444
+ const moonOffset = (ut) => angleOffset(moonPhaseDeg(ttDaysFromUt(ut)) - targetLonDeg);
445
+ const uncertainty = 1.5;
446
+ let ya = moonOffset(startUt);
447
+ if (ya > 0)
448
+ ya -= 360;
449
+ const estDt = -(MEAN_SYNODIC_MONTH * ya) / 360;
450
+ const dt1 = estDt - uncertainty;
451
+ if (dt1 > limitDays)
452
+ return null;
453
+ const dt2 = Math.min(limitDays, estDt + uncertainty);
454
+ return search(moonOffset, startUt + dt1, startUt + dt2, 0.1);
455
+ }
456
+ /**
457
+ * The four quarter moon phases within the half-open window
458
+ * `[startUtc, endUtc)`, sorted ascending.
459
+ *
460
+ * A phase is the instant the Moon's geocentric ecliptic longitude leads the
461
+ * Sun's by 0° (new), 90° (first quarter), 180° (full) or 270° (last quarter).
462
+ */
463
+ export function searchMoonPhases(startUtc, endUtc) {
464
+ assertSupported(startUtc);
465
+ assertSupportedWindowEnd(endUtc);
466
+ if (startUtc.getTime() >= endUtc.getTime())
467
+ return [];
468
+ const startUt = utDays(startUtc);
469
+ const endUt = utDays(endUtc);
470
+ const events = [];
471
+ // UPSTREAM `SearchMoonQuarter` finds the first quarter strictly after its
472
+ // argument, so the walk starts a day early: a phase landing exactly on
473
+ // startUtc belongs to this half-open window.
474
+ let probeUt = clampUt(startUt - 1);
475
+ let quarter = (Math.floor(moonPhaseDeg(ttDaysFromUt(probeUt)) / 90) + 1) % 4;
476
+ for (;;) {
477
+ const ut = searchMoonPhase(90 * quarter, probeUt, 10);
478
+ assertReached(ut !== null, 'moon quarter search');
479
+ if (ut === null)
480
+ break;
481
+ const q = quantizedUt(ut); // judge the half-open bounds on the reported instant, not the raw root
482
+ if (q >= endUt)
483
+ break;
484
+ if (q >= startUt)
485
+ events.push({ time: dateFromUt(ut), phase: PHASE_NAMES[quarter] });
486
+ // UPSTREAM `NextMoonQuarter`: skip 6 days, under the smallest observed
487
+ // quarter-to-quarter interval, so the next search cannot re-find this one.
488
+ probeUt = ut + 6;
489
+ quarter = (quarter + 1) % 4;
490
+ }
491
+ return events;
492
+ }
@@ -0,0 +1,32 @@
1
+ /** Moon illumination at an instant. `phase` is 0 at new moon, 0.5 at full. */
2
+ export interface MoonIllumination {
3
+ fraction: number;
4
+ phaseAngleDeg: number;
5
+ phase: number;
6
+ waxing: boolean;
7
+ }
8
+ /**
9
+ * INTERNAL: the Moon's apparent ecliptic longitude ahead of the Sun's, [0, 360)
10
+ * — 0 at new moon, 180 at full. The single definition of lunar phase in this
11
+ * package: `moonIllumination.phase` is this over 360, and `searchMoonPhases`
12
+ * roots this against 0/90/180/270.
13
+ *
14
+ * UPSTREAM: `MoonPhase` = `PairLongitude(Moon, Sun)` (astronomy.ts ~5221,
15
+ * ~4831) takes both longitudes from `GeoVector(body, t, aberration=false)`,
16
+ * i.e. geometric directions. The spec's moon-phase definition is **apparent**
17
+ * on both sides, matching the USNO/almanac definition of syzygy, so the Sun
18
+ * here is the aberrated vector `sunPosition` returns. The Moon needs no such
19
+ * change: aberration follows the observer's velocity *relative to the body* —
20
+ * 30 km/s for the Sun (20.5″) against 1 km/s for the Moon (0.7″). That 20.5″ is
21
+ * 40 s of elongation rate, and upstream's geometric convention sits exactly
22
+ * that far off the USNO catalogue.
23
+ */
24
+ export declare function moonPhaseDeg(tt: number): number;
25
+ /** INTERNAL: moon illumination for a TT instant. See {@link moonIllumination}. */
26
+ export declare function moonIlluminationAtTT(tt: number): MoonIllumination;
27
+ /**
28
+ * Moon illumination at a given instant: the fraction of the visible disc lit
29
+ * by the Sun, the phase angle it derives from (0 = full, 180 = new), and the
30
+ * phase (0 = new, 0.5 = full, `waxing` = phase < 0.5).
31
+ */
32
+ export declare function moonIllumination(time: Date): MoonIllumination;
@@ -0,0 +1,88 @@
1
+ // L1 moon illumination: phase angle / illuminated fraction (upstream
2
+ // `Illumination`, Body.Moon branch, astronomy.ts line ~5048) and phase
3
+ // (upstream `MoonPhase` = `PairLongitude(Moon, Sun)` / 360, astronomy.ts line
4
+ // ~5221), plus the Moon–Sun elongation both `phase` and the L3 phase search
5
+ // read — one definition, one implementation.
6
+ //
7
+ // Translated from the cosinekitty/astronomy upstream, pinned sha
8
+ // 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts.
9
+ //
10
+ // INTERNAL: not part of the curated public API (see index.ts).
11
+ import { assertSupported } from './types.js';
12
+ import { ttDays } from './time.js';
13
+ import { DEG2RAD, PrecessDirection, RAD2DEG, earthTilt, eclipticToEquatorial, gyration } from './nutation.js';
14
+ import { moonGeoVectorEqj } from './moon.js';
15
+ import { earthHelioVector, sunGeoVectorEqj } from './sun.js';
16
+ /** UPSTREAM: `AngleBetween`, astronomy.ts lines 256-273 — angle between two vectors, [0, 180]. */
17
+ function angleBetweenDeg(a, b) {
18
+ const aa = a.x * a.x + a.y * a.y + a.z * a.z;
19
+ const bb = b.x * b.x + b.y * b.y + b.z * b.z;
20
+ const dot = (a.x * b.x + a.y * b.y + a.z * b.z) / Math.sqrt(aa * bb);
21
+ if (dot <= -1)
22
+ return 180;
23
+ if (dot >= 1)
24
+ return 0;
25
+ return RAD2DEG * Math.acos(dot);
26
+ }
27
+ /**
28
+ * UPSTREAM: the `elon` half of `Ecliptic()` (astronomy.ts lines 3013-3030),
29
+ * applied to a J2000 mean-equator (EQJ) vector: `gyration(..., From2000)`
30
+ * reproduces `Ecliptic`'s precession+nutation-to-EQD step exactly, and
31
+ * `eclipticToEquatorial` called with the negated obliquity performs the same
32
+ * rotation as upstream's `RotateEquatorialToEcliptic` (its inverse).
33
+ */
34
+ function eclipticLonOfDateDeg(eqj, tt) {
35
+ const eqd = gyration(eqj, tt, PrecessDirection.From2000);
36
+ const tobl = earthTilt(tt).tobl;
37
+ const ecl = eclipticToEquatorial(-tobl, eqd);
38
+ if (ecl.x === 0 && ecl.y === 0)
39
+ return 0;
40
+ let elon = RAD2DEG * Math.atan2(ecl.y, ecl.x);
41
+ if (elon < 0)
42
+ elon += 360;
43
+ return elon;
44
+ }
45
+ /** UPSTREAM: `NormalizeLongitude`, astronomy.ts lines 4735-4739. */
46
+ function normalizeLongitude(lon) {
47
+ return ((lon % 360) + 360) % 360;
48
+ }
49
+ /**
50
+ * INTERNAL: the Moon's apparent ecliptic longitude ahead of the Sun's, [0, 360)
51
+ * — 0 at new moon, 180 at full. The single definition of lunar phase in this
52
+ * package: `moonIllumination.phase` is this over 360, and `searchMoonPhases`
53
+ * roots this against 0/90/180/270.
54
+ *
55
+ * UPSTREAM: `MoonPhase` = `PairLongitude(Moon, Sun)` (astronomy.ts ~5221,
56
+ * ~4831) takes both longitudes from `GeoVector(body, t, aberration=false)`,
57
+ * i.e. geometric directions. The spec's moon-phase definition is **apparent**
58
+ * on both sides, matching the USNO/almanac definition of syzygy, so the Sun
59
+ * here is the aberrated vector `sunPosition` returns. The Moon needs no such
60
+ * change: aberration follows the observer's velocity *relative to the body* —
61
+ * 30 km/s for the Sun (20.5″) against 1 km/s for the Moon (0.7″). That 20.5″ is
62
+ * 40 s of elongation rate, and upstream's geometric convention sits exactly
63
+ * that far off the USNO catalogue.
64
+ */
65
+ export function moonPhaseDeg(tt) {
66
+ return normalizeLongitude(eclipticLonOfDateDeg(moonGeoVectorEqj(tt), tt) - eclipticLonOfDateDeg(sunGeoVectorEqj(tt), tt));
67
+ }
68
+ /** INTERNAL: moon illumination for a TT instant. See {@link moonIllumination}. */
69
+ export function moonIlluminationAtTT(tt) {
70
+ // UPSTREAM `Illumination`, Body.Moon branch: gc = GeoMoon(time) (no
71
+ // backdating/aberration — see moonGeoVectorEqj); hc = earth + gc.
72
+ const gc = moonGeoVectorEqj(tt);
73
+ const earth = earthHelioVector(tt);
74
+ const hc = { x: earth.x + gc.x, y: earth.y + gc.y, z: earth.z + gc.z };
75
+ const phaseAngleDeg = angleBetweenDeg(gc, hc);
76
+ const fraction = (1 + Math.cos(DEG2RAD * phaseAngleDeg)) / 2;
77
+ const phase = moonPhaseDeg(tt) / 360;
78
+ return { fraction, phaseAngleDeg, phase, waxing: phase < 0.5 };
79
+ }
80
+ /**
81
+ * Moon illumination at a given instant: the fraction of the visible disc lit
82
+ * by the Sun, the phase angle it derives from (0 = full, 180 = new), and the
83
+ * phase (0 = new, 0.5 = full, `waxing` = phase < 0.5).
84
+ */
85
+ export function moonIllumination(time) {
86
+ assertSupported(time);
87
+ return moonIlluminationAtTT(ttDays(time));
88
+ }
@@ -0,0 +1,12 @@
1
+ export type { Observer } from './types.js';
2
+ export { AlmanacOutOfRangeError } from './types.js';
3
+ export type { SunPosition, MoonPosition } from './positions.js';
4
+ export { sunPosition, moonPosition } from './positions.js';
5
+ export type { AltAz } from './transforms.js';
6
+ export { sunAltAz, moonAltAz } from './transforms.js';
7
+ export type { MoonIllumination } from './illumination.js';
8
+ export { moonIllumination } from './illumination.js';
9
+ export type { SunEvent, SunEventKind, MoonEvent, MoonEventKind, MoonPhaseEvent, MoonPhaseName } from './events.js';
10
+ export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
11
+ export type { LunarEclipse, LunarEclipseVisibility } from './eclipse.js';
12
+ export { nextLunarEclipse, lunarEclipseVisibility } from './eclipse.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { AlmanacOutOfRangeError } from './types.js';
2
+ export { sunPosition, moonPosition } from './positions.js';
3
+ export { sunAltAz, moonAltAz } from './transforms.js';
4
+ export { moonIllumination } from './illumination.js';
5
+ export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
6
+ export { nextLunarEclipse, lunarEclipseVisibility } from './eclipse.js';
package/dist/moon.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { Vec3 } from './nutation.js';
2
+ /** Geocentric ecliptic-of-date position of the Moon; angles in radians. */
3
+ export interface MoonEcliptic {
4
+ geoEclipLon: number;
5
+ geoEclipLat: number;
6
+ distanceAu: number;
7
+ }
8
+ /**
9
+ * UPSTREAM: `CalcMoon`, astronomy.ts lines 1494-1793 — the full MOON2 series.
10
+ * Returns ecliptic longitude/latitude of date (radians) and distance (AU).
11
+ */
12
+ export declare function calcMoon(tt: number): MoonEcliptic;
13
+ /**
14
+ * UPSTREAM: `GeoMoon`, astronomy.ts lines 3049-3068, followed by the
15
+ * `gyration(..., From2000)` that `Equator(ofdate=true)` (line 2803) applies.
16
+ *
17
+ * `GeoVector(Body.Moon, ...)` returns `GeoMoon` unchanged whatever the
18
+ * `aberration` flag: the Moon shares the Earth's orbital velocity, so its
19
+ * geocentric aberration and its 1.3-second light time both fall far below the
20
+ * precision of this lunar theory.
21
+ *
22
+ * The precession round trip (into J2000, then back out inside `gyration`) is
23
+ * upstream's own composition and is kept so the Swift port can be a
24
+ * line-for-line translation.
25
+ *
26
+ * @returns geocentric position in AU, J2000 mean equator (EQJ) — the `mpos2`
27
+ * intermediate below, exported for the L2 topocentric path (Task 11), which
28
+ * needs the body vector in the same EQJ frame as the observer's geocentric
29
+ * position before subtracting the two.
30
+ */
31
+ export declare function moonGeoVectorEqj(tt: number): Vec3;
32
+ /**
33
+ * UPSTREAM: `GeoMoon` (astronomy.ts 3049-3068) followed by the
34
+ * `gyration(..., From2000)` that `Equator(ofdate=true)` (line 2803) applies.
35
+ *
36
+ * @returns geocentric position in AU, true equator & equinox of date.
37
+ */
38
+ export declare function moonGeoVector(tt: number): Vec3;