@essrt/physics 1.4.0 → 1.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@essrt/physics",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "ESSRT (Expanding Solar System Resonance Theory) physics core \u2014 pure, injectable-constants, no I/O, no globals, no DOM. The complete model incl. fitted coefficients; every published version immutably ships one recorded model identity.",
5
5
  "essrt": {
6
6
  "modelVersion": "v11.0",
@@ -2,14 +2,14 @@
2
2
  // Gives the TypeScript website full type safety at the boundary (§2g) while
3
3
  // packages/physics stays JavaScript.
4
4
 
5
- export declare const CONSTANTS_HASH: "e86ba5bb7a5e7c45";
5
+ export declare const CONSTANTS_HASH: "fa3230753655d190";
6
6
 
7
7
  export declare const MODEL_VERSION: string;
8
8
 
9
9
  export declare const PREPRINT_DOI: string;
10
10
 
11
11
  export declare const DEFAULT_CONSTANTS: {
12
- readonly hash: "e86ba5bb7a5e7c45";
12
+ readonly hash: "fa3230753655d190";
13
13
  readonly additionalBodies: {
14
14
  "pluto": {
15
15
  "name": string;
@@ -241,6 +241,9 @@ export declare const DEFAULT_CONSTANTS: {
241
241
  "earthMoiFactorJ2000": number;
242
242
  "earthFlatteningInverseWGS84": number;
243
243
  "aberrationConstantArcsec": number;
244
+ "gmstMeanSiderealT0Deg": number;
245
+ "gmstMeanSiderealRateDegPerDay": number;
246
+ "gmstMeanSiderealT2Deg": number;
244
247
  "nutationLeadingTermsArcsec": {
245
248
  "psiOmega": number;
246
249
  "psi2Ls": number;
@@ -32,7 +32,7 @@
32
32
  * carry it so a counterfactual is reproducible (§2d).
33
33
  * @type {string}
34
34
  */
35
- export const CONSTANTS_HASH = "e86ba5bb7a5e7c45";
35
+ export const CONSTANTS_HASH = "fa3230753655d190";
36
36
 
37
37
  /** Model version label — single source: public/input/model-version.json (§10 two-axis scheme). */
38
38
  export const MODEL_VERSION = "v11.0";
@@ -42,7 +42,7 @@ export const PREPRINT_DOI = "10.21203/rs.3.rs-8758810/v4";
42
42
 
43
43
  /** @type {Readonly<Record<string, unknown>>} */
44
44
  export const DEFAULT_CONSTANTS = Object.freeze({
45
- hash: "e86ba5bb7a5e7c45",
45
+ hash: "fa3230753655d190",
46
46
  additionalBodies: {
47
47
  "pluto": {
48
48
  "name": "Pluto",
@@ -298,6 +298,9 @@ export const DEFAULT_CONSTANTS = Object.freeze({
298
298
  "earthMoiFactorJ2000": 0.3306947,
299
299
  "earthFlatteningInverseWGS84": 298.257223563,
300
300
  "aberrationConstantArcsec": 20.4955,
301
+ "gmstMeanSiderealT0Deg": 280.46061837,
302
+ "gmstMeanSiderealRateDegPerDay": 360.98564736629,
303
+ "gmstMeanSiderealT2Deg": 0.000387933,
301
304
  "nutationLeadingTermsArcsec": {
302
305
  "psiOmega": -17.2,
303
306
  "psi2Ls": -1.32,
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Solar-eclipse shadow geometry — the location tier (20.3g).
3
+ *
4
+ * Instantaneous shadow-axis evaluation on the FULL shared series (no
5
+ * precomputed polynomial elements): at any JD(UT) the geocentric Sun and
6
+ * Moon are placed in the equatorial-of-date frame, the umbra axis is
7
+ * intersected with the ellipsoid, and observer local circumstances follow
8
+ * from the observer's perpendicular offset to the axis versus the
9
+ * penumbra/umbra cone radii at the observer's axial plane.
10
+ *
11
+ * THE TWO AXIS CONVENTIONS (both measured against the 15 NASA path-table
12
+ * centerline points, tools/verify/eclipse-audit.js `centerlines`; the raw
13
+ * finder chain read ~61″ shadow-plane, this chain 6.3″ mean / 9.1″ max —
14
+ * within every NASA path half-width):
15
+ *
16
+ * 1. TT bridge. The framework ΔT curve is ZEROED AT J2000 by construction
17
+ * (it integrates LOD excess from the epoch anchor); the model's
18
+ * absolute ΔT is deltaTStart + curve — exactly how every other
19
+ * consumer reads it (the browser accumulator, Meeus geometry, the ΔT
20
+ * charts; deltaTStart is the joint-world trend anchor, deliberately
21
+ * ~8 s below the IERS instantaneous 63.6 s — the industrial-era
22
+ * acceleration the cyclic model does not capture). The eclipse
23
+ * FINDERS deliberately stay on the zeroed convention — their fitted
24
+ * anchors (SUN_HARMONICS, canon statistics) were produced through it,
25
+ * and eclipse TIMING is elongation-class, where the convention
26
+ * cancels against the fitted phases. GROUND LOCATION is absolute: the
27
+ * Earth-fixed frame rides true UT rotation, so the bodies are
28
+ * evaluated at the model's absolute TT. ttBridgeSeconds = deltaTStart
29
+ * (no new constant; measured BETTER on the NASA reference points than
30
+ * the observed 63.8/64.2 s — mean 6.3″ vs 7.3″). ~34″ of the raw
31
+ * offset.
32
+ *
33
+ * 2. Full series, not the truncated finder forms. truncatedLonDeg omits
34
+ * the fitted moonMeeusLpCorrection anchor (+33.8″) and truncatedBetaDeg
35
+ * omits the −2235·sin(Lp) latitude family (~8″) — both deliberate for
36
+ * the certified finder statistics (knife-edge canon events), both
37
+ * required here. The injected moonFullAtDaysTT is the series'
38
+ * sceneEvalAt — the same evaluation the browser scene ships. Measured:
39
+ * ~27″ of the raw offset.
40
+ *
41
+ * Annual aberration is deliberately ABSENT: for ground location a
42
+ * common-mode rotation of both bodies moves the ground point by the raw
43
+ * angle (~0.6 km), and the elongation content cancels at syzygy (the
44
+ * round-3 result). Both bodies here are geometric mean-of-date, matching
45
+ * the MEAN sidereal time used for the Earth-fixed mapping.
46
+ *
47
+ * The scene-umbra conventions (THREE scaffold navigation) never enter this
48
+ * package (§2h) — this is an independent construction on the shared
49
+ * series, cross-checked against the scene twins through the same NASA
50
+ * reference points.
51
+ */
52
+
53
+ 'use strict';
54
+
55
+ /**
56
+ * @typedef {Object} BesselianDeps
57
+ * @property {(dDaysTT: number) => {lonDeg: number, latDeg: number, distKm: number}} moonFullAtDaysTT
58
+ * full-series Moon: ecliptic-of-date longitude/latitude (deg) + distance (km), TT axis (days since J2000)
59
+ * @property {(jdUT: number) => number} sunLonDegAt - geometric mean sun longitude (deg), JD(UT) axis with the finder's internal ΔT
60
+ * @property {(jd: number) => number} deltaTSecondsAt - framework ΔT (J2000-zeroed convention)
61
+ * @property {(year: number) => number} obliquityDegAt - framework obliquity (deg) at calendar year
62
+ * @property {(year: number) => number} eccentricityAt - framework Earth-orbit eccentricity at calendar year
63
+ * @property {(year: number) => number} perihelionLongitudeDegAt - framework perihelion longitude (deg) at calendar year
64
+ * @property {(jd: number) => number} yearFromJD - the model's own JD → calendar-year conversion
65
+ * @property {{ j2000JD: number, julianCenturyDays: number, earthDiameterKm: number,
66
+ * moonDiameterKm: number, sunDiameterKm: number, sunDistanceKm: number,
67
+ * earthFlatteningInverse: number, ttBridgeSeconds: number,
68
+ * gmstMeanSiderealT0Deg: number, gmstMeanSiderealRateDegPerDay: number,
69
+ * gmstMeanSiderealT2Deg: number }} constants
70
+ */
71
+
72
+ /** @param {BesselianDeps} deps */
73
+ function createBesselian(deps) {
74
+ const K = deps.constants;
75
+ const D2R = Math.PI / 180;
76
+ const R_E_KM = K.earthDiameterKm / 2;
77
+ const R_SUN_KM = K.sunDiameterKm / 2;
78
+ const R_MOON_KM = K.moonDiameterKm / 2;
79
+ const F = 1 / K.earthFlatteningInverse;
80
+
81
+ /** Mean sidereal time at Greenwich, degrees (mean equinox — pairs with the
82
+ * mean-of-date body longitudes; coefficients single-sourced from
83
+ * astro-reference.json physicalConstants). @param {number} jdUT @returns {number} */
84
+ function gmstDeg(jdUT) {
85
+ const T = (jdUT - K.j2000JD) / K.julianCenturyDays;
86
+ return ((K.gmstMeanSiderealT0Deg + K.gmstMeanSiderealRateDegPerDay * (jdUT - K.j2000JD)
87
+ + K.gmstMeanSiderealT2Deg * T * T) % 360 + 360) % 360;
88
+ }
89
+
90
+ /** Ecliptic-of-date → equatorial-of-date vector (km).
91
+ * @param {number} lonDeg @param {number} betDeg @param {number} distKm
92
+ * @param {number} epsRad @returns {[number, number, number]} */
93
+ function eclToEq(lonDeg, betDeg, distKm, epsRad) {
94
+ const lam = lonDeg * D2R, bet = betDeg * D2R;
95
+ const x = Math.cos(bet) * Math.cos(lam);
96
+ const y = Math.cos(bet) * Math.sin(lam) * Math.cos(epsRad) - Math.sin(bet) * Math.sin(epsRad);
97
+ const z = Math.cos(bet) * Math.sin(lam) * Math.sin(epsRad) + Math.sin(bet) * Math.cos(epsRad);
98
+ return [distKm * x, distKm * y, distKm * z];
99
+ }
100
+
101
+ /** Elliptical Sun distance — FRAMEWORK-NATIVE: the model's own
102
+ * eccentricity (Law-5-locked base + drift) and perihelion longitude
103
+ * (H/16 precession) close the ellipse, with the true anomaly from the
104
+ * already-computed sun longitude (v = λ − (ϖ + 180°), geocentric-perigee
105
+ * convention). Agrees with the Meeus Ch. 25 polynomial to ±0.001% over a
106
+ * year, with no fitted external coefficients, and stays honest at deep
107
+ * time (e and ϖ evolve with the model). The distance is LOAD-BEARING for
108
+ * the umbral cone radius — a small difference of large terms: the
109
+ * mean-AU approximation's ±1.7% seasonal error became tens of percent of
110
+ * umbra radius (measured: Carbondale 2017 central duration 95 s vs the
111
+ * real 158 s; elliptical gives 157 s).
112
+ * @param {number} year @param {number} sunLonDeg @returns {number} km */
113
+ function sunDistanceKm(year, sunLonDeg) {
114
+ const e = deps.eccentricityAt(year);
115
+ const v = (sunLonDeg - (deps.perihelionLongitudeDegAt(year) + 180)) * D2R;
116
+ return K.sunDistanceKm * (1 - e * e) / (1 + e * Math.cos(v));
117
+ }
118
+
119
+ /** Sun and Moon geocentric equatorial-of-date vectors at true TT.
120
+ * @param {number} jdUT @returns {{S: [number,number,number], M: [number,number,number]}} */
121
+ function bodiesAt(jdUT) {
122
+ const jb = jdUT + K.ttBridgeSeconds / 86400; // finder-axis input → its internal ΔT lands on true TT
123
+ const year = deps.yearFromJD(jb);
124
+ const eps = deps.obliquityDegAt(year) * D2R;
125
+ const sunLon = deps.sunLonDegAt(jb);
126
+ const dTT = (jb - K.j2000JD) + deps.deltaTSecondsAt(jb) / 86400;
127
+ const moon = deps.moonFullAtDaysTT(dTT);
128
+ return {
129
+ S: eclToEq(sunLon, 0, sunDistanceKm(year, sunLon), eps),
130
+ M: eclToEq(moon.lonDeg, moon.latDeg, moon.distKm, eps),
131
+ };
132
+ }
133
+
134
+ /** Umbra-axis ground intersection at JD(UT): geodetic latitude, east
135
+ * longitude — or null when the axis misses the ellipsoid.
136
+ * @param {number} jdUT @returns {{latDeg: number, lonDeg: number} | null} */
137
+ function umbraGroundAt(jdUT) {
138
+ const { S, M } = bodiesAt(jdUT);
139
+ const dv = [M[0] - S[0], M[1] - S[1], M[2] - S[2]];
140
+ const dl = Math.hypot(dv[0], dv[1], dv[2]);
141
+ const d = [dv[0] / dl, dv[1] / dl, dv[2] / dl];
142
+ const MdotD = M[0] * d[0] + M[1] * d[1] + M[2] * d[2];
143
+ const MdotM = M[0] * M[0] + M[1] * M[1] + M[2] * M[2];
144
+ const disc = MdotD * MdotD - (MdotM - R_E_KM * R_E_KM);
145
+ if (disc < 0) return null;
146
+ const s = -MdotD - Math.sqrt(disc);
147
+ const hit = [M[0] + s * d[0], M[1] + s * d[1], M[2] + s * d[2]];
148
+ const r = Math.hypot(hit[0], hit[1], hit[2]);
149
+ const latGc = Math.asin(Math.max(-1, Math.min(1, hit[2] / r)));
150
+ const latDeg = Math.atan(Math.tan(latGc) / ((1 - F) * (1 - F))) / D2R;
151
+ let lonDeg = Math.atan2(hit[1], hit[0]) / D2R - gmstDeg(jdUT);
152
+ lonDeg = ((lonDeg + 540) % 360) - 180;
153
+ return { latDeg, lonDeg };
154
+ }
155
+
156
+ /** Observer geocentric position in the equatorial-of-date frame (km).
157
+ * Geodetic latitude on the ellipsoid (sea level).
158
+ * @param {number} jdUT @param {number} latDeg @param {number} lonDeg
159
+ * @returns {[number, number, number]} */
160
+ function observerVec(jdUT, latDeg, lonDeg) {
161
+ const u = Math.atan((1 - F) * Math.tan(latDeg * D2R)); // reduced latitude
162
+ const rho = [Math.cos(u), (1 - F) * Math.sin(u)]; // [equatorial, polar] components / R_E
163
+ const lst = (gmstDeg(jdUT) + lonDeg) * D2R; // local mean sidereal angle
164
+ return [
165
+ R_E_KM * rho[0] * Math.cos(lst),
166
+ R_E_KM * rho[0] * Math.sin(lst),
167
+ R_E_KM * rho[1],
168
+ ];
169
+ }
170
+
171
+ /**
172
+ * Instantaneous shadow state at the observer: perpendicular offset from
173
+ * the axis vs cone radii at the observer's axial plane.
174
+ * @param {number} jdUT @param {number} latDeg @param {number} lonDeg
175
+ * @returns {{offsetKm: number, penumbraKm: number, umbraKm: number, magnitude: number}}
176
+ * umbraKm is SIGNED: positive = annular (beyond the umbral vertex),
177
+ * negative = total; |umbraKm| is the central-zone radius either way.
178
+ */
179
+ function shadowStateAt(jdUT, latDeg, lonDeg) {
180
+ const { S, M } = bodiesAt(jdUT);
181
+ const O = observerVec(jdUT, latDeg, lonDeg);
182
+ const dv = [M[0] - S[0], M[1] - S[1], M[2] - S[2]];
183
+ const dl = Math.hypot(dv[0], dv[1], dv[2]);
184
+ const d = [dv[0] / dl, dv[1] / dl, dv[2] / dl]; // Sun→Moon (toward Earth)
185
+ const OM = [O[0] - M[0], O[1] - M[1], O[2] - M[2]];
186
+ const along = OM[0] * d[0] + OM[1] * d[1] + OM[2] * d[2]; // axial distance Moon→observer plane
187
+ const w = [OM[0] - along * d[0], OM[1] - along * d[1], OM[2] - along * d[2]];
188
+ const offsetKm = Math.hypot(w[0], w[1], w[2]);
189
+ const tanF1 = (R_SUN_KM + R_MOON_KM) / dl; // penumbral half-angle
190
+ const tanF2 = (R_SUN_KM - R_MOON_KM) / dl; // umbral half-angle
191
+ const penumbraKm = R_MOON_KM + along * tanF1;
192
+ const umbraKm = along * tanF2 - R_MOON_KM; // signed: >0 annular, <0 total
193
+ // Besselian magnitude convention: SIGNED umbral radius in the
194
+ // denominator — total events read >1 at the centre, annular <1.
195
+ const magnitude = (penumbraKm - offsetKm) / (penumbraKm + umbraKm);
196
+ return { offsetKm, penumbraKm, umbraKm, magnitude };
197
+ }
198
+
199
+ /**
200
+ * Local circumstances for an observer around one solar eclipse.
201
+ * Scans ±4 h around jdGreatest at 30 s, bisects contacts to ~1 s.
202
+ * @param {number} jdGreatest - the finder's greatest-eclipse JD(UT)
203
+ * @param {number} latDeg @param {number} lonDeg
204
+ * @returns {{kind: 'none'|'partial'|'annular'|'total',
205
+ * magnitude: number, maxJd: number,
206
+ * contacts: {c1: number|null, c2: number|null, c3: number|null, c4: number|null},
207
+ * centralDurationSeconds: number|null}}
208
+ */
209
+ function localCircumstances(jdGreatest, latDeg, lonDeg) {
210
+ const HALF_WIN = 4 / 24, STEP = 30 / 86400;
211
+ // maximum: minimize offset − nothing fancier than a fine scan + refine
212
+ let best = { t: jdGreatest, magnitude: -Infinity };
213
+ for (let t = jdGreatest - HALF_WIN; t <= jdGreatest + HALF_WIN; t += STEP) {
214
+ const st = shadowStateAt(t, latDeg, lonDeg);
215
+ if (st.magnitude > best.magnitude) best = { t, magnitude: st.magnitude };
216
+ }
217
+ // golden-section-ish refine on magnitude around the best sample
218
+ let lo = best.t - STEP, hi = best.t + STEP;
219
+ for (let i = 0; i < 30; i++) {
220
+ const m1 = lo + (hi - lo) / 3, m2 = hi - (hi - lo) / 3;
221
+ if (shadowStateAt(m1, latDeg, lonDeg).magnitude
222
+ < shadowStateAt(m2, latDeg, lonDeg).magnitude) lo = m1; else hi = m2;
223
+ }
224
+ const maxJd = (lo + hi) / 2;
225
+ const atMax = shadowStateAt(maxJd, latDeg, lonDeg);
226
+
227
+ /** Bisect a sign change of fn between a (negative) and b (positive).
228
+ * @param {(t: number) => number} fn @param {number} a @param {number} b
229
+ * @returns {number} */
230
+ const bisect = (fn, a, b) => {
231
+ for (let i = 0; i < 40; i++) {
232
+ const m = (a + b) / 2;
233
+ if (fn(m) < 0) a = m; else b = m;
234
+ }
235
+ return (a + b) / 2;
236
+ };
237
+ /** @param {number} t @returns {number} >0 inside the penumbra */
238
+ const penEdge = (t) => {
239
+ const st = shadowStateAt(t, latDeg, lonDeg);
240
+ return st.penumbraKm - st.offsetKm;
241
+ };
242
+ /** @param {number} t @returns {number} >0 inside the central zone */
243
+ const umbEdge = (t) => {
244
+ const st = shadowStateAt(t, latDeg, lonDeg);
245
+ return Math.abs(st.umbraKm) - st.offsetKm;
246
+ };
247
+
248
+ /** @type {{c1: number|null, c2: number|null, c3: number|null, c4: number|null}} */
249
+ const contacts = { c1: null, c2: null, c3: null, c4: null };
250
+ let kind = /** @type {'none'|'partial'|'annular'|'total'} */ ('none');
251
+ if (penEdge(maxJd) > 0) {
252
+ kind = 'partial';
253
+ // outer contacts: walk out from max until outside, then bisect
254
+ for (let t = maxJd; t >= maxJd - HALF_WIN; t -= STEP) {
255
+ if (penEdge(t) < 0) { contacts.c1 = bisect(penEdge, t, t + STEP); break; }
256
+ }
257
+ for (let t = maxJd; t <= maxJd + HALF_WIN; t += STEP) {
258
+ if (penEdge(t) < 0) { contacts.c4 = bisect(/** @param {number} x */ (x) => -penEdge(x), t - STEP, t); break; }
259
+ }
260
+ if (umbEdge(maxJd) > 0) {
261
+ kind = atMax.umbraKm < 0 ? 'total' : 'annular';
262
+ for (let t = maxJd; t >= maxJd - HALF_WIN; t -= STEP) {
263
+ if (umbEdge(t) < 0) { contacts.c2 = bisect(umbEdge, t, t + STEP); break; }
264
+ }
265
+ for (let t = maxJd; t <= maxJd + HALF_WIN; t += STEP) {
266
+ if (umbEdge(t) < 0) { contacts.c3 = bisect(/** @param {number} x */ (x) => -umbEdge(x), t - STEP, t); break; }
267
+ }
268
+ }
269
+ }
270
+ const centralDurationSeconds = (contacts.c2 !== null && contacts.c3 !== null)
271
+ ? (contacts.c3 - contacts.c2) * 86400 : null;
272
+ return { kind, magnitude: Math.max(0, atMax.magnitude), maxJd, contacts, centralDurationSeconds };
273
+ }
274
+
275
+ return { umbraGroundAt, shadowStateAt, localCircumstances };
276
+ }
277
+
278
+ module.exports = { createBesselian };
package/src/model.js CHANGED
@@ -32,6 +32,7 @@ import { createChainCycleIntegrator } from './chain-cycles/index.cjs';
32
32
  import { createMoonArguments, jdToDecimalYear } from './moon/arguments.cjs';
33
33
  import { createMoonSeries } from './moon/series.cjs';
34
34
  import { createEclipseFinders } from './eclipse/finders.cjs';
35
+ import { createBesselian } from './eclipse/besselian.cjs';
35
36
  import { driver2PeriodSecondsAtAge } from './planets/orbit-chain.cjs';
36
37
 
37
38
  /**
@@ -782,7 +783,20 @@ export function assembleModel(C, F) {
782
783
  argsAt: /** @param {number} jdTT */ (jdTT) => moonArgs.argsAt(jdTT),
783
784
  eFactorForD: fwEFactor,
784
785
  eFactorAtJdTT: /** @param {number} jdTT */ (jdTT) => fwEFactor(jdTT - j2000JD),
785
- getMoonDistanceKm: () => moonDistanceKm,
786
+ // 20.3d(i): the Driver-1 ratio at the EVALUATED epoch (per-jd pure
787
+ // evaluator). MATCHED TRIPLE with the tools-lib and browser getters:
788
+ // identical age arithmetic — the SI-linear year (yearFromJD, never
789
+ // the calendar year: the recorded linear-vs-calendar mirror trap)
790
+ // against the MODEL-START anchor (startModelYearWithCorrection, the
791
+ // browser's J2000_CALENDAR_YEAR — NOT this model's 2000.0 yearToTMa
792
+ // convention: the 0.4977-yr difference is 4.95e-11 of distance and
793
+ // failed the bit-exact parity gate). Falls back to the J2000
794
+ // constant without a jd (legacy call shape) or past the domain.
795
+ getMoonDistanceKm: /** @param {number} [jdTT] */ (jdTT) => {
796
+ if (jdTT === undefined) return moonDistanceKm;
797
+ const d = moonDistanceMetresAtAge((startModelYearWithCorrection - yearFromJD(jdTT)) / 1e6);
798
+ return d === null ? moonDistanceKm : d / 1000;
799
+ },
786
800
  getEccentricityBase: () => C.moonReference.moonOrbitalEccentricityBase,
787
801
  deltaTSeconds: /** @param {number} jd */ (jd) => (jdTTFromUT(jd) - jd) * 86400,
788
802
  jdToSIyear: yearFromJD,
@@ -816,6 +830,39 @@ export function assembleModel(C, F) {
816
830
  },
817
831
  });
818
832
 
833
+ // 20.3g — the solar-eclipse LOCATION tier: shadow geometry on the FULL
834
+ // series (sceneEvalAt — the truncated finder forms omit the fitted Lp
835
+ // anchor and the −2235·sin(Lp) β family, deliberate for finder
836
+ // statistics, required here) at the model's ABSOLUTE TT (deltaTStart +
837
+ // curve, the same convention every other ΔT consumer reads). Both axis
838
+ // conventions are measured against the NASA path-table centerlines —
839
+ // see eclipse/besselian.cjs.
840
+ const besselian = createBesselian({
841
+ moonFullAtDaysTT: /** @param {number} dDaysTT */ (dDaysTT) => {
842
+ const ev = moonSeries.sceneEvalAt(dDaysTT);
843
+ return { lonDeg: ev.lonDeg, latDeg: ev.latDeg, distKm: ev.distKm };
844
+ },
845
+ sunLonDegAt: /** @param {number} jdUT */ (jdUT) => eclipseFinders.sunLonDegAt(jdUT),
846
+ deltaTSecondsAt: /** @param {number} jd */ (jd) => (jdTTFromUT(jd) - jd) * 86400,
847
+ obliquityDegAt: obliquityDeg,
848
+ eccentricityAt,
849
+ perihelionLongitudeDegAt: earthPerihelionDeg,
850
+ yearFromJD,
851
+ constants: {
852
+ j2000JD,
853
+ julianCenturyDays,
854
+ earthDiameterKm: C.bodyDiametersKm.earth,
855
+ moonDiameterKm: C.bodyDiametersKm.moon,
856
+ sunDiameterKm: C.bodyDiametersKm.sun,
857
+ sunDistanceKm: currentAUDistance,
858
+ earthFlatteningInverse: C.physicalConstants.earthFlatteningInverseWGS84,
859
+ ttBridgeSeconds: C.earthOrbital.deltaTStart,
860
+ gmstMeanSiderealT0Deg: C.physicalConstants.gmstMeanSiderealT0Deg,
861
+ gmstMeanSiderealRateDegPerDay: C.physicalConstants.gmstMeanSiderealRateDegPerDay,
862
+ gmstMeanSiderealT2Deg: C.physicalConstants.gmstMeanSiderealT2Deg,
863
+ },
864
+ });
865
+
819
866
  // ── The assembled surface ─────────────────────────────────────────────────
820
867
  return Object.freeze({
821
868
  time: Object.freeze({
@@ -874,6 +921,9 @@ export function assembleModel(C, F) {
874
921
  findLunarInRange: /** @param {number} jdStart @param {number} jdEnd */ (jdStart, jdEnd) => eclipseFinders.findLunarEclipsesInRange(jdStart, jdEnd),
875
922
  findSolarInRange: /** @param {number} jdStart @param {number} jdEnd */ (jdStart, jdEnd) => eclipseFinders.findSolarEclipsesInRange(jdStart, jdEnd),
876
923
  deltaTSecondsAtJD: frameworkDeltaTSecondsAtJD,
924
+ // 20.3g location tier (see eclipse/besselian.cjs):
925
+ umbraGroundAtJD: /** @param {number} jd @returns {{latDeg: number, lonDeg: number} | null} */ (jd) => besselian.umbraGroundAt(jd),
926
+ solarLocalCircumstances: /** @param {number} jdGreatest @param {number} latDeg @param {number} lonDeg */ (jdGreatest, latDeg, lonDeg) => besselian.localCircumstances(jdGreatest, latDeg, lonDeg),
877
927
  }),
878
928
  climate: Object.freeze({
879
929
  l1OrbitalPermil: evalClimateL1,
@@ -48,7 +48,7 @@
48
48
  * argsAt: (jdTT: number) => {Lp: number, D: number, M: number, Mp: number, F: number},
49
49
  * eFactorForD: (dDaysTT: number, T: number, T2: number) => number,
50
50
  * eFactorAtJdTT: (jdTT: number, T: number, T2: number) => number,
51
- * getMoonDistanceKm: () => number,
51
+ * getMoonDistanceKm: (jdTT?: number) => number,
52
52
  * getEccentricityBase: () => number,
53
53
  * deltaTSeconds: (jdUT: number) => number,
54
54
  * jdToSIyear: (jd: number) => number,
@@ -147,9 +147,17 @@ function createMoonSeries({ constants, fns }) {
147
147
  * @param {number} Dr @param {number} Mr @param {number} Mpr
148
148
  * @param {number} Fr @param {number} E @param {number} E2
149
149
  * @returns {number} km */
150
- function fullDistanceKm(Dr, Mr, Mpr, Fr, E, E2) {
150
+ // 20.3d(i): the Driver-1 ratio is evaluated AT THE EVALUATED EPOCH — the
151
+ // getter receives the evaluation jdTT so each engine can answer with its
152
+ // pure a_M(t) evaluator (per-jd deterministic; previously the browser
153
+ // answered with whatever epoch the render loop last set — a scene-state
154
+ // timing accident — while the Node engines pinned the J2000 constant).
155
+ // Engines whose getter ignores the argument keep their old behavior.
156
+ /** @param {number} Dr @param {number} Mr @param {number} Mpr @param {number} Fr
157
+ * @param {number} E @param {number} E2 @param {number} [jdTT] @returns {number} */
158
+ function fullDistanceKm(Dr, Mr, Mpr, Fr, E, E2, jdTT) {
151
159
  const Sr = sumTableCos(moonR, Dr, Mr, Mpr, Fr, E, E2);
152
- return (moonRMeanKm + Sr * 1e-3) * (getMoonDistanceKm() / moonDistanceJ2000Km);
160
+ return (moonRMeanKm + Sr * 1e-3) * (getMoonDistanceKm(jdTT) / moonDistanceJ2000Km);
153
161
  }
154
162
 
155
163
  /** The PRODUCTION scene evaluation at d days TT from J2000 (the caller
@@ -192,7 +200,7 @@ function createMoonSeries({ constants, fns }) {
192
200
  const fullSl = Sl + (2 * eocHalf / D2R * 1e6) * Math.sin(Mpr)
193
201
  + (1.25 * eocHalf * eocHalf / D2R * 1e6) * Math.sin(2 * Mpr);
194
202
  const lonDeg = Lp / D2R + fullSl * 1e-6 + moonMeeusLpCorrectionDeg;
195
- const distKm = fullDistanceKm(Dr, Mr, Mpr, Fr, E, E2);
203
+ const distKm = fullDistanceKm(Dr, Mr, Mpr, Fr, E, E2, j2000JD + d);
196
204
  return { thetaAddRad, lonDeg, latRad, latDeg, distKm, T };
197
205
  }
198
206
 
@@ -242,7 +250,7 @@ function createMoonSeries({ constants, fns }) {
242
250
  const args = argsAt(jdTT);
243
251
  const Dr = args.D * D2R, Mr = args.M * D2R, Mpr = args.Mp * D2R, Fr = args.F * D2R;
244
252
  const E = eFactorAtJdTT(jdTT, T, T * T);
245
- return fullDistanceKm(Dr, Mr, Mpr, Fr, E, E * E);
253
+ return fullDistanceKm(Dr, Mr, Mpr, Fr, E, E * E, jdTT);
246
254
  }
247
255
 
248
256
  return { sceneEvalAt, truncatedLonDeg, truncatedBetaDeg, truncatedDistanceKm, additionalArgs };