#!/usr/bin/env python3 """Check BOM output for common issues. Usage: python bom_check.py path/to/bom.csv Checks: - All components have footprint assigned - All components have value assigned (resistors, capacitors, inductors) - No duplicate reference designators - Passive values are standard E-series (basic check) - Flags DNP components """ from __future__ import annotations import csv import re import sys from pathlib import Path # Standard E12 values (covers most common passives) E12_VALUES = {1.0, 1.2, 1.5, 1.8, 2.2, 2.7, 3.3, 3.9, 4.7, 5.6, 6.8, 8.2} PASSIVE_PREFIXES = {"R", "C", "L"} def normalize_value(value_str: str) -> float | None: """Try to extract numeric value from component value string.""" match = re.match(r"^(\d+\.?\d*)\s*([kKmMuUnNpP]?)", value_str.strip()) if not match: return None num = float(match.group(1)) suffix = match.group(2).lower() multipliers = {"k": 1e3, "m": 1e6, "u": 1e-6, "n": 1e-9, "p": 1e-12} return num * multipliers.get(suffix, 1.0) def is_e12_compatible(value: float) -> bool: """Check if a value falls on an E12 series value (within 5% tolerance).""" if value <= 0: return False # Normalize to 1.0-9.99 range normalized = value while normalized >= 10: normalized /= 10 while normalized < 1: normalized *= 10 for e12 in E12_VALUES: if abs(normalized - e12) / e12 < 0.05: return True return False def check_bom(filepath: Path) -> tuple[list[str], list[str]]: errors: list[str] = [] warnings: list[str] = [] refs_seen: set[str] = set() with filepath.open(newline="", encoding="utf-8") as f: reader = csv.DictReader(f) if reader.fieldnames is None: errors.append("BOM file has no headers") return errors, warnings # Try common column name patterns ref_col = next((c for c in reader.fieldnames if c.lower() in ("ref", "reference", "designator")), None) val_col = next((c for c in reader.fieldnames if c.lower() in ("value", "val")), None) fp_col = next((c for c in reader.fieldnames if c.lower() in ("footprint", "package", "fp")), None) if ref_col is None: errors.append("Cannot find reference designator column in BOM") return errors, warnings for row_num, row in enumerate(reader, start=2): ref = row.get(ref_col, "").strip() if not ref: continue # Duplicate check if ref in refs_seen: errors.append(f"Row {row_num}: duplicate reference '{ref}'") refs_seen.add(ref) # Footprint check footprint = row.get(fp_col, "").strip() if fp_col else "" if not footprint: errors.append(f"{ref}: missing footprint") # Value check for passives value = row.get(val_col, "").strip() if val_col else "" prefix = re.match(r"^[A-Z]+", ref) if prefix and prefix.group() in PASSIVE_PREFIXES: if not value or value.upper() == "DNP": if value.upper() == "DNP": warnings.append(f"{ref}: marked as DNP — verify this is intentional") else: errors.append(f"{ref}: passive component missing value") else: numeric = normalize_value(value) if numeric and not is_e12_compatible(numeric): warnings.append(f"{ref}: value '{value}' may not be standard E12 series") return errors, warnings def main() -> int: if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} ") return 1 path = Path(sys.argv[1]) if not path.exists(): print(f"File not found: {path}") return 1 errors, warnings = check_bom(path) for w in warnings: print(f" WARN: {w}") for e in errors: print(f" ERROR: {e}") print(f"\n {len(errors)} errors, {len(warnings)} warnings") return 1 if errors else 0 if __name__ == "__main__": sys.exit(main())