Integrate ImpedenceFinder closed-form Z0 into the project Impedance tab.
Use the vendored Hammerstad-Jensen/Cohn engine for microstrip, stripline, and coupled-diff advice. Skip OpenEMS/pcbnew and refuse CPWG rather than inventing a number. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,10 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from backend.vendor_path import ensure_impedancefinder
|
||||
|
||||
ensure_impedancefinder()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_llm_post_passes(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Synthetic fixtures for the pure engine — no board_model, no pcbnew."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from impedancefinder.model import DielectricLayer, Point2D, Stackup, TraceSegment, ZonePolygon
|
||||
|
||||
|
||||
def rect(x0: float, y0: float, x1: float, y1: float) -> tuple[Point2D, ...]:
|
||||
return (Point2D(x0, y0), Point2D(x1, y0), Point2D(x1, y1), Point2D(x0, y1))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stackup_4layer() -> Stackup:
|
||||
return Stackup(
|
||||
copper_layer_names=("F.Cu", "In1.Cu", "In2.Cu", "B.Cu"),
|
||||
dielectrics=(
|
||||
DielectricLayer("prepreg_top", 4.3, 0.15),
|
||||
DielectricLayer("core", 4.4, 0.7),
|
||||
DielectricLayer("prepreg_bottom", 4.3, 0.15),
|
||||
),
|
||||
copper_thickness_mm=0.035,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def full_ground_plane() -> ZonePolygon:
|
||||
"""A ground pour on In1.Cu with no voids, spanning the whole test area."""
|
||||
return ZonePolygon(net="GND", layer="In1.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def split_ground_plane() -> ZonePolygon:
|
||||
"""A ground pour on In1.Cu with a gap between x=4mm and x=6mm."""
|
||||
return ZonePolygon(
|
||||
net="GND",
|
||||
layer="In1.Cu",
|
||||
outlines_mm=(rect(-5, -5, 4, 5), rect(6, -5, 50, 5)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_run_segment() -> TraceSegment:
|
||||
return TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(10, 0), width_mm=0.2)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neckdown_segments() -> tuple[TraceSegment, ...]:
|
||||
"""A trace that narrows partway along its run."""
|
||||
return (
|
||||
TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(5, 0), width_mm=0.3),
|
||||
TraceSegment(net="SIG", layer="F.Cu", start=Point2D(5, 0), end=Point2D(10, 0), width_mm=0.12),
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from impedancefinder import geometry, net_walk, planes
|
||||
from impedancefinder.model import Point2D, SamplePoint, Stackup, Topology, TraceSegment, ZonePolygon
|
||||
|
||||
from .conftest import rect
|
||||
|
||||
|
||||
def _sample(layer: str = "F.Cu", x_mm: float = 0.0, width_mm: float = 0.2) -> SamplePoint:
|
||||
return SamplePoint(net="SIG", layer=layer, distance_along_net_mm=x_mm, position=Point2D(x_mm, 0), width_mm=width_mm)
|
||||
|
||||
|
||||
def test_classify_outer_layer_with_plane_is_microstrip(stackup_4layer, full_ground_plane):
|
||||
sample = _sample()
|
||||
context = planes.resolve_reference_planes(stackup_4layer, (full_ground_plane,), sample)
|
||||
topology = geometry.classify_topology(sample, stackup_4layer, (full_ground_plane,), context)
|
||||
assert topology is Topology.MICROSTRIP
|
||||
|
||||
|
||||
def test_classify_inner_layer_is_stripline(stackup_4layer):
|
||||
top_plane = ZonePolygon(net="GND", layer="F.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||
bottom_plane = ZonePolygon(net="GND", layer="In2.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||
sample = _sample(layer="In1.Cu", width_mm=0.15)
|
||||
zones = (top_plane, bottom_plane)
|
||||
context = planes.resolve_reference_planes(stackup_4layer, zones, sample)
|
||||
topology = geometry.classify_topology(sample, stackup_4layer, zones, context)
|
||||
assert topology is Topology.STRIPLINE
|
||||
|
||||
|
||||
def test_classify_with_no_adjacent_copper_layer_is_unknown():
|
||||
# A genuine single-copper-layer board: no adjacent layer can exist at
|
||||
# all, unlike a 2-layer board whose reference plane merely has a void
|
||||
# (which still counts as "a plane" for classification, just flagged).
|
||||
stackup = Stackup(copper_layer_names=("F.Cu",), dielectrics=())
|
||||
sample = _sample()
|
||||
context = planes.resolve_reference_planes(stackup, (), sample)
|
||||
topology = geometry.classify_topology(sample, stackup, (), context)
|
||||
assert topology is Topology.UNKNOWN
|
||||
|
||||
|
||||
def test_cpwg_classification_surfaces_not_implemented_flag(stackup_4layer, full_ground_plane):
|
||||
# Coplanar ground pour on the trace's own layer, close enough to count.
|
||||
coplanar_gnd = ZonePolygon(net="GND", layer="F.Cu", outlines_mm=(rect(0.3, -5, 50, 5),))
|
||||
sample = _sample()
|
||||
zones = (full_ground_plane, coplanar_gnd)
|
||||
context = planes.resolve_reference_planes(stackup_4layer, zones, sample)
|
||||
topology = geometry.classify_topology(sample, stackup_4layer, zones, context)
|
||||
assert topology is Topology.COPLANAR_GROUNDED
|
||||
|
||||
impedance_sample = geometry.compute_sample_impedance(sample, stackup_4layer, context, topology)
|
||||
assert impedance_sample.z0_ohms is None
|
||||
assert "topology_not_supported" in impedance_sample.flags
|
||||
|
||||
|
||||
def _flat_samples(branches):
|
||||
return [sample for branch in branches for sample in branch.samples]
|
||||
|
||||
|
||||
def test_clean_microstrip_run_has_no_flags(stackup_4layer, full_ground_plane, clean_run_segment):
|
||||
branches = net_walk.sample_net((clean_run_segment,), pitch_mm=2.0)
|
||||
results = [geometry.analyze_sample(s, stackup_4layer, (full_ground_plane,)) for s in _flat_samples(branches)]
|
||||
assert results # sanity: the fixture actually produced samples
|
||||
assert all(not result.flags for result in results)
|
||||
assert all(result.topology is Topology.MICROSTRIP for result in results)
|
||||
|
||||
|
||||
def test_neckdown_is_caught_as_a_higher_impedance(stackup_4layer, full_ground_plane, neckdown_segments):
|
||||
branches = net_walk.sample_net(neckdown_segments, pitch_mm=1.0)
|
||||
results = [geometry.analyze_sample(s, stackup_4layer, (full_ground_plane,)) for s in _flat_samples(branches)]
|
||||
wide_z0 = [r.z0_ohms for r in results if r.width_mm == 0.3]
|
||||
narrow_z0 = [r.z0_ohms for r in results if r.width_mm == 0.12]
|
||||
assert wide_z0 and narrow_z0
|
||||
assert min(narrow_z0) > max(wide_z0)
|
||||
|
||||
|
||||
def test_void_crossing_trace_is_flagged(stackup_4layer, split_ground_plane, clean_run_segment):
|
||||
branches = net_walk.sample_net((clean_run_segment,), pitch_mm=0.5)
|
||||
results = [geometry.analyze_sample(s, stackup_4layer, (split_ground_plane,)) for s in _flat_samples(branches)]
|
||||
assert any("plane_broken" in result.flags for result in results)
|
||||
|
||||
|
||||
def test_find_pair_net_name_suffix_conventions():
|
||||
assert geometry.find_pair_net_name("USB_D_P") == "USB_D_N"
|
||||
assert geometry.find_pair_net_name("USB_D_N") == "USB_D_P"
|
||||
assert geometry.find_pair_net_name("D+") == "D-"
|
||||
assert geometry.find_pair_net_name("D-") == "D+"
|
||||
|
||||
|
||||
def test_find_pair_net_name_returns_none_for_unpaired_nets():
|
||||
assert geometry.find_pair_net_name("GND") is None
|
||||
assert geometry.find_pair_net_name("3V3") is None
|
||||
|
||||
|
||||
def test_differential_sample_impedance_is_between_single_and_twice_single(
|
||||
stackup_4layer, full_ground_plane
|
||||
):
|
||||
# Two parallel vertical traces 0.4mm apart center-to-center, 0.2mm wide
|
||||
# each -> 0.2mm edge-to-edge gap.
|
||||
sample = SamplePoint(
|
||||
net="D_P", layer="F.Cu", distance_along_net_mm=0, position=Point2D(0, 5), width_mm=0.2
|
||||
)
|
||||
partner_segments = (
|
||||
TraceSegment(net="D_N", layer="F.Cu", start=Point2D(0.4, 0), end=Point2D(0.4, 10), width_mm=0.2),
|
||||
)
|
||||
single_ended = geometry.analyze_sample(sample, stackup_4layer, (full_ground_plane,))
|
||||
differential = geometry.analyze_differential_sample(
|
||||
sample, partner_segments, stackup_4layer, (full_ground_plane,)
|
||||
)
|
||||
assert single_ended.z0_ohms < differential.z0_ohms < 2 * single_ended.z0_ohms
|
||||
|
||||
|
||||
def test_differential_sample_falls_back_to_single_ended_with_no_partner_segments(
|
||||
stackup_4layer, full_ground_plane
|
||||
):
|
||||
sample = SamplePoint(
|
||||
net="D_P", layer="F.Cu", distance_along_net_mm=0, position=Point2D(0, 5), width_mm=0.2
|
||||
)
|
||||
single_ended = geometry.analyze_sample(sample, stackup_4layer, (full_ground_plane,))
|
||||
differential = geometry.analyze_differential_sample(sample, (), stackup_4layer, (full_ground_plane,))
|
||||
assert differential.z0_ohms == single_ended.z0_ohms
|
||||
|
||||
|
||||
def test_differential_sample_falls_back_to_single_ended_when_partner_is_far_away(
|
||||
stackup_4layer, full_ground_plane
|
||||
):
|
||||
# Partner net exists but its nearest point is 50mm away -- clearly not a
|
||||
# coupled pair at this sample, e.g. before the pair converges.
|
||||
sample = SamplePoint(
|
||||
net="D_P", layer="F.Cu", distance_along_net_mm=0, position=Point2D(0, 5), width_mm=0.2
|
||||
)
|
||||
far_partner = (
|
||||
TraceSegment(net="D_N", layer="F.Cu", start=Point2D(50, 0), end=Point2D(50, 10), width_mm=0.2),
|
||||
)
|
||||
single_ended = geometry.analyze_sample(sample, stackup_4layer, (full_ground_plane,))
|
||||
differential = geometry.analyze_differential_sample(
|
||||
sample, far_partner, stackup_4layer, (full_ground_plane,)
|
||||
)
|
||||
assert differential.z0_ohms == single_ended.z0_ohms
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from impedancefinder import net_walk
|
||||
from impedancefinder.model import Point2D, TraceSegment
|
||||
|
||||
|
||||
def test_pitch_must_be_positive():
|
||||
with pytest.raises(ValueError):
|
||||
net_walk.sample_net((), pitch_mm=0.0)
|
||||
|
||||
|
||||
def test_bend_chains_into_one_continuous_branch():
|
||||
# Two segments sharing an exact endpoint at (5, 0) -- a bend, not a via.
|
||||
first = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(5, 0), width_mm=0.2)
|
||||
second = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(5, 0), end=Point2D(5, 5), width_mm=0.2)
|
||||
branches = net_walk.sample_net((first, second), pitch_mm=1.0)
|
||||
assert len(branches) == 1
|
||||
distances = [s.distance_along_net_mm for s in branches[0].samples]
|
||||
assert distances == sorted(distances)
|
||||
assert distances[0] == 0.0
|
||||
assert distances[-1] == pytest.approx(10.0) # 5mm + 5mm, continuous
|
||||
|
||||
|
||||
def test_via_like_layer_change_stays_continuous():
|
||||
# A segment on F.Cu ending exactly where a segment on In1.Cu begins --
|
||||
# this is what a via looks like geometrically, with no ViaSpan needed
|
||||
# for net_walk to treat it as one continuous run.
|
||||
top = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(3, 0), width_mm=0.2)
|
||||
bottom = TraceSegment(net="SIG", layer="In1.Cu", start=Point2D(3, 0), end=Point2D(7, 0), width_mm=0.2)
|
||||
branches = net_walk.sample_net((top, bottom), pitch_mm=1.0)
|
||||
assert len(branches) == 1
|
||||
assert branches[0].samples[-1].distance_along_net_mm == pytest.approx(7.0)
|
||||
|
||||
|
||||
def test_t_junction_splits_into_three_branches_zeroed_at_the_junction():
|
||||
junction = Point2D(0, 0)
|
||||
spoke_a = TraceSegment(net="SIG", layer="F.Cu", start=junction, end=Point2D(3, 0), width_mm=0.2)
|
||||
spoke_b = TraceSegment(net="SIG", layer="F.Cu", start=junction, end=Point2D(0, 4), width_mm=0.2)
|
||||
spoke_c = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(-5, 0), end=junction, width_mm=0.2)
|
||||
branches = net_walk.sample_net((spoke_a, spoke_b, spoke_c), pitch_mm=1.0)
|
||||
assert len(branches) == 3
|
||||
lengths = sorted(branch.samples[-1].distance_along_net_mm for branch in branches)
|
||||
assert lengths == pytest.approx([3.0, 4.0, 5.0])
|
||||
# every branch must start at the junction, not at its far leaf
|
||||
assert all(branch.samples[0].distance_along_net_mm == 0.0 for branch in branches)
|
||||
|
||||
|
||||
def test_disconnected_segments_become_separate_branches():
|
||||
isolated_a = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(2, 0), width_mm=0.2)
|
||||
isolated_b = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(100, 0), end=Point2D(103, 0), width_mm=0.2)
|
||||
branches = net_walk.sample_net((isolated_a, isolated_b), pitch_mm=1.0)
|
||||
assert len(branches) == 2
|
||||
lengths = sorted(branch.samples[-1].distance_along_net_mm for branch in branches)
|
||||
assert lengths == pytest.approx([2.0, 3.0])
|
||||
|
||||
|
||||
def test_zero_length_segment_produces_a_single_sample():
|
||||
point_segment = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(1, 1), end=Point2D(1, 1), width_mm=0.2)
|
||||
branches = net_walk.sample_net((point_segment,), pitch_mm=1.0)
|
||||
assert len(branches) == 1
|
||||
assert len(branches[0].samples) == 1
|
||||
assert branches[0].samples[0].distance_along_net_mm == 0.0
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from impedancefinder import planes
|
||||
from impedancefinder.model import Point2D, SamplePoint, ZonePolygon
|
||||
|
||||
from .conftest import rect
|
||||
|
||||
|
||||
def _sample_at(x_mm: float, width_mm: float = 0.2) -> SamplePoint:
|
||||
return SamplePoint(
|
||||
net="SIG", layer="F.Cu", distance_along_net_mm=x_mm, position=Point2D(x_mm, 0), width_mm=width_mm
|
||||
)
|
||||
|
||||
|
||||
def test_full_coverage_has_no_flags(full_ground_plane):
|
||||
sample = _sample_at(2.0)
|
||||
coverage = planes.coverage_at(sample, (full_ground_plane,), "In1.Cu")
|
||||
assert coverage.is_covered
|
||||
assert planes._flags_for(coverage, sample.width_mm) == ()
|
||||
|
||||
|
||||
def test_void_directly_under_trace_flags_broken(split_ground_plane):
|
||||
sample = _sample_at(5.0) # inside the 4..6mm gap
|
||||
coverage = planes.coverage_at(sample, (split_ground_plane,), "In1.Cu")
|
||||
assert not coverage.is_covered
|
||||
assert planes._flags_for(coverage, sample.width_mm) == ("plane_broken",)
|
||||
|
||||
|
||||
def test_void_near_but_not_under_trace_flags_proximity_only(split_ground_plane):
|
||||
sample = _sample_at(3.9, width_mm=0.5) # covered, close to the gap edge at x=4
|
||||
coverage = planes.coverage_at(sample, (split_ground_plane,), "In1.Cu")
|
||||
assert coverage.is_covered
|
||||
assert planes._flags_for(coverage, sample.width_mm) == ("plane_split_nearby",)
|
||||
|
||||
|
||||
def test_far_from_void_has_no_proximity_flag(split_ground_plane):
|
||||
sample = _sample_at(0.0)
|
||||
coverage = planes.coverage_at(sample, (split_ground_plane,), "In1.Cu")
|
||||
assert coverage.is_covered
|
||||
assert planes._flags_for(coverage, sample.width_mm) == ()
|
||||
|
||||
|
||||
def test_missing_plane_layer_reports_uncovered_not_a_crash():
|
||||
sample = _sample_at(0.0)
|
||||
coverage = planes.coverage_at(sample, (), "In1.Cu")
|
||||
assert not coverage.is_covered
|
||||
assert coverage.distance_to_void_mm is None
|
||||
|
||||
|
||||
def test_resolve_reference_planes_outer_layer_has_only_below(stackup_4layer, full_ground_plane):
|
||||
sample = _sample_at(2.0)
|
||||
context = planes.resolve_reference_planes(stackup_4layer, (full_ground_plane,), sample)
|
||||
assert context.above is None
|
||||
assert context.below is not None
|
||||
assert context.reference_plane_count == 1
|
||||
|
||||
|
||||
def test_resolve_reference_planes_inner_layer_has_both(stackup_4layer):
|
||||
top_plane = ZonePolygon(net="GND", layer="F.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||
bottom_plane = ZonePolygon(net="GND", layer="In2.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||
sample = SamplePoint(
|
||||
net="SIG", layer="In1.Cu", distance_along_net_mm=0, position=Point2D(0, 0), width_mm=0.15
|
||||
)
|
||||
context = planes.resolve_reference_planes(stackup_4layer, (top_plane, bottom_plane), sample)
|
||||
assert context.above is not None and context.above.is_covered
|
||||
assert context.below is not None and context.below.is_covered
|
||||
assert context.reference_plane_count == 2
|
||||
|
||||
|
||||
def test_exclude_net_ignores_the_traces_own_copper():
|
||||
own_net_pour = ZonePolygon(net="SIG", layer="F.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||
sample = _sample_at(0.0)
|
||||
coverage = planes.coverage_at(sample, (own_net_pour,), "F.Cu", exclude_net="SIG")
|
||||
assert not coverage.is_covered
|
||||
assert coverage.distance_to_void_mm is None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""zsolver validation: a published reference point plus monotonicity checks
|
||||
against the underlying physics, so the tests don't just re-derive whatever
|
||||
the implementation happens to compute.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from impedancefinder import zsolver
|
||||
|
||||
|
||||
def test_microstrip_matches_classic_50ohm_fr4_rule_of_thumb():
|
||||
# ~3mm trace on 1.6mm FR4 (er~4.5) is the textbook "50 ohm microstrip"
|
||||
# widely quoted in PCB fab application notes.
|
||||
z0 = zsolver.microstrip_z0(width_mm=3.0, height_mm=1.6, er=4.5, t_mm=0.035)
|
||||
assert z0 == pytest.approx(50.0, rel=0.05)
|
||||
|
||||
|
||||
def test_microstrip_z0_decreases_with_width():
|
||||
narrow = zsolver.microstrip_z0(0.2, 0.15, 4.3, 0.035)
|
||||
wide = zsolver.microstrip_z0(0.6, 0.15, 4.3, 0.035)
|
||||
assert wide < narrow
|
||||
|
||||
|
||||
def test_microstrip_z0_increases_with_dielectric_height():
|
||||
thin = zsolver.microstrip_z0(0.3, 0.1, 4.3, 0.035)
|
||||
thick = zsolver.microstrip_z0(0.3, 0.3, 4.3, 0.035)
|
||||
assert thick > thin
|
||||
|
||||
|
||||
def test_microstrip_z0_decreases_with_er():
|
||||
low_er = zsolver.microstrip_z0(0.3, 0.15, 3.0, 0.035)
|
||||
high_er = zsolver.microstrip_z0(0.3, 0.15, 5.0, 0.035)
|
||||
assert high_er < low_er
|
||||
|
||||
|
||||
def test_microstrip_z0_finite_thickness_correction_is_a_small_effect():
|
||||
with_thickness = zsolver.microstrip_z0(0.3, 0.15, 4.3, 0.035)
|
||||
without_thickness = zsolver.microstrip_z0(0.3, 0.15, 4.3, 0.0)
|
||||
assert without_thickness == pytest.approx(with_thickness, rel=0.15)
|
||||
|
||||
|
||||
def test_stripline_requires_positive_copper_thickness():
|
||||
with pytest.raises(ValueError):
|
||||
zsolver.stripline_z0(0.15, 0.3, 4.4, 0.0)
|
||||
|
||||
|
||||
def test_stripline_z0_decreases_with_width():
|
||||
narrow = zsolver.stripline_z0(0.1, 0.5, 4.4, 0.035)
|
||||
wide = zsolver.stripline_z0(0.3, 0.5, 4.4, 0.035)
|
||||
assert wide < narrow
|
||||
|
||||
|
||||
def test_stripline_z0_increases_with_plane_spacing():
|
||||
tight = zsolver.stripline_z0(0.15, 0.3, 4.4, 0.035)
|
||||
loose = zsolver.stripline_z0(0.15, 0.6, 4.4, 0.035)
|
||||
assert loose > tight
|
||||
|
||||
|
||||
def test_diff_microstrip_is_between_single_ended_and_twice_single_ended():
|
||||
single = zsolver.microstrip_z0(0.2, 0.15, 4.3, 0.035)
|
||||
diff = zsolver.diff_microstrip_z0(0.2, 0.15, 0.2, 4.3, 0.035)
|
||||
assert single < diff < 2 * single
|
||||
|
||||
|
||||
def test_diff_microstrip_approaches_twice_single_ended_as_spacing_grows():
|
||||
single = zsolver.microstrip_z0(0.2, 0.15, 4.3, 0.035)
|
||||
wide_gap = zsolver.diff_microstrip_z0(0.2, 0.15, 5.0, 4.3, 0.035)
|
||||
assert wide_gap == pytest.approx(2 * single, rel=0.02)
|
||||
|
||||
|
||||
def test_diff_stripline_approaches_twice_single_ended_as_spacing_grows():
|
||||
single = zsolver.stripline_z0(0.15, 0.3, 4.4, 0.035)
|
||||
wide_gap = zsolver.diff_stripline_z0(0.15, 0.3, 5.0, 4.4, 0.035)
|
||||
assert wide_gap == pytest.approx(2 * single, rel=0.02)
|
||||
|
||||
|
||||
def test_cpwg_is_not_implemented():
|
||||
with pytest.raises(NotImplementedError):
|
||||
zsolver.cpwg_z0()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""D1 impedance — ImpedenceFinder closed forms, no second formula set.
|
||||
|
||||
Favor: Pinscope Z0 equals vendored ImpedenceFinder bit-for-bit; classic
|
||||
3 mm / 1.6 mm FR4 is ~50 Ω; solve_width round-trips.
|
||||
Against: h<=0 invents nothing; CPWG stays unimplemented; stripline t=0
|
||||
raises; calculator emits no findings. OpenEMS is not imported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from impedancefinder import zsolver as ifz
|
||||
from backend.pinscopex.impedance import (
|
||||
GeometryError,
|
||||
TraceGeometry,
|
||||
coupled_diff_z,
|
||||
cpw_z0,
|
||||
export_kicad_dru,
|
||||
microstrip_z0,
|
||||
solve_width,
|
||||
stackup_targets,
|
||||
stripline_z0,
|
||||
)
|
||||
|
||||
|
||||
def test_microstrip_matches_impedancefinder_bit_for_bit():
|
||||
geo = TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.30)
|
||||
ours = microstrip_z0(geo)
|
||||
theirs = ifz.microstrip_z0(0.30, 0.15, 4.3, 0.035)
|
||||
assert ours == theirs
|
||||
|
||||
|
||||
def test_classic_fr4_50ohm_rule_of_thumb():
|
||||
z = microstrip_z0(TraceGeometry(h=1.6, er=4.5, t=0.035, w=3.0))
|
||||
assert z == pytest.approx(50.0, rel=0.05)
|
||||
|
||||
|
||||
def test_microstrip_zero_height_does_not_invent_z():
|
||||
with pytest.raises(GeometryError):
|
||||
microstrip_z0(TraceGeometry(h=0.0, er=4.5, t=0.035, w=0.35))
|
||||
|
||||
|
||||
def test_stripline_matches_impedancefinder():
|
||||
geo = TraceGeometry(h=0.5, er=4.4, t=0.035, w=0.15)
|
||||
assert stripline_z0(geo) == ifz.stripline_z0(0.15, 0.5, 4.4, 0.035)
|
||||
|
||||
|
||||
def test_stripline_zero_thickness_does_not_invent_z():
|
||||
with pytest.raises(GeometryError):
|
||||
stripline_z0(TraceGeometry(h=0.4, er=4.5, t=0.0, w=0.12))
|
||||
|
||||
|
||||
def test_stripline_missing_width_is_invalid():
|
||||
with pytest.raises(GeometryError):
|
||||
stripline_z0(TraceGeometry(h=0.4, er=4.5, t=0.035, w=None))
|
||||
|
||||
|
||||
def test_diff_matches_impedancefinder():
|
||||
geo = TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.20)
|
||||
_, _, zdiff = coupled_diff_z(geo)
|
||||
assert zdiff == ifz.diff_microstrip_z0(0.20, 0.15, 0.20, 4.3, 0.035)
|
||||
|
||||
|
||||
def test_wider_gap_raises_zdiff():
|
||||
tight = coupled_diff_z(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.08))
|
||||
loose = coupled_diff_z(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.40))
|
||||
assert loose[2] > tight[2]
|
||||
|
||||
|
||||
def test_coupled_diff_without_gap_is_invalid():
|
||||
with pytest.raises(GeometryError):
|
||||
coupled_diff_z(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=None))
|
||||
|
||||
|
||||
def test_cpwg_is_not_invented():
|
||||
with pytest.raises(GeometryError, match="not implemented"):
|
||||
cpw_z0(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.15))
|
||||
|
||||
|
||||
def test_solve_width_roundtrips_50_ohm_microstrip():
|
||||
w = solve_width("microstrip", target_z=50.0, h=1.6, er=4.5, t=0.035)
|
||||
z = microstrip_z0(TraceGeometry(h=1.6, er=4.5, t=0.035, w=w))
|
||||
assert z == pytest.approx(50.0, rel=0.01)
|
||||
assert w > 0
|
||||
|
||||
|
||||
def test_solve_width_rejects_non_positive_target():
|
||||
with pytest.raises(GeometryError):
|
||||
solve_width("microstrip", target_z=0.0, h=1.6, er=4.5, t=0.035)
|
||||
|
||||
|
||||
def test_stackup_suggests_50_90_100_without_findings():
|
||||
out = stackup_targets(h=0.20, er=4.5, t=0.035, s=0.20)
|
||||
assert out["microstrip_50"].z0 == pytest.approx(50.0, rel=0.02)
|
||||
assert out["diff_90"].zdiff == pytest.approx(90.0, rel=0.02)
|
||||
assert out["diff_100"].zdiff == pytest.approx(100.0, rel=0.02)
|
||||
assert "finding" not in out
|
||||
|
||||
|
||||
def test_stackup_rejects_non_positive_h():
|
||||
with pytest.raises(GeometryError):
|
||||
stackup_targets(h=0.0, er=4.5, t=0.035, s=0.2)
|
||||
|
||||
|
||||
def test_kicad_dru_is_advice_not_a_finding():
|
||||
dru = export_kicad_dru(stackup_targets(h=0.20, er=4.5, t=0.035, s=0.20))
|
||||
assert "(rule PINSCOPE_50OHM" in dru
|
||||
assert "PS-Z" not in dru
|
||||
|
||||
|
||||
def _impedance_client():
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_api_microstrip_equals_impedancefinder():
|
||||
res = _impedance_client().post("/api/impedance", json={
|
||||
"mode": "trace",
|
||||
"kind": "microstrip",
|
||||
"h": 1.6, "er": 4.5, "t": 0.035, "w": 3.0,
|
||||
})
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["z0"] == ifz.microstrip_z0(3.0, 1.6, 4.5, 0.035)
|
||||
assert "findings" not in body
|
||||
|
||||
|
||||
def test_api_zero_height_is_400():
|
||||
res = _impedance_client().post("/api/impedance", json={
|
||||
"mode": "trace",
|
||||
"kind": "microstrip",
|
||||
"h": 0, "er": 4.5, "t": 0.035, "w": 0.35,
|
||||
})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_api_cpw_is_400_not_a_fake_number():
|
||||
res = _impedance_client().post("/api/impedance", json={
|
||||
"mode": "trace",
|
||||
"kind": "cpw",
|
||||
"h": 0.15, "er": 4.3, "t": 0.035, "w": 0.2, "s": 0.15,
|
||||
})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_openems_is_not_on_the_impedancefinder_package():
|
||||
import impedancefinder
|
||||
import pkgutil
|
||||
|
||||
names = {m.name for m in pkgutil.iter_modules(impedancefinder.__path__)}
|
||||
assert "gerber2ems_export" not in names
|
||||
assert "board_model" not in names
|
||||
|
||||
|
||||
def test_api_stackup_returns_dru_not_findings():
|
||||
res = _impedance_client().post("/api/impedance", json={
|
||||
"mode": "stackup",
|
||||
"h": 0.20, "er": 4.5, "t": 0.035, "s": 0.20,
|
||||
})
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["targets"]["microstrip_50"]["z0"] == pytest.approx(50.0, rel=0.02)
|
||||
assert "(rule PINSCOPE_50OHM" in body["kicad_dru"]
|
||||
assert "findings" not in body
|
||||
Reference in New Issue
Block a user