Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eab58f3d1 | |||
| 935cb2a959 | |||
| 4408a4481d | |||
| 9ad3c526e5 | |||
| 959446978c | |||
| 38b6e34bfa | |||
| b2277f65bf | |||
| 9e307f2818 | |||
| 4ff729e192 | |||
| b4b666de77 |
@@ -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 }}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
*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.*
|
||||||
|
|
||||||
|

|
||||||
|
*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.
|
||||||
|
|
||||||
|

|
||||||
|
*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.
|
||||||
|
|
||||||
|

|
||||||
|
*|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.
|
||||||
|
|
||||||
|

|
||||||
|
*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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
+200
-35
@@ -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
|
||||||
z_bot_nm=stackup.z_bot_nm + 1, kind="pad",
|
for pad in net_pads:
|
||||||
pad_nm=_padstack_pad_nm(pad)))
|
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",
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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} mΩ",
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user