Compare commits

...
Author SHA1 Message Date
michele bb293414d7 Fix CI: align protocol tests + package version with packed catalog.
Update SGMII skeleton test for Cisco NUMERIC 1.25 Gbaud; expect cited
USB 90 Ω in report/catalog; sync periscope-web to changelog 2.87.3.
2026-09-23 08:38:05 +02:00
michele 7fd03607ea Soft-bump 2.87.3: HubAudio AF verify + USB 90 Ω regression.
HubAudio's last exam was still on 2.87.0 while live carried 2.87.2 AF
fixes. Cue a re-run and lock USB_D+ skipping PE-AF-002 in favour of the
English protocol 90 Ω cert line.
2026-09-23 08:29:10 +02:00
michele 78a7da2dbf Fix HubAudio PE-AF-002 noise: MDIO≠MDI, RD≡RX, USB/RMII cert (2.87.2).
Exclude ETH_MDIO/MDC from IEEE MDI 100 Ω. Deduplicate RD+/RX and TD+/TX
pair aliases to one cert per channel; inherit LAN8720A 10/100 when the
magnetics-side nets lack the PHY. USB D+/D− skip AF tr/f (90 Ω cert owns
them); RMII stays on the English PE-AF-003 AN-133 line.
2026-09-23 08:05:24 +02:00
michele 5c87d706c2 Remove coffee/email notice from progress UI (2.87.1).
Replace the running-pipeline coffee banner and email promise with a
neutral status line so the notice never shows again.
2026-09-23 07:46:13 +02:00
13 changed files with 528 additions and 82 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ Periscope is full **PCB analysis** for a board you intend to send to fab with co
Give it a netlist, a BOM, datasheet PDFs, and a KiCad PCB. It builds a queryable graph of the design, extracts manufacturer constraints into a shared library, then checks both the circuit and the copper: pads, tracks, vias, zones, measured geometry, and the interfaces that are actually on **this** board.
Live: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
Version: **2.87.0** (see `periscope/src/frontend/content/changelog.md`)
Version: **2.87.2** (see `periscope/src/frontend/content/changelog.md`)
Operator: Michele Bigi
## What you get
@@ -26,7 +26,6 @@ import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Coffee,
Coins,
Loader2,
OctagonX,
@@ -248,23 +247,23 @@ export default function ProgressPage({
)}
{isRunning && !isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
/>
<Coffee
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
<Loader2
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">
Feel free to grab a coffee
Pipeline running
</p>
<p className="text-sm text-muted-foreground mt-0.5">
You can close this tab we&apos;ll email you when it&apos;s ready.
Stages update below as the run proceeds.
</p>
</div>
</div>
@@ -41,19 +41,58 @@ _MDI_POL = re.compile(
r"^(?P<stem>.*(?:TX|RX|TD|RD|TRD\d|TP))(?P<pol>[PN])$",
re.I,
)
# RD≡RX, TD≡TX, +≡P, −≡N — same physical MDI pair under HubAudio-style dual names.
_MDI_CHANNEL_RE = re.compile(
r"(?P<ch>RX|RD|TX|TD|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 "")
leaf = (name or "").rsplit("/", 1)[-1]
m = _MDI_POL.match(leaf)
if m:
pol = m.group("pol").upper()
return m.group("stem") + ("N" if pol == "P" else "P")
stem = m.group("stem")
mate_leaf = stem + ("N" if pol == "P" else "P")
if "/" in (name or ""):
return name.rsplit("/", 1)[0] + "/" + mate_leaf
return mate_leaf
return partner_net(name)
def _mdi_channel_key(net: str) -> tuple[str, str] | None:
"""Canonical (hierarchy, rx|tx|…) so RD+/RXP and TD/TXN share one cert."""
if not net:
return None
prefix, _, leaf = net.rpartition("/")
m = _MDI_CHANNEL_RE.search(leaf)
if not m:
return None
raw = m.group("ch").upper()
if raw.startswith("TRD"):
ch = f"trd{raw[3:]}"
elif raw in ("RX", "RD"):
ch = "rx"
elif raw in ("TX", "TD"):
ch = "tx"
else:
ch = raw.lower()
return (prefix, ch)
def _prefer_mdi_unit(a: AfUnit, b: AfUnit) -> AfUnit:
"""Prefer PHY-side RXP/TXP names over magnetics RD+/TD+ when both exist."""
def score(u: AfUnit) -> tuple[int, int]:
blob = " ".join(u.nets).upper()
phy = 1 if re.search(r"(?:RX|TX)[PN]\b", blob) else 0
return (phy, len(u.nets))
return a if score(a) >= score(b) else b
def _is_eth_mdi_net(net: str) -> bool:
if single_ended_eth_mac(net):
return False
@@ -61,7 +100,7 @@ def _is_eth_mdi_net(net: str) -> bool:
def _coalesce_eth_pairs(units: list[AfUnit]) -> list[AfUnit]:
"""Treat each RJ45/PHY MDI pair as one unit, including TXP/TXN names."""
"""Treat each RJ45/PHY MDI pair as one unit, including TXP/TXN and RD≡RX aliases."""
by_net = {n: u for u in units for n in u.nets}
out: list[AfUnit] = []
seen: set[str] = set()
@@ -87,15 +126,52 @@ def _coalesce_eth_pairs(units: list[AfUnit]) -> list[AfUnit]:
continue
seen.update(unit.nets)
out.append(unit)
return out
return _dedupe_mdi_aliases(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."""
def _dedupe_mdi_aliases(units: list[AfUnit]) -> list[AfUnit]:
"""One unit per (sheet, rx|tx): RD+/RD- and RXP/RXN are the same physical pair."""
best: dict[tuple[str, str], AfUnit] = {}
passthrough: list[AfUnit] = []
for unit in units:
if unit.bus != "eth_mdi" and not all(_is_eth_mdi_net(n) for n in unit.nets):
passthrough.append(unit)
continue
keys = {_mdi_channel_key(n) for n in unit.nets}
keys.discard(None)
if len(keys) != 1:
# Mixed or unknown — keep as-is (still eth_mdi tagged).
tagged = unit if unit.bus == "eth_mdi" else AfUnit(
nets=unit.nets, length_mm=unit.length_mm, bus="eth_mdi",
)
passthrough.append(tagged)
continue
key = next(iter(keys))
assert key is not None
prev = best.get(key)
if prev is None:
best[key] = AfUnit(
nets=tuple(sorted(unit.nets)),
length_mm=unit.length_mm,
bus="eth_mdi",
)
else:
best[key] = _prefer_mdi_unit(prev, AfUnit(
nets=tuple(sorted(unit.nets)),
length_mm=max(prev.length_mm, unit.length_mm),
bus="eth_mdi",
))
return passthrough + list(best.values())
def _component_speed_bits(graph: DesignGraph, refs: list[str] | None = None) -> str:
bits: list[str] = []
for net in nets:
for ref in refs_on_matched_net(graph, net):
comp = graph.components.get(ref)
comps = (
{r: graph.components[r] for r in refs if r in graph.components}
if refs is not None
else graph.components
)
for ref, comp in comps.items():
if comp is None:
continue
bits.extend([
@@ -106,11 +182,23 @@ def _eth_speed(graph: DesignGraph, nets: tuple[str, ...]) -> str | None:
for key in ("description", "Description", "value", "Value"):
if row.get(key):
bits.append(str(row.get(key)))
text = " ".join(bits)
return " ".join(bits)
def _eth_speed(graph: DesignGraph, nets: tuple[str, ...]) -> str | None:
"""10/100 or gbe from the PHY/jack on these nets, else any board PHY (LAN8720A)."""
local_refs = [ref for net in nets for ref in refs_on_matched_net(graph, net)]
text = _component_speed_bits(graph, local_refs)
if _GBE_RE.search(text):
return "gbe"
if _FAST_RE.search(text):
return "10/100"
# Magnetics-side RD+/TD+ often lack the PHY MPN — inherit board speed.
board = _component_speed_bits(graph, None)
if _GBE_RE.search(board):
return "gbe"
if _FAST_RE.search(board):
return "10/100"
return None
@@ -249,6 +337,9 @@ def check_af_traces(
mac_phy_nets: list[str] = []
sgmii_units: list[AfUnit] = []
for unit in _coalesce_eth_pairs(list(iter_af_units(layout, graph))):
# USB 2.0 D+/D: protocol_l2 owns the 90 Ω cert line. Never PE-AF-002 tr/f.
if unit.bus == "usb2" or any(bus_class(n) == "usb2" for n in unit.nets):
continue
if any(single_ended_eth_mac(n) or phy_mac_kind(n) in {"rmii", "mii", "rgmii"} for n in unit.nets):
if not any(_is_eth_mdi_net(n) for n in unit.nets):
mac_phy_nets.extend(unit.nets)
+13 -1
View File
@@ -35,6 +35,9 @@ _XTAL_RE = re.compile(
re.I,
)
# MDIO/MDC management (often single-ended). Not RJ45/MDI 100 Ω differential.
_MDIO_MGMT_RE = re.compile(r"(?:^|[_/.])(?:MDIO|MDC)$", re.I)
_HS_CLASS_RE = (
("usb3", re.compile(
r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]|SS[_]?RX", re.I,
@@ -49,7 +52,8 @@ _HS_CLASS_RE = (
re.I,
)),
("eth_mdi", re.compile(
r"(?:^|[_/])(MDI|TRD[0-3]|TCT|RCT)|"
# MDI(?!O) — RJ45 Media Dependent Interface, never MDIO management.
r"(?:^|[_/])(MDI(?!O)|TRD[0-3]|TCT|RCT)|"
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
re.I,
)),
@@ -194,12 +198,20 @@ def skip_si_net(net: str) -> bool:
return bool(_SKIP_RE.search(leaf))
def is_mdio_mgmt(net: str) -> bool:
"""ETH_MDIO / ETH_MDC / MDIO — management, not IEEE MDI cabling."""
leaf = _leaf(net)
return bool(_MDIO_MGMT_RE.search(leaf) or _MDIO_MGMT_RE.search(net or ""))
def bus_class(net: str) -> str | None:
if skip_si_net(net):
return None
leaf = _leaf(net)
if _XTAL_RE.search(leaf):
return None
if is_mdio_mgmt(net):
return None
u = leaf.upper()
if re.search(r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]", u):
return "usb3"
@@ -2,6 +2,27 @@
What's new in Periscope.
## 2.87.3 — 2026-09-23 — HubAudio AF verify + re-run cue
Live already carried the 2.87.2 PE-AF-002 cleanup, but HubAudio's last PCB exam was still stamped **2.87.0** — so Michele still saw duplicate RD/TD “speed not identified”, ETH_MDIO 100 Ω, Italian RMII/USB tr/f. Replay of the saved HubAudio design/layout graphs confirms the fix. Soft-bump so the report version moves; **re-run HubAudio PCB exam** on live.
- [Changed] Regression: USB_D+ skips PE-AF-002; protocol owns the English 90 Ω cert line.
- [Changed] Changelog cue: HubAudio must be re-examined after AF noise fixes.
## 2.87.2 — 2026-09-23 — HubAudio AF noise: MDIO≠MDI, RD≡RX, USB/RMII cert
HubAudio PE-AF-002 cleanup on live 2.87.x. Ethernet MDI aliases (`RD+/``RXP/RXN`, `TD+/``TXP/TXN`) are one cert per pair; LAN8720A supplies 10/100 when the magnetics-side nets lack the PHY MPN. `ETH_MDIO` / `MDC` are management — not IEEE 100BASE-TX 25.4.9. USB D+/D leave the AF tr/f skip (protocol 90 Ω cert owns them). RMII stays on the AN-133 PE-AF-003 English line.
- [Changed] `si_check.py`: `is_mdio_mgmt`; `MDI(?!O)` so MDIO is not eth_mdi.
- [Changed] `af_trace_check.py`: MDI channel dedupe; graph-wide PHY speed; USB skips PE-AF-002.
- [Changed] pytest `test_hubaudio_af_noise.py`; AF geometry tests use HDMI (USB is cert-owned).
## 2.87.1 — 2026-09-23 — Remove coffee/email progress notice
Drop the running-pipeline “grab a coffee / well email you” banner. Progress shows a neutral “Pipeline running…” status instead.
- [Removed] Coffee icon + email promise on project progress while a run is active.
## 2.87.0 — 2026-09-23 — Live integrate: iPad top-bar, MACPHY pack, USB/RMII cert
Combines the iPad top-bar shell (light default, Save as Markdown), the MACPHY bus catalog pack (Micrel AN-133, OPEN Alliance RGMII EPL V2.2, Cisco SGMII ENG-46158), USB 2.0 D+/D cert lines versus the packed 90 Ω cite, RMII not-certified-on-recognition, and the `not_reviewed` count fix. One coherent live version above 2.86.0.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "periscope-web",
"version": "2.60.0",
"version": "2.87.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "periscope-web",
"version": "2.60.0",
"version": "2.87.3",
"dependencies": {
"@base-ui/react": "^1.3.0",
"@types/dagre": "^0.7.54",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
"version": "2.87.0",
"version": "2.87.3",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
@@ -33,7 +33,6 @@ import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Coffee,
Coins,
Loader2,
OctagonX,
@@ -237,21 +236,21 @@ export default function ProgressPage({
)}
{isRunning && !isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
/>
<Coffee
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
<Loader2
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">Feel free to grab a coffee</p>
<p className="text-base font-medium text-foreground">Pipeline running</p>
<p className="text-sm text-muted-foreground mt-0.5">
You can close this tab we&apos;ll email you when it&apos;s ready.
Stages update below as the run proceeds.
</p>
</div>
</div>
+1 -1
View File
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.87.0";
export const APP_VERSION = "2.87.3";
export const APP_VERSION_DATE = "2026-09-23";
+71 -33
View File
@@ -47,6 +47,28 @@ def _net(name: str, *pairs: tuple[str, str]) -> Net:
)
# Generic AF geometry tests use HDMI (not USB): USB is owned by the 90 Ω cert path.
_AF_P = "HDMI_D0+"
_AF_N = "HDMI_D0-"
def _af_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": _ic("U1", {"1": _AF_P, "2": _AF_N}),
"J1": Component(
reference="J1", value="HDMI", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"1": _AF_P, "2": _AF_N},
),
},
nets={
_AF_P: _net(_AF_P, ("U1", "1"), ("J1", "1")),
_AF_N: _net(_AF_N, ("U1", "2"), ("J1", "2")),
},
)
def _usb_graph() -> DesignGraph:
return DesignGraph(
components={
@@ -72,26 +94,26 @@ def _stack() -> LayoutStackup:
)
def _pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
def _pair_layout(*, length: float = 10.0, elbow: bool = False, p: str = _AF_P, n: str = _AF_N) -> 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-"),
LayoutSegment(start=(0, 0), end=(length, 0), width=0.2, layer="F.Cu", net=p),
LayoutSegment(start=(0, 0.4), end=(length, 0.4), width=0.2, layer="F.Cu", net=n),
]
if elbow:
segs.append(LayoutSegment(
start=(length, 0), end=(length, 3), width=0.2, layer="F.Cu", net="USB_D+",
start=(length, 0), end=(length, 3), width=0.2, layer="F.Cu", net=p,
))
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-")],
pads=[LayoutPad(number="1", x=0, y=0, net=p),
LayoutPad(number="2", x=0, y=0.4, net=n)],
),
"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-")],
"J1": LayoutFootprint(
reference="J1", footprint="P", x=length, y=0,
pads=[LayoutPad(number="1", x=length, y=0, net=p),
LayoutPad(number="2", x=length, y=0.4, net=n)],
),
},
segments=segs,
@@ -99,6 +121,10 @@ def _pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
)
def _usb_pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
return _pair_layout(length=length, elbow=elbow, p="USB_D+", n="USB_D-")
def _rules(*extra: dict) -> dict:
rules = list(extra)
return {
@@ -118,9 +144,9 @@ def test_catalog_pe_af_ids():
def test_pair_is_one_unit():
units = iter_af_units(_pair_layout(), _usb_graph())
units = iter_af_units(_pair_layout(), _af_graph())
assert len(units) == 1
assert set(units[0].nets) == {"USB_D+", "USB_D-"}
assert set(units[0].nets) == {_AF_P, _AF_N}
def test_gpio_is_not_a_candidate():
@@ -137,7 +163,7 @@ def test_gpio_is_not_a_candidate():
def test_missing_tr_f_is_visible_skip_no_ohm():
layout = _pair_layout()
out = check_af_traces(_usb_graph(), {}, layout)
out = check_af_traces(_af_graph(), {}, layout)
assert out
assert all(f.rule_id == "PE-AF-002" for f in out)
assert len(out) == 1
@@ -145,6 +171,7 @@ def test_missing_tr_f_is_visible_skip_no_ohm():
assert text.startswith("HF not certified — because")
assert "tr" in text and "f" in text
assert "mancanza" not in text
assert "Pista ad alta" not in text
assert out[0].evidence_status == "INSUFFICIENT"
assert out[0].finding_class == "REVIEW"
blob = (out[0].finding + out[0].facts).lower()
@@ -152,18 +179,26 @@ def test_missing_tr_f_is_visible_skip_no_ohm():
assert "invented" in blob
def test_usb_pair_skips_af_tr_f_pe_af_002():
"""USB D+/D must not get PE-AF-002 tr/f; protocol_l2 owns the 90 Ω cert line."""
out = check_af_traces(_usb_graph(), {}, _usb_pair_layout(length=40.0))
assert not any(f.rule_id == "PE-AF-002" for f in out)
assert not any("mancanza" in (f.finding or "") for f in out)
assert not any("Pista ad alta" in (f.finding or "") for f in out)
def test_short_pair_with_tr_is_not_af():
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 5.0})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 5.0})
layout = _pair_layout(length=8.0)
trig = evaluate_trigger(_usb_graph(), cons, layout, iter_af_units(layout, _usb_graph())[0])
trig = evaluate_trigger(_af_graph(), cons, layout, iter_af_units(layout, _af_graph())[0])
assert trig.af is False
assert check_af_traces(_usb_graph(), cons, layout) == []
assert check_af_traces(_af_graph(), cons, layout) == []
def test_triggered_without_z_target_is_visible_skip():
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
layout = _pair_layout(length=40.0)
out = check_af_traces(_usb_graph(), cons, layout)
out = check_af_traces(_af_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
@@ -172,45 +207,45 @@ def test_triggered_without_z_target_is_visible_skip():
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},
{"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05},
{"kind": "impedance", "net_class": "hdmi", "zdiff_ohm": 100, "tolerance_pct": 10},
)
layout = _pair_layout(length=40.0)
out = check_af_traces(_usb_graph(), cons, layout)
out = check_af_traces(_af_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].net in {_AF_P, _AF_N}
assert "100" in zhits[0].finding or "100" 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})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
layout = _pair_layout(length=40.0, elbow=True)
out = check_af_traces(_usb_graph(), cons, layout)
out = check_af_traces(_af_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})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "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)
layout.vias = [LayoutVia(x=5, y=0, net=_AF_P, drill=0.3)]
out = check_af_traces(_af_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})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "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)
layout.vias = [LayoutVia(x=5, y=0, net=_AF_P, drill=0.3, layers=("F.Cu", "B.Cu"))]
out = check_af_traces(_af_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)
layout = _usb_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)
@@ -233,10 +268,13 @@ def test_hubaudio_usb_skip_has_no_invented_ohm():
"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)
# USB must not produce PE-AF-002 tr/f or invented ohms.
for f in out:
if f.net and "USB" in (f.net or "").upper():
assert f.rule_id != "PE-AF-002"
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
assert "mancanza" not in low
+258
View File
@@ -0,0 +1,258 @@
"""HubAudio AF noise: MDIO≠MDI, RD≡RX pair dedup, RMII/USB not Italian tr/f."""
from __future__ import annotations
from backend.periscopex.af_trace_check import check_af_traces
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
LayoutDielectric,
LayoutGraph,
LayoutSegment,
LayoutStackup,
Net,
NetType,
PinConnection,
)
from backend.periscopex.si_check import bus_class, is_mdio_mgmt, single_ended_eth_mac
def _ic(ref: str, mpn: str, pins: dict[str, str]) -> 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 _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 test_eth_mdio_is_not_mdi():
assert is_mdio_mgmt("ETH_MDIO")
assert is_mdio_mgmt("ETH_MDC")
assert is_mdio_mgmt("MDIO")
assert bus_class("ETH_MDIO") is None
assert bus_class("ETH_MDC") is None
assert bus_class("/Ethernet/RJ45_RXP") == "eth_mdi"
def test_mdio_does_not_get_100ohm_ieee_mdi(monkeypatch):
graph = DesignGraph(
components={
"U19": _ic("U19", "LAN8720A", {"12": "ETH_MDIO", "11": "ETH_MDC"}),
"U3": _ic("U3", "MCU", {"20": "ETH_MDIO", "21": "ETH_MDC"}),
},
nets={
"ETH_MDIO": _net("ETH_MDIO", ("U19", "12"), ("U3", "20")),
"ETH_MDC": _net("ETH_MDC", ("U19", "11"), ("U3", "21")),
},
)
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(30, 0), width=0.15, layer="F.Cu", net="ETH_MDIO"),
LayoutSegment(start=(0, 0.5), end=(30, 0.5), width=0.15, layer="F.Cu", net="ETH_MDC"),
],
stackup=_stack(),
)
findings = check_af_traces(graph, {}, layout)
blob = " ".join(f.finding for f in findings)
assert "25.4.9" not in blob
assert "100BASE-TX" not in blob
assert not any(
f.finding.startswith("Ethernet certified") or f.finding.startswith("Ethernet not certified")
for f in findings
)
def test_rd_plus_and_rxn_are_one_mdi_pair(monkeypatch):
"""HubAudio-style dual names: RD+/RD- ≡ RXP/RXN — one cert, keep real Z FAIL."""
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="RJ45", footprint="RJ45",
component_type=ComponentType.CONNECTOR,
pins={
"1": "/Ethernet/RJ45_RD+", "2": "/Ethernet/RJ45_RD-",
"3": "/Ethernet/RJ45_TD+", "4": "/Ethernet/RJ45_TD-",
},
),
"U19": _ic("U19", "LAN8720A", {
"23": "/Ethernet/RJ45_RXP", "22": "/Ethernet/RJ45_RXN",
"21": "/Ethernet/RJ45_TXP", "20": "/Ethernet/RJ45_TXN",
}),
},
nets={
"/Ethernet/RJ45_RD+": _net("/Ethernet/RJ45_RD+", ("J1", "1")),
"/Ethernet/RJ45_RD-": _net("/Ethernet/RJ45_RD-", ("J1", "2")),
"/Ethernet/RJ45_TD+": _net("/Ethernet/RJ45_TD+", ("J1", "3")),
"/Ethernet/RJ45_TD-": _net("/Ethernet/RJ45_TD-", ("J1", "4")),
"/Ethernet/RJ45_RXP": _net("/Ethernet/RJ45_RXP", ("U19", "23")),
"/Ethernet/RJ45_RXN": _net("/Ethernet/RJ45_RXN", ("U19", "22")),
"/Ethernet/RJ45_TXP": _net("/Ethernet/RJ45_TXP", ("U19", "21")),
"/Ethernet/RJ45_TXN": _net("/Ethernet/RJ45_TXN", ("U19", "20")),
},
)
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RD+"),
LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RD-"),
LayoutSegment(start=(0, 1), end=(20, 1), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TD+"),
LayoutSegment(start=(0, 1.2), end=(20, 1.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TD-"),
LayoutSegment(start=(30, 0), end=(50, 0), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RXP"),
LayoutSegment(start=(30, 0.2), end=(50, 0.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RXN"),
LayoutSegment(start=(30, 1), end=(50, 1), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TXP"),
LayoutSegment(start=(30, 1.2), end=(50, 1.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TXN"),
],
stackup=_stack(),
)
monkeypatch.setattr(
"backend.periscopex.af_trace_check._pair_z_ohm",
lambda layout, unit: 46.0,
)
eth = [f for f in check_af_traces(graph, {}, layout) if f.rule_id == "PE-AF-002"]
assert len(eth) == 2 # one RX, one TX — not four
for f in eth:
assert f.status == "ERROR"
assert f.finding.startswith("Ethernet not certified — because Z")
assert "46" in f.finding
assert "100" in f.finding
assert "25.4.9" in f.finding
assert "speed" not in f.finding.lower() or "not identified" not in f.finding.lower()
assert "mancanza" not in f.finding
# Prefer PHY-side RXP/TXP naming in the kept unit.
facts = " ".join((f.facts or "") + f.finding for f in eth)
assert "RXP" in facts or "RXN" in facts
assert "TXP" in facts or "TXN" in facts
def test_rd_pair_alone_inherits_lan8720_speed(monkeypatch):
"""Magnetics RD+/RD- with LAN8720 elsewhere: no 'speed not identified' ERROR."""
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="RJ45", footprint="RJ45",
component_type=ComponentType.CONNECTOR,
pins={"1": "RJ45_RD+", "2": "RJ45_RD-"},
),
"U19": _ic("U19", "LAN8720A", {"1": "ETH_RXD0"}),
},
nets={
"RJ45_RD+": _net("RJ45_RD+", ("J1", "1")),
"RJ45_RD-": _net("RJ45_RD-", ("J1", "2")),
"ETH_RXD0": _net("ETH_RXD0", ("U19", "1")),
},
)
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="RJ45_RD+"),
LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="RJ45_RD-"),
],
)
monkeypatch.setattr(
"backend.periscopex.af_trace_check._pair_z_ohm",
lambda layout, unit: None,
)
eth = [f for f in check_af_traces(graph, {}, layout) if f.rule_id == "PE-AF-002"]
assert len(eth) == 1
assert "not identified" not in eth[0].finding
assert "25.4.9" in eth[0].finding
assert eth[0].finding.startswith("Ethernet not certified — because Z cannot be calculated")
def test_rmii_no_italian_tr_f_pe_af_002():
graph = DesignGraph(
components={
"U19": _ic("U19", "LAN8720A", {
"1": "ETH_RXD0", "2": "ETH_RXD1",
"3": "ETH_TXD0", "4": "ETH_TXD1", "5": "ETH_TXEN",
}),
"U3": _ic("U3", "MCU", {
"10": "ETH_RXD0", "11": "ETH_RXD1",
"12": "ETH_TXD0", "13": "ETH_TXD1", "14": "ETH_TXEN",
}),
},
nets={
"ETH_RXD0": _net("ETH_RXD0", ("U19", "1"), ("U3", "10")),
"ETH_RXD1": _net("ETH_RXD1", ("U19", "2"), ("U3", "11")),
"ETH_TXD0": _net("ETH_TXD0", ("U19", "3"), ("U3", "12")),
"ETH_TXD1": _net("ETH_TXD1", ("U19", "4"), ("U3", "13")),
"ETH_TXEN": _net("ETH_TXEN", ("U19", "5"), ("U3", "14")),
},
)
segs = []
y = 0.0
for net in ("ETH_RXD0", "ETH_RXD1", "ETH_TXD0", "ETH_TXD1", "ETH_TXEN"):
assert single_ended_eth_mac(net)
segs.append(LayoutSegment(start=(0, y), end=(40, y), width=0.15, layer="F.Cu", net=net))
y += 0.5
layout = LayoutGraph(segments=segs, stackup=_stack())
findings = check_af_traces(graph, {}, layout)
assert not any(f.rule_id == "PE-AF-002" for f in findings)
rmii = [
f for f in findings
if (f.finding or "").startswith("RMII certified —")
or (f.finding or "").startswith("RMII not certified —")
]
assert len(rmii) == 1
assert rmii[0].rule_id == "PE-AF-003"
assert "AN-133" in rmii[0].finding or "impedance" in rmii[0].finding.lower()
assert "mancanza" not in rmii[0].finding
assert "Pista ad alta" not in rmii[0].finding
assert "tr" not in rmii[0].finding.lower() or "because" in rmii[0].finding
def test_usb_d_plus_skips_pe_af_002_italian_tr_f():
"""USB_D+ must not get PE-AF-002 tr/f; protocol L2 owns the 90 Ω cert line."""
graph = DesignGraph(
components={
"J2": Component(
reference="J2", value="USB_C", footprint="USB_C",
component_type=ComponentType.CONNECTOR,
pins={"A6": "USB_D+", "A7": "USB_D-"},
),
"U11": _ic("U11", "USB_PHY", {"1": "USB_D+", "2": "USB_D-"}),
},
nets={
"USB_D+": _net("USB_D+", ("J2", "A6"), ("U11", "1")),
"USB_D-": _net("USB_D-", ("J2", "A7"), ("U11", "2")),
},
)
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(40, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 0.2), end=(40, 0.2), width=0.2, layer="F.Cu", net="USB_D-"),
],
stackup=_stack(),
)
findings = check_af_traces(graph, {}, layout)
assert not any(f.rule_id == "PE-AF-002" for f in findings)
assert not any("mancanza" in (f.finding or "") for f in findings)
assert not any("Pista ad alta" in (f.finding or "") for f in findings)
from backend.periscopex.protocol_l2 import certify_l2
from backend.periscopex.protocol_recognize import recognize_physical_buses
_l2, cert = certify_l2(
graph, recognize_physical_buses(graph), layout=layout, impedance_nets=None,
)
usb = [
f for f in cert
if (f.finding or "").startswith("USB certified —")
or (f.finding or "").startswith("USB not certified —")
]
assert len(usb) == 1
assert "90" in usb[0].finding
assert "mancanza" not in usb[0].finding
+32 -6
View File
@@ -73,7 +73,8 @@ def test_reject_typical_as_standard_still_holds():
parse_catalog(raw)
def test_usb2_and_usb_c_unknown_with_usbif_needed_document_no_90ohm():
def test_usb2_and_usb_c_cited_90_and_unknown_with_usbif_needed_document():
"""HS/Type-C pack cited 90 Ω (cable + PCB RECOMMENDED); bare differential_impedance stays UNKNOWN."""
cat = load_catalog()
logical = {p.id for p in cat.logical_protocols}
assert {"usb2-ls", "usb2-fs", "usb2-hs", "usb-c", "usb3-x", "usb4"} <= logical
@@ -84,11 +85,14 @@ def test_usb2_and_usb_c_unknown_with_usbif_needed_document_no_90ohm():
or p.id.startswith("usb-")
]
assert usb_ifaces
z_blob = json.dumps([
c.model_dump() for p in usb_ifaces for c in p.constraints
if c.parameter == "differential_impedance"
])
assert "90" not in z_blob
cited_90 = [
c for p in usb_ifaces for c in p.constraints
if c.parameter in {"cable_differential_impedance", "pcb_trace_nominal_zdiff"}
and c.value_kind == "NUMERIC"
]
assert cited_90
assert all(c.value == 90.0 for c in cited_90)
assert all(c.source and c.source.document for c in cited_90)
for p in usb_ifaces:
for c in p.constraints:
if c.parameter == "differential_impedance":
@@ -207,6 +211,7 @@ def test_mini_hdmi_is_connector_not_protocol_id():
def test_ethernet_hdmi_skeletons_unknown_needed_document():
"""SGMII is Cisco-packed (NUMERIC data_rate 1.25); other lids stay incomplete UNKNOWN."""
cat = load_catalog()
for lid in (
"2.5gbase-t", "5gbase-t",
@@ -217,6 +222,27 @@ def test_ethernet_hdmi_skeletons_unknown_needed_document():
assert ifaces, lid
assert all(p.incomplete for p in ifaces)
for p in ifaces:
if lid == "sgmii":
by = {c.parameter: c for c in p.constraints}
dr = by["data_rate"]
assert dr.value_kind == "NUMERIC"
assert dr.value == 1.25
assert dr.unit == "Gbaud"
assert dr.source and dr.source.document
assert dr.source_type == "VENDOR"
assert "SGMII" in (dr.source.document or "") or "GMII" in (dr.source.document or "")
for c in p.constraints:
if c.value_kind in {"UNKNOWN", "MISSING_SOURCE"}:
assert c.needed_document
nd = c.needed_document or ""
assert (
"IEEE" in nd
or "PHY" in nd
or "SGMII" in nd
or "GMII" in nd
or "Cisco" in nd
)
continue
for c in p.constraints:
if lid.startswith("hdmi") and (
c.parameter == "cec" or c.parameter.startswith("cec_")
+5 -3
View File
@@ -146,10 +146,12 @@ def test_empty_graph_keeps_no_protocol_message():
assert "ohm" not in blob.lower()
def test_report_section_lists_usb_without_z():
def test_report_section_lists_usb_with_cited_90():
"""USB report cites packed 90 Ω and emits not_certified when Z cannot be calculated."""
sec = protocol_section_for_graph(_usb_graph())
assert sec.recognized_instances
blob = json.dumps(sec.model_dump())
assert "90" not in blob
assert "Zdiff" not in blob
assert "Cited 90" in blob
assert "not_certified" in blob
assert "Universal Serial Bus Specification" in blob
assert all(row.get("recognition_status") != "FAIL" for row in sec.recognized_instances)