Ver Fonte

feat: extracting from raw BOMs. One issue is Elec_6.3x5.8 should probably save 6.3x5.8 into package instead of this whole + some value that would indicate category

n2749 há 1 mês atrás
pai
commit
4f23a3a1ae
37 ficheiros alterados com 1193 adições e 0 exclusões
  1. 0 0
      bom_assistant/__init__.py
  2. BIN
      bom_assistant/__pycache__/__init__.cpython-314.pyc
  3. 0 0
      bom_assistant/classification/__init__.py
  4. BIN
      bom_assistant/classification/__pycache__/__init__.cpython-314.pyc
  5. BIN
      bom_assistant/classification/__pycache__/classifier.cpython-314.pyc
  6. BIN
      bom_assistant/classification/__pycache__/param_extractor.cpython-314.pyc
  7. 75 0
      bom_assistant/classification/classifier.py
  8. 21 0
      bom_assistant/classification/package_patterns.json
  9. 308 0
      bom_assistant/classification/param_extractor.py
  10. 0 0
      bom_assistant/ingestion/__init__.py
  11. BIN
      bom_assistant/ingestion/__pycache__/__init__.cpython-314.pyc
  12. BIN
      bom_assistant/ingestion/__pycache__/column_mapper.cpython-314.pyc
  13. BIN
      bom_assistant/ingestion/__pycache__/normalizer.cpython-314.pyc
  14. BIN
      bom_assistant/ingestion/__pycache__/parser.cpython-314.pyc
  15. 176 0
      bom_assistant/ingestion/column_mapper.py
  16. 58 0
      bom_assistant/ingestion/column_synonyms.json
  17. 132 0
      bom_assistant/ingestion/normalizer.py
  18. 194 0
      bom_assistant/ingestion/parser.py
  19. 0 0
      bom_assistant/session/__init__.py
  20. BIN
      bom_assistant/session/__pycache__/__init__.cpython-314.pyc
  21. BIN
      bom_assistant/session/__pycache__/models.cpython-314.pyc
  22. 63 0
      bom_assistant/session/models.py
  23. BIN
      examples/bom/BOM.xlsx
  24. BIN
      examples/bom/BOM_G5Dot_REV_01.01_VARIANT_Mosaic-G5_P3H.xlsx
  25. BIN
      examples/bom/BOM_PI POLONES DETECTOR DE METAIS.xlsx
  26. BIN
      examples/bom/BOM_imx415_DigitalFPV (1).xlsx
  27. BIN
      examples/bom/Cashstick BOM.xlsx
  28. 5 0
      examples/bom/ESP32 WITH FLASHER.csv
  29. BIN
      examples/bom/FPGA1394V3-BOM.xlsx
  30. BIN
      examples/bom/QLA-BOM.xls
  31. BIN
      examples/bom/QLA-BOM.xlsx
  32. BIN
      examples/bom/bill of material.xlsx
  33. 55 0
      examples/bom/bom (1).csv
  34. 19 0
      examples/bom/machinka_control_v1_03042026_bom.csv
  35. 61 0
      examples/bom/metal detector test_bom_V2.csv
  36. 7 0
      requirements.txt
  37. 19 0
      test.sh

+ 0 - 0
bom_assistant/__init__.py


BIN
bom_assistant/__pycache__/__init__.cpython-314.pyc


+ 0 - 0
bom_assistant/classification/__init__.py


BIN
bom_assistant/classification/__pycache__/__init__.cpython-314.pyc


BIN
bom_assistant/classification/__pycache__/classifier.cpython-314.pyc


BIN
bom_assistant/classification/__pycache__/param_extractor.cpython-314.pyc


+ 75 - 0
bom_assistant/classification/classifier.py

@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import re
+
+from bom_assistant.session.models import ComponentCategory
+
+_PREFIX_MAP: dict[str, ComponentCategory] = {
+    "C": ComponentCategory.capacitor,
+    "R": ComponentCategory.resistor,
+    "RV": ComponentCategory.resistor,
+    "L": ComponentCategory.inductor,
+    "D": ComponentCategory.diode,
+    "U": ComponentCategory.ic,
+    "IC": ComponentCategory.ic,
+    "J": ComponentCategory.connector,
+    "P": ComponentCategory.connector,
+    "CON": ComponentCategory.connector,
+    "USB": ComponentCategory.connector,
+    "Q": ComponentCategory.mosfet,
+    "T": ComponentCategory.mosfet,
+    "X": ComponentCategory.crystal,
+    "Y": ComponentCategory.crystal,
+    "NTC": ComponentCategory.sensor,
+    "RT": ComponentCategory.sensor,
+}
+
+_LED_KEYWORDS = {"red", "grn", "blu", "green", "blue", "led", "yellow", "white", "amber"}
+
+_HW_RE = re.compile(
+    r"^(PCB|PCBA|screw|washer|nut|lock\s*nut|heatsink|standoff|spacer|heat\s*sink)",
+    re.IGNORECASE,
+)
+
+# Value-string heuristics (used when no designators)
+_CAP_RE = re.compile(r"\d+\.?\d*\s*[pnuµ][fF]?$", re.IGNORECASE)
+_RES_RE = re.compile(r"(\d+[kKMR]|\d+\.?\d*\s*[kKMΩR]$|\d+[kK]\d+)", re.IGNORECASE)
+_PART_NUM_RE = re.compile(r"^[A-Z]{2,}[\dA-Z\-]{3,}$")
+
+
+def classify(designators: list[str], raw_value: str) -> ComponentCategory:
+    prefix = _extract_prefix(designators[0]) if designators else ""
+    category = _PREFIX_MAP.get(prefix.upper())
+
+    if category == ComponentCategory.diode:
+        low = raw_value.lower()
+        if any(kw in low for kw in _LED_KEYWORDS):
+            return ComponentCategory.led
+        return ComponentCategory.diode
+
+    if category is not None:
+        return category
+
+    # no designator match — use value heuristics
+    return _classify_by_value(raw_value)
+
+
+def is_non_electronic(raw_value: str) -> bool:
+    return bool(_HW_RE.match(raw_value.strip()))
+
+
+def _extract_prefix(designator: str) -> str:
+    m = re.match(r"^([A-Za-z]+)", designator.strip())
+    return m.group(1) if m else ""
+
+
+def _classify_by_value(raw_value: str) -> ComponentCategory:
+    v = raw_value.strip()
+    if _CAP_RE.match(v):
+        return ComponentCategory.capacitor
+    if _RES_RE.match(v):
+        return ComponentCategory.resistor
+    # looks like a part number: ≥5 chars, mixed alpha+digit, no unit suffix
+    if _PART_NUM_RE.match(v) and len(v) >= 5:
+        return ComponentCategory.ic
+    return ComponentCategory.other

+ 21 - 0
bom_assistant/classification/package_patterns.json

@@ -0,0 +1,21 @@
+{
+  "patterns": [
+    "\\b(0201|0402|0603|0805|1206|1210|2010|2512|2220|1812)\\b",
+    "(?<![A-Za-z])(SOT-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(SOIC-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(HTSOP-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(HTSSOP-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(TSSOP-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(SO-\\d+)(?![A-Za-z\\d])",
+    "(?<![A-Za-z])(LQFP-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(QFN-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(TO-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(DFN-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(LGA-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(BGA-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(DO-\\d+(?:-[A-Z0-9]+)*)",
+    "(?<![A-Za-z])(SMA|SMB|SMC)(?![A-Za-z])",
+    "(Elec_\\d+\\.?\\d*x\\d+\\.?\\d*)"
+  ],
+  "known_footprints": {}
+}

