Files
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

122 lines
4.8 KiB
Python

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