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,
)