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
View File
+106
View File
@@ -0,0 +1,106 @@
"""Solder/mask-opening buildup tests. Uniform-coverage and split-coverage
strips are 1D-exact, validating the harmonic-mean face weights and the
parallel-sheet conductance model to solver precision."""
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.geometry import (Polygon, SurfaceBuildup, load_problem,
save_problem)
from tests.util import NM, make_problem, ring_mm, sigma_s, strip_problem
RHO_CU = 1.68e-8
RHO_SN = 1.32e-7
def _with_buildup(p, polys_mm, layer="F.Cu", solder_um=50.0, extra_um=0.0):
p.buildups = [SurfaceBuildup(
layer_name=layer,
polygons=[Polygon(outline=ring_mm(pts)) for pts in polys_mm])]
p.solder_thickness_nm = int(solder_um * 1000)
p.solder_rho_ohm_m = RHO_SN
p.extra_cu_nm = int(extra_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 _sigma_buildup(solder_um=50.0, extra_um=0.0):
return solder_um * 1e-6 / RHO_SN + extra_um * 1e-6 / RHO_CU
def test_full_coverage_exact():
"""Buildup over the whole strip: uniform parallel sheet, exact."""
plain = strip_problem(length=50, width=10, e_len=5)
covered = _with_buildup(strip_problem(length=50, width=10, e_len=5),
[[(0, 0), (50, 0), (50, 10), (0, 10)]])
r0, _ = _solve(plain, 0.5)
r1, stack = _solve(covered, 0.5)
sig = sigma_s() + _sigma_buildup()
assert r1.R_ohm == pytest.approx(81 / 20 / sig, rel=1e-9)
assert r1.R_ohm < r0.R_ohm
assert stack.buildup is not None and stack.buildup.any()
def test_half_coverage_series_exact():
"""Buildup over the right half: plain faces + one harmonic-mean
interface face + buildup faces in series, exact."""
p = _with_buildup(strip_problem(length=50, width=10, e_len=5),
[[(25, 0), (50, 0), (50, 10), (25, 10)]])
res, _ = _solve(p, 0.5)
s1 = sigma_s()
s2 = s1 + _sigma_buildup()
r_row = 40 / s1 + (s1 + s2) / (2 * s1 * s2) + 40 / s2
assert res.R_ohm == pytest.approx(r_row / 20, rel=1e-9)
def test_buildup_only_over_hole_is_inert():
"""Solder wets copper only: an opening over a hole changes nothing."""
holed = [([(0, 0), (50, 0), (50, 10), (0, 10)],
[[(20, 2), (30, 2), (30, 8), (20, 8)]])]
plain = make_problem(holed, rect1_mm=(0, 0, 5, 10),
rect2_mm=(45, 0, 50, 10))
masked = _with_buildup(
make_problem(holed, rect1_mm=(0, 0, 5, 10),
rect2_mm=(45, 0, 50, 10)),
[[(21, 3), (29, 3), (29, 7), (21, 7)]]) # strictly inside the hole
r0, _ = _solve(plain, 0.5)
r1, _ = _solve(masked, 0.5)
assert r1.R_ohm == pytest.approx(r0.R_ohm, rel=1e-12)
def test_extra_copper_helps_more_than_solder():
base = strip_problem(length=50, width=10, e_len=5)
solder_only = _with_buildup(strip_problem(length=50, width=10, e_len=5),
[[(0, 0), (50, 0), (50, 10), (0, 10)]])
with_cu = _with_buildup(strip_problem(length=50, width=10, e_len=5),
[[(0, 0), (50, 0), (50, 10), (0, 10)]],
extra_um=70.0)
r0, _ = _solve(base, 0.5)
r1, _ = _solve(solder_only, 0.5)
r2, _ = _solve(with_cu, 0.5)
assert r2.R_ohm < r1.R_ohm < r0.R_ohm
# 50 um SAC solder ~ 6.4 um Cu: expect a modest (<15%) improvement
assert r1.R_ohm > 0.85 * r0.R_ohm
# +70 um Cu roughly halves R (2x thickness + solder)
assert r2.R_ohm < 0.55 * r0.R_ohm
def test_json_v4_roundtrip(tmp_path):
p = _with_buildup(strip_problem(), [[(0, 0), (50, 0), (50, 10), (0, 10)]],
extra_um=35.0)
f = tmp_path / "d.json"
save_problem(p, f)
q = load_problem(f)
assert len(q.buildups) == 1 and q.buildups[0].layer_name == "F.Cu"
assert q.solder_thickness_nm == 50_000
assert q.extra_cu_nm == 35_000
assert q.solder_rho_ohm_m == pytest.approx(RHO_SN)
r_p, _ = _solve(p, 0.5)
r_q, _ = _solve(q, 0.5)
assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12)
+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")
+88
View File
@@ -0,0 +1,88 @@
import json
import math
import numpy as np
from fill_resistance.geometry import (arc_points, linearize_ring,
load_problem, problem_from_json,
save_problem)
from tests.util import make_multilayer, strip_problem
def test_arc_points_quarter_circle():
r = 10_000_000 # 10 mm in nm
start = (r, 0)
mid = (int(r / math.sqrt(2)), int(r / math.sqrt(2)))
end = (0, r)
tol = 50_000 # 50 um
pts = arc_points(start, mid, end, tol)
assert len(pts) >= 3
radii = np.hypot(pts[:, 0].astype(float), pts[:, 1].astype(float))
assert np.allclose(radii, r, rtol=1e-6)
allpts = np.vstack([pts, [end]]).astype(float)
for a, b in zip(allpts[:-1], allpts[1:]):
half_chord = np.hypot(*(b - a)) / 2
sagitta = r - math.sqrt(max(r**2 - half_chord**2, 0.0))
assert sagitta <= tol * 1.01
def test_arc_points_collinear_degrades_to_segment():
pts = arc_points((0, 0), (5_000_000, 0), (10_000_000, 0), 1000)
assert len(pts) == 1
assert tuple(pts[0]) == (0, 0)
def test_linearize_ring_mixed_nodes_and_closure():
nodes = [
("pt", (0, 0)),
("pt", (10, 0)),
("arc", ((10, 0), (17, 7), (10, 14))),
("pt", (0, 14)),
("pt", (0, 0)),
]
ring = linearize_ring(nodes, tol_nm=1)
assert (ring[0] != ring[-1]).any()
assert len(ring) > 4
def test_problem_json_roundtrip_v2(tmp_path):
p = make_multilayer(
[[([(0, 0), (10, 0), (10, 1), (0, 1)], [])],
[([(0, 0), (10, 0), (10, 1), (0, 1)], [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1", vias_mm=[(5.5, 0.5)])
f = tmp_path / "dump.json"
save_problem(p, f)
q = load_problem(f)
assert q.layer_names == ["L0", "L1"]
assert q.electrodes1[0].contact == "L0"
assert q.electrodes2[0].contact == "L1"
assert len(q.vias) == 1 and q.vias[0].drill_nm == p.vias[0].drill_nm
assert q.plating_nm == p.plating_nm
assert np.array_equal(q.layers[0].polygons[0].outline,
p.layers[0].polygons[0].outline)
assert abs(q.sigma_s(0) - p.sigma_s(0)) < 1e-12 * p.sigma_s(0)
def test_v1_schema_still_loads():
p = strip_problem()
v1 = {
"schema_version": 1,
"board_path": "old",
"layer_name": "F.Cu",
"net_name": "GND",
"thickness_nm": 70000,
"thickness_source": "stackup",
"rho_ohm_m": 1.68e-8,
"rect1": {"x0": 0, "y0": 0, "x1": 1000, "y1": 1000,
"layer_name": "User.1"},
"rect2": {"x0": 5000, "y0": 0, "x1": 6000, "y1": 1000,
"layer_name": "User.1"},
"polygons": [{"outline": p.layers[0].polygons[0].outline.tolist(),
"holes": []}],
}
q = problem_from_json(json.loads(json.dumps(v1)))
assert q.layer_names == ["F.Cu"]
assert q.vias == []
assert q.electrodes1[0].contact == "all"
assert q.layers[0].thickness_nm == 70000
+148
View File
@@ -0,0 +1,148 @@
"""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
+102
View File
@@ -0,0 +1,102 @@
import numpy as np
import pytest
from fill_resistance import config, raster, solver
from fill_resistance.errors import (ConnectivityError, ElectrodeError,
GridSizeError)
from tests.util import NM, make_problem, strip_problem
def _stack(problem, h_mm):
return raster.rasterize_stack(problem, h_mm * NM)
def test_exact_cell_count_square_with_hole():
# 10x10 mm square, centered 4x4 mm hole, h=1 mm: cell centers at
# half-integers, no boundary ambiguity -> exactly 100 - 16 cells
p = make_problem(
[([(0, 0), (10, 0), (10, 10), (0, 10)],
[[(3, 3), (7, 3), (7, 7), (3, 7)]])],
rect1_mm=(0, 0, 1, 10), rect2_mm=(9, 0, 10, 10))
stack = _stack(p, 1.0)
assert int(stack.masks[0].sum()) == 100 - 16
def test_margin_cells_are_empty():
p = strip_problem()
stack = _stack(p, 0.5)
m = stack.masks[0]
assert not m[0, :].any() and not m[-1, :].any()
assert not m[:, 0].any() and not m[:, -1].any()
def test_electrode_masks_and_counts():
p = strip_problem(length=50, width=10, e_len=5)
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p)
# electrodes: 5 mm / 0.5 mm = 10 columns x 20 rows on 1 layer
assert int(e1.sum()) == 200 and int(e2.sum()) == 200
def test_electrode_off_copper_raises():
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(20, 20, 25, 25), rect2_mm=(8, 0, 10, 10))
stack = _stack(p, 0.5)
with pytest.raises(ElectrodeError, match="does not overlap"):
raster.electrode_masks(stack, p)
def test_touching_electrodes_raise_equipotential():
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(5, 0, 10, 10))
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p) # touch is fine at mask level
with pytest.raises(ElectrodeError, match="touch"):
solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
def test_disconnected_regions_raise():
p = make_problem(
[([(0, 0), (10, 0), (10, 10), (0, 10)], []),
([(20, 0), (30, 0), (30, 10), (20, 10)], [])],
rect1_mm=(0, 0, 2, 10), rect2_mm=(28, 0, 30, 10))
stack = _stack(p, 0.5)
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_islands_dropped():
# island square not touching the main strip disappears from the mask
p = make_problem(
[([(0, 0), (50, 0), (50, 10), (0, 10)], []),
([(20, 20), (30, 20), (30, 30), (20, 30)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(45, 0, 50, 10))
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p)
before = int(stack.masks.sum())
solver.run_solve(p, stack, e1, e2, 1.0, contact_model="equipotential")
after = int(stack.masks.sum())
assert after < before
assert after == 100 * 20 # only the strip remains
def test_hard_max_cells_guard(monkeypatch):
monkeypatch.setattr(config, "CELL_UM_OVERRIDE", 1.0) # 1 um cells
p = strip_problem()
with pytest.raises(GridSizeError, match="M cells"):
raster.choose_cell_size(p.copper_bbox(), len(p.layers))
def test_auto_cell_size_hits_target():
# large plane: unclamped regime, cell count tracks TARGET_CELLS
p = strip_problem(length=200, width=100, e_len=5)
h = raster.choose_cell_size(p.copper_bbox(), 1)
ncells = (200.0 * NM / h) * (100.0 * NM / h)
assert 0.5 * config.TARGET_CELLS < ncells < 2.0 * config.TARGET_CELLS
# small board: MIN_CELL_UM clamp kicks in instead
q = strip_problem() # 50x10 mm
hq = raster.choose_cell_size(q.copper_bbox(), 1)
assert hq == pytest.approx(config.MIN_CELL_UM * 1000)
+103
View File
@@ -0,0 +1,103 @@
"""Skin-effect model tests. The single-layer AC solve scales ALL in-plane
conductances identically, so R_AC = R_DC * resistance_factor EXACTLY -
which turns the analytic foil formula into an end-to-end exact test."""
import math
import numpy as np
import pytest
from fill_resistance import raster, skin, solver
from tests.util import NM, make_multilayer, strip_problem
RHO = 1.68e-8
def _solve(problem, h_mm, i_test=1.0, freq=0.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, freq,
contact_model="equipotential"), stack
def test_skin_depth_value():
# copper @ 1 MHz: ~65-66 um
assert skin.skin_depth_m(1e6, RHO) * 1e6 == pytest.approx(65.2, rel=0.01)
def test_sheet_resistance_dc_limit():
t = 70e-6
assert skin.sheet_resistance_ac(t, 0.0, RHO) == pytest.approx(RHO / t)
# low frequency: within 0.1% of DC
assert skin.sheet_resistance_ac(t, 100.0, RHO) == pytest.approx(
RHO / t, rel=1e-3)
def test_sheet_resistance_high_f_limits():
t = 70e-6
f = 1e9 # delta << t
delta = skin.skin_depth_m(f, RHO)
assert skin.sheet_resistance_ac(t, f, RHO, sides=1) == pytest.approx(
RHO / delta, rel=0.01)
assert skin.sheet_resistance_ac(t, f, RHO, sides=2) == pytest.approx(
RHO / (2 * delta), rel=0.01)
def test_resistance_factor_monotonic():
t = 70e-6
factors = [skin.resistance_factor(t, f, RHO)
for f in (0, 1e4, 1e5, 1e6, 1e7, 1e8)]
assert all(b >= a - 1e-12 for a, b in zip(factors, factors[1:]))
assert factors[0] == 1.0
def test_parse_frequency():
assert skin.parse_frequency("") == 0.0
assert skin.parse_frequency("0") == 0.0
assert skin.parse_frequency("142k") == 142_000.0
assert skin.parse_frequency("1.5M") == 1_500_000.0
assert skin.parse_frequency("2meg") == 2_000_000.0
assert skin.parse_frequency("100000") == 100_000.0
assert skin.parse_frequency("100 kHz") == 100_000.0
assert skin.parse_frequency("junk") == 0.0
def test_single_layer_ac_scales_exactly():
"""Uniform conductance scaling leaves the field shape unchanged:
R_AC = R_DC * factor to solver precision."""
p = strip_problem(length=50, width=10, e_len=5)
f = 2e6 # delta=46um < t=70um
r_dc, _ = _solve(p, 0.5)
r_ac, _ = _solve(p, 0.5, freq=f)
factor = skin.resistance_factor(70e-6, f, RHO, sides=1)
assert factor > 1.2 # real crowding at 2 MHz
assert r_ac.R_ohm == pytest.approx(r_dc.R_ohm * factor, rel=1e-9)
assert r_ac.rs_ratios[0] == pytest.approx(factor, rel=1e-12)
assert r_ac.skin_depth_um == pytest.approx(
skin.skin_depth_m(f, RHO) * 1e6, rel=1e-12)
def test_via_chain_ac_exact():
"""1D chain: layers scale by the foil factor, the barrel by the
plating-wall factor - exact composition."""
STRIP = [(0, 0), (10, 0), (10, 1), (0, 1)]
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)
f = 2e6
sig_ac = 1.0 / skin.sheet_resistance_ac(70e-6, f, RHO, sides=1)
via_factor = skin.resistance_factor(18e-6, f, RHO, sides=2)
r_via_dc = RHO * 1e-3 / (math.pi * 0.3e-3 * 18e-6)
r_exact = (5 + 4) / sig_ac + r_via_dc * via_factor
res, _ = _solve(p, 1.0, freq=f)
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
def test_dc_default_unchanged():
"""freq omitted -> identical to the pre-skin behavior."""
p = strip_problem(length=50, width=10, e_len=5)
res, _ = _solve(p, 0.5)
assert res.freq_hz == 0.0
assert res.skin_depth_um is None
assert all(r == 1.0 for r in res.rs_ratios)
+124
View File
@@ -0,0 +1,124 @@
import numpy as np
import pytest
from fill_resistance import config, raster, solver
from tests.util import NM, make_problem, strip_problem
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
def test_uniform_strip_exact_discrete():
"""Full-width electrodes on a uniform strip: every row is an identical
series chain, so the discrete solution is exact:
R = (n_free_columns + 1) / (n_rows * sigma_s)."""
p = strip_problem(length=50, width=10, e_len=5)
res, stack = _solve(p, 0.5)
n_free_cols = int(round((50 - 2 * 5) / 0.5)) # 80
n_rows = int(round(10 / 0.5)) # 20
r_exact = (n_free_cols + 1) / n_rows / p.sigma_s(0)
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
assert res.mismatch_rel < 1e-10
r_cont = p.rho_ohm_m * 0.0405 / (0.010 * 70e-6)
assert res.R_ohm == pytest.approx(r_cont, rel=1e-6)
def test_strip_R_independent_of_h():
p = strip_problem(length=50, width=10, e_len=5)
for h in (1.0, 0.5, 0.25):
res, _ = _solve(p, h)
m = int(round(40 / h))
rows = int(round(10 / h))
assert res.R_ohm == pytest.approx((m + 1) / rows / p.sigma_s(0),
rel=1e-9)
def test_partial_electrode_constriction():
full = strip_problem(length=50, width=10, e_len=5)
partial = make_problem(
[([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 4, 5, 6),
rect2_mm=(45, 4, 50, 6))
r_full, _ = _solve(full, 0.25)
r_a, _ = _solve(partial, 0.25)
r_b, _ = _solve(partial, 0.125)
assert r_a.R_ohm > r_full.R_ohm * 1.05
assert abs(r_a.R_ohm - r_b.R_ohm) < 0.01 * r_b.R_ohm
def test_l_shape_corner_squares():
"""Right-angle bend of equal-width arms: corner square counts as
~0.559 squares (conformal-mapping result), within 5% at a fine grid."""
w = 10.0
outline = [(0, 0), (30, 0), (30, 30), (20, 30), (20, 10), (0, 10)]
p = make_problem([(outline, [])],
rect1_mm=(0, 0, 2, 10),
rect2_mm=(20, 28, 30, 30))
res, _ = _solve(p, 0.125)
a_sq = (20 - 2) / w
b_sq = (28 - 10) / w
r_expect = (a_sq + b_sq + 0.559) / p.sigma_s(0)
assert res.R_ohm == pytest.approx(r_expect, rel=0.05)
def test_hole_increases_resistance_and_converges():
solid = make_problem([([(0, 0), (40, 0), (40, 20), (0, 20)], [])],
rect1_mm=(0, 0, 2, 20), rect2_mm=(38, 0, 40, 20))
holed = make_problem(
[([(0, 0), (40, 0), (40, 20), (0, 20)],
[[(15, 5), (25, 5), (25, 15), (15, 15)]])],
rect1_mm=(0, 0, 2, 20), rect2_mm=(38, 0, 40, 20))
r_solid, _ = _solve(solid, 0.25)
r_a, _ = _solve(holed, 0.25)
r_b, _ = _solve(holed, 0.125)
assert r_a.R_ohm > r_solid.R_ohm * 1.1
assert abs(r_a.R_ohm - r_b.R_ohm) < 0.01 * r_b.R_ohm
def test_cg_path_matches_direct(monkeypatch):
p = strip_problem(length=50, width=10, e_len=5)
r_direct, _ = _solve(p, 0.25)
monkeypatch.setattr(config, "SPSOLVE_MAX_UNKNOWNS", 0)
r_cg, _ = _solve(p, 0.25)
assert r_cg.solve_info.method == "cg+jacobi"
assert r_cg.R_ohm == pytest.approx(r_direct.R_ohm, rel=1e-6)
assert r_cg.mismatch_rel < 1e-5
def test_current_density_and_potential_scale():
"""Uniform strip at 1 A: |J| in the free region equals I/(W t); the
potential span equals R * I."""
p = strip_problem(length=50, width=10, e_len=5)
res, stack = _solve(p, 0.5)
j_expect = 1.0 / (0.010 * 70e-6)
ny, nx = stack.shape2d
assert res.Jmag[0, ny // 2, nx // 2] == pytest.approx(j_expect, rel=1e-6)
assert np.nanmax(res.V) == pytest.approx(res.R_ohm, rel=1e-9)
def test_test_current_scaling():
"""V and J scale linearly with I_test, power quadratically; R fixed."""
p = strip_problem(length=50, width=10, e_len=5)
r1, _ = _solve(p, 0.5, i_test=1.0)
r10, _ = _solve(p, 0.5, i_test=10.0)
assert r10.R_ohm == pytest.approx(r1.R_ohm, rel=1e-12)
assert np.nanmax(r10.V) == pytest.approx(10 * np.nanmax(r1.V), rel=1e-9)
assert np.nanmax(r10.Jmag) == pytest.approx(10 * np.nanmax(r1.Jmag),
rel=1e-9)
assert r10.P_total == pytest.approx(100 * r1.P_total, rel=1e-9)
def test_power_identity():
"""Sum of edge powers equals I^2 R exactly for the direct solve."""
p = make_problem(
[([(0, 0), (40, 0), (40, 20), (0, 20)],
[[(15, 5), (25, 5), (25, 15), (15, 15)]])],
rect1_mm=(0, 0, 2, 20), rect2_mm=(38, 0, 40, 20))
res, _ = _solve(p, 0.25, i_test=40.0)
assert res.power_balance_rel < 1e-9
assert res.P_total == pytest.approx(40.0 ** 2 * res.R_ohm, rel=1e-12)
assert res.P_vias == 0.0
+74
View File
@@ -0,0 +1,74 @@
"""Synthetic Problem builders for tests. Dimensions in mm here, nm inside."""
import numpy as np
from fill_resistance.geometry import (Electrode, LayerFill, Polygon, Problem,
Rect, ViaLink)
NM = 1_000_000 # nm per mm
def ring_mm(points_mm) -> np.ndarray:
return (np.asarray(points_mm, dtype=float) * NM).astype(np.int64)
def _polys(polygons_mm) -> list[Polygon]:
return [
Polygon(outline=ring_mm(outline), holes=[ring_mm(h) for h in holes])
for outline, holes in polygons_mm
]
def rect_mm(r, layer="User.1") -> Rect:
return Rect.normalized(int(r[0] * NM), int(r[1] * NM),
int(r[2] * NM), int(r[3] * NM), layer)
def make_multilayer(layers_mm, rect1_mm, rect2_mm, contact1="all",
contact2="all", vias_mm=(), t_um=70.0, gap_mm=1.0,
drill_mm=0.3, plating_um=18.0, rho=1.68e-8) -> Problem:
"""layers_mm: one list of (outline_pts, [holes]) per layer; layer i is
named 'L{i}' at z = i * gap_mm. vias_mm: (x, y) through-barrels."""
nlayers = len(layers_mm)
return Problem(
board_path="synthetic",
net_name="TEST",
rho_ohm_m=rho,
plating_nm=int(plating_um * 1000),
layers=[
LayerFill(layer_name=f"L{i}", thickness_nm=int(t_um * 1000),
z_nm=int(i * gap_mm * NM), polygons=_polys(polys_mm))
for i, polys_mm in enumerate(layers_mm)
],
vias=[
ViaLink(x=int(x * NM), y=int(y * NM),
drill_nm=int(drill_mm * NM),
z_top_nm=-1, z_bot_nm=int((nlayers - 1) * gap_mm * NM) + 1)
for x, y in vias_mm
],
electrodes1=[Electrode(rect=rect_mm(rect1_mm), contact=contact1)],
electrodes2=[Electrode(rect=rect_mm(rect2_mm), contact=contact2)],
thickness_source="override",
)
def make_problem(polygons_mm, rect1_mm, rect2_mm, t_um=70.0,
rho=1.68e-8) -> Problem:
"""Single-layer problem (the v1 test surface)."""
p = make_multilayer([polygons_mm], rect1_mm, rect2_mm, t_um=t_um, rho=rho)
p.layers[0].layer_name = "F.Cu"
return p
def strip_problem(length=50.0, width=10.0, e_len=5.0, t_um=70.0):
"""Uniform strip with full-width electrodes at both ends."""
outline = [(0, 0), (length, 0), (length, width), (0, width)]
return make_problem(
[(outline, [])],
rect1_mm=(0, 0, e_len, width),
rect2_mm=(length - e_len, 0, length, width),
t_um=t_um,
)
def sigma_s(t_um=70.0, rho=1.68e-8) -> float:
return t_um * 1e-6 / rho