/** Small-amplitude undamped oscillator, calibrated scenario identity is explicit. */ export const MODEL_VERSION='1.0.0',EXPERIMENT_ID='course-02/inertial-balance'; export const DEFAULTS=Object.freeze({mass:.1,emptyMass:.043540722689577976,stiffness:20.184331416351085,amplitude:.005,cycles:20}); export function validateParameters(p){for(const[k,a,b]of[['mass',0,.25],['emptyMass',.001,.1],['stiffness',10,40],['amplitude',.001,.01],['cycles',1,100]])if(!Number.isFinite(p[k])||p[k]b)throw new RangeError(k);if(!Number.isInteger(p.cycles))throw new RangeError('integer cycles');return {...p};} export function period(p){validateParameters(p);return 2*Math.PI*Math.sqrt((p.mass+p.emptyMass)/p.stiffness);} export function state(p,t){validateParameters(p);if(!Number.isFinite(t)||t<0)throw new RangeError('time');const w=Math.sqrt(p.stiffness/(p.mass+p.emptyMass));return {t,x:p.amplitude*Math.cos(w*t),velocity:-p.amplitude*w*Math.sin(w*t)};} export function energy(p,s){return .5*(p.mass+p.emptyMass)*s.velocity*s.velocity+.5*p.stiffness*s.x*s.x;} export function calibration(rows){if(rows.length<3)throw new RangeError('three calibration points required');for(const r of rows)if(!Number.isFinite(r.mass)||r.mass<0||!Number.isFinite(r.period)||r.period<=0)throw new RangeError('calibration data');const n=rows.length,m=rows.reduce((s,r)=>s+r.mass,0)/n,t=rows.reduce((s,r)=>s+r.period**2,0)/n,xx=rows.reduce((s,r)=>s+(r.mass-m)**2,0);if(xx<=0)throw new RangeError('distinct masses required');const slope=rows.reduce((s,r)=>s+(r.mass-m)*(r.period*r.period-t),0)/xx,intercept=t-slope*m;if(slope<=0)throw new RangeError('nonpositive slope');const residuals=rows.map(r=>r.period*r.period-slope*r.mass-intercept),variance=residuals.reduce((s,r)=>s+r*r,0)/(n-2);return {slope,intercept,stiffness:4*Math.PI**2/slope,emptyMass:intercept/slope,residuals,slopeVariance:variance/xx,interceptVariance:variance*(1/n+m*m/xx),covariance:-m*variance/xx};} export function inferMass(periodValue,fit){if(!Number.isFinite(periodValue)||periodValue<=0)throw new RangeError('period');const mass=(periodValue**2-fit.intercept)/fit.slope,variance=(mass*mass*fit.slopeVariance+fit.interceptVariance+2*mass*fit.covariance)/(fit.slope*fit.slope);return {mass,calibrationComponent:Math.sqrt(Math.max(0,variance))};} export function makeRecord(p,elapsed=p.cycles*period(p),source='automatic-ideal-period'){validateParameters(p);if(!Number.isFinite(elapsed)||elapsed<=0)throw new RangeError('elapsed');if(!['automatic-ideal-period','user-simulation-timing'].includes(source))throw new RangeError('source');return {experimentId:EXPERIMENT_ID,modelVersion:MODEL_VERSION,source,scenario:'original-calibration-derived-teaching-model',parameters:{...p},group:[p.emptyMass,p.stiffness,p.amplitude,source].join('|'),mass:p.mass,period:elapsed/p.cycles,elapsed,cycles:p.cycles,units:{mass:'kg',period:'s',elapsed:'s'}};}