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
package/README.md
CHANGED
|
@@ -41,6 +41,12 @@ Then restart your MCP client and call `set_natal_chart`.
|
|
|
41
41
|
|
|
42
42
|
Run the CLI directly with `npx`:
|
|
43
43
|
|
|
44
|
+
```bash
|
|
45
|
+
npx --yes ether-to-astro --help
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or:
|
|
49
|
+
|
|
44
50
|
```bash
|
|
45
51
|
npx --yes --package=ether-to-astro e2a --help
|
|
46
52
|
```
|
|
@@ -87,6 +93,8 @@ You can ask your AI agent about:
|
|
|
87
93
|
|
|
88
94
|
### Transits
|
|
89
95
|
- **Daily mundane positions** - Current planetary positions
|
|
96
|
+
- **Mundane transit-to-transit aspects** - Deterministic aspect signals between transiting bodies
|
|
97
|
+
- **Mundane weather metadata** - Deterministic supportive/challenging grouping references (non-narrative)
|
|
90
98
|
- **Moon transits** - Fast-moving Moon aspects to natal planets
|
|
91
99
|
- **Personal planet transits** - Sun, Mercury, Venus, Mars aspects to natal chart
|
|
92
100
|
- **Outer planet transits** - Jupiter, Saturn, Uranus, Neptune, Pluto aspects
|
|
@@ -158,7 +166,7 @@ npm install
|
|
|
158
166
|
## Package Names
|
|
159
167
|
|
|
160
168
|
- Package: `ether-to-astro`
|
|
161
|
-
- CLI command: `e2a`
|
|
169
|
+
- CLI command aliases: `ether-to-astro`, `e2a`
|
|
162
170
|
- Canonical MCP command: `e2a --mcp`
|
|
163
171
|
- Compatibility MCP alias: `e2a-mcp`
|
|
164
172
|
|
|
@@ -194,8 +202,10 @@ This design is **MCP-compliant** for stdio transport and ensures complete isolat
|
|
|
194
202
|
|
|
195
203
|
`e2a` is JSON-first for agent usage and supports `--pretty` for human-readable output.
|
|
196
204
|
|
|
197
|
-
`npx` usage note:
|
|
198
|
-
`npx
|
|
205
|
+
`npx` usage note:
|
|
206
|
+
- `npx ether-to-astro ...` works directly (package-name bin alias).
|
|
207
|
+
- `npx e2a ...` does **not** work by itself because npm resolves package names first.
|
|
208
|
+
- `npx --package=ether-to-astro e2a ...` remains supported.
|
|
199
209
|
|
|
200
210
|
Examples:
|
|
201
211
|
|
|
@@ -294,8 +304,8 @@ Ask your AI agent:
|
|
|
294
304
|
- `forecast`: day-grouped transit output across the selected date window
|
|
295
305
|
- if `mode` is omitted, legacy behavior is preserved: `days_ahead=0` resolves to `snapshot`, and `days_ahead>0` resolves to `best_hit`
|
|
296
306
|
- each transit now includes additive placement metadata for both sides: sign, degree, and house
|
|
297
|
-
|
|
298
|
-
|
|
307
|
+
- with `include_mundane=true`, output includes deterministic mundane positions plus `mundane.aspects` and non-narrative `mundane.weather` grouping metadata
|
|
308
|
+
- when `include_mundane=true` and `mode=forecast`, output includes `mundane.days[]` with per-day grouped mundane aspects/weather
|
|
299
309
|
|
|
300
310
|
### Electional
|
|
301
311
|
- `get_electional_context` - Stateless electional context for a local date, time, and location. Returns deterministic ascendant, sect/day-night classification, Moon phase, applying aspects, and optional ASC-ruler basics without requiring a natal chart.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ChartRenderer } from '../charts.js';
|
|
2
|
+
import type { NatalChart } from '../types.js';
|
|
3
|
+
import type { GenerateChartInput, GenerateTransitChartInput } from './service-types.js';
|
|
4
|
+
interface ChartServiceResult {
|
|
5
|
+
format: 'svg' | 'png' | 'webp';
|
|
6
|
+
outputPath?: string;
|
|
7
|
+
text: string;
|
|
8
|
+
svg?: string;
|
|
9
|
+
image?: {
|
|
10
|
+
data: string;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
interface ChartOutputServiceDependencies {
|
|
15
|
+
chartRenderer: ChartRenderer;
|
|
16
|
+
now: () => Date;
|
|
17
|
+
writeFile: (path: string, data: string | Buffer, encoding?: BufferEncoding) => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Internal chart rendering/output workflow used by `AstroService`.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* This module owns theme defaults, target-date resolution, and inline-vs-file
|
|
24
|
+
* output serialization for natal and transit chart rendering.
|
|
25
|
+
*/
|
|
26
|
+
export declare class ChartOutputService {
|
|
27
|
+
private readonly chartRenderer;
|
|
28
|
+
private readonly now;
|
|
29
|
+
private readonly writeFile;
|
|
30
|
+
constructor(deps: ChartOutputServiceDependencies);
|
|
31
|
+
/**
|
|
32
|
+
* Generate a natal chart image or SVG for the current chart.
|
|
33
|
+
*/
|
|
34
|
+
generateNatalChart(natalChart: NatalChart, input?: GenerateChartInput): Promise<ChartServiceResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Generate a transit chart image or SVG for a target date.
|
|
37
|
+
*/
|
|
38
|
+
generateTransitChart(natalChart: NatalChart, input?: GenerateTransitChartInput): Promise<ChartServiceResult>;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the local-noon transit anchor when the caller omits a date.
|
|
41
|
+
*/
|
|
42
|
+
private resolveTransitTargetDate;
|
|
43
|
+
}
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { getDefaultTheme } from '../constants.js';
|
|
2
|
+
import { formatDateOnly } from '../formatter.js';
|
|
3
|
+
import { localToUTC, utcToLocal } from '../time-utils.js';
|
|
4
|
+
import { parseDateOnlyInput } from './date-input.js';
|
|
5
|
+
/**
|
|
6
|
+
* Internal chart rendering/output workflow used by `AstroService`.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* This module owns theme defaults, target-date resolution, and inline-vs-file
|
|
10
|
+
* output serialization for natal and transit chart rendering.
|
|
11
|
+
*/
|
|
12
|
+
export class ChartOutputService {
|
|
13
|
+
chartRenderer;
|
|
14
|
+
now;
|
|
15
|
+
writeFile;
|
|
16
|
+
constructor(deps) {
|
|
17
|
+
this.chartRenderer = deps.chartRenderer;
|
|
18
|
+
this.now = deps.now;
|
|
19
|
+
this.writeFile = deps.writeFile;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Generate a natal chart image or SVG for the current chart.
|
|
23
|
+
*/
|
|
24
|
+
async generateNatalChart(natalChart, input = {}) {
|
|
25
|
+
const theme = input.theme || getDefaultTheme(natalChart.location.timezone);
|
|
26
|
+
const format = input.format || 'svg';
|
|
27
|
+
const outputPath = input.output_path;
|
|
28
|
+
const chart = await this.chartRenderer.generateNatalChart(natalChart, theme, format);
|
|
29
|
+
if (outputPath) {
|
|
30
|
+
if (format === 'svg') {
|
|
31
|
+
await this.writeFile(outputPath, chart, 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
await this.writeFile(outputPath, chart);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
format,
|
|
38
|
+
outputPath,
|
|
39
|
+
text: `Natal Chart for ${natalChart.name} saved to: ${outputPath}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (format === 'svg') {
|
|
43
|
+
return {
|
|
44
|
+
format,
|
|
45
|
+
text: `Natal Chart for ${natalChart.name}:`,
|
|
46
|
+
svg: chart,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
format,
|
|
51
|
+
text: `Natal Chart for ${natalChart.name} (${theme} theme, ${format.toUpperCase()} format):`,
|
|
52
|
+
image: {
|
|
53
|
+
data: chart.toString('base64'),
|
|
54
|
+
mimeType: format === 'png' ? 'image/png' : 'image/webp',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Generate a transit chart image or SVG for a target date.
|
|
60
|
+
*/
|
|
61
|
+
async generateTransitChart(natalChart, input = {}) {
|
|
62
|
+
const theme = input.theme ?? getDefaultTheme(natalChart.location.timezone);
|
|
63
|
+
const format = input.format ?? 'svg';
|
|
64
|
+
const targetDate = this.resolveTransitTargetDate(natalChart, input.date);
|
|
65
|
+
const outputPath = input.output_path;
|
|
66
|
+
const chart = await this.chartRenderer.generateTransitChart(natalChart, targetDate, theme, format);
|
|
67
|
+
const dateLabel = formatDateOnly(targetDate, natalChart.location.timezone);
|
|
68
|
+
if (outputPath) {
|
|
69
|
+
if (format === 'svg') {
|
|
70
|
+
await this.writeFile(outputPath, chart, 'utf-8');
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
await this.writeFile(outputPath, chart);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
format,
|
|
77
|
+
outputPath,
|
|
78
|
+
text: `Transit Chart for ${natalChart.name} (${dateLabel}) saved to ${outputPath}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (format === 'svg') {
|
|
82
|
+
return {
|
|
83
|
+
format,
|
|
84
|
+
text: `Transit Chart for ${natalChart.name} (${dateLabel})`,
|
|
85
|
+
svg: chart,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
format,
|
|
90
|
+
text: `Transit Chart for ${natalChart.name} (${dateLabel}, ${theme} theme, ${format.toUpperCase()} format):`,
|
|
91
|
+
image: {
|
|
92
|
+
data: chart.toString('base64'),
|
|
93
|
+
mimeType: format === 'png' ? 'image/png' : 'image/webp',
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the local-noon transit anchor when the caller omits a date.
|
|
99
|
+
*/
|
|
100
|
+
resolveTransitTargetDate(natalChart, dateStr) {
|
|
101
|
+
if (dateStr) {
|
|
102
|
+
const parsed = parseDateOnlyInput(dateStr);
|
|
103
|
+
return localToUTC(parsed, natalChart.location.timezone);
|
|
104
|
+
}
|
|
105
|
+
const now = this.now();
|
|
106
|
+
const localNow = utcToLocal(now, natalChart.location.timezone);
|
|
107
|
+
const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
|
|
108
|
+
return localToUTC(localNoon, natalChart.location.timezone);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a date-only input into local noon components.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* The service treats date-only transit requests as local-noon lookups so the
|
|
6
|
+
* requested calendar day remains stable across timezone conversions.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseDateOnlyInput(dateStr: string): {
|
|
9
|
+
year: number;
|
|
10
|
+
month: number;
|
|
11
|
+
day: number;
|
|
12
|
+
hour: number;
|
|
13
|
+
minute: number;
|
|
14
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a date-only input into local noon components.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* The service treats date-only transit requests as local-noon lookups so the
|
|
7
|
+
* requested calendar day remains stable across timezone conversions.
|
|
8
|
+
*/
|
|
9
|
+
export function parseDateOnlyInput(dateStr) {
|
|
10
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
|
11
|
+
if (!match) {
|
|
12
|
+
throw new Error(`Invalid date format: expected YYYY-MM-DD, got "${dateStr}"`);
|
|
13
|
+
}
|
|
14
|
+
const year = Number(match[1]);
|
|
15
|
+
const month = Number(match[2]);
|
|
16
|
+
const day = Number(match[3]);
|
|
17
|
+
if (month < 1 || month > 12) {
|
|
18
|
+
throw new Error(`Invalid month: ${month} (must be 1-12)`);
|
|
19
|
+
}
|
|
20
|
+
if (day < 1 || day > 31) {
|
|
21
|
+
throw new Error(`Invalid day: ${day} (must be 1-31)`);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
Temporal.PlainDate.from({ year, month, day });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`Invalid calendar date: ${dateStr}`);
|
|
28
|
+
}
|
|
29
|
+
return { year, month, day, hour: 12, minute: 0 };
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EphemerisCalculator } from '../ephemeris.js';
|
|
2
|
+
import type { HouseCalculator } from '../houses.js';
|
|
3
|
+
import type { GetElectionalContextInput, ServiceResult } from './service-types.js';
|
|
4
|
+
interface ElectionalServiceDependencies {
|
|
5
|
+
ephem: EphemerisCalculator;
|
|
6
|
+
houseCalc: HouseCalculator;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Internal electional workflow used by `AstroService`.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* This module owns validation, deterministic instant resolution, sect/moon
|
|
13
|
+
* metadata, optional applying-aspect summaries, and readable electional text
|
|
14
|
+
* while the `AstroService` facade keeps the public contract stable.
|
|
15
|
+
*/
|
|
16
|
+
export declare class ElectionalService {
|
|
17
|
+
private readonly ephem;
|
|
18
|
+
private readonly houseCalc;
|
|
19
|
+
constructor(deps: ElectionalServiceDependencies);
|
|
20
|
+
/**
|
|
21
|
+
* Produce deterministic electional context for a single local instant.
|
|
22
|
+
*/
|
|
23
|
+
getElectionalContext(input: GetElectionalContextInput): ServiceResult<Record<string, unknown>>;
|
|
24
|
+
/**
|
|
25
|
+
* Return only currently applying aspects inside the requested orb.
|
|
26
|
+
*/
|
|
27
|
+
private getElectionalApplyingAspects;
|
|
28
|
+
/**
|
|
29
|
+
* Determine whether a near-aspect is applying instead of separating.
|
|
30
|
+
*/
|
|
31
|
+
private isElectionalAspectApplying;
|
|
32
|
+
/**
|
|
33
|
+
* Compute the signed shortest angular distance in degrees.
|
|
34
|
+
*/
|
|
35
|
+
private getSignedAngularDifference;
|
|
36
|
+
/**
|
|
37
|
+
* Bucket a Sun-Moon phase angle into the service's coarse phase names.
|
|
38
|
+
*/
|
|
39
|
+
private getElectionalPhaseName;
|
|
40
|
+
/**
|
|
41
|
+
* Return the traditional ruler used for the ascendant sign summary.
|
|
42
|
+
*/
|
|
43
|
+
private getTraditionalSignRuler;
|
|
44
|
+
}
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
import { localToUTC } from '../time-utils.js';
|
|
3
|
+
import { ASPECTS, PLANETS, ZODIAC_SIGNS, } from '../types.js';
|
|
4
|
+
import { parseDateOnlyInput } from './date-input.js';
|
|
5
|
+
import { normalizeLongitude } from './shared.js';
|
|
6
|
+
const ELECTIONAL_CONTEXT_PLANET_IDS = [
|
|
7
|
+
PLANETS.SUN,
|
|
8
|
+
PLANETS.MOON,
|
|
9
|
+
PLANETS.MERCURY,
|
|
10
|
+
PLANETS.VENUS,
|
|
11
|
+
PLANETS.MARS,
|
|
12
|
+
PLANETS.JUPITER,
|
|
13
|
+
PLANETS.SATURN,
|
|
14
|
+
PLANETS.URANUS,
|
|
15
|
+
PLANETS.NEPTUNE,
|
|
16
|
+
PLANETS.PLUTO,
|
|
17
|
+
];
|
|
18
|
+
const ELECTIONAL_CONTEXT_HOUSE_SYSTEMS = ['P', 'K', 'W', 'R'];
|
|
19
|
+
/**
|
|
20
|
+
* Internal electional workflow used by `AstroService`.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* This module owns validation, deterministic instant resolution, sect/moon
|
|
24
|
+
* metadata, optional applying-aspect summaries, and readable electional text
|
|
25
|
+
* while the `AstroService` facade keeps the public contract stable.
|
|
26
|
+
*/
|
|
27
|
+
export class ElectionalService {
|
|
28
|
+
ephem;
|
|
29
|
+
houseCalc;
|
|
30
|
+
constructor(deps) {
|
|
31
|
+
this.ephem = deps.ephem;
|
|
32
|
+
this.houseCalc = deps.houseCalc;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Produce deterministic electional context for a single local instant.
|
|
36
|
+
*/
|
|
37
|
+
getElectionalContext(input) {
|
|
38
|
+
if (input.latitude < -90 || input.latitude > 90) {
|
|
39
|
+
throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
|
|
40
|
+
}
|
|
41
|
+
if (input.longitude < -180 || input.longitude > 180) {
|
|
42
|
+
throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
|
|
43
|
+
}
|
|
44
|
+
const houseSystem = input.house_system ?? 'P';
|
|
45
|
+
if (!ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.includes(houseSystem)) {
|
|
46
|
+
throw new Error(`Invalid house_system: ${houseSystem} (must be one of ${ELECTIONAL_CONTEXT_HOUSE_SYSTEMS.join(', ')})`);
|
|
47
|
+
}
|
|
48
|
+
const includeRulerBasics = input.include_ruler_basics ?? false;
|
|
49
|
+
const includePlanetaryApplications = input.include_planetary_applications ?? true;
|
|
50
|
+
const orbDegrees = input.orb_degrees ?? 3;
|
|
51
|
+
if (!Number.isFinite(orbDegrees) || orbDegrees < 0.1 || orbDegrees > 10) {
|
|
52
|
+
throw new Error(`Invalid orb_degrees: ${orbDegrees} (must be between 0.1 and 10)`);
|
|
53
|
+
}
|
|
54
|
+
const parsedDate = parseDateOnlyInput(input.date);
|
|
55
|
+
const parsedTime = parseTimeOnlyInput(input.time);
|
|
56
|
+
let instantUtc;
|
|
57
|
+
try {
|
|
58
|
+
instantUtc = localToUTC({
|
|
59
|
+
year: parsedDate.year,
|
|
60
|
+
month: parsedDate.month,
|
|
61
|
+
day: parsedDate.day,
|
|
62
|
+
hour: parsedTime.hour,
|
|
63
|
+
minute: parsedTime.minute,
|
|
64
|
+
second: parsedTime.second,
|
|
65
|
+
}, input.timezone, 'reject');
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof RangeError) {
|
|
69
|
+
throw new Error(`Invalid local electional time: ${input.date} ${input.time} in ${input.timezone} is ambiguous or nonexistent due to a DST transition.`);
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
const jdUt = this.ephem.dateToJulianDay(instantUtc);
|
|
74
|
+
const houses = this.houseCalc.calculateHouses(jdUt, input.latitude, input.longitude, houseSystem);
|
|
75
|
+
const positions = this.ephem.getAllPlanets(jdUt, ELECTIONAL_CONTEXT_PLANET_IDS);
|
|
76
|
+
const sun = positions.find((position) => position.planet === 'Sun');
|
|
77
|
+
const moon = positions.find((position) => position.planet === 'Moon');
|
|
78
|
+
if (!sun || !moon) {
|
|
79
|
+
throw new Error('Ephemeris failed to compute Sun/Moon positions for electional context.');
|
|
80
|
+
}
|
|
81
|
+
const sunHorizontal = this.ephem.getHorizontalCoordinates(jdUt, sun, input.longitude, input.latitude);
|
|
82
|
+
const rawSunAltitudeDegrees = sunHorizontal.trueAltitude;
|
|
83
|
+
const sunAltitudeDegrees = Number.parseFloat(rawSunAltitudeDegrees.toFixed(2));
|
|
84
|
+
const isDayChart = rawSunAltitudeDegrees >= 0;
|
|
85
|
+
const applyingAspects = includePlanetaryApplications
|
|
86
|
+
? this.getElectionalApplyingAspects(positions, orbDegrees)
|
|
87
|
+
: undefined;
|
|
88
|
+
const moonApplyingAspects = applyingAspects?.filter((aspect) => aspect.from_body === 'Moon' || aspect.to_body === 'Moon');
|
|
89
|
+
const phaseAngle = Number.parseFloat(normalizeLongitude(moon.longitude - sun.longitude).toFixed(2));
|
|
90
|
+
const warnings = [];
|
|
91
|
+
if (Math.abs(rawSunAltitudeDegrees) < 0.5) {
|
|
92
|
+
warnings.push('Sun is near the horizon; day/night classification is close to the boundary.');
|
|
93
|
+
}
|
|
94
|
+
warnings.push('Moon void-of-course is deferred in this slice and returns null.');
|
|
95
|
+
if (houses.system !== houseSystem) {
|
|
96
|
+
warnings.push(`House calculation fell back from ${houseSystem} to ${houses.system} for this location.`);
|
|
97
|
+
}
|
|
98
|
+
const ascLongitude = normalizeLongitude(houses.ascendant);
|
|
99
|
+
const ascSign = ZODIAC_SIGNS[Math.floor(ascLongitude / 30)];
|
|
100
|
+
const response = {
|
|
101
|
+
input: {
|
|
102
|
+
date: input.date,
|
|
103
|
+
time: input.time,
|
|
104
|
+
timezone: input.timezone,
|
|
105
|
+
latitude: input.latitude,
|
|
106
|
+
longitude: input.longitude,
|
|
107
|
+
house_system: houses.system,
|
|
108
|
+
instant_utc: instantUtc.toISOString(),
|
|
109
|
+
jd_ut: Number.parseFloat(jdUt.toFixed(8)),
|
|
110
|
+
},
|
|
111
|
+
ascendant: {
|
|
112
|
+
longitude: Number.parseFloat(ascLongitude.toFixed(4)),
|
|
113
|
+
sign: ascSign,
|
|
114
|
+
degree_in_sign: Number.parseFloat((ascLongitude % 30).toFixed(4)),
|
|
115
|
+
},
|
|
116
|
+
sect: {
|
|
117
|
+
is_day_chart: isDayChart,
|
|
118
|
+
sun_altitude_degrees: sunAltitudeDegrees,
|
|
119
|
+
classification: isDayChart ? 'day' : 'night',
|
|
120
|
+
},
|
|
121
|
+
moon: {
|
|
122
|
+
longitude: Number.parseFloat(moon.longitude.toFixed(4)),
|
|
123
|
+
sign: moon.sign,
|
|
124
|
+
phase_angle: phaseAngle,
|
|
125
|
+
phase_name: this.getElectionalPhaseName(phaseAngle),
|
|
126
|
+
is_void_of_course: null,
|
|
127
|
+
...(moonApplyingAspects !== undefined ? { applying_aspects: moonApplyingAspects } : {}),
|
|
128
|
+
},
|
|
129
|
+
meta: {
|
|
130
|
+
deterministic: true,
|
|
131
|
+
requires_natal: false,
|
|
132
|
+
warnings,
|
|
133
|
+
deferred_features: [
|
|
134
|
+
'robust_void_of_course',
|
|
135
|
+
'detailed_ruler_condition',
|
|
136
|
+
'house_context',
|
|
137
|
+
'natal_overlays',
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
if (applyingAspects) {
|
|
142
|
+
response.applying_aspects = applyingAspects;
|
|
143
|
+
}
|
|
144
|
+
if (includeRulerBasics) {
|
|
145
|
+
const rulerBody = this.getTraditionalSignRuler(ascSign);
|
|
146
|
+
const rulerPosition = positions.find((position) => position.planet === rulerBody);
|
|
147
|
+
if (!rulerPosition) {
|
|
148
|
+
throw new Error(`Ephemeris failed to compute ASC ruler position for ${rulerBody}.`);
|
|
149
|
+
}
|
|
150
|
+
response.ruler_basics = {
|
|
151
|
+
asc_sign_ruler: {
|
|
152
|
+
body: rulerBody,
|
|
153
|
+
longitude: Number.parseFloat(rulerPosition.longitude.toFixed(4)),
|
|
154
|
+
sign: rulerPosition.sign,
|
|
155
|
+
speed: Number.parseFloat(rulerPosition.speed.toFixed(6)),
|
|
156
|
+
is_retrograde: rulerPosition.isRetrograde,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const humanText = [
|
|
161
|
+
`Electional context for ${input.date} ${input.time} (${input.timezone})`,
|
|
162
|
+
'',
|
|
163
|
+
`Ascendant: ${response.ascendant.degree_in_sign.toFixed(2)}° ${response.ascendant.sign}`,
|
|
164
|
+
`Sect: ${response.sect.classification} (${response.sect.sun_altitude_degrees.toFixed(2)}° Sun altitude)`,
|
|
165
|
+
`Moon: ${response.moon.phase_name} in ${response.moon.sign} (${response.moon.phase_angle.toFixed(2)}° phase angle)`,
|
|
166
|
+
];
|
|
167
|
+
if (includePlanetaryApplications) {
|
|
168
|
+
const topLevelAspectText = applyingAspects && applyingAspects.length > 0
|
|
169
|
+
? applyingAspects
|
|
170
|
+
.slice(0, 5)
|
|
171
|
+
.map((aspect) => `${aspect.from_body} ${aspect.aspect} ${aspect.to_body} (${aspect.orb.toFixed(2)}°)`)
|
|
172
|
+
.join('\n')
|
|
173
|
+
: 'No applying aspects found within the configured orb.';
|
|
174
|
+
humanText.push('', 'Applying Aspects:', topLevelAspectText);
|
|
175
|
+
}
|
|
176
|
+
if (response.ruler_basics) {
|
|
177
|
+
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)}°)`);
|
|
178
|
+
}
|
|
179
|
+
if (warnings.length > 0) {
|
|
180
|
+
humanText.push('', `Warnings: ${warnings.join(' ')}`);
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
data: response,
|
|
184
|
+
text: humanText.join('\n'),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Return only currently applying aspects inside the requested orb.
|
|
189
|
+
*/
|
|
190
|
+
getElectionalApplyingAspects(positions, orbDegrees) {
|
|
191
|
+
const aspects = [];
|
|
192
|
+
for (let i = 0; i < positions.length; i++) {
|
|
193
|
+
for (let j = i + 1; j < positions.length; j++) {
|
|
194
|
+
const from = positions[i];
|
|
195
|
+
const to = positions[j];
|
|
196
|
+
const currentAngle = this.ephem.calculateAspectAngle(from.longitude, to.longitude);
|
|
197
|
+
for (const aspect of ASPECTS) {
|
|
198
|
+
const orb = Math.abs(currentAngle - aspect.angle);
|
|
199
|
+
if (orb > aspect.orb || orb > orbDegrees) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const applying = this.isElectionalAspectApplying(from, to, aspect.angle);
|
|
203
|
+
if (!applying) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
aspects.push({
|
|
207
|
+
from_body: from.planet,
|
|
208
|
+
to_body: to.planet,
|
|
209
|
+
aspect: aspect.name,
|
|
210
|
+
orb: Number.parseFloat(orb.toFixed(4)),
|
|
211
|
+
applying: true,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return aspects.sort((a, b) => a.orb - b.orb ||
|
|
217
|
+
a.from_body.localeCompare(b.from_body) ||
|
|
218
|
+
a.to_body.localeCompare(b.to_body) ||
|
|
219
|
+
a.aspect.localeCompare(b.aspect));
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Determine whether a near-aspect is applying instead of separating.
|
|
223
|
+
*/
|
|
224
|
+
isElectionalAspectApplying(from, to, aspectAngle) {
|
|
225
|
+
const signedSeparation = this.getSignedAngularDifference(from.longitude, to.longitude);
|
|
226
|
+
const currentSeparation = Math.abs(signedSeparation);
|
|
227
|
+
if (currentSeparation === aspectAngle) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
const separationRate = Math.sign(signedSeparation || 1) * (to.speed - from.speed);
|
|
231
|
+
if (separationRate === 0) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
return currentSeparation < aspectAngle ? separationRate > 0 : separationRate < 0;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Compute the signed shortest angular distance in degrees.
|
|
238
|
+
*/
|
|
239
|
+
getSignedAngularDifference(fromLongitude, toLongitude) {
|
|
240
|
+
const normalized = ((toLongitude - fromLongitude + 540) % 360) - 180;
|
|
241
|
+
return normalized === -180 ? 180 : normalized;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Bucket a Sun-Moon phase angle into the service's coarse phase names.
|
|
245
|
+
*/
|
|
246
|
+
getElectionalPhaseName(phaseAngle) {
|
|
247
|
+
if (phaseAngle < 45)
|
|
248
|
+
return 'new';
|
|
249
|
+
if (phaseAngle < 90)
|
|
250
|
+
return 'crescent';
|
|
251
|
+
if (phaseAngle < 135)
|
|
252
|
+
return 'first_quarter';
|
|
253
|
+
if (phaseAngle < 180)
|
|
254
|
+
return 'gibbous';
|
|
255
|
+
if (phaseAngle < 225)
|
|
256
|
+
return 'full';
|
|
257
|
+
if (phaseAngle < 270)
|
|
258
|
+
return 'disseminating';
|
|
259
|
+
if (phaseAngle < 315)
|
|
260
|
+
return 'last_quarter';
|
|
261
|
+
return 'balsamic';
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Return the traditional ruler used for the ascendant sign summary.
|
|
265
|
+
*/
|
|
266
|
+
getTraditionalSignRuler(sign) {
|
|
267
|
+
const signRulers = {
|
|
268
|
+
Aries: 'Mars',
|
|
269
|
+
Taurus: 'Venus',
|
|
270
|
+
Gemini: 'Mercury',
|
|
271
|
+
Cancer: 'Moon',
|
|
272
|
+
Leo: 'Sun',
|
|
273
|
+
Virgo: 'Mercury',
|
|
274
|
+
Libra: 'Venus',
|
|
275
|
+
Scorpio: 'Mars',
|
|
276
|
+
Sagittarius: 'Jupiter',
|
|
277
|
+
Capricorn: 'Saturn',
|
|
278
|
+
Aquarius: 'Saturn',
|
|
279
|
+
Pisces: 'Jupiter',
|
|
280
|
+
};
|
|
281
|
+
return signRulers[sign] ?? 'Mars';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Parse a strict local wall-clock time for electional requests.
|
|
286
|
+
*/
|
|
287
|
+
function parseTimeOnlyInput(timeStr) {
|
|
288
|
+
const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(timeStr);
|
|
289
|
+
if (!match) {
|
|
290
|
+
throw new Error(`Invalid time format: expected HH:mm[:ss], got "${timeStr}"`);
|
|
291
|
+
}
|
|
292
|
+
const hour = Number(match[1]);
|
|
293
|
+
const minute = Number(match[2]);
|
|
294
|
+
const second = match[3] === undefined ? 0 : Number(match[3]);
|
|
295
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
|
|
296
|
+
throw new Error(`Invalid clock time: ${timeStr}`);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
Temporal.PlainTime.from({ hour, minute, second });
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
throw new Error(`Invalid clock time: ${timeStr}`);
|
|
303
|
+
}
|
|
304
|
+
return { hour, minute, second };
|
|
305
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { McpStartupDefaults } from '../entrypoint.js';
|
|
2
|
+
import type { EphemerisCalculator } from '../ephemeris.js';
|
|
3
|
+
import type { HouseCalculator } from '../houses.js';
|
|
4
|
+
import { type NatalChart } from '../types.js';
|
|
5
|
+
import type { GetHousesInput, ServiceResult, SetNatalChartInput } from './service-types.js';
|
|
6
|
+
interface NatalServiceDependencies {
|
|
7
|
+
ephem: EphemerisCalculator;
|
|
8
|
+
houseCalc: HouseCalculator;
|
|
9
|
+
mcpStartupDefaults: Readonly<McpStartupDefaults>;
|
|
10
|
+
isInitialized: () => boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Internal natal/chart-state workflow used by `AstroService`.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* This module owns natal chart initialization, house resolution, and basic
|
|
17
|
+
* server-status serialization while the public `AstroService` facade preserves
|
|
18
|
+
* the existing contract for MCP and CLI callers.
|
|
19
|
+
*/
|
|
20
|
+
export declare class NatalService {
|
|
21
|
+
private readonly ephem;
|
|
22
|
+
private readonly houseCalc;
|
|
23
|
+
private readonly mcpStartupDefaults;
|
|
24
|
+
private readonly isInitialized;
|
|
25
|
+
constructor(deps: NatalServiceDependencies);
|
|
26
|
+
/**
|
|
27
|
+
* Build and cache the shared natal chart payload used by later workflows.
|
|
28
|
+
*/
|
|
29
|
+
setNatalChart(input: SetNatalChartInput): ServiceResult<Record<string, unknown>> & {
|
|
30
|
+
chart: NatalChart;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Calculate house cusps and angles for a natal chart.
|
|
34
|
+
*/
|
|
35
|
+
getHouses(natalChart: NatalChart, input?: GetHousesInput): ServiceResult<Record<string, unknown>>;
|
|
36
|
+
/**
|
|
37
|
+
* Summarize process-local server state and configured startup defaults.
|
|
38
|
+
*/
|
|
39
|
+
getServerStatus(natalChart: NatalChart | null): ServiceResult<Record<string, unknown>>;
|
|
40
|
+
}
|
|
41
|
+
export {};
|