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.
Files changed (138) hide show
  1. package/.env.example +13 -0
  2. package/.github/pull_request_template.md +16 -0
  3. package/.github/workflows/release.yml +35 -0
  4. package/.github/workflows/test.yml +32 -0
  5. package/AGENTS.md +99 -0
  6. package/LICENSE +18 -0
  7. package/NOTICE.md +45 -0
  8. package/README.md +301 -0
  9. package/SETUP.md +70 -0
  10. package/TESTING_SUMMARY.md +238 -0
  11. package/TEST_SUITE_STATUS.md +218 -0
  12. package/biome.json +48 -0
  13. package/dist/astro-service.d.ts +98 -0
  14. package/dist/astro-service.js +496 -0
  15. package/dist/chart-types.d.ts +52 -0
  16. package/dist/chart-types.js +51 -0
  17. package/dist/charts.d.ts +125 -0
  18. package/dist/charts.js +324 -0
  19. package/dist/cli.d.ts +7 -0
  20. package/dist/cli.js +472 -0
  21. package/dist/constants.d.ts +81 -0
  22. package/dist/constants.js +76 -0
  23. package/dist/eclipses.d.ts +85 -0
  24. package/dist/eclipses.js +184 -0
  25. package/dist/ephemeris.d.ts +120 -0
  26. package/dist/ephemeris.js +379 -0
  27. package/dist/formatter.d.ts +2 -0
  28. package/dist/formatter.js +22 -0
  29. package/dist/houses.d.ts +82 -0
  30. package/dist/houses.js +169 -0
  31. package/dist/index.d.ts +14 -0
  32. package/dist/index.js +150 -0
  33. package/dist/loader.d.ts +2 -0
  34. package/dist/loader.js +31 -0
  35. package/dist/logger.d.ts +25 -0
  36. package/dist/logger.js +73 -0
  37. package/dist/profile-store.d.ts +48 -0
  38. package/dist/profile-store.js +156 -0
  39. package/dist/riseset.d.ts +82 -0
  40. package/dist/riseset.js +185 -0
  41. package/dist/storage.d.ts +10 -0
  42. package/dist/storage.js +40 -0
  43. package/dist/time-utils.d.ts +68 -0
  44. package/dist/time-utils.js +136 -0
  45. package/dist/tool-registry.d.ts +35 -0
  46. package/dist/tool-registry.js +307 -0
  47. package/dist/tool-result.d.ts +175 -0
  48. package/dist/tool-result.js +188 -0
  49. package/dist/transits.d.ts +108 -0
  50. package/dist/transits.js +263 -0
  51. package/dist/types.d.ts +450 -0
  52. package/dist/types.js +161 -0
  53. package/example-usage.md +131 -0
  54. package/natal-chart.json +187 -0
  55. package/package.json +61 -0
  56. package/scripts/download-ephemeris.js +115 -0
  57. package/setup.sh +21 -0
  58. package/src/astro-service.ts +710 -0
  59. package/src/chart-types.ts +125 -0
  60. package/src/charts.ts +399 -0
  61. package/src/cli.ts +694 -0
  62. package/src/constants.ts +89 -0
  63. package/src/eclipses.ts +226 -0
  64. package/src/ephemeris.ts +437 -0
  65. package/src/formatter.ts +25 -0
  66. package/src/houses.ts +202 -0
  67. package/src/index.ts +170 -0
  68. package/src/loader.ts +36 -0
  69. package/src/logger.ts +104 -0
  70. package/src/profile-store.ts +285 -0
  71. package/src/riseset.ts +229 -0
  72. package/src/time-utils.ts +167 -0
  73. package/src/tool-registry.ts +357 -0
  74. package/src/tool-result.ts +283 -0
  75. package/src/transits.ts +352 -0
  76. package/src/types.ts +547 -0
  77. package/tests/README.md +173 -0
  78. package/tests/TESTING_STRATEGY.md +178 -0
  79. package/tests/fixtures/bowen-yang-chart.ts +69 -0
  80. package/tests/fixtures/calculate-expected.ts +81 -0
  81. package/tests/fixtures/expected-results.ts +117 -0
  82. package/tests/fixtures/generate-expected-simple.ts +94 -0
  83. package/tests/helpers/date-fixtures.ts +15 -0
  84. package/tests/helpers/ephem.ts +11 -0
  85. package/tests/helpers/temp.ts +9 -0
  86. package/tests/setup.ts +11 -0
  87. package/tests/unit/astro-service.test.ts +323 -0
  88. package/tests/unit/chart-types.test.ts +18 -0
  89. package/tests/unit/charts-errors.test.ts +42 -0
  90. package/tests/unit/charts.test.ts +157 -0
  91. package/tests/unit/cli-commands.test.ts +82 -0
  92. package/tests/unit/cli-profiles.test.ts +128 -0
  93. package/tests/unit/cli.test.ts +191 -0
  94. package/tests/unit/constants.test.ts +26 -0
  95. package/tests/unit/correctness-critical.test.ts +408 -0
  96. package/tests/unit/eclipses.test.ts +108 -0
  97. package/tests/unit/ephemeris.test.ts +213 -0
  98. package/tests/unit/error-handling.test.ts +116 -0
  99. package/tests/unit/formatter.test.ts +29 -0
  100. package/tests/unit/houses-errors.test.ts +27 -0
  101. package/tests/unit/houses-validation.test.ts +164 -0
  102. package/tests/unit/houses.test.ts +205 -0
  103. package/tests/unit/profile-store.test.ts +163 -0
  104. package/tests/unit/real-user-charts.test.ts +148 -0
  105. package/tests/unit/riseset.test.ts +106 -0
  106. package/tests/unit/solver-edges.test.ts +197 -0
  107. package/tests/unit/time-utils-temporal.test.ts +303 -0
  108. package/tests/unit/time-utils.test.ts +173 -0
  109. package/tests/unit/tool-registry.test.ts +222 -0
  110. package/tests/unit/tool-result.test.ts +45 -0
  111. package/tests/unit/transit-correctness.test.ts +78 -0
  112. package/tests/unit/transits.test.ts +238 -0
  113. package/tests/validation/README.md +32 -0
  114. package/tests/validation/adapters/astrolog.ts +306 -0
  115. package/tests/validation/adapters/internal.ts +184 -0
  116. package/tests/validation/compare/eclipses.ts +47 -0
  117. package/tests/validation/compare/houses.ts +76 -0
  118. package/tests/validation/compare/positions.ts +104 -0
  119. package/tests/validation/compare/riseSet.ts +48 -0
  120. package/tests/validation/compare/roots.ts +90 -0
  121. package/tests/validation/compare/transits.ts +69 -0
  122. package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
  123. package/tests/validation/fixtures/eclipses/core.ts +14 -0
  124. package/tests/validation/fixtures/houses/core.ts +47 -0
  125. package/tests/validation/fixtures/positions/core.ts +159 -0
  126. package/tests/validation/fixtures/rise-set/core.ts +20 -0
  127. package/tests/validation/fixtures/roots/core.ts +47 -0
  128. package/tests/validation/fixtures/transits/core.ts +61 -0
  129. package/tests/validation/fixtures/transits/dst.ts +21 -0
  130. package/tests/validation/oracle.spec.ts +129 -0
  131. package/tests/validation/utils/denseRootOracle.ts +269 -0
  132. package/tests/validation/utils/fixtureTypes.ts +146 -0
  133. package/tests/validation/utils/report.ts +60 -0
  134. package/tests/validation/utils/tolerances.ts +23 -0
  135. package/tests/validation/validation.spec.ts +836 -0
  136. package/tools/color-picker.html +388 -0
  137. package/tsconfig.json +17 -0
  138. 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
+ }