Barrel contacts for vias/THT pads; configurable cap-drill threshold
Build PCM package / build (push) Successful in 13s

- Selected vias and through-hole pads inject at the drill-wall ring on
  every spanned layer (both contact models), not the whole pad face,
  so the pad/pour spreading resistance is part of the result. Vias are
  now selectable as contacts.
- Soldered THT joints: the hole is modeled solder-filled (core in
  parallel with the plating, also for stitching THT barrels) and the
  pad face carries an average-thickness solder coat over the modeled
  copper (SOLDER_THICKNESS_UM).
- Vias with drills above a configurable threshold (dialog field,
  default CAP_MAX_DRILL_MM = 0.5) keep open mouths even with capping
  selected - the fab caps only small vias.
- Geometry dump schema v6: electrode barrel fields, cap_max_drill_nm.
- Verified against R = rho/(pi t)*acosh(d/2a) for two circular contacts
  on a sheet (+2.6% at h = 0.15 mm, a = 1 mm; uniform model above the
  equipotential one as required by the contact bracket).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-16 18:28:16 +07:00
parent b4b666de77
commit 4ff729e192
11 changed files with 453 additions and 68 deletions
+158
View File
@@ -0,0 +1,158 @@
"""Barrel (via / through-hole pad) contact tests: current enters at the
drill-wall ring, not the pad face, and soldered THT joints carry a
solder-filled hole plus an average-thickness solder coat on the pad."""
import math
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.geometry import (Electrode, Polygon, ViaLink,
contact_solder_buildups, load_problem,
save_problem)
from tests.util import NM, make_problem, rect_mm, ring_mm
PLATE20 = [(0, 0), (20, 0), (20, 20), (0, 20)]
def _barrel(x_mm, y_mm, drill_mm, pad_mm=0.0, solder=False, polygons=None):
r = max(pad_mm, drill_mm) / 2
return Electrode(
rect=rect_mm((x_mm - r, y_mm - r, x_mm + r, y_mm + r)),
contact="all", label=f"via({x_mm},{y_mm})",
drill_nm=int(drill_mm * NM), pad_nm=int(pad_mm * NM),
center=(int(x_mm * NM), int(y_mm * NM)), solder=solder,
polygons=polygons)
def _disc(x_mm, y_mm, r_mm, n=64) -> Polygon:
ang = np.linspace(0, 2 * np.pi, n, endpoint=False)
return Polygon(outline=ring_mm(
[(x_mm + r_mm * np.cos(a), y_mm + r_mm * np.sin(a)) for a in ang]))
def _solve(p, h_mm, model="equipotential"):
stack = raster.rasterize_stack(p, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, p)
return solver.run_solve(p, stack, e1, e2, 1.0, contact_model=model), stack
def test_ring_cells_at_drill_wall():
"""The contact cells of a barrel electrode form a ring at the drill
wall (one-cell tolerance), not the pad face."""
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=1.6)]
stack = raster.rasterize_stack(p, 0.1 * NM)
e1, _ = raster.electrode_masks(stack, p)
ii, jj = np.nonzero(e1[0])
xs = stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM
ys = stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM
d = np.hypot(xs, ys)
assert len(ii) >= 8
assert (np.abs(d - 0.5 * NM) <= stack.h_nm + 1).all()
# far fewer cells than the full 1.6 mm pad disc
assert len(ii) < 0.5 * math.pi * (0.8 * NM / stack.h_nm) ** 2
def test_two_barrel_contacts_match_acosh():
"""Two equipotential circular contacts of radius a, centers d apart,
on a large sheet: R = rho/(pi t) * acosh(d / 2a). The barrel-ring
contact must reproduce the analytic spreading resistance."""
t_um, rho = 70.0, 1.68e-8
plate = [(0, 0), (80, 0), (80, 60), (0, 60)]
p = make_problem([(plate, [])], rect1_mm=(0, 0, 1, 1),
rect2_mm=(79, 59, 80, 60), t_um=t_um, rho=rho)
p.electrodes1 = [_barrel(30, 30, drill_mm=2.0)]
p.electrodes2 = [_barrel(50, 30, drill_mm=2.0)]
res, _ = _solve(p, 0.15)
r_ref = rho / (math.pi * t_um * 1e-6) * math.acosh(20e-3 / (2 * 1e-3))
assert res.R_ohm == pytest.approx(r_ref, rel=0.08)
def test_barrel_includes_pad_spreading_resistance():
"""Injecting at the barrel wall (0.5 mm ring) sees the spreading
resistance the whole-pad-face contact (2.4 mm equipotential disc)
short-circuits: R_barrel > R_pad_face."""
p1 = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p1.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4)]
r_barrel, _ = _solve(p1, 0.1)
p2 = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p2.electrodes1 = [Electrode(rect=rect_mm((8.8, 8.8, 11.2, 11.2)),
contact="all", label="pad face",
polygons=[_disc(10, 10, 1.2)])]
r_face, _ = _solve(p2, 0.1)
assert r_barrel.R_ohm > r_face.R_ohm * 1.05
def test_ring_fallback_nearest_copper():
"""Antipad bigger than the drill: no copper at the wall ring, the
contact falls back to the nearest copper ring inside the pad
footprint (e.g. thermal-spoke tips / hole edge)."""
hole = [(10 + 1.2 * np.cos(a), 10 + 1.2 * np.sin(a))
for a in np.linspace(0, 2 * np.pi, 64, endpoint=False)]
p = make_problem([(PLATE20, [hole])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=0.6, pad_mm=4.0)]
res, stack = _solve(p, 0.1)
e1, _ = raster.electrode_masks(stack, p)
ii, jj = np.nonzero(e1[0])
d = np.hypot(stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM,
stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM)
assert len(ii) >= 8
assert (d >= 1.2 * NM - stack.h_nm).all()
assert (d <= 1.2 * NM + 2.5 * stack.h_nm).all()
assert np.isfinite(res.R_ohm) and res.R_ohm > 0
def test_solder_filled_barrel_resistance():
"""THT joints: the solder core conducts in parallel with the plating.
Exact parallel-area formula, and a sanity ratio for a 1 mm drill."""
v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1)
rho, sn = 1.68e-8, 1.32e-7
r_plain = v.barrel_resistance(1_600_000, rho, 18_000)
r_fill = v.barrel_resistance(1_600_000, rho, 18_000,
solder_rho_ohm_m=sn)
ga = math.pi * 1e-3 * 18e-6 / rho
ga += math.pi * (0.5e-3 - 18e-6) ** 2 / sn
assert r_fill == pytest.approx(1.6e-3 / ga, rel=1e-12)
assert 1.5 < r_plain / r_fill < 4.0
def test_contact_solder_coat():
"""A soldered THT contact adds an average-thickness solder buildup
over the pad face on the outer layers, lowering the spreading
resistance vs the bare barrel contact."""
def prob():
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, polygons=[_disc(10, 10, 1.2)])]
return p
p = prob()
assert contact_solder_buildups(p) == ["F.Cu"]
assert len(p.buildups) == 1 and p.buildups[0].layer_name == "F.Cu"
r_coat, stack = _solve(p, 0.1)
assert stack.buildup is not None and stack.buildup.any()
r_bare, _ = _solve(prob(), 0.1) # helper not called: no coat
assert r_coat.R_ohm < r_bare.R_ohm
def test_barrel_electrode_json_roundtrip(tmp_path):
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=0.6, pad_mm=1.2, solder=True,
polygons=[_disc(10, 10, 0.6)])]
p.electrodes1[0].barrel_z = (-1, 1_600_001)
f = tmp_path / "d.json"
save_problem(p, f)
e = load_problem(f).electrodes1[0]
assert e.drill_nm == 600_000 and e.pad_nm == 1_200_000
assert e.center == (10 * NM, 10 * NM)
assert e.barrel_z == (-1, 1_600_001)
assert e.solder is True and len(e.polygons) == 1
+29 -4
View File
@@ -9,10 +9,13 @@ from tests.util import NM, make_multilayer
def _two_layer(width_mm=5.0, drill_mm=0.3, pad_mm=0.6, kind="via",
capped=True, cap_um=15.0, hole_mm=None):
capped=True, cap_um=15.0, hole_mm=None,
cap_max_drill_mm=10.0):
"""10 x width strip on both (outer-named) layers, e1 left on F.Cu,
e2 right on B.Cu, one via mid-strip. Optionally a circular hole in
the F.Cu fill around the via (ring-bridging scenario)."""
the F.Cu fill around the via (ring-bridging scenario). The cap-drill
threshold defaults to 10 mm here (= every drill capped) so the tests
exercise the mouth treatment itself; the threshold has its own test."""
y = width_mm / 2
strip = [(0, 0), (10, 0), (10, width_mm), (0, width_mm)]
holes = []
@@ -31,6 +34,7 @@ def _two_layer(width_mm=5.0, drill_mm=0.3, pad_mm=0.6, kind="via",
p.vias[0].pad_nm = int(pad_mm * NM)
p.vias_capped = capped
p.cap_plating_nm = int(cap_um * 1000)
p.cap_max_drill_nm = int(cap_max_drill_mm * NM)
return p
@@ -46,7 +50,11 @@ def test_cap_at_foil_thickness_is_identity():
so the result equals the feature-off reference (a 'pad'-kind barrel,
which skips rings and mouths) with the mouth fully inside copper."""
r_cap, _ = _solve(_two_layer(capped=True, cap_um=70.0), 0.1)
r_ref, _ = _solve(_two_layer(kind="pad"), 0.1)
ref = _two_layer(kind="pad")
# 'pad' barrels are solder-filled; kill the core so the reference
# barrel matches the via's plating-only resistance exactly
ref.solder_rho_ohm_m = 1e30
r_ref, _ = _solve(ref, 0.1)
assert r_cap.R_ohm == pytest.approx(r_ref.R_ohm, rel=1e-9)
@@ -90,14 +98,31 @@ def test_subcell_mouth_perturbs_gently():
assert r_solid.R_ohm <= r_open.R_ohm <= 1.05 * r_solid.R_ohm
def test_cap_drill_threshold():
"""Drills above cap_max_drill_nm stay open even with capping on: a
2 mm drill over a 0.5 mm threshold behaves exactly like uncapped,
while a threshold above the drill restores the cap."""
kw = dict(drill_mm=2.0, pad_mm=2.6)
r_big, s_big = _solve(_two_layer(capped=True, cap_max_drill_mm=0.5,
**kw), 0.25)
r_open, s_open = _solve(_two_layer(capped=False, **kw), 0.25)
assert r_big.R_ohm == pytest.approx(r_open.R_ohm, rel=1e-12)
assert int(s_big.masks.sum()) == int(s_open.masks.sum())
r_cap, _ = _solve(_two_layer(capped=True, cap_max_drill_mm=2.1,
**kw), 0.25)
assert r_cap.R_ohm < r_open.R_ohm
def test_capping_json_roundtrip(tmp_path):
from fill_resistance.geometry import load_problem, save_problem
p = _two_layer(capped=False, cap_um=12.0)
p = _two_layer(capped=False, cap_um=12.0, cap_max_drill_mm=0.8)
f = tmp_path / "d.json"
save_problem(p, f)
q = load_problem(f)
assert q.vias_capped is False
assert q.cap_plating_nm == 12_000
assert q.cap_max_drill_nm == 800_000
r_p, _ = _solve(p, 0.25)
r_q, _ = _solve(q, 0.25)
assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12)