@openwaters/almanac 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Almanac
2
2
 
3
- Offline sky engine for the Salish Sea and everywhere else: Sun and Moon positions, rise, set, twilight, Moon phase, lunar eclipses, solar eclipses for an observer and anywhere on Earth, 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, 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
5
  Twin implementations, one behavior:
6
6
 
@@ -12,7 +12,7 @@ Twin implementations, one behavior:
12
12
  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.
13
13
 
14
14
  - Contract: [`docs/CONTRACT.md`](docs/CONTRACT.md), including coordinates, public behavior, accuracy, and fixture evidence.
15
- - Scope: [`docs/ROADMAP.md`](docs/ROADMAP.md), including supported behavior and deliberate boundaries.
15
+ - Scope: [`docs/SCOPE.md`](docs/SCOPE.md), including supported behavior and deliberate boundaries.
16
16
  - Development and releases: [`CONTRIBUTING.md`](CONTRIBUTING.md), including pinned tools and required checks.
17
17
  - Landing page: [openwaters.io/sky](https://openwaters.io/sky), with the library running live in a browser.
18
18
 
@@ -21,11 +21,13 @@ Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/ast
21
21
 
22
22
  ## Performance
23
23
 
24
- Almanac includes a shared performance harness for both ports: **27 workloads**
24
+ Almanac includes a shared performance harness for both ports: **34 workloads**
25
25
  cover positions, a 228-hour sky track, short/year/polar event windows, full-range
26
26
  moon phases, next/previous/range lunar, solar, and global solar eclipse searches,
27
27
  including empty windows, solar obscuration over a 228-hour track, and the central
28
- line of the 2024-04-08 eclipse.
28
+ line of the 2024-04-08 eclipse, five-planet sky tracks, planetary rise/set, a
29
+ year of golden-hour crossings and Sun and Moon transits, and a 228-hour Moon
30
+ orientation track.
29
31
 
30
32
  Event searches find altitude extrema with Brent's method and altitude crossings
31
33
  with a cosine-seeded secant solver, each root proven inside a half-second
@@ -49,10 +51,10 @@ Reproduce the comparison locally from the repository root with mise 2026.9.1 or
49
51
  ```bash
50
52
  mise install
51
53
  mise exec -- npm ci --prefix typescript
52
- mise exec -- node benchmarks/run.mjs --base v0.4.1 --skip '^global/'
54
+ mise exec -- node benchmarks/run.mjs --base v0.4.1 --skip '^(global|planets|suncalc)/'
53
55
  ```
54
56
 
55
- The `--skip` pattern leaves out the global solar eclipse workloads, which need a base of 0.5.0 or later. Use `--skip '^(solar|global)/'` when the base predates 0.4.0, which is when the solar eclipse workloads arrived.
57
+ 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
58
 
57
59
  CI runs the harness on code changes and fails on **median regressions over 20%**.
58
60
  Results include timing tables, raw samples, checksums, and revision/toolchain
@@ -64,6 +66,10 @@ background task; use shorter windows for interactive queries.
64
66
 
65
67
  ## Usage
66
68
 
69
+ Migrating from SunCalc: [`docs/migrating-from-suncalc.md`](docs/migrating-from-suncalc.md) maps every SunCalc call to Almanac and gives drop-in `getTimes` and `getMoonTimes` adapters. Migrating from Solar: [`docs/migrating-from-solar.md`](docs/migrating-from-solar.md). How Almanac compares with SunCalc, Astronomy Engine, astronomia, Solar, SwiftAA, and EDSunriseSet, and when each is the better choice: [`docs/comparison.md`](docs/comparison.md).
70
+
71
+ Migrating from the Solar Swift library: [`docs/migrating-from-solar.md`](docs/migrating-from-solar.md) maps every Solar property to Almanac and gives a drop-in `Solar` adapter.
72
+
67
73
  ### TypeScript
68
74
 
69
75
  ```bash
@@ -98,10 +104,21 @@ for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
98
104
  const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
99
105
  ```
100
106
 
107
+ 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:
108
+
109
+ ```ts
110
+ import { horizonDip, sunAltAz } from '@openwaters/almanac';
111
+ const eyeHeightM = 2;
112
+ const viewer = { latitudeDeg: 48.5, longitudeDeg: -123.0, elevationM: 2 };
113
+ const horizonAltDeg = horizonDip(viewer, eyeHeightM);
114
+ const sunAltitudeAboveHorizonDeg = sunAltAz(new Date(), viewer).altDeg - horizonAltDeg;
115
+ const crossings = sunEvents(today, tomorrow, viewer, eyeHeightM);
116
+ ```
117
+
101
118
  ### Swift
102
119
 
103
120
  ```swift
104
- .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.6.0")
121
+ .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.8.0")
105
122
  ```
106
123
 
107
124
  ```swift
@@ -134,6 +151,65 @@ for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
134
151
  let star = try starAltAz(raDeg: 88.792939, decDeg: 7.407064, at: today, observer: observer)
135
152
  ```
136
153
 
154
+ ### Golden hour, transits, and the Moon's tilt
155
+
156
+ `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.
157
+
158
+ ```ts
159
+ import { sunAltitudeEvents, sunTransits, moonTransits, moonOrientation } from '@openwaters/almanac';
160
+ const golden = sunAltitudeEvents(today, tomorrow, observer, 6); // 'rising' ends morning golden hour, 'falling' starts the evening one
161
+ const solarMidnight = sunTransits(today, tomorrow, observer).find(e => e.kind === 'lower');
162
+ const moonHighest = moonTransits(today, tomorrow, observer).find(e => e.kind === 'upper');
163
+ const { brightLimbAngleDeg, parallacticAngleDeg } = moonOrientation(today, observer);
164
+ const brightLimbFromZenithDeg = brightLimbAngleDeg - parallacticAngleDeg;
165
+ ```
166
+
167
+ ```swift
168
+ let golden = try sunAltitudeEvents(from: today, to: tomorrow, observer: observer, altitudeDeg: 6)
169
+ let solarMidnight = try sunTransits(from: today, to: tomorrow, observer: observer).first { $0.kind == .lower }
170
+ let moonHighest = try moonTransits(from: today, to: tomorrow, observer: observer).first { $0.kind == .upper }
171
+ let orientation = try moonOrientation(at: today, observer: observer)
172
+ let brightLimbFromZenithDeg = orientation.brightLimbAngleDeg - orientation.parallacticAngleDeg
173
+ ```
174
+
175
+ ### Planetary views
176
+
177
+ 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:
178
+
179
+ ```ts
180
+ import { planetHeliocentricPosition, planetAltAz, planetIllumination, planetEvents,
181
+ horizonDip, sunAltAz } from '@openwaters/almanac';
182
+ import type { Planet } from '@openwaters/almanac';
183
+
184
+ const planets: Planet[] = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
185
+ const now = new Date();
186
+ const positions = planets.map(planet => ({ planet, ...planetHeliocentricPosition(planet, now) }));
187
+
188
+ const viewer = { latitudeDeg: 48.5, longitudeDeg: -123.0, elevationM: 2 };
189
+ const horizonAltDeg = horizonDip(viewer, 2);
190
+ const venus = planetAltAz('venus', now, viewer);
191
+ const brightness = planetIllumination('venus', now);
192
+ console.log(venus.azDeg, venus.altDeg - horizonAltDeg, sunAltAz(now, viewer).altDeg,
193
+ brightness.magnitude, brightness.elongationDeg);
194
+ const crossings = planetEvents('venus', now, new Date(now.getTime() + 86400000), viewer, 2);
195
+ ```
196
+
197
+ ```swift
198
+ let now = Date()
199
+ let positions = try Planet.allCases.map { try planetHeliocentricPosition($0, at: now) }
200
+ let viewer = try Observer(latitudeDeg: 48.5, longitudeDeg: -123.0, elevationM: 2)
201
+ let horizon = try horizonDip(observer: viewer, heightAboveGroundM: 2)
202
+ let venus = try planetAltAz(.venus, at: now, observer: viewer)
203
+ let brightness = try planetIllumination(.venus, at: now)
204
+ print(venus.azDeg, venus.altDeg - horizon, brightness.magnitude, brightness.elongationDeg)
205
+ let crossings = try planetEvents(.venus, from: now, to: now.addingTimeInterval(86400),
206
+ observer: viewer, heightAboveGroundM: 2)
207
+ ```
208
+
209
+ Earth is included for the solar-system view. Earth-based position, altitude/azimuth, illumination, and rise/set reject Earth. Sky coordinates include light-time and apparent corrections; heliocentric scene coordinates do not.
210
+
211
+ A viewing-window policy can combine planet altitude above the horizon, Sun altitude, elongation, and brightness. Actual visibility also depends on weather and observing conditions. Magnitude is approximate: the pinned model and Horizons differ by up to 0.75 magnitudes for extreme Venus crescents in the fixture interval.
212
+
137
213
  ### Eclipse searches
138
214
 
139
215
  Search for a previous eclipse or all eclipses in a time window:
package/dist/eclipse.d.ts CHANGED
@@ -127,4 +127,4 @@ export declare function lunarEclipses(startUtc: Date, endUtc: Date): LunarEclips
127
127
  *
128
128
  * @throws {RangeError} if `eclipse` is structurally invalid or `observer` is out of range.
129
129
  */
130
- export declare function lunarEclipseVisibility(eclipse: LunarEclipse, observer: Observer): LunarEclipseVisibility;
130
+ export declare function lunarEclipseVisibility(eclipse: LunarEclipse, observer: Observer, heightAboveGroundM?: number): LunarEclipseVisibility;
package/dist/eclipse.js CHANGED
@@ -17,12 +17,13 @@
17
17
  // result.
18
18
  // - upstream reports semi-durations in minutes; the spec's public shape is
19
19
  // the contact instants themselves, and `obscuration` is not in it.
20
- import { assertObserver, assertSupported, assertSupportedWindowEnd, AlmanacOutOfRangeError, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
20
+ import { assertSupported, assertSupportedWindowEnd, AlmanacOutOfRangeError, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
21
21
  import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
22
22
  import { KM_PER_AU, RAD2DEG } from './nutation.js';
23
23
  import { calcMoon, moonGeoVectorEqj } from './moon.js';
24
24
  import { sunGeoVectorEqj } from './sun.js';
25
25
  import { topoAltAzUnrefracted } from './transforms.js';
26
+ import { horizonDip } from './horizon.js';
26
27
  import { MOON_MEAN_RADIUS_KM, SUN_RADIUS_KM, search, searchMoonPhase } from './events.js';
27
28
  /** UPSTREAM: `EARTH_MEAN_RADIUS_KM` + `EARTH_ATMOSPHERE_KM`, astronomy.ts 142-144.
28
29
  * The 88 km of atmosphere is what makes the umbra match observed eclipse
@@ -306,19 +307,19 @@ function assertLunarEclipse(e) {
306
307
  *
307
308
  * @throws {RangeError} if `eclipse` is structurally invalid or `observer` is out of range.
308
309
  */
309
- export function lunarEclipseVisibility(eclipse, observer) {
310
+ export function lunarEclipseVisibility(eclipse, observer, heightAboveGroundM = 0) {
310
311
  assertLunarEclipse(eclipse);
311
- assertObserver(observer);
312
+ const dip = horizonDip(observer, heightAboveGroundM);
312
313
  const altAt = (d) => {
313
314
  const ut = utDays(d);
314
315
  return topoAltAzUnrefracted(moonGeoVectorEqj(ttDaysFromUt(ut)), ut, observer).altDeg;
315
316
  };
316
317
  // `== null` deliberately: the validator counts `undefined` as absent too,
317
318
  // so the reader must, or an `undefined` contact would reach `utDays` raw.
318
- const up = (d) => (d == null ? null : altAt(d) > 0);
319
+ const up = (d) => (d == null ? null : altAt(d) > dip);
319
320
  const peakAltDeg = altAt(eclipse.peak);
320
321
  return {
321
- visibleAtPeak: peakAltDeg > 0,
322
+ visibleAtPeak: peakAltDeg > dip,
322
323
  moonGeometricAltAtPeakDeg: peakAltDeg,
323
324
  contactsVisible: {
324
325
  p1: up(eclipse.p1),
package/dist/events.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Observer } from './types.js';
1
+ import { Observer, type Planet } from './types.js';
2
2
  /** A Sun event: a horizon or twilight crossing, or upper transit. */
3
3
  export type SunEventKind = 'rise' | 'set' | 'civilDawn' | 'civilDusk' | 'nauticalDawn' | 'nauticalDusk' | 'astroDawn' | 'astroDusk' | 'transit';
4
4
  export interface SunEvent {
@@ -11,6 +11,24 @@ export interface MoonEvent {
11
11
  time: Date;
12
12
  kind: MoonEventKind;
13
13
  }
14
+ /** A planet's point center crossing the apparent horizon. */
15
+ export type PlanetEventKind = 'rise' | 'set';
16
+ export interface PlanetEvent {
17
+ time: Date;
18
+ kind: PlanetEventKind;
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
+ }
14
32
  /** A quarter lunar phase. */
15
33
  export type MoonPhaseName = 'new' | 'firstQuarter' | 'full' | 'lastQuarter';
16
34
  export interface MoonPhaseEvent {
@@ -41,7 +59,7 @@ export declare function quantizedUt(ut: number): number;
41
59
  * Sun rise, set, the three twilights and upper transit within the half-open
42
60
  * window `[startUtc, endUtc)`, sorted ascending.
43
61
  *
44
- * Rise and set are the unrefracted geometric centre altitude at −(34′ + the
62
+ * Rise and set use the supplied horizon dip minus (34′ + the
45
63
  * Sun's true semidiameter at distance) — the upper-limb convention with the
46
64
  * actual disc, the same rule the Moon gets; the twilights are centre altitude
47
65
  * −6°, −12° and −18° with no refraction term; transit is local hour angle
@@ -49,14 +67,36 @@ export declare function quantizedUt(ut: number): number;
49
67
  * the crossings — while transit is reported regardless of whether the Sun is
50
68
  * above the horizon when it happens.
51
69
  */
52
- export declare function sunEvents(startUtc: Date, endUtc: Date, observer: Observer): SunEvent[];
70
+ export declare function sunEvents(startUtc: Date, endUtc: Date, observer: Observer, heightAboveGroundM?: number): SunEvent[];
53
71
  /**
54
72
  * Moonrise and moonset within the half-open window `[startUtc, endUtc)`,
55
73
  * sorted ascending — the apparent topocentric upper limb crossing the horizon,
56
74
  * so refraction (34′), topocentric parallax and the true semidiameter at the
57
75
  * Moon's distance are all included — the same upper-limb rule as the Sun's. An empty list is a valid answer.
58
76
  */
59
- export declare function moonEvents(startUtc: Date, endUtc: Date, observer: Observer): MoonEvent[];
77
+ export declare function moonEvents(startUtc: Date, endUtc: Date, observer: Observer, heightAboveGroundM?: number): MoonEvent[];
78
+ /** Rise/set of a point center at geometric altitude horizonDip minus 34 arcminutes. */
79
+ export declare function planetEvents(planet: Planet, startUtc: Date, endUtc: Date, observer: Observer, heightAboveGroundM?: number): PlanetEvent[];
80
+ /**
81
+ * The Sun's unrefracted geometric center crossing `altitudeDeg` within the
82
+ * half-open window `[startUtc, endUtc)`, sorted ascending: `rising` upward,
83
+ * `falling` downward. The twilight convention, so `-6` returns the civil
84
+ * twilight instants. A window with no crossing, as in polar day or night,
85
+ * returns an empty list.
86
+ */
87
+ export declare function sunAltitudeEvents(startUtc: Date, endUtc: Date, observer: Observer, altitudeDeg: number): AltitudeEvent[];
88
+ /**
89
+ * The Sun's upper (local hour angle 0) and lower (180°) transits within the
90
+ * half-open window `[startUtc, endUtc)`, sorted ascending, whether or not the
91
+ * Sun is above the horizon. The upper transit is `sunEvents`' `transit`.
92
+ */
93
+ export declare function sunTransits(startUtc: Date, endUtc: Date, observer: Observer): TransitEvent[];
94
+ /**
95
+ * The Moon's upper and lower transits within the half-open window
96
+ * `[startUtc, endUtc)`, sorted ascending, from the topocentric hour angle, so
97
+ * parallax is included, whether or not the Moon is above the horizon.
98
+ */
99
+ export declare function moonTransits(startUtc: Date, endUtc: Date, observer: Observer): TransitEvent[];
60
100
  /**
61
101
  * UPSTREAM: `Search`, astronomy.ts ~4634 — bisection with quadratic
62
102
  * acceleration for the ascending zero crossing of `f` in `[t1, t2]`. Times are
package/dist/events.js CHANGED
@@ -21,13 +21,15 @@
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 { assertObserver, 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';
28
28
  import { sunGeoVectorEqj } from './sun.js';
29
29
  import { moonGeoVectorEqj } from './moon.js';
30
30
  import { moonPhaseDeg } from './illumination.js';
31
+ import { horizonDip } from './horizon.js';
32
+ import { planetGeoVectorEqj } from './planetModels.js';
31
33
  // ---------------------------------------------------------------- constants
32
34
  const CIVIL_ALT_DEG = -6;
33
35
  const NAUTICAL_ALT_DEG = -12;
@@ -114,24 +116,30 @@ function assertReached(condition, what) {
114
116
  if (!condition)
115
117
  throw new Error(`almanac internal: ${what} did not converge`);
116
118
  }
117
- function sunSampler(observer) {
119
+ function sunSampler(observer, dip) {
118
120
  return (ut) => {
119
121
  const p = topoAltAzUnrefracted(sunGeoVectorEqj(ttDaysFromUt(ut)), ut, observer);
120
122
  return {
121
123
  ut, altDeg: p.altDeg, hourAngleDeg: p.hourAngleDeg,
122
- riseSetAltDeg: upperLimbTargetDeg(SUN_RADIUS_KM, p.distanceAu)
124
+ riseSetAltDeg: dip + upperLimbTargetDeg(SUN_RADIUS_KM, p.distanceAu)
123
125
  };
124
126
  };
125
127
  }
126
- function moonSampler(observer) {
128
+ function moonSampler(observer, dip) {
127
129
  return (ut) => {
128
130
  const p = topoAltAzUnrefracted(moonGeoVectorEqj(ttDaysFromUt(ut)), ut, observer);
129
131
  return {
130
132
  ut, altDeg: p.altDeg, hourAngleDeg: p.hourAngleDeg,
131
- riseSetAltDeg: upperLimbTargetDeg(MOON_MEAN_RADIUS_KM, p.distanceAu)
133
+ riseSetAltDeg: dip + upperLimbTargetDeg(MOON_MEAN_RADIUS_KM, p.distanceAu)
132
134
  };
133
135
  };
134
136
  }
137
+ function planetSampler(planet, observer, dip) {
138
+ return (ut) => {
139
+ const p = topoAltAzUnrefracted(planetGeoVectorEqj(planet, ttDaysFromUt(ut)), ut, observer);
140
+ return { ut, altDeg: p.altDeg, hourAngleDeg: p.hourAngleDeg, riseSetAltDeg: dip - HORIZON_REFRACTION_DEG };
141
+ };
142
+ }
135
143
  function offsetDeg(s, level) {
136
144
  return s.altDeg - (level === null ? s.riseSetAltDeg : level);
137
145
  }
@@ -404,9 +412,12 @@ function secantCrossing(f, a, b, s0, s1, targetDeg) {
404
412
  * solving every monotonic segment between consecutive extrema for every target
405
413
  * level. The chain starts a full cycle before `startUt` so the segment
406
414
  * containing the window's first instant is covered, and runs one extremum past
407
- * `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.
408
419
  */
409
- function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRateDegPerDay, cycleDays, flatCycle) {
420
+ function searchAltitudeEvents(sample, levels, transitKind, lowerTransitKind, startUt, endUt, haRateDegPerDay, cycleDays, flatCycle) {
410
421
  const extremumHalfWidthDays = flatCycle ? cycleDays / 2 : EXTREMUM_HALF_WIDTH_DAYS;
411
422
  const found = [];
412
423
  const emit = (ut, kind) => {
@@ -430,7 +441,8 @@ function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRat
430
441
  // extremum we are not allowed to probe past. The segment up to it is
431
442
  // still monotonic, so any crossing inside the window is still found.
432
443
  const s1 = atRangeEnd ? sample(MAX_UT)
433
- : refineExtremum(sample, nextEst, nextIsMax, extremumHalfWidthDays, s0.ut, flatCycle);
444
+ : levels.length === 0 ? sample(nextEst)
445
+ : refineExtremum(sample, nextEst, nextIsMax, extremumHalfWidthDays, s0.ut, flatCycle);
434
446
  assertReached(s1.ut > s0.ut, 'extremum chain ordering');
435
447
  // Earlier extrema still establish the chain, but their crossings cannot be emitted.
436
448
  if (quantizedUt(s1.ut) >= startUt) {
@@ -442,8 +454,9 @@ function searchAltitudeEvents(sample, levels, transitKind, startUt, endUt, haRat
442
454
  emit(solveCrossing(sample, spec.level, s0, s1), g1 > 0 ? spec.rising : spec.falling);
443
455
  }
444
456
  }
445
- if (nextIsMax && transitKind !== null && !atRangeEnd)
446
- emit(nextEst, transitKind);
457
+ const transit = nextIsMax ? transitKind : lowerTransitKind;
458
+ if (transit !== null && !atRangeEnd)
459
+ emit(nextEst, transit);
447
460
  s0 = s1;
448
461
  if (atRangeEnd)
449
462
  break;
@@ -469,7 +482,7 @@ function flatCycle(observer) {
469
482
  * Sun rise, set, the three twilights and upper transit within the half-open
470
483
  * window `[startUtc, endUtc)`, sorted ascending.
471
484
  *
472
- * Rise and set are the unrefracted geometric centre altitude at −(34′ + the
485
+ * Rise and set use the supplied horizon dip minus (34′ + the
473
486
  * Sun's true semidiameter at distance) — the upper-limb convention with the
474
487
  * actual disc, the same rule the Moon gets; the twilights are centre altitude
475
488
  * −6°, −12° and −18° with no refraction term; transit is local hour angle
@@ -477,13 +490,13 @@ function flatCycle(observer) {
477
490
  * the crossings — while transit is reported regardless of whether the Sun is
478
491
  * above the horizon when it happens.
479
492
  */
480
- export function sunEvents(startUtc, endUtc, observer) {
493
+ export function sunEvents(startUtc, endUtc, observer, heightAboveGroundM = 0) {
481
494
  assertSupported(startUtc);
482
495
  assertSupportedWindowEnd(endUtc);
483
- assertObserver(observer);
496
+ const dip = horizonDip(observer, heightAboveGroundM);
484
497
  if (startUtc.getTime() >= endUtc.getTime())
485
498
  return [];
486
- return searchAltitudeEvents(sunSampler(observer), 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));
487
500
  }
488
501
  /**
489
502
  * Moonrise and moonset within the half-open window `[startUtc, endUtc)`,
@@ -491,13 +504,68 @@ export function sunEvents(startUtc, endUtc, observer) {
491
504
  * so refraction (34′), topocentric parallax and the true semidiameter at the
492
505
  * Moon's distance are all included — the same upper-limb rule as the Sun's. An empty list is a valid answer.
493
506
  */
494
- export function moonEvents(startUtc, endUtc, observer) {
507
+ export function moonEvents(startUtc, endUtc, observer, heightAboveGroundM = 0) {
508
+ assertSupported(startUtc);
509
+ assertSupportedWindowEnd(endUtc);
510
+ const dip = horizonDip(observer, heightAboveGroundM);
511
+ if (startUtc.getTime() >= endUtc.getTime())
512
+ return [];
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));
514
+ }
515
+ /** Rise/set of a point center at geometric altitude horizonDip minus 34 arcminutes. */
516
+ export function planetEvents(planet, startUtc, endUtc, observer, heightAboveGroundM = 0) {
517
+ assertPlanet(planet);
518
+ assertSupported(startUtc);
519
+ assertSupportedWindowEnd(endUtc);
520
+ const dip = horizonDip(observer, heightAboveGroundM);
521
+ if (startUtc.getTime() >= endUtc.getTime())
522
+ return [];
523
+ const cycleDays = 0.9972695717592592;
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) {
495
563
  assertSupported(startUtc);
496
564
  assertSupportedWindowEnd(endUtc);
497
565
  assertObserver(observer);
498
566
  if (startUtc.getTime() >= endUtc.getTime())
499
567
  return [];
500
- return searchAltitudeEvents(moonSampler(observer), MOON_LEVELS, null, utDays(startUtc), utDays(endUtc), MOON_HA_RATE_DEG_PER_DAY, MOON_CYCLE_DAYS, flatCycle(observer));
568
+ return searchAltitudeEvents(moonSampler(observer, 0), [], 'upper', 'lower', utDays(startUtc), utDays(endUtc), MOON_HA_RATE_DEG_PER_DAY, MOON_CYCLE_DAYS, flatCycle(observer));
501
569
  }
502
570
  // -------------------------------------------------------------- moon phases
503
571
  /**
@@ -0,0 +1,3 @@
1
+ import { Observer } from './types.js';
2
+ /** Apparent horizon altitude below the horizontal plane; eye height is measured above the unobstructed surface. */
3
+ export declare function horizonDip(observer: Observer, heightAboveGroundM: number): number;
@@ -0,0 +1,24 @@
1
+ import { assertObserver } from './types.js';
2
+ import { DEG2RAD, RAD2DEG, EARTH_FLATTENING, EARTH_EQUATORIAL_RADIUS_KM } from './nutation.js';
3
+ /** Apparent horizon altitude below the horizontal plane; eye height is measured above the unobstructed surface. */
4
+ export function horizonDip(observer, heightAboveGroundM) {
5
+ assertObserver(observer);
6
+ const elevation = observer.elevationM ?? 0;
7
+ const ground = elevation - heightAboveGroundM;
8
+ if (!(heightAboveGroundM >= 0 && heightAboveGroundM <= 10000) || !(ground >= -500 && ground <= 10000))
9
+ throw new RangeError('eye height or ground elevation out of range');
10
+ if (heightAboveGroundM === 0)
11
+ return 0;
12
+ // HorizonDipAngle: https://github.com/cosinekitty/astronomy/blob/865d3da7d8112bbc7911238052c6af4aaf877181/source/js/astronomy.ts#L5437.
13
+ const phi = observer.latitudeDeg * DEG2RAD;
14
+ const sinphi = Math.sin(phi);
15
+ const cosphi = Math.cos(phi);
16
+ const c = 1 / Math.hypot(cosphi, sinphi * EARTH_FLATTENING);
17
+ const s = c * (EARTH_FLATTENING * EARTH_FLATTENING);
18
+ const htKm = ground / 1000;
19
+ const ach = EARTH_EQUATORIAL_RADIUS_KM * c + htKm;
20
+ const ash = EARTH_EQUATORIAL_RADIUS_KM * s + htKm;
21
+ const radiusM = 1000 * Math.hypot(ach * cosphi, ash * sinphi);
22
+ const k = 0.175 * Math.pow(1 - (6.5e-3 / 283.15) * (elevation - (2 / 3) * heightAboveGroundM), 3.256);
23
+ return RAD2DEG * -(Math.sqrt(2 * (1 - k) * heightAboveGroundM / radiusM) / (1 - k));
24
+ }
@@ -1,3 +1,5 @@
1
+ import { Observer } from './types.js';
2
+ import { Vec3 } from './nutation.js';
1
3
  /** Moon illumination at an instant. `phase` is 0 at new moon, 0.5 at full. */
2
4
  export interface MoonIllumination {
3
5
  fraction: number;
@@ -5,6 +7,8 @@ export interface MoonIllumination {
5
7
  phase: number;
6
8
  waxing: boolean;
7
9
  }
10
+ /** UPSTREAM: `AngleBetween`, astronomy.ts lines 256-273 — angle between two vectors, [0, 180]. */
11
+ export declare function angleBetweenDeg(a: Vec3, b: Vec3): number;
8
12
  /**
9
13
  * INTERNAL: the Moon's apparent ecliptic longitude ahead of the Sun's, [0, 360)
10
14
  * — 0 at new moon, 180 at full. The single definition of lunar phase in this
@@ -30,3 +34,21 @@ export declare function moonIlluminationAtTT(tt: number): MoonIllumination;
30
34
  * phase (0 = new, 0.5 = full, `waxing` = phase < 0.5).
31
35
  */
32
36
  export declare function moonIllumination(time: Date): MoonIllumination;
37
+ /**
38
+ * How the Moon's disc is turned on the observer's sky. `brightLimbAngleDeg` is
39
+ * the position angle of the bright limb's midpoint from celestial north
40
+ * through east, in [0, 360); `parallacticAngleDeg` is the angle at the Moon
41
+ * from celestial north to the observer's zenith, in (-180, 180]. Their
42
+ * difference is the bright limb's angle from the zenith.
43
+ */
44
+ export interface MoonOrientation {
45
+ brightLimbAngleDeg: number;
46
+ parallacticAngleDeg: number;
47
+ }
48
+ /**
49
+ * The Moon's orientation at an instant: the bright limb from the apparent
50
+ * topocentric Moon and the apparent geocentric Sun (Meeus, Astronomical
51
+ * Algorithms, 48.5), and the parallactic angle from the Moon's topocentric
52
+ * hour angle and declination at the observer's geodetic latitude (Meeus 14.1).
53
+ */
54
+ export declare function moonOrientation(time: Date, observer: Observer): MoonOrientation;
@@ -8,13 +8,15 @@
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';
16
18
  /** UPSTREAM: `AngleBetween`, astronomy.ts lines 256-273 — angle between two vectors, [0, 180]. */
17
- function angleBetweenDeg(a, b) {
19
+ export function angleBetweenDeg(a, b) {
18
20
  const aa = a.x * a.x + a.y * a.y + a.z * a.z;
19
21
  const bb = b.x * b.x + b.y * b.y + b.z * b.z;
20
22
  const dot = (a.x * b.x + a.y * b.y + a.z * b.z) / Math.sqrt(aa * bb);
@@ -89,3 +91,31 @@ export function moonIllumination(time) {
89
91
  assertSupported(time);
90
92
  return moonIlluminationAtTT(ttDays(time));
91
93
  }
94
+ /**
95
+ * The Moon's orientation at an instant: the bright limb from the apparent
96
+ * topocentric Moon and the apparent geocentric Sun (Meeus, Astronomical
97
+ * Algorithms, 48.5), and the parallactic angle from the Moon's topocentric
98
+ * hour angle and declination at the observer's geodetic latitude (Meeus 14.1).
99
+ */
100
+ export function moonOrientation(time, observer) {
101
+ assertSupported(time);
102
+ assertObserver(observer);
103
+ const ut = utDays(time);
104
+ const tt = ttDaysFromUt(ut);
105
+ const moon = topoAltAzUnrefracted(moonGeoVectorEqj(tt), ut, observer);
106
+ const sun = sunApparentAtTT(tt);
107
+ const dec = moon.decDeg * DEG2RAD;
108
+ const sunDec = sun.decDeg * DEG2RAD;
109
+ const dRa = (sun.raDeg - moon.raDeg) * DEG2RAD;
110
+ 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));
111
+ if (brightLimbAngleDeg < 0)
112
+ brightLimbAngleDeg += 360;
113
+ if (brightLimbAngleDeg >= 360)
114
+ brightLimbAngleDeg -= 360;
115
+ const ha = moon.hourAngleDeg * DEG2RAD;
116
+ const lat = observer.latitudeDeg * DEG2RAD;
117
+ let parallacticAngleDeg = RAD2DEG * Math.atan2(Math.sin(ha), Math.tan(lat) * Math.cos(dec) - Math.sin(dec) * Math.cos(ha));
118
+ if (parallacticAngleDeg <= -180)
119
+ parallacticAngleDeg += 360;
120
+ return { brightLimbAngleDeg, parallacticAngleDeg };
121
+ }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,16 @@
1
- export type { Observer } from './types.js';
1
+ export type { Observer, Planet } from './types.js';
2
+ export type { HeliocentricPosition, PlanetPosition, PlanetIllumination } from './planets.js';
3
+ export { planetHeliocentricPosition, planetPosition, planetAltAz, planetIllumination } from './planets.js';
4
+ export { horizonDip } from './horizon.js';
2
5
  export { AlmanacOutOfRangeError } from './types.js';
3
6
  export type { SunPosition, MoonPosition } from './positions.js';
4
7
  export { sunPosition, moonPosition } from './positions.js';
5
8
  export type { AltAz } from './transforms.js';
6
9
  export { sunAltAz, moonAltAz, starAltAz } 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';
10
+ export type { MoonIllumination, MoonOrientation } from './illumination.js';
11
+ export { moonIllumination, moonOrientation } from './illumination.js';
12
+ export type { SunEvent, SunEventKind, MoonEvent, MoonEventKind, MoonPhaseEvent, MoonPhaseName, PlanetEvent, PlanetEventKind, AltitudeEvent, AltitudeEventKind, TransitEvent, TransitEventKind } from './events.js';
13
+ export { sunEvents, moonEvents, searchMoonPhases, planetEvents, sunAltitudeEvents, sunTransits, moonTransits } from './events.js';
11
14
  export type { LunarEclipse, LunarEclipseVisibility } from './eclipse.js';
12
15
  export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
13
16
  export type { SolarEclipse, SolarEclipseKind, SolarEclipseSunAltitudes } from './solar.js';
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
+ export { planetHeliocentricPosition, planetPosition, planetAltAz, planetIllumination } from './planets.js';
2
+ export { horizonDip } from './horizon.js';
1
3
  export { AlmanacOutOfRangeError } from './types.js';
2
4
  export { sunPosition, moonPosition } from './positions.js';
3
5
  export { sunAltAz, moonAltAz, starAltAz } from './transforms.js';
4
- export { moonIllumination } from './illumination.js';
5
- export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
6
+ export { moonIllumination, moonOrientation } from './illumination.js';
7
+ export { sunEvents, moonEvents, searchMoonPhases, planetEvents, sunAltitudeEvents, sunTransits, moonTransits } from './events.js';
6
8
  export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
7
9
  export { nextSolarEclipse, previousSolarEclipse, solarEclipses, solarObscuration } from './solar.js';
8
10
  export { nextGlobalSolarEclipse, previousGlobalSolarEclipse, globalSolarEclipses, solarEclipseAxisPoint, solarEclipseCentralLine } from './globalSolar.js';
@@ -0,0 +1,5 @@
1
+ import { type Planet } from './types.js';
2
+ import { type Vec3 } from './nutation.js';
3
+ /** INTERNAL: geometric Sun-centered vector in AU, J2000 mean equator. */
4
+ export declare function planetHelioVector(planet: Planet, tt: number): Vec3;
5
+ export declare function planetGeoVectorEqj(planet: Planet, tt: number, aberration?: boolean): Vec3;