Compare commits

...

5 Commits

Author SHA1 Message Date
davide 560c6a6a62 Aggiunge animazione 3D isometrica del barattolo con colormap di temperatura
Estrude il campo x-z della superficie esterna lungo la circonferenza
tramite la stessa gaussiana usata per l'attenuazione del flusso, con
shading per rendere visibile la curvatura del cilindro anche nelle
zone a temperatura ambiente.
2026-07-06 11:59:34 +02:00
davide 288ae81b3e Parallelizza generazione run su più processi CPU
Ogni run usa un seme RNG indipendente derivato dal seed globale, così
l'esecuzione su ProcessPoolExecutor resta riproducibile.
2026-07-06 11:38:33 +02:00
davide 4d83ac4e70 Allinea le proprietà della banda stagnata all'acciaio dolce reale
Il rivestimento di stagno è troppo sottile per influire sulle proprietà
in massa: la banda stagnata è fisicamente acciaio a basso tenore di
carbonio, quindi ne eredita conducibilità, densità, calore specifico,
resistività e permeabilità. Ricalibra il flusso di picco della sorgente
di conseguenza e documenta le fonti in README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 22:37:21 +02:00
davide a04465a674 Calibra sorgente e disabilita randomizzazione per validare contro dati reali
Riduce il flusso di picco e riavvicina la sorgente per riprodurre il
picco e il decadimento osservati nel pirometro reale, mantenendo
velocità e distanza tra sorgenti invariate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 22:25:12 +02:00
davide 4369d2799f Deriva la conduzione y da un'equazione di aletta sull'intero cilindro
Sostituisce il parametro lunghezza_conduzione_y_mm con un termine
q_y = -(h_esterno + h_interno)/spessore * (T - T_amb) ricavato dal
bilancio sull'intero volume cilindrico, eliminando un parametro libero.
Riduce anche l'inerzia del sensore IR a 0.01 s (pirometro fast-response).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 22:00:06 +02:00
7 changed files with 223 additions and 41 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ Generatore di dataset per misurazioni termiche pseudo-realistiche di una fascett
- Le sorgenti gaussiane in movimento producono un profilo di flusso termico superficiale q(x) sul lato esterno, variabile nel tempo (`profilo_flusso_incidente_W_m2`) - Le sorgenti gaussiane in movimento producono un profilo di flusso termico superficiale q(x) sul lato esterno, variabile nel tempo (`profilo_flusso_incidente_W_m2`)
- Quel flusso è ridistribuito volumetricamente attraverso lo spessore con decadimento esponenziale in z (`profilo_deposizione_z_1_m`): q_vol(x, z) = q(x) · p(z) - Quel flusso è ridistribuito volumetricamente attraverso lo spessore con decadimento esponenziale in z (`profilo_deposizione_z_1_m`): q_vol(x, z) = q(x) · p(z)
- Uno schema 2D a volumi finiti con Eulero implicito integra l'equazione del calore su `n_nodi_x × n_nodi_z` celle: `prepara_stato_termico` costruisce griglia, coefficienti e matrice sparsa fattorizzata LU una volta per run (`costruisci_solutore_implicito_2d`, che restituisce l'oggetto `splu`), poi `passo_implicito` avanza il campo risolvendo solo il sistema triangolare - Uno schema 2D a volumi finiti con Eulero implicito integra l'equazione del calore su `n_nodi_x × n_nodi_z` celle: `prepara_stato_termico` costruisce griglia, coefficienti e matrice sparsa fattorizzata LU una volta per run (`costruisci_solutore_implicito_2d`, che restituisce l'oggetto `splu`), poi `passo_implicito` avanza il campo risolvendo solo il sistema triangolare
- Le condizioni al contorno sono incorporate nella matrice: convezione su tutti e quattro i lati della sezione, più un termine di conduzione circonferenziale (y) verso il resto della fascetta assunto a temperatura ambiente (q_y = -k·(T - T_amb)/L_y², con L_y = `lunghezza_conduzione_y_mm`) - Le condizioni al contorno sono incorporate nella matrice: convezione su tutti e quattro i lati della sezione, più un termine di conduzione circonferenziale (y) verso il resto della fascetta assunto a temperatura ambiente. Il termine è un'equazione di aletta ricavata sull'intero volume del cilindro: il calore conduce lungo y attraverso l'intero spessore mentre le superfici esterna e interna dell'intero cilindro scambiano per convezione, dando q_y = -(h_esterno + h_interno)/spessore · (T - T_amb), senza parametri di conduzione y configurabili a parte
- La temperatura iniziale del campo è la temperatura ambiente (randomizzata per run) - La temperatura iniziale del campo è la temperatura ambiente (randomizzata per run)
- L'output del sensore aggiunge inerzia del primo ordine, rumore gaussiano e quantizzazione - L'output del sensore aggiunge inerzia del primo ordine, rumore gaussiano e quantizzazione
+14 -3
View File
@@ -73,9 +73,11 @@ pratico di dataset. La catena di approssimazioni è la seguente.
(`h_interno_W_m2K`) e sui due bordi in x (`h_bordi_W_m2K`); (`h_interno_W_m2K`) e sui due bordi in x (`h_bordi_W_m2K`);
- **conduzione circonferenziale**: la sezione cede calore per conduzione lungo `y` - **conduzione circonferenziale**: la sezione cede calore per conduzione lungo `y`
al resto della fascetta, assunto a temperatura ambiente. Il termine è al resto della fascetta, assunto a temperatura ambiente. Il termine è
`q_y = -k·(T - T_amb)/L_y²` con `L_y = lunghezza_conduzione_y_mm`, la distanza un'equazione di aletta ricavata sull'intero volume del cilindro: il calore
caratteristica su cui si sviluppa il gradiente circonferenziale (valore più conduce lungo `y` attraverso l'intero spessore mentre le superfici esterna e
piccolo = pozzo termico più aggressivo). interna dell'intero cilindro perdono calore per convezione, dando
`q_y = -(h_esterno + h_interno)/spessore · (T - T_amb)` — nessun parametro di
conduzione `y` aggiuntivo da configurare.
5. **Temperatura iniziale** — il campo parte uniformemente alla temperatura ambiente 5. **Temperatura iniziale** — il campo parte uniformemente alla temperatura ambiente
del run (che è randomizzata, quindi varia run per run). del run (che è randomizzata, quindi varia run per run).
@@ -162,6 +164,15 @@ Punti da conoscere:
- **Materiale**: `FASCETTA["materiale"]` deve essere una chiave di `MATERIALI` in - **Materiale**: `FASCETTA["materiale"]` deve essere una chiave di `MATERIALI` in
`materials.py`. Per aggiungere un materiale basta una nuova voce nel dizionario `materials.py`. Per aggiungere un materiale basta una nuova voce nel dizionario
(conducibilità, densità, calore specifico, resistività elettrica, permeabilità). (conducibilità, densità, calore specifico, resistività elettrica, permeabilità).
- **Proprietà di `banda_stagnata`**: la banda stagnata è un nastro di acciaio a basso
tenore di carbonio (0,150,25% C) rivestito su entrambe le facce da un sottile
strato di stagno elettrolitico, dello spessore di pochi micrometri — trascurabile
rispetto allo spessore tipico della parete (es. 0,18 mm) e quindi ininfluente sulle
proprietà termiche, elettriche e magnetiche in massa. I valori in `materials.py`
sono quindi quelli dell'acciaio dolce sottostante:
[Banda stagnata: tutto quello che c'è da sapere (MUNDOLATAS)](https://mundolatas.com/it/banda-stagnata-tutto-quello-che-ce-da-sapere/),
[Bande stagnate elettrolitiche (EUROPERF)](https://www.europerf.it/it/banda-stagnata-elettrolitica.php),
[Differenza tra banda stagnata e acciaio inossidabile (Wuxi Bright Packing)](https://it.brightmetalcan.com/info/difference-between-tinplate-and-stainless-stee-48700260.html).
- **Randomizzazione**: ogni run perturba velocità, flusso di picco, sigma, offset y, - **Randomizzazione**: ogni run perturba velocità, flusso di picco, sigma, offset y,
temperatura ambiente e rumore del sensore con estrazioni da un RNG a seed fisso temperatura ambiente e rumore del sensore con estrazioni da un RNG a seed fisso
(`SIMULAZIONE["seed"]`): il dataset è riproducibile. (`SIMULAZIONE["seed"]`): il dataset è riproducibile.
+18 -15
View File
@@ -15,8 +15,12 @@
# - Il riscaldamento a induzione è approssimato come riscaldamento volumetrico # - Il riscaldamento a induzione è approssimato come riscaldamento volumetrico
# che decade esponenzialmente con la profondità z secondo la skin depth. # che decade esponenzialmente con la profondità z secondo la skin depth.
# - La sezione scambia per convezione con l'aria su tutti e quattro i lati # - La sezione scambia per convezione con l'aria su tutti e quattro i lati
# (esterno, interno e i due bordi in x) e per conduzione lungo y con il # (esterno, interno e i due bordi in x). Scambia inoltre per conduzione
# resto della fascetta, assunto a temperatura ambiente. # lungo y con il resto della fascetta, assunto a temperatura ambiente:
# il calore conduce attraverso l'intero volume dello spessore mentre le
# superfici esterna e interna dell'intero cilindro perdono calore per
# convezione (equazione dell'aletta), derivato da h_esterno, h_interno e
# spessore_mm senza parametri di conduzione y aggiuntivi.
# - La temperatura iniziale della fascetta è pari alla temperatura ambiente. # - La temperatura iniziale della fascetta è pari alla temperatura ambiente.
# - Il sensore è un pirometro a infrarossi posto all'interno della fascetta, # - Il sensore è un pirometro a infrarossi posto all'interno della fascetta,
# a distanza "distanza_parete_mm" dalla parete interna: misura senza # a distanza "distanza_parete_mm" dalla parete interna: misura senza
@@ -31,7 +35,7 @@
SIMULAZIONE = { SIMULAZIONE = {
# Numero di file CSV da generare. # Numero di file CSV da generare.
"num_run": 2, "num_run": 1,
# Tempo simulato totale. # Tempo simulato totale.
"durata_s": 30.0, "durata_s": 30.0,
@@ -49,6 +53,10 @@ SIMULAZIONE = {
# Cartella di output. # Cartella di output.
"cartella_output": "dataset", "cartella_output": "dataset",
# Numero di processi paralleli per la generazione dei run.
# None = usa tutti i core disponibili.
"num_processi": None,
} }
FASCETTA = { FASCETTA = {
@@ -59,7 +67,7 @@ FASCETTA = {
"lunghezza_mm": 100.0, "lunghezza_mm": 100.0,
# Spessore della parete [mm]. # Spessore della parete [mm].
"spessore_mm": 0.12, "spessore_mm": 0.18,
# Numero di celle del volume finito lungo x (lunghezza). # Numero di celle del volume finito lungo x (lunghezza).
"n_nodi_x": 100, "n_nodi_x": 100,
@@ -68,11 +76,6 @@ FASCETTA = {
# Più nodi = maggiore risoluzione spaziale, simulazione più lenta. # Più nodi = maggiore risoluzione spaziale, simulazione più lenta.
"n_nodi_z": 15, "n_nodi_z": 15,
# Distanza caratteristica lungo y (direzione circonferenziale) su cui la
# sezione scambia calore per conduzione con il resto della fascetta,
# assunto a temperatura ambiente.
"lunghezza_conduzione_y_mm": 25.0,
# Deve corrispondere a una chiave in materials.py. # Deve corrispondere a una chiave in materials.py.
"materiale": "banda_stagnata", "materiale": "banda_stagnata",
} }
@@ -94,7 +97,7 @@ ARIA = {
SORGENTE = { SORGENTE = {
# Distanza dal sensore, all'inizio della corsa, della sorgente del # Distanza dal sensore, all'inizio della corsa, della sorgente del
# gruppo più vicina al sensore (quella che lo raggiunge per prima). # gruppo più vicina al sensore (quella che lo raggiunge per prima).
"x_inizio_m": 1.0, "x_inizio_m": 5.0,
# Distanza dal sensore, alla fine della corsa, della sorgente del # Distanza dal sensore, alla fine della corsa, della sorgente del
# gruppo più lontana dal sensore (quella che lo supera per ultima). # gruppo più lontana dal sensore (quella che lo supera per ultima).
@@ -106,20 +109,20 @@ SORGENTE = {
"offset_y_percorso_m": 0.0, "offset_y_percorso_m": 0.0,
# Velocità delle sorgenti lungo x. Il segno indica il verso di marcia. # Velocità delle sorgenti lungo x. Il segno indica il verso di marcia.
"velocita_m_s": -2.0, "velocita_m_s": -1.0,
# Numero di sorgenti equidistanti che si muovono insieme come un gruppo # Numero di sorgenti equidistanti che si muovono insieme come un gruppo
# rigido (stessa velocità, sigma, flusso ed efficienza). # rigido (stessa velocità, sigma, flusso ed efficienza).
"numero_sorgenti": 3, "numero_sorgenti": 3,
# Distanza lungo x tra sorgenti consecutive del gruppo. # Distanza lungo x tra sorgenti consecutive del gruppo.
"distanza_sorgenti_m": 0.5, "distanza_sorgenti_m": 1.0,
# Raggio del punto gaussiano. Valore più alto = impronta termica più larga. # Raggio del punto gaussiano. Valore più alto = impronta termica più larga.
"sigma_punto_m": 0.012, "sigma_punto_m": 0.012,
# Flusso termico incidente massimo prima della correzione per efficienza. # Flusso termico incidente massimo prima della correzione per efficienza.
"flusso_termico_picco_W_m2": 5500000.0, "flusso_termico_picco_W_m2": 4434000.0,
# Frazione del flusso incidente che diventa effettivamente calore nella fascetta. # Frazione del flusso incidente che diventa effettivamente calore nella fascetta.
"efficienza_riscaldamento": 0.35, "efficienza_riscaldamento": 0.35,
@@ -146,7 +149,7 @@ SENSORE = {
# Inerzia del sensore del primo ordine. # Inerzia del sensore del primo ordine.
# Valore più alto = risposta del sensore più lenta. # Valore più alto = risposta del sensore più lenta.
"costante_tempo_s": 0.05, "costante_tempo_s": 0.01,
# Deviazione standard del rumore gaussiano. # Deviazione standard del rumore gaussiano.
"rumore_std_C": 0.15, "rumore_std_C": 0.15,
@@ -159,7 +162,7 @@ SENSORE = {
RANDOMIZZAZIONE = { RANDOMIZZAZIONE = {
# Se abilitata, ogni run varia leggermente alcuni parametri. # Se abilitata, ogni run varia leggermente alcuni parametri.
"abilitata": True, "abilitata": False,
# Deviazioni standard relative. # Deviazioni standard relative.
"velocita_std_rel": 0.007, "velocita_std_rel": 0.007,
+5 -5
View File
@@ -37,10 +37,10 @@ MATERIALI = {
"permeabilita_relativa": 1.0, "permeabilita_relativa": 1.0,
}, },
"banda_stagnata": { "banda_stagnata": {
"conducibilita_termica_W_mK": 60.0, "conducibilita_termica_W_mK": 54.0,
"densita_kg_m3": 7860.0, "densita_kg_m3": 7850.0,
"calore_specifico_J_kgK": 460.0, "calore_specifico_J_kgK": 470.0,
"resistivita_elettrica_ohm_m": 1.2e-7, "resistivita_elettrica_ohm_m": 1.6e-7,
"permeabilita_relativa": 150.0, "permeabilita_relativa": 100.0,
}, },
} }
+3 -3
View File
@@ -25,13 +25,13 @@ from simulate import (
T_INIZIO_ANIMAZIONE_S = 0.40 T_INIZIO_ANIMAZIONE_S = 0.40
# Istante di fine dell'animazione. # Istante di fine dell'animazione.
T_FINE_ANIMAZIONE_S = 20 T_FINE_ANIMAZIONE_S = 30
# Tempo simulato tra un fotogramma e il successivo. # Tempo simulato tra un fotogramma e il successivo.
DT_FRAME_S = 0.001 DT_FRAME_S = 0.05
# Millisecondi tra i fotogrammi in riproduzione. # Millisecondi tra i fotogrammi in riproduzione.
INTERVALLO_RIPRODUZIONE_MS = 1 INTERVALLO_RIPRODUZIONE_MS = 30
def simula_campi(cfg_run: dict) -> dict: def simula_campi(cfg_run: dict) -> dict:
+150
View File
@@ -0,0 +1,150 @@
# Animazione 3D del barattolo in vista isometrica: colormap della temperatura
# sulla superficie esterna.
#
# Il modello risolve solo la sezione x-z (vedi CLAUDE.md): la coordinata
# circonferenziale y non è simulata, è collassata in un'attenuazione
# gaussiana del flusso. Per estrudere il campo attorno alla circonferenza si
# riusa la stessa gaussiana: la sovratemperatura rispetto al piano y=0 (dove
# si trova il sensore) viene scalata in funzione della distanza
# circonferenziale dal percorso delle sorgenti. È quindi una ricostruzione
# visiva, non un calcolo di diffusione in y.
import random
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from matplotlib import cm
from matplotlib.animation import FuncAnimation, PillowWriter
from pathlib import Path
from config import FASCETTA, SIMULAZIONE
from plot_animazione import DT_FRAME_S as DT_FRAME_S_BASE
from plot_animazione import T_FINE_ANIMAZIONE_S, 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
# Numero di punti lungo la circonferenza per l'estrusione della superficie.
N_THETA = 72
def attenuazione_circonferenziale(
y_m: np.ndarray, offset_y_m: float, sigma_m: float
) -> np.ndarray:
# Fattore che scala la sovratemperatura (T - T_ambiente) del piano y=0
# in funzione della distanza circonferenziale y dal piano stesso,
# normalizzato in modo da valere 1 in y=0.
sigma = max(sigma_m, 1e-9)
esponente = -0.5 * ((y_m - offset_y_m) ** 2 - offset_y_m ** 2) / (sigma * sigma)
return np.exp(esponente)
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))
T_ambiente = cfg_run["aria"]["temperatura_ambiente_C"]
sorgente = cfg_run["sorgente"]
sigma_m = sorgente["sigma_punto_m"]
offset_y_m = sorgente["offset_y_percorso_m"]
numero_sorgenti = sorgente.get("numero_sorgenti", 1)
distanza_m = sorgente.get("distanza_sorgenti_m", 0.0)
raggio_m = (FASCETTA["diametro_mm"] / 1000.0) / 2.0
x_centri_m = dati["x_centri_mm"] / 1000.0
lunghezza_m = dati["lunghezza_mm"] / 1000.0
theta = np.linspace(-np.pi, np.pi, N_THETA)
y_circ_m = theta * raggio_m
attenuazione = attenuazione_circonferenziale(y_circ_m, offset_y_m, sigma_m)
Xm, Thetam = np.meshgrid(x_centri_m, theta)
Ym = raggio_m * np.sin(Thetam)
Zm = raggio_m * np.cos(Thetam)
T_max = max(c[:, 0].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]")
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()
T_lato_esterno = dati["campi"][k][:, 0]
T_superficie = T_ambiente + (T_lato_esterno[None, :] - T_ambiente) * attenuazione[:, None]
colori = cmap(norm(T_superficie))
ax.plot_surface(
Xm, Ym, Zm, facecolors=colori, rstride=1, cstride=1,
antialiased=False, shade=True, lightsource=lightsource, linewidth=0,
)
x_sorgenti_m = dati["x_riferimenti"][k] + np.arange(numero_sorgenti) * distanza_m
visibili = (x_sorgenti_m >= 0.0) & (x_sorgenti_m <= lunghezza_m)
if visibili.any():
theta_sorgente = offset_y_m / raggio_m
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()
+32 -14
View File
@@ -1,7 +1,9 @@
import csv import csv
import math import math
import os
import random import random
import shutil import shutil
from concurrent.futures import ProcessPoolExecutor
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
@@ -140,11 +142,11 @@ def costruisci_solutore_implicito_2d(
dt_s: float, dt_s: float,
dx_m: float, dx_m: float,
dz_m: float, dz_m: float,
spessore_m: float,
materiale: dict, materiale: dict,
h_esterno_W_m2K: float, h_esterno_W_m2K: float,
h_interno_W_m2K: float, h_interno_W_m2K: float,
h_bordi_W_m2K: float, h_bordi_W_m2K: float,
lunghezza_conduzione_y_m: float,
): ):
# Costruisce e fattorizza (LU sparsa) la matrice A per Eulero implicito 2D: # Costruisce e fattorizza (LU sparsa) la matrice A per Eulero implicito 2D:
# A * T_next = rhs # A * T_next = rhs
@@ -153,8 +155,12 @@ def costruisci_solutore_implicito_2d(
# appiattiti in ordine C (indice = i * n_z + j). Tutti e quattro i lati # appiattiti in ordine C (indice = i * n_z + j). Tutti e quattro i lati
# della sezione includono la convezione verso l'ambiente; su ogni cella # della sezione includono la convezione verso l'ambiente; su ogni cella
# agisce inoltre la conduzione circonferenziale (y) verso il resto della # agisce inoltre la conduzione circonferenziale (y) verso il resto della
# fascetta, assunto a temperatura ambiente e modellato come # fascetta, assunto a temperatura ambiente. Il termine è ricavato
# q_y = -k * (T - T_amb) / L_y². # considerando l'intero volume della fascia cilindrica (equazione
# dell'aletta): il calore conduce lungo y attraverso l'intero spessore
# mentre le superfici esterna e interna dell'intero cilindro scambiano
# per convezione, dando un sink distribuito uniformemente su ogni cella
# q_y = -(h_esterno + h_interno) / spessore * (T - T_amb).
k = materiale["conducibilita_termica_W_mK"] k = materiale["conducibilita_termica_W_mK"]
rho = materiale["densita_kg_m3"] rho = materiale["densita_kg_m3"]
cp = materiale["calore_specifico_J_kgK"] cp = materiale["calore_specifico_J_kgK"]
@@ -165,7 +171,7 @@ def costruisci_solutore_implicito_2d(
b_esterno = h_esterno_W_m2K * dt_s / (rho * cp * dz_m) b_esterno = h_esterno_W_m2K * dt_s / (rho * cp * dz_m)
b_interno = h_interno_W_m2K * dt_s / (rho * cp * dz_m) b_interno = h_interno_W_m2K * dt_s / (rho * cp * dz_m)
b_bordo = h_bordi_W_m2K * dt_s / (rho * cp * dx_m) b_bordo = h_bordi_W_m2K * dt_s / (rho * cp * dx_m)
c_y = alpha * dt_s / (lunghezza_conduzione_y_m * lunghezza_conduzione_y_m) c_y = (h_esterno_W_m2K + h_interno_W_m2K) * dt_s / (rho * cp * spessore_m)
n = n_x * n_z n = n_x * n_z
scambio = np.full(n, c_y) scambio = np.full(n, c_y)
@@ -194,7 +200,6 @@ def prepara_stato_termico(fascetta: dict, aria: dict, sorgente: dict) -> dict:
n_z = fascetta["n_nodi_z"] n_z = fascetta["n_nodi_z"]
dx = lunghezza / n_x dx = lunghezza / n_x
dz = spessore / n_z dz = spessore / n_z
lunghezza_y = fascetta["lunghezza_conduzione_y_mm"] / 1000.0
dt = SIMULAZIONE["dt_interno_s"] dt = SIMULAZIONE["dt_interno_s"]
@@ -224,7 +229,7 @@ def prepara_stato_termico(fascetta: dict, aria: dict, sorgente: dict) -> dict:
"b_esterno": aria["h_esterno_W_m2K"] * dt / (rho * cp * dz), "b_esterno": aria["h_esterno_W_m2K"] * dt / (rho * cp * dz),
"b_interno": aria["h_interno_W_m2K"] * dt / (rho * cp * dz), "b_interno": aria["h_interno_W_m2K"] * dt / (rho * cp * dz),
"b_bordo": aria["h_bordi_W_m2K"] * dt / (rho * cp * dx), "b_bordo": aria["h_bordi_W_m2K"] * dt / (rho * cp * dx),
"c_y": alpha * dt / (lunghezza_y * lunghezza_y), "c_y": (aria["h_esterno_W_m2K"] + aria["h_interno_W_m2K"]) * dt / (rho * cp * spessore),
"T_ambiente_C": aria["temperatura_ambiente_C"], "T_ambiente_C": aria["temperatura_ambiente_C"],
"solutore": costruisci_solutore_implicito_2d( "solutore": costruisci_solutore_implicito_2d(
n_x=n_x, n_x=n_x,
@@ -232,11 +237,11 @@ def prepara_stato_termico(fascetta: dict, aria: dict, sorgente: dict) -> dict:
dt_s=dt, dt_s=dt,
dx_m=dx, dx_m=dx,
dz_m=dz, dz_m=dz,
spessore_m=spessore,
materiale=materiale, materiale=materiale,
h_esterno_W_m2K=aria["h_esterno_W_m2K"], h_esterno_W_m2K=aria["h_esterno_W_m2K"],
h_interno_W_m2K=aria["h_interno_W_m2K"], h_interno_W_m2K=aria["h_interno_W_m2K"],
h_bordi_W_m2K=aria["h_bordi_W_m2K"], h_bordi_W_m2K=aria["h_bordi_W_m2K"],
lunghezza_conduzione_y_m=lunghezza_y,
), ),
} }
@@ -409,7 +414,6 @@ def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict:
"diametro_m": fascetta["diametro_mm"] / 1000.0, "diametro_m": fascetta["diametro_mm"] / 1000.0,
"lunghezza_m": fascetta["lunghezza_mm"] / 1000.0, "lunghezza_m": fascetta["lunghezza_mm"] / 1000.0,
"spessore_m": fascetta["spessore_mm"] / 1000.0, "spessore_m": fascetta["spessore_mm"] / 1000.0,
"lunghezza_conduzione_y_m": fascetta["lunghezza_conduzione_y_mm"] / 1000.0,
"n_nodi_x": n_x, "n_nodi_x": n_x,
"n_nodi_z": n_z, "n_nodi_z": n_z,
"durata_s": durata, "durata_s": durata,
@@ -440,19 +444,33 @@ def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict:
} }
def _esegui_run(indice_e_seme: tuple[int, int]) -> dict:
# Ogni run riceve un seme indipendente derivato dal seed globale, così
# l'esecuzione in parallelo resta riproducibile indipendentemente
# dall'ordine in cui i processi la completano.
indice, seme = indice_e_seme
rng = random.Random(seme)
cfg_run = configurazione_randomizzata(indice, rng)
cartella_output = Path(SIMULAZIONE["cartella_output"])
percorso_csv = cartella_output / f"{cfg_run['id_run']}.csv"
return simula_singolo(cfg_run, percorso_csv, rng)
def main() -> None: def main() -> None:
cartella_output = Path(SIMULAZIONE["cartella_output"]) cartella_output = Path(SIMULAZIONE["cartella_output"])
if cartella_output.exists(): if cartella_output.exists():
shutil.rmtree(cartella_output) shutil.rmtree(cartella_output)
cartella_output.mkdir(parents=True, exist_ok=True) cartella_output.mkdir(parents=True, exist_ok=True)
rng = random.Random(SIMULAZIONE["seed"]) rng_semi = random.Random(SIMULAZIONE["seed"])
num_run = SIMULAZIONE["num_run"]
semi = [rng_semi.randrange(2**63) for _ in range(num_run)]
righe_metadata = [] num_processi = SIMULAZIONE["num_processi"] or os.cpu_count() or 1
for i in range(1, SIMULAZIONE["num_run"] + 1): with ProcessPoolExecutor(max_workers=num_processi) as executor:
cfg_run = configurazione_randomizzata(i, rng) righe_metadata = list(
percorso_csv = cartella_output / f"{cfg_run['id_run']}.csv" executor.map(_esegui_run, enumerate(semi, start=1))
righe_metadata.append(simula_singolo(cfg_run, percorso_csv, rng)) )
percorso_metadata = cartella_output / "metadata.csv" percorso_metadata = cartella_output / "metadata.csv"
with percorso_metadata.open("w", newline="") as f: with percorso_metadata.open("w", newline="") as f: