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:
2026-09-20 12:29:27 +02:00
parent 65a91419a2
commit 1b3529501f
20 changed files with 1337 additions and 30 deletions
+417
View File
@@ -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
+133
View File
@@ -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
+16
View File
@@ -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()
+33 -10
View File
@@ -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]]:
+8
View File
@@ -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())
+155
View File
@@ -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")
+3 -2
View File
@@ -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",
+134
View File
@@ -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
+4 -1
View File
@@ -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"]},
+28 -2
View File
@@ -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 -1
View File
@@ -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",
+3 -1
View File
@@ -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.
+12 -9
View File
@@ -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% (8199), 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% (8199), 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.
+11 -1
View File
@@ -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).
+14 -2
View File
@@ -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)
); );
} }
+1
View File
@@ -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.
+4 -1
View File
@@ -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"]},
+1
View File
@@ -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):
+352
View File
@@ -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"
+7
View File
@@ -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)