caelus 0.17.0 → 0.19.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 CHANGED
@@ -5,7 +5,7 @@ ephemeris files. 1:1 port of the Python reference, checked by golden fixtures.
5
5
 
6
6
  ## Verification chain
7
7
 
8
- 1. Python engine checked against Swiss Ephemeris 2.10 across 19002099:
8
+ 1. Python engine checked against Swiss Ephemeris 2.10 across 18502150:
9
9
  every planet ≤ 1″ (Sun–Saturn), Moon ≤ 2.5″, Chiron ≤ 1″, mean node ≤ 1″,
10
10
  true node ≤ 1′ vs SE's built-in ephemeris (≤ 1″ vs JPL DE431)
11
11
  (vs full DE431 files, 1850–2149), angles and Placidus cusps ≤ 3.2″ — all
@@ -100,4 +100,4 @@ test/golden.test.ts conformance suite vs Python fixtures
100
100
  - caelus — this package
101
101
  - [caelus-birth](https://www.npmjs.com/package/caelus-birth) — local birth time + place → UT (charts take UT; use this)
102
102
  - [caelus-wheel](https://www.npmjs.com/package/caelus-wheel) — React SVG chart wheel
103
- - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, twenty-seven chart tools over stdio
103
+ - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, twenty-nine chart tools over stdio
package/accuracy.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
- "basis": "Swiss Ephemeris 2.10, 1900-2099, apparent geocentric ecliptic longitude (true equinox of date)",
3
- "range": "1900-2099",
2
+ "basis": "Swiss Ephemeris 2.10, 1850-2150, apparent geocentric ecliptic longitude (true equinox of date)",
3
+ "range": "1850-2150",
4
4
  "unit": "arcsec",
5
5
  "bodies": [
6
6
  {
7
7
  "name": "Sun",
8
- "max": "0.4",
8
+ "max": "0.5",
9
9
  "rms": "0.2",
10
10
  "note": ""
11
11
  },
@@ -23,7 +23,7 @@
23
23
  },
24
24
  {
25
25
  "name": "Mercury",
26
- "max": "0.5",
26
+ "max": "0.6",
27
27
  "rms": "0.2",
28
28
  "note": ""
29
29
  },
@@ -47,7 +47,7 @@
47
47
  },
48
48
  {
49
49
  "name": "Saturn",
50
- "max": "0.8",
50
+ "max": "1.0",
51
51
  "rms": "0.4",
52
52
  "note": ""
53
53
  },
@@ -65,9 +65,9 @@
65
65
  },
66
66
  {
67
67
  "name": "Pluto",
68
- "max": "2.5",
68
+ "max": "3.4",
69
69
  "rms": "1.0",
70
- "note": "series valid 1885-2099"
70
+ "note": "JPL Horizons barycenter Chebyshev pack (1700-2212, fit residual 4.1e-6 AU ≈ 0.03″); supersedes the Meeus ch.37 series. Bound is vs SE's own Moshier Pluto"
71
71
  },
72
72
  {
73
73
  "name": "Chiron",
@@ -101,7 +101,7 @@
101
101
  },
102
102
  {
103
103
  "name": "Mean Lilith",
104
- "max": "1.3",
104
+ "max": "1.6",
105
105
  "rms": "0.5",
106
106
  "note": "mean lunar apogee on the inclined orbit; latitude ≤0.1″"
107
107
  },
@@ -113,13 +113,13 @@
113
113
  },
114
114
  {
115
115
  "name": "RA / Dec",
116
- "max": "2.1",
116
+ "max": "2.6",
117
117
  "rms": "—",
118
118
  "note": "rotation is exact; bound tracks each body's ecliptic accuracy (Moon worst)"
119
119
  },
120
120
  {
121
121
  "name": "Topocentric Moon",
122
- "max": "2.5",
122
+ "max": "2.7",
123
123
  "rms": "—",
124
124
  "note": "parallax model adds ≤0.1″ over the geocentric bound"
125
125
  },
@@ -161,7 +161,7 @@
161
161
  },
