Treat KiCad Value/PNM as the IC MPN when the MPN column is empty.
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -301,7 +301,9 @@ def build_graph(
|
|||||||
for ref, footprint in parts.items():
|
for ref, footprint in parts.items():
|
||||||
bom_entry = bom.get(ref, {})
|
bom_entry = bom.get(ref, {})
|
||||||
value = bom_entry.get("value", "")
|
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(
|
components[ref] = Component(
|
||||||
reference=ref,
|
reference=ref,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
@@ -264,17 +265,27 @@ def parse_bom(
|
|||||||
refs_raw = row.get(reference_col, "")
|
refs_raw = row.get(reference_col, "")
|
||||||
value = row.get("Value", "") or row.get("Comment", "")
|
value = row.get("Value", "") or row.get("Comment", "")
|
||||||
footprint = row.get("Footprint", "")
|
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
|
lcsc = row.get("LCSC", "") or None
|
||||||
|
datasheet_url = (row.get("Datasheet", "") or "").strip() or None
|
||||||
|
|
||||||
# Expand grouped references: "C1,C2,C5" -> ["C1", "C2", "C5"]
|
# Expand grouped references: "C1,C2,C5" -> ["C1", "C2", "C5"]
|
||||||
for ref in (r.strip() for r in refs_raw.split(",")):
|
refs = [r.strip() for r in refs_raw.split(",") if r.strip()]
|
||||||
if ref:
|
# KiCad exports often leave Manufacturer Part Number empty and put
|
||||||
result[ref] = {
|
# the orderable code in Value (or PNM). Without this, U* never
|
||||||
"value": value,
|
# enter ic_mpns and review reports "no datasheet PDF".
|
||||||
"footprint": footprint,
|
if not mpn:
|
||||||
"mpn": mpn,
|
mpn = (row.get("PNM", "") or "").strip() or None
|
||||||
"lcsc": lcsc,
|
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
|
return result
|
||||||
|
|||||||
@@ -382,10 +382,13 @@ async def _from_digikey(mpn: str) -> DatasheetHit | None:
|
|||||||
return DatasheetHit(mpn, error=result.error, url=result.url, source="digikey")
|
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``.
|
"""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()
|
mpn = (mpn or "").strip()
|
||||||
if not mpn:
|
if not mpn:
|
||||||
@@ -394,6 +397,16 @@ async def find_datasheet(mpn: str, lcsc_id: str | None = None) -> DatasheetHit:
|
|||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
last_url: str | None = None
|
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):
|
for source_fn in (_from_lcsc, _from_ti, _from_digikey):
|
||||||
try:
|
try:
|
||||||
if source_fn is _from_lcsc:
|
if source_fn is _from_lcsc:
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ class PipelineContext:
|
|||||||
# the primary numeric value from here without saving to the shared library.
|
# the primary numeric value from here without saving to the shared library.
|
||||||
passive_values: dict[str, str] = field(default_factory=dict)
|
passive_values: dict[str, str] = field(default_factory=dict)
|
||||||
simple_mpns: dict[str, list[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,
|
# Cached purple-parts payload (description, category, subcategory, manufacturer,
|
||||||
# package, ...) keyed by *resolved* MPN. Populated by _resolve_lcsc_codes during
|
# package, ...) keyed by *resolved* MPN. Populated by _resolve_lcsc_codes during
|
||||||
# BOM parse; consumed by passive extraction as a first-pass auto-resolve source
|
# 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()):
|
for ref, info in sorted(bom.items()):
|
||||||
mpn = info.get("mpn")
|
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:
|
if not mpn:
|
||||||
continue
|
continue
|
||||||
typ = type_for_ref(ref)
|
typ = type_for_ref(ref)
|
||||||
@@ -705,7 +708,11 @@ async def _ensure_local_datasheet(
|
|||||||
{"stage": stage, "substep": mpn,
|
{"stage": stage, "substep": mpn,
|
||||||
"status": "running", "detail": "finding datasheet"},
|
"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:
|
if not hit.ok or not hit.pdf_bytes:
|
||||||
return False
|
return False
|
||||||
pdf_path.parent.mkdir(parents=True, exist_ok=True)
|
pdf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -231,11 +231,14 @@ function classifyBom(
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const refsRaw = row[refCol] || "";
|
const refsRaw = row[refCol] || "";
|
||||||
const mpn = row[mpnCol] || "";
|
const value = (row.Value || row.Comment || "").trim();
|
||||||
|
const mpnFromCol = (row[mpnCol] || "").trim();
|
||||||
const refs = refsRaw
|
const refs = refsRaw
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((r) => r.trim())
|
.map((r) => r.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
const hasIc = refs.some((r) => /^U\d/i.test(r));
|
||||||
|
const mpn = mpnFromCol || (hasIc ? value : "");
|
||||||
|
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
totalRefs++;
|
totalRefs++;
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user