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:
@@ -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}")
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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} mΩ")
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user