@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,311 @@
1
+ /**
2
+ * Eclipse geometry — sun longitude + the predictive finders (Phase 8.5).
3
+ *
4
+ * Single-copy extraction from the browser (there was never a Node twin;
5
+ * the moon series beneath is shared since 8.2 — the engines inject their
6
+ * own truncated-series evaluators and ΔT convention).
7
+ *
8
+ * sunLonDegAt — Meeus Ch. 25 low-precision Sun geocentric ecliptic
9
+ * longitude. Accepts JD_UT; converts internally to JD_TT via the injected
10
+ * ΔT. Returns MEAN GEOMETRIC longitude, NOT apparent — LOAD-BEARING: the
11
+ * framework's SUN_HARMONICS was fitted against observed eclipses that
12
+ * already include aberration/nutation, so adding raw aberration here
13
+ * double-counted the correction (measured 2026-07: 2024 Dallas 12→24 km
14
+ * worse). Upgrading to apparent longitude requires refitting
15
+ * SUN_HARMONICS with the correction active.
16
+ *
17
+ * findLunarEclipsesInRange — per-opposition shadow geometry (the Danjon
18
+ * ~2% atmospheric enlargement, as used by the NASA Lunar Canon and Meeus
19
+ * Ch. 54 — the pure geometric shadow under-classifies borderline events,
20
+ * e.g. 2021-05-26), zero-crossing + 40-step bisection to ~1 s.
21
+ *
22
+ * findSolarEclipsesInRange — per-conjunction geometry (topocentric Moon
23
+ * disk distinguishes total from annular), then refinement from
24
+ * longitude-conjunction to MINIMUM γ — NASA's "greatest eclipse"
25
+ * convention, typically 5–15 min apart.
26
+ *
27
+ * Sun distance and the synodic month arrive as GETTERS (both are
28
+ * epoch-mutable engine globals). Sun-distance variation over the orbit
29
+ * (~0.5%) is currently neglected — refinement recorded (Phase L-4).
30
+ *
31
+ * The scene-umbra conventions (piercing point / NASA radial projection)
32
+ * are NOT here: they navigate the THREE scene through the Tychosium-
33
+ * derived scaffold, which never enters this package (§2h).
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ /**
39
+ * @typedef {Object} EclipseFinderDeps
40
+ * @property {(jd: number) => number} moonLonDegAt - truncated-series ecliptic longitude
41
+ * @property {(jd: number) => number} moonBetaDegAt - truncated-series ecliptic latitude
42
+ * @property {(jd: number) => number} moonDistanceKmAt - truncated-series distance
43
+ * @property {(jd: number) => number} deltaTSecondsAt - the engine's ΔT convention
44
+ * @property {() => number} getSynodicMonthDays - live (epoch-mutable)
45
+ * @property {() => number} getSunDistanceKm - live (epoch-mutable)
46
+ * @property {{ rEarthMetres: number, moonDiameterKm: number,
47
+ * sunDiameterKm: number, j2000JD: number, julianCenturyDays: number }} constants
48
+ */
49
+
50
+ /** @param {EclipseFinderDeps} deps */
51
+ function createEclipseFinders(deps) {
52
+ const K = deps.constants;
53
+
54
+ /** Sun's geocentric ecliptic longitude in degrees (0–360) at given JD_UT.
55
+ * MEAN GEOMETRIC — see the module header. @param {number} jd @returns {number} */
56
+ function sunLonDegAt(jd) {
57
+ const _d2r = Math.PI / 180;
58
+ const T = (jd + deps.deltaTSecondsAt(jd) / 86400 - K.j2000JD) / K.julianCenturyDays;
59
+ const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T;
60
+ const M = (357.52911 + 35999.05029 * T - 0.0001537 * T * T) * _d2r;
61
+ const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M)
62
+ + (0.019993 - 0.000101 * T) * Math.sin(2 * M)
63
+ + 0.000289 * Math.sin(3 * M);
64
+ return ((L0 + C) % 360 + 360) % 360;
65
+ }
66
+
67
+ /**
68
+ * Find all lunar-eclipse-class oppositions in [jdStart, jdEnd].
69
+ * @param {number} jdStart @param {number} jdEnd
70
+ * @returns {Array<{jd: number, beta: number, moonDistance_km: number,
71
+ * type: string, magnitudeUmbral: number, magnitudePenumbral: number}>}
72
+ */
73
+ function findLunarEclipsesInRange(jdStart, jdEnd) {
74
+ // Step size: small fraction of synodic month for reliable zero-crossing
75
+ // detection (~6° diff change per step, well clear of the 30° wrap filter).
76
+ const STEP_DAYS = deps.getSynodicMonthDays() / 60;
77
+
78
+ // Per-event-distance geometry — the shadow angular radii are computed
79
+ // PER OPPOSITION using each event's Moon distance (perigee vs apogee
80
+ // varies the threshold by ~5.5%, which dominates type-classification
81
+ // at boundary cases).
82
+ const _rad2deg = 180 / Math.PI;
83
+ const R_EARTH_KM = K.rEarthMetres / 1000;
84
+ const R_MOON_KM = K.moonDiameterKm / 2;
85
+ const R_SUN_KM = K.sunDiameterKm / 2;
86
+ const D_SUN_KM = deps.getSunDistanceKm();
87
+ const umbraApex_rad = Math.atan((R_SUN_KM - R_EARTH_KM) / D_SUN_KM);
88
+ const penumbraApex_rad = Math.atan((R_SUN_KM + R_EARTH_KM) / D_SUN_KM);
89
+
90
+ // Shadow radii carry the standard ~2% atmospheric enlargement (Danjon
91
+ // rule / Chauvenet's 1/50, as used by the NASA Lunar Canon and Meeus
92
+ // Ch. 54).
93
+ const SHADOW_ENLARGEMENT = 1.02;
94
+ /** @param {number} D_MOON_KM */
95
+ const _shadowGeometry = (D_MOON_KM) => {
96
+ const moonR = Math.atan(R_MOON_KM / D_MOON_KM) * _rad2deg;
97
+ const umbraR = Math.atan((R_EARTH_KM - D_MOON_KM * Math.tan(umbraApex_rad)) / D_MOON_KM) * _rad2deg * SHADOW_ENLARGEMENT;
98
+ const penumR = Math.atan((R_EARTH_KM + D_MOON_KM * Math.tan(penumbraApex_rad)) / D_MOON_KM) * _rad2deg * SHADOW_ENLARGEMENT;
99
+ return {
100
+ moonR, umbraR, penumR,
101
+ totalMax: umbraR - moonR,
102
+ partialMax: umbraR + moonR,
103
+ penumMax: penumR + moonR,
104
+ };
105
+ };
106
+
107
+ // Wrapped opposition diff: 0 means Moon at opposition (Sun + 180°)
108
+ /** @param {number} jd */
109
+ const oppDiff = (jd) => {
110
+ let d = deps.moonLonDegAt(jd) - sunLonDegAt(jd) - 180;
111
+ while (d > 180) d -= 360;
112
+ while (d <= -180) d += 360;
113
+ return d;
114
+ };
115
+
116
+ const results = [];
117
+ let prevJD = jdStart;
118
+ let prevDiff = oppDiff(prevJD);
119
+
120
+ for (let jd = jdStart + STEP_DAYS; jd <= jdEnd; jd += STEP_DAYS) {
121
+ const d = oppDiff(jd);
122
+ // Zero-crossing: sign change from − to + with no wrap discontinuity
123
+ if (prevDiff < 0 && d >= 0 && (d - prevDiff) < 30) {
124
+ // Bisect to refine opposition to ~1-second precision
125
+ let lo = prevJD, hi = jd;
126
+ for (let i = 0; i < 40; i++) {
127
+ const mid = (lo + hi) / 2;
128
+ if (oppDiff(mid) < 0) lo = mid; else hi = mid;
129
+ if (hi - lo < 1 / 86400) break;
130
+ }
131
+ const jdOpp = (lo + hi) / 2;
132
+ const beta = deps.moonBetaDegAt(jdOpp);
133
+ const absB = Math.abs(beta);
134
+
135
+ // Per-event shadow geometry using the actual Moon distance at this
136
+ // opposition — closes the ~5.5% perigee/apogee threshold variation.
137
+ const D_moon_jd = deps.moonDistanceKmAt(jdOpp);
138
+ const G = _shadowGeometry(D_moon_jd);
139
+
140
+ let type = null;
141
+ if (absB <= G.totalMax) type = 'Total';
142
+ else if (absB <= G.partialMax) type = 'Partial';
143
+ else if (absB <= G.penumMax) type = 'Penumbral';
144
+
145
+ if (type) {
146
+ const magUmbral = Math.max(0, (G.partialMax - absB) / (2 * G.moonR));
147
+ const magPenumbral = Math.max(0, (G.penumMax - absB) / (2 * G.moonR));
148
+ results.push({
149
+ jd: jdOpp,
150
+ beta: beta,
151
+ moonDistance_km: D_moon_jd,
152
+ type: type,
153
+ magnitudeUmbral: magUmbral, // 0 for Penumbral-only; (0,1) Partial; ≥1 Total
154
+ magnitudePenumbral: magPenumbral,
155
+ });
156
+ }
157
+ }
158
+ prevDiff = d;
159
+ prevJD = jd;
160
+ }
161
+
162
+ return results;
163
+ }
164
+
165
+ /**
166
+ * Find all solar-eclipse-class conjunctions in [jdStart, jdEnd].
167
+ * Events are timed at MINIMUM γ (NASA "greatest eclipse" convention).
168
+ * @param {number} jdStart @param {number} jdEnd
169
+ * @returns {Array<{jd: number, beta: number, moonDistance_km: number,
170
+ * type: string, moonAppR_topo: number, sunAppR: number, moonSunRatio: number}>}
171
+ */
172
+ function findSolarEclipsesInRange(jdStart, jdEnd) {
173
+ const STEP_DAYS = deps.getSynodicMonthDays() / 60;
174
+ const _rad2deg = 180 / Math.PI;
175
+ const R_EARTH_KM = K.rEarthMetres / 1000;
176
+ const R_MOON_KM = K.moonDiameterKm / 2;
177
+ const R_SUN_KM = K.sunDiameterKm / 2;
178
+ const D_SUN_KM = deps.getSunDistanceKm();
179
+ const sunAppR = Math.atan(R_SUN_KM / D_SUN_KM) * _rad2deg; // ~0.266°
180
+
181
+ // Per-event geometry at conjunction (Meeus Ch. 54 simplified geocentric
182
+ // form). "Topocentric" Moon size: at the sub-Moon point an observer is
183
+ // 1 Earth-radius closer than the geocenter (ratio ≈ 1.017) — this is
184
+ // what distinguishes total from annular when geocentric Moon ≈ Sun.
185
+ /** @param {number} D_MOON_KM */
186
+ const _solarGeometry = (D_MOON_KM) => {
187
+ const moonAppR_geo = Math.atan(R_MOON_KM / D_MOON_KM) * _rad2deg;
188
+ const moonAppR_topo = Math.atan(R_MOON_KM / (D_MOON_KM - R_EARTH_KM)) * _rad2deg;
189
+ const parallax = Math.atan(R_EARTH_KM / D_MOON_KM) * _rad2deg; // ~0.95°
190
+ return {
191
+ moonAppR_geo, moonAppR_topo, parallax, sunAppR,
192
+ partialLim: sunAppR + moonAppR_geo + parallax,
193
+ centralLim: parallax - Math.abs(moonAppR_topo - sunAppR),
194
+ isTotal: moonAppR_topo > sunAppR,
195
+ };
196
+ };
197
+
198
+ // Wrapped conjunction diff: 0 means Moon at conjunction with the Sun
199
+ /** @param {number} jd */
200
+ const conjDiff = (jd) => {
201
+ let d = deps.moonLonDegAt(jd) - sunLonDegAt(jd);
202
+ while (d > 180) d -= 360;
203
+ while (d <= -180) d += 360;
204
+ return d;
205
+ };
206
+
207
+ // Geocentric γ (Earth-radii): perpendicular distance from Earth's center
208
+ // to the Sun–Moon line. NASA's "greatest eclipse" TD is the moment of
209
+ // MINIMUM γ — typically 5–15 min from longitude conjunction, because at
210
+ // conjunction the Moon may still be approaching in the β direction.
211
+ const _d2r = Math.PI / 180;
212
+ /** @param {number} jd */
213
+ const gammaAtJd = (jd) => {
214
+ const sunLonR = sunLonDegAt(jd) * _d2r;
215
+ const moonLonR = deps.moonLonDegAt(jd) * _d2r;
216
+ const moonBetR = deps.moonBetaDegAt(jd) * _d2r;
217
+ const D_moon = deps.moonDistanceKmAt(jd);
218
+ const D_sun = D_SUN_KM;
219
+ const sX = D_sun * Math.cos(sunLonR), sY = D_sun * Math.sin(sunLonR);
220
+ const cb = Math.cos(moonBetR);
221
+ const mX = D_moon * cb * Math.cos(moonLonR);
222
+ const mY = D_moon * cb * Math.sin(moonLonR);
223
+ const mZ = D_moon * Math.sin(moonBetR);
224
+ const dX = mX - sX, dY = mY - sY, dZ = mZ;
225
+ const dLen = Math.sqrt(dX*dX + dY*dY + dZ*dZ);
226
+ const ux = dX/dLen, uy = dY/dLen, uz = dZ/dLen;
227
+ // Perpendicular distance from origin to the line through Moon along u
228
+ const proj = mX*ux + mY*uy + mZ*uz;
229
+ const perpX = mX - proj*ux, perpY = mY - proj*uy, perpZ = mZ - proj*uz;
230
+ return Math.sqrt(perpX*perpX + perpY*perpY + perpZ*perpZ) / R_EARTH_KM;
231
+ };
232
+
233
+ // Refine conjunction-JD to true min-γ JD: coarse 1-min scan over ±20 min,
234
+ // then parabolic interpolation on the 3 points around the minimum.
235
+ /** @param {number} jdConj */
236
+ const refineMinGamma = (jdConj) => {
237
+ const stepMin = 1 / (24 * 60);
238
+ let bestJD = jdConj, bestG = gammaAtJd(jdConj);
239
+ for (let dt = -20; dt <= 20; dt++) {
240
+ if (dt === 0) continue;
241
+ const jd = jdConj + dt * stepMin;
242
+ const g = gammaAtJd(jd);
243
+ if (g < bestG) { bestG = g; bestJD = jd; }
244
+ }
245
+ const yL = gammaAtJd(bestJD - stepMin);
246
+ const y0 = bestG;
247
+ const yR = gammaAtJd(bestJD + stepMin);
248
+ const denom = yL - 2*y0 + yR;
249
+ if (Math.abs(denom) > 1e-15) {
250
+ const delta = 0.5 * (yL - yR) / denom; // fractional offset in [-1, +1]
251
+ if (Math.abs(delta) < 1) {
252
+ const refinedJD = bestJD + delta * stepMin;
253
+ const refinedG = gammaAtJd(refinedJD);
254
+ if (refinedG < bestG) return refinedJD;
255
+ }
256
+ }
257
+ return bestJD;
258
+ };
259
+
260
+ const results = [];
261
+ let prevJD = jdStart;
262
+ let prevDiff = conjDiff(prevJD);
263
+
264
+ for (let jd = jdStart + STEP_DAYS; jd <= jdEnd; jd += STEP_DAYS) {
265
+ const d = conjDiff(jd);
266
+ if (prevDiff < 0 && d >= 0 && (d - prevDiff) < 30) {
267
+ // Bisect to ~1-second precision on conjunction
268
+ let lo = prevJD, hi = jd;
269
+ for (let i = 0; i < 40; i++) {
270
+ const mid = (lo + hi) / 2;
271
+ if (conjDiff(mid) < 0) lo = mid; else hi = mid;
272
+ if (hi - lo < 1 / 86400) break;
273
+ }
274
+ const jdConj = (lo + hi) / 2;
275
+ // Refine to NASA's "greatest eclipse" convention (minimum γ).
276
+ const jdGreatest = refineMinGamma(jdConj);
277
+ const beta = deps.moonBetaDegAt(jdGreatest);
278
+ const absB = Math.abs(beta);
279
+ const D_moon = deps.moonDistanceKmAt(jdGreatest);
280
+ const G = _solarGeometry(D_moon);
281
+
282
+ let type = null;
283
+ if (absB <= G.centralLim) {
284
+ type = G.isTotal ? 'Total' : 'Annular';
285
+ } else if (absB <= G.partialLim) {
286
+ type = 'Partial';
287
+ }
288
+
289
+ if (type) {
290
+ results.push({
291
+ jd: jdGreatest,
292
+ beta: beta,
293
+ moonDistance_km: D_moon,
294
+ type: type,
295
+ moonAppR_topo: G.moonAppR_topo,
296
+ sunAppR: G.sunAppR,
297
+ moonSunRatio: G.moonAppR_topo / G.sunAppR,
298
+ });
299
+ }
300
+ }
301
+ prevDiff = d;
302
+ prevJD = jd;
303
+ }
304
+
305
+ return results;
306
+ }
307
+
308
+ return { sunLonDegAt, findLunarEclipsesInRange, findSolarEclipsesInRange };
309
+ }
310
+
311
+ module.exports = { createEclipseFinders };
package/src/index.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @essrt/physics — the pure physics core.
3
+ *
4
+ * PHASE 2 SKELETON. No physics has moved yet; `script.js` remains the
5
+ * implementation. This file exists so the architecture rules have something to
6
+ * enforce before code arrives — that ordering is the whole point of Phase 2.
7
+ *
8
+ * Three rules this package must never break (IP-unified-architecture.md §2b, §2h):
9
+ *
10
+ * 1. It imports NOTHING external. Not `three`, not `fs`, not `next`, not
11
+ * `document`, not `process`. Language built-ins only.
12
+ * 2. Constants are INJECTED, never imported. A `import { H } from './constants'`
13
+ * inside a layer is a design defect, not a shortcut — it makes
14
+ * counterfactuals impossible, which is the one capability DE440 and Laskar
15
+ * cannot offer.
16
+ * 3. No scene-graph scaffold. `containerObj`, `pivotObj`, `planetObj`,
17
+ * `orbitCentera/b/c`, `orbitTilta/b`, `startPos` are Tychosium-derived (GPL)
18
+ * and belong to `simulator`. Nothing GPL-derived may be reachable from here,
19
+ * because this package is the one that can be licensed commercially.
20
+ *
21
+ * All three are enforced: `npm run lint` and `npm run check:boundaries`.
22
+ */
23
+
24
+ import { DEFAULT_CONSTANTS as GENERATED, CONSTANTS_HASH, REFERENCE_DATA } from './constants/index.js';
25
+
26
+ /**
27
+ * Keys `createModel` refuses. Derived from REFERENCE_DATA rather than listed by
28
+ * hand, so classifying a new block in the generator automatically protects it —
29
+ * a hand-written list would drift the moment someone added a bound.
30
+ * @type {string[]}
31
+ */
32
+ const NEVER_INJECTABLE = Object.keys(REFERENCE_DATA);
33
+
34
+ /**
35
+ * @typedef {Record<string, unknown> & { hash?: string }} Constants
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} Model
40
+ * @property {Constants} constants the resolved context, frozen
41
+ * @property {string} hash identifies this context; differs for a counterfactual
42
+ * @property {() => {axialPrecessionPeriodYears: number, inclinationPrecessionPeriodYears: number, perihelionPrecessionPeriodYears: number}} computeLatticePeriodsYears
43
+ * @property {(year: number) => number} eccentricity
44
+ */
45
+
46
+ /**
47
+ * Generated at build time from `public/input/{model-parameters,astro-reference}.json`
48
+ * (§2g) by `tools/constants/generate.mjs`. 324 values across 15 blocks.
49
+ *
50
+ * Free parameters and measured anchors only. Validation targets are excluded by
51
+ * the generator's CLASSIFICATION map, so a counterfactual cannot move the
52
+ * goalposts it is judged by (§2d).
53
+ * @type {Constants}
54
+ */
55
+ export { GENERATED as DEFAULT_CONSTANTS, CONSTANTS_HASH };
56
+
57
+ /**
58
+ * Validation targets and presentation data. Single-sourced so nothing keeps a
59
+ * duplicate copy, but NOT part of the model context — `createModel` refuses
60
+ * these keys (§2d).
61
+ */
62
+ export { REFERENCE_DATA };
63
+
64
+ /**
65
+ * Fitting-pipeline output at full precision, with its own hash (§2j).
66
+ * Not part of the injectable context: a counterfactual perturbs the parameters
67
+ * we chose, not the 2,400-term output of a fit.
68
+ */
69
+ export { FITTED_COEFFICIENTS, COEFFICIENTS_HASH } from './constants/index.js';
70
+
71
+ /**
72
+ * Phase 6 surface — the epoch layer. `createEpochPrimitives` + `deriveEpochParams`
73
+ * are what `src/script.js` and `tools/lib/deep-time.js` converge on: one
74
+ * derivation of the parameter bundle, one implementation of the chain. The
75
+ * browser imports these; the Node engine is held bit-identical by the layer0
76
+ * identity gate until Phase C rewrites it as an adapter.
77
+ */
78
+ export { createEpochPrimitives } from './layer0/index.js';
79
+ export { deriveEpochParams } from './layer0/derive-params.js';
80
+ export { createDerivedViews } from './layer1/index.js';
81
+ // Phase 7 — the shared integrated-phase and cardinal-point machinery (CJS on
82
+ // purpose: tools/lib requires the same files via the exports-map subpaths;
83
+ // re-exported here so bundled ESM consumers need only the package root).
84
+ export { createPhaseMachinery } from './phase/index.cjs';
85
+ export { createCardinalModel } from './cardinal/index.cjs';
86
+ // Phase 8.2 — the lunar machinery, extracted layer by layer (survey order:
87
+ // eccentricity channel → month chain → cycle tables → arguments → series →
88
+ // apparent). Same CJS + root re-export convention.
89
+ export { createMoonEccChannel } from './moon/ecc-channel.cjs';
90
+ export { createMoonMonthChain } from './moon/month-chain.cjs';
91
+ export { createChainCycleIntegrator } from './chain-cycles/index.cjs';
92
+ export { createMoonArguments } from './moon/arguments.cjs';
93
+ export { createMoonSeries } from './moon/series.cjs';
94
+ export { createMoonApparent } from './moon/apparent.cjs';
95
+ // Phase 8.3 — the planet machinery, extracted by LAW over body records
96
+ // (survey order: geometry → Fibonacci laws → channels → chains → corrections).
97
+ export { derivePlanetGeometry } from './planets/geometry.cjs';
98
+ export * as planetFibonacciLaws from './planets/fibonacci-laws.cjs';
99
+ export { eccentricityFromCycles, computeEccentricityIntegrated } from './planets/ecc-channel.cjs';
100
+ export * as planetOrientation from './planets/orientation.cjs';
101
+ export { integrateAscendingNode } from './planets/asc-node-integrator.cjs';
102
+ export * as planetOrbitChain from './planets/orbit-chain.cjs';
103
+ export { evaluateParallaxBasis, gravitationTermDeltasDeg, evaluateElongationBasis } from './planets/corrections.cjs';
104
+ export { createPredictivePrecession, calcPlanetPerihelionLongDeg } from './planets/predict.cjs';
105
+ // L10 — the composition front door: one law set, N body records. Thin by
106
+ // design; engines keep their direct call sites (see planets/model.cjs).
107
+ export { createPlanetModel } from './planets/model.cjs';
108
+ // Phase 8.4 — the climate/ΔT machinery, extracted layer by layer.
109
+ export { createDeltaTCycles } from './deltat/cycles.cjs';
110
+ export { createDeepTimeLod } from './deltat/deep-time.cjs';
111
+ export { deltaTEspenakMeeusCanonSeconds } from './deltat/historical.cjs';
112
+ export { evalClimateL1OrbitalPermil } from './climate/l1-orbital.cjs';
113
+ // Phase 8.5 — eclipse geometry (single-copy: the browser had no Node twin).
114
+ export { createEclipseFinders } from './eclipse/finders.cjs';
115
+ // Phase 8.6 — the published reference curves (external comparison formulas
116
+ // and datasets, exactly as published; comparison references, never inputs).
117
+ export * as publishedCurves from './reference/published-curves.cjs';
118
+ // Phase 9 — S-P8: the fitted sun-longitude harmonic stack (three copies → one).
119
+ export { createSunLongitudeCorrection } from './sun/longitude-correction.cjs';
120
+
121
+ /**
122
+ * Build a model bound to a set of constants.
123
+ *
124
+ * Dependency injection rather than import is the key decision (§2d): it is what
125
+ * makes `createModel({ ...DEFAULT_CONSTANTS, neptuneMassRatio: x })` express a
126
+ * counterfactual. Retrofitting it later is prohibitive, so the shape lands now
127
+ * even though the body is empty.
128
+ *
129
+ * @param {Constants} [constants]
130
+ * @returns {Model}
131
+ */
132
+ export const createModel = (constants = GENERATED) => {
133
+ // Validation targets are not merely absent from DEFAULT_CONSTANTS — they are
134
+ // REFUSED here. Absence alone only stops the spread form
135
+ // `{...DEFAULT_CONSTANTS, x}`; nothing stopped a caller passing a bound
136
+ // explicitly. Saturn fails its Laplace-Lagrange bound in verify-laws (44/45),
137
+ // and a counterfactual that could widen that bound would be measuring itself.
138
+ for (const key of NEVER_INJECTABLE) {
139
+ if (constants && Object.prototype.hasOwnProperty.call(constants, key)) {
140
+ throw new Error(
141
+ `physics: "${key}" is a validation target and cannot be injected (§2d). `
142
+ + 'It is exported as REFERENCE_DATA, which createModel does not accept — '
143
+ + 'a counterfactual must not be able to move the goalposts it is judged by.',
144
+ );
145
+ }
146
+ }
147
+
148
+ // Test the ARGUMENT, not the copy. `ctx` below is a fresh frozen object, so
149
+ // `ctx === GENERATED` is never true and the fast path never fired — every
150
+ // call fell through to isDefault(), which serialises the whole context twice.
151
+ // Harmless at 10 KB (~0.4 ms); ruinous once the fitted coefficients arrive,
152
+ // which are ~400 KB.
153
+ const isGeneratedDefault = constants === GENERATED;
154
+
155
+ const ctx = Object.freeze({ ...constants });
156
+
157
+ // The hash identifies THIS context, not the default one. A counterfactual
158
+ // that reported the default hash would be unreproducible — you could not tell
159
+ // from a stored result which constants produced it, which is the whole point
160
+ // of carrying a hash (§2d). Recomputed rather than copied for that reason.
161
+ //
162
+ // isDefault() still runs for a caller who passes a value-identical COPY
163
+ // (`{...DEFAULT_CONSTANTS}`); only the identity case is short-circuited.
164
+ const hash = isGeneratedDefault || isDefault(ctx) ? CONSTANTS_HASH : hashOf(ctx);
165
+
166
+ return {
167
+ constants: ctx,
168
+ hash,
169
+
170
+ /**
171
+ * H-lattice periods — the structural identities, in years.
172
+ *
173
+ * PURE ALGEBRA OVER THE CONTEXT. No epoch, no formula, no fit. These are
174
+ * the divisor relationships H/13, H/3, H/16 that define the lattice, and
175
+ * they exist here in Phase 5 for one reason: a hash-only counterfactual
176
+ * test would still pass if `createModel` ignored its argument entirely.
177
+ * Something has to READ the context and return a number for injection to be
178
+ * demonstrated end to end.
179
+ *
180
+ * The motion model is Phase 6. This is not it.
181
+ *
182
+ * `divisor` and `period` are never interchangeable (CLAUDE.md): 13 is the
183
+ * divisor, H/13 years is the period. The names say which.
184
+ *
185
+ * @returns {{axialPrecessionPeriodYears: number, inclinationPrecessionPeriodYears: number, perihelionPrecessionPeriodYears: number}}
186
+ */
187
+ computeLatticePeriodsYears: () => {
188
+ const H = /** @type {{holisticyearLength: number}} */ (
189
+ /** @type {Record<string, unknown>} */ (ctx).foundational
190
+ ).holisticyearLength;
191
+ return {
192
+ axialPrecessionPeriodYears: H / 13,
193
+ inclinationPrecessionPeriodYears: H / 3,
194
+ perihelionPrecessionPeriodYears: H / 16,
195
+ };
196
+ },
197
+
198
+ /**
199
+ * @param {number} year
200
+ * @returns {number}
201
+ */
202
+ eccentricity: (year) => {
203
+ void year; void ctx;
204
+ throw new Error('physics: not implemented until Phase 6 — see IP-technical-design.md §6');
205
+ },
206
+ };
207
+ };
208
+
209
+ /**
210
+ * Key-sorted canonical form, so the digest depends on values and not on
211
+ * property insertion order — `{...DEFAULT, x}` and `{x, ...DEFAULT}` describe
212
+ * the same counterfactual and must hash alike.
213
+ *
214
+ * @param {unknown} v
215
+ * @returns {unknown}
216
+ */
217
+ const canonical = (v) => {
218
+ if (Array.isArray(v)) return v.map(canonical);
219
+ if (v && typeof v === 'object') {
220
+ const o = /** @type {Record<string, unknown>} */ (v);
221
+ return Object.fromEntries(Object.keys(o).sort().filter((k) => k !== 'hash').map((k) => [k, canonical(o[k])]));
222
+ }
223
+ return v;
224
+ };
225
+
226
+ /**
227
+ * @param {Constants} ctx
228
+ * @returns {boolean} true when ctx is value-identical to the generated set
229
+ */
230
+ const isDefault = (ctx) =>
231
+ JSON.stringify(canonical(ctx)) === JSON.stringify(canonical(GENERATED));
232
+
233
+ /**
234
+ * FNV-1a over the canonical form. Not cryptographic and does not need to be —
235
+ * it distinguishes constant sets, it does not authenticate them. `node:crypto`
236
+ * is unavailable here by design: physics imports no Node builtins (§2b),
237
+ * because it runs in a browser too.
238
+ *
239
+ * @param {Constants} ctx
240
+ * @returns {string} 16 hex chars, prefixed to mark it as a derived context
241
+ */
242
+ const hashOf = (ctx) => {
243
+ const s = JSON.stringify(canonical(ctx));
244
+ let h1 = 0x811c9dc5;
245
+ let h2 = 0x01000193;
246
+ for (let i = 0; i < s.length; i += 1) {
247
+ const c = s.charCodeAt(i);
248
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
249
+ h2 = Math.imul(h2 ^ c, 0x85ebca6b) >>> 0;
250
+ }
251
+ return `cf-${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}`;
252
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Derive the Layer 0 parameter bundle from raw catalogue constants.
3
+ *
4
+ * Mirrors `tools/lib/deep-time.js` lines ~60–101 OPERATION FOR OPERATION —
5
+ * grouping, order, the speed-of-light fallback ternary, all of it. Floating
6
+ * point is not associative, so "algebraically the same" is not the standard
7
+ * here; bit-identical is, and the layer0 identity test compares every field of
8
+ * this function's output against the values deep-time.js derives internally.
9
+ * If the two ever disagree, one of them changed and the other did not — which
10
+ * is the exact drift mode that produced five diverging implementations.
11
+ *
12
+ * Pure function, imports nothing. Lives in physics so the browser and the Node
13
+ * engine can both build Layer 0 from ONE derivation (deep-time.js keeps its
14
+ * own inline copy only until Phase C rewrites it; until then the test is the
15
+ * enforcement).
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} RawEpochConstants
20
+ * @property {number} solarLuminosityW IAU 2015 nominal (W)
21
+ * @property {number} solarWindKgPerS Ulysses/ACE/Wind
22
+ * @property {number} speedOfLightKmPerS km/s (fallback 299792458 m/s if falsy)
23
+ * @property {number} alpha1PerMa
24
+ * @property {number} alpha3PerMa3
25
+ * @property {number} alpha4PerMa4
26
+ * @property {number} holisticYearJ2000 H
27
+ * @property {number} meanSiderealYearSeconds
28
+ * @property {number} meanSiderealYearDaysKinematic
29
+ * @property {number} sunMassKg M_SUN
30
+ * @property {number} gmEarthAloneKm3S2
31
+ * @property {number} gmMoonAloneKm3S2
32
+ * @property {number} gravitationalConstantKm3KgS2
33
+ * @property {number} earthMoiFactorJ2000
34
+ * @property {number} earthDiameterKm
35
+ * @property {number} moonDistanceKm
36
+ * @property {number} moonOrbitalEccentricity
37
+ * @property {number} gmEarthMoonSystemKm3S2
38
+ */
39
+
40
+ /**
41
+ * @param {RawEpochConstants} raw
42
+ * @returns {import('./index.js').EpochParams}
43
+ */
44
+ export const deriveEpochParams = (raw) => {
45
+ // Solar physics — Driver 2 mass loss
46
+ const cSiMPerS = raw.speedOfLightKmPerS ? raw.speedOfLightKmPerS * 1000 : 299792458;
47
+ const dmDtTotalKgS = raw.solarLuminosityW / (cSiMPerS * cSiMPerS) + raw.solarWindKgPerS;
48
+
49
+ // J2000 anchors derived from framework constants
50
+ const lodNowH13Seconds = raw.meanSiderealYearSeconds / raw.meanSiderealYearDaysKinematic;
51
+ const solarMassLossFracPerYear = dmDtTotalKgS * raw.meanSiderealYearSeconds / raw.sunMassKg;
52
+
53
+ // Earth mass, moments
54
+ const earthMassKg = raw.gmEarthAloneKm3S2 / raw.gravitationalConstantKm3KgS2;
55
+ const moonMassKg = raw.gmMoonAloneKm3S2 / raw.gravitationalConstantKm3KgS2;
56
+ const earthRadiusM = (raw.earthDiameterKm / 2) * 1000;
57
+ const iEarthJ2000 = raw.earthMoiFactorJ2000 * earthMassKg * earthRadiusM * earthRadiusM;
58
+
59
+ // Moon constants
60
+ const moonDistanceNowM = raw.moonDistanceKm * 1000;
61
+ const moonEccentricityFactor = Math.sqrt(1 - raw.moonOrbitalEccentricity * raw.moonOrbitalEccentricity);
62
+ const gmEarthMoonM3S2 = raw.gmEarthMoonSystemKm3S2 * 1e9;
63
+ const totalAngularMomentumKgM2S = (iEarthJ2000 * 2 * Math.PI / lodNowH13Seconds)
64
+ + (moonMassKg * Math.sqrt(gmEarthMoonM3S2 * moonDistanceNowM) * moonEccentricityFactor);
65
+ const moonLockDistanceM = (totalAngularMomentumKgM2S / (moonMassKg * Math.sqrt(gmEarthMoonM3S2) * moonEccentricityFactor)) ** 2;
66
+
67
+ return Object.freeze({
68
+ epochYear: 2000,
69
+ alpha1PerMa: raw.alpha1PerMa,
70
+ alpha3PerMa3: raw.alpha3PerMa3,
71
+ alpha4PerMa4: raw.alpha4PerMa4,
72
+ moonDistanceNowM,
73
+ moonLockDistanceM,
74
+ totalAngularMomentumKgM2S,
75
+ moonMassKg,
76
+ gmEarthMoonM3S2,
77
+ moonEccentricityFactor,
78
+ earthMassKg,
79
+ earthRadiusM,
80
+ holisticYearJ2000: raw.holisticYearJ2000,
81
+ lodNowH13Seconds,
82
+ siderealYearJ2000Seconds: raw.meanSiderealYearSeconds,
83
+ solarMassLossFracPerYear,
84
+ });
85
+ };