@openwaters/almanac 0.7.0 → 0.8.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 CHANGED
@@ -1,12 +1,13 @@
1
1
  # Almanac
2
2
 
3
- Offline sky engine for the Salish Sea and everywhere else: Sun, Moon, and planet positions, apparent horizon dip, rise, set, twilight, Moon phase, planetary brightness, lunar and solar eclipses, and fixed-star altitude and azimuth from catalog positions, computed from pure geometry with zero network and zero runtime data files.
3
+ Offline sky engine for the Salish Sea and everywhere else: Sun, Moon, and planet positions, apparent horizon dip, rise, set, twilight, any Sun altitude, upper and lower transits, Moon phase and orientation, equinoxes and solstices, lunar perigee and apogee, the equation of time, planetary brightness, lunar and solar eclipses, and fixed-star altitude and azimuth from catalog positions, computed from pure geometry with zero network and zero runtime data files.
4
4
 
5
- Twin implementations, one behavior:
5
+ Three implementations, one behavior:
6
6
 
7
7
  - `swift/` — SwiftPM package `Almanac`
8
8
  - `typescript/` — npm `@openwaters/almanac`
9
- - `fixtures/` — the shared test corpus (JPL Horizons, USNO, Espenak) both suites
9
+ - `kotlin/` — Kotlin/JVM `io.openwaters.almanac`, source-consumed with Gradle
10
+ - `fixtures/` — the shared test corpus (JPL Horizons, USNO, Espenak) all three suites
10
11
  must pass; the contract that keeps the ports identical
11
12
 
