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,144 @@
1
+ import { localToUTC, utcToLocal } from '../time-utils.js';
2
+ import { ASTEROIDS, NODES, PLANETS } from '../types.js';
3
+ import { resolveReportingTimezone } from './shared.js';
4
+ /**
5
+ * Internal current-sky and runtime lookup workflow used by `AstroService`.
6
+ *
7
+ * @remarks
8
+ * This module owns read-only runtime lookups that depend on "now", including
9
+ * retrogrades, asteroid/node snapshots, rise/set tables, and eclipse queries.
10
+ */
11
+ export class SkyService {
12
+ ephem;
13
+ riseSetCalc;
14
+ eclipseCalc;
15
+ mcpStartupDefaults;
16
+ now;
17
+ formatTimestamp;
18
+ constructor(deps) {
19
+ this.ephem = deps.ephem;
20
+ this.riseSetCalc = deps.riseSetCalc;
21
+ this.eclipseCalc = deps.eclipseCalc;
22
+ this.mcpStartupDefaults = deps.mcpStartupDefaults;
23
+ this.now = deps.now;
24
+ this.formatTimestamp = deps.formatTimestamp;
25
+ }
26
+ /**
27
+ * Return the currently retrograde planets for the requested reporting timezone.
28
+ */
29
+ getRetrogradePlanets(timezone) {
30
+ const resolvedTimezone = resolveReportingTimezone(this.mcpStartupDefaults, timezone);
31
+ const now = this.now();
32
+ const jd = this.ephem.dateToJulianDay(now);
33
+ const positions = this.ephem.getAllPlanets(jd, Object.values(PLANETS));
34
+ const retrograde = positions.filter((position) => position.isRetrograde);
35
+ const structuredData = {
36
+ date: this.getDateLabel(now, resolvedTimezone),
37
+ timezone: resolvedTimezone,
38
+ planets: retrograde,
39
+ };
40
+ const humanText = retrograde.length === 0
41
+ ? 'No planets are currently retrograde.'
42
+ : `Retrograde Planets:\n\n${retrograde.map((position) => `${position.planet}: ${position.degree.toFixed(2)}° ${position.sign}`).join('\n')}`;
43
+ return { data: structuredData, text: humanText };
44
+ }
45
+ /**
46
+ * Return the next rise and set events after the local day anchor for the chart location.
47
+ */
48
+ async getRiseSetTimes(natalChart) {
49
+ const timezone = natalChart.location.timezone;
50
+ const reportingTimezone = this.mcpStartupDefaults.preferredTimezone || timezone;
51
+ const now = this.now();
52
+ const localNow = utcToLocal(now, timezone);
53
+ const localMidnight = {
54
+ year: localNow.year,
55
+ month: localNow.month,
56
+ day: localNow.day,
57
+ hour: 0,
58
+ minute: 0,
59
+ second: 0,
60
+ };
61
+ const midnightUTC = localToUTC(localMidnight, timezone);
62
+ const results = await this.riseSetCalc.getAllRiseSet(midnightUTC, natalChart.location.latitude, natalChart.location.longitude);
63
+ const structuredData = {
64
+ date: this.getDateLabel(now, timezone),
65
+ timezone,
66
+ times: results.map((result) => ({
67
+ planet: result.planet,
68
+ rise: result.rise?.toISOString() ?? null,
69
+ set: result.set?.toISOString() ?? null,
70
+ })),
71
+ };
72
+ const humanText = `Rise/Set Times:\n\n${results
73
+ .map((result) => {
74
+ const rise = result.rise ? this.formatTimestamp(result.rise, reportingTimezone) : 'none';
75
+ const set = result.set ? this.formatTimestamp(result.set, reportingTimezone) : 'none';
76
+ return `${result.planet}: Rise ${rise}, Set ${set}`;
77
+ })
78
+ .join('\n')}`;
79
+ return {
80
+ data: structuredData,
81
+ text: humanText,
82
+ };
83
+ }
84
+ /**
85
+ * Return current asteroid and node positions for the requested reporting timezone.
86
+ */
87
+ getAsteroidPositions(timezone) {
88
+ const resolvedTimezone = resolveReportingTimezone(this.mcpStartupDefaults, timezone);
89
+ const now = this.now();
90
+ const jd = this.ephem.dateToJulianDay(now);
91
+ const positions = this.ephem.getAllPlanets(jd, [...ASTEROIDS, ...NODES]);
92
+ const structuredData = {
93
+ date: this.getDateLabel(now, resolvedTimezone),
94
+ timezone: resolvedTimezone,
95
+ positions,
96
+ };
97
+ const humanText = `Asteroid & Node Positions:\n\n${positions
98
+ .map((position) => {
99
+ const retrogradeLabel = position.isRetrograde ? ' Rx' : '';
100
+ return `${position.planet}: ${position.degree.toFixed(2)}° ${position.sign}${retrogradeLabel}`;
101
+ })
102
+ .join('\n')}`;
103
+ return {
104
+ data: structuredData,
105
+ text: humanText,
106
+ };
107
+ }
108
+ /**
109
+ * Look up the next solar and lunar eclipses after the current instant.
110
+ */
111
+ getNextEclipses(timezone) {
112
+ const resolvedTimezone = resolveReportingTimezone(this.mcpStartupDefaults, timezone);
113
+ const jd = this.ephem.dateToJulianDay(this.now());
114
+ const solarEclipse = this.eclipseCalc.findNextSolarEclipse(jd);
115
+ const lunarEclipse = this.eclipseCalc.findNextLunarEclipse(jd);
116
+ const eclipses = [];
117
+ const humanLines = [];
118
+ if (solarEclipse) {
119
+ eclipses.push({
120
+ type: solarEclipse.type,
121
+ eclipseType: solarEclipse.eclipseType,
122
+ maxTime: solarEclipse.maxTime.toISOString(),
123
+ });
124
+ humanLines.push(`Next Solar Eclipse: ${this.formatTimestamp(solarEclipse.maxTime, resolvedTimezone)} (${solarEclipse.eclipseType})`);
125
+ }
126
+ if (lunarEclipse) {
127
+ eclipses.push({
128
+ type: lunarEclipse.type,
129
+ eclipseType: lunarEclipse.eclipseType,
130
+ maxTime: lunarEclipse.maxTime.toISOString(),
131
+ });
132
+ humanLines.push(`Next Lunar Eclipse: ${this.formatTimestamp(lunarEclipse.maxTime, resolvedTimezone)} (${lunarEclipse.eclipseType})`);
133
+ }
134
+ const structuredData = { timezone: resolvedTimezone, eclipses };
135
+ const humanText = eclipses.length === 0
136
+ ? 'No eclipses found in the near future.'
137
+ : `Upcoming Eclipses:\n\n${humanLines.join('\n')}`;
138
+ return { data: structuredData, text: humanText };
139
+ }
140
+ getDateLabel(date, timezone) {
141
+ const localDate = utcToLocal(date, timezone);
142
+ return `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
143
+ }
144
+ }
@@ -0,0 +1,82 @@
1
+ import type { McpStartupDefaults } from '../entrypoint.js';
2
+ import type { EphemerisCalculator } from '../ephemeris.js';
3
+ import type { HouseCalculator } from '../houses.js';
4
+ import { type TransitCalculator } from '../transits.js';
5
+ import { type NatalChart } from '../types.js';
6
+ import type { GetTransitsInput, ServiceResult } from './service-types.js';
7
+ /**
8
+ * Dependencies needed by the extracted transit workflow.
9
+ */
10
+ interface TransitServiceDependencies {
11
+ ephem: EphemerisCalculator;
12
+ transitCalc: TransitCalculator;
13
+ houseCalc: HouseCalculator;
14
+ mcpStartupDefaults: Readonly<McpStartupDefaults>;
15
+ now: () => Date;
16
+ formatTimestamp: (date: Date, timezone: string) => string;
17
+ }
18
+ /**
19
+ * Internal transit workflow service used by `AstroService`.
20
+ *
21
+ * @remarks
22
+ * This module owns transit-specific validation, aggregation, placement
23
+ * enrichment, mundane expansion, and human-readable response formatting while
24
+ * the public `AstroService` facade preserves the external contract.
25
+ */
26
+ export declare class TransitService {
27
+ private readonly ephem;
28
+ private readonly transitCalc;
29
+ private readonly houseCalc;
30
+ private readonly mcpStartupDefaults;
31
+ private readonly now;
32
+ private readonly formatTimestamp;
33
+ constructor(deps: TransitServiceDependencies);
34
+ /**
35
+ * Build the transit payload and readable text for a natal chart query.
36
+ */
37
+ getTransits(natalChart: NatalChart, input?: GetTransitsInput): ServiceResult<Record<string, unknown>>;
38
+ /**
39
+ * Resolve the query anchor instant for a transit lookup.
40
+ *
41
+ * @param dateStr - Optional YYYY-MM-DD date supplied by the caller
42
+ * @param calculationTimezone - Timezone used for local-day interpretation
43
+ * @returns UTC instant representing local noon on the requested day
44
+ */
45
+ private resolveTargetDate;
46
+ /**
47
+ * Expand category filters into the concrete transiting planet ids to compute.
48
+ *
49
+ * @param categories - Requested category filters from the transit input
50
+ * @returns Deduplicated transiting planet ids in stable insertion order
51
+ */
52
+ private resolveTransitingPlanetIds;
53
+ /**
54
+ * Derive a simple supportive/challenging weather summary from mundane aspects.
55
+ *
56
+ * @param aspects - Mundane aspects for a single reporting day
57
+ * @returns Grouped weather identifiers keyed by tone
58
+ */
59
+ private getMundaneWeather;
60
+ /**
61
+ * Compute transit-to-transit mundane aspects for a single day's positions.
62
+ *
63
+ * @param date - Reporting date label used in stable aspect ids
64
+ * @param positions - Transiting planetary positions for the day
65
+ * @returns Sorted mundane aspects with orb and applying metadata
66
+ */
67
+ private getMundaneAspects;
68
+ /**
69
+ * Build the optional mundane payload for one transit day.
70
+ *
71
+ * @param dayUTC - UTC instant representing the day anchor
72
+ * @param timezone - Reporting timezone used for day labels
73
+ * @param transitingPlanetIds - Planet ids included in the mundane calculation
74
+ * @returns Daily mundane bundle with positions, aspects, and weather
75
+ */
76
+ private getMundaneDay;
77
+ /**
78
+ * Format a local date tuple into the service's canonical YYYY-MM-DD label.
79
+ */
80
+ private formatDateLabel;
81
+ }
82
+ export {};
@@ -0,0 +1,353 @@
1
+ import { addLocalDays, localToUTC, utcToLocal } from '../time-utils.js';
2
+ import { deduplicateTransits } from '../transits.js';
3
+ import { ASPECTS, OUTER_PLANETS, PERSONAL_PLANETS, PLANET_NAMES, PLANETS, } from '../types.js';
4
+ import { parseDateOnlyInput } from './date-input.js';
5
+ import { getHouseNumber, getSignAndDegree, resolveHouseSystem, resolveTimezones, } from './shared.js';
6
+ /**
7
+ * Internal transit workflow service used by `AstroService`.
8
+ *
9
+ * @remarks
10
+ * This module owns transit-specific validation, aggregation, placement
11
+ * enrichment, mundane expansion, and human-readable response formatting while
12
+ * the public `AstroService` facade preserves the external contract.
13
+ */
14
+ export class TransitService {
15
+ ephem;
16
+ transitCalc;
17
+ houseCalc;
18
+ mcpStartupDefaults;
19
+ now;
20
+ formatTimestamp;
21
+ constructor(deps) {
22
+ this.ephem = deps.ephem;
23
+ this.transitCalc = deps.transitCalc;
24
+ this.houseCalc = deps.houseCalc;
25
+ this.mcpStartupDefaults = deps.mcpStartupDefaults;
26
+ this.now = deps.now;
27
+ this.formatTimestamp = deps.formatTimestamp;
28
+ }
29
+ /**
30
+ * Build the transit payload and readable text for a natal chart query.
31
+ */
32
+ getTransits(natalChart, input = {}) {
33
+ const dateStr = input.date;
34
+ const categories = input.categories ?? ['all'];
35
+ const includeMundane = input.include_mundane ?? false;
36
+ const daysAhead = input.days_ahead ?? 0;
37
+ const requestedMode = input.mode;
38
+ const maxOrb = input.max_orb ?? 8;
39
+ const exactOnly = input.exact_only ?? false;
40
+ const applyingOnly = input.applying_only ?? false;
41
+ if (!Number.isFinite(daysAhead) || daysAhead < 0) {
42
+ throw new Error('days_ahead must be a finite number >= 0');
43
+ }
44
+ if (!Number.isFinite(maxOrb) || maxOrb < 0) {
45
+ throw new Error('max_orb must be a finite number >= 0');
46
+ }
47
+ if (requestedMode !== undefined &&
48
+ requestedMode !== 'snapshot' &&
49
+ requestedMode !== 'best_hit' &&
50
+ requestedMode !== 'forecast') {
51
+ throw new Error('mode must be one of: snapshot, best_hit, forecast');
52
+ }
53
+ if (!natalChart.julianDay) {
54
+ throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
55
+ }
56
+ const mode = requestedMode ?? (daysAhead === 0 ? 'snapshot' : 'best_hit');
57
+ const modeSource = requestedMode === undefined ? 'legacy_default' : 'explicit';
58
+ const transitingPlanetIds = this.resolveTransitingPlanetIds(categories);
59
+ const { calculationTimezone, reportingTimezone } = resolveTimezones(this.mcpStartupDefaults, undefined, natalChart.location.timezone);
60
+ const targetDate = this.resolveTargetDate(dateStr, calculationTimezone);
61
+ const allTransits = [];
62
+ const transitsByDay = new Map();
63
+ const transitContext = new WeakMap();
64
+ const startLocal = utcToLocal(targetDate, calculationTimezone);
65
+ const effectiveDaysAhead = mode === 'snapshot' ? 0 : daysAhead;
66
+ for (let day = 0; day <= effectiveDaysAhead; day++) {
67
+ const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
68
+ const julianDay = this.ephem.dateToJulianDay(dayUTC);
69
+ const transitingPlanets = this.ephem.getAllPlanets(julianDay, transitingPlanetIds);
70
+ const transits = this.transitCalc.findTransits(transitingPlanets, natalChart.planets || [], julianDay);
71
+ for (const transit of transits) {
72
+ transitContext.set(transit, { julianDay });
73
+ }
74
+ allTransits.push(...transits);
75
+ transitsByDay.set(this.formatDateLabel(utcToLocal(dayUTC, reportingTimezone)), transits);
76
+ }
77
+ const filterTransits = (transits) => {
78
+ let filtered = transits.filter((transit) => transit.orb <= maxOrb);
79
+ if (exactOnly) {
80
+ filtered = filtered.filter((transit) => transit.exactTime !== undefined);
81
+ }
82
+ if (applyingOnly) {
83
+ filtered = filtered.filter((transit) => transit.isApplying);
84
+ }
85
+ filtered.sort((left, right) => left.orb - right.orb);
86
+ return filtered;
87
+ };
88
+ const chartHouseSystem = resolveHouseSystem(natalChart, this.mcpStartupDefaults);
89
+ const natalHouses = this.houseCalc.calculateHouses(natalChart.julianDay, natalChart.location.latitude, natalChart.location.longitude, chartHouseSystem);
90
+ const transitHouseCache = new Map();
91
+ const planetIdsByName = new Map(Object.entries(PLANET_NAMES).map(([planetId, planetName]) => [planetName, Number(planetId)]));
92
+ const getTransitHouses = (julianDay) => {
93
+ const cached = transitHouseCache.get(julianDay);
94
+ if (cached) {
95
+ return cached;
96
+ }
97
+ const houses = this.houseCalc.calculateHouses(julianDay, natalChart.location.latitude, natalChart.location.longitude, chartHouseSystem);
98
+ transitHouseCache.set(julianDay, houses);
99
+ return houses;
100
+ };
101
+ const serializeTransit = (transit) => {
102
+ const transitPlacement = getSignAndDegree(transit.transitLongitude);
103
+ const natalPlacement = getSignAndDegree(transit.natalLongitude);
104
+ const context = transitContext.get(transit);
105
+ const transitHouseJulianDay = transit.exactTime
106
+ ? this.ephem.dateToJulianDay(transit.exactTime)
107
+ : (context?.julianDay ?? this.ephem.dateToJulianDay(targetDate));
108
+ const transitHouses = getTransitHouses(transitHouseJulianDay);
109
+ const exactTransitLongitude = transit.exactTime && planetIdsByName.has(transit.transitingPlanet)
110
+ ? this.ephem.getPlanetPosition(planetIdsByName.get(transit.transitingPlanet), transitHouseJulianDay).longitude
111
+ : transit.transitLongitude;
112
+ return {
113
+ transitingPlanet: transit.transitingPlanet,
114
+ aspect: transit.aspect,
115
+ natalPlanet: transit.natalPlanet,
116
+ orb: Number.parseFloat(transit.orb.toFixed(2)),
117
+ isApplying: transit.isApplying,
118
+ exactTimeStatus: transit.exactTimeStatus,
119
+ exactTime: transit.exactTime?.toISOString(),
120
+ transitLongitude: transit.transitLongitude,
121
+ natalLongitude: transit.natalLongitude,
122
+ transitSign: transitPlacement.sign,
123
+ transitDegree: transitPlacement.degree,
124
+ transitHouse: getHouseNumber(exactTransitLongitude, transitHouses),
125
+ natalSign: natalPlacement.sign,
126
+ natalDegree: natalPlacement.degree,
127
+ natalHouse: getHouseNumber(transit.natalLongitude, natalHouses),
128
+ };
129
+ };
130
+ const filteredTransits = filterTransits(deduplicateTransits(allTransits));
131
+ const dateLabel = this.formatDateLabel(utcToLocal(targetDate, reportingTimezone));
132
+ const windowEndLabel = this.formatDateLabel(utcToLocal(addLocalDays(startLocal, calculationTimezone, effectiveDaysAhead), reportingTimezone));
133
+ const structuredData = {
134
+ date: dateLabel,
135
+ timezone: reportingTimezone,
136
+ calculation_timezone: calculationTimezone,
137
+ reporting_timezone: reportingTimezone,
138
+ transits: filteredTransits.map(serializeTransit),
139
+ };
140
+ const metadata = {
141
+ mode,
142
+ mode_source: modeSource,
143
+ days_ahead: effectiveDaysAhead,
144
+ window_start: dateLabel,
145
+ window_end: windowEndLabel,
146
+ };
147
+ let responseData = structuredData;
148
+ let mundaneText = '';
149
+ if (mode === 'forecast') {
150
+ const forecastDays = Array.from(transitsByDay.entries())
151
+ .sort(([leftDate], [rightDate]) => leftDate.localeCompare(rightDate))
152
+ .map(([dayDate, dayTransits]) => ({
153
+ date: dayDate,
154
+ transits: filterTransits(deduplicateTransits(dayTransits)).map(serializeTransit),
155
+ }));
156
+ responseData = {
157
+ ...metadata,
158
+ timezone: reportingTimezone,
159
+ calculation_timezone: calculationTimezone,
160
+ reporting_timezone: reportingTimezone,
161
+ forecast: forecastDays,
162
+ };
163
+ }
164
+ else {
165
+ responseData = {
166
+ ...structuredData,
167
+ ...metadata,
168
+ };
169
+ }
170
+ if (includeMundane) {
171
+ const mundaneDays = [];
172
+ for (let day = 0; day <= daysAhead; day++) {
173
+ const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
174
+ mundaneDays.push(this.getMundaneDay(dayUTC, reportingTimezone, transitingPlanetIds));
175
+ }
176
+ const [anchorMundane] = mundaneDays;
177
+ const mundaneData = {
178
+ date: anchorMundane.date,
179
+ timezone: anchorMundane.timezone,
180
+ positions: anchorMundane.positions,
181
+ aspects: anchorMundane.aspects,
182
+ days: mundaneDays,
183
+ };
184
+ responseData = { transits: responseData, mundane: mundaneData };
185
+ mundaneText = `\n\nCurrent Planetary Positions:\n\n${anchorMundane.positions
186
+ .map((position) => `${position.planet}: ${position.degree.toFixed(1)}° ${position.sign} (${position.isRetrograde ? 'Rx' : 'Direct'})`)
187
+ .join('\n')}`;
188
+ if (mode === 'forecast') {
189
+ mundaneText +=
190
+ '\n\nNote: mundane positions remain anchored to the forecast start date in this mode.';
191
+ }
192
+ }
193
+ const formatHumanTransit = (transit) => {
194
+ const exactStr = transit.exactTime
195
+ ? ` - Exact: ${this.formatTimestamp(transit.exactTime, reportingTimezone)}`
196
+ : '';
197
+ const applyStr = transit.isApplying ? '(applying)' : '(separating)';
198
+ return `${transit.transitingPlanet} ${transit.aspect} ${transit.natalPlanet}: ${transit.orb.toFixed(2)}° orb ${applyStr}${exactStr}`;
199
+ };
200
+ const rangeStr = effectiveDaysAhead > 0 ? ` (next ${effectiveDaysAhead + 1} days)` : '';
201
+ let transitHeader;
202
+ if (mode === 'forecast') {
203
+ const forecastLines = Array.from(transitsByDay.entries())
204
+ .sort(([leftDate], [rightDate]) => leftDate.localeCompare(rightDate))
205
+ .map(([dayDate, dayTransits]) => {
206
+ const dedupedDay = filterTransits(deduplicateTransits(dayTransits));
207
+ const lines = dedupedDay.length === 0
208
+ ? 'No transits found matching the specified criteria.'
209
+ : dedupedDay.map(formatHumanTransit).join('\n');
210
+ return `${dayDate}:\n${lines}`;
211
+ })
212
+ .join('\n\n');
213
+ transitHeader = `Forecast transits${rangeStr}:\n\n${forecastLines}`;
214
+ }
215
+ else {
216
+ const humanLines = filteredTransits.map(formatHumanTransit).join('\n');
217
+ const modeLabel = mode === 'snapshot' ? 'Transit snapshot' : 'Best-hit transits';
218
+ transitHeader =
219
+ filteredTransits.length > 0
220
+ ? `${modeLabel}${rangeStr}:\n\n${humanLines}`
221
+ : 'No transits found matching the specified criteria.';
222
+ }
223
+ return {
224
+ data: responseData,
225
+ text: transitHeader + mundaneText,
226
+ };
227
+ }
228
+ /**
229
+ * Resolve the query anchor instant for a transit lookup.
230
+ *
231
+ * @param dateStr - Optional YYYY-MM-DD date supplied by the caller
232
+ * @param calculationTimezone - Timezone used for local-day interpretation
233
+ * @returns UTC instant representing local noon on the requested day
234
+ */
235
+ resolveTargetDate(dateStr, calculationTimezone) {
236
+ if (dateStr) {
237
+ const parsed = parseDateOnlyInput(dateStr);
238
+ return localToUTC(parsed, calculationTimezone);
239
+ }
240
+ const now = this.now();
241
+ const localNow = utcToLocal(now, calculationTimezone);
242
+ return localToUTC({ ...localNow, hour: 12, minute: 0, second: 0 }, calculationTimezone);
243
+ }
244
+ /**
245
+ * Expand category filters into the concrete transiting planet ids to compute.
246
+ *
247
+ * @param categories - Requested category filters from the transit input
248
+ * @returns Deduplicated transiting planet ids in stable insertion order
249
+ */
250
+ resolveTransitingPlanetIds(categories) {
251
+ const transitingPlanetIds = [];
252
+ if (categories.includes('all')) {
253
+ return Object.values(PLANETS);
254
+ }
255
+ if (categories.includes('moon')) {
256
+ transitingPlanetIds.push(PLANETS.MOON);
257
+ }
258
+ if (categories.includes('personal')) {
259
+ transitingPlanetIds.push(...PERSONAL_PLANETS.filter((planetId) => !transitingPlanetIds.includes(planetId)));
260
+ }
261
+ if (categories.includes('outer')) {
262
+ transitingPlanetIds.push(...OUTER_PLANETS.filter((planetId) => !transitingPlanetIds.includes(planetId)));
263
+ }
264
+ return transitingPlanetIds;
265
+ }
266
+ /**
267
+ * Derive a simple supportive/challenging weather summary from mundane aspects.
268
+ *
269
+ * @param aspects - Mundane aspects for a single reporting day
270
+ * @returns Grouped weather identifiers keyed by tone
271
+ */
272
+ getMundaneWeather(aspects) {
273
+ const supportiveAspects = new Set(['conjunction', 'trine', 'sextile']);
274
+ const challengingAspects = new Set(['square', 'opposition']);
275
+ return {
276
+ supportive: aspects
277
+ .filter((aspect) => supportiveAspects.has(aspect.aspect))
278
+ .map((aspect) => aspect.id),
279
+ challenging: aspects
280
+ .filter((aspect) => challengingAspects.has(aspect.aspect))
281
+ .map((aspect) => aspect.id),
282
+ };
283
+ }
284
+ /**
285
+ * Compute transit-to-transit mundane aspects for a single day's positions.
286
+ *
287
+ * @param date - Reporting date label used in stable aspect ids
288
+ * @param positions - Transiting planetary positions for the day
289
+ * @returns Sorted mundane aspects with orb and applying metadata
290
+ */
291
+ getMundaneAspects(date, positions) {
292
+ const aspects = [];
293
+ for (let i = 0; i < positions.length; i++) {
294
+ for (let j = i + 1; j < positions.length; j++) {
295
+ const planetA = positions[i];
296
+ const planetB = positions[j];
297
+ const angle = this.ephem.calculateAspectAngle(planetA.longitude, planetB.longitude);
298
+ for (const aspect of ASPECTS) {
299
+ const orb = Math.abs(angle - aspect.angle);
300
+ if (orb > aspect.orb) {
301
+ continue;
302
+ }
303
+ const futureLongitudeA = (planetA.longitude + planetA.speed * 0.1 + 360) % 360;
304
+ const futureLongitudeB = (planetB.longitude + planetB.speed * 0.1 + 360) % 360;
305
+ const futureAngle = this.ephem.calculateAspectAngle(futureLongitudeA, futureLongitudeB);
306
+ const futureOrb = Math.abs(futureAngle - aspect.angle);
307
+ aspects.push({
308
+ id: `${date}:${planetA.planet}-${aspect.name}-${planetB.planet}`,
309
+ planetA: planetA.planet,
310
+ planetB: planetB.planet,
311
+ aspect: aspect.name,
312
+ orb: Number.parseFloat(orb.toFixed(2)),
313
+ isApplying: futureOrb < orb,
314
+ longitudeA: planetA.longitude,
315
+ longitudeB: planetB.longitude,
316
+ });
317
+ }
318
+ }
319
+ }
320
+ return aspects.sort((left, right) => left.orb - right.orb ||
321
+ left.planetA.localeCompare(right.planetA) ||
322
+ left.planetB.localeCompare(right.planetB) ||
323
+ left.aspect.localeCompare(right.aspect));
324
+ }
325
+ /**
326
+ * Build the optional mundane payload for one transit day.
327
+ *
328
+ * @param dayUTC - UTC instant representing the day anchor
329
+ * @param timezone - Reporting timezone used for day labels
330
+ * @param transitingPlanetIds - Planet ids included in the mundane calculation
331
+ * @returns Daily mundane bundle with positions, aspects, and weather
332
+ */
333
+ getMundaneDay(dayUTC, timezone, transitingPlanetIds) {
334
+ const localDay = utcToLocal(dayUTC, timezone);
335
+ const dateLabel = this.formatDateLabel(localDay);
336
+ const currentJD = this.ephem.dateToJulianDay(dayUTC);
337
+ const positions = this.ephem.getAllPlanets(currentJD, transitingPlanetIds);
338
+ const aspects = this.getMundaneAspects(dateLabel, positions);
339
+ return {
340
+ date: dateLabel,
341
+ timezone,
342
+ positions,
343
+ aspects,
344
+ weather: this.getMundaneWeather(aspects),
345
+ };
346
+ }
347
+ /**
348
+ * Format a local date tuple into the service's canonical YYYY-MM-DD label.
349
+ */
350
+ formatDateLabel(localDate) {
351
+ return `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
352
+ }
353
+ }