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,76 @@
1
+ const { DateTime } = require('luxon');
2
+
3
+ /**
4
+ * BirthParseError carries an HTTP-ish status code so a thin adapter (e.g. an
5
+ * Express handler) can map it back to a 400 response. We keep a plain class
6
+ * here so the engine doesn't need to know about Express.
7
+ */
8
+ class BirthParseError extends Error {
9
+ constructor(message, statusCode = 400) {
10
+ super(message);
11
+ this.name = 'BirthParseError';
12
+ this.statusCode = statusCode;
13
+ }
14
+ }
15
+
16
+ function parseYmdLoose(v) {
17
+ // Accept both ISO-padded and non-padded dates:
18
+ // - YYYY-MM-DD
19
+ // - YYYY-M-D
20
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(v || '').trim());
21
+ if (!m) return null;
22
+ const year = Number(m[1]);
23
+ const month = Number(m[2]);
24
+ const day = Number(m[3]);
25
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
26
+ if (month < 1 || month > 12) return null;
27
+ if (day < 1 || day > 31) return null;
28
+ return { year, month, day };
29
+ }
30
+
31
+ function parseHmsLoose(v) {
32
+ // Accept:
33
+ // - HH:mm
34
+ // - H:mm
35
+ // - HH:mm:ss
36
+ const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(String(v || '').trim());
37
+ if (!m) return null;
38
+ const hour = Number(m[1]);
39
+ const minute = Number(m[2]);
40
+ const second = m[3] === undefined ? 0 : Number(m[3]);
41
+ if (!Number.isFinite(hour) || !Number.isFinite(minute) || !Number.isFinite(second)) return null;
42
+ if (hour < 0 || hour > 23) return null;
43
+ if (minute < 0 || minute > 59) return null;
44
+ if (second < 0 || second > 59) return null;
45
+ return { hour, minute, second };
46
+ }
47
+
48
+ function parseBirthToUtc({ birthdate, birthtime, timezone }) {
49
+ if (!birthdate || !birthtime || !timezone) {
50
+ throw new BirthParseError('manual mode requires birthdate, birthtime, timezone');
51
+ }
52
+
53
+ // birthdate: YYYY-MM-DD (or YYYY-M-D), birthtime: HH:mm (or H:mm) or HH:mm:ss
54
+ //
55
+ // NOTE:
56
+ // Luxon `DateTime.fromISO()` is strict about ISO padding (e.g. it rejects "1972-8-2").
57
+ // We normalize by parsing components and building a DateTime from an object instead.
58
+ const dateParts = parseYmdLoose(birthdate);
59
+ const timeParts = parseHmsLoose(birthtime);
60
+ if (!dateParts || !timeParts) {
61
+ throw new BirthParseError(
62
+ 'Invalid birthdate/birthtime format. Expected birthdate "YYYY-MM-DD" (or "YYYY-M-D") and birthtime "HH:mm" (or "H:mm") optionally with seconds "HH:mm:ss".'
63
+ );
64
+ }
65
+
66
+ const dt = DateTime.fromObject({ ...dateParts, ...timeParts }, { zone: timezone });
67
+ if (!dt.isValid) {
68
+ throw new BirthParseError(
69
+ `Invalid datetime/timezone: ${dt.invalidReason || 'unknown'} (birthdate=${birthdate}, birthtime=${birthtime}, timezone=${timezone})`
70
+ );
71
+ }
72
+
73
+ return dt.toUTC().toJSDate();
74
+ }
75
+
76
+ module.exports = { parseBirthToUtc, BirthParseError };
@@ -0,0 +1,163 @@
1
+ // Pure-JS ephemeris backend, built on the MIT-licensed `astronomia` package.
2
+ //
3
+ // This is the DEFAULT backend for rave-engine: no native build, no `.se1` data
4
+ // files, no AGPL. It reproduces the same apparent geocentric tropical ecliptic
5
+ // longitudes that the Swiss Ephemeris (`SEFLG_SWIEPH`) produces — validated to
6
+ // the exact gate/line in the golden vectors (see test/profile.test.js).
7
+ //
8
+ // All longitudes are returned in DEGREES in [0, 360).
9
+ //
10
+ // Correctness note (the #1 trap): Swiss Ephemeris `swe_calc_ut` takes UT and
11
+ // adds ΔT internally; astronomia's planet/Sun/Moon functions take JDE = UT + ΔT.
12
+ // We convert UT → JDE once per call via `deltat.deltaT`, and keep all Julian
13
+ // Days the caller sees in UT (the engine's `_meta` JDs are UT).
14
+
15
+ const A = require('astronomia');
16
+ const data = require('astronomia/data').default;
17
+
18
+ const { normalizeAngleDegrees } = require('./mandala');
19
+
20
+ const R2D = 180 / Math.PI;
21
+
22
+ // VSOP87 planet instances (heliocentric spherical, "B" series). Built once.
23
+ const EARTH = new A.planetposition.Planet(data.vsop87Bearth);
24
+ const PLANETS = {
25
+ mercury: new A.planetposition.Planet(data.vsop87Bmercury),
26
+ venus: new A.planetposition.Planet(data.vsop87Bvenus),
27
+ mars: new A.planetposition.Planet(data.vsop87Bmars),
28
+ jupiter: new A.planetposition.Planet(data.vsop87Bjupiter),
29
+ saturn: new A.planetposition.Planet(data.vsop87Bsaturn),
30
+ uranus: new A.planetposition.Planet(data.vsop87Buranus),
31
+ neptune: new A.planetposition.Planet(data.vsop87Bneptune),
32
+ };
33
+
34
+ // Bodies this backend can place directly. Earth, south_node are DERIVED by the
35
+ // caller (Earth = Sun + 180°, South Node = North Node + 180°).
36
+ const BODIES = [
37
+ 'sun',
38
+ 'moon',
39
+ 'mercury',
40
+ 'venus',
41
+ 'mars',
42
+ 'jupiter',
43
+ 'saturn',
44
+ 'uranus',
45
+ 'neptune',
46
+ 'pluto',
47
+ 'north_node',
48
+ ];
49
+
50
+ /**
51
+ * Julian Day (UT) from a JS Date. Mirrors swe.swe_julday(..., SE_GREG_CAL).
52
+ * @param {Date} dt
53
+ * @returns {number} JD in UT
54
+ */
55
+ function julianDayUT(dt) {
56
+ const hour =
57
+ dt.getUTCHours() +
58
+ dt.getUTCMinutes() / 60 +
59
+ dt.getUTCSeconds() / 3600 +
60
+ dt.getUTCMilliseconds() / 3600000;
61
+ // CalendarGregorianToJD takes a fractional day.
62
+ return A.julian.CalendarGregorianToJD(
63
+ dt.getUTCFullYear(),
64
+ dt.getUTCMonth() + 1,
65
+ dt.getUTCDate() + hour / 24
66
+ );
67
+ }
68
+
69
+ function toJDE(jdUT) {
70
+ const cal = A.julian.JDToCalendar(jdUT);
71
+ const yearFrac = cal.year + (cal.month - 1) / 12;
72
+ return jdUT + A.deltat.deltaT(yearFrac) / 86400;
73
+ }
74
+
75
+ function trueObliquity(jde) {
76
+ // mean obliquity + nutation in obliquity (Δε)
77
+ return A.nutation.meanObliquity(jde) + A.nutation.nutation(jde)[1];
78
+ }
79
+
80
+ function sunLonDeg(jde) {
81
+ // apparent (of-date) ecliptic longitude: includes nutation + aberration + FK5
82
+ return A.solar.apparentVSOP87(EARTH, jde).lon * R2D;
83
+ }
84
+
85
+ function planetLonDeg(name, jde) {
86
+ // apparent geocentric equatorial (light-time, aberration, FK5, +Δψ, true ε)
87
+ const pos = A.elliptic.position(PLANETS[name], EARTH, jde);
88
+ const eps = trueObliquity(jde);
89
+ const ecl = new A.coord.Equatorial(pos.ra, pos.dec).toEcliptic(eps);
90
+ return ecl.lon * R2D;
91
+ }
92
+
93
+ function moonLonDeg(jde) {
94
+ // mean equinox of date, no nutation → add Δψ for apparent
95
+ const pos = A.moonposition.position(jde);
96
+ const dpsi = A.nutation.nutation(jde)[0];
97
+ return (pos.lon + dpsi) * R2D;
98
+ }
99
+
100
+ function northNodeLonDeg(jde) {
101
+ // TRUE lunar ascending node (oscillating), matching mainstream Human Design
102
+ // calculators. astronomia returns it referenced to the mean equinox of date;
103
+ // add Δψ for apparent.
104
+ const dpsi = A.nutation.nutation(jde)[0];
105
+ return (A.moonposition.trueNode(jde) + dpsi) * R2D;
106
+ }
107
+
108
+ function plutoLonDeg(jde) {
109
+ // pluto.astrometric returns J2000 astrometric equatorial (note: fields are
110
+ // _ra / _dec). Convert to J2000 ecliptic, precess J2000 → date, then add Δψ
111
+ // for apparent-of-date longitude.
112
+ //
113
+ // IMPORTANT: EclipticPrecessor epochs are JULIAN YEARS (e.g. 2000.0), not
114
+ // Julian Days — passing JD here silently produces nonsense.
115
+ const astro = A.pluto.astrometric(jde, EARTH);
116
+ const eclJ2000 = new A.coord.Equatorial(astro._ra, astro._dec).toEcliptic(
117
+ A.nutation.meanObliquity(2451545.0)
118
+ );
119
+ const epochTo = 2000 + (jde - 2451545.0) / 365.25;
120
+ const prec = new A.precess.EclipticPrecessor(2000, epochTo);
121
+ const moved = prec.precess(new A.coord.Ecliptic(eclJ2000.lon, eclJ2000.lat));
122
+ const dpsi = A.nutation.nutation(jde)[0];
123
+ return (moved.lon + dpsi) * R2D;
124
+ }
125
+
126
+ /**
127
+ * Apparent geocentric tropical ecliptic longitude of `body` at `jdUT`.
128
+ * @param {string} body one of BODIES
129
+ * @param {number} jdUT Julian Day in UT
130
+ * @returns {number} longitude in degrees [0, 360)
131
+ */
132
+ function longitude(body, jdUT) {
133
+ const jde = toJDE(jdUT);
134
+ let deg;
135
+ switch (body) {
136
+ case 'sun':
137
+ deg = sunLonDeg(jde);
138
+ break;
139
+ case 'moon':
140
+ deg = moonLonDeg(jde);
141
+ break;
142
+ case 'pluto':
143
+ deg = plutoLonDeg(jde);
144
+ break;
145
+ case 'north_node':
146
+ deg = northNodeLonDeg(jde);
147
+ break;
148
+ default:
149
+ if (!PLANETS[body]) throw new Error(`astronomia backend: unknown body "${body}"`);
150
+ deg = planetLonDeg(body, jde);
151
+ }
152
+ return normalizeAngleDegrees(deg);
153
+ }
154
+
155
+ module.exports = {
156
+ name: 'astronomia',
157
+ BODIES,
158
+ julianDayUT,
159
+ longitude,
160
+ // exposed for reuse/debugging
161
+ toJDE,
162
+ trueObliquity,
163
+ };
@@ -0,0 +1,61 @@
1
+ // Ephemeris backend selector.
2
+ //
3
+ // rave-engine is pure-JS by default: the `astronomia` backend needs no native
4
+ // build and no data files. A high-precision `swisseph` backend is available
5
+ // opt-in (EPHE_BACKEND=swisseph) when the native binding + ephemeris files are
6
+ // installed — it is never required.
7
+ //
8
+ // Both backends implement the same interface:
9
+ // { name, BODIES, julianDayUT(dt), longitude(body, jdUT) } // degrees [0,360)
10
+
11
+ const astronomiaBackend = require('./astronomia');
12
+
13
+ let _backend = null;
14
+
15
+ function parseBool(v) {
16
+ if (v === undefined || v === null || v === '') return false;
17
+ return String(v).toLowerCase() === 'true' || String(v) === '1';
18
+ }
19
+
20
+ function tryLoadSwisseph() {
21
+ try {
22
+ // eslint-disable-next-line global-require
23
+ return require('./swisseph');
24
+ } catch (e) {
25
+ if (parseBool(process.env.EPHE_DEBUG) || parseBool(process.env.EPHE_STATUS)) {
26
+ // eslint-disable-next-line no-console
27
+ console.warn(
28
+ `[rave-engine] EPHE_BACKEND=swisseph requested but unavailable (${e.message.split('\n')[0]}). Falling back to astronomia.`
29
+ );
30
+ }
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Resolve the active ephemeris backend (memoized).
37
+ * Default: astronomia. Opt into swisseph with EPHE_BACKEND=swisseph.
38
+ */
39
+ function getBackend() {
40
+ if (_backend) return _backend;
41
+ if (String(process.env.EPHE_BACKEND || '').toLowerCase() === 'swisseph') {
42
+ const sw = tryLoadSwisseph();
43
+ if (sw) {
44
+ _backend = sw;
45
+ return _backend;
46
+ }
47
+ }
48
+ _backend = astronomiaBackend;
49
+ return _backend;
50
+ }
51
+
52
+ /**
53
+ * @deprecated No-op. Kept for backward compatibility with callers that set the
54
+ * Swiss Ephemeris data path. The default astronomia backend needs no data files;
55
+ * the optional swisseph backend resolves EPHE_PATH itself.
56
+ */
57
+ function ensureEphePath() {
58
+ /* intentionally empty */
59
+ }
60
+
61
+ module.exports = { getBackend, ensureEphePath };
@@ -0,0 +1,66 @@
1
+ // Mandala / hexagram mapping helpers.
2
+ //
3
+ // Ported from preliminaries/gk_hex_profile.py to keep math identical.
4
+
5
+ const pi2 = Math.PI * 2;
6
+
7
+ // The wheel has 64 slices, each slice can be further subdivided into lines/colors/tones.
8
+ const hex_width = pi2 / 64;
9
+ const line_width = hex_width / 6;
10
+ const color_width = line_width / 6;
11
+ const tone_width = color_width / 6;
12
+ const base_width = tone_width / 5;
13
+
14
+ // start counting at 55, but this requires an offset in radiance!
15
+ const iching_map = [
16
+ 55, 37, 63, 22, 36, 25, 17, 21, 51, 42, 3, 27, 24, 2, 23, 8,
17
+ 20, 16, 35, 45, 12, 15, 52, 39, 53, 62, 56, 31, 33, 7, 4, 29,
18
+ 59, 40, 64, 47, 6, 46, 18, 48, 57, 32, 50, 28, 44, 1, 43, 14,
19
+ 34, 9, 5, 26, 11, 10, 58, 38, 54, 61, 60, 41, 19, 13, 49, 30,
20
+ ];
21
+
22
+ const DEG_TO_RADIANS = Math.PI / 180;
23
+ const LINE_SEGMENT = 1 / 6;
24
+
25
+ function neutron_stream_pos(radians_position) {
26
+ // (2*line_width - 1*color_width - 1*tone_width) plus additional offset.
27
+ const offset = 2 * line_width - 1 * color_width - 1 * tone_width + 3 * base_width;
28
+ const offset_calc = (360 * DEG_TO_RADIANS) / 64 * 5 + offset;
29
+ return (((radians_position + offset_calc) / pi2) * 64) % 64;
30
+ }
31
+
32
+ function map_on_hexagram(fractal_line) {
33
+ const fractal_bin = Math.floor(fractal_line);
34
+ const hexagram = iching_map[fractal_bin];
35
+
36
+ const remainder = fractal_line - fractal_bin;
37
+ const line = Math.floor(remainder / LINE_SEGMENT) + 1;
38
+
39
+ const color_bucket = remainder - LINE_SEGMENT * Math.floor(remainder / LINE_SEGMENT);
40
+ const color = Math.floor(color_bucket / LINE_SEGMENT) + 1;
41
+
42
+ return { hexagram, line, color };
43
+ }
44
+
45
+ function map_mandala_hexagram(radians_position) {
46
+ const bin_value = neutron_stream_pos(radians_position);
47
+ return map_on_hexagram(bin_value);
48
+ }
49
+
50
+ function normalizeAngleDegrees(angle) {
51
+ // [0, 360)
52
+ const out = angle % 360;
53
+ return out < 0 ? out + 360 : out;
54
+ }
55
+
56
+ function mapLongitudeDegrees(lonDeg) {
57
+ const lon = normalizeAngleDegrees(lonDeg);
58
+ return map_mandala_hexagram(lon * DEG_TO_RADIANS);
59
+ }
60
+
61
+ module.exports = {
62
+ mapLongitudeDegrees,
63
+ normalizeAngleDegrees,
64
+ // expose for debugging
65
+ iching_map,
66
+ };
@@ -0,0 +1,288 @@
1
+ const { mapLongitudeDegrees, normalizeAngleDegrees } = require('./mandala');
2
+ const { getBackend, ensureEphePath } = require('./ephemeris');
3
+
4
+ // Longitude speed is computed by central difference (only the sign is consumed,
5
+ // for the `retrograde` flag, so this is extremely robust across backends).
6
+ const SPEED_H = 0.5; // days
7
+
8
+ function jdUtcFromDate(dt) {
9
+ return getBackend().julianDayUT(dt);
10
+ }
11
+
12
+ function signedAngleDiff(a, b) {
13
+ // minimal signed difference (a - b) in degrees within [-180, 180)
14
+ return ((a - b + 540) % 360) - 180;
15
+ }
16
+
17
+ function bodyLongitude(body, jdUt) {
18
+ return getBackend().longitude(body, jdUt);
19
+ }
20
+
21
+ function bodyLongitudeSpeed(body, jdUt) {
22
+ const hi = bodyLongitude(body, jdUt + SPEED_H);
23
+ const lo = bodyLongitude(body, jdUt - SPEED_H);
24
+ return signedAngleDiff(hi, lo) / (2 * SPEED_H);
25
+ }
26
+
27
+ function sunLongitudeDegrees(jdUt) {
28
+ return bodyLongitude('sun', jdUt);
29
+ }
30
+
31
+ /**
32
+ * Find the previous Julian Day (UT) at which the Sun's longitude was
33
+ * `deltaDegrees` behind its value at `jdUt`. Used to locate the Human Design
34
+ * "design" moment (88° of solar arc before birth).
35
+ *
36
+ * @param {number} jdUt
37
+ * @param {number} deltaDegrees
38
+ * @param {*} [_flags] deprecated/ignored (kept for backward compatibility)
39
+ */
40
+ function findPreviousSolarLongitude(jdUt, deltaDegrees, _flags) {
41
+ const currentSun = sunLongitudeDegrees(jdUt);
42
+ const target = normalizeAngleDegrees(currentSun - deltaDegrees);
43
+
44
+ let highJd = jdUt;
45
+ const step = 1.0; // days
46
+ let lowJd = highJd - step;
47
+
48
+ let lowDiff = signedAngleDiff(sunLongitudeDegrees(lowJd), target);
49
+
50
+ let iterations = 0;
51
+ const maxIterations = 365;
52
+
53
+ // Walk back until we bracket the crossing.
54
+ while (lowDiff > 0 && iterations < maxIterations) {
55
+ lowJd -= step;
56
+ lowDiff = signedAngleDiff(sunLongitudeDegrees(lowJd), target);
57
+ iterations += 1;
58
+ }
59
+
60
+ if (lowDiff > 0) {
61
+ throw new Error('Failed to bracket previous solar longitude');
62
+ }
63
+
64
+ // Binary search for the crossing.
65
+ for (let i = 0; i < 50; i += 1) {
66
+ const mid = 0.5 * (lowJd + highJd);
67
+ const midDiff = signedAngleDiff(sunLongitudeDegrees(mid), target);
68
+ if (midDiff > 0) {
69
+ highJd = mid;
70
+ } else {
71
+ lowJd = mid;
72
+ }
73
+ }
74
+
75
+ return 0.5 * (lowJd + highJd);
76
+ }
77
+
78
+ function getBody(jdUt, body) {
79
+ return {
80
+ longitude: bodyLongitude(body, jdUt),
81
+ longitudeSpeed: bodyLongitudeSpeed(body, jdUt),
82
+ };
83
+ }
84
+
85
+ function deriveOpposite(planet) {
86
+ // Earth = Sun + 180°, South Node = North Node + 180°. Speed carries over.
87
+ return {
88
+ longitude: normalizeAngleDegrees(planet.longitude + 180),
89
+ longitudeSpeed: planet.longitudeSpeed,
90
+ };
91
+ }
92
+
93
+ function mapPlanetToGKLine(planet) {
94
+ const { hexagram, line } = mapLongitudeDegrees(planet.longitude);
95
+ return { gk: hexagram, line };
96
+ }
97
+
98
+ function mapPlanetToGateLineColor(planet) {
99
+ const { hexagram, line, color } = mapLongitudeDegrees(planet.longitude);
100
+ return {
101
+ gate: hexagram,
102
+ hexagram,
103
+ line,
104
+ color,
105
+ longitude: planet.longitude,
106
+ longitude_speed: planet.longitudeSpeed,
107
+ retrograde: planet.longitudeSpeed < 0,
108
+ };
109
+ }
110
+
111
+ // The two stream moments shared by every computation below.
112
+ function streamMoments({ birthUtc }) {
113
+ const jdPersonality = jdUtcFromDate(birthUtc);
114
+ const jdDesign = findPreviousSolarLongitude(jdPersonality, 88.0);
115
+ return { jdPersonality, jdDesign };
116
+ }
117
+
118
+ function computeProfileSpheres({ birthUtc }) {
119
+ const { jdPersonality, jdDesign } = streamMoments({ birthUtc });
120
+
121
+ // Personality bodies
122
+ const pSun = getBody(jdPersonality, 'sun');
123
+ const pMercury = getBody(jdPersonality, 'mercury');
124
+ const pVenus = getBody(jdPersonality, 'venus');
125
+ const pMars = getBody(jdPersonality, 'mars');
126
+ const pJupiter = getBody(jdPersonality, 'jupiter');
127
+
128
+ // Design bodies
129
+ const dSun = getBody(jdDesign, 'sun');
130
+ const dMoon = getBody(jdDesign, 'moon');
131
+ const dVenus = getBody(jdDesign, 'venus');
132
+ const dMars = getBody(jdDesign, 'mars');
133
+ const dJupiter = getBody(jdDesign, 'jupiter');
134
+ const dSaturn = getBody(jdDesign, 'saturn');
135
+ const dUranus = getBody(jdDesign, 'uranus');
136
+
137
+ // Earth is opposite Sun in HD.
138
+ const pEarth = deriveOpposite(pSun);
139
+ const dEarth = deriveOpposite(dSun);
140
+
141
+ // Sphere → body map (parity with event-horizon-api):
142
+ // - p_sun = Life's Work, p_earth = Evolution
143
+ // - d_sun = Radiance, d_earth = Purpose
144
+ // - p_venus = IQ, p_mars = EQ, p_jupiter = Pearl, p_mercury = Relating
145
+ // - d_moon = Attraction, d_venus = SQ, d_mars = Core, d_jupiter = Culture
146
+ // - d_saturn = Core Stability, d_uranus = Creativity
147
+ return {
148
+ lifeswork: mapPlanetToGKLine(pSun),
149
+ evolution: mapPlanetToGKLine(pEarth),
150
+ radiance: mapPlanetToGKLine(dSun),
151
+ purpose: mapPlanetToGKLine(dEarth),
152
+
153
+ iq: mapPlanetToGKLine(pVenus),
154
+ eq: mapPlanetToGKLine(pMars),
155
+ pearl: mapPlanetToGKLine(pJupiter),
156
+ relating: mapPlanetToGKLine(pMercury),
157
+
158
+ attraction: mapPlanetToGKLine(dMoon),
159
+ sq: mapPlanetToGKLine(dVenus),
160
+ core: mapPlanetToGKLine(dMars),
161
+ culture: mapPlanetToGKLine(dJupiter),
162
+ stability: mapPlanetToGKLine(dSaturn),
163
+ creativity: mapPlanetToGKLine(dUranus),
164
+
165
+ _meta: {
166
+ jd_personality: jdPersonality,
167
+ jd_design: jdDesign,
168
+ },
169
+ };
170
+ }
171
+
172
+ function computeEngineTest({ birthUtc }) {
173
+ const { jdPersonality, jdDesign } = streamMoments({ birthUtc });
174
+
175
+ // Personality bodies
176
+ const pSun = getBody(jdPersonality, 'sun');
177
+ const pMercury = getBody(jdPersonality, 'mercury');
178
+ const pVenus = getBody(jdPersonality, 'venus');
179
+ const pMars = getBody(jdPersonality, 'mars');
180
+ const pJupiter = getBody(jdPersonality, 'jupiter');
181
+
182
+ // Design bodies
183
+ const dSun = getBody(jdDesign, 'sun');
184
+ const dMoon = getBody(jdDesign, 'moon');
185
+ const dVenus = getBody(jdDesign, 'venus');
186
+ const dMars = getBody(jdDesign, 'mars');
187
+ const dJupiter = getBody(jdDesign, 'jupiter');
188
+ const dSaturn = getBody(jdDesign, 'saturn');
189
+ const dUranus = getBody(jdDesign, 'uranus');
190
+
191
+ const pEarth = deriveOpposite(pSun);
192
+ const dEarth = deriveOpposite(dSun);
193
+
194
+ return {
195
+ p_: {
196
+ sun: mapPlanetToGateLineColor(pSun),
197
+ earth: mapPlanetToGateLineColor(pEarth),
198
+ mercury: mapPlanetToGateLineColor(pMercury),
199
+ venus: mapPlanetToGateLineColor(pVenus),
200
+ mars: mapPlanetToGateLineColor(pMars),
201
+ jupiter: mapPlanetToGateLineColor(pJupiter),
202
+ },
203
+ d_: {
204
+ sun: mapPlanetToGateLineColor(dSun),
205
+ earth: mapPlanetToGateLineColor(dEarth),
206
+ moon: mapPlanetToGateLineColor(dMoon),
207
+ venus: mapPlanetToGateLineColor(dVenus),
208
+ mars: mapPlanetToGateLineColor(dMars),
209
+ jupiter: mapPlanetToGateLineColor(dJupiter),
210
+ saturn: mapPlanetToGateLineColor(dSaturn),
211
+ uranus: mapPlanetToGateLineColor(dUranus),
212
+ },
213
+ spheres: computeProfileSpheres({ birthUtc }),
214
+ _meta: {
215
+ jd_personality: jdPersonality,
216
+ jd_design: jdDesign,
217
+ },
218
+ };
219
+ }
220
+
221
+ // Full Human Design activation set: all 13 bodies in BOTH streams (26 gates),
222
+ // used to derive the bodygraph (centers / channels). Earth and South Node are
223
+ // derived from Sun / North Node.
224
+ const HD_BODIES = [
225
+ 'sun',
226
+ 'earth',
227
+ 'moon',
228
+ 'north_node',
229
+ 'south_node',
230
+ 'mercury',
231
+ 'venus',
232
+ 'mars',
233
+ 'jupiter',
234
+ 'saturn',
235
+ 'uranus',
236
+ 'neptune',
237
+ 'pluto',
238
+ ];
239
+
240
+ function activationAt(jdUt, body) {
241
+ let planet;
242
+ if (body === 'earth') planet = deriveOpposite(getBody(jdUt, 'sun'));
243
+ else if (body === 'south_node') planet = deriveOpposite(getBody(jdUt, 'north_node'));
244
+ else planet = getBody(jdUt, body);
245
+
246
+ const { hexagram, line, color } = mapLongitudeDegrees(planet.longitude);
247
+ return {
248
+ body,
249
+ gate: hexagram,
250
+ line,
251
+ color,
252
+ longitude: planet.longitude,
253
+ retrograde: planet.longitudeSpeed < 0,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Compute the 26 Human Design activations (13 bodies × personality + design).
259
+ * @param {{ birthUtc: Date }} args
260
+ * @returns {{
261
+ * personality: Array<object>,
262
+ * design: Array<object>,
263
+ * _meta: { jd_personality: number, jd_design: number }
264
+ * }}
265
+ */
266
+ function computeActivations({ birthUtc }) {
267
+ const { jdPersonality, jdDesign } = streamMoments({ birthUtc });
268
+ const personality = HD_BODIES.map((b) => ({ stream: 'personality', ...activationAt(jdPersonality, b) }));
269
+ const design = HD_BODIES.map((b) => ({ stream: 'design', ...activationAt(jdDesign, b) }));
270
+ return {
271
+ personality,
272
+ design,
273
+ _meta: { jd_personality: jdPersonality, jd_design: jdDesign },
274
+ };
275
+ }
276
+
277
+ module.exports = {
278
+ computeProfileSpheres,
279
+ computeEngineTest,
280
+ computeActivations,
281
+ jdUtcFromDate,
282
+ // Exported for reuse in global transit scanning (design stream).
283
+ findPreviousSolarLongitude,
284
+ signedAngleDiff,
285
+ // Kept exported for backward compatibility (now a no-op).
286
+ ensureEphePath,
287
+ HD_BODIES,
288
+ };