Skip module-die and truncated QFN pintable false PE-BOM hits (2.63.3).

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.
This commit is contained in:
2026-09-22 00:57:57 +02:00
parent cd7a7ca3a6
commit 8f434ffd31
4 changed files with 379 additions and 15 deletions
+3 -1
View File
@@ -566,7 +566,9 @@ Skip se manca un lato (BOM, footprint, o numero datasheet).
#### `PE-BOM-010` famiglia package #### `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 #### `PE-BOM-011` pintable vs pad, join per nome
+124 -14
View File
@@ -40,6 +40,17 @@ _FAMILIES = (
"WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70", "WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70",
"TO-252", "TO252", "TO-263", "QFP", "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( _EP_PAD = re.compile(
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$", r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
re.I, re.I,
@@ -66,7 +77,7 @@ def _family(blob: str) -> str | None:
return None return None
for fam in _FAMILIES: for fam in _FAMILIES:
if fam.replace("-", "") in u.replace("-", ""): if fam.replace("-", "") in u.replace("-", ""):
return fam return _FAMILY_CANON.get(fam, fam)
return None 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))) 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]: def _ds_join_keys(cons: ComponentConstraints | None) -> set[str]:
"""Pintable pin **numbers** (A5, 17, EP). Names like P1 are not pad ids.""" """Pintable pin **numbers** (A5, 17, EP). Names like P1 are not pad ids."""
out: set[str] = set() out: set[str] = set()
@@ -296,6 +367,7 @@ def _mismatch(
ref: str, mpn: str, rule_id: str, finding: str, facts: str, ref: str, mpn: str, rule_id: str, finding: str, facts: str,
requirement: str, rec: str, status: str = "ERROR", requirement: str, rec: str, status: str = "ERROR",
inference: str = "", net: str | None = None, inference: str = "", net: str | None = None,
evidence_status: str = "SUFFICIENT",
) -> Finding: ) -> Finding:
return Finding( return Finding(
designator=ref, designator=ref,
@@ -311,7 +383,7 @@ def _mismatch(
action=rec, action=rec,
source="bom_pcb_check", source="bom_pcb_check",
rule_id=rule_id, rule_id=rule_id,
evidence_status="SUFFICIENT", evidence_status=evidence_status,
net=net, net=net,
pins=[ref], pins=[ref],
) )
@@ -380,10 +452,13 @@ def _pin_delta(
facts: str, facts: str,
requirement: str, requirement: str,
rec: str, rec: str,
status: str = "ERROR",
evidence_status: str = "SUFFICIENT",
) -> Finding: ) -> Finding:
return _mismatch( return _mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011", ref=ref, mpn=mpn, rule_id="PE-BOM-011",
finding=finding, facts=facts, requirement=requirement, rec=rec, 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 "" mpn = comp.mpn or ""
pin_count = pkg.pin_count if pkg else None pin_count = pkg.pin_count if pkg else None
pcb_ids = _pads_as_ids(fp_layout) pcb_ids = _pads_as_ids(fp_layout)
pcb = _join_keys_from_ids(pcb_ids, pin_count=pin_count, ds_keys=ds)
fp_name = "" fp_name = ""
if fp_layout is not None: if fp_layout is not None:
fp_name = (fp_layout.footprint or "").strip() fp_name = (fp_layout.footprint or "").strip()
if not fp_name: if not fp_name:
fp_name = (comp.footprint or "").strip() 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( 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( sch = _join_keys_from_ids(
[(str(n), "") for n in comp.pins.keys()], [(str(n), "") for n in comp.pins.keys()],
pin_count=pin_count, pin_count=ep_count,
ds_keys=ds, ds_keys=ds,
) )
out: list[Finding] = [] 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: if lib is not None:
miss_lib = sorted(ds - lib) miss_lib = sorted(ds - lib)
extra_lib = sorted(lib - ds) extra_lib = sorted(lib - ds)
@@ -425,8 +529,8 @@ def _pinout_findings(
f"Change {ref}'s KiCad footprint library so pad names match " f"Change {ref}'s KiCad footprint library so pad names match "
f"the datasheet pintable (not a PCB pad-count guess)." f"the datasheet pintable (not a PCB pad-count guess)."
) )
out.append(_pin_delta( fnd = _join_issue(
ref=ref, mpn=mpn, miss=miss_lib, extra=extra_lib,
finding=( finding=(
f"{ref} datasheet pintable vs library footprint differ " f"{ref} datasheet pintable vs library footprint differ "
f"(missing on lib {miss_lib}, extra on lib {extra_lib})." 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." "pintable by pin name. pin_count is not authority."
), ),
rec=rec, rec=rec,
)) )
if fnd:
out.append(fnd)
miss_emb = sorted(lib - pcb) if pcb else sorted(lib) miss_emb = sorted(lib - pcb) if pcb else sorted(lib)
extra_emb = sorted(pcb - lib) if pcb else [] extra_emb = sorted(pcb - lib) if pcb else []
if pcb and (miss_emb or extra_emb): 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"Replace {ref}'s embedded PCB footprint so it matches the "
f"named library footprint {fp_name}." f"named library footprint {fp_name}."
) )
out.append(_pin_delta( fnd = _join_issue(
ref=ref, mpn=mpn, miss=miss_emb, extra=extra_emb,
finding=( finding=(
f"{ref} library footprint vs PCB embedded pads differ " f"{ref} library footprint vs PCB embedded pads differ "
f"(missing on PCB {miss_emb}, extra on PCB {extra_emb})." f"(missing on PCB {miss_emb}, extra on PCB {extra_emb})."
@@ -460,7 +566,9 @@ def _pinout_findings(
"by pin name." "by pin name."
), ),
rec=rec, rec=rec,
)) )
if fnd:
out.append(fnd)
return out return out
if pcb: if pcb:
miss = sorted(ds - pcb) miss = sorted(ds - pcb)
@@ -470,8 +578,8 @@ def _pinout_findings(
f"Fix {ref} pinout: PCB pad names vs datasheet pintable. " f"Fix {ref} pinout: PCB pad names vs datasheet pintable. "
f"Missing on PCB: {miss or ''}; extra on PCB: {extra or ''}." f"Missing on PCB: {miss or ''}; extra on PCB: {extra or ''}."
) )
out.append(_pin_delta( fnd = _join_issue(
ref=ref, mpn=mpn, miss=miss, extra=extra,
finding=( finding=(
f"{ref} pintable vs PCB pads differ " f"{ref} pintable vs PCB pads differ "
f"(missing {miss}, extra {extra})." f"(missing {miss}, extra {extra})."
@@ -485,7 +593,9 @@ def _pinout_findings(
"Do not compare pin_count to pad cardinality." "Do not compare pin_count to pad cardinality."
), ),
rec=rec, rec=rec,
)) )
if fnd:
out.append(fnd)
return out return out
if sch and ds != sch: if sch and ds != sch:
miss = sorted(ds - sch) miss = sorted(ds - sch)
@@ -2,6 +2,14 @@
What's new in Periscope. 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 124; 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 2548).
- [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 ## 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. 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.
+244
View File
@@ -277,3 +277,247 @@ def test_lib_vs_embedded_pcb_is_separate_finding(tmp_path: Path):
if f.rule_id == "PE-BOM-011" if f.rule_id == "PE-BOM-011"
] ]
assert any("embedded" in f.finding.lower() or "PCB" in f.finding for f in findings) 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 124 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]