# /// script # requires-python = ">=3.11" # dependencies = ["numpy"] # /// """Genera un CSV con T e ΔT = T − T_amb con rumore realistico (vedi docs/SPEC.md).""" import argparse import re import secrets import sys import tomllib from pathlib import Path import numpy as np CARTELLA_OUTPUT = Path("output") GRIGLIA_MS = 5.0 # passo della griglia fine per il filtro del sensore DESCRIZIONE = """\ Genera un CSV con la temperatura T di un sensore IR e la differenza dT = T - T_amb, con rumore, quantizzazione e jitter dei tempi simili a un sensore reale. T parte dalla temperatura ambiente, sale con uno o più gradini di riscaldamento e ridiscende con raffreddamento esponenziale fino a T_amb. Ogni corsa (seed) è diversa.""" EPILOGO = """\ esempi: genera.py config.toml scrive output/gen_.csv, seed casuale genera.py config.toml --seed 42 riproducibile: output/gen_42.csv genera.py config.toml -o dati/prova.csv percorso a scelta genera.py config.toml -o - > out.csv su stdout formato dell'output (separatore ',', fine riga '\\n'): t_ms,T_C,dT_C 151,22.90,0.20 t_ms è intero; T_C e dT_C hanno due decimali. dT è calcolato dopo rumore e quantizzazione di entrambi i canali (T e ambiente). cosa fa (in ordine): 1. tempi t_i = t_(i-1) + dt_ms +/- jitter_ms, fino a durata_s 2. T vera ambiente vero + gradini di riscaldamento con raffreddamento esponenziale, perturbati a caso a ogni corsa + disturbi a salto 3. sensore filtro del 1° ordine (tau_sensore_s) su griglia fine da 5 ms 4. rumore di T bianco (più forte a caldo) + deriva lenta, poi quantizzazione 5. ambiente, dT ambiente con rumore e quantizzazione propri; dT = T - ambiente gruppi di chiavi di config.toml (unica fonte dei valori, unità nel suffisso: _s secondi, _ms millisecondi, _c gradi C, _hz 1/s; ogni chiave è obbligatoria): tempi durata_s, dt_ms, jitter_ms ambiente amb_inf_c, amb_A_c, amb_tau_s, amb_sigma_c, amb_passo_c rumore di T sigma_c, sigma_caldo_c, x_rif_c, wander_sigma_c, wander_tau_s, passo_T_c gradini t_impulsi_s, dT_impulsi_c ([] = nessun riscaldamento; stessa lunghezza), k_raffreddamento_hz, tau_sensore_s variabilità impulsi_jitter_t_s, impulsi_jitter_amp_rel, k_jitter_rel (0 = disattivata) salti salti_rate_hz, salti_media_c, salti_sigma_c, salti_tau_s, salti_x_min_c, salti_max_c (raffreddamento); salti_risc_rate_hz, salti_risc_media_c, salti_risc_sigma_c, salti_risc_tau_s (riscaldamento) codici di uscita: 0 ok 1 errore, con messaggio in italiano su stderr: file di configurazione illeggibile o TOML non valido, chiave mancante, t_impulsi_s e dT_impulsi_c di lunghezza diversa, file di uscita non scrivibile 2 argomenti della riga di comando non validi altri strumenti: grafico.py (disegna un CSV), pulisci.sh (svuota output/). documentazione: docs/SPEC.md (modello e controlli), docs/FUNZIONALITA.md (funzionalità).""" CHIAVI = [ "durata_s", "dt_ms", "jitter_ms", "amb_inf_c", "amb_A_c", "amb_tau_s", "amb_sigma_c", "amb_passo_c", "sigma_c", "sigma_caldo_c", "x_rif_c", "wander_sigma_c", "wander_tau_s", "passo_T_c", "t_impulsi_s", "dT_impulsi_c", "k_raffreddamento_hz", "tau_sensore_s", "impulsi_jitter_t_s", "impulsi_jitter_amp_rel", "k_jitter_rel", "salti_rate_hz", "salti_media_c", "salti_sigma_c", "salti_tau_s", "salti_x_min_c", "salti_max_c", "salti_risc_rate_hz", "salti_risc_media_c", "salti_risc_sigma_c", "salti_risc_tau_s", ] class ParserIt(argparse.ArgumentParser): """ArgumentParser con i messaggi d'errore più comuni in italiano (exit 2).""" TRADUZIONI = [ (r"the following arguments are required: (.*)", r"manca l'argomento obbligatorio: \1"), (r"unrecognized arguments: (.*)", r"argomenti non riconosciuti: \1"), (r"argument (.*): invalid int value: (.*)", r"argomento \1: \2 non è un numero intero"), (r"argument (.*): expected one argument", r"argomento \1: manca il valore"), ] def error(self, message): for pattern, sostituzione in self.TRADUZIONI: message = re.sub(pattern, sostituzione, message) self.print_usage(sys.stderr) print(f"{self.prog}: errore: {message}", file=sys.stderr) sys.exit(2) def errore(msg): print(f"Errore: {msg}", file=sys.stderr) sys.exit(1) def leggi_config(percorso): try: with open(percorso, "rb") as f: cfg = tomllib.load(f) except OSError as e: errore(f"impossibile leggere '{percorso}': {e.strerror}") except tomllib.TOMLDecodeError as e: errore(f"TOML non valido in '{percorso}': {e}") mancanti = [k for k in CHIAVI if k not in cfg] if mancanti: errore(f"chiavi mancanti in '{percorso}': {', '.join(mancanti)}") if len(cfg["t_impulsi_s"]) != len(cfg["dT_impulsi_c"]): errore("'t_impulsi_s' e 'dT_impulsi_c' hanno lunghezze diverse") return cfg def eccesso(t_s, cfg): """Eccesso termico x(t): un gradino per impulso, poi raffreddamento esponenziale.""" x = np.zeros_like(t_s) for tj, dj in zip(cfg["t_impulsi_s"], cfg["dT_impulsi_c"]): dopo = t_s >= tj x[dopo] += dj * np.exp(-cfg["k_raffreddamento_hz"] * (t_s[dopo] - tj)) return x def perturba(cfg, rng): """Copia di cfg con istanti, ampiezze e k degli impulsi variati a caso (ogni corsa è diversa).""" n = len(cfg["t_impulsi_s"]) t = np.sort(np.asarray(cfg["t_impulsi_s"], float) + cfg["impulsi_jitter_t_s"] * rng.standard_normal(n)) d = np.asarray(cfg["dT_impulsi_c"], float) * (1 + cfg["impulsi_jitter_amp_rel"] * rng.standard_normal(n)) k = cfg["k_raffreddamento_hz"] * (1 + cfg["k_jitter_rel"] * rng.standard_normal()) return {**cfg, "t_impulsi_s": t, "dT_impulsi_c": d, "k_raffreddamento_hz": k} def eventi(g_s, cfg, rng, t_ini, t_fin, rate, media, sigma, tau, x_min): """Scarti casuali (Poisson) in [t_ini, t_fin], con rientro esponenziale, se x ≥ x_min.""" s = np.zeros_like(g_s) n = rng.poisson(rate * (t_fin - t_ini)) ts = np.sort(rng.uniform(t_ini, t_fin, n)) ampiezze = np.clip(rng.normal(media, sigma, n), -cfg["salti_max_c"], cfg["salti_max_c"]) for tj, aj in zip(ts, ampiezze): if eccesso(np.array([tj]), cfg)[0] < x_min: continue dopo = g_s >= tj s[dopo] += aj * np.exp(-(g_s[dopo] - tj) / tau) return s def salti(g_s, cfg, rng): """Disturbi a salto: durante il riscaldamento (tra primo e ultimo gradino) e il raffreddamento.""" if len(cfg["t_impulsi_s"]) == 0: return np.zeros_like(g_s) t_primo, t_ultimo = cfg["t_impulsi_s"][0], cfg["t_impulsi_s"][-1] risc = eventi(g_s, cfg, rng, t_primo, t_ultimo, cfg["salti_risc_rate_hz"], cfg["salti_risc_media_c"], cfg["salti_risc_sigma_c"], cfg["salti_risc_tau_s"], 0.0) raff = eventi(g_s, cfg, rng, t_ultimo, cfg["durata_s"], cfg["salti_rate_hz"], cfg["salti_media_c"], cfg["salti_sigma_c"], cfg["salti_tau_s"], cfg["salti_x_min_c"]) return risc + raff def ambiente_vero(t_s, cfg): return cfg["amb_inf_c"] + cfg["amb_A_c"] * np.exp(-t_s / cfg["amb_tau_s"]) def quantizza(v, passo): return np.round(v / passo) * passo def genera(cfg, rng): cfg = perturba(cfg, rng) # 1. tempi n_max = int(cfg["durata_s"] * 1000 / (cfg["dt_ms"] - cfg["jitter_ms"])) + 2 dt = cfg["dt_ms"] + rng.uniform(-cfg["jitter_ms"], cfg["jitter_ms"], n_max) t_ms = np.cumsum(dt) t_ms = t_ms[t_ms <= cfg["durata_s"] * 1000] dt = np.diff(t_ms, prepend=0.0) t_s = t_ms / 1000 # 2. T vera su griglia fine = ambiente vero + eccesso termico + salti g_s = np.arange(0.0, cfg["durata_s"] + GRIGLIA_MS / 1000, GRIGLIA_MS / 1000) t_vera = ambiente_vero(g_s, cfg) + eccesso(g_s, cfg) + salti(g_s, cfg, rng) # 3. risposta del sensore: filtro del 1° ordine, interpolato ai t_i a = np.exp(-GRIGLIA_MS / 1000 / cfg["tau_sensore_s"]) y = np.empty_like(t_vera) y[0] = t_vera[0] for n in range(1, len(y)): y[n] = a * y[n - 1] + (1 - a) * t_vera[n] y_i = np.interp(t_s, g_s, y) # 4. rumore di T: bianco con σ(x) + deriva Ornstein-Uhlenbeck, poi quantizzazione x_i = eccesso(t_s, cfg) sigma = cfg["sigma_c"] + (cfg["sigma_caldo_c"] - cfg["sigma_c"]) * np.minimum(1.0, x_i / cfg["x_rif_c"]) phi = np.exp(-(dt / 1000) / cfg["wander_tau_s"]) inn = rng.standard_normal(len(t_s)) w = np.empty_like(t_s) w[0] = cfg["wander_sigma_c"] * inn[0] for i in range(1, len(w)): w[i] = phi[i] * w[i - 1] + cfg["wander_sigma_c"] * np.sqrt(1 - phi[i] ** 2) * inn[i] T = quantizza(y_i + w + sigma * rng.standard_normal(len(t_s)), cfg["passo_T_c"]) # 5. ambiente misurato e ΔT (dopo rumore e quantizzazione di entrambi i canali) amb = quantizza( ambiente_vero(t_s, cfg) + cfg["amb_sigma_c"] * rng.standard_normal(len(t_s)), cfg["amb_passo_c"], ) dT = quantizza(T - amb, cfg["passo_T_c"]) return np.round(t_ms).astype(int), T, dT def scrivi(f, t_ms, T, dT): f.write("t_ms,T_C,dT_C\n") for t, a, b in zip(t_ms, T, dT): # "+ 0.0" evita "-0.00" f.write(f"{t},{round(a, 2) + 0.0:.2f},{round(b, 2) + 0.0:.2f}\n") def main(): ap = ParserIt( prog="genera.py", usage="genera.py [-h] [-o FILE] [--seed N] config", description=DESCRIZIONE, epilog=EPILOGO, formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False, ) argomenti = ap.add_argument_group("argomenti") opzioni = ap.add_argument_group("opzioni") opzioni.add_argument("-h", "--help", action="help", help="mostra questo aiuto ed esce") argomenti.add_argument("config", help="file di configurazione TOML (es. config.toml); tutte le chiavi sono obbligatorie") opzioni.add_argument( "-o", "--output", metavar="FILE", help="file CSV di uscita; le cartelle mancanti vengono create. '-' scrive su stdout. " "Default: output/gen_.csv", ) opzioni.add_argument( "--seed", type=int, metavar="N", help="seme del generatore casuale: stesso seed e stessa configurazione danno lo stesso file. " "Default: casuale, stampato su stderr", ) args = ap.parse_args() cfg = leggi_config(args.config) seed = args.seed if args.seed is not None else secrets.randbelow(2**32) if args.seed is None: print(f"seed: {seed}", file=sys.stderr) t_ms, T, dT = genera(cfg, np.random.default_rng(seed)) if args.output == "-": scrivi(sys.stdout, t_ms, T, dT) return percorso = Path(args.output) if args.output else CARTELLA_OUTPUT / f"gen_{seed}.csv" try: percorso.parent.mkdir(parents=True, exist_ok=True) with open(percorso, "w", newline="\n") as f: scrivi(f, t_ms, T, dT) except OSError as e: errore(f"impossibile scrivere '{percorso}': {e.strerror}") print(f"scritto: {percorso}", file=sys.stderr) if __name__ == "__main__": main()