"""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