Model sub-resolution traces as exact 1D resistor chains
Tracks are now first-class Problem objects (TrackSeg: centerline + width, dump schema v5), so the wide/narrow decision replays at raster time: traces at least TRACK_1D_FACTOR (3) cells wide rasterize from their outline as before; narrower ones mark the cells their centerline crosses as copper and connect them with explicit conductance links carrying the trace's TRUE arc length per link - no staircase inflation for diagonals or arcs, and no discretization error in the trace R, at any grid size. Links across cells already joined by pour faces are skipped (union, not sum); chain-only cells get no sheet faces (their copper is narrower than a cell). Electrodes, via barrels, connectivity restriction and the skin-effect scaling all work on chain cells unchanged. This removes the need to shrink the cell size for thin traces: a 0.2 mm bridge at 500 um cells now matches its finely-rasterized ground truth within a few percent (tested), including diagonal and arc traces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,12 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
fill on fewer than two layers carry no current and are reported.
|
||||
- The net's **traces** (straight and arc tracks, exact outline polygons
|
||||
incl. rounded ends) conduct together with the fills — dialog checkbox,
|
||||
on by default (`INCLUDE_TRACKS`). Pad copper other than the selected
|
||||
on by default (`INCLUDE_TRACKS`). Traces narrower than
|
||||
`TRACK_1D_FACTOR` (3) grid cells are modeled as exact **1D resistor
|
||||
chains** along their centerline — true arc length per link, so their
|
||||
series resistance carries no discretization error and no cell-size
|
||||
tuning is needed for thin traces. 1D-modeled traces show potential and
|
||||
power density but no |J| field. Pad copper other than the selected
|
||||
contacts is still **not** part of the conductor model.
|
||||
- **Solder buildup on mask openings** (dialog checkbox, **off by
|
||||
default**; `INCLUDE_MASK_BUILDUP`): zones drawn on `F.Mask`/`B.Mask`
|
||||
|
||||
+34
-18
@@ -17,11 +17,12 @@ from kipy.proto.board.board_types_pb2 import ZoneType
|
||||
from kipy.util.board_layer import (canonical_name, is_copper_layer,
|
||||
layer_from_canonical_name)
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import config
|
||||
from .errors import ApiVersionError, CandidateError, SelectionError
|
||||
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
|
||||
SurfaceBuildup, ViaLink, arc_band_ring, capsule_ring,
|
||||
linearize_ring)
|
||||
SurfaceBuildup, TrackSeg, ViaLink, linearize_ring)
|
||||
|
||||
MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"}
|
||||
|
||||
@@ -303,10 +304,11 @@ def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]:
|
||||
return fills
|
||||
|
||||
|
||||
def gather_net_tracks(board: Board) -> dict[str, dict[str, list[Polygon]]]:
|
||||
"""net -> layer -> track outline polygons (straight capsules and arc
|
||||
bands). Traces conduct together with the zone fills."""
|
||||
out: dict[str, dict[str, list[Polygon]]] = {}
|
||||
def gather_net_tracks(board: Board) -> dict[str, dict[str, list[TrackSeg]]]:
|
||||
"""net -> layer -> TrackSeg (centerline + width). Traces conduct
|
||||
together with the zone fills; the raster decides per run whether a
|
||||
trace is rasterized from its outline or becomes a 1D chain."""
|
||||
out: dict[str, dict[str, list[TrackSeg]]] = {}
|
||||
for t in board.get_tracks():
|
||||
if not is_copper_layer(t.layer):
|
||||
continue
|
||||
@@ -314,17 +316,29 @@ def gather_net_tracks(board: Board) -> dict[str, dict[str, list[Polygon]]]:
|
||||
if width <= 0:
|
||||
continue
|
||||
if isinstance(t, ArcTrack):
|
||||
ring = arc_band_ring((t.start.x, t.start.y), (t.mid.x, t.mid.y),
|
||||
(t.end.x, t.end.y), width, ARC_TOL_NM)
|
||||
pts = np.array([[t.start.x, t.start.y], [t.mid.x, t.mid.y],
|
||||
[t.end.x, t.end.y]], dtype=np.int64)
|
||||
else:
|
||||
ring = capsule_ring(t.start.x, t.start.y, t.end.x, t.end.y,
|
||||
width, ARC_TOL_NM)
|
||||
pts = np.array([[t.start.x, t.start.y], [t.end.x, t.end.y]],
|
||||
dtype=np.int64)
|
||||
net = t.net.name if t.net is not None else "<no net>"
|
||||
out.setdefault(net, {}).setdefault(
|
||||
canonical_name(t.layer), []).append(Polygon(outline=ring))
|
||||
layer = canonical_name(t.layer)
|
||||
out.setdefault(net, {}).setdefault(layer, []).append(
|
||||
TrackSeg(layer_name=layer, points=pts, width_nm=width))
|
||||
return out
|
||||
|
||||
|
||||
def tracks_as_polygons(tracks: dict) -> dict:
|
||||
"""net -> layer -> outline polygons of the tracks (for the bbox-based
|
||||
candidate detection; the Problem keeps the TrackSegs themselves)."""
|
||||
return {
|
||||
net: {layer: [Polygon(outline=seg.outline(ARC_TOL_NM))
|
||||
for seg in segs]
|
||||
for layer, segs in per_layer.items()}
|
||||
for net, per_layer in tracks.items()
|
||||
}
|
||||
|
||||
|
||||
def merge_copper(fills: dict, tracks: dict) -> dict:
|
||||
"""net -> layer -> fill + track polygons, for candidate detection
|
||||
and the dialog's layer lists (build_problem merges the same way)."""
|
||||
@@ -452,12 +466,13 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
||||
per_layer = fills.get(net, {})
|
||||
per_layer_tracks = (tracks or {}).get(net, {})
|
||||
layers = []
|
||||
segs: list[TrackSeg] = []
|
||||
for name in stackup.names: # keep stackup order
|
||||
if name not in layer_names:
|
||||
continue
|
||||
polys = (list(per_layer.get(name, []))
|
||||
+ list(per_layer_tracks.get(name, [])))
|
||||
if not polys:
|
||||
polys = list(per_layer.get(name, []))
|
||||
layer_segs = per_layer_tracks.get(name, [])
|
||||
if not polys and not layer_segs:
|
||||
print(f"note: net {net} has no copper on {name} - layer skipped")
|
||||
continue
|
||||
if config.COPPER_THICKNESS_UM is not None:
|
||||
@@ -466,6 +481,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
||||
t = stackup.thickness_nm[name]
|
||||
layers.append(LayerFill(layer_name=name, thickness_nm=t,
|
||||
z_nm=stackup.z_nm[name], polygons=polys))
|
||||
segs.extend(layer_segs)
|
||||
if not layers:
|
||||
raise CandidateError(
|
||||
f"Net {net} has no fill on any of the selected layers "
|
||||
@@ -477,10 +493,9 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
||||
SurfaceBuildup(layer_name=name, polygons=polys)
|
||||
for name, polys in (buildups or {}).items() if name in included
|
||||
]
|
||||
n_tracks = sum(len(per_layer_tracks.get(name, [])) for name in included)
|
||||
print(f"net {net}: {len(layers)} layer(s) "
|
||||
f"({', '.join(l.layer_name for l in layers)}), "
|
||||
f"{n_tracks} track(s), {len(vias)} via/pad barrel(s)"
|
||||
f"{len(segs)} track(s), {len(vias)} via/pad barrel(s)"
|
||||
+ (f", solder buildup on "
|
||||
f"{', '.join(b.layer_name for b in buildup_list)}"
|
||||
if buildup_list else ""))
|
||||
@@ -500,6 +515,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
||||
solder_rho_ohm_m=config.SOLDER_RHO_OHM_M,
|
||||
extra_cu_nm=int((extra_cu_um if extra_cu_um is not None
|
||||
else config.BUILDUP_EXTRA_CU_UM) * 1000),
|
||||
tracks=segs,
|
||||
)
|
||||
|
||||
|
||||
@@ -516,7 +532,7 @@ if __name__ == "__main__":
|
||||
refill(board)
|
||||
fills = gather_net_fills(board)
|
||||
tracks = gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
|
||||
copper = merge_copper(fills, tracks)
|
||||
copper = merge_copper(fills, tracks_as_polygons(tracks))
|
||||
nets = nets_overlapping(copper, es1, es2)
|
||||
if len(sys.argv) > 2:
|
||||
net = sys.argv[2]
|
||||
|
||||
@@ -42,6 +42,11 @@ BUILDUP_EXTRA_CU_UM = 0.0 # optional user-added copper (busbar/wire
|
||||
INCLUDE_TRACKS = True # the net's traces (straight + arc tracks)
|
||||
# conduct together with the zone fills;
|
||||
# dialog-toggleable
|
||||
TRACK_1D_FACTOR = 3.0 # traces narrower than this many grid cells
|
||||
# become exact 1D resistor chains along their
|
||||
# centerline instead of rasterized outlines
|
||||
# (no discretization error in the trace R;
|
||||
# 0 = always rasterize)
|
||||
LAYER_HINT: str | None = None # e.g. "F.Cu" to disambiguate candidate fills
|
||||
ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
|
||||
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
JSON_SCHEMA_VERSION = 4
|
||||
JSON_SCHEMA_VERSION = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -63,6 +63,34 @@ class SurfaceBuildup:
|
||||
polygons: list[Polygon]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackSeg:
|
||||
"""One trace segment: straight ((2, 2) points) or arc ((3, 2)
|
||||
start/mid/end points). Kept as centerline + width so the raster can
|
||||
decide per run: wide traces are rasterized from their outline,
|
||||
traces narrower than TRACK_1D_FACTOR grid cells become exact 1D
|
||||
resistor chains along the centerline."""
|
||||
layer_name: str
|
||||
points: np.ndarray # (2|3, 2) int64 nm
|
||||
width_nm: int
|
||||
|
||||
def outline(self, tol_nm: float) -> np.ndarray:
|
||||
if len(self.points) == 3:
|
||||
return arc_band_ring(self.points[0], self.points[1],
|
||||
self.points[2], self.width_nm, tol_nm)
|
||||
return capsule_ring(int(self.points[0][0]), int(self.points[0][1]),
|
||||
int(self.points[1][0]), int(self.points[1][1]),
|
||||
self.width_nm, tol_nm)
|
||||
|
||||
def centerline(self, tol_nm: float) -> np.ndarray:
|
||||
"""(N, 2) float polyline along the trace center, start to end."""
|
||||
if len(self.points) == 3:
|
||||
pts = arc_points(self.points[0], self.points[1], self.points[2],
|
||||
tol_nm)
|
||||
return np.vstack([pts, self.points[2][None, :]]).astype(float)
|
||||
return self.points.astype(float)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Electrode:
|
||||
"""One PART of a current-injection terminal: a drawn rectangle or a
|
||||
@@ -115,6 +143,7 @@ class Problem:
|
||||
solder_thickness_nm: int = 50_000
|
||||
solder_rho_ohm_m: float = 1.32e-7
|
||||
extra_cu_nm: int = 0
|
||||
tracks: list[TrackSeg] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def layer_names(self) -> list[str]:
|
||||
@@ -125,11 +154,15 @@ class Problem:
|
||||
return (self.layers[layer_index].thickness_nm * 1e-9) / self.rho_ohm_m
|
||||
|
||||
def copper_bbox(self) -> tuple[int, int, int, int]:
|
||||
xs = np.concatenate([p.outline[:, 0]
|
||||
for l in self.layers for p in l.polygons])
|
||||
ys = np.concatenate([p.outline[:, 1]
|
||||
for l in self.layers for p in l.polygons])
|
||||
return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
||||
xs = [p.outline[:, 0] for l in self.layers for p in l.polygons]
|
||||
ys = [p.outline[:, 1] for l in self.layers for p in l.polygons]
|
||||
for seg in self.tracks:
|
||||
ring = seg.outline(100_000.0) # coarse tol: bbox only
|
||||
xs.append(ring[:, 0])
|
||||
ys.append(ring[:, 1])
|
||||
x = np.concatenate(xs)
|
||||
y = np.concatenate(ys)
|
||||
return int(x.min()), int(y.min()), int(x.max()), int(y.max())
|
||||
|
||||
|
||||
def _arc_params(start, mid, end) -> tuple[float, float, float, float, float] | None:
|
||||
@@ -313,6 +346,11 @@ def problem_to_json(p: Problem) -> dict:
|
||||
for l in p.layers
|
||||
],
|
||||
"vias": [vars(v) | {} for v in p.vias],
|
||||
"tracks": [
|
||||
{"layer_name": s.layer_name, "points": s.points.tolist(),
|
||||
"width_nm": s.width_nm}
|
||||
for s in p.tracks
|
||||
],
|
||||
"buildups": [
|
||||
{"layer_name": b.layer_name,
|
||||
"polygons": [_poly_to_json(poly) for poly in b.polygons]}
|
||||
@@ -386,6 +424,12 @@ def problem_from_json(d: dict) -> Problem:
|
||||
solder_thickness_nm=int(d.get("solder_thickness_nm", 50_000)),
|
||||
solder_rho_ohm_m=float(d.get("solder_rho_ohm_m", 1.32e-7)),
|
||||
extra_cu_nm=int(d.get("extra_cu_nm", 0)),
|
||||
tracks=[
|
||||
TrackSeg(layer_name=td["layer_name"],
|
||||
points=np.asarray(td["points"], dtype=np.int64),
|
||||
width_nm=int(td["width_nm"]))
|
||||
for td in d.get("tracks", []) # <= v4: baked into polygons
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ def main() -> None:
|
||||
board_io.refill(board)
|
||||
fills = board_io.gather_net_fills(board)
|
||||
tracks = board_io.gather_net_tracks(board)
|
||||
copper = board_io.merge_copper(fills, tracks)
|
||||
copper = board_io.merge_copper(
|
||||
fills, board_io.tracks_as_polygons(tracks))
|
||||
candidate_nets = board_io.nets_overlapping(copper, es1, es2)
|
||||
buildups = board_io.gather_mask_buildups(board)
|
||||
except ApiError as e:
|
||||
|
||||
@@ -38,6 +38,10 @@ class RasterStack:
|
||||
layer_names: list[str]
|
||||
buildup: np.ndarray | None = None # bool (L, ny, nx): solder buildup
|
||||
# (mask opening ∩ copper)
|
||||
chain: np.ndarray | None = None # bool (L, ny, nx): cells that are
|
||||
# copper only through a 1D trace chain
|
||||
chain_edges: tuple | None = None # (a, b, g_dc, layer) arrays: explicit
|
||||
# DC conductances of the chain links
|
||||
|
||||
@property
|
||||
def nlayers(self) -> int:
|
||||
@@ -174,10 +178,29 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
|
||||
_paint_ring(stack, hole, False, pmask)
|
||||
stack.masks[li] |= pmask
|
||||
else:
|
||||
# hole-less (e.g. one of many track outlines): paint the
|
||||
# layer mask directly, skipping the full-frame temp
|
||||
# hole-less (e.g. a track outline): paint the layer mask
|
||||
# directly, skipping the full-frame temp
|
||||
_paint_ring(stack, poly.outline, True, stack.masks[li])
|
||||
|
||||
# traces: wide ones are rasterized from their outline, sub-resolution
|
||||
# ones become exact 1D resistor chains along their centerline
|
||||
index = {name: li for li, name in enumerate(stack.layer_names)}
|
||||
narrow = []
|
||||
for seg in problem.tracks:
|
||||
li = index.get(seg.layer_name)
|
||||
if li is None:
|
||||
continue
|
||||
if seg.width_nm >= config.TRACK_1D_FACTOR * h_nm:
|
||||
_paint_ring(stack, seg.outline(config.ARC_TOL_FRACTION * h_nm),
|
||||
True, stack.masks[li])
|
||||
else:
|
||||
narrow.append((li, seg))
|
||||
if narrow:
|
||||
n_links = _build_chains(stack, problem, narrow)
|
||||
print(f"{len(narrow)} trace(s) narrower than "
|
||||
f"{config.TRACK_1D_FACTOR:g} cells modeled as 1D resistor "
|
||||
f"chains ({n_links} links)")
|
||||
|
||||
if problem.buildups:
|
||||
stack.buildup = np.zeros_like(stack.masks)
|
||||
index = {name: li for li, name in enumerate(stack.layer_names)}
|
||||
@@ -195,6 +218,61 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
|
||||
return stack
|
||||
|
||||
|
||||
def _build_chains(stack: RasterStack, problem: Problem,
|
||||
narrow: list) -> int:
|
||||
"""Sub-resolution traces as 1D resistor chains: mark the cells their
|
||||
centerline crosses as copper and record one explicit conductance per
|
||||
pair of consecutive cells, allocating the trace's TRUE arc length to
|
||||
each link (a diagonal trace is not staircase-inflated). Links whose
|
||||
cells are already regular copper AND face-adjacent are skipped there
|
||||
(the trace merges into the pour: union, not sum). Returns the number
|
||||
of links."""
|
||||
L, ny, nx = stack.masks.shape
|
||||
plane = ny * nx
|
||||
h = stack.h_nm
|
||||
regular = stack.masks.copy()
|
||||
chain = np.zeros_like(stack.masks)
|
||||
aa, bb, gg, ll = [], [], [], []
|
||||
for li, seg in narrow:
|
||||
pts = seg.centerline(0.2 * h)
|
||||
d = np.hypot(*np.diff(pts, axis=0).T)
|
||||
s = np.concatenate([[0.0], np.cumsum(d)])
|
||||
length = float(s[-1])
|
||||
n_samp = max(2, int(math.ceil(length / (h / 3.0))) + 1)
|
||||
ss = np.linspace(0.0, length, n_samp)
|
||||
xs = np.interp(ss, s, pts[:, 0])
|
||||
ys = np.interp(ss, s, pts[:, 1])
|
||||
jj = np.floor((xs - stack.x0_nm) / h).astype(np.int64)
|
||||
ii = np.floor((ys - stack.y0_nm) / h).astype(np.int64)
|
||||
jj = np.clip(jj, 0, nx - 1) # bbox includes all tracks;
|
||||
ii = np.clip(ii, 0, ny - 1) # clip only guards rounding
|
||||
first = np.concatenate(
|
||||
[[True], (ii[1:] != ii[:-1]) | (jj[1:] != jj[:-1])])
|
||||
ci, cj, cs = ii[first], jj[first], ss[first]
|
||||
chain[li, ci, cj] = True
|
||||
g0 = (seg.width_nm * 1e-9
|
||||
* problem.layers[li].thickness_nm * 1e-9 / problem.rho_ohm_m)
|
||||
for k in range(len(ci) - 1):
|
||||
dl = (cs[k + 1] - cs[k]) * 1e-9
|
||||
if dl <= 0:
|
||||
continue
|
||||
adj4 = abs(int(ci[k + 1] - ci[k])) + abs(int(cj[k + 1] - cj[k])) == 1
|
||||
if adj4 and regular[li, ci[k], cj[k]] \
|
||||
and regular[li, ci[k + 1], cj[k + 1]]:
|
||||
continue # pour conducts here already
|
||||
aa.append(li * plane + int(ci[k]) * nx + int(cj[k]))
|
||||
bb.append(li * plane + int(ci[k + 1]) * nx + int(cj[k + 1]))
|
||||
gg.append(g0 / dl)
|
||||
ll.append(li)
|
||||
stack.chain = chain & ~regular
|
||||
stack.masks |= stack.chain
|
||||
stack.chain_edges = (np.asarray(aa, dtype=np.int64),
|
||||
np.asarray(bb, dtype=np.int64),
|
||||
np.asarray(gg, dtype=float),
|
||||
np.asarray(ll, dtype=np.int64))
|
||||
return len(aa)
|
||||
|
||||
|
||||
def _rect_cells(stack: RasterStack, rect: Rect) -> np.ndarray:
|
||||
"""Bool (ny, nx) mask of cells whose center lies inside the rectangle."""
|
||||
ny, nx = stack.shape2d
|
||||
|
||||
@@ -141,6 +141,10 @@ def build_edges(stack: RasterStack, problem: Problem, sigmas: list[float],
|
||||
|
||||
for li in range(L):
|
||||
m = stack.masks[li]
|
||||
if stack.chain is not None:
|
||||
# chain-only cells connect through their explicit 1D links,
|
||||
# never through sheet faces (their copper is narrower than h)
|
||||
m = m & ~stack.chain[li]
|
||||
sig = sigmas[li]
|
||||
scell = _sigma_2d(stack, li, sig, sigma_buildup)
|
||||
base = li * plane
|
||||
@@ -207,6 +211,19 @@ def build_edges(stack: RasterStack, problem: Problem, sigmas: list[float],
|
||||
ww.append(np.array([1.0 / r]))
|
||||
vv.append(np.array([vi], dtype=np.int32))
|
||||
|
||||
if stack.chain_edges is not None and len(stack.chain_edges[0]):
|
||||
ca, cb, cg, cl = stack.chain_edges
|
||||
alive = stack.masks.ravel()[ca] & stack.masks.ravel()[cb]
|
||||
if alive.any():
|
||||
# skin correction: scale like the layer's sheet conductance
|
||||
fac = np.array([sigmas[l] * problem.rho_ohm_m
|
||||
/ (problem.layers[l].thickness_nm * 1e-9)
|
||||
for l in range(L)])
|
||||
aa.append(ca[alive])
|
||||
bb.append(cb[alive])
|
||||
ww.append((cg * fac[cl])[alive])
|
||||
vv.append(np.full(int(alive.sum()), -1, dtype=np.int32))
|
||||
|
||||
if not aa:
|
||||
raise ConnectivityError("No copper found on the selected layers.")
|
||||
return Edges(a=np.concatenate(aa), b=np.concatenate(bb),
|
||||
@@ -540,6 +557,8 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
|
||||
)
|
||||
if stack.buildup is not None:
|
||||
stack.buildup &= stack.masks
|
||||
if stack.chain is not None:
|
||||
stack.chain &= stack.masks
|
||||
for label, m in (parts1 or []) + (parts2 or []):
|
||||
had = bool(m.any())
|
||||
m &= stack.masks # follow the component restriction
|
||||
|
||||
@@ -61,6 +61,7 @@ def main(argv=None) -> int:
|
||||
if args.layers:
|
||||
keep = [s.strip() for s in args.layers.split(",")]
|
||||
problem.layers = [l for l in problem.layers if l.layer_name in keep]
|
||||
problem.tracks = [t for t in problem.tracks if t.layer_name in keep]
|
||||
if not problem.layers:
|
||||
print(f"ERROR: no layer of the dump matches --layers {args.layers}",
|
||||
file=sys.stderr)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://go.kicad.org/pcm/schemas/v2",
|
||||
"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_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.\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), 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.\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",
|
||||
"type": "plugin",
|
||||
"author": {
|
||||
|
||||
+120
-4
@@ -1,6 +1,6 @@
|
||||
"""Track (trace) conductor tests: capsule / arc-band outline generation
|
||||
and solves on rasterized traces. The 1-cell-wide capsule chain is exact;
|
||||
the arc band is checked against the analytic annular-sector resistance."""
|
||||
"""Track (trace) conductor tests: capsule / arc-band outline generation,
|
||||
solves on rasterized traces, and the 1D resistor-chain model for traces
|
||||
narrower than TRACK_1D_FACTOR grid cells."""
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
@@ -8,10 +8,13 @@ import pytest
|
||||
|
||||
from fill_resistance import raster, solver
|
||||
from fill_resistance.geometry import (Electrode, LayerFill, Polygon, Problem,
|
||||
arc_band_ring, capsule_ring)
|
||||
TrackSeg, arc_band_ring, capsule_ring,
|
||||
load_problem, save_problem)
|
||||
from tests.util import NM, rect_mm, sigma_s
|
||||
|
||||
TOL_NM = 10_000
|
||||
RHO = 1.68e-8
|
||||
T_M = 70e-6
|
||||
|
||||
|
||||
def _track_problem(rings, rect1, rect2, t_um=70.0):
|
||||
@@ -27,6 +30,26 @@ def _track_problem(rings, rect1, rect2, t_um=70.0):
|
||||
)
|
||||
|
||||
|
||||
def _seg(points_mm, w_mm, layer="F.Cu") -> TrackSeg:
|
||||
pts = (np.asarray(points_mm, dtype=float) * NM).astype(np.int64)
|
||||
return TrackSeg(layer_name=layer, points=pts, width_nm=int(w_mm * NM))
|
||||
|
||||
|
||||
def _seg_problem(segs, rect1, rect2, fills_mm=(), t_um=70.0):
|
||||
polys = [Polygon(outline=(np.asarray(o, dtype=float) * NM
|
||||
).astype(np.int64)) for o in fills_mm]
|
||||
return Problem(
|
||||
board_path="synthetic", net_name="TEST", rho_ohm_m=RHO,
|
||||
plating_nm=18_000,
|
||||
layers=[LayerFill(layer_name="F.Cu", thickness_nm=int(t_um * 1000),
|
||||
z_nm=0, polygons=polys)],
|
||||
vias=[],
|
||||
electrodes1=[Electrode(rect=rect_mm(rect1))],
|
||||
electrodes2=[Electrode(rect=rect_mm(rect2))],
|
||||
tracks=segs,
|
||||
)
|
||||
|
||||
|
||||
def _solve(problem, h_mm):
|
||||
stack = raster.rasterize_stack(problem, h_mm * NM)
|
||||
e1, e2 = raster.electrode_masks(stack, problem)
|
||||
@@ -115,6 +138,99 @@ def test_arc_track_matches_annular_sector():
|
||||
assert err_fine < 0.04
|
||||
|
||||
|
||||
def test_narrow_trace_1d_matches_fine_raster():
|
||||
"""A 0.2 mm trace at h = 1 mm (1D chain) must agree with the same
|
||||
trace finely rasterized at h = 0.05 mm (4 cells wide) and with the
|
||||
analytic R between the electrode inner edges."""
|
||||
seg = _seg([(1, 0.5), (41, 0.5)], 0.2)
|
||||
p = _seg_problem([seg], (0, 0, 2, 1), (40, 0, 42, 1))
|
||||
res_1d, stack = _solve(p, 1.0)
|
||||
assert stack.chain is not None and stack.chain.any()
|
||||
res_fine, stack_f = _solve(_seg_problem([seg], (0, 0, 2, 1),
|
||||
(40, 0, 42, 1)), 0.05)
|
||||
assert stack_f.chain is None or not stack_f.chain.any()
|
||||
r_analytic = RHO * 0.038 / (0.2e-3 * T_M) # between x=2 and x=40
|
||||
assert res_fine.R_ohm == pytest.approx(r_analytic, rel=0.03)
|
||||
assert res_1d.R_ohm == pytest.approx(res_fine.R_ohm, rel=0.06)
|
||||
|
||||
|
||||
def test_diagonal_narrow_trace_no_staircase():
|
||||
"""1D links carry the TRUE arc length: a diagonal trace must not be
|
||||
inflated by the 4-connected staircase (which would be up to +41%)."""
|
||||
seg = _seg([(1, 1), (25, 19)], 0.2)
|
||||
p = _seg_problem([seg], (0, 0, 2, 2), (24, 18, 26, 20))
|
||||
res, _ = _solve(p, 1.0)
|
||||
L = math.hypot(24, 18) * 1e-3 # 30 mm
|
||||
r_full = RHO * L / (0.2e-3 * T_M)
|
||||
assert res.R_ohm < 1.05 * r_full # no staircase inflation
|
||||
assert res.R_ohm == pytest.approx(r_full, rel=0.08)
|
||||
|
||||
|
||||
def test_narrow_arc_trace_uses_arc_length():
|
||||
"""Quarter-circle 0.2 mm trace, r = 10 mm, as a 1D chain: R follows
|
||||
the arc length (a chord-based length would read ~10% low)."""
|
||||
seg = TrackSeg(layer_name="F.Cu", points=np.array(
|
||||
[[10 * NM, 0],
|
||||
[int(round(10 * NM / math.sqrt(2))),
|
||||
int(round(10 * NM / math.sqrt(2)))],
|
||||
[0, 10 * NM]], dtype=np.int64), width_nm=int(0.2 * NM))
|
||||
p = _seg_problem([seg], (9, -1, 11, 1), (-1, 9, 1, 11))
|
||||
res, _ = _solve(p, 0.5)
|
||||
# the electrode rects cover the arc where y < 1 (resp. x < 1), so the
|
||||
# free span is theta in [asin(0.1), pi/2 - asin(0.1)]
|
||||
th = math.asin(0.1)
|
||||
r_arc = RHO * ((math.pi / 2 - 2 * th) * 10e-3) / (0.2e-3 * T_M)
|
||||
assert res.R_ohm == pytest.approx(r_arc, rel=0.06)
|
||||
|
||||
|
||||
def test_narrow_trace_bridges_pours():
|
||||
"""A sub-resolution trace joins two pours: without it they are
|
||||
disconnected; with it R is dominated by the trace's gap length."""
|
||||
from fill_resistance.errors import ConnectivityError
|
||||
pour1 = [(0, 0), (10, 0), (10, 10), (0, 10)]
|
||||
pour2 = [(30, 0), (40, 0), (40, 10), (30, 10)]
|
||||
rects = ((0, 0, 2, 10), (38, 0, 40, 10))
|
||||
bare = _seg_problem([], *rects, fills_mm=(pour1, pour2))
|
||||
stack = raster.rasterize_stack(bare, 1.0 * NM)
|
||||
e1, e2 = raster.electrode_masks(stack, bare)
|
||||
with pytest.raises(ConnectivityError):
|
||||
solver.run_solve(bare, stack, e1, e2, 1.0,
|
||||
contact_model="equipotential")
|
||||
|
||||
seg = _seg([(5, 5), (35, 5)], 0.2)
|
||||
bridged = _seg_problem([seg], *rects, fills_mm=(pour1, pour2))
|
||||
res, _ = _solve(bridged, 1.0)
|
||||
r_gap = RHO * 0.020 / (0.2e-3 * T_M) # 20 mm between pours
|
||||
assert res.R_ohm == pytest.approx(r_gap, rel=0.10)
|
||||
assert res.power_balance_rel < 1e-9
|
||||
|
||||
|
||||
def test_wide_track_still_rasterized():
|
||||
"""At or above the width threshold the trace is rasterized normally
|
||||
and no chain cells appear."""
|
||||
seg = _seg([(1, 2), (19, 2)], 2.0)
|
||||
p = _seg_problem([seg], (0, 1, 2, 3), (18, 1, 20, 3))
|
||||
res, stack = _solve(p, 0.25)
|
||||
assert stack.chain is None or not stack.chain.any()
|
||||
assert int(stack.masks.sum()) > 300 # a real 2D band
|
||||
assert np.isfinite(res.R_ohm) and res.R_ohm > 0
|
||||
|
||||
|
||||
def test_json_v5_roundtrip_with_tracks(tmp_path):
|
||||
seg = _seg([(1, 0.5), (41, 0.5)], 0.2)
|
||||
p = _seg_problem([seg], (0, 0, 2, 1), (40, 0, 42, 1))
|
||||
f = tmp_path / "d.json"
|
||||
save_problem(p, f)
|
||||
q = load_problem(f)
|
||||
assert len(q.tracks) == 1
|
||||
assert q.tracks[0].layer_name == "F.Cu"
|
||||
assert q.tracks[0].width_nm == int(0.2 * NM)
|
||||
assert np.array_equal(q.tracks[0].points, p.tracks[0].points)
|
||||
r_p, _ = _solve(p, 1.0)
|
||||
r_q, _ = _solve(q, 1.0)
|
||||
assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12)
|
||||
|
||||
|
||||
def test_track_unions_with_fill():
|
||||
"""A trace overlapping a plate merges into one conductor: the mask is
|
||||
the union, and R drops when the trace bridges a slot."""
|
||||
|
||||
Reference in New Issue
Block a user