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
+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"