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
+5 -1
View File
@@ -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),
)
+8 -7
View File
@@ -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
+9 -1
View File
@@ -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:
+7
View File
@@ -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)),
)
+2 -1
View File
@@ -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}")
+82
View File
@@ -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
+12 -4
View File
@@ -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
+5
View File
@@ -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: