@openwaters/almanac 0.5.0 → 0.7.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, 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.
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,11 @@ 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: **30 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, and planetary rise/set.
29
29
 
30
30
  Event searches find altitude extrema with Brent's method and altitude crossings
31
31
  with a cosine-seeded secant solver, each root proven inside a half-second
@@ -49,10 +49,10 @@ Reproduce the comparison locally from the repository root with mise 2026.9.1 or
49
49
  ```bash
50
50
  mise install
51
51
  mise exec -- npm ci --prefix typescript
52
- mise exec -- node benchmarks/run.mjs --base v0.4.1 --skip '^global/'
52
+ mise exec -- node benchmarks/run.mjs --base v0.4.1 --skip '^(global|planets)/'
53
53
  ```
54
54
 
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.
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/'`.
56
56
 
57
57
  CI runs the harness on code changes and fails on **median regressions over 20%**.
58
58
  Results include timing tables, raw samples, checksums, and revision/toolchain
@@ -98,10 +98,21 @@ for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
98
98
  const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
99
99
  ```
100
100
 
101
+ 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:
102
+
103
+ ```ts
104
+ import { horizonDip, sunAltAz } from '@openwaters/almanac';
105
+ const eyeHeightM = 2;
106
+ const viewer = { latitudeDeg: 48.5, longitudeDeg: -123.0, elevationM: 2 };
107
+ const horizonAltDeg = horizonDip(viewer, eyeHeightM);
108
+ const sunAltitudeAboveHorizonDeg = sunAltAz(new Date(), viewer).altDeg - horizonAltDeg;
109
+ const crossings = sunEvents(today, tomorrow, viewer, eyeHeightM);
110
+ ```
111
+
101
112
  ### Swift
102
113
 
103
114
  ```swift
104
- .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.5.0")
115
+ .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.7.0")
105
116
  ```
106
117
 
107
118
  ```swift
@@ -134,6 +145,44 @@ for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
134
145
  let star = try starAltAz(raDeg: 88.792939, decDeg: 7.407064, at: today, observer: observer)
