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
+34 -18
View File
@@ -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]
+5
View File
@@ -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
+50 -6
View File
@@ -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
],
)
+2 -1
View File
@@ -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:
+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
+19
View File
@@ -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
+1
View File
@@ -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)