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
+154
View File
@@ -0,0 +1,154 @@
# Piano di implementazione: analisi AF della traccia (v1)
DRC = KiCad. Periscope = analisi. **Stesura ora = Python.** Niente rustup, Auth/JWT, OpenEMS, FEM 3D, field solver 2.5D. Pad ≠ via ≠ track ≠ zone. Non si inventano Z, I, mm, εr, tr, f.
Michele 2026-09-22: piano ok con correzioni sotto. SI/HF 2.62 e `PE-AF-001` restano **additivi**. ImpedenceFinder vendored (`vendor/impedancefinder`) non si riscrive.
Repo: `~/Development/periscope`. Copia di questo file anche in `docs/piano-af-analisi.md`.
---
## Paletti obbligatori (Michele)
1. **Z target solo da datasheet** (`z0_ohm` / `zdiff_ohm` / finestra `z_min``z_max` in `layout_rules`). Manca → **skip del confronto Z**, finding visibile INSUFFICIENT, **niente 50/90 Ω**.
2. **Coppia differenziale: i due membri insieme** (Zdiff, accoppiamento intra-coppia, lunghezze/skew). Mai una traccia sola se esiste il partner `_P/_N` o `+/-` sulla board.
3. **Simulazione numerica sì, OpenEMS no.** Niente FEM 3D né solver di campo 2.5D della spec. Metodo contenuto: sezioni da ImpedenceFinder Z0(x) → RLGC lossless per sezione → cascade ABCD → S11/S21 **solo se** Z_ref (= target datasheet) è FACT.
4. **C++ vs Rust:** valutati in §15. Implementazione v1 in Python. Niente rustup.
5. **Skip AF non silenziosi.** In report: *«Pista ad alta frequenza non controllata per mancanza di …»* (tr, f, εr, stackup, Z target datasheet, span via). Finding **INSUFFICIENT / REVIEW**. Non INFO con Z/I inventati. **Se il trigger è falso** (calcolato con FACT e `l` sotto soglia) → **niente finding** AF su quella net/coppia.
---
## 1. Perimetro vs spec
La spec (`specifica-af.md`) descrive Gerber/ODB++, field solver 2.5D, S/TDR completi. Periscope esamina `.kicad_pcb` + grafo + libreria.
| Spec | v1 Periscope |
| :--- | :--- |
| Parser Gerber/ODB++ | No. KiCad `LayoutGraph`. |
| Trigger λ/10 e tr/(6 tpd) | Sì, FACT only. |
| Gomiti, taper, 3W, piano 3H | Sì, solo net/coppie **triggerate**. Non DRC. |
| Via stub / L_via | Sì se drill + **span layer** + h. Altrimenti PE-AF-002 span via. |
| Field solver 2.5D / OpenEMS | **Fuori.** |
| RLGC + S-param | Cascade sezioni lossless + Z0 ImpedenceFinder. S11/S21 se Z_ref datasheet. |
| TDR IFFT | Fuori. Profilo Z0(x) non si chiama TDR. |
| Wheeler allegato B | Non usato. Hammerstad/Cohn del vendor. |
`run_pcb_checks` (SI/HF) **invariato**.
---
## 2. Trigger
Candidati: net con rame **traccia**, non power/GND, non `skip_si_net` (I2C/GPIO/EN/CC/strap), non XTAL. Coppia = ununità: `l = max(l_p, l_n)`; se un membro è candidato, lo è la coppia.
Costanti: c = 2.99792458×10⁸ m/s. `tpd = √εr_eff / c`. `λ = c / (f √εr_eff)`.
- λ/10 se `f` FACT oppure `f = 0.35/tr` con `tr` FACT, e εr_eff da stackup+geometria (ImpedenceFinder).
- Rise time: `l ≥ tr / (6 tpd)` con `tr` FACT (non la forma tr·vp/2).
**Trigger vero** → analisi discontinuità / Z / cascade / via.
**Trigger falso** (entrambe le soglie calcolate e `l` sotto) → silenzio AF.
**Trigger non calcolabile** su un candidato (manca tr **e** f, o manca stackup/εr) → **PE-AF-002 visibile**, testo italiano con la lista di ciò che manca. Non è “trigger falso”.
---
## 3. Skip visibili (non silenziosi)
| Manca | Testo (es.) | Quando |
| :--- | :--- | :--- |
| stackup / εr | mancanza di stackup, εr | candidato, trigger non valutabile |
| tr, f | mancanza di tr, f | candidato, serve almeno uno |
| Z target datasheet | mancanza di Z target datasheet | trigger **vero**, niente confronto ±10% e niente S |
| span via | mancanza di span via | trigger vero **e** c’è un via sulla net/coppia senza layers |
Niente ohm, niente ampere, niente 1 ns USB. Status WARNING, class REVIEW, `evidence_status=INSUFFICIENT` (il clamp del motore finding non deve promuovere RULE/ERROR).
`PE-AF-001` (AI Z0 senza stackup) resta; è già INSUFFICIENT visibile.
---
## 4. Coppia differenziale
Partner: ImpedenceFinder + `partner_net`. Un finding per coppia, non due cloni. Zdiff da `diff_microstrip_z0` / `diff_stripline_z0`. Intra-coppia: lunghezze, non regola 3W (il mate non è aggressore). 3W solo vs **altre** net.
---
## 5. Simulazione numerica (non OpenEMS)
Per net/coppia triggerata con campioni Z0:
1. Sezioni di lunghezza campionata, Z0 ImpedenceFinder (flag `plane_broken` esclusi dal voto ohm).
2. Lossless: `L' = Z0 · tpd`, `C' = tpd / Z0` per metro.
3. ABCD di cascata; S11/S21 rispetto a **Z_ref = target datasheet**.
4. Confronto ΔZ0 ±10% vs stessa finestra datasheet. Duplicato `PE-SI-002`: non secondo FAIL.
Senza Z_ref: PE-AF-002, cascade non pubblica S.
---
## 6. Dati
- Stackup KiCad già parsato (`epsilon_r`, thickness; mai 1 oz default).
- `LayoutVia.layers` da `(layers "F.Cu" "B.Cu")`. Size anulare ≠ drill ≠ track.
- `tr`/`f` da `layout_rules` del driver sulla net (`rise_time`, `tr_ns`, `f_hz`, …) e `net_class` espanso come SI.
---
## 7. Finding PE-*
| ID | Ruolo |
| :--- | :--- |
| PE-AF-001 | AI Z0 senza evidenza (esistente) |
| PE-AF-002 | Skip visibile “non controllata per mancanza di …” |
| PE-AF-020 | Gomito ~90° |
| PE-AF-021 | Gradino W / taper |
| PE-AF-030 | `plane_broken` |
| PE-AF-031 | Return 3H |
| PE-AF-032 | `plane_split_nearby` |
| PE-AF-040 | 3W vs aggressore (non il mate) |
| PE-AF-050 | Z0/Zdiff vs finestra datasheet + cascade in `calculation` |
| PE-AF-051 | CPWG/unknown: no ohm |
| PE-AF-060 | L_via se drill+h+span |
| PE-AF-061 | Via stub vs λ/20 |
---
## 8. Moduli e pipeline
`af_trigger.py`, `af_rlgc.py`, `af_trace_check.py`. Dopo `run_pcb_checks`, prima `af_ai`. Non dentro la lista 2.62. Stage SSE `af_trace`.
---
## 9. Test HubAudio
Path opzionale `~/Development/HubAudio/.../HubAudio.kicad_pcb`. USB senza tr FACT → PE-AF-002 (tr/f), zero 90 Ω. GPIO → niente AF. Trigger sintetico corto con tr FACT → zero finding. Lunga + target datasheet → PE-AF-050. Coppia: un finding, non D+ solo. SI stub `PE-SI-007` resta.
---
## 10. Fasi v1 (questa implementazione)
Parser via layers; trigger; skip visibili; coppia; Z/cascade se target; gomiti/width; 3W; via se span; pipeline; changelog; pytest.
---
## 11. Fuori scope
OpenEMS, FEM, 2.5D, TDR IFFT, NEXT/FEXT in volt, 5W default, auto-miter sul PCB, Gerber/ODB++, Auth/JWT, rustup.
---
## 15. C++ vs Rust (valutazione; stesura Python)
Criteri (come `docs/rust-criteri.md`): geometria pad≠via≠track≠zone al confine; memoria/ownership su HubAudio; hot path profilato; determinismo; FFI grosso (struct in → struct out).
| Criterio | AF v1 (trigger + Shapely + cascade) | C++ | Rust |
| :--- | :--- | :--- | :--- |
| Geometria | Tipi già distinti in Python; Shapely nel vendor. Un port deve **non** fondere via/pad. | Possibile (CGAL/Clipper), confine pericoloso se si “semplifica” AABB. | Stesso rischio; ownership aiuta i buffer, non la semantica PCB. |
| Memoria | HubAudio è un `.kicad_pcb` grande; il costo oggi è parse + zone Shapely, non la cascade (O(sezioni)). Nessuna misura che Python non basti. | Arena/SoA utili dopo profilo. | Stesso, dopo misura. |
| Hot path | Non profilato. Candidati *futuri* restano parser PCB e point-in-poly, non PE-AF isolato. | Solo se il profilo lo dice. | Idem. |
| Determinismo | Cascade ABCD e Z0 chiusi sono deterministici. | ok | ok (niente HashMap nel testo finding). |
| FFI grosso | Un rewrite ora = due runtime + duplicare LayoutGraph. **Complicherebbe** (Michele). | pybind/nanobind, grosso se si passa tutta la board. | PyO3, stesso costo. ImpedenceFinder resterebbe Python/Shapely. |
**Decisione:** v1 Python. Nessun pezzo scelto per C++ o Rust. Michele può volere Rust un giorno; sequenza obbligatoria resta: Python corretto → misura HubAudio → profilo → scelta esplicita. ImpedenceFinder non si riscrive “per Rust”. OpenEMS non è candidato a nessun linguaggio in Periscope.
@@ -492,6 +492,7 @@ class LayoutVia(BaseModel):
y: float y: float
net: str = "" net: str = ""
drill: float | None = None drill: float | None = None
layers: tuple[str, ...] = ()
class LayoutDielectric(BaseModel): class LayoutDielectric(BaseModel):
@@ -0,0 +1,76 @@
"""Lossless RLGC sections + ABCD cascade. Not a field solver. Not OpenEMS."""
from __future__ import annotations
import cmath
import math
from dataclasses import dataclass
from backend.periscopex.af_trigger import C_MPS
@dataclass(frozen=True)
class CascadeResult:
s11: complex
s21: complex
z_ref_ohm: float
n_sections: int
def _abcd_line(z0: float, tpd_s_per_m: float, length_m: float, omega: float) -> tuple[complex, complex, complex, complex]:
beta = omega * tpd_s_per_m
bl = beta * length_m
j = 1j
a = cmath.cos(bl)
b = j * z0 * cmath.sin(bl)
c = (j / z0) * cmath.sin(bl) if z0 else 0j
d = a
return a, b, c, d
def _mul(
p: tuple[complex, complex, complex, complex],
q: tuple[complex, complex, complex, complex],
) -> tuple[complex, complex, complex, complex]:
a1, b1, c1, d1 = p
a2, b2, c2, d2 = q
return (
a1 * a2 + b1 * c2,
a1 * b2 + b1 * d2,
c1 * a2 + d1 * c2,
c1 * b2 + d1 * d2,
)
def cascade_sparam(
z0_ohms: list[float],
length_m: list[float],
er_eff: float,
f_hz: float,
z_ref_ohm: float,
) -> CascadeResult | None:
"""Uniform-per-section lossless cascade. Z_ref must be a datasheet FACT."""
if (
not z0_ohms or len(z0_ohms) != len(length_m)
or er_eff <= 0 or f_hz <= 0 or z_ref_ohm <= 0
):
return None
tpd = math.sqrt(er_eff) / C_MPS
omega = 2.0 * math.pi * f_hz
abcd = (1 + 0j, 0j, 0j, 1 + 0j)
n = 0
for z0, ell in zip(z0_ohms, length_m, strict=True):
if z0 <= 0 or ell <= 0:
continue
abcd = _mul(abcd, _abcd_line(z0, tpd, ell, omega))
n += 1
if n == 0:
return None
a, b, c, d = abcd
zr = z_ref_ohm
denom = a + b / zr + c * zr + d
if denom == 0:
return None
s11 = (b - c * zr * zr) / denom
s21 = 2.0 / denom
return CascadeResult(s11=s11, s21=s21, z_ref_ohm=zr, n_sections=n)
@@ -0,0 +1,593 @@
"""Triggered AF trace analysis. Additive. Not DRC. Not OpenEMS.
Skip without FACT is a visible PE-AF-002, never invented 50/90 Ω.
Differential pairs are one unit. Trigger false → no finding.
"""
from __future__ import annotations
import logging
import math
from collections import defaultdict
from backend.periscopex.af_rlgc import cascade_sparam
from backend.periscopex.af_trigger import (
AfUnit,
TriggerResult,
dielectric_height_mm,
evaluate_trigger,
iter_af_units,
z_window_for_unit,
)
from backend.periscopex.finding_engine import complete_findings
from backend.periscopex.hf_line_check import COORD_QUANT_MM, ENDPOINT_SNAP_MM
from backend.periscopex.impedance import GeometryError
from backend.periscopex.impedance_traces import analyze_specified_nets
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.si_check import partner_net
log = logging.getLogger(__name__)
SOURCE = "af_trace_check"
_RIGHT_DOT = 0.087 # ~5° from 90°
_WIDTH_STEP_MM = 0.05
_PARALLEL_MIN_MM = 5.0
def check_af_traces(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None = None,
existing: list[Finding] | None = None,
) -> list[Finding]:
if layout is None or not layout.segments:
return []
out: list[Finding] = []
seen_si_z = {
f.net for f in (existing or [])
if f.rule_id == "PE-SI-002" and f.net
}
zrows = _z_rows(impedance_nets)
for unit in iter_af_units(layout, graph):
trig = evaluate_trigger(graph, constraints_map, layout, unit)
if trig.missing and not trig.af:
out.append(_skip(unit, trig.missing))
continue
if not trig.af:
continue
out.extend(_analyze_triggered(graph, constraints_map, layout, trig, zrows, seen_si_z))
complete_findings(out)
return out
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
raw = impedance_nets
if isinstance(impedance_nets, dict):
raw = list(impedance_nets.get("nets") or [])
return [r for r in (raw or []) if isinstance(r, dict) and not r.get("error")]
def _label(unit: AfUnit) -> str:
return " / ".join(unit.nets)
def _skip(unit: AfUnit, missing: tuple[str, ...], extra: str = "") -> Finding:
miss = ", ".join(missing)
text = f"Pista ad alta frequenza non controllata per mancanza di {miss}."
if extra:
text = f"{text} {extra}".strip()
rec = (
f"Fornire {miss} dal datasheet o dallo stackup KiCad. "
"Non si assume 50 Ω o 90 Ω."
)
return Finding(
designator="layout",
mpn="",
aspect="si",
finding=text,
facts=f"nets={_label(unit)}; missing={miss}; l={unit.length_mm:.3f} mm.",
requirement="AF checks need FACT tr or f, stackup/εr, and datasheet Z for Z0.",
inference="INSUFFICIENT — skip, not an invented ohm or ampere.",
why="An HF candidate without evidence is reported, not guessed.",
status="WARNING",
recommendation=rec,
action=rec,
source=SOURCE,
rule_id="PE-AF-002",
finding_class="REVIEW",
provenance="TYPICAL",
evidence_status="INSUFFICIENT",
net=unit.nets[0],
pins=[],
)
def _finding(
*,
rule_id: str,
unit: AfUnit,
finding: str,
facts: str,
requirement: str,
rec: str,
status: str = "WARNING",
cls: str = "RISK",
evidence: str = "SUFFICIENT",
calculation: str = "",
) -> Finding:
return Finding(
designator="layout",
mpn="",
aspect="si",
finding=finding,
facts=facts,
requirement=requirement,
inference="Triggered AF net: l ≥ λ/10 or l ≥ tr/(6 tpd).",
why=requirement,
status=status, # type: ignore[arg-type]
recommendation=rec,
action=rec,
source=SOURCE,
rule_id=rule_id,
finding_class=cls, # type: ignore[arg-type]
provenance="RECOMMENDED" if cls != "REVIEW" else "TYPICAL",
evidence_status=evidence, # type: ignore[arg-type]
net=unit.nets[0],
pins=[],
calculation=calculation,
)
def _analyze_triggered(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph,
trig: TriggerResult,
zrows: list[dict],
seen_si_z: set[str],
) -> list[Finding]:
unit = trig.unit
out: list[Finding] = []
out.extend(_corner_findings(layout, unit))
out.extend(_width_findings(layout, unit))
out.extend(_coupling_findings(layout, unit))
samples = _samples(layout, unit)
out.extend(_plane_findings(layout, unit, samples, trig))
win = z_window_for_unit(graph, constraints_map, unit)
if win is None:
out.append(_skip(unit, ("Z target datasheet",)))
else:
already = any(n in seen_si_z or any(kicad_nets_match(n, s) for s in seen_si_z) for n in unit.nets)
if not already:
zf = _z_finding(unit, samples, win, trig)
if zf is not None:
out.append(zf)
out.extend(_via_findings(layout, unit, trig))
return out
def _samples(layout: LayoutGraph, unit: AfUnit) -> list[dict]:
if layout.stackup is None:
return []
try:
rows = analyze_specified_nets(layout, list(unit.nets), 1.0)
except GeometryError:
return []
except Exception:
log.exception("ImpedenceFinder walk failed for %s", unit.nets)
return []
return [r for r in rows if isinstance(r, dict)]
def _z_vals(samples: list[dict], diff: bool) -> list[float]:
keys = ("zdiff_avg_ohms", "zdiff_ohms", "z0_avg_ohms", "z0_min_ohms") if diff else (
"z0_avg_ohms", "z0_min_ohms", "z0_max_ohms",
)
out: list[float] = []
for row in samples:
flags = str(row.get("flags") or "")
if "plane_broken" in flags or "topology_not_supported" in flags:
continue
for k in keys:
v = row.get(k)
if isinstance(v, (int, float)) and v > 0:
out.append(float(v))
break
return out
def _z_finding(
unit: AfUnit,
samples: list[dict],
window: tuple[float, float],
trig: TriggerResult,
) -> Finding | None:
diff = len(unit.nets) == 2
if any("topology_not_supported" in str(r.get("flags") or "") for r in samples) and not _z_vals(samples, diff):
rec = "CPWG is not solved by ImpedenceFinder; no ohm invented."
return _finding(
rule_id="PE-AF-051", unit=unit,
finding=f"Unverified: topology not supported on {_label(unit)} — no Z0 number.",
facts=f"flags from ImpedenceFinder; nets={_label(unit)}.",
requirement="Closed-form Z0 only for microstrip/stripline.",
rec=rec, cls="REVIEW", evidence="INSUFFICIENT",
)
vals = _z_vals(samples, diff)
if not vals:
return None
lo, hi = window
zmin, zmax = min(vals), max(vals)
zavg = sum(vals) / len(vals)
calc = ""
if trig.f_hz and trig.er_eff:
z0s = [zavg]
ell = [unit.length_mm / 1000.0]
casc = cascade_sparam(z0s, ell, trig.er_eff, trig.f_hz, (lo + hi) / 2.0)
if casc is not None:
calc = (
f"cascade lossless n={casc.n_sections} "
f"S11={casc.s11.real:.4f}{casc.s11.imag:+.4f}j "
f"S21={casc.s21.real:.4f}{casc.s21.imag:+.4f}j "
f"Z_ref={casc.z_ref_ohm:g} Ω (datasheet)."
)
kind = "Zdiff" if diff else "Z0"
facts = (
f"{kind} avg={zavg:.3f} min={zmin:.3f} max={zmax:.3f} Ω "
f"(ImpedenceFinder); window=[{lo:g}, {hi:g}] Ω; nets={_label(unit)}."
)
rec = f"Adjust geometry so {kind} stays in the datasheet window [{lo:g}, {hi:g}] Ω."
if zmin < lo or zmax > hi:
return _finding(
rule_id="PE-AF-050", unit=unit,
finding=f"FAIL: {kind} {zavg:.2f} Ω outside datasheet [{lo:g}, {hi:g}] Ω on {_label(unit)}.",
facts=facts, requirement="ΔZ vs datasheet target ± window.", rec=rec,
calculation=calc,
)
return None
def _plane_findings(
layout: LayoutGraph, unit: AfUnit, samples: list[dict], trig: TriggerResult,
) -> list[Finding]:
out: list[Finding] = []
broken = any("plane_broken" in str(r.get("flags") or "") for r in samples)
split = any("plane_split_nearby" in str(r.get("flags") or "") for r in samples)
if broken:
rec = "Restore continuous reference copper under the pair/net."
out.append(_finding(
rule_id="PE-AF-030", unit=unit,
finding=f"Reference plane broken under {_label(unit)} (ImpedenceFinder).",
facts=f"plane_broken on sampled walk; nets={_label(unit)}.",
requirement="AF return path must exist under the track.", rec=rec,
))
if split:
rec = "Move the track away from the plane split or close the void."
out.append(_finding(
rule_id="PE-AF-032", unit=unit,
finding=f"Reference-plane split nearby on {_label(unit)}.",
facts=f"plane_split_nearby; nets={_label(unit)}.",
requirement="3W proximity to a plane edge is REVIEW.", rec=rec, cls="REVIEW",
))
stack = layout.stackup
if stack is not None:
for net in unit.nets:
layer, _w = _any_layer_width(layout, net)
if not layer:
continue
h = dielectric_height_mm(stack, layer)
if h is None:
continue
if not _copper_within_3h(layout, net, layer, h):
rec = "Provide a reference pour within 3H of the track."
out.append(_finding(
rule_id="PE-AF-031", unit=unit,
finding=f"No reference copper within 3H under {net}.",
facts=f"H={h:g} mm; 3H={3 * h:g} mm; net={net}.",
requirement="Return path within 3H (not KiCad clearance).", rec=rec,
))
break
return out
def _any_layer_width(layout: LayoutGraph, net: str) -> tuple[str, float]:
for s in layout.segments:
if s.net and kicad_nets_match(s.net, net) and s.layer and s.width > 0:
return s.layer, s.width
return "", 0.0
def _copper_within_3h(layout: LayoutGraph, net: str, layer: str, h: float) -> bool:
from backend.periscopex.placement_check import _in_poly
stack = layout.stackup
if stack is None:
return True
try:
idx = stack.copper_layers.index(layer)
except ValueError:
return True
ref = None
if idx + 1 < len(stack.copper_layers):
ref = stack.copper_layers[idx + 1]
elif idx > 0:
ref = stack.copper_layers[idx - 1]
if not ref:
return True
pts: list[tuple[float, float]] = []
for s in layout.segments:
if s.net and kicad_nets_match(s.net, net):
pts.append(s.start)
pts.append(s.end)
if not pts:
return True
margin = 3.0 * h
for z in layout.zones:
if z.keepout or z.layer != ref:
continue
for ring in z.outlines:
if len(ring) < 3:
continue
for x, y in pts:
if _in_poly(x, y, ring):
return True
for px, py in ring:
if math.hypot(px - x, py - y) <= margin:
return True
return not any(z.layer == ref and not z.keepout for z in layout.zones)
def _qxy(x: float, y: float) -> tuple[float, float]:
q = COORD_QUANT_MM
return (round(x / q) * q, round(y / q) * q)
def _track_edges(layout: LayoutGraph, net: str) -> list[tuple[tuple[float, float], tuple[float, float], float, str]]:
edges = []
for s in layout.segments:
if not s.net or not kicad_nets_match(s.net, net):
continue
a, b = _qxy(*s.start), _qxy(*s.end)
if a == b:
continue
edges.append((a, b, s.width, s.layer))
return edges
def _corner_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
out: list[Finding] = []
for net in unit.nets:
nbrs: dict[tuple[float, float], list[tuple[tuple[float, float], float, str]]] = defaultdict(list)
for a, b, w, ly in _track_edges(layout, net):
nbrs[a].append((b, w, ly))
nbrs[b].append((a, w, ly))
hit = False
for node, friends in nbrs.items():
if len(friends) != 2:
continue
(p1, w1, ly1), (p2, w2, ly2) = friends
if ly1 != ly2:
continue
v1 = (p1[0] - node[0], p1[1] - node[1])
v2 = (p2[0] - node[0], p2[1] - node[1])
n1 = math.hypot(*v1)
n2 = math.hypot(*v2)
if n1 < ENDPOINT_SNAP_MM or n2 < ENDPOINT_SNAP_MM:
continue
dot = (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)
if abs(dot) <= _RIGHT_DOT:
hit = True
break
if hit:
rec = "Replace the 90° corner with a 45° miter or an arc R ≥ 3W."
out.append(_finding(
rule_id="PE-AF-020", unit=unit,
finding=f"Right-angle track corner on {net}.",
facts=f"two tracks meet at ~90°; net={net}; pair={_label(unit)}.",
requirement="AF corners should be mitered or curved (not DRC clearance).",
rec=rec,
))
return out
def _width_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
out: list[Finding] = []
for net in unit.nets:
nbrs: dict[tuple[float, float], list[tuple[tuple[float, float], float, str]]] = defaultdict(list)
for a, b, w, ly in _track_edges(layout, net):
nbrs[a].append((b, w, ly))
nbrs[b].append((a, w, ly))
for node, friends in nbrs.items():
if len(friends) != 2:
continue
(_p1, w1, ly1), (_p2, w2, ly2) = friends
if ly1 != ly2 or abs(w1 - w2) < _WIDTH_STEP_MM:
continue
rec = "Insert a taper L ≥ 2|W1W2|; pads are not the track width."
out.append(_finding(
rule_id="PE-AF-021", unit=unit,
finding=f"Abrupt width step {w1:.3f}{w2:.3f} mm on {net}.",
facts=f"W1={w1:g} mm W2={w2:g} mm at {_fmt(node)}; net={net}.",
requirement="Width steps on AF tracks need a taper (track ≠ pad).",
rec=rec,
))
break
return out
def _fmt(pt: tuple[float, float]) -> str:
return f"({pt[0]:g},{pt[1]:g})"
def _coupling_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
mates = set(unit.nets)
out: list[Finding] = []
for net in unit.nets:
segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)]
if not segs:
continue
w = segs[0].width or 0.0
if w <= 0:
continue
for other in {s.net for s in layout.segments if s.net}:
if other in mates or any(kicad_nets_match(other, m) for m in mates):
continue
if partner_net(net) and kicad_nets_match(other, partner_net(net) or ""):
continue
ov, gap = _parallel_overlap_gap(layout, net, other)
if ov < _PARALLEL_MIN_MM or gap is None:
continue
# 3W is centre-to-centre; gap here is edge-to-edge.
c2c = gap + w / 2.0 + _mean_width(layout, other) / 2.0
if c2c < 3.0 * w:
rec = "Increase spacing to S ≥ 3W versus the aggressor (not KiCad clearance)."
out.append(_finding(
rule_id="PE-AF-040", unit=unit,
finding=f"Spacing to {other} is {c2c:.3f} mm (< 3W={3 * w:.3f} mm) on {net}.",
facts=f"overlap={ov:.2f} mm; edge_gap={gap:.3f} mm; W={w:g} mm.",
requirement="3W coupling proxy on triggered AF nets; pair mate excluded.",
rec=rec, cls="REVIEW",
))
return out
return out
def _mean_width(layout: LayoutGraph, net: str) -> float:
ws = [s.width for s in layout.segments if s.net and kicad_nets_match(s.net, net) and s.width > 0]
return sum(ws) / len(ws) if ws else 0.0
def _parallel_overlap_gap(layout: LayoutGraph, a: str, b: str) -> tuple[float, float | None]:
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a)]
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b)]
best_gap = None
overlap = 0.0
for x in sa:
for y in sb:
if x.layer != y.layer:
continue
ov = _axis_overlap(x.start, x.end, y.start, y.end)
overlap = max(overlap, ov)
g = _segment_gap(x.start, x.end, x.width, y.start, y.end, y.width)
if g is not None and (best_gap is None or g < best_gap):
best_gap = g
return overlap, best_gap
def _axis_overlap(
a0: tuple[float, float], a1: tuple[float, float],
b0: tuple[float, float], b1: tuple[float, float],
) -> float:
if abs(a0[1] - a1[1]) < COORD_QUANT_MM and abs(b0[1] - b1[1]) < COORD_QUANT_MM:
return _interval_overlap(a0[0], a1[0], b0[0], b1[0])
if abs(a0[0] - a1[0]) < COORD_QUANT_MM and abs(b0[0] - b1[0]) < COORD_QUANT_MM:
return _interval_overlap(a0[1], a1[1], b0[1], b1[1])
return 0.0
def _interval_overlap(a: float, b: float, c: float, d: float) -> float:
lo = max(min(a, b), min(c, d))
hi = min(max(a, b), max(c, d))
return max(0.0, hi - lo)
def _segment_gap(
a0: tuple[float, float], a1: tuple[float, float], wa: float,
b0: tuple[float, float], b1: tuple[float, float], wb: float,
) -> float | None:
pts_a = (a0, a1)
pts_b = (b0, b1)
best = None
for p in pts_a:
for q in pts_b:
d = math.hypot(p[0] - q[0], p[1] - q[1]) - wa / 2.0 - wb / 2.0
if best is None or d < best:
best = d
return best
def _via_span_h(layout: LayoutGraph, via: LayoutVia) -> float | None:
stack = layout.stackup
if stack is None or len(via.layers) < 2:
return None
names = list(stack.copper_layers)
try:
i0 = names.index(via.layers[0])
i1 = names.index(via.layers[-1])
except ValueError:
return None
lo, hi = min(i0, i1), max(i0, i1)
h = 0.0
for i in range(lo, hi):
if i < len(stack.dielectrics):
h += stack.dielectrics[i].height_mm
return h if h > 0 else None
def _via_findings(layout: LayoutGraph, unit: AfUnit, trig: TriggerResult) -> list[Finding]:
out: list[Finding] = []
vias = [
v for v in layout.vias
if v.net and any(kicad_nets_match(v.net, n) for n in unit.nets)
]
if not vias:
return out
if any(len(v.layers) < 2 for v in vias):
out.append(_skip(unit, ("span via",)))
return out
for v in vias:
if v.drill is None or v.drill <= 0:
continue
h = _via_span_h(layout, v)
if h is None:
continue
ratio = 4.0 * h / v.drill
if ratio <= 1.0:
continue
l_nh = 0.2 * h * (math.log(ratio) + 1.0)
rec = "Treat the via as an RF discontinuity; add a return via or shorten the barrel."
out.append(_finding(
rule_id="PE-AF-060", unit=unit,
finding=f"Via L≈{l_nh:.3f} nH on {_label(unit)} (drill={v.drill:g} mm, h={h:g} mm).",
facts=f"drill={v.drill:g} mm; h={h:g} mm; layers={v.layers}; L={l_nh:.4f} nH.",
requirement="Via is not a pad or a track; L_via from drill and span.",
rec=rec, cls="REVIEW",
))
tracks = {
s.layer for s in layout.segments
if s.net and any(kicad_nets_match(s.net, n) for n in unit.nets) and s.layer
}
stack = layout.stackup
if stack is None or len(v.layers) < 2 or not tracks:
continue
names = list(stack.copper_layers)
try:
used = [names.index(ly) for ly in tracks if ly in names]
span = [names.index(v.layers[0]), names.index(v.layers[-1])]
except ValueError:
continue
if not used:
continue
stub_idx = max(span) - max(used)
stub_lo = min(used) - min(span)
layers_stub = max(stub_idx, stub_lo, 0)
stub_h = 0.0
lo, hi = min(span), max(span)
unused = [i for i in range(lo, hi) if i < min(used) or i >= max(used)]
for i in unused:
if 0 <= i < len(stack.dielectrics):
stub_h += stack.dielectrics[i].height_mm
if stub_h <= 0 or trig.lambda_10_mm is None:
continue
lam = trig.lambda_10_mm * 10.0
if stub_h > lam / 20.0:
rec = "Back-drill or use a blind via; unused barrel is a λ/4 stub risk."
out.append(_finding(
rule_id="PE-AF-061", unit=unit,
finding=f"Via stub {stub_h:.3f} mm > λ/20 on {_label(unit)}.",
facts=f"L_stub={stub_h:g} mm; λ={lam:g} mm; unused_layers~{layers_stub}.",
requirement="Via stub > λ/20 on a triggered AF net.",
rec=rec,
))
return out
@@ -0,0 +1,329 @@
"""AF trigger: λ/10 and tr/(6 tpd) from FACT only. Pad/via/zone are not length."""
from __future__ import annotations
import math
from dataclasses import dataclass
from backend.vendor_path import ensure_impedancefinder
ensure_impedancefinder()
from impedancefinder import zsolver
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.models import ComponentType, DesignGraph, LayoutGraph, LayoutStackup, NetType
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.si_check import (
_bus_in_targets,
_expand_bus_token,
bus_class,
net_length_mm,
partner_net,
skip_si_net,
)
C_MPS = 2.99792458e8
# Geometry snap for width pick — not an SI millimetre limit.
_MIN_W_MM = 1e-6
@dataclass(frozen=True)
class AfUnit:
"""One net or a differential pair analyzed together."""
nets: tuple[str, ...]
length_mm: float
bus: str | None
@dataclass(frozen=True)
class TriggerResult:
unit: AfUnit
af: bool
missing: tuple[str, ...]
er_eff: float | None
tr_s: float | None
f_hz: float | None
lambda_10_mm: float | None
l_crit_mm: float | None
def pair_partner(layout: LayoutGraph, net: str) -> str | None:
p = partner_net(net)
if not p:
return None
names = {s.net for s in layout.segments if s.net}
for n in names:
if kicad_nets_match(p, n):
return n
return None
def is_candidate_net(net: str, graph: DesignGraph | None) -> bool:
if not net or skip_si_net(net):
return False
if graph is not None:
rec = graph.nets.get(net)
if rec is not None and rec.net_type in (NetType.POWER, NetType.GROUND):
return False
return bus_class(net) is not None
def iter_af_units(layout: LayoutGraph, graph: DesignGraph | None) -> list[AfUnit]:
routed = sorted({s.net for s in layout.segments if s.net and s.width > 0})
seen: set[str] = set()
out: list[AfUnit] = []
for net in routed:
if net in seen or not is_candidate_net(net, graph):
continue
partner = pair_partner(layout, net)
if partner and is_candidate_net(partner, graph):
nets = tuple(sorted((net, partner)))
for n in nets:
seen.add(n)
lp = net_length_mm(layout, nets[0])
ln = net_length_mm(layout, nets[1])
out.append(AfUnit(
nets=nets, length_mm=max(lp, ln),
bus=bus_class(nets[0]) or bus_class(nets[1]),
))
else:
seen.add(net)
out.append(AfUnit(
nets=(net,), length_mm=net_length_mm(layout, net),
bus=bus_class(net),
))
return out
def _num(v) -> float | None:
if v is None or isinstance(v, bool):
return None
try:
x = float(v)
except (TypeError, ValueError):
return None
return x if math.isfinite(x) else None
def _tr_from_rule(rule: dict) -> float | None:
s = _num(rule.get("tr_s"))
if s is not None and s > 0:
return s
ns = _num(rule.get("tr_ns")) or _num(rule.get("rise_time_ns")) or _num(rule.get("t_r_ns"))
if ns is not None and ns > 0:
return ns * 1e-9
ps = _num(rule.get("tr_ps"))
if ps is not None and ps > 0:
return ps * 1e-12
kind = str(rule.get("kind") or "").lower()
raw = _num(rule.get("tr")) or _num(rule.get("value"))
if kind in {"rise_time", "tr", "t_r"} and raw is not None and raw > 0:
if raw > 1e-3:
return raw * 1e-9
return raw
return None
def _f_from_rule(rule: dict) -> float | None:
hz = _num(rule.get("f_hz")) or _num(rule.get("f_max_hz")) or _num(rule.get("frequency_hz"))
if hz is not None and hz > 0:
return hz
mhz = _num(rule.get("f_mhz"))
if mhz is not None and mhz > 0:
return mhz * 1e6
ghz = _num(rule.get("f_ghz"))
if ghz is not None and ghz > 0:
return ghz * 1e9
kind = str(rule.get("kind") or "").lower()
raw = _num(rule.get("f")) or _num(rule.get("value"))
if kind in {"frequency", "f_max", "f_clk"} and raw is not None and raw > 0:
if raw < 1e4:
return raw * 1e6
return raw
return None
def tr_f_for_unit(graph: DesignGraph, constraints_map: dict, unit: AfUnit) -> tuple[float | None, float | None]:
tr_s: float | None = None
f_hz: float | None = None
for _ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
ic_nets = {n for n in (comp.pins.values()) if n}
on_ic = any(
any(kicad_nets_match(n, icn) for icn in ic_nets)
for n in unit.nets
)
if not on_ic:
continue
for rule in cons.layout_rules or []:
targets = _expand_bus_token(str(rule.get("net_class") or ""))
if targets and unit.bus and not _bus_in_targets(unit.bus, targets):
continue
t = _tr_from_rule(rule)
f = _f_from_rule(rule)
if t is not None:
tr_s = t
if f is not None:
f_hz = f
return tr_s, f_hz
def z_window_for_unit(graph: DesignGraph, constraints_map: dict, unit: AfUnit) -> tuple[float, float] | None:
from backend.periscopex.si_check import _z_window
for _ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
ic_nets = {n for n in (comp.pins.values()) if n}
on_ic = any(
any(kicad_nets_match(n, icn) for icn in ic_nets)
for n in unit.nets
)
if not on_ic:
continue
for rule in cons.layout_rules or []:
kind = str(rule.get("kind") or "")
if kind not in {"impedance", "zdiff", "z0", "si"}:
continue
if kind == "si" and str(rule.get("parameter") or "").lower() not in {"impedance", "zdiff", "z0"}:
continue
targets = _expand_bus_token(str(rule.get("net_class") or ""))
if targets and unit.bus and not _bus_in_targets(unit.bus, targets):
continue
win = _z_window(rule)
if win:
return win
return None
def _dominant_trace(layout: LayoutGraph, net: str) -> tuple[str, float] | None:
by_layer: dict[str, list[float]] = {}
for s in layout.segments:
if not s.net or not kicad_nets_match(s.net, net) or s.width <= _MIN_W_MM:
continue
layer = s.layer or ""
by_layer.setdefault(layer, []).append(s.width)
if not by_layer:
return None
layer = max(by_layer, key=lambda ly: len(by_layer[ly]))
widths = sorted(by_layer[layer])
w = widths[len(widths) // 2]
return layer, w
def _er_eff(layout: LayoutGraph, unit: AfUnit) -> tuple[float | None, list[str]]:
missing: list[str] = []
stack = layout.stackup
if stack is None or not stack.copper_layers or not stack.dielectrics:
return None, ["stackup", "εr"]
t = stack.copper_thickness_mm
if t is None or t <= 0:
return None, ["stackup"]
picked = None
for net in unit.nets:
picked = _dominant_trace(layout, net)
if picked:
break
if picked is None:
return None, ["εr"]
layer, w = picked
try:
idx = stack.copper_layers.index(layer)
except ValueError:
return None, ["εr"]
outer = idx == 0 or idx == len(stack.copper_layers) - 1
if outer:
diel_i = 0 if idx == 0 else len(stack.dielectrics) - 1
if diel_i < 0 or diel_i >= len(stack.dielectrics):
return None, ["εr"]
d = stack.dielectrics[diel_i]
if d.er <= 0 or d.height_mm <= 0:
return None, ["εr"]
z = zsolver.microstrip_z0(w, d.height_mm, d.er, t)
zair = zsolver.microstrip_z0(w, d.height_mm, 1.0, t)
if z <= 0 or zair <= 0:
return None, ["εr"]
return (zair / z) ** 2, missing
if idx <= 0 or idx >= len(stack.dielectrics):
return None, ["εr"]
d = stack.dielectrics[idx]
if d.er <= 0:
return None, ["εr"]
return d.er, missing
def evaluate_trigger(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph,
unit: AfUnit,
) -> TriggerResult:
missing: list[str] = []
er_eff, er_miss = _er_eff(layout, unit)
missing.extend(er_miss)
tr_s, f_hz = tr_f_for_unit(graph, constraints_map, unit)
if f_hz is None and tr_s is not None:
f_hz = 0.35 / tr_s
if tr_s is None:
missing.append("tr")
if f_hz is None and tr_s is None:
missing.append("f")
elif f_hz is None:
missing.append("f")
l_m = unit.length_mm / 1000.0
lam10 = None
lcrit = None
af = False
if er_eff is not None and er_eff > 0:
tpd = math.sqrt(er_eff) / C_MPS
if f_hz is not None and f_hz > 0:
lam = C_MPS / (f_hz * math.sqrt(er_eff))
lam10 = lam / 10.0 * 1000.0
if l_m >= lam / 10.0:
af = True
if tr_s is not None and tr_s > 0:
lcrit_m = tr_s / (6.0 * tpd)
lcrit = lcrit_m * 1000.0
if l_m >= lcrit_m:
af = True
trigger_ready = er_eff is not None and (tr_s is not None or f_hz is not None)
if trigger_ready:
miss_trig = tuple()
else:
miss_trig = tuple(dict.fromkeys(missing))
return TriggerResult(
unit=unit,
af=bool(af and trigger_ready),
missing=miss_trig,
er_eff=er_eff,
tr_s=tr_s,
f_hz=f_hz,
lambda_10_mm=lam10,
l_crit_mm=lcrit,
)
def dielectric_height_mm(stack: LayoutStackup, layer: str) -> float | None:
try:
idx = stack.copper_layers.index(layer)
except ValueError:
return None
if idx == 0:
d = stack.dielectrics[0] if stack.dielectrics else None
elif idx == len(stack.copper_layers) - 1:
d = stack.dielectrics[-1] if stack.dielectrics else None
else:
d = stack.dielectrics[idx] if idx < len(stack.dielectrics) else None
if d is None or d.height_mm <= 0:
return None
return d.height_mm
@@ -266,6 +266,30 @@ def _seed() -> None:
requirement="Antenna matching L/C reported when those parts exist on the feed.") requirement="Antenna matching L/C reported when those parts exist on the feed.")
_add("PE-PDN-001", "TYPICAL", "INFO", domain="pcb", _add("PE-PDN-001", "TYPICAL", "INFO", domain="pcb",
requirement="PDN Z(f) only with frequency-domain FACT; SI Z0 is not PDN.") requirement="PDN Z(f) only with frequency-domain FACT; SI Z0 is not PDN.")
_add("PE-AF-001", "TYPICAL", "INFO", domain="pcb",
requirement="AI Z0 flag needs stackup+track FACT — never 50/90 Ω.")
_add("PE-AF-002", "TYPICAL", "REVIEW", domain="pcb",
requirement="HF trace not checked: missing tr, f, εr, stackup, Z target, or via span.")
_add("PE-AF-020", "RECOMMENDED", "RISK", domain="pcb",
requirement="Right-angle track corner on an electrically long net.")
_add("PE-AF-021", "RECOMMENDED", "RISK", domain="pcb",
requirement="Abrupt track width step on an electrically long net.")
_add("PE-AF-030", "RECOMMENDED", "RISK", domain="pcb",
requirement="Reference plane missing under a triggered AF sample.")
_add("PE-AF-031", "RECOMMENDED", "RISK", domain="pcb",
requirement="Return copper farther than 3H under a triggered AF track.")
_add("PE-AF-032", "TYPICAL", "REVIEW", domain="pcb",
requirement="Reference-plane split nearby (ImpedenceFinder flag).")
_add("PE-AF-040", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Aggressor spacing < 3W on a triggered AF net (not the pair mate).")
_add("PE-AF-050", "RECOMMENDED", "RISK", domain="pcb",
requirement="Z0/Zdiff vs datasheet window; cascade RLGC uses that same Z_ref.")
_add("PE-AF-051", "TYPICAL", "REVIEW", domain="pcb",
requirement="CPWG/unknown topology: no invented ohm.")
_add("PE-AF-060", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Via series inductance from drill, span, and dielectric height.")
_add("PE-AF-061", "RECOMMENDED", "RISK", domain="pcb",
requirement="Via stub length vs λ/20 when span and f are FACT.")
_seed() _seed()
@@ -500,6 +500,7 @@ class LayoutVia(BaseModel):
y: float y: float
net: str = "" net: str = ""
drill: float | None = None drill: float | None = None
layers: tuple[str, ...] = ()
class LayoutDielectric(BaseModel): class LayoutDielectric(BaseModel):
@@ -291,6 +291,15 @@ def _is_footprint_via(pad: object) -> bool:
return True return True
def _via_copper_layers(node: object) -> tuple[str, ...]:
"""KiCad ``(layers "F.Cu" "B.Cu")``. Empty if the file has no span."""
el = _kid(node, "layers")
if not el:
return ()
names = tuple(str(x) for x in el[1:] if not isinstance(x, list) and str(x).strip())
return names
def _via_from_node( def _via_from_node(
node: object, node: object,
nets: dict[str, int], nets: dict[str, int],
@@ -307,7 +316,7 @@ def _via_from_node(
rx, ry = _rotate(vx, vy, frot) rx, ry = _rotate(vx, vy, frot)
vx, vy = fx + rx, fy + ry vx, vy = fx + rx, fy + ry
net = _net_name(node, nets) net = _net_name(node, nets)
return LayoutVia(x=vx, y=vy, net=net, drill=drill) return LayoutVia(x=vx, y=vy, net=net, drill=drill, layers=_via_copper_layers(node))
def _courtyard_pts(node: object, fx: float, fy: float, frot: float) -> list[tuple[float, float]]: def _courtyard_pts(node: object, fx: float, fy: float, frot: float) -> list[tuple[float, float]]:
@@ -408,7 +417,8 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
drill_el = _kid(pad, "drill") drill_el = _kid(pad, "drill")
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
vias.append(LayoutVia( vias.append(LayoutVia(
x=ax, y=ay, net=_net_name(pad, nets) or _pad_net(pad), drill=drill, x=ax, y=ay, net=_net_name(pad, nets) or _pad_net(pad),
drill=drill, layers=_via_copper_layers(pad),
)) ))
continue continue
if _is_npth_pad(pad): if _is_npth_pad(pad):
+21 -3
View File
@@ -1,9 +1,9 @@
"""PCB review pipeline — parallel to analysis (exam, not auto-place). """PCB review pipeline — parallel to analysis (exam, not auto-place).
Stages: ensure_graph → parse_pcb → classify → inventory → checks → Stages: ensure_graph → parse_pcb → classify → inventory → checks →
af_ai → ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is af_trace → af_ai → ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
untouched. ``af_ai`` is additive (AI flags then deterministic investigate) and untouched. ``af_trace`` and ``af_ai`` are additive and do not replace
does not replace ``run_pcb_checks`` SI/HF. Does not write ``.kicad_pcb``. ``run_pcb_checks`` SI/HF. Does not write ``.kicad_pcb``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,6 +29,7 @@ from backend.periscopex.models import (
ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport, ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport,
) )
from backend.periscopex.af_ai_hf import append_investigated, hypotheses_from_rows from backend.periscopex.af_ai_hf import append_investigated, hypotheses_from_rows
from backend.periscopex.af_trace_check import check_af_traces
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
from backend.periscopex.pcb_inventory import build_pcb_inventory from backend.periscopex.pcb_inventory import build_pcb_inventory
from backend.services import projects as proj_svc from backend.services import projects as proj_svc
@@ -305,6 +306,23 @@ async def run_pcb_pipeline(
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""), + (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
) )
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "af_trace", "running", "AF trigger + closed-form/cascade (not OpenEMS)")
try:
extra_af = check_af_traces(graph, cmap, layout, zrep, findings)
findings.extend(extra_af)
n_trace = len(extra_af)
except Exception:
logger.exception("AF trace analysis failed — keeping PCB checks")
n_trace = 0
_step(
project_id, "af_trace", "complete",
f"{n_trace} AF trace findings (SI/HF checks kept)",
)
if _cancelled(storage, user_id, project_id): if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id) _finish_cancelled(storage, user_id, project_id)
return return
@@ -2,6 +2,15 @@
What's new in Periscope. What's new in Periscope.
## 2.63.5 — 2026-09-22 — AF trace analysis (trigger, cascade, visible skips)
PCB exam adds **AF trace analysis** after unchanged `run_pcb_checks` (SI/HF stay). Trigger is λ/10 or tr/(6 tpd) from FACT only. Differential pairs are one unit. Z0/Zdiff only vs a **datasheet** window. Missing tr/f/εr/stackup/Z target/via span is a visible INSUFFICIENT finding («Pista ad alta frequenza non controllata per mancanza di …»), not invented 50/90 Ω. Trigger false → no AF finding. Numerical cascade is lossless RLGC sections (ImpedenceFinder Z0); not OpenEMS/FEM. Python. PE-AF-001 AI path unchanged.
- [New] `check_af_traces` / `af_trigger` / `af_rlgc`; pipeline stage `af_trace`.
- [New] `PE-AF-002` visible skip; `PE-AF-020``061` on triggered nets.
- [New] Via `(layers …)` parsed on `LayoutVia` (span; size is not a track).
- [Changed] Changelog / PCB progress lists AF trace before AF+AI.
## 2.63.4 — 2026-09-22 — Report Error group, J2, U18 max severity ## 2.63.4 — 2026-09-22 — Report Error group, J2, U18 max severity
The findings tree always has an **Error** folder when ERROR findings are visible. Group badge is the worst child (U18 PE-PLC-002 ERROR is not labeled WARNING). Connectors (J2) sit in the same sidebar as U*. PE-PLC stays in the PCB exam bucket. No auth change. The findings tree always has an **Error** folder when ERROR findings are visible. Group badge is the worst child (U18 PE-PLC-002 ERROR is not labeled WARNING). Connectors (J2) sit in the same sidebar as U*. PE-PLC stays in the PCB exam bucket. No auth change.
@@ -11,6 +11,7 @@ const PCB_STAGES = [
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" }, { id: "classify", title: "Classify domains", description: "Domains and functional groups" },
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" }, { id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, HF lines, power traces, thermal, Kelvin" }, { id: "checks", title: "Deterministic checks", description: "Placement, SI, HF lines, power traces, thermal, Kelvin" },
{ id: "af_trace", title: "AF trace analysis", description: "λ/10 and tr trigger, closed-form Z0, RLGC cascade (not OpenEMS)" },
{ id: "af_ai", title: "AF Board + AI", description: "AI flags HF issues, then deterministic investigate (does not replace SI/HF)" }, { id: "af_ai", title: "AF Board + AI", description: "AI flags HF issues, then deterministic investigate (does not replace SI/HF)" },
{ id: "ai_review", title: "PCB AI exam", description: "Layout vs shared library extraction (no second PDF pass)" }, { id: "ai_review", title: "PCB AI exam", description: "Layout vs shared library extraction (no second PDF pass)" },
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" }, { id: "write_report", title: "Write report", description: "pcb_report.json findings" },
@@ -25,6 +25,7 @@ export function isLayoutFinding(f: {
f.source === "antenna_layout_check" || f.source === "antenna_layout_check" ||
f.source === "pdn_check" || f.source === "pdn_check" ||
f.source === "af_ai_hf" || f.source === "af_ai_hf" ||
f.source === "af_trace_check" ||
rid.startsWith("PE-PLC") || rid.startsWith("PE-PLC") ||
rid.startsWith("PE-LAY") || rid.startsWith("PE-LAY") ||
rid.startsWith("PE-SI") || rid.startsWith("PE-SI") ||
@@ -66,5 +67,5 @@ export function isAfAiFinding(f: {
rule_id?: string | null; rule_id?: string | null;
}): boolean { }): boolean {
const rid = f.rule_id || ""; const rid = f.rule_id || "";
return f.source === "af_ai_hf" || rid.startsWith("PE-AF"); return f.source === "af_ai_hf" || f.source === "af_trace_check" || rid.startsWith("PE-AF");
} }
+4 -1
View File
@@ -188,12 +188,15 @@ def test_pcb_pipeline_af_ai_is_after_run_pcb_checks():
src = inspect.getsource(pcb_pipeline.run_pcb_pipeline) src = inspect.getsource(pcb_pipeline.run_pcb_pipeline)
assert "run_pcb_checks" in src assert "run_pcb_checks" in src
assert "check_af_traces" in src
assert "_run_af_ai_section" in src assert "_run_af_ai_section" in src
assert src.find("run_pcb_checks") < src.find("_run_af_ai_section") 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) checks = inspect.getsource(pcb_checks.run_pcb_checks)
assert '("si_check"' in checks or '"si_check"' in checks assert '("si_check"' in checks or '"si_check"' in checks
assert '"hf_line"' in checks assert '"hf_line"' in checks
assert "af_ai" not in checks assert "af_ai" not in checks
assert "check_af_traces" not in checks
assert "investigate_hf_hypotheses" not in checks assert "investigate_hf_hypotheses" not in checks
+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
+2
View File
@@ -60,6 +60,8 @@ def test_parse_segment_and_via_resolve_net_name(tmp_path: Path):
assert g.segments[0].net == "GND" assert g.segments[0].net == "GND"
assert len(g.vias) == 1 assert len(g.vias) == 1
assert g.vias[0].net == "GND" assert g.vias[0].net == "GND"
assert g.vias[0].drill == pytest.approx(0.4)
assert g.vias[0].layers == ("F.Cu", "B.Cu")
def test_power_flag_footprint_is_skipped(tmp_path: Path): def test_power_flag_footprint_is_skipped(tmp_path: Path):