@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/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +75 -0
- package/dist/eclipse.d.ts +66 -0
- package/dist/eclipse.js +295 -0
- package/dist/events.d.ts +81 -0
- package/dist/events.js +492 -0
- package/dist/illumination.d.ts +32 -0
- package/dist/illumination.js +88 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/moon.d.ts +38 -0
- package/dist/moon.js +310 -0
- package/dist/nutation.d.ts +65 -0
- package/dist/nutation.js +255 -0
- package/dist/positions.d.ts +34 -0
- package/dist/positions.js +52 -0
- package/dist/sun.d.ts +27 -0
- package/dist/sun.js +184 -0
- package/dist/time.d.ts +28 -0
- package/dist/time.js +144 -0
- package/dist/transforms.d.ts +61 -0
- package/dist/transforms.js +198 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.js +31 -0
- package/package.json +34 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|