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:
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user