Files
kicad-zone-resistance/tests/test_skin.py
T
janikandClaude Fable 5 26b1cfaa45
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
Multiple Thevenin supplies and prescribed-current loads on one net,
solved in absolute volts with the Tellegen power balance verified per
run; a source-sink pair table (effective copper resistance per
supply x load pair plus an exactly-summing proportional-sharing loss
attribution), in summary.txt and as its own figure. Bonded terminals
short a package's contacts into one lug so the per-pin split becomes
a solve outcome. Geometry dumps carry the terminal set (schema v8).

The dialog gained a Classic/PDN mode selector and a full PDN editor:
per-role supply/load tables built from the marker rectangles (or a
config's terminal set, which never pins mode or net), with Component
hints, per-terminal Layer scopes, Active checkboxes, comments, a
per-net row filter, resizable tables and a scrolling, screen-sized
dialog. Numbers accept SI suffixes (50m, 4.7k) everywhere.

fill_res_config.json fully specifies a run (classic or PDN) with
validation, comments, named side-by-side configs (the one called
default auto-loads), Load/Save buttons with an editable file name,
and saves that never drop anything drawn on the board.

347 tests, green on Python 3.13 and on the 3.9 macOS wheel stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:01:24 +07:00

149 lines
5.7 KiB
Python

"""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
with pytest.raises(ValueError):
skin.parse_frequency("junk") # must not silently become DC
with pytest.raises(ValueError):
skin.parse_frequency("-5k")
def test_normalize_decimal():
"""European decimal commas parse; thousands-separator patterns are
rejected ('1,500' silently becoming 1.5 was a 1000x input error)."""
assert skin.normalize_decimal("1,5") == "1.5"
assert skin.normalize_decimal("0,25") == "0.25"
assert skin.normalize_decimal("1,5000") == "1.5000" # 4 digits: decimal
assert skin.normalize_decimal("2.5") == "2.5"
for bad in ("1,500", "1.500,5", "1,000,000", "12,345"):
with pytest.raises(ValueError, match="separator"):
skin.normalize_decimal(bad)
def test_parse_frequency_decimal_comma():
assert skin.parse_frequency("1,5k") == 1500.0
with pytest.raises(ValueError):
skin.parse_frequency("1,500") # ambiguous, not 1.5 Hz
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)
@pytest.mark.parametrize("text, value", [
("50m", 0.05), ("4.7k", 4700.0), ("2M", 2e6), ("10", 10.0),
("3,3", 3.3), ("1,5k", 1500.0), ("500u", 5e-4), ("2µ", 2e-6),
("100n", 1e-7), ("1p", 1e-12), ("1G", 1e9), ("4K", 4000.0),
("50 m", 0.05), ("-2m", -0.002), ("1e3", 1000.0), ("0", 0.0),
])
def test_parse_engineering_values(text, value):
assert skin.parse_engineering(text) == pytest.approx(value, rel=1e-12)
@pytest.mark.parametrize("bad", ["", "m", "junk", "5k5", "1,500k",
"1.2.3", "50 m m"])
def test_parse_engineering_rejects_garbage(bad):
with pytest.raises(ValueError):
skin.parse_engineering(bad)
def test_parse_engineering_case_separates_milli_from_mega():
# exactly the trap parse_frequency sidesteps by lowercasing: for
# general values 50m and 50M are 9 orders of magnitude apart
assert skin.parse_engineering("50m") == pytest.approx(0.05)
assert skin.parse_engineering("50M") == pytest.approx(5e7)