Files

192 lines
9.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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