@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,133 @@
1
+ /**
2
+ * Planet orientation channel — THE shared implementation (Phase 8.3, L4):
3
+ * invariable-plane inclination, ascending nodes, and the scene ecliptic
4
+ * inclination (the balanced-year dot-product form canonicalized at S-P4).
5
+ *
6
+ * Time is EXPLICIT here (yearsSinceBalanced / calendar year). A finding
7
+ * from extraction, preserved not fixed: the browser's
8
+ * computePlanetInvPlaneInclinationDynamic(planet, currentYear) IGNORED its
9
+ * currentYear parameter and read the live scene JD — the 8.3-0 fixtures
10
+ * recorded identical values at every probed epoch. The browser wrapper
11
+ * keeps that scene-coupling (visibly, at the wrapper); the Node engine
12
+ * honors the year as before. Unifying them is a measured decision for the
13
+ * factory layer, not a silent side effect.
14
+ *
15
+ * Two ascending-node conventions exist and BOTH are quantities (the S-P4
16
+ * lesson): the year-2000-anchored linear node here
17
+ * (ascendingNodeInvPlaneLinearAt — the node-integrator/dashboard mirror)
18
+ * and the balanced-year −8H/N node inside the scene tilt
19
+ * (eclipticInclinationFromBalanced). Do not merge them.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ /**
25
+ * Invariable-plane inclination at explicit time (browser form): the ICRF
26
+ * perihelion-linked oscillation i(t) = mean + s·A·cos(ϖ_ICRF(t) − anchor).
27
+ * @param {{ key?: string, isEarth?: boolean,
28
+ * invPlaneInclinationJ2000: number, invPlaneInclinationMean: number,
29
+ * invPlaneInclinationAmplitude: number, inclinationCycleAnchor: number,
30
+ * longitudePerihelion: number, perihelionEclipticYears: number,
31
+ * antiPhase?: boolean }} body
32
+ * @param {number} yearsSinceBalanced
33
+ * @param {{ H: number, yearsFromBalancedToJ2000: number }} env
34
+ * @returns {number} degrees */
35
+ function invPlaneInclinationAt(body, yearsSinceBalanced, env) {
36
+ const iJ2000 = body.invPlaneInclinationJ2000;
37
+ const amplitude = body.invPlaneInclinationAmplitude;
38
+ if (iJ2000 === undefined || amplitude === undefined) return iJ2000 || 0;
39
+ if (amplitude === 0) return iJ2000;
40
+
41
+ // ICRF period: Earth = H/3 directly; others = 1/(1/eclP − 1/(H/13)).
42
+ const genPrecRate = 1 / (env.H / 13);
43
+ const icrfPeriod = body.isEarth
44
+ ? env.H / 3
45
+ : 1 / (1 / body.perihelionEclipticYears - genPrecRate);
46
+ const icrfRate = 360 / icrfPeriod;
47
+
48
+ const periAtBalanced = body.longitudePerihelion - icrfRate * env.yearsFromBalancedToJ2000;
49
+ const periCurrent = periAtBalanced + icrfRate * yearsSinceBalanced;
50
+ const currentPhaseRad = (periCurrent - body.inclinationCycleAnchor) * Math.PI / 180;
51
+ const antiPhaseSign = body.antiPhase ? -1 : 1;
52
+ return body.invPlaneInclinationMean + antiPhaseSign * amplitude * Math.cos(currentPhaseRad);
53
+ }
54
+
55
+ /**
56
+ * Ascending node on the invariable plane — the LINEAR year-2000-anchored
57
+ * convention (node-integrator/dashboard; see the header).
58
+ * @param {{ ascendingNodeInvPlane?: number, ascendingNodePeriod?: number,
59
+ * perihelionEclipticYears: number }} body
60
+ * @param {number} year @returns {number} degrees 0–360 */
61
+ function ascendingNodeInvPlaneLinearAt(body, year) {
62
+ if (!body.ascendingNodeInvPlane) return 0;
63
+ const period = body.ascendingNodePeriod || body.perihelionEclipticYears;
64
+ const rate = 360 / period;
65
+ return ((body.ascendingNodeInvPlane + rate * (year - 2000)) % 360 + 360) % 360;
66
+ }
67
+
68
+ /**
69
+ * Scene ecliptic inclination — normal-vector dot product, balanced-year
70
+ * anchoring, planet Ω on the −8H/N assignment (canonicalized at S-P4; the
71
+ * mirror of src/script.js updateDynamicInclinations).
72
+ * @param {{ perihelionEclipticYears: number, longitudePerihelion: number,
73
+ * inclinationCycleAnchor: number, antiPhase?: boolean,
74
+ * invPlaneInclinationMean: number, invPlaneInclinationAmplitude: number,
75
+ * ascendingNodeInvPlane: number, ascendingNodeCyclesIn8H?: number }} body
76
+ * @param {{ invPlanePrecessionYears: number, inclinationMean: number,
77
+ * inclinationAmplitude: number, ascendingNodeInvPlane: number }} earth
78
+ * @param {number} yearsSinceBalanced
79
+ * @param {{ H: number, yearsFromBalancedToJ2000: number }} env
80
+ * @returns {number} degrees */
81
+ function eclipticInclinationFromBalanced(body, earth, yearsSinceBalanced, env) {
82
+ const d2r = Math.PI / 180;
83
+ const genPrecRate = 1 / (env.H / 13);
84
+
85
+ // --- Earth's orbital plane ---
86
+ const earthPhaseRad = (yearsSinceBalanced / earth.invPlanePrecessionYears) * 2 * Math.PI;
87
+ const earthI = (earth.inclinationMean
88
+ - earth.inclinationAmplitude * Math.cos(earthPhaseRad)) * d2r;
89
+
90
+ // Earth Ω regresses at -H/5 (ecliptic precession rate), NOT at H/3.
91
+ const earthAscNodePeriod = -env.H / 5;
92
+ const earthOmegaRate = 360 / earthAscNodePeriod;
93
+ const earthOmega = (earth.ascendingNodeInvPlane
94
+ - earthOmegaRate * env.yearsFromBalancedToJ2000
95
+ + earthOmegaRate * yearsSinceBalanced) * d2r;
96
+
97
+ // --- Planet's orbital plane ---
98
+ const eclRate = 1 / body.perihelionEclipticYears;
99
+ const icrfRate = (eclRate - genPrecRate) * 360; // deg/yr
100
+ const periICRFDeg = body.longitudePerihelion
101
+ - icrfRate * env.yearsFromBalancedToJ2000
102
+ + icrfRate * yearsSinceBalanced;
103
+
104
+ const planetPhaseDeg = periICRFDeg - body.inclinationCycleAnchor;
105
+ const antiPhaseSign = body.antiPhase ? -1 : 1;
106
+ const planetI = (body.invPlaneInclinationMean
107
+ + antiPhaseSign * body.invPlaneInclinationAmplitude * Math.cos(planetPhaseDeg * d2r)) * d2r;
108
+
109
+ // Planet Ω advances at the asc-node period (-8H/N), NOT the ecliptic
110
+ // perihelion period — they are different angles.
111
+ const planetAscNodePeriod = body.ascendingNodeCyclesIn8H
112
+ ? -(8 * env.H) / body.ascendingNodeCyclesIn8H
113
+ : body.perihelionEclipticYears;
114
+ const planetOmegaRate = 360 / planetAscNodePeriod;
115
+ const planetOmegaDeg = body.ascendingNodeInvPlane
116
+ - planetOmegaRate * env.yearsFromBalancedToJ2000
117
+ + planetOmegaRate * yearsSinceBalanced;
118
+ const planetOmega = planetOmegaDeg * d2r;
119
+
120
+ // --- Dot product of normal vectors → angle between orbital planes ---
121
+ const eNx = Math.sin(earthI) * Math.sin(earthOmega);
122
+ const eNy = Math.sin(earthI) * Math.cos(earthOmega);
123
+ const eNz = Math.cos(earthI);
124
+
125
+ const pNx = Math.sin(planetI) * Math.sin(planetOmega);
126
+ const pNy = Math.sin(planetI) * Math.cos(planetOmega);
127
+ const pNz = Math.cos(planetI);
128
+
129
+ const cosAngle = eNx * pNx + eNy * pNy + eNz * pNz;
130
+ return Math.acos(Math.max(-1, Math.min(1, cosAngle))) * (180 / Math.PI);
131
+ }
132
+
133
+ module.exports = { invPlaneInclinationAt, ascendingNodeInvPlaneLinearAt, eclipticInclinationFromBalanced };
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Predictive geocentric precession — the physical-beat feature basis
3
+ * (Phase 8.3, L9). Ported originally from
4
+ * tools/lib/python/predictive_formula_physical.py; this module is now THE
5
+ * shared implementation behind what were two hand-mirrors (browser +
6
+ * Node engine, ~220 duplicated lines each).
7
+ *
8
+ * GROUPS: A (Earth ref), B (planet angle), C+D+E (periods+beats × 7
9
+ * harmonics), F (period×angle), I (period×2δ), J (fund×Earth sidebands),
10
+ * L (high-harmonic fund×Earth ecl), K (ecl±icrf carriers × Earth ecl).
11
+ * Groups G+H (ERD×period) were removed after a 0.00%-impact diagnostic.
12
+ *
13
+ * MATCHED PAIR: PREDICT_COEFFS was trained against exactly this basis in
14
+ * exactly this push order — reordering or "simplifying" any group scrambles
15
+ * the ridge-regression dot product silently. Do not touch.
16
+ *
17
+ * The engines OWN the Earth scalar derivations (θ_E, ERD, obliquity,
18
+ * eccentricity) — injected, because the two engines deliberately differ
19
+ * there: the browser evaluates the deep-time epoch-aware forms
20
+ * (H_at_year, phaseAdvanceRadians), the Node engine the J2000-anchored
21
+ * forms. The predictor dot-products (PREDICT_PLANETS / PREDICT_COEFFS
22
+ * lookup and their guard semantics) also stay engine-side.
23
+ *
24
+ * Planet fields and H / balancedYear arrive as GETTERS: the browser
25
+ * mutates all of them on setEpoch and invalidates the feature-template
26
+ * cache via resetTemplateCache() at that site.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const PHYSICAL_PERIOD_KEYS = ['ecl', 'icrf', 'obliq', 'asc', 'axial', 'wobble'];
32
+ const PHYSICAL_HARMONIC_ORDERS = [1, 2, 4, 6, 8, 12, 16];
33
+ const SIDEBAND_J_HARMONICS = [1, 2, 3, 4];
34
+ const CARRIER_HARMONICS_K = [2, 4, 6, 8, 10, 12];
35
+ const SIDEBAND_K_HARMONICS = [1, 2, 3];
36
+ const CARRIER_HARMONICS_L = [6, 10, 12, 16];
37
+ const SIDEBAND_L_HARMONICS = [1, 2, 3];
38
+ const MAX_BEAT_YEARS = 1e12;
39
+
40
+ /**
41
+ * Any planet's perihelion longitude — simple linear precession from J2000.
42
+ *
43
+ * @param {number} theta0Deg - perihelion longitude at J2000 (degrees)
44
+ * @param {number} periodYears - precession period in years
45
+ * @param {number} year - decimal year
46
+ * @returns {number} longitude in degrees [0, 360)
47
+ */
48
+ function calcPlanetPerihelionLongDeg(theta0Deg, periodYears, year) {
49
+ return ((theta0Deg + 360.0 * (year - 2000) / periodYears) % 360 + 360) % 360;
50
+ }
51
+
52
+ /**
53
+ * Sum/difference beat periods of two cycles, null where degenerate or
54
+ * beyond MAX_BEAT_YEARS.
55
+ *
56
+ * @param {number|null} t1 @param {number|null} t2
57
+ * @returns {{ sum: number|null, diff: number|null }}
58
+ */
59
+ function beatPair(t1, t2) {
60
+ if (t1 == null || t2 == null) return { sum: null, diff: null };
61
+ const sr = 1 / t1 + 1 / t2;
62
+ const dr = 1 / t1 - 1 / t2;
63
+ let sp = Math.abs(sr) > 1e-20 ? 1 / sr : null;
64
+ let dp = Math.abs(dr) > 1e-20 ? 1 / dr : null;
65
+ if (sp != null && Math.abs(sp) > MAX_BEAT_YEARS) sp = null;
66
+ if (dp != null && Math.abs(dp) > MAX_BEAT_YEARS) dp = null;
67
+ return { sum: sp, diff: dp };
68
+ }
69
+
70
+ /**
71
+ * @typedef {Object} PredictPlanetFields
72
+ * @property {number} perihelionEclipticYears
73
+ * @property {number} longitudePerihelion
74
+ * @property {number|null|undefined} ascendingNodeCyclesIn8H
75
+ * @property {number} axialPrecessionYears
76
+ * @property {number|null|undefined} obliquityCycle
77
+ * @property {number|null|undefined} wobblePeriod
78
+ */
79
+
80
+ /**
81
+ * @typedef {Object} PredictDeps
82
+ * @property {() => number} getHYears - holistic year length (live: setEpoch mutates it)
83
+ * @property {() => number} getBalancedYear
84
+ * @property {(planetKey: string) => PredictPlanetFields} getPlanetFields - live per-call reads
85
+ * @property {(year: number) => number} calcEarthPerihelionDeg - θ_E, engine-owned form
86
+ * @property {(year: number) => number} calcErdRate - ERD deg/yr, engine-owned form
87
+ * @property {(year: number) => number} computeObliquityEarthDeg
88
+ * @property {(year: number) => number} computeEccentricityEarth
89
+ * @property {number} obliquityMeanDeg - the fitted solstice-obliquity mean
90
+ * @property {number} eccentricityMean - sqrt(base² + amplitude²)
91
+ */
92
+
93
+ /**
94
+ * Build the predictive-precession machinery over one engine's state.
95
+ * @param {PredictDeps} deps
96
+ */
97
+ function createPredictivePrecession(deps) {
98
+ /** @type {Record<string, number[]>} */
99
+ const templates = {};
100
+
101
+ /**
102
+ * The six fundamental periods (years) feeding the feature basis.
103
+ * Earth is pure H-lattice; planets derive icrf/asc from their fields.
104
+ * @param {string} planetName
105
+ * @returns {Record<string, number|null>}
106
+ */
107
+ function getPlanetFundamentalPeriodsYears(planetName) {
108
+ const H = deps.getHYears();
109
+ const H13 = H / 13;
110
+ if (planetName === 'earth') {
111
+ return { ecl: H / 16, icrf: H / 3, obliq: H / 8, asc: -8 * H / 40, axial: -H / 13, wobble: H / 16 };
112
+ }
113
+ const p = deps.getPlanetFields(planetName);
114
+ const tEcl = p.perihelionEclipticYears;
115
+ const tIcrf = (tEcl * H13) / (H13 - tEcl);
116
+ const ascN = p.ascendingNodeCyclesIn8H;
117
+ const tAsc = ascN ? -8 * H / ascN : null;
118
+ return {
119
+ ecl: tEcl,
120
+ icrf: tIcrf,
121
+ obliq: p.obliquityCycle || null,
122
+ asc: tAsc,
123
+ axial: p.axialPrecessionYears,
124
+ wobble: p.wobblePeriod || null,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Fundamentals + pairwise internal beats + planet×Earth beats, cached
130
+ * per planet until resetTemplateCache().
131
+ * @param {string} planetName
132
+ * @returns {number[]}
133
+ */
134
+ function getFeatureTemplate(planetName) {
135
+ if (templates[planetName]) return templates[planetName];
136
+ const pp = getPlanetFundamentalPeriodsYears(planetName);
137
+ const ep = getPlanetFundamentalPeriodsYears('earth');
138
+ const template = [];
139
+ for (const k of PHYSICAL_PERIOD_KEYS) {
140
+ if (pp[k] != null) template.push(pp[k]);
141
+ }
142
+ for (let i = 0; i < PHYSICAL_PERIOD_KEYS.length; i++) {
143
+ for (let j = i + 1; j < PHYSICAL_PERIOD_KEYS.length; j++) {
144
+ const b = beatPair(pp[PHYSICAL_PERIOD_KEYS[i]], pp[PHYSICAL_PERIOD_KEYS[j]]);
145
+ if (b.sum != null) template.push(b.sum);
146
+ if (b.diff != null) template.push(b.diff);
147
+ }
148
+ }
149
+ for (const pk of PHYSICAL_PERIOD_KEYS) {
150
+ for (const ek of PHYSICAL_PERIOD_KEYS) {
151
+ const b = beatPair(pp[pk], ep[ek]);
152
+ if (b.sum != null) template.push(b.sum);
153
+ if (b.diff != null) template.push(b.diff);
154
+ }
155
+ }
156
+ templates[planetName] = template;
157
+ return template;
158
+ }
159
+
160
+ /**
161
+ * The full feature vector (~2421 elements for Venus). Push order is the
162
+ * training order — see the module header.
163
+ * @param {number} year - calendar year
164
+ * @param {string} planetKey - e.g. 'venus'
165
+ * @returns {number[]}
166
+ */
167
+ function buildPredictiveFeatures(year, planetKey) {
168
+ const t = year - deps.getBalancedYear();
169
+ const p = deps.getPlanetFields(planetKey);
170
+ const planetPeriod = Math.abs(p.perihelionEclipticYears);
171
+ const planetTheta0 = p.longitudePerihelion;
172
+
173
+ const thetaE = deps.calcEarthPerihelionDeg(year);
174
+ const thetaP = calcPlanetPerihelionLongDeg(planetTheta0, planetPeriod, year);
175
+ const erd = deps.calcErdRate(year);
176
+ const obliq = deps.computeObliquityEarthDeg(year);
177
+ const ecc = deps.computeEccentricityEarth(year);
178
+
179
+ const thetaERad = thetaE * Math.PI / 180;
180
+ const thetaPRad = thetaP * Math.PI / 180;
181
+ const diff = thetaERad - thetaPRad;
182
+ const sumAngle = thetaERad + thetaPRad;
183
+
184
+ const obliqNorm = obliq - deps.obliquityMeanDeg;
185
+ const eccNorm = ecc - deps.eccentricityMean;
186
+ const erd2 = erd * erd;
187
+ const erd3 = erd2 * erd;
188
+
189
+ const f = [];
190
+
191
+ // GROUP A: Earth reference (49)
192
+ for (const n of [1, 2, 3, 4]) { f.push(Math.cos(n * diff), Math.sin(n * diff)); }
193
+ f.push(Math.cos(sumAngle), Math.sin(sumAngle));
194
+ for (const n of [1, 2]) { f.push(Math.cos(n * thetaERad), Math.sin(n * thetaERad)); }
195
+ for (const n of [1, 2]) { f.push(Math.cos(n * thetaPRad), Math.sin(n * thetaPRad)); }
196
+ f.push(obliqNorm, eccNorm);
197
+ f.push(obliqNorm * Math.cos(diff), obliqNorm * Math.sin(diff));
198
+ f.push(eccNorm * Math.cos(diff), eccNorm * Math.sin(diff));
199
+ f.push(erd, erd2, erd3);
200
+ f.push(erd * Math.cos(diff), erd * Math.sin(diff));
201
+ f.push(erd * Math.cos(2 * diff), erd * Math.sin(2 * diff));
202
+ f.push(erd * Math.cos(sumAngle), erd * Math.sin(sumAngle));
203
+ f.push(erd * obliqNorm, erd * eccNorm);
204
+ f.push(erd2 * Math.cos(diff), erd2 * Math.sin(diff));
205
+ f.push(erd2 * Math.cos(2 * diff));
206
+ f.push(erd * Math.cos(3 * diff), erd * Math.sin(3 * diff));
207
+ f.push(Math.cos(3 * diff), Math.sin(3 * diff));
208
+ f.push(Math.cos(4 * diff), Math.sin(4 * diff));
209
+ f.push(erd * Math.cos(2 * sumAngle), erd * Math.sin(2 * sumAngle));
210
+ f.push(erd2 * Math.cos(sumAngle), erd2 * Math.sin(sumAngle));
211
+ f.push(1.0);
212
+
213
+ // GROUP B: planet angle cross-terms (10)
214
+ for (const n of [3, 4]) { f.push(Math.cos(n * thetaPRad), Math.sin(n * thetaPRad)); }
215
+ f.push(obliqNorm * Math.cos(2 * diff), obliqNorm * Math.sin(2 * diff));
216
+ f.push(eccNorm * Math.cos(2 * diff), eccNorm * Math.sin(2 * diff));
217
+ f.push(Math.cos(thetaERad) * Math.cos(thetaPRad));
218
+ f.push(Math.cos(thetaERad) * Math.sin(thetaPRad));
219
+
220
+ // C+D+E: physical periods + beats × 7 harmonics
221
+ const template = getFeatureTemplate(planetKey);
222
+ for (let i = 0; i < template.length; i++) {
223
+ const basePhase = 2 * Math.PI * t / template[i];
224
+ for (const n of PHYSICAL_HARMONIC_ORDERS) {
225
+ f.push(Math.cos(n * basePhase), Math.sin(n * basePhase));
226
+ }
227
+ }
228
+
229
+ const pp = getPlanetFundamentalPeriodsYears(planetKey);
230
+
231
+ // GROUP F: period × angle
232
+ for (const k of PHYSICAL_PERIOD_KEYS) {
233
+ const period = pp[k];
234
+ if (period == null) continue;
235
+ const phase = 2 * Math.PI * t / period;
236
+ f.push(Math.cos(phase) * Math.cos(diff));
237
+ f.push(Math.sin(phase) * Math.sin(diff));
238
+ f.push(Math.cos(phase) * Math.cos(2 * diff));
239
+ f.push(Math.sin(phase) * Math.sin(2 * diff));
240
+ }
241
+
242
+ // GROUP I: period × 2δ
243
+ for (const k of PHYSICAL_PERIOD_KEYS) {
244
+ const period = pp[k];
245
+ if (period == null) continue;
246
+ const phase = 2 * Math.PI * t / period;
247
+ const sinP = Math.sin(phase), cosP = Math.cos(phase);
248
+ f.push(sinP * Math.cos(2 * diff), sinP * Math.sin(2 * diff));
249
+ f.push(cosP * Math.cos(2 * diff), cosP * Math.sin(2 * diff));
250
+ }
251
+
252
+ // GROUP J: fund × Earth cycles (4 harmonics)
253
+ // Earth's fundamentals are pure H-lattice — never null; the Record type
254
+ // can't express that, hence the casts here and below.
255
+ const ep = getPlanetFundamentalPeriodsYears('earth');
256
+ const earthCyclesJ = /** @type {number[]} */ ([ep.ecl, ep.obliq, ep.icrf, ep.axial]);
257
+ for (const k of PHYSICAL_PERIOD_KEYS) {
258
+ const period = pp[k];
259
+ if (period == null) continue;
260
+ const phaseP = 2 * Math.PI * t / period;
261
+ const sinP = Math.sin(phaseP), cosP = Math.cos(phaseP);
262
+ for (const TE of earthCyclesJ) {
263
+ for (const harm of SIDEBAND_J_HARMONICS) {
264
+ const phaseE = harm * 2 * Math.PI * t / TE;
265
+ const sinE = Math.sin(phaseE), cosE = Math.cos(phaseE);
266
+ f.push(sinP * sinE, sinP * cosE, cosP * sinE, cosP * cosE);
267
+ }
268
+ }
269
+ }
270
+
271
+ // GROUP L: high-harmonic fundamental × Earth ecl
272
+ const phiEEcl = 2 * Math.PI * t / /** @type {number} */ (ep.ecl);
273
+ for (const k of PHYSICAL_PERIOD_KEYS) {
274
+ const period = pp[k];
275
+ if (period == null) continue;
276
+ const phiFund = 2 * Math.PI * t / period;
277
+ for (const nL of CARRIER_HARMONICS_L) {
278
+ const ccL = Math.cos(nL * phiFund), scL = Math.sin(nL * phiFund);
279
+ for (const kh of SIDEBAND_L_HARMONICS) {
280
+ const modPhase = kh * phiEEcl;
281
+ const smL = Math.sin(modPhase), cmL = Math.cos(modPhase);
282
+ f.push(scL * smL, scL * cmL, ccL * smL, ccL * cmL);
283
+ }
284
+ }
285
+ }
286
+
287
+ // GROUP K: ICRF internal-beat carriers × Earth ecl sidebands
288
+ if (pp.ecl != null && pp.icrf != null) {
289
+ const phiEcl = 2 * Math.PI * t / pp.ecl;
290
+ const phiIcrf = 2 * Math.PI * t / pp.icrf;
291
+ for (const sign of [+1, -1]) {
292
+ for (const n of CARRIER_HARMONICS_K) {
293
+ const carrierPhase = n * (phiEcl + sign * phiIcrf);
294
+ const sc = Math.sin(carrierPhase), cc = Math.cos(carrierPhase);
295
+ for (const kh of SIDEBAND_K_HARMONICS) {
296
+ const modPhase = kh * phiEEcl;
297
+ const sm = Math.sin(modPhase), cm = Math.cos(modPhase);
298
+ f.push(sc * sm, sc * cm, cc * sm, cc * cm);
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ return f;
305
+ }
306
+
307
+ /**
308
+ * Invalidate cached feature templates after the underlying planet fields
309
+ * mutate (the browser's setEpoch rebuilds obliquity cycles, wobble
310
+ * periods, and the perihelion periods the templates froze at first touch).
311
+ */
312
+ function resetTemplateCache() {
313
+ for (const k in templates) delete templates[k];
314
+ }
315
+
316
+ return {
317
+ buildPredictiveFeatures,
318
+ getPlanetFundamentalPeriodsYears,
319
+ getFeatureTemplate,
320
+ resetTemplateCache,
321
+ };
322
+ }
323
+
324
+ module.exports = { createPredictivePrecession, calcPlanetPerihelionLongDeg, beatPair };