""" 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"{SOCIETA.upper()}", ParagraphStyle("_bl", alignment=TA_LEFT)), Paragraph( f"{DIPARTIMENTO}", 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 Trust Region Reflective (TRF) " "(scipy.optimize.curve_fit). " f"T∞ = {t_inf} °C è la media ponderata sul tempo dell'intera " "acquisizione (regola dei trapezi, " "calcola_t_amb.py).", 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)