Trigger λ/10 and tr/(6 tpd) from FACT; pairs analyzed together; Z only from datasheet; visible INSUFFICIENT skips; lossless RLGC cascade (no OpenEMS).
241 lines
8.3 KiB
Python
241 lines
8.3 KiB
Python
"""AF trace v1: trigger, visible skips, pair-together, no invented ohms."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backend.periscopex.af_rlgc import cascade_sparam
|
|
from backend.periscopex.af_trace_check import check_af_traces
|
|
from backend.periscopex.af_trigger import evaluate_trigger, iter_af_units
|
|
from backend.periscopex.finding_engine import lookup_rule
|
|
from backend.periscopex.models import (
|
|
Component,
|
|
ComponentConstraints,
|
|
ComponentType,
|
|
DesignGraph,
|
|
LayoutDielectric,
|
|
LayoutFootprint,
|
|
LayoutGraph,
|
|
LayoutPad,
|
|
LayoutSegment,
|
|
LayoutStackup,
|
|
LayoutVia,
|
|
Net,
|
|
NetType,
|
|
Pin,
|
|
PinConnection,
|
|
)
|
|
from backend.periscopex.pcb_checks import run_pcb_checks
|
|
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
|
|
|
_HUB = Path("/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb")
|
|
|
|
|
|
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY") -> Component:
|
|
return Component(
|
|
reference=ref, value=mpn, 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 _usb_graph() -> DesignGraph:
|
|
return DesignGraph(
|
|
components={
|
|
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}),
|
|
"J2": Component(
|
|
reference="J2", value="USB_C", footprint="",
|
|
component_type=ComponentType.CONNECTOR,
|
|
pins={"A6": "USB_D+", "A7": "USB_D-"},
|
|
),
|
|
},
|
|
nets={
|
|
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
|
|
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
|
|
},
|
|
)
|
|
|
|
|
|
def _stack() -> LayoutStackup:
|
|
return LayoutStackup(
|
|
copper_layers=["F.Cu", "B.Cu"],
|
|
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
|
|
copper_thickness_mm=0.035,
|
|
)
|
|
|
|
|
|
def _pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
|
|
segs = [
|
|
LayoutSegment(start=(0, 0), end=(length, 0), width=0.2, layer="F.Cu", net="USB_D+"),
|
|
LayoutSegment(start=(0, 0.4), end=(length, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
|
|
]
|
|
if elbow:
|
|
segs.append(LayoutSegment(
|
|
start=(length, 0), end=(length, 3), width=0.2, layer="F.Cu", net="USB_D+",
|
|
))
|
|
return LayoutGraph(
|
|
footprints={
|
|
"U1": LayoutFootprint(
|
|
reference="U1", footprint="P", x=0, y=0,
|
|
pads=[LayoutPad(number="1", x=0, y=0, net="USB_D+"),
|
|
LayoutPad(number="2", x=0, y=0.4, net="USB_D-")],
|
|
),
|
|
"J2": LayoutFootprint(
|
|
reference="J2", footprint="P", x=length, y=0,
|
|
pads=[LayoutPad(number="A6", x=length, y=0, net="USB_D+"),
|
|
LayoutPad(number="A7", x=length, y=0.4, net="USB_D-")],
|
|
),
|
|
},
|
|
segments=segs,
|
|
stackup=_stack(),
|
|
)
|
|
|
|
|
|
def _rules(*extra: dict) -> dict:
|
|
rules = list(extra)
|
|
return {
|
|
"PHY": ComponentConstraints(
|
|
mpn="PHY", pintable=[Pin(number="1", name="D+")],
|
|
absolute_maximum_ratings=[], rules=[],
|
|
layout_rules=rules,
|
|
),
|
|
}
|
|
|
|
|
|
def test_catalog_pe_af_ids():
|
|
for rid in ("PE-AF-001", "PE-AF-002", "PE-AF-020", "PE-AF-050", "PE-AF-060"):
|
|
rec = lookup_rule(rid)
|
|
assert rec is not None, rid
|
|
assert rec.domain == "pcb"
|
|
|
|
|
|
def test_pair_is_one_unit():
|
|
units = iter_af_units(_pair_layout(), _usb_graph())
|
|
assert len(units) == 1
|
|
assert set(units[0].nets) == {"USB_D+", "USB_D-"}
|
|
|
|
|
|
def test_gpio_is_not_a_candidate():
|
|
g = DesignGraph(
|
|
components={"U1": _ic("U1", {"1": "SDA"})},
|
|
nets={"SDA": _net("SDA", ("U1", "1"))},
|
|
)
|
|
layout = LayoutGraph(
|
|
segments=[LayoutSegment(start=(0, 0), end=(80, 0), width=0.2, layer="F.Cu", net="SDA")],
|
|
stackup=_stack(),
|
|
)
|
|
assert check_af_traces(g, {}, layout) == []
|
|
|
|
|
|
def test_missing_tr_f_is_visible_skip_no_ohm():
|
|
layout = _pair_layout()
|
|
out = check_af_traces(_usb_graph(), {}, layout)
|
|
assert out
|
|
assert all(f.rule_id == "PE-AF-002" for f in out)
|
|
assert len(out) == 1
|
|
text = out[0].finding
|
|
assert "Pista ad alta frequenza non controllata per mancanza di" in text
|
|
assert "tr" in text and "f" in text
|
|
assert out[0].evidence_status == "INSUFFICIENT"
|
|
assert out[0].finding_class == "REVIEW"
|
|
blob = (out[0].finding + out[0].facts).lower()
|
|
assert "90" not in blob and "50" not in blob and "ω" not in blob and "ohm" not in blob
|
|
|
|
|
|
def test_short_pair_with_tr_is_not_af():
|
|
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 5.0})
|
|
layout = _pair_layout(length=8.0)
|
|
trig = evaluate_trigger(_usb_graph(), cons, layout, iter_af_units(layout, _usb_graph())[0])
|
|
assert trig.af is False
|
|
assert check_af_traces(_usb_graph(), cons, layout) == []
|
|
|
|
|
|
def test_triggered_without_z_target_is_visible_skip():
|
|
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
|
layout = _pair_layout(length=40.0)
|
|
out = check_af_traces(_usb_graph(), cons, layout)
|
|
skips = [f for f in out if f.rule_id == "PE-AF-002"]
|
|
assert skips
|
|
assert "Z target datasheet" in skips[0].finding
|
|
assert all("90" not in f.finding for f in out)
|
|
|
|
|
|
def test_triggered_zdiff_window_uses_datasheet_not_folklore():
|
|
cons = _rules(
|
|
{"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05},
|
|
{"kind": "impedance", "net_class": "usb2", "zdiff_ohm": 90, "tolerance_pct": 10},
|
|
)
|
|
layout = _pair_layout(length=40.0)
|
|
out = check_af_traces(_usb_graph(), cons, layout)
|
|
zhits = [f for f in out if f.rule_id == "PE-AF-050"]
|
|
if zhits:
|
|
assert zhits[0].net in {"USB_D+", "USB_D-"}
|
|
assert "90" in zhits[0].finding or "90" in zhits[0].facts
|
|
assert zhits[0].calculation == "" or "Z_ref" in zhits[0].calculation
|
|
assert sum(1 for f in out if f.rule_id == "PE-AF-050") <= 1
|
|
|
|
|
|
def test_right_angle_on_triggered_pair():
|
|
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
|
layout = _pair_layout(length=40.0, elbow=True)
|
|
out = check_af_traces(_usb_graph(), cons, layout)
|
|
assert any(f.rule_id == "PE-AF-020" for f in out)
|
|
|
|
|
|
def test_via_without_span_is_visible_skip():
|
|
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
|
layout = _pair_layout(length=40.0)
|
|
layout.vias = [LayoutVia(x=5, y=0, net="USB_D+", drill=0.3)]
|
|
out = check_af_traces(_usb_graph(), cons, layout)
|
|
assert any("span via" in f.finding for f in out if f.rule_id == "PE-AF-002")
|
|
|
|
|
|
def test_via_with_span_is_not_a_pad():
|
|
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
|
layout = _pair_layout(length=40.0)
|
|
layout.vias = [LayoutVia(x=5, y=0, net="USB_D+", drill=0.3, layers=("F.Cu", "B.Cu"))]
|
|
out = check_af_traces(_usb_graph(), cons, layout)
|
|
assert any(f.rule_id == "PE-AF-060" for f in out)
|
|
assert all(f.rule_id != "PE-VIA-001" for f in out)
|
|
|
|
|
|
def test_si_stub_still_fires_without_af_module():
|
|
layout = _pair_layout(length=10.0, elbow=True)
|
|
cons = _rules({"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0, "note": "USB stub"})
|
|
pcb = run_pcb_checks(_usb_graph(), cons, layout)
|
|
assert any(f.rule_id == "PE-SI-007" for f in pcb)
|
|
|
|
|
|
def test_cascade_needs_positive_zref():
|
|
assert cascade_sparam([50.0], [0.04], 3.5, 1e9, 0.0) is None
|
|
r = cascade_sparam([50.0], [0.04], 3.5, 1e9, 50.0)
|
|
assert r is not None
|
|
assert r.n_sections == 1
|
|
|
|
|
|
@pytest.mark.skipif(not _HUB.is_file(), reason="HubAudio board not on this machine")
|
|
def test_hubaudio_usb_skip_has_no_invented_ohm():
|
|
layout = parse_kicad_pcb(_HUB)
|
|
graph = DesignGraph(
|
|
components={"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"})},
|
|
nets={
|
|
"USB_D+": _net("USB_D+", ("U1", "1")),
|
|
"USB_D-": _net("USB_D-", ("U1", "2")),
|
|
},
|
|
)
|
|
# Real net names may be hierarchical; still must not invent ohms on AF skips.
|
|
out = check_af_traces(graph, {}, layout)
|
|
for f in out:
|
|
if f.rule_id == "PE-AF-002":
|
|
low = f.finding.lower()
|
|
assert "90 Ω" not in f.finding and "50 Ω" not in f.finding
|
|
assert "90 ohm" not in low
|