Rename the core package to periscopex, update UI/docs/Docker/deploy defaults to periscope.michelebigi.it, and keep legacy version/storage key aliases so existing projects keep working. Co-authored-by: Cursor <cursoragent@cursor.com>
195 lines
6.3 KiB
Python
195 lines
6.3 KiB
Python
"""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.periscopex.impedance import GeometryError
|
|
from backend.periscopex.impedance_traces import (
|
|
analyze_specified_nets,
|
|
analyze_where_needed,
|
|
)
|
|
from backend.periscopex.models import (
|
|
DesignGraph,
|
|
LayoutDielectric,
|
|
LayoutGraph,
|
|
LayoutSegment,
|
|
LayoutStackup,
|
|
LayoutZone,
|
|
Net,
|
|
NetType,
|
|
)
|
|
from backend.periscopex.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"
|