diff --git a/README.md b/README.md index ab6d445..3d2712d 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,18 @@ SWIG API. Requires KiCad **10.0.1+**. only affects in-plane conduction across outer-layer mouths. Sub-cell mouths scale their cells' sheet conductance by the true covered fraction (4×4 supersampling), so coarse grids see the correct small - perturbation instead of a whole-cell hole. THT-pad copper and drills - remain outside the model, but THT-pad **barrels are solder-filled** - (a soldered component lead): the solder core (SAC305) conducts in - parallel with the plating annulus. At f > 0 the thickness scaling is - applied multiplicatively to the skin-corrected sheet conductance + perturbation instead of a whole-cell hole. Barrels are gathered in + **single-layer runs too** (drill mouths perforate a lone plane). + THT-pad copper and drills remain outside the model, but every + **populated THT pad** of the net carries its full **soldered + joint**: a solder-filled barrel (SAC305 core in parallel with the + plating), the average-thickness solder coat over a pad-diameter disc + on the outer layers, and the protruding-lead cone on the side + opposite its footprint (see barrel contacts below). Whether a hole + is a via or a THT pad, the owning footprint's side, and its **Do not + populate** flag are all read from KiCad — DNP pads stay plating-only + with no joint. At f > 0 the thickness scaling is applied + multiplicatively to the skin-corrected sheet conductance (approximation). Per layer a barrel attaches to the fill cell under it, or to the nearest copper cell within the pad footprint plus one grid cell — fills joined by **thermal-relief diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index 2c8824f..61ee7e9 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -23,7 +23,8 @@ from . import config from .errors import ApiVersionError, CandidateError, SelectionError from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect, SurfaceBuildup, TrackSeg, ViaLink, - contact_solder_buildups, linearize_ring) + contact_solder_buildups, linearize_ring, + tht_joint_buildups) MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"} @@ -180,29 +181,42 @@ def _pad_polygons(board: Board, pad: Pad, contact: str) -> list[Polygon] | None: return None -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 []: +def _footprint_pad_map(footprints) -> dict: + """(x, y, number) -> owning FootprintInstance. Footprint pads are + stored with absolute positions, so the lookup is exact.""" + out = {} + for fp in footprints or []: + try: 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") + out[(fpad.position.x, fpad.position.y, fpad.number)] = fp + except Exception: + continue + return out + + +def _pad_owner(pad: Pad, pad_map: dict): + return pad_map.get((pad.position.x, pad.position.y, pad.number)) + + +def _tht_protrusion_side(pad: Pad, pad_map: dict, quiet: bool = False) -> str: + """Outer layer where the clipped THT lead protrudes (tent + solder + cone): the side OPPOSITE the component. Unknown owner -> assume the + component sits on F.Cu (lead tents on B.Cu).""" + fp = _pad_owner(pad, pad_map) + if fp is not None: + try: + side = canonical_name(fp.layer) + return "F.Cu" if side == "B.Cu" else "B.Cu" + except Exception: + pass + if not quiet: + print(f"note: no footprint found for pad {pad.number} - assuming " + f"its lead protrudes on B.Cu") return "B.Cu" def _to_electrode(board: Board, item, stackup: StackupInfo | None = None, - footprints=None) -> Electrode: + pad_map: dict | None = 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, @@ -245,7 +259,7 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None, drill_nm=drill, pad_nm=_padstack_pad_nm(pad), center=(pad.position.x, pad.position.y), solder=drill > 0, - protrusion_side=(_tht_protrusion_side(pad, footprints) + protrusion_side=(_tht_protrusion_side(pad, pad_map or {}) if drill > 0 else None)) @@ -282,9 +296,9 @@ def get_electrodes(board: Board, stackup: StackupInfo | None = None 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) + pad_map = (_footprint_pad_map(board.get_footprints()) + if any(isinstance(s, Pad) and _pad_drill_nm(s) > 0 + for s in pads) else {}) if not selection: allr = [s for s in board.get_shapes() if isinstance(s, BoardRectangle)] @@ -320,7 +334,7 @@ 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, footprints) + pad_parts = [_to_electrode(board, p, stackup, pad_map) for p in pads] if not es1: es1 = pad_parts @@ -335,8 +349,8 @@ def get_electrodes(board: Board, stackup: StackupInfo | None = None items = rects + pads if len(items) == 2: - return ([_to_electrode(board, items[0], stackup, footprints)], - [_to_electrode(board, items[1], stackup, footprints)], + return ([_to_electrode(board, items[0], stackup, pad_map)], + [_to_electrode(board, items[1], stackup, pad_map)], _net_hint_of(pads)) raise SelectionError( f"The selection has {len(rects)} rectangle(s) (none on the marker " @@ -503,16 +517,35 @@ def gather_barrels(board: Board, net_name: str, z_bot_nm=z_bot, kind="via", pad_nm=_padstack_pad_nm(via))) if config.INCLUDE_TH_PADS: - for pad in board.get_pads(): - if pad.net is None or pad.net.name != net_name: - continue - drill = _pad_drill_nm(pad) - if drill <= 0: - continue - barrels.append(ViaLink(x=pad.position.x, y=pad.position.y, - drill_nm=drill, z_top_nm=-1, - z_bot_nm=stackup.z_bot_nm + 1, kind="pad", - pad_nm=_padstack_pad_nm(pad))) + net_pads = [pad for pad in board.get_pads() + if pad.net is not None and pad.net.name == net_name + and _pad_drill_nm(pad) > 0] + # populated (non-DNP) THT pads carry a soldered joint: filled + # hole + coat + lead cone on the side opposite the component + pad_map = (_footprint_pad_map(board.get_footprints()) + if net_pads else {}) + unknown = 0 + for pad in net_pads: + fp = _pad_owner(pad, pad_map) + unknown += fp is None + populated = True + if fp is not None: + try: + populated = not fp.attributes.do_not_populate + except Exception: + pass + barrels.append(ViaLink( + x=pad.position.x, y=pad.position.y, + 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), + solder_filled=populated, + protrusion_side=(_tht_protrusion_side(pad, pad_map, + quiet=True) + if populated else None))) + if unknown: + print(f"note: {unknown} THT pad(s) without an identifiable " + f"footprint - assumed populated, leads on B.Cu") return barrels @@ -550,7 +583,9 @@ def build_problem(board: Board, net: str, layer_names: list[str], f"Net {net} has no fill on any of the selected layers " f"({', '.join(layer_names)})." ) - vias = gather_barrels(board, net, stackup) if len(layers) > 1 else [] + # 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) included = {l.layer_name for l in layers} buildup_list = [ SurfaceBuildup(layer_name=name, polygons=polys) @@ -597,6 +632,15 @@ 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) + 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 "")) return problem diff --git a/fill_resistance/geometry.py b/fill_resistance/geometry.py index 6c5a3d1..dfd0882 100644 --- a/fill_resistance/geometry.py +++ b/fill_resistance/geometry.py @@ -134,6 +134,14 @@ 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 + # with the plating); False for + # vias and DNP footprints + protrusion_side: str | None = None # populated THT pad: outer layer + # where the clipped lead tents + # (solder cone), opposite the + # component side def spans(self, z_nm: int) -> bool: return self.z_top_nm - 1 <= z_nm <= self.z_bot_nm + 1 @@ -227,6 +235,38 @@ def contact_solder_buildups(problem: Problem) -> list[str]: return sorted(set(touched)) +def _disc_polygon(x_nm: float, y_nm: float, r_nm: float, + n: int = 32) -> Polygon: + th = np.linspace(0.0, 2.0 * math.pi, n, endpoint=False) + return Polygon(outline=np.round(np.stack( + [x_nm + r_nm * np.cos(th), y_nm + r_nm * np.sin(th)], + axis=1)).astype(np.int64)) + + +def tht_joint_buildups(problem: Problem) -> list[str]: + """Solder coat of the net's populated STITCHING through-hole pads + (ViaLink kind 'pad' with solder_filled): one pad-diameter disc per + outer layer - 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.""" + included = {l.layer_name for l in problem.layers} + outer = [n for n in ("F.Cu", "B.Cu") if n in included] + 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: + continue + disc = _disc_polygon(v.x, v.y, v.pad_nm / 2.0) + for name in outer: + problem.buildups.append( + SurfaceBuildup(layer_name=name, polygons=[disc])) + touched.append(name) + return sorted(set(touched)) + + def _arc_params(start, mid, end) -> tuple[float, float, float, float, float] | None: """Circle through three points: (cx, cy, r, a0, sweep) with a0 the start angle and sweep signed; None if the points are collinear.""" @@ -485,7 +525,11 @@ def problem_from_json(d: dict) -> Problem: ViaLink(x=int(vd["x"]), y=int(vd["y"]), drill_nm=int(vd["drill_nm"]), 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_nm=int(vd.get("pad_nm", 0)), + # older dumps: every THT pad counted as solder-filled + solder_filled=bool(vd.get( + "solder_filled", vd.get("kind", "via") == "pad")), + protrusion_side=vd.get("protrusion_side")) for vd in d["vias"] ], electrodes1=( diff --git a/fill_resistance/raster.py b/fill_resistance/raster.py index 03a789a..e201752 100644 --- a/fill_resistance/raster.py +++ b/fill_resistance/raster.py @@ -238,9 +238,10 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack: 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 + """Protruding THT leads (barrel contacts AND the net's populated + stitching through-hole pads): 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 @@ -254,18 +255,32 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None: ny, nx = stack.shape2d h = stack.h_nm index = {name: li for li, name in enumerate(stack.layer_names)} + + # one cone per joint: contact electrodes first (exact data), then the + # net's populated stitching THT pads, skipping the contacts' barrels + jobs = [] + seen = set() 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: + if e.drill_nm <= 0: 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 + 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)) + 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)) + + for x, y, drill_nm, pad_nm, side in jobs: + li = index.get(side) + if li is None or pad_nm <= drill_nm: + continue + ra, rb = drill_nm / 2.0, 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)) diff --git a/fill_resistance/solver.py b/fill_resistance/solver.py index 09fb648..cec59ca 100644 --- a/fill_resistance/solver.py +++ b/fill_resistance/solver.py @@ -178,12 +178,13 @@ def _barrel_links(stack: RasterStack, problem: Problem length = problem.layers[lb].z_nm - problem.layers[la].z_nm if length <= 0: continue - # THT pads carry a soldered component lead: the hole is - # solder-filled, the core conducts in parallel with the plating + # 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) r_dc = via.barrel_resistance( length, problem.rho_ohm_m, problem.plating_nm, solder_rho_ohm_m=(problem.solder_rho_ohm_m - if via.kind == "pad" else None)) + if via.solder_filled else None)) links.append((vi, la, ia, ja, lb, ib, jb, r_dc)) return links, dead diff --git a/tests/test_barrel_contacts.py b/tests/test_barrel_contacts.py index debeaf9..42ee813 100644 --- a/tests/test_barrel_contacts.py +++ b/tests/test_barrel_contacts.py @@ -9,7 +9,8 @@ import pytest from fill_resistance import raster, solver from fill_resistance.geometry import (Electrode, Polygon, ViaLink, contact_solder_buildups, load_problem, - save_problem) + problem_from_json, problem_to_json, + save_problem, tht_joint_buildups) from tests.util import NM, make_problem, rect_mm, ring_mm PLATE20 = [(0, 0), (20, 0), (20, 20), (0, 20)] @@ -195,6 +196,72 @@ def test_lead_fillet_lowers_resistance(monkeypatch): assert r_ada.R_ohm == pytest.approx(r_cone.R_ohm, rel=2e-3) +def _pad_link(populated=True): + return ViaLink(x=10 * NM, y=10 * NM, drill_nm=1_000_000, z_top_nm=-1, + z_bot_nm=1, kind="pad", pad_nm=2_400_000, + solder_filled=populated, + protrusion_side="F.Cu" if populated else None) + + +def test_stitching_pad_joint(): + """A populated THT pad on the net (not a contact) gets the full + joint: coat discs on the outer layers and a cone on its protrusion + side; a DNP pad gets neither.""" + def prob(populated=True): + p = make_problem([(PLATE20, [])], + rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) + p.vias = [_pad_link(populated)] + return p + + p = prob() + assert tht_joint_buildups(p) == ["F.Cu"] + assert len(p.buildups) == 1 + r_joint, stack = _solve(p, 0.1) + assert stack.thick_scale is not None and stack.thick_scale.max() > 3.0 + assert stack.buildup is not None and stack.buildup.any() + + q = prob(populated=False) + assert tht_joint_buildups(q) == [] + r_bare, s2 = _solve(q, 0.1) + assert s2.thick_scale is None and s2.buildup is None + assert r_joint.R_ohm < r_bare.R_ohm + + +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.""" + 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)])] + p.electrodes1[0].protrusion_side = "F.Cu" + p.vias = [_pad_link()] + 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 + assert stack.thick_scale.max() == pytest.approx(wall, rel=1e-12) + + +def test_vialink_solder_json(): + p = make_problem([(PLATE20, [])], + rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) + p.vias = [_pad_link()] + d = problem_to_json(p) + q = problem_from_json(d) + assert q.vias[0].solder_filled is True + assert q.vias[0].protrusion_side == "F.Cu" + # legacy dumps without the flag: THT pads counted as solder-filled, + # vias as plating-only + del d["vias"][0]["solder_filled"], d["vias"][0]["protrusion_side"] + q = problem_from_json(d) + assert q.vias[0].solder_filled is True + assert q.vias[0].protrusion_side is None + d["vias"][0]["kind"] = "via" + assert problem_from_json(d).vias[0].solder_filled is False + + def test_barrel_electrode_json_roundtrip(tmp_path): p = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) diff --git a/tests/test_capping.py b/tests/test_capping.py index 8d55b58..c261069 100644 --- a/tests/test_capping.py +++ b/tests/test_capping.py @@ -50,11 +50,9 @@ 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) - 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) + # a bare 'pad' barrel (solder_filled defaults False) is plating-only, + # exactly like the via's + r_ref, _ = _solve(_two_layer(kind="pad"), 0.1) assert r_cap.R_ohm == pytest.approx(r_ref.R_ohm, rel=1e-9)