From cd7a7ca3a603d529898d83ee2c40de6bd0a2fab8 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 22 Sep 2026 00:47:58 +0200 Subject: [PATCH] Join PE-BOM-011 pins by name, not pin_count (2.63.2). USB-C A5/B5 are contacts, not EP. Datasheet pintable, KiCad .kicad_mod, and embedded PCB pads join by pin identity. Missing pintable skips; scalar pin_count is never an ERROR authority. --- docs/albero-analisi.md | 6 +- .../src/backend/periscopex/bom_pcb_check.py | 302 ++++++++++++++---- .../src/backend/periscopex/finding_engine.py | 2 +- .../backend/periscopex/parsers_kicad_pcb.py | 32 ++ periscope/src/frontend/content/changelog.md | 8 + tests/pcb/test_bom_011_named_join.py | 279 ++++++++++++++++ 6 files changed, 560 insertions(+), 69 deletions(-) create mode 100644 tests/pcb/test_bom_011_named_join.py diff --git a/docs/albero-analisi.md b/docs/albero-analisi.md index 526f6d9..cd15fd4 100644 --- a/docs/albero-analisi.md +++ b/docs/albero-analisi.md @@ -568,11 +568,11 @@ Skip se manca un lato (BOM, footprint, o numero datasheet). - Test: `tests/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`. -#### `PE-BOM-011` pin_count/pintable vs pad PCB +#### `PE-BOM-011` pintable vs pad, join per nome -EP/thermal extra e via **non** sono pad. Via ≠ pad. +Join datasheet pintable × `.kicad_mod` lib × pad embedded. Non `pin_count` scalare. A5/B5 USB-C sono pin, non EP. EP/SH/mount classificati; via ≠ pad. Senza pintable: skip. -- Test: `tests/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010` (stesso test asserisce anche 011); `tests/test_pcb_software_bugs.py` `test_ep_split_pads_not_bom_011`; `tests/test_pcb_via_not_pad.py` (13 test geometria + `test_pcb_pipeline_reparses_stale_layout_graph`); `tests/test_incomplete_pcb.py` `test_partial_board_via_is_not_a_pad`. +- Test: `tests/pcb/test_bom_011_named_join.py`; `tests/pcb/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`; `tests/pcb/test_pcb_software_bugs.py` `test_ep_split_pads_not_bom_011`; `tests/pcb/test_pcb_via_not_pad.py`. #### `PE-BOM-012` Vop vs rating/abs-max legato al **pin** diff --git a/periscope/src/backend/periscopex/bom_pcb_check.py b/periscope/src/backend/periscopex/bom_pcb_check.py index 4f866b5..605840f 100644 --- a/periscope/src/backend/periscopex/bom_pcb_check.py +++ b/periscope/src/backend/periscopex/bom_pcb_check.py @@ -1,12 +1,16 @@ """BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings. Skip when a side is missing. No invented JEDEC land patterns or IEC -clearances. Thermal/EP pads may differ by one from pin_count. +clearances. PE-BOM-011 joins pintable × lib × PCB by pin name — never +by scalar pin_count. Pad ≠ via ≠ track ≠ zone. USB-C A5/B5 are pins, +not EP. """ from __future__ import annotations +import os import re +from pathlib import Path from backend.periscopex.models import ( AbsMaxRating, @@ -27,6 +31,7 @@ from backend.periscopex.thermal_check import ( _specs_values, ) from backend.periscopex.constraints_lookup import match_constraints as _match_constraints +from backend.periscopex.parsers_kicad_pcb import parse_kicad_mod_pads _FAMILIES = ( "LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN", @@ -40,6 +45,7 @@ _EP_PAD = re.compile( re.I, ) _EP_SPLIT = re.compile(r"^\d+_\d+$") +_SH_PAD = re.compile(r"^(?:SH|SHIELD)(?:[_-].*)?$", re.I) _V_PARAM = re.compile( r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)", re.I, @@ -74,9 +80,16 @@ def _sch_fp(graph: DesignGraph, ref: str, comp: Component) -> str: return str(row.get("footprint") or comp.footprint or "") +def _norm_pin(s: str) -> str: + return str(s).strip().upper() + + def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None) -> bool: + """Named EP / thermal / split / numeric pin_count+1. Not USB-C A5/B5.""" s = str(number).strip() pf = str(pinfunction or "").strip() + if _SH_PAD.match(s) or (pf and _SH_PAD.match(pf)): + return False if _EP_PAD.match(s) or (pf and _EP_PAD.match(pf)): return True if s.lower().replace(" ", "") in {"thermalpad", "thermal"}: @@ -85,35 +98,127 @@ def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None return True if pin_count and s.isdigit() and int(s) == pin_count + 1: return True - if pin_count and pin_count <= 48 and re.match(r"^[A-Z]\d+$", s): - return True return False -def _signal_pads(numbers: list[str], pin_count: int | None = None) -> set[str]: - out: set[str] = set() - for n in numbers: - s = str(n).strip() - if not s or _is_ep_land(s, pin_count=pin_count): - continue - out.add(s) - return out +def _is_shield_land(number: str, pinfunction: str = "") -> bool: + s = str(number).strip() + pf = str(pinfunction or "").strip() + return bool(_SH_PAD.match(s) or (pf and _SH_PAD.match(pf))) -def _pcb_signal_pads(fp_layout, pin_count: int | None = None) -> set[str]: - """Component lands only. Vias are not in ``fp.pads``; EP/thermal names skip.""" +def _ds_join_keys(cons: ComponentConstraints | None) -> set[str]: + """Pintable pin **numbers** (A5, 17, EP). Names like P1 are not pad ids.""" out: set[str] = set() - if fp_layout is None: + if not cons: return out - for pad in fp_layout.pads: - n = str(pad.number).strip() - pf = str(getattr(pad, "pinfunction", "") or "").strip() - if not n or _is_ep_land(n, pf, pin_count): - continue - out.add(n) + for pin in cons.pintable or []: + n = _norm_pin(str(pin.number or "")) + if n: + out.add(n) return out +def _join_keys_from_ids( + ids: list[tuple[str, str]], + *, + pin_count: int | None, + ds_keys: set[str], +) -> set[str]: + """Contact pads enter the join. EP/SH only if the pintable names them.""" + out: set[str] = set() + for number, pinfunction in ids: + n = str(number).strip() + if not n: + continue + pf = str(pinfunction or "") + key = _norm_pin(n) + if _is_shield_land(n, pf) or _is_ep_land(n, pf, pin_count): + if key in ds_keys: + out.add(key) + continue + out.add(key) + return out + + +def _pads_as_ids(fp_layout) -> list[tuple[str, str]]: + if fp_layout is None: + return [] + return [ + (str(p.number), str(getattr(p, "pinfunction", "") or "")) + for p in fp_layout.pads + ] + + +def _kicad_footprint_env_dirs() -> list[Path]: + out: list[Path] = [] + for key in ( + "KICAD10_FOOTPRINT_DIR", + "KICAD9_FOOTPRINT_DIR", + "KICAD8_FOOTPRINT_DIR", + "KICAD_FOOTPRINT_DIR", + ): + raw = os.environ.get(key) or "" + for part in raw.split(os.pathsep): + p = Path(part.strip()) + if part.strip() and p.is_dir(): + out.append(p) + return out + + +def resolve_kicad_mod( + footprint: str, + search_dirs: list[Path] | None = None, +) -> Path | None: + """Locate ``Lib:Name`` as ``Lib.pretty/Name.kicad_mod``. None if missing.""" + name = (footprint or "").strip() + if not name: + return None + rels: list[str] = [] + if ":" in name: + lib, mod = name.split(":", 1) + rels.append(str(Path(f"{lib}.pretty") / f"{mod}.kicad_mod")) + rels.append(f"{mod}.kicad_mod") + else: + rels.append(f"{name}.kicad_mod") + rels.append(str(Path(f"{name}.pretty") / f"{name}.kicad_mod")) + dirs = list(search_dirs or []) + _kicad_footprint_env_dirs() + seen: set[str] = set() + for root in dirs: + try: + root_p = Path(root) + except TypeError: + continue + if not root_p.is_dir(): + continue + for rel in rels: + cand = root_p / rel + key = str(cand) + if key in seen: + continue + seen.add(key) + if cand.is_file(): + return cand + return None + + +def _lib_join_keys( + footprint: str, + *, + pin_count: int | None, + ds_keys: set[str], + search_dirs: list[Path] | None, +) -> tuple[set[str] | None, str]: + """Lib pad set, or None when the ``.kicad_mod`` is not on disk.""" + path = resolve_kicad_mod(footprint, search_dirs) + if path is None: + return None, "" + ids = parse_kicad_mod_pads(path) + if ids is None: + return None, str(path) + return _join_keys_from_ids(ids, pin_count=pin_count, ds_keys=ds_keys), str(path) + + def _package_row(cons: ComponentConstraints | None, specs) -> PackageInfo | None: if cons and cons.package_info: return cons.package_info @@ -160,8 +265,9 @@ def check_bom_pcb_datasheet( graph: DesignGraph, constraints_map: dict, layout: LayoutGraph | None, + footprint_dirs: list[Path] | None = None, ) -> list[Finding]: - """Three-way mismatches when BOM, PCB, and library all have numbers.""" + """Package family + named pin join. Skip a side when it is missing.""" cmap = constraints_map or {} out: list[Finding] = [] for ref, comp in sorted(graph.components.items()): @@ -176,7 +282,9 @@ def check_bom_pcb_datasheet( out.extend(_package_findings( ref, comp, pkg, sch_name, bom_name, pcb_name, )) - out.extend(_pinout_findings(ref, comp, cons, fp_layout, pkg)) + out.extend(_pinout_findings( + ref, comp, cons, fp_layout, pkg, footprint_dirs, + )) out.extend(_voltage_findings(graph, ref, comp, cons)) out.extend(_temp_findings(ref, comp, cons)) out.extend(_current_findings(graph, ref, comp, cons)) @@ -264,71 +372,135 @@ def _package_findings( return out +def _pin_delta( + *, + ref: str, + mpn: str, + finding: str, + facts: str, + requirement: str, + rec: str, +) -> Finding: + return _mismatch( + ref=ref, mpn=mpn, rule_id="PE-BOM-011", + finding=finding, facts=facts, requirement=requirement, rec=rec, + ) + + def _pinout_findings( ref: str, comp: Component, cons: ComponentConstraints | None, fp_layout, pkg: PackageInfo | None, + footprint_dirs: list[Path] | None = None, ) -> list[Finding]: - out: list[Finding] = [] + """Join pintable × lib × PCB by name. Never ERROR on pin_count alone.""" + ds = _ds_join_keys(cons) + if not ds: + return [] mpn = comp.mpn or "" pin_count = pkg.pin_count if pkg else None - sch_pins = _signal_pads(list(comp.pins.keys()), pin_count) - ds_pins = _signal_pads( - [str(p.number) for p in (cons.pintable if cons else [])], - pin_count, + pcb_ids = _pads_as_ids(fp_layout) + pcb = _join_keys_from_ids(pcb_ids, pin_count=pin_count, ds_keys=ds) + fp_name = "" + if fp_layout is not None: + fp_name = (fp_layout.footprint or "").strip() + if not fp_name: + fp_name = (comp.footprint or "").strip() + lib, lib_path = _lib_join_keys( + fp_name, pin_count=pin_count, ds_keys=ds, search_dirs=footprint_dirs, ) - pcb_pins = _pcb_signal_pads(fp_layout, pin_count) - ds_count = pkg.pin_count if pkg else (len(ds_pins) or None) - pcb_count = len(pcb_pins) if pcb_pins else None - if ds_count and pcb_count and abs(ds_count - pcb_count) > 1: - rec = ( - f"Replace {ref}'s PCB footprint so pad count matches datasheet " - f"pin_count={ds_count}." - ) - out.append(_mismatch( - ref=ref, mpn=mpn, rule_id="PE-BOM-011", - finding=( - f"{ref} datasheet pin_count={ds_count} vs PCB signal pads={pcb_count}." - ), - facts=f"pin_count={ds_count}; PCB pads={sorted(pcb_pins)}; sch={sorted(sch_pins)}.", - requirement="Datasheet pin_count must match the PCB footprint (EP ±1 allowed).", - rec=rec, - )) - if ds_pins and pcb_pins: - missing_pcb = sorted(ds_pins - pcb_pins) - extra_pcb = sorted(pcb_pins - ds_pins) - if missing_pcb or extra_pcb: + sch = _join_keys_from_ids( + [(str(n), "") for n in comp.pins.keys()], + pin_count=pin_count, + ds_keys=ds, + ) + out: list[Finding] = [] + if lib is not None: + miss_lib = sorted(ds - lib) + extra_lib = sorted(lib - ds) + if miss_lib or extra_lib: rec = ( - f"Fix {ref} pinout: pads vs pintable. Missing on PCB: " - f"{missing_pcb or '—'}; extra on PCB: {extra_pcb or '—'}." + f"Change {ref}'s KiCad footprint library so pad names match " + f"the datasheet pintable (not a PCB pad-count guess)." ) - out.append(_mismatch( - ref=ref, mpn=mpn, rule_id="PE-BOM-011", + out.append(_pin_delta( + ref=ref, mpn=mpn, finding=( - f"{ref} pintable vs PCB pads differ " - f"(missing {missing_pcb}, extra {extra_pcb})." + f"{ref} datasheet pintable vs library footprint differ " + f"(missing on lib {miss_lib}, extra on lib {extra_lib})." + ), + facts=( + f"pintable={sorted(ds)}; lib={sorted(lib)}; " + f"mod={lib_path!r}; pin_count unused." + ), + requirement=( + "The KiCad library footprint must cover the datasheet " + "pintable by pin name. pin_count is not authority." ), - facts=f"pintable={sorted(ds_pins)}; PCB={sorted(pcb_pins)}; sch={sorted(sch_pins)}.", - requirement="Each datasheet pin number must exist as a PCB pad.", rec=rec, )) - elif ds_pins and sch_pins and ds_pins != sch_pins: - missing = sorted(ds_pins - sch_pins) - extra = sorted(sch_pins - ds_pins) - if missing or extra: + miss_emb = sorted(lib - pcb) if pcb else sorted(lib) + extra_emb = sorted(pcb - lib) if pcb else [] + if pcb and (miss_emb or extra_emb): + rec = ( + f"Replace {ref}'s embedded PCB footprint so it matches the " + f"named library footprint {fp_name}." + ) + out.append(_pin_delta( + ref=ref, mpn=mpn, + finding=( + f"{ref} library footprint vs PCB embedded pads differ " + f"(missing on PCB {miss_emb}, extra on PCB {extra_emb})." + ), + facts=f"lib={sorted(lib)}; PCB={sorted(pcb)}; fp={fp_name!r}.", + requirement=( + "Embedded PCB pads must match the named .kicad_mod, " + "by pin name." + ), + rec=rec, + )) + return out + if pcb: + miss = sorted(ds - pcb) + extra = sorted(pcb - ds) + if miss or extra: + rec = ( + f"Fix {ref} pinout: PCB pad names vs datasheet pintable. " + f"Missing on PCB: {miss or '—'}; extra on PCB: {extra or '—'}." + ) + out.append(_pin_delta( + ref=ref, mpn=mpn, + finding=( + f"{ref} pintable vs PCB pads differ " + f"(missing {miss}, extra {extra})." + ), + facts=( + f"pintable={sorted(ds)}; PCB={sorted(pcb)}; " + f"sch={sorted(sch)}; lib .kicad_mod not found." + ), + requirement=( + "Each datasheet pin name must exist as a PCB pad. " + "Do not compare pin_count to pad cardinality." + ), + rec=rec, + )) + return out + if sch and ds != sch: + miss = sorted(ds - sch) + extra = sorted(sch - ds) + if miss or extra: rec = f"Align {ref} schematic pins with the datasheet pintable." - out.append(_mismatch( - ref=ref, mpn=mpn, rule_id="PE-BOM-011", + out.append(_pin_delta( + ref=ref, mpn=mpn, finding=( f"{ref} pintable vs schematic pins differ " - f"(missing {missing}, extra {extra})." + f"(missing {miss}, extra {extra})." ), - facts=f"pintable={sorted(ds_pins)}; sch={sorted(sch_pins)}.", + facts=f"pintable={sorted(ds)}; sch={sorted(sch)}.", requirement="Schematic pin numbers must match the datasheet pintable.", rec=rec, - status="ERROR", )) return out diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py index 87e45a0..3702e1c 100644 --- a/periscope/src/backend/periscopex/finding_engine.py +++ b/periscope/src/backend/periscopex/finding_engine.py @@ -194,7 +194,7 @@ def _seed() -> None: _add("PE-BOM-010", "MANDATORY", "RULE", domain="pcb", requirement="BOM / PCB footprint family must match datasheet package.") _add("PE-BOM-011", "MANDATORY", "RULE", domain="pcb", - requirement="Datasheet pin_count / pintable must match PCB pads.") + requirement="Datasheet pintable names must join KiCad lib and PCB pads (not pin_count).") _add("PE-BOM-012", "MANDATORY", "RULE", domain="pcb", requirement="Operating voltage must not exceed the sourced rating / abs-max.") _add("PE-BOM-013", "MANDATORY", "RULE", domain="pcb", diff --git a/periscope/src/backend/periscopex/parsers_kicad_pcb.py b/periscope/src/backend/periscopex/parsers_kicad_pcb.py index fdc4cea..a08bf2a 100644 --- a/periscope/src/backend/periscopex/parsers_kicad_pcb.py +++ b/periscope/src/backend/periscopex/parsers_kicad_pcb.py @@ -236,6 +236,38 @@ def _is_npth_pad(pad: object) -> bool: return _pad_kind(pad) in {"np_thru_hole", "np_through_hole", "npth"} +def footprint_pad_ids(node: object) -> list[tuple[str, str]]: + """``(number, pinfunction)`` lands. Via and NPTH are not pads.""" + out: list[tuple[str, str]] = [] + for pad in _kids(node, "pad"): + if _is_footprint_via(pad) or _is_npth_pad(pad): + continue + num = str(pad[1]) if len(pad) > 1 and not isinstance(pad[1], list) else "" + if not num: + continue + out.append((num, _val(pad, "pinfunction"))) + return out + + +def parse_kicad_mod_pads(path: str | Path) -> list[tuple[str, str]] | None: + """Pad ids from a ``.kicad_mod``. None if the file is missing or not a footprint.""" + p = Path(path) + if not p.is_file(): + return None + try: + tree = _parse_sexp(p.read_text(encoding="utf-8", errors="replace")) + except (OSError, ValueError): + return None + tag = _tag(tree) + if tag in {"footprint", "module"}: + return footprint_pad_ids(tree) + if tag == "kicad_pcb": + for node in tree[1:]: + if isinstance(node, list) and _tag(node) in {"footprint", "module"}: + return footprint_pad_ids(node) + return None + + def _is_footprint_via(pad: object) -> bool: """Footprint child that is a via, never a component pin. diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index bda8953..1f903f3 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Periscope. +## 2.63.2 — 2026-09-22 — PE-BOM-011 joins pins by name, not pin_count + +Datasheet `pin_count` and PCB pad cardinality are incommensurable (USB-C 16P vs SH/mount/EP). PE-BOM-011 joins the pintable × KiCad `.kicad_mod` × embedded PCB pads by **pin name**. USB-C A5/B5 are CC pins, not EP. Missing pintable is a skip, never ERROR on `pin_count=16`. The library footprint is checked against the datasheet; lib vs embedded PCB is a separate mismatch. AI still extracts the table and runs review/class/SI — it does not count pads. Pad ≠ via. + +- [Fixed] PE-BOM-011 no longer treats `[A-Z]\\d+` as EP (HubAudio J2 16 vs 1 SH). +- [Fixed] Scalar `package_info.pin_count` is not an ERROR authority. +- [Changed] Named join: datasheet pintable vs lib vs PCB; SH/EP/mount classified first. + ## 2.63.1 — 2026-09-21 — Library component from PDF; AF+AI beside PCB checks Library import registers a **component** (not only a PDF blob) with no exam. AF Board+AI is a pipeline section **after** unchanged `run_pcb_checks` (PE-SI / HF line stay in 2.62.1). diff --git a/tests/pcb/test_bom_011_named_join.py b/tests/pcb/test_bom_011_named_join.py new file mode 100644 index 0000000..a3320ad --- /dev/null +++ b/tests/pcb/test_bom_011_named_join.py @@ -0,0 +1,279 @@ +"""PE-BOM-011 joins pintable × lib × PCB by name. pin_count is not authority.""" + +from __future__ import annotations + +from pathlib import Path + +from backend.periscopex.bom_pcb_check import ( + _is_ep_land, + check_bom_pcb_datasheet, +) +from backend.periscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + LayoutFootprint, + LayoutGraph, + LayoutPad, + PackageInfo, + Pin, +) +from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb + +_HUB_PCB = Path( + "/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb" +) + +_USB_C_PINS = [ + "A1", "A4", "A5", "A6", "A7", "A8", "A9", "A12", + "B1", "B4", "B5", "B6", "B7", "B8", "B9", "B12", +] + + +def _usb_c_cons(*, pintable: bool) -> ComponentConstraints: + pins = [Pin(number=n, name=n) for n in _USB_C_PINS] if pintable else [] + return ComponentConstraints( + mpn="TYPE-C-31-M-12", + package_info=PackageInfo( + base_family="USB", package="USB-C-16P", pin_count=16, + ), + pintable=pins, + absolute_maximum_ratings=[], + rules=[], + ) + + +def _j2_graph(pins: dict[str, str] | None = None) -> DesignGraph: + keys = pins or {n: "SIG" for n in _USB_C_PINS} + keys.setdefault("SH", "GND") + return DesignGraph( + components={ + "J2": Component( + reference="J2", + value="USB_C_Receptacle_USB2.0_16P", + footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12", + component_type=ComponentType.CONNECTOR, + mpn="TYPE-C-31-M-12", + pins=keys, + ), + }, + ) + + +def _j2_layout() -> LayoutGraph: + pads = [ + LayoutPad(number=n, x=0, y=0, net="SIG", pinfunction=f"PIN_{n}") + for n in _USB_C_PINS + ] + pads.extend( + LayoutPad(number="SH", x=i, y=0, net="GND", pinfunction="SHIELD_SH") + for i in range(4) + ) + return LayoutGraph( + footprints={ + "J2": LayoutFootprint( + reference="J2", + footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12", + x=0, y=0, layer="F.Cu", + pads=pads, + ), + }, + ) + + +def _ids(findings) -> set[str]: + return {f.rule_id for f in findings} + + +def test_a5_b5_are_not_ep(): + assert _is_ep_land("A5", "CC1_A5", pin_count=16) is False + assert _is_ep_land("B5", "CC2_B5", pin_count=16) is False + assert _is_ep_land("EP", pin_count=8) is True + assert _is_ep_land("9", "EP", pin_count=8) is True + assert _is_ep_land("9", pin_count=8) is True + assert _is_ep_land("9_1", pin_count=8) is True + + +def test_usb_c_pin_count_16_without_pintable_is_not_error(): + """The 16 vs 1 SH false ERROR: pin_count alone must not fire PE-BOM-011.""" + findings = check_bom_pcb_datasheet( + _j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=False)}, _j2_layout(), + ) + assert "PE-BOM-011" not in _ids(findings) + assert not any("pin_count=16" in (f.finding or "") for f in findings) + + +def test_usb_c_pintable_matches_contacts_ignores_sh_and_mount(): + findings = check_bom_pcb_datasheet( + _j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=True)}, _j2_layout(), + ) + assert "PE-BOM-011" not in _ids(findings) + + +def test_hubaudio_j2_is_not_16_vs_1_signal_pad(): + if not _HUB_PCB.is_file(): + return + layout = parse_kicad_pcb(_HUB_PCB) + j2 = layout.footprints.get("J2") + assert j2 is not None + numbers = {p.number for p in j2.pads} + assert "A5" in numbers and "B5" in numbers + assert "SH" in numbers + slim = LayoutGraph(footprints={"J2": j2}) + findings = check_bom_pcb_datasheet( + _j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=False)}, slim, + ) + bom011 = [f for f in findings if f.rule_id == "PE-BOM-011"] + assert bom011 == [] + findings_named = check_bom_pcb_datasheet( + _j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=True)}, slim, + ) + assert "PE-BOM-011" not in _ids(findings_named) + + +def test_qfn_ep_still_excluded_from_join(): + cons = ComponentConstraints( + mpn="SW", + package_info=PackageInfo(base_family="TI", package="SON-8-EP", pin_count=8), + pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)], + absolute_maximum_ratings=[], + rules=[], + ) + pads = [LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 9)] + pads += [ + LayoutPad(number="9", x=0, y=0, net="GND", pinfunction="EP"), + LayoutPad(number="9_1", x=0.2, y=0, net="GND"), + LayoutPad(number="9_2", x=0.4, y=0, net="GND"), + ] + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="SW", footprint="SON-8", + component_type=ComponentType.IC, mpn="SW", + pins={str(i): "GND" for i in range(1, 9)}, + ), + }, + ) + layout = LayoutGraph( + footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, pads=pads)}, + ) + assert "PE-BOM-011" not in _ids( + check_bom_pcb_datasheet(graph, {"SW": cons}, layout), + ) + + +def test_missing_signal_pad_still_fires(): + cons = ComponentConstraints( + mpn="IC24", + package_info=PackageInfo(base_family="QFN", package="QFN-24", pin_count=24), + pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)], + absolute_maximum_ratings=[], + rules=[], + ) + pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 24)] + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="IC", footprint="QFN-24", + component_type=ComponentType.IC, mpn="IC24", + pins={str(i): "SIG" for i in range(1, 25)}, + ), + }, + ) + layout = LayoutGraph( + footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, pads=pads)}, + ) + findings = check_bom_pcb_datasheet(graph, {"IC24": cons}, layout) + assert "PE-BOM-011" in _ids(findings) + assert any("24" in (f.finding or "") for f in findings if f.rule_id == "PE-BOM-011") + + +def _write_mod(root: Path, lib: str, name: str, pads: list[str]) -> Path: + pretty = root / f"{lib}.pretty" + pretty.mkdir(parents=True, exist_ok=True) + body = [f'(footprint "{lib}:{name}" (version 20240108) (layer "F.Cu")\n'] + for i, n in enumerate(pads): + body.append( + f' (pad "{n}" smd rect (at {i} 0) (size 0.3 0.8) ' + f'(layers "F.Cu" "F.Paste" "F.Mask") (pinfunction "{n}"))\n' + ) + body.append(")\n") + path = pretty / f"{name}.kicad_mod" + path.write_text("".join(body)) + return path + + +def test_lib_vs_datasheet_is_lib_finding_not_pcb_pad_count(tmp_path: Path): + _write_mod(tmp_path, "Pkg", "QFN-8", [str(i) for i in range(1, 8)]) + cons = ComponentConstraints( + mpn="IC8", + package_info=PackageInfo(base_family="QFN", package="QFN-8", pin_count=8), + pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)], + absolute_maximum_ratings=[], + rules=[], + ) + pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 9)] + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="IC", footprint="Pkg:QFN-8", + component_type=ComponentType.IC, mpn="IC8", + pins={str(i): "SIG" for i in range(1, 9)}, + ), + }, + ) + layout = LayoutGraph( + footprints={ + "U1": LayoutFootprint( + reference="U1", footprint="Pkg:QFN-8", x=0, y=0, pads=pads, + ), + }, + ) + findings = [ + f for f in check_bom_pcb_datasheet( + graph, {"IC8": cons}, layout, footprint_dirs=[tmp_path], + ) + if f.rule_id == "PE-BOM-011" + ] + assert findings + text = " ".join(f.finding for f in findings) + assert "library" in text.lower() + assert "pin_count=" not in text + assert "signal pads=" not in text + + +def test_lib_vs_embedded_pcb_is_separate_finding(tmp_path: Path): + _write_mod(tmp_path, "Pkg", "QFN-8", [str(i) for i in range(1, 9)]) + cons = ComponentConstraints( + mpn="IC8", + package_info=PackageInfo(base_family="QFN", package="QFN-8", pin_count=8), + pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)], + absolute_maximum_ratings=[], + rules=[], + ) + pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 8)] + pads.append(LayoutPad(number="99", x=1, y=0, net="SIG")) + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="IC", footprint="Pkg:QFN-8", + component_type=ComponentType.IC, mpn="IC8", + pins={str(i): "SIG" for i in range(1, 9)}, + ), + }, + ) + layout = LayoutGraph( + footprints={ + "U1": LayoutFootprint( + reference="U1", footprint="Pkg:QFN-8", x=0, y=0, pads=pads, + ), + }, + ) + findings = [ + f for f in check_bom_pcb_datasheet( + graph, {"IC8": cons}, layout, footprint_dirs=[tmp_path], + ) + if f.rule_id == "PE-BOM-011" + ] + assert any("embedded" in f.finding.lower() or "PCB" in f.finding for f in findings)