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
@@ -0,0 +1,395 @@
1
+ import { Temporal } from '@js-temporal/polyfill';
2
+ import type { EphemerisCalculator } from '../ephemeris.js';
3
+ import type { HouseCalculator } from '../houses.js';
4
+ import { localToUTC } from '../time-utils.js';
5
+ import type { ElectionalHouseSystem } from '../types.js';
6
+ import {
7
+ ASPECTS,
8
+ type ElectionalAspect,
9
+ type ElectionalContextResponse,
10
+ type ElectionalPhaseName,
11
+ PLANETS,
12
+ type PlanetName,
13
+ type PlanetPosition,
14
+ ZODIAC_SIGNS,
15
+ } from '../types.js';
16
+ import { parseDateOnlyInput } from './date-input.js';
17
+ import type { GetElectionalContextInput, ServiceResult } from './service-types.js';
18
+ import { normalizeLongitude } from './shared.js';
19
+
20
+ interface ElectionalServiceDependencies {
21
+ ephem: EphemerisCalculator;
22
+ houseCalc: HouseCalculator;
23
+ }
24
+
25
+ const ELECTIONAL_CONTEXT_PLANET_IDS = [
26
+ PLANETS.SUN,
27
+ PLANETS.MOON,
28
+ PLANETS.MERCURY,
29
+ PLANETS.VENUS,
30
+ PLANETS.MARS,
31
+ PLANETS.JUPITER,
32
+ PLANETS.SATURN,
33
+ PLANETS.URANUS,
34
+ PLANETS.NEPTUNE,
35
+ PLANETS.PLUTO,
36
+ ];
37
+
38
+ const ELECTIONAL_CONTEXT_HOUSE_SYSTEMS: ElectionalHouseSystem[] = ['P', 'K', 'W', 'R'];
39
+
40
+ /**
41
+ * Internal electional workflow used by `AstroService`.
42
+ *
43
+ * @remarks
44
+ * This module owns validation, deterministic instant resolution, sect/moon
45
+ * metadata, optional applying-aspect summaries, and readable electional text
46
+ * while the `AstroService` facade keeps the public contract stable.
47
+ */
48
+ export class ElectionalService {
49
+ private readonly ephem: EphemerisCalculator;
50
+ private readonly houseCalc: HouseCalculator;
51
+
52
+ constructor(deps: ElectionalServiceDependencies) {
53
+ this.ephem = deps.ephem;
54
+ this.houseCalc = deps.houseCalc;
55
+ }
56
+
57
+ /**
58
+ * Produce deterministic electional context for a single local instant.
59
+ */
60
+ getElectionalContext(input: GetElectionalContextInput): ServiceResult<Record<string, unknown>> {
61
+ if (input.latitude < -90 || input.latitude > 90) {
62
+ throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
63
+ }
64
+ if (input.longitude < -180 || input.longitude > 180) {
65
+ throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
66
+ }
67
+
68
+ const houseSystem = input.house_system ?? 'P';
69
+ if (!ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.includes(houseSystem)) {
70
+ throw new Error(
71
+ `Invalid house_system: ${houseSystem} (must be one of ${ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.join(', ')})`
72
+ );
73
+ }
74
+
75
+ const includeRulerBasics = input.include_ruler_basics ?? false;
76
+ const includePlanetaryApplications = input.include_planetary_applications ?? true;
77
+ const orbDegrees = input.orb_degrees ?? 3;
78
+ if (!Number.isFinite(orbDegrees) || orbDegrees < 0.1 || orbDegrees > 10) {
79
+ throw new Error(`Invalid orb_degrees: ${orbDegrees} (must be between 0.1 and 10)`);
80
+ }
81
+
82
+ const parsedDate = parseDateOnlyInput(input.date);
83
+ const parsedTime = parseTimeOnlyInput(input.time);
84
+ let instantUtc: Date;
85
+ try {
86
+ instantUtc = localToUTC(
87
+ {
88
+ year: parsedDate.year,
89
+ month: parsedDate.month,
90
+ day: parsedDate.day,
91
+ hour: parsedTime.hour,
92
+ minute: parsedTime.minute,
93
+ second: parsedTime.second,
94
+ },
95
+ input.timezone,
96
+ 'reject'
97
+ );
98
+ } catch (error) {
99
+ if (error instanceof RangeError) {
100
+ throw new Error(
101
+ `Invalid local electional time: ${input.date} ${input.time} in ${input.timezone} is ambiguous or nonexistent due to a DST transition.`
102
+ );
103
+ }
104
+ throw error;
105
+ }
106
+
107
+ const jdUt = this.ephem.dateToJulianDay(instantUtc);
108
+ const houses = this.houseCalc.calculateHouses(
109
+ jdUt,
110
+ input.latitude,
111
+ input.longitude,
112
+ houseSystem
113
+ );
114
+ const positions = this.ephem.getAllPlanets(jdUt, ELECTIONAL_CONTEXT_PLANET_IDS);
115
+
116
+ const sun = positions.find((position) => position.planet === 'Sun');
117
+ const moon = positions.find((position) => position.planet === 'Moon');
118
+ if (!sun || !moon) {
119
+ throw new Error('Ephemeris failed to compute Sun/Moon positions for electional context.');
120
+ }
121
+
122
+ const sunHorizontal = this.ephem.getHorizontalCoordinates(
123
+ jdUt,
124
+ sun,
125
+ input.longitude,
126
+ input.latitude
127
+ );
128
+ const rawSunAltitudeDegrees = sunHorizontal.trueAltitude;
129
+ const sunAltitudeDegrees = Number.parseFloat(rawSunAltitudeDegrees.toFixed(2));
130
+ const isDayChart = rawSunAltitudeDegrees >= 0;
131
+
132
+ const applyingAspects = includePlanetaryApplications
133
+ ? this.getElectionalApplyingAspects(positions, orbDegrees)
134
+ : undefined;
135
+ const moonApplyingAspects = applyingAspects?.filter(
136
+ (aspect) => aspect.from_body === 'Moon' || aspect.to_body === 'Moon'
137
+ );
138
+
139
+ const phaseAngle = Number.parseFloat(
140
+ normalizeLongitude(moon.longitude - sun.longitude).toFixed(2)
141
+ );
142
+ const warnings: string[] = [];
143
+ if (Math.abs(rawSunAltitudeDegrees) < 0.5) {
144
+ warnings.push('Sun is near the horizon; day/night classification is close to the boundary.');
145
+ }
146
+ warnings.push('Moon void-of-course is deferred in this slice and returns null.');
147
+ if (houses.system !== houseSystem) {
148
+ warnings.push(
149
+ `House calculation fell back from ${houseSystem} to ${houses.system} for this location.`
150
+ );
151
+ }
152
+
153
+ const ascLongitude = normalizeLongitude(houses.ascendant);
154
+ const ascSign = ZODIAC_SIGNS[Math.floor(ascLongitude / 30)];
155
+ const response: ElectionalContextResponse = {
156
+ input: {
157
+ date: input.date,
158
+ time: input.time,
159
+ timezone: input.timezone,
160
+ latitude: input.latitude,
161
+ longitude: input.longitude,
162
+ house_system: houses.system as ElectionalHouseSystem,
163
+ instant_utc: instantUtc.toISOString(),
164
+ jd_ut: Number.parseFloat(jdUt.toFixed(8)),
165
+ },
166
+ ascendant: {
167
+ longitude: Number.parseFloat(ascLongitude.toFixed(4)),
168
+ sign: ascSign,
169
+ degree_in_sign: Number.parseFloat((ascLongitude % 30).toFixed(4)),
170
+ },
171
+ sect: {
172
+ is_day_chart: isDayChart,
173
+ sun_altitude_degrees: sunAltitudeDegrees,
174
+ classification: isDayChart ? 'day' : 'night',
175
+ },
176
+ moon: {
177
+ longitude: Number.parseFloat(moon.longitude.toFixed(4)),
178
+ sign: moon.sign,
179
+ phase_angle: phaseAngle,
180
+ phase_name: this.getElectionalPhaseName(phaseAngle),
181
+ is_void_of_course: null,
182
+ ...(moonApplyingAspects !== undefined ? { applying_aspects: moonApplyingAspects } : {}),
183
+ },
184
+ meta: {
185
+ deterministic: true,
186
+ requires_natal: false,
187
+ warnings,
188
+ deferred_features: [
189
+ 'robust_void_of_course',
190
+ 'detailed_ruler_condition',
191
+ 'house_context',
192
+ 'natal_overlays',
193
+ ],
194
+ },
195
+ };
196
+
197
+ if (applyingAspects) {
198
+ response.applying_aspects = applyingAspects;
199
+ }
200
+
201
+ if (includeRulerBasics) {
202
+ const rulerBody = this.getTraditionalSignRuler(ascSign);
203
+ const rulerPosition = positions.find((position) => position.planet === rulerBody);
204
+ if (!rulerPosition) {
205
+ throw new Error(`Ephemeris failed to compute ASC ruler position for ${rulerBody}.`);
206
+ }
207
+ response.ruler_basics = {
208
+ asc_sign_ruler: {
209
+ body: rulerBody,
210
+ longitude: Number.parseFloat(rulerPosition.longitude.toFixed(4)),
211
+ sign: rulerPosition.sign,
212
+ speed: Number.parseFloat(rulerPosition.speed.toFixed(6)),
213
+ is_retrograde: rulerPosition.isRetrograde,
214
+ },
215
+ };
216
+ }
217
+
218
+ const humanText = [
219
+ `Electional context for ${input.date} ${input.time} (${input.timezone})`,
220
+ '',
221
+ `Ascendant: ${response.ascendant.degree_in_sign.toFixed(2)}° ${response.ascendant.sign}`,
222
+ `Sect: ${response.sect.classification} (${response.sect.sun_altitude_degrees.toFixed(2)}° Sun altitude)`,
223
+ `Moon: ${response.moon.phase_name} in ${response.moon.sign} (${response.moon.phase_angle.toFixed(2)}° phase angle)`,
224
+ ];
225
+
226
+ if (includePlanetaryApplications) {
227
+ const topLevelAspectText =
228
+ applyingAspects && applyingAspects.length > 0
229
+ ? applyingAspects
230
+ .slice(0, 5)
231
+ .map(
232
+ (aspect) =>
233
+ `${aspect.from_body} ${aspect.aspect} ${aspect.to_body} (${aspect.orb.toFixed(2)}°)`
234
+ )
235
+ .join('\n')
236
+ : 'No applying aspects found within the configured orb.';
237
+
238
+ humanText.push('', 'Applying Aspects:', topLevelAspectText);
239
+ }
240
+
241
+ if (response.ruler_basics) {
242
+ humanText.push(
243
+ '',
244
+ `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)}°)`
245
+ );
246
+ }
247
+
248
+ if (warnings.length > 0) {
249
+ humanText.push('', `Warnings: ${warnings.join(' ')}`);
250
+ }
251
+
252
+ return {
253
+ data: response as unknown as Record<string, unknown>,
254
+ text: humanText.join('\n'),
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Return only currently applying aspects inside the requested orb.
260
+ */
261
+ private getElectionalApplyingAspects(
262
+ positions: PlanetPosition[],
263
+ orbDegrees: number
264
+ ): ElectionalAspect[] {
265
+ const aspects: ElectionalAspect[] = [];
266
+
267
+ for (let i = 0; i < positions.length; i++) {
268
+ for (let j = i + 1; j < positions.length; j++) {
269
+ const from = positions[i];
270
+ const to = positions[j];
271
+ const currentAngle = this.ephem.calculateAspectAngle(from.longitude, to.longitude);
272
+
273
+ for (const aspect of ASPECTS) {
274
+ const orb = Math.abs(currentAngle - aspect.angle);
275
+ if (orb > aspect.orb || orb > orbDegrees) {
276
+ continue;
277
+ }
278
+
279
+ const applying = this.isElectionalAspectApplying(from, to, aspect.angle);
280
+ if (!applying) {
281
+ continue;
282
+ }
283
+
284
+ aspects.push({
285
+ from_body: from.planet,
286
+ to_body: to.planet,
287
+ aspect: aspect.name,
288
+ orb: Number.parseFloat(orb.toFixed(4)),
289
+ applying: true,
290
+ });
291
+ }
292
+ }
293
+ }
294
+
295
+ return aspects.sort(
296
+ (a, b) =>
297
+ a.orb - b.orb ||
298
+ a.from_body.localeCompare(b.from_body) ||
299
+ a.to_body.localeCompare(b.to_body) ||
300
+ a.aspect.localeCompare(b.aspect)
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Determine whether a near-aspect is applying instead of separating.
306
+ */
307
+ private isElectionalAspectApplying(
308
+ from: Pick<PlanetPosition, 'longitude' | 'speed'>,
309
+ to: Pick<PlanetPosition, 'longitude' | 'speed'>,
310
+ aspectAngle: number
311
+ ): boolean {
312
+ const signedSeparation = this.getSignedAngularDifference(from.longitude, to.longitude);
313
+ const currentSeparation = Math.abs(signedSeparation);
314
+ if (currentSeparation === aspectAngle) {
315
+ return false;
316
+ }
317
+
318
+ const separationRate = Math.sign(signedSeparation || 1) * (to.speed - from.speed);
319
+ if (separationRate === 0) {
320
+ return false;
321
+ }
322
+
323
+ return currentSeparation < aspectAngle ? separationRate > 0 : separationRate < 0;
324
+ }
325
+
326
+ /**
327
+ * Compute the signed shortest angular distance in degrees.
328
+ */
329
+ private getSignedAngularDifference(fromLongitude: number, toLongitude: number): number {
330
+ const normalized = ((toLongitude - fromLongitude + 540) % 360) - 180;
331
+ return normalized === -180 ? 180 : normalized;
332
+ }
333
+
334
+ /**
335
+ * Bucket a Sun-Moon phase angle into the service's coarse phase names.
336
+ */
337
+ private getElectionalPhaseName(phaseAngle: number): ElectionalPhaseName {
338
+ if (phaseAngle < 45) return 'new';
339
+ if (phaseAngle < 90) return 'crescent';
340
+ if (phaseAngle < 135) return 'first_quarter';
341
+ if (phaseAngle < 180) return 'gibbous';
342
+ if (phaseAngle < 225) return 'full';
343
+ if (phaseAngle < 270) return 'disseminating';
344
+ if (phaseAngle < 315) return 'last_quarter';
345
+ return 'balsamic';
346
+ }
347
+
348
+ /**
349
+ * Return the traditional ruler used for the ascendant sign summary.
350
+ */
351
+ private getTraditionalSignRuler(sign: string): PlanetName {
352
+ const signRulers: Record<string, PlanetName> = {
353
+ Aries: 'Mars',
354
+ Taurus: 'Venus',
355
+ Gemini: 'Mercury',
356
+ Cancer: 'Moon',
357
+ Leo: 'Sun',
358
+ Virgo: 'Mercury',
359
+ Libra: 'Venus',
360
+ Scorpio: 'Mars',
361
+ Sagittarius: 'Jupiter',
362
+ Capricorn: 'Saturn',
363
+ Aquarius: 'Saturn',
364
+ Pisces: 'Jupiter',
365
+ };
366
+
367
+ return signRulers[sign] ?? 'Mars';
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Parse a strict local wall-clock time for electional requests.
373
+ */
374
+ function parseTimeOnlyInput(timeStr: string): { hour: number; minute: number; second: number } {
375
+ const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(timeStr);
376
+ if (!match) {
377
+ throw new Error(`Invalid time format: expected HH:mm[:ss], got "${timeStr}"`);
378
+ }
379
+
380
+ const hour = Number(match[1]);
381
+ const minute = Number(match[2]);
382
+ const second = match[3] === undefined ? 0 : Number(match[3]);
383
+
384
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
385
+ throw new Error(`Invalid clock time: ${timeStr}`);
386
+ }
387
+
388
+ try {
389
+ Temporal.PlainTime.from({ hour, minute, second });
390
+ } catch {
391
+ throw new Error(`Invalid clock time: ${timeStr}`);
392
+ }
393
+
394
+ return { hour, minute, second };
395
+ }
@@ -0,0 +1,235 @@
1
+ import type { McpStartupDefaults } from '../entrypoint.js';
2
+ import type { EphemerisCalculator } from '../ephemeris.js';
3
+ import type { HouseCalculator } from '../houses.js';
4
+ import { localToUTC, utcToLocal } from '../time-utils.js';
5
+ import { type HouseSystem, type NatalChart, PLANETS, ZODIAC_SIGNS } from '../types.js';
6
+ import type { GetHousesInput, ServiceResult, SetNatalChartInput } from './service-types.js';
7
+ import { resolveHouseSystem } from './shared.js';
8
+
9
+ interface NatalServiceDependencies {
10
+ ephem: EphemerisCalculator;
11
+ houseCalc: HouseCalculator;
12
+ mcpStartupDefaults: Readonly<McpStartupDefaults>;
13
+ isInitialized: () => boolean;
14
+ }
15
+
16
+ /**
17
+ * Internal natal/chart-state workflow used by `AstroService`.
18
+ *
19
+ * @remarks
20
+ * This module owns natal chart initialization, house resolution, and basic
21
+ * server-status serialization while the public `AstroService` facade preserves
22
+ * the existing contract for MCP and CLI callers.
23
+ */
24
+ export class NatalService {
25
+ private readonly ephem: EphemerisCalculator;
26
+ private readonly houseCalc: HouseCalculator;
27
+ private readonly mcpStartupDefaults: Readonly<McpStartupDefaults>;
28
+ private readonly isInitialized: () => boolean;
29
+
30
+ constructor(deps: NatalServiceDependencies) {
31
+ this.ephem = deps.ephem;
32
+ this.houseCalc = deps.houseCalc;
33
+ this.mcpStartupDefaults = deps.mcpStartupDefaults;
34
+ this.isInitialized = deps.isInitialized;
35
+ }
36
+
37
+ /**
38
+ * Build and cache the shared natal chart payload used by later workflows.
39
+ */
40
+ setNatalChart(
41
+ input: SetNatalChartInput
42
+ ): ServiceResult<Record<string, unknown>> & { chart: NatalChart } {
43
+ const requestedHouseSystem = input.house_system ?? null;
44
+
45
+ const chart: NatalChart = {
46
+ name: input.name,
47
+ birthDate: {
48
+ year: input.year,
49
+ month: input.month,
50
+ day: input.day,
51
+ hour: input.hour,
52
+ minute: input.minute,
53
+ },
54
+ location: {
55
+ latitude: input.latitude,
56
+ longitude: input.longitude,
57
+ timezone: input.timezone,
58
+ },
59
+ };
60
+
61
+ const birthTimeDisambiguation = input.birth_time_disambiguation ?? 'reject';
62
+ const utcDate = localToUTC(chart.birthDate, chart.location.timezone, birthTimeDisambiguation);
63
+ const utcComponents = utcToLocal(utcDate, 'UTC');
64
+
65
+ const jd = this.ephem.dateToJulianDay(utcDate);
66
+ const planetIds = Object.values(PLANETS);
67
+ const positions = this.ephem.getAllPlanets(jd, planetIds);
68
+
69
+ const isPolar = Math.abs(chart.location.latitude) > 66;
70
+ let houseSystem: HouseSystem = requestedHouseSystem || 'P';
71
+ if (isPolar && houseSystem === 'P') {
72
+ houseSystem = 'W';
73
+ }
74
+
75
+ const houses = this.houseCalc.calculateHouses(
76
+ jd,
77
+ chart.location.latitude,
78
+ chart.location.longitude,
79
+ houseSystem
80
+ );
81
+
82
+ const storedChart: NatalChart = {
83
+ ...chart,
84
+ planets: positions,
85
+ julianDay: jd,
86
+ houseSystem: houses.system,
87
+ requestedHouseSystem: requestedHouseSystem ?? undefined,
88
+ utcDateTime: utcComponents,
89
+ };
90
+
91
+ const sun = positions.find((position) => position.planet === 'Sun');
92
+ const moon = positions.find((position) => position.planet === 'Moon');
93
+ if (!sun || !moon) {
94
+ throw new Error('Ephemeris failed to compute Sun/Moon positions for natal chart.');
95
+ }
96
+
97
+ const formatDegree = (longitude: number): string => {
98
+ const sign = ZODIAC_SIGNS[Math.floor(longitude / 30)];
99
+ const degree = longitude % 30;
100
+ return `${degree.toFixed(0)}° ${sign}`;
101
+ };
102
+
103
+ const localTimeStr = `${chart.birthDate.month}/${chart.birthDate.day}/${chart.birthDate.year} ${chart.birthDate.hour}:${String(chart.birthDate.minute).padStart(2, '0')}`;
104
+ const utcTimeStr = `${utcComponents.month}/${utcComponents.day}/${utcComponents.year} ${utcComponents.hour}:${String(utcComponents.minute).padStart(2, '0')} UTC`;
105
+
106
+ const systemNames: Record<string, string> = {
107
+ P: 'Placidus',
108
+ W: 'Whole Sign',
109
+ K: 'Koch',
110
+ E: 'Equal',
111
+ };
112
+
113
+ const latDir = chart.location.latitude >= 0 ? 'N' : 'S';
114
+ const lonDir = chart.location.longitude >= 0 ? 'E' : 'W';
115
+ const latAbs = Math.abs(chart.location.latitude);
116
+ const lonAbs = Math.abs(chart.location.longitude);
117
+
118
+ const feedback = [
119
+ `Natal chart saved for ${chart.name}`,
120
+ '',
121
+ 'Birth Details:',
122
+ `- Local Time: ${localTimeStr} (${chart.location.timezone})`,
123
+ `- UTC Time: ${utcTimeStr}`,
124
+ `- Location: ${latAbs.toFixed(2)}°${latDir}, ${lonAbs.toFixed(2)}°${lonDir}`,
125
+ '',
126
+ 'Chart Angles:',
127
+ `- Sun: ${formatDegree(sun.longitude)}`,
128
+ `- Moon: ${formatDegree(moon.longitude)}`,
129
+ `- Ascendant: ${formatDegree(houses.ascendant)}`,
130
+ `- MC: ${formatDegree(houses.mc)}`,
131
+ '',
132
+ `House System: ${systemNames[houses.system] || houses.system}`,
133
+ ];
134
+
135
+ if (isPolar && houses.system !== houseSystem) {
136
+ feedback.push(
137
+ '',
138
+ `Note: Polar latitude detected (${chart.location.latitude.toFixed(1)}°). Requested ${systemNames[houseSystem]}, using ${systemNames[houses.system]} instead.`
139
+ );
140
+ } else if (isPolar) {
141
+ feedback.push(
142
+ '',
143
+ `Note: Polar latitude detected (${chart.location.latitude.toFixed(1)}°). Using ${systemNames[houses.system]} house system.`
144
+ );
145
+ }
146
+
147
+ const structuredData: Record<string, unknown> = {
148
+ name: chart.name,
149
+ birthTime: {
150
+ local: localTimeStr,
151
+ utc: utcTimeStr,
152
+ timezone: chart.location.timezone,
153
+ },
154
+ location: {
155
+ latitude: chart.location.latitude,
156
+ longitude: chart.location.longitude,
157
+ },
158
+ julianDay: jd,
159
+ requestedHouseSystem,
160
+ resolvedHouseSystem: houses.system,
161
+ angles: {
162
+ sun: formatDegree(sun.longitude),
163
+ moon: formatDegree(moon.longitude),
164
+ ascendant: formatDegree(houses.ascendant),
165
+ mc: formatDegree(houses.mc),
166
+ },
167
+ isPolar,
168
+ };
169
+
170
+ return {
171
+ chart: storedChart,
172
+ data: structuredData,
173
+ text: feedback.join('\n'),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Calculate house cusps and angles for a natal chart.
179
+ */
180
+ getHouses(
181
+ natalChart: NatalChart,
182
+ input: GetHousesInput = {}
183
+ ): ServiceResult<Record<string, unknown>> {
184
+ const system = resolveHouseSystem(natalChart, this.mcpStartupDefaults, input.system);
185
+ if (!natalChart.julianDay) {
186
+ throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
187
+ }
188
+
189
+ const houses = this.houseCalc.calculateHouses(
190
+ natalChart.julianDay,
191
+ natalChart.location.latitude,
192
+ natalChart.location.longitude,
193
+ system
194
+ );
195
+
196
+ const humanLines = houses.cusps
197
+ .slice(1)
198
+ .map((degree, index) => {
199
+ const sign = ZODIAC_SIGNS[Math.floor(degree / 30)];
200
+ return `House ${index + 1}: ${(degree % 30).toFixed(2)}° ${sign}`;
201
+ })
202
+ .join('\n');
203
+ const humanText = `Houses (${houses.system}):\nAsc: ${houses.ascendant.toFixed(2)}° | MC: ${houses.mc.toFixed(2)}°\n\n${humanLines}`;
204
+
205
+ return {
206
+ data: houses as unknown as Record<string, unknown>,
207
+ text: humanText,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Summarize process-local server state and configured startup defaults.
213
+ */
214
+ getServerStatus(natalChart: NatalChart | null): ServiceResult<Record<string, unknown>> {
215
+ const statusData = {
216
+ serverVersion: '1.0.0',
217
+ hasNatalChart: natalChart !== null,
218
+ natalChartName: natalChart?.name ?? null,
219
+ natalChartTimezone: natalChart?.location.timezone ?? null,
220
+ startupDefaults: {
221
+ preferredTimezone: this.mcpStartupDefaults.preferredTimezone ?? null,
222
+ preferredHouseStyle: this.mcpStartupDefaults.preferredHouseStyle ?? null,
223
+ weekdayLabels: this.mcpStartupDefaults.weekdayLabels ?? null,
224
+ },
225
+ ephemerisInitialized: this.isInitialized(),
226
+ stateModel: 'stateful-per-process',
227
+ };
228
+
229
+ const humanText = natalChart
230
+ ? `Server ready. Natal chart loaded: ${natalChart.name} (${natalChart.location.timezone})`
231
+ : 'Server ready. No natal chart loaded — call set_natal_chart first.';
232
+
233
+ return { data: statusData, text: humanText };
234
+ }
235
+ }