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