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,507 @@
1
+ import type { McpStartupDefaults } from '../entrypoint.js';
2
+ import type { EphemerisCalculator } from '../ephemeris.js';
3
+ import type { HouseCalculator } from '../houses.js';
4
+ import { addLocalDays, localToUTC, utcToLocal } from '../time-utils.js';
5
+ import { deduplicateTransits, type TransitCalculator } from '../transits.js';
6
+ import {
7
+ ASPECTS,
8
+ type AspectType,
9
+ type HouseData,
10
+ type NatalChart,
11
+ OUTER_PLANETS,
12
+ PERSONAL_PLANETS,
13
+ PLANET_NAMES,
14
+ PLANETS,
15
+ type PlanetPosition,
16
+ type Transit,
17
+ type TransitResponse,
18
+ } from '../types.js';
19
+ import { parseDateOnlyInput } from './date-input.js';
20
+ import type { GetTransitsInput, ServiceResult } from './service-types.js';
21
+ import {
22
+ getHouseNumber,
23
+ getSignAndDegree,
24
+ resolveHouseSystem,
25
+ resolveTimezones,
26
+ } from './shared.js';
27
+
28
+ /**
29
+ * Serialized transit-to-transit aspect used for the optional mundane payload.
30
+ */
31
+ interface MundaneAspect {
32
+ id: string;
33
+ planetA: PlanetPosition['planet'];
34
+ planetB: PlanetPosition['planet'];
35
+ aspect: AspectType;
36
+ orb: number;
37
+ isApplying: boolean;
38
+ longitudeA: number;
39
+ longitudeB: number;
40
+ }
41
+
42
+ /**
43
+ * Lightweight supportive/challenging grouping for mundane aspect summaries.
44
+ */
45
+ interface MundaneWeather {
46
+ supportive: string[];
47
+ challenging: string[];
48
+ }
49
+
50
+ /**
51
+ * Per-day mundane transit bundle anchored to a reporting timezone label.
52
+ */
53
+ interface MundaneDay {
54
+ date: string;
55
+ timezone: string;
56
+ positions: PlanetPosition[];
57
+ aspects: MundaneAspect[];
58
+ weather: MundaneWeather;
59
+ }
60
+
61
+ /**
62
+ * Dependencies needed by the extracted transit workflow.
63
+ */
64
+ interface TransitServiceDependencies {
65
+ ephem: EphemerisCalculator;
66
+ transitCalc: TransitCalculator;
67
+ houseCalc: HouseCalculator;
68
+ mcpStartupDefaults: Readonly<McpStartupDefaults>;
69
+ now: () => Date;
70
+ formatTimestamp: (date: Date, timezone: string) => string;
71
+ }
72
+
73
+ /**
74
+ * Internal transit workflow service used by `AstroService`.
75
+ *
76
+ * @remarks
77
+ * This module owns transit-specific validation, aggregation, placement
78
+ * enrichment, mundane expansion, and human-readable response formatting while
79
+ * the public `AstroService` facade preserves the external contract.
80
+ */
81
+ export class TransitService {
82
+ private readonly ephem: EphemerisCalculator;
83
+ private readonly transitCalc: TransitCalculator;
84
+ private readonly houseCalc: HouseCalculator;
85
+ private readonly mcpStartupDefaults: Readonly<McpStartupDefaults>;
86
+ private readonly now: () => Date;
87
+ private readonly formatTimestamp: (date: Date, timezone: string) => string;
88
+
89
+ constructor(deps: TransitServiceDependencies) {
90
+ this.ephem = deps.ephem;
91
+ this.transitCalc = deps.transitCalc;
92
+ this.houseCalc = deps.houseCalc;
93
+ this.mcpStartupDefaults = deps.mcpStartupDefaults;
94
+ this.now = deps.now;
95
+ this.formatTimestamp = deps.formatTimestamp;
96
+ }
97
+
98
+ /**
99
+ * Build the transit payload and readable text for a natal chart query.
100
+ */
101
+ getTransits(
102
+ natalChart: NatalChart,
103
+ input: GetTransitsInput = {}
104
+ ): ServiceResult<Record<string, unknown>> {
105
+ const dateStr = input.date;
106
+ const categories = input.categories ?? ['all'];
107
+ const includeMundane = input.include_mundane ?? false;
108
+ const daysAhead = input.days_ahead ?? 0;
109
+ const requestedMode = input.mode;
110
+ const maxOrb = input.max_orb ?? 8;
111
+ const exactOnly = input.exact_only ?? false;
112
+ const applyingOnly = input.applying_only ?? false;
113
+
114
+ if (!Number.isFinite(daysAhead) || daysAhead < 0) {
115
+ throw new Error('days_ahead must be a finite number >= 0');
116
+ }
117
+ if (!Number.isFinite(maxOrb) || maxOrb < 0) {
118
+ throw new Error('max_orb must be a finite number >= 0');
119
+ }
120
+ if (
121
+ requestedMode !== undefined &&
122
+ requestedMode !== 'snapshot' &&
123
+ requestedMode !== 'best_hit' &&
124
+ requestedMode !== 'forecast'
125
+ ) {
126
+ throw new Error('mode must be one of: snapshot, best_hit, forecast');
127
+ }
128
+ if (!natalChart.julianDay) {
129
+ throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
130
+ }
131
+
132
+ const mode = requestedMode ?? (daysAhead === 0 ? 'snapshot' : 'best_hit');
133
+ const modeSource = requestedMode === undefined ? 'legacy_default' : 'explicit';
134
+ const transitingPlanetIds = this.resolveTransitingPlanetIds(categories);
135
+ const { calculationTimezone, reportingTimezone } = resolveTimezones(
136
+ this.mcpStartupDefaults,
137
+ undefined,
138
+ natalChart.location.timezone
139
+ );
140
+
141
+ const targetDate = this.resolveTargetDate(dateStr, calculationTimezone);
142
+ const allTransits: Transit[] = [];
143
+ const transitsByDay = new Map<string, Transit[]>();
144
+ const transitContext = new WeakMap<Transit, { julianDay: number }>();
145
+ const startLocal = utcToLocal(targetDate, calculationTimezone);
146
+ const effectiveDaysAhead = mode === 'snapshot' ? 0 : daysAhead;
147
+
148
+ for (let day = 0; day <= effectiveDaysAhead; day++) {
149
+ const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
150
+ const julianDay = this.ephem.dateToJulianDay(dayUTC);
151
+ const transitingPlanets = this.ephem.getAllPlanets(julianDay, transitingPlanetIds);
152
+ const transits = this.transitCalc.findTransits(
153
+ transitingPlanets,
154
+ natalChart.planets || [],
155
+ julianDay
156
+ );
157
+
158
+ for (const transit of transits) {
159
+ transitContext.set(transit, { julianDay });
160
+ }
161
+
162
+ allTransits.push(...transits);
163
+ transitsByDay.set(this.formatDateLabel(utcToLocal(dayUTC, reportingTimezone)), transits);
164
+ }
165
+
166
+ const filterTransits = (transits: Transit[]): Transit[] => {
167
+ let filtered = transits.filter((transit) => transit.orb <= maxOrb);
168
+ if (exactOnly) {
169
+ filtered = filtered.filter((transit) => transit.exactTime !== undefined);
170
+ }
171
+ if (applyingOnly) {
172
+ filtered = filtered.filter((transit) => transit.isApplying);
173
+ }
174
+ filtered.sort((left, right) => left.orb - right.orb);
175
+ return filtered;
176
+ };
177
+
178
+ const chartHouseSystem = resolveHouseSystem(natalChart, this.mcpStartupDefaults);
179
+ const natalHouses = this.houseCalc.calculateHouses(
180
+ natalChart.julianDay,
181
+ natalChart.location.latitude,
182
+ natalChart.location.longitude,
183
+ chartHouseSystem
184
+ );
185
+ const transitHouseCache = new Map<number, HouseData>();
186
+ const planetIdsByName = new Map(
187
+ Object.entries(PLANET_NAMES).map(([planetId, planetName]) => [planetName, Number(planetId)])
188
+ );
189
+ const getTransitHouses = (julianDay: number): HouseData => {
190
+ const cached = transitHouseCache.get(julianDay);
191
+ if (cached) {
192
+ return cached;
193
+ }
194
+
195
+ const houses = this.houseCalc.calculateHouses(
196
+ julianDay,
197
+ natalChart.location.latitude,
198
+ natalChart.location.longitude,
199
+ chartHouseSystem
200
+ );
201
+ transitHouseCache.set(julianDay, houses);
202
+ return houses;
203
+ };
204
+
205
+ const serializeTransit = (transit: Transit) => {
206
+ const transitPlacement = getSignAndDegree(transit.transitLongitude);
207
+ const natalPlacement = getSignAndDegree(transit.natalLongitude);
208
+ const context = transitContext.get(transit);
209
+ const transitHouseJulianDay = transit.exactTime
210
+ ? this.ephem.dateToJulianDay(transit.exactTime)
211
+ : (context?.julianDay ?? this.ephem.dateToJulianDay(targetDate));
212
+ const transitHouses = getTransitHouses(transitHouseJulianDay);
213
+ const exactTransitLongitude =
214
+ transit.exactTime && planetIdsByName.has(transit.transitingPlanet)
215
+ ? this.ephem.getPlanetPosition(
216
+ planetIdsByName.get(transit.transitingPlanet) as number,
217
+ transitHouseJulianDay
218
+ ).longitude
219
+ : transit.transitLongitude;
220
+
221
+ return {
222
+ transitingPlanet: transit.transitingPlanet,
223
+ aspect: transit.aspect,
224
+ natalPlanet: transit.natalPlanet,
225
+ orb: Number.parseFloat(transit.orb.toFixed(2)),
226
+ isApplying: transit.isApplying,
227
+ exactTimeStatus: transit.exactTimeStatus,
228
+ exactTime: transit.exactTime?.toISOString(),
229
+ transitLongitude: transit.transitLongitude,
230
+ natalLongitude: transit.natalLongitude,
231
+ transitSign: transitPlacement.sign,
232
+ transitDegree: transitPlacement.degree,
233
+ transitHouse: getHouseNumber(exactTransitLongitude, transitHouses),
234
+ natalSign: natalPlacement.sign,
235
+ natalDegree: natalPlacement.degree,
236
+ natalHouse: getHouseNumber(transit.natalLongitude, natalHouses),
237
+ };
238
+ };
239
+
240
+ const filteredTransits = filterTransits(deduplicateTransits(allTransits));
241
+ const dateLabel = this.formatDateLabel(utcToLocal(targetDate, reportingTimezone));
242
+ const windowEndLabel = this.formatDateLabel(
243
+ utcToLocal(
244
+ addLocalDays(startLocal, calculationTimezone, effectiveDaysAhead),
245
+ reportingTimezone
246
+ )
247
+ );
248
+
249
+ const structuredData: TransitResponse = {
250
+ date: dateLabel,
251
+ timezone: reportingTimezone,
252
+ calculation_timezone: calculationTimezone,
253
+ reporting_timezone: reportingTimezone,
254
+ transits: filteredTransits.map(serializeTransit),
255
+ };
256
+
257
+ const metadata = {
258
+ mode,
259
+ mode_source: modeSource,
260
+ days_ahead: effectiveDaysAhead,
261
+ window_start: dateLabel,
262
+ window_end: windowEndLabel,
263
+ };
264
+
265
+ let responseData: Record<string, unknown> = structuredData as unknown as Record<
266
+ string,
267
+ unknown
268
+ >;
269
+ let mundaneText = '';
270
+
271
+ if (mode === 'forecast') {
272
+ const forecastDays = Array.from(transitsByDay.entries())
273
+ .sort(([leftDate], [rightDate]) => leftDate.localeCompare(rightDate))
274
+ .map(([dayDate, dayTransits]) => ({
275
+ date: dayDate,
276
+ transits: filterTransits(deduplicateTransits(dayTransits)).map(serializeTransit),
277
+ }));
278
+ responseData = {
279
+ ...metadata,
280
+ timezone: reportingTimezone,
281
+ calculation_timezone: calculationTimezone,
282
+ reporting_timezone: reportingTimezone,
283
+ forecast: forecastDays,
284
+ };
285
+ } else {
286
+ responseData = {
287
+ ...structuredData,
288
+ ...metadata,
289
+ };
290
+ }
291
+
292
+ if (includeMundane) {
293
+ const mundaneDays: MundaneDay[] = [];
294
+ for (let day = 0; day <= daysAhead; day++) {
295
+ const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
296
+ mundaneDays.push(this.getMundaneDay(dayUTC, reportingTimezone, transitingPlanetIds));
297
+ }
298
+
299
+ const [anchorMundane] = mundaneDays;
300
+ const mundaneData = {
301
+ date: anchorMundane.date,
302
+ timezone: anchorMundane.timezone,
303
+ positions: anchorMundane.positions,
304
+ aspects: anchorMundane.aspects,
305
+ days: mundaneDays,
306
+ };
307
+
308
+ responseData = { transits: responseData, mundane: mundaneData };
309
+ mundaneText = `\n\nCurrent Planetary Positions:\n\n${anchorMundane.positions
310
+ .map(
311
+ (position) =>
312
+ `${position.planet}: ${position.degree.toFixed(1)}° ${position.sign} (${position.isRetrograde ? 'Rx' : 'Direct'})`
313
+ )
314
+ .join('\n')}`;
315
+ if (mode === 'forecast') {
316
+ mundaneText +=
317
+ '\n\nNote: mundane positions remain anchored to the forecast start date in this mode.';
318
+ }
319
+ }
320
+
321
+ const formatHumanTransit = (transit: Transit) => {
322
+ const exactStr = transit.exactTime
323
+ ? ` - Exact: ${this.formatTimestamp(transit.exactTime, reportingTimezone)}`
324
+ : '';
325
+ const applyStr = transit.isApplying ? '(applying)' : '(separating)';
326
+ return `${transit.transitingPlanet} ${transit.aspect} ${transit.natalPlanet}: ${transit.orb.toFixed(2)}° orb ${applyStr}${exactStr}`;
327
+ };
328
+ const rangeStr = effectiveDaysAhead > 0 ? ` (next ${effectiveDaysAhead + 1} days)` : '';
329
+
330
+ let transitHeader: string;
331
+ if (mode === 'forecast') {
332
+ const forecastLines = Array.from(transitsByDay.entries())
333
+ .sort(([leftDate], [rightDate]) => leftDate.localeCompare(rightDate))
334
+ .map(([dayDate, dayTransits]) => {
335
+ const dedupedDay = filterTransits(deduplicateTransits(dayTransits));
336
+ const lines =
337
+ dedupedDay.length === 0
338
+ ? 'No transits found matching the specified criteria.'
339
+ : dedupedDay.map(formatHumanTransit).join('\n');
340
+ return `${dayDate}:\n${lines}`;
341
+ })
342
+ .join('\n\n');
343
+ transitHeader = `Forecast transits${rangeStr}:\n\n${forecastLines}`;
344
+ } else {
345
+ const humanLines = filteredTransits.map(formatHumanTransit).join('\n');
346
+ const modeLabel = mode === 'snapshot' ? 'Transit snapshot' : 'Best-hit transits';
347
+ transitHeader =
348
+ filteredTransits.length > 0
349
+ ? `${modeLabel}${rangeStr}:\n\n${humanLines}`
350
+ : 'No transits found matching the specified criteria.';
351
+ }
352
+
353
+ return {
354
+ data: responseData,
355
+ text: transitHeader + mundaneText,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Resolve the query anchor instant for a transit lookup.
361
+ *
362
+ * @param dateStr - Optional YYYY-MM-DD date supplied by the caller
363
+ * @param calculationTimezone - Timezone used for local-day interpretation
364
+ * @returns UTC instant representing local noon on the requested day
365
+ */
366
+ private resolveTargetDate(dateStr: string | undefined, calculationTimezone: string): Date {
367
+ if (dateStr) {
368
+ const parsed = parseDateOnlyInput(dateStr);
369
+ return localToUTC(parsed, calculationTimezone);
370
+ }
371
+
372
+ const now = this.now();
373
+ const localNow = utcToLocal(now, calculationTimezone);
374
+ return localToUTC({ ...localNow, hour: 12, minute: 0, second: 0 }, calculationTimezone);
375
+ }
376
+
377
+ /**
378
+ * Expand category filters into the concrete transiting planet ids to compute.
379
+ *
380
+ * @param categories - Requested category filters from the transit input
381
+ * @returns Deduplicated transiting planet ids in stable insertion order
382
+ */
383
+ private resolveTransitingPlanetIds(categories: string[]): number[] {
384
+ const transitingPlanetIds: number[] = [];
385
+
386
+ if (categories.includes('all')) {
387
+ return Object.values(PLANETS);
388
+ }
389
+
390
+ if (categories.includes('moon')) {
391
+ transitingPlanetIds.push(PLANETS.MOON);
392
+ }
393
+ if (categories.includes('personal')) {
394
+ transitingPlanetIds.push(
395
+ ...PERSONAL_PLANETS.filter((planetId) => !transitingPlanetIds.includes(planetId))
396
+ );
397
+ }
398
+ if (categories.includes('outer')) {
399
+ transitingPlanetIds.push(
400
+ ...OUTER_PLANETS.filter((planetId) => !transitingPlanetIds.includes(planetId))
401
+ );
402
+ }
403
+
404
+ return transitingPlanetIds;
405
+ }
406
+
407
+ /**
408
+ * Derive a simple supportive/challenging weather summary from mundane aspects.
409
+ *
410
+ * @param aspects - Mundane aspects for a single reporting day
411
+ * @returns Grouped weather identifiers keyed by tone
412
+ */
413
+ private getMundaneWeather(aspects: MundaneAspect[]): MundaneWeather {
414
+ const supportiveAspects = new Set<AspectType>(['conjunction', 'trine', 'sextile']);
415
+ const challengingAspects = new Set<AspectType>(['square', 'opposition']);
416
+
417
+ return {
418
+ supportive: aspects
419
+ .filter((aspect) => supportiveAspects.has(aspect.aspect))
420
+ .map((aspect) => aspect.id),
421
+ challenging: aspects
422
+ .filter((aspect) => challengingAspects.has(aspect.aspect))
423
+ .map((aspect) => aspect.id),
424
+ };
425
+ }
426
+
427
+ /**
428
+ * Compute transit-to-transit mundane aspects for a single day's positions.
429
+ *
430
+ * @param date - Reporting date label used in stable aspect ids
431
+ * @param positions - Transiting planetary positions for the day
432
+ * @returns Sorted mundane aspects with orb and applying metadata
433
+ */
434
+ private getMundaneAspects(date: string, positions: PlanetPosition[]): MundaneAspect[] {
435
+ const aspects: MundaneAspect[] = [];
436
+
437
+ for (let i = 0; i < positions.length; i++) {
438
+ for (let j = i + 1; j < positions.length; j++) {
439
+ const planetA = positions[i];
440
+ const planetB = positions[j];
441
+ const angle = this.ephem.calculateAspectAngle(planetA.longitude, planetB.longitude);
442
+
443
+ for (const aspect of ASPECTS) {
444
+ const orb = Math.abs(angle - aspect.angle);
445
+ if (orb > aspect.orb) {
446
+ continue;
447
+ }
448
+
449
+ const futureLongitudeA = (planetA.longitude + planetA.speed * 0.1 + 360) % 360;
450
+ const futureLongitudeB = (planetB.longitude + planetB.speed * 0.1 + 360) % 360;
451
+ const futureAngle = this.ephem.calculateAspectAngle(futureLongitudeA, futureLongitudeB);
452
+ const futureOrb = Math.abs(futureAngle - aspect.angle);
453
+
454
+ aspects.push({
455
+ id: `${date}:${planetA.planet}-${aspect.name}-${planetB.planet}`,
456
+ planetA: planetA.planet,
457
+ planetB: planetB.planet,
458
+ aspect: aspect.name,
459
+ orb: Number.parseFloat(orb.toFixed(2)),
460
+ isApplying: futureOrb < orb,
461
+ longitudeA: planetA.longitude,
462
+ longitudeB: planetB.longitude,
463
+ });
464
+ }
465
+ }
466
+ }
467
+
468
+ return aspects.sort(
469
+ (left, right) =>
470
+ left.orb - right.orb ||
471
+ left.planetA.localeCompare(right.planetA) ||
472
+ left.planetB.localeCompare(right.planetB) ||
473
+ left.aspect.localeCompare(right.aspect)
474
+ );
475
+ }
476
+
477
+ /**
478
+ * Build the optional mundane payload for one transit day.
479
+ *
480
+ * @param dayUTC - UTC instant representing the day anchor
481
+ * @param timezone - Reporting timezone used for day labels
482
+ * @param transitingPlanetIds - Planet ids included in the mundane calculation
483
+ * @returns Daily mundane bundle with positions, aspects, and weather
484
+ */
485
+ private getMundaneDay(dayUTC: Date, timezone: string, transitingPlanetIds: number[]): MundaneDay {
486
+ const localDay = utcToLocal(dayUTC, timezone);
487
+ const dateLabel = this.formatDateLabel(localDay);
488
+ const currentJD = this.ephem.dateToJulianDay(dayUTC);
489
+ const positions = this.ephem.getAllPlanets(currentJD, transitingPlanetIds);
490
+ const aspects = this.getMundaneAspects(dateLabel, positions);
491
+
492
+ return {
493
+ date: dateLabel,
494
+ timezone,
495
+ positions,
496
+ aspects,
497
+ weather: this.getMundaneWeather(aspects),
498
+ };
499
+ }
500
+
501
+ /**
502
+ * Format a local date tuple into the service's canonical YYYY-MM-DD label.
503
+ */
504
+ private formatDateLabel(localDate: { year: number; month: number; day: number }): string {
505
+ return `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
506
+ }
507
+ }