Model every THT pad hole: exact pad copper, lead conductor, DNP holes
Build PCM package / build (push) Successful in 6s

- THT pad copper is now part of the conductor: the exact pad outline
  (incl. oblong/custom shapes) is fetched from KiCad once per pad and
  stamped onto every included layer (the outer shape stands in for
  inner rings). Annular rings bridge antipads, and joints land on real
  copper instead of only pour coverage.
- The internal lead conductor is modeled in every solder-filled hole:
  a cylinder of drill - THT_LEAD_CLEARANCE_MM (0.25 fab rule) with
  THT_LEAD_RHO_OHM_M (copper default; config for brass/steel leads),
  in parallel with the solder annulus and the plating.
- Drill mouths of THT pads: populated pads keep conducting mouth
  copper (stands in for the solder plug - conservative, the plug is
  worth ~200 um of copper equivalent); DNP pad holes are cut open on
  every layer like uncapped via mouths.
- Oblong pads: the coat uses the exact pad shape; the lead cone tapers
  within the inscribed circle (new pad_min_nm on ViaLink/Electrode) so
  the long axis is not overstated sideways.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-16 19:15:57 +07:00
parent 38b6e34bfa
commit 959446978c
8 changed files with 217 additions and 56 deletions
+50 -4
View File
@@ -257,6 +257,7 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
# barrel; the joint is solder-filled + pad-coated,
# 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),
center=(pad.position.x, pad.position.y),
solder=drill > 0,
protrusion_side=(_tht_protrusion_side(pad, pad_map or {})
@@ -489,6 +490,18 @@ def _padstack_pad_nm(item) -> int:
return 0
def _padstack_pad_min_nm(item) -> int:
"""Smallest dimension of the (largest) copper pad of a padstack; 0
if unknown. Bounds the lead-cone taper on oblong pads: the cone
stays within the inscribed circle."""
try:
sizes = [min(int(l.size.x), int(l.size.y))
for l in item.padstack.copper_layers]
return max(sizes) if sizes else 0
except Exception:
return 0
def _padstack_span(padstack, stackup: StackupInfo) -> tuple[int, int]:
"""(z_top, z_bot) of the barrel; falls back to the full stack."""
try:
@@ -539,6 +552,7 @@ def gather_barrels(board: Board, net_name: str,
drill_nm=_pad_drill_nm(pad), 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),
solder_filled=populated,
protrusion_side=(_tht_protrusion_side(pad, pad_map,
quiet=True)
@@ -549,6 +563,25 @@ def gather_barrels(board: Board, net_name: str,
return barrels
def gather_tht_pad_copper(board: Board, net_name: str
) -> dict[tuple[int, int], list[Polygon]]:
"""(x, y) -> exact copper shape(s) of every drilled (THT) pad on the
net. The annular-ring copper conducts on every layer the barrel
spans, so build_problem stamps these onto each included layer. One
API call per pad; the outer-layer shape stands in for the inner
rings (approximation - inner rings are usually the same or
smaller)."""
shapes: dict[tuple[int, int], list[Polygon]] = {}
for pad in board.get_pads():
if pad.net is None or pad.net.name != net_name \
or _pad_drill_nm(pad) <= 0:
continue
polys = _pad_polygons(board, pad, "all")
if polys:
shapes[(pad.position.x, pad.position.y)] = polys
return shapes
# --- top level ----------------------------------------------------------------
def build_problem(board: Board, net: str, layer_names: list[str],
@@ -586,6 +619,16 @@ def build_problem(board: Board, net: str, layer_names: list[str],
# barrels matter on a single layer too: via rings + drill mouths
# perforate the plane, THT joints locally stiffen it
vias = gather_barrels(board, net, stackup)
# THT pad copper is part of the conductor: stamp the exact pad
# shapes onto every included layer (the barrel spans the stack)
pad_shapes = (gather_tht_pad_copper(board, net)
if any(v.kind == "pad" for v in vias) else {})
if pad_shapes:
extra = [poly for polys in pad_shapes.values() for poly in polys]
for layer in layers:
layer.polygons = list(layer.polygons) + extra
print(f"{len(pad_shapes)} THT pad shape(s) stamped on every "
f"included layer")
included = {l.layer_name for l in layers}
buildup_list = [
SurfaceBuildup(layer_name=name, polygons=polys)
@@ -620,6 +663,8 @@ def build_problem(board: Board, net: str, layer_names: list[str],
cap_max_drill_nm=int((cap_max_drill_mm if cap_max_drill_mm is not None
else config.CAP_MAX_DRILL_MM) * 1e6),
tht_protrusion_nm=int(config.THT_LEAD_PROTRUSION_MM * 1e6),
tht_lead_clearance_nm=int(config.THT_LEAD_CLEARANCE_MM * 1e6),
tht_lead_rho_ohm_m=config.THT_LEAD_RHO_OHM_M,
)
solder_layers = contact_solder_buildups(problem)
if solder_layers:
@@ -632,15 +677,16 @@ def build_problem(board: Board, net: str, layer_names: list[str],
print(f"THT contact(s): solder-filled hole + "
f"{config.SOLDER_THICKNESS_UM:g} um average solder coat on the "
f"pad face ({', '.join(solder_layers)}){cone}")
tht_joint_buildups(problem)
tht_joint_buildups(problem, pad_shapes)
n_joint = sum(1 for v in problem.vias
if v.kind == "pad" and v.solder_filled)
n_dnp = sum(1 for v in problem.vias
if v.kind == "pad" and not v.solder_filled)
if n_joint or n_dnp:
print(f"{n_joint} populated THT pad joint(s): solder-filled hole + "
f"coat + lead cone"
+ (f"; {n_dnp} DNP pad(s) plating-only" if n_dnp else ""))
print(f"{n_joint} populated THT pad joint(s): lead + solder in the "
f"hole, coat + cone on the solder side"
+ (f"; {n_dnp} DNP pad(s): open hole, plating-only"
if n_dnp else ""))
return problem
+8 -1
View File
@@ -39,7 +39,14 @@ THT_LEAD_PROTRUSION_MM = 1.5 # clipped THT lead protrusion on the side
# opposite the component: a solder cone of
# this height at the drill wall (tapering to
# zero at the pad edge) wraps the lead of
# every soldered THT CONTACT. 0 = no cones
# every populated THT pad. 0 = no cones
THT_LEAD_CLEARANCE_MM = 0.25 # hole diameter minus lead diameter (fab
# rule): a lead cylinder of drill - this
# conducts inside every solder-filled hole
THT_LEAD_RHO_OHM_M = 1.68e-8 # lead material resistivity: copper leads/
# wires; brass ~6.4e-8, phosphor bronze
# ~1.1e-7, copper-clad steel higher - raise
# this if your components use such leads
SKIN_SIDES = 1 # skin-effect field config: 1 = plane facing a
# return plane (conservative), 2 = isolated foil
+55 -20
View File
@@ -114,7 +114,10 @@ class Electrode:
polygons: list[Polygon] | None = None
label: str = "rect"
drill_nm: int = 0 # >0: barrel contact
pad_nm: int = 0 # pad diameter (search bound)
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
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)
@@ -136,8 +139,13 @@ class ViaLink:
z_bot_nm: int
kind: str = "via" # "via" | "pad"
pad_nm: int = 0 # pad/annular diameter; 0 = unknown
solder_filled: bool = False # populated THT pad: the hole is
# solder-filled (core in parallel
# (oblong pads: LARGEST dimension,
# used as a search bound)
pad_min_nm: int = 0 # smallest pad dimension (bounds
# the lead-cone taper on oblong
# pads); 0 = same as pad_nm
solder_filled: bool = False # populated THT pad: the hole
# holds lead + solder (in parallel
# with the plating); False for
# vias and DNP footprints
protrusion_side: str | None = None # populated THT pad: outer layer
@@ -150,16 +158,23 @@ class ViaLink:
def barrel_resistance(self, length_nm: int, rho_ohm_m: float,
plating_nm: int,
solder_rho_ohm_m: float | None = None) -> float:
solder_rho_ohm_m: float | None = None,
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 is
solder-filled (soldered THT joint): the solder core conducts in
parallel with the plating."""
plating around the drill. 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 # plating conductance-area [m^2/ohm-m]
/ 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
ga += math.pi * r_core * r_core / solder_rho_ohm_m
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
return (length_nm * 1e-9) / ga
@@ -192,6 +207,13 @@ class Problem:
# wraps the lead on each solder
# contact's protrusion_side;
# 0 disables the cones
tht_lead_clearance_nm: int = 250_000 # hole minus lead diameter (fab
# rule): the lead cylinder of
# drill - this conducts inside
# every solder-filled hole
tht_lead_rho_ohm_m: float = 1.68e-8 # lead material resistivity
# (copper; brass ~6.4e-8,
# copper-clad steel higher)
@property
def layer_names(self) -> list[str]:
@@ -247,27 +269,33 @@ def _disc_polygon(x_nm: float, y_nm: float, r_nm: float,
axis=1)).astype(np.int64))
def tht_joint_buildups(problem: Problem) -> list[str]:
def tht_joint_buildups(problem: Problem,
shapes: dict | None = None) -> list[str]:
"""Solder coat of the net's populated STITCHING through-hole pads
(ViaLink kind 'pad' with solder_filled): one pad-diameter disc on
the pad's SOLDER side (the protrusion side, opposite the component;
the component-side face stays bare). The exact pad shape is unknown
for non-contact pads, and the coat intersects the modeled copper at
raster time anyway. Contact pads are skipped:
contact_solder_buildups already coats them with the exact pad
shape. Returns the affected layer names."""
(ViaLink kind 'pad' with solder_filled), on the pad's SOLDER side
(the protrusion side, opposite the component; the component-side
face stays bare). `shapes` maps (x, y) to the exact pad polygons
(fetched from KiCad); pads without one fall back to a pad-diameter
disc. Contact pads are skipped: contact_solder_buildups already
coats them with the exact pad shape. Returns the affected layer
names."""
included = {l.layer_name for l in problem.layers}
contacts = {e.center for e in problem.electrodes1 + problem.electrodes2
if e.drill_nm > 0 and e.center is not None}
touched = []
for v in problem.vias:
if v.kind != "pad" or not v.solder_filled \
or v.pad_nm <= v.drill_nm or (v.x, v.y) in contacts \
or (v.x, v.y) in contacts \
or v.protrusion_side not in included:
continue
disc = _disc_polygon(v.x, v.y, v.pad_nm / 2.0)
polys = (shapes or {}).get((v.x, v.y))
if polys is None:
if v.pad_nm <= v.drill_nm:
continue
polys = [_disc_polygon(v.x, v.y, v.pad_nm / 2.0)]
problem.buildups.append(
SurfaceBuildup(layer_name=v.protrusion_side, polygons=[disc]))
SurfaceBuildup(layer_name=v.protrusion_side,
polygons=list(polys)))
touched.append(v.protrusion_side)
return sorted(set(touched))
@@ -422,6 +450,7 @@ def _electrode_to_json(e: Electrode) -> dict:
else [_poly_to_json(poly) for poly in e.polygons]),
"drill_nm": e.drill_nm,
"pad_nm": e.pad_nm,
"pad_min_nm": e.pad_min_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,
@@ -438,6 +467,7 @@ def _electrode_from_json(d: dict) -> Electrode:
else [_poly_from_json(pd) for pd in d["polygons"]]),
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)),
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
@@ -484,6 +514,8 @@ def problem_to_json(p: Problem) -> dict:
"cap_plating_nm": p.cap_plating_nm,
"cap_max_drill_nm": p.cap_max_drill_nm,
"tht_protrusion_nm": p.tht_protrusion_nm,
"tht_lead_clearance_nm": p.tht_lead_clearance_nm,
"tht_lead_rho_ohm_m": p.tht_lead_rho_ohm_m,
}
@@ -531,6 +563,7 @@ def problem_from_json(d: dict) -> Problem:
z_top_nm=int(vd["z_top_nm"]), z_bot_nm=int(vd["z_bot_nm"]),
kind=vd.get("kind", "via"),
pad_nm=int(vd.get("pad_nm", 0)),
pad_min_nm=int(vd.get("pad_min_nm", 0)),
# older dumps: every THT pad counted as solder-filled
solder_filled=bool(vd.get(
"solder_filled", vd.get("kind", "via") == "pad")),
@@ -563,6 +596,8 @@ def problem_from_json(d: dict) -> Problem:
cap_plating_nm=int(d.get("cap_plating_nm", 15_000)),
cap_max_drill_nm=int(d.get("cap_max_drill_nm", 500_000)),
tht_protrusion_nm=int(d.get("tht_protrusion_nm", 1_500_000)),
tht_lead_clearance_nm=int(d.get("tht_lead_clearance_nm", 250_000)),
tht_lead_rho_ohm_m=float(d.get("tht_lead_rho_ohm_m", 1.68e-8)),
)
+16 -8
View File
@@ -270,11 +270,14 @@ 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:
jobs.append((x, y, e.drill_nm, e.pad_nm, e.protrusion_side))
# oblong pads: taper to the inscribed circle (conservative)
jobs.append((x, y, e.drill_nm, e.pad_min_nm or e.pad_nm,
e.protrusion_side))
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_nm, v.protrusion_side))
jobs.append((v.x, v.y, v.drill_nm, v.pad_min_nm or v.pad_nm,
v.protrusion_side))
for x, y, drill_nm, pad_nm, side in jobs:
li = index.get(side)
@@ -333,16 +336,21 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
capped vias carry a cap_plating-thin copper cap over the mouth on the
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. Fully swallowed cells leave the
mask; partially covered cells keep a thickness-scaled sheet
conductance via stack.thick_scale."""
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);
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."""
ny, nx = stack.shape2d
h = stack.h_nm
outer = {li for li, n in enumerate(stack.layer_names)
if n in ("F.Cu", "B.Cu")}
sub = (np.arange(4) + 0.5) / 4.0
for via in problem.vias:
if via.kind != "via" or via.drill_nm <= 0:
if via.drill_nm <= 0:
continue
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))
@@ -362,12 +370,12 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
if stack.thick_scale is None:
stack.thick_scale = np.ones(stack.masks.shape)
for li in _via_span(problem, via):
if problem.vias_capped and li in outer \
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
/ problem.layers[li].thickness_nm, 1.0)
else:
ratio = 0.0
ratio = 0.0 # open hole (also DNP THT holes)
s = 1.0 - cov * (1.0 - ratio)
gone = s <= 1e-9
stack.masks[li, i0:i1, j0:j1] &= ~gone
+7 -4
View File
@@ -178,13 +178,16 @@ def _barrel_links(stack: RasterStack, problem: Problem
length = problem.layers[lb].z_nm - problem.layers[la].z_nm
if length <= 0:
continue
# populated THT pads carry a soldered component lead: the hole
# is solder-filled, the core conducts in parallel with the
# plating (DNP pads and vias stay plating-only)
# populated THT pads carry a soldered component lead: lead
# cylinder (drill minus the fab clearance) + solder annulus
# in parallel with the plating (DNP pads and vias stay
# plating-only)
r_dc = via.barrel_resistance(
length, problem.rho_ohm_m, problem.plating_nm,
solder_rho_ohm_m=(problem.solder_rho_ohm_m
if via.solder_filled else None))
if via.solder_filled else None),
lead_nm=max(via.drill_nm - problem.tht_lead_clearance_nm, 0),
lead_rho_ohm_m=problem.tht_lead_rho_ohm_m)
links.append((vi, la, ia, ja, lb, ib, jb, r_dc))
return links, dead