caelus 0.15.0 → 0.17.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
@@ -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-two chart tools over stdio
103
+ - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, twenty-seven chart tools over stdio
package/accuracy.json CHANGED
@@ -273,7 +273,7 @@
273
273
  "counts": {
274
274
  "house_systems": 12,
275
275
  "sidereal_ayanamsas": 7,
276
- "mcp_tools": 22,
276
+ "mcp_tools": 27,
277
277
  "default_bodies": 13
278
278
  }
279
279
  }
@@ -6,6 +6,8 @@ export type Body = (typeof BODIES)[number];
6
6
  export declare const EXTRA_BODIES: readonly ["mean_lilith", "true_lilith"];
7
7
  /** Core names keep autocomplete; any string id is accepted (data packs). */
8
8
  export type BodyId = Body | (typeof EXTRA_BODIES)[number] | (string & {});
9
+ /** Points: excluded from aspect search by default. */
10
+ export declare const NOT_ASPECTABLE: Set<string>;
9
11
  export declare const SIGNS: string[];
10
12
  export declare const ASPECTS: Record<string, number>;
11
13
  export declare const DEFAULT_ORBS: Record<string, number>;
@@ -25,6 +27,8 @@ export declare function element(sign: number | string): Element;
25
27
  export declare function modality(sign: number | string): Modality;
26
28
  /** 1-based quadrant (I–IV) of a 1-based house number: houses 1–3 -> 1, etc. */
27
29
  export declare function quadrant(house: number): number;
30
+ export declare const DOMICILE: Record<string, number[]>;
31
+ export declare const EXALTATION: Record<string, number>;
28
32
  /**
29
33
  * Essential dignities a body holds in a sign: any of `"domicile"`,
30
34
  * `"exaltation"`, `"detriment"`, `"fall"` (the last two are the signs opposite
package/dist/src/chart.js CHANGED
@@ -10,7 +10,7 @@ export const BODIES = [
10
10
  /** Computable on request (not in the default chart set). */
11
11
  export const EXTRA_BODIES = ["mean_lilith", "true_lilith"];
12
12
  /** Points: excluded from aspect search by default. */
