Files
kicad-zone-resistance/tests/test_multilayer.py
T
janik 06c62e04f8 Initial import: KiCad zone resistance plugin
DC/AC resistance, power dissipation, and via/injection-area currents of
copper zone fills. KiCad 10 IPC-API plugin (kicad-python/kipy):
multi-layer via-coupled FDM solver, multi-part terminals via User.1/User.2
marker layers, pads as contacts, uniform-injection and equipotential
contact models, per-foil skin effect, optional solder/copper buildup on
mask openings. 54-case test suite incl. exact analytic references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:22:00 +07:00

149 lines
6.0 KiB
Python

"""Multi-layer / via solver tests. The 1-cell-wide strip cases are pure
series chains, so the discrete solution is exact and validates the via
barrel model, layer coupling, and flux integration to solver precision."""
import math
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.errors import ConnectivityError, ElectrodeError
from tests.util import NM, make_multilayer, sigma_s
def _solve(problem, h_mm, i_test=1.0):
stack = raster.rasterize_stack(problem, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, problem)
return solver.run_solve(problem, stack, e1, e2, i_test,
contact_model="equipotential"), stack
STRIP = [(0, 0), (10, 0), (10, 1), (0, 1)] # 10 x 1 mm; h=1 -> 1 row
def _r_via(length_mm, drill_mm=0.3, plating_um=18.0, rho=1.68e-8):
area = math.pi * (drill_mm * 1e-3) * (plating_um * 1e-6)
return rho * (length_mm * 1e-3) / area
def test_two_identical_layers_parallel():
"""Both electrodes contact both layers, no vias needed: two identical
independent sheets in parallel -> exactly half the single-layer R."""
one = make_multilayer([[(STRIP, [])]], (0, 0, 1, 1), (9, 0, 10, 1))
two = make_multilayer([[(STRIP, [])], [(STRIP, [])]],
(0, 0, 1, 1), (9, 0, 10, 1))
r1, _ = _solve(one, 1.0)
r2, _ = _solve(two, 1.0)
assert r2.R_ohm == pytest.approx(r1.R_ohm / 2, rel=1e-9)
def test_via_chain_1d_exact():
"""e1 on L0 left end, e2 on L1 right end, one through-via at x=5.5:
R = faces_L0/sigma + R_via + faces_L1/sigma, exact."""
p = make_multilayer(
[[(STRIP, [])], [(STRIP, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1",
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
res, stack = _solve(p, 1.0)
# cells at x centers 0.5..9.5 -> cols 0..9 (plus margins); electrode
# cells: col of 0.5 (e1), col of 9.5 (e2); via cell: col of 5.5
sig = sigma_s()
faces_l0 = 5 # cols 0->5: faces between 0|1 .. 4|5
faces_l1 = 4 # cols 5->9
r_exact = (faces_l0 + faces_l1) / sig + _r_via(1.0)
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
assert res.mismatch_rel < 1e-10
assert len(res.via_reports) == 1
# the single via carries the full test current
assert res.via_reports[0].current_a == pytest.approx(1.0, rel=1e-9)
assert res.via_reports[0].power_w == pytest.approx(_r_via(1.0), rel=1e-9)
def test_parallel_vias_halve_barrel_resistance():
base = dict(rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1", gap_mm=1.0)
one = make_multilayer([[(STRIP, [])], [(STRIP, [])]],
vias_mm=[(5.5, 0.5)], **base)
two = make_multilayer([[(STRIP, [])], [(STRIP, [])]],
vias_mm=[(5.5, 0.5), (5.5, 0.5)], **base)
r_one, _ = _solve(one, 1.0)
r_two, _ = _solve(two, 1.0)
assert (r_one.R_ohm - r_two.R_ohm) == pytest.approx(_r_via(1.0) / 2,
rel=1e-9)
def test_antipad_bridging():
"""3 layers; the middle layer has an antipad hole at the via cell, so
the barrel bridges L0 -> L2 directly with DOUBLE the length."""
mid_with_hole = [(STRIP, [[(5, 0), (6, 0), (6, 1), (5, 1)]])]
p = make_multilayer(
[[(STRIP, [])], mid_with_hole, [(STRIP, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L2",
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
res, _ = _solve(p, 1.0)
sig = sigma_s()
r_exact = (5 + 4) / sig + _r_via(2.0) # barrel length 2 mm
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
def test_via_short_between_electrodes_raises():
"""Both electrodes over the SAME cell on different layers with a via
there = direct short, no free copper -> error."""
p = make_multilayer(
[[(STRIP, [])], [(STRIP, [])]],
rect1_mm=(5, 0, 6, 1), rect2_mm=(5, 0, 6, 1),
contact1="L0", contact2="L1",
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
stack = raster.rasterize_stack(p, 1.0 * NM)
e1, e2 = raster.electrode_masks(stack, p)
with pytest.raises(ElectrodeError, match="directly connected"):
solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
def test_layers_without_via_disconnected():
p = make_multilayer(
[[(STRIP, [])], [(STRIP, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1", gap_mm=1.0) # no vias
stack = raster.rasterize_stack(p, 1.0 * NM)
e1, e2 = raster.electrode_masks(stack, p)
with pytest.raises(ConnectivityError, match="not connected"):
solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
def test_power_split_layers_and_vias():
"""Power accounting: layer + via powers sum to I^2 R."""
p = make_multilayer(
[[(STRIP, [])], [(STRIP, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1",
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
res, _ = _solve(p, 1.0, i_test=10.0)
assert res.power_balance_rel < 1e-9
assert res.P_vias == pytest.approx(100 * _r_via(1.0), rel=1e-9)
sig = sigma_s()
assert res.P_layers[0] == pytest.approx(100 * 5 / sig, rel=1e-9)
assert res.P_layers[1] == pytest.approx(100 * 4 / sig, rel=1e-9)
def test_pad_polygon_electrode():
"""A polygon-shaped electrode (pad) restricted to one layer."""
from fill_resistance.geometry import Electrode, Polygon
from tests.util import rect_mm, ring_mm
p = make_multilayer([[(STRIP, [])]], (0, 0, 1, 1), (9, 0, 10, 1))
# replace terminal 1 with a small polygon pad covering the same cell
p.electrodes1 = [Electrode(
rect=rect_mm((0.2, 0.2, 0.8, 0.8)),
contact="all",
polygons=[Polygon(outline=ring_mm(
[(0.2, 0.2), (0.8, 0.2), (0.8, 0.8), (0.2, 0.8)]))],
label="pad TP1.1",
)]
res, _ = _solve(p, 1.0)
sig = sigma_s()
assert res.R_ohm == pytest.approx(9 / sig, rel=1e-9) # cols 0..9