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>
227 lines
6.9 KiB
Python
227 lines
6.9 KiB
Python
"""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]
|