import re from pathlib import Path 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 from reportlab.platypus import (BaseDocTemplate, PageTemplate, Frame, Paragraph, Spacer, Table, TableStyle, Image, HRFlowable, PageBreak, NextPageTemplate, KeepTogether) from reportlab.pdfgen import canvas as rl_canvas from datetime import date BASE = Path(__file__).parent AUTORE = "Ufficio Tecnico – Cevolani S.p.A." VERSIONE = "1.0" STATO = "Definitivo" # ─── Palette ────────────────────────────────────────────────────────────────── C_DARK = colors.HexColor("#0f2236") C_BLUE = colors.HexColor("#1a3a5c") C_MID = colors.HexColor("#2c5f8a") C_LIGHT = colors.HexColor("#4a8fc0") C_ACCENT = colors.HexColor("#e8f2fa") C_GRAY = colors.HexColor("#f6f7f8") C_BORDER = colors.HexColor("#b8c8d8") C_TEXT = colors.HexColor("#1e1e1e") C_WHITE = colors.white W, H = A4 MARGIN = 2.5 * cm CONTENT_W = W - 2 * MARGIN # ─── TableStyle ─────────────────────────────────────────────────────────────── def make_table_style(header_color=None): hc = header_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), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("LEFTPADDING", (0, 0), (-1, -1), 8), ("RIGHTPADDING", (0, 0), (-1, -1), 8), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ]) TABLE_STD = make_table_style() TABLE_DARK = make_table_style(C_DARK) COVER_INFO_STYLE = TableStyle([ ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), ("FONTSIZE", (0, 0), (-1, -1), 10), ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), ("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.4, C_BORDER), ("TOPPADDING", (0, 0), (-1, -1), 7), ("BOTTOMPADDING", (0, 0), (-1, -1), 7), ("LEFTPADDING", (0, 0), (-1, -1), 12), ]) # ─── Parser markdown ────────────────────────────────────────────────────────── def parse_md(md_path: Path) -> dict: text = md_path.read_text(encoding="utf-8") data = {} m = re.search(r"^# (.+)$", text, re.MULTILINE) data["nome"] = m.group(1).strip() if m else md_path.parent.name kv = dict(re.findall(r"^\| (.+?) \| (.+?) \|$", text, re.MULTILINE)) kv = {k.strip(): v.strip() for k, v in kv.items() if k.strip() != "---"} data["t0"] = kv.get("Inizio", "").replace(" s", "") data["t_end"] = kv.get("Fine", "").replace(" s", "") data["n_punti"] = kv.get("Numero di punti", "") data["zona_exc"] = kv.get("Zona esclusa (peso = 0)", None) data["r2"] = kv.get("R²", "") data["intervallo"] = f"{data['t0']} – {data['t_end']} s" m = re.search(r"\$\$(.+?)\$\$", text, re.DOTALL) data["equazione_latex"] = m.group(1).strip() if m else "" data["equazione"] = (data["equazione_latex"] .replace(r"\cdot", "·").replace(r"\infty", "∞")) rows3 = re.findall(r"^\| (.+?) \| (.+?) \| (.+?) \|$", text, re.MULTILINE) params = {r[0].strip(): (r[1].strip(), r[2].strip()) for r in rows3 if r[0].strip() not in ("Parametro", "---")} data["A"] = params.get("A", ("", ""))[0].replace(" °C", "") data["A_err"] = (params.get("A", ("", ""))[1] .replace("± ", "").replace("+/- ", "").replace(" °C", "")) data["tau"] = params.get("τ", ("", ""))[0].replace(" s", "") data["tau_err"] = (params.get("τ", ("", ""))[1] .replace("± ", "").replace("+/- ", "").replace(" s", "")) pngs = sorted(md_path.parent.glob("*.png")) data["png"] = pngs[0] if pngs else None return data def collect_induttori() -> list: return [parse_md(md) for folder in sorted(BASE.glob("induttore*/")) for md in sorted(folder.glob("report*.md"))] # ─── Canvas con header/footer ───────────────────────────────────────────────── 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() # Barra colorata in cima self.setFillColor(C_DARK) self.rect(0, H - 1.4*cm, W, 1.4*cm, fill=1, stroke=0) self.setFillColor(C_LIGHT) self.rect(0, H - 1.4*cm, 0.5*cm, 1.4*cm, fill=1, stroke=0) self.setFillColor(C_WHITE) self.setFont("Helvetica-Bold", 9) self.drawString(0.8*cm, H - 0.9*cm, "ANALISI TERMICA FORNO") self.setFont("Helvetica", 8) self.drawRightString(W - 0.6*cm, H - 0.9*cm, "Cevolani S.p.A. — Uso interno") self.restoreState() def _draw_footer(self, total): self.saveState() self.setStrokeColor(C_MID) self.setLineWidth(0.8) self.line(MARGIN, 1.9*cm, W - MARGIN, 1.9*cm) self.setFillColor(C_BLUE) self.setFont("Helvetica", 7.5) self.drawString(MARGIN, 1.4*cm, f"Documento: report_completo | Ver. {VERSIONE} | {STATO}") self.setFont("Helvetica-Bold", 8) self.drawRightString(W - MARGIN, 1.4*cm, f"Pag. {self._pageNumber} / {total}") self.restoreState() # ─── Stili testo ────────────────────────────────────────────────────────────── def make_styles(): styles = getSampleStyleSheet() s = {} s["body"] = ParagraphStyle("body", parent=styles["BodyText"], fontSize=9.5, leading=14, textColor=C_TEXT, spaceAfter=6) s["h1"] = ParagraphStyle("h1", fontName="Helvetica-Bold", fontSize=14, textColor=C_WHITE, spaceBefore=16, spaceAfter=2, leftIndent=-0.3*cm, leading=18) s["h2"] = ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=10.5, textColor=C_BLUE, spaceBefore=10, spaceAfter=4, leading=14) s["eq"] = ParagraphStyle("eq", fontName="Courier", fontSize=9.5, leftIndent=18, spaceBefore=4, spaceAfter=6, backColor=C_ACCENT, borderPad=7, leading=14) s["caption"] = ParagraphStyle("caption", fontName="Helvetica-Oblique", fontSize=8.5, textColor=colors.HexColor("#555555"), alignment=TA_CENTER, spaceAfter=4) return s def section_header(title: str, s_h1) -> list: """Barra blu con titolo sezione.""" tbl = Table([[Paragraph(title, s_h1)]], colWidths=[CONTENT_W]) tbl.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, -1), C_BLUE), ("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10), ("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING",(0, 0), (-1, -1), 6), ])) return [tbl, Spacer(1, 0.25*cm)] # ─── Genera .tex ────────────────────────────────────────────────────────────── def generate_tex(induttori: list): today = date.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}", r"\usepackage{booktabs}", r"\usepackage{graphicx}", r"\usepackage{float}", r"\usepackage[table]{xcolor}", r"\usepackage{hyperref}", r"\usepackage{fancyhdr}", r"\definecolor{darkblue}{HTML}{1a3a5c}", r"\pagestyle{fancy}\fancyhf{}", r"\rhead{\textcolor{darkblue}{\small Cevolani S.p.A.}}", r"\lhead{\textcolor{darkblue}{\small Analisi Termica Forno}}", r"\rfoot{\thepage}", r"\lfoot{\textcolor{gray}{\tiny Ver. " + VERSIONE + r" -- " + today + r"}}", rf"\title{{\textbf{{Report Analisi Termica Forno}}\\[0.5em]\large Caratterizzazione termica degli induttori}}", rf"\author{{{AUTORE}}}", r"\date{\today}", r"\begin{document}", r"\maketitle", r"\tableofcontents", r"\newpage", "", r"\section{Modello di fitting}", r"Ogni intervallo è interpolato con il modello di raffreddamento di Newton:", r"\begin{equation}", r" T(t) = T_\infty + A \cdot e^{-(t-t_0)/\tau}", r"\end{equation}", r"dove $T_\infty = 22{,}99\,^\circ\mathrm{C}$ è la temperatura ambiente media ponderata.", r"La regressione non lineare usa il metodo \textit{Trust Region Reflective} (TRF)", r"di \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"Numero di punti & {ind['n_punti']} \\", ] if ind["zona_exc"]: lines.append(rf"Zona esclusa (peso = 0) & {ind['zona_exc']} \\") lines += [r"\bottomrule\end{tabular}", "", r"\subsection{Equazione trovata}", r"\begin{equation}", f" {ind['equazione_latex']}", r"\end{equation}", r"\begin{tabular}{lll}\toprule", r"\textbf{Param.} & \textbf{Valore} & \textbf{Incertezza ($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=0.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]} & \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.") # ─── Genera PDF ─────────────────────────────────────────────────────────────── def generate_pdf(induttori: list): s = make_styles() today_str = date.today().strftime("%d %B %Y") frame_cover = Frame(MARGIN, MARGIN, CONTENT_W, H - 2*MARGIN, id="cover") frame_normal = Frame(MARGIN, 2.5*cm, CONTENT_W, H - 2.5*cm - 1.6*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 = [] # ── COVER PAGE ────────────────────────────────────────────────────────────── # Barra superiore scura story.append(Table( [[Paragraph("CEVOLANI S.p.A.", ParagraphStyle("ch", alignment=TA_LEFT)), Paragraph("Analisi Termica — Uso Interno", ParagraphStyle("cr", alignment=TA_LEFT))]], colWidths=[CONTENT_W * 0.55, CONTENT_W * 0.45], style=TableStyle([ ("BACKGROUND", (0, 0), (-1, -1), C_DARK), ("TOPPADDING", (0, 0), (-1, -1), 10), ("BOTTOMPADDING", (0, 0), (-1, -1), 10), ("LEFTPADDING", (0, 0), (-1, -1), 14), ]) )) story.append(Spacer(1, 4*cm)) # Titolo principale story.append(HRFlowable(width="100%", thickness=4, color=C_BLUE)) story.append(Spacer(1, 0.4*cm)) story.append(Paragraph( "Report Analisi Termica Forno", ParagraphStyle("ct", alignment=TA_CENTER, leading=32))) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph( "Caratterizzazione termica degli induttori", ParagraphStyle("cs", alignment=TA_CENTER, leading=18))) story.append(Spacer(1, 0.4*cm)) story.append(HRFlowable(width="100%", thickness=4, color=C_BLUE)) story.append(Spacer(1, 2*cm)) # Scheda documento doc_info = [ ["Documento", "report_completo"], ["Autore", AUTORE], ["Data di emissione", today_str], ["Versione", VERSIONE], ["Stato", STATO], ["Induttori analizzati", str(len(induttori))], ] story.append(Table(doc_info, colWidths=[5.5*cm, CONTENT_W - 5.5*cm], style=COVER_INFO_STYLE)) story.append(NextPageTemplate("normal")) story.append(PageBreak()) # ── MODELLO ───────────────────────────────────────────────────────────────── story += section_header("1. Modello di fitting", s["h1"]) story.append(Paragraph( "Ogni intervallo di acquisizione è interpolato con la legge di " "raffreddamento di Newton, che descrive lo scambio termico convettivo " "tra il corpo e l'ambiente:", s["body"])) story.append(Paragraph("T(t) = T_inf + A · exp( -(t - t₀) / τ )", s["eq"])) story.append(Table([ ["Simbolo", "Significato", "Unità"], ["T_inf", "Temperatura ambiente (fissa)", "°C"], ["A", "Ampiezza iniziale (libera)", "°C"], ["t₀", "Istante di riferimento (fisso)", "s"], ["τ", "Costante di tempo (libera)", "s"], ], colWidths=[3*cm, 9*cm, 3*cm], style=TABLE_STD)) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph( "La regressione non lineare è eseguita con il metodo " "Trust Region Reflective (TRF) tramite " "scipy.optimize.curve_fit. " f"T_inf = 22.99 °C è la media ponderata sul tempo dell'intera acquisizione " "(calcolata con la regola dei trapezi).", s["body"])) story.append(Spacer(1, 0.4*cm)) # ── SEZIONI INDUTTORI ──────────────────────────────────────────────────────── for i, ind in enumerate(induttori, 1): story += section_header(f"{i + 1}. {ind['nome']}", s["h1"]) # Campionamento story.append(Paragraph("Intervallo di campionamento", s["h2"])) 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 dal fit (σ → ∞)", ind["zona_exc"]]) story.append(Table(rows, colWidths=[8*cm, CONTENT_W - 8*cm], style=TABLE_STD)) story.append(Spacer(1, 0.3*cm)) # Equazione story.append(Paragraph("Equazione del modello", s["h2"])) story.append(Paragraph(ind["equazione"], s["eq"])) story.append(Spacer(1, 0.2*cm)) story.append(Paragraph("Parametri stimati dalla regressione:", s["body"])) story.append(Table([ ["Parametro", "Valore stimato", "Incertezza (1σ)", "Note"], ["A", f"{ind['A']} °C", f"± {ind['A_err']} °C", "Ampiezza"], ["τ", f"{ind['tau']} s", f"± {ind['tau_err']} s","Costante di tempo"], ["R²", ind["r2"], "—", "Qualità del fit"], ], colWidths=[2.5*cm, 4.5*cm, 4*cm, CONTENT_W - 11*cm], style=TABLE_STD)) 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=CONTENT_W, height=CONTENT_W * 0.6), Paragraph( f"Figura {i}: fit esponenziale — {ind['nome']} " f"[{ind['intervallo']}]", s["caption"]), ])) story.append(Spacer(1, 0.5*cm)) # ── RIEPILOGO ──────────────────────────────────────────────────────────────── story.append(PageBreak()) story += section_header(f"{len(induttori) + 2}. Riepilogo comparativo", s["h1"]) story.append(Paragraph( "Tabella comparativa dei parametri ottimali ottenuti dalla regressione " "non lineare per ciascun induttore analizzato.", s["body"])) story.append(Spacer(1, 0.2*cm)) 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(Table(summary, colWidths=[3.5*cm, 4.5*cm, 3*cm, 3*cm, CONTENT_W - 14*cm], style=TABLE_DARK)) story.append(Spacer(1, 0.5*cm)) story.append(Paragraph( "Note: tutti i fit usano T_inf = 22.99 °C fissa. " "R² calcolato escludendo i punti con peso nullo.", s["body"])) doc.build(story, canvasmaker=ProfessionalCanvas) 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: generate_tex(induttori) generate_pdf(induttori)