diff --git a/README.md b/README.md index 8df7dfc..cde4661 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,11 @@ SWIG API. Requires KiCad **10.0.1+**. worth far more than the foil, so this is conservative. The pad face gets the average-thickness solder coat (exact pad shape) and the protruding-lead cone (see barrel contacts below; on oblong pads the - cone tapers within the inscribed circle). Whether a hole is a via or + cone tapers to the pad's short dimension). **Slotted (oval) holes** + keep their true stadium shape: the barrel wall, drill mouth, contact + ring and lead cone all follow the slot (rotated with the pad), and + the barrel conducts over the slot's real perimeter/bore area — not a + circle of the slot's long dimension. Whether a hole is a via or a THT pad, the owning footprint's side, and its **Do not populate** flag are all read from KiCad. **DNP pads** get an **open hole** and a plating-only barrel, no joint. At f > 0 the thickness scaling is @@ -159,7 +163,8 @@ SWIG API. Requires KiCad **10.0.1+**. physically enters through the lead/wire soldered into the hole, so the spreading resistance across the pad and surrounding pour is part of the result (both contact models; verified against - R = ρ/(π·t)·acosh(d/2a) for two circular contacts on a sheet). A + R = ρ/(π·t)·acosh(d/2a) for two circular contacts on a sheet). + Slotted holes inject along the stadium-shaped slot wall. A soldered **THT joint** additionally assumes the **hole is filled with solder** (core in parallel with the plating) and the **pad face on the solder side carries an average-thickness solder coat** diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index 7bdaf21..4957363 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -6,6 +6,7 @@ KiCad to extract without the dialog (all layers of the net, defaults). """ from __future__ import annotations +import math from dataclasses import dataclass, field from pathlib import Path @@ -139,11 +140,33 @@ def _convert_poly(poly_with_holes) -> Polygon: holes=[ring(h) for h in poly_with_holes.holes]) -def _pad_drill_nm(pad_or_via) -> int: +def _drill_info(pad_or_via) -> tuple[int, int, int]: + """(width_nm, slot_dx_nm, slot_dy_nm) of a padstack drill. Round + holes: (diameter, 0, 0). Slotted (oblong) holes: width is the + NARROW dimension, (slot_dx, slot_dy) the board-frame offset from + the drill center to each end-cap center of the slot. The slot + follows the pad rotation (KiCad rotates CCW with y down: + x' = x cos + y sin, y' = y cos - x sin).""" try: - return int(pad_or_via.padstack.drill.diameter.x) + d = pad_or_via.padstack.drill.diameter + dx, dy = int(d.x), int(d.y) except Exception: - return 0 + return 0, 0, 0 + if dx <= 0 or dy <= 0 or dx == dy: + return max(dx, 0), 0, 0 + half = (max(dx, dy) - min(dx, dy)) / 2.0 + try: + th = math.radians(pad_or_via.padstack.angle.degrees) + except Exception: + th = 0.0 + ux, uy = (1.0, 0.0) if dx > dy else (0.0, 1.0) + return (min(dx, dy), + int(round(half * (ux * math.cos(th) + uy * math.sin(th)))), + int(round(half * (uy * math.cos(th) - ux * math.sin(th))))) + + +def _pad_drill_nm(pad_or_via) -> int: + return _drill_info(pad_or_via)[0] def _pad_default_contact(pad: Pad) -> str: @@ -250,7 +273,7 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None, if box is None: raise SelectionError(f"Could not get the bounding box of {label}.") rect = _box2_to_rect(box, "pad") - drill = _pad_drill_nm(pad) + drill, slot_dx, slot_dy = _drill_info(pad) return Electrode(rect=rect, contact=contact, polygons=_pad_polygons(board, pad, contact), label=label, # through-hole pad: current enters at the soldered @@ -258,6 +281,7 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None, # with a solder cone around the protruding lead drill_nm=drill, pad_nm=_padstack_pad_nm(pad), pad_min_nm=_padstack_pad_min_nm(pad), + slot_dx_nm=slot_dx, slot_dy_nm=slot_dy, center=(pad.position.x, pad.position.y), solder=drill > 0, protrusion_side=(_tht_protrusion_side(pad, pad_map or {}) @@ -547,12 +571,14 @@ def gather_barrels(board: Board, net_name: str, populated = not fp.attributes.do_not_populate except Exception: pass + drill, slot_dx, slot_dy = _drill_info(pad) barrels.append(ViaLink( x=pad.position.x, y=pad.position.y, - drill_nm=_pad_drill_nm(pad), z_top_nm=-1, + drill_nm=drill, z_top_nm=-1, z_bot_nm=stackup.z_bot_nm + 1, kind="pad", pad_nm=_padstack_pad_nm(pad), pad_min_nm=_padstack_pad_min_nm(pad), + slot_dx_nm=slot_dx, slot_dy_nm=slot_dy, solder_filled=populated, protrusion_side=(_tht_protrusion_side(pad, pad_map, quiet=True) diff --git a/fill_resistance/geometry.py b/fill_resistance/geometry.py index c8b4124..b108747 100644 --- a/fill_resistance/geometry.py +++ b/fill_resistance/geometry.py @@ -113,11 +113,16 @@ class Electrode: contact: str = "all" polygons: list[Polygon] | None = None label: str = "rect" - drill_nm: int = 0 # >0: barrel contact + drill_nm: int = 0 # >0: barrel contact (slotted + # holes: the slot WIDTH) pad_nm: int = 0 # pad diameter (search bound; # largest dimension if oblong) pad_min_nm: int = 0 # smallest pad dimension (cone # taper bound); 0 = pad_nm + slot_dx_nm: int = 0 # slotted (oblong) hole: offset + slot_dy_nm: int = 0 # from `center` to each end-cap + # center of the slot, board + # frame; (0, 0) = round drill center: tuple[int, int] | None = None # drill center; None = rect center barrel_z: tuple[int, int] | None = None # (z_top, z_bot); None = full stack solder: bool = False # soldered THT joint (see above) @@ -134,7 +139,7 @@ class ViaLink: layers whose z lies within [z_top_nm, z_bot_nm].""" x: int y: int - drill_nm: int + drill_nm: int # slotted holes: the slot WIDTH z_top_nm: int z_bot_nm: int kind: str = "via" # "via" | "pad" @@ -144,6 +149,10 @@ class ViaLink: pad_min_nm: int = 0 # smallest pad dimension (bounds # the lead-cone taper on oblong # pads); 0 = same as pad_nm + slot_dx_nm: int = 0 # slotted (oblong) hole: offset + slot_dy_nm: int = 0 # from (x, y) to each end-cap + # center of the slot, board + # frame; (0, 0) = round drill solder_filled: bool = False # populated THT pad: the hole # holds lead + solder (in parallel # with the plating); False for @@ -162,19 +171,22 @@ class ViaLink: lead_nm: float = 0, lead_rho_ohm_m: float | None = None) -> float: """Barrel segment resistance over length_nm: thin-wall annulus of - plating around the drill. With solder_rho_ohm_m the hole holds a + plating around the drill (slotted holes: thin wall around the + stadium-shaped slot). With solder_rho_ohm_m the hole holds a soldered THT joint: the component lead (a cylinder of lead_nm diameter, resistivity lead_rho_ohm_m) and the solder filling the - remaining annulus conduct in parallel with the plating.""" - ga = math.pi * (self.drill_nm * 1e-9) * (plating_nm * 1e-9) \ - / rho_ohm_m # conductance-area [m^2/ohm-m] + remaining bore conduct in parallel with the plating.""" + ext = 2.0 * math.hypot(self.slot_dx_nm, self.slot_dy_nm) * 1e-9 + wall = math.pi * (self.drill_nm * 1e-9) + 2.0 * ext + ga = wall * (plating_nm * 1e-9) / rho_ohm_m + # conductance-area [m^2/ohm-m] if solder_rho_ohm_m is not None: r_core = max(self.drill_nm / 2.0 - plating_nm, 0.0) * 1e-9 r_lead = min(lead_nm * 1e-9 / 2.0, r_core) if lead_rho_ohm_m is not None and r_lead > 0: ga += math.pi * r_lead * r_lead / lead_rho_ohm_m - ga += math.pi * (r_core * r_core - r_lead * r_lead) \ - / solder_rho_ohm_m + ga += (math.pi * r_core * r_core + 2.0 * r_core * ext + - math.pi * r_lead * r_lead) / solder_rho_ohm_m return (length_nm * 1e-9) / ga @@ -261,6 +273,19 @@ def contact_solder_buildups(problem: Problem) -> list[str]: return sorted(set(touched)) +def slot_distance(xg, yg, dx_nm: int, dy_nm: int): + """Distance from points (xg, yg) (numpy-broadcastable, coordinates + RELATIVE to the hole center) to a slotted hole's axis - the segment + (-dx, -dy)..(+dx, +dy) between the end-cap centers. The slot wall + sits at distance width/2. Round drills (dx = dy = 0) reduce to the + plain radius, so callers need no special case.""" + if dx_nm == 0 and dy_nm == 0: + return np.hypot(xg, yg) + l2 = float(dx_nm) * dx_nm + float(dy_nm) * dy_nm + t = np.clip((xg * dx_nm + yg * dy_nm) / l2, -1.0, 1.0) + return np.hypot(xg - t * dx_nm, yg - t * dy_nm) + + def _disc_polygon(x_nm: float, y_nm: float, r_nm: float, n: int = 32) -> Polygon: th = np.linspace(0.0, 2.0 * math.pi, n, endpoint=False) @@ -269,6 +294,19 @@ def _disc_polygon(x_nm: float, y_nm: float, r_nm: float, axis=1)).astype(np.int64)) +def _capsule_polygon(x_nm: float, y_nm: float, dx_nm: float, dy_nm: float, + r_nm: float, n: int = 16) -> Polygon: + """Stadium: two half-circle caps of radius r_nm centered at + (x +- dx, y +- dy), joined by straight flanks.""" + a0 = math.atan2(dy_nm, dx_nm) + th = np.linspace(-0.5 * math.pi, 0.5 * math.pi, n) + a0 + cap1 = np.stack([x_nm + dx_nm + r_nm * np.cos(th), + y_nm + dy_nm + r_nm * np.sin(th)], axis=1) + cap2 = np.stack([x_nm - dx_nm + r_nm * np.cos(th + math.pi), + y_nm - dy_nm + r_nm * np.sin(th + math.pi)], axis=1) + return Polygon(outline=np.round(np.vstack([cap1, cap2])).astype(np.int64)) + + def tht_joint_buildups(problem: Problem, shapes: dict | None = None) -> list[str]: """Solder coat of the net's populated STITCHING through-hole pads @@ -292,7 +330,16 @@ def tht_joint_buildups(problem: Problem, if polys is None: if v.pad_nm <= v.drill_nm: continue - polys = [_disc_polygon(v.x, v.y, v.pad_nm / 2.0)] + # oblong pads: never coat past the pad - a capsule along the + # slot axis, or the inscribed disc when the axis is unknown + w = v.pad_min_nm or v.pad_nm + hl = math.hypot(v.slot_dx_nm, v.slot_dy_nm) + if hl > 0.0 and v.pad_nm > w: + s = (v.pad_nm - w) / 2.0 / hl + polys = [_capsule_polygon(v.x, v.y, v.slot_dx_nm * s, + v.slot_dy_nm * s, w / 2.0)] + else: + polys = [_disc_polygon(v.x, v.y, w / 2.0)] problem.buildups.append( SurfaceBuildup(layer_name=v.protrusion_side, polygons=list(polys))) @@ -451,6 +498,8 @@ def _electrode_to_json(e: Electrode) -> dict: "drill_nm": e.drill_nm, "pad_nm": e.pad_nm, "pad_min_nm": e.pad_min_nm, + "slot_dx_nm": e.slot_dx_nm, + "slot_dy_nm": e.slot_dy_nm, "center": (None if e.center is None else list(e.center)), "barrel_z": (None if e.barrel_z is None else list(e.barrel_z)), "solder": e.solder, @@ -468,6 +517,8 @@ def _electrode_from_json(d: dict) -> Electrode: drill_nm=int(d.get("drill_nm", 0)), pad_nm=int(d.get("pad_nm", 0)), pad_min_nm=int(d.get("pad_min_nm", 0)), + slot_dx_nm=int(d.get("slot_dx_nm", 0)), + slot_dy_nm=int(d.get("slot_dy_nm", 0)), center=(None if d.get("center") is None else (int(d["center"][0]), int(d["center"][1]))), barrel_z=(None if d.get("barrel_z") is None @@ -564,6 +615,8 @@ def problem_from_json(d: dict) -> Problem: kind=vd.get("kind", "via"), pad_nm=int(vd.get("pad_nm", 0)), pad_min_nm=int(vd.get("pad_min_nm", 0)), + slot_dx_nm=int(vd.get("slot_dx_nm", 0)), + slot_dy_nm=int(vd.get("slot_dy_nm", 0)), # older dumps: every THT pad counted as solder-filled solder_filled=bool(vd.get( "solder_filled", vd.get("kind", "via") == "pad")), diff --git a/fill_resistance/raster.py b/fill_resistance/raster.py index 8afe70c..75a40a6 100644 --- a/fill_resistance/raster.py +++ b/fill_resistance/raster.py @@ -23,7 +23,7 @@ from scipy import ndimage from . import config from .errors import ElectrodeError, GridSizeError -from .geometry import Electrode, Problem, Rect +from .geometry import Electrode, Problem, Rect, slot_distance # 4-connectivity: matches the in-plane 5-point stencil of the solver _STRUCT4 = ndimage.generate_binary_structure(2, 1) @@ -270,29 +270,31 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None: y = (e.rect.y0 + e.rect.y1) / 2.0 seen.add((int(x), int(y))) if e.solder and e.protrusion_side: - # oblong pads: taper to the inscribed circle (conservative) + # oblong pads: taper from the (slot) wall to the inscribed + # dimension (conservative) jobs.append((x, y, e.drill_nm, e.pad_min_nm or e.pad_nm, - e.protrusion_side)) + e.protrusion_side, e.slot_dx_nm, e.slot_dy_nm)) for v in problem.vias: if v.kind == "pad" and v.solder_filled and v.protrusion_side \ and (v.x, v.y) not in seen: jobs.append((v.x, v.y, v.drill_nm, v.pad_min_nm or v.pad_nm, - v.protrusion_side)) + v.protrusion_side, v.slot_dx_nm, v.slot_dy_nm)) - for x, y, drill_nm, pad_nm, side in jobs: + for x, y, drill_nm, pad_nm, side, sdx, sdy in jobs: li = index.get(side) if li is None or pad_nm <= drill_nm: continue ra, rb = drill_nm / 2.0, pad_nm / 2.0 - j0 = max(0, math.floor((x - rb - stack.x0_nm) / h)) - j1 = min(nx, math.floor((x + rb - stack.x0_nm) / h) + 1) - i0 = max(0, math.floor((y - rb - stack.y0_nm) / h)) - i1 = min(ny, math.floor((y + rb - stack.y0_nm) / h) + 1) + ex, ey = rb + abs(sdx), rb + abs(sdy) + j0 = max(0, math.floor((x - ex - stack.x0_nm) / h)) + j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1) + i0 = max(0, math.floor((y - ey - stack.y0_nm) / h)) + i1 = min(ny, math.floor((y + ey - stack.y0_nm) / h) + 1) if i0 >= i1 or j0 >= j1: continue xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y - r = np.sqrt(ys[:, None] ** 2 + xs[None, :] ** 2) + r = slot_distance(xs[None, :], ys[:, None], sdx, sdy) t_sn = H * np.clip((rb - r) / (rb - ra), 0.0, 1.0) t_eq = t_sn * (problem.rho_ohm_m / problem.solder_rho_ohm_m) factor = 1.0 + t_eq / problem.layers[li].thickness_nm @@ -353,18 +355,20 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None: if via.kind == "pad" and via.solder_filled: continue r = via.drill_nm / 2.0 - j0 = max(0, math.floor((via.x - r - stack.x0_nm) / h)) - j1 = min(nx, math.floor((via.x + r - stack.x0_nm) / h) + 1) - i0 = max(0, math.floor((via.y - r - stack.y0_nm) / h)) - i1 = min(ny, math.floor((via.y + r - stack.y0_nm) / h) + 1) + ex, ey = r + abs(via.slot_dx_nm), r + abs(via.slot_dy_nm) + j0 = max(0, math.floor((via.x - ex - stack.x0_nm) / h)) + j1 = min(nx, math.floor((via.x + ex - stack.x0_nm) / h) + 1) + i0 = max(0, math.floor((via.y - ey - stack.y0_nm) / h)) + i1 = min(ny, math.floor((via.y + ey - stack.y0_nm) / h) + 1) if i0 >= i1 or j0 >= j1: continue xs = stack.x0_nm + (np.arange(j0, j1)[:, None] + sub[None, :]) * h \ - via.x ys = stack.y0_nm + (np.arange(i0, i1)[:, None] + sub[None, :]) * h \ - via.y - cov = ((ys[:, None, :, None] ** 2 + xs[None, :, None, :] ** 2) - <= r * r).mean(axis=(2, 3)) + cov = (slot_distance(xs[None, :, None, :], ys[:, None, :, None], + via.slot_dx_nm, via.slot_dy_nm) + <= r).mean(axis=(2, 3)) if not (cov > 0).any(): continue # mouth far smaller than h if stack.thick_scale is None: @@ -477,7 +481,8 @@ def _electrode_cells2d(stack: RasterStack, e: Electrode) -> np.ndarray: def _barrel_ring2d(stack: RasterStack, e: Electrode, mask2d: np.ndarray) -> np.ndarray: """Contact cells of a barrel electrode on one layer: the copper ring - at the drill wall (cell centers within one cell of radius drill/2), + at the drill wall (cell centers within one cell of radius drill/2; + slotted holes: within one cell of the stadium-shaped slot wall), where the lead/wire soldered into the hole actually meets the layer. If rasterization or an antipad leaves no copper there, fall back to the nearest copper ring within the pad footprint (+1 cell of slop) - @@ -491,16 +496,17 @@ def _barrel_ring2d(stack: RasterStack, e: Electrode, y = (e.rect.y0 + e.rect.y1) / 2.0 r = e.drill_nm / 2.0 rw = max(e.pad_nm, e.drill_nm + 300_000) / 2.0 + h + ex, ey = rw + abs(e.slot_dx_nm), rw + abs(e.slot_dy_nm) out = np.zeros((ny, nx), dtype=bool) - j0 = max(0, math.floor((x - rw - stack.x0_nm) / h)) - j1 = min(nx, math.floor((x + rw - stack.x0_nm) / h) + 1) - i0 = max(0, math.floor((y - rw - stack.y0_nm) / h)) - i1 = min(ny, math.floor((y + rw - stack.y0_nm) / h) + 1) + j0 = max(0, math.floor((x - ex - stack.x0_nm) / h)) + j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1) + i0 = max(0, math.floor((y - ey - stack.y0_nm) / h)) + i1 = min(ny, math.floor((y + ey - stack.y0_nm) / h) + 1) if i0 >= i1 or j0 >= j1: return out xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y - d = np.sqrt(ys[:, None] ** 2 + xs[None, :] ** 2) + d = slot_distance(xs[None, :], ys[:, None], e.slot_dx_nm, e.slot_dy_nm) m = mask2d[i0:i1, j0:j1] ring = m & (np.abs(d - r) <= h) if not ring.any(): diff --git a/fill_resistance/solver.py b/fill_resistance/solver.py index 611cc27..b3030db 100644 --- a/fill_resistance/solver.py +++ b/fill_resistance/solver.py @@ -47,7 +47,7 @@ from scipy.sparse import linalg as sla from . import config, skin from .errors import ConnectivityError, ElectrodeError, SolverError -from .geometry import Problem +from .geometry import Problem, slot_distance from .raster import RasterStack, electrodes_touch @@ -156,12 +156,14 @@ def _barrel_links(stack: RasterStack, problem: Problem span = [li for li, layer in enumerate(problem.layers) if via.spans(layer.z_nm)] r_nm = max(via.pad_nm, via.drill_nm + 300_000) / 2.0 + h - win = int(r_nm // h) + 1 - i0, i1 = max(0, i - win), min(ny, i + win + 1) - j0, j1 = max(0, j - win), min(nx, j + win + 1) + win_j = int((r_nm + abs(via.slot_dx_nm)) // h) + 1 + win_i = int((r_nm + abs(via.slot_dy_nm)) // h) + 1 + i0, i1 = max(0, i - win_i), min(ny, i + win_i + 1) + j0, j1 = max(0, j - win_j), min(nx, j + win_j + 1) xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - via.x ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - via.y - d2 = ys[:, None] ** 2 + xs[None, :] ** 2 + d2 = slot_distance(xs[None, :], ys[:, None], + via.slot_dx_nm, via.slot_dy_nm) ** 2 d2 = np.where(d2 <= r_nm * r_nm, d2, np.inf) present = [] # (layer, i, j) per layer for li in span: diff --git a/tests/test_barrel_contacts.py b/tests/test_barrel_contacts.py index c4db222..741633e 100644 --- a/tests/test_barrel_contacts.py +++ b/tests/test_barrel_contacts.py @@ -318,6 +318,162 @@ def test_vialink_solder_json(): assert problem_from_json(d).vias[0].solder_filled is False +# --- slotted (oblong) holes -------------------------------------------------- +# The lead/barrel of a slotted hole is a stadium, not a circle: modeling +# it as a circle of the slot's LONG dimension painted contact rings, +# mouths and cones bigger than the oblong pad itself. + +def _slot_dist_mm(stack, ii, jj, x_mm, y_mm, dx_nm): + """Distance of cells (ii, jj) to a slot axis (+-dx_nm along x).""" + xs = stack.x0_nm + (jj + 0.5) * stack.h_nm - x_mm * NM + ys = stack.y0_nm + (ii + 0.5) * stack.h_nm - y_mm * NM + t = np.clip(xs / dx_nm, -1.0, 1.0) + return np.hypot(xs - t * dx_nm, ys), xs, ys + + +def test_slot_ring_hugs_slot_wall(): + """The contact ring of a slotted THT pad follows the stadium-shaped + slot wall: it reaches around the end caps but never pokes past the + oblong pad's short side (the old circular model of the slot's long + dimension put cells at radius 1.5 mm straight above/below).""" + p = make_problem([(PLATE20, [])], + rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) + e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6) # slot 3.0 x 1.0 mm + e.pad_min_nm = int(1.6 * NM) # pad 3.6 x 1.6 mm + e.slot_dx_nm = 1 * NM + p.electrodes1 = [e] + stack = raster.rasterize_stack(p, 0.1 * NM) + e1, _ = raster.electrode_masks(stack, p) + ii, jj = np.nonzero(e1[0]) + d, xs, ys = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM) + assert len(ii) >= 16 + assert (np.abs(d - 0.5 * NM) <= stack.h_nm + 1).all() + assert xs.max() > 1.2 * NM and xs.min() < -1.2 * NM # rings the caps + assert np.abs(ys).max() < 0.8 * NM # stays inside the 1.6 mm side + + +def test_slot_mouth_is_stadium(): + """A DNP slotted pad cuts a stadium-shaped hole: open along the whole + slot, copper kept just past the slot width and the end caps.""" + p = make_problem([(PLATE20, [])], + rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) + v = _pad_link(populated=False) + v.slot_dx_nm = 1 * NM # slot 3.0 x 1.0 mm along x + p.vias = [v] + stack = raster.rasterize_stack(p, 0.1 * NM) + m = stack.masks[0] + assert not m[stack.cell_of(10 * NM, 10 * NM)] + assert not m[stack.cell_of(int(10.9 * NM), 10 * NM)] # slot end: open + assert not m[stack.cell_of(int(9.1 * NM), 10 * NM)] + assert m[stack.cell_of(10 * NM, int(10.8 * NM))] # past the width: copper + assert m[stack.cell_of(10 * NM, int(9.2 * NM))] + assert m[stack.cell_of(int(11.8 * NM), 10 * NM)] # past the cap: copper + + +def test_slot_cone_follows_slot(): + """The lead cone of a slotted oblong pad tapers from the slot WALL + to the pad's short dimension. The old circular-drill model (diameter + = the slot's long dimension) skipped the cone entirely + (pad_min <= drill) and, for the mouth, ate the pad's short side.""" + p = make_problem([(PLATE20, [])], + rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) + e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6, solder=True) + e.pad_min_nm = int(1.6 * NM) + e.slot_dx_nm = 1 * NM + e.protrusion_side = "F.Cu" + p.electrodes1 = [e] + stack = raster.rasterize_stack(p, 0.1 * NM) + assert stack.thick_scale is not None + ny, nx = stack.shape2d + jj, ii = np.meshgrid(np.arange(nx), np.arange(ny)) + r, _, _ = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM) + ra, rb, H = 0.5 * NM, 0.8 * NM, p.tht_protrusion_nm + t_eq = H * np.clip((rb - r) / (rb - ra), 0, 1) \ + * (p.rho_ohm_m / p.solder_rho_ohm_m) + expect = np.where(stack.masks[0], + 1.0 + t_eq / p.layers[0].thickness_nm, 1.0) + assert np.allclose(stack.thick_scale[0], expect, rtol=1e-12) + assert stack.thick_scale[0].max() > 3.0 + + +def test_slot_barrel_resistance(): + """Slotted barrel: plating wall = stadium perimeter, solder core = + stadium bore area (both reduce to the circle for dx = dy = 0).""" + v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1, + slot_dx_nm=800_000, slot_dy_nm=600_000) # ext = 2 mm + rho, sn = 1.68e-8, 1.32e-7 + ga = (math.pi * 1e-3 + 2 * 2e-3) * 18e-6 / rho + r_plain = v.barrel_resistance(1_600_000, rho, 18_000) + assert r_plain == pytest.approx(1.6e-3 / ga, rel=1e-12) + rc = 0.5e-3 - 18e-6 + ga += (math.pi * rc * rc + 2 * rc * 2e-3) / sn + r_fill = v.barrel_resistance(1_600_000, rho, 18_000, solder_rho_ohm_m=sn) + assert r_fill == pytest.approx(1.6e-3 / ga, rel=1e-12) + + +def test_slot_coat_fallback_within_pad(): + """Without an exact pad shape the stitching coat falls back to a + capsule along the slot (width = pad_min), not the old pad_nm disc + that stuck out past an oblong pad's short side.""" + p = make_problem([(PLATE20, [])], + rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) + v = _pad_link() # pad_nm = 2.4 mm + v.pad_min_nm = 1_600_000 + v.slot_dx_nm = 1 * NM + p.vias = [v] + assert tht_joint_buildups(p) == ["F.Cu"] + pts = p.buildups[0].polygons[0].outline.astype(float) + xs, ys = pts[:, 0] - 10 * NM, pts[:, 1] - 10 * NM + t = np.clip(xs / (0.4 * NM), -1.0, 1.0) # caps at +-(2.4-1.6)/2 mm + d = np.hypot(xs - t * 0.4 * NM, ys) + assert np.allclose(d, 0.8 * NM, atol=2) + assert np.abs(xs).max() <= 1.2 * NM + 2 # never past pad_nm / 2 + assert np.abs(ys).max() <= 0.8 * NM + 2 # never past pad_min / 2 + + +def test_slot_json_roundtrip(): + p = make_problem([(PLATE20, [])], + rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) + e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6) + e.slot_dx_nm, e.slot_dy_nm = 700_000, -700_000 + p.electrodes1 = [e] + v = _pad_link() + v.slot_dx_nm = 1 * NM + p.vias = [v] + q = problem_from_json(problem_to_json(p)) + assert (q.electrodes1[0].slot_dx_nm, q.electrodes1[0].slot_dy_nm) \ + == (700_000, -700_000) + assert (q.vias[0].slot_dx_nm, q.vias[0].slot_dy_nm) == (1 * NM, 0) + # legacy dumps: round drills + d = problem_to_json(p) + for vd in d["vias"]: + del vd["slot_dx_nm"], vd["slot_dy_nm"] + assert problem_from_json(d).vias[0].slot_dx_nm == 0 + + +def test_drill_info_slot_rotation(): + """_drill_info: slot axis from the drill x/y sizes, rotated with the + pad (KiCad angles are CCW with y down: 90 deg sends +x to -y).""" + from types import SimpleNamespace as NS + + from fill_resistance.board_io import _drill_info + + def pad(dx_mm, dy_mm, angle_deg): + return NS(padstack=NS( + drill=NS(diameter=NS(x=int(dx_mm * NM), y=int(dy_mm * NM))), + angle=NS(degrees=angle_deg))) + + assert _drill_info(pad(1.0, 1.0, 0.0)) == (1 * NM, 0, 0) # round + assert _drill_info(pad(3.0, 1.0, 0.0)) == (1 * NM, 1 * NM, 0) + assert _drill_info(pad(1.0, 3.0, 0.0)) == (1 * NM, 0, 1 * NM) + w, dx, dy = _drill_info(pad(3.0, 1.0, 90.0)) + assert (w, dx, dy) == (1 * NM, 0, -1 * NM) + w, dx, dy = _drill_info(pad(3.0, 1.0, 45.0)) + assert w == 1 * NM + assert dx == pytest.approx(1 * NM / math.sqrt(2), abs=2) + assert dy == pytest.approx(-1 * NM / math.sqrt(2), abs=2) + + def test_barrel_electrode_json_roundtrip(tmp_path): p = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))