caelus 0.2.0 → 0.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 CHANGED
@@ -9,8 +9,8 @@ ephemeris files. 1:1 port of the Python reference, checked by golden fixtures.
9
9
  every planet ≤ 1″ (Sun–Saturn), Moon ≤ 2.5″, Chiron ≤ 1″, nodes ≤ 1″
10
10
  (vs full DE431 files, 1850–2149), angles and Placidus cusps ≤ 3.2″ — all
11
11
  invisible at the arcminute display precision chart software uses.
12
- 2. TypeScript port verified against Python golden fixtures: **1,438 checks,
13
- 0 failures, worst deviation 0.82 nano-arcseconds.** The two implementations
12
+ 2. TypeScript port verified against Python golden fixtures: **3,087 checks,
13
+ 0 failures, worst deviation 1.64 nano-arcseconds.** The two implementations
14
14
  are numerically identical.
15
15
 
16
16
  Regenerate fixtures any time from the Python side; any future TS change must
@@ -34,9 +34,13 @@ Moon to ~10″ via the analytic series. The 729 KB tier (1920–2080 JPL-fit Moo
34
34
 
35
35
  ## Usage
36
36
 
37
+ ```bash
38
+ npm install caelus
39
+ ```
40
+
37
41
  ```ts
38
42
  import { Engine, fmtLon } from "caelus";
39
- import { loadNodeData } from "caelus"; // Node only
43
+ import { loadNodeData } from "caelus/node"; // Node only
40
44
 
41
45
  // Node: filesystem loader
42
46
  const engine = new Engine(loadNodeData("./data", "embedded", "full"));
@@ -82,3 +86,10 @@ test/golden.test.ts conformance suite vs Python fixtures
82
86
  sums in reverse); keep orders identical to the Python reference.
83
87
  - All data is injected via `EngineData` — the core has zero I/O, zero deps,
84
88
  and runs identically in browser, edge runtime, or Node.
89
+
90
+ ## The caelus packages
91
+
92
+ - caelus — this package
93
+ - [caelus-birth](https://www.npmjs.com/package/caelus-birth) — local birth time + place → UT (charts take UT; use this)
94
+ - [caelus-wheel](https://www.npmjs.com/package/caelus-wheel) — React SVG chart wheel
95
+ - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, six chart tools over stdio
package/accuracy.json CHANGED
@@ -18,7 +18,14 @@
18
18
  { "name": "Mean node", "max": "0.1", "rms": "0.1", "note": "" },
19
19
  { "name": "True node", "max": "0.8", "rms": "0.4", "note": "vs full DE431 files; Swiss’s built-in Moshier mode itself differs from DE431 by up to ~15″ here" },
20
20
  { "name": "Ascendant / MC", "max": "3.2", "rms": "—", "note": "" },
21
- { "name": "Placidus cusps (all 12)", "max": "3.2", "rms": "—", "note": "" }
21
+ { "name": "Placidus cusps (all 12)", "max": "3.2", "rms": "—", "note": "" },
22
+ { "name": "Mean Lilith", "max": "1.3", "rms": "0.5", "note": "mean lunar apogee on the inclined orbit; latitude ≤0.1″" },
23
+ { "name": "Sidereal longitudes", "max": "0.1", "rms": "—", "note": "ayanamsa model vs SE ≤0.30″ at the 1900/2099 edges (IAU 1976 vs Vondrák precession); Sun worst-case 0.08″ at 120 sampled epochs" },
24
+ { "name": "RA / Dec", "max": "2.1", "rms": "—", "note": "rotation is exact; bound tracks each body's ecliptic accuracy (Moon worst)" },
25
+ { "name": "Topocentric Moon", "max": "2.7", "rms": "—", "note": "parallax model adds ≤0.1″ over the geocentric bound" },
26
+ { "name": "House cusps, 8 new systems", "max": "0.0", "rms": "0.0", "note": "Koch, Regiomontanus, Campanus, Alcabitius, Morinus, Meridian, Polich-Page, Vehlow: exact vs swe_houses_armc (200 polar-inclusive cases each)" },
27
+ { "name": "Vertex / east point", "max": "0.0", "rms": "0.0", "note": "exact vs swe_houses_armc" },
28
+ { "name": "Magnitudes", "max": "0.045 mag", "rms": "—", "note": "Mallama 2018; Moon (Allen law) valid to phase angle 140°" }
22
29
  ],
23
30
  "summary": [
24
31
  { "label": "Sun–Saturn", "bound": "≤ 1″" },
@@ -27,6 +34,10 @@
27
34
  { "label": "Moon (series, embedded)", "bound": "≤ 10″" },
28
35
  { "label": "Pluto / Chiron", "bound": "≤ 2.5″ / ≤ 1″" },
29
36
  { "label": "Angles & Placidus cusps", "bound": "≤ 3.2″" },
30
- { "label": "True node", "bound": "≤ 1″" }
31
- ]
37
+ { "label": "True node", "bound": "≤ 1″" },
38
+ { "label": "Mean Lilith", "bound": "≤ 1.3″" },
39
+ { "label": "Sidereal (5 ayanamsas)", "bound": "≤ 0.3″ added" },
40
+ { "label": "8 new house systems", "bound": "exact (0.0″)" }
41
+ ],
42
+ "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode)"
32
43
  }
@@ -1,17 +1,45 @@
1
1
  /** astroengine chart -- public API: natal charts, aspects, retrogrades. */
2
- import { EngineData } from "./core.js";
2
+ import { EngineData, AYANAMSA_J2000 } from "./core.js";
3
3
  export declare const BODIES: readonly ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node"];
4
4
  export type Body = (typeof BODIES)[number];
5
+ /** Computable on request (not in the default chart set). */
6
+ export declare const EXTRA_BODIES: readonly ["mean_lilith"];
7
+ /** Core names keep autocomplete; any string id is accepted (data packs). */
8
+ export type BodyId = Body | (typeof EXTRA_BODIES)[number] | (string & {});
5
9
  export declare const SIGNS: string[];
6
10
  export declare const ASPECTS: Record<string, number>;
7
11
  export declare const DEFAULT_ORBS: Record<string, number>;
8
- export type HouseSystem = "placidus" | "porphyry" | "equal" | "whole_sign";
12
+ export type HouseSystem = "placidus" | "porphyry" | "equal" | "whole_sign" | "koch" | "regiomontanus" | "campanus" | "alcabitius" | "morinus" | "meridian" | "polich_page" | "vehlow";
13
+ export type Ayanamsa = keyof typeof AYANAMSA_J2000 & string;
14
+ export type Zodiac = "tropical" | `sidereal:${string}`;
15
+ export interface Observer {
16
+ lat: number;
17
+ lonEast: number;
18
+ altM?: number;
19
+ }
20
+ export interface CalcOptions {
21
+ zodiac?: Zodiac;
22
+ topocentric?: boolean;
23
+ observer?: Observer;
24
+ }
25
+ export interface ChartOptions extends CalcOptions {
26
+ houseSystem?: HouseSystem;
27
+ bodies?: BodyId[];
28
+ orbs?: Record<string, number>;
29
+ }
9
30
  export interface Position {
10
31
  lon: number;
11
32
  speed: number;
12
33
  retrograde: boolean;
13
34
  sign: string;
14
35
  signDeg: number;
36
+ /** Ecliptic latitude, deg (0 for nodes). */
37
+ lat: number;
38
+ /** Geocentric distance in AU (Moon included); null for nodes and Lilith. */
39
+ dist: number | null;
40
+ /** Equatorial coordinates, true equinox of date, deg. */
41
+ ra: number;
42
+ dec: number;
15
43
  }
16
44
  export interface Aspect {
17
45
  a: string;
@@ -21,30 +49,46 @@ export interface Aspect {
21
49
  }
22
50
  export interface Chart {
23
51
  jdUt: number;
24
- /** House system actually used. May differ from the request: Placidus is
25
- * undefined above the polar circles and falls back to whole_sign. */
52
+ zodiac: Zodiac;
53
+ /** House system actually used. May differ from the request: Placidus and
54
+ * Koch are undefined above the polar circles and fall back to whole_sign. */
26
55
  houseSystem: HouseSystem;
27
56
  houseSystemRequested: HouseSystem;
28
57
  bodies: Record<string, Position>;
29
58
  angles: {
30
59
  asc: number;
31
60
  mc: number;
61
+ vertex: number;
62
+ eastPoint: number;
32
63
  };
33
64
  cusps: number[];
34
65
  aspects: Aspect[];
35
66
  }
36
67
  export declare class Engine {
37
- private data;
68
+ readonly data: EngineData;
38
69
  private moonCheb;
39
70
  private chironCheb;
40
71
  constructor(data: EngineData);
41
72
  private moonInRange;
42
- /** Apparent geocentric ecliptic longitude (deg), true equinox of date. */
43
- longitude(body: Body, jdUt: number): number;
44
- /** Longitude (deg) + speed (deg/day) + retrograde flag. */
45
- position(body: Body, jdUt: number): Position;
46
- /** Full natal chart. Time is UT. East longitude positive. */
47
- chart(y: number, mo: number, d: number, h: number, mi: number, s: number, lat: number, lonEast: number, houseSystem?: HouseSystem): Chart;
73
+ /** Body ids this engine can compute, given the data it was handed. */
74
+ bodies(): BodyId[];
75
+ /** Apparent geocentric [lon rad, lat rad, dist AU | null]. */
76
+ private ecliptic;
77
+ private lonOnly;
78
+ /** Apparent geocentric ecliptic longitude (deg). Tropical: true equinox
79
+ * of date. Sidereal: mean equinox minus ayanamsa. */
80
+ longitude(body: BodyId, jdUt: number, opts?: CalcOptions): number;
81
+ /** Geometric heliocentric ecliptic of date (deg, deg, AU). */
82
+ heliocentric(body: BodyId, jdUt: number): {
83
+ lon: number;
84
+ lat: number;
85
+ dist: number;
86
+ };
87
+ /** Full position: lon/speed/retrograde/sign + lat, dist (AU), ra, dec. */
88
+ position(body: BodyId, jdUt: number, opts?: CalcOptions): Position;
89
+ /** Full natal chart. Time is UT. East longitude positive. The ninth
90
+ * argument takes a house system name (0.2.x form) or a ChartOptions bag. */
91
+ chart(y: number, mo: number, d: number, h: number, mi: number, s: number, lat: number, lonEast: number, opts?: HouseSystem | ChartOptions): Chart;
48
92
  }
49
93
  export declare function findAspects(bodies: Record<string, Position>, orbs?: Record<string, number>): Aspect[];
50
94
  export declare function fmtLon(deg: number): string;
package/dist/src/chart.js CHANGED
@@ -1,10 +1,15 @@
1
1
  /** astroengine chart -- public API: natal charts, aspects, retrogrades. */
2
- import { DEG, mod, jdTT, julianDay, ChebSeries, planetApparent, sunApparent, moonApparentSeries, moonApparentPrecise, plutoApparent, chironApparent, meanNode, trueNodeSeries, trueNodePrecise, } from "./core.js";
2
+ import { DEG, mod, jdTT, julianDay, ChebSeries, planetApparent, sunApparent, moonApparentSeries, moonApparentPrecise, plutoApparent, chironApparent, meanNode, trueNodeSeries, trueNodePrecise, equatorial, ayanamsa, AYANAMSA_J2000, meanLilith, topocentricEcl, trueObliquity, nutation, plutoHeliocentric, vsopHeliocentric, precessEcliptic, J2000, } from "./core.js";
3
3
  import * as H from "./houses.js";
4
+ const TWO_PI = 2 * Math.PI;
4
5
  export const BODIES = [
5
6
  "sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn",
6
7
  "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node",
7
8
  ];
9
+ /** Computable on request (not in the default chart set). */
10
+ export const EXTRA_BODIES = ["mean_lilith"];
11
+ /** Points: excluded from aspect search by default. */
12
+ const NOT_ASPECTABLE = new Set(["mean_node", "true_node", "mean_lilith"]);
8
13
  export const SIGNS = [
9
14
  "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra",
10
15
  "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
@@ -15,6 +20,20 @@ export const ASPECTS = {
15
20
  export const DEFAULT_ORBS = {
16
21
  conjunction: 8, sextile: 4, square: 7, trine: 7, opposition: 8,
17
22
  };
23
+ const KM_PER_AU = 149597870.7;
24
+ function parseZodiac(zodiac) {
25
+ if (zodiac === "tropical")
26
+ return null;
27
+ if (zodiac.startsWith("sidereal:")) {
28
+ const mode = zodiac.slice("sidereal:".length);
29
+ if (AYANAMSA_J2000[mode] !== undefined)
30
+ return mode;
31
+ }
32
+ throw new Error(`unknown zodiac ${JSON.stringify(zodiac)}`);
33
+ }
34
+ const VSOP_BODIES = new Set([
35
+ "mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune",
36
+ ]);
18
37
  export class Engine {
19
38
  data;
20
39
  moonCheb;
@@ -28,93 +47,225 @@ export class Engine {
28
47
  return !!this.moonCheb
29
48
  && this.moonCheb.jd0 <= jde - 0.1 && jde + 0.1 <= this.moonCheb.jd1;
30
49
  }
31
- /** Apparent geocentric ecliptic longitude (deg), true equinox of date. */
32
- longitude(body, jdUt) {
33
- const jde = jdTT(jdUt);
34
- let lon;
35
- if (body === "sun") {
36
- [lon] = sunApparent(this.data, jde);
37
- }
38
- else if (body === "moon") {
39
- [lon] = this.moonInRange(jde)
50
+ /** Body ids this engine can compute, given the data it was handed. */
51
+ bodies() {
52
+ return [...BODIES, ...EXTRA_BODIES].filter((b) => b !== "chiron" || this.chironCheb);
53
+ }
54
+ /** Apparent geocentric [lon rad, lat rad, dist AU | null]. */
55
+ ecliptic(body, jde) {
56
+ if (body === "sun")
57
+ return sunApparent(this.data, jde);
58
+ if (body === "moon") {
59
+ const [lon, lat, km] = this.moonInRange(jde)
40
60
  ? moonApparentPrecise(this.data, this.moonCheb, jde)
41
61
  : moonApparentSeries(this.data, jde);
62
+ return [lon, lat, km / KM_PER_AU];
42
63
  }
43
- else if (body === "pluto") {
44
- [lon] = plutoApparent(this.data, jde);
64
+ if (body === "pluto")
65
+ return plutoApparent(this.data, jde);
66
+ if (body === "chiron") {
67
+ if (!this.chironCheb)
68
+ throw new Error("chiron data not loaded");
69
+ return chironApparent(this.data, this.chironCheb, jde);
70
+ }
71
+ if (body === "mean_node")
72
+ return [meanNode(this.data, jde), 0.0, null];
73
+ if (body === "true_node") {
74
+ return [
75
+ this.moonInRange(jde)
76
+ ? trueNodePrecise(this.data, this.moonCheb, jde)
77
+ : trueNodeSeries(this.data, jde),
78
+ 0.0, null,
79
+ ];
80
+ }
81
+ if (body === "mean_lilith") {
82
+ const [lon, lat] = meanLilith(this.data, jde);
83
+ return [lon, lat, null];
84
+ }
85
+ if (this.data.vsop[body])
86
+ return planetApparent(this.data, body, jde);
87
+ throw new Error(`no data loaded for body '${body}'`);
88
+ }
89
+ lonOnly(body, jdUt, mode, topo) {
90
+ const jde = jdTT(jdUt);
91
+ let [lon, lat, dist] = this.ecliptic(body, jde);
92
+ if (topo !== null && dist !== null) {
93
+ const lst = mod(H.gast(this.data, jdUt) + topo.lonEast * DEG, TWO_PI);
94
+ [lon, lat, dist] = topocentricEcl(lon, lat, dist, lst, topo.lat * DEG, topo.altM ?? 0.0, trueObliquity(this.data, jde));
95
+ }
96
+ let lonDeg = lon / DEG;
97
+ if (mode !== null) {
98
+ lonDeg = mod(lonDeg - nutation(this.data, jde)[0] / DEG - ayanamsa(jde, mode), 360);
99
+ }
100
+ return lonDeg;
101
+ }
102
+ /** Apparent geocentric ecliptic longitude (deg). Tropical: true equinox
103
+ * of date. Sidereal: mean equinox minus ayanamsa. */
104
+ longitude(body, jdUt, opts = {}) {
105
+ const mode = parseZodiac(opts.zodiac ?? "tropical");
106
+ const topo = opts.topocentric ? opts.observer ?? null : null;
107
+ return this.lonOnly(body, jdUt, mode, topo);
108
+ }
109
+ /** Geometric heliocentric ecliptic of date (deg, deg, AU). */
110
+ heliocentric(body, jdUt) {
111
+ const jde = jdTT(jdUt);
112
+ let l;
113
+ let b;
114
+ let r;
115
+ if (body === "pluto") {
116
+ [l, b, r] = plutoHeliocentric(this.data, jde);
117
+ [l, b] = precessEcliptic(l, b, J2000, jde);
45
118
  }
46
119
  else if (body === "chiron") {
47
120
  if (!this.chironCheb)
48
121
  throw new Error("chiron data not loaded");
49
- [lon] = chironApparent(this.data, this.chironCheb, jde);
50
- }
51
- else if (body === "mean_node") {
52
- lon = meanNode(this.data, jde);
122
+ const [x, y, z] = this.chironCheb.xyz(jde);
123
+ r = Math.sqrt(x * x + y * y + z * z);
124
+ l = mod(Math.atan2(y, x), TWO_PI);
125
+ b = Math.atan2(z, Math.hypot(x, y));
126
+ [l, b] = precessEcliptic(l, b, J2000, jde);
53
127
  }
54
- else if (body === "true_node") {
55
- lon = this.moonInRange(jde)
56
- ? trueNodePrecise(this.data, this.moonCheb, jde)
57
- : trueNodeSeries(this.data, jde);
128
+ else if (VSOP_BODIES.has(body) && this.data.vsop[body]) {
129
+ [l, b, r] = vsopHeliocentric(this.data.vsop[body], jde);
58
130
  }
59
131
  else {
60
- [lon] = planetApparent(this.data, body, jde);
132
+ throw new Error(`no heliocentric position for '${body}'`);
61
133
  }
62
- return lon / DEG;
134
+ return { lon: l / DEG, lat: b / DEG, dist: r };
63
135
  }
64
- /** Longitude (deg) + speed (deg/day) + retrograde flag. */
65
- position(body, jdUt) {
66
- const h = 0.25;
67
- const lon = this.longitude(body, jdUt);
68
- const l0 = this.longitude(body, jdUt - h);
69
- const l1 = this.longitude(body, jdUt + h);
136
+ /** Full position: lon/speed/retrograde/sign + lat, dist (AU), ra, dec. */
137
+ position(body, jdUt, opts = {}) {
138
+ const mode = parseZodiac(opts.zodiac ?? "tropical");
139
+ const topo = opts.topocentric ? opts.observer ?? null : null;
140
+ const jde = jdTT(jdUt);
141
+ let [lonR, latR, dist] = this.ecliptic(body, jde);
142
+ if (topo !== null && dist !== null) {
143
+ const lst = mod(H.gast(this.data, jdUt) + topo.lonEast * DEG, TWO_PI);
144
+ [lonR, latR, dist] = topocentricEcl(lonR, latR, dist, lst, topo.lat * DEG, topo.altM ?? 0.0, trueObliquity(this.data, jde));
145
+ }
146
+ const [ra, dec] = equatorial(lonR, latR, trueObliquity(this.data, jde));
147
+ let lon = lonR / DEG;
148
+ if (mode !== null) {
149
+ lon = mod(lon - nutation(this.data, jde)[0] / DEG - ayanamsa(jde, mode), 360);
150
+ }
151
+ const h = 0.25; // days; central difference
152
+ const l0 = this.lonOnly(body, jdUt - h, mode, topo);
153
+ const l1 = this.lonOnly(body, jdUt + h, mode, topo);
70
154
  const speed = (mod(l1 - l0 + 540, 360) - 180) / (2 * h);
71
155
  return {
72
156
  lon, speed, retrograde: speed < 0,
73
157
  sign: SIGNS[Math.floor(lon / 30)], signDeg: mod(lon, 30),
158
+ lat: latR / DEG, dist,
159
+ ra: ra / DEG, dec: dec / DEG,
74
160
  };
75
161
  }
76
- /** Full natal chart. Time is UT. East longitude positive. */
77
- chart(y, mo, d, h, mi, s, lat, lonEast, houseSystem = "placidus") {
162
+ /** Full natal chart. Time is UT. East longitude positive. The ninth
163
+ * argument takes a house system name (0.2.x form) or a ChartOptions bag. */
164
+ chart(y, mo, d, h, mi, s, lat, lonEast, opts = "placidus") {
165
+ const o = typeof opts === "string" ? { houseSystem: opts } : opts;
166
+ const houseSystem = o.houseSystem ?? "placidus";
167
+ const zodiac = o.zodiac ?? "tropical";
168
+ const mode = parseZodiac(zodiac);
78
169
  const jdUt = julianDay(y, mo, d, h, mi, s);
170
+ const calc = {
171
+ zodiac,
172
+ topocentric: o.topocentric,
173
+ observer: o.topocentric ? o.observer ?? { lat, lonEast, altM: 0.0 } : undefined,
174
+ };
175
+ const names = [
176
+ ...BODIES, ...(o.bodies ?? []).filter((b) => !BODIES.includes(b)),
177
+ ];
79
178
  const bodies = {};
80
- for (const b of BODIES)
81
- bodies[b] = this.position(b, jdUt);
179
+ for (const b of names)
180
+ bodies[b] = this.position(b, jdUt, calc);
82
181
  const [asc, mc, armc, eps] = H.angles(this.data, jdUt, lat, lonEast);
182
+ const [vtx, east] = H.vertexEastPoint(armc, lat * DEG, eps);
83
183
  const phi = lat * DEG;
84
- let cusps;
85
184
  let used = houseSystem;
86
- if (houseSystem === "placidus") {
87
- if (Math.abs(lat) < 66.0) {
185
+ let cusps;
186
+ try {
187
+ if (houseSystem === "placidus") {
188
+ if (Math.abs(lat) >= 66.0) {
189
+ throw new RangeError("placidus undefined above polar circles");
190
+ }
88
191
  cusps = H.housesPlacidus(armc, phi, eps);
89
192
  }
90
- else {
91
- used = "whole_sign"; // Placidus undefined above polar circles
193
+ else if (houseSystem === "porphyry") {
194
+ cusps = H.housesPorphyry(asc, mc);
195
+ }
196
+ else if (houseSystem === "equal") {
197
+ cusps = H.housesEqual(asc);
198
+ }
199
+ else if (houseSystem === "whole_sign") {
92
200
  cusps = H.housesWholeSign(asc);
93
201
  }
202
+ else if (houseSystem === "koch") {
203
+ cusps = H.housesKoch(armc, phi, eps);
204
+ }
205
+ else if (houseSystem === "regiomontanus") {
206
+ cusps = H.housesRegiomontanus(armc, phi, eps);
207
+ }
208
+ else if (houseSystem === "campanus") {
209
+ cusps = H.housesCampanus(armc, phi, eps);
210
+ }
211
+ else if (houseSystem === "alcabitius") {
212
+ cusps = H.housesAlcabitius(armc, phi, eps);
213
+ }
214
+ else if (houseSystem === "morinus") {
215
+ cusps = H.housesMorinus(armc, phi, eps);
216
+ }
217
+ else if (houseSystem === "meridian") {
218
+ cusps = H.housesMeridian(armc, phi, eps);
219
+ }
220
+ else if (houseSystem === "polich_page") {
221
+ cusps = H.housesPolichPage(armc, phi, eps);
222
+ }
223
+ else if (houseSystem === "vehlow") {
224
+ cusps = H.housesVehlow(armc, phi, eps);
225
+ }
226
+ else {
227
+ throw new Error(`unknown house system '${houseSystem}'`);
228
+ }
94
229
  }
95
- else if (houseSystem === "porphyry") {
96
- cusps = H.housesPorphyry(asc, mc);
230
+ catch (err) {
231
+ if (!(err instanceof RangeError))
232
+ throw err;
233
+ used = "whole_sign"; // Placidus/Koch undefined above polar circles
234
+ cusps = H.housesWholeSign(asc);
97
235
  }
98
- else if (houseSystem === "equal") {
99
- cusps = H.housesEqual(asc);
236
+ const jde = jdTT(jdUt);
237
+ let shift = 0.0;
238
+ if (mode !== null) {
239
+ shift = nutation(this.data, jde)[0] / DEG + ayanamsa(jde, mode);
240
+ }
241
+ const outDeg = (rad) => mod(rad / DEG - shift, 360);
242
+ let cuspsDeg;
243
+ if (mode !== null && used === "whole_sign") {
244
+ // whole-sign cusps must stay sign-aligned in the sidereal zodiac
245
+ const first = Math.floor(outDeg(asc) / 30) * 30.0;
246
+ cuspsDeg = Array.from({ length: 12 }, (_, i) => mod(first + i * 30.0, 360));
100
247
  }
101
248
  else {
102
- cusps = H.housesWholeSign(asc);
249
+ cuspsDeg = cusps.map(outDeg);
103
250
  }
104
251
  return {
105
252
  jdUt,
253
+ zodiac,
106
254
  houseSystem: used,
107
255
  houseSystemRequested: houseSystem,
108
256
  bodies,
109
- angles: { asc: asc / DEG, mc: mc / DEG },
110
- cusps: cusps.map((c) => c / DEG),
111
- aspects: findAspects(bodies),
257
+ angles: {
258
+ asc: outDeg(asc), mc: outDeg(mc),
259
+ vertex: outDeg(vtx), eastPoint: outDeg(east),
260
+ },
261
+ cusps: cuspsDeg,
262
+ aspects: findAspects(bodies, o.orbs ?? DEFAULT_ORBS),
112
263
  };
113
264
  }
114
265
  }
115
266
  export function findAspects(bodies, orbs = DEFAULT_ORBS) {
116
267
  const out = [];
117
- const names = Object.keys(bodies).filter((b) => !b.endsWith("_node"));
268
+ const names = Object.keys(bodies).filter((b) => !NOT_ASPECTABLE.has(b));
118
269
  for (let i = 0; i < names.length; i++) {
119
270
  for (let j = i + 1; j < names.length; j++) {
120
271
  const a = names[i];
@@ -134,5 +285,5 @@ export function fmtLon(deg) {
134
285
  const sign = SIGNS[Math.floor(deg / 30)];
135
286
  const d = mod(deg, 30);
136
287
  const m = mod(d, 1) * 60;
137
- return `${String(Math.floor(d)).padStart(2)}\u00b0${String(Math.floor(m)).padStart(2, "0")}' ${sign}`;
288
+ return `${String(Math.floor(d)).padStart(2)}°${String(Math.floor(m)).padStart(2, "0")}' ${sign}`;
138
289
  }
