board
OCuLink Riser for the Supermicro AIOM Slot
Public Unreviewedby John Lauer
Passive OCP NIC 3.0 SFF card that breaks a Supermicro AIOM slot's PCIe x16 into four OCuLink 4i ports (4 x4, Gen4): 6-layer, 85 ohm, length-matched, DRC-clean KiCad project with Gerbers, BOM and CPL.
main
John Lauer
README: component table per the wiki format, open-source section, no duplicate hero; generators at gen/
39a59ef
7d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
#!/usr/bin/env python3
"""After the maze: GND fan-out vias for SMD ground pads, GND stitching and return vias, pours and keepouts,
board text. Writes design/routes.json (everything build_board.py final needs)."""
import json, math, os, sys, time
import numpy as np
from scipy import ndimage
from shapely.geometry import Polygon, box, Point
sys.path.insert(0, os.path.dirname(__file__))
import route_all as R
import maze, hs
from maze import G, LI, NX, NY
ROOT = R.ROOT
ls = json.load(open(os.path.join(ROOT, "design", "routes_ls.json")))
B = R.B
T, V = R.T, R.V
_ts = set(map(tuple, T)); _vs = set(map(tuple, V))
for t in map(tuple, ls["tracks"]):
if t not in _ts:
T.append(t); B.track(*t); _ts.add(t)
for v in map(tuple, ls["vias"]):
if v not in _vs:
V.append(v); B.via(v[0], v[1], v[2], v[3]); _vs.add(v)
gnd = B.nid("GND")
def holes_mask(gap=0.62):
"""cells too close to any existing drilled hole (via or plated pad) for hole-to-hole spacing"""
m = np.zeros((NY, NX), bool)
pts = [(v[0], v[1], v[4] if len(v) > 4 else 0.2) for v in V]
pts += [(p["x"], p["y"], max(p["drill"])) for p in R.pads if p["drill"][0] > 0]
for (x, y, h) in pts:
r = gap + h / 2
j0, j1 = max(0, int((y - r) / G)), min(NY - 1, int((y + r) / G) + 1)
i0, i1 = max(0, int((x - r) / G)), min(NX - 1, int((x + r) / G) + 1)
yy_, xx_ = np.mgrid[j0:j1 + 1, i0:i1 + 1]
m[j0:j1 + 1, i0:i1 + 1] |= (xx_ * G - x) ** 2 + (yy_ * G - y) ** 2 < r * r
return m
def via_ok_mask(clr=0.25, clr_hs=0.35, d=maze.VIA_D):
ok = ~holes_mask()
for li in range(4):
lab = B.lab[li]
other = (lab >= 0) & (lab != gnd)
ok &= ndimage.distance_transform_edt(~other) * G >= clr + d / 2
oh = other & B.hs[li]
ok &= ndimage.distance_transform_edt(~oh) * G >= clr_hs + d / 2
ok &= ~B.block_via[li]
# keep top/bottom copper out of the 2.75 mm long-edge strips (OCP: component and trace free)
ok &= (B.X > 3.0) & (B.X < 73.0)
return ok
# ---------------------------------------------------------------- probe-pad stubs (design/tp_stubs.json)
_st = os.path.join(ROOT, "design", "tp_stubs.json")
if os.path.exists(_st):
for t in json.load(open(_st))["tracks"]:
t = tuple(t); T.append(t); B.track(*t)
# ---------------------------------------------------------------- GND fan-out for SMD ground pads
fan = 0
FAN_T, FAN_V = [], []
FIXED_NETS = {t[1] for t in ls.get("fixed_tracks", [])} | {v[2] for v in ls.get("fixed_vias", [])}
def fan_pad(p):
vok = via_ok_mask(0.25, 0.32)
cx, cy = p["x"], p["y"]
j0, i0 = int(round(cy / G)), int(round(cx / G))
r = int(2.5 / G)
jj, ii = np.nonzero(vok[max(0, j0 - r):j0 + r + 1, max(0, i0 - r):i0 + r + 1])
goal = {(LI["L1"], int(j + max(0, j0 - r)), int(i + max(0, i0 - r))) for j, i in zip(jj, ii)}
x0, y0, x1, y1 = p["geom"].bounds
from shapely import contains_xy
sj0, sj1, si0, si1 = int(y0 / G), int(y1 / G) + 1, int(x0 / G), int(x1 / G) + 1
sub = contains_xy(p["geom"].buffer(-0.02), B.X[sj0:sj1 + 1, si0:si1 + 1], B.Y[sj0:sj1 + 1, si0:si1 + 1])
sjs, sis = np.nonzero(sub)
start = {(LI["L1"], int(j + sj0), int(i + si0)) for j, i in zip(sjs, sis)}
if not goal or not start:
return False
paths, msg = maze.route_net(B, "GND", [start, goal], w=0.25, clr=0.2, clr_hs=0.32, allow=("L1",), max_expand=400000)
if paths is None:
# no via site nearby: a short top-layer trace to GND copper already there (another GND pad, a fan-out)
own = p["geom"].buffer(0.25)
gj, gi = np.nonzero(B.lab[LI["L1"], max(0, j0 - r):j0 + r + 1, max(0, i0 - r):i0 + r + 1] == gnd)
goal2 = {(LI["L1"], int(j + max(0, j0 - r)), int(i + max(0, i0 - r))) for j, i in zip(gj, gi)}
goal2 = {c for c in goal2 if not own.contains(Point(c[2] * G, c[1] * G))}
if goal2:
paths, msg = maze.route_net(B, "GND", [start, goal2], w=0.2, clr=0.15, clr_hs=0.3, allow=("L1",), max_expand=400000)
if paths is not None:
tr, _ = maze.path_to_copper(paths[0], "GND", 0.2)
for t in tr:
T.append(t); B.track(*t); FAN_T.append(t)
print("GND trace to nearby GND copper for", p["ref"], p["num"])
return True
if paths is None:
# no room beside the pad: a filled, capped via in the pad (JLC 6-layer POFV, as at the OCuLink pads)
d_ip, h_ip = 0.3, 0.15
vin = ~holes_mask(0.35)
for li in range(4):
other = (B.lab[li] >= 0) & (B.lab[li] != gnd)
vin &= ndimage.distance_transform_edt(~other) * G >= 0.15 + d_ip / 2
oh = other & B.hs[li]
vin &= ndimage.distance_transform_edt(~oh) * G >= 0.28 + d_ip / 2
cand = [(math.hypot(c[2] * G - cx, c[1] * G - cy), c) for c in start if vin[c[1], c[2]]
and p["geom"].buffer(-0.03).contains(Point(c[2] * G, c[1] * G).buffer(d_ip / 2))]
if not cand:
cand = [(math.hypot(c[2] * G - cx, c[1] * G - cy), c) for c in start if vin[c[1], c[2]]]
if not cand:
return False
c = min(cand)[1]
v = (round(c[2] * G, 4), round(c[1] * G, 4), "GND", d_ip, h_ip)
V.append(v); B.via(v[0], v[1], "GND", d_ip); FAN_V.append(v)
print("GND via-in-pad for", p["ref"], p["num"], v[:2])
return True
tr, _ = maze.path_to_copper(paths[0], "GND", 0.25)
end = paths[0][-1]
for t in tr:
T.append(t); B.track(*t); FAN_T.append(t)
v = (round(end[2] * G, 4), round(end[1] * G, 4), "GND", maze.VIA_D, maze.VIA_H)
V.append(v); B.via(v[0], v[1], "GND", v[3]); FAN_V.append(v)
return True
def unpaint(net):
nid = B.nid(net)
B.lab[B.lab == nid] = -1
for q in R.pads:
if q["net"] == net:
B.paint_geom(q["lis"], q["geom"], nid)
def rip_and_fan(p):
"""Last resort for a boxed-in GND pad: rip the low-speed nets crowding it on L1, fan the pad out, then
route those nets again around the new copper (restoring them as they were if that fails)."""
near = p["geom"].buffer(0.7)
x0, y0, x1, y1 = near.bounds
j0, j1, i0, i1 = int(y0 / G), int(y1 / G) + 1, int(x0 / G), int(x1 / G) + 1
ids = set(np.unique(B.lab[:, j0:j1 + 1, i0:i1 + 1])) - {-1, gnd}
rips = sorted(B.netname[i] for i in ids if B.netname[i] not in FIXED_NETS and not B.netname[i].startswith(("+", "PE", "REFCLK")))
if not rips:
return False
saved = {n: ([t for t in T if t[1] == n], [v for v in V if v[2] == n]) for n in rips}
for n in rips:
T[:] = [t for t in T if t[1] != n]; V[:] = [v for v in V if v[2] != n]
unpaint(n)
nT, nV = len(FAN_T), len(FAN_V)
ok = fan_pad(p)
if ok:
newc = {}
for n in rips:
w, allow = {r_[0]: (r_[1], r_[2]) for r_ in R.ROUTE}.get(n, (0.15, ("L1", "L3", "L5")))
paths, msg = maze.route_net(B, n, R.terminals(n), w=w, clr=R.CLR, clr_hs=R.CLR_HS, allow=allow, l6_region=R.L6_REGION)
if paths is None:
ok = False; print(" reroute failed:", n, msg); break
tr, vi = [], []
for pth in paths:
a_, b_ = maze.path_to_copper(pth, n, w); tr += a_; vi += b_
for t in tr: B.track(*t)
for v in vi: B.via(v[0], v[1], n, v[3])
newc[n] = (tr, vi)
if ok:
for n, (tr, vi) in newc.items():
T.extend(tr); V.extend(vi)
print(" ripped and re-routed", rips, "for", p["ref"], p["num"])
return True
# undo: drop the fan-out and the partial reroutes, restore the old copper
drop_t, drop_v = set(map(tuple, FAN_T[nT:])), set(map(tuple, FAN_V[nV:]))
del FAN_T[nT:]; del FAN_V[nV:]
T[:] = [t for t in T if tuple(t) not in drop_t]; V[:] = [v for v in V if tuple(v) not in drop_v]
unpaint("GND")
for t in T:
if t[1] == "GND": B.track(*t)
for v in V:
if v[2] == "GND": B.via(v[0], v[1], "GND", v[3])
for n in rips:
unpaint(n)
T[:] = [t for t in T if t[1] != n]; V[:] = [v for v in V if v[2] != n]
tr, vi = saved[n]
T.extend(tr); V.extend(vi)
for t in tr: B.track(*t)
for v in vi: B.via(v[0], v[1], n, v[3])
return False
for p in R.pads:
if p["net"] != "GND" or p["ref"] in ("J1", "J2", "J3", "J4", "J5", "H1", "H2") or p["drill"][0] > 0:
continue
if fan_pad(p) or rip_and_fan(p):
fan += 1
else:
print("no GND fan-out for", p["ref"], p["num"])
print("GND fan-out vias:", fan)
# ---------------------------------------------------------------- return vias beside every high-speed via pair
vok = via_ok_mask(0.25, 0.3)
ret = 0
hsv = [v for v in V if v[2] != "GND" and (v[2].startswith(("PET", "PER", "REFCLK")))]
taken = []
for (x, y, net, d, h) in hsv:
# look for a GND via spot within 1.1 mm, prefer the closest, at most one per signal via
j0, i0 = int(round(y / G)), int(round(x / G))
cands = []
for dj in range(-11, 12):
for di in range(-11, 12):
j, i = j0 + dj, i0 + di
if 0 <= j < NY and 0 <= i < NX and vok[j, i]:
rr = math.hypot(dj, di) * G
if 0.55 <= rr <= 1.1:
cands.append((rr, j, i))
cands.sort()
for rr, j, i in cands:
if all(math.hypot(i * G - a, j * G - b) > 0.65 for (a, b) in taken):
v = (i * G, j * G, "GND", maze.VIA_D, maze.VIA_H)
V.append(v); B.via(i * G, j * G, "GND", maze.VIA_D)
taken.append((i * G, j * G)); ret += 1
vok = None
break
if vok is None:
vok = via_ok_mask(0.25, 0.3)
print("return vias:", ret)
# ---------------------------------------------------------------- stitching grid
vok = via_ok_mask(0.25, 0.35)
st = 0
pitch = 2.0
for yy in np.arange(1.2, 112.0, pitch):
for xx in np.arange(3.2, 73.0, pitch):
j, i = int(round(yy / G)), int(round(xx / G))
if vok[j, i]:
V.append((round(xx, 3), round(yy, 3), "GND", maze.VIA_D, maze.VIA_H)); st += 1
# keep the mask honest for the next candidates
r = int(0.8 / G)
vok[max(0, j - r):j + r + 1, max(0, i - r):i + r + 1] = False
print("stitching vias:", st)
# ---------------------------------------------------------------- pours
outline = Polygon(R.lay["outline"])
outer = outline.buffer(-0.3).intersection(box(2.75, 0.3, 76 - 2.75, 111.95))
inner = outline.buffer(-1.0).intersection(box(1.0, 0.3, 75.0, 113.6))
def poly_pts(g):
if g.geom_type == "MultiPolygon":
g = max(g.geoms, key=lambda q: q.area)
return [(round(x, 4), round(y, 4)) for (x, y) in list(g.exterior.coords)[:-1]]
def prune_dangling():
"""Drop signal stubs that end nowhere (a fixed bus start the maze tapped further along): a via touching
fewer than two tracks and no pad, and a track whose free end touches no pad, via or other track."""
from shapely.geometry import LineString
padg = {}
for p in R.pads:
padg.setdefault(p["net"], []).append(p["geom"])
removed = 0
while True:
changed = False
for net in sorted({t[1] for t in T} - {"GND"}):
tr = [t for t in T if t[1] == net]
vi = [v for v in V if v[2] == net]
pg = padg.get(net, [])
def on_track(x, y, layer, skip):
pt = Point(x, y)
return any(t is not skip and t[0] == layer and
LineString([(t[2], t[3]), (t[4], t[5])]).distance(pt) <= (t[6] + skip[6]) / 2 - 0.005 for t in tr)
def at_via(x, y, w):
return any(math.hypot(v[0] - x, v[1] - y) <= (v[3] + w) / 2 - 0.005 for v in vi)
def in_pad(x, y, r=0.0):
return any(g.distance(Point(x, y)) <= max(r - 0.005, 1e-3) for g in pg)
for v in vi:
n = sum(1 for t in tr if LineString([(t[2], t[3]), (t[4], t[5])]).distance(Point(v[0], v[1])) <= (v[3] + t[6]) / 2 - 0.005)
if n < 2 and not in_pad(v[0], v[1], v[3] / 2):
V.remove(v); removed += 1; changed = True
vi = [v for v in V if v[2] == net]
for t in tr:
for end in (0, 1):
x, y = (t[4], t[5]) if end else (t[2], t[3])
if in_pad(x, y, t[6] / 2) or at_via(x, y, t[6]) or on_track(x, y, t[0], t):
continue
# free end: cut the segment back to the last tap along it (another track's end, a via or
# a pad touching it), or drop it when nothing touches it but its other end
sx, sy = (t[2], t[3]) if end else (t[4], t[5])
L = math.hypot(x - sx, y - sy)
seg = LineString([(sx, sy), (x, y)])
us = []
for o in tr:
if o is t or o[0] != t[0]:
continue
for (ox, oy) in ((o[2], o[3]), (o[4], o[5])):
if seg.distance(Point(ox, oy)) <= (o[6] + t[6]) / 2 - 0.005:
us.append(seg.project(Point(ox, oy)))
for v in vi:
if seg.distance(Point(v[0], v[1])) <= (v[3] + t[6]) / 2 - 0.005:
us.append(seg.project(Point(v[0], v[1])))
for g in pg:
if g.distance(seg) <= t[6] / 2 - 0.005:
us.append(seg.project(g.centroid))
us = [u for u in us if 1e-3 < u < L - 1e-3]
T.remove(t); removed += 1; changed = True
if us:
u = max(us)
nx, ny = sx + (x - sx) * u / L, sy + (y - sy) * u / L
nt = (t[0], t[1], round(sx, 4), round(sy, 4), round(nx, 4), round(ny, 4), t[6])
T.append(nt)
break
if not changed:
return removed
print("dangling stubs pruned:", prune_dangling())
zones = []
for layer, g, clr in (("L1", outer, 0.35), ("L6", outer, 0.35), ("L3", inner, 0.3), ("L5", inner, 0.3),
("L2", inner, 0.25), ("L4", inner, 0.25)):
zones.append({"layer": layer, "net": "GND", "poly": poly_pts(g), "clearance": clr, "priority": 0,
"thermal": False})
texts = [
{"text": "OCP NIC 3.0 SFF -> 4x OCuLink 4i", "x": 38.0, "y": 104.6, "layer": "F.SilkS", "size": 1.0},
]
json.dump({"tracks": T, "vias": V, "zones": zones, "texts": [], "fanout_tracks": FAN_T, "fanout_vias": FAN_V, "stats": {"fanout": fan, "return": ret, "stitch": st}},
open(os.path.join(ROOT, "design", "routes.json"), "w"))
print("routes.json:", len(T), "tracks,", len(V), "vias,", len(zones), "zones")