diff --git a/backend/periscopex/layout_rules.py b/backend/periscopex/layout_rules.py index c360421..f5ff8fb 100644 --- a/backend/periscopex/layout_rules.py +++ b/backend/periscopex/layout_rules.py @@ -48,6 +48,20 @@ def has_si_layout_rule(raw: object) -> bool: return False +def has_ungated_si_rule(raw: object) -> bool: + """True when an SI kind has no net_class — it must not paint USB/DDR/PHY.""" + if not isinstance(raw, list): + return False + for row in raw: + if not isinstance(row, dict): + continue + if str(row.get("kind") or "").strip() not in SI_KINDS: + continue + if not str(row.get("net_class") or "").strip(): + return True + return False + + def needs_layout_rules_refresh( data: dict, *, @@ -61,6 +75,9 @@ def needs_layout_rules_refresh( From skill 1.11.0, SI kinds are first-class. An older JSON that only has decoupling/thermal rules is stale and must be re-extracted so ImpedenceFinder checks are not starved. + + From skill 1.13.0, SI kinds without ``net_class`` are ungated (EN RC + was painted onto USB). Re-extract those. """ ver = str(data.get("model_version") or "0.0.0") if not min_scan_version or min_scan_version == "0.0.0": @@ -76,7 +93,19 @@ def needs_layout_rules_refresh( except Exception: need_si = False if need_si: - return not has_si_layout_rule(data.get("layout_rules")) + try: + need_bus = Version(min_scan_version) >= Version("1.13.0") + except Exception: + need_bus = False + if need_bus and has_ungated_si_rule(data.get("layout_rules")): + return True + if has_si_layout_rule(data.get("layout_rules")): + return False + try: + extracted_before_si = Version(ver) < Version("1.11.0") + except Exception: + extracted_before_si = True + return extracted_before_si return not has_any_layout_rule(data.get("layout_rules")) diff --git a/backend/periscopex/si_check.py b/backend/periscopex/si_check.py index eb4c00c..7250827 100644 --- a/backend/periscopex/si_check.py +++ b/backend/periscopex/si_check.py @@ -1,8 +1,9 @@ """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. +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 @@ -30,14 +31,71 @@ _SKIP_RE = re.compile( ) _HS_CLASS_RE = ( - ("usb", re.compile(r"USB", re.I)), + ("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)), - ("ethernet", re.compile(r"(?:^|[_/])(ETH|MDI|TRD[0-3])", 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)), - ("ddr", re.compile(r"DDR|DQS|(?:^|[_/])DQ\d+", 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", @@ -84,16 +142,97 @@ 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" + 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 cls == "usb": - continue 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 @@ -166,27 +305,34 @@ def _collect_rules(graph: DesignGraph, constraints_map: dict) -> list[tuple[str, 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} - 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() + 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 nc: - bc = bus_class(net) or "" - if nc not in bc and nc not in _leaf(net).lower(): + if targets: + if not _bus_in_targets(bc, targets): 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): + # No bus on the quote: pin-scoped only. Never paint USB/DDR/PHY. + if not pin: continue picked.append(net) return picked @@ -337,6 +483,7 @@ def check_si( 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) @@ -385,6 +532,9 @@ def check_si( 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, @@ -619,59 +769,57 @@ def check_si( )) 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=[], - )) + 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 diff --git a/backend/services/extraction.py b/backend/services/extraction.py index 559bda0..5b1da46 100644 --- a/backend/services/extraction.py +++ b/backend/services/extraction.py @@ -144,7 +144,9 @@ PINTABLE_TOOL = { "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." + "net_class (required for SI kinds: usb2 | usb3 | eth_mdi | rgmii | " + "sgmii | ddr3 | hdmi | pcie | lvds — never map EN/CHIP_PU RC onto " + "USB), note, source_page. Empty array if the PDF has no layout guidance." ), "items": { "type": "object", diff --git a/backend/skills_manifest.json b/backend/skills_manifest.json index 8c34037..c49e569 100644 --- a/backend/skills_manifest.json +++ b/backend/skills_manifest.json @@ -1,5 +1,5 @@ { - "default_model_version": "1.12.0", + "default_model_version": "1.13.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 6032c46..58bd0e8 100644 --- a/docs/piano-pcb-review.md +++ b/docs/piano-pcb-review.md @@ -97,9 +97,26 @@ 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 — 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 Ω. +**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. A rule applies only to the **quoted bus**. **Skip** I2C, GPIO, EN, analog REGN, USB CC, strap/EN RC (not USB/DDR/PHY 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. +### Coverage matrix (bus × requirement) + +Gate: library `layout_rules` kind + `net_class`/quote on that bus. Empty cell = PE-SI-010 INFO (measurement only) if the bus is on the board; never a folklore 90 Ω ERROR. + +| Bus | Z (002) | skew (001) | max L (003) | spacing (004) | ref/layer (005) | vias (006) | return (008) | series R (009) | no-Z (010) | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| USB2 D+/D− | gated | gated | gated | gated | gated | gated | gated | gated; **not** EN RC | measurement-only if no Z FACT | +| USB3 SuperSpeed | gated | gated | gated | gated | gated | gated | gated | gated | per-bus | +| ETH MDI / PHY+RJ45 | gated | gated | gated | gated | gated | gated | gated | gated | per-bus | +| RGMII MAC–PHY | gated | gated | gated | gated | gated | gated | gated | gated | per-bus | +| SGMII | gated | gated | gated | gated | gated | gated | gated | gated | per-bus | +| DDR3 CLK/DQS/DQ/ADDR | gated | gated | gated | gated | gated | gated | gated | gated | per-subclass if no Z | +| HDMI / PCIe / LVDS | gated | gated | gated | gated | gated | gated | gated | gated | per-bus | +| I2C / GPIO / EN / CC | skip | skip | skip | skip | skip | skip | skip | skip | skip | + +Emmaforo USB: extract with **no Z number** → Zavg ~88 Ω is **PE-SI-010** INFO (insufficient), **not** a 90 Ω FAIL. If library later has 90 Ω ±10% (81–99), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists. PE-SI-009 must not fire on USB from ESP32 EN RC 10 kΩ / 1 µF p.28. + +Pintable skill **1.13.0** (`net_class` required on SI kinds). Older extracts with ungated `series_resistor` refresh on schematic re-run (`si_extract_needed.json` on PCB). **B — still missing (true leftovers):** diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index a440274..35c8efc 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,15 @@ What's new in Periscope. +## 2.37.0 — 2026-09-20 — SI bus gate (EN RC ≠ USB series R) + +Datasheet SI facts apply only to the bus in the quote. ESP32 EN RC 10 kΩ / 1 µF is not a USB D+/D− series resistor. USB without a library Z number stays measurement-only (88 Ω), not a 90 Ω FAIL. Coverage: PHY+RJ45 MDI, MAC–PHY RGMII/SGMII, DDR3, USB-C SuperSpeed. + +- [Fixed] `PE-SI-009` (and other SI kinds) require `net_class` / quote bus match; strap/EN RC is skipped. +- [Changed] `bus_class`: `usb2` / `usb3` / `eth_mdi` / `rgmii` / `sgmii` / `ddr3_*`. +- [Changed] `PE-SI-010` is per bus with no impedance FACT (not silenced by an unrelated SI kind). +- [Changed] Pintable skill `1.13.0`: SI `net_class` required; EN/CHIP_PU RC is not `series_resistor`. + ## 2.36.0 — 2026-09-20 — Fase A: operator identity + PinScope fence This instance is operated by Michele Bigi. Faradworks, Inc. is not the operator. PinScope identifiers are dual-read only. PCB BOM/SPOF/EMI/θJA slices from 2.35.0 stay on this branch. diff --git a/skills/extract-pintable/SKILL.md b/skills/extract-pintable/SKILL.md index 80708fb..c52e8ac 100644 --- a/skills/extract-pintable/SKILL.md +++ b/skills/extract-pintable/SKILL.md @@ -72,6 +72,10 @@ You **must** look for layout guidance. Emit `layout_rules` as a list. Use `[]` o 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. +`net_class` is **required** for every SI kind (`impedance`, `length_match`, `max_length`, `spacing`, `ref_plane`, `si_via`, `layer`, `series_resistor`, `return_path`, `si`). Use one of: `usb2`, `usb3`, `eth_mdi`, `rgmii`, `sgmii`, `ddr3`, `hdmi`, `pcie`, `lvds`. PCB review will not map a rule onto another bus. + +`series_resistor` is a termination / series R **on that HS net** (e.g. USB 22 Ω, RGMII 22 Ω). It is **not** CHIP_PU / EN / RESET RC (10 kΩ + 1 µF), ILIM, or a strap divider — omit those or use `decoupling_proximity` / leave them to timing checks. + #### Fields - `pin` — number or name as printed (`"5"`, `"VIN"`, `"VDD"`, `"EP"`) - `cap_value_hint` — only if shown (`"100nF"`, `"10µF"`) @@ -81,7 +85,8 @@ Do **not** emit impedance/50 Ω rules for I2C, GPIO, EN, analog REGN, or USB CC. - **Never invent** JEDEC, USB, IPC, or “standard 3 mm / 5 mm” distances - `same_layer` — `true`/`false` only if text says same side / opposite side of the board; else null - `min_via_count` — integer only if stated (“at least 4 vias”) -- `net_class` / `note` — short quote of the guidance +- `net_class` — **required for SI kinds**: `usb2` | `usb3` | `eth_mdi` | `rgmii` | `sgmii` | `ddr3` | `hdmi` | `pcie` | `lvds`. Must match the quoted bus (PHY+RJ45 = `eth_mdi`, MAC–PHY = `rgmii`/`sgmii`, USB-C SuperSpeed = `usb3`, USB D+/D− = `usb2`). Never leave SI `net_class` empty. +- `note` — short quote of the guidance - `source_page` — 1-based page of the guidance (required when you emit a rule) #### Examples @@ -129,6 +134,8 @@ Thermal vias: - Do not invent land-pattern pad sizes from the mechanical drawing alone - 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 emit `series_resistor` for EN / CHIP_PU / RESET RC, ILIM, or strap networks +- Do not emit an SI kind without `net_class` naming the quoted bus - 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/validate.py b/skills/extract-pintable/validate.py index 6660c96..4eaa493 100644 --- a/skills/extract-pintable/validate.py +++ b/skills/extract-pintable/validate.py @@ -154,6 +154,27 @@ def validate(data: dict) -> list[str]: errors.append( f"layout_rules[{i}].same_layer must be a boolean or null" ) + si_kinds = { + "length_match", "impedance", "max_length", "spacing", + "ref_plane", "si_via", "layer", "series_resistor", + "return_path", "si", + } + nc = row.get("net_class") + if kind in si_kinds and not (isinstance(nc, str) and nc.strip()): + errors.append( + f"layout_rules[{i}] SI kind {kind!r} requires net_class " + f"(usb2|usb3|eth_mdi|rgmii|sgmii|ddr3|hdmi|pcie|lvds)" + ) + note = str(row.get("note") or "") + pin = str(row.get("pin") or "") + if kind == "series_resistor" and ( + re.search(r"[µu]F", note, re.I) + or re.search(r"\b(EN|CHIP_PU|CHIP_EN|STRAP|ILIM)\b", f"{note} {pin}", re.I) + ): + errors.append( + f"layout_rules[{i}] series_resistor is HS termination, " + f"not EN/CHIP_PU RC or strap" + ) return errors diff --git a/tests/test_pcb_plan_closeout.py b/tests/test_pcb_plan_closeout.py index f7e42ff..a3f4bbd 100644 --- a/tests/test_pcb_plan_closeout.py +++ b/tests/test_pcb_plan_closeout.py @@ -48,10 +48,35 @@ def test_si_refresh_when_old_extract_has_only_decoupling(): ) assert not needs_layout_rules_refresh( { - "model_version": "1.9.0", - "layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": None}], + "model_version": "1.12.0", + "layout_rules": [{"kind": "series_resistor", "value_ohms": 10000}], }, - min_scan_version="1.10.0", + min_scan_version="1.12.0", + ) + assert needs_layout_rules_refresh( + { + "model_version": "1.12.0", + "layout_rules": [{ + "kind": "series_resistor", + "value_ohms": 10000, + "note": "EN RC 10 kΩ / 1 µF", + }], + }, + min_scan_version="1.13.0", + ) + assert not needs_layout_rules_refresh( + { + "model_version": "1.12.0", + "layout_rules": [{"kind": "impedance", "net_class": "usb2", "zdiff_ohm": 90}], + }, + min_scan_version="1.13.0", + ) + assert not needs_layout_rules_refresh( + { + "model_version": "1.12.0", + "layout_rules": [], + }, + min_scan_version="1.13.0", ) diff --git a/tests/test_si_check.py b/tests/test_si_check.py index ba9c078..ff0e24c 100644 --- a/tests/test_si_check.py +++ b/tests/test_si_check.py @@ -69,7 +69,13 @@ def test_skip_i2c_gpio_cc_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_D+") == "usb2" + assert bus_class("USB_SSTX_P") == "usb3" + assert bus_class("ETH_TX+") == "eth_mdi" + assert bus_class("RGMII_TXD0") == "rgmii" + assert bus_class("SGMII_TX_P") == "sgmii" + assert bus_class("DDR3_DQ0") == "ddr3_dq" + assert bus_class("DDR3_DQS0_P") == "ddr3_dqs" assert bus_class("USB_CC1") is None assert bus_class("I2C_SCL") is None @@ -170,7 +176,10 @@ def test_usb_without_library_z_is_insufficient_not_90ohm_fail(): 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 "90" not in (f.requirement or "") + blob = f"{f.finding} {f.requirement} {f.inference}" + assert "FAIL" not in f.finding + assert "90 Ω" not in blob or "folklore" in (f.inference or "").lower() assert "CC" not in (f.net or "") @@ -215,3 +224,200 @@ def test_i2c_not_checked_as_50_ohm(): ]) assert all("I2C" not in (f.net or "") for f in findings) assert all("SDA" not in f.finding for f in findings) + + +def test_en_rc_series_resistor_does_not_paint_usb(): + """PE-SI-009 must not use ESP32 EN RC 10 kΩ / 1 µF as a USB series R.""" + cons = ComponentConstraints( + mpn="ESP32", + pintable=[Pin(number="1", name="D+"), Pin(number="3", name="EN")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[{ + "kind": "series_resistor", + "pin": "EN", + "value_ohms": 10000, + "note": "EN RC 10 kΩ / 1 µF", + "source_page": 28, + }], + ) + findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows()) + si009 = [f for f in findings if f.rule_id == "PE-SI-009"] + assert si009 == [] + zfail = [f for f in findings if f.rule_id == "PE-SI-002"] + assert zfail == [] + si010 = [f for f in findings if f.rule_id == "PE-SI-010"] + assert si010 + assert si010[0].evidence_status == "INSUFFICIENT" + assert "88" in (si010[0].finding or "") + assert "90" not in (si010[0].requirement or "") + + +def _phy_graph() -> DesignGraph: + pins = { + "1": "ETH_TX+", "2": "ETH_TX-", + "3": "RGMII_TXD0", "4": "RGMII_TXD1", + "5": "USB_SSTX_P", "6": "USB_SSTX_N", + "7": "DDR3_DQ0", "8": "DDR3_DQ1", + "9": "DDR3_DQS0_P", "10": "DDR3_DQS0_N", + "11": "USB_D+", "12": "USB_D-", + } + nets = { + n: Net(name=n, net_type=NetType.SIGNAL, pins=[ + PinConnection(component_ref="U1", pin_number=p), + ]) + for p, n in pins.items() + } + return DesignGraph( + components={ + "U1": Component( + reference="U1", value="PHY", footprint="", + component_type=ComponentType.IC, mpn="PHY", + pins=pins, + ), + }, + nets=nets, + ) + + +def _phy_layout() -> LayoutGraph: + segs = [ + LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="ETH_TX+"), + LayoutSegment(start=(0, 0.4), end=(10.2, 0.4), width=0.2, layer="F.Cu", net="ETH_TX-"), + LayoutSegment(start=(0, 2), end=(8, 2), width=0.15, layer="F.Cu", net="RGMII_TXD0"), + LayoutSegment(start=(0, 2.4), end=(8.4, 2.4), width=0.15, layer="F.Cu", net="RGMII_TXD1"), + LayoutSegment(start=(0, 4), end=(20, 4), width=0.12, layer="F.Cu", net="USB_SSTX_P"), + LayoutSegment(start=(0, 4.3), end=(20.5, 4.3), width=0.12, layer="F.Cu", net="USB_SSTX_N"), + LayoutSegment(start=(0, 6), end=(15, 6), width=0.1, layer="F.Cu", net="DDR3_DQ0"), + LayoutSegment(start=(0, 6.2), end=(15.1, 6.2), width=0.1, layer="F.Cu", net="DDR3_DQ1"), + LayoutSegment(start=(0, 7), end=(12, 7), width=0.1, layer="F.Cu", net="DDR3_DQS0_P"), + LayoutSegment(start=(0, 7.2), end=(12.3, 7.2), width=0.1, layer="F.Cu", net="DDR3_DQS0_N"), + LayoutSegment(start=(0, 9), end=(10, 9), width=0.2, layer="F.Cu", net="USB_D+"), + LayoutSegment(start=(0, 9.4), end=(12.5, 9.4), width=0.2, layer="F.Cu", net="USB_D-"), + ] + return LayoutGraph(segments=segs, vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)]) + + +def _z(name, partner, avg, length, **kw): + row = { + "net_name": name, + "partner_net_name": partner, + "is_differential": True, + "z0_avg_ohms": avg, + "z0_min_ohms": avg - 2, + "z0_max_ohms": avg + 2, + "length_mm": length, + "topologies": ["MICROSTRIP"], + "flags": (), + } + row.update(kw) + return row + + +def test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped(): + cons = ComponentConstraints( + mpn="PHY", + pintable=[Pin(number="1", name="TX+")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[ + { + "kind": "impedance", + "net_class": "eth_mdi", + "zdiff_ohm": 100, + "tolerance_pct": 10, + "note": "MDI 100 Ω to RJ45", + "source_page": 40, + }, + { + "kind": "max_length", + "net_class": "rgmii", + "max_distance_mm": 20, + "note": "RGMII MAC–PHY trace < 20 mm", + "source_page": 41, + }, + { + "kind": "length_match", + "net_class": "ddr3", + "max_distance_mm": 1.0, + "note": "DDR3 DQS intra-pair", + "source_page": 42, + }, + { + "kind": "impedance", + "net_class": "usb3", + "zdiff_ohm": 90, + "tolerance_pct": 10, + "note": "USB-C SuperSpeed 90 Ω", + "source_page": 43, + }, + { + "kind": "series_resistor", + "net_class": "rgmii", + "value_ohms": 22, + "note": "RGMII series 22 Ω", + "source_page": 41, + }, + ], + ) + rows = [ + _z("ETH_TX+", "ETH_TX-", 95.0, 10.0), + _z("ETH_TX-", "ETH_TX+", 95.0, 10.2), + _z("USB_SSTX_P", "USB_SSTX_N", 88.0, 20.0), + _z("USB_SSTX_N", "USB_SSTX_P", 88.0, 20.5), + _z("DDR3_DQS0_P", "DDR3_DQS0_N", 50.0, 12.0), + _z("DDR3_DQS0_N", "DDR3_DQS0_P", 50.0, 12.3), + _z("USB_D+", "USB_D-", 88.0, 10.0), + _z("USB_D-", "USB_D+", 88.0, 12.5), + {"net_name": "RGMII_TXD0", "z0_avg_ohms": 48.0, "length_mm": 8.0, "topologies": ["MICROSTRIP"]}, + {"net_name": "RGMII_TXD1", "z0_avg_ohms": 48.0, "length_mm": 8.4, "topologies": ["MICROSTRIP"]}, + {"net_name": "DDR3_DQ0", "z0_avg_ohms": 50.0, "length_mm": 15.0, "topologies": ["MICROSTRIP"]}, + ] + findings = check_si(_phy_graph(), {"PHY": cons}, _phy_layout(), rows) + by = {} + for f in findings: + by.setdefault(f.rule_id, []).append(f) + + mdi_z = [f for f in by.get("PE-SI-002", []) if f.net and "ETH" in f.net] + assert mdi_z and mdi_z[0].finding.startswith("PASS:") + usb3_z = [f for f in by.get("PE-SI-002", []) if f.net and "SSTX" in f.net] + assert usb3_z and usb3_z[0].finding.startswith("PASS:") + usb2_z = [f for f in by.get("PE-SI-002", []) if f.net and "USB_D" in (f.net or "")] + assert usb2_z == [] + + rgmii_len = [f for f in by.get("PE-SI-003", []) if f.net and "RGMII" in f.net] + assert rgmii_len and rgmii_len[0].finding.startswith("PASS:") + assert not any(f.net and "ETH_TX" in f.net for f in by.get("PE-SI-003", [])) + + dqs = [f for f in by.get("PE-SI-001", []) if f.net and "DQS" in f.net] + assert dqs and dqs[0].finding.startswith("PASS:") + + si009 = by.get("PE-SI-009", []) + assert si009 + assert all(f.net and "RGMII" in f.net for f in si009) + assert not any(f.net and "USB" in f.net for f in si009) + + si010 = by.get("PE-SI-010", []) + nets010 = {f.net for f in si010} + assert any(n and "USB_D" in n for n in nets010) + assert not any(n and "ETH_TX" in n for n in nets010) + assert not any(n and "SSTX" in n for n in nets010) + + +def test_empty_net_class_impedance_does_not_paint_usb(): + 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": "generic 50 Ω with no bus", + "source_page": 1, + }], + ) + findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows()) + assert all(f.rule_id != "PE-SI-002" for f in findings) + assert any(f.rule_id == "PE-SI-010" for f in findings)