@essrt/physics 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,211 @@
1
+ /**
2
+ * Meeus Ch. 47 lunar periodic series — THE shared implementation (8.2-6).
3
+ *
4
+ * Replaces THREE copies: the browser scene block (moveModel), the tools
5
+ * scene block (both the full production evaluation), and hosts the
6
+ * TRUNCATED eclipse-finder variant beside it. The dual form is DELIBERATE
7
+ * (doc 66 §Layer 1b): the truncated `_eclMoon*` helpers are the form the
8
+ * certified eclipse statistics were produced with (NASA canon recall
9
+ * 99.58/74.62/98.66, knife-edge at the γ ≈ 1.0/1.5 boundaries) — upgrading
10
+ * them to the full series is a RE-CERTIFICATION item, not a refactor.
11
+ *
12
+ * Full scene form: 60 longitude + 60 latitude terms with E/E² scaling on
13
+ * M-bearing terms, A1 (Venus, Meeus-observed rate — the 18V−16E−M′
14
+ * near-resonance makes it hypersensitive; no lattice identity), A2/A3
15
+ * (D2-derived lattice rates, CHAIN-INTEGRATED under deep time through
16
+ * their identified content), all six latitude corrections, the EoC-half
17
+ * subtraction (the off-centre orbit geometry already provides half the
18
+ * equation of centre), and the two-term ellipse distance.
19
+ *
20
+ * The correction literals (3958/1962/318; −2235/382/175/175/127/−115;
21
+ * phases 119.75/53.09/313.45; Meeus A/B rates 479264.290/481266.484) are
22
+ * Meeus Ch. 47 STRUCTURE and live here, like the argument polynomials
23
+ * (8.2-5 S2 precedent). The 60-term tables are INJECTED — they are fitted
24
+ * coefficients (the engines' two sources are byte-identical).
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ /**
30
+ * @typedef {Array<[number, number, number, number, number]>} MeeusTermTable
31
+ */
32
+
33
+ /**
34
+ * @param {{
35
+ * constants: {
36
+ * moonL: MeeusTermTable,
37
+ * moonB: MeeusTermTable,
38
+ * j2000JD: number,
39
+ * julianCenturyDays: number,
40
+ * moonMeeusLpCorrectionDeg: number,
41
+ * fwA2RateDegPerCy: number,
42
+ * fwA3RateDegPerCy: number,
43
+ * },
44
+ * fns: {
45
+ * argsAt: (jdTT: number) => {Lp: number, D: number, M: number, Mp: number, F: number},
46
+ * eFactorForD: (dDaysTT: number, T: number, T2: number) => number,
47
+ * eFactorAtJdTT: (jdTT: number, T: number, T2: number) => number,
48
+ * getMoonDistanceKm: () => number,
49
+ * getEccentricityBase: () => number,
50
+ * deltaTSeconds: (jdUT: number) => number,
51
+ * jdToSIyear: (jd: number) => number,
52
+ * tropicalOrbitsBetween: (yearA: number, yearB: number) => (number | null),
53
+ * apsidalOfDateCyclesBetween: (yearA: number, yearB: number) => (number | null),
54
+ * cyclesBetween: (yearA: number, yearB: number, divisorN: number) => (number | null),
55
+ * jupiterOrbitsBetween: (yearA: number, yearB: number) => (number | null),
56
+ * isDeepTime: () => boolean,
57
+ * isFrameworkNative: () => boolean,
58
+ * },
59
+ * }} deps — argsAt is the engine's dispatcher (probe hook and mode toggle
60
+ * ride along); eFactorForD preserves each engine's exact E-factor call
61
+ * shape (S11: the two convert d→years with differently-associated
62
+ * expressions); distance/eccentricity are GETTERS because the browser's
63
+ * moonDistance is deep-time-mutable.
64
+ */
65
+ function createMoonSeries({ constants, fns }) {
66
+ const {
67
+ moonL, moonB, j2000JD, julianCenturyDays,
68
+ moonMeeusLpCorrectionDeg, fwA2RateDegPerCy, fwA3RateDegPerCy,
69
+ } = constants;
70
+ const {
71
+ argsAt, eFactorForD, eFactorAtJdTT, getMoonDistanceKm, getEccentricityBase, deltaTSeconds,
72
+ jdToSIyear, tropicalOrbitsBetween, apsidalOfDateCyclesBetween,
73
+ cyclesBetween, jupiterOrbitsBetween, isDeepTime, isFrameworkNative,
74
+ } = fns;
75
+
76
+ const D2R = Math.PI / 180;
77
+
78
+ /** A1/A2/A3 in radians at T centuries TT (deep time: A2/A3 chain-integrated).
79
+ * @param {number} T @param {number} dDaysTT
80
+ * @returns {{A1: number, A2: number, A3: number}} */
81
+ function additionalArgs(T, dDaysTT) {
82
+ const A1 = (119.75 + 131.849 * T) * D2R;
83
+ let a2Deg = 53.09 + (isFrameworkNative() ? fwA2RateDegPerCy : 479264.290) * T;
84
+ let a3Deg = 313.45 + (isFrameworkNative() ? fwA3RateDegPerCy : 481266.484) * T;
85
+ if (isDeepTime() && isFrameworkNative()) {
86
+ const yA0 = jdToSIyear(j2000JD);
87
+ const yA = jdToSIyear(j2000JD + dDaysTT);
88
+ const nT = tropicalOrbitsBetween(yA0, yA);
89
+ const nAps = apsidalOfDateCyclesBetween(yA0, yA);
90
+ const nP13 = cyclesBetween(yA0, yA, 13);
91
+ const nJ = jupiterOrbitsBetween(yA0, yA);
92
+ if (nT !== null && nAps !== null && nP13 !== null && nJ !== null) {
93
+ a3Deg = 313.45 + 360 * (nT - nP13);
94
+ a2Deg = 53.09 + 360 * (nT + nAps - 2 * nJ);
95
+ }
96
+ }
97
+ return { A1, A2: a2Deg * D2R, A3: a3Deg * D2R };
98
+ }
99
+
100
+ /** One 60-term table pass with E/E² on M-bearing terms (micro-degrees).
101
+ * @param {MeeusTermTable} table @param {number} Dr @param {number} Mr
102
+ * @param {number} Mpr @param {number} Fr @param {number} E
103
+ * @param {number} E2 @returns {number} */
104
+ function sumTable(table, Dr, Mr, Mpr, Fr, E, E2) {
105
+ let s = 0;
106
+ for (let i = 0; i < table.length; i++) {
107
+ const r = table[i];
108
+ const arg = r[0] * Dr + r[1] * Mr + r[2] * Mpr + r[3] * Fr;
109
+ let term = r[4] * Math.sin(arg);
110
+ const absM = r[1] < 0 ? -r[1] : r[1];
111
+ if (absM === 1) term *= E;
112
+ else if (absM === 2) term *= E2;
113
+ s += term;
114
+ }
115
+ return s;
116
+ }
117
+
118
+ /** The PRODUCTION scene evaluation at d days TT from J2000 (the caller
119
+ * owns the UT→TT conversion — engine timing conventions differ).
120
+ * Returns everything the scene blocks write:
121
+ * thetaAddRad — the hierarchy θ increment (EoC-half-subtracted Σl);
122
+ * lonDeg — full ecliptic longitude incl. moonMeeusLpCorrection;
123
+ * latRad/latDeg — Σb with all six corrections (BOTH computed from Σb
124
+ * directly: the browser stores radians, the Node engine degrees, and
125
+ * (x·D2R)/D2R is not bit-exactly x); distKm — two-term ellipse; T.
126
+ * @param {number} dDaysTT
127
+ * @returns {{thetaAddRad: number, lonDeg: number, latRad: number, latDeg: number, distKm: number, T: number}} */
128
+ function sceneEvalAt(dDaysTT) {
129
+ const d = dDaysTT;
130
+ const T = d / julianCenturyDays;
131
+ const T2 = T * T;
132
+ const args = argsAt(j2000JD + d);
133
+ const Lp = args.Lp * D2R;
134
+ const Dr = args.D * D2R, Mr = args.M * D2R, Mpr = args.Mp * D2R, Fr = args.F * D2R;
135
+ const E = eFactorForD(d, T, T2);
136
+ const E2 = E * E;
137
+ const { A1, A2, A3 } = additionalArgs(T, d);
138
+
139
+ let Sl = sumTable(moonL, Dr, Mr, Mpr, Fr, E, E2);
140
+ Sl += 3958 * Math.sin(A1) + 1962 * Math.sin(Lp - Fr) + 318 * Math.sin(A2);
141
+ // Subtract the EoC portion the off-centre orbit geometry already provides.
142
+ const eocHalf = getEccentricityBase() / 2;
143
+ Sl -= (2 * eocHalf / D2R * 1e6) * Math.sin(Mpr);
144
+ Sl -= (1.25 * eocHalf * eocHalf / D2R * 1e6) * Math.sin(2 * Mpr);
145
+ const thetaAddRad = Sl * 1e-6 * D2R;
146
+
147
+ let Sb = sumTable(moonB, Dr, Mr, Mpr, Fr, E, E2);
148
+ Sb += -2235 * Math.sin(Lp) + 382 * Math.sin(A3);
149
+ Sb += 175 * Math.sin(A1 - Fr) + 175 * Math.sin(A1 + Fr);
150
+ Sb += 127 * Math.sin(Lp - Mpr) - 115 * Math.sin(Lp + Mpr);
151
+ const latRad = Sb * 1e-6 * D2R;
152
+ const latDeg = Sb * 1e-6;
153
+
154
+ // Full longitude re-adds the EoC half that θ absorbed.
155
+ const fullSl = Sl + (2 * eocHalf / D2R * 1e6) * Math.sin(Mpr)
156
+ + (1.25 * eocHalf * eocHalf / D2R * 1e6) * Math.sin(2 * Mpr);
157
+ const lonDeg = Lp / D2R + fullSl * 1e-6 + moonMeeusLpCorrectionDeg;
158
+ const distKm = getMoonDistanceKm() * (1 - getEccentricityBase() * Math.cos(Mpr));
159
+ return { thetaAddRad, lonDeg, latRad, latDeg, distKm, T };
160
+ }
161
+
162
+ /** TRUNCATED eclipse-finder longitude (doc 66 §Layer 1b): the certified
163
+ * form — no EoC subtraction, no A3/latitude-family, no LpCorrection.
164
+ * Accepts JD_UT; converts to TT via the injected ΔT.
165
+ * @param {number} jdUT @returns {number} degrees 0–360 */
166
+ function truncatedLonDeg(jdUT) {
167
+ const jdTT = jdUT + deltaTSeconds(jdUT) / 86400;
168
+ const T = (jdTT - j2000JD) / julianCenturyDays;
169
+ const T2 = T * T;
170
+ const args = argsAt(jdTT);
171
+ const LpMean = args.Lp;
172
+ const Dr = args.D * D2R, Mr = args.M * D2R, Mpr = args.Mp * D2R, Fr = args.F * D2R;
173
+ // Original-JD call shape — (j2000JD + (jdTT − j2000JD)) is NOT bit-exactly
174
+ // jdTT, so the truncated path keeps its own E-factor entry.
175
+ const E = eFactorAtJdTT(jdTT, T, T2);
176
+ const E2 = E * E;
177
+ let Sl = sumTable(moonL, Dr, Mr, Mpr, Fr, E, E2);
178
+ const A1 = (119.75 + 131.849 * T) * D2R;
179
+ const A2 = (53.09 + (isFrameworkNative() ? fwA2RateDegPerCy : 479264.290) * T) * D2R;
180
+ Sl += 3958 * Math.sin(A1) + 1962 * Math.sin(LpMean * D2R - Fr) + 318 * Math.sin(A2);
181
+ return (((LpMean + Sl * 1e-6) % 360) + 360) % 360;
182
+ }
183
+
184
+ /** TRUNCATED eclipse-finder latitude (doc 66 §Layer 1b): table only — the
185
+ * six additional corrections are DELIBERATELY absent (the certified
186
+ * eclipse statistics were produced with this form; the ~2.2 mdeg
187
+ * −2235·sin(Lp) family would move knife-edge canon events).
188
+ * @param {number} jdUT @returns {number} degrees */
189
+ function truncatedBetaDeg(jdUT) {
190
+ const jdTT = jdUT + deltaTSeconds(jdUT) / 86400;
191
+ const T = (jdTT - j2000JD) / julianCenturyDays;
192
+ const T2 = T * T;
193
+ const args = argsAt(jdTT);
194
+ const Dr = args.D * D2R, Mr = args.M * D2R, Mpr = args.Mp * D2R, Fr = args.F * D2R;
195
+ const E = eFactorAtJdTT(jdTT, T, T2);
196
+ const E2 = E * E;
197
+ return sumTable(moonB, Dr, Mr, Mpr, Fr, E, E2) * 1e-6;
198
+ }
199
+
200
+ /** Two-term ellipse distance at JD_UT (shared with the scene form).
201
+ * @param {number} jdUT @returns {number} km */
202
+ function truncatedDistanceKm(jdUT) {
203
+ const jdTT = jdUT + deltaTSeconds(jdUT) / 86400;
204
+ const Mpr = argsAt(jdTT).Mp * D2R;
205
+ return getMoonDistanceKm() * (1 - getEccentricityBase() * Math.cos(Mpr));
206
+ }
207
+
208
+ return { sceneEvalAt, truncatedLonDeg, truncatedBetaDeg, truncatedDistanceKm, additionalArgs };
209
+ }
210
+
211
+ module.exports = { createMoonSeries };
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Integrated-phase machinery — THE shared implementation (Phase 7.1).
3
+ *
4
+ * The cumulative ∫1/H(t)dt table and everything derived from it: cycle counts,
5
+ * the J2000 drift correction, and the inverse lookup. Extracted VERBATIM from
6
+ * tools/lib/deep-time.js, which was itself the port of src/script.js — this
7
+ * file replaces both copies (and is the reference the website's deepTime.ts
8
+ * must match; Phase E measured what happens when it doesn't).
9
+ *
10
+ * CommonJS on purpose: `tools/lib` is CJS and cannot require an ESM module
11
+ * synchronously; ESM (`src/script.js`, bundlers) imports CJS natively. One
12
+ * file, every engine.
13
+ *
14
+ * THE CONVENTIONS ARE LOAD-BEARING — the fitted coefficients were produced
15
+ * against THIS discretization, so the numerics ship with the coefficients:
16
+ *
17
+ * - TRAPEZOID rule on 10-kyr cells with LINEAR interpolation between cells.
18
+ * An analytically better integrator is WRONG here: the website's adaptive
19
+ * Simpson disagreed by ~1e-6 cycles at −280 kyr = ~18 s of cardinal-point
20
+ * JD through the ×16 braid phase (Gate E measured 24.7 s before the fix).
21
+ * - R2: the table must be built under the LATTICE α — the CONSTANT moment of
22
+ * inertia, not the climate-modulated α(t). The `holisticHAtAgeMa` the
23
+ * caller injects must already be pinned (see each engine's construction
24
+ * site). The always-on climate modulation integrates into a linear phase
25
+ * drift (the same ~18 s class — the dominant Phase E bug).
26
+ * - R3: the drift-correction anchor is identified by CALL SHAPE, never by
27
+ * distance from J2000 — the old distance heuristic put a 2·drift
28
+ * = 6.80e-5-cycle STEP DISCONTINUITY at yearB == yearA, worth 0.393 d of
29
+ * apsis displacement and 100% of 6d's anomalistic residual.
30
+ * - The table zero sits at the cell nearest `tableAnchorYear` (2000.5,
31
+ * startmodelYear); the drift reference is `driftRefYear`
32
+ * (startModelYearWithCorrection ≈ 2000.4977) — TWO different constants,
33
+ * half a day apart. Do not "unify" them.
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ /**
39
+ * @param {{
40
+ * holisticHAtAgeMa: (tMa: number) => (number | null),
41
+ * tableAnchorYear: number,
42
+ * driftRefYear: number,
43
+ * hJ2000: number,
44
+ * yearMin?: number,
45
+ * yearMax?: number,
46
+ * stepYears?: number,
47
+ * }} cfg — holisticHAtAgeMa MUST be the lattice-α form; tableAnchorYear is
48
+ * startmodelYear (2000.5, table zero + t_Ma convention); driftRefYear is
49
+ * startModelYearWithCorrection (the R3 drift anchor); hJ2000 the snapshot
50
+ * rate the drift compares against; 10-kyr cells match both engines'
51
+ * historic tables.
52
+ */
53
+ function createPhaseMachinery({
54
+ holisticHAtAgeMa,
55
+ tableAnchorYear,
56
+ driftRefYear,
57
+ hJ2000,
58
+ yearMin = -500e6,
59
+ yearMax = 500e6,
60
+ stepYears = 10000,
61
+ }) {
62
+ /** @type {Float64Array | null} */
63
+ let table = null;
64
+ let j2000Idx = -1;
65
+
66
+ function ensureTable() {
67
+ if (table !== null) return;
68
+ const N = Math.ceil((yearMax - yearMin) / stepYears) + 1;
69
+ const t = new Float64Array(N);
70
+ j2000Idx = Math.round((tableAnchorYear - yearMin) / stepYears);
71
+
72
+ /** @param {number} year */
73
+ const invH = (year) => {
74
+ const t_Ma = (tableAnchorYear - year) / 1e6;
75
+ const H = holisticHAtAgeMa(t_Ma);
76
+ return H === null ? null : 1 / H;
77
+ };
78
+
79
+ t[j2000Idx] = 0;
80
+ let prev = invH(yearMin + j2000Idx * stepYears);
81
+ for (let i = j2000Idx + 1; i < N; i++) {
82
+ const year_i = yearMin + i * stepYears;
83
+ const curr = invH(year_i);
84
+ t[i] = (prev !== null && curr !== null)
85
+ ? t[i - 1] + 0.5 * (prev + curr) * stepYears
86
+ : NaN;
87
+ prev = curr;
88
+ }
89
+ prev = invH(yearMin + j2000Idx * stepYears);
90
+ for (let i = j2000Idx - 1; i >= 0; i--) {
91
+ const year_i = yearMin + i * stepYears;
92
+ const curr = invH(year_i);
93
+ t[i] = (prev !== null && curr !== null)
94
+ ? t[i + 1] - 0.5 * (prev + curr) * stepYears
95
+ : NaN;
96
+ prev = curr;
97
+ }
98
+ table = t;
99
+ }
100
+
101
+ /** Cumulative ∫1/H at a year (linear interpolation). null outside the range
102
+ * or where the physics is undefined (past the tidal-lock asymptote).
103
+ * @param {number} year @returns {number | null} */
104
+ function cumulAtYear(year) {
105
+ ensureTable();
106
+ if (year < yearMin || year > yearMax) return null;
107
+ const t = /** @type {Float64Array} */ (table);
108
+ const idx_f = (year - yearMin) / stepYears;
109
+ const idx_lo = Math.floor(idx_f);
110
+ const idx_hi = Math.min(idx_lo + 1, t.length - 1);
111
+ const v_lo = t[idx_lo];
112
+ const v_hi = t[idx_hi];
113
+ if (Number.isNaN(v_lo) || Number.isNaN(v_hi)) return null;
114
+ return v_lo + (idx_f - idx_lo) * (v_hi - v_lo);
115
+ }
116
+
117
+ /** ∫_{yearA}^{yearB} 1/H(t') dt'. null if either endpoint is out of domain.
118
+ * @param {number} yearA @param {number} yearB @returns {number | null} */
119
+ function integralBetween(yearA, yearB) {
120
+ if (yearA === yearB) return 0;
121
+ const cA = cumulAtYear(yearA);
122
+ const cB = cumulAtYear(yearB);
123
+ if (cA === null || cB === null) return null;
124
+ return cB - cA;
125
+ }
126
+
127
+ /** How far the integrated form has drifted from the J2000-snapshot form over
128
+ * (yearA → driftRefYear). Subtracting it at the anchor restores the
129
+ * snapshot-fitted harmonic calibration at J2000 without changing the
130
+ * integrated form's shape at deep time.
131
+ * @param {number} yearA @returns {number} */
132
+ function j2000Drift(yearA) {
133
+ const integral = integralBetween(yearA, driftRefYear);
134
+ if (integral === null) return 0;
135
+ const snapshot = (driftRefYear - yearA) / hJ2000;
136
+ return integral - snapshot;
137
+ }
138
+
139
+ /** Total cycles between two years for a cycle of period H/divisorN.
140
+ * R3: the anchor is identified from the CALL SHAPE — two shapes exist,
141
+ * (anchor, movingYear, N) and (J2000, anchor, N) where yearA IS the J2000
142
+ * reference itself. The correction depends only on a FIXED endpoint, so it
143
+ * is constant across any scan. Returns null past the tidal-lock asymptote.
144
+ * @param {number} yearA @param {number} yearB @param {number} divisorN
145
+ * @returns {number | null} */
146
+ function cyclesBetween(yearA, yearB, divisorN) {
147
+ const integral = integralBetween(yearA, yearB);
148
+ if (integral === null) return null;
149
+ const anchorIsA = (yearA !== driftRefYear);
150
+ const correction = anchorIsA ? j2000Drift(yearA) : -j2000Drift(yearB);
151
+ return divisorN * (integral - correction);
152
+ }
153
+
154
+ /** Inverse of cumulAtYear — the year at a given cumulative ∫1/H (binary
155
+ * search over the monotone table). null outside the table domain.
156
+ * @param {number} targetCumul @returns {number | null} */
157
+ function yearAtCumul(targetCumul) {
158
+ ensureTable();
159
+ const t = /** @type {Float64Array} */ (table);
160
+ const N = t.length;
161
+ let lo = 0, hi = N - 1;
162
+ while (lo < N && Number.isNaN(t[lo])) lo++;
163
+ while (hi >= 0 && Number.isNaN(t[hi])) hi--;
164
+ if (lo >= hi) return null;
165
+ if (targetCumul < t[lo] || targetCumul > t[hi]) return null;
166
+ while (lo < hi - 1) {
167
+ const mid = (lo + hi) >> 1;
168
+ if (t[mid] <= targetCumul) lo = mid; else hi = mid;
169
+ }
170
+ const v_lo = t[lo], v_hi = t[hi];
171
+ const frac = (v_lo === v_hi) ? 0 : (targetCumul - v_lo) / (v_hi - v_lo);
172
+ return yearMin + (lo + frac) * stepYears;
173
+ }
174
+
175
+ /** Grid geometry — consumed by the engines' days/pos tables, which share
176
+ * this grid but keep their own integrands (they move at Phase 8). */
177
+ function grid() {
178
+ ensureTable();
179
+ const t = /** @type {Float64Array} */ (table);
180
+ return { yearMin, yearMax, stepYears, j2000Idx, length: t.length };
181
+ }
182
+
183
+ return { ensureTable, cumulAtYear, integralBetween, j2000Drift, cyclesBetween, yearAtCumul, grid };
184
+ }
185
+
186
+ module.exports = { createPhaseMachinery };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Dynamic ascending node — rate-based segment integration (Phase 8.3, L5).
3
+ *
4
+ * dΩ/dε = −sin(Ω)/tan(i), integrated over obliquity change with segment
5
+ * handling at obliquity extrema and Earth-inclination crossovers (the
6
+ * inclination DIRECTION flips the sign of the effect per segment).
7
+ *
8
+ * Extracted from tools/lib/orbital-engine.js
9
+ * calculateDynamicAscendingNodeFromTilts (itself the mirror of the
10
+ * browser's 6-arg variant — S-P5). The BROWSER variant remains engine-side
11
+ * this slice: neither copy is fixture-pinned, so the signature unification
12
+ * is deferred to the factory pass, WITH probes first (recorded follow-up).
13
+ *
14
+ * §2h BOUNDARY: this module takes {ascendingNodeDeg, inclinationDeg} — the
15
+ * Tychosium `orbitTilta/orbitTiltb` scheme names never enter the package;
16
+ * the engines own the atan2/hypot decomposition at their edge.
17
+ *
18
+ * All time-dependent inputs are INJECTED per call: the engine's obliquity
19
+ * and Earth-inclination evaluators, the precomputed obliquity-extrema
20
+ * lookup, the inclination-crossing finder, and (optionally) the per-planet
21
+ * dynamic ecliptic inclination.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ /**
27
+ * @param {{ ascendingNodeDeg: number, inclinationDeg: number }} tilt —
28
+ * static node and inclination (engine-decomposed from its scene scheme)
29
+ * @param {number} currentYear
30
+ * @param {{
31
+ * obliquityAt: (year: number) => number,
32
+ * earthInclinationAt: (year: number) => number,
33
+ * obliquityExtremaInRange: (yearMin: number, yearMax: number) => number[],
34
+ * inclinationCrossingsInRange: (inclinationDeg: number, yearMin: number, yearMax: number) => number[],
35
+ * eclipticInclinationAt: ((year: number) => number) | null,
36
+ * earthInclinationMeanDeg: number,
37
+ * earthInclinationAmplitudeDeg: number,
38
+ * epochYear?: number,
39
+ * }} deps — eclipticInclinationAt non-null enables the dynamic-inclination
40
+ * crossover search and per-segment rates (the planetName path).
41
+ * @returns {number} dynamic ascending node longitude, degrees 0–360
42
+ */
43
+ function integrateAscendingNode(tilt, currentYear, deps) {
44
+ const DEG2RAD = Math.PI / 180;
45
+ const RAD2DEG = 180 / Math.PI;
46
+
47
+ const staticOmega = ((tilt.ascendingNodeDeg % 360) + 360) % 360;
48
+ const planetInclination = tilt.inclinationDeg;
49
+
50
+ if (planetInclination < 1e-6) return staticOmega;
51
+
52
+ const i = planetInclination * DEG2RAD;
53
+ const OmegaRad = staticOmega * DEG2RAD;
54
+ const tanI = Math.tan(i);
55
+ if (Math.abs(tanI) < 1e-10) return staticOmega;
56
+
57
+ const sinOmega = Math.sin(OmegaRad);
58
+ const EPOCH_YEAR = deps.epochYear !== undefined ? deps.epochYear : 2000;
59
+
60
+ /** @param {number} fromYear @param {number} toYear @returns {number} */
61
+ const integrateEffect = (fromYear, toYear) => {
62
+ if (Math.abs(toYear - fromYear) < 0.1) return 0;
63
+
64
+ const yearMin = Math.min(fromYear, toYear);
65
+ const yearMax = Math.max(fromYear, toYear);
66
+ const dir = toYear >= fromYear ? 1 : -1; // integration direction — signs the total
67
+
68
+ /** @type {number[]} */
69
+ let criticalYears = [yearMin, yearMax];
70
+
71
+ criticalYears.push(...deps.obliquityExtremaInRange(yearMin, yearMax));
72
+
73
+ // Find ALL inclination crossings
74
+ const minEarthIncl = deps.earthInclinationMeanDeg - deps.earthInclinationAmplitudeDeg;
75
+ const maxEarthIncl = deps.earthInclinationMeanDeg + deps.earthInclinationAmplitudeDeg;
76
+
77
+ // With a dynamic ecliptic inclination the planet can enter Earth's range
78
+ // even if the static value is outside — always search for crossovers.
79
+ if (deps.eclipticInclinationAt || (planetInclination >= minEarthIncl && planetInclination <= maxEarthIncl)) {
80
+ const crossIncl = deps.eclipticInclinationAt
81
+ ? deps.eclipticInclinationAt((yearMin + yearMax) / 2)
82
+ : planetInclination;
83
+ criticalYears.push(...deps.inclinationCrossingsInRange(crossIncl, yearMin, yearMax));
84
+ }
85
+
86
+ criticalYears = [...new Set(criticalYears)].sort((a, b) => a - b);
87
+
88
+ // Integrate over segments
89
+ let effect = 0;
90
+ for (let idx = 0; idx < criticalYears.length - 1; idx++) {
91
+ const segStart = criticalYears[idx];
92
+ const segEnd = criticalYears[idx + 1];
93
+
94
+ const oblStart = deps.obliquityAt(segStart);
95
+ const oblEnd = deps.obliquityAt(segEnd);
96
+ const deltaObl = (oblEnd - oblStart) * DEG2RAD;
97
+
98
+ const midYear = (segStart + segEnd) / 2;
99
+ const earthInclAtMid = deps.earthInclinationAt(midYear);
100
+
101
+ const dynIncl = deps.eclipticInclinationAt
102
+ ? deps.eclipticInclinationAt(midYear)
103
+ : planetInclination;
104
+ const inclDirection = earthInclAtMid > dynIncl ? 1 : -1;
105
+ const dynTanI = Math.tan(dynIncl * DEG2RAD);
106
+ if (Math.abs(dynTanI) < 1e-10) continue; // skip near-zero inclination
107
+ const segRate = -sinOmega / dynTanI;
108
+
109
+ effect += segRate * inclDirection * deltaObl * RAD2DEG;
110
+ }
111
+ return effect * dir;
112
+ };
113
+
114
+ const effectFromEpoch = integrateEffect(EPOCH_YEAR, currentYear);
115
+
116
+ let newOmega = staticOmega + effectFromEpoch;
117
+ return ((newOmega % 360) + 360) % 360;
118
+ }
119
+
120
+ module.exports = { integrateAscendingNode };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Fitted post-hoc corrections — THE shared evaluators (Phase 8.3, L8).
3
+ *
4
+ * The parallax RA/Dec basis: ~80 fitted coefficient slots (A..CA) over a
5
+ * geometric state (orbital phase u from the ascending node, inverse
6
+ * geocentric distance, inverse heliocentric distance, Julian centuries,
7
+ * the triple-synodic conjunction phase, the Sun mean longitude, and the
8
+ * inner-planet mean anomaly). ONE basis served four hand-copies: browser
9
+ * dec, browser RA, Node dec, Node RA (~80 duplicated terms each).
10
+ *
11
+ * MATCHED PAIR: these coefficients were fitted against exactly this basis
12
+ * and this state derivation — the expression text is transcribed verbatim
13
+ * (including the (x || 0) missing-slot guards and the AU_ key, which
14
+ * avoids a collision with the AU unit name). Do not simplify.
15
+ *
16
+ * The engines OWN the state derivation (scene vectors, JD conventions) and
17
+ * the application sign convention (RA subtracts, Dec adds) — this module
18
+ * only evaluates.
19
+ *
20
+ * GRAVITATION (planet-planet perturbation harmonics): evaluated here PER
21
+ * TERM — the engines apply each term inside their own loops, because
22
+ * (x−a)−b ≠ x−(a+b) at the last bit and the fixtures pin the per-term
23
+ * application order.
24
+ *
25
+ * ELONGATION (elongation × Earth-perihelion geometry, 21 fitted slots per
26
+ * axis): BROWSER ASSOCIATION FORM — the six d² slots per table use the
27
+ * precomputed invD² ((a·b)·invD²); the Node mirror carried inline
28
+ * invD·invD (((a·b)·invD)·invD) at those sites — same 1-ULP class as the
29
+ * five parallax slots, measured by the fixture recorders at extraction.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ /**
35
+ * @typedef {{ u: number, invD: number, invS: number, T: number,
36
+ * cp: number, Lsun: number,
37
+ * sinM: number, cosM: number, sin2M: number, cos2M: number }} ParallaxState
38
+ */
39
+
40
+ /**
41
+ * Evaluate the fitted parallax basis for one coefficient table (RA or Dec —
42
+ * same basis, different tables). Returns DEGREES (the engines apply their
43
+ * own sign and rad conversion).
44
+ * @param {Record<string, number>} c @param {ParallaxState} s
45
+ * @returns {number} */
46
+ function evaluateParallaxBasis(c, s) {
47
+ const { u: _u, invD: _invD, invS: _invS, T: _T, Lsun: _Lsun } = s;
48
+ const _invD2 = _invD * _invD;
49
+ const _invD3 = _invD2 * _invD;
50
+ const _invS2 = _invS * _invS;
51
+ const _invDS = _invD * _invS;
52
+ const _sinU = Math.sin(_u), _cosU = Math.cos(_u);
53
+ const _sin2U = Math.sin(2 * _u), _cos2U = Math.cos(2 * _u);
54
+ const _sin3U = Math.sin(3 * _u), _cos3U = Math.cos(3 * _u);
55
+ const _sinCP = Math.sin(s.cp), _cosCP = Math.cos(s.cp);
56
+ const _sin2CP = Math.sin(2 * s.cp), _cos2CP = Math.cos(2 * s.cp);
57
+ const _sinL = Math.sin(_Lsun), _cosL = Math.cos(_Lsun);
58
+ const _sinM = s.sinM, _cosM = s.cosM, _sin2M = s.sin2M, _cos2M = s.cos2M;
59
+ return c.A + c.B * _invD + (c.C || 0) * _T
60
+ + (c.D * _sinU + c.E * _cosU + c.F * _sin2U + c.G * _cos2U
61
+ + (c.H || 0) * _sin3U + (c.I || 0) * _cos3U) * _invD
62
+ + _T * ((c.J || 0) * _sinU + (c.K || 0) * _cosU) * _invD
63
+ + (c.L || 0) * _invS + (c.M || 0) * _sinU * _invD2
64
+ + (c.N || 0) * _sin2U * _invS + (c.O || 0) * _cosU * _invS
65
+ + (c.P || 0) * _T * _sin2U * _invD + (c.Q || 0) * _T * _cos2U * _invD
66
+ + (c.R || 0) * _T * _sinU * _invS
67
+ + (c.S || 0) * _T * _invD + (c.U || 0) * _cosU * _invD2
68
+ + (c.V || 0) * _invS2 + (c.W || 0) * _sinU * _invS2
69
+ + (c.X || 0) * _cos3U * _invS + (c.Y || 0) * _sin3U * _invS
70
+ + (c.Z || 0) * _invDS + (c.AA || 0) * _sinU * _invDS
71
+ + (c.AB || 0) * _cos2U * _invDS + (c.AC || 0) * _T * _sin2U * _invS
72
+ + (c.AD || 0) * _cos3U * _invD2 + (c.AE || 0) * _sin2U * _invS2
73
+ + (c.AF || 0) * _sin3U * _invS2 + (c.AG || 0) * _cos3U * _invS2
74
+ + (c.AH || 0) * _cosU * _invS2 + (c.AI || 0) * _sinU * _invD2 * _invS
75
+ + (c.AJ || 0) * Math.cos(4 * _u) * _invS + (c.AK || 0) * _sin2U * _invD2 * _invS
76
+ + (c.AL || 0) * Math.sin(4 * _u) * _invD + (c.AM || 0) * Math.cos(4 * _u) * _invD
77
+ + (c.AN || 0) * _T * _sinU * _invD2 + (c.AO || 0) * _T * _cosU * _invD2
78
+ + (c.AP || 0) * _sinU * _invD2 * _invD + (c.AQ || 0) * _cosU * _invD2 * _invD
79
+ + (c.AR || 0) * _sinCP + (c.AS || 0) * _cosCP
80
+ + (c.AT || 0) * _sin2CP + (c.AU_ || 0) * _cos2CP
81
+ + (c.AV || 0) * _sinCP * _invD + (c.AW || 0) * _cosCP * _invD
82
+ + (c.AX || 0) * _sinL * _invD + (c.AY || 0) * _cosL * _invD
83
+ + (c.AZ || 0) * _sinL + (c.BA || 0) * _cosL
84
+ + (c.BB || 0) * _T * _sinL * _invD + (c.BC || 0) * _T * _cosL * _invD
85
+ + (c.BD || 0) * _T * _sinL + (c.BE || 0) * _T * _cosL
86
+ + (c.BF || 0) * _cosU * _sinL * _invD2 + (c.BG || 0) * _cosU * _cosL * _invD2
87
+ + (c.BH || 0) * _sinL * _invD3 + (c.BI || 0) * _cosL * _invD3
88
+ + (c.BJ || 0) * Math.sin(_u - _Lsun) * _invD2 + (c.BK || 0) * Math.cos(_u - _Lsun) * _invD2
89
+ + (c.BL || 0) * _T * _T * _invD + (c.BM || 0) * _T * _T * _sinU * _invD + (c.BN || 0) * _T * _T * _cosU * _invD
90
+ + (c.BO || 0) * _sin2U * _invD3 + (c.BP || 0) * _cos2U * _invD3
91
+ + (c.BQ || 0) * _sinU * _invD3 * _invD
92
+ + (c.BR || 0) * _sinM * _invD + (c.BS || 0) * _cosM * _invD
93
+ + (c.BT || 0) * _sin2M * _invD + (c.BU || 0) * _cos2M * _invD
94
+ + (c.BV || 0) * _sinM + (c.BW || 0) * _cosM
95
+ + (c.BX || 0) * _sin2M + (c.BY || 0) * _cos2M
96
+ + (c.BZ || 0) * _sinM * _invD2 + (c.CA || 0) * _cosM * _invD2;
97
+ }
98
+
99
+ /**
100
+ * Gravitation-correction term deltas, in DEGREES, one entry per fitted
101
+ * term. The engines apply these PER TERM (sign convention and the
102
+ * degrees→radians conversion stay engine-side).
103
+ * @param {Array<{ period: number, raSin: number, raCos: number,
104
+ * decSin: number, decCos: number }>} terms
105
+ * @param {number} yearsFrom2000
106
+ * @returns {Array<{ raDeg: number, decDeg: number }>} */
107
+ function gravitationTermDeltasDeg(terms, yearsFrom2000) {
108
+ const out = [];
109
+ for (const term of terms) {
110
+ const phase = 2 * Math.PI * yearsFrom2000 / term.period;
111
+ const sp = Math.sin(phase), cp = Math.cos(phase);
112
+ out.push({
113
+ raDeg: term.raSin * sp + term.raCos * cp,
114
+ decDeg: term.decSin * sp + term.decCos * cp,
115
+ });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * @typedef {{ elongRad: number, vFromWERad: number, synPhaseRad: number,
122
+ * invD: number }} ElongationState
123
+ */
124
+
125
+ /**
126
+ * Evaluate the fitted 21-slot elongation basis for one axis. Returns
127
+ * DEGREES; `suffix` selects the coefficient table ('ra' or 'dec' — same
128
+ * basis, different fitted slots). The engines derive the state (frame Sun
129
+ * RA, Earth-perihelion angle, exact synodic phase from the integer orbit
130
+ * count) and apply their own sign and rad conversion.
131
+ * @param {Record<string, number>} vc @param {ElongationState} s
132
+ * @param {'ra'|'dec'} suffix
133
+ * @returns {number} */
134
+ function evaluateElongationBasis(vc, s, suffix) {
135
+ const sinEl = Math.sin(s.elongRad), cosEl = Math.cos(s.elongRad);
136
+ const cosVwE = Math.cos(s.vFromWERad), sinVwE = Math.sin(s.vFromWERad);
137
+ const sin2VwE = Math.sin(2 * s.vFromWERad), cos2VwE = Math.cos(2 * s.vFromWERad);
138
+ const sin3VwE = Math.sin(3 * s.vFromWERad), cos3VwE = Math.cos(3 * s.vFromWERad);
139
+ const sin4VwE = Math.sin(4 * s.vFromWERad), cos4VwE = Math.cos(4 * s.vFromWERad);
140
+ const invD = s.invD;
141
+ const invD2 = invD * invD;
142
+ /** @param {string} k @returns {number} */
143
+ const c = (k) => vc[k + suffix] || 0;
144
+ return c('cosVwE_sinEl_') * cosVwE * sinEl
145
+ + c('sinEl_d_') * sinEl * invD
146
+ + c('sinVwE_sinEl_') * sinVwE * sinEl
147
+ + c('sin2VwE_sinEl_') * sin2VwE * sinEl
148
+ + c('cos2VwE_sinEl_') * cos2VwE * sinEl
149
+ + c('cos4VwE_sinEl_') * cos4VwE * sinEl
150
+ + c('sin4VwE_sinEl_') * sin4VwE * sinEl
151
+ + c('sinVwE_sinEl_d2_') * sinVwE * sinEl * invD2
152
+ + c('cos3VwE_sinEl_') * cos3VwE * sinEl
153
+ + c('sin3VwE_sinEl_') * sin3VwE * sinEl
154
+ + c('sin2syn_') * Math.sin(2 * s.synPhaseRad)
155
+ + c('cos1syn_') * Math.cos(s.synPhaseRad)
156
+ + c('sin3VwE_sinEl_d2_') * sin3VwE * sinEl * invD2
157
+ + c('sin2VwE_sinEl_d2_') * sin2VwE * sinEl * invD2
158
+ + c('cos2VwE_sinEl_d2_') * cos2VwE * sinEl * invD2
159
+ + c('cosEl_d_') * cosEl * invD
160
+ + c('cosVwE_cosEl_d_') * cosVwE * cosEl * invD
161
+ + c('sinVwE_cosEl_d_') * sinVwE * cosEl * invD
162
+ + c('cosEl_d2_') * cosEl * invD2
163
+ + c('cosVwE_cosEl_d2_') * cosVwE * cosEl * invD2
164
+ + c('sinVwE_cosEl_d2_') * sinVwE * cosEl * invD2;
165
+ }
166
+
167
+ module.exports = { evaluateParallaxBasis, gravitationTermDeltasDeg, evaluateElongationBasis };