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,710 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { Temporal } from '@js-temporal/polyfill';
3
+ import { ChartRenderer } from './charts.js';
4
+ import { getDefaultTheme } from './constants.js';
5
+ import { EclipseCalculator } from './eclipses.js';
6
+ import { EphemerisCalculator } from './ephemeris.js';
7
+ import { formatDateOnly, formatInTimezone } from './formatter.js';
8
+ import { HouseCalculator } from './houses.js';
9
+ import { RiseSetCalculator } from './riseset.js';
10
+ import { addLocalDays, type Disambiguation, localToUTC, utcToLocal } from './time-utils.js';
11
+ import { deduplicateTransits, TransitCalculator } from './transits.js';
12
+ import {
13
+ ASTEROIDS,
14
+ type HouseSystem,
15
+ type NatalChart,
16
+ NODES,
17
+ OUTER_PLANETS,
18
+ PERSONAL_PLANETS,
19
+ PLANETS,
20
+ type PlanetPositionResponse,
21
+ type Transit,
22
+ type TransitResponse,
23
+ ZODIAC_SIGNS,
24
+ } from './types.js';
25
+
26
+ interface AstroServiceDependencies {
27
+ ephem?: EphemerisCalculator;
28
+ transitCalc?: TransitCalculator;
29
+ houseCalc?: HouseCalculator;
30
+ riseSetCalc?: RiseSetCalculator;
31
+ eclipseCalc?: EclipseCalculator;
32
+ chartRenderer?: ChartRenderer;
33
+ now?: () => Date;
34
+ writeFile?: (path: string, data: string | Buffer, encoding?: BufferEncoding) => Promise<void>;
35
+ }
36
+
37
+ export interface SetNatalChartInput {
38
+ name: string;
39
+ year: number;
40
+ month: number;
41
+ day: number;
42
+ hour: number;
43
+ minute: number;
44
+ latitude: number;
45
+ longitude: number;
46
+ timezone: string;
47
+ house_system?: HouseSystem;
48
+ birth_time_disambiguation?: Disambiguation;
49
+ }
50
+
51
+ export interface GetTransitsInput {
52
+ date?: string;
53
+ categories?: string[];
54
+ include_mundane?: boolean;
55
+ days_ahead?: number;
56
+ max_orb?: number;
57
+ exact_only?: boolean;
58
+ applying_only?: boolean;
59
+ }
60
+
61
+ export interface GetHousesInput {
62
+ system?: string;
63
+ }
64
+
65
+ export interface GenerateChartInput {
66
+ theme?: 'light' | 'dark';
67
+ format?: 'svg' | 'png' | 'webp';
68
+ output_path?: string;
69
+ }
70
+
71
+ export interface GenerateTransitChartInput extends GenerateChartInput {
72
+ date?: string;
73
+ }
74
+
75
+ export interface ServiceResult<T> {
76
+ data: T;
77
+ text: string;
78
+ }
79
+
80
+ export interface ChartServiceResult {
81
+ format: 'svg' | 'png' | 'webp';
82
+ outputPath?: string;
83
+ text: string;
84
+ svg?: string;
85
+ image?: {
86
+ data: string;
87
+ mimeType: string;
88
+ };
89
+ }
90
+
91
+ export function parseDateOnlyInput(dateStr: string): {
92
+ year: number;
93
+ month: number;
94
+ day: number;
95
+ hour: number;
96
+ minute: number;
97
+ } {
98
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
99
+ if (!match) {
100
+ throw new Error(`Invalid date format: expected YYYY-MM-DD, got "${dateStr}"`);
101
+ }
102
+
103
+ const year = Number(match[1]);
104
+ const month = Number(match[2]);
105
+ const day = Number(match[3]);
106
+
107
+ if (month < 1 || month > 12) {
108
+ throw new Error(`Invalid month: ${month} (must be 1-12)`);
109
+ }
110
+ if (day < 1 || day > 31) {
111
+ throw new Error(`Invalid day: ${day} (must be 1-31)`);
112
+ }
113
+
114
+ try {
115
+ Temporal.PlainDate.from({ year, month, day });
116
+ } catch {
117
+ throw new Error(`Invalid calendar date: ${dateStr}`);
118
+ }
119
+
120
+ return { year, month, day, hour: 12, minute: 0 };
121
+ }
122
+
123
+ export class AstroService {
124
+ readonly ephem: EphemerisCalculator;
125
+ readonly transitCalc: TransitCalculator;
126
+ readonly houseCalc: HouseCalculator;
127
+ readonly riseSetCalc: RiseSetCalculator;
128
+ readonly eclipseCalc: EclipseCalculator;
129
+ readonly chartRenderer: ChartRenderer;
130
+ private readonly now: () => Date;
131
+ private readonly writeFileFn: (
132
+ path: string,
133
+ data: string | Buffer,
134
+ encoding?: BufferEncoding
135
+ ) => Promise<void>;
136
+
137
+ constructor(deps: AstroServiceDependencies = {}) {
138
+ this.ephem = deps.ephem ?? new EphemerisCalculator();
139
+ this.houseCalc = deps.houseCalc ?? new HouseCalculator(this.ephem);
140
+ this.transitCalc = deps.transitCalc ?? new TransitCalculator(this.ephem);
141
+ this.riseSetCalc = deps.riseSetCalc ?? new RiseSetCalculator(this.ephem);
142
+ this.eclipseCalc = deps.eclipseCalc ?? new EclipseCalculator(this.ephem);
143
+ this.chartRenderer = deps.chartRenderer ?? new ChartRenderer(this.ephem, this.houseCalc);
144
+ this.now = deps.now ?? (() => new Date());
145
+ this.writeFileFn = deps.writeFile ?? writeFile;
146
+ }
147
+
148
+ async init(): Promise<void> {
149
+ await this.ephem.init();
150
+ }
151
+
152
+ isInitialized(): boolean {
153
+ return !!this.ephem.eph;
154
+ }
155
+
156
+ setNatalChart(
157
+ input: SetNatalChartInput
158
+ ): ServiceResult<Record<string, unknown>> & { chart: NatalChart } {
159
+ const requestedHouseSystem = input.house_system ?? null;
160
+
161
+ const chart: NatalChart = {
162
+ name: input.name,
163
+ birthDate: {
164
+ year: input.year,
165
+ month: input.month,
166
+ day: input.day,
167
+ hour: input.hour,
168
+ minute: input.minute,
169
+ },
170
+ location: {
171
+ latitude: input.latitude,
172
+ longitude: input.longitude,
173
+ timezone: input.timezone,
174
+ },
175
+ };
176
+
177
+ const birthTimeDisambiguation = input.birth_time_disambiguation ?? 'reject';
178
+ const utcDate = localToUTC(chart.birthDate, chart.location.timezone, birthTimeDisambiguation);
179
+ const utcComponents = utcToLocal(utcDate, 'UTC');
180
+
181
+ const jd = this.ephem.dateToJulianDay(utcDate);
182
+ const planetIds = Object.values(PLANETS);
183
+ const positions = this.ephem.getAllPlanets(jd, planetIds);
184
+
185
+ const isPolar = Math.abs(chart.location.latitude) > 66;
186
+ let houseSystem: HouseSystem = requestedHouseSystem || 'P';
187
+ if (isPolar && houseSystem === 'P') {
188
+ houseSystem = 'W';
189
+ }
190
+
191
+ const houses = this.houseCalc.calculateHouses(
192
+ jd,
193
+ chart.location.latitude,
194
+ chart.location.longitude,
195
+ houseSystem
196
+ );
197
+
198
+ const storedChart: NatalChart = {
199
+ ...chart,
200
+ planets: positions,
201
+ julianDay: jd,
202
+ houseSystem: houses.system,
203
+ utcDateTime: utcComponents,
204
+ };
205
+
206
+ const sun = positions.find((p) => p.planet === 'Sun');
207
+ const moon = positions.find((p) => p.planet === 'Moon');
208
+ if (!sun || !moon) {
209
+ throw new Error('Ephemeris failed to compute Sun/Moon positions for natal chart.');
210
+ }
211
+
212
+ const formatDegree = (lon: number): string => {
213
+ const sign = ZODIAC_SIGNS[Math.floor(lon / 30)];
214
+ const degree = lon % 30;
215
+ return `${degree.toFixed(0)}° ${sign}`;
216
+ };
217
+
218
+ const localTimeStr = `${chart.birthDate.month}/${chart.birthDate.day}/${chart.birthDate.year} ${chart.birthDate.hour}:${String(chart.birthDate.minute).padStart(2, '0')}`;
219
+ const utcTimeStr = `${utcComponents.month}/${utcComponents.day}/${utcComponents.year} ${utcComponents.hour}:${String(utcComponents.minute).padStart(2, '0')} UTC`;
220
+
221
+ const systemNames: Record<string, string> = {
222
+ P: 'Placidus',
223
+ W: 'Whole Sign',
224
+ K: 'Koch',
225
+ E: 'Equal',
226
+ };
227
+
228
+ const latDir = chart.location.latitude >= 0 ? 'N' : 'S';
229
+ const lonDir = chart.location.longitude >= 0 ? 'E' : 'W';
230
+ const latAbs = Math.abs(chart.location.latitude);
231
+ const lonAbs = Math.abs(chart.location.longitude);
232
+
233
+ const feedback = [
234
+ `Natal chart saved for ${chart.name}`,
235
+ '',
236
+ 'Birth Details:',
237
+ `- Local Time: ${localTimeStr} (${chart.location.timezone})`,
238
+ `- UTC Time: ${utcTimeStr}`,
239
+ `- Location: ${latAbs.toFixed(2)}°${latDir}, ${lonAbs.toFixed(2)}°${lonDir}`,
240
+ '',
241
+ 'Chart Angles:',
242
+ `- Sun: ${formatDegree(sun.longitude)}`,
243
+ `- Moon: ${formatDegree(moon.longitude)}`,
244
+ `- Ascendant: ${formatDegree(houses.ascendant)}`,
245
+ `- MC: ${formatDegree(houses.mc)}`,
246
+ '',
247
+ `House System: ${systemNames[houses.system] || houses.system}`,
248
+ ];
249
+
250
+ if (isPolar && houses.system !== houseSystem) {
251
+ feedback.push(
252
+ '',
253
+ `Note: Polar latitude detected (${chart.location.latitude.toFixed(1)}°). Requested ${systemNames[houseSystem]}, using ${systemNames[houses.system]} instead.`
254
+ );
255
+ } else if (isPolar) {
256
+ feedback.push(
257
+ '',
258
+ `Note: Polar latitude detected (${chart.location.latitude.toFixed(1)}°). Using ${systemNames[houses.system]} house system.`
259
+ );
260
+ }
261
+
262
+ const structuredData: Record<string, unknown> = {
263
+ name: chart.name,
264
+ birthTime: {
265
+ local: localTimeStr,
266
+ utc: utcTimeStr,
267
+ timezone: chart.location.timezone,
268
+ },
269
+ location: {
270
+ latitude: chart.location.latitude,
271
+ longitude: chart.location.longitude,
272
+ },
273
+ julianDay: jd,
274
+ requestedHouseSystem,
275
+ resolvedHouseSystem: houses.system,
276
+ angles: {
277
+ sun: formatDegree(sun.longitude),
278
+ moon: formatDegree(moon.longitude),
279
+ ascendant: formatDegree(houses.ascendant),
280
+ mc: formatDegree(houses.mc),
281
+ },
282
+ isPolar,
283
+ };
284
+
285
+ return {
286
+ chart: storedChart,
287
+ data: structuredData,
288
+ text: feedback.join('\n'),
289
+ };
290
+ }
291
+
292
+ getTransits(
293
+ natalChart: NatalChart,
294
+ input: GetTransitsInput = {}
295
+ ): ServiceResult<Record<string, unknown>> {
296
+ const dateStr = input.date;
297
+ const categories = input.categories ?? ['all'];
298
+ const includeMundane = input.include_mundane ?? false;
299
+ const daysAhead = input.days_ahead ?? 0;
300
+ const maxOrb = input.max_orb ?? 8;
301
+ const exactOnly = input.exact_only ?? false;
302
+ const applyingOnly = input.applying_only ?? false;
303
+
304
+ if (daysAhead < 0) {
305
+ throw new Error('days_ahead must be >= 0');
306
+ }
307
+ if (maxOrb < 0) {
308
+ throw new Error('max_orb must be >= 0');
309
+ }
310
+
311
+ let transitingPlanetIds: number[] = [];
312
+ if (categories.includes('all')) {
313
+ transitingPlanetIds = Object.values(PLANETS);
314
+ } else {
315
+ if (categories.includes('moon')) transitingPlanetIds.push(PLANETS.MOON);
316
+ if (categories.includes('personal')) {
317
+ transitingPlanetIds.push(
318
+ ...PERSONAL_PLANETS.filter((p) => !transitingPlanetIds.includes(p))
319
+ );
320
+ }
321
+ if (categories.includes('outer')) {
322
+ transitingPlanetIds.push(...OUTER_PLANETS.filter((p) => !transitingPlanetIds.includes(p)));
323
+ }
324
+ }
325
+
326
+ const timezone = natalChart.location.timezone;
327
+
328
+ let targetDate: Date;
329
+ if (dateStr) {
330
+ const parsed = parseDateOnlyInput(dateStr);
331
+ targetDate = localToUTC(parsed, timezone);
332
+ } else {
333
+ const now = this.now();
334
+ const localNow = utcToLocal(now, timezone);
335
+ const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
336
+ targetDate = localToUTC(localNoon, timezone);
337
+ }
338
+
339
+ const allTransits: Transit[] = [];
340
+ const startLocal = utcToLocal(targetDate, timezone);
341
+ for (let day = 0; day <= daysAhead; day++) {
342
+ const dayUTC = addLocalDays(startLocal, timezone, day);
343
+ const jd = this.ephem.dateToJulianDay(dayUTC);
344
+ const transitingPlanets = this.ephem.getAllPlanets(jd, transitingPlanetIds);
345
+ const transits = this.transitCalc.findTransits(
346
+ transitingPlanets,
347
+ natalChart.planets || [],
348
+ jd
349
+ );
350
+ allTransits.push(...transits);
351
+ }
352
+
353
+ let filteredTransits = deduplicateTransits(allTransits);
354
+ filteredTransits = filteredTransits.filter((t) => t.orb <= maxOrb);
355
+ if (exactOnly) filteredTransits = filteredTransits.filter((t) => t.exactTime !== undefined);
356
+ if (applyingOnly) filteredTransits = filteredTransits.filter((t) => t.isApplying);
357
+ filteredTransits.sort((a, b) => a.orb - b.orb);
358
+
359
+ const localDate = utcToLocal(targetDate, timezone);
360
+ const dateLabel = `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
361
+
362
+ const structuredData: TransitResponse = {
363
+ date: dateLabel,
364
+ timezone,
365
+ transits: filteredTransits.map((t) => ({
366
+ transitingPlanet: t.transitingPlanet,
367
+ aspect: t.aspect,
368
+ natalPlanet: t.natalPlanet,
369
+ orb: Number.parseFloat(t.orb.toFixed(2)),
370
+ isApplying: t.isApplying,
371
+ exactTimeStatus: t.exactTimeStatus,
372
+ exactTime: t.exactTime?.toISOString(),
373
+ transitLongitude: t.transitLongitude,
374
+ natalLongitude: t.natalLongitude,
375
+ })),
376
+ };
377
+
378
+ let responseData: Record<string, unknown> = structuredData as unknown as Record<
379
+ string,
380
+ unknown
381
+ >;
382
+ let mundaneText = '';
383
+
384
+ if (includeMundane) {
385
+ const currentJD = this.ephem.dateToJulianDay(targetDate);
386
+ const currentPositions = this.ephem.getAllPlanets(currentJD, transitingPlanetIds);
387
+ const mundaneData: PlanetPositionResponse = {
388
+ date: dateLabel,
389
+ timezone,
390
+ positions: currentPositions,
391
+ };
392
+ responseData = { transits: structuredData, mundane: mundaneData };
393
+ mundaneText = `\n\nCurrent Planetary Positions:\n\n${currentPositions
394
+ .map(
395
+ (p) =>
396
+ `${p.planet}: ${p.degree.toFixed(1)}° ${p.sign} (${p.isRetrograde ? 'Rx' : 'Direct'})`
397
+ )
398
+ .join('\n')}`;
399
+ }
400
+
401
+ const humanLines = filteredTransits
402
+ .map((t) => {
403
+ const exactStr = t.exactTime ? ` - Exact: ${formatInTimezone(t.exactTime, timezone)}` : '';
404
+ const applyStr = t.isApplying ? '(applying)' : '(separating)';
405
+ return `${t.transitingPlanet} ${t.aspect} ${t.natalPlanet}: ${t.orb.toFixed(2)}° orb ${applyStr}${exactStr}`;
406
+ })
407
+ .join('\n');
408
+
409
+ const rangeStr = daysAhead > 0 ? ` (next ${daysAhead + 1} days)` : '';
410
+ const transitHeader =
411
+ filteredTransits.length > 0
412
+ ? `Transits${rangeStr}:\n\n${humanLines}`
413
+ : 'No transits found matching the specified criteria.';
414
+
415
+ return {
416
+ data: responseData,
417
+ text: transitHeader + mundaneText,
418
+ };
419
+ }
420
+
421
+ getHouses(
422
+ natalChart: NatalChart,
423
+ input: GetHousesInput = {}
424
+ ): ServiceResult<Record<string, unknown>> {
425
+ const system = input.system || natalChart.houseSystem || 'P';
426
+ if (!natalChart.julianDay) {
427
+ throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
428
+ }
429
+
430
+ const houses = this.houseCalc.calculateHouses(
431
+ natalChart.julianDay,
432
+ natalChart.location.latitude,
433
+ natalChart.location.longitude,
434
+ system
435
+ );
436
+
437
+ const humanLines = houses.cusps
438
+ .slice(1)
439
+ .map((deg: number, i: number) => {
440
+ const sign = ZODIAC_SIGNS[Math.floor(deg / 30)];
441
+ return `House ${i + 1}: ${(deg % 30).toFixed(2)}° ${sign}`;
442
+ })
443
+ .join('\n');
444
+ const humanText = `Houses (${houses.system}):\nAsc: ${houses.ascendant.toFixed(2)}° | MC: ${houses.mc.toFixed(2)}°\n\n${humanLines}`;
445
+
446
+ return {
447
+ data: houses as unknown as Record<string, unknown>,
448
+ text: humanText,
449
+ };
450
+ }
451
+
452
+ getRetrogradePlanets(timezone = 'UTC'): ServiceResult<Record<string, unknown>> {
453
+ const now = this.now();
454
+ const jd = this.ephem.dateToJulianDay(now);
455
+ const allPlanetIds = Object.values(PLANETS);
456
+ const positions = this.ephem.getAllPlanets(jd, allPlanetIds);
457
+ const retrograde = positions.filter((p) => p.isRetrograde);
458
+
459
+ const localNow = utcToLocal(now, timezone);
460
+ const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
461
+
462
+ const structuredData = {
463
+ date: dateLabel,
464
+ timezone,
465
+ planets: retrograde,
466
+ };
467
+
468
+ const humanText =
469
+ retrograde.length === 0
470
+ ? 'No planets are currently retrograde.'
471
+ : `Retrograde Planets:\n\n${retrograde.map((p) => `${p.planet}: ${p.degree.toFixed(2)}° ${p.sign}`).join('\n')}`;
472
+
473
+ return { data: structuredData, text: humanText };
474
+ }
475
+
476
+ async getRiseSetTimes(natalChart: NatalChart): Promise<ServiceResult<Record<string, unknown>>> {
477
+ const timezone = natalChart.location.timezone;
478
+ const now = this.now();
479
+ const localNow = utcToLocal(now, timezone);
480
+ const localMidnight = {
481
+ year: localNow.year,
482
+ month: localNow.month,
483
+ day: localNow.day,
484
+ hour: 0,
485
+ minute: 0,
486
+ second: 0,
487
+ };
488
+ const midnightUTC = localToUTC(localMidnight, timezone);
489
+
490
+ const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
491
+
492
+ const results = await this.riseSetCalc.getAllRiseSet(
493
+ midnightUTC,
494
+ natalChart.location.latitude,
495
+ natalChart.location.longitude
496
+ );
497
+
498
+ const structuredData = {
499
+ date: dateLabel,
500
+ timezone,
501
+ times: results.map((r) => ({
502
+ planet: r.planet,
503
+ rise: r.rise?.toISOString() ?? null,
504
+ set: r.set?.toISOString() ?? null,
505
+ })),
506
+ };
507
+
508
+ const humanText = `Rise/Set Times:\n\n${results
509
+ .map((r) => {
510
+ const rise = r.rise ? formatInTimezone(r.rise, timezone) : 'none';
511
+ const set = r.set ? formatInTimezone(r.set, timezone) : 'none';
512
+ return `${r.planet}: Rise ${rise}, Set ${set}`;
513
+ })
514
+ .join('\n')}`;
515
+
516
+ return {
517
+ data: structuredData,
518
+ text: humanText,
519
+ };
520
+ }
521
+
522
+ getAsteroidPositions(timezone = 'UTC'): ServiceResult<Record<string, unknown>> {
523
+ const now = this.now();
524
+ const jd = this.ephem.dateToJulianDay(now);
525
+ const asteroidIds = [...ASTEROIDS, ...NODES];
526
+ const positions = this.ephem.getAllPlanets(jd, asteroidIds);
527
+
528
+ const localNow = utcToLocal(now, timezone);
529
+ const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
530
+
531
+ const structuredData = {
532
+ date: dateLabel,
533
+ timezone,
534
+ positions,
535
+ };
536
+
537
+ const humanText = `Asteroid & Node Positions:\n\n${positions
538
+ .map((p) => {
539
+ const rx = p.isRetrograde ? ' Rx' : '';
540
+ return `${p.planet}: ${p.degree.toFixed(2)}° ${p.sign}${rx}`;
541
+ })
542
+ .join('\n')}`;
543
+
544
+ return {
545
+ data: structuredData,
546
+ text: humanText,
547
+ };
548
+ }
549
+
550
+ getNextEclipses(timezone = 'UTC'): ServiceResult<Record<string, unknown>> {
551
+ const now = this.now();
552
+ const jd = this.ephem.dateToJulianDay(now);
553
+
554
+ const solarEclipse = this.eclipseCalc.findNextSolarEclipse(jd);
555
+ const lunarEclipse = this.eclipseCalc.findNextLunarEclipse(jd);
556
+
557
+ const eclipses: Array<{ type: string; eclipseType: string; maxTime: string }> = [];
558
+ const humanLines: string[] = [];
559
+
560
+ if (solarEclipse) {
561
+ eclipses.push({
562
+ type: solarEclipse.type,
563
+ eclipseType: solarEclipse.eclipseType,
564
+ maxTime: solarEclipse.maxTime.toISOString(),
565
+ });
566
+ humanLines.push(
567
+ `Next Solar Eclipse: ${formatInTimezone(solarEclipse.maxTime, timezone)} (${solarEclipse.eclipseType})`
568
+ );
569
+ }
570
+
571
+ if (lunarEclipse) {
572
+ eclipses.push({
573
+ type: lunarEclipse.type,
574
+ eclipseType: lunarEclipse.eclipseType,
575
+ maxTime: lunarEclipse.maxTime.toISOString(),
576
+ });
577
+ humanLines.push(
578
+ `Next Lunar Eclipse: ${formatInTimezone(lunarEclipse.maxTime, timezone)} (${lunarEclipse.eclipseType})`
579
+ );
580
+ }
581
+
582
+ const structuredData = { timezone, eclipses };
583
+ const humanText =
584
+ eclipses.length === 0
585
+ ? 'No eclipses found in the near future.'
586
+ : `Upcoming Eclipses:\n\n${humanLines.join('\n')}`;
587
+
588
+ return { data: structuredData, text: humanText };
589
+ }
590
+
591
+ getServerStatus(natalChart: NatalChart | null): ServiceResult<Record<string, unknown>> {
592
+ const statusData = {
593
+ serverVersion: '1.0.0',
594
+ hasNatalChart: natalChart !== null,
595
+ natalChartName: natalChart?.name ?? null,
596
+ natalChartTimezone: natalChart?.location.timezone ?? null,
597
+ ephemerisInitialized: this.isInitialized(),
598
+ stateModel: 'stateful-per-process',
599
+ };
600
+
601
+ const humanText = natalChart
602
+ ? `Server ready. Natal chart loaded: ${natalChart.name} (${natalChart.location.timezone})`
603
+ : 'Server ready. No natal chart loaded — call set_natal_chart first.';
604
+
605
+ return { data: statusData, text: humanText };
606
+ }
607
+
608
+ async generateNatalChart(
609
+ natalChart: NatalChart,
610
+ input: GenerateChartInput = {}
611
+ ): Promise<ChartServiceResult> {
612
+ const theme = input.theme || getDefaultTheme(natalChart.location.timezone);
613
+ const format = input.format || 'svg';
614
+ const outputPath = input.output_path;
615
+ const chart = await this.chartRenderer.generateNatalChart(natalChart, theme, format);
616
+
617
+ if (outputPath) {
618
+ if (format === 'svg') {
619
+ await this.writeFileFn(outputPath, chart as string, 'utf-8');
620
+ } else {
621
+ await this.writeFileFn(outputPath, chart as Buffer);
622
+ }
623
+ return {
624
+ format,
625
+ outputPath,
626
+ text: `Natal Chart for ${natalChart.name} saved to: ${outputPath}`,
627
+ };
628
+ }
629
+
630
+ if (format === 'svg') {
631
+ return {
632
+ format,
633
+ text: `Natal Chart for ${natalChart.name}:`,
634
+ svg: chart as string,
635
+ };
636
+ }
637
+
638
+ const base64 = (chart as Buffer).toString('base64');
639
+ const mimeType = format === 'png' ? 'image/png' : 'image/webp';
640
+ return {
641
+ format,
642
+ text: `Natal Chart for ${natalChart.name} (${theme} theme, ${format.toUpperCase()} format):`,
643
+ image: {
644
+ data: base64,
645
+ mimeType,
646
+ },
647
+ };
648
+ }
649
+
650
+ async generateTransitChart(
651
+ natalChart: NatalChart,
652
+ input: GenerateTransitChartInput = {}
653
+ ): Promise<ChartServiceResult> {
654
+ const dateStr = input.date;
655
+ const theme = input.theme ?? getDefaultTheme(natalChart.location.timezone);
656
+ const format = input.format ?? 'svg';
657
+
658
+ let targetDate: Date;
659
+ if (dateStr) {
660
+ const parsed = parseDateOnlyInput(dateStr);
661
+ targetDate = localToUTC(parsed, natalChart.location.timezone);
662
+ } else {
663
+ const now = this.now();
664
+ const localNow = utcToLocal(now, natalChart.location.timezone);
665
+ const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
666
+ targetDate = localToUTC(localNoon, natalChart.location.timezone);
667
+ }
668
+
669
+ const outputPath = input.output_path;
670
+ const chart = await this.chartRenderer.generateTransitChart(
671
+ natalChart,
672
+ targetDate,
673
+ theme,
674
+ format
675
+ );
676
+ const dateLabel = formatDateOnly(targetDate, natalChart.location.timezone);
677
+
678
+ if (outputPath) {
679
+ if (format === 'svg') {
680
+ await this.writeFileFn(outputPath, chart as string, 'utf-8');
681
+ } else {
682
+ await this.writeFileFn(outputPath, chart as Buffer);
683
+ }
684
+ return {
685
+ format,
686
+ outputPath,
687
+ text: `Transit Chart for ${natalChart.name} (${dateLabel}) saved to ${outputPath}`,
688
+ };
689
+ }
690
+
691
+ if (format === 'svg') {
692
+ return {
693
+ format,
694
+ text: `Transit Chart for ${natalChart.name} (${dateLabel})`,
695
+ svg: chart as string,
696
+ };
697
+ }
698
+
699
+ const base64 = (chart as Buffer).toString('base64');
700
+ const mimeType = format === 'png' ? 'image/png' : 'image/webp';
701
+ return {
702
+ format,
703
+ text: `Transit Chart for ${natalChart.name} (${dateLabel}, ${theme} theme, ${format.toUpperCase()} format):`,
704
+ image: {
705
+ data: base64,
706
+ mimeType,
707
+ },
708
+ };
709
+ }
710
+ }