Files
periscope/backend/periscopex/bom_pcb_check.py
T
michele 1b3529501f Ship remaining PCB plan slices: BOM chain, SPOF, EMI FACT, gated Tj.
Package/pinout/ratings mismatches, SPOF as REVIEW, EMI only with a
datasheet quote, and Tj from copper+vias+θJA when every parameter exists.
Re-extract SI layout_rules from 1.12.0; ImpedenceFinder license stays UNKNOWN.
2026-09-20 12:29:27 +02:00

418 lines
14 KiB
Python

"""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.
"""
from __future__ import annotations
import re
from backend.periscopex.models import (
AbsMaxRating,
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
InductorSpecs,
LayoutGraph,
PackageInfo,
)
from backend.periscopex.thermal_check import (
_LOAD_KEYS,
_first,
_net_voltage,
_specs_values,
)
from backend.periscopex.validate import _match_constraints
_FAMILIES = (
"LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN",
"WSON", "USON", "SOIC", "SSOP", "TSSOP", "MSOP", "SOP",
"SOT-223", "SOT223", "SOT-23", "SOT23", "SOT-89", "SOT89",
"WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70",
"TO-252", "TO252", "TO-263", "QFP",
)
_EP_PAD = re.compile(r"^(?:EP|PAD|TAB|TH|THERMAL|DIEPAD)$", re.I)
_V_PARAM = re.compile(
r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)",
re.I,
)
_NOT_V = re.compile(r"(IOUT|CURRENT|POWER|PD|TJ|TSTG|TEMP)", re.I)
_T_PARAM = re.compile(r"(T[JA]|TJMAX|T_J|TSTG|TSTORAGE|OPERATING.?TEMP)", re.I)
_I_PARAM = re.compile(r"(IOUT|I_OUT|IDC|ICONT|CURRENT)", re.I)
def _family(blob: str) -> str | None:
u = (blob or "").upper().replace(" ", "")
if not u:
return None
for fam in _FAMILIES:
if fam.replace("-", "") in u.replace("-", ""):
return fam
return None
def _bom_fp(graph: DesignGraph, ref: str) -> str:
row = (graph.bom_fields or {}).get(ref) or {}
return str(row.get("footprint") or row.get("Footprint") or "")
def _sch_fp(graph: DesignGraph, ref: str, comp: Component) -> str:
row = (graph.schematic_fields or {}).get(ref) or {}
return str(row.get("footprint") or comp.footprint or "")
def _signal_pads(numbers: list[str]) -> set[str]:
out: set[str] = set()
for n in numbers:
s = str(n).strip()
if not s or _EP_PAD.match(s):
continue
out.add(s)
return out
def _package_row(cons: ComponentConstraints | None, specs) -> PackageInfo | None:
if cons and cons.package_info:
return cons.package_info
pkg = getattr(specs, "package_info", None)
if isinstance(pkg, PackageInfo):
return pkg
return None
def _cap_vrated(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, CapacitorSpecs) and specs.voltage_rating_v:
try:
return float(str(specs.voltage_rating_v).replace("V", "").strip())
except (TypeError, ValueError):
return None
return None
def _ind_irated(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, InductorSpecs) and specs.current_rating_a:
try:
return float(str(specs.current_rating_a).replace("A", "").strip())
except (TypeError, ValueError):
return None
return None
def _rating_unit_v(r: AbsMaxRating) -> bool:
return (r.unit or "").lower() in {"v", "volt", "volts"}
def _rating_unit_c(r: AbsMaxRating) -> bool:
u = (r.unit or "").lower().replace("°", "")
return u in {"c", "degc", "celsius"}
def _rating_unit_a(r: AbsMaxRating) -> bool:
return (r.unit or "").lower() in {"a", "amp", "amps"}
def check_bom_pcb_datasheet(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
"""Three-way mismatches when BOM, PCB, and library all have numbers."""
cmap = constraints_map or {}
out: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if ref.startswith("#"):
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
fp_layout = layout.footprints.get(ref) if layout else None
pcb_name = (fp_layout.footprint if fp_layout else "") or ""
sch_name = _sch_fp(graph, ref, comp)
bom_name = _bom_fp(graph, ref)
pkg = _package_row(cons, comp.specs)
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(_voltage_findings(graph, ref, comp, cons))
out.extend(_temp_findings(ref, comp, cons))
out.extend(_current_findings(graph, ref, comp, cons))
return out
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,
) -> Finding:
return Finding(
designator=ref,
mpn=mpn,
aspect="bom_pcb",
finding=finding,
facts=facts,
requirement=requirement,
inference=inference or "Measured three-way mismatch — not a guessed land pattern.",
why=requirement,
status=status,
recommendation=rec,
action=rec,
source="bom_pcb_check",
rule_id=rule_id,
evidence_status="SUFFICIENT",
net=net,
pins=[ref],
)
def _package_findings(
ref: str,
comp: Component,
pkg: PackageInfo | None,
sch_name: str,
bom_name: str,
pcb_name: str,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
names = {
"schematic": sch_name,
"BOM": bom_name,
"PCB": pcb_name,
}
present = {k: v for k, v in names.items() if (v or "").strip()}
fams = {k: _family(v) for k, v in present.items()}
known = {k: f for k, f in fams.items() if f}
if len(set(known.values())) > 1:
rec = (
f"Align {ref} footprint/package across BOM, schematic, and PCB "
f"to the datasheet package {pkg.package if pkg else '(unknown)'}."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-010",
finding=(
f"{ref} package family disagrees: "
+ ", ".join(f"{k}={v}" for k, v in sorted(known.items()))
+ (f"; datasheet={pkg.package}" if pkg else ".")
),
facts=(
f"sch={sch_name!r} bom={bom_name!r} pcb={pcb_name!r} "
f"datasheet={getattr(pkg, 'package', None)!r}."
),
requirement="BOM, PCB footprint, and datasheet package family must match.",
rec=rec,
))
ds_fam = _family(pkg.package) if pkg else None
if ds_fam and known and ds_fam not in known.values() and any(known.values()):
# Datasheet family vs every CAD family.
if all(f != ds_fam for f in known.values()):
rec = f"Change {ref}'s footprint to the datasheet package {pkg.package}."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-010",
finding=(
f"{ref} datasheet package {pkg.package} ({ds_fam}) does not "
f"match CAD families {sorted(set(known.values()))}."
),
facts=f"datasheet={pkg.package!r}; CAD={present!r}.",
requirement="PCB/BOM footprint family must match datasheet package_info.",
rec=rec,
))
return out
def _pinout_findings(
ref: str,
comp: Component,
cons: ComponentConstraints | None,
fp_layout,
pkg: PackageInfo | None,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
sch_pins = _signal_pads(list(comp.pins.keys()))
ds_pins = _signal_pads(
[str(p.number) for p in (cons.pintable if cons else [])]
)
pcb_pins = _signal_pads(
[p.number for p in (fp_layout.pads if fp_layout else [])]
)
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:
rec = (
f"Fix {ref} pinout: pads vs pintable. Missing on PCB: "
f"{missing_pcb or '—'}; extra on PCB: {extra_pcb or '—'}."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
finding=(
f"{ref} pintable vs PCB pads differ "
f"(missing {missing_pcb}, extra {extra_pcb})."
),
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:
rec = f"Align {ref} schematic pins with the datasheet pintable."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
finding=(
f"{ref} pintable vs schematic pins differ "
f"(missing {missing}, extra {extra})."
),
facts=f"pintable={sorted(ds_pins)}; sch={sorted(sch_pins)}.",
requirement="Schematic pin numbers must match the datasheet pintable.",
rec=rec,
status="ERROR",
))
return out
def _voltage_findings(
graph: DesignGraph,
ref: str,
comp: Component,
cons: ComponentConstraints | None,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
vrated = _cap_vrated(comp)
if vrated is not None:
return out # PE-DRT-* owns capacitor Vop/Vrated.
if not cons:
return out
for r in cons.absolute_maximum_ratings:
if r.max is None or not _rating_unit_v(r) or _NOT_V.search(r.parameter or ""):
continue
if not _V_PARAM.search(r.parameter or ""):
continue
for pin in cons.pintable:
blob = f"{pin.number} {pin.name} {pin.description or ''}"
if not _V_PARAM.search(blob):
continue
net = graph.pin_net(ref, str(pin.number))
if not net:
continue
vop = _net_voltage(graph, net)
if vop is None:
continue
if vop > float(r.max):
rec = (
f"Lower '{net}' below {r.max:g} V or change {ref} "
f"({r.parameter} abs-max)."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-012",
finding=(
f"{ref} '{net}' is {vop:g} V; datasheet {r.parameter} "
f"abs-max is {r.max:g} {r.unit}."
),
facts=f"Vop={vop:g} V; abs-max {r.parameter}={r.max:g} {r.unit} p.{r.source_page}.",
requirement="Operating voltage must stay within absolute maximum ratings.",
rec=rec,
net=net,
))
return out
def _temp_findings(
ref: str,
comp: Component,
cons: ComponentConstraints | None,
) -> list[Finding]:
"""Operating temp vs abs-max only when both numbers exist (no Ta=25 invented)."""
if not cons:
return []
values = _specs_values(comp)
ta = _first(values, ("ta_max_c", "operating_temp_max_c", "tamb_max_c", "ta_max"))
if ta is None:
return []
out: list[Finding] = []
mpn = comp.mpn or ""
for r in cons.absolute_maximum_ratings:
if r.max is None or not _rating_unit_c(r):
continue
if not _T_PARAM.search(r.parameter or ""):
continue
if ta > float(r.max):
rec = f"Keep {ref} ambient/operating temperature ≤ {r.max:g} °C."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-013",
finding=(
f"{ref} operating temp {ta:g} °C exceeds {r.parameter} "
f"abs-max {r.max:g} {r.unit}."
),
facts=f"Ta_max={ta:g} °C from specs; abs-max {r.parameter}={r.max:g}.",
requirement="Specified operating temperature must not exceed abs-max T.",
rec=rec,
status="ERROR",
))
return out
def _current_findings(
graph: DesignGraph,
ref: str,
comp: Component,
cons: ComponentConstraints | None,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
i_load = _first(_specs_values(comp), _LOAD_KEYS)
irated = _ind_irated(comp)
if irated is not None and i_load is not None and i_load > irated:
rec = f"Replace {ref} with an inductor rated above {i_load:g} A."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-014",
finding=f"{ref} I_load={i_load:g} A exceeds current rating {irated:g} A.",
facts=f"I_load={i_load:g} A; Irated={irated:g} A.",
requirement="Load current must not exceed the inductor current rating.",
rec=rec,
))
if not cons or i_load is None:
return out
for r in cons.absolute_maximum_ratings:
if r.max is None or not _rating_unit_a(r):
continue
if not _I_PARAM.search(r.parameter or ""):
continue
if i_load > float(r.max):
rec = f"Cut the load on {ref} below {r.max:g} A ({r.parameter})."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-014",
finding=(
f"{ref} I_load={i_load:g} A exceeds abs-max {r.parameter} "
f"{r.max:g} A."
),
facts=f"I_load={i_load:g} A; abs-max {r.parameter}={r.max:g} A p.{r.source_page}.",
requirement="I_load must stay within absolute maximum current.",
rec=rec,
))
return out