+ 308 - 0
bom_assistant/classification/param_extractor.py

@@ -0,0 +1,308 @@
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+from bom_assistant.session.models import ComponentCategory, NormalizedParams
+
+_PATTERNS_PATH = Path(__file__).parent / "package_patterns.json"
+
+# SI multipliers for passive values
+_MULT: dict[str, float] = {
+    "p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6,
+    "m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9,
+}
+
+# European notation: 2k2, 4k7, 1R5, 3n3
+_EURO_RE = re.compile(r"^(\d+)([pnuµmkKMGrR])(\d+)$")
+# Standard: 100R, 10k, 4.7k, 100n, 1nF, 22uF, 0.001R
+_STD_RE = re.compile(r"^(\d+\.?\d*)\s*([pnuµmkKMG]?)\s*([FfHhRrΩ]?)$")
+# Frequency: 8MHz, 32.768kHz
+_FREQ_RE = re.compile(r"(\d+\.?\d*)\s*(k|M|G)?Hz", re.IGNORECASE)
+
+
+def extract_params(
+    raw_value: str,
+    footprint: str | None,
+    category: ComponentCategory,
+) -> NormalizedParams:
+    params = NormalizedParams()
+    params.package = _extract_package(footprint)
+
+    cleaned = _preprocess(raw_value)
+    # try each comma-separated token, not just the first
+    tokens = [t.strip() for t in re.split(r"[,;]", cleaned) if t.strip()]
+    primary = tokens[0] if tokens else ""
+
+    if category == ComponentCategory.capacitor:
+        for tok in tokens:
+            _parse_capacitance(tok, params)
+            if params.value is not None:
+                break
+        # fallback: scan entire string for embedded capacitance
+        if params.value is None:
+            _extract_capacitance_from_description(cleaned, params)
+    elif category == ComponentCategory.resistor:
+        for tok in tokens:
+            _parse_resistance(tok, params)
+            if params.value is not None:
+                break
+        if params.value is None:
+            _extract_resistance_from_description(cleaned, params)
+    elif category == ComponentCategory.inductor:
+        for tok in tokens:
+            _parse_inductance(tok, params)
+            if params.value is not None:
+                break
+    elif category == ComponentCategory.crystal:
+        _parse_frequency(cleaned, params)
+    elif category in (ComponentCategory.ic, ComponentCategory.mosfet):
+        params.part_number = raw_value.strip()
+    elif category == ComponentCategory.sensor:
+        m = re.search(r"(\d+\.?\d*)\s*([kKMΩRr]?)", primary.split()[-1] if primary else "")
+        if m:
+            _parse_resistance(m.group(0), params)
+
+    # cross-category extractions from full string
+    _extract_voltage(cleaned, params)
+    _extract_current(cleaned, params)
+    _extract_power(cleaned, params)
+    _extract_tolerance(cleaned, params)
+
+    return params
+
+
+# ---------------------------------------------------------------------------
+# Pre-processing
+# ---------------------------------------------------------------------------
+
+def _preprocess(s: str) -> str:
+    # strip KiCad placeholder
+    if s.strip() == "~":
+        return ""
+    # normalize unicode ohm
+    s = s.replace("Ω", "R").replace("ω", "r")
+    # European decimal: "3,3K" → "3.3K" (digit comma digit followed by unit)
+    s = re.sub(r"(\d+),(\d+)([pnuµmkKMGrRFfHh])", lambda m: f"{m.group(1)}.{m.group(2)}{m.group(3)}", s)
+    return s
+
+
+# ---------------------------------------------------------------------------
+# Capacitance
+# ---------------------------------------------------------------------------
+
+def _parse_capacitance(token: str, params: NormalizedParams) -> None:
+    # ensure trailing F for bare prefix (e.g. "100n" → "100nF")
+    m = re.match(r"^(\d+\.?\d*)\s*([pnuµ])[fF]?$", token, re.IGNORECASE)
+    if m:
+        num, prefix = m.group(1), m.group(2).lower()
+        mult = _MULT.get(prefix, 1)
+        params.value = float(num) * mult
+        params.unit = "F"
+        params.value_str = f"{num}{prefix}F"
+        return
+
+    # European: 4n7, 2u2
+    m = _EURO_RE.match(token)
+    if m and m.group(2).lower() in ("p", "n", "u", "µ"):
+        prefix_char = m.group(2).lower()
+        val = (int(m.group(1)) + int(m.group(3)) / 10 ** len(m.group(3))) * _MULT[prefix_char]
+        params.value = val
+        params.unit = "F"
+        params.value_str = token
+        return
+
+
+# ---------------------------------------------------------------------------
+# Resistance
+# ---------------------------------------------------------------------------
+
+def _parse_resistance(token: str, params: NormalizedParams) -> None:
+    token = token.strip()
+
+    # European: 2k2, 4k7, 1R5, 3n3
+    m = _EURO_RE.match(token)
+    if m:
+        prefix_char = m.group(2)
+        mult = _MULT.get(prefix_char, _MULT.get(prefix_char.lower(), 1))
+        if prefix_char.lower() in ("r",):
+            mult = 1
+        val = (int(m.group(1)) + int(m.group(3)) / 10 ** len(m.group(3))) * mult
+        params.value = val
+        params.unit = "Ω"
+        params.value_str = token
+        return
+
+    # Standard: 100R, 10k, 0.001R, 220k, 4.7k
+    m = re.match(r"^(\d+\.?\d*)\s*([kKMmrRΩ]?)$", token)
+    if m:
+        num, suffix = m.group(1), m.group(2)
+        mult_map = {"k": 1e3, "K": 1e3, "m": 1e-3, "M": 1e6, "r": 1, "R": 1, "Ω": 1, "": 1}
+        mult = mult_map.get(suffix, 1)
+        params.value = float(num) * mult
+        params.unit = "Ω"
+        params.value_str = token
+        return
+
+
+# ---------------------------------------------------------------------------
+# Inductance
+# ---------------------------------------------------------------------------
+
+def _parse_inductance(token: str, params: NormalizedParams) -> None:
+    m = re.match(r"^(\d+\.?\d*)\s*([pnuµm]?)[hH]?$", token)
+    if m:
+        num, prefix = m.group(1), m.group(2).lower()
+        mult = _MULT.get(prefix, 1) if prefix else 1
+        params.value = float(num) * mult
+        params.unit = "H"
+        params.value_str = f"{num}{prefix}H" if prefix else f"{num}H"
+        return
+
+    # European: 4u7
+    m = _EURO_RE.match(token)
+    if m and m.group(2).lower() in ("p", "n", "u", "µ", "m"):
+        prefix_char = m.group(2).lower()
+        val = (int(m.group(1)) + int(m.group(3)) / 10 ** len(m.group(3))) * _MULT[prefix_char]
+        params.value = val
+        params.unit = "H"
+        params.value_str = token
+        return
+
+
+# ---------------------------------------------------------------------------
+# Description fallback scanners (long strings like "CAP CER 0.1UF 100V X7R 0603")
+# ---------------------------------------------------------------------------
+
+def _extract_capacitance_from_description(s: str, params: NormalizedParams) -> None:
+    m = re.search(r"\b(\d+\.?\d*)\s*([pnuµ])[fF]?\b", s, re.IGNORECASE)
+    if m:
+        num, prefix = m.group(1), m.group(2).lower()
+        mult = _MULT.get(prefix, 1)
+        params.value = float(num) * mult
+        params.unit = "F"
+        params.value_str = f"{num}{prefix}F"
+
+
+def _extract_resistance_from_description(s: str, params: NormalizedParams) -> None:
+    m = re.search(r"\b(\d+\.?\d*)\s*([kKMm]?)\s*[RrΩ]\b", s)
+    if m:
+        num, prefix = m.group(1), m.group(2)
+        mult = {"k": 1e3, "K": 1e3, "m": 1e-3, "M": 1e6, "": 1}.get(prefix, 1)
+        params.value = float(num) * mult
+        params.unit = "Ω"
+        params.value_str = m.group(0).strip()
+
+
+# ---------------------------------------------------------------------------
+# Frequency
+# ---------------------------------------------------------------------------
+
+def _parse_frequency(s: str, params: NormalizedParams) -> None:
+    m = _FREQ_RE.search(s)
+    if m:
+        num = float(m.group(1))
+        prefix = (m.group(2) or "").lower()
+        mult = {"k": 1e3, "m": 1e6, "g": 1e9}.get(prefix, 1)
+        params.value = num * mult
+        params.unit = "Hz"
+        params.value_str = m.group(0)
+
+
+# ---------------------------------------------------------------------------
+# Cross-category extractions
+# ---------------------------------------------------------------------------
+
+def _extract_voltage(s: str, params: NormalizedParams) -> None:
+    if params.voltage_rating is not None:
+        return
+    m = re.search(r"(\d+\.?\d*)\s*[Vv]\b", s)
+    if m:
+        params.voltage_rating = float(m.group(1))
+
+
+def _extract_current(s: str, params: NormalizedParams) -> None:
+    if params.current_rating is not None:
+        return
+    m = re.search(r"(\d+\.?\d*)\s*[Aa]\b", s)
+    if m:
+        params.current_rating = float(m.group(1))
+
+
+def _extract_power(s: str, params: NormalizedParams) -> None:
+    if params.power_rating is not None:
+        return
+    m = re.search(r"(\d+\.?\d*)\s*W\b", s)
+    if m:
+        params.power_rating = float(m.group(1))
+
+
+def _extract_tolerance(s: str, params: NormalizedParams) -> None:
+    if params.tolerance is not None:
+        return
+    m = re.search(r"(\d+\.?\d*)\s*(ppm|%)", s, re.IGNORECASE)
+    if m:
+        params.tolerance = f"{m.group(1)}{m.group(2)}"
+
+
+# ---------------------------------------------------------------------------
+# Package — file-backed patterns + AI fallback
+# ---------------------------------------------------------------------------
+
+def _load_package_data() -> dict:
+    with open(_PATTERNS_PATH, encoding="utf-8") as f:
+        return json.load(f)
+
+
+def _save_package_data(data: dict) -> None:
+    with open(_PATTERNS_PATH, "w", encoding="utf-8") as f:
+        json.dump(data, f, indent=2, ensure_ascii=False)
+
+
+def _extract_package(footprint: str | None) -> str | None:
+    if not footprint:
+        return None
+
+    data = _load_package_data()
+
+    # 1. exact match
+    if footprint in data["known_footprints"]:
+        return data["known_footprints"][footprint]
+
+    # 2. regex cascade
+    for raw_pattern in data["patterns"]:
+        m = re.search(raw_pattern, footprint)
+        if m:
+            return m.group(1)
+
+    # 3. AI fallback
+    package = _ai_identify_package(footprint)
+    if package:
+        data["known_footprints"][footprint] = package
+        _save_package_data(data)
+    return package
+
+
+def _ai_identify_package(footprint: str) -> str | None:
+    try:
+        import anthropic
+        client = anthropic.Anthropic()
+        msg = client.messages.create(
+            model="claude-haiku-4-5-20251001",
+            max_tokens=50,
+            messages=[{
+                "role": "user",
+                "content": (
+                    f"What is the standard electronic package name for this PCB footprint identifier: '{footprint}'?\n"
+                    "Reply with ONLY the package name (e.g. SOIC-8, 0603, HTSOP-8, TO-220) "
+                    "or 'unknown' if it cannot be determined. No explanation."
+                ),
+            }],
+        )
+        result = msg.content[0].text.strip()
+        if result.lower() == "unknown" or not result:
+            return None
+        return result
+    except Exception:
+        return None

