@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,320 @@
1
+ /**
2
+ * Framework-native lunar fundamental arguments — THE shared implementation
3
+ * (Phase 8.2-5). The measured argument-decomposition recipe of doc 66 §1:
4
+ * {Lp, D, M, Mp, F} in degrees, of-date, Meeus Ch. 47 convention.
5
+ *
6
+ * Extracted VERBATIM from src/script.js (_FW_MOON, _FW_SUN_SEC,
7
+ * _fwSunSecularDeviations, the two bounded Lp carriers, _fwMoonArgsDeep,
8
+ * _fwMoonArgs, and the pure-Meeus reference polynomials), which
9
+ * tools/lib/scene-graph.js mirrored. Mirror gaps closed here:
10
+ *
11
+ * - S3: the Sun secular deviations' year coordinate. The browser used the
12
+ * full Julian/Gregorian calendar conversion; the tools mirror used a
13
+ * linear approximation. The calendar conversion is EMBEDDED here as a
14
+ * private helper, so both engines now evaluate the same coordinate.
15
+ * - S9: ϖ/Ω composition order in the deep branch (wrapper-composed vs
16
+ * use-site subtraction) — one form now, the browser's.
17
+ * - S10: P_DEGCY was derived from the browser's MUTABLE holisticyearLength
18
+ * global (a latent epoch dependency in a J2000-frozen bundle) — it is
19
+ * built from the injected holisticYearJ2000 and has no consumers; kept
20
+ * as documentation of the frame decomposition.
21
+ *
22
+ * Construction (docs/66 §1, abridged): linear rates are observational J2000
23
+ * anchors; perigee/node ride the PHASE-AWARE e_E channel integral (s_ϖ
24
+ * 2.407 / s_Ω 1.018, Meeus-effective); Lp's planetary T² remainder rides
25
+ * the bounded e_E² carrier + the obliquity-line carrier (K_PL derived
26
+ * lazily from the closed v4 budget — zero new constants); D and M are
27
+ * identity-composed (D ≡ Lp − L_sun, M ≡ L_sun − ϖ_sun) with secular
28
+ * content from the closed-form year-length-harmonic integrals. Deep time:
29
+ * the SAME factored-law chains that phase the scene layers supply the
30
+ * skeleton (always-chains, Stage B); snapshot keeps the certified
31
+ * polynomial skeleton, clamped at |T| ≤ 100 cy (unclamped, the fitted T⁴
32
+ * tail reverses the lunar mean motion at year ≈ 1.99e6).
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ /**
38
+ * Astronomical JD → decimal year: Julian calendar before 1582-10-15,
39
+ * Gregorian after (Meeus Ch. 7 inverse). The S3-aligned coordinate for the
40
+ * Sun secular deviations. @param {number} jd @returns {number}
41
+ */
42
+ function jdToDecimalYear(jd) {
43
+ const J = jd + 0.5;
44
+ const Z = Math.floor(J);
45
+ const F = J - Z;
46
+ let A = Z;
47
+ if (Z >= 2299161) {
48
+ const alpha = Math.floor((Z - 1867216.25) / 36524.25);
49
+ A = Z + 1 + alpha - Math.floor(alpha / 4);
50
+ }
51
+ const B = A + 1524;
52
+ const C = Math.floor((B - 122.1) / 365.25);
53
+ const D = Math.floor(365.25 * C);
54
+ const E = Math.floor((B - D) / 30.6001);
55
+ const day = B - D - Math.floor(30.6001 * E) + F;
56
+ const month = (E < 14) ? E - 1 : E - 13;
57
+ const year = (month > 2) ? C - 4716 : C - 4715;
58
+ const isGregorian = (Z >= 2299161);
59
+ const isLeap = isGregorian
60
+ ? (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0))
61
+ : (year % 4 === 0);
62
+ const monthLengths = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
63
+ let dayOfYear = day;
64
+ for (let m = 0; m < month - 1; m++) dayOfYear += monthLengths[m];
65
+ const daysInYear = isLeap ? 366 : 365;
66
+ return year + (dayOfYear - 1) / daysInYear;
67
+ }
68
+
69
+ /**
70
+ * @typedef {{Lp: number, D: number, M: number, Mp: number, F: number}} MoonArgsDeg
71
+ */
72
+
73
+ /**
74
+ * @param {{
75
+ * constants: {
76
+ * j2000JD: number,
77
+ * julianCenturyDays: number,
78
+ * holisticYearJ2000: number,
79
+ * balancedYearJ2000: number,
80
+ * meanSolarYearDays: number,
81
+ * meanAnomalisticYearDays: number,
82
+ * tropicalYearHarmonics: Array<[number, number, number]>,
83
+ * anomalisticYearHarmonics: Array<[number, number, number]>,
84
+ * eccentricityJ2000: number,
85
+ * eccentricityDotJ2000: number,
86
+ * eccentricityDotDotJ2000: number,
87
+ * elpEarthFigureJ2ArcsecPerCy2: number,
88
+ * elpGeneralPrecessionPA_T2ArcsecPerCy2: number,
89
+ * eccE0: number,
90
+ * },
91
+ * fns: {
92
+ * eccAt: (tYr: number) => number,
93
+ * channelIntegral: (T: number, s: number) => number,
94
+ * computeObliquityEarth: (year: number) => number,
95
+ * jdToSIyear: (jd: number) => number,
96
+ * tropicalOrbitsBetween: (yearA: number, yearB: number) => (number | null),
97
+ * apsidalOfDateCyclesBetween: (yearA: number, yearB: number) => (number | null),
98
+ * nodalOfDateCyclesBetween: (yearA: number, yearB: number) => (number | null),
99
+ * cyclesBetween: (yearA: number, yearB: number, divisorN: number) => (number | null),
100
+ * isDeepTime: () => boolean,
101
+ * isFrameworkNative: () => boolean,
102
+ * },
103
+ * }} deps — fns are the ENGINE'S OWN chain wrappers (each engine's toggle
104
+ * semantics ride along); eccAt/channelIntegral are the shared moon ecc
105
+ * channel; constants are J2000-frozen injections.
106
+ */
107
+ function createMoonArguments({ constants, fns }) {
108
+ const {
109
+ j2000JD, julianCenturyDays, holisticYearJ2000, balancedYearJ2000,
110
+ meanSolarYearDays, meanAnomalisticYearDays,
111
+ tropicalYearHarmonics, anomalisticYearHarmonics,
112
+ eccentricityJ2000, eccentricityDotJ2000, eccentricityDotDotJ2000,
113
+ elpEarthFigureJ2ArcsecPerCy2, elpGeneralPrecessionPA_T2ArcsecPerCy2,
114
+ eccE0,
115
+ } = constants;
116
+ const {
117
+ eccAt, channelIntegral, computeObliquityEarth, jdToSIyear,
118
+ tropicalOrbitsBetween, apsidalOfDateCyclesBetween, nodalOfDateCyclesBetween,
119
+ cyclesBetween, isDeepTime, isFrameworkNative,
120
+ } = fns;
121
+
122
+ // ── The _FW_MOON bundle: Meeus Ch. 47 J2000 anchors + derived checks ─────
123
+ const bundle = (() => {
124
+ const LP0 = 218.3164477, D0 = 297.8501921, M0 = 357.5291092,
125
+ MP0 = 134.9633964, F0 = 93.2720950;
126
+ const LPR = 481267.88123421, DR = 445267.1114034, MR = 35999.0502909,
127
+ MPR = 477198.8675055, FR = 483202.0175233;
128
+ const P_DEGCY = 360 * 13 / holisticYearJ2000 * 100; // framework general precession, deg/Julian cy (S10: J2000-frozen by injection; no consumers — documentation of the frame decomposition)
129
+ const WDOT = LPR - MPR; // perigee ϖ̇ of-date (+4069.0137) = ϖ̇_ICRF + p
130
+ const NDOT = LPR - FR; // node Ω̇ of-date (−1934.1363) = Ω̇_ICRF + p
131
+ // e_E channel Taylor CHECKS (documented J2000 reference values — the live
132
+ // path is the phase-aware channelIntegral; T2_*/T3_* are NOT consumed):
133
+ const E0 = eccentricityJ2000, EDOT0 = eccentricityDotJ2000;
134
+ const KAPPA = 3 * E0 * EDOT0 / (1 - E0 * E0);
135
+ // v4 frame attribution: FRAME-EFFECTIVE exponents (physical pair after
136
+ // removing the IAU ṗ_A frame term: s_ϖ 2.479 / s_Ω 0.867).
137
+ const S_W = 2.407, S_N = 1.018;
138
+ const T2_W = S_W * WDOT * KAPPA / 2; // −0.010318 °/cy² (Meeus ϖ −0.010320)
139
+ const T2_N = S_N * NDOT * KAPPA / 2; // +0.0020752 °/cy² (Meeus Ω +0.0020753)
140
+ const EDDOT0 = eccentricityDotDotJ2000;
141
+ const KAPPA_DOT = 3 * (EDOT0 * EDOT0 + E0 * EDDOT0) / (1 - E0 * E0)
142
+ + 6 * E0 * E0 * EDOT0 * EDOT0 / Math.pow(1 - E0 * E0, 2);
143
+ const T3_W = WDOT * (S_W * S_W * KAPPA * KAPPA + S_W * KAPPA_DOT) / 6;
144
+ const T3_N = NDOT * (S_N * S_N * KAPPA * KAPPA + S_N * KAPPA_DOT) / 6;
145
+ // Lp carrier T²: framework tidal n̈/2 (LLR) + planetary secular remainder
146
+ const T2_LP_TIDAL = (-25.86 / 3600) / 2;
147
+ const T2_LP_PLANETARY = -0.0015786 - T2_LP_TIDAL; // +7.247″/cy²
148
+ const T2_LP = T2_LP_TIDAL + T2_LP_PLANETARY;
149
+ return { LP0, D0, M0, MP0, F0, LPR, DR, MR, P_DEGCY, WDOT, NDOT, T2_W, T2_N, T3_W, T3_N, T2_LP, T2_LP_TIDAL, S_W, S_N };
150
+ })();
151
+
152
+ // ── Sun secular deviations: closed-form year-length-harmonic integrals ───
153
+ // Normalized to value 0 AND slope 0 at J2000. KL/KA/KS capture the J2000
154
+ // mean-year values at construction by design; phases use the fixed J2000
155
+ // lattice (the documented snapshot approximation).
156
+ const sunSec = (() => {
157
+ const trop = tropicalYearHarmonics, anom = anomalisticYearHarmonics;
158
+ const PBAR = meanSolarYearDays, ABAR = meanAnomalisticYearDays, DBAR = ABAR - PBAR;
159
+ const KL = -(360 / PBAR);
160
+ const wbar = 360 * DBAR / ABAR;
161
+ const KA = wbar * PBAR / (ABAR * DBAR);
162
+ const KS = -wbar / DBAR;
163
+ /** @param {number} y @param {Array<[number, number, number]>} set */
164
+ const dP = (y, set) => {
165
+ const t = y - balancedYearJ2000; let s = 0;
166
+ for (const [d, sc, cc] of set) {
167
+ const w = 2 * Math.PI / (holisticYearJ2000 / d);
168
+ s += sc * Math.sin(w * t) + cc * Math.cos(w * t);
169
+ } return s;
170
+ };
171
+ /** @param {number} y @param {Array<[number, number, number]>} set */
172
+ const intdP = (y, set) => {
173
+ const t = y - balancedYearJ2000; let s = 0;
174
+ for (const [d, sc, cc] of set) {
175
+ const w = 2 * Math.PI / (holisticYearJ2000 / d);
176
+ s += (-sc * Math.cos(w * t) + cc * Math.sin(w * t)) / w;
177
+ } return s;
178
+ };
179
+ return { int0T: intdP(2000, trop), int0A: intdP(2000, anom),
180
+ slope0L: KL * dP(2000, trop),
181
+ slope0P: KA * dP(2000, anom) + KS * dP(2000, trop),
182
+ KL, KA, KS, intdP, trop, anom };
183
+ })();
184
+
185
+ /** @param {number} jdTT @returns {{dLs: number, dPeri: number}} */
186
+ function sunSecularDeviations(jdTT) {
187
+ const y = jdToDecimalYear(jdTT); // S3: the calendar coordinate, both engines
188
+ const S = sunSec;
189
+ const iT = S.intdP(y, S.trop) - S.int0T, iA = S.intdP(y, S.anom) - S.int0A;
190
+ return {
191
+ dLs: S.KL * iT - S.slope0L * (y - 2000),
192
+ dPeri: S.KA * iA + S.KS * iT - S.slope0P * (y - 2000),
193
+ };
194
+ }
195
+
196
+ // ── Bounded Lp carriers (K_PL / C_OBL derived lazily — zero new constants)
197
+ /** @type {number | null} */
198
+ let kPl = null;
199
+ /** @param {number} T @returns {number} */
200
+ function planetaryCarrier(T) {
201
+ if (T === 0) return 0;
202
+ if (kPl === null) {
203
+ const de2dT = Math.pow(eccAt(50), 2) - Math.pow(eccAt(-50), 2); // Δ(e²) per cy at J2000
204
+ const t2Obl = (elpEarthFigureJ2ArcsecPerCy2 + elpGeneralPrecessionPA_T2ArcsecPerCy2) / 3600;
205
+ kPl = 2 * (bundle.T2_LP - bundle.T2_LP_TIDAL - t2Obl) / de2dT;
206
+ }
207
+ // The channel's e0 anchor CONST — not eccAt(0), which under integrated
208
+ // phase carries the R3 drift correction (the 8.2-2 convention).
209
+ const e0sq = eccE0 * eccE0;
210
+ /** @param {number} t */
211
+ const f = (t) => { const e = eccAt(t * 100); return e * e - e0sq; };
212
+ const N = Math.max(2, 2 * Math.ceil(Math.abs(T) * 100 / 8000));
213
+ const h = T / N;
214
+ let sum = f(0) + f(T);
215
+ for (let i = 1; i < N; i++) sum += f(i * h) * (i % 2 ? 4 : 2);
216
+ return kPl * sum * h / 3;
217
+ }
218
+
219
+ /** @type {{eps0: number, C: number} | null} */
220
+ let obl = null;
221
+ /** @param {number} T @returns {number} */
222
+ function obliquityCarrier(T) {
223
+ if (T === 0) return 0;
224
+ if (obl === null) {
225
+ const T2_OBL = (elpEarthFigureJ2ArcsecPerCy2 + elpGeneralPrecessionPA_T2ArcsecPerCy2) / 3600;
226
+ const eps0 = computeObliquityEarth(2000);
227
+ const epsDot = computeObliquityEarth(2050) - computeObliquityEarth(1950);
228
+ obl = { eps0, C: 2 * T2_OBL / epsDot };
229
+ }
230
+ /** @param {number} t */
231
+ const f = (t) => computeObliquityEarth(2000 + t * 100) - /** @type {{eps0: number, C: number}} */ (obl).eps0;
232
+ const N = Math.max(2, 2 * Math.ceil(Math.abs(T) * 100 / 2000));
233
+ const h = T / N;
234
+ let sum = f(0) + f(T);
235
+ for (let i = 1; i < N; i++) sum += f(i * h) * (i % 2 ? 4 : 2);
236
+ return /** @type {{eps0: number, C: number}} */ (obl).C * sum * h / 3;
237
+ }
238
+
239
+ // ── Deep-time branch: always-chains (Stage B) ────────────────────────────
240
+ /** @type {number | null} */
241
+ let argsY0 = null;
242
+ /** @param {number} jd @returns {MoonArgsDeg | null} */
243
+ function fwArgsDeep(jd) {
244
+ const A = bundle;
245
+ if (argsY0 === null) argsY0 = jdToSIyear(j2000JD);
246
+ const y = jdToSIyear(jd);
247
+ const Ntrop = tropicalOrbitsBetween(argsY0, y);
248
+ const Naps = apsidalOfDateCyclesBetween(argsY0, y);
249
+ const Nnod = nodalOfDateCyclesBetween(argsY0, y);
250
+ const Nperi = cyclesBetween(argsY0, y, 16);
251
+ if (Ntrop === null || Naps === null || Nnod === null || Nperi === null) return null; // tidal-lock guard
252
+ /** @param {number} x */
253
+ const wrap = (x) => ((x % 360) + 360) % 360;
254
+ const dev = sunSecularDeviations(jd);
255
+ const Tj = (jd - j2000JD) / julianCenturyDays;
256
+ const Lp = A.LP0 + 360 * Ntrop + planetaryCarrier(Tj) + obliquityCarrier(Tj);
257
+ const w = (A.LP0 - A.MP0) + 360 * Naps; // perigee ϖ (of-date, advance)
258
+ const om = (A.LP0 - A.F0) - 360 * Nnod; // node Ω (of-date, regression)
259
+ const Lsun = (A.LP0 - A.D0) + 360 * (y - argsY0) + dev.dLs; // mean Sun (model timeline)
260
+ const ws = (A.LP0 - A.D0 - A.M0) + 360 * Nperi + dev.dPeri; // Sun perihelion (H/16 chain)
261
+ return {
262
+ Lp: wrap(Lp), D: wrap(Lp - Lsun), M: wrap(Lsun - ws),
263
+ Mp: wrap(Lp - w), F: wrap(Lp - om),
264
+ };
265
+ }
266
+
267
+ /** Framework-native args {Lp, D, M, Mp, F} (degrees, of-date) at JD_TT.
268
+ * Deep time: the chains; else the certified polynomial skeleton with the
269
+ * phase-aware channel perigee/node and identity-composed D/M.
270
+ * @param {number} jdTT @returns {MoonArgsDeg} */
271
+ function fwArgs(jdTT) {
272
+ if (isDeepTime()) {
273
+ const dt = fwArgsDeep(jdTT);
274
+ if (dt !== null) return dt;
275
+ }
276
+ const A = bundle;
277
+ const T = (jdTT - j2000JD) / julianCenturyDays;
278
+ /** @param {number} x */
279
+ const wrap = (x) => ((x % 360) + 360) % 360;
280
+ // Polynomial tails clamped at |T| ≤ 100 cy — see the header.
281
+ const Tc = Math.max(-100, Math.min(100, T));
282
+ const Tc2 = Tc * Tc, Tc3 = Tc2 * Tc, Tc4 = Tc3 * Tc;
283
+ const Lp = A.LP0 + A.LPR * T + A.T2_LP * Tc2 + Tc3 / 538841 - Tc4 / 65194000;
284
+ const w = (A.LP0 - A.MP0) + A.WDOT * (T + channelIntegral(T, A.S_W)); // perigee ϖ (of-date)
285
+ const om = (A.LP0 - A.F0) + A.NDOT * (T + channelIntegral(T, A.S_N)); // node Ω (of-date)
286
+ const dev = sunSecularDeviations(jdTT);
287
+ const Lsun = (A.LP0 - A.D0) + (A.LPR - A.DR) * T + dev.dLs; // of-date mean Sun
288
+ const ws = (A.LP0 - A.D0 - A.M0) + (A.LPR - A.DR - A.MR) * T + dev.dPeri; // of-date Sun perihelion
289
+ return {
290
+ Lp: wrap(Lp), D: wrap(Lp - Lsun), M: wrap(Lsun - ws),
291
+ Mp: wrap(Lp - w), F: wrap(Lp - om),
292
+ };
293
+ }
294
+
295
+ /** Pure Meeus Ch. 47 polynomial reference (A/B mode) — exact fractions.
296
+ * @param {number} jdTT @returns {MoonArgsDeg} */
297
+ function pureMeeusArgs(jdTT) {
298
+ const T = (jdTT - j2000JD) / julianCenturyDays;
299
+ const T2 = T * T, T3 = T2 * T, T4 = T3 * T;
300
+ /** @param {number} x */
301
+ const wrap = (x) => ((x % 360) + 360) % 360;
302
+ return {
303
+ Lp: wrap(218.3164477 + 481267.88123421 * T - 0.0015786 * T2 + T3 / 538841 - T4 / 65194000),
304
+ D: wrap(297.8501921 + 445267.1114034 * T - 0.0018819 * T2 + T3 / 545868 - T4 / 113065000),
305
+ M: wrap(357.5291092 + 35999.0502909 * T - 0.0001536 * T2 + T3 / 24490000),
306
+ Mp: wrap(134.9633964 + 477198.8675055 * T + 0.0087414 * T2 + T3 / 69699 - T4 / 14712000),
307
+ F: wrap( 93.2720950 + 483202.0175233 * T - 0.0036539 * T2 - T3 / 3526000 + T4 / 863310000),
308
+ };
309
+ }
310
+
311
+ /** Mode-dispatched arguments (probe hooks stay engine-side).
312
+ * @param {number} jdTT @returns {MoonArgsDeg} */
313
+ function argsAt(jdTT) {
314
+ return isFrameworkNative() ? fwArgs(jdTT) : pureMeeusArgs(jdTT);
315
+ }
316
+
317
+ return { argsAt, fwArgs, fwArgsDeep, pureMeeusArgs, sunSecularDeviations, planetaryCarrier, obliquityCarrier, bundle };
318
+ }
319
+
320
+ module.exports = { createMoonArguments, jdToDecimalYear };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The Moon-channel eccentricity line — THE shared implementation (Phase 8.2-2).
3
+ *
4
+ * ONE movement, FULLY DERIVED, zero solved values (doc 66 §"Framework-native
5
+ * e_E"): e(t) = eccentricityBase · (1 + cos θ(t) / 2), where θ rides the H/3
6
+ * wobble cycle that drives Earth's inclination. Mean = base (Law 5),
7
+ * amplitude = base/2, phase anchor θ₀ = ϖ_ICRF(J2000) − 21.77° = 81.18° past
8
+ * max. The observed J2000 e (−0.86%), ė (+1.7%) and the sign of ë are
9
+ * PREDICTIONS of this line, not inputs.
10
+ *
11
+ * NOT the same quantity as the Sun machinery's eccentricity: Earth's own
12
+ * orbital e for the cardinal-point/EoC path keeps the H/16 perihelion law
13
+ * (`cardinal`'s injected eccentricityAt). This line is the MOON channel's
14
+ * view of the H/3 movement — two different laws, deliberately.
15
+ *
16
+ * Extracted VERBATIM from src/script.js (_FW_ECC, _fwEarthEcc,
17
+ * _eCompModulation, _fwChannelIntegral, the framework branch of _fwEFactor),
18
+ * which tools/lib mirrored; this file replaces both copies. The 8.2-1
19
+ * alignment made the two engines bit-exact first, so this move is provably
20
+ * behaviour-preserving against the lunar golden masters.
21
+ *
22
+ * LOAD-BEARING conventions:
23
+ * - The injected `cyclesBetween` is the ENGINE'S OWN integrated-phase
24
+ * function (divisor 3 on the H lattice). Each engine's deep-time/snapshot
25
+ * toggle semantics ride along with it.
26
+ * - `e0`/`g0` are ANCHOR CONSTS by construction, never eccAt(0): under
27
+ * integrated phase, cycles(2000→2000) carries the R3 drift correction and
28
+ * is not exactly zero. The tools mirror once recomputed g0 from
29
+ * _fwEarthEcc(0) — unified here to the const.
30
+ * - channelIntegral uses composite Simpson with step ≤ ~4,000 yr (N ≥ 2,
31
+ * even) — the discretization the certified numbers were produced with.
32
+ * - Past the tidal-lock asymptote (cyclesBetween → null) eccAt returns the
33
+ * mean — the bounded continuation, not an error.
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ /**
39
+ * @param {{
40
+ * cyclesBetween: (yearA: number, yearB: number, divisorN: number) => (number | null),
41
+ * eccentricityBase: number,
42
+ * perihelionLongitudeJ2000Deg: number,
43
+ * inclinationCycleAnchorDeg: number,
44
+ * }} deps — cyclesBetween MUST be the engine's own H-lattice phase counter
45
+ * (integrated under deep time); perihelionLongitudeJ2000Deg is ϖ_ICRF at
46
+ * J2000 (102.94719°, the FULL value — a truncated copy once cost 0.684″);
47
+ * inclinationCycleAnchorDeg is the H/3 inclination anchor (21.77°).
48
+ */
49
+ function createMoonEccChannel({
50
+ cyclesBetween,
51
+ eccentricityBase,
52
+ perihelionLongitudeJ2000Deg,
53
+ inclinationCycleAnchorDeg,
54
+ }) {
55
+ const th0 = (perihelionLongitudeJ2000Deg - inclinationCycleAnchorDeg) * Math.PI / 180;
56
+ const amplitude = eccentricityBase / 2;
57
+ const mean = eccentricityBase;
58
+ const e0 = mean + amplitude * Math.cos(th0); // J2000 anchor, exact by construction
59
+ const g0 = Math.pow(1 - e0 * e0, -1.5); // (1−e²)^(−3/2) at the anchor
60
+
61
+ /** e_E on the H/3 line at tYr years from J2000 (negative = past).
62
+ * Returns the mean past the tidal-lock asymptote.
63
+ * @param {number} tYr @returns {number} */
64
+ function eccAt(tYr) {
65
+ const cycles = cyclesBetween(2000, 2000 + tYr, 3);
66
+ if (cycles === null) return mean;
67
+ return mean + amplitude * Math.cos(th0 + 2 * Math.PI * cycles);
68
+ }
69
+
70
+ /** [g(e(t))/g₀]^s — the perigee/node rate modulation, ≡ 1 at J2000.
71
+ * tMa is age in Myr (positive = past, the deep-time chain convention).
72
+ * @param {number} tMa @param {number} s @returns {number} */
73
+ function modulation(tMa, s) {
74
+ if (tMa === 0) return 1;
75
+ const e = eccAt(-tMa * 1e6);
76
+ return Math.pow(Math.pow(1 - e * e, -1.5) / g0, s);
77
+ }
78
+
79
+ /** ∫₀ᵀ [(g(e(t′))/g₀)^s − 1] dt′ in Julian centuries — the phase-aware
80
+ * channel-rate integral (the old frozen-κ T²/T³ Taylor coefficients were
81
+ * this integral's J2000 truncation). Composite Simpson, step ≤ ~4 kyr.
82
+ * @param {number} T @param {number} s @returns {number} */
83
+ function channelIntegral(T, s) {
84
+ if (T === 0) return 0;
85
+ /** @param {number} t */
86
+ const f = (t) => {
87
+ const e = eccAt(t * 100); // t in cy → years
88
+ return Math.pow(Math.pow(1 - e * e, -1.5) / g0, s) - 1;
89
+ };
90
+ const N = Math.max(2, 2 * Math.ceil(Math.abs(T) * 100 / 8000));
91
+ const h = T / N;
92
+ let sum = f(0) + f(T);
93
+ for (let i = 1; i < N; i++) sum += f(i * h) * (i % 2 ? 4 : 2);
94
+ return sum * h / 3;
95
+ }
96
+
97
+ /** Bounded Meeus E-factor: E ≡ e_E(t)/e_E(J2000), value 1 at J2000 by
98
+ * construction. The caller supplies tYr = days-from-J2000 / mean solar
99
+ * year; the pure-Meeus A/B polynomial branch stays ENGINE-LOCAL with its
100
+ * mode toggle. @param {number} tYr @returns {number} */
101
+ function eFactorAt(tYr) {
102
+ return eccAt(tYr) / e0;
103
+ }
104
+
105
+ return { eccAt, modulation, channelIntegral, eFactorAt, th0, amplitude, mean, e0, g0 };
106
+ }
107
+
108
+ module.exports = { createMoonEccChannel };
@@ -0,0 +1,251 @@
1
+ /**
2
+ * The deep-time lunar month/precession chain — THE shared implementation
3
+ * (Phase 8.2-3). Extracted VERBATIM from src/script.js, which
4
+ * tools/lib/deep-time.js mirrored; the 8.2-1 alignment proved the two
5
+ * bit-exact (56/56 fixture probes) before this move.
6
+ *
7
+ * The chain, by dependency (doc 99 / doc 66):
8
+ *
9
+ * Farhat distance a(t) ──► solar Δa (doc 24) ──► corrected a
10
+ * │ │
11
+ * ▼ ▼
12
+ * (engine LOD layer, injected) Kepler sidereal month
13
+ * ├─► synodic month (beat with sidereal year)
14
+ * ├─► tropical month (H/13 identity)
15
+ * └─► perigee/node precession
16
+ * (Brouwer–Clemence m² scaling ×
17
+ * e_E-channel modulation — the
18
+ * FACTORED law: period =
19
+ * invariant mean / rate modulation)
20
+ * ├─► anomalistic month
21
+ * ├─► nodal (draconic) month
22
+ * ├─► apsidal-meets-nodal beat
23
+ * └─► lunar leveling beat
24
+ *
25
+ * LOAD-BEARING conventions:
26
+ * - t_Ma is AGE in Myr: positive = past. The chain returns null past the
27
+ * tidal-lock asymptote (via the injected LOD) — bounded physics, not error.
28
+ * - The J2000 anchors (moonApsidalJ2000Seconds etc.) are the engines' own
29
+ * derived constants, injected — the t_Ma === 0 fast paths return them
30
+ * EXACTLY (anchor identity, pinned by the 0 Ma fixture probes).
31
+ * - `modulation` is the shared moon eccentricity channel's [g/g₀]^s with the
32
+ * Meeus-EFFECTIVE sensitivities s_ϖ = 2.407 / s_Ω = 1.018 (the physical
33
+ * pair is 2.479/0.867 after removing the IAU frame term — doc 66 §1).
34
+ * - The 'OfDate' cycle/precession family carries the legacy-'ICRF' input
35
+ * names but holds OF-DATE observational anchors (Option C+); the
36
+ * star-referenced values differ by ∓13 counts per H.
37
+ * - apsidalMeetsNodal / lunarLeveling existed ONLY in the browser before
38
+ * this move (the Node scene aliased apsidal-of-date on a cancellation
39
+ * argument valid for the paired scene nodes only) — the Node engine gains
40
+ * the real forms here.
41
+ */
42
+
43
+ 'use strict';
44
+
45
+ /**
46
+ * @param {{
47
+ * constants: {
48
+ * aMoonNowMetres: number,
49
+ * alpha1PerMa: number,
50
+ * alpha3PerMa3: number,
51
+ * alpha4PerMa4: number,
52
+ * gmEarthMoonM3PerS2: number,
53
+ * massRatioEarthMoon: number,
54
+ * moonSiderealMonthInputDays: number,
55
+ * holisticYearJ2000: number,
56
+ * meanSiderealYearJ2000Seconds: number,
57
+ * nApsidalOfDateJ2000: number,
58
+ * nNodalOfDateJ2000: number,
59
+ * moonApsidalJ2000Seconds: number,
60
+ * moonNodalJ2000Seconds: number,
61
+ * moonSiderealMonthJ2000Seconds: number,
62
+ * sPerigee: number,
63
+ * sNode: number,
64
+ * },
65
+ * fns: {
66
+ * meanLodSecondsAtAge: (tMa: number) => (number | null),
67
+ * meanSiderealYearSecondsAtAge: (tMa: number) => number,
68
+ * meanHAtAge: (tMa: number) => (number | null),
69
+ * modulation: (tMa: number, s: number) => number,
70
+ * },
71
+ * }} deps — fns are the ENGINE'S OWN layer-0/1 evaluators and the shared
72
+ * eccentricity channel's modulation; constants are the engine's derived
73
+ * J2000 anchors (both engines derive them from the same JSON).
74
+ */
75
+ function createMoonMonthChain({ constants, fns }) {
76
+ const {
77
+ aMoonNowMetres, alpha1PerMa, alpha3PerMa3, alpha4PerMa4,
78
+ gmEarthMoonM3PerS2, massRatioEarthMoon, moonSiderealMonthInputDays,
79
+ holisticYearJ2000, meanSiderealYearJ2000Seconds,
80
+ nApsidalOfDateJ2000, nNodalOfDateJ2000,
81
+ moonApsidalJ2000Seconds, moonNodalJ2000Seconds, moonSiderealMonthJ2000Seconds,
82
+ sPerigee, sNode,
83
+ } = constants;
84
+ const { meanLodSecondsAtAge, meanSiderealYearSecondsAtAge, meanHAtAge, modulation } = fns;
85
+
86
+ /** Farhat α₁/α₃/α₄ polynomial — Earth–Moon distance in metres at age t_Ma.
87
+ * @param {number} tMa @returns {number} */
88
+ function distanceMetresAtAge(tMa) {
89
+ const t = tMa;
90
+ return aMoonNowMetres * (1 + alpha1PerMa * t + alpha3PerMa3 * t * t * t + alpha4PerMa4 * t * t * t * t);
91
+ }
92
+
93
+ /** @param {number} tMa @returns {number} */
94
+ function distanceKmAtAge(tMa) {
95
+ return distanceMetresAtAge(tMa) / 1000;
96
+ }
97
+
98
+ /** Solar Δa correction (km) for the Moon's apparent semi-major axis
99
+ * (doc 24). @param {number} tMa @param {number} aApparentKm
100
+ * @returns {number | null} */
101
+ function solarDeltaAKmAtAge(tMa, aApparentKm) {
102
+ const lodS = meanLodSecondsAtAge(tMa);
103
+ if (lodS === null) return null;
104
+ const tSidDaysAtEpoch = meanSiderealYearSecondsAtAge(tMa) / lodS;
105
+ return aApparentKm * (1 / (massRatioEarthMoon + 1)) *
106
+ (moonSiderealMonthInputDays / tSidDaysAtEpoch);
107
+ }
108
+
109
+ /** Kepler-effective distance in km (a_apparent + solar Δa).
110
+ * @param {number} tMa @returns {number | null} */
111
+ function distanceCorrectedKmAtAge(tMa) {
112
+ const aApp = distanceKmAtAge(tMa);
113
+ const dA = solarDeltaAKmAtAge(tMa, aApp);
114
+ return (dA === null) ? null : aApp + dA;
115
+ }
116
+
117
+ /** Sidereal month in seconds (Kepler on the corrected a).
118
+ * @param {number} tMa @returns {number | null} */
119
+ function siderealMonthSecondsAtAge(tMa) {
120
+ const aCorrKm = distanceCorrectedKmAtAge(tMa);
121
+ if (aCorrKm === null) return null;
122
+ return 2 * Math.PI * Math.sqrt(Math.pow(aCorrKm * 1000, 3) / gmEarthMoonM3PerS2);
123
+ }
124
+
125
+ /** Synodic month in seconds (Moon–Sun alignment beat).
126
+ * @param {number} tMa @returns {number | null} */
127
+ function synodicMonthSecondsAtAge(tMa) {
128
+ const tSm = siderealMonthSecondsAtAge(tMa);
129
+ if (tSm === null) return null;
130
+ const tYr = meanSiderealYearSecondsAtAge(tMa);
131
+ return tSm * tYr / (tYr - tSm);
132
+ }
133
+
134
+ /** Tropical month in seconds (equinox-referenced, the H/13 identity).
135
+ * @param {number} tMa @returns {number | null} */
136
+ function tropicalMonthSecondsAtAge(tMa) {
137
+ const tSm = siderealMonthSecondsAtAge(tMa);
138
+ if (tSm === null) return null;
139
+ const tYr = meanSiderealYearSecondsAtAge(tMa);
140
+ const hT = meanHAtAge(tMa);
141
+ return tSm * (1 - 13 * tSm / (/** @type {number} */ (hT) * tYr));
142
+ }
143
+
144
+ /** Apsidal cycles per H (of-date convention; legacy-'ICRF' input name):
145
+ * N × (H/H₀)², real-valued. @param {number} tMa @returns {number | null} */
146
+ function apsidalCyclesOfDateAtAge(tMa) {
147
+ const hT = meanHAtAge(tMa);
148
+ if (hT === null) return null;
149
+ return nApsidalOfDateJ2000 * Math.pow(hT / holisticYearJ2000, 2);
150
+ }
151
+
152
+ /** @param {number} tMa @returns {number | null} */
153
+ function nodalCyclesOfDateAtAge(tMa) {
154
+ const hT = meanHAtAge(tMa);
155
+ if (hT === null) return null;
156
+ return nNodalOfDateJ2000 * Math.pow(hT / holisticYearJ2000, 2);
157
+ }
158
+
159
+ /** @param {number} tMa @returns {number | null} */
160
+ function apsidalPrecessionSecondsOfDateAtAge(tMa) {
161
+ const n = apsidalCyclesOfDateAtAge(tMa);
162
+ const hT = meanHAtAge(tMa);
163
+ const tYrS = meanSiderealYearSecondsAtAge(tMa);
164
+ if (n === null || hT === null) return null;
165
+ return hT * tYrS / n; // H in years × seconds/year / N
166
+ }
167
+
168
+ /** @param {number} tMa @returns {number | null} */
169
+ function nodalPrecessionSecondsOfDateAtAge(tMa) {
170
+ const n = nodalCyclesOfDateAtAge(tMa);
171
+ const hT = meanHAtAge(tMa);
172
+ const tYrS = meanSiderealYearSecondsAtAge(tMa);
173
+ if (n === null || hT === null) return null;
174
+ return hT * tYrS / n;
175
+ }
176
+
177
+ /** Perigee precession period in seconds — Brouwer–Clemence m² scaling ×
178
+ * e_E-channel modulation (the factored law: period = invariant mean /
179
+ * rate modulation). @param {number} tMa @returns {number | null} */
180
+ function perigeePrecessionSecondsAtAge(tMa) {
181
+ if (tMa === 0) return moonApsidalJ2000Seconds;
182
+ const tSmT = siderealMonthSecondsAtAge(tMa);
183
+ const tYrT = meanSiderealYearSecondsAtAge(tMa);
184
+ if (tSmT === null) return null;
185
+ return moonApsidalJ2000Seconds
186
+ * Math.pow(tYrT / meanSiderealYearJ2000Seconds, 2)
187
+ * (moonSiderealMonthJ2000Seconds / tSmT)
188
+ / modulation(tMa, sPerigee);
189
+ }
190
+
191
+ /** @param {number} tMa @returns {number | null} */
192
+ function nodePrecessionSecondsAtAge(tMa) {
193
+ if (tMa === 0) return moonNodalJ2000Seconds;
194
+ const tSmT = siderealMonthSecondsAtAge(tMa);
195
+ const tYrT = meanSiderealYearSecondsAtAge(tMa);
196
+ if (tSmT === null) return null;
197
+ return moonNodalJ2000Seconds
198
+ * Math.pow(tYrT / meanSiderealYearJ2000Seconds, 2)
199
+ * (moonSiderealMonthJ2000Seconds / tSmT)
200
+ / modulation(tMa, sNode);
201
+ }
202
+
203
+ /** Anomalistic month in seconds (perigee-to-perigee).
204
+ * @param {number} tMa @returns {number | null} */
205
+ function anomalisticMonthSecondsAtAge(tMa) {
206
+ const tSm = siderealMonthSecondsAtAge(tMa);
207
+ const tPer = perigeePrecessionSecondsAtAge(tMa);
208
+ if (tSm === null || tPer === null) return null;
209
+ return tSm * tPer / (tPer - tSm);
210
+ }
211
+
212
+ /** Nodal (draconic) month in seconds.
213
+ * @param {number} tMa @returns {number | null} */
214
+ function nodalMonthSecondsAtAge(tMa) {
215
+ const tSm = siderealMonthSecondsAtAge(tMa);
216
+ const tNode = nodePrecessionSecondsAtAge(tMa);
217
+ if (tSm === null || tNode === null) return null;
218
+ return tSm * tNode / (tNode + tSm);
219
+ }
220
+
221
+ /** Beat of the anomalistic and nodal months.
222
+ * @param {number} tMa @returns {number | null} */
223
+ function apsidalMeetsNodalSecondsAtAge(tMa) {
224
+ const tAnom = anomalisticMonthSecondsAtAge(tMa);
225
+ const tNod = nodalMonthSecondsAtAge(tMa);
226
+ if (tAnom === null || tNod === null) return null;
227
+ return tNod * tAnom / (tAnom - tNod);
228
+ }
229
+
230
+ /** Beat of the nodal and apsidal precessions.
231
+ * @param {number} tMa @returns {number | null} */
232
+ function lunarLevelingSecondsAtAge(tMa) {
233
+ const tApsi = perigeePrecessionSecondsAtAge(tMa);
234
+ const tNode = nodePrecessionSecondsAtAge(tMa);
235
+ if (tApsi === null || tNode === null) return null;
236
+ return tNode * tApsi / (tNode - tApsi);
237
+ }
238
+
239
+ return {
240
+ distanceMetresAtAge, distanceKmAtAge, solarDeltaAKmAtAge,
241
+ distanceCorrectedKmAtAge, siderealMonthSecondsAtAge,
242
+ synodicMonthSecondsAtAge, tropicalMonthSecondsAtAge,
243
+ apsidalCyclesOfDateAtAge, nodalCyclesOfDateAtAge,
244
+ apsidalPrecessionSecondsOfDateAtAge, nodalPrecessionSecondsOfDateAtAge,
245
+ perigeePrecessionSecondsAtAge, nodePrecessionSecondsAtAge,
246
+ anomalisticMonthSecondsAtAge, nodalMonthSecondsAtAge,
247
+ apsidalMeetsNodalSecondsAtAge, lunarLevelingSecondsAtAge,
248
+ };
249
+ }
250
+
251
+ module.exports = { createMoonMonthChain };