diff --git a/backend/periscopex/finding_engine.py b/backend/periscopex/finding_engine.py index 67d71ea..826550d 100644 --- a/backend/periscopex/finding_engine.py +++ b/backend/periscopex/finding_engine.py @@ -133,6 +133,22 @@ def _seed() -> None: ("PE-SI-001", "Differential skew vs length_match mm."), ): _add(rid, "MANDATORY", "RULE", domain="pcb", requirement=req) + _add("PE-SI-002", "RECOMMENDED", "RISK", domain="pcb", + requirement="Diff/SE Z0 vs datasheet window (ImpedenceFinder avg/min/max).") + _add("PE-SI-003", "RECOMMENDED", "RISK", domain="pcb", + requirement="Max routed length vs datasheet millimetres.") + _add("PE-SI-004", "RECOMMENDED", "RISK", domain="pcb", + requirement="Intra-pair spacing vs datasheet millimetres.") + _add("PE-SI-005", "RECOMMENDED", "RISK", domain="pcb", + requirement="Reference plane / topology vs datasheet.") + _add("PE-SI-006", "RECOMMENDED", "RISK", domain="pcb", + requirement="HS via count vs datasheet min/max.") + _add("PE-SI-008", "RECOMMENDED", "REVIEW", domain="pcb", + requirement="Return via next to an impedance-controlled pair.") + _add("PE-SI-009", "RECOMMENDED", "RISK", domain="pcb", + requirement="Series resistor on the HS net vs datasheet ohms.") + _add("PE-SI-010", "TYPICAL", "INFO", domain="pcb", + requirement="HS bus measured; library has no SI FACT — not 90 Ω folklore.") _add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb", requirement="Keepout from layout_rules — not a DRC for pad copper.") _add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb", diff --git a/backend/periscopex/layout_rules.py b/backend/periscopex/layout_rules.py index 23c3669..4e670dc 100644 --- a/backend/periscopex/layout_rules.py +++ b/backend/periscopex/layout_rules.py @@ -6,7 +6,11 @@ from typing import Any from packaging.version import Version -KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout", "length_match"}) +KNOWN_KINDS = frozenset({ + "decoupling_proximity", "thermal_via", "keepout", "length_match", + "impedance", "max_length", "spacing", "ref_plane", "si_via", + "layer", "series_resistor", "return_path", "si", +}) def _num(v: Any) -> float | None: @@ -79,6 +83,8 @@ def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]: via_i = int(n) if n is not None else None page = row.get("source_page") page_i = int(page) if isinstance(page, int) else None + mx_via = row.get("max_via_count") + mx_via_i = int(mx_via) if isinstance(mx_via, int) and not isinstance(mx_via, bool) else None ok.append({ "kind": kind, "pin": row.get("pin"), @@ -86,8 +92,19 @@ def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]: "max_distance_mm": dist, "same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None, "min_via_count": via_i, + "max_via_count": mx_via_i, "net_class": row.get("net_class"), "note": row.get("note"), "source_page": page_i, + "z0_ohm": _num(row.get("z0_ohm")), + "zdiff_ohm": _num(row.get("zdiff_ohm")), + "tolerance_pct": _num(row.get("tolerance_pct")), + "z_min_ohm": _num(row.get("z_min_ohm")), + "z_max_ohm": _num(row.get("z_max_ohm")), + "topology": row.get("topology") if isinstance(row.get("topology"), str) else None, + "min_spacing_mm": _num(row.get("min_spacing_mm")), + "value_ohms": _num(row.get("value_ohms")), + "ref_plane": row.get("ref_plane") if isinstance(row.get("ref_plane"), str) else None, + "parameter": row.get("parameter") if isinstance(row.get("parameter"), str) else None, }) return ok, errors diff --git a/backend/periscopex/pcb_checks.py b/backend/periscopex/pcb_checks.py index ad914ae..fe4e706 100644 --- a/backend/periscopex/pcb_checks.py +++ b/backend/periscopex/pcb_checks.py @@ -174,13 +174,14 @@ def run_pcb_checks( constraints_map: dict, layout: LayoutGraph | None, plan: FunctionalGroupsReport | None = None, + impedance_nets: list[dict] | dict | None = None, ) -> list[Finding]: """Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD.""" out: list[Finding] = [] for name, fn in ( ("pcb_net_match", lambda: check_pcb_net_match(graph, layout)), ("placement_check", lambda: check_placement(graph, constraints_map, layout)), - ("si_check", lambda: check_si(graph, constraints_map, layout)), + ("si_check", lambda: check_si(graph, constraints_map, layout, impedance_nets)), ("pcb_derating", lambda: check_pcb_derating(graph)), ("pcb_power_traces", lambda: check_pcb_power_traces(graph, constraints_map, layout)), ("pcb_via_current", lambda: check_pcb_via_current(graph, constraints_map, layout)), diff --git a/backend/periscopex/si_check.py b/backend/periscopex/si_check.py index 29ad7b7..ce60c22 100644 --- a/backend/periscopex/si_check.py +++ b/backend/periscopex/si_check.py @@ -1,17 +1,47 @@ -"""G1 SI: intra-pair skew only when the datasheet gives millimetres. +"""SI checks: datasheet layout_rules vs board + ImpedenceFinder. -Pair names (_DP/_DM, _P/_N) only identify which nets to compare. The -limit is never 3W, USB spec folklore, or a default millimetre. +USB / HDMI / PCIe / Ethernet / LVDS / DDR (and any net_class on an +``impedance`` / ``length_match`` / … rule) are checked. I2C, GPIO, EN, +analog REGN, USB CC are not 50 Ω pairs. No invented USB/IEC Z0. """ from __future__ import annotations import math +import re -from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutSegment +from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph, LayoutSegment +from backend.periscopex.pcb_net_match import kicad_nets_match, normalize_kicad_hierarchy_net +from backend.periscopex.pcb_power_thermal import _is_gnd_name from backend.periscopex.validate import _match_constraints -_PAIR_SUFFIXES = (("_DP", "_DM"), ("_P", "_N"), ("+", "-")) +_PAIR_SUFFIXES = ( + ("_DP", "_DM"), + ("_P", "_N"), + ("+", "-"), + ("_D+", "_D-"), + (".D+", ".D-"), +) + +_SKIP_RE = re.compile( + r"(?:^|[_/.\-])(SDA|SCL|I2C|GPIO\d*|GP\d+|EN|ENABLE|NRST|RST|REGN|" + r"CC[12]|SBU|ID|VBUS|VBAT|LED|ADC|NTC|STRAP)(?:$|[_/.\-]|\d)", + re.I, +) + +_HS_CLASS_RE = ( + ("usb", re.compile(r"USB", re.I)), + ("hdmi", re.compile(r"HDMI", re.I)), + ("pcie", re.compile(r"PCIE|PEX_", re.I)), + ("ethernet", re.compile(r"(?:^|[_/])(ETH|MDI|TRD[0-3])", re.I)), + ("lvds", re.compile(r"LVDS", re.I)), + ("ddr", re.compile(r"DDR|DQS|(?:^|[_/])DQ\d+", re.I)), +) + +_SI_KINDS = frozenset({ + "impedance", "length_match", "max_length", "spacing", + "ref_plane", "si_via", "layer", "series_resistor", "return_path", "si", +}) def _seg_len(seg: LayoutSegment) -> float: @@ -19,73 +49,628 @@ def _seg_len(seg: LayoutSegment) -> float: def net_length_mm(layout: LayoutGraph, net: str) -> float: - return sum(_seg_len(s) for s in layout.segments if s.net == net) + return sum( + _seg_len(s) for s in layout.segments + if s.net and kicad_nets_match(s.net, net) + ) def partner_net(name: str) -> str | None: + n = name or "" for a, b in _PAIR_SUFFIXES: - if name.endswith(a): - return name[: -len(a)] + b - if name.endswith(b): - return name[: -len(b)] + a + if n.endswith(a): + return n[: -len(a)] + b + if n.endswith(b): + return n[: -len(b)] + a return None -def _length_match_limit_mm(constraints_map: dict, graph: DesignGraph) -> tuple[float, int | None] | None: - for comp in graph.components.values(): - cons = _match_constraints(comp.mpn, constraints_map) +def _leaf(net: str) -> str: + n = normalize_kicad_hierarchy_net(net) + return n.split("/")[-1] if n else "" + + +def skip_si_net(net: str) -> bool: + """I2C / GPIO / EN / analog / USB-CC — not impedance-controlled pairs.""" + leaf = _leaf(net) + if not leaf: + return True + if re.search(r"CC[12]", leaf, re.I): + return True + return bool(_SKIP_RE.search(leaf)) + + +def bus_class(net: str) -> str | None: + if skip_si_net(net): + return None + leaf = _leaf(net) + if "USB" in leaf.upper() and re.search(r"(D\+|D-|DP|DM)", leaf, re.I): + return "usb" + for cls, cre in _HS_CLASS_RE: + if cls == "usb": + continue + if cre.search(leaf): + return cls + return None + + +def _is_hs_net(net: str) -> bool: + return bus_class(net) is not None + + +def _z_row(rows: list[dict] | None, net: str) -> dict | None: + for row in rows or []: + if not isinstance(row, dict) or row.get("error"): + continue + name = str(row.get("net_name") or row.get("name") or "") + if name and kicad_nets_match(name, net): + return row + return None + + +def _z_field(row: dict | None, *keys: str) -> float | None: + if not row: + return None + for k in keys: + v = row.get(k) + if isinstance(v, (int, float)): + return float(v) + return None + + +def _num(v) -> float | None: + if v is None or isinstance(v, bool): + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _z_window(rule: dict) -> tuple[float, float] | None: + lo = _num(rule.get("z_min_ohm")) + hi = _num(rule.get("z_max_ohm")) + if lo is not None and hi is not None and hi >= lo: + return (lo, hi) + nom = _num(rule.get("zdiff_ohm")) or _num(rule.get("z0_ohm")) + tol = _num(rule.get("tolerance_pct")) + if nom is not None and tol is not None and tol > 0: + return (nom * (1 - tol / 100.0), nom * (1 + tol / 100.0)) + return None + + +def _quote(rule: dict) -> str: + note = (rule.get("note") or "").strip() + page = rule.get("source_page") + bits = [note] if note else [] + if page is not None: + bits.append(f"p.{page}") + return "; ".join(bits) or "layout_rules" + + +def _collect_rules(graph: DesignGraph, constraints_map: dict) -> list[tuple[str, dict]]: + out: list[tuple[str, dict]] = [] + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn or comp.value, constraints_map) if not cons: continue + ic_nets = {n for n in (comp.pins.values()) if n} for rule in cons.layout_rules or []: - if rule.get("kind") != "length_match": + kind = str(rule.get("kind") or "") + if kind not in _SI_KINDS: continue - mm = rule.get("max_distance_mm") - if mm is None: + out.append((ref, {**rule, "_ic": ref, "_ic_nets": ic_nets, "_mpn": comp.mpn or ""})) + return out + + +def _rule_nets(layout: LayoutGraph, rule: dict, graph: DesignGraph) -> list[str]: + names = {s.net for s in layout.segments if s.net} + nc = str(rule.get("net_class") or "").strip().lower() + pin = str(rule.get("pin") or "").strip() + ic = rule.get("_ic") + ic_nets: set[str] = rule.get("_ic_nets") or set() + picked: list[str] = [] + for net in sorted(names): + if skip_si_net(net): + continue + 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 - return float(mm), rule.get("source_page") + else: + on_ic = net in ic_nets or any(kicad_nets_match(net, n) for n in ic_nets) + if not on_ic or not _is_hs_net(net): + continue + picked.append(net) + return picked + + +def _pair_key(a: str, b: str) -> tuple[str, str]: + return tuple(sorted((a, b))) # type: ignore[return-value] + + +def _partner_on_board(layout: LayoutGraph, net: str, zrow: dict | None) -> str | None: + names = {s.net for s in layout.segments if s.net} + if zrow: + p = zrow.get("partner_net_name") + if isinstance(p, str) and p: + for n in names: + if kicad_nets_match(p, n) and not skip_si_net(n): + return n + p = partner_net(net) + if p: + for n in names: + if kicad_nets_match(p, n) and not skip_si_net(n): + return n return None +def _min_pair_spacing_mm(layout: LayoutGraph, a: str, b: str) -> float | None: + sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a)] + sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b)] + best = None + for x in sa: + wx = x.width / 2.0 + for y in sb: + wy = y.width / 2.0 + for px, py in ( + (x.start, y.start), (x.start, y.end), + (x.end, y.start), (x.end, y.end), + ): + d = math.hypot(px[0] - py[0], px[1] - py[1]) - wx - wy + if best is None or d < best: + best = d + return best + + +def _via_count(layout: LayoutGraph, net: str) -> int: + return sum(1 for v in layout.vias if v.net and kicad_nets_match(v.net, net)) + + +def _layers(layout: LayoutGraph, net: str) -> list[str]: + return sorted({ + s.layer for s in layout.segments + if s.net and kicad_nets_match(s.net, net) and s.layer + }) + + +def _series_resistors(graph: DesignGraph, net: str) -> list[tuple[str, float | None]]: + from backend.periscopex.models import ResistorSpecs + from backend.periscopex.pcb_net_match import refs_on_matched_net + + out: list[tuple[str, float | None]] = [] + for ref in refs_on_matched_net(graph, net): + c = graph.components.get(ref) + if not c or c.component_type != ComponentType.RESISTOR: + continue + ohms = None + if isinstance(c.specs, ResistorSpecs) and c.specs.value_ohms: + ohms = float(c.specs.value_ohms) + out.append((ref, ohms)) + return out + + +def _gnd_via_in_net_bbox(layout: LayoutGraph, net: str) -> bool: + segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)] + if not segs: + return False + xs = [c for s in segs for c in (s.start[0], s.end[0])] + ys = [c for s in segs for c in (s.start[1], s.end[1])] + xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys) + pad = 2.0 + return any( + v.net and _is_gnd_name(v.net) + and xmin - pad <= v.x <= xmax + pad and ymin - pad <= v.y <= ymax + pad + for v in layout.vias + ) + + +def _si_finding( + *, + rule_id: str, + net: str, + mpn: str, + designator: str, + verdict: str, + finding: str, + facts: str, + requirement: str, + source_page: int | None, + quote: str, + rec: str, + provenance: str, + finding_class: str, +) -> Finding: + status = {"PASS": "INFO", "MARGIN": "WARNING", "FAIL": "WARNING"}[verdict] + if verdict == "FAIL" and provenance == "MANDATORY" and finding_class == "RULE": + status = "ERROR" + return Finding( + designator=designator or "layout", + mpn=mpn, + aspect="si", + finding=f"{verdict}: {finding}", + facts=facts, + requirement=requirement, + inference="" if verdict == "PASS" else f"{verdict} vs datasheet layout_rules.", + why=requirement, + status=status, # type: ignore[arg-type] + recommendation=rec, + action=rec, + source="si_check", + rule_id=rule_id, + finding_class=finding_class, # type: ignore[arg-type] + provenance=provenance, # type: ignore[arg-type] + evidence_status="SUFFICIENT", + net=net, + pins=[], + source_page=source_page, + source_quote=quote, + ) + + +def _param(rule: dict) -> str: + if rule.get("kind") == "si": + return str(rule.get("parameter") or "").lower() + return str(rule.get("kind") or "") + + def check_si( graph: DesignGraph, constraints_map: dict, layout: LayoutGraph | None, + impedance_nets: list[dict] | dict | None = None, ) -> list[Finding]: if layout is None or not layout.segments: return [] - limit = _length_match_limit_mm(constraints_map, graph) - if limit is None: - return [] - max_mm, page = limit - seen: set[tuple[str, str]] = set() + raw = impedance_nets + if isinstance(impedance_nets, dict): + raw = list(impedance_nets.get("nets") or []) + rows: list[dict] = [r for r in (raw or []) if isinstance(r, dict)] + findings: list[Finding] = [] - names = {s.net for s in layout.segments if s.net} - for net in names: - partner = partner_net(net) - if not partner or partner not in names: - continue - key = tuple(sorted((net, partner))) - if key in seen: - continue - seen.add(key) - skew = abs(net_length_mm(layout, net) - net_length_mm(layout, partner)) - if skew <= max_mm: - continue - findings.append(Finding( - designator="layout", - mpn="", - aspect="si", - finding=( - f"Intra-pair skew {skew:.1f} mm on {key[0]}/{key[1]} " - f"(datasheet max {max_mm:g} mm)." - ), - why=f"length_match max_distance_mm={max_mm:g}.", - status="ERROR", - recommendation="Length-match the differential pair.", - source="si_check", - rule_id="PE-SI-001", - net=net, - pins=[], - source_page=page, - )) + 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 = ( + "Extract Z0/skew/spacing/vias/layer from the PHY/module datasheet " + "into layout_rules, then re-run. Do not assume 90 Ω." + ) + findings.append(Finding( + designator="layout", + mpn="", + aspect="si", + finding=( + f"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 diff --git a/backend/services/extraction.py b/backend/services/extraction.py index 0a35c1b..e58749f 100644 --- a/backend/services/extraction.py +++ b/backend/services/extraction.py @@ -137,9 +137,13 @@ PINTABLE_TOOL = { "type": "array", "description": ( "PCB layout constraints from typical-application / PCB layout pages. " - "kind: decoupling_proximity | thermal_via | keepout | length_match. " + "kind: decoupling_proximity | thermal_via | keepout | length_match | " + "impedance | max_length | spacing | ref_plane | si_via | layer | " + "series_resistor | return_path | si. " "Fields: pin, cap_value_hint, max_distance_mm (ONLY if the PDF states a " "number — never invent 3 mm/JEDEC), same_layer (bool), min_via_count, " + "max_via_count, z0_ohm, zdiff_ohm, tolerance_pct, z_min_ohm, z_max_ohm, " + "topology, min_spacing_mm, value_ohms, ref_plane, parameter, " "net_class, note, source_page. Empty array if the PDF has no layout guidance." ), "items": { @@ -152,6 +156,15 @@ PINTABLE_TOOL = { "thermal_via", "keepout", "length_match", + "impedance", + "max_length", + "spacing", + "ref_plane", + "si_via", + "layer", + "series_resistor", + "return_path", + "si", ], }, "pin": {"type": ["string", "null"]}, @@ -159,9 +172,20 @@ PINTABLE_TOOL = { "max_distance_mm": {"type": ["number", "null"]}, "same_layer": {"type": ["boolean", "null"]}, "min_via_count": {"type": ["integer", "null"]}, + "max_via_count": {"type": ["integer", "null"]}, "net_class": {"type": ["string", "null"]}, "note": {"type": ["string", "null"]}, "source_page": {"type": ["integer", "null"]}, + "z0_ohm": {"type": ["number", "null"]}, + "zdiff_ohm": {"type": ["number", "null"]}, + "tolerance_pct": {"type": ["number", "null"]}, + "z_min_ohm": {"type": ["number", "null"]}, + "z_max_ohm": {"type": ["number", "null"]}, + "topology": {"type": ["string", "null"]}, + "min_spacing_mm": {"type": ["number", "null"]}, + "value_ohms": {"type": ["number", "null"]}, + "ref_plane": {"type": ["string", "null"]}, + "parameter": {"type": ["string", "null"]}, }, "required": ["kind"], }, diff --git a/backend/services/pcb_pipeline.py b/backend/services/pcb_pipeline.py index 231af1f..83dd6e2 100644 --- a/backend/services/pcb_pipeline.py +++ b/backend/services/pcb_pipeline.py @@ -183,15 +183,19 @@ async def run_pcb_pipeline( _step(project_id, "inventory", "running") z0_by_net: dict[str, float] = {} + zrep: dict = {"nets": [], "skipped": None} try: from backend.periscopex.impedance_traces import analyze_where_needed zrep = analyze_where_needed(layout, graph) + zpath = ws.local_path("impedance_nets.json") + zpath.write_text(json.dumps(zrep, indent=2) + "\n") + ws._upload_file("impedance_nets.json") for row in zrep.get("nets") or []: if not isinstance(row, dict) or row.get("error"): continue name = str(row.get("net_name") or row.get("name") or "") - z = row.get("z0_ohm") or row.get("mean_z0") or row.get("z0") + z = row.get("z0_avg_ohms") or row.get("z0_ohm") or row.get("mean_z0") or row.get("z0") if name and isinstance(z, (int, float)): z0_by_net[name] = float(z) except Exception: @@ -215,7 +219,7 @@ async def run_pcb_pipeline( return _step(project_id, "checks", "running") - findings = run_pcb_checks(graph, cmap, layout, plan) + findings = run_pcb_checks(graph, cmap, layout, plan, zrep) _step( project_id, "checks", "complete", f"{len(findings)} deterministic findings", diff --git a/backend/skills_manifest.json b/backend/skills_manifest.json index 8d4555a..b4e9424 100644 --- a/backend/skills_manifest.json +++ b/backend/skills_manifest.json @@ -1,5 +1,5 @@ { - "default_model_version": "1.10.0", + "default_model_version": "1.11.0", "extract-pintable": { "skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY", "latest_version": "1784798970179642", diff --git a/docs/piano-pcb-review.md b/docs/piano-pcb-review.md index c64d163..7c905d8 100644 --- a/docs/piano-pcb-review.md +++ b/docs/piano-pcb-review.md @@ -54,7 +54,10 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net | --- | --- | | PE-LAY-001…003 | pad/ref/hierarchy nets | | PE-PLC-001…004 | `layout_rules` numerici | -| PE-SI-001 | `length_match` mm | +| PE-SI-001 | `length_match` mm on USB/HDMI/PCIe/ETH/LVDS/DDR nets (not I2C/GPIO/CC) | +| PE-SI-002 | ImpedenceFinder Zavg/min/max vs `impedance` window | +| PE-SI-003…009 | max length, spacing, ref plane, vias, return, series R — each a datasheet number | +| PE-SI-010 | HS bus measured, library has no SI FACT (not 90 Ω folklore) | | PE-PWR-001 | I_load + width; IPC solo con thickness e Tjmax | | PE-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR | | PE-DRT-002 | 0.8 < Vop/Vr ≤ 1 → RISK MARGIN | @@ -90,7 +93,22 @@ AI PCB: skip IC senza pintable (`run schematic review first`). | PE-ESD-001 | ESD connettore/USB | REVIEW se manca parte ESD | | PE-RET-001 | return HS senza via GND | REVIEW, non ERROR | -**B — ancora aperta:** energy/thermal FEM; SI oltre skew mm; BOM↔PCB↔datasheet completo; SPOF; EMI oltre ESD REVIEW. +**B — SI (shipped, gated):** ImpedenceFinder is **in the checks**, not only the impedance tab dump. Each datasheet SI requirement is one finding: PASS / FAIL / MARGIN + FACT / REQUIREMENT / source. Buses: USB D+/D−, HDMI, PCIe, Ethernet, LVDS, DDR. **Skip** I2C, GPIO, EN, analog REGN, USB CC (not 90 Ω pairs). No invented 50/90 Ω. + +Emmaforo USB verdict: if library has e.g. 90 Ω ±10% (81–99), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min (~46 Ω) is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists and 2.5 > limit. Without library Z, **PE-SI-010** INFO (insufficient) — not a 90 Ω ERROR. + +**B — still missing (close-out):** + +| Gap | Why it is not this slice | +| --- | --- | +| Thermal FEM / energy | PE-THM-001 is courtyard vias/pour vs P=I×drop, not a field solve | +| SPOF | No single-point-of-failure / redundancy graph check | +| BOM ↔ PCB ↔ datasheet chain | Pad-net + MPN library; no full three-way BOM audit | +| ImpedenceFinder license | Vendored closed-form core (`vendor/impedancefinder`, SOURCE.md); **no LICENSE file in-tree** — confirm upstream license before commercial redistribution | +| CPWG / field solver | OpenEMS export explicitly excluded from the vendor snapshot | +| EMI beyond ESD REVIEW | PE-ESD-001 is REVIEW, not IEC 61000 | +| SI extraction coverage | New `layout_rules` kinds need a re-extract (`model_version` 1.11.0); old library JSON has no Z/skew until pintable is refreshed | +| Auto-place / pcbnew write-back | Fase C | **C — fuori:** auto-place, write-back pcbnew, unire i job, sshd/keys, PinScope identity/fork. diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index fdb8f18..0bcea90 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,7 +2,15 @@ What's new in Periscope. -## 2.33.2 — 2026-09-20 — PCB complete opens the findings tree +## 2.34.0 — 2026-09-20 — ImpedenceFinder SI checks (not a dump table) + +USB/HDMI/PCIe/ETH/LVDS/DDR layout_rules (diff/SE Z, skew, max length, spacing, ref plane, vias, layer, return, series R) are compared to ImpedenceFinder + copper. One PASS/FAIL/MARGIN finding per requirement. I2C/GPIO/CC/REGN are not 50 Ω. + +- [New] `PE-SI-002`…`PE-SI-010`; ImpedenceFinder `z0_avg/min/max` in `run_pcb_checks`. +- [Changed] `PE-SI-001` only on impedance-controlled buses matching the rule `net_class`. +- [Changed] extract-pintable `layout_rules` kinds for SI; `default_model_version` 1.11.0. + + A finished PCB exam no longer shows the raw watcher string `pcb_status=complete (terminal)`. The SSE hatch emits `pcb_complete`; the PCB page then opens `/report?domain=layout` (expand-on-click tree). diff --git a/skills/extract-pintable/SKILL.md b/skills/extract-pintable/SKILL.md index 26402b0..9595596 100644 --- a/skills/extract-pintable/SKILL.md +++ b/skills/extract-pintable/SKILL.md @@ -60,6 +60,16 @@ You **must** look for layout guidance. Emit `layout_rules` as a list. Use `[]` o | `thermal_via` | Vias under exposed pad / thermal pad / EP | | `keepout` | Keep foreign nets, digital return, or copper out of a region | | `length_match` | Intra-pair skew / matched length limit in mm | +| `impedance` | Single-ended `z0_ohm` or differential `zdiff_ohm` (plus `tolerance_pct` or `z_min_ohm`/`z_max_ohm`) | +| `max_length` | Maximum routed length in mm | +| `spacing` | Intra-pair / coupling gap (`min_spacing_mm`) | +| `ref_plane` | Required reference plane (`ref_plane`, `topology`) | +| `si_via` | Min/max vias on the HS net | +| `layer` | Required copper layer / topology | +| `series_resistor` | Series R on the HS net (`value_ohms`) | +| `return_path` | GND return via next to the pair | + +Do **not** emit impedance/50 Ω rules for I2C, GPIO, EN, analog REGN, or USB CC. Do **not** invent USB 90 Ω unless **this** datasheet states a number. #### Fields - `pin` — number or name as printed (`"5"`, `"VIN"`, `"VDD"`, `"EP"`) @@ -116,7 +126,8 @@ Thermal vias: #### Hard negatives - Do not invent land-pattern pad sizes from the mechanical drawing alone -- Do not emit `length_match` for USB/HDMI/PCIe unless the **this** datasheet states a skew/length number +- Do not emit `length_match` or `impedance` for USB/HDMI/PCIe unless **this** datasheet states a skew/Z number +- Do not treat I2C, GPIO, EN, analog, or USB-CC as 50 Ω / 90 Ω pairs - Do not use kinds outside the closed set - One rule per distinct pin/guidance; prefer supply pins that show caps in the application figure diff --git a/skills/extract-pintable/schema.json b/skills/extract-pintable/schema.json index 3bc5501..a1729c2 100644 --- a/skills/extract-pintable/schema.json +++ b/skills/extract-pintable/schema.json @@ -66,7 +66,16 @@ "decoupling_proximity", "thermal_via", "keepout", - "length_match" + "length_match", + "impedance", + "max_length", + "spacing", + "ref_plane", + "si_via", + "layer", + "series_resistor", + "return_path", + "si" ] }, "pin": {"type": ["string", "null"]}, @@ -74,9 +83,20 @@ "max_distance_mm": {"type": ["number", "null"]}, "same_layer": {"type": ["boolean", "null"]}, "min_via_count": {"type": ["integer", "null"]}, + "max_via_count": {"type": ["integer", "null"]}, "net_class": {"type": ["string", "null"]}, "note": {"type": ["string", "null"]}, - "source_page": {"type": ["integer", "null"]} + "source_page": {"type": ["integer", "null"]}, + "z0_ohm": {"type": ["number", "null"]}, + "zdiff_ohm": {"type": ["number", "null"]}, + "tolerance_pct": {"type": ["number", "null"]}, + "z_min_ohm": {"type": ["number", "null"]}, + "z_max_ohm": {"type": ["number", "null"]}, + "topology": {"type": ["string", "null"]}, + "min_spacing_mm": {"type": ["number", "null"]}, + "value_ohms": {"type": ["number", "null"]}, + "ref_plane": {"type": ["string", "null"]}, + "parameter": {"type": ["string", "null"]} }, "required": ["kind"] } diff --git a/skills/extract-pintable/validate.py b/skills/extract-pintable/validate.py index 8aa9ecb..90dfcc0 100644 --- a/skills/extract-pintable/validate.py +++ b/skills/extract-pintable/validate.py @@ -85,7 +85,11 @@ def validate(data: dict) -> list[str]: if not isinstance(data["layout_rules"], list): errors.append("layout_rules must be an array") else: - kinds = {"decoupling_proximity", "thermal_via", "keepout", "length_match"} + kinds = { + "decoupling_proximity", "thermal_via", "keepout", "length_match", + "impedance", "max_length", "spacing", "ref_plane", "si_via", + "layer", "series_resistor", "return_path", "si", + } for i, row in enumerate(data["layout_rules"]): if not isinstance(row, dict): errors.append(f"layout_rules[{i}] must be an object") diff --git a/tests/test_layout_rules.py b/tests/test_layout_rules.py index 01be11a..3271683 100644 --- a/tests/test_layout_rules.py +++ b/tests/test_layout_rules.py @@ -18,7 +18,18 @@ def test_numeric_max_distance_mm_is_kept(): assert ok[0]["max_distance_mm"] == given -def test_length_match_keeps_max_distance_mm_parameter(): +def test_impedance_kind_keeps_zdiff_window(): + ok, errors = validate_layout_rules([{ + "kind": "impedance", + "zdiff_ohm": 90, + "tolerance_pct": 10, + "net_class": "usb", + "source_page": 12, + }]) + assert errors == [] + assert ok[0]["zdiff_ohm"] == 90 + assert ok[0]["tolerance_pct"] == 10 + assert ok[0]["net_class"] == "usb" given = 2.0 ok, errors = validate_layout_rules([ {"kind": "length_match", "max_distance_mm": given}, diff --git a/tests/test_si_check.py b/tests/test_si_check.py index ff32751..ba9c078 100644 --- a/tests/test_si_check.py +++ b/tests/test_si_check.py @@ -2,6 +2,7 @@ Favor: real /USB.D+ and /USB.D- pair by suffix; eval stays 3 keys. Against: no .kicad_pcb → no PE-SI-001; 3W is not invented. +I2C/GPIO/CC are not 50/90 Ω. ImpedenceFinder numbers vs layout_rules only. """ from __future__ import annotations @@ -9,8 +10,21 @@ from __future__ import annotations from pathlib import Path from backend.periscopex.eval_report import eval_simple_project -from backend.periscopex.models import DesignGraph -from backend.periscopex.si_check import check_si, partner_net +from backend.periscopex.finding_engine import complete_finding +from backend.periscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + LayoutGraph, + LayoutSegment, + LayoutVia, + Net, + NetType, + Pin, + PinConnection, +) +from backend.periscopex.si_check import bus_class, check_si, partner_net, skip_si_net SIMPLE = Path(__file__).resolve().parents[1] / "simple_project" @@ -45,3 +59,159 @@ def test_simple_project_eval_has_no_si_keys(): k.startswith("PE-SI-") or k.startswith("PE-3W-") for k in scores.extra_keys ) + + +def test_skip_i2c_gpio_cc_regn(): + assert skip_si_net("I2C_SDA") + assert skip_si_net("GPIO9") + assert skip_si_net("USB_CC1") + assert skip_si_net("/power/REGN") + assert skip_si_net("ESP32_EN") + assert not skip_si_net("USB_D+") + assert not skip_si_net("USB_DP") + assert bus_class("USB_D+") == "usb" + assert bus_class("USB_CC1") is None + assert bus_class("I2C_SCL") is None + + +def _usb_graph() -> DesignGraph: + return DesignGraph( + components={ + "U1": Component( + reference="U1", value="PHY", footprint="", + component_type=ComponentType.IC, mpn="PHY", + pins={"1": "USB_D+", "2": "USB_D-", "3": "USB_CC1", "4": "I2C_SDA"}, + ), + }, + nets={ + "USB_D+": Net(name="USB_D+", net_type=NetType.SIGNAL, pins=[ + PinConnection(component_ref="U1", pin_number="1"), + ]), + "USB_D-": Net(name="USB_D-", net_type=NetType.SIGNAL, pins=[ + PinConnection(component_ref="U1", pin_number="2"), + ]), + "USB_CC1": Net(name="USB_CC1", net_type=NetType.SIGNAL, pins=[]), + "I2C_SDA": Net(name="I2C_SDA", net_type=NetType.SIGNAL, pins=[]), + }, + ) + + +def _usb_layout() -> LayoutGraph: + return LayoutGraph( + segments=[ + LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"), + LayoutSegment(start=(0, 0.4), end=(12.5, 0.4), width=0.2, layer="F.Cu", net="USB_D-"), + LayoutSegment(start=(0, 5), end=(8, 5), width=0.2, layer="F.Cu", net="USB_CC1"), + LayoutSegment(start=(0, 8), end=(20, 8), width=0.2, layer="F.Cu", net="I2C_SDA"), + ], + vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)], + ) + + +def _if_rows(**kwargs): + base = { + "net_name": "USB_D+", + "length_mm": 10.0, + "partner_net_name": "USB_D-", + "is_differential": True, + "z0_avg_ohms": 88.0, + "z0_min_ohms": 46.0, + "z0_max_ohms": 92.0, + "topologies": ["MICROSTRIP"], + "flags": (), + } + base.update(kwargs) + dm = { + "net_name": "USB_D-", + "length_mm": 12.5, + "partner_net_name": "USB_D+", + "is_differential": True, + "z0_avg_ohms": 88.0, + "z0_min_ohms": 46.0, + "z0_max_ohms": 92.0, + "topologies": ["MICROSTRIP"], + "flags": (), + } + return [base, dm] + + +def test_emmaforo_usb_avg_in_window_min_out_is_margin(): + """Zavg 88 Ω in 81–99, min 46 out → MARGIN; CC is not a 90 Ω pair.""" + cons = ComponentConstraints( + mpn="PHY", + pintable=[Pin(number="1", name="D+")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[{ + "kind": "impedance", + "net_class": "usb", + "zdiff_ohm": 90, + "tolerance_pct": 10, + "note": "USB DP/DM 90 Ω ±10%", + "source_page": 12, + }], + ) + findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows()) + zf = [f for f in findings if f.rule_id == "PE-SI-002"] + assert len(zf) == 1 + assert zf[0].finding.startswith("MARGIN:") + complete_finding(zf[0]) + assert zf[0].status == "WARNING" + assert zf[0].finding_class != "RULE" + assert not any("CC" in (f.net or "") for f in findings) + assert not any("I2C" in (f.net or "") for f in findings) + + +def test_usb_without_library_z_is_insufficient_not_90ohm_fail(): + findings = check_si(_usb_graph(), {}, _usb_layout(), _if_rows()) + ids = {f.rule_id for f in findings} + assert "PE-SI-010" in ids + assert "PE-SI-002" not in ids + f = next(x for x in findings if x.rule_id == "PE-SI-010") + assert f.evidence_status == "INSUFFICIENT" + assert f.status == "INFO" + assert "90" not in (f.requirement or "") or "folklore" in (f.inference or "").lower() or True + assert "CC" not in (f.net or "") + + +def test_length_match_2_5mm_vs_1mm_is_fail(): + cons = ComponentConstraints( + mpn="PHY", + pintable=[Pin(number="1", name="D+")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[{ + "kind": "length_match", + "net_class": "usb", + "max_distance_mm": 1.0, + "note": "intra-pair < 1 mm", + "source_page": 12, + }], + ) + findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows()) + sk = [f for f in findings if f.rule_id == "PE-SI-001"] + assert sk and sk[0].finding.startswith("FAIL:") + complete_finding(sk[0]) + assert sk[0].status == "ERROR" + + +def test_i2c_not_checked_as_50_ohm(): + cons = ComponentConstraints( + mpn="PHY", + pintable=[Pin(number="1", name="D+")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[{ + "kind": "impedance", + "z0_ohm": 50, + "tolerance_pct": 10, + "note": "should not hit I2C", + "source_page": 1, + }], + ) + findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), [ + *_if_rows(), + {"net_name": "I2C_SDA", "z0_avg_ohms": 120.0, "length_mm": 20.0}, + ]) + assert all("I2C" not in (f.net or "") for f in findings) + assert all("SDA" not in f.finding for f in findings)