+ 0 - 0
bom_assistant/ingestion/__init__.py


BIN
bom_assistant/ingestion/__pycache__/__init__.cpython-314.pyc


BIN
bom_assistant/ingestion/__pycache__/column_mapper.cpython-314.pyc


BIN
bom_assistant/ingestion/__pycache__/normalizer.cpython-314.pyc


BIN
bom_assistant/ingestion/__pycache__/parser.cpython-314.pyc


+ 176 - 0
bom_assistant/ingestion/column_mapper.py

@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+_SYNONYMS_PATH = Path(__file__).parent / "column_synonyms.json"
+
+# Columns where "Comment" maps to part_number instead of value when a
+# richer value column is also present.
+_VALUE_COLS = {"value", "description", "name", "part name", "component value", "part value"}
+_COMMENT_COL = "comment"
+
+
+def _load_synonyms() -> dict[str, list[str]]:
+    with open(_SYNONYMS_PATH, encoding="utf-8") as f:
+        return json.load(f)
+
+
+def _save_synonyms(synonyms: dict[str, list[str]]) -> None:
+    with open(_SYNONYMS_PATH, "w", encoding="utf-8") as f:
+        json.dump(synonyms, f, indent=2, ensure_ascii=False)
+
+
+def map_columns(raw_rows: list[dict[str, str]]) -> list[dict[str, str]]:
+    if not raw_rows:
+        return []
+
+    raw_keys = list(raw_rows[0].keys())
+    synonyms = _load_synonyms()
+    mapping = _heuristic_map(raw_keys, synonyms)
+    mapping = _disambiguate_comment(raw_keys, mapping, raw_rows)
+
+    missing_required = [c for c in ("value",) if c not in mapping]
+    if missing_required:
+        ai_result = _ai_map(raw_keys, missing_required, synonyms)
+        mapping.update(ai_result)
+
+    return [_remap_row(row, mapping) for row in raw_rows]
+
+
+def _normalize_key(s: str) -> str:
+    return re.sub(r"\s+", " ", s.lower().strip())
+
+
+def _heuristic_map(raw_keys: list[str], synonyms: dict[str, list[str]]) -> dict[str, str]:
+    # Build normalized lookup: normalized_key → original_key
+    norm_to_orig = {_normalize_key(k): k for k in raw_keys}
+
+    mapping: dict[str, str] = {}
+    # Iterate synonyms in priority order (JSON list order) for each canonical
+    for canonical, syn_list in synonyms.items():
+        if canonical in mapping:
+            continue
+        for syn in syn_list:
+            norm_syn = _normalize_key(syn)
+            if norm_syn in norm_to_orig:
+                mapping[canonical] = norm_to_orig[norm_syn]
+                break
+    return mapping
+
+
+_PASSIVE_VALUE_RE = re.compile(
+    r"^\s*[\d\.]+\s*[pnuµmkKMG]?[FfHhRrΩ]?\s*$|"
+    r"^\s*[\d\.]+\s*[kKMG]\s*$|"
+    r"^\s*[\d\.]+\s*[pnuµ][fF]\s*$",
+    re.IGNORECASE,
+)
+
+
+def _comment_looks_like_value(raw_rows: list[dict[str, str]], comment_key: str) -> bool:
+    """Return True if Comment column values look like passive component values, not MPNs."""
+    samples = [r.get(comment_key, "") for r in raw_rows[:10] if r.get(comment_key, "").strip()]
+    if not samples:
+        return False
+    matches = sum(1 for s in samples if _PASSIVE_VALUE_RE.match(s))
+    return matches / len(samples) > 0.4
+
+
+def _disambiguate_comment(
+    raw_keys: list[str],
+    mapping: dict[str, str],
+    raw_rows: list[dict[str, str]],
+) -> dict[str, str]:
+    """
+    If BOM has both Comment and a richer value column (Value/Description),
+    treat Comment as part_number — but only if Comment doesn't look like passive values.
+    """
+    lower_keys = {k.lower(): k for k in raw_keys}
+    has_comment = _COMMENT_COL in lower_keys
+    has_value_col = any(k in lower_keys for k in _VALUE_COLS)
+
+    if not (has_comment and has_value_col):
+        return mapping
+
+    original_comment_key = lower_keys[_COMMENT_COL]
+
+    # Don't remap if Comment values look like passive component values
+    if _comment_looks_like_value(raw_rows, original_comment_key):
+        return mapping
+
+    if "part_number" not in mapping:
+        mapping["part_number"] = original_comment_key
+    if mapping.get("value") == original_comment_key:
+        for col_name in _VALUE_COLS:
+            if col_name in lower_keys and lower_keys[col_name] != original_comment_key:
+                mapping["value"] = lower_keys[col_name]
+                break
+    return mapping
+
+
+def _ai_map(
+    raw_keys: list[str],
+    missing: list[str],
+    synonyms: dict[str, list[str]],
+) -> dict[str, str]:
+    import anthropic
+
+    client = anthropic.Anthropic()
+    prompt = (
+        f"Map these BOM spreadsheet column names to canonical fields.\n"
+        f"Columns present: {raw_keys}\n"
+        f"Need mappings for: {missing}\n"
+        f"Canonical fields:\n"
+        f"  value — component value or description (e.g. '100nF', '10k', 'STM32F405')\n"
+        f"  designators — reference designators (e.g. 'C1, C2', 'R5')\n"
+        f"  footprint — PCB footprint or package\n"
+        f"  quantity — how many of this component\n"
+        f"  part_number — manufacturer part number / MPN\n"
+        f"  manufacturer — manufacturer name\n\n"
+        f"Return JSON only, no explanation: "
+        f'{{\"canonical_field\": \"exact_original_column_name\", ...}}\n'
+        f"Only include fields you are confident about."
+    )
+
+    msg = client.messages.create(
+        model="claude-haiku-4-5-20251001",
+        max_tokens=300,
+        messages=[{"role": "user", "content": prompt}],
+    )
+    text = msg.content[0].text
+    match = re.search(r"\{.*\}", text, re.DOTALL)
+    if not match:
+        return {}
+
+    try:
+        ai_mapping: dict[str, str] = json.loads(match.group())
+    except json.JSONDecodeError:
+        return {}
+
+    # persist new synonyms so we avoid future API calls
+    updated = False
+    for canonical, raw_col in ai_mapping.items():
+        if canonical not in synonyms:
+            continue
+        normalized = raw_col.lower().strip()
+        existing = [s.lower() for s in synonyms[canonical]]
+        if normalized not in existing:
+            synonyms[canonical].append(raw_col.lower().strip())
+            updated = True
+    if updated:
+        _save_synonyms(synonyms)
+
+    return ai_mapping
+
+
+def _remap_row(row: dict[str, str], mapping: dict[str, str]) -> dict[str, str]:
+    mapped_originals = set(mapping.values())
+    result: dict[str, str] = {}
+    for canonical, original in mapping.items():
+        result[canonical] = row.get(original, "")
+    # pass through unmapped columns with _raw_ prefix
+    for k, v in row.items():
+        if k not in mapped_originals:
+            result[f"_raw_{k}"] = v
+    return result

