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
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"))
+219 -71
View File
@@ -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