Model slotted (oblong) THT holes as stadiums, not circles

The drill's y dimension was discarded (padstack.drill.diameter.x only),
so a milled slot became a round hole of its x size - contact rings,
drill mouths and lead cones painted circles larger than the oblong pad
itself, and the cone was skipped outright (pad_min <= drill).

Slots now keep their true stadium shape, rotated with the pad (KiCad
CCW, y down): Electrode/ViaLink carry the end-cap offset vector,
drill_nm becomes the slot WIDTH, and a shared slot_distance() reduces
to the plain radius for round holes. The barrel wall ring, mouth
coverage, cone taper and the solver's attachment search all follow the
slot; barrel_resistance uses the stadium perimeter and bore area. The
stitching-coat fallback becomes a capsule along the slot (inscribed
disc when the axis is unknown) instead of a largest-dimension disc.
Slot fields round-trip through the JSON dumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-17 20:37:01 +07:00
parent bc95b444b4
commit b070d7444e
6 changed files with 291 additions and 43 deletions
+7 -2
View File
@@ -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**
+31 -5
View File
@@ -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)
+62 -9
View File
@@ -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")),
+28 -22
View File
@@ -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():
+7 -5
View File
@@ -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:
+156
View File
@@ -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))