Files
simulatore-induttori/plot_animazione_fem.py
T
davideandClaude Opus 5 0f46f3d479 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>
2026-08-03 09:29:35 +02:00

171 lines
6.1 KiB
Python

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