From bb492c37a6bd933e4ff5e8d0fddd5bc9bce4bdd8 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 5 Jul 2026 19:20:47 +0200 Subject: [PATCH] Trasforma il modello termico da 1D a 2D sulla sezione della fascetta La lastra diventa una fascetta cilindrica (diametro, lunghezza, spessore) di cui si simula la sezione rettangolare lunghezza x spessore, con origine nel vertice in alto a sinistra: x = lunghezza, z = spessore, y = coordinata circonferenziale non risolta (offset collassato in attenuazione gaussiana). - Le sorgenti si muovono in direzione -x e producono un profilo di flusso q(x) su tutto il lato esterno; corretto il posizionamento del gruppo per velocita' negative - Sensore a infrarossi con coordinata x e distanza dalla parete interna, misura la superficie interna senza contatto - Volumi finiti 2D con Eulero implicito: matrice sparsa fattorizzata LU una volta per run (scipy.sparse.linalg.splu), a ogni passo solo i sistemi triangolari - Convezione sui lati esterno/interno, bordi adiabatici in x - Schema CSV invariato; metadata con geometria e griglia 2D Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 21 +++-- config.py | 77 ++++++++++------ materials.py | 2 +- plot_csv.py | 2 +- requirements.txt | 1 + simulate.py | 233 +++++++++++++++++++++++++---------------------- 6 files changed, 189 insertions(+), 147 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f594a70..ea77e5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,11 +31,13 @@ Non sono configurati test o linter. ## Architettura -Generatore di dataset per misurazioni termiche pseudo-realistiche di una piastra riscaldata da una sorgente a induzione in movimento. +Generatore di dataset per misurazioni termiche pseudo-realistiche di una fascetta (anello cilindrico sottile) riscaldata da sorgenti a induzione in movimento. + +**Geometria:** la fascetta ha diametro, spessore e lunghezza configurabili. Il dominio simulato è la sezione rettangolare lunghezza × spessore, con origine (0, 0) nel vertice in alto a sinistra: x = lunghezza (le sorgenti si muovono in direzione -x sul lato esterno), z = spessore (0 = lato esterno, spessore = lato interno). La coordinata circonferenziale y non è risolta: l'offset y delle sorgenti è collassato in un'attenuazione gaussiana del flusso. Il sensore è un pirometro a infrarossi dentro la fascetta che misura la superficie interna in un punto x fisso. **Flusso dei dati:** -1. `config.py` — tutti i parametri configurabili (dizionari SIMULAZIONE, PIASTRA, ARIA, SORGENTE, SENSORE, RANDOMIZZAZIONE) +1. `config.py` — tutti i parametri configurabili (dizionari SIMULAZIONE, FASCETTA, ARIA, SORGENTE, SENSORE, RANDOMIZZAZIONE) 2. `materials.py` — dizionario MATERIALI con proprietà termofisiche ed elettriche per materiale 3. `simulate.py` — motore principale: genera N run randomizzati, scrive i CSV, scrive `metadata.csv` 4. `plot_csv.py` — visualizzazione autonoma per un singolo run @@ -43,10 +45,10 @@ Generatore di dataset per misurazioni termiche pseudo-realistiche di una piastra **Pipeline fisica dentro `simula_singolo()` in [simulate.py](simulate.py):** - La skin depth è calcolata dalla resistività elettrica del materiale e dalla frequenza di induzione (`calcola_skin_depth_m`) -- La sorgente gaussiana in movimento è proiettata sulla linea del sensore fisso per produrre un flusso termico superficiale variabile nel tempo (`flusso_termico_incidente_W_m2`) -- Quel flusso è ridistribuito volumetricamente attraverso lo spessore con decadimento esponenziale (`riscaldamento_volumetrico_W_m3`) -- Uno schema 1D a volumi finiti con Eulero implicito integra l'equazione del calore su `n_nodi` celle (`costruisci_matrice_implicita`, poi `np.linalg.solve` ad ogni passo) -- Le condizioni al contorno di convezione sono incorporate nella matrice +- 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) +- Uno schema 2D a volumi finiti con Eulero implicito integra l'equazione del calore su `n_nodi_x × n_nodi_z` celle: la matrice sparsa è fattorizzata LU una volta per run (`costruisci_solutore_implicito_2d`, che restituisce l'oggetto `splu`), poi ad ogni passo si risolve solo il sistema triangolare +- Le condizioni al contorno sono incorporate nella matrice: convezione sui lati esterno (z = 0) e interno (z = spessore), bordi adiabatici a x = 0 e x = lunghezza - L'output del sensore aggiunge inerzia del primo ordine, rumore gaussiano e quantizzazione **Randomizzazione per run** (`configurazione_randomizzata`): ogni run perturba velocità, flusso di picco, sigma del punto, offset y, temperatura ambiente e rumore del sensore con estrazioni gaussiane/uniformi da un RNG con seed fisso, garantendo riproducibilità. @@ -59,7 +61,8 @@ Ogni parametro in [config.py](config.py) ha un commento che spiega solo cos'è ( ## Vincoli progettuali chiave -- Il modello è strettamente 1D (solo attraverso lo spessore). Il movimento laterale dell'induttore non è risolto spazialmente — è collassato in un flusso scalare variabile nel tempo sulla linea del sensore. -- `costruisci_matrice_implicita` è calcolata una volta per run (proprietà del materiale costanti, nessun coefficiente dipendente dalla temperatura). Se si aggiungono proprietà dipendenti dalla temperatura, la matrice deve essere ricostruita ad ogni passo temporale. +- Il modello è 2D nella sezione (x = lunghezza, z = spessore). La coordinata circonferenziale y non è risolta spazialmente — l'offset y del percorso delle sorgenti è collassato in un'attenuazione gaussiana del flusso; il diametro è registrato solo come geometria del setup. +- Le posizioni di inizio/fine corsa delle sorgenti (`x_inizio_m`, `x_fine_m`) sono distanze dal punto x del sensore lungo il verso di marcia; il segno di `velocita_m_s` determina il verso (negativo = -x). +- La matrice implicita è costruita e fattorizzata una volta per run (proprietà del materiale costanti, nessun coefficiente dipendente dalla temperatura). Se si aggiungono proprietà dipendenti dalla temperatura, la matrice deve essere ricostruita e rifattorizzata ad ogni passo temporale. - `simulate.py` cancella e ricrea l'intera cartella di output ad ogni esecuzione (`shutil.rmtree`). -- Aggiungere un nuovo materiale richiede solo una nuova voce nel dizionario `MATERIALI` in [materials.py](materials.py); la chiave del materiale va poi impostata in `PIASTRA["materiale"]` in [config.py](config.py). +- Aggiungere un nuovo materiale richiede solo una nuova voce nel dizionario `MATERIALI` in [materials.py](materials.py); la chiave del materiale va poi impostata in `FASCETTA["materiale"]` in [config.py](config.py). diff --git a/config.py b/config.py index f49f07d..88c0fb4 100644 --- a/config.py +++ b/config.py @@ -1,17 +1,25 @@ -# Configurazione per il simulatore termico 1D attraverso lo spessore. +# Configurazione per il simulatore termico 2D della sezione di una fascetta. # -# Modello fisico: -# - La piastra è ridotta a una dimensione: z = direzione dello spessore. -# - La sorgente di calore si trova sul lato caldo, z = 0. -# - Il sensore si trova sul lato opposto, z = spessore. -# - L'induttore si muove lungo x. Poiché il modello è 1D, il movimento è -# rappresentato come un flusso termico variabile nel tempo nel punto -# allineato con il sensore fisso. +# Geometria e modello fisico: +# - La fascetta è un anello cilindrico con diametro "diametro_mm", spessore +# "spessore_mm" e lunghezza "lunghezza_mm". +# - Il dominio simulato è la sezione rettangolare lunghezza × spessore. +# - Sistema di coordinate: origine (0, 0) nel vertice in alto a sinistra +# della sezione. x = direzione della lunghezza (da 0 a lunghezza), +# z = direzione dello spessore (0 = lato esterno, dove agiscono le +# sorgenti; spessore = lato interno, osservato dal sensore). +# - y è la coordinata circonferenziale: non è risolta spazialmente, l'offset +# y del percorso delle sorgenti è collassato in un'attenuazione gaussiana +# del flusso. +# - Le sorgenti a induzione si muovono in direzione -x sul lato esterno. # - Il riscaldamento a induzione è approssimato come riscaldamento volumetrico -# che decade esponenzialmente con la profondità secondo un parametro skin depth. +# che decade esponenzialmente con la profondità z secondo la skin depth. +# - Il sensore è un pirometro a infrarossi posto all'interno della fascetta, +# a distanza "distanza_parete_mm" dalla parete interna: misura senza +# contatto la temperatura della superficie interna nel punto x = "x_mm". # # Unità di misura: -# - lunghezza: m +# - lunghezza: m (mm dove indicato dal suffisso) # - tempo: s # - temperatura: °C # - flusso termico: W/m² @@ -39,13 +47,22 @@ SIMULAZIONE = { "cartella_output": "dataset", } -PIASTRA = { - # Spessore della piastra [mm]. +FASCETTA = { + # Diametro della fascetta [mm]. + "diametro_mm": 70.0, + + # Lunghezza della fascetta lungo x [mm]. + "lunghezza_mm": 100.0, + + # Spessore della parete [mm]. "spessore_mm": 0.12, - # Numero di celle del volume finito attraverso lo spessore. + # Numero di celle del volume finito lungo x (lunghezza). + "n_nodi_x": 100, + + # Numero di celle del volume finito lungo z (spessore). # Più nodi = maggiore risoluzione spaziale, simulazione più lenta. - "n_nodi": 61, + "n_nodi_z": 15, # Temperatura iniziale uniforme. "temperatura_iniziale_C": 25.0, @@ -58,11 +75,11 @@ ARIA = { # Temperatura dell'aria ambiente. "temperatura_ambiente_C": 25.0, - # Coefficiente di convezione sul lato caldo (sorgente). - "h_caldo_W_m2K": 12.0, + # Coefficiente di convezione sul lato esterno (z = 0, lato sorgenti). + "h_esterno_W_m2K": 12.0, - # Coefficiente di convezione sul lato freddo (sensore). - "h_freddo_W_m2K": 8.0, + # Coefficiente di convezione sul lato interno (z = spessore, lato sensore). + "h_interno_W_m2K": 8.0, } SORGENTE = { @@ -74,16 +91,13 @@ SORGENTE = { # gruppo più lontana dal sensore (quella che lo supera per ultima). "x_fine_m": 5.0, - # Coordinata laterale fissa della proiezione del sensore sul lato caldo. - # L'effetto della sorgente è massimo quando x_sorgente == x_sensore e offset_y_m == 0. - "x_sensore_m": 0.0, - - # Offset laterale tra il percorso della sorgente e la linea del sensore. - # Se diverso da zero, la sorgente passa a lato del sensore, riducendo il picco. + # Offset circonferenziale (y) tra il percorso delle sorgenti e il punto + # osservato dal sensore. Se diverso da zero, la sorgente passa a lato, + # riducendo il picco. "offset_y_percorso_m": 0.0, - # Velocità della sorgente lungo x. - "velocita_m_s": 2.0, + # Velocità delle sorgenti lungo x. Il segno indica il verso di marcia. + "velocita_m_s": -2.0, # Numero di sorgenti equidistanti che si muovono insieme come un gruppo # rigido (stessa velocità, sigma, flusso ed efficienza). @@ -98,7 +112,7 @@ SORGENTE = { # Flusso termico incidente massimo prima della correzione per efficienza. "flusso_termico_picco_W_m2": 5500000.0, - # Frazione del flusso incidente che diventa effettivamente calore nella piastra. + # Frazione del flusso incidente che diventa effettivamente calore nella fascetta. "efficienza_riscaldamento": 0.35, # Frequenza di induzione usata per stimare la skin depth se skin_depth_fissa_m è None. @@ -113,8 +127,13 @@ SORGENTE = { } SENSORE = { - # Il sensore si trova sul lato freddo della piastra. - "posizione": "lato_freddo", + # Coordinata x del punto della superficie interna osservato dal sensore [mm]. + "x_mm": 50.0, + + # Distanza del sensore dalla parete interna lungo z [mm]. + # Il sensore è a infrarossi: la distanza non influenza la misura, + # è registrata solo come geometria del setup. + "distanza_parete_mm": 10.0, # Inerzia del sensore del primo ordine. # Valore più alto = risposta del sensore più lenta. diff --git a/materials.py b/materials.py index 6f1ac62..27c3d8c 100644 --- a/materials.py +++ b/materials.py @@ -1,4 +1,4 @@ -# Database dei materiali per il simulatore termico 1D. +# Database dei materiali per il simulatore termico. # # Tutte le unità sono SI: # - conducibilita_termica_W_mK diff --git a/plot_csv.py b/plot_csv.py index cb112b4..7674ddd 100644 --- a/plot_csv.py +++ b/plot_csv.py @@ -28,7 +28,7 @@ def main() -> None: plt.plot(df["tempo_s"], df["flusso_termico_sorgente_W_m2"]) plt.xlabel("Tempo [s]") plt.ylabel("Flusso termico efficace [W/m²]") - plt.title("Flusso termico visto dalla linea del sensore") + plt.title("Flusso termico nel punto x osservato dal sensore") plt.grid(True) plt.tight_layout() plt.show() diff --git a/requirements.txt b/requirements.txt index 4021870..7bd7c44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ numpy +scipy pandas matplotlib diff --git a/simulate.py b/simulate.py index f306cae..54e5792 100644 --- a/simulate.py +++ b/simulate.py @@ -6,8 +6,10 @@ from copy import deepcopy from pathlib import Path import numpy as np +import scipy.sparse as sp +from scipy.sparse.linalg import splu -from config import ARIA, PIASTRA, RANDOMIZZAZIONE, SENSORE, SIMULAZIONE, SORGENTE +from config import ARIA, FASCETTA, RANDOMIZZAZIONE, SENSORE, SIMULAZIONE, SORGENTE from materials import MATERIALI @@ -34,31 +36,28 @@ def _spread_sorgenti_m(sorgente: dict) -> float: return (numero_sorgenti - 1) * distanza -def _x_riferimento_iniziale_m(sorgente: dict) -> float: - # Posizione a t=0 della sorgente di indice 0 (quella più arretrata nel - # verso di marcia). x_inizio_m è la distanza dal sensore della sorgente - # più avanzata (che quindi lo raggiunge per prima). +def _x_riferimento_iniziale_m(sorgente: dict, x_sensore_m: float) -> float: + # Posizione a t=0 della sorgente di indice 0. Le sorgenti i sono a + # x_i = riferimento + i * distanza, quindi per v >= 0 l'indice 0 è la + # più arretrata nel verso di marcia, per v < 0 è la più avanzata. + # x_inizio_m è la distanza dal sensore della sorgente più avanzata + # (quella che lo raggiunge per prima). spread = _spread_sorgenti_m(sorgente) - x_sensore = sorgente["x_sensore_m"] x_inizio = sorgente["x_inizio_m"] if sorgente["velocita_m_s"] >= 0: - return (x_sensore - x_inizio) - spread - return (x_sensore + x_inizio) + spread + return (x_sensore_m - x_inizio) - spread + return x_sensore_m + x_inizio -def _x_riferimento_finale_m(sorgente: dict) -> float: +def _x_riferimento_finale_m(sorgente: dict, x_sensore_m: float) -> float: # Posizione di fine corsa della sorgente di indice 0. x_fine_m è la - # distanza dal sensore della sorgente più arretrata (che quindi lo - # supera per ultima). - x_sensore = sorgente["x_sensore_m"] + # distanza dal sensore della sorgente più arretrata nel verso di marcia + # (quella che lo supera per ultima). + spread = _spread_sorgenti_m(sorgente) x_fine = sorgente["x_fine_m"] if sorgente["velocita_m_s"] >= 0: - return x_sensore + x_fine - return x_sensore - x_fine - - -def x_sorgente_al_tempo(sorgente: dict, t_s: float) -> float: - return _x_riferimento_iniziale_m(sorgente) + sorgente["velocita_m_s"] * t_s + return x_sensore_m + x_fine + return (x_sensore_m - x_fine) - spread def _intervallo_attivo(inizio: float, fine: float, v: float, x_m: float) -> bool: @@ -67,18 +66,21 @@ def _intervallo_attivo(inizio: float, fine: float, v: float, x_m: float) -> bool return fine <= x_m <= inizio -def flusso_termico_incidente_W_m2(sorgente: dict, t_s: float) -> tuple[float, float]: +def profilo_flusso_incidente_W_m2( + sorgente: dict, + x_sensore_m: float, + t_s: float, + x_centri_m: np.ndarray, +) -> tuple[float, np.ndarray]: # Restituisce x_sorgente_m (posizione della sorgente di riferimento) e - # flusso_termico_efficace_W_m2 (somma dei contributi di tutte le sorgenti). + # il profilo di flusso termico efficace q(x) [W/m²] sul lato esterno, + # somma dei contributi di tutte le sorgenti attive. # - # Il movimento è rappresentato con un'impronta gaussiana centrata su - # ciascuna sorgente in moto. Più sorgenti equidistanti si muovono insieme - # come un gruppo rigido: condividono velocità, sigma e flusso di picco, e - # sono sfalsate lungo x di un multiplo di "distanza_sorgenti_m". Il - # modello 1D vede solo il flusso lungo la linea che passa per il sensore - # fisso, sommando il contributo di tutte le sorgenti attive. - x_rif_iniziale = _x_riferimento_iniziale_m(sorgente) - x_rif_finale = _x_riferimento_finale_m(sorgente) + # Ogni sorgente in moto ha un'impronta gaussiana lungo x, valutata sui + # centri cella della sezione. L'offset circonferenziale y non è risolto + # spazialmente: entra come attenuazione gaussiana del flusso. + x_rif_iniziale = _x_riferimento_iniziale_m(sorgente, x_sensore_m) + x_rif_finale = _x_riferimento_finale_m(sorgente, x_sensore_m) x_riferimento = x_rif_iniziale + sorgente["velocita_m_s"] * t_s numero_sorgenti = sorgente.get("numero_sorgenti", 1) @@ -88,7 +90,10 @@ def flusso_termico_incidente_W_m2(sorgente: dict, t_s: float) -> tuple[float, fl dy = sorgente["offset_y_percorso_m"] sigma = sorgente["sigma_punto_m"] - q_totale = 0.0 + q_picco = sorgente["flusso_termico_picco_W_m2"] * sorgente["efficienza_riscaldamento"] + attenuazione_y = math.exp(-0.5 * (dy * dy) / (sigma * sigma)) + + q_x = np.zeros_like(x_centri_m) for i in range(numero_sorgenti): x_i = x_riferimento + i * distanza @@ -98,74 +103,76 @@ def flusso_termico_incidente_W_m2(sorgente: dict, t_s: float) -> tuple[float, fl if not _intervallo_attivo(inizio_i, fine_i, v, x_i): continue - dx = x_i - sorgente["x_sensore_m"] - gaussiana = math.exp(-0.5 * (dx * dx + dy * dy) / (sigma * sigma)) - q_totale += ( - sorgente["flusso_termico_picco_W_m2"] - * sorgente["efficienza_riscaldamento"] - * gaussiana - ) + dx = x_centri_m - x_i + q_x += q_picco * attenuazione_y * np.exp(-0.5 * (dx * dx) / (sigma * sigma)) - return x_riferimento, q_totale + return x_riferimento, q_x -def riscaldamento_volumetrico_W_m3( - q_superficie_W_m2: float, +def profilo_deposizione_z_1_m( z_centri_m: np.ndarray, spessore_m: float, skin_depth_m: float, ) -> np.ndarray: - # Converte il flusso superficiale equivalente in riscaldamento volumetrico q_vol(z). + # Profilo di deposizione volumetrica del flusso superficiale [1/m]: # - # q_vol(z) = A * exp(-z / delta) + # p(z) = exp(-z / delta) / (delta * (1 - exp(-spessore / delta))) # - # A è scelto in modo che integrale_0^L q_vol(z) dz = q_superficie_W_m2. - if q_superficie_W_m2 <= 0.0: - return np.zeros_like(z_centri_m) - + # Normalizzato in modo che integrale_0^spessore p(z) dz = 1, così che + # q_vol(x, z) = q(x) * p(z) conservi il flusso superficiale. delta = max(skin_depth_m, 1e-9) normalizzazione = delta * (1.0 - math.exp(-spessore_m / delta)) - return (q_superficie_W_m2 / normalizzazione) * np.exp(-z_centri_m / delta) + return np.exp(-z_centri_m / delta) / normalizzazione -def costruisci_matrice_implicita( - n: int, +def _laplaciano_1d(n: int) -> sp.spmatrix: + # Operatore alle differenze -T'' su n celle con bordi adiabatici (Neumann). + diagonale = np.full(n, 2.0) + diagonale[0] = 1.0 + diagonale[-1] = 1.0 + fuori = -np.ones(n - 1) + return sp.diags([fuori, diagonale, fuori], [-1, 0, 1]) + + +def costruisci_solutore_implicito_2d( + n_x: int, + n_z: int, dt_s: float, + dx_m: float, dz_m: float, materiale: dict, - h_caldo_W_m2K: float, - h_freddo_W_m2K: float, -) -> np.ndarray: - # Costruisce la matrice A per Eulero implicito: + h_esterno_W_m2K: float, + h_interno_W_m2K: float, +): + # Costruisce e fattorizza (LU sparsa) la matrice A per Eulero implicito 2D: # A * T_next = rhs # - # Le celle di bordo includono la convezione verso l'ambiente. + # Le incognite sono i centri cella T[i, j] con i lungo x e j lungo z, + # appiattiti in ordine C (indice = i * n_z + j). I bordi x = 0 e + # x = lunghezza sono adiabatici; i bordi z = 0 (esterno) e z = spessore + # (interno) includono la convezione verso l'ambiente. k = materiale["conducibilita_termica_W_mK"] rho = materiale["densita_kg_m3"] cp = materiale["calore_specifico_J_kgK"] alpha = k / (rho * cp) - r = alpha * dt_s / (dz_m * dz_m) - b_caldo = h_caldo_W_m2K * dt_s / (rho * cp * dz_m) - b_freddo = h_freddo_W_m2K * dt_s / (rho * cp * dz_m) + r_x = alpha * dt_s / (dx_m * dx_m) + r_z = alpha * dt_s / (dz_m * 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) - A = np.zeros((n, n), dtype=float) + n = n_x * n_z + convezione = np.zeros(n) + convezione[0::n_z] = b_esterno + convezione[n_z - 1::n_z] = b_interno - # Bordo caldo, z = 0. - A[0, 0] = 1.0 + r + b_caldo - A[0, 1] = -r - - # Celle interne. - for i in range(1, n - 1): - A[i, i - 1] = -r - A[i, i] = 1.0 + 2.0 * r - A[i, i + 1] = -r - - # Bordo freddo, z = spessore. - A[n - 1, n - 2] = -r - A[n - 1, n - 1] = 1.0 + r + b_freddo - - return A + A = ( + sp.identity(n) + + r_x * sp.kron(_laplaciano_1d(n_x), sp.identity(n_z)) + + r_z * sp.kron(sp.identity(n_x), _laplaciano_1d(n_z)) + + sp.diags(convezione) + ) + return splu(sp.csc_matrix(A)) def quantizza(valore: float, passo: float) -> float: @@ -175,7 +182,7 @@ def quantizza(valore: float, passo: float) -> float: def configurazione_randomizzata(indice_run: int, rng: random.Random) -> dict: - piastra = deepcopy(PIASTRA) + fascetta = deepcopy(FASCETTA) aria = deepcopy(ARIA) sorgente = deepcopy(SORGENTE) sensore = deepcopy(SENSORE) @@ -214,7 +221,7 @@ def configurazione_randomizzata(indice_run: int, rng: random.Random) -> dict: return { "id_run": f"run_{indice_run:04d}", - "piastra": piastra, + "fascetta": fascetta, "aria": aria, "sorgente": sorgente, "sensore": sensore, @@ -222,18 +229,25 @@ def configurazione_randomizzata(indice_run: int, rng: random.Random) -> dict: def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict: - piastra = cfg_run["piastra"] + fascetta = cfg_run["fascetta"] aria = cfg_run["aria"] sorgente = cfg_run["sorgente"] sensore = cfg_run["sensore"] - nome_materiale = piastra["materiale"] + nome_materiale = fascetta["materiale"] materiale = MATERIALI[nome_materiale] - spessore = piastra["spessore_mm"] / 1000.0 - n = piastra["n_nodi"] - dz = spessore / n - z_centri = (np.arange(n) + 0.5) * dz + lunghezza = fascetta["lunghezza_mm"] / 1000.0 + spessore = fascetta["spessore_mm"] / 1000.0 + n_x = fascetta["n_nodi_x"] + n_z = fascetta["n_nodi_z"] + dx = lunghezza / n_x + dz = spessore / n_z + x_centri = (np.arange(n_x) + 0.5) * dx + z_centri = (np.arange(n_z) + 0.5) * dz + + x_sensore = sensore["x_mm"] / 1000.0 + i_sensore = min(n_x - 1, max(0, int(x_sensore / dx))) dt = SIMULAZIONE["dt_interno_s"] durata = SIMULAZIONE["durata_s"] @@ -244,26 +258,30 @@ def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict: else: skin_depth = float(sorgente["skin_depth_fissa_m"]) - A = costruisci_matrice_implicita( - n=n, + solutore = costruisci_solutore_implicito_2d( + n_x=n_x, + n_z=n_z, dt_s=dt, + dx_m=dx, dz_m=dz, materiale=materiale, - h_caldo_W_m2K=aria["h_caldo_W_m2K"], - h_freddo_W_m2K=aria["h_freddo_W_m2K"], + h_esterno_W_m2K=aria["h_esterno_W_m2K"], + h_interno_W_m2K=aria["h_interno_W_m2K"], ) rho = materiale["densita_kg_m3"] cp = materiale["calore_specifico_J_kgK"] - b_caldo = aria["h_caldo_W_m2K"] * dt / (rho * cp * dz) - b_freddo = aria["h_freddo_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) - T = np.full(n, piastra["temperatura_iniziale_C"], dtype=float) - T_sensore = T[-1] + profilo_z = profilo_deposizione_z_1_m(z_centri, spessore, skin_depth) + + T = np.full((n_x, n_z), fascetta["temperatura_iniziale_C"], dtype=float) + T_sensore = T[i_sensore, -1] prossimo_campione_t = 0.0 - T_vera_max = T[-1] + T_vera_max = T[i_sensore, -1] T_misurata_max = T_sensore output_csv.parent.mkdir(parents=True, exist_ok=True) @@ -289,22 +307,19 @@ def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict: t = 0.0 while t <= durata + 1e-12: - x_sorgente, q_superficie = flusso_termico_incidente_W_m2(sorgente, t) - q_vol = riscaldamento_volumetrico_W_m3( - q_superficie_W_m2=q_superficie, - z_centri_m=z_centri, - spessore_m=spessore, - skin_depth_m=skin_depth, + x_sorgente, q_x = profilo_flusso_incidente_W_m2( + sorgente, x_sensore, t, x_centri ) - rhs = T + dt * q_vol / (rho * cp) - rhs[0] += b_caldo * aria["temperatura_ambiente_C"] - rhs[-1] += b_freddo * aria["temperatura_ambiente_C"] + rhs = T + (dt / (rho * cp)) * q_x[:, None] * profilo_z[None, :] + rhs[:, 0] += b_esterno * aria["temperatura_ambiente_C"] + rhs[:, -1] += b_interno * aria["temperatura_ambiente_C"] - T = np.linalg.solve(A, rhs) + T = solutore.solve(rhs.ravel()).reshape(n_x, n_z) - # Temperatura vera sul lato freddo, dove si trova il sensore fisso. - T_vera_lato_sensore = T[-1] + # Temperatura vera della superficie interna nel punto osservato + # dal sensore infrarosso. + T_vera_lato_sensore = T[i_sensore, -1] # Inerzia del sensore del primo ordine. tau_sensore = max(sensore["costante_tempo_s"], 1e-9) @@ -323,11 +338,11 @@ def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict: f"{t:.6f}", f"{x_sorgente:.9f}", f"{sorgente['offset_y_percorso_m']:.9f}", - f"{q_superficie:.6f}", + f"{q_x[i_sensore]:.6f}", f"{skin_depth:.9e}", f"{T_vera_lato_sensore:.6f}", f"{misurata:.6f}", - f"{T[0]:.6f}", + f"{T[i_sensore, 0]:.6f}", f"{aria['temperatura_ambiente_C']:.6f}", f"{sorgente['velocita_m_s']:.9f}", f"{sorgente['sigma_punto_m']:.9f}", @@ -342,17 +357,21 @@ def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict: "id_run": cfg_run["id_run"], "file_csv": str(output_csv.name), "materiale": nome_materiale, + "diametro_m": fascetta["diametro_mm"] / 1000.0, + "lunghezza_m": lunghezza, "spessore_m": spessore, - "n_nodi": n, + "n_nodi_x": n_x, + "n_nodi_z": n_z, "durata_s": durata, "frequenza_campionamento_hz": SIMULAZIONE["frequenza_campionamento_hz"], "dt_interno_s": dt, "temperatura_ambiente_C": aria["temperatura_ambiente_C"], - "h_caldo_W_m2K": aria["h_caldo_W_m2K"], - "h_freddo_W_m2K": aria["h_freddo_W_m2K"], + "h_esterno_W_m2K": aria["h_esterno_W_m2K"], + "h_interno_W_m2K": aria["h_interno_W_m2K"], "x_inizio_m": sorgente["x_inizio_m"], "x_fine_m": sorgente["x_fine_m"], - "x_sensore_m": sorgente["x_sensore_m"], + "x_sensore_m": x_sensore, + "distanza_sensore_parete_m": sensore["distanza_parete_mm"] / 1000.0, "offset_y_percorso_m": sorgente["offset_y_percorso_m"], "velocita_m_s": sorgente["velocita_m_s"], "numero_sorgenti": sorgente.get("numero_sorgenti", 1),