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:
@@ -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"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user