#!/usr/bin/env python3 """ sync_bom.py — Excel -> LaTeX BOM synchronization (Helgen EinarBoard 1.0) Reads helgen_einarboard_1.0_production_bom.xlsx and writes TWO LaTeX snippets: pcba_bom.tex <- tab "Internal PCBA BOM" (on-board, per board) external_bom.tex <- tab "External Integration BOM" (per HBOX unit) Both are pulled into einarboard-manual.tex via \\input{}. Filters section bars / blank rows / TOTAL rows, escapes LaTeX reserved tokens, and recomputes line costs (openpyxl stores formulas without cached values). The Excel workbook is the single source of truth for BOM content. Edit the workbook (or its generator), re-run this, recompile the manual. Usage: python3 sync_bom.py [workbook.xlsx] [outdir] """ import sys from datetime import date from pathlib import Path from openpyxl import load_workbook HERE = Path(__file__).resolve().parent # LaTeX reserved-token escape map (backslash first). _ESC = [("\\", r"\textbackslash{}"), ("&", r"\&"), ("%", r"\%"), ("$", r"\$"), ("#", r"\#"), ("_", r"\_"), ("{", r"\{"), ("}", r"\}"), ("~", r"\textasciitilde{}"), ("^", r"\textasciicircum{}")] def esc(v) -> str: if v is None: return "" s = str(v).strip() for raw, rep in _ESC: s = s.replace(raw, rep) return s def num(v): try: return float(v) except (TypeError, ValueError): return None def find_header(ws): for row in ws.iter_rows(min_row=1, max_row=12): if str(row[0].value).strip() == "Item": return row[0].row sys.exit(f"sync_bom: header row ('Item') not found on '{ws.title}'") def clean_rows(ws, ncols, key_cols, qty_col, unit_col): """Yield escaped column tuples + (qty, unit, line) numerics for data rows.""" hdr = find_header(ws) for row in ws.iter_rows(min_row=hdr + 1, values_only=True): cells = (list(row) + [None] * ncols)[:ncols] # Filters: section bars (text only in col A), blanks, Excel TOTAL rows. if any(cells[k] in (None, "") for k in key_cols): continue a = str(cells[0]).strip() if cells[0] is not None else "" if a.upper().startswith("TOTAL") or str(cells[3]).upper().startswith("TOTAL"): continue q, u = num(cells[qty_col]), num(cells[unit_col]) if q is None or u is None: continue yield cells, q, u, q * u HEADER_CELLS = lambda names: ( r"\rowcolor{HelgenIndigo}" + "\n" + " & ".join(rf"\textcolor{{white}}{{\textbf{{{n}}}}}" for n in names) + r" \\") def longtable(colspec, caption, label, header_names, body_lines, total_label, total): hdr = HEADER_CELLS(header_names) n = len(header_names) return "\n".join([ r"\begin{small}", r"\setlength{\LTleft}{0pt}\setlength{\LTright}{0pt}", rf"\begin{{longtable}}{{{colspec}}}", rf"\caption{{{caption}}}", rf"\label{{{label}}}\\", r"\toprule", hdr, r"\midrule\endfirsthead", r"\toprule", hdr, r"\midrule\endhead", rf"\midrule\multicolumn{{{n}}}{{r}}{{\emph{{continues}}}}\\\endfoot", r"\endlastfoot", *body_lines, r"\midrule", rf"\multicolumn{{{n-1}}}{{r}}{{\textbf{{{total_label}}}}} & \textbf{{{total:.2f}}} \\", r"\bottomrule", r"\end{longtable}", r"\end{small}", ]) def gen_pcba(wb): ws = wb["Internal PCBA BOM"] lines, total = [], 0.0 # cols: 0 Item 1 Qty 2 RefDes 3 Desc 4 Pkg 5 Mfr 6 MPN 7 LCSC 8 Assy 9 Unit 10 Line 11 Notes for cells, q, u, line in clean_rows(ws, 12, key_cols=(2, 3), qty_col=1, unit_col=9): total += line qs = str(int(q)) if q == int(q) else f"{q:g}" part = " ".join(x for x in (esc(cells[5]), esc(cells[6])) if x and x != "—") or "—" lines.append(f"{qs} & {esc(cells[2])} & {esc(cells[3])} & {esc(cells[4])} & " f"{part} & {esc(cells[7])} & {esc(cells[8])} & {u:.3f} & {line:.3f} \\\\") tex = longtable( r"@{}r p{2.6cm} p{5.4cm} p{1.5cm} p{2.6cm} p{1.7cm} p{1.4cm} r r@{}", "Internal PCBA BOM --- per board (synchronized from the production workbook).", "tab:pcba-bom", ["Qty", "RefDes", "Description", "Pkg", "Part", "LCSC", "Assy", r"Unit\,\$", r"Line\,\$"], lines, "TOTAL --- PCBA parts per board", total) return tex, len(lines), total def gen_external(wb): ws = wb["External Integration BOM"] lines, total = [], 0.0 # cols: 0 Item 1 Qty 2 Part 3 Desc 4 Source/MPN 5 Unit 6 Line 7 Notes for cells, q, u, line in clean_rows(ws, 8, key_cols=(2, 3), qty_col=1, unit_col=5): total += line qs = str(int(q)) if q == int(q) else f"{q:g}" desc = esc(cells[3]) note = esc(cells[7]) if note: desc += rf" \emph{{--- {note}}}" lines.append(f"{qs} & {esc(cells[2])} & {desc} & {esc(cells[4])} & " f"{u:.2f} & {line:.2f} \\\\") tex = longtable( r"@{}r p{3.4cm} p{7.2cm} p{2.9cm} r r@{}", "External Integration BOM --- per HBOX unit (enclosure, modules, pack, panel, harnesses; NOT in the JLC order).", "tab:ext-bom", ["Qty", "Part", "Description / notes", "Source / MPN", r"Unit\,\$", r"Line\,\$"], lines, "TOTAL --- external integration per unit", total) return tex, len(lines), total def main(): wb_path = Path(sys.argv[1]) if len(sys.argv) > 1 else HERE.parent / "helgen_einarboard_1.0_production_bom.xlsx" outdir = Path(sys.argv[2]) if len(sys.argv) > 2 else HERE.parent wb = load_workbook(wb_path, data_only=False) stamp = date.today().isoformat() banner = (f"% AUTO-GENERATED {stamp} by tools/sync_bom.py from {wb_path.name}.\n" f"% DO NOT HAND-EDIT — edit the workbook and re-run.\n") for fname, (tex, n, total) in { "pcba_bom.tex": gen_pcba(wb), "external_bom.tex": gen_external(wb), }.items(): out = outdir / fname out.write_text(banner + tex + "\n", encoding="utf-8") print(f"OK {out} ({n} line items, total ${total:.2f})") if __name__ == "__main__": main()