PE-SI-009 no longer treats ESP32 CHIP_PU/EN RC as a USB series R. USB without a library Z number stays PE-SI-010 measurement-only. Pintable skill 1.13.0 requires net_class on SI kinds (usb2/usb3/MDI/RGMII/DDR3).
826 lines
32 KiB
Python
826 lines
32 KiB
Python
"""SI checks: datasheet layout_rules vs board + ImpedenceFinder.
|
||
|
||
USB2 / USB3 SuperSpeed / Ethernet MDI / RGMII / SGMII / DDR3 / HDMI /
|
||
PCIe / LVDS are checked only against a rule whose quote or ``net_class``
|
||
names that bus. I2C, GPIO, EN, analog REGN, USB CC, and strap/EN RC are
|
||
not HS pairs. No invented USB/IEC 90 Ω.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
|
||
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"),
|
||
("+", "-"),
|
||
("_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 = (
|
||
("usb3", re.compile(
|
||
r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]|SS[_]?RX", re.I,
|
||
)),
|
||
("hdmi", re.compile(r"HDMI", re.I)),
|
||
("pcie", re.compile(r"PCIE|PEX_", re.I)),
|
||
("sgmii", re.compile(r"SGMII", re.I)),
|
||
("rgmii", re.compile(r"RGMII|(?:^|[_/])GMII", re.I)),
|
||
("eth_mdi", re.compile(
|
||
r"(?:^|[_/])(MDI|TRD[0-3]|TCT|RCT)|"
|
||
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
|
||
re.I,
|
||
)),
|
||
("lvds", re.compile(r"LVDS", re.I)),
|
||
("ddr3_clk", re.compile(r"DDR3?.*CK|(?:^|[_/])CK[_]?[PN](?:$|[_/])", re.I)),
|
||
("ddr3_dqs", re.compile(r"DQS", re.I)),
|
||
("ddr3_dq", re.compile(r"(?:^|[_/])DQ\d+|DDR3?.*DQ\d+", re.I)),
|
||
("ddr3_addr", re.compile(
|
||
r"DDR3?.*(?:A\d+|ADDR|BA\d+|RAS|CAS|WE|ODT|CKE|(?:^|[_/])CS)",
|
||
re.I,
|
||
)),
|
||
)
|
||
|
||
_STRAP_RULE_RE = re.compile(
|
||
r"(?:^|[\s_/.\-])(EN|CHIP_PU|CHIP_EN|ENABLE|NRST|RST|RESET|STRAP|"
|
||
r"ILIM|BOOT|CHIP_PU)\b|"
|
||
r"RC\s*(?:delay|filter|network)|"
|
||
r"10\s*k\s*(?:[Ωohm]|ohm).{0,32}1\s*[µu]F|"
|
||
r"1\s*[µu]F.{0,32}10\s*k",
|
||
re.I,
|
||
)
|
||
|
||
_BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = {
|
||
"usb": frozenset({"usb2"}),
|
||
"usb2": frozenset({"usb2"}),
|
||
"usb_2": frozenset({"usb2"}),
|
||
"usb2_0": frozenset({"usb2"}),
|
||
"hs_usb": frozenset({"usb2"}),
|
||
"usb3": frozenset({"usb3"}),
|
||
"usb_3": frozenset({"usb3"}),
|
||
"usb3_0": frozenset({"usb3"}),
|
||
"usb3_1": frozenset({"usb3"}),
|
||
"superspeed": frozenset({"usb3"}),
|
||
"ss": frozenset({"usb3"}),
|
||
"ethernet": frozenset({"eth_mdi"}),
|
||
"eth": frozenset({"eth_mdi"}),
|
||
"eth_mdi": frozenset({"eth_mdi"}),
|
||
"mdi": frozenset({"eth_mdi"}),
|
||
"rj45": frozenset({"eth_mdi"}),
|
||
"magnetics": frozenset({"eth_mdi"}),
|
||
"rgmii": frozenset({"rgmii"}),
|
||
"gmii": frozenset({"rgmii"}),
|
||
"mac_phy": frozenset({"rgmii", "sgmii"}),
|
||
"mac": frozenset({"rgmii", "sgmii"}),
|
||
"sgmii": frozenset({"sgmii"}),
|
||
"ddr": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
|
||
"ddr3": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
|
||
"ddr3_clk": frozenset({"ddr3_clk"}),
|
||
"ddr3_dqs": frozenset({"ddr3_dqs"}),
|
||
"ddr3_dq": frozenset({"ddr3_dq"}),
|
||
"ddr3_addr": frozenset({"ddr3_addr"}),
|
||
"hdmi": frozenset({"hdmi"}),
|
||
"pcie": frozenset({"pcie"}),
|
||
"lvds": frozenset({"lvds"}),
|
||
}
|
||
|
||
_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:
|
||
return math.hypot(seg.end[0] - seg.start[0], seg.end[1] - seg.start[1])
|
||
|
||
|
||
def net_length_mm(layout: LayoutGraph, net: str) -> float:
|
||
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 n.endswith(a):
|
||
return n[: -len(a)] + b
|
||
if n.endswith(b):
|
||
return n[: -len(b)] + a
|
||
return None
|
||
|
||
|
||
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)
|
||
u = leaf.upper()
|
||
if re.search(r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]", u):
|
||
return "usb3"
|
||
if "USB" in u and re.search(r"(D\+|D-|DP|DM)", leaf, re.I):
|
||
return "usb2"
|
||
if re.search(r"(?:ETH|MAC).*(TXD|RXD|TXC|RXC|TX_CLK|RX_CLK|TX_CTL|RX_CTL|TXEN|RXDV|GTX)", u):
|
||
return "rgmii"
|
||
if re.search(r"(?:^|[_/])ETH(?:$|[_/])", u) and re.search(r"[+\-]|_P$|_N$|_P/|_N/", leaf):
|
||
return "eth_mdi"
|
||
for cls, cre in _HS_CLASS_RE:
|
||
if cre.search(leaf):
|
||
return cls
|
||
if re.search(r"DDR", u):
|
||
return "ddr3_dq"
|
||
return None
|
||
|
||
|
||
def _norm_bus_token(raw: str) -> str:
|
||
t = re.sub(r"[^a-z0-9]+", "_", (raw or "").strip().lower()).strip("_")
|
||
t = t.replace("usb_2_0", "usb2").replace("usb2_0", "usb2")
|
||
t = t.replace("usb_3_1", "usb3").replace("usb_3_0", "usb3").replace("usb3_0", "usb3")
|
||
return t
|
||
|
||
|
||
def _expand_bus_token(raw: str) -> frozenset[str]:
|
||
t = _norm_bus_token(raw)
|
||
if not t:
|
||
return frozenset()
|
||
if t in _BUS_TOKEN_EXPAND:
|
||
return _BUS_TOKEN_EXPAND[t]
|
||
for key, buses in _BUS_TOKEN_EXPAND.items():
|
||
if t == key or t.startswith(key + "_") or key.startswith(t + "_"):
|
||
return buses
|
||
return frozenset({t})
|
||
|
||
|
||
def _is_strap_si_rule(rule: dict) -> bool:
|
||
"""EN / CHIP_PU RC, strap, ILIM — not a USB/DDR/PHY series R."""
|
||
blob = " ".join(
|
||
str(rule.get(k) or "") for k in ("pin", "net_class", "note", "parameter")
|
||
)
|
||
if _STRAP_RULE_RE.search(blob):
|
||
return True
|
||
kind = str(rule.get("kind") or "")
|
||
if kind == "series_resistor" and re.search(r"[µu]F", blob, re.I):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _rule_target_buses(rule: dict) -> frozenset[str]:
|
||
nc = str(rule.get("net_class") or "").strip()
|
||
note = str(rule.get("note") or "")
|
||
pin = str(rule.get("pin") or "")
|
||
found: set[str] = set()
|
||
if nc:
|
||
found |= set(_expand_bus_token(nc))
|
||
blob = f"{nc} {note} {pin}"
|
||
scans: tuple[tuple[str, str], ...] = (
|
||
(r"super\s*speed|usb\s*3|sstx|ssrx", "usb3"),
|
||
(r"rgmii|gtx_clk|tx_ctl|rx_ctl", "rgmii"),
|
||
(r"sgmii", "sgmii"),
|
||
(r"mdi|rj-?45|magnetics|trd[0-3]|1000\s*base", "eth_mdi"),
|
||
(r"ddr3|\bddr\b", "ddr3"),
|
||
(r"hdmi", "hdmi"),
|
||
(r"pcie|pci[\s-]*express", "pcie"),
|
||
(r"lvds", "lvds"),
|
||
(r"usb\s*2|d\s*\+|d\s*−|d\s*-|dp\s*/\s*dm|dp/dm", "usb2"),
|
||
(r"\busb\b", "usb2"),
|
||
(r"\bethernet\b|\beth\b", "eth_mdi"),
|
||
)
|
||
for pat, token in scans:
|
||
if re.search(pat, blob, re.I):
|
||
found |= set(_expand_bus_token(token))
|
||
if "usb3" in found:
|
||
found.discard("usb2")
|
||
return frozenset(found)
|
||
|
||
|
||
def _bus_in_targets(bc: str, targets: frozenset[str]) -> bool:
|
||
if not bc or not targets:
|
||
return False
|
||
if bc in targets:
|
||
return True
|
||
for t in targets:
|
||
if bc.startswith(t + "_") or t.startswith(bc + "_"):
|
||
return True
|
||
if t == "ddr3" and bc.startswith("ddr3"):
|
||
return True
|
||
return False
|
||
|
||
|
||
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 []:
|
||
kind = str(rule.get("kind") or "")
|
||
if kind not in _SI_KINDS:
|
||
continue
|
||
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]:
|
||
if _is_strap_si_rule(rule):
|
||
return []
|
||
names = {s.net for s in layout.segments if s.net}
|
||
pin = str(rule.get("pin") or "").strip()
|
||
ic = rule.get("_ic")
|
||
ic_nets: set[str] = rule.get("_ic_nets") or set()
|
||
targets = _rule_target_buses(rule)
|
||
picked: list[str] = []
|
||
for net in sorted(names):
|
||
if skip_si_net(net):
|
||
continue
|
||
bc = bus_class(net)
|
||
if not bc:
|
||
continue
|
||
on_ic = net in ic_nets or any(kicad_nets_match(net, n) for n in ic_nets)
|
||
if not on_ic:
|
||
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 targets:
|
||
if not _bus_in_targets(bc, targets):
|
||
continue
|
||
else:
|
||
# No bus on the quote: pin-scoped only. Never paint USB/DDR/PHY.
|
||
if not pin:
|
||
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 []
|
||
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] = []
|
||
rules = _collect_rules(graph, constraints_map)
|
||
seen: set[tuple] = set()
|
||
z_covered: set[str] = 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
|
||
paired.add(key_net)
|
||
sig = ("z", key_net, id(rule))
|
||
if sig in seen:
|
||
continue
|
||
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
|
||
bc = bus_class(net)
|
||
if bc:
|
||
z_covered.add(bc)
|
||
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)]
|
||
z_target = frozenset(z_covered)
|
||
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
|
||
if bc in z_covered or _bus_in_targets(bc, z_target):
|
||
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 = (
|
||
"Re-run schematic review so pintable extract ≥ 1.13.0 fills "
|
||
"layout_rules impedance for this bus (net_class required). Do not "
|
||
"assume 90 Ω. PCB does not re-read the PDF."
|
||
)
|
||
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 impedance FACT for this bus."
|
||
),
|
||
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 on this bus (none on file).",
|
||
inference="Not USB/IEC 90 Ω folklore; strap/EN RC and CC/GPIO/I2C are not this check.",
|
||
why="Insufficient library SI numbers for this bus.",
|
||
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
|