From e7627352c1e827bd7575305b4ac7cae69f6ca4d9 Mon Sep 17 00:00:00 2001 From: janik Date: Wed, 15 Jul 2026 14:54:45 +0700 Subject: [PATCH] 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 --- fill_resistance/board_io.py | 21 ++++- fill_resistance/dialog.py | 67 +++++++++++---- fill_resistance/errors.py | 4 + fill_resistance/geometry.py | 4 +- fill_resistance/pipeline.py | 3 + fill_resistance/raster.py | 9 ++- fill_resistance/report.py | 7 +- fill_resistance/skin.py | 12 +-- fill_resistance/solver.py | 94 +++++++++++++++++----- fill_resistance/standalone.py | 6 +- plugin.json | 4 +- tests/test_contacts.py | 21 ++++- tests/test_geometry.py | 2 + tests/test_multilayer.py | 44 +++++++++- tests/test_raster.py | 8 ++ tests/test_skin.py | 5 +- tests/test_solver.py | 11 +++ smoke/smoke_probe.py => tools/api_probe.py | 0 18 files changed, 262 insertions(+), 60 deletions(-) rename smoke/smoke_probe.py => tools/api_probe.py (100%) diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index fa67512..7aaad59 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -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: diff --git a/fill_resistance/dialog.py b/fill_resistance/dialog.py index 360acf6..50c633e 100644 --- a/fill_resistance/dialog.py +++ b/fill_resistance/dialog.py @@ -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 diff --git a/fill_resistance/errors.py b/fill_resistance/errors.py index 3b4dac8..cfbdc4d 100644 --- a/fill_resistance/errors.py +++ b/fill_resistance/errors.py @@ -32,3 +32,7 @@ class ConnectivityError(UserFacingError): class GridSizeError(UserFacingError): pass + + +class SolverError(UserFacingError): + pass diff --git a/fill_resistance/geometry.py b/fill_resistance/geometry.py index 8e167ac..0f5d0fb 100644 --- a/fill_resistance/geometry.py +++ b/fill_resistance/geometry.py @@ -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=( diff --git a/fill_resistance/pipeline.py b/fill_resistance/pipeline.py index ac52f25..fee5b3c 100644 --- a/fill_resistance/pipeline.py +++ b/fill_resistance/pipeline.py @@ -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 ...") diff --git a/fill_resistance/raster.py b/fill_resistance/raster.py index a0904aa..d10770d 100644 --- a/fill_resistance/raster.py +++ b/fill_resistance/raster.py @@ -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) diff --git a/fill_resistance/report.py b/fill_resistance/report.py index f186e16..0c5a191 100644 --- a/fill_resistance/report.py +++ b/fill_resistance/report.py @@ -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())}", "", diff --git a/fill_resistance/skin.py b/fill_resistance/skin.py index e27a332..f3b8618 100644 --- a/fill_resistance/skin.py +++ b/fill_resistance/skin.py @@ -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 diff --git a/fill_resistance/solver.py b/fill_resistance/solver.py index 8129da7..cbb1974 100644 --- a/fill_resistance/solver.py +++ b/fill_resistance/solver.py @@ -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 diff --git a/fill_resistance/standalone.py b/fill_resistance/standalone.py index 3785cca..0813a56 100644 --- a/fill_resistance/standalone.py +++ b/fill_resistance/standalone.py @@ -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) diff --git a/plugin.json b/plugin.json index 93a2e99..4ecf4d4 100644 --- a/plugin.json +++ b/plugin.json @@ -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"], diff --git a/tests/test_contacts.py b/tests/test_contacts.py index ea7193e..ce42810 100644 --- a/tests/test_contacts.py +++ b/tests/test_contacts.py @@ -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)) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 78bdee8..2eda95e 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -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) diff --git a/tests/test_multilayer.py b/tests/test_multilayer.py index 3835eba..ad83bb2 100644 --- a/tests/test_multilayer.py +++ b/tests/test_multilayer.py @@ -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.""" diff --git a/tests/test_raster.py b/tests/test_raster.py index 7737a19..d305104 100644 --- a/tests/test_raster.py +++ b/tests/test_raster.py @@ -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) diff --git a/tests/test_skin.py b/tests/test_skin.py index d5d6394..2b3e643 100644 --- a/tests/test_skin.py +++ b/tests/test_skin.py @@ -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(): diff --git a/tests/test_solver.py b/tests/test_solver.py index bf8a4db..b929bf5 100644 --- a/tests/test_solver.py +++ b/tests/test_solver.py @@ -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( diff --git a/smoke/smoke_probe.py b/tools/api_probe.py similarity index 100% rename from smoke/smoke_probe.py rename to tools/api_probe.py