Include the net's traces as conductors alongside zone fills
Straight tracks become capsule outline polygons (rectangle + semicircular caps), arc tracks annular bands with end caps, both tessellated to the same sagitta tolerance as zone-fill arcs; they merge into the per-layer copper next to the fills, so rasterization, via stitching, the solver and the plots handle them unchanged. Trace-only layers and trace-only nets now qualify as candidates. Dialog checkbox (on by default, INCLUDE_TRACKS) toggles them per run. Hole-less polygons (every track outline) now paint the layer mask directly instead of allocating a full-frame temporary each. Tests: exact N-cell chain on a rasterized capsule, analytic annular- sector convergence for an arc trace, capsule/arc-band outline geometry invariants, collinear-arc degradation, and fill+trace union solve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# Fill Resistance — KiCad 10 plugin
|
# Fill Resistance — KiCad 10 plugin
|
||||||
|
|
||||||
Computes the **DC or AC resistance of copper zone fills** between two
|
Computes the **DC or AC resistance of copper zone fills and traces**
|
||||||
contacts, **single- or multi-layer**: the chosen net's fills on the
|
between two contacts, **single- or multi-layer**: the chosen net's fills
|
||||||
selected copper layers are solved as coupled finite-difference sheets
|
and tracks on the selected copper layers are solved as coupled
|
||||||
linked by the net's **via and through-hole-pad barrels** (18 µm plating,
|
finite-difference sheets linked by the net's **via and through-hole-pad
|
||||||
configurable). At a user-set **frequency** the exact 1D foil/barrel
|
barrels** (18 µm plating, configurable). At a user-set **frequency** the exact 1D foil/barrel
|
||||||
skin-effect correction is applied (AC results are a rigorous lower
|
skin-effect correction is applied (AC results are a rigorous lower
|
||||||
bound — see *Model & limits*). Shows per-layer rasterized maps,
|
bound — see *Model & limits*). Shows per-layer rasterized maps,
|
||||||
potential, current density, and **power density**, reports **per-via
|
potential, current density, and **power density**, reports **per-via
|
||||||
@@ -78,8 +78,10 @@ SWIG API. Requires KiCad **10.0.1+**.
|
|||||||
spokes** still connect; wider antipads do not, and the barrel bridges
|
spokes** still connect; wider antipads do not, and the barrel bridges
|
||||||
the layers above/below with the full barrel length. Barrels that reach
|
the layers above/below with the full barrel length. Barrels that reach
|
||||||
fill on fewer than two layers carry no current and are reported.
|
fill on fewer than two layers carry no current and are reported.
|
||||||
- Tracks and pad copper (other than the selected contacts) are **not**
|
- The net's **traces** (straight and arc tracks, exact outline polygons
|
||||||
part of the conductor model — zone fills + barrels only.
|
incl. rounded ends) conduct together with the fills — dialog checkbox,
|
||||||
|
on by default (`INCLUDE_TRACKS`). Pad copper other than the selected
|
||||||
|
contacts is still **not** part of the conductor model.
|
||||||
- **Solder buildup on mask openings** (dialog checkbox, **off by
|
- **Solder buildup on mask openings** (dialog checkbox, **off by
|
||||||
default**; `INCLUDE_MASK_BUILDUP`): zones drawn on `F.Mask`/`B.Mask`
|
default**; `INCLUDE_MASK_BUILDUP`): zones drawn on `F.Mask`/`B.Mask`
|
||||||
are treated as mask openings that collect `SOLDER_THICKNESS_UM`
|
are treated as mask openings that collect `SOLDER_THICKNESS_UM`
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from kipy import KiCad
|
from kipy import KiCad
|
||||||
from kipy.board import Board
|
from kipy.board import Board
|
||||||
from kipy.board_types import BoardRectangle, Pad
|
from kipy.board_types import ArcTrack, BoardRectangle, Pad
|
||||||
from kipy.proto.board.board_pb2 import BoardStackupLayerType
|
from kipy.proto.board.board_pb2 import BoardStackupLayerType
|
||||||
from kipy.proto.board.board_types_pb2 import ZoneType
|
from kipy.proto.board.board_types_pb2 import ZoneType
|
||||||
from kipy.util.board_layer import (canonical_name, is_copper_layer,
|
from kipy.util.board_layer import (canonical_name, is_copper_layer,
|
||||||
@@ -20,7 +20,8 @@ from kipy.util.board_layer import (canonical_name, is_copper_layer,
|
|||||||
from . import config
|
from . import config
|
||||||
from .errors import ApiVersionError, CandidateError, SelectionError
|
from .errors import ApiVersionError, CandidateError, SelectionError
|
||||||
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
|
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
|
||||||
SurfaceBuildup, ViaLink, linearize_ring)
|
SurfaceBuildup, ViaLink, arc_band_ring, capsule_ring,
|
||||||
|
linearize_ring)
|
||||||
|
|
||||||
MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"}
|
MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"}
|
||||||
|
|
||||||
@@ -302,6 +303,39 @@ def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]:
|
|||||||
return fills
|
return fills
|
||||||
|
|
||||||
|
|
||||||
|
def gather_net_tracks(board: Board) -> dict[str, dict[str, list[Polygon]]]:
|
||||||
|
"""net -> layer -> track outline polygons (straight capsules and arc
|
||||||
|
bands). Traces conduct together with the zone fills."""
|
||||||
|
out: dict[str, dict[str, list[Polygon]]] = {}
|
||||||
|
for t in board.get_tracks():
|
||||||
|
if not is_copper_layer(t.layer):
|
||||||
|
continue
|
||||||
|
width = int(t.width or 0)
|
||||||
|
if width <= 0:
|
||||||
|
continue
|
||||||
|
if isinstance(t, ArcTrack):
|
||||||
|
ring = arc_band_ring((t.start.x, t.start.y), (t.mid.x, t.mid.y),
|
||||||
|
(t.end.x, t.end.y), width, ARC_TOL_NM)
|
||||||
|
else:
|
||||||
|
ring = capsule_ring(t.start.x, t.start.y, t.end.x, t.end.y,
|
||||||
|
width, ARC_TOL_NM)
|
||||||
|
net = t.net.name if t.net is not None else "<no net>"
|
||||||
|
out.setdefault(net, {}).setdefault(
|
||||||
|
canonical_name(t.layer), []).append(Polygon(outline=ring))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def merge_copper(fills: dict, tracks: dict) -> dict:
|
||||||
|
"""net -> layer -> fill + track polygons, for candidate detection
|
||||||
|
and the dialog's layer lists (build_problem merges the same way)."""
|
||||||
|
out: dict[str, dict[str, list[Polygon]]] = {}
|
||||||
|
for src in (fills, tracks):
|
||||||
|
for net, per_layer in src.items():
|
||||||
|
for layer, polys in per_layer.items():
|
||||||
|
out.setdefault(net, {}).setdefault(layer, []).extend(polys)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _rect_overlaps(rect: Rect, polygons: list[Polygon]) -> bool:
|
def _rect_overlaps(rect: Rect, polygons: list[Polygon]) -> bool:
|
||||||
for p in polygons:
|
for p in polygons:
|
||||||
px0, py0 = p.outline.min(axis=0)
|
px0, py0 = p.outline.min(axis=0)
|
||||||
@@ -413,15 +447,18 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
es1: list[Electrode], es2: list[Electrode],
|
es1: list[Electrode], es2: list[Electrode],
|
||||||
stackup: StackupInfo, fills: dict,
|
stackup: StackupInfo, fills: dict,
|
||||||
buildups: dict[str, list[Polygon]] | None = None,
|
buildups: dict[str, list[Polygon]] | None = None,
|
||||||
extra_cu_um: float | None = None) -> Problem:
|
extra_cu_um: float | None = None,
|
||||||
|
tracks: dict | None = None) -> Problem:
|
||||||
per_layer = fills.get(net, {})
|
per_layer = fills.get(net, {})
|
||||||
|
per_layer_tracks = (tracks or {}).get(net, {})
|
||||||
layers = []
|
layers = []
|
||||||
for name in stackup.names: # keep stackup order
|
for name in stackup.names: # keep stackup order
|
||||||
if name not in layer_names:
|
if name not in layer_names:
|
||||||
continue
|
continue
|
||||||
polys = per_layer.get(name, [])
|
polys = (list(per_layer.get(name, []))
|
||||||
|
+ list(per_layer_tracks.get(name, [])))
|
||||||
if not polys:
|
if not polys:
|
||||||
print(f"note: net {net} has no fill on {name} - layer skipped")
|
print(f"note: net {net} has no copper on {name} - layer skipped")
|
||||||
continue
|
continue
|
||||||
if config.COPPER_THICKNESS_UM is not None:
|
if config.COPPER_THICKNESS_UM is not None:
|
||||||
t = int(config.COPPER_THICKNESS_UM * 1000)
|
t = int(config.COPPER_THICKNESS_UM * 1000)
|
||||||
@@ -440,9 +477,10 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
SurfaceBuildup(layer_name=name, polygons=polys)
|
SurfaceBuildup(layer_name=name, polygons=polys)
|
||||||
for name, polys in (buildups or {}).items() if name in included
|
for name, polys in (buildups or {}).items() if name in included
|
||||||
]
|
]
|
||||||
|
n_tracks = sum(len(per_layer_tracks.get(name, [])) for name in included)
|
||||||
print(f"net {net}: {len(layers)} layer(s) "
|
print(f"net {net}: {len(layers)} layer(s) "
|
||||||
f"({', '.join(l.layer_name for l in layers)}), "
|
f"({', '.join(l.layer_name for l in layers)}), "
|
||||||
f"{len(vias)} via/pad barrel(s)"
|
f"{n_tracks} track(s), {len(vias)} via/pad barrel(s)"
|
||||||
+ (f", solder buildup on "
|
+ (f", solder buildup on "
|
||||||
f"{', '.join(b.layer_name for b in buildup_list)}"
|
f"{', '.join(b.layer_name for b in buildup_list)}"
|
||||||
if buildup_list else ""))
|
if buildup_list else ""))
|
||||||
@@ -477,7 +515,9 @@ if __name__ == "__main__":
|
|||||||
if any_zone_unfilled(board):
|
if any_zone_unfilled(board):
|
||||||
refill(board)
|
refill(board)
|
||||||
fills = gather_net_fills(board)
|
fills = gather_net_fills(board)
|
||||||
nets = nets_overlapping(fills, es1, es2)
|
tracks = gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
|
||||||
|
copper = merge_copper(fills, tracks)
|
||||||
|
nets = nets_overlapping(copper, es1, es2)
|
||||||
if len(sys.argv) > 2:
|
if len(sys.argv) > 2:
|
||||||
net = sys.argv[2]
|
net = sys.argv[2]
|
||||||
elif net_hint in nets:
|
elif net_hint in nets:
|
||||||
@@ -487,7 +527,7 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
print(f"candidate nets: {nets}; pass one as second argument")
|
print(f"candidate nets: {nets}; pass one as second argument")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
problem = build_problem(board, net, list(fills.get(net, {})), es1, es2,
|
problem = build_problem(board, net, list(copper.get(net, {})), es1, es2,
|
||||||
stackup, fills)
|
stackup, fills, tracks=tracks)
|
||||||
save_problem(problem, out)
|
save_problem(problem, out)
|
||||||
print(f"wrote {out}")
|
print(f"wrote {out}")
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ BUILDUP_EXTRA_CU_UM = 0.0 # optional user-added copper (busbar/wire
|
|||||||
# soldered into the opening); dialog-settable
|
# soldered into the opening); dialog-settable
|
||||||
|
|
||||||
# --- Zone / layer selection ---
|
# --- Zone / layer selection ---
|
||||||
|
INCLUDE_TRACKS = True # the net's traces (straight + arc tracks)
|
||||||
|
# conduct together with the zone fills;
|
||||||
|
# dialog-toggleable
|
||||||
LAYER_HINT: str | None = None # e.g. "F.Cu" to disambiguate candidate fills
|
LAYER_HINT: str | None = None # e.g. "F.Cu" to disambiguate candidate fills
|
||||||
ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
|
ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
|
||||||
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
|
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class Selection:
|
|||||||
contact_model: str = "uniform"
|
contact_model: str = "uniform"
|
||||||
include_buildup: bool = False
|
include_buildup: bool = False
|
||||||
extra_cu_um: float = 0.0
|
extra_cu_um: float = 0.0
|
||||||
|
include_tracks: bool = True
|
||||||
|
|
||||||
|
|
||||||
class _Dialog(QDialog):
|
class _Dialog(QDialog):
|
||||||
@@ -59,6 +60,11 @@ class _Dialog(QDialog):
|
|||||||
self.layer_list.setMaximumHeight(120)
|
self.layer_list.setMaximumHeight(120)
|
||||||
form.addRow("Layers:", self.layer_list)
|
form.addRow("Layers:", self.layer_list)
|
||||||
|
|
||||||
|
self.tracks_check = QCheckBox("include the net's traces "
|
||||||
|
"(tracks + arcs)")
|
||||||
|
self.tracks_check.setChecked(config.INCLUDE_TRACKS)
|
||||||
|
form.addRow("Conductors:", self.tracks_check)
|
||||||
|
|
||||||
self.contact1_box = QComboBox()
|
self.contact1_box = QComboBox()
|
||||||
self.contact2_box = QComboBox()
|
self.contact2_box = QComboBox()
|
||||||
form.addRow(f"V+ ({e1_label}):", self.contact1_box)
|
form.addRow(f"V+ ({e1_label}):", self.contact1_box)
|
||||||
@@ -199,7 +205,8 @@ class _Dialog(QDialog):
|
|||||||
freq_hz=freq,
|
freq_hz=freq,
|
||||||
contact_model=self.model_box.currentData(),
|
contact_model=self.model_box.currentData(),
|
||||||
include_buildup=self.buildup_check.isChecked(),
|
include_buildup=self.buildup_check.isChecked(),
|
||||||
extra_cu_um=extra_cu)
|
extra_cu_um=extra_cu,
|
||||||
|
include_tracks=self.tracks_check.isChecked())
|
||||||
|
|
||||||
def _try_accept(self) -> None:
|
def _try_accept(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+91
-13
@@ -132,10 +132,9 @@ class Problem:
|
|||||||
return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
||||||
|
|
||||||
|
|
||||||
def arc_points(start, mid, end, tol_nm: float) -> np.ndarray:
|
def _arc_params(start, mid, end) -> tuple[float, float, float, float, float] | None:
|
||||||
"""Tessellate a start/mid/end arc into points from start (inclusive)
|
"""Circle through three points: (cx, cy, r, a0, sweep) with a0 the
|
||||||
to end (exclusive), max sagitta <= tol_nm. Collinear input degrades
|
start angle and sweep signed; None if the points are collinear."""
|
||||||
to just the start point (straight segment)."""
|
|
||||||
sx, sy = float(start[0]), float(start[1])
|
sx, sy = float(start[0]), float(start[1])
|
||||||
mx, my = float(mid[0]), float(mid[1])
|
mx, my = float(mid[0]), float(mid[1])
|
||||||
ex, ey = float(end[0]), float(end[1])
|
ex, ey = float(end[0]), float(end[1])
|
||||||
@@ -143,30 +142,109 @@ def arc_points(start, mid, end, tol_nm: float) -> np.ndarray:
|
|||||||
d = 2.0 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my))
|
d = 2.0 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my))
|
||||||
chord = math.hypot(ex - sx, ey - sy)
|
chord = math.hypot(ex - sx, ey - sy)
|
||||||
if abs(d) < 1e-9 * max(chord, 1.0):
|
if abs(d) < 1e-9 * max(chord, 1.0):
|
||||||
return np.array([[start[0], start[1]]], dtype=np.int64)
|
return None
|
||||||
ux = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy)
|
cx = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy)
|
||||||
+ (ex**2 + ey**2) * (sy - my)) / d
|
+ (ex**2 + ey**2) * (sy - my)) / d
|
||||||
uy = ((sx**2 + sy**2) * (ex - mx) + (mx**2 + my**2) * (sx - ex)
|
cy = ((sx**2 + sy**2) * (ex - mx) + (mx**2 + my**2) * (sx - ex)
|
||||||
+ (ex**2 + ey**2) * (mx - sx)) / d
|
+ (ex**2 + ey**2) * (mx - sx)) / d
|
||||||
r = math.hypot(sx - ux, sy - uy)
|
r = math.hypot(sx - cx, sy - cy)
|
||||||
|
|
||||||
a0 = math.atan2(sy - uy, sx - ux)
|
a0 = math.atan2(sy - cy, sx - cx)
|
||||||
a1 = math.atan2(my - uy, mx - ux)
|
a1 = math.atan2(my - cy, mx - cx)
|
||||||
a2 = math.atan2(ey - uy, ex - ux)
|
a2 = math.atan2(ey - cy, ex - cx)
|
||||||
two_pi = 2.0 * math.pi
|
two_pi = 2.0 * math.pi
|
||||||
d01 = (a1 - a0) % two_pi
|
d01 = (a1 - a0) % two_pi
|
||||||
d02 = (a2 - a0) % two_pi
|
d02 = (a2 - a0) % two_pi
|
||||||
sweep = d02 if d01 <= d02 else d02 - two_pi
|
sweep = d02 if d01 <= d02 else d02 - two_pi
|
||||||
|
return cx, cy, r, a0, sweep
|
||||||
|
|
||||||
|
|
||||||
|
def _n_arc_segments(sweep_abs: float, r: float, tol_nm: float) -> int:
|
||||||
|
"""Segments needed to keep the sagitta of each chord <= tol_nm."""
|
||||||
tol = min(tol_nm, 0.999 * r)
|
tol = min(tol_nm, 0.999 * r)
|
||||||
dtheta_max = 2.0 * math.acos(1.0 - tol / r)
|
dtheta_max = 2.0 * math.acos(1.0 - tol / r)
|
||||||
n = max(2, int(math.ceil(abs(sweep) / dtheta_max)))
|
return max(2, int(math.ceil(sweep_abs / dtheta_max)))
|
||||||
|
|
||||||
|
|
||||||
|
def arc_points(start, mid, end, tol_nm: float) -> np.ndarray:
|
||||||
|
"""Tessellate a start/mid/end arc into points from start (inclusive)
|
||||||
|
to end (exclusive), max sagitta <= tol_nm. Collinear input degrades
|
||||||
|
to just the start point (straight segment)."""
|
||||||
|
params = _arc_params(start, mid, end)
|
||||||
|
if params is None:
|
||||||
|
return np.array([[start[0], start[1]]], dtype=np.int64)
|
||||||
|
cx, cy, r, a0, sweep = params
|
||||||
|
n = _n_arc_segments(abs(sweep), r, tol_nm)
|
||||||
ks = np.arange(n)
|
ks = np.arange(n)
|
||||||
angs = a0 + sweep * ks / n
|
angs = a0 + sweep * ks / n
|
||||||
pts = np.stack([ux + r * np.cos(angs), uy + r * np.sin(angs)], axis=1)
|
pts = np.stack([cx + r * np.cos(angs), cy + r * np.sin(angs)], axis=1)
|
||||||
return np.round(pts).astype(np.int64)
|
return np.round(pts).astype(np.int64)
|
||||||
|
|
||||||
|
|
||||||
|
def capsule_ring(x1: int, y1: int, x2: int, y2: int, width_nm: int,
|
||||||
|
tol_nm: float) -> np.ndarray:
|
||||||
|
"""Outline (open ring, int64 nm) of a straight track segment: a
|
||||||
|
rectangle with semicircular end caps; a circle for a zero-length
|
||||||
|
segment. Cap sagitta <= tol_nm."""
|
||||||
|
r = width_nm / 2.0
|
||||||
|
dx, dy = float(x2 - x1), float(y2 - y1)
|
||||||
|
length = math.hypot(dx, dy)
|
||||||
|
n = _n_arc_segments(math.pi, r, tol_nm)
|
||||||
|
if length < 1.0:
|
||||||
|
angs = np.linspace(0.0, 2.0 * math.pi, 2 * n, endpoint=False)
|
||||||
|
pts = np.stack([x1 + r * np.cos(angs), y1 + r * np.sin(angs)],
|
||||||
|
axis=1)
|
||||||
|
return np.round(pts).astype(np.int64)
|
||||||
|
ux, uy = dx / length, dy / length
|
||||||
|
a0 = math.atan2(ux, -uy) # angle of the left normal
|
||||||
|
ks = np.arange(n + 1)
|
||||||
|
cap2 = a0 - ks * math.pi / n # +normal -> -normal, around end
|
||||||
|
cap1 = a0 - (ks + n) * math.pi / n # -normal -> +normal, around start
|
||||||
|
pts = np.concatenate([
|
||||||
|
np.stack([x2 + r * np.cos(cap2), y2 + r * np.sin(cap2)], axis=1),
|
||||||
|
np.stack([x1 + r * np.cos(cap1), y1 + r * np.sin(cap1)], axis=1),
|
||||||
|
])
|
||||||
|
return np.round(pts).astype(np.int64)
|
||||||
|
|
||||||
|
|
||||||
|
def arc_band_ring(start, mid, end, width_nm: int, tol_nm: float) -> np.ndarray:
|
||||||
|
"""Outline of an arc track: the annular band of the given width
|
||||||
|
around the start/mid/end centerline, with semicircular end caps.
|
||||||
|
Collinear input degrades to the straight capsule."""
|
||||||
|
params = _arc_params(start, mid, end)
|
||||||
|
if params is None:
|
||||||
|
return capsule_ring(start[0], start[1], end[0], end[1], width_nm,
|
||||||
|
tol_nm)
|
||||||
|
cx, cy, r, a0, sweep = params
|
||||||
|
w2 = width_nm / 2.0
|
||||||
|
router = r + w2
|
||||||
|
rinner = max(r - w2, 0.0)
|
||||||
|
sgn = 1.0 if sweep >= 0 else -1.0
|
||||||
|
a1 = a0 + sweep
|
||||||
|
m = _n_arc_segments(abs(sweep), router, tol_nm)
|
||||||
|
ncap = _n_arc_segments(math.pi, w2, tol_nm)
|
||||||
|
ks = np.arange(m + 1)
|
||||||
|
|
||||||
|
th = a0 + sweep * ks / m # outer arc, start -> end
|
||||||
|
parts = [np.stack([cx + router * np.cos(th),
|
||||||
|
cy + router * np.sin(th)], axis=1)]
|
||||||
|
ex_, ey_ = cx + r * math.cos(a1), cy + r * math.sin(a1)
|
||||||
|
ca = a1 + sgn * math.pi * np.arange(1, ncap) / ncap # end cap, bulges
|
||||||
|
parts.append(np.stack([ex_ + w2 * np.cos(ca), # along exit tangent
|
||||||
|
ey_ + w2 * np.sin(ca)], axis=1))
|
||||||
|
if rinner > 0:
|
||||||
|
th = a1 - sweep * ks / m # inner arc, end -> start
|
||||||
|
parts.append(np.stack([cx + rinner * np.cos(th),
|
||||||
|
cy + rinner * np.sin(th)], axis=1))
|
||||||
|
else:
|
||||||
|
parts.append(np.array([[cx, cy]])) # band swallows the center
|
||||||
|
sx_, sy_ = cx + r * math.cos(a0), cy + r * math.sin(a0)
|
||||||
|
ca = a0 + math.pi + sgn * math.pi * np.arange(1, ncap) / ncap
|
||||||
|
parts.append(np.stack([sx_ + w2 * np.cos(ca), # start cap, bulges
|
||||||
|
sy_ + w2 * np.sin(ca)], axis=1)) # backwards
|
||||||
|
return np.round(np.concatenate(parts)).astype(np.int64)
|
||||||
|
|
||||||
|
|
||||||
def linearize_ring(nodes: list, tol_nm: float) -> np.ndarray:
|
def linearize_ring(nodes: list, tol_nm: float) -> np.ndarray:
|
||||||
"""nodes: list of ('pt', (x, y)) or ('arc', (start, mid, end)) tuples,
|
"""nodes: list of ('pt', (x, y)) or ('arc', (start, mid, end)) tuples,
|
||||||
already in board nm. Returns an (N, 2) int64 open ring."""
|
already in board nm. Returns an (N, 2) int64 open ring."""
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ def main() -> None:
|
|||||||
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
|
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
|
||||||
board_io.refill(board)
|
board_io.refill(board)
|
||||||
fills = board_io.gather_net_fills(board)
|
fills = board_io.gather_net_fills(board)
|
||||||
candidate_nets = board_io.nets_overlapping(fills, es1, es2)
|
tracks = board_io.gather_net_tracks(board)
|
||||||
|
copper = board_io.merge_copper(fills, tracks)
|
||||||
|
candidate_nets = board_io.nets_overlapping(copper, es1, es2)
|
||||||
buildups = board_io.gather_mask_buildups(board)
|
buildups = board_io.gather_mask_buildups(board)
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
raise UserFacingError(
|
raise UserFacingError(
|
||||||
@@ -47,9 +49,9 @@ def main() -> None:
|
|||||||
|
|
||||||
if not candidate_nets:
|
if not candidate_nets:
|
||||||
raise CandidateError(
|
raise CandidateError(
|
||||||
"No copper zone fill overlaps both contacts. Check that both "
|
"No copper (zone fill or trace) overlaps both contacts. "
|
||||||
"sit over (or in) filled pours and that the fills are up to "
|
"Check that both sit over copper of the same net and that "
|
||||||
"date (press B in the board editor)."
|
"the fills are up to date (press B in the board editor)."
|
||||||
)
|
)
|
||||||
|
|
||||||
def group_label(parts):
|
def group_label(parts):
|
||||||
@@ -64,7 +66,7 @@ def main() -> None:
|
|||||||
default_net = (net_hint if net_hint in candidate_nets
|
default_net = (net_hint if net_hint in candidate_nets
|
||||||
else candidate_nets[0])
|
else candidate_nets[0])
|
||||||
selection = dialog.ask(
|
selection = dialog.ask(
|
||||||
candidates={n: list(fills[n].keys()) for n in candidate_nets},
|
candidates={n: list(copper[n].keys()) for n in candidate_nets},
|
||||||
layer_order=stackup.names,
|
layer_order=stackup.names,
|
||||||
default_net=default_net,
|
default_net=default_net,
|
||||||
e1_label=group_label(es1), e2_label=group_label(es2),
|
e1_label=group_label(es1), e2_label=group_label(es2),
|
||||||
@@ -89,7 +91,8 @@ def main() -> None:
|
|||||||
board, selection.net, selection.layers, es1, es2, stackup,
|
board, selection.net, selection.layers, es1, es2, stackup,
|
||||||
fills,
|
fills,
|
||||||
buildups=(buildups if selection.include_buildup else None),
|
buildups=(buildups if selection.include_buildup else None),
|
||||||
extra_cu_um=selection.extra_cu_um)
|
extra_cu_um=selection.extra_cu_um,
|
||||||
|
tracks=(tracks if selection.include_tracks else None))
|
||||||
outdir = report.make_output_dir(board_io.board_dir(board))
|
outdir = report.make_output_dir(board_io.board_dir(board))
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
raise UserFacingError(f"KiCad API error: {e}")
|
raise UserFacingError(f"KiCad API error: {e}")
|
||||||
|
|||||||
@@ -167,11 +167,16 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
|
|||||||
)
|
)
|
||||||
for li, layer in enumerate(problem.layers):
|
for li, layer in enumerate(problem.layers):
|
||||||
for poly in layer.polygons:
|
for poly in layer.polygons:
|
||||||
|
if poly.holes:
|
||||||
pmask = np.zeros((ny, nx), dtype=bool)
|
pmask = np.zeros((ny, nx), dtype=bool)
|
||||||
_paint_ring(stack, poly.outline, True, pmask)
|
_paint_ring(stack, poly.outline, True, pmask)
|
||||||
for hole in poly.holes:
|
for hole in poly.holes:
|
||||||
_paint_ring(stack, hole, False, pmask)
|
_paint_ring(stack, hole, False, pmask)
|
||||||
stack.masks[li] |= pmask
|
stack.masks[li] |= pmask
|
||||||
|
else:
|
||||||
|
# hole-less (e.g. one of many track outlines): paint the
|
||||||
|
# layer mask directly, skipping the full-frame temp
|
||||||
|
_paint_ring(stack, poly.outline, True, stack.masks[li])
|
||||||
|
|
||||||
if problem.buildups:
|
if problem.buildups:
|
||||||
stack.buildup = np.zeros_like(stack.masks)
|
stack.buildup = np.zeros_like(stack.masks)
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://go.kicad.org/pcm/schemas/v2",
|
"$schema": "https://go.kicad.org/pcm/schemas/v2",
|
||||||
"name": "Fill Resistance",
|
"name": "Fill Resistance",
|
||||||
"description": "DC/AC resistance of copper zone fills between two contacts, single- or multi-layer with via coupling, with current and power density maps.",
|
"description": "DC/AC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps.",
|
||||||
"description_full": "Computes the DC or AC resistance of copper zone fills between two contacts (marker rectangles on User.1/User.2 and/or selected pads), single- or multi-layer: the chosen net's fills are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. At a user-set frequency the exact 1D foil/barrel skin-effect correction is applied (AC results are a rigorous lower bound). PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
|
"description_full": "Computes the DC or AC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. At a user-set frequency the exact 1D foil/barrel skin-effect correction is applied (AC results are a rigorous lower bound). PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
|
||||||
"identifier": "th.co.b4l.fill-resistance",
|
"identifier": "th.co.b4l.fill-resistance",
|
||||||
"type": "plugin",
|
"type": "plugin",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://go.kicad.org/api/schemas/v1",
|
"$schema": "https://go.kicad.org/api/schemas/v1",
|
||||||
"identifier": "th.co.b4l.fill-resistance",
|
"identifier": "th.co.b4l.fill-resistance",
|
||||||
"name": "Fill Resistance",
|
"name": "Fill Resistance",
|
||||||
"description": "DC/AC resistance of copper zone fills between two contacts (marker rectangles or pads), single- or multi-layer with via coupling",
|
"description": "DC/AC resistance of copper zone fills and traces between two contacts (marker rectangles or pads), single- or multi-layer with via coupling",
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"type": "python"
|
"type": "python"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Track (trace) conductor tests: capsule / arc-band outline generation
|
||||||
|
and solves on rasterized traces. The 1-cell-wide capsule chain is exact;
|
||||||
|
the arc band is checked against the analytic annular-sector resistance."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fill_resistance import raster, solver
|
||||||
|
from fill_resistance.geometry import (Electrode, LayerFill, Polygon, Problem,
|
||||||
|
arc_band_ring, capsule_ring)
|
||||||
|
from tests.util import NM, rect_mm, sigma_s
|
||||||
|
|
||||||
|
TOL_NM = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
def _track_problem(rings, rect1, rect2, t_um=70.0):
|
||||||
|
return Problem(
|
||||||
|
board_path="synthetic", net_name="TEST", rho_ohm_m=1.68e-8,
|
||||||
|
plating_nm=18_000,
|
||||||
|
layers=[LayerFill(layer_name="F.Cu", thickness_nm=int(t_um * 1000),
|
||||||
|
z_nm=0,
|
||||||
|
polygons=[Polygon(outline=r) for r in rings])],
|
||||||
|
vias=[],
|
||||||
|
electrodes1=[Electrode(rect=rect_mm(rect1))],
|
||||||
|
electrodes2=[Electrode(rect=rect_mm(rect2))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _solve(problem, h_mm):
|
||||||
|
stack = raster.rasterize_stack(problem, h_mm * NM)
|
||||||
|
e1, e2 = raster.electrode_masks(stack, problem)
|
||||||
|
return solver.run_solve(problem, stack, e1, e2, 1.0,
|
||||||
|
contact_model="equipotential"), stack
|
||||||
|
|
||||||
|
|
||||||
|
def test_straight_track_exact_chain():
|
||||||
|
"""A 1.2 mm wide capsule at h = 1 mm rasterizes to a single-cell-high
|
||||||
|
row (the grid origin floats with the polygon bbox, so the count is
|
||||||
|
taken from the mask): an N-cell chain solves to exactly (N-1) faces."""
|
||||||
|
ring = capsule_ring(1 * NM, NM // 2, 9 * NM, NM // 2,
|
||||||
|
int(1.2 * NM), TOL_NM)
|
||||||
|
p = _track_problem([ring], (0, 0, 1.5, 1), (8.5, 0, 10, 1))
|
||||||
|
res, stack = _solve(p, 1.0)
|
||||||
|
m = stack.masks[0]
|
||||||
|
rows = np.flatnonzero(m.any(axis=1))
|
||||||
|
assert len(rows) == 1 # one 1-cell-high chain
|
||||||
|
n = int(m.sum())
|
||||||
|
assert n >= 8
|
||||||
|
assert res.R_ohm == pytest.approx((n - 1) / sigma_s(), rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_capsule_zero_length_is_circle():
|
||||||
|
ring = capsule_ring(5 * NM, 5 * NM, 5 * NM, 5 * NM, 2 * NM, TOL_NM)
|
||||||
|
d = np.hypot(ring[:, 0] - 5 * NM, ring[:, 1] - 5 * NM)
|
||||||
|
assert np.allclose(d, NM, atol=TOL_NM + 2)
|
||||||
|
assert len(ring) >= 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_capsule_ring_geometry():
|
||||||
|
"""Every outline point lies on the capsule boundary: at half-width
|
||||||
|
from the centerline segment."""
|
||||||
|
ring = capsule_ring(2 * NM, 3 * NM, 17 * NM, 11 * NM,
|
||||||
|
int(1.5 * NM), TOL_NM)
|
||||||
|
a = np.array([2 * NM, 3 * NM], dtype=float)
|
||||||
|
b = np.array([17 * NM, 11 * NM], dtype=float)
|
||||||
|
ab = b - a
|
||||||
|
t = np.clip(((ring - a) @ ab) / (ab @ ab), 0.0, 1.0)
|
||||||
|
d = np.hypot(*(ring - (a + t[:, None] * ab)).T)
|
||||||
|
assert np.allclose(d, 0.75 * NM, atol=TOL_NM + 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_band_ring_geometry():
|
||||||
|
"""Arc-band points lie on the annulus walls or on the end caps."""
|
||||||
|
start, mid, end = ((10 * NM, 0), (int(10 * NM / math.sqrt(2)),
|
||||||
|
int(10 * NM / math.sqrt(2))),
|
||||||
|
(0, 10 * NM))
|
||||||
|
ring = arc_band_ring(start, mid, end, 1 * NM, TOL_NM).astype(float)
|
||||||
|
r = np.hypot(ring[:, 0], ring[:, 1])
|
||||||
|
on_annulus = (np.abs(r - 10.5 * NM) < TOL_NM + 2) \
|
||||||
|
| (np.abs(r - 9.5 * NM) < TOL_NM + 2)
|
||||||
|
d_start = np.hypot(ring[:, 0] - start[0], ring[:, 1] - start[1])
|
||||||
|
d_end = np.hypot(ring[:, 0] - end[0], ring[:, 1] - end[1])
|
||||||
|
on_caps = (d_start < 0.5 * NM + TOL_NM + 2) | (d_end < 0.5 * NM + TOL_NM + 2)
|
||||||
|
assert (on_annulus | on_caps).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_collinear_arc_degrades_to_capsule():
|
||||||
|
cap = capsule_ring(0, 0, 10 * NM, 0, NM, TOL_NM)
|
||||||
|
band = arc_band_ring((0, 0), (5 * NM, 0), (10 * NM, 0), NM, TOL_NM)
|
||||||
|
assert np.array_equal(cap, band)
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_track_matches_annular_sector():
|
||||||
|
"""90 deg arc trace, r = 10 mm, w = 1 mm: R = theta / (sigma *
|
||||||
|
ln(r_out/r_in)) between the radial end faces (electrodes cover the
|
||||||
|
end caps). The staircase on the curved walls narrows the band, so R
|
||||||
|
converges to the analytic value from above as h shrinks."""
|
||||||
|
start = (10 * NM, 0)
|
||||||
|
mid = (int(round(10 * NM / math.sqrt(2))),
|
||||||
|
int(round(10 * NM / math.sqrt(2))))
|
||||||
|
end = (0, 10 * NM)
|
||||||
|
ring = arc_band_ring(start, mid, end, 1 * NM, TOL_NM)
|
||||||
|
|
||||||
|
def solve_at(h_mm):
|
||||||
|
p = _track_problem([ring], rect1=(9.3, -0.8, 10.7, 0.05),
|
||||||
|
rect2=(-0.8, 9.3, 0.05, 10.7))
|
||||||
|
res, _ = _solve(p, h_mm)
|
||||||
|
return res.R_ohm
|
||||||
|
|
||||||
|
r_exact = (math.pi / 2) / (sigma_s() * math.log(10.5 / 9.5))
|
||||||
|
err_coarse = abs(solve_at(0.1) / r_exact - 1)
|
||||||
|
err_fine = abs(solve_at(0.05) / r_exact - 1)
|
||||||
|
assert err_fine < err_coarse # converges toward analytic
|
||||||
|
assert err_fine < 0.04
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_unions_with_fill():
|
||||||
|
"""A trace overlapping a plate merges into one conductor: the mask is
|
||||||
|
the union, and R drops when the trace bridges a slot."""
|
||||||
|
plate = [(0, 0), (20, 0), (20, 10), (0, 10)]
|
||||||
|
slot = [(9, 2), (11, 2), (11, 10), (9, 10)] # slot open to the top
|
||||||
|
plate_poly = Polygon(
|
||||||
|
outline=np.array([(x * NM, y * NM) for x, y in plate]),
|
||||||
|
holes=[np.array([(x * NM, y * NM) for x, y in slot])])
|
||||||
|
bridge = capsule_ring(6 * NM, 6 * NM, 14 * NM, 6 * NM,
|
||||||
|
int(1.2 * NM), TOL_NM)
|
||||||
|
|
||||||
|
def problem(polys):
|
||||||
|
return Problem(
|
||||||
|
board_path="synthetic", net_name="TEST", rho_ohm_m=1.68e-8,
|
||||||
|
plating_nm=18_000,
|
||||||
|
layers=[LayerFill(layer_name="F.Cu", thickness_nm=70_000,
|
||||||
|
z_nm=0, polygons=polys)],
|
||||||
|
vias=[],
|
||||||
|
electrodes1=[Electrode(rect=rect_mm((0, 0, 1, 10)))],
|
||||||
|
electrodes2=[Electrode(rect=rect_mm((19, 0, 20, 10)))],
|
||||||
|
)
|
||||||
|
|
||||||
|
r_plate, s_plate = _solve(problem([plate_poly]), 0.25)
|
||||||
|
r_both, s_both = _solve(problem([plate_poly,
|
||||||
|
Polygon(outline=bridge)]), 0.25)
|
||||||
|
assert int(s_both.masks.sum()) > int(s_plate.masks.sum())
|
||||||
|
assert r_both.R_ohm < 0.75 * r_plate.R_ohm # bridge shortens the detour
|
||||||
|
assert r_both.power_balance_rel < 1e-9
|
||||||
Reference in New Issue
Block a user