ether-to-astro 1.2.0 → 1.3.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 (66) hide show
  1. package/README.md +15 -5
  2. package/dist/astro-service/chart-output-service.d.ts +44 -0
  3. package/dist/astro-service/chart-output-service.js +110 -0
  4. package/dist/astro-service/date-input.d.ts +14 -0
  5. package/dist/astro-service/date-input.js +30 -0
  6. package/dist/astro-service/electional-service.d.ts +45 -0
  7. package/dist/astro-service/electional-service.js +305 -0
  8. package/dist/astro-service/natal-service.d.ts +41 -0
  9. package/dist/astro-service/natal-service.js +179 -0
  10. package/dist/astro-service/rising-sign-service.d.ts +37 -0
  11. package/dist/astro-service/rising-sign-service.js +137 -0
  12. package/dist/astro-service/service-types.d.ts +82 -0
  13. package/dist/astro-service/service-types.js +1 -0
  14. package/dist/astro-service/shared.d.ts +65 -0
  15. package/dist/astro-service/shared.js +98 -0
  16. package/dist/astro-service/sky-service.d.ts +48 -0
  17. package/dist/astro-service/sky-service.js +144 -0
  18. package/dist/astro-service/transit-service.d.ts +82 -0
  19. package/dist/astro-service/transit-service.js +353 -0
  20. package/dist/astro-service.d.ts +101 -89
  21. package/dist/astro-service.js +162 -1042
  22. package/dist/tool-registry.js +1 -1
  23. package/docs/product/architecture-boundaries.md +8 -0
  24. package/docs/releases/1.3.0.md +51 -0
  25. package/docs/releases/README.md +17 -0
  26. package/package.json +4 -1
  27. package/src/astro-service/chart-output-service.ts +155 -0
  28. package/src/astro-service/date-input.ts +40 -0
  29. package/src/astro-service/electional-service.ts +395 -0
  30. package/src/astro-service/natal-service.ts +235 -0
  31. package/src/astro-service/rising-sign-service.ts +181 -0
  32. package/src/astro-service/service-types.ts +90 -0
  33. package/src/astro-service/shared.ts +128 -0
  34. package/src/astro-service/sky-service.ts +191 -0
  35. package/src/astro-service/transit-service.ts +507 -0
  36. package/src/astro-service.ts +177 -1386
  37. package/src/tool-registry.ts +1 -1
  38. package/tests/README.md +15 -0
  39. package/tests/property/electional-service.property.test.ts +67 -0
  40. package/tests/property/helpers/arbitraries.ts +126 -0
  41. package/tests/property/helpers/config.ts +52 -0
  42. package/tests/property/helpers/runtime.ts +12 -0
  43. package/tests/property/houses.property.test.ts +74 -0
  44. package/tests/property/rising-sign-service.property.test.ts +255 -0
  45. package/tests/property/service-transits.property.test.ts +154 -0
  46. package/tests/property/time-utils.property.test.ts +91 -0
  47. package/tests/property/transits.property.test.ts +113 -0
  48. package/tests/unit/astro-service/chart-output-service.test.ts +102 -0
  49. package/tests/unit/astro-service/electional-service.test.ts +182 -0
  50. package/tests/unit/astro-service/natal-service.test.ts +126 -0
  51. package/tests/unit/astro-service/rising-sign-service.test.ts +145 -0
  52. package/tests/unit/astro-service/sky-service.test.ts +130 -0
  53. package/tests/unit/astro-service/transit-service.test.ts +312 -0
  54. package/tests/unit/astro-service.test.ts +136 -781
  55. package/tests/unit/rising-sign-windows.test.ts +93 -0
  56. package/tests/unit/tool-registry.test.ts +11 -0
  57. package/tests/validation/README.md +14 -0
  58. package/tests/validation/adapters/internal.ts +234 -4
  59. package/tests/validation/compare/electional.ts +151 -0
  60. package/tests/validation/compare/rising-sign-windows.ts +347 -0
  61. package/tests/validation/compare/service-transits.ts +205 -0
  62. package/tests/validation/fixtures/electional/core.ts +88 -0
  63. package/tests/validation/fixtures/rising-sign-windows/core.ts +57 -0
  64. package/tests/validation/fixtures/service-transits/core.ts +89 -0
  65. package/tests/validation/utils/fixtureTypes.ts +139 -1
  66. package/tests/validation/validation.spec.ts +82 -0
@@ -1,43 +1,30 @@
1
1
  import { writeFile } from 'node:fs/promises';
2
- import { Temporal } from '@js-temporal/polyfill';
2
+ import { ChartOutputService } from './astro-service/chart-output-service.js';
3
+ import { ElectionalService } from './astro-service/electional-service.js';
4
+ import { NatalService } from './astro-service/natal-service.js';
5
+ import { RisingSignService } from './astro-service/rising-sign-service.js';
6
+ import type {
7
+ GenerateChartInput,
8
+ GenerateTransitChartInput,
9
+ GetElectionalContextInput,
10
+ GetHousesInput,
11
+ GetRisingSignWindowsInput,
12
+ GetTransitsInput,
13
+ ServiceResult,
14
+ SetNatalChartInput,
15
+ } from './astro-service/service-types.js';
16
+ import { resolveReportingTimezone } from './astro-service/shared.js';
17
+ import { SkyService } from './astro-service/sky-service.js';
18
+ import { TransitService } from './astro-service/transit-service.js';
3
19
  import { ChartRenderer } from './charts.js';
4
- import { getDefaultTheme } from './constants.js';
5
20
  import { EclipseCalculator } from './eclipses.js';
6
21
  import type { McpStartupDefaults } from './entrypoint.js';
7
22
  import { EphemerisCalculator } from './ephemeris.js';
8
- import { formatDateOnly, formatInTimezone } from './formatter.js';
23
+ import { formatInTimezone } from './formatter.js';
9
24
  import { HouseCalculator } from './houses.js';
10
25
  import { RiseSetCalculator } from './riseset.js';
11
- import {
12
- addLocalDays,
13
- type Disambiguation,
14
- formatLocalTimestampWithOffset,
15
- localToUTC,
16
- utcToLocal,
17
- } from './time-utils.js';
18
- import { deduplicateTransits, TransitCalculator } from './transits.js';
19
- import {
20
- ASPECTS,
21
- ASTEROIDS,
22
- type AspectType,
23
- type ElectionalAspect,
24
- type ElectionalContextResponse,
25
- type ElectionalHouseSystem,
26
- type ElectionalPhaseName,
27
- type HouseData,
28
- type HouseSystem,
29
- type NatalChart,
30
- NODES,
31
- OUTER_PLANETS,
32
- PERSONAL_PLANETS,
33
- PLANET_NAMES,
34
- PLANETS,
35
- type PlanetName,
36
- type PlanetPosition,
37
- type Transit,
38
- type TransitResponse,
39
- ZODIAC_SIGNS,
40
- } from './types.js';
26
+ import { TransitCalculator } from './transits.js';
27
+ import type { NatalChart } from './types.js';
41
28
 