13
- const NOT_ASPECTABLE = new Set([
13
+ export const NOT_ASPECTABLE = new Set([
14
14
  "mean_node", "true_node", "mean_lilith", "true_lilith",
15
15
  ]);
16
16
  export const SIGNS = [
@@ -67,11 +67,11 @@ export function quadrant(house) {
67
67
  return Math.floor(mod(house - 1, 12) / 3) + 1;
68
68
  }
69
69
  // ----------------------------------------------------------- essential dignities
70
- const DOMICILE = {
70
+ export const DOMICILE = {
71
71
  sun: [4], moon: [3], mercury: [2, 5], venus: [1, 6],
72
72
  mars: [0, 7], jupiter: [8, 11], saturn: [9, 10],
73
73
  };
74
- const EXALTATION = {
74
+ export const EXALTATION = {
75
75
  sun: 0, moon: 1, mercury: 5, venus: 11, mars: 9, jupiter: 3, saturn: 6,
76
76
  };
77
77
  /**
@@ -0,0 +1,50 @@
1
+ export declare const PLANETS: string[];
2
+ export declare const DIGNITY_WEIGHTS: Record<string, number>;
3
+ /** Dorothean triplicity rulers by element (sign%4), as [day, night, participating]. */
4
+ export declare const TRIPLICITY: string[][];
5
+ /** Egyptian terms (Ptolemy I.21): per sign, [ruler, upper-degree] segments. */
6
+ export declare const TERMS_EGYPTIAN: Array<Array<[string, number]>>;
7
+ /** Faces (decans): Chaldean order from Mars at 0 Aries; floor(lon/10) selects. */
8
+ export declare const FACE_CYCLE: string[];
9
+ export type Sect = "day" | "night";
10
+ export declare function termRuler(sign: number, degInSign: number, terms?: [string, number][][]): string;
11
+ export declare function faceRuler(lon: number): string;
12
+ /** The essential-dignity breakdown of a planet at a longitude. */
13
+ export interface DignityScore {
14
+ planet: string;
15
+ rulership: number;
16
+ exaltation: number;
17
+ triplicity: number;
18
+ term: number;
19
+ face: number;
20
+ detriment: number;
21
+ fall: number;
22
+ /** Pure dignity sum (peregrine is not auto-scored). */
23
+ total: number;
24
+ /** True when none of the five dignities is held. */
25
+ peregrine: boolean;
26
+ term_ruler: string;
27
+ face_ruler: string;
28
+ }
29
+ /**
30
+ * The weighted essential dignities of `planet` at ecliptic longitude `lon`
31
+ * (degrees) in a day or night chart. Only the seven classical planets score.
32
+ *
33
+ * @param planet One of {@link PLANETS}.
34
+ * @param lon Ecliptic longitude in degrees.
35
+ * @param sect `"day"` or `"night"`, selecting the triplicity ruler.
36
+ * @param terms A term table; defaults to the Egyptian bounds.
37
+ * @returns A {@link DignityScore}.
38
+ */
39
+ export declare function dignityScore(planet: string, lon: number, sect?: Sect, terms?: [string, number][][]): DignityScore;
40
+ /**
41
+ * The almuten of a degree: the classical planet with the greatest positive
42
+ * essential dignity (rulership + exaltation + triplicity + term + face) at `lon`.
43
+ * Ties broken by the canonical planet order.
44
+ *
45
+ * @returns `{ planet, score }`.
46
+ */
47
+ export declare function almuten(lon: number, sect?: Sect, terms?: [string, number][][]): {
48
+ planet: string;
49
+ score: number;
50
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Weighted essential dignities (Ptolemaic / Lilly).
3
+ *
4
+ * Extends the qualitative {@link dignities} to the five-fold essential-dignity
5
+ * scoring of traditional astrology, with William Lilly's classical weights
6
+ * (*Christian Astrology*, 1647): rulership +5, exaltation +4, triplicity +3,
7
+ * term +2, face +1; detriment -5, fall -4.
8
+ *
9
+ * Tables, each pinned to a named authority and selectable where they vary:
10
+ * triplicity by the Dorothean rulers (day / night / participating); terms by the
11
+ * Egyptian bounds (Ptolemy, *Tetrabiblos* I.21); faces by the Chaldean order
12
+ * from 0 Aries. Peregrine (none of the five dignities present) is reported as a
13
+ * flag, not auto-scored, so `total` is a pure dignity sum. Port of the Python
14
+ * reference `astroengine.dignity_score`, pinned by `dignity-golden`.
15
+ */
16
+ import { mod } from "./core.js";
17
+ import { DOMICILE, EXALTATION } from "./chart.js";
18
+ export const PLANETS = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"];
19
+ export const DIGNITY_WEIGHTS = {
20
+ rulership: 5, exaltation: 4, triplicity: 3, term: 2, face: 1, detriment: -5, fall: -4,
21
+ };
22
+ /** Dorothean triplicity rulers by element (sign%4), as [day, night, participating]. */
23
+ export const TRIPLICITY = [
24
+ ["sun", "jupiter", "saturn"], // fire
25
+ ["venus", "moon", "mars"], // earth
26
+ ["saturn", "mercury", "jupiter"], // air
27
+ ["venus", "mars", "moon"], // water
28
+ ];
29
+ /** Egyptian terms (Ptolemy I.21): per sign, [ruler, upper-degree] segments. */
30
+ export const TERMS_EGYPTIAN = [
31
+ [["jupiter", 6], ["venus", 12], ["mercury", 20], ["mars", 25], ["saturn", 30]], // Aries
32
+ [["venus", 8], ["mercury", 14], ["jupiter", 22], ["saturn", 27], ["mars", 30]], // Taurus
33
+ [["mercury", 6], ["jupiter", 12], ["venus", 17], ["mars", 24], ["saturn", 30]], // Gemini
34
+ [["mars", 7], ["venus", 13], ["mercury", 19], ["jupiter", 26], ["saturn", 30]], // Cancer
35
+ [["jupiter", 6], ["venus", 11], ["saturn", 18], ["mercury", 24], ["mars", 30]], // Leo
36
+ [["mercury", 7], ["venus", 17], ["jupiter", 21], ["mars", 28], ["saturn", 30]], // Virgo
37
+ [["saturn", 6], ["mercury", 14], ["jupiter", 21], ["venus", 28], ["mars", 30]], // Libra
38
+ [["mars", 7], ["venus", 11], ["mercury", 19], ["jupiter", 24], ["saturn", 30]], // Scorpio
39
+ [["jupiter", 12], ["venus", 17], ["mercury", 21], ["saturn", 26], ["mars", 30]], // Sagittarius
40
+ [["mercury", 7], ["jupiter", 14], ["venus", 22], ["saturn", 26], ["mars", 30]], // Capricorn
41
+ [["mercury", 7], ["venus", 13], ["jupiter", 20], ["mars", 25], ["saturn", 30]], // Aquarius
42
+ [["venus", 12], ["jupiter", 16], ["mercury", 19], ["mars", 28], ["saturn", 30]], // Pisces
43
+ ];
44
+ /** Faces (decans): Chaldean order from Mars at 0 Aries; floor(lon/10) selects. */
45
+ export const FACE_CYCLE = ["mars", "sun", "venus", "mercury", "moon", "saturn", "jupiter"];
46
+ export function termRuler(sign, degInSign, terms = TERMS_EGYPTIAN) {
47
+ for (const [ruler, upper] of terms[sign])
48
+ if (degInSign < upper)
49
+ return ruler;
50
+ return terms[sign][terms[sign].length - 1][0];
51
+ }
52
+ export function faceRuler(lon) {
53
+ return FACE_CYCLE[Math.floor(mod(lon, 360) / 10) % 7];
54
+ }
55
+ /**
56
+ * The weighted essential dignities of `planet` at ecliptic longitude `lon`
57
+ * (degrees) in a day or night chart. Only the seven classical planets score.
58
+ *
59
+ * @param planet One of {@link PLANETS}.
60
+ * @param lon Ecliptic longitude in degrees.
61
+ * @param sect `"day"` or `"night"`, selecting the triplicity ruler.
62
+ * @param terms A term table; defaults to the Egyptian bounds.
63
+ * @returns A {@link DignityScore}.
64
+ */
65
+ export function dignityScore(planet, lon, sect = "day", terms = TERMS_EGYPTIAN) {
66
+ const L = mod(lon, 360);
67
+ const sign = Math.floor(L / 30) % 12;
68
+ const deg = L - sign * 30;
69
+ const held = {};
70
+ if (DOMICILE[planet]?.includes(sign))
71
+ held.rulership = DIGNITY_WEIGHTS.rulership;
72
+ if (EXALTATION[planet] === sign)
73
+ held.exaltation = DIGNITY_WEIGHTS.exaltation;
74
+ const trip = TRIPLICITY[sign % 4][sect === "day" ? 0 : 1];
75
+ if (planet === trip)
76
+ held.triplicity = DIGNITY_WEIGHTS.triplicity;
77
+ const tr = termRuler(sign, deg, terms);
78
+ if (planet === tr)
79
+ held.term = DIGNITY_WEIGHTS.term;
80
+ const fr = faceRuler(L);
81
+ if (planet === fr)
82
+ held.face = DIGNITY_WEIGHTS.face;
83
+ if (DOMICILE[planet]?.some((d) => (d + 6) % 12 === sign))
84
+ held.detriment = DIGNITY_WEIGHTS.detriment;
85
+ if (planet in EXALTATION && (EXALTATION[planet] + 6) % 12 === sign)
86
+ held.fall = DIGNITY_WEIGHTS.fall;
87
+ const positive = ["rulership", "exaltation", "triplicity", "term", "face"].some((k) => k in held);
88
+ return {
89
+ planet,
90
+ rulership: held.rulership ?? 0,
91
+ exaltation: held.exaltation ?? 0,
92
+ triplicity: held.triplicity ?? 0,
93
+ term: held.term ?? 0,
94
+ face: held.face ?? 0,
95
+ detriment: held.detriment ?? 0,
96
+ fall: held.fall ?? 0,
97
+ total: Object.values(held).reduce((a, b) => a + b, 0),
98
+ peregrine: !positive,
99
+ term_ruler: tr,
100
+ face_ruler: fr,
101
+ };
102
+ }
103
+ /**
104
+ * The almuten of a degree: the classical planet with the greatest positive
105
+ * essential dignity (rulership + exaltation + triplicity + term + face) at `lon`.
106
+ * Ties broken by the canonical planet order.
107
+ *
108
+ * @returns `{ planet, score }`.
109
+ */
110
+ export function almuten(lon, sect = "day", terms = TERMS_EGYPTIAN) {
111
+ let best = null;
112
+ let bestScore = -1;
113
+ for (const p of PLANETS) {
114
+ const d = dignityScore(p, lon, sect, terms);
115
+ const score = d.rulership + d.exaltation + d.triplicity + d.term + d.face;
116
+ if (score > bestScore) {
117
+ bestScore = score;
118
+ best = p;
119
+ }
120
+ }
121
+ return { planet: best, score: bestScore };
122
+ }
@@ -26,3 +26,7 @@ export * from "./yogini.js";
26
26
  export * from "./yogas.js";
27
27
  export * from "./ashtottari.js";
28
28
  export * from "./rajayoga.js";
29
+ export * from "./patterns.js";
30
+ export * from "./signature.js";
31
+ export * from "./dignity-score.js";
32
+ export * from "./parans.js";
package/dist/src/index.js CHANGED
@@ -26,3 +26,7 @@ export * from "./yogini.js";
26
26
  export * from "./yogas.js";
27
27
  export * from "./ashtottari.js";
28
28
  export * from "./rajayoga.js";
29
+ export * from "./patterns.js";
30
+ export * from "./signature.js";
31
+ export * from "./dignity-score.js";
32
+ export * from "./parans.js";
@@ -0,0 +1,55 @@
1
+ import type { Engine } from "./chart.js";
2
+ export declare const PARAN_ANGLES: readonly ["rise", "mtransit", "set", "itransit"];
3
+ export declare const DEFAULT_PARAN_BODIES: string[];
4
+ /** One co-angular pair: bodies `a` and `b` on the named angles at ~the same time. */
5
+ export interface Paran {
6
+ a: string;
7
+ a_angle: string;
8
+ b: string;
9
+ b_angle: string;
10
+ /** Midpoint instant of the two angle crossings, Julian Day (UT). */
11
+ jd: number;
12
+ /** Gap between the two crossings, minutes. */
13
+ gap_min: number;
14
+ }
15
+ /**
16
+ * Co-angular pairs over the 24 hours from `jd` (UT) at latitude `lat`: every
17
+ * pair of different bodies whose angle crossings fall within `toleranceMin`
18
+ * minutes. Ordered by (a, b, jd), with `a` < `b` by name.
19
+ *
20
+ * @param engine The engine used to evaluate rise/set/transit times.
21
+ * @param jd Julian Day in UT (the 24-hour window starts here).
22
+ * @param lat Geographic latitude in degrees, north positive.
23
+ * @param bodies Bodies to consider; defaults to the seven classical planets.
24
+ * @param toleranceMin The paran window in minutes (default 30).
25
+ * @returns The co-angular pairs as {@link Paran} objects.
26
+ */
27
+ export declare function parans(engine: Engine, jd: number, lat: number, bodies?: string[], toleranceMin?: number): Paran[];
28
+ /**
29
+ * The four angle crossings of a fixed `star` over the day from `jd` at latitude
30
+ * `lat`: the meridian transits always occur; `rise`/`set` are absent when the
31
+ * star is circumpolar or never rises.
32
+ */
33
+ export declare function starAngleTimes(engine: Engine, star: string, jd: number, lat: number): Record<string, number>;
34
+ /** One star-to-body paran: a fixed star and a body on angles at ~the same time. */
35
+ export interface StarParan {
36
+ star: string;
37
+ star_angle: string;
38
+ body: string;
39
+ body_angle: string;
40
+ jd: number;
41
+ gap_min: number;
42
+ }
43
+ /**
44
+ * Star-to-body parans over the day from `jd` (UT) at latitude `lat`: a fixed
45
+ * star and a moving body simultaneously on angles within `toleranceMin`
46
+ * minutes — Brady's fixed-star parans. Ordered by (star, body, jd).
47
+ *
48
+ * @param engine An engine whose data pack includes the fixed-star catalog.
49
+ * @param jd Julian Day in UT (the 24-hour window starts here).
50
+ * @param lat Geographic latitude in degrees, north positive.
51
+ * @param stars Catalog star names to test (see {@link Engine.starNames}).
52
+ * @param bodies Bodies to consider; defaults to the seven classical planets.
53
+ * @param toleranceMin The paran window in minutes (default 30).
54
+ */
55
+ export declare function starParans(engine: Engine, jd: number, lat: number, stars: string[], bodies?: string[], toleranceMin?: number): StarParan[];
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Paranatellonta (parans): co-angular bodies.
3
+ *
4
+ * Two bodies are in paran on a given day at a given latitude when both are
5
+ * simultaneously on one of the four angles: rising, culminating (upper
6
+ * meridian), setting, or anti-culminating (lower meridian) — the relationship
7
+ * behind the fixed-star parans of Brady's tradition, computed here for the
8
+ * moving bodies.
9
+ *
10
+ * Pure positional astronomy over the validated rise/set/transit times, with a
11
+ * stated tolerance (not a hidden convention). Longitude-independent, so latitude
12
+ * alone is needed. Port of the Python reference `astroengine.parans`, pinned by
13
+ * `parans-golden`.
14
+ */
15
+ import { riseSet } from "./events.js";
16
+ import { gast } from "./houses.js";
17
+ import { DEG, mod } from "./core.js";
18
+ export const PARAN_ANGLES = ["rise", "mtransit", "set", "itransit"];
19
+ export const DEFAULT_PARAN_BODIES = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"];
20
+ const TWO_PI = 2 * Math.PI;
21
+ // Local sidereal time advances one turn per sidereal day.
22
+ const SID_RATE = 360.98564736629 * DEG;
23
+ // Standard rise/set: the geometric horizon lifted by ~34' of refraction.
24
+ const RISE_ALT = -0.5667 * DEG;
25
+ /**
26
+ * Co-angular pairs over the 24 hours from `jd` (UT) at latitude `lat`: every
27
+ * pair of different bodies whose angle crossings fall within `toleranceMin`
28
+ * minutes. Ordered by (a, b, jd), with `a` < `b` by name.
29
+ *
30
+ * @param engine The engine used to evaluate rise/set/transit times.
31
+ * @param jd Julian Day in UT (the 24-hour window starts here).
32
+ * @param lat Geographic latitude in degrees, north positive.
33
+ * @param bodies Bodies to consider; defaults to the seven classical planets.
34
+ * @param toleranceMin The paran window in minutes (default 30).
35
+ * @returns The co-angular pairs as {@link Paran} objects.
36
+ */
37
+ export function parans(engine, jd, lat, bodies = DEFAULT_PARAN_BODIES, toleranceMin = 30) {
38
+ const events = [];
39
+ for (const b of bodies) {
40
+ for (const kind of PARAN_ANGLES) {
41
+ const t = riseSet(engine, b, jd, lat, 0, kind);
42
+ if (t !== null && t < jd + 1)
43
+ events.push([b, kind, t]);
44
+ }
45
+ }
46
+ const out = [];
47
+ for (let i = 0; i < events.length; i++) {
48
+ for (let j = i + 1; j < events.length; j++) {
49
+ const [ab, aa, ta] = events[i];
50
+ const [bb, ba, tb] = events[j];
51
+ if (ab === bb)
52
+ continue;
53
+ const gap = Math.abs(ta - tb) * 1440;
54
+ if (gap > toleranceMin)
55
+ continue;
56
+ const [pa, paa, pb, pba] = ab <= bb ? [ab, aa, bb, ba] : [bb, ba, ab, aa];
57
+ out.push({
58
+ a: pa, a_angle: paa, b: pb, b_angle: pba,
59
+ jd: Math.round(((ta + tb) / 2) * 1e6) / 1e6,
60
+ gap_min: Math.round(gap * 1e4) / 1e4,
61
+ });
62
+ }
63
+ }
64
+ out.sort((x, y) => (x.a < y.a ? -1 : x.a > y.a ? 1 : x.b < y.b ? -1 : x.b > y.b ? 1 : x.jd - y.jd));
65
+ return out;
66
+ }
67
+ /** The UT instant in `[jd, jd+1)` when apparent sidereal time = `target` (rad). */
68
+ function timeAtLst(engine, jd, target) {
69
+ const dlst = mod(target - gast(engine.data, jd), TWO_PI);
70
+ let t = jd + dlst / SID_RATE;
71
+ for (let i = 0; i < 2; i++) {
72
+ const err = mod(gast(engine.data, t) - target + Math.PI, TWO_PI) - Math.PI;
73
+ t -= err / SID_RATE;
74
+ }
75
+ return t;
76
+ }
77
+ /**
78
+ * The four angle crossings of a fixed `star` over the day from `jd` at latitude
79
+ * `lat`: the meridian transits always occur; `rise`/`set` are absent when the
80
+ * star is circumpolar or never rises.
81
+ */
82
+ export function starAngleTimes(engine, star, jd, lat) {
83
+ const fs = engine.fixedStar(star, jd);
84
+ const alpha = mod(fs.ra * DEG, TWO_PI);
85
+ const delta = fs.dec * DEG;
86
+ const phi = lat * DEG;
87
+ const out = {
88
+ mtransit: timeAtLst(engine, jd, alpha),
89
+ itransit: timeAtLst(engine, jd, mod(alpha + Math.PI, TWO_PI)),
90
+ };
91
+ const denom = Math.cos(phi) * Math.cos(delta);
92
+ if (denom !== 0) {
93
+ const cosH0 = (Math.sin(RISE_ALT) - Math.sin(phi) * Math.sin(delta)) / denom;
94
+ if (cosH0 >= -1 && cosH0 <= 1) {
95
+ const h0 = Math.acos(cosH0);
96
+ out.rise = timeAtLst(engine, jd, mod(alpha - h0, TWO_PI));
97
+ out.set = timeAtLst(engine, jd, mod(alpha + h0, TWO_PI));
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ /**
103
+ * Star-to-body parans over the day from `jd` (UT) at latitude `lat`: a fixed
104
+ * star and a moving body simultaneously on angles within `toleranceMin`
105
+ * minutes — Brady's fixed-star parans. Ordered by (star, body, jd).
106
+ *
107
+ * @param engine An engine whose data pack includes the fixed-star catalog.
108
+ * @param jd Julian Day in UT (the 24-hour window starts here).
109
+ * @param lat Geographic latitude in degrees, north positive.
110
+ * @param stars Catalog star names to test (see {@link Engine.starNames}).
111
+ * @param bodies Bodies to consider; defaults to the seven classical planets.
112
+ * @param toleranceMin The paran window in minutes (default 30).
113
+ */
114
+ export function starParans(engine, jd, lat, stars, bodies = DEFAULT_PARAN_BODIES, toleranceMin = 30) {
115
+ const bodyEvents = [];
116
+ for (const b of bodies) {
117
+ for (const kind of PARAN_ANGLES) {
118
+ const t = riseSet(engine, b, jd, lat, 0, kind);
119
+ if (t !== null && t < jd + 1)
120
+ bodyEvents.push([b, kind, t]);
121
+ }
122
+ }
123
+ const out = [];
124
+ for (const s of stars) {
125
+ const at = starAngleTimes(engine, s, jd, lat);
126
+ for (const [sa, ts] of Object.entries(at)) {
127
+ if (!(ts >= jd && ts < jd + 1))
128
+ continue;
129
+ for (const [b, ba, tb] of bodyEvents) {
130
+ const gap = Math.abs(ts - tb) * 1440;
131
+ if (gap <= toleranceMin) {
132
+ out.push({
133
+ star: s, star_angle: sa, body: b, body_angle: ba,
134
+ jd: Math.round(((ts + tb) / 2) * 1e6) / 1e6,
135
+ gap_min: Math.round(gap * 1e4) / 1e4,
136
+ });
137
+ }
138
+ }
139
+ }
140
+ }
141
+ out.sort((x, y) => (x.star < y.star ? -1 : x.star > y.star ? 1 : x.body < y.body ? -1 : x.body > y.body ? 1 : x.jd - y.jd));
142
+ return out;
143
+ }
@@ -0,0 +1,52 @@
1
+ import type { Chart } from "./chart.js";
2
+ /** Pattern aspects, including the quincunx the default Ptolemaic set omits. */
3
+ export declare const PATTERN_ANGLES: Record<string, number>;
4
+ /** Default orbs: {@link DEFAULT_ORBS} for the five Ptolemaic aspects, plus a
5
+ * tight quincunx. Override via {@link PatternOptions.orbs}. */
6
+ export declare const PATTERN_ORBS: Record<string, number>;
7
+ /** One configuration found in a chart. */
8
+ export interface ChartPattern {
9
+ /** Configuration kind, e.g. `"t_square"` or `"grand_trine"`. */
10
+ kind: string;
11
+ /** Participating body ids, sorted. */
12
+ bodies: string[];
13
+ /** Focal body for a T-square or yod (the squared / quincunx apex). */
14
+ apex?: string;
15
+ /** Sign for a `stellium_sign`. */
16
+ sign?: string;
17
+ /** House for a `stellium_house`. */
18
+ house?: number;
19
+ /** Worst defining-aspect orb in degrees; `0` for stelliums. */
20
+ orb: number;
21
+ }
22
+ /** A body's longitude (and house, for stelliums) for {@link detectPatternsIn}. */
23
+ export interface PatternBody {
24
+ lon: number;
25
+ house?: number | null;
26
+ }
27
+ export interface PatternOptions {
28
+ /** Per-aspect orb overrides (degrees), keyed by aspect name. */
29
+ orbs?: Record<string, number>;
30
+ /** Body ids to consider; defaults to the aspectable bodies present. */
31
+ bodies?: string[];
32
+ }
33
+ /**
34
+ * The configurations present among a body map. Lower-level form of
35
+ * {@link detectPatterns}: `bodies` maps a body id to `{ lon, house? }`.
36
+ */
37
+ export declare function detectPatternsIn(bodies: Record<string, PatternBody>, opts?: PatternOptions): ChartPattern[];
38
+ /**
39
+ * The aspect configurations present in a {@link Chart}: T-squares, grand trines,
40
+ * grand crosses, yods, kites, mystic rectangles, and stelliums by sign and by
41
+ * house. Bodies outside their fitted range (absent from the chart) are skipped.
42
+ *
43
+ * @param chart A {@link Chart} from {@link Engine.chart} / {@link Engine.chartAt}.
44
+ * @param opts Orb overrides and an optional explicit body set.
45
+ * @returns The configurations, most-complex first, each a {@link ChartPattern}.
46
+ * @example
47
+ * ```ts
48
+ * const chart = engine.chart(1990, 6, 10, 14, 30, 0, 27.95, -82.46, "placidus");
49
+ * detectPatterns(chart); // [{ kind: "mystic_rectangle", bodies: [...], orb: 2.54 }, ...]
50
+ * ```
51
+ */
52
+ export declare function detectPatterns(chart: Chart, opts?: PatternOptions): ChartPattern[];
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Classical aspect configurations as first-class objects.
3
+ *
4
+ * Pure geometry over a chart's body longitudes (and houses, for stelliums); no
5
+ * interpretation. Each configuration is judged from pairwise angular separations
6
+ * against an explicit, overridable orb policy: the engine's {@link DEFAULT_ORBS}
7
+ * for the Ptolemaic aspects, plus a quincunx (150°) for yods. The default body
8
+ * set is the aspectable bodies (planets and Chiron; nodes and Lilith excluded).
9
+ *
10
+ * Reported patterns are maximal: a grand cross suppresses the two T-squares it
11
+ * contains and a kite suppresses its grand trine. Port of the Python reference
12
+ * `astroengine.patterns`, pinned by `patterns-golden`.
13
+ */
14
+ import { mod } from "./core.js";
15
+ import { SIGNS, NOT_ASPECTABLE } from "./chart.js";
16
+ /** Pattern aspects, including the quincunx the default Ptolemaic set omits. */
17
+ export const PATTERN_ANGLES = {
18
+ conjunction: 0, sextile: 60, square: 90, trine: 120, quincunx: 150, opposition: 180,
19
+ };
20
+ /** Default orbs: {@link DEFAULT_ORBS} for the five Ptolemaic aspects, plus a
21
+ * tight quincunx. Override via {@link PatternOptions.orbs}. */
22
+ export const PATTERN_ORBS = {
23
+ conjunction: 8, sextile: 4, square: 7, trine: 7, quincunx: 3, opposition: 8,
24
+ };
25
+ const KIND_ORDER = [
26
+ "grand_cross", "mystic_rectangle", "kite", "t_square", "grand_trine",
27
+ "yod", "stellium_sign", "stellium_house",
28
+ ];
29
+ const separation = (la, lb) => Math.abs(mod(la - lb + 180, 360) - 180);
30
+ /** The single aspect a pair forms (orbs do not overlap), as `[name, orb]`, or null. */
31
+ function relation(la, lb, orbs) {
32
+ const sep = separation(la, lb);
33
+ for (const name of Object.keys(PATTERN_ANGLES)) {
34
+ const orb = Math.abs(sep - PATTERN_ANGLES[name]);
35
+ if (orb <= orbs[name])
36
+ return [name, orb];
37
+ }
38
+ return null;
39
+ }
40
+ function cmpBodies(a, b) {
41
+ const n = Math.min(a.length, b.length);
42
+ for (let i = 0; i < n; i++)
43
+ if (a[i] !== b[i])
44
+ return a[i] < b[i] ? -1 : 1;
45
+ return a.length - b.length;
46
+ }
47
+ /**
48
+ * The configurations present among a body map. Lower-level form of
49
+ * {@link detectPatterns}: `bodies` maps a body id to `{ lon, house? }`.
50
+ */
51
+ export function detectPatternsIn(bodies, opts = {}) {
52
+ const orbs = opts.orbs ?? PATTERN_ORBS;
53
+ const names = (opts.bodies ?? Object.keys(bodies).filter((b) => !NOT_ASPECTABLE.has(b)))
54
+ .filter((b) => b in bodies);
55
+ const lon = {};
56
+ for (const b of names)
57
+ lon[b] = mod(bodies[b].lon, 360);
58
+ const key = (a, b) => (a < b ? `${a}|${b}` : `${b}|${a}`);
59
+ const rel = new Map();
60
+ for (let i = 0; i < names.length; i++) {
61
+ for (let j = i + 1; j < names.length; j++) {
62
+ const r = relation(lon[names[i]], lon[names[j]], orbs);
63
+ if (r)
64
+ rel.set(key(names[i], names[j]), r);
65
+ }
66
+ }
67
+ const asp = (a, b) => rel.get(key(a, b)) ?? null;
68
+ const isAspect = (a, b, kind) => {
69
+ const r = asp(a, b);
70
+ return r !== null && r[0] === kind;
71
+ };
72
+ const out = [];
73
+ // Grand trines (3-body) and grand crosses / mystic rectangles (4-body).
74
+ const grandTrines = [];
75
+ for (let i = 0; i < names.length; i++) {
76
+ for (let j = i + 1; j < names.length; j++) {
77
+ for (let k = j + 1; k < names.length; k++) {
78
+ const [a, b, c] = [names[i], names[j], names[k]];
79
+ if (isAspect(a, b, "trine") && isAspect(b, c, "trine") && isAspect(a, c, "trine")) {
80
+ const orb = Math.max(asp(a, b)[1], asp(b, c)[1], asp(a, c)[1]);
81
+ grandTrines.push({ kind: "grand_trine", bodies: [a, b, c].sort(), orb });
82
+ }
83
+ }
84
+ }
85
+ }
86
+ const grandCrosses = [];
87
+ const mysticRectangles = [];
88
+ for (let i = 0; i < names.length; i++) {
89
+ for (let j = i + 1; j < names.length; j++) {
90
+ for (let k = j + 1; k < names.length; k++) {
91
+ for (let l = k + 1; l < names.length; l++) {
92
+ const quad = [names[i], names[j], names[k], names[l]];
93
+ const pairs = [
94
+ [quad[0], quad[1]], [quad[0], quad[2]], [quad[0], quad[3]],
95
+ [quad[1], quad[2]], [quad[1], quad[3]], [quad[2], quad[3]],
96
+ ];
97
+ const kinds = pairs.map(([a, b]) => asp(a, b));
98
+ if (kinds.some((r) => r === null))
99
+ continue;
100
+ const counts = {};
101
+ let worst = 0;
102
+ for (const r of kinds) {
103
+ counts[r[0]] = (counts[r[0]] ?? 0) + 1;
104
+ if (r[1] > worst)
105
+ worst = r[1];
106
+ }
107
+ if (counts.opposition === 2 && counts.square === 4) {
108
+ grandCrosses.push({ kind: "grand_cross", bodies: [...quad].sort(), orb: worst });
109
+ }
110
+ else if (counts.opposition === 2 && counts.trine === 2 && counts.sextile === 2) {
111
+ mysticRectangles.push({ kind: "mystic_rectangle", bodies: [...quad].sort(), orb: worst });
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ // Kite: a grand trine plus a fourth body opposite one member.
118
+ const kites = [];
119
+ for (const gt of grandTrines) {
120
+ const tri = gt.bodies;
121
+ for (const d of names) {
122
+ if (tri.includes(d))
123
+ continue;
124
+ for (const apex of tri) {
125
+ const others = tri.filter((x) => x !== apex);
126
+ if (isAspect(d, apex, "opposition")
127
+ && isAspect(d, others[0], "sextile")
128
+ && isAspect(d, others[1], "sextile")) {
129
+ const orb = Math.max(gt.orb, asp(d, apex)[1], asp(d, others[0])[1], asp(d, others[1])[1]);
130
+ kites.push({ kind: "kite", bodies: [...tri, d].sort(), apex, orb });
131
+ }
132
+ }
133
+ }
134
+ }
135
+ // T-square: an opposition whose two ends both square a common apex.
136
+ const tSquares = [];
137
+ for (let i = 0; i < names.length; i++) {
138
+ for (let j = i + 1; j < names.length; j++) {
139
+ const [a, b] = [names[i], names[j]];
140
+ if (!isAspect(a, b, "opposition"))
141
+ continue;
142
+ for (const apex of names) {
143
+ if (apex === a || apex === b)
144
+ continue;
145
+ if (isAspect(apex, a, "square") && isAspect(apex, b, "square")) {
146
+ const orb = Math.max(asp(a, b)[1], asp(apex, a)[1], asp(apex, b)[1]);
147
+ tSquares.push({ kind: "t_square", bodies: [a, b, apex].sort(), apex, orb });
148
+ }
149
+ }
150
+ }
151
+ }
152
+ // Yod: a sextile whose two ends both quincunx a common apex.
153
+ const yods = [];
154
+ for (let i = 0; i < names.length; i++) {
155
+ for (let j = i + 1; j < names.length; j++) {
156
+ const [a, b] = [names[i], names[j]];
157
+ if (!isAspect(a, b, "sextile"))
158
+ continue;
159
+ for (const apex of names) {
160
+ if (apex === a || apex === b)
161
+ continue;
162
+ if (isAspect(apex, a, "quincunx") && isAspect(apex, b, "quincunx")) {
163
+ const orb = Math.max(asp(a, b)[1], asp(apex, a)[1], asp(apex, b)[1]);
164
+ yods.push({ kind: "yod", bodies: [a, b, apex].sort(), apex, orb });
165
+ }
166
+ }
167
+ }
168
+ }
169
+ // Suppress sub-patterns contained in a larger reported one.
170
+ const subset = (small, big) => small.every((x) => big.includes(x));
171
+ const keptTSquares = tSquares.filter((t) => !grandCrosses.some((g) => subset(t.bodies, g.bodies)));
172
+ const keptTrines = grandTrines.filter((g) => !kites.some((k) => subset(g.bodies, k.bodies)));
173
+ out.push(...grandCrosses, ...mysticRectangles, ...kites, ...keptTSquares, ...keptTrines, ...yods);
174
+ // Stelliums by sign and by house: three or more bodies sharing one.
175
+ const bySign = {};
176
+ for (const b of names) {
177
+ const s = Math.floor(lon[b] / 30) % 12;
178
+ (bySign[s] ??= []).push(b);
179
+ }
180
+ for (const s of Object.keys(bySign)) {
181
+ const members = bySign[Number(s)];
182
+ if (members.length >= 3) {
183
+ out.push({ kind: "stellium_sign", bodies: [...members].sort(), sign: SIGNS[Number(s)], orb: 0 });
184
+ }
185
+ }
186
+ const byHouse = {};
187
+ for (const b of names) {
188
+ const h = bodies[b].house;
189
+ if (h != null)
190
+ (byHouse[h] ??= []).push(b);
191
+ }
192
+ for (const h of Object.keys(byHouse)) {
193
+ const members = byHouse[Number(h)];
194
+ if (members.length >= 3) {
195
+ out.push({ kind: "stellium_house", bodies: [...members].sort(), house: Number(h), orb: 0 });
196
+ }
197
+ }
198
+ out.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || cmpBodies(a.bodies, b.bodies));
199
+ for (const p of out)
200
+ p.orb = Math.round(p.orb * 1e4) / 1e4;
201
+ return out;
202
+ }
203
+ /**
204
+ * The aspect configurations present in a {@link Chart}: T-squares, grand trines,
205
+ * grand crosses, yods, kites, mystic rectangles, and stelliums by sign and by
206
+ * house. Bodies outside their fitted range (absent from the chart) are skipped.
207
+ *
208
+ * @param chart A {@link Chart} from {@link Engine.chart} / {@link Engine.chartAt}.
209
+ * @param opts Orb overrides and an optional explicit body set.
210
+ * @returns The configurations, most-complex first, each a {@link ChartPattern}.
211
+ * @example
212
+ * ```ts
213
+ * const chart = engine.chart(1990, 6, 10, 14, 30, 0, 27.95, -82.46, "placidus");
214
+ * detectPatterns(chart); // [{ kind: "mystic_rectangle", bodies: [...], orb: 2.54 }, ...]
215
+ * ```
216
+ */
217
+ export function detectPatterns(chart, opts = {}) {
218
+ const bodies = {};
219
+ for (const [name, p] of Object.entries(chart.bodies)) {
220
+ if (p)
221
+ bodies[name] = { lon: p.lon, house: p.house };
222
+ }
223
+ return detectPatternsIn(bodies, opts);
224
+ }
@@ -0,0 +1,58 @@
1
+ import type { Chart } from "./chart.js";
2
+ export declare const ELEMENTS: readonly ["fire", "earth", "air", "water"];
3
+ export declare const MODALITIES: readonly ["cardinal", "fixed", "mutable"];
4
+ export interface ChartSignature {
5
+ /** Bodies per element (fire/earth/air/water). */
6
+ elements: Record<string, number>;
7
+ /** Bodies per modality (cardinal/fixed/mutable). */
8
+ modalities: Record<string, number>;
9
+ /** Bodies per angularity (angular/succedent/cadent), house-based. */
10
+ angularity: Record<string, number>;
11
+ /** Bodies per quadrant ("1"-"4"), house-based. */
12
+ quadrants: Record<string, number>;
13
+ /** Bodies above/below the horizon and east/west, house-based. */
14
+ hemispheres: Record<string, number>;
15
+ /** Argmax of the distributions; `sign` is the most-occupied sign (>=2) or null. */
16
+ dominant: {
17
+ element: string;
18
+ modality: string;
19
+ sign: string | null;
20
+ };
21
+ /** Classical ruler of the Ascendant's sign, or null when no Ascendant given. */
22
+ ruler: string | null;
23
+ /** The bodies counted, sorted. */
24
+ bodies: string[];
25
+ }
26
+ /** A body's longitude (and house) for {@link chartSignatureOf}. */
27
+ export interface SignatureBody {
28
+ lon: number;
29
+ house?: number | null;
30
+ }
31
+ export interface SignatureOptions {
32
+ /** Ascendant sign index 0-11; yields the classical chart ruler. */
33
+ ascSign?: number | null;
34
+ /** Body ids to count; defaults to the aspectable bodies present. */
35
+ bodies?: string[];
36
+ }
37
+ /**
38
+ * Structural counts for a body map. Lower-level form of {@link chartSignature}:
39
+ * `bodies` maps a body id to `{ lon, house? }`.
40
+ */
41
+ export declare function chartSignatureOf(bodies: Record<string, SignatureBody>, opts?: SignatureOptions): ChartSignature;
42
+ /**
43
+ * The structural signature of a {@link Chart}: element / modality / angularity /
44
+ * quadrant / hemisphere distributions over its bodies, the dominant element,
45
+ * modality, and most-occupied sign, and the classical chart ruler. Counts only,
46
+ * no interpretation. Bodies absent from the chart (outside their fitted range)
47
+ * are skipped.
48
+ *
49
+ * @param chart A {@link Chart} from {@link Engine.chart} / {@link Engine.chartAt}.
50
+ * @param opts An explicit body set, or an Ascendant-sign override.
51
+ * @returns A {@link ChartSignature}.
52
+ * @example
53
+ * ```ts
54
+ * const chart = engine.chart(1990, 6, 10, 14, 30, 0, 27.95, -82.46, "placidus");
55
+ * chartSignature(chart).dominant; // { element: "earth", modality: "cardinal", sign: "Capricorn" }
56
+ * ```
57
+ */
58
+ export declare function chartSignature(chart: Chart, opts?: SignatureOptions): ChartSignature;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * A chart's structural signature, as plain counts.
3
+ *
4
+ * Element / modality distributions (from each body's sign), angularity /
5
+ * quadrant / hemisphere distributions (from its house), the dominant element,
6
+ * modality, and most-occupied sign (argmax of the counts), and the classical
7
+ * chart ruler (the domicile ruler of the Ascendant's sign). No interpretation,
8
+ * no "flavour" labels.
9
+ *
10
+ * The only convention is which bodies are counted and that each counts once: the
11
+ * default is the aspectable bodies (planets and Chiron; nodes and Lilith
12
+ * excluded), each weight 1. Weighted "dominance" schemes are deliberately not the
13
+ * default. Port of the Python reference `astroengine.signature`, pinned by
14
+ * `signature-golden`.
15
+ */
16
+ import { mod } from "./core.js";
17
+ import { SIGNS, NOT_ASPECTABLE } from "./chart.js";
18
+ export const ELEMENTS = ["fire", "earth", "air", "water"];
19
+ export const MODALITIES = ["cardinal", "fixed", "mutable"];
20
+ const ANGULARITY = ["angular", "succedent", "cadent"];
21
+ /** Classical (domicile) ruler by sign index 0-11, matching the engine's dignities. */
22
+ const RULERS = ["mars", "venus", "mercury", "moon", "sun", "mercury",
23
+ "venus", "mars", "jupiter", "saturn", "saturn", "jupiter"];
24
+ function argmax(counts, order) {
25
+ let best = order[0];
26
+ let bestV = -1;
27
+ for (const k of order)
28
+ if (counts[k] > bestV) {
29
+ bestV = counts[k];
30
+ best = k;
31
+ }
32
+ return best;
33
+ }
34
+ /**
35
+ * Structural counts for a body map. Lower-level form of {@link chartSignature}:
36
+ * `bodies` maps a body id to `{ lon, house? }`.
37
+ */
38
+ export function chartSignatureOf(bodies, opts = {}) {
39
+ const names = (opts.bodies ?? Object.keys(bodies).filter((b) => !NOT_ASPECTABLE.has(b)))
40
+ .filter((b) => b in bodies);
41
+ const elements = { fire: 0, earth: 0, air: 0, water: 0 };
42
+ const modalities = { cardinal: 0, fixed: 0, mutable: 0 };
43
+ const angularity = { angular: 0, succedent: 0, cadent: 0 };
44
+ const quadrants = { 1: 0, 2: 0, 3: 0, 4: 0 };
45
+ const hemispheres = { above: 0, below: 0, eastern: 0, western: 0 };
46
+ const signCounts = {};
47
+ for (const b of names) {
48
+ const sign = Math.floor(mod(bodies[b].lon, 360) / 30) % 12;
49
+ elements[ELEMENTS[sign % 4]]++;
50
+ modalities[MODALITIES[sign % 3]]++;
51
+ signCounts[sign] = (signCounts[sign] ?? 0) + 1;
52
+ const h = bodies[b].house;
53
+ if (h != null) {
54
+ angularity[ANGULARITY[(h - 1) % 3]]++;
55
+ quadrants[String(Math.floor((h - 1) / 3) + 1)]++;
56
+ hemispheres[h >= 7 ? "above" : "below"]++;
57
+ hemispheres[[10, 11, 12, 1, 2, 3].includes(h) ? "eastern" : "western"]++;
58
+ }
59
+ }
60
+ let domSign = null;
61
+ let best = 1;
62
+ for (const s of Object.keys(signCounts).map(Number).sort((a, b) => a - b)) {
63
+ if (signCounts[s] > best) {
64
+ best = signCounts[s];
65
+ domSign = s;
66
+ }
67
+ }
68
+ return {
69
+ elements,
70
+ modalities,
71
+ angularity,
72
+ quadrants,
73
+ hemispheres,
74
+ dominant: {
75
+ element: argmax(elements, ELEMENTS),
76
+ modality: argmax(modalities, MODALITIES),
77
+ sign: domSign !== null ? SIGNS[domSign] : null,
78
+ },
79
+ ruler: opts.ascSign != null ? RULERS[opts.ascSign] : null,
80
+ bodies: [...names].sort(),
81
+ };
82
+ }
83
+ /**
84
+ * The structural signature of a {@link Chart}: element / modality / angularity /
85
+ * quadrant / hemisphere distributions over its bodies, the dominant element,
86
+ * modality, and most-occupied sign, and the classical chart ruler. Counts only,
87
+ * no interpretation. Bodies absent from the chart (outside their fitted range)
88
+ * are skipped.
89
+ *
90
+ * @param chart A {@link Chart} from {@link Engine.chart} / {@link Engine.chartAt}.
91
+ * @param opts An explicit body set, or an Ascendant-sign override.
92
+ * @returns A {@link ChartSignature}.
93
+ * @example
94
+ * ```ts
95
+ * const chart = engine.chart(1990, 6, 10, 14, 30, 0, 27.95, -82.46, "placidus");
96
+ * chartSignature(chart).dominant; // { element: "earth", modality: "cardinal", sign: "Capricorn" }
97
+ * ```
98
+ */
99
+ export function chartSignature(chart, opts = {}) {
100
+ const bodies = {};
101
+ for (const [name, p] of Object.entries(chart.bodies))
102
+ if (p)
103
+ bodies[name] = { lon: p.lon, house: p.house };
104
+ const ascSign = opts.ascSign ?? Math.floor(mod(chart.angles.asc, 360) / 30) % 12;
105
+ return chartSignatureOf(bodies, { ...opts, ascSign });
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caelus",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Astrological ephemeris engine. MIT, no AGPL, no ephemeris files. Checked against Swiss Ephemeris.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",