# Animazione della superficie della fascetta durante il passaggio delle # sorgenti. # # Riproduce la fisica di run_0001 (stesso seed di simulate.py) e mostra: # - il profilo di flusso termico q(x) lungo la generatrice delle sorgenti; # - il campo di temperatura sulla superficie cilindrica sviluppata in piano, # cioè T(x, arco) con l'arco centrato sulla generatrice del sensore; # - il sensore infrarosso e la temperatura nel punto osservato. # # La mappa sviluppata è periodica in verticale: il bordo superiore e quello # inferiore sono lo stesso punto della fascetta. import random from pathlib import Path import matplotlib import matplotlib.pyplot as plt import numpy as np from matplotlib.animation import FuncAnimation, PillowWriter from matplotlib.collections import LineCollection import sensore as sens import sorgente as src from config import SIMULAZIONE from mesh import campo_su_griglia, chiudi_campo from simulate import ( campo_iniziale, configurazione_randomizzata, passo_termico, prepara_stato_termico, ) # Istante di inizio dei fotogrammi mostrati (la simulazione parte comunque da 0). T_INIZIO_ANIMAZIONE_S = 0.40 # Istante di fine dell'animazione. T_FINE_ANIMAZIONE_S = 30 # Tempo simulato tra un fotogramma e il successivo. DT_FRAME_S = 0.05 # Millisecondi tra i fotogrammi in riproduzione. INTERVALLO_RIPRODUZIONE_MS = 30 # Disegna i bordi degli elementi shell sopra il campo di temperatura. # Con la mesh di default (100 x 110, elementi di 1 x 2 mm) il reticolo è più # fitto dei pixel disponibili e copre il campo: 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 simula_campi(cfg_run: dict) -> dict: # Esegue la simulazione fino a T_FINE_ANIMAZIONE_S salvando, a ogni # fotogramma, campo di temperatura, profilo di flusso e stato del sensore. # # I campi sono restituiti come griglie (n_nodi_x, n_elementi_theta) con la # colonna dell'arco ruotata in modo da portare la generatrice del sensore # (theta = 0) al centro: è solo una scelta di visualizzazione. fascetta = cfg_run["fascetta"] aria = cfg_run["aria"] sorgente = cfg_run["sorgente"] sensore = cfg_run["sensore"] stato = prepara_stato_termico(fascetta, aria, sorgente, sensore) mesh = stato["mesh"] dt = stato["dt_s"] interpolatore = stato["interpolatore_sensore"] # Dopo np.roll(..., rotazione) la colonna theta = 0 finisce all'indice # "rotazione", quindi l'arco con segno della colonna m vale # (m - rotazione) * lato_arco. rotazione = mesh["n_elementi_theta"] // 2 arco_centrato_m = ( np.arange(mesh["n_elementi_theta"]) - rotazione ) * mesh["lato_arco_m"] T = campo_iniziale(stato) T_letta = sens.temperatura_osservata_C(interpolatore, T) tempi, campi, flussi, x_riferimenti = [], [], [], [] T_vere, T_lette = [], [] prossimo_frame_t = 0.0 t = 0.0 while t <= T_FINE_ANIMAZIONE_S + 1e-12: T, info = passo_termico(stato, T, t) T_vera = sens.temperatura_osservata_C(interpolatore, T) T_letta = sens.aggiorna_inerzia_C( T_letta, T_vera, dt, sensore["costante_tempo_s"] ) if t + 1e-12 >= prossimo_frame_t: _, q_nodi = src.profilo_flusso_x_W_m2( sorgente, stato["x_sensore_m"], t, mesh["x_nodi_m"] ) tempi.append(t) campi.append(np.roll(campo_su_griglia(mesh, T), rotazione, axis=1)) flussi.append(q_nodi) x_riferimenti.append(info["x_riferimento_m"]) T_vere.append(T_vera) T_lette.append(T_letta) prossimo_frame_t += DT_FRAME_S t += dt return { "tempi": np.array(tempi), "campi": campi, "flussi": flussi, "x_riferimenti": np.array(x_riferimenti), "T_vere": np.array(T_vere), "T_lette": np.array(T_lette), "mesh": mesh, "rotazione": rotazione, "x_nodi_mm": mesh["x_nodi_m"] * 1000.0, "arco_centrato_mm": arco_centrato_m * 1000.0, "arco_chiuso_mm": np.append( arco_centrato_m, arco_centrato_m[-1] + mesh["lato_arco_m"] ) * 1000.0, "lunghezza_mm": fascetta["lunghezza_mm"], "x_sensore_mm": sensore["x_mm"], "offset_sorgente_mm": sorgente["offset_y_percorso_m"] * 1000.0, "sorgente": sorgente, "T_ambiente_C": aria["temperatura_ambiente_C"], } 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)) n_frame = len(tempi) - indice_inizio lunghezza_mm = dati["lunghezza_mm"] x_vista_mm = (-10.0, lunghezza_mm + 10.0) arco_mm = dati["arco_chiuso_mm"] arco_min_mm, arco_max_mm = arco_mm[0], arco_mm[-1] # Il minimo evita un asse degenere se la finestra dell'animazione si # chiude prima che le sorgenti entrino nella fascetta. q_max_MW = max(max(q.max() for q in dati["flussi"]) / 1e6, 1e-3) T_max = max(c.max() for c in dati["campi"]) sorgente = dati["sorgente"] numero_sorgenti = sorgente.get("numero_sorgenti", 1) distanza_mm = sorgente.get("distanza_sorgenti_m", 0.0) * 1000.0 offset_mm = dati["offset_sorgente_mm"] fig, (ax_flusso, ax_superficie, ax_storia) = plt.subplots( 3, 1, figsize=(10, 8), height_ratios=[1.0, 1.6, 1.2], gridspec_kw={"hspace": 0.45}, ) fig.suptitle("Superficie sviluppata della fascetta: sorgenti in transito e sensore") # Pannello 1: profilo di flusso lungo x e posizioni delle sorgenti. linea_flusso, = ax_flusso.plot([], [], color="tab:red") marker_sorgenti, = ax_flusso.plot( [], [], "v", color="tab:red", markersize=10, clip_on=False ) ax_flusso.annotate( "verso di marcia", xy=(0.28, 0.85), xytext=(0.55, 0.85), xycoords="axes fraction", arrowprops={"arrowstyle": "->", "color": "gray"}, color="gray", va="center", ) ax_flusso.set_xlim(*x_vista_mm) ax_flusso.set_ylim(0.0, q_max_MW * 1.25) ax_flusso.set_ylabel("q(x) [MW/m²]") ax_flusso.grid(True, alpha=0.3) ax_flusso.set_xticklabels([]) # Pannello 2: temperatura sulla superficie sviluppata. L'asse verticale è # l'arco circonferenziale, con 0 sulla generatrice del sensore. # # pcolormesh con shading "gouraud" interpola linearmente tra i valori # nodali: è esattamente ciò che fanno le funzioni di forma bilineari # dell'elemento Q4, quindi il disegno è la soluzione dell'elemento finito # e non un'interpolazione grafica arbitraria. Xg, Yg = np.meshgrid(dati["x_nodi_mm"], dati["arco_chiuso_mm"], indexing="ij") mappa = ax_superficie.pcolormesh( Xg, Yg, chiudi_campo(dati["campi"][indice_inizio]), shading="gouraud", cmap="inferno", vmin=dati["T_ambiente_C"], vmax=T_max, ) if MOSTRA_MESH: # Bordi degli elementi shell: linee lungo x a ogni nodo # circonferenziale e viceversa. segmenti = [ [(x, arco_min_mm), (x, arco_max_mm)] for x in dati["x_nodi_mm"] ] + [ [(0.0, a), (lunghezza_mm, a)] for a in dati["arco_chiuso_mm"] ] ax_superficie.add_collection( LineCollection(segmenti, colors="white", linewidths=0.5, alpha=0.55) ) ax_superficie.set_xlim(*x_vista_mm) ax_superficie.set_ylim(arco_min_mm, arco_max_mm) ax_superficie.set_ylabel("arco circonferenziale [mm]") ax_superficie.set_xlabel("x [mm]") ax_superficie.plot( [dati["x_sensore_mm"]], [0.0], "^", color="tab:blue", markersize=11 ) ax_superficie.text( dati["x_sensore_mm"] + 3, 0.0, "sensore IR", color="tab:blue", va="center" ) ax_superficie.axhline( offset_mm, color="tab:cyan", linewidth=0.8, linestyle="--", alpha=0.7 ) # La colorbar è agganciata a tutti i pannelli per non restringere solo # quello della superficie, mantenendo allineati gli assi x. barra = fig.colorbar( mappa, ax=(ax_flusso, ax_superficie, ax_storia), pad=0.02, aspect=35 ) barra.set_label("T [°C]") # Pannello 3: temperatura nel punto osservato dal sensore. linea_vera, = ax_storia.plot([], [], label="T vera lato interno") linea_letta, = ax_storia.plot([], [], label="T sensore (con inerzia)") cursore = ax_storia.axvline(tempi[indice_inizio], color="gray", linewidth=0.8) ax_storia.set_xlim(0.0, T_FINE_ANIMAZIONE_S) ax_storia.set_ylim(15.0, 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) testo_tempo = ax_flusso.set_title(f"t = {tempi[indice_inizio]:.3f} s", loc="right") def aggiorna(frame: int): k = indice_inizio + frame t = tempi[k] linea_flusso.set_data(dati["x_nodi_mm"], dati["flussi"][k] / 1e6) x_sorgenti_mm = ( dati["x_riferimenti"][k] * 1000.0 + np.arange(numero_sorgenti) * distanza_mm ) visibili = (x_sorgenti_mm >= x_vista_mm[0]) & (x_sorgenti_mm <= x_vista_mm[1]) marker_sorgenti.set_data( x_sorgenti_mm[visibili], np.full(int(visibili.sum()), q_max_MW * 1.12), ) mappa.set_array(chiudi_campo(dati["campi"][k])) 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([t, t]) testo_tempo.set_text(f"t = {t:.3f} s") return ( linea_flusso, marker_sorgenti, mappa, linea_vera, linea_letta, cursore, testo_tempo, ) animazione = FuncAnimation( fig, aggiorna, frames=n_frame, interval=INTERVALLO_RIPRODUZIONE_MS, blit=False ) # Se il backend non è interattivo si salva una GIF invece di mostrare la finestra. if matplotlib.get_backend().lower() == "agg": percorso = Path("dataset") / "animazione_superficie.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()