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
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>
149 lines
5.7 KiB
Python
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)
|