"""Fit, regola di scelta e analisi dei domini su segnali sintetici (verità nota: τ = 16 s, ambiente 23 °C).""" import numpy as np import pytest from conftest import GRADINI, TAU, segnale from fitseg import domini from fitseg.analisi import analizza_domini, fit_scelto from fitseg.fit import adatta from fitseg.modelli import MODELLI, valuta def test_modelli_valutazione(): t = np.array([0.0, 1.0]) assert valuta("costante", t, [5.0]).tolist() == [5.0, 5.0] assert valuta("lineare", t, [1.0, 2.0]).tolist() == [1.0, 3.0] assert valuta("esp_decad", t, [1.0, 2.0, 1.0])[0] == pytest.approx(3.0) assert set(MODELLI) == {"costante", "lineare", "esp_decad"} @pytest.mark.parametrize("seed", range(5)) def test_esp_decad_recupera_parametri(cfg, seed): rng = np.random.default_rng(seed) t = np.arange(0, 100, 0.15) y = 23 + 180 * np.exp(-t / 16) + 0.1 * rng.standard_normal(len(t)) fit = adatta(t, y, "esp_decad", 0.1, cfg) p = fit["parametri"] assert p["tau"] == pytest.approx(16, rel=0.02) assert p["c"] == pytest.approx(23, abs=0.2) assert p["A"] == pytest.approx(180, rel=0.02) assert fit["rmse"] == pytest.approx(0.1, rel=0.2) assert not fit["non_identificabile"] and not fit["parametri_a_limite"] def test_parametri_fissi_restano_fissi(cfg): rng = np.random.default_rng(0) t = np.arange(0, 4, 0.15) y = 23 + 80 * np.exp(-t / 16) + 0.1 * rng.standard_normal(len(t)) fit = adatta(t, y, "esp_decad", 0.1, cfg, fissi={"c": 23.0, "tau": 16.0}) assert fit["fissi"] == ["c", "tau"] assert fit["parametri"]["c"] == 23.0 and fit["parametri"]["tau"] == 16.0 assert fit["n_parametri"] == 1 assert fit["parametri"]["A"] == pytest.approx(80, rel=0.03) @pytest.mark.parametrize("seed", range(5)) def test_tau_libero_non_identificabile_su_tratto_corto_e_rumoroso(cfg, seed): # 3 s di dati con disturbi dell'ordine degli scarti casuali del sensore (≈ 2 °C): τ non è stimabile da solo rng = np.random.default_rng(seed) t = np.arange(0, 3, 0.15) y = 23 + 80 * np.exp(-t / 16) + 2.0 * rng.standard_normal(len(t)) assert adatta(t, y, "esp_decad", 2.0, cfg)["non_identificabile"] def test_analisi_completa_sintetico(sintetico, cfg): t, serie = sintetico ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg) assert [r["tipo"] for r in ris] == ["riposo", "riscaldamento", "raffreddamento", "riscaldamento", "raffreddamento", "riscaldamento", "raffreddamento"] for nome in serie: assert ris[0]["serie"][nome]["scelta"] in ("costante", "lineare") finale = next(x for x in ris[6]["serie"][nome]["fit"] if x["modello"] == "esp_decad") assert finale["fissi"] == [] and finale["parametri"]["tau"] == pytest.approx(TAU, rel=0.03) for i in (2, 4): # raffreddamenti intermedi: c e τ ereditati dal finale fit = next(x for x in ris[i]["serie"][nome]["fit"] if x["modello"] == "esp_decad") assert ris[i]["serie"][nome]["scelta"] == "esp_decad" and fit["fissi"] == ["c", "tau"] assert fit["parametri"]["tau"] == finale["parametri"]["tau"] and fit["parametri"]["c"] == finale["parametri"]["c"] # ampiezza dei raffreddamenti intermedi = somma dei decadimenti dei gradini già avvenuti, in t' = 0 for i, n_gradini in ((2, 1), (4, 2)): t0 = ris[i]["t0_s"] attesa = sum(a * np.exp(-(t0 - tj) / TAU) for tj, a in GRADINI[:n_gradini]) fit = next(x for x in ris[i]["serie"]["dT_C"]["fit"] if x["modello"] == "esp_decad") assert fit["parametri"]["A"] == pytest.approx(attesa, rel=0.05) def test_riscaldamento_descritto_come_salto_non_fittato(sintetico, cfg): t, serie = sintetico ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg) for i, (_, ampiezza) in zip((1, 3, 5), GRADINI, strict=True): vs = ris[i]["serie"]["dT_C"] assert vs["fit"] == [] and vs["scelta"] is None g = vs["gradino"] assert g["stima"] == "modelli" and g["n"] <= 3 assert g["delta"] == pytest.approx(ampiezza, rel=0.03) # stimato dai modelli adiacenti all'istante del gradino assert g["delta_misurato"] == pytest.approx(ampiezza, rel=0.15) # misura sui campioni (stima per difetto) def test_un_solo_raffreddamento_resta_libero(cfg): t, serie = segnale(gradini=[(20.0, 80.0)], durata=60.0) ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg) assert [r["tipo"] for r in ris] == ["riposo", "riscaldamento", "raffreddamento"] assert all(x["fissi"] == [] for x in ris[2]["serie"]["T_C"]["fit"]) def test_dominio_troppo_corto_indeterminato(sintetico, cfg): t, serie = sintetico ds = [{"tipo": "riposo", "i0": 0, "i1": 5}] vs = analizza_domini(t, serie, ds, cfg)[0]["serie"]["T_C"] assert vs["scelta"] is None and "n_min_punti" in vs["motivo"] def test_riproducibile(sintetico, cfg): t, serie = sintetico ds = domini.proponi(t, serie, cfg) a = analizza_domini(t, serie, ds, cfg) b = analizza_domini(t, serie, ds, cfg) assert a == b def test_salto_senza_modelli_adiacenti_resta_la_misura(sintetico, cfg): t, serie = sintetico ds = domini.proponi(t, serie, cfg)[:2] # riposo + riscaldamento: nessun raffreddamento dopo il gradino g = analizza_domini(t, serie, ds, cfg)[1]["serie"]["dT_C"]["gradino"] assert g["stima"] == "misura" and g["delta"] == g["delta_misurato"] def test_raffreddamento_corto_con_parametri_fissi_si_fitta(cfg): # due gradini a 0.9 s: il tratto in mezzo ha pochi campioni, ma con c e τ fissi basta stimare A t, serie = segnale(gradini=[(20.0, 80.0), (20.9, 60.0)], durata=80.0) ds = domini.proponi(t, serie, cfg) ris = analizza_domini(t, serie, ds, cfg) intermedio = ris[2] assert intermedio["tipo"] == "raffreddamento" and intermedio["n_fit"] < cfg["domini"]["n_min_punti"] vs = intermedio["serie"]["dT_C"] assert vs["scelta"] == "esp_decad" and fit_scelto(vs)["fissi"] == ["c", "tau"] atteso = 80.0 * np.exp(-(intermedio["t0_s"] - 20.0) / TAU) assert fit_scelto(vs)["parametri"]["A"] == pytest.approx(atteso, rel=0.03) def test_troppo_corto_anche_per_i_parametri_fissi(cfg): t, serie = segnale(gradini=[(20.0, 80.0), (20.5, 60.0)], durata=80.0) # tratto intermedio di 1-2 campioni ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg) vs = ris[2]["serie"]["dT_C"] assert vs["scelta"] is None and "n_min_punti_parametri_fissi" in vs["motivo"] def test_sigma_non_stimabile_con_pochi_campioni(): from fitseg.domini import N_MIN_SIGMA, sigma_rumore y = np.random.default_rng(0).standard_normal(N_MIN_SIGMA) assert np.isnan(sigma_rumore(y[:-1])) and np.isfinite(sigma_rumore(y)) def test_tratto_corto_e_quantizzato_non_diventa_non_identificabile(cfg): # 5 campioni quantizzati a 0.01: la MAD delle derivate seconde si annullerebbe (σ ≈ 0) e la perdita robusta degenererebbe from fitseg.analisi import _serie ty = np.arange(5) * 0.15 yy = np.round(115.67 * np.exp(-ty / 15.8), 2) vs = _serie(ty, yy, sigma_rumore_di(yy), "raffreddamento", {"c": 0.0, "tau": 15.8}, cfg) assert vs["sigma"] is None and vs["adeguato"] is None assert vs["scelta"] == "esp_decad" and fit_scelto(vs)["parametri"]["A"] == pytest.approx(115.67, rel=0.01) def sigma_rumore_di(y): from fitseg.domini import sigma_rumore return sigma_rumore(y) def test_adeguatezza_ignora_gli_scarti_casuali(cfg): # modello giusto con pochi scarti casuali, solo a caldo come in gen_data (−3 °C che rientrano in 0.7 s): la RMSE sale oltre # la soglia del vecchio criterio, la scala robusta dei residui no t, serie = segnale(gradini=[(20.0, 80.0)], durata=80.0) T = serie["T_C"].copy() for tj, ampiezza in ((21.5, -3.5), (24.0, -3.0), (26.5, 3.0), (29.0, -4.0)): T += ampiezza * np.exp(-(t - tj) / 0.7) * (t >= tj) S = {"T_C": T, "dT_C": T - 23.0} vs = analizza_domini(t, S, domini.proponi(t, S, cfg), cfg)[-1]["serie"]["T_C"] fit = fit_scelto(vs) assert fit["rmse"] > 1.3 * vs["sigma"] # il vecchio criterio lo scartava… assert fit["scala_residui"] <= 1.5 * vs["sigma"] and vs["adeguato"] is True # …la scala robusta no assert fit["parametri"]["tau"] == pytest.approx(TAU, rel=0.03) def test_salto_con_tratto_adiacente_indeterminato_usa_la_misura_corretta(cfg): # due gradini a 0.5 s: il tratto in mezzo (1-2 campioni) è indeterminato, ma c'è il raffreddamento finale con c e τ t, serie = segnale(gradini=[(20.0, 80.0), (20.5, 60.0)], durata=80.0) ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg) assert ris[2]["serie"]["dT_C"]["scelta"] is None for i, ampiezza in ((1, 80.0), (3, 60.0)): g = ris[i]["serie"]["dT_C"]["gradino"] assert g["stima"] == "misura corretta" assert abs(g["delta"] - ampiezza) <= abs(g["delta_misurato"] - ampiezza) + 1e-9 assert g["delta"] == pytest.approx(ampiezza, rel=0.03) def test_dati_non_finiti_non_convergono_senza_eccezioni(cfg): t = np.arange(20) * 0.15 y = 25.0 + np.exp(-t / 5.0) y[3] = np.nan for nome in MODELLI: r = adatta(t, y, nome, 0.1, cfg) assert r["convergenza"] is False and r["modello"] == nome