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:
janik
2026-07-15 14:54:45 +07:00
parent 06c62e04f8
commit e7627352c1
18 changed files with 262 additions and 60 deletions
+17 -4
View File
@@ -355,6 +355,17 @@ def refill(board: Board) -> None:
# --- barrels -----------------------------------------------------------------
def _padstack_pad_nm(item) -> int:
"""Largest copper pad diameter of a via/pad padstack; 0 if unknown.
Used to bound the barrel-to-fill connection search in the solver."""
try:
sizes = [max(int(l.size.x), int(l.size.y))
for l in item.padstack.copper_layers]
return max(sizes) if sizes else 0
except Exception:
return 0
def _padstack_span(padstack, stackup: StackupInfo) -> tuple[int, int]:
"""(z_top, z_bot) of the barrel; falls back to the full stack."""
try:
@@ -380,7 +391,8 @@ def gather_barrels(board: Board, net_name: str,
z_top, z_bot = _padstack_span(via.padstack, stackup)
barrels.append(ViaLink(x=via.position.x, y=via.position.y,
drill_nm=drill, z_top_nm=z_top,
z_bot_nm=z_bot, kind="via"))
z_bot_nm=z_bot, kind="via",
pad_nm=_padstack_pad_nm(via)))
if config.INCLUDE_TH_PADS:
for pad in board.get_pads():
if pad.net is None or pad.net.name != net_name:
@@ -390,7 +402,8 @@ def gather_barrels(board: Board, net_name: str,
continue
barrels.append(ViaLink(x=pad.position.x, y=pad.position.y,
drill_nm=drill, z_top_nm=-1,
z_bot_nm=stackup.z_bot_nm + 1, kind="pad"))
z_bot_nm=stackup.z_bot_nm + 1, kind="pad",
pad_nm=_padstack_pad_nm(pad)))
return barrels
@@ -411,9 +424,9 @@ def build_problem(board: Board, net: str, layer_names: list[str],
print(f"note: net {net} has no fill on {name} - layer skipped")
continue
if config.COPPER_THICKNESS_UM is not None:
t, source = int(config.COPPER_THICKNESS_UM * 1000), "override"
t = int(config.COPPER_THICKNESS_UM * 1000)
else:
t, source = stackup.thickness_nm[name], "stackup"
t = stackup.thickness_nm[name]
layers.append(LayerFill(layer_name=name, thickness_nm=t,
z_nm=stackup.z_nm[name], polygons=polys))
if not layers:
+50 -17
View File
@@ -96,7 +96,7 @@ class _Dialog(QDialog):
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.accepted.connect(self._try_accept)
buttons.rejected.connect(self.reject)
lay = QVBoxLayout(self)
@@ -109,13 +109,21 @@ class _Dialog(QDialog):
note.setWordWrap(True)
note.setStyleSheet("color: gray; font-size: 10px;")
lay.addWidget(note)
self.error_label = QLabel("")
self.error_label.setWordWrap(True)
self.error_label.setStyleSheet("color: #b02a2a;")
self.error_label.setVisible(False)
lay.addWidget(self.error_label)
lay.addWidget(buttons)
self._selection: Selection | None = None
self._desired1, self._desired2 = contact1, contact2
self.net_box.currentTextChanged.connect(self._refresh)
self._refresh()
def _refresh(self):
self.error_label.setVisible(False)
net = self.net_box.currentText()
layers = [n for n in self._layer_order
if n in self._candidates.get(net, [])]
@@ -144,19 +152,39 @@ class _Dialog(QDialog):
out.append(item.text())
return out
def selection(self) -> Selection | None:
def _build_selection(self) -> Selection:
"""Parse and validate every field; raises ValueError with a
user-readable message instead of silently substituting defaults
(a typo silently becoming 1 A / DC would mislabel the result)."""
layers = self.checked_layers()
if not layers:
return None
raise ValueError("Check at least one layer.")
def number(edit: QLineEdit, name: str) -> float:
try:
return float(edit.text().strip().replace(",", "."))
except ValueError:
raise ValueError(f"{name}: '{edit.text()}' is not a number.")
current = number(self.current_edit, "Test current")
if current <= 0:
raise ValueError("Test current must be > 0 A.")
cell = None
if self.cell_edit.text().strip():
cell = number(self.cell_edit, "Cell size")
if cell <= 0:
raise ValueError("Cell size must be > 0 µm.")
try:
current = float(self.current_edit.text().replace(",", "."))
freq = skin.parse_frequency(self.freq_edit.text())
except ValueError:
current = config.TEST_CURRENT_A
cell_text = self.cell_edit.text().strip()
try:
cell = float(cell_text.replace(",", ".")) if cell_text else None
except ValueError:
cell = None
raise ValueError(
f"Frequency: cannot parse '{self.freq_edit.text()}' "
f"(examples: 0, 142k, 1.5M).")
extra_cu = 0.0
if self.extracu_edit.isEnabled():
extra_cu = number(self.extracu_edit, "Extra Cu")
if extra_cu < 0:
raise ValueError("Extra Cu must be ≥ 0 µm.")
def contact(box: QComboBox) -> str:
t = box.currentText()
@@ -164,18 +192,23 @@ class _Dialog(QDialog):
return "auto"
return "all" if t == ALL_LAYERS else t
try:
extra_cu = float(self.extracu_edit.text().replace(",", "."))
except ValueError:
extra_cu = 0.0
return Selection(net=self.net_box.currentText(), layers=layers,
contact1=contact(self.contact1_box),
contact2=contact(self.contact2_box),
current_a=current, cell_um=cell,
freq_hz=skin.parse_frequency(self.freq_edit.text()),
freq_hz=freq,
contact_model=self.model_box.currentData(),
include_buildup=self.buildup_check.isChecked(),
extra_cu_um=max(0.0, extra_cu))
extra_cu_um=extra_cu)
def _try_accept(self) -> None:
try:
self._selection = self._build_selection()
except ValueError as e:
self.error_label.setText(str(e))
self.error_label.setVisible(True)
return
self.accept()
def ask(candidates: dict[str, list[str]], layer_order: list[str],
@@ -190,4 +223,4 @@ def ask(candidates: dict[str, list[str]], layer_order: list[str],
dlg.activateWindow()
if dlg.exec() != QDialog.Accepted:
return None
return dlg.selection()
return dlg._selection
+4
View File
@@ -32,3 +32,7 @@ class ConnectivityError(UserFacingError):
class GridSizeError(UserFacingError):
pass
class SolverError(UserFacingError):
pass
+3 -1
View File
@@ -87,6 +87,7 @@ class ViaLink:
z_top_nm: int
z_bot_nm: int
kind: str = "via" # "via" | "pad"
pad_nm: int = 0 # pad/annular diameter; 0 = unknown
def spans(self, z_nm: int) -> bool:
return self.z_top_nm - 1 <= z_nm <= self.z_bot_nm + 1
@@ -287,7 +288,8 @@ def problem_from_json(d: dict) -> Problem:
vias=[
ViaLink(x=int(vd["x"]), y=int(vd["y"]), drill_nm=int(vd["drill_nm"]),
z_top_nm=int(vd["z_top_nm"]), z_bot_nm=int(vd["z_bot_nm"]),
kind=vd.get("kind", "via"))
kind=vd.get("kind", "via"),
pad_nm=int(vd.get("pad_nm", 0)))
for vd in d["vias"]
],
electrodes1=(
+3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from pathlib import Path
from . import config, plots, raster, report, solver
from .errors import UserFacingError
from .geometry import Problem
from .solver import Result
@@ -14,6 +15,8 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
contact_model: str | None = None) -> Result:
if i_test is None:
i_test = config.TEST_CURRENT_A
if i_test <= 0:
raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).")
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
print(f"rasterizing {len(problem.layers)} layer(s) at cell size "
f"{h / 1000:.1f} um ...")
+7 -2
View File
@@ -54,8 +54,8 @@ class RasterStack:
def cell_of(self, x_nm: float, y_nm: float) -> tuple[int, int] | None:
"""(i, j) of the cell containing the point, or None if outside."""
ny, nx = self.shape2d
j = int((x_nm - self.x0_nm) / self.h_nm)
i = int((y_nm - self.y0_nm) / self.h_nm)
j = math.floor((x_nm - self.x0_nm) / self.h_nm)
i = math.floor((y_nm - self.y0_nm) / self.h_nm)
if 0 <= i < ny and 0 <= j < nx:
return i, j
return None
@@ -81,6 +81,11 @@ def choose_cell_size(bbox_nm: tuple[int, int, int, int], nlayers: int) -> float:
raise GridSizeError("Copper geometry has a degenerate bounding box.")
if config.CELL_UM_OVERRIDE is not None:
if config.CELL_UM_OVERRIDE <= 0:
raise GridSizeError(
f"Cell size must be positive "
f"(got {config.CELL_UM_OVERRIDE:g} um)."
)
h = config.CELL_UM_OVERRIDE * 1000.0
else:
h = math.sqrt(w * ht * nlayers / config.TARGET_CELLS)
+5 -2
View File
@@ -102,8 +102,11 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
f"solver: {info.method}"
+ (f", {info.iterations} iters, residual {info.residual:.2e}"
if info.iterations is not None else ""),
f"I1/I2 @ 1V: {result.I1_a:.9g} / {result.I2_a:.9g} A "
f"(mismatch {result.mismatch_rel:.2e})",
(f"I1/I2 @ 1V: {result.I1_a:.9g} / {result.I2_a:.9g} A "
f"(mismatch {result.mismatch_rel:.2e})"
if result.contact_model == "equipotential" else
f"solve residual: {result.mismatch_rel:.2e} "
f"(KCL, prescribed injection)"),
f"timings [s]: "
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
"",
+7 -5
View File
@@ -59,7 +59,9 @@ def resistance_factor(thickness_m: float, freq_hz: float,
def parse_frequency(text: str) -> float:
"""'0', '100k', '1.5M', '142500' -> Hz. Empty/invalid -> 0 (DC)."""
"""'0', '100k', '1.5M', '142500' -> Hz; empty -> 0 (DC).
Raises ValueError on unparseable or negative input (a typo silently
becoming DC would mislabel the result)."""
t = text.strip().lower().replace(",", ".").removesuffix("hz").strip()
if not t:
return 0.0
@@ -72,7 +74,7 @@ def parse_frequency(text: str) -> float:
mult, t = 1e3, t[:-1]
elif t.endswith("g"):
mult, t = 1e9, t[:-1]
try:
return max(0.0, float(t) * mult)
except ValueError:
return 0.0
value = float(t) * mult # ValueError on garbage
if value < 0:
raise ValueError(f"negative frequency: {text!r}")
return value
+76 -18
View File
@@ -2,10 +2,12 @@
Each included copper layer is a 2D 5-point sheet with per-layer face
conductance sigma_s = t/rho [S] (square cells: independent of h); via and
plated-through-pad barrels add vertical conductances between vertically
aligned cells of the layers they span AND reach copper on. A barrel
passing an antipad still bridges the layers above/below it with the full
barrel length. At freq > 0 the per-layer sheet conductances and the
plated-through-pad barrels add vertical conductances between the layers
they span AND reach copper on. Per layer the barrel attaches to the cell
under it, or to the nearest copper cell within the pad footprint (+1
cell) - fills joined by thermal-relief spokes still connect. A barrel
passing a (wider) antipad still bridges the layers above/below it with
the full barrel length. At freq > 0 the per-layer sheet conductances and the
barrel walls get the 1D skin-effect correction (see skin.py; AC results
are a rigorous lower bound - lateral redistribution is not modeled).
@@ -44,7 +46,7 @@ from scipy.sparse import csgraph
from scipy.sparse import linalg as sla
from . import config, skin
from .errors import ConnectivityError, ElectrodeError
from .errors import ConnectivityError, ElectrodeError, SolverError
from .geometry import Problem
from .raster import RasterStack, electrodes_touch
@@ -63,6 +65,8 @@ class Edges:
b: np.ndarray
w: np.ndarray # conductance [S]
via_index: np.ndarray # int32; -1 = in-plane edge
dead_barrels: int = 0 # barrels spanning >=2 layers that found
# fill copper on fewer than 2 of them
@dataclass
@@ -159,35 +163,63 @@ def build_edges(stack: RasterStack, problem: Problem, sigmas: list[float],
ww.append(2.0 * s_a * s_b / (s_a + s_b))
vv.append(np.full(len(a), -1, dtype=np.int32))
h = stack.h_nm
dead_barrels = 0
for vi, via in enumerate(problem.vias):
cell = stack.cell_of(via.x, via.y)
if cell is None:
continue
i, j = cell
present = [li for li, layer in enumerate(problem.layers)
if via.spans(layer.z_nm) and stack.masks[li, i, j]]
for la, lb in zip(present[:-1], present[1:]):
span = [li for li, layer in enumerate(problem.layers)
if via.spans(layer.z_nm)]
# Connection cell per layer: the cell under the barrel, or the
# nearest copper cell whose center lies within the pad footprint
# (+1 cell of rasterization slop) - fills joined to the barrel by
# thermal-relief spokes still connect, wider antipads do not (the
# barrel then bridges the layers above/below as before).
r_nm = max(via.pad_nm, via.drill_nm + 300_000) / 2.0 + h
win = int(r_nm // h) + 1
i0, i1 = max(0, i - win), min(ny, i + win + 1)
j0, j1 = max(0, j - win), min(nx, j + win + 1)
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - via.x
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - via.y
d2 = ys[:, None] ** 2 + xs[None, :] ** 2
d2 = np.where(d2 <= r_nm * r_nm, d2, np.inf)
present = [] # (layer, i, j) per layer
for li in span:
if stack.masks[li, i, j]:
present.append((li, i, j))
continue
dc = np.where(stack.masks[li, i0:i1, j0:j1], d2, np.inf)
ci, cj = np.unravel_index(int(np.argmin(dc)), dc.shape)
if np.isfinite(dc[ci, cj]):
present.append((li, i0 + ci, j0 + cj))
if len(span) >= 2 and len(present) < 2:
dead_barrels += 1
for (la, ia, ja), (lb, ib, jb) in zip(present[:-1], present[1:]):
length = problem.layers[lb].z_nm - problem.layers[la].z_nm
if length <= 0:
continue
r = via.barrel_resistance(length, problem.rho_ohm_m,
problem.plating_nm) * via_factor
aa.append(np.array([la * plane + i * nx + j], dtype=np.int64))
bb.append(np.array([lb * plane + i * nx + j], dtype=np.int64))
aa.append(np.array([la * plane + ia * nx + ja], dtype=np.int64))
bb.append(np.array([lb * plane + ib * nx + jb], dtype=np.int64))
ww.append(np.array([1.0 / r]))
vv.append(np.array([vi], dtype=np.int32))
if not aa:
raise ConnectivityError("No copper found on the selected layers.")
return Edges(a=np.concatenate(aa), b=np.concatenate(bb),
w=np.concatenate(ww), via_index=np.concatenate(vv))
w=np.concatenate(ww), via_index=np.concatenate(vv),
dead_barrels=dead_barrels)
def connected_restrict(stack: RasterStack, e1: np.ndarray, e2: np.ndarray,
edges: Edges) -> bool:
edges: Edges) -> tuple[bool, int]:
"""Keep only components (through-plane AND through-via) touching both
terminals. Mutates stack.masks / e1 / e2. Returns True if anything
was dropped (caller must rebuild edges)."""
terminals. Mutates stack.masks / e1 / e2. Returns (changed,
n_components): whether anything was dropped (caller must rebuild
edges) and how many disjoint copper groups survive."""
n = stack.masks.size
graph = sparse.coo_matrix(
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(n, n))
@@ -205,7 +237,7 @@ def connected_restrict(stack: RasterStack, e1: np.ndarray, e2: np.ndarray,
stack.masks &= keep
e1 &= keep
e2 &= keep
return changed
return changed, len(common)
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
@@ -280,7 +312,7 @@ def solve_system(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, Solve
x, code = sla.cg(A, b, M=M, tol=config.CG_TOL,
maxiter=config.CG_MAXITER, callback=count)
if code != 0:
raise RuntimeError(
raise SolverError(
f"CG did not converge in {config.CG_MAXITER} iterations "
f"(code {code}). Try a coarser grid or raise CG_MAXITER."
)
@@ -460,12 +492,31 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
t0 = time.perf_counter()
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
if connected_restrict(stack, e1, e2, edges):
changed, n_groups = connected_restrict(stack, e1, e2, edges)
if changed:
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
if edges.dead_barrels:
print(f"warning: {edges.dead_barrels} via/pad barrel(s) found fill "
f"copper on fewer than 2 layers and carry no current (pad "
f"copper is not modeled; a finer grid may pick up thermal "
f"spokes)")
if n_groups > 1 and contact_model != "equipotential":
raise ConnectivityError(
f"The selected fills form {n_groups} disconnected copper groups "
f"that each touch both terminals. The uniform-injection contact "
f"model cannot determine the current split between disconnected "
f"sheets - switch to the equipotential contact model (bonded "
f"lug), or include the layers/vias that join them."
)
if stack.buildup is not None:
stack.buildup &= stack.masks
for _, m in (parts1 or []) + (parts2 or []):
for label, m in (parts1 or []) + (parts2 or []):
had = bool(m.any())
m &= stack.masks # follow the component restriction
if had and not m.any():
print(f"warning: contact part '{label}' only touches copper "
f"that is not connected to both terminals - it carries "
f"no current")
timings["edges_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
@@ -494,6 +545,13 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
P_vias = float(Pe[~inplane].sum())
P_total = i_test ** 2 * R
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
if not np.isfinite(balance) or balance > 1e-3:
raise SolverError(
f"Inconsistent solve: R = {R:.6g} ohm with power-balance error "
f"{balance:.2e} (sum of edge powers vs I^2*R). The result is "
f"not trustworthy - try the equipotential contact model or a "
f"different grid size."
)
# via reports: max segment current + total power per via
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) # amps at unit drive
+3 -3
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from . import config, pipeline
from .errors import UserFacingError
from .geometry import load_problem
from .skin import parse_frequency
def main(argv=None) -> int:
@@ -23,7 +24,7 @@ def main(argv=None) -> int:
ap.add_argument("dump", type=Path, help="geometry_dump.json from a plugin run")
ap.add_argument("--current", type=float, default=None,
help="test current [A] (default: config TEST_CURRENT_A)")
ap.add_argument("--freq", type=str, default="0",
ap.add_argument("--freq", type=parse_frequency, default=0.0,
help="frequency, e.g. 142k or 1.5M (default: DC). "
"AC results are a lower bound (skin per foil only)")
ap.add_argument("--cell-um", type=float, default=None,
@@ -64,11 +65,10 @@ def main(argv=None) -> int:
file=sys.stderr)
return 1
from .skin import parse_frequency
outdir = args.out if args.out is not None else args.dump.parent
try:
pipeline.run(problem, outdir, show=not args.no_show,
i_test=args.current, freq_hz=parse_frequency(args.freq),
i_test=args.current, freq_hz=args.freq,
contact_model=args.contact_model)
except UserFacingError as e:
print(f"ERROR: {e}", file=sys.stderr)
+2 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://go.kicad.org/api/schemas/v1",
"identifier": "th.co.b4l.fill-resistance",
"name": "Fill Resistance",
"description": "DC resistance of a copper zone fill between two rectangle electrodes",
"description": "DC/AC resistance of copper zone fills between two contacts (marker rectangles or pads), single- or multi-layer with via coupling",
"runtime": {
"type": "python"
},
@@ -10,7 +10,7 @@
{
"identifier": "fill-resistance-run",
"name": "Fill Resistance",
"description": "Select two rectangles marking the contact areas, then run to compute the fill resistance between them",
"description": "Mark the contacts (rectangles on the User.1/User.2 marker layers and/or selected pads), then run to compute the fill resistance between them",
"entrypoint": "fill_res_action.py",
"show-button": true,
"scopes": ["pcb"],
+19 -2
View File
@@ -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))
+2
View File
@@ -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)
+41 -3
View File
@@ -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."""
+8
View File
@@ -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
View File
@@ -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():
+11
View File
@@ -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(