Compare commits

...
10 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 e98e19a78c CLAUDE.md: extern/gen_data, numero e durata dei test, comando per generare i CSV di prova
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 08:57:40 +01:00
davideandClaude Sonnet 5.5 875d480df1 gen_data scaricato in extern/ (gitignorato) con scarica_gen_data.sh; test di validazione e documentazione collegati
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 08:57:20 +01:00
davideandClaude Sonnet 5.5 5e8ed1a8a1 Documentazione: limiti aperti (soglia gradini, falsi «non adeguato», riposo), test aggiunti, durata test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 13:32:23 +01:00
davideandClaude Sonnet 5.5 cfb1eb66a5 Test: errori di configurazione e CSV, domini.toml non validi, exit code CLI, varianti di report e grafici
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 13:32:23 +01:00
davideandClaude Sonnet 5.5 bb2e3bd2d4 fit: dati non finiti restituiscono «non convergente» invece di sollevare TypeError
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 13:32:23 +01:00
davideandClaude Sonnet 5.5 c37ca1e03d Documentazione aggiornata: assunzioni A17/A25, limiti, algoritmi, test, output e figura
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 11:54:09 +01:00
davideandClaude Sonnet 5.5 b223cf5fdc Test: validazione contro i parametri veri di gen_data, report, CLI e regressioni
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 11:54:09 +01:00
davideandClaude Sonnet 5.5 cbcbb7122b Analisi più robusta: sigma con almeno 8 campioni, adeguatezza sulla scala dei residui, salti dai modelli
- sigma stimata solo con >= 8 campioni (altrimenti null); risultati.json senza NaN
- adeguatezza = scala robusta dei residui (scala_residui_vs_rumore_max)
- scarta_dopo_salto_n = 0, n_min_punti_parametri_fissi per i raffreddamenti a parametri fissi
- salto del riscaldamento stimato dai modelli adiacenti, con ripiego sulla misura corretta

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 11:54:09 +01:00
davideandClaude Sonnet 5.5 512d15f4c8 Aggiunge pulisci.sh per svuotare output/ e input/*.csv
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 11:54:09 +01:00
davideandClaude Sonnet 5.5 f28bddc5a2 Documentazione completa in docs/: assunzioni, algoritmi, moduli, configurazione, output, test, limiti
- docs/SPEC.md sostituito da un file per argomento (indice in docs/README.md)
- assunzioni A1-A24 con dove nel codice e cosa succede se false
- formule in LaTeX corrette (nessuno spazio prima del dollaro di chiusura), simboli e chiavi in tabella
- diagrammi Mermaid per il flusso degli algoritmi e le dipendenze dei moduli
- figura dei domini sul seed 1 (docs/img/domini_esempio.png)
- README ridotto a indice con avvio rapido; CLAUDE.md e docstring allineati

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:44:41 +01:00
33 changed files with 1682 additions and 314 deletions

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ __pycache__/
*.egg-info/
.pytest_cache/
input/*.csv
extern/
+21 -10
View File
@@ -2,20 +2,22 @@
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 = 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.
`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–4 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).
Fonti di verità (non duplicarne il contenuto): la documentazione in [docs/](docs/README.md) — [assunzioni](docs/assunzioni.md) (A1–A25, richiamate nel codice), [algoritmi](docs/algoritmi.md), [moduli](docs/moduli.md), [configurazione](docs/configurazione.md), [output](docs/output.md), [test](docs/test.md), [limiti](docs/limiti.md) — e [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 [docs/configurazione.md](docs/configurazione.md). Il [README](README.md) è solo un indice con l'avvio rapido. **Quando cambi comportamento aggiorna il file di docs corrispondente** (e le assunzioni se ne cambia una).
## Comandi
```bash
uv venv .venv && uv pip install -e ".[dev]"
.venv/bin/fitseg domini input/gen_1.csv # passo 1: output/gen_1/01_domini/ (domini.png, domini.toml), nessun fit
.venv/bin/fitseg analizza input/gen_1.csv [--domini output/gen_1/01_domini/domini.toml] # tutti i passi → output/gen_1/{01_domini,02_sovrapposizione,03_report}
.venv/bin/fitseg domini input/gen_7.csv # passo 1: output/gen_7/01_domini/ (domini.png, domini.toml), nessun fit
.venv/bin/fitseg analizza input/gen_7.csv [--domini output/gen_7/01_domini/domini.toml] # tutti i passi → output/gen_7/{01_domini,02_sovrapposizione,03_report}
.venv/bin/pytest # tutti; singolo: -k raffreddamento o tests/test_fit.py
.venv/bin/ruff check src tests
./scarica_gen_data.sh [-u] # clona/aggiorna gen_data in extern/ (serve a test_validazione)
./pulisci.sh [-n] [-y] [--solo-output] # svuota output/ e cancella input/*.csv (con conferma); -n dry-run, --solo-output lascia input/
```
`--config`/`--output` vanno **prima** del sottocomando. `input/` contiene i CSV da analizzare; `output/` e `input/*.csv` sono ignorati da git. Ogni analisi scrive in `output/<nome csv>/` (`01_domini`, `02_sovrapposizione`, `03_report`, vedi `uscita.PASSI`).
`--config`/`--output` vanno **prima** del sottocomando. `input/` contiene i CSV da analizzare; `output/`, `input/*.csv` e `extern/` (clone di `gen_data`) sono ignorati da git. Ogni analisi scrive in `output/<nome csv>/` (`01_domini`, `02_sovrapposizione`, `03_report`, vedi `uscita.PASSI`).
## Architettura (`src/fitseg/`, un modulo per responsabilità)
@@ -24,15 +26,24 @@ uv venv .venv && uv pip install -e ".[dev]"
- 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.
- Nuovo modello = voce in `modelli.py` + riga in [docs/algoritmi.md](docs/algoritmi.md) §5.1 e in [docs/moduli.md](docs/moduli.md) + nome in `[fit.modelli_per_tipo]`. Non ci sono partenze casuali: il programma è deterministico.
## Test
Unitari su un segnale sintetico generato in [tests/conftest.py](tests/conftest.py) (τ = 16 s, 3 gradini, nessun disturbo a salto): non servono i CSV. Integrazione su `input/gen_{1,2,3}.csv` (prodotti da `../gen_data`: `.venv/bin/python genera.py config.toml --seed N -o ...`) e sulla CLI: saltati se i CSV mancano. Verità di `gen_data`: τ = 1/k = 15.9 s (k perturbato del 2 % per seed), 3 gradini a ≈15.7/17.8/21.7 s, ambiente ≈ 23 °C, scarti casuali ≤ 4 °C che rendono i residui autocorrelati (limite noto, non un bug).
`.venv/bin/pytest` (96 test, ≈ 40 s). Senza `extern/gen_data` i 20 di validazione sono saltati, gli altri non richiedono file esterni. Unitari su un segnale sintetico in [tests/conftest.py](tests/conftest.py) (τ = 16 s, 3 gradini, nessun disturbo a salto);
[tests/test_validazione.py](tests/test_validazione.py) genera 20 seed con `extern/gen_data` (clone gitignorato creato da `./scarica_gen_data.sh`; saltato se manca) e confronta gradini, τ, ampiezze e salti con i **parametri veri** sorteggiati
(`genera.sorteggia`): è il test che protegge da regressioni sul comportamento reale. [tests/test_report.py](tests/test_report.py) e [tests/test_cli.py](tests/test_cli.py) controllano report, grafici e CLI su CSV sintetici.
`gen_data` sorteggia a ogni seed numero di gradini (1–3), k (τ = 11–20 s), rumore e scarti (≤ 4 °C, residui autocorrelati: limite noto, non un bug).
## Gotcha
- 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.
- Il gradino si rileva sommando i campioni consecutivi in salita (`soglia_bordo_sigma`): la soglia `soglia_gradino_c` è sulla **salita complessiva**, non sul singolo salto (un gradino su 2 campioni può avere ogni salto sotto soglia). La salita appartiene al `riscaldamento`; se `soglia_bordo_sigma` è troppo alta un campione parziale può finire nel raffreddamento (`scarta_dopo_salto_n` lo esclude dal fit).
- Raffreddamenti intermedi corti: con gli scarti casuali τ non è identificabile da solo (su dati puliti sì), quindi c e τ sono fissi ai valori del finale e basta `n_min_punti_parametri_fissi` (4) campioni invece di `n_min_punti` (6); gli IC di `A` sono condizionati. Senza un finale valido tornano liberi e possono risultare indeterminati.
- Il salto Δ del riscaldamento è stimato dai modelli adiacenti (`analisi._affina_salti`, `stima` = `"modelli"`); se manca un modello vicino, dalla misura riportata all'istante del gradino con c e τ del finale (`"misura corretta"`); altrimenti dalla misura sui campioni, che è per difetto (fino a ≈ 10–20 %).
- **σ si stima con almeno 8 campioni** (`domini.N_MIN_SIGMA`): con meno la MAD di pochi d²y quantizzati può annullarsi e la perdita robusta degenera (fit scartato come «non identificabile»). Sotto 8, `sigma` è `null` e l'adeguatezza `null`. `risultati.json` non deve mai contenere NaN (`uscita._senza_nan`, `allow_nan=False`).
- «Adeguato» = scala **robusta** dei residui (1.4826·MAD) ≤ `scala_residui_vs_rumore_max`·σ, non la RMSE: gli scarti casuali gonfiano la RMSE ma non la scala robusta (vedi docs/algoritmi.md §6). `scarta_dopo_salto_n` vale 0 (la salita è già nel riscaldamento).
- Gli intervalli di confidenza coprono il τ vero solo nel 73 % dei casi (disturbi non stazionari, concentrati a inizio raffreddamento): correzione a più lag e sandwich HAC provate e scartate (docs/limiti.md). Non cambiare lo stimatore senza rimisurare la copertura.
- Limiti aperti (docs/limiti.md): falso «non adeguato» su ~metà dei raffreddamenti intermedi corti; `soglia_gradino_c` fissa (20 °C) non derivata dai dati; modello del riposo non unico (`T_C` e `dT_C` possono differire).
- `extern/gen_data` non è fissato a un commit: `./scarica_gen_data.sh -u` porta l'ultima versione, che può cambiare i seed. CSV di prova: `(cd extern/gen_data && ../../.venv/bin/python genera.py config.toml --seed 7 -o ../../input/gen_7.csv)`.
- Cambiando la config di `gen_data` (gradini, rumore) ricontrolla `soglia_gradino_c` e le tolleranze di `tests/test_validazione.py`.
- Exit code: 0 ok · 2 uso · 3 config · 4 CSV · 6 domini non validi.
+24 -98
View File
@@ -1,106 +1,32 @@
# fitseg
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.
Legge un CSV `t_ms,T_C,dT_C` (temperatura di un sensore IR che si scalda a gradini e si raffredda), lo divide in **domini** (riposo, riscaldamento, raffreddamento) e
trova per ognuno l'equazione che lo approssima meglio. Produce, per ogni CSV, i grafici della suddivisione, la sovrapposizione dei dati grezzi con le equazioni trovate
e un report in Markdown con formule in LaTeX.
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.
## Documentazione
## Installazione
Tutto è spiegato in **[docs/](docs/README.md)**: assunzioni, algoritmi, moduli, configurazione, output, test, limiti.
Python ≥ 3.11 e [uv](https://docs.astral.sh/uv/) (`curl -LsSf https://astral.sh/uv/install.sh | sh`):
| Se vuoi… | Vai a |
|---|---|
| capire cosa fa e vedere un esempio | [docs/panoramica.md](docs/panoramica.md) |
| installare e lanciare i comandi | [docs/uso.md](docs/uso.md) |
| sapere cosa si assume e quando non vale | [docs/assunzioni.md](docs/assunzioni.md) |
| capire come si ottengono le equazioni | [docs/algoritmi.md](docs/algoritmi.md) |
| orientarti nel codice | [docs/moduli.md](docs/moduli.md) |
| cambiare un valore (`config.toml`) | [docs/configurazione.md](docs/configurazione.md) |
| leggere i file e i grafici prodotti | [docs/output.md](docs/output.md) |
| eseguire i test | [docs/test.md](docs/test.md) |
| conoscere i limiti | [docs/limiti.md](docs/limiti.md) |
## Avvio rapido
```bash
uv venv .venv
uv pip install -e ".[dev]" # numpy, scipy, matplotlib; pytest e ruff per lo sviluppo
uv venv .venv && uv pip install -e ".[dev]" # installazione: docs/uso.md §1
.venv/bin/fitseg domini input/gen_7.csv # passo 1: come verrebbero suddivisi i dati
.venv/bin/fitseg analizza input/gen_7.csv # tutti i passi → output/gen_7/{01_domini,02_sovrapposizione,03_report}
.venv/bin/pytest # test: docs/test.md
```
## Dati di ingresso
CSV con intestazione, tempo crescente e le serie indicate in [config.toml](config.toml) (`[dati]`: colonne, unità `ms`/`s`, separatore). I CSV di prova vengono dal generatore `../gen_data`; **metti i CSV da analizzare nella cartella `input/`** (la cartella è nel repository, i file `*.csv` no); i test di integrazione usano i seed 1–3:
```bash
mkdir -p input
for s in 1 2 3; do
(cd ../gen_data && .venv/bin/python genera.py config.toml --seed $s -o ../EquationMiner/input/gen_$s.csv)
done
```
## Uso
```bash
.venv/bin/fitseg domini input/gen_1.csv # passo 1: come verrebbero suddivisi i dati (tabella, domini.png, domini.toml), nessun fit
.venv/bin/fitseg analizza input/gen_1.csv # tutti i passi: domini, equazioni, sovrapposizione, report
```
`--config PATH` (default `config.toml`) e `--output DIR` (default `output`) vanno **prima** del sottocomando. I file di ogni analisi vanno in `output/<nome del csv>/`.
**Correggere i domini a mano**: apri `output/gen_1/01_domini/domini.toml`, modifica i tempi (i domini devono restare contigui) e rilancia
```bash
.venv/bin/fitseg analizza input/gen_1.csv --domini output/gen_1/01_domini/domini.toml
```
```toml
[[dominio]]
tipo = "riscaldamento" # riposo | riscaldamento | raffreddamento
t0_s = 15.8 # incluso
t1_s = 17.76 # escluso (l'ultimo dominio arriva a fine dati)
```
### Cosa produce
Una cartella per ogni CSV analizzato, con un passo per sottocartella (`output/` è ignorata da git):
```
output/gen_1/
├── 01_domini/ suddivisione dei dati
│ ├── domini.png dati grezzi con i domini (colore per tipo, numerati)
│ └── domini.toml domini usati, modificabili a mano
├── 02_sovrapposizione/ dati grezzi contro le equazioni trovate
│ ├── sovrapposizione_<serie>.png serie intera con le equazioni a tratti e i residui
│ └── dominio<k>_<serie>.png un dominio: dati, modello scelto, altri modelli validi, residui
└── 03_report/
├── report.md report completo con tutte le immagini incorporate
└── risultati.json tutti i fit con tutte le metriche
```
`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. Tutte le grandezze matematiche (equazioni con i valori sostituiti, parametri ± IC, $\sigma$, $R^2$, $\rho_1$, $\Delta$, $t_0$, criteri di scelta) sono scritte in **LaTeX** (`$…$`, `$$…$$`): servono un visualizzatore Markdown con supporto matematico (GitHub, VS Code, Obsidian) e le immagini come link relativi.
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. 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 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]`.
## Test
```bash
.venv/bin/pytest # tutti (≈ 4 s)
.venv/bin/pytest tests/test_fit.py # un file
.venv/bin/pytest -k raffreddamento # per nome
.venv/bin/ruff check src tests
```
| File | Verifica | Servono i CSV? |
|---|---|---|
| `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` 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.
## Limiti noti
- Nel raffreddamento i residui sono autocorrelati (ACF1 ≈ 0.7) per scarti casuali e deriva lenta del sensore: i parametri sono buoni, ma gli IC sono troppo stretti e il dominio risulta «non adeguato».
- Ogni dominio è fittato in modo indipendente (nessun vincolo di continuità); provato su pochi seed e con la config d'esempio di `gen_data`.
`./pulisci.sh` pulisce i dati: svuota `output/` e cancella `input/*.csv`, con conferma ([docs/uso.md](docs/uso.md) §6).
I CSV da analizzare vanno in `input/` (dati di prova: `./scarica_gen_data.sh`, poi `extern/gen_data/genera.py --seed 7`, vedi [docs/uso.md](docs/uso.md) §2). Il report è `output/<csv>/03_report/report.md`.
+7 -6
View File
@@ -9,11 +9,12 @@ separatore = ","
[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 = 1 # campioni esclusi all'inizio di ogni raffreddamento (sicurezza dopo la salita)
soglia_gradino_c = 20.0 # salita complessiva (somma dei campioni consecutivi in salita) che segna un gradino di riscaldamento
soglia_bordo_sigma = 6.0 # un campione è "in salita" se il salto verso il successivo supera soglia × sigma robusta dei salti
distanza_min_salti_campioni = 3 # campioni in salita più vicini di così (in indici) appartengono alla stessa salita
n_min_punti = 6 # campioni minimi per fittare un dominio con parametri liberi (sotto: "indeterminato")
n_min_punti_parametri_fissi = 4 # campioni minimi quando c e tau sono fissi e si stima solo A (raffreddamenti intermedi corti)
scarta_dopo_salto_n = 0 # campioni esclusi all'inizio di ogni raffreddamento (0: la salita è già nel dominio di riscaldamento)
[fit]
raffreddamento_intermedio_fissa_da_finale = ["c", "tau"] # i raffreddamenti tra due gradini (corti) fissano questi parametri a quelli del raffreddamento finale ([] = liberi)
@@ -26,7 +27,7 @@ soglia_condizione = 1.0e8 # numero di condizione (jacobiano normalizzat
correggi_autocorrelazione = true # allarga gli intervalli di confidenza se ACF1 dei residui > soglia_acf
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
scala_residui_vs_rumore_max = 1.5 # scala robusta dei residui (1.4826·MAD) <= valore × sigma rumore => modello adeguato
[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"]
+18
View File
@@ -0,0 +1,18 @@
# Documentazione di fitseg
Un file per argomento. Per un primo giro leggi **panoramica**, poi **uso**; per capire perché i numeri sono quelli che sono, **assunzioni** e **algoritmi**.
| File | Contenuto | Quando leggerlo |
|---|---|---|
| [panoramica.md](panoramica.md) | cosa fa il programma, il segnale, glossario, un esempio completo | per iniziare |
| [uso.md](uso.md) | installazione, dati di ingresso, comandi, correggere i domini a mano, codici di uscita | per usarlo |
| [assunzioni.md](assunzioni.md) | ogni assunzione (A1–A25): perché, dove nel codice, cosa succede se è falsa; origine dei dati di prova | per fidarsi (o no) dei risultati |
| [algoritmi.md](algoritmi.md) | rilevamento dei gradini, domini, fit, incertezze, metriche, scelta del modello, con le formule | per capire i numeri |
| [moduli.md](moduli.md) | un modulo alla volta: responsabilità, funzioni, strutture dati, come aggiungere un modello | per modificare il codice |
| [configurazione.md](configurazione.md) | ogni chiave di `config.toml`, formato di `domini.toml`, esempi | per cambiare un valore |
| [output.md](output.md) | file prodotti, colori, grafici, struttura del report, come leggere i messaggi | per leggere i risultati |
| [test.md](test.md) | come eseguire i test, cosa verifica ognuno, il segnale sintetico | per sviluppare |
| [limiti.md](limiti.md) | limiti noti, estensioni possibili, scelte progettuali | prima di estendere |
Convenzioni: le assunzioni hanno identificativi **A1…A24** richiamati dagli altri file e dai commenti nel codice; le chiavi di configurazione sono scritte
`in_questo_formato`; le formule sono in LaTeX (servono un visualizzatore Markdown con supporto matematico: GitHub, VS Code, Obsidian).
-74
View File
@@ -1,74 +0,0 @@
# `fitseg` — specifica
**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) 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)
▼
[3 REPORT] 03_report/report.md (immagini incorporate, formule in LaTeX) e risultati.json
```
Tutto il calcolo è deterministico (nessuna partenza casuale, nessun LLM). Ogni CSV ha la sua cartella `output/<nome csv>/`.
## 2. Domini (`domini.py`)
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. 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).
`domini.toml` è modificabile (`tipo`, `t0_s` incluso, `t1_s` escluso; ultimo dominio fino a fine dati); i domini devono essere contigui, altrimenti exit 6.
## 3. Modelli (`modelli.py`, t' = t − t0 del dominio)
| Id | Equazione | Parametri | Tipi di dominio (`[fit.modelli_per_tipo]`) |
|---|---|---|---|
| `costante` | `y = a` | a | riposo |
| `lineare` | `y = a + b·t'` | a, b | riposo |
| `esp_decad` | `y = c + A·exp(−t'/τ)` | c, A, τ | riposo, raffreddamento |
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 (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 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.
## 5. CLI e file
| Comando | Funzione |
|---|---|
| `fitseg domini CSV` | passo 1: tabella, `01_domini/domini.png`, `01_domini/domini.toml` (nessun fit) |
| `fitseg analizza CSV [--domini FILE]` | tutti i passi; con `--domini` usa i domini corretti a mano al posto della proposta |
File in `output/<csv>/`: `01_domini/{domini.png, domini.toml}` · `02_sovrapposizione/{sovrapposizione_<serie>.png, dominio<k>_<serie>.png}` · `03_report/{report.md, risultati.json}`. Il report scrive in LaTeX tutte le grandezze matematiche (`$…$` inline, `$$…$$` per l'equazione scelta con i valori sostituiti; il campo `equazione` di `MODELLI` è LaTeX). Il report (indice: 1 suddivisione, 2 equazioni, 3 sovrapposizione, 4 dettaglio per dominio) incorpora le immagini con percorsi relativi.
Opzioni globali prima del sottocomando: `--config PATH`, `--output DIR` (default `[output] cartella_output`). Exit: 0 ok · 3 config non valida · 4 CSV illeggibile · 6 domini non validi.
## 6. Test
Unitari su un segnale sintetico (`tests/conftest.py`: τ = 16 s, 3 gradini, nessun disturbo a salto) e di integrazione su `input/gen_{1,2,3}.csv` (saltati se mancano). Dettaglio: README, «Test». Coperto: domini (3, 0, 1 gradino, sotto soglia, confini), recupero di τ/`c`/`A`, parametri fissi e ereditati dal raffreddamento, regola di scelta, errori di config/CSV/`domini.toml`, CLI.
## 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 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`.
+254
View File
@@ -0,0 +1,254 @@
# Algoritmi
Tutti i passi, nell'ordine in cui il programma li esegue. Le assunzioni richiamate (A1, A2, …) sono in [assunzioni.md](assunzioni.md); le
chiavi di configurazione (`in_questo_formato`) in [configurazione.md](configurazione.md); il modulo che implementa ogni passo in
[moduli.md](moduli.md).
## Visione d'insieme
```mermaid
flowchart TD
CSV(["CSV in input/"]) --> P1
subgraph P1["PASSO 1 · Domini"]
direction LR
L["<b>1 · Lettura</b><br/>validazione"] --> S["<b>2 · Rumore</b><br/>stima di σ"] --> G["<b>3 · Gradini</b><br/>salite sopra soglia"] --> D["<b>4 · Domini</b><br/>divisione del tempo"]
end
P1 --> RIP["<b>Riposo</b>"]
P1 --> RIS["<b>Riscaldamento</b><br/>2–4 campioni"]
P1 --> INT["<b>Raffreddamenti intermedi</b><br/>tra due gradini, corti"]
P1 --> FIN["<b>Raffreddamento finale</b><br/>l'ultimo, il più lungo"]
subgraph P2["PASSO 2 · Equazioni"]
direction TB
F0["<b>5 · Fit</b><br/>costante, lineare,<br/>esponenziale"]
F1["<b>5 · Fit esponenziale</b><br/>c, A, τ liberi"]
F2["<b>5b · Fit esponenziale</b><br/>c e τ fissi, solo A libera"]
SALTO["<b>7 · Salto Δ</b><br/>descritto, non fittato"]
SC["<b>6 · Scelta del modello</b><br/>validi → adeguati → meno parametri"]
F0 --> SC
F1 --> SC
F2 --> SC
end
RIP --> F0
FIN --> F1
INT --> F2
RIS --> SALTO
F1 -. "passa c, τ" .-> F2
SC --> REP
SALTO --> REP
REP(["<b>PASSO 3</b> · Sovrapposizione e report<br/>dati grezzi contro equazioni<br/>report.md · grafici · risultati.json"])
classDef dato fill:#eceff1,stroke:#607d8b,color:#263238
classDef calcolo fill:#e3f2fd,stroke:#1a73e8,color:#0d47a1
classDef dominio fill:#fff8e1,stroke:#f9a825,color:#5d4037
classDef uscita fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
class CSV dato
class L,S,G,D,F0,F1,F2,SC,SALTO calcolo
class RIP,RIS,INT,FIN dominio
class REP uscita
linkStyle 15 stroke:#e65100,stroke-width:2px,color:#e65100
```
Come si legge: dall'alto in basso. I numeri nei riquadri sono i paragrafi di questo documento. Colori: **grigio** dati, **azzurro** calcoli,
**giallo** tipi di dominio, **verde** risultati. Il Passo 1 produce i domini; ogni tipo ha il suo trattamento nel Passo 2; la freccia
**arancione tratteggiata** indica che il raffreddamento finale **passa** $c$ e $\tau$ ai raffreddamenti intermedi (§5b), per questo si elabora per primo.
Il riscaldamento non si fitta: se ne riporta il salto (§7).
## Simboli e chiavi di configurazione
Nelle formule si usano questi simboli; a destra la chiave di [configurazione.md](configurazione.md) con il valore.
| Simbolo | Chiave | Significato |
|---|---|---|
| $\theta_g$ | `soglia_gradino_c` | salita complessiva minima che segna un gradino [°C] |
| $\theta_b$ | `soglia_bordo_sigma` | soglia, in multipli di $\sigma_d$, sopra la quale un campione è «in salita» |
| $\theta_\rho$ | `soglia_acf` | soglia di $\rho_1$ oltre la quale si allargano gli intervalli |
| $\kappa$ | `scala_perdita_sigma` | scala della perdita robusta, in multipli di $\sigma$ |
| $\alpha_{\min},\ \alpha_{\max}$ | `tau_min_rel`, `tau_max_rel` | limiti di $\tau$ come frazione di $L$ |
| $\lambda$ | `scala_residui_vs_rumore_max` | un modello è «adeguato» se $\hat\sigma_\text{res} \le \lambda\,\sigma$ (scala robusta dei residui, §5.5) |
| $\delta$ | `soglia_delta_aicc` | tolleranza su $\Delta\mathrm{AICc}$ per preferire il modello più semplice |
| $L$ | — | durata del dominio, $t_\text{ultimo} - t_\text{primo}$ |
| $n$, $k$ | — | campioni usati nel fit; parametri **liberi** del modello |
## 1. Lettura dei dati (`dati.leggi_csv`)
Legge l'intestazione, controlla che ci siano la colonna del tempo e tutte le serie (A2), converte il tempo in secondi (A3) e verifica che
sia strettamente crescente (A1). Restituisce `t` (array in secondi) e un dizionario `serie` {nome: array}.
## 2. Stima del rumore (`domini.sigma_rumore`)
Per un rumore bianco $\varepsilon_i$ di deviazione $\sigma$, la **derivata seconda discreta** $\Delta^2 y_i = y_{i+2} - 2y_{i+1} + y_i$ elimina
le componenti lisce (costante, lineare, e in buona parte l'esponenziale) e ha varianza $6\sigma^2$. Per renderla robusta agli outlier si usa la
deviazione mediana assoluta:
$$\sigma = \frac{1.4826\,\mathrm{MAD}(\Delta^2 y)}{\sqrt{6}}, \qquad \mathrm{MAD}(x) = \mathrm{mediana}\,|x_i - \mathrm{mediana}(x)|$$
Il fattore 1.4826 rende la MAD uguale a $\sigma$ per dati gaussiani. La stima si calcola **per dominio** (A13) sui campioni usati nel fit
e serve come scala della perdita robusta e come riferimento per l'«adeguatezza» (§6). Ignora la deriva lenta (A14).
**Servono almeno 8 campioni** (`domini.N_MIN_SIGMA`): con meno, la MAD di pochi $\Delta^2 y$ può annullarsi, perché i valori sono quantizzati e
due $\Delta^2 y$ su tre possono coincidere. Con $\sigma \approx 0$ la scala della perdita robusta diventa quasi nulla, ogni residuo è trattato come
outlier, lo jacobiano pesato tende a zero e gli intervalli di confidenza esplodono (fit scartato come «non identificabile» pur essendo corretto). Sotto 8
campioni $\sigma$ è «non stimabile»: `sigma` vale `null`, la scala della perdita è 1 e l'adeguatezza non è valutabile.
## 3. Rilevamento dei gradini (`domini.trova_gradini`)
Sulla `serie_riferimento`, con $d_i = y_{i+1} - y_i$ e $\sigma_d = 1.4826\,\mathrm{MAD}(d)$ calcolata su **tutti** i salti:
1. **Campioni in salita**: gli indici con $d_i > \theta_b\,\sigma_d$ (solo salite, A12). Il rumore bianco e la pendenza del raffreddamento restano sotto questa soglia.
2. **Raggruppamento**: i campioni in salita a meno di `distanza_min_salti_campioni` indici l'uno dall'altro formano una **salita** (un gradino si
distribuisce su 2–4 campioni, A6).
3. **Gradino**: una salita è un gradino se la sua **salita complessiva** $y_{\text{ultimo}+1} - y_{\text{primo}}$ supera $\theta_g$ (A5). Si somma su tutta la
salita perché ogni singolo salto può restare sotto soglia: un gradino di 38 °C diviso in 19 + 19 è un gradino, e uno scarto casuale (≤ 4 °C) no.
4. Ogni gradino diventa la coppia $(i_0, i_1)$: $i_0$ è il primo campione già in salita e $i_1$ il campione successivo all'ultimo in salita
(estremo escluso). I **campioni di salita** sono quelli in $[i_0, i_1)$.
## 4. Costruzione dei domini (`domini.proponi`)
Dati $N$ gradini, in ordine di tempo:
- `riposo`: dall'inizio al primo gradino (o tutto il segnale se non ci sono gradini);
- per ogni gradino: un dominio `riscaldamento` sui suoi campioni di salita, seguito da un dominio `raffreddamento` fino al gradino
successivo (o alla fine). Un raffreddamento senza campioni (gradini adiacenti) non viene creato.
Con $N$ gradini i domini sono quindi $1 + 2N$. I domini sono contigui e coprono tutti i campioni. L'utente può sostituire la proposta con
`domini.toml` (A21), che viene validato: tipi ammessi, intervalli non vuoti, contiguità.
## 5. Fit (`fit.adatta`)
### 5.1 Modelli (`modelli.MODELLI`, $t' = t - t_0$)
| Modello | Equazione | Parametri | Dove si prova |
|---|---|---|---|
| `costante` | $y = a$ | $a$ | riposo |
| `lineare` | $y = a + b\,t'$ | $a, b$ | riposo |
| `esp_decad` | $y = c + A\,e^{-t'/\tau}$ | $c, A, \tau$ | riposo, raffreddamento |
Nel raffreddamento $c$ è l'asintoto (≈ ambiente per `T_C`, ≈ 0 per `dT_C`), $A$ l'ampiezza a $t'=0$ e $\tau = 1/k$ la costante di tempo.
### 5.2 Partenza
- Polinomi (`costante`, `lineare`): soluzione dei minimi quadrati lineari.
- `esp_decad`: **proiezione delle variabili**. Per $\tau$ fissato il modello è lineare in $(c, A)$. Si provano `n_griglia_tau` valori di $\tau$
su una griglia logaritmica tra $\alpha_{\min} L$ e $\alpha_{\max} L$, per ognuno si
risolvono $(c, A)$ in modo lineare e si tiene il $\tau$ con $\mathrm{RSS}$ minima. Poi il raffinamento non lineare parte da lì: nessuna
partenza casuale (A22).
- Se $c$ e $\tau$ sono fissi e solo $A$ è libero: $A = \sum (y - c)\,e^{-t'/\tau} \big/ \sum e^{-2t'/\tau}$.
### 5.3 Minimizzazione
`scipy.optimize.least_squares` sui soli parametri liberi, con `x_scale="jac"`, limiti $\tau \in [\alpha_{\min} L,\ \alpha_{\max} L]$ e
**perdita robusta** (`perdita`, default `soft_l1`):
$$\min_p \sum_i \rho\!\left(\frac{r_i^2}{s^2}\right), \quad \rho(z) = 2\left(\sqrt{1+z}-1\right), \quad s = \kappa\,\sigma, \quad r_i = f(t'_i; p) - y_i$$
Per $|r_i| \ll s$ si comporta come i minimi quadrati; per $|r_i| \gg s$ cresce linearmente, così gli **scarti casuali** pesano molto meno (A14).
Se $\sigma$ non è finita o è nulla la scala è 1 (A20).
### 5.4 Incertezze
$$s^2 = \frac{\mathrm{RSS}}{n-k}, \qquad \mathrm{cov}(p) = s^2\,(J^\top J)^{-1}, \qquad \mathrm{IC}_{95\%} = \pm 1.96\,\sqrt{\mathrm{diag}(\mathrm{cov})}\cdot f_\rho$$
$k$ è il numero di parametri **liberi**, $J$ lo jacobiano dei residui nella soluzione (con la pseudo-inversa di $J^\top J$), $\mathrm{RSS} = \sum r_i^2$ (A16).
Il fattore di autocorrelazione vale $f_\rho = \sqrt{(1+\rho_1)/(1-\rho_1)}$ se `correggi_autocorrelazione` è attivo e $\rho_1 > \theta_\rho$, altrimenti 1 (A17).
Per i parametri fissi l'incertezza è 0 (A18).
### 5.5 Metriche
| Metrica | Definizione |
|---|---|
| $\mathrm{RMSE}$ | $\sqrt{\mathrm{RSS}/n}$ |
| $R^2$ | $1 - \mathrm{RSS}/\mathrm{TSS}$, con $\mathrm{TSS} = \sum (y_i - \bar y)^2$ |
| $\hat\sigma_\text{res}$ | **scala robusta dei residui**: $1.4826\,\mathrm{MAD}(r)$, con $r$ i residui. Come la perdita del fit, ignora gli scarti casuali (la RMSE no) |
| $\rho_1$ | autocorrelazione dei residui a lag 1: $\sum_{i} \tilde r_{i+1}\tilde r_i \big/ \sum_i \tilde r_i^2$, con $\tilde r = r - \bar r$ |
| $\mathrm{AICc}$ | $n\ln(\mathrm{RSS}/n) + 2k + \dfrac{2k(k+1)}{n-k-1}$ (infinito se $n-k-1 \le 0$) |
| $\mathrm{BIC}$ | $n\ln(\mathrm{RSS}/n) + k\ln n$ |
| condizione | numero di condizione di $J$ con le colonne normalizzate in norma euclidea |
AICc e BIC sono calcolati da $\mathrm{RSS}$, quindi senza peso robusto (A19).
### 5.6 Validità e identificabilità
Un fit è **valido** se converge, nessun parametro libero è sul limite (distanza dal limite inferiore a $10^{-3}$ volte il limite stesso: $\tau$ al bordo della griglia),
il numero di condizione non supera `soglia_condizione` e l'AICc è finito. Un fit è **non identificabile** se la condizione supera la
soglia **oppure** un parametro libero diverso da `a` e `c` ha $1.96\,\sigma_p > |p|$ (intervallo che include lo zero: il parametro non
è distinguibile da 0).
## 5b. Costanti condivise (`analisi.analizza_domini`)
Per A8 i raffreddamenti hanno lo stesso $\tau$ e lo stesso $c$. L'ordine di elaborazione è quindi:
1. il **raffreddamento finale** (ultimo dominio di tipo `raffreddamento`) si fitta per primo con $c, A, \tau$ liberi;
2. se la scelta è valida, i valori di $c$ e $\tau$ (parametri in `raffreddamento_intermedio_fissa_da_finale`) sono salvati per serie;
3. ogni **raffreddamento intermedio** si fitta con quei valori **fissi**, e solo $A$ libera ($k = 1$).
Motivo: un intermedio ha pochi campioni (da 4 a qualche decina) con scarti casuali, e $\tau$ da solo non è identificabile (su dati puliti lo sarebbe: i test lo
verificano). Con un solo parametro libero bastano pochi campioni: il minimo per questi fit è `n_min_punti_parametri_fissi` (default 4) invece di
`n_min_punti` (6), così anche i tratti tra gradini ravvicinati (1–2 s) si fittano. Se il finale non dà un fit valido o la lista è vuota gli intermedi
restano liberi.
## 6. Scelta del modello (`analisi.scegli`)
Per ogni (dominio, serie) con più modelli candidati:
1. **Scarto**: via i fit non validi (§5.6). Se non ne resta nessuno: `indeterminato`.
2. **Adeguatezza**: sono *adeguati* i fit con $\hat\sigma_\text{res} \le \lambda\,\sigma$ (scala **robusta** dei residui, §5.5). Se ce n'è almeno uno si
sceglie tra questi, altrimenti tra tutti i validi (e il dominio è segnato *non adeguato*). Se $\sigma$ non è stimabile (meno di 8 campioni)
l'adeguatezza non è valutabile (`adeguato` = `null`) e si sceglie tra tutti i validi.
3. **Parsimonia**: tra i candidati con $\mathrm{AICc} \le \mathrm{AICc}_{\min} + \delta$ vince quello con **meno parametri**
(a parità, AICc minore).
Un dominio con meno di `n_min_punti` campioni (`n_min_punti_parametri_fissi` se $c$ e $\tau$ sono fissi) non viene fittato (`indeterminato`); un modello con più di $n - 2$ parametri **liberi** non viene provato.
Con un solo modello candidato (raffreddamento) la scelta è lui, se valido: il flag *adeguato* resta informativo.
*Perché la scala robusta e non la RMSE*: gli scarti casuali del sensore gonfiano la RMSE ($\mathrm{RMSE}/\sigma$ da 1.07 a 5.26 per il modello **giusto**) ma
non la scala robusta ($\hat\sigma_\text{res}/\sigma$ da 0.97 a 1.40 su 120 raffreddamenti finali). Con un modello sbagliato (lineare o costante sullo stesso tratto) la scala
robusta vale da 11 a 100 volte $\sigma$, quindi discrimina bene. Con la RMSE solo il 27 % dei fit corretti risultava «adeguato»; con la scala robusta il 100 %.
## 7. Riscaldamento come salto (`analisi._gradino`, `analisi._affina_salti`)
Il dominio di riscaldamento ha 2–4 campioni: nessun fit. Per ogni serie si riporta il salto $\Delta$ con due stime.
**Misura sui campioni** (`delta_misurato`), con $i_0, i_1$ gli estremi del dominio:
$$\Delta_\text{misurato} = y_{i_1-1} - y_{i_0-1}, \qquad \text{durata} = t_{i_1-1} - t_{i_0-1}$$
cioè dal campione **prima** della salita all'ultimo campione di salita. È una stima **per difetto**: l'ultimo campione di salita ha già cominciato a decadere
(0.3–0.45 s dopo il gradino) e l'errore cresce con la temperatura, fino a ≈ 10 % (A23).
**Stima dai modelli adiacenti** (`delta`, usata quando disponibile). L'istante del gradino $t_s$ è il punto medio tra l'ultimo campione prima della salita e
il primo in salita. Con $f_\text{prima}$ e $f_\text{dopo}$ i modelli scelti per il dominio precedente (riposo o raffreddamento) e per il successivo
(raffreddamento), valutati in $t_s$ con $t' = t - t_0$ del rispettivo dominio:
$$\Delta = f_\text{dopo}(t_s) - f_\text{prima}(t_s)$$
Per due raffreddamenti, che condividono $c$ e $\tau$, equivale a $A_\text{dopo}\,e^{(t_{0,\text{dopo}}-t_s)/\tau} - A_\text{prima}\,e^{-(t_s-t_{0,\text{prima}})/\tau}$.
I campi `da` e `a` sono allora i valori dei due modelli in $t_s$.
**Misura corretta** (se manca un modello adiacente, p.es. un tratto indeterminato, ma c'è il raffreddamento finale). Si riportano i due campioni all'istante del
gradino con la legge di decadimento del finale ($c$ e $\tau$ condivisi):
$$y_\text{prima}^* = c + (y_{i_0-1} - c)\,e^{-(t_s - t_{i_0-1})/\tau}, \qquad y_\text{dopo}^* = c + (y_{i_1-1} - c)\,e^{(t_{i_1-1} - t_s)/\tau}, \qquad \Delta = y_\text{dopo}^* - y_\text{prima}^*$$
**Ripiego finale**: la misura sui campioni, se non c'è nemmeno il raffreddamento finale (gradino senza fit dopo), o se la stima si discosta più del 50 % dalla misura
(guardia contro fit anomali). Il campo `stima` vale `"modelli"`, `"misura corretta"` o `"misura"`; il report mostra sempre anche la misura. `sigma` vale `null` e `fit` è vuoto.
**Accuratezza** (validazione su 60 seed di `gen_data`, [test.md](test.md)): con la stima dai modelli l'errore sull'ampiezza vera del gradino ha media +0.33 %, scarto
quadratico medio 0.86 % e massimo 3.16 % su 124 salti (con `scarta_dopo_salto_n = 0` nessun salto ha richiesto il ripiego). Con la sola misura l'errore medio era −5.5 % e
arrivava a −20 %; la misura corretta, usata da sola, ha errore mediano 1.85 % e massimo 11.9 % (è un ripiego, non un sostituto dei modelli).
## Esempio di calcolo completo (seed 7 di `gen_data`, `T_C`, raffreddamento finale)
1. $n = 620$ campioni da $t_0 = 26.79$ s (`scarta_dopo_salto_n = 0`: tutti usati nel fit); $\sigma = 0.115$ °C.
2. Griglia su $\tau \in [0.05 L,\ 50 L]$ con $L \approx 93$ s → $\tau_0$ vicino a 12 s, $c, A$ lineari.
3. Raffinamento `soft_l1` con scala $3\sigma = 0.35$ °C: $c = 23.92 \pm 0.06$, $A = 163.2 \pm 0.33$, $\tau = 12.12 \pm 0.04$ s (valore vero $\tau = 12.07$ s).
4. Metriche: $\mathrm{RMSE} = 0.247$, $\hat\sigma_\text{res} = 0.159$, $\rho_1 = 0.61$ (> 0.3: intervalli moltiplicati per $\sqrt{1.61/0.39} = 2.03$).
5. $\hat\sigma_\text{res} = 0.159 \le 1.5 \cdot 0.115 = 0.173$: **adeguato**, anche se $\mathrm{RMSE} = 0.247 > 1.5\,\sigma$ per gli scarti casuali (con il vecchio criterio sarebbe stato scartato).
6. Gli intermedi 3 e 5 usano $c = 23.92$, $\tau = 12.12$ e stimano solo $A$ ($76.7$ e $124.5$).
7. Il salto del terzo gradino: misurato $+80.9$, stimato dai modelli $+90.2$ (ampiezza vera $+89.4$).
+76
View File
@@ -0,0 +1,76 @@
# Assunzioni
Ogni assunzione ha un identificativo (A1, A2, …) richiamato nel codice e negli altri documenti. Per ognuna: cosa si assume, perché è
ragionevole, **dove** il codice ne dipende e **cosa succede se è falsa**. Le assunzioni sul segnale valgono per i dati del generatore
`extern/gen_data` (sezione finale): su dati reali vanno verificate.
## Dati
| ID | Assunzione | Dove | Se è falsa |
|---|---|---|---|
| **A1** | Il tempo è **strettamente crescente**, anche non uniforme (jitter del campionamento) | `dati.leggi_csv` | il CSV è rifiutato (exit 4). Il programma non riordina né deduplica |
| **A2** | Il CSV ha l'intestazione e tutte le colonne richieste da `[dati]` (tempo e serie), tutte numeriche, senza celle vuote | `dati.leggi_csv` | exit 4 con il nome della colonna mancante |
| **A3** | Il tempo è in `ms` o `s` come dichiarato in `[dati] unita_tempo` | `dati.leggi_csv` | tutti i tempi e le costanti $\tau$ risultano scalati di 1000 senza avvisi: controlla l'unità |
| **A4** | Le serie condividono gli **stessi domini** (stessi istanti di rottura) | `analisi.analizza_domini`, `domini.proponi` | i domini sono cercati solo su `serie_riferimento` (default `T_C`); una serie con gradini diversi non è gestita: si lancia l'analisi due volte |
## Struttura del segnale
| ID | Assunzione | Dove | Se è falsa |
|---|---|---|---|
| **A5** | I **gradini di riscaldamento** sono salite positive molto più grandi del rumore **e** degli scarti casuali: la **salita complessiva** dei campioni consecutivi in salita supera `soglia_gradino_c` (20 °C), mentre uno scarto casuale è ≤ 4 °C | `domini.trova_gradini` | gradini con salita complessiva sotto la soglia non sono visti (il segnale resta un unico tratto); scarti consecutivi che sommati superano la soglia sono scambiati per gradini. Soluzione: adattare la soglia o correggere `domini.toml` a mano. La soglia è un valore fisso, non derivato dai dati: misure e alternative in [limiti.md](limiti.md) |
| **A6** | La **salita dura pochi campioni** (2–4): la costante di tempo del sensore ($\tau_\text{sensore} \approx 0.04$–$0.12$ s) è dell'ordine dell'intervallo di campionamento (≈ 0.15 s), quindi un gradino si distribuisce su 2–4 campioni, anche ognuno sotto la soglia | `domini.trova_gradini` (campioni in salita raggruppati, soglia sulla salita complessiva), `analisi._gradino` | con un sensore più lento la salita avrebbe più campioni: sarebbe comunque raggruppata finché ogni salto supera `soglia_bordo_sigma`$\cdot\sigma_d$ e i campioni in salita distano meno di `distanza_min_salti_campioni`, ma non sarebbe fittata |
| **A7** | Dopo ogni gradino la temperatura **decade esponenzialmente** verso un asintoto: $y = c + A\,e^{-t'/\tau}$ | `modelli.MODELLI["esp_decad"]` | residui sistematici, `ρ₁` alto, «modello non adeguato». Con più processi di raffreddamento servirebbe un modello a più esponenziali (non presente, vedi [limiti.md](limiti.md)) |
| **A8** | Tutti i raffreddamenti hanno la **stessa costante di tempo $\tau$ e lo stesso asintoto $c$** (stesso processo fisico: dispersione verso l'ambiente). Anche nel generatore il decadimento dopo più gradini è la somma di esponenziali con lo stesso $k$, quindi un'unica esponenziale | `analisi.analizza_domini` (parametri `raffreddamento_intermedio_fissa_da_finale`) | gli intermedi sono forzati a $\tau$ e $c$ del finale: se in realtà differiscono, il fit dell'ampiezza $A$ è distorto e i residui lo mostrano. Per rimuovere il vincolo: `raffreddamento_intermedio_fissa_da_finale = []` |
| **A9** | Il **raffreddamento finale** è l'ultimo dominio ed è abbastanza lungo (centinaia di campioni) da stimare $c$, $A$, $\tau$ **liberamente**; i raffreddamenti intermedi, anche di 4–6 campioni, bastano per stimare la sola $A$ con $c$ e $\tau$ fissi (`n_min_punti_parametri_fissi`) | `analisi.analizza_domini`, `analisi._serie` | se il CSV finisce subito dopo un gradino, il finale è corto e $\tau$ può risultare non identificabile; allora anche gli intermedi restano liberi (con `n_min_punti`) e possono essere indeterminati. Tratti intermedi con meno di 4 campioni (gradini a meno di ≈ 1.2 s) restano indeterminati |
| **A10** | Il **riposo** (prima del primo gradino) è una baseline costante, lineare o esponenziale lenta (l'ambiente che si assesta) | `[fit.modelli_per_tipo] riposo` | il modello scelto è solo descrittivo: con una deriva lenta vince `lineare` o `esp_decad` |
| **A11** | Ogni dominio è **indipendente**: nessun vincolo di continuità tra domini | `analisi` | i modelli adiacenti possono non combaciare ai confini; il «salto» tra due raffreddamenti è il gradino |
| **A12** | Non esistono gradini **discendenti** (un calo brusco non è un evento): si cercano solo salti positivi | `domini.trova_gradini` (`d > soglia`) | un calo brusco resterebbe dentro un raffreddamento, aumentando RMSE e $\rho_1$ |
| **A25** | Il **ritardo di risposta del sensore** ($\tau_\text{sensore} \approx 0.04$–$0.12$ s) è trascurabile rispetto a $\tau$ del raffreddamento: il modello `esp_decad` non lo include | `modelli.MODELLI` (`esp_decad`) | un esponenziale filtrato da un filtro del primo ordine ha lo stesso $\tau$ ma ampiezza moltiplicata per $1/(1-\tau_\text{sensore}/\tau)$: $A$ risulta sovrastimata di circa $\tau_\text{sensore}/\tau \approx 0.5$ % (misurato: +0.45 % in media su 60 seed). $\tau$ non è affetta |
## Rumore
| ID | Assunzione | Dove | Se è falsa |
|---|---|---|---|
| **A13** | Il rumore è circa **gaussiano bianco**, con $\sigma$ stimabile per dominio da $\sigma = 1.4826\,\mathrm{MAD}(\Delta^2 y)/\sqrt{6}$ **su almeno 8 campioni**: con meno, la MAD di pochi $\Delta^2 y$ quantizzati può annullarsi | `domini.sigma_rumore` (`N_MIN_SIGMA`) | $\sigma$ sottostimata: l'adeguatezza è valutata male. Sotto 8 campioni $\sigma$ è «non stimabile» (`null`): scala della perdita 1, adeguatezza non valutabile. La stima ignora la parte lenta del rumore (vedi A14) |
| **A14** | Sul rumore bianco si aggiungono **disturbi che il modello non descrive**: deriva lenta (Ornstein–Uhlenbeck, $\tau \approx 3.5$ s) e **scarti casuali** (±4 °C, rientro < 1 s) | `fit.adatta` (perdita robusta), `uscita` (residui) | sono trattati come outlier dalla perdita `soft_l1` (non spostano molto il fit) ma lasciano residui autocorrelati ($\rho_1 \approx 0.7$) e intervalli di confidenza troppo stretti. Non è un errore del fit |
| **A15** | La **quantizzazione** (T a 0.01 °C, ambiente a 0.1 °C) è trascurabile rispetto al rumore | — | nessun trattamento specifico |
## Statistica
| ID | Assunzione | Dove | Se è falsa |
|---|---|---|---|
| **A16** | Gli intervalli al 95 % sono **gaussiani asintotici**: $\pm 1.96\,\sigma_p$ con $\mathrm{cov} = s^2 (J^\top J)^{-1}$, $s^2 = \mathrm{RSS}/(n-k)$ | `fit.adatta` | con pochi campioni o residui non gaussiani sono approssimati |
| **A17** | L'autocorrelazione dei residui è di tipo AR(1): gli errori si allargano di $\sqrt{(1+\rho_1)/(1-\rho_1)}$ se $\rho_1$ supera `soglia_acf` | `fit.adatta` | **approssimazione insufficiente**: su 60 seed il $\tau$ vero cade nell'intervallo al 95 % nel 73 % dei casi. La deviazione standard dell'errore su $\tau$ è 3.7 volte l'errore standard non corretto, perché gli scarti casuali sono concentrati all'inizio del raffreddamento (dove la sensibilità a $\tau$ è massima), quindi non stazionari. Provate e scartate: correzione a più lag di Bartlett (77 %) e stimatore sandwich HAC (≤ 62 %). Vedi [limiti.md](limiti.md) |
| **A18** | Per i parametri **fissi** l'incertezza non si propaga: gli intervalli di $A$ nei raffreddamenti intermedi sono **condizionati** a $c$ e $\tau$ del finale | `fit.adatta`, `uscita._params` | sono sottostimati rispetto all'incertezza totale |
| **A19** | AICc e BIC si calcolano da $\mathrm{RSS}$ come se i residui fossero gaussiani, anche se il fit usa la perdita robusta | `fit.adatta` | confronto tra modelli approssimato; sensibile agli outlier |
| **A20** | Se $\sigma$ non è stimabile (meno di 8 campioni, o nulla) la scala della perdita robusta è 1 e l'adeguatezza non è valutabile | `fit.adatta`, `analisi.scegli` | solo robustezza ridotta; il fit resta valido |
## Progetto
| ID | Assunzione | Dove |
|---|---|---|
| **A21** | La **suddivisione in domini la decide l'utente**: la proposta automatica è modificabile (`domini.toml`) e il fit non la cambia mai | `cli._passo_domini`, `domini.leggi_toml` |
| **A22** | Il programma è **deterministico**: nessuna partenza casuale, nessun seme | `fit._partenza` (griglia su $\tau$, minimi quadrati lineari) |
| **A23** | Il **riscaldamento non si fitta** (2–4 campioni). Il salto $\Delta$ si stima nell'ordine da: modelli dei domini adiacenti valutati all'istante del gradino; misura riportata a quell'istante con $c$ e $\tau$ del raffreddamento finale; misura sui campioni ($y_\text{fine salita} - y_\text{prima}$), che è per difetto (fino a ≈ 10–20 %: parte della salita cade sul campione precedente e l'ultimo campione è già decaduto) | `analisi._gradino`, `analisi._affina_salti` |
| **A24** | I **modelli provati** sono fissati per tipo di dominio in configurazione, non scelti dal programma | `[fit.modelli_per_tipo]` |
## Origine dei dati di prova (`extern/gen_data`)
I CSV di prova si generano con `extern/gen_data`, che a ogni corsa (seed) **sorteggia** i parametri da intervalli: ogni seed è un segnale diverso
(dettaglio: `extern/gen_data/docs/SPEC.md` e `extern/gen_data/config.toml`). Gli intervalli che fondano le assunzioni sopra:
| Aspetto | Valore nel generatore | Assunzioni |
|---|---|---|
| intervallo di campionamento | 150.5 ms con jitter ±0.6 ms (fisso) | A1, A6 |
| numero e istanti dei gradini | 1–3; il primo a 8–25 s, distanza tra gradini 1–6 s | A5, A9 |
| ampiezza dei gradini | 40–90 °C | A5, A7 |
| raffreddamento | $k = 0.05$–$0.09\ \mathrm{s^{-1}}$, cioè $ au = 1/k = 11$–$20$ s | A7, A8 |
| risposta del sensore | filtro del primo ordine, $ au_ ext{sensore} = 0.04$–$0.12$ s | A6 |
| rumore bianco | $\sigma = 0.06$–$0.16$ °C a freddo, $0.15$–$0.30$ a caldo | A13 |
| deriva lenta | Ornstein–Uhlenbeck, $\sigma = 0.02$–$0.08$ °C, $ au = 2$–$6$ s | A14 |
| scarti casuali | in riscaldamento: 0.4–1.2 Hz, $\sigma$ 1.5–3.5 °C, rientro 0.3 s; in raffreddamento: 0.1–0.5 Hz, media −1 °C, $\sigma$ 1.5–3.5 °C, rientro 0.7 s; ampiezza ≤ 4 °C | A5, A14 |
| ambiente | 20–26 °C a regime, deriva iniziale ±0.8 °C con $ au$ 30–90 s; non sale quando T è alta | A10; $c$ di `dT_C` ≈ 0 |
| `dT_C` | $T - T_\text{amb}$ calcolato dopo rumore e quantizzazione di entrambi i canali | A4 |
Poiché il sorteggio è riproducibile dal seed (`genera.sorteggia`), per ogni seed si conoscono i **valori veri** (gradini, ampiezze, $\tau$): il test
`tests/test_validazione.py` li confronta con i risultati di `fitseg` su 20 seed ([test.md](test.md)).
+82
View File
@@ -0,0 +1,82 @@
# Configurazione
Tutti i valori sono in [`config.toml`](../config.toml): **nessun default nel codice**, una chiave mancante è un errore (exit 3). Suffissi nei nomi: `_s`
secondi, `_c` gradi, `_rel` frazione della durata del dominio, `_sigma` multiplo di $\sigma$, `_n` / `_campioni` numero di campioni. Per aggiungere una chiave:
`config.toml`, `CHIAVI` in `config.py`, questo documento. Il modulo che la legge è indicato a destra.
Si usa un altro file con `fitseg --config altro.toml …`.
## `[dati]` — formato del CSV (`dati.py`)
| Chiave | Valore | Significato |
|---|---|---|
| `colonna_tempo` | `"t_ms"` | nome della colonna del tempo |
| `unita_tempo` | `"ms"` \| `"s"` | unità del tempo nel CSV; internamente tutto è in secondi (A3) |
| `serie` | `["T_C", "dT_C"]` | colonne da approssimare; ognuna ha le sue equazioni, tutte condividono i domini (A4) |
| `separatore` | `","` | separatore dei campi |
## `[domini]` — riconoscimento dei domini (`domini.py`, `analisi.py`)
| Chiave | Default | Significato | Effetto se alzata / abbassata |
|---|---|---|---|
| `serie_riferimento` | `"T_C"` | serie in cui si cercano i gradini; deve essere in `serie` | — |
| `soglia_gradino_c` | 20.0 | **salita complessiva** minima (somma dei salti dei campioni consecutivi in salita, in °C) per essere un gradino | alta: gradini piccoli non rilevati; bassa: scarti scambiati per gradini (A5) |
| `soglia_bordo_sigma` | 6.0 | un campione è «in salita» se il salto verso il successivo supera $\theta_b\,\sigma_d$, con $\theta_b$ questa soglia e $\sigma_d = 1.4826\,\mathrm{MAD}$ dei salti ([algoritmi.md](algoritmi.md) §3) | alta: i campioni di una salita distribuita non sono riconosciuti tutti; bassa: il rumore genera falsi campioni in salita |
| `distanza_min_salti_campioni` | 3 | campioni in salita a meno di questa distanza (in indici) appartengono alla stessa salita | alta: gradini ravvicinati fusi; bassa: una salita con un campione piatto in mezzo viene spezzata |
| `n_min_punti` | 6 | campioni minimi per fittare un dominio con parametri liberi; sotto → `indeterminato` | alta: più domini non fittati |
| `n_min_punti_parametri_fissi` | 4 | campioni minimi quando $c$ e $\tau$ sono fissi e si stima solo $A$ (raffreddamenti intermedi corti); sotto → `indeterminato`. Con 4–7 campioni $\sigma$ non è stimabile (servono 8, A13): il fit si fa comunque, l'adeguatezza no | 3: ancora fittabile ma con 1 grado di libertà; alta: tratti corti tra gradini ravvicinati restano indeterminati |
| `scarta_dopo_salto_n` | 0 | campioni esclusi dal fit **all'inizio di ogni raffreddamento**. 0 perché la salita è già nel dominio di riscaldamento: su 45 seed con 0 gli errori su $A$, $\tau$ e salti sono i migliori e i tratti indeterminati i meno numerosi; con 2 peggiorano molto | alta: perde campioni del decadimento (e rende indeterminati i tratti corti) |
## `[fit]` — regressione e scelta (`fit.py`, `analisi.py`)
| Chiave | Default | Significato |
|---|---|---|
| `raffreddamento_intermedio_fissa_da_finale` | `["c", "tau"]` | parametri che i raffreddamenti intermedi prendono dal raffreddamento finale ([algoritmi.md](algoritmi.md) §5b). `[]` = tutti liberi. Nomi ammessi: quelli del modello (`c`, `A`, `tau`) |
| `perdita` | `"soft_l1"` | funzione di perdita di `least_squares`: `"linear"` (minimi quadrati), `"soft_l1"`, `"huber"` (robuste agli scarti, A14) |
| `scala_perdita_sigma` | 3.0 | scala $s$ della perdita robusta, in multipli di $\sigma$: sotto $s$ come i minimi quadrati, sopra cresce linearmente |
| `tau_min_rel`, `tau_max_rel` | 0.05, 50.0 | limiti di $\tau$ come frazione della durata del dominio $L$: $\tau \in [0.05L,\ 50L]$; un $\tau$ al bordo rende il fit non valido |
| `n_griglia_tau` | 40 | punti della griglia logaritmica su $\tau$ per la partenza dell'esponenziale |
| `soglia_condizione` | $10^8$ | numero di condizione dello jacobiano normalizzato oltre cui i parametri sono «non identificabili» |
| `correggi_autocorrelazione` | `true` | allarga gli intervalli di $\sqrt{(1+\rho_1)/(1-\rho_1)}$ se $\rho_1$ supera `soglia_acf` (A17) |
| `soglia_acf` | 0.3 | soglia di $\rho_1$ per la correzione |
| `soglia_delta_aicc` | 2.0 | tra i modelli entro questo $\Delta\mathrm{AICc}$ dal migliore vince quello con meno parametri |
| `scala_residui_vs_rumore_max` | 1.5 | un modello è «adeguato» se $\hat\sigma_\text{res} \le$ questo valore $\cdot\ \sigma$, con $\hat\sigma_\text{res} = 1.4826\,\mathrm{MAD}(r)$ la scala **robusta** dei residui ([algoritmi.md](algoritmi.md) §5.5, §6). Osservato per il modello giusto: 0.97–1.40; per modelli sbagliati: 11–100 |
### `[fit.modelli_per_tipo]` — modelli provati per tipo di dominio
| Chiave | Default | Significato |
|---|---|---|
| `riposo` | `["costante", "lineare", "esp_decad"]` | modelli provati sul riposo (A10) |
| `raffreddamento` | `["esp_decad"]` | modelli provati sui raffreddamenti (A7) |
Entrambe le liste sono obbligatorie e non vuote. Non esiste la voce `riscaldamento`: quel dominio non si fitta (A23). Modelli ammessi: i nomi in
`modelli.MODELLI`.
## `[output]` (`cli.py`)
| Chiave | Default | Significato |
|---|---|---|
| `cartella_output` | `"output"` | cartella madre; ogni analisi scrive in `<cartella_output>/<nome del csv senza estensione>/`. `--output DIR` la sostituisce |
## `domini.toml` — domini modificabili (`domini.py`)
Scritto in `output/<csv>/01_domini/domini.toml`, rilegibile con `fitseg analizza CSV --domini FILE` ([uso.md](uso.md)):
```toml
[[dominio]]
tipo = "raffreddamento" # riposo | riscaldamento | raffreddamento
t0_s = 16.1000 # primo campione del dominio (incluso)
t1_s = 17.9100 # primo campione del dominio successivo (escluso); per l'ultimo: ultimo campione
```
I domini devono essere contigui ($t_1$ di uno uguale a $t_0$ del successivo): altrimenti exit 6.
## Esempi
| Voglio… | Cambio |
|---|---|
| gradini più piccoli (es. 8 °C) | `[domini] soglia_gradino_c = 8.0` e verifica che gli scarti restino sotto la soglia (A5) |
| tutti i raffreddamenti con parametri liberi | `[fit] raffreddamento_intermedio_fissa_da_finale = []` |
| fissare solo la costante di tempo | `["tau"]` |
| fit ai minimi quadrati puri | `perdita = "linear"` |
| un'altra colonna del CSV | `[dati] serie = [...]` e, se serve, `[domini] serie_riferimento` |
| tempo in secondi | `[dati] unita_tempo = "s"` e `colonna_tempo` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

+43
View File
@@ -0,0 +1,43 @@
# Limiti noti ed estensioni
## Limiti noti
| Limite | Perché | Effetto | Dove |
|---|---|---|---|
| **Intervalli di confidenza troppo stretti** | l'errore su $\tau$ ha deviazione standard 3.7 volte l'errore standard calcolato: gli scarti casuali sono concentrati all'inizio del raffreddamento (dove la sensibilità a $\tau$ è massima), quindi non stazionari (A14, A17) | su 60 seed il $\tau$ vero cade nell'intervallo al 95 % nel **73 %** dei casi (88 su 120), con errori comunque sotto il 2 %. **Provati e scartati**: correzione a più lag di Bartlett (77 %) e stimatore sandwich HAC (≤ 62 %, peggio dell'attuale AR(1)). Non si risolve con un fattore di correzione scalare: servirebbe un modello dei disturbi | [assunzioni.md](assunzioni.md) A14, A17 |
| **Il $\sigma$ stimato ignora la parte lenta del rumore** | la derivata seconda elimina la deriva (A13) | $\sigma$ sottostimata di ≈ 0–25 % (rapporto stimato/vero 0.73–1.35, mediana 1.00); servono 8 campioni, sotto $\sigma$ è «non stimabile» e l'adeguatezza non valutabile | A13 |
| **Il riscaldamento non è fittato** | 2–4 campioni, meno dei parametri utili (A23) | si riporta il salto $\Delta$: dai modelli adiacenti (errore medio +0.33 %, massimo 3.16 % su 124 salti), altrimenti dalla misura corretta con $c$, $\tau$ del finale, altrimenti misurato sui campioni (per difetto, fino a ≈ 10–20 %) | [algoritmi.md](algoritmi.md) §7 |
| **Incertezze condizionate nei raffreddamenti intermedi** | $c$ e $\tau$ sono fissi, la loro incertezza non si propaga (A18) | gli intervalli di $A$ sono troppo stretti | A18 |
| **Nessun vincolo di continuità** | ogni dominio è indipendente (A11) | gli intermedi non sono vincolati a combaciare col gradino | A11 |
| **Un solo esponenziale per raffreddamento** | modello `esp_decad` (A7) | un raffreddamento con due processi (conduzione e convezione) non è descritto bene | A7 |
| **Gradini cercati su una sola serie** | `serie_riferimento` (A4) | serie con gradini diversi richiedono due analisi | A4 |
| **Soglia dei gradini fissa e non derivata dai dati** | `soglia_gradino_c` = 20 °C assoluti in config (A5): un valore scelto a mano, non misurato sul file | funziona perché sui 60 seed i gruppi di campioni in salita che non sono gradini arrivano al massimo a 2.1 °C (7 casi) e i gradini veri partono da 33 °C: la soglia sta in un vuoto di circa 16 volte. Va però adattata a gradini più piccoli di ≈ 30 °C o a scarti più grandi. **Provato e scartato**: usare la caduta massima come scala dei disturbi (le cadute arrivano a 57.8 °C per il raffreddamento veloce). **Alternative oggettive non implementate**: interruzione naturale tra le salite osservate (richiede almeno un gradino nel file) e persistenza della salita rispetto a $\sigma$ (confonde un gradino piccolo con un disturbo); entrambe renderebbero gradino un salto isolato di 10 °C con rumore 0.1 °C, contro i test attuali | [configurazione.md](configurazione.md), A5 |
| **File senza gradini non validato sui dati generati** | `gen_data` produce sempre 1–3 gradini | il caso «nessun gradino» (un solo riposo) è coperto solo da test sintetici; con rumore forte e senza gradini la soglia fissa evita falsi gradini, un criterio derivato dai dati dovrebbe gestirlo esplicitamente | [test.md](test.md) |
| **Validato solo su dati sintetici** | 60 seed di `gen_data` (gradini, $\tau$, rumore e scarti sorteggiati): gradini 60/60, $\tau$ con scarto quadratico medio 0.39 % (massimo 1.99 %), ampiezze $A$ 0.59 % (massimo 2.14 %), nessun tratto indeterminato | nessuna validazione su dati reali o su rumore molto diverso dagli intervalli del generatore | [test.md](test.md) |
| **Piccola sovrastima di $A$** | il modello non include il ritardo di risposta del sensore (A25) | $A$ è sovrastimata di circa $\tau_\text{sensore}/\tau \approx 0.5$ % (misurato +0.45 % in media); $\tau$ non è affetta | A25 |
| **«Non adeguato» sui raffreddamenti intermedi corti** | $\sigma$ è stimata sui soli 8–30 campioni del tratto e $c$, $\tau$ sono fissi ai valori del finale (A8): la scala robusta dei residui risulta 1.5–1.9 volte $\sigma$ pur con il modello giusto | in 10 raffreddamenti intermedi su 23 in 20 seed casuali (3 su 9 nel primo lotto, 7 su 14 nel secondo; 8–29 campioni, scala fino a 1.85 $\sigma$; altri 2 non valutabili perché troppo corti); i raffreddamenti finali sono sempre adeguati (0 casi su 140). $\tau$ e $A$ restano corretti: è un falso allarme del criterio, non un errore del fit | [algoritmi.md](algoritmi.md) §6 |
| **Modello del riposo non unico** | il segnale a riposo contiene l'assestamento dell'ambiente più una deriva lenta casuale (Ornstein-Uhlenbeck in `gen_data`): su 10–20 s la sua forma non è ricostruibile e `T_C` e `dT_C` possono avere modelli diversi | su 120 riposi: `costante` 76, `lineare` 41, `esp_decad` 3; in questi 3 il $\tau$ è incerto (IC 95 % da metà del valore in su, sempre su `T_C`). Né la correzione per l'autocorrelazione dei residui ($n_\text{eff}$, $\bar\rho_1 \approx 0.1$, cambia 4 scelte su 120) né una soglia sull'incertezza relativa risolvono senza un parametro scelto a mano | [assunzioni.md](assunzioni.md) A10 |
| **Tratti tra gradini troppo ravvicinati** | meno di `n_min_punti_parametri_fissi` (4) campioni, cioè gradini a meno di ≈ 1 s l'uno dall'altro (nel generatore la distanza minima è 1 s: nei 60 seed nessun caso) | il raffreddamento tra i due gradini è indeterminato; i salti adiacenti usano la misura corretta con $c$, $\tau$ del finale, o la sola misura | [configurazione.md](configurazione.md) |
## Estensioni possibili (non presenti)
| Estensione | Cosa servirebbe |
|---|---|
| Modello a due esponenziali per il raffreddamento | voce in `modelli.MODELLI` con 5 parametri, partenza su griglia a due $\tau$, ordinamento $\tau_1 < \tau_2$, nome nelle liste di `modelli_per_tipo` ([moduli.md](moduli.md)) |
| Modelli di transizione liscia (logistica) o polinomi superiori | stessa procedura; limitare il centro della logistica al dominio per evitare che degeneri in esponenziale |
| Fit congiunto con continuità tra domini | un unico problema ai minimi quadrati con penalità sul salto residuo ai confini |
| Fit del riscaldamento | richiede più campioni (sensore più lento o campionamento più fitto) |
| Scelta dei modelli guidata da un agente (Claude Code) | era nel progetto iniziale e **è stata tolta**: la regola automatica ([algoritmi.md](algoritmi.md) §6) basta per questi dati |
| Rumore colorato nel fit (minimi quadrati generalizzati) | modello AR(1) dei residui per correggere intervalli e pesi invece del solo fattore $\sqrt{(1+\rho_1)/(1-\rho_1)}$ |
## Scelte progettuali già fatte
| Scelta | Alternativa scartata | Motivo |
|---|---|---|
| Domini dai gradini di `T_C` | PELT + fusione per BIC | i regimi qui sono determinati dai gradini; PELT con costo quadratico spezzerebbe gli esponenziali lisci |
| Riscaldamento non fittato, descritto come salto | fit su 2–3 punti | parametri non identificabili |
| $c$ e $\tau$ fissati negli intermedi | $\tau$ libero ovunque | $\tau$ non identificabile con 12–25 campioni e scarti casuali |
| Partenza per griglia su $\tau$ (proiezione delle variabili) | partenze casuali | deterministico e più robusto per $\tau$ |
| Perdita robusta `soft_l1` | minimi quadrati | gli scarti casuali sono outlier |
| Output per CSV in sottocartelle numerate | cartella piatta | ogni passo è ispezionabile separatamente |
| Un modulo per responsabilità, CLI a due comandi | script per fase, pacchetti `motore/` e `uscita/` | meno codice, nessuna duplicazione |
+138
View File
@@ -0,0 +1,138 @@
# Moduli
Mappa del codice in `src/fitseg/`. Un modulo ha una sola responsabilità; le dipendenze vanno in una sola direzione.
```mermaid
flowchart TD
cli[cli.py] --> config[config.py]
cli --> dati[dati.py]
cli --> domini[domini.py]
cli --> analisi[analisi.py]
cli --> uscita[uscita.py]
analisi --> domini
analisi --> fit[fit.py]
analisi --> modelli[modelli.py]
fit --> modelli
uscita --> analisi
uscita --> modelli
```
`config.py`, `dati.py`, `domini.py` e `modelli.py` non importano niente del pacchetto; `fit.py` importa solo `modelli`; `analisi.py` importa `domini`, `fit`, `modelli`;
`uscita.py` importa `analisi` (per `valido` e `fit_scelto`) e `modelli`. Nessun modulo di calcolo scrive file.
## Strutture dati comuni
**Domini** (lista, ordinata e contigua): ogni elemento è `{"tipo": "riposo"|"riscaldamento"|"raffreddamento", "i0": int, "i1": int}`, con
`i0` primo campione e `i1` escluso. `domini[k]["i1"] == domini[k+1]["i0"]`.
**Risultato** (lista, una voce per dominio; è anche il contenuto di `risultati.json`):
| Campo | Significato |
|---|---|
| `k` | numero del dominio (da 1) |
| `tipo`, `t0_s`, `t1_s` | tipo; tempo del primo e dell'ultimo campione del dominio (in secondi) |
| `n`, `n_fit` | campioni del dominio; campioni usati nel fit (meno `scarta_dopo_salto_n` nei raffreddamenti) |
| `serie` | `{nome serie: voce}` (sotto) |
**Voce di serie**: `sigma` (σ del rumore; `null` nel riscaldamento e se non è stimabile, cioè meno di 8 campioni), `fit` (lista dei fit provati), `scelta` (nome del modello o
`null`), `motivo` (testo), `adeguato` (`true`/`false`; `null` se σ non è stimabile; assente nel riscaldamento e nei domini troppo corti); nel riscaldamento al posto dei fit c'è `gradino`:
`{n, da, a, delta, delta_misurato, stima, durata_s, istante_s}`: `delta` è il salto usato; `stima` vale `"modelli"` (differenza dei modelli adiacenti all'istante
`istante_s`), `"misura corretta"` (misura riportata a quell'istante con $c$ e $\tau$ del raffreddamento finale) o `"misura"` (sui campioni); `da`/`a` sono i valori
prima/dopo usati.
**Fit** (elemento di `fit`): `modello`, `equazione` (LaTeX), `n_parametri` ($k$ liberi), `n`, `fissi` (nomi dei parametri fissi), `parametri`
`{nome: valore}`, `ic95` `{nome: ±}`, `rss`, `rmse`, `scala_residui` ($\hat\sigma_\text{res} = 1.4826\,\mathrm{MAD}$ dei residui), `r2`, `aicc`, `bic`, `acf1` ($\rho_1$), `condizione`, `parametri_a_limite`, `non_identificabile`,
`convergenza`. Se la minimizzazione fallisce: solo `modello`, `convergenza: false`, `n_parametri`.
## `cli.py` — punto d'ingresso
| | |
|---|---|
| Responsabilità | argomenti da riga di comando, orchestrazione dei passi, codici di uscita |
| Funzioni | `main(argv)`, `cmd_domini`, `cmd_analizza`, `_passo_domini(args)`, `errore(msg, codice)` |
| Entra | `--config`, `--output`, sottocomando `domini CSV` o `analizza CSV [--domini FILE]` |
| Esce | codici 0 ok, 2 uso errato (argparse), 3 `ErroreConfig`, 4 `ErroreDati`, 6 `domini.toml` non valido |
`_passo_domini` è il passo 1 comune ai due comandi: legge config e dati, crea `output/<nome csv>/` con le tre sottocartelle, ricava i domini
(da `--domini` oppure dalla proposta), scrive `01_domini/domini.toml` e `domini.png`, stampa la tabella dei domini. `cmd_analizza` poi chiama
`analizza_domini` e scrive sovrapposizione e report. Con `--domini` il file passato viene riscritto normalizzato in `01_domini/`.
## `config.py` — configurazione
| | |
|---|---|
| Responsabilità | leggere `config.toml`, rifiutare chiavi mancanti (**nessun default nel codice**) |
| Funzioni | `leggi(percorso) → dict`; costante `CHIAVI` (elenco obbligatorio per sezione); eccezione `ErroreConfig` |
| Controlli | chiavi presenti; `unita_tempo` ∈ {ms, s}; `serie_riferimento` ∈ `serie`; `modelli_per_tipo` ha liste non vuote per `riposo` e `raffreddamento` |
Una chiave nuova va aggiunta a `CHIAVI`, a `config.toml` e a [configurazione.md](configurazione.md).
## `dati.py` — lettura del CSV
| | |
|---|---|
| Funzioni | `leggi_csv(percorso, cfg) → (t, serie)`; eccezione `ErroreDati` |
| Garanzie | `t` in secondi, strettamente crescente (A1); `serie` = `{nome: array}` per le colonne di `[dati] serie`; nessun valore mancante |
## `domini.py` — suddivisione in domini
| Funzione | Cosa fa | Vedi |
|---|---|---|
| `sigma_robusto(d)` | $1.4826\,\mathrm{MAD}$ di un campione | [algoritmi.md](algoritmi.md) §2 |
| `sigma_rumore(y)` | $\sigma$ del rumore bianco da $\Delta^2 y$; `nan` con meno di `N_MIN_SIGMA` = 8 campioni | §2 |
| `trova_gradini(y, cfg)` | lista di $(i_0, i_1)$ dei campioni di salita: campioni in salita raggruppati, gradino se la salita complessiva supera `soglia_gradino_c` | §3 |
| `proponi(t, serie, cfg)` | lista dei domini (riposo + riscaldamento/raffreddamento per gradino) | §4 |
| `scrivi_toml(percorso, domini, t)` | scrive `domini.toml` (tempi con 4 decimali) | [uso.md](uso.md) |
| `leggi_toml(percorso, t)` | rilegge e **valida** i domini (tipi, intervalli, contiguità); `ValueError` se non validi | [uso.md](uso.md) |
Nel `domini.toml` `t1_s` è l'estremo **escluso** (tempo del primo campione del dominio successivo); per l'ultimo dominio è l'ultimo campione. Gli
indici si ricalcolano dal tempo con tolleranza $10^{-6}$ s.
## `modelli.py` — registro dei modelli
| | |
|---|---|
| Dati | `MODELLI = {nome: (funzione, [nomi parametri], equazione LaTeX, indice di τ o None)}` |
| Funzioni | `valuta(nome, t, p)` valuta un modello in $t$ con parametri `p` (lista nell'ordine dei nomi) |
Contiene `costante`, `lineare`, `esp_decad`. **Aggiungere un modello**: una funzione, una voce in `MODELLI`, il nome nelle liste di
`[fit.modelli_per_tipo]`, una riga in [algoritmi.md](algoritmi.md) §5.1; se il modello ha un nuovo tipo di parametro-tempo, indicare l'indice
in `MODELLI` e la partenza in `fit._partenza`; in `uscita._eq_numerica` aggiungere il ramo per l'equazione con i valori.
## `fit.py` — regressione di un modello
| Funzione | Cosa fa |
|---|---|
| `adatta(t, y, nome, sigma, cfg, fissi=None) → dict` | fit di un modello su un dominio; `fissi = {parametro: valore}` tenuti costanti. Restituisce il dizionario *Fit* sopra |
| `_partenza(t, y, nome, cfg, L)` | minimi quadrati per i polinomi, griglia su $\tau$ (VARPRO) per l'esponenziale |
| `_acf1(r)` | autocorrelazione dei residui a lag 1 |
Dettagli: [algoritmi.md](algoritmi.md) §5. Nessun accesso a file, nessuna casualità.
## `analisi.py` — scelta e orchestrazione dei domini
| Funzione | Cosa fa |
|---|---|
| `valido(fit)` | convergente, nessun parametro a limite, identificabile, AICc finito |
| `fit_scelto(voce)` | il fit scelto di una voce di serie, o `None` (indeterminato, riscaldamento) |
| `scegli(fit_list, sigma, cfg) → (fit, motivo, adeguato)` | regola di scelta ([algoritmi.md](algoritmi.md) §6); adeguato = scala robusta dei residui ≤ soglia·σ; `None` se σ non stimabile |
| `_gradino(t, y, d)` | descrive il riscaldamento come salto misurato sui campioni ([algoritmi.md](algoritmi.md) §7) |
| `_valore(dominio, nome, t)`, `_finale(risultati)`, `_affina_salti(t, serie, risultati, domini)` | valore del modello scelto in $t$; raffreddamento finale; sostituisce la misura del salto con la stima dai modelli adiacenti o, in mancanza, con la misura corretta con $c$, $\tau$ del finale (§7) |
| `_serie(ty, yy, sigma, tipo, fissi, cfg)` | fit di tutti i modelli del tipo per una serie e scelta; `indeterminato` se i campioni sono meno di `n_min_punti` (`n_min_punti_parametri_fissi` con parametri fissi) |
| `analizza_domini(t, serie, domini, cfg) → list` | un *Risultato* per dominio; ordine: raffreddamento finale, poi gli altri ([algoritmi.md](algoritmi.md) §5b) |
## `uscita.py` — cartelle, grafici, report
| Funzione | Cosa produce |
|---|---|
| `cartelle(base)` | crea `01_domini`, `02_sovrapposizione`, `03_report` e le restituisce |
| `colori_domini(tipi)` | un colore per dominio (grigio riposo, rosso riscaldamento, colori diversi per ogni raffreddamento) |
| `grafico_domini(percorso, t, serie, domini)` | passo 1: `domini.png` |
| `sovrapposizione_generale(...)` | passo 2a: `sovrapposizione_<serie>.png` |
| `sovrapposizione_domini(...)` | passo 2b: `dominio<k>_<serie>.png` |
| `scrivi_report(cartella, risultati, nome_csv, serie_nomi, cfg)` | passo 3: `report.md` e `risultati.json` |
| `_senza_nan(x)` | copia con NaN/∞ sostituiti da `None`: `risultati.json` è sempre JSON valido (`allow_nan=False`) |
| `_num`, `_params`, `_eq_numerica`, `_salto` | formattazione LaTeX (valori, parametri ± IC, equazione con i valori sostituiti, salto) |
Il report incorpora le immagini con percorsi **relativi** (`../01_domini/domini.png`, …): cambiando nomi di file o cartelle vanno aggiornati
`PASSI` e i riferimenti. Le grandezze matematiche del report sono sempre in LaTeX e i `$` devono restare bilanciati per riga (c'è un test).
Descrizione di file e grafici: [output.md](output.md).
+80
View File
@@ -0,0 +1,80 @@
# Output
Ogni analisi scrive in `output/<nome del csv>/` (la cartella `output/` è ignorata da git). Ogni passo ha la sua sottocartella, numerata
nell'ordine di esecuzione. Chi produce cosa: [moduli.md](moduli.md) (`uscita.py`).
```
output/gen_7/
├── 01_domini/ passo 1 — suddivisione dei dati
│ ├── domini.png dati grezzi con i domini (colore per tipo, numerati) + zoom sui gradini
│ └── domini.toml domini usati, modificabili a mano ([uso.md](uso.md))
├── 02_sovrapposizione/ passo 2 — dati grezzi contro le equazioni trovate
│ ├── sovrapposizione_<serie>.png serie intera con le equazioni a tratti, residui e zoom
│ └── dominio<k>_<serie>.png un dominio: dati, modello scelto, altri modelli validi, residui
└── 03_report/ passo 3
├── report.md report completo, immagini incorporate, formule in LaTeX
└── risultati.json tutti i fit con tutte le metriche (schema in moduli.md)
```
`fitseg domini` scrive solo `01_domini/`; `fitseg analizza` scrive tutto.
## Colori
| Dominio | Colore |
|---|---|
| riposo | grigio |
| riscaldamento | rosso (i suoi campioni sono evidenziati con pallini, perché la fascia è sottile) |
| raffreddamento | un colore **diverso per ognuno**, in ordine: blu, verde, viola, arancio, ciano (poi si ricomincia) |
Le fasce sono numerate come nelle tabelle (il numero compare solo se la fascia è abbastanza larga per il grafico). Il raffreddamento *k*-esimo
ha lo stesso colore in tutti i grafici.
## `01_domini/domini.png`
Una riga per serie. A sinistra l'intero segnale con le fasce dei domini; a destra lo **zoom** sulla zona dei gradini (da 2 s prima del primo
riscaldamento a 8 s dopo l'ultimo), dove 7 domini in pochi secondi si distinguono. Serve a controllare la suddivisione *prima* di fidarsi
delle equazioni: se è sbagliata si corregge `domini.toml` e si rilancia ([uso.md](uso.md) §3).
## `02_sovrapposizione/sovrapposizione_<serie>.png`
Sopra: dati grezzi (punti grigi), fasce dei domini e **curva dell'equazione scelta** per ogni dominio nel colore del dominio. Sotto: residui
$y - \hat y$ dei soli campioni usati nel fit (i campioni esclusi all'inizio dei raffreddamenti non compaiono, per non schiacciare la scala).
A destra lo stesso con lo zoom. Il riscaldamento non ha curva (non si fitta) e i suoi campioni sono i pallini rossi.
Come leggere i residui: attorno a zero e senza struttura = modello adeguato; ondulazioni o code verso il basso = **scarti casuali** del sensore
non descritti dal modello (A14), non un errore del fit.
## `02_sovrapposizione/dominio<k>_<serie>.png`
- **Riposo e raffreddamento**: punti neri = campioni usati nel fit; punti grigi = campioni esclusi (solo se `scarta_dopo_salto_n` > 0); **linea continua** = modello scelto
(nel colore del dominio, con $\mathrm{RMSE}$ in legenda); **tratteggiate** = gli altri modelli validi (confronto); sotto i residui del modello scelto.
- **Riscaldamento**: la salita (pallini rossi) con 8 campioni di contesto prima e dopo, e nel titolo $\Delta$, numero di campioni e durata. Nessuna curva.
- Dominio indeterminato: solo i dati (un pannello, senza residui), con «indeterminato» nel titolo.
## `03_report/report.md`
Le immagini sono link relativi (`../01_domini/domini.png`); le grandezze matematiche sono in LaTeX (`$…$` nel testo, `$$…$$` per l'equazione scelta
con i valori sostituiti): servono un visualizzatore Markdown con supporto matematico (GitHub, VS Code, Obsidian). Struttura:
| Sezione | Contenuto |
|---|---|
| intestazione | nome del CSV, data, serie, definizione $t' = t - t_0$, livello degli intervalli (95 %) |
| 1. Suddivisione in domini | `domini.png` e tabella (#, tipo, $t_0$, $t_1$, campioni) |
| 2. Equazioni trovate | criterio di scelta con le soglie correnti; tabella (dominio, tipo, serie, modello, equazione e parametri ± IC) con una riga per dominio e serie; i riscaldamenti hanno il salto |
| 3. Sovrapposizione dati–equazioni | un grafico per serie |
| 4. Dettaglio per dominio | per ogni dominio e serie: grafico, modello scelto, equazione con i valori ($$…$$), parametri ± IC, $\sigma_\text{rumore}$ (se stimabile), $\hat\sigma_\text{res}$ (scala robusta dei residui), $\mathrm{RMSE}$, $R^2$ (non per `costante`, per cui non ha senso), $\rho_1$, motivo, tabella di tutti i modelli provati ($k$, RMSE, $\hat\sigma_\text{res}$, AICc, BIC, $\rho_1$, valido); il titolo riporta l'intervallo $t_0 \le t \le t_1$ (primo e ultimo campione del dominio) |
### Come leggere i messaggi
| Messaggio | Significato | Cosa fare |
|---|---|---|
| **salto** (riscaldamento) | salita rapida descritta, non fittata (A23): $\Delta$ stimato dai modelli adiacenti (misura accanto); «misura riportata all'istante del gradino con $c$ e $\tau$ del raffreddamento finale» se manca un modello vicino; «misurato sui campioni» se manca anche quello | niente: la misura sui campioni è per difetto (fino a ≈ 10–20 %), le altre due stime no (errore tipico < 1 %, massimo ≈ 3 % su 60 seed) |
| **(fisso)** accanto a un parametro | tenuto costante al valore del raffreddamento finale (A8); l'IC di $A$ è condizionato (A18) | niente; per disattivarlo vedi [configurazione.md](configurazione.md) |
| **modello non adeguato** ($\hat\sigma_\text{res} > 1.5\,\sigma$) | anche la scala **robusta** dei residui ($1.4826\,\mathrm{MAD}$, che ignora gli scarti casuali) supera il rumore: il modello scelto non spiega i dati | verifica la suddivisione dei domini e i residui: nei raffreddamenti finali con modello e domini giusti non succede (0 su 140); nei raffreddamenti intermedi corti (< 30 campioni, $c$ e $\tau$ fissi) può essere un falso allarme ([limiti.md](limiti.md)) |
| **Indeterminato** | nessun modello valido, oppure meno di `n_min_punti` campioni (`n_min_punti_parametri_fissi` nei raffreddamenti con $c$ e $\tau$ fissi): il motivo indica quale | verifica la suddivisione: tra gradini a meno di ≈ 1.2 s restano meno di 4 campioni |
| «valido: no» in tabella | fit non convergente, $\tau$ al limite o non identificabile ([algoritmi.md](algoritmi.md) §5.6) | un modello più semplice può essere preferito |
## `03_report/risultati.json`
Lista di domini con tutti i fit e tutte le metriche, anche dei modelli scartati. Schema dei campi: [moduli.md](moduli.md), «Strutture dati comuni».
È JSON valido anche in presenza di NaN (i campi non applicabili o non calcolabili, come `sigma` del riscaldamento o con meno di 8 campioni, sono `null`), adatto a elaborazioni successive (confronti tra
analisi, tabelle).
+65
View File
@@ -0,0 +1,65 @@
# Panoramica
## Cosa fa
`fitseg` prende la serie temporale di un sensore IR che si scalda e si raffredda (CSV `t_ms,T_C,dT_C`) e per ogni **tratto** del segnale
trova l'equazione che lo descrive meglio. Il lavoro è in tre passi:
1. **Domini**: divide il tempo in tratti omogenei (riposo, riscaldamento, raffreddamento).
2. **Equazioni**: in ogni tratto fitta i modelli adatti con minimi quadrati non lineari e sceglie il migliore con una regola fissa.
3. **Sovrapposizione e report**: confronta i dati grezzi con le equazioni trovate e scrive un report con grafici e formule.
Tutto è deterministico: stessi dati e stessa configurazione danno gli stessi risultati. Non c'è nessun modello linguistico né nessuna
parte casuale.
## Il segnale
Il sensore parte dalla temperatura ambiente (**riposo**). Poi il campione viene scaldato a **gradini**: la temperatura sale in una frazione
di secondo (pochi campioni, **riscaldamento**) e subito dopo decade verso l'ambiente (**raffreddamento**). Con più gradini ravvicinati
(2–4 s) il decadimento di ognuno è interrotto dal successivo; dopo l'ultimo gradino il raffreddamento prosegue fino all'ambiente.
![Segnale del seed 7: i 7 domini, lo zoom sui gradini e la tabella dei risultati](img/domini_esempio.png)
*Figura: dati reali di `input/gen_7.csv` (serie `T_C`, seed 7 di `gen_data`) con i domini riconosciuti da `fitseg` e le equazioni trovate. In alto il segnale completo, al
centro lo zoom sui tre gradini, in basso la tabella che associa a ogni numero tipo, intervallo ed equazione. Il riscaldamento è rosso, il riposo grigio e
ogni raffreddamento ha un colore diverso. Con 3 gradini i domini sono $1 + 2\cdot 3 = 7$.*
Il generatore dei dati di prova è il repository `gen_data` (clonato in `extern/gen_data` con `./scarica_gen_data.sh`) (modello e parametri: `extern/gen_data/docs/SPEC.md`): a ogni seed sorteggia numero
dei gradini (1–3), costante di tempo, rumore e disturbi, quindi ogni file ha una struttura diversa. Le sue caratteristiche che contano per `fitseg` sono
elencate in [assunzioni.md](assunzioni.md), sezione «Origine dei dati di prova».
## Concetti
| Termine | Significato |
|---|---|
| **serie** | una colonna del CSV da approssimare (`T_C` temperatura, `dT_C` differenza dall'ambiente). Tutte le serie condividono gli stessi domini |
| **gradino** | salita positiva di temperatura, distribuita su 2–4 campioni, la cui salita complessiva supera `soglia_gradino_c` |
| **dominio** | tratto contiguo di campioni con un tipo: `riposo`, `riscaldamento` o `raffreddamento` |
| **riscaldamento** | la salita rapida di un gradino (2–4 campioni): troppo breve per un fit, è descritta come **salto** ($\Delta$, da → a, durata) |
| **raffreddamento** | il decadimento dopo un gradino, fino al gradino successivo o alla fine; modello esponenziale |
| **raffreddamento finale / intermedio** | il finale è l'ultimo dominio (il più lungo, parametri liberi); gli intermedi stanno tra due gradini (corti) e ricevono $c$ e $\tau$ dal finale |
| **modello** | una famiglia di equazioni (`costante`, `lineare`, `esp_decad`); vedi [algoritmi.md](algoritmi.md) §5.1 |
| **parametro fisso** | parametro tenuto costante nel fit (non conta nei gradi di libertà); marcato «(fisso)» nel report |
| **indeterminato** | nessun modello valido, oppure dominio con meno di `n_min_punti` campioni |
| **non adeguato** | modello scelto ma con $\hat\sigma_\text{res} > 1.5\,\sigma$ (scala robusta dei residui, che ignora gli scarti casuali): il modello non spiega i dati |
| **tempo del dominio** | $t' = t - t_0$, con $t_0$ il tempo del primo campione del dominio |
## Esempio (seed 7 di `gen_data`)
La figura sopra è il risultato di `fitseg analizza input/gen_7.csv` su `T_C`: sul raffreddamento finale $\tau = 12.12 \pm 0.04$ s, $c = 23.92$ °C, $A = 163.2$ °C;
i due raffreddamenti intermedi usano gli stessi $c$ e $\tau$ e stimano solo $A$ ($76.7$ e $124.5$ °C); i tre riscaldamenti sono salti di $+79.7$, $+71.4$ e $+90.2$ °C.
I valori veri del seed 7 sono $\tau = 1/k = 12.07$ s e salti di $79.6$, $71.1$ e $89.4$ °C: il fit li recupera entro lo 0.5 % ($\tau$) e l'1 % (salti).
Su 60 seed diversi l'errore su $\tau$ resta sotto il 2 % ([test.md](test.md)).
## Dove andare avanti
| Per… | Leggi |
|---|---|
| lanciare i comandi, correggere i domini a mano | [uso.md](uso.md) |
| capire cosa si dà per vero e cosa succede se non lo è | [assunzioni.md](assunzioni.md) |
| sapere come funzionano rilevamento, fit e scelta, con le formule | [algoritmi.md](algoritmi.md) |
| orientarsi nel codice (un modulo alla volta) | [moduli.md](moduli.md) |
| cambiare un valore (ogni chiave di `config.toml`) | [configurazione.md](configurazione.md) |
| leggere i file prodotti e i grafici | [output.md](output.md) |
| eseguire e capire i test | [test.md](test.md) |
| conoscere limiti noti e possibili estensioni | [limiti.md](limiti.md) |
+110
View File
@@ -0,0 +1,110 @@
# Test
```bash
.venv/bin/pytest # tutti (≈ 50 s)
.venv/bin/pytest tests/test_fit.py # un file
.venv/bin/pytest -k raffreddamento # per nome
.venv/bin/pytest -k validazione # solo la validazione contro i parametri veri (la parte lenta, ≈ 25 s)
.venv/bin/pytest -x -q # ferma al primo errore, output breve
.venv/bin/pytest -rs # mostra i test saltati e il motivo
.venv/bin/ruff check src tests # stile e import (deve passare)
```
## Tre livelli
| Livello | File | Dati | Richiede |
|---|---|---|---|
| **Unitari** | `test_domini`, `test_fit`, `test_selezione`, `test_config_dati` | segnale sintetico generato nei test (`conftest.py`) | niente |
| **Report e CLI** | `test_report`, `test_cli` | CSV sintetici scritti in una cartella temporanea | niente |
| **Validazione** | `test_validazione` | 20 seed generati al volo da `extern/gen_data`, confrontati con i suoi **parametri veri** | `extern/gen_data`, da scaricare con `./scarica_gen_data.sh` (altrimenti i test sono saltati) |
Nessun test usa i file in `input/`: i test girano su un clone nuovo e non dipendono dal contenuto di quella cartella.
## Il segnale sintetico (`tests/conftest.py`)
$$T(t) = T_\text{amb} + \sum_j A_j\,e^{-(t-t_j)/\tau}\,\mathbb{1}[t \ge t_j] + \varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, 0.1^2)$$
con $T_\text{amb} = 23$, $\tau = 16$ s, gradini $(t_j, A_j) = (15, 80), (18, 70), (22, 60)$, campionamento 0.15 s, 120 s, **nessuno scarto casuale e nessuna risposta del sensore**
(il gradino è un solo campione). La verità è nota, quindi si controllano i parametri recuperati. `segnale(...)` accetta rumore, seme, gradini e durata per
costruire casi particolari; `scrivi_csv(...)` scrive un CSV nel formato di `gen_data`. `dT_C` = $T - T_\text{amb}$. Fixture: `cfg` (config reale), `sintetico`.
## Validazione contro i parametri veri (`test_validazione.py`)
`extern/gen_data` sorteggia a ogni seed i parametri di una corsa (numero e istanti dei gradini, ampiezze, $k$, rumore, scarti: [assunzioni.md](assunzioni.md), «Origine dei dati di
prova»). Poiché il sorteggio è riproducibile (`genera.sorteggia(cfg, default_rng(seed))`), per ogni seed si conoscono i valori veri. Il test, per i seed 1–20,
genera il CSV, lancia l'analisi e controlla:
| Controllo | Tolleranza | Osservato su 60 seed |
|---|---|---|
| numero di gradini trovati = numero vero | esatto | 60/60 |
| istante di ogni gradino | < 0.3 s (due campioni) | ≤ 0.15 s |
| $\tau$ del raffreddamento finale ($\tau\cdot k = 1$) | ± 3 % | massimo 1.99 %, scarto quadratico medio 0.39 % |
| ampiezza $A$ di ogni raffreddamento (su `dT_C`) = somma dei decadimenti dei gradini precedenti | ± 4 % | massimo 2.14 %, media +0.45 % (ritardo del sensore, A25) |
| salto $\Delta$ stimato (modelli o misura corretta) = ampiezza vera del gradino | ± 5 % | massimo 3.16 %, media +0.33 % (124 salti, tutti dai modelli) |
| un raffreddamento è indeterminato solo se ha meno campioni del minimo con parametri fissi | esatto | nessun caso |
Misure fatte per decidere, non verificate dal test: il $\tau$ vero cade nell'intervallo al 95 % nel 73 % dei casi ([limiti.md](limiti.md)); la scala robusta dei residui del
modello giusto è 0.97–1.40 volte $\sigma$ su 120 raffreddamenti finali. Le tolleranze lasciano margine: se un seed nuovo le supera è un segnale di regressione o di un caso non coperto.
## `test_domini.py` — suddivisione (A5, A6, A21)
| Test | Verifica |
|---|---|
| `test_tre_gradini` | 3 gradini → 7 domini: riposo, (riscaldamento, raffreddamento) × 3 |
| `test_riscaldamento_e_la_salita_rapida` | ogni riscaldamento ha ≤ 3 campioni |
| `test_confini_vicini_agli_istanti_dei_gradini` | l'inizio di ogni riscaldamento è entro 0.5 s dal gradino vero |
| `test_domini_contigui_e_completi` | primo `i0 = 0`, ultimo `i1 = n`, nessun buco |
| `test_senza_gradini_un_solo_riposo`, `test_un_solo_gradino` | nessun gradino → riposo; un gradino → riposo, riscaldamento, raffreddamento |
| `test_gradino_sotto_soglia_ignorato` | un salto di 10 °C (< soglia) non è un gradino |
| `test_gradino_distribuito_su_due_campioni_sotto_soglia` | salita di 38 °C divisa in 19 + 19: nessun salto supera la soglia ma la salita complessiva sì → è un gradino |
| `test_salita_complessiva_sotto_soglia_non_e_un_gradino` | 16 °C in due campioni (< 20) → nessun gradino |
| `test_domini_toml_*` | andata e ritorno; domini non contigui, vuoto, intervallo vuoto o tipo sconosciuto → `ValueError` |
## `test_fit.py` — fit e analisi (A7, A8, A9, A13, A18, A22, A23)
| Test | Verifica |
|---|---|
| `test_modelli_valutazione` | valori dei modelli; il registro contiene esattamente `costante`, `lineare`, `esp_decad` |
| `test_esp_decad_recupera_parametri` (5 seed) | $\tau$ entro il 2 %, $c$ entro 0.2, $A$ entro il 2 %, $\mathrm{RMSE} \approx \sigma$, fit valido |
| `test_parametri_fissi_restano_fissi` | $c$ e $\tau$ fissi invariati, $k = 1$, $A$ entro il 3 % |
| `test_tau_libero_non_identificabile_su_tratto_corto_e_rumoroso` (5 seed) | su 3 s con disturbi di 2 °C, $\tau$ libero è «non identificabile» (motivo del fissaggio) |
| `test_analisi_completa_sintetico` | 7 domini; finale con $\tau$ entro il 3 % e nessun parametro fisso; intermedi con `c`, `tau` fissi uguali al finale e $A$ entro il 5 % |
| `test_riscaldamento_descritto_come_salto_non_fittato` | `fit` vuoto, `stima` = `modelli`, $\Delta$ entro il 3 % dall'ampiezza vera, misura entro il 15 % |
| `test_salto_con_tratto_adiacente_indeterminato_usa_la_misura_corretta` | tratto intermedio indeterminato: `stima` = `misura corretta`, più vicina al vero della misura e entro il 3 % |
| `test_salto_senza_modelli_adiacenti_resta_la_misura` | gradino senza raffreddamento dopo: `stima` = `misura`, $\Delta$ = misura |
| `test_raffreddamento_corto_con_parametri_fissi_si_fitta` | tratto intermedio sotto `n_min_punti` ma sopra il minimo con parametri fissi: fittato, $A$ entro il 3 % |
| `test_troppo_corto_anche_per_i_parametri_fissi` | tratto di 1–2 campioni → indeterminato, il motivo cita `n_min_punti_parametri_fissi` |
| `test_un_solo_raffreddamento_resta_libero` | un solo gradino → il raffreddamento (finale) non ha parametri fissi |
| `test_dominio_troppo_corto_indeterminato` | 5 campioni → indeterminato, il motivo cita `n_min_punti` |
| `test_dati_non_finiti_non_convergono_senza_eccezioni` | `NaN` in `y` → ogni modello restituisce `convergenza` falsa, senza eccezioni (regressione: `TypeError`) |
| `test_riproducibile` | due esecuzioni identiche danno risultati identici |
| `test_sigma_non_stimabile_con_pochi_campioni` | meno di 8 campioni → σ `nan`, da 8 in su finita |
| `test_tratto_corto_e_quantizzato_non_diventa_non_identificabile` | regressione: 5 campioni quantizzati (σ ≈ 0 con la MAD) → σ `null`, adeguatezza `null`, fit valido con $A$ corretta |
| `test_adeguatezza_ignora_gli_scarti_casuali` | modello giusto con 4 scarti casuali: $\mathrm{RMSE} > 1.3\,\sigma$ ma $\hat\sigma_\text{res} \le 1.5\,\sigma$ → adeguato, $\tau$ entro il 3 % |
## `test_selezione.py` — regola di scelta ([algoritmi.md](algoritmi.md) §6)
Su fit finti: vince il modello con meno parametri entro $\Delta\mathrm{AICc}$; vince il migliore oltre la soglia; si scartano non convergenti, a limite, non identificabili; nessun valido
→ indeterminato; si preferiscono i modelli adeguati (scala robusta dei residui) anche con AICc peggiore; l'adeguatezza usa la scala robusta e non la RMSE; nessun adeguato → scelto
ma segnalato (`adeguato` = `false`); σ non stimabile → adeguatezza non valutabile (`null`); il motivo cita la parsimonia solo se ci sono più modelli equivalenti.
## `test_config_dati.py` — errori di configurazione e CSV (A1, A2)
`config.toml` reale valido; chiave mancante; lista dei modelli vuota per un tipo; file di configurazione inesistente; CSV valido (conversione ms → s); colonna
mancante; tempo non crescente; TOML non valido; `unita_tempo` e `serie_riferimento` non ammessi; CSV senza righe di dati o con valori non numerici.
## `test_report.py` — forma del report e dei grafici
Su CSV sintetici: nessun «indeterminato: indeterminato» e il motivo cita il minimo; `risultati.json` è JSON valido anche con domini indeterminati (nessun NaN, `sigma` `null`); `_senza_nan`; la scala robusta dei residui è nel report; nessun $R^2 = -0.0000$ e nessun $R^2$ per `costante`; intervallo del dominio
$t_0 \le t \le t_1$; salto «stimato dai modelli adiacenti (misurato sui campioni: …)»; motivo senza clausola di parsimonia con un solo modello; il grafico di un
dominio indeterminato ha un solo pannello (senza residui); equazione `lineare` con segno; salto «misurato sui campioni»; grafici con campioni esclusi (`scarta_dopo_salto_n` > 0) e modelli alternativi tratteggiati.
## `test_cli.py` — CLI
`fitseg domini` scrive solo `01_domini/`; `analizza --domini` (domini corretti a mano) scrive sovrapposizione e report, che rimanda alle immagini giuste e ha le formule
principali in LaTeX con i `$` bilanciati su ogni riga; CSV inesistente o senza dati → exit 4; configurazione inesistente → exit 3; `domini.toml` non contiguo o inesistente → exit 6.
## Cosa non è coperto
Dati reali; rumore e disturbi molto diversi dagli intervalli di `gen_data`; i grafici nei dettagli (si controllano a vista, [output.md](output.md)).
Prova manuale dopo una modifica: `fitseg analizza input/gen_7.csv` e controllare in `report.md` i 7 domini e $\tau \approx 12.1$ s nel finale.
+91
View File
@@ -0,0 +1,91 @@
# Uso
## 1. Installazione
Python ≥ 3.11 e [uv](https://docs.astral.sh/uv/) (`curl -LsSf https://astral.sh/uv/install.sh | sh`):
```bash
uv venv .venv
uv pip install -e ".[dev]" # numpy, scipy, matplotlib; pytest e ruff per lo sviluppo
```
Il comando si chiama `fitseg`: `.venv/bin/fitseg`, oppure dopo `source .venv/bin/activate` semplicemente `fitseg`.
## 2. Dati di ingresso
I CSV da analizzare vanno nella cartella **`input/`** (la cartella è nel repository, i file `*.csv` no). Formato: intestazione, tempo
strettamente crescente, una colonna per serie, numeri senza celle vuote (A1, A2); i nomi delle colonne e l'unità del tempo sono in
`[dati]` di [config.toml](../config.toml) ([configurazione.md](configurazione.md)).
```
t_ms,T_C,dT_C
151,22.90,0.20
301,22.63,0.03
```
I CSV di prova si generano con il repository `gen_data` (modello: `extern/gen_data/docs/SPEC.md`), che non fa parte di questo repository: lo scarica
`./scarica_gen_data.sh` in `extern/gen_data` (cartella ignorata da git; `-u` lo aggiorna, `GEN_DATA_URL` cambia l'indirizzo, ad es. per SSH). Serve anche ai test di validazione ([test.md](test.md)). A ogni seed il generatore sorteggia numero dei gradini
(1–3), costante di tempo, rumore e disturbi: ogni file ha una struttura diversa.
```bash
./scarica_gen_data.sh
mkdir -p input
(cd extern/gen_data && ../../.venv/bin/python genera.py config.toml --seed 7 -o ../../input/gen_7.csv)
```
I test **non** richiedono questi file: generano i dati da soli ([test.md](test.md)).
## 3. Comandi
```bash
fitseg domini input/gen_7.csv # passo 1: come verrebbero suddivisi i dati (tabella, domini.png, domini.toml), nessun fit
fitseg analizza input/gen_7.csv # tutti i passi: domini, equazioni, sovrapposizione, report
fitseg analizza input/gen_7.csv --domini output/gen_7/01_domini/domini.toml # con i domini corretti a mano
```
| Opzione | Dove | Significato |
|---|---|---|
| `--config PATH` | prima del sottocomando | file di configurazione (default `config.toml`) |
| `--output DIR` | prima del sottocomando | cartella madre dell'output (default `[output] cartella_output`); i file vanno in `DIR/<nome csv>/` |
| `--domini FILE` | solo `analizza` | usa questi domini invece della proposta automatica |
**Flusso consigliato**: lancia `fitseg domini`, guarda `01_domini/domini.png` (in particolare lo zoom); se la suddivisione è giusta lancia
`fitseg analizza`, altrimenti correggi `domini.toml` e rilancia con `--domini`.
### Correggere i domini a mano
Apri `output/<csv>/01_domini/domini.toml`, modifica `t0_s` / `t1_s` o il `tipo` (`riposo`, `riscaldamento`, `raffreddamento`), aggiungi o togli blocchi
`[[dominio]]`. I domini devono restare contigui. Formato e regole: [configurazione.md](configurazione.md) («domini.toml»). Con `--domini` il file viene
riscritto normalizzato e il grafico rigenerato, quindi `domini.png` mostra sempre la suddivisione effettivamente usata.
## 4. Cosa esce
Una cartella per CSV con `01_domini/`, `02_sovrapposizione/`, `03_report/report.md`: [output.md](output.md). Il report si apre con un visualizzatore Markdown
con supporto matematico (formule in LaTeX).
## 5. Codici di uscita
| Codice | Significato |
|---|---|
| 0 | ok |
| 2 | uso errato della riga di comando (argparse) |
| 3 | `config.toml` non valido: file illeggibile, TOML non valido, chiave mancante, valore non ammesso |
| 4 | CSV illeggibile o non valido (colonna mancante, valori non numerici, tempo non crescente) |
| 6 | `domini.toml` non valido (tipo non ammesso, intervallo vuoto, domini non contigui) |
Gli errori sono stampati su stderr in italiano come `Errore: …`.
## 6. Pulizia dei dati
```bash
./pulisci.sh # svuota output/ e cancella input/*.csv, chiede conferma
./pulisci.sh -n # dry-run: mostra cosa cancellerebbe, senza cancellare
./pulisci.sh --solo-output # pulisce solo output/, lascia input/ com'è
./pulisci.sh -y # tutto, senza chiedere conferma
```
Cancella: il contenuto di `output/` (cartella di `[output] cartella_output`: risultati che si rifanno rilanciando `fitseg analizza`) e i file `*.csv` in `input/`
(i dati grezzi: **non** si rigenerano da soli, si rifanno con `./scarica_gen_data.sh` e `genera.py`, §2, per questo chiede conferma salvo `-y`). Le cartelle e i loro `.gitkeep`
restano; i file di `input/` che non sono `.csv` non si toccano. Rifiuta percorsi di output assoluti o con `..`. Codici di uscita: 0 ok (o niente da pulire), 1 annullato o
percorso non sicuro, 2 opzione sconosciuta.
## 7. Sviluppo
Test e controllo dello stile: [test.md](test.md). Mappa del codice e come aggiungere un modello: [moduli.md](moduli.md).
Executable
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Pulisce i dati: svuota la cartella di output e cancella i CSV in input/ (con --solo-output lascia input/ com'è).
set -euo pipefail
cd "$(dirname "$0")"
uso() {
cat <<'E'
uso: ./pulisci.sh [-n] [-y] [--solo-output]
Cancella i dati:
- il contenuto della cartella di output ([output] cartella_output in config.toml, default "output"):
risultati generati da `fitseg`, si rifanno rilanciando l'analisi. La cartella resta;
- i file *.csv in input/: i dati grezzi, NON si rifanno da soli (vanno rigenerati con extern/gen_data
(./scarica_gen_data.sh), vedi docs/uso.md §2). La cartella e il suo .gitkeep restano.
Chiede conferma prima di cancellare, salvo -y.
opzioni:
--solo-output pulisce solo l'output, lascia input/ com'è
-n, --dry-run mostra cosa verrebbe cancellato senza cancellare
-y, --si non chiede conferma
-h, --help mostra questo aiuto
esempi:
./pulisci.sh svuota output/ e input/*.csv (con conferma)
./pulisci.sh -n mostra cosa cancellerebbe, senza cancellare
./pulisci.sh --solo-output svuota solo output/
./pulisci.sh -y svuota tutto senza chiedere
E
}
input=1 secco=0 si=0
for a in "$@"; do
case "$a" in
--solo-output) input=0 ;;
-n|--dry-run) secco=1 ;;
-y|--si) si=1 ;;
-h|--help) uso; exit 0 ;;
*) echo "pulisci.sh: opzione non riconosciuta: $a" >&2; uso >&2; exit 2 ;;
esac
done
# cartella di output da config.toml (chiave cartella_output), altrimenti "output"
out=$(sed -n 's/^cartella_output[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' config.toml 2>/dev/null | head -n1)
out=${out:-output}
# sicurezza: solo percorsi relativi dentro il repository
case "$out" in
""|/*|.|..|*..*) echo "pulisci.sh: cartella di output non sicura: '$out'" >&2; exit 1 ;;
esac
# file da cancellare: contenuto di output/ (tranne un eventuale .gitkeep) e input/*.csv (salvo --solo-output)
elenco=()
if [ -d "$out" ]; then
while IFS= read -r -d '' f; do elenco+=("$f"); done < <(find "$out" -mindepth 1 -maxdepth 1 ! -name .gitkeep -print0)
fi
csv=()
if [ "$input" -eq 1 ] && [ -d input ]; then
while IFS= read -r -d '' f; do csv+=("$f"); done < <(find input -maxdepth 1 -type f -name '*.csv' -print0)
fi
if [ ${#elenco[@]} -eq 0 ] && [ ${#csv[@]} -eq 0 ]; then
echo "niente da pulire"
exit 0
fi
echo "da cancellare:"
for f in "${elenco[@]}" "${csv[@]}"; do echo " $f"; done
if [ "$secco" -eq 1 ]; then
echo "(dry-run: nessun file cancellato)"
exit 0
fi
if [ "$si" -eq 0 ]; then
[ ${#csv[@]} -gt 0 ] && echo "ATTENZIONE: i CSV in input/ sono i dati grezzi e non si rigenerano da soli."
read -r -p "procedere? [s/N] " risposta
case "$risposta" in s|S|si|SI|sì) ;; *) echo "annullato"; exit 1 ;; esac
fi
[ ${#elenco[@]} -gt 0 ] && rm -rf -- "${elenco[@]}"
[ ${#csv[@]} -gt 0 ] && rm -f -- "${csv[@]}"
echo "pulito: ${#elenco[@]} elementi in $out/, ${#csv[@]} CSV in input/"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Clona (o aggiorna) il generatore di dati di prova gen_data in extern/gen_data, cartella ignorata da git.
set -euo pipefail
cd "$(dirname "$0")"
URL_DEFAULT="https://santantonio.sytes.net/cevolani/gen_data.git"
DEST="extern/gen_data"
uso() {
cat <<E2
uso: ./scarica_gen_data.sh [-u] [-h]
Clona $URL_DEFAULT in $DEST (cartella ignorata da git: non fa parte di questo repository).
Servono i file genera.py e config.toml: tests/test_validazione.py li cerca lì e, se mancano, salta i test.
Per usare un altro indirizzo (es. SSH) imposta GEN_DATA_URL.
opzioni:
-u, --aggiorna se la cartella esiste già, la porta all'ultima versione (git pull --ff-only)
-h, --help mostra questo aiuto
esempi:
./scarica_gen_data.sh
./scarica_gen_data.sh -u
GEN_DATA_URL=ssh://git@santantonio.sytes.net:2223/cevolani/gen_data.git ./scarica_gen_data.sh
E2
}
aggiorna=0
for a in "$@"; do
case "$a" in
-u|--aggiorna) aggiorna=1 ;;
-h|--help) uso; exit 0 ;;
*) echo "scarica_gen_data.sh: opzione non riconosciuta: $a" >&2; uso >&2; exit 2 ;;
esac
done
url=${GEN_DATA_URL:-$URL_DEFAULT}
if [ -d "$DEST/.git" ]; then
if [ "$aggiorna" -eq 1 ]; then
git -C "$DEST" pull --ff-only
else
echo "$DEST esiste già (usa -u per aggiornarlo)"
fi
elif [ -e "$DEST" ]; then
echo "scarica_gen_data.sh: $DEST esiste e non è un repository git" >&2
exit 1
else
mkdir -p extern
git clone "$url" "$DEST"
fi
[ -f "$DEST/genera.py" ] || { echo "scarica_gen_data.sh: $DEST/genera.py non trovato" >&2; exit 1; }
echo "pronto: $DEST ($(git -C "$DEST" rev-parse --short HEAD))"
+72 -16
View File
@@ -1,10 +1,10 @@
"""Analisi: fit dei modelli per ogni dominio e serie e scelta con la regola di SPEC §4.2; il riscaldamento è descritto come salto."""
"""Analisi: fit dei modelli per ogni dominio e serie, scelta (docs/algoritmi.md §6) e stima dei salti del riscaldamento (§7)."""
import math
from .domini import sigma_rumore
from .fit import adatta
from .modelli import MODELLI
from .modelli import MODELLI, valuta
def valido(fit):
@@ -12,45 +12,100 @@ def valido(fit):
and math.isfinite(fit["aicc"]))
def fit_scelto(vs):
"""Il fit scelto di una voce di serie, o None (indeterminato, riscaldamento)."""
return next((x for x in vs["fit"] if x["modello"] == vs["scelta"]), None)
def scegli(fit_list, sigma, cfg):
"""(fit scelto, motivo, adeguato); (None, motivo, False) se nessun fit è valido."""
f = cfg["fit"]
validi = [x for x in fit_list if valido(x)]
if not validi:
return None, "indeterminato: nessun modello convergente, identificabile e lontano dai limiti", False
adeguati = [x for x in validi if x["rmse"] <= f["rmse_vs_rumore_max"] * sigma]
return None, "nessun modello convergente, identificabile e lontano dai limiti", False
noto = sigma is not None and math.isfinite(sigma) # con meno di 4 campioni σ non è stimabile
# adeguato = scala ROBUSTA dei residui vicina al rumore: come la perdita del fit, ignora gli scarti casuali (la RMSE no)
adeguati = [x for x in validi if noto and x["scala_residui"] <= f["scala_residui_vs_rumore_max"] * sigma]
pool = adeguati or validi
miglior = min(x["aicc"] for x in pool)
scelto = min((x for x in pool if x["aicc"] <= miglior + f["soglia_delta_aicc"]),
key=lambda x: (x["n_parametri"], x["aicc"]))
equivalenti = [x for x in pool if x["aicc"] <= miglior + f["soglia_delta_aicc"]]
scelto = min(equivalenti, key=lambda x: (x["n_parametri"], x["aicc"]))
motivo = (f"{len(validi)}/{len(fit_list)} modelli validi; "
+ ("modello adeguato" if adeguati else "nessun modello adeguato")
+ "; a parità di AICc, meno parametri")
return scelto, motivo, bool(adeguati)
+ ("modello adeguato" if adeguati else "nessun modello adeguato" if noto else "adeguatezza non valutabile (σ non stimabile)")
+ ("; tra i modelli equivalenti per AICc, quello con meno parametri" if len(equivalenti) > 1 else ""))
return scelto, motivo, bool(adeguati) if noto else None
def _gradino(t, y, d):
"""Descrizione del riscaldamento: salto dal campione prima della salita all'ultimo campione di salita."""
"""Descrizione del riscaldamento: salto misurato dal campione prima della salita all'ultimo campione di salita.
`_affina_salti` lo sostituisce con la stima dai modelli adiacenti quando possibile."""
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])}
g = {"n": int(i1 - i0), "da": prima, "a": dopo, "delta": dopo - prima, "delta_misurato": dopo - prima,
"stima": "misura", "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"]:
voce["motivo"] = f"indeterminato: {len(yy)} campioni < n_min_punti"
voce = {"sigma": float(sigma) if math.isfinite(sigma) else None, "fit": [], "scelta": None}
chiave = "n_min_punti_parametri_fissi" if fissi else "n_min_punti" # con c e τ fissi basta stimare A: bastano meno campioni
minimo = cfg["domini"][chiave]
if len(yy) < minimo:
voce["motivo"] = f"{len(yy)} campioni usati nel fit, meno del minimo {minimo} (`{chiave}`)"
return voce
for m in cfg["fit"]["modelli_per_tipo"][tipo]:
if len(MODELLI[m][1]) <= len(yy) - 2:
nomi = MODELLI[m][1]
if len(nomi) - len([p for p in (fissi or {}) if p in nomi]) <= len(yy) - 2: # parametri liberi ≤ n − 2
voce["fit"].append(adatta(ty, yy, m, sigma, cfg, fissi))
scelto, voce["motivo"], voce["adeguato"] = scegli(voce["fit"], sigma, cfg)
scelto, voce["motivo"], voce["adeguato"] = scegli(voce["fit"], voce["sigma"], cfg)
voce["scelta"] = scelto["modello"] if scelto else None
return voce
def _valore(dominio, nome, t):
"""Valore del modello scelto di un dominio (per la serie `nome`) all'istante t; None se non ha un modello."""
fit = fit_scelto(dominio["serie"][nome])
if fit is None:
return None
return float(valuta(fit["modello"], t - dominio["t0_s"], list(fit["parametri"].values())))
def _finale(risultati):
"""Il risultato del raffreddamento finale (ultimo dominio di raffreddamento), o None."""
return next((r for r in reversed(risultati) if r["tipo"] == "raffreddamento"), None)
def _affina_salti(t, serie, risultati, domini):
"""Stima il salto di ogni riscaldamento all'istante del gradino (a metà tra l'ultimo campione prima della salita e il
primo in salita), perché la misura sui campioni lo sottostima (l'ultimo campione di salita è già in parte decaduto):
1. "modelli": differenza tra i modelli scelti per i due domini adiacenti, valutati in quell'istante;
2. "misura corretta": se manca un modello adiacente (tratto indeterminato) ma c'è il raffreddamento finale, la misura
con i due campioni riportati all'istante del gradino usando $c$ e τ del finale (stessa legge di decadimento);
3. "misura": altrimenti la misura sui campioni.
Una stima che si discosta più del 50 % dalla misura è scartata (guardia contro fit anomali)."""
finale = _finale(risultati)
for i, (r, d) in enumerate(zip(risultati, domini, strict=True)):
if r["tipo"] != "riscaldamento" or i == 0 or i + 1 >= len(risultati):
continue
ts = float(0.5 * (t[d["i0"] - 1] + t[d["i0"]]))
for nome, vs in r["serie"].items():
g = vs["gradino"]
prima, dopo = _valore(risultati[i - 1], nome, ts), _valore(risultati[i + 1], nome, ts)
stima = "modelli"
if prima is None or dopo is None:
fit = fit_scelto(finale["serie"][nome]) if finale else None
if fit is None or "tau" not in fit["parametri"]:
continue
c, tau, y = fit["parametri"]["c"], fit["parametri"]["tau"], serie[nome]
prima = c + (y[d["i0"] - 1] - c) * math.exp(-(ts - t[d["i0"] - 1]) / tau)
dopo = c + (y[d["i1"] - 1] - c) * math.exp((t[d["i1"] - 1] - ts) / tau)
stima = "misura corretta"
delta = float(dopo - prima)
if math.isfinite(delta) and abs(delta - g["delta_misurato"]) <= 0.5 * abs(g["delta_misurato"]):
g.update(da=float(prima), a=float(dopo), delta=delta, stima=stima, istante_s=ts)
def analizza_domini(t, serie, domini, cfg):
"""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`
@@ -76,4 +131,5 @@ def analizza_domini(t, serie, domini, cfg):
par = next(x for x in vs["fit"] if x["modello"] == vs["scelta"])["parametri"]
fissi[nome] = {k: par[k] for k in cfg["fit"]["raffreddamento_intermedio_fissa_da_finale"] if k in par}
risultati[idx] = voce
_affina_salti(t, serie, risultati, domini)
return risultati
+2 -2
View File
@@ -5,10 +5,10 @@ import tomllib
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"],
"distanza_min_salti_campioni", "n_min_punti", "n_min_punti_parametri_fissi", "scarta_dopo_salto_n"],
"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"],
"soglia_delta_aicc", "scala_residui_vs_rumore_max"],
"output": ["cartella_output"],
}
+1 -1
View File
@@ -26,6 +26,6 @@ def leggi_csv(percorso, cfg):
if d["unita_tempo"] == "ms":
t = t / 1000.0
if np.any(np.diff(t) <= 0):
raise ErroreDati("il tempo non è strettamente crescente (ipotesi H1)")
raise ErroreDati("il tempo non è strettamente crescente (assunzione A1, docs/assunzioni.md)")
serie = {s: righe[:, intestazione.index(s)] for s in d["serie"]}
return t, serie
+15 -18
View File
@@ -8,39 +8,36 @@ def sigma_robusto(d):
return 1.4826 * np.median(np.abs(d - np.median(d)))
N_MIN_SIGMA = 8 # campioni minimi per stimare σ: con meno, la MAD di pochi d²y può annullarsi (valori quantizzati)
def sigma_rumore(y):
"""σ del rumore bianco dalla derivata seconda discreta: 1.4826·MAD(d²y)/√6."""
if len(y) < 4:
"""σ del rumore bianco dalla derivata seconda discreta: 1.4826·MAD(d²y)/√6; nan con meno di N_MIN_SIGMA campioni."""
if len(y) < N_MIN_SIGMA:
return float("nan")
return sigma_robusto(np.diff(y, 2)) / np.sqrt(6)
def trova_gradini(y, cfg):
"""(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)."""
"""(i0, i1) dei campioni di salita di ogni gradino di riscaldamento (i1 escluso).
Un campione è «in salita» se il salto verso il successivo supera `soglia_bordo_sigma` · σ_d (σ_d = σ robusta dei salti).
I campioni in salita separati da meno di `distanza_min_salti_campioni` indici formano una salita; è un gradino se la salita
complessiva supera `soglia_gradino_c` (così un gradino distribuito su più campioni, ognuno sotto soglia, è rilevato)."""
c = cfg["domini"]
d = np.diff(y)
forti = np.flatnonzero(d > c["soglia_gradino_c"])
if len(forti) == 0:
in_salita = np.flatnonzero(d > c["soglia_bordo_sigma"] * sigma_robusto(d))
if len(in_salita) == 0:
return []
soglia_bordo = c["soglia_bordo_sigma"] * sigma_robusto(d)
gruppi, corrente = [], [forti[0]]
for i in forti[1:]:
gruppi, corrente = [], [in_salita[0]]
for i in in_salita[1:]:
if i - corrente[-1] < c["distanza_min_salti_campioni"]:
corrente.append(i)
else:
gruppi.append(corrente)
corrente = [i]
gruppi.append(corrente)
gradini = []
for g in gruppi:
inizio, fine = int(g[0]), int(g[-1])
while inizio > 0 and d[inizio - 1] > soglia_bordo:
inizio -= 1
while fine + 1 < len(d) and d[fine + 1] > soglia_bordo:
fine += 1
gradini.append((inizio + 1, min(fine + 2, len(y))))
return gradini
return [(int(g[0]) + 1, min(int(g[-1]) + 2, len(y))) for g in gruppi if y[g[-1] + 1] - y[g[0]] > c["soglia_gradino_c"]]
def proponi(t, serie, cfg):
+3 -1
View File
@@ -32,6 +32,8 @@ def adatta(t, y, nome, sigma, cfg, fissi=None):
f = cfg["fit"]
funzione, nomi, equazione, i_tau = MODELLI[nome]
fissi = {k: v for k, v in (fissi or {}).items() if k in nomi}
if not (np.all(np.isfinite(t)) and np.all(np.isfinite(y))):
return {"modello": nome, "convergenza": False, "n_parametri": len(nomi) - len(fissi)}
n, k_tot = len(t), len(nomi)
libero = [i for i, nm in enumerate(nomi) if nm not in fissi]
k = len(libero)
@@ -76,7 +78,7 @@ def adatta(t, y, nome, sigma, cfg, fissi=None):
"modello": nome, "equazione": equazione, "n_parametri": k, "n": n, "fissi": sorted(fissi),
"parametri": dict(zip(nomi, map(float, p), strict=True)),
"ic95": dict(zip(nomi, map(float, 1.96 * errori), strict=True)),
"rss": rss, "rmse": float(np.sqrt(rss / n)), "r2": 1 - rss / tss if tss > 0 else float("nan"),
"rss": rss, "rmse": float(np.sqrt(rss / n)), "scala_residui": float(1.4826 * np.median(np.abs(res - np.median(res)))), "r2": 1 - rss / tss if tss > 0 else float("nan"),
"aicc": float(aicc), "bic": float(n * np.log(max(rss, 1e-300) / n) + k * np.log(n)),
"acf1": acf1, "condizione": cond, "parametri_a_limite": a_limite,
"non_identificabile": bool(non_ident), "convergenza": bool(r.success),
+1 -1
View File
@@ -1,4 +1,4 @@
"""Registro dei modelli (t' = t − inizio del dominio). Nuovo modello = nuova voce qui, in docs/SPEC.md e in config.toml."""
"""Registro dei modelli (t' = t − inizio del dominio). Nuovo modello = nuova voce qui, in docs/algoritmi.md (§5.1) e in config.toml."""
import numpy as np
+53 -28
View File
@@ -6,7 +6,7 @@ from datetime import date
import matplotlib
import numpy as np
from .analisi import valido
from .analisi import fit_scelto, valido
from .modelli import valuta
matplotlib.use("Agg")
@@ -38,10 +38,6 @@ def colori_domini(tipi):
return out
def _scelto(vs):
return next((x for x in vs["fit"] if x["modello"] == vs["scelta"]), None)
def _num(v, cifre=4):
"""Numero in LaTeX con `cifre` cifre significative (esponente come \\times 10^{n})."""
m, _, e = f"{v:.{cifre}g}".partition("e")
@@ -137,7 +133,7 @@ def grafico_domini(percorso, t, serie, domini):
def _curva(r, vs, tt):
fit = _scelto(vs)
fit = fit_scelto(vs)
return None if fit is None else valuta(fit["modello"], tt - r["t0_s"], list(fit["parametri"].values()))
@@ -207,13 +203,18 @@ def sovrapposizione_domini(cartella, t, serie, risultati, domini, cfg):
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)
y, scelto = serie[nome], fit_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)")
if scelto:
fig, (a, b) = plt.subplots(2, 1, figsize=(9, 6), sharex=True, height_ratios=[3, 1])
else: # indeterminato: nessuna curva e nessun residuo da mostrare
fig, a = plt.subplots(figsize=(9, 4.2))
b = None
if i0 > d["i0"]: # campioni esclusi dal fit (scarta_dopo_salto_n > 0)
a.plot(t[tutti][: i0 - d["i0"]], y[tutti][: i0 - d["i0"]], ".", ms=4, color="#aaa", label="dati (esclusi dal fit)")
a.plot(t[usati], y[usati], ".", ms=5, color="#222", label="dati grezzi")
for x in vs["fit"]:
if x is not scelto and valido(x):
@@ -224,10 +225,13 @@ def sovrapposizione_domini(cartella, t, serie, risultati, domini, cfg):
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)
if b is not None:
b.axhline(0, color="k", lw=0.5)
b.set_ylabel("residuo")
b.set_xlabel("t [s]")
else:
a.set_xlabel("t [s]")
a.set_ylabel(nome)
b.set_ylabel("residuo")
b.set_xlabel("t [s]")
a.set_title(f"Dominio {r['k']} — {r['tipo']} — {nome}" + ("" if scelto else " — indeterminato"), fontsize=10)
a.legend(fontsize=8)
a.grid(alpha=0.3)
@@ -236,17 +240,32 @@ def sovrapposizione_domini(cartella, t, serie, risultati, domini, cfg):
plt.close(fig)
def _senza_nan(x):
"""Copia di x con NaN e infiniti sostituiti da None: il JSON standard non li ammette."""
if isinstance(x, dict):
return {k: _senza_nan(v) for k, v in x.items()}
if isinstance(x, list):
return [_senza_nan(v) for v in x]
return None if isinstance(x, float) and not np.isfinite(x) else x
def _salto(vs):
"""Descrizione LaTeX del riscaldamento (non fittato): salto, estremi, campioni e durata."""
"""Descrizione LaTeX del riscaldamento (non fittato): salto stimato (o misurato), campioni e durata."""
g = vs["gradino"]
return (rf"$\Delta = {g['delta']:+.2f}$ (da ${g['da']:.2f}$ a ${g['a']:.2f}$) in {g['n']} campioni, "
f"{g['durata_s'] * 1000:.0f} ms, non fittato")
coda = f"{g['n']} campioni, {g['durata_s'] * 1000:.0f} ms, non fittato"
misurato = rf"misurato sui campioni: ${g['delta_misurato']:+.2f}$"
if g["stima"] == "modelli":
return rf"$\Delta = {g['delta']:+.2f}$ stimato dai modelli adiacenti ({misurato}), " + coda
if g["stima"] == "misura corretta":
return (rf"$\Delta = {g['delta']:+.2f}$ misura riportata all'istante del gradino con $c$ e $\tau$ del raffreddamento finale "
rf"({misurato}; modelli adiacenti non disponibili), " + coda)
return rf"$\Delta = {g['delta']:+.2f}$ misurato sui campioni (modelli adiacenti non disponibili), " + coda
def _riga_equazione(r, nome, vs):
if "gradino" in vs:
return f"| {r['k']} | {r['tipo']} | {nome} | salto | {_salto(vs)} |"
fit = _scelto(vs)
fit = 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']}") + " |")
@@ -254,15 +273,19 @@ def _riga_equazione(r, nome, vs):
def scrivi_report(cartella, risultati, nome_csv, serie_nomi, cfg):
"""Passo 3: report.md con le grandezze matematiche in LaTeX (immagini dai passi 1 e 2, percorsi relativi) e risultati.json."""
f = cfg["fit"]
soglia = rf"$\mathrm{{RMSE}} \le {f['rmse_vs_rumore_max']:g}\,\sigma$"
non_adeguato = rf" · **modello non adeguato** ($\mathrm{{RMSE}} > {f['rmse_vs_rumore_max']:g}\,\sigma$)"
lam = f["scala_residui_vs_rumore_max"]
soglia = rf"$\hat\sigma_\text{{res}} \le {lam:g}\,\sigma$"
non_adeguato = rf" · **modello non adeguato** ($\hat\sigma_\text{{res}} > {lam:g}\,\sigma$)"
L = [f"# Report fitseg — {nome_csv}\n",
(f"Data: {date.today().isoformat()} · Serie: {', '.join(serie_nomi)} · "
r"tempo del dominio $t' = t - t_0$ · intervalli di confidenza al 95 %" + "\n"),
r"tempo del dominio $t' = t - t_0$ · intervalli di confidenza al 95 % · "
r"$\hat\sigma_\text{res} = 1.4826\,\mathrm{MAD}(r)$ è la scala robusta dei residui $r$" + "\n"),
"## 1. Suddivisione in domini\n", f"![Suddivisione in domini](../{PASSI['domini']}/domini.png)\n",
"| # | tipo | $t_0$ [s] | $t_1$ [s] | campioni |", "|---|---|---|---|---|"]
L += [f"| {r['k']} | {r['tipo']} | {r['t0_s']:.2f} | {r['t1_s']:.2f} | {r['n']} |" for r in risultati]
L += ["", ("Il riscaldamento è la salita rapida (pochi campioni, non fittata); ogni raffreddamento ha un colore diverso. "
L += ["", ("Il riscaldamento è la salita rapida (pochi campioni, non fittata): il salto $\\Delta$ è stimato dai modelli dei domini adiacenti "
"(o, se mancano, dalla misura riportata all'istante del gradino). "
"Ogni raffreddamento ha un colore diverso. "
f"Domini modificabili in `../{PASSI['domini']}/domini.toml`.\n"), "## 2. Equazioni trovate\n",
(f"Criterio di scelta: tra i modelli validi si preferiscono quelli adeguati ({soglia}); tra questi, a meno di "
rf"$\Delta\mathrm{{AICc}} \le {f['soglia_delta_aicc']:g}$ dal migliore vince quello con meno parametri." + "\n"),
@@ -272,26 +295,28 @@ def scrivi_report(cartella, risultati, nome_csv, serie_nomi, cfg):
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} \\le t < {r['t1_s']:.2f}$ s)\n")
L.append(f"### Dominio {r['k']} — {r['tipo']} (${r['t0_s']:.2f} \\le t \\le {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"Salita rapida: {_salto(vs)}.\n")
continue
fit = _scelto(vs)
fit = fit_scelto(vs)
if not fit:
L.append(f"**Indeterminato** — {vs['motivo']}\n")
continue
L.append(f"**Scelto: `{fit['modello']}`**\n\n$$ {_eq_numerica(fit)} $$\n\n"
f"${fit['equazione']}$ con {_params(fit)} \n"
rf"$\sigma_\text{{rumore}} = {_num(vs['sigma'])}$ · $\mathrm{{RMSE}} = {_num(fit['rmse'])}$ · "
rf"$R^2 = {fit['r2']:.4f}$ · $\rho_1 = {fit['acf1']:.2f}$"
f"{'' if vs['adeguato'] else non_adeguato} \n"
+ (rf"$\sigma_\text{{rumore}} = {_num(vs['sigma'])}$ · " if vs["sigma"] is not None else "")
+ rf"$\hat\sigma_\text{{res}} = {_num(fit['scala_residui'])}$ · $\mathrm{{RMSE}} = {_num(fit['rmse'])}$ · "
+ (rf"$R^2 = {round(fit['r2'], 4) + 0.0:.4f}$ · " if fit["modello"] != "costante" else "")
+ rf"$\rho_1 = {fit['acf1']:.2f}$"
+ (non_adeguato if vs["adeguato"] is False else "") + " \n"
f"Motivo: {vs['motivo']}\n")
L += [r"| modello | $k$ | RMSE | AICc | BIC | $\rho_1$ | valido |", "|---|---|---|---|---|---|---|"]
L += [f"| `{x['modello']}` | {x['n_parametri']} | {x['rmse']:.3g} | {x['aicc']:.1f} | {x['bic']:.1f} | "
L += [r"| modello | $k$ | RMSE | $\hat\sigma_\text{res}$ | AICc | BIC | $\rho_1$ | valido |", "|---|---|---|---|---|---|---|---|"]
L += [f"| `{x['modello']}` | {x['n_parametri']} | {x['rmse']:.3g} | {x['scala_residui']:.3g} | {x['aicc']:.1f} | {x['bic']:.1f} | "
f"{x['acf1']:.2f} | {'sì' if valido(x) else 'no'} |"
for x in sorted((x for x in vs["fit"] if "aicc" in x), key=lambda x: x["aicc"])]
L.append("")
(cartella / "report.md").write_text("\n".join(L), encoding="utf-8")
(cartella / "risultati.json").write_text(json.dumps(risultati, indent=1, ensure_ascii=False), encoding="utf-8")
(cartella / "risultati.json").write_text(json.dumps(_senza_nan(risultati), indent=1, ensure_ascii=False, allow_nan=False), encoding="utf-8")
+8 -11
View File
@@ -1,4 +1,4 @@
"""Fixture comuni: configurazione, segnale sintetico (verità nota) e CSV generati da gen_data (se presenti)."""
"""Fixture comuni: configurazione, segnale sintetico (verità nota) e scrittura di CSV di prova."""
from pathlib import Path
@@ -8,13 +8,19 @@ import pytest
from fitseg import config
RADICE = Path(__file__).parent.parent
SEEDS = [1, 2, 3]
# verità del segnale sintetico
TAU, T_AMB = 16.0, 23.0
GRADINI = [(15.0, 80.0), (18.0, 70.0), (22.0, 60.0)] # (istante [s], ampiezza [°C])
def scrivi_csv(percorso, t, serie):
"""CSV nel formato di gen_data (t_ms intero, valori a due decimali)."""
with open(percorso, "w") as f:
f.write("t_ms," + ",".join(serie) + "\n")
f.writelines(f"{round(ti * 1000)}," + ",".join(f"{serie[n][i]:.2f}" for n in serie) + "\n" for i, ti in enumerate(t))
@pytest.fixture(scope="session")
def cfg():
return config.leggi(RADICE / "config.toml")
@@ -32,12 +38,3 @@ def segnale(rumore=0.1, seed=0, gradini=GRADINI, durata=120.0, dt=0.15):
@pytest.fixture(scope="session")
def sintetico():
return segnale()
@pytest.fixture(params=SEEDS)
def csv_gen(request):
"""CSV prodotto da gen_data per il seed; il test è saltato se il file non c'è (input/*.csv non è in git)."""
percorso = RADICE / f"input/gen_{request.param}.csv"
if not percorso.exists():
pytest.skip(f"{percorso} assente: rigenera con gen_data (vedi README)")
return percorso
@@ -1,47 +1,22 @@
"""Integrazione sui CSV di gen_data (input/gen_{1,2,3}.csv, ignorati da git: i test sono saltati se mancano) e sulla CLI."""
"""CLI su un CSV sintetico: file prodotti, correzione dei domini a mano, formule del report, codici di uscita."""
import pytest
from conftest import RADICE
from conftest import RADICE, scrivi_csv, segnale
from fitseg import cli, dati, domini
from fitseg.analisi import analizza_domini
from fitseg import cli
def analizza(csv, cfg):
t, serie = dati.leggi_csv(csv, cfg)
ds = domini.proponi(t, serie, cfg)
return ds, analizza_domini(t, serie, ds, cfg)
def test_domini_su_csv_gen(csv_gen, cfg):
ds, _ = analizza(csv_gen, cfg)
assert [d["tipo"] for d in ds] == ["riposo", "riscaldamento", "raffreddamento", "riscaldamento", "raffreddamento",
"riscaldamento", "raffreddamento"]
def test_raffreddamento_tau_nominale(csv_gen, cfg):
_, ris = analizza(csv_gen, cfg) # τ nominale 1/k = 15.9 s, k perturbato del 2 % per seed
for vs in ris[-1]["serie"].values():
assert vs["scelta"] == "esp_decad"
fit = next(x for x in vs["fit"] if x["modello"] == "esp_decad")
assert fit["parametri"]["tau"] == pytest.approx(15.9, rel=0.1)
def test_raffreddamenti_intermedi_ereditano_costanti(csv_gen, cfg):
_, ris = analizza(csv_gen, cfg)
for nome in ris[0]["serie"]:
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 csv_sintetico(tmp_path):
t, serie = segnale()
csv = tmp_path / "sint.csv"
scrivi_csv(csv, t, serie)
return csv
def test_cli_domini_e_analizza(tmp_path, capsys):
csv = RADICE / "input/gen_1.csv"
if not csv.exists():
pytest.skip(f"{csv} assente")
base = ["--config", str(RADICE / "config.toml"), "--output", str(tmp_path)]
out = tmp_path / "gen_1"
csv = csv_sintetico(tmp_path)
base = ["--config", str(RADICE / "config.toml"), "--output", str(tmp_path / "out")]
out = tmp_path / "out/sint"
cli.main([*base, "domini", str(csv)])
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
@@ -68,3 +43,24 @@ def test_cli_errori(tmp_path):
with pytest.raises(SystemExit) as e:
cli.main(["--config", "/non/esiste.toml", "domini", "x.csv"])
assert e.value.code == 3
def test_cli_domini_non_validi_exit_6(tmp_path):
csv = csv_sintetico(tmp_path)
toml = tmp_path / "d.toml"
toml.write_text('[[dominio]]\ntipo="riposo"\nt0_s=0\nt1_s=10\n[[dominio]]\ntipo="raffreddamento"\nt0_s=20\nt1_s=30\n')
with pytest.raises(SystemExit) as e:
cli.main(["--config", str(RADICE / "config.toml"), "--output", str(tmp_path / "out"), "analizza", str(csv), "--domini", str(toml)])
assert e.value.code == 6
with pytest.raises(SystemExit) as e:
cli.main(["--config", str(RADICE / "config.toml"), "--output", str(tmp_path / "out"), "analizza", str(csv),
"--domini", str(tmp_path / "manca.toml")])
assert e.value.code == 6
def test_cli_csv_non_valido_exit_4(tmp_path):
csv = tmp_path / "vuoto.csv"
csv.write_text("t_ms,T_C,dT_C\n")
with pytest.raises(SystemExit) as e:
cli.main(["--config", str(RADICE / "config.toml"), "--output", str(tmp_path / "out"), "domini", str(csv)])
assert e.value.code == 4
+30
View File
@@ -44,3 +44,33 @@ def test_csv_tempo_non_crescente(cfg, tmp_path):
(tmp_path / "d.csv").write_text("t_ms,T_C,dT_C\n200,20,0\n100,20,0\n")
with pytest.raises(dati.ErroreDati, match="crescente"):
dati.leggi_csv(tmp_path / "d.csv", cfg)
def test_toml_non_valido(tmp_path):
(tmp_path / "c.toml").write_text("[dati\nrotto")
with pytest.raises(config.ErroreConfig, match="TOML non valido"):
config.leggi(tmp_path / "c.toml")
@pytest.mark.parametrize(("vecchio", "nuovo", "messaggio"), [
('unita_tempo = "ms"', 'unita_tempo = "min"', "unita_tempo"),
('serie_riferimento = "T_C"', 'serie_riferimento = "X"', "serie_riferimento"),
])
def test_valori_non_ammessi(tmp_path, vecchio, nuovo, messaggio):
testo = (RADICE / "config.toml").read_text()
assert vecchio in testo
(tmp_path / "c.toml").write_text(testo.replace(vecchio, nuovo, 1))
with pytest.raises(config.ErroreConfig, match=messaggio):
config.leggi(tmp_path / "c.toml")
def test_csv_senza_righe_di_dati(cfg, tmp_path):
(tmp_path / "d.csv").write_text("t_ms,T_C,dT_C\n")
with pytest.raises(dati.ErroreDati, match="vuoti o non numerici"):
dati.leggi_csv(tmp_path / "d.csv", cfg)
def test_csv_con_valori_non_numerici(cfg, tmp_path):
(tmp_path / "d.csv").write_text("t_ms,T_C,dT_C\n100,abc,0.1\n250,20.1,0.2\n")
with pytest.raises(dati.ErroreDati, match="vuoti o non numerici"):
dati.leggi_csv(tmp_path / "d.csv", cfg)
+35
View File
@@ -74,3 +74,38 @@ def test_domini_toml_tipo_non_valido(sintetico, tmp_path):
(tmp_path / "d.toml").write_text('[[dominio]]\ntipo="boh"\nt0_s=0\nt1_s=10\n')
with pytest.raises(ValueError, match="non valido"):
domini.leggi_toml(tmp_path / "d.toml", t)
def test_gradino_distribuito_su_due_campioni_sotto_soglia(cfg):
# salita di 38 °C divisa in 19 + 19: nessun salto singolo supera `soglia_gradino_c` (20), la salita complessiva sì
t, serie = segnale(gradini=[])
T = serie["T_C"].copy()
i = 100
T[i + 1] += 19.0
T[i + 2:] += 38.0
ds = domini.proponi(t, {"T_C": T, "dT_C": T - 23.0}, cfg)
assert [d["tipo"] for d in ds] == ["riposo", "riscaldamento", "raffreddamento"]
assert ds[1]["i0"] == i + 1 and ds[1]["i1"] == i + 3
def test_salita_complessiva_sotto_soglia_non_e_un_gradino(cfg):
t, serie = segnale(gradini=[])
T = serie["T_C"].copy()
T[101] += 8.0
T[102:] += 16.0 # 16 °C in due campioni: sotto la soglia di 20
assert tipi(t, {"T_C": T, "dT_C": T - 23.0}, cfg) == ["riposo"]
def test_domini_toml_vuoto_rifiutato(sintetico, tmp_path):
t, _ = sintetico
(tmp_path / "d.toml").write_text("# nessun dominio\n")
with pytest.raises(ValueError, match="senza"):
domini.leggi_toml(tmp_path / "d.toml", t)
def test_domini_toml_intervallo_vuoto_rifiutato(sintetico, tmp_path):
t, _ = sintetico
(tmp_path / "d.toml").write_text('[[dominio]]\ntipo="riposo"\nt0_s=0\nt1_s=0\n'
'[[dominio]]\ntipo="raffreddamento"\nt0_s=0\nt1_s=30\n')
with pytest.raises(ValueError, match="vuoto"):
domini.leggi_toml(tmp_path / "d.toml", t)
+90 -4
View File
@@ -5,7 +5,7 @@ import pytest
from conftest import GRADINI, TAU, segnale
from fitseg import domini
from fitseg.analisi import analizza_domini
from fitseg.analisi import analizza_domini, fit_scelto
from fitseg.fit import adatta
from fitseg.modelli import MODELLI, valuta
@@ -79,8 +79,10 @@ def test_riscaldamento_descritto_come_salto_non_fittato(sintetico, 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
g = vs["gradino"]
assert g["stima"] == "modelli" and g["n"] <= 3
assert g["delta"] == pytest.approx(ampiezza, rel=0.03) # stimato dai modelli adiacenti all'istante del gradino
assert g["delta_misurato"] == pytest.approx(ampiezza, rel=0.15) # misura sui campioni (stima per difetto)
def test_un_solo_raffreddamento_resta_libero(cfg):
@@ -94,7 +96,7 @@ def test_dominio_troppo_corto_indeterminato(sintetico, cfg):
t, serie = sintetico
ds = [{"tipo": "riposo", "i0": 0, "i1": 5}]
vs = analizza_domini(t, serie, ds, cfg)[0]["serie"]["T_C"]
assert vs["scelta"] is None and "indeterminato" in vs["motivo"]
assert vs["scelta"] is None and "n_min_punti" in vs["motivo"]
def test_riproducibile(sintetico, cfg):
@@ -103,3 +105,87 @@ def test_riproducibile(sintetico, cfg):
a = analizza_domini(t, serie, ds, cfg)
b = analizza_domini(t, serie, ds, cfg)
assert a == b
def test_salto_senza_modelli_adiacenti_resta_la_misura(sintetico, cfg):
t, serie = sintetico
ds = domini.proponi(t, serie, cfg)[:2] # riposo + riscaldamento: nessun raffreddamento dopo il gradino
g = analizza_domini(t, serie, ds, cfg)[1]["serie"]["dT_C"]["gradino"]
assert g["stima"] == "misura" and g["delta"] == g["delta_misurato"]
def test_raffreddamento_corto_con_parametri_fissi_si_fitta(cfg):
# due gradini a 0.9 s: il tratto in mezzo ha pochi campioni, ma con c e τ fissi basta stimare A
t, serie = segnale(gradini=[(20.0, 80.0), (20.9, 60.0)], durata=80.0)
ds = domini.proponi(t, serie, cfg)
ris = analizza_domini(t, serie, ds, cfg)
intermedio = ris[2]
assert intermedio["tipo"] == "raffreddamento" and intermedio["n_fit"] < cfg["domini"]["n_min_punti"]
vs = intermedio["serie"]["dT_C"]
assert vs["scelta"] == "esp_decad" and fit_scelto(vs)["fissi"] == ["c", "tau"]
atteso = 80.0 * np.exp(-(intermedio["t0_s"] - 20.0) / TAU)
assert fit_scelto(vs)["parametri"]["A"] == pytest.approx(atteso, rel=0.03)
def test_troppo_corto_anche_per_i_parametri_fissi(cfg):
t, serie = segnale(gradini=[(20.0, 80.0), (20.5, 60.0)], durata=80.0) # tratto intermedio di 1-2 campioni
ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg)
vs = ris[2]["serie"]["dT_C"]
assert vs["scelta"] is None and "n_min_punti_parametri_fissi" in vs["motivo"]
def test_sigma_non_stimabile_con_pochi_campioni():
from fitseg.domini import N_MIN_SIGMA, sigma_rumore
y = np.random.default_rng(0).standard_normal(N_MIN_SIGMA)
assert np.isnan(sigma_rumore(y[:-1])) and np.isfinite(sigma_rumore(y))
def test_tratto_corto_e_quantizzato_non_diventa_non_identificabile(cfg):
# 5 campioni quantizzati a 0.01: la MAD delle derivate seconde si annullerebbe (σ ≈ 0) e la perdita robusta degenererebbe
from fitseg.analisi import _serie
ty = np.arange(5) * 0.15
yy = np.round(115.67 * np.exp(-ty / 15.8), 2)
vs = _serie(ty, yy, sigma_rumore_di(yy), "raffreddamento", {"c": 0.0, "tau": 15.8}, cfg)
assert vs["sigma"] is None and vs["adeguato"] is None
assert vs["scelta"] == "esp_decad" and fit_scelto(vs)["parametri"]["A"] == pytest.approx(115.67, rel=0.01)
def sigma_rumore_di(y):
from fitseg.domini import sigma_rumore
return sigma_rumore(y)
def test_adeguatezza_ignora_gli_scarti_casuali(cfg):
# modello giusto con pochi scarti casuali, solo a caldo come in gen_data (−3 °C che rientrano in 0.7 s): la RMSE sale oltre
# la soglia del vecchio criterio, la scala robusta dei residui no
t, serie = segnale(gradini=[(20.0, 80.0)], durata=80.0)
T = serie["T_C"].copy()
for tj, ampiezza in ((21.5, -3.5), (24.0, -3.0), (26.5, 3.0), (29.0, -4.0)):
T += ampiezza * np.exp(-(t - tj) / 0.7) * (t >= tj)
S = {"T_C": T, "dT_C": T - 23.0}
vs = analizza_domini(t, S, domini.proponi(t, S, cfg), cfg)[-1]["serie"]["T_C"]
fit = fit_scelto(vs)
assert fit["rmse"] > 1.3 * vs["sigma"] # il vecchio criterio lo scartava…
assert fit["scala_residui"] <= 1.5 * vs["sigma"] and vs["adeguato"] is True # …la scala robusta no
assert fit["parametri"]["tau"] == pytest.approx(TAU, rel=0.03)
def test_salto_con_tratto_adiacente_indeterminato_usa_la_misura_corretta(cfg):
# due gradini a 0.5 s: il tratto in mezzo (1-2 campioni) è indeterminato, ma c'è il raffreddamento finale con c e τ
t, serie = segnale(gradini=[(20.0, 80.0), (20.5, 60.0)], durata=80.0)
ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg)
assert ris[2]["serie"]["dT_C"]["scelta"] is None
for i, ampiezza in ((1, 80.0), (3, 60.0)):
g = ris[i]["serie"]["dT_C"]["gradino"]
assert g["stima"] == "misura corretta"
assert abs(g["delta"] - ampiezza) <= abs(g["delta_misurato"] - ampiezza) + 1e-9
assert g["delta"] == pytest.approx(ampiezza, rel=0.03)
def test_dati_non_finiti_non_convergono_senza_eccezioni(cfg):
t = np.arange(20) * 0.15
y = 25.0 + np.exp(-t / 5.0)
y[3] = np.nan
for nome in MODELLI:
r = adatta(t, y, nome, 0.1, cfg)
assert r["convergenza"] is False and r["modello"] == nome
+103
View File
@@ -0,0 +1,103 @@
"""Forma del report.md e dei grafici su un CSV sintetico (nessun file esterno)."""
import numpy as np
from conftest import scrivi_csv, segnale
from fitseg import cli
def esegui(tmp_path, gradini):
t, serie = segnale(gradini=gradini, durata=80.0)
csv = tmp_path / "sint.csv"
scrivi_csv(csv, t, serie)
cli.main(["--output", str(tmp_path / "out"), "analizza", str(csv)])
return (tmp_path / "out/sint/03_report/report.md").read_text(), tmp_path / "out/sint/02_sovrapposizione"
def test_dominio_indeterminato_senza_ripetizioni_e_con_il_minimo(tmp_path):
report, _ = esegui(tmp_path, [(20.0, 80.0), (20.5, 60.0)]) # tratto intermedio di 1-2 campioni
assert "indeterminato: indeterminato" not in report and "Indeterminato** — indeterminato" not in report
assert "meno del minimo 4 (`n_min_punti_parametri_fissi`)" in report
def test_costante_senza_r2_e_senza_zero_negativo(tmp_path):
report, _ = esegui(tmp_path, [(20.0, 80.0)])
assert "-0.0000" not in report
dettaglio = report.split("### Dominio 1")[1].split("### Dominio 2")[0]
assert "`costante`" in dettaglio and "R^2" not in dettaglio # R² non ha senso per un modello costante
assert "R^2 = " in report # ma c'è per l'esponenziale
def test_intervallo_del_dominio_include_l_ultimo_campione(tmp_path):
report, _ = esegui(tmp_path, [(20.0, 80.0)])
assert r"\le t \le" in report and r"\le t <" not in report
def test_salto_stimato_dai_modelli_nel_report(tmp_path):
report, _ = esegui(tmp_path, [(20.0, 80.0)])
assert "stimato dai modelli adiacenti (misurato sui campioni:" in report
def test_motivo_con_un_solo_modello_senza_parsimonia(tmp_path):
report, _ = esegui(tmp_path, [(20.0, 80.0)])
finale = report.split("### Dominio 3")[1]
assert "1/1 modelli validi" in finale and "meno parametri" not in finale
def test_grafico_indeterminato_senza_pannello_residui(tmp_path):
_, cartella = esegui(tmp_path, [(20.0, 80.0), (20.5, 60.0)])
from PIL import Image # matplotlib dipende già da Pillow
larghezza, altezza = Image.open(cartella / "dominio3_T_C.png").size
assert altezza / larghezza < 0.55 # un solo pannello (9×4.2), non due (9×6)
larghezza, altezza = Image.open(cartella / "dominio7_T_C.png").size if (cartella / "dominio7_T_C.png").exists() else (1, 1)
assert np.isfinite(altezza / larghezza)
def test_risultati_json_e_json_valido_anche_con_domini_indeterminati(tmp_path):
import json
esegui(tmp_path, [(20.0, 80.0), (20.5, 60.0)]) # tratto intermedio con meno di 4 campioni: σ non stimabile
testo = (tmp_path / "out/sint/03_report/risultati.json").read_text()
def rifiuta(costante):
raise ValueError(f"costante non ammessa in JSON: {costante}")
dati = json.loads(testo, parse_constant=rifiuta) # NaN / Infinity farebbero fallire
assert any(v["sigma"] is None for r in dati for v in r["serie"].values())
def test_senza_nan():
from fitseg.uscita import _senza_nan
assert _senza_nan({"a": float("nan"), "b": [1.0, float("inf")], "c": {"d": 2}}) == {"a": None, "b": [1.0, None], "c": {"d": 2}}
def test_report_mostra_la_scala_robusta_dei_residui(tmp_path):
report, _ = esegui(tmp_path, [(20.0, 80.0)])
assert r"\hat\sigma_\text{res}" in report and "1.4826" in report
def test_equazione_lineare_con_segno():
from fitseg.uscita import _eq_numerica
fit = {"modello": "lineare", "parametri": {"a": 20.0, "b": -0.5}}
assert _eq_numerica(fit) == r"y = 20 - 0.5\,t'"
assert _eq_numerica({"modello": "lineare", "parametri": {"a": 20.0, "b": 0.25}}) == r"y = 20 + 0.25\,t'"
def test_salto_solo_misurato_nel_report():
from fitseg.uscita import _salto
g = {"n": 3, "durata_s": 0.3, "stima": "misura", "delta": 70.0, "delta_misurato": 70.0}
testo = _salto({"gradino": g})
assert "misurato sui campioni" in testo and "modelli adiacenti non disponibili" in testo and testo.count("$") % 2 == 0
def test_grafici_con_campioni_esclusi_e_modelli_alternativi(tmp_path):
# riposo con deriva (costante, lineare ed esponenziale validi → curve tratteggiate) e scarta_dopo_salto_n > 0 (campioni grigi)
from conftest import RADICE
testo = (RADICE / "config.toml").read_text().replace("scarta_dopo_salto_n = 0", "scarta_dopo_salto_n = 2")
(tmp_path / "c.toml").write_text(testo)
t, serie = segnale(gradini=[(40.0, 70.0)], durata=80.0, rumore=0.3)
deriva = 0.02 * t
serie = {"T_C": serie["T_C"] + deriva, "dT_C": serie["dT_C"] + deriva}
csv = tmp_path / "sint.csv"
scrivi_csv(csv, t, serie)
cli.main(["--config", str(tmp_path / "c.toml"), "--output", str(tmp_path / "out"), "analizza", str(csv)])
cartella = tmp_path / "out/sint/02_sovrapposizione"
assert (cartella / "dominio1_T_C.png").stat().st_size > 0 and (cartella / "dominio3_T_C.png").stat().st_size > 0
+24 -8
View File
@@ -1,10 +1,10 @@
"""Regola di scelta del modello (SPEC §5.3) su fit finti."""
"""Regola di scelta del modello (docs/algoritmi.md §6) su fit finti."""
from fitseg.analisi import scegli
def fit(nome, k, aicc, rmse=0.1, **extra):
base = {"modello": nome, "n_parametri": k, "aicc": aicc, "rmse": rmse, "convergenza": True,
def fit(nome, k, aicc, rmse=0.1, scala_residui=0.1, **extra):
base = {"modello": nome, "n_parametri": k, "aicc": aicc, "rmse": rmse, "scala_residui": scala_residui, "convergenza": True,
"parametri_a_limite": [], "non_identificabile": False}
return {**base, **extra}
@@ -27,15 +27,31 @@ def test_scarta_non_validi(cfg):
def test_nessun_valido_indeterminato(cfg):
scelto, motivo, adeguato = scegli([fit("a", 1, -1.0, non_identificabile=True)], 0.1, cfg)
assert scelto is None and not adeguato and "indeterminato" in motivo
assert scelto is None and not adeguato and "nessun modello" in motivo
def test_preferisce_i_modelli_adeguati(cfg):
# b ha AICc molto migliore ma RMSE oltre soglia·σ; a è adeguato: vince tra gli adeguati
scelto, _, adeguato = scegli([fit("a", 1, -50.0, rmse=0.1), fit("b", 3, -500.0, rmse=1.0)], 0.1, cfg)
# b ha AICc molto migliore ma la scala dei residui supera soglia·σ; a è adeguato: vince tra gli adeguati
scelto, _, adeguato = scegli([fit("a", 1, -50.0, scala_residui=0.1), fit("b", 3, -500.0, scala_residui=1.0)], 0.1, cfg)
assert scelto["modello"] == "a" and adeguato
def test_nessun_adeguato_lo_segnala(cfg):
scelto, _, adeguato = scegli([fit("a", 1, -50.0, rmse=1.0)], 0.1, cfg)
assert scelto["modello"] == "a" and not adeguato
scelto, _, adeguato = scegli([fit("a", 1, -50.0, scala_residui=1.0)], 0.1, cfg)
assert scelto["modello"] == "a" and adeguato is False
def test_adeguatezza_usa_la_scala_robusta_non_la_rmse(cfg):
# RMSE enorme per via degli scarti casuali, ma la scala robusta dei residui è vicina al rumore: adeguato
scelto, _, adeguato = scegli([fit("a", 3, -50.0, rmse=0.5, scala_residui=0.11)], 0.1, cfg)
assert scelto["modello"] == "a" and adeguato is True
def test_sigma_non_stimabile_adeguatezza_non_valutabile(cfg):
scelto, motivo, adeguato = scegli([fit("a", 1, -50.0)], None, cfg)
assert scelto["modello"] == "a" and adeguato is None and "non valutabile" in motivo
def test_motivo_senza_clausola_di_parsimonia_con_un_solo_candidato(cfg):
assert "meno parametri" not in scegli([fit("a", 1, -50.0)], 0.1, cfg)[1]
assert "meno parametri" in scegli([fit("a", 1, -50.0), fit("b", 3, -50.5)], 0.1, cfg)[1]
+64
View File
@@ -0,0 +1,64 @@
"""Validazione contro i parametri VERI di gen_data: extern/gen_data sorteggia i parametri di ogni corsa dal seed, quindi per ogni seed si
conoscono gradini, ampiezze e costante di tempo. Richiede extern/gen_data, che si scarica con ./scarica_gen_data.sh (altrimenti i test sono saltati)."""
import sys
import numpy as np
import pytest
from conftest import RADICE
from fitseg import dati, domini
from fitseg.analisi import analizza_domini, fit_scelto
GEN = RADICE / "extern" / "gen_data"
pytestmark = pytest.mark.skipif(not (GEN / "genera.py").exists(), reason="extern/gen_data non trovato: lancia ./scarica_gen_data.sh")
@pytest.fixture(scope="module")
def genera():
sys.path.insert(0, str(GEN))
import genera as g
yield g
sys.path.remove(str(GEN))
@pytest.mark.parametrize("seed", range(1, 21))
def test_risultati_contro_i_parametri_veri(seed, genera, cfg, tmp_path):
gcfg = genera.leggi_config(GEN / "config.toml")
veri = genera.sorteggia(gcfg, np.random.default_rng(seed))
ordine = np.argsort(veri["t_impulsi_s"])
tj = np.array(veri["t_impulsi_s"], float)[ordine]
aj = np.array(veri["dT_impulsi_c"], float)[ordine]
k = veri["k_raffreddamento_hz"]
t_ms, T, dT = genera.genera(gcfg, np.random.default_rng(seed))
with open(tmp_path / "d.csv", "w") as f:
genera.scrivi(f, t_ms, T, dT)
t, serie = dati.leggi_csv(tmp_path / "d.csv", cfg)
ris = analizza_domini(t, serie, domini.proponi(t, serie, cfg), cfg)
# gradini: numero giusto e istanti entro un paio di campioni
risc = [r for r in ris if r["tipo"] == "riscaldamento"]
assert len(risc) == len(tj)
for r, x in zip(risc, tj, strict=True):
assert abs(r["t0_s"] - x) < 0.3
for nome in serie:
raff = [r for r in ris if r["tipo"] == "raffreddamento"]
# costante di tempo del raffreddamento finale (libero)
finale = fit_scelto(raff[-1]["serie"][nome])
assert finale is not None and finale["parametri"]["tau"] * k == pytest.approx(1.0, abs=0.03)
for r in raff:
fit = fit_scelto(r["serie"][nome])
if fit is None: # indeterminato solo se i campioni sono meno del minimo
assert r["n_fit"] < cfg["domini"]["n_min_punti_parametri_fissi"]
continue
atteso = float(sum(a * np.exp(-k * (r["t0_s"] - x)) for x, a in zip(tj, aj, strict=True) if x <= r["t0_s"]))
if nome == "dT_C": # ampiezza A al netto dell'asintoto: confrontabile con la somma dei decadimenti dei gradini
assert fit["parametri"]["A"] == pytest.approx(atteso, rel=0.04)
# salti: stimati dai modelli (o dalla misura corretta con c e τ del finale) entro il 5 % dall'ampiezza vera del gradino
for r, a in zip(risc, aj, strict=True):
g = r["serie"]["dT_C"]["gradino"]
if g["stima"] in ("modelli", "misura corretta"):
assert g["delta"] == pytest.approx(a, rel=0.05)