Fix solver correctness and input-validation issues from code review
- Refuse the uniform contact model when the fills form multiple disconnected copper groups that each touch both terminals: the prescribed injection split is ill-posed and the grounded system was singular, silently returning garbage (e.g. negative gigaohms). connected_restrict now reports the component count; a power-balance backstop (SolverError) catches any other inconsistent solve. - Connect via/pad barrels to the nearest fill copper within the pad footprint (+1 cell) instead of only the exact center cell, so thermal-relief spokes still stitch layers; barrels that reach fill on fewer than two layers are warned about. ViaLink gains pad_nm (extracted from the padstack, JSON-roundtripped). - Validate dialog input on OK (layers, current > 0, cell > 0, parseable frequency, extra Cu >= 0) with an inline error instead of silently substituting defaults; parse_frequency raises on garbage; pipeline rejects i_test <= 0; choose_cell_size rejects non-positive overrides. - Warn when a contact part is dropped by the connectivity restriction; floor instead of truncate in cell_of; correct the uniform-model summary line; drop an unused variable; refresh plugin.json wording. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+19
-2
@@ -3,9 +3,10 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from fill_resistance import raster, solver
|
||||
from fill_resistance.errors import ElectrodeError
|
||||
from fill_resistance.errors import ConnectivityError, ElectrodeError
|
||||
from fill_resistance.geometry import Electrode
|
||||
from tests.util import NM, make_problem, rect_mm, sigma_s, strip_problem
|
||||
from tests.util import (NM, make_multilayer, make_problem, rect_mm, sigma_s,
|
||||
strip_problem)
|
||||
|
||||
|
||||
def _solve(problem, h_mm, model, i_test=1.0):
|
||||
@@ -161,6 +162,22 @@ def test_injection_area_partition_first_wins():
|
||||
assert total == pytest.approx(1.0, rel=1e-12)
|
||||
|
||||
|
||||
def test_uniform_multicomponent_raises():
|
||||
"""Two disconnected sheets that each touch both terminals: the
|
||||
uniform model would build a singular system (one ground cell, pure-
|
||||
Neumann second component) and previously returned garbage silently
|
||||
(e.g. negative gigaohms). It must refuse; equipotential handles it."""
|
||||
strip1 = [(0, 0), (10, 0), (10, 1), (0, 1)]
|
||||
strip2 = [(0, 0), (10, 0), (10, 2), (0, 2)] # asymmetric shares
|
||||
p = make_multilayer([[(strip1, [])], [(strip2, [])]],
|
||||
(0, 0, 1, 2), (9, 0, 10, 1)) # contact 'all', no vias
|
||||
with pytest.raises(ConnectivityError, match="disconnected"):
|
||||
_solve(p, 1.0, "uniform")
|
||||
res, _ = _solve(p, 1.0, "equipotential")
|
||||
assert np.isfinite(res.R_ohm) and res.R_ohm > 0
|
||||
assert res.power_balance_rel < 1e-9
|
||||
|
||||
|
||||
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))
|
||||
|
||||
@@ -51,6 +51,7 @@ def test_problem_json_roundtrip_v2(tmp_path):
|
||||
[([(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)])
|
||||
p.vias[0].pad_nm = 600_000
|
||||
f = tmp_path / "dump.json"
|
||||
save_problem(p, f)
|
||||
q = load_problem(f)
|
||||
@@ -58,6 +59,7 @@ def test_problem_json_roundtrip_v2(tmp_path):
|
||||
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.vias[0].pad_nm == 600_000
|
||||
assert q.plating_nm == p.plating_nm
|
||||
assert np.array_equal(q.layers[0].polygons[0].outline,
|
||||
p.layers[0].polygons[0].outline)
|
||||
|
||||
@@ -74,9 +74,10 @@ def test_parallel_vias_halve_barrel_resistance():
|
||||
|
||||
|
||||
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)]])]
|
||||
"""3 layers; the middle layer has an antipad hole at the via, WIDER
|
||||
than the barrel connection search (pad footprint + 1 cell), so the
|
||||
barrel bridges L0 -> L2 directly with DOUBLE the length."""
|
||||
mid_with_hole = [(STRIP, [[(4, 0), (7, 0), (7, 1), (4, 1)]])]
|
||||
p = make_multilayer(
|
||||
[[(STRIP, [])], mid_with_hole, [(STRIP, [])]],
|
||||
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
|
||||
@@ -88,6 +89,43 @@ def test_antipad_bridging():
|
||||
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
|
||||
|
||||
|
||||
def test_thermal_gap_via_connects_to_nearby_copper():
|
||||
"""The cell under the via is not copper (thermal-relief knockout),
|
||||
but fill copper within the pad footprint (+1 cell) still reaches the
|
||||
barrel: the link lands on the nearest copper cell instead of being
|
||||
silently dropped."""
|
||||
top_with_gap = [(STRIP, [[(5, 0), (7, 0), (7, 1), (5, 1)]])]
|
||||
p = make_multilayer(
|
||||
[top_with_gap, [(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)
|
||||
sig = sigma_s()
|
||||
# L0 attaches at col 4 (nearest copper, 1.0 mm from the barrel),
|
||||
# L1 at col 5: 4 faces on L0, the barrel, 4 faces on L1 - exact
|
||||
r_exact = (4 + 4) / sig + _r_via(1.0)
|
||||
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
|
||||
assert len(res.via_reports) == 1
|
||||
assert res.via_reports[0].current_a == pytest.approx(1.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_dead_barrel_is_warned(capsys):
|
||||
"""A via isolated from the fill by an antipad wider than the search
|
||||
radius on all but one layer carries nothing and is reported."""
|
||||
bot_with_hole = [(STRIP, [[(3, 0), (8, 0), (8, 1), (3, 1)]])]
|
||||
p = make_multilayer(
|
||||
[[(STRIP, [])], bot_with_hole],
|
||||
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
|
||||
contact1="L0", contact2="L0",
|
||||
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
|
||||
res, _ = _solve(p, 1.0)
|
||||
sig = sigma_s()
|
||||
assert res.R_ohm == pytest.approx(9 / sig, rel=1e-9) # L0 alone
|
||||
assert res.via_reports == []
|
||||
assert "carry no current" in capsys.readouterr().out
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -90,6 +90,14 @@ def test_hard_max_cells_guard(monkeypatch):
|
||||
raster.choose_cell_size(p.copper_bbox(), len(p.layers))
|
||||
|
||||
|
||||
def test_cell_override_nonpositive_raises(monkeypatch):
|
||||
p = strip_problem()
|
||||
for bad in (0.0, -50.0):
|
||||
monkeypatch.setattr(config, "CELL_UM_OVERRIDE", bad)
|
||||
with pytest.raises(GridSizeError, match="positive"):
|
||||
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)
|
||||
|
||||
+4
-1
@@ -58,7 +58,10 @@ def test_parse_frequency():
|
||||
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
|
||||
with pytest.raises(ValueError):
|
||||
skin.parse_frequency("junk") # must not silently become DC
|
||||
with pytest.raises(ValueError):
|
||||
skin.parse_frequency("-5k")
|
||||
|
||||
|
||||
def test_single_layer_ac_scales_exactly():
|
||||
|
||||
@@ -112,6 +112,17 @@ def test_test_current_scaling():
|
||||
assert r10.P_total == pytest.approx(100 * r1.P_total, rel=1e-9)
|
||||
|
||||
|
||||
def test_nonpositive_test_current_rejected():
|
||||
"""i_test <= 0 would divide by zero in the percentage reporting;
|
||||
it must be rejected up front with a clean user-facing message."""
|
||||
from fill_resistance import pipeline
|
||||
from fill_resistance.errors import UserFacingError
|
||||
p = strip_problem()
|
||||
for bad in (0.0, -1.0):
|
||||
with pytest.raises(UserFacingError, match="Test current"):
|
||||
pipeline.run(p, None, show=False, i_test=bad)
|
||||
|
||||
|
||||
def test_power_identity():
|
||||
"""Sum of edge powers equals I^2 R exactly for the direct solve."""
|
||||
p = make_problem(
|
||||
|
||||
Reference in New Issue
Block a user