+ 58 - 0
bom_assistant/ingestion/column_synonyms.json

@@ -0,0 +1,58 @@
+{
+  "value": [
+    "comment",
+    "value",
+    "description",
+    "name",
+    "part value",
+    "component value",
+    "part name",
+    "component name"
+  ],
+  "designators": [
+    "designator",
+    "reference",
+    "ref des",
+    "references",
+    "ref",
+    "refdes",
+    "ref des (multi-4)",
+    "component reference",
+    "reference designator"
+  ],
+  "footprint": [
+    "footprint",
+    "package",
+    "pcb footprint",
+    "land pattern",
+    "pcb package"
+  ],
+  "quantity": [
+    "quantity",
+    "qty",
+    "qty.",
+    "count",
+    "amount",
+    "quantity per pcb",
+    "num",
+    "number"
+  ],
+  "part_number": [
+    "mpn",
+    "manufacturer part number",
+    "manufacturer part #",
+    "manu part number",
+    "mfr part no",
+    "mfr part number",
+    "manufacturer part no",
+    "part number",
+    "part no",
+    "part #"
+  ],
+  "manufacturer": [
+    "manufacturer",
+    "manufacturer1",
+    "mfr",
+    "make"
+  ]
+}

+ 132 - 0
bom_assistant/ingestion/normalizer.py

