diff --git a/README.md b/README.md index bbdce16..6e7e08f 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,24 @@ SWIG API. Requires KiCad **10.0.1+**. fraction (4×4 supersampling), so coarse grids see the correct small 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** on its SOLDER side (opposite the component; the - component-side pad face stays bare): a solder-filled barrel (SAC305 - core in parallel with the plating), the average-thickness solder - coat over a pad-diameter disc, and the protruding-lead cone (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 + **THT pads are fully modeled**: their exact copper shapes (incl. + oblong pads, fetched from KiCad; the outer shape stands in for inner + rings) are stamped onto every included layer, and every **populated** + pad carries its full **soldered joint** on its SOLDER side (opposite + the component; the component-side pad face stays bare): the hole + 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 plug — conservative, + the plug is worth far more than the foil); 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 within the inscribed circle). 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** get an **open hole** and + a plating-only barrel, 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 @@ -112,8 +119,9 @@ SWIG API. Requires KiCad **10.0.1+**. series resistance carries no discretization error and no cell-size tuning is needed for thin traces. 1D-modeled traces show potential, power density, and |J| (the true in-trace density from the link - currents, |ΔV|/(ρ·Δl)). Pad copper other than the selected - contacts is still **not** part of the conductor model. + currents, |ΔV|/(ρ·Δl)). THT pad copper is part of the conductor + (exact shapes, see above); **SMD** pad copper other than the + selected contacts is still **not**. - **Solder buildup on mask openings** (dialog checkbox, **off by default**; `INCLUDE_MASK_BUILDUP`): zones drawn on `F.Mask`/`B.Mask` are treated as mask openings that collect `SOLDER_THICKNESS_UM` diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index 61ee7e9..a55cc96 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -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 diff --git a/fill_resistance/config.py b/fill_resistance/config.py index cccc5a8..a320ff8 100644 --- a/fill_resistance/config.py +++ b/fill_resistance/config.py @@ -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 diff --git a/fill_resistance/geometry.py b/fill_resistance/geometry.py index 2ee94bc..c8b4124 100644 --- a/fill_resistance/geometry.py +++ b/fill_resistance/geometry.py @@ -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)), ) diff --git a/fill_resistance/raster.py b/fill_resistance/raster.py index e201752..8afe70c 100644 --- a/fill_resistance/raster.py +++ b/fill_resistance/raster.py @@ -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 diff --git a/fill_resistance/solver.py b/fill_resistance/solver.py index cec59ca..611cc27 100644 --- a/fill_resistance/solver.py +++ b/fill_resistance/solver.py @@ -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 diff --git a/tests/test_barrel_contacts.py b/tests/test_barrel_contacts.py index 7a84eae..c4db222 100644 --- a/tests/test_barrel_contacts.py +++ b/tests/test_barrel_contacts.py @@ -211,8 +211,8 @@ def _pad_link(populated=True): 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.""" + joint: solder-side coat, cone, and a conducting (plugged) mouth; + a DNP pad gets an open hole and nothing else.""" def prob(populated=True): p = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) @@ -225,11 +225,13 @@ def test_stitching_pad_joint(): 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() + assert stack.masks[0][stack.cell_of(10 * NM, 10 * NM)] # plugged mouth 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 s2.buildup is None + assert not s2.masks[0][s2.cell_of(10 * NM, 10 * NM)] # DNP: open hole assert r_joint.R_ohm < r_bare.R_ohm @@ -250,6 +252,54 @@ def test_cone_not_doubled_at_contact(): assert stack.thick_scale.max() == pytest.approx(wall, rel=1e-12) +def test_lead_in_barrel_resistance(): + """Populated hole: plating || lead cylinder || solder annulus, with + the lead clipped to the plating bore.""" + 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_solder = v.barrel_resistance(1_600_000, rho, 18_000, + solder_rho_ohm_m=sn) + r_lead = v.barrel_resistance(1_600_000, rho, 18_000, + solder_rho_ohm_m=sn, + lead_nm=750_000, lead_rho_ohm_m=rho) + rl, rc = 0.375e-3, 0.5e-3 - 18e-6 + ga = math.pi * 1e-3 * 18e-6 / rho + ga += math.pi * rl ** 2 / rho + math.pi * (rc ** 2 - rl ** 2) / sn + assert r_lead == pytest.approx(1.6e-3 / ga, rel=1e-12) + assert r_lead < r_solder + # a lead wider than the bore is clipped to it + r_big = v.barrel_resistance(1_600_000, rho, 18_000, + solder_rho_ohm_m=sn, + lead_nm=2_000_000, lead_rho_ohm_m=rho) + ga2 = math.pi * 1e-3 * 18e-6 / rho + math.pi * rc ** 2 / rho + assert r_big == pytest.approx(1.6e-3 / ga2, rel=1e-12) + + +def test_oblong_pad_cone_uses_inscribed_dim(): + """Oblong pads: the cone tapers to the inscribed circle (pad_min), + never past it, so the long pad axis is not overstated sideways.""" + p = make_problem([(PLATE20, [])], + rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) + p.vias = [_pad_link()] + p.vias[0].pad_min_nm = 1_600_000 # 2.4 mm max, 1.6 mm min + stack = raster.rasterize_stack(p, 0.1 * NM) + ii, jj = np.nonzero(stack.thick_scale[0] != 1.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(d) and d.max() < 0.8 * NM + + +def test_stitching_coat_exact_shape(): + """When KiCad supplies the exact pad polygon, the coat uses it + instead of the pad-diameter disc (oblong pads stay honest).""" + p = make_problem([(PLATE20, [])], + rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) + p.vias = [_pad_link()] + shape = _disc(10, 10, 0.9) + assert tht_joint_buildups(p, {(10 * NM, 10 * NM): [shape]}) == ["F.Cu"] + assert p.buildups[0].polygons[0] is shape + + def test_vialink_solder_json(): 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 c261069..615c8b4 100644 --- a/tests/test_capping.py +++ b/tests/test_capping.py @@ -50,9 +50,13 @@ 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) - # 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) + ref = _two_layer(kind="pad") + # populated pads skip rings and mouths; kill the lead + solder core + # so the reference barrel matches the via's plating-only resistance + ref.vias[0].solder_filled = True + ref.solder_rho_ohm_m = 1e30 + ref.tht_lead_clearance_nm = 10 ** 9 + r_ref, _ = _solve(ref, 0.1) assert r_cap.R_ohm == pytest.approx(r_ref.R_ohm, rel=1e-9)