diff --git a/periscope/src/backend/periscopex/graph.py b/periscope/src/backend/periscopex/graph.py index c8688ec..99750f3 100644 --- a/periscope/src/backend/periscopex/graph.py +++ b/periscope/src/backend/periscopex/graph.py @@ -303,8 +303,15 @@ def build_graph( parts, raw_nets = _apply_pcb_nets(pcb, parts, raw_nets) schematic_fields: dict[str, dict] = {} + net_path = Path(netlist_path) if fmt.startswith("kicad"): - schematic_fields = _merge_kicad_fields(Path(netlist_path), bom) + schematic_fields = _merge_kicad_fields(net_path, bom) + else: + from backend.periscopex.parsers_kicad import find_kicad_sch_root + + sch_root = find_kicad_sch_root(net_path.parent) + if sch_root is not None: + schematic_fields = _merge_kicad_fields(sch_root, bom) datasheets = _load_datasheets(datasheets_dir) models_dir = Path(component_models_dir) diff --git a/periscope/src/backend/periscopex/parsers_kicad.py b/periscope/src/backend/periscopex/parsers_kicad.py index 3b64e30..f53fb8a 100644 --- a/periscope/src/backend/periscopex/parsers_kicad.py +++ b/periscope/src/backend/periscopex/parsers_kicad.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any, Iterator _MPN_FIELD_NAMES = { - "mpn", "manufacturer part number", "manufacturer_part_number", + "mpn", "pnm", "manufacturer part number", "manufacturer_part_number", "manf#", "part number", "partnumber", "p/n", } @@ -682,6 +682,29 @@ def parse_kicad_sch_project( return parts, nets, fields +def find_kicad_sch_root(directory: str | Path) -> Path | None: + """Pick the hierarchical root ``.kicad_sch`` in *directory*, if any.""" + folder = Path(directory) + if not folder.is_dir(): + return None + scored: list[tuple[int, int, Path]] = [] + for path in folder.glob("*.kicad_sch"): + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + head = text.lstrip("\ufeff").lstrip()[:40] + if not head.startswith("(kicad_sch"): + continue + n_sheets = text.count('(property "Sheetfile"') + name_hit = 1 if path.stem.lower() == folder.name.lower() else 0 + scored.append((n_sheets, name_hit, path)) + if not scored: + return None + scored.sort(key=lambda row: (row[0], row[1], -len(row[2].name)), reverse=True) + return scored[0][2] + + # --------------------------------------------------------------------------- # Public # --------------------------------------------------------------------------- diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index c3ea31d..91af1f5 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Periscope. +## 2.60.5 — 2026-09-21 — Hierarchical schematic MPN when BOM omits a ref + +graph_build reads `PNM` on `.kicad_sch` symbols (all nested sheets). If the CSV has no row for a designator, the child-sheet `PNM`/`MPN`/IC `Value` still fills `mpn`. Empty child-sheet fields stay empty — no invented part numbers. A sibling `.kicad_sch` next to a PADS netlist is consulted the same way. + +- [Fixed] KiCad property `PNM` is an MPN alias on symbols, not only on BOM CSV. +- [New] Hierarchical fixture: U13 on a child sheet with PNM, omitted from CSV, still gets TPD2E007DCKR. + ## 2.60.4 — 2026-09-21 — KiCad PNM and honest missing-MPN skips KiCad BOM column `PNM` is an MPN alias. Grouped `Reference` lists still split. IC `Value` fills MPN when PNM is blank (PCA9534ARGTR). Netlist-only U* with no BOM row are `INSUFFICIENT_EVIDENCE` (quoted Value/Footprint), not a silent drop and not an invented part number. diff --git a/tests/test_kicad_parser.py b/tests/test_kicad_parser.py index 71cee9b..5da8f38 100644 --- a/tests/test_kicad_parser.py +++ b/tests/test_kicad_parser.py @@ -417,3 +417,112 @@ def test_cyclic_sheet_include_is_rejected(tmp_path: Path): )) with pytest.raises(ValueError, match="[Cc]yclic"): parse_kicad(root) + + +def _ic_symbol(ref: str, value: str, *, pnm: str = "", mpn: str = "", x: float = 0, y: float = 0) -> str: + uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0") + extra = "" + if pnm: + extra += ( + f' (property "PNM" "{pnm}" (at 0 0 0) ' + f'(effects (font (size 1.27 1.27))))\n' + ) + if mpn: + extra += ( + f' (property "MPN" "{mpn}" (at 0 0 0) ' + f'(effects (font (size 1.27 1.27))))\n' + ) + return f""" + (symbol + (lib_id "Device:R") + (at {x} {y} 0) + (unit 1) + (uuid "{uid}") + (property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27)))) + (property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27)))) +{extra} (pin "1" (uuid "p1{ref}")) + (pin "2" (uuid "p2{ref}")) + ) +""" + + +def _hier_root_with_child(tmp_path: Path, child_body: str) -> Path: + child = tmp_path / "codec.kicad_sch" + child.write_text(_sch( + child_body, + """ + (global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc")) +""", + )) + root = tmp_path / "root.kicad_sch" + root.write_text(_sch( + _resistor("R1", "10k"), + """ + (global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) + (sheet + (at 50 0) + (size 20 20) + (property "Sheetname" "Codec" (at 50 0 0) (effects (font (size 1.27 1.27)))) + (property "Sheetfile" "codec.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27)))) + ) +""", + )) + return root + + +def test_hierarchical_pnm_fills_graph_when_bom_omits_ref(tmp_path: Path): + from backend.periscopex.graph import build_graph + + root = _hier_root_with_child( + tmp_path, + _ic_symbol("U13", "TPD2E007DCKR", pnm="TPD2E007DCKR"), + ) + bom = tmp_path / "bom.csv" + bom.write_text( + "Reference,Value,Footprint,PNM\n" + "R1,10k,0603,\n" + '"U9,U14,U26",TPD2E007DCKR,SOT23,TPD2E007DCKR\n' + ) + g = build_graph( + root, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod", + mpn_col="Manufacturer Part Number", + ) + assert "U13" not in (bom.read_text()) + assert g.components["U13"].mpn == "TPD2E007DCKR" + assert g.schematic_fields["U13"]["cad_sheet"] == "codec.kicad_sch" + + +def test_hierarchical_empty_fields_do_not_invent_mpn(tmp_path: Path): + from backend.periscopex.graph import build_graph + + root = _hier_root_with_child(tmp_path, _ic_symbol("U15", "", pnm="", mpn="")) + bom = tmp_path / "bom.csv" + bom.write_text("Reference,Value,PNM\nR1,10k,\n") + g = build_graph( + root, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod", + mpn_col="Manufacturer Part Number", + ) + assert g.components["U15"].mpn in (None, "") + assert not (g.components["U15"].mpn or "").strip() + + +def test_pads_netlist_joins_sibling_hierarchical_sch(tmp_path: Path): + from backend.periscopex.graph import build_graph + + _hier_root_with_child( + tmp_path, + _ic_symbol("U13", "TPD2E007DCKR", pnm="TPD2E007DCKR"), + ) + asc = tmp_path / "netlist.asc" + asc.write_text( + "*PADS-PCB*\n*PART*\nU13 SOT23\nR1 0603\n*NET*\n" + "*SIGNAL* GND\nU13.1 R1.1\n*END*\n" + ) + bom = tmp_path / "bom.csv" + bom.write_text("Reference,Value,PNM\nR1,10k,\n") + g = build_graph( + asc, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod", + mpn_col="Manufacturer Part Number", + ) + assert g.components["U13"].mpn == "TPD2E007DCKR" +