Model via ring copper and drill mouths (capping), dialog-toggleable

Each via now contributes its ring/pad copper (full-thickness disc of
the pad diameter on every spanned layer) and its drill mouth: with
"vias filled + capped" (dialog checkbox, default on, VIAS_CAPPED) the
mouth carries a CAP_PLATING_UM (15 um) thin copper cap on the outer
layers and is an open hole on inner layers; unchecked, mouths are open
everywhere. Mouth coverage is area-weighted per cell (4x4
supersampling) through a per-cell thickness map feeding the existing
harmonic-mean face machinery, so sub-cell mouths perturb the sheet by
their true covered fraction instead of whole cells. Fully swallowed
cells leave the mask; the barrel then attaches through the ring via the
existing pad-footprint search. THT-pad copper and drills stay outside
the model. Ring discs paint before 1D trace chains (chains see them as
regular copper), mouths after wide tracks (drills go through trace
copper). standalone gains --uncapped.

Tests: cap==foil identity against the feature-off reference, strict
R(solid) < R(cap) < R(hole) ordering, ring bridging a fill gap that a
ringless barrel cannot cross, gentle sub-cell perturbation at coarse
grids, and JSON roundtrip of the new fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-15 16:42:21 +07:00
parent d183bc5fd7
commit d34d3ade51
10 changed files with 248 additions and 26 deletions
+103
View File
@@ -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)