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:
@@ -70,18 +70,21 @@ SWIG API. Requires KiCad **10.0.1+**.
|
|||||||
barrel lengths.
|
barrel lengths.
|
||||||
- Via/pad barrels: thin-wall annulus, R = ρ·L/(π·d·t_plating),
|
- Via/pad barrels: thin-wall annulus, R = ρ·L/(π·d·t_plating),
|
||||||
`VIA_PLATING_UM = 18` in `fill_resistance/config.py`. Vias are always
|
`VIA_PLATING_UM = 18` in `fill_resistance/config.py`. Vias are always
|
||||||
plated. **Via capping is not modeled.** Layer-to-layer it cannot
|
plated. Each via also contributes its **ring/pad copper** (a
|
||||||
matter at DC: the cap (≥15 µm plating per fab spec, thinner than the
|
full-thickness disc of the pad diameter on every spanned layer) and
|
||||||
foil) sits over the hole mouth in parallel with the annular-ring
|
its **drill mouth**, area-weighted per cell: with the **"vias filled +
|
||||||
contact, not in series. In-plane, the model treats every via mouth as
|
capped" checkbox** (default on, `VIAS_CAPPED`) the mouth carries a
|
||||||
solid layer-thickness copper (KiCad fills do not subtract the drill),
|
thin copper cap (`CAP_PLATING_UM = 15`, fab spec) on the **outer**
|
||||||
where reality is a hole (uncapped) or the thin cap. The error is
|
layers and is an open hole on inner layers; unchecked, mouths are open
|
||||||
bounded by the dilute-hole correction ρ_eff ≈ ρ·(1 + 2f) inside via
|
holes everywhere. Layer-to-layer the cap never matters at DC (it is in
|
||||||
fields (f = mouth area fraction; 0.3 mm drills on a 1 mm grid give
|
parallel with the annular-ring contact, not in series) — the checkbox
|
||||||
f ≈ 7 % → ≈ +14 % locally), is partially offset by the unmodeled
|
only affects in-plane conduction across outer-layer mouths. Sub-cell
|
||||||
annular-ring copper, and largely vanishes where the current descends
|
mouths scale their cells' sheet conductance by the true covered
|
||||||
into the barrels anyway — typically ≪ a few % of the total R.
|
fraction (4×4 supersampling), so coarse grids see the correct small
|
||||||
Per layer a barrel attaches to
|
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
|
the fill cell under it, or to the nearest copper cell within the pad
|
||||||
footprint plus one grid cell — fills joined by **thermal-relief
|
footprint plus one grid cell — fills joined by **thermal-relief
|
||||||
spokes** still connect; wider antipads do not, and the barrel bridges
|
spokes** still connect; wider antipads do not, and the barrel bridges
|
||||||
|
|||||||
@@ -462,7 +462,8 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
stackup: StackupInfo, fills: dict,
|
stackup: StackupInfo, fills: dict,
|
||||||
buildups: dict[str, list[Polygon]] | None = None,
|
buildups: dict[str, list[Polygon]] | None = None,
|
||||||
extra_cu_um: float | 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 = fills.get(net, {})
|
||||||
per_layer_tracks = (tracks or {}).get(net, {})
|
per_layer_tracks = (tracks or {}).get(net, {})
|
||||||
layers = []
|
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
|
extra_cu_nm=int((extra_cu_um if extra_cu_um is not None
|
||||||
else config.BUILDUP_EXTRA_CU_UM) * 1000),
|
else config.BUILDUP_EXTRA_CU_UM) * 1000),
|
||||||
tracks=segs,
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
COPPER_THICKNESS_UM: float | None = None # None -> stackup, fallback 35.0 with warning
|
||||||
FALLBACK_THICKNESS_UM = 35.0
|
FALLBACK_THICKNESS_UM = 35.0
|
||||||
TEST_CURRENT_A = 1.0 # default injected current (dialog/CLI-selectable)
|
TEST_CURRENT_A = 1.0 # default injected current (dialog/CLI-selectable)
|
||||||
VIA_PLATING_UM = 18.0 # barrel plating thickness (always plated).
|
VIA_PLATING_UM = 18.0 # barrel plating thickness (always plated)
|
||||||
# Via capping is NOT modeled: layer-to-layer
|
VIAS_CAPPED = True # filled + capped vias (dialog checkbox):
|
||||||
# the cap (>=15um per fab spec, thinner than
|
# outer-layer mouths carry a CAP_PLATING_UM
|
||||||
# the foil) is parallel to the annular-ring
|
# thin copper cap, inner-layer mouths are
|
||||||
# contact, not in series; in-plane every via
|
# holes. False = open mouths on all layers.
|
||||||
# mouth is treated as solid layer-thickness
|
# Ring/pad copper of vias is modeled either
|
||||||
# copper (error bound: see README).
|
# 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
|
INCLUDE_TH_PADS = True # plated through-hole pads stitch layers too
|
||||||
SKIN_SIDES = 1 # skin-effect field config: 1 = plane facing a
|
SKIN_SIDES = 1 # skin-effect field config: 1 = plane facing a
|
||||||
# return plane (conservative), 2 = isolated foil
|
# return plane (conservative), 2 = isolated foil
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class Selection:
|
|||||||
include_buildup: bool = False
|
include_buildup: bool = False
|
||||||
extra_cu_um: float = 0.0
|
extra_cu_um: float = 0.0
|
||||||
include_tracks: bool = True
|
include_tracks: bool = True
|
||||||
|
vias_capped: bool = True
|
||||||
|
|
||||||
|
|
||||||
class _Dialog(QDialog):
|
class _Dialog(QDialog):
|
||||||
@@ -65,6 +66,12 @@ class _Dialog(QDialog):
|
|||||||
self.tracks_check.setChecked(config.INCLUDE_TRACKS)
|
self.tracks_check.setChecked(config.INCLUDE_TRACKS)
|
||||||
form.addRow("Conductors:", self.tracks_check)
|
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.contact1_box = QComboBox()
|
||||||
self.contact2_box = QComboBox()
|
self.contact2_box = QComboBox()
|
||||||
form.addRow(f"V+ ({e1_label}):", self.contact1_box)
|
form.addRow(f"V+ ({e1_label}):", self.contact1_box)
|
||||||
@@ -206,7 +213,8 @@ class _Dialog(QDialog):
|
|||||||
contact_model=self.model_box.currentData(),
|
contact_model=self.model_box.currentData(),
|
||||||
include_buildup=self.buildup_check.isChecked(),
|
include_buildup=self.buildup_check.isChecked(),
|
||||||
extra_cu_um=extra_cu,
|
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:
|
def _try_accept(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ class Problem:
|
|||||||
solder_rho_ohm_m: float = 1.32e-7
|
solder_rho_ohm_m: float = 1.32e-7
|
||||||
extra_cu_nm: int = 0
|
extra_cu_nm: int = 0
|
||||||
tracks: list[TrackSeg] = field(default_factory=list)
|
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
|
@property
|
||||||
def layer_names(self) -> list[str]:
|
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_thickness_nm": p.solder_thickness_nm,
|
||||||
"solder_rho_ohm_m": p.solder_rho_ohm_m,
|
"solder_rho_ohm_m": p.solder_rho_ohm_m,
|
||||||
"extra_cu_nm": p.extra_cu_nm,
|
"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"]))
|
width_nm=int(td["width_nm"]))
|
||||||
for td in d.get("tracks", []) # <= v4: baked into polygons
|
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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ def main() -> None:
|
|||||||
fills,
|
fills,
|
||||||
buildups=(buildups if selection.include_buildup else None),
|
buildups=(buildups if selection.include_buildup else None),
|
||||||
extra_cu_um=selection.extra_cu_um,
|
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))
|
outdir = report.make_output_dir(board_io.board_dir(board))
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
raise UserFacingError(f"KiCad API error: {e}")
|
raise UserFacingError(f"KiCad API error: {e}")
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ class RasterStack:
|
|||||||
# copper only through a 1D trace chain
|
# copper only through a 1D trace chain
|
||||||
chain_edges: tuple | None = None # (a, b, g_dc, layer) arrays: explicit
|
chain_edges: tuple | None = None # (a, b, g_dc, layer) arrays: explicit
|
||||||
# DC conductances of the chain links
|
# 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
|
@property
|
||||||
def nlayers(self) -> int:
|
def nlayers(self) -> int:
|
||||||
@@ -182,6 +186,10 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
|
|||||||
# directly, skipping the full-frame temp
|
# directly, skipping the full-frame temp
|
||||||
_paint_ring(stack, poly.outline, True, stack.masks[li])
|
_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
|
# traces: wide ones are rasterized from their outline, sub-resolution
|
||||||
# ones become exact 1D resistor chains along their centerline
|
# ones become exact 1D resistor chains along their centerline
|
||||||
index = {name: li for li, name in enumerate(stack.layer_names)}
|
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"{config.TRACK_1D_FACTOR:g} cells modeled as 1D resistor "
|
||||||
f"chains ({n_links} links)")
|
f"chains ({n_links} links)")
|
||||||
|
|
||||||
|
_apply_via_mouths(stack, problem)
|
||||||
|
|
||||||
if problem.buildups:
|
if problem.buildups:
|
||||||
stack.buildup = np.zeros_like(stack.masks)
|
stack.buildup = np.zeros_like(stack.masks)
|
||||||
index = {name: li for li, name in enumerate(stack.layer_names)}
|
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
|
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,
|
def _build_chains(stack: RasterStack, problem: Problem,
|
||||||
narrow: list) -> int:
|
narrow: list) -> int:
|
||||||
"""Sub-resolution traces as 1D resistor chains: mark the cells their
|
"""Sub-resolution traces as 1D resistor chains: mark the cells their
|
||||||
|
|||||||
@@ -117,11 +117,19 @@ def _shifts2d():
|
|||||||
|
|
||||||
def _sigma_2d(stack: RasterStack, li: int, sigma_layer: float,
|
def _sigma_2d(stack: RasterStack, li: int, sigma_layer: float,
|
||||||
sigma_buildup: float) -> np.ndarray | None:
|
sigma_buildup: float) -> np.ndarray | None:
|
||||||
"""Per-cell sheet conductance for one layer, or None if uniform."""
|
"""Per-cell sheet conductance for one layer, or None if uniform.
|
||||||
if stack.buildup is None or sigma_buildup <= 0 \
|
Combines the via-mouth thickness map (cap-thin / partially drilled
|
||||||
or not stack.buildup[li].any():
|
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
|
return None
|
||||||
s = np.full(stack.shape2d, sigma_layer)
|
s = np.full(stack.shape2d, sigma_layer)
|
||||||
|
if have_t:
|
||||||
|
s *= stack.thick_scale[li]
|
||||||
|
if have_b:
|
||||||
s[stack.buildup[li]] += sigma_buildup
|
s[stack.buildup[li]] += sigma_buildup
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ def main(argv=None) -> int:
|
|||||||
default=None, help="contact model (default: config)")
|
default=None, help="contact model (default: config)")
|
||||||
ap.add_argument("--strip-buildup", action="store_true",
|
ap.add_argument("--strip-buildup", action="store_true",
|
||||||
help="ignore solder buildup stored in the dump")
|
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,
|
ap.add_argument("--extra-cu-um", type=float, default=None,
|
||||||
help="override the added copper in mask openings [um]")
|
help="override the added copper in mask openings [um]")
|
||||||
ap.add_argument("--force-iterative", action="store_true",
|
ap.add_argument("--force-iterative", action="store_true",
|
||||||
@@ -56,6 +59,8 @@ def main(argv=None) -> int:
|
|||||||
problem = load_problem(args.dump)
|
problem = load_problem(args.dump)
|
||||||
if args.strip_buildup:
|
if args.strip_buildup:
|
||||||
problem.buildups = []
|
problem.buildups = []
|
||||||
|
if args.uncapped:
|
||||||
|
problem.vias_capped = False
|
||||||
if args.extra_cu_um is not None:
|
if args.extra_cu_um is not None:
|
||||||
problem.extra_cu_nm = int(args.extra_cu_um * 1000)
|
problem.extra_cu_nm = int(args.extra_cu_um * 1000)
|
||||||
if args.layers:
|
if args.layers:
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user