Files
simulatore-induttori/plot_animazione_3d.py
T
2026-08-03 00:18:00 +02:00

141 lines
5.2 KiB
Python

# Animazione 3D del barattolo in vista isometrica: colormap della temperatura
# sulla superficie cilindrica.
#
# Il modello shell risolve la temperatura sull'intera superficie, quindi il
# campo disegnato qui è quello calcolato: non c'è più nessuna ricostruzione
# della distribuzione circonferenziale. La faccia esterna e quella interna
# hanno la stessa temperatura (una temperatura per nodo), quindi la superficie
# mostrata vale per entrambe.
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
import sorgente as src
from config import SIMULAZIONE
from mesh import chiudi_campo, coordinate_3d
from plot_animazione import DT_FRAME_S as DT_FRAME_S_BASE
from plot_animazione import T_INIZIO_ANIMAZIONE_S, simula_campi
from simulate import configurazione_randomizzata
# Tempo simulato tra un fotogramma e il successivo dell'animazione 3D. Più
# largo di DT_FRAME_S_BASE perché ricreare una superficie 3D a ogni
# fotogramma è più costoso della semplice imshow 2D.
DT_FRAME_S = 0.15
# Millisecondi tra i fotogrammi in riproduzione.
INTERVALLO_RIPRODUZIONE_MS = 60
# Disegna i bordi degli elementi shell sulla superficie del cilindro.
# Con la mesh di default (100 x 110, elementi di 1 x 2 mm) il reticolo satura
# il disegno e sbiadisce i colori: per ispezionare la mesh conviene attivarlo
# e insieme abbassare n_elementi_x e n_elementi_theta in config.py, per
# esempio a 25 x 28.
MOSTRA_MESH = False
def main() -> None:
rng = random.Random(SIMULAZIONE["seed"])
cfg_run = configurazione_randomizzata(1, rng)
dati = simula_campi(cfg_run)
tempi = dati["tempi"]
indice_inizio = int(np.searchsorted(tempi, T_INIZIO_ANIMAZIONE_S))
passo = max(1, round(DT_FRAME_S / DT_FRAME_S_BASE))
indici_frame = list(range(indice_inizio, len(tempi), passo))
mesh = dati["mesh"]
# simula_campi ruota le colonne per centrare il sensore nella mappa
# sviluppata: qui serve l'ordine originale della mesh.
rotazione = dati["rotazione"]
T_ambiente = dati["T_ambiente_C"]
sorgente = dati["sorgente"]
raggio_m = mesh["raggio_medio_m"]
lunghezza_m = mesh["lunghezza_m"]
Xm, Ym, Zm = coordinate_3d(mesh)
T_max = max(c.max() for c in dati["campi"])
# vmin più basso della temperatura ambiente reale: altrimenti le zone
# fredde cadrebbero sul nero puro di "inferno" e, essendo lo shading
# moltiplicativo, nessuna illuminazione basterebbe a renderle visibili.
norm = matplotlib.colors.Normalize(
vmin=T_ambiente - 0.4 * (T_max - T_ambiente), vmax=T_max
)
cmap = matplotlib.colormaps["inferno"]
lightsource = matplotlib.colors.LightSource(azdeg=315, altdeg=45)
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_xlabel("x [m]")
ax.set_axis_off()
# La colorbar mostra il range reale delle temperature: il norm esteso
# verso il basso serve solo a schiarire il colore di base della
# superficie fredda, non deve comparire nella scala mostrata all'utente.
norm_colorbar = matplotlib.colors.Normalize(vmin=T_ambiente, vmax=T_max)
mappabile = cm.ScalarMappable(cmap=cmap, norm=norm_colorbar)
mappabile.set_array([])
barra = fig.colorbar(mappabile, ax=ax, shrink=0.6, pad=0.05)
barra.set_label("T [°C]")
theta_sorgente = src.theta_percorso_rad(sorgente, raggio_m)
def disegna_frame(k: int):
ax.cla()
ax.view_init(elev=35.264, azim=45)
ax.set_box_aspect((lunghezza_m, 2 * raggio_m, 2 * raggio_m))
ax.set_axis_off()
campo = np.roll(dati["campi"][k], -rotazione, axis=1)
colori = cmap(norm(chiudi_campo(campo)))
# Ogni faccia disegnata è un elemento shell della mesh: con
# MOSTRA_MESH se ne vedono i bordi.
bordi = {"edgecolor": (1.0, 1.0, 1.0, 0.25), "linewidth": 0.15}
ax.plot_surface(
Xm, Ym, Zm, facecolors=colori, rstride=1, cstride=1,
antialiased=False, shade=True, lightsource=lightsource,
**(bordi if MOSTRA_MESH else {"linewidth": 0}),
)
x_sorgenti_m = src.posizioni_sorgenti_m(sorgente, dati["x_riferimenti"][k])
visibili = (x_sorgenti_m >= 0.0) & (x_sorgenti_m <= lunghezza_m)
if visibili.any():
ax.scatter(
x_sorgenti_m[visibili],
np.full(int(visibili.sum()), raggio_m * np.sin(theta_sorgente) * 1.05),
np.full(int(visibili.sum()), raggio_m * np.cos(theta_sorgente) * 1.05),
color="cyan", s=25, depthshade=False,
)
ax.set_title(f"t = {tempi[k]:.3f} s")
return ()
animazione = FuncAnimation(
fig, disegna_frame, frames=indici_frame,
interval=INTERVALLO_RIPRODUZIONE_MS, blit=False,
)
if matplotlib.get_backend().lower() == "agg":
percorso = Path("dataset") / "animazione_3d.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()