16 Commits

Author SHA1 Message Date
davide 51bb8e5fc8 docs: aggiunge README approfondito con descrizione fisica, architettura e utilizzo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:35:08 +02:00
davide b6598fb7d8 fix: corregge segno Robin BC a x=0 (era + doveva essere -)
A x=0 la normale uscente è -x, quindi la condizione Robin corretta è
dT/dx - (h/k)(T-T_amb) = 0, speculare a x=L dove vale dT/dx + (h/k)(T-T_amb) = 0.
Il segno errato causava T(0,t) sotto T_amb (~12°C) con sorgente attiva.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:12:38 +02:00
davide 5a8cd3ef46 fix: normalizza x in forward() dividendo per L
Rende la normalizzazione degli input simmetrica: x/L e t/T_END
entrambi in [0,1], indipendentemente dal valore di L in config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:08:18 +02:00
davide f94fd51942 fix: corregge normalizzazione BC loss con _bc_scale analitico
Il termine convettivo H_CONV/K*(T-T_AMB) domina il residuo Robin di un
fattore H*L/K=10 rispetto al gradiente, rendendo L_bc ~100x sovrastimata
rispetto a L_pde. Sostituisce grad_char=(Q/K)² con _bc_scale=max(Q/K,
H*T_char/K)² (~2.25e6) per bilanciare correttamente i termini della loss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:07:49 +02:00
davide b8301a4329 test: aggiunge suite completa — unit, integration ed e2e (42 test)
- pytest.ini: configura testpaths, marker slow, output verboso
- tests/conftest.py: fixture condivise (device, small_data, pinn_model)
- tests/test_config.py: sanità parametri fisici e numerici, CFL, _pde_scale
- tests/test_model.py: HeatPINN.forward e heat_pinn_loss (shape, finiti,
  zero-weight analytici per IC e BC, scaling dei pesi)
- tests/test_engine_data.py: set_seed, _get_device, prepare_data
  (shape, bounds, device consistency, determinismo)
- tests/test_integration_pinn.py: pipeline dati→modello→loss→backward
- tests/test_e2e.py: FDM completo, visualizer FDM/PINN con tmp_path,
  training breve (2 test @slow)
- requirements.txt: aggiunge pytest>=7.0.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:50:54 +02:00
davide 1237a57290 clear.sh: aggiunge menu per ripulire risultati PINN oltre a FDM
Estrae la logica interattiva in cleanup_dir() e aggiunge un menu di primo
livello con le opzioni: FDM, PINN, Entrambi, Annulla.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:50:42 +02:00
davide e868c47190 PINN: normalizza t in forward(), corregge _pde_scale e aumenta patience
- forward(): divide t per T_END prima di passarlo alla rete, evita saturazione
  di Tanh per t∈[0,10] e migliora la sensibilità temporale del modello
- _pde_scale: include il picco gaussiano della sorgente come denominatore;
  con GAUSS_SIGMA=0.01 il picco (~60 °C/s) supera T_char/T_END (15), rendendo
  la loss PDE non normalizzata senza questa correzione
- PATIENCE 100→500, SCHED_PATIENCE 30→150: il training ha ora spazio per
  convergere prima che l'early stopping o lo scheduler blocchino l'ottimizzatore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:50:36 +02:00
davide 649d26cfd4 config: W_BC 1→5, N_F 4000→6000, GAUSS_SIGMA 0.02→0.01
W_BC=1 causava temperatura sub-ambiente (11.93°C < T_AMB=20°C) perché
i bordi erano poco vincolati; 5 è bilanciamento tra evitare trivial solution
(W_BC=10) e rispettare le BC di Robin (W_BC=1).
N_F aumentato per coprire meglio il dominio temporale tardo dove l'errore
cresceva (max 12.87°C a t=10s).
GAUSS_SIGMA ridotto per avvicinarsi alla sorgente puntuale del FDM e
ridurre il mismatch fisico che causava L2=11.6%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:21:48 +02:00
davide 256945ada3 PINN: ripristina forward() originale, aumenta LBFGS_STEPS e corregge pesi loss
Il training collassava alla soluzione banale T=T_AMB perché W_BC=10 spingeva
Adam a soddisfare le Robin BC (trivialmente, con gradiente zero) sacrificando
la PDE. Fix: W_PDE=10, W_BC=1 così la PDE domina il gradiente fin dal primo
epoch. LBFGS_STEPS: 20→200 perché L-BFGS era l'unico ottimizzatore a fare
progressi reali. forward(): rimossa moltiplicazione t_norm che causava
vanishing gradient su dT/dx e d²T/dx².

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:50:18 +02:00
davide f02c5f2bbe PINN: vincolo IC hard — moltiplica output per t_norm per evitare trivial solution
La normalizzazione introdotta (x,t in [0,1]²) rendeva il minimo banale
net=0 (T=T_AMB ovunque) troppo accessibile, causando il collasso del training.
Soluzione: T = T_AMB + T_char * (t/T_END) * net(x_norm, t_norm).
Così T(x,0) = T_AMB per costruzione (vincolo hard) e la rete deve trovare
soluzioni non banali per t>0. La loss IC resta ma è sempre 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:28:00 +02:00
davide 9e77deffd5 PINN: risolve problemi minori — sigma in config, scale precompilate, closure fuori loop
- config.py: aggiunge GAUSS_SIGMA = 0.02 nella sezione parametri fisici
- model.py: T_char, grad_char, pde_scale diventano costanti di modulo (_T_char,
  _grad_char, _pde_scale) calcolate una sola volta all'import
- engine.py: closure L-BFGS definita una volta sola fuori dal loop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:23:33 +02:00
davide bca829bd7e PINN: sposta model.train() fuori dal loop e aggiunge weights_only a torch.load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:21:17 +02:00
davide 98bfc78651 PINN: normalizza input in [0,1]² e ottimizza autograd
- forward(): divide (x,t) per (L, T_END) prima di passare alla rete,
  così le due dimensioni hanno la stessa scala indipendentemente da T_END
- heat_pinn_loss: calcola dT_dt e dT_dx in un singolo backward pass
  usando autograd.grad con lista [t_f, x_f]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:20:20 +02:00
davide fbb0458f69 PINN: allinea output a results/pinn/ e centralizza parametri in config
- visualizer.py: sostituisce animations/ con results/pinn/TIMESTAMP/,
  nomi fissi (heatmap.html, animation.html, comparison.html) come FDM
- config.py: aggiunge sezioni architettura, sampling, Adam, L-BFGS, loss weights
- model.py: costruisce HeatPINN dinamicamente da HIDDEN_SIZE/N_HIDDEN_LAYERS;
  heat_pinn_loss legge pesi W_PDE/W_IC/W_BC da config
