From f79c722e687fa20137a9579dfef2dc9ca03178a8 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 22 Sep 2026 21:56:09 +0200 Subject: [PATCH] Match exposed pads by land, cite ESD, follow CC to Rd (2.84.0). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PE-BOM-011 treats EP/EPAD/THERMAL PAD and the footprint exposed land as one pad when both sides have one. PE-ESD-001 quotes a datasheet only with part and section, and does not warn on a 3V3 jack rail, uncitable VBUS, or optical S/PDIF. CC Rd is the packed 5.1 kΩ on the Type-C CC pin net. Ethernet MDI pair Z is PASS, FAIL, or ERROR against the cited 10/100 or gigabit clause. --- .../src/backend/periscopex/af_trace_check.py | 207 +++++++++- .../src/backend/periscopex/bom_pcb_check.py | 75 +++- .../backend/periscopex/esd_return_check.py | 118 +++++- .../src/backend/periscopex/protocol_l0.py | 173 +++++++- periscope/src/frontend/content/changelog.md | 13 + tests/pcb/test_bom_esd_ep_cc_eth.py | 371 ++++++++++++++++++ 6 files changed, 923 insertions(+), 34 deletions(-) create mode 100644 tests/pcb/test_bom_esd_ep_cc_eth.py diff --git a/periscope/src/backend/periscopex/af_trace_check.py b/periscope/src/backend/periscopex/af_trace_check.py index f1fbd98..22efa72 100644 --- a/periscope/src/backend/periscopex/af_trace_check.py +++ b/periscope/src/backend/periscopex/af_trace_check.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging import math +import re from collections import defaultdict from backend.periscopex.af_rlgc import cascade_sparam @@ -24,8 +25,9 @@ from backend.periscopex.hf_line_check import COORD_QUANT_MM, ENDPOINT_SNAP_MM from backend.periscopex.impedance import GeometryError from backend.periscopex.impedance_traces import analyze_specified_nets from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia -from backend.periscopex.pcb_net_match import kicad_nets_match -from backend.periscopex.si_check import partner_net +from backend.periscopex.pcb_net_match import kicad_nets_match, refs_on_matched_net +from backend.periscopex.protocol_catalog import load_catalog +from backend.periscopex.si_check import bus_class, partner_net log = logging.getLogger(__name__) SOURCE = "af_trace_check" @@ -35,6 +37,198 @@ _WIDTH_STEP_MM = 0.05 _PARALLEL_MIN_MM = 5.0 +_MDI_POL = re.compile( + r"^(?P.*(?:TX|RX|TD|RD|TRD\d|TP))(?P[PN])$", + re.I, +) +_GBE_RE = re.compile(r"1000\s*BASE|1000BASE|\bGBE\b|GIGABIT", re.I) +_FAST_RE = re.compile(r"LAN8720|10\s*/\s*100|100BASE|10BASE|BASE[\s\-]?TX", re.I) + + +def _mdi_mate(name: str) -> str | None: + """TXP/TXN and RXP/RXN, plus the existing _P/_N and +/- partners.""" + m = _MDI_POL.match(name or "") + if m: + pol = m.group("pol").upper() + return m.group("stem") + ("N" if pol == "P" else "P") + return partner_net(name) + + +def _is_eth_mdi_net(net: str) -> bool: + return bus_class(net) == "eth_mdi" + + +def _coalesce_eth_pairs(units: list[AfUnit]) -> list[AfUnit]: + """Treat each RJ45/PHY MDI pair as one unit, including TXP/TXN names.""" + by_net = {n: u for u in units for n in u.nets} + out: list[AfUnit] = [] + seen: set[str] = set() + for unit in units: + if unit.nets[0] in seen: + continue + if len(unit.nets) == 1 and _is_eth_mdi_net(unit.nets[0]): + mate = _mdi_mate(unit.nets[0]) + other = by_net.get(mate or "") + if mate and other is not None and mate not in seen: + nets = tuple(sorted((unit.nets[0], mate))) + seen.update(nets) + length = max(unit.length_mm, other.length_mm) + out.append(AfUnit(nets=nets, length_mm=length, bus="eth_mdi")) + continue + if all(_is_eth_mdi_net(n) for n in unit.nets): + seen.update(unit.nets) + out.append(AfUnit( + nets=tuple(sorted(unit.nets)), + length_mm=unit.length_mm, + bus="eth_mdi", + )) + continue + seen.update(unit.nets) + out.append(unit) + return out + + +def _eth_speed(graph: DesignGraph, nets: tuple[str, ...]) -> str | None: + """10/100 or gbe from the PHY/jack actually on these nets. Else None.""" + bits: list[str] = [] + for net in nets: + for ref in refs_on_matched_net(graph, net): + comp = graph.components.get(ref) + if comp is None: + continue + bits.extend([ + comp.mpn or "", comp.value or "", comp.footprint or "", + comp.component_subtype or "", + ]) + row = (graph.bom_fields or {}).get(ref) or {} + for key in ("description", "Description", "value", "Value"): + if row.get(key): + bits.append(str(row.get(key))) + text = " ".join(bits) + if _GBE_RE.search(text): + return "gbe" + if _FAST_RE.search(text): + return "10/100" + return None + + +def _eth_cite(speed: str) -> tuple[float, str] | None: + """Packed cable_channel ohm for the identified speed. Nothing invented.""" + want = "1000base-t-mdi" if speed == "gbe" else "100base-tx-mdi" + cat = load_catalog() + for iface in cat.physical_interfaces: + if iface.id != want: + continue + for c in iface.constraints: + if c.parameter != "cable_channel" or c.unit != "ohm" or c.value is None: + continue + src = c.source + doc = (src.document if src and src.document else "IEEE Std 802.3") + section = (src.section if src and src.section else "") + if speed == "gbe": + label = f"{doc} Clause 40 (§{section})" if section else f"{doc} Clause 40" + else: + label = ( + f"{doc} 100BASE-TX 25.4.9 (§{section})" + if section else f"{doc} 100BASE-TX 25.4.9" + ) + return float(c.value), label + return None + + +def _pair_z_ohm(layout: LayoutGraph, unit: AfUnit) -> float | None: + """One Z for the pair. None when stackup or geometry cannot produce it.""" + if layout.stackup is None or len(unit.nets) < 2: + return None + samples = _samples(layout, unit) + vals = _z_vals(samples, True) + if not vals: + return None + return sum(vals) / len(vals) + + +def _ethernet_mdi_finding( + graph: DesignGraph, + layout: LayoutGraph, + unit: AfUnit, +) -> Finding: + """Calculated pair Z vs the cited clause for the speed on the board. + + Inside → PASS. Outside → FAIL. Cannot calculate → ERROR. + """ + speed = _eth_speed(graph, unit.nets) + cite = _eth_cite(speed) if speed else None + measured = _pair_z_ohm(layout, unit) + label = _label(unit) + if cite is None: + text = ( + f"ERROR: Ethernet pair {label} speed is not identified on the " + "PHY/jack, so no 10/100 or 1000 clause was applied." + ) + req = "Pair Z is certified only against the cited clause for the identified speed." + rec = "Identify the PHY/jack speed. No ohm value is assumed." + return _finding( + rule_id="PE-AF-002", unit=unit, finding=text, + facts=f"nets={label}; speed=unidentified; measured=none.", + requirement=req, rec=rec, status="ERROR", cls="RULE", + evidence="SUFFICIENT", provenance="MANDATORY", + ) + limit, cite_label = cite + req = f"Cited limit {limit:g} Ω, {cite_label}." + if measured is None: + missing: list[str] = [] + if len(unit.nets) < 2: + missing.append("pair") + if layout.stackup is None: + missing.append("stackup") + else: + missing.append("geometry") + miss = "/".join(missing) + text = ( + f"ERROR: pair Z for {label} cannot be calculated from the PCB " + f"({miss} missing). Cited limit {limit:g} Ω, {cite_label}." + ) + rec = ( + f"Pair Z was not calculated ({miss} missing). " + f"Cited limit {limit:g} Ω, {cite_label}." + ) + return _finding( + rule_id="PE-AF-002", unit=unit, finding=text, + facts=f"nets={label}; speed={speed}; missing={miss}; measured=none.", + requirement=req, rec=rec, status="ERROR", cls="RULE", + evidence="SUFFICIENT", provenance="MANDATORY", + calculation=f"no computed Z; cited {limit:g} Ω.", + ) + inside = abs(measured - limit) <= 1e-6 * max(1.0, abs(limit)) + if inside: + text = ( + f"PASS: {label} pair Z {measured:g} Ω is inside the cited limit " + f"{limit:g} Ω, {cite_label}." + ) + rec = f"Pair Z matches {limit:g} Ω ({cite_label})." + return _finding( + rule_id="PE-AF-002", unit=unit, finding=text, + facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.", + requirement=req, rec=rec, status="INFO", cls="INFO", + evidence="SUFFICIENT", provenance="MANDATORY", + calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.", + ) + text = ( + f"FAIL: {label} pair Z {measured:g} Ω is outside the cited limit " + f"{limit:g} Ω, {cite_label}." + ) + rec = ( + f"Pair Z {measured:g} Ω is outside {limit:g} Ω ({cite_label})." + ) + return _finding( + rule_id="PE-AF-002", unit=unit, finding=text, + facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.", + requirement=req, rec=rec, status="ERROR", cls="RULE", + evidence="SUFFICIENT", provenance="MANDATORY", + calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.", + ) + + def check_af_traces( graph: DesignGraph, constraints_map: dict, @@ -50,7 +244,10 @@ def check_af_traces( if f.rule_id == "PE-SI-002" and f.net } zrows = _z_rows(impedance_nets) - for unit in iter_af_units(layout, graph): + for unit in _coalesce_eth_pairs(list(iter_af_units(layout, graph))): + if unit.bus == "eth_mdi": + out.append(_ethernet_mdi_finding(graph, layout, unit)) + continue trig = evaluate_trigger(graph, constraints_map, layout, unit) if trig.missing and not trig.af: out.append(_skip(unit, trig.missing)) @@ -116,7 +313,9 @@ def _finding( cls: str = "RISK", evidence: str = "SUFFICIENT", calculation: str = "", + provenance: str = "", ) -> Finding: + prov = provenance or ("RECOMMENDED" if cls != "REVIEW" else "TYPICAL") return Finding( designator="layout", mpn="", @@ -132,7 +331,7 @@ def _finding( source=SOURCE, rule_id=rule_id, finding_class=cls, # type: ignore[arg-type] - provenance="RECOMMENDED" if cls != "REVIEW" else "TYPICAL", + provenance=prov, evidence_status=evidence, # type: ignore[arg-type] net=unit.nets[0], pins=[], diff --git a/periscope/src/backend/periscopex/bom_pcb_check.py b/periscope/src/backend/periscopex/bom_pcb_check.py index 68ab2a2..a3f7437 100644 --- a/periscope/src/backend/periscopex/bom_pcb_check.py +++ b/periscope/src/backend/periscopex/bom_pcb_check.py @@ -52,7 +52,8 @@ _MODULE_FP = re.compile( re.I, ) _EP_PAD = re.compile( - r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$", + r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|" + r"THERMAL[\s_]?PAD|EXPOSED[\s_]?PAD)(?:[_-]?\d+)?$", re.I, ) _EP_SPLIT = re.compile(r"^\d+_\d+$") @@ -112,6 +113,45 @@ def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None return False +def _is_exposed_alias(name: str) -> bool: + """EP, EPAD, THERMAL PAD, exposed pad. Not a signal pin number.""" + s = str(name or "").strip() + if not s: + return False + if _EP_PAD.match(s): + return True + compact = re.sub(r"[\s_\-]+", "", s).lower() + return compact in {"thermalpad", "exposedpad", "epad", "thermal"} + + +def _ids_have_exposed( + ids: list[tuple[str, str]] | None, + pin_count: int | None, +) -> bool: + if not ids: + return False + return any(_is_ep_land(n, pf, pin_count) for n, pf in ids) + + +def _drop_exposed_name_mismatch( + miss: list[str], + extra: list[str], + *, + left_has: bool, + right_has: bool, +) -> tuple[list[str], list[str]]: + """Same exposed pad under two names is not a missing/extra pin. + + Datasheet EP with no exposed land on the other side stays a miss. + """ + if not (left_has and right_has): + return miss, extra + return ( + [m for m in miss if not _is_exposed_alias(m)], + [e for e in extra if not _is_exposed_alias(e)], + ) + + def _is_shield_land(number: str, pinfunction: str = "") -> bool: s = str(number).strip() pf = str(pinfunction or "").strip() @@ -273,6 +313,16 @@ def resolve_kicad_mod( return None +def _lib_pad_ids( + footprint: str, + search_dirs: list[Path] | None, +) -> list[tuple[str, str]] | None: + path = resolve_kicad_mod(footprint, search_dirs) + if path is None: + return None + return parse_kicad_mod_pads(path) + + def _lib_join_keys( footprint: str, *, @@ -489,6 +539,10 @@ def _pinout_findings( declared = _declared_io_pins(pkg, cad) ep_count = declared or pin_count pcb = _join_keys_from_ids(pcb_ids, pin_count=ep_count, ds_keys=ds) + pcb_has_ep = _ids_have_exposed(pcb_ids, ep_count) + ds_has_ep = any(_is_exposed_alias(k) for k in ds) + lib_raw = _lib_pad_ids(fp_name, footprint_dirs) + lib_has_ep = _ids_have_exposed(lib_raw, ep_count) lib, lib_path = _lib_join_keys( fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs, ) @@ -522,8 +576,10 @@ def _pinout_findings( ) if lib is not None: - miss_lib = sorted(ds - lib) - extra_lib = sorted(lib - ds) + miss_lib, extra_lib = _drop_exposed_name_mismatch( + sorted(ds - lib), sorted(lib - ds), + left_has=ds_has_ep, right_has=lib_has_ep, + ) if miss_lib or extra_lib: rec = ( f"Change {ref}'s KiCad footprint library so pad names match " @@ -547,8 +603,11 @@ def _pinout_findings( ) if fnd: out.append(fnd) - miss_emb = sorted(lib - pcb) if pcb else sorted(lib) - extra_emb = sorted(pcb - lib) if pcb else [] + miss_emb, extra_emb = _drop_exposed_name_mismatch( + sorted(lib - pcb) if pcb else sorted(lib), + sorted(pcb - lib) if pcb else [], + left_has=lib_has_ep, right_has=pcb_has_ep, + ) if pcb and (miss_emb or extra_emb): rec = ( f"Replace {ref}'s embedded PCB footprint so it matches the " @@ -571,8 +630,10 @@ def _pinout_findings( out.append(fnd) return out if pcb: - miss = sorted(ds - pcb) - extra = sorted(pcb - ds) + miss, extra = _drop_exposed_name_mismatch( + sorted(ds - pcb), sorted(pcb - ds), + left_has=ds_has_ep, right_has=pcb_has_ep, + ) if miss or extra: rec = ( f"Fix {ref} pinout: PCB pad names vs datasheet pintable. " diff --git a/periscope/src/backend/periscopex/esd_return_check.py b/periscope/src/backend/periscopex/esd_return_check.py index 528a789..519205b 100644 --- a/periscope/src/backend/periscopex/esd_return_check.py +++ b/periscope/src/backend/periscopex/esd_return_check.py @@ -27,7 +27,14 @@ _NC_NET_RE = re.compile( re.I, ) _ONBOARD_POWER_RE = re.compile( - r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|3V3|\+3V3|3\.3V)$", + r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|VCC\d*|VDD\d*|3V3|\+3V3|3\.3V)" + r"(?:[_./-].*)?$", + re.I, +) +_VBUS_LEAF_RE = re.compile(r"(?:^|[_./-])VBUS(?:$|[_./-])", re.I) +_SPDIF_RE = re.compile(r"SP(?:DIF|IF)", re.I) +_OPTICAL_RE = re.compile( + r"TOSLINK|OPTOCOUPL|OPTICAL|OPTODEVICE|\bAFBR\b|PHOTO[-_ ]?COUPL", re.I, ) @@ -57,6 +64,50 @@ def _skip_esd_net(net: str) -> bool: return False +def _optical_evidence(graph: DesignGraph, comp) -> bool: + """Schematic/BOM/footprint shows TOSLINK, optocoupler, or optical.""" + parts = [ + comp.value or "", + comp.footprint or "", + comp.mpn or "", + comp.component_subtype or "", + ] + row = (graph.bom_fields or {}).get(comp.reference) or {} + for key in ("description", "Description", "value", "Value", "footprint", "Footprint"): + if row.get(key): + parts.append(str(row.get(key))) + return bool(_OPTICAL_RE.search(" ".join(parts))) + + +def _esd_cite(cons, *, need_vbus: bool) -> str | None: + """Part + section/page quote. None when the datasheet is not cited.""" + if cons is None: + return None + for rule in cons.layout_rules or []: + if not isinstance(rule, dict): + continue + blob = " ".join( + str(rule.get(k) or "") + for k in ("kind", "parameter", "note", "net", "pin", "requirement") + ) + if "esd" not in blob.lower(): + continue + if need_vbus and not re.search(r"vbus", blob, re.I): + continue + doc = str( + rule.get("document") or rule.get("part") or rule.get("source") or "" + ).strip() + section = str(rule.get("section") or "").strip() + page = str(rule.get("page") or "").strip() + if not doc or not (section or page): + continue + loc = section + if page: + loc = f"{loc} p.{page}".strip() + return f"{doc} {loc}".strip() + return None + + def _is_j_connector(comp) -> bool: if comp.component_type != ComponentType.CONNECTOR: return False @@ -68,7 +119,8 @@ def check_esd( constraints_map: dict[str, ComponentConstraints] | None = None, ) -> list[Finding]: """One REVIEW per J* connector → IC net with no ESD part. No GPIO/NC/rail spam.""" - _ = constraints_map + from backend.periscopex.constraints_lookup import match_constraints + out: list[Finding] = [] seen: set[tuple[str, str, str]] = set() @@ -106,28 +158,64 @@ def check_esd( (str(p) for p, n in ic.pins.items() if n and kicad_nets_match(n, net)), "", ) - rec = ( - f"Add a datasheet-specified ESD device on {net} between " - f"{j_ref} and {ic_ref}." - ) + leaf = _net_leaf(net) + is_vbus = bool(_VBUS_LEAF_RE.search(leaf)) + is_rail = bool(_ONBOARD_POWER_RE.match(leaf)) + is_spdif = bool(_SPDIF_RE.search(leaf)) + optical = _optical_evidence(graph, j_comp) + cons = match_constraints(ic.mpn or ic.value, constraints_map or {}) + quote = _esd_cite(cons, need_vbus=is_vbus) + if is_spdif and optical: + continue + if is_vbus and not quote: + continue + if is_rail and not quote: + continue + if is_spdif and not optical: + finding = ( + f"{net} ({j_ref}–{ic_ref}) was not verified as optical " + "S/PDIF." + ) + rec = ( + f"Confirm whether {net} is photo-coupled " + "(TOSLINK/optocoupler). The path was not verified as optical." + ) + requirement = ( + "Optical S/PDIF is not an external copper signal. " + "Without TOSLINK/optocoupler evidence the path stays visible." + ) + else: + finding = ( + f"{net} is a {j_ref}–{ic_ref} path with no ESD/" + "protection part on the net." + ) + if quote: + rec = ( + f"Add a datasheet-specified ESD device ({quote}) on " + f"{net} between {j_ref} and {ic_ref}." + ) + requirement = f"Cited ESD requirement: {quote}." + else: + rec = ( + f"Review board ESD on {net} between {j_ref} and {ic_ref}." + ) + requirement = ( + "Connector–IC nets are reviewed for a board ESD part. " + "No datasheet section is cited for this net." + ) out.append(Finding( designator=ic_ref, mpn=ic.mpn or "", aspect="esd", - finding=( - f"{net} is a {j_ref}–{ic_ref} path with no ESD/" - "protection part on the net." - ), + finding=finding, facts=( f"Net {net}; connector={j_ref}.{j_pin}; IC={ic_ref}; " "ESD parts on net: 0." + + (f" cite={quote}." if quote else " cite=none.") ), - requirement=( - "External ESD is recommended on connector–IC nets unless " - "the extraction lists a mandatory clamp with a measured FACT." - ), + requirement=requirement, inference="REVIEW — on-die esd_clamp_pins are not a board RULE.", - why="Only J* ∩ IC nets; NC, unconnected, VSYS/GND/3V3 excluded.", + why="Only J* ∩ IC nets; NC, unconnected, onboard rails excluded.", status="WARNING", recommendation=rec, action=rec, diff --git a/periscope/src/backend/periscopex/protocol_l0.py b/periscope/src/backend/periscopex/protocol_l0.py index e4f67bf..983eb75 100644 --- a/periscope/src/backend/periscopex/protocol_l0.py +++ b/periscope/src/backend/periscopex/protocol_l0.py @@ -13,8 +13,18 @@ from typing import Any, Literal from pydantic import BaseModel, Field from backend.periscopex.finding_engine import complete_finding -from backend.periscopex.models import DesignGraph, Finding, LayoutGraph -from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net +from backend.periscopex.models import ( + Component, + ComponentType, + DesignGraph, + Finding, + LayoutGraph, + ResistorSpecs, +) +from backend.periscopex.pcb_net_match import ( + normalize_kicad_hierarchy_net, + refs_on_matched_net, +) from backend.periscopex.protocol_catalog import ( SCHEMA_VERSION, PhysicalInterface, @@ -47,7 +57,8 @@ L0_CHECKS = frozenset({ "superspeed_pairs", }) -_CC_RE = re.compile(r"(?:^|[_/.])CC[12]$", re.I) +_OHM_EMBEDDED = re.compile(r"^(\d+)([RKMG])(\d+)$", re.I) +_OHM_SUFFIX = re.compile(r"^(\d*\.?\d+)([RKMG])$", re.I) _VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS", re.I) _GND_RE = re.compile(r"(?:^|[_/.])(?:GND|VSS|DGND)(?:$|[_/.])", re.I) _SS_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|USB3|USB_SS", re.I) @@ -160,6 +171,156 @@ def certify_instance_l0( return out +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 _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 _rd_cite() -> tuple[float | None, str]: + """CabCon Rd already packed. No other USB number is introduced here.""" + cat = load_catalog() + chosen = None + fallback = None + for iface in cat.physical_interfaces: + if iface.id not in {"usb-c-usb2-receptacle", "usb-c-receptacle"}: + continue + for c in iface.constraints: + if c.parameter == "rd" and c.value is not None: + if iface.id == "usb-c-usb2-receptacle": + chosen = c + elif fallback is None: + fallback = c + chosen = chosen or fallback + if chosen is None or chosen.value is None: + return None, "CabCon Rd cite is not in the protocol pack." + src = chosen.source + bits = [] + if src and src.document: + bits.append(src.document) + if src and src.revision: + bits.append(src.revision) + if src and src.section: + bits.append(f"§{src.section}") + if src and src.table: + bits.append(src.table) + if src and src.page: + bits.append(f"p.{src.page}") + nominal = "nominal 5.1 kΩ" if any( + "5.1" in (cond or "") for cond in (chosen.conditions or []) + ) else f"{chosen.value:g} {chosen.unit or 'ohm'}" + cite = ", ".join(bits) if bits else chosen.id + return float(chosen.value), f"{cite}; {nominal}" + + +def _cc_pin_pairs(comp: Component) -> list[tuple[str, str | None]]: + """Receptacle CC pins. A5/B5 are the Type-C CC contacts, not net names.""" + pins = {str(k).upper(): v for k, v in comp.pins.items()} + if "A5" in pins or "B5" in pins: + return [("A5", pins.get("A5")), ("B5", pins.get("B5"))] + if "CC1" in pins or "CC2" in pins: + return [("CC1", pins.get("CC1")), ("CC2", pins.get("CC2"))] + return [] + + +def _typec_receptacle(graph: DesignGraph, inst: PhysicalBusInstance) -> Component | None: + found: list[Component] = [] + for comp in graph.components.values(): + if comp.component_type != ComponentType.CONNECTOR: + continue + if _cc_pin_pairs(comp): + found.append(comp) + if not found: + return None + for comp in found: + if comp.reference == inst.host_ref: + return comp + inst_nets = set(inst.nets) + for g in inst.groups: + inst_nets.update(g.nets) + for comp in found: + if inst_nets.intersection(v for v in comp.pins.values() if v): + return comp + return found[0] + + +def _rd_refs_on_net(graph: DesignGraph, net: str, nominal: float) -> list[str]: + """Resistors on this net whose value is the packed Rd (float slack 1 Ω).""" + hits: list[str] = [] + for ref in refs_on_matched_net(graph, net): + comp = graph.components.get(ref) + if comp is None or comp.component_type != ComponentType.RESISTOR: + continue + ohms = _resistor_ohms(comp) + if ohms is not None and abs(ohms - nominal) <= 1.0: + hits.append(ref) + return hits + + +def _cc_rd_on_pins( + graph: DesignGraph, + inst: PhysicalBusInstance, + mand: MandatoryClass, +) -> L0CheckResult: + """Pin → net → Rd. The net does not have to be named CC1/CC2.""" + nominal, cite = _rd_cite() + jack = _typec_receptacle(graph, inst) + if jack is None or nominal is None: + return _pack( + "cc_rd_rp", "FAIL", mand, + notes=( + "Type-C CC pins were not followed to an Rd. " + f"{cite}" + ), + ) + pairs = _cc_pin_pairs(jack) + nets: list[str] = [] + missing: list[str] = [] + present: list[str] = [] + for pin, net in pairs: + if not net: + missing.append(f"{jack.reference}.{pin} has no net") + continue + nets.append(net) + refs = _rd_refs_on_net(graph, net, nominal) + if refs: + present.append(f"{jack.reference}.{pin} net {net} Rd {', '.join(refs)}") + else: + missing.append( + f"{jack.reference}.{pin} net {net} has no {nominal:g} Ω Rd" + ) + if missing or len(present) < 2: + detail = "; ".join(missing) if missing else "both CC pins need an Rd" + return _pack( + "cc_rd_rp", "FAIL", mand, nets=nets, + notes=f"{detail}. {cite}. Net name is not required.", + ) + return _pack( + "cc_rd_rp", "PASS", mand, nets=nets, + notes=f"{'; '.join(present)}. {cite}.", + ) + + def _run_l0_check( graph: DesignGraph, inst: PhysicalBusInstance, @@ -212,11 +373,7 @@ def _run_l0_check( ok, msg = _structural_nets_ok(graph, clocks) return _pack(check, "PASS" if ok else "FAIL", mand, nets=clocks, notes=msg) if check == "cc_rd_rp": - ccs = [n for n in graph.nets if _CC_RE.search(_leaf(n))] - if len(ccs) < 1: - return _pack(check, "FAIL", mand, notes="CC1/CC2 net not present.") - ok, msg = _structural_nets_ok(graph, ccs) - return _pack(check, "PASS" if ok else "FAIL", mand, nets=ccs, notes=msg) + return _cc_rd_on_pins(graph, inst, mand) if check == "vbus_gnd": vbus = [n for n in graph.nets if _VBUS_RE.search(_leaf(n))] gnd = [n for n in graph.nets if _GND_RE.search(_leaf(n))] diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 6809942..4aefd20 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,19 @@ What's new in Periscope. +## 2.84.0 — 2026-09-22 — Exposed-pad names, ESD cites, CC-via-net, Ethernet pair Z + +PE-BOM-011 treats EP, EPAD, THERMAL PAD, exposed pad, and the footprint's exposed land (including pin_count+1) as the same pad when both sides have one. A datasheet exposed pad with no land on the footprint stays ERROR. + +PE-ESD-001 says "datasheet-specified" only with a part and section/page. A 3V3 jack rail and VBUS are not warned without that cite. Optical S/PDIF (TOSLINK/optocoupler) is not an ESD warning; an unverified S/PDIF path stays a visible warning. + +PE-PRT-L0-001 follows Type-C CC pins (A5/B5) to the net and looks for the packed CabCon Rd 5.1 kΩ. The net does not have to be named CC1/CC2. + +PE-AF-002 on an Ethernet MDI pair compares one calculated pair Z to the packed cite for the speed on the board (LAN8720A 10/100 → 100 Ω, IEEE 100BASE-TX 25.4.9; Clause 40 only if gigabit). Inside is PASS, outside is FAIL, and no calculation is ERROR. No tr/f request. + +- [Changed] `bom_pcb_check.py`, `esd_return_check.py`, `protocol_l0.py`, `af_trace_check.py`. +- [Changed] pytest `tests/pcb/test_bom_esd_ep_cc_eth.py`. + ## 2.83.0 — 2026-09-22 — Apple-like shell; USB-C vSafe still UNKNOWN Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff. diff --git a/tests/pcb/test_bom_esd_ep_cc_eth.py b/tests/pcb/test_bom_esd_ep_cc_eth.py new file mode 100644 index 0000000..cc87cb7 --- /dev/null +++ b/tests/pcb/test_bom_esd_ep_cc_eth.py @@ -0,0 +1,371 @@ +"""HubAudio false findings: exposed-pad names, ESD cites, CC-via-net, Ethernet Z.""" + +from __future__ import annotations + +import pytest + +from backend.periscopex.af_trace_check import check_af_traces +from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet +from backend.periscopex.esd_return_check import check_esd +from backend.periscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + LayoutFootprint, + LayoutGraph, + LayoutPad, + LayoutSegment, + Net, + NetType, + PackageInfo, + Pin, + PinConnection, +) +from backend.periscopex.protocol_l0 import _run_l0_check, l0_findings +from backend.periscopex.protocol_recognize import PhysicalBusInstance + + +def _ic(ref: str, mpn: str, pins: dict[str, str], *, footprint: str = "") -> Component: + return Component( + reference=ref, value=mpn, footprint=footprint, + component_type=ComponentType.IC, mpn=mpn, pins=pins, + ) + + +def _net(name: str, *pairs: tuple[str, str]) -> Net: + return Net( + name=name, net_type=NetType.SIGNAL, + pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs], + ) + + +def _cons(mpn: str, numbers: list[str], pin_count: int) -> ComponentConstraints: + return ComponentConstraints( + mpn=mpn, + package_info=PackageInfo(base_family="QFN", package=f"QFN-{pin_count}", pin_count=pin_count), + pintable=[Pin(number=n, name=n) for n in numbers], + absolute_maximum_ratings=[], + rules=[], + ) + + +def _layout(ref: str, pads: list[tuple[str, str]], *, footprint: str) -> LayoutGraph: + return LayoutGraph( + footprints={ + ref: LayoutFootprint( + reference=ref, footprint=footprint, x=0, y=0, + pads=[ + LayoutPad(number=n, x=0, y=0, net="GND", pinfunction=pf) + for n, pf in pads + ], + ), + }, + ) + + +def _bom(ref: str, mpn: str, numbers: list[str], pads: list[tuple[str, str]], pin_count: int): + graph = DesignGraph(components={ + ref: _ic(ref, mpn, {n: "N" for n, _pf in pads if n}, footprint="Lib:Part"), + }) + # Schematic pins follow the pintable numbers that are real contacts. + graph.components[ref].pins = {n: "N" for n in numbers if n not in {"EP", "THERMAL PAD"}} + layout = _layout(ref, pads, footprint="Lib:Part") + findings = check_bom_pcb_datasheet(graph, {mpn: _cons(mpn, numbers, pin_count)}, layout) + return [f for f in findings if f.rule_id == "PE-BOM-011"] + + +def test_u16_ep_matches_pad_41(): + """AXP2101: pintable EP, footprint pad 41 / EP_41.""" + hits = _bom( + "U16", "AXP2101", ["1", "EP"], + [("1", "CHGLED_1"), ("41", "EP_41")], + 40, + ) + assert hits == [] + + +def test_u18_ep_matches_pad_89(): + """ADAU1467: pintable EP, footprint pad 89 / EP_89.""" + hits = _bom( + "U18", "ADAU1467", ["1", "EP"], + [("1", "DGND_1_1"), ("89", "EP_89")], + 88, + ) + assert hits == [] + + +def test_u19_ep_matches_pad_25_vss(): + """LAN8720A: pintable EP, VQFN-24 land is pad 25 / VSS_25.""" + hits = _bom( + "U19", "LAN8720A", ["1", "EP"], + [("1", "VDD2A_1"), ("25", "VSS_25")], + 24, + ) + assert hits == [] + + +def test_u5_thermal_pad_matches_epad_29(): + """TAC5212: pintable THERMAL PAD, footprint pad 29 / EPAD_29.""" + hits = _bom( + "U5", "XTAC5212", ["1", "THERMAL PAD"], + [("1", "DREG_1"), ("29", "EPAD_29")], + 24, + ) + assert hits == [] + + +def test_exposed_pad_absent_from_footprint_stays_error(): + hits = _bom( + "U9", "BARE", ["1", "EP"], + [("1", "PIN_1")], + 24, + ) + assert hits + assert any("EP" in (f.finding or "") for f in hits) + + +def _esd_graph(net: str, *, j_fp: str = "", j_value: str = "JACK") -> DesignGraph: + return DesignGraph( + components={ + "J1": Component( + reference="J1", value=j_value, footprint=j_fp, + component_type=ComponentType.CONNECTOR, mpn="", + pins={"13": net}, + ), + "U19": _ic("U19", "LAN8720A", {"1": net}), + }, + nets={net: _net(net, ("J1", "13"), ("U19", "1"))}, + ) + + +def test_esd_does_not_say_datasheet_specified_without_a_cite(): + findings = check_esd(_esd_graph("USB_DP")) + assert findings and findings[0].rule_id == "PE-ESD-001" + blob = f"{findings[0].action} {findings[0].recommendation}" + assert "datasheet-specified" not in blob + + +def test_esd_quotes_datasheet_when_part_and_section_exist(): + cons = ComponentConstraints( + mpn="LAN8720A", + pintable=[], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[{ + "kind": "esd", + "document": "LAN8720A datasheet", + "section": "3.2", + "page": "12", + "note": "ESD on USB_DP", + }], + ) + findings = check_esd(_esd_graph("USB_DP"), {"LAN8720A": cons}) + assert findings + blob = f"{findings[0].action} {findings[0].finding}" + assert "datasheet-specified" in blob + assert "LAN8720A datasheet" in blob + assert "3.2" in blob + + +def test_3v3_ethernet_rail_is_not_an_esd_warning(): + assert check_esd(_esd_graph("3V3_ETHERNET")) == [] + + +def test_vbus_without_cite_is_not_an_esd_warning(): + graph = _esd_graph("VBUS") + graph.components["U16"] = _ic("U16", "AXP2101", {"37": "VBUS"}) + graph.components["J2"] = Component( + reference="J2", value="USB_C", footprint="Connector_USB:USB_C_Receptacle", + component_type=ComponentType.CONNECTOR, + pins={"A4": "VBUS"}, + ) + graph.nets = {"VBUS": _net("VBUS", ("J2", "A4"), ("U16", "37"))} + findings = check_esd(graph) + assert findings == [] + blob = " ".join(f.action or "" for f in findings) + assert "datasheet-specified" not in blob + + +def test_optical_spdif_is_not_an_esd_warning(): + findings = check_esd(_esd_graph( + "SPIF_IN", + j_fp="OptoDevice:Broadcom_AFBR-16xxZ_Horizontal", + j_value="TOSLINK", + )) + assert findings == [] + + +def test_unverified_spdif_warns_it_was_not_seen_as_optical(): + findings = check_esd(_esd_graph("SPIF_OUT", j_fp="Connector:PinHeader")) + assert findings + assert findings[0].status == "WARNING" + assert "not verified as optical" in (findings[0].finding or "") + assert "datasheet-specified" not in (findings[0].action or "") + + +def _cc_inst() -> PhysicalBusInstance: + return PhysicalBusInstance( + instance_id="usb-c-usb2-receptacle:J2", + logical_protocol_id="usb2-hs", + physical_interface_id="usb-c-usb2-receptacle", + pcb_relevant="YES", + confidence=1.0, + evidence_kind="pinout", + recognition_status="RECOGNIZED", + host_ref="J2", + nets=["Net-(J2-A5)", "Net-(J2-B5)"], + ) + + +def _cc_graph(*, both: bool) -> DesignGraph: + comps = { + "J2": Component( + reference="J2", + value="USB_C_Receptacle_USB2.0_16P", + footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12", + component_type=ComponentType.CONNECTOR, + pins={"A5": "Net-(J2-A5)", "B5": "Net-(J2-B5)", "A4": "VBUS"}, + ), + "R1": Component( + reference="R1", value="5.1k", footprint="R_0603", + component_type=ComponentType.RESISTOR, + pins={"1": "Net-(J2-A5)", "2": "GND"}, + ), + } + nets = { + "Net-(J2-A5)": _net("Net-(J2-A5)", ("J2", "A5"), ("R1", "1")), + "Net-(J2-B5)": _net("Net-(J2-B5)", ("J2", "B5")), + "GND": _net("GND", ("R1", "2")), + } + if both: + comps["R2"] = Component( + reference="R2", value="5.1k", footprint="R_0603", + component_type=ComponentType.RESISTOR, + pins={"1": "Net-(J2-B5)", "2": "GND"}, + ) + nets["Net-(J2-B5)"] = _net("Net-(J2-B5)", ("J2", "B5"), ("R2", "1")) + nets["GND"] = _net("GND", ("R1", "2"), ("R2", "2")) + return DesignGraph(components=comps, nets=nets) + + +def test_cc_rd_present_on_unnamed_nets_does_not_fail(): + row = _run_l0_check(_cc_graph(both=True), _cc_inst(), "cc_rd_rp", "MANDATORY") + assert row.result == "PASS" + assert "5.1" in row.notes + assert "CC1/CC2 net not present" not in row.notes + findings = l0_findings(_cc_inst(), [row]) + assert not any(f.rule_id == "PE-PRT-L0-001" for f in findings) + + +def test_cc_rd_missing_on_pin_net_fails(): + row = _run_l0_check(_cc_graph(both=False), _cc_inst(), "cc_rd_rp", "MANDATORY") + assert row.result == "FAIL" + assert "B5" in row.notes + assert "CC1/CC2 net not present" not in row.notes + assert "5.1" in row.notes + findings = l0_findings(_cc_inst(), [row]) + assert any(f.rule_id == "PE-PRT-L0-001" and f.status == "ERROR" for f in findings) + + +def _eth_graph(mpn: str, *, value: str = "") -> DesignGraph: + return DesignGraph( + components={ + "J1": Component( + reference="J1", value="RJ45", + footprint="Connector_RJ:RJ45_Abracon_ARJP11A-MA_Horizontal", + component_type=ComponentType.CONNECTOR, + pins={"9": "RJ45_TXP", "10": "RJ45_TXN"}, + ), + "U19": _ic("U19", mpn, {"21": "RJ45_TXP", "20": "RJ45_TXN"}, footprint="VQFN-24"), + }, + nets={ + "RJ45_TXP": _net("RJ45_TXP", ("J1", "9"), ("U19", "21")), + "RJ45_TXN": _net("RJ45_TXN", ("J1", "10"), ("U19", "20")), + }, + ) if not value else DesignGraph( + components={ + "J1": Component( + reference="J1", value="RJ45", + footprint="Connector_RJ:RJ45", + component_type=ComponentType.CONNECTOR, + pins={"9": "RJ45_TXP", "10": "RJ45_TXN"}, + ), + "U19": Component( + reference="U19", value=value, footprint="", + component_type=ComponentType.IC, mpn=mpn, + pins={"21": "RJ45_TXP", "20": "RJ45_TXN"}, + ), + }, + nets={ + "RJ45_TXP": _net("RJ45_TXP", ("J1", "9"), ("U19", "21")), + "RJ45_TXN": _net("RJ45_TXN", ("J1", "10"), ("U19", "20")), + }, + ) + + +def _eth_layout() -> LayoutGraph: + return LayoutGraph( + segments=[ + LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="RJ45_TXP"), + LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="RJ45_TXN"), + ], + ) + + +def _eth_findings(graph: DesignGraph, monkeypatch, z: float | None): + if z is not None: + monkeypatch.setattr( + "backend.periscopex.af_trace_check._pair_z_ohm", + lambda layout, unit: z, + ) + return [f for f in check_af_traces(graph, {}, _eth_layout()) if f.rule_id == "PE-AF-002"] + + +def test_lan8720_pair_without_z_is_error_with_100base_cite(monkeypatch): + findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, None) + assert len(findings) == 1 + f = findings[0] + assert f.status == "ERROR" + blob = f"{f.finding} {f.action} {f.requirement}" + assert blob.startswith("ERROR:") or "ERROR:" in f.finding + assert "100" in blob and "25.4.9" in blob + assert "stackup" in blob + assert "Fornire" not in blob + assert "tr" not in blob.lower() + assert "90" not in blob + assert "Clause 40" not in blob + assert "1000BASE" not in blob + assert "PASS" not in f.finding + + +def test_lan8720_pair_z_inside_cite_is_pass(monkeypatch): + findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 100.0) + assert len(findings) == 1 + assert findings[0].finding.startswith("PASS:") + assert findings[0].status == "INFO" + assert "RJ45_TXP" in findings[0].finding and "RJ45_TXN" in findings[0].finding + assert "25.4.9" in findings[0].finding + + +def test_lan8720_pair_z_outside_cite_is_fail(monkeypatch): + findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 90.0) + assert len(findings) == 1 + assert findings[0].finding.startswith("FAIL:") + assert findings[0].status == "ERROR" + assert "PASS" not in findings[0].finding + assert "25.4.9" in findings[0].finding + + +def test_gigabit_phy_uses_clause_40_not_100base(monkeypatch): + findings = _eth_findings( + _eth_graph("RTL8211F", value="1000BASE-T"), + monkeypatch, + None, + ) + assert len(findings) == 1 + blob = findings[0].finding + assert findings[0].status == "ERROR" + assert "Clause 40" in blob + assert "25.4.9" not in blob + assert "PASS" not in blob