Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd03607ea | ||
|
|
78a7da2dbf | ||
|
|
5c87d706c2 |
@@ -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'll email you when it'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,30 +126,79 @@ 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 _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] = []
|
||||
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([
|
||||
comp.mpn or "", comp.value or "", comp.footprint or "",
|
||||
comp.component_subtype or "",
|
||||
])
|
||||
row = (graph.bom_fields or {}).get(ref) or {}
|
||||
for key in ("description", "Description", "value", "Value"):
|
||||
if row.get(key):
|
||||
bits.append(str(row.get(key)))
|
||||
return " ".join(bits)
|
||||
|
||||
|
||||
def _eth_speed(graph: DesignGraph, nets: tuple[str, ...]) -> str | None:
|
||||
"""10/100 or gbe from the PHY/jack actually on these nets. Else None."""
|
||||
bits: list[str] = []
|
||||
for net in nets:
|
||||
for ref in refs_on_matched_net(graph, net):
|
||||
comp = graph.components.get(ref)
|
||||
if comp is None:
|
||||
continue
|
||||
bits.extend([
|
||||
comp.mpn or "", comp.value or "", comp.footprint or "",
|
||||
comp.component_subtype or "",
|
||||
])
|
||||
row = (graph.bom_fields or {}).get(ref) or {}
|
||||
for key in ("description", "Description", "value", "Value"):
|
||||
if row.get(key):
|
||||
bits.append(str(row.get(key)))
|
||||
text = " ".join(bits)
|
||||
"""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)
|
||||
|
||||
@@ -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 / we’ll 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, MAC–PHY pack, USB/RMII cert
|
||||
|
||||
Combines the iPad top-bar shell (light default, Save as Markdown), the MAC–PHY 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.
|
||||
|
||||
@@ -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'll email you when it's ready.
|
||||
Stages update below as the run proceeds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user