diff --git a/README.md b/README.md index 20011d1..af5b5eb 100644 --- a/README.md +++ b/README.md @@ -70,18 +70,21 @@ SWIG API. Requires KiCad **10.0.1+**. barrel lengths. - Via/pad barrels: thin-wall annulus, R = ρ·L/(π·d·t_plating), `VIA_PLATING_UM = 18` in `fill_resistance/config.py`. Vias are always - plated. **Via capping is not modeled.** Layer-to-layer it cannot - matter at DC: the cap (≥15 µm plating per fab spec, thinner than the - foil) sits over the hole mouth in parallel with the annular-ring - contact, not in series. In-plane, the model treats every via mouth as - solid layer-thickness copper (KiCad fills do not subtract the drill), - where reality is a hole (uncapped) or the thin cap. The error is - bounded by the dilute-hole correction ρ_eff ≈ ρ·(1 + 2f) inside via - fields (f = mouth area fraction; 0.3 mm drills on a 1 mm grid give - f ≈ 7 % → ≈ +14 % locally), is partially offset by the unmodeled - annular-ring copper, and largely vanishes where the current descends - into the barrels anyway — typically ≪ a few % of the total R. - Per layer a barrel attaches to + plated. Each via also contributes its **ring/pad copper** (a + full-thickness disc of the pad diameter on every spanned layer) and + its **drill mouth**, area-weighted per cell: with the **"vias filled + + capped" checkbox** (default on, `VIAS_CAPPED`) the mouth carries a + thin copper cap (`CAP_PLATING_UM = 15`, fab spec) on the **outer** + layers and is an open hole on inner layers; unchecked, mouths are open + holes everywhere. Layer-to-layer the cap never matters at DC (it is in + parallel with the annular-ring contact, not in series) — the checkbox + 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; 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 spokes** still connect; wider antipads do not, and the barrel bridges diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index d1a8f62..dee3bef 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -462,7 +462,8 @@ def build_problem(board: Board, net: str, layer_names: list[str], stackup: StackupInfo, fills: dict, buildups: dict[str, list[Polygon]] | None = None, extra_cu_um: float | None = None, - tracks: dict | None = None) -> Problem: + tracks: dict | None = None, + vias_capped: bool | None = None) -> Problem: per_layer = fills.get(net, {}) per_layer_tracks = (tracks or {}).get(net, {}) layers = [] @@ -516,6 +517,9 @@ def build_problem(board: Board, net: str, layer_names: list[str], extra_cu_nm=int((extra_cu_um if extra_cu_um is not None else config.BUILDUP_EXTRA_CU_UM) * 1000), tracks=segs, + vias_capped=(vias_capped if vias_capped is not None + else config.VIAS_CAPPED), + cap_plating_nm=int(config.CAP_PLATING_UM * 1000), ) diff --git a/fill_resistance/config.py b/fill_resistance/config.py index b1135c6..0aed78a 100644 --- a/fill_resistance/config.py +++ b/fill_resistance/config.py @@ -20,13 +20,14 @@ RHO_CU_OHM_M = 1.68e-8 # copper resistivity at 20 degC COPPER_THICKNESS_UM: float | None = None # None -> stackup, fallback 35.0 with warning FALLBACK_THICKNESS_UM = 35.0 TEST_CURRENT_A = 1.0 # default injected current (dialog/CLI-selectable) -VIA_PLATING_UM = 18.0 # barrel plating thickness (always plated). - # Via capping is NOT modeled: layer-to-layer - # the cap (>=15um per fab spec, thinner than - # the foil) is parallel to the annular-ring - # contact, not in series; in-plane every via - # mouth is treated as solid layer-thickness - # copper (error bound: see README). +VIA_PLATING_UM = 18.0 # barrel plating thickness (always plated) +VIAS_CAPPED = True # filled + capped vias (dialog checkbox): + # outer-layer mouths carry a CAP_PLATING_UM + # thin copper cap, inner-layer mouths are + # holes. False = open mouths on all layers. + # Ring/pad copper of vias is modeled either + # way; THT-pad copper/drills are not. +CAP_PLATING_UM = 15.0 # cap plating thickness (fab spec) INCLUDE_TH_PADS = True # plated through-hole pads stitch layers too SKIN_SIDES = 1 # skin-effect field config: 1 = plane facing a # return plane (conservative), 2 = isolated foil diff --git a/fill_resistance/dialog.py b/fill_resistance/dialog.py index 2949689..d8652b7 100644 --- a/fill_resistance/dialog.py +++ b/fill_resistance/dialog.py @@ -36,6 +36,7 @@ class Selection: include_buildup: bool = False extra_cu_um: float = 0.0 include_tracks: bool = True + vias_capped: bool = True class _Dialog(QDialog): @@ -65,6 +66,12 @@ class _Dialog(QDialog): self.tracks_check.setChecked(config.INCLUDE_TRACKS) form.addRow("Conductors:", self.tracks_check) + self.capped_check = QCheckBox( + f"vias filled + capped ({config.CAP_PLATING_UM:g} µm cap; " + f"off = open mouths)") + self.capped_check.setChecked(config.VIAS_CAPPED) + form.addRow("Vias:", self.capped_check) + self.contact1_box = QComboBox() self.contact2_box = QComboBox() form.addRow(f"V+ ({e1_label}):", self.contact1_box) @@ -206,7 +213,8 @@ class _Dialog(QDialog): contact_model=self.model_box.currentData(), include_buildup=self.buildup_check.isChecked(), extra_cu_um=extra_cu, - include_tracks=self.tracks_check.isChecked()) + include_tracks=self.tracks_check.isChecked(), + vias_capped=self.capped_check.isChecked()) def _try_accept(self) -> None: try: diff --git a/fill_resistance/geometry.py b/fill_resistance/geometry.py index 1197998..f679813 100644 --- a/fill_resistance/geometry.py +++ b/fill_resistance/geometry.py @@ -144,6 +144,9 @@ class Problem: solder_rho_ohm_m: float = 1.32e-7 extra_cu_nm: int = 0 tracks: list[TrackSeg] = field(default_factory=list) + vias_capped: bool = True # filled+capped vias: thin cap + cap_plating_nm: int = 15_000 # over outer-layer mouths; + # False = open mouths @property def layer_names(self) -> list[str]: @@ -359,6 +362,8 @@ def problem_to_json(p: Problem) -> dict: "solder_thickness_nm": p.solder_thickness_nm, "solder_rho_ohm_m": p.solder_rho_ohm_m, "extra_cu_nm": p.extra_cu_nm, + "vias_capped": p.vias_capped, + "cap_plating_nm": p.cap_plating_nm, } @@ -430,6 +435,8 @@ def problem_from_json(d: dict) -> Problem: width_nm=int(td["width_nm"])) for td in d.get("tracks", []) # <= v4: baked into polygons ], + vias_capped=bool(d.get("vias_capped", True)), + cap_plating_nm=int(d.get("cap_plating_nm", 15_000)), ) diff --git a/fill_resistance/main.py b/fill_resistance/main.py index cda6073..ad395e0 100644 --- a/fill_resistance/main.py +++ b/fill_resistance/main.py @@ -93,7 +93,8 @@ def main() -> None: fills, buildups=(buildups if selection.include_buildup else None), extra_cu_um=selection.extra_cu_um, - tracks=(tracks if selection.include_tracks else None)) + tracks=(tracks if selection.include_tracks else None), + vias_capped=selection.vias_capped) outdir = report.make_output_dir(board_io.board_dir(board)) except ApiError as e: raise UserFacingError(f"KiCad API error: {e}") diff --git a/fill_resistance/raster.py b/fill_resistance/raster.py index 94443eb..e3e296e 100644 --- a/fill_resistance/raster.py +++ b/fill_resistance/raster.py @@ -42,6 +42,10 @@ class RasterStack: # copper only through a 1D trace chain chain_edges: tuple | None = None # (a, b, g_dc, layer) arrays: explicit # DC conductances of the chain links + 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 @property def nlayers(self) -> int: @@ -182,6 +186,10 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack: # directly, skipping the full-frame temp _paint_ring(stack, poly.outline, True, stack.masks[li]) + # via ring/pad copper BEFORE tracks, so 1D chains see it as regular + # copper; drill mouths AFTER tracks, so drills go through trace copper + _paint_via_rings(stack, problem) + # traces: wide ones are rasterized from their outline, sub-resolution # ones become exact 1D resistor chains along their centerline index = {name: li for li, name in enumerate(stack.layer_names)} @@ -201,6 +209,8 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack: f"{config.TRACK_1D_FACTOR:g} cells modeled as 1D resistor " f"chains ({n_links} links)") + _apply_via_mouths(stack, problem) + if problem.buildups: stack.buildup = np.zeros_like(stack.masks) index = {name: li for li, name in enumerate(stack.layer_names)} @@ -218,6 +228,78 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack: return stack +def _via_span(problem: Problem, via) -> list[int]: + return [li for li, layer in enumerate(problem.layers) + if via.spans(layer.z_nm)] + + +def _paint_via_rings(stack: RasterStack, problem: Problem) -> None: + """Annular-ring / via-pad copper: a full-thickness disc of the pad + diameter on every layer the barrel spans (kind='via' only - THT pad + copper stays outside the model). The drill mouth re-opens the disc + center in _apply_via_mouths.""" + ny, nx = stack.shape2d + h = stack.h_nm + for via in problem.vias: + if via.kind != "via" or via.pad_nm <= 0: + continue + r = via.pad_nm / 2.0 + j0 = max(0, math.floor((via.x - r - stack.x0_nm) / h)) + j1 = min(nx, math.floor((via.x + r - stack.x0_nm) / h) + 1) + i0 = max(0, math.floor((via.y - r - stack.y0_nm) / h)) + i1 = min(ny, math.floor((via.y + r - stack.y0_nm) / h) + 1) + if i0 >= i1 or j0 >= j1: + continue + xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - via.x + ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - via.y + disc = (ys[:, None] ** 2 + xs[None, :] ** 2) <= r * r + for li in _via_span(problem, via): + stack.masks[li, i0:i1, j0:j1] |= disc + + +def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None: + """Drill-mouth treatment, area-weighted per cell (4x4 supersampling): + 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. 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: + continue + r = via.drill_nm / 2.0 + j0 = max(0, math.floor((via.x - r - stack.x0_nm) / h)) + j1 = min(nx, math.floor((via.x + r - stack.x0_nm) / h) + 1) + i0 = max(0, math.floor((via.y - r - stack.y0_nm) / h)) + i1 = min(ny, math.floor((via.y + r - stack.y0_nm) / h) + 1) + if i0 >= i1 or j0 >= j1: + continue + xs = stack.x0_nm + (np.arange(j0, j1)[:, None] + sub[None, :]) * h \ + - via.x + ys = stack.y0_nm + (np.arange(i0, i1)[:, None] + sub[None, :]) * h \ + - via.y + cov = ((ys[:, None, :, None] ** 2 + xs[None, :, None, :] ** 2) + <= r * r).mean(axis=(2, 3)) + if not (cov > 0).any(): + continue # mouth far smaller than h + 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: + ratio = min(problem.cap_plating_nm + / problem.layers[li].thickness_nm, 1.0) + else: + ratio = 0.0 + s = 1.0 - cov * (1.0 - ratio) + gone = s <= 1e-9 + stack.masks[li, i0:i1, j0:j1] &= ~gone + stack.thick_scale[li, i0:i1, j0:j1] *= np.where(gone, 1.0, s) + + def _build_chains(stack: RasterStack, problem: Problem, narrow: list) -> int: """Sub-resolution traces as 1D resistor chains: mark the cells their diff --git a/fill_resistance/solver.py b/fill_resistance/solver.py index e575975..49ce5ed 100644 --- a/fill_resistance/solver.py +++ b/fill_resistance/solver.py @@ -117,12 +117,20 @@ def _shifts2d(): def _sigma_2d(stack: RasterStack, li: int, sigma_layer: float, sigma_buildup: float) -> np.ndarray | None: - """Per-cell sheet conductance for one layer, or None if uniform.""" - if stack.buildup is None or sigma_buildup <= 0 \ - or not stack.buildup[li].any(): + """Per-cell sheet conductance for one layer, or None if uniform. + Combines the via-mouth thickness map (cap-thin / partially drilled + cells) with the solder-buildup addition.""" + have_b = (stack.buildup is not None and sigma_buildup > 0 + and stack.buildup[li].any()) + have_t = (stack.thick_scale is not None + and bool((stack.thick_scale[li] != 1.0).any())) + if not have_b and not have_t: return None s = np.full(stack.shape2d, sigma_layer) - s[stack.buildup[li]] += sigma_buildup + if have_t: + s *= stack.thick_scale[li] + if have_b: + s[stack.buildup[li]] += sigma_buildup return s diff --git a/fill_resistance/standalone.py b/fill_resistance/standalone.py index 47b1f89..f27c13d 100644 --- a/fill_resistance/standalone.py +++ b/fill_resistance/standalone.py @@ -39,6 +39,9 @@ def main(argv=None) -> int: default=None, help="contact model (default: config)") ap.add_argument("--strip-buildup", action="store_true", help="ignore solder buildup stored in the dump") + ap.add_argument("--uncapped", action="store_true", + help="treat vias as uncapped (open drill mouths on " + "all layers)") ap.add_argument("--extra-cu-um", type=float, default=None, help="override the added copper in mask openings [um]") ap.add_argument("--force-iterative", action="store_true", @@ -56,6 +59,8 @@ def main(argv=None) -> int: problem = load_problem(args.dump) if args.strip_buildup: problem.buildups = [] + if args.uncapped: + problem.vias_capped = False if args.extra_cu_um is not None: problem.extra_cu_nm = int(args.extra_cu_um * 1000) if args.layers: diff --git a/tests/test_capping.py b/tests/test_capping.py new file mode 100644 index 0000000..d3ada8f --- /dev/null +++ b/tests/test_capping.py @@ -0,0 +1,103 @@ +"""Via ring-copper and drill-mouth (capping) tests. THT pads (kind +'pad') skip both, which doubles as the feature-off reference.""" +import numpy as np +import pytest + +from fill_resistance import raster, solver +from fill_resistance.errors import ConnectivityError +from tests.util import NM, make_multilayer + + +def _two_layer(width_mm=5.0, drill_mm=0.3, pad_mm=0.6, kind="via", + capped=True, cap_um=15.0, hole_mm=None): + """10 x width strip on both (outer-named) layers, e1 left on F.Cu, + e2 right on B.Cu, one via mid-strip. Optionally a circular hole in + the F.Cu fill around the via (ring-bridging scenario).""" + y = width_mm / 2 + strip = [(0, 0), (10, 0), (10, width_mm), (0, width_mm)] + holes = [] + if hole_mm is not None: + ang = np.linspace(0, 2 * np.pi, 64, endpoint=False) + holes = [[(5 + hole_mm * np.cos(a), y + hole_mm * np.sin(a)) + for a in ang]] + p = make_multilayer( + [[(strip, holes)], [(strip, [])]], + rect1_mm=(0, 0, 1, width_mm), rect2_mm=(9, 0, 10, width_mm), + contact1="F.Cu", contact2="B.Cu", + vias_mm=[(5, y)], gap_mm=1.0, drill_mm=drill_mm) + p.layers[0].layer_name = "F.Cu" + p.layers[1].layer_name = "B.Cu" + p.vias[0].kind = kind + p.vias[0].pad_nm = int(pad_mm * NM) + p.vias_capped = capped + p.cap_plating_nm = int(cap_um * 1000) + return p + + +def _solve(problem, h_mm): + stack = raster.rasterize_stack(problem, h_mm * NM) + e1, e2 = raster.electrode_masks(stack, problem) + return solver.run_solve(problem, stack, e1, e2, 1.0, + contact_model="equipotential"), stack + + +def test_cap_at_foil_thickness_is_identity(): + """cap thickness == foil thickness makes every mouth scale exactly 1, + 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) + r_ref, _ = _solve(_two_layer(kind="pad"), 0.1) + assert r_cap.R_ohm == pytest.approx(r_ref.R_ohm, rel=1e-9) + + +def test_mouth_ordering_solid_capped_uncapped(): + """A large mouth in the current path: R(solid) < R(15 um cap) < + R(open hole), and the barrel stays connected through the ring.""" + kw = dict(drill_mm=2.0, pad_mm=2.6) + r_solid, _ = _solve(_two_layer(capped=True, cap_um=70.0, **kw), 0.25) + r_cap, s_cap = _solve(_two_layer(capped=True, cap_um=15.0, **kw), 0.25) + r_open, s_open = _solve(_two_layer(capped=False, **kw), 0.25) + assert r_solid.R_ohm < r_cap.R_ohm < r_open.R_ohm + assert s_cap.thick_scale is not None + # open mouths remove the fully covered cells from the copper + assert int(s_open.masks.sum()) < int(s_cap.masks.sum()) + assert r_open.power_balance_rel < 1e-9 + + +def test_ring_bridges_fill_gap(): + """F.Cu fill has a 1 mm-radius hole around the via; the 2.4 mm pad + ring bridges it. A 'pad'-kind barrel (no ring) stays disconnected.""" + p = _two_layer(drill_mm=0.3, pad_mm=2.4, hole_mm=1.0) + res, _ = _solve(p, 0.2) + assert np.isfinite(res.R_ohm) and res.R_ohm > 0 + assert len(res.via_reports) == 1 + + bare = _two_layer(drill_mm=0.3, pad_mm=0.0, kind="pad", hole_mm=1.0) + stack = raster.rasterize_stack(bare, 0.2 * NM) + e1, e2 = raster.electrode_masks(stack, bare) + with pytest.raises(ConnectivityError): + solver.run_solve(bare, stack, e1, e2, 1.0, + contact_model="equipotential") + + +def test_subcell_mouth_perturbs_gently(): + """A 0.3 mm mouth at 0.5 mm cells must not knock out whole cells: + the area-weighted scaling changes R only slightly.""" + r_solid, _ = _solve(_two_layer(capped=True, cap_um=70.0), 0.5) + r_open, s = _solve(_two_layer(capped=False), 0.5) + assert int(s.masks.sum()) == int(_solve( + _two_layer(capped=True, cap_um=70.0), 0.5)[1].masks.sum()) + assert r_solid.R_ohm <= r_open.R_ohm <= 1.05 * r_solid.R_ohm + + +def test_capping_json_roundtrip(tmp_path): + from fill_resistance.geometry import load_problem, save_problem + p = _two_layer(capped=False, cap_um=12.0) + f = tmp_path / "d.json" + save_problem(p, f) + q = load_problem(f) + assert q.vias_capped is False + assert q.cap_plating_nm == 12_000 + r_p, _ = _solve(p, 0.25) + r_q, _ = _solve(q, 0.25) + assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12)