Model every THT pad hole: exact pad copper, lead conductor, DNP holes
Build PCM package / build (push) Successful in 6s
Build PCM package / build (push) Successful in 6s
- THT pad copper is now part of the conductor: the exact pad outline (incl. oblong/custom shapes) is fetched from KiCad once per pad and stamped onto every included layer (the outer shape stands in for inner rings). Annular rings bridge antipads, and joints land on real copper instead of only pour coverage. - The internal lead conductor is modeled in every solder-filled hole: a cylinder of drill - THT_LEAD_CLEARANCE_MM (0.25 fab rule) with THT_LEAD_RHO_OHM_M (copper default; config for brass/steel leads), in parallel with the solder annulus and the plating. - Drill mouths of THT pads: populated pads keep conducting mouth copper (stands in for the solder plug - conservative, the plug is worth ~200 um of copper equivalent); DNP pad holes are cut open on every layer like uncapped via mouths. - Oblong pads: the coat uses the exact pad shape; the lead cone tapers within the inscribed circle (new pad_min_nm on ViaLink/Electrode) so the long axis is not overstated sideways. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -87,17 +87,24 @@ SWIG API. Requires KiCad **10.0.1+**.
|
|||||||
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. Barrels are gathered in
|
perturbation instead of a whole-cell hole. Barrels are gathered in
|
||||||
**single-layer runs too** (drill mouths perforate a lone plane).
|
**single-layer runs too** (drill mouths perforate a lone plane).
|
||||||
THT-pad copper and drills remain outside the model, but every
|
**THT pads are fully modeled**: their exact copper shapes (incl.
|
||||||
**populated THT pad** of the net carries its full **soldered
|
oblong pads, fetched from KiCad; the outer shape stands in for inner
|
||||||
joint** on its SOLDER side (opposite the component; the
|
rings) are stamped onto every included layer, and every **populated**
|
||||||
component-side pad face stays bare): a solder-filled barrel (SAC305
|
pad carries its full **soldered joint** on its SOLDER side (opposite
|
||||||
core in parallel with the plating), the average-thickness solder
|
the component; the component-side pad face stays bare): the hole
|
||||||
coat over a pad-diameter disc, and the protruding-lead cone (see
|
holds the **component lead** (a cylinder of drill −
|
||||||
barrel contacts below). Whether a hole
|
`THT_LEAD_CLEARANCE_MM`, resistivity `THT_LEAD_RHO_OHM_M`, copper by
|
||||||
is a via or a THT pad, the owning footprint's side, and its **Do not
|
default — raise it for brass/steel leads) **plus solder** in the
|
||||||
populate** flag are all read from KiCad — DNP pads stay plating-only
|
remaining annulus, both in parallel with the plating; the mouth
|
||||||
with no joint. At f > 0 the thickness scaling is applied
|
copper stays conducting (it stands in for the plug — conservative,
|
||||||
multiplicatively to the skin-corrected sheet conductance
|
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
|
||||||
@@ -112,8 +119,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`
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
|
|||||||
# barrel; the joint is solder-filled + pad-coated,
|
# barrel; the joint is solder-filled + pad-coated,
|
||||||
# with a solder cone around the protruding lead
|
# with a solder cone around the protruding lead
|
||||||
drill_nm=drill, pad_nm=_padstack_pad_nm(pad),
|
drill_nm=drill, pad_nm=_padstack_pad_nm(pad),
|
||||||
|
pad_min_nm=_padstack_pad_min_nm(pad),
|
||||||
center=(pad.position.x, pad.position.y),
|
center=(pad.position.x, pad.position.y),
|
||||||
solder=drill > 0,
|
solder=drill > 0,
|
||||||
protrusion_side=(_tht_protrusion_side(pad, pad_map or {})
|
protrusion_side=(_tht_protrusion_side(pad, pad_map or {})
|
||||||
@@ -489,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:
|
||||||
@@ -539,6 +552,7 @@ def gather_barrels(board: Board, net_name: str,
|
|||||||
drill_nm=_pad_drill_nm(pad), z_top_nm=-1,
|
drill_nm=_pad_drill_nm(pad), z_top_nm=-1,
|
||||||
z_bot_nm=stackup.z_bot_nm + 1, kind="pad",
|
z_bot_nm=stackup.z_bot_nm + 1, kind="pad",
|
||||||
pad_nm=_padstack_pad_nm(pad),
|
pad_nm=_padstack_pad_nm(pad),
|
||||||
|
pad_min_nm=_padstack_pad_min_nm(pad),
|
||||||
solder_filled=populated,
|
solder_filled=populated,
|
||||||
protrusion_side=(_tht_protrusion_side(pad, pad_map,
|
protrusion_side=(_tht_protrusion_side(pad, pad_map,
|
||||||
quiet=True)
|
quiet=True)
|
||||||
@@ -549,6 +563,25 @@ def gather_barrels(board: Board, net_name: str,
|
|||||||
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],
|
||||||
@@ -586,6 +619,16 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
# barrels matter on a single layer too: via rings + drill mouths
|
# barrels matter on a single layer too: via rings + drill mouths
|
||||||
# perforate the plane, THT joints locally stiffen it
|
# perforate the plane, THT joints locally stiffen it
|
||||||
vias = gather_barrels(board, net, stackup)
|
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)
|
||||||
@@ -620,6 +663,8 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
cap_max_drill_nm=int((cap_max_drill_mm if cap_max_drill_mm is not None
|
cap_max_drill_nm=int((cap_max_drill_mm if cap_max_drill_mm is not None
|
||||||
else config.CAP_MAX_DRILL_MM) * 1e6),
|
else config.CAP_MAX_DRILL_MM) * 1e6),
|
||||||
tht_protrusion_nm=int(config.THT_LEAD_PROTRUSION_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)
|
solder_layers = contact_solder_buildups(problem)
|
||||||
if solder_layers:
|
if solder_layers:
|
||||||
@@ -632,15 +677,16 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
print(f"THT contact(s): solder-filled hole + "
|
print(f"THT contact(s): solder-filled hole + "
|
||||||
f"{config.SOLDER_THICKNESS_UM:g} um average solder coat on the "
|
f"{config.SOLDER_THICKNESS_UM:g} um average solder coat on the "
|
||||||
f"pad face ({', '.join(solder_layers)}){cone}")
|
f"pad face ({', '.join(solder_layers)}){cone}")
|
||||||
tht_joint_buildups(problem)
|
tht_joint_buildups(problem, pad_shapes)
|
||||||
n_joint = sum(1 for v in problem.vias
|
n_joint = sum(1 for v in problem.vias
|
||||||
if v.kind == "pad" and v.solder_filled)
|
if v.kind == "pad" and v.solder_filled)
|
||||||
n_dnp = sum(1 for v in problem.vias
|
n_dnp = sum(1 for v in problem.vias
|
||||||
if v.kind == "pad" and not v.solder_filled)
|
if v.kind == "pad" and not v.solder_filled)
|
||||||
if n_joint or n_dnp:
|
if n_joint or n_dnp:
|
||||||
print(f"{n_joint} populated THT pad joint(s): solder-filled hole + "
|
print(f"{n_joint} populated THT pad joint(s): lead + solder in the "
|
||||||
f"coat + lead cone"
|
f"hole, coat + cone on the solder side"
|
||||||
+ (f"; {n_dnp} DNP pad(s) plating-only" if n_dnp else ""))
|
+ (f"; {n_dnp} DNP pad(s): open hole, plating-only"
|
||||||
|
if n_dnp else ""))
|
||||||
return problem
|
return problem
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ THT_LEAD_PROTRUSION_MM = 1.5 # clipped THT lead protrusion on the side
|
|||||||
# opposite the component: a solder cone of
|
# opposite the component: a solder cone of
|
||||||
# this height at the drill wall (tapering to
|
# this height at the drill wall (tapering to
|
||||||
# zero at the pad edge) wraps the lead of
|
# zero at the pad edge) wraps the lead of
|
||||||
# every soldered THT CONTACT. 0 = no cones
|
# 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
|
||||||
|
|
||||||
|
|||||||
+55
-20
@@ -114,7 +114,10 @@ class Electrode:
|
|||||||
polygons: list[Polygon] | None = None
|
polygons: list[Polygon] | None = None
|
||||||
label: str = "rect"
|
label: str = "rect"
|
||||||
drill_nm: int = 0 # >0: barrel contact
|
drill_nm: int = 0 # >0: barrel contact
|
||||||
pad_nm: int = 0 # pad diameter (search bound)
|
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
|
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
|
barrel_z: tuple[int, int] | None = None # (z_top, z_bot); None = full stack
|
||||||
solder: bool = False # soldered THT joint (see above)
|
solder: bool = False # soldered THT joint (see above)
|
||||||
@@ -136,8 +139,13 @@ 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
|
||||||
solder_filled: bool = False # populated THT pad: the hole is
|
# (oblong pads: LARGEST dimension,
|
||||||
# solder-filled (core in parallel
|
# 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
|
# with the plating); False for
|
||||||
# vias and DNP footprints
|
# vias and DNP footprints
|
||||||
protrusion_side: str | None = None # populated THT pad: outer layer
|
protrusion_side: str | None = None # populated THT pad: outer layer
|
||||||
@@ -150,16 +158,23 @@ class ViaLink:
|
|||||||
|
|
||||||
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,
|
plating_nm: int,
|
||||||
solder_rho_ohm_m: float | None = None) -> float:
|
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. With solder_rho_ohm_m the hole is
|
plating around the drill. With solder_rho_ohm_m the hole holds a
|
||||||
solder-filled (soldered THT joint): the solder core conducts in
|
soldered THT joint: the component lead (a cylinder of lead_nm
|
||||||
parallel with the plating."""
|
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) \
|
ga = math.pi * (self.drill_nm * 1e-9) * (plating_nm * 1e-9) \
|
||||||
/ rho_ohm_m # plating conductance-area [m^2/ohm-m]
|
/ rho_ohm_m # conductance-area [m^2/ohm-m]
|
||||||
if solder_rho_ohm_m is not None:
|
if solder_rho_ohm_m is not None:
|
||||||
r_core = max(self.drill_nm / 2.0 - plating_nm, 0.0) * 1e-9
|
r_core = max(self.drill_nm / 2.0 - plating_nm, 0.0) * 1e-9
|
||||||
ga += math.pi * r_core * r_core / solder_rho_ohm_m
|
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
|
return (length_nm * 1e-9) / ga
|
||||||
|
|
||||||
|
|
||||||
@@ -192,6 +207,13 @@ class Problem:
|
|||||||
# wraps the lead on each solder
|
# wraps the lead on each solder
|
||||||
# contact's protrusion_side;
|
# contact's protrusion_side;
|
||||||
# 0 disables the cones
|
# 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]:
|
||||||
@@ -247,27 +269,33 @@ def _disc_polygon(x_nm: float, y_nm: float, r_nm: float,
|
|||||||
axis=1)).astype(np.int64))
|
axis=1)).astype(np.int64))
|
||||||
|
|
||||||
|
|
||||||
def tht_joint_buildups(problem: Problem) -> list[str]:
|
def tht_joint_buildups(problem: Problem,
|
||||||
|
shapes: dict | None = None) -> list[str]:
|
||||||
"""Solder coat of the net's populated STITCHING through-hole pads
|
"""Solder coat of the net's populated STITCHING through-hole pads
|
||||||
(ViaLink kind 'pad' with solder_filled): one pad-diameter disc on
|
(ViaLink kind 'pad' with solder_filled), on the pad's SOLDER side
|
||||||
the pad's SOLDER side (the protrusion side, opposite the component;
|
(the protrusion side, opposite the component; the component-side
|
||||||
the component-side face stays bare). The exact pad shape is unknown
|
face stays bare). `shapes` maps (x, y) to the exact pad polygons
|
||||||
for non-contact pads, and the coat intersects the modeled copper at
|
(fetched from KiCad); pads without one fall back to a pad-diameter
|
||||||
raster time anyway. Contact pads are skipped:
|
disc. Contact pads are skipped: contact_solder_buildups already
|
||||||
contact_solder_buildups already coats them with the exact pad
|
coats them with the exact pad shape. Returns the affected layer
|
||||||
shape. Returns the affected layer names."""
|
names."""
|
||||||
included = {l.layer_name for l in problem.layers}
|
included = {l.layer_name for l in problem.layers}
|
||||||
contacts = {e.center for e in problem.electrodes1 + problem.electrodes2
|
contacts = {e.center for e in problem.electrodes1 + problem.electrodes2
|
||||||
if e.drill_nm > 0 and e.center is not None}
|
if e.drill_nm > 0 and e.center is not None}
|
||||||
touched = []
|
touched = []
|
||||||
for v in problem.vias:
|
for v in problem.vias:
|
||||||
if v.kind != "pad" or not v.solder_filled \
|
if v.kind != "pad" or not v.solder_filled \
|
||||||
or v.pad_nm <= v.drill_nm or (v.x, v.y) in contacts \
|
or (v.x, v.y) in contacts \
|
||||||
or v.protrusion_side not in included:
|
or v.protrusion_side not in included:
|
||||||
continue
|
continue
|
||||||
disc = _disc_polygon(v.x, v.y, v.pad_nm / 2.0)
|
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(
|
problem.buildups.append(
|
||||||
SurfaceBuildup(layer_name=v.protrusion_side, polygons=[disc]))
|
SurfaceBuildup(layer_name=v.protrusion_side,
|
||||||
|
polygons=list(polys)))
|
||||||
touched.append(v.protrusion_side)
|
touched.append(v.protrusion_side)
|
||||||
return sorted(set(touched))
|
return sorted(set(touched))
|
||||||
|
|
||||||
@@ -422,6 +450,7 @@ def _electrode_to_json(e: Electrode) -> dict:
|
|||||||
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,
|
"drill_nm": e.drill_nm,
|
||||||
"pad_nm": e.pad_nm,
|
"pad_nm": e.pad_nm,
|
||||||
|
"pad_min_nm": e.pad_min_nm,
|
||||||
"center": (None if e.center is None else list(e.center)),
|
"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)),
|
"barrel_z": (None if e.barrel_z is None else list(e.barrel_z)),
|
||||||
"solder": e.solder,
|
"solder": e.solder,
|
||||||
@@ -438,6 +467,7 @@ def _electrode_from_json(d: dict) -> Electrode:
|
|||||||
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)),
|
drill_nm=int(d.get("drill_nm", 0)),
|
||||||
pad_nm=int(d.get("pad_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
|
center=(None if d.get("center") is None
|
||||||
else (int(d["center"][0]), int(d["center"][1]))),
|
else (int(d["center"][0]), int(d["center"][1]))),
|
||||||
barrel_z=(None if d.get("barrel_z") is None
|
barrel_z=(None if d.get("barrel_z") is None
|
||||||
@@ -484,6 +514,8 @@ def problem_to_json(p: Problem) -> dict:
|
|||||||
"cap_plating_nm": p.cap_plating_nm,
|
"cap_plating_nm": p.cap_plating_nm,
|
||||||
"cap_max_drill_nm": p.cap_max_drill_nm,
|
"cap_max_drill_nm": p.cap_max_drill_nm,
|
||||||
"tht_protrusion_nm": p.tht_protrusion_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -531,6 +563,7 @@ def problem_from_json(d: dict) -> Problem:
|
|||||||
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
|
# older dumps: every THT pad counted as solder-filled
|
||||||
solder_filled=bool(vd.get(
|
solder_filled=bool(vd.get(
|
||||||
"solder_filled", vd.get("kind", "via") == "pad")),
|
"solder_filled", vd.get("kind", "via") == "pad")),
|
||||||
@@ -563,6 +596,8 @@ def problem_from_json(d: dict) -> Problem:
|
|||||||
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)),
|
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_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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -270,11 +270,14 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
|
|||||||
y = (e.rect.y0 + e.rect.y1) / 2.0
|
y = (e.rect.y0 + e.rect.y1) / 2.0
|
||||||
seen.add((int(x), int(y)))
|
seen.add((int(x), int(y)))
|
||||||
if e.solder and e.protrusion_side:
|
if e.solder and e.protrusion_side:
|
||||||
jobs.append((x, y, e.drill_nm, e.pad_nm, 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:
|
for v in problem.vias:
|
||||||
if v.kind == "pad" and v.solder_filled and v.protrusion_side \
|
if v.kind == "pad" and v.solder_filled and v.protrusion_side \
|
||||||
and (v.x, v.y) not in seen:
|
and (v.x, v.y) not in seen:
|
||||||
jobs.append((v.x, v.y, v.drill_nm, v.pad_nm, v.protrusion_side))
|
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:
|
for x, y, drill_nm, pad_nm, side in jobs:
|
||||||
li = index.get(side)
|
li = index.get(side)
|
||||||
@@ -333,16 +336,21 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
|
|||||||
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. The fab caps only small vias: drills above cap_max_drill_nm
|
hole. The fab caps only small vias: drills above cap_max_drill_nm
|
||||||
stay open even with vias_capped. Fully swallowed cells leave the
|
stay open even with vias_capped. THT pad mouths: populated pads are
|
||||||
mask; partially covered cells keep a thickness-scaled sheet
|
solder-filled - the mouth copper stays and stands in for the plug
|
||||||
conductance via stack.thick_scale."""
|
(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))
|
||||||
@@ -362,12 +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:
|
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
|
||||||
|
|||||||
@@ -178,13 +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
|
||||||
# populated THT pads carry a soldered component lead: the hole
|
# populated THT pads carry a soldered component lead: lead
|
||||||
# is solder-filled, the core conducts in parallel with the
|
# cylinder (drill minus the fab clearance) + solder annulus
|
||||||
# plating (DNP pads and vias stay plating-only)
|
# in parallel with the plating (DNP pads and vias stay
|
||||||
|
# plating-only)
|
||||||
r_dc = via.barrel_resistance(
|
r_dc = via.barrel_resistance(
|
||||||
length, problem.rho_ohm_m, problem.plating_nm,
|
length, problem.rho_ohm_m, problem.plating_nm,
|
||||||
solder_rho_ohm_m=(problem.solder_rho_ohm_m
|
solder_rho_ohm_m=(problem.solder_rho_ohm_m
|
||||||
if via.solder_filled else None))
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -211,8 +211,8 @@ def _pad_link(populated=True):
|
|||||||
|
|
||||||
def test_stitching_pad_joint():
|
def test_stitching_pad_joint():
|
||||||
"""A populated THT pad on the net (not a contact) gets the full
|
"""A populated THT pad on the net (not a contact) gets the full
|
||||||
joint: coat discs on the outer layers and a cone on its protrusion
|
joint: solder-side coat, cone, and a conducting (plugged) mouth;
|
||||||
side; a DNP pad gets neither."""
|
a DNP pad gets an open hole and nothing else."""
|
||||||
def prob(populated=True):
|
def prob(populated=True):
|
||||||
p = make_problem([(PLATE20, [])],
|
p = make_problem([(PLATE20, [])],
|
||||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||||
@@ -225,11 +225,13 @@ def test_stitching_pad_joint():
|
|||||||
r_joint, stack = _solve(p, 0.1)
|
r_joint, stack = _solve(p, 0.1)
|
||||||
assert stack.thick_scale is not None and stack.thick_scale.max() > 3.0
|
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.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)
|
q = prob(populated=False)
|
||||||
assert tht_joint_buildups(q) == []
|
assert tht_joint_buildups(q) == []
|
||||||
r_bare, s2 = _solve(q, 0.1)
|
r_bare, s2 = _solve(q, 0.1)
|
||||||
assert s2.thick_scale is None and s2.buildup is None
|
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
|
assert r_joint.R_ohm < r_bare.R_ohm
|
||||||
|
|
||||||
|
|
||||||
@@ -250,6 +252,54 @@ def test_cone_not_doubled_at_contact():
|
|||||||
assert stack.thick_scale.max() == pytest.approx(wall, rel=1e-12)
|
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():
|
def test_vialink_solder_json():
|
||||||
p = make_problem([(PLATE20, [])],
|
p = make_problem([(PLATE20, [])],
|
||||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||||
|
|||||||
@@ -50,9 +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)
|
||||||
# a bare 'pad' barrel (solder_filled defaults False) is plating-only,
|
ref = _two_layer(kind="pad")
|
||||||
# exactly like the via's
|
# populated pads skip rings and mouths; kill the lead + solder core
|
||||||
r_ref, _ = _solve(_two_layer(kind="pad"), 0.1)
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user