@@ -68,5 +68,25 @@ export declare function trueNodePrecise(data: EngineData, cheb: ChebSeries, jde:
68
68
  export declare function meanNode(data: EngineData, jde: number): number;
69
69
  /** Osculating node from the series moon (fallback outside Chebyshev range). */
70
70
  export declare function trueNodeSeries(data: EngineData, jde: number): number;
71
+ /** Ecliptic lon/lat -> right ascension, declination (all radians). */
72
+ export declare function equatorial(lon: number, lat: number, eps: number): [number, number];
73
+ /** Mean ayanamsa at J2000.0 (degrees) per mode. Standard epoch anchors
74
+ * (matched to Swiss Ephemeris 2.10 to 1e-9 deg); propagation uses IAU 1976
75
+ * ecliptic precession. Agreement with Swiss Ephemeris over 1900-2099 is
76
+ * <=0.30 arcsec (precession-model difference: SE uses Vondrak 2011). */
77
+ export declare const AYANAMSA_J2000: Record<string, number>;
78
+ /** Mean ayanamsa in degrees. Sidereal longitude = (tropical true-equinox
79
+ * longitude - nutation in longitude) - ayanamsa: the sidereal zodiac is
80
+ * anchored to the mean equinox. */
81
+ export declare function ayanamsa(jde: number, mode: string): number;
82
+ /** Mean lunar apogee (Black Moon Lilith) on the inclined lunar orbit:
83
+ * apparent lon (true equinox) and orbital latitude, radians. */
84
+ export declare function meanLilith(data: EngineData, jde: number): [number, number];
85
+ export declare const EARTH_RADIUS_AU: number;
86
+ /** Diurnal parallax in ecliptic coordinates (Meeus ch. 11/40).
87
+ * lst = local apparent sidereal time (rad). Returns [lon, lat, distAu]. */
88
+ export declare function topocentricEcl(lon: number, lat: number, distAu: number, lst: number, obsLat: number, altM: number, eps: number): [number, number, number];
89
+ /** Meeus ch.37 heliocentric Pluto, ecliptic J2000: [l rad, b rad, r AU]. */
90
+ export declare function plutoHeliocentric(data: EngineData, jde: number): [number, number, number];
71
91
  export declare function plutoApparent(data: EngineData, jde: number): [number, number, number];
72
92
  export declare function chironApparent(data: EngineData, cheb: ChebSeries, jde: number): [number, number, number];
package/dist/src/core.js CHANGED
@@ -377,30 +377,99 @@ export function trueNodeSeries(data, jde) {
377
377
  const node = mod(Math.atan2(hx, -hy), TWO_PI);
378
378
  return mod(node + nutation(data, jde)[0], TWO_PI);
379
379
  }
380
+ // ---------------------------------------------------------------- frames+
381
+ /** Ecliptic lon/lat -> right ascension, declination (all radians). */
382
+ export function equatorial(lon, lat, eps) {
383
+ const ra = mod(Math.atan2(Math.sin(lon) * Math.cos(eps) - Math.tan(lat) * Math.sin(eps), Math.cos(lon)), TWO_PI);
384
+ const dec = Math.asin(Math.sin(lat) * Math.cos(eps) + Math.cos(lat) * Math.sin(eps) * Math.sin(lon));
385
+ return [ra, dec];
386
+ }
387
+ /** Mean ayanamsa at J2000.0 (degrees) per mode. Standard epoch anchors
388
+ * (matched to Swiss Ephemeris 2.10 to 1e-9 deg); propagation uses IAU 1976
389
+ * ecliptic precession. Agreement with Swiss Ephemeris over 1900-2099 is
390
+ * <=0.30 arcsec (precession-model difference: SE uses Vondrak 2011). */
391
+ export const AYANAMSA_J2000 = {
392
+ lahiri: 23.857092325,
393
+ fagan_bradley: 24.740299966,
394
+ krishnamurti: 23.760240012,
395
+ raman: 22.410791012,
396
+ yukteshwar: 22.478803000,
397
+ };
398
+ /** Mean ayanamsa in degrees. Sidereal longitude = (tropical true-equinox
399
+ * longitude - nutation in longitude) - ayanamsa: the sidereal zodiac is
400
+ * anchored to the mean equinox. */
401
+ export function ayanamsa(jde, mode) {
402
+ const a0 = AYANAMSA_J2000[mode];
403
+ if (a0 === undefined)
404
+ throw new Error(`unknown ayanamsa ${mode}`);
405
+ const [lon] = precessEcliptic(a0 * DEG, 0.0, J2000, jde);
406
+ return lon / DEG;
407
+ }
408
+ /** Mean lunar apogee (Black Moon Lilith) on the inclined lunar orbit:
409
+ * apparent lon (true equinox) and orbital latitude, radians. */
410
+ export function meanLilith(data, jde) {
411
+ const T = (jde - J2000) / 36525.0;
412
+ const [Lp, , , Mp] = moonFundamental(T);
413
+ const apog = Lp - Mp + Math.PI; // mean perigee + 180
414
+ const om = (125.0445479 - 1934.1362891 * T + 0.0020754 * T * T
415
+ + T ** 3 / 467441 - T ** 4 / 60616000) * DEG;
416
+ const inc = 5.145396374 * DEG;
417
+ const u = apog - om;
418
+ const lat = Math.asin(Math.sin(inc) * Math.sin(u));
419
+ let lon = om + Math.atan2(Math.cos(inc) * Math.sin(u), Math.cos(u));
420
+ lon = mod(lon + nutation(data, jde)[0], TWO_PI);
421
+ return [lon, lat];
422
+ }
423
+ export const EARTH_RADIUS_AU = 6378.14 / 149597870.7;
424
+ const EARTH_FLAT = 0.99664719; // 1 - f, IAU 1976 figure
425
+ /** Diurnal parallax in ecliptic coordinates (Meeus ch. 11/40).
426
+ * lst = local apparent sidereal time (rad). Returns [lon, lat, distAu]. */
427
+ export function topocentricEcl(lon, lat, distAu, lst, obsLat, altM, eps) {
428
+ const u = Math.atan(EARTH_FLAT * Math.tan(obsLat));
429
+ const rs = EARTH_FLAT * Math.sin(u) + (altM / 6378140.0) * Math.sin(obsLat);
430
+ const rc = Math.cos(u) + (altM / 6378140.0) * Math.cos(obsLat);
431
+ const ox = EARTH_RADIUS_AU * rc * Math.cos(lst);
432
+ const oy = EARTH_RADIUS_AU * rc * Math.sin(lst);
433
+ const oz = EARTH_RADIUS_AU * rs;
434
+ const [ra, dec] = equatorial(lon, lat, eps);
435
+ const bx = distAu * Math.cos(dec) * Math.cos(ra);
436
+ const by = distAu * Math.cos(dec) * Math.sin(ra);
437
+ const bz = distAu * Math.sin(dec);
438
+ const tx = bx - ox;
439
+ const ty = by - oy;
440
+ const tz = bz - oz;
441
+ const ra2 = Math.atan2(ty, tx);
442
+ const dec2 = Math.atan2(tz, Math.hypot(tx, ty));
443
+ const lon2 = mod(Math.atan2(Math.sin(ra2) * Math.cos(eps) + Math.tan(dec2) * Math.sin(eps), Math.cos(ra2)), TWO_PI);
444
+ const lat2 = Math.asin(Math.sin(dec2) * Math.cos(eps) - Math.cos(dec2) * Math.sin(eps) * Math.sin(ra2));
445
+ return [lon2, lat2, Math.sqrt(tx * tx + ty * ty + tz * tz)];
446
+ }
380
447
  // ---------------------------------------------------------------- pluto
448
+ /** Meeus ch.37 heliocentric Pluto, ecliptic J2000: [l rad, b rad, r AU]. */
449
+ export function plutoHeliocentric(data, jde) {
450
+ const T = (jde - J2000) / 36525.0;
451
+ const J = (34.35 + 3034.9057 * T) * DEG;
452
+ const S = (50.08 + 1222.1138 * T) * DEG;
453
+ const P = (238.96 + 144.96 * T) * DEG;
454
+ let l = 0.0;
455
+ let b = 0.0;
456
+ let r = 0.0;
457
+ for (const [i, j, k, lA, lB, bA, bB, rA, rB] of data.pluto) {
458
+ const a = i * J + j * S + k * P;
459
+ const sa = Math.sin(a);
460
+ const ca = Math.cos(a);
461
+ l += lA * sa + lB * ca;
462
+ b += bA * sa + bB * ca;
463
+ r += rA * sa + rB * ca;
464
+ }
465
+ return [
466
+ (l + 238.958116 + 144.96 * T) * DEG,
467
+ (b - 3.908239) * DEG,
468
+ r + 40.7241346,
469
+ ];
470
+ }
381
471
  export function plutoApparent(data, jde) {
382
- const helioJ2000 = (tJde) => {
383
- const T = (tJde - J2000) / 36525.0;
384
- const J = (34.35 + 3034.9057 * T) * DEG;
385
- const S = (50.08 + 1222.1138 * T) * DEG;
386
- const P = (238.96 + 144.96 * T) * DEG;
387
- let l = 0.0;
388
- let b = 0.0;
389
- let r = 0.0;
390
- for (const [i, j, k, lA, lB, bA, bB, rA, rB] of data.pluto) {
391
- const a = i * J + j * S + k * P;
392
- const sa = Math.sin(a);
393
- const ca = Math.cos(a);
394
- l += lA * sa + lB * ca;
395
- b += bA * sa + bB * ca;
396
- r += rA * sa + rB * ca;
397
- }
398
- return [
399
- (l + 238.958116 + 144.96 * T) * DEG,
400
- (b - 3.908239) * DEG,
401
- r + 40.7241346,
402
- ];
403
- };
472
+ const helioJ2000 = (tJde) => plutoHeliocentric(data, tJde);
404
473
  const [L0d, B0d, R0d] = vsopHeliocentric(data.vsop.earth, jde);
405
474
  const [Lj, Bj] = precessEcliptic(L0d, B0d, jde, J2000);
406
475
  const ex = R0d * Math.cos(Bj) * Math.cos(Lj);
@@ -4,11 +4,42 @@ import { EngineData } from "./core.js";
4
4
  export declare function gmst(jdUt: number): number;
5
5
  /** Greenwich apparent sidereal time. */
6
6
  export declare function gast(data: EngineData, jdUt: number): number;
7
+ /** Ecliptic longitude where the house circle with pole `pole` crosses the
8
+ * ecliptic; `ra` measured like ARMC. The Ascendant is houseCusp(armc+90,
9
+ * phi); the MC is houseCusp(armc, 0). */
10
+ export declare function houseCusp(ra: number, pole: number, eps: number): number;
7
11
  /** Ascendant, MC, ARMC, obliquity. East longitude positive. */
8
12
  export declare function angles(data: EngineData, jdUt: number, latDeg: number, lonDeg: number): [number, number, number, number];
13
+ /** Vertex (western crossing of prime vertical and ecliptic) and east
14
+ * point (equatorial ascendant). Radians in, radians out. */
15
+ export declare function vertexEastPoint(armc: number, phi: number, eps: number): [number, number];
9
16
  export declare function housesWholeSign(asc: number): number[];
10
17
  export declare function housesEqual(asc: number): number[];
11
18
  export declare function housesPorphyry(asc: number, mc: number): number[];
19
+ /** Koch (birthplace): cusps are ascendants at ARMC +/- k/3 of the MC
20
+ * degree's diurnal semi-arc. Throws where the MC degree is circumpolar
21
+ * (|phi| >= 90 - eps, matching Swiss Ephemeris). */
22
+ export declare function housesKoch(armc: number, phi: number, eps: number): number[];
23
+ /** Regiomontanus: equal divisions of the celestial equator; cusp poles
24
+ * tan P = tan(phi) sin(H). */
25
+ export declare function housesRegiomontanus(armc: number, phi: number, eps: number): number[];
26
+ /** Campanus: equal divisions of the prime vertical. House circles run
27
+ * through the horizon's north/south points; cusps are their ecliptic
28
+ * crossings, assigned in zodiacal order MC->ASC->IC. */
29
+ export declare function housesCampanus(armc: number, phi: number, eps: number): number[];
30
+ /** Alcabitius: trisect the Ascendant degree's semi-arcs in right ascension;
31
+ * project cusps along meridians. */
32
+ export declare function housesAlcabitius(armc: number, phi: number, eps: number): number[];
33
+ /** Morinus: equal RA divisions projected onto the ecliptic by great circles
34
+ * through the ecliptic poles. Latitude-independent. */
35
+ export declare function housesMorinus(armc: number, _phi: number, eps: number): number[];
36
+ /** Meridian (axial rotation): equal RA divisions projected along hour
37
+ * circles. Latitude-independent. */
38
+ export declare function housesMeridian(armc: number, _phi: number, eps: number): number[];
39
+ /** Polich-Page ('topocentric'): cusp poles tan P = (k/3) tan(phi). */
40
+ export declare function housesPolichPage(armc: number, phi: number, eps: number): number[];
41
+ /** Vehlow: equal houses with the ASC at the middle of house 1. */
42
+ export declare function housesVehlow(armc: number, phi: number, eps: number): number[];
12
43
  /**
13
44
  * Placidus cusps via the classic iterative scheme. Semi-arc derivation:
14
45
  * for ALL four intermediate cusps RA = ARMC + offset + f*AD with
@@ -15,16 +15,49 @@ export function gast(data, jdUt) {
15
15
  const eps = trueObliquity(data, jde);
16
16
  return mod(gmst(jdUt) + dpsi * Math.cos(eps), TWO_PI);
17
17
  }
18
+ /** Ecliptic longitude where the house circle with pole `pole` crosses the
19
+ * ecliptic; `ra` measured like ARMC. The Ascendant is houseCusp(armc+90,
20
+ * phi); the MC is houseCusp(armc, 0). */
21
+ export function houseCusp(ra, pole, eps) {
22
+ return mod(Math.atan2(Math.sin(ra), Math.cos(ra) * Math.cos(eps) - Math.sin(eps) * Math.tan(pole)), TWO_PI);
23
+ }
24
+ function mcOf(armc, eps) {
25
+ return mod(Math.atan2(Math.sin(armc), Math.cos(armc) * Math.cos(eps)), TWO_PI);
26
+ }
27
+ /** Ascendant with the polar-latitude convention: the ASC always lies in
28
+ * the half-circle (MC, MC+180). Above ~66 deg the raw horizon intersection
29
+ * can be the setting one; Swiss Ephemeris applies the same correction. */
30
+ function ascOf(armc, phi, eps) {
31
+ let asc = houseCusp(armc + Math.PI / 2, phi, eps);
32
+ if (mod(asc - mcOf(armc, eps), TWO_PI) >= Math.PI) {
33
+ asc = mod(asc + Math.PI, TWO_PI);
34
+ }
35
+ return asc;
36
+ }
18
37
  /** Ascendant, MC, ARMC, obliquity. East longitude positive. */
19
38
  export function angles(data, jdUt, latDeg, lonDeg) {
20
39
  const jde = jdTT(jdUt);
21
40
  const eps = trueObliquity(data, jde);
22
41
  const armc = mod(gast(data, jdUt) + lonDeg * DEG, TWO_PI);
23
42
  const phi = latDeg * DEG;
24
- const mc = mod(Math.atan2(Math.sin(armc), Math.cos(armc) * Math.cos(eps)), TWO_PI);
25
- const asc = mod(Math.atan2(Math.cos(armc), -(Math.sin(armc) * Math.cos(eps) + Math.tan(phi) * Math.sin(eps))), TWO_PI);
43
+ const mc = mcOf(armc, eps);
44
+ const asc = ascOf(armc, phi, eps);
26
45
  return [asc, mc, armc, eps];
27
46
  }
47
+ /** Vertex (western crossing of prime vertical and ecliptic) and east
48
+ * point (equatorial ascendant). Radians in, radians out. */
49
+ export function vertexEastPoint(armc, phi, eps) {
50
+ const colat = phi >= 0 ? Math.PI / 2 - phi : -Math.PI / 2 - phi;
51
+ let vtx = houseCusp(armc + (3 * Math.PI) / 2, colat, eps);
52
+ // pick the western intersection: equatorial direction . east-point < 0
53
+ const dx = Math.cos(vtx);
54
+ const dy = Math.sin(vtx) * Math.cos(eps);
55
+ if (dx * -Math.sin(armc) + dy * Math.cos(armc) > 0) {
56
+ vtx = mod(vtx + Math.PI, TWO_PI);
57
+ }
58
+ const east = houseCusp(armc + Math.PI / 2, 0.0, eps);
59
+ return [vtx, east];
60
+ }
28
61
  export function housesWholeSign(asc) {
29
62
  const first = Math.floor(asc / (30 * DEG)) * 30 * DEG;
30
63
  return Array.from({ length: 12 }, (_, i) => mod(first + i * 30 * DEG, TWO_PI));
@@ -53,6 +86,176 @@ export function housesPorphyry(asc, mc) {
53
86
  cusps[8] = mod(cusps[2] + Math.PI, TWO_PI);
54
87
  return cusps;
55
88
  }
89
+ function signed(x) {
90
+ return mod(x + Math.PI, TWO_PI) - Math.PI;
91
+ }
92
+ function fillOpposites(out) {
93
+ for (const k of [3, 4, 5])
94
+ out[k] = mod(out[k + 6] + Math.PI, TWO_PI);
95
+ for (const k of [6, 7, 8])
96
+ out[k] = mod(out[k - 6] + Math.PI, TWO_PI);
97
+ return out;
98
+ }
99
+ /** Cusps 1 and 10. With flipMc (Regiomontanus, Campanus, Polich-Page), the
100
+ * MC moves to the IC when the polar ASC correction fires, keeping the cusps
101
+ * in zodiacal order; Swiss Ephemeris does the same. Alcabitius and Koch
102
+ * keep the astronomical MC. */
103
+ function quadrantFrame(armc, phi, eps, flipMc) {
104
+ const out = new Array(12).fill(0);
105
+ let mc = mcOf(armc, eps);
106
+ let asc = houseCusp(armc + Math.PI / 2, phi, eps);
107
+ if (mod(asc - mc, TWO_PI) >= Math.PI) {
108
+ asc = mod(asc + Math.PI, TWO_PI);
109
+ if (flipMc)
110
+ mc = mod(mc + Math.PI, TWO_PI);
111
+ }
112
+ out[0] = asc;
113
+ out[9] = mc;
114
+ return out;
115
+ }
116
+ /** Every quadrant-system house circle passes through the horizon's
117
+ * north/south points, so its two ecliptic crossings sit east and west of
118
+ * the meridian. Cusps 11, 12, 2, 3 are the eastern ones. */
119
+ function eastOfMeridian(lon, armc, eps) {
120
+ const ra = Math.atan2(Math.sin(lon) * Math.cos(eps), Math.cos(lon));
121
+ if (Math.sin(armc - ra) > 0)
122
+ return mod(lon + Math.PI, TWO_PI);
123
+ return lon;
124
+ }
125
+ /** Force a cusp candidate onto the short arc from lo spanning the signed
126
+ * angle d (negative when the polar ASC correction reverses the zodiacal
127
+ * direction of the house sequence). */
128
+ function normArc(lon, lo, d) {
129
+ const off = signed(lon - lo);
130
+ const inside = d >= 0 ? off >= 0 && off <= d : off >= d && off <= 0;
131
+ return inside ? lon : mod(lon + Math.PI, TWO_PI);
132
+ }
133
+ /** Koch (birthplace): cusps are ascendants at ARMC +/- k/3 of the MC
134
+ * degree's diurnal semi-arc. Throws where the MC degree is circumpolar
135
+ * (|phi| >= 90 - eps, matching Swiss Ephemeris). */
136
+ export function housesKoch(armc, phi, eps) {
137
+ if (Math.abs(phi) >= Math.PI / 2 - eps) {
138
+ throw new RangeError("koch undefined at polar latitudes");
139
+ }
140
+ const out = quadrantFrame(armc, phi, eps, false);
141
+ const decMc = Math.asin(Math.sin(eps) * Math.sin(out[9]));
142
+ const x = Math.tan(phi) * Math.tan(decMc);
143
+ if (Math.abs(x) > 1) {
144
+ throw new RangeError("koch undefined: MC degree circumpolar");
145
+ }
146
+ const sa = Math.PI / 2 + Math.asin(x); // diurnal semi-arc of the MC degree
147
+ out[10] = ascOf(armc - (2 * sa) / 3, phi, eps);
148
+ out[11] = ascOf(armc - sa / 3, phi, eps);
149
+ out[1] = ascOf(armc + sa / 3, phi, eps);
150
+ out[2] = ascOf(armc + (2 * sa) / 3, phi, eps);
151
+ return fillOpposites(out);
152
+ }
153
+ /** Regiomontanus: equal divisions of the celestial equator; cusp poles
154
+ * tan P = tan(phi) sin(H). */
155
+ export function housesRegiomontanus(armc, phi, eps) {
156
+ const out = quadrantFrame(armc, phi, eps, true);
157
+ for (const [k, h] of [[10, 30], [11, 60], [1, 120], [2, 150]]) {
158
+ const pole = Math.atan(Math.tan(phi) * Math.sin(h * DEG));
159
+ out[k] = eastOfMeridian(houseCusp(armc + h * DEG, pole, eps), armc, eps);
160
+ }
161
+ return fillOpposites(out);
162
+ }
163
+ /** Campanus: equal divisions of the prime vertical. House circles run
164
+ * through the horizon's north/south points; cusps are their ecliptic
165
+ * crossings, assigned in zodiacal order MC->ASC->IC. */
166
+ export function housesCampanus(armc, phi, eps) {
167
+ const out = quadrantFrame(armc, phi, eps, true);
168
+ const n = [
169
+ -Math.sin(phi) * Math.cos(armc), -Math.sin(phi) * Math.sin(armc), Math.cos(phi),
170
+ ];
171
+ const zen = [
172
+ Math.cos(phi) * Math.cos(armc), Math.cos(phi) * Math.sin(armc), Math.sin(phi),
173
+ ];
174
+ const east = [-Math.sin(armc), Math.cos(armc), 0.0];
175
+ const pole = [0.0, -Math.sin(eps), Math.cos(eps)];
176
+ const cusp = (theta) => {
177
+ const t = theta * DEG;
178
+ const v = [
179
+ east[0] * Math.cos(t) + zen[0] * Math.sin(t),
180
+ east[1] * Math.cos(t) + zen[1] * Math.sin(t),
181
+ east[2] * Math.cos(t) + zen[2] * Math.sin(t),
182
+ ];
183
+ const m = [
184
+ n[1] * v[2] - n[2] * v[1], n[2] * v[0] - n[0] * v[2], n[0] * v[1] - n[1] * v[0],
185
+ ];
186
+ const d = [
187
+ m[1] * pole[2] - m[2] * pole[1],
188
+ m[2] * pole[0] - m[0] * pole[2],
189
+ m[0] * pole[1] - m[1] * pole[0],
190
+ ];
191
+ return mod(Math.atan2(d[1] * Math.cos(eps) + d[2] * Math.sin(eps), d[0]), TWO_PI);
192
+ };
193
+ for (const [k, theta] of [[10, 30], [11, 60], [1, 120], [2, 150]]) {
194
+ out[k] = cusp(theta);
195
+ }
196
+ const mc = out[9];
197
+ const asc = out[0];
198
+ const dUp = signed(asc - mc);
199
+ const dDn = signed(mod(mc + Math.PI, TWO_PI) - asc);
200
+ for (const k of [10, 11])
201
+ out[k] = normArc(out[k], mc, dUp);
202
+ for (const k of [1, 2])
203
+ out[k] = normArc(out[k], asc, dDn);
204
+ // within each quadrant the two cusps must be in house order (away from
205
+ // MC, away from ASC)
206
+ if (Math.abs(signed(out[10] - mc)) > Math.abs(signed(out[11] - mc))) {
207
+ [out[10], out[11]] = [out[11], out[10]];
208
+ }
209
+ if (Math.abs(signed(out[1] - asc)) > Math.abs(signed(out[2] - asc))) {
210
+ [out[1], out[2]] = [out[2], out[1]];
211
+ }
212
+ return fillOpposites(out);
213
+ }
214
+ /** Alcabitius: trisect the Ascendant degree's semi-arcs in right ascension;
215
+ * project cusps along meridians. */
216
+ export function housesAlcabitius(armc, phi, eps) {
217
+ const out = quadrantFrame(armc, phi, eps, false);
218
+ const dec = Math.asin(Math.sin(eps) * Math.sin(out[0]));
219
+ const x = Math.max(-1.0, Math.min(1.0, Math.tan(phi) * Math.tan(dec)));
220
+ const ad = Math.asin(x);
221
+ const sda = Math.PI / 2 + ad; // diurnal semi-arc of the ASC degree
222
+ const sna = Math.PI / 2 - ad;
223
+ const ras = [
224
+ [10, armc + sda / 3], [11, armc + (2 * sda) / 3],
225
+ [1, armc + Math.PI - (2 * sna) / 3], [2, armc + Math.PI - sna / 3],
226
+ ];
227
+ for (const [k, ra] of ras) {
228
+ out[k] = mod(Math.atan2(Math.sin(ra), Math.cos(ra) * Math.cos(eps)), TWO_PI);
229
+ }
230
+ return fillOpposites(out);
231
+ }
232
+ /** Morinus: equal RA divisions projected onto the ecliptic by great circles
233
+ * through the ecliptic poles. Latitude-independent. */
234
+ export function housesMorinus(armc, _phi, eps) {
235
+ return Array.from({ length: 12 }, (_, i) => mod(Math.atan2(Math.sin(armc + (i + 3) * 30 * DEG) * Math.cos(eps), Math.cos(armc + (i + 3) * 30 * DEG)), TWO_PI));
236
+ }
237
+ /** Meridian (axial rotation): equal RA divisions projected along hour
238
+ * circles. Latitude-independent. */
239
+ export function housesMeridian(armc, _phi, eps) {
240
+ return Array.from({ length: 12 }, (_, i) => mod(Math.atan2(Math.sin(armc + (i + 3) * 30 * DEG), Math.cos(armc + (i + 3) * 30 * DEG) * Math.cos(eps)), TWO_PI));
241
+ }
242
+ /** Polich-Page ('topocentric'): cusp poles tan P = (k/3) tan(phi). */
243
+ export function housesPolichPage(armc, phi, eps) {
244
+ const out = quadrantFrame(armc, phi, eps, true);
245
+ const spec = [
246
+ [10, 30, 1], [11, 60, 2], [1, 120, 2], [2, 150, 1],
247
+ ];
248
+ for (const [k, h, w] of spec) {
249
+ const pole = Math.atan((Math.tan(phi) * w) / 3.0);
250
+ out[k] = eastOfMeridian(houseCusp(armc + h * DEG, pole, eps), armc, eps);
251
+ }
252
+ return fillOpposites(out);
253
+ }
254
+ /** Vehlow: equal houses with the ASC at the middle of house 1. */
255
+ export function housesVehlow(armc, phi, eps) {
256
+ const asc = ascOf(armc, phi, eps);
257
+ return Array.from({ length: 12 }, (_, i) => mod(asc - 15 * DEG + i * 30 * DEG, TWO_PI));
258
+ }
56
259
  /**
57
260
  * Placidus cusps via the classic iterative scheme. Semi-arc derivation:
58
261
  * for ALL four intermediate cusps RA = ARMC + offset + f*AD with
@@ -1,3 +1,4 @@
1
1
  export * from "./core.js";
2
2
  export * from "./houses.js";
3
3
  export * from "./chart.js";
4
+ export * from "./pheno.js";
package/dist/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./core.js";
2
2
  export * from "./houses.js";
3
3
  export * from "./chart.js";
4
+ export * from "./pheno.js";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * astroengine pheno -- phase, elongation, apparent diameter, magnitude,
3
+ * equation of time, horizontal coordinates, refraction.
4
+ *
5
+ * Magnitude models: Mallama & Hilton 2018 for Mercury-Saturn (Saturn with
6
+ * the ring term), constant-plus-distance for Sun and Pluto, the Mallama
7
+ * secular ramp for Neptune, Allen's phase law for the Moon (valid to phase
8
+ * angle ~140 deg; the Moon is invisible near conjunction anyway).
9
+ * Validated against swe_pheno (Swiss Ephemeris 2.10, Moshier mode).
10
+ */
11
+ import { EngineData } from "./core.js";
12
+ import { Engine, BodyId } from "./chart.js";
13
+ /** Equatorial diameters, km (IAU values, as used by Swiss Ephemeris). */
14
+ export declare const DIAMETER_KM: Record<string, number>;
15
+ export interface Pheno {
16
+ phaseAngle: number;
17
+ phase: number;
18
+ elongation: number;
19
+ diameter: number;
20
+ magnitude: number;
21
+ }
22
+ /** Phase angle (deg), illuminated fraction, elongation (deg), apparent
23
+ * diameter (deg), apparent magnitude. */
24
+ export declare function pheno(engine: Engine, body: BodyId, jdUt: number): Pheno;
25
+ /** Apparent minus mean solar time, minutes (Meeus ch. 28). */
26
+ export declare function equationOfTime(engine: Engine, jdUt: number): number;
27
+ /** Apparent ecliptic position -> azimuth (deg, from true north, east-
28
+ * positive) and true altitude (deg). No refraction. */
29
+ export declare function azAlt(data: EngineData, lonDeg: number, latDeg: number, jdUt: number, obsLat: number, obsLonEast: number): [number, number];
30
+ /** Saemundsson refraction, degrees. Returns the input unchanged when even
31
+ * the refracted altitude stays below the horizon (matches Swiss
32
+ * Ephemeris). */
33
+ export declare function refractTrueToApparent(altDeg: number, pressure?: number, tempC?: number): number;
34
+ /** Bennett refraction, degrees. */
35
+ export declare function refractApparentToTrue(altDeg: number, pressure?: number, tempC?: number): number;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * astroengine pheno -- phase, elongation, apparent diameter, magnitude,
3
+ * equation of time, horizontal coordinates, refraction.
4
+ *
5
+ * Magnitude models: Mallama & Hilton 2018 for Mercury-Saturn (Saturn with
6
+ * the ring term), constant-plus-distance for Sun and Pluto, the Mallama
7
+ * secular ramp for Neptune, Allen's phase law for the Moon (valid to phase
8
+ * angle ~140 deg; the Moon is invisible near conjunction anyway).
9
+ * Validated against swe_pheno (Swiss Ephemeris 2.10, Moshier mode).
10
+ */
11
+ import { DEG, J2000, mod, jdTT, trueObliquity, equatorial, sunApparent, } from "./core.js";
12
+ import { gast } from "./houses.js";
13
+ const TWO_PI = 2 * Math.PI;
14
+ const KM_PER_AU = 149597870.7;
15
+ /** Equatorial diameters, km (IAU values, as used by Swiss Ephemeris). */
16
+ export const DIAMETER_KM = {
17
+ sun: 1392000.0, moon: 3475.0, mercury: 4878.8, venus: 12103.6,
18
+ mars: 6779.0, jupiter: 139822.0, saturn: 116464.0,
19
+ uranus: 50724.0, neptune: 49244.0, pluto: 2376.6,
20
+ };
21
+ function magnitude(body, a, r, dlt, jde, lonDeg, latDeg) {
22
+ const x = 5 * Math.log10(r * dlt);
23
+ switch (body) {
24
+ case "sun":
25
+ return -26.86 + 5 * Math.log10(dlt);
26
+ case "moon":
27
+ // Allen phase law; constant solved against swe_pheno (a < 130).
28
+ return 0.233431 + x + 0.026 * Math.abs(a) + 4e-9 * a ** 4;
29
+ case "mercury":
30
+ return x - 0.613 + 6.328e-2 * a - 1.6336e-3 * a ** 2 + 3.3644e-5 * a ** 3
31
+ - 3.4265e-7 * a ** 4 + 1.6893e-9 * a ** 5 - 3.0334e-12 * a ** 6;
32
+ case "venus":
33
+ if (a <= 163.7) {
34
+ return x - 4.384 - 1.044e-3 * a + 3.687e-4 * a ** 2
35
+ - 2.814e-6 * a ** 3 + 8.938e-9 * a ** 4;
36
+ }
37
+ return x + 236.05828 - 2.81914 * a + 8.39034e-3 * a ** 2;
38
+ case "mars":
39
+ return x - 1.601 + 2.267e-2 * a - 1.302e-4 * a ** 2;
40
+ case "jupiter":
41
+ return x - 9.395 - 3.7e-4 * a + 6.16e-4 * a ** 2;
42
+ case "saturn": {
43
+ // ring inclination (Meeus ch. 45)
44
+ const T = (jde - J2000) / 36525.0;
45
+ const i = (28.075216 - 0.012998 * T + 0.000004 * T * T) * DEG;
46
+ const om = (169.50847 + 1.394681 * T + 0.000412 * T * T) * DEG;
47
+ const lam = lonDeg * DEG;
48
+ const bet = latDeg * DEG;
49
+ const sinB = Math.sin(i) * Math.cos(bet) * Math.sin(lam - om)
50
+ - Math.cos(i) * Math.sin(bet);
51
+ const b = Math.abs(Math.asin(Math.max(-1.0, Math.min(1.0, sinB))));
52
+ return x - 8.914 - 1.825 * Math.sin(b) + 0.026 * a
53
+ - 0.378 * Math.sin(b) * Math.exp(-2.25 * a);
54
+ }
55
+ case "uranus":
56
+ // constant absorbs Mallama's sub-solar-latitude term
57
+ return x - 7.16 + 6.587e-3 * a + 1.045e-4 * a ** 2;
58
+ case "neptune": {
59
+ const y = 2000.0 + (jde - J2000) / 365.25;
60
+ const base = y < 1980.0 ? -6.89
61
+ : y < 2000.0 ? -6.89 - (0.11 * (y - 1980.0)) / 20.0
62
+ : -7.0;
63
+ return x + base + 7.944e-3 * a + 9.617e-5 * a ** 2;
64
+ }
65
+ default: // pluto
66
+ return x - 1.01;
67
+ }
68
+ }
69
+ /** Phase angle (deg), illuminated fraction, elongation (deg), apparent
70
+ * diameter (deg), apparent magnitude. */
71
+ export function pheno(engine, body, jdUt) {
72
+ if (DIAMETER_KM[body] === undefined) {
73
+ throw new Error(`pheno not available for '${body}'`);
74
+ }
75
+ const jde = jdTT(jdUt);
76
+ const p = engine.position(body, jdUt);
77
+ const s = body === "sun" ? p : engine.position("sun", jdUt);
78
+ const dlt = p.dist;
79
+ const a1 = p.lon * DEG;
80
+ const d1 = p.lat * DEG;
81
+ const a2 = s.lon * DEG;
82
+ const d2 = s.lat * DEG;
83
+ const elong = Math.acos(Math.max(-1.0, Math.min(1.0, Math.sin(d1) * Math.sin(d2) + Math.cos(d1) * Math.cos(d2) * Math.cos(a1 - a2))));
84
+ let phaseAngle;
85
+ let r;
86
+ if (body === "sun") {
87
+ phaseAngle = 0.0;
88
+ r = dlt;
89
+ }
90
+ else if (body === "moon") {
91
+ r = s.dist; // sun-earth distance stands in for sun-moon
92
+ const R = s.dist;
93
+ phaseAngle = Math.atan2(R * Math.sin(elong), dlt - R * Math.cos(elong));
94
+ }
95
+ else {
96
+ r = engine.heliocentric(body, jdUt).dist;
97
+ const cosi = (r * r + dlt * dlt - s.dist ** 2) / (2 * r * dlt);
98
+ phaseAngle = Math.acos(Math.max(-1.0, Math.min(1.0, cosi)));
99
+ }
100
+ const aDeg = phaseAngle / DEG;
101
+ const diam = (2 * Math.asin(DIAMETER_KM[body] / (2 * dlt * KM_PER_AU))) / DEG;
102
+ return {
103
+ phaseAngle: aDeg,
104
+ phase: (1 + Math.cos(phaseAngle)) / 2,
105
+ elongation: elong / DEG,
106
+ diameter: diam,
107
+ magnitude: magnitude(body, aDeg, r, dlt, jde, p.lon, p.lat),
108
+ };
109
+ }
110
+ /** Apparent minus mean solar time, minutes (Meeus ch. 28). */
111
+ export function equationOfTime(engine, jdUt) {
112
+ const jde = jdTT(jdUt);
113
+ const t = (jde - J2000) / 365250.0;
114
+ const l0 = mod(280.4664567 + 360007.6982779 * t + 0.03032028 * t * t
115
+ + t ** 3 / 49931 - t ** 4 / 15300 - t ** 5 / 2000000, 360);
116
+ const [lon, lat] = sunApparent(engine.data, jde);
117
+ const [ra] = equatorial(lon, lat, trueObliquity(engine.data, jde));
118
+ const e = mod(l0 - 0.0057183 - ra / DEG + 180, 360) - 180;
119
+ return e * 4.0; // degrees -> minutes
120
+ }
121
+ /** Apparent ecliptic position -> azimuth (deg, from true north, east-
122
+ * positive) and true altitude (deg). No refraction. */
123
+ export function azAlt(data, lonDeg, latDeg, jdUt, obsLat, obsLonEast) {
124
+ const jde = jdTT(jdUt);
125
+ const eps = trueObliquity(data, jde);
126
+ const [ra, dec] = equatorial(lonDeg * DEG, latDeg * DEG, eps);
127
+ const lst = mod(gast(data, jdUt) + obsLonEast * DEG, TWO_PI);
128
+ const ha = lst - ra;
129
+ const phi = obsLat * DEG;
130
+ const alt = Math.asin(Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(ha));
131
+ const azS = Math.atan2(Math.sin(ha), Math.cos(ha) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi));
132
+ return [mod(azS / DEG + 180.0, 360.0), alt / DEG];
133
+ }
134
+ /** Saemundsson refraction, degrees. Returns the input unchanged when even
135
+ * the refracted altitude stays below the horizon (matches Swiss
136
+ * Ephemeris). */
137
+ export function refractTrueToApparent(altDeg, pressure = 1013.25, tempC = 15.0) {
138
+ if (altDeg < -2.0)
139
+ return altDeg;
140
+ let r = 1.02 / Math.tan((altDeg + 10.3 / (altDeg + 5.11)) * DEG);
141
+ r *= (pressure / 1010.0) * (283.0 / (273.0 + tempC));
142
+ const out = altDeg + r / 60.0;
143
+ return out < 0.0 ? altDeg : out;
144
+ }
145
+ /** Bennett refraction, degrees. */
146
+ export function refractApparentToTrue(altDeg, pressure = 1013.25, tempC = 15.0) {
147
+ if (altDeg < -2.0)
148
+ return altDeg;
149
+ let r = 1.0 / Math.tan((altDeg + 7.31 / (altDeg + 4.4)) * DEG);
150
+ r *= (pressure / 1010.0) * (283.0 / (273.0 + tempC));
151
+ return altDeg - r / 60.0;
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caelus",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",