- engine.py: tutti i parametri di training letti da config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:14:11 +02:00
davide 4f050e80df Estende scope di lavoro a tutto il repository
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:59:34 +02:00
davide b5553691e8 Merge branch 'fdm': output timestampato, heatmap animata, menu semplificato
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:19:50 +02:00
16 changed files with 1162 additions and 122 deletions
+1
View File
@@ -98,6 +98,7 @@ StyleCopReport.xml
*.svclog *.svclog
*.scc *.scc
.venv .venv
.venv*
# Chutzpah Test files # Chutzpah Test files
_Chutzpah* _Chutzpah*
+1 -1
View File
@@ -8,7 +8,7 @@ Write all git commit messages in Italian.
## Scope of Work ## Scope of Work
**Modifica solo il modulo `fdm/`.** Ignora qualsiasi richiesta riguardante PINN (`model.py`, `engine.py`, `visualizer.py`, `app.py` radice). Se una richiesta coinvolge file PINN, avvisa l'utente e non apportare modifiche. Lavora su tutto il repository: `fdm/`, `model.py`, `engine.py`, `visualizer.py`, `app.py`, `config.py`. Aiuta con migliorie, bugfix e ottimizzazioni su qualsiasi file del progetto.
## Project Overview ## Project Overview
+553
View File
@@ -0,0 +1,553 @@
# Heat Equation PINN
![Python](https://img.shields.io/badge/Python-3.10%2B-blue)
![PyTorch](https://img.shields.io/badge/PyTorch-2.0%2B-orange)
![Plotly](https://img.shields.io/badge/Plotly-5.15%2B-green)
![Tests](https://img.shields.io/badge/tests-42%20passing-brightgreen)
Soluzione dell'equazione del calore 1D con sorgente puntuale tramite **Physics-Informed Neural Network (PINN)**, validata contro un solver numerico **FDM** (Finite Difference Method).
---
## Indice
1. [Problema fisico](#1-problema-fisico)
2. [Approccio: PINN vs FDM](#2-approccio-pinn-vs-fdm)
3. [Struttura del progetto](#3-struttura-del-progetto)
4. [Installazione](#4-installazione)
5. [Utilizzo](#5-utilizzo)
6. [Architettura della rete neurale](#6-architettura-della-rete-neurale)
7. [Funzione di loss](#7-funzione-di-loss)
8. [Training](#8-training)
9. [Solver FDM di riferimento](#9-solver-fdm-di-riferimento)
10. [Visualizzazioni](#10-visualizzazioni)
11. [Parametri configurabili](#11-parametri-configurabili)
12. [Test](#12-test)
---
## 1. Problema fisico
Il progetto risolve l'equazione del calore 1D con una sorgente di calore puntuale interna che si attiva a un istante fissato:
```
∂T/∂t = α ∂²T/∂x² + (α/k) Q(t) δ(x X_SRC)
```
dove:
- `T(x, t)` — temperatura [°C]
- `α` — diffusività termica [m²/s]
- `k` — conducibilità termica [W/m·K]
- `Q(t) = Q_VAL` se `t ≥ T_STEP`, altrimenti `0` — flusso di calore [W/m²]
- `δ(x X_SRC)` — delta di Dirac nella posizione della sorgente
- Dominio: `x ∈ [0, L]`, `t ∈ [0, T_END]`
### Condizioni al contorno (Robin / convezione)
Entrambe le estremità della barra scambiano calore per convezione con l'ambiente:
```
x = 0: k ∂T/∂x = h (T T_AMB) → ∂T/∂x (h/k)(T T_AMB) = 0
x = L: k ∂T/∂x = h (T T_AMB) → ∂T/∂x + (h/k)(T T_AMB) = 0
```
### Condizione iniziale
```
T(x, 0) = T₀ (temperatura uniforme)
```
### Parametri fisici di default
| Parametro | Valore | Unità | Significato |
|--------------|---------|----------|------------------------------------------|
| `ALPHA` | 0.01 | m²/s | Diffusività termica |
| `K` | 1.0 | W/m·K | Conducibilità termica |
| `L` | 1.0 | m | Lunghezza della barra |
| `T0` | 20.0 | °C | Temperatura iniziale uniforme |
| `X_SRC` | 0.35 | m | Posizione della sorgente di calore |
| `Q_VAL` | 150.0 | W/m² | Intensità del flusso di calore |
| `T_STEP` | 0.2 | s | Istante di attivazione della sorgente |
| `H_CONV` | 10.0 | W/m²·K | Coefficiente convettivo alle estremità |
| `T_AMB` | 20.0 | °C | Temperatura ambiente |
| `T_END` | 10.0 | s | Fine della simulazione |
---
## 2. Approccio: PINN vs FDM
### PINN (Physics-Informed Neural Network)
Una PINN è una rete neurale che impara a soddisfare un'equazione differenziale alle derivate parziali **senza dati sperimentali**. L'addestramento avviene minimizzando una funzione di loss che penalizza le violazioni della PDE, delle condizioni iniziali e delle condizioni al contorno in un insieme di **punti di collocazione** distribuiti nel dominio.
Vantaggi:
- Non richiede una griglia regolare
- Può essere addestrata su domini irregolari
- La soluzione è una funzione continua e differenziabile ovunque
In questo progetto la loss ha tre componenti:
1. **Residuo PDE** — la rete deve soddisfare l'equazione del calore
2. **Condizione iniziale** — la rete deve restituire `T₀` per `t = 0`
3. **Condizioni al contorno** — la rete deve soddisfare le Robin BC a `x=0` e `x=L`
### FDM (Finite Difference Method)
Il solver FDM (`fdm/`) implementa lo schema **FTCS** (Forward-Time Centered-Space) esplicito su una griglia regolare `NX × NT`. Non usa reti neurali: è una soluzione numerica classica che serve come **riferimento ad alta fedeltà** per validare la PINN.
La valutazione della PINN consiste nel calcolare l'errore relativo L2 tra la predizione della rete e la soluzione FDM interpolata sulla stessa griglia `100 × 100`.
---
## 3. Struttura del progetto
```
pinn/
├── config.py ← tutti i parametri fisici e numerici (modificare qui)
├── model.py ← HeatPINN (rete neurale) + heat_pinn_loss()
├── engine.py ← campionamento dati, training, valutazione vs FDM
├── visualizer.py ← grafici PINN vs FDM: heatmap, animazione, serie temporali
├── app.py ← CLI interattiva per PINN
├── requirements.txt
├── pytest.ini
├── clear.sh ← script di pulizia artefatti
├── fdm/
│ ├── solver.py ← schema FTCS esplicito con Robin BC e sorgente puntuale
│ ├── visualizer.py ← grafici FDM standalone
│ └── app.py ← CLI interattiva per FDM
└── tests/
├── conftest.py
├── test_config.py
├── test_model.py
├── test_engine_data.py
├── test_fdm_solver.py
├── test_integration_pinn.py
└── test_e2e.py
```
| File | Responsabilità |
|--------------------|-------------------------------------------------------------------------|
| `config.py` | Unica fonte di verità per tutti i parametri |
| `model.py` | Definizione della rete e calcolo della loss fisica |
| `engine.py` | Pipeline completa: campionamento → training → valutazione |
| `visualizer.py` | Plot interattivi HTML (PINN vs FDM) |
| `app.py` | Menu CLI per l'utente |
| `fdm/solver.py` | Solver numerico FTCS per la soluzione di riferimento |
| `fdm/visualizer.py`| Plot interattivi HTML (FDM standalone) |
| `fdm/app.py` | Menu CLI per il solver FDM |
---
## 4. Installazione
**Prerequisiti:** Python 3.10+, `pip`, `virtualenv` (o `venv`).
```bash
git clone <repository-url>
cd pinn
python -m venv .venv
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\activate # Windows
pip install -r requirements.txt
```
Il progetto rileva automaticamente GPU/MPS/CPU all'avvio (vedi [engine.py — device detection](#12-dettagli-implementativi)).
---
## 5. Utilizzo
Attivare sempre il virtual environment prima di eseguire qualsiasi script:
```bash
source .venv/bin/activate
```
### PINN (`app.py`)
```bash
python app.py
```
```
1. Addestra nuovo modello
2. Valuta vs FDM (L2 error, max error)
3. Visualizza (genera 3 file HTML)
0. Esci
```
- **Opzione 1** — avvia l'addestramento (Adam + L-BFGS). Chiede il numero di epoche; premi Invio per usare il default (5000). Il modello migliore viene salvato in `models/best_heat_pinn_model.pth`. Per riaddestrare da zero: `rm models/best_heat_pinn_model.pth`.
- **Opzione 2** — carica il modello salvato, esegue il solver FDM, stampa l'errore relativo L2 e l'errore massimo assoluto.
- **Opzione 3** — genera tre file HTML interattivi in `results/pinn/<timestamp>/`.
### FDM (`fdm/app.py`)
```bash
python fdm/app.py
```
```
1. Risolvi (schema FTCS)
2. Visualizza (genera 3 file HTML)
0. Esci
```
- **Opzione 1** — esegue il solver e stampa shape della matrice e range di temperatura.
- **Opzione 2** — genera tre file HTML in `results/fdm/<timestamp>/`.
### Test
```bash
pytest tests/ -v # tutti i test (42)
pytest tests/test_model.py -v # rete e loss
pytest tests/test_engine_data.py -v # campionamento dati
pytest tests/test_fdm_solver.py -v # solver FDM
pytest tests/test_integration_pinn.py -v # integrazione PINN
pytest tests/test_e2e.py -v # workflow completo
```
### Pulizia artefatti
```bash
./clear.sh # menu interattivo per eliminare models/, results/ o entrambi
```
---
## 6. Architettura della rete neurale
`HeatPINN` ([model.py](model.py)) è una rete fully connected a 5 layer:
```
Input (x, t)
[Linear 2→128, Tanh]
[Linear 128→128, Tanh] ×3
[Linear 128→1]
Output: T
```
La rete riceve le coordinate `(x, t)` e produce un unico scalare: la temperatura `T(x, t)`.
### Normalizzazione dell'input
Prima di entrare nella rete, le coordinate vengono normalizzate al range `[0, 1]`:
```python
x_norm = x / L # x ∈ [0, 1]
t_norm = t / T_END # t ∈ [0, 1]
```
Questo migliora il condizionamento numerico dell'ottimizzazione.
### Output scaling
La rete interna `net` non predice direttamente la temperatura: predice una **perturbazione adimensionale**. La temperatura fisica viene ricostruita con:
```python
T = T_AMB + (Q_VAL * L / K) * net(x_norm, t_norm)
```
dove `T_char = Q_VAL * L / K ≈ 150 °C` è la scala caratteristica di temperatura del problema.
**Perché questo scaling?**
- L'output della rete rimane nell'ordine di `[0, 1]`, rendendo il training più stabile.
- I gradienti `∂T/∂x` risultanti sono `O(1)` — la rete può imparare la struttura spaziale senza problemi di scala.
- Il termine di fondo `T_AMB` garantisce che la soluzione parta dalla condizione iniziale corretta anche con pesi random.
**Non rimuovere questo scaling**: senza di esso la rete deve imparare ordini di grandezza diversi tra le condizioni iniziali/al contorno e il gradiente interno, rendendo l'ottimizzazione molto più difficile.
---
## 7. Funzione di loss
`heat_pinn_loss()` ([model.py](model.py)) calcola quattro valori: `(total, L_pde, L_ic, L_bc)`.
```
total = W_PDE * L_pde + W_IC * L_ic + W_BC * L_bc
```
Ogni termine è **normalizzato automaticamente** da scale precompilate che dipendono solo dalle costanti in `config.py`. Cambiare `Q_VAL`, `K`, `H_CONV` o `L` ribilancia automaticamente la loss senza richiedere rituning manuale dei pesi.
### L_pde — Residuo PDE
Valutato su `N_F` punti di collocazione `(x_f, t_f)` distribuiti nel dominio:
```
residuo = dT/dt α d²T/dx² source(x, t)
```
Il termine sorgente usa un'approssimazione gaussiana al delta di Dirac (il delta non è differenziabile):
```
source(x, t) = (α/k) · Q(t) · G(x)
G(x) = exp(0.5 · ((x X_SRC) / σ)²) / (σ √(2π)) σ = GAUSS_SIGMA = 0.01 m
```
`Q(t)` è la funzione gradino: vale `Q_VAL` per `t ≥ T_STEP`, zero altrimenti.
I gradienti `dT/dt`, `dT/dx`, `d²T/dx²` sono calcolati con `torch.autograd.grad`**autodifferenziazione esatta**, non differenze finite.
Normalizzazione: `_pde_scale = max((T_char/T_END)², src_peak²)` dove `src_peak` è il picco gaussiano.
### L_ic — Condizione iniziale
Valutato su `N_IC` punti `(x_ic, 0)`:
```
L_ic = mean( (T(x, 0) T₀)² ) / T_char²
```
### L_bc — Condizioni al contorno Robin
Valutato su `N_BC` istanti temporali `t_bc`, applicato a entrambe le estremità:
```
x = 0: ∂T/∂x(0, t) (h/k)(T(0,t) T_AMB) = 0
x = L: ∂T/∂x(L, t) + (h/k)(T(L,t) T_AMB) = 0
```
Normalizzazione: `_bc_scale = max(Q_VAL/K, H_CONV·T_char/K)²`
> **Nota sul segno:** la BC a sinistra (x=0) ha segno negativo davanti al termine convettivo perché il flusso uscente è orientato verso `x`; a destra (x=L) il segno è positivo perché il flusso uscente è orientato verso `+x`.
---
## 8. Training
Il training è implementato in `train_model()` ([engine.py](engine.py)) e procede in due fasi.
### Fase 1: Adam
```
Ottimizzatore: Adam, LR = 1e-3
Scheduler: ReduceLROnPlateau (factor=0.5, patience=150, min_lr=1e-6)
Early stopping: se la loss non migliora di > 1e-7 per 500 epoche consecutive
```
Il modello con la loss più bassa viene salvato a ogni miglioramento in `models/best_heat_pinn_model.pth`.
### Fase 2: L-BFGS (fine-tuning)
Al termine dell'Adam, viene caricato il miglior modello e affinato con L-BFGS:
```
Ottimizzatore: L-BFGS, LR=0.1, max_iter=50, history_size=50, strong Wolfe
Steps: 200
```
L-BFGS è un ottimizzatore di secondo ordine (quasi-Newton) particolarmente efficace nella fase finale del training PINN perché sfrutta la curvatura della loss per convergere a minimi più precisi di quanto Adam riesca a fare.
**Meccanismo closure:** L-BFGS richiede di poter rivalutare la loss più volte per iterazione. La funzione `closure()` cattura i componenti della loss in un dizionario `_last` per poterli stampare senza ricalcolare il grafo computazionale fuori dal contesto di `backward()`.
### Campionamento dei punti di collocazione
`prepare_data()` genera i punti di collocazione con **clustering deliberato** nelle zone fisicamente più complesse:
| Zona | Proporzione | Motivazione |
|-----------------------------|-------------|------------------------------------------------|
| Uniforme `[0,L] × [0,T_END]`| 50% | Copertura generale del dominio |
| Intorno a `X_SRC ± 5% L` | 25% | Gradiente ripido in prossimità della sorgente |
| Intorno a `T_STEP ± 0.1 s` | 25% | Discontinuità temporale all'attivazione |
Il clustering aumenta la densità di punti dove la fisica è più difficile da apprendere, senza aumentare il costo computazionale totale.
---
## 9. Solver FDM di riferimento
`fdm/solver.py` implementa lo schema **FTCS** (Forward-Time Centered-Space) esplicito.
### Schema di avanzamento temporale
```
T[i, n+1] = T[i, n] + r · (T[i+1,n] 2·T[i,n] + T[i1,n])
r = α·dt/dx² (numero di Courant-Friedrichs-Lewy)
```
### Stabilità CFL
Condizione necessaria per la stabilità dello schema esplicito:
```
r = α·dt/dx² ≤ 0.5
```
Se la condizione è violata, il solver stampa un avvertimento ma non si blocca. Con i parametri di default (`NX=250`, `NT=15000`) la condizione è soddisfatta. Se si riducono `NX` o `NT`, verificare che `r ≤ 0.5`.
### Condizioni al contorno Robin
Le BC sono applicate a ogni passo temporale usando uno schema centrato:
```
T[0] = (T[1] + robin_coeff · T_AMB) / (1 + robin_coeff) # x = 0
T[-1] = (T[-2] + robin_coeff · T_AMB) / (1 + robin_coeff) # x = L
robin_coeff = dx · h / k
```
### Iniezione della sorgente puntuale
Dopo l'applicazione delle BC, la sorgente viene iniettata al nodo più vicino a `X_SRC`:
```
T[i_src] += Q(t) · α · dt / (k · dx)
```
---
## 10. Visualizzazioni
Tutti i plot sono **HTML interattivi** generati con Plotly (zoom, hover, slider, play/pause).
### PINN vs FDM (`visualizer.py`)
Generati con `python app.py → opzione 3`, salvati in `results/pinn/<timestamp>/`:
| File | Contenuto |
|-------------------|------------------------------------------------------------------------|
| `heatmap.html` | Heatmap 2D affiancate PINN vs FDM, stessa scala colori |
| `animation.html` | Profilo T(x) animato nel tempo: PINN (blu continuo) vs FDM (rosso tratteggiato) |
| `comparison.html` | Serie temporali T(t) nei punti fissi `x=0`, `x=L/2`, `x=L`; linea verticale a `t=T_STEP` |
### FDM standalone (`fdm/visualizer.py`)
Generati con `python fdm/app.py → opzione 2`, salvati in `results/fdm/<timestamp>/`:
| File | Contenuto |
|-------------------|------------------------------------------------------------------------|
| `heatmap.html` | Heatmap 2D T(x,t) + striscia animata del profilo di temperatura |
| `animation.html` | Profilo T(x) animato nel tempo |
| `time_series.html`| Serie temporali in 5 punti fissi: `0`, `0.25L`, `0.5L`, `0.75L`, `L` |
---
## 11. Parametri configurabili
Tutti i parametri si trovano in `config.py`. Modificare solo questo file per cambiare il problema o il training.
### Fisica
| Parametro | Default | Unità | Descrizione |
|--------------|---------|--------|------------------------------------------|
| `ALPHA` | 0.01 | m²/s | Diffusività termica |
| `K` | 1.0 | W/m·K | Conducibilità termica |
| `L` | 1.0 | m | Lunghezza della barra |
| `T0` | 20.0 | °C | Temperatura iniziale uniforme |
| `X_SRC` | 0.35 | m | Posizione della sorgente di calore |
| `Q_VAL` | 150.0 | W/m² | Intensità del flusso di calore |
| `T_STEP` | 0.2 | s | Istante di attivazione della sorgente |
| `H_CONV` | 10.0 | W/m²·K | Coefficiente convettivo alle estremità |
| `T_AMB` | 20.0 | °C | Temperatura ambiente |
| `T_END` | 10.0 | s | Fine della simulazione |
### Griglia FDM
| Parametro | Default | Descrizione |
|--------------|---------|---------------------------------------------------------|
| `NX` | 250 | Nodi spaziali (aumentare per maggiore risoluzione) |
| `NT` | 15000 | Passi temporali (verificare `r = α·dt/dx² ≤ 0.5`) |
| `GAUSS_SIGMA`| 0.01 | Larghezza del picco gaussiano nella loss PINN [m] |
### Architettura PINN
| Parametro | Default | Descrizione |
|-------------------|---------|----------------------------------------------|
| `HIDDEN_SIZE` | 128 | Neuroni per layer nascosto |
| `N_HIDDEN_LAYERS` | 4 | Numero di layer nascosti (totale: 5 layer) |
### Campionamento
| Parametro | Default | Descrizione |
|-----------|---------|--------------------------------------------------------------------|
| `N_F` | 6000 | Punti PDE (+ 50% clustering automatico vicino a X_SRC e T_STEP) |
| `N_IC` | 400 | Punti condizione iniziale |
| `N_BC` | 400 | Punti condizioni al contorno |
### Training Adam
| Parametro | Default | Descrizione |
|-----------------|---------|-----------------------------------------------------|
| `EPOCHS` | 5000 | Epoche massime |
| `PATIENCE` | 500 | Early stopping: epoche senza miglioramento |
| `LR_ADAM` | 1e-3 | Learning rate iniziale |
| `SCHED_FACTOR` | 0.5 | Fattore di riduzione LR (ReduceLROnPlateau) |
| `SCHED_PATIENCE`| 150 | Patience per la riduzione LR |
| `SCHED_MIN_LR` | 1e-6 | Learning rate minimo |
### Fine-tuning L-BFGS
| Parametro | Default | Descrizione |
|---------------|---------|--------------------------|
| `LR_LBFGS` | 0.1 | Learning rate L-BFGS |
| `LBFGS_STEPS` | 200 | Numero di step L-BFGS |
### Pesi della loss
| Parametro | Default | Descrizione |
|-----------|---------|--------------------------------|
| `W_PDE` | 10.0 | Peso residuo PDE |
| `W_IC` | 1.0 | Peso condizione iniziale |
| `W_BC` | 5.0 | Peso condizioni al contorno |
> **Se la loss diverge:** verificare che `T_char = Q_VAL * L / K` non sia vicino a zero. Questo valore è la scala caratteristica di temperatura usata per normalizzare tutti i termini.
---
## 12. Test
La test suite è in `tests/` e conta **42 test** organizzati in tre livelli:
| File | Tipo | Cosa testa |
|------------------------------|-------------|---------------------------------------------------|
| `test_config.py` | Unit | Validità e coerenza dei parametri in `config.py` |
| `test_model.py` | Unit | Shape output, finitezza, loss components |
| `test_engine_data.py` | Unit | Campionamento e clustering dei punti |
| `test_fdm_solver.py` | Unit | Griglia, CFL, shape output del solver FDM |
| `test_integration_pinn.py` | Integration | Caricamento modello, griglia predizione, pipeline loss |
| `test_e2e.py` | End-to-end | Workflow completo: train → evaluate → visualize |
```bash
pytest tests/ -v # run tutti i test
pytest tests/ -k "model" # solo test con "model" nel nome
pytest tests/ --tb=short # traceback breve in caso di fallimento
```
---
## Dettagli implementativi
### Normalizzazione automatica della loss
Le scale sono precompilate una sola volta a import time in `model.py`:
```python
_T_char = Q_VAL * L / K # ~150 °C
_src_peak = ALPHA * Q_VAL / (K * GAUSS_SIGMA * sqrt(2π))
_pde_scale = max((_T_char / T_END)², _src_peak²) + 1e-8
_bc_scale = max(Q_VAL / K, H_CONV * _T_char / K) ** 2
```
Dividere ogni termine per la sua scala porta tutti i contributi a `O(1)`, rendendo i pesi `W_PDE`, `W_IC`, `W_BC` interpretabili come importanza relativa piuttosto che come fattori di scala assoluti.
### Rilevamento device
`engine.py` seleziona automaticamente il device più performante disponibile:
```python
CUDA MPS (Apple Silicon) CPU
```
Include un test di funzionamento effettivo della GPU prima di usarla, per evitare fallimenti silenziosi su driver incompleti.
### Closure L-BFGS
L-BFGS richiede una funzione `closure()` che esegue `zero_grad`, forward pass, `backward`, e restituisce la loss. I componenti della loss vengono catturati in un dizionario `_last` per permettere il logging a ogni step senza ricalcolare il grafo fuori dal contesto `backward`.
### Subsampling delle animazioni FDM
Se `NT > 200`, il visualizer FDM campiona ogni `n`-esimo frame (`n = NT // 200`) per mantenere le animazioni HTML leggere e fluide.
+49 -25
View File
@@ -1,37 +1,42 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
RESULTS_DIR="$(dirname "$0")/results/fdm" BASE_DIR="$(dirname "$0")"
if [[ ! -d "$RESULTS_DIR" ]]; then cleanup_dir() {
echo "Nessuna cartella results/fdm trovata." local LABEL="$1"
exit 0 local DIR="$2"
fi
mapfile -t RUNS < <(find "$RESULTS_DIR" -mindepth 1 -maxdepth 1 -type d | sort) if [[ ! -d "$DIR" ]]; then
echo "Nessuna cartella $DIR trovata."
return
fi
if [[ ${#RUNS[@]} -eq 0 ]]; then mapfile -t RUNS < <(find "$DIR" -mindepth 1 -maxdepth 1 -type d | sort)
echo "Nessun risultato da cancellare."
exit 0
fi
echo "Risultati disponibili:" if [[ ${#RUNS[@]} -eq 0 ]]; then
for i in "${!RUNS[@]}"; do echo "Nessun risultato $LABEL da cancellare."
return
fi
echo ""
echo "Risultati $LABEL disponibili:"
for i in "${!RUNS[@]}"; do
printf " %2d. %s\n" "$((i+1))" "$(basename "${RUNS[$i]}")" printf " %2d. %s\n" "$((i+1))" "$(basename "${RUNS[$i]}")"
done done
echo "" echo ""
echo "a. Cancella tutti" echo "a. Cancella tutti"
echo "s. Selezione manuale" echo "s. Selezione manuale"
echo "0. Annulla" echo "0. Annulla"
echo "" echo ""
read -rp "Scelta: " MODE read -rp "Scelta [$LABEL]: " MODE
case "$MODE" in case "$MODE" in
a|A) a|A)
read -rp "Confermi la cancellazione di ${#RUNS[@]} cartelle? [s/N] " CONFIRM read -rp "Confermi la cancellazione di ${#RUNS[@]} cartelle $LABEL? [s/N] " CONFIRM
if [[ "${CONFIRM,,}" == "s" ]]; then if [[ "${CONFIRM,,}" == "s" ]]; then
rm -rf "${RUNS[@]}" rm -rf "${RUNS[@]}"
echo "Cancellati ${#RUNS[@]} risultati." echo "Cancellati ${#RUNS[@]} risultati $LABEL."
else else
echo "Annullato." echo "Annullato."
fi fi
@@ -48,7 +53,7 @@ case "$MODE" in
done done
if [[ ${#TO_DELETE[@]} -eq 0 ]]; then if [[ ${#TO_DELETE[@]} -eq 0 ]]; then
echo "Nessuna selezione valida." echo "Nessuna selezione valida."
exit 0 return
fi fi
echo "Verranno cancellati:" echo "Verranno cancellati:"
for D in "${TO_DELETE[@]}"; do for D in "${TO_DELETE[@]}"; do
@@ -57,7 +62,7 @@ case "$MODE" in
read -rp "Confermi? [s/N] " CONFIRM read -rp "Confermi? [s/N] " CONFIRM
if [[ "${CONFIRM,,}" == "s" ]]; then if [[ "${CONFIRM,,}" == "s" ]]; then
rm -rf "${TO_DELETE[@]}" rm -rf "${TO_DELETE[@]}"
echo "Cancellati ${#TO_DELETE[@]} risultati." echo "Cancellati ${#TO_DELETE[@]} risultati $LABEL."
else else
echo "Annullato." echo "Annullato."
fi fi
@@ -67,6 +72,25 @@ case "$MODE" in
;; ;;
*) *)
echo "Scelta non valida." echo "Scelta non valida."
exit 1
;; ;;
esac
}
echo "Cosa vuoi ripulire?"
echo " 1. Risultati FDM (results/fdm/)"
echo " 2. Risultati PINN (results/pinn/)"
echo " 3. Entrambi"
echo " 0. Annulla"
echo ""
read -rp "Scelta: " CHOICE
case "$CHOICE" in
1) cleanup_dir "FDM" "$BASE_DIR/results/fdm" ;;
2) cleanup_dir "PINN" "$BASE_DIR/results/pinn" ;;
3)
cleanup_dir "FDM" "$BASE_DIR/results/fdm"
cleanup_dir "PINN" "$BASE_DIR/results/pinn"
;;
0|"") echo "Annullato." ;;
*) echo "Scelta non valida." ; exit 1 ;;
esac esac
+29
View File
@@ -19,3 +19,32 @@ T_END = 10.0 # fine simulazione [s]
# Griglia FDM # Griglia FDM
NX = 250 # nodi spaziali NX = 250 # nodi spaziali
NT = 15000 # passi temporali (verifica CFL automatica) NT = 15000 # passi temporali (verifica CFL automatica)
# Sorgente gaussiana (approssimazione continua del delta di Dirac)
GAUSS_SIGMA = 0.01 # larghezza del picco gaussiano [m]
# Architettura PINN
HIDDEN_SIZE = 128 # neuroni per layer nascosto
N_HIDDEN_LAYERS = 4 # numero di layer nascosti
# Sampling punti di collocazione
N_F = 6000 # punti PDE (+ 50% clustering automatico vicino a X_SRC e T_STEP)
N_IC = 400 # punti condizione iniziale
N_BC = 400 # punti condizioni al contorno
# Training Adam
EPOCHS = 5000 # epoche massime
PATIENCE = 500 # early stopping
LR_ADAM = 1e-3 # learning rate iniziale
SCHED_FACTOR = 0.5 # ReduceLROnPlateau: fattore di riduzione
SCHED_PATIENCE = 150 # ReduceLROnPlateau: patience
SCHED_MIN_LR = 1e-6 # ReduceLROnPlateau: lr minimo
# Fine-tuning L-BFGS
LR_LBFGS = 0.1 # learning rate L-BFGS
LBFGS_STEPS = 200 # numero di step L-BFGS
# Pesi della loss
W_PDE = 10.0 # peso residuo PDE
W_IC = 1.0 # peso condizione iniziale
W_BC = 5.0 # peso condizioni al contorno
+18 -10
View File
@@ -35,7 +35,10 @@ def _get_device():
return torch.device('cpu') return torch.device('cpu')
def prepare_data(N_f=4000, N_ic=400, N_bc=400): def prepare_data(N_f=None, N_ic=None, N_bc=None):
if N_f is None: N_f = config.N_F
if N_ic is None: N_ic = config.N_IC
if N_bc is None: N_bc = config.N_BC
set_seed(42) set_seed(42)
device = _get_device() device = _get_device()
@@ -61,19 +64,24 @@ def prepare_data(N_f=4000, N_ic=400, N_bc=400):
return {'device': device, 'x_f': x_f, 't_f': t_f, 'x_ic': x_ic, 't_bc': t_bc} return {'device': device, 'x_f': x_f, 't_f': t_f, 'x_ic': x_ic, 't_bc': t_bc}
def train_model(data, epochs=5000, patience=100): def train_model(data, epochs=None, patience=None):
if epochs is None: epochs = config.EPOCHS
if patience is None: patience = config.PATIENCE
device = data['device'] device = data['device']
model = HeatPINN().to(device) model = HeatPINN().to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-3) optimizer = optim.Adam(model.parameters(), lr=config.LR_ADAM)
scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=30, min_lr=1e-6) scheduler = ReduceLROnPlateau(optimizer, mode='min',
factor=config.SCHED_FACTOR,
patience=config.SCHED_PATIENCE,
min_lr=config.SCHED_MIN_LR)
os.makedirs(MODELS_DIR, exist_ok=True) os.makedirs(MODELS_DIR, exist_ok=True)
best_loss = float('inf') best_loss = float('inf')
patience_counter = 0 patience_counter = 0
print(f"\n--- Heat PINN Training (Adam) on {device} ---") print(f"\n--- Heat PINN Training (Adam) on {device} ---")
for epoch in range(epochs):
model.train() model.train()
for epoch in range(epochs):
optimizer.zero_grad() optimizer.zero_grad()
loss, L_pde, L_ic, L_bc = heat_pinn_loss( loss, L_pde, L_ic, L_bc = heat_pinn_loss(
model, data['x_f'], data['t_f'], data['x_ic'], data['t_bc'] model, data['x_f'], data['t_f'], data['x_ic'], data['t_bc']
@@ -101,15 +109,14 @@ def train_model(data, epochs=5000, patience=100):
# L-BFGS fine-tuning phase (standard PINN practice for convergence to better minima) # L-BFGS fine-tuning phase (standard PINN practice for convergence to better minima)
print("\n--- L-BFGS fine-tuning ---") print("\n--- L-BFGS fine-tuning ---")
ckpt = torch.load(MODEL_SAVE_PATH, map_location=device) ckpt = torch.load(MODEL_SAVE_PATH, map_location=device, weights_only=True)
model.load_state_dict(ckpt['state_dict']) model.load_state_dict(ckpt['state_dict'])
lbfgs = optim.LBFGS(model.parameters(), lr=0.1, max_iter=50, lbfgs = optim.LBFGS(model.parameters(), lr=config.LR_LBFGS, max_iter=50,
history_size=50, tolerance_grad=1e-7, line_search_fn='strong_wolfe') history_size=50, tolerance_grad=1e-7, line_search_fn='strong_wolfe')
_last = {} _last = {}
for step in range(20):
def closure(): def closure():
lbfgs.zero_grad() lbfgs.zero_grad()
loss, L_pde, L_ic, L_bc = heat_pinn_loss( loss, L_pde, L_ic, L_bc = heat_pinn_loss(
@@ -122,12 +129,13 @@ def train_model(data, epochs=5000, patience=100):
_last['bc'] = L_bc.item() _last['bc'] = L_bc.item()
return loss return loss
for step in range(config.LBFGS_STEPS):
lbfgs.step(closure) lbfgs.step(closure)
if _last['loss'] < best_loss: if _last['loss'] < best_loss:
best_loss = _last['loss'] best_loss = _last['loss']
torch.save({'state_dict': model.state_dict()}, MODEL_SAVE_PATH) torch.save({'state_dict': model.state_dict()}, MODEL_SAVE_PATH)
if (step + 1) % 5 == 0: if (step + 1) % 5 == 0:
print(f"L-BFGS step {step+1}/20 | Loss: {_last['loss']:.6f} " print(f"L-BFGS step {step+1}/{config.LBFGS_STEPS} | Loss: {_last['loss']:.6f} "
f"| PDE: {_last['pde']:.6f} | IC: {_last['ic']:.6f} | BC: {_last['bc']:.6f}") f"| PDE: {_last['pde']:.6f} | IC: {_last['ic']:.6f} | BC: {_last['bc']:.6f}")
print("Training complete! Model saved.") print("Training complete! Model saved.")
@@ -137,7 +145,7 @@ def _load_model(device):
if not os.path.exists(MODEL_SAVE_PATH): if not os.path.exists(MODEL_SAVE_PATH):
print("Error: model not found. Train the model first.") print("Error: model not found. Train the model first.")
return None return None
ckpt = torch.load(MODEL_SAVE_PATH, map_location=device) ckpt = torch.load(MODEL_SAVE_PATH, map_location=device, weights_only=True)
model = HeatPINN().to(device) model = HeatPINN().to(device)
model.load_state_dict(ckpt['state_dict']) model.load_state_dict(ckpt['state_dict'])
model.eval() model.eval()
+32 -24
View File
@@ -6,58 +6,66 @@ import config
class HeatPINN(nn.Module): class HeatPINN(nn.Module):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.net = nn.Sequential( h = config.HIDDEN_SIZE
nn.Linear(2, 128), nn.Tanh(), layers = [nn.Linear(2, h), nn.Tanh()]
nn.Linear(128, 128), nn.Tanh(), for _ in range(config.N_HIDDEN_LAYERS - 1):
nn.Linear(128, 128), nn.Tanh(), layers += [nn.Linear(h, h), nn.Tanh()]
nn.Linear(128, 128), nn.Tanh(), layers.append(nn.Linear(h, 1))
nn.Linear(128, 1), self.net = nn.Sequential(*layers)
)
def forward(self, x): def forward(self, xt):
# Output scaled to physical range: T_AMB + (Q*L/K) * net x = xt[:, :1] / config.L
# net learns dimensionless perturbation in [0,1] range t = xt[:, 1:] / config.T_END
T_scale = config.T_AMB + (config.Q_VAL * config.L / config.K) * self.net(x) return config.T_AMB + (config.Q_VAL * config.L / config.K) * self.net(torch.cat([x, t], dim=1))
return T_scale
def heat_pinn_loss(model, x_f, t_f, x_ic, t_bc, w_pde=1.0, w_ic=1.0, w_bc=10.0): # Precomputed loss scales (depend only on config constants)
# Characteristic scales for normalization _T_char = config.Q_VAL * config.L / config.K # ~150 °C — temperature scale
T_char = config.Q_VAL * config.L / config.K # ~50 °C — temperature scale # _pde_scale covers both dT/dt and the Gaussian source peak (dominant with small sigma)
grad_char = (config.Q_VAL / config.K) ** 2 # ~2500 — gradient scale² _src_peak = config.ALPHA * config.Q_VAL / (config.K * config.GAUSS_SIGMA * (2 * 3.141592653589793) ** 0.5)
_pde_scale = max((_T_char / config.T_END) ** 2, _src_peak ** 2) + 1e-8
# Robin BC residual scale: max(dT/dx, H_CONV/K * T_char) — convective term dominates when H*L/K >> 1
_bc_scale = max(config.Q_VAL / config.K,
config.H_CONV * _T_char / config.K) ** 2
def heat_pinn_loss(model, x_f, t_f, x_ic, t_bc,
w_pde=None, w_ic=None, w_bc=None):
if w_pde is None: w_pde = config.W_PDE
if w_ic is None: w_ic = config.W_IC
if w_bc is None: w_bc = config.W_BC
T_char = _T_char
# PDE residual: dT/dt - alpha * d2T/dx2 - source(x,t) = 0 (normalized by T_char/t_char) # PDE residual: dT/dt - alpha * d2T/dx2 - source(x,t) = 0 (normalized by T_char/t_char)
x_f = x_f.detach().requires_grad_(True) x_f = x_f.detach().requires_grad_(True)
t_f = t_f.detach().requires_grad_(True) t_f = t_f.detach().requires_grad_(True)
T_f = model(torch.stack([x_f, t_f], dim=1)) T_f = model(torch.stack([x_f, t_f], dim=1))
dT_dt = torch.autograd.grad(T_f.sum(), t_f, create_graph=True)[0] dT_dt, dT_dx = torch.autograd.grad(T_f.sum(), [t_f, x_f], create_graph=True)
dT_dx = torch.autograd.grad(T_f.sum(), x_f, create_graph=True)[0]
d2T_dx2 = torch.autograd.grad(dT_dx.sum(), x_f, create_graph=True)[0] d2T_dx2 = torch.autograd.grad(dT_dx.sum(), x_f, create_graph=True)[0]
sigma = 0.02
Q_t_f = torch.where(t_f >= config.T_STEP, Q_t_f = torch.where(t_f >= config.T_STEP,
torch.tensor(config.Q_VAL, device=t_f.device, dtype=t_f.dtype), torch.tensor(config.Q_VAL, device=t_f.device, dtype=t_f.dtype),
torch.tensor(0.0, device=t_f.device, dtype=t_f.dtype)) torch.tensor(0.0, device=t_f.device, dtype=t_f.dtype))
sigma = config.GAUSS_SIGMA
gauss = torch.exp(-0.5 * ((x_f - config.X_SRC) / sigma) ** 2) / (sigma * (2 * torch.pi) ** 0.5) gauss = torch.exp(-0.5 * ((x_f - config.X_SRC) / sigma) ** 2) / (sigma * (2 * torch.pi) ** 0.5)
source_term = (config.ALPHA / config.K) * Q_t_f * gauss source_term = (config.ALPHA / config.K) * Q_t_f * gauss
pde_scale = (T_char / config.T_END) ** 2 + 1e-8 L_pde = ((dT_dt - config.ALPHA * d2T_dx2 - source_term) ** 2).mean() / _pde_scale
L_pde = ((dT_dt - config.ALPHA * d2T_dx2 - source_term) ** 2).mean() / pde_scale
# IC: T(x, 0) = T0 — normalized by T_char² # IC: T(x, 0) = T0 — normalized by T_char²
T_ic_pred = model(torch.stack([x_ic, torch.zeros_like(x_ic)], dim=1)) T_ic_pred = model(torch.stack([x_ic, torch.zeros_like(x_ic)], dim=1))
T_ic_true = torch.full_like(T_ic_pred, config.T0) T_ic_true = torch.full_like(T_ic_pred, config.T0)
L_ic = ((T_ic_pred - T_ic_true) ** 2).mean() / (T_char ** 2 + 1e-8) L_ic = ((T_ic_pred - T_ic_true) ** 2).mean() / (_T_char ** 2 + 1e-8)
# BC x=0: Robin — dT/dx + H_CONV/K * (T(0,t) - T_AMB) = 0 # BC x=0: Robin — dT/dx + H_CONV/K * (T(0,t) - T_AMB) = 0
x_left = torch.zeros(t_bc.shape[0], device=t_bc.device).requires_grad_(True) x_left = torch.zeros(t_bc.shape[0], device=t_bc.device).requires_grad_(True)
T_left = model(torch.stack([x_left, t_bc.detach()], dim=1)) T_left = model(torch.stack([x_left, t_bc.detach()], dim=1))
dT_dx_left = torch.autograd.grad(T_left.sum(), x_left, create_graph=True)[0] dT_dx_left = torch.autograd.grad(T_left.sum(), x_left, create_graph=True)[0]
L_bc_left = ((dT_dx_left + (config.H_CONV / config.K) * (T_left.squeeze() - config.T_AMB)) ** 2).mean() / grad_char L_bc_left = ((dT_dx_left - (config.H_CONV / config.K) * (T_left.squeeze() - config.T_AMB)) ** 2).mean() / _bc_scale
# BC x=L: Robin — dT/dx + H_CONV/K * (T(L,t) - T_AMB) = 0 # BC x=L: Robin — dT/dx + H_CONV/K * (T(L,t) - T_AMB) = 0
x_right = torch.full((t_bc.shape[0],), config.L, device=t_bc.device).requires_grad_(True) x_right = torch.full((t_bc.shape[0],), config.L, device=t_bc.device).requires_grad_(True)
T_right = model(torch.stack([x_right, t_bc.detach()], dim=1)) T_right = model(torch.stack([x_right, t_bc.detach()], dim=1))
dT_dx_right = torch.autograd.grad(T_right.sum(), x_right, create_graph=True)[0] dT_dx_right = torch.autograd.grad(T_right.sum(), x_right, create_graph=True)[0]
L_bc_right = ((dT_dx_right + (config.H_CONV / config.K) * (T_right.squeeze() - config.T_AMB)) ** 2).mean() / grad_char L_bc_right = ((dT_dx_right + (config.H_CONV / config.K) * (T_right.squeeze() - config.T_AMB)) ** 2).mean() / _bc_scale
L_bc = L_bc_left + L_bc_right L_bc = L_bc_left + L_bc_right
total = w_pde * L_pde + w_ic * L_ic + w_bc * L_bc total = w_pde * L_pde + w_ic * L_ic + w_bc * L_bc
+5
View File
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
addopts = -v --tb=short
markers =
slow: test lenti (training completo) — escludi con -m "not slow"
+1
View File
@@ -1,4 +1,5 @@
torch>=2.0.0 torch>=2.0.0
pytest>=7.0.0
pandas>=2.0.0 pandas>=2.0.0
numpy>=1.24.0 numpy>=1.24.0
scikit-learn>=1.3.0 scikit-learn>=1.3.0
+24
View File
@@ -0,0 +1,24 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
import torch
@pytest.fixture(scope="session")
def device():
from engine import _get_device
return _get_device()
@pytest.fixture
def small_data():
from engine import prepare_data
return prepare_data(N_f=200, N_ic=50, N_bc=50)
@pytest.fixture
def pinn_model(device):
from model import HeatPINN
return HeatPINN().to(device)
+73
View File
@@ -0,0 +1,73 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import math
import config
def test_x_src_within_domain():
assert 0.0 <= config.X_SRC <= config.L
def test_t_step_before_t_end():
assert 0.0 < config.T_STEP < config.T_END
def test_gauss_sigma_positive():
assert config.GAUSS_SIGMA > 0.0
def test_physics_positive():
assert config.ALPHA > 0.0
assert config.K > 0.0
assert config.H_CONV > 0.0
assert config.L > 0.0
assert config.T_END > 0.0
def test_training_hyperparameters_positive():
assert config.PATIENCE > 0
assert config.EPOCHS > 0
assert config.LR_ADAM > 0.0
assert config.SCHED_MIN_LR > 0.0
assert config.SCHED_FACTOR > 0.0
assert config.SCHED_PATIENCE > 0
def test_lr_ordering():
"""Min LR deve essere inferiore all'LR iniziale."""
assert config.SCHED_MIN_LR < config.LR_ADAM
def test_sched_patience_lt_patience():
"""Lo scheduler deve poter agire prima che scatti l'early stopping."""
assert config.SCHED_PATIENCE < config.PATIENCE
def test_cfl_stability():
"""La griglia FDM deve soddisfare la condizione CFL (r ≤ 0.5)."""
dx = config.L / (config.NX - 1)
dt = config.T_END / (config.NT - 1)
r = config.ALPHA * dt / dx ** 2
assert r <= 0.5, f"CFL violata: r={r:.4f} > 0.5"
def test_grid_dimensions():
assert config.NX >= 2
assert config.NT >= 2
assert config.N_F >= 1
assert config.N_IC >= 1
assert config.N_BC >= 1
def test_pde_scale_covers_source_peak():
"""_pde_scale in model.py deve coprire il picco gaussiano della sorgente."""
from model import _pde_scale
src_peak = config.ALPHA * config.Q_VAL / (
config.K * config.GAUSS_SIGMA * math.sqrt(2 * math.pi)
)
assert _pde_scale >= src_peak ** 2 - 1e-6, (
f"_pde_scale={_pde_scale:.1f} < src_peak²={src_peak**2:.1f}: "
"la loss PDE non è normalizzata correttamente"
)
+87
View File
@@ -0,0 +1,87 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
import numpy as np
import torch
import config
# ── FDM ───────────────────────────────────────────────────────────────────────
def test_fdm_full_run():
"""Il solver FDM produce un campo di temperatura fisicamente corretto."""
from fdm.solver import solve
T, x, t = solve()
assert T.shape == (config.NX, config.NT)
assert np.isfinite(T).all()
assert T[:, -1].mean() > config.T0 # la sorgente ha scaldato il dominio
assert T.max() > config.T0 # picco sopra la IC
assert T.min() >= config.T0 - 1e-6 # nessun raffreddamento sotto T0 (no sorgenti fredde)
def test_fdm_visualizer_creates_html(tmp_path, monkeypatch):
"""Il visualizer FDM scrive almeno un file HTML senza errori."""
import fdm.visualizer as fdm_vis
monkeypatch.setattr(fdm_vis, 'BASE_DIR', str(tmp_path))
from fdm.solver import solve
T, x, t = solve()
fdm_vis.visualize_fdm(T, x, t)
html_files = list(tmp_path.rglob('*.html'))
assert len(html_files) >= 1, "Nessun file HTML generato dal visualizer FDM"
def test_pinn_visualizer_creates_html(tmp_path, monkeypatch):
"""Il visualizer PINN scrive i tre file HTML senza errori."""
import visualizer as pinn_vis
monkeypatch.setattr(pinn_vis, 'BASE_DIR', str(tmp_path))
from fdm.solver import solve as fdm_solve
T_fdm, _, _ = fdm_solve()
nx, nt = 20, 20
x_vals = np.linspace(0, config.L, nx)
t_vals = np.linspace(0, config.T_END, nt)
T_pred = np.full((nx, nt), config.T_AMB) # predizione costante (dummy)
pinn_vis.visualize_heat_field(T_pred, x_vals, t_vals, T_fdm)
html_files = list(tmp_path.rglob('*.html'))
assert len(html_files) == 3, f"Attesi 3 HTML, trovati {len(html_files)}"
# ── PINN training (lento) ─────────────────────────────────────────────────────
@pytest.mark.slow
def test_pinn_training_saves_checkpoint(tmp_path, monkeypatch):
"""Training per 30 epoche: il checkpoint viene salvato."""
import engine
save_path = str(tmp_path / 'model.pth')
monkeypatch.setattr(engine, 'MODEL_SAVE_PATH', save_path)
monkeypatch.setattr(engine, 'MODELS_DIR', str(tmp_path))
from engine import prepare_data, train_model
data = prepare_data(N_f=300, N_ic=100, N_bc=100)
train_model(data, epochs=30, patience=30)
assert os.path.exists(save_path)
ckpt = torch.load(save_path, map_location='cpu', weights_only=True)
assert 'state_dict' in ckpt
@pytest.mark.slow
def test_pinn_evaluate_after_training(tmp_path, monkeypatch):
"""evaluate_model gira senza errori dopo un training minimo."""
import engine
save_path = str(tmp_path / 'model.pth')
monkeypatch.setattr(engine, 'MODEL_SAVE_PATH', save_path)
monkeypatch.setattr(engine, 'MODELS_DIR', str(tmp_path))
from engine import prepare_data, train_model, evaluate_model
data = prepare_data(N_f=300, N_ic=100, N_bc=100)
train_model(data, epochs=30, patience=30)
evaluate_model(data)
+67
View File
@@ -0,0 +1,67 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import torch
import config
from engine import set_seed, _get_device, prepare_data
def test_set_seed_reproducibility():
set_seed(42)
r1 = torch.rand(10)
set_seed(42)
r2 = torch.rand(10)
torch.testing.assert_close(r1, r2)
def test_get_device_valid():
device = _get_device()
assert isinstance(device, torch.device)
assert device.type in ('cpu', 'cuda', 'mps')
def test_prepare_data_keys():
data = prepare_data(N_f=100, N_ic=50, N_bc=50)
assert set(data.keys()) == {'device', 'x_f', 't_f', 'x_ic', 't_bc'}
def test_prepare_data_shapes():
N_f, N_ic, N_bc = 100, 50, 50
data = prepare_data(N_f=N_f, N_ic=N_ic, N_bc=N_bc)
# engine.py aggiunge 2 * (N_f // 4) punti di clustering
expected_f = N_f + 2 * (N_f // 4)
assert data['x_f'].shape == (expected_f,)
assert data['t_f'].shape == (expected_f,)
assert data['x_ic'].shape == (N_ic,)
assert data['t_bc'].shape == (N_bc,)
def test_prepare_data_x_bounds():
data = prepare_data(N_f=500, N_ic=100, N_bc=100)
assert data['x_f'].min().item() >= 0.0
assert data['x_f'].max().item() <= config.L
assert data['x_ic'].min().item() >= 0.0
assert data['x_ic'].max().item() <= config.L
def test_prepare_data_t_bounds():
data = prepare_data(N_f=500, N_ic=100, N_bc=100)
assert data['t_f'].min().item() >= 0.0
assert data['t_f'].max().item() <= config.T_END
def test_prepare_data_device_consistency():
data = prepare_data(N_f=100, N_ic=50, N_bc=50)
expected = data['device'].type
for key in ('x_f', 't_f', 'x_ic', 't_bc'):
assert data[key].device.type == expected, f"{key} sul device sbagliato"
def test_prepare_data_deterministic():
"""Due chiamate con lo stesso seed (fissato in prepare_data) producono dati identici."""
d1 = prepare_data(N_f=100, N_ic=50, N_bc=50)
d2 = prepare_data(N_f=100, N_ic=50, N_bc=50)
torch.testing.assert_close(d1['x_f'], d2['x_f'])
torch.testing.assert_close(d1['t_f'], d2['t_f'])
torch.testing.assert_close(d1['x_ic'], d2['x_ic'])
+65
View File
@@ -0,0 +1,65 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import torch
from engine import prepare_data
from model import HeatPINN, heat_pinn_loss
def test_data_to_model_forward():
"""prepare_data → forward: shape e device coerenti, nessun NaN."""
data = prepare_data(N_f=100, N_ic=50, N_bc=50)
model = HeatPINN().to(data['device'])
xt = torch.stack([data['x_f'], data['t_f']], dim=1)
out = model(xt)
assert out.shape == (data['x_f'].shape[0], 1)
assert out.device.type == data['device'].type
assert torch.isfinite(out).all()
def test_full_loss_pipeline():
"""prepare_data → heat_pinn_loss: tutti i componenti finiti e non-negativi."""
data = prepare_data(N_f=100, N_ic=50, N_bc=50)
model = HeatPINN().to(data['device'])
total, L_pde, L_ic, L_bc = heat_pinn_loss(
model, data['x_f'], data['t_f'], data['x_ic'], data['t_bc']
)
for name, v in [('total', total), ('L_pde', L_pde), ('L_ic', L_ic), ('L_bc', L_bc)]:
assert torch.isfinite(v), f"{name} non è finita"
assert v.item() >= 0.0, f"{name} è negativa"
def test_backward_gradients_finite():
"""Il backward della loss non produce NaN/Inf nei parametri."""
data = prepare_data(N_f=100, N_ic=50, N_bc=50)
model = HeatPINN().to(data['device'])
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
optimizer.zero_grad()
total, _, _, _ = heat_pinn_loss(
model, data['x_f'], data['t_f'], data['x_ic'], data['t_bc']
)
total.backward()
for p in model.parameters():
assert p.grad is not None
assert torch.isfinite(p.grad).all(), "gradiente NaN/Inf"
def test_training_loop_stable():
"""20 step di Adam non producono NaN/Inf nei parametri né nella loss."""
data = prepare_data(N_f=200, N_ic=100, N_bc=100)
model = HeatPINN().to(data['device'])
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
args = (model, data['x_f'], data['t_f'], data['x_ic'], data['t_bc'])
for _ in range(20):
optimizer.zero_grad()
loss, _, _, _ = heat_pinn_loss(*args)
loss.backward()
optimizer.step()
for p in model.parameters():
assert torch.isfinite(p).all(), "parametro NaN/Inf dopo training"
total, _, _, _ = heat_pinn_loss(*args)
assert torch.isfinite(total)
+102
View File
@@ -0,0 +1,102 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import torch
import config
from model import HeatPINN, heat_pinn_loss
# ── HeatPINN.forward ─────────────────────────────────────────────────────────
def test_forward_output_shape(pinn_model, device):
xt = torch.zeros(64, 2, device=device)
xt[:, 0] = torch.rand(64) * config.L
xt[:, 1] = torch.rand(64) * config.T_END
assert pinn_model(xt).shape == (64, 1)
def test_forward_finite(pinn_model, device):
xt = torch.zeros(100, 2, device=device)
xt[:, 0] = torch.rand(100) * config.L
xt[:, 1] = torch.rand(100) * config.T_END
assert torch.isfinite(pinn_model(xt)).all()
def test_forward_zero_weights_returns_t_amb(device):
"""Con pesi nulli net(x,t)=0 ⇒ forward restituisce T_AMB per ogni input."""
model = HeatPINN().to(device)
for p in model.parameters():
p.data.zero_()
xt = torch.zeros(20, 2, device=device)
xt[:, 0] = torch.rand(20) * config.L
xt[:, 1] = torch.rand(20) * config.T_END
out = model(xt)
torch.testing.assert_close(out, torch.full_like(out, config.T_AMB), atol=1e-5, rtol=0.0)
def test_forward_t_normalization(device):
"""t viene normalizzato a [0,1]: il modello deve restituire output finiti
anche a t=T_END senza saturazione di Tanh."""
model = HeatPINN().to(device)
torch.nn.init.xavier_uniform_(model.net[0].weight)
xt = torch.tensor([[0.5, 0.0], [0.5, config.T_END]], device=device)
out = model(xt)
assert out.shape == (2, 1)
assert torch.isfinite(out).all()
# ── heat_pinn_loss ────────────────────────────────────────────────────────────
def _dummy_inputs(device, n_f=100, n_ic=50, n_bc=50):
x_f = torch.rand(n_f, device=device) * config.L
t_f = torch.rand(n_f, device=device) * config.T_END
x_ic = torch.rand(n_ic, device=device) * config.L
t_bc = torch.rand(n_bc, device=device) * config.T_END
return x_f, t_f, x_ic, t_bc
def test_loss_returns_four_values(pinn_model, device):
result = heat_pinn_loss(pinn_model, *_dummy_inputs(device))
assert len(result) == 4
def test_loss_components_non_negative(pinn_model, device):
total, L_pde, L_ic, L_bc = heat_pinn_loss(pinn_model, *_dummy_inputs(device))
assert total.item() >= 0.0
assert L_pde.item() >= 0.0
assert L_ic.item() >= 0.0
assert L_bc.item() >= 0.0
def test_loss_finite(pinn_model, device):
for v in heat_pinn_loss(pinn_model, *_dummy_inputs(device)):
assert torch.isfinite(v), f"loss non finita: {v}"
def test_loss_weight_doubles_pde_contribution(pinn_model, device):
"""Raddoppiare w_pde con w_ic=w_bc=0 deve raddoppiare il totale."""
inputs = _dummy_inputs(device)
total1, L_pde1, _, _ = heat_pinn_loss(pinn_model, *inputs, w_pde=1.0, w_ic=0.0, w_bc=0.0)
total2, L_pde2, _, _ = heat_pinn_loss(pinn_model, *inputs, w_pde=2.0, w_ic=0.0, w_bc=0.0)
# L_pde deve essere identico tra le due chiamate (stesso modello, stessi dati)
torch.testing.assert_close(L_pde1, L_pde2, atol=1e-5, rtol=1e-4)
torch.testing.assert_close(total2, 2.0 * total1, atol=1e-5, rtol=1e-4)
def test_ic_loss_zero_when_net_is_zero(device):
"""Con net=0 ⇒ T = T_AMB = T0 ⇒ L_ic = 0."""
model = HeatPINN().to(device)
for p in model.parameters():
p.data.zero_()
_, _, L_ic, _ = heat_pinn_loss(model, *_dummy_inputs(device))
assert L_ic.item() < 1e-8
def test_bc_loss_zero_when_net_is_zero(device):
"""Con net=0 ⇒ T = T_AMB e dT/dx = 0 ⇒ Robin BC soddisfatta ⇒ L_bc = 0."""
model = HeatPINN().to(device)
for p in model.parameters():
p.data.zero_()
_, _, _, L_bc = heat_pinn_loss(model, *_dummy_inputs(device))
assert L_bc.item() < 1e-8
+8 -15
View File
@@ -1,15 +1,17 @@
import os import os
from datetime import datetime
import numpy as np import numpy as np
import plotly.graph_objects as go import plotly.graph_objects as go
from plotly.subplots import make_subplots from plotly.subplots import make_subplots
import config import config
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ANIMATIONS_DIR = os.path.join(BASE_DIR, 'animations')
def visualize_heat_field(T_pred, x_vals, t_vals, T_fdm): def visualize_heat_field(T_pred, x_vals, t_vals, T_fdm):
os.makedirs(ANIMATIONS_DIR, exist_ok=True) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
out_dir = os.path.join(BASE_DIR, 'results', 'pinn', timestamp)
os.makedirs(out_dir, exist_ok=True)
# Downsample T_fdm from shape (NX_fdm, NT_fdm) to match PINN grid # Downsample T_fdm from shape (NX_fdm, NT_fdm) to match PINN grid
nx_pred = len(x_vals) nx_pred = len(x_vals)
@@ -44,7 +46,7 @@ def visualize_heat_field(T_pred, x_vals, t_vals, T_fdm):
fig_map.update_yaxes(title_text='t', row=1, col=1) fig_map.update_yaxes(title_text='t', row=1, col=1)
fig_map.update_layout(title_text='Heat Equation PINN vs FDM', height=450) fig_map.update_layout(title_text='Heat Equation PINN vs FDM', height=450)
map_path = _next_path('heatmap', '.html') map_path = os.path.join(out_dir, 'heatmap.html')
fig_map.write_html(map_path) fig_map.write_html(map_path)
print(f"Heatmap saved → {map_path}") print(f"Heatmap saved → {map_path}")
@@ -92,7 +94,7 @@ def visualize_heat_field(T_pred, x_vals, t_vals, T_fdm):
frames=frames, frames=frames,
) )
anim_path = _next_path('heat_animation', '.html') anim_path = os.path.join(out_dir, 'animation.html')
fig_anim.write_html(anim_path) fig_anim.write_html(anim_path)
print(f"Animation saved → {anim_path}") print(f"Animation saved → {anim_path}")
@@ -141,15 +143,6 @@ def visualize_heat_field(T_pred, x_vals, t_vals, T_fdm):
height=500, height=500,
) )
comparison_path = _next_path('comparison', '.html') comparison_path = os.path.join(out_dir, 'comparison.html')
fig_ts.write_html(comparison_path) fig_ts.write_html(comparison_path)
print(f"Time-series saved → {comparison_path}") print(f"Comparison saved {comparison_path}")
def _next_path(prefix, ext):
i = 1
while True:
path = os.path.join(ANIMATIONS_DIR, f'{prefix}_{i:03d}{ext}')
if not os.path.exists(path):
return path
i += 1