From 56e2f9c81a46c3f02523d41d23ac478545173004 Mon Sep 17 00:00:00 2001 From: janik Date: Wed, 15 Jul 2026 15:37:51 +0700 Subject: [PATCH] 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 --- README.md | 16 ++-- fill_resistance/board_io.py | 58 ++++++++++++--- fill_resistance/config.py | 3 + fill_resistance/dialog.py | 9 ++- fill_resistance/geometry.py | 104 ++++++++++++++++++++++---- fill_resistance/main.py | 15 ++-- fill_resistance/raster.py | 15 ++-- metadata.json | 4 +- plugin.json | 2 +- tests/test_tracks.py | 145 ++++++++++++++++++++++++++++++++++++ 10 files changed, 327 insertions(+), 44 deletions(-) create mode 100644 tests/test_tracks.py diff --git a/README.md b/README.md index 715db6f..db358eb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Fill Resistance — KiCad 10 plugin -Computes the **DC or AC resistance of copper zone fills** between two -contacts, **single- or multi-layer**: the chosen net's fills on the -selected copper layers are solved as coupled finite-difference sheets -linked by the net's **via and through-hole-pad barrels** (18 µm plating, -configurable). At a user-set **frequency** the exact 1D foil/barrel +Computes the **DC or AC resistance of copper zone fills and traces** +between two contacts, **single- or multi-layer**: the chosen net's fills +and tracks on the selected copper layers are solved as coupled +finite-difference sheets linked by the net's **via and through-hole-pad +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 bound — see *Model & limits*). Shows per-layer rasterized maps, 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 the layers above/below with the full barrel length. Barrels that reach fill on fewer than two layers carry no current and are reported. -- Tracks and pad copper (other than the selected contacts) are **not** - part of the conductor model — zone fills + barrels only. +- The net's **traces** (straight and arc tracks, exact outline polygons + 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 default**; `INCLUDE_MASK_BUILDUP`): zones drawn on `F.Mask`/`B.Mask` are treated as mask openings that collect `SOLDER_THICKNESS_UM` diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index 7aaad59..8220d59 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -11,7 +11,7 @@ from pathlib import Path from kipy import KiCad 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_types_pb2 import ZoneType 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 .errors import ApiVersionError, CandidateError, SelectionError 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"} @@ -302,6 +303,39 @@ def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]: 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 "" + 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: for p in polygons: 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], stackup: StackupInfo, fills: dict, 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_tracks = (tracks or {}).get(net, {}) layers = [] for name in stackup.names: # keep stackup order if name not in layer_names: continue - polys = per_layer.get(name, []) + polys = (list(per_layer.get(name, [])) + + list(per_layer_tracks.get(name, []))) 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 if config.COPPER_THICKNESS_UM is not None: 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) 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) " 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"{', '.join(b.layer_name for b in buildup_list)}" if buildup_list else "")) @@ -477,7 +515,9 @@ if __name__ == "__main__": if any_zone_unfilled(board): refill(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: net = sys.argv[2] elif net_hint in nets: @@ -487,7 +527,7 @@ if __name__ == "__main__": else: print(f"candidate nets: {nets}; pass one as second argument") sys.exit(1) - problem = build_problem(board, net, list(fills.get(net, {})), es1, es2, - stackup, fills) + problem = build_problem(board, net, list(copper.get(net, {})), es1, es2, + stackup, fills, tracks=tracks) save_problem(problem, out) print(f"wrote {out}") diff --git a/fill_resistance/config.py b/fill_resistance/config.py index f3bced3..350ca19 100644 --- a/fill_resistance/config.py +++ b/fill_resistance/config.py @@ -39,6 +39,9 @@ BUILDUP_EXTRA_CU_UM = 0.0 # optional user-added copper (busbar/wire # soldered into the opening); dialog-settable # --- 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 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 diff --git a/fill_resistance/dialog.py b/fill_resistance/dialog.py index 50c633e..2949689 100644 --- a/fill_resistance/dialog.py +++ b/fill_resistance/dialog.py @@ -35,6 +35,7 @@ class Selection: contact_model: str = "uniform" include_buildup: bool = False extra_cu_um: float = 0.0 + include_tracks: bool = True class _Dialog(QDialog): @@ -59,6 +60,11 @@ class _Dialog(QDialog): self.layer_list.setMaximumHeight(120) 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.contact2_box = QComboBox() form.addRow(f"V+ ({e1_label}):", self.contact1_box) @@ -199,7 +205,8 @@ class _Dialog(QDialog): freq_hz=freq, contact_model=self.model_box.currentData(), 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: try: diff --git a/fill_resistance/geometry.py b/fill_resistance/geometry.py index 0f5d0fb..7e1e06e 100644 --- a/fill_resistance/geometry.py +++ b/fill_resistance/geometry.py @@ -132,10 +132,9 @@ class Problem: return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.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).""" +def _arc_params(start, mid, end) -> tuple[float, float, float, float, float] | None: + """Circle through three points: (cx, cy, r, a0, sweep) with a0 the + start angle and sweep signed; None if the points are collinear.""" sx, sy = float(start[0]), float(start[1]) mx, my = float(mid[0]), float(mid[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)) chord = math.hypot(ex - sx, ey - sy) if abs(d) < 1e-9 * max(chord, 1.0): - return np.array([[start[0], start[1]]], dtype=np.int64) - ux = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy) + return None + cx = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy) + (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 - r = math.hypot(sx - ux, sy - uy) + r = math.hypot(sx - cx, sy - cy) - a0 = math.atan2(sy - uy, sx - ux) - a1 = math.atan2(my - uy, mx - ux) - a2 = math.atan2(ey - uy, ex - ux) + a0 = math.atan2(sy - cy, sx - cx) + a1 = math.atan2(my - cy, mx - cx) + a2 = math.atan2(ey - cy, ex - cx) two_pi = 2.0 * math.pi d01 = (a1 - a0) % two_pi d02 = (a2 - a0) % 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) 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) 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) +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: """nodes: list of ('pt', (x, y)) or ('arc', (start, mid, end)) tuples, already in board nm. Returns an (N, 2) int64 open ring.""" diff --git a/fill_resistance/main.py b/fill_resistance/main.py index 1f99c18..5d4271e 100644 --- a/fill_resistance/main.py +++ b/fill_resistance/main.py @@ -37,7 +37,9 @@ def main() -> None: if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL: board_io.refill(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) except ApiError as e: raise UserFacingError( @@ -47,9 +49,9 @@ def main() -> None: if not candidate_nets: raise CandidateError( - "No copper zone fill overlaps both contacts. Check that both " - "sit over (or in) filled pours and that the fills are up to " - "date (press B in the board editor)." + "No copper (zone fill or trace) overlaps both contacts. " + "Check that both sit over copper of the same net and that " + "the fills are up to date (press B in the board editor)." ) def group_label(parts): @@ -64,7 +66,7 @@ def main() -> None: default_net = (net_hint if net_hint in candidate_nets else candidate_nets[0]) 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, default_net=default_net, 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, fills, 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)) except ApiError as e: raise UserFacingError(f"KiCad API error: {e}") diff --git a/fill_resistance/raster.py b/fill_resistance/raster.py index 19672ad..e35933c 100644 --- a/fill_resistance/raster.py +++ b/fill_resistance/raster.py @@ -167,11 +167,16 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack: ) for li, layer in enumerate(problem.layers): for poly in layer.polygons: - pmask = np.zeros((ny, nx), dtype=bool) - _paint_ring(stack, poly.outline, True, pmask) - for hole in poly.holes: - _paint_ring(stack, hole, False, pmask) - stack.masks[li] |= pmask + if poly.holes: + pmask = np.zeros((ny, nx), dtype=bool) + _paint_ring(stack, poly.outline, True, pmask) + for hole in poly.holes: + _paint_ring(stack, hole, False, 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: stack.buildup = np.zeros_like(stack.masks) diff --git a/metadata.json b/metadata.json index 160b64d..c949150 100644 --- a/metadata.json +++ b/metadata.json @@ -1,8 +1,8 @@ { "$schema": "https://go.kicad.org/pcm/schemas/v2", "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_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": "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 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", "type": "plugin", "author": { diff --git a/plugin.json b/plugin.json index 4ecf4d4..853684f 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://go.kicad.org/api/schemas/v1", "identifier": "th.co.b4l.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": { "type": "python" }, diff --git a/tests/test_tracks.py b/tests/test_tracks.py new file mode 100644 index 0000000..dbac7e2 --- /dev/null +++ b/tests/test_tracks.py @@ -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