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.
418 lines
14 KiB
Python
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
|