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:
@@ -355,6 +355,17 @@ def refill(board: Board) -> None:
|
|||||||
|
|
||||||
# --- barrels -----------------------------------------------------------------
|
# --- 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]:
|
def _padstack_span(padstack, stackup: StackupInfo) -> tuple[int, int]:
|
||||||
"""(z_top, z_bot) of the barrel; falls back to the full stack."""
|
"""(z_top, z_bot) of the barrel; falls back to the full stack."""
|
||||||
try:
|
try:
|
||||||
@@ -380,7 +391,8 @@ def gather_barrels(board: Board, net_name: str,
|
|||||||
z_top, z_bot = _padstack_span(via.padstack, stackup)
|
z_top, z_bot = _padstack_span(via.padstack, stackup)
|
||||||
barrels.append(ViaLink(x=via.position.x, y=via.position.y,
|
barrels.append(ViaLink(x=via.position.x, y=via.position.y,
|
||||||
drill_nm=drill, z_top_nm=z_top,
|
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:
|
if config.INCLUDE_TH_PADS:
|
||||||
for pad in board.get_pads():
|
for pad in board.get_pads():
|
||||||
if pad.net is None or pad.net.name != net_name:
|
if pad.net is None or pad.net.name != net_name:
|
||||||
@@ -390,7 +402,8 @@ def gather_barrels(board: Board, net_name: str,
|
|||||||
continue
|
continue
|
||||||
barrels.append(ViaLink(x=pad.position.x, y=pad.position.y,
|
barrels.append(ViaLink(x=pad.position.x, y=pad.position.y,
|
||||||
drill_nm=drill, z_top_nm=-1,
|
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
|
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")
|
print(f"note: net {net} has no fill on {name} - layer skipped")
|
||||||
continue
|
continue
|
||||||
if config.COPPER_THICKNESS_UM is not None:
|
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:
|
else:
|
||||||
t, source = stackup.thickness_nm[name], "stackup"
|
t = stackup.thickness_nm[name]
|
||||||
layers.append(LayerFill(layer_name=name, thickness_nm=t,
|
layers.append(LayerFill(layer_name=name, thickness_nm=t,
|
||||||
z_nm=stackup.z_nm[name], polygons=polys))
|
z_nm=stackup.z_nm[name], polygons=polys))
|
||||||
if not layers:
|
if not layers:
|
||||||
|
|||||||
+49
-16
@@ -96,7 +96,7 @@ class _Dialog(QDialog):
|
|||||||
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
|
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
buttons.accepted.connect(self.accept)
|
buttons.accepted.connect(self._try_accept)
|
||||||
buttons.rejected.connect(self.reject)
|
buttons.rejected.connect(self.reject)
|
||||||
|
|
||||||
lay = QVBoxLayout(self)
|
lay = QVBoxLayout(self)
|
||||||
@@ -109,13 +109,21 @@ class _Dialog(QDialog):
|
|||||||
note.setWordWrap(True)
|
note.setWordWrap(True)
|
||||||
note.setStyleSheet("color: gray; font-size: 10px;")
|
note.setStyleSheet("color: gray; font-size: 10px;")
|
||||||
lay.addWidget(note)
|
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)
|
lay.addWidget(buttons)
|
||||||
|
|
||||||
|
self._selection: Selection | None = None
|
||||||
|
|
||||||
self._desired1, self._desired2 = contact1, contact2
|
self._desired1, self._desired2 = contact1, contact2
|
||||||
self.net_box.currentTextChanged.connect(self._refresh)
|
self.net_box.currentTextChanged.connect(self._refresh)
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
def _refresh(self):
|
def _refresh(self):
|
||||||
|
self.error_label.setVisible(False)
|
||||||
net = self.net_box.currentText()
|
net = self.net_box.currentText()
|
||||||
layers = [n for n in self._layer_order
|
layers = [n for n in self._layer_order
|
||||||
if n in self._candidates.get(net, [])]
|
if n in self._candidates.get(net, [])]
|
||||||
@@ -144,19 +152,39 @@ class _Dialog(QDialog):
|
|||||||
out.append(item.text())
|
out.append(item.text())
|
||||||
return out
|
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()
|
layers = self.checked_layers()
|
||||||
if not layers:
|
if not layers:
|
||||||
return None
|
raise ValueError("Check at least one layer.")
|
||||||
|
|
||||||
|
def number(edit: QLineEdit, name: str) -> float:
|
||||||
try:
|
try:
|
||||||
current = float(self.current_edit.text().replace(",", "."))
|
return float(edit.text().strip().replace(",", "."))
|
||||||
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:
|
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
|
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:
|
||||||
|
freq = skin.parse_frequency(self.freq_edit.text())
|
||||||
|
except ValueError:
|
||||||
|
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:
|
def contact(box: QComboBox) -> str:
|
||||||
t = box.currentText()
|
t = box.currentText()
|
||||||
@@ -164,18 +192,23 @@ class _Dialog(QDialog):
|
|||||||
return "auto"
|
return "auto"
|
||||||
return "all" if t == ALL_LAYERS else t
|
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,
|
return Selection(net=self.net_box.currentText(), layers=layers,
|
||||||
contact1=contact(self.contact1_box),
|
contact1=contact(self.contact1_box),
|
||||||
contact2=contact(self.contact2_box),
|
contact2=contact(self.contact2_box),
|
||||||
current_a=current, cell_um=cell,
|
current_a=current, cell_um=cell,
|
||||||
freq_hz=skin.parse_frequency(self.freq_edit.text()),
|
freq_hz=freq,
|
||||||
contact_model=self.model_box.currentData(),
|
contact_model=self.model_box.currentData(),
|
||||||
include_buildup=self.buildup_check.isChecked(),
|
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],
|
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()
|
dlg.activateWindow()
|
||||||
if dlg.exec() != QDialog.Accepted:
|
if dlg.exec() != QDialog.Accepted:
|
||||||
return None
|
return None
|
||||||
return dlg.selection()
|
return dlg._selection
|
||||||
|
|||||||
@@ -32,3 +32,7 @@ class ConnectivityError(UserFacingError):
|
|||||||
|
|
||||||
class GridSizeError(UserFacingError):
|
class GridSizeError(UserFacingError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SolverError(UserFacingError):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class ViaLink:
|
|||||||
z_top_nm: int
|
z_top_nm: int
|
||||||
z_bot_nm: int
|
z_bot_nm: int
|
||||||
kind: str = "via" # "via" | "pad"
|
kind: str = "via" # "via" | "pad"
|
||||||
|
pad_nm: int = 0 # pad/annular diameter; 0 = unknown
|
||||||
|
|
||||||
def spans(self, z_nm: int) -> bool:
|
def spans(self, z_nm: int) -> bool:
|
||||||
return self.z_top_nm - 1 <= z_nm <= self.z_bot_nm + 1
|
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=[
|
vias=[
|
||||||
ViaLink(x=int(vd["x"]), y=int(vd["y"]), drill_nm=int(vd["drill_nm"]),
|
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"]),
|
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"]
|
for vd in d["vias"]
|
||||||
],
|
],
|
||||||
electrodes1=(
|
electrodes1=(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import config, plots, raster, report, solver
|
from . import config, plots, raster, report, solver
|
||||||
|
from .errors import UserFacingError
|
||||||
from .geometry import Problem
|
from .geometry import Problem
|
||||||
from .solver import Result
|
from .solver import Result
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
|||||||
contact_model: str | None = None) -> Result:
|
contact_model: str | None = None) -> Result:
|
||||||
if i_test is None:
|
if i_test is None:
|
||||||
i_test = config.TEST_CURRENT_A
|
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))
|
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
|
||||||
print(f"rasterizing {len(problem.layers)} layer(s) at cell size "
|
print(f"rasterizing {len(problem.layers)} layer(s) at cell size "
|
||||||
f"{h / 1000:.1f} um ...")
|
f"{h / 1000:.1f} um ...")
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ class RasterStack:
|
|||||||
def cell_of(self, x_nm: float, y_nm: float) -> tuple[int, int] | None:
|
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."""
|
"""(i, j) of the cell containing the point, or None if outside."""
|
||||||
ny, nx = self.shape2d
|
ny, nx = self.shape2d
|
||||||
j = int((x_nm - self.x0_nm) / self.h_nm)
|
j = math.floor((x_nm - self.x0_nm) / self.h_nm)
|
||||||
i = int((y_nm - self.y0_nm) / self.h_nm)
|
i = math.floor((y_nm - self.y0_nm) / self.h_nm)
|
||||||
if 0 <= i < ny and 0 <= j < nx:
|
if 0 <= i < ny and 0 <= j < nx:
|
||||||
return i, j
|
return i, j
|
||||||
return None
|
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.")
|
raise GridSizeError("Copper geometry has a degenerate bounding box.")
|
||||||
|
|
||||||
if config.CELL_UM_OVERRIDE is not None:
|
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
|
h = config.CELL_UM_OVERRIDE * 1000.0
|
||||||
else:
|
else:
|
||||||
h = math.sqrt(w * ht * nlayers / config.TARGET_CELLS)
|
h = math.sqrt(w * ht * nlayers / config.TARGET_CELLS)
|
||||||
|
|||||||
@@ -102,8 +102,11 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
|||||||
f"solver: {info.method}"
|
f"solver: {info.method}"
|
||||||
+ (f", {info.iterations} iters, residual {info.residual:.2e}"
|
+ (f", {info.iterations} iters, residual {info.residual:.2e}"
|
||||||
if info.iterations is not None else ""),
|
if info.iterations is not None else ""),
|
||||||
f"I1/I2 @ 1V: {result.I1_a:.9g} / {result.I2_a:.9g} A "
|
(f"I1/I2 @ 1V: {result.I1_a:.9g} / {result.I2_a:.9g} A "
|
||||||
f"(mismatch {result.mismatch_rel:.2e})",
|
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"timings [s]: "
|
||||||
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
|
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ def resistance_factor(thickness_m: float, freq_hz: float,
|
|||||||
|
|
||||||
|
|
||||||
def parse_frequency(text: str) -> 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()
|
t = text.strip().lower().replace(",", ".").removesuffix("hz").strip()
|
||||||
if not t:
|
if not t:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -72,7 +74,7 @@ def parse_frequency(text: str) -> float:
|
|||||||
mult, t = 1e3, t[:-1]
|
mult, t = 1e3, t[:-1]
|
||||||
elif t.endswith("g"):
|
elif t.endswith("g"):
|
||||||
mult, t = 1e9, t[:-1]
|
mult, t = 1e9, t[:-1]
|
||||||
try:
|
value = float(t) * mult # ValueError on garbage
|
||||||
return max(0.0, float(t) * mult)
|
if value < 0:
|
||||||
except ValueError:
|
raise ValueError(f"negative frequency: {text!r}")
|
||||||
return 0.0
|
return value
|
||||||
|
|||||||
+76
-18
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
Each included copper layer is a 2D 5-point sheet with per-layer face
|
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
|
conductance sigma_s = t/rho [S] (square cells: independent of h); via and
|
||||||
plated-through-pad barrels add vertical conductances between vertically
|
plated-through-pad barrels add vertical conductances between the layers
|
||||||
aligned cells of the layers they span AND reach copper on. A barrel
|
they span AND reach copper on. Per layer the barrel attaches to the cell
|
||||||
passing an antipad still bridges the layers above/below it with the full
|
under it, or to the nearest copper cell within the pad footprint (+1
|
||||||
barrel length. At freq > 0 the per-layer sheet conductances and the
|
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
|
barrel walls get the 1D skin-effect correction (see skin.py; AC results
|
||||||
are a rigorous lower bound - lateral redistribution is not modeled).
|
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 scipy.sparse import linalg as sla
|
||||||
|
|
||||||
from . import config, skin
|
from . import config, skin
|
||||||
from .errors import ConnectivityError, ElectrodeError
|
from .errors import ConnectivityError, ElectrodeError, SolverError
|
||||||
from .geometry import Problem
|
from .geometry import Problem
|
||||||
from .raster import RasterStack, electrodes_touch
|
from .raster import RasterStack, electrodes_touch
|
||||||
|
|
||||||
@@ -63,6 +65,8 @@ class Edges:
|
|||||||
b: np.ndarray
|
b: np.ndarray
|
||||||
w: np.ndarray # conductance [S]
|
w: np.ndarray # conductance [S]
|
||||||
via_index: np.ndarray # int32; -1 = in-plane edge
|
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
|
@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))
|
ww.append(2.0 * s_a * s_b / (s_a + s_b))
|
||||||
vv.append(np.full(len(a), -1, dtype=np.int32))
|
vv.append(np.full(len(a), -1, dtype=np.int32))
|
||||||
|
|
||||||
|
h = stack.h_nm
|
||||||
|
dead_barrels = 0
|
||||||
for vi, via in enumerate(problem.vias):
|
for vi, via in enumerate(problem.vias):
|
||||||
cell = stack.cell_of(via.x, via.y)
|
cell = stack.cell_of(via.x, via.y)
|
||||||
if cell is None:
|
if cell is None:
|
||||||
continue
|
continue
|
||||||
i, j = cell
|
i, j = cell
|
||||||
present = [li for li, layer in enumerate(problem.layers)
|
span = [li for li, layer in enumerate(problem.layers)
|
||||||
if via.spans(layer.z_nm) and stack.masks[li, i, j]]
|
if via.spans(layer.z_nm)]
|
||||||
for la, lb in zip(present[:-1], present[1:]):
|
# 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
|
length = problem.layers[lb].z_nm - problem.layers[la].z_nm
|
||||||
if length <= 0:
|
if length <= 0:
|
||||||
continue
|
continue
|
||||||
r = via.barrel_resistance(length, problem.rho_ohm_m,
|
r = via.barrel_resistance(length, problem.rho_ohm_m,
|
||||||
problem.plating_nm) * via_factor
|
problem.plating_nm) * via_factor
|
||||||
aa.append(np.array([la * 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 + i * nx + j], dtype=np.int64))
|
bb.append(np.array([lb * plane + ib * nx + jb], dtype=np.int64))
|
||||||
ww.append(np.array([1.0 / r]))
|
ww.append(np.array([1.0 / r]))
|
||||||
vv.append(np.array([vi], dtype=np.int32))
|
vv.append(np.array([vi], dtype=np.int32))
|
||||||
|
|
||||||
if not aa:
|
if not aa:
|
||||||
raise ConnectivityError("No copper found on the selected layers.")
|
raise ConnectivityError("No copper found on the selected layers.")
|
||||||
return Edges(a=np.concatenate(aa), b=np.concatenate(bb),
|
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,
|
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
|
"""Keep only components (through-plane AND through-via) touching both
|
||||||
terminals. Mutates stack.masks / e1 / e2. Returns True if anything
|
terminals. Mutates stack.masks / e1 / e2. Returns (changed,
|
||||||
was dropped (caller must rebuild edges)."""
|
n_components): whether anything was dropped (caller must rebuild
|
||||||
|
edges) and how many disjoint copper groups survive."""
|
||||||
n = stack.masks.size
|
n = stack.masks.size
|
||||||
graph = sparse.coo_matrix(
|
graph = sparse.coo_matrix(
|
||||||
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(n, n))
|
(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
|
stack.masks &= keep
|
||||||
e1 &= keep
|
e1 &= keep
|
||||||
e2 &= keep
|
e2 &= keep
|
||||||
return changed
|
return changed, len(common)
|
||||||
|
|
||||||
|
|
||||||
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
|
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,
|
x, code = sla.cg(A, b, M=M, tol=config.CG_TOL,
|
||||||
maxiter=config.CG_MAXITER, callback=count)
|
maxiter=config.CG_MAXITER, callback=count)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise RuntimeError(
|
raise SolverError(
|
||||||
f"CG did not converge in {config.CG_MAXITER} iterations "
|
f"CG did not converge in {config.CG_MAXITER} iterations "
|
||||||
f"(code {code}). Try a coarser grid or raise CG_MAXITER."
|
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()
|
t0 = time.perf_counter()
|
||||||
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
|
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)
|
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:
|
if stack.buildup is not None:
|
||||||
stack.buildup &= stack.masks
|
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
|
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
|
timings["edges_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
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_vias = float(Pe[~inplane].sum())
|
||||||
P_total = i_test ** 2 * R
|
P_total = i_test ** 2 * R
|
||||||
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
|
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
|
# via reports: max segment current + total power per via
|
||||||
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) # amps at unit drive
|
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) # amps at unit drive
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
|||||||
from . import config, pipeline
|
from . import config, pipeline
|
||||||
from .errors import UserFacingError
|
from .errors import UserFacingError
|
||||||
from .geometry import load_problem
|
from .geometry import load_problem
|
||||||
|
from .skin import parse_frequency
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
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("dump", type=Path, help="geometry_dump.json from a plugin run")
|
||||||
ap.add_argument("--current", type=float, default=None,
|
ap.add_argument("--current", type=float, default=None,
|
||||||
help="test current [A] (default: config TEST_CURRENT_A)")
|
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). "
|
help="frequency, e.g. 142k or 1.5M (default: DC). "
|
||||||
"AC results are a lower bound (skin per foil only)")
|
"AC results are a lower bound (skin per foil only)")
|
||||||
ap.add_argument("--cell-um", type=float, default=None,
|
ap.add_argument("--cell-um", type=float, default=None,
|
||||||
@@ -64,11 +65,10 @@ def main(argv=None) -> int:
|
|||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
from .skin import parse_frequency
|
|
||||||
outdir = args.out if args.out is not None else args.dump.parent
|
outdir = args.out if args.out is not None else args.dump.parent
|
||||||
try:
|
try:
|
||||||
pipeline.run(problem, outdir, show=not args.no_show,
|
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)
|
contact_model=args.contact_model)
|
||||||
except UserFacingError as e:
|
except UserFacingError as e:
|
||||||
print(f"ERROR: {e}", file=sys.stderr)
|
print(f"ERROR: {e}", file=sys.stderr)
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://go.kicad.org/api/schemas/v1",
|
"$schema": "https://go.kicad.org/api/schemas/v1",
|
||||||
"identifier": "th.co.b4l.fill-resistance",
|
"identifier": "th.co.b4l.fill-resistance",
|
||||||
"name": "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": {
|
"runtime": {
|
||||||
"type": "python"
|
"type": "python"
|
||||||
},
|
},
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
{
|
{
|
||||||
"identifier": "fill-resistance-run",
|
"identifier": "fill-resistance-run",
|
||||||
"name": "Fill Resistance",
|
"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",
|
"entrypoint": "fill_res_action.py",
|
||||||
"show-button": true,
|
"show-button": true,
|
||||||
"scopes": ["pcb"],
|
"scopes": ["pcb"],
|
||||||
|
|||||||
+19
-2
@@ -3,9 +3,10 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from fill_resistance import raster, solver
|
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 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):
|
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)
|
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():
|
def test_touching_ok_uniform_error_equipotential():
|
||||||
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
|
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
|
||||||
rect1_mm=(0, 0, 5, 10), rect2_mm=(5, 0, 10, 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)], [])]],
|
[([(0, 0), (10, 0), (10, 1), (0, 1)], [])]],
|
||||||
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
|
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
|
||||||
contact1="L0", contact2="L1", vias_mm=[(5.5, 0.5)])
|
contact1="L0", contact2="L1", vias_mm=[(5.5, 0.5)])
|
||||||
|
p.vias[0].pad_nm = 600_000
|
||||||
f = tmp_path / "dump.json"
|
f = tmp_path / "dump.json"
|
||||||
save_problem(p, f)
|
save_problem(p, f)
|
||||||
q = load_problem(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.electrodes1[0].contact == "L0"
|
||||||
assert q.electrodes2[0].contact == "L1"
|
assert q.electrodes2[0].contact == "L1"
|
||||||
assert len(q.vias) == 1 and q.vias[0].drill_nm == p.vias[0].drill_nm
|
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 q.plating_nm == p.plating_nm
|
||||||
assert np.array_equal(q.layers[0].polygons[0].outline,
|
assert np.array_equal(q.layers[0].polygons[0].outline,
|
||||||
p.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():
|
def test_antipad_bridging():
|
||||||
"""3 layers; the middle layer has an antipad hole at the via cell, so
|
"""3 layers; the middle layer has an antipad hole at the via, WIDER
|
||||||
the barrel bridges L0 -> L2 directly with DOUBLE the length."""
|
than the barrel connection search (pad footprint + 1 cell), so the
|
||||||
mid_with_hole = [(STRIP, [[(5, 0), (6, 0), (6, 1), (5, 1)]])]
|
barrel bridges L0 -> L2 directly with DOUBLE the length."""
|
||||||
|
mid_with_hole = [(STRIP, [[(4, 0), (7, 0), (7, 1), (4, 1)]])]
|
||||||
p = make_multilayer(
|
p = make_multilayer(
|
||||||
[[(STRIP, [])], mid_with_hole, [(STRIP, [])]],
|
[[(STRIP, [])], mid_with_hole, [(STRIP, [])]],
|
||||||
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
|
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)
|
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():
|
def test_via_short_between_electrodes_raises():
|
||||||
"""Both electrodes over the SAME cell on different layers with a via
|
"""Both electrodes over the SAME cell on different layers with a via
|
||||||
there = direct short, no free copper -> error."""
|
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))
|
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():
|
def test_auto_cell_size_hits_target():
|
||||||
# large plane: unclamped regime, cell count tracks TARGET_CELLS
|
# large plane: unclamped regime, cell count tracks TARGET_CELLS
|
||||||
p = strip_problem(length=200, width=100, e_len=5)
|
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("2meg") == 2_000_000.0
|
||||||
assert skin.parse_frequency("100000") == 100_000.0
|
assert skin.parse_frequency("100000") == 100_000.0
|
||||||
assert skin.parse_frequency("100 kHz") == 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():
|
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)
|
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():
|
def test_power_identity():
|
||||||
"""Sum of edge powers equals I^2 R exactly for the direct solve."""
|
"""Sum of edge powers equals I^2 R exactly for the direct solve."""
|
||||||
p = make_problem(
|
p = make_problem(
|
||||||
|
|||||||
Reference in New Issue
Block a user