"""Barrel (via / through-hole pad) contact tests: current enters at the drill-wall ring, not the pad face, and soldered THT joints carry a solder-filled hole plus an average-thickness solder coat on the pad.""" import math import numpy as np import pytest from fill_resistance import raster, solver 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_multilayer, make_problem, rect_mm, ring_mm PLATE20 = [(0, 0), (20, 0), (20, 20), (0, 20)] def _barrel(x_mm, y_mm, drill_mm, pad_mm=0.0, solder=False, polygons=None): r = max(pad_mm, drill_mm) / 2 return Electrode( rect=rect_mm((x_mm - r, y_mm - r, x_mm + r, y_mm + r)), contact="all", label=f"via({x_mm},{y_mm})", drill_nm=int(drill_mm * NM), pad_nm=int(pad_mm * NM), center=(int(x_mm * NM), int(y_mm * NM)), solder=solder, polygons=polygons) def _disc(x_mm, y_mm, r_mm, n=64) -> Polygon: ang = np.linspace(0, 2 * np.pi, n, endpoint=False) return Polygon(outline=ring_mm( [(x_mm + r_mm * np.cos(a), y_mm + r_mm * np.sin(a)) for a in ang])) def _solve(p, h_mm, model="equipotential"): stack = raster.rasterize_stack(p, h_mm * NM) e1, e2 = raster.electrode_masks(stack, p) return solver.run_solve(p, stack, e1, e2, 1.0, contact_model=model), stack def test_ring_cells_at_drill_wall(): """The contact cells of a barrel electrode form a ring at the drill wall (one-cell tolerance), not the pad face.""" 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=1.6)] stack = raster.rasterize_stack(p, 0.1 * NM) e1, _ = raster.electrode_masks(stack, p) ii, jj = np.nonzero(e1[0]) xs = stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM ys = stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM d = np.hypot(xs, ys) assert len(ii) >= 8 assert (np.abs(d - 0.5 * NM) <= stack.h_nm + 1).all() # far fewer cells than the full 1.6 mm pad disc assert len(ii) < 0.5 * math.pi * (0.8 * NM / stack.h_nm) ** 2 def test_two_barrel_contacts_match_acosh(): """Two equipotential circular contacts of radius a, centers d apart, on a large sheet: R = rho/(pi t) * acosh(d / 2a). The barrel-ring contact must reproduce the analytic spreading resistance.""" t_um, rho = 70.0, 1.68e-8 plate = [(0, 0), (80, 0), (80, 60), (0, 60)] p = make_problem([(plate, [])], rect1_mm=(0, 0, 1, 1), rect2_mm=(79, 59, 80, 60), t_um=t_um, rho=rho) p.electrodes1 = [_barrel(30, 30, drill_mm=2.0)] p.electrodes2 = [_barrel(50, 30, drill_mm=2.0)] res, _ = _solve(p, 0.15) r_ref = rho / (math.pi * t_um * 1e-6) * math.acosh(20e-3 / (2 * 1e-3)) assert res.R_ohm == pytest.approx(r_ref, rel=0.08) def test_barrel_includes_pad_spreading_resistance(): """Injecting at the barrel wall (0.5 mm ring) sees the spreading resistance the whole-pad-face contact (2.4 mm equipotential disc) short-circuits: R_barrel > R_pad_face.""" p1 = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) p1.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4)] r_barrel, _ = _solve(p1, 0.1) p2 = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) p2.electrodes1 = [Electrode(rect=rect_mm((8.8, 8.8, 11.2, 11.2)), contact="all", label="pad face", polygons=[_disc(10, 10, 1.2)])] r_face, _ = _solve(p2, 0.1) assert r_barrel.R_ohm > r_face.R_ohm * 1.05 def test_ring_fallback_nearest_copper(): """Antipad bigger than the drill: no copper at the wall ring, the contact falls back to the nearest copper ring inside the pad footprint (e.g. thermal-spoke tips / hole edge).""" hole = [(10 + 1.2 * np.cos(a), 10 + 1.2 * np.sin(a)) for a in np.linspace(0, 2 * np.pi, 64, endpoint=False)] p = make_problem([(PLATE20, [hole])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) p.electrodes1 = [_barrel(10, 10, drill_mm=0.6, pad_mm=4.0)] res, stack = _solve(p, 0.1) e1, _ = raster.electrode_masks(stack, p) ii, jj = np.nonzero(e1[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(ii) >= 8 assert (d >= 1.2 * NM - stack.h_nm).all() assert (d <= 1.2 * NM + 2.5 * stack.h_nm).all() assert np.isfinite(res.R_ohm) and res.R_ohm > 0 def test_solder_filled_barrel_resistance(): """THT joints: the solder core conducts in parallel with the plating. Exact parallel-area formula, and a sanity ratio for a 1 mm drill.""" 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_plain = v.barrel_resistance(1_600_000, rho, 18_000) r_fill = v.barrel_resistance(1_600_000, rho, 18_000, solder_rho_ohm_m=sn) ga = math.pi * 1e-3 * 18e-6 / rho ga += math.pi * (0.5e-3 - 18e-6) ** 2 / sn assert r_fill == pytest.approx(1.6e-3 / ga, rel=1e-12) assert 1.5 < r_plain / r_fill < 4.0 def test_contact_solder_coat(): """A soldered THT contact adds an average-thickness solder buildup over the pad face on its SOLDER side only (opposite the component), lowering the spreading resistance vs the bare barrel contact.""" def prob(): 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" return p # solder side not among the included layers -> no coat there q = prob() q.electrodes1[0].protrusion_side = "B.Cu" assert contact_solder_buildups(q) == [] p = prob() assert contact_solder_buildups(p) == ["F.Cu"] assert len(p.buildups) == 1 and p.buildups[0].layer_name == "F.Cu" r_coat, stack = _solve(p, 0.1) assert stack.buildup is not None and stack.buildup.any() r_bare, _ = _solve(prob(), 0.1) # helper not called: no 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 _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: 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)) 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() 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.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 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 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, 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) 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) 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)) 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 # --- slotted (oblong) holes -------------------------------------------------- # The lead/barrel of a slotted hole is a stadium, not a circle: modeling # it as a circle of the slot's LONG dimension painted contact rings, # mouths and cones bigger than the oblong pad itself. def _slot_dist_mm(stack, ii, jj, x_mm, y_mm, dx_nm): """Distance of cells (ii, jj) to a slot axis (+-dx_nm along x).""" xs = stack.x0_nm + (jj + 0.5) * stack.h_nm - x_mm * NM ys = stack.y0_nm + (ii + 0.5) * stack.h_nm - y_mm * NM t = np.clip(xs / dx_nm, -1.0, 1.0) return np.hypot(xs - t * dx_nm, ys), xs, ys def test_slot_ring_hugs_slot_wall(): """The contact ring of a slotted THT pad follows the stadium-shaped slot wall: it reaches around the end caps but never pokes past the oblong pad's short side (the old circular model of the slot's long dimension put cells at radius 1.5 mm straight above/below).""" p = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6) # slot 3.0 x 1.0 mm e.pad_min_nm = int(1.6 * NM) # pad 3.6 x 1.6 mm e.slot_dx_nm = 1 * NM p.electrodes1 = [e] stack = raster.rasterize_stack(p, 0.1 * NM) e1, _ = raster.electrode_masks(stack, p) ii, jj = np.nonzero(e1[0]) d, xs, ys = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM) assert len(ii) >= 16 assert (np.abs(d - 0.5 * NM) <= stack.h_nm + 1).all() assert xs.max() > 1.2 * NM and xs.min() < -1.2 * NM # rings the caps assert np.abs(ys).max() < 0.8 * NM # stays inside the 1.6 mm side def test_slot_mouth_is_stadium(): """A DNP slotted pad cuts a stadium-shaped hole: open along the whole slot, copper kept just past the slot width and the end caps.""" p = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) v = _pad_link(populated=False) v.slot_dx_nm = 1 * NM # slot 3.0 x 1.0 mm along x p.vias = [v] stack = raster.rasterize_stack(p, 0.1 * NM) m = stack.masks[0] assert not m[stack.cell_of(10 * NM, 10 * NM)] assert not m[stack.cell_of(int(10.9 * NM), 10 * NM)] # slot end: open assert not m[stack.cell_of(int(9.1 * NM), 10 * NM)] assert m[stack.cell_of(10 * NM, int(10.8 * NM))] # past the width: copper assert m[stack.cell_of(10 * NM, int(9.2 * NM))] assert m[stack.cell_of(int(11.8 * NM), 10 * NM)] # past the cap: copper def test_slot_cone_follows_slot(): """The lead cone of a slotted oblong pad tapers from the slot WALL to the pad's short dimension. The old circular-drill model (diameter = the slot's long dimension) skipped the cone entirely (pad_min <= drill) and, for the mouth, ate the pad's short side.""" p = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6, solder=True) e.pad_min_nm = int(1.6 * NM) e.slot_dx_nm = 1 * NM e.protrusion_side = "F.Cu" p.electrodes1 = [e] 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, _, _ = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM) ra, rb, H = 0.5 * NM, 0.8 * 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) 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).""" v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1, slot_dx_nm=800_000, slot_dy_nm=600_000) # ext = 2 mm rho, sn = 1.68e-8, 1.32e-7 ga = (math.pi * 1e-3 + 2 * 2e-3) * 18e-6 / rho r_plain = v.barrel_resistance(1_600_000, rho, 18_000) assert r_plain == pytest.approx(1.6e-3 / ga, rel=1e-12) rc = 0.5e-3 - 18e-6 ga += (math.pi * rc * rc + 2 * rc * 2e-3) / sn r_fill = v.barrel_resistance(1_600_000, rho, 18_000, solder_rho_ohm_m=sn) assert r_fill == pytest.approx(1.6e-3 / ga, rel=1e-12) def test_slot_coat_fallback_within_pad(): """Without an exact pad shape the stitching coat falls back to a capsule along the slot (width = pad_min), not the old pad_nm disc that stuck out past an oblong pad's short side.""" p = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) v = _pad_link() # pad_nm = 2.4 mm v.pad_min_nm = 1_600_000 v.slot_dx_nm = 1 * NM p.vias = [v] assert tht_joint_buildups(p) == ["F.Cu"] pts = p.buildups[0].polygons[0].outline.astype(float) xs, ys = pts[:, 0] - 10 * NM, pts[:, 1] - 10 * NM t = np.clip(xs / (0.4 * NM), -1.0, 1.0) # caps at +-(2.4-1.6)/2 mm d = np.hypot(xs - t * 0.4 * NM, ys) assert np.allclose(d, 0.8 * NM, atol=2) assert np.abs(xs).max() <= 1.2 * NM + 2 # never past pad_nm / 2 assert np.abs(ys).max() <= 0.8 * NM + 2 # never past pad_min / 2 def test_slot_json_roundtrip(): p = make_problem([(PLATE20, [])], rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20)) e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6) e.slot_dx_nm, e.slot_dy_nm = 700_000, -700_000 p.electrodes1 = [e] v = _pad_link() v.slot_dx_nm = 1 * NM p.vias = [v] q = problem_from_json(problem_to_json(p)) assert (q.electrodes1[0].slot_dx_nm, q.electrodes1[0].slot_dy_nm) \ == (700_000, -700_000) assert (q.vias[0].slot_dx_nm, q.vias[0].slot_dy_nm) == (1 * NM, 0) # legacy dumps: round drills d = problem_to_json(p) for vd in d["vias"]: del vd["slot_dx_nm"], vd["slot_dy_nm"] assert problem_from_json(d).vias[0].slot_dx_nm == 0 def test_drill_info_slot_rotation(): """_drill_info: slot axis from the drill x/y sizes, rotated with the pad (KiCad angles are CCW with y down: 90 deg sends +x to -y).""" from types import SimpleNamespace as NS from fill_resistance.board_io import _drill_info def pad(dx_mm, dy_mm, angle_deg): return NS(padstack=NS( drill=NS(diameter=NS(x=int(dx_mm * NM), y=int(dy_mm * NM))), angle=NS(degrees=angle_deg))) assert _drill_info(pad(1.0, 1.0, 0.0)) == (1 * NM, 0, 0) # round assert _drill_info(pad(3.0, 1.0, 0.0)) == (1 * NM, 1 * NM, 0) assert _drill_info(pad(1.0, 3.0, 0.0)) == (1 * NM, 0, 1 * NM) w, dx, dy = _drill_info(pad(3.0, 1.0, 90.0)) assert (w, dx, dy) == (1 * NM, 0, -1 * NM) w, dx, dy = _drill_info(pad(3.0, 1.0, 45.0)) assert w == 1 * NM assert dx == pytest.approx(1 * NM / math.sqrt(2), abs=2) assert dy == pytest.approx(-1 * NM / math.sqrt(2), abs=2) 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) 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