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>
139 lines
6.5 KiB
Python
139 lines
6.5 KiB
Python
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
|