@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import re
+import uuid
+
+from bom_assistant.classification.classifier import classify, is_non_electronic
+from bom_assistant.classification.param_extractor import extract_params
+from bom_assistant.session.models import BomRow, RowState
+
+_DNP_KEYWORDS = re.compile(
+    r"\b(dnp|do\s+not\s+place|do\s+not\s+populate|np|not\s+placed)\b",
+    re.IGNORECASE,
+)
+_DNP_TRUTHY = {"1", "true", "yes", "dnp", "x"}
+
+# Range designator: "C1-5", "R56-61"
+_RANGE_RE = re.compile(r"^([A-Za-z]+)(\d+)-(\d+)$")
+
+
+def normalize(mapped_rows: list[dict[str, str]]) -> list[BomRow]:
+    rows: list[BomRow] = []
+    for raw in mapped_rows:
+        row = _normalize_row(raw)
+        if row is not None:
+            rows.append(row)
+    return rows
+
+
+def _normalize_row(raw: dict[str, str]) -> BomRow | None:
+    value = raw.get("value", "").strip()
+
+    # skip empty or KiCad placeholder
+    if not value or value == "~":
+        return None
+
+    designators = _parse_designators(raw.get("designators", ""))
+    quantity = _parse_quantity(raw.get("quantity", ""))
+    footprint = raw.get("footprint", "").strip() or None
+
+    # pull part_number / manufacturer from mapped columns if present
+    part_number = raw.get("part_number", "").strip() or None
+    manufacturer = raw.get("manufacturer", "").strip() or None
+
+    # state determination
+    state = _determine_state(value, footprint, quantity, raw)
+
+    category = classify(designators, value)
+
+    params = extract_params(value, footprint, category)
+
+    # inject part_number / manufacturer from mapped columns if extractor didn't find them
+    if part_number and not params.part_number:
+        params.part_number = part_number
+    if manufacturer and not params.manufacturer:
+        params.manufacturer = manufacturer
+
+    return BomRow(
+        row_id=str(uuid.uuid4()),
+        designators=designators,
+        quantity=quantity,
+        raw_value=value,
+        footprint=footprint,
+        category=category,
+        normalized_params=params,
+        state=state,
+    )
+
+
+def _parse_designators(s: str) -> list[str]:
+    if not s:
+        return []
+    # split on comma, semicolon, or whitespace
+    parts = re.split(r"[,;\s]+", s.strip())
+    expanded: list[str] = []
+    for part in parts:
+        part = part.strip()
+        if not part:
+            continue
+        expanded.extend(_expand_range(part))
+    # deduplicate while preserving order
+    seen: set[str] = set()
+    result: list[str] = []
+    for d in expanded:
+        if d not in seen:
+            seen.add(d)
+            result.append(d)
+    return sorted(result, key=lambda x: (re.sub(r"\d", "", x), int(re.sub(r"\D", "", x) or 0)))
+
+
+def _expand_range(s: str) -> list[str]:
+    m = _RANGE_RE.match(s)
+    if m:
+        prefix = m.group(1)
+        start, end = int(m.group(2)), int(m.group(3))
+        if start <= end and (end - start) < 200:  # sanity cap
+            return [f"{prefix}{i}" for i in range(start, end + 1)]
+    return [s]
+
+
+def _parse_quantity(s: str) -> int:
+    s = s.strip()
+    if not s:
+        return 1
+    try:
+        return max(0, int(float(s)))
+    except (ValueError, TypeError):
+        return 1
+
+
+def _determine_state(
+    value: str,
+    footprint: str | None,
+    quantity: int,
+    raw: dict[str, str],
+) -> RowState:
+    if quantity == 0:
+        return RowState.flagged
+
+    combined = f"{value} {footprint or ''}"
+    if _DNP_KEYWORDS.search(combined):
+        return RowState.flagged
+
+    # check explicit boolean DNP columns
+    for col in ("DNP", "Exclude from BOM", "Mounting", "_raw_DNP", "_raw_Exclude from BOM", "_raw_Mounting"):
+        col_val = raw.get(col, "").strip().lower()
+        if col_val in _DNP_TRUTHY:
+            return RowState.flagged
+
+    if is_non_electronic(value):
+        return RowState.flagged
+
+    return RowState.pending

+ 194 - 0
bom_assistant/ingestion/parser.py

@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+import csv
+import io
+import re
+from datetime import datetime
+from pathlib import Path
+
+
+_FOOTER_RE = re.compile(
+    r"^(Component\s+Groups?|Component\s+Count|Fitted|Total\s+comp|"
+    r"Schematic|PCB\s+Variant|BoM\s+Date)\s*[:\-]",
+    re.IGNORECASE,
+)
+
+_TOTALS_RE = re.compile(
+    r"^(Estimated\s+Total|Total\s+Cost|Grand\s+Total)",
+    re.IGNORECASE,
+)
+
+
+def parse_bom(content: bytes, filename: str) -> list[dict[str, str]]:
+    suffix = Path(filename).suffix.lower()
+    if suffix == ".csv":
+        return _parse_csv(content)
+    elif suffix == ".xlsx":
+        return _parse_xlsx(content)
+    elif suffix == ".xls":
+        return _parse_xls(content)
+    else:
+        raise ValueError(f"Unsupported format: {suffix!r}")
+
+
+# ---------------------------------------------------------------------------
+# CSV
+# ---------------------------------------------------------------------------
+
+def _parse_csv(content: bytes) -> list[dict[str, str]]:
+    text = content.decode("utf-8-sig")
+    delimiter = _detect_delimiter(text)
+    reader = csv.reader(io.StringIO(text), delimiter=delimiter)
+    all_rows = list(reader)
+    if not all_rows:
+        return []
+
+    headers = [h.strip() for h in all_rows[0]]
+
+    result: list[dict[str, str]] = []
+    for row in all_rows[1:]:
+        # pad short rows
+        while len(row) < len(headers):
+            row.append("")
+        row_dict = {headers[i]: row[i].strip() for i in range(len(headers)) if headers[i]}
+
+        # stop at footer
+        first_val = next((v for v in row_dict.values() if v), "")
+        if _FOOTER_RE.match(first_val) or _TOTALS_RE.match(first_val):
+            break
+
+        # skip all-empty or all-non-alpha rows (e.g. blank semicolon rows)
+        vals = [v for v in row_dict.values() if v]
+        if not vals or all(not re.search(r"[A-Za-z0-9]", v) for v in vals):
+            continue
+
+        result.append(row_dict)
+    return result
+
+
+def _detect_delimiter(text: str) -> str:
+    first_line = text.split("\n", 1)[0]
+    comma_count = first_line.count(",")
+    semi_count = first_line.count(";")
+    return ";" if semi_count > comma_count else ","
+
+
+# ---------------------------------------------------------------------------
+# XLSX
+# ---------------------------------------------------------------------------
+
+def _parse_xlsx(content: bytes) -> list[dict[str, str]]:
+    import openpyxl
+
+    wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
+    ws = wb.active
+    all_rows = list(ws.iter_rows(values_only=True))
+    return _extract_table(all_rows)
+
+
+# ---------------------------------------------------------------------------
+# XLS
+# ---------------------------------------------------------------------------
+
+def _parse_xls(content: bytes) -> list[dict[str, str]]:
+    import xlrd
+
+    wb = xlrd.open_workbook(file_contents=content)
+    ws = wb.sheet_by_index(0)
+    all_rows = []
+    for r in range(ws.nrows):
+        row = []
+        for c in range(ws.ncols):
+            cell = ws.cell(r, c)
+            if cell.ctype == xlrd.XL_CELL_DATE:
+                try:
+                    dt = xlrd.xldate_as_datetime(cell.value, wb.datemode)
+                    row.append(dt.isoformat())
+                except Exception:
+                    row.append(str(cell.value))
+            elif cell.ctype == xlrd.XL_CELL_EMPTY:
+                row.append(None)
+            else:
+                row.append(cell.value)
+        all_rows.append(tuple(row))
+    return _extract_table(all_rows)
+
+
+# ---------------------------------------------------------------------------
+# Shared table extraction (XLSX + XLS)
+# ---------------------------------------------------------------------------
+
+def _extract_table(all_rows: list[tuple]) -> list[dict[str, str]]:
+    if not all_rows:
+        return []
+
+    header_idx, col_offset = _find_header(all_rows)
+    if header_idx is None:
+        return []
+
+    raw_headers = all_rows[header_idx]
+    headers: list[str] = []
+    for cell in raw_headers[col_offset:]:
+        if cell is None:
+            headers.append("")
+        else:
+            h = str(cell).replace("\n", " ").strip()
+            headers.append(h)
+
+    # drop trailing None/empty headers
+    while headers and not headers[-1]:
+        headers.pop()
+
+    result: list[dict[str, str]] = []
+    for row in all_rows[header_idx + 1:]:
+        row_slice = row[col_offset: col_offset + len(headers)]
+        # pad
+        row_slice = list(row_slice) + [""] * (len(headers) - len(row_slice))
+
+        row_dict: dict[str, str] = {}
+        for i, h in enumerate(headers):
+            if not h:
+                continue
+            cell = row_slice[i]
+            row_dict[h] = _cell_to_str(cell)
+
+        # stop at footer / totals
+        first_val = next((v for v in row_dict.values() if v), "")
+        if _FOOTER_RE.match(first_val) or _TOTALS_RE.match(first_val):
+            break
+
+        # skip rows where every value is empty (blank separator rows)
+        if not any(row_dict.values()):
+            continue
+
+        result.append(row_dict)
+    return result
+
+
+def _find_header(all_rows: list[tuple]) -> tuple[int | None, int]:
+    from bom_assistant.ingestion.column_mapper import _load_synonyms
+
+    all_synonyms: set[str] = set()
+    for syns in _load_synonyms().values():
+        all_synonyms.update(s.lower() for s in syns)
+
+    for row_idx, row in enumerate(all_rows[:15]):
+        string_cells = [c for c in row if c is not None and isinstance(c, str) and c.strip()]
+        if len(string_cells) < 2:
+            continue
+        matches = sum(1 for c in string_cells if c.strip().lower() in all_synonyms)
+        if matches >= 1:
+            # find first non-None column offset
+            col_offset = next((i for i, c in enumerate(row) if c is not None), 0)
+            return row_idx, col_offset
+    return None, 0
+
+
+def _cell_to_str(cell) -> str:
+    if cell is None:
+        return ""
+    if isinstance(cell, datetime):
+        return cell.isoformat()
+    if isinstance(cell, float) and cell == int(cell):
+        return str(int(cell))
+    return str(cell).strip()

