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": [ "deterministic_keys": [
"PE-I2C-001|U3|/I2C0.SDA", "PE-I2C-001|U3|/I2C0.SDA",
"PE-I2C-001|U3|/I2C0.SCL", "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.lifecycle import check_lifecycle
from backend.periscopex.errata_check import check_errata from backend.periscopex.errata_check import check_errata
from backend.periscopex.internal_features_check import check_internal_features 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.placement_check import check_placement
from backend.periscopex.si_check import check_si 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_lifecycle(graph, {}))
out.extend(check_errata(graph, cmap)) out.extend(check_errata(graph, cmap))
out.extend(check_internal_features(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_placement(graph, cmap, None))
out.extend(check_si(graph, cmap, None)) out.extend(check_si(graph, cmap, None))
return out return out
@@ -193,6 +193,31 @@ def _seed() -> None:
requirement="Single regulator or crystal feeding many ICs is REVIEW, not ERROR.") requirement="Single regulator or crystal feeding many ICs is REVIEW, not ERROR.")
_add("PE-EMI-001", "RECOMMENDED", "REVIEW", domain="pcb", _add("PE-EMI-001", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Datasheet EMI/filter FACT vs ferrite/common-mode on the net.") 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() _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.pi_check import check_power_integrity
from backend.periscopex.placement_check import check_placement 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.si_check import check_si
from backend.periscopex.spof_check import check_spof from backend.periscopex.spof_check import check_spof
from backend.periscopex.timing_check import check_timing 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 []) f for f in (schema.get("findings") or [])
if not str(f.get("finding_id") or "").startswith("PCB-") 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 = dict(schema)
out["findings"] = findings out["findings"] = findings
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0} 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)), ("spof_check", lambda: check_spof(graph, constraints_map)),
("emi_check", lambda: check_emi(graph, constraints_map, layout)), ("emi_check", lambda: check_emi(graph, constraints_map, layout)),
("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)), ("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)),
("interface_class", lambda: check_interface_classes(graph)),
): ):
try: try:
out.extend(fn()) 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.pcb_power_thermal import _footprint_region
from backend.periscopex.placement_check import _in_poly from backend.periscopex.placement_check import _in_poly
from backend.periscopex.si_check import net_length_mm from backend.periscopex.si_check import net_length_mm
from backend.periscopex.interface_class_check import format_interface_class_context
PCB_SYSTEM_PROMPT = """\ PCB_SYSTEM_PROMPT = """\
You are an electrical engineer reviewing a PCB layout against the IC \ 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. 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. - 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. - 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 ### Evidence
Cite numbers from "Parsed board geometry". Say insufficient evidence ONLY \ Cite numbers from "Parsed board geometry". Say insufficient evidence ONLY \
@@ -315,4 +320,5 @@ def build_pcb_layout_context(
) )
if domain_id: if domain_id:
lines.append(f"(domain_id={domain_id})") lines.append(f"(domain_id={domain_id})")
lines.append(format_interface_class_context(graph))
return "\n".join(lines) return "\n".join(lines)
@@ -40,6 +40,10 @@ datasheet's recommended values.
- Required external components named by the datasheet (bootstrap, \ - Required external components named by the datasheet (bootstrap, \
compensation, feedback divider, sense resistor). compensation, feedback divider, sense resistor).
- Unused / no-connect pins. - 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 \ 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 \ 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.finding_engine import apply_decisions
from backend.periscopex.hf_coverage_check import check_hf_decoupling_coverage from backend.periscopex.hf_coverage_check import check_hf_decoupling_coverage
from backend.periscopex.internal_features_check import check_internal_features 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.led_current_check import check_led_current
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
from backend.periscopex.models import ComponentType, DesignGraph, Finding, ValidationReport 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)), ("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
("crystal_cl_check", lambda: check_crystal_cl(graph)), ("crystal_cl_check", lambda: check_crystal_cl(graph)),
("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)), ("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)),
("interface_class", lambda: check_interface_classes(graph)),
): ):
try: try:
out.extend(fn()) 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-EMI-001 | filtro EMI solo se `layout_rules` cita choke/ferrite/shield |
| PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net | | PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net |
| PE-STCH-001 | pour GND + segnale senza via GND nel bbox | | 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 | | Creepage | **skip** senza numero datasheet/IEC |
| Crystal keepout | PE-PLC-004 se kind=keepout | | 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 | | 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 | | 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 | | 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` | | 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. 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 ## 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. 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", "name": "periscope-web",
"version": "2.60.0", "version": "2.61.0",
"private": true, "private": true,
"scripts": { "scripts": {
"sync-version": "node scripts/sync-version.mjs", "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. */ /** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.56.0"; export const APP_VERSION = "2.61.0";
export const APP_VERSION_DATE = "2026-09-20"; 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": { "connector.fpc": {
"description": "FPC / FFC connector", "description": "FPC / FFC connector",
"extra_specs": [ "extra_specs": [
+4 -3
View File
@@ -1,7 +1,8 @@
"""Eval harness — finding count, citation hit-rate, precision/recall. """Eval harness — finding count, citation hit-rate, precision/recall.
Favor: perfect golden match; citation rate ignores deterministic findings; 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; Against: extra finding drops precision; missing golden key drops recall;
Unverified quotes are citation misses, not hits. 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.graph_ok, scores.graph_errors
assert scores.precision == 1.0 assert scores.precision == 1.0
assert scores.recall == 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["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): 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(): def test_package_json_is_periscope_web():
import re
text = PKG.read_text(encoding="utf-8") text = PKG.read_text(encoding="utf-8")
assert '"name": "periscope-web"' in text 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 "Native Periscope overlay" not in text[:400]
assert LOCK.is_file() assert LOCK.is_file()
lock = LOCK.read_text(encoding="utf-8") 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(): def test_simple_project_eval_has_no_placement_keys():
scores = eval_simple_project(SIMPLE) scores = eval_simple_project(SIMPLE)
assert scores.finding_count == 3 assert scores.finding_count == 10
assert scores.precision == 1.0 assert scores.precision == 1.0
assert scores.recall == 1.0 assert scores.recall == 1.0
assert not any(k.startswith("PE-PLC-") for k in scores.extra_keys) 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. """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. 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. 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(): def test_simple_project_eval_has_no_si_keys():
scores = eval_simple_project(SIMPLE) scores = eval_simple_project(SIMPLE)
assert scores.finding_count == 3 assert scores.finding_count == 10
assert scores.precision == 1.0 assert scores.precision == 1.0
assert scores.recall == 1.0 assert scores.recall == 1.0
assert not any( 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 assert len(shared.entries) == sum(entries_for.values()) == 15
report = json.loads(workspace["report"].read_text()) 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 @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 is marked partial because a gate tripped.
report = json.loads(workspace["report"].read_text()) report = json.loads(workspace["report"].read_text())
assert report.get("partial") is True 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