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.
755 lines
24 KiB
Python
755 lines
24 KiB
Python
"""BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings.
|
||
|
||
Skip when a side is missing. No invented JEDEC land patterns or IEC
|
||
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,
|
||
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.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",
|
||
"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",
|
||
)
|
||
# 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(
|
||
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
|
||
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,
|
||
)
|
||
_NOT_V = re.compile(r"(IOUT|CURRENT|POWER|PD|TJ|TSTG|TEMP)", re.I)
|
||
_GND_DIFF_V = re.compile(
|
||
r"ground voltage|vss to vss|vss.?to.?vss|thermal pad",
|
||
re.I,
|
||
)
|
||
_RAIL_TOKEN = re.compile(r"\b(V[A-Z][A-Z0-9]*)\b")
|
||
_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 _FAMILY_CANON.get(fam, 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 _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"}:
|
||
return True
|
||
if _EP_SPLIT.match(s):
|
||
return True
|
||
if pin_count and s.isdigit() and int(s) == pin_count + 1:
|
||
return True
|
||
return False
|
||
|
||
|
||
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 _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]:
|
||
"""Pintable pin **numbers** (A5, 17, EP). Names like P1 are not pad ids."""
|
||
out: set[str] = set()
|
||
if not cons:
|
||
return out
|
||
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
|
||
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,
|
||
footprint_dirs: list[Path] | None = None,
|
||
) -> list[Finding]:
|
||
"""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()):
|
||
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, 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))
|
||
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,
|
||
evidence_status: str = "SUFFICIENT",
|
||
) -> 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=evidence_status,
|
||
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 _pin_delta(
|
||
*,
|
||
ref: str,
|
||
mpn: str,
|
||
finding: str,
|
||
facts: str,
|
||
requirement: str,
|
||
rec: str,
|
||
status: str = "ERROR",
|
||
evidence_status: str = "SUFFICIENT",
|
||
) -> Finding:
|
||
return _mismatch(
|
||
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
|
||
finding=finding, facts=facts, requirement=requirement, rec=rec,
|
||
status=status, evidence_status=evidence_status,
|
||
)
|
||
|
||
|
||
def _pinout_findings(
|
||
ref: str,
|
||
comp: Component,
|
||
cons: ComponentConstraints | None,
|
||
fp_layout,
|
||
pkg: PackageInfo | None,
|
||
footprint_dirs: list[Path] | None = None,
|
||
) -> 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
|
||
pcb_ids = _pads_as_ids(fp_layout)
|
||
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()
|
||
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(
|
||
fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs,
|
||
)
|
||
sch = _join_keys_from_ids(
|
||
[(str(n), "") for n in comp.pins.keys()],
|
||
pin_count=ep_count,
|
||
ds_keys=ds,
|
||
)
|
||
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:
|
||
miss_lib = sorted(ds - lib)
|
||
extra_lib = sorted(lib - ds)
|
||
if miss_lib or extra_lib:
|
||
rec = (
|
||
f"Change {ref}'s KiCad footprint library so pad names match "
|
||
f"the datasheet pintable (not a PCB pad-count guess)."
|
||
)
|
||
fnd = _join_issue(
|
||
miss=miss_lib, extra=extra_lib,
|
||
finding=(
|
||
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."
|
||
),
|
||
rec=rec,
|
||
)
|
||
if fnd:
|
||
out.append(fnd)
|
||
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"Replace {ref}'s embedded PCB footprint so it matches the "
|
||
f"named library footprint {fp_name}."
|
||
)
|
||
fnd = _join_issue(
|
||
miss=miss_emb, extra=extra_emb,
|
||
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,
|
||
)
|
||
if fnd:
|
||
out.append(fnd)
|
||
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 '—'}."
|
||
)
|
||
fnd = _join_issue(
|
||
miss=miss, extra=extra,
|
||
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,
|
||
)
|
||
if fnd:
|
||
out.append(fnd)
|
||
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(_pin_delta(
|
||
ref=ref, mpn=mpn,
|
||
finding=(
|
||
f"{ref} pintable vs schematic pins differ "
|
||
f"(missing {miss}, extra {extra})."
|
||
),
|
||
facts=f"pintable={sorted(ds)}; sch={sorted(sch)}.",
|
||
requirement="Schematic pin numbers must match the datasheet pintable.",
|
||
rec=rec,
|
||
))
|
||
return out
|
||
|
||
|
||
def _absmax_binds_pin(parameter: str, pin) -> bool:
|
||
"""Bind a voltage abs-max to the named pin, not every supply pin."""
|
||
param = parameter or ""
|
||
if _GND_DIFF_V.search(param) and not re.search(r"\bVIN\b|\bVDD\b|\bVCC\b", param, re.I):
|
||
return False
|
||
pin_u = f"{pin.name or ''} {pin.description or ''}".upper()
|
||
xtal = re.search(r"XTAL\d*", param, re.I)
|
||
if xtal:
|
||
return xtal.group(0).upper() in pin_u
|
||
rails = _RAIL_TOKEN.findall(param.upper())
|
||
rails = [r for r in rails if r not in {"VOLTAGE", "VSS", "VEE"}]
|
||
if not rails:
|
||
return False
|
||
name_u = (pin.name or "").upper()
|
||
return any(r == name_u or r in name_u.split("/") or name_u.startswith(r) for r in rails)
|
||
|
||
|
||
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:
|
||
if not _absmax_binds_pin(r.parameter or "", pin):
|
||
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
|