148 lines
6.0 KiB
Python
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()
|