Compare commits

..

5 Commits

Author SHA1 Message Date
davide 7f540ed108 Riscrive il README per il modello 2D della fascetta
Documenta geometria e sistema di coordinate (con schema della sezione),
la catena di approssimazioni fisiche (sorgenti gaussiane, skin depth,
diffusione 2D, scambi sul contorno e conduzione circonferenziale,
sensore realistico), il metodo numerico, i tre script, i punti chiave
della configurazione, lo schema colonna per colonna dei CSV e le
limitazioni del modello. Sostituisce la descrizione 1D obsoleta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:23:08 +02:00
davide c67f6f8da6 Scambia calore su tutto il contorno e per conduzione circonferenziale
- Convezione anche sui bordi laterali x = 0 e x = lunghezza (nuovo
  coefficiente h_bordi_W_m2K in ARIA)
- Termine di conduzione lungo y verso il resto della fascetta, assunto a
  temperatura ambiente: q_y = -k*(T - T_amb)/L_y^2 con L_y configurabile
  (lunghezza_conduzione_y_mm in FASCETTA)
- La temperatura iniziale del campo e' la temperatura ambiente del run
  (rimosso temperatura_iniziale_C)
- Fisica estratta in prepara_stato_termico e passo_implicito, condivise
  tra simulate.py e plot_animazione.py per evitare duplicazione
- Entrambi i termini sono lineari e restano nella matrice fattorizzata
  una volta per run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:22:51 +02:00
