ether-to-astro 1.0.2 → 1.2.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 (60) hide show
  1. package/.github/ISSUE_TEMPLATE/bug-report.yml +87 -0
  2. package/.github/ISSUE_TEMPLATE/capability-request.yml +117 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  4. package/.github/ISSUE_TEMPLATE/paper-cut.yml +59 -0
  5. package/.github/pull_request_template.md +1 -0
  6. package/.github/workflows/release.yml +2 -2
  7. package/.github/workflows/test.yml +2 -2
  8. package/AGENTS.md +46 -1
  9. package/DEVELOPER.md +78 -0
  10. package/README.md +128 -75
  11. package/SETUP.md +100 -41
  12. package/dist/astro-service.d.ts +51 -2
  13. package/dist/astro-service.js +660 -56
  14. package/dist/cli.js +31 -0
  15. package/dist/entrypoint.d.ts +13 -0
  16. package/dist/entrypoint.js +78 -0
  17. package/dist/ephemeris.d.ts +15 -0
  18. package/dist/ephemeris.js +33 -0
  19. package/dist/formatter.d.ts +5 -1
  20. package/dist/formatter.js +4 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.js +63 -114
  23. package/dist/loader.d.ts +1 -1
  24. package/dist/loader.js +61 -23
  25. package/dist/mcp-alias.d.ts +2 -0
  26. package/dist/mcp-alias.js +8 -0
  27. package/dist/time-utils.d.ts +8 -0
  28. package/dist/time-utils.js +16 -0
  29. package/dist/tool-registry.js +111 -5
  30. package/dist/tool-result.d.ts +8 -0
  31. package/dist/tool-result.js +39 -0
  32. package/dist/types.d.ts +79 -1
  33. package/docs/product/adrs/0001-mcp-vs-skill-boundary.md +96 -0
  34. package/docs/product/architecture-boundaries.md +223 -0
  35. package/docs/product/product-tenets.md +174 -0
  36. package/docs/releases/1.2.0-draft.md +48 -0
  37. package/package.json +7 -7
  38. package/skills/.curated/daily-brief/SKILL.md +75 -0
  39. package/skills/.curated/electional-overlay/SKILL.md +67 -0
  40. package/skills/.curated/weekly-overview/SKILL.md +73 -0
  41. package/skills/.system/write-skill/SKILL.md +90 -0
  42. package/src/astro-service.ts +861 -60
  43. package/src/cli.ts +84 -0
  44. package/src/entrypoint.ts +118 -0
  45. package/src/ephemeris.ts +44 -0
  46. package/src/formatter.ts +13 -1
  47. package/src/index.ts +77 -121
  48. package/src/loader.ts +69 -25
  49. package/src/mcp-alias.ts +10 -0
  50. package/src/time-utils.ts +18 -0
  51. package/src/tool-registry.ts +129 -9
  52. package/src/tool-result.ts +44 -0
  53. package/src/types.ts +91 -1
  54. package/tests/unit/astro-service.test.ts +751 -5
  55. package/tests/unit/cli-commands.test.ts +13 -0
  56. package/tests/unit/entrypoint.test.ts +67 -0
  57. package/tests/unit/error-mapping.test.ts +20 -0
  58. package/tests/unit/formatter.test.ts +6 -0
  59. package/tests/unit/tool-registry.test.ts +114 -2
  60. package/setup.sh +0 -21
@@ -3,21 +3,37 @@ import { Temporal } from '@js-temporal/polyfill';
3
3
  import { ChartRenderer } from './charts.js';
4
4
  import { getDefaultTheme } from './constants.js';
5
5
  import { EclipseCalculator } from './eclipses.js';
6
+ import type { McpStartupDefaults } from './entrypoint.js';
6
7
  import { EphemerisCalculator } from './ephemeris.js';
7
8
  import { formatDateOnly, formatInTimezone } from './formatter.js';
8
9
  import { HouseCalculator } from './houses.js';
9
10
  import { RiseSetCalculator } from './riseset.js';
