This commit is contained in:
2026-08-03 00:18:00 +02:00
parent 560c6a6a62
commit 7883f3dead
13 changed files with 1460 additions and 488 deletions
+110 -59
View File
@@ -1,9 +1,14 @@
# Animazione della sezione della fascetta durante il passaggio delle sorgenti.
# 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) sul lato esterno e le sorgenti in moto;
# - il campo di temperatura T(x, z) nella sezione lunghezza × spessore;
# - 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
@@ -12,13 +17,17 @@ 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_implicito,
passo_termico,
prepara_stato_termico,
profilo_flusso_incidente_W_m2,
)
# Istante di inizio dei fotogrammi mostrati (la simulazione parte comunque da 0).
@@ -33,27 +42,41 @@ 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)
n_x = stato["n_x"]
n_z = stato["n_z"]
x_centri = stato["x_centri_m"]
stato = prepara_stato_termico(fascetta, aria, sorgente, sensore)
mesh = stato["mesh"]
dt = stato["dt_s"]
interpolatore = stato["interpolatore_sensore"]
x_sensore = sensore["x_mm"] / 1000.0
i_sensore = min(n_x - 1, max(0, int(x_sensore / stato["dx_m"])))
# 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 = np.full((n_x, n_z), aria["temperatura_ambiente_C"], dtype=float)
T_sensore = T[i_sensore, -1]
tau_sensore = max(sensore["costante_tempo_s"], 1e-9)
T = campo_iniziale(stato)
T_letta = sens.temperatura_osservata_C(interpolatore, T)
tempi, campi, flussi, x_riferimenti = [], [], [], []
T_vere, T_lette = [], []
@@ -61,18 +84,22 @@ def simula_campi(cfg_run: dict) -> dict:
prossimo_frame_t = 0.0
t = 0.0
while t <= T_FINE_ANIMAZIONE_S + 1e-12:
x_rif, q_x = profilo_flusso_incidente_W_m2(sorgente, x_sensore, t, x_centri)
T = passo_implicito(stato, T, q_x)
T_sensore += (T[i_sensore, -1] - T_sensore) * dt / tau_sensore
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(T.copy())
flussi.append(q_x.copy())
x_riferimenti.append(x_rif)
T_vere.append(T[i_sensore, -1])
T_lette.append(T_sensore)
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
@@ -84,11 +111,18 @@ def simula_campi(cfg_run: dict) -> dict:
"x_riferimenti": np.array(x_riferimenti),
"T_vere": np.array(T_vere),
"T_lette": np.array(T_lette),
"x_centri_mm": x_centri * 1000.0,
"spessore_mm": fascetta["spessore_mm"],
"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"],
"sorgente": cfg_run["sorgente"],
"offset_sorgente_mm": sorgente["offset_y_percorso_m"] * 1000.0,
"sorgente": sorgente,
"T_ambiente_C": aria["temperatura_ambiente_C"],
}
@@ -102,23 +136,27 @@ def main() -> None:
n_frame = len(tempi) - indice_inizio
lunghezza_mm = dati["lunghezza_mm"]
spessore_mm = dati["spessore_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]
q_max_MW = max(q.max() for q in dati["flussi"]) / 1e6
# 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_sezione, ax_storia) = plt.subplots(
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("Sezione della fascetta: sorgenti in transito e sensore")
fig.suptitle("Superficie sviluppata della fascetta: sorgenti in transito e sensore")
# Pannello 1: profilo di flusso sul lato esterno e posizioni delle sorgenti.
# 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
@@ -135,37 +173,50 @@ def main() -> None:
ax_flusso.grid(True, alpha=0.3)
ax_flusso.set_xticklabels([])
# Pannello 2: campo di temperatura nella sezione (z verso il basso,
# origine nel vertice in alto a sinistra come nel modello).
immagine = ax_sezione.imshow(
dati["campi"][indice_inizio].T,
extent=(0.0, lunghezza_mm, spessore_mm, 0.0),
aspect="auto",
# 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=cfg_run["aria"]["temperatura_ambiente_C"],
vmin=dati["T_ambiente_C"],
vmax=T_max,
interpolation="bilinear",
)
ax_sezione.set_xlim(*x_vista_mm)
ax_sezione.set_ylim(3.2 * spessore_mm, -0.6 * spessore_mm)
ax_sezione.set_ylabel("z [mm]")
ax_sezione.set_xlabel("x [mm]")
# Sensore infrarosso sotto la parete interna (posizione schematica,
# non in scala) con linea di vista tratteggiata.
x_sens = dati["x_sensore_mm"]
ax_sezione.plot([x_sens], [2.4 * spessore_mm], "^", color="tab:blue", markersize=12)
ax_sezione.plot(
[x_sens, x_sens], [1.1 * spessore_mm, 2.1 * spessore_mm],
linestyle="--", color="tab:blue", linewidth=1,
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_sezione.text(
x_sens + 3, 2.4 * spessore_mm, "sensore IR",
color="tab:blue", va="center",
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 sezione, mantenendo allineati gli assi x.
# quello della superficie, mantenendo allineati gli assi x.
barra = fig.colorbar(
immagine, ax=(ax_flusso, ax_sezione, ax_storia), pad=0.02, aspect=35
mappa, ax=(ax_flusso, ax_superficie, ax_storia), pad=0.02, aspect=35
)
barra.set_label("T [°C]")
@@ -186,7 +237,7 @@ def main() -> None:
k = indice_inizio + frame
t = tempi[k]
linea_flusso.set_data(dati["x_centri_mm"], dati["flussi"][k] / 1e6)
linea_flusso.set_data(dati["x_nodi_mm"], dati["flussi"][k] / 1e6)
x_sorgenti_mm = (
dati["x_riferimenti"][k] * 1000.0
@@ -198,7 +249,7 @@ def main() -> None:
np.full(int(visibili.sum()), q_max_MW * 1.12),
)
immagine.set_data(dati["campi"][k].T)
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])
@@ -206,7 +257,7 @@ def main() -> None:
testo_tempo.set_text(f"t = {t:.3f} s")
return (
linea_flusso, marker_sorgenti, immagine,
linea_flusso, marker_sorgenti, mappa,
linea_vera, linea_letta, cursore, testo_tempo,
)
@@ -216,7 +267,7 @@ def main() -> None:
# Se il backend non è interattivo si salva una GIF invece di mostrare la finestra.
if matplotlib.get_backend().lower() == "agg":
percorso = Path("dataset") / "animazione_sezione.gif"
percorso = Path("dataset") / "animazione_superficie.gif"
animazione.save(
percorso, writer=PillowWriter(fps=1000 // INTERVALLO_RIPRODUZIONE_MS)
)