06c62e04f8
DC/AC resistance, power dissipation, and via/injection-area currents of copper zone fills. KiCad 10 IPC-API plugin (kicad-python/kipy): multi-layer via-coupled FDM solver, multi-part terminals via User.1/User.2 marker layers, pads as contacts, uniform-injection and equipotential contact models, per-foil skin effect, optional solder/copper buildup on mask openings. 54-case test suite incl. exact analytic references. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
194 lines
7.4 KiB
Python
194 lines
7.4 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
|
||
|
||
|
||
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.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.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)
|
||
lay.addWidget(buttons)
|
||
|
||
self._desired1, self._desired2 = contact1, contact2
|
||
self.net_box.currentTextChanged.connect(self._refresh)
|
||
self._refresh()
|
||
|
||
def _refresh(self):
|
||
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 selection(self) -> Selection | None:
|
||
layers = self.checked_layers()
|
||
if not layers:
|
||
return None
|
||
try:
|
||
current = float(self.current_edit.text().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:
|
||
cell = None
|
||
|
||
def contact(box: QComboBox) -> str:
|
||
t = box.currentText()
|
||
if t == AUTO_CONTACT:
|
||
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()),
|
||
contact_model=self.model_box.currentData(),
|
||
include_buildup=self.buildup_check.isChecked(),
|
||
extra_cu_um=max(0.0, extra_cu))
|
||
|
||
|
||
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()
|