"""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