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,181 @@
|
|
|
1
|
+
import type { EphemerisCalculator } from '../ephemeris.js';
|
|
2
|
+
import { formatInTimezone } from '../formatter.js';
|
|
3
|
+
import type { HouseCalculator } from '../houses.js';
|
|
4
|
+
import {
|
|
5
|
+
addLocalDays,
|
|
6
|
+
formatLocalTimestampWithOffset,
|
|
7
|
+
localToUTC,
|
|
8
|
+
utcToLocal,
|
|
9
|
+
} from '../time-utils.js';
|
|
10
|
+
import { ZODIAC_SIGNS } from '../types.js';
|
|
11
|
+
import { parseDateOnlyInput } from './date-input.js';
|
|
12
|
+
import type { GetRisingSignWindowsInput, ServiceResult } from './service-types.js';
|
|
13
|
+
import { normalizeLongitude } from './shared.js';
|
|
14
|
+
|
|
15
|
+
interface RisingSignServiceDependencies {
|
|
16
|
+
ephem: EphemerisCalculator;
|
|
17
|
+
houseCalc: HouseCalculator;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Internal rising-sign window scanner used by `AstroService`.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* This module owns the local-day scan, optional exact-boundary refinement, and
|
|
25
|
+
* serialization of sign windows while the public facade keeps the same method
|
|
26
|
+
* signature and result shape.
|
|
27
|
+
*/
|
|
28
|
+
export class RisingSignService {
|
|
29
|
+
private readonly ephem: EphemerisCalculator;
|
|
30
|
+
private readonly houseCalc: HouseCalculator;
|
|
31
|
+
|
|
32
|
+
constructor(deps: RisingSignServiceDependencies) {
|
|
33
|
+
this.ephem = deps.ephem;
|
|
34
|
+
this.houseCalc = deps.houseCalc;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find rising-sign windows across a local calendar day.
|
|
39
|
+
*/
|
|
40
|
+
getRisingSignWindows(input: GetRisingSignWindowsInput): ServiceResult<Record<string, unknown>> {
|
|
41
|
+
const mode = input.mode ?? 'approximate';
|
|
42
|
+
if (mode !== 'approximate' && mode !== 'exact') {
|
|
43
|
+
throw new Error(`Invalid mode: ${mode} (must be approximate or exact)`);
|
|
44
|
+
}
|
|
45
|
+
if (input.latitude < -90 || input.latitude > 90) {
|
|
46
|
+
throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
|
|
47
|
+
}
|
|
48
|
+
if (input.longitude < -180 || input.longitude > 180) {
|
|
49
|
+
throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parsed = parseDateOnlyInput(input.date);
|
|
53
|
+
try {
|
|
54
|
+
utcToLocal(new Date(), input.timezone);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`Invalid timezone: ${input.timezone}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const dayStartLocal = {
|
|
60
|
+
year: parsed.year,
|
|
61
|
+
month: parsed.month,
|
|
62
|
+
day: parsed.day,
|
|
63
|
+
hour: 0,
|
|
64
|
+
minute: 0,
|
|
65
|
+
second: 0,
|
|
66
|
+
};
|
|
67
|
+
const dayStartUtc = localToUTC(dayStartLocal, input.timezone);
|
|
68
|
+
const dayEndUtc = addLocalDays(dayStartLocal, input.timezone, 1);
|
|
69
|
+
|
|
70
|
+
const stepMs = mode === 'exact' ? 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
|
|
71
|
+
const probeStepMs = mode === 'exact' ? 5 * 60 * 1000 : 30 * 60 * 1000;
|
|
72
|
+
const boundaries: Date[] = [dayStartUtc];
|
|
73
|
+
let cursor = dayStartUtc;
|
|
74
|
+
while (cursor < dayEndUtc) {
|
|
75
|
+
const next = new Date(Math.min(cursor.getTime() + stepMs, dayEndUtc.getTime()));
|
|
76
|
+
boundaries.push(...this.findSignTransitionsInBucket(input, mode, cursor, next, probeStepMs));
|
|
77
|
+
cursor = next;
|
|
78
|
+
}
|
|
79
|
+
boundaries.push(dayEndUtc);
|
|
80
|
+
|
|
81
|
+
const windows = boundaries.slice(0, -1).map((start, index) => {
|
|
82
|
+
const end = boundaries[index + 1];
|
|
83
|
+
const sample = new Date((start.getTime() + end.getTime()) / 2);
|
|
84
|
+
const sign = this.getAscSign(input, sample).sign;
|
|
85
|
+
return {
|
|
86
|
+
sign,
|
|
87
|
+
start: formatLocalTimestampWithOffset(start, input.timezone),
|
|
88
|
+
end: formatLocalTimestampWithOffset(end, input.timezone),
|
|
89
|
+
durationMs: end.getTime() - start.getTime(),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const structuredData = {
|
|
94
|
+
date: input.date,
|
|
95
|
+
timezone: input.timezone,
|
|
96
|
+
location: {
|
|
97
|
+
latitude: input.latitude,
|
|
98
|
+
longitude: input.longitude,
|
|
99
|
+
},
|
|
100
|
+
mode,
|
|
101
|
+
windows,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const humanText = `Rising Sign Windows (${input.date}, ${input.timezone}, ${mode}):\n\n${windows
|
|
105
|
+
.map(
|
|
106
|
+
(window) =>
|
|
107
|
+
`${window.sign}: ${formatInTimezone(new Date(window.start), input.timezone)} → ${formatInTimezone(new Date(window.end), input.timezone)}`
|
|
108
|
+
)
|
|
109
|
+
.join('\n')}`;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
data: structuredData,
|
|
113
|
+
text: humanText,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Sample the ascendant sign for a specific moment.
|
|
119
|
+
*/
|
|
120
|
+
private getAscSign(
|
|
121
|
+
input: Pick<GetRisingSignWindowsInput, 'latitude' | 'longitude'>,
|
|
122
|
+
date: Date
|
|
123
|
+
): { sign: string; longitude: number } {
|
|
124
|
+
const jd = this.ephem.dateToJulianDay(date);
|
|
125
|
+
const houses = this.houseCalc.calculateHouses(jd, input.latitude, input.longitude, 'P');
|
|
126
|
+
const normalized = normalizeLongitude(houses.ascendant);
|
|
127
|
+
return { sign: ZODIAC_SIGNS[Math.floor(normalized / 30)], longitude: normalized };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Binary-search a sign change down to a stable exact-mode boundary.
|
|
132
|
+
*/
|
|
133
|
+
private refineBoundary(
|
|
134
|
+
input: Pick<GetRisingSignWindowsInput, 'latitude' | 'longitude'>,
|
|
135
|
+
left: Date,
|
|
136
|
+
right: Date
|
|
137
|
+
): Date {
|
|
138
|
+
const leftSign = this.getAscSign(input, left).sign;
|
|
139
|
+
let lo = left;
|
|
140
|
+
let hi = right;
|
|
141
|
+
for (let i = 0; i < 25; i++) {
|
|
142
|
+
const mid = new Date((lo.getTime() + hi.getTime()) / 2);
|
|
143
|
+
const midSign = this.getAscSign(input, mid).sign;
|
|
144
|
+
if (midSign === leftSign) {
|
|
145
|
+
lo = mid;
|
|
146
|
+
} else {
|
|
147
|
+
hi = mid;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return hi;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Probe a scan bucket and emit every sign transition inside it.
|
|
155
|
+
*/
|
|
156
|
+
private findSignTransitionsInBucket(
|
|
157
|
+
input: Pick<GetRisingSignWindowsInput, 'latitude' | 'longitude'>,
|
|
158
|
+
mode: 'approximate' | 'exact',
|
|
159
|
+
start: Date,
|
|
160
|
+
end: Date,
|
|
161
|
+
probeStepMs: number
|
|
162
|
+
): Date[] {
|
|
163
|
+
const boundaries: Date[] = [];
|
|
164
|
+
let probeCursor = start;
|
|
165
|
+
let currentSign = this.getAscSign(input, probeCursor).sign;
|
|
166
|
+
|
|
167
|
+
while (probeCursor < end) {
|
|
168
|
+
const probeNext = new Date(Math.min(probeCursor.getTime() + probeStepMs, end.getTime()));
|
|
169
|
+
const nextSign = this.getAscSign(input, probeNext).sign;
|
|
170
|
+
if (nextSign !== currentSign) {
|
|
171
|
+
boundaries.push(
|
|
172
|
+
mode === 'exact' ? this.refineBoundary(input, probeCursor, probeNext) : probeNext
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
probeCursor = probeNext;
|
|
176
|
+
currentSign = nextSign;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return boundaries;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Disambiguation } from '../time-utils.js';
|
|
2
|
+
import type { ElectionalHouseSystem, HouseSystem } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Public input type for building and caching the shared natal chart payload.
|
|
6
|
+
*/
|
|
7
|
+
export interface SetNatalChartInput {
|
|
8
|
+
name: string;
|
|
9
|
+
year: number;
|
|
10
|
+
month: number;
|
|
11
|
+
day: number;
|
|
12
|
+
hour: number;
|
|
13
|
+
minute: number;
|
|
14
|
+
latitude: number;
|
|
15
|
+
longitude: number;
|
|
16
|
+
timezone: string;
|
|
17
|
+
house_system?: HouseSystem;
|
|
18
|
+
birth_time_disambiguation?: Disambiguation;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Public input type for querying natal transits.
|
|
23
|
+
*/
|
|
24
|
+
export interface GetTransitsInput {
|
|
25
|
+
date?: string;
|
|
26
|
+
categories?: string[];
|
|
27
|
+
include_mundane?: boolean;
|
|
28
|
+
days_ahead?: number;
|
|
29
|
+
mode?: 'snapshot' | 'best_hit' | 'forecast';
|
|
30
|
+
max_orb?: number;
|
|
31
|
+
exact_only?: boolean;
|
|
32
|
+
applying_only?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Public input type for stateless electional context lookup.
|
|
37
|
+
*/
|
|
38
|
+
export interface GetElectionalContextInput {
|
|
39
|
+
date: string;
|
|
40
|
+
time: string;
|
|
41
|
+
timezone: string;
|
|
42
|
+
latitude: number;
|
|
43
|
+
longitude: number;
|
|
44
|
+
house_system?: ElectionalHouseSystem;
|
|
45
|
+
include_ruler_basics?: boolean;
|
|
46
|
+
include_planetary_applications?: boolean;
|
|
47
|
+
orb_degrees?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Public input type for house lookup on an existing natal chart.
|
|
52
|
+
*/
|
|
53
|
+
export interface GetHousesInput {
|
|
54
|
+
system?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Public input type for daily rising-sign window lookup.
|
|
59
|
+
*/
|
|
60
|
+
export interface GetRisingSignWindowsInput {
|
|
61
|
+
date: string;
|
|
62
|
+
latitude: number;
|
|
63
|
+
longitude: number;
|
|
64
|
+
timezone: string;
|
|
65
|
+
mode?: 'approximate' | 'exact';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Public output-wrapper shared by service methods that return data plus text.
|
|
70
|
+
*/
|
|
71
|
+
export interface ServiceResult<T> {
|
|
72
|
+
data: T;
|
|
73
|
+
text: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Public input type for chart rendering methods.
|
|
78
|
+
*/
|
|
79
|
+
export interface GenerateChartInput {
|
|
80
|
+
theme?: 'light' | 'dark';
|
|
81
|
+
format?: 'svg' | 'png' | 'webp';
|
|
82
|
+
output_path?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Public input type for transit-chart rendering methods.
|
|
87
|
+
*/
|
|
88
|
+
export interface GenerateTransitChartInput extends GenerateChartInput {
|
|
89
|
+
date?: string;
|
|
90
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { McpStartupDefaults } from '../entrypoint.js';
|
|
2
|
+
import { type HouseData, type HouseSystem, type NatalChart, ZODIAC_SIGNS } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalize any longitude into the standard 0-360 range.
|
|
6
|
+
*
|
|
7
|
+
* @param longitude - Raw longitude in degrees, including negative or >360 values
|
|
8
|
+
* @returns Longitude normalized into the half-open interval [0, 360)
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeLongitude(longitude: number): number {
|
|
11
|
+
return ((longitude % 360) + 360) % 360;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert a raw longitude into zodiac sign and in-sign degree.
|
|
16
|
+
*
|
|
17
|
+
* @param longitude - Raw longitude in degrees
|
|
18
|
+
* @returns Sign name plus degree within that sign
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* Rounded degree values that would otherwise land on 30.00 are carried into
|
|
22
|
+
* the next sign so serialized placement stays astrologically valid.
|
|
23
|
+
*/
|
|
24
|
+
export function getSignAndDegree(longitude: number): { sign: string; degree: number } {
|
|
25
|
+
const normalized = normalizeLongitude(longitude);
|
|
26
|
+
const baseSignIndex = Math.floor(normalized / 30);
|
|
27
|
+
const roundedDegree = Number.parseFloat((normalized % 30).toFixed(2));
|
|
28
|
+
const shouldCarryToNextSign = roundedDegree >= 30;
|
|
29
|
+
const signIndex = shouldCarryToNextSign
|
|
30
|
+
? (baseSignIndex + 1) % ZODIAC_SIGNS.length
|
|
31
|
+
: baseSignIndex;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
sign: ZODIAC_SIGNS[signIndex],
|
|
35
|
+
degree: shouldCarryToNextSign ? 0 : roundedDegree,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Map a longitude to its house number for a resolved house table.
|
|
41
|
+
*
|
|
42
|
+
* @param longitude - Longitude to place into a house
|
|
43
|
+
* @param houses - Resolved house cusps for the relevant chart or moment
|
|
44
|
+
* @returns 1-based house number
|
|
45
|
+
*/
|
|
46
|
+
export function getHouseNumber(longitude: number, houses: HouseData): number {
|
|
47
|
+
const normalized = normalizeLongitude(longitude);
|
|
48
|
+
|
|
49
|
+
for (let house = 1; house <= 12; house++) {
|
|
50
|
+
const start = normalizeLongitude(houses.cusps[house]);
|
|
51
|
+
const nextHouse = house === 12 ? 1 : house + 1;
|
|
52
|
+
const end = normalizeLongitude(houses.cusps[nextHouse]);
|
|
53
|
+
const span = (end - start + 360) % 360;
|
|
54
|
+
const offset = (normalized - start + 360) % 360;
|
|
55
|
+
|
|
56
|
+
if (span === 0 || offset === 0 || offset < span) {
|
|
57
|
+
return house;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return 12;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the house system precedence shared by service entrypoints.
|
|
66
|
+
*
|
|
67
|
+
* @param natalChart - Natal chart carrying stored and requested house system state
|
|
68
|
+
* @param startupDefaults - Process startup defaults that can provide fallback policy
|
|
69
|
+
* @param explicitSystem - Per-call override when the caller requested a specific system
|
|
70
|
+
* @returns Final house system to use for the calculation
|
|
71
|
+
*/
|
|
72
|
+
export function resolveHouseSystem(
|
|
73
|
+
natalChart: NatalChart,
|
|
74
|
+
startupDefaults: Readonly<McpStartupDefaults>,
|
|
75
|
+
explicitSystem?: string
|
|
76
|
+
): HouseSystem {
|
|
77
|
+
return (explicitSystem ||
|
|
78
|
+
natalChart.requestedHouseSystem ||
|
|
79
|
+
startupDefaults.preferredHouseStyle ||
|
|
80
|
+
natalChart.houseSystem ||
|
|
81
|
+
'P') as HouseSystem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the reporting timezone precedence shared by service entrypoints.
|
|
86
|
+
*
|
|
87
|
+
* @param startupDefaults - Process startup defaults
|
|
88
|
+
* @param explicitTimezone - Per-call reporting timezone override
|
|
89
|
+
* @param natalTimezone - Natal chart timezone used as fallback
|
|
90
|
+
* @returns Final reporting timezone for text and date labels
|
|
91
|
+
*/
|
|
92
|
+
export function resolveReportingTimezone(
|
|
93
|
+
startupDefaults: Readonly<McpStartupDefaults>,
|
|
94
|
+
explicitTimezone?: string,
|
|
95
|
+
natalTimezone?: string
|
|
96
|
+
): string {
|
|
97
|
+
return explicitTimezone ?? startupDefaults.preferredTimezone ?? natalTimezone ?? 'UTC';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve both calculation and reporting timezones for chart-based workflows.
|
|
102
|
+
*
|
|
103
|
+
* @param startupDefaults - Process startup defaults
|
|
104
|
+
* @param explicitReportingTimezone - Per-call reporting timezone override
|
|
105
|
+
* @param natalTimezone - Natal chart timezone used for local-day interpretation
|
|
106
|
+
* @returns Calculation timezone plus reporting timezone
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* Calculation timezone controls local-day math and ephemeris lookups. Reporting
|
|
110
|
+
* timezone controls user-facing labels and formatted timestamps.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveTimezones(
|
|
113
|
+
startupDefaults: Readonly<McpStartupDefaults>,
|
|
114
|
+
explicitReportingTimezone?: string,
|
|
115
|
+
natalTimezone?: string
|
|
116
|
+
): {
|
|
117
|
+
calculationTimezone: string;
|
|
118
|
+
reportingTimezone: string;
|
|
119
|
+
} {
|
|
120
|
+
return {
|
|
121
|
+
calculationTimezone: natalTimezone ?? 'UTC',
|
|
122
|
+
reportingTimezone: resolveReportingTimezone(
|
|
123
|
+
startupDefaults,
|
|
124
|
+
explicitReportingTimezone,
|
|
125
|
+
natalTimezone
|
|
126
|
+
),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { EclipseCalculator } from '../eclipses.js';
|
|
2
|
+
import type { McpStartupDefaults } from '../entrypoint.js';
|
|
3
|
+
import type { EphemerisCalculator } from '../ephemeris.js';
|
|
4
|
+
import type { RiseSetCalculator } from '../riseset.js';
|
|
5
|
+
import { localToUTC, utcToLocal } from '../time-utils.js';
|
|
6
|
+
import { ASTEROIDS, type NatalChart, NODES, PLANETS } from '../types.js';
|
|
7
|
+
import type { ServiceResult } from './service-types.js';
|
|
8
|
+
import { resolveReportingTimezone } from './shared.js';
|
|
9
|
+
|
|
10
|
+
interface SkyServiceDependencies {
|
|
11
|
+
ephem: EphemerisCalculator;
|
|
12
|
+
riseSetCalc: RiseSetCalculator;
|
|
13
|
+
eclipseCalc: EclipseCalculator;
|
|
14
|
+
mcpStartupDefaults: Readonly<McpStartupDefaults>;
|
|
15
|
+
now: () => Date;
|
|
16
|
+
formatTimestamp: (date: Date, timezone: string) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Internal current-sky and runtime lookup workflow used by `AstroService`.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* This module owns read-only runtime lookups that depend on "now", including
|
|
24
|
+
* retrogrades, asteroid/node snapshots, rise/set tables, and eclipse queries.
|
|
25
|
+
*/
|
|
26
|
+
export class SkyService {
|
|
27
|
+
private readonly ephem: EphemerisCalculator;
|
|
28
|
+
private readonly riseSetCalc: RiseSetCalculator;
|
|
29
|
+
private readonly eclipseCalc: EclipseCalculator;
|
|
30
|
+
private readonly mcpStartupDefaults: Readonly<McpStartupDefaults>;
|
|
31
|
+
private readonly now: () => Date;
|
|
32
|
+
private readonly formatTimestamp: (date: Date, timezone: string) => string;
|
|
33
|
+
|
|
34
|
+
constructor(deps: SkyServiceDependencies) {
|
|
35
|
+
this.ephem = deps.ephem;
|
|
36
|
+
this.riseSetCalc = deps.riseSetCalc;
|
|
37
|
+
this.eclipseCalc = deps.eclipseCalc;
|
|
38
|
+
this.mcpStartupDefaults = deps.mcpStartupDefaults;
|
|
39
|
+
this.now = deps.now;
|
|
40
|
+
this.formatTimestamp = deps.formatTimestamp;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return the currently retrograde planets for the requested reporting timezone.
|
|
45
|
+
*/
|
|
46
|
+
getRetrogradePlanets(timezone?: string): ServiceResult<Record<string, unknown>> {
|
|
47
|
+
const resolvedTimezone = resolveReportingTimezone(this.mcpStartupDefaults, timezone);
|
|
48
|
+
const now = this.now();
|
|
49
|
+
const jd = this.ephem.dateToJulianDay(now);
|
|
50
|
+
const positions = this.ephem.getAllPlanets(jd, Object.values(PLANETS));
|
|
51
|
+
const retrograde = positions.filter((position) => position.isRetrograde);
|
|
52
|
+
|
|
53
|
+
const structuredData = {
|
|
54
|
+
date: this.getDateLabel(now, resolvedTimezone),
|
|
55
|
+
timezone: resolvedTimezone,
|
|
56
|
+
planets: retrograde,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const humanText =
|
|
60
|
+
retrograde.length === 0
|
|
61
|
+
? 'No planets are currently retrograde.'
|
|
62
|
+
: `Retrograde Planets:\n\n${retrograde.map((position) => `${position.planet}: ${position.degree.toFixed(2)}° ${position.sign}`).join('\n')}`;
|
|
63
|
+
|
|
64
|
+
return { data: structuredData, text: humanText };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Return the next rise and set events after the local day anchor for the chart location.
|
|
69
|
+
*/
|
|
70
|
+
async getRiseSetTimes(natalChart: NatalChart): Promise<ServiceResult<Record<string, unknown>>> {
|
|
71
|
+
const timezone = natalChart.location.timezone;
|
|
72
|
+
const reportingTimezone = this.mcpStartupDefaults.preferredTimezone || timezone;
|
|
73
|
+
const now = this.now();
|
|
74
|
+
const localNow = utcToLocal(now, timezone);
|
|
75
|
+
const localMidnight = {
|
|
76
|
+
year: localNow.year,
|
|
77
|
+
month: localNow.month,
|
|
78
|
+
day: localNow.day,
|
|
79
|
+
hour: 0,
|
|
80
|
+
minute: 0,
|
|
81
|
+
second: 0,
|
|
82
|
+
};
|
|
83
|
+
const midnightUTC = localToUTC(localMidnight, timezone);
|
|
84
|
+
|
|
85
|
+
const results = await this.riseSetCalc.getAllRiseSet(
|
|
86
|
+
midnightUTC,
|
|
87
|
+
natalChart.location.latitude,
|
|
88
|
+
natalChart.location.longitude
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const structuredData = {
|
|
92
|
+
date: this.getDateLabel(now, timezone),
|
|
93
|
+
timezone,
|
|
94
|
+
times: results.map((result) => ({
|
|
95
|
+
planet: result.planet,
|
|
96
|
+
rise: result.rise?.toISOString() ?? null,
|
|
97
|
+
set: result.set?.toISOString() ?? null,
|
|
98
|
+
})),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const humanText = `Rise/Set Times:\n\n${results
|
|
102
|
+
.map((result) => {
|
|
103
|
+
const rise = result.rise ? this.formatTimestamp(result.rise, reportingTimezone) : 'none';
|
|
104
|
+
const set = result.set ? this.formatTimestamp(result.set, reportingTimezone) : 'none';
|
|
105
|
+
return `${result.planet}: Rise ${rise}, Set ${set}`;
|
|
106
|
+
})
|
|
107
|
+
.join('\n')}`;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
data: structuredData,
|
|
111
|
+
text: humanText,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Return current asteroid and node positions for the requested reporting timezone.
|
|
117
|
+
*/
|
|
118
|
+
getAsteroidPositions(timezone?: string): ServiceResult<Record<string, unknown>> {
|
|
119
|
+
const resolvedTimezone = resolveReportingTimezone(this.mcpStartupDefaults, timezone);
|
|
120
|
+
const now = this.now();
|
|
121
|
+
const jd = this.ephem.dateToJulianDay(now);
|
|
122
|
+
const positions = this.ephem.getAllPlanets(jd, [...ASTEROIDS, ...NODES]);
|
|
123
|
+
|
|
124
|
+
const structuredData = {
|
|
125
|
+
date: this.getDateLabel(now, resolvedTimezone),
|
|
126
|
+
timezone: resolvedTimezone,
|
|
127
|
+
positions,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const humanText = `Asteroid & Node Positions:\n\n${positions
|
|
131
|
+
.map((position) => {
|
|
132
|
+
const retrogradeLabel = position.isRetrograde ? ' Rx' : '';
|
|
133
|
+
return `${position.planet}: ${position.degree.toFixed(2)}° ${position.sign}${retrogradeLabel}`;
|
|
134
|
+
})
|
|
135
|
+
.join('\n')}`;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
data: structuredData,
|
|
139
|
+
text: humanText,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Look up the next solar and lunar eclipses after the current instant.
|
|
145
|
+
*/
|
|
146
|
+
getNextEclipses(timezone?: string): ServiceResult<Record<string, unknown>> {
|
|
147
|
+
const resolvedTimezone = resolveReportingTimezone(this.mcpStartupDefaults, timezone);
|
|
148
|
+
const jd = this.ephem.dateToJulianDay(this.now());
|
|
149
|
+
|
|
150
|
+
const solarEclipse = this.eclipseCalc.findNextSolarEclipse(jd);
|
|
151
|
+
const lunarEclipse = this.eclipseCalc.findNextLunarEclipse(jd);
|
|
152
|
+
|
|
153
|
+
const eclipses: Array<{ type: string; eclipseType: string; maxTime: string }> = [];
|
|
154
|
+
const humanLines: string[] = [];
|
|
155
|
+
|
|
156
|
+
if (solarEclipse) {
|
|
157
|
+
eclipses.push({
|
|
158
|
+
type: solarEclipse.type,
|
|
159
|
+
eclipseType: solarEclipse.eclipseType,
|
|
160
|
+
maxTime: solarEclipse.maxTime.toISOString(),
|
|
161
|
+
});
|
|
162
|
+
humanLines.push(
|
|
163
|
+
`Next Solar Eclipse: ${this.formatTimestamp(solarEclipse.maxTime, resolvedTimezone)} (${solarEclipse.eclipseType})`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (lunarEclipse) {
|
|
168
|
+
eclipses.push({
|
|
169
|
+
type: lunarEclipse.type,
|
|
170
|
+
eclipseType: lunarEclipse.eclipseType,
|
|
171
|
+
maxTime: lunarEclipse.maxTime.toISOString(),
|
|
172
|
+
});
|
|
173
|
+
humanLines.push(
|
|
174
|
+
`Next Lunar Eclipse: ${this.formatTimestamp(lunarEclipse.maxTime, resolvedTimezone)} (${lunarEclipse.eclipseType})`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const structuredData = { timezone: resolvedTimezone, eclipses };
|
|
179
|
+
const humanText =
|
|
180
|
+
eclipses.length === 0
|
|
181
|
+
? 'No eclipses found in the near future.'
|
|
182
|
+
: `Upcoming Eclipses:\n\n${humanLines.join('\n')}`;
|
|
183
|
+
|
|
184
|
+
return { data: structuredData, text: humanText };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private getDateLabel(date: Date, timezone: string): string {
|
|
188
|
+
const localDate = utcToLocal(date, timezone);
|
|
189
|
+
return `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
|
|
190
|
+
}
|
|
191
|
+
}
|