Compare commits

..

8 Commits

Author SHA1 Message Date
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
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 703 additions and 289 deletions
+15 -9
View File
@@ -31,11 +31,13 @@ Non sono configurati test o linter.
## Architettura ## 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:** **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 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` 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 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):** **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 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`) - 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 (`riscaldamento_volumetrico_W_m3`) - 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 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) - 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 di convezione sono incorporate nella matrice - 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)
- 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
`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à. **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. **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 ## 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. - 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.
- `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. - 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`). - `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).
+172 -105
View File
@@ -1,39 +1,113 @@
# 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 ```text
lato caldo, z = 0 sorgenti (induttori), in moto verso -x
[sorgente / induttore] ▼ ▼ ▼
--------------------- piastra (0,0) ─────────────────────────────────────► x
| │ ┌───────────────────────────────────┐ z = 0 lato ESTERNO
| spessore │ │ sezione della fascetta │ (flusso termico)
| │ └───────────────────────────────────┘ z = spessore lato INTERNO
--------------------- ▼ ┆
[sensore] z ┆ linea di vista
lato freddo, z = L
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 Il **sensore** è un pirometro a infrarossi posto all'interno della fascetta, a una
q(t) è massimo quando x_sorgente(t) è allineata con x_sensore 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 è
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 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
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 ## File
```text ```text
config.py tutti i parametri di simulazione config.py tutti i parametri di simulazione
materials.py proprietà dei materiali materials.py proprietà termofisiche ed elettriche dei materiali
simulate.py genera i file CSV simulate.py motore fisico + generazione dei CSV
plot_csv.py utilità di visualizzazione rapida plot_csv.py grafici rapidi (temperature e flusso) del primo run
dataset/ file CSV generati dopo aver eseguito simulate.py plot_animazione.py animazione della sezione: campo T(x,z), sorgenti, sensore
dataset/ output generato da simulate.py (ricreato a ogni esecuzione)
``` ```
## Installazione ## Installazione
@@ -44,106 +118,99 @@ source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
## Generare i file CSV ## Uso
```bash ```bash
# genera il dataset (ATTENZIONE: cancella e ricrea la cartella dataset/)
python simulate.py python simulate.py
```
La cartella di output conterrà: # grafici statici del primo run (temperatura e flusso nel tempo)
```text
dataset/run_0001.csv
dataset/run_0002.csv
...
dataset/metadata.csv
```
## Visualizzare un run
```bash
python plot_csv.py 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 Tutto si modifica in `config.py`. I dizionari principali:
PIASTRA = {
"spessore_m": 0.005,
"n_nodi": 61,
"temperatura_iniziale_C": 25.0,
"materiale": "acciaio_basso_carbonio",
}
```
### 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 Punti da conoscere:
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,
}
```
### 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à).
- **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,
temperatura ambiente e rumore del sensore con estrazioni da un RNG a seed fisso
(`SIMULAZIONE["seed"]`): il dataset è riproducibile.
```python ## Output
ARIA = {
"temperatura_ambiente_C": 25.0,
"h_caldo_W_m2K": 12.0,
"h_freddo_W_m2K": 8.0,
}
```
### Sensore ### `dataset/run_XXXX.csv` — serie temporale del run
```python | Colonna | Significato |
SENSORE = { |--------------------------------|--------------------------------------------------------------------|
"costante_tempo_s": 1.5, | `id_run` | identificativo del run |
"rumore_std_C": 0.15, | `tempo_s` | tempo simulato |
"quantizzazione_C": 0.25, | `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 ## Limitazioni
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
```
## Limitazione importante 1. Il campo elettromagnetico non è simulato: l'accoppiamento induttivo è ridotto a
impronta gaussiana × efficienza × decadimento esponenziale in z.
Questo non è una simulazione FEM elettromagnetica + termica completa. È un generatore pratico di dataset. 2. La coordinata circonferenziale y non è risolta: offset del percorso e conduzione
verso il resto della fascetta sono modelli collassati (attenuazione gaussiana e
La sorgente a induzione è approssimata da: scambio lineare verso T ambiente).
3. Le proprietà dei materiali sono costanti con la temperatura; per gli acciai
1. un'impronta termica gaussiana in movimento; ferromagnetici la skin depth reale varia fortemente con temperatura e campo
2. un flusso termico efficace; (punto di Curie non modellato).
3. deposizione esponenziale del calore attraverso lo spessore tramite skin depth; 4. Irraggiamento non modellato: a ~220 °C le perdite radiative non sono del tutto
4. diffusione del calore 1D attraverso la piastra; trascurabili rispetto alla convezione.
5. perdite per convezione su entrambi i lati;
6. inerzia, rumore e quantizzazione del sensore.
+66 -39
View File
@@ -1,17 +1,33 @@
# Configurazione per il simulatore termico 1D attraverso lo spessore. # Configurazione per il simulatore termico 2D della sezione di una fascetta.
# #
# Modello fisico: # Geometria e modello fisico:
# - La piastra è ridotta a una dimensione: z = direzione dello spessore. # - La fascetta è un anello cilindrico con diametro "diametro_mm", spessore
# - La sorgente di calore si trova sul lato caldo, z = 0. # "spessore_mm" e lunghezza "lunghezza_mm".
# - Il sensore si trova sul lato opposto, z = spessore. # - Il dominio simulato è la sezione rettangolare lunghezza × spessore.
# - L'induttore si muove lungo x. Poiché il modello è 1D, il movimento è # - Sistema di coordinate: origine (0, 0) nel vertice in alto a sinistra
# rappresentato come un flusso termico variabile nel tempo nel punto # della sezione. x = direzione della lunghezza (da 0 a lunghezza),
# allineato con il sensore fisso. # 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 # - 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). Scambia inoltre per conduzione
# 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.
# - 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: # Unità di misura:
# - lunghezza: m # - lunghezza: m (mm dove indicato dal suffisso)
# - tempo: s # - tempo: s
# - temperatura: °C # - temperatura: °C
# - flusso termico: W/m² # - flusso termico: W/m²
@@ -19,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,
@@ -39,16 +55,22 @@ SIMULAZIONE = {
"cartella_output": "dataset", "cartella_output": "dataset",
} }
PIASTRA = { FASCETTA = {
# Spessore della piastra [mm]. # Diametro della fascetta [mm].
"spessore_mm": 0.12, "diametro_mm": 70.0,
# Numero di celle del volume finito attraverso lo spessore. # Lunghezza della fascetta lungo x [mm].
"lunghezza_mm": 100.0,
# Spessore della parete [mm].
"spessore_mm": 0.18,
# 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. # Più nodi = maggiore risoluzione spaziale, simulazione più lenta.
"n_nodi": 61, "n_nodi_z": 15,
# Temperatura iniziale uniforme.
"temperatura_iniziale_C": 25.0,
# Deve corrispondere a una chiave in materials.py. # Deve corrispondere a una chiave in materials.py.
"materiale": "banda_stagnata", "materiale": "banda_stagnata",
@@ -58,47 +80,47 @@ ARIA = {
# Temperatura dell'aria ambiente. # Temperatura dell'aria ambiente.
"temperatura_ambiente_C": 25.0, "temperatura_ambiente_C": 25.0,
# Coefficiente di convezione sul lato caldo (sorgente). # Coefficiente di convezione sul lato esterno (z = 0, lato sorgenti).
"h_caldo_W_m2K": 12.0, "h_esterno_W_m2K": 12.0,
# Coefficiente di convezione sul lato freddo (sensore). # Coefficiente di convezione sul lato interno (z = spessore, lato sensore).
"h_freddo_W_m2K": 8.0, "h_interno_W_m2K": 8.0,
# Coefficiente di convezione sui bordi laterali (x = 0 e x = lunghezza).
"h_bordi_W_m2K": 10.0,
} }
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).
"x_fine_m": 5.0, "x_fine_m": 5.0,
# Coordinata laterale fissa della proiezione del sensore sul lato caldo. # Offset circonferenziale (y) tra il percorso delle sorgenti e il punto
# L'effetto della sorgente è massimo quando x_sorgente == x_sensore e offset_y_m == 0. # osservato dal sensore. Se diverso da zero, la sorgente passa a lato,
"x_sensore_m": 0.0, # riducendo il picco.
# 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_y_percorso_m": 0.0, "offset_y_percorso_m": 0.0,
# Velocità della sorgente lungo x. # 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 piastra. # Frazione del flusso incidente che diventa effettivamente calore nella fascetta.
"efficienza_riscaldamento": 0.35, "efficienza_riscaldamento": 0.35,
# Frequenza di induzione usata per stimare la skin depth se skin_depth_fissa_m è None. # Frequenza di induzione usata per stimare la skin depth se skin_depth_fissa_m è None.
@@ -113,12 +135,17 @@ SORGENTE = {
} }
SENSORE = { SENSORE = {
# Il sensore si trova sul lato freddo della piastra. # Coordinata x del punto della superficie interna osservato dal sensore [mm].
"posizione": "lato_freddo", "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. # 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,
@@ -131,7 +158,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,
+6 -6
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: # Tutte le unità sono SI:
# - conducibilita_termica_W_mK # - conducibilita_termica_W_mK
@@ -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,
}, },
} }
+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 = 30
# Tempo simulato tra un fotogramma e il successivo.
DT_FRAME_S = 0.05
# Millisecondi tra i fotogrammi in riproduzione.
INTERVALLO_RIPRODUZIONE_MS = 30
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 from pathlib import Path
import matplotlib
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import pandas as pd import pandas as pd
@@ -13,24 +14,33 @@ def main() -> None:
df = pd.read_csv(percorso_csv) df = pd.read_csv(percorso_csv)
plt.figure() fig_temperature = plt.figure()
plt.plot(df["tempo_s"], df["T_vera_lato_sensore_C"], label="Temperatura vera lato freddo") 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.plot(df["tempo_s"], df["T_misurata_sensore_C"], label="Temperatura misurata dal sensore")
plt.xlabel("Tempo [s]") plt.xlabel("Tempo [s]")
plt.ylabel("Temperatura [°C]") plt.ylabel("Temperatura [°C]")
plt.title("Sensore fisso, induttore in moto") plt.title("Sensore fisso, sorgenti in moto")
plt.legend() plt.legend()
plt.grid(True) plt.grid(True)
plt.tight_layout() plt.tight_layout()
plt.show()
plt.figure() fig_flusso = plt.figure()
plt.plot(df["tempo_s"], df["flusso_termico_sorgente_W_m2"]) plt.plot(df["tempo_s"], df["flusso_termico_sorgente_W_m2"])
plt.xlabel("Tempo [s]") plt.xlabel("Tempo [s]")
plt.ylabel("Flusso termico efficace [W/m²]") 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.grid(True)
plt.tight_layout() 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() plt.show()
+2
View File
@@ -1,3 +1,5 @@
numpy numpy
scipy
pandas pandas
matplotlib matplotlib
PySide6
+195 -123
View File
@@ -6,8 +6,10 @@ from copy import deepcopy
from pathlib import Path from pathlib import Path
import numpy as np 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 from materials import MATERIALI
@@ -34,31 +36,28 @@ def _spread_sorgenti_m(sorgente: dict) -> float:
return (numero_sorgenti - 1) * distanza return (numero_sorgenti - 1) * distanza
def _x_riferimento_iniziale_m(sorgente: dict) -> float: def _x_riferimento_iniziale_m(sorgente: dict, x_sensore_m: float) -> float:
# Posizione a t=0 della sorgente di indice 0 (quella più arretrata nel # Posizione a t=0 della sorgente di indice 0. Le sorgenti i sono a
# verso di marcia). x_inizio_m è la distanza dal sensore della sorgente # x_i = riferimento + i * distanza, quindi per v >= 0 l'indice 0 è la
# più avanzata (che quindi lo raggiunge per prima). # 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) spread = _spread_sorgenti_m(sorgente)
x_sensore = sorgente["x_sensore_m"]
x_inizio = sorgente["x_inizio_m"] x_inizio = sorgente["x_inizio_m"]
if sorgente["velocita_m_s"] >= 0: if sorgente["velocita_m_s"] >= 0:
return (x_sensore - x_inizio) - spread return (x_sensore_m - x_inizio) - spread
return (x_sensore + 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 # Posizione di fine corsa della sorgente di indice 0. x_fine_m è la
# distanza dal sensore della sorgente più arretrata (che quindi lo # distanza dal sensore della sorgente più arretrata nel verso di marcia
# supera per ultima). # (quella che lo supera per ultima).
x_sensore = sorgente["x_sensore_m"] spread = _spread_sorgenti_m(sorgente)
x_fine = sorgente["x_fine_m"] x_fine = sorgente["x_fine_m"]
if sorgente["velocita_m_s"] >= 0: if sorgente["velocita_m_s"] >= 0:
return x_sensore + x_fine return x_sensore_m + x_fine
return x_sensore - x_fine return (x_sensore_m - x_fine) - spread
def x_sorgente_al_tempo(sorgente: dict, t_s: float) -> float:
return _x_riferimento_iniziale_m(sorgente) + sorgente["velocita_m_s"] * t_s
def _intervallo_attivo(inizio: float, fine: float, v: float, x_m: float) -> bool: 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 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 # 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 # Ogni sorgente in moto ha un'impronta gaussiana lungo x, valutata sui
# ciascuna sorgente in moto. Più sorgenti equidistanti si muovono insieme # centri cella della sezione. L'offset circonferenziale y non è risolto
# come un gruppo rigido: condividono velocità, sigma e flusso di picco, e # spazialmente: entra come attenuazione gaussiana del flusso.
# sono sfalsate lungo x di un multiplo di "distanza_sorgenti_m". Il x_rif_iniziale = _x_riferimento_iniziale_m(sorgente, x_sensore_m)
# modello 1D vede solo il flusso lungo la linea che passa per il sensore x_rif_finale = _x_riferimento_finale_m(sorgente, x_sensore_m)
# 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)
x_riferimento = x_rif_iniziale + sorgente["velocita_m_s"] * t_s x_riferimento = x_rif_iniziale + sorgente["velocita_m_s"] * t_s
numero_sorgenti = sorgente.get("numero_sorgenti", 1) 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"] dy = sorgente["offset_y_percorso_m"]
sigma = sorgente["sigma_punto_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): for i in range(numero_sorgenti):
x_i = x_riferimento + i * distanza x_i = x_riferimento + i * distanza
@@ -98,74 +103,161 @@ 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): if not _intervallo_attivo(inizio_i, fine_i, v, x_i):
continue continue
dx = x_i - sorgente["x_sensore_m"] dx = x_centri_m - x_i
gaussiana = math.exp(-0.5 * (dx * dx + dy * dy) / (sigma * sigma)) q_x += q_picco * attenuazione_y * np.exp(-0.5 * (dx * dx) / (sigma * sigma))
q_totale += (
sorgente["flusso_termico_picco_W_m2"]
* sorgente["efficienza_riscaldamento"]
* gaussiana
)
return x_riferimento, q_totale return x_riferimento, q_x
def riscaldamento_volumetrico_W_m3( def profilo_deposizione_z_1_m(
q_superficie_W_m2: float,
z_centri_m: np.ndarray, z_centri_m: np.ndarray,
spessore_m: float, spessore_m: float,
skin_depth_m: float, skin_depth_m: float,
) -> np.ndarray: ) -> 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. # Normalizzato in modo che integrale_0^spessore p(z) dz = 1, così che
if q_superficie_W_m2 <= 0.0: # q_vol(x, z) = q(x) * p(z) conservi il flusso superficiale.
return np.zeros_like(z_centri_m)
delta = max(skin_depth_m, 1e-9) delta = max(skin_depth_m, 1e-9)
normalizzazione = delta * (1.0 - math.exp(-spessore_m / delta)) 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( def _laplaciano_1d(n: int) -> sp.spmatrix:
n: int, # 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, dt_s: float,
dx_m: float,
dz_m: float, dz_m: float,
spessore_m: float,
materiale: dict, materiale: dict,
h_caldo_W_m2K: float, h_esterno_W_m2K: float,
h_freddo_W_m2K: float, h_interno_W_m2K: float,
) -> np.ndarray: h_bordi_W_m2K: float,
# Costruisce la matrice A per Eulero implicito: ):
# Costruisce e fattorizza (LU sparsa) la matrice A per Eulero implicito 2D:
# A * T_next = rhs # 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. Il termine è ricavato
# 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"]
alpha = k / (rho * cp) alpha = k / (rho * cp)
r = alpha * dt_s / (dz_m * dz_m) r_x = alpha * dt_s / (dx_m * dx_m)
b_caldo = h_caldo_W_m2K * dt_s / (rho * cp * dz_m) r_z = alpha * dt_s / (dz_m * dz_m)
b_freddo = h_freddo_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_bordo = h_bordi_W_m2K * dt_s / (rho * cp * dx_m)
c_y = (h_esterno_W_m2K + h_interno_W_m2K) * dt_s / (rho * cp * spessore_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 = (
A[0, 0] = 1.0 + r + b_caldo sp.identity(n)
A[0, 1] = -r + 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. def prepara_stato_termico(fascetta: dict, aria: dict, sorgente: dict) -> dict:
A[n - 1, n - 2] = -r # Prepara griglia, coefficienti e solutore fattorizzato per un run:
A[n - 1, n - 1] = 1.0 + r + b_freddo # 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
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": (aria["h_esterno_W_m2K"] + aria["h_interno_W_m2K"]) * dt / (rho * cp * spessore),
"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,
spessore_m=spessore,
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"],
),
}
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: def quantizza(valore: float, passo: float) -> float:
@@ -175,7 +267,7 @@ def quantizza(valore: float, passo: float) -> float:
def configurazione_randomizzata(indice_run: int, rng: random.Random) -> dict: def configurazione_randomizzata(indice_run: int, rng: random.Random) -> dict:
piastra = deepcopy(PIASTRA) fascetta = deepcopy(FASCETTA)
aria = deepcopy(ARIA) aria = deepcopy(ARIA)
sorgente = deepcopy(SORGENTE) sorgente = deepcopy(SORGENTE)
sensore = deepcopy(SENSORE) sensore = deepcopy(SENSORE)
@@ -214,7 +306,7 @@ def configurazione_randomizzata(indice_run: int, rng: random.Random) -> dict:
return { return {
"id_run": f"run_{indice_run:04d}", "id_run": f"run_{indice_run:04d}",
"piastra": piastra, "fascetta": fascetta,
"aria": aria, "aria": aria,
"sorgente": sorgente, "sorgente": sorgente,
"sensore": sensore, "sensore": sensore,
@@ -222,48 +314,31 @@ def configurazione_randomizzata(indice_run: int, rng: random.Random) -> dict:
def simula_singolo(cfg_run: dict, output_csv: Path, 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"] aria = cfg_run["aria"]
sorgente = cfg_run["sorgente"] sorgente = cfg_run["sorgente"]
sensore = cfg_run["sensore"] sensore = cfg_run["sensore"]
nome_materiale = piastra["materiale"] nome_materiale = fascetta["materiale"]
materiale = MATERIALI[nome_materiale]
spessore = piastra["spessore_mm"] / 1000.0 stato = prepara_stato_termico(fascetta, aria, sorgente)
n = piastra["n_nodi"] n_x = stato["n_x"]
dz = spessore / n n_z = stato["n_z"]
z_centri = (np.arange(n) + 0.5) * dz 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"] durata = SIMULAZIONE["durata_s"]
periodo_campionamento = 1.0 / SIMULAZIONE["frequenza_campionamento_hz"] periodo_campionamento = 1.0 / SIMULAZIONE["frequenza_campionamento_hz"]
if sorgente["skin_depth_fissa_m"] is None: T = np.full((n_x, n_z), aria["temperatura_ambiente_C"], dtype=float)
skin_depth = calcola_skin_depth_m(materiale, sorgente["frequenza_hz"]) T_sensore = T[i_sensore, -1]
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]
prossimo_campione_t = 0.0 prossimo_campione_t = 0.0
T_vera_max = T[-1] T_vera_max = T[i_sensore, -1]
T_misurata_max = T_sensore T_misurata_max = T_sensore
output_csv.parent.mkdir(parents=True, exist_ok=True) output_csv.parent.mkdir(parents=True, exist_ok=True)
@@ -289,22 +364,14 @@ def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict:
t = 0.0 t = 0.0
while t <= durata + 1e-12: while t <= durata + 1e-12:
x_sorgente, q_superficie = flusso_termico_incidente_W_m2(sorgente, t) x_sorgente, q_x = profilo_flusso_incidente_W_m2(
q_vol = riscaldamento_volumetrico_W_m3( sorgente, x_sensore, t, x_centri
q_superficie_W_m2=q_superficie,
z_centri_m=z_centri,
spessore_m=spessore,
skin_depth_m=skin_depth,
) )
T = passo_implicito(stato, T, q_x)
rhs = T + dt * q_vol / (rho * cp) # Temperatura vera della superficie interna nel punto osservato
rhs[0] += b_caldo * aria["temperatura_ambiente_C"] # dal sensore infrarosso.
rhs[-1] += b_freddo * aria["temperatura_ambiente_C"] T_vera_lato_sensore = T[i_sensore, -1]
T = np.linalg.solve(A, rhs)
# Temperatura vera sul lato freddo, dove si trova il sensore fisso.
T_vera_lato_sensore = T[-1]
# Inerzia del sensore del primo ordine. # Inerzia del sensore del primo ordine.
tau_sensore = max(sensore["costante_tempo_s"], 1e-9) tau_sensore = max(sensore["costante_tempo_s"], 1e-9)
@@ -323,11 +390,11 @@ def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict:
f"{t:.6f}", f"{t:.6f}",
f"{x_sorgente:.9f}", f"{x_sorgente:.9f}",
f"{sorgente['offset_y_percorso_m']:.9f}", f"{sorgente['offset_y_percorso_m']:.9f}",
f"{q_superficie:.6f}", f"{q_x[i_sensore]:.6f}",
f"{skin_depth:.9e}", f"{skin_depth:.9e}",
f"{T_vera_lato_sensore:.6f}", f"{T_vera_lato_sensore:.6f}",
f"{misurata:.6f}", f"{misurata:.6f}",
f"{T[0]:.6f}", f"{T[i_sensore, 0]:.6f}",
f"{aria['temperatura_ambiente_C']:.6f}", f"{aria['temperatura_ambiente_C']:.6f}",
f"{sorgente['velocita_m_s']:.9f}", f"{sorgente['velocita_m_s']:.9f}",
f"{sorgente['sigma_punto_m']:.9f}", f"{sorgente['sigma_punto_m']:.9f}",
@@ -342,17 +409,22 @@ def simula_singolo(cfg_run: dict, output_csv: Path, rng: random.Random) -> dict:
"id_run": cfg_run["id_run"], "id_run": cfg_run["id_run"],
"file_csv": str(output_csv.name), "file_csv": str(output_csv.name),
"materiale": nome_materiale, "materiale": nome_materiale,
"spessore_m": spessore, "diametro_m": fascetta["diametro_mm"] / 1000.0,
"n_nodi": n, "lunghezza_m": fascetta["lunghezza_mm"] / 1000.0,
"spessore_m": fascetta["spessore_mm"] / 1000.0,
"n_nodi_x": n_x,
"n_nodi_z": n_z,
"durata_s": durata, "durata_s": durata,
"frequenza_campionamento_hz": SIMULAZIONE["frequenza_campionamento_hz"], "frequenza_campionamento_hz": SIMULAZIONE["frequenza_campionamento_hz"],
"dt_interno_s": dt, "dt_interno_s": dt,
"temperatura_ambiente_C": aria["temperatura_ambiente_C"], "temperatura_ambiente_C": aria["temperatura_ambiente_C"],
"h_caldo_W_m2K": aria["h_caldo_W_m2K"], "h_esterno_W_m2K": aria["h_esterno_W_m2K"],
"h_freddo_W_m2K": aria["h_freddo_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_inizio_m": sorgente["x_inizio_m"],
"x_fine_m": sorgente["x_fine_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"], "offset_y_percorso_m": sorgente["offset_y_percorso_m"],
"velocita_m_s": sorgente["velocita_m_s"], "velocita_m_s": sorgente["velocita_m_s"],
"numero_sorgenti": sorgente.get("numero_sorgenti", 1), "numero_sorgenti": sorgente.get("numero_sorgenti", 1),