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:
@@ -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,
|
||||
}
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user