Run ImpedenceFinder Z0 on routed signal nets during project analysis.

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 <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 00:21:49 +02:00
co-authored by Cursor
parent 796cd9d8a2
commit 3f6082ae6d
12 changed files with 731 additions and 22 deletions
+156
View File
@@ -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,
}
+21
View File
@@ -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] = []
+97 -12
View File
@@ -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,
)
+52 -2
View File
@@ -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}
+26
View File
@@ -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",