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:
@@ -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**
|
||||
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
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"Replace {ref}'s PCB footprint so pad count matches datasheet "
|
||||
f"pin_count={ds_count}."
|
||||
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} datasheet pin_count={ds_count} vs PCB signal pads={pcb_count}."
|
||||
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"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:
|
||||
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"Fix {ref} pinout: pads vs pintable. Missing on PCB: "
|
||||
f"{missing_pcb or '—'}; extra on PCB: {extra_pcb or '—'}."
|
||||
f"Replace {ref}'s embedded PCB footprint so it matches the "
|
||||
f"named library footprint {fp_name}."
|
||||
)
|
||||
out.append(_mismatch(
|
||||
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
|
||||
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 {missing_pcb}, extra {extra_pcb})."
|
||||
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."
|
||||
),
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user