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,437 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import sweph, { constants as Constants } from 'sweph';
5
+ import { logger } from './logger.js';
6
+ import { PLANET_NAMES, type PlanetPosition, ZODIAC_SIGNS } from './types.js';
7
+
8
+ // Constants for exact transit time calculation
9
+ const DEFAULT_EXACT_TIME_TOLERANCE = 0.01; // degrees
10
+ const MAX_EXACT_TIME_ITERATIONS = 50;
11
+ const ROOT_DEDUP_EPSILON_DAYS = 1 / 1440; // 1 minute
12
+ const COARSE_SCAN_MAX_STEP_DAYS = 1;
13
+ const MAX_COARSE_SCAN_SAMPLES = 500;
14
+ const TANGENTIAL_ROOT_SCAN_FACTOR = 20; // candidate threshold in tolerance multiples
15
+
16
+ /**
17
+ * Ephemeris calculator wrapper for native Swiss Ephemeris (sweph)
18
+ *
19
+ * @remarks
20
+ * Provides a high-level interface for planetary calculations using the
21
+ * Swiss Ephemeris Node bindings. Handles initialization,
22
+ * coordinate conversions, and common astrological calculations.
23
+ *
24
+ * All longitudes are tropical (not sidereal) and geocentric.
25
+ */
26
+ export class EphemerisCalculator {
27
+ /** Native sweph module instance */
28
+ public eph: typeof sweph | null = null;
29
+
30
+ /**
31
+ * Initialize the Swiss Ephemeris native module
32
+ *
33
+ * @returns Promise that resolves when initialization is complete
34
+ * @throws Error if module setup fails
35
+ *
36
+ * @remarks
37
+ * Must be called before any other methods. Loads the Swiss Ephemeris
38
+ * data files and prepares the calculation engine.
39
+ */
40
+ async init(): Promise<void> {
41
+ if (!this.eph) {
42
+ this.eph = sweph;
43
+ const __dirname = dirname(fileURLToPath(import.meta.url));
44
+ const projectRoot = join(__dirname, '..');
45
+ const ephePath = join(projectRoot, 'data', 'ephemeris');
46
+
47
+ try {
48
+ logger.info('Loading ephemeris files from filesystem', { ephePath });
49
+ const files = await readdir(ephePath);
50
+ const se1Files = files.filter((f) => f.endsWith('.se1'));
51
+ logger.info(`Found ${se1Files.length} .se1 files on filesystem`);
52
+
53
+ // Basic readability sanity check; native sweph reads files by path.
54
+ for (const filename of se1Files) {
55
+ const filePath = join(ephePath, filename);
56
+ const buffer = await readFile(filePath);
57
+ logger.info(`Detected ${filename} (${(buffer.length / 1024).toFixed(2)}KB)`);
58
+ }
59
+
60
+ this.eph.set_ephe_path(ephePath);
61
+ logger.info(`✅ Ephemeris path configured for native sweph: ${ephePath}`);
62
+ } catch (error) {
63
+ logger.warn('⚠️ Failed to verify ephemeris files - continuing with sweph defaults', {
64
+ error: error instanceof Error ? error.message : String(error),
65
+ });
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Convert a JavaScript Date to Julian Day
72
+ *
73
+ * @param date - Date to convert (should be in UTC)
74
+ * @returns Julian Day number
75
+ * @throws Error if ephemeris not initialized
76
+ *
77
+ * @remarks
78
+ * Julian Day is a continuous count of days since noon Universal Time
79
+ * on January 1, 4713 BCE. It's the standard time system for astronomical
80
+ * calculations.
81
+ */
82
+ dateToJulianDay(date: Date): number {
83
+ if (!this.eph) throw new Error('Ephemeris not initialized');
84
+
85
+ const year = date.getUTCFullYear();
86
+ const month = date.getUTCMonth() + 1;
87
+ const day = date.getUTCDate();
88
+ const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
89
+
90
+ return this.eph.julday(year, month, day, hour, Constants.SE_GREG_CAL);
91
+ }
92
+
93
+ /**
94
+ * Normalize angle to 0-360 degree range
95
+ *
96
+ * @param angle - Angle in degrees (may be negative or > 360)
97
+ * @returns Normalized angle in degrees (0-360)
98
+ *
99
+ * @remarks
100
+ * Uses modulo arithmetic to handle negative angles correctly.
101
+ * Example: -10° becomes 350°, 370° becomes 10°.
102
+ */
103
+ private normalizeAngle(angle: number): number {
104
+ return ((angle % 360) + 360) % 360;
105
+ }
106
+
107
+ /**
108
+ * Get position of a single planet at a specific time
109
+ *
110
+ * @param planetId - Swiss Ephemeris planet ID (from PLANETS constant)
111
+ * @param jd - Julian Day for the calculation
112
+ * @returns Planet position with all relevant data
113
+ * @throws Error if ephemeris not initialized or invalid planet ID
114
+ *
115
+ * @remarks
116
+ * Returns tropical, geocentric coordinates. Includes zodiac sign
117
+ * calculation and retrograde status.
118
+ */
119
+ getPlanetPosition(planetId: number, jd: number): PlanetPosition {
120
+ if (!this.eph) throw new Error('Ephemeris not initialized');
121
+ if (!Number.isInteger(planetId) || !Number.isFinite(planetId)) {
122
+ throw new Error(`Invalid planet ID: ${planetId} (must be a finite integer)`);
123
+ }
124
+ if (!Number.isFinite(jd)) {
125
+ throw new Error(`Invalid Julian Day: ${jd} (must be finite)`);
126
+ }
127
+
128
+ const result = this.eph.calc_ut(jd, planetId, Constants.SEFLG_SPEED);
129
+
130
+ // Swiss Ephemeris puts warnings in error field even on success
131
+ // Log warnings but only throw if we don't have valid data
132
+ if (result.error) {
133
+ logger.ephemerisWarning(result.error);
134
+ }
135
+
136
+ if (!result.data || result.data.length < 4) {
137
+ throw new Error(
138
+ `Failed to calculate position for planet ${planetId}: ${result.error || 'No data returned'}`
139
+ );
140
+ }
141
+
142
+ const longitude = result.data[0];
143
+ const latitude = result.data[1];
144
+ const distance = result.data[2];
145
+ const speed = result.data[3];
146
+
147
+ const normalizedLon = this.normalizeAngle(longitude);
148
+ const signIndex = Math.floor(normalizedLon / 30);
149
+ const degreeInSign = normalizedLon % 30;
150
+
151
+ const planetName = PLANET_NAMES[planetId];
152
+ if (!planetName) {
153
+ throw new Error(`Unknown planet ID: ${planetId}`);
154
+ }
155
+
156
+ return {
157
+ planetId,
158
+ planet: planetName,
159
+ longitude: normalizedLon,
160
+ latitude,
161
+ distance,
162
+ speed,
163
+ sign: ZODIAC_SIGNS[signIndex],
164
+ degree: degreeInSign,
165
+ isRetrograde: speed < 0,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Get positions for multiple planets at a specific time
171
+ *
172
+ * @param planetIds - Array of Swiss Ephemeris planet IDs
173
+ * @param jd - Julian Day for the calculation
174
+ * @returns Array of planet positions in the same order as planetIds
175
+ * @throws Error if ephemeris not initialized
176
+ *
177
+ * @remarks
178
+ * Convenience wrapper that maps over planetIds and calls getPlanetPosition for each.
179
+ */
180
+ getAllPlanets(jd: number, planetIds: number[]): PlanetPosition[] {
181
+ return planetIds.map((id) => this.getPlanetPosition(id, jd));
182
+ }
183
+
184
+ /**
185
+ * Calculate angular distance between two planets
186
+ *
187
+ * @param lon1 - First planet's longitude
188
+ * @param lon2 - Second planet's longitude
189
+ * @returns Angular distance in degrees (0-180)
190
+ *
191
+ * @remarks
192
+ * Always returns the shorter arc between the two planets.
193
+ * For example, 350° and 10° have a distance of 20°, not 340°.
194
+ */
195
+ calculateAspectAngle(lon1: number, lon2: number): number {
196
+ let diff = Math.abs(lon1 - lon2);
197
+ if (diff > 180) {
198
+ diff = 360 - diff;
199
+ }
200
+ return diff;
201
+ }
202
+
203
+ /**
204
+ * Find all exact times when planet reaches a specific longitude
205
+ *
206
+ * @param planetId - Swiss Ephemeris planet ID
207
+ * @param targetLongitude - Target longitude in degrees (will be normalized to 0-360)
208
+ * @param startJD - Start of search window (Julian Day)
209
+ * @param endJD - End of search window (Julian Day)
210
+ * @param tolerance - Desired precision in degrees (default: 0.01°)
211
+ * @returns Array of Julian Days where crossings occur, sorted earliest-first, or empty array if none
212
+ * @throws Error if ephemeris not initialized or invalid inputs
213
+ *
214
+ * @remarks
215
+ * Uses multi-stage search: coarse scan for root detection, then bracket/minimum refinement.
216
+ * Endpoint-near-zero cases are collected directly as candidate roots.
217
+ * Only sign-change intervals are refined via bisection.
218
+ * Local minima of |diff| are refined to catch tangential no-sign-change roots.
219
+ * Returns all detected crossings in the interval, deduplicated within 1 minute,
220
+ * and sorted earliest-first. No guarantees are made outside the searched interval.
221
+ */
222
+ findExactTransitTimes(
223
+ planetId: number,
224
+ targetLongitude: number,
225
+ startJD: number,
226
+ endJD: number,
227
+ tolerance: number = DEFAULT_EXACT_TIME_TOLERANCE
228
+ ): number[] {
229
+ // Validate inputs
230
+ if (!Number.isFinite(startJD) || !Number.isFinite(endJD)) {
231
+ throw new Error('Invalid Julian Day: must be finite numbers');
232
+ }
233
+ if (startJD >= endJD) {
234
+ throw new Error(`Invalid interval: startJD (${startJD}) must be < endJD (${endJD})`);
235
+ }
236
+ if (!Number.isFinite(tolerance) || tolerance <= 0) {
237
+ throw new Error(`Invalid tolerance: ${tolerance} (must be > 0)`);
238
+ }
239
+
240
+ // Normalize target longitude to 0-360
241
+ targetLongitude = this.normalizeAngle(targetLongitude);
242
+
243
+ // Helper: calculate signed shortest-angle difference
244
+ const signedDiff = (lon: number): number => {
245
+ let diff = lon - targetLongitude;
246
+ if (diff > 180) diff -= 360;
247
+ if (diff < -180) diff += 360;
248
+ return diff;
249
+ };
250
+
251
+ // Stage 1: Coarse scan for root detection
252
+ // Resolution is driven by max step size, with a hard cap for bounded compute.
253
+ const windowDays = endJD - startJD;
254
+ const rawSamples = Math.ceil(windowDays / COARSE_SCAN_MAX_STEP_DAYS);
255
+ const numSamples = Math.max(1, Math.min(rawSamples, MAX_COARSE_SCAN_SAMPLES));
256
+ const step = windowDays / numSamples;
257
+
258
+ const samples: Array<{ jd: number; diff: number }> = [];
259
+ for (let i = 0; i <= numSamples; i++) {
260
+ const jd = startJD + i * step;
261
+ const pos = this.getPlanetPosition(planetId, jd);
262
+ const diff = signedDiff(pos.longitude);
263
+ samples.push({ jd, diff });
264
+ }
265
+
266
+ // Root detection outputs:
267
+ // - candidateRoots for near-zero samples
268
+ // - brackets for sign-change intervals (to be refined via bisection)
269
+ // - tangentialIntervals for local minima in |diff| (to catch no-sign-change roots)
270
+ const candidateRoots: number[] = [];
271
+ const brackets: Array<{ start: number; end: number }> = [];
272
+ const tangentialIntervals: Array<{ start: number; end: number }> = [];
273
+ const absDiffs = samples.map((s) => Math.abs(s.diff));
274
+
275
+ for (let i = 0; i < samples.length; i++) {
276
+ if (Math.abs(samples[i].diff) < tolerance * 5) {
277
+ candidateRoots.push(samples[i].jd);
278
+ }
279
+ }
280
+
281
+ for (let i = 0; i < samples.length - 1; i++) {
282
+ const curr = samples[i];
283
+ const next = samples[i + 1];
284
+
285
+ // Only true sign-change intervals are refined with bisection
286
+ if ((curr.diff > 0 && next.diff < 0) || (curr.diff < 0 && next.diff > 0)) {
287
+ brackets.push({ start: curr.jd, end: next.jd });
288
+ }
289
+ }
290
+
291
+ // Detect local minima in |diff| for tangential roots that do not change sign.
292
+ for (let i = 1; i < samples.length - 1; i++) {
293
+ const prevAbs = absDiffs[i - 1];
294
+ const currAbs = absDiffs[i];
295
+ const nextAbs = absDiffs[i + 1];
296
+ const prevDiff = samples[i - 1].diff;
297
+ const nextDiff = samples[i + 1].diff;
298
+
299
+ const isLocalMin =
300
+ currAbs <= prevAbs && currAbs <= nextAbs && (currAbs < prevAbs || currAbs < nextAbs);
301
+
302
+ if (!isLocalMin) continue;
303
+ const hasSignChangeAcrossWindow =
304
+ (prevDiff < 0 && nextDiff > 0) || (prevDiff > 0 && nextDiff < 0);
305
+ if (hasSignChangeAcrossWindow) continue;
306
+
307
+ const dipProminence = Math.max(prevAbs, nextAbs) - currAbs;
308
+ const looksPromising =
309
+ currAbs < tolerance * TANGENTIAL_ROOT_SCAN_FACTOR || dipProminence > tolerance;
310
+
311
+ if (looksPromising) {
312
+ tangentialIntervals.push({
313
+ start: samples[i - 1].jd,
314
+ end: samples[i + 1].jd,
315
+ });
316
+ }
317
+ }
318
+
319
+ // Stage 2a: Bisection refinement on sign-change brackets
320
+ const roots: number[] = [...candidateRoots];
321
+
322
+ for (const bracket of brackets) {
323
+ let jd1 = bracket.start;
324
+ let jd2 = bracket.end;
325
+ let iteration = 0;
326
+
327
+ while (iteration < MAX_EXACT_TIME_ITERATIONS) {
328
+ const jdMid = (jd1 + jd2) / 2;
329
+
330
+ // Stop if interval is tiny (< 1 minute)
331
+ if (jd2 - jd1 < ROOT_DEDUP_EPSILON_DAYS) {
332
+ break;
333
+ }
334
+
335
+ const posMid = this.getPlanetPosition(planetId, jdMid);
336
+ const diffMid = signedDiff(posMid.longitude);
337
+
338
+ // Check if close enough
339
+ if (Math.abs(diffMid) < tolerance) {
340
+ roots.push(jdMid);
341
+ break;
342
+ }
343
+
344
+ // Narrow the interval based on which half brackets the target
345
+ const posStart = this.getPlanetPosition(planetId, jd1);
346
+ const diffStart = signedDiff(posStart.longitude);
347
+
348
+ // Pick the half that brackets the target (sign change)
349
+ if ((diffStart > 0 && diffMid > 0) || (diffStart < 0 && diffMid < 0)) {
350
+ jd1 = jdMid;
351
+ } else {
352
+ jd2 = jdMid;
353
+ }
354
+
355
+ iteration++;
356
+ }
357
+
358
+ // If we didn't converge within tolerance, check final midpoint
359
+ if (iteration === MAX_EXACT_TIME_ITERATIONS || jd2 - jd1 < ROOT_DEDUP_EPSILON_DAYS) {
360
+ const finalMid = (jd1 + jd2) / 2;
361
+ const finalPos = this.getPlanetPosition(planetId, finalMid);
362
+ const finalDiff = signedDiff(finalPos.longitude);
363
+
364
+ if (Math.abs(finalDiff) < tolerance * 2) {
365
+ roots.push(finalMid);
366
+ }
367
+ }
368
+ }
369
+
370
+ // Stage 2b: Minimum refinement on tangential intervals (no sign change required)
371
+ for (const interval of tangentialIntervals) {
372
+ let left = interval.start;
373
+ let right = interval.end;
374
+ let iteration = 0;
375
+
376
+ while (iteration < MAX_EXACT_TIME_ITERATIONS && right - left > ROOT_DEDUP_EPSILON_DAYS) {
377
+ const m1 = left + (right - left) / 3;
378
+ const m2 = right - (right - left) / 3;
379
+
380
+ const d1 = Math.abs(signedDiff(this.getPlanetPosition(planetId, m1).longitude));
381
+ const d2 = Math.abs(signedDiff(this.getPlanetPosition(planetId, m2).longitude));
382
+
383
+ if (d1 <= d2) {
384
+ right = m2;
385
+ } else {
386
+ left = m1;
387
+ }
388
+
389
+ iteration++;
390
+ }
391
+
392
+ const minJD = (left + right) / 2;
393
+ const minAbs = Math.abs(signedDiff(this.getPlanetPosition(planetId, minJD).longitude));
394
+ if (minAbs < tolerance * 2) {
395
+ roots.push(minJD);
396
+ }
397
+ }
398
+
399
+ // Sort all roots chronologically
400
+ roots.sort((a, b) => a - b);
401
+
402
+ // Deduplicate roots with epsilon (adjacent brackets can converge to same crossing)
403
+ // Use 1 minute threshold to avoid duplicates
404
+ const deduped: number[] = [];
405
+ for (const root of roots) {
406
+ const last = deduped[deduped.length - 1];
407
+ if (last == null || Math.abs(root - last) > ROOT_DEDUP_EPSILON_DAYS) {
408
+ deduped.push(root);
409
+ }
410
+ }
411
+
412
+ return deduped;
413
+ }
414
+
415
+ /**
416
+ * Convert Julian Day to JavaScript Date
417
+ *
418
+ * @param jd - Julian Day number
419
+ * @returns JavaScript Date in UTC
420
+ * @throws Error if ephemeris not initialized
421
+ *
422
+ * @remarks
423
+ * The returned Date is always in UTC regardless of the original
424
+ * timezone of the calculation.
425
+ */
426
+ julianDayToDate(jd: number): Date {
427
+ if (!this.eph) throw new Error('Ephemeris not initialized');
428
+
429
+ const result = this.eph.revjul(jd, Constants.SE_GREG_CAL);
430
+
431
+ // Convert fractional hour to milliseconds from midnight and round once.
432
+ // Adding to midnight timestamp naturally handles overflow carries.
433
+ const msFromMidnight = Math.round(result.hour * 3600 * 1000);
434
+ const midnightUtcMs = Date.UTC(result.year, result.month - 1, result.day, 0, 0, 0, 0);
435
+ return new Date(midnightUtcMs + msFromMidnight);
436
+ }
437
+ }
@@ -0,0 +1,25 @@
1
+ export function formatInTimezone(date: Date, timezone: string): string {
2
+ const options: Intl.DateTimeFormatOptions = {
3
+ timeZone: timezone,
4
+ year: 'numeric',
5
+ month: 'short',
6
+ day: 'numeric',
7
+ hour: 'numeric',
8
+ minute: '2-digit',
9
+ hour12: true,
10
+ timeZoneName: 'short',
11
+ };
12
+
13
+ return new Intl.DateTimeFormat('en-US', options).format(date);
14
+ }
15
+
16
+ export function formatDateOnly(date: Date, timezone: string): string {
17
+ const options: Intl.DateTimeFormatOptions = {
18
+ timeZone: timezone,
19
+ year: 'numeric',
20
+ month: 'short',
21
+ day: 'numeric',
22
+ };
23
+
24
+ return new Intl.DateTimeFormat('en-US', options).format(date);
25
+ }
package/src/houses.ts ADDED
@@ -0,0 +1,202 @@
1
+ import type { EphemerisCalculator } from './ephemeris.js';
2
+ import { type HouseData, type HouseSystem, ZODIAC_SIGNS } from './types.js';
3
+
4
+ /**
5
+ * Calculator for astrological houses, Ascendant, and Midheaven
6
+ *
7
+ * @remarks
8
+ * Calculates house cusps using various house systems. Handles polar
9
+ * latitude edge cases by falling back to Whole Sign when needed.
10
+ * Uses Swiss Ephemeris 1-based indexing for cusps array.
11
+ */
12
+ export class HouseCalculator {
13
+ /** Ephemeris calculator instance */
14
+ private ephem: EphemerisCalculator;
15
+
16
+ /**
17
+ * Create a new house calculator
18
+ *
19
+ * @param ephem - Initialized ephemeris calculator
20
+ * @throws Error if ephemeris is not initialized
21
+ *
22
+ * @remarks
23
+ * The ephemeris calculator must be initialized before passing
24
+ * to the HouseCalculator constructor.
25
+ */
26
+ constructor(ephem: EphemerisCalculator) {
27
+ this.ephem = ephem;
28
+ }
29
+
30
+ /**
31
+ * Calculate house cusps, Ascendant, and Midheaven for a given time and location
32
+ *
33
+ * Supported house systems:
34
+ * - P: Placidus (most common, fails >66° latitude)
35
+ * - W: Whole Sign (works at all latitudes)
36
+ * - K: Koch
37
+ * - E: Equal
38
+ * - O: Porphyry
39
+ * - R: Regiomontanus
40
+ * - C: Campanus
41
+ * - A: Equal (MC)
42
+ * - V: Vehlow Equal
43
+ * - X: Axial Rotation
44
+ * - H: Azimuthal/Horizontal
45
+ * - T: Polich/Page (Topocentric)
46
+ * - B: Alcabitus
47
+ *
48
+ * Polar latitude handling:
49
+ * - For latitudes >66°, Placidus/Koch/etc may fail mathematically
50
+ * - Automatically falls back to Whole Sign if requested system fails
51
+ * - Returns actual system used in result.system field
52
+ *
53
+ * Cusp array format:
54
+ * - Swiss Ephemeris 1-based indexing: cusps[0] is unused, cusps[1..12] are houses 1-12
55
+ * - This preserves the original Swiss Ephemeris convention
56
+ *
57
+ * @param julianDay - Julian Day for calculation
58
+ * @param latitude - Observer latitude in degrees
59
+ * @param longitude - Observer longitude in degrees
60
+ * @param houseSystem - Single-character house system code (default: 'P')
61
+ * @returns House data with cusps, ascendant, MC, and actual system used
62
+ * @throws {Error} If house calculation fails or invalid system specified
63
+ */
64
+ calculateHouses(
65
+ julianDay: number,
66
+ latitude: number,
67
+ longitude: number,
68
+ houseSystem: string = 'P'
69
+ ): HouseData {
70
+ if (!this.ephem.eph) {
71
+ throw new Error('Ephemeris not initialized');
72
+ }
73
+
74
+ // Validate and normalize house system
75
+ const normalized = this.normalizeHouseSystem(houseSystem);
76
+
77
+ const isPolar = Math.abs(latitude) > 66;
78
+ let systemToUse = normalized as HouseSystem;
79
+
80
+ // Try requested system
81
+ const result = this.ephem.eph.houses_ex2(julianDay, 0, latitude, longitude, systemToUse);
82
+
83
+ // Handle polar latitude failure with real fallback
84
+ if (result.flag < 0 && isPolar && systemToUse !== 'W') {
85
+ // Retry with Whole Sign (works at all latitudes)
86
+ systemToUse = 'W';
87
+ const fallbackResult = this.ephem.eph.houses_ex2(
88
+ julianDay,
89
+ 0,
90
+ latitude,
91
+ longitude,
92
+ systemToUse
93
+ );
94
+
95
+ if (fallbackResult.flag < 0) {
96
+ // Even Whole Sign failed - this should never happen
97
+ throw new Error(
98
+ `House calculation failed even with Whole Sign fallback at latitude ${latitude.toFixed(1)}°`
99
+ );
100
+ }
101
+
102
+ // Return fallback result with actual system used
103
+ return {
104
+ ascendant: fallbackResult.data.points[0],
105
+ mc: fallbackResult.data.points[1],
106
+ cusps: [0, ...Array.from(fallbackResult.data.houses)], // Swiss 1-based: [0] unused, [1..12] houses
107
+ system: systemToUse, // Return 'W', not original requested system
108
+ };
109
+ }
110
+
111
+ // For non-polar failures, throw error (don't return fake data)
112
+ if (result.flag < 0) {
113
+ throw new Error(
114
+ `House calculation failed for ${systemToUse} system at latitude ${latitude.toFixed(1)}°`
115
+ );
116
+ }
117
+
118
+ // Success - return actual data
119
+ return {
120
+ ascendant: result.data.points[0],
121
+ mc: result.data.points[1],
122
+ cusps: [0, ...Array.from(result.data.houses)], // Swiss 1-based: [0] unused, [1..12] houses
123
+ system: systemToUse,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Normalize house system code
129
+ *
130
+ * @param system - House system code (single character or name)
131
+ * @returns Normalized single-character code
132
+ * @throws Error if invalid system
133
+ *
134
+ * @remarks
135
+ * Accepts both single-letter codes and full names.
136
+ * Validates against supported systems.
137
+ */
138
+ private normalizeHouseSystem(system: string): HouseSystem {
139
+ const upperSystem = system.toUpperCase().trim();
140
+
141
+ // Map common names to single-letter codes
142
+ const nameMap: { [key: string]: string } = {
143
+ PLACIDUS: 'P',
144
+ 'WHOLE SIGN': 'W',
145
+ KOCH: 'K',
146
+ EQUAL: 'E',
147
+ PORPHYRY: 'O',
148
+ REGIOMONTANUS: 'R',
149
+ CAMPANUS: 'C',
150
+ 'EQUAL MC': 'A',
151
+ 'VEHLOW EQUAL': 'V',
152
+ 'AXIAL ROTATION': 'X',
153
+ AZIMUTHAL: 'H',
154
+ HORIZONTAL: 'H',
155
+ TOPOCENTRIC: 'T',
156
+ POLICH: 'T',
157
+ PAGE: 'T',
158
+ ALCABITUS: 'B',
159
+ };
160
+
161
+ const normalized = nameMap[upperSystem] || upperSystem;
162
+
163
+ // Validate against allowed systems
164
+ const validSystems: HouseSystem[] = [
165
+ 'P',
166
+ 'W',
167
+ 'K',
168
+ 'E',
169
+ 'O',
170
+ 'R',
171
+ 'C',
172
+ 'A',
173
+ 'V',
174
+ 'X',
175
+ 'H',
176
+ 'T',
177
+ 'B',
178
+ ];
179
+ if (!validSystems.includes(normalized as HouseSystem)) {
180
+ throw new Error(`Invalid house system: ${system}. Valid systems: ${validSystems.join(', ')}`);
181
+ }
182
+
183
+ return normalized as HouseSystem;
184
+ }
185
+
186
+ /**
187
+ * Format house position as readable string
188
+ *
189
+ * @param longitude - House cusp longitude in degrees
190
+ * @returns Formatted string like "15.30° Aries"
191
+ *
192
+ * @remarks
193
+ * Normalizes longitude to 0-360° range and determines zodiac sign.
194
+ */
195
+ formatHousePosition(longitude: number): string {
196
+ // Normalize longitude to 0-360 range
197
+ const normalizedLon = ((longitude % 360) + 360) % 360;
198
+ const signIndex = Math.floor(normalizedLon / 30);
199
+ const degree = normalizedLon % 30;
200
+ return `${degree.toFixed(2)}° ${ZODIAC_SIGNS[signIndex]}`;
201
+ }
202
+ }