From cb001a8bbbd6fe3224dd8dd99f92d6c2d572a5e3 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Fri, 28 Aug 2026 10:38:40 +0200 Subject: [PATCH] Treat KiCad Value/PNM as the IC MPN when the MPN column is empty. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Those 13 U* were in the BOM (TPS22965, TPD2E007, TMP117, …) but never entered ic_mpns, so review skipped them as missing PDFs. Also try the BOM Datasheet URL first. Co-authored-by: Cursor --- backend/pinscopex/graph.py | 4 ++- backend/pinscopex/parsers.py | 29 +++++++++++------ backend/services/datasheet_finder.py | 17 ++++++++-- backend/services/pipeline.py | 11 +++++-- .../dashboard/create-project-dialog.tsx | 5 ++- tests/test_bom_parser.py | 31 +++++++++++++++++++ 6 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 tests/test_bom_parser.py diff --git a/backend/pinscopex/graph.py b/backend/pinscopex/graph.py index 9ad823f..3b0afa9 100644 --- a/backend/pinscopex/graph.py +++ b/backend/pinscopex/graph.py @@ -301,7 +301,9 @@ def build_graph( for ref, footprint in parts.items(): bom_entry = bom.get(ref, {}) value = bom_entry.get("value", "") - mpn = bom_entry.get("mpn") + mpn = bom_entry.get("mpn") or None + if not mpn and _classify_component(ref, footprint) == ComponentType.IC: + mpn = (value or "").strip() or None components[ref] = Component( reference=ref, diff --git a/backend/pinscopex/parsers.py b/backend/pinscopex/parsers.py index e38ffd9..4b1aa79 100644 --- a/backend/pinscopex/parsers.py +++ b/backend/pinscopex/parsers.py @@ -3,6 +3,7 @@ from __future__ import annotations import csv +import re from pathlib import Path from typing import Literal @@ -264,17 +265,27 @@ def parse_bom( refs_raw = row.get(reference_col, "") value = row.get("Value", "") or row.get("Comment", "") footprint = row.get("Footprint", "") - mpn = row.get(mpn_col, "") or None + mpn = (row.get(mpn_col, "") or "").strip() or None lcsc = row.get("LCSC", "") or None + datasheet_url = (row.get("Datasheet", "") or "").strip() or None # Expand grouped references: "C1,C2,C5" -> ["C1", "C2", "C5"] - for ref in (r.strip() for r in refs_raw.split(",")): - if ref: - result[ref] = { - "value": value, - "footprint": footprint, - "mpn": mpn, - "lcsc": lcsc, - } + refs = [r.strip() for r in refs_raw.split(",") if r.strip()] + # KiCad exports often leave Manufacturer Part Number empty and put + # the orderable code in Value (or PNM). Without this, U* never + # enter ic_mpns and review reports "no datasheet PDF". + if not mpn: + mpn = (row.get("PNM", "") or "").strip() or None + if not mpn and any(re.match(r"^U\d", r, re.I) for r in refs): + mpn = (value or "").strip() or None + + for ref in refs: + result[ref] = { + "value": value, + "footprint": footprint, + "mpn": mpn, + "lcsc": lcsc, + "datasheet_url": datasheet_url, + } return result diff --git a/backend/services/datasheet_finder.py b/backend/services/datasheet_finder.py index b8d3383..478652f 100644 --- a/backend/services/datasheet_finder.py +++ b/backend/services/datasheet_finder.py @@ -382,10 +382,13 @@ async def _from_digikey(mpn: str) -> DatasheetHit | None: return DatasheetHit(mpn, error=result.error, url=result.url, source="digikey") -async def find_datasheet(mpn: str, lcsc_id: str | None = None) -> DatasheetHit: +async def find_datasheet( + mpn: str, lcsc_id: str | None = None, url_hint: str | None = None, +) -> DatasheetHit: """Find and download a datasheet PDF for ``mpn``. - Tries LCSC, then TI (when the MPN looks like a TI part), then DigiKey. + Tries an explicit BOM URL first, then LCSC, then TI (when the MPN + looks like a TI part), then DigiKey. """ mpn = (mpn or "").strip() if not mpn: @@ -394,6 +397,16 @@ async def find_datasheet(mpn: str, lcsc_id: str | None = None) -> DatasheetHit: errors: list[str] = [] last_url: str | None = None + hint = (url_hint or "").strip() + if hint.startswith("http"): + try: + pdf = await _download_pdf(hint) + return DatasheetHit(mpn, pdf_bytes=pdf, url=hint, source="bom") + except Exception as exc: + log.info("BOM datasheet URL missed %s: %s", mpn, exc) + errors.append(f"bom: {exc}") + last_url = hint + for source_fn in (_from_lcsc, _from_ti, _from_digikey): try: if source_fn is _from_lcsc: diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index 220f1f2..5c86e45 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -369,7 +369,7 @@ class PipelineContext: # the primary numeric value from here without saving to the shared library. passive_values: dict[str, str] = field(default_factory=dict) simple_mpns: dict[str, list[str]] = field(default_factory=dict) - simple_mpn_types: dict[str, str] = field(default_factory=dict) + datasheet_urls: dict[str, str] = field(default_factory=dict) # Cached purple-parts payload (description, category, subcategory, manufacturer, # package, ...) keyed by *resolved* MPN. Populated by _resolve_lcsc_codes during # BOM parse; consumed by passive extraction as a first-pass auto-resolve source @@ -605,6 +605,9 @@ async def _stage_bom_parse(ctx: PipelineContext) -> None: for ref, info in sorted(bom.items()): mpn = info.get("mpn") + url = (info.get("datasheet_url") or "").strip() + if mpn and url and mpn not in ctx.datasheet_urls: + ctx.datasheet_urls[mpn] = url if not mpn: continue typ = type_for_ref(ref) @@ -705,7 +708,11 @@ async def _ensure_local_datasheet( {"stage": stage, "substep": mpn, "status": "running", "detail": "finding datasheet"}, ) - hit = await find_datasheet(mpn, lcsc_id=_lcsc_id_for_mpn(ctx, mpn)) + hit = await find_datasheet( + mpn, + lcsc_id=_lcsc_id_for_mpn(ctx, mpn), + url_hint=ctx.datasheet_urls.get(mpn), + ) if not hit.ok or not hit.pdf_bytes: return False pdf_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/frontend/src/components/dashboard/create-project-dialog.tsx b/frontend/src/components/dashboard/create-project-dialog.tsx index 5eb23eb..e79516c 100644 --- a/frontend/src/components/dashboard/create-project-dialog.tsx +++ b/frontend/src/components/dashboard/create-project-dialog.tsx @@ -231,11 +231,14 @@ function classifyBom( for (const row of rows) { const refsRaw = row[refCol] || ""; - const mpn = row[mpnCol] || ""; + const value = (row.Value || row.Comment || "").trim(); + const mpnFromCol = (row[mpnCol] || "").trim(); const refs = refsRaw .split(",") .map((r) => r.trim()) .filter(Boolean); + const hasIc = refs.some((r) => /^U\d/i.test(r)); + const mpn = mpnFromCol || (hasIc ? value : ""); for (const ref of refs) { totalRefs++; diff --git a/tests/test_bom_parser.py b/tests/test_bom_parser.py new file mode 100644 index 0000000..fc38384 --- /dev/null +++ b/tests/test_bom_parser.py @@ -0,0 +1,31 @@ +"""KiCad BOM: Value/PNM as MPN when Manufacturer Part Number is empty.""" + +from pathlib import Path + +from backend.pinscopex.parsers import parse_bom + + +def test_parse_bom_uses_value_for_ic_when_mpn_column_empty(tmp_path: Path): + csv = tmp_path / "bom.csv" + csv.write_text( + "Reference,Qty,Value,DNP,Footprint,Datasheet,PNM\n" + "U1,1,TPS22965DSGR,,SON-8,,\n" + '"U9,U13",5,TPD2E007DCKR,,SOT-23,,\n' + "C1,1,100nF,,0805,,\n" + ) + bom = parse_bom(csv, mpn_col="Manufacturer Part Number") + assert bom["U1"]["mpn"] == "TPS22965DSGR" + assert bom["U9"]["mpn"] == "TPD2E007DCKR" + assert bom["U13"]["mpn"] == "TPD2E007DCKR" + assert bom["C1"]["mpn"] is None + + +def test_parse_bom_keeps_pnm_and_datasheet_url(tmp_path: Path): + csv = tmp_path / "bom.csv" + csv.write_text( + "Reference,Value,PNM,Datasheet\n" + "U31,TMP117,TMP117NAIDRVR,https://www.ti.com/lit/ds/symlink/tmp117.pdf\n" + ) + bom = parse_bom(csv, mpn_col="Manufacturer Part Number") + assert bom["U31"]["mpn"] == "TMP117NAIDRVR" + assert bom["U31"]["datasheet_url"].endswith("tmp117.pdf")