+ 0 - 0
bom_assistant/session/__init__.py


BIN
bom_assistant/session/__pycache__/__init__.cpython-314.pyc


BIN
bom_assistant/session/__pycache__/models.cpython-314.pyc


+ 63 - 0
bom_assistant/session/models.py

@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class ComponentCategory(str, Enum):
+    capacitor = "capacitor"
+    resistor = "resistor"
+    inductor = "inductor"
+    diode = "diode"
+    led = "led"
+    ic = "ic"
+    connector = "connector"
+    crystal = "crystal"
+    mosfet = "mosfet"
+    sensor = "sensor"
+    other = "other"
+
+
+class RowState(str, Enum):
+    pending = "pending"
+    scouting = "scouting"
+    filtering = "filtering"
+    confirmed = "confirmed"
+    flagged = "flagged"
+
+
+class NormalizedParams(BaseModel):
+    package: str | None = None
+    value: float | None = None       # SI base: F / Ω / H / Hz
+    value_str: str | None = None     # human-readable: "100nF", "10kΩ"
+    unit: str | None = None          # "F" | "Ω" | "H" | "Hz"
+    voltage_rating: float | None = None
+    current_rating: float | None = None
+    power_rating: float | None = None
+    tolerance: str | None = None     # "5%", "10ppm"
+    part_number: str | None = None   # MPN if found in BOM
+    manufacturer: str | None = None
+
+
+class BomRow(BaseModel):
+    row_id: str
+    designators: list[str] = Field(default_factory=list)
+    quantity: int = 1
+    raw_value: str
+    footprint: str | None = None
+    category: ComponentCategory
+    normalized_params: NormalizedParams
+    state: RowState = RowState.pending
+    applied_filters: dict[str, Any] = Field(default_factory=dict)
+    facet_groups: dict[str, Any] = Field(default_factory=dict)
+    confirmed_pick: dict[str, Any] | None = None
+
+
+class BomSession(BaseModel):
+    session_id: str
+    created_at: str
+    filename: str
+    preferred_suppliers: list[str] = Field(default_factory=list)
+    rows: list[BomRow] = Field(default_factory=list)

BIN
examples/bom/BOM.xlsx


BIN
examples/bom/BOM_G5Dot_REV_01.01_VARIANT_Mosaic-G5_P3H.xlsx


BIN
examples/bom/BOM_PI POLONES DETECTOR DE METAIS.xlsx


BIN
examples/bom/BOM_imx415_DigitalFPV (1).xlsx


BIN
examples/bom/Cashstick BOM.xlsx


+ 5 - 0
examples/bom/ESP32 WITH FLASHER.csv

@@ -0,0 +1,5 @@
+"Reference","Qty","Value","DNP","Exclude from BOM","Exclude from Board","Footprint","Datasheet"
+"D1,D2,D3,D4,D5,D6,D7,D8,D9,D10","10","15400385A3590","","","","WL-TIRW_D3.9H5.3",""
+"J1","1","10118193-0001LF","","","","AMPHENOL_10118193-0001LF",""
+"R1,R2,R3,R4,R5,R6,R7,R8,R9,R10","10","ERJ-8GEY0R00V","","","","RESC3216X70X50NL05T20",""
+"U1","1","ESP32-WROOM-32","","","","MODULE_ESP32-WROOM-32",""

BIN
examples/bom/FPGA1394V3-BOM.xlsx


BIN
examples/bom/QLA-BOM.xls


BIN
examples/bom/QLA-BOM.xlsx


BIN
examples/bom/bill of material.xlsx


+ 55 - 0
examples/bom/bom (1).csv

@@ -0,0 +1,55 @@
+Comment,Designator,Footprint,Quantity
+"330u, 63V","C38, C39",CP_10x20mm_hor_with_model_brd_edge,2,,Blue highlighted componenets are not avaiable from JLCPCB's SMT service.
+2.2u,"C5, C8, C19, C25, C26, C28, C30, C45, C46, C47, C48, C49, C53, C54, C55, C56",C_0603_1608Metric,16,,
+1nF,"C16, C29, C33, C34, C44, C57, C58, C61, C62, C63, C65, C66, C67",C_0603_1608Metric,13,,
+100n,"C1, C6, C7, C17, C32, C43, C52, C59, C60, C64",C_0603_1608Metric,10,,
+0.01uF,"C2, C3, C4",C_0603_1608Metric,3,,
+220n,"C18, C24, C27",C_0603_1608Metric,3,,
+15p,"C50, C51",C_0603_1608Metric,2,,
+6.8n,C12,C_0603_1608Metric,1,,
+150p,C13,C_0603_1608Metric,1,,
+15nF,C14,C_0603_1608Metric,1,,
+22n,C23,C_0603_1608Metric,1,,
+22uf,"C9, C10, C11, C20, C21, C22",C_0805_2012Metric,6,,
+"4.7nF, 100V","C40, C41, C42",C_0805_2012Metric,3,,
+"4.7uF, 100V","C15, C31",C_1210_3225Metric,2,,
+"10uF, 100V","C35, C36, C37",C_2220_5650Metric_Pad1.97x5.40mm_HandSolder,3,,
+2k2,"R2, R12, R15, R16, R17, R25, R29, R32, R38",R_0603_1608Metric,9,,
+1k,"R24, R26, R28, R30, R66, R67, R69, R70",R_0603_1608Metric,8,,
+100R,"R5, R6, R7, R8, R9, R10, R11",R_0603_1608Metric,7,,
+10k,"R13, R14, R22, R23, R34, R35",R_0603_1608Metric,6,,
+4.7k,"R44, R45, R46, R50, R51, R52",R_0603_1608Metric,6,,
+39k,"R1, R27, R31, R36",R_0603_1608Metric,4,,
+22R,"R3, R4",R_0603_1608Metric,2,,
+56R,"R62, R63",R_0603_1608Metric,2,,
+20k,"R64, R65",R_0603_1608Metric,2,,
+3.3k,R18,R_0603_1608Metric,1,,
+220k,R19,R_0603_1608Metric,1,,
+15k,R20,R_0603_1608Metric,1,,
+18k,R21,R_0603_1608Metric,1,,
+10R,"R33, R37, R39, R41, R42, R43, R47, R48, R49, R53, R54, R55",R_0805_2012Metric,12,,
+0.001R,"R56, R57, R58, R59, R60, R61",R_2512_Yageo_big_pads,6,,
+22u,L1,L_Taiyo-Yuden_NR-60xx_HandSoldering,1,,
+"100v, 1A",D6,D_SMA,1,,
+RED,D3,LED_0603_1608Metric,1,,
+GRN,D4,LED_0603_1608Metric,1,,
+BLU,D5,LED_0603_1608Metric,1,,
+ SMF05CT1G,"D1, D2",SOT-363_SC-70-6,2,,
+BMI160,U6,Bosch_LGA-14_3x2.5mm_P0.5mm,1,,
+DRV8301,U2,HTSSOP-56-1EP_6.1x14mm_P0.5mm_EP3.61x6.35mm,1,,
+STM32F405RGT6,U5,LQFP-64_10x10mm_P0.5mm,1,,
+TP2604-SR,U3,SOIC-8_3.9x4.9mm_P1.27mm,1,,
+TJA1051T/3/1J,U4,SOIC-8_3.9x4.9mm_P1.27mm,1,,
+AMS1117-3.3,U1,SOT-223-3_TabPin2,1,,
+8MHz 10ppm (X50328MSB2GI),X1,Crystal_SMD_5032-2Pin_5.0x3.2mm,1,,
+NTC 10k,NTC1,R_0603_1608Metric,1,,
+USB Micro  U-D-M5DD-W-1,USB1,USB_Micro-B_Wuerth_614105150721_Vertical,1,,
+Conn_01x02,J1,JST_PH_B2B-PH-K_1x02_P2.00mm_Vertical,1,,
+On/Off,J4,JST_PH_B2B-PH-K_1x02_P2.00mm_Vertical,1,,
+Conn_01x03,J3,JST_PH_B3B-PH-K_1x03_P2.00mm_Vertical,1,,
+Conn_01x07,J2,JST_PH_B7B-PH-K_1x07_P2.00mm_Vertical,1,,
+UNUSED MCU PINS,J5,PinHeader_2x05_P2.00mm_Vertical,1,,
+HALL/Encoder,P4,PH-6AK,1,,
+SWD,P5,PinHeader_1x05_P2.54mm_Vertical,1,,
+NCEP60T20,"Q1, Q2, Q3, Q4, Q5, Q6",TO-220-3_Horizontal_TabUp_no_hole,6,,
+

