@openwaters/almanac 0.3.0 → 0.4.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,9 +1,6 @@
1
1
  # Almanac
2
2
 
3
- Offline sun & moon engine for the Salish Sea and everywhere else — positions,
4
- rise/set/twilight, moon phase, lunar eclipses (solar planned), and where any fixed
5
- star stands from a catalog position, computed from pure geometry with zero network
6
- and zero runtime data files.
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 fixed-star altitude and azimuth from catalog positions, computed from pure geometry with zero network and zero runtime data files.
7
4
 
8
5
  Twin implementations, one behavior:
9
6
 
@@ -12,22 +9,22 @@ Twin implementations, one behavior:
12
9
  - `fixtures/` — the shared test corpus (JPL Horizons, USNO, Espenak) both suites
13
10
  must pass; the contract that keeps the ports identical
14
11
 
15
- Supported interval: 1950-01-01T00:00Z ≤ t < 2101-01-01T00:00Z; results outside it
16
- raise a typed error. All instants are UT1-accurate, not civil-UTC-accurate in the far
17
- future — see the design spec's Conventions section for what that means and why.
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.
18
13
 
19
- - Design: [`docs/superpowers/specs/2026-08-28-almanac-v1-design.md`](docs/superpowers/specs/2026-08-28-almanac-v1-design.md)
20
- - Roadmap: [`docs/ROADMAP.md`](docs/ROADMAP.md) — what ships next, and what will not.
21
- - Landing page: [openwaters.io/sky](https://openwaters.io/sky) — the library running live in a browser.
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.
16
+ - Development and releases: [`CONTRIBUTING.md`](CONTRIBUTING.md), including pinned tools and required checks.
17
+ - Landing page: [openwaters.io/sky](https://openwaters.io/sky), with the library running live in a browser.
22
18
 
23
19
  Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/astronomy)
24
20
  (MIT, Don Cross) — see [NOTICE](NOTICE). MIT licensed.
25
21
 
26
22
  ## Performance
27
23
 
28
- Almanac includes a shared performance harness for both ports: **15 workloads**
24
+ Almanac includes a shared performance harness for both ports: **22 workloads**
29
25
  cover positions, a 228-hour sky track, short/year/polar event windows, full-range
30
- moon phases, and next/previous/range eclipse searches, including empty windows.
26
+ moon phases, next/previous/range lunar and solar eclipse searches, including empty
27
+ windows, and solar obscuration over a 228-hour track.
31
28
 
32
29
  The v0.2.1 optimizations reuse Moon and shadow geometry, flatten scratch arrays,
33
30
  and refine altitude crossings with fewer position evaluations. In the
@@ -43,20 +40,18 @@ median query time fell relative to the v0.2.0 astronomy code:
43
40
  | Previous lunar eclipse | 26.0% | 36.6% |
44
41
  | Lunar eclipses / 1950–2100 | 26.4% | 23.4% |
45
42
 
46
- TypeScript was measured on Ubuntu with Node 22; Swift used release builds on
47
- macOS 15. Each comparison builds both revisions with the same harness and
48
- toolchain, then takes seven interleaved process pairs with 300 ms warmup per
49
- process. Build and startup time are excluded. Timings vary by machine; the shared
50
- correctness fixtures and parity tolerances remain the accuracy gates.
43
+ TypeScript was measured on Ubuntu; Swift used release builds on macOS. Each comparison builds both revisions with the same harness and toolchain, then takes seven interleaved process pairs with 300 ms warmup per process. Build and startup time are excluded. Timings vary by machine; the shared correctness fixtures and parity tolerances remain the accuracy gates.
51
44
 
52
- Reproduce the comparison locally from the repository root (Node 22+ and Swift
53
- 5.9+):
45
+ Reproduce the comparison locally from the repository root with mise 2026.9.1 or newer after installing the configured Node and Swift versions in `mise.toml`:
54
46
 
55
47
  ```bash
56
- npm ci --prefix typescript
57
- node benchmarks/run.mjs --base v0.2.0
48
+ mise install
49
+ mise exec -- npm ci --prefix typescript
50
+ mise exec -- node benchmarks/run.mjs --base v0.2.0 --skip '^solar/'
58
51
  ```
59
52
 
53
+ The `--skip` pattern leaves out the solar eclipse workloads, which need a base of 0.4.0 or later.
54
+
60
55
  CI runs the harness on code changes and fails on **median regressions over 20%**.
61
56
  Results include timing tables, raw samples, checksums, and revision/toolchain
62
57
  metadata. See [the harness guide](CONTRIBUTING.md#performance) for choosing a
@@ -74,7 +69,7 @@ npm install @openwaters/almanac
74
69
  ```
75
70
 
76
71
  ```ts
77
- import { nextLunarEclipse, lunarEclipseVisibility, sunEvents, starAltAz } from '@openwaters/almanac';
72
+ import { nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, sunEvents, starAltAz } from '@openwaters/almanac';
78
73
 
79
74
  const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
80
75
 
@@ -82,6 +77,10 @@ const eclipse = nextLunarEclipse(new Date());
82
77
  const visibility = lunarEclipseVisibility(eclipse, observer);
83
78
  console.log(eclipse.kind, eclipse.peak, visibility.visibleAtPeak);
84
79
 
80
+ const solar = nextSolarEclipse(new Date(), observer);
81
+ console.log(solar.kind, solar.peak, solar.obscuration, solar.sunAltDeg.peak);
82
+ console.log(solarObscuration(solar.peak, observer)); // fraction of the Sun's disc covered, 0 to 1
83
+
85
84
  const today = new Date();
86
85
  const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
87
86
  for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
@@ -95,7 +94,7 @@ const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
95
94
  ### Swift
96
95
 
97
96
  ```swift
98
- .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.3.0")
97
+ .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.4.0")
99
98
  ```
100
99
 
101
100
  ```swift
@@ -108,6 +107,10 @@ let eclipse = try nextLunarEclipse(after: Date())
108
107
  let visibility = try lunarEclipseVisibility(eclipse, observer: observer)
109
108
  print(eclipse.kind, eclipse.peak, visibility.visibleAtPeak)
110
109
 
110
+ let solar = try nextSolarEclipse(after: Date(), observer: observer)
111
+ print(solar.kind, solar.peak, solar.obscuration, solar.sunAltDeg.peak)
112
+ print(try solarObscuration(at: solar.peak, observer: observer)) // fraction of the Sun's disc covered, 0 to 1
113
+
111
114
  let today = Date()
112
115
  let tomorrow = today.addingTimeInterval(24 * 60 * 60)
113
116
  for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
@@ -136,3 +139,5 @@ let eclipses = try lunarEclipses(from: today, to: tomorrow)
136
139
  Ranges include peaks at the start and exclude peaks at the end. Contacts may
137
140
  extend outside the range. Previous/next searches skip peaks within 100 ms of the
138
141
  anchor. Search results are global; apply `lunarEclipseVisibility` for an observer.
142
+
143
+ 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.
package/dist/eclipse.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Observer } from './types.js';
2
+ import { Vec3 } from './nutation.js';
2
3
  /** A lunar eclipse: peak circumstances plus the contact instants around them. */
3
4
  export interface LunarEclipse {
4
5
  /** `total` if the Moon is fully inside the umbra, `partial` if it touches
@@ -39,6 +40,57 @@ export interface LunarEclipseVisibility {
39
40
  p4: boolean;
40
41
  };
41
42
  }
43
+ /** UPSTREAM: `PruneLatitude`, inside `SearchLunarEclipse` — full- or new-Moon
44
+ * ecliptic latitude above which no eclipse is possible.
45
+ * INTERNAL: shared with solar.ts. */
46
+ export declare const PRUNE_LATITUDE_DEG = 1.8;
47
+ /** Root-finder tolerance for every shadow search, seconds — upstream's `Search` default.
48
+ * INTERNAL: shared with solar.ts. */
49
+ export declare const SHADOW_TOL_SECONDS = 1;
50
+ /** UPSTREAM: `Search`'s default `iter_limit`.
51
+ * INTERNAL: shared with solar.ts. */
52
+ export declare const SHADOW_ITER_CAP = 20;
53
+ /**
54
+ * Peaks within 100 ms of a next/previous search anchor count as the same
55
+ * eclipse. The fixed full-moon seed below removes within-runtime drift; keep
56
+ * the band for peaks saved by older versions or computed on another runtime.
57
+ *
58
+ * This can skip a wanted peak when the anchor is within 100 ms of it, but
59
+ * cannot skip a distinct eclipse (the catalog's minimum gap is 29 days).
60
+ * Range searches use exact half-open bounds without this band.
61
+ * INTERNAL: shared with solar.ts.
62
+ */
63
+ export declare const SAME_ECLIPSE_MS = 100;
64
+ /**
65
+ * UPSTREAM: `ShadowInfo` (astronomy.ts ~8445). `target` and `dir` are the
66
+ * inputs `CalcShadow` measured from: the lunar case passes the geocentric
67
+ * Moon against the Sun-to-Earth line, the solar case (solar.ts) the
68
+ * lunacentric observer against the heliocentric Moon, and the obscuration
69
+ * path reads them back. Upstream's `time` is an `AstroTime`; here the instant
70
+ * travels as days since J2000 UT, as everywhere in L3.
71
+ * INTERNAL: exported for `solar.ts`, not part of the public API.
72
+ */
73
+ export interface ShadowInfo {
74
+ /** Days since J2000 (UT). */
75
+ ut: number;
76
+ /** Shadow-axis parameter: distance to the shadow plane over the casting body's distance. */
77
+ u: number;
78
+ /** Distance from `target` to the shadow axis, km. */
79
+ r: number;
80
+ /** Umbra radius at the shadow plane, km. */
81
+ k: number;
82
+ /** Penumbra radius at the shadow plane, km. */
83
+ p: number;
84
+ /** The point measured from, AU. */
85
+ target: Vec3;
86
+ /** The shadow axis, AU. */
87
+ dir: Vec3;
88
+ }
89
+ /** UPSTREAM: `CalcShadow`, astronomy.ts ~8458. INTERNAL, shared with solar.ts. */
90
+ export declare function calcShadow(bodyRadiusKm: number, ut: number, target: Vec3, dir: Vec3): ShadowInfo;
91
+ /** UPSTREAM: `MoonEclipticLatitudeDegrees`, astronomy.ts ~8616.
92
+ * INTERNAL: shared with solar.ts. */
93
+ export declare function moonEclipticLatitudeDeg(ut: number): number;
42
94
  /**
43
95
  * The first lunar eclipse whose peak falls strictly after `after`.
44
96
  *
package/dist/eclipse.js CHANGED
@@ -28,9 +28,10 @@ import { MOON_MEAN_RADIUS_KM, SUN_RADIUS_KM, search, searchMoonPhase } from './e
28
28
  * The 88 km of atmosphere is what makes the umbra match observed eclipse
29
29
  * magnitudes; it is not the geometric Earth radius. */
30
30
  const EARTH_ECLIPSE_RADIUS_KM = 6371.0 + 88.0;
31
- /** UPSTREAM: `PruneLatitude`, inside `SearchLunarEclipse` — full-Moon ecliptic
32
- * latitude above which no eclipse is possible. */
33
- const PRUNE_LATITUDE_DEG = 1.8;
31
+ /** UPSTREAM: `PruneLatitude`, inside `SearchLunarEclipse` — full- or new-Moon
32
+ * ecliptic latitude above which no eclipse is possible.
33
+ * INTERNAL: shared with solar.ts. */
34
+ export const PRUNE_LATITUDE_DEG = 1.8;
34
35
  /** Spec: the search gives up after two years. The catalog's longest gap between
35
36
  * consecutive lunar eclipses over 1950-2100 is under a year. */
36
37
  const SCAN_LIMIT_DAYS = 730;
@@ -39,10 +40,12 @@ const PEAK_WINDOW_DAYS = 0.03;
39
40
  /** Upstream's outermost `ShadowSemiDurationMinutes` window, in minutes. */
40
41
  const PENUMBRAL_WINDOW_MINUTES = 200.0;
41
42
  const MINUTES_PER_DAY = 24.0 * 60.0;
42
- /** Root-finder tolerance for every shadow search, seconds — upstream's `Search` default. */
43
- const SHADOW_TOL_SECONDS = 1;
44
- /** UPSTREAM: `Search`'s default `iter_limit`. */
45
- const SHADOW_ITER_CAP = 20;
43
+ /** Root-finder tolerance for every shadow search, seconds — upstream's `Search` default.
44
+ * INTERNAL: shared with solar.ts. */
45
+ export const SHADOW_TOL_SECONDS = 1;
46
+ /** UPSTREAM: `Search`'s default `iter_limit`.
47
+ * INTERNAL: shared with solar.ts. */
48
+ export const SHADOW_ITER_CAP = 20;
46
49
  /**
47
50
  * Peaks within 100 ms of a next/previous search anchor count as the same
48
51
  * eclipse. The fixed full-moon seed below removes within-runtime drift; keep
@@ -51,10 +54,11 @@ const SHADOW_ITER_CAP = 20;
51
54
  * This can skip a wanted peak when the anchor is within 100 ms of it, but
52
55
  * cannot skip a distinct eclipse (the catalog's minimum gap is 29 days).
53
56
  * Range searches use exact half-open bounds without this band.
57
+ * INTERNAL: shared with solar.ts.
54
58
  */
55
- const SAME_ECLIPSE_MS = 100;
56
- /** UPSTREAM: `CalcShadow`, astronomy.ts ~8458. */
57
- function calcShadow(bodyRadiusKm, ut, target, dir) {
59
+ export const SAME_ECLIPSE_MS = 100;
60
+ /** UPSTREAM: `CalcShadow`, astronomy.ts ~8458. INTERNAL, shared with solar.ts. */
61
+ export function calcShadow(bodyRadiusKm, ut, target, dir) {
58
62
  const u = (dir.x * target.x + dir.y * target.y + dir.z * target.z) / (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
59
63
  const dx = (u * dir.x) - target.x;
60
64
  const dy = (u * dir.y) - target.y;
@@ -62,7 +66,7 @@ function calcShadow(bodyRadiusKm, ut, target, dir) {
62
66
  const r = KM_PER_AU * Math.hypot(dx, dy, dz);
63
67
  const k = +SUN_RADIUS_KM - (1.0 + u) * (SUN_RADIUS_KM - bodyRadiusKm);
64
68
  const p = -SUN_RADIUS_KM + (1.0 + u) * (SUN_RADIUS_KM + bodyRadiusKm);
65
- return { ut, u, r, k, p };
69
+ return { ut, u, r, k, p, target, dir };
66
70
  }
67
71
  /**
68
72
  * UPSTREAM: `EarthShadow`, astronomy.ts ~8470 — the Earth's shadow cone
@@ -119,8 +123,9 @@ function shadowSemiDurationMinutes(shadow, radiusLimitKm, windowMinutes) {
119
123
  throw new Error('almanac internal: failed to find shadow semiduration');
120
124
  return (t2 - t1) * (MINUTES_PER_DAY / 2.0); // convert days to minutes and average the semi-durations
121
125
  }
122
- /** UPSTREAM: `MoonEclipticLatitudeDegrees`, astronomy.ts ~8616. */
123
- function moonEclipticLatitudeDeg(ut) {
126
+ /** UPSTREAM: `MoonEclipticLatitudeDegrees`, astronomy.ts ~8616.
127
+ * INTERNAL: shared with solar.ts. */
128
+ export function moonEclipticLatitudeDeg(ut) {
124
129
  return RAD2DEG * calcMoon(ttDaysFromUt(ut)).geoEclipLat;
125
130
  }
126
131
  /**
package/dist/index.d.ts CHANGED
@@ -10,3 +10,5 @@ export type { SunEvent, SunEventKind, MoonEvent, MoonEventKind, MoonPhaseEvent,
10
10
  export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
11
11
  export type { LunarEclipse, LunarEclipseVisibility } from './eclipse.js';
12
12
  export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
13
+ export type { SolarEclipse, SolarEclipseKind, SolarEclipseSunAltitudes } from './solar.js';
14
+ export { nextSolarEclipse, previousSolarEclipse, solarEclipses, solarObscuration } from './solar.js';
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export { sunAltAz, moonAltAz, starAltAz } from './transforms.js';
4
4
  export { moonIllumination } from './illumination.js';
5
5
  export { sunEvents, moonEvents, searchMoonPhases } from './events.js';
6
6
  export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
7
+ export { nextSolarEclipse, previousSolarEclipse, solarEclipses, solarObscuration } from './solar.js';
@@ -0,0 +1,66 @@
1
+ import { Observer } from './types.js';
2
+ /** How much of the Sun an observer sees covered: `partial` when the Moon never covers it, `annular` when the Moon sits inside the Sun's disc, `total` when it covers it. */
3
+ export type SolarEclipseKind = 'partial' | 'annular' | 'total';
4
+ /** The Sun's refracted topocentric altitude at each contact, degrees; `null` exactly where the eclipse has no such contact. */
5
+ export interface SolarEclipseSunAltitudes {
6
+ c1: number;
7
+ c2: number | null;
8
+ peak: number;
9
+ c3: number | null;
10
+ c4: number;
11
+ }
12
+ /** A solar eclipse as one observer sees it: peak circumstances plus the contact instants around them. */
13
+ export interface SolarEclipse {
14
+ kind: SolarEclipseKind;
15
+ /** Fraction of the Sun's disc area covered at peak; exactly 1 for a total eclipse. */
16
+ obscuration: number;
17
+ /** First contact: the partial phase begins. */
18
+ c1: Date;
19
+ /** Second contact: the total or annular phase begins — `null` for a partial eclipse. */
20
+ c2: Date | null;
21
+ /** Closest approach of the Moon's shadow axis to the observer. */
22
+ peak: Date;
23
+ /** Third contact: the total or annular phase ends — `null` for a partial eclipse. */
24
+ c3: Date | null;
25
+ /** Fourth contact: the partial phase ends. */
26
+ c4: Date;
27
+ /** The number `sunAltAz` reports at each contact instant, so "above the horizon" agrees with the Sun a consumer draws. */
28
+ sunAltDeg: SolarEclipseSunAltitudes;
29
+ }
30
+ /**
31
+ * The fraction of the Sun's disc area the Moon covers for `observer` at
32
+ * `time`: 0 when the discs are apart, 1 in totality, the ratio of the disc
33
+ * areas in annularity, and the lens overlap between. Purely geometric — it
34
+ * does not test the horizon; a consumer that draws the Sun already knows
35
+ * whether it is up.
36
+ *
37
+ * @throws {AlmanacOutOfRangeError} if `time` is outside the supported interval.
38
+ * @throws {RangeError} if `time` is invalid or `observer` is out of range.
39
+ */
40
+ export declare function solarObscuration(time: Date, observer: Observer): number;
41
+ /**
42
+ * The first solar eclipse `observer` can see whose peak falls strictly after
43
+ * `after`.
44
+ *
45
+ * Every new moon up to the end of the supported interval is tested: the
46
+ * Moon's ecliptic latitude prunes the ones no shadow can reach, and the rest
47
+ * get a peak-shadow search from the observer. An eclipse whose Sun is below
48
+ * the horizon at C1, the peak, and C4 is skipped.
49
+ *
50
+ * @throws {AlmanacOutOfRangeError} if `after` is outside the supported
51
+ * interval, or if no visible eclipse remains before the end of it.
52
+ * @throws {RangeError} if `after` is invalid or `observer` is out of range.
53
+ */
54
+ export declare function nextSolarEclipse(after: Date, observer: Observer): SolarEclipse;
55
+ /**
56
+ * The last solar eclipse `observer` can see whose peak falls strictly before
57
+ * `before`, skipping peaks within 100 ms of it, just as `nextSolarEclipse`
58
+ * does on the other side.
59
+ *
60
+ * @throws {AlmanacOutOfRangeError} if `before` is outside the supported
61
+ * interval, or if no visible eclipse remains after the start of it.
62
+ * @throws {RangeError} if `before` is invalid or `observer` is out of range.
63
+ */
64
+ export declare function previousSolarEclipse(before: Date, observer: Observer): SolarEclipse;
65
+ /** Solar eclipses `observer` can see with peaks in `[startUtc, endUtc)`, sorted ascending. Contacts may fall outside the window. */
66
+ export declare function solarEclipses(startUtc: Date, endUtc: Date, observer: Observer): SolarEclipse[];
package/dist/solar.js ADDED
@@ -0,0 +1,344 @@
1
+ // L3 solar eclipses for one observer: the Moon's shadow cone against the
2
+ // observer at every new moon — the local peak, the contacts C1–C4 with the
3
+ // Sun's altitude at each, the covered fraction at peak, and the covered
4
+ // fraction of the Sun's disc at any instant.
5
+ //
6
+ // Translated from the cosinekitty/astronomy upstream, pinned sha
7
+ // 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts:
8
+ // AngleBetween (~256), geo_pos (~2236, in transforms.ts),
9
+ // LocalMoonShadow (~8494), PeakLocalMoonShadow (~8587), Obscuration (~8622),
10
+ // SolarEclipseObscuration (~8670), EclipseKindFromUmbra (~8834),
11
+ // local_partial_distance (~9126), local_total_distance (~9130),
12
+ // LocalEclipse (~9137), LocalEclipseTransition (~9165), SunAltitude (~9181)
13
+ // and SearchLocalSolarEclipse (~9211).
14
+ // Constants and operation order are preserved so the Swift port can be a
15
+ // line-for-line translation.
16
+ //
17
+ // Four deliberate departures from upstream, all spec-driven:
18
+ // - the new-moon probe is this package's `searchMoonPhase`, whose longitudes
19
+ // are apparent rather than upstream's geometric (see events.ts). It only
20
+ // seeds a ±0.2 d peak search, so the ~40 s difference cannot change a
21
+ // result.
22
+ // - previous and range searches, the fixed whole-UT-day new-moon seed and
23
+ // the 100 ms same-eclipse band come from the lunar port (eclipse.ts);
24
+ // upstream searches forward only.
25
+ // - the night filter also accepts the Sun above the horizon at the peak, not
26
+ // only at C1 or C4, so a short polar day inside the eclipse is kept.
27
+ // - no two-year scan limit: next/previous walk new moons to the supported
28
+ // boundary. A place can go years without a visible solar eclipse, and a
29
+ // pruned new moon costs one Moon evaluation.
30
+ import { assertObserver, assertSupported, assertSupportedWindowEnd, AlmanacOutOfRangeError, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
31
+ import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
32
+ import { DEG2RAD, KM_PER_AU, RAD2DEG } from './nutation.js';
33
+ import { moonGeoVectorEqj } from './moon.js';
34
+ import { sunGeoVectorEqj } from './sun.js';
35
+ import { observerGeoVectorEqj, refractionDeg, topoAltAzUnrefracted } from './transforms.js';
36
+ import { MOON_MEAN_RADIUS_KM, SUN_RADIUS_KM, search, searchMoonPhase } from './events.js';
37
+ import { calcShadow, moonEclipticLatitudeDeg, PRUNE_LATITUDE_DEG, SAME_ECLIPSE_MS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP } from './eclipse.js';
38
+ /** UPSTREAM: `SUN_RADIUS_AU` and `MOON_POLAR_RADIUS_AU`, astronomy.ts 135 and 149-150. */
39
+ const SUN_RADIUS_AU = SUN_RADIUS_KM / KM_PER_AU;
40
+ const MOON_POLAR_RADIUS_KM = 1736.0;
41
+ const MOON_POLAR_RADIUS_AU = MOON_POLAR_RADIUS_KM / KM_PER_AU;
42
+ /** Upstream's `PeakLocalMoonShadow` window, in days, either side of the new moon. */
43
+ const PEAK_WINDOW_DAYS = 0.2;
44
+ /** Upstream's `LocalEclipse` windows, in days, either side of the peak. */
45
+ const PARTIAL_WINDOW_DAYS = 0.2;
46
+ const TOTAL_WINDOW_DAYS = 0.01;
47
+ /**
48
+ * UPSTREAM: `LocalMoonShadow`, astronomy.ts ~8494 — the Moon's shadow cone
49
+ * evaluated at the observer: the lunacentric observer measured against the
50
+ * heliocentric Moon. All three vectors are EQJ, and `calcShadow` only ever
51
+ * takes dot products and norms of them, so the frame cancels.
52
+ */
53
+ function localMoonShadow(ut, observer) {
54
+ const tt = ttDaysFromUt(ut);
55
+ // Observer's geocentric position.
56
+ const pos = observerGeoVectorEqj(ut, observer);
57
+ // Light-travel and aberration corrected Sun.
58
+ const s = sunGeoVectorEqj(tt);
59
+ // Geocentric Moon.
60
+ const m = moonGeoVectorEqj(tt);
61
+ // Lunacentric location of an observer on the Earth's surface.
62
+ const o = { x: pos.x - m.x, y: pos.y - m.y, z: pos.z - m.z };
63
+ // Convert geocentric moon to heliocentric Moon.
64
+ const hm = { x: m.x - s.x, y: m.y - s.y, z: m.z - s.z };
65
+ return calcShadow(MOON_MEAN_RADIUS_KM, ut, o, hm);
66
+ }
67
+ /** UPSTREAM: `AngleBetween`, astronomy.ts ~256 — degrees. */
68
+ function angleBetweenDeg(a, b) {
69
+ const aa = (a.x * a.x + a.y * a.y + a.z * a.z);
70
+ if (Math.abs(aa) < 1.0e-8)
71
+ throw new Error('almanac internal: AngleBetween first vector is too short');
72
+ const bb = (b.x * b.x + b.y * b.y + b.z * b.z);
73
+ if (Math.abs(bb) < 1.0e-8)
74
+ throw new Error('almanac internal: AngleBetween second vector is too short');
75
+ const dot = (a.x * b.x + a.y * b.y + a.z * b.z) / Math.sqrt(aa * bb);
76
+ if (dot <= -1.0)
77
+ return 180;
78
+ if (dot >= +1.0)
79
+ return 0;
80
+ return RAD2DEG * Math.acos(dot);
81
+ }
82
+ /**
83
+ * UPSTREAM: `Obscuration`, astronomy.ts ~8622 — the area of intersection of
84
+ * two discs of radii `a` and `b` whose centres are `c` apart, divided by the
85
+ * area of the first disc.
86
+ */
87
+ function discOverlap(a, b, c) {
88
+ if (a <= 0.0)
89
+ throw new Error('almanac internal: radius of first disc must be positive');
90
+ if (b <= 0.0)
91
+ throw new Error('almanac internal: radius of second disc must be positive');
92
+ if (c < 0.0)
93
+ throw new Error('almanac internal: distance between discs is not allowed to be negative');
94
+ if (c >= a + b) {
95
+ // The discs are too far apart to have any overlapping area.
96
+ return 0.0;
97
+ }
98
+ if (c == 0.0) {
99
+ // The discs have a common center. Therefore, one disc is inside the other.
100
+ return (a <= b) ? 1.0 : (b * b) / (a * a);
101
+ }
102
+ const x = (a * a - b * b + c * c) / (2 * c);
103
+ const radicand = a * a - x * x;
104
+ if (radicand <= 0.0) {
105
+ // The circumferences do not intersect, or are tangent.
106
+ // We already ruled out the case of non-overlapping discs.
107
+ // Therefore, one disc is inside the other.
108
+ return (a <= b) ? 1.0 : (b * b) / (a * a);
109
+ }
110
+ // The discs overlap fractionally in a pair of lens-shaped areas.
111
+ const y = Math.sqrt(radicand);
112
+ // Return the overlapping fractional area.
113
+ // There are two lens-shaped areas, one to the left of x, the other to the right of x.
114
+ // Each part is calculated by subtracting a triangular area from a sector's area.
115
+ const lens1 = a * a * Math.acos(x / a) - x * y;
116
+ const lens2 = b * b * Math.acos((c - x) / b) - (c - x) * y;
117
+ // Find the fractional area with respect to the first disc.
118
+ return (lens1 + lens2) / (Math.PI * a * a);
119
+ }
120
+ /**
121
+ * UPSTREAM: the body of `SolarEclipseObscuration`, astronomy.ts ~8670, before
122
+ * its clamp — the fraction of the Sun's apparent disc the Moon covers for an
123
+ * observer, from the heliocentric Moon `hm` and the lunacentric observer `lo`.
124
+ */
125
+ function discObscuration(hm, lo) {
126
+ // Find heliocentric observer.
127
+ const ho = { x: hm.x + lo.x, y: hm.y + lo.y, z: hm.z + lo.z };
128
+ // Calculate the apparent angular radius of the Sun for the observer.
129
+ const sunRadius = Math.asin(SUN_RADIUS_AU / Math.hypot(ho.x, ho.y, ho.z));
130
+ // Calculate the apparent angular radius of the Moon for the observer.
131
+ const moonRadius = Math.asin(MOON_POLAR_RADIUS_AU / Math.hypot(lo.x, lo.y, lo.z));
132
+ // Calculate the apparent angular separation between the Sun's center and the Moon's center.
133
+ const sunMoonSeparation = angleBetweenDeg(lo, ho);
134
+ // Find the fraction of the Sun's apparent disc area that is covered by the Moon.
135
+ return discOverlap(sunRadius, moonRadius, sunMoonSeparation * DEG2RAD);
136
+ }
137
+ /**
138
+ * The fraction of the Sun's disc area the Moon covers for `observer` at
139
+ * `time`: 0 when the discs are apart, 1 in totality, the ratio of the disc
140
+ * areas in annularity, and the lens overlap between. Purely geometric — it
141
+ * does not test the horizon; a consumer that draws the Sun already knows
142
+ * whether it is up.
143
+ *
144
+ * @throws {AlmanacOutOfRangeError} if `time` is outside the supported interval.
145
+ * @throws {RangeError} if `time` is invalid or `observer` is out of range.
146
+ */
147
+ export function solarObscuration(time, observer) {
148
+ assertSupported(time);
149
+ assertObserver(observer);
150
+ const shadow = localMoonShadow(utDays(time), observer);
151
+ return discObscuration(shadow.dir, shadow.target);
152
+ }
153
+ /**
154
+ * UPSTREAM: `SolarEclipseObscuration`, astronomy.ts ~8670, with its clamp:
155
+ * "in marginal cases, we need to clamp obscuration to less than 1.0. This
156
+ * function is never called for total eclipses, so it should never return 1.0."
157
+ */
158
+ function solarEclipseObscuration(hm, lo) {
159
+ return Math.min(0.9999, discObscuration(hm, lo));
160
+ }
161
+ /** UPSTREAM: `ShadowDistanceSlope`, astronomy.ts ~8535, bound to `localMoonShadow`. */
162
+ function localShadowSlope(ut, observer) {
163
+ const dt = 1.0 / 86400.0;
164
+ return (localMoonShadow(ut + dt, observer).r - localMoonShadow(ut - dt, observer).r) / dt;
165
+ }
166
+ /**
167
+ * UPSTREAM: `PeakLocalMoonShadow`, astronomy.ts ~8587 — the time near the
168
+ * new moon when the Moon's shadow axis comes closest to the observer, i.e.
169
+ * the ascending zero of the axis distance's time derivative.
170
+ */
171
+ function peakLocalMoonShadow(centerUt, observer) {
172
+ // Use the same new-moon seed regardless of search direction or window.
173
+ // Otherwise a ~1 ms root shift can lose/duplicate a peak when a caller
174
+ // splits a range at a previously returned peak. Only unpruned moons pay
175
+ // for this second phase search; its start is a fixed whole UT day.
176
+ const newmoon = searchMoonPhase(0, Math.floor(centerUt) - 1, 4);
177
+ if (newmoon === null)
178
+ throw new Error('almanac internal: cannot refine new moon');
179
+ const ut = search(u => localShadowSlope(u, observer), newmoon - PEAK_WINDOW_DAYS, newmoon + PEAK_WINDOW_DAYS, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'peak local moon shadow');
180
+ if (ut === null)
181
+ throw new Error('almanac internal: failed to find peak local Moon shadow time');
182
+ return localMoonShadow(ut, observer);
183
+ }
184
+ /**
185
+ * UPSTREAM: `EclipseKindFromUmbra`, astronomy.ts ~8834 — a positive umbra
186
+ * radius at the observer is a total eclipse, otherwise annular. The 14 m
187
+ * bias is upstream's, added to match Espenak's classifications.
188
+ */
189
+ function eclipseKindFromUmbra(k) {
190
+ return (k > 0.014) ? 'total' : 'annular';
191
+ }
192
+ /** UPSTREAM: `local_partial_distance`, astronomy.ts ~9126. */
193
+ function localPartialDistance(shadow) {
194
+ return shadow.p - shadow.r;
195
+ }
196
+ /** UPSTREAM: `local_total_distance`, astronomy.ts ~9130 — `|k|`, because the umbra radius is negative for an annular eclipse. */
197
+ function localTotalDistance(shadow) {
198
+ return Math.abs(shadow.k) - shadow.r;
199
+ }
200
+ /** UPSTREAM: `LocalEclipseTransition`, astronomy.ts ~9165 — the instant `func` crosses zero in `direction` inside `[t1, t2]`. */
201
+ function localEclipseTransition(observer, direction, func, t1, t2) {
202
+ const ut = search(u => direction * func(localMoonShadow(u, observer)), t1, t2, SHADOW_TOL_SECONDS, SHADOW_ITER_CAP, 'local eclipse transition');
203
+ if (ut === null)
204
+ throw new Error('almanac internal: local eclipse transition search failed');
205
+ return ut;
206
+ }
207
+ /**
208
+ * UPSTREAM: `SunAltitude`, astronomy.ts ~9181 — the refracted topocentric
209
+ * altitude `sunAltAz` reports, without its interval assertion: a contact can
210
+ * fall just outside the supported interval while its peak is inside.
211
+ */
212
+ function sunAltDegAt(d, observer) {
213
+ const ut = utDays(d);
214
+ const alt = topoAltAzUnrefracted(sunGeoVectorEqj(ttDaysFromUt(ut)), ut, observer).altDeg;
215
+ return alt + refractionDeg(alt);
216
+ }
217
+ /** UPSTREAM: `LocalEclipse`, astronomy.ts ~9137 — contacts and kind around a peak the observer is inside the penumbra for. */
218
+ function buildSolarEclipse(shadow, observer) {
219
+ const peakUt = shadow.ut;
220
+ const c1Ut = localEclipseTransition(observer, 1, localPartialDistance, peakUt - PARTIAL_WINDOW_DAYS, peakUt);
221
+ const c4Ut = localEclipseTransition(observer, -1, localPartialDistance, peakUt, peakUt + PARTIAL_WINDOW_DAYS);
222
+ let c2Ut = null;
223
+ let c3Ut = null;
224
+ let kind;
225
+ if (shadow.r < Math.abs(shadow.k)) { // take absolute value of 'k' to handle annular eclipses too.
226
+ c2Ut = localEclipseTransition(observer, 1, localTotalDistance, peakUt - TOTAL_WINDOW_DAYS, peakUt);
227
+ c3Ut = localEclipseTransition(observer, -1, localTotalDistance, peakUt, peakUt + TOTAL_WINDOW_DAYS);
228
+ kind = eclipseKindFromUmbra(shadow.k);
229
+ }
230
+ else {
231
+ kind = 'partial';
232
+ }
233
+ const obscuration = (kind === 'total') ? 1.0 : solarEclipseObscuration(shadow.dir, shadow.target);
234
+ // Altitudes come from the reported (TimeClip-truncated) instants, so
235
+ // `sunAltAz(e.c1, observer).altDeg === e.sunAltDeg.c1` exactly.
236
+ const c1 = dateFromUt(c1Ut);
237
+ const c2 = c2Ut === null ? null : dateFromUt(c2Ut);
238
+ const peak = dateFromUt(peakUt);
239
+ const c3 = c3Ut === null ? null : dateFromUt(c3Ut);
240
+ const c4 = dateFromUt(c4Ut);
241
+ const alt = (d) => (d === null ? null : sunAltDegAt(d, observer));
242
+ return {
243
+ kind, obscuration, c1, c2, peak, c3, c4,
244
+ sunAltDeg: { c1: alt(c1), c2: alt(c2), peak: alt(peak), c3: alt(c3), c4: alt(c4) }
245
+ };
246
+ }
247
+ /**
248
+ * The night filter: the Sun's centre must be above the horizon at C1, the
249
+ * peak, or C4. Upstream tests only C1 and C4; the peak keeps a short polar day
250
+ * inside the eclipse.
251
+ * ponytail: three samples, not a sunrise search — a day that starts after C1
252
+ * and ends before the peak still drops. Upgrade path: sunEvents over [c1, c4].
253
+ */
254
+ function seesAnyOfIt(e) {
255
+ return e.sunAltDeg.c1 > 0.0 || e.sunAltDeg.peak > 0.0 || e.sunAltDeg.c4 > 0.0;
256
+ }
257
+ /**
258
+ * The first solar eclipse `observer` can see whose peak falls strictly after
259
+ * `after`.
260
+ *
261
+ * Every new moon up to the end of the supported interval is tested: the
262
+ * Moon's ecliptic latitude prunes the ones no shadow can reach, and the rest
263
+ * get a peak-shadow search from the observer. An eclipse whose Sun is below
264
+ * the horizon at C1, the peak, and C4 is skipped.
265
+ *
266
+ * @throws {AlmanacOutOfRangeError} if `after` is outside the supported
267
+ * interval, or if no visible eclipse remains before the end of it.
268
+ * @throws {RangeError} if `after` is invalid or `observer` is out of range.
269
+ */
270
+ export function nextSolarEclipse(after, observer) {
271
+ return nearestSolarEclipse(after, 1, observer);
272
+ }
273
+ /**
274
+ * The last solar eclipse `observer` can see whose peak falls strictly before
275
+ * `before`, skipping peaks within 100 ms of it, just as `nextSolarEclipse`
276
+ * does on the other side.
277
+ *
278
+ * @throws {AlmanacOutOfRangeError} if `before` is outside the supported
279
+ * interval, or if no visible eclipse remains after the start of it.
280
+ * @throws {RangeError} if `before` is invalid or `observer` is out of range.
281
+ */
282
+ export function previousSolarEclipse(before, observer) {
283
+ return nearestSolarEclipse(before, -1, observer);
284
+ }
285
+ /** Solar eclipses `observer` can see with peaks in `[startUtc, endUtc)`, sorted ascending. Contacts may fall outside the window. */
286
+ export function solarEclipses(startUtc, endUtc, observer) {
287
+ assertSupported(startUtc);
288
+ assertSupportedWindowEnd(endUtc);
289
+ assertObserver(observer);
290
+ return scanSolarEclipses(startUtc.getTime(), endUtc.getTime(), 1, false, observer);
291
+ }
292
+ function nearestSolarEclipse(anchor, direction, observer) {
293
+ assertSupported(anchor);
294
+ assertObserver(observer);
295
+ const ms = anchor.getTime();
296
+ // No scan limit: walk to the supported boundary. A place can go years
297
+ // without a visible solar eclipse, and a pruned new moon is cheap.
298
+ const startMs = direction > 0 ? ms + SAME_ECLIPSE_MS + 1 : SUPPORTED_MIN;
299
+ const endMs = direction > 0 ? SUPPORTED_MAX : ms - SAME_ECLIPSE_MS;
300
+ const found = scanSolarEclipses(startMs, endMs, direction, true, observer);
301
+ if (found.length)
302
+ return found[0];
303
+ throw new AlmanacOutOfRangeError();
304
+ }
305
+ function scanSolarEclipses(startMs, endMs, direction, firstOnly, observer) {
306
+ const found = [];
307
+ if (startMs >= endMs)
308
+ return found;
309
+ // Peak and new moon differ by up to the peak window. Include the entire
310
+ // margin at both ends, then apply the caller's bounds to the reported peak.
311
+ const startUt = utDays(new Date(startMs)) - PEAK_WINDOW_DAYS;
312
+ const endUt = utDays(new Date(endMs)) + PEAK_WINDOW_DAYS;
313
+ let nmUt = direction > 0 ? startUt : endUt;
314
+ const limitUt = direction > 0 ? endUt : startUt;
315
+ while (direction * (limitUt - nmUt) > 0) {
316
+ const newmoon = searchMoonPhase(0, nmUt, direction * Math.min(40, Math.abs(limitUt - nmUt)));
317
+ if (newmoon === null)
318
+ break;
319
+ // UPSTREAM `SearchLocalSolarEclipse`: step past this new moon before
320
+ // the next probe, so the same one cannot be found twice.
321
+ nmUt = newmoon + direction * 10;
322
+ // Pruning: if the new moon's ecliptic latitude is too large, a solar
323
+ // eclipse is not possible.
324
+ if (Math.abs(moonEclipticLatitudeDeg(newmoon)) >= PRUNE_LATITUDE_DEG)
325
+ continue;
326
+ // Search near the new moon for the time when the observer is closest
327
+ // to the line passing through the centers of the Sun and Moon.
328
+ const shadow = peakLocalMoonShadow(newmoon, observer);
329
+ if (shadow.r >= shadow.p)
330
+ continue; // the observer never enters the penumbra
331
+ const peak = dateFromUt(shadow.ut);
332
+ if (peak.getTime() < startMs || peak.getTime() >= endMs)
333
+ continue;
334
+ // This is at least a partial solar eclipse for the observer.
335
+ const eclipse = buildSolarEclipse(shadow, observer);
336
+ // Ignore any eclipse that happens completely at night.
337
+ if (!seesAnyOfIt(eclipse))
338
+ continue;
339
+ found.push(eclipse);
340
+ if (firstOnly)
341
+ break;
342
+ }
343
+ return found;
344
+ }
@@ -23,6 +23,15 @@ export interface TopoUnrefracted extends AltAz {
23
23
  * are folded into one multiply here — same value, one division dropped.
24
24
  */
25
25
  export declare function siderealDeg(ut: number): number;
26
+ /**
27
+ * UPSTREAM: `geo_pos`, astronomy.ts ~2236 — the observer's geocentric
28
+ * position in AU on the J2000 mean equator: `terra`'s of-date vector
29
+ * gyrated into J2000. The topocentric path below avoids this rotation; the
30
+ * solar-eclipse layer needs it because it subtracts the observer from a
31
+ * geocentric Moon that stays in EQJ.
32
+ * INTERNAL: exported for `solar.ts`, not part of the public API.
33
+ */
34
+ export declare function observerGeoVectorEqj(ut: number, observer: Observer): Vec3;
26
35
  /**
27
36
  * UPSTREAM: the topocentric path composed of `Equator(body, date, observer,
28
37
  * ofdate=true, aberration=true)` (astronomy.ts ~2803) followed by `Horizon`
@@ -81,6 +81,17 @@ function observerGeoVectorOfDate(gastDeg, observer) {
81
81
  z: ash * sinphi / KM_PER_AU
82
82
  };
83
83
  }
84
+ /**
85
+ * UPSTREAM: `geo_pos`, astronomy.ts ~2236 — the observer's geocentric
86
+ * position in AU on the J2000 mean equator: `terra`'s of-date vector
87
+ * gyrated into J2000. The topocentric path below avoids this rotation; the
88
+ * solar-eclipse layer needs it because it subtracts the observer from a
89
+ * geocentric Moon that stays in EQJ.
90
+ * INTERNAL: exported for `solar.ts`, not part of the public API.
91
+ */
92
+ export function observerGeoVectorEqj(ut, observer) {
93
+ return gyration(observerGeoVectorOfDate(siderealDeg(ut), observer), ttDaysFromUt(ut), PrecessDirection.Into2000);
94
+ }
84
95
  /** UPSTREAM: `spin`, astronomy.ts ~2518 — rotate a horizontal-frame unit vector by sidereal angle. */
85
96
  function spin(angleDeg, pos) {
86
97
  const angr = angleDeg * DEG2RAD;
package/package.json CHANGED
@@ -1,16 +1,50 @@
1
1
  {
2
2
  "name": "@openwaters/almanac",
3
- "version": "0.3.0",
4
- "description": "Offline sun & moon engine: positions, rise/set, moon phase, lunar eclipses.",
3
+ "version": "0.4.0",
4
+ "description": "Offline sun & moon engine: positions, rise/set, moon phase, lunar and solar eclipses.",
5
5
  "license": "MIT",
6
- "repository": { "type": "git", "url": "git+https://github.com/openwatersio/almanac.git", "directory": "typescript" },
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/openwatersio/almanac.git",
9
+ "directory": "typescript"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/openwatersio/almanac/issues"
13
+ },
14
+ "keywords": [
15
+ "astronomy",
16
+ "sun",
17
+ "moon",
18
+ "ephemeris",
19
+ "lunar-eclipse",
20
+ "solar-eclipse",
21
+ "marine"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
7
26
  "homepage": "https://openwaters.io/sky",
8
27
  "type": "module",
9
28
  "main": "./dist/index.js",
10
29
  "types": "./dist/index.d.ts",
11
- "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
12
- "files": ["dist", "NOTICE"],
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "NOTICE"
39
+ ],
13
40
  "//": "index.ts is a curated allow-list: ONLY the spec's public API table is exported — no guards, no L0/L1 internals",
14
- "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run", "prepack": "npm run build && cp ../NOTICE ../LICENSE ../README.md ." },
15
- "devDependencies": { "typescript": "^5.5.0", "vitest": "^2.0.0" }
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.json",
43
+ "test": "vitest run",
44
+ "prepack": "npm run build && cp ../NOTICE ../LICENSE ../README.md ."
45
+ },
46
+ "devDependencies": {
47
+ "typescript": "^7.0.2",
48
+ "vitest": "^5.0.0"
49
+ }
16
50
  }