Match exposed pads by land, cite ESD, follow CC to Rd (2.84.0).
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.
This commit is contained in:
@@ -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<stem>.*(?:TX|RX|TD|RD|TRD\d|TP))(?P<pol>[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=[],
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user