From 3f6082ae6d829fd4bd9a0c4fd8d91a2f5864aa70 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Fri, 11 Sep 2026 00:21:49 +0200 Subject: [PATCH] Run ImpedenceFinder Z0 on routed signal nets during project analysis. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline samples PCB traces when stackup εr/h is present; power/ground are skipped. Named nets can be re-analyzed from the Impedance tab. Co-authored-by: Cursor --- backend/pinscopex/impedance_traces.py | 156 ++++++++++++++ backend/pinscopex/models.py | 21 ++ backend/pinscopex/parsers_kicad_pcb.py | 109 ++++++++-- backend/routers/impedance.py | 54 ++++- backend/services/pipeline.py | 26 +++ docs/piano-implementazione.md | 2 +- frontend/content/changelog.md | 6 + frontend/src/app/(app)/project/[id]/page.tsx | 4 +- .../components/project/impedance-panel.tsx | 122 ++++++++++- frontend/src/lib/api.ts | 38 ++++ frontend/src/lib/types.ts | 21 ++ tests/test_impedance_traces.py | 194 ++++++++++++++++++ 12 files changed, 731 insertions(+), 22 deletions(-) create mode 100644 backend/pinscopex/impedance_traces.py create mode 100644 tests/test_impedance_traces.py diff --git a/backend/pinscopex/impedance_traces.py b/backend/pinscopex/impedance_traces.py new file mode 100644 index 0000000..1a77350 --- /dev/null +++ b/backend/pinscopex/impedance_traces.py @@ -0,0 +1,156 @@ +"""ImpedenceFinder net analysis on specified PCB traces. + +Walks sampled points on named nets (net_walk + planes + zsolver). +Stackup and widths come from the board (or explicit LayoutStackup). +No invented εr/h; missing stackup or empty net list skips. +""" + +from __future__ import annotations + +from dataclasses import asdict + +from backend.vendor_path import ensure_impedancefinder + +ensure_impedancefinder() +from impedancefinder import net_analysis, report +from impedancefinder.model import ( + BoardData, + DielectricLayer, + Point2D, + Stackup, + TraceSegment, + ViaSpan, + ZonePolygon, +) + +from backend.pinscopex.impedance import GeometryError +from backend.pinscopex.models import DesignGraph, LayoutGraph, NetType + + +def _stackup(layout: LayoutGraph) -> Stackup: + raw = layout.stackup + if raw is None: + raise GeometryError("PCB has no stackup (copper + dielectric εr/h)") + t = raw.copper_thickness_mm + if t is None or t <= 0: + raise GeometryError("PCB stackup has no copper thickness") + return Stackup( + copper_layer_names=tuple(raw.copper_layers), + dielectrics=tuple( + DielectricLayer(name=d.name, er=d.er, height_mm=d.height_mm) + for d in raw.dielectrics + ), + copper_thickness_mm=t, + ) + + +def layout_to_board_data(layout: LayoutGraph) -> BoardData: + stackup = _stackup(layout) + segments: list[TraceSegment] = [] + for s in layout.segments: + if not s.net or s.width <= 0: + continue + segments.append(TraceSegment( + net=s.net, + layer=s.layer, + start=Point2D(s.start[0], s.start[1]), + end=Point2D(s.end[0], s.end[1]), + width_mm=s.width, + )) + vias: list[ViaSpan] = [] + layers = stackup.copper_layer_names + if len(layers) >= 2: + top, bot = layers[0], layers[-1] + for v in layout.vias: + if not v.net or v.drill is None or v.drill <= 0: + continue + vias.append(ViaSpan( + net=v.net, + position=Point2D(v.x, v.y), + top_layer=top, + bottom_layer=bot, + drill_mm=v.drill, + )) + zones: list[ZonePolygon] = [] + for z in layout.zones: + rings = tuple( + tuple(Point2D(x, y) for x, y in ring) + for ring in z.outlines + if len(ring) >= 3 + ) + if rings: + zones.append(ZonePolygon(net=z.net, layer=z.layer, outlines_mm=rings)) + return BoardData( + segments=tuple(segments), + vias=tuple(vias), + zone_polygons=tuple(zones), + copper_layer_names=stackup.copper_layer_names, + stackup=stackup, + outline=None, + ) + + +def analyze_specified_nets( + layout: LayoutGraph, + net_names: list[str], + pitch_mm: float, +) -> list[dict]: + """Analyze only the named nets. Empty names → []. Missing net → error row.""" + if pitch_mm <= 0: + raise GeometryError("pitch_mm must be > 0") + wanted = [n.strip() for n in net_names if n and n.strip()] + if not wanted: + return [] + board = layout_to_board_data(layout) + stackup = board.stackup + assert stackup is not None + rows: list[dict] = [] + for name in wanted: + segs = net_analysis.segments_for(board, name) + if not segs: + rows.append({"net_name": name, "error": "no segments on this net"}) + continue + result = net_analysis.analyze_net(board, stackup, name, pitch_mm) + summary = report.summarize_net(name, board, result) + row = asdict(summary) + row["sample_count"] = len(result.samples) + rows.append(row) + return rows + + +# ImpedenceFinder net_walk sample interval (mm). Same as vendor +# tests/test_net_walk.py pitch_mm=1.0 — not a Z0 target. +NET_WALK_PITCH_MM = 1.0 + + +def nets_needed(layout: LayoutGraph, graph: DesignGraph | None) -> list[str]: + """Routed copper that is not a power/ground net in the schematic.""" + routed = {s.net for s in layout.segments if s.net and s.width > 0} + needed: list[str] = [] + for name in sorted(routed): + if graph is not None: + net = graph.nets.get(name) + if net is not None and net.net_type in (NetType.POWER, NetType.GROUND): + continue + needed.append(name) + return needed + + +def analyze_where_needed( + layout: LayoutGraph, + graph: DesignGraph | None = None, + pitch_mm: float = NET_WALK_PITCH_MM, +) -> dict: + """Pipeline entry: skip without stackup or without routed signal nets.""" + if pitch_mm <= 0: + raise GeometryError("pitch_mm must be > 0") + if layout.stackup is None: + return {"pitch_mm": pitch_mm, "nets": [], "skipped": "no stackup"} + names = nets_needed(layout, graph) + if not names: + return {"pitch_mm": pitch_mm, "nets": [], "skipped": "no routed signal nets"} + return { + "pitch_mm": pitch_mm, + "nets": analyze_specified_nets(layout, names, pitch_mm), + "skipped": None, + } diff --git a/backend/pinscopex/models.py b/backend/pinscopex/models.py index 43493b8..6ce9466 100644 --- a/backend/pinscopex/models.py +++ b/backend/pinscopex/models.py @@ -477,6 +477,25 @@ class LayoutVia(BaseModel): x: float y: float net: str = "" + drill: float | None = None + + +class LayoutDielectric(BaseModel): + name: str + er: float + height_mm: float + + +class LayoutStackup(BaseModel): + copper_layers: list[str] + dielectrics: list[LayoutDielectric] + copper_thickness_mm: float | None = None + + +class LayoutZone(BaseModel): + net: str + layer: str + outlines: list[list[tuple[float, float]]] = [] class LayoutGraph(BaseModel): @@ -485,4 +504,6 @@ class LayoutGraph(BaseModel): footprints: dict[str, LayoutFootprint] = {} segments: list[LayoutSegment] = [] vias: list[LayoutVia] = [] + stackup: LayoutStackup | None = None + zones: list[LayoutZone] = [] diff --git a/backend/pinscopex/parsers_kicad_pcb.py b/backend/pinscopex/parsers_kicad_pcb.py index 6c0f885..238223c 100644 --- a/backend/pinscopex/parsers_kicad_pcb.py +++ b/backend/pinscopex/parsers_kicad_pcb.py @@ -8,11 +8,14 @@ from __future__ import annotations from pathlib import Path from backend.pinscopex.models import ( + LayoutDielectric, LayoutFootprint, LayoutGraph, LayoutPad, LayoutSegment, + LayoutStackup, LayoutVia, + LayoutZone, ) from backend.pinscopex.parsers_kicad import ( _at, @@ -47,6 +50,89 @@ def _pad_net(pad: object) -> str: return "" +def _net_name(node: object, nets: dict[str, int]) -> str: + n = _kid(node, "net") + if not n or len(n) < 2: + named = _val(node, "net_name") + return named + if len(n) >= 3: + return str(n[2]) + try: + code = int(_fnum(n[1])) + except (TypeError, ValueError): + return "" + return next((name for name, c in nets.items() if c == code), str(code)) + + +def _layer_type(node: object) -> str: + return str(_val(node, "type") or "").lower() + + +def _parse_stackup(tree: object) -> LayoutStackup | None: + setup = _kid(tree, "setup") + if not setup: + return None + stack = _kid(setup, "stackup") + if not stack: + return None + copper: list[str] = [] + dielectrics: list[LayoutDielectric] = [] + thicknesses: list[float] = [] + for layer in _kids(stack, "layer"): + name = str(layer[1]) if len(layer) > 1 and not isinstance(layer[1], list) else "" + kind = _layer_type(layer) + thick = _kid(layer, "thickness") + height = _fnum(thick[1]) if thick and len(thick) > 1 else None + if kind == "copper" or name.endswith(".Cu"): + if name: + copper.append(name) + if height is not None and height > 0: + thicknesses.append(height) + continue + if kind in {"core", "prepreg", "dielectric"} or name.lower().startswith("dielectric"): + er_el = _kid(layer, "epsilon_r") + if er_el is None: + er_el = _kid(layer, "epsilonr") + er = _fnum(er_el[1]) if er_el and len(er_el) > 1 else None + if er is None or height is None or er <= 0 or height <= 0: + continue + dielectrics.append(LayoutDielectric( + name=name or f"dielectric_{len(dielectrics)}", + er=er, + height_mm=height, + )) + if len(copper) < 2 or len(dielectrics) != len(copper) - 1: + return None + t = thicknesses[0] if thicknesses else None + return LayoutStackup( + copper_layers=copper, + dielectrics=dielectrics, + copper_thickness_mm=t, + ) + + +def _pts_xy(node: object) -> list[tuple[float, float]]: + pts_el = _kid(node, "pts") + if not pts_el: + return [] + out: list[tuple[float, float]] = [] + for xy in pts_el[1:]: + if isinstance(xy, list) and xy and xy[0] == "xy" and len(xy) >= 3: + out.append((_fnum(xy[1]), _fnum(xy[2]))) + return out + + +def _parse_zone(node: object, nets: dict[str, int]) -> list[LayoutZone]: + net = str(_val(node, "net_name") or "") or _net_name(node, nets) + zones: list[LayoutZone] = [] + for poly in _kids(node, "filled_polygon"): + layer = _val(poly, "layer") + pts = _pts_xy(poly) + if layer and len(pts) >= 3: + zones.append(LayoutZone(net=net, layer=layer, outlines=[pts])) + return zones + + def _is_crtyd(layer: str) -> bool: return str(layer).endswith("CrtYd") @@ -105,6 +191,7 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph: footprints: dict[str, LayoutFootprint] = {} segments: list[LayoutSegment] = [] vias: list[LayoutVia] = [] + zones: list[LayoutZone] = [] for node in tree[1:]: if not isinstance(node, list) or not node: @@ -150,31 +237,29 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph: ) continue if tag == "segment": - net_el = _kid(node, "net") - net_name = "" - if net_el and len(net_el) >= 2: - code = int(_fnum(net_el[1])) - net_name = next((n for n, c in nets.items() if c == code), str(code)) segments.append(LayoutSegment( start=_xy(node, "start"), end=_xy(node, "end"), width=_fnum(_val(node, "width") or 0), layer=_val(node, "layer"), - net=net_name, + net=_net_name(node, nets), )) continue if tag == "via": - net_el = _kid(node, "net") - net_name = "" - if net_el and len(net_el) >= 2: - code = int(_fnum(net_el[1])) - net_name = next((n for n, c in nets.items() if c == code), str(code)) + drill_el = _kid(node, "drill") + drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None vx, vy, _ = _at(node) - vias.append(LayoutVia(x=vx, y=vy, net=net_name)) + vias.append(LayoutVia(x=vx, y=vy, net=_net_name(node, nets), drill=drill)) + continue + if tag == "zone": + zones.extend(_parse_zone(node, nets)) + continue return LayoutGraph( nets=nets, footprints=footprints, segments=segments, vias=vias, + stackup=_parse_stackup(tree), + zones=zones, ) diff --git a/backend/routers/impedance.py b/backend/routers/impedance.py index bf9d784..a186fa5 100644 --- a/backend/routers/impedance.py +++ b/backend/routers/impedance.py @@ -1,11 +1,13 @@ -"""Standalone impedance calculator (no PCB, no findings).""" +"""Impedance calculator and ImpedenceFinder analysis of PCB nets.""" from __future__ import annotations +import os +import tempfile from dataclasses import asdict from typing import Literal -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from backend.pinscopex.impedance import ( @@ -19,6 +21,13 @@ from backend.pinscopex.impedance import ( stackup_targets, stripline_z0, ) +from backend.pinscopex.impedance_traces import ( + NET_WALK_PITCH_MM, + analyze_specified_nets, +) +from backend.pinscopex.parsers_kicad_pcb import parse_kicad_pcb +from backend.routers.deps import get_storage, resolve_or_404 +from backend.services import projects as proj_svc router = APIRouter(tags=["impedance"]) @@ -73,3 +82,44 @@ def compute_impedance(body: ImpedanceRequest): return _z_for_kind(kind, geo) except GeometryError as exc: raise HTTPException(400, str(exc)) from exc + + +class NetsRequest(BaseModel): + nets: list[str] + pitch_mm: float | None = None + + +@router.get("/projects/{project_id}/impedance/nets") +async def get_project_impedance_nets(project_id: str, request: Request): + storage = get_storage(request) + owner, _ = await resolve_or_404(request, project_id) + prefix = proj_svc.project_prefix(owner, project_id) + key = f"{prefix}/impedance_nets.json" + if not storage.exists(key): + return {"pitch_mm": NET_WALK_PITCH_MM, "nets": [], "skipped": "not run"} + return storage.read_json(key) + + +@router.post("/projects/{project_id}/impedance/nets") +async def analyze_project_impedance_nets( + project_id: str, body: NetsRequest, request: Request, +): + storage = get_storage(request) + owner, _ = await resolve_or_404(request, project_id) + prefix = proj_svc.project_prefix(owner, project_id) + pcb_key = f"{prefix}/uploads/pcb.kicad_pcb" + if not storage.exists(pcb_key): + raise HTTPException(400, "No .kicad_pcb on this project") + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb") + try: + tmp.write(storage.read_bytes(pcb_key)) + tmp.close() + layout = parse_kicad_pcb(tmp.name) + finally: + os.unlink(tmp.name) + pitch = body.pitch_mm if body.pitch_mm is not None else NET_WALK_PITCH_MM + try: + rows = analyze_specified_nets(layout, body.nets, pitch) + except GeometryError as exc: + raise HTTPException(400, str(exc)) from exc + return {"pitch_mm": pitch, "nets": rows, "skipped": None} diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index bd3368c..0603821 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -257,6 +257,8 @@ class PipelineWorkspace: self._upload_dir("patterns") self._upload_dir("models") self._upload_file("design_graph.json") + self._upload_file("layout_graph.json") + self._upload_file("impedance_nets.json") self._upload_file("bom_summary.json") self._upload_file("derating.json") self._upload_file("report.json") @@ -1516,6 +1518,28 @@ def _write_layout_graph(ws: PipelineWorkspace, project_id: str) -> None: logger.exception("kicad_pcb parse failed — continuing without layout") +def _write_impedance_nets(ws: PipelineWorkspace, graph) -> None: + """ImpedenceFinder Z0 on routed signal nets. Skip without PCB stackup.""" + path = ws.local_path("layout_graph.json") + if not path.is_file(): + return + try: + from backend.pinscopex.impedance_traces import analyze_where_needed + from backend.pinscopex.models import LayoutGraph + + layout = LayoutGraph.model_validate_json(path.read_text()) + report = analyze_where_needed(layout, graph) + out = ws.local_path("impedance_nets.json") + out.write_text(json.dumps(report, indent=2) + "\n") + logger.info( + "impedance_nets: %s nets (skipped=%s)", + len(report.get("nets") or []), + report.get("skipped"), + ) + except Exception: + logger.exception("impedance net analysis failed — continuing") + + async def _stage_graph_build(ctx: PipelineContext) -> None: """Stage 4 — Build the design graph from netlist, BOM, and extracted data.""" broker.publish(ctx.project_id, "step_update", @@ -1546,6 +1570,7 @@ async def _stage_graph_build(ctx: PipelineContext) -> None: graph_path = ctx.ws.local_path("design_graph.json") graph_path.write_text(ctx.graph.model_dump_json(indent=2) + "\n") _write_layout_graph(ctx.ws, ctx.project_id) + _write_impedance_nets(ctx.ws, ctx.graph) broker.publish(ctx.project_id, "step_update", {"stage": "graph_build", "status": "complete", @@ -2110,6 +2135,7 @@ async def run_regen_pipeline( graph_path = ws.local_path("design_graph.json") graph_path.write_text(graph.model_dump_json(indent=2) + "\n") _write_layout_graph(ws, project_id) + _write_impedance_nets(ws, graph) broker.publish(project_id, "step_update", {"stage": "graph_build", "status": "complete", diff --git a/docs/piano-implementazione.md b/docs/piano-implementazione.md index c9d3ac1..208c148 100644 --- a/docs/piano-implementazione.md +++ b/docs/piano-implementazione.md @@ -106,7 +106,7 @@ Passi: | --- | --- | --- | --- | | 1 Plugin / telemetry / BOM match | Parser KiCad XML/sexp/`.kicad_sch` gerarchico; wizard BOM; `plugins/kicad/` + cad-bridge JSON | EasyEDA fuori scope; uuid/sheet sul plugin da verificare su board reale | **OK** | | 2 Datasheet / errata / OCR blocchi | Pintable, excerpt, quote_verify, errata, `internal_features` | Nessun RAG vendor | **OK** | -| 3 Impedenze / stackup | ImpedenceFinder + tab Impedance + `.kicad_dru` | CPWG non nel vendor; niente Z0 sul rame del PCB | **OK** | +| 3 Impedenze / stackup | ImpedenceFinder: calcolatrice + **Z0 sulle tracce** dei net signal (stackup PCB) | CPWG non nel vendor | **OK** | | 4 Filtri | `check_filters` (solo con poli/numeri in specs) | Niente \(f_c\) inventata | **OK** | | 5 Capacità PI | Decoupling sulla net; derating V; DC-bias/ESR se c’è il numero; mm sul PCB (`PS-PLC-001`) | — | **OK** | | 6 Elettrico | Pin mux; I2C/reset pull-up; LED; sequencing/IR/power margin se c’è il parametro | Senza numero in specs → skip | **OK** | diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 5fc678c..44e2da0 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,12 @@ What's new in Pinscope. +## 2.26.0 — 2026-09-11 — ImpedenceFinder Z0 on PCB nets + +A pipeline run with `.kicad_pcb` + stackup samples routed **signal** nets (power/ground skipped). Extra net names can be analyzed from the Impedance tab. Z0 is ImpedenceFinder net_walk/zsolver; no invented εr. + +- [New] `impedance_nets.json` after graph build. GET/POST `/api/projects/{id}/impedance/nets`. + ## 2.25.0 — 2026-09-10 — Keepout courtyard `layout_rules` `keepout` flags a foreign net whose track endpoint is inside the KiCad courtyard. Own net and missing courtyard skip. No invented analog/digital classes. diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx index b6c314b..cf06e45 100644 --- a/frontend/src/app/(app)/project/[id]/page.tsx +++ b/frontend/src/app/(app)/project/[id]/page.tsx @@ -347,7 +347,9 @@ export default function ProjectDetailPage({ /> )} - {tab === "impedance" && } + {tab === "impedance" && ( + + )} {tab === "logs" && ( diff --git a/frontend/src/components/project/impedance-panel.tsx b/frontend/src/components/project/impedance-panel.tsx index 96312ae..a063d59 100644 --- a/frontend/src/components/project/impedance-panel.tsx +++ b/frontend/src/components/project/impedance-panel.tsx @@ -1,13 +1,18 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { computeImpedance } from "@/lib/api"; +import { + analyzeImpedanceNets, + computeImpedance, + fetchImpedanceNets, +} from "@/lib/api"; import type { ImpedanceKind, + ImpedanceNetsReport, ImpedanceStackupResult, ImpedanceTraceResult, } from "@/lib/types"; @@ -21,7 +26,13 @@ function fmt(n: number | null | undefined, digits = 2): string { return n.toFixed(digits); } -export function ImpedancePanel() { +export function ImpedancePanel({ + projectId, + hasPcb, +}: { + projectId: string; + hasPcb: boolean; +}) { const [kind, setKind] = useState("microstrip"); const [h, setH] = useState("0.20"); const [er, setEr] = useState("4.5"); @@ -33,6 +44,22 @@ export function ImpedancePanel() { const [busy, setBusy] = useState(false); const [trace, setTrace] = useState(null); const [stackup, setStackup] = useState(null); + const [boardNets, setBoardNets] = useState(null); + const [extraNets, setExtraNets] = useState(""); + + useEffect(() => { + let cancelled = false; + fetchImpedanceNets(projectId) + .then((r) => { + if (!cancelled) setBoardNets(r); + }) + .catch(() => { + if (!cancelled) setBoardNets(null); + }); + return () => { + cancelled = true; + }; + }, [projectId]); async function runTrace() { setBusy(true); @@ -90,6 +117,23 @@ export function ImpedancePanel() { URL.revokeObjectURL(url); } + async function runSpecifiedNets() { + const names = extraNets + .split(/[\s,]+/) + .map((n) => n.trim()) + .filter(Boolean); + if (names.length === 0) return; + setBusy(true); + setError(null); + try { + setBoardNets(await analyzeImpedanceNets(projectId, names)); + } catch (e) { + setError(e instanceof Error ? e.message : "Net analysis failed"); + } finally { + setBusy(false); + } + } + const needsGap = kind === "cpw" || kind === "diff"; return ( @@ -100,9 +144,9 @@ export function ImpedancePanel() {

- IPC-2141 / Hammerstad–Jensen from ImpedenceFinder (same as KiCad - pcb_calculator). Advice only — this tab does not sample the PCB - and does not emit findings. CPWG is not implemented. + ImpedenceFinder (Hammerstad–Jensen). The calculator is advice only. + With a `.kicad_pcb` and stackup, a pipeline run samples routed signal + nets. CPWG is not implemented upstream.