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:
@@ -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 = un’unità: `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
|
||||
net: str = ""
|
||||
drill: float | None = None
|
||||
layers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
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|W1−W2|; 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.")
|
||||
_add("PE-PDN-001", "TYPICAL", "INFO", domain="pcb",
|
||||
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()
|
||||
|
||||
@@ -500,6 +500,7 @@ class LayoutVia(BaseModel):
|
||||
y: float
|
||||
net: str = ""
|
||||
drill: float | None = None
|
||||
layers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class LayoutDielectric(BaseModel):
|
||||
|
||||
@@ -291,6 +291,15 @@ def _is_footprint_via(pad: object) -> bool:
|
||||
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(
|
||||
node: object,
|
||||
nets: dict[str, int],
|
||||
@@ -307,7 +316,7 @@ def _via_from_node(
|
||||
rx, ry = _rotate(vx, vy, frot)
|
||||
vx, vy = fx + rx, fy + ry
|
||||
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]]:
|
||||
@@ -408,7 +417,8 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
|
||||
drill_el = _kid(pad, "drill")
|
||||
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
|
||||
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
|
||||
if _is_npth_pad(pad):
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""PCB review pipeline — parallel to analysis (exam, not auto-place).
|
||||
|
||||
Stages: ensure_graph → parse_pcb → classify → inventory → checks →
|
||||
af_ai → ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
|
||||
untouched. ``af_ai`` is additive (AI flags then deterministic investigate) and
|
||||
does not replace ``run_pcb_checks`` SI/HF. Does not write ``.kicad_pcb``.
|
||||
af_trace → af_ai → ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
|
||||
untouched. ``af_trace`` and ``af_ai`` are additive and do not replace
|
||||
``run_pcb_checks`` SI/HF. Does not write ``.kicad_pcb``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,6 +29,7 @@ from backend.periscopex.models import (
|
||||
ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport,
|
||||
)
|
||||
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_inventory import build_pcb_inventory
|
||||
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 ""),
|
||||
)
|
||||
|
||||
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):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
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
|
||||
|
||||
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: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
|
||||
{ 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: "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" },
|
||||
|
||||
@@ -25,6 +25,7 @@ export function isLayoutFinding(f: {
|
||||
f.source === "antenna_layout_check" ||
|
||||
f.source === "pdn_check" ||
|
||||
f.source === "af_ai_hf" ||
|
||||
f.source === "af_trace_check" ||
|
||||
rid.startsWith("PE-PLC") ||
|
||||
rid.startsWith("PE-LAY") ||
|
||||
rid.startsWith("PE-SI") ||
|
||||
@@ -66,5 +67,5 @@ export function isAfAiFinding(f: {
|
||||
rule_id?: string | null;
|
||||
}): boolean {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -188,12 +188,15 @@ def test_pcb_pipeline_af_ai_is_after_run_pcb_checks():
|
||||
|
||||
src = inspect.getsource(pcb_pipeline.run_pcb_pipeline)
|
||||
assert "run_pcb_checks" in src
|
||||
assert "check_af_traces" in src
|
||||
assert "_run_af_ai_section" in src
|
||||
assert src.find("run_pcb_checks") < src.find("_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)
|
||||
assert '("si_check"' in checks or '"si_check"' in checks
|
||||
assert '"hf_line"' in checks
|
||||
assert "af_ai" not in checks
|
||||
assert "check_af_traces" not in checks
|
||||
assert "investigate_hf_hypotheses" not in checks
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -60,6 +60,8 @@ def test_parse_segment_and_via_resolve_net_name(tmp_path: Path):
|
||||
assert g.segments[0].net == "GND"
|
||||
assert len(g.vias) == 1
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user