davide c92fd26864 Aggiunge animazione 2D della sezione con sorgenti e sensore
Nuovo script plot_animazione.py che riproduce la fisica di run_0001
(stesso seed di simulate.py) e anima tre pannelli con asse x allineato:
profilo di flusso q(x) con le sorgenti in transito e il verso di marcia,
campo di temperatura T(x, z) nella sezione con il sensore IR e la sua
linea di vista, temperatura nel punto osservato (vera e con inerzia del
sensore) con cursore temporale. Con backend non interattivo salva una GIF
in dataset/ invece di aprire la finestra.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:22:37 +02:00
davide c4e71a2676 Usa il backend Qt per i grafici e salva PNG quando non interattivo
Aggiunge PySide6 alle dipendenze cosi' matplotlib seleziona il backend
qtagg (WSLg mostra le finestre); senza tkinter/Qt ripiegava su agg e
plt.show() non apriva nulla. Se il backend resta non interattivo,
plot_csv.py salva i grafici come PNG in dataset/ invece di non mostrare
niente. Etichetta della legenda aggiornata al lato interno.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:21:00 +02:00
davide bb492c37a6 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 <noreply@anthropic.com>
2026-07-05 19:20:47 +02:00
8 changed files with 679 additions and 277 deletions
+15 -9
View File
@@ -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,12 +45,15 @@ 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: `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`)
- La temperatura iniziale del campo è la temperatura ambiente (randomizzata per run)
- L'output del sensore aggiunge inerzia del primo ordine, rumore gaussiano e quantizzazione
`prepara_stato_termico` e `passo_implicito` sono condivisi con `plot_animazione.py`, che riproduce la fisica di run_0001 per animare la sezione: ogni modifica alla fisica va fatta lì, non duplicata.
**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à.
**Schema di output** (`dataset/run_XXXX.csv`): serie temporale con colonne `id_run, tempo_s, x_sorgente_m, offset_y_sorgente_m, flusso_termico_sorgente_W_m2, skin_depth_m, T_vera_lato_sensore_C, T_misurata_sensore_C, T_lato_caldo_C, T_ambiente_C, velocita_m_s, sigma_punto_m, flusso_picco_W_m2, materiale`. `metadata.csv` ha una riga per run con tutti i parametri e le temperature di picco.
@@ -59,7 +64,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, e la conduzione lungo y è un termine di scambio lineare verso la temperatura ambiente; 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).
+161 -105
View File
@@ -1,39 +1,111 @@
# Simulatore Termico 1D — Induttore Mobile, Sensore Fisso
# Simulatore Termico 2D — Fascetta, Sorgenti a Induzione Mobili, Sensore IR Fisso
Questo progetto genera misurazioni CSV pseudo-realistiche per una piastra riscaldata da un induttore in movimento.
Questo progetto genera misurazioni CSV pseudo-realistiche della temperatura di una
fascetta (anello cilindrico sottile) riscaldata da un gruppo di sorgenti a induzione
in movimento, osservata da un sensore a infrarossi fisso. Lo scopo è produrre dataset
per l'addestramento e la validazione di modelli di stima/regressione termica.
## Idea fisica
## Geometria
La geometria è semplificata a 1D attraverso lo spessore della piastra:
La fascetta è un anello cilindrico definito da tre dimensioni:
- **diametro** (default 70 mm) — il diametro del cilindro;
- **spessore** (default 0.12 mm) — lo spessore della parete;
- **lunghezza** (default 100 mm) — l'estensione assiale lungo `x`.
Il dominio simulato è la **sezione rettangolare lunghezza × spessore**. Il sistema di
coordinate ha l'origine `(0, 0)` nel vertice in alto a sinistra della sezione:
```text
lato caldo, z = 0
[sorgente / induttore]
--------------------- piastra
|
| spessore
|
---------------------
[sensore]
lato freddo, z = L
sorgenti (induttori), in moto verso -x
▼ ▼ ▼
(0,0) ─────────────────────────────────────► x
│ ┌───────────────────────────────────┐ z = 0 lato ESTERNO
│ │ sezione della fascetta │ (flusso termico)
│ └───────────────────────────────────┘ z = spessore lato INTERNO
▼ ┆
z ┆ linea di vista
sensore IR (fisso, x = 50 mm,
a 10 mm dalla parete interna)
```
L'induttore si muove lungo `x`, mentre il sensore è fisso. Poiché il modello è 1D, il movimento della sorgente è convertito in un flusso termico dipendente dal tempo:
- **x** = direzione della lunghezza, da `0` a `lunghezza`. Le sorgenti viaggiano in
direzione `-x` sul lato esterno.
- **z** = direzione dello spessore, da `0` (lato esterno, dove arriva il flusso
termico) a `spessore` (lato interno, osservato dal sensore).
- **y** = coordinata circonferenziale (lungo la circonferenza π·diametro). Non è
risolta spazialmente: vedi sotto come viene trattata.
```text
q(t) è massimo quando x_sorgente(t) è allineata con x_sensore
```
Il **sensore** è un pirometro a infrarossi posto all'interno della fascetta, a una
distanza configurabile dalla parete interna (default 10 mm). Essendo senza contatto,
la distanza non influenza la misura: il sensore legge la temperatura della superficie
interna nel punto `x` configurato (default 50 mm, al centro della lunghezza).
Il calore viene depositato attraverso lo spessore con un decadimento esponenziale basato sulla skin depth.
## Modello fisico
Non è una simulazione FEM elettromagnetica + termica completa: è un generatore
pratico di dataset. La catena di approssimazioni è la seguente.
1. **Sorgenti gaussiane in moto** — ogni sorgente ha un'impronta gaussiana di raggio
`sigma_punto_m`. Un gruppo di `numero_sorgenti` sorgenti equidistanti
(`distanza_sorgenti_m`) si muove rigidamente a velocità costante. Il profilo di
flusso sul lato esterno è la somma dei contributi:
`q(x, t) = Σᵢ q_picco · efficienza · exp(-((x - xᵢ(t))² + Δy²) / (2σ²))`.
L'offset circonferenziale `Δy` tra il percorso delle sorgenti e il punto osservato
dal sensore non è risolto spazialmente: entra come attenuazione gaussiana del flusso.
2. **Skin depth** — il riscaldamento a induzione è approssimato come riscaldamento
volumetrico che decade esponenzialmente con la profondità `z`:
`q_vol(x, z) = q(x) · exp(-z/δ) / (δ·(1 - exp(-spessore/δ)))`, normalizzato in modo
da conservare il flusso superficiale. La skin depth `δ = √(2ρₑ/(ωμ))` è calcolata
dalla resistività elettrica e dalla permeabilità del materiale alla frequenza di
induzione, oppure può essere imposta con `skin_depth_fissa_m`. Per la banda
stagnata a 20 kHz risulta ≈ 0.1 mm, confrontabile con lo spessore: la parete è
quasi isoterma attraverso lo spessore.
3. **Diffusione 2D del calore** — l'equazione del calore è integrata nella sezione
`(x, z)` con volumi finiti ed Eulero implicito (incondizionatamente stabile).
4. **Scambi con l'esterno** — la sezione scambia calore con l'ambiente su tutto il
contorno:
- convezione sul lato esterno (`h_esterno_W_m2K`), sul lato interno
(`h_interno_W_m2K`) e sui due bordi in x (`h_bordi_W_m2K`);
- **conduzione circonferenziale**: la sezione cede calore per conduzione lungo `y`
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
caratteristica su cui si sviluppa il gradiente circonferenziale (valore più
piccolo = pozzo termico più aggressivo).
5. **Temperatura iniziale** — il campo parte uniformemente alla temperatura ambiente
del run (che è randomizzata, quindi varia run per run).
6. **Sensore realistico** — la lettura aggiunge alla temperatura vera della superficie
interna: inerzia del primo ordine (`costante_tempo_s`), rumore gaussiano
(`rumore_std_C`) e quantizzazione (`quantizzazione_C`).
## Metodo numerico
- Griglia a volumi finiti `n_nodi_x × n_nodi_z` (default 100 × 15); le incognite sono
i centri cella.
- Eulero implicito con passo `dt_interno_s` (default 0.2 ms), più fine del periodo di
campionamento CSV.
- Tutti i termini (diffusione, convezione, conduzione circonferenziale) sono lineari e
costanti nel run: la matrice sparsa viene costruita e **fattorizzata LU una sola
volta per run** (`scipy.sparse.linalg.splu`); ogni passo temporale risolve solo i
sistemi triangolari. Un run da 30 s simulati richiede ~20 s di calcolo.
- Se in futuro si introducessero proprietà dipendenti dalla temperatura, la matrice
andrebbe ricostruita e rifattorizzata a ogni passo.
## File
```text
config.py tutti i parametri di simulazione
materials.py proprietà dei materiali
simulate.py genera i file CSV
plot_csv.py utilità di visualizzazione rapida
dataset/ file CSV generati dopo aver eseguito simulate.py
materials.py proprietà termofisiche ed elettriche dei materiali
simulate.py motore fisico + generazione dei CSV
plot_csv.py grafici rapidi (temperature e flusso) del primo run
plot_animazione.py animazione della sezione: campo T(x,z), sorgenti, sensore
dataset/ output generato da simulate.py (ricreato a ogni esecuzione)
```
## Installazione
@@ -44,106 +116,90 @@ source .venv/bin/activate
pip install -r requirements.txt
```
## Generare i file CSV
## Uso
```bash
# genera il dataset (ATTENZIONE: cancella e ricrea la cartella dataset/)
python simulate.py
```
La cartella di output conterrà:
```text
dataset/run_0001.csv
dataset/run_0002.csv
...
dataset/metadata.csv
```
## Visualizzare un run
```bash
# grafici statici del primo run (temperatura e flusso nel tempo)
python plot_csv.py
# animazione della sezione durante il passaggio delle sorgenti
python plot_animazione.py
```
## Principali punti di configurazione
Gli script di visualizzazione aprono finestre interattive (backend Qt); se il backend
non è interattivo (es. sessione senza display) salvano automaticamente PNG/GIF in
`dataset/`.
Modifica `config.py`.
L'animazione riproduce esattamente la fisica di `run_0001` (stesso seed) e mostra tre
pannelli allineati: il profilo di flusso `q(x)` con le sorgenti in transito, il campo
di temperatura nella sezione con il sensore IR, e la temperatura nel punto osservato
(vera e con inerzia del sensore). Finestra temporale e cadenza dei fotogrammi si
regolano con le costanti in testa a `plot_animazione.py`.
### Piastra
## Configurazione
```python
PIASTRA = {
"spessore_m": 0.005,
"n_nodi": 61,
"temperatura_iniziale_C": 25.0,
"materiale": "acciaio_basso_carbonio",
}
```
Tutto si modifica in `config.py`. I dizionari principali:
### Sorgente mobile
| Dizionario | Contenuto |
|------------------|---------------------------------------------------------------------------|
| `SIMULAZIONE` | numero di run, durata, campionamento CSV, passo interno, seed, cartella |
| `FASCETTA` | diametro, lunghezza, spessore, griglia, conduzione circonferenziale, materiale |
| `ARIA` | temperatura ambiente e coefficienti di convezione dei quattro lati |
| `SORGENTE` | corsa, velocità (il segno dà il verso), gruppo di sorgenti, gaussiana, flusso, frequenza |
| `SENSORE` | posizione (x e distanza dalla parete), inerzia, rumore, quantizzazione |
| `RANDOMIZZAZIONE`| entità delle perturbazioni per run |
```python
SORGENTE = {
"x_inizio_m": -0.08,
"x_fine_m": 0.08,
"velocita_m_s": 0.004,
"sigma_punto_m": 0.012,
"flusso_termico_picco_W_m2": 70000.0,
"efficienza_riscaldamento": 0.35,
"frequenza_hz": 20000.0,
}
```
Punti da conoscere:
### Aria
- **Corsa delle sorgenti**: `x_inizio_m` è la distanza dal sensore, all'inizio della
corsa, della sorgente più avanzata (quella che lo raggiunge per prima);
`x_fine_m` è la distanza dal sensore, a fine corsa, della sorgente più arretrata
(quella che lo supera per ultima). Con `zero_dopo_fine` ogni sorgente si spegne
alla fine della propria corsa.
- **Materiale**: `FASCETTA["materiale"]` deve essere una chiave di `MATERIALI` in
`materials.py`. Per aggiungere un materiale basta una nuova voce nel dizionario
(conducibilità, densità, calore specifico, resistività elettrica, permeabilità).
- **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
(`SIMULAZIONE["seed"]`): il dataset è riproducibile.
```python
ARIA = {
"temperatura_ambiente_C": 25.0,
"h_caldo_W_m2K": 12.0,
"h_freddo_W_m2K": 8.0,
}
```
## Output
### Sensore
### `dataset/run_XXXX.csv` — serie temporale del run
```python
SENSORE = {
"costante_tempo_s": 1.5,
"rumore_std_C": 0.15,
"quantizzazione_C": 0.25,
}
```
| Colonna | Significato |
|--------------------------------|--------------------------------------------------------------------|
| `id_run` | identificativo del run |
| `tempo_s` | tempo simulato |
| `x_sorgente_m` | posizione della sorgente di riferimento del gruppo |
| `offset_y_sorgente_m` | offset circonferenziale del percorso (costante nel run) |
| `flusso_termico_sorgente_W_m2` | flusso efficace nel punto x del sensore |
| `skin_depth_m` | skin depth usata (costante nel run) |
| `T_vera_lato_sensore_C` | temperatura vera della superficie interna nel punto del sensore |
| `T_misurata_sensore_C` | lettura del sensore (inerzia + rumore + quantizzazione) |
| `T_lato_caldo_C` | temperatura della superficie esterna nello stesso punto x |
| `T_ambiente_C` | temperatura ambiente del run |
| `velocita_m_s`, `sigma_punto_m`, `flusso_picco_W_m2` | parametri randomizzati del run |
| `materiale` | chiave del materiale |
## Colonne CSV
### `dataset/metadata.csv` — una riga per run
Ogni `run_XXXX.csv` contiene:
Contiene tutti i parametri effettivi del run (geometria, griglia, coefficienti di
scambio, parametri delle sorgenti e del sensore, valori randomizzati) e le temperature
di picco vera e misurata: utile come ground truth e per filtrare i run.
```text
id_run
tempo_s
x_sorgente_m
offset_y_sorgente_m
flusso_termico_sorgente_W_m2
skin_depth_m
T_vera_lato_sensore_C
T_misurata_sensore_C
T_lato_caldo_C
T_ambiente_C
velocita_m_s
sigma_punto_m
flusso_picco_W_m2
materiale
```
## Limitazioni
## Limitazione importante
Questo non è una simulazione FEM elettromagnetica + termica completa. È un generatore pratico di dataset.
La sorgente a induzione è approssimata da:
1. un'impronta termica gaussiana in movimento;
2. un flusso termico efficace;
3. deposizione esponenziale del calore attraverso lo spessore tramite skin depth;
4. diffusione del calore 1D attraverso la piastra;
5. perdite per convezione su entrambi i lati;
6. inerzia, rumore e quantizzazione del sensore.
1. Il campo elettromagnetico non è simulato: l'accoppiamento induttivo è ridotto a
impronta gaussiana × efficienza × decadimento esponenziale in z.
2. La coordinata circonferenziale y non è risolta: offset del percorso e conduzione
verso il resto della fascetta sono modelli collassati (attenuazione gaussiana e
scambio lineare verso T ambiente).
3. Le proprietà dei materiali sono costanti con la temperatura; per gli acciai
ferromagnetici la skin depth reale varia fortemente con temperatura e campo
(punto di Curie non modellato).
4. Irraggiamento non modellato: a ~220 °C le perdite radiative non sono del tutto
trascurabili rispetto alla convezione.
+60 -32
View File
@@ -1,17 +1,29 @@
# 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.
# - 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
# resto della fascetta, assunto a temperatura ambiente.
# - La temperatura iniziale della fascetta è pari alla temperatura ambiente.
# - 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,16 +51,27 @@ 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.
# Più nodi = maggiore risoluzione spaziale, simulazione più lenta.
"n_nodi": 61,
# Numero di celle del volume finito lungo x (lunghezza).
"n_nodi_x": 100,
# Temperatura iniziale uniforme.
"temperatura_iniziale_C": 25.0,
# Numero di celle del volume finito lungo z (spessore).
# Più nodi = maggiore risoluzione spaziale, simulazione più lenta.
"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.
"materiale": "banda_stagnata",
@@ -58,11 +81,14 @@ 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,
# Coefficiente di convezione sui bordi laterali (x = 0 e x = lunghezza).
"h_bordi_W_m2K": 10.0,
}
SORGENTE = {
@@ -74,16 +100,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 +121,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 +136,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.
+1 -1
View File
@@ -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
+230
View File
@@ -0,0 +1,230 @@
# Animazione della sezione 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 sensore infrarosso e la temperatura nel punto osservato.
import random
from pathlib import Path
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation, PillowWriter
from config import SIMULAZIONE
from simulate import (
configurazione_randomizzata,
passo_implicito,
prepara_stato_termico,
profilo_flusso_incidente_W_m2,
)
# 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 = 20
# Tempo simulato tra un fotogramma e il successivo.
DT_FRAME_S = 0.001
# Millisecondi tra i fotogrammi in riproduzione.
INTERVALLO_RIPRODUZIONE_MS = 1
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.
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"]
dt = stato["dt_s"]
x_sensore = sensore["x_mm"] / 1000.0
i_sensore = min(n_x - 1, max(0, int(x_sensore / stato["dx_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)
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:
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
if t + 1e-12 >= prossimo_frame_t:
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)
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),
"x_centri_mm": x_centri * 1000.0,
"spessore_mm": fascetta["spessore_mm"],
"lunghezza_mm": fascetta["lunghezza_mm"],
"x_sensore_mm": sensore["x_mm"],
"sorgente": cfg_run["sorgente"],
}
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"]
spessore_mm = dati["spessore_mm"]
x_vista_mm = (-10.0, lunghezza_mm + 10.0)
q_max_MW = max(q.max() for q in dati["flussi"]) / 1e6
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
fig, (ax_flusso, ax_sezione, 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")
# Pannello 1: profilo di flusso sul lato esterno 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: 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",
cmap="inferno",
vmin=cfg_run["aria"]["temperatura_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,
)
ax_sezione.text(
x_sens + 3, 2.4 * spessore_mm, "sensore IR",
color="tab:blue", va="center",
)
# La colorbar è agganciata a tutti i pannelli per non restringere solo
# quello della sezione, mantenendo allineati gli assi x.
barra = fig.colorbar(
immagine, ax=(ax_flusso, ax_sezione, 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_centri_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),
)
immagine.set_data(dati["campi"][k].T)
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, immagine,
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_sezione.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()
+16 -6
View File
@@ -1,5 +1,6 @@
from pathlib import Path
import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
@@ -13,24 +14,33 @@ def main() -> None:
df = pd.read_csv(percorso_csv)
plt.figure()
plt.plot(df["tempo_s"], df["T_vera_lato_sensore_C"], label="Temperatura vera lato freddo")
fig_temperature = plt.figure()
plt.plot(df["tempo_s"], df["T_vera_lato_sensore_C"], label="Temperatura vera lato interno")
plt.plot(df["tempo_s"], df["T_misurata_sensore_C"], label="Temperatura misurata dal sensore")
plt.xlabel("Tempo [s]")
plt.ylabel("Temperatura [°C]")
plt.title("Sensore fisso, induttore in moto")
plt.title("Sensore fisso, sorgenti in moto")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
plt.figure()
fig_flusso = plt.figure()
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()
# Se il backend non è interattivo (nessun display o backend GUI
# disponibile), plt.show() non aprirebbe nulla: si salvano i PNG.
if matplotlib.get_backend().lower() == "agg":
for fig, nome in [(fig_temperature, "temperature"), (fig_flusso, "flusso")]:
percorso = Path("dataset") / f"grafico_{nome}.png"
fig.savefig(percorso, dpi=150)
print(f"Backend non interattivo: grafico salvato in {percorso}")
return
plt.show()
+2
View File
@@ -1,3 +1,5 @@
numpy
scipy
pandas
matplotlib
PySide6
+193 -123
View File
@@ -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,158 @@ 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,
h_bordi_W_m2K: float,
lunghezza_conduzione_y_m: 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). Tutti e quattro i lati
# della sezione includono la convezione verso l'ambiente; su ogni cella
# agisce inoltre la conduzione circonferenziale (y) verso il resto della
# fascetta, assunto a temperatura ambiente e modellato come
# q_y = -k * (T - T_amb) / L_y².
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)
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)
A = np.zeros((n, n), dtype=float)
n = n_x * n_z
scambio = np.full(n, c_y)
scambio[0::n_z] += b_esterno
scambio[n_z - 1::n_z] += b_interno
scambio[:n_z] += b_bordo
scambio[n - n_z:] += b_bordo
# Bordo caldo, z = 0.
A[0, 0] = 1.0 + r + b_caldo
A[0, 1] = -r
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(scambio)
)
return splu(sp.csc_matrix(A))
# 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
def prepara_stato_termico(fascetta: dict, aria: dict, sorgente: dict) -> dict:
# Prepara griglia, coefficienti e solutore fattorizzato per un run:
# tutto ciò che resta costante durante l'integrazione temporale.
materiale = MATERIALI[fascetta["materiale"]]
return A
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
lunghezza_y = fascetta["lunghezza_conduzione_y_mm"] / 1000.0
dt = SIMULAZIONE["dt_interno_s"]
if sorgente["skin_depth_fissa_m"] is None:
skin_depth = calcola_skin_depth_m(materiale, sorgente["frequenza_hz"])
else:
skin_depth = float(sorgente["skin_depth_fissa_m"])
rho = materiale["densita_kg_m3"]
cp = materiale["calore_specifico_J_kgK"]
alpha = materiale["conducibilita_termica_W_mK"] / (rho * cp)
z_centri = (np.arange(n_z) + 0.5) * dz
return {
"n_x": n_x,
"n_z": n_z,
"dx_m": dx,
"dz_m": dz,
"dt_s": dt,
"x_centri_m": (np.arange(n_x) + 0.5) * dx,
"z_centri_m": z_centri,
"skin_depth_m": skin_depth,
"rho": rho,
"cp": cp,
"profilo_z": profilo_deposizione_z_1_m(z_centri, spessore, skin_depth),
"b_esterno": aria["h_esterno_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),
"c_y": alpha * dt / (lunghezza_y * lunghezza_y),
"T_ambiente_C": aria["temperatura_ambiente_C"],
"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_esterno_W_m2K=aria["h_esterno_W_m2K"],
h_interno_W_m2K=aria["h_interno_W_m2K"],
h_bordi_W_m2K=aria["h_bordi_W_m2K"],
lunghezza_conduzione_y_m=lunghezza_y,
),
}
def passo_implicito(stato: dict, T: np.ndarray, q_x: np.ndarray) -> np.ndarray:
# Avanza il campo di temperatura di un passo dt: assembla il termine noto
# (sorgente volumetrica, convezione sui quattro lati, conduzione
# circonferenziale verso l'ambiente) e risolve il sistema implicito.
T_amb = stato["T_ambiente_C"]
rhs = T + (stato["dt_s"] / (stato["rho"] * stato["cp"])) * (
q_x[:, None] * stato["profilo_z"][None, :]
)
rhs += stato["c_y"] * T_amb
rhs[:, 0] += stato["b_esterno"] * T_amb
rhs[:, -1] += stato["b_interno"] * T_amb
rhs[0, :] += stato["b_bordo"] * T_amb
rhs[-1, :] += stato["b_bordo"] * T_amb
return stato["solutore"].solve(rhs.ravel()).reshape(T.shape)
def quantizza(valore: float, passo: float) -> float:
@@ -175,7 +264,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 +303,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,48 +311,31 @@ 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"]
materiale = MATERIALI[nome_materiale]
nome_materiale = fascetta["materiale"]
spessore = piastra["spessore_mm"] / 1000.0
n = piastra["n_nodi"]
dz = spessore / n
z_centri = (np.arange(n) + 0.5) * dz
stato = prepara_stato_termico(fascetta, aria, sorgente)
n_x = stato["n_x"]
n_z = stato["n_z"]
x_centri = stato["x_centri_m"]
skin_depth = stato["skin_depth_m"]
dt = SIMULAZIONE["dt_interno_s"]
x_sensore = sensore["x_mm"] / 1000.0
i_sensore = min(n_x - 1, max(0, int(x_sensore / stato["dx_m"])))
dt = stato["dt_s"]
durata = SIMULAZIONE["durata_s"]
periodo_campionamento = 1.0 / SIMULAZIONE["frequenza_campionamento_hz"]
if sorgente["skin_depth_fissa_m"] is None:
skin_depth = calcola_skin_depth_m(materiale, sorgente["frequenza_hz"])
else:
skin_depth = float(sorgente["skin_depth_fissa_m"])
A = costruisci_matrice_implicita(
n=n,
dt_s=dt,
dz_m=dz,
materiale=materiale,
h_caldo_W_m2K=aria["h_caldo_W_m2K"],
h_freddo_W_m2K=aria["h_freddo_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)
T = np.full(n, piastra["temperatura_iniziale_C"], dtype=float)
T_sensore = T[-1]
T = np.full((n_x, n_z), aria["temperatura_ambiente_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 +361,14 @@ 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
)
T = passo_implicito(stato, T, q_x)
rhs = T + dt * q_vol / (rho * cp)
rhs[0] += b_caldo * aria["temperatura_ambiente_C"]
rhs[-1] += b_freddo * aria["temperatura_ambiente_C"]
T = np.linalg.solve(A, rhs)
# 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 +387,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 +406,23 @@ 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,
"spessore_m": spessore,
"n_nodi": n,
"diametro_m": fascetta["diametro_mm"] / 1000.0,
"lunghezza_m": fascetta["lunghezza_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_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"],
"h_bordi_W_m2K": aria["h_bordi_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),