10
- import { addLocalDays, type Disambiguation, localToUTC, utcToLocal } from './time-utils.js';
11
+ import {
12
+ addLocalDays,
13
+ type Disambiguation,
14
+ formatLocalTimestampWithOffset,
15
+ localToUTC,
16
+ utcToLocal,
17
+ } from './time-utils.js';
11
18
  import { deduplicateTransits, TransitCalculator } from './transits.js';
12
19
  import {
20
+ ASPECTS,
13
21
  ASTEROIDS,
22
+ type AspectType,
23
+ type ElectionalAspect,
24
+ type ElectionalContextResponse,
25
+ type ElectionalHouseSystem,
26
+ type ElectionalPhaseName,
27
+ type HouseData,
14
28
  type HouseSystem,
15
29
  type NatalChart,
16
30
  NODES,
17
31
  OUTER_PLANETS,
18
32
  PERSONAL_PLANETS,
33
+ PLANET_NAMES,
19
34
  PLANETS,
20
- type PlanetPositionResponse,
35
+ type PlanetName,
36
+ type PlanetPosition,
21
37
  type Transit,
22
38
  type TransitResponse,
23
39
  ZODIAC_SIGNS,
@@ -30,6 +46,7 @@ interface AstroServiceDependencies {
30
46
  riseSetCalc?: RiseSetCalculator;
31
47
  eclipseCalc?: EclipseCalculator;
32
48
  chartRenderer?: ChartRenderer;
49
+ mcpStartupDefaults?: McpStartupDefaults;
33
50
  now?: () => Date;
34
51
  writeFile?: (path: string, data: string | Buffer, encoding?: BufferEncoding) => Promise<void>;
35
52
  }
@@ -53,15 +70,36 @@ export interface GetTransitsInput {
53
70
  categories?: string[];
54
71
  include_mundane?: boolean;
55
72
  days_ahead?: number;
73
+ mode?: 'snapshot' | 'best_hit' | 'forecast';
56
74
  max_orb?: number;
57
75
  exact_only?: boolean;
58
76
  applying_only?: boolean;
59
77
  }
60
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
+
61
91
  export interface GetHousesInput {
62
92
  system?: string;
63
93
  }
64
94
 
95
+ export interface GetRisingSignWindowsInput {
96
+ date: string;
97
+ latitude: number;
98
+ longitude: number;
99
+ timezone: string;
100
+ mode?: 'approximate' | 'exact';
101
+ }
102
+
65
103
  export interface GenerateChartInput {
66
104
  theme?: 'light' | 'dark';
67
105
  format?: 'svg' | 'png' | 'webp';
@@ -77,7 +115,30 @@ export interface ServiceResult<T> {
77
115
  text: string;
78
116
  }
79
117
 
80
- export interface ChartServiceResult {
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
+ interface ChartServiceResult {
81
142
  format: 'svg' | 'png' | 'webp';
82
143
  outputPath?: string;
83
144
  text: string;
@@ -120,6 +181,44 @@ export function parseDateOnlyInput(dateStr: string): {
120
181
  return { year, month, day, hour: 12, minute: 0 };
121
182
  }
122
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
+
123
222
  export class AstroService {
124
223
  readonly ephem: EphemerisCalculator;
125
224
  readonly transitCalc: TransitCalculator;
@@ -127,6 +226,7 @@ export class AstroService {
127
226
  readonly riseSetCalc: RiseSetCalculator;
128
227
  readonly eclipseCalc: EclipseCalculator;
129
228
  readonly chartRenderer: ChartRenderer;
229
+ readonly mcpStartupDefaults: Readonly<McpStartupDefaults>;
130
230
  private readonly now: () => Date;
131
231
  private readonly writeFileFn: (
132
232
  path: string,
@@ -141,10 +241,72 @@ export class AstroService {
141
241
  this.riseSetCalc = deps.riseSetCalc ?? new RiseSetCalculator(this.ephem);
142
242
  this.eclipseCalc = deps.eclipseCalc ?? new EclipseCalculator(this.ephem);
143
243
  this.chartRenderer = deps.chartRenderer ?? new ChartRenderer(this.ephem, this.houseCalc);
244
+ this.mcpStartupDefaults = Object.freeze({ ...(deps.mcpStartupDefaults ?? {}) });
144
245
  this.now = deps.now ?? (() => new Date());
145
246
  this.writeFileFn = deps.writeFile ?? writeFile;
146
247
  }
147
248
 
249
+ private formatTimestamp(date: Date, timezone: string): string {
250
+ return formatInTimezone(date, timezone, {
251
+ weekday: this.mcpStartupDefaults.weekdayLabels ?? false,
252
+ });
253
+ }
254
+
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
+
306
+ resolveReportingTimezone(explicitTimezone?: string, natalTimezone?: string): string {
307
+ return explicitTimezone ?? this.mcpStartupDefaults.preferredTimezone ?? natalTimezone ?? 'UTC';
308
+ }
309
+
148
310
  async init(): Promise<void> {
149
311
  await this.ephem.init();
150
312
  }
@@ -200,6 +362,7 @@ export class AstroService {
200
362
  planets: positions,
201
363
  julianDay: jd,
202
364
  houseSystem: houses.system,
365
+ requestedHouseSystem: requestedHouseSystem ?? undefined,
203
366
  utcDateTime: utcComponents,
204
367
  };
205
368
 
@@ -297,16 +460,31 @@ export class AstroService {
297
460
  const categories = input.categories ?? ['all'];
298
461
  const includeMundane = input.include_mundane ?? false;
299
462
  const daysAhead = input.days_ahead ?? 0;
463
+ const requestedMode = input.mode;
300
464
  const maxOrb = input.max_orb ?? 8;
301
465
  const exactOnly = input.exact_only ?? false;
302
466
  const applyingOnly = input.applying_only ?? false;
303
467
 
304
- if (daysAhead < 0) {
305
- throw new Error('days_ahead must be >= 0');
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');
306
473
  }
307
- if (maxOrb < 0) {
308
- throw new Error('max_orb must be >= 0');
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');
309
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';
310
488
 
311
489
  let transitingPlanetIds: number[] = [];
312
490
  if (categories.includes('all')) {
@@ -323,23 +501,29 @@ export class AstroService {
323
501
  }
324
502
  }
325
503
 
326
- const timezone = natalChart.location.timezone;
504
+ const { calculationTimezone, reportingTimezone } = this.resolveTimezones(
505
+ undefined,
506
+ natalChart.location.timezone
507
+ );
327
508
 
328
509
  let targetDate: Date;
329
510
  if (dateStr) {
330
511
  const parsed = parseDateOnlyInput(dateStr);
331
- targetDate = localToUTC(parsed, timezone);
512
+ targetDate = localToUTC(parsed, calculationTimezone);
332
513
  } else {
333
514
  const now = this.now();
334
- const localNow = utcToLocal(now, timezone);
515
+ const localNow = utcToLocal(now, calculationTimezone);
335
516
  const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
336
- targetDate = localToUTC(localNoon, timezone);
517
+ targetDate = localToUTC(localNoon, calculationTimezone);
337
518
  }
338
519
 
339
520
  const allTransits: Transit[] = [];
340
- const startLocal = utcToLocal(targetDate, timezone);
341
- for (let day = 0; day <= daysAhead; day++) {
342
- const dayUTC = addLocalDays(startLocal, timezone, day);
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);
343
527
  const jd = this.ephem.dateToJulianDay(dayUTC);
344
528
  const transitingPlanets = this.ephem.getAllPlanets(jd, transitingPlanetIds);
345
529
  const transits = this.transitCalc.findTransits(
@@ -347,22 +531,65 @@ export class AstroService {
347
531
  natalChart.planets || [],
348
532
  jd
349
533
  );
534
+ for (const transit of transits) {
535
+ transitContext.set(transit, { julianDay: jd });
536
+ }
350
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);
351
541
  }
352
542
 
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);
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
+ }
358
566
 
359
- const localDate = utcToLocal(targetDate, timezone);
360
- const dateLabel = `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
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;
361
591
 
362
- const structuredData: TransitResponse = {
363
- date: dateLabel,
364
- timezone,
365
- transits: filteredTransits.map((t) => ({
592
+ return {
366
593
  transitingPlanet: t.transitingPlanet,
367
594
  aspect: t.aspect,
368
595
  natalPlanet: t.natalPlanet,
@@ -372,7 +599,42 @@ export class AstroService {
372
599
  exactTime: t.exactTime?.toISOString(),
373
600
  transitLongitude: t.transitLongitude,
374
601
  natalLongitude: t.natalLongitude,
375
- })),
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,
376
638
  };
377
639
 
378
640
  let responseData: Record<string, unknown> = structuredData as unknown as Record<
@@ -381,36 +643,85 @@ export class AstroService {
381
643
  >;
382
644
  let mundaneText = '';
383
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
+
384
667
  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,
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,
391
681
  };
392
- responseData = { transits: structuredData, mundane: mundaneData };
393
- mundaneText = `\n\nCurrent Planetary Positions:\n\n${currentPositions
682
+ responseData = { transits: responseData, mundane: mundaneData };
683
+ mundaneText = `\n\nCurrent Planetary Positions:\n\n${anchorMundane.positions
394
684
  .map(
395
685
  (p) =>
396
686
  `${p.planet}: ${p.degree.toFixed(1)}° ${p.sign} (${p.isRetrograde ? 'Rx' : 'Direct'})`
397
687
  )
398
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
+ }
399
693
  }
400
694
 
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.';
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
+ }
414
725
 
415
726
  return {
416
727
  data: responseData,
@@ -418,11 +729,277 @@ export class AstroService {
418
729
  };
419
730
  }
420
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
+ };
800
+ }
801
+
802
+ 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
+ };
996
+ }
997
+
421
998
  getHouses(
422
999
  natalChart: NatalChart,
423
1000
  input: GetHousesInput = {}
424
1001
  ): ServiceResult<Record<string, unknown>> {
425
- const system = input.system || natalChart.houseSystem || 'P';
1002
+ const system = this.resolveHouseSystem(natalChart, input.system);
426
1003
  if (!natalChart.julianDay) {
427
1004
  throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
428
1005
  }
@@ -449,19 +1026,235 @@ export class AstroService {
449
1026
  };
450
1027
  }
451
1028
 
452
- getRetrogradePlanets(timezone = 'UTC'): ServiceResult<Record<string, unknown>> {
1029
+ 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';
1242
+ }
1243
+
1244
+ getRetrogradePlanets(timezone?: string): ServiceResult<Record<string, unknown>> {
1245
+ const resolvedTimezone = this.resolveReportingTimezone(timezone);
453
1246
  const now = this.now();
454
1247
  const jd = this.ephem.dateToJulianDay(now);
455
1248
  const allPlanetIds = Object.values(PLANETS);
456
1249
  const positions = this.ephem.getAllPlanets(jd, allPlanetIds);
457
1250
  const retrograde = positions.filter((p) => p.isRetrograde);
458
1251
 
459
- const localNow = utcToLocal(now, timezone);
1252
+ const localNow = utcToLocal(now, resolvedTimezone);
460
1253
  const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
461
1254
 
462
1255
  const structuredData = {
463
1256
  date: dateLabel,
464
- timezone,
1257
+ timezone: resolvedTimezone,
465
1258
  planets: retrograde,
466
1259
  };
467
1260
 
@@ -475,6 +1268,7 @@ export class AstroService {
475
1268
 
476
1269
  async getRiseSetTimes(natalChart: NatalChart): Promise<ServiceResult<Record<string, unknown>>> {
477
1270
  const timezone = natalChart.location.timezone;
1271
+ const reportingTimezone = this.mcpStartupDefaults.preferredTimezone || timezone;
478
1272
  const now = this.now();
479
1273
  const localNow = utcToLocal(now, timezone);
480
1274
  const localMidnight = {
@@ -507,8 +1301,8 @@ export class AstroService {
507
1301
 
508
1302
  const humanText = `Rise/Set Times:\n\n${results
509
1303
  .map((r) => {
510
- const rise = r.rise ? formatInTimezone(r.rise, timezone) : 'none';
511
- const set = r.set ? formatInTimezone(r.set, timezone) : 'none';
1304
+ const rise = r.rise ? this.formatTimestamp(r.rise, reportingTimezone) : 'none';
1305
+ const set = r.set ? this.formatTimestamp(r.set, reportingTimezone) : 'none';
512
1306
  return `${r.planet}: Rise ${rise}, Set ${set}`;
513
1307
  })
514
1308
  .join('\n')}`;
@@ -519,18 +1313,19 @@ export class AstroService {
519
1313
  };
520
1314
  }
521
1315
 
522
- getAsteroidPositions(timezone = 'UTC'): ServiceResult<Record<string, unknown>> {
1316
+ getAsteroidPositions(timezone?: string): ServiceResult<Record<string, unknown>> {
1317
+ const resolvedTimezone = this.resolveReportingTimezone(timezone);
523
1318
  const now = this.now();
524
1319
  const jd = this.ephem.dateToJulianDay(now);
525
1320
  const asteroidIds = [...ASTEROIDS, ...NODES];
526
1321
  const positions = this.ephem.getAllPlanets(jd, asteroidIds);
527
1322
 
528
- const localNow = utcToLocal(now, timezone);
1323
+ const localNow = utcToLocal(now, resolvedTimezone);
529
1324
  const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
530
1325
 
531
1326
  const structuredData = {
532
1327
  date: dateLabel,
533
- timezone,
1328
+ timezone: resolvedTimezone,
534
1329
  positions,
535
1330
  };
536
1331
 
@@ -547,7 +1342,8 @@ export class AstroService {
547
1342
  };
548
1343
  }
549
1344
 
550
- getNextEclipses(timezone = 'UTC'): ServiceResult<Record<string, unknown>> {
1345
+ getNextEclipses(timezone?: string): ServiceResult<Record<string, unknown>> {
1346
+ const resolvedTimezone = this.resolveReportingTimezone(timezone);
551
1347
  const now = this.now();
552
1348
  const jd = this.ephem.dateToJulianDay(now);
553
1349
 
@@ -564,7 +1360,7 @@ export class AstroService {
564
1360
  maxTime: solarEclipse.maxTime.toISOString(),
565
1361
  });
566
1362
  humanLines.push(
567
- `Next Solar Eclipse: ${formatInTimezone(solarEclipse.maxTime, timezone)} (${solarEclipse.eclipseType})`
1363
+ `Next Solar Eclipse: ${this.formatTimestamp(solarEclipse.maxTime, resolvedTimezone)} (${solarEclipse.eclipseType})`
568
1364
  );
569
1365
  }
570
1366
 
@@ -575,11 +1371,11 @@ export class AstroService {
575
1371
  maxTime: lunarEclipse.maxTime.toISOString(),
576
1372
  });
577
1373
  humanLines.push(
578
- `Next Lunar Eclipse: ${formatInTimezone(lunarEclipse.maxTime, timezone)} (${lunarEclipse.eclipseType})`
1374
+ `Next Lunar Eclipse: ${this.formatTimestamp(lunarEclipse.maxTime, resolvedTimezone)} (${lunarEclipse.eclipseType})`
579
1375
  );
580
1376
  }
581
1377
 
582
- const structuredData = { timezone, eclipses };
1378
+ const structuredData = { timezone: resolvedTimezone, eclipses };
583
1379
  const humanText =
584
1380
  eclipses.length === 0
585
1381
  ? 'No eclipses found in the near future.'
@@ -594,6 +1390,11 @@ export class AstroService {
594
1390
  hasNatalChart: natalChart !== null,
595
1391
  natalChartName: natalChart?.name ?? null,
596
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
+ },
597
1398
  ephemerisInitialized: this.isInitialized(),
598
1399
  stateModel: 'stateful-per-process',
599
1400
  };