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
|
x: float
|
||||||
y: float
|
y: float
|
||||||
net: str = ""
|
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):
|
class LayoutGraph(BaseModel):
|
||||||
@@ -485,4 +504,6 @@ class LayoutGraph(BaseModel):
|
|||||||
footprints: dict[str, LayoutFootprint] = {}
|
footprints: dict[str, LayoutFootprint] = {}
|
||||||
segments: list[LayoutSegment] = []
|
segments: list[LayoutSegment] = []
|
||||||
vias: list[LayoutVia] = []
|
vias: list[LayoutVia] = []
|
||||||
|
stackup: LayoutStackup | None = None
|
||||||
|
zones: list[LayoutZone] = []
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.pinscopex.models import (
|
from backend.pinscopex.models import (
|
||||||
|
LayoutDielectric,
|
||||||
LayoutFootprint,
|
LayoutFootprint,
|
||||||
LayoutGraph,
|
LayoutGraph,
|
||||||
LayoutPad,
|
LayoutPad,
|
||||||
LayoutSegment,
|
LayoutSegment,
|
||||||
|
LayoutStackup,
|
||||||
LayoutVia,
|
LayoutVia,
|
||||||
|
LayoutZone,
|
||||||
)
|
)
|
||||||
from backend.pinscopex.parsers_kicad import (
|
from backend.pinscopex.parsers_kicad import (
|
||||||
_at,
|
_at,
|
||||||
@@ -47,6 +50,89 @@ def _pad_net(pad: object) -> str:
|
|||||||
return ""
|
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:
|
def _is_crtyd(layer: str) -> bool:
|
||||||
return str(layer).endswith("CrtYd")
|
return str(layer).endswith("CrtYd")
|
||||||
|
|
||||||
@@ -105,6 +191,7 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
|
|||||||
footprints: dict[str, LayoutFootprint] = {}
|
footprints: dict[str, LayoutFootprint] = {}
|
||||||
segments: list[LayoutSegment] = []
|
segments: list[LayoutSegment] = []
|
||||||
vias: list[LayoutVia] = []
|
vias: list[LayoutVia] = []
|
||||||
|
zones: list[LayoutZone] = []
|
||||||
|
|
||||||
for node in tree[1:]:
|
for node in tree[1:]:
|
||||||
if not isinstance(node, list) or not node:
|
if not isinstance(node, list) or not node:
|
||||||
@@ -150,31 +237,29 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if tag == "segment":
|
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(
|
segments.append(LayoutSegment(
|
||||||
start=_xy(node, "start"),
|
start=_xy(node, "start"),
|
||||||
end=_xy(node, "end"),
|
end=_xy(node, "end"),
|
||||||
width=_fnum(_val(node, "width") or 0),
|
width=_fnum(_val(node, "width") or 0),
|
||||||
layer=_val(node, "layer"),
|
layer=_val(node, "layer"),
|
||||||
net=net_name,
|
net=_net_name(node, nets),
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
if tag == "via":
|
if tag == "via":
|
||||||
net_el = _kid(node, "net")
|
drill_el = _kid(node, "drill")
|
||||||
net_name = ""
|
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
|
||||||
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))
|
|
||||||
vx, vy, _ = _at(node)
|
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(
|
return LayoutGraph(
|
||||||
nets=nets,
|
nets=nets,
|
||||||
footprints=footprints,
|
footprints=footprints,
|
||||||
segments=segments,
|
segments=segments,
|
||||||
vias=vias,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.pinscopex.impedance import (
|
from backend.pinscopex.impedance import (
|
||||||
@@ -19,6 +21,13 @@ from backend.pinscopex.impedance import (
|
|||||||
stackup_targets,
|
stackup_targets,
|
||||||
stripline_z0,
|
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"])
|
router = APIRouter(tags=["impedance"])
|
||||||
|
|
||||||
@@ -73,3 +82,44 @@ def compute_impedance(body: ImpedanceRequest):
|
|||||||
return _z_for_kind(kind, geo)
|
return _z_for_kind(kind, geo)
|
||||||
except GeometryError as exc:
|
except GeometryError as exc:
|
||||||
raise HTTPException(400, str(exc)) from 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("patterns")
|
||||||
self._upload_dir("models")
|
self._upload_dir("models")
|
||||||
self._upload_file("design_graph.json")
|
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("bom_summary.json")
|
||||||
self._upload_file("derating.json")
|
self._upload_file("derating.json")
|
||||||
self._upload_file("report.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")
|
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:
|
async def _stage_graph_build(ctx: PipelineContext) -> None:
|
||||||
"""Stage 4 — Build the design graph from netlist, BOM, and extracted data."""
|
"""Stage 4 — Build the design graph from netlist, BOM, and extracted data."""
|
||||||
broker.publish(ctx.project_id, "step_update",
|
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 = ctx.ws.local_path("design_graph.json")
|
||||||
graph_path.write_text(ctx.graph.model_dump_json(indent=2) + "\n")
|
graph_path.write_text(ctx.graph.model_dump_json(indent=2) + "\n")
|
||||||
_write_layout_graph(ctx.ws, ctx.project_id)
|
_write_layout_graph(ctx.ws, ctx.project_id)
|
||||||
|
_write_impedance_nets(ctx.ws, ctx.graph)
|
||||||
|
|
||||||
broker.publish(ctx.project_id, "step_update",
|
broker.publish(ctx.project_id, "step_update",
|
||||||
{"stage": "graph_build", "status": "complete",
|
{"stage": "graph_build", "status": "complete",
|
||||||
@@ -2110,6 +2135,7 @@ async def run_regen_pipeline(
|
|||||||
graph_path = ws.local_path("design_graph.json")
|
graph_path = ws.local_path("design_graph.json")
|
||||||
graph_path.write_text(graph.model_dump_json(indent=2) + "\n")
|
graph_path.write_text(graph.model_dump_json(indent=2) + "\n")
|
||||||
_write_layout_graph(ws, project_id)
|
_write_layout_graph(ws, project_id)
|
||||||
|
_write_impedance_nets(ws, graph)
|
||||||
|
|
||||||
broker.publish(project_id, "step_update",
|
broker.publish(project_id, "step_update",
|
||||||
{"stage": "graph_build", "status": "complete",
|
{"stage": "graph_build", "status": "complete",
|
||||||
|
|||||||
@@ -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** |
|
| 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** |
|
| 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** |
|
| 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** |
|
| 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 Elettrico | Pin mux; I2C/reset pull-up; LED; sequencing/IR/power margin se c’è il parametro | Senza numero in specs → skip | **OK** |
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
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
|
## 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.
|
`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.
|
||||||
|
|||||||
@@ -347,7 +347,9 @@ export default function ProjectDetailPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "impedance" && <ImpedancePanel />}
|
{tab === "impedance" && (
|
||||||
|
<ImpedancePanel projectId={id} hasPcb={Boolean(project.hasPcb)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "logs" && (
|
{tab === "logs" && (
|
||||||
<ApiLogsSection logs={logs} />
|
<ApiLogsSection logs={logs} />
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { computeImpedance } from "@/lib/api";
|
import {
|
||||||
|
analyzeImpedanceNets,
|
||||||
|
computeImpedance,
|
||||||
|
fetchImpedanceNets,
|
||||||
|
} from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
ImpedanceKind,
|
ImpedanceKind,
|
||||||
|
ImpedanceNetsReport,
|
||||||
ImpedanceStackupResult,
|
ImpedanceStackupResult,
|
||||||
ImpedanceTraceResult,
|
ImpedanceTraceResult,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
@@ -21,7 +26,13 @@ function fmt(n: number | null | undefined, digits = 2): string {
|
|||||||
return n.toFixed(digits);
|
return n.toFixed(digits);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImpedancePanel() {
|
export function ImpedancePanel({
|
||||||
|
projectId,
|
||||||
|
hasPcb,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
hasPcb: boolean;
|
||||||
|
}) {
|
||||||
const [kind, setKind] = useState<ImpedanceKind>("microstrip");
|
const [kind, setKind] = useState<ImpedanceKind>("microstrip");
|
||||||
const [h, setH] = useState("0.20");
|
const [h, setH] = useState("0.20");
|
||||||
const [er, setEr] = useState("4.5");
|
const [er, setEr] = useState("4.5");
|
||||||
@@ -33,6 +44,22 @@ export function ImpedancePanel() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [trace, setTrace] = useState<ImpedanceTraceResult | null>(null);
|
const [trace, setTrace] = useState<ImpedanceTraceResult | null>(null);
|
||||||
const [stackup, setStackup] = useState<ImpedanceStackupResult | 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() {
|
async function runTrace() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -90,6 +117,23 @@ export function ImpedancePanel() {
|
|||||||
URL.revokeObjectURL(url);
|
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";
|
const needsGap = kind === "cpw" || kind === "diff";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,9 +144,9 @@ export function ImpedancePanel() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
IPC-2141 / Hammerstad–Jensen from ImpedenceFinder (same as KiCad
|
ImpedenceFinder (Hammerstad–Jensen). The calculator is advice only.
|
||||||
pcb_calculator). Advice only — this tab does not sample the PCB
|
With a `.kicad_pcb` and stackup, a pipeline run samples routed signal
|
||||||
and does not emit findings. CPWG is not implemented.
|
nets. CPWG is not implemented upstream.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
@@ -175,6 +219,72 @@ export function ImpedancePanel() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>min–max</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 && (
|
{stackup && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
DesignGraph,
|
DesignGraph,
|
||||||
DeratingRow,
|
DeratingRow,
|
||||||
ImpedanceKind,
|
ImpedanceKind,
|
||||||
|
ImpedanceNetsReport,
|
||||||
ImpedanceStackupResult,
|
ImpedanceStackupResult,
|
||||||
ImpedanceTraceResult,
|
ImpedanceTraceResult,
|
||||||
EdifSubDesign,
|
EdifSubDesign,
|
||||||
@@ -609,6 +610,43 @@ export async function computeImpedance(body: {
|
|||||||
return res.json();
|
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(
|
export async function fetchReport(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<ValidationReport> {
|
): Promise<ValidationReport> {
|
||||||
|
|||||||
@@ -364,6 +364,27 @@ export interface ImpedanceStackupResult {
|
|||||||
kicad_dru: string;
|
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 {
|
export interface NetlistPreviewDesignator {
|
||||||
ref: string;
|
ref: string;
|
||||||
pins: { number: string; net_name: string }[];
|
pins: { number: string; net_name: string }[];
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user