12
13
  Supported interval: 1950-01-01T00:00Z ≤ t < 2101-01-01T00:00Z; results outside it raise a typed error. All instants are UT1-accurate, while unknown future DUT1 is outside the civil-UTC accuracy promise. See the [public contract](docs/CONTRACT.md#time-and-supported-interval) for the time model.
@@ -21,11 +22,13 @@ Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/ast
21
22
 
22
23
  ## Performance
23
24
 
24
- Almanac includes a shared performance harness for both ports: **30 workloads**
25
+ Almanac includes a shared performance harness for TypeScript and Swift: **34 workloads**
25
26
  cover positions, a 228-hour sky track, short/year/polar event windows, full-range
26
27
  moon phases, next/previous/range lunar, solar, and global solar eclipse searches,
27
28
  including empty windows, solar obscuration over a 228-hour track, and the central
28
- line of the 2024-04-08 eclipse, five-planet sky tracks, and planetary rise/set.
29
+ line of the 2024-04-08 eclipse, five-planet sky tracks, planetary rise/set, a
30
+ year of golden-hour crossings and Sun and Moon transits, and a 228-hour Moon
31
+ orientation track.
29
32
 
30
33
  Event searches find altitude extrema with Brent's method and altitude crossings
31
34
  with a cosine-seeded secant solver, each root proven inside a half-second
@@ -49,10 +52,10 @@ Reproduce the comparison locally from the repository root with mise 2026.9.1 or
49
52
  ```bash
50
53
  mise install
51
54
  mise exec -- npm ci --prefix typescript
52
- mise exec -- node benchmarks/run.mjs --base v0.4.1 --skip '^(global|planets)/'
55
+ mise exec -- node benchmarks/run.mjs --base v0.4.1 --skip '^(global|planets|suncalc)/'
53
56
  ```
54
57
 
55
- The `--skip` pattern leaves out global eclipse and planetary workloads absent from that base. Use `--skip '^(solar|global|planets)/'` when the base predates 0.4.0, which is when the solar eclipse workloads arrived. For a newer base that lacks only planets, use `--skip '^planets/'`.
58
+ The `--skip` pattern leaves out global eclipse, planetary, and SunCalc-parity workloads absent from that base. Use `--skip '^(solar|global|planets|suncalc)/'` when the base predates 0.4.0, which is when the solar eclipse workloads arrived. For a base that lacks only the SunCalc-parity APIs, use `--skip '^suncalc/'`.
56
59
 
57
60
  CI runs the harness on code changes and fails on **median regressions over 20%**.
58
61
  Results include timing tables, raw samples, checksums, and revision/toolchain
@@ -64,6 +67,39 @@ background task; use shorter windows for interactive queries.
64
67
 
65
68
  ## Usage
66
69
 
70
+ Migration guides for [SunCalc](docs/migrating-from-suncalc.md), [Astronomy Engine](docs/migrating-from-astronomy-engine.md), and [Solar](docs/migrating-from-solar.md) include tested examples. The [library comparison](docs/comparison.md) explains the features and limits of each library.
71
+
72
+ Angles use degrees. All three ports provide `degreesToRadians(angle)` and `radiansToDegrees(angle)` for callers that use radians. These functions preserve sign and full turns without wrapping or changing the angle's origin; non-finite inputs and conversion overflow raise validation errors. Swift calls use `try`.
73
+
74
+ ### JavaScript module loading
75
+
76
+ The npm package is ESM and has no separate CommonJS build. Use Node 22.12 or later for the Node examples; CI and release checks load the packed package on Node 22.12 and Node 24. Browser applications can import it through their ESM bundler.
77
+
78
+ In an ES module (`.mjs` or a package with `"type": "module"`):
79
+
80
+ ```js
81
+ import { sunAltAz } from '@openwaters/almanac';
82
+ const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
83
+ console.log(sunAltAz(new Date(), observer));
84
+ ```
85
+
86
+ A CommonJS application can keep synchronous initialization. [Node's `require(esm)` support](https://nodejs.org/docs/latest-v22.x/api/modules.html#loading-ecmascript-modules-using-require) is enabled without a flag from Node 22.12:
87
+
88
+ ```js
89
+ const { sunAltAz } = require('@openwaters/almanac');
90
+ const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
91
+ console.log(sunAltAz(new Date(), observer));
92
+ ```
93
+
94
+ CommonJS can also load ESM asynchronously if its initialization flow already supports it:
95
+
96
+ ```js
97
+ async function initializeSky() {
98
+ const { sunAltAz } = await import('@openwaters/almanac');
99
+ return sunAltAz(new Date(), { latitudeDeg: 48.5, longitudeDeg: -123.0 });
100
+ }
101
+ ```
102
+
67
103
  ### TypeScript
68
104
 
69
105
  ```bash
@@ -72,7 +108,8 @@ npm install @openwaters/almanac
72
108
 
73
109
  ```ts
74
110
  import {
75
- nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, nextGlobalSolarEclipse, sunEvents, starAltAz
111
+ nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, nextGlobalSolarEclipse, sunEvents, starAltAz,
112
+ seasons, moonApsides, equationOfTime
76
113
  } from '@openwaters/almanac';
77
114
 
78
115
  const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
@@ -96,6 +133,13 @@ for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
96
133
 
97
134
  // Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
98
135
  const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
136
+
137
+ // The year's equinoxes and solstices, the Moon's perigees and apogees with
138
+ // distances, and the equation of time in minutes.
139
+ const year = new Date(today.getUTCFullYear() + 1, 0, 1);
140
+ for (const { kind, time } of seasons(today, year)) console.log(kind, time.toISOString());
141
+ for (const { kind, time, distanceKm } of moonApsides(today, year)) console.log(kind, time.toISOString(), distanceKm);
142
+ console.log(equationOfTime(today)); // sundial minus clock, minutes
99
143
  ```
100
144
 
101
145
  For an unobstructed sea horizon, supply eye height above the water separately from elevation above sea level. Body altitudes remain measured from the horizontal plane:
@@ -112,7 +156,7 @@ const crossings = sunEvents(today, tomorrow, viewer, eyeHeightM);
112
156
  ### Swift
113
157
 
114
158
  ```swift
115
- .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.7.0")
159
+ .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.8.0")
116
160
  ```
117
161
 
118
162
  ```swift
@@ -143,18 +187,64 @@ for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
143
187
 
144
188
  // Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
145
189
  let star = try starAltAz(raDeg: 88.792939, decDeg: 7.407064, at: today, observer: observer)
190
+
191
+ // The year's equinoxes and solstices, the Moon's perigees and apogees with
192
+ // distances, and the equation of time in minutes.
193
+ let year = today.addingTimeInterval(366 * 24 * 60 * 60)
194
+ for event in try seasons(from: today, to: year) { print(event.kind, event.time) }
195
+ for apsis in try moonApsides(from: today, to: year) { print(apsis.kind, apsis.time, apsis.distanceKm) }
196
+ print(try equationOfTime(today)) // sundial minus clock, minutes
197
+ ```
198
+
199
+ ### Kotlin/JVM and Android
200
+
201
+ The Kotlin library provides the same 37 functions and result fields, using `java.time.Instant` and immutable data classes. It targets JVM 8 and Android minSdk 26, builds with JDK 17, and has no runtime dependencies beyond the Kotlin standard library and platform APIs. Maven publication and a Kotlin benchmark runner are deferred; consume the source at a reviewed Git SHA as described in [`kotlin/README.md`](kotlin/README.md).
202
+
203
+ ```kotlin
204
+ import io.openwaters.almanac.*
205
+ import java.time.Instant
206
+
207
+ val observer = Observer(48.5, -123.0)
208
+ val today = Instant.now()
209
+ val tomorrow = today.plusSeconds(86400)
210
+ val events = sunEvents(today, tomorrow, observer)
211
+ val moon = moonIllumination(today)
212
+ val next = nextGlobalSolarEclipse(today)
213
+ val line = solarEclipseCentralLine(next.peak)
214
+ val minutes = equationOfTime(today)
215
+ ```
216
+
217
+ ### Golden hour, transits, and the Moon's tilt
218
+
219
+ `sunAltitudeEvents` finds the Sun's center crossing any altitude, with the twilight convention, so `6` gives golden hour and `-6` civil twilight. `sunTransits` and `moonTransits` give each body's highest (`upper`) and lowest (`lower`) meridian crossings. `moonOrientation` turns the Moon's disc: subtracting the parallactic angle from the bright-limb angle gives the bright limb's direction from the zenith.
220
+
221
+ ```ts
222
+ import { sunAltitudeEvents, sunTransits, moonTransits, moonOrientation } from '@openwaters/almanac';
223
+ const golden = sunAltitudeEvents(today, tomorrow, observer, 6); // 'rising' ends morning golden hour, 'falling' starts the evening one
224
+ const solarMidnight = sunTransits(today, tomorrow, observer).find(e => e.kind === 'lower');
225
+ const moonHighest = moonTransits(today, tomorrow, observer).find(e => e.kind === 'upper');
226
+ const { brightLimbAngleDeg, parallacticAngleDeg } = moonOrientation(today, observer);
227
+ const brightLimbFromZenithDeg = brightLimbAngleDeg - parallacticAngleDeg;
228
+ ```
229
+
230
+ ```swift
231
+ let golden = try sunAltitudeEvents(from: today, to: tomorrow, observer: observer, altitudeDeg: 6)
232
+ let solarMidnight = try sunTransits(from: today, to: tomorrow, observer: observer).first { $0.kind == .lower }
233
+ let moonHighest = try moonTransits(from: today, to: tomorrow, observer: observer).first { $0.kind == .upper }
234
+ let orientation = try moonOrientation(at: today, observer: observer)
235
+ let brightLimbFromZenithDeg = orientation.brightLimbAngleDeg - orientation.parallacticAngleDeg
146
236
  ```
147
237
 
148
238
  ### Planetary views
149
239
 
150
- Mercury, Venus, Earth, Mars, Jupiter, and Saturn have geometric Sun-centered positions in AU, in fixed J2000 equatorial coordinates. Place the Sun at `(0, 0, 0)` and orient the scene's camera for the desired view:
240
+ Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune have geometric Sun-centered positions in AU, in fixed J2000 equatorial coordinates. Place the Sun at `(0, 0, 0)` and orient the scene's camera for the desired view:
151
241
 
152
242
  ```ts
153
243
  import { planetHeliocentricPosition, planetAltAz, planetIllumination, planetEvents,
154
244
  horizonDip, sunAltAz } from '@openwaters/almanac';
155
245
  import type { Planet } from '@openwaters/almanac';
156
246
 
157
- const planets: Planet[] = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn'];
247
+ const planets: Planet[] = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
158
248
  const now = new Date();
159
249
  const positions = planets.map(planet => ({ planet, ...planetHeliocentricPosition(planet, now) }));
160
250
 
@@ -0,0 +1,4 @@
1
+ /** Converts degrees to radians without wrapping the angle. */
2
+ export declare function degreesToRadians(degrees: number): number;
3
+ /** Converts radians to degrees without wrapping; rejects conversion overflow. */
4
+ export declare function radiansToDegrees(radians: number): number;
package/dist/angles.js ADDED
@@ -0,0 +1,16 @@
1
+ import { DEG2RAD, RAD2DEG } from './nutation.js';
2
+ /** Converts degrees to radians without wrapping the angle. */
3
+ export function degreesToRadians(degrees) {
4
+ if (!Number.isFinite(degrees))
5
+ throw new RangeError('degrees must be finite');
6
+ return degrees * DEG2RAD;
7
+ }
8
+ /** Converts radians to degrees without wrapping; rejects conversion overflow. */
9
+ export function radiansToDegrees(radians) {
10
+ if (!Number.isFinite(radians))
11
+ throw new RangeError('radians must be finite');
12
+ const degrees = radians * RAD2DEG;
13
+ if (!Number.isFinite(degrees))
14
+ throw new RangeError('angle conversion overflow');
15
+ return degrees;
16
+ }
@@ -0,0 +1,14 @@
1
+ /** Closest approach or farthest remove of the Moon from the Earth's centre. */
2
+ export type MoonApsisKind = 'perigee' | 'apogee';
3
+ /** A lunar perigee or apogee, with the Earth–Moon centre distance there. */
4
+ export interface MoonApsis {
5
+ time: Date;
6
+ kind: MoonApsisKind;
7
+ distanceKm: number;
8
+ }
9
+ /**
10
+ * The Moon's perigees and apogees within the half-open window
11
+ * `[startUtc, endUtc)`, sorted ascending, each with the geocentric distance
12
+ * at that instant.
13
+ */
14
+ export declare function moonApsides(startUtc: Date, endUtc: Date): MoonApsis[];
@@ -0,0 +1,88 @@
1
+ // L3 lunar apsides: perigee and apogee, the zeros of the Moon's radial
2
+ // velocity, from the MOON2 distance the position layer already evaluates.
3
+ //
4
+ // Translated from the cosinekitty/astronomy upstream, pinned sha
5
+ // 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts:
6
+ // `SearchLunarApsis` (~6534) — its central-difference `distance_slope`, its
7
+ // five-day scan, its sign-change classification, and the `Search` it hands
8
+ // each bracket to. Upstream scans forward from the caller's instant and
9
+ // `NextLunarApsis` (~6621) chains from the last result; here the five-day scan
10
+ // points are a fixed grid anchored at J2000, so every window that contains an
11
+ // apsis finds it from the same bracket and reports the same millisecond — the
12
+ // fixed-seed rule the eclipse searches follow. Apsides are at least 11.8 days
13
+ // apart, so a cell holds at most one.
14
+ //
15
+ // INTERNAL except `moonApsides`: not part of the curated public API (see index.ts).
16
+ import { assertSupported, assertSupportedWindowEnd } from './types.js';
17
+ import { utDays, ttDaysFromUt, dateFromUt } from './time.js';
18
+ import { KM_PER_AU } from './nutation.js';
19
+ import { calcMoon } from './moon.js';
20
+ import { search, quantizedUt, assertReached } from './events.js';
21
+ /** UPSTREAM: the central-difference half-width `dt` inside `SearchLunarApsis`. */
22
+ const SLOPE_DT_DAYS = 0.001;
23
+ /** UPSTREAM: `increment` — the scan step, here also the grid cell in days since J2000 UT. */
24
+ const APSIS_CELL_DAYS = 5;
25
+ /** UPSTREAM: `distance_slope` — dr/dt in AU per day, by central difference. */
26
+ function distanceSlope(ut) {
27
+ const tt = ttDaysFromUt(ut);
28
+ const r1 = calcMoon(tt - SLOPE_DT_DAYS / 2).distanceAu;
29
+ const r2 = calcMoon(tt + SLOPE_DT_DAYS / 2).distanceAu;
30
+ return (r2 - r1) / SLOPE_DT_DAYS;
31
+ }
32
+ function negativeDistanceSlope(ut) {
33
+ return -distanceSlope(ut);
34
+ }
35
+ /**
36
+ * UPSTREAM: the body of `SearchLunarApsis`'s scan loop for one cell `[t1, t2]`
37
+ * with the slopes `m1`, `m2` at its ends. A slope rising through zero is a
38
+ * perigee and falling through zero an apogee. A zero exactly at a grid point
39
+ * belongs to the cell it ends, so no apsis is reported twice.
40
+ */
41
+ function searchApsisInCell(t1, t2, m1, m2) {
42
+ if (m1 < 0 && m2 >= 0) {
43
+ const ut = search(distanceSlope, t1, t2, 1, undefined, 'perigee search');
44
+ assertReached(ut !== null, 'perigee search');
45
+ return { ut: ut, kind: 'perigee' };
46
+ }
47
+ if (m1 > 0 && m2 <= 0) {
48
+ const ut = search(negativeDistanceSlope, t1, t2, 1, undefined, 'apogee search');
49
+ assertReached(ut !== null, 'apogee search');
50
+ return { ut: ut, kind: 'apogee' };
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * The Moon's perigees and apogees within the half-open window
56
+ * `[startUtc, endUtc)`, sorted ascending, each with the geocentric distance
57
+ * at that instant.
58
+ */
59
+ export function moonApsides(startUtc, endUtc) {
60
+ assertSupported(startUtc);
61
+ assertSupportedWindowEnd(endUtc);
62
+ if (startUtc.getTime() >= endUtc.getTime())
63
+ return [];
64
+ const startUt = utDays(startUtc);
65
+ const endUt = utDays(endUtc);
66
+ const events = [];
67
+ let t1 = Math.floor(startUt / APSIS_CELL_DAYS) * APSIS_CELL_DAYS;
68
+ let m1 = distanceSlope(t1);
69
+ while (t1 < endUt) {
70
+ const t2 = t1 + APSIS_CELL_DAYS;
71
+ const m2 = distanceSlope(t2);
72
+ const found = searchApsisInCell(t1, t2, m1, m2);
73
+ if (found !== null) {
74
+ const q = quantizedUt(found.ut); // judge the half-open bounds on the reported instant, not the raw root
75
+ if (q >= endUt)
76
+ break;
77
+ if (q >= startUt) {
78
+ events.push({
79
+ time: dateFromUt(found.ut), kind: found.kind,
80
+ distanceKm: calcMoon(ttDaysFromUt(found.ut)).distanceAu * KM_PER_AU
81
+ });
82
+ }
83
+ }
84
+ t1 = t2;
85
+ m1 = m2;
86
+ }
87
+ return events;
88
+ }
package/dist/events.d.ts CHANGED
@@ -17,6 +17,18 @@ export interface PlanetEvent {
17
17
  time: Date;
18
18
  kind: PlanetEventKind;
19
19
  }
20
+ /** The Sun's center crossing a caller-chosen altitude, upward or downward. */
21
+ export type AltitudeEventKind = 'rising' | 'falling';
22
+ export interface AltitudeEvent {
23
+ time: Date;
24
+ kind: AltitudeEventKind;
25
+ }
26
+ /** A body crossing the local meridian: highest at `upper`, lowest at `lower`. */
27
+ export type TransitEventKind = 'upper' | 'lower';
28
+ export interface TransitEvent {
29
+ time: Date;
30
+ kind: TransitEventKind;
31
+ }
20
32
  /** A quarter lunar phase. */
21
33
  export type MoonPhaseName = 'new' | 'firstQuarter' | 'full' | 'lastQuarter';
22
34
  export interface MoonPhaseEvent {
@@ -31,6 +43,16 @@ export interface MoonPhaseEvent {
31
43
  export declare const MOON_MEAN_RADIUS_KM = 1737.4;
32
44
  /** UPSTREAM: `SUN_RADIUS_KM`, astronomy.ts line 134. INTERNAL, see above. */
33
45
  export declare const SUN_RADIUS_KM = 695700;
46
+ /**
47
+ * Clamps a probe into the supported interval. The altitude-search chain
48
+ * (searchAltitudeEvents) applies this to every probe it samples, so it never
49
+ * evaluates the model outside [MIN_UT, MAX_UT]. The phase-search walk
50
+ * (searchMoonPhases) only clamps its first probe — later quarter-to-quarter
51
+ * probes can land past MAX_UT and evaluate the model there harmlessly; it is
52
+ * the half-open window filter on emitted results, not this clamp, that keeps
53
+ * reported answers inside the interval.
54
+ */
55
+ export declare function clampUt(ut: number): number;
34
56
  /**
35
57
  * Truncates a ut-days root to the same integer-ms precision `dateFromUt`
36
58
  * reports it at (TimeClip). A root finder's raw `ut` carries sub-ms
@@ -43,6 +65,10 @@ export declare const SUN_RADIUS_KM = 695700;
43
65
  * no other module consumes it.
44
66
  */
45
67
  export declare function quantizedUt(ut: number): number;
68
+ /** Hitting an iteration cap is a bug, not a runtime condition — see the spec. */
69
+ export declare function assertReached(condition: boolean, what: string): void;
70
+ /** Wrap a hour-angle difference into (−180, 180]. */
71
+ export declare function angleOffset(diff: number): number;
46
72
  /**
47
73
  * Sun rise, set, the three twilights and upper transit within the half-open
48
74
  * window `[startUtc, endUtc)`, sorted ascending.
@@ -65,6 +91,33 @@ export declare function sunEvents(startUtc: Date, endUtc: Date, observer: Observ
65
91
  export declare function moonEvents(startUtc: Date, endUtc: Date, observer: Observer, heightAboveGroundM?: number): MoonEvent[];
66
92
  /** Rise/set of a point center at geometric altitude horizonDip minus 34 arcminutes. */
67
93
  export declare function planetEvents(planet: Planet, startUtc: Date, endUtc: Date, observer: Observer, heightAboveGroundM?: number): PlanetEvent[];
94
+ /**
95
+ * The Sun's unrefracted geometric center crossing `altitudeDeg` within the
96
+ * half-open window `[startUtc, endUtc)`, sorted ascending: `rising` upward,
97
+ * `falling` downward. The twilight convention, so `-6` returns the civil
98
+ * twilight instants. A window with no crossing, as in polar day or night,
99
+ * returns an empty list.
100
+ */
101
+ export declare function sunAltitudeEvents(startUtc: Date, endUtc: Date, observer: Observer, altitudeDeg: number): AltitudeEvent[];
102
+ /**
103
+ * The Sun's upper (local hour angle 0) and lower (180°) transits within the
104
+ * half-open window `[startUtc, endUtc)`, sorted ascending, whether or not the
105
+ * Sun is above the horizon. The upper transit is `sunEvents`' `transit`.
106
+ */
107
+ export declare function sunTransits(startUtc: Date, endUtc: Date, observer: Observer): TransitEvent[];
108
+ /**
109
+ * The Moon's upper and lower transits within the half-open window
110
+ * `[startUtc, endUtc)`, sorted ascending, from the topocentric hour angle, so
111
+ * parallax is included, whether or not the Moon is above the horizon.
112
+ */
113
+ export declare function moonTransits(startUtc: Date, endUtc: Date, observer: Observer): TransitEvent[];
114
+ /**
115
+ * UPSTREAM: `MEAN_SYNODIC_MONTH`, astronomy.ts line 129. The elongation it
116
+ * searches is {@link moonPhaseDeg}, apparent on both sides per the spec's
117
+ * moon-phase definition — upstream's `PairLongitude` uses geometric longitudes,
118
+ * which sit a flat ~40 s off the USNO catalogue.
119
+ */
120
+ export declare const MEAN_SYNODIC_MONTH = 29.530588;
68
121
  /**
69
122
  * UPSTREAM: `Search`, astronomy.ts ~4634 — bisection with quadratic
70
123
  * acceleration for the ascending zero crossing of `f` in `[t1, t2]`. Times are
package/dist/events.js CHANGED
@@ -21,7 +21,7 @@
21
21
  // `SearchMoonPhase` (~5260) and the `Search` (~4634) / `QuadInterp` (~4528)
22
22
  // root finder it calls, plus the quarter walk of `SearchMoonQuarter` (~5339)
23
23
  // and `NextMoonQuarter` (~5362).
24
- import { assertPlanet, assertSupported, assertSupportedWindowEnd, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
24
+ import { assertObserver, assertPlanet, assertSupported, assertSupportedWindowEnd, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
25
25
  import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
26
26
  import { DEG2RAD, KM_PER_AU, RAD2DEG } from './nutation.js';
27
27
  import { topoAltAzUnrefracted } from './transforms.js';
@@ -94,7 +94,7 @@ const MAX_UT = utDays(new Date(SUPPORTED_MAX));
94
94
  * the half-open window filter on emitted results, not this clamp, that keeps
95
95
  * reported answers inside the interval.
96
96
  */
97
- function clampUt(ut) {
97
+ export function clampUt(ut) {
98
98
  return ut < MIN_UT ? MIN_UT : (ut > MAX_UT ? MAX_UT : ut);
99
99
  }
100
100
  /**
@@ -112,7 +112,7 @@ export function quantizedUt(ut) {
112
112
  return utDays(dateFromUt(ut));
113
113
  }
114
114
  /** Hitting an iteration cap is a bug, not a runtime condition — see the spec. */
115
- function assertReached(condition, what) {
115
+ export function assertReached(condition, what) {
116
116
  if (!condition)
117
117
  throw new Error(`almanac internal: ${what} did not converge`);
118
118
  }
@@ -150,7 +150,7 @@ function crossingSign(s, level) {
150
150
  }
151
151
  // ----------------------------------------------------------- root findings
152
152
  /** Wrap a hour-angle difference into (−180, 180]. */
153
- function angleOffset(diff) {
153
+ export function angleOffset(diff) {
154
154
  let offset = diff % 360;
155
155
  if (offset <= -180)
156
156
  offset += 360;
@@ -412,9 +412,12 @@ function secantCrossing(f, a, b, s0, s1, targetDeg) {
412
412
  * solving every monotonic segment between consecutive extrema for every target
413
413
  * level. The chain starts a full cycle before `startUt` so the segment
414
414
  * containing the window's first instant is covered, and runs one extremum past
415
- * `endUt` so an event just inside the window's end is too.
415
+ * `endUt` so an event just inside the window's end is too. `transitKind` is
416
+ * emitted at each hour angle 0 and `lowerTransitKind` at each hour angle 180.
417
+ * With no levels the extrema are never refined: the hour-angle chain alone
418
+ * places the transits.
416
419
  */
417
- function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRateDegPerDay, cycleDays, flatCycle) {
420
+ function searchAltitudeEvents(sample, levels, transitKind, lowerTransitKind, startUt, endUt, haRateDegPerDay, cycleDays, flatCycle) {
418
421
  const extremumHalfWidthDays = flatCycle ? cycleDays / 2 : EXTREMUM_HALF_WIDTH_DAYS;
419
422
  const found = [];
420
423
  const emit = (ut, kind) => {
@@ -438,7 +441,8 @@ function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRat
438
441
  // extremum we are not allowed to probe past. The segment up to it is
439
442
  // still monotonic, so any crossing inside the window is still found.
440
443
  const s1 = atRangeEnd ? sample(MAX_UT)
441
- : refineExtremum(sample, nextEst, nextIsMax, extremumHalfWidthDays, s0.ut, flatCycle);
444
+ : levels.length === 0 ? sample(nextEst)
445
+ : refineExtremum(sample, nextEst, nextIsMax, extremumHalfWidthDays, s0.ut, flatCycle);
442
446
  assertReached(s1.ut > s0.ut, 'extremum chain ordering');
443
447
  // Earlier extrema still establish the chain, but their crossings cannot be emitted.
444
448
  if (quantizedUt(s1.ut) >= startUt) {
@@ -450,8 +454,9 @@ function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRat
450
454
  emit(solveCrossing(sample, spec.level, s0, s1), g1 > 0 ? spec.rising : spec.falling);
451
455
  }
452
456
  }
453
- if (nextIsMax && transitKind !== null && !atRangeEnd)
454
- emit(nextEst, transitKind);
457
+ const transit = nextIsMax ? transitKind : lowerTransitKind;
458
+ if (transit !== null && !atRangeEnd)
459
+ emit(nextEst, transit);
455
460
  s0 = s1;
456
461
  if (atRangeEnd)
457
462
  break;
@@ -491,7 +496,7 @@ export function sunEvents(startUtc, endUtc, observer, heightAboveGroundM = 0) {
491
496
  const dip = horizonDip(observer, heightAboveGroundM);
492
497
  if (startUtc.getTime() >= endUtc.getTime())
493
498
  return [];
494
- return searchAltitudeEvents(sunSampler(observer, dip), SUN_LEVELS, 'transit', utDays(startUtc), utDays(endUtc), SUN_HA_RATE_DEG_PER_DAY, SUN_CYCLE_DAYS, flatCycle(observer));
499
+ return searchAltitudeEvents(sunSampler(observer, dip), SUN_LEVELS, 'transit', null, utDays(startUtc), utDays(endUtc), SUN_HA_RATE_DEG_PER_DAY, SUN_CYCLE_DAYS, flatCycle(observer));
495
500
  }
496
501
  /**
497
502
  * Moonrise and moonset within the half-open window `[startUtc, endUtc)`,
@@ -505,7 +510,7 @@ export function moonEvents(startUtc, endUtc, observer, heightAboveGroundM = 0) {
505
510
  const dip = horizonDip(observer, heightAboveGroundM);
506
511
  if (startUtc.getTime() >= endUtc.getTime())
507
512
  return [];
508
- return searchAltitudeEvents(moonSampler(observer, dip), MOON_LEVELS, null, utDays(startUtc), utDays(endUtc), MOON_HA_RATE_DEG_PER_DAY, MOON_CYCLE_DAYS, flatCycle(observer));
513
+ return searchAltitudeEvents(moonSampler(observer, dip), MOON_LEVELS, null, null, utDays(startUtc), utDays(endUtc), MOON_HA_RATE_DEG_PER_DAY, MOON_CYCLE_DAYS, flatCycle(observer));
509
514
  }
510
515
  /** Rise/set of a point center at geometric altitude horizonDip minus 34 arcminutes. */
511
516
  export function planetEvents(planet, startUtc, endUtc, observer, heightAboveGroundM = 0) {
@@ -516,7 +521,51 @@ export function planetEvents(planet, startUtc, endUtc, observer, heightAboveGrou
516
521
  if (startUtc.getTime() >= endUtc.getTime())
517
522
  return [];
518
523
  const cycleDays = 0.9972695717592592;
519
- return searchAltitudeEvents(planetSampler(planet, observer, dip), MOON_LEVELS, null, utDays(startUtc), utDays(endUtc), 360 / cycleDays, cycleDays, flatCycle(observer));
524
+ return searchAltitudeEvents(planetSampler(planet, observer, dip), MOON_LEVELS, null, null, utDays(startUtc), utDays(endUtc), 360 / cycleDays, cycleDays, flatCycle(observer));
525
+ }
526
+ /**
527
+ * The Sun's unrefracted geometric center crossing `altitudeDeg` within the
528
+ * half-open window `[startUtc, endUtc)`, sorted ascending: `rising` upward,
529
+ * `falling` downward. The twilight convention, so `-6` returns the civil
530
+ * twilight instants. A window with no crossing, as in polar day or night,
531
+ * returns an empty list.
532
+ */
533
+ export function sunAltitudeEvents(startUtc, endUtc, observer, altitudeDeg) {
534
+ if (!(altitudeDeg >= -90 && altitudeDeg <= 90))
535
+ throw new RangeError(`altitudeDeg out of range: ${altitudeDeg}`);
536
+ assertSupported(startUtc);
537
+ assertSupportedWindowEnd(endUtc);
538
+ assertObserver(observer);
539
+ if (startUtc.getTime() >= endUtc.getTime())
540
+ return [];
541
+ const levels = [{ level: altitudeDeg, rising: 'rising', falling: 'falling' }];
542
+ return searchAltitudeEvents(sunSampler(observer, 0), levels, null, null, utDays(startUtc), utDays(endUtc), SUN_HA_RATE_DEG_PER_DAY, SUN_CYCLE_DAYS, flatCycle(observer));
543
+ }
544
+ /**
545
+ * The Sun's upper (local hour angle 0) and lower (180°) transits within the
546
+ * half-open window `[startUtc, endUtc)`, sorted ascending, whether or not the
547
+ * Sun is above the horizon. The upper transit is `sunEvents`' `transit`.
548
+ */
549
+ export function sunTransits(startUtc, endUtc, observer) {
550
+ assertSupported(startUtc);
551
+ assertSupportedWindowEnd(endUtc);
552
+ assertObserver(observer);
553
+ if (startUtc.getTime() >= endUtc.getTime())
554
+ return [];
555
+ return searchAltitudeEvents(sunSampler(observer, 0), [], 'upper', 'lower', utDays(startUtc), utDays(endUtc), SUN_HA_RATE_DEG_PER_DAY, SUN_CYCLE_DAYS, flatCycle(observer));
556
+ }
557
+ /**
558
+ * The Moon's upper and lower transits within the half-open window
559
+ * `[startUtc, endUtc)`, sorted ascending, from the topocentric hour angle, so
560
+ * parallax is included, whether or not the Moon is above the horizon.
561
+ */
562
+ export function moonTransits(startUtc, endUtc, observer) {
563
+ assertSupported(startUtc);
564
+ assertSupportedWindowEnd(endUtc);
565
+ assertObserver(observer);
566
+ if (startUtc.getTime() >= endUtc.getTime())
567
+ return [];
568
+ return searchAltitudeEvents(moonSampler(observer, 0), [], 'upper', 'lower', utDays(startUtc), utDays(endUtc), MOON_HA_RATE_DEG_PER_DAY, MOON_CYCLE_DAYS, flatCycle(observer));
520
569
  }
521
570
  // -------------------------------------------------------------- moon phases
522
571
  /**
@@ -525,7 +574,7 @@ export function planetEvents(planet, startUtc, endUtc, observer, heightAboveGrou
525
574
  * moon-phase definition — upstream's `PairLongitude` uses geometric longitudes,
526
575
  * which sit a flat ~40 s off the USNO catalogue.
527
576
  */
528
- const MEAN_SYNODIC_MONTH = 29.530588;
577
+ export const MEAN_SYNODIC_MONTH = 29.530588;
529
578
  const PHASE_NAMES = ['new', 'firstQuarter', 'full', 'lastQuarter'];
530
579
  /** UPSTREAM: `QuadInterp`, astronomy.ts ~4528. */
531
580
  function quadInterp(tm, dt, fa, fm, fb) {
@@ -1,3 +1,4 @@
1
+ import { Observer } from './types.js';
1
2
  import { Vec3 } from './nutation.js';
2
3
  /** Moon illumination at an instant. `phase` is 0 at new moon, 0.5 at full. */
3
4
  export interface MoonIllumination {
@@ -8,6 +9,12 @@ export interface MoonIllumination {
8
9
  }
9
10
  /** UPSTREAM: `AngleBetween`, astronomy.ts lines 256-273 — angle between two vectors, [0, 180]. */
10
11
  export declare function angleBetweenDeg(a: Vec3, b: Vec3): number;
12
+ /**
13
+ * INTERNAL: the Sun's apparent geocentric ecliptic longitude of date, [0, 360)
14
+ * — upstream `SunPosition(t).elon` (astronomy.ts ~2738), which `seasons.ts`
15
+ * roots against 0/90/180/270. Aberration is in the vector, nutation in the frame.
16
+ */
17
+ export declare function sunEclipticLonDeg(tt: number): number;
11
18
  /**
12
19
  * INTERNAL: the Moon's apparent ecliptic longitude ahead of the Sun's, [0, 360)
13
20
  * — 0 at new moon, 180 at full. The single definition of lunar phase in this
@@ -33,3 +40,21 @@ export declare function moonIlluminationAtTT(tt: number): MoonIllumination;
33
40
  * phase (0 = new, 0.5 = full, `waxing` = phase < 0.5).
34
41
  */
35
42
  export declare function moonIllumination(time: Date): MoonIllumination;
43
+ /**
44
+ * How the Moon's disc is turned on the observer's sky. `brightLimbAngleDeg` is
45
+ * the position angle of the bright limb's midpoint from celestial north
46
+ * through east, in [0, 360); `parallacticAngleDeg` is the angle at the Moon
47
+ * from celestial north to the observer's zenith, in (-180, 180]. Their
48
+ * difference is the bright limb's angle from the zenith.
49
+ */
50
+ export interface MoonOrientation {
51
+ brightLimbAngleDeg: number;
52
+ parallacticAngleDeg: number;
53
+ }
54
+ /**
55
+ * The Moon's orientation at an instant: the bright limb from the apparent
56
+ * topocentric Moon and the apparent geocentric Sun (Meeus, Astronomical
57
+ * Algorithms, 48.5), and the parallactic angle from the Moon's topocentric
58
+ * hour angle and declination at the observer's geodetic latitude (Meeus 14.1).
59
+ */
60
+ export declare function moonOrientation(time: Date, observer: Observer): MoonOrientation;
@@ -8,8 +8,10 @@
8
8
  // 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts.
9
9
  //
10
10
  // INTERNAL: not part of the curated public API (see index.ts).
11
- import { assertSupported } from './types.js';
12
- import { ttDays } from './time.js';
11
+ import { assertObserver, assertSupported } from './types.js';
12
+ import { ttDays, ttDaysFromUt, utDays } from './time.js';
13
+ import { topoAltAzUnrefracted } from './transforms.js';
14
+ import { sunApparentAtTT } from './positions.js';
13
15
  import { DEG2RAD, PrecessDirection, RAD2DEG, earthTilt, eclipticToEquatorial, gyration } from './nutation.js';
14
16
  import { moonGeoVectorEqj } from './moon.js';
15
17
  import { earthHelioVector, sunGeoVectorEqj } from './sun.js';
@@ -42,6 +44,14 @@ function eclipticLonOfDateDeg(eqj, tt) {
42
44
  elon += 360;
43
45
  return elon;
44
46
  }
47
+ /**
48
+ * INTERNAL: the Sun's apparent geocentric ecliptic longitude of date, [0, 360)
49
+ * — upstream `SunPosition(t).elon` (astronomy.ts ~2738), which `seasons.ts`
50
+ * roots against 0/90/180/270. Aberration is in the vector, nutation in the frame.
51
+ */
52
+ export function sunEclipticLonDeg(tt) {
53
+ return eclipticLonOfDateDeg(sunGeoVectorEqj(tt), tt);
54
+ }
45
55
  /** UPSTREAM: `NormalizeLongitude`, astronomy.ts lines 4735-4739. */
46
56
  function normalizeLongitude(lon) {
47
57
  return ((lon % 360) + 360) % 360;
@@ -89,3 +99,31 @@ export function moonIllumination(time) {
89
99
  assertSupported(time);
90
100
  return moonIlluminationAtTT(ttDays(time));
91
101
  }
102
+ /**
103
+ * The Moon's orientation at an instant: the bright limb from the apparent
104
+ * topocentric Moon and the apparent geocentric Sun (Meeus, Astronomical
105
+ * Algorithms, 48.5), and the parallactic angle from the Moon's topocentric
106
+ * hour angle and declination at the observer's geodetic latitude (Meeus 14.1).
107
+ */
108
+ export function moonOrientation(time, observer) {
109
+ assertSupported(time);
110
+ assertObserver(observer);
111
+ const ut = utDays(time);
112
+ const tt = ttDaysFromUt(ut);
113
+ const moon = topoAltAzUnrefracted(moonGeoVectorEqj(tt), ut, observer);
114
+ const sun = sunApparentAtTT(tt);
115
+ const dec = moon.decDeg * DEG2RAD;
116
+ const sunDec = sun.decDeg * DEG2RAD;
117
+ const dRa = (sun.raDeg - moon.raDeg) * DEG2RAD;
118
+ let brightLimbAngleDeg = RAD2DEG * Math.atan2(Math.cos(sunDec) * Math.sin(dRa), Math.sin(sunDec) * Math.cos(dec) - Math.cos(sunDec) * Math.sin(dec) * Math.cos(dRa));
119
+ if (brightLimbAngleDeg < 0)
120
+ brightLimbAngleDeg += 360;
121
+ if (brightLimbAngleDeg >= 360)
122
+ brightLimbAngleDeg -= 360;
123
+ const ha = moon.hourAngleDeg * DEG2RAD;
124
+ const lat = observer.latitudeDeg * DEG2RAD;
125
+ let parallacticAngleDeg = RAD2DEG * Math.atan2(Math.sin(ha), Math.tan(lat) * Math.cos(dec) - Math.sin(dec) * Math.cos(ha));
126
+ if (parallacticAngleDeg <= -180)
127
+ parallacticAngleDeg += 360;
128
+ return { brightLimbAngleDeg, parallacticAngleDeg };
129
+ }