@essrt/physics 9.2.0 → 11.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.
package/README.md CHANGED
@@ -87,6 +87,27 @@ solar/lunar eclipse search over a JD window, greatest-eclipse convention),
87
87
  records and orientation at epoch). Validation targets are refused as
88
88
  inputs — a counterfactual cannot move the goalposts it is judged by.
89
89
 
90
+ ### The series artifact
91
+
92
+ Earth's published orbit history (eccentricity, perihelion, obliquity) is
93
+ the engine's banked N-body series — a 9-MB governed artifact kept in the
94
+ [public repository](https://github.com/dvansonsbeek/3d)
95
+ (`data/nbody-secular-series.json`), not inside this package. Pass it in to
96
+ get the published values:
97
+
98
+ ```js
99
+ const series = JSON.parse(readFileSync('nbody-secular-series.json', 'utf8'));
100
+ const model = createModel(undefined, { secularSeriesArtifact: series });
101
+ model.earth.eccentricity(2000 - 100000); // 0.04008 — the series (La2004: 0.04006)
102
+ createModel().earth.eccentricity(2000 - 100000); // 0.03374 — the mode table
103
+ ```
104
+
105
+ Without the artifact `createModel()` evaluates the embedded 18-term mode
106
+ table at every epoch: exact at J2000, a compact approximation away from it
107
+ (RMS 3.6·10⁻³ in e against La2010a over the last 500 kyr, where the series
108
+ reads 2.5·10⁻⁵). The hosted API, the MCP server and the simulator always
109
+ pass the artifact.
110
+
90
111
  ## Underneath: a parts library
91
112
 
92
113
  `createModel()` is assembly over unassembled factories. Each factory
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@essrt/physics",
3
- "version": "9.2.0",
3
+ "version": "11.0.0",
4
4
  "description": "ESSRT (Expanding Solar System Resonance Theory) physics core — 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
- "modelVersion": "v14.0",
6
+ "modelVersion": "v16.0",
7
7
  "_note": "The model identity this package version ships (IP-unified-architecture §10: package semver tracks the API axis; the model version + CONSTANTS_HASH/COEFFICIENTS_HASH in src/constants/ carry the scientific identity). The version-pinning gate enforces this pairing against public/input/model-version.json."
8
8
  },
9
9
  "type": "module",
@@ -85,17 +85,35 @@ function laggedL1Terms(l1Terms, relaxationKyr) {
85
85
  * relaxationKyr: number,
86
86
  * alphaGiaRateJ2000PerYr: number,
87
87
  * alphaJ2000: number,
88
- * }} cfg - relaxationKyr and alphaGiaRateJ2000PerYr are model-parameters
89
- * deepTime constants; alphaJ2000 the IERS moment-of-inertia factor
88
+ * j2RateJ2000PerYr?: number,
89
+ * j2J2000?: number,
90
+ * }} cfg - relaxationKyr, alphaGiaRateJ2000PerYr and j2RateJ2000PerYr are
91
+ * model-parameters deepTime constants; alphaJ2000 the IERS
92
+ * moment-of-inertia factor; j2J2000 Earth's J₂ (astro-reference)
90
93
  * @returns {{
91
94
  * alphaAt: (year: number) => number,
95
+ * j2RatioAt: (year: number) => number,
92
96
  * laggedL1PermilAt: (year: number) => number,
93
97
  * kPerPermille: number,
94
98
  * relaxationKyr: number,
95
99
  * laggedL1Terms: Array<{period_kyr: number, a: number, b: number}>,
96
100
  * }}
