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>
199 lines
7.6 KiB
Python
199 lines
7.6 KiB
Python
# Animazione 3D isometrica della fascetta con il campo di temperatura calcolato
|
|
# a elementi finiti sulla mesh shell (fem.py).
|
|
#
|
|
# La coordinata circonferenziale è una direzione risolta del modello FEM,
|
|
# quindi la temperatura dipinta su ogni elemento è quella effettivamente
|
|
# calcolata, senza ricostruzioni.
|
|
#
|
|
# 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.
|
|
|
|
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 USCITA
|
|
from fem import simula_campo_fem
|
|
from mesh import genera_mesh, riepilogo_mesh
|
|
|
|
# Istante del primo fotogramma mostrato: i primi istanti sono ancora uniformi
|
|
# alla temperatura ambiente.
|
|
T_INIZIO_ANIMAZIONE_S = 0.40
|
|
|
|
# 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))
|
|
|
|
print("Integrazione FEM in corso...")
|
|
dati = simula_campo_fem(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\n"
|
|
f"Skin depth (diagnostica): {1000.0 * dati['skin_depth_m']:.3f} mm"
|
|
)
|
|
|
|
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, 8.5))
|
|
griglia = fig.add_gridspec(2, 1, height_ratios=[2.6, 1.0], hspace=0.05)
|
|
ax = fig.add_subplot(griglia[0], projection="3d")
|
|
ax_storia = fig.add_subplot(griglia[1])
|
|
ax.view_init(elev=35.264, azim=45)
|
|
# zoom > 1 riempie il riquadro: con set_axis_off gli assi 3D lascerebbero
|
|
# altrimenti molto margine vuoto sopra il pannello della storia.
|
|
ax.set_box_aspect((lunghezza_m, 2 * raggio_m, 2 * raggio_m), zoom=1.12)
|
|
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([])
|
|
# La colorbar è agganciata a entrambi i pannelli per non restringere solo
|
|
# quello 3D: così i due riquadri restano della stessa larghezza.
|
|
barra = fig.colorbar(mappabile, ax=(ax, ax_storia), shrink=0.6, pad=0.02, aspect=30)
|
|
barra.set_label("T [°C]")
|
|
|
|
titolo = ax.set_title("")
|
|
|
|
# Pannello inferiore: storia della temperatura nel punto osservato dal
|
|
# sensore. T_vere è il valore nodale calcolato dal FEM, T_lette lo stesso
|
|
# segnale degradato dal sensore reale (inerzia, rumore, quantizzazione).
|
|
# A parte il rumore le due curve sono quasi sovrapposte: la prima è
|
|
# tracciata spessa e trasparente perché la seconda resti leggibile sopra
|
|
# di essa.
|
|
linea_vera, = ax_storia.plot(
|
|
[], [], color="tab:blue", linewidth=3.0, alpha=0.4,
|
|
label="T nodo del sensore (FEM)",
|
|
)
|
|
linea_letta, = ax_storia.plot(
|
|
[], [], color="tab:red", linewidth=1.0,
|
|
label="T misurata dal sensore (inerzia + rumore + quantizzazione)",
|
|
)
|
|
cursore = ax_storia.axvline(tempi[indice_inizio], color="gray", linewidth=0.8)
|
|
ax_storia.set_xlim(0.0, tempi[-1])
|
|
ax_storia.set_ylim(
|
|
T_ambiente - 0.05 * (T_max - T_ambiente),
|
|
max(dati["T_vere"].max(), dati["T_lette"].max()) * 1.08,
|
|
)
|
|
ax_storia.set_xlabel("Tempo [s]")
|
|
ax_storia.set_ylabel("T [°C]")
|
|
ax_storia.legend(loc="upper left")
|
|
ax_storia.grid(True, alpha=0.3)
|
|
|
|
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),
|
|
)
|
|
|
|
linea_vera.set_data(tempi[: k + 1], dati["T_vere"][: k + 1])
|
|
linea_letta.set_data(tempi[: k + 1], dati["T_lette"][: k + 1])
|
|
cursore.set_xdata([tempi[k], tempi[k]])
|
|
|
|
titolo.set_text(
|
|
f"FEM shell — t = {tempi[k]:.2f} s — "
|
|
f"T sensore = {dati['T_lette'][k]:.1f} °C"
|
|
)
|
|
return collezione, marker_sorgenti, linea_vera, linea_letta, cursore, titolo
|
|
|
|
animazione = FuncAnimation(
|
|
fig, disegna_frame, frames=indici_frame,
|
|
interval=INTERVALLO_RIPRODUZIONE_MS, blit=False,
|
|
)
|
|
|
|
if matplotlib.get_backend().lower() == "agg":
|
|
cartella = Path(USCITA["cartella"])
|
|
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()
|