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:
@@ -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 (TRD0–3)."
|
||||
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 TRD0–TRD3 (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 0–8).",
|
||||
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=[],
|
||||
)
|
||||
@@ -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`. L’IA spiega i FACT, non certifica.
|
||||
|
||||
## Dopo
|
||||
|
||||
DDR3 (indirizzo/comando vs DQ, lunghezze, VTT/VREF). Altre periferiche con lo stesso schema.
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "periscope-web",
|
||||
"version": "2.60.0",
|
||||
"version": "2.61.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync-version": "node scripts/sync-version.mjs",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user