a2a8a9d702
Two-point fluxes across coarse-fine faces miss the tangential potential gradient (offset leaf centers), biasing R ~0.5-2% low. After the first solve, per-leaf gradients are reconstructed by least squares over face neighbors and the known tangential term g*delta*Gt moves to the right-hand side of a re-solve (ADAPTIVE_CORRECTION_PASSES, default 1). The matrix is unchanged, so the new PreparedSolver reuses the LU factorization / AMG hierarchy across passes; the corrected currents satisfy KCL exactly, so the power-balance identity, via currents and part fluxes all use them consistently (edge power = dV * I_corr). Measured: strip worst case -1.74% -> -0.028% (1 pass); feature-dense plate end-to-end -1.1% -> -0.011% at 7.2 s vs 25.9 s uniform (5.58M -> 823k unknowns). Tests tightened accordingly plus a passes-knob test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
250 lines
9.8 KiB
Python
250 lines
9.8 KiB
Python
"""Qt selection dialog shown on plugin launch: net, layers, per-electrode
|
||
contact, test current, optional cell size. PySide6 is already a plugin
|
||
dependency (matplotlib QtAgg backend); the QApplication created here is
|
||
reused by matplotlib afterwards.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
from PySide6.QtCore import Qt
|
||
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog,
|
||
QDialogButtonBox, QFormLayout, QLabel,
|
||
QLineEdit, QListWidget, QListWidgetItem,
|
||
QVBoxLayout)
|
||
|
||
from . import config, skin
|
||
|
||
ALL_LAYERS = "All selected layers"
|
||
AUTO_CONTACT = "(auto: per contact part)"
|
||
MODEL_LABELS = {
|
||
"uniform": "Uniform injection (conductor pressed on top)",
|
||
"equipotential": "Equipotential (ideal bonded lug)",
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class Selection:
|
||
net: str
|
||
layers: list[str]
|
||
contact1: str # "auto", "all" or layer name
|
||
contact2: str
|
||
current_a: float
|
||
cell_um: float | None
|
||
freq_hz: float = 0.0
|
||
contact_model: str = "uniform"
|
||
include_buildup: bool = False
|
||
extra_cu_um: float = 0.0
|
||
include_tracks: bool = True
|
||
vias_capped: bool = True
|
||
adaptive: bool = False
|
||
|
||
|
||
class _Dialog(QDialog):
|
||
def __init__(self, candidates: dict[str, list[str]], layer_order: list[str],
|
||
default_net: str, e1_label: str, e2_label: str,
|
||
contact1: str, contact2: str, buildup_layers: list[str]):
|
||
super().__init__()
|
||
self.setWindowTitle("Fill Resistance")
|
||
self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
|
||
self._candidates = candidates
|
||
self._layer_order = layer_order
|
||
|
||
form = QFormLayout()
|
||
|
||
self.net_box = QComboBox()
|
||
for net in sorted(candidates):
|
||
self.net_box.addItem(net)
|
||
self.net_box.setCurrentText(default_net)
|
||
form.addRow("Signal (net):", self.net_box)
|
||
|
||
self.layer_list = QListWidget()
|
||
self.layer_list.setMaximumHeight(120)
|
||
form.addRow("Layers:", self.layer_list)
|
||
|
||
self.tracks_check = QCheckBox("include the net's traces "
|
||
"(tracks + arcs)")
|
||
self.tracks_check.setChecked(config.INCLUDE_TRACKS)
|
||
form.addRow("Conductors:", self.tracks_check)
|
||
|
||
self.capped_check = QCheckBox(
|
||
f"vias filled + capped ({config.CAP_PLATING_UM:g} µm cap; "
|
||
f"off = open mouths)")
|
||
self.capped_check.setChecked(config.VIAS_CAPPED)
|
||
form.addRow("Vias:", self.capped_check)
|
||
|
||
self.adaptive_check = QCheckBox(
|
||
"adaptive cells (coarsen plane interiors; faster on large "
|
||
"boards, corrected to ≲0.1 % of the uniform grid)")
|
||
self.adaptive_check.setChecked(config.ADAPTIVE_CELLS)
|
||
form.addRow("Grid:", self.adaptive_check)
|
||
|
||
self.contact1_box = QComboBox()
|
||
self.contact2_box = QComboBox()
|
||
form.addRow(f"V+ ({e1_label}):", self.contact1_box)
|
||
form.addRow(f"V− ({e2_label}):", self.contact2_box)
|
||
|
||
self.model_box = QComboBox()
|
||
for key in ("uniform", "equipotential"):
|
||
self.model_box.addItem(MODEL_LABELS[key], key)
|
||
default_index = 0 if config.CONTACT_MODEL == "uniform" else 1
|
||
self.model_box.setCurrentIndex(default_index)
|
||
form.addRow("Contact model:", self.model_box)
|
||
|
||
self.current_edit = QLineEdit(f"{config.TEST_CURRENT_A:g}")
|
||
form.addRow("Test current [A]:", self.current_edit)
|
||
|
||
self.freq_edit = QLineEdit("")
|
||
self.freq_edit.setPlaceholderText("0 = DC (e.g. 142k, 1.5M)")
|
||
form.addRow("Frequency [Hz]:", self.freq_edit)
|
||
|
||
self.cell_edit = QLineEdit("")
|
||
self.cell_edit.setPlaceholderText("auto")
|
||
form.addRow("Cell size [µm]:", self.cell_edit)
|
||
|
||
self.buildup_check = QCheckBox(
|
||
f"{config.SOLDER_THICKNESS_UM:g} µm solder on mask openings"
|
||
+ (f" ({', '.join(buildup_layers)})" if buildup_layers
|
||
else " (none found)"))
|
||
self.buildup_check.setChecked(bool(buildup_layers)
|
||
and config.INCLUDE_MASK_BUILDUP)
|
||
self.buildup_check.setEnabled(bool(buildup_layers))
|
||
form.addRow("Buildup:", self.buildup_check)
|
||
|
||
self.extracu_edit = QLineEdit(f"{config.BUILDUP_EXTRA_CU_UM:g}")
|
||
self.extracu_edit.setEnabled(bool(buildup_layers))
|
||
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
|
||
|
||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||
buttons.accepted.connect(self._try_accept)
|
||
buttons.rejected.connect(self.reject)
|
||
|
||
lay = QVBoxLayout(self)
|
||
lay.addLayout(form)
|
||
note = QLabel("Multiple layers are coupled through the net's "
|
||
"via/through-pad barrels. At f > 0 the foil-thickness "
|
||
"skin effect is applied per layer; lateral (proximity) "
|
||
"redistribution is not modeled, so AC results are a "
|
||
"lower bound.")
|
||
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, [])]
|
||
self.layer_list.clear()
|
||
for name in layers:
|
||
item = QListWidgetItem(name)
|
||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||
item.setCheckState(Qt.Checked)
|
||
self.layer_list.addItem(item)
|
||
for box, desired in ((self.contact1_box, self._desired1),
|
||
(self.contact2_box, self._desired2)):
|
||
box.clear()
|
||
box.addItem(AUTO_CONTACT)
|
||
box.addItem(ALL_LAYERS)
|
||
box.addItems(layers)
|
||
if desired == "all":
|
||
box.setCurrentText(ALL_LAYERS)
|
||
elif desired in layers:
|
||
box.setCurrentText(desired)
|
||
|
||
def checked_layers(self) -> list[str]:
|
||
out = []
|
||
for i in range(self.layer_list.count()):
|
||
item = self.layer_list.item(i)
|
||
if item.checkState() == Qt.Checked:
|
||
out.append(item.text())
|
||
return out
|
||
|
||
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:
|
||
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:
|
||
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:
|
||
t = box.currentText()
|
||
if t == AUTO_CONTACT:
|
||
return "auto"
|
||
return "all" if t == ALL_LAYERS else t
|
||
|
||
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=freq,
|
||
contact_model=self.model_box.currentData(),
|
||
include_buildup=self.buildup_check.isChecked(),
|
||
extra_cu_um=extra_cu,
|
||
include_tracks=self.tracks_check.isChecked(),
|
||
vias_capped=self.capped_check.isChecked(),
|
||
adaptive=self.adaptive_check.isChecked())
|
||
|
||
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],
|
||
default_net: str, e1_label: str, e2_label: str,
|
||
contact1: str, contact2: str,
|
||
buildup_layers: list[str] | None = None) -> Selection | None:
|
||
"""Show the dialog; returns None on cancel."""
|
||
app = QApplication.instance() or QApplication([])
|
||
dlg = _Dialog(candidates, layer_order, default_net, e1_label, e2_label,
|
||
contact1, contact2, buildup_layers or [])
|
||
dlg.raise_()
|
||
dlg.activateWindow()
|
||
if dlg.exec() != QDialog.Accepted:
|
||
return None
|
||
return dlg._selection
|