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:
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
Vendored snapshot of https://github.com/manvalan/ImpedenceFinder
|
||||
|
||||
Commit: a0c8d0ec37c9a1b099082926e50a245778ec8d6e
|
||||
|
||||
Included: closed-form core (`zsolver`, `geometry`, `planes`, `model`,
|
||||
`net_walk`, `net_analysis`, `report`).
|
||||
|
||||
Excluded on purpose:
|
||||
- `gerber2ems_export.py`, `prepare_simulation.py`, `crop_board.py`,
|
||||
`simulate_net.sh` (OpenEMS / field-solver export)
|
||||
- `board_model.py` and `plugin/` (pcbnew). Pinscope does not load KiCad's
|
||||
Python; PCB ingest stays in `pinscopex.parsers_kicad_pcb`.
|
||||
Vendored
+209
@@ -0,0 +1,209 @@
|
||||
"""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
|
||||
)
|
||||
Vendored
+226
@@ -0,0 +1,226 @@
|
||||
"""Pure, pcbnew-free domain model for ImpedanceFinder.
|
||||
|
||||
Every value here is a plain dataclass in millimetres. Nothing in this module
|
||||
imports pcbnew, performs I/O, or calls a solver — it only describes shapes
|
||||
that are valid by construction (invalid states can't be built).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Point2D:
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
|
||||
def distance_to(self, other: "Point2D") -> float:
|
||||
return ((self.x_mm - other.x_mm) ** 2 + (self.y_mm - other.y_mm) ** 2) ** 0.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DielectricLayer:
|
||||
name: str
|
||||
er: float
|
||||
height_mm: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.er <= 0:
|
||||
raise ValueError(f"er must be positive, got {self.er}")
|
||||
if self.height_mm <= 0:
|
||||
raise ValueError(f"height_mm must be positive, got {self.height_mm}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Stackup:
|
||||
"""Copper layers top-to-bottom, with one dielectric between each pair."""
|
||||
|
||||
copper_layer_names: tuple[str, ...]
|
||||
dielectrics: tuple[DielectricLayer, ...]
|
||||
copper_thickness_mm: float = 0.035 # 1 oz/ft^2 copper, the common PCB default
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
expected = len(self.copper_layer_names) - 1
|
||||
if len(self.dielectrics) != expected:
|
||||
raise ValueError(
|
||||
f"expected {expected} dielectrics between "
|
||||
f"{len(self.copper_layer_names)} copper layers, "
|
||||
f"got {len(self.dielectrics)}"
|
||||
)
|
||||
if self.copper_thickness_mm <= 0:
|
||||
raise ValueError(f"copper_thickness_mm must be positive, got {self.copper_thickness_mm}")
|
||||
|
||||
def dielectric_between(self, top_layer: str, bottom_layer: str) -> DielectricLayer:
|
||||
top_index = self.copper_layer_names.index(top_layer)
|
||||
bottom_index = self.copper_layer_names.index(bottom_layer)
|
||||
if bottom_index != top_index + 1:
|
||||
raise ValueError(f"{top_layer!r} and {bottom_layer!r} are not adjacent")
|
||||
return self.dielectrics[top_index]
|
||||
|
||||
def is_outer_layer(self, layer_name: str) -> bool:
|
||||
return layer_name in (self.copper_layer_names[0], self.copper_layer_names[-1])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceSegment:
|
||||
net: str
|
||||
layer: str
|
||||
start: Point2D
|
||||
end: Point2D
|
||||
width_mm: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.width_mm <= 0:
|
||||
raise ValueError(f"width_mm must be positive, got {self.width_mm}")
|
||||
|
||||
@property
|
||||
def length_mm(self) -> float:
|
||||
return self.start.distance_to(self.end)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ViaSpan:
|
||||
net: str
|
||||
position: Point2D
|
||||
top_layer: str
|
||||
bottom_layer: str
|
||||
drill_mm: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.drill_mm <= 0:
|
||||
raise ValueError(f"drill_mm must be positive, got {self.drill_mm}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SamplePoint:
|
||||
"""One point along a net's routed length, before plane/impedance
|
||||
analysis has been attached (see planes.py, geometry.py)."""
|
||||
|
||||
net: str
|
||||
layer: str
|
||||
distance_along_net_mm: float
|
||||
position: Point2D
|
||||
width_mm: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.width_mm <= 0:
|
||||
raise ValueError(f"width_mm must be positive, got {self.width_mm}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NetBranch:
|
||||
"""One continuous, ordered run of samples with a monotonic distance
|
||||
axis. A net with a single point-to-point route is one branch; a
|
||||
T-topology net (one fan-out point) is split into one branch per spoke,
|
||||
each restarting its distance axis at the fan-out point. See
|
||||
net_walk.sample_net."""
|
||||
|
||||
samples: tuple[SamplePoint, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlaneCoverage:
|
||||
"""Whether a reference plane actually covers a sample point, and if not,
|
||||
how close the nearest plane edge/void is (None when covered and the
|
||||
distance wasn't computed)."""
|
||||
|
||||
layer: str
|
||||
is_covered: bool
|
||||
distance_to_void_mm: Optional[float] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.distance_to_void_mm is not None and self.distance_to_void_mm < 0:
|
||||
raise ValueError("distance_to_void_mm must be >= 0")
|
||||
|
||||
|
||||
class Topology(Enum):
|
||||
MICROSTRIP = auto()
|
||||
STRIPLINE = auto()
|
||||
COPLANAR_GROUNDED = auto() # CPWG
|
||||
UNKNOWN = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImpedanceSample:
|
||||
distance_along_net_mm: float
|
||||
position: Point2D
|
||||
layer: str
|
||||
width_mm: float
|
||||
topology: Topology
|
||||
z0_ohms: Optional[float]
|
||||
flags: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.width_mm <= 0:
|
||||
raise ValueError(f"width_mm must be positive, got {self.width_mm}")
|
||||
if self.z0_ohms is not None and self.z0_ohms <= 0:
|
||||
raise ValueError(f"z0_ohms must be positive, got {self.z0_ohms}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NetProfile:
|
||||
net_name: str
|
||||
samples: tuple[ImpedanceSample, ...]
|
||||
|
||||
@property
|
||||
def has_flags(self) -> bool:
|
||||
return any(sample.flags for sample in self.samples)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NetSummary:
|
||||
"""One row of a batch report (board_report.py): length + impedance
|
||||
range for a whole net, collapsed from its per-sample ImpedanceSample
|
||||
profile. topologies/flags are the distinct values seen, in first-seen
|
||||
order, so a net that changes layer (MICROSTRIP -> STRIPLINE) or crosses
|
||||
a plane void is still visible in one row instead of only in the
|
||||
full per-sample CSV."""
|
||||
|
||||
net_name: str
|
||||
length_mm: float
|
||||
branch_count: int
|
||||
is_differential: bool
|
||||
partner_net_name: Optional[str]
|
||||
topologies: tuple[str, ...]
|
||||
z0_min_ohms: Optional[float]
|
||||
z0_max_ohms: Optional[float]
|
||||
z0_avg_ohms: Optional[float]
|
||||
flags: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ZonePolygon:
|
||||
"""A filled zone's outline(s) on one copper layer, in mm. Each entry in
|
||||
outlines_mm is one closed ring (KiCad's SHAPE_POLY_SET "outline"); a
|
||||
zone with disjoint copper islands has more than one. Extracted by
|
||||
board_model.py, consumed by planes.py — pure data, no pcbnew handle."""
|
||||
|
||||
net: str
|
||||
layer: str
|
||||
outlines_mm: tuple[tuple[Point2D, ...], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoardOutline:
|
||||
"""Bounding box of the board's Edge.Cuts outline, in mm, in pcbnew's own
|
||||
coordinate convention (Y increases downward, matching the screen) --
|
||||
NOT necessarily the same convention a Gerber-consuming tool expects.
|
||||
See gerber2ems_export.board_origin_mm's docstring before using this as
|
||||
a "bottom-left" origin for anything outside pcbnew."""
|
||||
|
||||
min_corner: Point2D
|
||||
max_corner: Point2D
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoardData:
|
||||
"""Everything the pure engine needs from a routed board, in mm."""
|
||||
|
||||
segments: tuple[TraceSegment, ...]
|
||||
vias: tuple[ViaSpan, ...]
|
||||
zone_polygons: tuple[ZonePolygon, ...]
|
||||
copper_layer_names: tuple[str, ...]
|
||||
stackup: Optional[Stackup]
|
||||
outline: Optional[BoardOutline]
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Shared net-analysis orchestration used by both cli.py (one net, full
|
||||
per-sample detail) and board_report.py (many nets, summarized). Pure: works
|
||||
on an already-loaded BoardData/Stackup, no pcbnew import needed here, so
|
||||
it's testable with no KiCad installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from impedancefinder import geometry, net_walk
|
||||
from impedancefinder.model import BoardData, ImpedanceSample, NetBranch, Stackup, TraceSegment
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NetAnalysisResult:
|
||||
samples: tuple[ImpedanceSample, ...]
|
||||
branch_count: int
|
||||
partner_net_name: Optional[str] # None means single-ended
|
||||
|
||||
@property
|
||||
def is_differential(self) -> bool:
|
||||
return self.partner_net_name is not None
|
||||
|
||||
|
||||
def analyze_net(
|
||||
board_data: BoardData, stackup: Stackup, net_name: str, pitch_mm: float, single_ended: bool = False
|
||||
) -> NetAnalysisResult:
|
||||
"""Raises ValueError if the net has no routed segments on this board."""
|
||||
segments = segments_for(board_data, net_name)
|
||||
if not segments:
|
||||
raise ValueError(f"no segments found on net {net_name!r}")
|
||||
branches = net_walk.sample_net(segments, pitch_mm)
|
||||
partner_net = None if single_ended else geometry.find_pair_net_name(net_name)
|
||||
partner_segments = segments_for(board_data, partner_net) if partner_net else ()
|
||||
if not partner_segments:
|
||||
partner_net = None
|
||||
results: list[ImpedanceSample] = []
|
||||
for branch in branches:
|
||||
results.extend(_analyze_branch(branch, partner_segments, stackup, board_data))
|
||||
return NetAnalysisResult(samples=tuple(results), branch_count=len(branches), partner_net_name=partner_net)
|
||||
|
||||
|
||||
def _analyze_branch(
|
||||
branch: NetBranch, partner_segments: tuple[TraceSegment, ...], stackup: Stackup, board_data: BoardData
|
||||
) -> tuple[ImpedanceSample, ...]:
|
||||
if not partner_segments:
|
||||
return tuple(
|
||||
geometry.analyze_sample(sample, stackup, board_data.zone_polygons) for sample in branch.samples
|
||||
)
|
||||
return tuple(
|
||||
geometry.analyze_differential_sample(sample, partner_segments, stackup, board_data.zone_polygons)
|
||||
for sample in branch.samples
|
||||
)
|
||||
|
||||
|
||||
def segments_for(board_data: BoardData, net_name: str) -> tuple[TraceSegment, ...]:
|
||||
return tuple(segment for segment in board_data.segments if segment.net == net_name)
|
||||
|
||||
|
||||
def net_length_mm(board_data: BoardData, net_name: str) -> float:
|
||||
return sum(segment.length_mm for segment in segments_for(board_data, net_name))
|
||||
Vendored
+157
@@ -0,0 +1,157 @@
|
||||
"""Samples a net's routed segments into evenly-spaced points along a
|
||||
continuous distance axis.
|
||||
|
||||
Segments are chained by endpoint coincidence: two segments that share an
|
||||
exact (x, y) point are treated as connected, regardless of layer. This
|
||||
means a via is handled for free -- the segment ending on one layer and the
|
||||
segment starting on the other share the via's exact position, so the
|
||||
distance axis carries straight through without any via-specific code.
|
||||
|
||||
A net with a single point-to-point route becomes one NetBranch. A
|
||||
T-topology net (any point where 3+ segments meet) is split into one branch
|
||||
per spoke leaving that point, each restarting its distance axis at zero
|
||||
there -- callers that want a single unified axis across the whole net will
|
||||
need to stitch branches together themselves; this module only guarantees
|
||||
that each individual branch's axis is correct and continuous.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from impedancefinder.model import NetBranch, Point2D, SamplePoint, TraceSegment
|
||||
|
||||
_COORDINATE_PRECISION_MM = 6 # matches pcbnew's nm-to-mm conversion exactly
|
||||
|
||||
|
||||
def sample_net(segments: tuple[TraceSegment, ...], pitch_mm: float) -> tuple[NetBranch, ...]:
|
||||
"""Sample every branch of a net at pitch_mm, plus each segment's exact
|
||||
endpoint. All segments are assumed to belong to the same net; callers
|
||||
should pre-filter board_model.BoardData.segments by net name first.
|
||||
"""
|
||||
if pitch_mm <= 0:
|
||||
raise ValueError(f"pitch_mm must be positive, got {pitch_mm}")
|
||||
branches = _group_into_branches(segments)
|
||||
return tuple(_sample_branch(branch, pitch_mm) for branch in branches)
|
||||
|
||||
|
||||
def _endpoint_key(point: Point2D) -> tuple[float, float]:
|
||||
return (round(point.x_mm, _COORDINATE_PRECISION_MM), round(point.y_mm, _COORDINATE_PRECISION_MM))
|
||||
|
||||
|
||||
def _build_adjacency(
|
||||
segments: tuple[TraceSegment, ...]
|
||||
) -> dict[tuple[float, float], list[TraceSegment]]:
|
||||
adjacency: dict[tuple[float, float], list[TraceSegment]] = {}
|
||||
for segment in segments:
|
||||
for endpoint in (segment.start, segment.end):
|
||||
adjacency.setdefault(_endpoint_key(endpoint), []).append(segment)
|
||||
return adjacency
|
||||
|
||||
|
||||
def _orient_from(segment: TraceSegment, from_key: tuple[float, float]) -> TraceSegment:
|
||||
if _endpoint_key(segment.start) == from_key:
|
||||
return segment
|
||||
return TraceSegment(
|
||||
net=segment.net, layer=segment.layer, start=segment.end, end=segment.start, width_mm=segment.width_mm
|
||||
)
|
||||
|
||||
|
||||
def _walk_branch(
|
||||
entry_key: tuple[float, float],
|
||||
entry_segment: TraceSegment,
|
||||
adjacency: dict[tuple[float, float], list[TraceSegment]],
|
||||
visited: set,
|
||||
) -> tuple[TraceSegment, ...]:
|
||||
ordered: list[TraceSegment] = []
|
||||
current_key, current_segment = entry_key, entry_segment
|
||||
while True:
|
||||
visited.add(id(current_segment))
|
||||
oriented = _orient_from(current_segment, current_key)
|
||||
ordered.append(oriented)
|
||||
next_key = _endpoint_key(oriented.end)
|
||||
neighbors = [s for s in adjacency[next_key] if id(s) not in visited]
|
||||
if len(neighbors) != 1 or len(adjacency[next_key]) != 2:
|
||||
break
|
||||
current_key, current_segment = next_key, neighbors[0]
|
||||
return tuple(ordered)
|
||||
|
||||
|
||||
def _group_into_branches(segments: tuple[TraceSegment, ...]) -> tuple[tuple[TraceSegment, ...], ...]:
|
||||
# Junctions (degree >= 3) are walked in a full first pass, before any
|
||||
# leaf is considered -- otherwise a leaf reached first in dict-iteration
|
||||
# order would claim a spoke and the branch would start at the leaf
|
||||
# instead of the junction, leaving sibling spokes of the same junction
|
||||
# inconsistently zeroed (one from the leaf, the rest from the junction).
|
||||
adjacency = _build_adjacency(segments)
|
||||
visited: set = set()
|
||||
branches = []
|
||||
for key, segments_at_node in adjacency.items():
|
||||
if len(segments_at_node) >= 3:
|
||||
branches.extend(_walk_unvisited(key, segments_at_node, adjacency, visited))
|
||||
for key, segments_at_node in adjacency.items():
|
||||
if len(segments_at_node) == 1:
|
||||
branches.extend(_walk_unvisited(key, segments_at_node, adjacency, visited))
|
||||
branches.extend(_group_remaining_loops(segments, adjacency, visited))
|
||||
return tuple(branches)
|
||||
|
||||
|
||||
def _walk_unvisited(
|
||||
key: tuple[float, float],
|
||||
segments_at_node: list[TraceSegment],
|
||||
adjacency: dict[tuple[float, float], list[TraceSegment]],
|
||||
visited: set,
|
||||
) -> list[tuple[TraceSegment, ...]]:
|
||||
return [
|
||||
_walk_branch(key, segment, adjacency, visited)
|
||||
for segment in segments_at_node
|
||||
if id(segment) not in visited
|
||||
]
|
||||
|
||||
|
||||
def _group_remaining_loops(
|
||||
segments: tuple[TraceSegment, ...],
|
||||
adjacency: dict[tuple[float, float], list[TraceSegment]],
|
||||
visited: set,
|
||||
) -> tuple[tuple[TraceSegment, ...], ...]:
|
||||
# Anything left unvisited lies entirely on degree-2 nodes -- a pure loop
|
||||
# with no leaf or junction to start from. Walk each remaining loop once,
|
||||
# starting arbitrarily from one of its segments.
|
||||
loops = []
|
||||
for segment in segments:
|
||||
if id(segment) not in visited:
|
||||
loops.append(_walk_branch(_endpoint_key(segment.start), segment, adjacency, visited))
|
||||
return tuple(loops)
|
||||
|
||||
|
||||
def _sample_branch(branch_segments: tuple[TraceSegment, ...], pitch_mm: float) -> NetBranch:
|
||||
samples: list[SamplePoint] = []
|
||||
cumulative_mm = 0.0
|
||||
for segment in branch_segments:
|
||||
samples.extend(_sample_segment(segment, pitch_mm, cumulative_mm))
|
||||
cumulative_mm += segment.length_mm
|
||||
return NetBranch(samples=tuple(samples))
|
||||
|
||||
|
||||
def _sample_segment(
|
||||
segment: TraceSegment, pitch_mm: float, offset_mm: float
|
||||
) -> tuple[SamplePoint, ...]:
|
||||
length_mm = segment.length_mm
|
||||
if length_mm == 0:
|
||||
return (_sample_at(segment, 0.0, offset_mm),)
|
||||
step_count = max(1, int(length_mm // pitch_mm))
|
||||
local_distances = [i * pitch_mm for i in range(step_count + 1) if i * pitch_mm < length_mm]
|
||||
local_distances.append(length_mm)
|
||||
return tuple(_sample_at(segment, distance, offset_mm + distance) for distance in local_distances)
|
||||
|
||||
|
||||
def _sample_at(segment: TraceSegment, local_distance_mm: float, cumulative_distance_mm: float) -> SamplePoint:
|
||||
fraction = 0.0 if segment.length_mm == 0 else local_distance_mm / segment.length_mm
|
||||
position = Point2D(
|
||||
x_mm=segment.start.x_mm + fraction * (segment.end.x_mm - segment.start.x_mm),
|
||||
y_mm=segment.start.y_mm + fraction * (segment.end.y_mm - segment.start.y_mm),
|
||||
)
|
||||
return SamplePoint(
|
||||
net=segment.net,
|
||||
layer=segment.layer,
|
||||
distance_along_net_mm=cumulative_distance_mm,
|
||||
position=position,
|
||||
width_mm=segment.width_mm,
|
||||
)
|
||||
Vendored
+121
@@ -0,0 +1,121 @@
|
||||
"""Reference-plane resolution, coverage, and void proximity.
|
||||
|
||||
This is the crux module: it's what lets the tool catch a broken or split
|
||||
reference plane under a trace, not just a nominal width-based Z0. Pure and
|
||||
pcbnew-free — it works entirely off the ZonePolygon outline points that
|
||||
board_model.py already extracted (see that module's docstring for why
|
||||
containment/distance are done here with shapely rather than by calling back
|
||||
into pcbnew's HitTestFilledArea/Contains).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from shapely.geometry import Point as ShapelyPoint
|
||||
from shapely.geometry import Polygon as ShapelyPolygon
|
||||
|
||||
from impedancefinder.model import (
|
||||
DielectricLayer,
|
||||
PlaneCoverage,
|
||||
SamplePoint,
|
||||
Stackup,
|
||||
ZonePolygon,
|
||||
)
|
||||
|
||||
# A covered sample within this many trace-widths of the plane's edge is
|
||||
# flagged as approaching a split, even before it fully crosses one.
|
||||
_VOID_PROXIMITY_WIDTH_MULTIPLE = 3.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlaneContext:
|
||||
"""Which reference plane(s) back a sample, and the dielectric between
|
||||
the trace and each one. Either side is None when the trace is on an
|
||||
outer layer (no plane above) or the stackup has no layer beyond it."""
|
||||
|
||||
above: Optional[PlaneCoverage]
|
||||
below: Optional[PlaneCoverage]
|
||||
dielectric_above: Optional[DielectricLayer]
|
||||
dielectric_below: Optional[DielectricLayer]
|
||||
|
||||
@property
|
||||
def reference_plane_count(self) -> int:
|
||||
return sum(1 for coverage in (self.above, self.below) if coverage is not None)
|
||||
|
||||
|
||||
def resolve_reference_planes(
|
||||
stackup: Stackup, zone_polygons: tuple[ZonePolygon, ...], sample: SamplePoint
|
||||
) -> PlaneContext:
|
||||
"""Find the copper layer(s) adjacent to the sample's layer and check
|
||||
whether each one actually has copper under/over this point."""
|
||||
layer_index = stackup.copper_layer_names.index(sample.layer)
|
||||
below_layer = _layer_at(stackup, layer_index + 1)
|
||||
above_layer = _layer_at(stackup, layer_index - 1)
|
||||
return PlaneContext(
|
||||
above=coverage_at(sample, zone_polygons, above_layer) if above_layer else None,
|
||||
below=coverage_at(sample, zone_polygons, below_layer) if below_layer else None,
|
||||
dielectric_above=stackup.dielectric_between(above_layer, sample.layer) if above_layer else None,
|
||||
dielectric_below=stackup.dielectric_between(sample.layer, below_layer) if below_layer else None,
|
||||
)
|
||||
|
||||
|
||||
def _layer_at(stackup: Stackup, index: int) -> Optional[str]:
|
||||
if 0 <= index < len(stackup.copper_layer_names):
|
||||
return stackup.copper_layer_names[index]
|
||||
return None
|
||||
|
||||
|
||||
def coverage_at(
|
||||
sample: SamplePoint,
|
||||
zone_polygons: tuple[ZonePolygon, ...],
|
||||
layer: str,
|
||||
exclude_net: Optional[str] = None,
|
||||
) -> PlaneCoverage:
|
||||
"""Is `layer` actually covered by copper under/over the sample, and how
|
||||
close is the nearest plane edge (a covered point's distance to falling
|
||||
off the plane, or an uncovered point's distance to landing on one)?
|
||||
|
||||
exclude_net skips zones on the trace's own net — geometry.py reuses this
|
||||
to measure the gap to same-layer *coplanar ground* copper, where the
|
||||
trace's own copper obviously shouldn't count.
|
||||
"""
|
||||
polygons = [
|
||||
polygon
|
||||
for zone_polygon in zone_polygons
|
||||
if zone_polygon.layer == layer and zone_polygon.net != exclude_net
|
||||
for polygon in _to_shapely_polygons(zone_polygon)
|
||||
]
|
||||
if not polygons:
|
||||
return PlaneCoverage(layer=layer, is_covered=False, distance_to_void_mm=None)
|
||||
point = ShapelyPoint(sample.position.x_mm, sample.position.y_mm)
|
||||
is_covered = any(polygon.contains(point) for polygon in polygons)
|
||||
distance_mm = min(polygon.boundary.distance(point) for polygon in polygons)
|
||||
return PlaneCoverage(layer=layer, is_covered=is_covered, distance_to_void_mm=distance_mm)
|
||||
|
||||
|
||||
def _to_shapely_polygons(zone_polygon: ZonePolygon) -> tuple[ShapelyPolygon, ...]:
|
||||
# Each outline is treated as its own simple polygon; nested cutouts
|
||||
# within one filled zone island aren't modeled separately in this pass.
|
||||
return tuple(
|
||||
ShapelyPolygon([(point.x_mm, point.y_mm) for point in outline])
|
||||
for outline in zone_polygon.outlines_mm
|
||||
if len(outline) >= 3
|
||||
)
|
||||
|
||||
|
||||
def flags_for_context(context: PlaneContext, width_mm: float) -> tuple[str, ...]:
|
||||
"""Plane-health flags for a sample, deduplicated across above/below."""
|
||||
flags = _flags_for(context.below, width_mm) + _flags_for(context.above, width_mm)
|
||||
return tuple(dict.fromkeys(flags))
|
||||
|
||||
|
||||
def _flags_for(coverage: Optional[PlaneCoverage], width_mm: float) -> tuple[str, ...]:
|
||||
if coverage is None:
|
||||
return ()
|
||||
if not coverage.is_covered:
|
||||
return ("plane_broken",)
|
||||
threshold_mm = _VOID_PROXIMITY_WIDTH_MULTIPLE * width_mm
|
||||
if coverage.distance_to_void_mm is not None and coverage.distance_to_void_mm < threshold_mm:
|
||||
return ("plane_split_nearby",)
|
||||
return ()
|
||||
Vendored
+121
@@ -0,0 +1,121 @@
|
||||
"""Profile assembly and export.
|
||||
|
||||
build_profile is pure. to_csv is this module's impure edge (writes a file).
|
||||
|
||||
gerber2ems export/import now lives in gerber2ems_export.py, not here (see
|
||||
docs/field-solver-export-plan.md for why it grew into its own module and
|
||||
what was verified against the real installed tool). to_rf2dfieldsolver
|
||||
below is still a stub -- RF2DFieldSolver is GUI-only with no batch mode
|
||||
(verified against its main.cpp), so an automated export-and-read-back loop
|
||||
isn't possible for it the way it is for gerber2ems; see the plan doc for
|
||||
the full comparison.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
|
||||
from impedancefinder import net_analysis
|
||||
from impedancefinder.model import BoardData, ImpedanceSample, NetProfile, NetSummary
|
||||
from impedancefinder.net_analysis import NetAnalysisResult
|
||||
|
||||
_CSV_COLUMNS = (
|
||||
"distance_along_net_mm",
|
||||
"x_mm",
|
||||
"y_mm",
|
||||
"layer",
|
||||
"width_mm",
|
||||
"topology",
|
||||
"z0_ohms",
|
||||
"flags",
|
||||
)
|
||||
|
||||
_SUMMARY_CSV_COLUMNS = (
|
||||
"net_name",
|
||||
"length_mm",
|
||||
"branch_count",
|
||||
"is_differential",
|
||||
"partner_net_name",
|
||||
"topologies",
|
||||
"z0_min_ohms",
|
||||
"z0_max_ohms",
|
||||
"z0_avg_ohms",
|
||||
"flags",
|
||||
)
|
||||
|
||||
|
||||
def build_profile(net_name: str, samples: tuple[ImpedanceSample, ...]) -> NetProfile:
|
||||
return NetProfile(net_name=net_name, samples=samples)
|
||||
|
||||
|
||||
def to_csv(profile: NetProfile, path: str) -> None:
|
||||
with open(path, "w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(_CSV_COLUMNS)
|
||||
for sample in profile.samples:
|
||||
writer.writerow(_csv_row(sample))
|
||||
|
||||
|
||||
def _csv_row(sample: ImpedanceSample) -> tuple:
|
||||
return (
|
||||
sample.distance_along_net_mm,
|
||||
sample.position.x_mm,
|
||||
sample.position.y_mm,
|
||||
sample.layer,
|
||||
sample.width_mm,
|
||||
sample.topology.name,
|
||||
"" if sample.z0_ohms is None else sample.z0_ohms,
|
||||
";".join(sample.flags),
|
||||
)
|
||||
|
||||
|
||||
def summarize_net(net_name: str, board_data: BoardData, result: NetAnalysisResult) -> NetSummary:
|
||||
"""Collapse one net's per-sample analysis into a single report row —
|
||||
used by board_report.py for a batch of nets, one closed-form pass each,
|
||||
no openEMS involved."""
|
||||
z0_values = tuple(sample.z0_ohms for sample in result.samples if sample.z0_ohms is not None)
|
||||
return NetSummary(
|
||||
net_name=net_name,
|
||||
length_mm=net_analysis.net_length_mm(board_data, net_name),
|
||||
branch_count=result.branch_count,
|
||||
is_differential=result.is_differential,
|
||||
partner_net_name=result.partner_net_name,
|
||||
topologies=_unique_in_order(sample.topology.name for sample in result.samples),
|
||||
z0_min_ohms=min(z0_values) if z0_values else None,
|
||||
z0_max_ohms=max(z0_values) if z0_values else None,
|
||||
z0_avg_ohms=sum(z0_values) / len(z0_values) if z0_values else None,
|
||||
flags=_unique_in_order(flag for sample in result.samples for flag in sample.flags),
|
||||
)
|
||||
|
||||
|
||||
def _unique_in_order(values) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
|
||||
def summaries_to_csv(summaries: tuple[NetSummary, ...], path: str) -> None:
|
||||
with open(path, "w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(_SUMMARY_CSV_COLUMNS)
|
||||
for summary in summaries:
|
||||
writer.writerow(_summary_csv_row(summary))
|
||||
|
||||
|
||||
def _summary_csv_row(summary: NetSummary) -> tuple:
|
||||
return (
|
||||
summary.net_name,
|
||||
summary.length_mm,
|
||||
summary.branch_count,
|
||||
summary.is_differential,
|
||||
summary.partner_net_name or "",
|
||||
";".join(summary.topologies),
|
||||
"" if summary.z0_min_ohms is None else summary.z0_min_ohms,
|
||||
"" if summary.z0_max_ohms is None else summary.z0_max_ohms,
|
||||
"" if summary.z0_avg_ohms is None else summary.z0_avg_ohms,
|
||||
";".join(summary.flags),
|
||||
)
|
||||
|
||||
|
||||
def to_rf2dfieldsolver(profile: NetProfile, path: str) -> None:
|
||||
"""Export a 2D cross-section for RF2DFieldSolver at a flagged region.
|
||||
Not implemented yet — see the module docstring and
|
||||
docs/field-solver-export-plan.md."""
|
||||
raise NotImplementedError("RF2DFieldSolver export is not implemented yet")
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Manual stackup description consumed by impedancefinder.board_model.load_manual_stackup().
|
||||
#
|
||||
# This is the primary way to supply Er/height data today: KiCad's Python API
|
||||
# (verified against KiCad 10.0.4) does not expose BOARD_STACKUP to scripting,
|
||||
# so Board Setup's physical stackup cannot be read back reliably. Fill this
|
||||
# file in from the fabricator's stackup table instead.
|
||||
#
|
||||
# copper_layers: ordered top-to-bottom, using KiCad's canonical layer names.
|
||||
# dielectrics: ordered top-to-bottom, one entry between each pair of adjacent
|
||||
# copper layers (len(dielectrics) == len(copper_layers) - 1).
|
||||
# copper_thickness_mm: optional, defaults to 0.035mm (1oz copper) if omitted.
|
||||
|
||||
copper_thickness_mm: 0.035
|
||||
|
||||
copper_layers:
|
||||
- F.Cu
|
||||
- In1.Cu
|
||||
- In2.Cu
|
||||
- B.Cu
|
||||
|
||||
dielectrics:
|
||||
- name: core
|
||||
er: 4.4
|
||||
height_mm: 0.2
|
||||
- name: prepreg
|
||||
er: 4.1
|
||||
height_mm: 1.0
|
||||
- name: core
|
||||
er: 4.4
|
||||
height_mm: 0.2
|
||||
Vendored
+144
@@ -0,0 +1,144 @@
|
||||
"""Closed-form characteristic-impedance solvers.
|
||||
|
||||
Static (zero-frequency) Hammerstad-Jensen microstrip and Cohn/IPC-2141
|
||||
stripline formulas, ported term-for-term from KiCad's own pcb_calculator
|
||||
engine (common/transline_calculations/{microstrip,stripline}.cpp, verified
|
||||
against the KiCad 10.0.4 source tag) with the frequency-dispersion, cover,
|
||||
and conductor/dielectric-loss terms dropped — this tool needs the static Z0
|
||||
for post-route verification, not a full RF loss/dispersion analysis.
|
||||
Differential corrections use the separate, simpler IPC-2141A empirical
|
||||
odd-mode formulas rather than KiCad's full coupled-line even/odd-mode solver.
|
||||
|
||||
All functions are pure: same inputs always give the same output, no state,
|
||||
no I/O. Dimensions are millimetres, er is dimensionless, results are ohms.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
_FREE_SPACE_IMPEDANCE_OHMS = 376.730313668 # NIST CODATA Z0
|
||||
|
||||
|
||||
def _thickness_width_correction(u: float, t_h: float, er: float) -> float:
|
||||
"""Hammerstad-Jensen effective-width correction for finite copper
|
||||
thickness (delta_u in microstrip.cpp)."""
|
||||
if t_h <= 0:
|
||||
return 0.0
|
||||
delta_u = (t_h / math.pi) * math.log(
|
||||
1.0 + (4.0 * math.e) * math.tanh(math.sqrt(6.517 * u)) ** 2 / t_h
|
||||
)
|
||||
return 0.5 * delta_u * (1.0 + 1.0 / math.cosh(math.sqrt(er - 1.0)))
|
||||
|
||||
|
||||
def _homogeneous_impedance_ohms(u: float) -> float:
|
||||
"""Hammerstad's single-formula air-filled microstrip impedance for
|
||||
shape ratio u = W/H, valid across the full range of u."""
|
||||
shape = 6.0 + (2.0 * math.pi - 6.0) * math.exp(-((30.666 / u) ** 0.7528))
|
||||
return (_FREE_SPACE_IMPEDANCE_OHMS / (2.0 * math.pi)) * math.log(
|
||||
shape / u + math.sqrt(1.0 + 4.0 / (u * u))
|
||||
)
|
||||
|
||||
|
||||
def _filling_factor(u: float, er: float) -> float:
|
||||
"""Hammerstad-Jensen dielectric filling factor q for shape ratio u."""
|
||||
u2, u3, u4 = u * u, u**3, u**4
|
||||
a = (
|
||||
1.0
|
||||
+ math.log((u4 + u2 / 2704.0) / (u4 + 0.432)) / 49.0
|
||||
+ math.log(1.0 + u3 / 5929.741) / 18.7
|
||||
)
|
||||
b = 0.564 * ((er - 0.9) / (er + 3.0)) ** 0.053
|
||||
return (1.0 + 10.0 / u) ** (-a * b)
|
||||
|
||||
|
||||
def microstrip_z0(width_mm: float, height_mm: float, er: float, t_mm: float = 0.0) -> float:
|
||||
"""Single-ended microstrip Z0 via Hammerstad-Jensen, static (f=0).
|
||||
|
||||
width_mm: trace width. height_mm: dielectric height to the reference
|
||||
plane below the trace. er: dielectric relative permittivity. t_mm:
|
||||
copper thickness (0 disables the thickness correction).
|
||||
"""
|
||||
u = width_mm / height_mm
|
||||
t_h = t_mm / height_mm
|
||||
|
||||
u_er = u + _thickness_width_correction(u, t_h, er)
|
||||
z0_dielectric = _homogeneous_impedance_ohms(u_er)
|
||||
|
||||
q = _filling_factor(u_er, er) - (2.0 * math.log(2.0) / math.pi) * (t_h / math.sqrt(u_er))
|
||||
er_eff = 0.5 * (er + 1.0) + 0.5 * q * (er - 1.0)
|
||||
|
||||
return z0_dielectric / math.sqrt(er_eff)
|
||||
|
||||
|
||||
def _stripline_line_impedance_ohms(
|
||||
plane_spacing_mm: float, width_mm: float, t_mm: float, er: float
|
||||
) -> float:
|
||||
"""Cohn's stripline formula as used by KiCad's stripline.cpp, specialized
|
||||
to the width-dominated (>=0.35) and narrow-trace regimes."""
|
||||
hmt = plane_spacing_mm - t_mm
|
||||
if width_mm / hmt >= 0.35:
|
||||
wide = width_mm + (
|
||||
2.0 * plane_spacing_mm * math.log((2.0 * plane_spacing_mm - t_mm) / hmt)
|
||||
- t_mm * math.log(plane_spacing_mm**2 / hmt**2 - 1.0)
|
||||
) / math.pi
|
||||
return _FREE_SPACE_IMPEDANCE_OHMS * hmt / math.sqrt(er) / 4.0 / wide
|
||||
|
||||
ratio = t_mm / width_mm
|
||||
if ratio > 1.0:
|
||||
ratio = width_mm / t_mm
|
||||
effective_diameter = (
|
||||
1.0 + ratio / math.pi * (1.0 + math.log(4.0 * math.pi / ratio)) + 0.236 * ratio**1.65
|
||||
)
|
||||
effective_diameter *= (t_mm / 2.0) if (t_mm / width_mm) > 1.0 else (width_mm / 2.0)
|
||||
return (
|
||||
_FREE_SPACE_IMPEDANCE_OHMS
|
||||
/ (2.0 * math.pi * math.sqrt(er))
|
||||
* math.log(4.0 * plane_spacing_mm / math.pi / effective_diameter)
|
||||
)
|
||||
|
||||
|
||||
def stripline_z0(width_mm: float, b_mm: float, er: float, t_mm: float) -> float:
|
||||
"""Symmetric (centered) stripline Z0, ported from KiCad's Cohn-derived
|
||||
stripline.cpp, specialized to a trace centered between two reference
|
||||
planes spaced b_mm apart.
|
||||
|
||||
t_mm must be > 0: the formula divides by hmt = b_mm - t_mm and takes a
|
||||
log that is singular at t_mm == 0. Real copper always has finite
|
||||
thickness, so callers must supply it (e.g. 0.035 mm for 1 oz copper).
|
||||
"""
|
||||
if t_mm <= 0:
|
||||
raise ValueError("stripline_z0 requires t_mm > 0 (finite copper thickness)")
|
||||
return _stripline_line_impedance_ohms(b_mm, width_mm, t_mm, er)
|
||||
|
||||
|
||||
def diff_microstrip_z0(
|
||||
width_mm: float, height_mm: float, spacing_mm: float, er: float, t_mm: float = 0.0
|
||||
) -> float:
|
||||
"""Edge-coupled differential microstrip Z0: IPC-2141A's empirical
|
||||
odd-mode correction applied to the single-ended Hammerstad-Jensen value.
|
||||
|
||||
spacing_mm: edge-to-edge gap between the two traces of the pair.
|
||||
"""
|
||||
z0 = microstrip_z0(width_mm, height_mm, er, t_mm)
|
||||
return 2.0 * z0 * (1.0 - 0.48 * math.exp(-0.96 * spacing_mm / height_mm))
|
||||
|
||||
|
||||
def diff_stripline_z0(
|
||||
width_mm: float, b_mm: float, spacing_mm: float, er: float, t_mm: float
|
||||
) -> float:
|
||||
"""Edge-coupled differential stripline Z0: IPC-2141A's empirical
|
||||
odd-mode correction applied to the single-ended stripline value."""
|
||||
z0 = stripline_z0(width_mm, b_mm, er, t_mm)
|
||||
return 2.0 * z0 * (1.0 - 0.347 * math.exp(-2.9 * spacing_mm / b_mm))
|
||||
|
||||
|
||||
def cpwg_z0(*_args, **_kwargs) -> float:
|
||||
"""Grounded coplanar waveguide Z0 — not implemented yet.
|
||||
|
||||
CPWG needs the coplanar-ground-gap geometry in addition to the
|
||||
reference-plane height, which isn't modeled by this solver set yet.
|
||||
Raises explicitly so a CPWG classification surfaces as a clear
|
||||
"not supported" flag (see geometry.classify_topology) instead of a
|
||||
silently wrong number.
|
||||
"""
|
||||
raise NotImplementedError("CPWG closed-form solver is not implemented yet")
|
||||
Reference in New Issue
Block a user