export const MODEL_VERSION='1.0.0';export const DEFAULT_PARAMETERS=Object.freeze({alpha:17.25e-6,length:.150,referenceTemperature:20,targetTemperature:100,timeConstant:15,temperatureBias:0,dialGain:1,dialOrigin:20e-6});export const PARAMETER_BOUNDS=Object.freeze({alpha:[5e-6,30e-6],length:[.1,.3],referenceTemperature:[20,20],targetTemperature:[30,110],timeConstant:[5,60],temperatureBias:[-2,2],dialGain:[.95,1.05],dialOrigin:[0,50e-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}]`);return p;} export function temperature(p,t){validateParameters(p);if(!Number.isFinite(t)||t<0)throw new RangeError('invalid time');return p.targetTemperature+(p.referenceTemperature-p.targetTemperature)*Math.exp(-t/p.timeConstant);} export function reading(p,T){validateParameters(p);if(!Number.isFinite(T)||T<20||T>110)throw new RangeError('温度须在20–110℃');const extension=p.alpha*p.length*(T-p.referenceTemperature);return{temperature:T,indicatedTemperature:T+p.temperatureBias,extension,dialReading:p.dialOrigin+p.dialGain*extension};} export function measurement(p,temperatureReading,dialReading,source,actualTemperature=null){validateParameters(p);if(!Number.isFinite(temperatureReading)||temperatureReading<18||temperatureReading>112||!Number.isFinite(dialReading)||dialReading<0||dialReading>.001||!['ideal-equilibrium','manual-readings'].includes(source))throw new RangeError('温度读数18–112℃、千分表读数0–1000μm');return{experiment:'course-02/thermal-expansion',modelVersion:MODEL_VERSION,source,parameters:{...p},raw:{temperatureReading,dialReading,actualTemperature},derived:{extensionFromOrigin:dialReading-p.dialOrigin}};} export function sweep(p){return[30,40,50,60,70,80,90,100].map(T=>{const q=reading(p,T);return measurement(p,q.indicatedTemperature,q.dialReading,'ideal-equilibrium',T);});} export function lineFit(x,y){if(x.length!==y.length||x.length<3||![...x,...y].every(Number.isFinite))throw new RangeError('至少3组有限值');const mx=x.reduce((a,b)=>a+b)/x.length,my=y.reduce((a,b)=>a+b)/y.length,sxx=x.reduce((s,v)=>s+(v-mx)**2,0);if(sxx<1e-20)throw new RangeError('须包含不同温度');const slope=x.reduce((s,v,i)=>s+(v-mx)*(y[i]-my),0)/sxx,intercept=my-slope*mx,residuals=y.map((v,i)=>v-slope*x[i]-intercept);return{slope,intercept,residuals};} export function fitRecords(rows,source){const a=rows.filter(r=>r.source===source);if(a.length<3)throw new RangeError('至少3组相同来源记录');const p=a[0].parameters;for(const r of a)for(const k of['alpha','length','referenceTemperature','temperatureBias','dialGain','dialOrigin'])if(r.parameters[k]!==p[k])throw new RangeError('材料、长度与传感器条件须保持不变');const fit=lineFit(a.map(r=>r.raw.temperatureReading),a.map(r=>r.raw.dialReading));return{...fit,alpha:fit.slope/p.length};}