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:
janik
2026-07-15 16:02:01 +07:00
parent 56e2f9c81a
commit 92bb29637f
10 changed files with 318 additions and 33 deletions
+80 -2
View File
@@ -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