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.
This commit is contained in:
2026-09-22 00:47:58 +02:00
parent 6b751522b7
commit cd7a7ca3a6
6 changed files with 560 additions and 69 deletions
+3 -3
View File
@@ -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`. - 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** #### `PE-BOM-012` Vop vs rating/abs-max legato al **pin**
+237 -65
View File
@@ -1,12 +1,16 @@
"""BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings. """BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings.
Skip when a side is missing. No invented JEDEC land patterns or IEC 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 from __future__ import annotations
import os
import re import re
from pathlib import Path
from backend.periscopex.models import ( from backend.periscopex.models import (
AbsMaxRating, AbsMaxRating,
@@ -27,6 +31,7 @@ from backend.periscopex.thermal_check import (
_specs_values, _specs_values,
) )
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.parsers_kicad_pcb import parse_kicad_mod_pads
_FAMILIES = ( _FAMILIES = (
"LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN", "LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN",
@@ -40,6 +45,7 @@ _EP_PAD = re.compile(
re.I, re.I,
) )
_EP_SPLIT = re.compile(r"^\d+_\d+$") _EP_SPLIT = re.compile(r"^\d+_\d+$")
_SH_PAD = re.compile(r"^(?:SH|SHIELD)(?:[_-].*)?$", re.I)
_V_PARAM = re.compile( _V_PARAM = re.compile(
r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)", r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)",
re.I, 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 "") 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: 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() s = str(number).strip()
pf = str(pinfunction or "").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)): if _EP_PAD.match(s) or (pf and _EP_PAD.match(pf)):
return True return True
if s.lower().replace(" ", "") in {"thermalpad", "thermal"}: 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 return True
if pin_count and s.isdigit() and int(s) == pin_count + 1: if pin_count and s.isdigit() and int(s) == pin_count + 1:
return True return True
if pin_count and pin_count <= 48 and re.match(r"^[A-Z]\d+$", s):
return True
return False return False
def _signal_pads(numbers: list[str], pin_count: int | None = None) -> set[str]: def _is_shield_land(number: str, pinfunction: str = "") -> bool:
out: set[str] = set() s = str(number).strip()
for n in numbers: pf = str(pinfunction or "").strip()
s = str(n).strip() return bool(_SH_PAD.match(s) or (pf and _SH_PAD.match(pf)))
if not s or _is_ep_land(s, pin_count=pin_count):
continue
out.add(s)
return out
def _pcb_signal_pads(fp_layout, pin_count: int | None = None) -> set[str]: def _ds_join_keys(cons: ComponentConstraints | None) -> set[str]:
"""Component lands only. Vias are not in ``fp.pads``; EP/thermal names skip.""" """Pintable pin **numbers** (A5, 17, EP). Names like P1 are not pad ids."""
out: set[str] = set() out: set[str] = set()
if fp_layout is None: if not cons:
return out return out
for pad in fp_layout.pads: for pin in cons.pintable or []:
n = str(pad.number).strip() n = _norm_pin(str(pin.number or ""))
pf = str(getattr(pad, "pinfunction", "") or "").strip() if n:
if not n or _is_ep_land(n, pf, pin_count): out.add(n)
continue
out.add(n)
return out 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: def _package_row(cons: ComponentConstraints | None, specs) -> PackageInfo | None:
if cons and cons.package_info: if cons and cons.package_info:
return cons.package_info return cons.package_info
@@ -160,8 +265,9 @@ def check_bom_pcb_datasheet(
graph: DesignGraph, graph: DesignGraph,
constraints_map: dict, constraints_map: dict,
layout: LayoutGraph | None, layout: LayoutGraph | None,
footprint_dirs: list[Path] | None = None,
) -> list[Finding]: ) -> 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 {} cmap = constraints_map or {}
out: list[Finding] = [] out: list[Finding] = []
for ref, comp in sorted(graph.components.items()): for ref, comp in sorted(graph.components.items()):
@@ -176,7 +282,9 @@ def check_bom_pcb_datasheet(
out.extend(_package_findings( out.extend(_package_findings(
ref, comp, pkg, sch_name, bom_name, pcb_name, 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(_voltage_findings(graph, ref, comp, cons))
out.extend(_temp_findings(ref, comp, cons)) out.extend(_temp_findings(ref, comp, cons))
out.extend(_current_findings(graph, ref, comp, cons)) out.extend(_current_findings(graph, ref, comp, cons))
@@ -264,71 +372,135 @@ def _package_findings(
return out 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( def _pinout_findings(
ref: str, ref: str,
comp: Component, comp: Component,
cons: ComponentConstraints | None, cons: ComponentConstraints | None,
fp_layout, fp_layout,
pkg: PackageInfo | None, pkg: PackageInfo | None,
footprint_dirs: list[Path] | None = None,
) -> list[Finding]: ) -> 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 "" mpn = comp.mpn or ""
pin_count = pkg.pin_count if pkg else None pin_count = pkg.pin_count if pkg else None
sch_pins = _signal_pads(list(comp.pins.keys()), pin_count) pcb_ids = _pads_as_ids(fp_layout)
ds_pins = _signal_pads( pcb = _join_keys_from_ids(pcb_ids, pin_count=pin_count, ds_keys=ds)
[str(p.number) for p in (cons.pintable if cons else [])], fp_name = ""
pin_count, 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) sch = _join_keys_from_ids(
ds_count = pkg.pin_count if pkg else (len(ds_pins) or None) [(str(n), "") for n in comp.pins.keys()],
pcb_count = len(pcb_pins) if pcb_pins else None pin_count=pin_count,
if ds_count and pcb_count and abs(ds_count - pcb_count) > 1: ds_keys=ds,
rec = ( )
f"Replace {ref}'s PCB footprint so pad count matches datasheet " out: list[Finding] = []
f"pin_count={ds_count}." if lib is not None:
) miss_lib = sorted(ds - lib)
out.append(_mismatch( extra_lib = sorted(lib - ds)
ref=ref, mpn=mpn, rule_id="PE-BOM-011", if miss_lib or extra_lib:
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:
rec = ( rec = (
f"Fix {ref} pinout: pads vs pintable. Missing on PCB: " f"Change {ref}'s KiCad footprint library so pad names match "
f"{missing_pcb or ''}; extra on PCB: {extra_pcb or ''}." f"the datasheet pintable (not a PCB pad-count guess)."
) )
out.append(_mismatch( out.append(_pin_delta(
ref=ref, mpn=mpn, rule_id="PE-BOM-011", ref=ref, mpn=mpn,
finding=( finding=(
f"{ref} pintable vs PCB pads differ " f"{ref} datasheet pintable vs library footprint differ "
f"(missing {missing_pcb}, extra {extra_pcb})." 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, rec=rec,
)) ))
elif ds_pins and sch_pins and ds_pins != sch_pins: miss_emb = sorted(lib - pcb) if pcb else sorted(lib)
missing = sorted(ds_pins - sch_pins) extra_emb = sorted(pcb - lib) if pcb else []
extra = sorted(sch_pins - ds_pins) if pcb and (miss_emb or extra_emb):
if missing or extra: 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." rec = f"Align {ref} schematic pins with the datasheet pintable."
out.append(_mismatch( out.append(_pin_delta(
ref=ref, mpn=mpn, rule_id="PE-BOM-011", ref=ref, mpn=mpn,
finding=( finding=(
f"{ref} pintable vs schematic pins differ " 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.", requirement="Schematic pin numbers must match the datasheet pintable.",
rec=rec, rec=rec,
status="ERROR",
)) ))
return out return out
@@ -194,7 +194,7 @@ def _seed() -> None:
_add("PE-BOM-010", "MANDATORY", "RULE", domain="pcb", _add("PE-BOM-010", "MANDATORY", "RULE", domain="pcb",
requirement="BOM / PCB footprint family must match datasheet package.") requirement="BOM / PCB footprint family must match datasheet package.")
_add("PE-BOM-011", "MANDATORY", "RULE", domain="pcb", _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", _add("PE-BOM-012", "MANDATORY", "RULE", domain="pcb",
requirement="Operating voltage must not exceed the sourced rating / abs-max.") requirement="Operating voltage must not exceed the sourced rating / abs-max.")
_add("PE-BOM-013", "MANDATORY", "RULE", domain="pcb", _add("PE-BOM-013", "MANDATORY", "RULE", domain="pcb",
@@ -236,6 +236,38 @@ def _is_npth_pad(pad: object) -> bool:
return _pad_kind(pad) in {"np_thru_hole", "np_through_hole", "npth"} 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: def _is_footprint_via(pad: object) -> bool:
"""Footprint child that is a via, never a component pin. """Footprint child that is a via, never a component pin.
@@ -2,6 +2,14 @@
What's new in Periscope. 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 ## 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). 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).
+279
View File
@@ -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)