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:
2026-09-10 23:14:57 +02:00
co-authored by Cursor
parent 3e43bb6ecb
commit 6fc2ac583d
30 changed files with 2219 additions and 2 deletions
+12
View File
@@ -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`.
View File
+209
View File
@@ -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
)
+226
View File
@@ -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
View File
@@ -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))
+157
View File
@@ -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,
)
+121
View File
@@ -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 ()
+121
View File
@@ -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
View File
@@ -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
+144
View File
@@ -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")