@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,166 @@
1
+ /**
2
+ * Layer 0 — epoch primitives. Pure `f(year)` → SI scalar.
3
+ *
4
+ * Imports NOTHING: not a sibling layer, not the constants module, not a Node
5
+ * builtin. Everything arrives through `createEpochPrimitives`. That is §2b and
6
+ * §2d of IP-unified-architecture.md, and it is what makes a counterfactual
7
+ * expressible — the one thing DE440 and Laskar cannot offer.
8
+ *
9
+ * ── ONE AXIS, BY MEASUREMENT ──────────────────────────────────────────────
10
+ * The API is `f(year)` (naming rule: the epoch parameter is a year). The
11
+ * internal cores work in t_Ma, converted once by `tMa()`. A second `t_Ma`
12
+ * entry axis was built and then REMOVED: the worry was that pre-migration
13
+ * callers holding a t_Ma would suffer `t → year → t` float wobble (1.9% of the
14
+ * domain fails that round-trip), but a 200k-point sweep found ZERO output-level
15
+ * differences — the chain's sensitivity (~20 s/Ma on LOD) times a 1-ULP wobble
16
+ * in t sits orders below the output ULP everywhere in ±495 Ma. The layer0
17
+ * identity gate pins that conclusion with harvested non-round-tripping t
18
+ * values, so a future chain steep enough to break it fails loudly.
19
+ *
20
+ * ── α IS INJECTED, AND THAT IS R2 ─────────────────────────────────────────
21
+ * `alphaAtAgeMa` is a parameter, not an import. The Earth moment-of-inertia
22
+ * factor rides on the climate L1 series, so hard-wiring it would drag the
23
+ * climate formula into Layer 0 — but the deeper reason is R2: when BUILDING an
24
+ * H-lattice table α must be held at its J2000 reference, or the lattice
25
+ * defines itself in terms of its own output. The browser gets this right today
26
+ * only by accident (a TDZ throw during table build); Node integrates the live
27
+ * α(t) and lands 141.6 d away at cycle 0. Making α an argument turns that
28
+ * accident into a decision the caller states out loud. R2 itself is a Phase C
29
+ * change — nothing here applies it.
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} EpochParams
34
+ * @property {number} epochYear year at t_Ma = 0 (2000, NOT startmodelYear)
35
+ * @property {number} alpha1PerMa Moon recession, LLR-anchored
36
+ * @property {number} alpha3PerMa3
37
+ * @property {number} alpha4PerMa4
38
+ * @property {number} moonDistanceNowM a_Moon at J2000, metres
39
+ * @property {number} moonLockDistanceM tidal-lock asymptote, metres
40
+ * @property {number} totalAngularMomentumKgM2S L_tot of the Earth-Moon system
41
+ * @property {number} moonMassKg
42
+ * @property {number} gmEarthMoonM3S2
43
+ * @property {number} moonEccentricityFactor sqrt(1 - e^2)
44
+ * @property {number} earthMassKg
45
+ * @property {number} earthRadiusM
46
+ * @property {number} holisticYearJ2000
47
+ * @property {number} lodNowH13Seconds
48
+ * @property {number} siderealYearJ2000Seconds
49
+ * @property {number} solarMassLossFracPerYear
50
+ */
51
+
52
+ /**
53
+ * @typedef {Object} EpochPrimitives
54
+ * @property {(year: number) => number} tMa
55
+ * @property {(year: number) => number} moonDistanceMetres
56
+ * @property {(year: number) => number|null} lodSeconds
57
+ * @property {(year: number) => number|null} holisticH
58
+ * @property {(year: number) => number} siderealYearSeconds
59
+ * @property {(year: number) => number} tropicalYearSeconds
60
+ * @property {(year: number) => number|null} anomalisticYearSeconds
61
+ */
62
+
63
+ /**
64
+ * @param {{params: EpochParams, alphaAtAgeMa: (tMa: number) => number}} deps
65
+ * @returns {EpochPrimitives}
66
+ */
67
+ export const createEpochPrimitives = ({ params: p, alphaAtAgeMa }) => {
68
+ /**
69
+ * Years before the epoch, in Ma. The ONE place this conversion lives.
70
+ * @param {number} year
71
+ * @returns {number}
72
+ */
73
+ const tMa = (year) => (p.epochYear - year) / 1e6;
74
+
75
+ /**
76
+ * Moon semi-major axis. Cubic in t_Ma: alpha1 is LLR-anchored, alpha3/alpha4
77
+ * are the Farhat 2022 deep-time fit.
78
+ * @param {number} t age in Ma
79
+ * @returns {number} metres
80
+ */
81
+ const moonDistanceMetresCore = (t) =>
82
+ p.moonDistanceNowM
83
+ * (1 + p.alpha1PerMa * t + p.alpha3PerMa3 * t * t * t + p.alpha4PerMa4 * t * t * t * t);
84
+
85
+ /**
86
+ * Length of day from angular-momentum conservation: as the Moon recedes it
87
+ * takes orbital angular momentum and Earth's spin slows to compensate.
88
+ * @param {number} t age in Ma
89
+ * @returns {number|null} seconds; null past the tidal-lock asymptote
90
+ */
91
+ const lodSecondsCore = (t) => {
92
+ const a = moonDistanceMetresCore(t);
93
+ if (a <= 0 || a >= p.moonLockDistanceM) return null;
94
+ const iEarth = alphaAtAgeMa(t) * p.earthMassKg * p.earthRadiusM * p.earthRadiusM;
95
+ return (2 * Math.PI * iEarth)
96
+ / (p.totalAngularMomentumKgM2S
97
+ - p.moonMassKg * Math.sqrt(p.gmEarthMoonM3S2 * a) * p.moonEccentricityFactor);
98
+ };
99
+
100
+ /**
101
+ * The Earth Fundamental Cycle. H scales with LOD — the lattice is defined by
102
+ * the rotation rate, so a longer day is a longer H.
103
+ * @param {number} t age in Ma
104
+ * @returns {number|null} years
105
+ */
106
+ const holisticHCore = (t) => {
107
+ const lod = lodSecondsCore(t);
108
+ return lod === null ? null : p.holisticYearJ2000 * lod / p.lodNowH13Seconds;
109
+ };
110
+
111
+ /**
112
+ * Sidereal year. Driver 2 alone — solar mass loss via Kepler, `dT/T = -2 dM/M`
113
+ * under the adiabatic `a*M = const` coupling. Carries NO alpha and NO LOD,
114
+ * which is why attempt 1 used it as the clean probe for the mass-loss
115
+ * amplitude (measured 0.9964, i.e. 1.00).
116
+ * @param {number} t age in Ma
117
+ * @returns {number} seconds
118
+ */
119
+ const siderealYearSecondsCore = (t) => {
120
+ if (t === 0) return p.siderealYearJ2000Seconds;
121
+ return p.siderealYearJ2000Seconds * (1 - 2 * p.solarMassLossFracPerYear * t * 1e6);
122
+ };
123
+
124
+ /**
125
+ * Tropical year — sidereal less one axial-precession turn, H/13.
126
+ * Falls back to the J2000 H past the asymptote, matching the shipped chain.
127
+ * @param {number} t age in Ma
128
+ * @returns {number} seconds
129
+ */
130
+ const tropicalYearSecondsCore = (t) => {
131
+ const sid = siderealYearSecondsCore(t);
132
+ const H = holisticHCore(t);
133
+ return H === null ? sid * (1 - 13 / p.holisticYearJ2000) : sid * (1 - 13 / H);
134
+ };
135
+
136
+ /**
137
+ * Anomalistic year.
138
+ *
139
+ * NOT `T_sid * (1 + 3/H)`. That is the FIRST-ORDER form and the plan quotes
140
+ * it, but the shipped chain composes the lattice steps exactly: tropical is
141
+ * `(H-13)/H` of sidereal, then anomalistic is `H/(H-16)` of tropical, giving
142
+ * `T_sid * (H-13)/(H-16)`. The two differ by `48/H^2 * T_sid` ~ 13.5 ms —
143
+ * comparable to 6d's own 0.0178 s anomalistic RMSE, so adopting the
144
+ * first-order form would move a shipped value for no reason. Exact
145
+ * composition it is; the identity 13 + 3 = 16 still holds to first order.
146
+ *
147
+ * @param {number} t age in Ma
148
+ * @returns {number|null} seconds
149
+ */
150
+ const anomalisticYearSecondsCore = (t) => {
151
+ const H = holisticHCore(t);
152
+ if (H === null) return null;
153
+ const sid = siderealYearSecondsCore(t);
154
+ return sid * (H - 13) / H * H / (H - 16);
155
+ };
156
+
157
+ return Object.freeze({
158
+ tMa,
159
+ moonDistanceMetres: (year) => moonDistanceMetresCore(tMa(year)),
160
+ lodSeconds: (year) => lodSecondsCore(tMa(year)),
161
+ holisticH: (year) => holisticHCore(tMa(year)),
162
+ siderealYearSeconds: (year) => siderealYearSecondsCore(tMa(year)),
163
+ tropicalYearSeconds: (year) => tropicalYearSecondsCore(tMa(year)),
164
+ anomalisticYearSeconds: (year) => anomalisticYearSecondsCore(tMa(year)),
165
+ });
166
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Layer 1 — derived views. Pure, composed only from Layer 0.
3
+ *
4
+ * The point of this layer is that TWO AXES DISAPPEAR (§2c):
5
+ *
6
+ * "Kinematic" is not a code path. It is `f(2000)`.
7
+ * "RealLOD" is not a function variant. It is a division by `lodSeconds(Y)`.
8
+ *
9
+ * Everything here is a year length expressed in DAYS, and a day is
10
+ * `lodSeconds(Y)` — the epoch's own rotation period, not 86400 SI seconds. That
11
+ * distinction is not cosmetic: the CSV measures JD intervals (SI days) while the
12
+ * ESSRT chart is in real days, and detrending one against the other injects a
13
+ * 2590 s ramp. Unit in the name, always.
14
+ */
15
+
16
+ /**
17
+ * @typedef {import('../layer0/index.js').EpochPrimitives} EpochPrimitives
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} DerivedViews
22
+ * @property {(year: number) => number|null} tropicalYearDays
23
+ * @property {(year: number) => number|null} siderealYearDays
24
+ * @property {(year: number) => number|null} anomalisticYearDays
25
+ * @property {(year: number) => number|null} siderealYearDaysViaLattice
26
+ */
27
+
28
+ /**
29
+ * @param {{primitives: EpochPrimitives}} deps
30
+ * @returns {DerivedViews}
31
+ */
32
+ export const createDerivedViews = ({ primitives: L0 }) => {
33
+ /**
34
+ * Days per year at the epoch's own day length. `null` past tidal lock, which
35
+ * propagates rather than silently becoming Infinity.
36
+ * @param {(year: number) => number|null} seconds
37
+ * @returns {(year: number) => number|null}
38
+ */
39
+ const perDay = (seconds) => (year) => {
40
+ const s = seconds(year);
41
+ const lod = L0.lodSeconds(year);
42
+ return (s === null || lod === null || lod === 0) ? null : s / lod;
43
+ };
44
+
45
+ const tropicalYearDays = perDay(L0.tropicalYearSeconds);
46
+ const siderealYearDays = perDay(L0.siderealYearSeconds);
47
+ const anomalisticYearDays = perDay(L0.anomalisticYearSeconds);
48
+
49
+ /**
50
+ * The sidereal year in days, reached the long way — from the tropical year
51
+ * through the H/13 lattice identity instead of from sidereal seconds.
52
+ *
53
+ * ALGEBRAICALLY THE SAME NUMBER. `T_trop = T_sid·(H−13)/H`, so
54
+ * `(T_trop/LOD)·H/(H−13)` cancels to `T_sid/LOD` — but the operation ORDER
55
+ * differs, so it is not bit-identical everywhere. Measured across the fixture
56
+ * grid: identical at six of seven epochs, 0.7 ULP apart at +0.3 Ma. The
57
+ * browser shows exactly the same one-epoch split, which is how we know the
58
+ * port is faithful rather than merely close.
59
+ *
60
+ * It exists because `src/script.js` carries BOTH as separate mutable globals
61
+ * — `meansiderealyearlengthinDays` and `meansiderealyearlengthinDays_kinematic`
62
+ * — and they hold the same value at every epoch once `recomputeEpochAnchors`
63
+ * has run. They diverge only at module load, where one is the IAU anchor
64
+ * (365.256363004) and the other is lattice-derived: the 118 ms of R16.
65
+ *
66
+ * So the "kinematic" variant was never a different quantity, only a
67
+ * different arrival route with a different starting constant. Keeping the
68
+ * route explicit here lets B.3 retire one global without asserting the
69
+ * equality on faith.
70
+ *
71
+ * @param {number} year
72
+ * @returns {number|null}
73
+ */
74
+ const siderealYearDaysViaLattice = (year) => {
75
+ const trop = tropicalYearDays(year);
76
+ const H = L0.holisticH(year);
77
+ return (trop === null || H === null) ? null : trop * H / (H - 13);
78
+ };
79
+
80
+ return Object.freeze({
81
+ tropicalYearDays,
82
+ siderealYearDays,
83
+ anomalisticYearDays,
84
+ siderealYearDaysViaLattice,
85
+ });
86
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Composites — cardinal points, earth, planets, moon, climate, deeptime. May use layer0 and layer1, never another layer2 module.
3
+ *
4
+ * PHASE 2 SKELETON — empty until the phase noted in IP-technical-design.md §6.
5
+ */
6
+ export {};
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Moon apparent position — THE shared implementation (Phase 8.2-7, the
3
+ * final lunar layer). The D5 derived optics + the post-hoc RA/Dec override:
4
+ *
5
+ * - sunGeoVecEqD5: framework-native geocentric Sun vector (equatorial km).
6
+ * e(T) from the anchored observed eccentricity + drift; EoC coefficients
7
+ * DERIVED from e via the Kepler series (2e − e³/4, (5/4)e², (13/12)e³ —
8
+ * the identity the D1 laboratory proved at 2 ppm); mean-longitude rate =
9
+ * framework tropical year; mean-anomaly rate = that minus the H/16
10
+ * perihelion rate; R from the AU distance; ε from the framework
11
+ * obliquity. One J2000 anchor: sunMeanLongitudeJ2000_deg.
12
+ * - moonAberrationRaDec: central-difference v_E/c annual aberration,
13
+ * SUBTRACTED (delta TO the aberration-removed direction u − v/c).
14
+ * - overrideRaDec: ecl→eq (Meeus eq. 13.3/13.4) on the series output,
15
+ * then framework-native analytic aberration + the small fitted residual
16
+ * (MOON_CORRECTION_RESIDUAL), or the legacy 3-argument fitted
17
+ * MOON_CORRECTION in pure-Meeus A/B mode.
18
+ *
19
+ * LOAD-BEARING: the correction patch's hard-coded linear arguments
20
+ * (297.850 + 12.19074912·dJD, 134.963 + 13.06499295·dJD,
21
+ * 357.529 + 0.98560028·dJD) are DELIBERATELY independent of the argument
22
+ * skeleton — the fitted coefficients were produced against THIS form
23
+ * (coefficients and runtime form are a matched pair). Do not "unify" them
24
+ * with the args dispatcher.
25
+ *
26
+ * S8 (obliquity source) stays ENGINE-INJECTED per call: the browser passes
27
+ * its live scene value (o.obliquityEarth, refreshed in updatePositions);
28
+ * the Node engine recomputes for the scene year. Equal when both describe
29
+ * the same year — a scene-state convention, not physics.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ /**
35
+ * @typedef {{raSinD: number, raCosD: number, raSinMp: number, raCosMp: number,
36
+ * raSinMs: number, raCosMs: number, decSinD: number, decCosD: number,
37
+ * decSinMp: number, decCosMp: number, decSinMs: number, decCosMs: number}} MoonCorrectionTable
38
+ */
39
+
40
+ /**
41
+ * @param {{
42
+ * constants: {
43
+ * j2000JD: number,
44
+ * julianCenturyDays: number,
45
+ * sunMeanLongitudeJ2000Deg: number,
46
+ * perihelionLongitudeJ2000Deg: number,
47
+ * eccentricityJ2000: number,
48
+ * eccentricityDotJ2000: number,
49
+ * d5RateLDegPerDay: number,
50
+ * d5RatePeriDegPerDay: number,
51
+ * speedOfLight: number,
52
+ * },
53
+ * fns: {
54
+ * computeObliquityEarth: (year: number) => number,
55
+ * getAuDistanceKm: () => number,
56
+ * isFrameworkNative: () => boolean,
57
+ * getCorrectionResidual: () => (MoonCorrectionTable | null),
58
+ * getCorrectionLegacy: () => (MoonCorrectionTable | null),
59
+ * },
60
+ * }} deps — the D5 rates are the engines' J2000-frozen values (the year
61
+ * globals are deep-time-mutable, same pattern as FW_A2_RATE); the AU
62
+ * distance is a GETTER (browser-mutable under deep time).
63
+ */
64
+ function createMoonApparent({ constants, fns }) {
65
+ const {
66
+ j2000JD, julianCenturyDays, sunMeanLongitudeJ2000Deg,
67
+ perihelionLongitudeJ2000Deg, eccentricityJ2000, eccentricityDotJ2000,
68
+ d5RateLDegPerDay, d5RatePeriDegPerDay, speedOfLight,
69
+ } = constants;
70
+ const {
71
+ computeObliquityEarth, getAuDistanceKm, isFrameworkNative,
72
+ getCorrectionResidual, getCorrectionLegacy,
73
+ } = fns;
74
+
75
+ /** Framework-native geocentric Sun vector, equatorial km.
76
+ * @param {number} jd @returns {[number, number, number]} */
77
+ function sunGeoVecEqD5(jd) {
78
+ const T = (jd - j2000JD) / julianCenturyDays;
79
+ const d = jd - j2000JD;
80
+ const L0 = sunMeanLongitudeJ2000Deg + d5RateLDegPerDay * d;
81
+ const M = ((L0 - (perihelionLongitudeJ2000Deg + d5RatePeriDegPerDay * d)) + 180) * Math.PI / 180; // geocentric-perigee convention (Sun perigee = Earth perihelion + 180°)
82
+ const e = eccentricityJ2000 + eccentricityDotJ2000 * T;
83
+ const Ceq = ((2 * e - Math.pow(e, 3) / 4) * Math.sin(M)
84
+ + 1.25 * e * e * Math.sin(2 * M)
85
+ + (13 / 12) * Math.pow(e, 3) * Math.sin(3 * M)) * 180 / Math.PI; // deg (Kepler EoC series)
86
+ const lam = (L0 + Ceq) * Math.PI / 180;
87
+ const v = M + Ceq * Math.PI / 180;
88
+ const R = (1 - e * e) / (1 + e * Math.cos(v)) * getAuDistanceKm();
89
+ const eps = computeObliquityEarth(2000 + d / 365.2425) * Math.PI / 180;
90
+ return [R * Math.cos(lam), R * Math.sin(lam) * Math.cos(eps), R * Math.sin(lam) * Math.sin(eps)];
91
+ }
92
+
93
+ /** Annual-aberration delta (central difference, v_E/c SUBTRACTED).
94
+ * @param {number} jd @param {number} ra @param {number} dec
95
+ * @returns {{dRA: number, dDec: number}} */
96
+ function moonAberrationRaDec(jd, ra, dec) {
97
+ const h = 0.02; // days (central difference)
98
+ const a = sunGeoVecEqD5(jd - h), b = sunGeoVecEqD5(jd + h);
99
+ const s = 1 / (2 * h * 86400 * speedOfLight);
100
+ const kx = -(b[0] - a[0]) * s, ky = -(b[1] - a[1]) * s, kz = -(b[2] - a[2]) * s; // v_E/c
101
+ const cd = Math.cos(dec);
102
+ const ux = cd * Math.cos(ra), uy = cd * Math.sin(ra), uz = Math.sin(dec);
103
+ const wx = ux - kx, wy = uy - ky, wz = uz - kz; // SUBTRACT the aberration content
104
+ const wr = Math.sqrt(wx * wx + wy * wy + wz * wz);
105
+ let dRA = Math.atan2(wy, wx) - ra;
106
+ dRA = Math.atan2(Math.sin(dRA), Math.cos(dRA));
107
+ return { dRA, dDec: Math.asin(Math.max(-1, Math.min(1, wz / wr))) - dec };
108
+ }
109
+
110
+ /** The post-hoc RA/Dec override on the series output. Returns EQUATORIAL
111
+ * radians — the engines own their scene storage conventions (the browser
112
+ * stores dec as π/2 − dec, phi form).
113
+ * @param {{lonDeg: number, betRad: number, meeusT: (number | undefined), obliquityDeg: number}} p
114
+ * @returns {{raRad: number, decRad: number}} */
115
+ function overrideRaDec({ lonDeg, betRad, meeusT, obliquityDeg }) {
116
+ const eps = obliquityDeg * (Math.PI / 180);
117
+ const cosE = Math.cos(eps), sinE = Math.sin(eps);
118
+ const lamR = lonDeg * (Math.PI / 180);
119
+ const sinLam = Math.sin(lamR), cosLam = Math.cos(lamR);
120
+ const sinBet = Math.sin(betRad), cosBet = Math.cos(betRad);
121
+
122
+ // Ecliptic → equatorial (Meeus eq. 13.3, 13.4)
123
+ let newRA = Math.atan2(sinLam * cosE - Math.tan(betRad) * sinE, cosLam);
124
+ if (newRA < 0) newRA += 2 * Math.PI;
125
+ let newDec = Math.asin(sinBet * cosE + cosBet * sinE * sinLam);
126
+
127
+ if (isFrameworkNative()) {
128
+ const ab = moonAberrationRaDec(j2000JD + (meeusT || 0) * julianCenturyDays, newRA, newDec);
129
+ newRA += ab.dRA;
130
+ newDec += ab.dDec;
131
+ }
132
+ const mc = isFrameworkNative() ? getCorrectionResidual() : getCorrectionLegacy();
133
+ if (mc) {
134
+ const d2r = Math.PI / 180;
135
+ const dJD = (meeusT || 0) * julianCenturyDays;
136
+ // MATCHED-PAIR arguments — fitted against these exact linear forms.
137
+ const Dc = (297.850 + 12.19074912 * dJD) * d2r;
138
+ const Mpc = (134.963 + 13.06499295 * dJD) * d2r;
139
+ const Msc = (357.529 + 0.98560028 * dJD) * d2r;
140
+ newRA -= (mc.raSinD * Math.sin(Dc) + mc.raCosD * Math.cos(Dc)
141
+ + mc.raSinMp * Math.sin(Mpc) + mc.raCosMp * Math.cos(Mpc)
142
+ + mc.raSinMs * Math.sin(Msc) + mc.raCosMs * Math.cos(Msc)) * d2r;
143
+ newDec -= (mc.decSinD * Math.sin(Dc) + mc.decCosD * Math.cos(Dc)
144
+ + mc.decSinMp * Math.sin(Mpc) + mc.decCosMp * Math.cos(Mpc)
145
+ + mc.decSinMs * Math.sin(Msc) + mc.decCosMs * Math.cos(Msc)) * d2r;
146
+ }
147
+ return { raRad: newRA, decRad: newDec };
148
+ }
149
+
150
+ return { sunGeoVecEqD5, moonAberrationRaDec, overrideRaDec };
151
+ }
152
+
153
+ module.exports = { createMoonApparent };