@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.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @essrt/physics
2
+
3
+ The physics core of **ESSRT** — the Expanding Solar System Resonance Theory —
4
+ as implemented by the [Holistic Universe Model](https://3d.holisticuniverse.com)
5
+ ([source, AGPL-3.0](https://github.com/dvansonsbeek/3d)). Pure computation:
6
+ no I/O, no globals, no DOM, constants injected. The package ships the
7
+ **complete model**, including the fitted coefficients, so an installed copy
8
+ reproduces the hosted simulator bit-for-bit on the shared golden masters.
9
+
10
+ ## Versioning — two axes, deliberately
11
+
12
+ - **Package semver** (this package's `version`) tracks the **API axis** —
13
+ what `import` gives you.
14
+ - **Model identity** rides *inside* every published version: the
15
+ `essrt.modelVersion` field in `package.json` plus `CONSTANTS_HASH` and
16
+ `COEFFICIENTS_HASH` in `src/constants/`. A refit or structural model
17
+ change always lands as a new package version — npm's immutability then
18
+ guarantees a pinned version's results never change.
19
+
20
+ Cite results as *model vX.Y (package A.B.C)*.
21
+
22
+ ## Usage
23
+
24
+ ```js
25
+ import { DEFAULT_CONSTANTS, FITTED_COEFFICIENTS, createEpochPrimitives } from '@essrt/physics';
26
+ ```
27
+
28
+ Domain modules are CJS subpath exports, each a `create*` factory taking its
29
+ dependencies explicitly (the injectable-constants design — counterfactual
30
+ runs are first-class):
31
+
32
+ ```js
33
+ const { createEclipseFinders } = require('@essrt/physics/eclipse/finders');
34
+ const { createPredictivePrecession } = require('@essrt/physics/planets/predict');
35
+ ```
36
+
37
+ See the `exports` map in `package.json` for the full surface: moon
38
+ (arguments/series/apparent/ecc-channel/month-chain), planets (geometry,
39
+ corrections, predict, model, fibonacci-laws, …), deltat (cycles, deep-time,
40
+ historical), cardinal points, phase, chain-cycles, sun, climate, eclipse
41
+ finders, and the published reference curves.
42
+
43
+ ## Provenance
44
+
45
+ Every value derives from the model parameters and fitted coefficients in
46
+ this package; the fitting pipeline, verification gates, dataset manifest and
47
+ documentation live in the [public repository](https://github.com/dvansonsbeek/3d).
48
+ Preprint: <https://doi.org/10.21203/rs.3.rs-8758810/v4>.
49
+
50
+ ## Licence
51
+
52
+ AGPL-3.0-or-later. Commercial licensing: dennis@holisticuniverse.com.
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@essrt/physics",
3
+ "version": "1.0.0",
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
+ "essrt": {
6
+ "modelVersion": "v10.0",
7
+ "_note": "The model identity this package version ships (IP-unified-architecture \u00a710: 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
+ },
9
+ "type": "module",
10
+ "main": "src/index.js",
11
+ "files": [
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/dvansonsbeek/3d.git",
18
+ "directory": "packages/physics"
19
+ },
20
+ "homepage": "https://3d.holisticuniverse.com",
21
+ "keywords": [
22
+ "essrt",
23
+ "astronomy",
24
+ "solar-system",
25
+ "deep-time",
26
+ "eclipse",
27
+ "precession",
28
+ "geocentric-model"
29
+ ],
30
+ "exports": {
31
+ ".": "./src/index.js",
32
+ "./phase": "./src/phase/index.cjs",
33
+ "./cardinal": "./src/cardinal/index.cjs",
34
+ "./moon/ecc-channel": "./src/moon/ecc-channel.cjs",
35
+ "./moon/month-chain": "./src/moon/month-chain.cjs",
36
+ "./chain-cycles": "./src/chain-cycles/index.cjs",
37
+ "./moon/arguments": "./src/moon/arguments.cjs",
38
+ "./moon/series": "./src/moon/series.cjs",
39
+ "./moon/apparent": "./src/moon/apparent.cjs",
40
+ "./planets/geometry": "./src/planets/geometry.cjs",
41
+ "./planets/fibonacci-laws": "./src/planets/fibonacci-laws.cjs",
42
+ "./planets/ecc-channel": "./src/planets/ecc-channel.cjs",
43
+ "./planets/orientation": "./src/planets/orientation.cjs",
44
+ "./planets/asc-node-integrator": "./src/planets/asc-node-integrator.cjs",
45
+ "./planets/orbit-chain": "./src/planets/orbit-chain.cjs",
46
+ "./planets/corrections": "./src/planets/corrections.cjs",
47
+ "./planets/predict": "./src/planets/predict.cjs",
48
+ "./planets/model": "./src/planets/model.cjs",
49
+ "./deltat/cycles": "./src/deltat/cycles.cjs",
50
+ "./deltat/deep-time": "./src/deltat/deep-time.cjs",
51
+ "./deltat/historical": "./src/deltat/historical.cjs",
52
+ "./climate/l1-orbital": "./src/climate/l1-orbital.cjs",
53
+ "./eclipse/finders": "./src/eclipse/finders.cjs",
54
+ "./reference/published-curves": "./src/reference/published-curves.cjs",
55
+ "./sun/longitude-correction": "./src/sun/longitude-correction.cjs"
56
+ },
57
+ "license": "AGPL-3.0-or-later"
58
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Cardinal-point model — THE shared implementation (Phase 7.2).
3
+ *
4
+ * The §10 derived form and everything on it, extracted verbatim from
5
+ * tools/lib/orbital-engine.js (which the browser mirrored function-for-
6
+ * function — this file replaces both copies):
7
+ *
8
+ * JD_X(Y) = anchor_X + ΣT_trop(Y) + δ_X(Y) − δ_X(2000)
9
+ * ΣT_trop = lincoef·(Y−2000) + driftTerm(Y) + Ih(Y)
10
+ * δ_X = sinusoids + equation-of-centre orders (e(t)ⁿ·sin(nM), e(t)
11
+ * the LAW OF COSINES) + §10g quadrature-locked joint sidebands
12
+ * T_X(Y) = the EXACT term-by-term derivative of JD_X
13
+ *
14
+ * LOAD-BEARING, do not "improve":
15
+ * - lincoef/h0/h1 come from the fit and are used VERBATIM — recomputing
16
+ * lincoef from the 1-year anchor injects a −12,276 s ramp; holding H
17
+ * constant inside Ih costs up to 5.2 s (§10c/§10e-quinquies).
18
+ * - The drift term is Simpson on 2000-yr NODE SPACING (not a fixed node
19
+ * count: that was exact in-window and −33,758 s at −380 Ma) plus the
20
+ * Euler–Maclaurin endpoint term (omitting it costs 6–13 s).
21
+ * - The equation-of-centre terms are {order,sin,cos}, NOT sinusoids — read
22
+ * as H/16+H/32 harmonics they are wrong by the whole ~1.78 d braid.
23
+ * - §10g joint sidebands are SHARED across the four points with phase
24
+ * order·λ_X − 2π·div·c — COUNTER-rotating. The minus sign is load-bearing:
25
+ * the co-rotating sense captures NOTHING (measured — the sign experiment,
26
+ * doc 99 "the braid law").
27
+ * - The year-length derivative keeps ONE deliberate divergence from the
28
+ * exact derivative: its drift part uses the real-LOD convention
29
+ * (`meanYearRealLodDays`) — the tweakpane's epoch-local days ("~400 days
30
+ * at the Devonian") — where the JD form integrates the SI form. Equal at
31
+ * J2000 up to the known 118 ms fit-basis gap.
32
+ *
33
+ * The `cyclesBetween` the caller injects is the ENGINE'S OWN function, so
34
+ * each engine's deep-time/snapshot toggle semantics are preserved exactly;
35
+ * under deep time both engines now route it through @essrt/physics/phase.
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ /** @type {Record<string, number>} */
41
+ const JOINT_LAMBDA = { SS: 0, AE: Math.PI / 2, WS: Math.PI, VE: 1.5 * Math.PI };
42
+
43
+ const DRIFT_NODE_SPACING_YEARS = 2000;
44
+ const DRIFT_SIMPSON_N_MIN = 64;
45
+
46
+ /** @typedef {{ lincoef: number, h0: number, h1: number }} DerivedCoefs */
47
+ /** @typedef {{ order: number, sin: number, cos: number }} EccTerm */
48
+ /** @typedef {{ order: number, div: number, sin: number, cos: number }} JointTerm */
49
+
50
+ /**
51
+ * @param {{
52
+ * isDeepTime: () => boolean,
53
+ * constants: {
54
+ * anchors: Record<string, number>,
55
+ * harmonics: Record<string, Array<[number, number, number]>>,
56
+ * eccTerms: (Record<string, EccTerm[]> | null),
57
+ * jointTerms: ({ terms: JointTerm[] } | null),
58
+ * derived: (DerivedCoefs | null),
59
+ * tropicalHarmonics: Array<[number, number, number]>,
60
+ * balancedYear: number,
61
+ * meanSolarYearDays: number,
62
+ * hJ2000: number,
63
+ * eccentricityBase: number,
64
+ * eccentricityAmplitude: number,
65
+ * tiltMeanDeg: number,
66
+ * raAngleDeg: number,
67
+ * inclAmplitudeDeg: number,
68
+ * },
69
+ * fns: {
70
+ * cyclesBetween: (yearA: number, yearB: number, divisorN: number) => (number | null),
71
+ * analyticTropicalDays: (year: number) => (number | null),
72
+ * meanHAtAgeMa: (tMa: number) => (number | null),
73
+ * meanYearRealLodDays: (tMa: number) => (number | null),
74
+ * eccentricityAt: (year: number) => number,
75
+ * },
76
+ * }} deps
77
+ */
78
+ function createCardinalModel({ isDeepTime, constants, fns }) {
79
+ const {
80
+ anchors, // { SS, WS, VE, AE } — J2000 event JDs
81
+ harmonics, // { type: [[div, sin, cos], …] } — δ sinusoids
82
+ eccTerms, // { type: [{order, sin, cos}, …] } | null
83
+ jointTerms, // { terms: [{order, div, sin, cos}, …] } | null
84
+ derived, // { lincoef, h0, h1 } — fit-calibrated, VERBATIM
85
+ tropicalHarmonics, // TROPICAL_YEAR_HARMONICS [[div, sin, cos], …]
86
+ balancedYear,
87
+ meanSolarYearDays,
88
+ hJ2000,
89
+ eccentricityBase,
90
+ eccentricityAmplitude,
91
+ tiltMeanDeg, // for RA
92
+ raAngleDeg,
93
+ inclAmplitudeDeg,
94
+ } = constants;
95
+ const {
96
+ cyclesBetween, // (yearA, yearB, divisorN) => cycles | null
97
+ analyticTropicalDays, // (year) => SI days | null — the drift integrand base
98
+ meanHAtAgeMa, // (t_Ma) => H | null — the derivative's dc/dY
99
+ meanYearRealLodDays, // (t_Ma) => days | null — real-LOD drift convention
100
+ eccentricityAt, // (year) => e(t), law of cosines on the H/16 phase
101
+ } = fns;
102
+
103
+ /** @param {number} year */
104
+ const cycleOf = (year) => {
105
+ const c = cyclesBetween(balancedYear, year, 1);
106
+ return c === null ? 0 : c;
107
+ };
108
+
109
+ /** @param {number} year */
110
+ const driftIntegrand = (year) => {
111
+ const a = analyticTropicalDays(year);
112
+ return a === null ? 0 : (a - meanSolarYearDays);
113
+ };
114
+
115
+ /** @param {number} year */
116
+ function driftTerm(year) {
117
+ const span = year - 2000;
118
+ if (span === 0) return 0;
119
+ let n = Math.ceil(Math.abs(span) / DRIFT_NODE_SPACING_YEARS);
120
+ if (n % 2) n++;
121
+ if (n < DRIFT_SIMPSON_N_MIN) n = DRIFT_SIMPSON_N_MIN;
122
+ const h = span / n;
123
+ const f0 = driftIntegrand(2000), fN = driftIntegrand(year);
124
+ let acc = f0 + fN;
125
+ for (let i = 1; i < n; i++) acc += driftIntegrand(2000 + i * h) * ((i % 2) ? 4 : 2);
126
+ return acc * h / 3 - (fN - f0) / 2;
127
+ }
128
+
129
+ /** Step 6d's self-corrected tropical harmonic series, as a length deviation.
130
+ * @param {number} year */
131
+ function tropHarmonicsAt(year) {
132
+ const c = cycleOf(year), c0 = cycleOf(2000);
133
+ let s = 0;
134
+ for (const [div, sinC, cosC] of tropicalHarmonics) {
135
+ const th = 2 * Math.PI * div * c, th0 = 2 * Math.PI * div * c0;
136
+ s += sinC * (Math.sin(th) - Math.sin(th0)) + cosC * (Math.cos(th) - Math.cos(th0));
137
+ }
138
+ return s;
139
+ }
140
+
141
+ // Ih(Y) = Σ_{2000→Y} of the tropical harmonics, closed form. H stays INSIDE
142
+ // the integral (h0 + h1·c) — it moves 27.5 yr across the window and
143
+ // multiplies the amplitude. Deep-time only, so `derived` is present here.
144
+ /** @param {number} year */
145
+ function integratedTropHarmonics(year) {
146
+ const D = /** @type {DerivedCoefs} */ (derived);
147
+ const cY = cycleOf(year), c0 = cycleOf(2000);
148
+ let tot = 0, k0 = 0;
149
+ for (const [div, sinC, cosC] of tropicalHarmonics) {
150
+ const k = 2 * Math.PI * div;
151
+ /** @param {number} c */
152
+ const F = (c) => {
153
+ const sn = Math.sin(k * c), cs = Math.cos(k * c), Hc = D.h0 + D.h1 * c;
154
+ return [-Hc * cs / k + D.h1 * sn / (k * k), Hc * sn / k + D.h1 * cs / (k * k)];
155
+ };
156
+ const a = F(c0), b = F(cY);
157
+ tot += sinC * (b[0] - a[0]) + cosC * (b[1] - a[1]);
158
+ k0 += sinC * Math.sin(k * c0) + cosC * Math.cos(k * c0);
159
+ }
160
+ return tot - k0 * (year - 2000)
161
+ - (tropHarmonicsAt(year) - tropHarmonicsAt(2000)) / 2;
162
+ }
163
+
164
+ /** @param {number} year */
165
+ function sigmaTropical(year) {
166
+ const D = /** @type {DerivedCoefs} */ (derived);
167
+ return D.lincoef * (year - 2000) + driftTerm(year) + integratedTropHarmonics(year);
168
+ }
169
+
170
+ // δ_X(2000) — the self-correction that pins JD(2000) to the anchor exactly
171
+ // (sinusoids + ecc orders; the §10g joint terms self-correct inline).
172
+ // Computed lazily so the phase table builds on the caller's schedule.
173
+ /** @type {Record<string, number> | null} */
174
+ let _selfCorr = null;
175
+ function selfCorr() {
176
+ if (_selfCorr !== null) return _selfCorr;
177
+ _selfCorr = {};
178
+ const c2000 = cycleOf(2000);
179
+ for (const type of ['SS', 'WS', 'VE', 'AE']) {
180
+ let h2000 = 0;
181
+ for (const [div, sinC, cosC] of harmonics[type]) {
182
+ const th0 = 2 * Math.PI * div * c2000;
183
+ h2000 += sinC * Math.sin(th0) + cosC * Math.cos(th0);
184
+ }
185
+ const ecc = eccTerms && eccTerms[type];
186
+ if (ecc) {
187
+ const e0 = eccentricityAt(2000);
188
+ const th0 = 2 * Math.PI * 16 * c2000;
189
+ for (const t of ecc) {
190
+ const eN0 = Math.pow(e0, t.order);
191
+ h2000 += t.sin * eN0 * Math.sin(t.order * th0) + t.cos * eN0 * Math.cos(t.order * th0);
192
+ }
193
+ }
194
+ _selfCorr[type] = h2000;
195
+ }
196
+ return _selfCorr;
197
+ }
198
+
199
+ /** @param {number} year @param {string} [type] */
200
+ function computeSolsticeJD(year, type) {
201
+ const cp = type || 'SS';
202
+ const anchor = anchors[cp];
203
+ const deep = isDeepTime();
204
+
205
+ let jd = deep
206
+ ? anchor + sigmaTropical(year)
207
+ : anchor + meanSolarYearDays * (year - 2000);
208
+
209
+ const cY = cycleOf(year);
210
+ for (const [div, sinC, cosC] of harmonics[cp]) {
211
+ jd += sinC * Math.sin(2 * Math.PI * div * cY) + cosC * Math.cos(2 * Math.PI * div * cY);
212
+ }
213
+ const ecc = eccTerms && eccTerms[cp];
214
+ if (ecc) {
215
+ const e = eccentricityAt(year);
216
+ const th = 2 * Math.PI * 16 * cY;
217
+ for (const t of ecc) {
218
+ const eN = Math.pow(e, t.order);
219
+ jd += t.sin * eN * Math.sin(t.order * th) + t.cos * eN * Math.cos(t.order * th);
220
+ }
221
+ }
222
+ if (jointTerms) {
223
+ const lam = JOINT_LAMBDA[cp];
224
+ const c2000 = cycleOf(2000);
225
+ for (const t of jointTerms.terms) {
226
+ const th = t.order * lam - 2 * Math.PI * t.div * cY;
227
+ const th0 = t.order * lam - 2 * Math.PI * t.div * c2000;
228
+ jd += t.sin * (Math.sin(th) - Math.sin(th0)) + t.cos * (Math.cos(th) - Math.cos(th0));
229
+ }
230
+ }
231
+ jd -= selfCorr()[cp];
232
+ return jd;
233
+ }
234
+
235
+ /** The EXACT term-by-term derivative of computeSolsticeJD — except the
236
+ * drift part, which deliberately stays in the real-LOD convention (see
237
+ * the file header). Neglected: the drift Euler–Maclaurin half-sample
238
+ * term's own derivative (f′/2, sub-µs).
239
+ * @param {number} year @param {string} [type] */
240
+ function computeSolsticeYearLength(year, type) {
241
+ const cp = type || 'SS';
242
+ const deep = isDeepTime();
243
+ const t_Ma = (2000 - year) / 1e6;
244
+
245
+ const H_at = deep ? (meanHAtAgeMa(t_Ma) ?? hJ2000) : hJ2000;
246
+ const dcdY = 1 / H_at;
247
+ const cY = cycleOf(year);
248
+
249
+ let length;
250
+ if (deep && derived) {
251
+ length = derived.lincoef;
252
+ const mSY_at = meanYearRealLodDays(t_Ma);
253
+ if (mSY_at !== null) length += (mSY_at - meanSolarYearDays);
254
+ length += tropHarmonicsAt(year);
255
+ let dTrop = 0;
256
+ for (const [div, sinC, cosC] of tropicalHarmonics) {
257
+ const k = 2 * Math.PI * div, th = k * cY;
258
+ dTrop += k * dcdY * (sinC * Math.cos(th) - cosC * Math.sin(th));
259
+ }
260
+ length -= dTrop / 2;
261
+ } else {
262
+ length = meanSolarYearDays;
263
+ }
264
+
265
+ for (const [div, sinC, cosC] of harmonics[cp]) {
266
+ const k = 2 * Math.PI * div, th = k * cY;
267
+ length += k * dcdY * (sinC * Math.cos(th) - cosC * Math.sin(th));
268
+ }
269
+
270
+ const ecc = eccTerms && eccTerms[cp];
271
+ if (ecc) {
272
+ const th16 = 2 * Math.PI * 16 * cY;
273
+ const thp = 2 * Math.PI * 16 * dcdY;
274
+ const e = eccentricityAt(year);
275
+ const de = eccentricityBase * eccentricityAmplitude * Math.sin(th16) * thp / e;
276
+ for (const t of ecc) {
277
+ const n = t.order, nth = n * th16;
278
+ const eN = Math.pow(e, n), eN1 = Math.pow(e, n - 1);
279
+ length += t.sin * (n * eN1 * de * Math.sin(nth) + eN * n * thp * Math.cos(nth))
280
+ + t.cos * (n * eN1 * de * Math.cos(nth) - eN * n * thp * Math.sin(nth));
281
+ }
282
+ }
283
+
284
+ if (jointTerms) {
285
+ const lam = JOINT_LAMBDA[cp];
286
+ for (const t of jointTerms.terms) {
287
+ const k = 2 * Math.PI * t.div;
288
+ const th = t.order * lam - k * cY;
289
+ length += k * dcdY * (-t.sin * Math.cos(th) + t.cos * Math.sin(th));
290
+ }
291
+ }
292
+ return length;
293
+ }
294
+
295
+ /** RA where a cardinal point occurs — fully derived, zero fitted constants.
296
+ * INTEGRATED phase: the formula describes where the SCENE puts the point,
297
+ * and the scene's H/3 and H/8 objects rotate on integrated phase.
298
+ * @param {number} year @param {string} [type] */
299
+ function computeSolsticeRA(year, type) {
300
+ const sinE = Math.sin(tiltMeanDeg * Math.PI / 180);
301
+ const baseRA = /** @type {Record<string, number>} */ ({ SS: 90, WS: 270, VE: 0, AE: 180 })[type || 'SS'];
302
+ const raMean = baseRA - raAngleDeg / sinE;
303
+ const amp = inclAmplitudeDeg / sinE;
304
+ const cY = cycleOf(year);
305
+ const phase3 = 2 * Math.PI * 3 * cY;
306
+ const phase8 = 2 * Math.PI * 8 * cY;
307
+ return raMean + amp * (-Math.sin(phase3) + Math.sin(phase8));
308
+ }
309
+
310
+ /** Tropical year as the mean of the four cardinal intervals — the
311
+ * physically correct definition (Σδ_X ≡ 0 makes the braid cancel).
312
+ * @param {number} year */
313
+ function computeTropicalYearLength(year) {
314
+ return (computeSolsticeYearLength(year, 'SS') +
315
+ computeSolsticeYearLength(year, 'WS') +
316
+ computeSolsticeYearLength(year, 'VE') +
317
+ computeSolsticeYearLength(year, 'AE')) / 4;
318
+ }
319
+
320
+ return { computeSolsticeJD, computeSolsticeYearLength, computeSolsticeRA, computeTropicalYearLength };
321
+ }
322
+
323
+ module.exports = { createCardinalModel, JOINT_LAMBDA };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Generic chain-cycle integrator — THE shared implementation (Phase 8.2-4).
3
+ *
4
+ * Counts cycles of any evolving period T(t) between two calendar years:
5
+ * ∫ (T_yr_SI / T_period_SI)(y) dy. Serves every Moon scene-graph chain
6
+ * (tropical month, perigee/node precession, the beat cycles) AND the seven
7
+ * planet OrbitalCyclesBetween functions — it is chain-generic, keyed by the
8
+ * period function's identity.
9
+ *
10
+ * Extracted VERBATIM from src/script.js _moonCycleTable/_moonCycleTableAt/
11
+ * _moonChainCycles, which tools/lib/scene-graph.js mirrored. Three mirror
12
+ * gaps closed by this move (each measured against the lunar golden masters):
13
+ *
14
+ * - S5: the snapshot branch (deep-time OFF → linear at the J2000 rate),
15
+ * the memoized periodFn(0), and the bounded FIFO cache existed only in
16
+ * the browser. They are the implementation now; the Node engine gains
17
+ * them (its snapshot semantics thereby ALIGN with the browser's).
18
+ * - S12: the browser maps year → age with ageAnchorYear = startmodelYear
19
+ * (2000.5, the scene's t_Ma convention) while the tools mirror used a
20
+ * literal 2000 — a half-year skew in the integrand argument. The anchor
21
+ * is INJECTED; both engines pass startmodelYear (the certified browser
22
+ * convention).
23
+ * - The cumulative table stays anchored C(gridAnchorYear) = 0 at calendar
24
+ * 2000 (NOT 2000.5) in both engines — grid anchor and age anchor are two
25
+ * different constants, deliberately (cf. the phase machinery's
26
+ * tableAnchorYear vs driftRefYear pair).
27
+ *
28
+ * NUMERICS ARE LOAD-BEARING: fixed 10-yr grid over ±250 kyr, per-cell
29
+ * 3-point Simpson at build, LINEAR interpolation on read; adaptive Simpson
30
+ * (~1 sample/kyr, n ∈ [32, 1024], even) as the out-of-range fallback. The
31
+ * table replaced the per-call Simpson for in-range spans (the L-4
32
+ * 92 s/century full-canon regression); an "analytically better" integrator
33
+ * here would detach the runtime from the certified numbers.
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ /**
39
+ * @typedef {(tMa: number) => (number | null)} PeriodSecondsAtAge
40
+ */
41
+
42
+ /**
43
+ * @param {{
44
+ * ageAnchorYear: number,
45
+ * tropicalYearSecondsAtAge: (tMa: number) => (number | null),
46
+ * tropicalYearJ2000Seconds: number,
47
+ * isDeepTime: () => boolean,
48
+ * gridMinYear?: number,
49
+ * gridMaxYear?: number,
50
+ * gridStepYears?: number,
51
+ * gridAnchorYear?: number,
52
+ * maxCacheEntries?: number,
53
+ * }} deps — ageAnchorYear is the year whose age is 0 in the period
54
+ * functions' t_Ma coordinate (startmodelYear = 2000.5, the certified
55
+ * convention); tropicalYearJ2000Seconds feeds the snapshot branch;
56
+ * isDeepTime is read PER CALL so the engine's runtime toggle works.
57
+ */
58
+ function createChainCycleIntegrator({
59
+ ageAnchorYear,
60
+ tropicalYearSecondsAtAge,
61
+ tropicalYearJ2000Seconds,
62
+ isDeepTime,
63
+ gridMinYear = 2000 - 250000,
64
+ gridMaxYear = 2000 + 250000,
65
+ gridStepYears = 10,
66
+ gridAnchorYear = 2000,
67
+ maxCacheEntries = 512,
68
+ }) {
69
+ /** @type {WeakMap<PeriodSecondsAtAge, Map<string, number>>} */
70
+ const caches = new WeakMap();
71
+ /** @type {WeakMap<PeriodSecondsAtAge, number>} */
72
+ const j2000Periods = new WeakMap(); // memoized periodFn(0)
73
+ /** @type {Map<PeriodSecondsAtAge, Float64Array | null>} */
74
+ const tables = new Map();
75
+
76
+ /** Lazy cumulative table for one chain; null when the chain is undefined
77
+ * anywhere in range (→ Simpson fallback).
78
+ * @param {PeriodSecondsAtAge} periodFnSeconds
79
+ * @returns {Float64Array | null} */
80
+ function table(periodFnSeconds) {
81
+ let tab = tables.get(periodFnSeconds);
82
+ if (tab !== undefined) return tab;
83
+ const N = Math.round((gridMaxYear - gridMinYear) / gridStepYears);
84
+ const cum = new Float64Array(N + 1);
85
+ const anchorIdx = Math.round((gridAnchorYear - gridMinYear) / gridStepYears);
86
+ /** @param {number} y @returns {number | null} */
87
+ const f = (y) => {
88
+ const tMa = (ageAnchorYear - y) / 1e6;
89
+ const tPeriodS = periodFnSeconds(tMa);
90
+ if (tPeriodS === null) return null;
91
+ const tYrS = tropicalYearSecondsAtAge(tMa);
92
+ return tYrS === null ? null : tYrS / tPeriodS;
93
+ };
94
+ let ok = true;
95
+ let fPrev = f(gridMinYear);
96
+ for (let i = 1; i <= N; i++) {
97
+ const fMid = f(gridMinYear + (i - 0.5) * gridStepYears);
98
+ const fCur = f(gridMinYear + i * gridStepYears);
99
+ if (fPrev === null || fMid === null || fCur === null) { ok = false; break; }
100
+ cum[i] = cum[i - 1] + (fPrev + 4 * fMid + fCur) * (gridStepYears / 6);
101
+ fPrev = fCur;
102
+ }
103
+ if (ok) {
104
+ const c0 = cum[anchorIdx];
105
+ for (let i = 0; i <= N; i++) cum[i] -= c0; // anchor C(gridAnchorYear) = 0
106
+ tab = cum;
107
+ } else {
108
+ tab = null;
109
+ }
110
+ tables.set(periodFnSeconds, tab);
111
+ return tab;
112
+ }
113
+
114
+ /** @param {Float64Array} tab @param {number} y @returns {number} */
115
+ function tableAt(tab, y) {
116
+ const idxF = (y - gridMinYear) / gridStepYears;
117
+ const i = Math.floor(idxF);
118
+ return tab[i] + (idxF - i) * (tab[i + 1] - tab[i]);
119
+ }
120
+
121
+ /** Cycles of the chain between two calendar years. Snapshot mode: linear
122
+ * at the J2000 rate. Deep time: table lookup in ±range, adaptive Simpson
123
+ * beyond. null past the tidal-lock asymptote.
124
+ * @param {PeriodSecondsAtAge} periodFnSeconds
125
+ * @param {number} yearA @param {number} yearB
126
+ * @returns {number | null} */
127
+ function cyclesBetween(periodFnSeconds, yearA, yearB) {
128
+ const dy = yearB - yearA;
129
+ if (dy === 0) return 0;
130
+
131
+ let tJ2000 = j2000Periods.get(periodFnSeconds);
132
+ if (tJ2000 === undefined) {
133
+ const t0 = periodFnSeconds(0);
134
+ if (t0 === null) return null;
135
+ tJ2000 = t0;
136
+ j2000Periods.set(periodFnSeconds, tJ2000);
137
+ }
138
+
139
+ if (!isDeepTime()) {
140
+ return dy * tropicalYearJ2000Seconds / tJ2000;
141
+ }
142
+
143
+ // Cumulative-table fast path (deterministic, call-order independent)
144
+ if (yearA > gridMinYear && yearA < gridMaxYear && yearB > gridMinYear && yearB < gridMaxYear) {
145
+ const tab = table(periodFnSeconds);
146
+ if (tab !== null) return tableAt(tab, yearB) - tableAt(tab, yearA);
147
+ }
148
+
149
+ let cache = caches.get(periodFnSeconds);
150
+ if (!cache) {
151
+ cache = new Map();
152
+ caches.set(periodFnSeconds, cache);
153
+ }
154
+ const cacheKey = yearA + '|' + yearB;
155
+ const hit = cache.get(cacheKey);
156
+ if (hit !== undefined) return hit;
157
+
158
+ // Adaptive Simpson: ~1 sample per 1 kyr, capped at 1024 to bound per-call cost.
159
+ let n = Math.max(32, Math.ceil(Math.abs(dy) / 1000));
160
+ if (n > 1024) n = 1024;
161
+ if (n % 2 === 1) n++;
162
+ const h = dy / n;
163
+
164
+ let sum = 0;
165
+ for (let i = 0; i <= n; i++) {
166
+ const y = yearA + i * h;
167
+ const tMa = (ageAnchorYear - y) / 1e6;
168
+ const tPeriodS = periodFnSeconds(tMa);
169
+ if (tPeriodS === null) return null;
170
+ const tYrS = tropicalYearSecondsAtAge(tMa);
171
+ if (tYrS === null) return null;
172
+ const integrand = tYrS / tPeriodS; // cycles per SI year
173
+ const w = (i === 0 || i === n) ? 1 : (i % 2 === 1 ? 4 : 2);
174
+ sum += w * integrand;
175
+ }
176
+ const result = (sum * h) / 3;
177
+
178
+ if (cache.size >= maxCacheEntries) {
179
+ const firstKey = cache.keys().next().value;
180
+ cache.delete(/** @type {string} */ (firstKey));
181
+ }
182
+ cache.set(cacheKey, result);
183
+ return result;
184
+ }
185
+
186
+ return { cyclesBetween };
187
+ }
188
+
189
+ module.exports = { createChainCycleIntegrator };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Climate Formula L1 orbital layer — THE shared evaluator (Phase 8.4,
3
+ * slice 4). The δ¹⁸O contribution from the L1 (orbital/Milankovitch)
4
+ * harmonic layer only, in ‰ — excludes the intercept, L2 (405-kyr
5
+ * carbon), L3 (regime steps), y_mean, and trend_slope: consumers want
6
+ * the orbital FLUCTUATION around J2000, not the secular baseline.
7
+ *
8
+ * Primary consumer: the GIA α(t) chain — one physical mechanism, two
9
+ * observables: the same L1 signal that fits δ¹⁸O also drives α via
10
+ * Milankovitch forcing → ice sheets → GIA J₂/α → LOD (doc 99
11
+ * §prediction-7). Both engines hand-mirrored this loop; it lives once
12
+ * now.
13
+ *
14
+ * ENGINE-SIDE, by design: the regime selection (CLIMATE_FORMULA_COEFFS
15
+ * lookup, the ALPHA_CLIMATE_REGIME_KEY choice), the α formula itself
16
+ * (one subtraction around engine state), and the lattice-α pin machinery
17
+ * (_withLatticeAlpha — mutable engine state with try/finally semantics
18
+ * and, in the browser, a TDZ-history guard).
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ /**
24
+ * @typedef {Object} ClimateL1Regime
25
+ * @property {Array<{n: number, a: number, b: number}>} l1Terms - 8H-lattice
26
+ * harmonics (n = cycles per 8H)
27
+ * @property {number} yStdDenormalization - the fit's y_std scale-back
28
+ * @property {number} eightHKyr - 8H in kyr (the fit's period base)
29
+ */
30
+
31
+ /**
32
+ * @param {number} year - calendar year
33
+ * @param {ClimateL1Regime} regime
34
+ * @returns {number} L1 orbital δ¹⁸O contribution, ‰
35
+ */
36
+ function evalClimateL1OrbitalPermil(year, regime) {
37
+ const t_kyr_BP = (2000 - year) / 1000;
38
+ let L1_sum = 0;
39
+ for (const c of regime.l1Terms) {
40
+ const omega = 2 * Math.PI * c.n / regime.eightHKyr;
41
+ L1_sum += c.a * Math.cos(omega * t_kyr_BP) + c.b * Math.sin(omega * t_kyr_BP);
42
+ }
43
+ return L1_sum * regime.yStdDenormalization;
44
+ }
45
+
46
+ module.exports = { evalClimateL1OrbitalPermil };