@openwaters/almanac 0.8.0 → 0.9.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,12 +1,13 @@
1
1
  # Almanac
2
2
 
3
- Offline sky engine for the Salish Sea and everywhere else: Sun, Moon, and planet positions, apparent horizon dip, rise, set, twilight, any Sun altitude, upper and lower transits, Moon phase and orientation, planetary brightness, lunar and solar eclipses, and fixed-star altitude and azimuth from catalog positions, computed from pure geometry with zero network and zero runtime data files.
3
+ Offline sky engine for the Salish Sea and everywhere else: Sun, Moon, and planet positions, apparent horizon dip, rise, set, twilight, any Sun altitude, upper and lower transits, Moon phase and orientation, equinoxes and solstices, lunar perigee and apogee, the equation of time, planetary brightness, lunar and solar eclipses, and fixed-star altitude and azimuth from catalog positions, computed from pure geometry with zero network and zero runtime data files.
4
4
 
5
- Twin implementations, one behavior:
5
+ Three implementations, one behavior:
6
6
 
7
7
  - `swift/` — SwiftPM package `Almanac`
8
8
  - `typescript/` — npm `@openwaters/almanac`
9
- - `fixtures/` — the shared test corpus (JPL Horizons, USNO, Espenak) both suites
9
+ - `kotlin/` — Kotlin/JVM `io.openwaters.almanac`, source-consumed with Gradle
10
+ - `fixtures/` — the shared test corpus (JPL Horizons, USNO, Espenak) all three suites
10
11
  must pass; the contract that keeps the ports identical
11
12
 