42
29
  interface AstroServiceDependencies {
43
30
  ephem?: EphemerisCalculator;
@@ -51,93 +38,6 @@ interface AstroServiceDependencies {
51
38
  writeFile?: (path: string, data: string | Buffer, encoding?: BufferEncoding) => Promise<void>;
52
39
  }
53
40
 
54
- export interface SetNatalChartInput {
55
- name: string;
56
- year: number;
57
- month: number;
58
- day: number;
59
- hour: number;
60
- minute: number;
61
- latitude: number;
62
- longitude: number;
63
- timezone: string;
64
- house_system?: HouseSystem;
65
- birth_time_disambiguation?: Disambiguation;
66
- }
67
-
68
- export interface GetTransitsInput {
69
- date?: string;
70
- categories?: string[];
71
- include_mundane?: boolean;
72
- days_ahead?: number;
73
- mode?: 'snapshot' | 'best_hit' | 'forecast';
74
- max_orb?: number;
75
- exact_only?: boolean;
76
- applying_only?: boolean;
77
- }
78
-
79
- export interface GetElectionalContextInput {
80
- date: string;
81
- time: string;
82
- timezone: string;
83
- latitude: number;
84
- longitude: number;
85
- house_system?: ElectionalHouseSystem;
86
- include_ruler_basics?: boolean;
87
- include_planetary_applications?: boolean;
88
- orb_degrees?: number;
89
- }
90
-
91
- export interface GetHousesInput {
92
- system?: string;
93
- }
94
-
95
- export interface GetRisingSignWindowsInput {
96
- date: string;
97
- latitude: number;
98
- longitude: number;
99
- timezone: string;
100
- mode?: 'approximate' | 'exact';
101
- }
102
-
103
- export interface GenerateChartInput {
104
- theme?: 'light' | 'dark';
105
- format?: 'svg' | 'png' | 'webp';
106
- output_path?: string;
107
- }
108
-
109
- export interface GenerateTransitChartInput extends GenerateChartInput {
110
- date?: string;
111
- }
112
-
113
- export interface ServiceResult<T> {
114
- data: T;
115
- text: string;
116
- }
117
-
118
- export interface MundaneAspect {
119
- id: string;
120
- planetA: PlanetPosition['planet'];
121
- planetB: PlanetPosition['planet'];
122
- aspect: AspectType;
123
- orb: number;
124
- isApplying: boolean;
125
- longitudeA: number;
126
- longitudeB: number;
127
- }
128
-
129
- interface MundaneWeather {
130
- supportive: string[];
131
- challenging: string[];
132
- }
133
-
134
- interface MundaneDay {
135
- date: string;
136
- timezone: string;
137
- positions: PlanetPosition[];
138
- aspects: MundaneAspect[];
139
- weather: MundaneWeather;
140
- }
141
41
  interface ChartServiceResult {
142
42
  format: 'svg' | 'png' | 'webp';
143
43
  outputPath?: string;
@@ -149,76 +49,25 @@ interface ChartServiceResult {
149
49
  };
150
50
  }
151
51
 
152
- export function parseDateOnlyInput(dateStr: string): {
153
- year: number;
154
- month: number;
155
- day: number;
156
- hour: number;
157
- minute: number;
158
- } {
159
- const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
160
- if (!match) {
161
- throw new Error(`Invalid date format: expected YYYY-MM-DD, got "${dateStr}"`);
162
- }
163
-
164
- const year = Number(match[1]);
165
- const month = Number(match[2]);
166
- const day = Number(match[3]);
167
-
168
- if (month < 1 || month > 12) {
169
- throw new Error(`Invalid month: ${month} (must be 1-12)`);
170
- }
171
- if (day < 1 || day > 31) {
172
- throw new Error(`Invalid day: ${day} (must be 1-31)`);
173
- }
174
-
175
- try {
176
- Temporal.PlainDate.from({ year, month, day });
177
- } catch {
178
- throw new Error(`Invalid calendar date: ${dateStr}`);
179
- }
180
-
181
- return { year, month, day, hour: 12, minute: 0 };
182
- }
183
-
184
- function parseTimeOnlyInput(timeStr: string): { hour: number; minute: number; second: number } {
185
- const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(timeStr);
186
- if (!match) {
187
- throw new Error(`Invalid time format: expected HH:mm[:ss], got "${timeStr}"`);
188
- }
189
-
190
- const hour = Number(match[1]);
191
- const minute = Number(match[2]);
192
- const second = match[3] === undefined ? 0 : Number(match[3]);
193
-
194
- if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
195
- throw new Error(`Invalid clock time: ${timeStr}`);
196
- }
197
-
198
- try {
199
- Temporal.PlainTime.from({ hour, minute, second });
200
- } catch {
201
- throw new Error(`Invalid clock time: ${timeStr}`);
202
- }
203
-
204
- return { hour, minute, second };
205
- }
206
-
207
- const ELECTIONAL_CONTEXT_PLANET_IDS = [
208
- PLANETS.SUN,
209
- PLANETS.MOON,
210
- PLANETS.MERCURY,
211
- PLANETS.VENUS,
212
- PLANETS.MARS,
213
- PLANETS.JUPITER,
214
- PLANETS.SATURN,
215
- PLANETS.URANUS,
216
- PLANETS.NEPTUNE,
217
- PLANETS.PLUTO,
218
- ];
219
-
220
- const ELECTIONAL_CONTEXT_HOUSE_SYSTEMS: ElectionalHouseSystem[] = ['P', 'K', 'W', 'R'];
221
-
52
+ export { parseDateOnlyInput } from './astro-service/date-input.js';
53
+ export type {
54
+ GenerateChartInput,
55
+ GenerateTransitChartInput,
56
+ GetElectionalContextInput,
57
+ GetHousesInput,
58
+ GetRisingSignWindowsInput,
59
+ GetTransitsInput,
60
+ ServiceResult,
61
+ SetNatalChartInput,
62
+ } from './astro-service/service-types.js';
63
+
64
+ /**
65
+ * Shared service facade used by both the MCP server and the CLI.
66
+ *
67
+ * @remarks
68
+ * Public methods remain the stable orchestration boundary while domain-specific
69
+ * internals can be extracted behind the class without changing callers.
70
+ */
222
71
  export class AstroService {
223
72
  readonly ephem: EphemerisCalculator;
224
73
  readonly transitCalc: TransitCalculator;
@@ -227,6 +76,12 @@ export class AstroService {
227
76
  readonly eclipseCalc: EclipseCalculator;
228
77
  readonly chartRenderer: ChartRenderer;
229
78
  readonly mcpStartupDefaults: Readonly<McpStartupDefaults>;
79
+ private readonly transitService: TransitService;
80
+ private readonly electionalService: ElectionalService;
81
+ private readonly risingSignService: RisingSignService;
82
+ private readonly natalService: NatalService;
83
+ private readonly skyService: SkyService;
84
+ private readonly chartOutputService: ChartOutputService;
230
85
  private readonly now: () => Date;
231
86
  private readonly writeFileFn: (
232
87
  path: string,
@@ -244,1268 +99,204 @@ export class AstroService {
244
99
  this.mcpStartupDefaults = Object.freeze({ ...(deps.mcpStartupDefaults ?? {}) });
245
100
  this.now = deps.now ?? (() => new Date());
246
101
  this.writeFileFn = deps.writeFile ?? writeFile;
102
+ this.transitService = new TransitService({
103
+ ephem: this.ephem,
104
+ transitCalc: this.transitCalc,
105
+ houseCalc: this.houseCalc,
106
+ mcpStartupDefaults: this.mcpStartupDefaults,
107
+ now: this.now,
108
+ formatTimestamp: this.formatTimestamp.bind(this),
109
+ });
110
+ this.electionalService = new ElectionalService({
111
+ ephem: this.ephem,
112
+ houseCalc: this.houseCalc,
113
+ });
114
+ this.risingSignService = new RisingSignService({
115
+ ephem: this.ephem,
116
+ houseCalc: this.houseCalc,
117
+ });
118
+ this.natalService = new NatalService({
119
+ ephem: this.ephem,
120
+ houseCalc: this.houseCalc,
121
+ mcpStartupDefaults: this.mcpStartupDefaults,
122
+ isInitialized: this.isInitialized.bind(this),
123
+ });
124
+ this.skyService = new SkyService({
125
+ ephem: this.ephem,
126
+ riseSetCalc: this.riseSetCalc,
127
+ eclipseCalc: this.eclipseCalc,
128
+ mcpStartupDefaults: this.mcpStartupDefaults,
129
+ now: this.now,
130
+ formatTimestamp: this.formatTimestamp.bind(this),
131
+ });
132
+ this.chartOutputService = new ChartOutputService({
133
+ chartRenderer: this.chartRenderer,
134
+ now: this.now,
135
+ writeFile: this.writeFileFn,
136
+ });
247
137
  }
248
138
 
139
+ /**
140
+ * Format user-facing timestamps using the current startup default weekday policy.
141
+ */
249
142
  private formatTimestamp(date: Date, timezone: string): string {
250
143
  return formatInTimezone(date, timezone, {
251
144
  weekday: this.mcpStartupDefaults.weekdayLabels ?? false,
252
145
  });
253
146
  }
254
147
 
255
- private normalizeLongitude(longitude: number): number {
256
- return ((longitude % 360) + 360) % 360;
257
- }
258
-
259
- private getSignAndDegree(longitude: number): { sign: string; degree: number } {
260
- const normalized = this.normalizeLongitude(longitude);
261
- const baseSignIndex = Math.floor(normalized / 30);
262
- const roundedDegree = Number.parseFloat((normalized % 30).toFixed(2));
263
- const shouldCarryToNextSign = roundedDegree >= 30;
264
- const signIndex = shouldCarryToNextSign
265
- ? (baseSignIndex + 1) % ZODIAC_SIGNS.length
266
- : baseSignIndex;
267
- return {
268
- sign: ZODIAC_SIGNS[signIndex],
269
- degree: shouldCarryToNextSign ? 0 : roundedDegree,
270
- };
271
- }
272
-
273
- private getHouseNumber(longitude: number, houses: HouseData): number {
274
- const normalized = this.normalizeLongitude(longitude);
275
-
276
- for (let house = 1; house <= 12; house++) {
277
- const start = this.normalizeLongitude(houses.cusps[house]);
278
- const nextHouse = house === 12 ? 1 : house + 1;
279
- const end = this.normalizeLongitude(houses.cusps[nextHouse]);
280
- const span = (end - start + 360) % 360;
281
- const offset = (normalized - start + 360) % 360;
282
-
283
- if (span === 0 || offset === 0 || offset < span) {
284
- return house;
285
- }
286
- }
287
-
288
- return 12;
289
- }
290
-
291
- private resolveHouseSystem(natalChart: NatalChart, explicitSystem?: string): HouseSystem {
292
- return (explicitSystem ||
293
- natalChart.requestedHouseSystem ||
294
- this.mcpStartupDefaults.preferredHouseStyle ||
295
- natalChart.houseSystem ||
296
- 'P') as HouseSystem;
297
- }
298
-
299
- private resolveTimezones(explicitReportingTimezone?: string, natalTimezone?: string) {
300
- return {
301
- calculationTimezone: natalTimezone ?? 'UTC',
302
- reportingTimezone: this.resolveReportingTimezone(explicitReportingTimezone, natalTimezone),
303
- };
304
- }
305
-
148
+ /**
149
+ * Resolve the timezone used for user-facing timestamps and labels.
150
+ *
151
+ * @remarks
152
+ * Explicit per-call timezone wins, then startup defaults, then the natal chart
153
+ * timezone, and finally UTC.
154
+ */
306
155
  resolveReportingTimezone(explicitTimezone?: string, natalTimezone?: string): string {
307
- return explicitTimezone ?? this.mcpStartupDefaults.preferredTimezone ?? natalTimezone ?? 'UTC';
156
+ return resolveReportingTimezone(this.mcpStartupDefaults, explicitTimezone, natalTimezone);
308
157
  }
309
158
 
159
+ /**
160
+ * Initialize the underlying ephemeris engine.
161
+ */
310
162
  async init(): Promise<void> {
311
163
  await this.ephem.init();
312
164
  }
313
165
 
166
+ /**
167
+ * Report whether the ephemeris engine has been initialized.
168
+ */
314
169
  isInitialized(): boolean {
315
170
  return !!this.ephem.eph;
316
171
  }
317
172
 
173
+ /**
174
+ * Build and cache the shared natal chart payload used by later workflows.
175
+ *
176
+ * @remarks
177
+ * This preserves the existing natal contract, including polar-latitude house
178
+ * fallback behavior and the current user-facing summary text.
179
+ */
318
180
  setNatalChart(
319
181
  input: SetNatalChartInput
320
182
  ): ServiceResult<Record<string, unknown>> & { chart: NatalChart } {
321
- const requestedHouseSystem = input.house_system ?? null;
322
-
323
- const chart: NatalChart = {
324
- name: input.name,
325
- birthDate: {
326
- year: input.year,
327
- month: input.month,
328
- day: input.day,
329
- hour: input.hour,
330
- minute: input.minute,
331
- },
332
- location: {
333
- latitude: input.latitude,
334
- longitude: input.longitude,
335
- timezone: input.timezone,
336
- },
337
- };
338
-
339
- const birthTimeDisambiguation = input.birth_time_disambiguation ?? 'reject';
340
- const utcDate = localToUTC(chart.birthDate, chart.location.timezone, birthTimeDisambiguation);
341
- const utcComponents = utcToLocal(utcDate, 'UTC');
342
-
343
- const jd = this.ephem.dateToJulianDay(utcDate);
344
- const planetIds = Object.values(PLANETS);
345
- const positions = this.ephem.getAllPlanets(jd, planetIds);
346
-
347
- const isPolar = Math.abs(chart.location.latitude) > 66;
348
- let houseSystem: HouseSystem = requestedHouseSystem || 'P';
349
- if (isPolar && houseSystem === 'P') {
350
- houseSystem = 'W';
351
- }
352
-
353
- const houses = this.houseCalc.calculateHouses(
354
- jd,
355
- chart.location.latitude,
356
- chart.location.longitude,
357
- houseSystem
358
- );
359
-
360
- const storedChart: NatalChart = {
361
- ...chart,
362
- planets: positions,
363
- julianDay: jd,
364
- houseSystem: houses.system,
365
- requestedHouseSystem: requestedHouseSystem ?? undefined,
366
- utcDateTime: utcComponents,
367
- };
368
-
369
- const sun = positions.find((p) => p.planet === 'Sun');
370
- const moon = positions.find((p) => p.planet === 'Moon');
371
- if (!sun || !moon) {
372
- throw new Error('Ephemeris failed to compute Sun/Moon positions for natal chart.');
373
- }
374
-
375
- const formatDegree = (lon: number): string => {
376
- const sign = ZODIAC_SIGNS[Math.floor(lon / 30)];
377
- const degree = lon % 30;
378
- return `${degree.toFixed(0)}° ${sign}`;
379
- };
380
-
381
- const localTimeStr = `${chart.birthDate.month}/${chart.birthDate.day}/${chart.birthDate.year} ${chart.birthDate.hour}:${String(chart.birthDate.minute).padStart(2, '0')}`;
382
- const utcTimeStr = `${utcComponents.month}/${utcComponents.day}/${utcComponents.year} ${utcComponents.hour}:${String(utcComponents.minute).padStart(2, '0')} UTC`;
383
-
384
- const systemNames: Record<string, string> = {
385
- P: 'Placidus',
386
- W: 'Whole Sign',
387
- K: 'Koch',
388
- E: 'Equal',
389
- };
390
-
391
- const latDir = chart.location.latitude >= 0 ? 'N' : 'S';
392
- const lonDir = chart.location.longitude >= 0 ? 'E' : 'W';
393
- const latAbs = Math.abs(chart.location.latitude);
394
- const lonAbs = Math.abs(chart.location.longitude);
395
-
396
- const feedback = [
397
- `Natal chart saved for ${chart.name}`,
398
- '',
399
- 'Birth Details:',
400
- `- Local Time: ${localTimeStr} (${chart.location.timezone})`,
401
- `- UTC Time: ${utcTimeStr}`,
402
- `- Location: ${latAbs.toFixed(2)}°${latDir}, ${lonAbs.toFixed(2)}°${lonDir}`,
403
- '',
404
- 'Chart Angles:',
405
- `- Sun: ${formatDegree(sun.longitude)}`,
406
- `- Moon: ${formatDegree(moon.longitude)}`,
407
- `- Ascendant: ${formatDegree(houses.ascendant)}`,
408
- `- MC: ${formatDegree(houses.mc)}`,
409
- '',
410
- `House System: ${systemNames[houses.system] || houses.system}`,
411
- ];
412
-
413
- if (isPolar && houses.system !== houseSystem) {
414
- feedback.push(
415
- '',
416
- `Note: Polar latitude detected (${chart.location.latitude.toFixed(1)}°). Requested ${systemNames[houseSystem]}, using ${systemNames[houses.system]} instead.`
417
- );
418
- } else if (isPolar) {
419
- feedback.push(
420
- '',
421
- `Note: Polar latitude detected (${chart.location.latitude.toFixed(1)}°). Using ${systemNames[houses.system]} house system.`
422
- );
423
- }
424
-
425
- const structuredData: Record<string, unknown> = {
426
- name: chart.name,
427
- birthTime: {
428
- local: localTimeStr,
429
- utc: utcTimeStr,
430
- timezone: chart.location.timezone,
431
- },
432
- location: {
433
- latitude: chart.location.latitude,
434
- longitude: chart.location.longitude,
435
- },
436
- julianDay: jd,
437
- requestedHouseSystem,
438
- resolvedHouseSystem: houses.system,
439
- angles: {
440
- sun: formatDegree(sun.longitude),
441
- moon: formatDegree(moon.longitude),
442
- ascendant: formatDegree(houses.ascendant),
443
- mc: formatDegree(houses.mc),
444
- },
445
- isPolar,
446
- };
447
-
448
- return {
449
- chart: storedChart,
450
- data: structuredData,
451
- text: feedback.join('\n'),
452
- };
183
+ return this.natalService.setNatalChart(input);
453
184
  }
454
185
 
186
+ /**
187
+ * Calculate natal transits while preserving the public service contract.
188
+ *
189
+ * @remarks
190
+ * Transit day interpretation uses the natal chart timezone for calculation and
191
+ * may use a different reporting timezone for labels when startup defaults are set.
192
+ */
455
193
  getTransits(
456
194
  natalChart: NatalChart,
457
195
  input: GetTransitsInput = {}
458
196
  ): ServiceResult<Record<string, unknown>> {
459
- const dateStr = input.date;
460
- const categories = input.categories ?? ['all'];
461
- const includeMundane = input.include_mundane ?? false;
462
- const daysAhead = input.days_ahead ?? 0;
463
- const requestedMode = input.mode;
464
- const maxOrb = input.max_orb ?? 8;
465
- const exactOnly = input.exact_only ?? false;
466
- const applyingOnly = input.applying_only ?? false;
467
-
468
- if (!Number.isFinite(daysAhead) || daysAhead < 0) {
469
- throw new Error('days_ahead must be a finite number >= 0');
470
- }
471
- if (!Number.isFinite(maxOrb) || maxOrb < 0) {
472
- throw new Error('max_orb must be a finite number >= 0');
473
- }
474
- if (
475
- requestedMode !== undefined &&
476
- requestedMode !== 'snapshot' &&
477
- requestedMode !== 'best_hit' &&
478
- requestedMode !== 'forecast'
479
- ) {
480
- throw new Error('mode must be one of: snapshot, best_hit, forecast');
481
- }
482
- if (!natalChart.julianDay) {
483
- throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
484
- }
485
-
486
- const mode = requestedMode ?? (daysAhead === 0 ? 'snapshot' : 'best_hit');
487
- const modeSource = requestedMode === undefined ? 'legacy_default' : 'explicit';
488
-
489
- let transitingPlanetIds: number[] = [];
490
- if (categories.includes('all')) {
491
- transitingPlanetIds = Object.values(PLANETS);
492
- } else {
493
- if (categories.includes('moon')) transitingPlanetIds.push(PLANETS.MOON);
494
- if (categories.includes('personal')) {
495
- transitingPlanetIds.push(
496
- ...PERSONAL_PLANETS.filter((p) => !transitingPlanetIds.includes(p))
497
- );
498
- }
499
- if (categories.includes('outer')) {
500
- transitingPlanetIds.push(...OUTER_PLANETS.filter((p) => !transitingPlanetIds.includes(p)));
501
- }
502
- }
503
-
504
- const { calculationTimezone, reportingTimezone } = this.resolveTimezones(
505
- undefined,
506
- natalChart.location.timezone
507
- );
508
-
509
- let targetDate: Date;
510
- if (dateStr) {
511
- const parsed = parseDateOnlyInput(dateStr);
512
- targetDate = localToUTC(parsed, calculationTimezone);
513
- } else {
514
- const now = this.now();
515
- const localNow = utcToLocal(now, calculationTimezone);
516
- const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
517
- targetDate = localToUTC(localNoon, calculationTimezone);
518
- }
519
-
520
- const allTransits: Transit[] = [];
521
- const transitsByDay = new Map<string, Transit[]>();
522
- const transitContext = new WeakMap<Transit, { julianDay: number }>();
523
- const startLocal = utcToLocal(targetDate, calculationTimezone);
524
- const effectiveDaysAhead = mode === 'snapshot' ? 0 : daysAhead;
525
- for (let day = 0; day <= effectiveDaysAhead; day++) {
526
- const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
527
- const jd = this.ephem.dateToJulianDay(dayUTC);
528
- const transitingPlanets = this.ephem.getAllPlanets(jd, transitingPlanetIds);
529
- const transits = this.transitCalc.findTransits(
530
- transitingPlanets,
531
- natalChart.planets || [],
532
- jd
533
- );
534
- for (const transit of transits) {
535
- transitContext.set(transit, { julianDay: jd });
536
- }
537
- allTransits.push(...transits);
538
- const dayLocal = utcToLocal(dayUTC, reportingTimezone);
539
- const dayLabel = `${dayLocal.year}-${String(dayLocal.month).padStart(2, '0')}-${String(dayLocal.day).padStart(2, '0')}`;
540
- transitsByDay.set(dayLabel, transits);
541
- }
542
-
543
- const filterTransits = (transits: Transit[]): Transit[] => {
544
- let filtered = transits.filter((t) => t.orb <= maxOrb);
545
- if (exactOnly) filtered = filtered.filter((t) => t.exactTime !== undefined);
546
- if (applyingOnly) filtered = filtered.filter((t) => t.isApplying);
547
- filtered.sort((a, b) => a.orb - b.orb);
548
- return filtered;
549
- };
550
- const chartHouseSystem = this.resolveHouseSystem(natalChart);
551
- const natalHouses = this.houseCalc.calculateHouses(
552
- natalChart.julianDay,
553
- natalChart.location.latitude,
554
- natalChart.location.longitude,
555
- chartHouseSystem
556
- );
557
- const transitHouseCache = new Map<number, HouseData>();
558
- const planetIdsByName = new Map(
559
- Object.entries(PLANET_NAMES).map(([id, name]) => [name, Number(id)])
560
- );
561
- const getTransitHouses = (julianDay: number): HouseData => {
562
- const cached = transitHouseCache.get(julianDay);
563
- if (cached) {
564
- return cached;
565
- }
566
-
567
- const houses = this.houseCalc.calculateHouses(
568
- julianDay,
569
- natalChart.location.latitude,
570
- natalChart.location.longitude,
571
- chartHouseSystem
572
- );
573
- transitHouseCache.set(julianDay, houses);
574
- return houses;
575
- };
576
- const serializeTransit = (t: Transit) => {
577
- const transitPlacement = this.getSignAndDegree(t.transitLongitude);
578
- const natalPlacement = this.getSignAndDegree(t.natalLongitude);
579
- const context = transitContext.get(t);
580
- const transitHouseJulianDay = t.exactTime
581
- ? this.ephem.dateToJulianDay(t.exactTime)
582
- : (context?.julianDay ?? this.ephem.dateToJulianDay(targetDate));
583
- const transitHouses = getTransitHouses(transitHouseJulianDay);
584
- const exactTransitLongitude =
585
- t.exactTime && planetIdsByName.has(t.transitingPlanet)
586
- ? this.ephem.getPlanetPosition(
587
- planetIdsByName.get(t.transitingPlanet) as number,
588
- transitHouseJulianDay
589
- ).longitude
590
- : t.transitLongitude;
591
-
592
- return {
593
- transitingPlanet: t.transitingPlanet,
594
- aspect: t.aspect,
595
- natalPlanet: t.natalPlanet,
596
- orb: Number.parseFloat(t.orb.toFixed(2)),
597
- isApplying: t.isApplying,
598
- exactTimeStatus: t.exactTimeStatus,
599
- exactTime: t.exactTime?.toISOString(),
600
- transitLongitude: t.transitLongitude,
601
- natalLongitude: t.natalLongitude,
602
- transitSign: transitPlacement.sign,
603
- transitDegree: transitPlacement.degree,
604
- transitHouse: this.getHouseNumber(exactTransitLongitude, transitHouses),
605
- natalSign: natalPlacement.sign,
606
- natalDegree: natalPlacement.degree,
607
- natalHouse: this.getHouseNumber(t.natalLongitude, natalHouses),
608
- };
609
- };
610
-
611
- const filteredTransits =
612
- mode === 'forecast'
613
- ? filterTransits(deduplicateTransits(allTransits))
614
- : filterTransits(deduplicateTransits(allTransits));
615
-
616
- const localDate = utcToLocal(targetDate, reportingTimezone);
617
- const dateLabel = `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
618
- const endLocal = utcToLocal(
619
- addLocalDays(startLocal, calculationTimezone, effectiveDaysAhead),
620
- reportingTimezone
621
- );
622
- const windowEndLabel = `${endLocal.year}-${String(endLocal.month).padStart(2, '0')}-${String(endLocal.day).padStart(2, '0')}`;
623
-
624
- const structuredData: TransitResponse = {
625
- date: dateLabel,
626
- timezone: reportingTimezone,
627
- calculation_timezone: calculationTimezone,
628
- reporting_timezone: reportingTimezone,
629
- transits: filteredTransits.map(serializeTransit),
630
- };
631
-
632
- const metadata = {
633
- mode,
634
- mode_source: modeSource,
635
- days_ahead: effectiveDaysAhead,
636
- window_start: dateLabel,
637
- window_end: windowEndLabel,
638
- };
639
-
640
- let responseData: Record<string, unknown> = structuredData as unknown as Record<
641
- string,
642
- unknown
643
- >;
644
- let mundaneText = '';
645
-
646
- if (mode === 'forecast') {
647
- const forecastDays = Array.from(transitsByDay.entries())
648
- .sort(([a], [b]) => a.localeCompare(b))
649
- .map(([dayDate, dayTransits]) => ({
650
- date: dayDate,
651
- transits: filterTransits(deduplicateTransits(dayTransits)).map(serializeTransit),
652
- }));
653
- responseData = {
654
- ...metadata,
655
- timezone: reportingTimezone,
656
- calculation_timezone: calculationTimezone,
657
- reporting_timezone: reportingTimezone,
658
- forecast: forecastDays,
659
- };
660
- } else {
661
- responseData = {
662
- ...structuredData,
663
- ...metadata,
664
- };
665
- }
666
-
667
- if (includeMundane) {
668
- const mundaneDays: MundaneDay[] = [];
669
- for (let day = 0; day <= daysAhead; day++) {
670
- const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
671
- mundaneDays.push(this.getMundaneDay(dayUTC, reportingTimezone, transitingPlanetIds));
672
- }
673
-
674
- const [anchorMundane] = mundaneDays;
675
- const mundaneData = {
676
- date: anchorMundane.date,
677
- timezone: anchorMundane.timezone,
678
- positions: anchorMundane.positions,
679
- aspects: anchorMundane.aspects,
680
- days: mundaneDays,
681
- };
682
- responseData = { transits: responseData, mundane: mundaneData };
683
- mundaneText = `\n\nCurrent Planetary Positions:\n\n${anchorMundane.positions
684
- .map(
685
- (p) =>
686
- `${p.planet}: ${p.degree.toFixed(1)}° ${p.sign} (${p.isRetrograde ? 'Rx' : 'Direct'})`
687
- )
688
- .join('\n')}`;
689
- if (mode === 'forecast') {
690
- mundaneText +=
691
- '\n\nNote: mundane positions remain anchored to the forecast start date in this mode.';
692
- }
693
- }
694
-
695
- const formatHumanTransit = (t: Transit) => {
696
- const exactStr = t.exactTime
697
- ? ` - Exact: ${this.formatTimestamp(t.exactTime, reportingTimezone)}`
698
- : '';
699
- const applyStr = t.isApplying ? '(applying)' : '(separating)';
700
- return `${t.transitingPlanet} ${t.aspect} ${t.natalPlanet}: ${t.orb.toFixed(2)}° orb ${applyStr}${exactStr}`;
701
- };
702
- const rangeStr = effectiveDaysAhead > 0 ? ` (next ${effectiveDaysAhead + 1} days)` : '';
703
- let transitHeader: string;
704
- if (mode === 'forecast') {
705
- const forecastLines = Array.from(transitsByDay.entries())
706
- .sort(([a], [b]) => a.localeCompare(b))
707
- .map(([dayDate, dayTransits]) => {
708
- const dedupedDay = filterTransits(deduplicateTransits(dayTransits));
709
- const lines =
710
- dedupedDay.length === 0
711
- ? 'No transits found matching the specified criteria.'
712
- : dedupedDay.map(formatHumanTransit).join('\n');
713
- return `${dayDate}:\n${lines}`;
714
- })
715
- .join('\n\n');
716
- transitHeader = `Forecast transits${rangeStr}:\n\n${forecastLines}`;
717
- } else {
718
- const humanLines = filteredTransits.map(formatHumanTransit).join('\n');
719
- const modeLabel = mode === 'snapshot' ? 'Transit snapshot' : 'Best-hit transits';
720
- transitHeader =
721
- filteredTransits.length > 0
722
- ? `${modeLabel}${rangeStr}:\n\n${humanLines}`
723
- : 'No transits found matching the specified criteria.';
724
- }
725
-
726
- return {
727
- data: responseData,
728
- text: transitHeader + mundaneText,
729
- };
730
- }
731
-
732
- private getMundaneWeather(aspects: MundaneAspect[]): MundaneWeather {
733
- const supportiveAspects = new Set<AspectType>(['conjunction', 'trine', 'sextile']);
734
- const challengingAspects = new Set<AspectType>(['square', 'opposition']);
735
-
736
- return {
737
- supportive: aspects.filter((a) => supportiveAspects.has(a.aspect)).map((a) => a.id),
738
- challenging: aspects.filter((a) => challengingAspects.has(a.aspect)).map((a) => a.id),
739
- };
740
- }
741
-
742
- private getMundaneAspects(date: string, positions: PlanetPosition[]): MundaneAspect[] {
743
- const aspects: MundaneAspect[] = [];
744
-
745
- for (let i = 0; i < positions.length; i++) {
746
- for (let j = i + 1; j < positions.length; j++) {
747
- const planetA = positions[i];
748
- const planetB = positions[j];
749
-
750
- const angle = this.ephem.calculateAspectAngle(planetA.longitude, planetB.longitude);
751
-
752
- for (const aspect of ASPECTS) {
753
- const orb = Math.abs(angle - aspect.angle);
754
- if (orb > aspect.orb) {
755
- continue;
756
- }
757
-
758
- const futureLongitudeA = (planetA.longitude + planetA.speed * 0.1 + 360) % 360;
759
- const futureLongitudeB = (planetB.longitude + planetB.speed * 0.1 + 360) % 360;
760
- const futureAngle = this.ephem.calculateAspectAngle(futureLongitudeA, futureLongitudeB);
761
- const futureOrb = Math.abs(futureAngle - aspect.angle);
762
-
763
- aspects.push({
764
- id: `${date}:${planetA.planet}-${aspect.name}-${planetB.planet}`,
765
- planetA: planetA.planet,
766
- planetB: planetB.planet,
767
- aspect: aspect.name,
768
- orb: Number.parseFloat(orb.toFixed(2)),
769
- isApplying: futureOrb < orb,
770
- longitudeA: planetA.longitude,
771
- longitudeB: planetB.longitude,
772
- });
773
- }
774
- }
775
- }
776
-
777
- return aspects.sort(
778
- (a, b) =>
779
- a.orb - b.orb ||
780
- a.planetA.localeCompare(b.planetA) ||
781
- a.planetB.localeCompare(b.planetB) ||
782
- a.aspect.localeCompare(b.aspect)
783
- );
784
- }
785
-
786
- private getMundaneDay(dayUTC: Date, timezone: string, transitingPlanetIds: number[]): MundaneDay {
787
- const localDay = utcToLocal(dayUTC, timezone);
788
- const dateLabel = `${localDay.year}-${String(localDay.month).padStart(2, '0')}-${String(localDay.day).padStart(2, '0')}`;
789
- const currentJD = this.ephem.dateToJulianDay(dayUTC);
790
- const positions = this.ephem.getAllPlanets(currentJD, transitingPlanetIds);
791
- const aspects = this.getMundaneAspects(dateLabel, positions);
792
-
793
- return {
794
- date: dateLabel,
795
- timezone,
796
- positions,
797
- aspects,
798
- weather: this.getMundaneWeather(aspects),
799
- };
197
+ return this.transitService.getTransits(natalChart, input);
800
198
  }
801
199
 
200
+ /**
201
+ * Produce deterministic electional context for a single local instant.
202
+ *
203
+ * @remarks
204
+ * Electional local times keep strict DST rejection semantics for ambiguous or
205
+ * nonexistent wall-clock instants.
206
+ */
802
207
  getElectionalContext(input: GetElectionalContextInput): ServiceResult<Record<string, unknown>> {
803
- if (input.latitude < -90 || input.latitude > 90) {
804
- throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
805
- }
806
- if (input.longitude < -180 || input.longitude > 180) {
807
- throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
808
- }
809
-
810
- const houseSystem = input.house_system ?? 'P';
811
- if (!ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.includes(houseSystem)) {
812
- throw new Error(
813
- `Invalid house_system: ${houseSystem} (must be one of ${ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.join(', ')})`
814
- );
815
- }
816
-
817
- const includeRulerBasics = input.include_ruler_basics ?? false;
818
- const includePlanetaryApplications = input.include_planetary_applications ?? true;
819
- const orbDegrees = input.orb_degrees ?? 3;
820
- if (!Number.isFinite(orbDegrees) || orbDegrees < 0.1 || orbDegrees > 10) {
821
- throw new Error(`Invalid orb_degrees: ${orbDegrees} (must be between 0.1 and 10)`);
822
- }
823
-
824
- const parsedDate = parseDateOnlyInput(input.date);
825
- const parsedTime = parseTimeOnlyInput(input.time);
826
- let instantUtc: Date;
827
- try {
828
- instantUtc = localToUTC(
829
- {
830
- year: parsedDate.year,
831
- month: parsedDate.month,
832
- day: parsedDate.day,
833
- hour: parsedTime.hour,
834
- minute: parsedTime.minute,
835
- second: parsedTime.second,
836
- },
837
- input.timezone,
838
- 'reject'
839
- );
840
- } catch (error) {
841
- if (error instanceof RangeError) {
842
- throw new Error(
843
- `Invalid local electional time: ${input.date} ${input.time} in ${input.timezone} is ambiguous or nonexistent due to a DST transition.`
844
- );
845
- }
846
- throw error;
847
- }
848
- const jdUt = this.ephem.dateToJulianDay(instantUtc);
849
- const houses = this.houseCalc.calculateHouses(
850
- jdUt,
851
- input.latitude,
852
- input.longitude,
853
- houseSystem
854
- );
855
- const positions = this.ephem.getAllPlanets(jdUt, ELECTIONAL_CONTEXT_PLANET_IDS);
856
-
857
- const sun = positions.find((position) => position.planet === 'Sun');
858
- const moon = positions.find((position) => position.planet === 'Moon');
859
- if (!sun || !moon) {
860
- throw new Error('Ephemeris failed to compute Sun/Moon positions for electional context.');
861
- }
862
-
863
- const sunHorizontal = this.ephem.getHorizontalCoordinates(
864
- jdUt,
865
- sun,
866
- input.longitude,
867
- input.latitude
868
- );
869
- const sunAltitudeDegrees = Number.parseFloat(sunHorizontal.trueAltitude.toFixed(2));
870
- const isDayChart = sunAltitudeDegrees >= 0;
871
-
872
- const applyingAspects = includePlanetaryApplications
873
- ? this.getElectionalApplyingAspects(positions, orbDegrees)
874
- : undefined;
875
- const moonApplyingAspects = applyingAspects?.filter(
876
- (aspect) => aspect.from_body === 'Moon' || aspect.to_body === 'Moon'
877
- );
878
-
879
- const phaseAngle = Number.parseFloat(
880
- ((((moon.longitude - sun.longitude) % 360) + 360) % 360).toFixed(2)
881
- );
882
- const warnings: string[] = [];
883
- if (Math.abs(sunAltitudeDegrees) < 0.5) {
884
- warnings.push('Sun is near the horizon; day/night classification is close to the boundary.');
885
- }
886
- warnings.push('Moon void-of-course is deferred in this slice and returns null.');
887
- if (houses.system !== houseSystem) {
888
- warnings.push(
889
- `House calculation fell back from ${houseSystem} to ${houses.system} for this location.`
890
- );
891
- }
892
-
893
- const ascLongitude = ((houses.ascendant % 360) + 360) % 360;
894
- const ascSign = ZODIAC_SIGNS[Math.floor(ascLongitude / 30)];
895
- const response: ElectionalContextResponse = {
896
- input: {
897
- date: input.date,
898
- time: input.time,
899
- timezone: input.timezone,
900
- latitude: input.latitude,
901
- longitude: input.longitude,
902
- house_system: houses.system as ElectionalHouseSystem,
903
- instant_utc: instantUtc.toISOString(),
904
- jd_ut: Number.parseFloat(jdUt.toFixed(8)),
905
- },
906
- ascendant: {
907
- longitude: Number.parseFloat(ascLongitude.toFixed(4)),
908
- sign: ascSign,
909
- degree_in_sign: Number.parseFloat((ascLongitude % 30).toFixed(4)),
910
- },
911
- sect: {
912
- is_day_chart: isDayChart,
913
- sun_altitude_degrees: sunAltitudeDegrees,
914
- classification: isDayChart ? 'day' : 'night',
915
- },
916
- moon: {
917
- longitude: Number.parseFloat(moon.longitude.toFixed(4)),
918
- sign: moon.sign,
919
- phase_angle: phaseAngle,
920
- phase_name: this.getElectionalPhaseName(phaseAngle),
921
- is_void_of_course: null,
922
- ...(moonApplyingAspects !== undefined ? { applying_aspects: moonApplyingAspects } : {}),
923
- },
924
- meta: {
925
- deterministic: true,
926
- requires_natal: false,
927
- warnings,
928
- deferred_features: [
929
- 'robust_void_of_course',
930
- 'detailed_ruler_condition',
931
- 'house_context',
932
- 'natal_overlays',
933
- ],
934
- },
935
- };
936
-
937
- if (applyingAspects) {
938
- response.applying_aspects = applyingAspects;
939
- }
940
-
941
- if (includeRulerBasics) {
942
- const rulerBody = this.getTraditionalSignRuler(ascSign);
943
- const rulerPosition = positions.find((position) => position.planet === rulerBody);
944
- if (!rulerPosition) {
945
- throw new Error(`Ephemeris failed to compute ASC ruler position for ${rulerBody}.`);
946
- }
947
- response.ruler_basics = {
948
- asc_sign_ruler: {
949
- body: rulerBody,
950
- longitude: Number.parseFloat(rulerPosition.longitude.toFixed(4)),
951
- sign: rulerPosition.sign,
952
- speed: Number.parseFloat(rulerPosition.speed.toFixed(6)),
953
- is_retrograde: rulerPosition.isRetrograde,
954
- },
955
- };
956
- }
957
-
958
- const humanText = [
959
- `Electional context for ${input.date} ${input.time} (${input.timezone})`,
960
- '',
961
- `Ascendant: ${response.ascendant.degree_in_sign.toFixed(2)}° ${response.ascendant.sign}`,
962
- `Sect: ${response.sect.classification} (${response.sect.sun_altitude_degrees.toFixed(2)}° Sun altitude)`,
963
- `Moon: ${response.moon.phase_name} in ${response.moon.sign} (${response.moon.phase_angle.toFixed(2)}° phase angle)`,
964
- ];
965
-
966
- if (includePlanetaryApplications) {
967
- const topLevelAspectText =
968
- applyingAspects && applyingAspects.length > 0
969
- ? applyingAspects
970
- .slice(0, 5)
971
- .map(
972
- (aspect) =>
973
- `${aspect.from_body} ${aspect.aspect} ${aspect.to_body} (${aspect.orb.toFixed(2)}°)`
974
- )
975
- .join('\n')
976
- : 'No applying aspects found within the configured orb.';
977
-
978
- humanText.push('', 'Applying Aspects:', topLevelAspectText);
979
- }
980
-
981
- if (response.ruler_basics) {
982
- humanText.push(
983
- '',
984
- `ASC Ruler: ${response.ruler_basics.asc_sign_ruler.body} in ${response.ruler_basics.asc_sign_ruler.sign} (${response.ruler_basics.asc_sign_ruler.longitude.toFixed(2)}°)`
985
- );
986
- }
987
-
988
- if (warnings.length > 0) {
989
- humanText.push('', `Warnings: ${warnings.join(' ')}`);
990
- }
991
-
992
- return {
993
- data: response as unknown as Record<string, unknown>,
994
- text: humanText.join('\n'),
995
- };
208
+ return this.electionalService.getElectionalContext(input);
996
209
  }
997
210
 
211
+ /**
212
+ * Calculate house cusps and angles for a natal chart.
213
+ *
214
+ * @remarks
215
+ * House-system resolution still respects explicit per-call input, then stored
216
+ * chart preference, then startup defaults.
217
+ */
998
218
  getHouses(
999
219
  natalChart: NatalChart,
1000
220
  input: GetHousesInput = {}
1001
221
  ): ServiceResult<Record<string, unknown>> {
1002
- const system = this.resolveHouseSystem(natalChart, input.system);
1003
- if (!natalChart.julianDay) {
1004
- throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
1005
- }
1006
-
1007
- const houses = this.houseCalc.calculateHouses(
1008
- natalChart.julianDay,
1009
- natalChart.location.latitude,
1010
- natalChart.location.longitude,
1011
- system
1012
- );
1013
-
1014
- const humanLines = houses.cusps
1015
- .slice(1)
1016
- .map((deg: number, i: number) => {
1017
- const sign = ZODIAC_SIGNS[Math.floor(deg / 30)];
1018
- return `House ${i + 1}: ${(deg % 30).toFixed(2)}° ${sign}`;
1019
- })
1020
- .join('\n');
1021
- const humanText = `Houses (${houses.system}):\nAsc: ${houses.ascendant.toFixed(2)}° | MC: ${houses.mc.toFixed(2)}°\n\n${humanLines}`;
1022
-
1023
- return {
1024
- data: houses as unknown as Record<string, unknown>,
1025
- text: humanText,
1026
- };
222
+ return this.natalService.getHouses(natalChart, input);
1027
223
  }
1028
224
 
225
+ /**
226
+ * Find rising-sign windows across a calendar day at a specific location.
227
+ *
228
+ * @remarks
229
+ * `exact` mode refines sign boundaries more aggressively; `approximate` mode
230
+ * keeps the cheaper bucketed scan behavior.
231
+ */
1029
232
  getRisingSignWindows(input: GetRisingSignWindowsInput): ServiceResult<Record<string, unknown>> {
1030
- const mode = input.mode ?? 'approximate';
1031
- if (mode !== 'approximate' && mode !== 'exact') {
1032
- throw new Error(`Invalid mode: ${mode} (must be approximate or exact)`);
1033
- }
1034
- if (input.latitude < -90 || input.latitude > 90) {
1035
- throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
1036
- }
1037
- if (input.longitude < -180 || input.longitude > 180) {
1038
- throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
1039
- }
1040
-
1041
- const parsed = parseDateOnlyInput(input.date);
1042
- try {
1043
- utcToLocal(new Date(), input.timezone);
1044
- } catch {
1045
- throw new Error(`Invalid timezone: ${input.timezone}`);
1046
- }
1047
-
1048
- const dayStartLocal = {
1049
- year: parsed.year,
1050
- month: parsed.month,
1051
- day: parsed.day,
1052
- hour: 0,
1053
- minute: 0,
1054
- second: 0,
1055
- };
1056
- const dayStartUtc = localToUTC(dayStartLocal, input.timezone);
1057
- const dayEndUtc = addLocalDays(dayStartLocal, input.timezone, 1);
1058
-
1059
- const getAscSign = (date: Date): { sign: string; longitude: number } => {
1060
- const jd = this.ephem.dateToJulianDay(date);
1061
- const houses = this.houseCalc.calculateHouses(jd, input.latitude, input.longitude, 'P');
1062
- const normalized = ((houses.ascendant % 360) + 360) % 360;
1063
- return { sign: ZODIAC_SIGNS[Math.floor(normalized / 30)], longitude: normalized };
1064
- };
1065
-
1066
- const refineBoundary = (left: Date, right: Date): Date => {
1067
- const leftSign = getAscSign(left).sign;
1068
- let lo = left;
1069
- let hi = right;
1070
- for (let i = 0; i < 25; i++) {
1071
- const mid = new Date((lo.getTime() + hi.getTime()) / 2);
1072
- const midSign = getAscSign(mid).sign;
1073
- if (midSign === leftSign) {
1074
- lo = mid;
1075
- } else {
1076
- hi = mid;
1077
- }
1078
- }
1079
- return hi;
1080
- };
1081
-
1082
- const findSignTransitionsInBucket = (start: Date, end: Date, probeStepMs: number): Date[] => {
1083
- const boundaries: Date[] = [];
1084
- let probeCursor = start;
1085
- let currentSign = getAscSign(probeCursor).sign;
1086
-
1087
- while (probeCursor < end) {
1088
- const probeNext = new Date(Math.min(probeCursor.getTime() + probeStepMs, end.getTime()));
1089
- const nextSign = getAscSign(probeNext).sign;
1090
- if (nextSign !== currentSign) {
1091
- boundaries.push(mode === 'exact' ? refineBoundary(probeCursor, probeNext) : probeNext);
1092
- }
1093
- probeCursor = probeNext;
1094
- currentSign = nextSign;
1095
- }
1096
-
1097
- return boundaries;
1098
- };
1099
-
1100
- const stepMs = mode === 'exact' ? 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
1101
- const probeStepMs = mode === 'exact' ? 5 * 60 * 1000 : 30 * 60 * 1000;
1102
- const boundaries: Date[] = [dayStartUtc];
1103
- let cursor = dayStartUtc;
1104
- while (cursor < dayEndUtc) {
1105
- const next = new Date(Math.min(cursor.getTime() + stepMs, dayEndUtc.getTime()));
1106
- boundaries.push(...findSignTransitionsInBucket(cursor, next, probeStepMs));
1107
- cursor = next;
1108
- }
1109
- boundaries.push(dayEndUtc);
1110
-
1111
- const windows = boundaries.slice(0, -1).map((start, i) => {
1112
- const end = boundaries[i + 1];
1113
- const sample = new Date((start.getTime() + end.getTime()) / 2);
1114
- const sign = getAscSign(sample).sign;
1115
- return {
1116
- sign,
1117
- start: formatLocalTimestampWithOffset(start, input.timezone),
1118
- end: formatLocalTimestampWithOffset(end, input.timezone),
1119
- durationMinutes: Math.round((end.getTime() - start.getTime()) / 60000),
1120
- };
1121
- });
1122
-
1123
- const structuredData = {
1124
- date: input.date,
1125
- timezone: input.timezone,
1126
- location: {
1127
- latitude: input.latitude,
1128
- longitude: input.longitude,
1129
- },
1130
- mode,
1131
- windows,
1132
- };
1133
-
1134
- const humanText = `Rising Sign Windows (${input.date}, ${input.timezone}, ${mode}):\n\n${windows
1135
- .map(
1136
- (window) =>
1137
- `${window.sign}: ${formatInTimezone(new Date(window.start), input.timezone)} → ${formatInTimezone(new Date(window.end), input.timezone)}`
1138
- )
1139
- .join('\n')}`;
1140
-
1141
- return {
1142
- data: structuredData,
1143
- text: humanText,
1144
- };
1145
- }
1146
-
1147
- private getElectionalApplyingAspects(
1148
- positions: PlanetPosition[],
1149
- orbDegrees: number
1150
- ): ElectionalAspect[] {
1151
- const aspects: ElectionalAspect[] = [];
1152
-
1153
- for (let i = 0; i < positions.length; i++) {
1154
- for (let j = i + 1; j < positions.length; j++) {
1155
- const from = positions[i];
1156
- const to = positions[j];
1157
- const currentAngle = this.ephem.calculateAspectAngle(from.longitude, to.longitude);
1158
-
1159
- for (const aspect of ASPECTS) {
1160
- const orb = Math.abs(currentAngle - aspect.angle);
1161
- if (orb > aspect.orb || orb > orbDegrees) {
1162
- continue;
1163
- }
1164
-
1165
- const applying = this.isElectionalAspectApplying(from, to, aspect.angle);
1166
- if (!applying) {
1167
- continue;
1168
- }
1169
-
1170
- aspects.push({
1171
- from_body: from.planet,
1172
- to_body: to.planet,
1173
- aspect: aspect.name,
1174
- orb: Number.parseFloat(orb.toFixed(4)),
1175
- applying: true,
1176
- });
1177
- }
1178
- }
1179
- }
1180
-
1181
- return aspects.sort(
1182
- (a, b) =>
1183
- a.orb - b.orb ||
1184
- a.from_body.localeCompare(b.from_body) ||
1185
- a.to_body.localeCompare(b.to_body) ||
1186
- a.aspect.localeCompare(b.aspect)
1187
- );
1188
- }
1189
-
1190
- private isElectionalAspectApplying(
1191
- from: Pick<PlanetPosition, 'longitude' | 'speed'>,
1192
- to: Pick<PlanetPosition, 'longitude' | 'speed'>,
1193
- aspectAngle: number
1194
- ): boolean {
1195
- const signedSeparation = this.getSignedAngularDifference(from.longitude, to.longitude);
1196
- const currentSeparation = Math.abs(signedSeparation);
1197
- if (currentSeparation === aspectAngle) {
1198
- return false;
1199
- }
1200
-
1201
- const separationRate = Math.sign(signedSeparation || 1) * (to.speed - from.speed);
1202
- if (separationRate === 0) {
1203
- return false;
1204
- }
1205
-
1206
- return currentSeparation < aspectAngle ? separationRate > 0 : separationRate < 0;
1207
- }
1208
-
1209
- private getSignedAngularDifference(fromLongitude: number, toLongitude: number): number {
1210
- const normalized = ((toLongitude - fromLongitude + 540) % 360) - 180;
1211
- return normalized === -180 ? 180 : normalized;
1212
- }
1213
-
1214
- private getElectionalPhaseName(phaseAngle: number): ElectionalPhaseName {
1215
- if (phaseAngle < 45) return 'new';
1216
- if (phaseAngle < 90) return 'crescent';
1217
- if (phaseAngle < 135) return 'first_quarter';
1218
- if (phaseAngle < 180) return 'gibbous';
1219
- if (phaseAngle < 225) return 'full';
1220
- if (phaseAngle < 270) return 'disseminating';
1221
- if (phaseAngle < 315) return 'last_quarter';
1222
- return 'balsamic';
1223
- }
1224
-
1225
- private getTraditionalSignRuler(sign: string): PlanetName {
1226
- const signRulers: Record<string, PlanetName> = {
1227
- Aries: 'Mars',
1228
- Taurus: 'Venus',
1229
- Gemini: 'Mercury',
1230
- Cancer: 'Moon',
1231
- Leo: 'Sun',
1232
- Virgo: 'Mercury',
1233
- Libra: 'Venus',
1234
- Scorpio: 'Mars',
1235
- Sagittarius: 'Jupiter',
1236
- Capricorn: 'Saturn',
1237
- Aquarius: 'Saturn',
1238
- Pisces: 'Jupiter',
1239
- };
1240
-
1241
- return signRulers[sign] ?? 'Mars';
233
+ return this.risingSignService.getRisingSignWindows(input);
1242
234
  }
1243
235
 
236
+ /**
237
+ * Return the currently retrograde planets for the requested reporting timezone.
238
+ */
1244
239
  getRetrogradePlanets(timezone?: string): ServiceResult<Record<string, unknown>> {
1245
- const resolvedTimezone = this.resolveReportingTimezone(timezone);
1246
- const now = this.now();
1247
- const jd = this.ephem.dateToJulianDay(now);
1248
- const allPlanetIds = Object.values(PLANETS);
1249
- const positions = this.ephem.getAllPlanets(jd, allPlanetIds);
1250
- const retrograde = positions.filter((p) => p.isRetrograde);
1251
-
1252
- const localNow = utcToLocal(now, resolvedTimezone);
1253
- const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
1254
-
1255
- const structuredData = {
1256
- date: dateLabel,
1257
- timezone: resolvedTimezone,
1258
- planets: retrograde,
1259
- };
1260
-
1261
- const humanText =
1262
- retrograde.length === 0
1263
- ? 'No planets are currently retrograde.'
1264
- : `Retrograde Planets:\n\n${retrograde.map((p) => `${p.planet}: ${p.degree.toFixed(2)}° ${p.sign}`).join('\n')}`;
1265
-
1266
- return { data: structuredData, text: humanText };
240
+ return this.skyService.getRetrogradePlanets(timezone);
1267
241
  }
1268
242
 
243
+ /**
244
+ * Return the next rise and set events after the local day anchor for the chart location.
245
+ *
246
+ * @remarks
247
+ * The lookup anchor remains local midnight in the natal chart timezone even
248
+ * when reporting text uses a preferred reporting timezone.
249
+ */
1269
250
  async getRiseSetTimes(natalChart: NatalChart): Promise<ServiceResult<Record<string, unknown>>> {
1270
- const timezone = natalChart.location.timezone;
1271
- const reportingTimezone = this.mcpStartupDefaults.preferredTimezone || timezone;
1272
- const now = this.now();
1273
- const localNow = utcToLocal(now, timezone);
1274
- const localMidnight = {
1275
- year: localNow.year,
1276
- month: localNow.month,
1277
- day: localNow.day,
1278
- hour: 0,
1279
- minute: 0,
1280
- second: 0,
1281
- };
1282
- const midnightUTC = localToUTC(localMidnight, timezone);
1283
-
1284
- const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
1285
-
1286
- const results = await this.riseSetCalc.getAllRiseSet(
1287
- midnightUTC,
1288
- natalChart.location.latitude,
1289
- natalChart.location.longitude
1290
- );
1291
-
1292
- const structuredData = {
1293
- date: dateLabel,
1294
- timezone,
1295
- times: results.map((r) => ({
1296
- planet: r.planet,
1297
- rise: r.rise?.toISOString() ?? null,
1298
- set: r.set?.toISOString() ?? null,
1299
- })),
1300
- };
1301
-
1302
- const humanText = `Rise/Set Times:\n\n${results
1303
- .map((r) => {
1304
- const rise = r.rise ? this.formatTimestamp(r.rise, reportingTimezone) : 'none';
1305
- const set = r.set ? this.formatTimestamp(r.set, reportingTimezone) : 'none';
1306
- return `${r.planet}: Rise ${rise}, Set ${set}`;
1307
- })
1308
- .join('\n')}`;
1309
-
1310
- return {
1311
- data: structuredData,
1312
- text: humanText,
1313
- };
251
+ return this.skyService.getRiseSetTimes(natalChart);
1314
252
  }
1315
253
 
254
+ /**
255
+ * Return current asteroid and node positions for the requested reporting timezone.
256
+ */
1316
257
  getAsteroidPositions(timezone?: string): ServiceResult<Record<string, unknown>> {
1317
- const resolvedTimezone = this.resolveReportingTimezone(timezone);
1318
- const now = this.now();
1319
- const jd = this.ephem.dateToJulianDay(now);
1320
- const asteroidIds = [...ASTEROIDS, ...NODES];
1321
- const positions = this.ephem.getAllPlanets(jd, asteroidIds);
1322
-
1323
- const localNow = utcToLocal(now, resolvedTimezone);
1324
- const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
1325
-
1326
- const structuredData = {
1327
- date: dateLabel,
1328
- timezone: resolvedTimezone,
1329
- positions,
1330
- };
1331
-
1332
- const humanText = `Asteroid & Node Positions:\n\n${positions
1333
- .map((p) => {
1334
- const rx = p.isRetrograde ? ' Rx' : '';
1335
- return `${p.planet}: ${p.degree.toFixed(2)}° ${p.sign}${rx}`;
1336
- })
1337
- .join('\n')}`;
1338
-
1339
- return {
1340
- data: structuredData,
1341
- text: humanText,
1342
- };
258
+ return this.skyService.getAsteroidPositions(timezone);
1343
259
  }
1344
260
 
261
+ /**
262
+ * Look up the next solar and lunar eclipses after the current instant.
263
+ */
1345
264
  getNextEclipses(timezone?: string): ServiceResult<Record<string, unknown>> {
1346
- const resolvedTimezone = this.resolveReportingTimezone(timezone);
1347
- const now = this.now();
1348
- const jd = this.ephem.dateToJulianDay(now);
1349
-
1350
- const solarEclipse = this.eclipseCalc.findNextSolarEclipse(jd);
1351
- const lunarEclipse = this.eclipseCalc.findNextLunarEclipse(jd);
1352
-
1353
- const eclipses: Array<{ type: string; eclipseType: string; maxTime: string }> = [];
1354
- const humanLines: string[] = [];
1355
-
1356
- if (solarEclipse) {
1357
- eclipses.push({
1358
- type: solarEclipse.type,
1359
- eclipseType: solarEclipse.eclipseType,
1360
- maxTime: solarEclipse.maxTime.toISOString(),
1361
- });
1362
- humanLines.push(
1363
- `Next Solar Eclipse: ${this.formatTimestamp(solarEclipse.maxTime, resolvedTimezone)} (${solarEclipse.eclipseType})`
1364
- );
1365
- }
1366
-
1367
- if (lunarEclipse) {
1368
- eclipses.push({
1369
- type: lunarEclipse.type,
1370
- eclipseType: lunarEclipse.eclipseType,
1371
- maxTime: lunarEclipse.maxTime.toISOString(),
1372
- });
1373
- humanLines.push(
1374
- `Next Lunar Eclipse: ${this.formatTimestamp(lunarEclipse.maxTime, resolvedTimezone)} (${lunarEclipse.eclipseType})`
1375
- );
1376
- }
1377
-
1378
- const structuredData = { timezone: resolvedTimezone, eclipses };
1379
- const humanText =
1380
- eclipses.length === 0
1381
- ? 'No eclipses found in the near future.'
1382
- : `Upcoming Eclipses:\n\n${humanLines.join('\n')}`;
1383
-
1384
- return { data: structuredData, text: humanText };
265
+ return this.skyService.getNextEclipses(timezone);
1385
266
  }
1386
267
 
268
+ /**
269
+ * Summarize process-local server state and configured startup defaults.
270
+ */
1387
271
  getServerStatus(natalChart: NatalChart | null): ServiceResult<Record<string, unknown>> {
1388
- const statusData = {
1389
- serverVersion: '1.0.0',
1390
- hasNatalChart: natalChart !== null,
1391
- natalChartName: natalChart?.name ?? null,
1392
- natalChartTimezone: natalChart?.location.timezone ?? null,
1393
- startupDefaults: {
1394
- preferredTimezone: this.mcpStartupDefaults.preferredTimezone ?? null,
1395
- preferredHouseStyle: this.mcpStartupDefaults.preferredHouseStyle ?? null,
1396
- weekdayLabels: this.mcpStartupDefaults.weekdayLabels ?? null,
1397
- },
1398
- ephemerisInitialized: this.isInitialized(),
1399
- stateModel: 'stateful-per-process',
1400
- };
1401
-
1402
- const humanText = natalChart
1403
- ? `Server ready. Natal chart loaded: ${natalChart.name} (${natalChart.location.timezone})`
1404
- : 'Server ready. No natal chart loaded — call set_natal_chart first.';
1405
-
1406
- return { data: statusData, text: humanText };
272
+ return this.natalService.getServerStatus(natalChart);
1407
273
  }
1408
274
 
275
+ /**
276
+ * Generate a natal chart image or SVG for the current chart.
277
+ *
278
+ * @remarks
279
+ * When `output_path` is omitted the payload is returned inline; otherwise the
280
+ * rendered asset is written to disk and only path metadata is returned.
281
+ */
1409
282
  async generateNatalChart(
1410
283
  natalChart: NatalChart,
1411
284
  input: GenerateChartInput = {}
1412
285
  ): Promise<ChartServiceResult> {
1413
- const theme = input.theme || getDefaultTheme(natalChart.location.timezone);
1414
- const format = input.format || 'svg';
1415
- const outputPath = input.output_path;
1416
- const chart = await this.chartRenderer.generateNatalChart(natalChart, theme, format);
1417
-
1418
- if (outputPath) {
1419
- if (format === 'svg') {
1420
- await this.writeFileFn(outputPath, chart as string, 'utf-8');
1421
- } else {
1422
- await this.writeFileFn(outputPath, chart as Buffer);
1423
- }
1424
- return {
1425
- format,
1426
- outputPath,
1427
- text: `Natal Chart for ${natalChart.name} saved to: ${outputPath}`,
1428
- };
1429
- }
1430
-
1431
- if (format === 'svg') {
1432
- return {
1433
- format,
1434
- text: `Natal Chart for ${natalChart.name}:`,
1435
- svg: chart as string,
1436
- };
1437
- }
1438
-
1439
- const base64 = (chart as Buffer).toString('base64');
1440
- const mimeType = format === 'png' ? 'image/png' : 'image/webp';
1441
- return {
1442
- format,
1443
- text: `Natal Chart for ${natalChart.name} (${theme} theme, ${format.toUpperCase()} format):`,
1444
- image: {
1445
- data: base64,
1446
- mimeType,
1447
- },
1448
- };
286
+ return this.chartOutputService.generateNatalChart(natalChart, input);
1449
287
  }
1450
288
 
289
+ /**
290
+ * Generate a transit chart image or SVG for a target date.
291
+ *
292
+ * @remarks
293
+ * Omitted dates still resolve to local noon in the natal chart timezone before
294
+ * rendering so date-only behavior stays stable across timezone conversions.
295
+ */
1451
296
  async generateTransitChart(
1452
297
  natalChart: NatalChart,
1453
298
  input: GenerateTransitChartInput = {}
1454
299
  ): Promise<ChartServiceResult> {
1455
- const dateStr = input.date;
1456
- const theme = input.theme ?? getDefaultTheme(natalChart.location.timezone);
1457
- const format = input.format ?? 'svg';
1458
-
1459
- let targetDate: Date;
1460
- if (dateStr) {
1461
- const parsed = parseDateOnlyInput(dateStr);
1462
- targetDate = localToUTC(parsed, natalChart.location.timezone);
1463
- } else {
1464
- const now = this.now();
1465
- const localNow = utcToLocal(now, natalChart.location.timezone);
1466
- const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
1467
- targetDate = localToUTC(localNoon, natalChart.location.timezone);
1468
- }
1469
-
1470
- const outputPath = input.output_path;
1471
- const chart = await this.chartRenderer.generateTransitChart(
1472
- natalChart,
1473
- targetDate,
1474
- theme,
1475
- format
1476
- );
1477
- const dateLabel = formatDateOnly(targetDate, natalChart.location.timezone);
1478
-
1479
- if (outputPath) {
1480
- if (format === 'svg') {
1481
- await this.writeFileFn(outputPath, chart as string, 'utf-8');
1482
- } else {
1483
- await this.writeFileFn(outputPath, chart as Buffer);
1484
- }
1485
- return {
1486
- format,
1487
- outputPath,
1488
- text: `Transit Chart for ${natalChart.name} (${dateLabel}) saved to ${outputPath}`,
1489
- };
1490
- }
1491
-
1492
- if (format === 'svg') {
1493
- return {
1494
- format,
1495
- text: `Transit Chart for ${natalChart.name} (${dateLabel})`,
1496
- svg: chart as string,
1497
- };
1498
- }
1499
-
1500
- const base64 = (chart as Buffer).toString('base64');
1501
- const mimeType = format === 'png' ? 'image/png' : 'image/webp';
1502
- return {
1503
- format,
1504
- text: `Transit Chart for ${natalChart.name} (${dateLabel}, ${theme} theme, ${format.toUpperCase()} format):`,
1505
- image: {
1506
- data: base64,
1507
- mimeType,
1508
- },
1509
- };
300
+ return this.chartOutputService.generateTransitChart(natalChart, input);
1510
301
  }
1511
302
  }