caelus 0.17.0 → 0.19.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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * astroengine counterfactual -- a real chart, perturbed, and what changed.
3
+ *
4
+ * The `counterfactual` realm: take a resolved chart and ask "what if". Shift the
5
+ * instant ("born an hour later") or the place -- a real ephemeris recompute --
6
+ * or splice a body to a new longitude ("Mars in the next sign") -- a geometry
7
+ * what-if that keeps everything else and recomputes the aspects it touches.
8
+ * {@link chartDiff} reports the difference so the change is legible, not buried
9
+ * in two full charts.
10
+ */
11
+ import { Engine, Chart, ChartOptions, Aspect } from "./chart.js";
12
+ import { AnchorRegistry } from "./provenance.js";
13
+ import { AnchoredChart, RealizedChart } from "./anchored.js";
14
+ /** A perturbation of a resolved chart. */
15
+ export interface CounterfactualEdit {
16
+ /** Shift the resolved instant by a duration (e.g. `"1h"`, `"-30m"`, `"P1D"`). */
17
+ shiftTime?: string;
18
+ /** Recompute at a different place. */
19
+ place?: {
20
+ lat: number;
21
+ lonEast: number;
22
+ altM?: number;
23
+ };
24
+ /** Move bodies to given ecliptic longitudes (degrees), keeping everything else
25
+ * -- a geometry what-if. The moved body's house and the touched aspects are
26
+ * recomputed; the angles and other bodies are untouched. */
27
+ setLongitudes?: Record<string, number>;
28
+ }
29
+ /** A body whose sign or house changed between two charts. */
30
+ export interface BodyChange {
31
+ body: string;
32
+ /** Signed degrees the body moved (`b` minus `a`). */
33
+ dLon: number;
34
+ signFrom: string;
35
+ signTo: string;
36
+ houseFrom: number;
37
+ houseTo: number;
38
+ }
39
+ /** An angle whose sign changed. */
40
+ export interface AngleChange {
41
+ angle: string;
42
+ from: string;
43
+ to: string;
44
+ }
45
+ /** What differs between two charts. */
46
+ export interface ChartDiff {
47
+ /** Bodies whose sign or house changed. */
48
+ bodies: BodyChange[];
49
+ /** Aspects present in the variant but not the original. */
50
+ aspectsGained: Aspect[];
51
+ /** Aspects present in the original but not the variant. */
52
+ aspectsLost: Aspect[];
53
+ /** Angles whose sign changed. */
54
+ angles: AngleChange[];
55
+ }
56
+ /** Diff two charts: body sign/house shifts, aspects gained/lost, angle sign
57
+ * changes. Bodies and angles that did not change sign/house are omitted. */
58
+ export declare function chartDiff(a: Chart, b: Chart): ChartDiff;
59
+ /** A counterfactual: a base chart and a perturbed variant, with the diff. */
60
+ export interface Counterfactual {
61
+ edit: CounterfactualEdit;
62
+ /** The realized base (its `chart` is the original). */
63
+ original: RealizedChart;
64
+ /** The perturbed chart, or null when the base had no chart to perturb. */
65
+ variant: Chart | null;
66
+ diff: ChartDiff | null;
67
+ note: string;
68
+ }
69
+ /**
70
+ * Realize an {@link AnchoredChart}, then apply a {@link CounterfactualEdit} and
71
+ * diff the result -- "a real event, perturbed." A time/place edit recomputes the
72
+ * ephemeris; a `setLongitudes` edit splices the geometry.
73
+ *
74
+ * @param engine The engine.
75
+ * @param base The base chart to perturb (realized via {@link realize}).
76
+ * @param edit The perturbation.
77
+ * @param registry Anchor lookups for the base.
78
+ * @param opts Chart options (house system, zodiac).
79
+ * @returns A {@link Counterfactual}; `variant`/`diff` are null when the base
80
+ * produced no chart (e.g. a constraints-only form).
81
+ */
82
+ export declare function counterfactual(engine: Engine, base: AnchoredChart, edit: CounterfactualEdit, registry?: AnchorRegistry, opts?: ChartOptions): Counterfactual;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * astroengine counterfactual -- a real chart, perturbed, and what changed.
3
+ *
4
+ * The `counterfactual` realm: take a resolved chart and ask "what if". Shift the
5
+ * instant ("born an hour later") or the place -- a real ephemeris recompute --
6
+ * or splice a body to a new longitude ("Mars in the next sign") -- a geometry
7
+ * what-if that keeps everything else and recomputes the aspects it touches.
8
+ * {@link chartDiff} reports the difference so the change is legible, not buried
9
+ * in two full charts.
10
+ */
11
+ import { SIGNS, findAspects, DEFAULT_ORBS, } from "./chart.js";
12
+ import { houseOf } from "./electional.js";
13
+ import { mod } from "./core.js";
14
+ import { parseOffset } from "./provenance.js";
15
+ import { realize } from "./anchored.js";
16
+ const signOf = (lon) => SIGNS[Math.floor(mod(lon, 360) / 30)];
17
+ const aspectKey = (x) => `${[x.a, x.b].sort().join("~")}:${x.aspect}`;
18
+ /** Diff two charts: body sign/house shifts, aspects gained/lost, angle sign
19
+ * changes. Bodies and angles that did not change sign/house are omitted. */
20
+ export function chartDiff(a, b) {
21
+ const bodies = [];
22
+ for (const [name, pa] of Object.entries(a.bodies)) {
23
+ const pb = b.bodies[name];
24
+ if (!pa || !pb || (pa.sign === pb.sign && pa.house === pb.house))
25
+ continue;
26
+ bodies.push({
27
+ body: name, dLon: mod(pb.lon - pa.lon + 180, 360) - 180,
28
+ signFrom: pa.sign, signTo: pb.sign, houseFrom: pa.house, houseTo: pb.house,
29
+ });
30
+ }
31
+ const aset = new Set(a.aspects.map(aspectKey));
32
+ const bset = new Set(b.aspects.map(aspectKey));
33
+ const angles = [];
34
+ for (const ang of ["asc", "mc", "vertex", "eastPoint"]) {
35
+ const from = signOf(a.angles[ang]);
36
+ const to = signOf(b.angles[ang]);
37
+ if (from !== to)
38
+ angles.push({ angle: ang, from, to });
39
+ }
40
+ return {
41
+ bodies,
42
+ aspectsGained: b.aspects.filter((x) => !aset.has(aspectKey(x))),
43
+ aspectsLost: a.aspects.filter((x) => !bset.has(aspectKey(x))),
44
+ angles,
45
+ };
46
+ }
47
+ /** Splice bodies to new longitudes, recomputing their sign/house and the
48
+ * aspects (the angles and untouched bodies stay as they were). */
49
+ function spliceLongitudes(chart, overrides) {
50
+ const bodies = { ...chart.bodies };
51
+ for (const [name, lon] of Object.entries(overrides)) {
52
+ const cur = bodies[name];
53
+ if (!cur)
54
+ continue;
55
+ const L = mod(lon, 360);
56
+ bodies[name] = {
57
+ ...cur, lon: L, sign: SIGNS[Math.floor(L / 30)], signDeg: L % 30,
58
+ house: houseOf(L, chart.cusps),
59
+ };
60
+ }
61
+ return {
62
+ ...chart, bodies: bodies,
63
+ aspects: findAspects(bodies, DEFAULT_ORBS),
64
+ };
65
+ }
66
+ /**
67
+ * Realize an {@link AnchoredChart}, then apply a {@link CounterfactualEdit} and
68
+ * diff the result -- "a real event, perturbed." A time/place edit recomputes the
69
+ * ephemeris; a `setLongitudes` edit splices the geometry.
70
+ *
71
+ * @param engine The engine.
72
+ * @param base The base chart to perturb (realized via {@link realize}).
73
+ * @param edit The perturbation.
74
+ * @param registry Anchor lookups for the base.
75
+ * @param opts Chart options (house system, zodiac).
76
+ * @returns A {@link Counterfactual}; `variant`/`diff` are null when the base
77
+ * produced no chart (e.g. a constraints-only form).
78
+ */
79
+ export function counterfactual(engine, base, edit, registry = {}, opts = {}) {
80
+ const original = realize(engine, base, registry, opts);
81
+ if (!original.chart) {
82
+ return { edit, original, variant: null, diff: null, note: `nothing to perturb (${original.note})` };
83
+ }
84
+ const shiftsTimeOrPlace = edit.shiftTime !== undefined || edit.place !== undefined;
85
+ let variant = original.chart;
86
+ if (shiftsTimeOrPlace) {
87
+ const off = edit.shiftTime !== undefined ? parseOffset(edit.shiftTime) : 0;
88
+ if (Number.isNaN(off))
89
+ throw new Error(`unparseable shiftTime ${edit.shiftTime}`);
90
+ const lat = edit.place?.lat ?? original.place.place?.lat ?? 0;
91
+ const lon = edit.place?.lonEast ?? original.place.place?.lonEast ?? 0;
92
+ variant = engine.chartAt(original.time.jd + off, lat, lon, opts);
93
+ }
94
+ if (edit.setLongitudes)
95
+ variant = spliceLongitudes(variant, edit.setLongitudes);
96
+ const bits = [
97
+ edit.shiftTime !== undefined ? `time ${edit.shiftTime}` : null,
98
+ edit.place ? "place" : null,
99
+ edit.setLongitudes ? `moved ${Object.keys(edit.setLongitudes).join(", ")}` : null,
100
+ ].filter(Boolean);
101
+ return {
102
+ edit, original, variant, diff: chartDiff(original.chart, variant),
103
+ note: bits.length ? `perturbed: ${bits.join("; ")}` : "no edit applied",
104
+ };
105
+ }
@@ -38,3 +38,106 @@ export declare function lunarEclipses(engine: Engine, jdStart: number, jdEnd: nu
38
38
  * ```
39
39
  */
40
40
  export declare function solarEclipses(engine: Engine, jdStart: number, jdEnd: number): SolarEclipse[];
41
+ /** Geographic point on the Earth's surface (geodetic latitude, east longitude). */
42
+ export interface GeoPoint {
43
+ /** Geodetic latitude in degrees, north positive. */
44
+ lat: number;
45
+ /** Longitude in degrees, east positive, in (-180, 180]. */
46
+ lonEast: number;
47
+ }
48
+ /** Local circumstances of a solar eclipse seen from one place. */
49
+ export interface SolarLocal {
50
+ /** What the observer sees at maximum: `"none"` when no part of the Sun is
51
+ * covered from this place. */
52
+ type: "total" | "annular" | "partial" | "none";
53
+ /** Eclipse magnitude: fraction of the Sun's *diameter* covered at maximum
54
+ * (can exceed 1 in totality). 0 when `type` is `"none"`. */
55
+ magnitude: number;
56
+ /** Obscuration: fraction of the Sun's *area* covered at maximum, in [0, 1]. */
57
+ obscuration: number;
58
+ /** Time of maximum eclipse at this place (JD UT), or `null` when unseen. */
59
+ maxTime: number | null;
60
+ /** First contact (partial begins), JD UT, or `null` when unseen. */
61
+ c1: number | null;
62
+ /** Second contact (totality/annularity begins), JD UT, or `null`. */
63
+ c2: number | null;
64
+ /** Third contact (totality/annularity ends), JD UT, or `null`. */
65
+ c3: number | null;
66
+ /** Fourth contact (partial ends), JD UT, or `null` when unseen. */
67
+ c4: number | null;
68
+ }
69
+ /**
70
+ * Sub-shadow geographic point where the eclipse axis meets the Earth at a
71
+ * Julian Day (UT): the centre line of totality/annularity at that instant.
72
+ * Sample it across the eclipse (e.g. between the {@link SolarEclipse} `begin`
73
+ * and `end`) to draw the ground track.
74
+ *
75
+ * @param engine The engine used to evaluate positions.
76
+ * @param jd Instant to evaluate, Julian Day (UT) -- typically a
77
+ * {@link SolarEclipse.tMax} for the point of greatest eclipse.
78
+ * @returns The {@link GeoPoint} on the IAU 1976 ellipsoid, or `null` when the
79
+ * axis misses the Earth (only a partial eclipse exists anywhere then).
80
+ */
81
+ export declare function solarEclipseWhere(engine: Engine, jd: number): GeoPoint | null;
82
+ /**
83
+ * Local circumstances of a solar eclipse as seen from one place: contact
84
+ * times, magnitude, and obscuration. Topocentric Sun and Moon disks, so it
85
+ * accounts for lunar parallax (which is what makes the same eclipse total in
86
+ * one town and partial in the next).
87
+ *
88
+ * @param engine The engine used to evaluate positions.
89
+ * @param jd A time near the eclipse, JD (UT) -- typically a
90
+ * {@link SolarEclipse.tMax}; the local maximum is found within a few hours.
91
+ * @param latDeg Observer geodetic latitude in degrees (north positive).
92
+ * @param lonEastDeg Observer longitude in degrees (east positive).
93
+ * @param altM Observer height above the ellipsoid in metres (default 0).
94
+ * @returns {@link SolarLocal}. `type` is `"none"` when the Sun is not eclipsed
95
+ * from this place at all; `c2`/`c3` are `null` outside totality/annularity.
96
+ */
97
+ export declare function solarEclipseLocal(engine: Engine, jd: number, latDeg: number, lonEastDeg: number, altM?: number): SolarLocal;
98
+ /** The umbra/antumbra path of a solar eclipse at one instant. */
99
+ export interface EclipsePath {
100
+ /** Central line point (greatest coverage) at this instant. */
101
+ center: GeoPoint;
102
+ /** Northern edge of totality/annularity, or `null` if it runs off the Earth. */
103
+ north: GeoPoint | null;
104
+ /** Southern edge, or `null` if it runs off the Earth. */
105
+ south: GeoPoint | null;
106
+ /** Full path width (km) between the limits, or `null` when a limit is missing. */
107
+ widthKm: number | null;
108
+ }
109
+ /**
110
+ * Ground path of a solar eclipse at a Julian Day (UT): the central point and
111
+ * the north/south limits of totality (or annularity), with the path width.
112
+ * Marches perpendicular to the shadow's ground track out to the umbra edge --
113
+ * where the Moon just fully covers (or is covered by) the Sun. Sample across
114
+ * the eclipse to trace the full path of totality.
115
+ *
116
+ * @param engine The engine used to evaluate positions.
117
+ * @param jd Instant to evaluate, Julian Day (UT) -- typically a
118
+ * {@link SolarEclipse.tMax} for the path at greatest eclipse.
119
+ * @returns The {@link EclipsePath}, or `null` when no central eclipse exists
120
+ * then (only a partial eclipse, or the axis misses the Earth).
121
+ */
122
+ export declare function solarEclipseLimits(engine: Engine, jd: number): EclipsePath | null;
123
+ /** Whether a lunar eclipse is up at a place, and how high the Moon stands. */
124
+ export interface LunarLocal {
125
+ /** Moon's true altitude in degrees at the given instant (negative = below
126
+ * the horizon). */
127
+ altitude: number;
128
+ /** Whether the Moon is above the horizon (the eclipse is visible there). */
129
+ visible: boolean;
130
+ }
131
+ /**
132
+ * Local visibility of a lunar eclipse: a lunar eclipse happens at the same
133
+ * instant for the whole Earth, so "local circumstances" is simply whether the
134
+ * Moon is up. Pass a contact time (e.g. {@link LunarEclipse.tMax} or a phase
135
+ * boundary) to learn whether that phase is visible from a place.
136
+ *
137
+ * @param engine The engine used to evaluate positions.
138
+ * @param jd Instant to evaluate, Julian Day (UT).
139
+ * @param latDeg Observer latitude in degrees (north positive).
140
+ * @param lonEastDeg Observer longitude in degrees (east positive).
141
+ * @returns {@link LunarLocal} with the Moon's altitude and a visibility flag.
142
+ */
143
+ export declare function lunarEclipseLocal(engine: Engine, jd: number, latDeg: number, lonEastDeg: number): LunarLocal;
@@ -11,9 +11,18 @@
11
11
  * Sun-Moon axis to the geocenter in Earth radii; the umbral cone's reach
12
12
  * at the surface separates total from annular, a sign change along the
13
13
  * track marks hybrids. Types match Swiss Ephemeris exactly over decades.
14
- * Local circumstances (where/visibility) are not computed here.
14
+ *
15
+ * Solar (where): the same shadow axis intersected with the IAU 1976 Earth
16
+ * ellipsoid gives the sub-shadow geographic point -- the centre line of
17
+ * totality/annularity at an instant; sampled across the eclipse it draws the
18
+ * ground track. Solar (local): topocentric Sun/Moon disks at an observer give
19
+ * the contact times, magnitude, and obscuration as seen from that point. With
20
+ * a ~2.5" Moon these land within seconds of time and a few km of track: right
21
+ * for charts, not for eclipse-chaser path maps.
15
22
  */
16
- import { ARCSEC, jdTT, mod } from "./core.js";
23
+ import { ARCSEC, DEG, jdTT, mod, trueObliquity, equatorial, topocentricEcl } from "./core.js";
24
+ import { gast } from "./houses.js";
25
+ import { azAlt } from "./pheno.js";
17
26
  const KM_PER_AU = 149597870.7;
18
27
  const R_EARTH = 6378.14;
19
28
  const R_SUN = 696000.0;
@@ -177,3 +186,252 @@ export function solarEclipses(engine, jdStart, jdEnd) {
177
186
  }
178
187
  return out;
179
188
  }
189
+ // ---------------------------------------------------------------- where + local
190
+ const EARTH_FLAT = 0.99664719; // 1 - f, IAU 1976 figure (b/a)
191
+ const EARTH_FLAT2 = EARTH_FLAT * EARTH_FLAT; // (b/a)^2 = 1 - e^2
192
+ /** Geocentric *equatorial* Cartesian (km) of the Sun and Moon, plus obliquity. */
193
+ function sunMoonEq(engine, jde) {
194
+ const eps = trueObliquity(engine.data, jde);
195
+ const vec = (body) => {
196
+ const [lon, lat, dist] = engine.ecliptic(body, jde);
197
+ const [ra, dec] = equatorial(lon, lat, eps);
198
+ const r = dist * KM_PER_AU;
199
+ return [
200
+ r * Math.cos(dec) * Math.cos(ra),
201
+ r * Math.cos(dec) * Math.sin(ra),
202
+ r * Math.sin(dec),
203
+ ];
204
+ };
205
+ return { S: vec("sun"), M: vec("moon") };
206
+ }
207
+ /**
208
+ * Sub-shadow geographic point where the eclipse axis meets the Earth at a
209
+ * Julian Day (UT): the centre line of totality/annularity at that instant.
210
+ * Sample it across the eclipse (e.g. between the {@link SolarEclipse} `begin`
211
+ * and `end`) to draw the ground track.
212
+ *
213
+ * @param engine The engine used to evaluate positions.
214
+ * @param jd Instant to evaluate, Julian Day (UT) -- typically a
215
+ * {@link SolarEclipse.tMax} for the point of greatest eclipse.
216
+ * @returns The {@link GeoPoint} on the IAU 1976 ellipsoid, or `null` when the
217
+ * axis misses the Earth (only a partial eclipse exists anywhere then).
218
+ */
219
+ export function solarEclipseWhere(engine, jd) {
220
+ const jde = jdTT(jd);
221
+ const { S, M } = sunMoonEq(engine, jde);
222
+ const SM = [M[0] - S[0], M[1] - S[1], M[2] - S[2]];
223
+ const smn = Math.hypot(SM[0], SM[1], SM[2]);
224
+ const d = SM.map((c) => c / smn); // travels Sun -> Moon -> Earth
225
+ // Intersect the line M + s*d with the ellipsoid by scaling z by 1/flat,
226
+ // which maps the ellipsoid to a sphere of radius R_EARTH.
227
+ const Mz = [M[0], M[1], M[2] / EARTH_FLAT];
228
+ const dz = [d[0], d[1], d[2] / EARTH_FLAT];
229
+ const a = dz[0] ** 2 + dz[1] ** 2 + dz[2] ** 2;
230
+ const b = 2 * (Mz[0] * dz[0] + Mz[1] * dz[1] + Mz[2] * dz[2]);
231
+ const c = Mz[0] ** 2 + Mz[1] ** 2 + Mz[2] ** 2 - R_EARTH ** 2;
232
+ const disc = b * b - 4 * a * c;
233
+ if (disc < 0)
234
+ return null;
235
+ const s = (-b - Math.sqrt(disc)) / (2 * a); // near side, facing the Moon
236
+ const P = [M[0] + s * d[0], M[1] + s * d[1], M[2] + s * d[2]];
237
+ const rho = Math.hypot(P[0], P[1]);
238
+ const lat = Math.atan2(P[2], EARTH_FLAT2 * rho); // geocentric -> geodetic
239
+ const ra = Math.atan2(P[1], P[0]);
240
+ const lonEast = mod(ra - gast(engine.data, jd) + Math.PI, 2 * Math.PI) - Math.PI;
241
+ return { lat: lat / DEG, lonEast: lonEast / DEG };
242
+ }
243
+ /** Topocentric Sun/Moon angular separation and disk radii (rad) at a place. */
244
+ function topoCircs(engine, jd, latDeg, lonEastDeg, altM) {
245
+ const jde = jdTT(jd);
246
+ const eps = trueObliquity(engine.data, jde);
247
+ const lst = mod(gast(engine.data, jd) + lonEastDeg * DEG, 2 * Math.PI);
248
+ const topo = (body) => {
249
+ const [lon, lat, dist] = engine.ecliptic(body, jde);
250
+ return topocentricEcl(lon, lat, dist, lst, latDeg * DEG, altM, eps);
251
+ };
252
+ const [slon, slat, sdist] = topo("sun");
253
+ const [mlon, mlat, mdist] = topo("moon");
254
+ const cosSep = Math.sin(slat) * Math.sin(mlat)
255
+ + Math.cos(slat) * Math.cos(mlat) * Math.cos(slon - mlon);
256
+ return {
257
+ sep: Math.acos(Math.max(-1, Math.min(1, cosSep))),
258
+ sS: Math.asin(R_SUN / (sdist * KM_PER_AU)),
259
+ sM: Math.asin(R_MOON / (mdist * KM_PER_AU)),
260
+ };
261
+ }
262
+ /** Area where two disks (radii r1, r2, centre distance d) overlap. */
263
+ function lensArea(d, r1, r2) {
264
+ if (d >= r1 + r2)
265
+ return 0;
266
+ if (d <= Math.abs(r1 - r2))
267
+ return Math.PI * Math.min(r1, r2) ** 2;
268
+ const a1 = Math.acos((d * d + r1 * r1 - r2 * r2) / (2 * d * r1));
269
+ const a2 = Math.acos((d * d + r2 * r2 - r1 * r1) / (2 * d * r2));
270
+ return r1 * r1 * (a1 - Math.sin(2 * a1) / 2) + r2 * r2 * (a2 - Math.sin(2 * a2) / 2);
271
+ }
272
+ /** Step out from `tMax` (where g < 0) until `g` changes sign, then bisect. */
273
+ function contact(g, tMax, dir) {
274
+ const step = 0.003; // ~4.3 min
275
+ let prev = tMax;
276
+ let fprev = g(tMax);
277
+ for (let i = 1; i <= 120; i++) { // search up to ~8.6 h either side
278
+ const t = tMax + dir * i * step;
279
+ const f = g(t);
280
+ if (fprev * f <= 0)
281
+ return bisect(g, Math.min(prev, t), Math.max(prev, t));
282
+ prev = t;
283
+ fprev = f;
284
+ }
285
+ return null;
286
+ }
287
+ /**
288
+ * Local circumstances of a solar eclipse as seen from one place: contact
289
+ * times, magnitude, and obscuration. Topocentric Sun and Moon disks, so it
290
+ * accounts for lunar parallax (which is what makes the same eclipse total in
291
+ * one town and partial in the next).
292
+ *
293
+ * @param engine The engine used to evaluate positions.
294
+ * @param jd A time near the eclipse, JD (UT) -- typically a
295
+ * {@link SolarEclipse.tMax}; the local maximum is found within a few hours.
296
+ * @param latDeg Observer geodetic latitude in degrees (north positive).
297
+ * @param lonEastDeg Observer longitude in degrees (east positive).
298
+ * @param altM Observer height above the ellipsoid in metres (default 0).
299
+ * @returns {@link SolarLocal}. `type` is `"none"` when the Sun is not eclipsed
300
+ * from this place at all; `c2`/`c3` are `null` outside totality/annularity.
301
+ */
302
+ export function solarEclipseLocal(engine, jd, latDeg, lonEastDeg, altM = 0) {
303
+ const sepAt = (t) => topoCircs(engine, t, latDeg, lonEastDeg, altM).sep;
304
+ const tMax = minimize(sepAt, jd - 0.2, jd + 0.2);
305
+ const { sep, sS, sM } = topoCircs(engine, tMax, latDeg, lonEastDeg, altM);
306
+ const none = {
307
+ type: "none", magnitude: 0, obscuration: 0,
308
+ maxTime: null, c1: null, c2: null, c3: null, c4: null,
309
+ };
310
+ if (sep >= sS + sM)
311
+ return none;
312
+ const type = sep <= sM - sS ? "total" : sep <= sS - sM ? "annular" : "partial";
313
+ const gOuter = (t) => {
314
+ const c = topoCircs(engine, t, latDeg, lonEastDeg, altM);
315
+ return c.sep - (c.sS + c.sM);
316
+ };
317
+ let c2 = null;
318
+ let c3 = null;
319
+ if (type === "total" || type === "annular") {
320
+ const gInner = (t) => {
321
+ const c = topoCircs(engine, t, latDeg, lonEastDeg, altM);
322
+ return c.sep - Math.abs(c.sM - c.sS);
323
+ };
324
+ c2 = contact(gInner, tMax, -1);
325
+ c3 = contact(gInner, tMax, 1);
326
+ }
327
+ // Magnitude = fraction of the Sun's diameter covered. In the partial regime
328
+ // one Moon edge is inside the Sun's disk; once central (annular or total) it
329
+ // is the Moon/Sun diameter ratio -- < 1 in annularity, > 1 in totality.
330
+ return {
331
+ type,
332
+ magnitude: sep <= Math.abs(sM - sS) ? sM / sS : (sS + sM - sep) / (2 * sS),
333
+ obscuration: lensArea(sep, sS, sM) / (Math.PI * sS * sS),
334
+ maxTime: tMax,
335
+ c1: contact(gOuter, tMax, -1),
336
+ c2, c3,
337
+ c4: contact(gOuter, tMax, 1),
338
+ };
339
+ }
340
+ const R_MEAN = 6371.0; // mean Earth radius (km) for short surface offsets
341
+ /** Geodetic destination point `distKm` from (lat, lon) along `bearingDeg`. */
342
+ function destPoint(lat, lon, bearingDeg, distKm) {
343
+ const d = distKm / R_MEAN;
344
+ const br = bearingDeg * DEG;
345
+ const p1 = lat * DEG;
346
+ const l1 = lon * DEG;
347
+ const p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(br));
348
+ const l2 = l1 + Math.atan2(Math.sin(br) * Math.sin(d) * Math.cos(p1), Math.cos(d) - Math.sin(p1) * Math.sin(p2));
349
+ return { lat: p2 / DEG, lonEast: mod(l2 / DEG + 540, 360) - 180 };
350
+ }
351
+ /** Initial great-circle bearing (deg) from `a` to `b`. */
352
+ function bearing(a, b) {
353
+ const p1 = a.lat * DEG;
354
+ const p2 = b.lat * DEG;
355
+ const dl = (b.lonEast - a.lonEast) * DEG;
356
+ return mod(Math.atan2(Math.sin(dl) * Math.cos(p2), Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl)) / DEG, 360);
357
+ }
358
+ /** Great-circle distance (km) between two geographic points. */
359
+ function greatCircleKm(a, b) {
360
+ const p1 = a.lat * DEG;
361
+ const p2 = b.lat * DEG;
362
+ const dp = (b.lat - a.lat) * DEG;
363
+ const dl = (b.lonEast - a.lonEast) * DEG;
364
+ const h = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
365
+ return R_MEAN * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
366
+ }
367
+ /**
368
+ * Ground path of a solar eclipse at a Julian Day (UT): the central point and
369
+ * the north/south limits of totality (or annularity), with the path width.
370
+ * Marches perpendicular to the shadow's ground track out to the umbra edge --
371
+ * where the Moon just fully covers (or is covered by) the Sun. Sample across
372
+ * the eclipse to trace the full path of totality.
373
+ *
374
+ * @param engine The engine used to evaluate positions.
375
+ * @param jd Instant to evaluate, Julian Day (UT) -- typically a
376
+ * {@link SolarEclipse.tMax} for the path at greatest eclipse.
377
+ * @returns The {@link EclipsePath}, or `null` when no central eclipse exists
378
+ * then (only a partial eclipse, or the axis misses the Earth).
379
+ */
380
+ export function solarEclipseLimits(engine, jd) {
381
+ const center = solarEclipseWhere(engine, jd);
382
+ if (center === null)
383
+ return null;
384
+ const ahead = solarEclipseWhere(engine, jd + 1 / 86400);
385
+ const track = ahead ? bearing(center, ahead) : 0; // ground-track direction
386
+ // Inside the umbra/antumbra the centres are closer than |radii difference|.
387
+ const edge = (lat, lon) => {
388
+ const c = topoCircs(engine, jd, lat, lon, 0);
389
+ return c.sep - Math.abs(c.sM - c.sS);
390
+ };
391
+ const march = (brg) => {
392
+ let gPrev = edge(center.lat, center.lonEast); // < 0 on the central line
393
+ for (let s = 4; s <= 400; s += 4) {
394
+ const q = destPoint(center.lat, center.lonEast, brg, s);
395
+ if (gPrev * edge(q.lat, q.lonEast) <= 0) {
396
+ let lo = s - 4;
397
+ let hi = s; // edge is between lo (inside) and hi (outside)
398
+ for (let i = 0; i < 40; i++) {
399
+ const mid = (lo + hi) / 2;
400
+ const qm = destPoint(center.lat, center.lonEast, brg, mid);
401
+ if (edge(qm.lat, qm.lonEast) <= 0)
402
+ lo = mid;
403
+ else
404
+ hi = mid;
405
+ }
406
+ return destPoint(center.lat, center.lonEast, brg, (lo + hi) / 2);
407
+ }
408
+ gPrev = edge(q.lat, q.lonEast);
409
+ }
410
+ return null;
411
+ };
412
+ const a = march(mod(track - 90, 360));
413
+ const b = march(mod(track + 90, 360));
414
+ // Label by latitude so `north` is always the higher-latitude edge.
415
+ const [north, south] = !a || !b ? [a, b] : a.lat >= b.lat ? [a, b] : [b, a];
416
+ return {
417
+ center, north, south,
418
+ widthKm: north && south ? greatCircleKm(north, south) : null,
419
+ };
420
+ }
421
+ /**
422
+ * Local visibility of a lunar eclipse: a lunar eclipse happens at the same
423
+ * instant for the whole Earth, so "local circumstances" is simply whether the
424
+ * Moon is up. Pass a contact time (e.g. {@link LunarEclipse.tMax} or a phase
425
+ * boundary) to learn whether that phase is visible from a place.
426
+ *
427
+ * @param engine The engine used to evaluate positions.
428
+ * @param jd Instant to evaluate, Julian Day (UT).
429
+ * @param latDeg Observer latitude in degrees (north positive).
430
+ * @param lonEastDeg Observer longitude in degrees (east positive).
431
+ * @returns {@link LunarLocal} with the Moon's altitude and a visibility flag.
432
+ */
433
+ export function lunarEclipseLocal(engine, jd, latDeg, lonEastDeg) {
434
+ const [mlon, mlat] = engine.ecliptic("moon", jdTT(jd));
435
+ const [, altitude] = azAlt(engine.data, mlon / DEG, mlat / DEG, jd, latDeg, lonEastDeg);
436
+ return { altitude, visible: altitude > 0 };
437
+ }
@@ -28,5 +28,11 @@ export * from "./ashtottari.js";
28
28
  export * from "./rajayoga.js";
29
29
  export * from "./patterns.js";
30
30
  export * from "./signature.js";
31
+ export * from "./interpretation.js";
32
+ export * from "./interpret.js";
33
+ export * from "./brief.js";
34
+ export * from "./provenance.js";
35
+ export * from "./anchored.js";
36
+ export * from "./counterfactual.js";
31
37
  export * from "./dignity-score.js";
32
38
  export * from "./parans.js";
package/dist/src/index.js CHANGED
@@ -28,5 +28,11 @@ export * from "./ashtottari.js";
28
28
  export * from "./rajayoga.js";
29
29
  export * from "./patterns.js";
30
30
  export * from "./signature.js";
31
+ export * from "./interpretation.js";
32
+ export * from "./interpret.js";
33
+ export * from "./brief.js";
34
+ export * from "./provenance.js";
35
+ export * from "./anchored.js";
36
+ export * from "./counterfactual.js";
31
37
  export * from "./dignity-score.js";
32
38
  export * from "./parans.js";