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