diff --git a/README.md b/README.md index cde4661..3ac100d 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/fill_resistance/plots.py b/fill_resistance/plots.py index 53bce17..e643208 100644 --- a/fill_resistance/plots.py +++ b/fill_resistance/plots.py @@ -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) diff --git a/fill_resistance/raster.py b/fill_resistance/raster.py index 75a40a6..9844e29 100644 --- a/fill_resistance/raster.py +++ b/fill_resistance/raster.py @@ -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 diff --git a/tests/test_barrel_contacts.py b/tests/test_barrel_contacts.py index 741633e..92833ea 100644 --- a/tests/test_barrel_contacts.py +++ b/tests/test_barrel_contacts.py @@ -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)."""