diff --git a/README.md b/README.md index f857ace..ab6d445 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,17 @@ SWIG API. Requires KiCad **10.0.1+**. soldered **THT joint** additionally assumes the **hole is filled with solder** (core in parallel with the plating) and the **pad face carries an average-thickness solder coat** (`SOLDER_THICKNESS_UM`, - 50 µm) over the modeled copper under the pad shape. To model a probe - pressed onto the pad face instead, draw a marker rectangle over the - pad. + 50 µm) over the modeled copper under the pad shape. The **clipped + lead protrudes** `THT_LEAD_PROTRUSION_MM` (1.5 mm, 0 = off) on the + side opposite the component (taken from the owning footprint; + assumed `B.Cu` if it cannot be found) and a **solder cone** wraps + it: full protrusion height at the drill wall, tapering linearly to + zero at the pad edge, applied as extra conduction-equivalent copper + per cell. The tall solder column at the wall pulls the joint + vicinity to lead potential — equivalent to extending the barrel wall + vertically — while the taper carries the radial spreading. To model + a probe pressed onto the pad face instead, draw a marker rectangle + over the pad. - **Contact models** (dialog / `CONTACT_MODEL`): default **uniform injection** — a conductor pressed on top feeds the current orthogonally with uniform surface density, so |J| ramps across the contact area diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index b6c3aa2..2c8824f 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -180,8 +180,29 @@ def _pad_polygons(board: Board, pad: Pad, contact: str) -> list[Polygon] | None: return None -def _to_electrode(board: Board, item, - stackup: StackupInfo | None = None) -> Electrode: +def _tht_protrusion_side(pad: Pad, footprints) -> str: + """Outer layer where the clipped THT lead protrudes (tent + solder + cone): the side OPPOSITE the component. Footprint pads are stored + with absolute positions, so the owning footprint is matched by pad + number + position. Unknown owner -> assume the component sits on + F.Cu (lead tents on B.Cu).""" + try: + for fp in footprints or []: + for fpad in fp.definition.pads: + if fpad.number == pad.number \ + and fpad.position.x == pad.position.x \ + and fpad.position.y == pad.position.y: + side = canonical_name(fp.layer) + return "F.Cu" if side == "B.Cu" else "B.Cu" + except Exception: + pass + print(f"note: no footprint found for pad {pad.number} - assuming its " + f"lead protrudes on B.Cu") + return "B.Cu" + + +def _to_electrode(board: Board, item, stackup: StackupInfo | None = None, + footprints=None) -> Electrode: if isinstance(item, BoardRectangle): tl, br = item.top_left, item.bottom_right rect = Rect.normalized(tl.x, tl.y, br.x, br.y, @@ -219,10 +240,13 @@ def _to_electrode(board: Board, item, return Electrode(rect=rect, contact=contact, polygons=_pad_polygons(board, pad, contact), label=label, # through-hole pad: current enters at the soldered - # barrel; the joint is solder-filled + pad-coated + # 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), center=(pad.position.x, pad.position.y), - solder=drill > 0) + solder=drill > 0, + protrusion_side=(_tht_protrusion_side(pad, footprints) + if drill > 0 else None)) def _net_hint_of(items: list) -> str | None: @@ -257,6 +281,10 @@ def get_electrodes(board: Board, stackup: StackupInfo | None = None selection = list(board.get_selection()) rects = [s for s in selection if isinstance(s, BoardRectangle)] pads = [s for s in selection if isinstance(s, (Pad, Via))] + # protrusion-side lookup needs the owning footprints (THT pads only) + footprints = (board.get_footprints() + if any(isinstance(s, Pad) and _pad_drill_nm(s) > 0 + for s in pads) else None) if not selection: allr = [s for s in board.get_shapes() if isinstance(s, BoardRectangle)] @@ -292,7 +320,8 @@ def get_electrodes(board: Board, stackup: StackupInfo | None = None f"only for a side that has none." ) if pads: - pad_parts = [_to_electrode(board, p, stackup) for p in pads] + pad_parts = [_to_electrode(board, p, stackup, footprints) + for p in pads] if not es1: es1 = pad_parts else: @@ -306,8 +335,9 @@ def get_electrodes(board: Board, stackup: StackupInfo | None = None items = rects + pads if len(items) == 2: - return ([_to_electrode(board, items[0], stackup)], - [_to_electrode(board, items[1], stackup)], _net_hint_of(pads)) + return ([_to_electrode(board, items[0], stackup, footprints)], + [_to_electrode(board, items[1], stackup, footprints)], + _net_hint_of(pads)) raise SelectionError( f"The selection has {len(rects)} rectangle(s) (none on the marker " f"layers) and {len(pads)} pad(s)/via(s); without marker layers " @@ -554,12 +584,19 @@ def build_problem(board: Board, net: str, layer_names: list[str], cap_plating_nm=int(config.CAP_PLATING_UM * 1000), 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), ) solder_layers = contact_solder_buildups(problem) if solder_layers: + sides = sorted({e.protrusion_side + for e in problem.electrodes1 + problem.electrodes2 + if e.solder and e.protrusion_side}) + cone = (f", {config.THT_LEAD_PROTRUSION_MM:g} mm lead + solder cone " + f"on {', '.join(sides)}" + if sides and problem.tht_protrusion_nm > 0 else "") 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)})") + f"pad face ({', '.join(solder_layers)}){cone}") return problem diff --git a/fill_resistance/config.py b/fill_resistance/config.py index 40cf46c..cccc5a8 100644 --- a/fill_resistance/config.py +++ b/fill_resistance/config.py @@ -35,6 +35,11 @@ INCLUDE_TH_PADS = True # plated through-hole pads stitch layers too; # their holes are modeled solder-filled (a # soldered component lead), so the solder core # conducts in parallel with the plating +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 SKIN_SIDES = 1 # skin-effect field config: 1 = plane facing a # return plane (conservative), 2 = isolated foil diff --git a/fill_resistance/geometry.py b/fill_resistance/geometry.py index b72ec43..6c5a3d1 100644 --- a/fill_resistance/geometry.py +++ b/fill_resistance/geometry.py @@ -116,6 +116,11 @@ class Electrode: 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) + protrusion_side: str | None = None # outer layer where the clipped + # lead protrudes (opposite the + # component): a solder cone + # wraps it there, see + # Problem.tht_protrusion_nm @dataclass @@ -170,6 +175,13 @@ class Problem: cap_max_drill_nm: int = 500_000 # fab caps only small vias: # drills above this stay open # even with vias_capped + tht_protrusion_nm: int = 1_500_000 # clipped THT lead protrusion: + # a solder cone of this height + # at the drill wall (tapering + # to zero at the pad edge) + # wraps the lead on each solder + # contact's protrusion_side; + # 0 disables the cones @property def layer_names(self) -> list[str]: @@ -368,6 +380,7 @@ def _electrode_to_json(e: Electrode) -> dict: "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, + "protrusion_side": e.protrusion_side, } @@ -385,6 +398,7 @@ def _electrode_from_json(d: dict) -> Electrode: barrel_z=(None if d.get("barrel_z") is None else (int(d["barrel_z"][0]), int(d["barrel_z"][1]))), solder=bool(d.get("solder", False)), + protrusion_side=d.get("protrusion_side"), ) @@ -424,6 +438,7 @@ def problem_to_json(p: Problem) -> dict: "vias_capped": p.vias_capped, "cap_plating_nm": p.cap_plating_nm, "cap_max_drill_nm": p.cap_max_drill_nm, + "tht_protrusion_nm": p.tht_protrusion_nm, } @@ -498,6 +513,7 @@ def problem_from_json(d: dict) -> Problem: vias_capped=bool(d.get("vias_capped", True)), 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)), ) diff --git a/fill_resistance/raster.py b/fill_resistance/raster.py index 81589a8..03a789a 100644 --- a/fill_resistance/raster.py +++ b/fill_resistance/raster.py @@ -232,9 +232,58 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack: _paint_ring(stack, hole, False, pmask) stack.buildup[li] |= pmask stack.buildup &= stack.masks # solder wets exposed copper only + + _paint_lead_fillets(stack, problem) return stack +def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None: + """Protruding THT leads of soldered barrel contacts: the clipped + lead sticks 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 + 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 + the skin-corrected sheet conductance, like the via mouths + (approximation).""" + H = problem.tht_protrusion_nm + if H <= 0: + return + ny, nx = stack.shape2d + h = stack.h_nm + index = {name: li for li, name in enumerate(stack.layer_names)} + for e in problem.electrodes1 + problem.electrodes2: + if not (e.solder and e.drill_nm > 0 and e.protrusion_side): + continue + li = index.get(e.protrusion_side) + if li is None or e.pad_nm <= e.drill_nm: + continue + if e.center is not None: + x, y = e.center + else: + x = (e.rect.x0 + e.rect.x1) / 2.0 + y = (e.rect.y0 + e.rect.y1) / 2.0 + ra, rb = e.drill_nm / 2.0, e.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) + 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) + 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) + m = stack.masks[li, i0:i1, j0:j1] + stack.thick_scale[li, i0:i1, j0:j1] *= np.where(m, factor, 1.0) + + def _via_span(problem: Problem, via) -> list[int]: return [li for li, layer in enumerate(problem.layers) if via.spans(layer.z_nm)] diff --git a/tests/test_barrel_contacts.py b/tests/test_barrel_contacts.py index 27debe9..debeaf9 100644 --- a/tests/test_barrel_contacts.py +++ b/tests/test_barrel_contacts.py @@ -143,16 +143,73 @@ def test_contact_solder_coat(): assert r_coat.R_ohm < r_bare.R_ohm +def test_lead_fillet_profile(): + """The protruding-lead solder cone paints thick_scale with the exact + per-cell formula: 1 + H*clip((rb-r)/(rb-ra), 0, 1)*(rho_cu/rho_sn)/t + on copper of the protrusion side; nothing elsewhere.""" + 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)] + p.electrodes1[0].protrusion_side = "F.Cu" + 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 = np.hypot(stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM, + stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM) + ra, rb, H = 0.5 * NM, 1.2 * 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) + # 1.5 mm of solder at the wall ~ 191 um copper: factor ~ 3.7 on 70 um + assert stack.thick_scale[0].max() > 3.0 + + p.electrodes1[0].protrusion_side = None # e.g. via contact: no cone + s2 = raster.rasterize_stack(p, 0.1 * NM) + assert s2.thick_scale is None + + +def test_lead_fillet_lowers_resistance(monkeypatch): + """The cone shorts the joint vicinity: R(with cone) < R(coat-less + bare barrel); the adaptive grid pins the cone cells fine and + matches the uniform grid.""" + def prob(protrude=True): + 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)] + p.electrodes1[0].protrusion_side = "F.Cu" + if not protrude: + p.tht_protrusion_nm = 0 + return p + + r_cone, _ = _solve(prob(), 0.1) + r_bare, _ = _solve(prob(protrude=False), 0.1) + assert r_cone.R_ohm < r_bare.R_ohm + + from fill_resistance import config + monkeypatch.setattr(config, "ADAPTIVE_CELLS", True) + r_ada, _ = _solve(prob(), 0.1) + assert r_ada.R_ohm == pytest.approx(r_cone.R_ohm, rel=2e-3) + + 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) + p.electrodes1[0].protrusion_side = "B.Cu" + p.tht_protrusion_nm = 1_200_000 f = tmp_path / "d.json" save_problem(p, f) - e = load_problem(f).electrodes1[0] + q = load_problem(f) + e = q.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 + assert e.protrusion_side == "B.Cu" + assert q.tht_protrusion_nm == 1_200_000