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:
janik
2026-07-15 15:37:51 +07:00
parent b62e45a9b4
commit 56e2f9c81a
10 changed files with 327 additions and 44 deletions
+9 -7
View File
@@ -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`
+49 -9
View File
@@ -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}")
+3
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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."""
+9 -6
View File
@@ -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}")
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}, },
+145
View File
@@ -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