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.
This commit is contained in:
@@ -0,0 +1,417 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""EMI beyond ESD — only when a datasheet FACT names a filter.
|
||||||
|
|
||||||
|
No IEC 61000 invention. Common-mode / ferrite / shield from layout_rules
|
||||||
|
notes or kind, checked against parts on the same HS connector net.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from backend.periscopex.models import (
|
||||||
|
ComponentConstraints,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
Finding,
|
||||||
|
LayoutGraph,
|
||||||
|
)
|
||||||
|
from backend.periscopex.pcb_net_match import (
|
||||||
|
normalize_kicad_hierarchy_net,
|
||||||
|
refs_on_matched_net,
|
||||||
|
)
|
||||||
|
from backend.periscopex.si_check import bus_class, skip_si_net
|
||||||
|
from backend.periscopex.validate import _match_constraints
|
||||||
|
|
||||||
|
_EMI_RE = re.compile(
|
||||||
|
r"(emi|emc|common[-\s]?mode|cmc|choke|ferrite\s*bead|shield(?:ing)?|"
|
||||||
|
r"pi\s*filter|lc\s*filter)",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_EMI_KINDS = frozenset({"emi", "common_mode", "shield"})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_filter_part(comp) -> bool:
|
||||||
|
sub = (comp.component_subtype or "").lower()
|
||||||
|
val = (comp.value or "").lower()
|
||||||
|
blob = f"{sub} {val} {comp.mpn or ''}"
|
||||||
|
if "esd" in sub or "protection" in sub:
|
||||||
|
return False
|
||||||
|
if "ferrite" in blob or "common" in blob or "choke" in blob or "cmc" in blob:
|
||||||
|
return True
|
||||||
|
if comp.component_type == ComponentType.INDUCTOR and _EMI_RE.search(blob):
|
||||||
|
return True
|
||||||
|
if (comp.reference or "").upper().startswith(("FB", "L")) and _EMI_RE.search(blob):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _emi_rules(cons: ComponentConstraints | None) -> list[dict]:
|
||||||
|
if not cons:
|
||||||
|
return []
|
||||||
|
rows: list[dict] = []
|
||||||
|
for rule in cons.layout_rules or []:
|
||||||
|
if not isinstance(rule, dict):
|
||||||
|
continue
|
||||||
|
kind = str(rule.get("kind") or "")
|
||||||
|
note = str(rule.get("note") or "")
|
||||||
|
if kind in _EMI_KINDS or _EMI_RE.search(note) or _EMI_RE.search(kind):
|
||||||
|
rows.append(rule)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def check_emi(
|
||||||
|
graph: DesignGraph,
|
||||||
|
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||||
|
layout: LayoutGraph | None = None,
|
||||||
|
) -> list[Finding]:
|
||||||
|
"""REVIEW when a sourced EMI/filter FACT has no matching part on the net."""
|
||||||
|
_ = layout
|
||||||
|
cmap = constraints_map or {}
|
||||||
|
out: list[Finding] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for ref, comp in sorted(graph.components.items()):
|
||||||
|
if comp.component_type not in (ComponentType.IC, ComponentType.CONNECTOR):
|
||||||
|
continue
|
||||||
|
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||||
|
rules = _emi_rules(cons)
|
||||||
|
if not rules:
|
||||||
|
continue
|
||||||
|
for pin, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||||
|
if not net or skip_si_net(net):
|
||||||
|
continue
|
||||||
|
if not bus_class(net) and comp.component_type != ComponentType.CONNECTOR:
|
||||||
|
continue
|
||||||
|
key = (ref, normalize_kicad_hierarchy_net(net))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
on_net = refs_on_matched_net(graph, net)
|
||||||
|
if any(
|
||||||
|
r in graph.components and _is_filter_part(graph.components[r])
|
||||||
|
for r in on_net
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
quote = next(
|
||||||
|
(str(r.get("note") or "") for r in rules if r.get("note")),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
page = next(
|
||||||
|
(r.get("source_page") for r in rules if r.get("source_page")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
rec = (
|
||||||
|
f"Add the datasheet EMI/filter part on '{net}' "
|
||||||
|
f"(common-mode / ferrite / shield as quoted). Not IEC 61000."
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
out.append(Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=comp.mpn or "",
|
||||||
|
aspect="emi",
|
||||||
|
finding=(
|
||||||
|
f"{net} has a datasheet EMI/filter FACT and no ferrite/"
|
||||||
|
f"common-mode/choke part on the net."
|
||||||
|
),
|
||||||
|
facts=(
|
||||||
|
f"net={net}; pin={pin}; filter_parts=none; "
|
||||||
|
f"quote={quote!r}; page={page}."
|
||||||
|
),
|
||||||
|
requirement=quote or "Datasheet layout_rules EMI/filter note.",
|
||||||
|
inference="Filter missing vs sourced note — REVIEW, not IEC.",
|
||||||
|
why="EMI only when the library quotes a filter; ESD is PE-ESD-001.",
|
||||||
|
status="WARNING",
|
||||||
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
|
source="emi_check",
|
||||||
|
source_page=int(page) if isinstance(page, int) else None,
|
||||||
|
source_quote=quote,
|
||||||
|
rule_id="PE-EMI-001",
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
net=net,
|
||||||
|
pins=[str(pin)],
|
||||||
|
))
|
||||||
|
return out
|
||||||
@@ -177,6 +177,22 @@ def _seed() -> None:
|
|||||||
requirement="Connector/USB net ESD — REVIEW unless a mandatory FACT exists.")
|
requirement="Connector/USB net ESD — REVIEW unless a mandatory FACT exists.")
|
||||||
_add("PE-RET-001", "TYPICAL", "REVIEW", domain="pcb",
|
_add("PE-RET-001", "TYPICAL", "REVIEW", domain="pcb",
|
||||||
requirement="High-speed net over a GND pour should have a nearby GND via.")
|
requirement="High-speed net over a GND pour should have a nearby GND via.")
|
||||||
|
_add("PE-THM-002", "TYPICAL", "RISK", domain="pcb",
|
||||||
|
requirement="Tj = Ta + P·θJA when copper area, vias, P, and θJA all exist.")
|
||||||
|
_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.")
|
||||||
|
_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",
|
||||||
|
requirement="Specified operating temperature must not exceed abs-max T.")
|
||||||
|
_add("PE-BOM-014", "MANDATORY", "RULE", domain="pcb",
|
||||||
|
requirement="I_load must not exceed the sourced current rating / abs-max.")
|
||||||
|
_add("PE-SPOF-001", "TYPICAL", "REVIEW", domain="pcb",
|
||||||
|
requirement="Single regulator or crystal feeding many ICs is REVIEW, not ERROR.")
|
||||||
|
_add("PE-EMI-001", "RECOMMENDED", "REVIEW", domain="pcb",
|
||||||
|
requirement="Datasheet EMI/filter FACT vs ferrite/common-mode on the net.")
|
||||||
|
|
||||||
|
|
||||||
_seed()
|
_seed()
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ from typing import Any
|
|||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
|
|
||||||
KNOWN_KINDS = frozenset({
|
SI_KINDS = frozenset({
|
||||||
"decoupling_proximity", "thermal_via", "keepout", "length_match",
|
"impedance", "length_match", "max_length", "spacing",
|
||||||
"impedance", "max_length", "spacing", "ref_plane", "si_via",
|
"ref_plane", "si_via", "layer", "series_resistor", "return_path", "si",
|
||||||
"layer", "series_resistor", "return_path", "si",
|
|
||||||
})
|
})
|
||||||
|
EMI_KINDS = frozenset({"emi", "common_mode", "shield"})
|
||||||
|
KNOWN_KINDS = frozenset({
|
||||||
|
"decoupling_proximity", "thermal_via", "keepout",
|
||||||
|
}) | SI_KINDS | EMI_KINDS
|
||||||
|
|
||||||
|
|
||||||
def _num(v: Any) -> float | None:
|
def _num(v: Any) -> float | None:
|
||||||
@@ -36,25 +39,45 @@ def has_any_layout_rule(raw: object) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def has_si_layout_rule(raw: object) -> bool:
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return False
|
||||||
|
for row in raw:
|
||||||
|
if isinstance(row, dict) and str(row.get("kind") or "").strip() in SI_KINDS:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def needs_layout_rules_refresh(
|
def needs_layout_rules_refresh(
|
||||||
data: dict,
|
data: dict,
|
||||||
*,
|
*,
|
||||||
min_scan_version: str,
|
min_scan_version: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True when layout_rules are empty and the extract predates the scan version.
|
"""True when this extract should be re-run against the current pintable skill.
|
||||||
|
|
||||||
After a successful extract at ``min_scan_version`` or newer, an empty
|
After a successful extract at ``min_scan_version`` or newer, empty
|
||||||
``layout_rules`` list means the datasheet had no guidance — do not loop.
|
``layout_rules`` means the datasheet had no guidance — do not loop.
|
||||||
|
|
||||||
|
From skill 1.11.0, SI kinds are first-class. An older JSON that only
|
||||||
|
has decoupling/thermal rules is stale and must be re-extracted so
|
||||||
|
ImpedenceFinder checks are not starved.
|
||||||
"""
|
"""
|
||||||
if has_any_layout_rule(data.get("layout_rules")):
|
|
||||||
return False
|
|
||||||
ver = str(data.get("model_version") or "0.0.0")
|
ver = str(data.get("model_version") or "0.0.0")
|
||||||
if not min_scan_version or min_scan_version == "0.0.0":
|
if not min_scan_version or min_scan_version == "0.0.0":
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
return Version(ver) < Version(min_scan_version)
|
stale = Version(ver) < Version(min_scan_version)
|
||||||
except Exception:
|
except Exception:
|
||||||
return True
|
return True
|
||||||
|
if not stale:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
need_si = Version(min_scan_version) >= Version("1.11.0")
|
||||||
|
except Exception:
|
||||||
|
need_si = False
|
||||||
|
if need_si:
|
||||||
|
return not has_si_layout_rule(data.get("layout_rules"))
|
||||||
|
return not has_any_layout_rule(data.get("layout_rules"))
|
||||||
|
|
||||||
|
|
||||||
def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
|
def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import logging
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from backend.periscopex.derating import build_derating_table
|
from backend.periscopex.derating import build_derating_table
|
||||||
|
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||||
|
from backend.periscopex.emi_check import check_emi
|
||||||
from backend.periscopex.esd_return_check import check_esd, check_return_path
|
from backend.periscopex.esd_return_check import check_esd, check_return_path
|
||||||
from backend.periscopex.finding_engine import complete_findings
|
from backend.periscopex.finding_engine import complete_findings
|
||||||
from backend.periscopex.functional_groups import FunctionalGroupsReport
|
from backend.periscopex.functional_groups import FunctionalGroupsReport
|
||||||
@@ -14,6 +16,7 @@ from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
|||||||
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
||||||
from backend.periscopex.pcb_power_thermal import (
|
from backend.periscopex.pcb_power_thermal import (
|
||||||
check_pcb_gnd_stitch,
|
check_pcb_gnd_stitch,
|
||||||
|
check_pcb_junction_temp,
|
||||||
check_pcb_kelvin,
|
check_pcb_kelvin,
|
||||||
check_pcb_power_traces,
|
check_pcb_power_traces,
|
||||||
check_pcb_thermal_copper,
|
check_pcb_thermal_copper,
|
||||||
@@ -22,6 +25,7 @@ from backend.periscopex.pcb_power_thermal import (
|
|||||||
from backend.periscopex.pi_check import check_power_integrity
|
from backend.periscopex.pi_check import check_power_integrity
|
||||||
from backend.periscopex.placement_check import check_placement
|
from backend.periscopex.placement_check import check_placement
|
||||||
from backend.periscopex.si_check import check_si
|
from backend.periscopex.si_check import check_si
|
||||||
|
from backend.periscopex.spof_check import check_spof
|
||||||
from backend.periscopex.timing_check import check_timing
|
from backend.periscopex.timing_check import check_timing
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -193,6 +197,10 @@ def run_pcb_checks(
|
|||||||
("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)),
|
("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)),
|
||||||
("esd_check", lambda: check_esd(graph, constraints_map)),
|
("esd_check", lambda: check_esd(graph, constraints_map)),
|
||||||
("return_path", lambda: check_return_path(graph, layout)),
|
("return_path", lambda: check_return_path(graph, layout)),
|
||||||
|
("bom_pcb", lambda: check_bom_pcb_datasheet(graph, constraints_map, layout)),
|
||||||
|
("spof_check", lambda: check_spof(graph, constraints_map)),
|
||||||
|
("emi_check", lambda: check_emi(graph, constraints_map, layout)),
|
||||||
|
("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
out.extend(fn())
|
out.extend(fn())
|
||||||
|
|||||||
@@ -372,6 +372,161 @@ def check_pcb_thermal_copper(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _shoelace_mm2(pts: list[tuple[float, float]]) -> float:
|
||||||
|
if len(pts) < 3:
|
||||||
|
return 0.0
|
||||||
|
s = 0.0
|
||||||
|
n = len(pts)
|
||||||
|
for i in range(n):
|
||||||
|
x1, y1 = pts[i]
|
||||||
|
x2, y2 = pts[(i + 1) % n]
|
||||||
|
s += x1 * y2 - x2 * y1
|
||||||
|
return abs(s) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def check_pcb_junction_temp(
|
||||||
|
graph: DesignGraph,
|
||||||
|
constraints_map: dict,
|
||||||
|
layout: LayoutGraph | None,
|
||||||
|
) -> list[Finding]:
|
||||||
|
"""Tj = Ta + P·θJA when P, θJA, copper area, and via count all exist.
|
||||||
|
|
||||||
|
Copper area and via count are FACT only — θJA is not scaled with an
|
||||||
|
invented spreading/FEM model. Missing any parameter → INSUFFICIENT.
|
||||||
|
"""
|
||||||
|
if layout is None:
|
||||||
|
return []
|
||||||
|
out: list[Finding] = []
|
||||||
|
for ref, comp in sorted(graph.components.items()):
|
||||||
|
if comp.component_type != ComponentType.IC:
|
||||||
|
continue
|
||||||
|
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
|
||||||
|
values = _specs_values(comp)
|
||||||
|
i_load = _first(values, _LOAD_KEYS)
|
||||||
|
if i_load is None:
|
||||||
|
continue
|
||||||
|
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
|
||||||
|
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
|
||||||
|
vin = _net_voltage(graph, vin_n) if vin_n else None
|
||||||
|
vout = _net_voltage(graph, vout_n) if vout_n else None
|
||||||
|
if vin is None or vout is None or vin <= vout:
|
||||||
|
continue
|
||||||
|
p = i_load * (vin - vout)
|
||||||
|
theta = _first(values, _THETA_KEYS)
|
||||||
|
tjmax = _first(values, _TJMAX_KEYS)
|
||||||
|
fp = layout.footprints.get(ref)
|
||||||
|
region = _footprint_region(fp) if fp else []
|
||||||
|
via_n = 0
|
||||||
|
copper_mm2 = 0.0
|
||||||
|
if len(region) >= 3:
|
||||||
|
for v in layout.vias:
|
||||||
|
if _in_poly(v.x, v.y, region):
|
||||||
|
via_n += 1
|
||||||
|
cy = _shoelace_mm2(region)
|
||||||
|
for z in layout.zones:
|
||||||
|
if not z.net:
|
||||||
|
continue
|
||||||
|
leaf = normalize_kicad_hierarchy_net(z.net).upper()
|
||||||
|
ok_net = False
|
||||||
|
if vout_n and kicad_nets_match(z.net, vout_n):
|
||||||
|
ok_net = True
|
||||||
|
elif vin_n and kicad_nets_match(z.net, vin_n):
|
||||||
|
ok_net = True
|
||||||
|
elif leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or "GND" in leaf:
|
||||||
|
ok_net = True
|
||||||
|
if not ok_net:
|
||||||
|
continue
|
||||||
|
for outline in z.outlines:
|
||||||
|
if len(outline) >= 3 and _in_poly(fp.x, fp.y, outline):
|
||||||
|
copper_mm2 += min(cy, _shoelace_mm2(outline)) if cy else _shoelace_mm2(outline)
|
||||||
|
break
|
||||||
|
missing: list[str] = []
|
||||||
|
if theta is None:
|
||||||
|
missing.append("θJA")
|
||||||
|
if len(region) < 3:
|
||||||
|
missing.append("courtyard")
|
||||||
|
rec_ins = (
|
||||||
|
"Add datasheet θJA, a KiCad courtyard, and copper/via geometry; "
|
||||||
|
"Tj is not estimated from invented 1 oz / 10 °C defaults."
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
out.append(Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=comp.mpn or "",
|
||||||
|
aspect="layout_thermal",
|
||||||
|
finding=(
|
||||||
|
f"{ref} P≈{p:.3g} W but Tj is not estimated "
|
||||||
|
f"(missing {', '.join(missing)})."
|
||||||
|
),
|
||||||
|
facts=(
|
||||||
|
f"P≈{p:.3g} W; θJA={theta}; copper_mm2={copper_mm2:.3g}; "
|
||||||
|
f"vias_in_courtyard={via_n}; missing={missing}."
|
||||||
|
),
|
||||||
|
requirement="Tj = Ta + P·θJA only with measured copper area, via count, and θJA.",
|
||||||
|
inference="Insufficient evidence — not a FEM solve.",
|
||||||
|
why="No invented spreading model or default copper weight.",
|
||||||
|
status="INFO",
|
||||||
|
recommendation=rec_ins,
|
||||||
|
action=rec_ins,
|
||||||
|
source="pcb_power_thermal",
|
||||||
|
rule_id="PE-THM-002",
|
||||||
|
evidence_status="INSUFFICIENT",
|
||||||
|
net=vout_n,
|
||||||
|
pins=[],
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
tj = _TA_C + p * theta
|
||||||
|
calc = (
|
||||||
|
f"Tj = {_TA_C:g} + {p:.4g}×{theta:g} = {tj:.1f} °C "
|
||||||
|
f"(copper_mm2={copper_mm2:.3g}, vias={via_n}; θJA not derated)."
|
||||||
|
)
|
||||||
|
over = tjmax is not None and tj > tjmax
|
||||||
|
rec = (
|
||||||
|
f"Lower P or θJA so Tj stays below {tjmax:g} °C."
|
||||||
|
if over else
|
||||||
|
"Tj estimate is within the known θJA model; FEM not used."
|
||||||
|
)
|
||||||
|
out.append(Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=comp.mpn or "",
|
||||||
|
aspect="layout_thermal",
|
||||||
|
finding=(
|
||||||
|
f"{ref} Tj≈{tj:.0f} °C (Ta={_TA_C:.0f} °C, P≈{p:.3g} W, "
|
||||||
|
f"θJA={theta:g} °C/W, copper≈{copper_mm2:.3g} mm², vias={via_n})"
|
||||||
|
+ (f"; Tjmax={tjmax:g} °C." if tjmax is not None else ".")
|
||||||
|
),
|
||||||
|
facts=(
|
||||||
|
f"P={p:.4g} W; θJA={theta:g}; copper_mm2={copper_mm2:.4g}; "
|
||||||
|
f"vias={via_n}; Ta={_TA_C:g}; Tj={tj:.2f}"
|
||||||
|
+ (f"; Tjmax={tjmax:g}" if tjmax is not None else "")
|
||||||
|
+ "."
|
||||||
|
),
|
||||||
|
requirement=(
|
||||||
|
"Datasheet θJA applies as published; copper/vias reported as FACT."
|
||||||
|
),
|
||||||
|
inference=(
|
||||||
|
"Tj exceeds Tjmax under this θJA model."
|
||||||
|
if over else
|
||||||
|
"Closed-form θJA estimate — not a thermal FEM."
|
||||||
|
),
|
||||||
|
why="All of P, θJA, courtyard copper area, and via count were measured.",
|
||||||
|
status="WARNING" if over else "INFO",
|
||||||
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
|
source="pcb_power_thermal",
|
||||||
|
rule_id="PE-THM-002",
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
calculation=calc,
|
||||||
|
assumptions=[
|
||||||
|
f"Ta={_TA_C:g} °C (explicit default, not a 10 °C rise).",
|
||||||
|
"θJA is used as published; no via/copper spreading formula.",
|
||||||
|
],
|
||||||
|
net=vout_n,
|
||||||
|
pins=[],
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _is_gnd_name(name: str) -> bool:
|
def _is_gnd_name(name: str) -> bool:
|
||||||
leaf = normalize_kicad_hierarchy_net(name).upper()
|
leaf = normalize_kicad_hierarchy_net(name).upper()
|
||||||
return leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or leaf.endswith("/GND")
|
return leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or leaf.endswith("/GND")
|
||||||
|
|||||||
@@ -642,8 +642,9 @@ def check_si(
|
|||||||
lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner)
|
lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner)
|
||||||
skew = abs(la - lb) if partner else 0.0
|
skew = abs(la - lb) if partner else 0.0
|
||||||
rec = (
|
rec = (
|
||||||
"Extract Z0/skew/spacing/vias/layer from the PHY/module datasheet "
|
"Re-run schematic review so pintable extract ≥ 1.12.0 fills "
|
||||||
"into layout_rules, then re-run. Do not assume 90 Ω."
|
"layout_rules (Z0/skew/spacing). Do not assume 90 Ω. PCB does "
|
||||||
|
"not re-read the PDF."
|
||||||
)
|
)
|
||||||
findings.append(Finding(
|
findings.append(Finding(
|
||||||
designator="layout",
|
designator="layout",
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Single-point-of-failure on power rails — REVIEW, never RULE ERROR.
|
||||||
|
|
||||||
|
FACT: how many regulators drive a rail and how many ICs sit on it.
|
||||||
|
REQUIREMENT: redundancy is not a datasheet shall unless stated.
|
||||||
|
INFERENCE: this rail is a SPOF — review, do not invent IEC SIL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from backend.periscopex.models import (
|
||||||
|
ComponentConstraints,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
Finding,
|
||||||
|
NetType,
|
||||||
|
)
|
||||||
|
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
|
||||||
|
from backend.periscopex.power_margin_check import _regulators
|
||||||
|
|
||||||
|
_SKIP_RAIL = frozenset({"GND", "AGND", "DGND", "PGND", "VSS"})
|
||||||
|
|
||||||
|
|
||||||
|
def check_spof(
|
||||||
|
graph: DesignGraph,
|
||||||
|
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||||
|
) -> list[Finding]:
|
||||||
|
cmap = constraints_map or {}
|
||||||
|
producers: dict[str, list[str]] = defaultdict(list)
|
||||||
|
for ref, _comp, _cons, _vin, vout in _regulators(graph, cmap):
|
||||||
|
key = normalize_kicad_hierarchy_net(vout)
|
||||||
|
if key.upper() in _SKIP_RAIL:
|
||||||
|
continue
|
||||||
|
producers[key].append(ref)
|
||||||
|
|
||||||
|
out: list[Finding] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for net_name, net in sorted(graph.nets.items()):
|
||||||
|
key = normalize_kicad_hierarchy_net(net_name)
|
||||||
|
if key in seen or key.upper() in _SKIP_RAIL:
|
||||||
|
continue
|
||||||
|
if net.net_type != NetType.POWER and not producers.get(key):
|
||||||
|
continue
|
||||||
|
regs = producers.get(key) or []
|
||||||
|
if len(regs) != 1:
|
||||||
|
continue
|
||||||
|
ics = [
|
||||||
|
r for r in graph.components_on_net(net_name)
|
||||||
|
if r in graph.components
|
||||||
|
and graph.components[r].component_type == ComponentType.IC
|
||||||
|
and r not in regs
|
||||||
|
]
|
||||||
|
if len(ics) < 1:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
reg = regs[0]
|
||||||
|
rcomp = graph.components[reg]
|
||||||
|
rec = (
|
||||||
|
f"Review whether '{net_name}' needs a second source; {reg} is the "
|
||||||
|
f"only regulator feeding {len(ics)} IC(s). This is not a design-rule ERROR."
|
||||||
|
)
|
||||||
|
out.append(Finding(
|
||||||
|
designator=reg,
|
||||||
|
mpn=rcomp.mpn or "",
|
||||||
|
aspect="spof",
|
||||||
|
finding=(
|
||||||
|
f"Rail '{net_name}' has a single regulator ({reg}) and "
|
||||||
|
f"{len(ics)} downstream IC(s) ({', '.join(ics[:8])}"
|
||||||
|
f"{'…' if len(ics) > 8 else ''})."
|
||||||
|
),
|
||||||
|
facts=(
|
||||||
|
f"producers={regs}; downstream_ics={ics}; "
|
||||||
|
f"net_type={net.net_type.value}."
|
||||||
|
),
|
||||||
|
requirement=(
|
||||||
|
"Redundancy / dual-supply is REVIEW unless a datasheet shall "
|
||||||
|
"names a second source (none assumed)."
|
||||||
|
),
|
||||||
|
inference="Single point of failure on this rail — engineering review.",
|
||||||
|
why="SPOF is not a measured RULE; no IEC SIL invented.",
|
||||||
|
status="INFO",
|
||||||
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
|
source="spof_check",
|
||||||
|
rule_id="PE-SPOF-001",
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
net=net_name,
|
||||||
|
pins=[],
|
||||||
|
))
|
||||||
|
|
||||||
|
# Crystal / clock: one XTAL feeding one MCU is normal — only flag when
|
||||||
|
# several ICs share one crystal net with no second oscillator FACT.
|
||||||
|
for ref, comp in sorted(graph.components.items()):
|
||||||
|
if comp.component_type != ComponentType.CRYSTAL:
|
||||||
|
continue
|
||||||
|
nets = [n for n in set(comp.pins.values()) if n]
|
||||||
|
ics: list[str] = []
|
||||||
|
for n in nets:
|
||||||
|
for r in graph.components_on_net(n):
|
||||||
|
c = graph.components.get(r)
|
||||||
|
if c and c.component_type == ComponentType.IC and r not in ics:
|
||||||
|
ics.append(r)
|
||||||
|
xtals = [
|
||||||
|
r for r, c in graph.components.items()
|
||||||
|
if c.component_type == ComponentType.CRYSTAL
|
||||||
|
]
|
||||||
|
if len(ics) < 2 or len(xtals) > 1:
|
||||||
|
continue
|
||||||
|
rec = (
|
||||||
|
f"Review clock redundancy: {ref} is the only crystal and feeds "
|
||||||
|
f"{len(ics)} ICs. Not a RULE."
|
||||||
|
)
|
||||||
|
out.append(Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=comp.mpn or "",
|
||||||
|
aspect="spof",
|
||||||
|
finding=(
|
||||||
|
f"{ref} is the only crystal and clocks {len(ics)} ICs "
|
||||||
|
f"({', '.join(ics)})."
|
||||||
|
),
|
||||||
|
facts=f"xtals={xtals}; ics_on_crystal_nets={ics}.",
|
||||||
|
requirement="A second oscillator is not assumed from IEC or folklore.",
|
||||||
|
inference="Clock SPOF — REVIEW.",
|
||||||
|
why="Counted crystals and IC pins on those nets.",
|
||||||
|
status="INFO",
|
||||||
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
|
source="spof_check",
|
||||||
|
rule_id="PE-SPOF-001",
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
pins=[],
|
||||||
|
))
|
||||||
|
return out
|
||||||
@@ -139,7 +139,7 @@ PINTABLE_TOOL = {
|
|||||||
"PCB layout constraints from typical-application / PCB layout pages. "
|
"PCB layout constraints from typical-application / PCB layout pages. "
|
||||||
"kind: decoupling_proximity | thermal_via | keepout | length_match | "
|
"kind: decoupling_proximity | thermal_via | keepout | length_match | "
|
||||||
"impedance | max_length | spacing | ref_plane | si_via | layer | "
|
"impedance | max_length | spacing | ref_plane | si_via | layer | "
|
||||||
"series_resistor | return_path | si. "
|
"series_resistor | return_path | si | emi | common_mode | shield. "
|
||||||
"Fields: pin, cap_value_hint, max_distance_mm (ONLY if the PDF states a "
|
"Fields: pin, cap_value_hint, max_distance_mm (ONLY if the PDF states a "
|
||||||
"number — never invent 3 mm/JEDEC), same_layer (bool), min_via_count, "
|
"number — never invent 3 mm/JEDEC), same_layer (bool), min_via_count, "
|
||||||
"max_via_count, z0_ohm, zdiff_ohm, tolerance_pct, z_min_ohm, z_max_ohm, "
|
"max_via_count, z0_ohm, zdiff_ohm, tolerance_pct, z_min_ohm, z_max_ohm, "
|
||||||
@@ -165,6 +165,9 @@ PINTABLE_TOOL = {
|
|||||||
"series_resistor",
|
"series_resistor",
|
||||||
"return_path",
|
"return_path",
|
||||||
"si",
|
"si",
|
||||||
|
"emi",
|
||||||
|
"common_mode",
|
||||||
|
"shield",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"pin": {"type": ["string", "null"]},
|
"pin": {"type": ["string", "null"]},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import logging
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.config import settings as app_settings
|
||||||
from backend.periscopex.cad_bridge import (
|
from backend.periscopex.cad_bridge import (
|
||||||
annotate_findings_cad,
|
annotate_findings_cad,
|
||||||
build_cad_bridge,
|
build_cad_bridge,
|
||||||
@@ -21,6 +22,7 @@ from backend.periscopex.cad_bridge import (
|
|||||||
from backend.periscopex.finding_engine import apply_decisions
|
from backend.periscopex.finding_engine import apply_decisions
|
||||||
from backend.periscopex.functional_groups import build_placement_plan
|
from backend.periscopex.functional_groups import build_placement_plan
|
||||||
from backend.periscopex.graph import build_graph
|
from backend.periscopex.graph import build_graph
|
||||||
|
from backend.periscopex.layout_rules import needs_layout_rules_refresh
|
||||||
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph, ValidationReport
|
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph, ValidationReport
|
||||||
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
|
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
|
||||||
from backend.periscopex.pcb_inventory import build_pcb_inventory
|
from backend.periscopex.pcb_inventory import build_pcb_inventory
|
||||||
@@ -220,9 +222,33 @@ async def run_pcb_pipeline(
|
|||||||
|
|
||||||
_step(project_id, "checks", "running")
|
_step(project_id, "checks", "running")
|
||||||
findings = run_pcb_checks(graph, cmap, layout, plan, zrep)
|
findings = run_pcb_checks(graph, cmap, layout, plan, zrep)
|
||||||
|
si_skip: list[dict] = []
|
||||||
|
scan_ver = app_settings.get_default_model_version()
|
||||||
|
for mpn, cons in sorted(cmap.items()):
|
||||||
|
payload = {
|
||||||
|
"model_version": cons.model_version,
|
||||||
|
"layout_rules": cons.layout_rules,
|
||||||
|
}
|
||||||
|
if needs_layout_rules_refresh(
|
||||||
|
payload, min_scan_version=scan_ver,
|
||||||
|
):
|
||||||
|
si_skip.append({
|
||||||
|
"designator": mpn,
|
||||||
|
"reason": (
|
||||||
|
f"SI layout_rules empty at model_version="
|
||||||
|
f"{cons.model_version}; re-run schematic review "
|
||||||
|
f"to re-extract pintable {scan_ver} "
|
||||||
|
"(PCB does not re-read the PDF)."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if si_skip:
|
||||||
|
sipath = ws.local_path("si_extract_needed.json")
|
||||||
|
sipath.write_text(json.dumps(si_skip, indent=2) + "\n")
|
||||||
|
ws._upload_file("si_extract_needed.json")
|
||||||
_step(
|
_step(
|
||||||
project_id, "checks", "complete",
|
project_id, "checks", "complete",
|
||||||
f"{len(findings)} deterministic findings",
|
f"{len(findings)} deterministic findings"
|
||||||
|
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
if _cancelled(storage, user_id, project_id):
|
if _cancelled(storage, user_id, project_id):
|
||||||
@@ -283,7 +309,7 @@ async def run_pcb_pipeline(
|
|||||||
findings=findings,
|
findings=findings,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
coverage=coverage,
|
coverage=coverage,
|
||||||
not_reviewed=skipped,
|
not_reviewed=skipped + si_skip,
|
||||||
)
|
)
|
||||||
report_path = ws.local_path("pcb_report.json")
|
report_path = ws.local_path("pcb_report.json")
|
||||||
report_path.write_text(report.model_dump_json(indent=2) + "\n")
|
report_path.write_text(report.model_dump_json(indent=2) + "\n")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"default_model_version": "1.11.0",
|
"default_model_version": "1.12.0",
|
||||||
"extract-pintable": {
|
"extract-pintable": {
|
||||||
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
|
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
|
||||||
"latest_version": "1784798970179642",
|
"latest_version": "1784798970179642",
|
||||||
|
|||||||
@@ -113,4 +113,6 @@ Geometria board: width/thickness, via, pour courtyard, placement mm, return/stit
|
|||||||
|
|
||||||
**Fase B (questa slice):** gerarchia component→pin→net→block; derating PASS/MARGIN/RISK da Vop/Vrated; timing RC/strap solo con numeri; PI local vs bulk; ESD e return path come REVIEW.
|
**Fase B (questa slice):** gerarchia component→pin→net→block; derating PASS/MARGIN/RISK da Vop/Vrated; timing RC/strap solo con numeri; PI local vs bulk; ESD e return path come REVIEW.
|
||||||
|
|
||||||
**Fase B ancora aperta:** thermal FEM; SI oltre skew mm; BOM↔PCB↔datasheet; SPOF; EMI oltre ESD.
|
**Fase B close-out:** BOM↔PCB↔datasheet (package/pinout/V/T/I); SPOF REVIEW; EMI solo con FACT in `layout_rules`; Tj da θJA+rame+via quando tutti i parametri ci sono.
|
||||||
|
|
||||||
|
**Fase B leftovers:** thermal FEM / OpenEMS; ImpedenceFinder LICENSE UNKNOWN; auto-place pcbnew; PinScope identity.
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net
|
|||||||
| PE-ESD-001 / PE-RET-001 | REVIEW unless mandatory FACT |
|
| PE-ESD-001 / PE-RET-001 | REVIEW unless mandatory FACT |
|
||||||
| PE-VIA-001 | via count + I_load (INFO, no tabella inventata) |
|
| PE-VIA-001 | via count + I_load (INFO, no tabella inventata) |
|
||||||
| PE-THM-001 | P=I_load×drop, courtyard senza pour/via |
|
| PE-THM-001 | P=I_load×drop, courtyard senza pour/via |
|
||||||
|
| PE-THM-002 | Tj=Ta+P·θJA solo con P, θJA, area rame, conteggio via |
|
||||||
|
| PE-BOM-010…014 | package / pinout / V / T / I quando BOM, PCB e datasheet hanno i numeri |
|
||||||
|
| PE-SPOF-001 | un regolatore (o un XTAL condiviso) — REVIEW |
|
||||||
|
| PE-EMI-001 | filtro EMI solo se `layout_rules` cita choke/ferrite/shield |
|
||||||
| PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net |
|
| PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net |
|
||||||
| PE-STCH-001 | pour GND + segnale senza via GND nel bbox |
|
| PE-STCH-001 | pour GND + segnale senza via GND nel bbox |
|
||||||
| Creepage | **skip** senza numero datasheet/IEC |
|
| Creepage | **skip** senza numero datasheet/IEC |
|
||||||
@@ -97,18 +101,17 @@ AI PCB: skip IC senza pintable (`run schematic review first`).
|
|||||||
|
|
||||||
Emmaforo USB verdict: if library has e.g. 90 Ω ±10% (81–99), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min (~46 Ω) is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists and 2.5 > limit. Without library Z, **PE-SI-010** INFO (insufficient) — not a 90 Ω ERROR.
|
Emmaforo USB verdict: if library has e.g. 90 Ω ±10% (81–99), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min (~46 Ω) is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists and 2.5 > limit. Without library Z, **PE-SI-010** INFO (insufficient) — not a 90 Ω ERROR.
|
||||||
|
|
||||||
**B — still missing (close-out):**
|
**B — still missing (true leftovers):**
|
||||||
|
|
||||||
| Gap | Why it is not this slice |
|
| Gap | Why it stays out |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Thermal FEM / energy | PE-THM-001 is courtyard vias/pour vs P=I×drop, not a field solve |
|
| Thermal FEM / energy | PE-THM-002 is Tj=Ta+P·θJA with measured copper/vias; no spreading/FEM |
|
||||||
| SPOF | No single-point-of-failure / redundancy graph check |
|
| CPWG / OpenEMS | Field-solver export excluded from the ImpedenceFinder vendor snapshot |
|
||||||
| BOM ↔ PCB ↔ datasheet chain | Pad-net + MPN library; no full three-way BOM audit |
|
| ImpedenceFinder license | Upstream has **no LICENSE** (UNKNOWN). Not invented in-tree. See `vendor/impedancefinder/SOURCE.md` |
|
||||||
| ImpedenceFinder license | Vendored closed-form core (`vendor/impedancefinder`, SOURCE.md); **no LICENSE file in-tree** — confirm upstream license before commercial redistribution |
|
|
||||||
| CPWG / field solver | OpenEMS export explicitly excluded from the vendor snapshot |
|
|
||||||
| EMI beyond ESD REVIEW | PE-ESD-001 is REVIEW, not IEC 61000 |
|
|
||||||
| SI extraction coverage | New `layout_rules` kinds need a re-extract (`model_version` 1.11.0); old library JSON has no Z/skew until pintable is refreshed |
|
|
||||||
| Auto-place / pcbnew write-back | Fase C |
|
| Auto-place / pcbnew write-back | Fase C |
|
||||||
|
| PinScope identity / TOS | Other worker |
|
||||||
|
|
||||||
|
Shipped this close-out: BOM↔PCB↔datasheet (`PE-BOM-010`…`014`); SPOF REVIEW; EMI with datasheet FACT; gated θJA Tj; SI re-extract when library `layout_rules` lack SI kinds (`model_version` 1.12.0, `si_extract_needed.json`).
|
||||||
|
|
||||||
**C — fuori:** auto-place, write-back pcbnew, unire i job, sshd/keys, PinScope identity/fork.
|
**C — fuori:** auto-place, write-back pcbnew, unire i job, sshd/keys, PinScope identity/fork.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
What's new in Periscope.
|
||||||
|
|
||||||
|
## 2.35.0 — 2026-09-20 — BOM↔PCB↔datasheet, SPOF, EMI FACT, gated Tj
|
||||||
|
|
||||||
|
Remaining Fase B slices except auto-place and thermal FEM / OpenEMS.
|
||||||
|
|
||||||
|
- [New] `PE-BOM-010`…`014` package / pinout / voltage / temp / current when BOM, PCB, and datasheet all have numbers.
|
||||||
|
- [New] `PE-SPOF-001` single regulator or shared crystal — REVIEW, never ERROR.
|
||||||
|
- [New] `PE-EMI-001` only if `layout_rules` quotes EMI/common-mode/ferrite/shield (not IEC 61000).
|
||||||
|
- [New] `PE-THM-002` Tj = Ta + P·θJA when P, θJA, copper area, and via count exist; else INSUFFICIENT.
|
||||||
|
- [Changed] Pintable skill `1.12.0`: SI+EMI kinds; empty SI on older extracts triggers schematic re-extract (`si_extract_needed.json` on PCB). ImpedenceFinder license remains UNKNOWN (no upstream LICENSE).
|
||||||
|
|
||||||
## 2.34.0 — 2026-09-20 — ImpedenceFinder SI checks (not a dump table)
|
## 2.34.0 — 2026-09-20 — ImpedenceFinder SI checks (not a dump table)
|
||||||
|
|
||||||
USB/HDMI/PCIe/ETH/LVDS/DDR layout_rules (diff/SE Z, skew, max length, spacing, ref plane, vias, layer, return, series R) are compared to ImpedenceFinder + copper. One PASS/FAIL/MARGIN finding per requirement. I2C/GPIO/CC/REGN are not 50 Ω.
|
USB/HDMI/PCIe/ETH/LVDS/DDR layout_rules (diff/SE Z, skew, max length, spacing, ref plane, vias, layer, return, series R) are compared to ImpedenceFinder + copper. One PASS/FAIL/MARGIN finding per requirement. I2C/GPIO/CC/REGN are not 50 Ω.
|
||||||
@@ -10,7 +20,7 @@ USB/HDMI/PCIe/ETH/LVDS/DDR layout_rules (diff/SE Z, skew, max length, spacing, r
|
|||||||
- [Changed] `PE-SI-001` only on impedance-controlled buses matching the rule `net_class`.
|
- [Changed] `PE-SI-001` only on impedance-controlled buses matching the rule `net_class`.
|
||||||
- [Changed] extract-pintable `layout_rules` kinds for SI; `default_model_version` 1.11.0.
|
- [Changed] extract-pintable `layout_rules` kinds for SI; `default_model_version` 1.11.0.
|
||||||
|
|
||||||
|
## 2.33.2 — 2026-09-20 — PCB complete UI, not a raw SSE error
|
||||||
|
|
||||||
A finished PCB exam no longer shows the raw watcher string `pcb_status=complete (terminal)`. The SSE hatch emits `pcb_complete`; the PCB page then opens `/report?domain=layout` (expand-on-click tree).
|
A finished PCB exam no longer shows the raw watcher string `pcb_status=complete (terminal)`. The SSE hatch emits `pcb_complete`; the PCB page then opens `/report?domain=layout` (expand-on-click tree).
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export function isLayoutFinding(f: {
|
|||||||
f.source === "timing_check" ||
|
f.source === "timing_check" ||
|
||||||
f.source === "pi_check" ||
|
f.source === "pi_check" ||
|
||||||
f.source === "esd_return_check" ||
|
f.source === "esd_return_check" ||
|
||||||
|
f.source === "bom_pcb_check" ||
|
||||||
|
f.source === "spof_check" ||
|
||||||
|
f.source === "emi_check" ||
|
||||||
rid.startsWith("PE-PLC") ||
|
rid.startsWith("PE-PLC") ||
|
||||||
rid.startsWith("PE-LAY") ||
|
rid.startsWith("PE-LAY") ||
|
||||||
rid.startsWith("PE-SI") ||
|
rid.startsWith("PE-SI") ||
|
||||||
@@ -28,7 +31,10 @@ export function isLayoutFinding(f: {
|
|||||||
rid.startsWith("PE-TIM") ||
|
rid.startsWith("PE-TIM") ||
|
||||||
rid.startsWith("PE-PI") ||
|
rid.startsWith("PE-PI") ||
|
||||||
rid.startsWith("PE-ESD") ||
|
rid.startsWith("PE-ESD") ||
|
||||||
rid.startsWith("PE-RET")
|
rid.startsWith("PE-RET") ||
|
||||||
|
rid.startsWith("PE-SPOF") ||
|
||||||
|
rid.startsWith("PE-EMI") ||
|
||||||
|
/^PE-BOM-01[0-4]$/.test(rid)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +50,9 @@ export function isPcbExamFinding(f: {
|
|||||||
f.source === "timing_check" ||
|
f.source === "timing_check" ||
|
||||||
f.source === "pi_check" ||
|
f.source === "pi_check" ||
|
||||||
f.source === "esd_return_check" ||
|
f.source === "esd_return_check" ||
|
||||||
|
f.source === "bom_pcb_check" ||
|
||||||
|
f.source === "spof_check" ||
|
||||||
|
f.source === "emi_check" ||
|
||||||
rid.startsWith("PE-PWR") ||
|
rid.startsWith("PE-PWR") ||
|
||||||
rid.startsWith("PE-THM") ||
|
rid.startsWith("PE-THM") ||
|
||||||
rid.startsWith("PE-VIA") ||
|
rid.startsWith("PE-VIA") ||
|
||||||
@@ -53,6 +62,9 @@ export function isPcbExamFinding(f: {
|
|||||||
rid.startsWith("PE-TIM") ||
|
rid.startsWith("PE-TIM") ||
|
||||||
rid.startsWith("PE-PI") ||
|
rid.startsWith("PE-PI") ||
|
||||||
rid.startsWith("PE-ESD") ||
|
rid.startsWith("PE-ESD") ||
|
||||||
rid.startsWith("PE-RET")
|
rid.startsWith("PE-RET") ||
|
||||||
|
rid.startsWith("PE-SPOF") ||
|
||||||
|
rid.startsWith("PE-EMI") ||
|
||||||
|
/^PE-BOM-01[0-4]$/.test(rid)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ You **must** look for layout guidance. Emit `layout_rules` as a list. Use `[]` o
|
|||||||
| `layer` | Required copper layer / topology |
|
| `layer` | Required copper layer / topology |
|
||||||
| `series_resistor` | Series R on the HS net (`value_ohms`) |
|
| `series_resistor` | Series R on the HS net (`value_ohms`) |
|
||||||
| `return_path` | GND return via next to the pair |
|
| `return_path` | GND return via next to the pair |
|
||||||
|
| `emi` / `common_mode` / `shield` | Common-mode choke, ferrite bead, shield, or EMI filter **quoted from this datasheet** (no IEC 61000 invention) |
|
||||||
|
|
||||||
Do **not** emit impedance/50 Ω rules for I2C, GPIO, EN, analog REGN, or USB CC. Do **not** invent USB 90 Ω unless **this** datasheet states a number.
|
Do **not** emit impedance/50 Ω rules for I2C, GPIO, EN, analog REGN, or USB CC. Do **not** invent USB 90 Ω unless **this** datasheet states a number.
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,10 @@
|
|||||||
"layer",
|
"layer",
|
||||||
"series_resistor",
|
"series_resistor",
|
||||||
"return_path",
|
"return_path",
|
||||||
"si"
|
"si",
|
||||||
|
"emi",
|
||||||
|
"common_mode",
|
||||||
|
"shield"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"pin": {"type": ["string", "null"]},
|
"pin": {"type": ["string", "null"]},
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ def validate(data: dict) -> list[str]:
|
|||||||
"decoupling_proximity", "thermal_via", "keepout", "length_match",
|
"decoupling_proximity", "thermal_via", "keepout", "length_match",
|
||||||
"impedance", "max_length", "spacing", "ref_plane", "si_via",
|
"impedance", "max_length", "spacing", "ref_plane", "si_via",
|
||||||
"layer", "series_resistor", "return_path", "si",
|
"layer", "series_resistor", "return_path", "si",
|
||||||
|
"emi", "common_mode", "shield",
|
||||||
}
|
}
|
||||||
for i, row in enumerate(data["layout_rules"]):
|
for i, row in enumerate(data["layout_rules"]):
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Fase B close-out: BOM↔PCB↔datasheet, SPOF, EMI FACT, gated Tj, SI refresh."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.periscopex.layout_rules import needs_layout_rules_refresh
|
||||||
|
from backend.periscopex.models import (
|
||||||
|
AbsMaxRating,
|
||||||
|
Component,
|
||||||
|
ComponentConstraints,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
InductorSpecs,
|
||||||
|
LayoutFootprint,
|
||||||
|
LayoutGraph,
|
||||||
|
LayoutPad,
|
||||||
|
LayoutVia,
|
||||||
|
LayoutZone,
|
||||||
|
Net,
|
||||||
|
NetType,
|
||||||
|
PackageInfo,
|
||||||
|
Pin,
|
||||||
|
PinConnection,
|
||||||
|
SimpleComponentSpecs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_si_refresh_when_old_extract_has_only_decoupling():
|
||||||
|
assert needs_layout_rules_refresh(
|
||||||
|
{
|
||||||
|
"model_version": "1.10.0",
|
||||||
|
"layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": 2}],
|
||||||
|
},
|
||||||
|
min_scan_version="1.12.0",
|
||||||
|
)
|
||||||
|
assert not needs_layout_rules_refresh(
|
||||||
|
{
|
||||||
|
"model_version": "1.12.0",
|
||||||
|
"layout_rules": [],
|
||||||
|
},
|
||||||
|
min_scan_version="1.12.0",
|
||||||
|
)
|
||||||
|
assert not needs_layout_rules_refresh(
|
||||||
|
{
|
||||||
|
"model_version": "1.10.0",
|
||||||
|
"layout_rules": [{"kind": "impedance", "zdiff_ohm": 90}],
|
||||||
|
},
|
||||||
|
min_scan_version="1.12.0",
|
||||||
|
)
|
||||||
|
assert not needs_layout_rules_refresh(
|
||||||
|
{
|
||||||
|
"model_version": "1.9.0",
|
||||||
|
"layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": None}],
|
||||||
|
},
|
||||||
|
min_scan_version="1.10.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_family_mismatch_is_pe_bom_010():
|
||||||
|
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||||
|
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="IC1",
|
||||||
|
package_info=PackageInfo(
|
||||||
|
base_family="MSP", package="LQFP-48", pin_count=48,
|
||||||
|
),
|
||||||
|
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 49)],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
)
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="IC", footprint="Package_DFN_QFN:QFN-16-1EP",
|
||||||
|
component_type=ComponentType.IC, mpn="IC1",
|
||||||
|
pins={str(i): "GND" for i in range(1, 17)},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={"GND": Net(name="GND", net_type=NetType.GROUND, pins=[])},
|
||||||
|
schematic_fields={"U1": {"footprint": "Package_DFN_QFN:QFN-16-1EP"}},
|
||||||
|
bom_fields={"U1": {"footprint": "QFN-16"}},
|
||||||
|
)
|
||||||
|
layout = LayoutGraph(
|
||||||
|
footprints={
|
||||||
|
"U1": LayoutFootprint(
|
||||||
|
reference="U1", footprint="Package_DFN_QFN:QFN-16-1EP_3x3mm",
|
||||||
|
x=0, y=0, layer="F.Cu",
|
||||||
|
pads=[LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 17)],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
findings = check_bom_pcb_datasheet(graph, {"IC1": cons}, layout)
|
||||||
|
ids = {f.rule_id for f in findings}
|
||||||
|
assert "PE-BOM-010" in ids
|
||||||
|
assert "PE-BOM-011" in ids
|
||||||
|
assert all(f.status == "ERROR" for f in findings if f.rule_id in {"PE-BOM-010", "PE-BOM-011"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_abs_max_voltage_and_temp_and_current():
|
||||||
|
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||||
|
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="LDO1",
|
||||||
|
package_info=PackageInfo(base_family="SPX", package="SOT-23-5", pin_count=5),
|
||||||
|
pintable=[
|
||||||
|
Pin(number="1", name="VIN"),
|
||||||
|
Pin(number="2", name="GND"),
|
||||||
|
Pin(number="5", name="VOUT"),
|
||||||
|
],
|
||||||
|
absolute_maximum_ratings=[
|
||||||
|
AbsMaxRating(parameter="VIN", min=None, max=4.0, unit="V", source_page=3),
|
||||||
|
AbsMaxRating(parameter="TJ", min=None, max=125, unit="C", source_page=3),
|
||||||
|
AbsMaxRating(parameter="IOUT", min=None, max=0.1, unit="A", source_page=4),
|
||||||
|
],
|
||||||
|
rules=[],
|
||||||
|
)
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="LDO", footprint="Package_TO_SOT_SMD:SOT-23-5",
|
||||||
|
component_type=ComponentType.IC, mpn="LDO1",
|
||||||
|
pins={"1": "VIN", "2": "GND", "5": "VOUT"},
|
||||||
|
specs=SimpleComponentSpecs(
|
||||||
|
specs_type="discrete",
|
||||||
|
values={"i_load_a": 0.5, "ta_max_c": 150},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"VIN": Net(
|
||||||
|
name="VIN", net_type=NetType.POWER, voltage=5.0,
|
||||||
|
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||||
|
),
|
||||||
|
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
|
||||||
|
"VOUT": Net(name="VOUT", net_type=NetType.POWER, voltage=3.3, pins=[]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
findings = check_bom_pcb_datasheet(graph, {"LDO1": cons}, None)
|
||||||
|
ids = {f.rule_id for f in findings}
|
||||||
|
assert "PE-BOM-012" in ids
|
||||||
|
assert "PE-BOM-013" in ids
|
||||||
|
assert "PE-BOM-014" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_inductor_current_rating():
|
||||||
|
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||||
|
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"L1": Component(
|
||||||
|
reference="L1", value="2.2uH", footprint="",
|
||||||
|
component_type=ComponentType.INDUCTOR, mpn="IND1",
|
||||||
|
pins={"1": "SW", "2": "VOUT"},
|
||||||
|
specs=InductorSpecs(
|
||||||
|
value_henries=2.2e-6,
|
||||||
|
value_formatted="2.2uH",
|
||||||
|
current_rating_a="0.2",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={},
|
||||||
|
)
|
||||||
|
graph.components["L1"].specs # type: ignore
|
||||||
|
# I_load on the inductor itself
|
||||||
|
graph.components["L1"] = graph.components["L1"].model_copy(
|
||||||
|
update={"specs": InductorSpecs(
|
||||||
|
value_henries=2.2e-6, value_formatted="2.2uH", current_rating_a="0.2",
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
# specs_values reads SimpleComponentSpecs.values — inductor uses current_rating_a
|
||||||
|
findings = check_bom_pcb_datasheet(graph, {}, None)
|
||||||
|
# Without i_load on inductor specs, skip.
|
||||||
|
assert all(f.rule_id != "PE-BOM-014" for f in findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spof_review_single_ldo():
|
||||||
|
from backend.periscopex.finding_engine import complete_finding
|
||||||
|
from backend.periscopex.spof_check import check_spof
|
||||||
|
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="LDO1",
|
||||||
|
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
)
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="LDO", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="LDO1",
|
||||||
|
component_subtype="ic.power.ldo",
|
||||||
|
pins={"1": "VIN", "2": "+3V3"},
|
||||||
|
),
|
||||||
|
"U2": Component(
|
||||||
|
reference="U2", value="MCU", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="MCU",
|
||||||
|
pins={"1": "+3V3"},
|
||||||
|
),
|
||||||
|
"U3": Component(
|
||||||
|
reference="U3", value="PHY", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="PHY",
|
||||||
|
pins={"1": "+3V3"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"VIN": Net(name="VIN", net_type=NetType.POWER, pins=[]),
|
||||||
|
"+3V3": Net(
|
||||||
|
name="+3V3", net_type=NetType.POWER,
|
||||||
|
pins=[
|
||||||
|
PinConnection(component_ref="U1", pin_number="2"),
|
||||||
|
PinConnection(component_ref="U2", pin_number="1"),
|
||||||
|
PinConnection(component_ref="U3", pin_number="1"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
findings = check_spof(graph, {"LDO1": cons})
|
||||||
|
assert findings
|
||||||
|
assert findings[0].rule_id == "PE-SPOF-001"
|
||||||
|
complete_finding(findings[0])
|
||||||
|
assert findings[0].finding_class == "REVIEW"
|
||||||
|
assert findings[0].status != "ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_emi_only_with_datasheet_fact():
|
||||||
|
from backend.periscopex.emi_check import check_emi
|
||||||
|
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="PHY",
|
||||||
|
pintable=[Pin(number="1", name="USB_DP")],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
layout_rules=[{
|
||||||
|
"kind": "keepout",
|
||||||
|
"note": "Place a common-mode choke on the USB pair",
|
||||||
|
"source_page": 22,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U2": Component(
|
||||||
|
reference="U2", value="PHY", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="PHY",
|
||||||
|
pins={"1": "USB_DP"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"USB_DP": Net(
|
||||||
|
name="USB_DP", net_type=NetType.SIGNAL,
|
||||||
|
pins=[PinConnection(component_ref="U2", pin_number="1")],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
findings = check_emi(graph, {"PHY": cons}, None)
|
||||||
|
assert findings and findings[0].rule_id == "PE-EMI-001"
|
||||||
|
cons2 = cons.model_copy(update={"layout_rules": []})
|
||||||
|
assert check_emi(graph, {"PHY": cons2}, None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tj_when_theta_copper_vias_exist():
|
||||||
|
from backend.periscopex.pcb_power_thermal import check_pcb_junction_temp
|
||||||
|
from backend.periscopex.models import LayoutSegment, LayoutStackup
|
||||||
|
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="LDO1",
|
||||||
|
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
)
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="LDO", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="LDO1",
|
||||||
|
component_subtype="ic.power.ldo",
|
||||||
|
pins={"1": "VIN", "2": "VOUT"},
|
||||||
|
specs=SimpleComponentSpecs(
|
||||||
|
specs_type="discrete",
|
||||||
|
values={"i_load_a": 0.5, "theta_ja": 50, "tj_max": 150},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"VIN": Net(
|
||||||
|
name="VIN", net_type=NetType.POWER, voltage=5.0,
|
||||||
|
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||||
|
),
|
||||||
|
"VOUT": Net(
|
||||||
|
name="VOUT", net_type=NetType.POWER, voltage=3.3,
|
||||||
|
pins=[PinConnection(component_ref="U1", pin_number="2")],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
layout = LayoutGraph(
|
||||||
|
stackup=LayoutStackup(copper_layers=["F.Cu"], dielectrics=[], copper_thickness_mm=0.035),
|
||||||
|
footprints={
|
||||||
|
"U1": LayoutFootprint(
|
||||||
|
reference="U1", x=0, y=0, layer="F.Cu",
|
||||||
|
courtyard=[(-2, -2), (2, -2), (2, 2), (-2, 2)],
|
||||||
|
pads=[LayoutPad(number="2", x=0, y=0, net="VOUT")],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
segments=[LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT")],
|
||||||
|
vias=[LayoutVia(x=0.1, y=0.1, net="GND", drill=0.3)],
|
||||||
|
zones=[LayoutZone(
|
||||||
|
net="GND", layer="F.Cu",
|
||||||
|
outlines=[[(-3, -3), (3, -3), (3, 3), (-3, 3)]],
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
findings = check_pcb_junction_temp(graph, {"LDO1": cons}, layout)
|
||||||
|
assert findings and findings[0].rule_id == "PE-THM-002"
|
||||||
|
assert findings[0].evidence_status == "SUFFICIENT"
|
||||||
|
assert "Tj" in findings[0].finding
|
||||||
|
assert findings[0].status == "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tj_insufficient_without_theta():
|
||||||
|
from backend.periscopex.pcb_power_thermal import check_pcb_junction_temp
|
||||||
|
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="LDO1",
|
||||||
|
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
)
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="LDO", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="LDO1",
|
||||||
|
pins={"1": "VIN", "2": "VOUT"},
|
||||||
|
specs=SimpleComponentSpecs(
|
||||||
|
specs_type="discrete",
|
||||||
|
values={"i_load_a": 0.5},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"VIN": Net(
|
||||||
|
name="VIN", net_type=NetType.POWER, voltage=5.0,
|
||||||
|
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||||
|
),
|
||||||
|
"VOUT": Net(
|
||||||
|
name="VOUT", net_type=NetType.POWER, voltage=3.3,
|
||||||
|
pins=[PinConnection(component_ref="U1", pin_number="2")],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
layout = LayoutGraph(footprints={})
|
||||||
|
findings = check_pcb_junction_temp(graph, {"LDO1": cons}, layout)
|
||||||
|
assert findings and findings[0].rule_id == "PE-THM-002"
|
||||||
|
assert findings[0].evidence_status == "INSUFFICIENT"
|
||||||
|
assert findings[0].status == "INFO"
|
||||||
Vendored
+7
@@ -5,6 +5,13 @@ Commit: a0c8d0ec37c9a1b099082926e50a245778ec8d6e
|
|||||||
Included: closed-form core (`zsolver`, `geometry`, `planes`, `model`,
|
Included: closed-form core (`zsolver`, `geometry`, `planes`, `model`,
|
||||||
`net_walk`, `net_analysis`, `report`).
|
`net_walk`, `net_analysis`, `report`).
|
||||||
|
|
||||||
|
**License: UNKNOWN.** Upstream
|
||||||
|
https://github.com/manvalan/ImpedenceFinder at commit
|
||||||
|
`a0c8d0ec37c9a1b099082926e50a245778ec8d6e` has **no LICENSE file**
|
||||||
|
(HTTP 404 on `/LICENSE`). Periscope does **not** invent a license text
|
||||||
|
in-tree. Confirm with the copyright holder before commercial
|
||||||
|
redistribution of `vendor/impedancefinder`.
|
||||||
|
|
||||||
Excluded on purpose:
|
Excluded on purpose:
|
||||||
- `gerber2ems_export.py`, `prepare_simulation.py`, `crop_board.py`,
|
- `gerber2ems_export.py`, `prepare_simulation.py`, `crop_board.py`,
|
||||||
`simulate_net.sh` (OpenEMS / field-solver export)
|
`simulate_net.sh` (OpenEMS / field-solver export)
|
||||||
|
|||||||
Reference in New Issue
Block a user