Package/pinout/ratings mismatches, SPOF as REVIEW, EMI only with a datasheet quote, and Tj from copper+vias+θJA when every parameter exists. Re-extract SI layout_rules from 1.12.0; ImpedenceFinder license stays UNKNOWN.
678 lines
27 KiB
Python
678 lines
27 KiB
Python
"""SI checks: datasheet layout_rules vs board + ImpedenceFinder.
|
||
|
||
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 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 = (
|
||
("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:
|
||
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)
|
||
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 []:
|
||
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]:
|
||
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
|
||
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 []
|
||
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()
|
||
|
||
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
|
||
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 = (
|
||
"Re-run schematic review so pintable extract ≥ 1.12.0 fills "
|
||
"layout_rules (Z0/skew/spacing). 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 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
|