Files
simulatore-induttori/simulate.py
T
2026-08-03 00:18:00 +02:00

387 lines
14 KiB
Python

# 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()