From 8f434ffd314de136ec753ea3d90219cf2081718b Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 22 Sep 2026 00:57:57 +0200 Subject: [PATCH] Skip module-die and truncated QFN pintable false PE-BOM hits (2.63.3). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WROOM lands are not the ESP32 die table (U11). QFN-48 pads 25–48 are not extras when extraction stopped at 1–24 (U12). VQFN is the QFN family (U19 24-QFN 4×4). Real extra signal pads still ERROR. --- docs/albero-analisi.md | 4 +- .../src/backend/periscopex/bom_pcb_check.py | 138 +++++++++- periscope/src/frontend/content/changelog.md | 8 + tests/pcb/test_bom_011_named_join.py | 244 ++++++++++++++++++ 4 files changed, 379 insertions(+), 15 deletions(-) diff --git a/docs/albero-analisi.md b/docs/albero-analisi.md index cd15fd4..138e237 100644 --- a/docs/albero-analisi.md +++ b/docs/albero-analisi.md @@ -566,7 +566,9 @@ Skip se manca un lato (BOM, footprint, o numero datasheet). #### `PE-BOM-010` famiglia package -- Test: `tests/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`. +VQFN/WQFN/HVQFN = QFN. LQFP ≠ QFN. + +- Test: `tests/pcb/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`; `tests/pcb/test_bom_011_named_join.py` `test_qfn_vs_vqfn_same_24_qfn_land_not_bom_010`. #### `PE-BOM-011` pintable vs pad, join per nome diff --git a/periscope/src/backend/periscopex/bom_pcb_check.py b/periscope/src/backend/periscopex/bom_pcb_check.py index 605840f..68ab2a2 100644 --- a/periscope/src/backend/periscopex/bom_pcb_check.py +++ b/periscope/src/backend/periscopex/bom_pcb_check.py @@ -40,6 +40,17 @@ _FAMILIES = ( "WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70", "TO-252", "TO252", "TO-263", "QFP", ) +# Same land family: VQFN/WQFN/HVQFN are QFN (24-QFN 4×4 ≠ a different package). +_FAMILY_CANON = { + "VQFN": "QFN", + "WQFN": "QFN", + "HVQFN": "QFN", +} +_QFN_PIN_N = re.compile(r"(?:HVQFN|VQFN|WQFN|QFN)[_-](\d+)\b", re.I) +_MODULE_FP = re.compile( + r"WROOM|WROVER|ESP-WROOM|RF[_-]?MODULE|CASTELLAT", + re.I, +) _EP_PAD = re.compile( r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$", re.I, @@ -66,7 +77,7 @@ def _family(blob: str) -> str | None: return None for fam in _FAMILIES: if fam.replace("-", "") in u.replace("-", ""): - return fam + return _FAMILY_CANON.get(fam, fam) return None @@ -107,6 +118,66 @@ def _is_shield_land(number: str, pinfunction: str = "") -> bool: return bool(_SH_PAD.match(s) or (pf and _SH_PAD.match(pf))) +def _cad_blob(*parts: str) -> str: + return " ".join(p for p in parts if (p or "").strip()) + + +def _is_module_land(blob: str) -> bool: + """WROOM / RF module footprint — not the silicon die pintable.""" + return bool(_MODULE_FP.search(blob or "")) + + +def _declared_io_pins(pkg: PackageInfo | None, *blobs: str) -> int | None: + """Largest QFN-n / pin_count seen in CAD or package_info. Not a pad census.""" + n: list[int] = [] + if pkg and pkg.pin_count: + try: + n.append(int(pkg.pin_count)) + except (TypeError, ValueError): + pass + for b in blobs: + for m in _QFN_PIN_N.finditer(b or ""): + n.append(int(m.group(1))) + return max(n) if n else None + + +def _drop_explainable_extras( + extra: list[str] | set[str], + *, + missing: list[str] | set[str], + is_module: bool, + declared: int | None, +) -> tuple[list[str], str]: + """Module lands beyond the die, or QFN-n pads the pintable never listed. + + Missing datasheet pins are never dropped. Pad ≠ via. + """ + leftover: list[str] = [] + reasons: list[str] = [] + miss = {str(x) for x in missing} + if miss: + return sorted(extra), "" + extras = [str(x) for x in extra] + if not extras: + return [], "" + if is_module: + reasons.append("module-footprint-vs-die-pintable") + return [], reasons[0] + if declared: + kept: list[str] = [] + dropped = False + for e in extras: + if e.isdigit() and int(e) <= declared: + dropped = True + continue + kept.append(e) + if dropped and not kept: + reasons.append(f"pintable-truncated-vs-declared-{declared}") + return [], reasons[0] + extras = kept + return sorted(extras), (reasons[0] if reasons else "") + + 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() @@ -296,6 +367,7 @@ def _mismatch( ref: str, mpn: str, rule_id: str, finding: str, facts: str, requirement: str, rec: str, status: str = "ERROR", inference: str = "", net: str | None = None, + evidence_status: str = "SUFFICIENT", ) -> Finding: return Finding( designator=ref, @@ -311,7 +383,7 @@ def _mismatch( action=rec, source="bom_pcb_check", rule_id=rule_id, - evidence_status="SUFFICIENT", + evidence_status=evidence_status, net=net, pins=[ref], ) @@ -380,10 +452,13 @@ def _pin_delta( facts: str, requirement: str, rec: str, + status: str = "ERROR", + evidence_status: str = "SUFFICIENT", ) -> Finding: return _mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-011", finding=finding, facts=facts, requirement=requirement, rec=rec, + status=status, evidence_status=evidence_status, ) @@ -402,21 +477,50 @@ def _pinout_findings( mpn = comp.mpn or "" pin_count = pkg.pin_count if pkg else None 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() + cad = _cad_blob( + fp_name, comp.footprint or "", mpn, comp.value or "", + ) + is_module = _is_module_land(cad) + declared = _declared_io_pins(pkg, cad) + ep_count = declared or pin_count + pcb = _join_keys_from_ids(pcb_ids, pin_count=ep_count, ds_keys=ds) lib, lib_path = _lib_join_keys( - fp_name, pin_count=pin_count, ds_keys=ds, search_dirs=footprint_dirs, + fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs, ) sch = _join_keys_from_ids( [(str(n), "") for n in comp.pins.keys()], - pin_count=pin_count, + pin_count=ep_count, ds_keys=ds, ) out: list[Finding] = [] + + def _join_issue( + *, + miss: list[str], + extra: list[str], + finding: str, + facts: str, + requirement: str, + rec: str, + ) -> Finding | None: + extra, _why = _drop_explainable_extras( + extra, missing=miss, is_module=is_module, declared=declared, + ) + if not miss and not extra: + return None + return _pin_delta( + ref=ref, mpn=mpn, + finding=finding, + facts=facts, + requirement=requirement, + rec=rec, + ) + if lib is not None: miss_lib = sorted(ds - lib) extra_lib = sorted(lib - ds) @@ -425,8 +529,8 @@ def _pinout_findings( f"Change {ref}'s KiCad footprint library so pad names match " f"the datasheet pintable (not a PCB pad-count guess)." ) - out.append(_pin_delta( - ref=ref, mpn=mpn, + fnd = _join_issue( + miss=miss_lib, extra=extra_lib, finding=( f"{ref} datasheet pintable vs library footprint differ " f"(missing on lib {miss_lib}, extra on lib {extra_lib})." @@ -440,7 +544,9 @@ def _pinout_findings( "pintable by pin name. pin_count is not authority." ), rec=rec, - )) + ) + if fnd: + out.append(fnd) 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): @@ -448,8 +554,8 @@ def _pinout_findings( 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, + fnd = _join_issue( + miss=miss_emb, extra=extra_emb, finding=( f"{ref} library footprint vs PCB embedded pads differ " f"(missing on PCB {miss_emb}, extra on PCB {extra_emb})." @@ -460,7 +566,9 @@ def _pinout_findings( "by pin name." ), rec=rec, - )) + ) + if fnd: + out.append(fnd) return out if pcb: miss = sorted(ds - pcb) @@ -470,8 +578,8 @@ def _pinout_findings( 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, + fnd = _join_issue( + miss=miss, extra=extra, finding=( f"{ref} pintable vs PCB pads differ " f"(missing {miss}, extra {extra})." @@ -485,7 +593,9 @@ def _pinout_findings( "Do not compare pin_count to pad cardinality." ), rec=rec, - )) + ) + if fnd: + out.append(fnd) return out if sch and ds != sch: miss = sorted(ds - sch) diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 1f903f3..20a9e65 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.3 — 2026-09-22 — Module die, truncated QFN pintable, QFN=VQFN + +HubAudio software leftovers on PE-BOM-010/011: a WROOM module is not the ESP32 die pintable; a QFN-48 land is not wrong because extraction stopped at pins 1–24; VQFN-24 4×4 is the same QFN family as datasheet QFN-24. Extra signal pads above the declared QFN-n still ERROR. Pad ≠ via. Kelvin unchanged. + +- [Fixed] PE-BOM-011 skips die-pintable extras on WROOM/WROVER module footprints (U11). +- [Fixed] PE-BOM-011 skips QFN-n pads the pintable never listed (U12 25–48). +- [Fixed] PE-BOM-010 treats VQFN/WQFN/HVQFN as QFN (U19). + ## 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. diff --git a/tests/pcb/test_bom_011_named_join.py b/tests/pcb/test_bom_011_named_join.py index a3320ad..daaf391 100644 --- a/tests/pcb/test_bom_011_named_join.py +++ b/tests/pcb/test_bom_011_named_join.py @@ -277,3 +277,247 @@ def test_lib_vs_embedded_pcb_is_separate_finding(tmp_path: Path): if f.rule_id == "PE-BOM-011" ] assert any("embedded" in f.finding.lower() or "PCB" in f.finding for f in findings) + + +def _ic( + ref: str, + mpn: str, + footprint: str, + n_sch: int, +) -> Component: + return Component( + reference=ref, value=mpn, footprint=footprint, + component_type=ComponentType.IC, mpn=mpn, + pins={str(i): "SIG" for i in range(1, n_sch + 1)}, + ) + + +def test_wroom_module_pads_vs_die_pintable_not_bom_011(): + """U11: WROOM land ≠ ESP32 die 61-pin pintable.""" + cons = ComponentConstraints( + mpn="ESP32-S31-WROOM-3", + package_info=PackageInfo( + base_family="ESP", package="QFN-56", pin_count=61, + ), + pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 62)], + absolute_maximum_ratings=[], + rules=[], + ) + pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 100)] + graph = DesignGraph( + components={ + "U11": _ic( + "U11", "ESP32-S31-WROOM-3", + "MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", 41, + ), + }, + ) + layout = LayoutGraph( + footprints={ + "U11": LayoutFootprint( + reference="U11", + footprint="MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", + x=0, y=0, pads=pads, + ), + }, + ) + ids = _ids(check_bom_pcb_datasheet(graph, {"ESP32-S31-WROOM-3": cons}, layout)) + assert "PE-BOM-011" not in ids + + +def test_wroom_missing_die_pin_still_fires(): + cons = ComponentConstraints( + mpn="ESP32-S31-WROOM-3", + package_info=PackageInfo( + base_family="ESP", package="QFN-56", pin_count=61, + ), + pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 62)], + absolute_maximum_ratings=[], + rules=[], + ) + pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 60)] + graph = DesignGraph( + components={ + "U11": _ic( + "U11", "ESP32-S31-WROOM-3", + "MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", 40, + ), + }, + ) + layout = LayoutGraph( + footprints={ + "U11": LayoutFootprint( + reference="U11", + footprint="MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", + x=0, y=0, pads=pads, + ), + }, + ) + assert "PE-BOM-011" in _ids( + check_bom_pcb_datasheet(graph, {"ESP32-S31-WROOM-3": cons}, layout), + ) + + +def test_qfn48_truncated_pintable_24_not_extra_error(): + """U12: pintable 1–24 vs QFN-48 lands is extraction, not a wrong footprint.""" + cons = ComponentConstraints( + mpn="Si4684-A10-GM", + package_info=PackageInfo( + base_family="Skyworks", package="QFN-48", 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, 49)] + pads.append(LayoutPad(number="49", x=0, y=0, net="GND", pinfunction="EP")) + graph = DesignGraph( + components={ + "U12": _ic( + "U12", "Si4684-A10-GM", + "Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm_ThermalVias", + 48, + ), + }, + ) + layout = LayoutGraph( + footprints={ + "U12": LayoutFootprint( + reference="U12", + footprint="Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm_ThermalVias", + x=0, y=0, pads=pads, + ), + }, + ) + assert "PE-BOM-011" not in _ids( + check_bom_pcb_datasheet(graph, {"Si4684-A10-GM": cons}, layout), + ) + + +def test_qfn24_extra_pad_30_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, 25)] + pads.append(LayoutPad(number="30", x=1, y=0, net="SIG")) + graph = DesignGraph( + components={"U1": _ic("U1", "IC24", "Package_DFN_QFN:QFN-24-1EP", 24)}, + ) + layout = LayoutGraph( + footprints={ + "U1": LayoutFootprint( + reference="U1", footprint="Package_DFN_QFN:QFN-24-1EP", + x=0, y=0, pads=pads, + ), + }, + ) + assert "PE-BOM-011" in _ids( + check_bom_pcb_datasheet(graph, {"IC24": cons}, layout), + ) + + +def test_qfn_vs_vqfn_same_24_qfn_land_not_bom_010(): + """U19: datasheet QFN vs CAD VQFN-24 4×4 is the same land family.""" + cons = ComponentConstraints( + mpn="LAN8720A", + package_info=PackageInfo( + base_family="SMS", 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, 25)] + pads.append(LayoutPad(number="25", x=0, y=0, net="GND", pinfunction="EP")) + graph = DesignGraph( + components={ + "U19": _ic( + "U19", "LAN8720A", + "Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias", + 24, + ), + }, + schematic_fields={ + "U19": { + "footprint": ( + "Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias" + ), + }, + }, + bom_fields={"U19": {"footprint": "VQFN-24"}}, + ) + layout = LayoutGraph( + footprints={ + "U19": LayoutFootprint( + reference="U19", + footprint=( + "Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias" + ), + x=0, y=0, pads=pads, + ), + }, + ) + ids = _ids(check_bom_pcb_datasheet(graph, {"LAN8720A": cons}, layout)) + assert "PE-BOM-010" not in ids + assert "PE-BOM-011" not in ids + + +def test_hubaudio_u11_u12_u19_software_not_error(): + if not _HUB_PCB.is_file(): + return + layout = parse_kicad_pcb(_HUB_PCB) + slim = LayoutGraph( + footprints={k: layout.footprints[k] for k in ("U11", "U12", "U19") + if k in layout.footprints}, + ) + graph = DesignGraph( + components={ + "U11": _ic( + "U11", "ESP32-S31-WROOM-3", + "MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", 61, + ), + "U12": _ic( + "U12", "Si4684-A10-GM", + "Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm_ThermalVias", + 48, + ), + "U19": _ic( + "U19", "LAN8720A", + "Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias", + 24, + ), + }, + ) + cmap = { + "ESP32-S31-WROOM-3": ComponentConstraints( + mpn="ESP32-S31-WROOM-3", + package_info=PackageInfo(base_family="ESP", package="QFN-56", pin_count=61), + pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 62)], + absolute_maximum_ratings=[], + rules=[], + ), + "Si4684-A10-GM": ComponentConstraints( + mpn="Si4684-A10-GM", + package_info=PackageInfo(base_family="Sky", package="QFN-48", pin_count=24), + pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)], + absolute_maximum_ratings=[], + rules=[], + ), + "LAN8720A": ComponentConstraints( + mpn="LAN8720A", + package_info=PackageInfo(base_family="SMS", 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=[], + ), + } + findings = check_bom_pcb_datasheet(graph, cmap, slim) + err = [ + f for f in findings + if f.rule_id in {"PE-BOM-010", "PE-BOM-011"} and f.status == "ERROR" + ] + assert err == [], [f.finding for f in err]