"""Synthetic load-cell calibration, causal filters and explicit zero tracking."""
from dataclasses import dataclass
import math
import numpy as np

def fit_calibration(mass_g, voltage_uv):
    x=np.asarray(mass_g,dtype=float);y=np.asarray(voltage_uv,dtype=float)
    if len(x)<3 or x.shape!=y.shape or not np.all(np.isfinite(x+y)) or np.ptp(x)==0:
        raise ValueError('Need at least three finite calibration samples spanning mass')
    X=np.column_stack([np.ones(len(x)),x]);coef=np.linalg.lstsq(X,y,rcond=None)[0]
    if coef[1]<=0:raise ValueError('Positive sensitivity required')
    residual=y-X@coef;variance=float(residual@residual/(len(x)-2))
    covariance=variance*np.linalg.inv(X.T@X)
    return dict(offset_uv=float(coef[0]),slope_uv_per_g=float(coef[1]),covariance=covariance,
                residual_uv=residual,residual_std_uv=math.sqrt(variance))

def convert(voltage_uv,fit):return (np.asarray(voltage_uv)-fit['offset_uv'])/fit['slope_uv_per_g']

def calibration_uncertainty(fit,mass_g):
    """Propagate correlated intercept and slope for m=(V-b)/s."""
    jac=np.array([-1/fit['slope_uv_per_g'],-mass_g/fit['slope_uv_per_g']])
    return float(np.sqrt(jac@fit['covariance']@jac))

def moving_average(x,window):
    x=np.asarray(x,dtype=float)
    if window<1 or int(window)!=window:raise ValueError('Positive integer window required')
    if not len(x):return x.copy()
    # Startup uses the first observed value, not future samples.
    return np.convolve(np.pad(x,(window-1,0),mode='edge'),np.ones(window)/window,mode='valid')

def exponential(x,tau_s,sample_hz):
    if tau_s<=0 or sample_hz<=0:raise ValueError('Positive time constant and sample rate required')
    x=np.asarray(x,dtype=float)
    if not len(x):return x.copy()
    alpha=-math.expm1(-1/(tau_s*sample_hz));y=np.empty_like(x);y[0]=x[0]
    for i in range(1,len(x)):y[i]=y[i-1]+alpha*(x[i]-y[i-1])
    return y

def filters(x,hz):
    return {'Raw':np.asarray(x).copy(),'MA 10':moving_average(x,10),'MA 25':moving_average(x,25),
            'MA 50':moving_average(x,50),'EMA 0.1 s':exponential(x,.1,hz),'EMA 0.3 s':exponential(x,.3,hz)}

def settling_time(y,step_index,target,tolerance,hz):
    """First sample after the LAST out-of-band sample; None if end never settles."""
    z=np.asarray(y)[step_index:];bad=np.where(np.abs(z-target)>tolerance)[0]
    first=int(bad[-1]+1) if len(bad) else 0
    return None if first>=len(z) else first/hz

@dataclass
class ZeroTracker:
    """Optional: requires external empty-pan confirmation, small signal and stable window.
    It cannot distinguish a small real load from drift if empty confirmation is wrong.
    """
    gate_g:float=5.0
    stable_std_g:float=1.0
    alpha:float=.02
    offset_g:float=0.0
    def update(self,window,empty_confirmed=False):
        data=np.asarray(window,dtype=float)
        if len(data)<25 or not np.all(np.isfinite(data)):return self.offset_g
        mean=float(np.mean(data))
        if empty_confirmed and abs(mean)<=self.gate_g and np.std(data,ddof=1)<=self.stable_std_g:
            self.offset_g+=(mean-self.offset_g)*self.alpha
        return self.offset_g

def uncertainty_budget(fit,repeatability_g,mass_g,adc_step_g,drift_bound_g,inputs):
    values=[('Filtered single-reading repeatability',repeatability_g,'normal','Synthetic white-noise trace; not the uncertainty of the mean'),
            ('Calibration fit (slope/intercept covariance)',calibration_uncertainty(fit,mass_g),'normal','OLS covariance; correlated parameters propagated together'),
            ('Reference masses',inputs['reference_standard_g'],'normal','Assumed standard uncertainty, not a weight certificate'),
            ('Residual nonlinearity',inputs['nonlinearity_bound_g']/math.sqrt(3),'rectangular','Assumed symmetric bound'),
            ('Hysteresis',inputs['hysteresis_bound_g']/math.sqrt(3),'rectangular','Assumed symmetric bound'),
            ('Temperature zero shift',inputs['temperature_zero_bound_g']/math.sqrt(3),'rectangular','Assumed bound over declared temperature excursion'),
            ('Temperature span shift',mass_g*inputs['temperature_span_bound_fraction']/math.sqrt(3),'rectangular','Assumed fraction of reading'),
            ('60 s drift',drift_bound_g/math.sqrt(3),'rectangular','Assumed symmetric bound; trace uses positive ramp as one stress case'),
            ('ADC rounding',adc_step_g/math.sqrt(12),'rectangular','Quantization interval; ENOB and analog noise are separate')]
    rows=[dict(component=n,standard_uncertainty_g=u,variance_g2=u*u,distribution=d,basis=b) for n,u,d,b in values]
    return rows,math.sqrt(sum(r['variance_g2'] for r in rows))