12
13
  Supported interval: 1950-01-01T00:00Z ≤ t < 2101-01-01T00:00Z; results outside it raise a typed error. All instants are UT1-accurate, while unknown future DUT1 is outside the civil-UTC accuracy promise. See the [public contract](docs/CONTRACT.md#time-and-supported-interval) for the time model.
@@ -21,7 +22,7 @@ Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/ast
21
22
 
22
23
  ## Performance
23
24
 
24
- Almanac includes a shared performance harness for both ports: **34 workloads**
25
+ Almanac includes a shared performance harness for TypeScript and Swift: **34 workloads**
25
26
  cover positions, a 228-hour sky track, short/year/polar event windows, full-range
26
27
  moon phases, next/previous/range lunar, solar, and global solar eclipse searches,
27
28
  including empty windows, solar obscuration over a 228-hour track, and the central
@@ -66,9 +67,40 @@ background task; use shorter windows for interactive queries.
66
67
 
67
68
  ## Usage
68
69
 
69
- Migrating from SunCalc: [`docs/migrating-from-suncalc.md`](docs/migrating-from-suncalc.md) maps every SunCalc call to Almanac and gives drop-in `getTimes` and `getMoonTimes` adapters. Migrating from Solar: [`docs/migrating-from-solar.md`](docs/migrating-from-solar.md). How Almanac compares with SunCalc, Astronomy Engine, astronomia, Solar, SwiftAA, and EDSunriseSet, and when each is the better choice: [`docs/comparison.md`](docs/comparison.md).
70
+ Migration guides for [SunCalc](docs/migrating-from-suncalc.md), [Astronomy Engine](docs/migrating-from-astronomy-engine.md), and [Solar](docs/migrating-from-solar.md) include tested examples. The [library comparison](docs/comparison.md) explains the features and limits of each library.
70
71
 
71
- Migrating from the Solar Swift library: [`docs/migrating-from-solar.md`](docs/migrating-from-solar.md) maps every Solar property to Almanac and gives a drop-in `Solar` adapter.
72
+ Angles use degrees. All three ports provide `degreesToRadians(angle)` and `radiansToDegrees(angle)` for callers that use radians. These functions preserve sign and full turns without wrapping or changing the angle's origin; non-finite inputs and conversion overflow raise validation errors. Swift calls use `try`.
73
+
74
+ For an instantaneous day/night decision, `sunIsUp(time, observer, heightAboveGroundM = 0)` uses the same upper-limb convention as sunrise and sunset, including polar periods. See the [day/night recipe](docs/cookbook.md).
75
+
76
+ ### JavaScript module loading
77
+
78
+ The npm package is ESM and has no separate CommonJS build. Use Node 22.12 or later for the Node examples; CI and release checks load the packed package on Node 22.12 and Node 24. Browser applications can import it through their ESM bundler.
79
+
80
+ In an ES module (`.mjs` or a package with `"type": "module"`):
81
+
82
+ ```js
83
+ import { sunAltAz } from '@openwaters/almanac';
84
+ const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
85
+ console.log(sunAltAz(new Date(), observer));
86
+ ```
87
+
88
+ A CommonJS application can keep synchronous initialization. [Node's `require(esm)` support](https://nodejs.org/docs/latest-v22.x/api/modules.html#loading-ecmascript-modules-using-require) is enabled without a flag from Node 22.12:
89
+
90
+ ```js
91
+ const { sunAltAz } = require('@openwaters/almanac');
92
+ const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
93
+ console.log(sunAltAz(new Date(), observer));
94
+ ```
95
+
96
+ CommonJS can also load ESM asynchronously if its initialization flow already supports it:
97
+
98
+ ```js
99
+ async function initializeSky() {
100
+ const { sunAltAz } = await import('@openwaters/almanac');
101
+ return sunAltAz(new Date(), { latitudeDeg: 48.5, longitudeDeg: -123.0 });
102
+ }
103
+ ```
72
104
 
73
105
  ### TypeScript
74
106
 
@@ -78,7 +110,8 @@ npm install @openwaters/almanac
78
110
 
79
111
  ```ts
80
112
  import {
81
- nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, nextGlobalSolarEclipse, sunEvents, starAltAz
113
+ nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, nextGlobalSolarEclipse, sunEvents, starAltAz,
114
+ seasons, moonApsides, equationOfTime
82
115
  } from '@openwaters/almanac';
83
116
 
84
117
  const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
@@ -102,6 +135,13 @@ for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
102
135
 
103
136
  // Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
104
137
  const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
138
+
139
+ // The year's equinoxes and solstices, the Moon's perigees and apogees with
140
+ // distances, and the equation of time in minutes.
141
+ const year = new Date(today.getUTCFullYear() + 1, 0, 1);
142
+ for (const { kind, time } of seasons(today, year)) console.log(kind, time.toISOString());
143
+ for (const { kind, time, distanceKm } of moonApsides(today, year)) console.log(kind, time.toISOString(), distanceKm);
144
+ console.log(equationOfTime(today)); // sundial minus clock, minutes
105
145
  ```
106
146
 
107
147
  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:
@@ -149,6 +189,31 @@ for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
149
189
 
150
190
  // Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
151
191
  let star = try starAltAz(raDeg: 88.792939, decDeg: 7.407064, at: today, observer: observer)
192
+
193
+ // The year's equinoxes and solstices, the Moon's perigees and apogees with
194
+ // distances, and the equation of time in minutes.
195
+ let year = today.addingTimeInterval(366 * 24 * 60 * 60)
196
+ for event in try seasons(from: today, to: year) { print(event.kind, event.time) }
197
+ for apsis in try moonApsides(from: today, to: year) { print(apsis.kind, apsis.time, apsis.distanceKm) }
198
+ print(try equationOfTime(today)) // sundial minus clock, minutes
199
+ ```
200
+
201
+ ### Kotlin/JVM and Android
202
+
203
+ The Kotlin library provides the same 38 functions and result fields, using `java.time.Instant` and immutable data classes. It targets JVM 8 and Android minSdk 26, builds with JDK 17, and has no runtime dependencies beyond the Kotlin standard library and platform APIs. Maven publication and a Kotlin benchmark runner are deferred; consume the source at a reviewed Git SHA as described in [`kotlin/README.md`](kotlin/README.md).
204
+
205
+ ```kotlin
206
+ import io.openwaters.almanac.*
207
+ import java.time.Instant
208
+
209
+ val observer = Observer(48.5, -123.0)
210
+ val today = Instant.now()
211
+ val tomorrow = today.plusSeconds(86400)
212
+ val events = sunEvents(today, tomorrow, observer)
213
+ val moon = moonIllumination(today)
214
+ val next = nextGlobalSolarEclipse(today)
215
+ val line = solarEclipseCentralLine(next.peak)
216
+ val minutes = equationOfTime(today)
152
217
  ```
153
218
 
154
219
  ### Golden hour, transits, and the Moon's tilt
@@ -0,0 +1,4 @@
1
+ /** Converts degrees to radians without wrapping the angle. */
2
+ export declare function degreesToRadians(degrees: number): number;
3
+ /** Converts radians to degrees without wrapping; rejects conversion overflow. */
4
+ export declare function radiansToDegrees(radians: number): number;
package/dist/angles.js ADDED
@@ -0,0 +1,16 @@
1
+ import { DEG2RAD, RAD2DEG } from './nutation.js';
2
+ /** Converts degrees to radians without wrapping the angle. */
3
+ export function degreesToRadians(degrees) {
4
+ if (!Number.isFinite(degrees))
5
+ throw new RangeError('degrees must be finite');
6
+ return degrees * DEG2RAD;
7
+ }
8
+ /** Converts radians to degrees without wrapping; rejects conversion overflow. */
9
+ export function radiansToDegrees(radians) {
10
+ if (!Number.isFinite(radians))
11
+ throw new RangeError('radians must be finite');
12
+ const degrees = radians * RAD2DEG;
13
+ if (!Number.isFinite(degrees))
14
+ throw new RangeError('angle conversion overflow');
15
+ return degrees;
16
+ }
@@ -0,0 +1,14 @@
1
+ /** Closest approach or farthest remove of the Moon from the Earth's centre. */
2
+ export type MoonApsisKind = 'perigee' | 'apogee';
3
+ /** A lunar perigee or apogee, with the Earth–Moon centre distance there. */
4
+ export interface MoonApsis {
5
+ time: Date;
6
+ kind: MoonApsisKind;
7
+ distanceKm: number;
8
+ }
9
+ /**
10
+ * The Moon's perigees and apogees within the half-open window
11
+ * `[startUtc, endUtc)`, sorted ascending, each with the geocentric distance
12
+ * at that instant.
13
+ */
14
+ export declare function moonApsides(startUtc: Date, endUtc: Date): MoonApsis[];
@@ -0,0 +1,88 @@
1
+ // L3 lunar apsides: perigee and apogee, the zeros of the Moon's radial
2
+ // velocity, from the MOON2 distance the position layer already evaluates.
3
+ //
4
+ // Translated from the cosinekitty/astronomy upstream, pinned sha
5
+ // 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts:
6
+ // `SearchLunarApsis` (~6534) — its central-difference `distance_slope`, its
7
+ // five-day scan, its sign-change classification, and the `Search` it hands
8
+ // each bracket to. Upstream scans forward from the caller's instant and
9
+ // `NextLunarApsis` (~6621) chains from the last result; here the five-day scan
10
+ // points are a fixed grid anchored at J2000, so every window that contains an
11
+ // apsis finds it from the same bracket and reports the same millisecond — the
12
+ // fixed-seed rule the eclipse searches follow. Apsides are at least 11.8 days
13
+ // apart, so a cell holds at most one.
14
+ //
15
+ // INTERNAL except `moonApsides`: not part of the curated public API (see index.ts).
16
+ import { assertSupported, assertSupportedWindowEnd } from './types.js';
17
+ import { utDays, ttDaysFromUt, dateFromUt } from './time.js';
18
+ import { KM_PER_AU } from './nutation.js';
19
+ import { calcMoon } from './moon.js';
20
+ import { search, quantizedUt, assertReached } from './events.js';
21
+ /** UPSTREAM: the central-difference half-width `dt` inside `SearchLunarApsis`. */
22
+ const SLOPE_DT_DAYS = 0.001;
23
+ /** UPSTREAM: `increment` — the scan step, here also the grid cell in days since J2000 UT. */
24
+ const APSIS_CELL_DAYS = 5;
25
+ /** UPSTREAM: `distance_slope` — dr/dt in AU per day, by central difference. */
26
+ function distanceSlope(ut) {
27
+ const tt = ttDaysFromUt(ut);
28
+ const r1 = calcMoon(tt - SLOPE_DT_DAYS / 2).distanceAu;
29
+ const r2 = calcMoon(tt + SLOPE_DT_DAYS / 2).distanceAu;
30
+ return (r2 - r1) / SLOPE_DT_DAYS;
31
+ }
32
+ function negativeDistanceSlope(ut) {
33
+ return -distanceSlope(ut);
34
+ }
35
+ /**
36
+ * UPSTREAM: the body of `SearchLunarApsis`'s scan loop for one cell `[t1, t2]`
37
+ * with the slopes `m1`, `m2` at its ends. A slope rising through zero is a
38
+ * perigee and falling through zero an apogee. A zero exactly at a grid point
39
+ * belongs to the cell it ends, so no apsis is reported twice.
40
+ */
41
+ function searchApsisInCell(t1, t2, m1, m2) {
42
+ if (m1 < 0 && m2 >= 0) {
43
+ const ut = search(distanceSlope, t1, t2, 1, undefined, 'perigee search');
44
+ assertReached(ut !== null, 'perigee search');
45
+ return { ut: ut, kind: 'perigee' };
46
+ }
47
+ if (m1 > 0 && m2 <= 0) {
48
+ const ut = search(negativeDistanceSlope, t1, t2, 1, undefined, 'apogee search');
49
+ assertReached(ut !== null, 'apogee search');
50
+ return { ut: ut, kind: 'apogee' };
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * The Moon's perigees and apogees within the half-open window
56
+ * `[startUtc, endUtc)`, sorted ascending, each with the geocentric distance
57
+ * at that instant.
58
+ */
59
+ export function moonApsides(startUtc, endUtc) {
60
+ assertSupported(startUtc);
61
+ assertSupportedWindowEnd(endUtc);
62
+ if (startUtc.getTime() >= endUtc.getTime())
63
+ return [];
64
+ const startUt = utDays(startUtc);
65
+ const endUt = utDays(endUtc);
66
+ const events = [];
67
+ let t1 = Math.floor(startUt / APSIS_CELL_DAYS) * APSIS_CELL_DAYS;
68
+ let m1 = distanceSlope(t1);
69
+ while (t1 < endUt) {
70
+ const t2 = t1 + APSIS_CELL_DAYS;
71
+ const m2 = distanceSlope(t2);
72
+ const found = searchApsisInCell(t1, t2, m1, m2);
73
+ if (found !== null) {
74
+ const q = quantizedUt(found.ut); // judge the half-open bounds on the reported instant, not the raw root
75
+ if (q >= endUt)
76
+ break;
77
+ if (q >= startUt) {
78
+ events.push({
79
+ time: dateFromUt(found.ut), kind: found.kind,
80
+ distanceKm: calcMoon(ttDaysFromUt(found.ut)).distanceAu * KM_PER_AU
81
+ });
82
+ }
83
+ }
84
+ t1 = t2;
85
+ m1 = m2;
86
+ }
87
+ return events;
88
+ }
package/dist/events.d.ts CHANGED
@@ -43,6 +43,16 @@ export interface MoonPhaseEvent {
43
43
  export declare const MOON_MEAN_RADIUS_KM = 1737.4;
44
44
  /** UPSTREAM: `SUN_RADIUS_KM`, astronomy.ts line 134. INTERNAL, see above. */
45
45
  export declare const SUN_RADIUS_KM = 695700;
46
+ /**
47
+ * Clamps a probe into the supported interval. The altitude-search chain
48
+ * (searchAltitudeEvents) applies this to every probe it samples, so it never
49
+ * evaluates the model outside [MIN_UT, MAX_UT]. The phase-search walk
50
+ * (searchMoonPhases) only clamps its first probe — later quarter-to-quarter
51
+ * probes can land past MAX_UT and evaluate the model there harmlessly; it is
52
+ * the half-open window filter on emitted results, not this clamp, that keeps
53
+ * reported answers inside the interval.
54
+ */
55
+ export declare function clampUt(ut: number): number;
46
56
  /**
47
57
  * Truncates a ut-days root to the same integer-ms precision `dateFromUt`
48
58
  * reports it at (TimeClip). A root finder's raw `ut` carries sub-ms
@@ -55,6 +65,12 @@ export declare const SUN_RADIUS_KM = 695700;
55
65
  * no other module consumes it.
56
66
  */
57
67
  export declare function quantizedUt(ut: number): number;
68
+ /** Hitting an iteration cap is a bug, not a runtime condition — see the spec. */
69
+ export declare function assertReached(condition: boolean, what: string): void;
70
+ /** Wrap a hour-angle difference into (−180, 180]. */
71
+ export declare function angleOffset(diff: number): number;
72
+ /** Whether the Sun's upper limb is above the modeled horizon, using the sunrise/set convention. */
73
+ export declare function sunIsUp(time: Date, observer: Observer, heightAboveGroundM?: number): boolean;
58
74
  /**
59
75
  * Sun rise, set, the three twilights and upper transit within the half-open
60
76
  * window `[startUtc, endUtc)`, sorted ascending.
@@ -97,6 +113,13 @@ export declare function sunTransits(startUtc: Date, endUtc: Date, observer: Obse
97
113
  * parallax is included, whether or not the Moon is above the horizon.
98
114
  */
99
115
  export declare function moonTransits(startUtc: Date, endUtc: Date, observer: Observer): TransitEvent[];
116
+ /**
117
+ * UPSTREAM: `MEAN_SYNODIC_MONTH`, astronomy.ts line 129. The elongation it
118
+ * searches is {@link moonPhaseDeg}, apparent on both sides per the spec's
119
+ * moon-phase definition — upstream's `PairLongitude` uses geometric longitudes,
120
+ * which sit a flat ~40 s off the USNO catalogue.
121
+ */
122
+ export declare const MEAN_SYNODIC_MONTH = 29.530588;
100
123
  /**
101
124
  * UPSTREAM: `Search`, astronomy.ts ~4634 — bisection with quadratic
102
125
  * acceleration for the ascending zero crossing of `f` in `[t1, t2]`. Times are
package/dist/events.js CHANGED
@@ -94,7 +94,7 @@ const MAX_UT = utDays(new Date(SUPPORTED_MAX));
94
94
  * the half-open window filter on emitted results, not this clamp, that keeps
95
95
  * reported answers inside the interval.
96
96
  */
97
- function clampUt(ut) {
97
+ export function clampUt(ut) {
98
98
  return ut < MIN_UT ? MIN_UT : (ut > MAX_UT ? MAX_UT : ut);
99
99
  }
100
100
  /**
@@ -112,7 +112,7 @@ export function quantizedUt(ut) {
112
112
  return utDays(dateFromUt(ut));
113
113
  }
114
114
  /** Hitting an iteration cap is a bug, not a runtime condition — see the spec. */
115
- function assertReached(condition, what) {
115
+ export function assertReached(condition, what) {
116
116
  if (!condition)
117
117
  throw new Error(`almanac internal: ${what} did not converge`);
118
118
  }
@@ -150,7 +150,7 @@ function crossingSign(s, level) {
150
150
  }
151
151
  // ----------------------------------------------------------- root findings
152
152
  /** Wrap a hour-angle difference into (−180, 180]. */
153
- function angleOffset(diff) {
153
+ export function angleOffset(diff) {
154
154
  let offset = diff % 360;
155
155
  if (offset <= -180)
156
156
  offset += 360;
@@ -478,6 +478,13 @@ const MOON_LEVELS = [
478
478
  function flatCycle(observer) {
479
479
  return Math.abs(observer.latitudeDeg) >= FLAT_CYCLE_LATITUDE_DEG;
480
480
  }
481
+ /** Whether the Sun's upper limb is above the modeled horizon, using the sunrise/set convention. */
482
+ export function sunIsUp(time, observer, heightAboveGroundM = 0) {
483
+ assertSupported(time);
484
+ const dip = horizonDip(observer, heightAboveGroundM);
485
+ const sample = sunSampler(observer, dip)(utDays(time));
486
+ return sample.altDeg > sample.riseSetAltDeg;
487
+ }
481
488
  /**
482
489
  * Sun rise, set, the three twilights and upper transit within the half-open
483
490
  * window `[startUtc, endUtc)`, sorted ascending.
@@ -574,7 +581,7 @@ export function moonTransits(startUtc, endUtc, observer) {
574
581
  * moon-phase definition — upstream's `PairLongitude` uses geometric longitudes,
575
582
  * which sit a flat ~40 s off the USNO catalogue.
576
583
  */
577
- const MEAN_SYNODIC_MONTH = 29.530588;
584
+ export const MEAN_SYNODIC_MONTH = 29.530588;
578
585
  const PHASE_NAMES = ['new', 'firstQuarter', 'full', 'lastQuarter'];
579
586
  /** UPSTREAM: `QuadInterp`, astronomy.ts ~4528. */
580
587
  function quadInterp(tm, dt, fa, fm, fb) {
@@ -9,6 +9,12 @@ export interface MoonIllumination {
9
9
  }
10
10
  /** UPSTREAM: `AngleBetween`, astronomy.ts lines 256-273 — angle between two vectors, [0, 180]. */
11
11
  export declare function angleBetweenDeg(a: Vec3, b: Vec3): number;
12
+ /**
13
+ * INTERNAL: the Sun's apparent geocentric ecliptic longitude of date, [0, 360)
14
+ * — upstream `SunPosition(t).elon` (astronomy.ts ~2738), which `seasons.ts`
15
+ * roots against 0/90/180/270. Aberration is in the vector, nutation in the frame.
16
+ */
17
+ export declare function sunEclipticLonDeg(tt: number): number;
12
18
  /**
13
19
  * INTERNAL: the Moon's apparent ecliptic longitude ahead of the Sun's, [0, 360)
14
20
  * — 0 at new moon, 180 at full. The single definition of lunar phase in this
@@ -44,6 +44,14 @@ function eclipticLonOfDateDeg(eqj, tt) {
44
44
  elon += 360;
45
45
  return elon;
46
46
  }
47
+ /**
48
+ * INTERNAL: the Sun's apparent geocentric ecliptic longitude of date, [0, 360)
49
+ * — upstream `SunPosition(t).elon` (astronomy.ts ~2738), which `seasons.ts`
50
+ * roots against 0/90/180/270. Aberration is in the vector, nutation in the frame.
51
+ */
52
+ export function sunEclipticLonDeg(tt) {
53
+ return eclipticLonOfDateDeg(sunGeoVectorEqj(tt), tt);
54
+ }
47
55
  /** UPSTREAM: `NormalizeLongitude`, astronomy.ts lines 4735-4739. */
48
56
  function normalizeLongitude(lon) {
49
57
  return ((lon % 360) + 360) % 360;
package/dist/index.d.ts CHANGED
@@ -1,16 +1,21 @@
1
+ export { degreesToRadians, radiansToDegrees } from './angles.js';
1
2
  export type { Observer, Planet } from './types.js';
2
3
  export type { HeliocentricPosition, PlanetPosition, PlanetIllumination } from './planets.js';
3
4
  export { planetHeliocentricPosition, planetPosition, planetAltAz, planetIllumination } from './planets.js';
4
5
  export { horizonDip } from './horizon.js';
5
6
  export { AlmanacOutOfRangeError } from './types.js';
6
7
  export type { SunPosition, MoonPosition } from './positions.js';
7
- export { sunPosition, moonPosition } from './positions.js';
8
+ export { sunPosition, moonPosition, equationOfTime } from './positions.js';
9
+ export type { SeasonKind, SeasonEvent } from './seasons.js';
10
+ export { seasons } from './seasons.js';
11
+ export type { MoonApsisKind, MoonApsis } from './apsides.js';
12
+ export { moonApsides } from './apsides.js';
8
13
  export type { AltAz } from './transforms.js';
9
14
  export { sunAltAz, moonAltAz, starAltAz } from './transforms.js';
10
15
  export type { MoonIllumination, MoonOrientation } from './illumination.js';
11
16
  export { moonIllumination, moonOrientation } from './illumination.js';
12
17
  export type { SunEvent, SunEventKind, MoonEvent, MoonEventKind, MoonPhaseEvent, MoonPhaseName, PlanetEvent, PlanetEventKind, AltitudeEvent, AltitudeEventKind, TransitEvent, TransitEventKind } from './events.js';
13
- export { sunEvents, moonEvents, searchMoonPhases, planetEvents, sunAltitudeEvents, sunTransits, moonTransits } from './events.js';
18
+ export { sunIsUp, sunEvents, moonEvents, searchMoonPhases, planetEvents, sunAltitudeEvents, sunTransits, moonTransits } from './events.js';
14
19
  export type { LunarEclipse, LunarEclipseVisibility } from './eclipse.js';
15
20
  export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
16
21
  export type { SolarEclipse, SolarEclipseKind, SolarEclipseSunAltitudes } from './solar.js';
package/dist/index.js CHANGED
@@ -1,10 +1,14 @@
1
+ // The public contract's API table is the complete export allow-list.
2
+ export { degreesToRadians, radiansToDegrees } from './angles.js';
1
3
  export { planetHeliocentricPosition, planetPosition, planetAltAz, planetIllumination } from './planets.js';
2
4
  export { horizonDip } from './horizon.js';
3
5
  export { AlmanacOutOfRangeError } from './types.js';
4
- export { sunPosition, moonPosition } from './positions.js';
6
+ export { sunPosition, moonPosition, equationOfTime } from './positions.js';
7
+ export { seasons } from './seasons.js';
8
+ export { moonApsides } from './apsides.js';
5
9
  export { sunAltAz, moonAltAz, starAltAz } from './transforms.js';
6
10
  export { moonIllumination, moonOrientation } from './illumination.js';
7
- export { sunEvents, moonEvents, searchMoonPhases, planetEvents, sunAltitudeEvents, sunTransits, moonTransits } from './events.js';
11
+ export { sunIsUp, sunEvents, moonEvents, searchMoonPhases, planetEvents, sunAltitudeEvents, sunTransits, moonTransits } from './events.js';
8
12
  export { nextLunarEclipse, previousLunarEclipse, lunarEclipses, lunarEclipseVisibility } from './eclipse.js';
9
13
  export { nextSolarEclipse, previousSolarEclipse, solarEclipses, solarObscuration } from './solar.js';
10
14
  export { nextGlobalSolarEclipse, previousGlobalSolarEclipse, globalSolarEclipses, solarEclipseAxisPoint, solarEclipseCentralLine } from './globalSolar.js';
@@ -32,3 +32,18 @@ export declare function sunPosition(time: Date): SunPosition;
32
32
  * the Earth-Moon centre distance in kilometres.
33
33
  */
34
34
  export declare function moonPosition(time: Date): MoonPosition;
35
+ /**
36
+ * INTERNAL: equation of time for a TT instant, in minutes. Not from upstream:
37
+ * Meeus, *Astronomical Algorithms*, formula 28.1, `E = L0 − 0.0057183° − α +
38
+ * Δψ cos ε`, with `L0` the Sun's mean longitude from formula 28.2 and `α` the
39
+ * apparent right ascension `sunApparentAtTT` already computes. The constant
40
+ * is aberration plus the FK5 correction; `Δψ cos ε` moves `L0` from the mean
41
+ * equinox to the true one `α` is measured from.
42
+ */
43
+ export declare function equationOfTimeAtTT(tt: number): number;
44
+ /**
45
+ * Equation of time: apparent solar time minus mean solar time, in minutes.
46
+ * Positive when a sundial runs ahead of the clock; about +16.4 at the start
47
+ * of November and −14.2 in mid February.
48
+ */
49
+ export declare function equationOfTime(time: Date): number;
package/dist/positions.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // parallax subtraction) is zero and drops out.
14
14
  import { assertSupported } from './types.js';
15
15
  import { ttDays } from './time.js';
16
- import { KM_PER_AU, equatorialFromVector } from './nutation.js';
16
+ import { DEG2RAD, KM_PER_AU, earthTilt, equatorialFromVector } from './nutation.js';
17
17
  import { moonGeoVector } from './moon.js';
18
18
  import { sunGeoVector } from './sun.js';
19
19
  /**
@@ -50,3 +50,30 @@ export function moonPosition(time) {
50
50
  assertSupported(time);
51
51
  return moonApparentAtTT(ttDays(time));
52
52
  }
53
+ /**
54
+ * INTERNAL: equation of time for a TT instant, in minutes. Not from upstream:
55
+ * Meeus, *Astronomical Algorithms*, formula 28.1, `E = L0 − 0.0057183° − α +
56
+ * Δψ cos ε`, with `L0` the Sun's mean longitude from formula 28.2 and `α` the
57
+ * apparent right ascension `sunApparentAtTT` already computes. The constant
58
+ * is aberration plus the FK5 correction; `Δψ cos ε` moves `L0` from the mean
59
+ * equinox to the true one `α` is measured from.
60
+ */
61
+ export function equationOfTimeAtTT(tt) {
62
+ const tau = tt / 365250;
63
+ const l0 = 280.4664567 + tau * (360007.6982779 + tau * (0.03032028
64
+ + tau * (1 / 49931 + tau * (-1 / 15300 + tau * (-1 / 2000000)))));
65
+ const tilt = earthTilt(tt);
66
+ const nutationRaDeg = (tilt.dpsi / 3600) * Math.cos(tilt.tobl * DEG2RAD);
67
+ let e = l0 - 0.0057183 - sunApparentAtTT(tt).raDeg + nutationRaDeg;
68
+ e = ((e % 360) + 540) % 360 - 180; // (−180, 180]
69
+ return e * 4; // 360° of right ascension is 1440 minutes
70
+ }
71
+ /**
72
+ * Equation of time: apparent solar time minus mean solar time, in minutes.
73
+ * Positive when a sundial runs ahead of the clock; about +16.4 at the start
74
+ * of November and −14.2 in mid February.
75
+ */
76
+ export function equationOfTime(time) {
77
+ assertSupported(time);
78
+ return equationOfTimeAtTT(ttDays(time));
79
+ }
@@ -0,0 +1,17 @@
1
+ /** The season the Sun's longitude starts: 0° March equinox, 90° June solstice, 180° September equinox, 270° December solstice. */
2
+ export type SeasonKind = 'marchEquinox' | 'juneSolstice' | 'septemberEquinox' | 'decemberSolstice';
3
+ /** An equinox or solstice. */
4
+ export interface SeasonEvent {
5
+ time: Date;
6
+ kind: SeasonKind;
7
+ }
8
+ /**
9
+ * The equinoxes and solstices within the half-open window `[startUtc, endUtc)`,
10
+ * sorted ascending.
11
+ *
12
+ * A season starts the instant the Sun's apparent geocentric ecliptic longitude
13
+ * reaches 0° (March equinox), 90° (June solstice), 180° (September equinox) or
14
+ * 270° (December solstice). The names are months, not seasons, because the
15
+ * same instant starts opposite seasons in the two hemispheres.
16
+ */
17
+ export declare function seasons(startUtc: Date, endUtc: Date): SeasonEvent[];
@@ -0,0 +1,73 @@
1
+ // L3 season search: the four instants a year when the Sun's apparent
2
+ // geocentric ecliptic longitude reaches 0°, 90°, 180° and 270°.
3
+ //
4
+ // Translated from the cosinekitty/astronomy upstream, pinned sha
5
+ // 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts:
6
+ // `SearchSunLongitude` (~4787), whose `sun_offset` and 0.01 s `Search` are
7
+ // kept. Upstream's `Seasons(year)` (~6130) brackets each search from a fixed
8
+ // calendar date. Here the bracket is a cell of a fixed 80-day grid anchored at
9
+ // J2000, so every window that contains a season finds it from the same bracket
10
+ // and reports the same millisecond — the fixed-seed rule the eclipse searches
11
+ // follow. A cell spans under 90° of solar longitude, so it holds at most one
12
+ // quarter crossing.
13
+ //
14
+ // INTERNAL except `seasons`: not part of the curated public API (see index.ts).
15
+ import { assertSupported, assertSupportedWindowEnd } from './types.js';
16
+ import { utDays, ttDaysFromUt, dateFromUt } from './time.js';
17
+ import { sunEclipticLonDeg } from './illumination.js';
18
+ import { search, angleOffset, quantizedUt, assertReached } from './events.js';
19
+ const SEASON_KINDS = ['marchEquinox', 'juneSolstice', 'septemberEquinox', 'decemberSolstice'];
20
+ /** Grid cell, in days since J2000 UT: under the 89-day shortest season, so a cell holds at most one crossing. */
21
+ const SEASON_CELL_DAYS = 80;
22
+ function sunLonDeg(ut) {
23
+ return sunEclipticLonDeg(ttDaysFromUt(ut));
24
+ }
25
+ /**
26
+ * UPSTREAM: `SearchSunLongitude` on one grid cell `[t1, t2]`, with
27
+ * `lon1 = sunLonDeg(t1)` already in hand. Returns the crossing of the next
28
+ * multiple of 90° after `lon1`, or `null` when the cell ends before it.
29
+ */
30
+ function searchSeasonInCell(t1, t2, lon1) {
31
+ const quarter = (Math.floor(lon1 / 90) + 1) % 4;
32
+ const targetLonDeg = 90 * quarter;
33
+ const sunOffset = (ut) => angleOffset(sunLonDeg(ut) - targetLonDeg);
34
+ if (sunOffset(t2) < 0)
35
+ return null;
36
+ const ut = search(sunOffset, t1, t2, 0.01, undefined, 'season search');
37
+ assertReached(ut !== null, 'season search');
38
+ return { ut: ut, quarter };
39
+ }
40
+ /**
41
+ * The equinoxes and solstices within the half-open window `[startUtc, endUtc)`,
42
+ * sorted ascending.
43
+ *
44
+ * A season starts the instant the Sun's apparent geocentric ecliptic longitude
45
+ * reaches 0° (March equinox), 90° (June solstice), 180° (September equinox) or
46
+ * 270° (December solstice). The names are months, not seasons, because the
47
+ * same instant starts opposite seasons in the two hemispheres.
48
+ */
49
+ export function seasons(startUtc, endUtc) {
50
+ assertSupported(startUtc);
51
+ assertSupportedWindowEnd(endUtc);
52
+ if (startUtc.getTime() >= endUtc.getTime())
53
+ return [];
54
+ const startUt = utDays(startUtc);
55
+ const endUt = utDays(endUtc);
56
+ const events = [];
57
+ let t1 = Math.floor(startUt / SEASON_CELL_DAYS) * SEASON_CELL_DAYS;
58
+ let lon1 = sunLonDeg(t1);
59
+ while (t1 < endUt) {
60
+ const t2 = t1 + SEASON_CELL_DAYS;
61
+ const found = searchSeasonInCell(t1, t2, lon1);
62
+ if (found !== null) {
63
+ const q = quantizedUt(found.ut); // judge the half-open bounds on the reported instant, not the raw root
64
+ if (q >= endUt)
65
+ break;
66
+ if (q >= startUt)
67
+ events.push({ time: dateFromUt(found.ut), kind: SEASON_KINDS[found.quarter] });
68
+ }
69
+ t1 = t2;
70
+ lon1 = sunLonDeg(t1);
71
+ }
72
+ return events;
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwaters/almanac",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Offline astronomy: Sun, Moon and planet positions, apparent horizon, rise/set, phases and eclipses.",
5
5
  "license": "MIT",
6
6
  "repository": {