import unittest, math
import numpy as np
from measurement import *

class MeasurementTests(unittest.TestCase):
    def test_exact_calibration_recovers_known_parameters(self):
        x=np.array([0,100,200,300]);f=fit_calibration(x,17+1.2*x)
        np.testing.assert_allclose(convert(17+1.2*x,f),x,atol=1e-10)
        self.assertAlmostEqual(f['offset_uv'],17,places=10);self.assertAlmostEqual(f['slope_uv_per_g'],1.2,places=10)
    def test_calibration_residual_orthogonality(self):
        x=np.arange(20)*100;f=fit_calibration(x,30+x+np.random.default_rng(7).normal(0,2,20))
        self.assertLess(abs(sum(f['residual_uv'])),1e-8);self.assertLess(abs(x@f['residual_uv']),1e-5)
    def test_covariance_against_monte_carlo(self):
        rng=np.random.default_rng(82);x=np.repeat(np.linspace(0,10000,6),30)
        f=fit_calibration(x,30+1.0004*x+rng.normal(0,2,len(x)))
        draws=rng.multivariate_normal([f['offset_uv'],f['slope_uv_per_g']],f['covariance'],size=100000)
        v=f['offset_uv']+f['slope_uv_per_g']*5000
        simulated=(v-draws[:,0])/draws[:,1]
        self.assertLess(abs(np.std(simulated)/calibration_uncertainty(f,5000)-1),.02)
    def test_mean_filter_known_step_and_noise_gain(self):
        x=np.r_[np.zeros(10),np.ones(30)];y=moving_average(x,10)
        self.assertAlmostEqual(y[10],.1);self.assertAlmostEqual(y[19],1)
        self.assertEqual(settling_time(y,10,1,.001,100),.09)
        noise=np.random.default_rng(9).normal(0,2,100000)
        self.assertLess(abs(np.std(moving_average(noise,25)[100:])/(2/5)-1),.03)
    def test_filters_are_causal(self):
        x=np.random.default_rng(3).normal(size=100);changed=x.copy();changed[50:]+=10000
        for name,y in filters(x,100).items():np.testing.assert_allclose(y[:50],filters(changed,100)[name][:50])
    def test_constant_signal_is_preserved(self):
        for y in filters(np.ones(100)*7,100).values():np.testing.assert_allclose(y,7)
    def test_ema_matches_analytic_step(self):
        y=exponential(np.r_[0.,np.ones(100)],.1,100)
        np.testing.assert_allclose(y[1:],1-np.exp(-np.arange(1,101)/10),atol=1e-14)
    def test_drift_is_not_hidden_by_filter(self):
        x=np.arange(1000)/100*.02;y=moving_average(x,25)
        self.assertAlmostEqual(x[-1]-y[-1],.02*24/(2*100),places=10)
    def test_zero_tracking_requires_external_empty_confirmation(self):
        tracker=ZeroTracker()
        for _ in range(1000):tracker.update(np.ones(100)*3,False)
        self.assertEqual(tracker.offset_g,0)
    def test_zero_tracking_rejects_large_or_unstable_load(self):
        tracker=ZeroTracker()
        for window in [np.ones(100)*5000,np.tile([-4,4],50)]:tracker.update(window,True)
        self.assertEqual(tracker.offset_g,0)
    def test_explicit_empty_stable_window_can_track_zero(self):
        tracker=ZeroTracker();tracker.update(np.ones(100)*2,True);self.assertAlmostEqual(tracker.offset_g,.04)
    def test_resolution_headroom_and_uniform_uncertainty(self):
        fs=2.5/128;lsb=2*fs/2**24
        self.assertGreater(fs,.012);self.assertLess(lsb,3e-9)
        draws=np.random.default_rng(5).uniform(-2,2,100000)
        self.assertLess(abs(np.std(draws)-2/math.sqrt(3)),.01)
    def test_invalid_calibration_and_filters(self):
        for x,y in [([1,1,1],[1,2,3]),([1,2],[1,2]),([1,2,3],[3,2,1])]:
            with self.assertRaises(ValueError):fit_calibration(x,y)
        with self.assertRaises(ValueError):moving_average([1,2],0)
        with self.assertRaises(ValueError):exponential([1,2],0,100)
    def test_settling_rejects_late_reexcursion(self):
        self.assertIsNone(settling_time([0,1,1,0],1,1,.01,1))

if __name__=='__main__':unittest.main()
