06c62e04f8
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>
172 lines
7.4 KiB
Python
172 lines
7.4 KiB
Python
"""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") |