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
|
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|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.")
|
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):
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 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):
|
||||||
|
|||||||
Reference in New Issue
Block a user