162
162
  {
163
163
  "name": "True Lilith (osc. apogee)",
164
- "max": "187",
164
+ "max": "214",
165
165
  "rms": "—",
166
166
  "note": "hypersensitive to the lunar theory (~1/e amplification); SE's own Moshier-vs-DE difference dominates. 'True Lilith' values disagree across software at this scale"
167
167
  },
@@ -221,7 +221,7 @@
221
221
  },
222
222
  {
223
223
  "label": "Pluto / Chiron",
224
- "bound": "≤ 2.5″ / ≤ 1″"
224
+ "bound": "≤ 3.4″ / ≤ 1″"
225
225
  },
226
226
  {
227
227
  "label": "Angles & Placidus cusps",
@@ -233,7 +233,7 @@
233
233
  },
234
234
  {
235
235
  "label": "Mean Lilith",
236
- "bound": "≤ 1.3″"
236
+ "bound": "≤ 1.6″"
237
237
  },
238
238
  {
239
239
  "label": "8 new house systems",
@@ -268,12 +268,12 @@
268
268
  "bound": "types exact; max ≤ 9 s"
269
269
  }
270
270
  ],
271
- "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode)",
271
+ "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode), sampled over 1850-2150; validate_horizons.py cross-checks the edges (banded 1800/2200) against JPL directly",
272
272
  "true_node_vs_builtin": "1′",
273
273
  "counts": {
274
274
  "house_systems": 12,
275
275
  "sidereal_ayanamsas": 7,
276
- "mcp_tools": 27,
276
+ "mcp_tools": 29,
277
277
  "default_bodies": 13
278
278
  }
