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:
@@ -133,6 +133,22 @@ def _seed() -> None:
|
||||
("PE-SI-001", "Differential skew vs length_match mm."),
|
||||
):
|
||||
_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",
|
||||
requirement="Keepout from layout_rules — not a DRC for pad copper.")
|
||||
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
|
||||
|
||||
@@ -6,7 +6,11 @@ from typing import Any
|
||||
|
||||
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:
|
||||
@@ -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
|
||||
page = row.get("source_page")
|
||||
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({
|
||||
"kind": kind,
|
||||
"pin": row.get("pin"),
|
||||
@@ -86,8 +92,19 @@ def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
|
||||
"max_distance_mm": dist,
|
||||
"same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None,
|
||||
"min_via_count": via_i,
|
||||
"max_via_count": mx_via_i,
|
||||
"net_class": row.get("net_class"),
|
||||
"note": row.get("note"),
|
||||
"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
|
||||
|
||||
@@ -174,13 +174,14 @@ def run_pcb_checks(
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
plan: FunctionalGroupsReport | None = None,
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD."""
|
||||
out: list[Finding] = []
|
||||
for name, fn in (
|
||||
("pcb_net_match", lambda: check_pcb_net_match(graph, 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_power_traces", lambda: check_pcb_power_traces(graph, constraints_map, layout)),
|
||||
("pcb_via_current", lambda: check_pcb_via_current(graph, constraints_map, layout)),
|
||||
|
||||
+623
-38
@@ -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
|
||||
limit is never 3W, USB spec folklore, or a default millimetre.
|
||||
USB / HDMI / PCIe / Ethernet / LVDS / DDR (and any net_class on an
|
||||
``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
|
||||
|
||||
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
|
||||
|
||||
_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:
|
||||
@@ -19,73 +49,628 @@ def _seg_len(seg: LayoutSegment) -> 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:
|
||||
n = name or ""
|
||||
for a, b in _PAIR_SUFFIXES:
|
||||
if name.endswith(a):
|
||||
return name[: -len(a)] + b
|
||||
if name.endswith(b):
|
||||
return name[: -len(b)] + a
|
||||
if n.endswith(a):
|
||||
return n[: -len(a)] + b
|
||||
if n.endswith(b):
|
||||
return n[: -len(b)] + a
|
||||
return None
|
||||
|
||||
|
||||
def _length_match_limit_mm(constraints_map: dict, graph: DesignGraph) -> tuple[float, int | None] | None:
|
||||
for comp in graph.components.values():
|
||||
cons = _match_constraints(comp.mpn, constraints_map)
|
||||
def _leaf(net: str) -> str:
|
||||
n = normalize_kicad_hierarchy_net(net)
|
||||
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:
|
||||
continue
|
||||
ic_nets = {n for n in (comp.pins.values()) if n}
|
||||
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
|
||||
mm = rule.get("max_distance_mm")
|
||||
if mm is None:
|
||||
out.append((ref, {**rule, "_ic": ref, "_ic_nets": ic_nets, "_mpn": comp.mpn or ""}))
|
||||
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
|
||||
return float(mm), rule.get("source_page")
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
) -> list[Finding]:
|
||||
if layout is None or not layout.segments:
|
||||
return []
|
||||
limit = _length_match_limit_mm(constraints_map, graph)
|
||||
if limit is None:
|
||||
return []
|
||||
max_mm, page = limit
|
||||
seen: set[tuple[str, str]] = set()
|
||||
raw = impedance_nets
|
||||
if isinstance(impedance_nets, dict):
|
||||
raw = list(impedance_nets.get("nets") or [])
|
||||
rows: list[dict] = [r for r in (raw or []) if isinstance(r, dict)]
|
||||
|
||||
findings: list[Finding] = []
|
||||
names = {s.net for s in layout.segments if s.net}
|
||||
for net in names:
|
||||
partner = partner_net(net)
|
||||
if not partner or partner not in names:
|
||||
rules = _collect_rules(graph, constraints_map)
|
||||
seen: set[tuple] = set()
|
||||
|
||||
for _ref, rule in rules:
|
||||
kind = _param(rule)
|
||||
nets = _rule_nets(layout, rule, graph)
|
||||
quote = _quote(rule)
|
||||
page = rule.get("source_page") if isinstance(rule.get("source_page"), int) else None
|
||||
mpn = str(rule.get("_mpn") or "")
|
||||
ic = str(rule.get("_ic") or "layout")
|
||||
|
||||
if kind in ("impedance", "zdiff", "z0"):
|
||||
window = _z_window(rule)
|
||||
nom = _num(rule.get("zdiff_ohm")) or _num(rule.get("z0_ohm"))
|
||||
want_diff = _num(rule.get("zdiff_ohm")) is not None or kind == "zdiff"
|
||||
paired: set[tuple[str, str]] = set()
|
||||
for net in nets:
|
||||
zrow = _z_row(rows, net)
|
||||
partner = _partner_on_board(layout, net, zrow) if want_diff else None
|
||||
key_net = _pair_key(net, partner) if partner else (net, "")
|
||||
if key_net in paired:
|
||||
continue
|
||||
key = tuple(sorted((net, partner)))
|
||||
if key in seen:
|
||||
paired.add(key_net)
|
||||
sig = ("z", key_net, id(rule))
|
||||
if sig in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
skew = abs(net_length_mm(layout, net) - net_length_mm(layout, partner))
|
||||
if skew <= max_mm:
|
||||
seen.add(sig)
|
||||
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"Intra-pair skew {skew:.1f} mm on {key[0]}/{key[1]} "
|
||||
f"(datasheet max {max_mm:g} mm)."
|
||||
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."
|
||||
),
|
||||
why=f"length_match max_distance_mm={max_mm:g}.",
|
||||
status="ERROR",
|
||||
recommendation="Length-match the differential pair.",
|
||||
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-001",
|
||||
rule_id="PE-SI-010",
|
||||
finding_class="INFO",
|
||||
provenance="TYPICAL",
|
||||
evidence_status="INSUFFICIENT",
|
||||
net=net,
|
||||
pins=[],
|
||||
source_page=page,
|
||||
))
|
||||
return findings
|
||||
|
||||
@@ -137,9 +137,13 @@ PINTABLE_TOOL = {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"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 "
|
||||
"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."
|
||||
),
|
||||
"items": {
|
||||
@@ -152,6 +156,15 @@ PINTABLE_TOOL = {
|
||||
"thermal_via",
|
||||
"keepout",
|
||||
"length_match",
|
||||
"impedance",
|
||||
"max_length",
|
||||
"spacing",
|
||||
"ref_plane",
|
||||
"si_via",
|
||||
"layer",
|
||||
"series_resistor",
|
||||
"return_path",
|
||||
"si",
|
||||
],
|
||||
},
|
||||
"pin": {"type": ["string", "null"]},
|
||||
@@ -159,9 +172,20 @@ PINTABLE_TOOL = {
|
||||
"max_distance_mm": {"type": ["number", "null"]},
|
||||
"same_layer": {"type": ["boolean", "null"]},
|
||||
"min_via_count": {"type": ["integer", "null"]},
|
||||
"max_via_count": {"type": ["integer", "null"]},
|
||||
"net_class": {"type": ["string", "null"]},
|
||||
"note": {"type": ["string", "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"],
|
||||
},
|
||||
|
||||
@@ -183,15 +183,19 @@ async def run_pcb_pipeline(
|
||||
|
||||
_step(project_id, "inventory", "running")
|
||||
z0_by_net: dict[str, float] = {}
|
||||
zrep: dict = {"nets": [], "skipped": None}
|
||||
try:
|
||||
from backend.periscopex.impedance_traces import analyze_where_needed
|
||||
|
||||
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 []:
|
||||
if not isinstance(row, dict) or row.get("error"):
|
||||
continue
|
||||
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)):
|
||||
z0_by_net[name] = float(z)
|
||||
except Exception:
|
||||
@@ -215,7 +219,7 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "checks", "running")
|
||||
findings = run_pcb_checks(graph, cmap, layout, plan)
|
||||
findings = run_pcb_checks(graph, cmap, layout, plan, zrep)
|
||||
_step(
|
||||
project_id, "checks", "complete",
|
||||
f"{len(findings)} deterministic findings",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"default_model_version": "1.10.0",
|
||||
"default_model_version": "1.11.0",
|
||||
"extract-pintable": {
|
||||
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
|
||||
"latest_version": "1784798970179642",
|
||||
|
||||
@@ -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-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-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR |
|
||||
| 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-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% (81–99), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min (~46 Ω) is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists and 2.5 > limit. Without library Z, **PE-SI-010** INFO (insufficient) — not a 90 Ω ERROR.
|
||||
|
||||
**B — still missing (close-out):**
|
||||
|
||||
| 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.
|
||||
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| `keepout` | Keep foreign nets, digital return, or copper out of a region |
|
||||
| `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
|
||||
- `pin` — number or name as printed (`"5"`, `"VIN"`, `"VDD"`, `"EP"`)
|
||||
@@ -116,7 +126,8 @@ Thermal vias:
|
||||
|
||||
#### Hard negatives
|
||||
- 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
|
||||
- One rule per distinct pin/guidance; prefer supply pins that show caps in the application figure
|
||||
|
||||
|
||||
@@ -66,7 +66,16 @@
|
||||
"decoupling_proximity",
|
||||
"thermal_via",
|
||||
"keepout",
|
||||
"length_match"
|
||||
"length_match",
|
||||
"impedance",
|
||||
"max_length",
|
||||
"spacing",
|
||||
"ref_plane",
|
||||
"si_via",
|
||||
"layer",
|
||||
"series_resistor",
|
||||
"return_path",
|
||||
"si"
|
||||
]
|
||||
},
|
||||
"pin": {"type": ["string", "null"]},
|
||||
@@ -74,9 +83,20 @@
|
||||
"max_distance_mm": {"type": ["number", "null"]},
|
||||
"same_layer": {"type": ["boolean", "null"]},
|
||||
"min_via_count": {"type": ["integer", "null"]},
|
||||
"max_via_count": {"type": ["integer", "null"]},
|
||||
"net_class": {"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"]
|
||||
}
|
||||
|
||||
@@ -85,7 +85,11 @@ def validate(data: dict) -> list[str]:
|
||||
if not isinstance(data["layout_rules"], list):
|
||||
errors.append("layout_rules must be an array")
|
||||
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"]):
|
||||
if not isinstance(row, dict):
|
||||
errors.append(f"layout_rules[{i}] must be an object")
|
||||
|
||||
@@ -18,7 +18,18 @@ def test_numeric_max_distance_mm_is_kept():
|
||||
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
|
||||
ok, errors = validate_layout_rules([
|
||||
{"kind": "length_match", "max_distance_mm": given},
|
||||
|
||||
+172
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
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.
|
||||
I2C/GPIO/CC are not 50/90 Ω. ImpedenceFinder numbers vs layout_rules only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -9,8 +10,21 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.eval_report import eval_simple_project
|
||||
from backend.periscopex.models import DesignGraph
|
||||
from backend.periscopex.si_check import check_si, partner_net
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
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"
|
||||
|
||||
@@ -45,3 +59,159 @@ def test_simple_project_eval_has_no_si_keys():
|
||||
k.startswith("PE-SI-") or k.startswith("PE-3W-")
|
||||
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 81–99, 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)
|
||||
|
||||
Reference in New Issue
Block a user