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