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>
210 lines
8.0 KiB
Python
210 lines
8.0 KiB
Python
"""Topology classification and dispatch to the closed-form solvers.
|
|
|
|
Classifies each sample as microstrip, stripline, or grounded-coplanar
|
|
(CPWG) from the vertical plane structure (planes.py) and, for CPWG, a
|
|
same-layer copper-proximity heuristic — then calls the matching zsolver
|
|
function.
|
|
|
|
Differential pairs are supported via analyze_differential_sample: pairing is
|
|
name-based (NET_P/NET_N or NET+/NET-), and the edge-to-edge spacing is
|
|
measured geometrically against the nearest point on the partner net's
|
|
segments — there's no assumption that the two nets share a common,
|
|
continuous distance axis (net_walk.py's per-segment sampling doesn't
|
|
guarantee that yet; see its module docstring).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
from impedancefinder import planes, zsolver
|
|
from impedancefinder.model import (
|
|
ImpedanceSample,
|
|
Point2D,
|
|
SamplePoint,
|
|
Stackup,
|
|
Topology,
|
|
TraceSegment,
|
|
ZonePolygon,
|
|
)
|
|
from impedancefinder.planes import PlaneContext
|
|
|
|
# Same-layer copper (a different net) closer than this many trace-widths is
|
|
# treated as a coplanar ground gap, i.e. CPWG rather than plain microstrip.
|
|
_CPWG_GAP_WIDTH_MULTIPLE = 5.0
|
|
|
|
# NET<positive> pairs with NET<negative>, tried in order; the first suffix
|
|
# match wins (checked longest-first isn't needed since "_P"/"_N" and "+"/"-"
|
|
# can't collide on the same net name).
|
|
_DIFF_PAIR_SUFFIX_PAIRS = (("_P", "_N"), ("+", "-"))
|
|
|
|
# A same-named-pair net whose nearest routed point is farther than this many
|
|
# trace-widths away isn't genuinely coupled here (e.g. before the pair
|
|
# converges near a connector) -- treat the sample as single-ended instead.
|
|
_DIFF_PAIR_MAX_GAP_WIDTH_MULTIPLE = 10.0
|
|
|
|
|
|
def classify_topology(
|
|
sample: SamplePoint,
|
|
stackup: Stackup,
|
|
zone_polygons: tuple[ZonePolygon, ...],
|
|
context: PlaneContext,
|
|
) -> Topology:
|
|
if context.reference_plane_count == 0:
|
|
return Topology.UNKNOWN
|
|
if not stackup.is_outer_layer(sample.layer):
|
|
return Topology.STRIPLINE
|
|
if _has_coplanar_ground(sample, zone_polygons):
|
|
return Topology.COPLANAR_GROUNDED
|
|
return Topology.MICROSTRIP
|
|
|
|
|
|
def _has_coplanar_ground(sample: SamplePoint, zone_polygons: tuple[ZonePolygon, ...]) -> bool:
|
|
gap = planes.coverage_at(
|
|
sample, zone_polygons, sample.layer, exclude_net=sample.net
|
|
).distance_to_void_mm
|
|
return gap is not None and gap < _CPWG_GAP_WIDTH_MULTIPLE * sample.width_mm
|
|
|
|
|
|
def compute_sample_impedance(
|
|
sample: SamplePoint,
|
|
stackup: Stackup,
|
|
context: PlaneContext,
|
|
topology: Topology,
|
|
spacing_mm: Optional[float] = None,
|
|
) -> ImpedanceSample:
|
|
"""spacing_mm is the edge-to-edge gap to a differential partner trace;
|
|
leave it None for single-ended analysis."""
|
|
z0_ohms, solver_flags = _solve_z0(sample, stackup, context, topology, spacing_mm)
|
|
flags = planes.flags_for_context(context, sample.width_mm) + solver_flags
|
|
return ImpedanceSample(
|
|
distance_along_net_mm=sample.distance_along_net_mm,
|
|
position=sample.position,
|
|
layer=sample.layer,
|
|
width_mm=sample.width_mm,
|
|
topology=topology,
|
|
z0_ohms=z0_ohms,
|
|
flags=flags,
|
|
)
|
|
|
|
|
|
def analyze_sample(
|
|
sample: SamplePoint, stackup: Stackup, zone_polygons: tuple[ZonePolygon, ...]
|
|
) -> ImpedanceSample:
|
|
"""Convenience wrapper: resolve planes, classify, and solve in one call
|
|
— what cli.py and the plugin use per single-ended sample."""
|
|
context = planes.resolve_reference_planes(stackup, zone_polygons, sample)
|
|
topology = classify_topology(sample, stackup, zone_polygons, context)
|
|
return compute_sample_impedance(sample, stackup, context, topology)
|
|
|
|
|
|
def find_pair_net_name(net_name: str) -> Optional[str]:
|
|
"""Guess a differential partner's net name from common KiCad naming
|
|
conventions (NET_P/NET_N, NET+/NET-). Returns None if net_name matches
|
|
neither — callers should then treat it as single-ended."""
|
|
for positive_suffix, negative_suffix in _DIFF_PAIR_SUFFIX_PAIRS:
|
|
if net_name.endswith(positive_suffix):
|
|
return net_name[: -len(positive_suffix)] + negative_suffix
|
|
if net_name.endswith(negative_suffix):
|
|
return net_name[: -len(negative_suffix)] + positive_suffix
|
|
return None
|
|
|
|
|
|
def analyze_differential_sample(
|
|
sample: SamplePoint,
|
|
partner_segments: tuple[TraceSegment, ...],
|
|
stackup: Stackup,
|
|
zone_polygons: tuple[ZonePolygon, ...],
|
|
) -> ImpedanceSample:
|
|
"""Like analyze_sample, but measures the edge-to-edge gap to the nearest
|
|
point on partner_segments (the paired net's routed segments) and
|
|
dispatches to zsolver's diff_* solvers instead of the single-ended
|
|
ones. Falls back to single-ended analysis if partner_segments is empty
|
|
or too far away to plausibly be a coupled pair."""
|
|
context = planes.resolve_reference_planes(stackup, zone_polygons, sample)
|
|
topology = classify_topology(sample, stackup, zone_polygons, context)
|
|
spacing_mm = _nearest_partner_gap_mm(sample, partner_segments)
|
|
return compute_sample_impedance(sample, stackup, context, topology, spacing_mm)
|
|
|
|
|
|
def _nearest_partner_gap_mm(
|
|
sample: SamplePoint, partner_segments: tuple[TraceSegment, ...]
|
|
) -> Optional[float]:
|
|
if not partner_segments:
|
|
return None
|
|
nearest_segment, center_distance_mm = min(
|
|
((segment, _distance_to_segment(sample.position, segment)) for segment in partner_segments),
|
|
key=lambda pair: pair[1],
|
|
)
|
|
gap_mm = max(0.0, center_distance_mm - sample.width_mm / 2.0 - nearest_segment.width_mm / 2.0)
|
|
if gap_mm > _DIFF_PAIR_MAX_GAP_WIDTH_MULTIPLE * sample.width_mm:
|
|
return None
|
|
return gap_mm
|
|
|
|
|
|
def _distance_to_segment(point: Point2D, segment: TraceSegment) -> float:
|
|
return point.distance_to(_nearest_point_on_segment(point, segment))
|
|
|
|
|
|
def _nearest_point_on_segment(point: Point2D, segment: TraceSegment) -> Point2D:
|
|
start, end = segment.start, segment.end
|
|
dx, dy = end.x_mm - start.x_mm, end.y_mm - start.y_mm
|
|
length_sq = dx * dx + dy * dy
|
|
if length_sq == 0:
|
|
return start
|
|
t = ((point.x_mm - start.x_mm) * dx + (point.y_mm - start.y_mm) * dy) / length_sq
|
|
t = max(0.0, min(1.0, t))
|
|
return Point2D(start.x_mm + t * dx, start.y_mm + t * dy)
|
|
|
|
|
|
def _solve_z0(
|
|
sample: SamplePoint,
|
|
stackup: Stackup,
|
|
context: PlaneContext,
|
|
topology: Topology,
|
|
spacing_mm: Optional[float] = None,
|
|
) -> tuple[Optional[float], tuple[str, ...]]:
|
|
try:
|
|
if topology is Topology.MICROSTRIP:
|
|
return _microstrip_z0(sample, stackup, context, spacing_mm), ()
|
|
if topology is Topology.STRIPLINE:
|
|
return _stripline_z0(sample, stackup, context, spacing_mm), ()
|
|
if topology is Topology.COPLANAR_GROUNDED:
|
|
return zsolver.cpwg_z0(), ()
|
|
except NotImplementedError:
|
|
return None, ("topology_not_supported",)
|
|
return None, ("topology_unknown",)
|
|
|
|
|
|
def _microstrip_z0(
|
|
sample: SamplePoint, stackup: Stackup, context: PlaneContext, spacing_mm: Optional[float]
|
|
) -> float:
|
|
dielectric = context.dielectric_below or context.dielectric_above
|
|
if spacing_mm is None:
|
|
return zsolver.microstrip_z0(
|
|
width_mm=sample.width_mm,
|
|
height_mm=dielectric.height_mm,
|
|
er=dielectric.er,
|
|
t_mm=stackup.copper_thickness_mm,
|
|
)
|
|
return zsolver.diff_microstrip_z0(
|
|
width_mm=sample.width_mm,
|
|
height_mm=dielectric.height_mm,
|
|
spacing_mm=spacing_mm,
|
|
er=dielectric.er,
|
|
t_mm=stackup.copper_thickness_mm,
|
|
)
|
|
|
|
|
|
def _stripline_z0(
|
|
sample: SamplePoint, stackup: Stackup, context: PlaneContext, spacing_mm: Optional[float]
|
|
) -> float:
|
|
b_mm = context.dielectric_above.height_mm + context.dielectric_below.height_mm
|
|
er = context.dielectric_below.er # assumes one uniform dielectric between both planes
|
|
if spacing_mm is None:
|
|
return zsolver.stripline_z0(
|
|
width_mm=sample.width_mm, b_mm=b_mm, er=er, t_mm=stackup.copper_thickness_mm
|
|
)
|
|
return zsolver.diff_stripline_z0(
|
|
width_mm=sample.width_mm, b_mm=b_mm, spacing_mm=spacing_mm, er=er, t_mm=stackup.copper_thickness_mm
|
|
)
|