Aggiunge il solutore termico FEM sulla mesh shell e la sua animazione 3D

fem.py risolve l'equazione del calore transitoria sulla superficie media del
cilindro con elementi shell quadrangolari bilineari: la circonferenza diventa
una direzione risolta (niente più attenuazione gaussiana né sink di aletta),
mentre lo spessore è collassato perché con parete da 0.18 mm il numero di
Biot è ~1e-7. Eulero implicito con matrice costante fattorizzata una volta
per run; le matrici di elemento sono identiche per tutti gli elementi e
l'assemblaggio è vettorizzato.

plot_animazione_fem.py dipinge il campo calcolato direttamente sugli elementi
della mesh, con ombreggiatura ricavata dalla normale radiale perché i colori
delle facce cambiano a ogni fotogramma.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 09:29:35 +02:00
co-authored by Claude Opus 5
parent 14226234bd
commit 0f46f3d479
5 changed files with 515 additions and 2 deletions
+19 -2
View File
@@ -24,6 +24,7 @@ python main.py simula # genera dataset/run_XXXX.csv + dataset/metadata.csv
python main.py grafico # grafici del primo run
python main.py anima # animazione 2D della sezione
python main.py anima3d # animazione 3D isometrica del barattolo
python main.py fem # animazione 3D del campo FEM sulla mesh shell
```
Ogni modulo resta eseguibile anche direttamente (`python simulate.py`, `python plot_csv.py`, ...).
@@ -45,10 +46,26 @@ Generatore di dataset per misurazioni termiche pseudo-realistiche di una fascett
2. `materials.py` — dizionario MATERIALI con proprietà termofisiche ed elettriche per materiale
3. `simulate.py` — motore principale: genera N run randomizzati, scrive i CSV, scrive `metadata.csv`
4. `plot_csv.py` — visualizzazione autonoma per un singolo run
5. `mesh.py` — mesh a elementi shell quadrangolari della superficie media del cilindro (base per la futura analisi FEM), visualizzata da `plot_mesh.py`
5. `mesh.py` — mesh a elementi shell quadrangolari della superficie media del cilindro, visualizzata da `plot_mesh.py`
6. `fem.py` — solutore termico transitorio a elementi finiti sulla mesh shell, animato da `plot_animazione_fem.py`
**Mesh shell (`mesh.py`):** griglia strutturata `n_elementi_x × n_elementi_circonferenza` di quadrilateri a 4 nodi sulla superficie media (raggio = (diametro spessore)/2); lo spessore non è discretizzato, è un attributo della shell. La mesh è chiusa lungo la circonferenza (nessun nodo duplicato sulla cucitura) e usa lo stesso sistema di coordinate globale di `plot_animazione_3d.py`: x = asse, y = R·sin(theta), z = R·cos(theta), con theta = 0 sul piano del sensore.
## Due modelli termici distinti
Il progetto contiene due discretizzazioni indipendenti della stessa fisica; scambiano solo la cinematica delle sorgenti (`_x_riferimento_iniziale_m`, `_x_riferimento_finale_m`, `_intervallo_attivo` in [simulate.py](simulate.py)), che deve restare unica.
| | volumi finiti ([simulate.py](simulate.py)) | elementi finiti ([fem.py](fem.py)) |
|---|---|---|
| dominio | sezione x-z (spessore risolto) | superficie media x-s (circonferenza risolta) |
| circonferenza y | attenuazione gaussiana + sink di aletta | direzione risolta, conduzione reale |
| spessore | `n_nodi_z` celle, deposizione esponenziale da skin depth | collassato: T uniforme nella parete |
| griglia | `FASCETTA["n_nodi_x"]`, `n_nodi_z` | `MESH["n_elementi_x"]`, `n_elementi_circonferenza` |
| passo temporale | `SIMULAZIONE["dt_interno_s"]` | `FEM["dt_s"]` |
| output | dataset CSV + animazioni 2D/3D | animazione 3D sulla mesh |
**Pipeline FEM dentro `fem.py`:** equazione risolta per unità di superficie media, `rho·cp·t ∂T/∂t = k·t ∇²T + q(x, s, t) (h_est + h_int)(T T_amb)`, con convezione `h_bordi` sui due bordi anulari (area = spessore × perimetro). Il collasso in spessore è lecito perché Biot ≈ 1e-7 e il tempo di diffusione nella parete è di pochi ms: la skin depth non entra nel modello shell, conta solo il flusso assorbito. L'impronta della sorgente è una gaussiana isotropa in (x, s) con distanza circonferenziale valutata sull'immagine più vicina (superficie chiusa). Gli elementi sono bilineari a 4 nodi e tutti identici (rettangoli dx × ds), quindi le matrici di elemento (`MASSA_RIF`, `RIGIDEZZA_X`, `RIGIDEZZA_S`) sono calcolate una volta e assemblate in forma vettorizzata; la matrice di massa di superficie è riusata per capacità, convezione e carico della sorgente. Eulero implicito con matrice costante fattorizzata LU una volta per run (`prepara_stato_fem`), poi `passo_implicito_fem` assembla solo il termine noto.
**Pipeline fisica dentro `simula_singolo()` in [simulate.py](simulate.py):**
- La skin depth è calcolata dalla resistività elettrica del materiale e dalla frequenza di induzione (`calcola_skin_depth_m`)
@@ -71,7 +88,7 @@ Ogni parametro in [config.py](config.py) ha un commento che spiega solo cos'è (
## Vincoli progettuali chiave
- Il modello è 2D nella sezione (x = lunghezza, z = spessore). La coordinata circonferenziale y non è risolta spazialmente — l'offset y del percorso delle sorgenti è collassato in un'attenuazione gaussiana del flusso, e la conduzione lungo y è un termine di scambio lineare verso la temperatura ambiente; il diametro è registrato solo come geometria del setup.
- Il modello ai volumi finiti di `simulate.py` è 2D nella sezione (x = lunghezza, z = spessore). La coordinata circonferenziale y non è risolta spazialmente — l'offset y del percorso delle sorgenti è collassato in un'attenuazione gaussiana del flusso, e la conduzione lungo y è un termine di scambio lineare verso la temperatura ambiente; il diametro è registrato solo come geometria del setup. Il modello FEM di `fem.py` fa il contrario (circonferenza risolta, spessore collassato): non modificare uno assumendo che valgano le ipotesi dell'altro.
- Le posizioni di inizio/fine corsa delle sorgenti (`x_inizio_m`, `x_fine_m`) sono distanze dal punto x del sensore lungo il verso di marcia; il segno di `velocita_m_s` determina il verso (negativo = -x).
- La matrice implicita è costruita e fattorizzata una volta per run (proprietà del materiale costanti, nessun coefficiente dipendente dalla temperatura). Se si aggiungono proprietà dipendenti dalla temperatura, la matrice deve essere ricostruita e rifattorizzata ad ogni passo temporale.
- `simulate.py` cancella e ricrea l'intera cartella di output ad ogni esecuzione (`shutil.rmtree`).
+5
View File
@@ -89,6 +89,11 @@ MESH = {
"n_elementi_circonferenza": 48,
}
FEM = {
# Passo di integrazione temporale del solutore a elementi finiti.
"dt_s": 0.001,
}
ARIA = {
# Temperatura dell'aria ambiente.
"temperatura_ambiente_C": 25.0,
+313
View File
@@ -0,0 +1,313 @@
# 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 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.
#
# Equazione risolta (per unità di superficie media):
#
# rho·cp·t ∂T/∂t = k·t ∇²T + q(x, s, t) - (h_est + h_int)·(T - T_amb)
#
# con convezione aggiuntiva sui due bordi anulari x = 0 e x = L (coefficiente
# h_bordi su un'area pari a spessore × perimetro). L'impronta della sorgente
# è una gaussiana isotropa nel piano (x, s), con la distanza circonferenziale
# valutata sull'immagine più vicina perché la superficie è chiusa.
#
# Discretizzazione: elementi shell quadrangolari bilineari a 4 nodi. Gli
# elementi della mesh sono tutti rettangoli identici dx × ds, quindi le
# matrici di elemento sono calcolate una volta sola e assemblate in forma
# vettorizzata. Integrazione temporale con Eulero implicito: la matrice di
# sistema è costante e viene fattorizzata LU una volta per run.
import math
import numpy as np
import scipy.sparse as sp
from scipy.sparse.linalg import splu
from config import FEM
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,
)
# Matrici di riferimento dell'elemento rettangolare bilineare a 4 nodi, con
# nodi locali in ordine antiorario: (0,0), (a,0), (a,b), (0,b).
#
# MASSA_RIF va moltiplicata per l'area a·b e dà l'integrale di N_i·N_j;
# RIGIDEZZA_X per b/a e RIGIDEZZA_S per a/b, e la loro somma dà l'integrale
# di grad(N_i)·grad(N_j).
MASSA_RIF = np.array(
[[4.0, 2.0, 1.0, 2.0],
[2.0, 4.0, 2.0, 1.0],
[1.0, 2.0, 4.0, 2.0],
[2.0, 1.0, 2.0, 4.0]]
) / 36.0
RIGIDEZZA_X = np.array(
[[2.0, -2.0, -1.0, 1.0],
[-2.0, 2.0, 1.0, -1.0],
[-1.0, 1.0, 2.0, -2.0],
[1.0, -1.0, -2.0, 2.0]]
) / 6.0
RIGIDEZZA_S = np.array(
[[2.0, 1.0, -1.0, -2.0],
[1.0, 2.0, -2.0, -1.0],
[-1.0, -2.0, 2.0, 1.0],
[-2.0, -1.0, 1.0, 2.0]]
) / 6.0
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
# stesso (riga, colonna) vengono sommati dal formato COO.
n_elementi = len(elementi)
righe = np.broadcast_to(elementi[:, :, None], (n_elementi, 4, 4)).ravel()
colonne = np.broadcast_to(elementi[:, None, :], (n_elementi, 4, 4)).ravel()
valori = np.broadcast_to(matrice_elemento, (n_elementi, 4, 4)).ravel()
return sp.coo_matrix(
(valori, (righe, colonne)), shape=(n_nodi, n_nodi)
).tocsr()
def _massa_anello(indici: np.ndarray, lunghezza_segmento_m: float, n_nodi: int):
# Matrice di massa 1D su un anello chiuso di nodi (integrale di N_i·N_j
# lungo la circonferenza), usata per la convezione sui bordi x = 0 e x = L.
n = len(indici)
successivi = indici[(np.arange(n) + 1) % n]
coefficiente = lunghezza_segmento_m / 6.0
righe = np.concatenate([indici, indici, successivi, successivi])
colonne = np.concatenate([indici, successivi, indici, successivi])
valori = coefficiente * np.concatenate(
[np.full(n, 2.0), np.full(n, 1.0), np.full(n, 1.0), np.full(n, 2.0)]
)
return sp.coo_matrix(
(valori, (righe, colonne)), shape=(n_nodi, n_nodi)
).tocsr()
def prepara_stato_fem(
fascetta: dict,
aria: dict,
sensore: dict,
mesh_dati: dict | None = None,
dt_s: float | None = None,
) -> dict:
"""Assembla le matrici FEM e fattorizza il sistema implicito.
Restituisce lo stato costante per l'integrazione temporale: matrici,
solutore LU, coordinate nodali e indice del nodo osservato dal sensore.
"""
if mesh_dati is None:
mesh_dati = genera_mesh(fascetta)
if dt_s is None:
dt_s = FEM["dt_s"]
materiale = MATERIALI[fascetta["materiale"]]
k = materiale["conducibilita_termica_W_mK"]
rho = materiale["densita_kg_m3"]
cp = materiale["calore_specifico_J_kgK"]
spessore_m = mesh_dati["spessore_m"]
raggio_m = mesh_dati["raggio_m"]
circonferenza_m = 2.0 * math.pi * raggio_m
n_x = mesh_dati["n_elementi_x"]
n_theta = mesh_dati["n_elementi_circonferenza"]
elementi = mesh_dati["elementi"]
indice_nodo = mesh_dati["indice_nodo"]
n_nodi = len(mesh_dati["nodi"])
dx_m = mesh_dati["lunghezza_m"] / n_x
ds_m = circonferenza_m / n_theta
h_esterno = aria["h_esterno_W_m2K"]
h_interno = aria["h_interno_W_m2K"]
h_bordi = aria["h_bordi_W_m2K"]
T_ambiente = aria["temperatura_ambiente_C"]
# Matrice di massa di superficie: riusata per la capacità termica, per la
# convezione sulle due facce e per il carico della sorgente.
M_area = _assembla(elementi, MASSA_RIF * (dx_m * ds_m), n_nodi)
K_cond = _assembla(
elementi,
k * spessore_m * (RIGIDEZZA_X * (ds_m / dx_m) + RIGIDEZZA_S * (dx_m / ds_m)),
n_nodi,
)
# Convezione sui bordi anulari: l'area di scambio di ogni segmento è
# spessore × lunghezza del segmento circonferenziale.
H_bordi = h_bordi * spessore_m * (
_massa_anello(indice_nodo[0, :], ds_m, n_nodi)
+ _massa_anello(indice_nodo[-1, :], ds_m, n_nodi)
)
C_su_dt = (rho * cp * spessore_m / dt_s) * M_area
A = C_su_dt + K_cond + (h_esterno + h_interno) * M_area + H_bordi
# Termine noto costante della convezione: (somma delle righe) × h × T_amb,
# perché la somma delle righe della matrice di massa è l'integrale di N_i.
area_nodale = np.asarray(M_area.sum(axis=1)).ravel()
bordo_nodale = np.asarray(H_bordi.sum(axis=1)).ravel()
carico_convezione = (h_esterno + h_interno) * area_nodale * T_ambiente
carico_convezione += bordo_nodale * T_ambiente
# Coordinate nodali nel piano parametrico (x, s) della superficie media.
x_nodi_m = np.repeat(mesh_dati["x_nodi_m"], n_theta)
s_nodi_m = np.tile(raggio_m * mesh_dati["theta_nodi_rad"], n_x + 1)
# Nodo osservato dal sensore: x più vicino a x_mm sul piano theta = 0.
x_sensore_m = sensore["x_mm"] / 1000.0
i_sensore = int(np.argmin(np.abs(mesh_dati["x_nodi_m"] - x_sensore_m)))
indice_sensore = int(indice_nodo[i_sensore, 0])
return {
"mesh": mesh_dati,
"dt_s": dt_s,
"M_area": M_area,
"C_su_dt": C_su_dt,
"carico_convezione": carico_convezione,
"solutore": splu(sp.csc_matrix(A)),
"x_nodi_m": x_nodi_m,
"s_nodi_m": s_nodi_m,
"circonferenza_m": circonferenza_m,
"area_nodale_m2": area_nodale,
"T_ambiente_C": T_ambiente,
"x_sensore_m": x_sensore_m,
"indice_sensore": indice_sensore,
"n_nodi": n_nodi,
}
def flusso_nodale_W_m2(
sorgente: dict,
stato: dict,
t_s: float,
) -> tuple[float, np.ndarray]:
"""Flusso termico assorbito nei nodi all'istante t_s.
Restituisce (x_riferimento_m, q_nodale) dove x_riferimento_m è la
posizione della sorgente di indice 0. Ogni sorgente attiva contribuisce
con un'impronta gaussiana isotropa in (x, s); la distanza circonferenziale
è valutata sull'immagine più vicina, perché la superficie è chiusa.
"""
x_sensore_m = stato["x_sensore_m"]
x_rif_iniziale = _x_riferimento_iniziale_m(sorgente, x_sensore_m)
x_rif_finale = _x_riferimento_finale_m(sorgente, x_sensore_m)
x_riferimento = x_rif_iniziale + sorgente["velocita_m_s"] * t_s
numero_sorgenti = sorgente.get("numero_sorgenti", 1)
distanza = sorgente.get("distanza_sorgenti_m", 0.0)
v = sorgente["velocita_m_s"]
zero_dopo_fine = sorgente.get("zero_dopo_fine", True)
sigma = sorgente["sigma_punto_m"]
q_picco = sorgente["flusso_termico_picco_W_m2"] * sorgente["efficienza_riscaldamento"]
circonferenza = stato["circonferenza_m"]
ds = stato["s_nodi_m"] - sorgente["offset_y_percorso_m"]
ds -= circonferenza * np.round(ds / circonferenza)
peso_circonferenziale = np.exp(-0.5 * (ds * ds) / (sigma * sigma))
q_nodale = np.zeros(stato["n_nodi"])
for i in range(numero_sorgenti):
x_i = x_riferimento + i * distanza
if zero_dopo_fine:
inizio_i = x_rif_iniziale + i * distanza
fine_i = x_rif_finale + i * distanza
if not _intervallo_attivo(inizio_i, fine_i, v, x_i):
continue
dx = stato["x_nodi_m"] - x_i
q_nodale += (
q_picco
* peso_circonferenziale
* np.exp(-0.5 * (dx * dx) / (sigma * sigma))
)
return x_riferimento, q_nodale
def passo_implicito_fem(stato: dict, T: np.ndarray, q_nodale: np.ndarray) -> np.ndarray:
# Avanza il campo nodale di un passo dt: termine noto = capacità sul campo
# precedente + carico della sorgente + carico di convezione, poi risoluzione
# del sistema già fattorizzato.
rhs = stato["C_su_dt"] @ T
rhs += stato["M_area"] @ q_nodale
rhs += stato["carico_convezione"]
return stato["solutore"].solve(rhs)
def campo_iniziale(stato: dict) -> np.ndarray:
return np.full(stato["n_nodi"], stato["T_ambiente_C"], dtype=float)
def simula_campo_fem(
cfg_run: dict,
durata_s: float,
dt_frame_s: float,
mesh_dati: dict | 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"]
stato = prepara_stato_fem(fascetta, aria, sensore, mesh_dati=mesh_dati)
dt = stato["dt_s"]
indice_sensore = stato["indice_sensore"]
T = campo_iniziale(stato)
T_sensore = float(T[indice_sensore])
tau_sensore = max(sensore["costante_tempo_s"], 1e-9)
tempi, campi, x_riferimenti = [], [], []
T_vere, T_lette = [], []
prossimo_frame_t = 0.0
t = 0.0
while t <= durata_s + 1e-12:
x_rif, q_nodale = flusso_nodale_W_m2(sorgente, stato, t)
T = passo_implicito_fem(stato, T, q_nodale)
T_sensore += (T[indice_sensore] - T_sensore) * dt / tau_sensore
if t + 1e-12 >= prossimo_frame_t:
tempi.append(t)
campi.append(T.copy())
x_riferimenti.append(x_rif)
T_vere.append(float(T[indice_sensore]))
T_lette.append(T_sensore)
prossimo_frame_t += dt_frame_s
t += dt
return {
"stato": stato,
"mesh": stato["mesh"],
"tempi": np.array(tempi),
"campi": campi,
"x_riferimenti": np.array(x_riferimenti),
"T_vere": np.array(T_vere),
"T_lette": np.array(T_lette),
"sorgente": sorgente,
"T_ambiente_C": stato["T_ambiente_C"],
}
+8
View File
@@ -6,6 +6,7 @@
# python main.py grafico # grafici del primo run generato
# python main.py anima # animazione 2D della sezione
# python main.py anima3d # animazione 3D isometrica del barattolo
# python main.py fem # animazione 3D del campo FEM sulla mesh shell
#
# Senza argomenti stampa l'elenco delle azioni disponibili.
@@ -43,6 +44,12 @@ def _azione_anima3d() -> None:
plot_animazione_3d.main()
def _azione_fem() -> None:
import plot_animazione_fem
plot_animazione_fem.main()
# Chiave da riga di comando -> (funzione, descrizione mostrata nell'help).
AZIONI = {
"mesh": (_azione_mesh, "Disegna la mesh a elementi shell della fascetta"),
@@ -50,6 +57,7 @@ AZIONI = {
"grafico": (_azione_grafico, "Grafici temperatura e flusso del primo run"),
"anima": (_azione_anima, "Animazione 2D del campo di temperatura nella sezione"),
"anima3d": (_azione_anima3d, "Animazione 3D isometrica del barattolo"),
"fem": (_azione_fem, "Animazione 3D del campo FEM sulla mesh a elementi shell"),
}
+170
View File
@@ -0,0 +1,170 @@
# Animazione 3D isometrica del barattolo con il campo di temperatura calcolato
# a elementi finiti sulla mesh shell (fem.py).
#
# È l'equivalente di plot_animazione_3d.py ma senza ricostruzioni: lì il campo
# circonferenziale era estruso riusando la gaussiana della sorgente, qui la
# coordinata circonferenziale è una direzione risolta del modello FEM, quindi
# la temperatura dipinta su ogni elemento è quella effettivamente calcolata.
#
# Ogni elemento shell è disegnato come faccia piana con colore pari alla media
# dei suoi quattro valori nodali. L'ombreggiatura è calcolata a mano dalla
# normale radiale dell'elemento: Poly3DCollection ombreggia solo alla
# creazione, mentre qui i colori delle facce cambiano a ogni fotogramma.
import random
from pathlib import Path
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from matplotlib import cm
from matplotlib.animation import FuncAnimation, PillowWriter
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
from config import SIMULAZIONE
from fem import simula_campo_fem
from mesh import genera_mesh, riepilogo_mesh
from plot_animazione import T_FINE_ANIMAZIONE_S, T_INIZIO_ANIMAZIONE_S
from simulate import configurazione_randomizzata
# Tempo simulato tra un fotogramma e il successivo.
DT_FRAME_S = 0.15
# Millisecondi tra i fotogrammi in riproduzione.
INTERVALLO_RIPRODUZIONE_MS = 60
# Direzione della luce (x = asse del cilindro, y-z = piano della sezione) e
# quota di luce ambiente, per dare volume alla superficie.
DIREZIONE_LUCE = np.array([-0.25, 0.45, 0.85])
LUCE_AMBIENTE = 0.45
# Se True disegna il reticolo degli elementi sopra il campo. Con la mesh fitta
# i bordi degli elementi sul lato nascosto rendono la superficie confusa,
# quindi per default i bordi prendono il colore della faccia: così le facce
# adiacenti combaciano senza lasciare fessure di antialiasing.
MOSTRA_BORDI_ELEMENTI = False
def _illuminazione(mesh_dati: dict) -> np.ndarray:
# Fattore moltiplicativo di luminosità per elemento, dalla normale
# radiale uscente valutata nel baricentro dell'elemento.
facce = mesh_dati["nodi"][mesh_dati["elementi"]]
baricentri = facce.mean(axis=1)
normali = baricentri.copy()
# La normale della superficie cilindrica è radiale: nessuna componente
# lungo l'asse x.
normali[:, 0] = 0.0
normali /= np.linalg.norm(normali, axis=1, keepdims=True)
luce = DIREZIONE_LUCE / np.linalg.norm(DIREZIONE_LUCE)
diffusa = np.clip(normali @ luce, 0.0, None)
return LUCE_AMBIENTE + (1.0 - LUCE_AMBIENTE) * diffusa
def main() -> None:
mesh_dati = genera_mesh()
print(riepilogo_mesh(mesh_dati))
rng = random.Random(SIMULAZIONE["seed"])
cfg_run = configurazione_randomizzata(1, rng)
print("Integrazione FEM in corso...")
dati = simula_campo_fem(
cfg_run,
durata_s=T_FINE_ANIMAZIONE_S,
dt_frame_s=DT_FRAME_S,
mesh_dati=mesh_dati,
)
tempi = dati["tempi"]
indice_inizio = int(np.searchsorted(tempi, T_INIZIO_ANIMAZIONE_S))
indici_frame = list(range(indice_inizio, len(tempi)))
T_ambiente = dati["T_ambiente_C"]
T_max = max(campo.max() for campo in dati["campi"])
print(
f"T massima nodale: {T_max:.1f} °C — "
f"T massima nel punto del sensore: {dati['T_vere'].max():.1f} °C"
)
elementi = mesh_dati["elementi"]
facce = mesh_dati["nodi"][elementi]
illuminazione = _illuminazione(mesh_dati)[:, None]
raggio_m = mesh_dati["raggio_m"]
lunghezza_m = mesh_dati["lunghezza_m"]
sorgente = dati["sorgente"]
numero_sorgenti = sorgente.get("numero_sorgenti", 1)
distanza_m = sorgente.get("distanza_sorgenti_m", 0.0)
theta_sorgente = sorgente["offset_y_percorso_m"] / raggio_m
norm = matplotlib.colors.Normalize(vmin=T_ambiente, vmax=T_max)
cmap = matplotlib.colormaps["inferno"]
fig = plt.figure(figsize=(9, 7))
ax = fig.add_subplot(projection="3d")
ax.view_init(elev=35.264, azim=45)
ax.set_box_aspect((lunghezza_m, 2 * raggio_m, 2 * raggio_m))
ax.set_axis_off()
ax.set_xlim(0.0, lunghezza_m)
ax.set_ylim(-raggio_m, raggio_m)
ax.set_zlim(-raggio_m, raggio_m)
collezione = Poly3DCollection(facce, linewidths=0.25, shade=False)
ax.add_collection3d(collezione)
marker_sorgenti = ax.plot(
[], [], [], "o", color="cyan", markersize=6, zorder=10
)[0]
mappabile = cm.ScalarMappable(cmap=cmap, norm=norm)
mappabile.set_array([])
barra = fig.colorbar(mappabile, ax=ax, shrink=0.6, pad=0.05)
barra.set_label("T [°C]")
titolo = ax.set_title("")
def disegna_frame(k: int):
T_elementi = dati["campi"][k][elementi].mean(axis=1)
colori = cmap(norm(T_elementi))
# Ombreggiatura moltiplicativa sui soli canali RGB.
colori[:, :3] *= illuminazione
collezione.set_facecolor(colori)
collezione.set_edgecolor("#40404060" if MOSTRA_BORDI_ELEMENTI else colori)
x_sorgenti_m = dati["x_riferimenti"][k] + np.arange(numero_sorgenti) * distanza_m
visibili = (x_sorgenti_m >= 0.0) & (x_sorgenti_m <= lunghezza_m)
n_visibili = int(visibili.sum())
marker_sorgenti.set_data_3d(
x_sorgenti_m[visibili],
np.full(n_visibili, raggio_m * np.sin(theta_sorgente) * 1.06),
np.full(n_visibili, raggio_m * np.cos(theta_sorgente) * 1.06),
)
titolo.set_text(
f"FEM shell — t = {tempi[k]:.2f} s — "
f"T sensore = {dati['T_lette'][k]:.1f} °C"
)
return collezione, marker_sorgenti, titolo
animazione = FuncAnimation(
fig, disegna_frame, frames=indici_frame,
interval=INTERVALLO_RIPRODUZIONE_MS, blit=False,
)
if matplotlib.get_backend().lower() == "agg":
cartella = Path("dataset")
cartella.mkdir(parents=True, exist_ok=True)
percorso = cartella / "animazione_fem.gif"
animazione.save(
percorso, writer=PillowWriter(fps=1000 // INTERVALLO_RIPRODUZIONE_MS)
)
print(f"Backend non interattivo: animazione salvata in {percorso}")
return
plt.show()
if __name__ == "__main__":
main()