@openwaters/almanac 0.2.1 → 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 +34 -22
- package/dist/eclipse.d.ts +52 -0
- package/dist/eclipse.js +18 -13
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/solar.d.ts +66 -0
- package/dist/solar.js +344 -0
- package/dist/transforms.d.ts +21 -0
- package/dist/transforms.js +49 -0
- package/package.json +41 -7
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# Almanac
|
|
2
2
|
|
|
3
|
-
Offline
|
|
4
|
-
rise/set/twilight, moon phase, and lunar eclipses (solar planned), computed from pure
|
|
5
|
-
geometry with zero network 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.
|
|
6
4
|
|
|
7
5
|
Twin implementations, one behavior:
|
|
8
6
|
|
|
@@ -11,22 +9,22 @@ Twin implementations, one behavior:
|
|
|
11
9
|
- `fixtures/` — the shared test corpus (JPL Horizons, USNO, Espenak) both suites
|
|
12
10
|
must pass; the contract that keeps the ports identical
|
|
13
11
|
|
|
14
|
-
Supported interval: 1950-01-01T00:00Z ≤ t < 2101-01-01T00:00Z; results outside it
|
|
15
|
-
raise a typed error. All instants are UT1-accurate, not civil-UTC-accurate in the far
|
|
16
|
-
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.
|
|
17
13
|
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
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.
|
|
21
18
|
|
|
22
19
|
Algorithms translated from [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
|
23
20
|
(MIT, Don Cross) — see [NOTICE](NOTICE). MIT licensed.
|
|
24
21
|
|
|
25
22
|
## Performance
|
|
26
23
|
|
|
27
|
-
Almanac includes a shared performance harness for both ports: **
|
|
24
|
+
Almanac includes a shared performance harness for both ports: **22 workloads**
|
|
28
25
|
cover positions, a 228-hour sky track, short/year/polar event windows, full-range
|
|
29
|
-
moon phases,
|
|
26
|
+
moon phases, next/previous/range lunar and solar eclipse searches, including empty
|
|
27
|
+
windows, and solar obscuration over a 228-hour track.
|
|
30
28
|
|
|
31
29
|
The v0.2.1 optimizations reuse Moon and shadow geometry, flatten scratch arrays,
|
|
32
30
|
and refine altitude crossings with fewer position evaluations. In the
|
|
@@ -42,20 +40,18 @@ median query time fell relative to the v0.2.0 astronomy code:
|
|
|
42
40
|
| Previous lunar eclipse | 26.0% | 36.6% |
|
|
43
41
|
| Lunar eclipses / 1950–2100 | 26.4% | 23.4% |
|
|
44
42
|
|
|
45
|
-
TypeScript was measured on Ubuntu
|
|
46
|
-
macOS 15. Each comparison builds both revisions with the same harness and
|
|
47
|
-
toolchain, then takes seven interleaved process pairs with 300 ms warmup per
|
|
48
|
-
process. Build and startup time are excluded. Timings vary by machine; the shared
|
|
49
|
-
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.
|
|
50
44
|
|
|
51
|
-
Reproduce the comparison locally from the repository root
|
|
52
|
-
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`:
|
|
53
46
|
|
|
54
47
|
```bash
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
mise install
|
|
49
|
+
mise exec -- npm ci --prefix typescript
|
|
50
|
+
mise exec -- node benchmarks/run.mjs --base v0.2.0 --skip '^solar/'
|
|
57
51
|
```
|
|
58
52
|
|
|
53
|
+
The `--skip` pattern leaves out the solar eclipse workloads, which need a base of 0.4.0 or later.
|
|
54
|
+
|
|
59
55
|
CI runs the harness on code changes and fails on **median regressions over 20%**.
|
|
60
56
|
Results include timing tables, raw samples, checksums, and revision/toolchain
|
|
61
57
|
metadata. See [the harness guide](CONTRIBUTING.md#performance) for choosing a
|
|
@@ -73,7 +69,7 @@ npm install @openwaters/almanac
|
|
|
73
69
|
```
|
|
74
70
|
|
|
75
71
|
```ts
|
|
76
|
-
import { nextLunarEclipse, lunarEclipseVisibility, sunEvents } from '@openwaters/almanac';
|
|
72
|
+
import { nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, sunEvents, starAltAz } from '@openwaters/almanac';
|
|
77
73
|
|
|
78
74
|
const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };
|
|
79
75
|
|
|
@@ -81,17 +77,24 @@ const eclipse = nextLunarEclipse(new Date());
|
|
|
81
77
|
const visibility = lunarEclipseVisibility(eclipse, observer);
|
|
82
78
|
console.log(eclipse.kind, eclipse.peak, visibility.visibleAtPeak);
|
|
83
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
|
+
|
|
84
84
|
const today = new Date();
|
|
85
85
|
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
|
|
86
86
|
for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
|
|
87
87
|
console.log(kind, time.toISOString());
|
|
88
88
|
}
|
|
89
|
+
|
|
90
|
+
// Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
|
|
91
|
+
const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
|
|
89
92
|
```
|
|
90
93
|
|
|
91
94
|
### Swift
|
|
92
95
|
|
|
93
96
|
```swift
|
|
94
|
-
.package(url: "https://github.com/openwatersio/almanac.git", exact: "0.
|
|
97
|
+
.package(url: "https://github.com/openwatersio/almanac.git", exact: "0.4.0")
|
|
95
98
|
```
|
|
96
99
|
|
|
97
100
|
```swift
|
|
@@ -104,11 +107,18 @@ let eclipse = try nextLunarEclipse(after: Date())
|
|
|
104
107
|
let visibility = try lunarEclipseVisibility(eclipse, observer: observer)
|
|
105
108
|
print(eclipse.kind, eclipse.peak, visibility.visibleAtPeak)
|
|
106
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
|
+
|
|
107
114
|
let today = Date()
|
|
108
115
|
let tomorrow = today.addingTimeInterval(24 * 60 * 60)
|
|
109
116
|
for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
|
|
110
117
|
print(event.kind, event.time)
|
|
111
118
|
}
|
|
119
|
+
|
|
120
|
+
// Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
|
|
121
|
+
let star = try starAltAz(raDeg: 88.792939, decDeg: 7.407064, at: today, observer: observer)
|
|
112
122
|
```
|
|
113
123
|
|
|
114
124
|
### Eclipse searches
|
|
@@ -129,3 +139,5 @@ let eclipses = try lunarEclipses(from: today, to: tomorrow)
|
|
|
129
139
|
Ranges include peaks at the start and exclude peaks at the end. Contacts may
|
|
130
140
|
extend outside the range. Previous/next searches skip peaks within 100 ms of the
|
|
131
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
|
|
32
|
-
* latitude above which no eclipse is possible.
|
|
33
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
@@ -3,10 +3,12 @@ export { AlmanacOutOfRangeError } from './types.js';
|
|
|
3
3
|
export type { SunPosition, MoonPosition } from './positions.js';
|
|
4
4
|
export { sunPosition, moonPosition } from './positions.js';
|
|
5
5
|
export type { AltAz } from './transforms.js';
|
|
6
|
-
export { sunAltAz, moonAltAz } from './transforms.js';
|
|
6
|
+
export { sunAltAz, moonAltAz, starAltAz } from './transforms.js';
|
|
7
7
|
export type { MoonIllumination } from './illumination.js';
|
|
8
8
|
export { moonIllumination } from './illumination.js';
|
|
9
9
|
export type { SunEvent, SunEventKind, MoonEvent, MoonEventKind, MoonPhaseEvent, MoonPhaseName } from './events.js';
|
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { AlmanacOutOfRangeError } from './types.js';
|
|
2
2
|
export { sunPosition, moonPosition } from './positions.js';
|
|
3
|
-
export { sunAltAz, moonAltAz } from './transforms.js';
|
|
3
|
+
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';
|
package/dist/solar.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/transforms.d.ts
CHANGED
|
@@ -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`
|
|
@@ -59,3 +68,15 @@ export declare function sunAltAz(time: Date, observer: Observer): AltAz;
|
|
|
59
68
|
* refraction (upstream `'normal'` model) both applied.
|
|
60
69
|
*/
|
|
61
70
|
export declare function moonAltAz(time: Date, observer: Observer): AltAz;
|
|
71
|
+
/**
|
|
72
|
+
* Topocentric azimuth/altitude of a fixed star from its J2000 (ICRS)
|
|
73
|
+
* catalog right ascension and declination, both in degrees.
|
|
74
|
+
*
|
|
75
|
+
* Applied: precession and nutation to the equator of date, Greenwich
|
|
76
|
+
* apparent sidereal time, and atmospheric refraction (upstream `'normal'`
|
|
77
|
+
* model, the same as sunAltAz/moonAltAz). Not applied: annual aberration
|
|
78
|
+
* (at most 20.5″ — see the roadmap), proper motion (pass catalog
|
|
79
|
+
* coordinates; fast movers such as Arcturus drift ~1′ in 26 years), and
|
|
80
|
+
* stellar parallax (negligible at the 1000 ly the star is placed at).
|
|
81
|
+
*/
|
|
82
|
+
export declare function starAltAz(raDeg: number, decDeg: number, time: Date, observer: Observer): AltAz;
|
package/dist/transforms.js
CHANGED
|
@@ -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;
|
|
@@ -196,3 +207,41 @@ export function moonAltAz(time, observer) {
|
|
|
196
207
|
const ut = utDays(time);
|
|
197
208
|
return refract(topoAltAzUnrefracted(moonGeoVectorEqj(ttDaysFromUt(ut)), ut, observer));
|
|
198
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* UPSTREAM: the user-defined star path — `HelioVector` for a `DefineStar`
|
|
212
|
+
* body (astronomy.ts ~4097: `VectorFromSphere(Spherical(dec, 15*ra, dist))`)
|
|
213
|
+
* into `Equator(body, date, observer, ofdate=true, aberration=false)` (~2803)
|
|
214
|
+
* and `Horizon` (~2565): the same topocentric path sunAltAz/moonAltAz take.
|
|
215
|
+
* Upstream floors a star's distance at 1 light-year and suggests 1000 when
|
|
216
|
+
* unknown; the star sits at 1000 ly here (`AU_PER_LY`, astronomy.ts line 52).
|
|
217
|
+
* Upstream's geocentric step (star vector minus Earth's heliocentric
|
|
218
|
+
* position) is skipped: at 1000 ly it moves the star 0.003″.
|
|
219
|
+
*/
|
|
220
|
+
const STAR_DISTANCE_AU = 1000 * 63241.07708807546;
|
|
221
|
+
/**
|
|
222
|
+
* Topocentric azimuth/altitude of a fixed star from its J2000 (ICRS)
|
|
223
|
+
* catalog right ascension and declination, both in degrees.
|
|
224
|
+
*
|
|
225
|
+
* Applied: precession and nutation to the equator of date, Greenwich
|
|
226
|
+
* apparent sidereal time, and atmospheric refraction (upstream `'normal'`
|
|
227
|
+
* model, the same as sunAltAz/moonAltAz). Not applied: annual aberration
|
|
228
|
+
* (at most 20.5″ — see the roadmap), proper motion (pass catalog
|
|
229
|
+
* coordinates; fast movers such as Arcturus drift ~1′ in 26 years), and
|
|
230
|
+
* stellar parallax (negligible at the 1000 ly the star is placed at).
|
|
231
|
+
*/
|
|
232
|
+
export function starAltAz(raDeg, decDeg, time, observer) {
|
|
233
|
+
assertSupported(time);
|
|
234
|
+
assertObserver(observer);
|
|
235
|
+
if (!(raDeg >= 0 && raDeg < 360))
|
|
236
|
+
throw new RangeError(`raDeg out of range: ${raDeg}`);
|
|
237
|
+
if (!(decDeg >= -90 && decDeg <= 90))
|
|
238
|
+
throw new RangeError(`decDeg out of range: ${decDeg}`);
|
|
239
|
+
const ut = utDays(time);
|
|
240
|
+
const rCosDec = STAR_DISTANCE_AU * Math.cos(decDeg * DEG2RAD);
|
|
241
|
+
const eqj = {
|
|
242
|
+
x: rCosDec * Math.cos(raDeg * DEG2RAD),
|
|
243
|
+
y: rCosDec * Math.sin(raDeg * DEG2RAD),
|
|
244
|
+
z: STAR_DISTANCE_AU * Math.sin(decDeg * DEG2RAD),
|
|
245
|
+
};
|
|
246
|
+
return refract(topoAltAzUnrefracted(eqj, ut, observer));
|
|
247
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwaters/almanac",
|
|
3
|
-
"version": "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": {
|
|
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": {
|
|
12
|
-
|
|
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": {
|
|
15
|
-
|
|
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
|
}
|