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",
+1 -1
View File
@@ -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** |
+6
View File
@@ -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.
+3 -1
View File
@@ -347,7 +347,9 @@ export default function ProjectDetailPage({
/>
)}
{tab === "impedance" && <ImpedancePanel />}
{tab === "impedance" && (
<ImpedancePanel projectId={id} hasPcb={Boolean(project.hasPcb)} />
)}
{tab === "logs" && (
<ApiLogsSection logs={logs} />
@@ -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<ImpedanceKind>("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<ImpedanceTraceResult | null>(null);
const [stackup, setStackup] = useState<ImpedanceStackupResult | null>(null);
const [boardNets, setBoardNets] = useState<ImpedanceNetsReport | null>(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() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
IPC-2141 / HammerstadJensen 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 (HammerstadJensen). The calculator is advice only.
With a `.kicad_pcb` and stackup, a pipeline run samples routed signal
nets. CPWG is not implemented upstream.
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<label className="space-y-1">
@@ -175,6 +219,72 @@ export function ImpedancePanel() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">PCB net Z0 (ImpedenceFinder)</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{!hasPcb && (
<p className="text-sm text-muted-foreground">
Upload a `.kicad_pcb` and run analysis. Power/ground nets are
skipped; signal traces with stackup εr/h are sampled.
</p>
)}
{hasPcb && boardNets?.skipped && (
<p className="text-sm text-muted-foreground">{boardNets.skipped}</p>
)}
{boardNets && boardNets.nets.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground">
<th className="py-1">Net</th>
<th>Z0 avg Ω</th>
<th>minmax</th>
<th>mm</th>
<th>topology</th>
</tr>
</thead>
<tbody>
{boardNets.nets.map((row) => (
<tr key={row.net_name} className="border-t border-border">
<td className="py-1">{row.net_name}</td>
<td className="tabular-nums">
{row.error ?? fmt(row.z0_avg_ohms)}
</td>
<td className="tabular-nums">
{row.z0_min_ohms != null
? `${fmt(row.z0_min_ohms)}${fmt(row.z0_max_ohms)}`
: "—"}
</td>
<td className="tabular-nums">{fmt(row.length_mm, 2)}</td>
<td>{(row.topologies || []).join(", ") || "—"}</td>
</tr>
))}
</tbody>
</table>
)}
{hasPcb && (
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-40 flex-1 space-y-1">
<Label>Extra nets</Label>
<Input
value={extraNets}
onChange={(e) => setExtraNets(e.target.value)}
placeholder="/USB.D+ /USB.D-"
/>
</label>
<Button
variant="outline"
onClick={runSpecifiedNets}
disabled={busy}
>
Analyze named nets
</Button>
</div>
)}
</CardContent>
</Card>
{stackup && (
<Card>
<CardHeader>
+38
View File
@@ -11,6 +11,7 @@ import type {
DesignGraph,
DeratingRow,
ImpedanceKind,
ImpedanceNetsReport,
ImpedanceStackupResult,
ImpedanceTraceResult,
EdifSubDesign,
@@ -609,6 +610,43 @@ export async function computeImpedance(body: {
return res.json();
}
export async function fetchImpedanceNets(
projectId: string,
): Promise<ImpedanceNetsReport> {
const res = await authFetch(
`${BASE}/api/projects/${projectId}/impedance/nets`,
);
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(
typeof detail.detail === "string" ? detail.detail : "Impedance nets failed",
);
}
return res.json();
}
export async function analyzeImpedanceNets(
projectId: string,
nets: string[],
pitch_mm?: number,
): Promise<ImpedanceNetsReport> {
const res = await authFetch(
`${BASE}/api/projects/${projectId}/impedance/nets`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nets, pitch_mm }),
},
);
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(
typeof detail.detail === "string" ? detail.detail : "Impedance nets failed",
);
}
return res.json();
}
export async function fetchReport(
projectId: string,
): Promise<ValidationReport> {
+21
View File
@@ -364,6 +364,27 @@ export interface ImpedanceStackupResult {
kicad_dru: string;
}
export interface ImpedanceNetRow {
net_name: string;
length_mm?: number;
branch_count?: number;
is_differential?: boolean;
partner_net_name?: string | null;
topologies?: string[];
z0_min_ohms?: number | null;
z0_max_ohms?: number | null;
z0_avg_ohms?: number | null;
flags?: string[];
sample_count?: number;
error?: string;
}
export interface ImpedanceNetsReport {
pitch_mm: number;
nets: ImpedanceNetRow[];
skipped: string | null;
}
export interface NetlistPreviewDesignator {
ref: string;
pins: { number: string; net_name: string }[];
+194
View File
@@ -0,0 +1,194 @@
"""ImpedenceFinder analysis on specified nets — widths/stackup from the board.
Favor: named net Z0 matches zsolver on the same w/h/εr/t.
Against: empty net list; missing net; no stackup.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from backend.pinscopex.impedance import GeometryError
from backend.pinscopex.impedance_traces import (
analyze_specified_nets,
analyze_where_needed,
)
from backend.pinscopex.models import (
DesignGraph,
LayoutDielectric,
LayoutGraph,
LayoutSegment,
LayoutStackup,
LayoutZone,
Net,
NetType,
)
from backend.pinscopex.parsers_kicad_pcb import parse_kicad_pcb
from backend.vendor_path import ensure_impedancefinder
ensure_impedancefinder()
from impedancefinder import zsolver
_H = 0.15
_ER = 4.3
_T = 0.035
_W = 0.2
_PITCH = 2.0
def _layout() -> LayoutGraph:
return LayoutGraph(
segments=[
LayoutSegment(
start=(0.0, 0.0), end=(10.0, 0.0),
width=_W, layer="F.Cu", net="SIG",
),
LayoutSegment(
start=(0.0, 2.0), end=(4.0, 2.0),
width=0.3, layer="F.Cu", net="OTHER",
),
],
stackup=LayoutStackup(
copper_layers=["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"],
dielectrics=[
LayoutDielectric(name="prepreg_top", er=_ER, height_mm=_H),
LayoutDielectric(name="core", er=4.4, height_mm=0.7),
LayoutDielectric(name="prepreg_bottom", er=4.3, height_mm=0.15),
],
copper_thickness_mm=_T,
),
zones=[
LayoutZone(
net="GND",
layer="In1.Cu",
outlines=[[(-5.0, -5.0), (50.0, -5.0), (50.0, 5.0), (-5.0, 5.0)]],
),
],
)
def test_specified_net_z0_matches_impedancefinder_zsolver():
rows = analyze_specified_nets(_layout(), ["SIG"], _PITCH)
assert len(rows) == 1
row = rows[0]
assert row["net_name"] == "SIG"
expect = zsolver.microstrip_z0(_W, _H, _ER, _T)
assert row["z0_avg_ohms"] == pytest.approx(expect, rel=1e-6)
assert row["z0_min_ohms"] == pytest.approx(expect, rel=1e-6)
def test_only_specified_nets_are_analyzed():
rows = analyze_specified_nets(_layout(), ["SIG"], _PITCH)
assert [r["net_name"] for r in rows] == ["SIG"]
def test_missing_net_is_error_not_invented_z0():
rows = analyze_specified_nets(_layout(), ["NO_SUCH_NET"], _PITCH)
assert rows[0]["error"] == "no segments on this net"
assert "z0_avg_ohms" not in rows[0] or rows[0].get("z0_avg_ohms") is None
def test_empty_net_list_is_silent():
assert analyze_specified_nets(_layout(), [" ", ""], _PITCH) == []
def test_no_stackup_raises():
layout = LayoutGraph(
segments=[LayoutSegment(start=(0, 0), end=(1, 0), width=0.2, layer="F.Cu", net="SIG")],
)
with pytest.raises(GeometryError, match="stackup"):
analyze_specified_nets(layout, ["SIG"], _PITCH)
_PCB_STACKUP = """(kicad_pcb (version 20240108) (generator pcbnew)
(net 0 "")
(net 1 "GND")
(net 2 "SIG")
(setup
(stackup
(layer "F.Cu" (type copper) (thickness 0.035))
(layer "dielectric 1" (type core) (thickness 0.15) (epsilon_r 4.3))
(layer "In1.Cu" (type copper) (thickness 0.035))
(layer "dielectric 2" (type core) (thickness 0.7) (epsilon_r 4.4))
(layer "In2.Cu" (type copper) (thickness 0.035))
(layer "dielectric 3" (type prepreg) (thickness 0.15) (epsilon_r 4.3))
(layer "B.Cu" (type copper) (thickness 0.035))
)
)
(segment (start 0 0) (end 10 0) (width 0.2) (layer "F.Cu") (net 2))
(zone (net 1) (net_name "GND") (layer "In1.Cu")
(filled_polygon (layer "In1.Cu")
(pts (xy -5 -5) (xy 50 -5) (xy 50 5) (xy -5 5))
)
)
)
"""
def test_parse_stackup_and_zone_then_analyze_sig(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB_STACKUP)
layout = parse_kicad_pcb(p)
assert layout.stackup is not None
assert layout.stackup.copper_layers[0] == "F.Cu"
assert layout.stackup.dielectrics[0].er == 4.3
assert layout.zones and layout.zones[0].net == "GND"
rows = analyze_specified_nets(layout, ["SIG"], _PITCH)
expect = zsolver.microstrip_z0(_W, _H, _ER, _T)
assert rows[0]["z0_avg_ohms"] == pytest.approx(expect, rel=1e-6)
def test_project_analysis_skips_power_and_runs_signal():
graph = DesignGraph(nets={
"SIG": Net(name="SIG", net_type=NetType.SIGNAL),
"OTHER": Net(name="OTHER", net_type=NetType.POWER),
})
report = analyze_where_needed(_layout(), graph, pitch_mm=_PITCH)
assert report["skipped"] is None
names = [r["net_name"] for r in report["nets"]]
assert names == ["SIG"]
expect = zsolver.microstrip_z0(_W, _H, _ER, _T)
assert report["nets"][0]["z0_avg_ohms"] == pytest.approx(expect, rel=1e-6)
def test_project_analysis_skips_without_stackup():
layout = LayoutGraph(
segments=[LayoutSegment(start=(0, 0), end=(1, 0), width=0.2, layer="F.Cu", net="SIG")],
)
report = analyze_where_needed(layout, None, pitch_mm=_PITCH)
assert report["nets"] == []
assert report["skipped"] == "no stackup"
def test_pipeline_writes_impedance_nets_for_signal(tmp_path: Path):
import json
from backend.services.pipeline import _write_impedance_nets
class Ws:
def local_path(self, rel: str) -> Path:
return tmp_path / rel
layout = _layout()
(tmp_path / "layout_graph.json").write_text(layout.model_dump_json())
graph = DesignGraph(nets={
"SIG": Net(name="SIG", net_type=NetType.SIGNAL),
"OTHER": Net(name="OTHER", net_type=NetType.POWER),
})
_write_impedance_nets(Ws(), graph)
data = json.loads((tmp_path / "impedance_nets.json").read_text())
assert [r["net_name"] for r in data["nets"]] == ["SIG"]
def test_get_impedance_nets_without_run_is_empty(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "z0"}).json()["id"]
body = client.get(f"/api/projects/{pid}/impedance/nets").json()
assert body["nets"] == []
assert body["skipped"] == "not run"