Populated THT holes conduct in-plane as their solder plug + lead

The mouth of a populated THT pad kept only foil copper on every layer:
on the component side and inner layers the joint looked (and conducted)
like plain plane, and current had to crowd through the single barrel
attachment cell. The filled hole - lead cylinder plus solder bore - now
adds conduction-equivalent copper of the full hole depth on EVERY
spanned layer (the pin continues beyond both mouths, so each layer sees
the whole plug cross-section). Side to side the joint differs only by
the solder: coat and cone stay on the protrusion side.

Cone and plug contributions accumulate ADDITIVELY (stack.t_extra_nm)
and fold into thick_scale once; multiplying the factors would overstate
mouth cells carrying both. The raster map draws filled mouths in a
darker tin with their own legend entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-17 20:38:01 +07:00
parent c77e3408bd
commit 666abaa50f
4 changed files with 131 additions and 20 deletions
+9 -3
View File
@@ -107,9 +107,15 @@ SWIG API. Requires KiCad **10.0.1+**.
holds the **component lead** (a cylinder of drill
`THT_LEAD_CLEARANCE_MM`, resistivity `THT_LEAD_RHO_OHM_M`, copper by
default; raise it for brass/steel leads) **plus solder** in the
remaining annulus, both in parallel with the plating. The mouth
copper stays conducting: it stands in for the solder plug, which is
worth far more than the foil, so this is conservative. The pad face
remaining annulus, both in parallel with the plating. The filled
hole also conducts **in-plane on every spanned layer** (component
side and inner layers included): the mouth keeps its copper and
additionally carries the plug — lead disc plus solder bore — as
conduction-equivalent copper of the **full hole depth** (the pin
continues beyond both mouths, so each layer sees the whole plug
cross-section). The joint is side-symmetric except for the solder:
coat and cone on the solder side only. On the raster map these
mouths render in a darker tin color. 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 to the pad's short dimension). **Slotted (oval) holes**
+9 -2
View File
@@ -52,6 +52,7 @@ _E2_COLOR = "#2f6fb0"
_VIA_COLOR = "#2d6b45"
_PAD_COLOR = "#5b4a8a" # THT pad barrels (kind='pad'), violet-ink
_SOLDER = "#9aa3ad" # tin-gray: solder buildup areas
_PLUG = "#6e7885" # darker tin: solder-filled THT holes (lead + plug)
_MESH = "#a56c33" # darker copper: adaptive leaf boundaries
_INK = "#3a3a3a"
_GRID_INK = "#b8b4ae"
@@ -224,8 +225,9 @@ def _injection_area_labels(ax, li, layer_name, problem, result):
def fig_raster(stack, e1, e2, problem, result=None):
cmap = ListedColormap([_BG, _COPPER, _E1_COLOR, _E2_COLOR, _SOLDER,
_MESH])
_MESH, _PLUG])
has_buildup = stack.buildup is not None and stack.buildup.any()
has_plug = stack.plug is not None and stack.plug.any()
has_mesh = stack.mesh is not None and stack.mesh.any()
def paint(ax, li):
@@ -233,11 +235,13 @@ def fig_raster(stack, e1, e2, problem, result=None):
codes[stack.masks[li]] = 1
if has_buildup:
codes[stack.buildup[li]] = 4
if has_plug:
codes[stack.plug[li]] = 6
if has_mesh:
codes[stack.mesh[li]] = 5
codes[e1[li]] = 2
codes[e2[li]] = 3
ax.imshow(codes, cmap=cmap, vmin=0, vmax=5, origin="upper",
ax.imshow(codes, cmap=cmap, vmin=0, vmax=6, origin="upper",
extent=stack.extent_mm(), interpolation="nearest")
_via_markers(ax, problem, problem.layers[li])
if result is not None and (result.part_currents1
@@ -264,6 +268,9 @@ def fig_raster(stack, e1, e2, problem, result=None):
f"({problem.solder_thickness_nm / 1000:.0f} µm"
+ (f" + {problem.extra_cu_nm / 1000:.0f} µm Cu"
if problem.extra_cu_nm else "") + ")"))
if has_plug:
handles.append(Patch(
fc=_PLUG, label="solder-filled THT hole (lead + solder)"))
if result is not None and (result.part_currents1
or result.part_currents2):
entries = ([("+", _E1_COLOR, i, amps)
+61 -11
View File
@@ -46,7 +46,16 @@ class RasterStack:
thick_scale: np.ndarray | None = None # float (L, ny, nx): per-cell
# copper-thickness factor (via
# mouths: cap-thin or partially
# drilled cells); None = all 1
# drilled cells; folded-in cone
# and plug extras); None = all 1
t_extra_nm: np.ndarray | None = None # float (L, ny, nx): additive
# conduction-equivalent copper
# (lead cones + hole plugs),
# folded into thick_scale at the
# end of rasterize_stack
plug: np.ndarray | None = None # bool (L, ny, nx): solder-filled THT
# hole mouths (lead + solder plug,
# drawn on the raster map)
mesh: np.ndarray | None = None # bool (L, ny, nx): adaptive leaf
# boundaries (drawn on the raster map)
@@ -234,6 +243,17 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
stack.buildup &= stack.masks # solder wets exposed copper only
_paint_lead_fillets(stack, problem)
if stack.t_extra_nm is not None:
# cones + plugs are ADDITIVE conduction-equivalent copper; fold
# them into the multiplicative per-cell scale once (multiplying
# per contribution would overstate cells carrying both)
if stack.thick_scale is None:
stack.thick_scale = np.ones(stack.masks.shape)
for li, layer in enumerate(problem.layers):
stack.thick_scale[li] *= np.where(
stack.masks[li],
1.0 + stack.t_extra_nm[li] / layer.thickness_nm, 1.0)
return stack
@@ -243,7 +263,8 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
tht_protrusion_nm out of the hole on the side opposite the
component, wrapped by a solder cone - full protrusion height at
the drill wall, tapering linearly to zero at the pad edge. Modeled
as extra conduction-equivalent copper via stack.thick_scale: the
as extra conduction-equivalent copper (stack.t_extra_nm, ADDITIVE
with the hole plug, folded into thick_scale by rasterize_stack): the
tall solder column next to the wall pulls those cells to lead
potential (equivalent to extending the barrel wall vertically), the
taper carries the radial spreading. At f > 0 the factor multiplies
@@ -297,11 +318,10 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
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
if stack.thick_scale is None:
stack.thick_scale = np.ones(stack.masks.shape)
if stack.t_extra_nm is None:
stack.t_extra_nm = np.zeros(stack.masks.shape)
m = stack.masks[li, i0:i1, j0:j1]
stack.thick_scale[li, i0:i1, j0:j1] *= np.where(m, factor, 1.0)
stack.t_extra_nm[li, i0:i1, j0:j1] += np.where(m, t_eq, 0.0)
def _via_span(problem: Problem, via) -> list[int]:
@@ -339,8 +359,13 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
OUTER layers, uncapped vias (and inner layers either way) get an open
hole. The fab caps only small vias: drills above cap_max_drill_nm
stay open even with vias_capped. THT pad mouths: populated pads are
solder-filled - the mouth copper stays and stands in for the plug
(conservative: the plug's solder is worth far more than the foil);
solder-filled - the mouth keeps its copper and additionally carries
the PLUG (the component lead plus the solder filling the bore) as
in-plane conduction-equivalent copper of the FULL hole depth on
EVERY spanned layer (the pin continues beyond both mouths, so each
layer sees the whole plug cross-section); the joint is then
side-symmetric except for the solder: the solder-side coat and cone
come on top, additively (see _paint_lead_fillets).
DNP pad holes are cut open on every layer. Fully swallowed cells
leave the mask; partially covered cells keep a thickness-scaled
sheet conductance via stack.thick_scale."""
@@ -352,8 +377,7 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
for via in problem.vias:
if via.drill_nm <= 0:
continue
if via.kind == "pad" and via.solder_filled:
continue
plugged = via.kind == "pad" and via.solder_filled
r = via.drill_nm / 2.0
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))
@@ -371,9 +395,35 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
<= r).mean(axis=(2, 3))
if not (cov > 0).any():
continue # mouth far smaller than h
span = _via_span(problem, via)
if plugged:
# lead cylinder + solder bore: the pin continues beyond BOTH
# mouths (component body / clipped stickout), so every
# spanned layer sees the FULL plug depth for lateral
# spreading - no per-layer split
r_lead = max(via.drill_nm - problem.tht_lead_clearance_nm,
0) / 2.0
cov_lead = (np.hypot(xs[None, :, None, :],
ys[:, None, :, None])
<= r_lead).mean(axis=(2, 3))
t_sn = problem.rho_ohm_m / problem.solder_rho_ohm_m
t_pb = problem.rho_ohm_m / problem.tht_lead_rho_ohm_m
depth = max(float(via.z_bot_nm - via.z_top_nm), 0.0)
t_eq = depth * (cov_lead * t_pb + (cov - cov_lead) * t_sn)
if stack.t_extra_nm is None:
stack.t_extra_nm = np.zeros(stack.masks.shape)
if stack.plug is None:
stack.plug = np.zeros_like(stack.masks)
for li in span:
m = stack.masks[li, i0:i1, j0:j1]
stack.t_extra_nm[li, i0:i1, j0:j1] += np.where(m, t_eq, 0.0)
stack.plug[li, i0:i1, j0:j1] |= m & (cov > 0.5)
continue
if stack.thick_scale is None:
stack.thick_scale = np.ones(stack.masks.shape)
for li in _via_span(problem, via):
for li in span:
if via.kind == "via" and problem.vias_capped and li in outer \
and via.drill_nm <= problem.cap_max_drill_nm:
ratio = min(problem.cap_plating_nm
+52 -4
View File
@@ -11,7 +11,7 @@ from fill_resistance.geometry import (Electrode, Polygon, ViaLink,
contact_solder_buildups, load_problem,
problem_from_json, problem_to_json,
save_problem, tht_joint_buildups)
from tests.util import NM, make_problem, rect_mm, ring_mm
from tests.util import NM, make_multilayer, make_problem, rect_mm, ring_mm
PLATE20 = [(0, 0), (20, 0), (20, 20), (0, 20)]
@@ -237,7 +237,9 @@ def test_stitching_pad_joint():
def test_cone_not_doubled_at_contact():
"""A contact THT pad also appears in the net's pad list (ViaLink):
the cone and coat must be applied once, not squared/stacked."""
the cone and coat must be applied once, not squared/stacked. The
hole plug (this synthetic barrel spans z = -1..1, so 2 nm of lead)
ADDS to the cone at the mouth instead of multiplying it."""
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4, solder=True,
@@ -247,8 +249,9 @@ def test_cone_not_doubled_at_contact():
assert contact_solder_buildups(p) == ["F.Cu"]
assert tht_joint_buildups(p) == [] # contact center is skipped
stack = raster.rasterize_stack(p, 0.1 * NM)
wall = 1.0 + p.tht_protrusion_nm \
* (p.rho_ohm_m / p.solder_rho_ohm_m) / p.layers[0].thickness_nm
t_cone = p.tht_protrusion_nm * (p.rho_ohm_m / p.solder_rho_ohm_m)
t_plug = 2.0 * (p.rho_ohm_m / p.tht_lead_rho_ohm_m)
wall = 1.0 + (t_cone + t_plug) / p.layers[0].thickness_nm
assert stack.thick_scale.max() == pytest.approx(wall, rel=1e-12)
@@ -396,6 +399,51 @@ def test_slot_cone_follows_slot():
assert stack.thick_scale[0].max() > 3.0
def test_plug_conducts_on_component_side():
"""A populated THT pad's filled hole (lead + solder plug) conducts
IN-PLANE across the mouth on EVERY spanned layer - the component
side is not bare foil. Each layer carries the FULL hole depth (the
pin continues beyond both mouths, so the whole plug cross-section
spreads current at every layer; side-to-side the only difference
is the solder coat + cone), converted to conduction-equivalent
copper: lead disc at lead resistivity, solder bore around it."""
p = make_multilayer([[(PLATE20, [])], [(PLATE20, [])]],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.vias = [ViaLink(x=10 * NM, y=10 * NM, drill_nm=1_000_000, z_top_nm=-1,
z_bot_nm=1 * NM + 1, kind="pad", pad_nm=2_400_000,
solder_filled=True, protrusion_side="L0")]
stack = raster.rasterize_stack(p, 0.1 * NM)
c = stack.cell_of(10 * NM, 10 * NM)
assert stack.masks[0][c] and stack.masks[1][c] # plugged, not open
assert stack.plug[0][c] and stack.plug[1][c] # drawn on both sides
t = p.layers[0].thickness_nm
# full hole depth z = -1 .. 1 mm + 1 on both layers; the mouth
# center lies inside the 0.75 mm lead (copper resistivity)
depth = 1 * NM + 2.0
t_cone = p.tht_protrusion_nm * (p.rho_ohm_m / p.solder_rho_ohm_m)
assert stack.thick_scale[0][c] == pytest.approx(
1.0 + (t_cone + depth * (p.rho_ohm_m / p.tht_lead_rho_ohm_m)) / t,
rel=1e-9) # solder side: + cone
assert stack.thick_scale[1][c] == pytest.approx(
1.0 + depth * (p.rho_ohm_m / p.tht_lead_rho_ohm_m) / t, rel=1e-9)
# far from the joint: untouched foil
assert stack.thick_scale[1][stack.cell_of(14 * NM, 10 * NM)] == 1.0
# clearance swallowing the bore -> no lead, solder-only plug
p.tht_lead_clearance_nm = 1_000_000
s_sn = raster.rasterize_stack(p, 0.1 * NM)
assert s_sn.thick_scale[1][c] == pytest.approx(
1.0 + depth * (p.rho_ohm_m / p.solder_rho_ohm_m) / t, rel=1e-9)
p.tht_lead_clearance_nm = 250_000
# a DNP pad still cuts an open hole and gets no plug
p.vias[0].solder_filled = False
p.vias[0].protrusion_side = None
s2 = raster.rasterize_stack(p, 0.1 * NM)
assert not s2.masks[0][c] and not s2.masks[1][c]
assert s2.plug is None
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)."""