Check ImpedenceFinder against datasheet SI rules, not a dump table.

Each layout_rules SI requirement (diff/SE Z, skew, max length, spacing,
ref plane, vias, layer, return, series R) is one PASS/FAIL/MARGIN finding
with FACT and REQUIREMENT. USB/HDMI/PCIe/ETH/LVDS/DDR only; I2C GPIO CC
and analog REGN are skipped. No invented 90 Ω.
This commit is contained in:
2026-09-20 10:45:27 +02:00
parent 187605763d
commit 65a91419a2
14 changed files with 956 additions and 67 deletions
+16
View File
@@ -133,6 +133,22 @@ def _seed() -> None:
("PE-SI-001", "Differential skew vs length_match mm."), ("PE-SI-001", "Differential skew vs length_match mm."),
): ):
_add(rid, "MANDATORY", "RULE", domain="pcb", requirement=req) _add(rid, "MANDATORY", "RULE", domain="pcb", requirement=req)
_add("PE-SI-002", "RECOMMENDED", "RISK", domain="pcb",
requirement="Diff/SE Z0 vs datasheet window (ImpedenceFinder avg/min/max).")
_add("PE-SI-003", "RECOMMENDED", "RISK", domain="pcb",
requirement="Max routed length vs datasheet millimetres.")
_add("PE-SI-004", "RECOMMENDED", "RISK", domain="pcb",
requirement="Intra-pair spacing vs datasheet millimetres.")
_add("PE-SI-005", "RECOMMENDED", "RISK", domain="pcb",
requirement="Reference plane / topology vs datasheet.")
_add("PE-SI-006", "RECOMMENDED", "RISK", domain="pcb",
requirement="HS via count vs datasheet min/max.")
_add("PE-SI-008", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Return via next to an impedance-controlled pair.")
_add("PE-SI-009", "RECOMMENDED", "RISK", domain="pcb",
requirement="Series resistor on the HS net vs datasheet ohms.")
_add("PE-SI-010", "TYPICAL", "INFO", domain="pcb",
requirement="HS bus measured; library has no SI FACT — not 90 Ω folklore.")
_add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb", _add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Keepout from layout_rules — not a DRC for pad copper.") requirement="Keepout from layout_rules — not a DRC for pad copper.")
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb", _add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
+18 -1
View File
@@ -6,7 +6,11 @@ from typing import Any
from packaging.version import Version from packaging.version import Version
KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout", "length_match"}) KNOWN_KINDS = frozenset({
"decoupling_proximity", "thermal_via", "keepout", "length_match",
"impedance", "max_length", "spacing", "ref_plane", "si_via",
"layer", "series_resistor", "return_path", "si",
})
def _num(v: Any) -> float | None: def _num(v: Any) -> float | None:
@@ -79,6 +83,8 @@ def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
via_i = int(n) if n is not None else None via_i = int(n) if n is not None else None
page = row.get("source_page") page = row.get("source_page")
page_i = int(page) if isinstance(page, int) else None page_i = int(page) if isinstance(page, int) else None
mx_via = row.get("max_via_count")
mx_via_i = int(mx_via) if isinstance(mx_via, int) and not isinstance(mx_via, bool) else None
ok.append({ ok.append({
"kind": kind, "kind": kind,
"pin": row.get("pin"), "pin": row.get("pin"),
@@ -86,8 +92,19 @@ def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
"max_distance_mm": dist, "max_distance_mm": dist,
"same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None, "same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None,
"min_via_count": via_i, "min_via_count": via_i,
"max_via_count": mx_via_i,
"net_class": row.get("net_class"), "net_class": row.get("net_class"),
"note": row.get("note"), "note": row.get("note"),
"source_page": page_i, "source_page": page_i,
"z0_ohm": _num(row.get("z0_ohm")),
"zdiff_ohm": _num(row.get("zdiff_ohm")),
"tolerance_pct": _num(row.get("tolerance_pct")),
"z_min_ohm": _num(row.get("z_min_ohm")),
"z_max_ohm": _num(row.get("z_max_ohm")),
"topology": row.get("topology") if isinstance(row.get("topology"), str) else None,
"min_spacing_mm": _num(row.get("min_spacing_mm")),
"value_ohms": _num(row.get("value_ohms")),
"ref_plane": row.get("ref_plane") if isinstance(row.get("ref_plane"), str) else None,
"parameter": row.get("parameter") if isinstance(row.get("parameter"), str) else None,
}) })
return ok, errors return ok, errors
+2 -1
View File
@@ -174,13 +174,14 @@ def run_pcb_checks(
constraints_map: dict, constraints_map: dict,
layout: LayoutGraph | None, layout: LayoutGraph | None,
plan: FunctionalGroupsReport | None = None, plan: FunctionalGroupsReport | None = None,
impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]: ) -> list[Finding]:
"""Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD.""" """Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD."""
out: list[Finding] = [] out: list[Finding] = []
for name, fn in ( for name, fn in (
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)), ("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
("placement_check", lambda: check_placement(graph, constraints_map, layout)), ("placement_check", lambda: check_placement(graph, constraints_map, layout)),
("si_check", lambda: check_si(graph, constraints_map, layout)), ("si_check", lambda: check_si(graph, constraints_map, layout, impedance_nets)),
("pcb_derating", lambda: check_pcb_derating(graph)), ("pcb_derating", lambda: check_pcb_derating(graph)),
("pcb_power_traces", lambda: check_pcb_power_traces(graph, constraints_map, layout)), ("pcb_power_traces", lambda: check_pcb_power_traces(graph, constraints_map, layout)),
("pcb_via_current", lambda: check_pcb_via_current(graph, constraints_map, layout)), ("pcb_via_current", lambda: check_pcb_via_current(graph, constraints_map, layout)),
+636 -51
View File
@@ -1,17 +1,47 @@
"""G1 SI: intra-pair skew only when the datasheet gives millimetres. """SI checks: datasheet layout_rules vs board + ImpedenceFinder.
Pair names (_DP/_DM, _P/_N) only identify which nets to compare. The USB / HDMI / PCIe / Ethernet / LVDS / DDR (and any net_class on an
limit is never 3W, USB spec folklore, or a default millimetre. ``impedance`` / ``length_match`` / … rule) are checked. I2C, GPIO, EN,
analog REGN, USB CC are not 50 Ω pairs. No invented USB/IEC Z0.
""" """
from __future__ import annotations from __future__ import annotations
import math import math
import re
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutSegment from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph, LayoutSegment
from backend.periscopex.pcb_net_match import kicad_nets_match, normalize_kicad_hierarchy_net
from backend.periscopex.pcb_power_thermal import _is_gnd_name
from backend.periscopex.validate import _match_constraints from backend.periscopex.validate import _match_constraints
_PAIR_SUFFIXES = (("_DP", "_DM"), ("_P", "_N"), ("+", "-")) _PAIR_SUFFIXES = (
("_DP", "_DM"),
("_P", "_N"),
("+", "-"),
("_D+", "_D-"),
(".D+", ".D-"),
)
_SKIP_RE = re.compile(
r"(?:^|[_/.\-])(SDA|SCL|I2C|GPIO\d*|GP\d+|EN|ENABLE|NRST|RST|REGN|"
r"CC[12]|SBU|ID|VBUS|VBAT|LED|ADC|NTC|STRAP)(?:$|[_/.\-]|\d)",
re.I,
)
_HS_CLASS_RE = (
("usb", re.compile(r"USB", re.I)),
("hdmi", re.compile(r"HDMI", re.I)),
("pcie", re.compile(r"PCIE|PEX_", re.I)),
("ethernet", re.compile(r"(?:^|[_/])(ETH|MDI|TRD[0-3])", re.I)),
("lvds", re.compile(r"LVDS", re.I)),
("ddr", re.compile(r"DDR|DQS|(?:^|[_/])DQ\d+", re.I)),
)
_SI_KINDS = frozenset({
"impedance", "length_match", "max_length", "spacing",
"ref_plane", "si_via", "layer", "series_resistor", "return_path", "si",
})
def _seg_len(seg: LayoutSegment) -> float: def _seg_len(seg: LayoutSegment) -> float:
@@ -19,73 +49,628 @@ def _seg_len(seg: LayoutSegment) -> float:
def net_length_mm(layout: LayoutGraph, net: str) -> float: def net_length_mm(layout: LayoutGraph, net: str) -> float:
return sum(_seg_len(s) for s in layout.segments if s.net == net) return sum(
_seg_len(s) for s in layout.segments
if s.net and kicad_nets_match(s.net, net)
)
def partner_net(name: str) -> str | None: def partner_net(name: str) -> str | None:
n = name or ""
for a, b in _PAIR_SUFFIXES: for a, b in _PAIR_SUFFIXES:
if name.endswith(a): if n.endswith(a):
return name[: -len(a)] + b return n[: -len(a)] + b
if name.endswith(b): if n.endswith(b):
return name[: -len(b)] + a return n[: -len(b)] + a
return None return None
def _length_match_limit_mm(constraints_map: dict, graph: DesignGraph) -> tuple[float, int | None] | None: def _leaf(net: str) -> str:
for comp in graph.components.values(): n = normalize_kicad_hierarchy_net(net)
cons = _match_constraints(comp.mpn, constraints_map) return n.split("/")[-1] if n else ""
def skip_si_net(net: str) -> bool:
"""I2C / GPIO / EN / analog / USB-CC — not impedance-controlled pairs."""
leaf = _leaf(net)
if not leaf:
return True
if re.search(r"CC[12]", leaf, re.I):
return True
return bool(_SKIP_RE.search(leaf))
def bus_class(net: str) -> str | None:
if skip_si_net(net):
return None
leaf = _leaf(net)
if "USB" in leaf.upper() and re.search(r"(D\+|D-|DP|DM)", leaf, re.I):
return "usb"
for cls, cre in _HS_CLASS_RE:
if cls == "usb":
continue
if cre.search(leaf):
return cls
return None
def _is_hs_net(net: str) -> bool:
return bus_class(net) is not None
def _z_row(rows: list[dict] | None, net: str) -> dict | None:
for row in rows or []:
if not isinstance(row, dict) or row.get("error"):
continue
name = str(row.get("net_name") or row.get("name") or "")
if name and kicad_nets_match(name, net):
return row
return None
def _z_field(row: dict | None, *keys: str) -> float | None:
if not row:
return None
for k in keys:
v = row.get(k)
if isinstance(v, (int, float)):
return float(v)
return None
def _num(v) -> float | None:
if v is None or isinstance(v, bool):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _z_window(rule: dict) -> tuple[float, float] | None:
lo = _num(rule.get("z_min_ohm"))
hi = _num(rule.get("z_max_ohm"))
if lo is not None and hi is not None and hi >= lo:
return (lo, hi)
nom = _num(rule.get("zdiff_ohm")) or _num(rule.get("z0_ohm"))
tol = _num(rule.get("tolerance_pct"))
if nom is not None and tol is not None and tol > 0:
return (nom * (1 - tol / 100.0), nom * (1 + tol / 100.0))
return None
def _quote(rule: dict) -> str:
note = (rule.get("note") or "").strip()
page = rule.get("source_page")
bits = [note] if note else []
if page is not None:
bits.append(f"p.{page}")
return "; ".join(bits) or "layout_rules"
def _collect_rules(graph: DesignGraph, constraints_map: dict) -> list[tuple[str, dict]]:
out: list[tuple[str, dict]] = []
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)
if not cons: if not cons:
continue continue
ic_nets = {n for n in (comp.pins.values()) if n}
for rule in cons.layout_rules or []: for rule in cons.layout_rules or []:
if rule.get("kind") != "length_match": kind = str(rule.get("kind") or "")
if kind not in _SI_KINDS:
continue continue
mm = rule.get("max_distance_mm") out.append((ref, {**rule, "_ic": ref, "_ic_nets": ic_nets, "_mpn": comp.mpn or ""}))
if mm is None: return out
def _rule_nets(layout: LayoutGraph, rule: dict, graph: DesignGraph) -> list[str]:
names = {s.net for s in layout.segments if s.net}
nc = str(rule.get("net_class") or "").strip().lower()
pin = str(rule.get("pin") or "").strip()
ic = rule.get("_ic")
ic_nets: set[str] = rule.get("_ic_nets") or set()
picked: list[str] = []
for net in sorted(names):
if skip_si_net(net):
continue
if pin and ic:
sch = graph.pin_net(ic, pin) if hasattr(graph, "pin_net") else None
if sch and not kicad_nets_match(sch, net):
if pin.upper() not in _leaf(net).upper():
continue
if nc:
bc = bus_class(net) or ""
if nc not in bc and nc not in _leaf(net).lower():
continue continue
return float(mm), rule.get("source_page") else:
on_ic = net in ic_nets or any(kicad_nets_match(net, n) for n in ic_nets)
if not on_ic or not _is_hs_net(net):
continue
picked.append(net)
return picked
def _pair_key(a: str, b: str) -> tuple[str, str]:
return tuple(sorted((a, b))) # type: ignore[return-value]
def _partner_on_board(layout: LayoutGraph, net: str, zrow: dict | None) -> str | None:
names = {s.net for s in layout.segments if s.net}
if zrow:
p = zrow.get("partner_net_name")
if isinstance(p, str) and p:
for n in names:
if kicad_nets_match(p, n) and not skip_si_net(n):
return n
p = partner_net(net)
if p:
for n in names:
if kicad_nets_match(p, n) and not skip_si_net(n):
return n
return None return None
def _min_pair_spacing_mm(layout: LayoutGraph, a: str, b: str) -> float | None:
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a)]
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b)]
best = None
for x in sa:
wx = x.width / 2.0
for y in sb:
wy = y.width / 2.0
for px, py in (
(x.start, y.start), (x.start, y.end),
(x.end, y.start), (x.end, y.end),
):
d = math.hypot(px[0] - py[0], px[1] - py[1]) - wx - wy
if best is None or d < best:
best = d
return best
def _via_count(layout: LayoutGraph, net: str) -> int:
return sum(1 for v in layout.vias if v.net and kicad_nets_match(v.net, net))
def _layers(layout: LayoutGraph, net: str) -> list[str]:
return sorted({
s.layer for s in layout.segments
if s.net and kicad_nets_match(s.net, net) and s.layer
})
def _series_resistors(graph: DesignGraph, net: str) -> list[tuple[str, float | None]]:
from backend.periscopex.models import ResistorSpecs
from backend.periscopex.pcb_net_match import refs_on_matched_net
out: list[tuple[str, float | None]] = []
for ref in refs_on_matched_net(graph, net):
c = graph.components.get(ref)
if not c or c.component_type != ComponentType.RESISTOR:
continue
ohms = None
if isinstance(c.specs, ResistorSpecs) and c.specs.value_ohms:
ohms = float(c.specs.value_ohms)
out.append((ref, ohms))
return out
def _gnd_via_in_net_bbox(layout: LayoutGraph, net: str) -> bool:
segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)]
if not segs:
return False
xs = [c for s in segs for c in (s.start[0], s.end[0])]
ys = [c for s in segs for c in (s.start[1], s.end[1])]
xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
pad = 2.0
return any(
v.net and _is_gnd_name(v.net)
and xmin - pad <= v.x <= xmax + pad and ymin - pad <= v.y <= ymax + pad
for v in layout.vias
)
def _si_finding(
*,
rule_id: str,
net: str,
mpn: str,
designator: str,
verdict: str,
finding: str,
facts: str,
requirement: str,
source_page: int | None,
quote: str,
rec: str,
provenance: str,
finding_class: str,
) -> Finding:
status = {"PASS": "INFO", "MARGIN": "WARNING", "FAIL": "WARNING"}[verdict]
if verdict == "FAIL" and provenance == "MANDATORY" and finding_class == "RULE":
status = "ERROR"
return Finding(
designator=designator or "layout",
mpn=mpn,
aspect="si",
finding=f"{verdict}: {finding}",
facts=facts,
requirement=requirement,
inference="" if verdict == "PASS" else f"{verdict} vs datasheet layout_rules.",
why=requirement,
status=status, # type: ignore[arg-type]
recommendation=rec,
action=rec,
source="si_check",
rule_id=rule_id,
finding_class=finding_class, # type: ignore[arg-type]
provenance=provenance, # type: ignore[arg-type]
evidence_status="SUFFICIENT",
net=net,
pins=[],
source_page=source_page,
source_quote=quote,
)
def _param(rule: dict) -> str:
if rule.get("kind") == "si":
return str(rule.get("parameter") or "").lower()
return str(rule.get("kind") or "")
def check_si( def check_si(
graph: DesignGraph, graph: DesignGraph,
constraints_map: dict, constraints_map: dict,
layout: LayoutGraph | None, layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]: ) -> list[Finding]:
if layout is None or not layout.segments: if layout is None or not layout.segments:
return [] return []
limit = _length_match_limit_mm(constraints_map, graph) raw = impedance_nets
if limit is None: if isinstance(impedance_nets, dict):
return [] raw = list(impedance_nets.get("nets") or [])
max_mm, page = limit rows: list[dict] = [r for r in (raw or []) if isinstance(r, dict)]
seen: set[tuple[str, str]] = set()
findings: list[Finding] = [] findings: list[Finding] = []
names = {s.net for s in layout.segments if s.net} rules = _collect_rules(graph, constraints_map)
for net in names: seen: set[tuple] = set()
partner = partner_net(net)
if not partner or partner not in names: for _ref, rule in rules:
continue kind = _param(rule)
key = tuple(sorted((net, partner))) nets = _rule_nets(layout, rule, graph)
if key in seen: quote = _quote(rule)
continue page = rule.get("source_page") if isinstance(rule.get("source_page"), int) else None
seen.add(key) mpn = str(rule.get("_mpn") or "")
skew = abs(net_length_mm(layout, net) - net_length_mm(layout, partner)) ic = str(rule.get("_ic") or "layout")
if skew <= max_mm:
continue if kind in ("impedance", "zdiff", "z0"):
findings.append(Finding( window = _z_window(rule)
designator="layout", nom = _num(rule.get("zdiff_ohm")) or _num(rule.get("z0_ohm"))
mpn="", want_diff = _num(rule.get("zdiff_ohm")) is not None or kind == "zdiff"
aspect="si", paired: set[tuple[str, str]] = set()
finding=( for net in nets:
f"Intra-pair skew {skew:.1f} mm on {key[0]}/{key[1]} " zrow = _z_row(rows, net)
f"(datasheet max {max_mm:g} mm)." partner = _partner_on_board(layout, net, zrow) if want_diff else None
), key_net = _pair_key(net, partner) if partner else (net, "")
why=f"length_match max_distance_mm={max_mm:g}.", if key_net in paired:
status="ERROR", continue
recommendation="Length-match the differential pair.", paired.add(key_net)
source="si_check", sig = ("z", key_net, id(rule))
rule_id="PE-SI-001", if sig in seen:
net=net, continue
pins=[], seen.add(sig)
source_page=page, avg = _z_field(zrow, "z0_avg_ohms", "z0_ohm", "mean_z0", "z0")
)) zmin = _z_field(zrow, "z0_min_ohms")
zmax = _z_field(zrow, "z0_max_ohms")
topo = ""
if zrow:
t = zrow.get("topologies")
if isinstance(t, (list, tuple)):
topo = ",".join(str(x) for x in t)
elif t:
topo = str(t)
pair_lbl = f"{net}/{partner}" if partner else net
facts = (
f"ImpedenceFinder {pair_lbl}: avg={avg} min={zmin} max={zmax} Ω; "
f"topology={topo or ''}"
)
req = quote
if window:
req = f"Z={'diff ' if want_diff else ''}{window[0]:g}{window[1]:g} Ω ({quote})"
elif nom is not None:
req = f"Z={'diff ' if want_diff else ''}{nom:g} Ω ({quote})"
rec = f"Adjust {net} geometry toward the datasheet Z, then re-run PCB review."
if window is None and nom is None:
continue
if avg is None:
findings.append(_si_finding(
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic,
verdict="FAIL",
finding=f"{net} has no ImpedenceFinder Z0 (need stackup + routed copper).",
facts=facts, requirement=req, source_page=page, quote=quote,
rec=rec, provenance="RECOMMENDED", finding_class="RISK",
))
continue
if window is None:
findings.append(_si_finding(
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic,
verdict="MARGIN",
finding=(
f"{pair_lbl} Zavg={avg:.1f} Ω vs datasheet {nom:g} Ω "
"(no tolerance in library)."
),
facts=facts, requirement=req, source_page=page, quote=quote,
rec=rec, provenance="RECOMMENDED", finding_class="REVIEW",
))
continue
lo, hi = window
avg_ok = lo <= avg <= hi
min_ok = zmin is None or lo <= zmin <= hi
max_ok = zmax is None or lo <= zmax <= hi
if avg_ok and min_ok and max_ok:
verdict, cls, prov = "PASS", "INFO", "TYPICAL"
elif avg_ok:
verdict, cls, prov = "MARGIN", "RISK", "RECOMMENDED"
else:
verdict, cls, prov = "FAIL", "RISK", "RECOMMENDED"
findings.append(_si_finding(
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=(
f"{pair_lbl} Zavg={avg:.1f} Ω "
f"(min {zmin if zmin is not None else ''}, "
f"max {zmax if zmax is not None else ''}) vs {lo:g}{hi:g} Ω."
),
facts=facts, requirement=req, source_page=page, quote=quote,
rec=rec if verdict != "PASS" else "No Z0 change required.",
provenance=prov, finding_class=cls,
))
elif kind in ("length_match", "skew"):
lim = _num(rule.get("max_distance_mm"))
if lim is None:
continue
done: set[tuple[str, str]] = set()
for net in nets:
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow)
if not partner:
continue
key = _pair_key(net, partner)
if key in done:
continue
done.add(key)
la = _z_field(zrow, "length_mm") or net_length_mm(layout, net)
zb = _z_row(rows, partner)
lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner)
skew = abs(la - lb)
verdict = "PASS" if skew <= lim else "FAIL"
rec = (
"Length-match the differential pair."
if verdict == "FAIL"
else "Intra-pair skew is within the datasheet millimetre."
)
findings.append(_si_finding(
rule_id="PE-SI-001", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{key[0]}/{key[1]} skew {skew:.2f} mm (max {lim:g} mm).",
facts=f"L({key[0]})={la:.2f} mm; L({key[1]})={lb:.2f} mm; |Δ|={skew:.2f} mm.",
requirement=f"length_match max_distance_mm={lim:g} ({quote})",
source_page=page, quote=quote, rec=rec,
provenance="MANDATORY" if verdict == "FAIL" else "TYPICAL",
finding_class="RULE" if verdict == "FAIL" else "INFO",
))
elif kind == "max_length":
lim = _num(rule.get("max_distance_mm"))
if lim is None:
continue
for net in nets:
zrow = _z_row(rows, net)
length = _z_field(zrow, "length_mm") or net_length_mm(layout, net)
verdict = "PASS" if length <= lim else "FAIL"
findings.append(_si_finding(
rule_id="PE-SI-003", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{net} length {length:.2f} mm (max {lim:g} mm).",
facts=f"length_mm={length:.2f} (ImpedenceFinder or copper sum).",
requirement=f"max_length {lim:g} mm ({quote})",
source_page=page, quote=quote,
rec="Shorten the net or document the exception." if verdict == "FAIL" else "Length is within the datasheet max.",
provenance="RECOMMENDED",
finding_class="RISK" if verdict == "FAIL" else "INFO",
))
elif kind == "spacing":
gap_min = _num(rule.get("min_spacing_mm")) or _num(rule.get("max_distance_mm"))
if gap_min is None:
continue
done_s: set[tuple[str, str]] = set()
for net in nets:
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow)
if not partner:
continue
key = _pair_key(net, partner)
if key in done_s:
continue
done_s.add(key)
gap = _min_pair_spacing_mm(layout, net, partner)
if gap is None:
continue
verdict = "PASS" if gap + 1e-9 >= gap_min else "FAIL"
findings.append(_si_finding(
rule_id="PE-SI-004", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{key[0]}/{key[1]} edge gap {gap:.3f} mm (min {gap_min:g} mm).",
facts=f"min edge-to-edge {gap:.3f} mm from copper segments.",
requirement=f"spacing min_spacing_mm={gap_min:g} ({quote})",
source_page=page, quote=quote,
rec="Increase intra-pair gap to the datasheet millimetre." if verdict == "FAIL" else "Pair spacing meets the datasheet.",
provenance="RECOMMENDED",
finding_class="RISK" if verdict == "FAIL" else "INFO",
))
elif kind in ("ref_plane", "layer"):
want_topo = str(rule.get("topology") or "").strip().upper()
want_plane = str(rule.get("ref_plane") or "").lower()
for net in nets:
zrow = _z_row(rows, net)
flags = zrow.get("flags") if zrow else ()
if isinstance(flags, str):
flags = (flags,)
flags_l = " ".join(str(f).lower() for f in (flags or ()))
topos = zrow.get("topologies") if zrow else ()
if isinstance(topos, str):
topos = (topos,)
layers = _layers(layout, net)
facts = f"topology={topos or ''}; layers={layers}; flags={flags_l or ''}"
ok = True
reasons: list[str] = []
if want_topo:
names = {str(t).upper() for t in (topos or ())}
if names and want_topo not in names and not any(want_topo in n for n in names):
ok = False
reasons.append(f"topology {topos}{want_topo}")
if kind == "ref_plane" or "gnd" in want_plane or "ground" in want_plane:
if "void" in flags_l or "no_reference" in flags_l:
ok = False
reasons.append("ImpedenceFinder flags missing/void reference plane")
verdict = "PASS" if ok else "FAIL"
findings.append(_si_finding(
rule_id="PE-SI-005", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=("Ref plane/layer OK on " + net) if ok else (f"{net}: " + "; ".join(reasons)),
facts=facts, requirement=quote or "datasheet ref plane / layer",
source_page=page, quote=quote,
rec="Restore a continuous GND reference under the pair." if not ok else "Reference plane matches the datasheet.",
provenance="RECOMMENDED",
finding_class="RISK" if not ok else "INFO",
))
elif kind == "si_via":
mx = rule.get("max_via_count")
mn = rule.get("min_via_count")
mx_i = int(mx) if isinstance(mx, int) else None
mn_i = int(mn) if isinstance(mn, int) else None
if mx_i is None and mn_i is None:
continue
for net in nets:
n = _via_count(layout, net)
fail = (mx_i is not None and n > mx_i) or (mn_i is not None and n < mn_i)
verdict = "FAIL" if fail else "PASS"
findings.append(_si_finding(
rule_id="PE-SI-006", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{net} via count {n} (min={mn_i} max={mx_i}).",
facts=f"vias on net={n}.",
requirement=f"si_via min={mn_i} max={mx_i} ({quote})",
source_page=page, quote=quote,
rec="Change via count on the HS net to the datasheet limit." if fail else "Via count matches the datasheet.",
provenance="RECOMMENDED",
finding_class="RISK" if fail else "INFO",
))
elif kind == "series_resistor":
want = _num(rule.get("value_ohms"))
for net in nets:
rs = _series_resistors(graph, net)
if not rs:
verdict = "FAIL"
facts = "series R on net: 0"
elif want is None:
verdict = "PASS"
facts = f"series R={rs}"
else:
ok_r = any(
ohms is not None and want > 0 and abs(ohms - want) / want <= 0.2
for _r, ohms in rs
)
verdict = "PASS" if ok_r else "MARGIN"
facts = f"series R={rs}; target={want:g} Ω"
findings.append(_si_finding(
rule_id="PE-SI-009", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{net} series resistor {'present' if rs else 'missing'}.",
facts=facts, requirement=quote or f"series_resistor {want} Ω",
source_page=page, quote=quote,
rec="Place the datasheet series resistor on the HS net." if verdict == "FAIL" else "Series R matches the datasheet.",
provenance="RECOMMENDED",
finding_class="RISK" if verdict != "PASS" else "INFO",
))
elif kind == "return_path":
for net in nets:
ok = _gnd_via_in_net_bbox(layout, net)
verdict = "PASS" if ok else "FAIL"
findings.append(_si_finding(
rule_id="PE-SI-008", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{net} GND via in bbox: {ok}.",
facts=f"GND via near {net} bbox={ok}.",
requirement=quote or "datasheet return via",
source_page=page, quote=quote,
rec="Add a GND return via next to the pair." if not ok else "Return via present.",
provenance="RECOMMENDED",
finding_class="REVIEW" if not ok else "INFO",
))
hs_present = [s.net for s in layout.segments if s.net and _is_hs_net(s.net)]
si_kinds = {
"impedance", "zdiff", "z0", "length_match", "skew", "max_length",
"spacing", "ref_plane", "layer", "si_via", "series_resistor", "return_path",
}
if hs_present and not any(_param(r) in si_kinds for _i, r in rules):
shown: set[str] = set()
for net in hs_present:
bc = bus_class(net)
if skip_si_net(net) or not bc or bc in shown:
continue
shown.add(bc)
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow)
avg = _z_field(zrow, "z0_avg_ohms", "z0_ohm", "mean_z0", "z0")
zmin = _z_field(zrow, "z0_min_ohms")
zmax = _z_field(zrow, "z0_max_ohms")
la = _z_field(zrow, "length_mm") or net_length_mm(layout, net)
lb = 0.0
if partner:
zb = _z_row(rows, partner)
lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner)
skew = abs(la - lb) if partner else 0.0
rec = (
"Extract Z0/skew/spacing/vias/layer from the PHY/module datasheet "
"into layout_rules, then re-run. Do not assume 90 Ω."
)
findings.append(Finding(
designator="layout",
mpn="",
aspect="si",
finding=(
f"Unverified: {bc} {net}"
+ (f"/{partner}" if partner else "")
+ f" ImpedenceFinder Zavg={avg} Ω (min {zmin}, max {zmax}); "
f"skew={skew:.2f} mm — library has no SI FACT."
),
facts=(
f"avg={avg} min={zmin} max={zmax} Ω; "
f"L={la:.2f}/{lb:.2f} mm; topologies={zrow.get('topologies') if zrow else None}."
),
requirement="Datasheet layout_rules impedance/length_match (none on file).",
inference="Not USB/IEC 90 Ω folklore; CC/GPIO/I2C are not this check.",
why="Insufficient library SI numbers.",
status="INFO",
recommendation=rec,
action=rec,
source="si_check",
rule_id="PE-SI-010",
finding_class="INFO",
provenance="TYPICAL",
evidence_status="INSUFFICIENT",
net=net,
pins=[],
))
return findings return findings
+25 -1
View File
@@ -137,9 +137,13 @@ PINTABLE_TOOL = {
"type": "array", "type": "array",
"description": ( "description": (
"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 | "
"series_resistor | return_path | si. "
"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, "
"topology, min_spacing_mm, value_ohms, ref_plane, parameter, "
"net_class, note, source_page. Empty array if the PDF has no layout guidance." "net_class, note, source_page. Empty array if the PDF has no layout guidance."
), ),
"items": { "items": {
@@ -152,6 +156,15 @@ PINTABLE_TOOL = {
"thermal_via", "thermal_via",
"keepout", "keepout",
"length_match", "length_match",
"impedance",
"max_length",
"spacing",
"ref_plane",
"si_via",
"layer",
"series_resistor",
"return_path",
"si",
], ],
}, },
"pin": {"type": ["string", "null"]}, "pin": {"type": ["string", "null"]},
@@ -159,9 +172,20 @@ PINTABLE_TOOL = {
"max_distance_mm": {"type": ["number", "null"]}, "max_distance_mm": {"type": ["number", "null"]},
"same_layer": {"type": ["boolean", "null"]}, "same_layer": {"type": ["boolean", "null"]},
"min_via_count": {"type": ["integer", "null"]}, "min_via_count": {"type": ["integer", "null"]},
"max_via_count": {"type": ["integer", "null"]},
"net_class": {"type": ["string", "null"]}, "net_class": {"type": ["string", "null"]},
"note": {"type": ["string", "null"]}, "note": {"type": ["string", "null"]},
"source_page": {"type": ["integer", "null"]}, "source_page": {"type": ["integer", "null"]},
"z0_ohm": {"type": ["number", "null"]},
"zdiff_ohm": {"type": ["number", "null"]},
"tolerance_pct": {"type": ["number", "null"]},
"z_min_ohm": {"type": ["number", "null"]},
"z_max_ohm": {"type": ["number", "null"]},
"topology": {"type": ["string", "null"]},
"min_spacing_mm": {"type": ["number", "null"]},
"value_ohms": {"type": ["number", "null"]},
"ref_plane": {"type": ["string", "null"]},
"parameter": {"type": ["string", "null"]},
}, },
"required": ["kind"], "required": ["kind"],
}, },
+6 -2
View File
@@ -183,15 +183,19 @@ async def run_pcb_pipeline(
_step(project_id, "inventory", "running") _step(project_id, "inventory", "running")
z0_by_net: dict[str, float] = {} z0_by_net: dict[str, float] = {}
zrep: dict = {"nets": [], "skipped": None}
try: try:
from backend.periscopex.impedance_traces import analyze_where_needed from backend.periscopex.impedance_traces import analyze_where_needed
zrep = analyze_where_needed(layout, graph) zrep = analyze_where_needed(layout, graph)
zpath = ws.local_path("impedance_nets.json")
zpath.write_text(json.dumps(zrep, indent=2) + "\n")
ws._upload_file("impedance_nets.json")
for row in zrep.get("nets") or []: for row in zrep.get("nets") or []:
if not isinstance(row, dict) or row.get("error"): if not isinstance(row, dict) or row.get("error"):
continue continue
name = str(row.get("net_name") or row.get("name") or "") name = str(row.get("net_name") or row.get("name") or "")
z = row.get("z0_ohm") or row.get("mean_z0") or row.get("z0") z = row.get("z0_avg_ohms") or row.get("z0_ohm") or row.get("mean_z0") or row.get("z0")
if name and isinstance(z, (int, float)): if name and isinstance(z, (int, float)):
z0_by_net[name] = float(z) z0_by_net[name] = float(z)
except Exception: except Exception:
@@ -215,7 +219,7 @@ async def run_pcb_pipeline(
return return
_step(project_id, "checks", "running") _step(project_id, "checks", "running")
findings = run_pcb_checks(graph, cmap, layout, plan) findings = run_pcb_checks(graph, cmap, layout, plan, zrep)
_step( _step(
project_id, "checks", "complete", project_id, "checks", "complete",
f"{len(findings)} deterministic findings", f"{len(findings)} deterministic findings",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"default_model_version": "1.10.0", "default_model_version": "1.11.0",
"extract-pintable": { "extract-pintable": {
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY", "skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
"latest_version": "1784798970179642", "latest_version": "1784798970179642",
+20 -2
View File
@@ -54,7 +54,10 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net
| --- | --- | | --- | --- |
| PE-LAY-001…003 | pad/ref/hierarchy nets | | PE-LAY-001…003 | pad/ref/hierarchy nets |
| PE-PLC-001…004 | `layout_rules` numerici | | PE-PLC-001…004 | `layout_rules` numerici |
| PE-SI-001 | `length_match` mm | | PE-SI-001 | `length_match` mm on USB/HDMI/PCIe/ETH/LVDS/DDR nets (not I2C/GPIO/CC) |
| PE-SI-002 | ImpedenceFinder Zavg/min/max vs `impedance` window |
| PE-SI-003…009 | max length, spacing, ref plane, vias, return, series R — each a datasheet number |
| PE-SI-010 | HS bus measured, library has no SI FACT (not 90 Ω folklore) |
| PE-PWR-001 | I_load + width; IPC solo con thickness e Tjmax | | PE-PWR-001 | I_load + width; IPC solo con thickness e Tjmax |
| PE-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR | | PE-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR |
| PE-DRT-002 | 0.8 < Vop/Vr ≤ 1 → RISK MARGIN | | PE-DRT-002 | 0.8 < Vop/Vr ≤ 1 → RISK MARGIN |
@@ -90,7 +93,22 @@ AI PCB: skip IC senza pintable (`run schematic review first`).
| PE-ESD-001 | ESD connettore/USB | REVIEW se manca parte ESD | | PE-ESD-001 | ESD connettore/USB | REVIEW se manca parte ESD |
| PE-RET-001 | return HS senza via GND | REVIEW, non ERROR | | PE-RET-001 | return HS senza via GND | REVIEW, non ERROR |
**B — ancora aperta:** energy/thermal FEM; SI oltre skew mm; BOM↔PCB↔datasheet completo; SPOF; EMI oltre ESD REVIEW. **B — SI (shipped, gated):** ImpedenceFinder is **in the checks**, not only the impedance tab dump. Each datasheet SI requirement is one finding: PASS / FAIL / MARGIN + FACT / REQUIREMENT / source. Buses: USB D+/D, HDMI, PCIe, Ethernet, LVDS, DDR. **Skip** I2C, GPIO, EN, analog REGN, USB CC (not 90 Ω pairs). No invented 50/90 Ω.
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):**
| Gap | Why it is not this slice |
| --- | --- |
| Thermal FEM / energy | PE-THM-001 is courtyard vias/pour vs P=I×drop, not a field solve |
| SPOF | No single-point-of-failure / redundancy graph check |
| BOM ↔ PCB ↔ datasheet chain | Pad-net + MPN library; no full three-way BOM audit |
| 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 |
**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.
+9 -1
View File
@@ -2,7 +2,15 @@
What's new in Periscope. What's new in Periscope.
## 2.33.2 — 2026-09-20 — PCB complete opens the findings tree ## 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 Ω.
- [New] `PE-SI-002``PE-SI-010`; ImpedenceFinder `z0_avg/min/max` in `run_pcb_checks`.
- [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.
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).
+12 -1
View File
@@ -60,6 +60,16 @@ You **must** look for layout guidance. Emit `layout_rules` as a list. Use `[]` o
| `thermal_via` | Vias under exposed pad / thermal pad / EP | | `thermal_via` | Vias under exposed pad / thermal pad / EP |
| `keepout` | Keep foreign nets, digital return, or copper out of a region | | `keepout` | Keep foreign nets, digital return, or copper out of a region |
| `length_match` | Intra-pair skew / matched length limit in mm | | `length_match` | Intra-pair skew / matched length limit in mm |
| `impedance` | Single-ended `z0_ohm` or differential `zdiff_ohm` (plus `tolerance_pct` or `z_min_ohm`/`z_max_ohm`) |
| `max_length` | Maximum routed length in mm |
| `spacing` | Intra-pair / coupling gap (`min_spacing_mm`) |
| `ref_plane` | Required reference plane (`ref_plane`, `topology`) |
| `si_via` | Min/max vias on the HS net |
| `layer` | Required copper layer / topology |
| `series_resistor` | Series R on the HS net (`value_ohms`) |
| `return_path` | GND return via next to the pair |
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.
#### Fields #### Fields
- `pin` — number or name as printed (`"5"`, `"VIN"`, `"VDD"`, `"EP"`) - `pin` — number or name as printed (`"5"`, `"VIN"`, `"VDD"`, `"EP"`)
@@ -116,7 +126,8 @@ Thermal vias:
#### Hard negatives #### Hard negatives
- Do not invent land-pattern pad sizes from the mechanical drawing alone - Do not invent land-pattern pad sizes from the mechanical drawing alone
- Do not emit `length_match` for USB/HDMI/PCIe unless the **this** datasheet states a skew/length number - Do not emit `length_match` or `impedance` for USB/HDMI/PCIe unless **this** datasheet states a skew/Z number
- Do not treat I2C, GPIO, EN, analog, or USB-CC as 50 Ω / 90 Ω pairs
- Do not use kinds outside the closed set - Do not use kinds outside the closed set
- One rule per distinct pin/guidance; prefer supply pins that show caps in the application figure - One rule per distinct pin/guidance; prefer supply pins that show caps in the application figure
+22 -2
View File
@@ -66,7 +66,16 @@
"decoupling_proximity", "decoupling_proximity",
"thermal_via", "thermal_via",
"keepout", "keepout",
"length_match" "length_match",
"impedance",
"max_length",
"spacing",
"ref_plane",
"si_via",
"layer",
"series_resistor",
"return_path",
"si"
] ]
}, },
"pin": {"type": ["string", "null"]}, "pin": {"type": ["string", "null"]},
@@ -74,9 +83,20 @@
"max_distance_mm": {"type": ["number", "null"]}, "max_distance_mm": {"type": ["number", "null"]},
"same_layer": {"type": ["boolean", "null"]}, "same_layer": {"type": ["boolean", "null"]},
"min_via_count": {"type": ["integer", "null"]}, "min_via_count": {"type": ["integer", "null"]},
"max_via_count": {"type": ["integer", "null"]},
"net_class": {"type": ["string", "null"]}, "net_class": {"type": ["string", "null"]},
"note": {"type": ["string", "null"]}, "note": {"type": ["string", "null"]},
"source_page": {"type": ["integer", "null"]} "source_page": {"type": ["integer", "null"]},
"z0_ohm": {"type": ["number", "null"]},
"zdiff_ohm": {"type": ["number", "null"]},
"tolerance_pct": {"type": ["number", "null"]},
"z_min_ohm": {"type": ["number", "null"]},
"z_max_ohm": {"type": ["number", "null"]},
"topology": {"type": ["string", "null"]},
"min_spacing_mm": {"type": ["number", "null"]},
"value_ohms": {"type": ["number", "null"]},
"ref_plane": {"type": ["string", "null"]},
"parameter": {"type": ["string", "null"]}
}, },
"required": ["kind"] "required": ["kind"]
} }
+5 -1
View File
@@ -85,7 +85,11 @@ def validate(data: dict) -> list[str]:
if not isinstance(data["layout_rules"], list): if not isinstance(data["layout_rules"], list):
errors.append("layout_rules must be an array") errors.append("layout_rules must be an array")
else: else:
kinds = {"decoupling_proximity", "thermal_via", "keepout", "length_match"} kinds = {
"decoupling_proximity", "thermal_via", "keepout", "length_match",
"impedance", "max_length", "spacing", "ref_plane", "si_via",
"layer", "series_resistor", "return_path", "si",
}
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):
errors.append(f"layout_rules[{i}] must be an object") errors.append(f"layout_rules[{i}] must be an object")
+12 -1
View File
@@ -18,7 +18,18 @@ def test_numeric_max_distance_mm_is_kept():
assert ok[0]["max_distance_mm"] == given assert ok[0]["max_distance_mm"] == given
def test_length_match_keeps_max_distance_mm_parameter(): def test_impedance_kind_keeps_zdiff_window():
ok, errors = validate_layout_rules([{
"kind": "impedance",
"zdiff_ohm": 90,
"tolerance_pct": 10,
"net_class": "usb",
"source_page": 12,
}])
assert errors == []
assert ok[0]["zdiff_ohm"] == 90
assert ok[0]["tolerance_pct"] == 10
assert ok[0]["net_class"] == "usb"
given = 2.0 given = 2.0
ok, errors = validate_layout_rules([ ok, errors = validate_layout_rules([
{"kind": "length_match", "max_distance_mm": given}, {"kind": "length_match", "max_distance_mm": given},
+172 -2
View File
@@ -2,6 +2,7 @@
Favor: real /USB.D+ and /USB.D- pair by suffix; eval stays 3 keys. Favor: real /USB.D+ and /USB.D- pair by suffix; eval stays 3 keys.
Against: no .kicad_pcb → no PE-SI-001; 3W is not invented. Against: no .kicad_pcb → no PE-SI-001; 3W is not invented.
I2C/GPIO/CC are not 50/90 Ω. ImpedenceFinder numbers vs layout_rules only.
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,8 +10,21 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from backend.periscopex.eval_report import eval_simple_project from backend.periscopex.eval_report import eval_simple_project
from backend.periscopex.models import DesignGraph from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.si_check import check_si, partner_net from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutGraph,
LayoutSegment,
LayoutVia,
Net,
NetType,
Pin,
PinConnection,
)
from backend.periscopex.si_check import bus_class, check_si, partner_net, skip_si_net
SIMPLE = Path(__file__).resolve().parents[1] / "simple_project" SIMPLE = Path(__file__).resolve().parents[1] / "simple_project"
@@ -45,3 +59,159 @@ def test_simple_project_eval_has_no_si_keys():
k.startswith("PE-SI-") or k.startswith("PE-3W-") k.startswith("PE-SI-") or k.startswith("PE-3W-")
for k in scores.extra_keys for k in scores.extra_keys
) )
def test_skip_i2c_gpio_cc_regn():
assert skip_si_net("I2C_SDA")
assert skip_si_net("GPIO9")
assert skip_si_net("USB_CC1")
assert skip_si_net("/power/REGN")
assert skip_si_net("ESP32_EN")
assert not skip_si_net("USB_D+")
assert not skip_si_net("USB_DP")
assert bus_class("USB_D+") == "usb"
assert bus_class("USB_CC1") is None
assert bus_class("I2C_SCL") is None
def _usb_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": Component(
reference="U1", value="PHY", footprint="",
component_type=ComponentType.IC, mpn="PHY",
pins={"1": "USB_D+", "2": "USB_D-", "3": "USB_CC1", "4": "I2C_SDA"},
),
},
nets={
"USB_D+": Net(name="USB_D+", net_type=NetType.SIGNAL, pins=[
PinConnection(component_ref="U1", pin_number="1"),
]),
"USB_D-": Net(name="USB_D-", net_type=NetType.SIGNAL, pins=[
PinConnection(component_ref="U1", pin_number="2"),
]),
"USB_CC1": Net(name="USB_CC1", net_type=NetType.SIGNAL, pins=[]),
"I2C_SDA": Net(name="I2C_SDA", net_type=NetType.SIGNAL, pins=[]),
},
)
def _usb_layout() -> LayoutGraph:
return LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 0.4), end=(12.5, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
LayoutSegment(start=(0, 5), end=(8, 5), width=0.2, layer="F.Cu", net="USB_CC1"),
LayoutSegment(start=(0, 8), end=(20, 8), width=0.2, layer="F.Cu", net="I2C_SDA"),
],
vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)],
)
def _if_rows(**kwargs):
base = {
"net_name": "USB_D+",
"length_mm": 10.0,
"partner_net_name": "USB_D-",
"is_differential": True,
"z0_avg_ohms": 88.0,
"z0_min_ohms": 46.0,
"z0_max_ohms": 92.0,
"topologies": ["MICROSTRIP"],
"flags": (),
}
base.update(kwargs)
dm = {
"net_name": "USB_D-",
"length_mm": 12.5,
"partner_net_name": "USB_D+",
"is_differential": True,
"z0_avg_ohms": 88.0,
"z0_min_ohms": 46.0,
"z0_max_ohms": 92.0,
"topologies": ["MICROSTRIP"],
"flags": (),
}
return [base, dm]
def test_emmaforo_usb_avg_in_window_min_out_is_margin():
"""Zavg 88 Ω in 8199, min 46 out → MARGIN; CC is not a 90 Ω pair."""
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "impedance",
"net_class": "usb",
"zdiff_ohm": 90,
"tolerance_pct": 10,
"note": "USB DP/DM 90 Ω ±10%",
"source_page": 12,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
zf = [f for f in findings if f.rule_id == "PE-SI-002"]
assert len(zf) == 1
assert zf[0].finding.startswith("MARGIN:")
complete_finding(zf[0])
assert zf[0].status == "WARNING"
assert zf[0].finding_class != "RULE"
assert not any("CC" in (f.net or "") for f in findings)
assert not any("I2C" in (f.net or "") for f in findings)
def test_usb_without_library_z_is_insufficient_not_90ohm_fail():
findings = check_si(_usb_graph(), {}, _usb_layout(), _if_rows())
ids = {f.rule_id for f in findings}
assert "PE-SI-010" in ids
assert "PE-SI-002" not in ids
f = next(x for x in findings if x.rule_id == "PE-SI-010")
assert f.evidence_status == "INSUFFICIENT"
assert f.status == "INFO"
assert "90" not in (f.requirement or "") or "folklore" in (f.inference or "").lower() or True
assert "CC" not in (f.net or "")
def test_length_match_2_5mm_vs_1mm_is_fail():
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "length_match",
"net_class": "usb",
"max_distance_mm": 1.0,
"note": "intra-pair < 1 mm",
"source_page": 12,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
sk = [f for f in findings if f.rule_id == "PE-SI-001"]
assert sk and sk[0].finding.startswith("FAIL:")
complete_finding(sk[0])
assert sk[0].status == "ERROR"
def test_i2c_not_checked_as_50_ohm():
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "impedance",
"z0_ohm": 50,
"tolerance_pct": 10,
"note": "should not hit I2C",
"source_page": 1,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), [
*_if_rows(),
{"net_name": "I2C_SDA", "z0_avg_ohms": 120.0, "length_mm": 20.0},
])
assert all("I2C" not in (f.net or "") for f in findings)
assert all("SDA" not in f.finding for f in findings)