diff --git a/.gitignore b/.gitignore index 6f9dff9..037f285 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .venv/ -lavoro/ +output/ __pycache__/ *.egg-info/ .pytest_cache/ -dati/*.csv +input/*.csv diff --git a/CLAUDE.md b/CLAUDE.md index bf482c8..720e742 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,16 +10,16 @@ Fonti di verità (non duplicarne il contenuto): [docs/SPEC.md](docs/SPEC.md) (al ```bash uv venv .venv && uv pip install -e ".[dev]" -.venv/bin/fitseg domini dati/gen_1.csv # suddivisione: lavoro/domini.toml + proposta.png, nessun fit -.venv/bin/fitseg analizza dati/gen_1.csv [--domini lavoro/domini.toml] # → lavoro/out/ (report.md, risultati.json, dominio*_*.png) +.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/pytest # tutti; singolo: -k raffreddamento o tests/test_fit.py .venv/bin/ruff check src tests ``` -`--config`/`--lavoro` vanno **prima** del sottocomando. `lavoro/` e `dati/*.csv` sono ignorati da git. +`--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//` (`01_domini`, `02_sovrapposizione`, `03_report`, vedi `uscita.PASSI`). ## Architettura (`src/fitseg/`, un modulo per responsabilità) -`cli.py` (due sottocomandi, unico punto d'ingresso) → `config.py`, `dati.py` (lettura) → `domini.py` (gradini ⇒ domini, I/O di `domini.toml`) → `analisi.py` (ordina i domini, sceglie il modello) → `fit.py` (`adatta`) con `modelli.py` (registro `MODELLI`) → `uscita.py` (report, JSON, grafici). +`cli.py` (due sottocomandi, unico punto d'ingresso) → `config.py`, `dati.py` (lettura) → `domini.py` (gradini ⇒ domini, I/O di `domini.toml`) → `analisi.py` (ordina i domini, sceglie il modello) → `fit.py` (`adatta`) con `modelli.py` (registro `MODELLI`) → `uscita.py` (cartelle dei passi, grafici, report; il report incorpora le immagini con percorsi relativi `../01_domini/…`, quindi i nomi dei file/cartelle vanno cambiati in modo coerente). - I domini derivano **solo dai gradini positivi** di `serie_riferimento` (`diff > soglia_gradino_c`), con arretramento ai campioni di transizione; `domini.toml` è modificabile e deve essere contiguo. - `analisi.analizza_domini`: i domini di raffreddamento si fittano **per primi**; i loro `c`/`tau` sono fissati nei domini di riscaldamento (`riscaldamento_fissa_da_raffreddamento`, `adatta(..., fissi=)`). I modelli provati dipendono dal tipo (`[fit.modelli_per_tipo]`: riscaldamento e raffreddamento solo `esp_decad`). @@ -28,7 +28,7 @@ uv venv .venv && uv pip install -e ".[dev]" ## 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 `dati/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). +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). ## Gotcha diff --git a/README.md b/README.md index a061f99..9529313 100644 --- a/README.md +++ b/README.md @@ -15,26 +15,26 @@ uv pip install -e ".[dev]" # numpy, scipy, matplotlib; pytest e ruff per lo ## 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`; i test di integrazione usano i seed 1–3 in `dati/` (ignorata da git): +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 dati +mkdir -p input for s in 1 2 3; do - (cd ../gen_data && .venv/bin/python genera.py config.toml --seed $s -o ../EquationMiner/dati/gen_$s.csv) + (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 dati/gen_1.csv # come verrebbero suddivisi i dati: tabella, lavoro/domini.toml, lavoro/proposta.png (nessun fit) -.venv/bin/fitseg analizza dati/gen_1.csv # domini + regressione + report in lavoro/out/ +.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 `--lavoro DIR` (default `lavoro`) vanno **prima** del sottocomando. +`--config PATH` (default `config.toml`) e `--output DIR` (default `output`) vanno **prima** del sottocomando. I file di ogni analisi vanno in `output//`. -**Correggere i domini a mano**: apri `lavoro/domini.toml`, modifica i tempi (i domini devono restare contigui) e rilancia +**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 dati/gen_1.csv --domini lavoro/domini.toml +.venv/bin/fitseg analizza input/gen_1.csv --domini output/gen_1/01_domini/domini.toml ``` ```toml [[dominio]] @@ -43,14 +43,24 @@ t0_s = 15.8 # incluso t1_s = 17.76 # escluso (l'ultimo dominio arriva a fine dati) ``` -### Cosa produce (in `lavoro/`, ignorata da git) +### Cosa produce -| File | Contenuto | -|---|---| -| `domini.toml`, `proposta.png` | domini proposti e grafico dei dati con la suddivisione | -| `out/report.md` | per dominio e serie: grafico, modello scelto con parametri ± IC 95 %, metriche, tabella di tutti i modelli provati | -| `out/dominio_.png` | dati grezzi contro il modello scelto (colore del dominio) e gli altri modelli validi (tratteggiati), più residui; campioni esclusi dal fit in grigio | -| `out/risultati.json` | tutti i fit con tutte le metriche | +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_.png serie intera con le equazioni a tratti e i residui +│ └── dominio_.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. Le immagini sono link relativi: si vedono in un visualizzatore Markdown. Come leggere il report: **non adeguato** = RMSE oltre 1.3 volte il rumore stimato (guarda l'ACF1 dei residui); **indeterminato** = nessun modello valido o dominio con meno di `n_min_punti` campioni. Nel raffreddamento `τ` è la costante di tempo e `c` l'asintoto (≈ ambiente per `T_C`, ≈ 0 per `dT_C`). @@ -84,9 +94,9 @@ Nuovo modello: voce in `MODELLI` di [src/fitseg/modelli.py](src/fitseg/modelli.p | `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 `dati/gen_{1,2,3}.csv`; CLI `domini` e `analizza --domini`; codici di uscita | sì (altrimenti saltati) | +| `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 dati/gen_1.csv` e controlla in `lavoro/out/report.md` 4 domini (riposo, due riscaldamenti, raffreddamento) con τ ≈ 16 s. +I test unitari generano il segnale da soli (`tests/conftest.py`). Prova manuale: `fitseg analizza input/gen_1.csv` e controlla in `output/gen_1/03_report/report.md` 4 domini (riposo, due riscaldamenti, raffreddamento) con τ ≈ 16 s. Codici di uscita: 0 ok · 3 configurazione non valida · 4 CSV illeggibile/non valido · 6 `domini.toml` non valido. diff --git a/config.toml b/config.toml index 788ffd7..bce8257 100644 --- a/config.toml +++ b/config.toml @@ -34,4 +34,4 @@ riscaldamento = ["esp_decad"] raffreddamento = ["esp_decad"] [output] -cartella_lavoro = "lavoro" +cartella_output = "output" # i file di ogni analisi vanno in output// diff --git a/docs/SPEC.md b/docs/SPEC.md index 757ce40..73f0a44 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -5,11 +5,13 @@ ## 1. Flusso ``` -CSV ─► [DOMINI] gradini ⇒ riposo | riscaldamento… | raffreddamento ─► lavoro/domini.toml (modificabile), proposta.png +CSV ─► [1 DOMINI] gradini ⇒ riposo | riscaldamento… | raffreddamento ─► output//01_domini/ (domini.png, domini.toml modificabile) ▼ - [FIT] per (dominio, serie): modelli del tipo ─► regola di scelta ─► lavoro/out/report.md, risultati.json, dominio*_*.png + [2 FIT] per (dominio, serie): modelli del tipo ─► regola di scelta ─► 02_sovrapposizione/ (dati contro equazioni) + ▼ + [3 REPORT] 03_report/report.md (immagini incorporate) e risultati.json ``` -Tutto il calcolo è deterministico (nessuna partenza casuale, nessun LLM). +Tutto il calcolo è deterministico (nessuna partenza casuale, nessun LLM). Ogni CSV ha la sua cartella `output//`. ## 2. Domini (`domini.py`) @@ -51,14 +53,16 @@ Scarta non convergenti, con parametri a limite o non identificabili. Se esistono | Comando | Funzione | |---|---| -| `fitseg domini CSV` | propone i domini: tabella, `lavoro/domini.toml`, `lavoro/proposta.png` | -| `fitseg analizza CSV [--domini FILE]` | domini (proposti o da FILE) + fit + `lavoro/out/{report.md, risultati.json, dominio_.png}` | +| `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 | -Opzioni globali prima del sottocomando: `--config PATH`, `--lavoro DIR`. Exit: 0 ok · 3 config non valida · 4 CSV illeggibile · 6 domini non validi. +File in `output//`: `01_domini/{domini.png, domini.toml}` · `02_sovrapposizione/{sovrapposizione_.png, dominio_.png}` · `03_report/{report.md, risultati.json}`. 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 `dati/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. +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 diff --git a/input/.gitkeep b/input/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/fitseg/cli.py b/src/fitseg/cli.py index 3bc7ce9..0b38dbe 100644 --- a/src/fitseg/cli.py +++ b/src/fitseg/cli.py @@ -1,4 +1,4 @@ -"""CLI: `fitseg domini CSV` (vedi la suddivisione) e `fitseg analizza CSV` (domini + regressione + report).""" +"""CLI: `fitseg domini CSV` (vedi la suddivisione) e `fitseg analizza CSV` (domini + equazioni + sovrapposizione + report).""" import argparse import sys @@ -16,57 +16,57 @@ def errore(msg, codice): sys.exit(codice) -def _prepara(args): - """cfg, cartella di lavoro, (t, serie) e domini (da domini.toml se dato, altrimenti la proposta, che viene salvata).""" +def _passo_domini(args): + """Passo 1: legge i dati, propone i domini (o usa --domini) e scrive 01_domini/. Ritorna (cfg, cartelle, t, serie, domini).""" try: cfg = config.leggi(args.config) - lavoro = Path(args.lavoro or cfg["output"]["cartella_lavoro"]) t, serie = dati.leggi_csv(args.csv, cfg) except config.ErroreConfig as e: errore(e, EXIT_CONFIG) except dati.ErroreDati as e: errore(e, EXIT_DATI) - lavoro.mkdir(parents=True, exist_ok=True) + base = Path(args.output or cfg["output"]["cartella_output"]) / Path(args.csv).stem + cartelle = uscita.cartelle(base) if getattr(args, "domini", None): try: - return cfg, lavoro, t, serie, dom.leggi_toml(args.domini, t) + domini = dom.leggi_toml(args.domini, t) except (OSError, ValueError, KeyError) as e: errore(f"{args.domini}: {e}", EXIT_DOMINI) - domini = dom.proponi(t, serie, cfg) - dom.scrivi_toml(lavoro / "domini.toml", domini, t) - uscita.grafico_proposta(lavoro / "proposta.png", t, serie, domini) - return cfg, lavoro, t, serie, domini + else: + domini = dom.proponi(t, serie, cfg) + dom.scrivi_toml(cartelle["domini"] / "domini.toml", domini, t) + uscita.grafico_domini(cartelle["domini"] / "domini.png", t, serie, domini) + for k, d in enumerate(domini, 1): + print(f"{k:2d} {d['tipo']:15s} {t[d['i0']]:8.2f} – {t[d['i1'] - 1]:8.2f} s n={d['i1'] - d['i0']}") + print(f"scritto: {cartelle['domini']}/ (domini.png, domini.toml)", file=sys.stderr) + return cfg, cartelle, t, serie, domini def cmd_domini(args): - _, lavoro, t, _, domini = _prepara(args) - for k, d in enumerate(domini, 1): - print(f"{k:2d} {d['tipo']:15s} {t[d['i0']]:8.2f} – {t[d['i1'] - 1]:8.2f} s n={d['i1'] - d['i0']}") - print(f"scritto: {lavoro / 'domini.toml'}, {lavoro / 'proposta.png'}", file=sys.stderr) + _passo_domini(args) def cmd_analizza(args): - cfg, lavoro, t, serie, domini = _prepara(args) - out = lavoro / "out" - out.mkdir(parents=True, exist_ok=True) + cfg, cartelle, t, serie, domini = _passo_domini(args) risultati = analizza_domini(t, serie, domini, cfg) - uscita.scrivi_report(out, risultati, Path(args.csv).name) - uscita.grafici_domini(out, t, serie, risultati, domini, cfg) + uscita.sovrapposizione_generale(cartelle["sovrapposizione"], t, serie, risultati, domini, cfg) + uscita.sovrapposizione_domini(cartelle["sovrapposizione"], t, serie, risultati, domini, cfg) + uscita.scrivi_report(cartelle["report"], risultati, Path(args.csv).name, list(serie)) for r in risultati: for nome, vs in r["serie"].items(): print(f"dominio {r['k']} {r['tipo']:15s} {nome:5s} -> {vs['scelta'] or 'indeterminato'}") - print(f"scritto: {out}/ (report.md, risultati.json, dominio*_*.png)", file=sys.stderr) + print(f"scritto: {cartelle['sovrapposizione']}/, {cartelle['report']}/ (report.md, risultati.json)", file=sys.stderr) def main(argv=None): ap = argparse.ArgumentParser(prog="fitseg", description=__doc__) ap.add_argument("--config", default="config.toml") - ap.add_argument("--lavoro", help="cartella di lavoro (default: [output] cartella_lavoro)") + ap.add_argument("--output", help="cartella di output (default: [output] cartella_output); i file vanno in //") sub = ap.add_subparsers(dest="cmd", required=True) - p = sub.add_parser("domini", help="propone i domini: tabella, domini.toml e proposta.png (nessun fit)") + p = sub.add_parser("domini", help="passo 1: suddivisione in domini (tabella, domini.png, domini.toml), nessun fit") p.add_argument("csv") p.set_defaults(f=cmd_domini) - p = sub.add_parser("analizza", help="domini + regressione + report in lavoro/out/") + p = sub.add_parser("analizza", help="tutti i passi: domini, equazioni, sovrapposizione, report") p.add_argument("csv") p.add_argument("--domini", help="domini.toml corretto a mano, al posto della proposta automatica") p.set_defaults(f=cmd_analizza) diff --git a/src/fitseg/config.py b/src/fitseg/config.py index 4908a3b..85e057c 100644 --- a/src/fitseg/config.py +++ b/src/fitseg/config.py @@ -9,7 +9,7 @@ CHIAVI = { "fit": ["modelli_per_tipo", "riscaldamento_fissa_da_raffreddamento", "perdita", "scala_perdita_sigma", "tau_min_rel", "tau_max_rel", "n_griglia_tau", "soglia_condizione", "correggi_autocorrelazione", "soglia_acf", "soglia_delta_aicc", "rmse_vs_rumore_max"], - "output": ["cartella_lavoro"], + "output": ["cartella_output"], } diff --git a/src/fitseg/uscita.py b/src/fitseg/uscita.py index bbb4a79..c1b31a1 100644 --- a/src/fitseg/uscita.py +++ b/src/fitseg/uscita.py @@ -1,6 +1,7 @@ -"""File prodotti: report.md (con i grafici), risultati.json, proposta.png e un grafico dati/modelli per dominio.""" +"""Cartella `output//`: 01_domini (suddivisione), 02_sovrapposizione (dati contro equazioni), 03_report.""" import json +from datetime import date import matplotlib import numpy as np @@ -10,63 +11,85 @@ from .modelli import valuta matplotlib.use("Agg") import matplotlib.pyplot as plt +from matplotlib.patches import Patch -COLORI = {"riposo": "#9aa0a6", "riscaldamento": "#d93025", "raffreddamento": "#1a73e8"} +COLORI = {"riposo": "#7f8c8d", "riscaldamento": "#d93025", "raffreddamento": "#1a73e8"} +PASSI = {"domini": "01_domini", "sovrapposizione": "02_sovrapposizione", "report": "03_report"} + + +def cartelle(base): + """Crea e restituisce le cartelle dei passi dentro `base`.""" + out = {k: base / v for k, v in PASSI.items()} + for c in out.values(): + c.mkdir(parents=True, exist_ok=True) + return out def _scelto(vs): return next((x for x in vs["fit"] if x["modello"] == vs["scelta"]), None) -def _eq(fit): - p = ", ".join(f"{k} = {v:.4g} (fisso)" if k in fit["fissi"] else f"{k} = {v:.4g} ± {fit['ic95'][k]:.2g}" - for k, v in fit["parametri"].items()) - return f"{fit['equazione']} ({p})" +def _params(fit): + return ", ".join(f"{k} = {v:.4g} (fisso)" if k in fit["fissi"] else f"{k} = {v:.4g} ± {fit['ic95'][k]:.2g}" + for k, v in fit["parametri"].items()) -def scrivi_report(cartella, risultati, nome_csv): - """report.md, risultati.json; i grafici dominio_.png sono incorporati nel report.""" - righe = [f"# Report fitseg — {nome_csv}\n", "t' = t − t0 (inizio del dominio). Intervalli al 95 %.\n", - "| # | tipo | t0 [s] | t1 [s] | n |", "|---|---|---|---|---|"] - righe += [f"| {r['k']} | {r['tipo']} | {r['t0_s']:.2f} | {r['t1_s']:.2f} | {r['n']} |" for r in risultati] - for r in risultati: - righe.append(f"\n## Dominio {r['k']} — {r['tipo']} ({r['t0_s']:.2f}–{r['t1_s']:.2f} s)\n") - for nome, vs in r["serie"].items(): - fit = _scelto(vs) - righe += [f"### {nome} (σ rumore {vs['sigma']:.3g})\n", f"![dominio {r['k']} {nome}](dominio{r['k']}_{nome}.png)\n"] - if not fit: - righe.append(f"**Indeterminato** — {vs['motivo']}\n") - continue - righe.append(f"**Scelto: {fit['modello']}** — {_eq(fit)} \n" - f"RMSE {fit['rmse']:.3g}, R² {fit['r2']:.4f}, ACF1 {fit['acf1']:.2f}" - f"{'' if vs['adeguato'] else ' — **modello non adeguato** (RMSE > soglia·σ)'} \n" - f"Motivo: {vs['motivo']}\n") - righe += ["| modello | k | RMSE | AICc | BIC | ACF1 | valido |", "|---|---|---|---|---|---|---|"] - righe += [f"| {x['modello']} | {x['n_parametri']} | {x['rmse']:.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"])] - righe.append("") - (cartella / "report.md").write_text("\n".join(righe), encoding="utf-8") - (cartella / "risultati.json").write_text(json.dumps(risultati, indent=1, ensure_ascii=False), encoding="utf-8") +def _legenda_tipi(ax): + ax.legend(handles=[Patch(color=c, alpha=0.4, label=t) for t, c in COLORI.items()], fontsize=8, loc="upper right") -def grafico_proposta(percorso, t, serie, domini): - """Dati grezzi con i domini proposti (colore per tipo), senza alcun fit.""" - fig, assi = plt.subplots(len(serie), 1, figsize=(11, 3.6 * len(serie)), sharex=True, squeeze=False) +def grafico_domini(percorso, t, serie, domini): + """Passo 1: dati grezzi con la suddivisione in domini (colore per tipo), senza alcun fit.""" + fig, assi = plt.subplots(len(serie), 1, figsize=(11, 3.8 * len(serie)), sharex=True, squeeze=False) for ax, (nome, y) in zip(assi[:, 0], serie.items(), strict=True): ax.plot(t, y, ".", ms=2, color="#333") for k, d in enumerate(domini, 1): ax.axvspan(t[d["i0"]], t[d["i1"] - 1], color=COLORI[d["tipo"]], alpha=0.15) - ax.text(t[d["i0"]], ax.get_ylim()[1], f" {k} {d['tipo']}", va="top", fontsize=8, rotation=90) + ax.text(t[d["i0"]], ax.get_ylim()[1], f" {k}", va="top", fontsize=9, fontweight="bold") ax.set_ylabel(nome) + ax.grid(alpha=0.3) + _legenda_tipi(assi[0, 0]) + assi[0, 0].set_title("Dati grezzi e suddivisione in domini", fontsize=11) assi[-1, 0].set_xlabel("t [s]") fig.tight_layout() - fig.savefig(percorso, dpi=110) + fig.savefig(percorso, dpi=120) plt.close(fig) -def grafici_domini(cartella, t, serie, risultati, domini, cfg): - """Un PNG per (dominio, serie): dati grezzi contro il modello scelto e gli altri modelli validi, più residui.""" +def _curva(r, vs, tt): + fit = _scelto(vs) + return None if fit is None else valuta(fit["modello"], tt - r["t0_s"], list(fit["parametri"].values())) + + +def sovrapposizione_generale(cartella, t, serie, risultati, domini, cfg): + """Passo 2a: per ogni serie, dati grezzi con le equazioni scelte (a tratti) e residui (dei soli campioni usati nel fit).""" + scarta = cfg["domini"]["scarta_dopo_salto_n"] + for nome, y in serie.items(): + fig, (a, b) = plt.subplots(2, 1, figsize=(11, 7), sharex=True, height_ratios=[3, 1]) + a.plot(t, y, ".", ms=2, color="#555", label="dati grezzi") + for r, d in zip(risultati, domini, strict=True): + a.axvspan(t[d["i0"]], t[d["i1"] - 1], color=COLORI[r["tipo"]], alpha=0.10) + idx = np.arange(d["i0"], d["i1"]) + yf = _curva(r, r["serie"][nome], t[idx]) + if yf is not None: + a.plot(t[idx], yf, "-", lw=2, color=COLORI[r["tipo"]]) + dal = scarta if r["tipo"] != "riposo" else 0 + b.plot(t[idx][dal:], (y[idx] - yf)[dal:], ".", ms=2, color="#555") + b.axhline(0, color="k", lw=0.5) + a.set_ylabel(nome) + b.set_ylabel("residuo") + b.set_xlabel("t [s]") + a.set_title(f"{nome}: dati grezzi contro le equazioni trovate", fontsize=11) + _legenda_tipi(a) + a.grid(alpha=0.3) + b.grid(alpha=0.3) + fig.tight_layout() + fig.savefig(cartella / f"sovrapposizione_{nome}.png", dpi=120) + plt.close(fig) + + +def sovrapposizione_domini(cartella, t, serie, risultati, domini, cfg): + """Passo 2b: un PNG per (dominio, serie): dati grezzi, modello scelto, altri modelli validi, residui.""" scarta = cfg["domini"]["scarta_dopo_salto_n"] for r, d in zip(risultati, domini, strict=True): i0 = d["i0"] + (scarta if d["tipo"] != "riposo" else 0) @@ -92,6 +115,46 @@ def grafici_domini(cartella, t, serie, risultati, domini, cfg): 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) fig.tight_layout() - fig.savefig(cartella / f"dominio{r['k']}_{nome}.png", dpi=110) + fig.savefig(cartella / f"dominio{r['k']}_{nome}.png", dpi=120) plt.close(fig) + + +def scrivi_report(cartella, risultati, nome_csv, serie_nomi): + """Passo 3: report.md (immagini dai passi 1 e 2 con percorsi relativi) e risultati.json.""" + L = [f"# Report fitseg — {nome_csv}\n", (f"Data: {date.today().isoformat()} · Serie: {', '.join(serie_nomi)} · " + "t' = t − t0 (inizio del dominio) · intervalli di confidenza al 95 %\n"), + "## 1. Suddivisione in domini\n", f"![Suddivisione in domini](../{PASSI['domini']}/domini.png)\n", + "| # | tipo | t0 [s] | t1 [s] | campioni |", "|---|---|---|---|---|"] + L += [f"| {r['k']} | {r['tipo']} | {r['t0_s']:.2f} | {r['t1_s']:.2f} | {r['n']} |" for r in risultati] + L += ["", f"Domini modificabili in `../{PASSI['domini']}/domini.toml`.\n", "## 2. Equazioni trovate\n", + "| dominio | tipo | serie | modello | equazione e parametri |", "|---|---|---|---|---|"] + for r in risultati: + for nome, vs in r["serie"].items(): + fit = _scelto(vs) + L.append(f"| {r['k']} | {r['tipo']} | {nome} | {fit['modello'] if fit else '—'} | " + + (f"{fit['equazione']} — {_params(fit)}" if fit else f"indeterminato: {vs['motivo']}") + " |") + L += ["", "## 3. Sovrapposizione dati–equazioni\n"] + L += [f"![{n}](../{PASSI['sovrapposizione']}/sovrapposizione_{n}.png)\n" for n in serie_nomi] + L.append("## 4. Dettaglio per dominio\n") + for r in risultati: + L.append(f"### Dominio {r['k']} — {r['tipo']} ({r['t0_s']:.2f}–{r['t1_s']:.2f} s)\n") + for nome, vs in r["serie"].items(): + fit = _scelto(vs) + L += [f"#### {nome} (σ rumore {vs['sigma']:.3g})\n", + f"![dominio {r['k']} {nome}](../{PASSI['sovrapposizione']}/dominio{r['k']}_{nome}.png)\n"] + if not fit: + L.append(f"**Indeterminato** — {vs['motivo']}\n") + continue + L.append(f"**Scelto: {fit['modello']}** — {fit['equazione']} \n{_params(fit)} \n" + f"RMSE {fit['rmse']:.3g} · R² {fit['r2']:.4f} · ACF1 {fit['acf1']:.2f}" + f"{'' if vs['adeguato'] else ' · **modello non adeguato** (RMSE > soglia·σ)'} \n" + f"Motivo: {vs['motivo']}\n") + L += ["| modello | k | RMSE | AICc | BIC | ACF1 | valido |", "|---|---|---|---|---|---|---|"] + L += [f"| {x['modello']} | {x['n_parametri']} | {x['rmse']:.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") diff --git a/tests/conftest.py b/tests/conftest.py index 20e02d5..1ac635d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,8 +36,8 @@ def sintetico(): @pytest.fixture(params=SEEDS) def csv_gen(request): - """CSV prodotto da gen_data per il seed; il test è saltato se il file non c'è (dati/ non è in git).""" - percorso = RADICE / f"dati/gen_{request.param}.csv" + """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 diff --git a/tests/test_integrazione.py b/tests/test_integrazione.py index 0d3773e..aeeff70 100644 --- a/tests/test_integrazione.py +++ b/tests/test_integrazione.py @@ -1,4 +1,4 @@ -"""Integrazione sui CSV di gen_data (dati/gen_{1,2,3}.csv, ignorati da git: i test sono saltati se mancano) e sulla CLI.""" +"""Integrazione sui CSV di gen_data (input/gen_{1,2,3}.csv, ignorati da git: i test sono saltati se mancano) e sulla CLI.""" import pytest from conftest import RADICE @@ -36,22 +36,25 @@ def test_riscaldamento_eredita_costanti(csv_gen, cfg): def test_cli_domini_e_analizza(tmp_path, capsys): - csv = RADICE / "dati/gen_1.csv" + csv = RADICE / "input/gen_1.csv" if not csv.exists(): pytest.skip(f"{csv} assente") - lavoro = tmp_path / "lavoro" - base = ["--config", str(RADICE / "config.toml"), "--lavoro", str(lavoro)] + base = ["--config", str(RADICE / "config.toml"), "--output", str(tmp_path)] + out = tmp_path / "gen_1" cli.main([*base, "domini", str(csv)]) - assert (lavoro / "domini.toml").exists() and (lavoro / "proposta.png").exists() - assert not (lavoro / "out").exists() # `domini` non fa nessun fit - cli.main([*base, "analizza", str(csv), "--domini", str(lavoro / "domini.toml")]) # domini corretti a mano - for f in ("out/report.md", "out/risultati.json", "out/dominio4_T_C.png"): - assert (lavoro / f).exists(), f + assert (out / "01_domini/domini.toml").exists() and (out / "01_domini/domini.png").exists() + assert not (out / "03_report").exists() or not any((out / "03_report").iterdir()) # `domini` non fa nessun fit + cli.main([*base, "analizza", str(csv), "--domini", str(out / "01_domini/domini.toml")]) # domini corretti a mano + for f in ("02_sovrapposizione/sovrapposizione_T_C.png", "02_sovrapposizione/dominio4_T_C.png", + "03_report/report.md", "03_report/risultati.json"): + assert (out / f).exists(), f + report = (out / "03_report/report.md").read_text() + assert "../01_domini/domini.png" in report and "../02_sovrapposizione/dominio4_T_C.png" in report assert "dominio 4 raffreddamento" in capsys.readouterr().out def test_cli_errori(tmp_path): - base = ["--config", str(RADICE / "config.toml"), "--lavoro", str(tmp_path)] + base = ["--config", str(RADICE / "config.toml"), "--output", str(tmp_path)] with pytest.raises(SystemExit) as e: cli.main([*base, "domini", str(tmp_path / "non_esiste.csv")]) assert e.value.code == 4