Rimuove il modello ai volumi finiti lasciando la sola analisi FEM

Il progetto conteneva due discretizzazioni indipendenti della stessa fisica:
i volumi finiti sulla sezione x-z di simulate.py (con generazione del dataset
CSV e le sue animazioni 2D/3D) e gli elementi finiti sulla mesh shell di
fem.py. Resta solo il secondo, che risolve la circonferenza invece di
collassarla in un'attenuazione gaussiana.

Eliminati simulate.py, plot_csv.py, plot_animazione.py e plot_animazione_3d.py.
Il FEM dipendeva da simulate.py per la cinematica del gruppo di sorgenti e per
la configurazione del run, quindi fem.py assorbe _x_riferimento_iniziale_m,
_x_riferimento_finale_m, _intervallo_attivo e configurazione_randomizzata; la
lettura del sensore (inerzia, rumore, quantizzazione) è ora applicata dentro
simula_campo_fem e la skin depth resta calcolata dalla frequenza dell'induttore
come sola grandezza diagnostica, dato che nel modello shell lo spessore è
collassato.

config.py perde i parametri che servivano solo ai volumi finiti (griglia
n_nodi_x/n_nodi_z, campionamento e cartella del dataset, numero di processi) e
guadagna FEM["durata_s"], FEM["seed"] e USCITA["cartella"]: le immagini e le
animazioni vanno in output/ invece che in dataset/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:44:58 +02:00
co-authored by Claude Opus 5
parent 6a55427b60
commit 1631d6c94e
13 changed files with 351 additions and 1252 deletions
+159 -25
View File
@@ -1,19 +1,17 @@
# Solutore termico transitorio a elementi finiti sulla mesh shell della
# fascetta (vedi mesh.py per la geometria).
#
# Differenza rispetto al modello ai volumi finiti di simulate.py:
# - lì il dominio è la sezione x-z (spessore risolto, circonferenza y
# collassata in un'attenuazione gaussiana del flusso e in un sink lineare
# di tipo aletta);
# - qui il dominio è la superficie media del cilindro, con x e la coordinata
# circonferenziale s = R·theta entrambe risolte, mentre lo spessore è
# collassato (temperatura uniforme attraverso la parete).
# Il dominio è la superficie media del cilindro, con x e la coordinata
# circonferenziale s = R·theta entrambe risolte, mentre lo spessore è
# collassato (temperatura uniforme attraverso la parete).
#
# Il collasso in spessore è lecito: con spessore 0.18 mm il numero di Biot
# h·t/k vale ~1e-7 e il tempo di diffusione attraverso la parete t²/alpha è
# di pochi millisecondi, molto più rapido del transito delle sorgenti. Di
# conseguenza la skin depth non entra nel modello shell: conta solo il flusso
# totale assorbito per unità di superficie.
# conseguenza la profondità di penetrazione del riscaldamento a induzione non
# entra nel modello: conta solo il flusso totale assorbito per unità di
# superficie. La skin depth è comunque calcolata dalla frequenza dell'induttore
# come grandezza diagnostica (`calcola_skin_depth_m`).
#
# Equazione risolta (per unità di superficie media):
#
@@ -31,21 +29,18 @@
# sistema è costante e viene fattorizzata LU una volta per run.
import math
import random
from copy import deepcopy
import numpy as np
import scipy.sparse as sp
from scipy.sparse.linalg import splu
from config import FEM
from config import ARIA, FASCETTA, FEM, RANDOMIZZAZIONE, SENSORE, SORGENTE
from materials import MATERIALI
from mesh import genera_mesh
# Helper della cinematica delle sorgenti condivisi con il modello ai volumi
# finiti: la corsa del gruppo di induttori deve essere identica nei due modelli.
from simulate import (
_intervallo_attivo,
_x_riferimento_finale_m,
_x_riferimento_iniziale_m,
)
MU0 = 4.0 * math.pi * 1e-7
# Matrici di riferimento dell'elemento rettangolare bilineare a 4 nodi, con
# nodi locali in ordine antiorario: (0,0), (a,0), (a,b), (0,b).
@@ -75,6 +70,122 @@ RIGIDEZZA_S = np.array(
) / 6.0
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.
#
# Nel modello shell è una grandezza diagnostica: serve a verificare che la
# deposizione del calore resti confinata entro uno spessore confrontabile
# con la parete, non entra nell'equazione risolta.
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 quantizza(valore: float, passo: float) -> float:
if passo <= 0.0:
return valore
return round(valore / passo) * passo
def configurazione_randomizzata(rng: random.Random | None = None) -> dict:
"""Copia dei dizionari di configurazione usati da un'analisi FEM.
Se `RANDOMIZZAZIONE["abilitata"]`, velocità, flusso di picco, sigma dello
spot, offset y del percorso, temperatura ambiente e rumore del sensore sono
perturbati con estrazioni dall'RNG passato: analisi diverse dello stesso
setup differiscono come differirebbero due passaggi reali.
"""
fascetta = deepcopy(FASCETTA)
aria = deepcopy(ARIA)
sorgente = deepcopy(SORGENTE)
sensore = deepcopy(SENSORE)
if RANDOMIZZAZIONE.get("abilitata", False):
if rng is None:
rng = random.Random(FEM["seed"])
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 {
"fascetta": fascetta,
"aria": aria,
"sorgente": sorgente,
"sensore": sensore,
}
def _spread_sorgenti_m(sorgente: dict) -> float:
# Distanza lungo x tra la prima e l'ultima sorgente del gruppo.
numero_sorgenti = sorgente.get("numero_sorgenti", 1)
distanza = sorgente.get("distanza_sorgenti_m", 0.0)
return (numero_sorgenti - 1) * distanza
def _x_riferimento_iniziale_m(sorgente: dict, x_sensore_m: float) -> float:
# Posizione a t=0 della sorgente di indice 0. Le sorgenti i sono a
# x_i = riferimento + i * distanza, quindi per v >= 0 l'indice 0 è la
# più arretrata nel verso di marcia, per v < 0 è la più avanzata.
# x_inizio_m è la distanza dal sensore della sorgente più avanzata
# (quella che lo raggiunge per prima).
spread = _spread_sorgenti_m(sorgente)
x_inizio = sorgente["x_inizio_m"]
if sorgente["velocita_m_s"] >= 0:
return (x_sensore_m - x_inizio) - spread
return x_sensore_m + x_inizio
def _x_riferimento_finale_m(sorgente: dict, x_sensore_m: float) -> float:
# Posizione di fine corsa della sorgente di indice 0. x_fine_m è la
# distanza dal sensore della sorgente più arretrata nel verso di marcia
# (quella che lo supera per ultima).
spread = _spread_sorgenti_m(sorgente)
x_fine = sorgente["x_fine_m"]
if sorgente["velocita_m_s"] >= 0:
return x_sensore_m + x_fine
return (x_sensore_m - x_fine) - spread
def _intervallo_attivo(inizio: float, fine: float, v: float, x_m: float) -> bool:
if v >= 0:
return inizio <= x_m <= fine
return fine <= x_m <= inizio
def _assembla(elementi: np.ndarray, matrice_elemento: np.ndarray, n_nodi: int):
# Assembla una matrice globale sparsa a partire da un'unica matrice di
# elemento 4×4, uguale per tutti gli elementi. I contributi ripetuti sullo
@@ -124,6 +235,10 @@ def prepara_stato_fem(
dt_s = FEM["dt_s"]
materiale = MATERIALI[fascetta["materiale"]]
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"])
k = materiale["conducibilita_termica_W_mK"]
rho = materiale["densita_kg_m3"]
cp = materiale["calore_specifico_J_kgK"]
@@ -201,6 +316,8 @@ def prepara_stato_fem(
"x_sensore_m": x_sensore_m,
"indice_sensore": indice_sensore,
"n_nodi": n_nodi,
# Diagnostica: non entra nell'equazione risolta.
"skin_depth_m": skin_depth_m,
}
@@ -265,16 +382,29 @@ def campo_iniziale(stato: dict) -> np.ndarray:
def simula_campo_fem(
cfg_run: dict,
durata_s: float,
dt_frame_s: float,
cfg: dict | None = None,
durata_s: float | None = None,
mesh_dati: dict | None = None,
rng: random.Random | None = None,
) -> dict:
"""Integra il campo FEM fino a durata_s, salvando un campo ogni dt_frame_s."""
fascetta = cfg_run["fascetta"]
aria = cfg_run["aria"]
sorgente = cfg_run["sorgente"]
sensore = cfg_run["sensore"]
"""Integra il campo FEM fino a durata_s, salvando un campo ogni dt_frame_s.
Il campo nodale è la temperatura vera della parete; la lettura del sensore
ne è la versione degradata: inerzia del primo ordine, rumore gaussiano e
quantizzazione, questi ultimi due applicati a ogni campionamento.
"""
if rng is None:
rng = random.Random(FEM["seed"])
if cfg is None:
cfg = configurazione_randomizzata(rng)
if durata_s is None:
durata_s = FEM["durata_s"]
fascetta = cfg["fascetta"]
aria = cfg["aria"]
sorgente = cfg["sorgente"]
sensore = cfg["sensore"]
stato = prepara_stato_fem(fascetta, aria, sorgente, sensore, mesh_dati=mesh_dati)
dt = stato["dt_s"]
@@ -296,11 +426,14 @@ def simula_campo_fem(
T_sensore += (T[indice_sensore] - T_sensore) * dt / tau_sensore
if t + 1e-12 >= prossimo_frame_t:
letta = T_sensore + rng.gauss(0.0, sensore["rumore_std_C"])
letta = quantizza(letta, sensore["quantizzazione_C"])
tempi.append(t)
campi.append(T.copy())
x_riferimenti.append(x_rif)
T_vere.append(float(T[indice_sensore]))
T_lette.append(T_sensore)
T_lette.append(letta)
prossimo_frame_t += dt_frame_s
t += dt
@@ -315,4 +448,5 @@ def simula_campo_fem(
"T_lette": np.array(T_lette),
"sorgente": sorgente,
"T_ambiente_C": stato["T_ambiente_C"],
"skin_depth_m": stato["skin_depth_m"],
}