Gate SI layout_rules to the quoted bus, not EN RC onto USB.

PE-SI-009 no longer treats ESP32 CHIP_PU/EN RC as a USB series R.
USB without a library Z number stays PE-SI-010 measurement-only.
Pintable skill 1.13.0 requires net_class on SI kinds (usb2/usb3/MDI/RGMII/DDR3).
This commit is contained in:
2026-09-20 13:13:19 +02:00
parent a63e5bd7ab
commit 312057b6d3
10 changed files with 546 additions and 82 deletions
+30 -1
View File
@@ -48,6 +48,20 @@ def has_si_layout_rule(raw: object) -> bool:
return False 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( def needs_layout_rules_refresh(
data: dict, 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 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 has decoupling/thermal rules is stale and must be re-extracted so
ImpedenceFinder checks are not starved. 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") ver = str(data.get("model_version") or "0.0.0")
if not min_scan_version or min_scan_version == "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: except Exception:
need_si = False need_si = False
if need_si: 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")) return not has_any_layout_rule(data.get("layout_rules"))
+219 -71
View File
@@ -1,8 +1,9 @@
"""SI checks: datasheet layout_rules vs board + ImpedenceFinder. """SI checks: datasheet layout_rules vs board + ImpedenceFinder.
USB / HDMI / PCIe / Ethernet / LVDS / DDR (and any net_class on an USB2 / USB3 SuperSpeed / Ethernet MDI / RGMII / SGMII / DDR3 / HDMI /
``impedance`` / ``length_match`` / … rule) are checked. I2C, GPIO, EN, PCIe / LVDS are checked only against a rule whose quote or ``net_class``
analog REGN, USB CC are not 50 Ω pairs. No invented USB/IEC Z0. 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 from __future__ import annotations
@@ -30,14 +31,71 @@ _SKIP_RE = re.compile(
) )
_HS_CLASS_RE = ( _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)), ("hdmi", re.compile(r"HDMI", re.I)),
("pcie", re.compile(r"PCIE|PEX_", 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)), ("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({ _SI_KINDS = frozenset({
"impedance", "length_match", "max_length", "spacing", "impedance", "length_match", "max_length", "spacing",
"ref_plane", "si_via", "layer", "series_resistor", "return_path", "si", "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): if skip_si_net(net):
return None return None
leaf = _leaf(net) leaf = _leaf(net)
if "USB" in leaf.upper() and re.search(r"(D\+|D-|DP|DM)", leaf, re.I): u = leaf.upper()
return "usb" 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: for cls, cre in _HS_CLASS_RE:
if cls == "usb":
continue
if cre.search(leaf): if cre.search(leaf):
return cls return cls
if re.search(r"DDR", u):
return "ddr3_dq"
return None 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: def _is_hs_net(net: str) -> bool:
return bus_class(net) is not None 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]: 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} 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() pin = str(rule.get("pin") or "").strip()
ic = rule.get("_ic") ic = rule.get("_ic")
ic_nets: set[str] = rule.get("_ic_nets") or set() ic_nets: set[str] = rule.get("_ic_nets") or set()
targets = _rule_target_buses(rule)
picked: list[str] = [] picked: list[str] = []
for net in sorted(names): for net in sorted(names):
if skip_si_net(net): if skip_si_net(net):
continue 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: if pin and ic:
sch = graph.pin_net(ic, pin) if hasattr(graph, "pin_net") else None sch = graph.pin_net(ic, pin) if hasattr(graph, "pin_net") else None
if sch and not kicad_nets_match(sch, net): if sch and not kicad_nets_match(sch, net):
if pin.upper() not in _leaf(net).upper(): if pin.upper() not in _leaf(net).upper():
continue continue
if nc: if targets:
bc = bus_class(net) or "" if not _bus_in_targets(bc, targets):
if nc not in bc and nc not in _leaf(net).lower():
continue continue
else: else:
on_ic = net in ic_nets or any(kicad_nets_match(net, n) for n in ic_nets) # No bus on the quote: pin-scoped only. Never paint USB/DDR/PHY.
if not on_ic or not _is_hs_net(net): if not pin:
continue continue
picked.append(net) picked.append(net)
return picked return picked
@@ -337,6 +483,7 @@ def check_si(
findings: list[Finding] = [] findings: list[Finding] = []
rules = _collect_rules(graph, constraints_map) rules = _collect_rules(graph, constraints_map)
seen: set[tuple] = set() seen: set[tuple] = set()
z_covered: set[str] = set()
for _ref, rule in rules: for _ref, rule in rules:
kind = _param(rule) kind = _param(rule)
@@ -385,6 +532,9 @@ def check_si(
rec = f"Adjust {net} geometry toward the datasheet Z, then re-run PCB review." rec = f"Adjust {net} geometry toward the datasheet Z, then re-run PCB review."
if window is None and nom is None: if window is None and nom is None:
continue continue
bc = bus_class(net)
if bc:
z_covered.add(bc)
if avg is None: if avg is None:
findings.append(_si_finding( findings.append(_si_finding(
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic, 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)] hs_present = [s.net for s in layout.segments if s.net and _is_hs_net(s.net)]
si_kinds = { z_target = frozenset(z_covered)
"impedance", "zdiff", "z0", "length_match", "skew", "max_length", shown: set[str] = set()
"spacing", "ref_plane", "layer", "si_via", "series_resistor", "return_path", for net in hs_present:
} bc = bus_class(net)
if hs_present and not any(_param(r) in si_kinds for _i, r in rules): if skip_si_net(net) or not bc or bc in shown:
shown: set[str] = set() continue
for net in hs_present: if bc in z_covered or _bus_in_targets(bc, z_target):
bc = bus_class(net) continue
if skip_si_net(net) or not bc or bc in shown: shown.add(bc)
continue zrow = _z_row(rows, net)
shown.add(bc) partner = _partner_on_board(layout, net, zrow)
zrow = _z_row(rows, net) avg = _z_field(zrow, "z0_avg_ohms", "z0_ohm", "mean_z0", "z0")
partner = _partner_on_board(layout, net, zrow) zmin = _z_field(zrow, "z0_min_ohms")
avg = _z_field(zrow, "z0_avg_ohms", "z0_ohm", "mean_z0", "z0") zmax = _z_field(zrow, "z0_max_ohms")
zmin = _z_field(zrow, "z0_min_ohms") la = _z_field(zrow, "length_mm") or net_length_mm(layout, net)
zmax = _z_field(zrow, "z0_max_ohms") lb = 0.0
la = _z_field(zrow, "length_mm") or net_length_mm(layout, net) if partner:
lb = 0.0 zb = _z_row(rows, partner)
if partner: lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner)
zb = _z_row(rows, partner) skew = abs(la - lb) if partner else 0.0
lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner) rec = (
skew = abs(la - lb) if partner else 0.0 "Re-run schematic review so pintable extract ≥ 1.13.0 fills "
rec = ( "layout_rules impedance for this bus (net_class required). Do not "
"Re-run schematic review so pintable extract ≥ 1.12.0 fills " "assume 90 Ω. PCB does not re-read the PDF."
"layout_rules (Z0/skew/spacing). Do not assume 90 Ω. PCB does " )
"not re-read the PDF." findings.append(Finding(
) designator="layout",
findings.append(Finding( mpn="",
designator="layout", aspect="si",
mpn="", finding=(
aspect="si", f"Unverified: {bc} {net}"
finding=( + (f"/{partner}" if partner else "")
f"Unverified: {bc} {net}" + f" ImpedenceFinder Zavg={avg} Ω (min {zmin}, max {zmax}); "
+ (f"/{partner}" if partner else "") f"skew={skew:.2f} mm — library has no impedance FACT for this bus."
+ 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} Ω; "
facts=( f"L={la:.2f}/{lb:.2f} mm; topologies={zrow.get('topologies') if zrow else None}."
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.",
requirement="Datasheet layout_rules impedance/length_match (none on file).", why="Insufficient library SI numbers for this bus.",
inference="Not USB/IEC 90 Ω folklore; CC/GPIO/I2C are not this check.", status="INFO",
why="Insufficient library SI numbers.", recommendation=rec,
status="INFO", action=rec,
recommendation=rec, source="si_check",
action=rec, rule_id="PE-SI-010",
source="si_check", finding_class="INFO",
rule_id="PE-SI-010", provenance="TYPICAL",
finding_class="INFO", evidence_status="INSUFFICIENT",
provenance="TYPICAL", net=net,
evidence_status="INSUFFICIENT", pins=[],
net=net, ))
pins=[],
))
return findings return findings
+3 -1
View File
@@ -144,7 +144,9 @@ PINTABLE_TOOL = {
"number — never invent 3 mm/JEDEC), same_layer (bool), min_via_count, " "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, " "max_via_count, z0_ohm, zdiff_ohm, tolerance_pct, z_min_ohm, z_max_ohm, "
"topology, min_spacing_mm, value_ohms, ref_plane, parameter, " "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": { "items": {
"type": "object", "type": "object",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"default_model_version": "1.12.0", "default_model_version": "1.13.0",
"extract-pintable": { "extract-pintable": {
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY", "skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
"latest_version": "1784798970179642", "latest_version": "1784798970179642",
+19 -2
View File
@@ -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-ESD-001 | ESD connettore/USB | REVIEW se manca parte ESD |
| PE-RET-001 | return HS senza via GND | REVIEW, non ERROR | | 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% (8199), 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 MACPHY | 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% (8199), 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):** **B — still missing (true leftovers):**
+9
View File
@@ -2,6 +2,15 @@
What's new in Periscope. 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, MACPHY 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 ## 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. 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.
+8 -1
View File
@@ -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. 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 #### Fields
- `pin` — number or name as printed (`"5"`, `"VIN"`, `"VDD"`, `"EP"`) - `pin` — number or name as printed (`"5"`, `"VIN"`, `"VDD"`, `"EP"`)
- `cap_value_hint` — only if shown (`"100nF"`, `"10µF"`) - `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 - **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 - `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”) - `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`, MACPHY = `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) - `source_page` — 1-based page of the guidance (required when you emit a rule)
#### Examples #### Examples
@@ -129,6 +134,8 @@ Thermal vias:
- Do not invent land-pattern pad sizes from the mechanical drawing alone - 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 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 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 - Do not use kinds outside the closed set
- One rule per distinct pin/guidance; prefer supply pins that show caps in the application figure - One rule per distinct pin/guidance; prefer supply pins that show caps in the application figure
+21
View File
@@ -154,6 +154,27 @@ def validate(data: dict) -> list[str]:
errors.append( errors.append(
f"layout_rules[{i}].same_layer must be a boolean or null" 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 return errors
+28 -3
View File
@@ -48,10 +48,35 @@ def test_si_refresh_when_old_extract_has_only_decoupling():
) )
assert not needs_layout_rules_refresh( assert not needs_layout_rules_refresh(
{ {
"model_version": "1.9.0", "model_version": "1.12.0",
"layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": None}], "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",
) )
+208 -2
View File
@@ -69,7 +69,13 @@ def test_skip_i2c_gpio_cc_regn():
assert skip_si_net("ESP32_EN") assert skip_si_net("ESP32_EN")
assert not skip_si_net("USB_D+") assert not skip_si_net("USB_D+")
assert not skip_si_net("USB_DP") 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("USB_CC1") is None
assert bus_class("I2C_SCL") 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") f = next(x for x in findings if x.rule_id == "PE-SI-010")
assert f.evidence_status == "INSUFFICIENT" assert f.evidence_status == "INSUFFICIENT"
assert f.status == "INFO" 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 "") 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("I2C" not in (f.net or "") for f in findings)
assert all("SDA" not in f.finding 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 MACPHY 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)