279
279
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * astroengine anchored charts -- realize a {@link Realm} + anchors into a chart.
3
+ *
4
+ * This is where the provenance layer meets the two generators. Given what a
5
+ * chart *is* and how its time/place are anchored, {@link realize} routes:
6
+ * when an instant can be resolved it runs the ephemeris (`chartAt`); when it
7
+ * cannot but constraints are supplied it runs the compiler's symbolic synthesis
8
+ * (`compileForm`); otherwise it reports that nothing could be computed and why.
9
+ * The realm rides along as framing for the interpretation layer.
10
+ */
11
+ import { Engine, Chart, ChartOptions } from "./chart.js";
12
+ import { CompiledForm, Constraint } from "./compiler.js";
13
+ import { Realm, TemporalAnchor, SpatialAnchor, AnchorRegistry, ResolvedTime, ResolvedPlace } from "./provenance.js";
14
+ /** A chart described by its realm and anchors, not a bare (instant, place). */
15
+ export interface AnchoredChart {
16
+ realm: Realm;
17
+ when: TemporalAnchor;
18
+ where?: SpatialAnchor;
19
+ /** Constraints for the compiler path -- a chart with no instant
20
+ * (archetypal / conceptual / symbolic). */
21
+ constraints?: Constraint[];
22
+ }
23
+ /** The outcome of {@link realize}: how (or whether) a chart was produced. */
24
+ export interface RealizedChart {
25
+ realm: Realm;
26
+ /** `ephemeris` (an instant resolved), `compiler` (synthesized from
27
+ * constraints), or `none` (neither was possible). */
28
+ via: "ephemeris" | "compiler" | "none";
29
+ time: ResolvedTime;
30
+ place: ResolvedPlace;
31
+ /** The computed chart, when an instant was available. */
32
+ chart: Chart | null;
33
+ /** The synthesized form, when the compiler path ran. */
34
+ form: CompiledForm | null;
35
+ note: string;
36
+ }
37
+ /**
38
+ * Realize an {@link AnchoredChart}: resolve its anchors and run the appropriate
39
+ * generator. A resolvable instant always wins (the realm is then just framing);
40
+ * failing that, constraints synthesize a form; failing both, nothing computes.
41
+ *
42
+ * @param engine The engine for the ephemeris path.
43
+ * @param anchored The realm + temporal/spatial anchors (+ optional constraints).
44
+ * @param registry Lookups for relative/narrative/named anchors.
45
+ * @param opts Chart options (house system, zodiac) for the ephemeris path.
46
+ * @returns A {@link RealizedChart}; `chart`/`form` are null on the paths that
47
+ * did not run.
48
+ */
49
+ export declare function realize(engine: Engine, anchored: AnchoredChart, registry?: AnchorRegistry, opts?: ChartOptions): RealizedChart;
@@ -0,0 +1,40 @@
1
+ import { compileForm } from "./compiler.js";
2
+ import { resolveTime, resolvePlace, isTimeAnchored, } from "./provenance.js";
3
+ /**
4
+ * Realize an {@link AnchoredChart}: resolve its anchors and run the appropriate
5
+ * generator. A resolvable instant always wins (the realm is then just framing);
6
+ * failing that, constraints synthesize a form; failing both, nothing computes.
7
+ *
8
+ * @param engine The engine for the ephemeris path.
9
+ * @param anchored The realm + temporal/spatial anchors (+ optional constraints).
10
+ * @param registry Lookups for relative/narrative/named anchors.
11
+ * @param opts Chart options (house system, zodiac) for the ephemeris path.
12
+ * @returns A {@link RealizedChart}; `chart`/`form` are null on the paths that
13
+ * did not run.
14
+ */
15
+ export function realize(engine, anchored, registry = {}, opts = {}) {
16
+ const time = resolveTime(anchored.when, registry);
17
+ const place = resolvePlace(anchored.where ?? { kind: "none", reason: "intentionally_unset" }, registry);
18
+ const base = { realm: anchored.realm, time, place };
19
+ if (time.jd !== null) {
20
+ const chart = engine.chartAt(time.jd, place.place?.lat ?? 0, place.place?.lonEast ?? 0, opts);
21
+ return {
22
+ ...base, via: "ephemeris", chart, form: null,
23
+ note: `ephemeris (${time.certainty}) `
24
+ + (place.place ? `at ${place.note ?? "given coordinates"}` : "no place; houses nominal at 0,0"),
25
+ };
26
+ }
27
+ if (anchored.constraints?.length) {
28
+ const form = compileForm(anchored.constraints);
29
+ return {
30
+ ...base, via: "compiler", chart: null, form,
31
+ note: `compiler synthesis (${form.impossible ? "impossible form" : `residual ${form.residual.toFixed(2)}`})`,
32
+ };
33
+ }
34
+ return {
35
+ ...base, via: "none", chart: null, form: null,
36
+ note: isTimeAnchored(anchored.realm)
37
+ ? `time-anchored realm but no instant resolved (${time.note ?? "no time"}); supply a resolvable anchor or constraints`
38
+ : `${anchored.realm} realm has no instant; supply constraints to synthesize a form`,
39
+ };
40
+ }
@@ -24,10 +24,15 @@ export function planetLines(ra, dec, gastDeg, latMin = -85.0, latMax = 85.0, lat
24
24
  const asc = [];
25
25
  const dsc = [];
26
26
  const n = Math.floor((latMax - latMin) / latStep + 1e-9); // never exceed latMax
27
+ // Skip the degenerate near-tangent point where |x| -> 1 (h0 -> 0 or 180): the
28
+ // body grazes the horizon at the meridian, acos' is singular there, and
29
+ // cross-platform libm rounding would swing the longitude by ~mas. Trimming it
30
+ // costs <0.003 deg of line length and makes the track deterministic.
31
+ const EDGE = 1.0 - 1e-9;
27
32
  for (let i = 0; i <= n; i++) {
28
33
  const phi = latMin + i * latStep;
29
34
  const x = -Math.tan(phi * DEG) * td;
30
- if (x >= -1.0 && x <= 1.0) {
35
+ if (x >= -EDGE && x <= EDGE) {
31
36
  const h0 = Math.acos(x) / DEG; // hour-angle half-width, degrees
32
37
  asc.push([mapLon(ra - h0 - gastDeg), phi]); // eastern horizon
33
38
  dsc.push([mapLon(ra + h0 - gastDeg), phi]); // western horizon
@@ -0,0 +1,90 @@
1
+ /**
2
+ * astroengine interpretation brief -- a chart as compact, citable LLM input,
3
+ * and an audit that the model cited real facts.
4
+ *
5
+ * This is the "novel and accurate" seam. An LLM writes fluent, original prose
6
+ * (novel); to keep it honest (accurate) it is given only the validated fact
7
+ * atoms, each tagged with a stable id, and asked to cite the id(s) every
8
+ * statement rests on. {@link auditCitations} then checks those citations
9
+ * resolve -- a claim that cites an id not in the brief invented its provenance
10
+ * and is flagged. The chart math was never the model's to hallucinate.
11
+ *
12
+ * Pairs with the MCP app, where the host model is already the interpreter:
13
+ * feed it {@link Brief.prompt} instead of raw positions it would guess at.
14
+ */
15
+ import type { FactKind, InterpretationContext } from "./interpretation.js";
16
+ import type { Reading } from "./interpret.js";
17
+ import type { Zodiac } from "./chart.js";
18
+ import type { Realm, Certainty } from "./provenance.js";
19
+ /** Default instruction header prepended to {@link Brief.prompt}. */
20
+ export declare const BRIEF_INSTRUCTIONS: string;
21
+ /** A one-line framing for the realm and certainty, or `""` when neither needs it. */
22
+ export declare function realmFraming(realm?: Realm, certainty?: Certainty): string;
23
+ export interface BriefOptions {
24
+ /** Keep only the top-N facts by salience. Default: all. */
25
+ limit?: number;
26
+ /** Restrict to certain atom kinds. */
27
+ kinds?: FactKind[];
28
+ /** Drop facts below this salience. */
29
+ minSalience?: number;
30
+ /** Fold a resolved {@link Reading}'s entries in as suggested readings. */
31
+ reading?: Reading;
32
+ /** Prepend {@link BRIEF_INSTRUCTIONS}. Default `true`. */
33
+ header?: boolean;
34
+ }
35
+ /** A salience-ranked fact in a {@link Brief}. */
36
+ export interface BriefFact {
37
+ id: string;
38
+ kind: FactKind;
39
+ text: string;
40
+ salience: number;
41
+ }
42
+ /** A chart rendered as citable LLM input. */
43
+ export interface Brief {
44
+ jdUt: number;
45
+ zodiac: Zodiac;
46
+ /** The facts offered, ranked by salience. */
47
+ facts: BriefFact[];
48
+ /** A prompt-ready, id-tagged rendering of {@link Brief.facts}. */
49
+ prompt: string;
50
+ }
51
+ /**
52
+ * Render an {@link InterpretationContext} as a compact, id-tagged {@link Brief}
53
+ * for an LLM to interpret and cite.
54
+ *
55
+ * @param ctx A projection from {@link interpretationContext}.
56
+ * @param opts Capping, kind filter, an optional {@link Reading} to fold in, and
57
+ * whether to prepend {@link BRIEF_INSTRUCTIONS}.
58
+ * @returns The {@link Brief}: a ranked `facts` list and a ready `prompt`.
59
+ */
60
+ export declare function chartBrief(ctx: InterpretationContext, opts?: BriefOptions): Brief;
61
+ /** A model-produced statement and the fact ids it claims to rest on. */
62
+ export interface Claim {
63
+ text: string;
64
+ cites: string[];
65
+ }
66
+ /** The result of {@link auditCitations}. */
67
+ export interface CitationAudit {
68
+ /** True when every cited id resolves to a fact in the context. */
69
+ ok: boolean;
70
+ /** Total claims examined. */
71
+ claims: number;
72
+ /** Claims that cited at least one fact. */
73
+ cited: number;
74
+ /** Claims with no citation. */
75
+ uncited: number;
76
+ /** Distinct cited ids that resolve to a real atom. */
77
+ valid: string[];
78
+ /** Distinct cited ids with no matching atom -- invented provenance. */
79
+ unknown: string[];
80
+ }
81
+ /**
82
+ * Check that a model's claims cite only facts that exist in the context -- the
83
+ * accuracy half of "novel and accurate". A claim citing an id not in `ctx`
84
+ * fabricated its provenance, so `ok` is false and the id lands in `unknown`.
85
+ *
86
+ * @param claims The model's statements with their cited ids.
87
+ * @param ctx The {@link InterpretationContext} the brief was built from.
88
+ * @returns A {@link CitationAudit}.
89
+ */
90
+ export declare function auditCitations(claims: Claim[], ctx: InterpretationContext): CitationAudit;
@@ -0,0 +1,85 @@
1
+ /** Default instruction header prepended to {@link Brief.prompt}. */
2
+ export const BRIEF_INSTRUCTIONS = "Natal chart facts follow, each with a stable id in [brackets]. Interpret them "
3
+ + "in your own words; after each statement, cite the id(s) it rests on as [id]. "
4
+ + "Do not introduce astrological facts that are not listed here.";
5
+ /** How to frame an interpretation given what the chart is. */
6
+ const REALM_FRAMING = {
7
+ observed: "", reported: "",
8
+ planned: "This is a planned future moment; frame statements as potentials, not settled facts.",
9
+ forecast: "This is a forecast moment; frame statements as tendencies, not certainties.",
10
+ fictional: "This is a fictional subject; interpret the symbolism, not a real person's life.",
11
+ mythic: "This is a mythic subject; read it as a symbol or story, not a biography.",
12
+ counterfactual: "This is a hypothetical variant of a real event; keep it conditional.",
13
+ archetypal: "This is an archetype, not a person; interpret the configuration's meaning itself.",
14
+ conceptual: "This is a concept or organization, not a person; interpret it as such.",
15
+ };
16
+ /** A one-line framing for the realm and certainty, or `""` when neither needs it. */
17
+ export function realmFraming(realm, certainty) {
18
+ const parts = [];
19
+ if (realm && REALM_FRAMING[realm])
20
+ parts.push(REALM_FRAMING[realm]);
21
+ if (certainty && certainty !== "exact") {
22
+ parts.push(`The time is ${certainty}, so the Moon, the angles, and the houses are uncertain`
23
+ + " -- lean on the slower planets and sign-level statements.");
24
+ }
25
+ return parts.join(" ");
26
+ }
27
+ /**
28
+ * Render an {@link InterpretationContext} as a compact, id-tagged {@link Brief}
29
+ * for an LLM to interpret and cite.
30
+ *
31
+ * @param ctx A projection from {@link interpretationContext}.
32
+ * @param opts Capping, kind filter, an optional {@link Reading} to fold in, and
33
+ * whether to prepend {@link BRIEF_INSTRUCTIONS}.
34
+ * @returns The {@link Brief}: a ranked `facts` list and a ready `prompt`.
35
+ */
36
+ export function chartBrief(ctx, opts = {}) {
37
+ let atoms = ctx.atoms;
38
+ if (opts.kinds)
39
+ atoms = atoms.filter((a) => opts.kinds.includes(a.kind));
40
+ if (opts.minSalience !== undefined)
41
+ atoms = atoms.filter((a) => a.salience >= opts.minSalience);
42
+ if (opts.limit !== undefined)
43
+ atoms = atoms.slice(0, opts.limit);
44
+ const facts = atoms.map((a) => ({
45
+ id: a.id, kind: a.kind, text: a.text, salience: a.salience,
46
+ }));
47
+ const lines = facts.map((f) => `[${f.id}] ${f.text}`);
48
+ const framing = realmFraming(ctx.realm, ctx.certainty);
49
+ let prompt = (opts.header === false ? "" : `${BRIEF_INSTRUCTIONS}\n\n`)
50
+ + (framing ? `${framing}\n\n` : "")
51
+ + lines.join("\n");
52
+ if (opts.reading && opts.reading.entries.length) {
53
+ prompt += "\n\nSuggested readings (cite the same fact ids):\n"
54
+ + opts.reading.entries
55
+ .map((e) => `${e.atomIds.map((id) => `[${id}]`).join("")} ${e.text}`)
56
+ .join("\n");
57
+ }
58
+ return { jdUt: ctx.jdUt, zodiac: ctx.zodiac, facts, prompt };
59
+ }
60
+ /**
61
+ * Check that a model's claims cite only facts that exist in the context -- the
62
+ * accuracy half of "novel and accurate". A claim citing an id not in `ctx`
63
+ * fabricated its provenance, so `ok` is false and the id lands in `unknown`.
64
+ *
65
+ * @param claims The model's statements with their cited ids.
66
+ * @param ctx The {@link InterpretationContext} the brief was built from.
67
+ * @returns A {@link CitationAudit}.
68
+ */
69
+ export function auditCitations(claims, ctx) {
70
+ const ids = new Set(ctx.atoms.map((a) => a.id));
71
+ const valid = new Set();
72
+ const unknown = new Set();
73
+ let cited = 0;
74
+ for (const c of claims) {
75
+ if (c.cites.length)
76
+ cited++;
77
+ for (const id of c.cites)
78
+ (ids.has(id) ? valid : unknown).add(id);
79
+ }
80
+ return {
81
+ ok: unknown.size === 0,
82
+ claims: claims.length, cited, uncited: claims.length - cited,
83
+ valid: [...valid], unknown: [...unknown],
84
+ };
85
+ }
@@ -1,5 +1,6 @@
1
1
  /** astroengine chart -- public API: natal charts, aspects, retrogrades. */
2
2
  import { EngineData, AYANAMSA_J2000 } from "./core.js";
3
+ import type { AspectPhase } from "./electional.js";
3
4
  export declare const BODIES: readonly ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node"];
4
5
  export type Body = (typeof BODIES)[number];
5
6
  /** Computable on request (not in the default chart set). */
@@ -130,6 +131,10 @@ export interface Aspect {
130
131
  aspect: string;
131
132
  /** Orb from exact, in degrees. */
132
133
  orb: number;
134
+ /** Applying, separating, or exact -- from the two bodies' longitude speeds. */
135
+ phase: AspectPhase;
136
+ /** Closeness in `[0, 1]`: `1` exact, `0` at the orb limit. */
137
+ strength: number;
133
138
  }
134
139
  /** A full natal chart, as returned by {@link Engine.chart} and
135
140
  * {@link Engine.chartAt}. Longitudes are degrees in the chart's `zodiac`. */
@@ -237,6 +242,44 @@ export declare class Engine {
237
242
  * @returns Sorted catalog star names.
238
243
  */
239
244
  starNames(): string[];
245
+ /**
246
+ * Fixed-star conjunctions in a chart: each body within `orb` of a catalog
247
+ * star, in the chart's own zodiac. Feed the result to
248
+ * {@link interpretationContext} as `stars` to project `star` fact atoms (the
249
+ * Chart itself carries no star catalog).
250
+ *
251
+ * @param chart A chart from {@link Engine.chart} / {@link Engine.chartAt}.
252
+ * @param opts `orb` (default 1°); `stars` to restrict to named stars (then no
253
+ * magnitude filter); else `maxMag` keeps only stars brighter than it
254
+ * (default 2.5) so obscure catalog entries do not flood the result.
255
+ * @returns Conjunctions sorted by increasing orb.
256
+ */
257
+ starConjunctions(chart: Chart, opts?: {
258
+ orb?: number;
259
+ maxMag?: number;
260
+ stars?: string[];
261
+ }): {
262
+ body: string;
263
+ star: string;
264
+ orb: number;
265
+ }[];
266
+ /**
267
+ * The seven Hermetic lots of a chart, each placed by sign and house. Sect is
268
+ * read from the Sun (above the horizon -> a day chart). Feed the result to
269
+ * {@link interpretationContext} as `lots` to project `lot` fact atoms.
270
+ *
271
+ * @param chart A chart from {@link Engine.chart} / {@link Engine.chartAt}; it
272
+ * must carry the seven classical planets.
273
+ * @returns One entry per lot with its longitude, sign, `signDeg`, and house,
274
+ * or an empty array if a required planet is absent.
275
+ */
276
+ lots(chart: Chart): {
277
+ lot: string;
278
+ lon: number;
279
+ sign: string;
280
+ signDeg: number;
281
+ house: number;
282
+ }[];
240
283
  private lonOnly;
241
284
  /**
242
285
  * Apparent geocentric ecliptic longitude of a body, in degrees `[0, 360)`,
package/dist/src/chart.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** astroengine chart -- public API: natal charts, aspects, retrogrades. */
2
2
  import { DEG, mod, jdTT, julianDay, ChebSeries, planetApparent, sunApparent, moonApparentSeries, moonApparentPrecise, plutoApparent, chironApparent, meanNode, trueNodeSeries, trueNodePrecise, equatorial, ayanamsa, AYANAMSA_J2000, meanLilith, topocentricEcl, oscApogeePrecise, oscApogeeSeries, KeplerOrbit, trueObliquity, nutation, plutoHeliocentric, vsopHeliocentric, precessEcliptic, J2000, } from "./core.js";
3
3
  import { starApparent } from "./stars.js";
4
+ import { hermeticLots, HERMETIC_LOTS } from "./lots.js";
4
5
  import * as H from "./houses.js";
5
6
  const TWO_PI = 2 * Math.PI;
6
7
  export const BODIES = [
@@ -200,7 +201,10 @@ export class Engine {
200
201
  : moonApparentSeries(this.data, jde);
201
202
  return [lon, lat, km / KM_PER_AU];
202
203
  }
203
- if (body === "pluto")
204
+ // Pluto: a wide-range Chebyshev pack when one is loaded (same heliocentric
205
+ // pipeline as Chiron, via the generic packed-body path below), else the
206
+ // Meeus ch.37 series (valid 1885-2099, accuracy degrades outside).
207
+ if (body === "pluto" && !this.data.chebPacks?.pluto)
204
208
  return plutoApparent(this.data, jde);
205
209
  if (body === "chiron") {
206
210
  if (!this.chironCheb)
@@ -292,6 +296,68 @@ export class Engine {
292
296
  starNames() {
293
297
  return Object.keys(this.data.fixedStars?.stars ?? {}).sort();
294
298
  }
299
+ /**
300
+ * Fixed-star conjunctions in a chart: each body within `orb` of a catalog
301
+ * star, in the chart's own zodiac. Feed the result to
302
+ * {@link interpretationContext} as `stars` to project `star` fact atoms (the
303
+ * Chart itself carries no star catalog).
304
+ *
305
+ * @param chart A chart from {@link Engine.chart} / {@link Engine.chartAt}.
306
+ * @param opts `orb` (default 1°); `stars` to restrict to named stars (then no
307
+ * magnitude filter); else `maxMag` keeps only stars brighter than it
308
+ * (default 2.5) so obscure catalog entries do not flood the result.
309
+ * @returns Conjunctions sorted by increasing orb.
310
+ */
311
+ starConjunctions(chart, opts = {}) {
312
+ const catalog = this.data.fixedStars?.stars;
313
+ if (!catalog)
314
+ return [];
315
+ const orbLimit = opts.orb ?? 1.0;
316
+ const names = opts.stars ?? Object.keys(catalog);
317
+ const useMag = opts.stars === undefined;
318
+ const maxMag = opts.maxMag ?? 2.5;
319
+ const out = [];
320
+ for (const name of names) {
321
+ const s = catalog[name];
322
+ if (!s || (useMag && s.mag > maxMag))
323
+ continue;
324
+ const starLon = this.fixedStar(name, chart.jdUt, { zodiac: chart.zodiac }).lon;
325
+ for (const [body, p] of Object.entries(chart.bodies)) {
326
+ if (!p)
327
+ continue;
328
+ const sep = Math.abs(mod(p.lon - starLon + 180, 360) - 180);
329
+ if (sep <= orbLimit)
330
+ out.push({ body, star: name, orb: sep });
331
+ }
332
+ }
333
+ out.sort((a, b) => a.orb - b.orb);
334
+ return out;
335
+ }
336
+ /**
337
+ * The seven Hermetic lots of a chart, each placed by sign and house. Sect is
338
+ * read from the Sun (above the horizon -> a day chart). Feed the result to
339
+ * {@link interpretationContext} as `lots` to project `lot` fact atoms.
340
+ *
341
+ * @param chart A chart from {@link Engine.chart} / {@link Engine.chartAt}; it
342
+ * must carry the seven classical planets.
343
+ * @returns One entry per lot with its longitude, sign, `signDeg`, and house,
344
+ * or an empty array if a required planet is absent.
345
+ */
346
+ lots(chart) {
347
+ const b = chart.bodies;
348
+ const need = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"];
349
+ if (need.some((k) => !b[k]))
350
+ return [];
351
+ const day = (b.sun.house >= 7); // Sun above the horizon (houses 7-12)
352
+ const h = hermeticLots(chart.angles.asc, day, b.sun.lon, b.moon.lon, b.mercury.lon, b.venus.lon, b.mars.lon, b.jupiter.lon, b.saturn.lon);
353
+ return HERMETIC_LOTS.map((lot) => {
354
+ const lon = mod(h[lot], 360);
355
+ return {
356
+ lot, lon, sign: SIGNS[Math.floor(lon / 30)], signDeg: mod(lon, 30),
357
+ house: houseIndex(lon, chart.cusps),
358
+ };
359
+ });
360
+ }
295
361
  lonOnly(body, jdUt, mode, topo) {
296
362
  const jde = jdTT(jdUt);
297
363
  let [lon, lat, dist] = this.ecliptic(body, jde);
@@ -346,7 +412,7 @@ export class Engine {
346
412
  let l;
347
413
  let b;
348
414
  let r;
349
- if (body === "pluto") {
415
+ if (body === "pluto" && !this.data.chebPacks?.pluto) {
350
416
  [l, b, r] = plutoHeliocentric(this.data, jde);
351
417
  [l, b] = precessEcliptic(l, b, J2000, jde);
352
418
  }
@@ -628,11 +694,24 @@ export function findAspects(bodies, orbs = DEFAULT_ORBS) {
628
694
  for (let j = i + 1; j < names.length; j++) {
629
695
  const a = names[i];
630
696
  const b = names[j];
631
- const sep = Math.abs(mod(bodies[a].lon - bodies[b].lon + 180, 360) - 180);
697
+ const e = mod(bodies[a].lon - bodies[b].lon + 180, 360) - 180; // signed gap
698
+ const sep = Math.abs(e);
632
699
  for (const [asp, angle] of Object.entries(ASPECTS)) {
633
700
  const orb = Math.abs(sep - angle);
634
701
  if (orb <= orbs[asp]) {
635
- out.push({ a, b, aspect: asp, orb: Math.round(orb * 100) / 100 });
702
+ const orbRounded = Math.round(orb * 100) / 100;
703
+ // Applying/separating from the closing of the signed orb (matches
704
+ // electional.aspectPhase); strength from the same rounded orb so a
705
+ // consumer can reproduce it from .orb and the orb policy.
706
+ const signedOrb = sep - angle;
707
+ const dAbsOrbDt = (signedOrb >= 0 ? 1 : -1) * (e >= 0 ? 1 : -1)
708
+ * (bodies[a].speed - bodies[b].speed);
709
+ const phase = Math.abs(signedOrb) < 1e-9
710
+ ? "exact" : dAbsOrbDt < 0 ? "applying" : "separating";
711
+ out.push({
712
+ a, b, aspect: asp, orb: orbRounded,
713
+ phase, strength: Math.max(0, 1 - orbRounded / orbs[asp]),
714
+ });
636
715
  }
637
716
  }
638
717
  }