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