diff --git a/README.md b/README.md index db358eb..ca18e8d 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,12 @@ SWIG API. Requires KiCad **10.0.1+**. fill on fewer than two layers carry no current and are reported. - 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 + on by default (`INCLUDE_TRACKS`). Traces narrower than + `TRACK_1D_FACTOR` (3) grid cells are modeled as exact **1D resistor + chains** along their centerline — true arc length per link, so their + series resistance carries no discretization error and no cell-size + tuning is needed for thin traces. 1D-modeled traces show potential and + power density but no |J| field. 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` diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index 8220d59..d1a8f62 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -17,11 +17,12 @@ from kipy.proto.board.board_types_pb2 import ZoneType from kipy.util.board_layer import (canonical_name, is_copper_layer, layer_from_canonical_name) +import numpy as np + from . import config from .errors import ApiVersionError, CandidateError, SelectionError from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect, - SurfaceBuildup, ViaLink, arc_band_ring, capsule_ring, - linearize_ring) + SurfaceBuildup, TrackSeg, ViaLink, linearize_ring) MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"} @@ -303,10 +304,11 @@ 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]]] = {} +def gather_net_tracks(board: Board) -> dict[str, dict[str, list[TrackSeg]]]: + """net -> layer -> TrackSeg (centerline + width). Traces conduct + together with the zone fills; the raster decides per run whether a + trace is rasterized from its outline or becomes a 1D chain.""" + out: dict[str, dict[str, list[TrackSeg]]] = {} for t in board.get_tracks(): if not is_copper_layer(t.layer): continue @@ -314,17 +316,29 @@ def gather_net_tracks(board: Board) -> dict[str, dict[str, list[Polygon]]]: 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) + pts = np.array([[t.start.x, t.start.y], [t.mid.x, t.mid.y], + [t.end.x, t.end.y]], dtype=np.int64) else: - ring = capsule_ring(t.start.x, t.start.y, t.end.x, t.end.y, - width, ARC_TOL_NM) + pts = np.array([[t.start.x, t.start.y], [t.end.x, t.end.y]], + dtype=np.int64) net = t.net.name if t.net is not None else "" - out.setdefault(net, {}).setdefault( - canonical_name(t.layer), []).append(Polygon(outline=ring)) + layer = canonical_name(t.layer) + out.setdefault(net, {}).setdefault(layer, []).append( + TrackSeg(layer_name=layer, points=pts, width_nm=width)) return out +def tracks_as_polygons(tracks: dict) -> dict: + """net -> layer -> outline polygons of the tracks (for the bbox-based + candidate detection; the Problem keeps the TrackSegs themselves).""" + return { + net: {layer: [Polygon(outline=seg.outline(ARC_TOL_NM)) + for seg in segs] + for layer, segs in per_layer.items()} + for net, per_layer in tracks.items() + } + + 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).""" @@ -452,12 +466,13 @@ def build_problem(board: Board, net: str, layer_names: list[str], per_layer = fills.get(net, {}) per_layer_tracks = (tracks or {}).get(net, {}) layers = [] + segs: list[TrackSeg] = [] for name in stackup.names: # keep stackup order if name not in layer_names: continue - polys = (list(per_layer.get(name, [])) - + list(per_layer_tracks.get(name, []))) - if not polys: + polys = list(per_layer.get(name, [])) + layer_segs = per_layer_tracks.get(name, []) + if not polys and not layer_segs: print(f"note: net {net} has no copper on {name} - layer skipped") continue if config.COPPER_THICKNESS_UM is not None: @@ -466,6 +481,7 @@ def build_problem(board: Board, net: str, layer_names: list[str], t = stackup.thickness_nm[name] layers.append(LayerFill(layer_name=name, thickness_nm=t, z_nm=stackup.z_nm[name], polygons=polys)) + segs.extend(layer_segs) if not layers: raise CandidateError( f"Net {net} has no fill on any of the selected layers " @@ -477,10 +493,9 @@ 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"{n_tracks} track(s), {len(vias)} via/pad barrel(s)" + f"{len(segs)} 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 "")) @@ -500,6 +515,7 @@ def build_problem(board: Board, net: str, layer_names: list[str], solder_rho_ohm_m=config.SOLDER_RHO_OHM_M, extra_cu_nm=int((extra_cu_um if extra_cu_um is not None else config.BUILDUP_EXTRA_CU_UM) * 1000), + tracks=segs, ) @@ -516,7 +532,7 @@ if __name__ == "__main__": refill(board) fills = gather_net_fills(board) tracks = gather_net_tracks(board) if config.INCLUDE_TRACKS else {} - copper = merge_copper(fills, tracks) + copper = merge_copper(fills, tracks_as_polygons(tracks)) nets = nets_overlapping(copper, es1, es2) if len(sys.argv) > 2: net = sys.argv[2] diff --git a/fill_resistance/config.py b/fill_resistance/config.py index 350ca19..732e25e 100644 --- a/fill_resistance/config.py +++ b/fill_resistance/config.py @@ -42,6 +42,11 @@ BUILDUP_EXTRA_CU_UM = 0.0 # optional user-added copper (busbar/wire INCLUDE_TRACKS = True # the net's traces (straight + arc tracks) # conduct together with the zone fills; # dialog-toggleable +TRACK_1D_FACTOR = 3.0 # traces narrower than this many grid cells + # become exact 1D resistor chains along their + # centerline instead of rasterized outlines + # (no discretization error in the trace R; + # 0 = always rasterize) 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/geometry.py b/fill_resistance/geometry.py index 7e1e06e..1197998 100644 --- a/fill_resistance/geometry.py +++ b/fill_resistance/geometry.py @@ -17,7 +17,7 @@ from pathlib import Path import numpy as np -JSON_SCHEMA_VERSION = 4 +JSON_SCHEMA_VERSION = 5 @dataclass(frozen=True) @@ -63,6 +63,34 @@ class SurfaceBuildup: polygons: list[Polygon] +@dataclass +class TrackSeg: + """One trace segment: straight ((2, 2) points) or arc ((3, 2) + start/mid/end points). Kept as centerline + width so the raster can + decide per run: wide traces are rasterized from their outline, + traces narrower than TRACK_1D_FACTOR grid cells become exact 1D + resistor chains along the centerline.""" + layer_name: str + points: np.ndarray # (2|3, 2) int64 nm + width_nm: int + + def outline(self, tol_nm: float) -> np.ndarray: + if len(self.points) == 3: + return arc_band_ring(self.points[0], self.points[1], + self.points[2], self.width_nm, tol_nm) + return capsule_ring(int(self.points[0][0]), int(self.points[0][1]), + int(self.points[1][0]), int(self.points[1][1]), + self.width_nm, tol_nm) + + def centerline(self, tol_nm: float) -> np.ndarray: + """(N, 2) float polyline along the trace center, start to end.""" + if len(self.points) == 3: + pts = arc_points(self.points[0], self.points[1], self.points[2], + tol_nm) + return np.vstack([pts, self.points[2][None, :]]).astype(float) + return self.points.astype(float) + + @dataclass class Electrode: """One PART of a current-injection terminal: a drawn rectangle or a @@ -115,6 +143,7 @@ class Problem: solder_thickness_nm: int = 50_000 solder_rho_ohm_m: float = 1.32e-7 extra_cu_nm: int = 0 + tracks: list[TrackSeg] = field(default_factory=list) @property def layer_names(self) -> list[str]: @@ -125,11 +154,15 @@ class Problem: return (self.layers[layer_index].thickness_nm * 1e-9) / self.rho_ohm_m def copper_bbox(self) -> tuple[int, int, int, int]: - xs = np.concatenate([p.outline[:, 0] - for l in self.layers for p in l.polygons]) - ys = np.concatenate([p.outline[:, 1] - for l in self.layers for p in l.polygons]) - return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()) + xs = [p.outline[:, 0] for l in self.layers for p in l.polygons] + ys = [p.outline[:, 1] for l in self.layers for p in l.polygons] + for seg in self.tracks: + ring = seg.outline(100_000.0) # coarse tol: bbox only + xs.append(ring[:, 0]) + ys.append(ring[:, 1]) + x = np.concatenate(xs) + y = np.concatenate(ys) + return int(x.min()), int(y.min()), int(x.max()), int(y.max()) def _arc_params(start, mid, end) -> tuple[float, float, float, float, float] | None: @@ -313,6 +346,11 @@ def problem_to_json(p: Problem) -> dict: for l in p.layers ], "vias": [vars(v) | {} for v in p.vias], + "tracks": [ + {"layer_name": s.layer_name, "points": s.points.tolist(), + "width_nm": s.width_nm} + for s in p.tracks + ], "buildups": [ {"layer_name": b.layer_name, "polygons": [_poly_to_json(poly) for poly in b.polygons]} @@ -386,6 +424,12 @@ def problem_from_json(d: dict) -> Problem: solder_thickness_nm=int(d.get("solder_thickness_nm", 50_000)), solder_rho_ohm_m=float(d.get("solder_rho_ohm_m", 1.32e-7)), extra_cu_nm=int(d.get("extra_cu_nm", 0)), + tracks=[ + TrackSeg(layer_name=td["layer_name"], + points=np.asarray(td["points"], dtype=np.int64), + width_nm=int(td["width_nm"])) + for td in d.get("tracks", []) # <= v4: baked into polygons + ], ) diff --git a/fill_resistance/main.py b/fill_resistance/main.py index 5d4271e..cda6073 100644 --- a/fill_resistance/main.py +++ b/fill_resistance/main.py @@ -38,7 +38,8 @@ def main() -> None: board_io.refill(board) fills = board_io.gather_net_fills(board) tracks = board_io.gather_net_tracks(board) - copper = board_io.merge_copper(fills, tracks) + copper = board_io.merge_copper( + fills, board_io.tracks_as_polygons(tracks)) candidate_nets = board_io.nets_overlapping(copper, es1, es2) buildups = board_io.gather_mask_buildups(board) except ApiError as e: diff --git a/fill_resistance/raster.py b/fill_resistance/raster.py index e35933c..94443eb 100644 --- a/fill_resistance/raster.py +++ b/fill_resistance/raster.py @@ -38,6 +38,10 @@ class RasterStack: layer_names: list[str] buildup: np.ndarray | None = None # bool (L, ny, nx): solder buildup # (mask opening ∩ copper) + chain: np.ndarray | None = None # bool (L, ny, nx): cells that are + # copper only through a 1D trace chain + chain_edges: tuple | None = None # (a, b, g_dc, layer) arrays: explicit + # DC conductances of the chain links @property def nlayers(self) -> int: @@ -174,10 +178,29 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack: _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 + # hole-less (e.g. a track outline): paint the layer mask + # directly, skipping the full-frame temp _paint_ring(stack, poly.outline, True, stack.masks[li]) + # traces: wide ones are rasterized from their outline, sub-resolution + # ones become exact 1D resistor chains along their centerline + index = {name: li for li, name in enumerate(stack.layer_names)} + narrow = [] + for seg in problem.tracks: + li = index.get(seg.layer_name) + if li is None: + continue + if seg.width_nm >= config.TRACK_1D_FACTOR * h_nm: + _paint_ring(stack, seg.outline(config.ARC_TOL_FRACTION * h_nm), + True, stack.masks[li]) + else: + narrow.append((li, seg)) + if narrow: + n_links = _build_chains(stack, problem, narrow) + print(f"{len(narrow)} trace(s) narrower than " + f"{config.TRACK_1D_FACTOR:g} cells modeled as 1D resistor " + f"chains ({n_links} links)") + if problem.buildups: stack.buildup = np.zeros_like(stack.masks) index = {name: li for li, name in enumerate(stack.layer_names)} @@ -195,6 +218,61 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack: return stack +def _build_chains(stack: RasterStack, problem: Problem, + narrow: list) -> int: + """Sub-resolution traces as 1D resistor chains: mark the cells their + centerline crosses as copper and record one explicit conductance per + pair of consecutive cells, allocating the trace's TRUE arc length to + each link (a diagonal trace is not staircase-inflated). Links whose + cells are already regular copper AND face-adjacent are skipped there + (the trace merges into the pour: union, not sum). Returns the number + of links.""" + L, ny, nx = stack.masks.shape + plane = ny * nx + h = stack.h_nm + regular = stack.masks.copy() + chain = np.zeros_like(stack.masks) + aa, bb, gg, ll = [], [], [], [] + for li, seg in narrow: + pts = seg.centerline(0.2 * h) + d = np.hypot(*np.diff(pts, axis=0).T) + s = np.concatenate([[0.0], np.cumsum(d)]) + length = float(s[-1]) + n_samp = max(2, int(math.ceil(length / (h / 3.0))) + 1) + ss = np.linspace(0.0, length, n_samp) + xs = np.interp(ss, s, pts[:, 0]) + ys = np.interp(ss, s, pts[:, 1]) + jj = np.floor((xs - stack.x0_nm) / h).astype(np.int64) + ii = np.floor((ys - stack.y0_nm) / h).astype(np.int64) + jj = np.clip(jj, 0, nx - 1) # bbox includes all tracks; + ii = np.clip(ii, 0, ny - 1) # clip only guards rounding + first = np.concatenate( + [[True], (ii[1:] != ii[:-1]) | (jj[1:] != jj[:-1])]) + ci, cj, cs = ii[first], jj[first], ss[first] + chain[li, ci, cj] = True + g0 = (seg.width_nm * 1e-9 + * problem.layers[li].thickness_nm * 1e-9 / problem.rho_ohm_m) + for k in range(len(ci) - 1): + dl = (cs[k + 1] - cs[k]) * 1e-9 + if dl <= 0: + continue + adj4 = abs(int(ci[k + 1] - ci[k])) + abs(int(cj[k + 1] - cj[k])) == 1 + if adj4 and regular[li, ci[k], cj[k]] \ + and regular[li, ci[k + 1], cj[k + 1]]: + continue # pour conducts here already + aa.append(li * plane + int(ci[k]) * nx + int(cj[k])) + bb.append(li * plane + int(ci[k + 1]) * nx + int(cj[k + 1])) + gg.append(g0 / dl) + ll.append(li) + stack.chain = chain & ~regular + stack.masks |= stack.chain + stack.chain_edges = (np.asarray(aa, dtype=np.int64), + np.asarray(bb, dtype=np.int64), + np.asarray(gg, dtype=float), + np.asarray(ll, dtype=np.int64)) + return len(aa) + + def _rect_cells(stack: RasterStack, rect: Rect) -> np.ndarray: """Bool (ny, nx) mask of cells whose center lies inside the rectangle.""" ny, nx = stack.shape2d diff --git a/fill_resistance/solver.py b/fill_resistance/solver.py index 0d6d850..e575975 100644 --- a/fill_resistance/solver.py +++ b/fill_resistance/solver.py @@ -141,6 +141,10 @@ def build_edges(stack: RasterStack, problem: Problem, sigmas: list[float], for li in range(L): m = stack.masks[li] + if stack.chain is not None: + # chain-only cells connect through their explicit 1D links, + # never through sheet faces (their copper is narrower than h) + m = m & ~stack.chain[li] sig = sigmas[li] scell = _sigma_2d(stack, li, sig, sigma_buildup) base = li * plane @@ -207,6 +211,19 @@ def build_edges(stack: RasterStack, problem: Problem, sigmas: list[float], ww.append(np.array([1.0 / r])) vv.append(np.array([vi], dtype=np.int32)) + if stack.chain_edges is not None and len(stack.chain_edges[0]): + ca, cb, cg, cl = stack.chain_edges + alive = stack.masks.ravel()[ca] & stack.masks.ravel()[cb] + if alive.any(): + # skin correction: scale like the layer's sheet conductance + fac = np.array([sigmas[l] * problem.rho_ohm_m + / (problem.layers[l].thickness_nm * 1e-9) + for l in range(L)]) + aa.append(ca[alive]) + bb.append(cb[alive]) + ww.append((cg * fac[cl])[alive]) + vv.append(np.full(int(alive.sum()), -1, dtype=np.int32)) + if not aa: raise ConnectivityError("No copper found on the selected layers.") return Edges(a=np.concatenate(aa), b=np.concatenate(bb), @@ -540,6 +557,8 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray, ) if stack.buildup is not None: stack.buildup &= stack.masks + if stack.chain is not None: + stack.chain &= stack.masks for label, m in (parts1 or []) + (parts2 or []): had = bool(m.any()) m &= stack.masks # follow the component restriction diff --git a/fill_resistance/standalone.py b/fill_resistance/standalone.py index 4999e31..47b1f89 100644 --- a/fill_resistance/standalone.py +++ b/fill_resistance/standalone.py @@ -61,6 +61,7 @@ def main(argv=None) -> int: if args.layers: keep = [s.strip() for s in args.layers.split(",")] problem.layers = [l for l in problem.layers if l.layer_name in keep] + problem.tracks = [t for t in problem.tracks if t.layer_name in keep] if not problem.layers: print(f"ERROR: no layer of the dump matches --layers {args.layers}", file=sys.stderr) diff --git a/metadata.json b/metadata.json index c949150..8e8fd7f 100644 --- a/metadata.json +++ b/metadata.json @@ -2,7 +2,7 @@ "$schema": "https://go.kicad.org/pcm/schemas/v2", "name": "Fill Resistance", "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.", + "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; traces narrower than the grid become exact 1D resistor chains.\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/tests/test_tracks.py b/tests/test_tracks.py index dbac7e2..9799f54 100644 --- a/tests/test_tracks.py +++ b/tests/test_tracks.py @@ -1,6 +1,6 @@ -"""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.""" +"""Track (trace) conductor tests: capsule / arc-band outline generation, +solves on rasterized traces, and the 1D resistor-chain model for traces +narrower than TRACK_1D_FACTOR grid cells.""" import math import numpy as np @@ -8,10 +8,13 @@ import pytest from fill_resistance import raster, solver from fill_resistance.geometry import (Electrode, LayerFill, Polygon, Problem, - arc_band_ring, capsule_ring) + TrackSeg, arc_band_ring, capsule_ring, + load_problem, save_problem) from tests.util import NM, rect_mm, sigma_s TOL_NM = 10_000 +RHO = 1.68e-8 +T_M = 70e-6 def _track_problem(rings, rect1, rect2, t_um=70.0): @@ -27,6 +30,26 @@ def _track_problem(rings, rect1, rect2, t_um=70.0): ) +def _seg(points_mm, w_mm, layer="F.Cu") -> TrackSeg: + pts = (np.asarray(points_mm, dtype=float) * NM).astype(np.int64) + return TrackSeg(layer_name=layer, points=pts, width_nm=int(w_mm * NM)) + + +def _seg_problem(segs, rect1, rect2, fills_mm=(), t_um=70.0): + polys = [Polygon(outline=(np.asarray(o, dtype=float) * NM + ).astype(np.int64)) for o in fills_mm] + return Problem( + board_path="synthetic", net_name="TEST", rho_ohm_m=RHO, + plating_nm=18_000, + layers=[LayerFill(layer_name="F.Cu", thickness_nm=int(t_um * 1000), + z_nm=0, polygons=polys)], + vias=[], + electrodes1=[Electrode(rect=rect_mm(rect1))], + electrodes2=[Electrode(rect=rect_mm(rect2))], + tracks=segs, + ) + + def _solve(problem, h_mm): stack = raster.rasterize_stack(problem, h_mm * NM) e1, e2 = raster.electrode_masks(stack, problem) @@ -115,6 +138,99 @@ def test_arc_track_matches_annular_sector(): assert err_fine < 0.04 +def test_narrow_trace_1d_matches_fine_raster(): + """A 0.2 mm trace at h = 1 mm (1D chain) must agree with the same + trace finely rasterized at h = 0.05 mm (4 cells wide) and with the + analytic R between the electrode inner edges.""" + seg = _seg([(1, 0.5), (41, 0.5)], 0.2) + p = _seg_problem([seg], (0, 0, 2, 1), (40, 0, 42, 1)) + res_1d, stack = _solve(p, 1.0) + assert stack.chain is not None and stack.chain.any() + res_fine, stack_f = _solve(_seg_problem([seg], (0, 0, 2, 1), + (40, 0, 42, 1)), 0.05) + assert stack_f.chain is None or not stack_f.chain.any() + r_analytic = RHO * 0.038 / (0.2e-3 * T_M) # between x=2 and x=40 + assert res_fine.R_ohm == pytest.approx(r_analytic, rel=0.03) + assert res_1d.R_ohm == pytest.approx(res_fine.R_ohm, rel=0.06) + + +def test_diagonal_narrow_trace_no_staircase(): + """1D links carry the TRUE arc length: a diagonal trace must not be + inflated by the 4-connected staircase (which would be up to +41%).""" + seg = _seg([(1, 1), (25, 19)], 0.2) + p = _seg_problem([seg], (0, 0, 2, 2), (24, 18, 26, 20)) + res, _ = _solve(p, 1.0) + L = math.hypot(24, 18) * 1e-3 # 30 mm + r_full = RHO * L / (0.2e-3 * T_M) + assert res.R_ohm < 1.05 * r_full # no staircase inflation + assert res.R_ohm == pytest.approx(r_full, rel=0.08) + + +def test_narrow_arc_trace_uses_arc_length(): + """Quarter-circle 0.2 mm trace, r = 10 mm, as a 1D chain: R follows + the arc length (a chord-based length would read ~10% low).""" + seg = TrackSeg(layer_name="F.Cu", points=np.array( + [[10 * NM, 0], + [int(round(10 * NM / math.sqrt(2))), + int(round(10 * NM / math.sqrt(2)))], + [0, 10 * NM]], dtype=np.int64), width_nm=int(0.2 * NM)) + p = _seg_problem([seg], (9, -1, 11, 1), (-1, 9, 1, 11)) + res, _ = _solve(p, 0.5) + # the electrode rects cover the arc where y < 1 (resp. x < 1), so the + # free span is theta in [asin(0.1), pi/2 - asin(0.1)] + th = math.asin(0.1) + r_arc = RHO * ((math.pi / 2 - 2 * th) * 10e-3) / (0.2e-3 * T_M) + assert res.R_ohm == pytest.approx(r_arc, rel=0.06) + + +def test_narrow_trace_bridges_pours(): + """A sub-resolution trace joins two pours: without it they are + disconnected; with it R is dominated by the trace's gap length.""" + from fill_resistance.errors import ConnectivityError + pour1 = [(0, 0), (10, 0), (10, 10), (0, 10)] + pour2 = [(30, 0), (40, 0), (40, 10), (30, 10)] + rects = ((0, 0, 2, 10), (38, 0, 40, 10)) + bare = _seg_problem([], *rects, fills_mm=(pour1, pour2)) + stack = raster.rasterize_stack(bare, 1.0 * NM) + e1, e2 = raster.electrode_masks(stack, bare) + with pytest.raises(ConnectivityError): + solver.run_solve(bare, stack, e1, e2, 1.0, + contact_model="equipotential") + + seg = _seg([(5, 5), (35, 5)], 0.2) + bridged = _seg_problem([seg], *rects, fills_mm=(pour1, pour2)) + res, _ = _solve(bridged, 1.0) + r_gap = RHO * 0.020 / (0.2e-3 * T_M) # 20 mm between pours + assert res.R_ohm == pytest.approx(r_gap, rel=0.10) + assert res.power_balance_rel < 1e-9 + + +def test_wide_track_still_rasterized(): + """At or above the width threshold the trace is rasterized normally + and no chain cells appear.""" + seg = _seg([(1, 2), (19, 2)], 2.0) + p = _seg_problem([seg], (0, 1, 2, 3), (18, 1, 20, 3)) + res, stack = _solve(p, 0.25) + assert stack.chain is None or not stack.chain.any() + assert int(stack.masks.sum()) > 300 # a real 2D band + assert np.isfinite(res.R_ohm) and res.R_ohm > 0 + + +def test_json_v5_roundtrip_with_tracks(tmp_path): + seg = _seg([(1, 0.5), (41, 0.5)], 0.2) + p = _seg_problem([seg], (0, 0, 2, 1), (40, 0, 42, 1)) + f = tmp_path / "d.json" + save_problem(p, f) + q = load_problem(f) + assert len(q.tracks) == 1 + assert q.tracks[0].layer_name == "F.Cu" + assert q.tracks[0].width_nm == int(0.2 * NM) + assert np.array_equal(q.tracks[0].points, p.tracks[0].points) + r_p, _ = _solve(p, 1.0) + r_q, _ = _solve(q, 1.0) + assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12) + + 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."""