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.
- package/README.md +15 -5
- package/dist/astro-service/chart-output-service.d.ts +44 -0
- package/dist/astro-service/chart-output-service.js +110 -0
- package/dist/astro-service/date-input.d.ts +14 -0
- package/dist/astro-service/date-input.js +30 -0
- package/dist/astro-service/electional-service.d.ts +45 -0
- package/dist/astro-service/electional-service.js +305 -0
- package/dist/astro-service/natal-service.d.ts +41 -0
- package/dist/astro-service/natal-service.js +179 -0
- package/dist/astro-service/rising-sign-service.d.ts +37 -0
- package/dist/astro-service/rising-sign-service.js +137 -0
- package/dist/astro-service/service-types.d.ts +82 -0
- package/dist/astro-service/service-types.js +1 -0
- package/dist/astro-service/shared.d.ts +65 -0
- package/dist/astro-service/shared.js +98 -0
- package/dist/astro-service/sky-service.d.ts +48 -0
- package/dist/astro-service/sky-service.js +144 -0
- package/dist/astro-service/transit-service.d.ts +82 -0
- package/dist/astro-service/transit-service.js +353 -0
- package/dist/astro-service.d.ts +101 -89
- package/dist/astro-service.js +162 -1042
- package/dist/tool-registry.js +1 -1
- package/docs/product/architecture-boundaries.md +8 -0
- package/docs/releases/1.3.0.md +51 -0
- package/docs/releases/README.md +17 -0
- package/package.json +4 -1
- package/src/astro-service/chart-output-service.ts +155 -0
- package/src/astro-service/date-input.ts +40 -0
- package/src/astro-service/electional-service.ts +395 -0
- package/src/astro-service/natal-service.ts +235 -0
- package/src/astro-service/rising-sign-service.ts +181 -0
- package/src/astro-service/service-types.ts +90 -0
- package/src/astro-service/shared.ts +128 -0
- package/src/astro-service/sky-service.ts +191 -0
- package/src/astro-service/transit-service.ts +507 -0
- package/src/astro-service.ts +177 -1386
- package/src/tool-registry.ts +1 -1
- package/tests/README.md +15 -0
- package/tests/property/electional-service.property.test.ts +67 -0
- package/tests/property/helpers/arbitraries.ts +126 -0
- package/tests/property/helpers/config.ts +52 -0
- package/tests/property/helpers/runtime.ts +12 -0
- package/tests/property/houses.property.test.ts +74 -0
- package/tests/property/rising-sign-service.property.test.ts +255 -0
- package/tests/property/service-transits.property.test.ts +154 -0
- package/tests/property/time-utils.property.test.ts +91 -0
- package/tests/property/transits.property.test.ts +113 -0
- package/tests/unit/astro-service/chart-output-service.test.ts +102 -0
- package/tests/unit/astro-service/electional-service.test.ts +182 -0
- package/tests/unit/astro-service/natal-service.test.ts +126 -0
- package/tests/unit/astro-service/rising-sign-service.test.ts +145 -0
- package/tests/unit/astro-service/sky-service.test.ts +130 -0
- package/tests/unit/astro-service/transit-service.test.ts +312 -0
- package/tests/unit/astro-service.test.ts +136 -781
- package/tests/unit/rising-sign-windows.test.ts +93 -0
- package/tests/unit/tool-registry.test.ts +11 -0
- package/tests/validation/README.md +14 -0
- package/tests/validation/adapters/internal.ts +234 -4
- package/tests/validation/compare/electional.ts +151 -0
- package/tests/validation/compare/rising-sign-windows.ts +347 -0
- package/tests/validation/compare/service-transits.ts +205 -0
- package/tests/validation/fixtures/electional/core.ts +88 -0
- package/tests/validation/fixtures/rising-sign-windows/core.ts +57 -0
- package/tests/validation/fixtures/service-transits/core.ts +89 -0
- package/tests/validation/utils/fixtureTypes.ts +139 -1
- 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
|
+
}
|