ether-to-astro 1.0.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/.env.example +13 -0
- package/.github/pull_request_template.md +16 -0
- package/.github/workflows/release.yml +35 -0
- package/.github/workflows/test.yml +32 -0
- package/AGENTS.md +99 -0
- package/LICENSE +18 -0
- package/NOTICE.md +45 -0
- package/README.md +301 -0
- package/SETUP.md +70 -0
- package/TESTING_SUMMARY.md +238 -0
- package/TEST_SUITE_STATUS.md +218 -0
- package/biome.json +48 -0
- package/dist/astro-service.d.ts +98 -0
- package/dist/astro-service.js +496 -0
- package/dist/chart-types.d.ts +52 -0
- package/dist/chart-types.js +51 -0
- package/dist/charts.d.ts +125 -0
- package/dist/charts.js +324 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +472 -0
- package/dist/constants.d.ts +81 -0
- package/dist/constants.js +76 -0
- package/dist/eclipses.d.ts +85 -0
- package/dist/eclipses.js +184 -0
- package/dist/ephemeris.d.ts +120 -0
- package/dist/ephemeris.js +379 -0
- package/dist/formatter.d.ts +2 -0
- package/dist/formatter.js +22 -0
- package/dist/houses.d.ts +82 -0
- package/dist/houses.js +169 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +150 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +31 -0
- package/dist/logger.d.ts +25 -0
- package/dist/logger.js +73 -0
- package/dist/profile-store.d.ts +48 -0
- package/dist/profile-store.js +156 -0
- package/dist/riseset.d.ts +82 -0
- package/dist/riseset.js +185 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +40 -0
- package/dist/time-utils.d.ts +68 -0
- package/dist/time-utils.js +136 -0
- package/dist/tool-registry.d.ts +35 -0
- package/dist/tool-registry.js +307 -0
- package/dist/tool-result.d.ts +175 -0
- package/dist/tool-result.js +188 -0
- package/dist/transits.d.ts +108 -0
- package/dist/transits.js +263 -0
- package/dist/types.d.ts +450 -0
- package/dist/types.js +161 -0
- package/example-usage.md +131 -0
- package/natal-chart.json +187 -0
- package/package.json +61 -0
- package/scripts/download-ephemeris.js +115 -0
- package/setup.sh +21 -0
- package/src/astro-service.ts +710 -0
- package/src/chart-types.ts +125 -0
- package/src/charts.ts +399 -0
- package/src/cli.ts +694 -0
- package/src/constants.ts +89 -0
- package/src/eclipses.ts +226 -0
- package/src/ephemeris.ts +437 -0
- package/src/formatter.ts +25 -0
- package/src/houses.ts +202 -0
- package/src/index.ts +170 -0
- package/src/loader.ts +36 -0
- package/src/logger.ts +104 -0
- package/src/profile-store.ts +285 -0
- package/src/riseset.ts +229 -0
- package/src/time-utils.ts +167 -0
- package/src/tool-registry.ts +357 -0
- package/src/tool-result.ts +283 -0
- package/src/transits.ts +352 -0
- package/src/types.ts +547 -0
- package/tests/README.md +173 -0
- package/tests/TESTING_STRATEGY.md +178 -0
- package/tests/fixtures/bowen-yang-chart.ts +69 -0
- package/tests/fixtures/calculate-expected.ts +81 -0
- package/tests/fixtures/expected-results.ts +117 -0
- package/tests/fixtures/generate-expected-simple.ts +94 -0
- package/tests/helpers/date-fixtures.ts +15 -0
- package/tests/helpers/ephem.ts +11 -0
- package/tests/helpers/temp.ts +9 -0
- package/tests/setup.ts +11 -0
- package/tests/unit/astro-service.test.ts +323 -0
- package/tests/unit/chart-types.test.ts +18 -0
- package/tests/unit/charts-errors.test.ts +42 -0
- package/tests/unit/charts.test.ts +157 -0
- package/tests/unit/cli-commands.test.ts +82 -0
- package/tests/unit/cli-profiles.test.ts +128 -0
- package/tests/unit/cli.test.ts +191 -0
- package/tests/unit/constants.test.ts +26 -0
- package/tests/unit/correctness-critical.test.ts +408 -0
- package/tests/unit/eclipses.test.ts +108 -0
- package/tests/unit/ephemeris.test.ts +213 -0
- package/tests/unit/error-handling.test.ts +116 -0
- package/tests/unit/formatter.test.ts +29 -0
- package/tests/unit/houses-errors.test.ts +27 -0
- package/tests/unit/houses-validation.test.ts +164 -0
- package/tests/unit/houses.test.ts +205 -0
- package/tests/unit/profile-store.test.ts +163 -0
- package/tests/unit/real-user-charts.test.ts +148 -0
- package/tests/unit/riseset.test.ts +106 -0
- package/tests/unit/solver-edges.test.ts +197 -0
- package/tests/unit/time-utils-temporal.test.ts +303 -0
- package/tests/unit/time-utils.test.ts +173 -0
- package/tests/unit/tool-registry.test.ts +222 -0
- package/tests/unit/tool-result.test.ts +45 -0
- package/tests/unit/transit-correctness.test.ts +78 -0
- package/tests/unit/transits.test.ts +238 -0
- package/tests/validation/README.md +32 -0
- package/tests/validation/adapters/astrolog.ts +306 -0
- package/tests/validation/adapters/internal.ts +184 -0
- package/tests/validation/compare/eclipses.ts +47 -0
- package/tests/validation/compare/houses.ts +76 -0
- package/tests/validation/compare/positions.ts +104 -0
- package/tests/validation/compare/riseSet.ts +48 -0
- package/tests/validation/compare/roots.ts +90 -0
- package/tests/validation/compare/transits.ts +69 -0
- package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
- package/tests/validation/fixtures/eclipses/core.ts +14 -0
- package/tests/validation/fixtures/houses/core.ts +47 -0
- package/tests/validation/fixtures/positions/core.ts +159 -0
- package/tests/validation/fixtures/rise-set/core.ts +20 -0
- package/tests/validation/fixtures/roots/core.ts +47 -0
- package/tests/validation/fixtures/transits/core.ts +61 -0
- package/tests/validation/fixtures/transits/dst.ts +21 -0
- package/tests/validation/oracle.spec.ts +129 -0
- package/tests/validation/utils/denseRootOracle.ts +269 -0
- package/tests/validation/utils/fixtureTypes.ts +146 -0
- package/tests/validation/utils/report.ts +60 -0
- package/tests/validation/utils/tolerances.ts +23 -0
- package/tests/validation/validation.spec.ts +836 -0
- package/tools/color-picker.html +388 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +31 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import type { NormalizedBody, NormalizedHouseResult } from '../utils/fixtureTypes.js';
|
|
3
|
+
|
|
4
|
+
const BODY_NAMES = [
|
|
5
|
+
'Sun',
|
|
6
|
+
'Moon',
|
|
7
|
+
'Mercury',
|
|
8
|
+
'Venus',
|
|
9
|
+
'Mars',
|
|
10
|
+
'Jupiter',
|
|
11
|
+
'Saturn',
|
|
12
|
+
'Uranus',
|
|
13
|
+
'Neptune',
|
|
14
|
+
'Pluto',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export interface AstrologProbe {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
available: boolean;
|
|
20
|
+
bin: string;
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AstrologRunOptions {
|
|
25
|
+
longitude?: number;
|
|
26
|
+
latitude?: number;
|
|
27
|
+
houseSystem?: 'P' | 'W';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function probeAstrolog(): AstrologProbe {
|
|
31
|
+
const enabled = process.env.VALIDATE_WITH_ASTROLOG === '1';
|
|
32
|
+
const bin = process.env.ASTROLOG_BIN || 'astrolog';
|
|
33
|
+
|
|
34
|
+
if (!enabled) {
|
|
35
|
+
return {
|
|
36
|
+
enabled: false,
|
|
37
|
+
available: false,
|
|
38
|
+
bin,
|
|
39
|
+
reason: 'VALIDATE_WITH_ASTROLOG is not enabled',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const probe = spawnSync(bin, ['-Hc'], { encoding: 'utf8' });
|
|
44
|
+
const probeText = `${probe.stdout || ''}\n${probe.stderr || ''}`;
|
|
45
|
+
if (probe.error || (probe.status !== 0 && !/Astrolog\s+version/i.test(probeText))) {
|
|
46
|
+
return {
|
|
47
|
+
enabled,
|
|
48
|
+
available: false,
|
|
49
|
+
bin,
|
|
50
|
+
reason: probe.error?.message || probe.stderr || `Exit ${probe.status}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { enabled, available: true, bin };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parsePositionsFromStdout(stdout: string): NormalizedBody[] {
|
|
58
|
+
const lines = stdout.split(/\r?\n/);
|
|
59
|
+
const parsed: NormalizedBody[] = [];
|
|
60
|
+
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
// Common Astrolog table line format:
|
|
63
|
+
// "Sun : 6Ari38 ..."
|
|
64
|
+
const tableMatch = line.match(
|
|
65
|
+
/^(Sun|Moon|Merc|Venu|Mars|Jupi|Satu|Uran|Nept|Plut)\s*:\s*([0-9]{1,2})([A-Za-z]{3})([0-9]{1,2})\s*(R?)\s*[+-]/i
|
|
66
|
+
);
|
|
67
|
+
if (tableMatch) {
|
|
68
|
+
const bodyMap: Record<string, string> = {
|
|
69
|
+
Sun: 'Sun',
|
|
70
|
+
Moon: 'Moon',
|
|
71
|
+
Merc: 'Mercury',
|
|
72
|
+
Venu: 'Venus',
|
|
73
|
+
Mars: 'Mars',
|
|
74
|
+
Jupi: 'Jupiter',
|
|
75
|
+
Satu: 'Saturn',
|
|
76
|
+
Uran: 'Uranus',
|
|
77
|
+
Nept: 'Neptune',
|
|
78
|
+
Plut: 'Pluto',
|
|
79
|
+
};
|
|
80
|
+
const signOrder = [
|
|
81
|
+
'Ari',
|
|
82
|
+
'Tau',
|
|
83
|
+
'Gem',
|
|
84
|
+
'Can',
|
|
85
|
+
'Leo',
|
|
86
|
+
'Vir',
|
|
87
|
+
'Lib',
|
|
88
|
+
'Sco',
|
|
89
|
+
'Sag',
|
|
90
|
+
'Cap',
|
|
91
|
+
'Aqu',
|
|
92
|
+
'Pis',
|
|
93
|
+
];
|
|
94
|
+
const body = bodyMap[tableMatch[1]];
|
|
95
|
+
const degree = Number(tableMatch[2]);
|
|
96
|
+
const signIndex = signOrder.indexOf(tableMatch[3]);
|
|
97
|
+
const minutes = Number(tableMatch[4]);
|
|
98
|
+
if (body && signIndex >= 0) {
|
|
99
|
+
parsed.push({
|
|
100
|
+
body,
|
|
101
|
+
longitude: signIndex * 30 + degree + minutes / 60,
|
|
102
|
+
retrograde: tableMatch[5] === 'R',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Example-ish line formats across Astrolog builds often include:
|
|
109
|
+
// "Sun 6Ar19 ..." or "Sun 6.32"
|
|
110
|
+
const plainMatch = line.match(
|
|
111
|
+
/^(Sun|Moon|Mercury|Venus|Mars|Jupiter|Saturn|Uranus|Neptune|Pluto)\s+([0-9]+(?:\.[0-9]+)?)/i
|
|
112
|
+
);
|
|
113
|
+
if (plainMatch) {
|
|
114
|
+
parsed.push({ body: plainMatch[1], longitude: Number(plainMatch[2]) });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const zodiacMatch = line.match(
|
|
119
|
+
/^(Sun|Moon|Mercury|Venus|Mars|Jupiter|Saturn|Uranus|Neptune|Pluto)\s+([0-9]{1,2})([A-Za-z]{2})([0-9]{1,2})/i
|
|
120
|
+
);
|
|
121
|
+
if (zodiacMatch) {
|
|
122
|
+
const signOrder = ['Ar', 'Ta', 'Ge', 'Cn', 'Le', 'Vi', 'Li', 'Sc', 'Sg', 'Cp', 'Aq', 'Pi'];
|
|
123
|
+
const degree = Number(zodiacMatch[2]);
|
|
124
|
+
const sign = zodiacMatch[3];
|
|
125
|
+
const minutes = Number(zodiacMatch[4]);
|
|
126
|
+
const signIndex = signOrder.indexOf(sign);
|
|
127
|
+
if (signIndex >= 0) {
|
|
128
|
+
parsed.push({
|
|
129
|
+
body: zodiacMatch[1],
|
|
130
|
+
longitude: signIndex * 30 + degree + minutes / 60,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Keep only one row per body.
|
|
137
|
+
const byBody = new Map<string, NormalizedBody>();
|
|
138
|
+
for (const row of parsed) {
|
|
139
|
+
if (!byBody.has(row.body)) byBody.set(row.body, row);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return BODY_NAMES.map((body) => byBody.get(body)).filter((row): row is NormalizedBody =>
|
|
143
|
+
Boolean(row)
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function toAstrologCoord(
|
|
148
|
+
value: number,
|
|
149
|
+
positiveHemisphere: string,
|
|
150
|
+
negativeHemisphere: string
|
|
151
|
+
): string {
|
|
152
|
+
const abs = Math.abs(value);
|
|
153
|
+
let degrees = Math.floor(abs);
|
|
154
|
+
let minutes = Math.round((abs - degrees) * 60);
|
|
155
|
+
if (minutes === 60) {
|
|
156
|
+
degrees += 1;
|
|
157
|
+
minutes = 0;
|
|
158
|
+
}
|
|
159
|
+
const hemisphere = value >= 0 ? positiveHemisphere : negativeHemisphere;
|
|
160
|
+
return `${degrees}${hemisphere}${String(minutes).padStart(2, '0')}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function runAstrologChart(
|
|
164
|
+
isoUtc: string,
|
|
165
|
+
probe: AstrologProbe,
|
|
166
|
+
options: AstrologRunOptions = {}
|
|
167
|
+
): { ok: boolean; stdout?: string; reason?: string } {
|
|
168
|
+
if (!probe.enabled || !probe.available) {
|
|
169
|
+
return { ok: false, reason: probe.reason ?? 'Astrolog not available' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const d = new Date(isoUtc);
|
|
173
|
+
const month = d.getUTCMonth() + 1;
|
|
174
|
+
const day = d.getUTCDate();
|
|
175
|
+
const year = d.getUTCFullYear();
|
|
176
|
+
const hour = String(d.getUTCHours()).padStart(2, '0');
|
|
177
|
+
const minute = String(d.getUTCMinutes()).padStart(2, '0');
|
|
178
|
+
const longitude = options.longitude ?? 0;
|
|
179
|
+
const latitude = options.latitude ?? 0;
|
|
180
|
+
const lonToken = toAstrologCoord(longitude, 'E', 'W');
|
|
181
|
+
const latToken = toAstrologCoord(latitude, 'N', 'S');
|
|
182
|
+
|
|
183
|
+
const args = [
|
|
184
|
+
'-qa',
|
|
185
|
+
String(month),
|
|
186
|
+
String(day),
|
|
187
|
+
String(year),
|
|
188
|
+
`${hour}:${minute}`,
|
|
189
|
+
'0',
|
|
190
|
+
lonToken,
|
|
191
|
+
latToken,
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
if (options.houseSystem === 'W') {
|
|
195
|
+
args.push('-c', '14');
|
|
196
|
+
} else if (options.houseSystem === 'P') {
|
|
197
|
+
args.push('-c', '0');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const result = spawnSync(probe.bin, args, { encoding: 'utf8' });
|
|
201
|
+
if (result.error || result.status !== 0) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
reason: result.error?.message || result.stderr || `Exit ${result.status}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { ok: true, stdout: result.stdout };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseHouseSystem(stdout: string): 'P' | 'W' | null {
|
|
212
|
+
const match = stdout.match(/(Placidus|Whole)\s+Houses/i);
|
|
213
|
+
if (!match) return null;
|
|
214
|
+
if (match[1].toLowerCase() === 'whole') return 'W';
|
|
215
|
+
return 'P';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseHouseCusps(stdout: string): number[] {
|
|
219
|
+
const cusps: number[] = new Array(12).fill(Number.NaN);
|
|
220
|
+
const signOrder = [
|
|
221
|
+
'Ari',
|
|
222
|
+
'Tau',
|
|
223
|
+
'Gem',
|
|
224
|
+
'Can',
|
|
225
|
+
'Leo',
|
|
226
|
+
'Vir',
|
|
227
|
+
'Lib',
|
|
228
|
+
'Sco',
|
|
229
|
+
'Sag',
|
|
230
|
+
'Cap',
|
|
231
|
+
'Aqu',
|
|
232
|
+
'Pis',
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const lines = stdout.split(/\r?\n/);
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
const m = line.match(/House cusp\s+(\d+):\s+([0-9]{1,2})([A-Za-z]{3})([0-9]{1,2})/i);
|
|
238
|
+
if (!m) continue;
|
|
239
|
+
const cuspIndex = Number(m[1]);
|
|
240
|
+
if (cuspIndex < 1 || cuspIndex > 12) continue;
|
|
241
|
+
const degree = Number(m[2]);
|
|
242
|
+
const signIndex = signOrder.indexOf(m[3]);
|
|
243
|
+
const minutes = Number(m[4]);
|
|
244
|
+
if (signIndex < 0) continue;
|
|
245
|
+
cusps[cuspIndex - 1] = signIndex * 30 + degree + minutes / 60;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return cusps;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function getAstrologPositions(
|
|
252
|
+
isoUtc: string,
|
|
253
|
+
probe: AstrologProbe
|
|
254
|
+
): { ok: boolean; positions?: NormalizedBody[]; reason?: string } {
|
|
255
|
+
const chart = runAstrologChart(isoUtc, probe);
|
|
256
|
+
if (!chart.ok || !chart.stdout) {
|
|
257
|
+
return { ok: false, reason: chart.reason };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const parsed = parsePositionsFromStdout(chart.stdout);
|
|
261
|
+
if (parsed.length >= 5) {
|
|
262
|
+
return { ok: true, positions: parsed };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
reason: 'Astrolog is installed but output parsing/flags did not yield usable position rows',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function getAstrologHouses(
|
|
272
|
+
input: {
|
|
273
|
+
isoUtc: string;
|
|
274
|
+
latitude: number;
|
|
275
|
+
longitude: number;
|
|
276
|
+
houseSystem: 'P' | 'W';
|
|
277
|
+
},
|
|
278
|
+
probe: AstrologProbe
|
|
279
|
+
): { ok: boolean; houses?: NormalizedHouseResult; reason?: string } {
|
|
280
|
+
const chart = runAstrologChart(input.isoUtc, probe, {
|
|
281
|
+
latitude: input.latitude,
|
|
282
|
+
longitude: input.longitude,
|
|
283
|
+
houseSystem: input.houseSystem,
|
|
284
|
+
});
|
|
285
|
+
if (!chart.ok || !chart.stdout) {
|
|
286
|
+
return { ok: false, reason: chart.reason };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const cusps = parseHouseCusps(chart.stdout);
|
|
290
|
+
if (cusps.some((c) => !Number.isFinite(c))) {
|
|
291
|
+
return { ok: false, reason: 'Could not parse all 12 house cusps from Astrolog output' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const system = parseHouseSystem(chart.stdout) ?? input.houseSystem;
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
houses: {
|
|
298
|
+
system,
|
|
299
|
+
cusps,
|
|
300
|
+
// Astrolog textual chart output doesn't expose explicit ASC/MC values;
|
|
301
|
+
// use cusp 1/10 proxies for parity sanity.
|
|
302
|
+
ascendant: cusps[0],
|
|
303
|
+
mc: cusps[9],
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { EclipseCalculator } from '../../../src/eclipses.js';
|
|
2
|
+
import { EphemerisCalculator } from '../../../src/ephemeris.js';
|
|
3
|
+
import { HouseCalculator } from '../../../src/houses.js';
|
|
4
|
+
import { RiseSetCalculator } from '../../../src/riseset.js';
|
|
5
|
+
import { TransitCalculator } from '../../../src/transits.js';
|
|
6
|
+
import type { PlanetPosition, Transit } from '../../../src/types.js';
|
|
7
|
+
import type {
|
|
8
|
+
NormalizedBody,
|
|
9
|
+
NormalizedEclipse,
|
|
10
|
+
NormalizedHouseResult,
|
|
11
|
+
NormalizedRiseSet,
|
|
12
|
+
NormalizedRoot,
|
|
13
|
+
NormalizedTransit,
|
|
14
|
+
} from '../utils/fixtureTypes.js';
|
|
15
|
+
|
|
16
|
+
export class InternalValidationAdapter {
|
|
17
|
+
readonly ephem: EphemerisCalculator;
|
|
18
|
+
readonly houseCalc: HouseCalculator;
|
|
19
|
+
readonly transitCalc: TransitCalculator;
|
|
20
|
+
readonly riseSetCalc: RiseSetCalculator;
|
|
21
|
+
readonly eclipseCalc: EclipseCalculator;
|
|
22
|
+
|
|
23
|
+
private constructor(ephem: EphemerisCalculator) {
|
|
24
|
+
this.ephem = ephem;
|
|
25
|
+
this.houseCalc = new HouseCalculator(ephem);
|
|
26
|
+
this.transitCalc = new TransitCalculator(ephem);
|
|
27
|
+
this.riseSetCalc = new RiseSetCalculator(ephem);
|
|
28
|
+
this.eclipseCalc = new EclipseCalculator(ephem);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static async create(): Promise<InternalValidationAdapter> {
|
|
32
|
+
const ephem = new EphemerisCalculator();
|
|
33
|
+
await ephem.init();
|
|
34
|
+
return new InternalValidationAdapter(ephem);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getPositions(isoUtc: string, planetIds: number[]): NormalizedBody[] {
|
|
38
|
+
const jd = this.ephem.dateToJulianDay(new Date(isoUtc));
|
|
39
|
+
return this.ephem.getAllPlanets(jd, planetIds).map((p) => this.normalizeBody(p));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getHouseResult(
|
|
43
|
+
isoUtc: string,
|
|
44
|
+
latitude: number,
|
|
45
|
+
longitude: number,
|
|
46
|
+
houseSystem: string
|
|
47
|
+
): NormalizedHouseResult {
|
|
48
|
+
const jd = this.ephem.dateToJulianDay(new Date(isoUtc));
|
|
49
|
+
const result = this.houseCalc.calculateHouses(jd, latitude, longitude, houseSystem);
|
|
50
|
+
return {
|
|
51
|
+
system: result.system,
|
|
52
|
+
ascendant: result.ascendant,
|
|
53
|
+
mc: result.mc,
|
|
54
|
+
cusps: result.cusps.slice(1),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getTransitsFromOffsets(input: {
|
|
59
|
+
currentIsoUtc: string;
|
|
60
|
+
transitingPlanetId: number;
|
|
61
|
+
natalPlanetId: number;
|
|
62
|
+
natalOffsetDegrees: number;
|
|
63
|
+
}): NormalizedTransit[] {
|
|
64
|
+
const currentJD = this.ephem.dateToJulianDay(new Date(input.currentIsoUtc));
|
|
65
|
+
const transiting = this.ephem.getPlanetPosition(input.transitingPlanetId, currentJD);
|
|
66
|
+
|
|
67
|
+
const natal: PlanetPosition = {
|
|
68
|
+
planetId: input.natalPlanetId,
|
|
69
|
+
planet: this.ephem.getPlanetPosition(input.natalPlanetId, currentJD).planet,
|
|
70
|
+
longitude: (transiting.longitude + input.natalOffsetDegrees + 360) % 360,
|
|
71
|
+
latitude: 0,
|
|
72
|
+
distance: 1,
|
|
73
|
+
speed: 1,
|
|
74
|
+
sign: 'Aries',
|
|
75
|
+
degree: 0,
|
|
76
|
+
isRetrograde: false,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return this.transitCalc
|
|
80
|
+
.findTransits([transiting], [natal], currentJD)
|
|
81
|
+
.map((t) => this.normalizeTransit(t));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getTransits(
|
|
85
|
+
transitingPlanets: PlanetPosition[],
|
|
86
|
+
natalPlanets: PlanetPosition[],
|
|
87
|
+
currentIsoUtc: string
|
|
88
|
+
): NormalizedTransit[] {
|
|
89
|
+
const currentJD = this.ephem.dateToJulianDay(new Date(currentIsoUtc));
|
|
90
|
+
return this.transitCalc
|
|
91
|
+
.findTransits(transitingPlanets, natalPlanets, currentJD)
|
|
92
|
+
.map((t) => this.normalizeTransit(t));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getExactRoots(
|
|
96
|
+
planetId: number,
|
|
97
|
+
targetLongitude: number,
|
|
98
|
+
startIsoUtc: string,
|
|
99
|
+
endIsoUtc: string
|
|
100
|
+
): NormalizedRoot[] {
|
|
101
|
+
const startJD = this.ephem.dateToJulianDay(new Date(startIsoUtc));
|
|
102
|
+
const endJD = this.ephem.dateToJulianDay(new Date(endIsoUtc));
|
|
103
|
+
const roots = this.ephem.findExactTransitTimes(planetId, targetLongitude, startJD, endJD);
|
|
104
|
+
return roots.map((jd) => ({ jd, isoUtc: this.ephem.julianDayToDate(jd).toISOString() }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getRiseSet(
|
|
108
|
+
isoUtc: string,
|
|
109
|
+
planetId: number,
|
|
110
|
+
latitude: number,
|
|
111
|
+
longitude: number
|
|
112
|
+
): NormalizedRiseSet {
|
|
113
|
+
const jd = this.ephem.dateToJulianDay(new Date(isoUtc));
|
|
114
|
+
const result = this.riseSetCalc.calculateRiseSet(jd, planetId, latitude, longitude);
|
|
115
|
+
return {
|
|
116
|
+
body: result.planet,
|
|
117
|
+
rise: result.rise?.toISOString(),
|
|
118
|
+
set: result.set?.toISOString(),
|
|
119
|
+
upperMeridianTransit: result.upperMeridianTransit?.toISOString(),
|
|
120
|
+
lowerMeridianTransit: result.lowerMeridianTransit?.toISOString(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getNextEclipse(startIsoUtc: string, type: 'solar' | 'lunar'): NormalizedEclipse | null {
|
|
125
|
+
const startJD = this.ephem.dateToJulianDay(new Date(startIsoUtc));
|
|
126
|
+
const result =
|
|
127
|
+
type === 'solar'
|
|
128
|
+
? this.eclipseCalc.findNextSolarEclipse(startJD)
|
|
129
|
+
: this.eclipseCalc.findNextLunarEclipse(startJD);
|
|
130
|
+
|
|
131
|
+
if (!result) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
type: result.type,
|
|
137
|
+
eclipseType: result.eclipseType,
|
|
138
|
+
maxTime: result.maxTime.toISOString(),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
canComputeRiseSet(): boolean {
|
|
143
|
+
try {
|
|
144
|
+
const probeJd = this.ephem.dateToJulianDay(new Date('2024-03-26T12:00:00Z'));
|
|
145
|
+
this.riseSetCalc.calculateRiseSet(probeJd, 0, 34.0522, -118.2437);
|
|
146
|
+
return true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return !String(error).includes('is not a function');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
canComputeEclipses(): boolean {
|
|
153
|
+
try {
|
|
154
|
+
const probeJd = this.ephem.dateToJulianDay(new Date('2024-03-26T00:00:00Z'));
|
|
155
|
+
this.ephem.eph?.sol_eclipse_when_glob(probeJd, 0, 0, false);
|
|
156
|
+
this.ephem.eph?.lun_eclipse_when(probeJd, 0, 0, false);
|
|
157
|
+
return true;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return !String(error).includes('is not a function');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
normalizeBody(p: PlanetPosition): NormalizedBody {
|
|
164
|
+
return {
|
|
165
|
+
body: p.planet,
|
|
166
|
+
longitude: p.longitude,
|
|
167
|
+
latitude: p.latitude,
|
|
168
|
+
speed: p.speed,
|
|
169
|
+
retrograde: p.isRetrograde,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
normalizeTransit(t: Transit): NormalizedTransit {
|
|
174
|
+
return {
|
|
175
|
+
transitingPlanet: t.transitingPlanet,
|
|
176
|
+
natalPlanet: t.natalPlanet,
|
|
177
|
+
aspect: t.aspect,
|
|
178
|
+
orb: t.orb,
|
|
179
|
+
exactTime: t.exactTime?.toISOString(),
|
|
180
|
+
exactTimeStatus: t.exactTimeStatus,
|
|
181
|
+
isApplying: t.isApplying,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { NormalizedEclipse } from '../utils/fixtureTypes.js';
|
|
2
|
+
import type { ValidationReport } from '../utils/report.js';
|
|
3
|
+
import { minutesBetweenIso, TOLERANCES } from '../utils/tolerances.js';
|
|
4
|
+
|
|
5
|
+
export function compareEclipses(
|
|
6
|
+
fixtureName: string,
|
|
7
|
+
expected: NormalizedEclipse,
|
|
8
|
+
actual: NormalizedEclipse,
|
|
9
|
+
report: ValidationReport
|
|
10
|
+
): void {
|
|
11
|
+
if (expected.type !== actual.type) {
|
|
12
|
+
report.addHard({
|
|
13
|
+
fixture: fixtureName,
|
|
14
|
+
subsystem: 'eclipses',
|
|
15
|
+
expected: expected.type,
|
|
16
|
+
actual: actual.type,
|
|
17
|
+
delta: null,
|
|
18
|
+
tolerance: 'exact',
|
|
19
|
+
message: 'Eclipse type mismatch',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (expected.eclipseType !== actual.eclipseType) {
|
|
24
|
+
report.addHard({
|
|
25
|
+
fixture: fixtureName,
|
|
26
|
+
subsystem: 'eclipses',
|
|
27
|
+
expected: expected.eclipseType,
|
|
28
|
+
actual: actual.eclipseType,
|
|
29
|
+
delta: null,
|
|
30
|
+
tolerance: 'exact',
|
|
31
|
+
message: 'Eclipse subtype mismatch',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const delta = minutesBetweenIso(expected.maxTime, actual.maxTime);
|
|
36
|
+
if (delta > TOLERANCES.eclipseMinutes) {
|
|
37
|
+
report.addHard({
|
|
38
|
+
fixture: fixtureName,
|
|
39
|
+
subsystem: 'eclipses',
|
|
40
|
+
expected: expected.maxTime,
|
|
41
|
+
actual: actual.maxTime,
|
|
42
|
+
delta,
|
|
43
|
+
tolerance: TOLERANCES.eclipseMinutes,
|
|
44
|
+
message: 'Eclipse maxTime delta exceeds tolerance',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { NormalizedHouseResult } from '../utils/fixtureTypes.js';
|
|
2
|
+
import type { ValidationReport } from '../utils/report.js';
|
|
3
|
+
import { TOLERANCES } from '../utils/tolerances.js';
|
|
4
|
+
|
|
5
|
+
export function compareHouses(
|
|
6
|
+
fixtureName: string,
|
|
7
|
+
expected: NormalizedHouseResult,
|
|
8
|
+
actual: NormalizedHouseResult,
|
|
9
|
+
report: ValidationReport
|
|
10
|
+
): void {
|
|
11
|
+
if (expected.system !== actual.system) {
|
|
12
|
+
report.addHard({
|
|
13
|
+
fixture: fixtureName,
|
|
14
|
+
subsystem: 'houses',
|
|
15
|
+
expected: expected.system,
|
|
16
|
+
actual: actual.system,
|
|
17
|
+
delta: null,
|
|
18
|
+
tolerance: 'exact',
|
|
19
|
+
message: 'House system mismatch (including fallback system)',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ascDelta = Math.abs(expected.ascendant - actual.ascendant);
|
|
24
|
+
if (ascDelta > TOLERANCES.houseDeg) {
|
|
25
|
+
report.addHard({
|
|
26
|
+
fixture: fixtureName,
|
|
27
|
+
subsystem: 'houses',
|
|
28
|
+
expected: expected.ascendant,
|
|
29
|
+
actual: actual.ascendant,
|
|
30
|
+
delta: ascDelta,
|
|
31
|
+
tolerance: TOLERANCES.houseDeg,
|
|
32
|
+
message: 'Ascendant delta exceeds tolerance',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mcDelta = Math.abs(expected.mc - actual.mc);
|
|
37
|
+
if (mcDelta > TOLERANCES.houseDeg) {
|
|
38
|
+
report.addHard({
|
|
39
|
+
fixture: fixtureName,
|
|
40
|
+
subsystem: 'houses',
|
|
41
|
+
expected: expected.mc,
|
|
42
|
+
actual: actual.mc,
|
|
43
|
+
delta: mcDelta,
|
|
44
|
+
tolerance: TOLERANCES.houseDeg,
|
|
45
|
+
message: 'MC delta exceeds tolerance',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (expected.cusps.length !== actual.cusps.length) {
|
|
50
|
+
report.addHard({
|
|
51
|
+
fixture: fixtureName,
|
|
52
|
+
subsystem: 'houses',
|
|
53
|
+
expected: expected.cusps.length,
|
|
54
|
+
actual: actual.cusps.length,
|
|
55
|
+
delta: actual.cusps.length - expected.cusps.length,
|
|
56
|
+
tolerance: 0,
|
|
57
|
+
message: 'Cusp count mismatch',
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < expected.cusps.length; i++) {
|
|
63
|
+
const delta = Math.abs(expected.cusps[i] - actual.cusps[i]);
|
|
64
|
+
if (delta > TOLERANCES.houseDeg) {
|
|
65
|
+
report.addHard({
|
|
66
|
+
fixture: fixtureName,
|
|
67
|
+
subsystem: 'houses',
|
|
68
|
+
expected: expected.cusps[i],
|
|
69
|
+
actual: actual.cusps[i],
|
|
70
|
+
delta,
|
|
71
|
+
tolerance: TOLERANCES.houseDeg,
|
|
72
|
+
message: `House cusp ${i + 1} delta exceeds tolerance`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|