Initial import: KiCad zone resistance plugin

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>
This commit is contained in:
janik
2026-07-14 17:22:00 +07:00
commit 06c62e04f8
33 changed files with 4000 additions and 0 deletions
View File
+480
View File
@@ -0,0 +1,480 @@
"""All KiCad IPC access. This is the ONLY module that imports kipy;
everything downstream works on plain geometry dataclasses.
Run `python -m fill_resistance.board_io dump.json [net]` against a live
KiCad to extract without the dialog (all layers of the net, defaults).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from kipy import KiCad
from kipy.board import Board
from kipy.board_types import BoardRectangle, Pad
from kipy.proto.board.board_pb2 import BoardStackupLayerType
from kipy.proto.board.board_types_pb2 import ZoneType
from kipy.util.board_layer import (canonical_name, is_copper_layer,
layer_from_canonical_name)
from . import config
from .errors import ApiVersionError, CandidateError, SelectionError
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
SurfaceBuildup, ViaLink, linearize_ring)
MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"}
# zone fills are polygonal in practice; tolerance only guards arc nodes
ARC_TOL_NM = 10_000
def connect() -> tuple[KiCad, Board]:
try:
kicad = KiCad()
kicad.ping()
except Exception as e:
raise ApiVersionError(
f"Could not connect to KiCad's IPC API: {e}\n"
f"Is KiCad running with the API server enabled "
f"(Preferences > Plugins > Enable KiCad API)?"
)
try:
print(f"connected to KiCad {kicad.get_version()}")
except Exception:
pass
try:
board = kicad.get_board()
except Exception as e:
raise SelectionError(
f"Could not get the open board from KiCad: {e}\n"
f"Open the PCB in the board editor and run again."
)
return kicad, board
def board_dir(board: Board) -> Path:
# document.board_filename is a bare file name (no directory) in
# KiCad 10.0.1; the project path is the reliable location
try:
path = board.get_project().path
if path and Path(path).is_dir():
return Path(path)
except Exception:
pass
try:
filename = getattr(board.document, "board_filename", "") or ""
if Path(filename).is_absolute():
return Path(filename).parent
except Exception:
pass
return Path.cwd()
# --- stackup geometry --------------------------------------------------------
@dataclass
class StackupInfo:
names: list[str] # copper layers, top to bottom
thickness_nm: dict[str, int]
z_nm: dict[str, int] # copper center depth
z_bot_nm: int # total stack thickness
def get_stackup_info(board: Board) -> StackupInfo:
names: list[str] = []
thickness: dict[str, int] = {}
z_center: dict[str, int] = {}
z = 0
for sl in board.get_stackup().layers:
t = int(sl.thickness or 0)
if sl.type == BoardStackupLayerType.BSLT_COPPER:
name = canonical_name(sl.layer)
if t <= 0:
t = int(config.FALLBACK_THICKNESS_UM * 1000)
print(f"warning: stackup gives no thickness for {name}; "
f"assuming {config.FALLBACK_THICKNESS_UM} um")
names.append(name)
thickness[name] = t
z_center[name] = z + t // 2
z += t
if not names:
raise CandidateError(
"Could not read any copper layer from the board stackup."
)
return StackupInfo(names=names, thickness_nm=thickness, z_nm=z_center,
z_bot_nm=z)
# --- electrodes from selection ----------------------------------------------
def _box2_to_rect(box, layer_name: str) -> Rect:
try:
pos, size = box.pos, box.size
return Rect.normalized(pos.x, pos.y, pos.x + size.x, pos.y + size.y,
layer_name)
except AttributeError:
c, s = box.center, box.size
return Rect.normalized(c.x - s.x // 2, c.y - s.y // 2,
c.x + s.x // 2, c.y + s.y // 2, layer_name)
def _convert_poly(poly_with_holes) -> Polygon:
def ring(polyline):
nodes = []
for node in polyline.nodes:
if node.has_point:
nodes.append(("pt", (node.point.x, node.point.y)))
elif node.has_arc:
arc = node.arc
nodes.append(("arc", ((arc.start.x, arc.start.y),
(arc.mid.x, arc.mid.y),
(arc.end.x, arc.end.y))))
return linearize_ring(nodes, ARC_TOL_NM)
return Polygon(outline=ring(poly_with_holes.outline),
holes=[ring(h) for h in poly_with_holes.holes])
def _pad_drill_nm(pad_or_via) -> int:
try:
return int(pad_or_via.padstack.drill.diameter.x)
except Exception:
return 0
def _pad_default_contact(pad: Pad) -> str:
if _pad_drill_nm(pad) > 0:
return "all" # through-hole: contacts the stack
try:
copper = [canonical_name(l) for l in pad.padstack.layers
if is_copper_layer(l)]
if len(copper) == 1:
return copper[0] # SMD: its own layer
except Exception:
pass
return "all"
def _pad_polygons(board: Board, pad: Pad, contact: str) -> list[Polygon] | None:
layer_ids = []
if contact != "all":
try:
layer_ids.append(layer_from_canonical_name(contact))
except Exception:
pass
for name in ("F.Cu", "B.Cu"):
try:
layer_ids.append(layer_from_canonical_name(name))
except Exception:
pass
for lid in layer_ids:
try:
shape = board.get_pad_shapes_as_polygons(pad, layer=lid)
if shape is not None:
return [_convert_poly(shape)]
except Exception:
continue
return None
def _to_electrode(board: Board, item) -> Electrode:
if isinstance(item, BoardRectangle):
tl, br = item.top_left, item.bottom_right
rect = Rect.normalized(tl.x, tl.y, br.x, br.y,
canonical_name(item.layer))
cx = (rect.x0 + rect.x1) / 2e6
cy = (rect.y0 + rect.y1) / 2e6
return Electrode(rect=rect, contact="all",
label=f"rect({cx:.1f},{cy:.1f})")
# Pad
pad: Pad = item
contact = _pad_default_contact(pad)
net = pad.net.name if pad.net is not None else "?"
label = f"pad {pad.number}@{net}"
box = board.get_item_bounding_box(pad)
if box is None:
raise SelectionError(f"Could not get the bounding box of {label}.")
rect = _box2_to_rect(box, "pad")
return Electrode(rect=rect, contact=contact,
polygons=_pad_polygons(board, pad, contact), label=label)
def _net_hint_of(pads: list[Pad]) -> str | None:
for pad in pads:
if pad.net is not None:
return pad.net.name
return None
def get_electrodes(board: Board
) -> tuple[list[Electrode], list[Electrode], str | None]:
"""Terminals from the selection. Each terminal may have MULTIPLE parts
(all merged into one externally-bonded contact):
- rectangles on ELECTRODE_POS_LAYER -> V+ parts, on ELECTRODE_NEG_LAYER
-> V- parts; selected pads fill a side that has no rectangles;
- no marker rectangles selected: legacy mode, exactly 2 items
(rects/pads, any layer) -> one part each;
- empty selection: board-wide scan of both marker layers.
"""
pos_l = config.ELECTRODE_POS_LAYER
neg_l = config.ELECTRODE_NEG_LAYER
scheme = (f"Draw V+ rectangle(s) on {pos_l} and V- rectangle(s) on "
f"{neg_l} (axis-aligned), and/or select pads for a side "
f"without rectangles.")
selection = list(board.get_selection())
rects = [s for s in selection if isinstance(s, BoardRectangle)]
pads = [s for s in selection if isinstance(s, Pad)]
if not selection:
allr = [s for s in board.get_shapes() if isinstance(s, BoardRectangle)]
pos = [r for r in allr if canonical_name(r.layer) == pos_l]
neg = [r for r in allr if canonical_name(r.layer) == neg_l]
if pos and neg:
print(f"selection empty - using {len(pos)} rectangle(s) on "
f"{pos_l} as V+ and {len(neg)} on {neg_l} as V-")
return ([_to_electrode(board, r) for r in pos],
[_to_electrode(board, r) for r in neg], None)
raise SelectionError(
f"Nothing selected, and the board-wide scan found "
f"{len(pos)} rectangle(s) on {pos_l} / {len(neg)} on {neg_l} "
f"(need at least one on each).\n{scheme}"
)
pos = [r for r in rects if canonical_name(r.layer) == pos_l]
neg = [r for r in rects if canonical_name(r.layer) == neg_l]
other = [r for r in rects if canonical_name(r.layer) not in (pos_l, neg_l)]
if pos or neg:
if other:
raise SelectionError(
f"{len(other)} selected rectangle(s) are on neither marker "
f"layer ({pos_l} = V+, {neg_l} = V-). {scheme}"
)
es1 = [_to_electrode(board, r) for r in pos]
es2 = [_to_electrode(board, r) for r in neg]
if pads and es1 and es2:
raise SelectionError(
f"Cannot assign the {len(pads)} selected pad(s): both marker "
f"layers already provide rectangles. Use pads only for a "
f"side that has none."
)
if pads:
pad_parts = [_to_electrode(board, p) for p in pads]
if not es1:
es1 = pad_parts
else:
es2 = pad_parts
if es1 and es2:
return es1, es2, _net_hint_of(pads)
raise SelectionError(
f"Only one terminal defined: V+ has {len(es1)} and V- has "
f"{len(es2)} contact(s). {scheme}"
)
items = rects + pads
if len(items) == 2:
return ([_to_electrode(board, items[0])],
[_to_electrode(board, items[1])], _net_hint_of(pads))
raise SelectionError(
f"The selection has {len(rects)} rectangle(s) (none on the marker "
f"layers) and {len(pads)} pad(s); without marker layers exactly 2 "
f"contacts are needed.\n{scheme}"
)
# --- fills -------------------------------------------------------------------
def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]:
"""net -> layer_name -> merged fill polygons (non-empty only)."""
fills: dict[str, dict[str, list[Polygon]]] = {}
for zone in board.get_zones():
if zone.type != ZoneType.ZT_COPPER:
continue
net = zone.net.name if zone.net is not None else "<no net>"
for layer, polys in zone.filled_polygons.items():
if not is_copper_layer(layer) or not polys:
continue
fills.setdefault(net, {}).setdefault(
canonical_name(layer), []).extend(
_convert_poly(p) for p in polys)
return fills
def _rect_overlaps(rect: Rect, polygons: list[Polygon]) -> bool:
for p in polygons:
px0, py0 = p.outline.min(axis=0)
px1, py1 = p.outline.max(axis=0)
if rect.x0 <= px1 and rect.x1 >= px0 and rect.y0 <= py1 and rect.y1 >= py0:
return True
return False
def nets_overlapping(fills: dict, es1: list[Electrode],
es2: list[Electrode]) -> list[str]:
"""Nets whose fills overlap both terminals (any part, any layer each -
the connection may go through vias). Permissive bbox prefilter."""
out = []
for net, per_layer in fills.items():
hit1 = any(_rect_overlaps(e.rect, polys) for e in es1
for polys in per_layer.values())
hit2 = any(_rect_overlaps(e.rect, polys) for e in es2
for polys in per_layer.values())
if hit1 and hit2:
out.append(net)
return sorted(out)
def gather_mask_buildups(board: Board) -> dict[str, list[Polygon]]:
"""Zones on F.Mask/B.Mask (mask openings) -> fill polygons keyed by
the outer copper layer they expose."""
out: dict[str, list[Polygon]] = {}
for zone in board.get_zones():
try:
filled = zone.filled_polygons
except Exception:
continue
for layer, polys in filled.items():
copper = MASK_TO_COPPER.get(canonical_name(layer))
if copper and polys:
out.setdefault(copper, []).extend(
_convert_poly(p) for p in polys)
return out
def any_zone_unfilled(board: Board) -> bool:
return any(z.type == ZoneType.ZT_COPPER and not z.filled
for z in board.get_zones())
def refill(board: Board) -> None:
print("refilling zones - this modifies the open document ...")
board.refill_zones(block=True)
# --- barrels -----------------------------------------------------------------
def _padstack_span(padstack, stackup: StackupInfo) -> tuple[int, int]:
"""(z_top, z_bot) of the barrel; falls back to the full stack."""
try:
copper = [canonical_name(l) for l in padstack.layers
if is_copper_layer(l)]
zs = [stackup.z_nm[c] for c in copper if c in stackup.z_nm]
if len(zs) >= 2:
return min(zs) - 1, max(zs) + 1
except Exception:
pass
return -1, stackup.z_bot_nm + 1
def gather_barrels(board: Board, net_name: str,
stackup: StackupInfo) -> list[ViaLink]:
barrels = []
for via in board.get_vias():
if via.net is None or via.net.name != net_name:
continue
drill = int(via.drill_diameter or 0) or _pad_drill_nm(via)
if drill <= 0:
continue
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"))
if config.INCLUDE_TH_PADS:
for pad in board.get_pads():
if pad.net is None or pad.net.name != net_name:
continue
drill = _pad_drill_nm(pad)
if drill <= 0:
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"))
return barrels
# --- top level ----------------------------------------------------------------
def build_problem(board: Board, net: str, layer_names: list[str],
es1: list[Electrode], es2: list[Electrode],
stackup: StackupInfo, fills: dict,
buildups: dict[str, list[Polygon]] | None = None,
extra_cu_um: float | None = None) -> Problem:
per_layer = fills.get(net, {})
layers = []
for name in stackup.names: # keep stackup order
if name not in layer_names:
continue
polys = per_layer.get(name, [])
if not polys:
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"
else:
t, source = stackup.thickness_nm[name], "stackup"
layers.append(LayerFill(layer_name=name, thickness_nm=t,
z_nm=stackup.z_nm[name], polygons=polys))
if not layers:
raise CandidateError(
f"Net {net} has no fill on any of the selected layers "
f"({', '.join(layer_names)})."
)
vias = gather_barrels(board, net, stackup) if len(layers) > 1 else []
included = {l.layer_name for l in layers}
buildup_list = [
SurfaceBuildup(layer_name=name, polygons=polys)
for name, polys in (buildups or {}).items() if name in included
]
print(f"net {net}: {len(layers)} layer(s) "
f"({', '.join(l.layer_name for l in layers)}), "
f"{len(vias)} via/pad barrel(s)"
+ (f", solder buildup on "
f"{', '.join(b.layer_name for b in buildup_list)}"
if buildup_list else ""))
return Problem(
board_path=board.name or "",
net_name=net,
rho_ohm_m=config.RHO_CU_OHM_M,
plating_nm=int(config.VIA_PLATING_UM * 1000),
layers=layers,
vias=vias,
electrodes1=es1,
electrodes2=es2,
thickness_source=("override" if config.COPPER_THICKNESS_UM is not None
else "stackup"),
buildups=buildup_list,
solder_thickness_nm=int(config.SOLDER_THICKNESS_UM * 1000),
solder_rho_ohm_m=config.SOLDER_RHO_OHM_M,
extra_cu_nm=int((extra_cu_um if extra_cu_um is not None
else config.BUILDUP_EXTRA_CU_UM) * 1000),
)
if __name__ == "__main__":
import sys
from .geometry import save_problem
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("geometry_dump.json")
_, board = connect()
stackup = get_stackup_info(board)
es1, es2, net_hint = get_electrodes(board)
if any_zone_unfilled(board):
refill(board)
fills = gather_net_fills(board)
nets = nets_overlapping(fills, es1, es2)
if len(sys.argv) > 2:
net = sys.argv[2]
elif net_hint in nets:
net = net_hint
elif len(nets) == 1:
net = nets[0]
else:
print(f"candidate nets: {nets}; pass one as second argument")
sys.exit(1)
problem = build_problem(board, net, list(fills.get(net, {})), es1, es2,
stackup, fills)
save_problem(problem, out)
print(f"wrote {out}")
+72
View File
@@ -0,0 +1,72 @@
"""All tunable constants. v1 has no GUI dialog: edit here, re-run.
A future version may read overrides from <project>/fill_res_config.json.
"""
# --- Grid sizing ---
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
# 150 um cells down to 50 um; 1.7M unknowns direct-solve in ~17 s
# (raster ~20 s). Accuracy is feature-limited (slots/necks narrower than
# one cell), not plane-limited - override CELL_UM_OVERRIDE for boards
# with sub-cell slots.
TARGET_CELLS = 2_000_000 # auto cell size aims for roughly this many cells
HARD_MAX_CELLS = 16_000_000 # abort above this (see GridSizeError message)
MIN_CELL_UM = 25.0 # clamp for auto cell size
MAX_CELL_UM = 500.0
CELL_UM_OVERRIDE: float | None = None # force a cell size, bypasses auto (not HARD_MAX)
MARGIN_CELLS = 2 # empty guard cells around the copper bbox
# --- Physics ---
RHO_CU_OHM_M = 1.68e-8 # copper resistivity at 20 degC
COPPER_THICKNESS_UM: float | None = None # None -> stackup, fallback 35.0 with warning
FALLBACK_THICKNESS_UM = 35.0
TEST_CURRENT_A = 1.0 # default injected current (dialog/CLI-selectable)
VIA_PLATING_UM = 18.0 # barrel plating thickness (always plated).
# Capped vs uncapped vias do not change the
# layer-to-layer DC path: the >=5um cap sits
# over the hole mouth in parallel with the
# annular-ring contact, not in series.
INCLUDE_TH_PADS = True # plated through-hole pads stitch layers too
SKIN_SIDES = 1 # skin-effect field config: 1 = plane facing a
# return plane (conservative), 2 = isolated foil
# --- Solder / mask-opening buildup ---
INCLUDE_MASK_BUILDUP = False # OFF by default; dialog-toggleable. Zones on
# F.Mask/B.Mask = mask openings that collect
# solder on the pour underneath
SOLDER_THICKNESS_UM = 50.0 # solder height over opened copper
SOLDER_RHO_OHM_M = 1.32e-7 # SAC305, ~7.9x copper
BUILDUP_EXTRA_CU_UM = 0.0 # optional user-added copper (busbar/wire
# soldered into the opening); dialog-settable
# --- Zone / layer selection ---
LAYER_HINT: str | None = None # e.g. "F.Cu" to disambiguate candidate fills
ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
ALWAYS_REFILL = False # refill zones even if KiCad says they are filled
# --- Solver ---
CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects
# orthogonally with uniform surface density
# (J ramps across the contact); "equipotential":
# ideal bonded lug (Dirichlet). The two bracket
# a real contact: R_equi <= R_real <= R_uniform.
SPSOLVE_MAX_UNKNOWNS = 2_500_000 # above this, use CG (Jacobi) instead of
# direct solve (measured: direct is ~14x
# faster at 1.7M unknowns, ~3 GB peak)
CG_TOL = 1e-8
CG_MAXITER = 50_000 # CG iterations are cheap; large grids need many
# --- Geometry ---
ARC_TOL_FRACTION = 0.5 # arc sagitta tolerance as a fraction of cell size
# --- Plots / output ---
CMAP_POTENTIAL = "viridis"
CMAP_CURRENT = "inferno"
CMAP_POWER = "magma"
POWER_DYNAMIC_RANGE = 1e4 # LogNorm span for the power map (power ~ J^2)
LOG_CURRENT_SCALE = True
CURRENT_DYNAMIC_RANGE = 1e3 # LogNorm vmin = vmax / this
DPI = 150
INTERACTIVE = True # False -> save PNGs only, never open windows
OUTPUT_DIRNAME = "fill_res_results"
+193
View File
@@ -0,0 +1,193 @@
"""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()
+34
View File
@@ -0,0 +1,34 @@
"""User-facing error hierarchy.
Every UserFacingError message is shown both on stdout (KiCad status bar)
and in a matplotlib error figure, so keep messages self-contained and
actionable.
"""
class UserFacingError(Exception):
pass
class ApiVersionError(UserFacingError):
pass
class SelectionError(UserFacingError):
pass
class CandidateError(UserFacingError):
pass
class ElectrodeError(UserFacingError):
pass
class ConnectivityError(UserFacingError):
pass
class GridSizeError(UserFacingError):
pass
+317
View File
@@ -0,0 +1,317 @@
"""Plain geometry data model. No kipy imports here.
Everything is int64 nanometers in KiCad board coordinates (y grows down);
z grows from the board top surface downwards through the stackup.
Problem is the complete solver input and doubles as the JSON dump schema,
so the whole pipeline downstream of board_io runs without KiCad.
Schema v2 is multi-layer: per-layer fills at stackup depths, linked by
via/through-pad barrels. v1 dumps (single layer, no vias) still load.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
JSON_SCHEMA_VERSION = 4
@dataclass(frozen=True)
class Rect:
x0: int
y0: int
x1: int
y1: int
layer_name: str
@classmethod
def normalized(cls, xa: int, ya: int, xb: int, yb: int, layer_name: str) -> "Rect":
return cls(min(xa, xb), min(ya, yb), max(xa, xb), max(ya, yb), layer_name)
@property
def width(self) -> int:
return self.x1 - self.x0
@property
def height(self) -> int:
return self.y1 - self.y0
@dataclass
class Polygon:
outline: np.ndarray # (N, 2) int64 nm, open ring
holes: list[np.ndarray] = field(default_factory=list)
@dataclass
class LayerFill:
layer_name: str
thickness_nm: int
z_nm: int # copper center depth from board top
polygons: list[Polygon]
@dataclass
class SurfaceBuildup:
"""Solder (plus optional added copper) sitting on an outer copper
layer inside solder-mask openings (zones on F.Mask/B.Mask)."""
layer_name: str # copper layer it sits on
polygons: list[Polygon]
@dataclass
class Electrode:
"""One PART of a current-injection terminal: a drawn rectangle or a
selected pad. A terminal (V+ or V-) is a LIST of parts, all merged
into one equipotential contact (externally bonded). `polygons`
(board nm) is the exact copper shape when known (pads); None means
the rectangle itself is the shape. `contact` = 'all' or a layer
name: which included layers this part touches."""
rect: Rect # bounding box (labels/summary)
contact: str = "all"
polygons: list[Polygon] | None = None
label: str = "rect"
@dataclass
class ViaLink:
"""A conductive barrel (via or plated through-hole pad) linking copper
layers whose z lies within [z_top_nm, z_bot_nm]."""
x: int
y: int
drill_nm: int
z_top_nm: int
z_bot_nm: int
kind: str = "via" # "via" | "pad"
def spans(self, z_nm: int) -> bool:
return self.z_top_nm - 1 <= z_nm <= self.z_bot_nm + 1
def barrel_resistance(self, length_nm: int, rho_ohm_m: float,
plating_nm: int) -> float:
"""Barrel segment resistance over length_nm: thin-wall annulus of
plating around the drill."""
area_m2 = math.pi * (self.drill_nm * 1e-9) * (plating_nm * 1e-9)
return rho_ohm_m * (length_nm * 1e-9) / area_m2
@dataclass
class Problem:
board_path: str
net_name: str
rho_ohm_m: float
plating_nm: int
layers: list[LayerFill] # sorted by z_nm (top first)
vias: list[ViaLink]
electrodes1: list[Electrode] # V+ terminal parts (merged)
electrodes2: list[Electrode] # V- terminal parts (merged)
thickness_source: str = "stackup"
buildups: list[SurfaceBuildup] = field(default_factory=list)
solder_thickness_nm: int = 50_000
solder_rho_ohm_m: float = 1.32e-7
extra_cu_nm: int = 0
@property
def layer_names(self) -> list[str]:
return [l.layer_name for l in self.layers]
def sigma_s(self, layer_index: int) -> float:
"""Sheet conductance of one layer [S per square]."""
return (self.layers[layer_index].thickness_nm * 1e-9) / self.rho_ohm_m
def copper_bbox(self) -> tuple[int, int, int, int]:
xs = np.concatenate([p.outline[:, 0]
for l in self.layers for p in l.polygons])
ys = np.concatenate([p.outline[:, 1]
for l in self.layers for p in l.polygons])
return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
def arc_points(start, mid, end, tol_nm: float) -> np.ndarray:
"""Tessellate a start/mid/end arc into points from start (inclusive)
to end (exclusive), max sagitta <= tol_nm. Collinear input degrades
to just the start point (straight segment)."""
sx, sy = float(start[0]), float(start[1])
mx, my = float(mid[0]), float(mid[1])
ex, ey = float(end[0]), float(end[1])
d = 2.0 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my))
chord = math.hypot(ex - sx, ey - sy)
if abs(d) < 1e-9 * max(chord, 1.0):
return np.array([[start[0], start[1]]], dtype=np.int64)
ux = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy)
+ (ex**2 + ey**2) * (sy - my)) / d
uy = ((sx**2 + sy**2) * (ex - mx) + (mx**2 + my**2) * (sx - ex)
+ (ex**2 + ey**2) * (mx - sx)) / d
r = math.hypot(sx - ux, sy - uy)
a0 = math.atan2(sy - uy, sx - ux)
a1 = math.atan2(my - uy, mx - ux)
a2 = math.atan2(ey - uy, ex - ux)
two_pi = 2.0 * math.pi
d01 = (a1 - a0) % two_pi
d02 = (a2 - a0) % two_pi
sweep = d02 if d01 <= d02 else d02 - two_pi
tol = min(tol_nm, 0.999 * r)
dtheta_max = 2.0 * math.acos(1.0 - tol / r)
n = max(2, int(math.ceil(abs(sweep) / dtheta_max)))
ks = np.arange(n)
angs = a0 + sweep * ks / n
pts = np.stack([ux + r * np.cos(angs), uy + r * np.sin(angs)], axis=1)
return np.round(pts).astype(np.int64)
def linearize_ring(nodes: list, tol_nm: float) -> np.ndarray:
"""nodes: list of ('pt', (x, y)) or ('arc', (start, mid, end)) tuples,
already in board nm. Returns an (N, 2) int64 open ring."""
parts = []
for kind, data in nodes:
if kind == "pt":
parts.append(np.array([[data[0], data[1]]], dtype=np.int64))
elif kind == "arc":
parts.append(arc_points(data[0], data[1], data[2], tol_nm))
else:
raise ValueError(f"unknown polyline node kind: {kind}")
ring = np.concatenate(parts, axis=0)
if len(ring) > 1 and (ring[0] == ring[-1]).all():
ring = ring[:-1]
return ring
# --- JSON dump / load -------------------------------------------------------
def _poly_to_json(p: Polygon) -> dict:
return {"outline": p.outline.tolist(), "holes": [h.tolist() for h in p.holes]}
def _poly_from_json(d: dict) -> Polygon:
return Polygon(outline=np.asarray(d["outline"], dtype=np.int64),
holes=[np.asarray(h, dtype=np.int64) for h in d["holes"]])
def _electrode_to_json(e: Electrode) -> dict:
return {
"rect": vars(e.rect) | {},
"contact": e.contact,
"label": e.label,
"polygons": (None if e.polygons is None
else [_poly_to_json(poly) for poly in e.polygons]),
}
def _electrode_from_json(d: dict) -> Electrode:
return Electrode(
rect=_rect_from_json(d["rect"]),
contact=d.get("contact", "all"),
label=d.get("label", "rect"),
polygons=(None if d.get("polygons") is None
else [_poly_from_json(pd) for pd in d["polygons"]]),
)
def problem_to_json(p: Problem) -> dict:
return {
"schema_version": JSON_SCHEMA_VERSION,
"board_path": p.board_path,
"net_name": p.net_name,
"rho_ohm_m": p.rho_ohm_m,
"plating_nm": p.plating_nm,
"thickness_source": p.thickness_source,
"electrodes1": [_electrode_to_json(e) for e in p.electrodes1],
"electrodes2": [_electrode_to_json(e) for e in p.electrodes2],
"layers": [
{
"layer_name": l.layer_name,
"thickness_nm": l.thickness_nm,
"z_nm": l.z_nm,
"polygons": [_poly_to_json(poly) for poly in l.polygons],
}
for l in p.layers
],
"vias": [vars(v) | {} for v in p.vias],
"buildups": [
{"layer_name": b.layer_name,
"polygons": [_poly_to_json(poly) for poly in b.polygons]}
for b in p.buildups
],
"solder_thickness_nm": p.solder_thickness_nm,
"solder_rho_ohm_m": p.solder_rho_ohm_m,
"extra_cu_nm": p.extra_cu_nm,
}
def _rect_from_json(rd: dict) -> Rect:
return Rect(int(rd["x0"]), int(rd["y0"]), int(rd["x1"]), int(rd["y1"]),
rd["layer_name"])
def problem_from_json(d: dict) -> Problem:
version = d.get("schema_version", 1)
if version == 1:
# v1: single layer, no vias, rect electrodes
return Problem(
board_path=d["board_path"],
net_name=d["net_name"],
rho_ohm_m=float(d["rho_ohm_m"]),
plating_nm=18_000,
layers=[LayerFill(
layer_name=d["layer_name"],
thickness_nm=int(d["thickness_nm"]),
z_nm=0,
polygons=[_poly_from_json(pd) for pd in d["polygons"]],
)],
vias=[],
electrodes1=[Electrode(rect=_rect_from_json(d["rect1"]))],
electrodes2=[Electrode(rect=_rect_from_json(d["rect2"]))],
thickness_source=d.get("thickness_source", "unknown"),
)
return Problem(
board_path=d["board_path"],
net_name=d["net_name"],
rho_ohm_m=float(d["rho_ohm_m"]),
plating_nm=int(d["plating_nm"]),
layers=[
LayerFill(
layer_name=ld["layer_name"],
thickness_nm=int(ld["thickness_nm"]),
z_nm=int(ld["z_nm"]),
polygons=[_poly_from_json(pd) for pd in ld["polygons"]],
)
for ld in d["layers"]
],
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"))
for vd in d["vias"]
],
electrodes1=(
[_electrode_from_json(ed) for ed in d["electrodes1"]]
if version >= 3 else [_electrode_from_json(d["electrode1"])]),
electrodes2=(
[_electrode_from_json(ed) for ed in d["electrodes2"]]
if version >= 3 else [_electrode_from_json(d["electrode2"])]),
thickness_source=d.get("thickness_source", "unknown"),
buildups=[
SurfaceBuildup(
layer_name=bd["layer_name"],
polygons=[_poly_from_json(pd) for pd in bd["polygons"]])
for bd in d.get("buildups", [])
],
solder_thickness_nm=int(d.get("solder_thickness_nm", 50_000)),
solder_rho_ohm_m=float(d.get("solder_rho_ohm_m", 1.32e-7)),
extra_cu_nm=int(d.get("extra_cu_nm", 0)),
)
def save_problem(p: Problem, path: Path) -> None:
path.write_text(json.dumps(problem_to_json(p)), encoding="utf-8")
def load_problem(path: Path) -> Problem:
return problem_from_json(json.loads(Path(path).read_text(encoding="utf-8")))
+108
View File
@@ -0,0 +1,108 @@
"""Top-level orchestration for the KiCad-launched action.
Flow: connect -> read the two selected contacts (rectangles/pads) ->
gather fills -> selection dialog (net, layers, contacts, current, cell)
-> extract vias -> solve -> figures + report.
Every failure is reported twice: on stdout (lands in the KiCad status-bar
warning list) and as a matplotlib error figure, so it cannot be missed.
"""
from __future__ import annotations
import sys
import traceback
from . import config, pipeline, report
from .errors import CandidateError, UserFacingError
def _fail(message: str, outdir) -> None:
print(f"ERROR: {message}")
from . import plots
fig = plots.fig_error(message)
plots.save_and_show([(fig, "error")], outdir)
sys.exit(1)
def main() -> None:
outdir = None
try:
from kipy.errors import ApiError
from . import board_io, dialog
try:
kicad, board = board_io.connect()
stackup = board_io.get_stackup_info(board)
es1, es2, net_hint = board_io.get_electrodes(board)
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
board_io.refill(board)
fills = board_io.gather_net_fills(board)
candidate_nets = board_io.nets_overlapping(fills, es1, es2)
buildups = board_io.gather_mask_buildups(board)
except ApiError as e:
raise UserFacingError(
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
f"it and run again."
)
if not candidate_nets:
raise CandidateError(
"No copper zone fill overlaps both contacts. Check that both "
"sit over (or in) filled pours and that the fills are up to "
"date (press B in the board editor)."
)
def group_label(parts):
names = [p.label for p in parts[:3]]
more = f" +{len(parts) - 3}" if len(parts) > 3 else ""
return f"{len(parts)}× " + ", ".join(names) + more
def group_contact(parts):
contacts = {p.contact for p in parts}
return contacts.pop() if len(contacts) == 1 else "auto"
default_net = (net_hint if net_hint in candidate_nets
else candidate_nets[0])
selection = dialog.ask(
candidates={n: list(fills[n].keys()) for n in candidate_nets},
layer_order=stackup.names,
default_net=default_net,
e1_label=group_label(es1), e2_label=group_label(es2),
contact1=group_contact(es1), contact2=group_contact(es2),
buildup_layers=sorted(buildups.keys()),
)
if selection is None:
print("cancelled")
return
if selection.contact1 != "auto":
for e in es1:
e.contact = selection.contact1
if selection.contact2 != "auto":
for e in es2:
e.contact = selection.contact2
if selection.cell_um is not None:
config.CELL_UM_OVERRIDE = selection.cell_um
try:
problem = board_io.build_problem(
board, selection.net, selection.layers, es1, es2, stackup,
fills,
buildups=(buildups if selection.include_buildup else None),
extra_cu_um=selection.extra_cu_um)
outdir = report.make_output_dir(board_io.board_dir(board))
except ApiError as e:
raise UserFacingError(f"KiCad API error: {e}")
report.write_geometry_dump(outdir, problem)
pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
freq_hz=selection.freq_hz,
contact_model=selection.contact_model)
except UserFacingError as e:
_fail(str(e), outdir)
except Exception:
_fail(traceback.format_exc(), outdir)
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
"""Geometry-in -> results-out pipeline shared by the KiCad entrypoint and
the offline standalone runner."""
from __future__ import annotations
from pathlib import Path
from . import config, plots, raster, report, solver
from .geometry import Problem
from .solver import Result
def run(problem: Problem, outdir: Path | None, show: bool = True,
i_test: float | None = None, freq_hz: float = 0.0,
contact_model: str | None = None) -> Result:
if i_test is None:
i_test = config.TEST_CURRENT_A
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 ...")
stack = raster.rasterize_stack(problem, h)
print(f"grid {stack.shape2d[1]}x{stack.shape2d[0]}x{stack.nlayers}, "
f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} "
f"via/pad barrel(s)")
e1, e2 = raster.electrode_masks(stack, problem)
parts1, parts2 = raster.electrode_partition(stack, problem)
print(f"solving @ {i_test:g} A"
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...")
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
contact_model, parts1, parts2)
for prefix, pcs in (("P", result.part_currents1),
("N", result.part_currents2)):
for i, (label, amps) in enumerate(pcs):
print(f" {prefix}{i + 1} ({label}): {amps:.4g} A "
f"({100 * amps / i_test:.1f}%)")
if outdir is not None:
outdir.mkdir(parents=True, exist_ok=True)
report.write_summary(outdir, problem, stack, result)
print(report.result_line(result, problem, stack))
figs = [
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
(plots.fig_potential(result, stack, e1, e2, problem), "2_potential"),
(plots.fig_current(result, stack, e1, e2, problem),
"3_current_density"),
(plots.fig_power(result, stack, e1, e2, problem), "4_power_density"),
]
plots.save_and_show(figs, outdir, show=show)
return result
+391
View File
@@ -0,0 +1,391 @@
"""Figures: per-layer rasterized maps, potential, current density, power
density, and the error figure. PNGs are saved BEFORE any window opens.
Backend: interactive if a GUI toolkit exists (tkinter, else Qt), else Agg
with os.startfile on the saved PNGs so results are never silent.
"""
from __future__ import annotations
import os
import textwrap
from pathlib import Path
import matplotlib
import numpy as np
def _pick_backend():
"""matplotlib.use() is lazy and 'succeeds' for backends whose GUI
toolkit is missing (KiCad's Python has no tkinter), so probe the
toolkits explicitly."""
try:
import tkinter # noqa: F401
return "TkAgg"
except Exception:
pass
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try:
__import__(qt)
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception:
continue
return None
INTERACTIVE_BACKEND = _pick_backend()
matplotlib.use(INTERACTIVE_BACKEND or "Agg")
import matplotlib.pyplot as plt # noqa: E402 (after backend selection)
from matplotlib.colors import ListedColormap, LogNorm # noqa: E402
from matplotlib.patches import Patch # noqa: E402
from . import config # noqa: E402
_BG = "#f5f3f0"
_COPPER = "#c98b4e"
_E1_COLOR = "#c8385a"
_E2_COLOR = "#2f6fb0"
_VIA_COLOR = "#2d6b45"
_SOLDER = "#9aa3ad" # tin-gray: solder buildup areas
_INK = "#3a3a3a"
_GRID_INK = "#b8b4ae"
def _fmt_si(value: float, unit: str) -> str:
for scale, prefix in ((1.0, ""), (1e-3, "m"), (1e-6, "µ")):
if abs(value) >= scale:
return f"{value / scale:.4g} {prefix}{unit}"
return f"{value:.3g} {unit}"
def _suptitle(problem, stack, result=None) -> str:
ny, nx = stack.shape2d
parts = []
if result is not None:
parts.append(f"R = {result.R_ohm * 1000:.4g}")
parts.append(f"P = {_fmt_si(result.P_total, 'W')} @ "
f"{result.i_test:g} A")
if result.freq_hz > 0:
parts.append(f"f = {result.freq_hz / 1e3:g} kHz "
f"(δ={result.skin_depth_um:.0f} µm, lower bound)")
parts.append(problem.net_name)
parts.append(f"{nx}×{ny}×{stack.nlayers} @ {stack.h_nm / 1000:.0f} µm")
return " | ".join(parts)
def _layer_fig(stack, window_title: str):
L = stack.nlayers
ny, nx = stack.shape2d
aspect = ny / nx
w = 9.5
row_h = min(max(w * aspect * 0.9 + 0.6, 1.8), 8.5 / L)
fig, axes = plt.subplots(L, 1, figsize=(w, row_h * L + 1.4),
sharex=True, sharey=True, squeeze=False)
axes = axes[:, 0]
if INTERACTIVE_BACKEND:
fig.canvas.manager.set_window_title(window_title)
for ax, name in zip(axes, stack.layer_names):
ax.set_ylabel(f"{name}\ny [mm]", fontsize=8)
ax.tick_params(colors=_INK, labelsize=8)
for s in ax.spines.values():
s.set_color(_GRID_INK)
axes[-1].set_xlabel("x [mm]")
return fig, axes
def _electrode_labels(ax, stack, e1_l, e2_l):
"""Label each connected contact part (multi-part terminals get one
label per island, largest first, up to 4)."""
from scipy import ndimage
for e, label, color in ((e1_l, "V+", _E1_COLOR), (e2_l, "V", _E2_COLOR)):
if not e.any():
continue
labels, n = ndimage.label(e)
sizes = ndimage.sum_labels(np.ones_like(labels), labels,
range(1, n + 1))
order = np.argsort(sizes)[::-1][:4] + 1
for comp in order:
ii, jj = np.nonzero(labels == comp)
cx = (stack.x0_nm + (jj.mean() + 0.5) * stack.h_nm) * 1e-6
cy = (stack.y0_nm + (ii.mean() + 0.5) * stack.h_nm) * 1e-6
ax.annotate(label, (cx, cy), xytext=(0, 0),
textcoords="offset points", color="white",
fontsize=9, fontweight="bold", ha="center",
va="center",
bbox=dict(boxstyle="round,pad=0.2", fc=color,
ec="none", alpha=0.9))
def _via_markers(ax, problem, layer):
xs = [v.x * 1e-6 for v in problem.vias if v.spans(layer.z_nm)]
ys = [v.y * 1e-6 for v in problem.vias if v.spans(layer.z_nm)]
if xs:
ax.plot(xs, ys, ".", ms=2.5, color=_VIA_COLOR, alpha=0.7)
def area_tag(sign: str, index: int) -> str:
"""Short injection-area tag: P1, P2, ... for V+; N1, N2, ... for V-."""
return f"{'P' if sign == '+' else 'N'}{index + 1}"
def _injection_area_labels(ax, li, layer_name, problem, result):
"""Mark every injection area with its short tag (currents live in
the legend)."""
groups = ((problem.electrodes1, "+", _E1_COLOR),
(problem.electrodes2, "-", _E2_COLOR))
for parts, sign, color in groups:
for i, el in enumerate(parts):
if el.contact != "all" and el.contact != layer_name:
continue
cx = (el.rect.x0 + el.rect.x1) / 2e6
cy = (el.rect.y0 + el.rect.y1) / 2e6
ax.annotate(area_tag(sign, i), (cx, cy), xytext=(0, 0),
textcoords="offset points", color="white",
fontsize=8, fontweight="bold", ha="center",
va="center",
bbox=dict(boxstyle="round,pad=0.15", fc=color,
ec="none", alpha=0.9))
def fig_raster(stack, e1, e2, problem, result=None):
fig, axes = _layer_fig(stack, "Fill Resistance - rasterized map")
cmap = ListedColormap([_BG, _COPPER, _E1_COLOR, _E2_COLOR, _SOLDER])
has_buildup = stack.buildup is not None and stack.buildup.any()
for li, ax in enumerate(axes):
codes = np.zeros(stack.shape2d, dtype=np.uint8)
codes[stack.masks[li]] = 1
if has_buildup:
codes[stack.buildup[li]] = 4
codes[e1[li]] = 2
codes[e2[li]] = 3
ax.imshow(codes, cmap=cmap, vmin=0, vmax=4, origin="upper",
extent=stack.extent_mm(), interpolation="nearest")
_via_markers(ax, problem, problem.layers[li])
if result is not None and (result.part_currents1
or result.part_currents2):
_injection_area_labels(ax, li, stack.layer_names[li], problem,
result)
else:
_electrode_labels(ax, stack, e1[li], e2[li])
handles = [Patch(fc=_COPPER, label="copper"),
Patch(fc=_VIA_COLOR, label="vias")]
if has_buildup:
handles.append(Patch(
fc=_SOLDER,
label=f"solder buildup "
f"({problem.solder_thickness_nm / 1000:.0f} µm"
+ (f" + {problem.extra_cu_nm / 1000:.0f} µm Cu"
if problem.extra_cu_nm else "") + ")"))
if result is not None and (result.part_currents1
or result.part_currents2):
entries = ([("+", _E1_COLOR, i, amps)
for i, (_, amps) in enumerate(result.part_currents1)]
+ [("-", _E2_COLOR, i, amps)
for i, (_, amps) in enumerate(result.part_currents2)])
shown = entries[:14]
for sign, color, i, amps in shown:
handles.append(Patch(
fc=color,
label=f"{area_tag(sign, i)}: {amps:.3g} A "
f"({100 * amps / result.i_test:.0f}%)"))
if len(entries) > len(shown):
handles.append(Patch(fc="#00000000",
label=f"... +{len(entries) - len(shown)} "
f"more in summary.txt"))
else:
handles += [Patch(fc=_E1_COLOR, label="V+"),
Patch(fc=_E2_COLOR, label="V")]
axes[0].legend(handles=handles, loc="upper right", fontsize=7,
framealpha=0.9)
fig.suptitle("Rasterized fill + electrodes | "
+ _suptitle(problem, stack, result), fontsize=10, color=_INK)
fig.tight_layout()
return fig
def fig_potential(result, stack, e1, e2, problem):
fig, axes = _layer_fig(stack, "Fill Resistance - potential")
vmax = float(np.nanmax(result.V))
unit, scale = ("mV", 1e3) if vmax < 0.1 else ("V", 1.0)
cmap = matplotlib.colormaps[config.CMAP_POTENTIAL].copy()
cmap.set_bad(_BG)
im = None
for li, ax in enumerate(axes):
vs = result.V[li] * scale
im = ax.imshow(vs, cmap=cmap, vmin=0, vmax=vmax * scale,
origin="upper", extent=stack.extent_mm(),
interpolation="nearest")
if np.isfinite(vs).sum() > 4:
ext = stack.extent_mm()
ny, nx = stack.shape2d
xs = np.linspace(ext[0], ext[1], nx, endpoint=False)
xs += (xs[1] - xs[0]) / 2
ys = np.linspace(ext[3], ext[2], ny, endpoint=False)
ys += (ys[1] - ys[0]) / 2
with np.errstate(invalid="ignore"):
ax.contour(xs, ys, vs, levels=15, colors="white",
linewidths=0.4, alpha=0.5)
_electrode_labels(ax, stack, e1[li], e2[li])
cb = fig.colorbar(im, ax=axes, shrink=0.85)
cb.set_label(f"potential [{unit}] @ {result.i_test:g} A", fontsize=9)
fig.suptitle("Potential | " + _suptitle(problem, stack, result),
fontsize=10, color=_INK)
return fig
def _field_fig(result, stack, e1, e2, problem, data3, cmap_name, dyn_range,
label, title, window):
"""Shared per-layer LogNorm field figure (current, power)."""
fig, axes = _layer_fig(stack, window)
vmax = float(np.nanmax(data3))
cmap = matplotlib.colormaps[cmap_name].copy()
cmap.set_bad(_BG)
if config.LOG_CURRENT_SCALE and vmax > 0:
norm = LogNorm(vmin=vmax / dyn_range, vmax=vmax)
else:
norm = None
im = None
for li, ax in enumerate(axes):
d = data3[li]
shown = np.clip(d, vmax / dyn_range, None) if norm is not None else d
im = ax.imshow(shown, cmap=cmap, norm=norm, origin="upper",
extent=stack.extent_mm(), interpolation="nearest")
_electrode_labels(ax, stack, e1[li], e2[li])
if vmax > 0:
li, i, j = np.unravel_index(np.nanargmax(data3), data3.shape)
mx = (stack.x0_nm + (j + 0.5) * stack.h_nm) * 1e-6
my = (stack.y0_nm + (i + 0.5) * stack.h_nm) * 1e-6
axes[li].plot(mx, my, "o", ms=9, mfc="none", mec="white", mew=1.4)
axes[li].annotate(f"max {vmax:.3g}", (mx, my), xytext=(10, -10),
textcoords="offset points", color="white",
fontsize=8,
bbox=dict(boxstyle="round,pad=0.2", fc="#00000088",
ec="none"))
cb = fig.colorbar(im, ax=axes, shrink=0.85)
cb.set_label(label, fontsize=9)
fig.suptitle(title + " | " + _suptitle(problem, stack, result),
fontsize=10, color=_INK)
return fig, axes
def fig_current(result, stack, e1, e2, problem):
fig, axes = _field_fig(
result, stack, e1, e2, problem, result.Jmag * 1e-6,
config.CMAP_CURRENT, config.CURRENT_DYNAMIC_RANGE,
f"|J| [A/mm²] @ {result.i_test:g} A",
"Current density (log)", "Fill Resistance - current density")
# mark the hottest via
if result.via_reports:
v = result.via_reports[0]
for ax in axes:
ax.plot(v.x_mm, v.y_mm, "s", ms=7, mfc="none", mec="#7fe0a8",
mew=1.2)
axes[0].annotate(
f"hottest via {v.current_a:.3g} A", (v.x_mm, v.y_mm),
xytext=(10, 10), textcoords="offset points", color="white",
fontsize=8,
bbox=dict(boxstyle="round,pad=0.2", fc="#2d6b45", ec="none"))
return fig
def fig_power(result, stack, e1, e2, problem):
# W/m^2 -> W/mm^2
fig, axes = _field_fig(
result, stack, e1, e2, problem, result.Parea * 1e-6,
config.CMAP_POWER, config.POWER_DYNAMIC_RANGE,
f"p [W/mm²] @ {result.i_test:g} A",
"Power density (log)", "Fill Resistance - power density")
for li, ax in enumerate(axes):
ax.set_title(f"P({stack.layer_names[li]}) = "
f"{_fmt_si(result.P_layers[li], 'W')}",
fontsize=8, color=_INK, loc="right", pad=2)
return fig
def fig_error(message: str):
fig, ax = plt.subplots(figsize=(9, 4.5))
ax.axis("off")
ax.set_title("Fill Resistance — ERROR", color="#b02a2a",
fontsize=14, fontweight="bold", loc="left")
wrapped = "\n".join(
textwrap.fill(line, width=90) for line in message.splitlines()
)
ax.text(0.0, 0.95, wrapped, family="monospace", fontsize=9,
va="top", ha="left", color=_INK, transform=ax.transAxes)
fig.tight_layout()
return fig
def _resolve_label_overlaps(fig):
"""Measure every annotation's rendered box and greedily push
overlapping labels upward until nothing collides. Runs on the real
renderer, so it handles any font/DPI."""
from matplotlib.text import Annotation
try:
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
except Exception:
return
for ax in fig.axes:
anns = [c for c in ax.get_children() if isinstance(c, Annotation)]
placed = []
for a in sorted(anns, key=lambda t: t.get_window_extent(renderer).x0):
try:
bb = a.get_window_extent(renderer)
except Exception:
continue
guard = 50
while guard > 0:
hit = next((p for p in placed if bb.overlaps(p)), None)
if hit is None:
break
push_px = (hit.y1 - bb.y0) + 3.0
dx, dy = a.xyann
a.xyann = (dx, dy + push_px * 72.0 / fig.dpi)
bb = a.get_window_extent(renderer)
guard -= 1
placed.append(bb)
def _raise_windows():
"""Best effort: bring plot windows in front of KiCad (windows spawned
by a background process tend to open behind)."""
for num in plt.get_fignums():
try:
win = plt.figure(num).canvas.manager.window
if hasattr(win, "attributes"): # Tk
win.attributes("-topmost", True)
win.after(300, lambda w=win: w.attributes("-topmost", False))
else: # Qt
win.raise_()
win.activateWindow()
except Exception:
pass
def save_and_show(figs_named: list[tuple], outdir: Path | None,
show: bool = True) -> list[Path]:
"""figs_named: [(figure, basename), ...]. Saves first, then shows."""
saved = []
for fig, _ in figs_named:
_resolve_label_overlaps(fig)
if outdir is not None:
outdir.mkdir(parents=True, exist_ok=True)
for fig, name in figs_named:
p = outdir / f"{name}.png"
fig.savefig(p, dpi=config.DPI, facecolor="white",
bbox_inches="tight")
saved.append(p)
print(f"saved {p}")
if show and config.INTERACTIVE:
if INTERACTIVE_BACKEND:
_raise_windows()
plt.show()
else:
for p in saved:
try:
os.startfile(p) # windows: open in default viewer
except Exception:
pass
plt.close("all")
return saved
+263
View File
@@ -0,0 +1,263 @@
"""Rasterization of the fill polygons onto a shared multi-layer grid, and
electrode mask construction.
Grid convention: layer l, row i, col j maps to the cell center
x = x0_nm + (j + 0.5) * h_nm
y = y0_nm + (i + 0.5) * h_nm
in KiCad board coordinates (y grows down). Row 0 is the minimum-y row,
the TOP of the board as drawn in the editor; plots use origin='upper'.
All layers share the same frame, so cell (i, j) is vertically aligned
across layers (via links connect equal (i, j) on different layers).
Connectivity restriction lives in solver.py: it needs the via edges.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
from matplotlib.path import Path as MplPath
from scipy import ndimage
from . import config
from .errors import ElectrodeError, GridSizeError
from .geometry import Electrode, Problem, Rect
# 4-connectivity: matches the in-plane 5-point stencil of the solver
_STRUCT4 = ndimage.generate_binary_structure(2, 1)
@dataclass
class RasterStack:
masks: np.ndarray # bool (L, ny, nx), True = copper
x0_nm: float # grid origin (outer corner of cell [., 0, 0])
y0_nm: float
h_nm: float
layer_names: list[str]
buildup: np.ndarray | None = None # bool (L, ny, nx): solder buildup
# (mask opening ∩ copper)
@property
def nlayers(self) -> int:
return self.masks.shape[0]
@property
def shape2d(self) -> tuple[int, int]:
return self.masks.shape[1:]
def cell_centers(self, i0: int, i1: int, j0: int, j1: int):
xs = self.x0_nm + (np.arange(j0, j1) + 0.5) * self.h_nm
ys = self.y0_nm + (np.arange(i0, i1) + 0.5) * self.h_nm
return np.meshgrid(xs, ys)
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)
if 0 <= i < ny and 0 <= j < nx:
return i, j
return None
def extent_mm(self) -> tuple[float, float, float, float]:
"""imshow extent (left, right, bottom, top) for origin='upper',
y axis in board orientation (increasing downward)."""
ny, nx = self.shape2d
return (
self.x0_nm * 1e-6,
(self.x0_nm + nx * self.h_nm) * 1e-6,
(self.y0_nm + ny * self.h_nm) * 1e-6,
self.y0_nm * 1e-6,
)
def choose_cell_size(bbox_nm: tuple[int, int, int, int], nlayers: int) -> float:
"""Pick the cell size h [nm]; TARGET_CELLS counts TOTAL cells across
all layers. Raise if the grid would exceed HARD_MAX_CELLS."""
x0, y0, x1, y1 = bbox_nm
w, ht = float(x1 - x0), float(y1 - y0)
if w <= 0 or ht <= 0:
raise GridSizeError("Copper geometry has a degenerate bounding box.")
if config.CELL_UM_OVERRIDE is not None:
h = config.CELL_UM_OVERRIDE * 1000.0
else:
h = math.sqrt(w * ht * nlayers / config.TARGET_CELLS)
h = min(max(h, config.MIN_CELL_UM * 1000.0), config.MAX_CELL_UM * 1000.0)
ncells = math.ceil(w / h) * math.ceil(ht / h) * nlayers
if ncells > config.HARD_MAX_CELLS:
raise GridSizeError(
f"Grid would need ~{ncells / 1e6:.1f} M cells over {nlayers} "
f"layer(s) at cell size {h / 1000:.0f} um (limit "
f"{config.HARD_MAX_CELLS / 1e6:.0f} M). Raise MAX_CELL_UM / "
f"CELL_UM_OVERRIDE in config.py, deselect layers, or measure a "
f"smaller region."
)
return h
def _paint_ring(stack: RasterStack, ring: np.ndarray, value: bool,
target: np.ndarray) -> None:
"""Set target (2D) cells whose center lies inside ring to `value`,
testing only cells within the ring's bbox (cheap for small holes)."""
ny, nx = stack.shape2d
h = stack.h_nm
j0 = max(0, int((ring[:, 0].min() - stack.x0_nm) / h) - 1)
j1 = min(nx, int((ring[:, 0].max() - stack.x0_nm) / h) + 2)
i0 = max(0, int((ring[:, 1].min() - stack.y0_nm) / h) - 1)
i1 = min(ny, int((ring[:, 1].max() - stack.y0_nm) / h) + 2)
if i0 >= i1 or j0 >= j1:
return
xg, yg = stack.cell_centers(i0, i1, j0, j1)
pts = np.column_stack([xg.ravel(), yg.ravel()])
# Path(closed=True) treats the LAST vertex as the CLOSEPOLY dummy, so
# the first vertex must be appended or the ring loses its last corner
verts = np.vstack([ring, ring[:1]])
inside = MplPath(verts, closed=True).contains_points(pts)
inside = inside.reshape(i1 - i0, j1 - j0)
sub = target[i0:i1, j0:j1]
sub[inside] = value
def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
"""Rasterize every included layer onto one shared frame."""
x0, y0, x1, y1 = problem.copper_bbox()
m = config.MARGIN_CELLS
nx = math.ceil((x1 - x0) / h_nm) + 2 * m
ny = math.ceil((y1 - y0) / h_nm) + 2 * m
stack = RasterStack(
masks=np.zeros((len(problem.layers), ny, nx), dtype=bool),
x0_nm=x0 - m * h_nm,
y0_nm=y0 - m * h_nm,
h_nm=h_nm,
layer_names=problem.layer_names,
)
for li, layer in enumerate(problem.layers):
for poly in layer.polygons:
pmask = np.zeros((ny, nx), dtype=bool)
_paint_ring(stack, poly.outline, True, pmask)
for hole in poly.holes:
_paint_ring(stack, hole, False, pmask)
stack.masks[li] |= pmask
if problem.buildups:
stack.buildup = np.zeros_like(stack.masks)
index = {name: li for li, name in enumerate(stack.layer_names)}
for b in problem.buildups:
li = index.get(b.layer_name)
if li is None:
continue
for poly in b.polygons:
pmask = np.zeros((ny, nx), dtype=bool)
_paint_ring(stack, poly.outline, True, pmask)
for hole in poly.holes:
_paint_ring(stack, hole, False, pmask)
stack.buildup[li] |= pmask
stack.buildup &= stack.masks # solder wets exposed copper only
return stack
def _rect_cells(stack: RasterStack, rect: Rect) -> np.ndarray:
"""Bool (ny, nx) mask of cells whose center lies inside the rectangle."""
ny, nx = stack.shape2d
h = stack.h_nm
out = np.zeros((ny, nx), dtype=bool)
j0 = max(0, int(math.ceil((rect.x0 - stack.x0_nm) / h - 0.5)))
j1 = min(nx, int(math.floor((rect.x1 - stack.x0_nm) / h - 0.5)) + 1)
i0 = max(0, int(math.ceil((rect.y0 - stack.y0_nm) / h - 0.5)))
i1 = min(ny, int(math.floor((rect.y1 - stack.y0_nm) / h - 0.5)) + 1)
if i0 < i1 and j0 < j1:
out[i0:i1, j0:j1] = True
return out
def _electrode_cells2d(stack: RasterStack, e: Electrode) -> np.ndarray:
"""2D footprint of the electrode shape (pad polygons or rectangle)."""
if e.polygons:
cells = np.zeros(stack.shape2d, dtype=bool)
for poly in e.polygons:
pm = np.zeros(stack.shape2d, dtype=bool)
_paint_ring(stack, poly.outline, True, pm)
for hole in poly.holes:
_paint_ring(stack, hole, False, pm)
cells |= pm
if not cells.any():
# shape smaller than one grid cell (small pad): use the cell
# containing its center
r = e.rect
c = stack.cell_of((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)
if c is not None:
cells[c] = True
return cells
return _rect_cells(stack, e.rect)
def electrode_masks(stack: RasterStack, problem: Problem
) -> tuple[np.ndarray, np.ndarray]:
"""Terminal mask = OR over its parts; part = shape ∩ copper on the
part's contact layer(s). contact 'all' = every included layer (bolted
lug / through pad); a layer name = that layer only. Every part must
individually land on copper (clear feedback). V+/V- must not overlap;
touching is checked later, only for the equipotential contact model."""
def build(parts: list[Electrode], which: str) -> np.ndarray:
e = np.zeros_like(stack.masks)
for el in parts:
cells2d = _electrode_cells2d(stack, el)
part = np.zeros_like(stack.masks)
for li, name in enumerate(stack.layer_names):
if el.contact == "all" or el.contact == name:
part[li] = cells2d & stack.masks[li]
if not part.any():
raise ElectrodeError(
f"A {which} contact part ({el.label}) does not overlap "
f"any copper of the selected fill on contact layer(s) "
f"'{el.contact}' (or is smaller than one grid cell)."
)
e |= part
if not e.any():
raise ElectrodeError(f"The {which} terminal has no contact parts.")
return e
e1 = build(problem.electrodes1, "V+")
e2 = build(problem.electrodes2, "V-")
if (e1 & e2).any():
raise ElectrodeError(
"The V+ and V- contact areas overlap on the copper grid. "
"Move them apart."
)
return e1, e2
def electrode_partition(stack: RasterStack, problem: Problem
) -> tuple[list, list]:
"""Per-part cell masks for both terminals, as [(label, mask3d), ...].
Cells covered by several overlapping parts are attributed to the
FIRST part (first-wins partition), so part currents sum exactly to
the terminal current."""
def build(parts: list[Electrode]) -> list:
out = []
claimed = np.zeros_like(stack.masks)
for el in parts:
cells2d = _electrode_cells2d(stack, el)
m = np.zeros_like(stack.masks)
for li, name in enumerate(stack.layer_names):
if el.contact == "all" or el.contact == name:
m[li] = cells2d & stack.masks[li]
m &= ~claimed
claimed |= m
out.append((el.label, m))
return out
return build(problem.electrodes1), build(problem.electrodes2)
def electrodes_touch(stack: RasterStack, e1: np.ndarray,
e2: np.ndarray) -> str | None:
"""Layer name where the terminals are 4-adjacent, or None."""
for li in range(stack.nlayers):
if (ndimage.binary_dilation(e1[li], structure=_STRUCT4) & e2[li]).any():
return stack.layer_names[li]
return None
+146
View File
@@ -0,0 +1,146 @@
"""Output directory, summary.txt, geometry dump, stdout one-liner."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import numpy as np
from . import config
from .geometry import Problem, save_problem
from .raster import RasterStack
from .solver import Result
def make_output_dir(board_dir: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = Path(board_dir) / config.OUTPUT_DIRNAME / stamp
out.mkdir(parents=True, exist_ok=True)
return out
def write_geometry_dump(outdir: Path, problem: Problem) -> Path:
p = outdir / "geometry_dump.json"
save_problem(problem, p)
return p
def result_line(result: Result, problem: Problem, stack: RasterStack) -> str:
ny, nx = stack.shape2d
ac = (f" @ {result.freq_hz / 1e3:g} kHz (lower bound)"
if result.freq_hz > 0 else "")
return (f"R = {result.R_ohm * 1000:.4g} mOhm{ac}, "
f"P = {result.P_total:.4g} W @ {result.i_test:g} A "
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
f"grid {nx}x{ny}x{stack.nlayers}, cell {stack.h_nm / 1000:.0f} um)")
def _electrode_line(e) -> str:
r = e.rect
return (f"{e.label:12s} contact={e.contact:8s} "
f"x [{r.x0 / 1e6:.2f}, {r.x1 / 1e6:.2f}] "
f"y [{r.y0 / 1e6:.2f}, {r.y1 / 1e6:.2f}] mm")
def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
result: Result) -> Path:
ny, nx = stack.shape2d
info = result.solve_info
lines = [
"fill_resistance summary",
"=======================",
f"board: {problem.board_path}",
f"net: {problem.net_name}",
f"test current: {result.i_test:g} A",
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
f"via plating: {problem.plating_nm / 1000:.0f} um",
"",
(f"frequency: "
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
if result.freq_hz > 0 else "DC")),
f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm"
+ (" (AC LOWER BOUND: lateral/proximity redistribution not modeled)"
if result.freq_hz > 0 else ""),
f"VOLTAGE DROP: {result.R_ohm * result.i_test * 1000:.4g} mV "
f"@ {result.i_test:g} A",
f"TOTAL POWER: {result.P_total:.6g} W @ {result.i_test:g} A",
f" in vias: {result.P_vias:.4g} W",
f" power balance: {result.power_balance_rel:.2e} (consistency)",
"",
"layers (top to bottom):",
]
if problem.buildups and stack.buildup is not None:
eq_um = (problem.solder_thickness_nm / 1000
* problem.rho_ohm_m / problem.solder_rho_ohm_m
+ problem.extra_cu_nm / 1000)
cell_mm2 = (stack.h_nm * 1e-6) ** 2
per_layer = {name: float(stack.buildup[li].sum()) * cell_mm2
for li, name in enumerate(stack.layer_names)
if stack.buildup[li].any()}
areas = ", ".join(f"{n}: {a:.0f} mm^2" for n, a in per_layer.items())
lines.insert(-1, f"solder buildup: "
f"{problem.solder_thickness_nm / 1000:.0f} um solder"
+ (f" + {problem.extra_cu_nm / 1000:.0f} um Cu"
if problem.extra_cu_nm else "")
+ f" = {eq_um:.1f} um equivalent Cu ({areas})")
for li, layer in enumerate(problem.layers):
ac = (f" Rs_AC/Rs_DC={result.rs_ratios[li]:.2f}"
if result.freq_hz > 0 else "")
lines.append(
f" {layer.layer_name:8s} t={layer.thickness_nm / 1000:5.1f} um "
f"z={layer.z_nm / 1000:7.1f} um "
f"P={result.P_layers[li]:.4g} W "
f"maxJ={float(np.nanmax(result.Jmag[li])) * 1e-6 if np.isfinite(result.Jmag[li]).any() else 0:.4g} A/mm^2"
+ ac
)
lines += [
"",
f"grid: {nx} x {ny} x {stack.nlayers} cells @ "
f"{stack.h_nm / 1000:.1f} um",
f"copper cells: {int(stack.masks.sum())}",
f"free unknowns: {result.n_free}",
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"timings [s]: "
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
"",
f"contact model: {result.contact_model}"
+ (" (uniform orthogonal injection; R is the upper contact bound)"
if result.contact_model == "uniform" else " (ideal bonded lug)"),
f"terminals:",
f" V+ ({len(problem.electrodes1)} injection area(s)):",
*(f" {_electrode_line(e)}" for e in problem.electrodes1),
f" V- ({len(problem.electrodes2)} injection area(s)):",
*(f" {_electrode_line(e)}" for e in problem.electrodes2),
]
if result.part_currents1 or result.part_currents2:
how = ("prescribed by area share (uniform model)"
if result.contact_model == "uniform"
else "computed flux (equipotential model)")
lines += ["", f"current per injection area @ {result.i_test:g} A "
f"({how}):"]
for sign, pcs in (("+", result.part_currents1),
("-", result.part_currents2)):
for i, (label, amps) in enumerate(pcs):
tag = f"{'P' if sign == '+' else 'N'}{i + 1}"
lines.append(f" {tag:4s} {label:24s} {amps:9.4g} A "
f"({100 * amps / result.i_test:5.1f}%)")
if result.via_reports:
n_shown = min(10, len(result.via_reports))
lines += [
"",
f"vias/pads carrying current (top {n_shown} of "
f"{len(result.via_reports)}, @ {result.i_test:g} A):",
" x [mm] y [mm] kind drill I [A] P [W]",
]
for v in result.via_reports[:n_shown]:
lines.append(
f" {v.x_mm:8.2f} {v.y_mm:8.2f} {v.kind:5s} "
f"{v.drill_mm:5.2f} {v.current_a:8.4g} {v.power_w:.4g}"
)
p = outdir / "summary.txt"
p.write_text("\n".join(lines), encoding="utf-8")
return p
+78
View File
@@ -0,0 +1,78 @@
"""Skin-effect corrections: frequency-dependent effective sheet
resistance of a copper foil and via-barrel wall.
1D diffusion through the foil thickness (exact): with tau = (1+j)/delta,
the internal impedance per square of a foil of thickness t is
one-sided field (plane over a return plane): Zs = tau*rho * coth(tau*t)
two-sided field (isolated foil): Zs = tau*rho/2 * coth(tau*t/2)
Both reduce to rho/t at DC and to rho/delta (resp. rho/(2*delta)) at
high frequency. R_AC = Re(Zs) is used as the effective sheet resistance.
HONESTY NOTE (also in the README): only the through-thickness current
crowding is modeled. Lateral redistribution (proximity effect - AC
current following the minimum-inductance path) needs a magneto-
quasistatic solve and is NOT captured; since the resistance-driven
distribution is the minimum-dissipation one, the reported AC resistance
is a rigorous LOWER BOUND at the given frequency.
"""
from __future__ import annotations
import cmath
import math
MU0 = 4e-7 * math.pi
def skin_depth_m(freq_hz: float, rho_ohm_m: float) -> float:
return math.sqrt(2.0 * rho_ohm_m / (2.0 * math.pi * freq_hz * MU0))
def _coth(x: complex) -> complex:
return 1.0 / cmath.tanh(x)
def sheet_resistance_ac(thickness_m: float, freq_hz: float,
rho_ohm_m: float, sides: int = 1) -> float:
"""Effective sheet resistance [ohm/sq] of a foil at freq_hz.
sides=1: field on one side (plane facing a return plane, conservative);
sides=2: symmetric field on both sides (isolated foil)."""
if freq_hz <= 0.0:
return rho_ohm_m / thickness_m
delta = skin_depth_m(freq_hz, rho_ohm_m)
tau = (1.0 + 1.0j) / delta
if sides == 2:
zs = tau * rho_ohm_m / 2.0 * _coth(tau * thickness_m / 2.0)
else:
zs = tau * rho_ohm_m * _coth(tau * thickness_m)
return zs.real
def resistance_factor(thickness_m: float, freq_hz: float,
rho_ohm_m: float, sides: int = 1) -> float:
"""R_AC / R_DC of a foil (or barrel wall) of the given thickness."""
if freq_hz <= 0.0:
return 1.0
return (sheet_resistance_ac(thickness_m, freq_hz, rho_ohm_m, sides)
/ (rho_ohm_m / thickness_m))
def parse_frequency(text: str) -> float:
"""'0', '100k', '1.5M', '142500' -> Hz. Empty/invalid -> 0 (DC)."""
t = text.strip().lower().replace(",", ".").removesuffix("hz").strip()
if not t:
return 0.0
mult = 1.0
if t.endswith("meg"):
mult, t = 1e6, t[:-3]
elif t.endswith("m"):
mult, t = 1e6, t[:-1]
elif t.endswith("k"):
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
+549
View File
@@ -0,0 +1,549 @@
"""Coupled multi-layer finite-difference solver.
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
barrel walls get the 1D skin-effect correction (see skin.py; AC results
are a rigorous lower bound - lateral redistribution is not modeled).
Two contact models for the terminals (each terminal = merged parts):
- "uniform" (default): a conductor pressed onto the contact area injects
the current orthogonally with UNIFORM surface density: every contact
cell sources (sinks) I/N. The in-plane current density ramps across
the contact instead of being zero. The pure-Neumann system is grounded
at one V- cell (that cell's sink share is exactly the flux that exits
through the ground reference, so the solution equals the singular
system's). R = (<V over V+ cells> - <V over V- cells>) / I; because
the injection and averaging weights coincide, sum(edge powers) = I^2 R
holds exactly and remains the consistency check.
- "equipotential": ideal bonded lug; contact cells are Dirichlet
(V+ = 1 V, V- = 0). R from the exact discrete electrode flux. Touching
terminals are rejected (a direct face would short the Dirichlet
regions); with "uniform" contacts touching is physically fine.
The two models bracket a real contact: R_equipotential <= R_real <=
R_uniform. Missing neighbors give no matrix term = insulated boundary.
Current density per layer comes from face currents (np.gradient across
the NaN staircase boundary would pollute the field). Power density per
layer distributes each in-plane edge's dissipation half to each endpoint
cell. All reported fields are rescaled to the test current I_test.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
import numpy as np
from scipy import sparse
from scipy.sparse import csgraph
from scipy.sparse import linalg as sla
from . import config, skin
from .errors import ConnectivityError, ElectrodeError
from .geometry import Problem
from .raster import RasterStack, electrodes_touch
@dataclass
class SolveInfo:
method: str # "spsolve" | "cg+jacobi"
n_unknowns: int
iterations: int | None = None
residual: float | None = None
@dataclass
class Edges:
a: np.ndarray # int64 flat cell ids
b: np.ndarray
w: np.ndarray # conductance [S]
via_index: np.ndarray # int32; -1 = in-plane edge
@dataclass
class ViaReport:
x_mm: float
y_mm: float
kind: str
drill_mm: float
current_a: float # max barrel-segment current @ I_test
power_w: float # total barrel dissipation @ I_test
@dataclass
class Result:
R_ohm: float
i_test: float
V: np.ndarray # (L, ny, nx) volts @ I_test, NaN off-copper
Jmag: np.ndarray # (L, ny, nx) A/m^2 @ I_test
Parea: np.ndarray # (L, ny, nx) W/m^2 @ I_test
layer_names: list[str]
P_total: float # I_test^2 * R
P_layers: list[float] # in-plane dissipation per layer @ I_test
P_vias: float # total barrel dissipation @ I_test
power_balance_rel: float # |sum(edge powers) - I^2 R| / I^2 R
via_reports: list[ViaReport] # sorted by current, descending
I1_a: float # electrode currents (unit drive)
I2_a: float
mismatch_rel: float
n_free: int
solve_info: SolveInfo
# per-part terminal currents @ I_test: [(label, amps), ...];
# computed flux for "equipotential", prescribed area share for "uniform"
part_currents1: list = field(default_factory=list)
part_currents2: list = field(default_factory=list)
contact_model: str = "uniform"
freq_hz: float = 0.0
skin_depth_um: float | None = None
rs_ratios: list[float] = field(default_factory=list) # R_AC/R_DC per layer
timings: dict = field(default_factory=dict)
def _shifts2d():
return [
((slice(None), slice(None, -1)), (slice(None), slice(1, None))),
((slice(None, -1), slice(None)), (slice(1, None), slice(None))),
]
def _sigma_2d(stack: RasterStack, li: int, sigma_layer: float,
sigma_buildup: float) -> np.ndarray | None:
"""Per-cell sheet conductance for one layer, or None if uniform."""
if stack.buildup is None or sigma_buildup <= 0 \
or not stack.buildup[li].any():
return None
s = np.full(stack.shape2d, sigma_layer)
s[stack.buildup[li]] += sigma_buildup
return s
def build_edges(stack: RasterStack, problem: Problem, sigmas: list[float],
via_factor: float = 1.0,
sigma_buildup: float = 0.0) -> Edges:
"""All copper-copper conductances: in-plane faces + via barrels.
sigmas: effective (possibly AC) sheet conductance per layer;
via_factor: R_AC/R_DC of the barrel wall; sigma_buildup: extra sheet
conductance on solder-buildup cells. Faces between cells of unequal
conductance use the harmonic mean (series half-cells), which reduces
exactly to sigma for uniform regions."""
L, ny, nx = stack.masks.shape
plane = ny * nx
aa, bb, ww, vv = [], [], [], []
for li in range(L):
m = stack.masks[li]
sig = sigmas[li]
scell = _sigma_2d(stack, li, sig, sigma_buildup)
base = li * plane
for src, dst in _shifts2d():
pair = m[src] & m[dst]
ii, jj = np.nonzero(pair)
if src[0] == slice(None): # horizontal: j, j+1
a = base + ii * nx + jj
b = a + 1
else: # vertical: i, i+1
a = base + ii * nx + jj
b = a + nx
aa.append(a.astype(np.int64))
bb.append(b.astype(np.int64))
if scell is None:
ww.append(np.full(len(a), sig))
else:
s_a = scell[src][pair]
s_b = scell[dst][pair]
ww.append(2.0 * s_a * s_b / (s_a + s_b))
vv.append(np.full(len(a), -1, dtype=np.int32))
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:]):
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))
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))
def connected_restrict(stack: RasterStack, e1: np.ndarray, e2: np.ndarray,
edges: Edges) -> bool:
"""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)."""
n = stack.masks.size
graph = sparse.coo_matrix(
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(n, n))
_, labels = csgraph.connected_components(graph, directed=False)
labels3 = labels.reshape(stack.masks.shape)
common = np.intersect1d(np.unique(labels3[e1]), np.unique(labels3[e2]))
if len(common) == 0:
raise ConnectivityError(
"The two terminals are not connected by the selected fill "
"layers (not even through vias). Check the layer selection and "
"that the fills are up to date."
)
keep = np.isin(labels3, common) & stack.masks
changed = bool((stack.masks & ~keep).any())
stack.masks &= keep
e1 &= keep
e2 &= keep
return changed
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
"""Weighted-Laplacian assembly with Dirichlet elimination.
state: 0 off, 1 free, 2 Dirichlet@1V, 3 Dirichlet@0V.
rhs_extra: per-flat-cell current injection [A] added for free cells."""
n = state.size
sa, sb = state[edges.a], state[edges.b]
short = ((sa == 2) & (sb == 3)) | ((sa == 3) & (sb == 2))
if short.any():
n_via = int((edges.via_index[short] >= 0).sum())
raise ElectrodeError(
f"The terminals are directly connected by {int(short.sum())} "
f"conductance(s) ({n_via} via barrel(s)) without any free copper "
f"in between - move the contacts apart."
)
free = state == 1
n_free = int(free.sum())
if n_free == 0:
raise ElectrodeError(
"No free copper cells remain between the terminals - the "
"contacts cover the whole fill at this grid resolution."
)
idx = np.full(n, -1, dtype=np.int64)
idx[free] = np.arange(n_free)
diag = np.zeros(n_free)
rhs = np.zeros(n_free)
fa, fb = sa == 1, sb == 1
np.add.at(diag, idx[edges.a[fa]], edges.w[fa])
np.add.at(diag, idx[edges.b[fb]], edges.w[fb])
r1a = fa & (sb == 2)
r1b = fb & (sa == 2)
np.add.at(rhs, idx[edges.a[r1a]], edges.w[r1a])
np.add.at(rhs, idx[edges.b[r1b]], edges.w[r1b])
if rhs_extra is not None:
rhs += rhs_extra[free]
ff = fa & fb
rows = np.concatenate([idx[edges.a[ff]], idx[edges.b[ff]],
np.arange(n_free)])
cols = np.concatenate([idx[edges.b[ff]], idx[edges.a[ff]],
np.arange(n_free)])
vals = np.concatenate([-edges.w[ff], -edges.w[ff], diag])
A = sparse.coo_matrix((vals, (rows, cols)),
shape=(n_free, n_free)).tocsr()
return A, rhs, idx
def solve_system(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
n = A.shape[0]
if n <= config.SPSOLVE_MAX_UNKNOWNS:
x = sla.spsolve(A.tocsc(), b)
return x, SolveInfo(method="spsolve", n_unknowns=n)
# The matrix is SPD, so CG is guaranteed to converge. Jacobi is the
# only preconditioner in scipy that keeps the preconditioned operator
# SPD without a factorization that can break down at this scale.
d = A.diagonal()
M = sla.LinearOperator((n, n), lambda v: v / d)
iters = 0
def count(_):
nonlocal iters
iters += 1
try:
x, code = sla.cg(A, b, M=M, rtol=config.CG_TOL,
maxiter=config.CG_MAXITER, callback=count)
except TypeError: # scipy < 1.12 uses tol=
x, code = sla.cg(A, b, M=M, tol=config.CG_TOL,
maxiter=config.CG_MAXITER, callback=count)
if code != 0:
raise RuntimeError(
f"CG did not converge in {config.CG_MAXITER} iterations "
f"(code {code}). Try a coarser grid or raise CG_MAXITER."
)
res = float(np.linalg.norm(b - A @ x) / np.linalg.norm(b))
return x, SolveInfo(method="cg+jacobi", n_unknowns=n, iterations=iters,
residual=res)
def _face_current_density(V2: np.ndarray, mask2: np.ndarray, sigma: float,
h_m: float, t_m: float,
sig2d: np.ndarray | None = None,
rho: float | None = None) -> np.ndarray:
"""|J| (A/m^2) for one layer from face currents; V2 in volts.
With a per-cell conductance map (buildup), face currents use the
harmonic mean and J is referenced to the conductance-equivalent
copper thickness t_eq = sigma_cell * rho (equals the geometric t for
plain DC copper)."""
ny, nx = mask2.shape
face_x = mask2[:, :-1] & mask2[:, 1:]
face_y = mask2[:-1, :] & mask2[1:, :]
if sig2d is None:
wx = wy = sigma
teq = np.full((ny, nx), t_m)
else:
wx = 2.0 * sig2d[:, :-1] * sig2d[:, 1:] / (sig2d[:, :-1] + sig2d[:, 1:])
wy = 2.0 * sig2d[:-1, :] * sig2d[1:, :] / (sig2d[:-1, :] + sig2d[1:, :])
teq = sig2d * rho
with np.errstate(invalid="ignore"):
Ix = np.where(face_x, (V2[:, :-1] - V2[:, 1:]) * wx, 0.0)
Iy = np.where(face_y, (V2[:-1, :] - V2[1:, :]) * wy, 0.0)
IxP = np.zeros((ny, nx + 1))
IxP[:, 1:nx] = Ix
IyP = np.zeros((ny + 1, nx))
IyP[1:ny, :] = Iy
Jx = 0.5 * (IxP[:, :-1] + IxP[:, 1:])
Jy = 0.5 * (IyP[:-1, :] + IyP[1:, :])
Jmag = np.hypot(Jx, Jy) / (h_m * teq)
Jmag[~mask2] = np.nan
return Jmag
def _solve_equipotential(stack, e1, e2, edges):
"""Dirichlet terminals at 1 V / 0 V. Returns (Vflat_unit, R, I1, I2,
mismatch, volts_per_amp, info). Fields at 1 V drive; scale by
i_test * R to get volts at I_test."""
if (layer := electrodes_touch(stack, e1, e2)) is not None:
raise ElectrodeError(
f"The terminals touch on {layer}. With the equipotential "
f"contact model at least one cell of copper must separate "
f"them; the uniform-injection model allows touching contacts."
)
state = np.zeros(stack.masks.size, dtype=np.uint8)
state[stack.masks.ravel()] = 1
state[e1.ravel()] = 2
state[e2.ravel()] = 3
A, rhs, idx = _assemble(state, edges, None)
x, info = solve_system(A, rhs)
Vflat = np.zeros(state.size)
Vflat[state == 2] = 1.0
Vflat[state == 1] = x
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b])
sa, sb = state[edges.a], state[edges.b]
I1 = float(Ie[sa == 2].sum() - Ie[sb == 2].sum())
I2 = float(Ie[sb == 3].sum() - Ie[sa == 3].sum())
mismatch = abs(I1 - I2) / max(abs(I1), abs(I2), 1e-300)
R = 1.0 / (0.5 * (I1 + I2))
return Vflat, R, I1, I2, mismatch, R, info
def _solve_uniform(stack, e1, e2, edges):
"""Uniform orthogonal injection: every contact cell sources (sinks)
1 A / N. Grounded at one V- cell. Returns like _solve_equipotential;
fields are at 1 A drive, so volts_per_amp = 1."""
n = stack.masks.size
e1f, e2f = e1.ravel(), e2.ravel()
n1, n2 = int(e1f.sum()), int(e2f.sum())
inj = np.zeros(n)
inj[e1f] = 1.0 / n1
inj[e2f] = -1.0 / n2
state = np.zeros(n, dtype=np.uint8)
state[stack.masks.ravel()] = 1
ground = int(np.flatnonzero(e2f)[0])
state[ground] = 3 # single Dirichlet 0 V reference;
# its sink share is exactly the flux that exits through the reference,
# so the grounded solution equals the pure-Neumann one
A, rhs, idx = _assemble(state, edges, inj)
x, info = solve_system(A, rhs)
Vflat = np.zeros(n)
Vflat[state == 1] = x
v_plus = float(Vflat[e1f].mean())
v_minus = float(Vflat[e2f].mean())
R = (v_plus - v_minus) / 1.0
Vflat = Vflat - v_minus # display reference: <V-> = 0
# quality: KCL residual of the solved system
res = info.residual
if res is None:
res = float(np.linalg.norm(A @ x - rhs)
/ max(np.linalg.norm(rhs), 1e-300))
return Vflat, R, 1.0, 1.0, res, 1.0, info
def _part_currents(parts, Ie, edges, e_flat, scale,
i_test, contact_model, n_terminal_cells):
"""Current through each contact part @ I_test. Equipotential: exact
discrete flux out of the part's cells (same-terminal internal edges
carry zero, opposite-terminal edges are forbidden). Uniform: the
injection is prescribed, so a part carries exactly its cell share."""
out = []
for label, mask3 in parts:
pf = mask3.ravel() & e_flat
n = int(pf.sum())
if contact_model == "uniform":
amps = i_test * n / max(n_terminal_cells, 1)
else:
ina = pf[edges.a]
inb = pf[edges.b]
amps = abs(float(Ie[ina].sum() - Ie[inb].sum())) * scale
out.append((label, amps))
return out
def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
e2: np.ndarray, i_test: float, freq_hz: float = 0.0,
contact_model: str | None = None,
parts1: list | None = None,
parts2: list | None = None) -> Result:
timings = {}
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
if contact_model is None:
contact_model = config.CONTACT_MODEL
# effective (AC) sheet conductances and barrel factor
sigmas = [
1.0 / skin.sheet_resistance_ac(
problem.layers[li].thickness_nm * 1e-9, freq_hz,
problem.rho_ohm_m, config.SKIN_SIDES)
for li in range(L)
]
rs_ratios = [
skin.resistance_factor(problem.layers[li].thickness_nm * 1e-9,
freq_hz, problem.rho_ohm_m, config.SKIN_SIDES)
for li in range(L)
]
via_factor = skin.resistance_factor(problem.plating_nm * 1e-9, freq_hz,
problem.rho_ohm_m, sides=2)
sigma_buildup = 0.0
if problem.buildups and stack.buildup is not None \
and stack.buildup.any():
sigma_buildup = 1.0 / skin.sheet_resistance_ac(
problem.solder_thickness_nm * 1e-9, freq_hz,
problem.solder_rho_ohm_m, config.SKIN_SIDES)
if problem.extra_cu_nm > 0:
sigma_buildup += 1.0 / skin.sheet_resistance_ac(
problem.extra_cu_nm * 1e-9, freq_hz, problem.rho_ohm_m,
config.SKIN_SIDES)
eq_um = sigma_buildup * problem.rho_ohm_m * 1e6
print(f"solder buildup: {problem.solder_thickness_nm / 1000:.0f} um "
f"solder + {problem.extra_cu_nm / 1000:.0f} um Cu on "
f"{int(stack.buildup.sum())} cells "
f"(= {eq_um:.1f} um equivalent copper)")
if freq_hz > 0:
depth = skin.skin_depth_m(freq_hz, problem.rho_ohm_m)
print(f"AC @ {freq_hz:g} Hz: skin depth {depth * 1e6:.0f} um, "
f"per-layer Rs ratio "
f"{', '.join(f'{r:.2f}' for r in rs_ratios)}, "
f"via factor {via_factor:.2f}")
t0 = time.perf_counter()
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
if connected_restrict(stack, e1, e2, edges):
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
if stack.buildup is not None:
stack.buildup &= stack.masks
for _, m in (parts1 or []) + (parts2 or []):
m &= stack.masks # follow the component restriction
timings["edges_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
if contact_model == "equipotential":
Vflat, R, I1, I2, mismatch, volts_per_amp, info = \
_solve_equipotential(stack, e1, e2, edges)
else:
Vflat, R, I1, I2, mismatch, volts_per_amp, info = \
_solve_uniform(stack, e1, e2, edges)
timings["solve_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
s = i_test * volts_per_amp # unit-drive volts -> volts @ I_test
# per-edge power @ I_test; distribute in-plane power to endpoint cells
Pe = edges.w * ((Vflat[edges.a] - Vflat[edges.b]) * s) ** 2
inplane = edges.via_index < 0
Pflat = np.zeros(Vflat.size)
np.add.at(Pflat, edges.a[inplane], 0.5 * Pe[inplane])
np.add.at(Pflat, edges.b[inplane], 0.5 * Pe[inplane])
Parea = Pflat.reshape(L, ny, nx) / (h_m * h_m)
Parea[~stack.masks] = np.nan
plane = ny * nx
P_layers = [float(Pflat[li * plane:(li + 1) * plane].sum())
for li in range(L)]
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)
# via reports: max segment current + total power per via
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) # amps at unit drive
via_reports = []
if problem.vias:
vidx = edges.via_index
for vi in np.unique(vidx[vidx >= 0]):
sel = vidx == vi
via = problem.vias[vi]
via_reports.append(ViaReport(
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
drill_mm=via.drill_nm * 1e-6,
current_a=float(np.abs(Ie[sel]).max()) * s,
power_w=float(Pe[sel].sum()),
))
via_reports.sort(key=lambda v: v.current_a, reverse=True)
# per-injection-area currents
part_currents1 = _part_currents(
parts1 or [], Ie, edges, e1.ravel(), s, i_test,
contact_model, int(e1.sum()))
part_currents2 = _part_currents(
parts2 or [], Ie, edges, e2.ravel(), s, i_test,
contact_model, int(e2.sum()))
# embedded potential + per-layer current density @ I_test
V3 = np.full((L, ny, nx), np.nan)
V3[stack.masks] = Vflat.reshape(L, ny, nx)[stack.masks] * s
J3 = np.stack([
_face_current_density(
np.nan_to_num(V3[li]), stack.masks[li], sigmas[li],
h_m, problem.layers[li].thickness_nm * 1e-9,
sig2d=_sigma_2d(stack, li, sigmas[li], sigma_buildup),
rho=problem.rho_ohm_m)
for li in range(L)
])
timings["postprocess_s"] = time.perf_counter() - t0
return Result(
R_ohm=R, i_test=i_test, V=V3, Jmag=J3, Parea=Parea,
layer_names=list(stack.layer_names),
P_total=P_total, P_layers=P_layers, P_vias=P_vias,
power_balance_rel=balance, via_reports=via_reports,
I1_a=I1, I2_a=I2, mismatch_rel=mismatch,
n_free=info.n_unknowns, solve_info=info,
part_currents1=part_currents1, part_currents2=part_currents2,
contact_model=contact_model,
freq_hz=freq_hz,
skin_depth_um=(skin.skin_depth_m(freq_hz, problem.rho_ohm_m) * 1e6
if freq_hz > 0 else None),
rs_ratios=rs_ratios,
timings=timings,
)
+80
View File
@@ -0,0 +1,80 @@
"""Offline runner: solve a geometry_dump.json without KiCad.
python -m fill_resistance.standalone dump.json [--current 40]
[--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show] [--out DIR]
[--force-iterative]
This is the dev loop and the convergence-study tool (KiCad 10 has no
headless API server, so the plugin path always needs the GUI).
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from . import config, pipeline
from .errors import UserFacingError
from .geometry import load_problem
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
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",
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,
help="force grid cell size [um]")
ap.add_argument("--layers", type=str, default=None,
help="comma-separated subset of layers to include")
ap.add_argument("--out", type=Path, default=None,
help="output directory (default: next to the dump)")
ap.add_argument("--no-show", action="store_true",
help="save PNGs only, no windows")
ap.add_argument("--contact-model", choices=["uniform", "equipotential"],
default=None, help="contact model (default: config)")
ap.add_argument("--strip-buildup", action="store_true",
help="ignore solder buildup stored in the dump")
ap.add_argument("--extra-cu-um", type=float, default=None,
help="override the added copper in mask openings [um]")
ap.add_argument("--force-iterative", action="store_true",
help="use CG (Jacobi) regardless of problem size")
args = ap.parse_args(argv)
if args.cell_um is not None:
config.CELL_UM_OVERRIDE = args.cell_um
if args.no_show:
config.INTERACTIVE = False
if args.force_iterative:
config.SPSOLVE_MAX_UNKNOWNS = 0
problem = load_problem(args.dump)
if args.strip_buildup:
problem.buildups = []
if args.extra_cu_um is not None:
problem.extra_cu_nm = int(args.extra_cu_um * 1000)
if args.layers:
keep = [s.strip() for s in args.layers.split(",")]
problem.layers = [l for l in problem.layers if l.layer_name in keep]
if not problem.layers:
print(f"ERROR: no layer of the dump matches --layers {args.layers}",
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),
contact_model=args.contact_model)
except UserFacingError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())