rave-engine 1.0.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.
@@ -0,0 +1,87 @@
1
+ // OPTIONAL high-precision backend, built on the native `swisseph` binding.
2
+ //
3
+ // This file is only loaded when EPHE_BACKEND=swisseph AND the native binding +
4
+ // ephemeris files are actually available. `swisseph` is an optional dependency:
5
+ // the package works fully without it (the pure-JS `astronomia` backend is the
6
+ // default). Note that `swisseph` is AGPL/commercial and ships Astrodienst data
7
+ // files, so it is never required for the published package.
8
+ //
9
+ // Exposes the same interface as ./astronomia.js so the two are interchangeable:
10
+ // { name, BODIES, julianDayUT(dt), longitude(body, jdUT) }
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ const { normalizeAngleDegrees } = require('./mandala');
16
+
17
+ // Loaded lazily — require() of the native binding can throw on ABI mismatch.
18
+ const swe = require('swisseph');
19
+
20
+ const BODY_TO_SE = {
21
+ sun: swe.SE_SUN,
22
+ moon: swe.SE_MOON,
23
+ mercury: swe.SE_MERCURY,
24
+ venus: swe.SE_VENUS,
25
+ mars: swe.SE_MARS,
26
+ jupiter: swe.SE_JUPITER,
27
+ saturn: swe.SE_SATURN,
28
+ uranus: swe.SE_URANUS,
29
+ neptune: swe.SE_NEPTUNE,
30
+ pluto: swe.SE_PLUTO,
31
+ north_node: swe.SE_TRUE_NODE,
32
+ };
33
+
34
+ const BODIES = Object.keys(BODY_TO_SE);
35
+ const FLAGS = swe.SEFLG_SWIEPH | swe.SEFLG_SPEED;
36
+
37
+ let _ephePathSet = false;
38
+
39
+ function isReadableDir(dirPath) {
40
+ try {
41
+ return fs.statSync(dirPath).isDirectory();
42
+ } catch (_e) {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ function ensureEphePath() {
48
+ if (_ephePathSet) return;
49
+ const configured = process.env.EPHE_PATH;
50
+ let ephePath = null;
51
+ if (configured && isReadableDir(path.resolve(configured))) {
52
+ ephePath = path.resolve(configured);
53
+ } else {
54
+ // swisseph ships its own ephe/ folder; use it as a fallback.
55
+ try {
56
+ const pkg = require.resolve('swisseph/package.json');
57
+ const candidate = path.join(path.dirname(pkg), 'ephe');
58
+ if (isReadableDir(candidate)) ephePath = candidate;
59
+ } catch (_e) {
60
+ /* ignore */
61
+ }
62
+ }
63
+ if (ephePath) swe.swe_set_ephe_path(ephePath);
64
+ _ephePathSet = true;
65
+ }
66
+
67
+ function julianDayUT(dt) {
68
+ const hour =
69
+ dt.getUTCHours() +
70
+ dt.getUTCMinutes() / 60 +
71
+ dt.getUTCSeconds() / 3600 +
72
+ dt.getUTCMilliseconds() / 3600000;
73
+ return swe.swe_julday(dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate(), hour, swe.SE_GREG_CAL);
74
+ }
75
+
76
+ function longitude(body, jdUT) {
77
+ ensureEphePath();
78
+ const id = BODY_TO_SE[body];
79
+ if (id === undefined) throw new Error(`swisseph backend: unknown body "${body}"`);
80
+ const r = swe.swe_calc_ut(jdUT, id, FLAGS);
81
+ if (!r || !Number.isFinite(r.longitude)) {
82
+ throw new Error(`swisseph backend: swe_calc_ut failed for "${body}" (${r && r.error})`);
83
+ }
84
+ return normalizeAngleDegrees(r.longitude);
85
+ }
86
+
87
+ module.exports = { name: 'swisseph', BODIES, julianDayUT, longitude };
package/src/chart.js ADDED
@@ -0,0 +1,84 @@
1
+ // computeChart — the high-level "powerful primitive".
2
+ //
3
+ // From a birth date / time / timezone (and an optional location), it returns
4
+ // three coherent sections:
5
+ // - geneKeys : the Gene Keys ("Jinki") sphere map — gk + line per sphere
6
+ // - humanDesign: the full Human Design bodygraph — activations, gates,
7
+ // channels, defined / open centers, Type, Authority, Profile
8
+ // - astrology : extended astrology — Ascendant / Descendant / MC / IC and
9
+ // house cusps (needs a location; null if none resolvable)
10
+
11
+ const { parseBirthToUtc } = require('./birth/parseBirth');
12
+ const { computeEngineTest, computeActivations } = require('./calc/profile');
13
+ const { computeBodygraph } = require('./hd/bodygraph');
14
+ const { computeAngles, computeHouses } = require('./hd/houses');
15
+ const { resolveLocation } = require('./timezone/location');
16
+
17
+ /**
18
+ * @param {{
19
+ * birthdate: string,
20
+ * birthtime: string,
21
+ * timezone: string,
22
+ * location?: { lat:number, lng:number },
23
+ * houseSystem?: 'placidus'|'whole'|'equal'
24
+ * }} input
25
+ */
26
+ function computeChart(input) {
27
+ const birthUtc = parseBirthToUtc(input);
28
+
29
+ const engine = computeEngineTest({ birthUtc });
30
+ const activations = computeActivations({ birthUtc });
31
+ const bodygraph = computeBodygraph(activations);
32
+
33
+ // Resolve a birth location: explicit lat/lng wins, else the timezone's
34
+ // representative (most-populous-city) coordinates.
35
+ const location = resolveLocation({
36
+ lat: input.location && input.location.lat,
37
+ lng: input.location && input.location.lng,
38
+ timezone: input.timezone,
39
+ });
40
+
41
+ let astrology = null;
42
+ if (location) {
43
+ const jdUT = engine._meta.jd_personality;
44
+ const houseSystem = input.houseSystem || 'placidus';
45
+ astrology = {
46
+ location,
47
+ angles: computeAngles({ jdUT, lat: location.lat, lng: location.lng }),
48
+ houses: computeHouses({ jdUT, lat: location.lat, lng: location.lng, system: houseSystem }),
49
+ };
50
+ }
51
+
52
+ // geneKeys spheres without the internal _meta key.
53
+ const { _meta: spheresMeta, ...spheres } = engine.spheres;
54
+
55
+ return {
56
+ input: {
57
+ birthdate: input.birthdate,
58
+ birthtime: input.birthtime,
59
+ timezone: input.timezone,
60
+ birth_utc: birthUtc.toISOString(),
61
+ location,
62
+ },
63
+ geneKeys: { spheres },
64
+ humanDesign: {
65
+ type: bodygraph.type,
66
+ authority: bodygraph.authority,
67
+ profile: bodygraph.profile,
68
+ definitionCount: bodygraph.definitionCount,
69
+ p_: engine.p_,
70
+ d_: engine.d_,
71
+ activations: { personality: activations.personality, design: activations.design },
72
+ activatedGates: bodygraph.activatedGates,
73
+ definedChannels: bodygraph.definedChannels,
74
+ definedCenters: bodygraph.definedCenters,
75
+ openCenters: bodygraph.openCenters,
76
+ centers: bodygraph.centers,
77
+ gateActivations: bodygraph.gateActivations,
78
+ },
79
+ astrology,
80
+ _meta: engine._meta,
81
+ };
82
+ }
83
+
84
+ module.exports = { computeChart };
@@ -0,0 +1,270 @@
1
+ // Human Design bodygraph: the canonical (Jovian Archive / Ra Uru Hu) mapping of
2
+ // the 64 gates to the 9 centers and the 36 channels, plus the derivation of a
3
+ // full bodygraph (activated gates, defined channels, defined / open centers,
4
+ // Type, Authority, Profile) from a set of planetary gate activations.
5
+ //
6
+ // This data is fixed, well-known reference data — the same bodygraph used by
7
+ // every Human Design chart calculator.
8
+
9
+ // The 9 centers.
10
+ const CENTERS = [
11
+ 'head',
12
+ 'ajna',
13
+ 'throat',
14
+ 'g',
15
+ 'heart',
16
+ 'sacral',
17
+ 'solarplexus',
18
+ 'spleen',
19
+ 'root',
20
+ ];
21
+
22
+ // Human-readable center labels.
23
+ const CENTER_LABELS = {
24
+ head: 'Head',
25
+ ajna: 'Ajna',
26
+ throat: 'Throat',
27
+ g: 'G (Identity)',
28
+ heart: 'Heart (Ego/Will)',
29
+ sacral: 'Sacral',
30
+ solarplexus: 'Solar Plexus',
31
+ spleen: 'Spleen',
32
+ root: 'Root',
33
+ };
34
+
35
+ // The "motor" centers — sources of energy/pressure that can power the Throat.
36
+ const MOTOR_CENTERS = ['sacral', 'heart', 'solarplexus', 'root'];
37
+
38
+ // Gate → center. All 64 gates appear exactly once (asserted in tests).
39
+ const GATE_CENTER = {
40
+ // Head (Crown)
41
+ 64: 'head', 61: 'head', 63: 'head',
42
+ // Ajna
43
+ 47: 'ajna', 24: 'ajna', 4: 'ajna', 17: 'ajna', 11: 'ajna', 43: 'ajna',
44
+ // Throat
45
+ 62: 'throat', 23: 'throat', 56: 'throat', 35: 'throat', 12: 'throat', 45: 'throat',
46
+ 33: 'throat', 8: 'throat', 31: 'throat', 20: 'throat', 16: 'throat',
47
+ // G (Identity / Self)
48
+ 1: 'g', 13: 'g', 25: 'g', 46: 'g', 2: 'g', 15: 'g', 10: 'g', 7: 'g',
49
+ // Heart (Ego / Will)
50
+ 21: 'heart', 40: 'heart', 26: 'heart', 51: 'heart',
51
+ // Spleen
52
+ 48: 'spleen', 57: 'spleen', 44: 'spleen', 50: 'spleen', 32: 'spleen', 28: 'spleen', 18: 'spleen',
53
+ // Sacral
54
+ 34: 'sacral', 5: 'sacral', 14: 'sacral', 29: 'sacral', 59: 'sacral', 9: 'sacral',
55
+ 3: 'sacral', 42: 'sacral', 27: 'sacral',
56
+ // Solar Plexus (Emotional)
57
+ 6: 'solarplexus', 37: 'solarplexus', 30: 'solarplexus', 55: 'solarplexus',
58
+ 49: 'solarplexus', 22: 'solarplexus', 36: 'solarplexus',
59
+ // Root
60
+ 53: 'root', 60: 'root', 52: 'root', 19: 'root', 39: 'root', 41: 'root',
61
+ 58: 'root', 38: 'root', 54: 'root',
62
+ };
63
+
64
+ // The 36 channels as unordered gate pairs. The two centers a channel connects
65
+ // are DERIVED from GATE_CENTER (see CHANNELS below) so the data can't drift.
66
+ const CHANNEL_GATE_PAIRS = [
67
+ [1, 8], [2, 14], [3, 60], [4, 63], [5, 15], [6, 59], [7, 31], [9, 52],
68
+ [10, 20], [10, 34], [10, 57], [11, 56], [12, 22], [13, 33], [16, 48], [17, 62],
69
+ [18, 58], [19, 49], [20, 34], [20, 57], [21, 45], [23, 43], [24, 61], [25, 51],
70
+ [26, 44], [27, 50], [28, 38], [29, 46], [30, 41], [32, 54], [34, 57], [35, 36],
71
+ [37, 40], [39, 55], [42, 53], [47, 64],
72
+ ];
73
+
74
+ // Optional channel names (nice-to-have for output; not required for derivation).
75
+ const CHANNEL_NAMES = {
76
+ '1-8': 'Inspiration', '2-14': 'The Beat', '3-60': 'Mutation', '4-63': 'Logic',
77
+ '5-15': 'Rhythm', '6-59': 'Mating', '7-31': 'The Alpha', '9-52': 'Concentration',
78
+ '10-20': 'Awakening', '10-34': 'Exploration', '10-57': 'Perfected Form',
79
+ '11-56': 'Curiosity', '12-22': 'Openness', '13-33': 'The Prodigal',
80
+ '16-48': 'The Wavelength', '17-62': 'Acceptance', '18-58': 'Judgment',
81
+ '19-49': 'Synthesis', '20-34': 'Charisma', '20-57': 'The Brainwave',
82
+ '21-45': 'Money', '23-43': 'Structuring', '24-61': 'Awareness', '25-51': 'Initiation',
83
+ '26-44': 'Surrender', '27-50': 'Preservation', '28-38': 'Struggle',
84
+ '29-46': 'Discovery', '30-41': 'Recognition', '32-54': 'Transformation',
85
+ '34-57': 'Power', '35-36': 'Transitoriness', '37-40': 'Community',
86
+ '39-55': 'Emoting', '42-53': 'Maturation', '47-64': 'Abstraction',
87
+ };
88
+
89
+ function channelKey(a, b) {
90
+ const [lo, hi] = a < b ? [a, b] : [b, a];
91
+ return `${lo}-${hi}`;
92
+ }
93
+
94
+ // Materialized channel list with derived centers.
95
+ const CHANNELS = CHANNEL_GATE_PAIRS.map(([a, b]) => {
96
+ const [lo, hi] = a < b ? [a, b] : [b, a];
97
+ const key = `${lo}-${hi}`;
98
+ return {
99
+ key,
100
+ gates: [lo, hi],
101
+ centers: [GATE_CENTER[lo], GATE_CENTER[hi]],
102
+ name: CHANNEL_NAMES[key] || null,
103
+ };
104
+ });
105
+
106
+ function uniqueSorted(nums) {
107
+ return [...new Set(nums)].sort((x, y) => x - y);
108
+ }
109
+
110
+ // Reachability in the defined-center graph: can any motor center reach Throat?
111
+ function motorConnectsToThroat(definedCentersSet, definedChannels) {
112
+ if (!definedCentersSet.has('throat')) return false;
113
+ // adjacency among defined centers via defined channels
114
+ const adj = new Map();
115
+ for (const c of definedCentersSet) adj.set(c, new Set());
116
+ for (const ch of definedChannels) {
117
+ const [c1, c2] = ch.centers;
118
+ if (adj.has(c1) && adj.has(c2)) {
119
+ adj.get(c1).add(c2);
120
+ adj.get(c2).add(c1);
121
+ }
122
+ }
123
+ const motors = MOTOR_CENTERS.filter((m) => definedCentersSet.has(m));
124
+ // BFS from each motor; success if Throat is reached.
125
+ const seen = new Set();
126
+ const queue = [...motors];
127
+ motors.forEach((m) => seen.add(m));
128
+ while (queue.length) {
129
+ const cur = queue.shift();
130
+ if (cur === 'throat') return true;
131
+ for (const nxt of adj.get(cur) || []) {
132
+ if (!seen.has(nxt)) {
133
+ seen.add(nxt);
134
+ queue.push(nxt);
135
+ }
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+
141
+ function determineType(definedCentersSet, definedChannels) {
142
+ if (definedCentersSet.size === 0) return 'Reflector';
143
+ const sacral = definedCentersSet.has('sacral');
144
+ const motorThroat = motorConnectsToThroat(definedCentersSet, definedChannels);
145
+ if (sacral) return motorThroat ? 'Manifesting Generator' : 'Generator';
146
+ return motorThroat ? 'Manifestor' : 'Projector';
147
+ }
148
+
149
+ function determineAuthority(definedCentersSet, definedChannels) {
150
+ const has = (c) => definedCentersSet.has(c);
151
+ if (definedCentersSet.size === 0) return 'Lunar (Reflector)';
152
+ if (has('solarplexus')) return 'Emotional (Solar Plexus)';
153
+ if (has('sacral')) return 'Sacral';
154
+ if (has('spleen')) return 'Splenic';
155
+ if (has('heart')) return 'Ego (Heart)';
156
+ // G defined and connected to the throat → Self-Projected.
157
+ if (has('g') && has('throat')) {
158
+ const gReachesThroat = motorConnectsToThroatFrom('g', definedCentersSet, definedChannels);
159
+ if (gReachesThroat) return 'Self-Projected (G)';
160
+ }
161
+ // Only awareness/throat centers → Mental (Environmental / "None").
162
+ return 'Mental (None / Environmental)';
163
+ }
164
+
165
+ // Generic reachability from a single start center to the throat.
166
+ function motorConnectsToThroatFrom(start, definedCentersSet, definedChannels) {
167
+ if (!definedCentersSet.has('throat') || !definedCentersSet.has(start)) return false;
168
+ const adj = new Map();
169
+ for (const c of definedCentersSet) adj.set(c, new Set());
170
+ for (const ch of definedChannels) {
171
+ const [c1, c2] = ch.centers;
172
+ if (adj.has(c1) && adj.has(c2)) {
173
+ adj.get(c1).add(c2);
174
+ adj.get(c2).add(c1);
175
+ }
176
+ }
177
+ const seen = new Set([start]);
178
+ const queue = [start];
179
+ while (queue.length) {
180
+ const cur = queue.shift();
181
+ if (cur === 'throat') return true;
182
+ for (const nxt of adj.get(cur) || []) {
183
+ if (!seen.has(nxt)) {
184
+ seen.add(nxt);
185
+ queue.push(nxt);
186
+ }
187
+ }
188
+ }
189
+ return false;
190
+ }
191
+
192
+ /**
193
+ * Derive a full Human Design bodygraph from planetary activations.
194
+ *
195
+ * @param {{
196
+ * personality: Array<{ body: string, gate: number, line: number }>,
197
+ * design: Array<{ body: string, gate: number, line: number }>
198
+ * }} activations (as returned by computeActivations)
199
+ * @returns {{
200
+ * type: string,
201
+ * authority: string,
202
+ * profile: string,
203
+ * definitionCount: number,
204
+ * activatedGates: number[],
205
+ * definedChannels: Array<{ key, gates, centers, name }>,
206
+ * definedCenters: string[],
207
+ * openCenters: string[],
208
+ * centers: Record<string, boolean>,
209
+ * gateActivations: Record<number, Array<{ stream, body, line }>>
210
+ * }}
211
+ */
212
+ function computeBodygraph(activations) {
213
+ const personality = activations.personality || [];
214
+ const design = activations.design || [];
215
+ const all = [...personality, ...design];
216
+
217
+ const activatedGates = uniqueSorted(all.map((a) => a.gate));
218
+ const gateSet = new Set(activatedGates);
219
+
220
+ const definedChannels = CHANNELS.filter(
221
+ (ch) => gateSet.has(ch.gates[0]) && gateSet.has(ch.gates[1])
222
+ );
223
+
224
+ const definedCentersSet = new Set();
225
+ for (const ch of definedChannels) {
226
+ definedCentersSet.add(ch.centers[0]);
227
+ definedCentersSet.add(ch.centers[1]);
228
+ }
229
+ const definedCenters = CENTERS.filter((c) => definedCentersSet.has(c));
230
+ const openCenters = CENTERS.filter((c) => !definedCentersSet.has(c));
231
+
232
+ const centers = {};
233
+ for (const c of CENTERS) centers[c] = definedCentersSet.has(c);
234
+
235
+ // Per-gate activation detail (which body/stream lit each gate).
236
+ const gateActivations = {};
237
+ for (const a of all) {
238
+ if (!gateActivations[a.gate]) gateActivations[a.gate] = [];
239
+ gateActivations[a.gate].push({ stream: a.stream, body: a.body, line: a.line });
240
+ }
241
+
242
+ // Profile = personality Sun line / design Sun line.
243
+ const pSun = personality.find((a) => a.body === 'sun');
244
+ const dSun = design.find((a) => a.body === 'sun');
245
+ const profile = pSun && dSun ? `${pSun.line}/${dSun.line}` : null;
246
+
247
+ return {
248
+ type: determineType(definedCentersSet, definedChannels),
249
+ authority: determineAuthority(definedCentersSet, definedChannels),
250
+ profile,
251
+ definitionCount: definedChannels.length,
252
+ activatedGates,
253
+ definedChannels,
254
+ definedCenters,
255
+ openCenters,
256
+ centers,
257
+ gateActivations,
258
+ };
259
+ }
260
+
261
+ module.exports = {
262
+ CENTERS,
263
+ CENTER_LABELS,
264
+ MOTOR_CENTERS,
265
+ GATE_CENTER,
266
+ CHANNELS,
267
+ CHANNEL_NAMES,
268
+ channelKey,
269
+ computeBodygraph,
270
+ };
@@ -0,0 +1,229 @@
1
+ // Extended astrology: chart angles (Ascendant, Descendant, MC, IC) and house
2
+ // cusps (Whole Sign, Equal, Placidus).
3
+ //
4
+ // These need NO ephemeris — only local sidereal time and the obliquity of the
5
+ // ecliptic — so they are computed purely from Julian Day (UT) + geographic
6
+ // latitude/longitude using the MIT `astronomia` package for sidereal time and
7
+ // obliquity.
8
+
9
+ const A = require('astronomia');
10
+ const { normalizeAngleDegrees, mapLongitudeDegrees } = require('../calc/mandala');
11
+
12
+ const D2R = Math.PI / 180;
13
+ const R2D = 180 / Math.PI;
14
+
15
+ const SIGNS = [
16
+ 'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
17
+ 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces',
18
+ ];
19
+
20
+ function signOf(lonDeg) {
21
+ const lon = normalizeAngleDegrees(lonDeg);
22
+ const idx = Math.floor(lon / 30);
23
+ return {
24
+ sign: SIGNS[idx],
25
+ signIndex: idx,
26
+ degInSign: lon - idx * 30,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Greenwich Apparent Sidereal Time in degrees for a Julian Day (UT).
32
+ * astronomia returns seconds of time; convert to degrees (×15/3600).
33
+ */
34
+ function gastDegrees(jdUT) {
35
+ return normalizeAngleDegrees((A.sidereal.apparent(jdUT) / 3600) * 15);
36
+ }
37
+
38
+ function trueObliquityDeg(jdUT) {
39
+ // mean obliquity + nutation in obliquity, in degrees
40
+ return (A.nutation.meanObliquity(jdUT) + A.nutation.nutation(jdUT)[1]) * R2D;
41
+ }
42
+
43
+ /**
44
+ * Right Ascension of the Midheaven (Local Apparent Sidereal Time) in degrees.
45
+ * @param {number} jdUT
46
+ * @param {number} lngEast geographic longitude, degrees east (negative = west)
47
+ */
48
+ function ramcDegrees(jdUT, lngEast) {
49
+ return normalizeAngleDegrees(gastDegrees(jdUT) + lngEast);
50
+ }
51
+
52
+ /**
53
+ * Ecliptic longitude of a point on the ecliptic (β=0) given its right ascension.
54
+ * λ = atan2(sin α, cos α · cos ε)
55
+ */
56
+ function eclipticLonFromRA(raDeg, epsDeg) {
57
+ const ra = raDeg * D2R;
58
+ const eps = epsDeg * D2R;
59
+ return normalizeAngleDegrees(Math.atan2(Math.sin(ra), Math.cos(ra) * Math.cos(eps)) * R2D);
60
+ }
61
+
62
+ function declOfEclipticPoint(lonDeg, epsDeg) {
63
+ return Math.asin(Math.sin(epsDeg * D2R) * Math.sin(lonDeg * D2R)) * R2D;
64
+ }
65
+
66
+ /**
67
+ * Compute the four chart angles.
68
+ * @param {{ jdUT: number, lat: number, lng: number }} args lat/lng in degrees
69
+ * (lat north +, lng east +)
70
+ * @returns {{ ascendant, descendant, mc, ic }} each { longitude, sign, signIndex, degInSign, gateLine }
71
+ */
72
+ function computeAngles({ jdUT, lat, lng }) {
73
+ const eps = trueObliquityDeg(jdUT);
74
+ const ramc = ramcDegrees(jdUT, lng);
75
+ const ramcR = ramc * D2R;
76
+ const epsR = eps * D2R;
77
+ const phiR = lat * D2R;
78
+
79
+ const mc = normalizeAngleDegrees(
80
+ Math.atan2(Math.sin(ramcR), Math.cos(ramcR) * Math.cos(epsR)) * R2D
81
+ );
82
+
83
+ let asc = normalizeAngleDegrees(
84
+ Math.atan2(
85
+ Math.cos(ramcR),
86
+ -(Math.sin(ramcR) * Math.cos(epsR) + Math.tan(phiR) * Math.sin(epsR))
87
+ ) * R2D
88
+ );
89
+
90
+ // The Ascendant must lie in the rising semicircle, i.e. 0..180° east of the
91
+ // MC. If the formula landed on the opposite point, flip it.
92
+ if (normalizeAngleDegrees(asc - mc) > 180) {
93
+ asc = normalizeAngleDegrees(asc + 180);
94
+ }
95
+
96
+ const decorate = (lon) => ({
97
+ longitude: lon,
98
+ ...signOf(lon),
99
+ gateLine: (() => {
100
+ const m = mapLongitudeDegrees(lon);
101
+ return { gate: m.hexagram, line: m.line };
102
+ })(),
103
+ });
104
+
105
+ return {
106
+ ascendant: decorate(asc),
107
+ descendant: decorate(normalizeAngleDegrees(asc + 180)),
108
+ mc: decorate(mc),
109
+ ic: decorate(normalizeAngleDegrees(mc + 180)),
110
+ _meta: { ramc, obliquity: eps },
111
+ };
112
+ }
113
+
114
+ // Iterative Placidus intermediate cusp (houses 11, 12, 2, 3).
115
+ // Solves for the ecliptic point whose RA divides its day/night semi-arc in the
116
+ // Placidus ratio. Returns null if the point is circumpolar (Placidus undefined,
117
+ // typically |lat| > ~66°).
118
+ function placidusIntermediate(ramc, eps, lat, which) {
119
+ // which: '11' | '12' | '2' | '3'
120
+ const phiR = lat * D2R;
121
+ const epsR = eps * D2R;
122
+
123
+ // target RA(SA, NA) per cusp; SA = semidiurnal arc, NA = 180 - SA
124
+ function targetRA(SA) {
125
+ const NA = 180 - SA;
126
+ switch (which) {
127
+ case '11':
128
+ return ramc + (1 / 3) * SA;
129
+ case '12':
130
+ return ramc + (2 / 3) * SA;
131
+ case '2':
132
+ return ramc + SA + (1 / 3) * NA;
133
+ case '3':
134
+ return ramc + SA + (2 / 3) * NA;
135
+ default:
136
+ throw new Error(`bad cusp ${which}`);
137
+ }
138
+ }
139
+
140
+ // initial offsets 30/60/120/150
141
+ const initialOffset = { 11: 30, 12: 60, 2: 120, 3: 150 }[which];
142
+ let ra = ramc + initialOffset;
143
+
144
+ for (let i = 0; i < 100; i += 1) {
145
+ const lon = eclipticLonFromRA(ra, eps);
146
+ const decl = declOfEclipticPoint(lon, eps);
147
+ const cosSA = -Math.tan(phiR) * Math.tan(decl * D2R);
148
+ if (cosSA <= -1 || cosSA >= 1) return null; // circumpolar → Placidus fails
149
+ const SA = Math.acos(cosSA) * R2D;
150
+ const next = targetRA(SA);
151
+ if (Math.abs(normalizeAngleDegrees(next - ra + 180) - 180) < 1e-9) {
152
+ ra = next;
153
+ break;
154
+ }
155
+ ra = next;
156
+ }
157
+ return eclipticLonFromRA(ra, eps);
158
+ }
159
+
160
+ /**
161
+ * Compute the 12 house cusps for a given system.
162
+ * @param {{ jdUT, lat, lng, system?: 'whole'|'equal'|'placidus' }} args
163
+ * @returns {{ system, cusps: Array<{ house, longitude, sign, degInSign }>, angles, fallback? }}
164
+ */
165
+ function computeHouses({ jdUT, lat, lng, system = 'placidus' }) {
166
+ const angles = computeAngles({ jdUT, lat, lng });
167
+ const asc = angles.ascendant.longitude;
168
+ const mc = angles.mc.longitude;
169
+ const eps = angles._meta.obliquity;
170
+ const ramc = angles._meta.ramc;
171
+
172
+ const sys = String(system).toLowerCase();
173
+ let cuspLons = new Array(12);
174
+ let usedSystem = sys;
175
+ let fallback;
176
+
177
+ if (sys === 'whole') {
178
+ const base = Math.floor(normalizeAngleDegrees(asc) / 30) * 30;
179
+ for (let i = 0; i < 12; i += 1) cuspLons[i] = normalizeAngleDegrees(base + i * 30);
180
+ } else if (sys === 'equal') {
181
+ for (let i = 0; i < 12; i += 1) cuspLons[i] = normalizeAngleDegrees(asc + i * 30);
182
+ } else {
183
+ // placidus
184
+ const c11 = placidusIntermediate(ramc, eps, lat, '11');
185
+ const c12 = placidusIntermediate(ramc, eps, lat, '12');
186
+ const c2 = placidusIntermediate(ramc, eps, lat, '2');
187
+ const c3 = placidusIntermediate(ramc, eps, lat, '3');
188
+ if ([c11, c12, c2, c3].some((x) => x === null)) {
189
+ // Polar latitude: fall back to whole sign.
190
+ fallback = 'placidus undefined at this latitude — fell back to whole sign';
191
+ usedSystem = 'whole';
192
+ const base = Math.floor(normalizeAngleDegrees(asc) / 30) * 30;
193
+ for (let i = 0; i < 12; i += 1) cuspLons[i] = normalizeAngleDegrees(base + i * 30);
194
+ } else {
195
+ cuspLons[0] = asc; // 1
196
+ cuspLons[1] = c2; // 2
197
+ cuspLons[2] = c3; // 3
198
+ cuspLons[3] = normalizeAngleDegrees(mc + 180); // 4 (IC)
199
+ cuspLons[4] = normalizeAngleDegrees(c11 + 180); // 5
200
+ cuspLons[5] = normalizeAngleDegrees(c12 + 180); // 6
201
+ cuspLons[6] = normalizeAngleDegrees(asc + 180); // 7 (Desc)
202
+ cuspLons[7] = normalizeAngleDegrees(c2 + 180); // 8
203
+ cuspLons[8] = normalizeAngleDegrees(c3 + 180); // 9
204
+ cuspLons[9] = mc; // 10 (MC)
205
+ cuspLons[10] = c11; // 11
206
+ cuspLons[11] = c12; // 12
207
+ }
208
+ }
209
+
210
+ const cusps = cuspLons.map((lon, i) => ({
211
+ house: i + 1,
212
+ longitude: lon,
213
+ ...signOf(lon),
214
+ }));
215
+
216
+ return { system: usedSystem, requestedSystem: sys, fallback, cusps, angles };
217
+ }
218
+
219
+ module.exports = {
220
+ SIGNS,
221
+ signOf,
222
+ computeAngles,
223
+ computeHouses,
224
+ // exposed for testing
225
+ gastDegrees,
226
+ trueObliquityDeg,
227
+ ramcDegrees,
228
+ eclipticLonFromRA,
229
+ };