# Motore principale: genera N run randomizzati della fascetta riscaldata da # sorgenti a induzione in movimento e scrive i CSV del dataset. # # Il modello termico è una shell a elementi finiti quadrilateri sull'intera # superficie cilindrica, con una temperatura per nodo. La pipeline di un run è # in prepara_stato_termico() e passo_termico(), condivise con i moduli di # animazione: ogni modifica alla fisica va fatta lì, non duplicata. import csv import math import os import random import shutil from concurrent.futures import ProcessPoolExecutor from copy import deepcopy from pathlib import Path import numpy as np import assemblaggio import sensore as sens import solutore as sol import sorgente as src from config import ARIA, FASCETTA, RANDOMIZZAZIONE, SENSORE, SIMULAZIONE, SORGENTE from materials import MATERIALI from mesh import costruisci_mesh_cilindrica MU0 = 4.0 * math.pi * 1e-7 def calcola_skin_depth_m(materiale: dict, frequenza_hz: float) -> float: # Skin depth elettromagnetica approssimata: # delta = sqrt(2 * rho_e / (omega * mu)) # # Semplificata. Per acciai ferromagnetici il comportamento reale # è fortemente non lineare con temperatura e campo magnetico. rho_e = materiale["resistivita_elettrica_ohm_m"] mu_r = materiale["permeabilita_relativa"] omega = 2.0 * math.pi * frequenza_hz mu = MU0 * mu_r return math.sqrt(2.0 * rho_e / (omega * mu)) def numero_fourier_spessore( materiale: dict, spessore_m: float, sorgente: dict ) -> float: # Verifica quantitativa dell'ipotesi di parete termicamente sottile: # # Fo = alpha * (sigma / |v|) / spessore^2 # # confronta il tempo di diffusione attraverso lo spessore con il tempo di # transito della sorgente. Per Fo >> 1 lo spessore si equilibra molto # prima che la sorgente sia passata e una shell a una temperatura per # nodo è adeguata; scendendo verso Fo ~ 1 servirebbe una formulazione # multistrato. alpha = materiale["conducibilita_termica_W_mK"] / ( materiale["densita_kg_m3"] * materiale["calore_specifico_J_kgK"] ) velocita = abs(sorgente["velocita_m_s"]) if velocita <= 0.0: return float("inf") tempo_transito = sorgente["sigma_punto_m"] / velocita return alpha * tempo_transito / (spessore_m * spessore_m) def prepara_stato_termico( fascetta: dict, aria: dict, sorgente: dict, sensore: dict ) -> dict: # Costruisce mesh, matrici globali e solutore fattorizzato per un run: # tutto ciò che resta costante durante l'integrazione temporale. materiale = MATERIALI[fascetta["materiale"]] lunghezza_m = fascetta["lunghezza_mm"] / 1000.0 spessore_m = fascetta["spessore_mm"] / 1000.0 raggio_medio_m = fascetta["diametro_medio_mm"] / 2000.0 mesh = costruisci_mesh_cilindrica( lunghezza_m=lunghezza_m, raggio_medio_m=raggio_medio_m, n_elementi_x=fascetta["n_elementi_x"], n_elementi_theta=fascetta["n_elementi_theta"], ) C = assemblaggio.assembla_capacita(mesh, materiale, spessore_m) K_cond = assemblaggio.assembla_conduzione(mesh, materiale, spessore_m) K_conv, f_ambiente = assemblaggio.assembla_convezione(mesh, aria, spessore_m) dt_s = SIMULAZIONE["dt_interno_s"] solutore = sol.costruisci_solutore(C, K_cond + K_conv, dt_s) assemblatore = assemblaggio.prepara_assemblatore_sorgente(mesh) # Il fattore circonferenziale del flusso non dipende dal tempo: le # sorgenti traslano solo lungo x. fattore_arco = src.fattore_circonferenziale( sorgente, raggio_medio_m, assemblatore["arco_gauss_m"] ) attenuazione_al_sensore = float( src.fattore_circonferenziale(sorgente, raggio_medio_m, np.zeros(1))[0] ) if sorgente["skin_depth_fissa_m"] is None: skin_depth_m = calcola_skin_depth_m(materiale, sorgente["frequenza_hz"]) else: skin_depth_m = float(sorgente["skin_depth_fissa_m"]) x_sensore_m = sensore["x_mm"] / 1000.0 return { "mesh": mesh, "materiale": materiale, "spessore_m": spessore_m, "raggio_medio_m": raggio_medio_m, "dt_s": dt_s, "solutore": solutore, "assemblatore": assemblatore, "f_ambiente": f_ambiente, "fattore_arco": fattore_arco, "attenuazione_al_sensore": attenuazione_al_sensore, "x_sensore_m": x_sensore_m, "interpolatore_sensore": sens.prepara_interpolatore(mesh, x_sensore_m, 0.0), "sorgente": sorgente, "T_ambiente_C": aria["temperatura_ambiente_C"], "skin_depth_m": skin_depth_m, "numero_fourier_spessore": numero_fourier_spessore( materiale, spessore_m, sorgente ), } def campo_iniziale(stato: dict) -> np.ndarray: return np.full(stato["mesh"]["n_nodi"], stato["T_ambiente_C"], dtype=float) def passo_termico(stato: dict, T: np.ndarray, t_s: float) -> tuple[np.ndarray, dict]: # Avanza il campo di temperatura di un passo dt e restituisce anche lo # stato della sorgente in quell'istante. sorgente = stato["sorgente"] x_riferimento_m, flusso_x = src.profilo_flusso_x_W_m2( sorgente, stato["x_sensore_m"], t_s, stato["assemblatore"]["x_gauss_m"] ) f_sorgente = assemblaggio.assembla_sorgente( stato["assemblatore"], flusso_x, stato["fattore_arco"] ) T_next = sol.passo_implicito( stato["solutore"], T, stato["f_ambiente"], f_sorgente ) _, flusso_al_sensore = src.profilo_flusso_x_W_m2( sorgente, stato["x_sensore_m"], t_s, np.array([stato["x_sensore_m"]]) ) return T_next, { "x_riferimento_m": x_riferimento_m, "flusso_al_sensore_W_m2": float( flusso_al_sensore[0] * stato["attenuazione_al_sensore"] ), } def configurazione_randomizzata(indice_run: int, rng: random.Random) -> dict: fascetta = deepcopy(FASCETTA) aria = deepcopy(ARIA) sorgente = deepcopy(SORGENTE) sensore = deepcopy(SENSORE) if RANDOMIZZAZIONE.get("abilitata", False): def perturba_rel(valore: float, std_rel: float, fattore_min: float = 0.1) -> float: fattore = rng.gauss(1.0, std_rel) fattore = max(fattore_min, fattore) return valore * fattore sorgente["velocita_m_s"] = perturba_rel( sorgente["velocita_m_s"], RANDOMIZZAZIONE["velocita_std_rel"], ) sorgente["flusso_termico_picco_W_m2"] = perturba_rel( sorgente["flusso_termico_picco_W_m2"], RANDOMIZZAZIONE["flusso_picco_std_rel"], ) sorgente["sigma_punto_m"] = perturba_rel( sorgente["sigma_punto_m"], RANDOMIZZAZIONE["sigma_punto_std_rel"], ) sorgente["offset_y_percorso_m"] = rng.uniform( -RANDOMIZZAZIONE["offset_y_max_assoluto_m"], RANDOMIZZAZIONE["offset_y_max_assoluto_m"], ) aria["temperatura_ambiente_C"] += rng.gauss( 0.0, RANDOMIZZAZIONE["temperatura_ambiente_std_C"], ) sensore["rumore_std_C"] = perturba_rel( sensore["rumore_std_C"], RANDOMIZZAZIONE["rumore_sensore_std_rel"], fattore_min=0.0, ) return { "id_run": f"run_{indice_run:04d}", "fascetta": fascetta, "aria": aria, "sorgente": sorgente, "sensore": sensore, } def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict: fascetta = cfg_run["fascetta"] aria = cfg_run["aria"] sorgente = cfg_run["sorgente"] sensore = cfg_run["sensore"] nome_materiale = fascetta["materiale"] stato = prepara_stato_termico(fascetta, aria, sorgente, sensore) mesh = stato["mesh"] interpolatore = stato["interpolatore_sensore"] skin_depth = stato["skin_depth_m"] dt = stato["dt_s"] durata = SIMULAZIONE["durata_s"] periodo_campionamento = 1.0 / SIMULAZIONE["frequenza_campionamento_hz"] T = campo_iniziale(stato) T_letta = sens.temperatura_osservata_C(interpolatore, T) prossimo_campione_t = 0.0 T_vera_max = T_letta T_misurata_max = T_letta T_fascetta_max = float(T.max()) output_csv.parent.mkdir(parents=True, exist_ok=True) with output_csv.open("w", newline="") as f: writer = csv.writer(f) writer.writerow([ "id_run", "tempo_s", "x_sorgente_m", "offset_y_sorgente_m", "flusso_termico_sorgente_W_m2", "skin_depth_m", "T_vera_lato_sensore_C", "T_misurata_sensore_C", "T_max_fascetta_C", "T_media_fascetta_C", "T_ambiente_C", "velocita_m_s", "sigma_punto_m", "flusso_picco_W_m2", "materiale", ]) t = 0.0 while t <= durata + 1e-12: T, info = passo_termico(stato, T, t) # Temperatura vera della superficie interna nel punto osservato # dal sensore infrarosso. T_vera = sens.temperatura_osservata_C(interpolatore, T) T_letta = sens.aggiorna_inerzia_C( T_letta, T_vera, dt, sensore["costante_tempo_s"] ) # I massimi delle temperature vere sono aggiornati a ogni passo # interno, non solo agli istanti di campionamento: servono come # ground truth e il picco è stretto rispetto al periodo di # campionamento CSV, che da solo lo taglierebbe di oltre un grado. T_max = float(T.max()) T_vera_max = max(T_vera_max, T_vera) T_fascetta_max = max(T_fascetta_max, T_max) # Campionamento CSV. if t + 1e-12 >= prossimo_campione_t: misurata = T_letta + rng.gauss(0.0, sensore["rumore_std_C"]) misurata = sens.quantizza(misurata, sensore["quantizzazione_C"]) T_misurata_max = max(T_misurata_max, misurata) writer.writerow([ cfg_run["id_run"], f"{t:.6f}", f"{info['x_riferimento_m']:.9f}", f"{sorgente['offset_y_percorso_m']:.9f}", f"{info['flusso_al_sensore_W_m2']:.6f}", f"{skin_depth:.9e}", f"{T_vera:.6f}", f"{misurata:.6f}", f"{T_max:.6f}", f"{float(T.mean()):.6f}", f"{aria['temperatura_ambiente_C']:.6f}", f"{sorgente['velocita_m_s']:.9f}", f"{sorgente['sigma_punto_m']:.9f}", f"{sorgente['flusso_termico_picco_W_m2']:.6f}", nome_materiale, ]) prossimo_campione_t += periodo_campionamento t += dt return { "id_run": cfg_run["id_run"], "file_csv": str(output_csv.name), "materiale": nome_materiale, "diametro_medio_m": fascetta["diametro_medio_mm"] / 1000.0, "lunghezza_m": fascetta["lunghezza_mm"] / 1000.0, "spessore_m": fascetta["spessore_mm"] / 1000.0, "n_elementi_x": mesh["n_elementi_x"], "n_elementi_theta": mesh["n_elementi_theta"], "n_nodi": mesh["n_nodi"], "lato_elemento_x_m": mesh["lato_x_m"], "lato_elemento_arco_m": mesh["lato_arco_m"], "durata_s": durata, "frequenza_campionamento_hz": SIMULAZIONE["frequenza_campionamento_hz"], "dt_interno_s": dt, "temperatura_ambiente_C": aria["temperatura_ambiente_C"], "h_esterno_W_m2K": aria["h_esterno_W_m2K"], "h_interno_W_m2K": aria["h_interno_W_m2K"], "h_bordi_W_m2K": aria["h_bordi_W_m2K"], "x_inizio_m": sorgente["x_inizio_m"], "x_fine_m": sorgente["x_fine_m"], "x_sensore_m": stato["x_sensore_m"], "distanza_sensore_parete_m": sensore["distanza_parete_mm"] / 1000.0, "offset_y_percorso_m": sorgente["offset_y_percorso_m"], "theta_percorso_rad": src.theta_percorso_rad( sorgente, stato["raggio_medio_m"] ), "velocita_m_s": sorgente["velocita_m_s"], "numero_sorgenti": sorgente.get("numero_sorgenti", 1), "distanza_sorgenti_m": sorgente.get("distanza_sorgenti_m", 0.0), "sigma_punto_m": sorgente["sigma_punto_m"], "flusso_termico_picco_W_m2": sorgente["flusso_termico_picco_W_m2"], "efficienza_riscaldamento": sorgente["efficienza_riscaldamento"], "frequenza_hz": sorgente["frequenza_hz"], "skin_depth_m": skin_depth, "numero_fourier_spessore": stato["numero_fourier_spessore"], "costante_tempo_sensore_s": sensore["costante_tempo_s"], "rumore_std_sensore_C": sensore["rumore_std_C"], "quantizzazione_sensore_C": sensore["quantizzazione_C"], "T_vera_max_lato_sensore_C": T_vera_max, "T_misurata_max_sensore_C": T_misurata_max, "T_max_fascetta_C": T_fascetta_max, } def _esegui_run(indice_e_seme: tuple[int, int]) -> dict: # Ogni run riceve un seme indipendente derivato dal seed globale, così # l'esecuzione in parallelo resta riproducibile indipendentemente # dall'ordine in cui i processi la completano. indice, seme = indice_e_seme rng = random.Random(seme) cfg_run = configurazione_randomizzata(indice, rng) cartella_output = Path(SIMULAZIONE["cartella_output"]) percorso_csv = cartella_output / f"{cfg_run['id_run']}.csv" return simula_singolo(cfg_run, percorso_csv, rng) def main() -> None: cartella_output = Path(SIMULAZIONE["cartella_output"]) if cartella_output.exists(): shutil.rmtree(cartella_output) cartella_output.mkdir(parents=True, exist_ok=True) rng_semi = random.Random(SIMULAZIONE["seed"]) num_run = SIMULAZIONE["num_run"] semi = [rng_semi.randrange(2**63) for _ in range(num_run)] num_processi = SIMULAZIONE["num_processi"] or os.cpu_count() or 1 with ProcessPoolExecutor(max_workers=num_processi) as executor: righe_metadata = list( executor.map(_esegui_run, enumerate(semi, start=1)) ) percorso_metadata = cartella_output / "metadata.csv" with percorso_metadata.open("w", newline="") as f: writer = csv.DictWriter(f, fieldnames=list(righe_metadata[0].keys())) writer.writeheader() writer.writerows(righe_metadata) print(f"Generati {len(righe_metadata)} run in: {cartella_output.resolve()}") print(f"Metadata: {percorso_metadata.resolve()}") if __name__ == "__main__": main()