Initial import: KiCad zone resistance plugin

DC/AC resistance, power dissipation, and via/injection-area currents of
copper zone fills. KiCad 10 IPC-API plugin (kicad-python/kipy):
multi-layer via-coupled FDM solver, multi-part terminals via User.1/User.2
marker layers, pads as contacts, uniform-injection and equipotential
contact models, per-foil skin effect, optional solder/copper buildup on
mask openings. 54-case test suite incl. exact analytic references.

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

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://go.kicad.org/api/schemas/v1",
"identifier": "th.co.b4l.fill-resistance",
"name": "Fill Resistance",
"description": "DC resistance of a copper zone fill between two rectangle electrodes",
"runtime": {
"type": "python"
},
"actions": [
{
"identifier": "fill-resistance-run",
"name": "Fill Resistance",
"description": "Select two rectangles marking the contact areas, then run to compute the fill resistance between them",
"entrypoint": "fill_res_action.py",
"show-button": true,
"scopes": ["pcb"],
"icons-light": ["icons/fill_res_24_light.png"],
"icons-dark": ["icons/fill_res_24_dark.png"]
}
]
}
+5
View File
@@ -0,0 +1,5 @@
kicad-python>=0.7.0
numpy
scipy
matplotlib
PySide6
+100
View File
@@ -0,0 +1,100 @@
"""M1 smoke probe: verify the IPC API surface against a live KiCad.
Run from the dev venv while KiCad is open with the board loaded:
.venv\\Scripts\\python.exe smoke\\smoke_probe.py
Deliberately uses kipy directly (not board_io) so it works even if
board_io has a bug. Prints: version, board path, selection contents,
shapes summary, zones + filled polygon stats, stackup table.
"""
import sys
import traceback
from kipy import KiCad
from kipy.board_types import BoardRectangle, BoardShape
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
def mm(nm):
return nm / 1e6
def main():
print("== connect ==")
kicad = KiCad()
kicad.ping()
print("version:", kicad.get_version())
try:
print("check_version:", kicad.check_version())
except Exception as e:
print("check_version raised:", e)
board = kicad.get_board()
print("board.name:", board.name)
print("board_filename:", getattr(board.document, "board_filename", "?"))
print("\n== selection ==")
sel = list(board.get_selection())
print(f"{len(sel)} item(s) selected")
for item in sel:
line = f" {type(item).__name__}"
if isinstance(item, BoardRectangle):
tl, br = item.top_left, item.bottom_right
line += (f" layer={canonical_name(item.layer)}"
f" tl=({mm(tl.x):.2f}, {mm(tl.y):.2f})mm"
f" br=({mm(br.x):.2f}, {mm(br.y):.2f})mm")
elif isinstance(item, BoardShape):
line += f" layer={canonical_name(item.layer)}"
print(line)
print("\n== shapes (board-wide rectangles) ==")
shapes = list(board.get_shapes())
rect_shapes = [s for s in shapes if isinstance(s, BoardRectangle)]
print(f"{len(shapes)} shapes total, {len(rect_shapes)} rectangles")
for s in rect_shapes[:20]:
tl, br = s.top_left, s.bottom_right
print(f" rect on {canonical_name(s.layer)}: "
f"({mm(tl.x):.2f}, {mm(tl.y):.2f}) - ({mm(br.x):.2f}, {mm(br.y):.2f}) mm")
print("\n== zones ==")
for zone in board.get_zones():
ztype = ZoneType.Name(zone.type)
net = zone.net.name if zone.net is not None else "<none>"
layers = [canonical_name(l) for l in zone.layers]
print(f" zone '{zone.name}' type={ztype} net={net} "
f"layers={layers} filled={zone.filled}")
try:
for layer, polys in zone.filled_polygons.items():
narcs = 0
nnodes = 0
nholes = 0
for p in polys:
nnodes += len(p.outline.nodes)
nholes += len(p.holes)
narcs += sum(1 for n in p.outline.nodes if n.has_arc)
print(f" fill on {canonical_name(layer)}: {len(polys)} "
f"poly(s), {nnodes} outline nodes, {nholes} holes, "
f"{narcs} arc nodes")
except Exception:
print(" filled_polygons FAILED:")
traceback.print_exc()
print("\n== stackup ==")
try:
for sl in board.get_stackup().layers:
tname = BoardStackupLayerType.Name(sl.type)
lname = canonical_name(sl.layer) if sl.type == \
BoardStackupLayerType.BSLT_COPPER else "-"
print(f" {tname:18s} layer={lname:8s} thickness={sl.thickness} nm"
f" enabled={sl.enabled}")
except Exception:
traceback.print_exc()
print("\nsmoke probe DONE")
return 0
if __name__ == "__main__":
sys.exit(main())
View File
+106
View File
@@ -0,0 +1,106 @@
"""Solder/mask-opening buildup tests. Uniform-coverage and split-coverage
strips are 1D-exact, validating the harmonic-mean face weights and the
parallel-sheet conductance model to solver precision."""
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.geometry import (Polygon, SurfaceBuildup, load_problem,
save_problem)
from tests.util import NM, make_problem, ring_mm, sigma_s, strip_problem
RHO_CU = 1.68e-8
RHO_SN = 1.32e-7
def _with_buildup(p, polys_mm, layer="F.Cu", solder_um=50.0, extra_um=0.0):
p.buildups = [SurfaceBuildup(
layer_name=layer,
polygons=[Polygon(outline=ring_mm(pts)) for pts in polys_mm])]
p.solder_thickness_nm = int(solder_um * 1000)
p.solder_rho_ohm_m = RHO_SN
p.extra_cu_nm = int(extra_um * 1000)
return p
def _solve(problem, h_mm):
stack = raster.rasterize_stack(problem, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, problem)
return solver.run_solve(problem, stack, e1, e2, 1.0,
contact_model="equipotential"), stack
def _sigma_buildup(solder_um=50.0, extra_um=0.0):
return solder_um * 1e-6 / RHO_SN + extra_um * 1e-6 / RHO_CU
def test_full_coverage_exact():
"""Buildup over the whole strip: uniform parallel sheet, exact."""
plain = strip_problem(length=50, width=10, e_len=5)
covered = _with_buildup(strip_problem(length=50, width=10, e_len=5),
[[(0, 0), (50, 0), (50, 10), (0, 10)]])
r0, _ = _solve(plain, 0.5)
r1, stack = _solve(covered, 0.5)
sig = sigma_s() + _sigma_buildup()
assert r1.R_ohm == pytest.approx(81 / 20 / sig, rel=1e-9)
assert r1.R_ohm < r0.R_ohm
assert stack.buildup is not None and stack.buildup.any()
def test_half_coverage_series_exact():
"""Buildup over the right half: plain faces + one harmonic-mean
interface face + buildup faces in series, exact."""
p = _with_buildup(strip_problem(length=50, width=10, e_len=5),
[[(25, 0), (50, 0), (50, 10), (25, 10)]])
res, _ = _solve(p, 0.5)
s1 = sigma_s()
s2 = s1 + _sigma_buildup()
r_row = 40 / s1 + (s1 + s2) / (2 * s1 * s2) + 40 / s2
assert res.R_ohm == pytest.approx(r_row / 20, rel=1e-9)
def test_buildup_only_over_hole_is_inert():
"""Solder wets copper only: an opening over a hole changes nothing."""
holed = [([(0, 0), (50, 0), (50, 10), (0, 10)],
[[(20, 2), (30, 2), (30, 8), (20, 8)]])]
plain = make_problem(holed, rect1_mm=(0, 0, 5, 10),
rect2_mm=(45, 0, 50, 10))
masked = _with_buildup(
make_problem(holed, rect1_mm=(0, 0, 5, 10),
rect2_mm=(45, 0, 50, 10)),
[[(21, 3), (29, 3), (29, 7), (21, 7)]]) # strictly inside the hole
r0, _ = _solve(plain, 0.5)
r1, _ = _solve(masked, 0.5)
assert r1.R_ohm == pytest.approx(r0.R_ohm, rel=1e-12)
def test_extra_copper_helps_more_than_solder():
base = strip_problem(length=50, width=10, e_len=5)
solder_only = _with_buildup(strip_problem(length=50, width=10, e_len=5),
[[(0, 0), (50, 0), (50, 10), (0, 10)]])
with_cu = _with_buildup(strip_problem(length=50, width=10, e_len=5),
[[(0, 0), (50, 0), (50, 10), (0, 10)]],
extra_um=70.0)
r0, _ = _solve(base, 0.5)
r1, _ = _solve(solder_only, 0.5)
r2, _ = _solve(with_cu, 0.5)
assert r2.R_ohm < r1.R_ohm < r0.R_ohm
# 50 um SAC solder ~ 6.4 um Cu: expect a modest (<15%) improvement
assert r1.R_ohm > 0.85 * r0.R_ohm
# +70 um Cu roughly halves R (2x thickness + solder)
assert r2.R_ohm < 0.55 * r0.R_ohm
def test_json_v4_roundtrip(tmp_path):
p = _with_buildup(strip_problem(), [[(0, 0), (50, 0), (50, 10), (0, 10)]],
extra_um=35.0)
f = tmp_path / "d.json"
save_problem(p, f)
q = load_problem(f)
assert len(q.buildups) == 1 and q.buildups[0].layer_name == "F.Cu"
assert q.solder_thickness_nm == 50_000
assert q.extra_cu_nm == 35_000
assert q.solder_rho_ohm_m == pytest.approx(RHO_SN)
r_p, _ = _solve(p, 0.5)
r_q, _ = _solve(q, 0.5)
assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12)
+172
View File
@@ -0,0 +1,172 @@
"""Contact-model (uniform vs equipotential) and multi-part terminal tests."""
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.errors import ElectrodeError
from fill_resistance.geometry import Electrode
from tests.util import NM, make_problem, rect_mm, sigma_s, strip_problem
def _solve(problem, h_mm, model, i_test=1.0):
stack = raster.rasterize_stack(problem, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, problem)
parts1, parts2 = raster.electrode_partition(stack, problem)
return solver.run_solve(problem, stack, e1, e2, i_test,
contact_model=model,
parts1=parts1, parts2=parts2), stack
def _uniform_1d_reference(m_cols, n1, n2, sig, rows):
"""Independent 1D reference: uniform injection over the first n1
columns, extraction over the last n2, unit total current. R from
mean potentials, per row conductance sig, `rows` parallel rows."""
inj = np.zeros(m_cols)
inj[:n1] += 1.0 / n1
inj[m_cols - n2:] -= 1.0 / n2
face_current = np.cumsum(inj)[:-1] # current through face k,k+1
v = np.zeros(m_cols)
v[1:] = -np.cumsum(face_current) / sig # per single row of cells
v_plus = v[:n1].mean()
v_minus = v[m_cols - n2:].mean()
return (v_plus - v_minus) / 1.0 / rows # rows in parallel
def test_uniform_strip_exact_1d():
"""Full-width contacts on a uniform strip: rows are identical 1D
chains; compare with an independent 1D computation, exact."""
p = strip_problem(length=50, width=10, e_len=5)
res, _ = _solve(p, 0.5, "uniform")
sig = sigma_s()
r_ref = _uniform_1d_reference(m_cols=100, n1=10, n2=10, sig=sig, rows=20)
assert res.R_ohm == pytest.approx(r_ref, rel=1e-9)
assert res.contact_model == "uniform"
assert res.power_balance_rel < 1e-9 # P = b^T V = I^2 R identity
def test_uniform_higher_than_equipotential():
p = strip_problem(length=50, width=10, e_len=5)
r_uni, _ = _solve(p, 0.5, "uniform")
r_equ, _ = _solve(p, 0.5, "equipotential")
assert r_uni.R_ohm > r_equ.R_ohm
def test_uniform_current_density_ramps_inside_contact():
"""Inside the V+ contact, |J| must ramp: ~0 at the outer edge,
~full sheet current at the inner (leading) edge; the equipotential
model shows ~0 throughout the contact interior."""
p = strip_problem(length=50, width=10, e_len=5)
res_u, stack = _solve(p, 0.5, "uniform")
ny, nx = stack.shape2d
row = ny // 2
# contact columns are the first 10 copper columns (margin = 2)
j_outer = res_u.Jmag[0, row, 2] # first contact column
j_inner = res_u.Jmag[0, row, 11] # last contact column
j_free = res_u.Jmag[0, row, nx // 2] # mid strip = I/(W t)
assert j_inner > 0.8 * j_free # ramped up to ~full
assert j_outer < 0.2 * j_free # near zero at outer edge
assert j_inner > 5 * max(j_outer, 1e-30)
res_e, _ = _solve(p, 0.5, "equipotential")
j_center_e = res_e.Jmag[0, row, 6] # deep inside Dirichlet region
assert j_center_e < 0.05 * j_free
def test_multipart_terminal_equals_single_rect():
"""V+ split into two half-height rectangles == one full rectangle,
for both contact models (exact)."""
whole = strip_problem(length=50, width=10, e_len=5)
split = strip_problem(length=50, width=10, e_len=5)
split.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 5, 5))),
Electrode(rect=rect_mm((0, 5, 5, 10))),
]
for model in ("uniform", "equipotential"):
r_whole, _ = _solve(whole, 0.5, model)
r_split, _ = _solve(split, 0.5, model)
assert r_split.R_ohm == pytest.approx(r_whole.R_ohm, rel=1e-9), model
def test_multipart_asymmetric_parts():
"""Two separated V+ parts feeding one V-: sane R, balance holds."""
p = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 3), rect2_mm=(45, 0, 50, 10))
p.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 2, 3)), label="top lug"),
Electrode(rect=rect_mm((0, 7, 2, 10)), label="bottom lug"),
]
single, _ = _solve(
make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 3), rect2_mm=(45, 0, 50, 10)),
0.25, "uniform")
multi, _ = _solve(p, 0.25, "uniform")
assert multi.R_ohm < single.R_ohm # more contact area helps
assert multi.power_balance_rel < 1e-9
def test_part_off_copper_raises_with_label():
p = strip_problem(length=50, width=10, e_len=5)
p.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 5, 10))),
Electrode(rect=rect_mm((100, 100, 105, 105)), label="stray part"),
]
stack = raster.rasterize_stack(p, 0.5 * NM)
with pytest.raises(ElectrodeError, match="stray part"):
raster.electrode_masks(stack, p)
def test_injection_area_currents_equipotential_flux():
"""Two V+ lugs at different distances: the nearer one carries more;
the flux split sums exactly to the test current."""
p = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 10), rect2_mm=(48, 0, 50, 10))
p.electrodes1 = [
Electrode(rect=rect_mm((0, 4, 2, 6)), label="far lug"),
Electrode(rect=rect_mm((10, 4, 12, 6)), label="near lug"),
]
res, _ = _solve(p, 0.25, "equipotential", i_test=10.0)
pc = dict(res.part_currents1)
assert pc["near lug"] > pc["far lug"]
assert pc["near lug"] + pc["far lug"] == pytest.approx(10.0, rel=1e-9)
# V- side: single part carries everything
assert res.part_currents2[0][1] == pytest.approx(10.0, rel=1e-9)
def test_injection_area_currents_uniform_area_share():
"""Uniform model: each injection area carries exactly its cell share."""
p = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 10), rect2_mm=(48, 0, 50, 10))
p.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 2, 6)), label="big"), # 2x6 mm
Electrode(rect=rect_mm((0, 6, 2, 9)), label="small"), # 2x3 mm
]
res, _ = _solve(p, 0.25, "uniform", i_test=9.0)
pc = dict(res.part_currents1)
# cell counts: 8x24 = 192 and 8x12 = 96 at h=0.25 -> shares 2/3, 1/3
assert pc["big"] == pytest.approx(6.0, rel=1e-12)
assert pc["small"] == pytest.approx(3.0, rel=1e-12)
def test_injection_area_partition_first_wins():
"""Overlapping parts: shared cells attributed to the first part, so
the shares still sum to the terminal current."""
p = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 0, 2, 10), rect2_mm=(48, 0, 50, 10))
p.electrodes1 = [
Electrode(rect=rect_mm((0, 0, 2, 6)), label="first"),
Electrode(rect=rect_mm((0, 4, 2, 10)), label="second"), # overlaps
]
res, _ = _solve(p, 0.25, "uniform", i_test=1.0)
total = sum(a for _, a in res.part_currents1)
assert total == pytest.approx(1.0, rel=1e-12)
def test_touching_ok_uniform_error_equipotential():
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(5, 0, 10, 10))
res, _ = _solve(p, 0.5, "uniform") # touching is fine here
assert res.R_ohm > 0
p2 = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(5, 0, 10, 10))
with pytest.raises(ElectrodeError, match="touch"):
_solve(p2, 0.5, "equipotential")
+88
View File
@@ -0,0 +1,88 @@
import json
import math
import numpy as np
from fill_resistance.geometry import (arc_points, linearize_ring,
load_problem, problem_from_json,
save_problem)
from tests.util import make_multilayer, strip_problem
def test_arc_points_quarter_circle():
r = 10_000_000 # 10 mm in nm
start = (r, 0)
mid = (int(r / math.sqrt(2)), int(r / math.sqrt(2)))
end = (0, r)
tol = 50_000 # 50 um
pts = arc_points(start, mid, end, tol)
assert len(pts) >= 3
radii = np.hypot(pts[:, 0].astype(float), pts[:, 1].astype(float))
assert np.allclose(radii, r, rtol=1e-6)
allpts = np.vstack([pts, [end]]).astype(float)
for a, b in zip(allpts[:-1], allpts[1:]):
half_chord = np.hypot(*(b - a)) / 2
sagitta = r - math.sqrt(max(r**2 - half_chord**2, 0.0))
assert sagitta <= tol * 1.01
def test_arc_points_collinear_degrades_to_segment():
pts = arc_points((0, 0), (5_000_000, 0), (10_000_000, 0), 1000)
assert len(pts) == 1
assert tuple(pts[0]) == (0, 0)
def test_linearize_ring_mixed_nodes_and_closure():
nodes = [
("pt", (0, 0)),
("pt", (10, 0)),
("arc", ((10, 0), (17, 7), (10, 14))),
("pt", (0, 14)),
("pt", (0, 0)),
]
ring = linearize_ring(nodes, tol_nm=1)
assert (ring[0] != ring[-1]).any()
assert len(ring) > 4
def test_problem_json_roundtrip_v2(tmp_path):
p = make_multilayer(
[[([(0, 0), (10, 0), (10, 1), (0, 1)], [])],
[([(0, 0), (10, 0), (10, 1), (0, 1)], [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1", vias_mm=[(5.5, 0.5)])
f = tmp_path / "dump.json"
save_problem(p, f)
q = load_problem(f)
assert q.layer_names == ["L0", "L1"]
assert q.electrodes1[0].contact == "L0"
assert q.electrodes2[0].contact == "L1"
assert len(q.vias) == 1 and q.vias[0].drill_nm == p.vias[0].drill_nm
assert q.plating_nm == p.plating_nm
assert np.array_equal(q.layers[0].polygons[0].outline,
p.layers[0].polygons[0].outline)
assert abs(q.sigma_s(0) - p.sigma_s(0)) < 1e-12 * p.sigma_s(0)
def test_v1_schema_still_loads():
p = strip_problem()
v1 = {
"schema_version": 1,
"board_path": "old",
"layer_name": "F.Cu",
"net_name": "GND",
"thickness_nm": 70000,
"thickness_source": "stackup",
"rho_ohm_m": 1.68e-8,
"rect1": {"x0": 0, "y0": 0, "x1": 1000, "y1": 1000,
"layer_name": "User.1"},
"rect2": {"x0": 5000, "y0": 0, "x1": 6000, "y1": 1000,
"layer_name": "User.1"},
"polygons": [{"outline": p.layers[0].polygons[0].outline.tolist(),
"holes": []}],
}
q = problem_from_json(json.loads(json.dumps(v1)))
assert q.layer_names == ["F.Cu"]
assert q.vias == []
assert q.electrodes1[0].contact == "all"
assert q.layers[0].thickness_nm == 70000
+148
View File
@@ -0,0 +1,148 @@
"""Multi-layer / via solver tests. The 1-cell-wide strip cases are pure
series chains, so the discrete solution is exact and validates the via
barrel model, layer coupling, and flux integration to solver precision."""
import math
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.errors import ConnectivityError, ElectrodeError
from tests.util import NM, make_multilayer, sigma_s
def _solve(problem, h_mm, i_test=1.0):
stack = raster.rasterize_stack(problem, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, problem)
return solver.run_solve(problem, stack, e1, e2, i_test,
contact_model="equipotential"), stack
STRIP = [(0, 0), (10, 0), (10, 1), (0, 1)] # 10 x 1 mm; h=1 -> 1 row
def _r_via(length_mm, drill_mm=0.3, plating_um=18.0, rho=1.68e-8):
area = math.pi * (drill_mm * 1e-3) * (plating_um * 1e-6)
return rho * (length_mm * 1e-3) / area
def test_two_identical_layers_parallel():
"""Both electrodes contact both layers, no vias needed: two identical
independent sheets in parallel -> exactly half the single-layer R."""
one = make_multilayer([[(STRIP, [])]], (0, 0, 1, 1), (9, 0, 10, 1))
two = make_multilayer([[(STRIP, [])], [(STRIP, [])]],
(0, 0, 1, 1), (9, 0, 10, 1))
r1, _ = _solve(one, 1.0)
r2, _ = _solve(two, 1.0)
assert r2.R_ohm == pytest.approx(r1.R_ohm / 2, rel=1e-9)
def test_via_chain_1d_exact():
"""e1 on L0 left end, e2 on L1 right end, one through-via at x=5.5:
R = faces_L0/sigma + R_via + faces_L1/sigma, exact."""
p = make_multilayer(
[[(STRIP, [])], [(STRIP, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1",
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
res, stack = _solve(p, 1.0)
# cells at x centers 0.5..9.5 -> cols 0..9 (plus margins); electrode
# cells: col of 0.5 (e1), col of 9.5 (e2); via cell: col of 5.5
sig = sigma_s()
faces_l0 = 5 # cols 0->5: faces between 0|1 .. 4|5
faces_l1 = 4 # cols 5->9
r_exact = (faces_l0 + faces_l1) / sig + _r_via(1.0)
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
assert res.mismatch_rel < 1e-10
assert len(res.via_reports) == 1
# the single via carries the full test current
assert res.via_reports[0].current_a == pytest.approx(1.0, rel=1e-9)
assert res.via_reports[0].power_w == pytest.approx(_r_via(1.0), rel=1e-9)
def test_parallel_vias_halve_barrel_resistance():
base = dict(rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1", gap_mm=1.0)
one = make_multilayer([[(STRIP, [])], [(STRIP, [])]],
vias_mm=[(5.5, 0.5)], **base)
two = make_multilayer([[(STRIP, [])], [(STRIP, [])]],
vias_mm=[(5.5, 0.5), (5.5, 0.5)], **base)
r_one, _ = _solve(one, 1.0)
r_two, _ = _solve(two, 1.0)
assert (r_one.R_ohm - r_two.R_ohm) == pytest.approx(_r_via(1.0) / 2,
rel=1e-9)
def test_antipad_bridging():
"""3 layers; the middle layer has an antipad hole at the via cell, so
the barrel bridges L0 -> L2 directly with DOUBLE the length."""
mid_with_hole = [(STRIP, [[(5, 0), (6, 0), (6, 1), (5, 1)]])]
p = make_multilayer(
[[(STRIP, [])], mid_with_hole, [(STRIP, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L2",
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
res, _ = _solve(p, 1.0)
sig = sigma_s()
r_exact = (5 + 4) / sig + _r_via(2.0) # barrel length 2 mm
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
def test_via_short_between_electrodes_raises():
"""Both electrodes over the SAME cell on different layers with a via
there = direct short, no free copper -> error."""
p = make_multilayer(
[[(STRIP, [])], [(STRIP, [])]],
rect1_mm=(5, 0, 6, 1), rect2_mm=(5, 0, 6, 1),
contact1="L0", contact2="L1",
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
stack = raster.rasterize_stack(p, 1.0 * NM)
e1, e2 = raster.electrode_masks(stack, p)
with pytest.raises(ElectrodeError, match="directly connected"):
solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
def test_layers_without_via_disconnected():
p = make_multilayer(
[[(STRIP, [])], [(STRIP, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1", gap_mm=1.0) # no vias
stack = raster.rasterize_stack(p, 1.0 * NM)
e1, e2 = raster.electrode_masks(stack, p)
with pytest.raises(ConnectivityError, match="not connected"):
solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
def test_power_split_layers_and_vias():
"""Power accounting: layer + via powers sum to I^2 R."""
p = make_multilayer(
[[(STRIP, [])], [(STRIP, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1",
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
res, _ = _solve(p, 1.0, i_test=10.0)
assert res.power_balance_rel < 1e-9
assert res.P_vias == pytest.approx(100 * _r_via(1.0), rel=1e-9)
sig = sigma_s()
assert res.P_layers[0] == pytest.approx(100 * 5 / sig, rel=1e-9)
assert res.P_layers[1] == pytest.approx(100 * 4 / sig, rel=1e-9)
def test_pad_polygon_electrode():
"""A polygon-shaped electrode (pad) restricted to one layer."""
from fill_resistance.geometry import Electrode, Polygon
from tests.util import rect_mm, ring_mm
p = make_multilayer([[(STRIP, [])]], (0, 0, 1, 1), (9, 0, 10, 1))
# replace terminal 1 with a small polygon pad covering the same cell
p.electrodes1 = [Electrode(
rect=rect_mm((0.2, 0.2, 0.8, 0.8)),
contact="all",
polygons=[Polygon(outline=ring_mm(
[(0.2, 0.2), (0.8, 0.2), (0.8, 0.8), (0.2, 0.8)]))],
label="pad TP1.1",
)]
res, _ = _solve(p, 1.0)
sig = sigma_s()
assert res.R_ohm == pytest.approx(9 / sig, rel=1e-9) # cols 0..9
+102
View File
@@ -0,0 +1,102 @@
import numpy as np
import pytest
from fill_resistance import config, raster, solver
from fill_resistance.errors import (ConnectivityError, ElectrodeError,
GridSizeError)
from tests.util import NM, make_problem, strip_problem
def _stack(problem, h_mm):
return raster.rasterize_stack(problem, h_mm * NM)
def test_exact_cell_count_square_with_hole():
# 10x10 mm square, centered 4x4 mm hole, h=1 mm: cell centers at
# half-integers, no boundary ambiguity -> exactly 100 - 16 cells
p = make_problem(
[([(0, 0), (10, 0), (10, 10), (0, 10)],
[[(3, 3), (7, 3), (7, 7), (3, 7)]])],
rect1_mm=(0, 0, 1, 10), rect2_mm=(9, 0, 10, 10))
stack = _stack(p, 1.0)
assert int(stack.masks[0].sum()) == 100 - 16
def test_margin_cells_are_empty():
p = strip_problem()
stack = _stack(p, 0.5)
m = stack.masks[0]
assert not m[0, :].any() and not m[-1, :].any()
assert not m[:, 0].any() and not m[:, -1].any()
def test_electrode_masks_and_counts():
p = strip_problem(length=50, width=10, e_len=5)
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p)
# electrodes: 5 mm / 0.5 mm = 10 columns x 20 rows on 1 layer
assert int(e1.sum()) == 200 and int(e2.sum()) == 200
def test_electrode_off_copper_raises():
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(20, 20, 25, 25), rect2_mm=(8, 0, 10, 10))
stack = _stack(p, 0.5)
with pytest.raises(ElectrodeError, match="does not overlap"):
raster.electrode_masks(stack, p)
def test_touching_electrodes_raise_equipotential():
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(5, 0, 10, 10))
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p) # touch is fine at mask level
with pytest.raises(ElectrodeError, match="touch"):
solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
def test_disconnected_regions_raise():
p = make_problem(
[([(0, 0), (10, 0), (10, 10), (0, 10)], []),
([(20, 0), (30, 0), (30, 10), (20, 10)], [])],
rect1_mm=(0, 0, 2, 10), rect2_mm=(28, 0, 30, 10))
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p)
with pytest.raises(ConnectivityError, match="not connected"):
solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
def test_islands_dropped():
# island square not touching the main strip disappears from the mask
p = make_problem(
[([(0, 0), (50, 0), (50, 10), (0, 10)], []),
([(20, 20), (30, 20), (30, 30), (20, 30)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(45, 0, 50, 10))
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p)
before = int(stack.masks.sum())
solver.run_solve(p, stack, e1, e2, 1.0, contact_model="equipotential")
after = int(stack.masks.sum())
assert after < before
assert after == 100 * 20 # only the strip remains
def test_hard_max_cells_guard(monkeypatch):
monkeypatch.setattr(config, "CELL_UM_OVERRIDE", 1.0) # 1 um cells
p = strip_problem()
with pytest.raises(GridSizeError, match="M cells"):
raster.choose_cell_size(p.copper_bbox(), len(p.layers))
def test_auto_cell_size_hits_target():
# large plane: unclamped regime, cell count tracks TARGET_CELLS
p = strip_problem(length=200, width=100, e_len=5)
h = raster.choose_cell_size(p.copper_bbox(), 1)
ncells = (200.0 * NM / h) * (100.0 * NM / h)
assert 0.5 * config.TARGET_CELLS < ncells < 2.0 * config.TARGET_CELLS
# small board: MIN_CELL_UM clamp kicks in instead
q = strip_problem() # 50x10 mm
hq = raster.choose_cell_size(q.copper_bbox(), 1)
assert hq == pytest.approx(config.MIN_CELL_UM * 1000)
+103
View File
@@ -0,0 +1,103 @@
"""Skin-effect model tests. The single-layer AC solve scales ALL in-plane
conductances identically, so R_AC = R_DC * resistance_factor EXACTLY -
which turns the analytic foil formula into an end-to-end exact test."""
import math
import numpy as np
import pytest
from fill_resistance import raster, skin, solver
from tests.util import NM, make_multilayer, strip_problem
RHO = 1.68e-8
def _solve(problem, h_mm, i_test=1.0, freq=0.0):
stack = raster.rasterize_stack(problem, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, problem)
return solver.run_solve(problem, stack, e1, e2, i_test, freq,
contact_model="equipotential"), stack
def test_skin_depth_value():
# copper @ 1 MHz: ~65-66 um
assert skin.skin_depth_m(1e6, RHO) * 1e6 == pytest.approx(65.2, rel=0.01)
def test_sheet_resistance_dc_limit():
t = 70e-6
assert skin.sheet_resistance_ac(t, 0.0, RHO) == pytest.approx(RHO / t)
# low frequency: within 0.1% of DC
assert skin.sheet_resistance_ac(t, 100.0, RHO) == pytest.approx(
RHO / t, rel=1e-3)
def test_sheet_resistance_high_f_limits():
t = 70e-6
f = 1e9 # delta << t
delta = skin.skin_depth_m(f, RHO)
assert skin.sheet_resistance_ac(t, f, RHO, sides=1) == pytest.approx(
RHO / delta, rel=0.01)
assert skin.sheet_resistance_ac(t, f, RHO, sides=2) == pytest.approx(
RHO / (2 * delta), rel=0.01)
def test_resistance_factor_monotonic():
t = 70e-6
factors = [skin.resistance_factor(t, f, RHO)
for f in (0, 1e4, 1e5, 1e6, 1e7, 1e8)]
assert all(b >= a - 1e-12 for a, b in zip(factors, factors[1:]))
assert factors[0] == 1.0
def test_parse_frequency():
assert skin.parse_frequency("") == 0.0
assert skin.parse_frequency("0") == 0.0
assert skin.parse_frequency("142k") == 142_000.0
assert skin.parse_frequency("1.5M") == 1_500_000.0
assert skin.parse_frequency("2meg") == 2_000_000.0
assert skin.parse_frequency("100000") == 100_000.0
assert skin.parse_frequency("100 kHz") == 100_000.0
assert skin.parse_frequency("junk") == 0.0
def test_single_layer_ac_scales_exactly():
"""Uniform conductance scaling leaves the field shape unchanged:
R_AC = R_DC * factor to solver precision."""
p = strip_problem(length=50, width=10, e_len=5)
f = 2e6 # delta=46um < t=70um
r_dc, _ = _solve(p, 0.5)
r_ac, _ = _solve(p, 0.5, freq=f)
factor = skin.resistance_factor(70e-6, f, RHO, sides=1)
assert factor > 1.2 # real crowding at 2 MHz
assert r_ac.R_ohm == pytest.approx(r_dc.R_ohm * factor, rel=1e-9)
assert r_ac.rs_ratios[0] == pytest.approx(factor, rel=1e-12)
assert r_ac.skin_depth_um == pytest.approx(
skin.skin_depth_m(f, RHO) * 1e6, rel=1e-12)
def test_via_chain_ac_exact():
"""1D chain: layers scale by the foil factor, the barrel by the
plating-wall factor - exact composition."""
STRIP = [(0, 0), (10, 0), (10, 1), (0, 1)]
p = make_multilayer(
[[(STRIP, [])], [(STRIP, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(9, 0, 10, 1),
contact1="L0", contact2="L1",
vias_mm=[(5.5, 0.5)], gap_mm=1.0)
f = 2e6
sig_ac = 1.0 / skin.sheet_resistance_ac(70e-6, f, RHO, sides=1)
via_factor = skin.resistance_factor(18e-6, f, RHO, sides=2)
r_via_dc = RHO * 1e-3 / (math.pi * 0.3e-3 * 18e-6)
r_exact = (5 + 4) / sig_ac + r_via_dc * via_factor
res, _ = _solve(p, 1.0, freq=f)
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
def test_dc_default_unchanged():
"""freq omitted -> identical to the pre-skin behavior."""
p = strip_problem(length=50, width=10, e_len=5)
res, _ = _solve(p, 0.5)
assert res.freq_hz == 0.0
assert res.skin_depth_um is None
assert all(r == 1.0 for r in res.rs_ratios)
+124
View File
@@ -0,0 +1,124 @@
import numpy as np
import pytest
from fill_resistance import config, raster, solver
from tests.util import NM, make_problem, strip_problem
def _solve(problem, h_mm, i_test=1.0):
stack = raster.rasterize_stack(problem, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, problem)
return solver.run_solve(problem, stack, e1, e2, i_test,
contact_model="equipotential"), stack
def test_uniform_strip_exact_discrete():
"""Full-width electrodes on a uniform strip: every row is an identical
series chain, so the discrete solution is exact:
R = (n_free_columns + 1) / (n_rows * sigma_s)."""
p = strip_problem(length=50, width=10, e_len=5)
res, stack = _solve(p, 0.5)
n_free_cols = int(round((50 - 2 * 5) / 0.5)) # 80
n_rows = int(round(10 / 0.5)) # 20
r_exact = (n_free_cols + 1) / n_rows / p.sigma_s(0)
assert res.R_ohm == pytest.approx(r_exact, rel=1e-9)
assert res.mismatch_rel < 1e-10
r_cont = p.rho_ohm_m * 0.0405 / (0.010 * 70e-6)
assert res.R_ohm == pytest.approx(r_cont, rel=1e-6)
def test_strip_R_independent_of_h():
p = strip_problem(length=50, width=10, e_len=5)
for h in (1.0, 0.5, 0.25):
res, _ = _solve(p, h)
m = int(round(40 / h))
rows = int(round(10 / h))
assert res.R_ohm == pytest.approx((m + 1) / rows / p.sigma_s(0),
rel=1e-9)
def test_partial_electrode_constriction():
full = strip_problem(length=50, width=10, e_len=5)
partial = make_problem(
[([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=(0, 4, 5, 6),
rect2_mm=(45, 4, 50, 6))
r_full, _ = _solve(full, 0.25)
r_a, _ = _solve(partial, 0.25)
r_b, _ = _solve(partial, 0.125)
assert r_a.R_ohm > r_full.R_ohm * 1.05
assert abs(r_a.R_ohm - r_b.R_ohm) < 0.01 * r_b.R_ohm
def test_l_shape_corner_squares():
"""Right-angle bend of equal-width arms: corner square counts as
~0.559 squares (conformal-mapping result), within 5% at a fine grid."""
w = 10.0
outline = [(0, 0), (30, 0), (30, 30), (20, 30), (20, 10), (0, 10)]
p = make_problem([(outline, [])],
rect1_mm=(0, 0, 2, 10),
rect2_mm=(20, 28, 30, 30))
res, _ = _solve(p, 0.125)
a_sq = (20 - 2) / w
b_sq = (28 - 10) / w
r_expect = (a_sq + b_sq + 0.559) / p.sigma_s(0)
assert res.R_ohm == pytest.approx(r_expect, rel=0.05)
def test_hole_increases_resistance_and_converges():
solid = make_problem([([(0, 0), (40, 0), (40, 20), (0, 20)], [])],
rect1_mm=(0, 0, 2, 20), rect2_mm=(38, 0, 40, 20))
holed = make_problem(
[([(0, 0), (40, 0), (40, 20), (0, 20)],
[[(15, 5), (25, 5), (25, 15), (15, 15)]])],
rect1_mm=(0, 0, 2, 20), rect2_mm=(38, 0, 40, 20))
r_solid, _ = _solve(solid, 0.25)
r_a, _ = _solve(holed, 0.25)
r_b, _ = _solve(holed, 0.125)
assert r_a.R_ohm > r_solid.R_ohm * 1.1
assert abs(r_a.R_ohm - r_b.R_ohm) < 0.01 * r_b.R_ohm
def test_cg_path_matches_direct(monkeypatch):
p = strip_problem(length=50, width=10, e_len=5)
r_direct, _ = _solve(p, 0.25)
monkeypatch.setattr(config, "SPSOLVE_MAX_UNKNOWNS", 0)
r_cg, _ = _solve(p, 0.25)
assert r_cg.solve_info.method == "cg+jacobi"
assert r_cg.R_ohm == pytest.approx(r_direct.R_ohm, rel=1e-6)
assert r_cg.mismatch_rel < 1e-5
def test_current_density_and_potential_scale():
"""Uniform strip at 1 A: |J| in the free region equals I/(W t); the
potential span equals R * I."""
p = strip_problem(length=50, width=10, e_len=5)
res, stack = _solve(p, 0.5)
j_expect = 1.0 / (0.010 * 70e-6)
ny, nx = stack.shape2d
assert res.Jmag[0, ny // 2, nx // 2] == pytest.approx(j_expect, rel=1e-6)
assert np.nanmax(res.V) == pytest.approx(res.R_ohm, rel=1e-9)
def test_test_current_scaling():
"""V and J scale linearly with I_test, power quadratically; R fixed."""
p = strip_problem(length=50, width=10, e_len=5)
r1, _ = _solve(p, 0.5, i_test=1.0)
r10, _ = _solve(p, 0.5, i_test=10.0)
assert r10.R_ohm == pytest.approx(r1.R_ohm, rel=1e-12)
assert np.nanmax(r10.V) == pytest.approx(10 * np.nanmax(r1.V), rel=1e-9)
assert np.nanmax(r10.Jmag) == pytest.approx(10 * np.nanmax(r1.Jmag),
rel=1e-9)
assert r10.P_total == pytest.approx(100 * r1.P_total, rel=1e-9)
def test_power_identity():
"""Sum of edge powers equals I^2 R exactly for the direct solve."""
p = make_problem(
[([(0, 0), (40, 0), (40, 20), (0, 20)],
[[(15, 5), (25, 5), (25, 15), (15, 15)]])],
rect1_mm=(0, 0, 2, 20), rect2_mm=(38, 0, 40, 20))
res, _ = _solve(p, 0.25, i_test=40.0)
assert res.power_balance_rel < 1e-9
assert res.P_total == pytest.approx(40.0 ** 2 * res.R_ohm, rel=1e-12)
assert res.P_vias == 0.0
+74
View File
@@ -0,0 +1,74 @@
"""Synthetic Problem builders for tests. Dimensions in mm here, nm inside."""
import numpy as np
from fill_resistance.geometry import (Electrode, LayerFill, Polygon, Problem,
Rect, ViaLink)
NM = 1_000_000 # nm per mm
def ring_mm(points_mm) -> np.ndarray:
return (np.asarray(points_mm, dtype=float) * NM).astype(np.int64)
def _polys(polygons_mm) -> list[Polygon]:
return [
Polygon(outline=ring_mm(outline), holes=[ring_mm(h) for h in holes])
for outline, holes in polygons_mm
]
def rect_mm(r, layer="User.1") -> Rect:
return Rect.normalized(int(r[0] * NM), int(r[1] * NM),
int(r[2] * NM), int(r[3] * NM), layer)
def make_multilayer(layers_mm, rect1_mm, rect2_mm, contact1="all",
contact2="all", vias_mm=(), t_um=70.0, gap_mm=1.0,
drill_mm=0.3, plating_um=18.0, rho=1.68e-8) -> Problem:
"""layers_mm: one list of (outline_pts, [holes]) per layer; layer i is
named 'L{i}' at z = i * gap_mm. vias_mm: (x, y) through-barrels."""
nlayers = len(layers_mm)
return Problem(
board_path="synthetic",
net_name="TEST",
rho_ohm_m=rho,
plating_nm=int(plating_um * 1000),
layers=[
LayerFill(layer_name=f"L{i}", thickness_nm=int(t_um * 1000),
z_nm=int(i * gap_mm * NM), polygons=_polys(polys_mm))
for i, polys_mm in enumerate(layers_mm)
],
vias=[
ViaLink(x=int(x * NM), y=int(y * NM),
drill_nm=int(drill_mm * NM),
z_top_nm=-1, z_bot_nm=int((nlayers - 1) * gap_mm * NM) + 1)
for x, y in vias_mm
],
electrodes1=[Electrode(rect=rect_mm(rect1_mm), contact=contact1)],
electrodes2=[Electrode(rect=rect_mm(rect2_mm), contact=contact2)],
thickness_source="override",
)
def make_problem(polygons_mm, rect1_mm, rect2_mm, t_um=70.0,
rho=1.68e-8) -> Problem:
"""Single-layer problem (the v1 test surface)."""
p = make_multilayer([polygons_mm], rect1_mm, rect2_mm, t_um=t_um, rho=rho)
p.layers[0].layer_name = "F.Cu"
return p
def strip_problem(length=50.0, width=10.0, e_len=5.0, t_um=70.0):
"""Uniform strip with full-width electrodes at both ends."""
outline = [(0, 0), (length, 0), (length, width), (0, width)]
return make_problem(
[(outline, [])],
rect1_mm=(0, 0, e_len, width),
rect2_mm=(length - e_len, 0, length, width),
t_um=t_um,
)
def sigma_s(t_um=70.0, rho=1.68e-8) -> float:
return t_um * 1e-6 / rho