+ 19 - 0
examples/bom/machinka_control_v1_03042026_bom.csv

@@ -0,0 +1,19 @@
+Designator,Footprint,Quantity,Value,LCSC Part #
+"C1, C4, C6, C8",CP_Elec_6.3x5.8,4,22u,
+"C13, C14, C15, C16",C_Elec_6.3x5.8,4,100u,
+"C2, C3, C5, C7",0805,4,100n,
+D1,0603,1,LED,
+J1,PinHeader_2x20_P2.54mm_Vertical,1,Raspberry_Pi_2_3,
+"J10, J8",PinHeader_1x04_P2.54mm_Vertical,2,Conn_01x04,
+"J11, J13, J14, J2, J3, J4, J5, J9",TerminalBlock_MetzConnect_Type101_RT01602HBWC_1x02_P5.08mm_Horizontal,8,Screw_Terminal_01x02,
+"J6, J7",PinHeader_1x02_P2.54mm_Vertical,2,Conn_01x02,
+"M1, M2, M3, M4",PinHeader_1x03_P2.54mm_Vertical,4,Motor_Servo,
+"R1, R2, R3, R4",0603,4,30k,
+"R24, R28, R31, R38, R39, R8",0603,6,10k,
+R5,0603,1,150k,
+R6,0603,1,33k,
+R7,0603,1,120,
+U1,esp32_S3_module,1,ESP32-S3-DEVKITC-1 Mod,
+U13,SO-8_STM,1,VN5E160S-E,
+"U2, U3, U4, U5",Texas_HTSOP-8-1EP_3.9x4.9mm_P1.27mm_EP2.95x4.9mm_Mask2.4x3.1mm_ThermalVias,4,DRV8871DDA,
+U6,ZXDN10S1205SAW,1,ZXDN10,

+ 61 - 0
examples/bom/metal detector test_bom_V2.csv

