Add KiCad module carrier PCB draft
Introduce an editable KiCad 10 schematic and routed two-layer 82 × 80 mm layout with local provisional footprints, validation tooling, and component research. Keep the RS-232 male-module mapping and mechanical clearances explicitly provisional pending hardware verification.
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/python3
|
||||
"""Bounded, board-specific two-layer draft router. Requires pcbnew and numpy.
|
||||
|
||||
Dry-run by default. --route --overwrite explicitly permits saving routing;
|
||||
existing copper additionally requires --replace-routes. Never edits the project,
|
||||
schematic, footprint libraries or generator. Review actual KiCad DRC afterwards.
|
||||
This is not a production autorouter or a fabrication qualification.
|
||||
"""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pcbnew as p
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
PCB = ROOT / 'serial-carrier.kicad_pcb'
|
||||
STEP = 0.05
|
||||
NX, NY = int(82 / STEP) + 1, int(80 / STEP) + 1
|
||||
N = NX * NY
|
||||
CLEARANCE = 0.25
|
||||
MARGIN = 0.02
|
||||
LAYERS = (p.F_Cu, p.B_Cu)
|
||||
X = 100 + np.arange(NX)[None, :] * STEP
|
||||
Y = 100 + np.arange(NY)[:, None] * STEP
|
||||
|
||||
|
||||
def mm(v):
|
||||
return p.FromMM(v)
|
||||
|
||||
|
||||
def point(xy):
|
||||
return p.VECTOR2I(mm(xy[0]), mm(xy[1]))
|
||||
|
||||
|
||||
def pos(item):
|
||||
v = item.GetPosition()
|
||||
return (p.ToMM(v.x), p.ToMM(v.y))
|
||||
|
||||
|
||||
def identity(board):
|
||||
"""Immutable geometry/linkage snapshot, independent of serialization order."""
|
||||
footprints = []
|
||||
for f in board.GetFootprints():
|
||||
pads = sorted((a.m_Uuid.AsString(), a.GetNumber(), a.GetNetname(),
|
||||
pos(a), (a.GetSize().x, a.GetSize().y),
|
||||
(a.GetDrillSize().x, a.GetDrillSize().y),
|
||||
a.GetShape(), a.GetAttribute(), a.GetLayerSet().FmtHex(),
|
||||
a.GetOrientationDegrees(), a.GetPinFunction(), a.GetPinType())
|
||||
for a in f.Pads())
|
||||
footprints.append((f.m_Uuid.AsString(), f.GetReference(), f.GetValue(),
|
||||
f.GetFPIDAsString(), f.GetPath().AsString(), pos(f),
|
||||
f.GetOrientationDegrees(), f.GetLayer(), pads))
|
||||
edges = sorted((a.m_Uuid.AsString(), a.GetShape(), a.GetStart().x,
|
||||
a.GetStart().y, a.GetEnd().x, a.GetEnd().y, a.GetWidth())
|
||||
for a in board.GetDrawings() if a.GetLayer() == p.Edge_Cuts)
|
||||
zones = sorted((z.m_Uuid.AsString(), z.GetLayerSet().FmtHex(),
|
||||
z.GetIsRuleArea(), z.GetDoNotAllowTracks(),
|
||||
z.GetDoNotAllowVias(), z.GetDoNotAllowZoneFills(),
|
||||
tuple((z.Outline().COutline(0).CPoint(i).x,
|
||||
z.Outline().COutline(0).CPoint(i).y)
|
||||
for i in range(z.Outline().COutline(0).PointCount())))
|
||||
for z in board.Zones())
|
||||
nets = sorted((n.GetNetCode(), n.GetNetname()) for n in board.GetNetsByNetcode().values())
|
||||
return (board.GetCopperLayerCount(), sorted(footprints), edges, zones, nets)
|
||||
|
||||
|
||||
def window(x0, y0, x1, y1):
|
||||
ix0 = max(0, int(math.floor((x0 - 100) / STEP)))
|
||||
iy0 = max(0, int(math.floor((y0 - 100) / STEP)))
|
||||
ix1 = min(NX, int(math.ceil((x1 - 100) / STEP)) + 1)
|
||||
iy1 = min(NY, int(math.ceil((y1 - 100) / STEP)) + 1)
|
||||
return slice(iy0, iy1), slice(ix0, ix1)
|
||||
|
||||
|
||||
def capsule(mask, a, b, radius):
|
||||
sy, sx = window(min(a[0], b[0]) - radius, min(a[1], b[1]) - radius,
|
||||
max(a[0], b[0]) + radius, max(a[1], b[1]) + radius)
|
||||
xx, yy = X[:, sx], Y[sy, :]
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length2 = dx * dx + dy * dy
|
||||
t = np.clip(((xx - a[0]) * dx + (yy - a[1]) * dy) / length2, 0, 1) if length2 else 0
|
||||
mask[sy, sx] |= (xx - a[0] - t * dx)**2 + (yy - a[1] - t * dy)**2 <= radius**2
|
||||
|
||||
|
||||
def pad_obstacle(mask, pad, extra, hole_only=False):
|
||||
xy = pos(pad)
|
||||
size = pad.GetDrillSize() if hole_only else pad.GetSize()
|
||||
rx, ry = p.ToMM(size.x) / 2, p.ToMM(size.y) / 2
|
||||
if not rx or not ry:
|
||||
return
|
||||
if abs(rx - ry) < 1e-6 and (hole_only or pad.GetShape() == p.PAD_SHAPE_CIRCLE):
|
||||
capsule(mask, xy, xy, rx + extra)
|
||||
else:
|
||||
# Bounding rectangle deliberately overestimates non-circular pad shapes.
|
||||
box = pad.GetBoundingBox()
|
||||
x0, y0 = p.ToMM(box.GetX()) - extra, p.ToMM(box.GetY()) - extra
|
||||
x1, y1 = p.ToMM(box.GetRight()) + extra, p.ToMM(box.GetBottom()) + extra
|
||||
sy, sx = window(x0, y0, x1, y1)
|
||||
mask[sy, sx] |= (X[:, sx] >= x0) & (X[:, sx] <= x1) & (Y[sy, :] >= y0) & (Y[sy, :] <= y1)
|
||||
|
||||
|
||||
def boundary(radius):
|
||||
edge = 0.5 + radius + MARGIN
|
||||
mask = np.broadcast_to((X < 100 + edge) | (X > 182 - edge) |
|
||||
(Y < 100 + edge) | (Y > 180 - edge), (NY, NX)).copy()
|
||||
mask |= (X < 136 + edge) & (Y > 161 - edge)
|
||||
# Expand the RF reservation by copper radius plus numerical safety margin.
|
||||
r = radius + MARGIN
|
||||
mask |= (X >= 110 - r) & (X <= 131 + r) & (Y <= 113 + r)
|
||||
return mask
|
||||
|
||||
|
||||
def masks(board, pads, net, width):
|
||||
blocked = np.stack([boundary(width / 2)] * 2)
|
||||
via = boundary(0.35)
|
||||
for pad in pads:
|
||||
if pad.GetNetCode() != net or not pad.GetNumber():
|
||||
for z, layer in enumerate(LAYERS):
|
||||
if pad.IsOnLayer(layer):
|
||||
pad_obstacle(blocked[z], pad, CLEARANCE + width / 2 + MARGIN)
|
||||
pad_obstacle(via, pad, CLEARANCE + 0.35 + MARGIN)
|
||||
# Even same-net holes must not receive a drilled via.
|
||||
pad_obstacle(via, pad, CLEARANCE + 0.35 + MARGIN, hole_only=True)
|
||||
for track in board.GetTracks():
|
||||
if track.GetNetCode() == net:
|
||||
if isinstance(track, p.PCB_VIA):
|
||||
capsule(via, pos(track), pos(track), 0.7 + CLEARANCE + MARGIN)
|
||||
continue
|
||||
a = (p.ToMM(track.GetStart().x), p.ToMM(track.GetStart().y))
|
||||
b = (p.ToMM(track.GetEnd().x), p.ToMM(track.GetEnd().y))
|
||||
radius = p.ToMM(track.GetWidth(p.F_Cu) if isinstance(track, p.PCB_VIA) else track.GetWidth()) / 2
|
||||
for z, layer in enumerate(LAYERS):
|
||||
if isinstance(track, p.PCB_VIA) or track.GetLayer() == layer:
|
||||
capsule(blocked[z], a, b, radius + CLEARANCE + width / 2 + MARGIN)
|
||||
capsule(via, a, b, radius + CLEARANCE + 0.35 + MARGIN)
|
||||
return blocked.reshape(-1), via.reshape(-1)
|
||||
|
||||
|
||||
def node(xy, layer=0):
|
||||
x, y = (int(round((v - 100) / STEP)) for v in xy)
|
||||
return layer * N + y * NX + x
|
||||
|
||||
|
||||
def decode(i):
|
||||
z, q = divmod(i, N)
|
||||
y, x = divmod(q, NX)
|
||||
return (100 + x * STEP, 100 + y * STEP), z
|
||||
|
||||
|
||||
def astar(blocked, via, start, target, deadline, max_expansions):
|
||||
tx = int(round((target[0] - 100) / STEP))
|
||||
ty = int(round((target[1] - 100) / STEP))
|
||||
targetq = ty * NX + tx
|
||||
|
||||
def h(q):
|
||||
y, x = divmod(q, NX)
|
||||
dx, dy = abs(x - tx), abs(y - ty)
|
||||
return 10 * max(dx, dy) + 4 * min(dx, dy)
|
||||
|
||||
dist = np.full(2 * N, 2147483647, dtype=np.int32)
|
||||
parent = np.full(2 * N, -1, dtype=np.int32)
|
||||
heap = []
|
||||
for z in (0, 1):
|
||||
i = node(start, z)
|
||||
if not blocked[i]:
|
||||
dist[i] = 0
|
||||
heapq.heappush(heap, (h(i % N), 0, i))
|
||||
expanded = 0
|
||||
moves = ((1, 0, 10), (-1, 0, 10), (0, 1, 10), (0, -1, 10),
|
||||
(1, 1, 14), (1, -1, 14), (-1, 1, 14), (-1, -1, 14))
|
||||
while heap:
|
||||
_, cost, i = heapq.heappop(heap)
|
||||
if cost != dist[i]:
|
||||
continue
|
||||
z, q = divmod(i, N)
|
||||
if q == targetq:
|
||||
path = [i]
|
||||
while parent[path[-1]] >= 0:
|
||||
path.append(int(parent[path[-1]]))
|
||||
return path[::-1], expanded
|
||||
expanded += 1
|
||||
if expanded >= max_expansions or (expanded % 1024 == 0 and time.monotonic() >= deadline):
|
||||
return None, expanded
|
||||
y, x = divmod(q, NX)
|
||||
for dx, dy, stepcost in moves:
|
||||
if not (0 <= x + dx < NX and 0 <= y + dy < NY):
|
||||
continue
|
||||
j = i + dy * NX + dx
|
||||
if blocked[j] or (dx and dy and (blocked[i + dx] or blocked[i + dy * NX])):
|
||||
continue
|
||||
# Mild layer preference prevents needless coincident layer choices.
|
||||
penalty = int((z == 0 and dx == 0) or (z == 1 and dy == 0))
|
||||
nc = cost + stepcost + penalty
|
||||
if nc < dist[j]:
|
||||
dist[j], parent[j] = nc, i
|
||||
heapq.heappush(heap, (nc + h(j % N), nc, j))
|
||||
j = (1 - z) * N + q
|
||||
if not via[q] and not blocked[j]:
|
||||
nc = cost + int(18 / STEP) # 1.8 mm equivalent cost for a layer change.
|
||||
if nc < dist[j]:
|
||||
dist[j], parent[j] = nc, i
|
||||
heapq.heappush(heap, (nc + h(q), nc, j))
|
||||
return None, expanded
|
||||
|
||||
|
||||
def add_path(board, path, start, target, net, width):
|
||||
points = [decode(i) for i in path]
|
||||
points = [(start, points[0][1])] + points + [(target, points[-1][1])]
|
||||
# Only merge exactly collinear grid runs; never shortcut obstacle checks.
|
||||
simple = []
|
||||
for item in points:
|
||||
if simple and item == simple[-1]:
|
||||
continue
|
||||
while len(simple) >= 2 and item[1] == simple[-1][1] == simple[-2][1]:
|
||||
a, b, c = simple[-2][0], simple[-1][0], item[0]
|
||||
cross = (b[0]-a[0])*(c[1]-b[1]) - (b[1]-a[1])*(c[0]-b[0])
|
||||
dot = (b[0]-a[0])*(c[0]-b[0]) + (b[1]-a[1])*(c[1]-b[1])
|
||||
if abs(cross) > 1e-8 or dot < 0:
|
||||
break
|
||||
simple.pop()
|
||||
simple.append(item)
|
||||
for (a, z), (b, zz) in zip(simple, simple[1:]):
|
||||
if z != zz:
|
||||
assert a == b
|
||||
item = p.PCB_VIA(board)
|
||||
item.SetPosition(point(a))
|
||||
item.SetWidth(mm(0.7))
|
||||
item.SetDrill(mm(0.3))
|
||||
item.SetViaType(p.VIATYPE_THROUGH)
|
||||
item.SetLayerPair(p.F_Cu, p.B_Cu)
|
||||
else:
|
||||
if a == b:
|
||||
continue
|
||||
item = p.PCB_TRACK(board)
|
||||
item.SetStart(point(a))
|
||||
item.SetEnd(point(b))
|
||||
item.SetWidth(mm(width))
|
||||
item.SetLayer(LAYERS[z])
|
||||
item.SetNetCode(net)
|
||||
board.Add(item)
|
||||
|
||||
|
||||
def pairs_for_net(pads):
|
||||
# Minimum spanning tree includes duplicate switch contacts explicitly.
|
||||
connected = {0}
|
||||
todo = set(range(1, len(pads)))
|
||||
pairs = []
|
||||
while todo:
|
||||
length, a, b = min((math.dist(pos(pads[a]), pos(pads[b])), a, b)
|
||||
for a in connected for b in todo)
|
||||
pairs.append((length, pads[a], pads[b]))
|
||||
connected.add(b)
|
||||
todo.remove(b)
|
||||
return pairs
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--route', action='store_true')
|
||||
parser.add_argument('--overwrite', action='store_true')
|
||||
parser.add_argument('--replace-routes', action='store_true')
|
||||
parser.add_argument('--seconds', type=float, default=240)
|
||||
parser.add_argument('--max-expansions', type=int, default=1200000)
|
||||
args = parser.parse_args()
|
||||
if args.route and not args.overwrite:
|
||||
parser.error('--route requires --overwrite; back up manual routing first')
|
||||
if not 0 < args.seconds <= 900 or not 0 < args.max_expansions <= 1500000:
|
||||
parser.error('bounds: seconds (0,900], max-expansions (0,1500000]')
|
||||
if ROOT.joinpath('~serial-carrier.kicad_pcb.lck').exists():
|
||||
parser.error('PCB editor lock exists; close the PCB before routing')
|
||||
original_bytes = PCB.read_bytes()
|
||||
board = p.LoadBoard(str(PCB))
|
||||
before = identity(board)
|
||||
if board.GetCopperLayerCount() != 2 or len(board.GetFootprints()) != 13:
|
||||
parser.error('unexpected board structure; review router assumptions')
|
||||
if len(board.Zones()) != 1 or not board.Zones()[0].GetIsRuleArea():
|
||||
parser.error('unexpected zones; router supports only the original RF rule area')
|
||||
outline = [(100, 100), (182, 100), (182, 180), (136, 180), (136, 161), (100, 161)]
|
||||
expected_edges = {frozenset((a, b)) for a, b in zip(outline, outline[1:] + outline[:1])}
|
||||
actual_edges = {frozenset(((p.ToMM(e.GetStart().x), p.ToMM(e.GetStart().y)),
|
||||
(p.ToMM(e.GetEnd().x), p.ToMM(e.GetEnd().y))))
|
||||
for e in board.GetDrawings() if e.GetLayer() == p.Edge_Cuts}
|
||||
if actual_edges != expected_edges:
|
||||
parser.error('outline differs from the board-specific routing envelope')
|
||||
reserve = board.Zones()[0]
|
||||
contour = reserve.Outline().COutline(0)
|
||||
rf_points = {(p.ToMM(contour.CPoint(i).x), p.ToMM(contour.CPoint(i).y))
|
||||
for i in range(contour.PointCount())}
|
||||
if (rf_points != {(110, 100), (131, 100), (131, 113), (110, 113)} or
|
||||
not all(reserve.IsOnLayer(layer) for layer in LAYERS) or
|
||||
not all((reserve.GetDoNotAllowTracks(), reserve.GetDoNotAllowVias(),
|
||||
reserve.GetDoNotAllowZoneFills()))):
|
||||
parser.error('RF rule area differs from the board-specific reservation')
|
||||
if board.GetTracks() and not args.replace_routes:
|
||||
parser.error('existing routing protected; --replace-routes required even for preview')
|
||||
for track in list(board.GetTracks()):
|
||||
board.Delete(track)
|
||||
pads = [a for f in board.GetFootprints() for a in f.Pads()]
|
||||
if any(a.GetOrientationDegrees() % 90 or not all(a.IsOnLayer(l) for l in LAYERS) for a in pads):
|
||||
parser.error('router requires axis-aligned through-hole pads')
|
||||
nets = defaultdict(list)
|
||||
for pad in pads:
|
||||
if pad.GetNumber() and pad.GetNetCode():
|
||||
nets[pad.GetNetCode()].append(pad)
|
||||
work = []
|
||||
for net, netpads in nets.items():
|
||||
if len(netpads) > 1:
|
||||
name = netpads[0].GetNetname()
|
||||
for length, a, b in pairs_for_net(netpads):
|
||||
# Short local links first, then the long signal fanout; ground last.
|
||||
priority = (name == '/GND', length)
|
||||
work.append((priority, net, name, a, b))
|
||||
work.sort(key=lambda w: (w[0], w[2]))
|
||||
start_time = time.monotonic()
|
||||
deadline = start_time + args.seconds
|
||||
results = []
|
||||
for _, net, name, a, b in work:
|
||||
width = 0.5 if name in ('/+3V3', '/GND') else 0.25
|
||||
path, expanded = None, 0
|
||||
if time.monotonic() < deadline:
|
||||
blocked, via = masks(board, pads, net, width)
|
||||
path, expanded = astar(blocked, via, pos(a), pos(b), deadline, args.max_expansions)
|
||||
fallback = False
|
||||
if path is None and width == 0.5 and time.monotonic() < deadline:
|
||||
width, fallback = 0.25, True
|
||||
blocked, via = masks(board, pads, net, width)
|
||||
path, more = astar(blocked, via, pos(a), pos(b), deadline, args.max_expansions)
|
||||
expanded += more
|
||||
label = lambda pad: pad.GetParentFootprint().GetReference() + '.' + pad.GetNumber()
|
||||
result = dict(net=name, start=label(a), end=label(b), start_mm=pos(a), end_mm=pos(b),
|
||||
routed=path is not None, width_mm=width, power_fallback=fallback, expansions=expanded)
|
||||
results.append(result)
|
||||
if path is not None:
|
||||
add_path(board, path, pos(a), pos(b), net, width)
|
||||
print(json.dumps(result), flush=True)
|
||||
assert identity(board) == before, 'immutable board data changed'
|
||||
report = dict(draft_only=True, saved=args.route, grid_mm=STEP, clearance_mm=CLEARANCE,
|
||||
safety_margin_mm=MARGIN, via_diameter_mm=0.7, via_drill_mm=0.3,
|
||||
elapsed_seconds=time.monotonic()-start_time, connections=results,
|
||||
routed_tree_edges=sum(r['routed'] for r in results), total_tree_edges=len(results),
|
||||
tracks=sum(not isinstance(t, p.PCB_VIA) for t in board.GetTracks()),
|
||||
vias=sum(isinstance(t, p.PCB_VIA) for t in board.GetTracks()),
|
||||
immutable_identity_sha256=hashlib.sha256(repr(before).encode()).hexdigest(),
|
||||
input_sha256=hashlib.sha256(original_bytes).hexdigest(),
|
||||
validation='KiCad DRC must be run separately; tree-edge counts are not DRC connectivity')
|
||||
if args.route:
|
||||
if PCB.read_bytes() != original_bytes or ROOT.joinpath('~serial-carrier.kicad_pcb.lck').exists():
|
||||
raise RuntimeError('PCB changed or editor opened during routing; refusing overwrite')
|
||||
# Check a serialized candidate before replacing the user's board.
|
||||
candidate = ROOT / 'validation/pcb-routing-candidate.kicad_pcb'
|
||||
p.SaveBoard(str(candidate), board)
|
||||
assert identity(p.LoadBoard(str(candidate))) == before, 'saved identity mismatch'
|
||||
candidate.replace(PCB)
|
||||
ROOT.joinpath('validation/pcb-routing-run.json').write_text(json.dumps(report, indent=2)+'\n')
|
||||
print(json.dumps({k: v for k, v in report.items() if k != 'connections'}, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user