Join KiCad hierarchical PNM/MPN when the BOM CSV omits a designator.
Child-sheet symbol fields fill graph mpn; empty sheet fields stay empty. A sibling .kicad_sch next to a PADS netlist is used the same way.
This commit is contained in:
@@ -303,8 +303,15 @@ def build_graph(
|
|||||||
parts, raw_nets = _apply_pcb_nets(pcb, parts, raw_nets)
|
parts, raw_nets = _apply_pcb_nets(pcb, parts, raw_nets)
|
||||||
|
|
||||||
schematic_fields: dict[str, dict] = {}
|
schematic_fields: dict[str, dict] = {}
|
||||||
|
net_path = Path(netlist_path)
|
||||||
if fmt.startswith("kicad"):
|
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)
|
datasheets = _load_datasheets(datasheets_dir)
|
||||||
models_dir = Path(component_models_dir)
|
models_dir = Path(component_models_dir)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
_MPN_FIELD_NAMES = {
|
_MPN_FIELD_NAMES = {
|
||||||
"mpn", "manufacturer part number", "manufacturer_part_number",
|
"mpn", "pnm", "manufacturer part number", "manufacturer_part_number",
|
||||||
"manf#", "part number", "partnumber", "p/n",
|
"manf#", "part number", "partnumber", "p/n",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,6 +682,29 @@ def parse_kicad_sch_project(
|
|||||||
return parts, nets, fields
|
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
|
# Public
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -417,3 +417,112 @@ def test_cyclic_sheet_include_is_rejected(tmp_path: Path):
|
|||||||
))
|
))
|
||||||
with pytest.raises(ValueError, match="[Cc]yclic"):
|
with pytest.raises(ValueError, match="[Cc]yclic"):
|
||||||
parse_kicad(root)
|
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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user