10 Commits

Author SHA1 Message Date
janik 1eab58f3d1 Release 1.1.0
Build PCM package / build (push) Successful in 12s
Barrel contacts for vias/THT pads (drill-wall ring injection, verified
against the analytic acosh spreading resistance), full solder-joint
modeling of every populated THT hole (lead conductor, solder fill,
one-sided pad coat, protruding-lead cone, exact pad shapes and DNP
flags read from KiCad), configurable cap-drill threshold, and README
model figures. The release now also attaches the docs/img figures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:32:03 +07:00
janik 935cb2a959 README: potential map as its own figure, |J| map back to plain
The overlaid contour lines belonged on the potential plot the plugin
already produces; demo-potential.png is that figure for the same demo
solve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:29:58 +07:00
janik 4408a4481d Overlay equipotential lines on the README current-density figure
Build PCM package / build (push) Successful in 8s
Shared contour levels across both layers, so line density reads as
field strength (the caption points at the notch carrying nearly the
whole drop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:26:15 +07:00
janik 9ad3c526e5 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>
2026-07-17 11:44:32 +07:00
janik 959446978c Model every THT pad hole: exact pad copper, lead conductor, DNP holes
Build PCM package / build (push) Successful in 6s
- THT pad copper is now part of the conductor: the exact pad outline
  (incl. oblong/custom shapes) is fetched from KiCad once per pad and
  stamped onto every included layer (the outer shape stands in for
  inner rings). Annular rings bridge antipads, and joints land on real
  copper instead of only pour coverage.
- The internal lead conductor is modeled in every solder-filled hole:
  a cylinder of drill - THT_LEAD_CLEARANCE_MM (0.25 fab rule) with
  THT_LEAD_RHO_OHM_M (copper default; config for brass/steel leads),
  in parallel with the solder annulus and the plating.
- Drill mouths of THT pads: populated pads keep conducting mouth
  copper (stands in for the solder plug - conservative, the plug is
  worth ~200 um of copper equivalent); DNP pad holes are cut open on
  every layer like uncapped via mouths.
- Oblong pads: the coat uses the exact pad shape; the lead cone tapers
  within the inscribed circle (new pad_min_nm on ViaLink/Electrode) so
  the long axis is not overstated sideways.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 19:15:57 +07:00
janik 38b6e34bfa Solder coat only on the solder side of a THT joint
Build PCM package / build (push) Successful in 6s
The pad face is wetted where the joint is: the protrusion side,
opposite the component (already read from the owning footprint). The
component-side pad face stays bare - contact_solder_buildups and
tht_joint_buildups no longer coat both outer layers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 19:05:06 +07:00
janik b2277f65bf Full solder joint at every populated THT pad, read from KiCad
Build PCM package / build (push) Successful in 8s
No size-threshold guessing: whether a hole is a via or a THT pad
already comes from KiCad (board.get_vias vs drilled board.get_pads).
Every populated THT pad of the net now carries the complete joint the
contacts got: solder-filled barrel, average-thickness coat over a
pad-diameter disc on the outer layers, and the protruding-lead cone on
the side opposite its owning footprint. The footprint side and the
Do-not-populate flag are read from KiCad (footprint pads store absolute
positions, so owner lookup is an exact (x, y, number) map); DNP pads
stay plating-only with no joint. Contact pads are deduplicated by
barrel center so their cone/coat is never applied twice.

Barrels are now gathered in single-layer runs too: via rings and drill
mouths perforate a lone plane, THT joints stiffen it locally.

ViaLink gains solder_filled + protrusion_side (legacy dumps load with
the old every-THT-pad-filled semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 19:00:09 +07:00
janik 9e307f2818 Solder cone around protruding THT leads (tent structure)
Build PCM package / build (push) Successful in 5s
The clipped lead of a soldered THT contact protrudes
THT_LEAD_PROTRUSION_MM (1.5 mm default, 0 disables) out of the hole on
the side opposite the component, and a solder cone wraps it: full
protrusion height at the drill wall, tapering linearly to zero at the
pad edge. Painted as per-cell extra conduction-equivalent copper via
stack.thick_scale - the tall solder column at the wall pulls the joint
vicinity to lead potential (equivalent to extending the barrel wall
vertically), the taper carries the radial spreading. DC-exact additive
conductance; at f > 0 the factor multiplies the skin-corrected sheet
conductance like the via mouths (documented approximation).

The protrusion side is looked up from the owning footprint (pads store
absolute positions; component on F.Cu -> lead tents on B.Cu), with a
logged B.Cu fallback. Dump schema gains protrusion_side and
tht_protrusion_nm (defaults keep older v6 dumps loading).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:47:10 +07:00
janik 4ff729e192 Barrel contacts for vias/THT pads; configurable cap-drill threshold
Build PCM package / build (push) Successful in 13s
- Selected vias and through-hole pads inject at the drill-wall ring on
  every spanned layer (both contact models), not the whole pad face,
  so the pad/pour spreading resistance is part of the result. Vias are
  now selectable as contacts.
- Soldered THT joints: the hole is modeled solder-filled (core in
  parallel with the plating, also for stitching THT barrels) and the
  pad face carries an average-thickness solder coat over the modeled
  copper (SOLDER_THICKNESS_UM).
- Vias with drills above a configurable threshold (dialog field,
  default CAP_MAX_DRILL_MM = 0.5) keep open mouths even with capping
  selected - the fab caps only small vias.
- Geometry dump schema v6: electrode barrel fields, cap_max_drill_nm.
- Verified against R = rho/(pi t)*acosh(d/2a) for two circular contacts
  on a sheet (+2.6% at h = 0.15 mm, a = 1 mm; uniform model above the
  equipotential one as required by the contact bracket).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:28:47 +07:00
grabowski b4b666de77 Document the release process in docs/RELEASING.md
Build PCM package / build (push) Successful in 5s
2026-07-15 20:06:29 +07:00
20 changed files with 1415 additions and 89 deletions
+2 -1
View File
@@ -35,11 +35,12 @@ jobs:
dist/*.zip dist/*.zip
dist/metadata-registry.json dist/metadata-registry.json
- name: Create release with the zip attached - name: Create release with the zip, registry metadata and figures
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
uses: akkuman/gitea-release-action@b8d9144f302c68610911db1aaf722708d5c02d94 # v1 uses: akkuman/gitea-release-action@b8d9144f302c68610911db1aaf722708d5c02d94 # v1
with: with:
files: | files: |
dist/*.zip dist/*.zip
dist/metadata-registry.json dist/metadata-registry.json
docs/img/*.png
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
+87 -10
View File
@@ -11,6 +11,18 @@ 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.*
![Potential on the two-layer demo net](docs/img/demo-potential.png)
*The matching potential map with equipotential contour lines: they
bunch where the field is strongest — nearly the whole 8.7 mV drop
happens around the notch on F.Cu.*
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+**.
@@ -44,9 +56,10 @@ SWIG API. Requires KiCad **10.0.1+**.
- **V+ rectangles on `User.1`**, **V rectangles on `User.2`** - **V+ rectangles on `User.1`**, **V rectangles on `User.2`**
(marker layers, configurable via `ELECTRODE_POS_LAYER` / (marker layers, configurable via `ELECTRODE_POS_LAYER` /
`ELECTRODE_NEG_LAYER`), any number per side, axis-aligned; `ELECTRODE_NEG_LAYER`), any number per side, axis-aligned;
- **pads** (real copper shape; through-hole pad contacts all layers, - **pads and vias** (SMD pad: real copper shape on its own layer;
SMD pad its own layer) — selected pads fill a side that has no through-hole pads and vias become **barrel contacts** — the current
rectangles; enters at the drill wall on every spanned layer, see below) —
selected pads/vias fill a side that has no rectangles;
- legacy: exactly 2 selected contacts with no marker rectangles still - legacy: exactly 2 selected contacts with no marker rectangles still
works; empty selection scans the whole board's marker layers. works; empty selection scans the whole board's marker layers.
2. **Select the contacts**, click the **Fill Resistance** Ω button. 2. **Select the contacts**, click the **Fill Resistance** Ω button.
@@ -76,20 +89,46 @@ SWIG API. Requires KiCad **10.0.1+**.
capped" checkbox** (default on, `VIAS_CAPPED`) the mouth carries a capped" checkbox** (default on, `VIAS_CAPPED`) the mouth carries a
thin copper cap (`CAP_PLATING_UM = 15`, fab spec) on the **outer** thin copper cap (`CAP_PLATING_UM = 15`, fab spec) on the **outer**
layers and is an open hole on inner layers; unchecked, mouths are open layers and is an open hole on inner layers; unchecked, mouths are open
holes everywhere. Layer-to-layer the cap never matters at DC (it is in holes everywhere. The fab caps only small vias: drills above the
dialog's **"capped up to drill"** threshold (default
`CAP_MAX_DRILL_MM = 0.5`) keep open mouths even with capping
selected. Layer-to-layer the cap never matters at DC (it is in
parallel with the annular-ring contact, not in series) — the checkbox parallel with the annular-ring contact, not in series) — the checkbox
only affects in-plane conduction across outer-layer mouths. Sub-cell only affects in-plane conduction across outer-layer mouths. Sub-cell
mouths scale their cells' sheet conductance by the true covered mouths scale their cells' sheet conductance by the true covered
fraction (4×4 supersampling), so coarse grids see the correct small fraction (4×4 supersampling), so coarse grids see the correct small
perturbation instead of a whole-cell hole. THT-pad copper and drills perturbation instead of a whole-cell hole. Barrels are gathered in
remain outside the model; at f > 0 the thickness scaling is applied **single-layer runs too** (drill mouths perforate a lone plane).
multiplicatively to the skin-corrected sheet conductance **THT pads are fully modeled**: their exact copper shapes (incl.
oblong pads, fetched from KiCad; the outer shape stands in for inner
rings) are stamped onto every included layer, and every **populated**
pad carries its full **soldered joint** on its SOLDER side (opposite
the component; the component-side pad face stays bare): the hole
holds the **component lead** (a cylinder of drill
`THT_LEAD_CLEARANCE_MM`, resistivity `THT_LEAD_RHO_OHM_M`, copper by
default — raise it for brass/steel leads) **plus solder** in the
remaining annulus, both in parallel with the plating; the mouth
copper stays conducting (it stands in for the plug — conservative,
the plug is worth far more than the foil); the pad face gets the
average-thickness solder coat (exact pad shape) and the
protruding-lead cone (see barrel contacts below; on oblong pads the
cone tapers within the inscribed circle). Whether a hole is a via or
a THT pad, the owning footprint's side, and its **Do not populate**
flag are all read from KiCad — **DNP pads** get an **open hole** and
a plating-only barrel, no joint. At f > 0 the thickness scaling is
applied multiplicatively to the skin-corrected sheet conductance
(approximation). Per layer a barrel attaches to (approximation). Per layer a barrel attaches to
the fill cell under it, or to the nearest copper cell within the pad the fill cell under it, or to the nearest copper cell within the pad
footprint plus one grid cell — fills joined by **thermal-relief footprint plus one grid cell — fills joined by **thermal-relief
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
@@ -98,8 +137,9 @@ SWIG API. Requires KiCad **10.0.1+**.
series resistance carries no discretization error and no cell-size series resistance carries no discretization error and no cell-size
tuning is needed for thin traces. 1D-modeled traces show potential, tuning is needed for thin traces. 1D-modeled traces show potential,
power density, and |J| (the true in-trace density from the link power density, and |J| (the true in-trace density from the link
currents, |ΔV|/(ρ·Δl)). Pad copper other than the selected currents, |ΔV|/(ρ·Δl)). THT pad copper is part of the conductor
contacts is still **not** part of the conductor model. (exact shapes, see above); **SMD** pad copper other than the
selected contacts is still **not**.
- **Solder buildup on mask openings** (dialog checkbox, **off by - **Solder buildup on mask openings** (dialog checkbox, **off by
default**; `INCLUDE_MASK_BUILDUP`): zones drawn on `F.Mask`/`B.Mask` default**; `INCLUDE_MASK_BUILDUP`): zones drawn on `F.Mask`/`B.Mask`
are treated as mask openings that collect `SOLDER_THICKNESS_UM` are treated as mask openings that collect `SOLDER_THICKNESS_UM`
@@ -110,6 +150,28 @@ SWIG API. Requires KiCad **10.0.1+**.
interface faces use harmonic-mean conductances. Buildup areas render interface faces use harmonic-mean conductances. Buildup areas render
tin-gray on the raster map; |J| in them is referenced to the tin-gray on the raster map; |J| in them is referenced to the
conductance-equivalent copper thickness. conductance-equivalent copper thickness.
- **Barrel contacts**: a selected **via or through-hole pad** injects at
the **drill-wall ring** on every layer the barrel spans — the current
physically enters through the lead/wire soldered into the hole, so
the spreading resistance across the pad and surrounding pour is part
of the result (both contact models; verified against
R = ρ/(π·t)·acosh(d/2a) for two circular contacts on a sheet). A
soldered **THT joint** additionally assumes the **hole is filled with
solder** (core in parallel with the plating) and the **pad face on
the solder side carries an average-thickness solder coat**
(`SOLDER_THICKNESS_UM`, 50 µm) over the modeled copper under the pad
shape — the solder side is the side opposite the component (taken
from the owning footprint; assumed `B.Cu` if it cannot be found),
and the component-side pad face stays bare. There the **clipped
lead protrudes** `THT_LEAD_PROTRUSION_MM` (1.5 mm, 0 = off)
and a **solder cone** wraps
it: full protrusion height at the drill wall, tapering linearly to
zero at the pad edge, applied as extra conduction-equivalent copper
per cell. The tall solder column at the wall pulls the joint
vicinity to lead potential — equivalent to extending the barrel wall
vertically — while the taper carries the radial spreading. To model
a probe pressed onto the pad face instead, draw a marker rectangle
over the pad.
- **Contact models** (dialog / `CONTACT_MODEL`): default **uniform - **Contact models** (dialog / `CONTACT_MODEL`): default **uniform
injection** — a conductor pressed on top feeds the current orthogonally injection** — a conductor pressed on top feeds the current orthogonally
with uniform surface density, so |J| ramps across the contact area with uniform surface density, so |J| ramps across the contact area
@@ -120,6 +182,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)/δ`
@@ -158,6 +226,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:
@@ -188,7 +262,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
+67
View File
@@ -0,0 +1,67 @@
# Releasing a new version
The PCM addon zip is built by CI (`.gitea/workflows/build-pcm.yml`).
Every push to `main` builds it as a downloadable artifact; pushing a
`v<version>` tag additionally creates a Gitea release with the zip
attached. The release job checks that the tag matches `metadata.json`
and fails on a mismatch.
## Steps
1. **Bump the version** in `metadata.json` — the single entry in
`versions` (plain `MAJOR.MINOR.PATCH`, no `v` prefix; the PCM schema
rejects anything else):
```json
"versions": [
{
"version": "1.0.2",
"status": "stable",
"kicad_version": "10.0",
"runtime": "ipc"
}
]
```
2. **Commit, tag, push** (tag = `v` + the manifest version):
```powershell
git add metadata.json
git commit -m "Release 1.0.2"
git tag v1.0.2
git push
git push origin v1.0.2
```
3. **Verify**: the Actions run for the tag builds
`th.co.b4l.fill-resistance_<version>.zip` and publishes it at
<https://git.b4l.co.th/B4L/kicad-zone-resistance/releases>, together
with `metadata-registry.json`. The zip installs directly via
Plugin and Content Manager → *Install from File*.
## Publishing to the official KiCad registry (optional)
The attached `metadata-registry.json` already carries the release
`download_url`, `download_sha256` and sizes. Submit it as
`packages/th.co.b4l.fill-resistance/metadata.json` in a merge request
to <https://gitlab.com/kicad/addons/metadata>. The registry keeps every
published version: append the new entry to the `versions` array of the
registry copy instead of replacing the previous one (the repo's own
`metadata.json` only ever holds the current version —
`tools/build_package.py` reads `versions[0]`).
## Local build (no CI)
```powershell
python tools/build_package.py # writes dist/<identifier>_<version>.zip
```
Pure stdlib — no venv needed. `dist/` is gitignored.
## CI prerequisites (one-time, server side)
- Actions enabled for the repo (Settings → Actions unit).
- A runner registered with the `ubuntu-latest` label; the default
act_runner image works — the build needs only Python 3.
- Workflow actions are pinned to commit SHAs; when bumping them, update
the SHA and the trailing version comment together.
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: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+199 -34
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from kipy import KiCad from kipy import KiCad
from kipy.board import Board from kipy.board import Board
from kipy.board_types import ArcTrack, BoardRectangle, Pad from kipy.board_types import ArcTrack, BoardRectangle, Pad, Via
from kipy.proto.board.board_pb2 import BoardStackupLayerType from kipy.proto.board.board_pb2 import BoardStackupLayerType
from kipy.proto.board.board_types_pb2 import ZoneType from kipy.proto.board.board_types_pb2 import ZoneType
from kipy.util.board_layer import (canonical_name, is_copper_layer, from kipy.util.board_layer import (canonical_name, is_copper_layer,
@@ -22,7 +22,9 @@ import numpy as np
from . import config from . import config
from .errors import ApiVersionError, CandidateError, SelectionError from .errors import ApiVersionError, CandidateError, SelectionError
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect, from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
SurfaceBuildup, TrackSeg, ViaLink, linearize_ring) SurfaceBuildup, TrackSeg, ViaLink,
contact_solder_buildups, linearize_ring,
tht_joint_buildups)
MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"} MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"}
@@ -179,7 +181,42 @@ def _pad_polygons(board: Board, pad: Pad, contact: str) -> list[Polygon] | None:
return None return None
def _to_electrode(board: Board, item) -> Electrode: def _footprint_pad_map(footprints) -> dict:
"""(x, y, number) -> owning FootprintInstance. Footprint pads are
stored with absolute positions, so the lookup is exact."""
out = {}
for fp in footprints or []:
try:
for fpad in fp.definition.pads:
out[(fpad.position.x, fpad.position.y, fpad.number)] = fp
except Exception:
continue
return out
def _pad_owner(pad: Pad, pad_map: dict):
return pad_map.get((pad.position.x, pad.position.y, pad.number))
def _tht_protrusion_side(pad: Pad, pad_map: dict, quiet: bool = False) -> str:
"""Outer layer where the clipped THT lead protrudes (tent + solder
cone): the side OPPOSITE the component. Unknown owner -> assume the
component sits on F.Cu (lead tents on B.Cu)."""
fp = _pad_owner(pad, pad_map)
if fp is not None:
try:
side = canonical_name(fp.layer)
return "F.Cu" if side == "B.Cu" else "B.Cu"
except Exception:
pass
if not quiet:
print(f"note: no footprint found for pad {pad.number} - assuming "
f"its lead protrudes on B.Cu")
return "B.Cu"
def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
pad_map: dict | None = None) -> Electrode:
if isinstance(item, BoardRectangle): if isinstance(item, BoardRectangle):
tl, br = item.top_left, item.bottom_right tl, br = item.top_left, item.bottom_right
rect = Rect.normalized(tl.x, tl.y, br.x, br.y, rect = Rect.normalized(tl.x, tl.y, br.x, br.y,
@@ -188,6 +225,22 @@ def _to_electrode(board: Board, item) -> Electrode:
cy = (rect.y0 + rect.y1) / 2e6 cy = (rect.y0 + rect.y1) / 2e6
return Electrode(rect=rect, contact="all", return Electrode(rect=rect, contact="all",
label=f"rect({cx:.1f},{cy:.1f})") label=f"rect({cx:.1f},{cy:.1f})")
if isinstance(item, Via):
via: Via = item
x, y = via.position.x, via.position.y
drill = int(via.drill_diameter or 0) or _pad_drill_nm(via)
if drill <= 0:
raise SelectionError(
f"Selected via at ({x / 1e6:.2f}, {y / 1e6:.2f}) mm has no "
f"drill diameter - cannot use it as a contact.")
pad_nm = _padstack_pad_nm(via)
r = max(pad_nm, drill) // 2
rect = Rect.normalized(x - r, y - r, x + r, y + r, "via")
return Electrode(
rect=rect, contact="all", label=f"via({x / 1e6:.1f},{y / 1e6:.1f})",
drill_nm=drill, pad_nm=pad_nm, center=(x, y),
barrel_z=(_padstack_span(via.padstack, stackup)
if stackup is not None else None))
# Pad # Pad
pad: Pad = item pad: Pad = item
contact = _pad_default_contact(pad) contact = _pad_default_contact(pad)
@@ -197,37 +250,56 @@ def _to_electrode(board: Board, item) -> Electrode:
if box is None: if box is None:
raise SelectionError(f"Could not get the bounding box of {label}.") raise SelectionError(f"Could not get the bounding box of {label}.")
rect = _box2_to_rect(box, "pad") rect = _box2_to_rect(box, "pad")
drill = _pad_drill_nm(pad)
return Electrode(rect=rect, contact=contact, return Electrode(rect=rect, contact=contact,
polygons=_pad_polygons(board, pad, contact), label=label) polygons=_pad_polygons(board, pad, contact), label=label,
# through-hole pad: current enters at the soldered
# barrel; the joint is solder-filled + pad-coated,
# with a solder cone around the protruding lead
drill_nm=drill, pad_nm=_padstack_pad_nm(pad),
pad_min_nm=_padstack_pad_min_nm(pad),
center=(pad.position.x, pad.position.y),
solder=drill > 0,
protrusion_side=(_tht_protrusion_side(pad, pad_map or {})
if drill > 0 else None))
def _net_hint_of(pads: list[Pad]) -> str | None: def _net_hint_of(items: list) -> str | None:
for pad in pads: for item in items:
if pad.net is not None: if item.net is not None:
return pad.net.name return item.net.name
return None return None
def get_electrodes(board: Board def get_electrodes(board: Board, stackup: StackupInfo | None = None
) -> tuple[list[Electrode], list[Electrode], str | None]: ) -> tuple[list[Electrode], list[Electrode], str | None]:
"""Terminals from the selection. Each terminal may have MULTIPLE parts """Terminals from the selection. Each terminal may have MULTIPLE parts
(all merged into one externally-bonded contact): (all merged into one externally-bonded contact):
- rectangles on ELECTRODE_POS_LAYER -> V+ parts, on ELECTRODE_NEG_LAYER - rectangles on ELECTRODE_POS_LAYER -> V+ parts, on ELECTRODE_NEG_LAYER
-> V- parts; selected pads fill a side that has no rectangles; -> V- parts; selected pads/vias fill a side that has no rectangles;
- no marker rectangles selected: legacy mode, exactly 2 items - no marker rectangles selected: legacy mode, exactly 2 items
(rects/pads, any layer) -> one part each; (rects/pads/vias, any layer) -> one part each;
- empty selection: board-wide scan of both marker layers. - empty selection: board-wide scan of both marker layers.
Selected vias and through-hole pads become BARREL contacts: current
enters at the drill-wall ring (the soldered lead/wire), not the pad
face. Draw a marker rectangle over the pad instead to model a probe
pressed onto the pad face.
""" """
pos_l = config.ELECTRODE_POS_LAYER pos_l = config.ELECTRODE_POS_LAYER
neg_l = config.ELECTRODE_NEG_LAYER neg_l = config.ELECTRODE_NEG_LAYER
scheme = (f"Draw V+ rectangle(s) on {pos_l} and V- rectangle(s) on " 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"{neg_l} (axis-aligned), and/or select pads/vias for a side "
f"without rectangles.") f"without rectangles.")
selection = list(board.get_selection()) selection = list(board.get_selection())
rects = [s for s in selection if isinstance(s, BoardRectangle)] rects = [s for s in selection if isinstance(s, BoardRectangle)]
pads = [s for s in selection if isinstance(s, Pad)] pads = [s for s in selection if isinstance(s, (Pad, Via))]
# protrusion-side lookup needs the owning footprints (THT pads only)
pad_map = (_footprint_pad_map(board.get_footprints())
if any(isinstance(s, Pad) and _pad_drill_nm(s) > 0
for s in pads) else {})
if not selection: if not selection:
allr = [s for s in board.get_shapes() if isinstance(s, BoardRectangle)] allr = [s for s in board.get_shapes() if isinstance(s, BoardRectangle)]
@@ -258,12 +330,13 @@ def get_electrodes(board: Board
es2 = [_to_electrode(board, r) for r in neg] es2 = [_to_electrode(board, r) for r in neg]
if pads and es1 and es2: if pads and es1 and es2:
raise SelectionError( raise SelectionError(
f"Cannot assign the {len(pads)} selected pad(s): both marker " f"Cannot assign the {len(pads)} selected pad(s)/via(s): both "
f"layers already provide rectangles. Use pads only for a " f"marker layers already provide rectangles. Use pads/vias "
f"side that has none." f"only for a side that has none."
) )
if pads: if pads:
pad_parts = [_to_electrode(board, p) for p in pads] pad_parts = [_to_electrode(board, p, stackup, pad_map)
for p in pads]
if not es1: if not es1:
es1 = pad_parts es1 = pad_parts
else: else:
@@ -277,12 +350,13 @@ def get_electrodes(board: Board
items = rects + pads items = rects + pads
if len(items) == 2: if len(items) == 2:
return ([_to_electrode(board, items[0])], return ([_to_electrode(board, items[0], stackup, pad_map)],
[_to_electrode(board, items[1])], _net_hint_of(pads)) [_to_electrode(board, items[1], stackup, pad_map)],
_net_hint_of(pads))
raise SelectionError( raise SelectionError(
f"The selection has {len(rects)} rectangle(s) (none on the marker " 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"layers) and {len(pads)} pad(s)/via(s); without marker layers "
f"contacts are needed.\n{scheme}" f"exactly 2 contacts are needed.\n{scheme}"
) )
@@ -416,6 +490,18 @@ def _padstack_pad_nm(item) -> int:
return 0 return 0
def _padstack_pad_min_nm(item) -> int:
"""Smallest dimension of the (largest) copper pad of a padstack; 0
if unknown. Bounds the lead-cone taper on oblong pads: the cone
stays within the inscribed circle."""
try:
sizes = [min(int(l.size.x), int(l.size.y))
for l in item.padstack.copper_layers]
return max(sizes) if sizes else 0
except Exception:
return 0
def _padstack_span(padstack, stackup: StackupInfo) -> tuple[int, int]: def _padstack_span(padstack, stackup: StackupInfo) -> tuple[int, int]:
"""(z_top, z_bot) of the barrel; falls back to the full stack.""" """(z_top, z_bot) of the barrel; falls back to the full stack."""
try: try:
@@ -444,19 +530,58 @@ def gather_barrels(board: Board, net_name: str,
z_bot_nm=z_bot, kind="via", z_bot_nm=z_bot, kind="via",
pad_nm=_padstack_pad_nm(via))) pad_nm=_padstack_pad_nm(via)))
if config.INCLUDE_TH_PADS: if config.INCLUDE_TH_PADS:
for pad in board.get_pads(): net_pads = [pad for pad in board.get_pads()
if pad.net is None or pad.net.name != net_name: if pad.net is not None and pad.net.name == net_name
continue and _pad_drill_nm(pad) > 0]
drill = _pad_drill_nm(pad) # populated (non-DNP) THT pads carry a soldered joint: filled
if drill <= 0: # hole + coat + lead cone on the side opposite the component
continue pad_map = (_footprint_pad_map(board.get_footprints())
barrels.append(ViaLink(x=pad.position.x, y=pad.position.y, if net_pads else {})
drill_nm=drill, z_top_nm=-1, unknown = 0
for pad in net_pads:
fp = _pad_owner(pad, pad_map)
unknown += fp is None
populated = True
if fp is not None:
try:
populated = not fp.attributes.do_not_populate
except Exception:
pass
barrels.append(ViaLink(
x=pad.position.x, y=pad.position.y,
drill_nm=_pad_drill_nm(pad), z_top_nm=-1,
z_bot_nm=stackup.z_bot_nm + 1, kind="pad", z_bot_nm=stackup.z_bot_nm + 1, kind="pad",
pad_nm=_padstack_pad_nm(pad))) pad_nm=_padstack_pad_nm(pad),
pad_min_nm=_padstack_pad_min_nm(pad),
solder_filled=populated,
protrusion_side=(_tht_protrusion_side(pad, pad_map,
quiet=True)
if populated else None)))
if unknown:
print(f"note: {unknown} THT pad(s) without an identifiable "
f"footprint - assumed populated, leads on B.Cu")
return barrels return barrels
def gather_tht_pad_copper(board: Board, net_name: str
) -> dict[tuple[int, int], list[Polygon]]:
"""(x, y) -> exact copper shape(s) of every drilled (THT) pad on the
net. The annular-ring copper conducts on every layer the barrel
spans, so build_problem stamps these onto each included layer. One
API call per pad; the outer-layer shape stands in for the inner
rings (approximation - inner rings are usually the same or
smaller)."""
shapes: dict[tuple[int, int], list[Polygon]] = {}
for pad in board.get_pads():
if pad.net is None or pad.net.name != net_name \
or _pad_drill_nm(pad) <= 0:
continue
polys = _pad_polygons(board, pad, "all")
if polys:
shapes[(pad.position.x, pad.position.y)] = polys
return shapes
# --- top level ---------------------------------------------------------------- # --- top level ----------------------------------------------------------------
def build_problem(board: Board, net: str, layer_names: list[str], def build_problem(board: Board, net: str, layer_names: list[str],
@@ -465,7 +590,8 @@ def build_problem(board: Board, net: str, layer_names: list[str],
buildups: dict[str, list[Polygon]] | None = None, buildups: dict[str, list[Polygon]] | None = None,
extra_cu_um: float | None = None, extra_cu_um: float | None = None,
tracks: dict | None = None, tracks: dict | None = None,
vias_capped: bool | None = None) -> Problem: vias_capped: bool | None = None,
cap_max_drill_mm: float | None = None) -> Problem:
per_layer = fills.get(net, {}) per_layer = fills.get(net, {})
per_layer_tracks = (tracks or {}).get(net, {}) per_layer_tracks = (tracks or {}).get(net, {})
layers = [] layers = []
@@ -490,7 +616,19 @@ def build_problem(board: Board, net: str, layer_names: list[str],
f"Net {net} has no fill on any of the selected layers " f"Net {net} has no fill on any of the selected layers "
f"({', '.join(layer_names)})." f"({', '.join(layer_names)})."
) )
vias = gather_barrels(board, net, stackup) if len(layers) > 1 else [] # barrels matter on a single layer too: via rings + drill mouths
# perforate the plane, THT joints locally stiffen it
vias = gather_barrels(board, net, stackup)
# THT pad copper is part of the conductor: stamp the exact pad
# shapes onto every included layer (the barrel spans the stack)
pad_shapes = (gather_tht_pad_copper(board, net)
if any(v.kind == "pad" for v in vias) else {})
if pad_shapes:
extra = [poly for polys in pad_shapes.values() for poly in polys]
for layer in layers:
layer.polygons = list(layer.polygons) + extra
print(f"{len(pad_shapes)} THT pad shape(s) stamped on every "
f"included layer")
included = {l.layer_name for l in layers} included = {l.layer_name for l in layers}
buildup_list = [ buildup_list = [
SurfaceBuildup(layer_name=name, polygons=polys) SurfaceBuildup(layer_name=name, polygons=polys)
@@ -502,7 +640,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
+ (f", solder buildup on " + (f", solder buildup on "
f"{', '.join(b.layer_name for b in buildup_list)}" f"{', '.join(b.layer_name for b in buildup_list)}"
if buildup_list else "")) if buildup_list else ""))
return Problem( problem = Problem(
board_path=board.name or "", board_path=board.name or "",
net_name=net, net_name=net,
rho_ohm_m=config.RHO_CU_OHM_M, rho_ohm_m=config.RHO_CU_OHM_M,
@@ -522,7 +660,34 @@ def build_problem(board: Board, net: str, layer_names: list[str],
vias_capped=(vias_capped if vias_capped is not None vias_capped=(vias_capped if vias_capped is not None
else config.VIAS_CAPPED), else config.VIAS_CAPPED),
cap_plating_nm=int(config.CAP_PLATING_UM * 1000), cap_plating_nm=int(config.CAP_PLATING_UM * 1000),
cap_max_drill_nm=int((cap_max_drill_mm if cap_max_drill_mm is not None
else config.CAP_MAX_DRILL_MM) * 1e6),
tht_protrusion_nm=int(config.THT_LEAD_PROTRUSION_MM * 1e6),
tht_lead_clearance_nm=int(config.THT_LEAD_CLEARANCE_MM * 1e6),
tht_lead_rho_ohm_m=config.THT_LEAD_RHO_OHM_M,
) )
solder_layers = contact_solder_buildups(problem)
if solder_layers:
sides = sorted({e.protrusion_side
for e in problem.electrodes1 + problem.electrodes2
if e.solder and e.protrusion_side})
cone = (f", {config.THT_LEAD_PROTRUSION_MM:g} mm lead + solder cone "
f"on {', '.join(sides)}"
if sides and problem.tht_protrusion_nm > 0 else "")
print(f"THT contact(s): solder-filled hole + "
f"{config.SOLDER_THICKNESS_UM:g} um average solder coat on the "
f"pad face ({', '.join(solder_layers)}){cone}")
tht_joint_buildups(problem, pad_shapes)
n_joint = sum(1 for v in problem.vias
if v.kind == "pad" and v.solder_filled)
n_dnp = sum(1 for v in problem.vias
if v.kind == "pad" and not v.solder_filled)
if n_joint or n_dnp:
print(f"{n_joint} populated THT pad joint(s): lead + solder in the "
f"hole, coat + cone on the solder side"
+ (f"; {n_dnp} DNP pad(s): open hole, plating-only"
if n_dnp else ""))
return problem
if __name__ == "__main__": if __name__ == "__main__":
@@ -533,7 +698,7 @@ if __name__ == "__main__":
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("geometry_dump.json") out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("geometry_dump.json")
_, board = connect() _, board = connect()
stackup = get_stackup_info(board) stackup = get_stackup_info(board)
es1, es2, net_hint = get_electrodes(board) es1, es2, net_hint = get_electrodes(board, stackup)
if any_zone_unfilled(board): if any_zone_unfilled(board):
refill(board) refill(board)
fills = gather_net_fills(board) fills = gather_net_fills(board)
+19 -1
View File
@@ -28,7 +28,25 @@ VIAS_CAPPED = True # filled + capped vias (dialog checkbox):
# Ring/pad copper of vias is modeled either # Ring/pad copper of vias is modeled either
# way; THT-pad copper/drills are not. # way; THT-pad copper/drills are not.
CAP_PLATING_UM = 15.0 # cap plating thickness (fab spec) CAP_PLATING_UM = 15.0 # cap plating thickness (fab spec)
INCLUDE_TH_PADS = True # plated through-hole pads stitch layers too CAP_MAX_DRILL_MM = 0.5 # fab caps only small vias: drills above this
# stay open even with VIAS_CAPPED
# (dialog-settable)
INCLUDE_TH_PADS = True # plated through-hole pads stitch layers too;
# their holes are modeled solder-filled (a
# soldered component lead), so the solder core
# conducts in parallel with the plating
THT_LEAD_PROTRUSION_MM = 1.5 # clipped THT lead protrusion on the side
# opposite the component: a solder cone of
# this height at the drill wall (tapering to
# zero at the pad edge) wraps the lead of
# every populated THT pad. 0 = no cones
THT_LEAD_CLEARANCE_MM = 0.25 # hole diameter minus lead diameter (fab
# rule): a lead cylinder of drill - this
# conducts inside every solder-filled hole
THT_LEAD_RHO_OHM_M = 1.68e-8 # lead material resistivity: copper leads/
# wires; brass ~6.4e-8, phosphor bronze
# ~1.1e-7, copper-clad steel higher - raise
# this if your components use such leads
SKIN_SIDES = 1 # skin-effect field config: 1 = plane facing a SKIN_SIDES = 1 # skin-effect field config: 1 = plane facing a
# return plane (conservative), 2 = isolated foil # return plane (conservative), 2 = isolated foil
+12
View File
@@ -37,6 +37,7 @@ class Selection:
extra_cu_um: float = 0.0 extra_cu_um: float = 0.0
include_tracks: bool = True include_tracks: bool = True
vias_capped: bool = True vias_capped: bool = True
cap_max_drill_mm: float = 0.5
adaptive: bool = False adaptive: bool = False
@@ -73,6 +74,11 @@ class _Dialog(QDialog):
self.capped_check.setChecked(config.VIAS_CAPPED) self.capped_check.setChecked(config.VIAS_CAPPED)
form.addRow("Vias:", self.capped_check) form.addRow("Vias:", self.capped_check)
self.cap_drill_edit = QLineEdit(f"{config.CAP_MAX_DRILL_MM:g}")
self.cap_drill_edit.setEnabled(config.VIAS_CAPPED)
self.capped_check.toggled.connect(self.cap_drill_edit.setEnabled)
form.addRow("Capped up to drill [mm]:", self.cap_drill_edit)
self.adaptive_check = QCheckBox( self.adaptive_check = QCheckBox(
"adaptive cells (coarsen plane interiors; faster on large " "adaptive cells (coarsen plane interiors; faster on large "
"boards, corrected to ≲0.1 % of the uniform grid)") "boards, corrected to ≲0.1 % of the uniform grid)")
@@ -205,6 +211,11 @@ class _Dialog(QDialog):
extra_cu = number(self.extracu_edit, "Extra Cu") extra_cu = number(self.extracu_edit, "Extra Cu")
if extra_cu < 0: if extra_cu < 0:
raise ValueError("Extra Cu must be ≥ 0 µm.") raise ValueError("Extra Cu must be ≥ 0 µm.")
cap_max_drill = config.CAP_MAX_DRILL_MM
if self.capped_check.isChecked():
cap_max_drill = number(self.cap_drill_edit, "Capped up to drill")
if cap_max_drill <= 0:
raise ValueError("Capped-up-to drill must be > 0 mm.")
def contact(box: QComboBox) -> str: def contact(box: QComboBox) -> str:
t = box.currentText() t = box.currentText()
@@ -222,6 +233,7 @@ class _Dialog(QDialog):
extra_cu_um=extra_cu, extra_cu_um=extra_cu,
include_tracks=self.tracks_check.isChecked(), include_tracks=self.tracks_check.isChecked(),
vias_capped=self.capped_check.isChecked(), vias_capped=self.capped_check.isChecked(),
cap_max_drill_mm=cap_max_drill,
adaptive=self.adaptive_check.isChecked()) adaptive=self.adaptive_check.isChecked())
def _try_accept(self) -> None: def _try_accept(self) -> None:
+168 -12
View File
@@ -17,7 +17,7 @@ from pathlib import Path
import numpy as np import numpy as np
JSON_SCHEMA_VERSION = 5 JSON_SCHEMA_VERSION = 6
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -93,16 +93,39 @@ class TrackSeg:
@dataclass @dataclass
class Electrode: class Electrode:
"""One PART of a current-injection terminal: a drawn rectangle or a """One PART of a current-injection terminal: a drawn rectangle, a
selected pad. A terminal (V+ or V-) is a LIST of parts, all merged selected pad, or a selected via. A terminal (V+ or V-) is a LIST of
into one equipotential contact (externally bonded). `polygons` parts, all merged into one equipotential contact (externally
(board nm) is the exact copper shape when known (pads); None means bonded). `polygons` (board nm) is the exact copper shape when known
the rectangle itself is the shape. `contact` = 'all' or a layer (pads); None means the rectangle itself is the shape. `contact` =
name: which included layers this part touches.""" 'all' or a layer name: which included layers this part touches.
drill_nm > 0 marks a BARREL contact (selected via or through-hole
pad): the current physically enters through the plated barrel (the
lead/wire soldered into the hole), so the contact cells are the
copper ring at the drill wall, not the whole pad face. `solder`
additionally models a soldered THT joint: the hole is filled with
solder and the pad face on the SOLDER side (protrusion_side,
opposite the component) carries an average-thickness solder coat
(Problem.solder_thickness_nm over `polygons`) plus the
protruding-lead cone."""
rect: Rect # bounding box (labels/summary) rect: Rect # bounding box (labels/summary)
contact: str = "all" contact: str = "all"
polygons: list[Polygon] | None = None polygons: list[Polygon] | None = None
label: str = "rect" label: str = "rect"
drill_nm: int = 0 # >0: barrel contact
pad_nm: int = 0 # pad diameter (search bound;
# largest dimension if oblong)
pad_min_nm: int = 0 # smallest pad dimension (cone
# taper bound); 0 = pad_nm
center: tuple[int, int] | None = None # drill center; None = rect center
barrel_z: tuple[int, int] | None = None # (z_top, z_bot); None = full stack
solder: bool = False # soldered THT joint (see above)
protrusion_side: str | None = None # outer layer where the clipped
# lead protrudes (opposite the
# component): a solder cone
# wraps it there, see
# Problem.tht_protrusion_nm
@dataclass @dataclass
@@ -116,16 +139,43 @@ class ViaLink:
z_bot_nm: int z_bot_nm: int
kind: str = "via" # "via" | "pad" kind: str = "via" # "via" | "pad"
pad_nm: int = 0 # pad/annular diameter; 0 = unknown pad_nm: int = 0 # pad/annular diameter; 0 = unknown
# (oblong pads: LARGEST dimension,
# used as a search bound)
pad_min_nm: int = 0 # smallest pad dimension (bounds
# the lead-cone taper on oblong
# pads); 0 = same as pad_nm
solder_filled: bool = False # populated THT pad: the hole
# holds lead + solder (in parallel
# with the plating); False for
# vias and DNP footprints
protrusion_side: str | None = None # populated THT pad: outer layer
# where the clipped lead tents
# (solder cone), opposite the
# component side
def spans(self, z_nm: int) -> bool: def spans(self, z_nm: int) -> bool:
return self.z_top_nm - 1 <= z_nm <= self.z_bot_nm + 1 return self.z_top_nm - 1 <= z_nm <= self.z_bot_nm + 1
def barrel_resistance(self, length_nm: int, rho_ohm_m: float, def barrel_resistance(self, length_nm: int, rho_ohm_m: float,
plating_nm: int) -> float: plating_nm: int,
solder_rho_ohm_m: float | None = None,
lead_nm: float = 0,
lead_rho_ohm_m: float | None = None) -> float:
"""Barrel segment resistance over length_nm: thin-wall annulus of """Barrel segment resistance over length_nm: thin-wall annulus of
plating around the drill.""" plating around the drill. With solder_rho_ohm_m the hole holds a
area_m2 = math.pi * (self.drill_nm * 1e-9) * (plating_nm * 1e-9) soldered THT joint: the component lead (a cylinder of lead_nm
return rho_ohm_m * (length_nm * 1e-9) / area_m2 diameter, resistivity lead_rho_ohm_m) and the solder filling the
remaining annulus conduct in parallel with the plating."""
ga = math.pi * (self.drill_nm * 1e-9) * (plating_nm * 1e-9) \
/ rho_ohm_m # conductance-area [m^2/ohm-m]
if solder_rho_ohm_m is not None:
r_core = max(self.drill_nm / 2.0 - plating_nm, 0.0) * 1e-9
r_lead = min(lead_nm * 1e-9 / 2.0, r_core)
if lead_rho_ohm_m is not None and r_lead > 0:
ga += math.pi * r_lead * r_lead / lead_rho_ohm_m
ga += math.pi * (r_core * r_core - r_lead * r_lead) \
/ solder_rho_ohm_m
return (length_nm * 1e-9) / ga
@dataclass @dataclass
@@ -147,6 +197,23 @@ class Problem:
vias_capped: bool = True # filled+capped vias: thin cap vias_capped: bool = True # filled+capped vias: thin cap
cap_plating_nm: int = 15_000 # over outer-layer mouths; cap_plating_nm: int = 15_000 # over outer-layer mouths;
# False = open mouths # False = open mouths
cap_max_drill_nm: int = 500_000 # fab caps only small vias:
# drills above this stay open
# even with vias_capped
tht_protrusion_nm: int = 1_500_000 # clipped THT lead protrusion:
# a solder cone of this height
# at the drill wall (tapering
# to zero at the pad edge)
# wraps the lead on each solder
# contact's protrusion_side;
# 0 disables the cones
tht_lead_clearance_nm: int = 250_000 # hole minus lead diameter (fab
# rule): the lead cylinder of
# drill - this conducts inside
# every solder-filled hole
tht_lead_rho_ohm_m: float = 1.68e-8 # lead material resistivity
# (copper; brass ~6.4e-8,
# copper-clad steel higher)
@property @property
def layer_names(self) -> list[str]: def layer_names(self) -> list[str]:
@@ -173,6 +240,66 @@ class Problem:
return int(x.min()), int(y.min()), int(x.max()), int(y.max()) return int(x.min()), int(y.min()), int(x.max()), int(y.max())
def contact_solder_buildups(problem: Problem) -> list[str]:
"""Soldered THT-joint contacts: the pad face on the SOLDER side (the
protrusion side, opposite the component - the component-side face
stays bare) is covered in solder of average thickness
solder_thickness_nm. Adds one SurfaceBuildup there for every
`solder` electrode's pad shape (the buildup machinery intersects
with actual copper at raster time). Returns the affected layer
names. Called once when the problem is built."""
included = {l.layer_name for l in problem.layers}
touched = []
for e in problem.electrodes1 + problem.electrodes2:
if not e.solder or not e.polygons \
or e.protrusion_side not in included:
continue
problem.buildups.append(
SurfaceBuildup(layer_name=e.protrusion_side,
polygons=list(e.polygons)))
touched.append(e.protrusion_side)
return sorted(set(touched))
def _disc_polygon(x_nm: float, y_nm: float, r_nm: float,
n: int = 32) -> Polygon:
th = np.linspace(0.0, 2.0 * math.pi, n, endpoint=False)
return Polygon(outline=np.round(np.stack(
[x_nm + r_nm * np.cos(th), y_nm + r_nm * np.sin(th)],
axis=1)).astype(np.int64))
def tht_joint_buildups(problem: Problem,
shapes: dict | None = None) -> list[str]:
"""Solder coat of the net's populated STITCHING through-hole pads
(ViaLink kind 'pad' with solder_filled), on the pad's SOLDER side
(the protrusion side, opposite the component; the component-side
face stays bare). `shapes` maps (x, y) to the exact pad polygons
(fetched from KiCad); pads without one fall back to a pad-diameter
disc. Contact pads are skipped: contact_solder_buildups already
coats them with the exact pad shape. Returns the affected layer
names."""
included = {l.layer_name for l in problem.layers}
contacts = {e.center for e in problem.electrodes1 + problem.electrodes2
if e.drill_nm > 0 and e.center is not None}
touched = []
for v in problem.vias:
if v.kind != "pad" or not v.solder_filled \
or (v.x, v.y) in contacts \
or v.protrusion_side not in included:
continue
polys = (shapes or {}).get((v.x, v.y))
if polys is None:
if v.pad_nm <= v.drill_nm:
continue
polys = [_disc_polygon(v.x, v.y, v.pad_nm / 2.0)]
problem.buildups.append(
SurfaceBuildup(layer_name=v.protrusion_side,
polygons=list(polys)))
touched.append(v.protrusion_side)
return sorted(set(touched))
def _arc_params(start, mid, end) -> tuple[float, float, float, float, float] | None: def _arc_params(start, mid, end) -> tuple[float, float, float, float, float] | None:
"""Circle through three points: (cx, cy, r, a0, sweep) with a0 the """Circle through three points: (cx, cy, r, a0, sweep) with a0 the
start angle and sweep signed; None if the points are collinear.""" start angle and sweep signed; None if the points are collinear."""
@@ -321,6 +448,13 @@ def _electrode_to_json(e: Electrode) -> dict:
"label": e.label, "label": e.label,
"polygons": (None if e.polygons is None "polygons": (None if e.polygons is None
else [_poly_to_json(poly) for poly in e.polygons]), else [_poly_to_json(poly) for poly in e.polygons]),
"drill_nm": e.drill_nm,
"pad_nm": e.pad_nm,
"pad_min_nm": e.pad_min_nm,
"center": (None if e.center is None else list(e.center)),
"barrel_z": (None if e.barrel_z is None else list(e.barrel_z)),
"solder": e.solder,
"protrusion_side": e.protrusion_side,
} }
@@ -331,6 +465,15 @@ def _electrode_from_json(d: dict) -> Electrode:
label=d.get("label", "rect"), label=d.get("label", "rect"),
polygons=(None if d.get("polygons") is None polygons=(None if d.get("polygons") is None
else [_poly_from_json(pd) for pd in d["polygons"]]), else [_poly_from_json(pd) for pd in d["polygons"]]),
drill_nm=int(d.get("drill_nm", 0)),
pad_nm=int(d.get("pad_nm", 0)),
pad_min_nm=int(d.get("pad_min_nm", 0)),
center=(None if d.get("center") is None
else (int(d["center"][0]), int(d["center"][1]))),
barrel_z=(None if d.get("barrel_z") is None
else (int(d["barrel_z"][0]), int(d["barrel_z"][1]))),
solder=bool(d.get("solder", False)),
protrusion_side=d.get("protrusion_side"),
) )
@@ -369,6 +512,10 @@ def problem_to_json(p: Problem) -> dict:
"extra_cu_nm": p.extra_cu_nm, "extra_cu_nm": p.extra_cu_nm,
"vias_capped": p.vias_capped, "vias_capped": p.vias_capped,
"cap_plating_nm": p.cap_plating_nm, "cap_plating_nm": p.cap_plating_nm,
"cap_max_drill_nm": p.cap_max_drill_nm,
"tht_protrusion_nm": p.tht_protrusion_nm,
"tht_lead_clearance_nm": p.tht_lead_clearance_nm,
"tht_lead_rho_ohm_m": p.tht_lead_rho_ohm_m,
} }
@@ -415,7 +562,12 @@ def problem_from_json(d: dict) -> Problem:
ViaLink(x=int(vd["x"]), y=int(vd["y"]), drill_nm=int(vd["drill_nm"]), 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"]), z_top_nm=int(vd["z_top_nm"]), z_bot_nm=int(vd["z_bot_nm"]),
kind=vd.get("kind", "via"), kind=vd.get("kind", "via"),
pad_nm=int(vd.get("pad_nm", 0))) pad_nm=int(vd.get("pad_nm", 0)),
pad_min_nm=int(vd.get("pad_min_nm", 0)),
# older dumps: every THT pad counted as solder-filled
solder_filled=bool(vd.get(
"solder_filled", vd.get("kind", "via") == "pad")),
protrusion_side=vd.get("protrusion_side"))
for vd in d["vias"] for vd in d["vias"]
], ],
electrodes1=( electrodes1=(
@@ -442,6 +594,10 @@ def problem_from_json(d: dict) -> Problem:
], ],
vias_capped=bool(d.get("vias_capped", True)), vias_capped=bool(d.get("vias_capped", True)),
cap_plating_nm=int(d.get("cap_plating_nm", 15_000)), cap_plating_nm=int(d.get("cap_plating_nm", 15_000)),
cap_max_drill_nm=int(d.get("cap_max_drill_nm", 500_000)),
tht_protrusion_nm=int(d.get("tht_protrusion_nm", 1_500_000)),
tht_lead_clearance_nm=int(d.get("tht_lead_clearance_nm", 250_000)),
tht_lead_rho_ohm_m=float(d.get("tht_lead_rho_ohm_m", 1.68e-8)),
) )
+3 -2
View File
@@ -33,7 +33,7 @@ def main() -> None:
try: try:
kicad, board = board_io.connect() kicad, board = board_io.connect()
stackup = board_io.get_stackup_info(board) stackup = board_io.get_stackup_info(board)
es1, es2, net_hint = board_io.get_electrodes(board) es1, es2, net_hint = board_io.get_electrodes(board, stackup)
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL: if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
board_io.refill(board) board_io.refill(board)
fills = board_io.gather_net_fills(board) fills = board_io.gather_net_fills(board)
@@ -95,7 +95,8 @@ def main() -> None:
buildups=(buildups if selection.include_buildup else None), buildups=(buildups if selection.include_buildup else None),
extra_cu_um=selection.extra_cu_um, extra_cu_um=selection.extra_cu_um,
tracks=(tracks if selection.include_tracks else None), tracks=(tracks if selection.include_tracks else None),
vias_capped=selection.vias_capped) vias_capped=selection.vias_capped,
cap_max_drill_mm=selection.cap_max_drill_mm)
outdir = report.make_output_dir(board_io.board_dir(board)) outdir = report.make_output_dir(board_io.board_dir(board))
except ApiError as e: except ApiError as e:
raise UserFacingError(f"KiCad API error: {e}") raise UserFacingError(f"KiCad API error: {e}")
+153 -20
View File
@@ -232,9 +232,76 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
_paint_ring(stack, hole, False, pmask) _paint_ring(stack, hole, False, pmask)
stack.buildup[li] |= pmask stack.buildup[li] |= pmask
stack.buildup &= stack.masks # solder wets exposed copper only stack.buildup &= stack.masks # solder wets exposed copper only
_paint_lead_fillets(stack, problem)
return stack return stack
def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
"""Protruding THT leads (barrel contacts AND the net's populated
stitching through-hole pads): the clipped lead sticks
tht_protrusion_nm out of the hole on the side opposite the
component, wrapped by a solder cone - full protrusion height at
the drill wall, tapering linearly to zero at the pad edge. Modeled
as extra conduction-equivalent copper via stack.thick_scale: the
tall solder column next to the wall pulls those cells to lead
potential (equivalent to extending the barrel wall vertically), the
taper carries the radial spreading. At f > 0 the factor multiplies
the skin-corrected sheet conductance, like the via mouths
(approximation)."""
H = problem.tht_protrusion_nm
if H <= 0:
return
ny, nx = stack.shape2d
h = stack.h_nm
index = {name: li for li, name in enumerate(stack.layer_names)}
# one cone per joint: contact electrodes first (exact data), then the
# net's populated stitching THT pads, skipping the contacts' barrels
jobs = []
seen = set()
for e in problem.electrodes1 + problem.electrodes2:
if e.drill_nm <= 0:
continue
if e.center is not None:
x, y = e.center
else:
x = (e.rect.x0 + e.rect.x1) / 2.0
y = (e.rect.y0 + e.rect.y1) / 2.0
seen.add((int(x), int(y)))
if e.solder and e.protrusion_side:
# oblong pads: taper to the inscribed circle (conservative)
jobs.append((x, y, e.drill_nm, e.pad_min_nm or e.pad_nm,
e.protrusion_side))
for v in problem.vias:
if v.kind == "pad" and v.solder_filled and v.protrusion_side \
and (v.x, v.y) not in seen:
jobs.append((v.x, v.y, v.drill_nm, v.pad_min_nm or v.pad_nm,
v.protrusion_side))
for x, y, drill_nm, pad_nm, side in jobs:
li = index.get(side)
if li is None or pad_nm <= drill_nm:
continue
ra, rb = drill_nm / 2.0, pad_nm / 2.0
j0 = max(0, math.floor((x - rb - stack.x0_nm) / h))
j1 = min(nx, math.floor((x + rb - stack.x0_nm) / h) + 1)
i0 = max(0, math.floor((y - rb - stack.y0_nm) / h))
i1 = min(ny, math.floor((y + rb - stack.y0_nm) / h) + 1)
if i0 >= i1 or j0 >= j1:
continue
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y
r = np.sqrt(ys[:, None] ** 2 + xs[None, :] ** 2)
t_sn = H * np.clip((rb - r) / (rb - ra), 0.0, 1.0)
t_eq = t_sn * (problem.rho_ohm_m / problem.solder_rho_ohm_m)
factor = 1.0 + t_eq / problem.layers[li].thickness_nm
if stack.thick_scale is None:
stack.thick_scale = np.ones(stack.masks.shape)
m = stack.masks[li, i0:i1, j0:j1]
stack.thick_scale[li, i0:i1, j0:j1] *= np.where(m, factor, 1.0)
def _via_span(problem: Problem, via) -> list[int]: def _via_span(problem: Problem, via) -> list[int]:
return [li for li, layer in enumerate(problem.layers) return [li for li, layer in enumerate(problem.layers)
if via.spans(layer.z_nm)] if via.spans(layer.z_nm)]
@@ -268,15 +335,22 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
"""Drill-mouth treatment, area-weighted per cell (4x4 supersampling): """Drill-mouth treatment, area-weighted per cell (4x4 supersampling):
capped vias carry a cap_plating-thin copper cap over the mouth on the capped vias carry a cap_plating-thin copper cap over the mouth on the
OUTER layers, uncapped vias (and inner layers either way) get an open OUTER layers, uncapped vias (and inner layers either way) get an open
hole. Fully swallowed cells leave the mask; partially covered cells hole. The fab caps only small vias: drills above cap_max_drill_nm
keep a thickness-scaled sheet conductance via stack.thick_scale.""" stay open even with vias_capped. THT pad mouths: populated pads are
solder-filled - the mouth copper stays and stands in for the plug
(conservative: the plug's solder is worth far more than the foil);
DNP pad holes are cut open on every layer. Fully swallowed cells
leave the mask; partially covered cells keep a thickness-scaled
sheet conductance via stack.thick_scale."""
ny, nx = stack.shape2d ny, nx = stack.shape2d
h = stack.h_nm h = stack.h_nm
outer = {li for li, n in enumerate(stack.layer_names) outer = {li for li, n in enumerate(stack.layer_names)
if n in ("F.Cu", "B.Cu")} if n in ("F.Cu", "B.Cu")}
sub = (np.arange(4) + 0.5) / 4.0 sub = (np.arange(4) + 0.5) / 4.0
for via in problem.vias: for via in problem.vias:
if via.kind != "via" or via.drill_nm <= 0: if via.drill_nm <= 0:
continue
if via.kind == "pad" and via.solder_filled:
continue continue
r = via.drill_nm / 2.0 r = via.drill_nm / 2.0
j0 = max(0, math.floor((via.x - r - stack.x0_nm) / h)) j0 = max(0, math.floor((via.x - r - stack.x0_nm) / h))
@@ -296,11 +370,12 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
if stack.thick_scale is None: if stack.thick_scale is None:
stack.thick_scale = np.ones(stack.masks.shape) stack.thick_scale = np.ones(stack.masks.shape)
for li in _via_span(problem, via): for li in _via_span(problem, via):
if problem.vias_capped and li in outer: if via.kind == "via" and problem.vias_capped and li in outer \
and via.drill_nm <= problem.cap_max_drill_nm:
ratio = min(problem.cap_plating_nm ratio = min(problem.cap_plating_nm
/ problem.layers[li].thickness_nm, 1.0) / problem.layers[li].thickness_nm, 1.0)
else: else:
ratio = 0.0 ratio = 0.0 # open hole (also DNP THT holes)
s = 1.0 - cov * (1.0 - ratio) s = 1.0 - cov * (1.0 - ratio)
gone = s <= 1e-9 gone = s <= 1e-9
stack.masks[li, i0:i1, j0:j1] &= ~gone stack.masks[li, i0:i1, j0:j1] &= ~gone
@@ -399,26 +474,88 @@ def _electrode_cells2d(stack: RasterStack, e: Electrode) -> np.ndarray:
return _rect_cells(stack, e.rect) return _rect_cells(stack, e.rect)
def _barrel_ring2d(stack: RasterStack, e: Electrode,
mask2d: np.ndarray) -> np.ndarray:
"""Contact cells of a barrel electrode on one layer: the copper ring
at the drill wall (cell centers within one cell of radius drill/2),
where the lead/wire soldered into the hole actually meets the layer.
If rasterization or an antipad leaves no copper there, fall back to
the nearest copper ring within the pad footprint (+1 cell of slop) -
the same search bound as the solver's barrel attachment."""
ny, nx = stack.shape2d
h = stack.h_nm
if e.center is not None:
x, y = e.center
else:
x = (e.rect.x0 + e.rect.x1) / 2.0
y = (e.rect.y0 + e.rect.y1) / 2.0
r = e.drill_nm / 2.0
rw = max(e.pad_nm, e.drill_nm + 300_000) / 2.0 + h
out = np.zeros((ny, nx), dtype=bool)
j0 = max(0, math.floor((x - rw - stack.x0_nm) / h))
j1 = min(nx, math.floor((x + rw - stack.x0_nm) / h) + 1)
i0 = max(0, math.floor((y - rw - stack.y0_nm) / h))
i1 = min(ny, math.floor((y + rw - stack.y0_nm) / h) + 1)
if i0 >= i1 or j0 >= j1:
return out
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y
d = np.sqrt(ys[:, None] ** 2 + xs[None, :] ** 2)
m = mask2d[i0:i1, j0:j1]
ring = m & (np.abs(d - r) <= h)
if not ring.any():
dc = np.where(m & (d <= rw), d, np.inf)
dmin = dc.min()
if np.isfinite(dmin):
ring = dc <= dmin + h # e.g. thermal-spoke tips
out[i0:i1, j0:j1] = ring
return out
def _part_mask3d(stack: RasterStack, problem: Problem,
el: Electrode) -> np.ndarray:
"""(L, ny, nx) contact cells of one electrode part: the barrel-wall
ring on every spanned layer for via/THT-pad contacts, else the
part's shape ∩ copper on its contact layer(s)."""
part = np.zeros_like(stack.masks)
if el.drill_nm > 0:
for li, name in enumerate(stack.layer_names):
if el.contact not in ("all", name):
continue
if el.barrel_z is not None:
z = problem.layers[li].z_nm
if not (el.barrel_z[0] - 1 <= z <= el.barrel_z[1] + 1):
continue
part[li] = _barrel_ring2d(stack, el, stack.masks[li])
return part
cells2d = _electrode_cells2d(stack, el)
for li, name in enumerate(stack.layer_names):
if el.contact in ("all", name):
part[li] = cells2d & stack.masks[li]
return part
def electrode_masks(stack: RasterStack, problem: Problem def electrode_masks(stack: RasterStack, problem: Problem
) -> tuple[np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray]:
"""Terminal mask = OR over its parts; part = shape ∩ copper on the """Terminal mask = OR over its parts; part = shape ∩ copper on the
part's contact layer(s). contact 'all' = every included layer (bolted part's contact layer(s), or the barrel-wall ring for via/THT-pad
lug / through pad); a layer name = that layer only. Every part must contacts (current enters through the soldered barrel, not the pad
individually land on copper (clear feedback). V+/V- must not overlap; face). contact 'all' = every included layer (bolted lug / through
touching is checked later, only for the equipotential contact model.""" 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: def build(parts: list[Electrode], which: str) -> np.ndarray:
e = np.zeros_like(stack.masks) e = np.zeros_like(stack.masks)
for el in parts: for el in parts:
cells2d = _electrode_cells2d(stack, el) part = _part_mask3d(stack, problem, 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(): if not part.any():
where = ("near its barrel (drill-wall ring / pad footprint)"
if el.drill_nm > 0 else
"(or is smaller than one grid cell)")
raise ElectrodeError( raise ElectrodeError(
f"A {which} contact part ({el.label}) does not overlap " f"A {which} contact part ({el.label}) does not overlap "
f"any copper of the selected fill on contact layer(s) " f"any copper of the selected fill on contact layer(s) "
f"'{el.contact}' (or is smaller than one grid cell)." f"'{el.contact}' {where}."
) )
e |= part e |= part
if not e.any(): if not e.any():
@@ -446,11 +583,7 @@ def electrode_partition(stack: RasterStack, problem: Problem
out = [] out = []
claimed = np.zeros_like(stack.masks) claimed = np.zeros_like(stack.masks)
for el in parts: for el in parts:
cells2d = _electrode_cells2d(stack, el) m = _part_mask3d(stack, problem, 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 m &= ~claimed
claimed |= m claimed |= m
out.append((el.label, m)) out.append((el.label, m))
+10 -2
View File
@@ -178,8 +178,16 @@ def _barrel_links(stack: RasterStack, problem: Problem
length = problem.layers[lb].z_nm - problem.layers[la].z_nm length = problem.layers[lb].z_nm - problem.layers[la].z_nm
if length <= 0: if length <= 0:
continue continue
r_dc = via.barrel_resistance(length, problem.rho_ohm_m, # populated THT pads carry a soldered component lead: lead
problem.plating_nm) # cylinder (drill minus the fab clearance) + solder annulus
# in parallel with the plating (DNP pads and vias stay
# plating-only)
r_dc = via.barrel_resistance(
length, problem.rho_ohm_m, problem.plating_nm,
solder_rho_ohm_m=(problem.solder_rho_ohm_m
if via.solder_filled else None),
lead_nm=max(via.drill_nm - problem.tht_lead_clearance_nm, 0),
lead_rho_ohm_m=problem.tht_lead_rho_ohm_m)
links.append((vi, la, ia, ja, lb, ib, jb, r_dc)) links.append((vi, la, ia, ja, lb, ib, jb, r_dc))
return links, dead return links, dead
+6
View File
@@ -42,6 +42,10 @@ def main(argv=None) -> int:
ap.add_argument("--uncapped", action="store_true", ap.add_argument("--uncapped", action="store_true",
help="treat vias as uncapped (open drill mouths on " help="treat vias as uncapped (open drill mouths on "
"all layers)") "all layers)")
ap.add_argument("--cap-max-drill", type=float, default=None,
metavar="MM",
help="cap only vias with drill <= this [mm]; larger "
"drills stay open (default: from the dump)")
ap.add_argument("--extra-cu-um", type=float, default=None, ap.add_argument("--extra-cu-um", type=float, default=None,
help="override the added copper in mask openings [um]") help="override the added copper in mask openings [um]")
ap.add_argument("--force-iterative", action="store_true", ap.add_argument("--force-iterative", action="store_true",
@@ -68,6 +72,8 @@ def main(argv=None) -> int:
problem.buildups = [] problem.buildups = []
if args.uncapped: if args.uncapped:
problem.vias_capped = False problem.vias_capped = False
if args.cap_max_drill is not None:
problem.cap_max_drill_nm = int(args.cap_max_drill * 1e6)
if args.extra_cu_um is not None: if args.extra_cu_um is not None:
problem.extra_cu_nm = int(args.extra_cu_um * 1000) problem.extra_cu_nm = int(args.extra_cu_um * 1000)
if args.layers: if args.layers:
+2 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://go.kicad.org/pcm/schemas/v2", "$schema": "https://go.kicad.org/pcm/schemas/v2",
"name": "Fill Resistance", "name": "Fill Resistance",
"description": "DC/AC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps.", "description": "DC/AC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps.",
"description_full": "Computes the DC or AC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels; traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. At a user-set frequency the exact 1D foil/barrel skin-effect correction is applied (AC results are a rigorous lower bound). PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.", "description_full": "Computes the DC or AC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads/vias), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels. Selected vias/THT pads inject at the drill-wall barrel, and every populated THT hole carries its full solder joint (component lead, solder fill, one-sided pad coat and protruding-lead cone) with exact pad shapes and do-not-populate flags read from KiCad; traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. At a user-set frequency the exact 1D foil/barrel skin-effect correction is applied (AC results are a rigorous lower bound). PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
"identifier": "th.co.b4l.fill-resistance", "identifier": "th.co.b4l.fill-resistance",
"type": "plugin", "type": "plugin",
"author": { "author": {
@@ -17,7 +17,7 @@
}, },
"versions": [ "versions": [
{ {
"version": "1.0.1", "version": "1.1.0",
"status": "stable", "status": "stable",
"kicad_version": "10.0", "kicad_version": "10.0",
"runtime": "ipc" "runtime": "ipc"
+338
View File
@@ -0,0 +1,338 @@
"""Barrel (via / through-hole pad) contact tests: current enters at the
drill-wall ring, not the pad face, and soldered THT joints carry a
solder-filled hole plus an average-thickness solder coat on the pad."""
import math
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.geometry import (Electrode, Polygon, ViaLink,
contact_solder_buildups, load_problem,
problem_from_json, problem_to_json,
save_problem, tht_joint_buildups)
from tests.util import NM, make_problem, rect_mm, ring_mm
PLATE20 = [(0, 0), (20, 0), (20, 20), (0, 20)]
def _barrel(x_mm, y_mm, drill_mm, pad_mm=0.0, solder=False, polygons=None):
r = max(pad_mm, drill_mm) / 2
return Electrode(
rect=rect_mm((x_mm - r, y_mm - r, x_mm + r, y_mm + r)),
contact="all", label=f"via({x_mm},{y_mm})",
drill_nm=int(drill_mm * NM), pad_nm=int(pad_mm * NM),
center=(int(x_mm * NM), int(y_mm * NM)), solder=solder,
polygons=polygons)
def _disc(x_mm, y_mm, r_mm, n=64) -> Polygon:
ang = np.linspace(0, 2 * np.pi, n, endpoint=False)
return Polygon(outline=ring_mm(
[(x_mm + r_mm * np.cos(a), y_mm + r_mm * np.sin(a)) for a in ang]))
def _solve(p, h_mm, model="equipotential"):
stack = raster.rasterize_stack(p, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, p)
return solver.run_solve(p, stack, e1, e2, 1.0, contact_model=model), stack
def test_ring_cells_at_drill_wall():
"""The contact cells of a barrel electrode form a ring at the drill
wall (one-cell tolerance), not the pad face."""
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=1.6)]
stack = raster.rasterize_stack(p, 0.1 * NM)
e1, _ = raster.electrode_masks(stack, p)
ii, jj = np.nonzero(e1[0])
xs = stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM
ys = stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM
d = np.hypot(xs, ys)
assert len(ii) >= 8
assert (np.abs(d - 0.5 * NM) <= stack.h_nm + 1).all()
# far fewer cells than the full 1.6 mm pad disc
assert len(ii) < 0.5 * math.pi * (0.8 * NM / stack.h_nm) ** 2
def test_two_barrel_contacts_match_acosh():
"""Two equipotential circular contacts of radius a, centers d apart,
on a large sheet: R = rho/(pi t) * acosh(d / 2a). The barrel-ring
contact must reproduce the analytic spreading resistance."""
t_um, rho = 70.0, 1.68e-8
plate = [(0, 0), (80, 0), (80, 60), (0, 60)]
p = make_problem([(plate, [])], rect1_mm=(0, 0, 1, 1),
rect2_mm=(79, 59, 80, 60), t_um=t_um, rho=rho)
p.electrodes1 = [_barrel(30, 30, drill_mm=2.0)]
p.electrodes2 = [_barrel(50, 30, drill_mm=2.0)]
res, _ = _solve(p, 0.15)
r_ref = rho / (math.pi * t_um * 1e-6) * math.acosh(20e-3 / (2 * 1e-3))
assert res.R_ohm == pytest.approx(r_ref, rel=0.08)
def test_barrel_includes_pad_spreading_resistance():
"""Injecting at the barrel wall (0.5 mm ring) sees the spreading
resistance the whole-pad-face contact (2.4 mm equipotential disc)
short-circuits: R_barrel > R_pad_face."""
p1 = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p1.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4)]
r_barrel, _ = _solve(p1, 0.1)
p2 = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p2.electrodes1 = [Electrode(rect=rect_mm((8.8, 8.8, 11.2, 11.2)),
contact="all", label="pad face",
polygons=[_disc(10, 10, 1.2)])]
r_face, _ = _solve(p2, 0.1)
assert r_barrel.R_ohm > r_face.R_ohm * 1.05
def test_ring_fallback_nearest_copper():
"""Antipad bigger than the drill: no copper at the wall ring, the
contact falls back to the nearest copper ring inside the pad
footprint (e.g. thermal-spoke tips / hole edge)."""
hole = [(10 + 1.2 * np.cos(a), 10 + 1.2 * np.sin(a))
for a in np.linspace(0, 2 * np.pi, 64, endpoint=False)]
p = make_problem([(PLATE20, [hole])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=0.6, pad_mm=4.0)]
res, stack = _solve(p, 0.1)
e1, _ = raster.electrode_masks(stack, p)
ii, jj = np.nonzero(e1[0])
d = np.hypot(stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM,
stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM)
assert len(ii) >= 8
assert (d >= 1.2 * NM - stack.h_nm).all()
assert (d <= 1.2 * NM + 2.5 * stack.h_nm).all()
assert np.isfinite(res.R_ohm) and res.R_ohm > 0
def test_solder_filled_barrel_resistance():
"""THT joints: the solder core conducts in parallel with the plating.
Exact parallel-area formula, and a sanity ratio for a 1 mm drill."""
v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1)
rho, sn = 1.68e-8, 1.32e-7
r_plain = v.barrel_resistance(1_600_000, rho, 18_000)
r_fill = v.barrel_resistance(1_600_000, rho, 18_000,
solder_rho_ohm_m=sn)
ga = math.pi * 1e-3 * 18e-6 / rho
ga += math.pi * (0.5e-3 - 18e-6) ** 2 / sn
assert r_fill == pytest.approx(1.6e-3 / ga, rel=1e-12)
assert 1.5 < r_plain / r_fill < 4.0
def test_contact_solder_coat():
"""A soldered THT contact adds an average-thickness solder buildup
over the pad face on its SOLDER side only (opposite the component),
lowering the spreading resistance vs the bare barrel contact."""
def prob():
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4,
solder=True, polygons=[_disc(10, 10, 1.2)])]
p.electrodes1[0].protrusion_side = "F.Cu"
return p
# solder side not among the included layers -> no coat there
q = prob()
q.electrodes1[0].protrusion_side = "B.Cu"
assert contact_solder_buildups(q) == []
p = prob()
assert contact_solder_buildups(p) == ["F.Cu"]
assert len(p.buildups) == 1 and p.buildups[0].layer_name == "F.Cu"
r_coat, stack = _solve(p, 0.1)
assert stack.buildup is not None and stack.buildup.any()
r_bare, _ = _solve(prob(), 0.1) # helper not called: no coat
assert r_coat.R_ohm < r_bare.R_ohm
def test_lead_fillet_profile():
"""The protruding-lead solder cone paints thick_scale with the exact
per-cell formula: 1 + H*clip((rb-r)/(rb-ra), 0, 1)*(rho_cu/rho_sn)/t
on copper of the protrusion side; nothing elsewhere."""
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4, solder=True)]
p.electrodes1[0].protrusion_side = "F.Cu"
stack = raster.rasterize_stack(p, 0.1 * NM)
assert stack.thick_scale is not None
ny, nx = stack.shape2d
jj, ii = np.meshgrid(np.arange(nx), np.arange(ny))
r = np.hypot(stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM,
stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM)
ra, rb, H = 0.5 * NM, 1.2 * NM, p.tht_protrusion_nm
t_eq = H * np.clip((rb - r) / (rb - ra), 0, 1) \
* (p.rho_ohm_m / p.solder_rho_ohm_m)
expect = np.where(stack.masks[0],
1.0 + t_eq / p.layers[0].thickness_nm, 1.0)
assert np.allclose(stack.thick_scale[0], expect, rtol=1e-12)
# 1.5 mm of solder at the wall ~ 191 um copper: factor ~ 3.7 on 70 um
assert stack.thick_scale[0].max() > 3.0
p.electrodes1[0].protrusion_side = None # e.g. via contact: no cone
s2 = raster.rasterize_stack(p, 0.1 * NM)
assert s2.thick_scale is None
def test_lead_fillet_lowers_resistance(monkeypatch):
"""The cone shorts the joint vicinity: R(with cone) < R(coat-less
bare barrel); the adaptive grid pins the cone cells fine and
matches the uniform grid."""
def prob(protrude=True):
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4,
solder=True)]
p.electrodes1[0].protrusion_side = "F.Cu"
if not protrude:
p.tht_protrusion_nm = 0
return p
r_cone, _ = _solve(prob(), 0.1)
r_bare, _ = _solve(prob(protrude=False), 0.1)
assert r_cone.R_ohm < r_bare.R_ohm
from fill_resistance import config
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
r_ada, _ = _solve(prob(), 0.1)
assert r_ada.R_ohm == pytest.approx(r_cone.R_ohm, rel=2e-3)
def _pad_link(populated=True):
return ViaLink(x=10 * NM, y=10 * NM, drill_nm=1_000_000, z_top_nm=-1,
z_bot_nm=1, kind="pad", pad_nm=2_400_000,
solder_filled=populated,
protrusion_side="F.Cu" if populated else None)
def test_stitching_pad_joint():
"""A populated THT pad on the net (not a contact) gets the full
joint: solder-side coat, cone, and a conducting (plugged) mouth;
a DNP pad gets an open hole and nothing else."""
def prob(populated=True):
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.vias = [_pad_link(populated)]
return p
p = prob()
assert tht_joint_buildups(p) == ["F.Cu"]
assert len(p.buildups) == 1
r_joint, stack = _solve(p, 0.1)
assert stack.thick_scale is not None and stack.thick_scale.max() > 3.0
assert stack.buildup is not None and stack.buildup.any()
assert stack.masks[0][stack.cell_of(10 * NM, 10 * NM)] # plugged mouth
q = prob(populated=False)
assert tht_joint_buildups(q) == []
r_bare, s2 = _solve(q, 0.1)
assert s2.buildup is None
assert not s2.masks[0][s2.cell_of(10 * NM, 10 * NM)] # DNP: open hole
assert r_joint.R_ohm < r_bare.R_ohm
def test_cone_not_doubled_at_contact():
"""A contact THT pad also appears in the net's pad list (ViaLink):
the cone and coat must be applied once, not squared/stacked."""
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4, solder=True,
polygons=[_disc(10, 10, 1.2)])]
p.electrodes1[0].protrusion_side = "F.Cu"
p.vias = [_pad_link()]
assert contact_solder_buildups(p) == ["F.Cu"]
assert tht_joint_buildups(p) == [] # contact center is skipped
stack = raster.rasterize_stack(p, 0.1 * NM)
wall = 1.0 + p.tht_protrusion_nm \
* (p.rho_ohm_m / p.solder_rho_ohm_m) / p.layers[0].thickness_nm
assert stack.thick_scale.max() == pytest.approx(wall, rel=1e-12)
def test_lead_in_barrel_resistance():
"""Populated hole: plating || lead cylinder || solder annulus, with
the lead clipped to the plating bore."""
v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1)
rho, sn = 1.68e-8, 1.32e-7
r_solder = v.barrel_resistance(1_600_000, rho, 18_000,
solder_rho_ohm_m=sn)
r_lead = v.barrel_resistance(1_600_000, rho, 18_000,
solder_rho_ohm_m=sn,
lead_nm=750_000, lead_rho_ohm_m=rho)
rl, rc = 0.375e-3, 0.5e-3 - 18e-6
ga = math.pi * 1e-3 * 18e-6 / rho
ga += math.pi * rl ** 2 / rho + math.pi * (rc ** 2 - rl ** 2) / sn
assert r_lead == pytest.approx(1.6e-3 / ga, rel=1e-12)
assert r_lead < r_solder
# a lead wider than the bore is clipped to it
r_big = v.barrel_resistance(1_600_000, rho, 18_000,
solder_rho_ohm_m=sn,
lead_nm=2_000_000, lead_rho_ohm_m=rho)
ga2 = math.pi * 1e-3 * 18e-6 / rho + math.pi * rc ** 2 / rho
assert r_big == pytest.approx(1.6e-3 / ga2, rel=1e-12)
def test_oblong_pad_cone_uses_inscribed_dim():
"""Oblong pads: the cone tapers to the inscribed circle (pad_min),
never past it, so the long pad axis is not overstated sideways."""
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.vias = [_pad_link()]
p.vias[0].pad_min_nm = 1_600_000 # 2.4 mm max, 1.6 mm min
stack = raster.rasterize_stack(p, 0.1 * NM)
ii, jj = np.nonzero(stack.thick_scale[0] != 1.0)
d = np.hypot(stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM,
stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM)
assert len(d) and d.max() < 0.8 * NM
def test_stitching_coat_exact_shape():
"""When KiCad supplies the exact pad polygon, the coat uses it
instead of the pad-diameter disc (oblong pads stay honest)."""
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.vias = [_pad_link()]
shape = _disc(10, 10, 0.9)
assert tht_joint_buildups(p, {(10 * NM, 10 * NM): [shape]}) == ["F.Cu"]
assert p.buildups[0].polygons[0] is shape
def test_vialink_solder_json():
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.vias = [_pad_link()]
d = problem_to_json(p)
q = problem_from_json(d)
assert q.vias[0].solder_filled is True
assert q.vias[0].protrusion_side == "F.Cu"
# legacy dumps without the flag: THT pads counted as solder-filled,
# vias as plating-only
del d["vias"][0]["solder_filled"], d["vias"][0]["protrusion_side"]
q = problem_from_json(d)
assert q.vias[0].solder_filled is True
assert q.vias[0].protrusion_side is None
d["vias"][0]["kind"] = "via"
assert problem_from_json(d).vias[0].solder_filled is False
def test_barrel_electrode_json_roundtrip(tmp_path):
p = make_problem([(PLATE20, [])],
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
p.electrodes1 = [_barrel(10, 10, drill_mm=0.6, pad_mm=1.2, solder=True,
polygons=[_disc(10, 10, 0.6)])]
p.electrodes1[0].barrel_z = (-1, 1_600_001)
p.electrodes1[0].protrusion_side = "B.Cu"
p.tht_protrusion_nm = 1_200_000
f = tmp_path / "d.json"
save_problem(p, f)
q = load_problem(f)
e = q.electrodes1[0]
assert e.drill_nm == 600_000 and e.pad_nm == 1_200_000
assert e.center == (10 * NM, 10 * NM)
assert e.barrel_z == (-1, 1_600_001)
assert e.solder is True and len(e.polygons) == 1
assert e.protrusion_side == "B.Cu"
assert q.tht_protrusion_nm == 1_200_000
+31 -4
View File
@@ -9,10 +9,13 @@ from tests.util import NM, make_multilayer
def _two_layer(width_mm=5.0, drill_mm=0.3, pad_mm=0.6, kind="via", def _two_layer(width_mm=5.0, drill_mm=0.3, pad_mm=0.6, kind="via",
capped=True, cap_um=15.0, hole_mm=None): capped=True, cap_um=15.0, hole_mm=None,
cap_max_drill_mm=10.0):
"""10 x width strip on both (outer-named) layers, e1 left on F.Cu, """10 x width strip on both (outer-named) layers, e1 left on F.Cu,
e2 right on B.Cu, one via mid-strip. Optionally a circular hole in e2 right on B.Cu, one via mid-strip. Optionally a circular hole in
the F.Cu fill around the via (ring-bridging scenario).""" the F.Cu fill around the via (ring-bridging scenario). The cap-drill
threshold defaults to 10 mm here (= every drill capped) so the tests
exercise the mouth treatment itself; the threshold has its own test."""
y = width_mm / 2 y = width_mm / 2
strip = [(0, 0), (10, 0), (10, width_mm), (0, width_mm)] strip = [(0, 0), (10, 0), (10, width_mm), (0, width_mm)]
holes = [] holes = []
@@ -31,6 +34,7 @@ def _two_layer(width_mm=5.0, drill_mm=0.3, pad_mm=0.6, kind="via",
p.vias[0].pad_nm = int(pad_mm * NM) p.vias[0].pad_nm = int(pad_mm * NM)
p.vias_capped = capped p.vias_capped = capped
p.cap_plating_nm = int(cap_um * 1000) p.cap_plating_nm = int(cap_um * 1000)
p.cap_max_drill_nm = int(cap_max_drill_mm * NM)
return p return p
@@ -46,7 +50,13 @@ def test_cap_at_foil_thickness_is_identity():
so the result equals the feature-off reference (a 'pad'-kind barrel, so the result equals the feature-off reference (a 'pad'-kind barrel,
which skips rings and mouths) with the mouth fully inside copper.""" which skips rings and mouths) with the mouth fully inside copper."""
r_cap, _ = _solve(_two_layer(capped=True, cap_um=70.0), 0.1) r_cap, _ = _solve(_two_layer(capped=True, cap_um=70.0), 0.1)
r_ref, _ = _solve(_two_layer(kind="pad"), 0.1) ref = _two_layer(kind="pad")
# populated pads skip rings and mouths; kill the lead + solder core
# so the reference barrel matches the via's plating-only resistance
ref.vias[0].solder_filled = True
ref.solder_rho_ohm_m = 1e30
ref.tht_lead_clearance_nm = 10 ** 9
r_ref, _ = _solve(ref, 0.1)
assert r_cap.R_ohm == pytest.approx(r_ref.R_ohm, rel=1e-9) assert r_cap.R_ohm == pytest.approx(r_ref.R_ohm, rel=1e-9)
@@ -90,14 +100,31 @@ def test_subcell_mouth_perturbs_gently():
assert r_solid.R_ohm <= r_open.R_ohm <= 1.05 * r_solid.R_ohm assert r_solid.R_ohm <= r_open.R_ohm <= 1.05 * r_solid.R_ohm
def test_cap_drill_threshold():
"""Drills above cap_max_drill_nm stay open even with capping on: a
2 mm drill over a 0.5 mm threshold behaves exactly like uncapped,
while a threshold above the drill restores the cap."""
kw = dict(drill_mm=2.0, pad_mm=2.6)
r_big, s_big = _solve(_two_layer(capped=True, cap_max_drill_mm=0.5,
**kw), 0.25)
r_open, s_open = _solve(_two_layer(capped=False, **kw), 0.25)
assert r_big.R_ohm == pytest.approx(r_open.R_ohm, rel=1e-12)
assert int(s_big.masks.sum()) == int(s_open.masks.sum())
r_cap, _ = _solve(_two_layer(capped=True, cap_max_drill_mm=2.1,
**kw), 0.25)
assert r_cap.R_ohm < r_open.R_ohm
def test_capping_json_roundtrip(tmp_path): def test_capping_json_roundtrip(tmp_path):
from fill_resistance.geometry import load_problem, save_problem from fill_resistance.geometry import load_problem, save_problem
p = _two_layer(capped=False, cap_um=12.0) p = _two_layer(capped=False, cap_um=12.0, cap_max_drill_mm=0.8)
f = tmp_path / "d.json" f = tmp_path / "d.json"
save_problem(p, f) save_problem(p, f)
q = load_problem(f) q = load_problem(f)
assert q.vias_capped is False assert q.vias_capped is False
assert q.cap_plating_nm == 12_000 assert q.cap_plating_nm == 12_000
assert q.cap_max_drill_nm == 800_000
r_p, _ = _solve(p, 0.25) r_p, _ = _solve(p, 0.25)
r_q, _ = _solve(q, 0.25) r_q, _ = _solve(q, 0.25)
assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12) assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12)
+317
View File
@@ -0,0 +1,317 @@
"""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.fig_potential(res, stack, e1, e2, p), "demo-potential"),
]
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()