README figures: model diagrams generated from the solver itself
Build PCM package / build (push) Successful in 8s

docs/img, embedded in the README with captions:
- demo-current / demo-raster: real pipeline output on a synthetic
  two-layer net (soldered THT-pad contact, notched pour, stitching-via
  field) showing the current-density map and the raster map with the
  adaptive-mesh overlay and the one-sided solder coat
- contact-models: |J| around the same contact under the equipotential
  and uniform-injection models (the bracket, with both R values)
- hole-model: hand-drawn cross-section of the four hole types (capped
  via, open via, populated THT joint with lead/solder/cone/coat, DNP)

tools/gen_readme_figs.py regenerates all four; noted in Packaging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-17 11:44:32 +07:00
parent 959446978c
commit 9ad3c526e5
6 changed files with 345 additions and 1 deletions
+29 -1
View File
@@ -11,6 +11,13 @@ potential, current density, and **power density**, reports **per-via
currents** (via ampacity!) and total dissipation at a **selectable test currents** (via ampacity!) and total dissipation at a **selectable test
current**. PNGs + a text summary are saved per run. current**. PNGs + a text summary are saved per run.
![Current density on a two-layer demo net](docs/img/demo-current.png)
*Real output on a synthetic two-layer net: current from a soldered
THT-pad contact (V+, injected at the drill-wall ring) squeezes past a
notch in the F.Cu pour, transfers through the stitching-via field into
the B.Cu pour and leaves at the V lug — per-via currents and the
hottest via are reported.*
Uses the KiCad **IPC API** (`kicad-python` / `kipy`), not the deprecated Uses the KiCad **IPC API** (`kicad-python` / `kipy`), not the deprecated
SWIG API. Requires KiCad **10.0.1+**. SWIG API. Requires KiCad **10.0.1+**.
@@ -111,6 +118,12 @@ SWIG API. Requires KiCad **10.0.1+**.
spokes** still connect; wider antipads do not, and the barrel bridges spokes** still connect; wider antipads do not, and the barrel bridges
the layers above/below with the full barrel length. Barrels that reach the layers above/below with the full barrel length. Barrels that reach
fill on fewer than two layers carry no current and are reported. fill on fewer than two layers carry no current and are reported.
![How drilled holes are modeled — cross-section](docs/img/hole-model.png)
*The four hole types: capped small via, open large via, populated THT
pad with its full solder joint (lead ∥ solder ∥ plating in the hole,
one-sided pad coat, protruding-lead solder cone), and a DNP THT pad.*
- The net's **traces** (straight and arc tracks, exact outline polygons - The net's **traces** (straight and arc tracks, exact outline polygons
incl. rounded ends) conduct together with the fills — dialog checkbox, incl. rounded ends) conduct together with the fills — dialog checkbox,
on by default (`INCLUDE_TRACKS`). Traces narrower than on by default (`INCLUDE_TRACKS`). Traces narrower than
@@ -164,6 +177,12 @@ SWIG API. Requires KiCad **10.0.1+**.
terminals (e.g. planes joined only through the bolted lugs), only the terminals (e.g. planes joined only through the bolted lugs), only the
equipotential model is well-defined; the uniform model stops with an equipotential model is well-defined; the uniform model stops with an
error instead of prescribing an arbitrary split. error instead of prescribing an arbitrary split.
![The two contact models bracket a real contact](docs/img/contact-models.png)
*|J| around the same 3×3 mm contact under both models: the ideal
bonded lug crowds the current at the contact edges (no in-sheet
current inside an equipotential region), the pressed conductor ramps
it across the contact area.*
- Fields are reported at the dialog's test current; power scales with I². - Fields are reported at the dialog's test current; power scales with I².
- **Skin effect (f > 0)**: per-layer effective sheet resistance from the - **Skin effect (f > 0)**: per-layer effective sheet resistance from the
exact 1D foil-diffusion solution `Zs = τρ·coth(τt)`, `τ = (1+j)/δ` exact 1D foil-diffusion solution `Zs = τρ·coth(τt)`, `τ = (1+j)/δ`
@@ -202,6 +221,12 @@ SWIG API. Requires KiCad **10.0.1+**.
uniform grid ≲ 0.03 %, with the power-balance identity intact. All uniform grid ≲ 0.03 %, with the power-balance identity intact. All
fields are expanded back to the fine grid for the maps and reports. fields are expanded back to the fine grid for the maps and reports.
![Rasterized map with the adaptive mesh overlay](docs/img/demo-raster.png)
*The raster map of the demo net: quadtree leaves drawn on the copper
(fine at boundaries, electrodes, via mouths and pads; coarse blocks
in plane interiors), the tin-gray solder coat of the THT-pad contact
P1, and the via field with its pad copper.*
## Offline / development ## Offline / development
Every run writes `geometry_dump.json`; re-solve without KiCad: Every run writes `geometry_dump.json`; re-solve without KiCad:
@@ -232,7 +257,10 @@ filled in. To publish: upload the zip to a release, set `download_url`
(and the `homepage` resource in `metadata.json`), then submit the (and the `homepage` resource in `metadata.json`), then submit the
registry copy as `packages/th.co.b4l.fill-resistance/metadata.json` in a registry copy as `packages/th.co.b4l.fill-resistance/metadata.json` in a
merge request to <https://gitlab.com/kicad/addons/metadata>. Icons are merge request to <https://gitlab.com/kicad/addons/metadata>. Icons are
regenerated with `python tools/gen_icons.py`. regenerated with `python tools/gen_icons.py`; the README figures in
`docs/img/` with `.venv\Scripts\python.exe tools\gen_readme_figs.py`
(real solver output on small synthetic boards, plus the hand-drawn
hole cross-section).
## License ## License
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+316
View File
@@ -0,0 +1,316 @@
"""Generate the README model figures in docs/img/.
.venv\\Scripts\\python.exe tools\\gen_readme_figs.py
Real solver output wherever possible: the demo-board maps (raster map
with the adaptive mesh, current density) and the contact-model
comparison come straight from the plugin's own pipeline on small
synthetic boards; only the hole-anatomy cross-section is drawn by hand.
"""
import sys
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from fill_resistance import config # noqa: E402
config.INTERACTIVE = False
import matplotlib # noqa: E402
matplotlib.use("Agg", force=True)
from fill_resistance import plots, raster, solver # noqa: E402
from fill_resistance.geometry import (Electrode, LayerFill, Polygon, # noqa: E402
Problem, Rect, ViaLink,
contact_solder_buildups)
import matplotlib.pyplot as plt # noqa: E402
plt.switch_backend("Agg")
plots.INTERACTIVE_BACKEND = None # save-only: no window panels
NM = 1_000_000
OUT = ROOT / "docs" / "img"
COPPER = plots._COPPER
SOLDER = plots._SOLDER
LEAD = "#5c6570"
CORE = "#ccd6b3" # FR-4
FILLER = "#eae5dc" # non-conductive via fill
INK = plots._INK
def _poly(pts_mm, holes_mm=()) -> Polygon:
ring = lambda pts: np.asarray( # noqa: E731
[[int(x * NM), int(y * NM)] for x, y in pts], dtype=np.int64)
return Polygon(outline=ring(pts_mm),
holes=[ring(h) for h in holes_mm])
def _rect(x0, y0, x1, y1, layer="User.1") -> Rect:
return Rect.normalized(int(x0 * NM), int(y0 * NM),
int(x1 * NM), int(y1 * NM), layer)
def _disc(x_mm, y_mm, r_mm, n=64) -> Polygon:
ang = np.linspace(0, 2 * np.pi, n, endpoint=False)
return _poly([(x_mm + r_mm * np.cos(a), y_mm + r_mm * np.sin(a))
for a in ang])
def _solve(problem, h_mm, i_test=10.0, model=None):
stack = raster.rasterize_stack(problem, int(h_mm * NM))
e1, e2 = raster.electrode_masks(stack, problem)
p1, p2 = raster.electrode_partition(stack, problem)
res = solver.run_solve(problem, stack, e1, e2, i_test,
contact_model=model, parts1=p1, parts2=p2)
return res, stack, e1, e2
# --- demo board: 2 layers, notched F.Cu pour, half-size B.Cu pour, ----
# --- a soldered THT-pad contact and a stitching-via field -------------
def demo_problem() -> Problem:
# F.Cu: full pour with a notch from the top edge down to y=6 - the
# current from the left contact must squeeze through the channel
top = _poly([(0, 0), (40, 0), (40, 22), (13, 22), (13, 6),
(10, 6), (10, 22), (0, 22)])
# B.Cu: pour on the right half only - vias must carry the transfer
bot = _poly([(18, 0), (40, 0), (40, 22), (18, 22)])
z_bot = int(1.6 * NM)
vias = [ViaLink(x=int(x * NM), y=int(y * NM), drill_nm=300_000,
z_top_nm=-1, z_bot_nm=z_bot + 1, pad_nm=600_000)
for x in (20.5, 24.5, 28.5, 32.5, 36) for y in (3, 7, 11, 15, 19)]
tht = Electrode(
rect=_rect(3.4, 9.9, 5.6, 12.1), contact="F.Cu",
label="THT pad (4.5, 11)", drill_nm=1_000_000,
pad_nm=int(2.2 * NM), pad_min_nm=int(2.2 * NM),
center=(int(4.5 * NM), int(11 * NM)), solder=True,
protrusion_side="F.Cu", polygons=[_disc(4.5, 11, 1.1)])
lug = Electrode(rect=_rect(37.5, 3, 39.5, 19, "User.2"),
contact="B.Cu", label="lug")
p = Problem(
board_path="synthetic", net_name="DEMO",
rho_ohm_m=1.68e-8, plating_nm=18_000,
layers=[LayerFill("F.Cu", 70_000, 0, [top]),
LayerFill("B.Cu", 70_000, z_bot, [bot])],
vias=vias, electrodes1=[tht], electrodes2=[lug],
thickness_source="override")
contact_solder_buildups(p)
return p
def gen_demo_maps():
p = demo_problem()
res, stack, e1, e2 = _solve(p, 0.05)
figs = [
(plots.fig_raster(stack, e1, e2, p, res), "demo-raster"),
(plots.fig_current(res, stack, e1, e2, p), "demo-current"),
]
plots.save_and_show(figs, OUT, show=False)
# --- contact models: equipotential vs uniform injection ---------------
def gen_contact_models():
plate = [(0, 0), (24, 0), (24, 18), (0, 18)]
r_ohm, zooms = {}, {}
# uniform grid: the coarse adaptive leaves would pixelate the |J| zoom
config.ADAPTIVE_CELLS = False
for model in ("equipotential", "uniform"):
p = Problem(
board_path="synthetic", net_name="DEMO",
rho_ohm_m=1.68e-8, plating_nm=18_000,
layers=[LayerFill("F.Cu", 70_000, 0, [_poly(plate)])],
vias=[],
electrodes1=[Electrode(rect=_rect(4.5, 7.5, 7.5, 10.5))],
electrodes2=[Electrode(rect=_rect(22, 1, 23.5, 17, "User.2"))],
thickness_source="override")
res, stack, _, _ = _solve(p, 0.05, model=model)
h_mm = stack.h_nm / NM
j = res.Jmag[0] * 1e-6 # A/mm^2
x0, y0 = stack.x0_nm / NM, stack.y0_nm / NM
c0, c1 = int((2 - x0) / h_mm), int((13 - x0) / h_mm)
r0, r1 = int((3 - y0) / h_mm), int((15 - y0) / h_mm)
zooms[model] = (j[r0:r1, c0:c1],
(x0 + c0 * h_mm, x0 + c1 * h_mm,
y0 + r1 * h_mm, y0 + r0 * h_mm))
r_ohm[model] = res.R_ohm
config.ADAPTIVE_CELLS = True
vmax = float(np.percentile(
zooms["equipotential"][0][np.isfinite(zooms["equipotential"][0])],
99.0))
fig, axes = plt.subplots(1, 2, figsize=(9.5, 4.2), sharey=True,
layout="constrained")
titles = {"equipotential": "equipotential (ideal bonded lug):\n"
"|J| crowds at the contact edges",
"uniform": "uniform injection (pressed conductor):\n"
"|J| ramps across the contact"}
for ax, model in zip(axes, ("equipotential", "uniform")):
data, extent = zooms[model]
cmap = matplotlib.colormaps[config.CMAP_CURRENT].copy()
cmap.set_bad(plots._BG)
im = ax.imshow(data, cmap=cmap, vmin=0, vmax=vmax, origin="upper",
extent=extent, interpolation="nearest")
ax.add_patch(plt.Rectangle((4.5, 7.5), 3, 3, fill=False,
ec="white", ls="--", lw=1.0))
ax.set_title(f"{titles[model]}\nR = {r_ohm[model] * 1e3:.3f}",
fontsize=9, color=INK)
ax.set_xlabel("x [mm]", fontsize=8)
ax.tick_params(labelsize=8, colors=INK)
axes[0].set_ylabel("y [mm]", fontsize=8)
cb = fig.colorbar(im, ax=axes, shrink=0.85)
cb.set_label("|J| [A/mm²] @ 10 A", fontsize=9)
fig.suptitle("The two contact models bracket a real contact: "
"R$_{equipotential}$ ≤ R$_{real}$ ≤ R$_{uniform}$",
fontsize=10, color=INK)
fig.savefig(OUT / "contact-models.png", dpi=config.DPI,
facecolor="white", bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT / 'contact-models.png'}")
# --- hole anatomy: hand-drawn cross-section of the four hole types ----
CORE_T = 1.6 # substrate thickness [drawing units ~ mm]
FOIL_T = 0.18 # foil thickness, exaggerated
PLATE_W = 0.12 # barrel plating, exaggerated
CAP_T = 0.07 # via cap
COAT_T = 0.1 # pad-face solder coat
Y_TOP = CORE_T + FOIL_T
def _board_segment(ax, x0, x1):
ax.add_patch(plt.Rectangle((x0, 0), x1 - x0, CORE_T, fc=CORE, ec="none"))
for y in (CORE_T, -FOIL_T):
ax.add_patch(plt.Rectangle((x0, y), x1 - x0, FOIL_T,
fc=COPPER, ec="none"))
def _barrel(ax, xc, drill):
for s in (-1, 1):
x = xc + s * drill / 2 - (PLATE_W if s > 0 else 0)
ax.add_patch(plt.Rectangle((x, -FOIL_T), PLATE_W,
CORE_T + 2 * FOIL_T, fc=COPPER,
ec="none"))
def _label(ax, text, xy, xytext, ha="left"):
ax.annotate(text, xy, xytext=xytext, fontsize=7.5, color=INK, ha=ha,
va="center",
arrowprops=dict(arrowstyle="-", color=INK, lw=0.7,
shrinkA=2, shrinkB=1))
def gen_hole_anatomy():
fig, ax = plt.subplots(figsize=(12.5, 5.2), layout="constrained")
holes = [(3.0, 0.7), (10.0, 1.6), (17.5, 1.6), (25.0, 1.6)]
edges = [0.0]
for xc, d in holes:
edges += [xc - d / 2, xc + d / 2]
edges.append(28.5)
for x0, x1 in zip(edges[::2], edges[1::2]):
_board_segment(ax, x0, x1)
for xc, d in holes:
_barrel(ax, xc, d)
# 1: small via, filled + capped
xc, d = holes[0]
ax.add_patch(plt.Rectangle((xc - d / 2 + PLATE_W, -FOIL_T),
d - 2 * PLATE_W, CORE_T + 2 * FOIL_T,
fc=FILLER, ec="none"))
for y in (Y_TOP, -FOIL_T - CAP_T):
ax.add_patch(plt.Rectangle((xc - d / 2 - 0.12, y), d + 0.24, CAP_T,
fc=COPPER, ec="none"))
_label(ax, "cap, CAP_PLATING_UM (15 µm)\non both outer mouths",
(xc, Y_TOP + CAP_T), (xc, 3.3), ha="center")
_label(ax, "non-conductive fill", (xc, 0.8), (5.6, -0.9))
# 2: big via, mouth open
xc, d = holes[1]
_label(ax, "open mouth: covered cells\nremoved, sub-cell mouths\n"
"scale the sheet conductance",
(xc, Y_TOP - FOIL_T / 2), (xc, 3.2), ha="center")
# 3: populated THT pad - full solder joint
xc, d = holes[2]
lead_w = d - 0.5 # drill - clearance, exaggerated
pad_r = 1.7
prot = 1.5
sn_edge = "#7d8791" # delineate solder sub-shapes
# solder fill between plating and lead
for s in (-1, 1):
x0 = xc + s * lead_w / 2 if s > 0 else xc - d / 2 + PLATE_W
ax.add_patch(plt.Rectangle((x0, -FOIL_T),
d / 2 - PLATE_W - lead_w / 2,
CORE_T + 2 * FOIL_T, fc=SOLDER,
ec="none"))
# pad-face coat, solder side only
ax.add_patch(plt.Rectangle((xc - pad_r, Y_TOP), 2 * pad_r, COAT_T,
fc=SOLDER, ec=sn_edge, lw=0.5))
# solder cone: protrusion height at the wall -> 0 at the pad edge
for s in (-1, 1):
wall = xc + s * lead_w / 2
ax.add_patch(plt.Polygon(
[(wall, Y_TOP + prot), (wall, Y_TOP + COAT_T),
(xc + s * pad_r, Y_TOP + COAT_T)],
closed=True, fc=SOLDER, ec=sn_edge, lw=0.5))
# lead: through the hole, protruding on top, component below
ax.add_patch(plt.Rectangle((xc - lead_w / 2, -2.05), lead_w,
2.05 + Y_TOP + prot, fc=LEAD, ec="none"))
ax.add_patch(plt.Rectangle((xc - 1.5, -2.75), 3.0, 0.7,
fc="#8a8f96", ec="none"))
ax.text(xc, -2.4, "component", fontsize=7.5, color="white",
ha="center", va="center")
_label(ax, "clipped lead protrudes\nTHT_LEAD_PROTRUSION_MM (1.5 mm)",
(xc + lead_w / 2, Y_TOP + prot - 0.2), (xc + 3.4, 4.15))
_label(ax, "solder cone: full height at the\nwall, tapers to 0 at the "
"pad edge", (xc - (lead_w / 2 + pad_r) / 2, Y_TOP + 0.7),
(13.6, 4.2), ha="center")
_label(ax, "pad-face solder coat (50 µm),\nSOLDER side only",
(xc - pad_r + 0.2, Y_TOP + COAT_T / 2), (12.9, 2.35),
ha="center")
_label(ax, "solder-filled hole: lead ∥ solder ∥ plating\n"
"lead ⌀ = drill THT_LEAD_CLEARANCE_MM",
(xc - d / 2 + PLATE_W + 0.07, 0.5), (12.3, -1.5), ha="center")
_label(ax, "component side:\npad face stays bare",
(xc + pad_r - 0.3, -FOIL_T), (xc + 4.0, -1.05))
# 4: DNP THT pad
xc, d = holes[3]
_label(ax, "open hole on every layer,\nplating-only barrel, no joint",
(xc, 0.8), (xc + 1.3, -2.45), ha="center")
for (xc, _), title in zip(holes, (
"via ≤ cap-drill\n(capped)", "via > cap-drill\n(open)",
"THT pad, populated\n(read from KiCad)", "THT pad, DNP")):
ax.text(xc, 5.6, title, fontsize=9, color=INK, ha="center",
va="top", fontweight="bold")
handles = [plt.Rectangle((0, 0), 1, 1, fc=c) for c in
(COPPER, SOLDER, LEAD, CORE, FILLER)]
fig.legend(handles, ("copper (foil / plating / pad)", "solder",
"component lead", "FR-4", "non-conductive fill"),
loc="outside right center", fontsize=8, framealpha=0.95)
ax.set_title("How drilled holes are modeled — cross-section "
"(vertical scale exaggerated)", fontsize=11, color=INK)
ax.set_xlim(-0.3, 29.0)
ax.set_ylim(-3.1, 5.7)
ax.set_aspect("equal")
ax.axis("off")
fig.savefig(OUT / "hole-model.png", dpi=config.DPI,
facecolor="white", bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT / 'hole-model.png'}")
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
gen_hole_anatomy()
gen_contact_models()
gen_demo_maps()