Add USB-C, Ethernet, and PoE interface class certifiers (2.61.0).

Dedicated deterministic engines certify class and connection integrity
(CC1/CC2, Rd 5.1 kΩ, VBUS/GND, SuperSpeed if USB3; 10/100 vs GbE
magnetics; PoE only with evidence). Missing evidence is INSUFFICIENT
or N/A, never invented PoE, SuperSpeed, Z, or I. No FEM, no via IPC.
This commit is contained in:
2026-09-21 11:52:44 +02:00
parent 07ad5c72fa
commit 6dc04b5cc7
20 changed files with 1575 additions and 14 deletions
@@ -5,6 +5,13 @@
"deterministic_keys": [
"PE-I2C-001|U3|/I2C0.SDA",
"PE-I2C-001|U3|/I2C0.SCL",
"PE-ESR-001|U1|+3V3"
"PE-ESR-001|U1|+3V3",
"PE-USBC-001|J1|",
"PE-USBC-002|J1|/USBC.CC1",
"PE-USBC-003|J1|",
"PE-USBC-004|J1|",
"PE-USBC-005|J1|",
"PE-ETH-001|PCB|",
"PE-POE-001|PCB|"
]
}
@@ -30,6 +30,7 @@ from backend.periscopex.dnp_check import check_dnp_enables
from backend.periscopex.lifecycle import check_lifecycle
from backend.periscopex.errata_check import check_errata
from backend.periscopex.internal_features_check import check_internal_features
from backend.periscopex.interface_class_check import check_interface_classes
from backend.periscopex.placement_check import check_placement
from backend.periscopex.si_check import check_si
@@ -101,6 +102,7 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]:
out.extend(check_lifecycle(graph, {}))
out.extend(check_errata(graph, cmap))
out.extend(check_internal_features(graph, cmap))
out.extend(check_interface_classes(graph))
out.extend(check_placement(graph, cmap, None))
out.extend(check_si(graph, cmap, None))
return out
@@ -193,6 +193,31 @@ def _seed() -> None:
requirement="Single regulator or crystal feeding many ICs is REVIEW, not ERROR.")
_add("PE-EMI-001", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Datasheet EMI/filter FACT vs ferrite/common-mode on the net.")
# Interface class (USB-C / Ethernet / PoE) — connection integrity, not SI.
_add("PE-USBC-001", "TYPICAL", "INFO", domain="shared",
requirement="USB-C class from BOM/footprint/MPN/nets (USB2 vs USB3).")
_add("PE-USBC-002", "MANDATORY", "RULE", domain="shared",
requirement="USB-C receptacle shall present CC1 and CC2.")
_add("PE-USBC-003", "MANDATORY", "RULE", domain="shared",
requirement="USB Type-C Rd 5.1 kΩ ±10% to GND, or Rp 56/22/10 kΩ to VBUS.")
_add("PE-USBC-004", "MANDATORY", "RULE", domain="shared",
requirement="USB-C receptacle shall connect VBUS and GND.")
_add("PE-USBC-005", "MANDATORY", "RULE", domain="shared",
requirement="SuperSpeed TX/RX pairs only when class is USB3; USB2 is N/A.")
_add("PE-ETH-001", "TYPICAL", "INFO", domain="shared",
requirement="Ethernet class 10/100 vs GbE from BOM/footprint/MPN/MDI pairs.")
_add("PE-ETH-002", "MANDATORY", "RULE", domain="shared",
requirement="10/100 needs TX/RX; GbE needs four MDI pairs.")
_add("PE-ETH-003", "MANDATORY", "RULE", domain="shared",
requirement="1000BASE-T requires magnetics (transformer or MagJack).")
_add("PE-ETH-004", "RECOMMENDED", "REVIEW", domain="shared",
requirement="Bob Smith 75 Ω is typical for GbE; often inside MagJack.")
_add("PE-POE-001", "TYPICAL", "INFO", domain="shared",
requirement="PoE certifier runs only with PoE evidence; bare RJ45 is N/A.")
_add("PE-POE-002", "TYPICAL", "INFO", domain="shared",
requirement="PoE class/type from 802.3af/at/bt or Class n — never invented.")
_add("PE-POE-003", "MANDATORY", "RULE", domain="shared",
requirement="PoE requires magnetics/isolation (MagJack or LAN transformer).")
_seed()
@@ -0,0 +1,814 @@
"""Dedicated interface class certifiers (USB-C, Ethernet, PoE).
Not generic SI. Not via ampacity. Not thermal FEM. Graph connectivity only:
class from BOM / footprint / net FACT, then connection integrity. Missing
numbers are INSUFFICIENT_EVIDENCE. A bare RJ45 is not PoE.
"""
from __future__ import annotations
import re
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Finding,
NetType,
ResistorSpecs,
)
from backend.periscopex.pcb_net_match import (
is_no_connect_net,
normalize_kicad_hierarchy_net,
refs_on_matched_net,
)
SOURCE = "interface_class_check"
# USB Type-C Cable and Connector Specification: Rd = 5.1 kΩ ±10% (UFP).
USB_C_RD_OHM = 5100.0
USB_C_RD_TOL = 0.10
# Rp advertisement windows (DFP to VBUS). ±20% so we do not invent tighter bands.
USB_C_RP_OHM = (56000.0, 22000.0, 10000.0)
USB_C_RP_TOL = 0.20
BOB_SMITH_OHM = 75.0
BOB_SMITH_TOL = 0.10
_USB_C_RE = re.compile(r"USB[\s_\-]*C\b|TYPE[\s_\-]*C|USB4085|USB_C", re.I)
_USB2_RE = re.compile(r"USB[\s_\-]*2|16P", re.I)
_USB3_RE = re.compile(r"USB[\s_\-]*3|SUPER\s*SPEED", re.I)
_CC1_RE = re.compile(r"(?:^|.*[._\-])CC1$", re.I)
_CC2_RE = re.compile(r"(?:^|.*[._\-])CC2$", re.I)
_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS|VCC_USB", re.I)
_SS_NET_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|SS_R[XR]|USB3|USB_SS", re.I)
_USBC_SS_PINS = frozenset({
"A2", "A3", "A10", "A11", "B2", "B3", "B10", "B11",
})
_USBC_VBUS_PINS = frozenset({"A4", "A9", "B4", "B9"})
_USBC_GND_PINS = frozenset({"A1", "A12", "B1", "B12", "S1"})
_RJ45_RE = re.compile(r"RJ[\s\-]?45|8P8C|MAGJACK", re.I)
_ETH_10_100_RE = re.compile(r"10\s*/\s*100|100BASE|10BASE|BASE[\s\-]?TX", re.I)
_ETH_GBE_RE = re.compile(r"1000\s*BASE|1000BASE|\bGBE\b|GIGABIT", re.I)
_MAG_RE = re.compile(r"MAGNETIC|MAGJACK|MAG[\s\-]?JACK|LAN\s*TRANSFORMER", re.I)
_POE_RE = re.compile(
r"\bPOE\b|802\.3A[FT]|802\.3BT|POWER\s*OVER\s*ETHERNET|VPOE|48V_POE",
re.I,
)
_POE_CLASS_RE = re.compile(
r"802\.3A[FT]|802\.3BT|CLASS\s*([0-8])|TYPE\s*([1-4])",
re.I,
)
_CT_RE = re.compile(r"(?:^|.*[._\-])(?:T?CT|RCT|CENTER\s*TAP)$", re.I)
_OHM_EMBEDDED = re.compile(r"^(\d+)([RKMG])(\d+)$", re.I)
_OHM_SUFFIX = re.compile(r"^(\d*\.?\d+)([RKMG])$", re.I)
def check_interface_classes(graph: DesignGraph) -> list[Finding]:
"""USB-C, Ethernet, and PoE class + connection integrity."""
out: list[Finding] = []
out.extend(_check_usb_c(graph))
out.extend(_check_ethernet(graph))
out.extend(_check_poe(graph))
return out
def format_interface_class_context(graph: DesignGraph) -> str:
"""Plain FACT block for AI review — not the verification engine."""
lines = [
"### Interface class (deterministic certifiers — do not invent)",
"USB-C: CC1/CC2, Rp/Rd 5.1 kΩ, VBUS/GND, SuperSpeed only if class is USB3.",
"Ethernet: 10/100 vs GbE; magnetics required for GbE. Bob Smith is recommended.",
"PoE: only with PoE evidence. Never invent PoE on a bare RJ45. No Z/I/mm.",
]
findings = check_interface_classes(graph)
if not findings:
lines.append("(no interface-class findings)")
return "\n".join(lines)
for f in findings:
lines.append(
f"- {f.rule_id} {f.designator}: {f.finding} "
f"[{f.status}/{f.finding_class}/{f.evidence_status}]"
)
return "\n".join(lines)
def _check_usb_c(graph: DesignGraph) -> list[Finding]:
connectors = [c for c in graph.components.values() if _is_usb_c(c)]
if not connectors:
return [_na("PCB", "PE-USBC-001", "USB-C",
"No USB-C receptacle in the netlist (footprint/value/MPN/nets).")]
out: list[Finding] = []
for comp in sorted(connectors, key=lambda c: c.reference):
out.extend(_usb_c_one(graph, comp))
return out
def _usb_c_one(graph: DesignGraph, comp: Component) -> list[Finding]:
gen = _usb_c_generation(graph, comp)
roles = _usb_c_roles(comp)
out = [_usb_c_class_finding(comp, gen)]
out.append(_usb_c_cc_finding(comp, roles))
out.append(_usb_c_rd_finding(graph, comp, roles))
out.append(_usb_c_vbus_gnd_finding(comp, roles))
out.append(_usb_c_ss_finding(comp, roles, gen))
return out
def _usb_c_class_finding(comp: Component, gen: str) -> Finding:
ref = comp.reference
if gen == "usb2":
text = f"{ref} class USB-C USB2 (Type-C receptacle; SuperSpeed not required)."
return _info(ref, "PE-USBC-001", text, text,
"USB-C class from BOM/footprint/MPN/nets (USB2 token).",
mpn=comp.mpn or "")
if gen == "usb3":
text = f"{ref} class USB-C USB3 (SuperSpeed required)."
return _info(ref, "PE-USBC-001", text, text,
"USB-C class from BOM/footprint/MPN/nets (USB3/SuperSpeed token).",
mpn=comp.mpn or "")
return _insufficient(
ref, "PE-USBC-001",
f"{ref} is USB-C; generation USB2 vs USB3 is not evidenced.",
f"blob={_blob(None, comp)[:180]!r}; no USB2.0/16P and no USB3/SuperSpeed token.",
"USB-C class needs a USB2 or USB3 FACT; SuperSpeed is not assumed.",
mpn=comp.mpn or "",
)
def _usb_c_cc_finding(comp: Component, roles: dict[str, list[str]]) -> Finding:
ref = comp.reference
has1 = bool(roles.get("CC1"))
has2 = bool(roles.get("CC2"))
facts = f"CC1 nets={roles.get('CC1') or []}; CC2 nets={roles.get('CC2') or []}."
req = "USB-C receptacle shall present CC1 and CC2 (USB Type-C spec)."
if has1 and has2:
return _info(ref, "PE-USBC-002",
f"{ref} CC1 and CC2 are connected.",
facts, req, mpn=comp.mpn or "",
net=(roles["CC1"][0] if roles.get("CC1") else None),
pins=[f"{ref}.A5", f"{ref}.B5"])
missing = [n for n, ok in (("CC1", has1), ("CC2", has2)) if not ok]
return _error(ref, "PE-USBC-002",
f"{ref} missing USB-C {', '.join(missing)}.",
facts, req,
f"Connect {', '.join(missing)} on {ref} (not NC).",
mpn=comp.mpn or "")
def _usb_c_rd_finding(
graph: DesignGraph, comp: Component, roles: dict[str, list[str]],
) -> Finding:
ref = comp.reference
req = (
"USB Type-C Rd = 5.1 kΩ ±10% CC to GND (UFP), or Rp 56/22/10 kΩ to "
"VBUS (DFP). A CC/PD IC may provide them — that is not a discrete FACT."
)
reports: list[str] = []
worst = "pass"
for name in ("CC1", "CC2"):
nets = roles.get(name) or []
if not nets:
continue
kind, detail = _cc_termination(graph, comp.reference, nets[0])
reports.append(f"{name}:{kind}:{detail}")
worst = _worse_term(worst, kind)
facts = "; ".join(reports) or "no CC nets."
if worst == "pass":
return _info(ref, "PE-USBC-003",
f"{ref} CC Rp/Rd matches USB Type-C (Rd 5.1 kΩ or Rp window).",
facts, req, mpn=comp.mpn or "")
if worst == "fail":
return _error(ref, "PE-USBC-003",
f"{ref} CC termination is not USB Type-C Rd 5.1 kΩ / Rp.",
facts, req,
f"Fit 5.1 kΩ Rd to GND on both CC, or a USB-C CC controller.",
mpn=comp.mpn or "")
return _insufficient(
ref, "PE-USBC-003",
f"{ref} CC Rp/Rd is not a measured discrete FACT.",
facts, req, mpn=comp.mpn or "",
)
def _usb_c_vbus_gnd_finding(comp: Component, roles: dict[str, list[str]]) -> Finding:
ref = comp.reference
has_v = bool(roles.get("VBUS"))
has_g = bool(roles.get("GND"))
facts = f"VBUS nets={roles.get('VBUS') or []}; GND nets={roles.get('GND') or []}."
req = "USB-C receptacle shall connect VBUS and GND."
if has_v and has_g:
return _info(ref, "PE-USBC-004",
f"{ref} VBUS and GND are connected.",
facts, req, mpn=comp.mpn or "")
missing = [n for n, ok in (("VBUS", has_v), ("GND", has_g)) if not ok]
return _error(ref, "PE-USBC-004",
f"{ref} missing USB-C {', '.join(missing)}.",
facts, req,
f"Connect {', '.join(missing)} on {ref}.",
mpn=comp.mpn or "")
def _usb_c_ss_finding(
comp: Component, roles: dict[str, list[str]], gen: str,
) -> Finding:
ref = comp.reference
ss_nets = roles.get("SS") or []
facts = f"generation={gen}; SuperSpeed nets={ss_nets}."
if gen == "usb2":
return _info(ref, "PE-USBC-005",
f"{ref} SuperSpeed N/A (USB2 Type-C class).",
facts,
"SuperSpeed pairs are required only when the class is USB3.",
mpn=comp.mpn or "")
if gen != "usb3":
return _insufficient(
ref, "PE-USBC-005",
f"{ref} SuperSpeed not certified — USB2 vs USB3 class is not evidenced.",
facts,
"Do not invent SuperSpeed on a Type-C jack without a USB3 FACT.",
mpn=comp.mpn or "",
)
if len(ss_nets) >= 4:
return _info(ref, "PE-USBC-005",
f"{ref} SuperSpeed pairs are present.",
facts,
"USB3 Type-C requires SuperSpeed TX/RX pairs.",
mpn=comp.mpn or "")
return _error(ref, "PE-USBC-005",
f"{ref} class USB3 but SuperSpeed pairs are missing.",
facts,
"USB3 Type-C requires SuperSpeed TX/RX pairs.",
f"Route SSTX/SSRX pairs on {ref} or declare USB2 Type-C.",
mpn=comp.mpn or "")
def _check_ethernet(graph: DesignGraph) -> list[Finding]:
jacks = [c for c in graph.components.values() if _is_ethernet_jack(c)]
if not jacks:
return [_na("PCB", "PE-ETH-001", "Ethernet",
"No RJ45 / Ethernet jack in the netlist.")]
out: list[Finding] = []
for comp in sorted(jacks, key=lambda c: c.reference):
out.extend(_eth_one(graph, comp))
return out
def _eth_one(graph: DesignGraph, comp: Component) -> list[Finding]:
speed = _eth_speed(graph, comp)
pairs = _mdi_pair_ids(comp)
mag = _has_magnetics(graph, comp)
out = [_eth_class_finding(comp, speed, pairs)]
out.append(_eth_pair_finding(comp, speed, pairs))
out.append(_eth_mag_finding(graph, comp, speed, mag))
out.append(_eth_bob_smith_finding(graph, comp, speed))
return out
def _eth_class_finding(comp: Component, speed: str, pairs: set[str]) -> Finding:
ref = comp.reference
facts = f"speed={speed}; mdi_pairs={sorted(pairs)}; blob={_blob(None, comp)[:160]!r}."
if speed == "10/100":
return _info(ref, "PE-ETH-001",
f"{ref} class Ethernet 10/100 (not GbE).",
facts, "10/100 vs 1000 from BOM/footprint/MPN/named MDI pairs.",
mpn=comp.mpn or "")
if speed == "gbe":
return _info(ref, "PE-ETH-001",
f"{ref} class Ethernet GbE / 1000BASE-T.",
facts, "10/100 vs 1000 from BOM/footprint/MPN/named MDI pairs.",
mpn=comp.mpn or "")
return _insufficient(
ref, "PE-ETH-001",
f"{ref} Ethernet speed 10/100 vs GbE is not evidenced.",
facts, "Do not invent GbE or 10/100 without a FACT.",
mpn=comp.mpn or "",
)
def _eth_pair_finding(comp: Component, speed: str, pairs: set[str]) -> Finding:
ref = comp.reference
facts = f"speed={speed}; pairs={sorted(pairs)}."
req = "10/100 needs TX and RX; GbE needs four MDI pairs (TRD03)."
if speed == "gbe":
trd = {p for p in pairs if p in {"0", "1", "2", "3"}}
if len(trd) >= 4 or len(pairs) >= 4:
return _info(ref, "PE-ETH-002",
f"{ref} GbE MDI pairs are present.",
facts, req, mpn=comp.mpn or "")
if pairs:
return _error(ref, "PE-ETH-002",
f"{ref} class GbE but fewer than four MDI pairs are named.",
facts, req,
f"Connect TRD0TRD3 (or equivalent) on {ref}.",
mpn=comp.mpn or "")
return _insufficient(ref, "PE-ETH-002",
f"{ref} GbE pair names are not in the netlist.",
facts, req, mpn=comp.mpn or "")
if speed == "10/100":
if "tx" in pairs and "rx" in pairs:
return _info(ref, "PE-ETH-002",
f"{ref} 10/100 TX/RX pairs are present.",
facts, req, mpn=comp.mpn or "")
if pairs:
return _error(ref, "PE-ETH-002",
f"{ref} 10/100 missing named TX or RX pair.",
facts, req,
f"Connect TX± and RX± on {ref}.",
mpn=comp.mpn or "")
return _insufficient(ref, "PE-ETH-002",
f"{ref} TX/RX pair names are not in the netlist.",
facts, req, mpn=comp.mpn or "")
return _insufficient(ref, "PE-ETH-002",
f"{ref} MDI pairs not certified — speed class unknown.",
facts, req, mpn=comp.mpn or "")
def _eth_mag_finding(
graph: DesignGraph, comp: Component, speed: str, mag: bool,
) -> Finding:
ref = comp.reference
facts = f"speed={speed}; magnetics={mag}; blob={_blob(graph, comp)[:160]!r}."
req = "1000BASE-T requires magnetics (transformer or MagJack). Not a Z0 check."
if speed != "gbe":
return _info(ref, "PE-ETH-003",
f"{ref} GbE magnetics N/A (class is not GbE).",
facts, req, mpn=comp.mpn or "")
if mag:
return _info(ref, "PE-ETH-003",
f"{ref} GbE magnetics are present.",
facts, req, mpn=comp.mpn or "")
return _error(ref, "PE-ETH-003",
f"{ref} class GbE with no magnetics/MagJack FACT.",
facts, req,
f"Use a MagJack or LAN transformer on {ref} MDI.",
mpn=comp.mpn or "")
def _eth_bob_smith_finding(
graph: DesignGraph, comp: Component, speed: str,
) -> Finding:
ref = comp.reference
ohms = _bob_smith_ohms(graph, comp)
facts = f"speed={speed}; observed_75ohm={ohms}."
req = (
"Bob Smith (75 Ω on unused pairs / center taps) is typical for GbE EMI. "
"Often inside a MagJack — not a MANDATORY discrete FACT."
)
if speed != "gbe":
return _info(ref, "PE-ETH-004",
f"{ref} Bob Smith N/A (not GbE).",
facts, req, mpn=comp.mpn or "",
finding_class="INFO", provenance="RECOMMENDED")
if ohms:
return _info(ref, "PE-ETH-004",
f"{ref} Bob Smith 75 Ω observed ({ohms}).",
facts, req, mpn=comp.mpn or "",
finding_class="INFO", provenance="RECOMMENDED")
return _insufficient(
ref, "PE-ETH-004",
f"{ref} Bob Smith 75 Ω not observed (may be inside MagJack).",
facts, req, mpn=comp.mpn or "",
)
def _check_poe(graph: DesignGraph) -> list[Finding]:
jacks = [c for c in graph.components.values() if _is_ethernet_jack(c)]
if not jacks:
return [_na("PCB", "PE-POE-001", "PoE",
"No RJ45 — PoE is N/A (not invented).")]
out: list[Finding] = []
for comp in sorted(jacks, key=lambda c: c.reference):
ev = _poe_blob(graph, comp)
if not ev:
out.append(_na(comp.reference, "PE-POE-001", "PoE",
f"{comp.reference} is RJ45 without PoE evidence — "
"PoE not certified (bare jack is not PoE)."))
continue
out.extend(_poe_one(graph, comp, ev))
return out
def _poe_one(graph: DesignGraph, comp: Component, evidence: str) -> list[Finding]:
ref = comp.reference
mag = _has_magnetics(graph, comp)
class_txt = _poe_class_text(evidence)
out = [_info(ref, "PE-POE-001",
f"{ref} has PoE evidence — class certifier applies.",
f"evidence={evidence[:200]!r}.",
"PoE is certified only with a PoE FACT (never a bare RJ45).",
mpn=comp.mpn or "")]
if class_txt:
out.append(_info(ref, "PE-POE-002",
f"{ref} PoE {class_txt}.",
f"matched={class_txt}; evidence={evidence[:160]!r}.",
"PoE class/type from 802.3af/at/bt or Class n FACT.",
mpn=comp.mpn or ""))
else:
out.append(_insufficient(
ref, "PE-POE-002",
f"{ref} PoE class/type is not evidenced (af/at/bt / Class 08).",
f"evidence={evidence[:160]!r}.",
"Do not invent IEEE class or wattage.",
mpn=comp.mpn or "",
))
req = "PoE requires magnetics/isolation (MagJack or LAN transformer). Not FEM."
facts = f"magnetics={mag}; evidence={evidence[:120]!r}."
if mag:
out.append(_info(ref, "PE-POE-003",
f"{ref} PoE magnetics/isolation FACT is present.",
facts, req, mpn=comp.mpn or ""))
else:
out.append(_error(ref, "PE-POE-003",
f"{ref} has PoE evidence but no magnetics/isolation FACT.",
facts, req,
f"Use PoE magnetics / MagJack isolation on {ref}.",
mpn=comp.mpn or ""))
return out
def _blob(graph: DesignGraph | None, comp: Component) -> str:
parts = [
comp.value or "",
comp.footprint or "",
comp.mpn or "",
comp.component_subtype or "",
]
if graph is not None:
row = (graph.bom_fields or {}).get(comp.reference) or {}
for key in ("description", "Description", "value", "Value"):
if row.get(key):
parts.append(str(row.get(key)))
return " ".join(parts)
def _is_usb_c(comp: Component) -> bool:
if comp.component_type != ComponentType.CONNECTOR:
return False
if _USB_C_RE.search(_blob(None, comp)):
return True
return any(
_CC1_RE.search(_leaf(n)) or _CC2_RE.search(_leaf(n))
for n in comp.pins.values() if n
)
def _usb_c_generation(graph: DesignGraph, comp: Component) -> str:
blob = _blob(graph, comp)
if _USB2_RE.search(blob) and not _USB3_RE.search(blob):
return "usb2"
if _USB3_RE.search(blob):
return "usb3"
roles = _usb_c_roles(comp)
if roles.get("SS"):
return "usb3"
return "unknown"
def _usb_c_roles(comp: Component) -> dict[str, list[str]]:
roles: dict[str, list[str]] = {
"CC1": [], "CC2": [], "VBUS": [], "GND": [], "SS": [],
}
for pin, net in comp.pins.items():
if not net or is_no_connect_net(net):
continue
leaf = _leaf(net)
p = str(pin).upper()
if _CC1_RE.search(leaf) or p == "A5":
roles["CC1"].append(net)
elif _CC2_RE.search(leaf) or p == "B5":
roles["CC2"].append(net)
if _VBUS_RE.search(leaf) or p in _USBC_VBUS_PINS:
roles["VBUS"].append(net)
if _is_gnd(net) or p in _USBC_GND_PINS:
roles["GND"].append(net)
if _SS_NET_RE.search(leaf) or p in _USBC_SS_PINS:
roles["SS"].append(net)
for key, nets in list(roles.items()):
seen: set[str] = set()
uniq: list[str] = []
for n in nets:
if n not in seen:
seen.add(n)
uniq.append(n)
roles[key] = uniq
return roles
def _is_ethernet_jack(comp: Component) -> bool:
if comp.component_type != ComponentType.CONNECTOR:
return False
if _RJ45_RE.search(_blob(None, comp)):
return True
return bool(_mdi_pair_ids(comp))
def _eth_speed(graph: DesignGraph, comp: Component) -> str:
blob = _blob(graph, comp)
pairs = _mdi_pair_ids(comp)
trd = {p for p in pairs if p in {"0", "1", "2", "3"}}
if _ETH_GBE_RE.search(blob) or len(trd) >= 4:
return "gbe"
if _ETH_10_100_RE.search(blob):
return "10/100"
return "unknown"
def _mdi_pair_ids(comp: Component) -> set[str]:
polar: dict[str, set[str]] = {}
for net in comp.pins.values():
if not net or is_no_connect_net(net):
continue
leaf = _leaf(net).upper()
m = re.search(r"(?:TRD|MDI|TP)(\d)", leaf)
if m:
key = m.group(1)
pol = "p" if re.search(r"_P$|\+$", leaf) else "n" if re.search(
r"_N$|_M$|-$", leaf,
) else "?"
polar.setdefault(key, set()).add(pol)
continue
if re.search(r"(?:TX|TD)(?:\+|_P)$", leaf):
polar.setdefault("tx", set()).add("p")
elif re.search(r"(?:TX|TD)(?:-|_N)$", leaf):
polar.setdefault("tx", set()).add("n")
if re.search(r"(?:RX|RD)(?:\+|_P)$", leaf):
polar.setdefault("rx", set()).add("p")
elif re.search(r"(?:RX|RD)(?:-|_N)$", leaf):
polar.setdefault("rx", set()).add("n")
found: set[str] = set()
for key, pols in polar.items():
if "p" in pols and "n" in pols:
found.add(key)
return found
def _has_magnetics(graph: DesignGraph, comp: Component) -> bool:
if _MAG_RE.search(_blob(graph, comp)):
return True
for net in comp.pins.values():
if not net:
continue
for r in refs_on_matched_net(graph, net):
other = graph.components.get(r)
if other is None or other.reference == comp.reference:
continue
if other.component_type == ComponentType.TRANSFORMER:
return True
if _MAG_RE.search(_blob(graph, other)):
return True
return False
def _poe_blob(graph: DesignGraph, comp: Component) -> str:
blob = _blob(graph, comp)
hits = [m.group(0) for m in _POE_RE.finditer(blob)]
for net in comp.pins.values():
if net and _POE_RE.search(_leaf(net)):
hits.append(_leaf(net))
return " ".join(hits)
def _poe_class_text(evidence: str) -> str:
m = _POE_CLASS_RE.search(evidence or "")
if not m:
return ""
raw = m.group(0)
if re.search(r"802\.3BT", raw, re.I):
return "802.3bt"
if re.search(r"802\.3AT", raw, re.I):
return "802.3at"
if re.search(r"802\.3AF", raw, re.I):
return "802.3af"
if m.group(1):
return f"Class {m.group(1)}"
if m.group(2):
return f"Type {m.group(2)}"
return raw
def _cc_termination(
graph: DesignGraph, jack_ref: str, net: str,
) -> tuple[str, str]:
"""Return (pass|fail|insufficient, detail)."""
refs = refs_on_matched_net(graph, net)
r_gnd: list[tuple[str, float | None]] = []
r_pwr: list[tuple[str, float | None]] = []
ics: list[str] = []
for r in refs:
if r == jack_ref:
continue
c = graph.components.get(r)
if c is None:
continue
if c.component_type == ComponentType.IC:
ics.append(r)
continue
if c.component_type != ComponentType.RESISTOR:
continue
others = [n for n in c.pins.values() if n and not _nets_equal(n, net)]
ohms = _resistor_ohms(c)
if any(_is_gnd(n) for n in others):
r_gnd.append((r, ohms))
elif any(_is_power(graph, n) for n in others):
r_pwr.append((r, ohms))
if r_gnd:
known = [o for _, o in r_gnd if o is not None]
if any(o is None for _, o in r_gnd) and not known:
return "insufficient", f"Rd candidate {[r for r, _ in r_gnd]} value unknown"
if known and all(_in_band(o, USB_C_RD_OHM, USB_C_RD_TOL) for o in known):
return "pass", f"Rd={[f'{r}={o:g}Ω' for r, o in r_gnd if o is not None]}"
if known:
return "fail", (
f"Rd not 5.1k {[f'{r}={o:g}Ω' for r, o in r_gnd if o is not None]}"
)
if r_pwr:
known = [o for _, o in r_pwr if o is not None]
if any(o is None for _, o in r_pwr) and not known:
return "insufficient", f"Rp candidate {[r for r, _ in r_pwr]} value unknown"
if known and all(_in_rp_window(o) for o in known):
return "pass", f"Rp={[f'{r}={o:g}Ω' for r, o in r_pwr if o is not None]}"
if known:
return "fail", (
f"Rp not 56/22/10k {[f'{r}={o:g}Ω' for r, o in r_pwr if o is not None]}"
)
if ics:
return "insufficient", f"CC on IC {ics} without discrete Rp/Rd FACT"
return "fail", "CC floating (no Rp/Rd, no CC IC)"
def _worse_term(a: str, b: str) -> str:
rank = {"pass": 0, "insufficient": 1, "fail": 2}
return a if rank.get(a, 0) >= rank.get(b, 0) else b
def _bob_smith_ohms(graph: DesignGraph, jack: Component) -> list[str]:
found: list[str] = []
extra = [n for n in graph.nets if _CT_RE.search(_leaf(n))]
for net in list(jack.pins.values()) + extra:
if not net:
continue
for r in refs_on_matched_net(graph, net):
c = graph.components.get(r)
if c is None or c.component_type != ComponentType.RESISTOR:
continue
ohms = _resistor_ohms(c)
if ohms is not None and _in_band(ohms, BOB_SMITH_OHM, BOB_SMITH_TOL):
found.append(f"{c.reference}={ohms:g}Ω")
return found
def _resistor_ohms(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0:
return float(specs.value_ohms)
return _parse_ohms(comp.value)
def _parse_ohms(raw: str | None) -> float | None:
t = (raw or "").strip().upper().replace("OHMS", "").replace("OHM", "")
t = t.replace("Ω", "").replace(" ", "")
if not t:
return None
m = _OHM_EMBEDDED.match(t)
if m:
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
return (float(m.group(1)) + float(f"0.{m.group(3)}")) * mult
m = _OHM_SUFFIX.match(t)
if m:
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
return float(m.group(1)) * mult
try:
return float(t)
except ValueError:
return None
def _in_band(value: float, center: float, tol: float) -> bool:
return abs(value - center) <= center * tol
def _in_rp_window(ohms: float) -> bool:
return any(_in_band(ohms, c, USB_C_RP_TOL) for c in USB_C_RP_OHM)
def _leaf(net: str) -> str:
n = normalize_kicad_hierarchy_net(net)
return n.split("/")[-1] if n else ""
def _is_gnd(net: str) -> bool:
leaf = _leaf(net).upper()
return leaf in {"GND", "AGND", "DGND", "PGND", "VSS", "GNDA", "GNDD"} or (
leaf.startswith("GND") or leaf.endswith("_GND") or leaf.endswith("_VSS")
)
def _is_power(graph: DesignGraph, net: str) -> bool:
n = graph.nets.get(net)
if n and n.net_type == NetType.POWER:
return True
leaf = _leaf(net)
if _VBUS_RE.search(leaf):
return True
return bool(re.match(r"^\+?\d+(\.\d+)?V", leaf, re.I))
def _nets_equal(a: str, b: str) -> bool:
if a == b:
return True
return _leaf(a).upper() == _leaf(b).upper()
def _na(designator: str, rule_id: str, kind: str, facts: str) -> Finding:
return _info(
designator, rule_id,
f"{kind} N/A — no candidate to certify.",
facts,
f"{kind} certifier runs always; absence is N/A, not ERROR.",
)
def _info(
designator: str, rule_id: str, finding: str, facts: str, requirement: str,
*, mpn: str = "", net: str | None = None, pins: list[str] | None = None,
finding_class: str = "INFO", provenance: str = "TYPICAL",
) -> Finding:
rec = "No connection-integrity change required for this check."
return Finding(
designator=designator,
mpn=mpn,
aspect="interface_class",
finding=finding,
why=requirement,
facts=facts,
requirement=requirement,
inference="Class/integrity FACT from netlist — not SI Z0 and not invented I.",
status="INFO",
recommendation=rec,
action=rec,
source=SOURCE,
rule_id=rule_id,
finding_class=finding_class, # type: ignore[arg-type]
provenance=provenance, # type: ignore[arg-type]
evidence_status="SUFFICIENT",
confidence=0.9,
net=net,
pins=pins or [],
)
def _insufficient(
designator: str, rule_id: str, finding: str, facts: str, requirement: str,
*, mpn: str = "",
) -> Finding:
rec = "Add BOM/net/footprint evidence, then re-run. Do not invent class or values."
return Finding(
designator=designator,
mpn=mpn,
aspect="interface_class",
finding=finding,
why=requirement,
facts=facts,
requirement=requirement,
inference=(
"Insufficient evidence — not inventing PoE, SuperSpeed, Z, I, or mm."
),
status="INFO",
recommendation=rec,
action=rec,
source=SOURCE,
rule_id=rule_id,
finding_class="INFO",
provenance="TYPICAL",
evidence_status="INSUFFICIENT",
confidence=0.3,
pins=[],
)
def _error(
designator: str, rule_id: str, finding: str, facts: str, requirement: str,
action: str, *, mpn: str = "",
) -> Finding:
return Finding(
designator=designator,
mpn=mpn,
aspect="interface_class",
finding=finding,
why=requirement,
facts=facts,
requirement=requirement,
inference="Measured netlist FACT vs USB-C / IEEE connection requirement.",
status="ERROR",
recommendation=action,
action=action,
source=SOURCE,
rule_id=rule_id,
finding_class="RULE",
provenance="MANDATORY",
evidence_status="SUFFICIENT",
confidence=0.85,
pins=[],
)
+15 -1
View File
@@ -28,6 +28,7 @@ from backend.periscopex.pcb_power_thermal import (
)
from backend.periscopex.pi_check import check_power_integrity
from backend.periscopex.placement_check import check_placement
from backend.periscopex.interface_class_check import SOURCE as IF_SOURCE, check_interface_classes
from backend.periscopex.si_check import check_si
from backend.periscopex.spof_check import check_spof
from backend.periscopex.timing_check import check_timing
@@ -161,7 +162,19 @@ def merge_schema_pcb_reports(
f for f in (schema.get("findings") or [])
if not str(f.get("finding_id") or "").startswith("PCB-")
]
findings.extend(pcb.get("findings") or [])
schema_if = {
(str(f.get("rule_id") or ""), str(f.get("designator") or ""))
for f in findings
if f.get("source") == IF_SOURCE
}
for f in (pcb.get("findings") or []):
if (
f.get("source") == IF_SOURCE
and (str(f.get("rule_id") or ""), str(f.get("designator") or ""))
in schema_if
):
continue
findings.append(f)
out = dict(schema)
out["findings"] = findings
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
@@ -272,6 +285,7 @@ def run_pcb_checks(
("spof_check", lambda: check_spof(graph, constraints_map)),
("emi_check", lambda: check_emi(graph, constraints_map, layout)),
("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)),
("interface_class", lambda: check_interface_classes(graph)),
):
try:
out.extend(fn())
@@ -12,6 +12,7 @@ from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.pcb_power_thermal import _footprint_region
from backend.periscopex.placement_check import _in_poly
from backend.periscopex.si_check import net_length_mm
from backend.periscopex.interface_class_check import format_interface_class_context
PCB_SYSTEM_PROMPT = """\
You are an electrical engineer reviewing a PCB layout against the IC \
@@ -35,6 +36,10 @@ notes. Do not claim the via count or size is missing if that block lists them.
Do not claim antenna keepout geometry is missing if keepout zones are listed.
- Kelvin/sense pins, crystal keepout — only if named in the pintable/layout_rules.
- Creepage/clearance only if the extraction or IEC number is in context.
- Interface class: USB-C CC/Rd/VBUS, Ethernet 10/100 vs GbE magnetics, \
PoE only with evidence. Deterministic certifiers already ran; explain \
those FACTS. Never invent PoE on a bare RJ45 or SuperSpeed on USB2 Type-C. \
Never invent millimetres, Z, or I.
### Evidence
Cite numbers from "Parsed board geometry". Say insufficient evidence ONLY \
@@ -315,4 +320,5 @@ def build_pcb_layout_context(
)
if domain_id:
lines.append(f"(domain_id={domain_id})")
lines.append(format_interface_class_context(graph))
return "\n".join(lines)
@@ -40,6 +40,10 @@ datasheet's recommended values.
- Required external components named by the datasheet (bootstrap, \
compensation, feedback divider, sense resistor).
- Unused / no-connect pins.
- Dedicated interface class (USB-C CC1/CC2 Rp/Rd 5.1 kΩ VBUS/GND; Ethernet \
10/100 vs GbE magnetics; PoE only with evidence). Deterministic certifiers \
own those FACTS. Do not invent PoE on a bare RJ45, SuperSpeed on USB 2.0 \
Type-C, or geometry/Z/I.
Then work the areas one at a time. For EACH area, don't just confirm a \
part is present — ask what specific failure mode would make it wrong \
@@ -24,6 +24,7 @@ from backend.periscopex.filter_check import check_filters
from backend.periscopex.finding_engine import apply_decisions
from backend.periscopex.hf_coverage_check import check_hf_decoupling_coverage
from backend.periscopex.internal_features_check import check_internal_features
from backend.periscopex.interface_class_check import check_interface_classes
from backend.periscopex.led_current_check import check_led_current
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
from backend.periscopex.models import ComponentType, DesignGraph, Finding, ValidationReport
@@ -84,6 +85,7 @@ def _run_deterministic_checks(
("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
("crystal_cl_check", lambda: check_crystal_cl(graph)),
("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)),
("interface_class", lambda: check_interface_classes(graph)),
):
try:
out.extend(fn())
@@ -0,0 +1,34 @@
# Certifier di classe (interfacce)
Michele 2026-09-21: per USB-C, Ethernet (10/100 vs GbE), PoE, DDR3 e analoghe, check dedicati (deterministici + IA). Certificare **classe** e **integrità delle connessioni**. FEM termico: no, per ora.
## Cosa non è
Non è un unico check SI generico. Non è il DRC KiCad. Non è “via ampacity IPC” (traccia vs corrente è un altro motore).
## Formato finding
Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). Se manca evidenza: INSUFFICIENT_EVIDENCE, niente geometria/Z/I inventati. Via ≠ pad ≠ track.
## Prima fetta (shipped 2.61.0)
| ID | Cosa certifica |
| --- | --- |
| PE-USBC-001 | Classe USB-C USB2 vs USB3 (BOM/footprint/MPN/net) |
| PE-USBC-002 | CC1 e CC2 connessi, non NC |
| PE-USBC-003 | Rd 5.1 kΩ ±10% verso GND, o Rp 56/22/10 kΩ verso VBUS; IC CC → INSUFFICIENT |
| PE-USBC-004 | VBUS e GND |
| PE-USBC-005 | Coppie SuperSpeed **solo** se la classe è USB3; USB2 = N/A |
| PE-ETH-001 | Classe 10/100 vs GbE |
| PE-ETH-002 | Coppie TX/RX (10/100) o quattro MDI (GbE) |
| PE-ETH-003 | Magnetics se GbE; N/A se non GbE |
| PE-ETH-004 | Bob Smith 75 Ω (RECOMMENDED; spesso nel MagJack) |
| PE-POE-001 | Evidenza PoE, altrimenti N/A (RJ45 nudo ≠ PoE) |
| PE-POE-002 | Classe/tipo 802.3af/at/bt se citati; altrimenti INSUFFICIENT |
| PE-POE-003 | Isolamento/magnetics solo con evidenza PoE |
Motore: `periscopex/interface_class_check.py` (grafo, non geometria). Schema `MODE=run` e PCB `MODE=pcb`. LIA spiega i FACT, non certifica.
## Dopo
DDR3 (indirizzo/comando vs DQ, lunghezze, VTT/VREF). Altre periferiche con lo stesso schema.
+4
View File
@@ -74,6 +74,9 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net
| PE-EMI-001 | filtro EMI solo se `layout_rules` cita choke/ferrite/shield |
| PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net |
| PE-STCH-001 | pour GND + segnale senza via GND nel bbox |
| PE-USBC-001…005 | classe USB-C + CC + Rd/Rp + VBUS/GND + SuperSpeed se USB3 |
| PE-ETH-001…004 | 10/100 vs GbE; magnetics se GbE; Bob Smith REVIEW |
| PE-POE-001…003 | PoE solo con evidenza; isolamento/magnetics; RJ45 nudo = N/A |
| Creepage | **skip** senza numero datasheet/IEC |
| Crystal keepout | PE-PLC-004 se kind=keepout |
@@ -122,6 +125,7 @@ Pintable skill **1.13.0** (`net_class` required on SI kinds). Older extracts wit
| Gap | Why it stays out |
| --- | --- |
| DDR3 class certifier | After USB-C / Ethernet / PoE first slice (2.61.0) |
| Thermal FEM / energy | PE-THM-002 is Tj=Ta+P·θJA with measured copper/vias; no spreading/FEM |
| CPWG / OpenEMS | Field-solver export excluded from the ImpedenceFinder vendor snapshot |
| ImpedenceFinder license | Upstream has **no LICENSE** (UNKNOWN). Not invented in-tree. See `vendor/impedancefinder/SOURCE.md` |
@@ -2,6 +2,15 @@
What's new in Periscope.
## 2.61.0 — 2026-09-21 — Interface class certifiers (USB-C, Ethernet, PoE)
Dedicated per-interface certifiers (deterministic + AI explanation). Not generic SI. USB-C: CC1/CC2, Rp/Rd 5.1 kΩ, VBUS/GND, SuperSpeed only if the class is USB3. Ethernet: 10/100 vs GbE; magnetics required for GbE. PoE only with PoE evidence — a bare RJ45 is N/A, never invented PoE. Missing generation/class/ohms → INSUFFICIENT. DDR3 and via IPC ampacity are out of this slice. No FEM.
- [New] `PE-USBC-001``005` USB-C class + CC + Rd/Rp + VBUS/GND + SuperSpeed gate.
- [New] `PE-ETH-001``004` Ethernet 10/100 vs GbE, TX/RX pairs, GbE magnetics, Bob Smith REVIEW.
- [New] `PE-POE-001``003` PoE evidence / class / isolation; bare RJ45 is N/A.
- [New] Taxonomy `connector.rj45`. AI prompts must not invent PoE or SuperSpeed.
## 2.60.11 — 2026-09-21 — PCB software false positives
KiCad 9/10 boards have no numeric net table (`(net "GND")` on pads/tracks). The net index is filled from those names. PE-LAY-004 counts copper on **tracks, vias, and pours**, skips `unconnected-(…)` NC nets, and matches `/sheet/` prefixes — it must not report ~139 “unrouted” nets when KiCad DRC `unconnected_items` is ~30 (islands on already-named nets). Missing `I_load` is INSUFFICIENT on PE-PWR-001 / PE-VIA-001. Thermal/EP extra pads are not PE-BOM-011 ERROR. Voltage abs-max binds to the named pin. Kelvin ignores CRS/IN±. EMI/PI skip NC nets. LQW18AN `murata_lqw_inductance` decodes nH. FB/BLM beads are ferrite; DCR is not Z. Thinking `reasoning_content` echo unchanged.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
"version": "2.60.0",
"version": "2.61.0",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
+2 -2
View File
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.56.0";
export const APP_VERSION_DATE = "2026-09-20";
export const APP_VERSION = "2.61.0";
export const APP_VERSION_DATE = "2026-09-21";
+13
View File
@@ -45,6 +45,19 @@
}
]
},
"connector.rj45": {
"description": "RJ45 / 8P8C Ethernet jack (with or without magnetics)",
"extra_specs": [
{
"name": "speed",
"description": "Ethernet speed class (10/100, 1000) when marked"
},
{
"name": "poe",
"description": "PoE marking when present (never inferred from a bare jack)"
}
]
},
"connector.fpc": {
"description": "FPC / FFC connector",
"extra_specs": [
+4 -3
View File
@@ -1,7 +1,8 @@
"""Eval harness — finding count, citation hit-rate, precision/recall.
Favor: perfect golden match; citation rate ignores deterministic findings;
simple_project graph still has U1/U2/U3 and the two I2C pull-up keys.
simple_project graph still has U1/U2/U3, the two I2C pull-up keys,
and USB-C class certifiers on J1 (Ethernet/PoE N/A).
Against: extra finding drops precision; missing golden key drops recall;
Unverified quotes are citation misses, not hits.
"""
@@ -87,9 +88,9 @@ def test_simple_project_eval_matches_committed_golden():
assert scores.graph_ok, scores.graph_errors
assert scores.precision == 1.0
assert scores.recall == 1.0
assert scores.finding_count == 3
assert scores.finding_count == 10
assert scores.by_status["WARNING"] == 2
assert scores.by_status["INFO"] == 1
assert scores.by_status["INFO"] == 8
def test_simple_project_eval_rejects_truncated_graph(tmp_path: Path):
+609
View File
@@ -0,0 +1,609 @@
"""Interface class certifiers — USB-C, Ethernet 10/100 vs GbE, PoE.
First slice: class + connection integrity. Not generic SI, not via ampacity,
not thermal FEM. Missing evidence is INSUFFICIENT or N/A, never invented
geometry / Z / I / PoE / SuperSpeed.
"""
from __future__ import annotations
from backend.periscopex.finding_engine import complete_finding, lookup_rule
from backend.periscopex.interface_class_check import check_interface_classes
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
ResistorSpecs,
)
from backend.periscopex.pcb_checks import merge_schema_pcb_reports, run_pcb_checks
from tests.paths import SIMPLE_PROJECT
def _comp(
ref: str,
*,
ctype: ComponentType,
value: str = "",
footprint: str = "",
mpn: str = "",
pins: dict[str, str] | None = None,
subtype: str | None = None,
specs=None,
) -> Component:
return Component(
reference=ref,
value=value,
footprint=footprint,
component_type=ctype,
component_subtype=subtype,
mpn=mpn or None,
pins=pins or {},
specs=specs,
)
def _net(name: str, ntype: NetType, *refs_pins: tuple[str, str]) -> Net:
return Net(
name=name,
net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in refs_pins],
)
def _by_rule(findings, rule_id: str, designator: str | None = None):
out = [
f for f in findings
if f.rule_id == rule_id and (designator is None or f.designator == designator)
]
return out
def _rd(ref: str, cc_net: str) -> Component:
return _comp(
ref,
ctype=ComponentType.RESISTOR,
value="5k1",
pins={"1": cc_net, "2": "GND"},
specs=ResistorSpecs(
value_ohms=5100.0, value_formatted="5.1k",
),
)
def _usbc_device_graph(*, ss: bool = False, usb2_value: bool = True) -> DesignGraph:
"""USB-C receptacle with Rd 5.1 kΩ on CC1/CC2, VBUS, GND."""
value = "USB_C_Receptacle_USB2.0_16P" if usb2_value else "USB_C_Receptacle_USB3.1"
pins = {
"A5": "USB_CC1",
"B5": "USB_CC2",
"A4": "VBUS",
"A9": "VBUS",
"A1": "GND",
"A12": "GND",
"A6": "USB_D+",
"A7": "USB_D-",
}
nets = {
"USB_CC1": _net("USB_CC1", NetType.SIGNAL, ("J2", "A5"), ("R10", "1")),
"USB_CC2": _net("USB_CC2", NetType.SIGNAL, ("J2", "B5"), ("R11", "1")),
"VBUS": _net("VBUS", NetType.POWER, ("J2", "A4"), ("J2", "A9")),
"GND": _net("GND", NetType.GROUND, ("J2", "A1"), ("J2", "A12"), ("R10", "2"), ("R11", "2")),
"USB_D+": _net("USB_D+", NetType.SIGNAL, ("J2", "A6")),
"USB_D-": _net("USB_D-", NetType.SIGNAL, ("J2", "A7")),
}
comps = {
"J2": _comp(
"J2",
ctype=ComponentType.CONNECTOR,
value=value,
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
mpn="TYPE-C-31-M-12",
subtype="connector.usb",
pins=pins,
),
"R10": _rd("R10", "USB_CC1"),
"R11": _rd("R11", "USB_CC2"),
}
if ss:
pins["A2"] = "USB_SSTX_P"
pins["A3"] = "USB_SSTX_N"
pins["B11"] = "USB_SSRX_P"
pins["B10"] = "USB_SSRX_N"
nets["USB_SSTX_P"] = _net("USB_SSTX_P", NetType.SIGNAL, ("J2", "A2"))
nets["USB_SSTX_N"] = _net("USB_SSTX_N", NetType.SIGNAL, ("J2", "A3"))
nets["USB_SSRX_P"] = _net("USB_SSRX_P", NetType.SIGNAL, ("J2", "B11"))
nets["USB_SSRX_N"] = _net("USB_SSRX_N", NetType.SIGNAL, ("J2", "B10"))
comps["J2"] = _comp(
"J2",
ctype=ComponentType.CONNECTOR,
value="USB_C_Receptacle_USB3.1",
footprint="Connector_USB:USB_C_Receptacle_24P",
mpn="USB3-C-24",
subtype="connector.usb",
pins=pins,
)
return DesignGraph(components=comps, nets=nets)
def test_no_connector_is_na_not_error():
g = DesignGraph(
components={
"U1": _comp("U1", ctype=ComponentType.IC, value="MCU", pins={"1": "GND"}),
},
nets={"GND": _net("GND", NetType.GROUND, ("U1", "1"))},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
assert _by_rule(findings, "PE-USBC-001")
assert _by_rule(findings, "PE-ETH-001")
assert _by_rule(findings, "PE-POE-001")
assert all(f.status != "ERROR" for f in findings)
assert all(f.finding_class == "INFO" for f in findings)
assert "N/A" in _by_rule(findings, "PE-USBC-001")[0].finding
assert "N/A" in _by_rule(findings, "PE-ETH-001")[0].finding
assert "N/A" in _by_rule(findings, "PE-POE-001")[0].finding
def test_usbc_rd_vbus_gnd_certified():
findings = check_interface_classes(_usbc_device_graph())
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-USBC-001", "J2")[0]
assert cls.status == "INFO"
assert "USB2" in cls.finding
assert _by_rule(findings, "PE-USBC-002", "J2")[0].status == "INFO"
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status == "INFO"
assert rd.evidence_status == "SUFFICIENT"
assert "5100" in rd.facts or "5.1" in rd.facts
assert _by_rule(findings, "PE-USBC-004", "J2")[0].status == "INFO"
ss = _by_rule(findings, "PE-USBC-005", "J2")[0]
assert ss.status != "ERROR"
assert ss.finding_class == "INFO"
assert "N/A" in ss.finding or "USB2" in ss.finding
assert all(f.status != "ERROR" for f in findings if f.designator == "J2")
def test_usbc_missing_cc2_is_error():
g = _usbc_device_graph()
j2 = g.components["J2"]
pins = dict(j2.pins)
pins.pop("B5")
g.components["J2"] = j2.model_copy(update={"pins": pins})
del g.nets["USB_CC2"]
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cc = _by_rule(findings, "PE-USBC-002", "J2")[0]
assert cc.status == "ERROR"
assert cc.finding_class == "RULE"
assert cc.evidence_status == "SUFFICIENT"
def test_usbc_wrong_rd_is_error():
g = _usbc_device_graph()
g.components["R10"] = _comp(
"R10",
ctype=ComponentType.RESISTOR,
value="10k",
pins={"1": "USB_CC1", "2": "GND"},
specs=ResistorSpecs(value_ohms=10000.0, value_formatted="10k"),
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status == "ERROR"
assert rd.finding_class == "RULE"
def test_usbc_cc_to_controller_without_r_is_insufficient_not_error():
g = _usbc_device_graph()
del g.components["R10"]
del g.components["R11"]
g.components["U5"] = _comp(
"U5",
ctype=ComponentType.IC,
value="CC controller",
mpn="CC-IC",
pins={"1": "USB_CC1", "2": "USB_CC2"},
)
g.nets["USB_CC1"] = _net("USB_CC1", NetType.SIGNAL, ("J2", "A5"), ("U5", "1"))
g.nets["USB_CC2"] = _net("USB_CC2", NetType.SIGNAL, ("J2", "B5"), ("U5", "2"))
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status != "ERROR"
assert rd.evidence_status == "INSUFFICIENT"
assert rd.finding_class == "INFO"
def test_usbc_unknown_r_value_is_insufficient():
g = _usbc_device_graph()
g.components["R10"] = _comp(
"R10",
ctype=ComponentType.RESISTOR,
value="",
pins={"1": "USB_CC1", "2": "GND"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status != "ERROR"
assert rd.evidence_status == "INSUFFICIENT"
def test_usb3_class_without_ss_pairs_is_error():
g = _usbc_device_graph(usb2_value=False)
j2 = g.components["J2"]
g.components["J2"] = j2.model_copy(update={
"value": "USB_C_Receptacle_USB3.1",
"footprint": "Connector_USB:USB_C_Receptacle_24P",
"mpn": "USB3-C",
})
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
ss = _by_rule(findings, "PE-USBC-005", "J2")[0]
assert ss.status == "ERROR"
assert ss.finding_class == "RULE"
assert "SuperSpeed" in ss.finding or "USB3" in ss.finding
def test_usb3_with_ss_pairs_is_certified():
findings = check_interface_classes(_usbc_device_graph(ss=True, usb2_value=False))
for f in findings:
complete_finding(f)
ss = _by_rule(findings, "PE-USBC-005", "J2")[0]
assert ss.status == "INFO"
assert ss.evidence_status == "SUFFICIENT"
assert ss.status != "ERROR"
def test_missing_vbus_is_error():
g = _usbc_device_graph()
j2 = g.components["J2"]
pins = {k: v for k, v in j2.pins.items() if v != "VBUS"}
g.components["J2"] = j2.model_copy(update={"pins": pins})
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
pwr = _by_rule(findings, "PE-USBC-004", "J2")[0]
assert pwr.status == "ERROR"
assert pwr.finding_class == "RULE"
def _rj45(
*,
value: str,
mpn: str,
pins: dict[str, str],
extra_comps: dict[str, Component] | None = None,
extra_nets: dict[str, Net] | None = None,
) -> DesignGraph:
comps = {
"J1": _comp(
"J1",
ctype=ComponentType.CONNECTOR,
value=value,
footprint="Connector_RJ:RJ45",
mpn=mpn,
pins=pins,
),
}
nets = {}
for pin, net in pins.items():
nets.setdefault(net, Net(name=net, net_type=NetType.SIGNAL, pins=[]))
nets[net].pins.append(PinConnection(component_ref="J1", pin_number=pin))
if net.upper() in {"GND", "AGND"}:
nets[net].net_type = NetType.GROUND
if extra_comps:
comps.update(extra_comps)
if extra_nets:
for n, net in extra_nets.items():
if n in nets:
nets[n].pins.extend(net.pins)
else:
nets[n] = net
return DesignGraph(components=comps, nets=nets)
def test_ethernet_10_100_magjack_no_gbe_magnetics_error():
g = _rj45(
value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module",
mpn="ARJP11A-MASA-B-A-EMU2",
pins={
"1": "ETH_TX+",
"2": "ETH_TX-",
"3": "ETH_RX+",
"6": "ETH_RX-",
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-ETH-001", "J1")[0]
assert cls.status == "INFO"
assert "10/100" in cls.finding
assert "GbE" not in cls.finding or "not GbE" in cls.finding.lower() or "10/100" in cls.finding
pairs = _by_rule(findings, "PE-ETH-002", "J1")[0]
assert pairs.status == "INFO"
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status != "ERROR"
assert mag.finding_class == "INFO"
assert "N/A" in mag.finding or "10/100" in mag.finding
def test_gbe_without_magnetics_is_error():
g = _rj45(
value="RJ45 1000BASE-T",
mpn="RJ45-GBE-BARE",
pins={
"1": "TRD0_P",
"2": "TRD0_N",
"3": "TRD1_P",
"6": "TRD1_N",
"4": "TRD2_P",
"5": "TRD2_N",
"7": "TRD3_P",
"8": "TRD3_N",
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-ETH-001", "J1")[0]
assert "GbE" in cls.finding or "1000" in cls.finding
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status == "ERROR"
assert mag.finding_class == "RULE"
assert mag.evidence_status == "SUFFICIENT"
def test_gbe_with_magnetics_is_certified():
g = _rj45(
value="RJ45 1000BASE-T MagJack",
mpn="PULSE-GBE-MAG",
pins={
"1": "TRD0_P",
"2": "TRD0_N",
"3": "TRD1_P",
"6": "TRD1_N",
"4": "TRD2_P",
"5": "TRD2_N",
"7": "TRD3_P",
"8": "TRD3_N",
},
extra_comps={
"T1": _comp(
"T1",
ctype=ComponentType.TRANSFORMER,
value="LAN magnetics",
mpn="HX1198FNL",
subtype="transformer.signal",
pins={"1": "TRD0_P", "2": "TRD0_N"},
),
},
extra_nets={
"TRD0_P": _net("TRD0_P", NetType.SIGNAL, ("T1", "1")),
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status == "INFO"
assert mag.evidence_status == "SUFFICIENT"
assert mag.status != "ERROR"
def test_rj45_without_speed_is_insufficient_not_invented_gbe():
g = _rj45(
value="RJ45",
mpn="8P8C",
pins={"1": "NET1", "2": "NET2", "3": "NET3", "6": "NET6"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-ETH-001", "J1")[0]
assert cls.status != "ERROR"
assert cls.evidence_status == "INSUFFICIENT"
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status != "ERROR"
assert mag.finding_class == "INFO"
def test_bare_rj45_does_not_invent_poe():
g = _rj45(
value="RJ45",
mpn="8P8C-BARE",
pins={"1": "ETH_TX+", "2": "ETH_TX-", "3": "ETH_RX+", "6": "ETH_RX-"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
poe = _by_rule(findings, "PE-POE-001", "J1")[0]
assert poe.status != "ERROR"
assert poe.finding_class == "INFO"
assert "N/A" in poe.finding
assert not _by_rule(findings, "PE-POE-003", "J1") or _by_rule(
findings, "PE-POE-003", "J1",
)[0].status != "ERROR"
def test_poe_with_evidence_requires_magnetics_isolation():
g = _rj45(
value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module",
mpn="ARJP11A-MASA-B-A-EMU2",
pins={
"1": "ETH_TX+",
"2": "ETH_TX-",
"3": "ETH_RX+",
"6": "ETH_RX-",
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
ev = _by_rule(findings, "PE-POE-001", "J1")[0]
assert ev.status == "INFO"
assert ev.evidence_status == "SUFFICIENT"
assert "PoE" in ev.finding
cls = _by_rule(findings, "PE-POE-002", "J1")[0]
assert cls.status != "ERROR"
assert cls.evidence_status == "INSUFFICIENT"
iso = _by_rule(findings, "PE-POE-003", "J1")[0]
assert iso.status == "INFO"
assert iso.evidence_status == "SUFFICIENT"
def test_poe_evidence_on_bare_jack_is_isolation_error():
g = _rj45(
value="RJ45 PoE 802.3af",
mpn="RJ45-POE-BARE",
pins={"1": "ETH_TX+", "2": "ETH_TX-", "3": "ETH_RX+", "6": "ETH_RX-"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
iso = _by_rule(findings, "PE-POE-003", "J1")[0]
assert iso.status == "ERROR"
assert iso.finding_class == "RULE"
def test_simple_project_usbc_not_false_error():
graph = DesignGraph.model_validate_json(
(SIMPLE_PROJECT / "design_graph.json").read_text()
)
findings = check_interface_classes(graph)
for f in findings:
complete_finding(f)
usbc = [f for f in findings if (f.rule_id or "").startswith("PE-USBC") and f.designator == "J1"]
assert usbc
assert all(f.status != "ERROR" for f in usbc)
rd = _by_rule(findings, "PE-USBC-003", "J1")[0]
assert rd.status == "INFO"
assert rd.evidence_status == "SUFFICIENT"
ss = _by_rule(findings, "PE-USBC-005", "J1")[0]
assert ss.status != "ERROR"
assert ss.evidence_status == "INSUFFICIENT" or "N/A" in ss.finding
assert _by_rule(findings, "PE-ETH-001")[0].status != "ERROR"
assert "N/A" in _by_rule(findings, "PE-ETH-001")[0].finding
assert _by_rule(findings, "PE-POE-001")[0].status != "ERROR"
assert "N/A" in _by_rule(findings, "PE-POE-001")[0].finding
def test_hubaudio_like_usbc_usb2_and_poe_magjack():
"""HubAudio J2 is USB2 Type-C 16P; J1 is PoE 10/100 MagJack — no false SS/GbE/PoE class."""
usbc = _usbc_device_graph()
eth = _rj45(
value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module",
mpn="ARJP11A-MASA-B-A-EMU2",
pins={
"1": "ETH_TX+",
"2": "ETH_TX-",
"3": "ETH_RX+",
"6": "ETH_RX-",
},
)
comps = dict(usbc.components)
comps.update(eth.components)
nets = dict(usbc.nets)
for name, net in eth.nets.items():
if name in nets:
nets[name] = nets[name].model_copy(
update={"pins": list(nets[name].pins) + list(net.pins)},
)
else:
nets[name] = net
g = DesignGraph(components=comps, nets=nets)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
assert _by_rule(findings, "PE-USBC-005", "J2")[0].status != "ERROR"
assert _by_rule(findings, "PE-ETH-001", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-ETH-003", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-POE-001", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-POE-002", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-POE-003", "J1")[0].status != "ERROR"
assert all(
f.status != "ERROR"
for f in findings
if (f.rule_id or "").startswith(("PE-USBC", "PE-ETH", "PE-POE"))
)
def test_rule_catalog_shared_not_si():
for rid in (
"PE-USBC-001", "PE-USBC-002", "PE-USBC-003", "PE-USBC-004", "PE-USBC-005",
"PE-ETH-001", "PE-ETH-002", "PE-ETH-003", "PE-ETH-004",
"PE-POE-001", "PE-POE-002", "PE-POE-003",
):
rec = lookup_rule(rid)
assert rec is not None, rid
assert rec.domain == "shared"
def test_run_pcb_checks_includes_interface_class():
findings = run_pcb_checks(DesignGraph(), {}, None)
ids = {f.rule_id for f in findings}
assert "PE-USBC-001" in ids
assert "PE-ETH-001" in ids
assert "PE-POE-001" in ids
if_f = [f for f in findings if (f.rule_id or "").startswith(("PE-USBC", "PE-ETH", "PE-POE"))]
assert if_f
assert all(f.status != "ERROR" for f in if_f)
assert all((f.finding_id or "").startswith("PCB-") for f in if_f)
def test_merge_drops_duplicate_pcb_interface_findings():
schema = {
"findings": [{
"finding_id": "J2-001",
"designator": "J2",
"finding": "class USB-C USB2",
"status": "INFO",
"rule_id": "PE-USBC-001",
"source": "interface_class_check",
}],
"summary": {"INFO": 1, "ERROR": 0, "WARNING": 0},
}
pcb = {
"findings": [{
"finding_id": "PCB-J2-001",
"designator": "J2",
"finding": "class USB-C USB2",
"status": "INFO",
"rule_id": "PE-USBC-001",
"source": "interface_class_check",
}],
"summary": {"INFO": 1, "ERROR": 0, "WARNING": 0},
}
merged = merge_schema_pcb_reports(schema, pcb)
usbc = [
f for f in merged["findings"]
if f.get("rule_id") == "PE-USBC-001" and f.get("designator") == "J2"
]
assert len(usbc) == 1
assert usbc[0]["finding_id"] == "J2-001"
def test_does_not_use_layout_geometry():
"""Certifier takes the netlist graph only — no invented mm / Z / I."""
import inspect
from backend.periscopex import interface_class_check as mod
sig = inspect.signature(mod.check_interface_classes)
assert list(sig.parameters) == ["graph"]
src = inspect.getsource(mod)
assert "LayoutVia" not in src
assert "LayoutPad" not in src
assert "LayoutSegment" not in src
assert "LayoutZone" not in src
@@ -15,9 +15,15 @@ DOCKERIGNORE = ROOT / ".dockerignore"
def test_package_json_is_periscope_web():
import re
text = PKG.read_text(encoding="utf-8")
assert '"name": "periscope-web"' in text
assert '"version": "2.60.0"' in text
chg = (ROOT / "periscope" / "src" / "frontend" / "content" / "changelog.md").read_text(
encoding="utf-8",
)
ver = re.search(r"^##\s+(\d+\.\d+\.\d+)", chg, re.M)
assert ver, "changelog missing ## X.Y.Z"
assert f'"version": "{ver.group(1)}"' in text
assert "Native Periscope overlay" not in text[:400]
assert LOCK.is_file()
lock = LOCK.read_text(encoding="utf-8")
+1 -1
View File
@@ -48,7 +48,7 @@ def test_simple_project_without_pcb_has_no_ps_plc():
def test_simple_project_eval_has_no_placement_keys():
scores = eval_simple_project(SIMPLE)
assert scores.finding_count == 3
assert scores.finding_count == 10
assert scores.precision == 1.0
assert scores.recall == 1.0
assert not any(k.startswith("PE-PLC-") for k in scores.extra_keys)
+2 -2
View File
@@ -1,6 +1,6 @@
"""G1 SI vs simple_project — no invented millimetres or USBPHY boards.
Favor: real /USB.D+ and /USB.D- pair by suffix; eval stays 3 keys.
Favor: real /USB.D+ and /USB.D- pair by suffix; eval has no PE-SI keys.
Against: no .kicad_pcb → no PE-SI-001; 3W is not invented.
I2C/GPIO/CC are not 50/90 Ω. ImpedenceFinder numbers vs layout_rules only.
"""
@@ -54,7 +54,7 @@ def test_simple_project_without_pcb_has_no_ps_si_001():
def test_simple_project_eval_has_no_si_keys():
scores = eval_simple_project(SIMPLE)
assert scores.finding_count == 3
assert scores.finding_count == 10
assert scores.precision == 1.0
assert scores.recall == 1.0
assert not any(
+13 -2
View File
@@ -154,7 +154,15 @@ async def test_concurrency_bounded_by_knob_and_charging_isolated(workspace, monk
assert len(shared.entries) == sum(entries_for.values()) == 15
report = json.loads(workspace["report"].read_text())
assert report["summary"]["total"] == len(IC_MPNS) # all 5 reviewed
review_n = sum(
1 for f in report["findings"] if f.get("source") in (None, "review")
)
assert review_n == len(IC_MPNS) # all 5 reviewed
if_ids = {
f.get("rule_id") for f in report["findings"]
if (f.get("rule_id") or "").startswith(("PE-USBC", "PE-ETH", "PE-POE"))
}
assert "PE-USBC-001" in if_ids # N/A certifier, not a 6th IC review
@pytest.mark.asyncio
@@ -220,4 +228,7 @@ async def test_gate_trip_stops_new_reviews(workspace, monkeypatch):
# Report is marked partial because a gate tripped.
report = json.loads(workspace["report"].read_text())
assert report.get("partial") is True
assert report["summary"]["total"] == LIMIT
review_n = sum(
1 for f in report["findings"] if f.get("source") in (None, "review")
)
assert review_n == LIMIT