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/dist/astro-service.js
CHANGED
|
@@ -7,9 +7,9 @@ import { EphemerisCalculator } from './ephemeris.js';
|
|
|
7
7
|
import { formatDateOnly, formatInTimezone } from './formatter.js';
|
|
8
8
|
import { HouseCalculator } from './houses.js';
|
|
9
9
|
import { RiseSetCalculator } from './riseset.js';
|
|
10
|
-
import { addLocalDays, localToUTC, utcToLocal } from './time-utils.js';
|
|
10
|
+
import { addLocalDays, formatLocalTimestampWithOffset, localToUTC, utcToLocal, } from './time-utils.js';
|
|
11
11
|
import { deduplicateTransits, TransitCalculator } from './transits.js';
|
|
12
|
-
import { ASTEROIDS, NODES, OUTER_PLANETS, PERSONAL_PLANETS, PLANETS, ZODIAC_SIGNS, } from './types.js';
|
|
12
|
+
import { ASPECTS, ASTEROIDS, NODES, OUTER_PLANETS, PERSONAL_PLANETS, PLANET_NAMES, PLANETS, ZODIAC_SIGNS, } from './types.js';
|
|
13
13
|
export function parseDateOnlyInput(dateStr) {
|
|
14
14
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
|
15
15
|
if (!match) {
|
|
@@ -32,6 +32,38 @@ export function parseDateOnlyInput(dateStr) {
|
|
|
32
32
|
}
|
|
33
33
|
return { year, month, day, hour: 12, minute: 0 };
|
|
34
34
|
}
|
|
35
|
+
function parseTimeOnlyInput(timeStr) {
|
|
36
|
+
const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(timeStr);
|
|
37
|
+
if (!match) {
|
|
38
|
+
throw new Error(`Invalid time format: expected HH:mm[:ss], got "${timeStr}"`);
|
|
39
|
+
}
|
|
40
|
+
const hour = Number(match[1]);
|
|
41
|
+
const minute = Number(match[2]);
|
|
42
|
+
const second = match[3] === undefined ? 0 : Number(match[3]);
|
|
43
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
|
|
44
|
+
throw new Error(`Invalid clock time: ${timeStr}`);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
Temporal.PlainTime.from({ hour, minute, second });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error(`Invalid clock time: ${timeStr}`);
|
|
51
|
+
}
|
|
52
|
+
return { hour, minute, second };
|
|
53
|
+
}
|
|
54
|
+
const ELECTIONAL_CONTEXT_PLANET_IDS = [
|
|
55
|
+
PLANETS.SUN,
|
|
56
|
+
PLANETS.MOON,
|
|
57
|
+
PLANETS.MERCURY,
|
|
58
|
+
PLANETS.VENUS,
|
|
59
|
+
PLANETS.MARS,
|
|
60
|
+
PLANETS.JUPITER,
|
|
61
|
+
PLANETS.SATURN,
|
|
62
|
+
PLANETS.URANUS,
|
|
63
|
+
PLANETS.NEPTUNE,
|
|
64
|
+
PLANETS.PLUTO,
|
|
65
|
+
];
|
|
66
|
+
const ELECTIONAL_CONTEXT_HOUSE_SYSTEMS = ['P', 'K', 'W', 'R'];
|
|
35
67
|
export class AstroService {
|
|
36
68
|
ephem;
|
|
37
69
|
transitCalc;
|
|
@@ -39,6 +71,7 @@ export class AstroService {
|
|
|
39
71
|
riseSetCalc;
|
|
40
72
|
eclipseCalc;
|
|
41
73
|
chartRenderer;
|
|
74
|
+
mcpStartupDefaults;
|
|
42
75
|
now;
|
|
43
76
|
writeFileFn;
|
|
44
77
|
constructor(deps = {}) {
|
|
@@ -48,9 +81,61 @@ export class AstroService {
|
|
|
48
81
|
this.riseSetCalc = deps.riseSetCalc ?? new RiseSetCalculator(this.ephem);
|
|
49
82
|
this.eclipseCalc = deps.eclipseCalc ?? new EclipseCalculator(this.ephem);
|
|
50
83
|
this.chartRenderer = deps.chartRenderer ?? new ChartRenderer(this.ephem, this.houseCalc);
|
|
84
|
+
this.mcpStartupDefaults = Object.freeze({ ...(deps.mcpStartupDefaults ?? {}) });
|
|
51
85
|
this.now = deps.now ?? (() => new Date());
|
|
52
86
|
this.writeFileFn = deps.writeFile ?? writeFile;
|
|
53
87
|
}
|
|
88
|
+
formatTimestamp(date, timezone) {
|
|
89
|
+
return formatInTimezone(date, timezone, {
|
|
90
|
+
weekday: this.mcpStartupDefaults.weekdayLabels ?? false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
normalizeLongitude(longitude) {
|
|
94
|
+
return ((longitude % 360) + 360) % 360;
|
|
95
|
+
}
|
|
96
|
+
getSignAndDegree(longitude) {
|
|
97
|
+
const normalized = this.normalizeLongitude(longitude);
|
|
98
|
+
const baseSignIndex = Math.floor(normalized / 30);
|
|
99
|
+
const roundedDegree = Number.parseFloat((normalized % 30).toFixed(2));
|
|
100
|
+
const shouldCarryToNextSign = roundedDegree >= 30;
|
|
101
|
+
const signIndex = shouldCarryToNextSign
|
|
102
|
+
? (baseSignIndex + 1) % ZODIAC_SIGNS.length
|
|
103
|
+
: baseSignIndex;
|
|
104
|
+
return {
|
|
105
|
+
sign: ZODIAC_SIGNS[signIndex],
|
|
106
|
+
degree: shouldCarryToNextSign ? 0 : roundedDegree,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
getHouseNumber(longitude, houses) {
|
|
110
|
+
const normalized = this.normalizeLongitude(longitude);
|
|
111
|
+
for (let house = 1; house <= 12; house++) {
|
|
112
|
+
const start = this.normalizeLongitude(houses.cusps[house]);
|
|
113
|
+
const nextHouse = house === 12 ? 1 : house + 1;
|
|
114
|
+
const end = this.normalizeLongitude(houses.cusps[nextHouse]);
|
|
115
|
+
const span = (end - start + 360) % 360;
|
|
116
|
+
const offset = (normalized - start + 360) % 360;
|
|
117
|
+
if (span === 0 || offset === 0 || offset < span) {
|
|
118
|
+
return house;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return 12;
|
|
122
|
+
}
|
|
123
|
+
resolveHouseSystem(natalChart, explicitSystem) {
|
|
124
|
+
return (explicitSystem ||
|
|
125
|
+
natalChart.requestedHouseSystem ||
|
|
126
|
+
this.mcpStartupDefaults.preferredHouseStyle ||
|
|
127
|
+
natalChart.houseSystem ||
|
|
128
|
+
'P');
|
|
129
|
+
}
|
|
130
|
+
resolveTimezones(explicitReportingTimezone, natalTimezone) {
|
|
131
|
+
return {
|
|
132
|
+
calculationTimezone: natalTimezone ?? 'UTC',
|
|
133
|
+
reportingTimezone: this.resolveReportingTimezone(explicitReportingTimezone, natalTimezone),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
resolveReportingTimezone(explicitTimezone, natalTimezone) {
|
|
137
|
+
return explicitTimezone ?? this.mcpStartupDefaults.preferredTimezone ?? natalTimezone ?? 'UTC';
|
|
138
|
+
}
|
|
54
139
|
async init() {
|
|
55
140
|
await this.ephem.init();
|
|
56
141
|
}
|
|
@@ -91,6 +176,7 @@ export class AstroService {
|
|
|
91
176
|
planets: positions,
|
|
92
177
|
julianDay: jd,
|
|
93
178
|
houseSystem: houses.system,
|
|
179
|
+
requestedHouseSystem: requestedHouseSystem ?? undefined,
|
|
94
180
|
utcDateTime: utcComponents,
|
|
95
181
|
};
|
|
96
182
|
const sun = positions.find((p) => p.planet === 'Sun');
|
|
@@ -170,15 +256,27 @@ export class AstroService {
|
|
|
170
256
|
const categories = input.categories ?? ['all'];
|
|
171
257
|
const includeMundane = input.include_mundane ?? false;
|
|
172
258
|
const daysAhead = input.days_ahead ?? 0;
|
|
259
|
+
const requestedMode = input.mode;
|
|
173
260
|
const maxOrb = input.max_orb ?? 8;
|
|
174
261
|
const exactOnly = input.exact_only ?? false;
|
|
175
262
|
const applyingOnly = input.applying_only ?? false;
|
|
176
|
-
if (daysAhead < 0) {
|
|
177
|
-
throw new Error('days_ahead must be >= 0');
|
|
263
|
+
if (!Number.isFinite(daysAhead) || daysAhead < 0) {
|
|
264
|
+
throw new Error('days_ahead must be a finite number >= 0');
|
|
178
265
|
}
|
|
179
|
-
if (maxOrb < 0) {
|
|
180
|
-
throw new Error('max_orb must be >= 0');
|
|
266
|
+
if (!Number.isFinite(maxOrb) || maxOrb < 0) {
|
|
267
|
+
throw new Error('max_orb must be a finite number >= 0');
|
|
181
268
|
}
|
|
269
|
+
if (requestedMode !== undefined &&
|
|
270
|
+
requestedMode !== 'snapshot' &&
|
|
271
|
+
requestedMode !== 'best_hit' &&
|
|
272
|
+
requestedMode !== 'forecast') {
|
|
273
|
+
throw new Error('mode must be one of: snapshot, best_hit, forecast');
|
|
274
|
+
}
|
|
275
|
+
if (!natalChart.julianDay) {
|
|
276
|
+
throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
|
|
277
|
+
}
|
|
278
|
+
const mode = requestedMode ?? (daysAhead === 0 ? 'snapshot' : 'best_hit');
|
|
279
|
+
const modeSource = requestedMode === undefined ? 'legacy_default' : 'explicit';
|
|
182
280
|
let transitingPlanetIds = [];
|
|
183
281
|
if (categories.includes('all')) {
|
|
184
282
|
transitingPlanetIds = Object.values(PLANETS);
|
|
@@ -193,40 +291,70 @@ export class AstroService {
|
|
|
193
291
|
transitingPlanetIds.push(...OUTER_PLANETS.filter((p) => !transitingPlanetIds.includes(p)));
|
|
194
292
|
}
|
|
195
293
|
}
|
|
196
|
-
const
|
|
294
|
+
const { calculationTimezone, reportingTimezone } = this.resolveTimezones(undefined, natalChart.location.timezone);
|
|
197
295
|
let targetDate;
|
|
198
296
|
if (dateStr) {
|
|
199
297
|
const parsed = parseDateOnlyInput(dateStr);
|
|
200
|
-
targetDate = localToUTC(parsed,
|
|
298
|
+
targetDate = localToUTC(parsed, calculationTimezone);
|
|
201
299
|
}
|
|
202
300
|
else {
|
|
203
301
|
const now = this.now();
|
|
204
|
-
const localNow = utcToLocal(now,
|
|
302
|
+
const localNow = utcToLocal(now, calculationTimezone);
|
|
205
303
|
const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
|
|
206
|
-
targetDate = localToUTC(localNoon,
|
|
304
|
+
targetDate = localToUTC(localNoon, calculationTimezone);
|
|
207
305
|
}
|
|
208
306
|
const allTransits = [];
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
307
|
+
const transitsByDay = new Map();
|
|
308
|
+
const transitContext = new WeakMap();
|
|
309
|
+
const startLocal = utcToLocal(targetDate, calculationTimezone);
|
|
310
|
+
const effectiveDaysAhead = mode === 'snapshot' ? 0 : daysAhead;
|
|
311
|
+
for (let day = 0; day <= effectiveDaysAhead; day++) {
|
|
312
|
+
const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
|
|
212
313
|
const jd = this.ephem.dateToJulianDay(dayUTC);
|
|
213
314
|
const transitingPlanets = this.ephem.getAllPlanets(jd, transitingPlanetIds);
|
|
214
315
|
const transits = this.transitCalc.findTransits(transitingPlanets, natalChart.planets || [], jd);
|
|
316
|
+
for (const transit of transits) {
|
|
317
|
+
transitContext.set(transit, { julianDay: jd });
|
|
318
|
+
}
|
|
215
319
|
allTransits.push(...transits);
|
|
320
|
+
const dayLocal = utcToLocal(dayUTC, reportingTimezone);
|
|
321
|
+
const dayLabel = `${dayLocal.year}-${String(dayLocal.month).padStart(2, '0')}-${String(dayLocal.day).padStart(2, '0')}`;
|
|
322
|
+
transitsByDay.set(dayLabel, transits);
|
|
216
323
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
324
|
+
const filterTransits = (transits) => {
|
|
325
|
+
let filtered = transits.filter((t) => t.orb <= maxOrb);
|
|
326
|
+
if (exactOnly)
|
|
327
|
+
filtered = filtered.filter((t) => t.exactTime !== undefined);
|
|
328
|
+
if (applyingOnly)
|
|
329
|
+
filtered = filtered.filter((t) => t.isApplying);
|
|
330
|
+
filtered.sort((a, b) => a.orb - b.orb);
|
|
331
|
+
return filtered;
|
|
332
|
+
};
|
|
333
|
+
const chartHouseSystem = this.resolveHouseSystem(natalChart);
|
|
334
|
+
const natalHouses = this.houseCalc.calculateHouses(natalChart.julianDay, natalChart.location.latitude, natalChart.location.longitude, chartHouseSystem);
|
|
335
|
+
const transitHouseCache = new Map();
|
|
336
|
+
const planetIdsByName = new Map(Object.entries(PLANET_NAMES).map(([id, name]) => [name, Number(id)]));
|
|
337
|
+
const getTransitHouses = (julianDay) => {
|
|
338
|
+
const cached = transitHouseCache.get(julianDay);
|
|
339
|
+
if (cached) {
|
|
340
|
+
return cached;
|
|
341
|
+
}
|
|
342
|
+
const houses = this.houseCalc.calculateHouses(julianDay, natalChart.location.latitude, natalChart.location.longitude, chartHouseSystem);
|
|
343
|
+
transitHouseCache.set(julianDay, houses);
|
|
344
|
+
return houses;
|
|
345
|
+
};
|
|
346
|
+
const serializeTransit = (t) => {
|
|
347
|
+
const transitPlacement = this.getSignAndDegree(t.transitLongitude);
|
|
348
|
+
const natalPlacement = this.getSignAndDegree(t.natalLongitude);
|
|
349
|
+
const context = transitContext.get(t);
|
|
350
|
+
const transitHouseJulianDay = t.exactTime
|
|
351
|
+
? this.ephem.dateToJulianDay(t.exactTime)
|
|
352
|
+
: (context?.julianDay ?? this.ephem.dateToJulianDay(targetDate));
|
|
353
|
+
const transitHouses = getTransitHouses(transitHouseJulianDay);
|
|
354
|
+
const exactTransitLongitude = t.exactTime && planetIdsByName.has(t.transitingPlanet)
|
|
355
|
+
? this.ephem.getPlanetPosition(planetIdsByName.get(t.transitingPlanet), transitHouseJulianDay).longitude
|
|
356
|
+
: t.transitLongitude;
|
|
357
|
+
return {
|
|
230
358
|
transitingPlanet: t.transitingPlanet,
|
|
231
359
|
aspect: t.aspect,
|
|
232
360
|
natalPlanet: t.natalPlanet,
|
|
@@ -236,41 +364,323 @@ export class AstroService {
|
|
|
236
364
|
exactTime: t.exactTime?.toISOString(),
|
|
237
365
|
transitLongitude: t.transitLongitude,
|
|
238
366
|
natalLongitude: t.natalLongitude,
|
|
239
|
-
|
|
367
|
+
transitSign: transitPlacement.sign,
|
|
368
|
+
transitDegree: transitPlacement.degree,
|
|
369
|
+
transitHouse: this.getHouseNumber(exactTransitLongitude, transitHouses),
|
|
370
|
+
natalSign: natalPlacement.sign,
|
|
371
|
+
natalDegree: natalPlacement.degree,
|
|
372
|
+
natalHouse: this.getHouseNumber(t.natalLongitude, natalHouses),
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
const filteredTransits = mode === 'forecast'
|
|
376
|
+
? filterTransits(deduplicateTransits(allTransits))
|
|
377
|
+
: filterTransits(deduplicateTransits(allTransits));
|
|
378
|
+
const localDate = utcToLocal(targetDate, reportingTimezone);
|
|
379
|
+
const dateLabel = `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
|
|
380
|
+
const endLocal = utcToLocal(addLocalDays(startLocal, calculationTimezone, effectiveDaysAhead), reportingTimezone);
|
|
381
|
+
const windowEndLabel = `${endLocal.year}-${String(endLocal.month).padStart(2, '0')}-${String(endLocal.day).padStart(2, '0')}`;
|
|
382
|
+
const structuredData = {
|
|
383
|
+
date: dateLabel,
|
|
384
|
+
timezone: reportingTimezone,
|
|
385
|
+
calculation_timezone: calculationTimezone,
|
|
386
|
+
reporting_timezone: reportingTimezone,
|
|
387
|
+
transits: filteredTransits.map(serializeTransit),
|
|
388
|
+
};
|
|
389
|
+
const metadata = {
|
|
390
|
+
mode,
|
|
391
|
+
mode_source: modeSource,
|
|
392
|
+
days_ahead: effectiveDaysAhead,
|
|
393
|
+
window_start: dateLabel,
|
|
394
|
+
window_end: windowEndLabel,
|
|
240
395
|
};
|
|
241
396
|
let responseData = structuredData;
|
|
242
397
|
let mundaneText = '';
|
|
398
|
+
if (mode === 'forecast') {
|
|
399
|
+
const forecastDays = Array.from(transitsByDay.entries())
|
|
400
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
401
|
+
.map(([dayDate, dayTransits]) => ({
|
|
402
|
+
date: dayDate,
|
|
403
|
+
transits: filterTransits(deduplicateTransits(dayTransits)).map(serializeTransit),
|
|
404
|
+
}));
|
|
405
|
+
responseData = {
|
|
406
|
+
...metadata,
|
|
407
|
+
timezone: reportingTimezone,
|
|
408
|
+
calculation_timezone: calculationTimezone,
|
|
409
|
+
reporting_timezone: reportingTimezone,
|
|
410
|
+
forecast: forecastDays,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
responseData = {
|
|
415
|
+
...structuredData,
|
|
416
|
+
...metadata,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
243
419
|
if (includeMundane) {
|
|
244
|
-
const
|
|
245
|
-
|
|
420
|
+
const mundaneDays = [];
|
|
421
|
+
for (let day = 0; day <= daysAhead; day++) {
|
|
422
|
+
const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
|
|
423
|
+
mundaneDays.push(this.getMundaneDay(dayUTC, reportingTimezone, transitingPlanetIds));
|
|
424
|
+
}
|
|
425
|
+
const [anchorMundane] = mundaneDays;
|
|
246
426
|
const mundaneData = {
|
|
247
|
-
date:
|
|
248
|
-
timezone,
|
|
249
|
-
positions:
|
|
427
|
+
date: anchorMundane.date,
|
|
428
|
+
timezone: anchorMundane.timezone,
|
|
429
|
+
positions: anchorMundane.positions,
|
|
430
|
+
aspects: anchorMundane.aspects,
|
|
431
|
+
days: mundaneDays,
|
|
250
432
|
};
|
|
251
|
-
responseData = { transits:
|
|
252
|
-
mundaneText = `\n\nCurrent Planetary Positions:\n\n${
|
|
433
|
+
responseData = { transits: responseData, mundane: mundaneData };
|
|
434
|
+
mundaneText = `\n\nCurrent Planetary Positions:\n\n${anchorMundane.positions
|
|
253
435
|
.map((p) => `${p.planet}: ${p.degree.toFixed(1)}° ${p.sign} (${p.isRetrograde ? 'Rx' : 'Direct'})`)
|
|
254
436
|
.join('\n')}`;
|
|
437
|
+
if (mode === 'forecast') {
|
|
438
|
+
mundaneText +=
|
|
439
|
+
'\n\nNote: mundane positions remain anchored to the forecast start date in this mode.';
|
|
440
|
+
}
|
|
255
441
|
}
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
442
|
+
const formatHumanTransit = (t) => {
|
|
443
|
+
const exactStr = t.exactTime
|
|
444
|
+
? ` - Exact: ${this.formatTimestamp(t.exactTime, reportingTimezone)}`
|
|
445
|
+
: '';
|
|
259
446
|
const applyStr = t.isApplying ? '(applying)' : '(separating)';
|
|
260
447
|
return `${t.transitingPlanet} ${t.aspect} ${t.natalPlanet}: ${t.orb.toFixed(2)}° orb ${applyStr}${exactStr}`;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
448
|
+
};
|
|
449
|
+
const rangeStr = effectiveDaysAhead > 0 ? ` (next ${effectiveDaysAhead + 1} days)` : '';
|
|
450
|
+
let transitHeader;
|
|
451
|
+
if (mode === 'forecast') {
|
|
452
|
+
const forecastLines = Array.from(transitsByDay.entries())
|
|
453
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
454
|
+
.map(([dayDate, dayTransits]) => {
|
|
455
|
+
const dedupedDay = filterTransits(deduplicateTransits(dayTransits));
|
|
456
|
+
const lines = dedupedDay.length === 0
|
|
457
|
+
? 'No transits found matching the specified criteria.'
|
|
458
|
+
: dedupedDay.map(formatHumanTransit).join('\n');
|
|
459
|
+
return `${dayDate}:\n${lines}`;
|
|
460
|
+
})
|
|
461
|
+
.join('\n\n');
|
|
462
|
+
transitHeader = `Forecast transits${rangeStr}:\n\n${forecastLines}`;
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
const humanLines = filteredTransits.map(formatHumanTransit).join('\n');
|
|
466
|
+
const modeLabel = mode === 'snapshot' ? 'Transit snapshot' : 'Best-hit transits';
|
|
467
|
+
transitHeader =
|
|
468
|
+
filteredTransits.length > 0
|
|
469
|
+
? `${modeLabel}${rangeStr}:\n\n${humanLines}`
|
|
470
|
+
: 'No transits found matching the specified criteria.';
|
|
471
|
+
}
|
|
267
472
|
return {
|
|
268
473
|
data: responseData,
|
|
269
474
|
text: transitHeader + mundaneText,
|
|
270
475
|
};
|
|
271
476
|
}
|
|
477
|
+
getMundaneWeather(aspects) {
|
|
478
|
+
const supportiveAspects = new Set(['conjunction', 'trine', 'sextile']);
|
|
479
|
+
const challengingAspects = new Set(['square', 'opposition']);
|
|
480
|
+
return {
|
|
481
|
+
supportive: aspects.filter((a) => supportiveAspects.has(a.aspect)).map((a) => a.id),
|
|
482
|
+
challenging: aspects.filter((a) => challengingAspects.has(a.aspect)).map((a) => a.id),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
getMundaneAspects(date, positions) {
|
|
486
|
+
const aspects = [];
|
|
487
|
+
for (let i = 0; i < positions.length; i++) {
|
|
488
|
+
for (let j = i + 1; j < positions.length; j++) {
|
|
489
|
+
const planetA = positions[i];
|
|
490
|
+
const planetB = positions[j];
|
|
491
|
+
const angle = this.ephem.calculateAspectAngle(planetA.longitude, planetB.longitude);
|
|
492
|
+
for (const aspect of ASPECTS) {
|
|
493
|
+
const orb = Math.abs(angle - aspect.angle);
|
|
494
|
+
if (orb > aspect.orb) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const futureLongitudeA = (planetA.longitude + planetA.speed * 0.1 + 360) % 360;
|
|
498
|
+
const futureLongitudeB = (planetB.longitude + planetB.speed * 0.1 + 360) % 360;
|
|
499
|
+
const futureAngle = this.ephem.calculateAspectAngle(futureLongitudeA, futureLongitudeB);
|
|
500
|
+
const futureOrb = Math.abs(futureAngle - aspect.angle);
|
|
501
|
+
aspects.push({
|
|
502
|
+
id: `${date}:${planetA.planet}-${aspect.name}-${planetB.planet}`,
|
|
503
|
+
planetA: planetA.planet,
|
|
504
|
+
planetB: planetB.planet,
|
|
505
|
+
aspect: aspect.name,
|
|
506
|
+
orb: Number.parseFloat(orb.toFixed(2)),
|
|
507
|
+
isApplying: futureOrb < orb,
|
|
508
|
+
longitudeA: planetA.longitude,
|
|
509
|
+
longitudeB: planetB.longitude,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return aspects.sort((a, b) => a.orb - b.orb ||
|
|
515
|
+
a.planetA.localeCompare(b.planetA) ||
|
|
516
|
+
a.planetB.localeCompare(b.planetB) ||
|
|
517
|
+
a.aspect.localeCompare(b.aspect));
|
|
518
|
+
}
|
|
519
|
+
getMundaneDay(dayUTC, timezone, transitingPlanetIds) {
|
|
520
|
+
const localDay = utcToLocal(dayUTC, timezone);
|
|
521
|
+
const dateLabel = `${localDay.year}-${String(localDay.month).padStart(2, '0')}-${String(localDay.day).padStart(2, '0')}`;
|
|
522
|
+
const currentJD = this.ephem.dateToJulianDay(dayUTC);
|
|
523
|
+
const positions = this.ephem.getAllPlanets(currentJD, transitingPlanetIds);
|
|
524
|
+
const aspects = this.getMundaneAspects(dateLabel, positions);
|
|
525
|
+
return {
|
|
526
|
+
date: dateLabel,
|
|
527
|
+
timezone,
|
|
528
|
+
positions,
|
|
529
|
+
aspects,
|
|
530
|
+
weather: this.getMundaneWeather(aspects),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
getElectionalContext(input) {
|
|
534
|
+
if (input.latitude < -90 || input.latitude > 90) {
|
|
535
|
+
throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
|
|
536
|
+
}
|
|
537
|
+
if (input.longitude < -180 || input.longitude > 180) {
|
|
538
|
+
throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
|
|
539
|
+
}
|
|
540
|
+
const houseSystem = input.house_system ?? 'P';
|
|
541
|
+
if (!ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.includes(houseSystem)) {
|
|
542
|
+
throw new Error(`Invalid house_system: ${houseSystem} (must be one of ${ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.join(', ')})`);
|
|
543
|
+
}
|
|
544
|
+
const includeRulerBasics = input.include_ruler_basics ?? false;
|
|
545
|
+
const includePlanetaryApplications = input.include_planetary_applications ?? true;
|
|
546
|
+
const orbDegrees = input.orb_degrees ?? 3;
|
|
547
|
+
if (!Number.isFinite(orbDegrees) || orbDegrees < 0.1 || orbDegrees > 10) {
|
|
548
|
+
throw new Error(`Invalid orb_degrees: ${orbDegrees} (must be between 0.1 and 10)`);
|
|
549
|
+
}
|
|
550
|
+
const parsedDate = parseDateOnlyInput(input.date);
|
|
551
|
+
const parsedTime = parseTimeOnlyInput(input.time);
|
|
552
|
+
let instantUtc;
|
|
553
|
+
try {
|
|
554
|
+
instantUtc = localToUTC({
|
|
555
|
+
year: parsedDate.year,
|
|
556
|
+
month: parsedDate.month,
|
|
557
|
+
day: parsedDate.day,
|
|
558
|
+
hour: parsedTime.hour,
|
|
559
|
+
minute: parsedTime.minute,
|
|
560
|
+
second: parsedTime.second,
|
|
561
|
+
}, input.timezone, 'reject');
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
if (error instanceof RangeError) {
|
|
565
|
+
throw new Error(`Invalid local electional time: ${input.date} ${input.time} in ${input.timezone} is ambiguous or nonexistent due to a DST transition.`);
|
|
566
|
+
}
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
const jdUt = this.ephem.dateToJulianDay(instantUtc);
|
|
570
|
+
const houses = this.houseCalc.calculateHouses(jdUt, input.latitude, input.longitude, houseSystem);
|
|
571
|
+
const positions = this.ephem.getAllPlanets(jdUt, ELECTIONAL_CONTEXT_PLANET_IDS);
|
|
572
|
+
const sun = positions.find((position) => position.planet === 'Sun');
|
|
573
|
+
const moon = positions.find((position) => position.planet === 'Moon');
|
|
574
|
+
if (!sun || !moon) {
|
|
575
|
+
throw new Error('Ephemeris failed to compute Sun/Moon positions for electional context.');
|
|
576
|
+
}
|
|
577
|
+
const sunHorizontal = this.ephem.getHorizontalCoordinates(jdUt, sun, input.longitude, input.latitude);
|
|
578
|
+
const sunAltitudeDegrees = Number.parseFloat(sunHorizontal.trueAltitude.toFixed(2));
|
|
579
|
+
const isDayChart = sunAltitudeDegrees >= 0;
|
|
580
|
+
const applyingAspects = includePlanetaryApplications
|
|
581
|
+
? this.getElectionalApplyingAspects(positions, orbDegrees)
|
|
582
|
+
: undefined;
|
|
583
|
+
const moonApplyingAspects = applyingAspects?.filter((aspect) => aspect.from_body === 'Moon' || aspect.to_body === 'Moon');
|
|
584
|
+
const phaseAngle = Number.parseFloat(((((moon.longitude - sun.longitude) % 360) + 360) % 360).toFixed(2));
|
|
585
|
+
const warnings = [];
|
|
586
|
+
if (Math.abs(sunAltitudeDegrees) < 0.5) {
|
|
587
|
+
warnings.push('Sun is near the horizon; day/night classification is close to the boundary.');
|
|
588
|
+
}
|
|
589
|
+
warnings.push('Moon void-of-course is deferred in this slice and returns null.');
|
|
590
|
+
if (houses.system !== houseSystem) {
|
|
591
|
+
warnings.push(`House calculation fell back from ${houseSystem} to ${houses.system} for this location.`);
|
|
592
|
+
}
|
|
593
|
+
const ascLongitude = ((houses.ascendant % 360) + 360) % 360;
|
|
594
|
+
const ascSign = ZODIAC_SIGNS[Math.floor(ascLongitude / 30)];
|
|
595
|
+
const response = {
|
|
596
|
+
input: {
|
|
597
|
+
date: input.date,
|
|
598
|
+
time: input.time,
|
|
599
|
+
timezone: input.timezone,
|
|
600
|
+
latitude: input.latitude,
|
|
601
|
+
longitude: input.longitude,
|
|
602
|
+
house_system: houses.system,
|
|
603
|
+
instant_utc: instantUtc.toISOString(),
|
|
604
|
+
jd_ut: Number.parseFloat(jdUt.toFixed(8)),
|
|
605
|
+
},
|
|
606
|
+
ascendant: {
|
|
607
|
+
longitude: Number.parseFloat(ascLongitude.toFixed(4)),
|
|
608
|
+
sign: ascSign,
|
|
609
|
+
degree_in_sign: Number.parseFloat((ascLongitude % 30).toFixed(4)),
|
|
610
|
+
},
|
|
611
|
+
sect: {
|
|
612
|
+
is_day_chart: isDayChart,
|
|
613
|
+
sun_altitude_degrees: sunAltitudeDegrees,
|
|
614
|
+
classification: isDayChart ? 'day' : 'night',
|
|
615
|
+
},
|
|
616
|
+
moon: {
|
|
617
|
+
longitude: Number.parseFloat(moon.longitude.toFixed(4)),
|
|
618
|
+
sign: moon.sign,
|
|
619
|
+
phase_angle: phaseAngle,
|
|
620
|
+
phase_name: this.getElectionalPhaseName(phaseAngle),
|
|
621
|
+
is_void_of_course: null,
|
|
622
|
+
...(moonApplyingAspects !== undefined ? { applying_aspects: moonApplyingAspects } : {}),
|
|
623
|
+
},
|
|
624
|
+
meta: {
|
|
625
|
+
deterministic: true,
|
|
626
|
+
requires_natal: false,
|
|
627
|
+
warnings,
|
|
628
|
+
deferred_features: [
|
|
629
|
+
'robust_void_of_course',
|
|
630
|
+
'detailed_ruler_condition',
|
|
631
|
+
'house_context',
|
|
632
|
+
'natal_overlays',
|
|
633
|
+
],
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
if (applyingAspects) {
|
|
637
|
+
response.applying_aspects = applyingAspects;
|
|
638
|
+
}
|
|
639
|
+
if (includeRulerBasics) {
|
|
640
|
+
const rulerBody = this.getTraditionalSignRuler(ascSign);
|
|
641
|
+
const rulerPosition = positions.find((position) => position.planet === rulerBody);
|
|
642
|
+
if (!rulerPosition) {
|
|
643
|
+
throw new Error(`Ephemeris failed to compute ASC ruler position for ${rulerBody}.`);
|
|
644
|
+
}
|
|
645
|
+
response.ruler_basics = {
|
|
646
|
+
asc_sign_ruler: {
|
|
647
|
+
body: rulerBody,
|
|
648
|
+
longitude: Number.parseFloat(rulerPosition.longitude.toFixed(4)),
|
|
649
|
+
sign: rulerPosition.sign,
|
|
650
|
+
speed: Number.parseFloat(rulerPosition.speed.toFixed(6)),
|
|
651
|
+
is_retrograde: rulerPosition.isRetrograde,
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const humanText = [
|
|
656
|
+
`Electional context for ${input.date} ${input.time} (${input.timezone})`,
|
|
657
|
+
'',
|
|
658
|
+
`Ascendant: ${response.ascendant.degree_in_sign.toFixed(2)}° ${response.ascendant.sign}`,
|
|
659
|
+
`Sect: ${response.sect.classification} (${response.sect.sun_altitude_degrees.toFixed(2)}° Sun altitude)`,
|
|
660
|
+
`Moon: ${response.moon.phase_name} in ${response.moon.sign} (${response.moon.phase_angle.toFixed(2)}° phase angle)`,
|
|
661
|
+
];
|
|
662
|
+
if (includePlanetaryApplications) {
|
|
663
|
+
const topLevelAspectText = applyingAspects && applyingAspects.length > 0
|
|
664
|
+
? applyingAspects
|
|
665
|
+
.slice(0, 5)
|
|
666
|
+
.map((aspect) => `${aspect.from_body} ${aspect.aspect} ${aspect.to_body} (${aspect.orb.toFixed(2)}°)`)
|
|
667
|
+
.join('\n')
|
|
668
|
+
: 'No applying aspects found within the configured orb.';
|
|
669
|
+
humanText.push('', 'Applying Aspects:', topLevelAspectText);
|
|
670
|
+
}
|
|
671
|
+
if (response.ruler_basics) {
|
|
672
|
+
humanText.push('', `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)}°)`);
|
|
673
|
+
}
|
|
674
|
+
if (warnings.length > 0) {
|
|
675
|
+
humanText.push('', `Warnings: ${warnings.join(' ')}`);
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
data: response,
|
|
679
|
+
text: humanText.join('\n'),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
272
682
|
getHouses(natalChart, input = {}) {
|
|
273
|
-
const system = input.system
|
|
683
|
+
const system = this.resolveHouseSystem(natalChart, input.system);
|
|
274
684
|
if (!natalChart.julianDay) {
|
|
275
685
|
throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
|
|
276
686
|
}
|
|
@@ -288,17 +698,203 @@ export class AstroService {
|
|
|
288
698
|
text: humanText,
|
|
289
699
|
};
|
|
290
700
|
}
|
|
291
|
-
|
|
701
|
+
getRisingSignWindows(input) {
|
|
702
|
+
const mode = input.mode ?? 'approximate';
|
|
703
|
+
if (mode !== 'approximate' && mode !== 'exact') {
|
|
704
|
+
throw new Error(`Invalid mode: ${mode} (must be approximate or exact)`);
|
|
705
|
+
}
|
|
706
|
+
if (input.latitude < -90 || input.latitude > 90) {
|
|
707
|
+
throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
|
|
708
|
+
}
|
|
709
|
+
if (input.longitude < -180 || input.longitude > 180) {
|
|
710
|
+
throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
|
|
711
|
+
}
|
|
712
|
+
const parsed = parseDateOnlyInput(input.date);
|
|
713
|
+
try {
|
|
714
|
+
utcToLocal(new Date(), input.timezone);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
throw new Error(`Invalid timezone: ${input.timezone}`);
|
|
718
|
+
}
|
|
719
|
+
const dayStartLocal = {
|
|
720
|
+
year: parsed.year,
|
|
721
|
+
month: parsed.month,
|
|
722
|
+
day: parsed.day,
|
|
723
|
+
hour: 0,
|
|
724
|
+
minute: 0,
|
|
725
|
+
second: 0,
|
|
726
|
+
};
|
|
727
|
+
const dayStartUtc = localToUTC(dayStartLocal, input.timezone);
|
|
728
|
+
const dayEndUtc = addLocalDays(dayStartLocal, input.timezone, 1);
|
|
729
|
+
const getAscSign = (date) => {
|
|
730
|
+
const jd = this.ephem.dateToJulianDay(date);
|
|
731
|
+
const houses = this.houseCalc.calculateHouses(jd, input.latitude, input.longitude, 'P');
|
|
732
|
+
const normalized = ((houses.ascendant % 360) + 360) % 360;
|
|
733
|
+
return { sign: ZODIAC_SIGNS[Math.floor(normalized / 30)], longitude: normalized };
|
|
734
|
+
};
|
|
735
|
+
const refineBoundary = (left, right) => {
|
|
736
|
+
const leftSign = getAscSign(left).sign;
|
|
737
|
+
let lo = left;
|
|
738
|
+
let hi = right;
|
|
739
|
+
for (let i = 0; i < 25; i++) {
|
|
740
|
+
const mid = new Date((lo.getTime() + hi.getTime()) / 2);
|
|
741
|
+
const midSign = getAscSign(mid).sign;
|
|
742
|
+
if (midSign === leftSign) {
|
|
743
|
+
lo = mid;
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
hi = mid;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return hi;
|
|
750
|
+
};
|
|
751
|
+
const findSignTransitionsInBucket = (start, end, probeStepMs) => {
|
|
752
|
+
const boundaries = [];
|
|
753
|
+
let probeCursor = start;
|
|
754
|
+
let currentSign = getAscSign(probeCursor).sign;
|
|
755
|
+
while (probeCursor < end) {
|
|
756
|
+
const probeNext = new Date(Math.min(probeCursor.getTime() + probeStepMs, end.getTime()));
|
|
757
|
+
const nextSign = getAscSign(probeNext).sign;
|
|
758
|
+
if (nextSign !== currentSign) {
|
|
759
|
+
boundaries.push(mode === 'exact' ? refineBoundary(probeCursor, probeNext) : probeNext);
|
|
760
|
+
}
|
|
761
|
+
probeCursor = probeNext;
|
|
762
|
+
currentSign = nextSign;
|
|
763
|
+
}
|
|
764
|
+
return boundaries;
|
|
765
|
+
};
|
|
766
|
+
const stepMs = mode === 'exact' ? 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
|
|
767
|
+
const probeStepMs = mode === 'exact' ? 5 * 60 * 1000 : 30 * 60 * 1000;
|
|
768
|
+
const boundaries = [dayStartUtc];
|
|
769
|
+
let cursor = dayStartUtc;
|
|
770
|
+
while (cursor < dayEndUtc) {
|
|
771
|
+
const next = new Date(Math.min(cursor.getTime() + stepMs, dayEndUtc.getTime()));
|
|
772
|
+
boundaries.push(...findSignTransitionsInBucket(cursor, next, probeStepMs));
|
|
773
|
+
cursor = next;
|
|
774
|
+
}
|
|
775
|
+
boundaries.push(dayEndUtc);
|
|
776
|
+
const windows = boundaries.slice(0, -1).map((start, i) => {
|
|
777
|
+
const end = boundaries[i + 1];
|
|
778
|
+
const sample = new Date((start.getTime() + end.getTime()) / 2);
|
|
779
|
+
const sign = getAscSign(sample).sign;
|
|
780
|
+
return {
|
|
781
|
+
sign,
|
|
782
|
+
start: formatLocalTimestampWithOffset(start, input.timezone),
|
|
783
|
+
end: formatLocalTimestampWithOffset(end, input.timezone),
|
|
784
|
+
durationMinutes: Math.round((end.getTime() - start.getTime()) / 60000),
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
const structuredData = {
|
|
788
|
+
date: input.date,
|
|
789
|
+
timezone: input.timezone,
|
|
790
|
+
location: {
|
|
791
|
+
latitude: input.latitude,
|
|
792
|
+
longitude: input.longitude,
|
|
793
|
+
},
|
|
794
|
+
mode,
|
|
795
|
+
windows,
|
|
796
|
+
};
|
|
797
|
+
const humanText = `Rising Sign Windows (${input.date}, ${input.timezone}, ${mode}):\n\n${windows
|
|
798
|
+
.map((window) => `${window.sign}: ${formatInTimezone(new Date(window.start), input.timezone)} → ${formatInTimezone(new Date(window.end), input.timezone)}`)
|
|
799
|
+
.join('\n')}`;
|
|
800
|
+
return {
|
|
801
|
+
data: structuredData,
|
|
802
|
+
text: humanText,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
getElectionalApplyingAspects(positions, orbDegrees) {
|
|
806
|
+
const aspects = [];
|
|
807
|
+
for (let i = 0; i < positions.length; i++) {
|
|
808
|
+
for (let j = i + 1; j < positions.length; j++) {
|
|
809
|
+
const from = positions[i];
|
|
810
|
+
const to = positions[j];
|
|
811
|
+
const currentAngle = this.ephem.calculateAspectAngle(from.longitude, to.longitude);
|
|
812
|
+
for (const aspect of ASPECTS) {
|
|
813
|
+
const orb = Math.abs(currentAngle - aspect.angle);
|
|
814
|
+
if (orb > aspect.orb || orb > orbDegrees) {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const applying = this.isElectionalAspectApplying(from, to, aspect.angle);
|
|
818
|
+
if (!applying) {
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
aspects.push({
|
|
822
|
+
from_body: from.planet,
|
|
823
|
+
to_body: to.planet,
|
|
824
|
+
aspect: aspect.name,
|
|
825
|
+
orb: Number.parseFloat(orb.toFixed(4)),
|
|
826
|
+
applying: true,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return aspects.sort((a, b) => a.orb - b.orb ||
|
|
832
|
+
a.from_body.localeCompare(b.from_body) ||
|
|
833
|
+
a.to_body.localeCompare(b.to_body) ||
|
|
834
|
+
a.aspect.localeCompare(b.aspect));
|
|
835
|
+
}
|
|
836
|
+
isElectionalAspectApplying(from, to, aspectAngle) {
|
|
837
|
+
const signedSeparation = this.getSignedAngularDifference(from.longitude, to.longitude);
|
|
838
|
+
const currentSeparation = Math.abs(signedSeparation);
|
|
839
|
+
if (currentSeparation === aspectAngle) {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
const separationRate = Math.sign(signedSeparation || 1) * (to.speed - from.speed);
|
|
843
|
+
if (separationRate === 0) {
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
return currentSeparation < aspectAngle ? separationRate > 0 : separationRate < 0;
|
|
847
|
+
}
|
|
848
|
+
getSignedAngularDifference(fromLongitude, toLongitude) {
|
|
849
|
+
const normalized = ((toLongitude - fromLongitude + 540) % 360) - 180;
|
|
850
|
+
return normalized === -180 ? 180 : normalized;
|
|
851
|
+
}
|
|
852
|
+
getElectionalPhaseName(phaseAngle) {
|
|
853
|
+
if (phaseAngle < 45)
|
|
854
|
+
return 'new';
|
|
855
|
+
if (phaseAngle < 90)
|
|
856
|
+
return 'crescent';
|
|
857
|
+
if (phaseAngle < 135)
|
|
858
|
+
return 'first_quarter';
|
|
859
|
+
if (phaseAngle < 180)
|
|
860
|
+
return 'gibbous';
|
|
861
|
+
if (phaseAngle < 225)
|
|
862
|
+
return 'full';
|
|
863
|
+
if (phaseAngle < 270)
|
|
864
|
+
return 'disseminating';
|
|
865
|
+
if (phaseAngle < 315)
|
|
866
|
+
return 'last_quarter';
|
|
867
|
+
return 'balsamic';
|
|
868
|
+
}
|
|
869
|
+
getTraditionalSignRuler(sign) {
|
|
870
|
+
const signRulers = {
|
|
871
|
+
Aries: 'Mars',
|
|
872
|
+
Taurus: 'Venus',
|
|
873
|
+
Gemini: 'Mercury',
|
|
874
|
+
Cancer: 'Moon',
|
|
875
|
+
Leo: 'Sun',
|
|
876
|
+
Virgo: 'Mercury',
|
|
877
|
+
Libra: 'Venus',
|
|
878
|
+
Scorpio: 'Mars',
|
|
879
|
+
Sagittarius: 'Jupiter',
|
|
880
|
+
Capricorn: 'Saturn',
|
|
881
|
+
Aquarius: 'Saturn',
|
|
882
|
+
Pisces: 'Jupiter',
|
|
883
|
+
};
|
|
884
|
+
return signRulers[sign] ?? 'Mars';
|
|
885
|
+
}
|
|
886
|
+
getRetrogradePlanets(timezone) {
|
|
887
|
+
const resolvedTimezone = this.resolveReportingTimezone(timezone);
|
|
292
888
|
const now = this.now();
|
|
293
889
|
const jd = this.ephem.dateToJulianDay(now);
|
|
294
890
|
const allPlanetIds = Object.values(PLANETS);
|
|
295
891
|
const positions = this.ephem.getAllPlanets(jd, allPlanetIds);
|
|
296
892
|
const retrograde = positions.filter((p) => p.isRetrograde);
|
|
297
|
-
const localNow = utcToLocal(now,
|
|
893
|
+
const localNow = utcToLocal(now, resolvedTimezone);
|
|
298
894
|
const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
|
|
299
895
|
const structuredData = {
|
|
300
896
|
date: dateLabel,
|
|
301
|
-
timezone,
|
|
897
|
+
timezone: resolvedTimezone,
|
|
302
898
|
planets: retrograde,
|
|
303
899
|
};
|
|
304
900
|
const humanText = retrograde.length === 0
|
|
@@ -308,6 +904,7 @@ export class AstroService {
|
|
|
308
904
|
}
|
|
309
905
|
async getRiseSetTimes(natalChart) {
|
|
310
906
|
const timezone = natalChart.location.timezone;
|
|
907
|
+
const reportingTimezone = this.mcpStartupDefaults.preferredTimezone || timezone;
|
|
311
908
|
const now = this.now();
|
|
312
909
|
const localNow = utcToLocal(now, timezone);
|
|
313
910
|
const localMidnight = {
|
|
@@ -332,8 +929,8 @@ export class AstroService {
|
|
|
332
929
|
};
|
|
333
930
|
const humanText = `Rise/Set Times:\n\n${results
|
|
334
931
|
.map((r) => {
|
|
335
|
-
const rise = r.rise ?
|
|
336
|
-
const set = r.set ?
|
|
932
|
+
const rise = r.rise ? this.formatTimestamp(r.rise, reportingTimezone) : 'none';
|
|
933
|
+
const set = r.set ? this.formatTimestamp(r.set, reportingTimezone) : 'none';
|
|
337
934
|
return `${r.planet}: Rise ${rise}, Set ${set}`;
|
|
338
935
|
})
|
|
339
936
|
.join('\n')}`;
|
|
@@ -342,16 +939,17 @@ export class AstroService {
|
|
|
342
939
|
text: humanText,
|
|
343
940
|
};
|
|
344
941
|
}
|
|
345
|
-
getAsteroidPositions(timezone
|
|
942
|
+
getAsteroidPositions(timezone) {
|
|
943
|
+
const resolvedTimezone = this.resolveReportingTimezone(timezone);
|
|
346
944
|
const now = this.now();
|
|
347
945
|
const jd = this.ephem.dateToJulianDay(now);
|
|
348
946
|
const asteroidIds = [...ASTEROIDS, ...NODES];
|
|
349
947
|
const positions = this.ephem.getAllPlanets(jd, asteroidIds);
|
|
350
|
-
const localNow = utcToLocal(now,
|
|
948
|
+
const localNow = utcToLocal(now, resolvedTimezone);
|
|
351
949
|
const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
|
|
352
950
|
const structuredData = {
|
|
353
951
|
date: dateLabel,
|
|
354
|
-
timezone,
|
|
952
|
+
timezone: resolvedTimezone,
|
|
355
953
|
positions,
|
|
356
954
|
};
|
|
357
955
|
const humanText = `Asteroid & Node Positions:\n\n${positions
|
|
@@ -365,7 +963,8 @@ export class AstroService {
|
|
|
365
963
|
text: humanText,
|
|
366
964
|
};
|
|
367
965
|
}
|
|
368
|
-
getNextEclipses(timezone
|
|
966
|
+
getNextEclipses(timezone) {
|
|
967
|
+
const resolvedTimezone = this.resolveReportingTimezone(timezone);
|
|
369
968
|
const now = this.now();
|
|
370
969
|
const jd = this.ephem.dateToJulianDay(now);
|
|
371
970
|
const solarEclipse = this.eclipseCalc.findNextSolarEclipse(jd);
|
|
@@ -378,7 +977,7 @@ export class AstroService {
|
|
|
378
977
|
eclipseType: solarEclipse.eclipseType,
|
|
379
978
|
maxTime: solarEclipse.maxTime.toISOString(),
|
|
380
979
|
});
|
|
381
|
-
humanLines.push(`Next Solar Eclipse: ${
|
|
980
|
+
humanLines.push(`Next Solar Eclipse: ${this.formatTimestamp(solarEclipse.maxTime, resolvedTimezone)} (${solarEclipse.eclipseType})`);
|
|
382
981
|
}
|
|
383
982
|
if (lunarEclipse) {
|
|
384
983
|
eclipses.push({
|
|
@@ -386,9 +985,9 @@ export class AstroService {
|
|
|
386
985
|
eclipseType: lunarEclipse.eclipseType,
|
|
387
986
|
maxTime: lunarEclipse.maxTime.toISOString(),
|
|
388
987
|
});
|
|
389
|
-
humanLines.push(`Next Lunar Eclipse: ${
|
|
988
|
+
humanLines.push(`Next Lunar Eclipse: ${this.formatTimestamp(lunarEclipse.maxTime, resolvedTimezone)} (${lunarEclipse.eclipseType})`);
|
|
390
989
|
}
|
|
391
|
-
const structuredData = { timezone, eclipses };
|
|
990
|
+
const structuredData = { timezone: resolvedTimezone, eclipses };
|
|
392
991
|
const humanText = eclipses.length === 0
|
|
393
992
|
? 'No eclipses found in the near future.'
|
|
394
993
|
: `Upcoming Eclipses:\n\n${humanLines.join('\n')}`;
|
|
@@ -400,6 +999,11 @@ export class AstroService {
|
|
|
400
999
|
hasNatalChart: natalChart !== null,
|
|
401
1000
|
natalChartName: natalChart?.name ?? null,
|
|
402
1001
|
natalChartTimezone: natalChart?.location.timezone ?? null,
|
|
1002
|
+
startupDefaults: {
|
|
1003
|
+
preferredTimezone: this.mcpStartupDefaults.preferredTimezone ?? null,
|
|
1004
|
+
preferredHouseStyle: this.mcpStartupDefaults.preferredHouseStyle ?? null,
|
|
1005
|
+
weekdayLabels: this.mcpStartupDefaults.weekdayLabels ?? null,
|
|
1006
|
+
},
|
|
403
1007
|
ephemerisInitialized: this.isInitialized(),
|
|
404
1008
|
stateModel: 'stateful-per-process',
|
|
405
1009
|
};
|