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
+3 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+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-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):**
+9
View File
@@ -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, 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
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.
`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`, 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)
#### 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
+21
View File
@@ -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
+28 -3
View File
@@ -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",
)
+208 -2
View File
@@ -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 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)