Domini corretti: riscaldamento = salita rapida, raffreddamento = decadimento dopo ogni gradino

- 1 + 2N domini: riposo, poi (riscaldamento, raffreddamento) per ogni gradino
- riscaldamento non fittato: descritto come salto (delta, da -> a, durata)
- raffreddamenti intermedi fissano c e tau a quelli del raffreddamento finale
- un colore per ogni raffreddamento nei grafici, zoom sui gradini
- sigma None (non NaN) nei domini non fittati: JSON valido

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-29 10:28:00 +01:00
1 parent da0964be4d
commit b49af5c112
12 files changed
+277 -142

No files matched your search

+5 -5
View File
@@ -2,7 +2,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
`fitseg` legge un CSV `t_ms,T_C,dT_C` (temperatura di un sensore IR), lo divide in **domini** (riposo / riscaldamento = gradini / raffreddamento) e trova per ogni (dominio, serie) l'equazione che meglio lo approssima. Documentazione, commenti e messaggi sono in italiano: mantieni la lingua.
`fitseg` legge un CSV `t_ms,T_C,dT_C` (temperatura di un sensore IR), lo divide in **domini** (riposo / riscaldamento = la salita rapida di un gradino, 2–3 campioni / raffreddamento = il decadimento dopo ogni gradino) e trova per ogni (dominio, serie) l'equazione che meglio lo approssima. Documentazione, commenti e messaggi sono in italiano: mantieni la lingua.
Fonti di verità (non duplicarne il contenuto): [docs/SPEC.md](docs/SPEC.md) (algoritmi, limiti), [README.md](README.md) (uso, test), [config.toml](config.toml) (unica fonte dei valori: nessun default nel codice, ogni chiave nuova va in `CHIAVI` di [src/fitseg/config.py](src/fitseg/config.py) e in SPEC).
@@ -21,8 +21,8 @@ uv venv .venv && uv pip install -e ".[dev]"
`cli.py` (due sottocomandi, unico punto d'ingresso) → `config.py`, `dati.py` (lettura) → `domini.py` (gradini ⇒ domini, I/O di `domini.toml`) → `analisi.py` (ordina i domini, sceglie il modello) → `fit.py` (`adatta`) con `modelli.py` (registro `MODELLI`) → `uscita.py` (cartelle dei passi, grafici, report; il report incorpora le immagini con percorsi relativi `../01_domini/…`, quindi i nomi dei file/cartelle vanno cambiati in modo coerente).
- I domini derivano **solo dai gradini positivi** di `serie_riferimento` (`diff > soglia_gradino_c`), con arretramento ai campioni di transizione; `domini.toml` è modificabile e deve essere contiguo.
- `analisi.analizza_domini`: i domini di raffreddamento si fittano **per primi**; i loro `c`/`tau` sono fissati nei domini di riscaldamento (`riscaldamento_fissa_da_raffreddamento`, `adatta(..., fissi=)`). I modelli provati dipendono dal tipo (`[fit.modelli_per_tipo]`: riscaldamento e raffreddamento solo `esp_decad`).
- I domini derivano **solo dai gradini positivi** di `serie_riferimento` (`diff > soglia_gradino_c`): la salita (estesa ai campioni adiacenti ancora in salita) è un `riscaldamento`, il tratto dopo è un `raffreddamento`; con N gradini i domini sono 1 + 2N. `domini.toml` è modificabile e deve essere contiguo.
- `analisi.analizza_domini`: il raffreddamento **finale** (ultimo) si fitta per primo, libero; i raffreddamenti intermedi fissano `c`/`tau` ai suoi valori (`raffreddamento_intermedio_fissa_da_finale`, `adatta(..., fissi=)`). Il `riscaldamento` non si fitta: `_gradino` ne riporta il salto (`vs["gradino"]`, `sigma` None). Modelli per tipo in `[fit.modelli_per_tipo]` (riposo, raffreddamento).
- `fit.adatta`: `least_squares` con perdita robusta; l'esponenziale parte da una griglia su τ. "Non identificabile" = condizione > soglia **o** IC 95 % > |valore| per un parametro diverso da `a`/`c`. σ del rumore **per dominio** da `MAD(d²y)`; tempo traslato a `t' = t − t0`.
- Nuovo modello = voce in `modelli.py` + riga in SPEC §3 + nome in `[fit.modelli_per_tipo]`. Non ci sono partenze casuali: il programma è deterministico.
@@ -32,7 +32,7 @@ Unitari su un segnale sintetico generato in [tests/conftest.py](tests/conftest.p
## Gotcha
- `scarta_dopo_salto_n` deve coprire i campioni di transizione del sensore (τ_sensore 0.07 s, dt 0.15 s ⇒ ≥ 2–3 campioni); se troppo basso 1–2 punti a ~100 °C sporcano la fine del dominio.
- Domini di riscaldamento di 14–26 campioni: con gli scarti casuali τ non è identificabile da solo (su dati puliti sì), quindi è fisso al valore del raffreddamento; gli IC di `A` sono condizionati. Senza un raffreddamento valido il riscaldamento torna al fit libero e può risultare indeterminato.
- La salita del gradino (campioni di transizione del sensore, τ_sensore 0.07 s, dt 0.15 s) appartiene al `riscaldamento`, non al raffreddamento: se `soglia_bordo_sigma` è troppo alta un campione parziale può finire nel raffreddamento (`scarta_dopo_salto_n` lo esclude dal fit).
- Raffreddamenti intermedi di 12–25 campioni: con gli scarti casuali τ non è identificabile da solo (su dati puliti sì), quindi è fisso al valore del finale; gli IC di `A` sono condizionati. Senza un finale valido tornano liberi e possono risultare indeterminati.
- Cambiando la config di `gen_data` (gradini, rumore) ricontrolla `soglia_gradino_c` e i test di integrazione.
- Exit code: 0 ok · 2 uso · 3 config · 4 CSV · 6 domini non validi.
+8 -8
View File
@@ -1,6 +1,6 @@
# fitseg
Legge un CSV `t_ms,T_C,dT_C` (temperatura di un sensore IR che si scalda e si raffredda), riconosce **riposo**, **riscaldamento** (gradini di flusso in entrata) e **raffreddamento**, lo divide in **domini** e trova per ogni dominio e ogni serie l'equazione che lo approssima meglio, con minimi quadrati non lineari.
Legge un CSV `t_ms,T_C,dT_C` (temperatura di un sensore IR che si scalda e si raffredda), riconosce **riposo**, **riscaldamento** (la salita rapida di ogni gradino, 2–3 campioni) e **raffreddamento** (il decadimento esponenziale tra un gradino e l'altro e dopo l'ultimo), lo divide in **domini** e trova per ogni dominio e ogni serie l'equazione che lo approssima meglio, con minimi quadrati non lineari.
Risultato per dominio: modello scelto, parametri con intervallo di confidenza al 95 %, metriche (RMSE, R², AICc, BIC, autocorrelazione dei residui), un grafico dati/equazioni e un report Markdown.
@@ -62,20 +62,20 @@ output/gen_1/
`report.md` contiene: (1) suddivisione in domini, (2) tabella delle equazioni con parametri ± IC 95 %, (3) sovrapposizione dati–equazioni, (4) dettaglio per dominio con metriche e tabella di tutti i modelli provati. Le immagini sono link relativi: si vedono in un visualizzatore Markdown.
Come leggere il report: **non adeguato** = RMSE oltre 1.3 volte il rumore stimato (guarda l'ACF1 dei residui); **indeterminato** = nessun modello valido o dominio con meno di `n_min_punti` campioni. Nel raffreddamento `τ` è la costante di tempo e `c` l'asintoto (≈ ambiente per `T_C`, ≈ 0 per `dT_C`).
Come leggere il report: **salto** = riscaldamento descritto ma non fittato; **non adeguato** = RMSE oltre 1.3 volte il rumore stimato (guarda l'ACF1 dei residui); **indeterminato** = nessun modello valido o dominio con meno di `n_min_punti` campioni. Nel raffreddamento `τ` è la costante di tempo e `c` l'asintoto (≈ ambiente per `T_C`, ≈ 0 per `dT_C`).
## Come funziona
1. **Domini** (`domini.py`): un salto positivo tra due campioni consecutivi sopra `soglia_gradino_c` è un gradino di riscaldamento. Prima del primo gradino: `riposo`; tra due gradini: `riscaldamento`; dopo l'ultimo: `raffreddamento`.
2. **Modelli** (`modelli.py`): `riposo` → `costante`, `lineare`, `esp_decad`; `riscaldamento` e `raffreddamento` → solo `esp_decad` `y = c + A·exp(−t'/τ)`, con `t' = t − t0` del dominio.
3. **Fit** (`fit.py`): `scipy.optimize.least_squares` con perdita robusta; partenza dell'esponenziale da una griglia su τ. I domini di raffreddamento si fittano per primi e nei domini di riscaldamento `c` e `τ` sono **fissati** ai loro valori (stesse costanti fisiche; in 2–4 s di dati con scarti casuali τ non si stima da solo), restando libera solo `A`. Gli IC di `A` sono condizionati ai valori fissi.
1. **Domini** (`domini.py`): un salto positivo tra due campioni consecutivi sopra `soglia_gradino_c` è un gradino. I campioni della salita formano un dominio `riscaldamento` (pochi punti, esteso ai campioni adiacenti ancora in salita). Prima del primo gradino: `riposo`; dopo ogni gradino: `raffreddamento`, fino al gradino successivo o alla fine. Con 3 gradini i domini sono 7: riposo, (riscaldamento, raffreddamento) × 3.
2. **Modelli** (`modelli.py`): `riposo` → `costante`, `lineare`, `esp_decad`; `raffreddamento` → solo `esp_decad` `y = c + A·exp(−t'/τ)`, con `t' = t − t0` del dominio. Il `riscaldamento` ha troppo pochi campioni per un fit: è descritto come **salto** (Δ, da → a, numero di campioni, durata).
3. **Fit** (`fit.py`): `scipy.optimize.least_squares` con perdita robusta; partenza dell'esponenziale da una griglia su τ. Il raffreddamento finale (il più lungo) si fitta per primo con parametri liberi; i raffreddamenti intermedi, tra due gradini (12–25 campioni), **fissano `c` e `τ`** ai suoi valori (stesse costanti fisiche; in pochi secondi di dati con scarti casuali τ non si stima da solo), restando libera solo `A`. Gli IC di `A` sono condizionati ai valori fissi.
4. **Scelta** (`analisi.py`): scarta i modelli non convergenti, con parametri a limite o non identificabili; preferisce i modelli «adeguati» (RMSE ≤ `rmse_vs_rumore_max` × σ rumore); tra quelli entro `soglia_delta_aicc` dal miglior AICc vince chi ha meno parametri.
Specifica e limiti: [docs/SPEC.md](docs/SPEC.md).
## Configurazione
Tutti i valori sono in [config.toml](config.toml), nessun default nel codice (chiave mancante = errore, exit 3). Le principali: `[domini] soglia_gradino_c` (20 °C), `scarta_dopo_salto_n` (campioni di transizione esclusi dal fit), `n_min_punti`; `[fit.modelli_per_tipo]` (modelli per tipo di dominio); `[fit] riscaldamento_fissa_da_raffreddamento` (`["c", "tau"]`, `[]` = parametri liberi), `soglia_delta_aicc`, `rmse_vs_rumore_max`.
Tutti i valori sono in [config.toml](config.toml), nessun default nel codice (chiave mancante = errore, exit 3). Le principali: `[domini] soglia_gradino_c` (20 °C), `scarta_dopo_salto_n` (campioni esclusi all'inizio di ogni raffreddamento), `n_min_punti`; `[fit.modelli_per_tipo]` (modelli per tipo di dominio); `[fit] raffreddamento_intermedio_fissa_da_finale` (`["c", "tau"]`, `[]` = parametri liberi), `soglia_delta_aicc`, `rmse_vs_rumore_max`.
Nuovo modello: voce in `MODELLI` di [src/fitseg/modelli.py](src/fitseg/modelli.py), riga in SPEC §4 e nome in `[fit.modelli_per_tipo]`.
@@ -90,13 +90,13 @@ Nuovo modello: voce in `MODELLI` di [src/fitseg/modelli.py](src/fitseg/modelli.p
| File | Verifica | Servono i CSV? |
|---|---|---|
| `test_domini.py` | 3 gradini, nessun gradino, un solo gradino, gradino sotto soglia, confini vicini ai gradini veri, `domini.toml` (andata e ritorno, non contiguo, tipo non valido) | no |
| `test_domini.py` | 7 domini con 3 gradini (riscaldamento = pochi campioni), nessun gradino, un solo gradino, gradino sotto soglia, confini vicini ai gradini veri, `domini.toml` (andata e ritorno, non contiguo, tipo non valido) | no |
| `test_fit.py` | recupero di τ, `c`, `A` su segnale sintetico (τ = 16 s), parametri fissi, τ non identificabile su tratti corti e rumorosi, analisi completa, dominio troppo corto, riproducibilità | no |
| `test_selezione.py` | regola di scelta | no |
| `test_config_dati.py` | errori di config e CSV | no |
| `test_integrazione.py` | domini e τ su `input/gen_{1,2,3}.csv`; CLI `domini` e `analizza --domini`; codici di uscita | sì (altrimenti saltati) |
I test unitari generano il segnale da soli (`tests/conftest.py`). Prova manuale: `fitseg analizza input/gen_1.csv` e controlla in `output/gen_1/03_report/report.md` 4 domini (riposo, due riscaldamenti, raffreddamento) con τ ≈ 16 s.
I test unitari generano il segnale da soli (`tests/conftest.py`). Prova manuale: `fitseg analizza input/gen_1.csv` e controlla in `output/gen_1/03_report/report.md` 7 domini (riposo, poi riscaldamento e raffreddamento per ognuno dei 3 gradini) e τ ≈ 16 s nel raffreddamento finale; in `01_domini/domini.png` ogni raffreddamento ha un colore diverso.
Codici di uscita: 0 ok · 3 configurazione non valida · 4 CSV illeggibile/non valido · 6 `domini.toml` non valido.
+4 -5
View File
@@ -7,16 +7,16 @@ unita_tempo = "ms" # "ms" | "s"
serie = ["T_C", "dT_C"] # colonne da approssimare
separatore = ","
[domini] # riconoscimento di riposo / riscaldamento / raffreddamento (nessun LLM)
[domini] # riconoscimento di riposo / riscaldamento (salita rapida) / raffreddamento (decadimento)
serie_riferimento = "T_C" # serie su cui si cercano i gradini di riscaldamento
soglia_gradino_c = 20.0 # salto positivo tra due campioni consecutivi che segna un gradino di riscaldamento
soglia_bordo_sigma = 6.0 # il gradino si estende ai campioni adiacenti con salto > soglia × sigma robusta dei salti
distanza_min_salti_campioni = 3 # rilevazioni più vicine di così sono lo stesso gradino
n_min_punti = 6 # campioni minimi per dominio (sotto: "indeterminato")
scarta_dopo_salto_n = 3 # campioni esclusi dal fit all'inizio di un dominio dopo un gradino (transizione del sensore)
scarta_dopo_salto_n = 1 # campioni esclusi all'inizio di ogni raffreddamento (sicurezza dopo la salita)
[fit]
riscaldamento_fissa_da_raffreddamento = ["c", "tau"] # parametri dei domini di riscaldamento fissati ai valori del fit del raffreddamento ([] = liberi)
raffreddamento_intermedio_fissa_da_finale = ["c", "tau"] # i raffreddamenti tra due gradini (corti) fissano questi parametri a quelli del raffreddamento finale ([] = liberi)
perdita = "soft_l1" # "linear" | "soft_l1" | "huber" (robusta agli outlier)
scala_perdita_sigma = 3.0 # scala della perdita robusta, in unità di sigma rumore
tau_min_rel = 0.05 # limite inferiore di tau, come frazione della durata del dominio
@@ -28,9 +28,8 @@ soglia_acf = 0.3
soglia_delta_aicc = 2.0 # entro questa differenza vince il modello con meno parametri
rmse_vs_rumore_max = 1.3 # RMSE <= valore × sigma rumore => modello adeguato
[fit.modelli_per_tipo] # modelli provati in ogni tipo di dominio (riscaldamento e raffreddamento: esponenziali)
[fit.modelli_per_tipo] # modelli provati per tipo di dominio; il riscaldamento (2-3 campioni) non si fitta: è descritto come salto
riposo = ["costante", "lineare", "esp_decad"]
riscaldamento = ["esp_decad"]
raffreddamento = ["esp_decad"]
[output]
+13 -11
View File
@@ -1,11 +1,11 @@
# `fitseg` — specifica
**Obiettivo**: leggere un CSV (`t_ms,T_C,dT_C`, p.es. da `gen_data`), riconoscere **riposo**, **riscaldamento** (flusso in entrata: gradini di temperatura) e **raffreddamento**, dividere il tempo in **domini** e trovare per ogni (dominio, serie) l'equazione che lo approssima meglio. Valori configurabili: solo in [`config.toml`](../config.toml). Uso: [README](../README.md).
**Obiettivo**: leggere un CSV (`t_ms,T_C,dT_C`, p.es. da `gen_data`), riconoscere **riposo**, **riscaldamento** (flusso in entrata: la salita rapida di ogni gradino, pochi campioni) e **raffreddamento** (decadimento esponenziale dopo ogni gradino), dividere il tempo in **domini** e trovare per ogni (dominio, serie) l'equazione che lo approssima meglio. Valori configurabili: solo in [`config.toml`](../config.toml). Uso: [README](../README.md).
## 1. Flusso
```
CSV ─► [1 DOMINI] gradini ⇒ riposo | riscaldamento… | raffreddamento ─► output/<csv>/01_domini/ (domini.png, domini.toml modificabile)
CSV ─► [1 DOMINI] gradini ⇒ riposo | (riscaldamento, raffreddamento) per ogni gradino ─► output/<csv>/01_domini/ (domini.png, domini.toml modificabile)
▼
[2 FIT] per (dominio, serie): modelli del tipo ─► regola di scelta ─► 02_sovrapposizione/ (dati contro equazioni)
▼
@@ -15,11 +15,12 @@ Tutto il calcolo è deterministico (nessuna partenza casuale, nessun LLM). Ogni
## 2. Domini (`domini.py`)
Struttura fisica (vedi `gen_data/docs/SPEC.md`): T parte dall'ambiente, **gradini istantanei positivi** (riscaldamento, 2–4 s uno dall'altro) ognuno seguito da un decadimento esponenziale; dopo l'ultimo resta solo il **raffreddamento** esponenziale verso l'ambiente. Compaiono anche scarti casuali (±4 °C, rientro < 1 s).
Struttura fisica (vedi `gen_data/docs/SPEC.md`): T parte dall'ambiente, **gradini istantanei positivi** (2–4 s uno dall'altro): la salita dura 2–3 campioni ed è il **riscaldamento**; ognuno è seguito da un decadimento esponenziale, il **raffreddamento**, che dopo l'ultimo gradino prosegue fino all'ambiente. Compaiono anche scarti casuali (±4 °C, rientro < 1 s).
1. `d = diff(y)` sulla `serie_riferimento`; gradino = `d > soglia_gradino_c`; rilevazioni più vicine di `distanza_min_salti_campioni` sono lo stesso gradino.
2. Il gradino parte dal primo campione già in salita (si arretra finché `d > soglia_bordo_sigma · σ_d`, σ_d = MAD dei salti). Quel campione apre il nuovo dominio; i primi `scarta_dopo_salto_n` campioni sono esclusi dal fit (transizione del sensore).
3. Prima del primo gradino `riposo`; tra due gradini `riscaldamento`; dopo l'ultimo `raffreddamento`. Senza gradini: un solo `riposo`.
2. I campioni della salita formano il dominio `riscaldamento`: dal primo campione già in salita all'ultimo (si estende ai campioni adiacenti finché `d > soglia_bordo_sigma · σ_d`, σ_d = MAD dei salti).
3. Prima del primo gradino `riposo`; dopo ogni gradino `raffreddamento`, fino al gradino successivo o alla fine. Con N gradini i domini sono 1 + 2N. Senza gradini: un solo `riposo`. Un raffreddamento senza campioni (gradini adiacenti) non viene creato.
4. I primi `scarta_dopo_salto_n` campioni di ogni `raffreddamento` sono esclusi dal fit (sicurezza dopo la salita).
Ipotesi: tempo crescente; rumore circa gaussiano, σ stimato per dominio da `1.4826·MAD(d²y)/√6`; le serie condividono i domini; i gradini sono salti positivi ≫ rumore e scarti casuali (`soglia_gradino_c` ≫ ampiezza degli scarti).
@@ -31,20 +32,20 @@ Ipotesi: tempo crescente; rumore circa gaussiano, σ stimato per dominio da `1.4
|---|---|---|---|
| `costante` | `y = a` | a | riposo |
| `lineare` | `y = a + b·t'` | a, b | riposo |
| `esp_decad` | `y = c + A·exp(−t'/τ)` | c, A, τ | riposo, riscaldamento, raffreddamento |
| `esp_decad` | `y = c + A·exp(−t'/τ)` | c, A, τ | riposo, raffreddamento |
Riscaldamento e raffreddamento sono esponenziali. Nel raffreddamento `c` è l'asintoto (≈ ambiente) e `τ = 1/k`. Nuovo modello = voce in `MODELLI`, riga qui, nome in config.
Il raffreddamento è esponenziale; il **riscaldamento** (2–3 campioni) non ha modelli: è descritto come salto (Δ = ultimo campione di salita − campione precedente, da → a, n campioni, durata). Nel raffreddamento `c` è l'asintoto (≈ ambiente) e `τ = 1/k`. Nuovo modello = voce in `MODELLI`, riga qui, nome in config.
## 4. Fit (`fit.py`)
1. Dati del dominio senza i primi `scarta_dopo_salto_n` campioni (se non `riposo`); tempo traslato.
1. Dati del dominio (raffreddamento: senza i primi `scarta_dopo_salto_n` campioni); tempo traslato.
2. Partenza: polinomi per minimi quadrati; esponenziale con griglia log-spaziata su τ (`tau_min_rel·L` … `tau_max_rel·L`, L = durata) e `c`, `A` lineari.
3. `scipy.optimize.least_squares`, perdita `perdita` (robusta `soft_l1`, scala `scala_perdita_sigma·σ`), limiti su τ.
4. IC 95 % = ±1.96·σ_p da `s²(JᵀJ)⁻¹`, moltiplicati per `√((1+ρ)/(1−ρ))` se ACF1 > `soglia_acf` (`correggi_autocorrelazione`).
5. Metriche: RSS, RMSE, R², AICc, BIC, ACF1, parametri a limite, numero di condizione. **Non identificabile** se condizione > `soglia_condizione` o un parametro (esclusi `a`, `c`) ha IC 95 % > |valore|.
### 4.1 Costanti condivise tra riscaldamento e raffreddamento (`analisi.py`)
Dopo ogni gradino la temperatura decade con la stessa legge del raffreddamento finale. I domini di raffreddamento si fittano per primi; nei domini di riscaldamento i parametri di `riscaldamento_fissa_da_raffreddamento` (default `c`, `tau`) sono **fissati** ai loro valori e resta libera solo `A`. Motivo: con gli scarti casuali del sensore, in 2–4 s di dati τ non si identifica da solo (su dati puliti sì). Gli IC di `A` sono condizionati ai valori fissi; i parametri fissi sono marcati «(fisso)» nel report. Se manca un raffreddamento valido, o la lista è vuota, i parametri restano liberi.
### 4.1 Costanti condivise tra i raffreddamenti (`analisi.py`)
Ogni raffreddamento segue la stessa legge (stessa costante di tempo, stesso asintoto ≈ ambiente). Il raffreddamento **finale** (ultimo dominio, il più lungo) si fitta per primo con parametri liberi; i raffreddamenti **intermedi** (tra due gradini, 12–25 campioni) fissano i parametri di `raffreddamento_intermedio_fissa_da_finale` (default `c`, `tau`) ai suoi valori e hanno libera solo `A`. Motivo: con gli scarti casuali del sensore, in pochi secondi di dati τ non si identifica da solo (su dati puliti sì). Gli IC di `A` sono condizionati ai valori fissi; i parametri fissi sono marcati «(fisso)» nel report. Se manca un finale valido, o la lista è vuota, i parametri restano liberi.
### 4.2 Regola di scelta (`analisi.scegli`)
Scarta non convergenti, con parametri a limite o non identificabili. Se esistono modelli con `RMSE ≤ rmse_vs_rumore_max·σ` («adeguati») si sceglie tra questi, altrimenti tra tutti i validi; tra i modelli entro `soglia_delta_aicc` dal miglior AICc vince quello con meno parametri. Nessun modello valido ⇒ **indeterminato**; meno di `n_min_punti` campioni ⇒ non fittato. Scelto ma con RMSE > soglia·σ ⇒ «non adeguato» nel report.
@@ -67,6 +68,7 @@ Unitari su un segnale sintetico (`tests/conftest.py`: τ = 16 s, 3 gradini, ness
## 7. Limiti noti
- **Residui autocorrelati nel raffreddamento** (ACF1 ≈ 0.7, RMSE ≈ 0.36 contro σ ≈ 0.15): scarti casuali e deriva lenta non sono nel modello; parametri buoni, IC troppo stretti, dominio «non adeguato».
- **Riscaldamento**: secondo tratto con ACF1 ≈ 0.7 e RMSE ≈ 2σ per gli scarti: «non adeguato» anche se la forma è giusta.
- **Riscaldamento non fittato**: con 2–3 campioni si riporta solo il salto Δ; il valore dipende da quanta salita cade sul campione precedente (campioni di transizione), quindi è una stima per difetto dell'ampiezza reale del gradino.
- **Raffreddamenti intermedi**: ACF1 alto e RMSE ≈ 2σ per gli scarti: «non adeguati» anche se la forma è giusta.
- `soglia_gradino_c` va adattata se i gradini reali sono più piccoli di 20 °C o gli scarti più grandi.
- Nessun vincolo di continuità tra domini. Provato su pochi seed e con la config d'esempio di `gen_data`.
+26 -10
View File
@@ -1,4 +1,4 @@
"""Analisi: fit dei modelli di ogni tipo di dominio su ogni (dominio, serie) e scelta con la regola di SPEC §5.3."""
"""Analisi: fit dei modelli per ogni dominio e serie e scelta con la regola di SPEC §4.2; il riscaldamento è descritto come salto."""
import math
@@ -29,6 +29,15 @@ def scegli(fit_list, sigma, cfg):
return scelto, motivo, bool(adeguati)
def _gradino(t, y, d):
"""Descrizione del riscaldamento: salto dal campione prima della salita all'ultimo campione di salita."""
i0, i1 = d["i0"], d["i1"]
prima, dopo = float(y[i0 - 1]), float(y[i1 - 1])
g = {"n": int(i1 - i0), "da": prima, "a": dopo, "delta": dopo - prima, "durata_s": float(t[i1 - 1] - t[i0 - 1])}
return {"sigma": None, "fit": [], "scelta": None, "gradino": g,
"motivo": f"salita rapida di {g['n']} campioni ({g['durata_s'] * 1000:.0f} ms), non fittata"}
def _serie(ty, yy, sigma, tipo, fissi, cfg):
voce = {"sigma": float(sigma), "fit": [], "scelta": None}
if len(yy) < cfg["domini"]["n_min_punti"]:
@@ -36,28 +45,35 @@ def _serie(ty, yy, sigma, tipo, fissi, cfg):
return voce
for m in cfg["fit"]["modelli_per_tipo"][tipo]:
if len(MODELLI[m][1]) <= len(yy) - 2:
voce["fit"].append(adatta(ty, yy, m, sigma, cfg, fissi if tipo == "riscaldamento" else None))
voce["fit"].append(adatta(ty, yy, m, sigma, cfg, fissi))
scelto, voce["motivo"], voce["adeguato"] = scegli(voce["fit"], sigma, cfg)
voce["scelta"] = scelto["modello"] if scelto else None
return voce
def analizza_domini(t, serie, domini, cfg):
"""Un risultato per dominio. I domini di raffreddamento si fittano per primi: i loro parametri
(`riscaldamento_fissa_da_raffreddamento`) vengono fissati nei domini di riscaldamento (stesse costanti fisiche)."""
"""Un risultato per dominio. Il raffreddamento finale (ultimo) si fitta per primo, con parametri liberi; i
raffreddamenti intermedi (tra due gradini, corti) fissano i parametri di `raffreddamento_intermedio_fissa_da_finale`
ai suoi valori (stesse costanti fisiche). Il riscaldamento non si fitta: è descritto come salto."""
scarta = cfg["domini"]["scarta_dopo_salto_n"]
raffreddamenti = [i for i, d in enumerate(domini) if d["tipo"] == "raffreddamento"]
finale = raffreddamenti[-1] if raffreddamenti else None
risultati = [None] * len(domini)
fissi = {nome: {} for nome in serie} # serie -> parametri del raffreddamento
for idx in sorted(range(len(domini)), key=lambda i: domini[i]["tipo"] != "raffreddamento"):
fissi = {nome: {} for nome in serie}
for idx in sorted(range(len(domini)), key=lambda i: i != finale):
d = domini[idx]
i0, i1, t0 = d["i0"] + (scarta if d["tipo"] != "riposo" else 0), d["i1"], t[d["i0"]]
i0, i1, t0 = d["i0"] + (scarta if d["tipo"] == "raffreddamento" else 0), d["i1"], t[d["i0"]]
voce = {"k": idx + 1, "tipo": d["tipo"], "t0_s": float(t0), "t1_s": float(t[i1 - 1]),
"n": int(i1 - d["i0"]), "n_fit": int(i1 - i0), "serie": {}}
for nome, y in serie.items():
if d["tipo"] == "riscaldamento":
voce["serie"][nome] = _gradino(t, y, d)
continue
yy = y[i0:i1]
vs = voce["serie"][nome] = _serie(t[i0:i1] - t0, yy, sigma_rumore(yy), d["tipo"], fissi[nome], cfg)
if d["tipo"] == "raffreddamento" and vs["scelta"] and not fissi[nome]:
usa = fissi[nome] if d["tipo"] == "raffreddamento" and idx != finale else None
vs = voce["serie"][nome] = _serie(t[i0:i1] - t0, yy, sigma_rumore(yy), d["tipo"], usa, cfg)
if idx == finale and vs["scelta"]:
par = next(x for x in vs["fit"] if x["modello"] == vs["scelta"])["parametri"]
fissi[nome] = {k: par[k] for k in cfg["fit"]["riscaldamento_fissa_da_raffreddamento"] if k in par}
fissi[nome] = {k: par[k] for k in cfg["fit"]["raffreddamento_intermedio_fissa_da_finale"] if k in par}
risultati[idx] = voce
return risultati
+2 -1
View File
@@ -54,7 +54,8 @@ def cmd_analizza(args):
uscita.scrivi_report(cartelle["report"], risultati, Path(args.csv).name, list(serie))
for r in risultati:
for nome, vs in r["serie"].items():
print(f"dominio {r['k']} {r['tipo']:15s} {nome:5s} -> {vs['scelta'] or 'indeterminato'}")
esito = f"salto Δ = {vs['gradino']['delta']:+.1f}" if "gradino" in vs else vs["scelta"] or "indeterminato"
print(f"dominio {r['k']} {r['tipo']:15s} {nome:5s} -> {esito}")
print(f"scritto: {cartelle['sovrapposizione']}/, {cartelle['report']}/ (report.md, risultati.json)", file=sys.stderr)
+2 -2
View File
@@ -6,7 +6,7 @@ CHIAVI = {
"dati": ["colonna_tempo", "unita_tempo", "serie", "separatore"],
"domini": ["serie_riferimento", "soglia_gradino_c", "soglia_bordo_sigma",
"distanza_min_salti_campioni", "n_min_punti", "scarta_dopo_salto_n"],
"fit": ["modelli_per_tipo", "riscaldamento_fissa_da_raffreddamento", "perdita", "scala_perdita_sigma", "tau_min_rel", "tau_max_rel",
"fit": ["modelli_per_tipo", "raffreddamento_intermedio_fissa_da_finale", "perdita", "scala_perdita_sigma", "tau_min_rel", "tau_max_rel",
"n_griglia_tau", "soglia_condizione", "correggi_autocorrelazione", "soglia_acf",
"soglia_delta_aicc", "rmse_vs_rumore_max"],
"output": ["cartella_output"],
@@ -29,7 +29,7 @@ def leggi(percorso):
if mancanti:
raise ErroreConfig(f"chiavi mancanti in '{percorso}': {', '.join(mancanti)}")
mpt = cfg["fit"]["modelli_per_tipo"]
for tipo in ("riposo", "riscaldamento", "raffreddamento"):
for tipo in ("riposo", "raffreddamento"):
if not mpt.get(tipo):
raise ErroreConfig(f"[fit.modelli_per_tipo] manca la lista di modelli per '{tipo}'")
if cfg["dati"]["unita_tempo"] not in ("ms", "s"):
+20 -22
View File
@@ -1,4 +1,4 @@
"""Riconoscimento dei domini: riposo, gradini di riscaldamento, raffreddamento (nessun LLM)."""
"""Riconoscimento dei domini: riposo, riscaldamento (salita rapida), raffreddamento (decadimento) — nessun LLM."""
import numpy as np
@@ -16,7 +16,8 @@ def sigma_rumore(y):
def trova_gradini(y, cfg):
"""Indice del primo campione di transizione di ogni gradino di riscaldamento (salto positivo > soglia)."""
"""(i0, i1) dei campioni di salita di ogni gradino di riscaldamento (i1 escluso): salto positivo > soglia,
esteso ai campioni adiacenti ancora in salita (salto > soglia_bordo_sigma · σ dei salti)."""
c = cfg["domini"]
d = np.diff(y)
forti = np.flatnonzero(d > c["soglia_gradino_c"])
@@ -31,32 +32,29 @@ def trova_gradini(y, cfg):
gruppi.append(corrente)
corrente = [i]
gruppi.append(corrente)
inizi = []
gradini = []
for g in gruppi:
# il gradino parte dal primo campione già in salita: i campioni di transizione aprono il nuovo dominio
# (e sono esclusi dal fit da scarta_dopo_salto_n), così non sporcano la fine del dominio precedente
inizio = g[0]
inizio, fine = int(g[0]), int(g[-1])
while inizio > 0 and d[inizio - 1] > soglia_bordo:
inizio -= 1
inizi.append(int(inizio) + 1)
return inizi
while fine + 1 < len(d) and d[fine + 1] > soglia_bordo:
fine += 1
gradini.append((inizio + 1, min(fine + 2, len(y))))
return gradini
def proponi(t, serie, cfg):
"""Lista di domini {tipo, i0, i1} (i1 escluso) ordinati nel tempo."""
c = cfg["domini"]
y = serie[c["serie_riferimento"]]
inizi = trova_gradini(y, cfg)
tagli = [0, *inizi, len(y)]
domini = []
for k in range(len(tagli) - 1):
if not inizi or k == 0:
tipo = "riposo"
elif k == len(tagli) - 2:
tipo = "raffreddamento"
else:
tipo = "riscaldamento"
domini.append({"tipo": tipo, "i0": tagli[k], "i1": tagli[k + 1]})
"""Lista di domini {tipo, i0, i1} (i1 escluso): riposo, poi per ogni gradino un `riscaldamento` (la salita rapida,
pochi campioni) seguito da un `raffreddamento` (il decadimento fino al gradino successivo o alla fine)."""
y = serie[cfg["domini"]["serie_riferimento"]]
domini, prec = [], 0
for g0, g1 in trova_gradini(y, cfg):
if g0 > prec:
domini.append({"tipo": "riposo" if not domini else "raffreddamento", "i0": prec, "i1": g0})
domini.append({"tipo": "riscaldamento", "i0": g0, "i1": g1})
prec = g1
if prec < len(y):
domini.append({"tipo": "riposo" if not domini else "raffreddamento", "i0": prec, "i1": len(y)})
return domini
+147 -48
View File
@@ -13,8 +13,9 @@ matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
COLORI = {"riposo": "#7f8c8d", "riscaldamento": "#d93025", "raffreddamento": "#1a73e8"}
PASSI = {"domini": "01_domini", "sovrapposizione": "02_sovrapposizione", "report": "03_report"}
RIPOSO, RISCALDAMENTO = "#7f8c8d", "#d93025"
RAFFREDDAMENTI = ["#1a73e8", "#0f9d58", "#9334e6", "#e8710a", "#00acc1"] # un colore per ogni raffreddamento
def cartelle(base):
@@ -25,6 +26,18 @@ def cartelle(base):
return out
def colori_domini(tipi):
"""Un colore per dominio: grigio il riposo, rosso il riscaldamento, un colore diverso per ogni raffreddamento."""
n, out = 0, []
for tipo in tipi:
if tipo == "raffreddamento":
out.append(RAFFREDDAMENTI[n % len(RAFFREDDAMENTI)])
n += 1
else:
out.append(RIPOSO if tipo == "riposo" else RISCALDAMENTO)
return out
def _scelto(vs):
return next((x for x in vs["fit"] if x["modello"] == vs["scelta"]), None)
@@ -34,23 +47,65 @@ def _params(fit):
for k, v in fit["parametri"].items())
def _legenda_tipi(ax):
ax.legend(handles=[Patch(color=c, alpha=0.4, label=t) for t, c in COLORI.items()], fontsize=8, loc="upper right")
def _etichetta(k, tipo):
return f"{k} · {tipo}"
def _zoom(t, domini):
"""(x0, x1) della zona dei gradini (dal primo riscaldamento a qualche secondo dopo l'ultimo), None se non ce ne sono."""
risc = [d for d in domini if d["tipo"] == "riscaldamento"]
if not risc:
return None
return t[risc[0]["i0"]] - 2.0, min(t[risc[-1]["i1"] - 1] + 8.0, t[-1])
def _sfondo(ax, t, y, domini, colori, x0=None, x1=None):
"""Fasce colorate per dominio; numerati se abbastanza larghi per [x0, x1]; i campioni di riscaldamento sono evidenziati."""
x0, x1 = (t[0], t[-1]) if x0 is None else (x0, x1)
for k, (d, c) in enumerate(zip(domini, colori, strict=True), 1):
ax.axvspan(t[d["i0"]], t[d["i1"] - 1], color=c, alpha=0.18 if d["tipo"] != "riscaldamento" else 0.35)
if t[d["i1"] - 1] - t[d["i0"]] > 0.03 * (x1 - x0) and x0 <= t[d["i0"]] <= x1:
ax.text(t[d["i0"]], 1.0, f" {k}", transform=ax.get_xaxis_transform(), va="top", fontsize=9,
fontweight="bold", clip_on=True)
if d["tipo"] == "riscaldamento":
ax.plot(t[d["i0"]:d["i1"]], y[d["i0"]:d["i1"]], "o", ms=4, color=RISCALDAMENTO, zorder=5)
def _legenda(ax, domini, colori):
ax.legend(handles=[Patch(color=c, alpha=0.5, label=_etichetta(k, d["tipo"]))
for k, (d, c) in enumerate(zip(domini, colori, strict=True), 1)], fontsize=7, loc="upper right",
ncol=2)
def _limiti_y(ax, t, y, x0, x1):
m = (t >= x0) & (t <= x1)
if m.any():
margine = 0.05 * (y[m].max() - y[m].min() or 1.0)
ax.set_ylim(y[m].min() - margine, y[m].max() + margine)
def grafico_domini(percorso, t, serie, domini):
"""Passo 1: dati grezzi con la suddivisione in domini (colore per tipo), senza alcun fit."""
fig, assi = plt.subplots(len(serie), 1, figsize=(11, 3.8 * len(serie)), sharex=True, squeeze=False)
for ax, (nome, y) in zip(assi[:, 0], serie.items(), strict=True):
ax.plot(t, y, ".", ms=2, color="#333")
for k, d in enumerate(domini, 1):
ax.axvspan(t[d["i0"]], t[d["i1"] - 1], color=COLORI[d["tipo"]], alpha=0.15)
ax.text(t[d["i0"]], ax.get_ylim()[1], f" {k}", va="top", fontsize=9, fontweight="bold")
ax.set_ylabel(nome)
ax.grid(alpha=0.3)
_legenda_tipi(assi[0, 0])
"""Passo 1: dati grezzi con la suddivisione in domini; a destra lo zoom sulla zona dei gradini."""
colori = colori_domini([d["tipo"] for d in domini])
zoom = _zoom(t, domini)
colonne = 2 if zoom else 1
fig, assi = plt.subplots(len(serie), colonne, figsize=(8 + 5 * (colonne - 1), 3.8 * len(serie)), squeeze=False,
gridspec_kw={"width_ratios": [3, 2][:colonne]})
for riga, (nome, y) in zip(assi, serie.items(), strict=True):
for j, ax in enumerate(riga):
ax.plot(t, y, ".", ms=2 if j == 0 else 4, color="#333")
_sfondo(ax, t, y, domini, colori, *(zoom if j == 1 else (None, None)))
ax.grid(alpha=0.3)
if j == 1:
ax.set_xlim(*zoom)
_limiti_y(ax, t, y, *zoom)
riga[0].set_ylabel(nome)
_legenda(assi[0, 0], domini, colori)
assi[0, 0].set_title("Dati grezzi e suddivisione in domini", fontsize=11)
assi[-1, 0].set_xlabel("t [s]")
if zoom:
assi[0, 1].set_title("Zoom sui gradini", fontsize=11)
for ax in assi[-1]:
ax.set_xlabel("t [s]")
fig.tight_layout()
fig.savefig(percorso, dpi=120)
plt.close(fig)
@@ -62,41 +117,76 @@ def _curva(r, vs, tt):
def sovrapposizione_generale(cartella, t, serie, risultati, domini, cfg):
"""Passo 2a: per ogni serie, dati grezzi con le equazioni scelte (a tratti) e residui (dei soli campioni usati nel fit)."""
"""Passo 2a: per ogni serie dati grezzi con le equazioni scelte e residui (dei soli campioni usati nel fit);
a destra lo zoom sulla zona dei gradini."""
scarta = cfg["domini"]["scarta_dopo_salto_n"]
colori = colori_domini([d["tipo"] for d in domini])
zoom = _zoom(t, domini)
colonne = 2 if zoom else 1
for nome, y in serie.items():
fig, (a, b) = plt.subplots(2, 1, figsize=(11, 7), sharex=True, height_ratios=[3, 1])
a.plot(t, y, ".", ms=2, color="#555", label="dati grezzi")
for r, d in zip(risultati, domini, strict=True):
a.axvspan(t[d["i0"]], t[d["i1"] - 1], color=COLORI[r["tipo"]], alpha=0.10)
idx = np.arange(d["i0"], d["i1"])
yf = _curva(r, r["serie"][nome], t[idx])
if yf is not None:
a.plot(t[idx], yf, "-", lw=2, color=COLORI[r["tipo"]])
dal = scarta if r["tipo"] != "riposo" else 0
b.plot(t[idx][dal:], (y[idx] - yf)[dal:], ".", ms=2, color="#555")
b.axhline(0, color="k", lw=0.5)
a.set_ylabel(nome)
b.set_ylabel("residuo")
b.set_xlabel("t [s]")
a.set_title(f"{nome}: dati grezzi contro le equazioni trovate", fontsize=11)
_legenda_tipi(a)
a.grid(alpha=0.3)
b.grid(alpha=0.3)
fig, assi = plt.subplots(2, colonne, figsize=(8 + 5 * (colonne - 1), 7), sharex="col", squeeze=False,
gridspec_kw={"width_ratios": [3, 2][:colonne], "height_ratios": [3, 1]})
for j in range(colonne):
a, b = assi[0, j], assi[1, j]
a.plot(t, y, ".", ms=2 if j == 0 else 4, color="#555", label="dati grezzi")
_sfondo(a, t, y, domini, colori, *(zoom if j == 1 else (None, None)))
for r, d, c in zip(risultati, domini, colori, strict=True):
idx = np.arange(d["i0"], d["i1"])
yf = _curva(r, r["serie"][nome], t[idx])
if yf is None:
continue
dal = scarta if r["tipo"] == "raffreddamento" else 0
a.plot(t[idx][dal:], yf[dal:], "-", lw=2, color=c)
b.plot(t[idx][dal:], (y[idx] - yf)[dal:], ".", ms=2 if j == 0 else 4, color=c)
b.axhline(0, color="k", lw=0.5)
a.grid(alpha=0.3)
b.grid(alpha=0.3)
b.set_xlabel("t [s]")
if j == 1:
a.set_xlim(*zoom)
_limiti_y(a, t, y, *zoom)
assi[0, 0].set_ylabel(nome)
assi[1, 0].set_ylabel("residuo")
assi[0, 0].set_title(f"{nome}: dati grezzi contro le equazioni trovate", fontsize=11)
_legenda(assi[0, 0], domini, colori)
fig.tight_layout()
fig.savefig(cartella / f"sovrapposizione_{nome}.png", dpi=120)
plt.close(fig)
def _grafico_gradino(percorso, t, y, d, r, nome):
"""Riscaldamento: nessun fit, si mostra la salita (campioni rossi) con un po' di contesto."""
g = r["serie"][nome]["gradino"]
a, b = max(d["i0"] - 8, 0), min(d["i1"] + 8, len(t))
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(t[a:b], y[a:b], "o-", ms=4, lw=0.6, color="#888", label="dati grezzi")
ax.plot(t[d["i0"]:d["i1"]], y[d["i0"]:d["i1"]], "o", ms=7, color=RISCALDAMENTO, label="salita (riscaldamento)")
ax.set_title(f"Dominio {r['k']} — riscaldamento — {nome}: Δ = {g['delta']:+.1f} in {g['n']} campioni "
f"({g['durata_s'] * 1000:.0f} ms)", fontsize=9)
ax.set_xlabel("t [s]")
ax.set_ylabel(nome)
ax.legend(fontsize=8)
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(percorso, dpi=120)
plt.close(fig)
def sovrapposizione_domini(cartella, t, serie, risultati, domini, cfg):
"""Passo 2b: un PNG per (dominio, serie): dati grezzi, modello scelto, altri modelli validi, residui."""
"""Passo 2b: un PNG per (dominio, serie): dati grezzi, modello scelto, altri modelli validi, residui;
per il riscaldamento (non fittato) la salita con un po' di contesto."""
scarta = cfg["domini"]["scarta_dopo_salto_n"]
for r, d in zip(risultati, domini, strict=True):
i0 = d["i0"] + (scarta if d["tipo"] != "riposo" else 0)
colori = colori_domini([d["tipo"] for d in domini])
for r, d, c in zip(risultati, domini, colori, strict=True):
i0 = d["i0"] + (scarta if r["tipo"] == "raffreddamento" else 0)
tutti, usati = slice(d["i0"], d["i1"]), slice(i0, d["i1"])
tt = np.linspace(t[i0], t[d["i1"] - 1], 400)
for nome, vs in r["serie"].items():
y, scelto = serie[nome], _scelto(vs)
destinazione = cartella / f"dominio{r['k']}_{nome}.png"
if r["tipo"] == "riscaldamento":
_grafico_gradino(destinazione, t, y, d, r, nome)
continue
fig, (a, b) = plt.subplots(2, 1, figsize=(9, 6), sharex=True, height_ratios=[3, 1])
a.plot(t[tutti], y[tutti], ".", ms=4, color="#aaa", label="dati (esclusi dal fit)")
a.plot(t[usati], y[usati], ".", ms=5, color="#222", label="dati grezzi")
@@ -106,7 +196,7 @@ def sovrapposizione_domini(cartella, t, serie, risultati, domini, cfg):
alpha=0.8, label=f"{x['modello']} (RMSE {x['rmse']:.3g})")
if scelto:
p = list(scelto["parametri"].values())
a.plot(tt, valuta(scelto["modello"], tt - r["t0_s"], p), "-", lw=2.2, color=COLORI[r["tipo"]],
a.plot(tt, valuta(scelto["modello"], tt - r["t0_s"], p), "-", lw=2.2, color=c,
label=f"scelto: {scelto['modello']} (RMSE {scelto['rmse']:.3g})")
b.plot(t[usati], y[usati] - valuta(scelto["modello"], t[usati] - r["t0_s"], p), ".", ms=4, color="#222")
b.axhline(0, color="k", lw=0.5)
@@ -117,38 +207,47 @@ def sovrapposizione_domini(cartella, t, serie, risultati, domini, cfg):
a.legend(fontsize=8)
a.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(cartella / f"dominio{r['k']}_{nome}.png", dpi=120)
fig.savefig(destinazione, dpi=120)
plt.close(fig)
def _riga_equazione(r, nome, vs):
if "gradino" in vs:
g = vs["gradino"]
return (f"| {r['k']} | {r['tipo']} | {nome} | salto | Δ = {g['delta']:+.2f} ({g['da']:.2f} → {g['a']:.2f}) "
f"in {g['n']} campioni, {g['durata_s'] * 1000:.0f} ms (non fittato) |")
fit = _scelto(vs)
return (f"| {r['k']} | {r['tipo']} | {nome} | {fit['modello'] if fit else '—'} | "
+ (f"{fit['equazione']} — {_params(fit)}" if fit else f"indeterminato: {vs['motivo']}") + " |")
def scrivi_report(cartella, risultati, nome_csv, serie_nomi):
"""Passo 3: report.md (immagini dai passi 1 e 2 con percorsi relativi) e risultati.json."""
L = [f"# Report fitseg — {nome_csv}\n", (f"Data: {date.today().isoformat()} · Serie: {', '.join(serie_nomi)} · "
"t' = t − t0 (inizio del dominio) · intervalli di confidenza al 95 %\n"),
"t' = t − t0 (inizio del dominio) · intervalli di confidenza al 95 %\n"),
"## 1. Suddivisione in domini\n", f"![Suddivisione in domini](../{PASSI['domini']}/domini.png)\n",
"| # | tipo | t0 [s] | t1 [s] | campioni |", "|---|---|---|---|---|"]
L += [f"| {r['k']} | {r['tipo']} | {r['t0_s']:.2f} | {r['t1_s']:.2f} | {r['n']} |" for r in risultati]
L += ["", f"Domini modificabili in `../{PASSI['domini']}/domini.toml`.\n", "## 2. Equazioni trovate\n",
L += ["", ("Il riscaldamento è la salita rapida (pochi campioni, non fittata); ogni raffreddamento ha un colore diverso. "
f"Domini modificabili in `../{PASSI['domini']}/domini.toml`.\n"), "## 2. Equazioni trovate\n",
"| dominio | tipo | serie | modello | equazione e parametri |", "|---|---|---|---|---|"]
for r in risultati:
for nome, vs in r["serie"].items():
fit = _scelto(vs)
L.append(f"| {r['k']} | {r['tipo']} | {nome} | {fit['modello'] if fit else '—'} | "
+ (f"{fit['equazione']} — {_params(fit)}" if fit else f"indeterminato: {vs['motivo']}") + " |")
L += [_riga_equazione(r, nome, vs) for r in risultati for nome, vs in r["serie"].items()]
L += ["", "## 3. Sovrapposizione dati–equazioni\n"]
L += [f"![{n}](../{PASSI['sovrapposizione']}/sovrapposizione_{n}.png)\n" for n in serie_nomi]
L.append("## 4. Dettaglio per dominio\n")
for r in risultati:
L.append(f"### Dominio {r['k']} — {r['tipo']} ({r['t0_s']:.2f}–{r['t1_s']:.2f} s)\n")
for nome, vs in r["serie"].items():
L += [f"#### {nome}\n", f"![dominio {r['k']} {nome}](../{PASSI['sovrapposizione']}/dominio{r['k']}_{nome}.png)\n"]
if "gradino" in vs:
L.append(f"{vs['motivo'].capitalize()}: {_riga_equazione(r, nome, vs).split('|')[5].strip()}.\n")
continue
fit = _scelto(vs)
L += [f"#### {nome} (σ rumore {vs['sigma']:.3g})\n",
f"![dominio {r['k']} {nome}](../{PASSI['sovrapposizione']}/dominio{r['k']}_{nome}.png)\n"]
if not fit:
L.append(f"**Indeterminato** — {vs['motivo']}\n")
continue
L.append(f"**Scelto: {fit['modello']}** — {fit['equazione']} \n{_params(fit)} \n"
f"RMSE {fit['rmse']:.3g} · R² {fit['r2']:.4f} · ACF1 {fit['acf1']:.2f}"
f"σ rumore {vs['sigma']:.3g} · RMSE {fit['rmse']:.3g} · R² {fit['r2']:.4f} · ACF1 {fit['acf1']:.2f}"
f"{'' if vs['adeguato'] else ' · **modello non adeguato** (RMSE > soglia·σ)'} \n"
f"Motivo: {vs['motivo']}\n")
L += ["| modello | k | RMSE | AICc | BIC | ACF1 | valido |", "|---|---|---|---|---|---|---|"]
+11 -3
View File
@@ -14,13 +14,21 @@ def tipi(t, serie, cfg):
def test_tre_gradini(sintetico, cfg):
t, serie = sintetico
assert tipi(t, serie, cfg) == ["riposo", "riscaldamento", "riscaldamento", "raffreddamento"]
assert tipi(t, serie, cfg) == ["riposo", "riscaldamento", "raffreddamento", "riscaldamento", "raffreddamento",
"riscaldamento", "raffreddamento"]
def test_riscaldamento_e_la_salita_rapida(sintetico, cfg):
t, serie = sintetico
for d in domini.proponi(t, serie, cfg):
if d["tipo"] == "riscaldamento":
assert d["i1"] - d["i0"] <= 3 # pochi campioni
def test_confini_vicini_agli_istanti_dei_gradini(sintetico, cfg):
t, serie = sintetico
ds = domini.proponi(t, serie, cfg)
for d, (tj, _) in zip(ds[1:], GRADINI, strict=True):
for d, (tj, _) in zip(ds[1::2], GRADINI, strict=True): # i riscaldamenti sono i domini 2, 4, 6
assert abs(t[d["i0"]] - tj) < 0.5
@@ -38,7 +46,7 @@ def test_senza_gradini_un_solo_riposo(cfg):
def test_un_solo_gradino(cfg):
t, serie = segnale(gradini=[(20.0, 80.0)])
assert tipi(t, serie, cfg) == ["riposo", "raffreddamento"]
assert tipi(t, serie, cfg) == ["riposo", "riscaldamento", "raffreddamento"]
def test_gradino_sotto_soglia_ignorato(cfg):
+29 -18
View File
@@ -55,28 +55,39 @@ def test_tau_libero_non_identificabile_su_tratto_corto_e_rumoroso(cfg, seed):
def test_analisi_completa_sintetico(sintetico, cfg):
t, serie = sintetico
ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg)
assert [r["tipo"] for r in ris] == ["riposo", "riscaldamento", "riscaldamento", "raffreddamento"]
assert [r["tipo"] for r in ris] == ["riposo", "riscaldamento", "raffreddamento", "riscaldamento", "raffreddamento",
"riscaldamento", "raffreddamento"]
for nome in serie:
assert ris[0]["serie"][nome]["scelta"] in ("costante", "lineare")
raff = next(x for x in ris[3]["serie"][nome]["fit"] if x["modello"] == "esp_decad")["parametri"]
assert raff["tau"] == pytest.approx(TAU, rel=0.03)
for r in ris[1:3]: # riscaldamento: c e τ ereditati dal raffreddamento
fit = next(x for x in r["serie"][nome]["fit"] if x["modello"] == "esp_decad")
assert r["serie"][nome]["scelta"] == "esp_decad" and fit["fissi"] == ["c", "tau"]
assert fit["parametri"]["tau"] == raff["tau"] and fit["parametri"]["c"] == raff["c"]
# ampiezza del secondo tratto = somma dei decadimenti dei gradini precedenti + il nuovo, in t'=0
t1 = ris[1]["t0_s"]
attesa = sum(a * np.exp(-(t1 - tj) / TAU) for tj, a in GRADINI[:1])
fit1 = next(x for x in ris[1]["serie"]["dT_C"]["fit"] if x["modello"] == "esp_decad")
assert fit1["parametri"]["A"] == pytest.approx(attesa, rel=0.05)
finale = next(x for x in ris[6]["serie"][nome]["fit"] if x["modello"] == "esp_decad")
assert finale["fissi"] == [] and finale["parametri"]["tau"] == pytest.approx(TAU, rel=0.03)
for i in (2, 4): # raffreddamenti intermedi: c e τ ereditati dal finale
fit = next(x for x in ris[i]["serie"][nome]["fit"] if x["modello"] == "esp_decad")
assert ris[i]["serie"][nome]["scelta"] == "esp_decad" and fit["fissi"] == ["c", "tau"]
assert fit["parametri"]["tau"] == finale["parametri"]["tau"] and fit["parametri"]["c"] == finale["parametri"]["c"]
# ampiezza dei raffreddamenti intermedi = somma dei decadimenti dei gradini già avvenuti, in t' = 0
for i, n_gradini in ((2, 1), (4, 2)):
t0 = ris[i]["t0_s"]
attesa = sum(a * np.exp(-(t0 - tj) / TAU) for tj, a in GRADINI[:n_gradini])
fit = next(x for x in ris[i]["serie"]["dT_C"]["fit"] if x["modello"] == "esp_decad")
assert fit["parametri"]["A"] == pytest.approx(attesa, rel=0.05)
def test_senza_raffreddamento_riscaldamento_resta_libero(cfg):
t, serie = segnale(gradini=[(20.0, 80.0)], durata=40.0)
ds = [{"tipo": "riposo", "i0": 0, "i1": 100}, {"tipo": "riscaldamento", "i0": 100, "i1": len(t)}]
ris = analizza_domini(t, serie, ds, cfg) # nessun dominio di raffreddamento: niente parametri da ereditare
for x in ris[1]["serie"]["T_C"]["fit"]:
assert x["fissi"] == []
def test_riscaldamento_descritto_come_salto_non_fittato(sintetico, cfg):
t, serie = sintetico
ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg)
for i, (_, ampiezza) in zip((1, 3, 5), GRADINI, strict=True):
vs = ris[i]["serie"]["dT_C"]
assert vs["fit"] == [] and vs["scelta"] is None
assert vs["gradino"]["delta"] == pytest.approx(ampiezza, rel=0.15) # salto sul canale ΔT, meno il decadimento
assert vs["gradino"]["n"] <= 3
def test_un_solo_raffreddamento_resta_libero(cfg):
t, serie = segnale(gradini=[(20.0, 80.0)], durata=60.0)
ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg)
assert [r["tipo"] for r in ris] == ["riposo", "riscaldamento", "raffreddamento"]
assert all(x["fissi"] == [] for x in ris[2]["serie"]["T_C"]["fit"])
def test_dominio_troppo_corto_indeterminato(sintetico, cfg):
+10 -9
View File
@@ -15,7 +15,8 @@ def analizza(csv, cfg):
def test_domini_su_csv_gen(csv_gen, cfg):
ds, _ = analizza(csv_gen, cfg)
assert [d["tipo"] for d in ds] == ["riposo", "riscaldamento", "riscaldamento", "raffreddamento"]
assert [d["tipo"] for d in ds] == ["riposo", "riscaldamento", "raffreddamento", "riscaldamento", "raffreddamento",
"riscaldamento", "raffreddamento"]
def test_raffreddamento_tau_nominale(csv_gen, cfg):
@@ -26,13 +27,13 @@ def test_raffreddamento_tau_nominale(csv_gen, cfg):
assert fit["parametri"]["tau"] == pytest.approx(15.9, rel=0.1)
def test_riscaldamento_eredita_costanti(csv_gen, cfg):
def test_raffreddamenti_intermedi_ereditano_costanti(csv_gen, cfg):
_, ris = analizza(csv_gen, cfg)
for nome in ris[0]["serie"]:
raff = next(x for x in ris[-1]["serie"][nome]["fit"] if x["modello"] == "esp_decad")["parametri"]
for r in ris[1:3]:
fit = next(x for x in r["serie"][nome]["fit"] if x["modello"] == "esp_decad")
assert fit["fissi"] == ["c", "tau"] and fit["parametri"]["tau"] == raff["tau"] and fit["parametri"]["A"] > 0
finale = next(x for x in ris[-1]["serie"][nome]["fit"] if x["modello"] == "esp_decad")["parametri"]
for i in (2, 4):
fit = next(x for x in ris[i]["serie"][nome]["fit"] if x["modello"] == "esp_decad")
assert fit["fissi"] == ["c", "tau"] and fit["parametri"]["tau"] == finale["tau"] and fit["parametri"]["A"] > 0
def test_cli_domini_e_analizza(tmp_path, capsys):
@@ -45,12 +46,12 @@ def test_cli_domini_e_analizza(tmp_path, capsys):
assert (out / "01_domini/domini.toml").exists() and (out / "01_domini/domini.png").exists()
assert not (out / "03_report").exists() or not any((out / "03_report").iterdir()) # `domini` non fa nessun fit
cli.main([*base, "analizza", str(csv), "--domini", str(out / "01_domini/domini.toml")]) # domini corretti a mano
for f in ("02_sovrapposizione/sovrapposizione_T_C.png", "02_sovrapposizione/dominio4_T_C.png",
for f in ("02_sovrapposizione/sovrapposizione_T_C.png", "02_sovrapposizione/dominio7_T_C.png",
"03_report/report.md", "03_report/risultati.json"):
assert (out / f).exists(), f
report = (out / "03_report/report.md").read_text()
assert "../01_domini/domini.png" in report and "../02_sovrapposizione/dominio4_T_C.png" in report
assert "dominio 4 raffreddamento" in capsys.readouterr().out
assert "../01_domini/domini.png" in report and "../02_sovrapposizione/dominio7_T_C.png" in report
assert "dominio 7 raffreddamento" in capsys.readouterr().out
def test_cli_errori(tmp_path):