diff --git a/CLAUDE.md b/CLAUDE.md index 720e742..8d0971f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 9529313..8236128 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config.toml b/config.toml index bce8257..dd2e017 100644 --- a/config.toml +++ b/config.toml @@ -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] diff --git a/docs/SPEC.md b/docs/SPEC.md index 73f0a44..3efad6a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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//01_domini/ (domini.png, domini.toml modificabile) +CSV ─► [1 DOMINI] gradini ⇒ riposo | (riscaldamento, raffreddamento) per ogni gradino ─► output//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`. diff --git a/src/fitseg/analisi.py b/src/fitseg/analisi.py index b6a5a2f..bbb3496 100644 --- a/src/fitseg/analisi.py +++ b/src/fitseg/analisi.py @@ -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 diff --git a/src/fitseg/cli.py b/src/fitseg/cli.py index 0b38dbe..8fbb238 100644 --- a/src/fitseg/cli.py +++ b/src/fitseg/cli.py @@ -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) diff --git a/src/fitseg/config.py b/src/fitseg/config.py index 85e057c..e52b407 100644 --- a/src/fitseg/config.py +++ b/src/fitseg/config.py @@ -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"): diff --git a/src/fitseg/domini.py b/src/fitseg/domini.py index ecc3e4d..9da3b4d 100644 --- a/src/fitseg/domini.py +++ b/src/fitseg/domini.py @@ -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 diff --git a/src/fitseg/uscita.py b/src/fitseg/uscita.py index c1b31a1..e656d16 100644 --- a/src/fitseg/uscita.py +++ b/src/fitseg/uscita.py @@ -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 |", "|---|---|---|---|---|---|---|"] diff --git a/tests/test_domini.py b/tests/test_domini.py index 8eb5f46..3a405fa 100644 --- a/tests/test_domini.py +++ b/tests/test_domini.py @@ -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): diff --git a/tests/test_fit.py b/tests/test_fit.py index 70d4587..0ffcb0d 100644 --- a/tests/test_fit.py +++ b/tests/test_fit.py @@ -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): diff --git a/tests/test_integrazione.py b/tests/test_integrazione.py index aeeff70..77e1652 100644 --- a/tests/test_integrazione.py +++ b/tests/test_integrazione.py @@ -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):