@openwaters/almanac 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,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';