export const MODEL_VERSION='1.0.0'; export const DEFAULT_PARAMETERS=Object.freeze({length:1.191,mass:.100,linearDensity:.000570,gravity:9.794,mode:2}); export const PARAMETER_BOUNDS=Object.freeze({length:[.2,2],mass:[.02,.3],linearDensity:[.00005,.001],gravity:[9,10],mode:[1,6]}); export function validateParameters(p){for(const[k,[lo,hi]]of Object.entries(PARAMETER_BOUNDS)){if(!Number.isFinite(p[k])||p[k]hi)throw new RangeError(`${k} outside [${lo},${hi}]`);}if(!Number.isInteger(p.mode))throw new RangeError('mode must be integer');return p;} export function properties(p){validateParameters(p);const tension=p.mass*p.gravity,speed=Math.sqrt(tension/p.linearDensity),wavelength=2*p.length/p.mode,frequency=speed/wavelength;return{tension,speed,wavelength,frequency,period:1/frequency,nodes:Array.from({length:p.mode+1},(_,i)=>i*p.length/p.mode)};} export function displacement(x,t,p){validateParameters(p);if(!Number.isFinite(x)||x<0||x>p.length||!Number.isFinite(t))throw new RangeError('invalid x or t');return Math.sin(p.mode*Math.PI*x/p.length)*Math.cos(2*Math.PI*properties(p).frequency*t);} export function markerMeasurement(p,x1,x2){const q=properties(p);if(![x1,x2].every(x=>Number.isFinite(x)&&x>=0&&x<=p.length)||x1===x2)throw new RangeError('markers must differ and lie on string');const wavelength=2*Math.abs(x2-x1);return{...q,wavelength,speed:wavelength*q.frequency,markerPositions:[x1,x2]};} export function powerFit(x,y){if(x.length!==y.length||x.length<3||x.some(v=>!Number.isFinite(v)||v<=0)||y.some(v=>!Number.isFinite(v)||v<=0))throw new RangeError('need three positive pairs');const a=x.map(Math.log10),b=y.map(Math.log10),mx=a.reduce((s,v)=>s+v,0)/a.length,my=b.reduce((s,v)=>s+v,0)/b.length,sxx=a.reduce((s,v)=>s+(v-mx)**2,0);if(sxx<1e-20)throw new RangeError('distinct inputs required');const slope=a.reduce((s,v,i)=>s+(v-mx)*(b[i]-my),0)/sxx,intercept=my-slope*mx,residuals=b.map((v,i)=>v-slope*a[i]-intercept),slopeSE=Math.sqrt(residuals.reduce((s,v)=>s+v*v,0)/(a.length-2)/sxx);return{slope,intercept,slopeSE,residuals,count:x.length};} export function generateSweep(p,regime){validateParameters(p);const base=properties(p);if(!['tension','frequency'].includes(regime))throw new RangeError('unknown regime');const inputs=regime==='tension'?[.06,.08,.1,.12,.14,.16,.18]:[60,70,80,90,100,110,120];return inputs.map(value=>{let next={...p};if(regime==='tension'){next.mass=value;next.length=next.mode*Math.sqrt(next.mass*next.gravity/next.linearDensity)/(2*base.frequency);}else next.length=next.mode*base.speed/(2*value);validateParameters(next);return{experiment:'course-02/string-vibration',modelVersion:MODEL_VERSION,source:'ideal-model',regime,parameters:next,raw:{length:next.length,halfWaves:next.mode},derived:properties(next)};});} export function fitRecords(records,regime){const rows=records.filter(r=>r.regime===regime);if(rows.length<3)throw new RangeError('至少记录三组数据');const p=rows[0].parameters;for(const r of rows){if(r.parameters.linearDensity!==p.linearDensity||r.parameters.gravity!==p.gravity||r.source!==rows[0].source)throw new RangeError('线密度、重力或来源不同,请清空后重新记录');const fixed=regime==='tension'?'frequency':'tension';if(Math.abs(r.derived[fixed]-rows[0].derived[fixed])>1e-9*Math.max(1,Math.abs(rows[0].derived[fixed])))throw new RangeError(regime==='tension'?'张力拟合须保持频率不变':'频率拟合须保持张力不变');}return powerFit(rows.map(r=>r.derived[regime]),rows.map(r=>r.derived.wavelength));}