diff --git a/find_bottom_termination_parts.py b/find_bottom_termination_parts.py new file mode 100644 index 0000000..9087186 --- /dev/null +++ b/find_bottom_termination_parts.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +From a spreadsheet (designator + description column, data from row 10), find components +whose description indicates bottom termination package type (e.g. QFN, DFN, BGA). +Only the description column is searched (no separate package column). + +Usage: + python3 find_bottom_termination_parts.py sheet.xlsx --description-col 1 [-o output.json] + python3 find_bottom_termination_parts.py # uses SHEET, DESCRIPTION_COL, BOTTOM_TERM_OUTPUT from .env + +All paths and options: use .env or CLI; CLI overrides .env. +""" + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + +try: + import pandas as pd +except ImportError: + print("Install pandas and openpyxl: pip install pandas openpyxl", file=sys.stderr) + sys.exit(1) + + +# Bottom-termination package patterns (case-insensitive, word boundary where needed) +BOTTOM_TERMINATION_PATTERNS = [ + r"\bqfn\b", # Quad Flat No-leads + r"\bdfn\b", # Dual Flat No-leads + r"\bbga\b", # Ball Grid Array + r"\blga\b", # Land Grid Array + r"\bson\b", # Small Outline No-lead + r"\bmlf\b", # Micro Leadframe + r"\bmlp\b", + r"\bwdfn\b", + r"\bwqfn\b", + r"\bvqfn\b", + r"\buqfn\b", + r"\bxqfn\b", + r"\b bottom\s+termination\b", # generic + 0201/0402 +] +BOTTOM_TERM_REGEXES = [re.compile(p, re.I) for p in BOTTOM_TERMINATION_PATTERNS] + + +def load_sheet( + path: str, + designator_col: int = 0, + description_col: int = 1, + start_row: int = 9, +) -> list[dict]: + """Load spreadsheet; return list of {designator, description} from start_row (0-based; 9 = row 10).""" + p = Path(path) + if not p.exists(): + raise FileNotFoundError(path) + suffix = p.suffix.lower() + if suffix in (".xlsx", ".xls"): + df = pd.read_excel(path, header=None, engine="openpyxl" if suffix == ".xlsx" else None) + elif suffix == ".csv": + df = pd.read_csv(path, header=None) + else: + raise ValueError(f"Unsupported format: {suffix}") + if max(designator_col, description_col) >= df.shape[1]: + raise ValueError(f"Sheet has {df.shape[1]} columns; need at least {max(designator_col, description_col) + 1}") + rows = [] + for i in range(start_row, len(df)): + des = str(df.iloc[i, designator_col]).strip() if pd.notna(df.iloc[i, designator_col]) else "" + desc = str(df.iloc[i, description_col]).strip() if pd.notna(df.iloc[i, description_col]) else "" + if des or desc: + rows.append({"designator": des, "description": desc}) + return rows + + +def is_bottom_termination_in_description(description: str) -> tuple[bool, str]: + """ + True if description (case-insensitive) contains a bottom termination package type + (e.g. QFN, DFN, BGA). Returns (matched, pattern_matched) e.g. (True, "qfn"). + """ + if not (description or "").strip(): + return False, "" + d = description.lower() + for pat in BOTTOM_TERM_REGEXES: + if pat.search(d): + name = pat.pattern.replace(r"\b", "").replace("\\s+", " ").strip() + return True, name + return False, "" + + +def main() -> int: + default_sheet = os.environ.get("SHEET", "").strip() or None + default_out = os.environ.get("BOTTOM_TERM_OUTPUT", "").strip() or "output/bottom_termination_parts.json" + default_des_col = os.environ.get("DESCRIPTION_COL", "").strip() + default_start = os.environ.get("START_ROW", "").strip() + try: + default_des_col = int(default_des_col) if default_des_col else 1 + except ValueError: + default_des_col = 1 + try: + default_start = int(default_start) if default_start else 9 + except ValueError: + default_start = 9 + + parser = argparse.ArgumentParser( + description="Find components with bottom termination package types (e.g. QFN, DFN, BGA); only description column is searched" + ) + parser.add_argument("file", nargs="?", default=default_sheet, help="Spreadsheet path (default: SHEET from .env)") + parser.add_argument("-o", "--output", default=default_out, help="Output JSON (default: BOTTOM_TERM_OUTPUT from .env)") + parser.add_argument("--designator-col", type=int, default=0, help="Designator column 0-based (default 0)") + parser.add_argument("--description-col", type=int, default=default_des_col, metavar="COL", help="Description column 0-based (searched for package types; default: DESCRIPTION_COL from .env or 1)") + parser.add_argument("--start-row", type=int, default=default_start, help="First data row 0-based, 9=row 10 (default: START_ROW from .env or 9)") + args = parser.parse_args() + + path = (args.file or default_sheet or "").strip() + if not path: + parser.error("No spreadsheet. Set SHEET in .env or pass file path.") + if not Path(path).exists(): + print(f"Error: file not found: {path}", file=sys.stderr) + return 1 + + try: + rows = load_sheet( + path, + args.designator_col, + args.description_col, + args.start_row, + ) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + matches = [] + for r in rows: + ok, pattern = is_bottom_termination_in_description(r["description"]) + if ok: + matches.append({**r, "matched_pattern": pattern}) + + report = { + "file": path, + "designator_col": args.designator_col, + "description_col": args.description_col, + "start_row": args.start_row + 1, + "count": len(matches), + "parts": matches, + } + + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + print(f"Wrote {args.output}") + print(f"Found {len(matches)} components with bottom termination (by description column)") + for m in matches: + extra = f" [{m['matched_pattern']}]" if m.get("matched_pattern") else "" + desc = m["description"][:70] + "..." if len(m["description"]) > 70 else m["description"] + print(f" {m['designator']}: {desc}{extra}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())