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,181 @@
1
+ import type { EphemerisCalculator } from '../ephemeris.js';
2
+ import { formatInTimezone } from '../formatter.js';
3
+ import type { HouseCalculator } from '../houses.js';
4
+ import {
5
+ addLocalDays,
6
+ formatLocalTimestampWithOffset,
7
+ localToUTC,
8
+ utcToLocal,
9
+ } from '../time-utils.js';
10
+ import { ZODIAC_SIGNS } from '../types.js';
11
+ import { parseDateOnlyInput } from './date-input.js';
12
+ import type { GetRisingSignWindowsInput, ServiceResult } from './service-types.js';
13
+ import { normalizeLongitude } from './shared.js';
14
+
15
+ interface RisingSignServiceDependencies {
16
+ ephem: EphemerisCalculator;
17
+ houseCalc: HouseCalculator;
18
+ }
19
+
20
+ /**
21
+ * Internal rising-sign window scanner used by `AstroService`.
22
+ *
23
+ * @remarks
24
+ * This module owns the local-day scan, optional exact-boundary refinement, and
25
+ * serialization of sign windows while the public facade keeps the same method
26
+ * signature and result shape.
27
+ */
28
+ export class RisingSignService {
29
+ private readonly ephem: EphemerisCalculator;
30
+ private readonly houseCalc: HouseCalculator;
31
+
32
+ constructor(deps: RisingSignServiceDependencies) {
33
+ this.ephem = deps.ephem;
34
+ this.houseCalc = deps.houseCalc;
35
+ }
36
+
37
+ /**
38
+ * Find rising-sign windows across a local calendar day.
39
+ */
40
+ getRisingSignWindows(input: GetRisingSignWindowsInput): ServiceResult<Record<string, unknown>> {
41
+ const mode = input.mode ?? 'approximate';
42
+ if (mode !== 'approximate' && mode !== 'exact') {
43
+ throw new Error(`Invalid mode: ${mode} (must be approximate or exact)`);
44
+ }
45
+ if (input.latitude < -90 || input.latitude > 90) {
46
+ throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
47
+ }
48
+ if (input.longitude < -180 || input.longitude > 180) {
49
+ throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
50
+ }
51
+
52
+ const parsed = parseDateOnlyInput(input.date);
53
+ try {
54
+ utcToLocal(new Date(), input.timezone);
55
+ } catch {
56
+ throw new Error(`Invalid timezone: ${input.timezone}`);
57
+ }
58
+
59
+ const dayStartLocal = {
60
+ year: parsed.year,
61
+ month: parsed.month,
62
+ day: parsed.day,
63
+ hour: 0,
64
+ minute: 0,
65
+ second: 0,
66
+ };
67
+ const dayStartUtc = localToUTC(dayStartLocal, input.timezone);
68
+ const dayEndUtc = addLocalDays(dayStartLocal, input.timezone, 1);
69
+
70
+ const stepMs = mode === 'exact' ? 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
71
+ const probeStepMs = mode === 'exact' ? 5 * 60 * 1000 : 30 * 60 * 1000;
72
+ const boundaries: Date[] = [dayStartUtc];
73
+ let cursor = dayStartUtc;
74
+ while (cursor < dayEndUtc) {
75
+ const next = new Date(Math.min(cursor.getTime() + stepMs, dayEndUtc.getTime()));
76
+ boundaries.push(...this.findSignTransitionsInBucket(input, mode, cursor, next, probeStepMs));
77
+ cursor = next;
78
+ }
79
+ boundaries.push(dayEndUtc);
80
+
81
+ const windows = boundaries.slice(0, -1).map((start, index) => {
82
+ const end = boundaries[index + 1];
83
+ const sample = new Date((start.getTime() + end.getTime()) / 2);
84
+ const sign = this.getAscSign(input, sample).sign;
85
+ return {
86
+ sign,
87
+ start: formatLocalTimestampWithOffset(start, input.timezone),
88
+ end: formatLocalTimestampWithOffset(end, input.timezone),
89
+ durationMs: end.getTime() - start.getTime(),
90
+ };
91
+ });
92
+
93
+ const structuredData = {
94
+ date: input.date,
95
+ timezone: input.timezone,
96
+ location: {
97
+ latitude: input.latitude,
98
+ longitude: input.longitude,
99
+ },
100
+ mode,
101
+ windows,
102
+ };
103
+
104
+ const humanText = `Rising Sign Windows (${input.date}, ${input.timezone}, ${mode}):\n\n${windows
105
+ .map(
106
+ (window) =>
107
+ `${window.sign}: ${formatInTimezone(new Date(window.start), input.timezone)} → ${formatInTimezone(new Date(window.end), input.timezone)}`
108
+ )
109
+ .join('\n')}`;
110
+
111
+ return {
112
+ data: structuredData,
113
+ text: humanText,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Sample the ascendant sign for a specific moment.
119
+ */
120
+ private getAscSign(
121
+ input: Pick<GetRisingSignWindowsInput, 'latitude' | 'longitude'>,
122
+ date: Date
123
+ ): { sign: string; longitude: number } {
124
+ const jd = this.ephem.dateToJulianDay(date);
125
+ const houses = this.houseCalc.calculateHouses(jd, input.latitude, input.longitude, 'P');
126
+ const normalized = normalizeLongitude(houses.ascendant);
127
+ return { sign: ZODIAC_SIGNS[Math.floor(normalized / 30)], longitude: normalized };
128
+ }
129
+
130
+ /**
131
+ * Binary-search a sign change down to a stable exact-mode boundary.
132
+ */
133
+ private refineBoundary(
134
+ input: Pick<GetRisingSignWindowsInput, 'latitude' | 'longitude'>,
135
+ left: Date,
136
+ right: Date
137
+ ): Date {
138
+ const leftSign = this.getAscSign(input, left).sign;
139
+ let lo = left;
140
+ let hi = right;
141
+ for (let i = 0; i < 25; i++) {
142
+ const mid = new Date((lo.getTime() + hi.getTime()) / 2);
143
+ const midSign = this.getAscSign(input, mid).sign;
144
+ if (midSign === leftSign) {
145
+ lo = mid;
146
+ } else {
147
+ hi = mid;
148
+ }
149
+ }
150
+ return hi;
151
+ }
152
+
153
+ /**
154
+ * Probe a scan bucket and emit every sign transition inside it.
155
+ */
156
+ private findSignTransitionsInBucket(
157
+ input: Pick<GetRisingSignWindowsInput, 'latitude' | 'longitude'>,
158
+ mode: 'approximate' | 'exact',
159
+ start: Date,
160
+ end: Date,
161
+ probeStepMs: number
162
+ ): Date[] {
163
+ const boundaries: Date[] = [];
164
+ let probeCursor = start;
165
+ let currentSign = this.getAscSign(input, probeCursor).sign;
166
+
167
+ while (probeCursor < end) {
168
+ const probeNext = new Date(Math.min(probeCursor.getTime() + probeStepMs, end.getTime()));
169
+ const nextSign = this.getAscSign(input, probeNext).sign;
170
+ if (nextSign !== currentSign) {
171
+ boundaries.push(
172
+ mode === 'exact' ? this.refineBoundary(input, probeCursor, probeNext) : probeNext
173
+ );
174
+ }
175
+ probeCursor = probeNext;
176
+ currentSign = nextSign;
177
+ }
178
+
179
+ return boundaries;
180
+ }
181
+ }
@@ -0,0 +1,90 @@
1
+ import type { Disambiguation } from '../time-utils.js';
2
+ import type { ElectionalHouseSystem, HouseSystem } from '../types.js';
3
+
4
+ /**
5
+ * Public input type for building and caching the shared natal chart payload.
6
+ */
7
+ export interface SetNatalChartInput {
8
+ name: string;
9
+ year: number;
10
+ month: number;
11
+ day: number;
12
+ hour: number;
13
+ minute: number;
14
+ latitude: number;
15
+ longitude: number;
16
+ timezone: string;
17
+ house_system?: HouseSystem;
18
+ birth_time_disambiguation?: Disambiguation;
19
+ }
20
+
21
+ /**
22
+ * Public input type for querying natal transits.
23
+ */
24
+ export interface GetTransitsInput {
25
+ date?: string;
26
+ categories?: string[];
27
+ include_mundane?: boolean;
28
+ days_ahead?: number;
29
+ mode?: 'snapshot' | 'best_hit' | 'forecast';
30
+ max_orb?: number;
31
+ exact_only?: boolean;
32
+ applying_only?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Public input type for stateless electional context lookup.
37
+ */
38
+ export interface GetElectionalContextInput {
39
+ date: string;
40
+ time: string;
41
+ timezone: string;
42
+ latitude: number;
43
+ longitude: number;
44
+ house_system?: ElectionalHouseSystem;
45
+ include_ruler_basics?: boolean;
46
+ include_planetary_applications?: boolean;
47
+ orb_degrees?: number;
48
+ }
49
+
50
+ /**
51
+ * Public input type for house lookup on an existing natal chart.
52
+ */
53
+ export interface GetHousesInput {
54
+ system?: string;
55
+ }
56
+
57
+ /**
58
+ * Public input type for daily rising-sign window lookup.
59
+ */
60
+ export interface GetRisingSignWindowsInput {
61
+ date: string;
62
+ latitude: number;
63
+ longitude: number;
64
+ timezone: string;
65
+ mode?: 'approximate' | 'exact';
66
+ }
67
+
68
+ /**
69
+ * Public output-wrapper shared by service methods that return data plus text.
70
+ */
71
+ export interface ServiceResult<T> {
72
+ data: T;
73
+ text: string;
74
+ }
75
+
76
+ /**
77
+ * Public input type for chart rendering methods.
78
+ */
79
+ export interface GenerateChartInput {
80
+ theme?: 'light' | 'dark';
81
+ format?: 'svg' | 'png' | 'webp';
82
+ output_path?: string;
83
+ }
84
+
85
+ /**
86
+ * Public input type for transit-chart rendering methods.
87
+ */
88
+ export interface GenerateTransitChartInput extends GenerateChartInput {
89
+ date?: string;
90
+ }
@@ -0,0 +1,128 @@
1
+ import type { McpStartupDefaults } from '../entrypoint.js';
2
+ import { type HouseData, type HouseSystem, type NatalChart, ZODIAC_SIGNS } from '../types.js';
3
+
4
+ /**
5
+ * Normalize any longitude into the standard 0-360 range.
6
+ *
7
+ * @param longitude - Raw longitude in degrees, including negative or >360 values
8
+ * @returns Longitude normalized into the half-open interval [0, 360)
9
+ */
10
+ export function normalizeLongitude(longitude: number): number {
11
+ return ((longitude % 360) + 360) % 360;
12
+ }
13
+
14
+ /**
15
+ * Convert a raw longitude into zodiac sign and in-sign degree.
16
+ *
17
+ * @param longitude - Raw longitude in degrees
18
+ * @returns Sign name plus degree within that sign
19
+ *
20
+ * @remarks
21
+ * Rounded degree values that would otherwise land on 30.00 are carried into
22
+ * the next sign so serialized placement stays astrologically valid.
23
+ */
24
+ export function getSignAndDegree(longitude: number): { sign: string; degree: number } {
25
+ const normalized = normalizeLongitude(longitude);
26
+ const baseSignIndex = Math.floor(normalized / 30);
27
+ const roundedDegree = Number.parseFloat((normalized % 30).toFixed(2));
28
+ const shouldCarryToNextSign = roundedDegree >= 30;
29
+ const signIndex = shouldCarryToNextSign
30
+ ? (baseSignIndex + 1) % ZODIAC_SIGNS.length
31
+ : baseSignIndex;
32
+
33
+ return {
34
+ sign: ZODIAC_SIGNS[signIndex],
35
+ degree: shouldCarryToNextSign ? 0 : roundedDegree,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Map a longitude to its house number for a resolved house table.
41
+ *
42
+ * @param longitude - Longitude to place into a house
43
+ * @param houses - Resolved house cusps for the relevant chart or moment
44
+ * @returns 1-based house number
45
+ */
46
+ export function getHouseNumber(longitude: number, houses: HouseData): number {
47
+ const normalized = normalizeLongitude(longitude);
48
+
49
+ for (let house = 1; house <= 12; house++) {
50
+ const start = normalizeLongitude(houses.cusps[house]);
51
+ const nextHouse = house === 12 ? 1 : house + 1;
52
+ const end = normalizeLongitude(houses.cusps[nextHouse]);
53
+ const span = (end - start + 360) % 360;
54
+ const offset = (normalized - start + 360) % 360;
55
+
56
+ if (span === 0 || offset === 0 || offset < span) {
57
+ return house;
58
+ }
59
+ }
60
+
61
+ return 12;
62
+ }
63
+
64
+ /**
65
+ * Resolve the house system precedence shared by service entrypoints.
66
+ *
67
+ * @param natalChart - Natal chart carrying stored and requested house system state
68
+ * @param startupDefaults - Process startup defaults that can provide fallback policy
69
+ * @param explicitSystem - Per-call override when the caller requested a specific system
70
+ * @returns Final house system to use for the calculation
71
+ */
72
+ export function resolveHouseSystem(
73
+ natalChart: NatalChart,
74
+ startupDefaults: Readonly<McpStartupDefaults>,
75
+ explicitSystem?: string
76
+ ): HouseSystem {
77
+ return (explicitSystem ||
78
+ natalChart.requestedHouseSystem ||
79
+ startupDefaults.preferredHouseStyle ||
80
+ natalChart.houseSystem ||
81
+ 'P') as HouseSystem;
82
+ }
83
+
84
+ /**
85
+ * Resolve the reporting timezone precedence shared by service entrypoints.
86
+ *
87
+ * @param startupDefaults - Process startup defaults
88
+ * @param explicitTimezone - Per-call reporting timezone override
89
+ * @param natalTimezone - Natal chart timezone used as fallback
90
+ * @returns Final reporting timezone for text and date labels
91
+ */
92
+ export function resolveReportingTimezone(
93
+ startupDefaults: Readonly<McpStartupDefaults>,
94
+ explicitTimezone?: string,
95
+ natalTimezone?: string
96
+ ): string {
97
+ return explicitTimezone ?? startupDefaults.preferredTimezone ?? natalTimezone ?? 'UTC';
98
+ }
99
+
100
+ /**
101
+ * Resolve both calculation and reporting timezones for chart-based workflows.
102
+ *
103
+ * @param startupDefaults - Process startup defaults
104
+ * @param explicitReportingTimezone - Per-call reporting timezone override
105
+ * @param natalTimezone - Natal chart timezone used for local-day interpretation
106
+ * @returns Calculation timezone plus reporting timezone
107
+ *
108
+ * @remarks
109
+ * Calculation timezone controls local-day math and ephemeris lookups. Reporting
110
+ * timezone controls user-facing labels and formatted timestamps.
111
+ */
112
+ export function resolveTimezones(
113
+ startupDefaults: Readonly<McpStartupDefaults>,
114
+ explicitReportingTimezone?: string,
115
+ natalTimezone?: string
116
+ ): {
117
+ calculationTimezone: string;
118
+ reportingTimezone: string;
119
+ } {
120
+ return {
121
+ calculationTimezone: natalTimezone ?? 'UTC',
122
+ reportingTimezone: resolveReportingTimezone(
123
+ startupDefaults,
124
+ explicitReportingTimezone,
125
+ natalTimezone
126
+ ),
127
+ };
128
+ }
@@ -0,0 +1,191 @@
1
+ import type { EclipseCalculator } from '../eclipses.js';
2
+ import type { McpStartupDefaults } from '../entrypoint.js';
3
+ import type { EphemerisCalculator } from '../ephemeris.js';
4
+ import type { RiseSetCalculator } from '../riseset.js';
5
+ import { localToUTC, utcToLocal } from '../time-utils.js';
6
+ import { ASTEROIDS, type NatalChart, NODES, PLANETS } from '../types.js';
7
+ import type { ServiceResult } from './service-types.js';
8
+ import { resolveReportingTimezone } from './shared.js';
9
+
10
+ interface SkyServiceDependencies {
11
+ ephem: EphemerisCalculator;
12
+ riseSetCalc: RiseSetCalculator;
13
+ eclipseCalc: EclipseCalculator;
14
+ mcpStartupDefaults: Readonly<McpStartupDefaults>;
15
+ now: () => Date;
16
+ formatTimestamp: (date: Date, timezone: string) => string;
17
+ }
18
+
19
+ /**
20
+ * Internal current-sky and runtime lookup workflow used by `AstroService`.
21
+ *
22
+ * @remarks
23
+ * This module owns read-only runtime lookups that depend on "now", including
24
+ * retrogrades, asteroid/node snapshots, rise/set tables, and eclipse queries.
25
+ */
26
+ export class SkyService {
27
+ private readonly ephem: EphemerisCalculator;
28
+ private readonly riseSetCalc: RiseSetCalculator;
29
+ private readonly eclipseCalc: EclipseCalculator;
30
+ private readonly mcpStartupDefaults: Readonly<McpStartupDefaults>;
31
+ private readonly now: () => Date;
32
+ private readonly formatTimestamp: (date: Date, timezone: string) => string;
33
+
34
+ constructor(deps: SkyServiceDependencies) {
35
+ this.ephem = deps.ephem;
36
+ this.riseSetCalc = deps.riseSetCalc;
37
+ this.eclipseCalc = deps.eclipseCalc;
38
+ this.mcpStartupDefaults = deps.mcpStartupDefaults;
39
+ this.now = deps.now;
40
+ this.formatTimestamp = deps.formatTimestamp;
41
+ }
42
+
43
+ /**
44
+ * Return the currently retrograde planets for the requested reporting timezone.
45
+ */
46
+ getRetrogradePlanets(timezone?: string): ServiceResult<Record<string, unknown>> {
47
+ const resolvedTimezone = resolveReportingTimezone(this.mcpStartupDefaults, timezone);
48
+ const now = this.now();
49
+ const jd = this.ephem.dateToJulianDay(now);
50
+ const positions = this.ephem.getAllPlanets(jd, Object.values(PLANETS));
51
+ const retrograde = positions.filter((position) => position.isRetrograde);
52
+
53
+ const structuredData = {
54
+ date: this.getDateLabel(now, resolvedTimezone),
55
+ timezone: resolvedTimezone,
56
+ planets: retrograde,
57
+ };
58
+
59
+ const humanText =
60
+ retrograde.length === 0
61
+ ? 'No planets are currently retrograde.'
62
+ : `Retrograde Planets:\n\n${retrograde.map((position) => `${position.planet}: ${position.degree.toFixed(2)}° ${position.sign}`).join('\n')}`;
63
+
64
+ return { data: structuredData, text: humanText };
65
+ }
66
+
67
+ /**
68
+ * Return the next rise and set events after the local day anchor for the chart location.
69
+ */
70
+ async getRiseSetTimes(natalChart: NatalChart): Promise<ServiceResult<Record<string, unknown>>> {
71
+ const timezone = natalChart.location.timezone;
72
+ const reportingTimezone = this.mcpStartupDefaults.preferredTimezone || timezone;
73
+ const now = this.now();
74
+ const localNow = utcToLocal(now, timezone);
75
+ const localMidnight = {
76
+ year: localNow.year,
77
+ month: localNow.month,
78
+ day: localNow.day,
79
+ hour: 0,
80
+ minute: 0,
81
+ second: 0,
82
+ };
83
+ const midnightUTC = localToUTC(localMidnight, timezone);
84
+
85
+ const results = await this.riseSetCalc.getAllRiseSet(
86
+ midnightUTC,
87
+ natalChart.location.latitude,
88
+ natalChart.location.longitude
89
+ );
90
+
91
+ const structuredData = {
92
+ date: this.getDateLabel(now, timezone),
93
+ timezone,
94
+ times: results.map((result) => ({
95
+ planet: result.planet,
96
+ rise: result.rise?.toISOString() ?? null,
97
+ set: result.set?.toISOString() ?? null,
98
+ })),
99
+ };
100
+
101
+ const humanText = `Rise/Set Times:\n\n${results
102
+ .map((result) => {
103
+ const rise = result.rise ? this.formatTimestamp(result.rise, reportingTimezone) : 'none';
104
+ const set = result.set ? this.formatTimestamp(result.set, reportingTimezone) : 'none';
105
+ return `${result.planet}: Rise ${rise}, Set ${set}`;
106
+ })
107
+ .join('\n')}`;
108
+
109
+ return {
110
+ data: structuredData,
111
+ text: humanText,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Return current asteroid and node positions for the requested reporting timezone.
117
+ */
118
+ getAsteroidPositions(timezone?: string): ServiceResult<Record<string, unknown>> {
119
+ const resolvedTimezone = resolveReportingTimezone(this.mcpStartupDefaults, timezone);
120
+ const now = this.now();
121
+ const jd = this.ephem.dateToJulianDay(now);
122
+ const positions = this.ephem.getAllPlanets(jd, [...ASTEROIDS, ...NODES]);
123
+
124
+ const structuredData = {
125
+ date: this.getDateLabel(now, resolvedTimezone),
126
+ timezone: resolvedTimezone,
127
+ positions,
128
+ };
129
+
130
+ const humanText = `Asteroid & Node Positions:\n\n${positions
131
+ .map((position) => {
132
+ const retrogradeLabel = position.isRetrograde ? ' Rx' : '';
133
+ return `${position.planet}: ${position.degree.toFixed(2)}° ${position.sign}${retrogradeLabel}`;
134
+ })
135
+ .join('\n')}`;
136
+
137
+ return {
138
+ data: structuredData,
139
+ text: humanText,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Look up the next solar and lunar eclipses after the current instant.
145
+ */
146
+ getNextEclipses(timezone?: string): ServiceResult<Record<string, unknown>> {
147
+ const resolvedTimezone = resolveReportingTimezone(this.mcpStartupDefaults, timezone);
148
+ const jd = this.ephem.dateToJulianDay(this.now());
149
+
150
+ const solarEclipse = this.eclipseCalc.findNextSolarEclipse(jd);
151
+ const lunarEclipse = this.eclipseCalc.findNextLunarEclipse(jd);
152
+
153
+ const eclipses: Array<{ type: string; eclipseType: string; maxTime: string }> = [];
154
+ const humanLines: string[] = [];
155
+
156
+ if (solarEclipse) {
157
+ eclipses.push({
158
+ type: solarEclipse.type,
159
+ eclipseType: solarEclipse.eclipseType,
160
+ maxTime: solarEclipse.maxTime.toISOString(),
161
+ });
162
+ humanLines.push(
163
+ `Next Solar Eclipse: ${this.formatTimestamp(solarEclipse.maxTime, resolvedTimezone)} (${solarEclipse.eclipseType})`
164
+ );
165
+ }
166
+
167
+ if (lunarEclipse) {
168
+ eclipses.push({
169
+ type: lunarEclipse.type,
170
+ eclipseType: lunarEclipse.eclipseType,
171
+ maxTime: lunarEclipse.maxTime.toISOString(),
172
+ });
173
+ humanLines.push(
174
+ `Next Lunar Eclipse: ${this.formatTimestamp(lunarEclipse.maxTime, resolvedTimezone)} (${lunarEclipse.eclipseType})`
175
+ );
176
+ }
177
+
178
+ const structuredData = { timezone: resolvedTimezone, eclipses };
179
+ const humanText =
180
+ eclipses.length === 0
181
+ ? 'No eclipses found in the near future.'
182
+ : `Upcoming Eclipses:\n\n${humanLines.join('\n')}`;
183
+
184
+ return { data: structuredData, text: humanText };
185
+ }
186
+
187
+ private getDateLabel(date: Date, timezone: string): string {
188
+ const localDate = utcToLocal(date, timezone);
189
+ return `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
190
+ }
191
+ }