"""AF board + AI: hypotheses of probable HF issues, then deterministic checks. Does not replace run_pcb_checks. Not DRC. Never invents Z. """ from __future__ import annotations from backend.periscopex.af_ai_hf import ( HfHypothesis, append_investigated, hypotheses_from_rows, investigate_hf_hypotheses, ) from backend.periscopex.hf_line_check import check_hf_lines from backend.periscopex.models import ( Component, ComponentConstraints, ComponentType, DesignGraph, LayoutFootprint, LayoutGraph, LayoutPad, LayoutSegment, LayoutStackup, LayoutDielectric, Net, NetType, Pin, PinConnection, ) from backend.periscopex.pcb_checks import run_pcb_checks 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 _fp(ref: str, pads: list[LayoutPad], x: float = 0.0) -> LayoutFootprint: return LayoutFootprint(reference=ref, footprint="P", x=x, y=0.0, pads=pads) def _stub_layout() -> LayoutGraph: return LayoutGraph( footprints={ "U1": _fp("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]), "J2": _fp("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0), }, segments=[ LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"), LayoutSegment(start=(10, 0), end=(10, 3), width=0.2, layer="F.Cu", net="USB_D+"), ], ) def _stub_constraints() -> dict: return { "PHY": ComponentConstraints( mpn="PHY", pintable=[Pin(number="1", name="D+")], absolute_maximum_ratings=[], rules=[], layout_rules=[{ "kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0, "note": "USB stub < 1 mm", "source_page": 12, }], ), } def test_ai_stub_flag_then_deterministic_pe_si_007(): graph = _usb_graph() layout = _stub_layout() cons = _stub_constraints() flags = [HfHypothesis(net="USB_D+", issue_class="stub", why="looks long")] out = investigate_hf_hypotheses(graph, cons, layout, flags) codes = [f.rule_id for f in out] assert "PE-SI-007" in codes hit = next(f for f in out if f.rule_id == "PE-SI-007") assert "FAIL" in hit.finding assert hit.evidence_status == "SUFFICIENT" def test_ai_z0_flag_without_stackup_is_insufficient_no_ohm(): graph = _usb_graph() layout = _stub_layout() flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm")] out = investigate_hf_hypotheses(graph, {}, layout, flags) assert len(out) == 1 f = out[0] assert f.rule_id == "PE-AF-001" assert f.evidence_status == "INSUFFICIENT" assert "90" not in f.finding assert "50" not in f.finding assert "Ω" not in f.finding and "ohm" not in f.finding.lower() def test_ai_flag_on_missing_superspeed_net_is_silence(): graph = _usb_graph() layout = _stub_layout() flags = [HfHypothesis(net="USB3_SSTX_P", issue_class="stub", why="SS pair")] out = investigate_hf_hypotheses(graph, _stub_constraints(), layout, flags) assert out == [] def test_run_pcb_checks_still_finds_stub_without_af_ai(): """AF+AI is extra: the PCB check list must still emit PE-SI-007 alone.""" graph = _usb_graph() layout = _stub_layout() pcb = run_pcb_checks(graph, _stub_constraints(), layout) assert any(f.rule_id == "PE-SI-007" for f in pcb) hf = check_hf_lines(graph, _stub_constraints(), layout) assert any(f.rule_id == "PE-SI-007" for f in hf) def test_z0_with_stackup_does_not_invent_ohm_number(): graph = _usb_graph() layout = _stub_layout() layout.stackup = LayoutStackup( copper_layers=["F.Cu", "B.Cu"], dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)], ) flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="check Z0")] out = investigate_hf_hypotheses(graph, {}, layout, flags) for f in out: assert "50 Ω" not in (f.finding or "") assert "90 Ω" not in (f.finding or "") def test_append_investigated_keeps_pcb_checks_and_adds_pe_af(): graph = _usb_graph() layout = _stub_layout() cons = _stub_constraints() pcb = run_pcb_checks(graph, cons, layout) n_si = sum(1 for f in pcb if f.rule_id == "PE-SI-007") assert n_si >= 1 flags = [ HfHypothesis(net="USB_D+", issue_class="stub", why="looks long"), HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm"), ] added = append_investigated(pcb, graph, cons, layout, flags) assert any(f.rule_id == "PE-AF-001" for f in added) assert sum(1 for f in pcb if f.rule_id == "PE-SI-007") == n_si assert "si_check" in _pcb_checks_source() assert "hf_line" in _pcb_checks_source() def test_hypotheses_from_rows_drops_unknown_class(): rows = [ {"net": "USB_D+", "issue_class": "stub", "why": "x"}, {"net": "USB_D+", "issue_class": "clearance", "why": "drc"}, {"net": "", "issue_class": "z0", "why": "empty"}, ] hyps = hypotheses_from_rows(rows) assert len(hyps) == 1 assert hyps[0].issue_class == "stub" def test_pcb_pipeline_af_ai_is_after_run_pcb_checks(): import inspect from backend.periscopex import pcb_checks from backend.services import pcb_pipeline src = inspect.getsource(pcb_pipeline.run_pcb_pipeline) assert "run_pcb_checks" in src assert "check_af_traces" in src assert "_run_af_ai_section" in src assert src.find("run_pcb_checks") < src.find("check_af_traces") assert src.find("check_af_traces") < src.find("_run_af_ai_section") checks = inspect.getsource(pcb_checks.run_pcb_checks) assert '("si_check"' in checks or '"si_check"' in checks assert '"hf_line"' in checks assert "af_ai" not in checks assert "check_af_traces" not in checks assert "investigate_hf_hypotheses" not in checks def _pcb_checks_source() -> str: import inspect from backend.periscopex.pcb_checks import run_pcb_checks return inspect.getsource(run_pcb_checks)