Drogheda/03-Architecture-Hardware/EinarBoard-Manual/tools/sync_bom.py

148 lines
6.0 KiB
Python

#!/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()