Model slotted (oblong) THT holes as stadiums, not circles
The drill's y dimension was discarded (padstack.drill.diameter.x only), so a milled slot became a round hole of its x size - contact rings, drill mouths and lead cones painted circles larger than the oblong pad itself, and the cone was skipped outright (pad_min <= drill). Slots now keep their true stadium shape, rotated with the pad (KiCad CCW, y down): Electrode/ViaLink carry the end-cap offset vector, drill_nm becomes the slot WIDTH, and a shared slot_distance() reduces to the plain radius for round holes. The barrel wall ring, mouth coverage, cone taper and the solver's attachment search all follow the slot; barrel_resistance uses the stadium perimeter and bore area. The stitching-coat fallback becomes a capsule along the slot (inscribed disc when the axis is unknown) instead of a largest-dimension disc. Slot fields round-trip through the JSON dumps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,7 +112,11 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
worth far more than the foil, so this is conservative. 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
|
||||
cone tapers to the pad's short dimension). **Slotted (oval) holes**
|
||||
keep their true stadium shape: the barrel wall, drill mouth, contact
|
||||
ring and lead cone all follow the slot (rotated with the pad), and
|
||||
the barrel conducts over the slot's real perimeter/bore area — not a
|
||||
circle of the slot's long dimension. 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
|
||||
@@ -159,7 +163,8 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
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
|
||||
R = ρ/(π·t)·acosh(d/2a) for two circular contacts on a sheet).
|
||||
Slotted holes inject along the stadium-shaped slot wall. 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**
|
||||
|
||||
@@ -6,6 +6,7 @@ KiCad to extract without the dialog (all layers of the net, defaults).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -139,11 +140,33 @@ def _convert_poly(poly_with_holes) -> Polygon:
|
||||
holes=[ring(h) for h in poly_with_holes.holes])
|
||||
|
||||
|
||||
def _pad_drill_nm(pad_or_via) -> int:
|
||||
def _drill_info(pad_or_via) -> tuple[int, int, int]:
|
||||
"""(width_nm, slot_dx_nm, slot_dy_nm) of a padstack drill. Round
|
||||
holes: (diameter, 0, 0). Slotted (oblong) holes: width is the
|
||||
NARROW dimension, (slot_dx, slot_dy) the board-frame offset from
|
||||
the drill center to each end-cap center of the slot. The slot
|
||||
follows the pad rotation (KiCad rotates CCW with y down:
|
||||
x' = x cos + y sin, y' = y cos - x sin)."""
|
||||
try:
|
||||
return int(pad_or_via.padstack.drill.diameter.x)
|
||||
d = pad_or_via.padstack.drill.diameter
|
||||
dx, dy = int(d.x), int(d.y)
|
||||
except Exception:
|
||||
return 0
|
||||
return 0, 0, 0
|
||||
if dx <= 0 or dy <= 0 or dx == dy:
|
||||
return max(dx, 0), 0, 0
|
||||
half = (max(dx, dy) - min(dx, dy)) / 2.0
|
||||
try:
|
||||
th = math.radians(pad_or_via.padstack.angle.degrees)
|
||||
except Exception:
|
||||
th = 0.0
|
||||
ux, uy = (1.0, 0.0) if dx > dy else (0.0, 1.0)
|
||||
return (min(dx, dy),
|
||||
int(round(half * (ux * math.cos(th) + uy * math.sin(th)))),
|
||||
int(round(half * (uy * math.cos(th) - ux * math.sin(th)))))
|
||||
|
||||
|
||||
def _pad_drill_nm(pad_or_via) -> int:
|
||||
return _drill_info(pad_or_via)[0]
|
||||
|
||||
|
||||
def _pad_default_contact(pad: Pad) -> str:
|
||||
@@ -250,7 +273,7 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
|
||||
if box is None:
|
||||
raise SelectionError(f"Could not get the bounding box of {label}.")
|
||||
rect = _box2_to_rect(box, "pad")
|
||||
drill = _pad_drill_nm(pad)
|
||||
drill, slot_dx, slot_dy = _drill_info(pad)
|
||||
return Electrode(rect=rect, contact=contact,
|
||||
polygons=_pad_polygons(board, pad, contact), label=label,
|
||||
# through-hole pad: current enters at the soldered
|
||||
@@ -258,6 +281,7 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
|
||||
# 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),
|
||||
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
|
||||
center=(pad.position.x, pad.position.y),
|
||||
solder=drill > 0,
|
||||
protrusion_side=(_tht_protrusion_side(pad, pad_map or {})
|
||||
@@ -547,12 +571,14 @@ def gather_barrels(board: Board, net_name: str,
|
||||
populated = not fp.attributes.do_not_populate
|
||||
except Exception:
|
||||
pass
|
||||
drill, slot_dx, slot_dy = _drill_info(pad)
|
||||
barrels.append(ViaLink(
|
||||
x=pad.position.x, y=pad.position.y,
|
||||
drill_nm=_pad_drill_nm(pad), z_top_nm=-1,
|
||||
drill_nm=drill, 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),
|
||||
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
|
||||
solder_filled=populated,
|
||||
protrusion_side=(_tht_protrusion_side(pad, pad_map,
|
||||
quiet=True)
|
||||
|
||||
@@ -113,11 +113,16 @@ class Electrode:
|
||||
contact: str = "all"
|
||||
polygons: list[Polygon] | None = None
|
||||
label: str = "rect"
|
||||
drill_nm: int = 0 # >0: barrel contact
|
||||
drill_nm: int = 0 # >0: barrel contact (slotted
|
||||
# holes: the slot WIDTH)
|
||||
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
|
||||
slot_dx_nm: int = 0 # slotted (oblong) hole: offset
|
||||
slot_dy_nm: int = 0 # from `center` to each end-cap
|
||||
# center of the slot, board
|
||||
# frame; (0, 0) = round drill
|
||||
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)
|
||||
@@ -134,7 +139,7 @@ class ViaLink:
|
||||
layers whose z lies within [z_top_nm, z_bot_nm]."""
|
||||
x: int
|
||||
y: int
|
||||
drill_nm: int
|
||||
drill_nm: int # slotted holes: the slot WIDTH
|
||||
z_top_nm: int
|
||||
z_bot_nm: int
|
||||
kind: str = "via" # "via" | "pad"
|
||||
@@ -144,6 +149,10 @@ class ViaLink:
|
||||
pad_min_nm: int = 0 # smallest pad dimension (bounds
|
||||
# the lead-cone taper on oblong
|
||||
# pads); 0 = same as pad_nm
|
||||
slot_dx_nm: int = 0 # slotted (oblong) hole: offset
|
||||
slot_dy_nm: int = 0 # from (x, y) to each end-cap
|
||||
# center of the slot, board
|
||||
# frame; (0, 0) = round drill
|
||||
solder_filled: bool = False # populated THT pad: the hole
|
||||
# holds lead + solder (in parallel
|
||||
# with the plating); False for
|
||||
@@ -162,19 +171,22 @@ class ViaLink:
|
||||
lead_nm: float = 0,
|
||||
lead_rho_ohm_m: float | None = None) -> float:
|
||||
"""Barrel segment resistance over length_nm: thin-wall annulus of
|
||||
plating around the drill. With solder_rho_ohm_m the hole holds a
|
||||
plating around the drill (slotted holes: thin wall around the
|
||||
stadium-shaped slot). With solder_rho_ohm_m the hole holds a
|
||||
soldered THT joint: the component lead (a cylinder of lead_nm
|
||||
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]
|
||||
remaining bore conduct in parallel with the plating."""
|
||||
ext = 2.0 * math.hypot(self.slot_dx_nm, self.slot_dy_nm) * 1e-9
|
||||
wall = math.pi * (self.drill_nm * 1e-9) + 2.0 * ext
|
||||
ga = wall * (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
|
||||
ga += (math.pi * r_core * r_core + 2.0 * r_core * ext
|
||||
- math.pi * r_lead * r_lead) / solder_rho_ohm_m
|
||||
return (length_nm * 1e-9) / ga
|
||||
|
||||
|
||||
@@ -261,6 +273,19 @@ def contact_solder_buildups(problem: Problem) -> list[str]:
|
||||
return sorted(set(touched))
|
||||
|
||||
|
||||
def slot_distance(xg, yg, dx_nm: int, dy_nm: int):
|
||||
"""Distance from points (xg, yg) (numpy-broadcastable, coordinates
|
||||
RELATIVE to the hole center) to a slotted hole's axis - the segment
|
||||
(-dx, -dy)..(+dx, +dy) between the end-cap centers. The slot wall
|
||||
sits at distance width/2. Round drills (dx = dy = 0) reduce to the
|
||||
plain radius, so callers need no special case."""
|
||||
if dx_nm == 0 and dy_nm == 0:
|
||||
return np.hypot(xg, yg)
|
||||
l2 = float(dx_nm) * dx_nm + float(dy_nm) * dy_nm
|
||||
t = np.clip((xg * dx_nm + yg * dy_nm) / l2, -1.0, 1.0)
|
||||
return np.hypot(xg - t * dx_nm, yg - t * dy_nm)
|
||||
|
||||
|
||||
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)
|
||||
@@ -269,6 +294,19 @@ def _disc_polygon(x_nm: float, y_nm: float, r_nm: float,
|
||||
axis=1)).astype(np.int64))
|
||||
|
||||
|
||||
def _capsule_polygon(x_nm: float, y_nm: float, dx_nm: float, dy_nm: float,
|
||||
r_nm: float, n: int = 16) -> Polygon:
|
||||
"""Stadium: two half-circle caps of radius r_nm centered at
|
||||
(x +- dx, y +- dy), joined by straight flanks."""
|
||||
a0 = math.atan2(dy_nm, dx_nm)
|
||||
th = np.linspace(-0.5 * math.pi, 0.5 * math.pi, n) + a0
|
||||
cap1 = np.stack([x_nm + dx_nm + r_nm * np.cos(th),
|
||||
y_nm + dy_nm + r_nm * np.sin(th)], axis=1)
|
||||
cap2 = np.stack([x_nm - dx_nm + r_nm * np.cos(th + math.pi),
|
||||
y_nm - dy_nm + r_nm * np.sin(th + math.pi)], axis=1)
|
||||
return Polygon(outline=np.round(np.vstack([cap1, cap2])).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
|
||||
@@ -292,7 +330,16 @@ def tht_joint_buildups(problem: Problem,
|
||||
if polys is None:
|
||||
if v.pad_nm <= v.drill_nm:
|
||||
continue
|
||||
polys = [_disc_polygon(v.x, v.y, v.pad_nm / 2.0)]
|
||||
# oblong pads: never coat past the pad - a capsule along the
|
||||
# slot axis, or the inscribed disc when the axis is unknown
|
||||
w = v.pad_min_nm or v.pad_nm
|
||||
hl = math.hypot(v.slot_dx_nm, v.slot_dy_nm)
|
||||
if hl > 0.0 and v.pad_nm > w:
|
||||
s = (v.pad_nm - w) / 2.0 / hl
|
||||
polys = [_capsule_polygon(v.x, v.y, v.slot_dx_nm * s,
|
||||
v.slot_dy_nm * s, w / 2.0)]
|
||||
else:
|
||||
polys = [_disc_polygon(v.x, v.y, w / 2.0)]
|
||||
problem.buildups.append(
|
||||
SurfaceBuildup(layer_name=v.protrusion_side,
|
||||
polygons=list(polys)))
|
||||
@@ -451,6 +498,8 @@ def _electrode_to_json(e: Electrode) -> dict:
|
||||
"drill_nm": e.drill_nm,
|
||||
"pad_nm": e.pad_nm,
|
||||
"pad_min_nm": e.pad_min_nm,
|
||||
"slot_dx_nm": e.slot_dx_nm,
|
||||
"slot_dy_nm": e.slot_dy_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,
|
||||
@@ -468,6 +517,8 @@ def _electrode_from_json(d: dict) -> Electrode:
|
||||
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)),
|
||||
slot_dx_nm=int(d.get("slot_dx_nm", 0)),
|
||||
slot_dy_nm=int(d.get("slot_dy_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
|
||||
@@ -564,6 +615,8 @@ def problem_from_json(d: dict) -> Problem:
|
||||
kind=vd.get("kind", "via"),
|
||||
pad_nm=int(vd.get("pad_nm", 0)),
|
||||
pad_min_nm=int(vd.get("pad_min_nm", 0)),
|
||||
slot_dx_nm=int(vd.get("slot_dx_nm", 0)),
|
||||
slot_dy_nm=int(vd.get("slot_dy_nm", 0)),
|
||||
# older dumps: every THT pad counted as solder-filled
|
||||
solder_filled=bool(vd.get(
|
||||
"solder_filled", vd.get("kind", "via") == "pad")),
|
||||
|
||||
+28
-22
@@ -23,7 +23,7 @@ from scipy import ndimage
|
||||
|
||||
from . import config
|
||||
from .errors import ElectrodeError, GridSizeError
|
||||
from .geometry import Electrode, Problem, Rect
|
||||
from .geometry import Electrode, Problem, Rect, slot_distance
|
||||
|
||||
# 4-connectivity: matches the in-plane 5-point stencil of the solver
|
||||
_STRUCT4 = ndimage.generate_binary_structure(2, 1)
|
||||
@@ -270,29 +270,31 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
|
||||
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)
|
||||
# oblong pads: taper from the (slot) wall to the inscribed
|
||||
# dimension (conservative)
|
||||
jobs.append((x, y, e.drill_nm, e.pad_min_nm or e.pad_nm,
|
||||
e.protrusion_side))
|
||||
e.protrusion_side, e.slot_dx_nm, e.slot_dy_nm))
|
||||
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))
|
||||
v.protrusion_side, v.slot_dx_nm, v.slot_dy_nm))
|
||||
|
||||
for x, y, drill_nm, pad_nm, side in jobs:
|
||||
for x, y, drill_nm, pad_nm, side, sdx, sdy 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)
|
||||
ex, ey = rb + abs(sdx), rb + abs(sdy)
|
||||
j0 = max(0, math.floor((x - ex - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((y - ey - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((y + ey - 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)
|
||||
r = slot_distance(xs[None, :], ys[:, None], sdx, sdy)
|
||||
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
|
||||
@@ -353,18 +355,20 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
|
||||
if via.kind == "pad" and via.solder_filled:
|
||||
continue
|
||||
r = via.drill_nm / 2.0
|
||||
j0 = max(0, math.floor((via.x - r - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((via.x + r - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((via.y - r - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((via.y + r - stack.y0_nm) / h) + 1)
|
||||
ex, ey = r + abs(via.slot_dx_nm), r + abs(via.slot_dy_nm)
|
||||
j0 = max(0, math.floor((via.x - ex - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((via.x + ex - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((via.y - ey - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((via.y + ey - stack.y0_nm) / h) + 1)
|
||||
if i0 >= i1 or j0 >= j1:
|
||||
continue
|
||||
xs = stack.x0_nm + (np.arange(j0, j1)[:, None] + sub[None, :]) * h \
|
||||
- via.x
|
||||
ys = stack.y0_nm + (np.arange(i0, i1)[:, None] + sub[None, :]) * h \
|
||||
- via.y
|
||||
cov = ((ys[:, None, :, None] ** 2 + xs[None, :, None, :] ** 2)
|
||||
<= r * r).mean(axis=(2, 3))
|
||||
cov = (slot_distance(xs[None, :, None, :], ys[:, None, :, None],
|
||||
via.slot_dx_nm, via.slot_dy_nm)
|
||||
<= r).mean(axis=(2, 3))
|
||||
if not (cov > 0).any():
|
||||
continue # mouth far smaller than h
|
||||
if stack.thick_scale is None:
|
||||
@@ -477,7 +481,8 @@ def _electrode_cells2d(stack: RasterStack, e: Electrode) -> np.ndarray:
|
||||
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),
|
||||
at the drill wall (cell centers within one cell of radius drill/2;
|
||||
slotted holes: within one cell of the stadium-shaped slot wall),
|
||||
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) -
|
||||
@@ -491,16 +496,17 @@ def _barrel_ring2d(stack: RasterStack, e: Electrode,
|
||||
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
|
||||
ex, ey = rw + abs(e.slot_dx_nm), rw + abs(e.slot_dy_nm)
|
||||
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)
|
||||
j0 = max(0, math.floor((x - ex - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((y - ey - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((y + ey - 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)
|
||||
d = slot_distance(xs[None, :], ys[:, None], e.slot_dx_nm, e.slot_dy_nm)
|
||||
m = mask2d[i0:i1, j0:j1]
|
||||
ring = m & (np.abs(d - r) <= h)
|
||||
if not ring.any():
|
||||
|
||||
@@ -47,7 +47,7 @@ from scipy.sparse import linalg as sla
|
||||
|
||||
from . import config, skin
|
||||
from .errors import ConnectivityError, ElectrodeError, SolverError
|
||||
from .geometry import Problem
|
||||
from .geometry import Problem, slot_distance
|
||||
from .raster import RasterStack, electrodes_touch
|
||||
|
||||
|
||||
@@ -156,12 +156,14 @@ def _barrel_links(stack: RasterStack, problem: Problem
|
||||
span = [li for li, layer in enumerate(problem.layers)
|
||||
if via.spans(layer.z_nm)]
|
||||
r_nm = max(via.pad_nm, via.drill_nm + 300_000) / 2.0 + h
|
||||
win = int(r_nm // h) + 1
|
||||
i0, i1 = max(0, i - win), min(ny, i + win + 1)
|
||||
j0, j1 = max(0, j - win), min(nx, j + win + 1)
|
||||
win_j = int((r_nm + abs(via.slot_dx_nm)) // h) + 1
|
||||
win_i = int((r_nm + abs(via.slot_dy_nm)) // h) + 1
|
||||
i0, i1 = max(0, i - win_i), min(ny, i + win_i + 1)
|
||||
j0, j1 = max(0, j - win_j), min(nx, j + win_j + 1)
|
||||
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - via.x
|
||||
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - via.y
|
||||
d2 = ys[:, None] ** 2 + xs[None, :] ** 2
|
||||
d2 = slot_distance(xs[None, :], ys[:, None],
|
||||
via.slot_dx_nm, via.slot_dy_nm) ** 2
|
||||
d2 = np.where(d2 <= r_nm * r_nm, d2, np.inf)
|
||||
present = [] # (layer, i, j) per layer
|
||||
for li in span:
|
||||
|
||||
@@ -318,6 +318,162 @@ def test_vialink_solder_json():
|
||||
assert problem_from_json(d).vias[0].solder_filled is False
|
||||
|
||||
|
||||
# --- slotted (oblong) holes --------------------------------------------------
|
||||
# The lead/barrel of a slotted hole is a stadium, not a circle: modeling
|
||||
# it as a circle of the slot's LONG dimension painted contact rings,
|
||||
# mouths and cones bigger than the oblong pad itself.
|
||||
|
||||
def _slot_dist_mm(stack, ii, jj, x_mm, y_mm, dx_nm):
|
||||
"""Distance of cells (ii, jj) to a slot axis (+-dx_nm along x)."""
|
||||
xs = stack.x0_nm + (jj + 0.5) * stack.h_nm - x_mm * NM
|
||||
ys = stack.y0_nm + (ii + 0.5) * stack.h_nm - y_mm * NM
|
||||
t = np.clip(xs / dx_nm, -1.0, 1.0)
|
||||
return np.hypot(xs - t * dx_nm, ys), xs, ys
|
||||
|
||||
|
||||
def test_slot_ring_hugs_slot_wall():
|
||||
"""The contact ring of a slotted THT pad follows the stadium-shaped
|
||||
slot wall: it reaches around the end caps but never pokes past the
|
||||
oblong pad's short side (the old circular model of the slot's long
|
||||
dimension put cells at radius 1.5 mm straight above/below)."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6) # slot 3.0 x 1.0 mm
|
||||
e.pad_min_nm = int(1.6 * NM) # pad 3.6 x 1.6 mm
|
||||
e.slot_dx_nm = 1 * NM
|
||||
p.electrodes1 = [e]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
e1, _ = raster.electrode_masks(stack, p)
|
||||
ii, jj = np.nonzero(e1[0])
|
||||
d, xs, ys = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM)
|
||||
assert len(ii) >= 16
|
||||
assert (np.abs(d - 0.5 * NM) <= stack.h_nm + 1).all()
|
||||
assert xs.max() > 1.2 * NM and xs.min() < -1.2 * NM # rings the caps
|
||||
assert np.abs(ys).max() < 0.8 * NM # stays inside the 1.6 mm side
|
||||
|
||||
|
||||
def test_slot_mouth_is_stadium():
|
||||
"""A DNP slotted pad cuts a stadium-shaped hole: open along the whole
|
||||
slot, copper kept just past the slot width and the end caps."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
v = _pad_link(populated=False)
|
||||
v.slot_dx_nm = 1 * NM # slot 3.0 x 1.0 mm along x
|
||||
p.vias = [v]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
m = stack.masks[0]
|
||||
assert not m[stack.cell_of(10 * NM, 10 * NM)]
|
||||
assert not m[stack.cell_of(int(10.9 * NM), 10 * NM)] # slot end: open
|
||||
assert not m[stack.cell_of(int(9.1 * NM), 10 * NM)]
|
||||
assert m[stack.cell_of(10 * NM, int(10.8 * NM))] # past the width: copper
|
||||
assert m[stack.cell_of(10 * NM, int(9.2 * NM))]
|
||||
assert m[stack.cell_of(int(11.8 * NM), 10 * NM)] # past the cap: copper
|
||||
|
||||
|
||||
def test_slot_cone_follows_slot():
|
||||
"""The lead cone of a slotted oblong pad tapers from the slot WALL
|
||||
to the pad's short dimension. The old circular-drill model (diameter
|
||||
= the slot's long dimension) skipped the cone entirely
|
||||
(pad_min <= drill) and, for the mouth, ate the pad's short side."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6, solder=True)
|
||||
e.pad_min_nm = int(1.6 * NM)
|
||||
e.slot_dx_nm = 1 * NM
|
||||
e.protrusion_side = "F.Cu"
|
||||
p.electrodes1 = [e]
|
||||
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, _, _ = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM)
|
||||
ra, rb, H = 0.5 * NM, 0.8 * 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)
|
||||
assert stack.thick_scale[0].max() > 3.0
|
||||
|
||||
|
||||
def test_slot_barrel_resistance():
|
||||
"""Slotted barrel: plating wall = stadium perimeter, solder core =
|
||||
stadium bore area (both reduce to the circle for dx = dy = 0)."""
|
||||
v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1,
|
||||
slot_dx_nm=800_000, slot_dy_nm=600_000) # ext = 2 mm
|
||||
rho, sn = 1.68e-8, 1.32e-7
|
||||
ga = (math.pi * 1e-3 + 2 * 2e-3) * 18e-6 / rho
|
||||
r_plain = v.barrel_resistance(1_600_000, rho, 18_000)
|
||||
assert r_plain == pytest.approx(1.6e-3 / ga, rel=1e-12)
|
||||
rc = 0.5e-3 - 18e-6
|
||||
ga += (math.pi * rc * rc + 2 * rc * 2e-3) / sn
|
||||
r_fill = v.barrel_resistance(1_600_000, rho, 18_000, solder_rho_ohm_m=sn)
|
||||
assert r_fill == pytest.approx(1.6e-3 / ga, rel=1e-12)
|
||||
|
||||
|
||||
def test_slot_coat_fallback_within_pad():
|
||||
"""Without an exact pad shape the stitching coat falls back to a
|
||||
capsule along the slot (width = pad_min), not the old pad_nm disc
|
||||
that stuck out past an oblong pad's short side."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
v = _pad_link() # pad_nm = 2.4 mm
|
||||
v.pad_min_nm = 1_600_000
|
||||
v.slot_dx_nm = 1 * NM
|
||||
p.vias = [v]
|
||||
assert tht_joint_buildups(p) == ["F.Cu"]
|
||||
pts = p.buildups[0].polygons[0].outline.astype(float)
|
||||
xs, ys = pts[:, 0] - 10 * NM, pts[:, 1] - 10 * NM
|
||||
t = np.clip(xs / (0.4 * NM), -1.0, 1.0) # caps at +-(2.4-1.6)/2 mm
|
||||
d = np.hypot(xs - t * 0.4 * NM, ys)
|
||||
assert np.allclose(d, 0.8 * NM, atol=2)
|
||||
assert np.abs(xs).max() <= 1.2 * NM + 2 # never past pad_nm / 2
|
||||
assert np.abs(ys).max() <= 0.8 * NM + 2 # never past pad_min / 2
|
||||
|
||||
|
||||
def test_slot_json_roundtrip():
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6)
|
||||
e.slot_dx_nm, e.slot_dy_nm = 700_000, -700_000
|
||||
p.electrodes1 = [e]
|
||||
v = _pad_link()
|
||||
v.slot_dx_nm = 1 * NM
|
||||
p.vias = [v]
|
||||
q = problem_from_json(problem_to_json(p))
|
||||
assert (q.electrodes1[0].slot_dx_nm, q.electrodes1[0].slot_dy_nm) \
|
||||
== (700_000, -700_000)
|
||||
assert (q.vias[0].slot_dx_nm, q.vias[0].slot_dy_nm) == (1 * NM, 0)
|
||||
# legacy dumps: round drills
|
||||
d = problem_to_json(p)
|
||||
for vd in d["vias"]:
|
||||
del vd["slot_dx_nm"], vd["slot_dy_nm"]
|
||||
assert problem_from_json(d).vias[0].slot_dx_nm == 0
|
||||
|
||||
|
||||
def test_drill_info_slot_rotation():
|
||||
"""_drill_info: slot axis from the drill x/y sizes, rotated with the
|
||||
pad (KiCad angles are CCW with y down: 90 deg sends +x to -y)."""
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
from fill_resistance.board_io import _drill_info
|
||||
|
||||
def pad(dx_mm, dy_mm, angle_deg):
|
||||
return NS(padstack=NS(
|
||||
drill=NS(diameter=NS(x=int(dx_mm * NM), y=int(dy_mm * NM))),
|
||||
angle=NS(degrees=angle_deg)))
|
||||
|
||||
assert _drill_info(pad(1.0, 1.0, 0.0)) == (1 * NM, 0, 0) # round
|
||||
assert _drill_info(pad(3.0, 1.0, 0.0)) == (1 * NM, 1 * NM, 0)
|
||||
assert _drill_info(pad(1.0, 3.0, 0.0)) == (1 * NM, 0, 1 * NM)
|
||||
w, dx, dy = _drill_info(pad(3.0, 1.0, 90.0))
|
||||
assert (w, dx, dy) == (1 * NM, 0, -1 * NM)
|
||||
w, dx, dy = _drill_info(pad(3.0, 1.0, 45.0))
|
||||
assert w == 1 * NM
|
||||
assert dx == pytest.approx(1 * NM / math.sqrt(2), abs=2)
|
||||
assert dy == pytest.approx(-1 * NM / math.sqrt(2), abs=2)
|
||||
|
||||
|
||||
def test_barrel_electrode_json_roundtrip(tmp_path):
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
|
||||
Reference in New Issue
Block a user