Files
simulatore-induttori/plot_animazione_3d.py
T

141 lines
5.2 KiB
Python
Raw Normal View History

# Animazione 3D del barattolo in vista isometrica: colormap della temperatura
2026-08-03 00:18:00 +02:00
# sulla superficie cilindrica.
#
2026-08-03 00:18:00 +02:00
# 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
2026-08-03 00:18:00 +02:00
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
2026-08-03 00:18:00 +02:00
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
2026-08-03 00:18:00 +02:00
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
2026-08-03 00:18:00 +02:00
# 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))
2026-08-03 00:18:00 +02:00
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"]
2026-08-03 00:18:00 +02:00
T_ambiente = dati["T_ambiente_C"]
sorgente = dati["sorgente"]
raggio_m = mesh["raggio_medio_m"]
lunghezza_m = mesh["lunghezza_m"]
2026-08-03 00:18:00 +02:00
Xm, Ym, Zm = coordinate_3d(mesh)
2026-08-03 00:18:00 +02:00
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]")
2026-08-03 00:18:00 +02:00
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()
2026-08-03 00:18:00 +02:00
campo = np.roll(dati["campi"][k], -rotazione, axis=1)
colori = cmap(norm(chiudi_campo(campo)))
2026-08-03 00:18:00 +02:00
# 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,
2026-08-03 00:18:00 +02:00
antialiased=False, shade=True, lightsource=lightsource,
**(bordi if MOSTRA_MESH else {"linewidth": 0}),
)
2026-08-03 00:18:00 +02:00
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()