diff --git a/docs/piano-af-analisi.md b/docs/piano-af-analisi.md new file mode 100644 index 0000000..88cf38c --- /dev/null +++ b/docs/piano-af-analisi.md @@ -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. diff --git a/periscope/dependency/backend/periscopex/models.py b/periscope/dependency/backend/periscopex/models.py index 473c907..8abb147 100644 --- a/periscope/dependency/backend/periscopex/models.py +++ b/periscope/dependency/backend/periscopex/models.py @@ -492,6 +492,7 @@ class LayoutVia(BaseModel): y: float net: str = "" drill: float | None = None + layers: tuple[str, ...] = () class LayoutDielectric(BaseModel): diff --git a/periscope/src/backend/periscopex/af_rlgc.py b/periscope/src/backend/periscopex/af_rlgc.py new file mode 100644 index 0000000..50eb4d6 --- /dev/null +++ b/periscope/src/backend/periscopex/af_rlgc.py @@ -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) diff --git a/periscope/src/backend/periscopex/af_trace_check.py b/periscope/src/backend/periscopex/af_trace_check.py new file mode 100644 index 0000000..f1fbd98 --- /dev/null +++ b/periscope/src/backend/periscopex/af_trace_check.py @@ -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 diff --git a/periscope/src/backend/periscopex/af_trigger.py b/periscope/src/backend/periscopex/af_trigger.py new file mode 100644 index 0000000..4d41578 --- /dev/null +++ b/periscope/src/backend/periscopex/af_trigger.py @@ -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 diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py index 3702e1c..d3518ee 100644 --- a/periscope/src/backend/periscopex/finding_engine.py +++ b/periscope/src/backend/periscopex/finding_engine.py @@ -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() diff --git a/periscope/src/backend/periscopex/models.py b/periscope/src/backend/periscopex/models.py index e9f80a9..47eb6d1 100644 --- a/periscope/src/backend/periscopex/models.py +++ b/periscope/src/backend/periscopex/models.py @@ -500,6 +500,7 @@ class LayoutVia(BaseModel): y: float net: str = "" drill: float | None = None + layers: tuple[str, ...] = () class LayoutDielectric(BaseModel): diff --git a/periscope/src/backend/periscopex/parsers_kicad_pcb.py b/periscope/src/backend/periscopex/parsers_kicad_pcb.py index a08bf2a..76a0185 100644 --- a/periscope/src/backend/periscopex/parsers_kicad_pcb.py +++ b/periscope/src/backend/periscopex/parsers_kicad_pcb.py @@ -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): diff --git a/periscope/src/backend/services/pcb_pipeline.py b/periscope/src/backend/services/pcb_pipeline.py index 44ca5fb..844191a 100644 --- a/periscope/src/backend/services/pcb_pipeline.py +++ b/periscope/src/backend/services/pcb_pipeline.py @@ -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 diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 374b0e9..d0906b0 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -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. diff --git a/periscope/src/frontend/src/hooks/use-pcb-progress.ts b/periscope/src/frontend/src/hooks/use-pcb-progress.ts index bd96544..7f26b6d 100644 --- a/periscope/src/frontend/src/hooks/use-pcb-progress.ts +++ b/periscope/src/frontend/src/hooks/use-pcb-progress.ts @@ -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" }, diff --git a/periscope/src/frontend/src/lib/layout-finding.ts b/periscope/src/frontend/src/lib/layout-finding.ts index c5dd8a2..7249cec 100644 --- a/periscope/src/frontend/src/lib/layout-finding.ts +++ b/periscope/src/frontend/src/lib/layout-finding.ts @@ -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"); } diff --git a/tests/af_ai/test_af_ai_hf.py b/tests/af_ai/test_af_ai_hf.py index dd79034..6b69dae 100644 --- a/tests/af_ai/test_af_ai_hf.py +++ b/tests/af_ai/test_af_ai_hf.py @@ -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 diff --git a/tests/pcb/test_af_trace_check.py b/tests/pcb/test_af_trace_check.py new file mode 100644 index 0000000..f1437ea --- /dev/null +++ b/tests/pcb/test_af_trace_check.py @@ -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 diff --git a/tests/pcb/test_kicad_pcb.py b/tests/pcb/test_kicad_pcb.py index 6bdcbff..65fd47c 100644 --- a/tests/pcb/test_kicad_pcb.py +++ b/tests/pcb/test_kicad_pcb.py @@ -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):