Files
report-temperatura/report_completo.py
T
davideandClaude Sonnet 4.6 6579673518 report_completo: evita la rottura delle tabelle tra pagine
Ogni blocco (titolo + tabella) è avvolto in KeepTogether: simboli
del modello, campionamento e parametri per induttore, riepilogo finale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:11:35 +02:00

583 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
report_completo.py
Genera report_completo.tex e report_completo.pdf leggendo dinamicamente
tutti i report*.md presenti nelle cartelle induttore*/.
"""
import re
import tempfile
import shutil
from pathlib import Path
from datetime import date
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.platypus import (
BaseDocTemplate, PageTemplate, Frame,
Paragraph, Spacer, Table, TableStyle,
Image, HRFlowable, PageBreak, NextPageTemplate, KeepTogether,
)
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas as rl_canvas
# ── Identità aziendale ────────────────────────────────────────────────────────
SOCIETA = "Cevolani Italia s.r.l."
DIPARTIMENTO = "Ufficio Tecnico Meccanico"
DOC_NUM = "RT-TERM-001"
REVISIONE = "Rev. 00"
STATO = "Definitivo"
CLASSIFICAZIONE = "USO INTERNO"
TODAY = date.today()
BASE = Path(__file__).parent
W, H = A4
MARGIN = 2.5 * cm
CW = W - 2 * MARGIN
# ── Palette ───────────────────────────────────────────────────────────────────
C_DARK = colors.HexColor("#0f2236")
C_BLUE = colors.HexColor("#1a3a5c")
C_MID = colors.HexColor("#2c5f8a")
C_LIGHT = colors.HexColor("#4a8fc0")
C_GRAY = colors.HexColor("#f6f7f8")
C_BORDER = colors.HexColor("#b8c8d8")
C_WHITE = colors.white
C_TEXT = colors.HexColor("#1e1e1e")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 1. PARSING DEI REPORT MARKDOWN
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _strip(val: str, *suffixes) -> str:
for s in suffixes:
val = val.replace(s, "")
return val.strip()
def parse_report(md_path: Path) -> dict:
text = md_path.read_text(encoding="utf-8")
title = re.search(r"^# (.+)$", text, re.MULTILINE)
kv2 = {k.strip(): v.strip()
for k, v in re.findall(r"^\| (.+?) \| (.+?) \|$", text, re.MULTILINE)
if k.strip() != "---"}
kv3 = {r[0].strip(): (r[1].strip(), r[2].strip())
for r in re.findall(r"^\| (.+?) \| (.+?) \| (.+?) \|$", text, re.MULTILINE)
if r[0].strip() not in ("Parametro", "---")}
eq = re.search(r"\$\$(.+?)\$\$", text, re.DOTALL)
t0 = _strip(kv2.get("Inizio", ""), " s")
tend = _strip(kv2.get("Fine", ""), " s")
def param(key):
v, e = kv3.get(key, ("", ""))
return (_strip(v, " °C", " s"),
_strip(e, "± ", "+/- ", " °C", " s"))
A, A_err = param("A")
tau, tau_err = param("τ")
return {
"nome": title.group(1).strip() if title else md_path.parent.name,
"t0": t0,
"t_end": tend,
"intervallo": f"{t0} – {tend} s",
"n_punti": kv2.get("Numero di punti", ""),
"zona_exc": kv2.get("Zona esclusa (peso = 0)"),
"eq_latex": eq.group(1).strip() if eq else "",
"A": A, "A_err": A_err,
"tau": tau, "tau_err": tau_err,
"r2": kv2.get("R²", ""),
"png": next(iter(sorted(md_path.parent.glob("*.png"))), None),
}
def collect_induttori() -> list[dict]:
return [
parse_report(md)
for folder in sorted(BASE.glob("induttore*/"))
for md in sorted(folder.glob("report*.md"))
]
def read_t_inf() -> str:
"""Legge T∞ da start/report.md generato da calcola_t_amb.py."""
report = BASE / "start" / "report.md"
if not report.exists():
return "N/D"
m = re.search(r"media ponderata\*\*\s*\|\s*\*\*([0-9.]+)\s*°C",
report.read_text(encoding="utf-8"))
return m.group(1) if m else "N/D"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 2. RENDERING EQUAZIONI CON MATPLOTLIB
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _save_math_png(latex: str, tmpdir: Path, prefix: str,
figsize, fontsize: int, dpi: int, pad: float) -> Path:
"""Renderizza una stringa LaTeX math come PNG trasparente."""
fig = plt.figure(figsize=figsize)
ax = fig.add_axes([0, 0, 1, 1])
ax.text(0.5, 0.5, f"${latex}$",
ha="center", va="center", fontsize=fontsize,
transform=ax.transAxes, color="#0f2236")
ax.axis("off")
out = tmpdir / f"{prefix}_{abs(hash(latex))}.png"
fig.savefig(out, dpi=dpi, bbox_inches="tight",
transparent=True, pad_inches=pad)
plt.close(fig)
return out
def render_eq(latex: str, tmpdir: Path,
width_cm: float = 13, fontsize: int = 16) -> Image:
"""Equazione a blocco, larghezza piena, altezza proporzionale al PNG."""
out = _save_math_png(latex, tmpdir, "eq",
figsize=(width_cm / 2.54, 1.5),
fontsize=fontsize, dpi=220, pad=0.25)
iw, ih = ImageReader(str(out)).getSize()
w_pt = width_cm * cm
return Image(str(out), width=w_pt, height=w_pt * ih / iw)
def render_sym(latex: str, tmpdir: Path, width_cm: float = 1.3) -> Image:
"""Simbolo matematico per cella tabella — stesso stile di render_eq."""
out = _save_math_png(latex, tmpdir, "sym",
figsize=(width_cm / 2.54, 0.6),
fontsize=13, dpi=220, pad=0.12)
iw, ih = ImageReader(str(out)).getSize()
w_pt = width_cm * cm
return Image(str(out), width=w_pt, height=w_pt * ih / iw)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 3. GENERAZIONE .TEX
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def generate_tex(induttori: list[dict], t_inf: str):
today_str = TODAY.strftime("%d/%m/%Y")
lines = [
r"\documentclass[11pt,a4paper]{article}",
r"\usepackage[utf8]{inputenc}",
r"\usepackage[T1]{fontenc}",
r"\usepackage[margin=2.5cm]{geometry}",
r"\usepackage{amsmath,amssymb,booktabs,graphicx,float}",
r"\usepackage[table]{xcolor}",
r"\usepackage[colorlinks,linkcolor=blue,urlcolor=blue]{hyperref}",
r"\usepackage{fancyhdr}",
r"\definecolor{darkblue}{HTML}{1a3a5c}",
r"\pagestyle{fancy}\fancyhf{}",
r"\lhead{\textcolor{darkblue}{\small\textbf{" + SOCIETA + r"}}}",
r"\chead{\textcolor{darkblue}{\small Analisi Termica Forno}}",
r"\rhead{\textcolor{darkblue}{\small " + DOC_NUM + r"\ --\ " + REVISIONE + r"}}",
r"\lfoot{\textcolor{gray}{\tiny " + CLASSIFICAZIONE + r"}}",
r"\cfoot{\textcolor{gray}{\tiny " + DOC_NUM + r"\ |\ " + REVISIONE + r"\ |\ " + today_str + r"}}",
r"\rfoot{\thepage}",
rf"\title{{\textbf{{Report Analisi Termica Forno}}\\[0.4em]"
rf"\large Caratterizzazione termica degli induttori}}",
rf"\author{{{DIPARTIMENTO} \\ {SOCIETA}}}",
r"\date{\today}",
r"\begin{document}",
r"\maketitle\tableofcontents\newpage",
"",
r"\section{Modello di fitting}",
r"Ogni intervallo è interpolato con la legge di raffreddamento di Newton:",
r"\begin{equation}",
r" T(t) = T_{\infty} + A\,e^{-(t-t_0)/\tau}",
r"\end{equation}",
rf"con $T_{{\infty}}={t_inf.replace('.', '{,}')}\,^{{\circ}}\mathrm{{C}}$ fissa (media ponderata),"
r" parametri $A$ e $\tau$ liberi stimati con TRF (\texttt{scipy.optimize.curve\_fit}).",
"",
]
for ind in induttori:
lines += [
rf"\section{{{ind['nome']}}}",
r"\subsection{Intervallo di campionamento}",
r"\begin{tabular}{ll}\toprule",
r"\textbf{Parametro}&\textbf{Valore}\\\midrule",
rf"Inizio&${ind['t0']}$ s\\",
rf"Fine&${ind['t_end']}$ s\\",
rf"N.\,punti&{ind['n_punti']}\\",
]
if ind["zona_exc"]:
lines.append(rf"Zona esclusa&{ind['zona_exc']}\\")
lines += [
r"\bottomrule\end{tabular}",
r"\subsection{Equazione trovata}",
r"\begin{equation}", f" {ind['eq_latex']}", r"\end{equation}",
r"\begin{tabular}{lll}\toprule",
r"\textbf{Param.}&\textbf{Valore}&\textbf{Incert.\,($1\sigma$)}\\\midrule",
rf"$A$&${ind['A']}\,^{{\circ}}\mathrm{{C}}$&$\pm\,{ind['A_err']}\,^{{\circ}}\mathrm{{C}}$\\",
rf"$\tau$&${ind['tau']}\,\mathrm{{s}}$&$\pm\,{ind['tau_err']}\,\mathrm{{s}}$\\",
rf"$R^2$&\multicolumn{{2}}{{l}}{{{ind['r2']}}}\\",
r"\bottomrule\end{tabular}",
r"\subsection{Grafico}",
r"\begin{figure}[H]\centering",
rf"\includegraphics[width=.9\textwidth]{{{ind['png']}}}",
rf"\caption{{Fit esponenziale — {ind['nome']} [{ind['intervallo']}]}}",
r"\end{figure}\newpage",
]
lines += [
r"\section{Riepilogo}",
r"\begin{tabular}{lllll}\toprule",
r"\textbf{Induttore}&\textbf{Intervallo [s]}&\textbf{$A$ [°C]}"
r"&\textbf{$\tau$ [s]}&\textbf{$R^2$}\\\midrule",
]
for ind in induttori:
lines.append(
rf"{ind['nome']}&{ind['intervallo']}&{ind['A']}&{ind['tau']}&{ind['r2']}\\"
)
lines += [r"\bottomrule\end{tabular}", r"\end{document}"]
(BASE / "report_completo.tex").write_text("\n".join(lines), encoding="utf-8")
print("report_completo.tex generato.")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 4. CANVAS CON HEADER / FOOTER
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Altezza header: riga 1 (azienda / titolo / docnum) + riga 2 (dip. / data)
HDR_H = 1.6 * cm
class ProfessionalCanvas(rl_canvas.Canvas):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._saved = []
def showPage(self):
self._saved.append(dict(self.__dict__))
self._startPage()
def save(self):
n = len(self._saved)
for state in self._saved:
self.__dict__.update(state)
if self._pageNumber > 1:
self._draw_header()
self._draw_footer(n)
super().showPage()
super().save()
def _draw_header(self):
self.saveState()
# Sfondo intero header
self.setFillColor(C_DARK)
self.rect(0, H - HDR_H, W, HDR_H, fill=1, stroke=0)
# Striscia laterale colorata
self.setFillColor(C_LIGHT)
self.rect(0, H - HDR_H, 0.4 * cm, HDR_H, fill=1, stroke=0)
# Linea separatrice tra riga 1 e riga 2
self.setStrokeColor(colors.HexColor("#1e4a72"))
self.setLineWidth(0.4)
self.line(0.6 * cm, H - HDR_H * 0.55, W - 0.5 * cm, H - HDR_H * 0.55)
# -- Riga 1: azienda | titolo documento | codice + revisione --
y1 = H - HDR_H * 0.38
self.setFillColor(C_WHITE)
self.setFont("Helvetica-Bold", 8)
self.drawString(0.65 * cm, y1, SOCIETA.upper())
self.setFont("Helvetica", 7.5)
self.drawCentredString(W / 2, y1, "ANALISI TERMICA FORNO")
self.setFont("Helvetica-Bold", 7.5)
self.drawRightString(W - 0.5 * cm, y1, f"{DOC_NUM} | {REVISIONE}")
# -- Riga 2: dipartimento | data --
y2 = H - HDR_H * 0.75
self.setFillColor(colors.HexColor("#8ab4d0"))
self.setFont("Helvetica", 6.5)
self.drawString(0.65 * cm, y2, DIPARTIMENTO)
self.drawRightString(W - 0.5 * cm, y2, TODAY.strftime("%d/%m/%Y"))
self.restoreState()
def _draw_footer(self, total):
self.saveState()
# Linea superiore footer
self.setStrokeColor(C_MID)
self.setLineWidth(0.6)
self.line(MARGIN, 1.9 * cm, W - MARGIN, 1.9 * cm)
# Classificazione (sinistra)
self.setFillColor(C_BLUE)
self.setFont("Helvetica-Bold", 7)
self.drawString(MARGIN, 1.5 * cm, CLASSIFICAZIONE)
# Riferimento documento (centro)
self.setFillColor(C_MID)
self.setFont("Helvetica", 7)
self.drawCentredString(
W / 2, 1.5 * cm,
f"{DOC_NUM} | {REVISIONE} | {STATO} | {TODAY.strftime('%d/%m/%Y')}"
)
# Numero pagina (destra)
self.setFillColor(C_BLUE)
self.setFont("Helvetica-Bold", 8)
self.drawRightString(W - MARGIN, 1.5 * cm,
f"Pag. {self._pageNumber} / {total}")
self.restoreState()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 5. COMPONENTI PDF
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _styles() -> dict:
base = getSampleStyleSheet()
mono = ParagraphStyle("mono", fontName="Courier", fontSize=9, textColor=C_TEXT)
return {
"body": ParagraphStyle("body", parent=base["BodyText"],
fontSize=9.5, leading=14.5, textColor=C_TEXT,
spaceAfter=6),
"mono": mono,
"h1": ParagraphStyle("h1", fontName="Helvetica-Bold", fontSize=12,
textColor=C_WHITE, leading=16, spaceAfter=2),
"h2": ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=10,
textColor=C_BLUE, spaceBefore=10, spaceAfter=4,
leading=13),
"cap": ParagraphStyle("cap", fontName="Helvetica-Oblique", fontSize=8,
textColor=colors.HexColor("#555555"),
alignment=TA_CENTER, spaceBefore=2),
"cov_title": ParagraphStyle("cov_title", fontName="Helvetica-Bold",
fontSize=24, textColor=C_BLUE,
alignment=TA_CENTER, leading=30),
"cov_sub": ParagraphStyle("cov_sub", fontName="Helvetica",
fontSize=12, textColor=C_MID,
alignment=TA_CENTER, leading=17),
}
def _ts(hdr_color=None) -> TableStyle:
hc = hdr_color or C_MID
return TableStyle([
("BACKGROUND", (0, 0), (-1, 0), hc),
("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 9),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [C_WHITE, C_GRAY]),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.4, C_BORDER),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 7),
("RIGHTPADDING", (0, 0), (-1, -1), 7),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
])
def _ts_cover() -> TableStyle:
"""Stile per la scheda documento in copertina."""
return TableStyle([
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
("FONTSIZE", (0, 0), (-1, -1), 10),
("TEXTCOLOR", (0, 0), (0, -1), C_BLUE),
("TEXTCOLOR", (1, 0), (1, -1), C_TEXT),
("ROWBACKGROUNDS",(0, 0), (-1, -1), [C_WHITE, C_GRAY]),
("GRID", (0, 0), (-1, -1), 0.5, C_BORDER),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 14),
("RIGHTPADDING", (0, 0), (-1, -1), 14),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
# Riga classificazione in evidenza
("BACKGROUND", (0, 5), (-1, 5), colors.HexColor("#fff3cd")),
("TEXTCOLOR", (1, 5), (1, 5), colors.HexColor("#7a4f00")),
("FONTNAME", (1, 5), (1, 5), "Helvetica-Bold"),
])
def _section_banner(title: str, s_h1) -> list:
tbl = Table([[Paragraph(title, s_h1)]], colWidths=[CW])
tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_BLUE),
("LEFTPADDING", (0, 0), (-1, -1), 12),
("RIGHTPADDING", (0, 0), (-1, -1), 12),
("TOPPADDING", (0, 0), (-1, -1), 7),
("BOTTOMPADDING", (0, 0), (-1, -1), 7),
]))
return [tbl, Spacer(1, 0.2 * cm)]
def _cover(s: dict, n: int) -> list:
# Intestazione aziendale
banner = Table([[
Paragraph(
f"<font color='white' size='11'><b>{SOCIETA.upper()}</b></font>",
ParagraphStyle("_bl", alignment=TA_LEFT)),
Paragraph(
f"<font color='#4a8fc0' size='9'>{DIPARTIMENTO}</font>",
ParagraphStyle("_br", alignment=TA_RIGHT)),
]], colWidths=[CW * 0.6, CW * 0.4],
style=TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_DARK),
("TOPPADDING", (0, 0), (-1, -1), 14),
("BOTTOMPADDING", (0, 0), (-1, -1), 14),
("LEFTPADDING", (0, 0), (-1, -1), 16),
("RIGHTPADDING", (0, 0), (-1, -1), 16),
]))
# Scheda documento
doc_table = Table([
["Codice documento", DOC_NUM],
["Revisione", REVISIONE],
["Data di emissione", TODAY.strftime("%d %B %Y")],
["Stato", STATO],
["Redatto da", DIPARTIMENTO],
["Classificazione", CLASSIFICAZIONE],
["Induttori analizzati", str(n)],
], colWidths=[6 * cm, CW - 6 * cm], style=_ts_cover())
return [
banner,
Spacer(1, 3.8 * cm),
HRFlowable(width="100%", thickness=5, color=C_BLUE),
Spacer(1, 0.45 * cm),
Paragraph("Report Analisi Termica Forno", s["cov_title"]),
Spacer(1, 0.25 * cm),
Paragraph("Caratterizzazione termica degli induttori", s["cov_sub"]),
Spacer(1, 0.45 * cm),
HRFlowable(width="100%", thickness=5, color=C_BLUE),
Spacer(1, 1.8 * cm),
doc_table,
NextPageTemplate("normal"),
PageBreak(),
]
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 6. GENERAZIONE PDF
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def generate_pdf(induttori: list[dict], t_inf: str):
s = _styles()
tmpdir = Path(tempfile.mkdtemp())
frame_cover = Frame(MARGIN, MARGIN, CW, H - 2 * MARGIN, id="cover")
frame_normal = Frame(MARGIN, 2.6 * cm, CW, H - HDR_H - 2.6 * cm - 0.3 * cm,
id="normal", topPadding=0)
doc = BaseDocTemplate(str(BASE / "report_completo.pdf"), pagesize=A4)
doc.addPageTemplates([
PageTemplate(id="cover", frames=[frame_cover]),
PageTemplate(id="normal", frames=[frame_normal]),
])
story: list = []
# ── Copertina ──
story += _cover(s, len(induttori))
# ── 1. Modello di fitting ──
story += _section_banner("1. Modello di fitting", s["h1"])
story.append(Paragraph(
"Ogni tratto di acquisizione è interpolato con la legge di "
"raffreddamento di Newton (convezione termica):", s["body"]))
story.append(Spacer(1, 0.1 * cm))
story.append(KeepTogether([
render_eq(r"T(t) = T_{\infty} + A\,e^{-(t-t_0)/\tau}", tmpdir),
Spacer(1, 0.15 * cm),
Table([
["Simbolo", "Significato", "Unità"],
[render_sym(r"T_{\infty}", tmpdir), "Temperatura ambiente — valore fisso", "°C"],
[render_sym(r"A", tmpdir), "Ampiezza iniziale — parametro libero", "°C"],
[render_sym(r"t_0", tmpdir), "Istante di riferimento — valore fisso", "s"],
[render_sym(r"\tau", tmpdir), "Costante di tempo — parametro libero", "s"],
], colWidths=[2.5 * cm, 9.5 * cm, CW - 12 * cm], style=_ts()),
]))
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph(
"La regressione usa il metodo <i>Trust Region Reflective</i> (TRF) "
"(<font name='Courier'>scipy.optimize.curve_fit</font>). "
f"T∞ = {t_inf} °C è la media ponderata sul tempo dell'intera "
"acquisizione (regola dei trapezi, "
"<font name='Courier'>calcola_t_amb.py</font>).",
s["body"]))
story.append(Spacer(1, 0.4 * cm))
# ── Sezioni induttori ──
for i, ind in enumerate(induttori, 2):
story += _section_banner(f"{i}. {ind['nome']}", s["h1"])
# Campionamento
rows = [["Parametro", "Valore"],
["Inizio intervallo", f"{ind['t0']} s"],
["Fine intervallo", f"{ind['t_end']} s"],
["Numero di punti", ind["n_punti"]]]
if ind["zona_exc"]:
rows.append(["Zona esclusa (σ → ∞)", ind["zona_exc"]])
story.append(KeepTogether([
Paragraph("Intervallo di campionamento", s["h2"]),
Table(rows, colWidths=[7 * cm, CW - 7 * cm], style=_ts()),
]))
story.append(Spacer(1, 0.3 * cm))
# Equazione
story.append(KeepTogether([
Paragraph("Equazione del modello", s["h2"]),
render_eq(ind["eq_latex"], tmpdir),
Spacer(1, 0.2 * cm),
Table([
["Parametro", "Valore stimato", "Incertezza (1σ)", "Descrizione"],
[render_sym(r"A", tmpdir), f"{ind['A']} °C", f"± {ind['A_err']} °C", "Ampiezza"],
[render_sym(r"\tau", tmpdir), f"{ind['tau']} s", f"± {ind['tau_err']} s", "Costante di tempo"],
[render_sym(r"R^2", tmpdir), ind["r2"], "—", "Qualità del fit"],
], colWidths=[2.2 * cm, 4.2 * cm, 4 * cm, CW - 10.4 * cm], style=_ts()),
]))
story.append(Spacer(1, 0.3 * cm))
# Grafico
if ind["png"] and Path(ind["png"]).exists():
story.append(Paragraph("Grafico del fit", s["h2"]))
story.append(KeepTogether([
Image(str(ind["png"]), width=CW, height=CW * 0.6),
Paragraph(
f"Fig. {i - 1} — Fit esponenziale: {ind['nome']} "
f"[{ind['intervallo']}]", s["cap"]),
]))
story.append(Spacer(1, 0.5 * cm))
# ── Riepilogo ──
story.append(PageBreak())
n_sec = len(induttori) + 2
story += _section_banner(f"{n_sec}. Riepilogo comparativo", s["h1"])
summary = [["Induttore", "Intervallo [s]", "A [°C]", "τ [s]", "R²"]]
for ind in induttori:
summary.append([ind["nome"], ind["intervallo"],
ind["A"], ind["tau"], ind["r2"]])
story.append(KeepTogether([
Paragraph(
"Parametri ottimali dalla regressione non lineare per ciascun induttore. "
"R² calcolato escludendo i punti con peso nullo.", s["body"]),
Spacer(1, 0.2 * cm),
Table(summary,
colWidths=[3.5 * cm, 4.5 * cm, 3 * cm, 3 * cm, CW - 14 * cm],
style=_ts(C_DARK)),
]))
doc.build(story, canvasmaker=ProfessionalCanvas)
shutil.rmtree(tmpdir, ignore_errors=True)
print(f"report_completo.pdf generato ({len(induttori)} induttori).")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ENTRY POINT
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if __name__ == "__main__":
induttori = collect_induttori()
if not induttori:
print("Nessun report*.md trovato nelle cartelle induttore*/.")
else:
t_inf = read_t_inf()
generate_tex(induttori, t_inf)
generate_pdf(induttori, t_inf)