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>
This commit is contained in:
janik
2026-07-14 17:22:00 +07:00
commit 06c62e04f8
33 changed files with 4000 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
"""Contact-model (uniform vs equipotential) and multi-part terminal tests."""
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.errors import ElectrodeError
from fill_resistance.geometry import Electrode
from tests.util import NM, make_problem, rect_mm, sigma_s, strip_problem
def _solve(problem, h_mm, model, i_test=1.0):
stack = raster.rasterize_stack(problem, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, problem)
parts1, parts2 = raster.electrode_partition(stack, problem)
return solver.run_solve(problem, stack, e1, e2, i_test,
contact_model=model,
parts1=parts1, parts2=parts2), stack
def _uniform_1d_reference(m_cols, n1, n2, sig, rows):
"""Independent 1D reference: uniform injection over the first n1
columns, extraction over the last n2, unit total current. R from
mean potentials, per row conductance sig, `rows` parallel rows."""
inj = np.zeros(m_cols)
inj[:n1] += 1.0 / n1
inj[m_cols - n2:] -= 1.0 / n2
face_current = np.cumsum(inj)[:-1] # current through face k,k+1
v = np.zeros(m_cols)
v[1:] = -np.cumsum(face_current) / sig # per single row of cells
v_plus = v[:n1].mean()
v_minus = v[m_cols - n2:].mean()
return (v_plus - v_minus) / 1.0 / rows # rows in parallel
def test_uniform_strip_exact_1d():
"""Full-width contacts on a uniform strip: rows are identical 1D
chains; compare with an independent 1D computation, exact."""
p = strip_problem(length=50, width=10, e_len=5)
res, _ = _solve(p, 0.5, "uniform")
sig = sigma_s()
r_ref = _uniform_1d_reference(m_cols=100, n1=10, n2=10, sig=sig, rows=20)
assert res.R_ohm == pytest.approx(r_ref, rel=1e-9)
assert res.contact_model == "uniform"
assert res.power_balance_rel < 1e-9 # P = b^T V = I^2 R identity
def test_uniform_higher_than_equipotential():
p = strip_problem(length=50, width=10, e_len=5)
r_uni, _ = _solve(p, 0.5, "uniform")
r_equ, _ = _solve(p, 0.5, "equipotential")
assert r_uni.R_ohm > r_equ.R_ohm
def test_uniform_current_density_ramps_inside_contact():
"""Inside the V+ contact, |J| must ramp: ~0 at the outer edge,
~full sheet current at the inner (leading) edge; the equipotential
model shows ~0 throughout the contact interior."""
p = strip_problem(length=50, width=10, e_len=5)
res_u, stack = _solve(p, 0.5, "uniform")
ny, nx = stack.shape2d
row = ny // 2
# contact columns are the first 10 copper columns (margin = 2)
j_outer = res_u.Jmag[0, row, 2] # first contact column
j_inner = res_u.Jmag[0, row, 11] # last contact column
j_free = res_u.Jmag[0, row, nx // 2] # mid strip = I/(W t)
assert j_inner > 0.8 * j_free # ramped up to ~full
assert j_outer < 0.2 * j_free # near zero at outer edge
assert j_inner > 5 * max(j_outer, 1e-30)
res_e, _ = _solve(p, 0.5, "equipotential")
j_center_e = res_e.Jmag[0, row, 6] # deep inside Dirichlet region
assert j_center_e < 0.05 * j_free
def test_multipart_terminal_equals_single_rect():
"""V+ split into two half-height rectangles == one full rectangle,
for both contact models (exact)."""
whole = strip_problem(length=50, width=10, e_len=5)
split = strip_problem(length=50, width=10, e_len=5)
split.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 5, 5))),
Electrode(rect=rect_mm((0, 5, 5, 10))),
]
for model in ("uniform", "equipotential"):
r_whole, _ = _solve(whole, 0.5, model)
r_split, _ = _solve(split, 0.5, model)
assert r_split.R_ohm == pytest.approx(r_whole.R_ohm, rel=1e-9), model
def test_multipart_asymmetric_parts():
"""Two separated V+ parts feeding one V-: sane R, balance holds."""
p = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 3), rect2_mm=(45, 0, 50, 10))
p.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 2, 3)), label="top lug"),
Electrode(rect=rect_mm((0, 7, 2, 10)), label="bottom lug"),
]
single, _ = _solve(
make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 3), rect2_mm=(45, 0, 50, 10)),
0.25, "uniform")
multi, _ = _solve(p, 0.25, "uniform")
assert multi.R_ohm < single.R_ohm # more contact area helps
assert multi.power_balance_rel < 1e-9
def test_part_off_copper_raises_with_label():
p = strip_problem(length=50, width=10, e_len=5)
p.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 5, 10))),
Electrode(rect=rect_mm((100, 100, 105, 105)), label="stray part"),
]
stack = raster.rasterize_stack(p, 0.5 * NM)
with pytest.raises(ElectrodeError, match="stray part"):
raster.electrode_masks(stack, p)
def test_injection_area_currents_equipotential_flux():
"""Two V+ lugs at different distances: the nearer one carries more;
the flux split sums exactly to the test current."""
p = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 10), rect2_mm=(48, 0, 50, 10))
p.electrodes1 = [
Electrode(rect=rect_mm((0, 4, 2, 6)), label="far lug"),
Electrode(rect=rect_mm((10, 4, 12, 6)), label="near lug"),
]
res, _ = _solve(p, 0.25, "equipotential", i_test=10.0)
pc = dict(res.part_currents1)
assert pc["near lug"] > pc["far lug"]
assert pc["near lug"] + pc["far lug"] == pytest.approx(10.0, rel=1e-9)
# V- side: single part carries everything
assert res.part_currents2[0][1] == pytest.approx(10.0, rel=1e-9)
def test_injection_area_currents_uniform_area_share():
"""Uniform model: each injection area carries exactly its cell share."""
p = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 10), rect2_mm=(48, 0, 50, 10))
p.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 2, 6)), label="big"), # 2x6 mm
Electrode(rect=rect_mm((0, 6, 2, 9)), label="small"), # 2x3 mm
]
res, _ = _solve(p, 0.25, "uniform", i_test=9.0)
pc = dict(res.part_currents1)
# cell counts: 8x24 = 192 and 8x12 = 96 at h=0.25 -> shares 2/3, 1/3
assert pc["big"] == pytest.approx(6.0, rel=1e-12)
assert pc["small"] == pytest.approx(3.0, rel=1e-12)
def test_injection_area_partition_first_wins():
"""Overlapping parts: shared cells attributed to the first part, so
the shares still sum to the terminal current."""
p = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 10), rect2_mm=(48, 0, 50, 10))
p.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 2, 6)), label="first"),
Electrode(rect=rect_mm((0, 4, 2, 10)), label="second"), # overlaps
]
res, _ = _solve(p, 0.25, "uniform", i_test=1.0)
total = sum(a for _, a in res.part_currents1)
assert total == pytest.approx(1.0, rel=1e-12)
def test_touching_ok_uniform_error_equipotential():
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(5, 0, 10, 10))
res, _ = _solve(p, 0.5, "uniform") # touching is fine here
assert res.R_ohm > 0
p2 = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(5, 0, 10, 10))
with pytest.raises(ElectrodeError, match="touch"):
_solve(p2, 0.5, "equipotential")