135
146
  ```
136
147
 
148
+ ### Planetary views
149
+
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:
151
+
152
+ ```ts
153
+ import { planetHeliocentricPosition, planetAltAz, planetIllumination, planetEvents,
154
+ horizonDip, sunAltAz } from '@openwaters/almanac';
155
+ import type { Planet } from '@openwaters/almanac';
156
+
157
+ const planets: Planet[] = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn'];
158
+ const now = new Date();
159
+ const positions = planets.map(planet => ({ planet, ...planetHeliocentricPosition(planet, now) }));
160
+
161
+ const viewer = { latitudeDeg: 48.5, longitudeDeg: -123.0, elevationM: 2 };
162
+ const horizonAltDeg = horizonDip(viewer, 2);
163
+ const venus = planetAltAz('venus', now, viewer);
164
+ const brightness = planetIllumination('venus', now);
165
+ console.log(venus.azDeg, venus.altDeg - horizonAltDeg, sunAltAz(now, viewer).altDeg,
166
+ brightness.magnitude, brightness.elongationDeg);
167
+ const crossings = planetEvents('venus', now, new Date(now.getTime() + 86400000), viewer, 2);
168
+ ```
169
+
170
+ ```swift
171
+ let now = Date()
172
+ let positions = try Planet.allCases.map { try planetHeliocentricPosition($0, at: now) }
173
+ let viewer = try Observer(latitudeDeg: 48.5, longitudeDeg: -123.0, elevationM: 2)
174
+ let horizon = try horizonDip(observer: viewer, heightAboveGroundM: 2)
175
+ let venus = try planetAltAz(.venus, at: now, observer: viewer)
176
+ let brightness = try planetIllumination(.venus, at: now)
177
+ print(venus.azDeg, venus.altDeg - horizon, brightness.magnitude, brightness.elongationDeg)
178
+ let crossings = try planetEvents(.venus, from: now, to: now.addingTimeInterval(86400),
179
+ observer: viewer, heightAboveGroundM: 2)
180
+ ```
181
+
182
+ 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.
183
+
184
+ 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.
185
+
137
186
  ### Eclipse searches
138
187
 
139
188
  Search for a previous eclipse or all eclipses in a time window:
@@ -155,7 +204,7 @@ anchor. Search results are global; apply `lunarEclipseVisibility` for an observe
155
204
 
156
205
  Solar eclipses are searched for an observer, because their contacts only exist for a place: `nextSolarEclipse(after, observer)`, `previousSolarEclipse(before, observer)`, and `solarEclipses(startUtc, endUtc, observer)`. An eclipse whose Sun is below the horizon at C1, the peak, and C4 is not returned.
157
206
 
158
- To ask about the whole Earth instead, `nextGlobalSolarEclipse(after)`, `previousGlobalSolarEclipse(before)`, and `globalSolarEclipses(startUtc, endUtc)` need no observer. Each eclipse reports its greatest eclipse, the shadow axis's distance from the Earth's center, and where the axis meets the ground, with the kind and obscuration seen there. When the axis misses the Earth the eclipse is partial and has no ground point.
207
+ To ask about the whole Earth instead, `nextGlobalSolarEclipse(after)`, `previousGlobalSolarEclipse(before)`, and `globalSolarEclipses(startUtc, endUtc)` need no observer. Each eclipse reports its greatest eclipse, the shadow axis's distance from the Earth's center, and where the axis meets the ground, with the kind and obscuration seen there. When the axis misses the Earth the eclipse is partial and has no ground point, and greatest eclipse falls on the Earth's limb nearest the axis, with the Sun on the horizon: `greatestLatitudeDeg`, `greatestLongitudeDeg`, and `greatestObscuration` give that place for every eclipse.
159
208
 
160
209
  `solarEclipseCentralLine(peak, stepSeconds)` samples where the axis meets the ground from its first contact to its last, and `solarEclipseAxisPoint(time)` gives that point at any instant. How far the next totality passes from a place is a distance to each point of a line:
161
210
 
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,12 @@ 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
+ }
14
20
  /** A quarter lunar phase. */
15
21
  export type MoonPhaseName = 'new' | 'firstQuarter' | 'full' | 'lastQuarter';
16
22
  export interface MoonPhaseEvent {
@@ -41,7 +47,7 @@ export declare function quantizedUt(ut: number): number;
41
47
  * Sun rise, set, the three twilights and upper transit within the half-open
42
48
  * window `[startUtc, endUtc)`, sorted ascending.
43
49
  *
44
- * Rise and set are the unrefracted geometric centre altitude at −(34′ + the
50
+ * Rise and set use the supplied horizon dip minus (34′ + the
45
51
  * Sun's true semidiameter at distance) — the upper-limb convention with the
46
52
  * actual disc, the same rule the Moon gets; the twilights are centre altitude
47
53
  * −6°, −12° and −18° with no refraction term; transit is local hour angle
@@ -49,14 +55,16 @@ export declare function quantizedUt(ut: number): number;
49
55
  * the crossings — while transit is reported regardless of whether the Sun is
50
56
  * above the horizon when it happens.
51
57
  */
52
- export declare function sunEvents(startUtc: Date, endUtc: Date, observer: Observer): SunEvent[];
58
+ export declare function sunEvents(startUtc: Date, endUtc: Date, observer: Observer, heightAboveGroundM?: number): SunEvent[];
53
59
  /**
54
60
  * Moonrise and moonset within the half-open window `[startUtc, endUtc)`,
55
61
  * sorted ascending — the apparent topocentric upper limb crossing the horizon,
56
62
  * so refraction (34′), topocentric parallax and the true semidiameter at the
57
63
  * Moon's distance are all included — the same upper-limb rule as the Sun's. An empty list is a valid answer.
58
64
  */
59
- export declare function moonEvents(startUtc: Date, endUtc: Date, observer: Observer): MoonEvent[];
65
+ export declare function moonEvents(startUtc: Date, endUtc: Date, observer: Observer, heightAboveGroundM?: number): MoonEvent[];
66
+ /** Rise/set of a point center at geometric altitude horizonDip minus 34 arcminutes. */
67
+ export declare function planetEvents(planet: Planet, startUtc: Date, endUtc: Date, observer: Observer, heightAboveGroundM?: number): PlanetEvent[];
60
68
  /**
61
69
  * UPSTREAM: `Search`, astronomy.ts ~4634 — bisection with quadratic
62
70
  * 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 { 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
  }
@@ -469,7 +477,7 @@ function flatCycle(observer) {
469
477
  * Sun rise, set, the three twilights and upper transit within the half-open
470
478
  * window `[startUtc, endUtc)`, sorted ascending.
471
479
  *
472
- * Rise and set are the unrefracted geometric centre altitude at −(34′ + the
480
+ * Rise and set use the supplied horizon dip minus (34′ + the
473
481
  * Sun's true semidiameter at distance) — the upper-limb convention with the
474
482
  * actual disc, the same rule the Moon gets; the twilights are centre altitude
475
483
  * −6°, −12° and −18° with no refraction term; transit is local hour angle
@@ -477,13 +485,13 @@ function flatCycle(observer) {
477
485
  * the crossings — while transit is reported regardless of whether the Sun is
478
486
  * above the horizon when it happens.
479
487
  */
480
- export function sunEvents(startUtc, endUtc, observer) {
488
+ export function sunEvents(startUtc, endUtc, observer, heightAboveGroundM = 0) {
481
489
  assertSupported(startUtc);
482
490
  assertSupportedWindowEnd(endUtc);
483
- assertObserver(observer);
491
+ const dip = horizonDip(observer, heightAboveGroundM);
484
492
  if (startUtc.getTime() >= endUtc.getTime())
485
493
  return [];
486
- return searchAltitudeEvents(sunSampler(observer), SUN_LEVELS, 'transit', utDays(startUtc), utDays(endUtc), SUN_HA_RATE_DEG_PER_DAY, SUN_CYCLE_DAYS, flatCycle(observer));
494
+ return searchAltitudeEvents(sunSampler(observer, dip), SUN_LEVELS, 'transit', utDays(startUtc), utDays(endUtc), SUN_HA_RATE_DEG_PER_DAY, SUN_CYCLE_DAYS, flatCycle(observer));
487
495
  }
488
496
  /**
489
497
  * Moonrise and moonset within the half-open window `[startUtc, endUtc)`,
@@ -491,13 +499,24 @@ export function sunEvents(startUtc, endUtc, observer) {
491
499
  * so refraction (34′), topocentric parallax and the true semidiameter at the
492
500
  * Moon's distance are all included — the same upper-limb rule as the Sun's. An empty list is a valid answer.
493
501
  */
494
- export function moonEvents(startUtc, endUtc, observer) {
502
+ export function moonEvents(startUtc, endUtc, observer, heightAboveGroundM = 0) {
503
+ assertSupported(startUtc);
504
+ assertSupportedWindowEnd(endUtc);
505
+ const dip = horizonDip(observer, heightAboveGroundM);
506
+ if (startUtc.getTime() >= endUtc.getTime())
507
+ 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));
509
+ }
510
+ /** Rise/set of a point center at geometric altitude horizonDip minus 34 arcminutes. */
511
+ export function planetEvents(planet, startUtc, endUtc, observer, heightAboveGroundM = 0) {
512
+ assertPlanet(planet);
495
513
  assertSupported(startUtc);
496
514
  assertSupportedWindowEnd(endUtc);
497
- assertObserver(observer);
515
+ const dip = horizonDip(observer, heightAboveGroundM);
498
516
  if (startUtc.getTime() >= endUtc.getTime())
499
517
  return [];
500
- return searchAltitudeEvents(moonSampler(observer), MOON_LEVELS, null, utDays(startUtc), utDays(endUtc), MOON_HA_RATE_DEG_PER_DAY, MOON_CYCLE_DAYS, flatCycle(observer));
518
+ const cycleDays = 0.9972695717592592;
519
+ return searchAltitudeEvents(planetSampler(planet, observer, dip), MOON_LEVELS, null, utDays(startUtc), utDays(endUtc), 360 / cycleDays, cycleDays, flatCycle(observer));
501
520
  }
502
521
  // -------------------------------------------------------------- moon phases
503
522
  /**
@@ -13,6 +13,12 @@ export interface GlobalSolarEclipse {
13
13
  longitudeDeg: number | null;
14
14
  /** Fraction of the Sun's disc area covered at that point; exactly 1 for a total eclipse; `null` for a partial eclipse. */
15
15
  obscuration: number | null;
16
+ /** Geodetic latitude of greatest eclipse, degrees: the ground point, or for a partial eclipse the point on the Earth's limb nearest the axis, with the Sun on the horizon. */
17
+ greatestLatitudeDeg: number;
18
+ /** Longitude of greatest eclipse, degrees east in (−180, 180]. */
19
+ greatestLongitudeDeg: number;
20
+ /** Fraction of the Sun's disc area covered at greatest eclipse. */
21
+ greatestObscuration: number;
16
22
  }
17
23
  /** A point on a solar eclipse's central line: where the Moon's shadow axis meets the ground at one instant. */
18
24
  export interface SolarEclipseAxisPoint {
@@ -30,6 +30,10 @@
30
30
  // quadratic's discriminant, and an axis point rejects the crossing on the
31
31
  // Sun's side of the Moon that upstream, asking only at greatest eclipse,
32
32
  // never meets.
33
+ //
34
+ // Neither is greatest eclipse for an axis that misses the Earth: upstream
35
+ // reports no place at all, and this package reports the point on the limb
36
+ // nearest the axis, where the Sun is on the horizon.
33
37
  import { assertSupported, assertSupportedWindowEnd, AlmanacOutOfRangeError, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
34
38
  import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
35
39
  import { EARTH_EQUATORIAL_RADIUS_KM, EARTH_FLATTENING, EARTH_FLATTENING_SQUARED, KM_PER_AU, RAD2DEG, PrecessDirection, gyration } from './nutation.js';
@@ -133,6 +137,24 @@ function axisGround(shadow, axis, u) {
133
137
  const px = u * v.x - e.x;
134
138
  const py = u * v.y - e.y;
135
139
  const pz = (u * v.z - e.z) * EARTH_FLATTENING;
140
+ const { latitudeDeg, longitudeDeg, o } = geodeticPlace(shadow, tt, px, py, pz);
141
+ // Recalculate the shadow using a vector from the Moon's center toward the observer.
142
+ const surface = calcShadow(MOON_POLAR_RADIUS_KM, shadow.ut, o, shadow.dir);
143
+ // If we did everything right, the shadow distance should be very close to zero.
144
+ // That's because we already determined the observer 'o' is on the shadow axis!
145
+ // Upstream's bound is 1e-9 km; exceeding it is an implementation failure.
146
+ if (surface.r > 1.0e-9 || surface.r < 0.0)
147
+ throw new Error(`almanac internal: unexpected shadow distance from geoid intersection = ${surface.r}`);
148
+ const kind = eclipseKindFromUmbra(surface.k);
149
+ const obscuration = (kind === 'total') ? 1.0 : solarEclipseObscuration(shadow.dir, o);
150
+ return { latitudeDeg, longitudeDeg, kind, obscuration };
151
+ }
152
+ /**
153
+ * UPSTREAM: the middle of `GeoidIntersect`, astronomy.ts ~8842 — geodetic
154
+ * latitude and longitude of a geocentric equator-of-date point in km, and the
155
+ * lunacentric EQJ vector toward it in AU.
156
+ */
157
+ function geodeticPlace(shadow, tt, px, py, pz) {
136
158
  // Convert cartesian coordinates into geodetic latitude/longitude.
137
159
  const proj = Math.hypot(px, py) * EARTH_FLATTENING_SQUARED;
138
160
  let latitudeDeg;
@@ -153,16 +175,31 @@ function axisGround(shadow, axis, u) {
153
175
  const og = gyration({ x: px / KM_PER_AU, y: py / KM_PER_AU, z: pz / KM_PER_AU }, tt, PrecessDirection.Into2000);
154
176
  // Convert geocentric vector to lunacentric vector.
155
177
  const o = { x: og.x + shadow.target.x, y: og.y + shadow.target.y, z: og.z + shadow.target.z };
156
- // Recalculate the shadow using a vector from the Moon's center toward the observer.
178
+ return { latitudeDeg, longitudeDeg, o };
179
+ }
180
+ /**
181
+ * Greatest eclipse when the axis misses the geoid: the point on the Earth's
182
+ * limb nearest the axis. In the dilated coordinates the geoid is a sphere, and
183
+ * the axis's closest approach to its center, scaled onto it, stands for that point.
184
+ * The axis runs perpendicular to the radius there, so it lies in the sphere's
185
+ * tangent plane; dilation maps tangent planes to tangent planes, so on the
186
+ * ellipsoid the axis lies in the geodetic horizon and the Sun's center is at
187
+ * geometric altitude zero, as the catalog tabulates. The observer there sees
188
+ * what the local search would: a total or annular phase only inside the
189
+ * umbra, which a non-central eclipse can graze.
190
+ */
191
+ function limbGround(shadow, axis) {
192
+ const { tt, v, e, A, B } = axis;
193
+ const u = -B / (2 * A);
194
+ const cx = u * v.x - e.x;
195
+ const cy = u * v.y - e.y;
196
+ const cz = u * v.z - e.z;
197
+ const scale = EARTH_EQUATORIAL_RADIUS_KM / Math.sqrt(cx * cx + cy * cy + cz * cz);
198
+ const { latitudeDeg, longitudeDeg, o } = geodeticPlace(shadow, tt, cx * scale, cy * scale, cz * scale * EARTH_FLATTENING);
157
199
  const surface = calcShadow(MOON_POLAR_RADIUS_KM, shadow.ut, o, shadow.dir);
158
- // If we did everything right, the shadow distance should be very close to zero.
159
- // That's because we already determined the observer 'o' is on the shadow axis!
160
- // Upstream's bound is 1e-9 km; exceeding it is an implementation failure.
161
- if (surface.r > 1.0e-9 || surface.r < 0.0)
162
- throw new Error(`almanac internal: unexpected shadow distance from geoid intersection = ${surface.r}`);
163
- const kind = eclipseKindFromUmbra(surface.k);
164
- const obscuration = (kind === 'total') ? 1.0 : solarEclipseObscuration(shadow.dir, o);
165
- return { latitudeDeg, longitudeDeg, kind, obscuration };
200
+ const obscuration = (surface.r < Math.abs(surface.k) && eclipseKindFromUmbra(surface.k) === 'total')
201
+ ? 1.0 : solarEclipseObscuration(shadow.dir, o);
202
+ return { latitudeDeg, longitudeDeg, obscuration };
166
203
  }
167
204
  /**
168
205
  * UPSTREAM: `GeoidIntersect`'s choice of root — where the axis first meets
@@ -201,13 +238,18 @@ function grazingGround(shadow) {
201
238
  function geoidIntersect(shadow, peak) {
202
239
  const ground = nearAxisGround(shadow);
203
240
  if (ground === null) {
204
- // This is a partial solar eclipse, and an obscuration needs a place:
205
- // `solarEclipses` and `solarObscuration` take an observer.
206
- return { kind: 'partial', peak, axisDistanceKm: shadow.r, latitudeDeg: null, longitudeDeg: null, obscuration: null };
241
+ // This is a partial solar eclipse: no one stands on the axis, and
242
+ // greatest eclipse is on the limb nearest it.
243
+ const limb = limbGround(shadow, dilatedAxis(shadow));
244
+ return {
245
+ kind: 'partial', peak, axisDistanceKm: shadow.r, latitudeDeg: null, longitudeDeg: null, obscuration: null,
246
+ greatestLatitudeDeg: limb.latitudeDeg, greatestLongitudeDeg: limb.longitudeDeg, greatestObscuration: limb.obscuration
247
+ };
207
248
  }
208
249
  return {
209
250
  kind: ground.kind, peak, axisDistanceKm: shadow.r,
210
- latitudeDeg: ground.latitudeDeg, longitudeDeg: ground.longitudeDeg, obscuration: ground.obscuration
251
+ latitudeDeg: ground.latitudeDeg, longitudeDeg: ground.longitudeDeg, obscuration: ground.obscuration,
252
+ greatestLatitudeDeg: ground.latitudeDeg, greatestLongitudeDeg: ground.longitudeDeg, greatestObscuration: ground.obscuration
211
253
  };
212
254
  }
213
255
  /**
@@ -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,4 @@
1
+ import { Vec3 } from './nutation.js';
1
2
  /** Moon illumination at an instant. `phase` is 0 at new moon, 0.5 at full. */
2
3
  export interface MoonIllumination {
3
4
  fraction: number;
@@ -5,6 +6,8 @@ export interface MoonIllumination {
5
6
  phase: number;
6
7
  waxing: boolean;
7
8
  }
9
+ /** UPSTREAM: `AngleBetween`, astronomy.ts lines 256-273 — angle between two vectors, [0, 180]. */
10
+ export declare function angleBetweenDeg(a: Vec3, b: Vec3): number;
8
11
  /**
9
12
  * INTERNAL: the Moon's apparent ecliptic longitude ahead of the Sun's, [0, 360)
10
13
  * — 0 at new moon, 180 at full. The single definition of lunar phase in this
@@ -14,7 +14,7 @@ import { DEG2RAD, PrecessDirection, RAD2DEG, earthTilt, eclipticToEquatorial, gy
14
14
  import { moonGeoVectorEqj } from './moon.js';
15
15
  import { earthHelioVector, sunGeoVectorEqj } from './sun.js';
16
16
  /** UPSTREAM: `AngleBetween`, astronomy.ts lines 256-273 — angle between two vectors, [0, 180]. */
17
- function angleBetweenDeg(a, b) {
17
+ export function angleBetweenDeg(a, b) {
18
18
  const aa = a.x * a.x + a.y * a.y + a.z * a.z;
19
19
  const bb = b.x * b.x + b.y * b.y + b.z * b.z;
20
20
  const dot = (a.x * b.x + a.y * b.y + a.z * b.z) / Math.sqrt(aa * bb);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
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';
@@ -6,8 +9,8 @@ export type { AltAz } from './transforms.js';
6
9
  export { sunAltAz, moonAltAz, starAltAz } from './transforms.js';
7
10
  export type { MoonIllumination } from './illumination.js';
8
11
  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';
12
+ export type { SunEvent, SunEventKind, MoonEvent, MoonEventKind, MoonPhaseEvent, MoonPhaseName, PlanetEvent, PlanetEventKind } from './events.js';
13
+ export { sunEvents, moonEvents, searchMoonPhases, planetEvents } 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
6
  export { moonIllumination } from './illumination.js';
5
- export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
7
+ export { sunEvents, moonEvents, searchMoonPhases, planetEvents } 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;