@@ -0,0 +1,61 @@
+Component;Description;Part;References;Value;Footprint;Quantity Per PCB;Datasheet;MPN;Alt MPN
+1;Multiple-cell battery;Battery;BT1;12V (18650 x3);PinHeader_1x02_P2.54mm_Vertical;1;~;106990288;
+2;Unpolarized capacitor, small symbol;C_Small;C8;10n;C_0805_2012Metric_Pad1.18x1.45mm_HandSolder;1;~;;
+3;Unpolarized capacitor, small symbol;C_Small;C7 C26;33n;C_0805_2012Metric_Pad1.18x1.45mm_HandSolder;2;~;;
+4;Unpolarized capacitor, small symbol;C_Small;C4 C5 C6 C10 C13 C14 C16 C18 C20 C23 C24 C27 C28 C29 C30;100n;C_0805_2012Metric_Pad1.18x1.45mm_HandSolder;15;~;;
+5;Unpolarized capacitor, small symbol;C_Small;C9 C19;.33u;C_0805_2012Metric_Pad1.18x1.45mm_HandSolder;2;~;;
+6;Unpolarized capacitor, small symbol;C_Small;C2;470n;C_1206_3216Metric_Pad1.33x1.80mm_HandSolder;1;~;;
+7;Polarized capacitor, small US symbol;C_Polarized_Small_US;C11 C21;3.3u;C_Polarized_0805_2012Metric_HandSolder_allen;2;~;;
+8;Polarized capacitor, small US symbol;C_Polarized_Small_US;C12;10u;C_Polarized_1206_3216Metric_HandSolder_allen;1;~;;
+9;Polarized capacitor, small US symbol;C_Polarized_Small_US;C3;22u;C_Polarized_1206_3216Metric_HandSolder_allen;1;~;;
+10;Polarized capacitor, small US symbol;C_Polarized_Small_US;C1 C17;100u;C_Polarized_1206_3216Metric_HandSolder_allen;2;~;;
+11;Polarized capacitor, US symbol;C_Polarized_US;C15 C22 C25;1000u;CP_Radial_D10.0mm_P5.00mm;3;~;A750MV108M1EAAE014;
+12;Teyleten Robot 2.42 inch 128x64 OLED Display Module SSD1309 With 7 Pin SPI/IIC Interface;2.42_inch_128x64_oled;DS1;2.42_inch_128x64_oled;oled_2.42in_128x64;1;https://www.amazon.com/gp/product/B09LND6QJ1/ref=ppx_yo_dt_b_search_asin_title?ie=UTF8&psc=1#customerReviews;B09LND6QJ1;
+13;Ferrite bead, small symbol;FerriteBead_Small;FB1;ferrite bead smt;L_0805_2012Metric_Pad1.05x1.20mm_HandSolder;1;~;HZ0805E601R-10;
+14;Audio Jack, 3 Poles (Stereo / TRS), Switched T Pole (Normalling);AudioJack3_SwitchT;J1;3.5mm audio jack TRS;SJ-3524-SMT_audio_jack_with_tip_switch_smt;1;~;SJ-3524-SMT;
+;;;;;;;;;
+;;;;;;;;;
+;;;;;;;;;
+;;;;;;;;;
+;;;;;;;;;
+20;NPN transistor, base/collector/emitter;Q_NPN_BCE;Q2;NPN;SOT-89-3_Handsoldering_allen;1;~;2SC5824T100Q;
+21;PNP transistor, base/collector/emitter;Q_PNP_BCE;Q1;PNP;SOT-89-3_Handsoldering_allen;1;~;2SA2071T100Q;
+22;Resistor, small US symbol;R_Small_US;R13 R16;2.7;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;2;~;;
+23;Resistor, small US symbol;R_Small_US;R3;560;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;1;~;;
+24;Resistor, small US symbol;R_Small_US;R2;1.2k;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;1;~;;
+25;Resistor, small US symbol;R_Small_US;R11;2k;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;1;~;;
+26;Resistor, small US symbol;R_Small_US;R15 R17;5k1;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;2;~;;
+27;Resistor, small US symbol;R_Small_US;R12;10k;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;1;~;;
+28;Resistor, small US symbol;R_Small_US;R9;12k;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;1;~;;
+29;Resistor, small US symbol;R_Small_US;R4 R8;18k;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;2;~;;
+30;Resistor, small US symbol;R_Small_US;R1;27k;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;1;~;;
+31;Resistor, small US symbol;R_Small_US;R10;33k;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;1;~;;
+32;Resistor, small US symbol;R_Small_US;R14 R18;49.9k;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;2;~;;
+33;Resistor, small US symbol;R_Small_US;R5 R6 R7 R19 R20;100k;R_0805_2012Metric_Pad1.20x1.40mm_HandSolder;5;~;;
+34;Single Pole Single Throw (SPST) switch;SW_SPST;SW0 SW1 SW2 SW3 SW4 SW5 SW6 SW7 SW8;SW_SPST;SW_SPST_PTS647;9;~;PTS 647 SN50 SMTR2 LFS;
+35;Single Pole Single Throw (SPST) switch;SW_SPST;SW9;SW_SPST;A11AH_right_angle_toggle_switch;1;~;A11AH;
+36;700mA Micropower Low drop-out regulator, Fixed Output 3.3V, SOT-223;LT1129-3.3_SOT223;U5 U10;3.3v lin reg;SOT-223-3_TabPin2;2;https://www.analog.com/media/en/technical-documentation/data-sheets/112935ff.pdf;LT1129CST-3.3#PBF;UA78M33QDCYRG4Q1
+37;Positive 1.5A 35V Linear Regulator, Fixed Output 5V, TO-220/TO-263/TO-252;L7805;U4 U9;5v lin reg;SOT-223-3_TabPin2;2;https://www.ti.com/lit/ds/slvs059t/slvs059t.pdf;UA78M05CDCYRG3;LM2940IMP-5.0/NOPB
+38;700mA Micropower Low drop-out regulator, Fixed Output 3.3V, SOT-223;LT1129-3.3_SOT223;U1;9v lin reg;SOT-223-3_TabPin2;1;https://www.ti.com/lit/ds/symlink/lm2940-n.pdf?HQS=dis-dk-null-digikeymode-dsf-pf-null-wwe&ts=1680023082050&ref_url=https%253A%252F%252Fwww.ti.com%252Fgeneral%252Fdocs%252Fsuppproductinfo.tsp%253FdistId%253D10%2526gotoUrl%253Dhttps%253A%252F%252Fwww.ti.com%252Flit%252Fgpn%252Flm2940-n;LM2940IMP-9.0/NOPB;
+39;Stereo 11W Audio Power Amplifier, TO-263-7;LM4752TS;U6;audio amp;TO-263-7_TabPin4;1;http://www.ti.com/lit/ds/symlink/lm4752.pdf;LM4752TS;
+40;Switched Cap Low-Pass Filter;MAX7423EUA+_allen;U7 U11;lp filter;TSSOP-8_4.4x3mm_P0.65mm;2;https://www.analog.com/media/en/technical-documentation/data-sheets/max7418-max7425.pdf;MAX7423EUA+;
+41;STM32 NUCLEO32 module with the STM32F303K8 MCU;NUCLEO-F303K8;U3;nucleo-32 uC module;Nucleo-32_allen;1;https://www.st.com/resource/en/user_manual/um1956-stm32-nucleo32-boards-mb1180-stmicroelectronics.pdf;NUCLEO-F303K8;
+42;Precision, 10-MHz, Low-Noise, Low-Power, RRIO, CMOS Operational Amplifier, SOIC-8;OPA2140AID;U2;op amp (higher voltage);SOIC-8_3.9x4.9mm_P1.27mm;1;https://www.ti.com/lit/ds/symlink/opa2140.pdf?HQS=dis-dk-null-digikeymode-dsf-pf-null-wwe&ts=1680023654676&ref_url=https%253A%252F%252Fwww.ti.com%252Fgeneral%252Fdocs%252Fsuppproductinfo.tsp%253FdistId%253D10%2526gotoUrl%253Dhttps%253A%252F%252Fwww.ti.com%252Flit%252Fgpn%252Fopa2140;OPA2140AID;
+43;Precision, 10-MHz, Low-Noise, Low-Power, RRIO, CMOS Operational Amplifier, SOIC-8;OPA2320AID;U12;op amp (lower voltage);SOIC-8_3.9x4.9mm_P1.27mm;1;https://www.ti.com/lit/gpn/OPA2320;OPA2320AID;
+44;Switch Debounce IC;LS20-S;U8;switch debounce;SOIC-20W_7.5x12.8mm_P1.27mm;1;https://www.logiswitch.com/wp-content/uploads/LS10-Series-ICs-Technical-Details.pdf;LS20-S;
+;;;;;;;;;
+;;;;;;;;;
+;;;;;;;;;
+;;;;;;;;;
+;;;;;;;;;
+Component Groups:;44;;;;;;;;
+Component Count:;87;;;;;;;;
+Fitted Components:;87;;;;;;;;
+Number of PCBs:;1;;;;;;;;
+Total components:;87;;;;;;;;
+Schematic Version:;V2;;;;;;;;
+Schematic Date:;2023-03-24;;;;;;;;
+PCB Variant:;default;;;;;;;;
+BoM Date:;3/28/2023 11:18:38 AM;;;;;;;;
+Schematic Source:;metal detector test.kicad_sch;;;;;;;;
+KiCad Version:;Eeschema 7.0.1;;;;;;;;

+ 7 - 0
requirements.txt

@@ -0,0 +1,7 @@
+fastapi
+uvicorn
+pydantic>=2
+python-multipart
+openpyxl
+xlrd>=2.0
+anthropic

+ 19 - 0
test.sh

@@ -0,0 +1,19 @@
+#!/usr/bin/sh
+source .venv/bin/activate
+
+path=$1
+python -c "
+from bom_assistant.ingestion.parser import parse_bom
+from bom_assistant.ingestion.column_mapper import map_columns
+from bom_assistant.ingestion.normalizer import normalize
+import pathlib, json
+
+f = pathlib.Path('$path')
+raw = parse_bom(f.read_bytes(), f.name)
+mapped = map_columns(raw)
+rows = normalize(mapped)
+
+for r in rows:
+    p = r.normalized_params
+    print(f'{r.state.value:8} [{r.category.value:10}] {str(r.designators):30} val={p.value_str!r:10} pkg={p.package!r} V={p.voltage_rating}')
+"