ether-to-astro 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug-report.yml +87 -0
- package/.github/ISSUE_TEMPLATE/capability-request.yml +117 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/.github/ISSUE_TEMPLATE/paper-cut.yml +59 -0
- package/.github/pull_request_template.md +1 -0
- package/.github/workflows/release.yml +2 -2
- package/.github/workflows/test.yml +2 -2
- package/AGENTS.md +46 -1
- package/DEVELOPER.md +78 -0
- package/README.md +128 -75
- package/SETUP.md +100 -41
- package/dist/astro-service.d.ts +51 -2
- package/dist/astro-service.js +660 -56
- package/dist/cli.js +31 -0
- package/dist/entrypoint.d.ts +13 -0
- package/dist/entrypoint.js +78 -0
- package/dist/ephemeris.d.ts +15 -0
- package/dist/ephemeris.js +33 -0
- package/dist/formatter.d.ts +5 -1
- package/dist/formatter.js +4 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +63 -114
- package/dist/loader.d.ts +1 -1
- package/dist/loader.js +61 -23
- package/dist/mcp-alias.d.ts +2 -0
- package/dist/mcp-alias.js +8 -0
- package/dist/time-utils.d.ts +8 -0
- package/dist/time-utils.js +16 -0
- package/dist/tool-registry.js +111 -5
- package/dist/tool-result.d.ts +8 -0
- package/dist/tool-result.js +39 -0
- package/dist/types.d.ts +79 -1
- package/docs/product/adrs/0001-mcp-vs-skill-boundary.md +96 -0
- package/docs/product/architecture-boundaries.md +223 -0
- package/docs/product/product-tenets.md +174 -0
- package/docs/releases/1.2.0-draft.md +48 -0
- package/package.json +7 -7
- package/skills/.curated/daily-brief/SKILL.md +75 -0
- package/skills/.curated/electional-overlay/SKILL.md +67 -0
- package/skills/.curated/weekly-overview/SKILL.md +73 -0
- package/skills/.system/write-skill/SKILL.md +90 -0
- package/src/astro-service.ts +861 -60
- package/src/cli.ts +84 -0
- package/src/entrypoint.ts +118 -0
- package/src/ephemeris.ts +44 -0
- package/src/formatter.ts +13 -1
- package/src/index.ts +77 -121
- package/src/loader.ts +69 -25
- package/src/mcp-alias.ts +10 -0
- package/src/time-utils.ts +18 -0
- package/src/tool-registry.ts +129 -9
- package/src/tool-result.ts +44 -0
- package/src/types.ts +91 -1
- package/tests/unit/astro-service.test.ts +751 -5
- package/tests/unit/cli-commands.test.ts +13 -0
- package/tests/unit/entrypoint.test.ts +67 -0
- package/tests/unit/error-mapping.test.ts +20 -0
- package/tests/unit/formatter.test.ts +6 -0
- package/tests/unit/tool-registry.test.ts +114 -2
- package/setup.sh +0 -21
package/src/astro-service.ts
CHANGED
|
@@ -3,21 +3,37 @@ import { Temporal } from '@js-temporal/polyfill';
|
|
|
3
3
|
import { ChartRenderer } from './charts.js';
|
|
4
4
|
import { getDefaultTheme } from './constants.js';
|
|
5
5
|
import { EclipseCalculator } from './eclipses.js';
|
|
6
|
+
import type { McpStartupDefaults } from './entrypoint.js';
|
|
6
7
|
import { EphemerisCalculator } from './ephemeris.js';
|
|
7
8
|
import { formatDateOnly, formatInTimezone } from './formatter.js';
|
|
8
9
|
import { HouseCalculator } from './houses.js';
|
|
9
10
|
import { RiseSetCalculator } from './riseset.js';
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
addLocalDays,
|
|
13
|
+
type Disambiguation,
|
|
14
|
+
formatLocalTimestampWithOffset,
|
|
15
|
+
localToUTC,
|
|
16
|
+
utcToLocal,
|
|
17
|
+
} from './time-utils.js';
|
|
11
18
|
import { deduplicateTransits, TransitCalculator } from './transits.js';
|
|
12
19
|
import {
|
|
20
|
+
ASPECTS,
|
|
13
21
|
ASTEROIDS,
|
|
22
|
+
type AspectType,
|
|
23
|
+
type ElectionalAspect,
|
|
24
|
+
type ElectionalContextResponse,
|
|
25
|
+
type ElectionalHouseSystem,
|
|
26
|
+
type ElectionalPhaseName,
|
|
27
|
+
type HouseData,
|
|
14
28
|
type HouseSystem,
|
|
15
29
|
type NatalChart,
|
|
16
30
|
NODES,
|
|
17
31
|
OUTER_PLANETS,
|
|
18
32
|
PERSONAL_PLANETS,
|
|
33
|
+
PLANET_NAMES,
|
|
19
34
|
PLANETS,
|
|
20
|
-
type
|
|
35
|
+
type PlanetName,
|
|
36
|
+
type PlanetPosition,
|
|
21
37
|
type Transit,
|
|
22
38
|
type TransitResponse,
|
|
23
39
|
ZODIAC_SIGNS,
|
|
@@ -30,6 +46,7 @@ interface AstroServiceDependencies {
|
|
|
30
46
|
riseSetCalc?: RiseSetCalculator;
|
|
31
47
|
eclipseCalc?: EclipseCalculator;
|
|
32
48
|
chartRenderer?: ChartRenderer;
|
|
49
|
+
mcpStartupDefaults?: McpStartupDefaults;
|
|
33
50
|
now?: () => Date;
|
|
34
51
|
writeFile?: (path: string, data: string | Buffer, encoding?: BufferEncoding) => Promise<void>;
|
|
35
52
|
}
|
|
@@ -53,15 +70,36 @@ export interface GetTransitsInput {
|
|
|
53
70
|
categories?: string[];
|
|
54
71
|
include_mundane?: boolean;
|
|
55
72
|
days_ahead?: number;
|
|
73
|
+
mode?: 'snapshot' | 'best_hit' | 'forecast';
|
|
56
74
|
max_orb?: number;
|
|
57
75
|
exact_only?: boolean;
|
|
58
76
|
applying_only?: boolean;
|
|
59
77
|
}
|
|
60
78
|
|
|
79
|
+
export interface GetElectionalContextInput {
|
|
80
|
+
date: string;
|
|
81
|
+
time: string;
|
|
82
|
+
timezone: string;
|
|
83
|
+
latitude: number;
|
|
84
|
+
longitude: number;
|
|
85
|
+
house_system?: ElectionalHouseSystem;
|
|
86
|
+
include_ruler_basics?: boolean;
|
|
87
|
+
include_planetary_applications?: boolean;
|
|
88
|
+
orb_degrees?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
61
91
|
export interface GetHousesInput {
|
|
62
92
|
system?: string;
|
|
63
93
|
}
|
|
64
94
|
|
|
95
|
+
export interface GetRisingSignWindowsInput {
|
|
96
|
+
date: string;
|
|
97
|
+
latitude: number;
|
|
98
|
+
longitude: number;
|
|
99
|
+
timezone: string;
|
|
100
|
+
mode?: 'approximate' | 'exact';
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
export interface GenerateChartInput {
|
|
66
104
|
theme?: 'light' | 'dark';
|
|
67
105
|
format?: 'svg' | 'png' | 'webp';
|
|
@@ -77,7 +115,30 @@ export interface ServiceResult<T> {
|
|
|
77
115
|
text: string;
|
|
78
116
|
}
|
|
79
117
|
|
|
80
|
-
export interface
|
|
118
|
+
export interface MundaneAspect {
|
|
119
|
+
id: string;
|
|
120
|
+
planetA: PlanetPosition['planet'];
|
|
121
|
+
planetB: PlanetPosition['planet'];
|
|
122
|
+
aspect: AspectType;
|
|
123
|
+
orb: number;
|
|
124
|
+
isApplying: boolean;
|
|
125
|
+
longitudeA: number;
|
|
126
|
+
longitudeB: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface MundaneWeather {
|
|
130
|
+
supportive: string[];
|
|
131
|
+
challenging: string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface MundaneDay {
|
|
135
|
+
date: string;
|
|
136
|
+
timezone: string;
|
|
137
|
+
positions: PlanetPosition[];
|
|
138
|
+
aspects: MundaneAspect[];
|
|
139
|
+
weather: MundaneWeather;
|
|
140
|
+
}
|
|
141
|
+
interface ChartServiceResult {
|
|
81
142
|
format: 'svg' | 'png' | 'webp';
|
|
82
143
|
outputPath?: string;
|
|
83
144
|
text: string;
|
|
@@ -120,6 +181,44 @@ export function parseDateOnlyInput(dateStr: string): {
|
|
|
120
181
|
return { year, month, day, hour: 12, minute: 0 };
|
|
121
182
|
}
|
|
122
183
|
|
|
184
|
+
function parseTimeOnlyInput(timeStr: string): { hour: number; minute: number; second: number } {
|
|
185
|
+
const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(timeStr);
|
|
186
|
+
if (!match) {
|
|
187
|
+
throw new Error(`Invalid time format: expected HH:mm[:ss], got "${timeStr}"`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const hour = Number(match[1]);
|
|
191
|
+
const minute = Number(match[2]);
|
|
192
|
+
const second = match[3] === undefined ? 0 : Number(match[3]);
|
|
193
|
+
|
|
194
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
|
|
195
|
+
throw new Error(`Invalid clock time: ${timeStr}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
Temporal.PlainTime.from({ hour, minute, second });
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error(`Invalid clock time: ${timeStr}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { hour, minute, second };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const ELECTIONAL_CONTEXT_PLANET_IDS = [
|
|
208
|
+
PLANETS.SUN,
|
|
209
|
+
PLANETS.MOON,
|
|
210
|
+
PLANETS.MERCURY,
|
|
211
|
+
PLANETS.VENUS,
|
|
212
|
+
PLANETS.MARS,
|
|
213
|
+
PLANETS.JUPITER,
|
|
214
|
+
PLANETS.SATURN,
|
|
215
|
+
PLANETS.URANUS,
|
|
216
|
+
PLANETS.NEPTUNE,
|
|
217
|
+
PLANETS.PLUTO,
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
const ELECTIONAL_CONTEXT_HOUSE_SYSTEMS: ElectionalHouseSystem[] = ['P', 'K', 'W', 'R'];
|
|
221
|
+
|
|
123
222
|
export class AstroService {
|
|
124
223
|
readonly ephem: EphemerisCalculator;
|
|
125
224
|
readonly transitCalc: TransitCalculator;
|
|
@@ -127,6 +226,7 @@ export class AstroService {
|
|
|
127
226
|
readonly riseSetCalc: RiseSetCalculator;
|
|
128
227
|
readonly eclipseCalc: EclipseCalculator;
|
|
129
228
|
readonly chartRenderer: ChartRenderer;
|
|
229
|
+
readonly mcpStartupDefaults: Readonly<McpStartupDefaults>;
|
|
130
230
|
private readonly now: () => Date;
|
|
131
231
|
private readonly writeFileFn: (
|
|
132
232
|
path: string,
|
|
@@ -141,10 +241,72 @@ export class AstroService {
|
|
|
141
241
|
this.riseSetCalc = deps.riseSetCalc ?? new RiseSetCalculator(this.ephem);
|
|
142
242
|
this.eclipseCalc = deps.eclipseCalc ?? new EclipseCalculator(this.ephem);
|
|
143
243
|
this.chartRenderer = deps.chartRenderer ?? new ChartRenderer(this.ephem, this.houseCalc);
|
|
244
|
+
this.mcpStartupDefaults = Object.freeze({ ...(deps.mcpStartupDefaults ?? {}) });
|
|
144
245
|
this.now = deps.now ?? (() => new Date());
|
|
145
246
|
this.writeFileFn = deps.writeFile ?? writeFile;
|
|
146
247
|
}
|
|
147
248
|
|
|
249
|
+
private formatTimestamp(date: Date, timezone: string): string {
|
|
250
|
+
return formatInTimezone(date, timezone, {
|
|
251
|
+
weekday: this.mcpStartupDefaults.weekdayLabels ?? false,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private normalizeLongitude(longitude: number): number {
|
|
256
|
+
return ((longitude % 360) + 360) % 360;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private getSignAndDegree(longitude: number): { sign: string; degree: number } {
|
|
260
|
+
const normalized = this.normalizeLongitude(longitude);
|
|
261
|
+
const baseSignIndex = Math.floor(normalized / 30);
|
|
262
|
+
const roundedDegree = Number.parseFloat((normalized % 30).toFixed(2));
|
|
263
|
+
const shouldCarryToNextSign = roundedDegree >= 30;
|
|
264
|
+
const signIndex = shouldCarryToNextSign
|
|
265
|
+
? (baseSignIndex + 1) % ZODIAC_SIGNS.length
|
|
266
|
+
: baseSignIndex;
|
|
267
|
+
return {
|
|
268
|
+
sign: ZODIAC_SIGNS[signIndex],
|
|
269
|
+
degree: shouldCarryToNextSign ? 0 : roundedDegree,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private getHouseNumber(longitude: number, houses: HouseData): number {
|
|
274
|
+
const normalized = this.normalizeLongitude(longitude);
|
|
275
|
+
|
|
276
|
+
for (let house = 1; house <= 12; house++) {
|
|
277
|
+
const start = this.normalizeLongitude(houses.cusps[house]);
|
|
278
|
+
const nextHouse = house === 12 ? 1 : house + 1;
|
|
279
|
+
const end = this.normalizeLongitude(houses.cusps[nextHouse]);
|
|
280
|
+
const span = (end - start + 360) % 360;
|
|
281
|
+
const offset = (normalized - start + 360) % 360;
|
|
282
|
+
|
|
283
|
+
if (span === 0 || offset === 0 || offset < span) {
|
|
284
|
+
return house;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return 12;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private resolveHouseSystem(natalChart: NatalChart, explicitSystem?: string): HouseSystem {
|
|
292
|
+
return (explicitSystem ||
|
|
293
|
+
natalChart.requestedHouseSystem ||
|
|
294
|
+
this.mcpStartupDefaults.preferredHouseStyle ||
|
|
295
|
+
natalChart.houseSystem ||
|
|
296
|
+
'P') as HouseSystem;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private resolveTimezones(explicitReportingTimezone?: string, natalTimezone?: string) {
|
|
300
|
+
return {
|
|
301
|
+
calculationTimezone: natalTimezone ?? 'UTC',
|
|
302
|
+
reportingTimezone: this.resolveReportingTimezone(explicitReportingTimezone, natalTimezone),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
resolveReportingTimezone(explicitTimezone?: string, natalTimezone?: string): string {
|
|
307
|
+
return explicitTimezone ?? this.mcpStartupDefaults.preferredTimezone ?? natalTimezone ?? 'UTC';
|
|
308
|
+
}
|
|
309
|
+
|
|
148
310
|
async init(): Promise<void> {
|
|
149
311
|
await this.ephem.init();
|
|
150
312
|
}
|
|
@@ -200,6 +362,7 @@ export class AstroService {
|
|
|
200
362
|
planets: positions,
|
|
201
363
|
julianDay: jd,
|
|
202
364
|
houseSystem: houses.system,
|
|
365
|
+
requestedHouseSystem: requestedHouseSystem ?? undefined,
|
|
203
366
|
utcDateTime: utcComponents,
|
|
204
367
|
};
|
|
205
368
|
|
|
@@ -297,16 +460,31 @@ export class AstroService {
|
|
|
297
460
|
const categories = input.categories ?? ['all'];
|
|
298
461
|
const includeMundane = input.include_mundane ?? false;
|
|
299
462
|
const daysAhead = input.days_ahead ?? 0;
|
|
463
|
+
const requestedMode = input.mode;
|
|
300
464
|
const maxOrb = input.max_orb ?? 8;
|
|
301
465
|
const exactOnly = input.exact_only ?? false;
|
|
302
466
|
const applyingOnly = input.applying_only ?? false;
|
|
303
467
|
|
|
304
|
-
if (daysAhead < 0) {
|
|
305
|
-
throw new Error('days_ahead must be >= 0');
|
|
468
|
+
if (!Number.isFinite(daysAhead) || daysAhead < 0) {
|
|
469
|
+
throw new Error('days_ahead must be a finite number >= 0');
|
|
470
|
+
}
|
|
471
|
+
if (!Number.isFinite(maxOrb) || maxOrb < 0) {
|
|
472
|
+
throw new Error('max_orb must be a finite number >= 0');
|
|
306
473
|
}
|
|
307
|
-
if (
|
|
308
|
-
|
|
474
|
+
if (
|
|
475
|
+
requestedMode !== undefined &&
|
|
476
|
+
requestedMode !== 'snapshot' &&
|
|
477
|
+
requestedMode !== 'best_hit' &&
|
|
478
|
+
requestedMode !== 'forecast'
|
|
479
|
+
) {
|
|
480
|
+
throw new Error('mode must be one of: snapshot, best_hit, forecast');
|
|
309
481
|
}
|
|
482
|
+
if (!natalChart.julianDay) {
|
|
483
|
+
throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const mode = requestedMode ?? (daysAhead === 0 ? 'snapshot' : 'best_hit');
|
|
487
|
+
const modeSource = requestedMode === undefined ? 'legacy_default' : 'explicit';
|
|
310
488
|
|
|
311
489
|
let transitingPlanetIds: number[] = [];
|
|
312
490
|
if (categories.includes('all')) {
|
|
@@ -323,23 +501,29 @@ export class AstroService {
|
|
|
323
501
|
}
|
|
324
502
|
}
|
|
325
503
|
|
|
326
|
-
const
|
|
504
|
+
const { calculationTimezone, reportingTimezone } = this.resolveTimezones(
|
|
505
|
+
undefined,
|
|
506
|
+
natalChart.location.timezone
|
|
507
|
+
);
|
|
327
508
|
|
|
328
509
|
let targetDate: Date;
|
|
329
510
|
if (dateStr) {
|
|
330
511
|
const parsed = parseDateOnlyInput(dateStr);
|
|
331
|
-
targetDate = localToUTC(parsed,
|
|
512
|
+
targetDate = localToUTC(parsed, calculationTimezone);
|
|
332
513
|
} else {
|
|
333
514
|
const now = this.now();
|
|
334
|
-
const localNow = utcToLocal(now,
|
|
515
|
+
const localNow = utcToLocal(now, calculationTimezone);
|
|
335
516
|
const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
|
|
336
|
-
targetDate = localToUTC(localNoon,
|
|
517
|
+
targetDate = localToUTC(localNoon, calculationTimezone);
|
|
337
518
|
}
|
|
338
519
|
|
|
339
520
|
const allTransits: Transit[] = [];
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
521
|
+
const transitsByDay = new Map<string, Transit[]>();
|
|
522
|
+
const transitContext = new WeakMap<Transit, { julianDay: number }>();
|
|
523
|
+
const startLocal = utcToLocal(targetDate, calculationTimezone);
|
|
524
|
+
const effectiveDaysAhead = mode === 'snapshot' ? 0 : daysAhead;
|
|
525
|
+
for (let day = 0; day <= effectiveDaysAhead; day++) {
|
|
526
|
+
const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
|
|
343
527
|
const jd = this.ephem.dateToJulianDay(dayUTC);
|
|
344
528
|
const transitingPlanets = this.ephem.getAllPlanets(jd, transitingPlanetIds);
|
|
345
529
|
const transits = this.transitCalc.findTransits(
|
|
@@ -347,22 +531,65 @@ export class AstroService {
|
|
|
347
531
|
natalChart.planets || [],
|
|
348
532
|
jd
|
|
349
533
|
);
|
|
534
|
+
for (const transit of transits) {
|
|
535
|
+
transitContext.set(transit, { julianDay: jd });
|
|
536
|
+
}
|
|
350
537
|
allTransits.push(...transits);
|
|
538
|
+
const dayLocal = utcToLocal(dayUTC, reportingTimezone);
|
|
539
|
+
const dayLabel = `${dayLocal.year}-${String(dayLocal.month).padStart(2, '0')}-${String(dayLocal.day).padStart(2, '0')}`;
|
|
540
|
+
transitsByDay.set(dayLabel, transits);
|
|
351
541
|
}
|
|
352
542
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
543
|
+
const filterTransits = (transits: Transit[]): Transit[] => {
|
|
544
|
+
let filtered = transits.filter((t) => t.orb <= maxOrb);
|
|
545
|
+
if (exactOnly) filtered = filtered.filter((t) => t.exactTime !== undefined);
|
|
546
|
+
if (applyingOnly) filtered = filtered.filter((t) => t.isApplying);
|
|
547
|
+
filtered.sort((a, b) => a.orb - b.orb);
|
|
548
|
+
return filtered;
|
|
549
|
+
};
|
|
550
|
+
const chartHouseSystem = this.resolveHouseSystem(natalChart);
|
|
551
|
+
const natalHouses = this.houseCalc.calculateHouses(
|
|
552
|
+
natalChart.julianDay,
|
|
553
|
+
natalChart.location.latitude,
|
|
554
|
+
natalChart.location.longitude,
|
|
555
|
+
chartHouseSystem
|
|
556
|
+
);
|
|
557
|
+
const transitHouseCache = new Map<number, HouseData>();
|
|
558
|
+
const planetIdsByName = new Map(
|
|
559
|
+
Object.entries(PLANET_NAMES).map(([id, name]) => [name, Number(id)])
|
|
560
|
+
);
|
|
561
|
+
const getTransitHouses = (julianDay: number): HouseData => {
|
|
562
|
+
const cached = transitHouseCache.get(julianDay);
|
|
563
|
+
if (cached) {
|
|
564
|
+
return cached;
|
|
565
|
+
}
|
|
358
566
|
|
|
359
|
-
|
|
360
|
-
|
|
567
|
+
const houses = this.houseCalc.calculateHouses(
|
|
568
|
+
julianDay,
|
|
569
|
+
natalChart.location.latitude,
|
|
570
|
+
natalChart.location.longitude,
|
|
571
|
+
chartHouseSystem
|
|
572
|
+
);
|
|
573
|
+
transitHouseCache.set(julianDay, houses);
|
|
574
|
+
return houses;
|
|
575
|
+
};
|
|
576
|
+
const serializeTransit = (t: Transit) => {
|
|
577
|
+
const transitPlacement = this.getSignAndDegree(t.transitLongitude);
|
|
578
|
+
const natalPlacement = this.getSignAndDegree(t.natalLongitude);
|
|
579
|
+
const context = transitContext.get(t);
|
|
580
|
+
const transitHouseJulianDay = t.exactTime
|
|
581
|
+
? this.ephem.dateToJulianDay(t.exactTime)
|
|
582
|
+
: (context?.julianDay ?? this.ephem.dateToJulianDay(targetDate));
|
|
583
|
+
const transitHouses = getTransitHouses(transitHouseJulianDay);
|
|
584
|
+
const exactTransitLongitude =
|
|
585
|
+
t.exactTime && planetIdsByName.has(t.transitingPlanet)
|
|
586
|
+
? this.ephem.getPlanetPosition(
|
|
587
|
+
planetIdsByName.get(t.transitingPlanet) as number,
|
|
588
|
+
transitHouseJulianDay
|
|
589
|
+
).longitude
|
|
590
|
+
: t.transitLongitude;
|
|
361
591
|
|
|
362
|
-
|
|
363
|
-
date: dateLabel,
|
|
364
|
-
timezone,
|
|
365
|
-
transits: filteredTransits.map((t) => ({
|
|
592
|
+
return {
|
|
366
593
|
transitingPlanet: t.transitingPlanet,
|
|
367
594
|
aspect: t.aspect,
|
|
368
595
|
natalPlanet: t.natalPlanet,
|
|
@@ -372,7 +599,42 @@ export class AstroService {
|
|
|
372
599
|
exactTime: t.exactTime?.toISOString(),
|
|
373
600
|
transitLongitude: t.transitLongitude,
|
|
374
601
|
natalLongitude: t.natalLongitude,
|
|
375
|
-
|
|
602
|
+
transitSign: transitPlacement.sign,
|
|
603
|
+
transitDegree: transitPlacement.degree,
|
|
604
|
+
transitHouse: this.getHouseNumber(exactTransitLongitude, transitHouses),
|
|
605
|
+
natalSign: natalPlacement.sign,
|
|
606
|
+
natalDegree: natalPlacement.degree,
|
|
607
|
+
natalHouse: this.getHouseNumber(t.natalLongitude, natalHouses),
|
|
608
|
+
};
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const filteredTransits =
|
|
612
|
+
mode === 'forecast'
|
|
613
|
+
? filterTransits(deduplicateTransits(allTransits))
|
|
614
|
+
: filterTransits(deduplicateTransits(allTransits));
|
|
615
|
+
|
|
616
|
+
const localDate = utcToLocal(targetDate, reportingTimezone);
|
|
617
|
+
const dateLabel = `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
|
|
618
|
+
const endLocal = utcToLocal(
|
|
619
|
+
addLocalDays(startLocal, calculationTimezone, effectiveDaysAhead),
|
|
620
|
+
reportingTimezone
|
|
621
|
+
);
|
|
622
|
+
const windowEndLabel = `${endLocal.year}-${String(endLocal.month).padStart(2, '0')}-${String(endLocal.day).padStart(2, '0')}`;
|
|
623
|
+
|
|
624
|
+
const structuredData: TransitResponse = {
|
|
625
|
+
date: dateLabel,
|
|
626
|
+
timezone: reportingTimezone,
|
|
627
|
+
calculation_timezone: calculationTimezone,
|
|
628
|
+
reporting_timezone: reportingTimezone,
|
|
629
|
+
transits: filteredTransits.map(serializeTransit),
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const metadata = {
|
|
633
|
+
mode,
|
|
634
|
+
mode_source: modeSource,
|
|
635
|
+
days_ahead: effectiveDaysAhead,
|
|
636
|
+
window_start: dateLabel,
|
|
637
|
+
window_end: windowEndLabel,
|
|
376
638
|
};
|
|
377
639
|
|
|
378
640
|
let responseData: Record<string, unknown> = structuredData as unknown as Record<
|
|
@@ -381,36 +643,85 @@ export class AstroService {
|
|
|
381
643
|
>;
|
|
382
644
|
let mundaneText = '';
|
|
383
645
|
|
|
646
|
+
if (mode === 'forecast') {
|
|
647
|
+
const forecastDays = Array.from(transitsByDay.entries())
|
|
648
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
649
|
+
.map(([dayDate, dayTransits]) => ({
|
|
650
|
+
date: dayDate,
|
|
651
|
+
transits: filterTransits(deduplicateTransits(dayTransits)).map(serializeTransit),
|
|
652
|
+
}));
|
|
653
|
+
responseData = {
|
|
654
|
+
...metadata,
|
|
655
|
+
timezone: reportingTimezone,
|
|
656
|
+
calculation_timezone: calculationTimezone,
|
|
657
|
+
reporting_timezone: reportingTimezone,
|
|
658
|
+
forecast: forecastDays,
|
|
659
|
+
};
|
|
660
|
+
} else {
|
|
661
|
+
responseData = {
|
|
662
|
+
...structuredData,
|
|
663
|
+
...metadata,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
384
667
|
if (includeMundane) {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
668
|
+
const mundaneDays: MundaneDay[] = [];
|
|
669
|
+
for (let day = 0; day <= daysAhead; day++) {
|
|
670
|
+
const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
|
|
671
|
+
mundaneDays.push(this.getMundaneDay(dayUTC, reportingTimezone, transitingPlanetIds));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const [anchorMundane] = mundaneDays;
|
|
675
|
+
const mundaneData = {
|
|
676
|
+
date: anchorMundane.date,
|
|
677
|
+
timezone: anchorMundane.timezone,
|
|
678
|
+
positions: anchorMundane.positions,
|
|
679
|
+
aspects: anchorMundane.aspects,
|
|
680
|
+
days: mundaneDays,
|
|
391
681
|
};
|
|
392
|
-
responseData = { transits:
|
|
393
|
-
mundaneText = `\n\nCurrent Planetary Positions:\n\n${
|
|
682
|
+
responseData = { transits: responseData, mundane: mundaneData };
|
|
683
|
+
mundaneText = `\n\nCurrent Planetary Positions:\n\n${anchorMundane.positions
|
|
394
684
|
.map(
|
|
395
685
|
(p) =>
|
|
396
686
|
`${p.planet}: ${p.degree.toFixed(1)}° ${p.sign} (${p.isRetrograde ? 'Rx' : 'Direct'})`
|
|
397
687
|
)
|
|
398
688
|
.join('\n')}`;
|
|
689
|
+
if (mode === 'forecast') {
|
|
690
|
+
mundaneText +=
|
|
691
|
+
'\n\nNote: mundane positions remain anchored to the forecast start date in this mode.';
|
|
692
|
+
}
|
|
399
693
|
}
|
|
400
694
|
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
695
|
+
const formatHumanTransit = (t: Transit) => {
|
|
696
|
+
const exactStr = t.exactTime
|
|
697
|
+
? ` - Exact: ${this.formatTimestamp(t.exactTime, reportingTimezone)}`
|
|
698
|
+
: '';
|
|
699
|
+
const applyStr = t.isApplying ? '(applying)' : '(separating)';
|
|
700
|
+
return `${t.transitingPlanet} ${t.aspect} ${t.natalPlanet}: ${t.orb.toFixed(2)}° orb ${applyStr}${exactStr}`;
|
|
701
|
+
};
|
|
702
|
+
const rangeStr = effectiveDaysAhead > 0 ? ` (next ${effectiveDaysAhead + 1} days)` : '';
|
|
703
|
+
let transitHeader: string;
|
|
704
|
+
if (mode === 'forecast') {
|
|
705
|
+
const forecastLines = Array.from(transitsByDay.entries())
|
|
706
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
707
|
+
.map(([dayDate, dayTransits]) => {
|
|
708
|
+
const dedupedDay = filterTransits(deduplicateTransits(dayTransits));
|
|
709
|
+
const lines =
|
|
710
|
+
dedupedDay.length === 0
|
|
711
|
+
? 'No transits found matching the specified criteria.'
|
|
712
|
+
: dedupedDay.map(formatHumanTransit).join('\n');
|
|
713
|
+
return `${dayDate}:\n${lines}`;
|
|
714
|
+
})
|
|
715
|
+
.join('\n\n');
|
|
716
|
+
transitHeader = `Forecast transits${rangeStr}:\n\n${forecastLines}`;
|
|
717
|
+
} else {
|
|
718
|
+
const humanLines = filteredTransits.map(formatHumanTransit).join('\n');
|
|
719
|
+
const modeLabel = mode === 'snapshot' ? 'Transit snapshot' : 'Best-hit transits';
|
|
720
|
+
transitHeader =
|
|
721
|
+
filteredTransits.length > 0
|
|
722
|
+
? `${modeLabel}${rangeStr}:\n\n${humanLines}`
|
|
723
|
+
: 'No transits found matching the specified criteria.';
|
|
724
|
+
}
|
|
414
725
|
|
|
415
726
|
return {
|
|
416
727
|
data: responseData,
|
|
@@ -418,11 +729,277 @@ export class AstroService {
|
|
|
418
729
|
};
|
|
419
730
|
}
|
|
420
731
|
|
|
732
|
+
private getMundaneWeather(aspects: MundaneAspect[]): MundaneWeather {
|
|
733
|
+
const supportiveAspects = new Set<AspectType>(['conjunction', 'trine', 'sextile']);
|
|
734
|
+
const challengingAspects = new Set<AspectType>(['square', 'opposition']);
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
supportive: aspects.filter((a) => supportiveAspects.has(a.aspect)).map((a) => a.id),
|
|
738
|
+
challenging: aspects.filter((a) => challengingAspects.has(a.aspect)).map((a) => a.id),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private getMundaneAspects(date: string, positions: PlanetPosition[]): MundaneAspect[] {
|
|
743
|
+
const aspects: MundaneAspect[] = [];
|
|
744
|
+
|
|
745
|
+
for (let i = 0; i < positions.length; i++) {
|
|
746
|
+
for (let j = i + 1; j < positions.length; j++) {
|
|
747
|
+
const planetA = positions[i];
|
|
748
|
+
const planetB = positions[j];
|
|
749
|
+
|
|
750
|
+
const angle = this.ephem.calculateAspectAngle(planetA.longitude, planetB.longitude);
|
|
751
|
+
|
|
752
|
+
for (const aspect of ASPECTS) {
|
|
753
|
+
const orb = Math.abs(angle - aspect.angle);
|
|
754
|
+
if (orb > aspect.orb) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const futureLongitudeA = (planetA.longitude + planetA.speed * 0.1 + 360) % 360;
|
|
759
|
+
const futureLongitudeB = (planetB.longitude + planetB.speed * 0.1 + 360) % 360;
|
|
760
|
+
const futureAngle = this.ephem.calculateAspectAngle(futureLongitudeA, futureLongitudeB);
|
|
761
|
+
const futureOrb = Math.abs(futureAngle - aspect.angle);
|
|
762
|
+
|
|
763
|
+
aspects.push({
|
|
764
|
+
id: `${date}:${planetA.planet}-${aspect.name}-${planetB.planet}`,
|
|
765
|
+
planetA: planetA.planet,
|
|
766
|
+
planetB: planetB.planet,
|
|
767
|
+
aspect: aspect.name,
|
|
768
|
+
orb: Number.parseFloat(orb.toFixed(2)),
|
|
769
|
+
isApplying: futureOrb < orb,
|
|
770
|
+
longitudeA: planetA.longitude,
|
|
771
|
+
longitudeB: planetB.longitude,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return aspects.sort(
|
|
778
|
+
(a, b) =>
|
|
779
|
+
a.orb - b.orb ||
|
|
780
|
+
a.planetA.localeCompare(b.planetA) ||
|
|
781
|
+
a.planetB.localeCompare(b.planetB) ||
|
|
782
|
+
a.aspect.localeCompare(b.aspect)
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private getMundaneDay(dayUTC: Date, timezone: string, transitingPlanetIds: number[]): MundaneDay {
|
|
787
|
+
const localDay = utcToLocal(dayUTC, timezone);
|
|
788
|
+
const dateLabel = `${localDay.year}-${String(localDay.month).padStart(2, '0')}-${String(localDay.day).padStart(2, '0')}`;
|
|
789
|
+
const currentJD = this.ephem.dateToJulianDay(dayUTC);
|
|
790
|
+
const positions = this.ephem.getAllPlanets(currentJD, transitingPlanetIds);
|
|
791
|
+
const aspects = this.getMundaneAspects(dateLabel, positions);
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
date: dateLabel,
|
|
795
|
+
timezone,
|
|
796
|
+
positions,
|
|
797
|
+
aspects,
|
|
798
|
+
weather: this.getMundaneWeather(aspects),
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
getElectionalContext(input: GetElectionalContextInput): ServiceResult<Record<string, unknown>> {
|
|
803
|
+
if (input.latitude < -90 || input.latitude > 90) {
|
|
804
|
+
throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
|
|
805
|
+
}
|
|
806
|
+
if (input.longitude < -180 || input.longitude > 180) {
|
|
807
|
+
throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const houseSystem = input.house_system ?? 'P';
|
|
811
|
+
if (!ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.includes(houseSystem)) {
|
|
812
|
+
throw new Error(
|
|
813
|
+
`Invalid house_system: ${houseSystem} (must be one of ${ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.join(', ')})`
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const includeRulerBasics = input.include_ruler_basics ?? false;
|
|
818
|
+
const includePlanetaryApplications = input.include_planetary_applications ?? true;
|
|
819
|
+
const orbDegrees = input.orb_degrees ?? 3;
|
|
820
|
+
if (!Number.isFinite(orbDegrees) || orbDegrees < 0.1 || orbDegrees > 10) {
|
|
821
|
+
throw new Error(`Invalid orb_degrees: ${orbDegrees} (must be between 0.1 and 10)`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const parsedDate = parseDateOnlyInput(input.date);
|
|
825
|
+
const parsedTime = parseTimeOnlyInput(input.time);
|
|
826
|
+
let instantUtc: Date;
|
|
827
|
+
try {
|
|
828
|
+
instantUtc = localToUTC(
|
|
829
|
+
{
|
|
830
|
+
year: parsedDate.year,
|
|
831
|
+
month: parsedDate.month,
|
|
832
|
+
day: parsedDate.day,
|
|
833
|
+
hour: parsedTime.hour,
|
|
834
|
+
minute: parsedTime.minute,
|
|
835
|
+
second: parsedTime.second,
|
|
836
|
+
},
|
|
837
|
+
input.timezone,
|
|
838
|
+
'reject'
|
|
839
|
+
);
|
|
840
|
+
} catch (error) {
|
|
841
|
+
if (error instanceof RangeError) {
|
|
842
|
+
throw new Error(
|
|
843
|
+
`Invalid local electional time: ${input.date} ${input.time} in ${input.timezone} is ambiguous or nonexistent due to a DST transition.`
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
const jdUt = this.ephem.dateToJulianDay(instantUtc);
|
|
849
|
+
const houses = this.houseCalc.calculateHouses(
|
|
850
|
+
jdUt,
|
|
851
|
+
input.latitude,
|
|
852
|
+
input.longitude,
|
|
853
|
+
houseSystem
|
|
854
|
+
);
|
|
855
|
+
const positions = this.ephem.getAllPlanets(jdUt, ELECTIONAL_CONTEXT_PLANET_IDS);
|
|
856
|
+
|
|
857
|
+
const sun = positions.find((position) => position.planet === 'Sun');
|
|
858
|
+
const moon = positions.find((position) => position.planet === 'Moon');
|
|
859
|
+
if (!sun || !moon) {
|
|
860
|
+
throw new Error('Ephemeris failed to compute Sun/Moon positions for electional context.');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const sunHorizontal = this.ephem.getHorizontalCoordinates(
|
|
864
|
+
jdUt,
|
|
865
|
+
sun,
|
|
866
|
+
input.longitude,
|
|
867
|
+
input.latitude
|
|
868
|
+
);
|
|
869
|
+
const sunAltitudeDegrees = Number.parseFloat(sunHorizontal.trueAltitude.toFixed(2));
|
|
870
|
+
const isDayChart = sunAltitudeDegrees >= 0;
|
|
871
|
+
|
|
872
|
+
const applyingAspects = includePlanetaryApplications
|
|
873
|
+
? this.getElectionalApplyingAspects(positions, orbDegrees)
|
|
874
|
+
: undefined;
|
|
875
|
+
const moonApplyingAspects = applyingAspects?.filter(
|
|
876
|
+
(aspect) => aspect.from_body === 'Moon' || aspect.to_body === 'Moon'
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
const phaseAngle = Number.parseFloat(
|
|
880
|
+
((((moon.longitude - sun.longitude) % 360) + 360) % 360).toFixed(2)
|
|
881
|
+
);
|
|
882
|
+
const warnings: string[] = [];
|
|
883
|
+
if (Math.abs(sunAltitudeDegrees) < 0.5) {
|
|
884
|
+
warnings.push('Sun is near the horizon; day/night classification is close to the boundary.');
|
|
885
|
+
}
|
|
886
|
+
warnings.push('Moon void-of-course is deferred in this slice and returns null.');
|
|
887
|
+
if (houses.system !== houseSystem) {
|
|
888
|
+
warnings.push(
|
|
889
|
+
`House calculation fell back from ${houseSystem} to ${houses.system} for this location.`
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const ascLongitude = ((houses.ascendant % 360) + 360) % 360;
|
|
894
|
+
const ascSign = ZODIAC_SIGNS[Math.floor(ascLongitude / 30)];
|
|
895
|
+
const response: ElectionalContextResponse = {
|
|
896
|
+
input: {
|
|
897
|
+
date: input.date,
|
|
898
|
+
time: input.time,
|
|
899
|
+
timezone: input.timezone,
|
|
900
|
+
latitude: input.latitude,
|
|
901
|
+
longitude: input.longitude,
|
|
902
|
+
house_system: houses.system as ElectionalHouseSystem,
|
|
903
|
+
instant_utc: instantUtc.toISOString(),
|
|
904
|
+
jd_ut: Number.parseFloat(jdUt.toFixed(8)),
|
|
905
|
+
},
|
|
906
|
+
ascendant: {
|
|
907
|
+
longitude: Number.parseFloat(ascLongitude.toFixed(4)),
|
|
908
|
+
sign: ascSign,
|
|
909
|
+
degree_in_sign: Number.parseFloat((ascLongitude % 30).toFixed(4)),
|
|
910
|
+
},
|
|
911
|
+
sect: {
|
|
912
|
+
is_day_chart: isDayChart,
|
|
913
|
+
sun_altitude_degrees: sunAltitudeDegrees,
|
|
914
|
+
classification: isDayChart ? 'day' : 'night',
|
|
915
|
+
},
|
|
916
|
+
moon: {
|
|
917
|
+
longitude: Number.parseFloat(moon.longitude.toFixed(4)),
|
|
918
|
+
sign: moon.sign,
|
|
919
|
+
phase_angle: phaseAngle,
|
|
920
|
+
phase_name: this.getElectionalPhaseName(phaseAngle),
|
|
921
|
+
is_void_of_course: null,
|
|
922
|
+
...(moonApplyingAspects !== undefined ? { applying_aspects: moonApplyingAspects } : {}),
|
|
923
|
+
},
|
|
924
|
+
meta: {
|
|
925
|
+
deterministic: true,
|
|
926
|
+
requires_natal: false,
|
|
927
|
+
warnings,
|
|
928
|
+
deferred_features: [
|
|
929
|
+
'robust_void_of_course',
|
|
930
|
+
'detailed_ruler_condition',
|
|
931
|
+
'house_context',
|
|
932
|
+
'natal_overlays',
|
|
933
|
+
],
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
if (applyingAspects) {
|
|
938
|
+
response.applying_aspects = applyingAspects;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (includeRulerBasics) {
|
|
942
|
+
const rulerBody = this.getTraditionalSignRuler(ascSign);
|
|
943
|
+
const rulerPosition = positions.find((position) => position.planet === rulerBody);
|
|
944
|
+
if (!rulerPosition) {
|
|
945
|
+
throw new Error(`Ephemeris failed to compute ASC ruler position for ${rulerBody}.`);
|
|
946
|
+
}
|
|
947
|
+
response.ruler_basics = {
|
|
948
|
+
asc_sign_ruler: {
|
|
949
|
+
body: rulerBody,
|
|
950
|
+
longitude: Number.parseFloat(rulerPosition.longitude.toFixed(4)),
|
|
951
|
+
sign: rulerPosition.sign,
|
|
952
|
+
speed: Number.parseFloat(rulerPosition.speed.toFixed(6)),
|
|
953
|
+
is_retrograde: rulerPosition.isRetrograde,
|
|
954
|
+
},
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const humanText = [
|
|
959
|
+
`Electional context for ${input.date} ${input.time} (${input.timezone})`,
|
|
960
|
+
'',
|
|
961
|
+
`Ascendant: ${response.ascendant.degree_in_sign.toFixed(2)}° ${response.ascendant.sign}`,
|
|
962
|
+
`Sect: ${response.sect.classification} (${response.sect.sun_altitude_degrees.toFixed(2)}° Sun altitude)`,
|
|
963
|
+
`Moon: ${response.moon.phase_name} in ${response.moon.sign} (${response.moon.phase_angle.toFixed(2)}° phase angle)`,
|
|
964
|
+
];
|
|
965
|
+
|
|
966
|
+
if (includePlanetaryApplications) {
|
|
967
|
+
const topLevelAspectText =
|
|
968
|
+
applyingAspects && applyingAspects.length > 0
|
|
969
|
+
? applyingAspects
|
|
970
|
+
.slice(0, 5)
|
|
971
|
+
.map(
|
|
972
|
+
(aspect) =>
|
|
973
|
+
`${aspect.from_body} ${aspect.aspect} ${aspect.to_body} (${aspect.orb.toFixed(2)}°)`
|
|
974
|
+
)
|
|
975
|
+
.join('\n')
|
|
976
|
+
: 'No applying aspects found within the configured orb.';
|
|
977
|
+
|
|
978
|
+
humanText.push('', 'Applying Aspects:', topLevelAspectText);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (response.ruler_basics) {
|
|
982
|
+
humanText.push(
|
|
983
|
+
'',
|
|
984
|
+
`ASC Ruler: ${response.ruler_basics.asc_sign_ruler.body} in ${response.ruler_basics.asc_sign_ruler.sign} (${response.ruler_basics.asc_sign_ruler.longitude.toFixed(2)}°)`
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (warnings.length > 0) {
|
|
989
|
+
humanText.push('', `Warnings: ${warnings.join(' ')}`);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return {
|
|
993
|
+
data: response as unknown as Record<string, unknown>,
|
|
994
|
+
text: humanText.join('\n'),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
421
998
|
getHouses(
|
|
422
999
|
natalChart: NatalChart,
|
|
423
1000
|
input: GetHousesInput = {}
|
|
424
1001
|
): ServiceResult<Record<string, unknown>> {
|
|
425
|
-
const system = input.system
|
|
1002
|
+
const system = this.resolveHouseSystem(natalChart, input.system);
|
|
426
1003
|
if (!natalChart.julianDay) {
|
|
427
1004
|
throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
|
|
428
1005
|
}
|
|
@@ -449,19 +1026,235 @@ export class AstroService {
|
|
|
449
1026
|
};
|
|
450
1027
|
}
|
|
451
1028
|
|
|
452
|
-
|
|
1029
|
+
getRisingSignWindows(input: GetRisingSignWindowsInput): ServiceResult<Record<string, unknown>> {
|
|
1030
|
+
const mode = input.mode ?? 'approximate';
|
|
1031
|
+
if (mode !== 'approximate' && mode !== 'exact') {
|
|
1032
|
+
throw new Error(`Invalid mode: ${mode} (must be approximate or exact)`);
|
|
1033
|
+
}
|
|
1034
|
+
if (input.latitude < -90 || input.latitude > 90) {
|
|
1035
|
+
throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
|
|
1036
|
+
}
|
|
1037
|
+
if (input.longitude < -180 || input.longitude > 180) {
|
|
1038
|
+
throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const parsed = parseDateOnlyInput(input.date);
|
|
1042
|
+
try {
|
|
1043
|
+
utcToLocal(new Date(), input.timezone);
|
|
1044
|
+
} catch {
|
|
1045
|
+
throw new Error(`Invalid timezone: ${input.timezone}`);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const dayStartLocal = {
|
|
1049
|
+
year: parsed.year,
|
|
1050
|
+
month: parsed.month,
|
|
1051
|
+
day: parsed.day,
|
|
1052
|
+
hour: 0,
|
|
1053
|
+
minute: 0,
|
|
1054
|
+
second: 0,
|
|
1055
|
+
};
|
|
1056
|
+
const dayStartUtc = localToUTC(dayStartLocal, input.timezone);
|
|
1057
|
+
const dayEndUtc = addLocalDays(dayStartLocal, input.timezone, 1);
|
|
1058
|
+
|
|
1059
|
+
const getAscSign = (date: Date): { sign: string; longitude: number } => {
|
|
1060
|
+
const jd = this.ephem.dateToJulianDay(date);
|
|
1061
|
+
const houses = this.houseCalc.calculateHouses(jd, input.latitude, input.longitude, 'P');
|
|
1062
|
+
const normalized = ((houses.ascendant % 360) + 360) % 360;
|
|
1063
|
+
return { sign: ZODIAC_SIGNS[Math.floor(normalized / 30)], longitude: normalized };
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
const refineBoundary = (left: Date, right: Date): Date => {
|
|
1067
|
+
const leftSign = getAscSign(left).sign;
|
|
1068
|
+
let lo = left;
|
|
1069
|
+
let hi = right;
|
|
1070
|
+
for (let i = 0; i < 25; i++) {
|
|
1071
|
+
const mid = new Date((lo.getTime() + hi.getTime()) / 2);
|
|
1072
|
+
const midSign = getAscSign(mid).sign;
|
|
1073
|
+
if (midSign === leftSign) {
|
|
1074
|
+
lo = mid;
|
|
1075
|
+
} else {
|
|
1076
|
+
hi = mid;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return hi;
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
const findSignTransitionsInBucket = (start: Date, end: Date, probeStepMs: number): Date[] => {
|
|
1083
|
+
const boundaries: Date[] = [];
|
|
1084
|
+
let probeCursor = start;
|
|
1085
|
+
let currentSign = getAscSign(probeCursor).sign;
|
|
1086
|
+
|
|
1087
|
+
while (probeCursor < end) {
|
|
1088
|
+
const probeNext = new Date(Math.min(probeCursor.getTime() + probeStepMs, end.getTime()));
|
|
1089
|
+
const nextSign = getAscSign(probeNext).sign;
|
|
1090
|
+
if (nextSign !== currentSign) {
|
|
1091
|
+
boundaries.push(mode === 'exact' ? refineBoundary(probeCursor, probeNext) : probeNext);
|
|
1092
|
+
}
|
|
1093
|
+
probeCursor = probeNext;
|
|
1094
|
+
currentSign = nextSign;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
return boundaries;
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
const stepMs = mode === 'exact' ? 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
|
|
1101
|
+
const probeStepMs = mode === 'exact' ? 5 * 60 * 1000 : 30 * 60 * 1000;
|
|
1102
|
+
const boundaries: Date[] = [dayStartUtc];
|
|
1103
|
+
let cursor = dayStartUtc;
|
|
1104
|
+
while (cursor < dayEndUtc) {
|
|
1105
|
+
const next = new Date(Math.min(cursor.getTime() + stepMs, dayEndUtc.getTime()));
|
|
1106
|
+
boundaries.push(...findSignTransitionsInBucket(cursor, next, probeStepMs));
|
|
1107
|
+
cursor = next;
|
|
1108
|
+
}
|
|
1109
|
+
boundaries.push(dayEndUtc);
|
|
1110
|
+
|
|
1111
|
+
const windows = boundaries.slice(0, -1).map((start, i) => {
|
|
1112
|
+
const end = boundaries[i + 1];
|
|
1113
|
+
const sample = new Date((start.getTime() + end.getTime()) / 2);
|
|
1114
|
+
const sign = getAscSign(sample).sign;
|
|
1115
|
+
return {
|
|
1116
|
+
sign,
|
|
1117
|
+
start: formatLocalTimestampWithOffset(start, input.timezone),
|
|
1118
|
+
end: formatLocalTimestampWithOffset(end, input.timezone),
|
|
1119
|
+
durationMinutes: Math.round((end.getTime() - start.getTime()) / 60000),
|
|
1120
|
+
};
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
const structuredData = {
|
|
1124
|
+
date: input.date,
|
|
1125
|
+
timezone: input.timezone,
|
|
1126
|
+
location: {
|
|
1127
|
+
latitude: input.latitude,
|
|
1128
|
+
longitude: input.longitude,
|
|
1129
|
+
},
|
|
1130
|
+
mode,
|
|
1131
|
+
windows,
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const humanText = `Rising Sign Windows (${input.date}, ${input.timezone}, ${mode}):\n\n${windows
|
|
1135
|
+
.map(
|
|
1136
|
+
(window) =>
|
|
1137
|
+
`${window.sign}: ${formatInTimezone(new Date(window.start), input.timezone)} → ${formatInTimezone(new Date(window.end), input.timezone)}`
|
|
1138
|
+
)
|
|
1139
|
+
.join('\n')}`;
|
|
1140
|
+
|
|
1141
|
+
return {
|
|
1142
|
+
data: structuredData,
|
|
1143
|
+
text: humanText,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
private getElectionalApplyingAspects(
|
|
1148
|
+
positions: PlanetPosition[],
|
|
1149
|
+
orbDegrees: number
|
|
1150
|
+
): ElectionalAspect[] {
|
|
1151
|
+
const aspects: ElectionalAspect[] = [];
|
|
1152
|
+
|
|
1153
|
+
for (let i = 0; i < positions.length; i++) {
|
|
1154
|
+
for (let j = i + 1; j < positions.length; j++) {
|
|
1155
|
+
const from = positions[i];
|
|
1156
|
+
const to = positions[j];
|
|
1157
|
+
const currentAngle = this.ephem.calculateAspectAngle(from.longitude, to.longitude);
|
|
1158
|
+
|
|
1159
|
+
for (const aspect of ASPECTS) {
|
|
1160
|
+
const orb = Math.abs(currentAngle - aspect.angle);
|
|
1161
|
+
if (orb > aspect.orb || orb > orbDegrees) {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const applying = this.isElectionalAspectApplying(from, to, aspect.angle);
|
|
1166
|
+
if (!applying) {
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
aspects.push({
|
|
1171
|
+
from_body: from.planet,
|
|
1172
|
+
to_body: to.planet,
|
|
1173
|
+
aspect: aspect.name,
|
|
1174
|
+
orb: Number.parseFloat(orb.toFixed(4)),
|
|
1175
|
+
applying: true,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return aspects.sort(
|
|
1182
|
+
(a, b) =>
|
|
1183
|
+
a.orb - b.orb ||
|
|
1184
|
+
a.from_body.localeCompare(b.from_body) ||
|
|
1185
|
+
a.to_body.localeCompare(b.to_body) ||
|
|
1186
|
+
a.aspect.localeCompare(b.aspect)
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
private isElectionalAspectApplying(
|
|
1191
|
+
from: Pick<PlanetPosition, 'longitude' | 'speed'>,
|
|
1192
|
+
to: Pick<PlanetPosition, 'longitude' | 'speed'>,
|
|
1193
|
+
aspectAngle: number
|
|
1194
|
+
): boolean {
|
|
1195
|
+
const signedSeparation = this.getSignedAngularDifference(from.longitude, to.longitude);
|
|
1196
|
+
const currentSeparation = Math.abs(signedSeparation);
|
|
1197
|
+
if (currentSeparation === aspectAngle) {
|
|
1198
|
+
return false;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const separationRate = Math.sign(signedSeparation || 1) * (to.speed - from.speed);
|
|
1202
|
+
if (separationRate === 0) {
|
|
1203
|
+
return false;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
return currentSeparation < aspectAngle ? separationRate > 0 : separationRate < 0;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
private getSignedAngularDifference(fromLongitude: number, toLongitude: number): number {
|
|
1210
|
+
const normalized = ((toLongitude - fromLongitude + 540) % 360) - 180;
|
|
1211
|
+
return normalized === -180 ? 180 : normalized;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
private getElectionalPhaseName(phaseAngle: number): ElectionalPhaseName {
|
|
1215
|
+
if (phaseAngle < 45) return 'new';
|
|
1216
|
+
if (phaseAngle < 90) return 'crescent';
|
|
1217
|
+
if (phaseAngle < 135) return 'first_quarter';
|
|
1218
|
+
if (phaseAngle < 180) return 'gibbous';
|
|
1219
|
+
if (phaseAngle < 225) return 'full';
|
|
1220
|
+
if (phaseAngle < 270) return 'disseminating';
|
|
1221
|
+
if (phaseAngle < 315) return 'last_quarter';
|
|
1222
|
+
return 'balsamic';
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
private getTraditionalSignRuler(sign: string): PlanetName {
|
|
1226
|
+
const signRulers: Record<string, PlanetName> = {
|
|
1227
|
+
Aries: 'Mars',
|
|
1228
|
+
Taurus: 'Venus',
|
|
1229
|
+
Gemini: 'Mercury',
|
|
1230
|
+
Cancer: 'Moon',
|
|
1231
|
+
Leo: 'Sun',
|
|
1232
|
+
Virgo: 'Mercury',
|
|
1233
|
+
Libra: 'Venus',
|
|
1234
|
+
Scorpio: 'Mars',
|
|
1235
|
+
Sagittarius: 'Jupiter',
|
|
1236
|
+
Capricorn: 'Saturn',
|
|
1237
|
+
Aquarius: 'Saturn',
|
|
1238
|
+
Pisces: 'Jupiter',
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
return signRulers[sign] ?? 'Mars';
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
getRetrogradePlanets(timezone?: string): ServiceResult<Record<string, unknown>> {
|
|
1245
|
+
const resolvedTimezone = this.resolveReportingTimezone(timezone);
|
|
453
1246
|
const now = this.now();
|
|
454
1247
|
const jd = this.ephem.dateToJulianDay(now);
|
|
455
1248
|
const allPlanetIds = Object.values(PLANETS);
|
|
456
1249
|
const positions = this.ephem.getAllPlanets(jd, allPlanetIds);
|
|
457
1250
|
const retrograde = positions.filter((p) => p.isRetrograde);
|
|
458
1251
|
|
|
459
|
-
const localNow = utcToLocal(now,
|
|
1252
|
+
const localNow = utcToLocal(now, resolvedTimezone);
|
|
460
1253
|
const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
|
|
461
1254
|
|
|
462
1255
|
const structuredData = {
|
|
463
1256
|
date: dateLabel,
|
|
464
|
-
timezone,
|
|
1257
|
+
timezone: resolvedTimezone,
|
|
465
1258
|
planets: retrograde,
|
|
466
1259
|
};
|
|
467
1260
|
|
|
@@ -475,6 +1268,7 @@ export class AstroService {
|
|
|
475
1268
|
|
|
476
1269
|
async getRiseSetTimes(natalChart: NatalChart): Promise<ServiceResult<Record<string, unknown>>> {
|
|
477
1270
|
const timezone = natalChart.location.timezone;
|
|
1271
|
+
const reportingTimezone = this.mcpStartupDefaults.preferredTimezone || timezone;
|
|
478
1272
|
const now = this.now();
|
|
479
1273
|
const localNow = utcToLocal(now, timezone);
|
|
480
1274
|
const localMidnight = {
|
|
@@ -507,8 +1301,8 @@ export class AstroService {
|
|
|
507
1301
|
|
|
508
1302
|
const humanText = `Rise/Set Times:\n\n${results
|
|
509
1303
|
.map((r) => {
|
|
510
|
-
const rise = r.rise ?
|
|
511
|
-
const set = r.set ?
|
|
1304
|
+
const rise = r.rise ? this.formatTimestamp(r.rise, reportingTimezone) : 'none';
|
|
1305
|
+
const set = r.set ? this.formatTimestamp(r.set, reportingTimezone) : 'none';
|
|
512
1306
|
return `${r.planet}: Rise ${rise}, Set ${set}`;
|
|
513
1307
|
})
|
|
514
1308
|
.join('\n')}`;
|
|
@@ -519,18 +1313,19 @@ export class AstroService {
|
|
|
519
1313
|
};
|
|
520
1314
|
}
|
|
521
1315
|
|
|
522
|
-
getAsteroidPositions(timezone
|
|
1316
|
+
getAsteroidPositions(timezone?: string): ServiceResult<Record<string, unknown>> {
|
|
1317
|
+
const resolvedTimezone = this.resolveReportingTimezone(timezone);
|
|
523
1318
|
const now = this.now();
|
|
524
1319
|
const jd = this.ephem.dateToJulianDay(now);
|
|
525
1320
|
const asteroidIds = [...ASTEROIDS, ...NODES];
|
|
526
1321
|
const positions = this.ephem.getAllPlanets(jd, asteroidIds);
|
|
527
1322
|
|
|
528
|
-
const localNow = utcToLocal(now,
|
|
1323
|
+
const localNow = utcToLocal(now, resolvedTimezone);
|
|
529
1324
|
const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
|
|
530
1325
|
|
|
531
1326
|
const structuredData = {
|
|
532
1327
|
date: dateLabel,
|
|
533
|
-
timezone,
|
|
1328
|
+
timezone: resolvedTimezone,
|
|
534
1329
|
positions,
|
|
535
1330
|
};
|
|
536
1331
|
|
|
@@ -547,7 +1342,8 @@ export class AstroService {
|
|
|
547
1342
|
};
|
|
548
1343
|
}
|
|
549
1344
|
|
|
550
|
-
getNextEclipses(timezone
|
|
1345
|
+
getNextEclipses(timezone?: string): ServiceResult<Record<string, unknown>> {
|
|
1346
|
+
const resolvedTimezone = this.resolveReportingTimezone(timezone);
|
|
551
1347
|
const now = this.now();
|
|
552
1348
|
const jd = this.ephem.dateToJulianDay(now);
|
|
553
1349
|
|
|
@@ -564,7 +1360,7 @@ export class AstroService {
|
|
|
564
1360
|
maxTime: solarEclipse.maxTime.toISOString(),
|
|
565
1361
|
});
|
|
566
1362
|
humanLines.push(
|
|
567
|
-
`Next Solar Eclipse: ${
|
|
1363
|
+
`Next Solar Eclipse: ${this.formatTimestamp(solarEclipse.maxTime, resolvedTimezone)} (${solarEclipse.eclipseType})`
|
|
568
1364
|
);
|
|
569
1365
|
}
|
|
570
1366
|
|
|
@@ -575,11 +1371,11 @@ export class AstroService {
|
|
|
575
1371
|
maxTime: lunarEclipse.maxTime.toISOString(),
|
|
576
1372
|
});
|
|
577
1373
|
humanLines.push(
|
|
578
|
-
`Next Lunar Eclipse: ${
|
|
1374
|
+
`Next Lunar Eclipse: ${this.formatTimestamp(lunarEclipse.maxTime, resolvedTimezone)} (${lunarEclipse.eclipseType})`
|
|
579
1375
|
);
|
|
580
1376
|
}
|
|
581
1377
|
|
|
582
|
-
const structuredData = { timezone, eclipses };
|
|
1378
|
+
const structuredData = { timezone: resolvedTimezone, eclipses };
|
|
583
1379
|
const humanText =
|
|
584
1380
|
eclipses.length === 0
|
|
585
1381
|
? 'No eclipses found in the near future.'
|
|
@@ -594,6 +1390,11 @@ export class AstroService {
|
|
|
594
1390
|
hasNatalChart: natalChart !== null,
|
|
595
1391
|
natalChartName: natalChart?.name ?? null,
|
|
596
1392
|
natalChartTimezone: natalChart?.location.timezone ?? null,
|
|
1393
|
+
startupDefaults: {
|
|
1394
|
+
preferredTimezone: this.mcpStartupDefaults.preferredTimezone ?? null,
|
|
1395
|
+
preferredHouseStyle: this.mcpStartupDefaults.preferredHouseStyle ?? null,
|
|
1396
|
+
weekdayLabels: this.mcpStartupDefaults.weekdayLabels ?? null,
|
|
1397
|
+
},
|
|
597
1398
|
ephemerisInitialized: this.isInitialized(),
|
|
598
1399
|
stateModel: 'stateful-per-process',
|
|
599
1400
|
};
|