Add AF trace analysis after PCB checks (2.63.5).

Trigger λ/10 and tr/(6 tpd) from FACT; pairs analyzed together; Z only from
datasheet; visible INSUFFICIENT skips; lossless RLGC cascade (no OpenEMS).
This commit is contained in:
2026-09-22 08:39:37 +02:00
parent 8b4f7710cf
commit c87adf11a9
15 changed files with 1469 additions and 7 deletions
+240
View File
@@ -0,0 +1,240 @@
"""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