101
+ *
102
+ * THE SAME REDISTRIBUTION, ITS SECOND OBSERVABLE — j2RatioAt(year) = J₂(t)/J₂₀.
103
+ * The mass that changes the polar moment changes the oblateness, and the
104
+ * luni-solar precession rate is proportional to it (ψ̇ ∝ (C − A)/C; the
105
+ * consumer is the spin integration, earth/deep-orbital-history). The J₂ side
106
+ * is scaled on its OWN observation, exactly as the α side is on its rate:
107
+ *
108
+ * J₂(t) = J₂₀ − k_J · [ L1'(t) − L1'(2000) ], k_J = −(dJ₂/dt)_obs / (dL1'/dt)₂₀₀₀
109
+ *
110
+ * so dJ₂/dt(J2000) IS the observed satellite rate (Cox & Chao 2002) by
111
+ * construction, and the J₂→α conversion factor never enters. A constant
112
+ * dJ₂/dt would be a rate valid at a point used across a span; this is the
113
+ * history. Bounded: ±5·10⁻⁴ over the glacial cycles, no secular part.
114
+ * Without the two constants the ratio is 1 at every year.
97
115
  */
98
- function createAlphaGiaChannel({ l1Terms, yStdDenormalization, relaxationKyr, alphaGiaRateJ2000PerYr, alphaJ2000 }) {
116
+ function createAlphaGiaChannel({ l1Terms, yStdDenormalization, relaxationKyr, alphaGiaRateJ2000PerYr, alphaJ2000, j2RateJ2000PerYr, j2J2000 }) {
99
117
  const lagged = laggedL1Terms(l1Terms, relaxationKyr);
100
118
  const regime = { l1Terms: lagged, yStdDenormalization };
101
119
  // dL'/dyear at J2000 in closed form: with t = (2000 − year)/1000,
@@ -105,8 +123,14 @@ function createAlphaGiaChannel({ l1Terms, yStdDenormalization, relaxationKyr, al
105
123
  const slopeJ2000PermillePerYr = (-sum / 1000) * yStdDenormalization;
106
124
  const kPerPermille = -alphaGiaRateJ2000PerYr / slopeJ2000PermillePerYr; // dα/dt = −k · dL'/dt
107
125
  const laggedJ2000 = evalClimateL1OrbitalPermil(2000, regime);
126
+ const hasJ2 = j2RateJ2000PerYr !== undefined && j2J2000 !== undefined;
127
+ // (k_J / J₂₀) per ‰ — the relative oblateness coupling, from the observed dJ₂/dt
128
+ const j2RelPerPermille = hasJ2 ? (-j2RateJ2000PerYr / slopeJ2000PermillePerYr) / j2J2000 : 0;
108
129
  return {
109
130
  alphaAt: (year) => alphaJ2000 - kPerPermille * (evalClimateL1OrbitalPermil(year, regime) - laggedJ2000),
131
+ j2RatioAt: hasJ2
132
+ ? (year) => 1 - j2RelPerPermille * (evalClimateL1OrbitalPermil(year, regime) - laggedJ2000)
133
+ : () => 1,
110
134
  laggedL1PermilAt: (year) => evalClimateL1OrbitalPermil(year, regime),
111
135
  kPerPermille,
112
136
  relaxationKyr,
@@ -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: "b9c4d78647577e67";
5
+ export declare const CONSTANTS_HASH: "726b10e23d8ace09";
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: "b9c4d78647577e67";
12
+ readonly hash: "726b10e23d8ace09";
13
13
  readonly additionalBodies: {
14
14
  "pluto": {
15
15
  "name": string;
@@ -136,6 +136,7 @@ export declare const DEFAULT_CONSTANTS: {
136
136
  "alpha4PerMa4": number;
137
137
  "alphaGiaRateJ2000PerYr": number;
138
138
  "alphaGiaRelaxationKyr": number;
139
+ "j2RateJ2000PerYr": number;
139
140
  "dtStackTaperFullHalfwidthYr": number;
140
141
  "dtStackTaperTotalHalfwidthYr": number;
141
142
  "recessionRegime": {
@@ -33,17 +33,17 @@
33
33
  * carry it so a counterfactual is reproducible (§2d).
34
34
  * @type {string}
35
35
  */
36
- export const CONSTANTS_HASH = "b9c4d78647577e67";
36
+ export const CONSTANTS_HASH = "726b10e23d8ace09";
37
37
 
38
38
  /** Model version label — single source: public/input/model-version.json (§10 two-axis scheme). */
39
- export const MODEL_VERSION = "v14.0";
39
+ export const MODEL_VERSION = "v16.0";
40
40
 
41
41
  /** Canonical preprint DOI — single source: public/input/model-version.json. */
42
42
  export const PREPRINT_DOI = "10.21203/rs.3.rs-8758810/v4";
43
43
 
44
44
  /** @type {Readonly<Record<string, unknown>>} */
45
45
  export const DEFAULT_CONSTANTS = Object.freeze({
46
- hash: "b9c4d78647577e67",
46
+ hash: "726b10e23d8ace09",
47
47
  additionalBodies: {
48
48
  "pluto": {
49
49
  "name": "Pluto",
@@ -182,6 +182,7 @@ export const DEFAULT_CONSTANTS = Object.freeze({
182
182
  "alpha4PerMa4": 1.3619800519e-16,
183
183
  "alphaGiaRateJ2000PerYr": -1.35e-11,
184
184
  "alphaGiaRelaxationKyr": 6,
185
+ "j2RateJ2000PerYr": -2.7e-11,
185
186
  "dtStackTaperFullHalfwidthYr": 300000,
186
187
  "dtStackTaperTotalHalfwidthYr": 400000,
187
188
  "recessionRegime": {
@@ -45,6 +45,8 @@
45
45
  * axialPrecessionYearsJ2000: number,
46
46
  * obliquityJ2000Deg: number,
47
47
  * axialPrecessionYearsAtYearFn?: (year: number) => number,
48
+ * solarTorqueShareJ2000?: number,
49
+ * dynamicalEllipticityRatioAtYearFn?: (year: number) => number,
48
50
  * }} deps — mode tables from the governed deep artifact (choose the ζ tier
49
51
  * per consumer: era = its own 8-term extraction, deep = the 16-term
50
52
  * table; NEVER a slice); anchors from the chain artifact's one home; the
@@ -57,13 +59,17 @@
57
59
  * vs La2004 over −200 kyr vs the deep tier's 0.069° — the C-1 verdict,
58
60
  * plan 02) and zetaModes serves only as the TAIL beyond the span (the
59
61
  * seam at the span edge is the extraction residual, far outside every
60
- * certified window).
62
+ * certified window). solarTorqueShareJ2000 and
63
+ * dynamicalEllipticityRatioAtYearFn: THE PRECESSION OF DATE — the two
64
+ * factors on the precession constant (see alphaAtGeneral below); absent,
65
+ * the constant is the secular law alone, bit-identical.
61
66
  */
62
67
  function createDeepOrbitalHistory({
63
68
  zModes, zetaModes, zetaSeries, zSeries,
64
69
  anchorE, anchorPeriEclipticDeg, anchorInclEclipticDeg, anchorAscNodeEclipticDeg,
65
70
  axialPrecessionYearsJ2000, obliquityJ2000Deg,
66
71
  axialPrecessionYearsAtYearFn,
72
+ solarTorqueShareJ2000, dynamicalEllipticityRatioAtYearFn,
67
73
  }) {
68
74
  const D2R = Math.PI / 180, R2D = 180 / Math.PI;
69
75
 
@@ -198,10 +204,59 @@ function createDeepOrbitalHistory({
198
204
  // dynamical). No new constants: ψ̇(t)'s J2000 anchor is the same
199
205
  // certified year-length machinery; only its deep-time scaling is injected. Absent the option, α stays constant (the
200
206
  // pre-D1 ±Myr-class behavior, bit-identical).
201
- const alphaAtGeneral = axialPrecessionYearsAtYearFn
207
+ const alphaSecular = axialPrecessionYearsAtYearFn
202
208
  ? (/** @type {number} */ t) =>
203
209
  ((2 * Math.PI) / axialPrecessionYearsAtYearFn(2000 + t)) / Math.cos(EPS0)
204
210
  : () => ALPHA;
211
+ // THE PRECESSION OF DATE (plan 06 §9 item 10 — owner: "adopt it in the
212
+ // model"). The secular law above scales the J2000 rate on the tidal history;
213
+ // the torque of date carries two more factors, both exactly 1 at J2000:
214
+ //
215
+ // SOLAR TORQUE — ∝ (1 − e²)^(−3/2): the Sun's share f_S of the torque is
216
+ // the engine's own solar-share formula, which holds e at its J2000 value;
217
+ // here it rides the eccentricity OF DATE, the z this factory already
218
+ // carries. Measured: the frozen form runs the precession 0.032 % slow on
219
+ // the million-year mean (the mean e² sits above today's) — 4.5° of
220
+ // equinox at −1 Myr, the obliquity ~100″ rms off La2004 there; with the
221
+ // factor it holds 12″ rms over the last Myr (La2004 integrates the same
222
+ // eccentricity-dependent torque).
223
+ //
224
+ // DYNAMICAL ELLIPTICITY — ψ̇ ∝ (C − A)/C ∝ J₂: the injected J₂(t)/J₂₀ of
225
+ // the engine's GIA channel (climate/l1-orbital j2RatioAt — the ice-age
226
+ // mass redistribution the length-of-day side already carries, scaled on
227
+ // the observed dJ₂/dt). Bounded ±5·10⁻⁴, no secular part.
228
+ //
229
+ // Together they close the millennial equinox: against Vondrák et al. (2011)
230
+ // the lag was −26.5″ at −2950 and −20.1″ at −2450 (the Sun's whole residual
231
+ // against Horizons there); with them −6.3″ and −3.7″, ≤ 0.3″ inside
232
+ // −450…+2500 (banked by tools/verify/equinox-vs-vondrak.js, which refuses
233
+ // to write if a factor is dropped). Absent both options the constant is
234
+ // the secular law alone — bit-identical to the pre-adoption factory.
235
+ const hasOfDate = solarTorqueShareJ2000 !== undefined || dynamicalEllipticityRatioAtYearFn !== undefined;
236
+ const oneMinusE0Sq = (() => { const [x0, y0] = zAt(0); return 1 - (x0 * x0 + y0 * y0); })();
237
+ const solarTorqueFactor = solarTorqueShareJ2000 !== undefined
238
+ ? (/** @type {number} */ t) => {
239
+ const [zx, zy] = zAt(t);
240
+ return 1 + solarTorqueShareJ2000 * (Math.pow((1 - (zx * zx + zy * zy)) / oneMinusE0Sq, -1.5) - 1);
241
+ }
242
+ : () => 1;
243
+ const ellipticityFactor = dynamicalEllipticityRatioAtYearFn
244
+ ? (/** @type {number} */ t) => dynamicalEllipticityRatioAtYearFn(2000 + t)
245
+ : () => 1;
246
+ // Two-entry memo on the precession constant of date: RK4 asks k2 and k3 at
247
+ // the same instant and the next step's k1 repeats this step's k4 — half
248
+ // the evaluations of the secular law AND of the two factors (measured: the
249
+ // tier builds cost what they did before the factors), the values
250
+ // bit-identical (a pure function of t).
251
+ let memoT0 = NaN, memoV0 = 0, memoT1 = NaN, memoV1 = 0;
252
+ const alphaOfDate = (/** @type {number} */ t) => {
253
+ if (t === memoT0) return memoV0;
254
+ if (t === memoT1) return memoV1;
255
+ const v = alphaSecular(t) * (solarTorqueFactor(t) * ellipticityFactor(t));
256
+ memoT1 = memoT0; memoV1 = memoV0; memoT0 = t; memoV0 = v;
257
+ return v;
258
+ };
259
+ const alphaAtGeneral = hasOfDate ? alphaOfDate : alphaSecular;
205
260
 
206
261
  const cross = (/** @type {number[]} */ a, /** @type {number[]} */ b) =>
207
262
  [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
@@ -287,12 +342,46 @@ function createDeepOrbitalHistory({
287
342
  return [o[0] / r, o[1] / r, o[2] / r];
288
343
  };
289
344
 
290
- /** Equinox-node longitude only (J2000 ecliptic frame) — the light read
291
- * used for the node-rate finite difference at store time. */
292
- function eqLonOnlyDeg(/** @type {number[]} */ s, /** @type {number} */ t) {
345
+ /** The equinox's GENERAL-PRECESSION longitude (deg, J2000 ecliptic frame):
346
+ * the broken angle through the ascending node of the ecliptic of date on
347
+ * the J2000 ecliptic — Π (the node's longitude) plus the arc along the
348
+ * ecliptic of date from that node to the equinox. This is the p_A of the
349
+ * precession literature (Lieske 1977; Simon et al. 1994: p_A = Λ_A − Π_A):
350
+ * the quantity the mean longitude OF DATE subtracts from the fixed-frame
351
+ * mean longitude — the N-body λ = Ω + ω + M is itself this broken angle
352
+ * (lattice-long-window-test.mjs), so the tropical year of date must be
353
+ * built on THIS rate. NOT the projected longitude atan2(g_y, g_x)
354
+ * (equinoxLonJ2000Deg, which the frame reconstruction needs): the two
355
+ * agree at J2000, where the ecliptic of date coincides with the J2000
356
+ * ecliptic, and part with the SQUARE of the ecliptic's tilt to its J2000
357
+ * position — (i²/4)·sin 2u class: 0.9″ at 0 AD, 5.9″ at −3000,
358
+ * arcminutes at deep time; as a rate 0.05 s of tropical year at ±4 kyr,
359
+ * 0.1–0.2 s at ±10 kyr, ~1 s beyond 100 kyr (measured 2026-09: the scene's
360
+ * own sidereal angle read exactly this against the projected-rate law at
361
+ * 16 epochs, and the re-booked Sun is flat against DE441 in the inertial
362
+ * frame over −2500…+2000 where the projected booking ramped to +5.6″).
363
+ * Degenerate node as i → 0: the dogleg's two parts compensate each other's
364
+ * error, and below 1e-9 the projected form is the exact limit.
365
+ * @param {number[]} n the ecliptic pole of date @param {number[]} gu the unit equinox */
366
+ function generalPrecessionLonDeg(n, gu) {
367
+ const Nx = -n[1], Ny = n[0]; // ẑ × n̂ — the ascending node of the ecliptic of date
368
+ const sN = Math.hypot(Nx, Ny);
369
+ if (sN < 1e-9) return Math.atan2(gu[1], gu[0]) * R2D;
370
+ const N = [Nx / sN, Ny / sN, 0];
371
+ const Pi = Math.atan2(N[1], N[0]);
372
+ const u = Math.atan2(dot(n, cross(N, gu)), dot(N, gu));
373
+ return (Pi + u) * R2D;
374
+ }
375
+
376
+ /** Equinox-node longitudes only (J2000 ecliptic frame; projected and
377
+ * general-precession forms) — the light read used for the node-rate
378
+ * finite differences at store time. */
379
+ function eqLonsOnlyDeg(/** @type {number[]} */ s, /** @type {number} */ t) {
293
380
  const n = orbitNormal(t);
294
381
  const g = cross(s, n);
295
- return Math.atan2(g[1], g[0]) * R2D;
382
+ const gn = Math.hypot(g[0], g[1], g[2]);
383
+ const gu = [g[0] / gn, g[1] / gn, g[2] / gn];
384
+ return { proj: Math.atan2(gu[1], gu[0]) * R2D, general: generalPrecessionLonDeg(n, gu) };
296
385
  }
297
386
 
298
387
  /** One quantity bundle at time t (years from J2000) from spin axis s. */
@@ -328,26 +417,38 @@ function createDeepOrbitalHistory({
328
417
  // interpolation on the grid is exact to the ζ series' own resolution.
329
418
  orbitNormalX: n[0],
330
419
  orbitNormalY: n[1],
331
- // The equinox node (ŝ×n̂) longitude in the J2000 ecliptic frame —
332
- // its year-over-year retrograde advance IS the general precession of
333
- // date, wobble included (the n̂(t) geometry generates the equinox
334
- // wobble; ψ̇ itself is the secular α(H(t))). Consumers derive the
335
- // tropical year of date from it: T_trop = T_sid·(1 − p_yr/360°).
336
- // ⚠ RATE-CONSUMER CONTRACT (measured): this field's rate is correct
420
+ // The equinox node (ŝ×n̂) PROJECTED longitude in the J2000 ecliptic
421
+ // frame — the frame reconstruction's input (frame-of-date.cjs rebuilds
422
+ // the unit equinox from it). Its rate is the general precession only
423
+ // to first order in the ecliptic's tilt to its J2000 position: the
424
+ // tropical-year consumers read generalPrecessionLonDeg below (measured
425
+ // 2026-09 — the projected rate booked the Sun's mean longitude of date
426
+ // 5.9″ off at −3000 and the sidereal year of date 1 s off at depth).
427
+ // ⚠ RATE-CONSUMER CONTRACT (measured): these fields' rates are correct
337
428
  // AS IS — α is self-anchored to the LUNISOLAR rate at construction
338
429
  // (K_LUNI, the one-RK4-step probe above), so the realized general
339
- // precession p_geom(J2000) equals 360/axialPrecessionYearsJ2000 by
340
- // construction. No correction, no δ subtraction. History (why this
341
- // contract exists): before the self-anchor, α carried the GENERAL
342
- // sid/(sid−sol) rate and the n̂(t) geometry re-added the planetary
343
- // mean — a double-count of +0.097″/yr ≈ +2.4 s of tropical year at
344
- // J2000 — and consumers had to subtract the runtime anchor
430
+ // precession p_geom(J2000) equals 360/axialPrecessionYearsJ2000 per
431
+ // JULIAN year (the sampler's time unit) by construction. No
432
+ // correction, no δ subtraction. History (why this contract exists):
433
+ // before the self-anchor, α carried the GENERAL sid/(sid−sol) rate and
434
+ // the n̂(t) geometry re-added the planetary mean — a double-count of
435
+ // +0.097″/yr ≈ +2.4 s of tropical year at J2000 — and consumers had to
436
+ // subtract the runtime anchor
345
437
  // δ = p_geom(J2000) − 360/axialPrecessionYearsJ2000.
346
438
  // A consumer may still measure δ at runtime as a SELF-CHECK; it
347
439
  // must read ≈0 (sub-1e-4 ″/yr class). The LONGITUDE itself is raw
348
440
  // geometry and has always needed no correction.
349
441
  equinoxLonJ2000Deg: ((Math.atan2(gu[1], gu[0]) * R2D) % 360 + 360) % 360,
350
442
  equinoxLonRateDegPerYr: 0, // filled at store time (build's ±2.5-yr central difference)
443
+ // The equinox's GENERAL-PRECESSION longitude (the broken angle through
444
+ // the node of the ecliptic of date — the literature's p_A; see
445
+ // generalPrecessionLonDeg above): its year-over-year retrograde advance
446
+ // IS the general precession of date, wobble included (the n̂(t)
447
+ // geometry generates the equinox wobble; ψ̇ itself is the secular
448
+ // α(H(t)) times the two factors of date). The tropical year of date is
449
+ // built on this rate (earth/year-lengths.cjs).
450
+ generalPrecessionLonDeg: ((generalPrecessionLonDeg(n, gu) % 360) + 360) % 360,
451
+ generalPrecessionLonRateDegPerYr: 0, // filled at store time (build's ±2.5-yr central difference)
351
452
  };
352
453
  }
353
454
 
@@ -390,9 +491,10 @@ function createDeepOrbitalHistory({
390
491
  const storeWithRate = (/** @type {number} */ key, /** @type {number[]} */ sv, /** @type {number} */ tv) => {
391
492
  const smp = sampleAt(sv, tv);
392
493
  const hR = 2.5;
393
- const lp = eqLonOnlyDeg(rk4(sv, tv, +hR), tv + hR);
394
- const lm = eqLonOnlyDeg(rk4(sv, tv, -hR), tv - hR);
395
- smp.equinoxLonRateDegPerYr = (((lp - lm + 540) % 360) - 180) / (2 * hR);
494
+ const lp = eqLonsOnlyDeg(rk4(sv, tv, +hR), tv + hR);
495
+ const lm = eqLonsOnlyDeg(rk4(sv, tv, -hR), tv - hR);
496
+ smp.equinoxLonRateDegPerYr = (((lp.proj - lm.proj + 540) % 360) - 180) / (2 * hR);
497
+ smp.generalPrecessionLonRateDegPerYr = (((lp.general - lm.general + 540) % 360) - 180) / (2 * hR);
396
498
  grid.set(key, smp);
397
499
  };
398
500
  storeWithRate(0, s, 0);
@@ -421,6 +523,19 @@ function createDeepOrbitalHistory({
421
523
  const f = (tYr - k0) / stepYr;
422
524
  const lerp = (/** @type {number} */ x, /** @type {number} */ y) => x + (y - x) * f;
423
525
  const dAng = (/** @type {number} */ x, /** @type {number} */ y) => x + (((y - x + 540) % 360) - 180) * f;
526
+ // C1 (cubic Hermite) on a wrapped angle with node rates (deg/yr) —
527
+ // linear interpolation here made the realized equinox RATE
528
+ // piecewise-constant per grid cell (the measured per-year
529
+ // precession staircase). Node rates come from the build's own
530
+ // ±2.5-yr central differences.
531
+ const hermiteAngle = (/** @type {number} */ l0, /** @type {number} */ l1, /** @type {number} */ r0, /** @type {number} */ r1) => {
532
+ const dl = ((l1 - l0 + 540) % 360) - 180; // unwrapped segment
533
+ const m0 = r0 * stepYr, m1 = r1 * stepYr;
534
+ const f2 = f * f, f3 = f2 * f;
535
+ const v = (2 * f3 - 3 * f2 + 1) * l0 + (f3 - 2 * f2 + f) * m0
536
+ + (-2 * f3 + 3 * f2) * (l0 + dl) + (f3 - f2) * m1;
537
+ return ((v % 360) + 360) % 360;
538
+ };
424
539
  return {
425
540
  epsDeg: lerp(a.epsDeg, b.epsDeg),
426
541
  e: lerp(a.e, b.e),
@@ -430,21 +545,12 @@ function createDeepOrbitalHistory({
430
545
  inclEclDeg: lerp(a.inclEclDeg, b.inclEclDeg),
431
546
  orbitNormalX: lerp(a.orbitNormalX, b.orbitNormalX),
432
547
  orbitNormalY: lerp(a.orbitNormalY, b.orbitNormalY),
433
- // C1 (cubic Hermite) — linear interpolation here made the
434
- // realized equinox RATE piecewise-constant per grid cell (the
435
- // measured per-year precession staircase). Node rates come from
436
- // the build's own ±2.5-yr central differences.
437
- equinoxLonJ2000Deg: (() => {
438
- const l0 = a.equinoxLonJ2000Deg;
439
- const dl = ((b.equinoxLonJ2000Deg - l0 + 540) % 360) - 180; // unwrapped segment
440
- const m0 = a.equinoxLonRateDegPerYr * stepYr;
441
- const m1 = (b.equinoxLonRateDegPerYr ?? a.equinoxLonRateDegPerYr) * stepYr;
442
- const f2 = f * f, f3 = f2 * f;
443
- const v = (2 * f3 - 3 * f2 + 1) * l0 + (f3 - 2 * f2 + f) * m0
444
- + (-2 * f3 + 3 * f2) * (l0 + dl) + (f3 - f2) * m1;
445
- return ((v % 360) + 360) % 360;
446
- })(),
548
+ equinoxLonJ2000Deg: hermiteAngle(a.equinoxLonJ2000Deg, b.equinoxLonJ2000Deg, a.equinoxLonRateDegPerYr, b.equinoxLonRateDegPerYr ?? a.equinoxLonRateDegPerYr),
447
549
  equinoxLonRateDegPerYr: lerp(a.equinoxLonRateDegPerYr, b.equinoxLonRateDegPerYr ?? a.equinoxLonRateDegPerYr),
550
+ // the same C1 form for the general-precession longitude (its
551
+ // year-over-year difference is the tropical-year law's p).
552
+ generalPrecessionLonDeg: hermiteAngle(a.generalPrecessionLonDeg, b.generalPrecessionLonDeg, a.generalPrecessionLonRateDegPerYr, b.generalPrecessionLonRateDegPerYr ?? a.generalPrecessionLonRateDegPerYr),
553
+ generalPrecessionLonRateDegPerYr: lerp(a.generalPrecessionLonRateDegPerYr, b.generalPrecessionLonRateDegPerYr ?? a.generalPrecessionLonRateDegPerYr),
448
554
  };
449
555
  },
450
556
  };
@@ -456,6 +562,12 @@ function createDeepOrbitalHistory({
456
562
  // D4e diagnostics: the self-anchored lunisolar rate actually integrated.
457
563
  alphaLunisolarArcsecPerYr: ALPHA * K_LUNI * R2D * 3600,
458
564
  axialPrecessionYearsLunisolarJ2000: axialPrecessionYearsJ2000 / K_LUNI,
565
+ /** THE PRECESSION OF DATE — the two factors this integration applies to
566
+ * the secular precession constant at t years from J2000 (both 1 at
567
+ * t = 0; both 1 everywhere when the options are absent). ONE home for
568
+ * the published surface and the docs.
569
+ * @param {number} t @returns {{solarTorque: number, ellipticity: number}} */
570
+ precessionOfDateFactorsAt: (t) => ({ solarTorque: solarTorqueFactor(t), ellipticity: ellipticityFactor(t) }),
459
571
  };
460
572
  }
461
573