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:
1 parent
da0964be4d
commit
b49af5c112
12 files changed
+277
-142
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"\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" 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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user