Files
periscope/vendor/impedancefinder/net_walk.py
T
micheleandCursor 6fc2ac583d 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>
2026-09-10 23:14:57 +02:00

158 lines
6.5 KiB
Python

"""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,
)