caelus 0.7.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 +1 -1
- package/accuracy.json +7 -1
- package/dist/src/electional.d.ts +54 -0
- package/dist/src/electional.js +222 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/scan.d.ts +44 -0
- package/dist/src/scan.js +74 -0
- package/dist/src/turbo.d.ts +20 -0
- package/dist/src/turbo.js +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -100,4 +100,4 @@ test/golden.test.ts conformance suite vs Python fixtures
|
|
|
100
100
|
- caelus — this package
|
|
101
101
|
- [caelus-birth](https://www.npmjs.com/package/caelus-birth) — local birth time + place → UT (charts take UT; use this)
|
|
102
102
|
- [caelus-wheel](https://www.npmjs.com/package/caelus-wheel) — React SVG chart wheel
|
|
103
|
-
- [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server,
|
|
103
|
+
- [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, nine chart tools over stdio
|
package/accuracy.json
CHANGED
|
@@ -269,5 +269,11 @@
|
|
|
269
269
|
}
|
|
270
270
|
],
|
|
271
271
|
"v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode)",
|
|
272
|
-
"true_node_vs_builtin": "1′"
|
|
272
|
+
"true_node_vs_builtin": "1′",
|
|
273
|
+
"counts": {
|
|
274
|
+
"house_systems": 12,
|
|
275
|
+
"sidereal_ayanamsas": 7,
|
|
276
|
+
"mcp_tools": 9,
|
|
277
|
+
"default_bodies": 13
|
|
278
|
+
}
|
|
273
279
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Engine, BodyId, Zodiac } from "./chart.js";
|
|
2
|
+
export declare const CAZIMI_DEG = 0.2833;
|
|
3
|
+
export declare const COMBUST_DEG = 8.5;
|
|
4
|
+
export declare const UNDER_BEAMS_DEG = 15;
|
|
5
|
+
/** Signed shortest angle from b to a, in (-180, 180] degrees. */
|
|
6
|
+
export declare function signedElongation(lonA: number, lonB: number): number;
|
|
7
|
+
/** Unsigned angular separation in [0, 180] degrees. */
|
|
8
|
+
export declare function separation(lonA: number, lonB: number): number;
|
|
9
|
+
export type AspectPhase = "applying" | "separating" | "exact";
|
|
10
|
+
/** Applying/separating/exact for the aspect (degrees) between body a and body b,
|
|
11
|
+
* from their longitudes and longitude speeds (deg/day). Applying = the orb to
|
|
12
|
+
* the exact aspect is closing. */
|
|
13
|
+
export declare function aspectPhase(lonA: number, speedA: number, lonB: number, speedB: number, aspectDeg: number): AspectPhase;
|
|
14
|
+
export interface AspectMatch {
|
|
15
|
+
aspect: string;
|
|
16
|
+
orb: number;
|
|
17
|
+
separation: number;
|
|
18
|
+
phase: AspectPhase;
|
|
19
|
+
}
|
|
20
|
+
/** The tightest major aspect between two bodies at jd, within orb, or null.
|
|
21
|
+
* orb is the signed distance from exact (degrees). */
|
|
22
|
+
export declare function aspectBetween(engine: Engine, bodyA: BodyId, bodyB: BodyId, jdUt: number, zodiac?: Zodiac, orbs?: Record<string, number>): AspectMatch | null;
|
|
23
|
+
export type SolarPhase = "cazimi" | "combust" | "under_beams" | null;
|
|
24
|
+
/** Ecliptic-longitude separation between a body and the Sun (degrees). */
|
|
25
|
+
export declare function solarElongation(engine: Engine, body: BodyId, jdUt: number, zodiac?: Zodiac): number;
|
|
26
|
+
/** cazimi / combust / under_beams / null for a body's nearness to the Sun by
|
|
27
|
+
* ecliptic longitude. The Sun itself returns null. */
|
|
28
|
+
export declare function solarPhase(engine: Engine, body: BodyId, jdUt: number, zodiac?: Zodiac, cazimi?: number, combust?: number, underBeams?: number): SolarPhase;
|
|
29
|
+
export interface PlanetaryHour {
|
|
30
|
+
ruler: string;
|
|
31
|
+
kind: "day" | "night";
|
|
32
|
+
hour: number;
|
|
33
|
+
dayRuler: string;
|
|
34
|
+
start: number;
|
|
35
|
+
end: number;
|
|
36
|
+
}
|
|
37
|
+
/** The planetary hour containing jdUt at a place, or null at latitudes where
|
|
38
|
+
* the Sun does not rise or set on the day in question. */
|
|
39
|
+
export declare function planetaryHour(engine: Engine, jdUt: number, lat: number, lonEast: number): PlanetaryHour | null;
|
|
40
|
+
export interface VoidOfCourse {
|
|
41
|
+
isVoid: boolean;
|
|
42
|
+
sign: string;
|
|
43
|
+
signExit: number;
|
|
44
|
+
nextAspect: number | null;
|
|
45
|
+
}
|
|
46
|
+
/** Void-of-course state of the Moon at jdUt: void from its last perfecting
|
|
47
|
+
* aspect to a traditional planet (Sun..Saturn) until it leaves the sign it
|
|
48
|
+
* occupies at jdUt. */
|
|
49
|
+
export declare function voidOfCourse(engine: Engine, jdUt: number, zodiac?: Zodiac, maxDays?: number): VoidOfCourse;
|
|
50
|
+
/** 1-based house number for an ecliptic longitude (degrees) given the twelve
|
|
51
|
+
* cusps (degrees), wrapping across 0. */
|
|
52
|
+
export declare function houseOf(lon: number, cusps: number[]): number;
|
|
53
|
+
/** angular / succedent / cadent for a 1-based house number. */
|
|
54
|
+
export declare function angularity(house: number): "angular" | "succedent" | "cadent";
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astroengine electional -- electional building blocks on the validated
|
|
3
|
+
* primitives: applying/separating aspects, solar phase (combustion/cazimi),
|
|
4
|
+
* planetary hours, void-of-course Moon, and house placement.
|
|
5
|
+
*
|
|
6
|
+
* Arithmetic and time-mapping on apparent positions already checked against
|
|
7
|
+
* Swiss Ephemeris and JPL Horizons. Mirrors the Python reference
|
|
8
|
+
* (astroengine/electional.py); the golden fixtures pin the two together.
|
|
9
|
+
*/
|
|
10
|
+
import { mod } from "./core.js";
|
|
11
|
+
import { SIGNS, ASPECTS, DEFAULT_ORBS } from "./chart.js";
|
|
12
|
+
import { riseSet } from "./events.js";
|
|
13
|
+
/** Chaldean order for planetary hours (slowest to fastest). */
|
|
14
|
+
const CHALDEAN = ["saturn", "jupiter", "mars", "sun", "venus", "mercury", "moon"];
|
|
15
|
+
/** Weekday ruler, index 0 = Sunday (Meeus day-of-week convention). */
|
|
16
|
+
const DAY_RULERS = ["sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn"];
|
|
17
|
+
export const CAZIMI_DEG = 0.2833; // 17 arcminutes
|
|
18
|
+
export const COMBUST_DEG = 8.5;
|
|
19
|
+
export const UNDER_BEAMS_DEG = 15.0;
|
|
20
|
+
// Local copy of the events.ts bisection (identical), to keep the same roots.
|
|
21
|
+
function bisect(f, a, b, iters = 45) {
|
|
22
|
+
let fa = f(a);
|
|
23
|
+
for (let i = 0; i < iters; i++) {
|
|
24
|
+
const m = (a + b) / 2;
|
|
25
|
+
if (fa * f(m) <= 0) {
|
|
26
|
+
b = m;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
a = m;
|
|
30
|
+
fa = f(a);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return (a + b) / 2;
|
|
34
|
+
}
|
|
35
|
+
function wrap180(x) {
|
|
36
|
+
return mod(x + 180.0, 360.0) - 180.0;
|
|
37
|
+
}
|
|
38
|
+
/** Signed shortest angle from b to a, in (-180, 180] degrees. */
|
|
39
|
+
export function signedElongation(lonA, lonB) {
|
|
40
|
+
return wrap180(lonA - lonB);
|
|
41
|
+
}
|
|
42
|
+
/** Unsigned angular separation in [0, 180] degrees. */
|
|
43
|
+
export function separation(lonA, lonB) {
|
|
44
|
+
return Math.abs(wrap180(lonA - lonB));
|
|
45
|
+
}
|
|
46
|
+
/** Applying/separating/exact for the aspect (degrees) between body a and body b,
|
|
47
|
+
* from their longitudes and longitude speeds (deg/day). Applying = the orb to
|
|
48
|
+
* the exact aspect is closing. */
|
|
49
|
+
export function aspectPhase(lonA, speedA, lonB, speedB, aspectDeg) {
|
|
50
|
+
const e = wrap180(lonA - lonB);
|
|
51
|
+
const sep = Math.abs(e);
|
|
52
|
+
const dsepDt = (e >= 0.0 ? 1.0 : -1.0) * (speedA - speedB);
|
|
53
|
+
const orb = sep - aspectDeg;
|
|
54
|
+
if (Math.abs(orb) < 1e-9)
|
|
55
|
+
return "exact";
|
|
56
|
+
const dAbsOrbDt = (orb >= 0.0 ? 1.0 : -1.0) * dsepDt;
|
|
57
|
+
return dAbsOrbDt < 0.0 ? "applying" : "separating";
|
|
58
|
+
}
|
|
59
|
+
/** The tightest major aspect between two bodies at jd, within orb, or null.
|
|
60
|
+
* orb is the signed distance from exact (degrees). */
|
|
61
|
+
export function aspectBetween(engine, bodyA, bodyB, jdUt, zodiac = "tropical", orbs = DEFAULT_ORBS) {
|
|
62
|
+
const pa = engine.position(bodyA, jdUt, { zodiac });
|
|
63
|
+
const pb = engine.position(bodyB, jdUt, { zodiac });
|
|
64
|
+
const sep = separation(pa.lon, pb.lon);
|
|
65
|
+
let best = null;
|
|
66
|
+
for (const [name, deg] of Object.entries(ASPECTS)) {
|
|
67
|
+
const orb = sep - deg;
|
|
68
|
+
if (Math.abs(orb) <= (orbs[name] ?? 0.0)) {
|
|
69
|
+
if (best === null || Math.abs(orb) < Math.abs(best[1]))
|
|
70
|
+
best = [name, orb];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (best === null)
|
|
74
|
+
return null;
|
|
75
|
+
const [name, orb] = best;
|
|
76
|
+
return {
|
|
77
|
+
aspect: name,
|
|
78
|
+
orb,
|
|
79
|
+
separation: sep,
|
|
80
|
+
phase: aspectPhase(pa.lon, pa.speed, pb.lon, pb.speed, ASPECTS[name]),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Ecliptic-longitude separation between a body and the Sun (degrees). */
|
|
84
|
+
export function solarElongation(engine, body, jdUt, zodiac = "tropical") {
|
|
85
|
+
return separation(engine.longitude(body, jdUt, { zodiac }), engine.longitude("sun", jdUt, { zodiac }));
|
|
86
|
+
}
|
|
87
|
+
/** cazimi / combust / under_beams / null for a body's nearness to the Sun by
|
|
88
|
+
* ecliptic longitude. The Sun itself returns null. */
|
|
89
|
+
export function solarPhase(engine, body, jdUt, zodiac = "tropical", cazimi = CAZIMI_DEG, combust = COMBUST_DEG, underBeams = UNDER_BEAMS_DEG) {
|
|
90
|
+
if (body === "sun")
|
|
91
|
+
return null;
|
|
92
|
+
const sep = solarElongation(engine, body, jdUt, zodiac);
|
|
93
|
+
if (sep <= cazimi)
|
|
94
|
+
return "cazimi";
|
|
95
|
+
if (sep <= combust)
|
|
96
|
+
return "combust";
|
|
97
|
+
if (sep <= underBeams)
|
|
98
|
+
return "under_beams";
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/** The planetary hour containing jdUt at a place, or null at latitudes where
|
|
102
|
+
* the Sun does not rise or set on the day in question. */
|
|
103
|
+
export function planetaryHour(engine, jdUt, lat, lonEast) {
|
|
104
|
+
let sr = riseSet(engine, "sun", jdUt - 1.0, lat, lonEast, "rise");
|
|
105
|
+
if (sr === null)
|
|
106
|
+
return null;
|
|
107
|
+
let nxt = riseSet(engine, "sun", sr + 0.01, lat, lonEast, "rise");
|
|
108
|
+
while (nxt !== null && nxt <= jdUt) {
|
|
109
|
+
sr = nxt;
|
|
110
|
+
nxt = riseSet(engine, "sun", sr + 0.01, lat, lonEast, "rise");
|
|
111
|
+
}
|
|
112
|
+
if (sr > jdUt)
|
|
113
|
+
return null;
|
|
114
|
+
const dayStart = sr;
|
|
115
|
+
const dayEnd = riseSet(engine, "sun", dayStart + 0.01, lat, lonEast, "set");
|
|
116
|
+
if (dayEnd === null)
|
|
117
|
+
return null;
|
|
118
|
+
const nightEnd = riseSet(engine, "sun", dayEnd + 0.01, lat, lonEast, "rise");
|
|
119
|
+
if (nightEnd === null)
|
|
120
|
+
return null;
|
|
121
|
+
let span;
|
|
122
|
+
let kind;
|
|
123
|
+
let hourNumber;
|
|
124
|
+
let start;
|
|
125
|
+
if (jdUt < dayEnd) {
|
|
126
|
+
span = (dayEnd - dayStart) / 12.0;
|
|
127
|
+
const idx = Math.min(Math.floor((jdUt - dayStart) / span), 11);
|
|
128
|
+
kind = "day";
|
|
129
|
+
hourNumber = idx;
|
|
130
|
+
start = dayStart + idx * span;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
span = (nightEnd - dayEnd) / 12.0;
|
|
134
|
+
const idx = Math.min(Math.floor((jdUt - dayEnd) / span), 11);
|
|
135
|
+
kind = "night";
|
|
136
|
+
hourNumber = 12 + idx;
|
|
137
|
+
start = dayEnd + idx * span;
|
|
138
|
+
}
|
|
139
|
+
const weekday = Math.floor(dayStart + 1.5) % 7; // 0 = Sunday
|
|
140
|
+
const dayRuler = DAY_RULERS[weekday];
|
|
141
|
+
const ruler = CHALDEAN[(CHALDEAN.indexOf(dayRuler) + hourNumber) % 7];
|
|
142
|
+
return { ruler, kind, hour: hourNumber + 1, dayRuler, start, end: start + span };
|
|
143
|
+
}
|
|
144
|
+
// --------------------------------------------------------- void-of-course Moon
|
|
145
|
+
function perfections(engine, bodyA, bodyB, aspectDeg, jdStart, jdEnd, zodiac, step) {
|
|
146
|
+
const roots = [];
|
|
147
|
+
const orientations = aspectDeg !== 0.0 && aspectDeg !== 180.0 ? [1, -1] : [1];
|
|
148
|
+
for (const orient of orientations) {
|
|
149
|
+
const f = (t) => {
|
|
150
|
+
const la = engine.longitude(bodyA, t, { zodiac });
|
|
151
|
+
const lb = engine.longitude(bodyB, t, { zodiac });
|
|
152
|
+
return mod(la - lb - orient * aspectDeg + 180.0, 360.0) - 180.0;
|
|
153
|
+
};
|
|
154
|
+
let prev = f(jdStart);
|
|
155
|
+
for (let t = jdStart + step; t <= jdEnd; t += step) {
|
|
156
|
+
const cur = f(t);
|
|
157
|
+
if (prev * cur < 0.0 && Math.abs(cur - prev) < 180.0) {
|
|
158
|
+
roots.push(bisect(f, t - step, t));
|
|
159
|
+
}
|
|
160
|
+
prev = cur;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
roots.sort((a, b) => a - b);
|
|
164
|
+
return roots;
|
|
165
|
+
}
|
|
166
|
+
/** Void-of-course state of the Moon at jdUt: void from its last perfecting
|
|
167
|
+
* aspect to a traditional planet (Sun..Saturn) until it leaves the sign it
|
|
168
|
+
* occupies at jdUt. */
|
|
169
|
+
export function voidOfCourse(engine, jdUt, zodiac = "tropical", maxDays = 14.0) {
|
|
170
|
+
const moon = engine.longitude("moon", jdUt, { zodiac });
|
|
171
|
+
const sign = mod(Math.floor(moon / 30), 12);
|
|
172
|
+
const boundary = mod((sign + 1) * 30.0, 360.0);
|
|
173
|
+
const edge = (t) => mod(engine.longitude("moon", t, { zodiac }) - boundary + 180.0, 360.0) - 180.0;
|
|
174
|
+
let signExit = null;
|
|
175
|
+
const step = 0.125;
|
|
176
|
+
let prev = edge(jdUt);
|
|
177
|
+
for (let t = jdUt + step; t <= jdUt + maxDays; t += step) {
|
|
178
|
+
const cur = edge(t);
|
|
179
|
+
if (prev * cur < 0.0 && Math.abs(cur - prev) < 180.0) {
|
|
180
|
+
signExit = bisect(edge, t - step, t);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
prev = cur;
|
|
184
|
+
}
|
|
185
|
+
if (signExit === null)
|
|
186
|
+
signExit = jdUt + maxDays;
|
|
187
|
+
let nextAspect = null;
|
|
188
|
+
for (const planet of ["sun", "mercury", "venus", "mars", "jupiter", "saturn"]) {
|
|
189
|
+
for (const deg of Object.values(ASPECTS)) {
|
|
190
|
+
for (const jd of perfections(engine, "moon", planet, deg, jdUt, signExit, zodiac, 0.125)) {
|
|
191
|
+
if (jd > jdUt && (nextAspect === null || jd < nextAspect))
|
|
192
|
+
nextAspect = jd;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
isVoid: nextAspect === null,
|
|
198
|
+
sign: SIGNS[sign],
|
|
199
|
+
signExit,
|
|
200
|
+
nextAspect,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
// -------------------------------------------------------------- house helpers
|
|
204
|
+
/** 1-based house number for an ecliptic longitude (degrees) given the twelve
|
|
205
|
+
* cusps (degrees), wrapping across 0. */
|
|
206
|
+
export function houseOf(lon, cusps) {
|
|
207
|
+
lon = mod(lon, 360.0);
|
|
208
|
+
for (let i = 0; i < 12; i++) {
|
|
209
|
+
const a = mod(cusps[i], 360.0);
|
|
210
|
+
const b = mod(cusps[(i + 1) % 12], 360.0);
|
|
211
|
+
const span = mod(b - a, 360.0);
|
|
212
|
+
if (span === 0.0)
|
|
213
|
+
continue;
|
|
214
|
+
if (mod(lon - a, 360.0) < span)
|
|
215
|
+
return i + 1;
|
|
216
|
+
}
|
|
217
|
+
return 12;
|
|
218
|
+
}
|
|
219
|
+
/** angular / succedent / cadent for a 1-based house number. */
|
|
220
|
+
export function angularity(house) {
|
|
221
|
+
return ["angular", "succedent", "cadent"][(house - 1) % 3];
|
|
222
|
+
}
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astroengine scan -- long-scan ergonomics over the engine: a batched scan with
|
|
3
|
+
* progress, and rankMoments to find the best instants by a score, synchronously
|
|
4
|
+
* or without blocking the event loop.
|
|
5
|
+
*
|
|
6
|
+
* This is control flow over the validated primitives, not new ephemeris. The
|
|
7
|
+
* score function is yours: compose it from positions, aspects, or the electional
|
|
8
|
+
* state. scan imposes no scoring model. Because the core engine does no I/O,
|
|
9
|
+
* these helpers (and the engine calls inside your score) run unchanged inside a
|
|
10
|
+
* Web Worker; rankMomentsAsync additionally yields to the event loop so a
|
|
11
|
+
* main-thread scan of hundreds of charts keeps the UI responsive.
|
|
12
|
+
*/
|
|
13
|
+
export interface ScanOptions {
|
|
14
|
+
/** First instant, UT Julian Day (inclusive). */
|
|
15
|
+
start: number;
|
|
16
|
+
/** Last instant, UT Julian Day (inclusive). */
|
|
17
|
+
end: number;
|
|
18
|
+
/** Spacing between samples, days. Must be positive. */
|
|
19
|
+
step: number;
|
|
20
|
+
/** Called with (done, total) sample counts during the scan. */
|
|
21
|
+
onProgress?: (done: number, total: number) => void;
|
|
22
|
+
/** Samples between progress callbacks (default 256). */
|
|
23
|
+
progressEvery?: number;
|
|
24
|
+
}
|
|
25
|
+
/** Number of samples a scan of these options visits. */
|
|
26
|
+
export declare function sampleCount(start: number, end: number, step: number): number;
|
|
27
|
+
/** Evaluate fn at each sampled instant in [start, end], returning the results in
|
|
28
|
+
* time order. */
|
|
29
|
+
export declare function scan<T>(opts: ScanOptions, fn: (jd: number) => T): T[];
|
|
30
|
+
export interface RankOptions extends ScanOptions {
|
|
31
|
+
/** Keep only the top N moments (default: all). */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** Drop moments scoring below this (default: keep all). */
|
|
34
|
+
minScore?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface RankedMoment {
|
|
37
|
+
jd: number;
|
|
38
|
+
score: number;
|
|
39
|
+
}
|
|
40
|
+
/** Score every sampled instant and return the best, highest score first. */
|
|
41
|
+
export declare function rankMoments(opts: RankOptions, score: (jd: number) => number): RankedMoment[];
|
|
42
|
+
/** rankMoments that yields to the event loop every `chunk` samples, so a long
|
|
43
|
+
* main-thread scan does not freeze the UI. Same result as rankMoments. */
|
|
44
|
+
export declare function rankMomentsAsync(opts: RankOptions, score: (jd: number) => number, chunk?: number): Promise<RankedMoment[]>;
|
package/dist/src/scan.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astroengine scan -- long-scan ergonomics over the engine: a batched scan with
|
|
3
|
+
* progress, and rankMoments to find the best instants by a score, synchronously
|
|
4
|
+
* or without blocking the event loop.
|
|
5
|
+
*
|
|
6
|
+
* This is control flow over the validated primitives, not new ephemeris. The
|
|
7
|
+
* score function is yours: compose it from positions, aspects, or the electional
|
|
8
|
+
* state. scan imposes no scoring model. Because the core engine does no I/O,
|
|
9
|
+
* these helpers (and the engine calls inside your score) run unchanged inside a
|
|
10
|
+
* Web Worker; rankMomentsAsync additionally yields to the event loop so a
|
|
11
|
+
* main-thread scan of hundreds of charts keeps the UI responsive.
|
|
12
|
+
*/
|
|
13
|
+
/** Number of samples a scan of these options visits. */
|
|
14
|
+
export function sampleCount(start, end, step) {
|
|
15
|
+
if (step <= 0)
|
|
16
|
+
throw new Error("scan step must be positive");
|
|
17
|
+
if (end < start)
|
|
18
|
+
return 0;
|
|
19
|
+
return Math.floor((end - start) / step + 1e-9) + 1;
|
|
20
|
+
}
|
|
21
|
+
/** Evaluate fn at each sampled instant in [start, end], returning the results in
|
|
22
|
+
* time order. */
|
|
23
|
+
export function scan(opts, fn) {
|
|
24
|
+
const total = sampleCount(opts.start, opts.end, opts.step);
|
|
25
|
+
const every = opts.progressEvery ?? 256;
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = 0; i < total; i++) {
|
|
28
|
+
out.push(fn(opts.start + i * opts.step));
|
|
29
|
+
if (opts.onProgress && (i + 1) % every === 0)
|
|
30
|
+
opts.onProgress(i + 1, total);
|
|
31
|
+
}
|
|
32
|
+
if (opts.onProgress && total > 0)
|
|
33
|
+
opts.onProgress(total, total);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function rank(moments, limit) {
|
|
37
|
+
// Highest score first; ties broken by earliest instant, so the order is
|
|
38
|
+
// deterministic and identical between the sync and async paths.
|
|
39
|
+
moments.sort((a, b) => b.score - a.score || a.jd - b.jd);
|
|
40
|
+
return limit === Infinity ? moments : moments.slice(0, limit);
|
|
41
|
+
}
|
|
42
|
+
/** Score every sampled instant and return the best, highest score first. */
|
|
43
|
+
export function rankMoments(opts, score) {
|
|
44
|
+
const minScore = opts.minScore ?? -Infinity;
|
|
45
|
+
const moments = [];
|
|
46
|
+
scan(opts, (jd) => {
|
|
47
|
+
const s = score(jd);
|
|
48
|
+
if (s >= minScore)
|
|
49
|
+
moments.push({ jd, score: s });
|
|
50
|
+
return s;
|
|
51
|
+
});
|
|
52
|
+
return rank(moments, opts.limit ?? Infinity);
|
|
53
|
+
}
|
|
54
|
+
/** rankMoments that yields to the event loop every `chunk` samples, so a long
|
|
55
|
+
* main-thread scan does not freeze the UI. Same result as rankMoments. */
|
|
56
|
+
export async function rankMomentsAsync(opts, score, chunk = 256) {
|
|
57
|
+
const minScore = opts.minScore ?? -Infinity;
|
|
58
|
+
const total = sampleCount(opts.start, opts.end, opts.step);
|
|
59
|
+
const moments = [];
|
|
60
|
+
for (let i = 0; i < total; i++) {
|
|
61
|
+
const jd = opts.start + i * opts.step;
|
|
62
|
+
const s = score(jd);
|
|
63
|
+
if (s >= minScore)
|
|
64
|
+
moments.push({ jd, score: s });
|
|
65
|
+
if ((i + 1) % chunk === 0) {
|
|
66
|
+
if (opts.onProgress)
|
|
67
|
+
opts.onProgress(i + 1, total);
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (opts.onProgress && total > 0)
|
|
72
|
+
opts.onProgress(total, total);
|
|
73
|
+
return rank(moments, opts.limit ?? Infinity);
|
|
74
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface TurboBody {
|
|
2
|
+
seg_days: number;
|
|
3
|
+
segments: number[][];
|
|
4
|
+
}
|
|
5
|
+
export interface TurboPack {
|
|
6
|
+
jd0: number;
|
|
7
|
+
jd1: number;
|
|
8
|
+
degree: number;
|
|
9
|
+
zodiac: string;
|
|
10
|
+
bodies: Record<string, TurboBody>;
|
|
11
|
+
}
|
|
12
|
+
export declare class Turbo {
|
|
13
|
+
readonly jd0: number;
|
|
14
|
+
readonly jd1: number;
|
|
15
|
+
private readonly bodies;
|
|
16
|
+
constructor(pack: TurboPack);
|
|
17
|
+
has(body: string): boolean;
|
|
18
|
+
/** Apparent ecliptic longitude (degrees) from the turbo pack. */
|
|
19
|
+
longitude(body: string, jd: number): number;
|
|
20
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astroengine turbo -- fast longitude evaluator for a turbo pack.
|
|
3
|
+
*
|
|
4
|
+
* A turbo pack is a segmented Chebyshev representation of the engine's apparent
|
|
5
|
+
* longitude, fit to the engine itself (see python/astroengine/turbo.py and
|
|
6
|
+
* fit_turbo.py). Evaluating a longitude is a couple of dozen multiply-adds, so
|
|
7
|
+
* a century-scale transit scan that calls it tens of thousands of times runs in
|
|
8
|
+
* milliseconds. The pack is data you mint for your range and bodies; this is
|
|
9
|
+
* the runtime-only evaluator (no fitting, no engine, no I/O).
|
|
10
|
+
*
|
|
11
|
+
* The evaluator mirrors the Python reference exactly, so both reproduce the
|
|
12
|
+
* pack bit-identically.
|
|
13
|
+
*/
|
|
14
|
+
import { mod } from "./core.js";
|
|
15
|
+
function clenshaw(coeffs, x) {
|
|
16
|
+
let b0 = 0;
|
|
17
|
+
let b1 = 0;
|
|
18
|
+
for (let i = coeffs.length - 1; i >= 1; i--) {
|
|
19
|
+
const t = 2 * x * b0 - b1 + coeffs[i];
|
|
20
|
+
b1 = b0;
|
|
21
|
+
b0 = t;
|
|
22
|
+
}
|
|
23
|
+
return x * b0 - b1 + coeffs[0];
|
|
24
|
+
}
|
|
25
|
+
export class Turbo {
|
|
26
|
+
jd0;
|
|
27
|
+
jd1;
|
|
28
|
+
bodies;
|
|
29
|
+
constructor(pack) {
|
|
30
|
+
this.jd0 = pack.jd0;
|
|
31
|
+
this.jd1 = pack.jd1;
|
|
32
|
+
this.bodies = pack.bodies;
|
|
33
|
+
}
|
|
34
|
+
has(body) {
|
|
35
|
+
return body in this.bodies;
|
|
36
|
+
}
|
|
37
|
+
/** Apparent ecliptic longitude (degrees) from the turbo pack. */
|
|
38
|
+
longitude(body, jd) {
|
|
39
|
+
const b = this.bodies[body];
|
|
40
|
+
if (!b)
|
|
41
|
+
throw new Error(`turbo: no pack for ${body}`);
|
|
42
|
+
if (jd < this.jd0 || jd > this.jd1) {
|
|
43
|
+
throw new Error(`jd ${jd} outside turbo range ${this.jd0}-${this.jd1}`);
|
|
44
|
+
}
|
|
45
|
+
const seg = b.seg_days;
|
|
46
|
+
const i = Math.min(Math.floor((jd - this.jd0) / seg), b.segments.length - 1);
|
|
47
|
+
const x = 2 * (jd - (this.jd0 + i * seg)) / seg - 1;
|
|
48
|
+
return mod(clenshaw(b.segments[i], x), 360);
|
|
49
|
+
}
|
|
50
|
+
}
|