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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
#!/usr/bin/env python3
"""Write the manufacturing set from the finished board with kicad-cli, then re-read every file and check it against
the board: 6 copper Gerbers + mask/paste/silk/edge, Excellon drill (PTH and NPTH) whose hit count equals the board's
holes, a CPL whose references and rotations equal the board's placed parts, a BOM whose references cover them.
Writes fab/ (gerbers.zip, the loose files, bom.csv, cpl.csv) and fab/export-check.json; exits 1 on any mismatch."""
import csv, glob, json, os, re, subprocess, sys, zipfile
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
K = "/mnt/c/Users/john/AppData/Local/Programs/KiCad/10.0/bin"
BOARD = os.path.join(ROOT, "kicad", "ocp-oculink-riser.kicad_pcb")
FAB = os.path.join(ROOT, "fab")
G = os.path.join(FAB, "gerbers")
os.makedirs(G, exist_ok=True)
for f in glob.glob(os.path.join(G, "*")):
os.remove(f)
def wp(p):
return subprocess.run(["wslpath", "-w", p], capture_output=True, text=True).stdout.strip()
def cli(*args):
r = subprocess.run([f"{K}/kicad-cli.exe", *args], capture_output=True, text=True)
if r.returncode:
raise SystemExit(f"kicad-cli {' '.join(args[:3])} failed: {r.stdout}{r.stderr}")
return r.stdout
LAYERS = "F.Cu,In1.Cu,In2.Cu,In3.Cu,In4.Cu,B.Cu,F.Paste,B.Paste,F.Silkscreen,B.Silkscreen,F.Mask,B.Mask,Edge.Cuts"
cli("pcb", "export", "gerbers", "--output", wp(G) + "\\", "--layers", LAYERS, "--subtract-soldermask",
"--use-drill-file-origin", wp(BOARD))
cli("pcb", "export", "drill", "--output", wp(G) + "\\", "--format", "excellon", "--excellon-separate-th",
"--excellon-units", "mm", "--drill-origin", "plot", "--generate-map", "--map-format", "gerberx2", wp(BOARD))
pos = os.path.join(FAB, "pos-kicad.csv")
cli("pcb", "export", "pos", "--output", wp(pos), "--format", "csv", "--units", "mm", "--side", "both",
"--exclude-dnp", "--use-drill-file-origin", wp(BOARD))
check = {"board": "kicad/ocp-oculink-riser.kicad_pcb", "problems": []}
files = sorted(os.listdir(G))
check["files"] = files
# copper layers: six Gerbers carrying copper
cu = [f for f in files if re.search(r"(F_Cu|In[1-4]_Cu|B_Cu)\.g", f)]
check["copperGerbers"] = len(cu)
if len(cu) != 6:
check["problems"].append(f"expected 6 copper Gerbers, found {len(cu)}: {cu}")
for f in cu:
txt = open(os.path.join(G, f), errors="ignore").read()
if "D03*" not in txt and "D01*" not in txt:
check["problems"].append(f"{f} has no drawn copper")
# drill hits against the board's holes
txt_b = open(BOARD).read()
vias = len(re.findall(r"^\t\(via\b", txt_b, re.M))
pth_pads = sum(1 for m in re.finditer(r"\(pad \"[^\"]*\" thru_hole", txt_b))
npth_pads = sum(1 for m in re.finditer(r"\(pad \"[^\"]*\" np_thru_hole", txt_b))
hits = {"PTH": 0, "NPTH": 0}
for f in files:
if f.endswith(".drl"):
kind = "NPTH" if "NPTH" in f else "PTH"
hits[kind] += sum(1 for line in open(os.path.join(G, f)) if re.match(r"^X-?[\d.]+Y-?[\d.]+", line.strip()) or re.match(r"^G85", line.strip()))
check["drill"] = {"pthHits": hits["PTH"], "npthHits": hits["NPTH"], "boardVias": vias, "boardPthPads": pth_pads, "boardNpthPads": npth_pads}
if hits["PTH"] != vias + pth_pads:
check["problems"].append(f"PTH drill hits {hits['PTH']} != vias {vias} + plated pads {pth_pads}")
if hits["NPTH"] != npth_pads:
check["problems"].append(f"NPTH drill hits {hits['NPTH']} != NPTH pads {npth_pads}")
# CPL: JLC column names, every placed part, rotations as on the board
rows = list(csv.DictReader(open(pos)))
cpl = os.path.join(FAB, "cpl.csv")
with open(cpl, "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["Designator", "Val", "Package", "Mid X", "Mid Y", "Rotation", "Layer"])
for r in rows:
w.writerow([r["Ref"], r["Val"], r["Package"], r["PosX"], r["PosY"], r["Rot"], "Top" if r["Side"] == "top" else "Bottom"])
cpl_refs = {r["Ref"] for r in rows}
board_refs = set()
for m in re.finditer(r'\(footprint "([^"]+)".*?\(property "Reference" "([^"]+)"', txt_b, re.S):
board_refs.add(m.group(2))
excluded = {"J1", "H1", "H2", "JP1"} | {r for r in board_refs if r.startswith("TP")}
placed = board_refs - excluded
check["cpl"] = {"rows": len(rows), "missing": sorted(placed - cpl_refs), "extra": sorted(cpl_refs - placed)}
if placed - cpl_refs or cpl_refs - placed:
check["problems"].append(f"CPL refs differ from placed parts: missing {sorted(placed - cpl_refs)} extra {sorted(cpl_refs - placed)}")
# BOM: the design BOM, purchased parts only, grouped by MPN
bom_rows = [r for r in csv.DictReader(open(os.path.join(ROOT, "design", "bom.csv"))) if r["mpn"] not in ("no part", "")]
bom_refs = {r["ref"] for r in bom_rows}
check["bom"] = {"lines": len(bom_rows), "missing": sorted(placed - bom_refs), "extra": sorted(bom_refs - placed)}
if placed - bom_refs:
check["problems"].append(f"BOM lacks placed parts: {sorted(placed - bom_refs)}")
groups = {}
for r in bom_rows:
g = groups.setdefault(r["mpn"], dict(r, refs=[]))
g["refs"].append(r["ref"])
with open(os.path.join(FAB, "bom.csv"), "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["Comment", "Designator", "Footprint", "Qty", "Manufacturer", "MPN", "Mouser", "Stock (checked 2026-10-12)"])
for mpn, g in sorted(groups.items(), key=lambda kv: kv[1]["refs"][0]):
w.writerow([g["value"], ",".join(sorted(g["refs"], key=lambda s: (s[0], int(re.sub(r'\D', '', s) or 0)))), g["package"], len(g["refs"]),
g["manufacturer"], mpn, g["vendor_pn"], g[[k for k in g if k.startswith("stock_checked")][0]]])
# zip the Gerbers and drills for upload
with zipfile.ZipFile(os.path.join(FAB, "gerbers.zip"), "w", zipfile.ZIP_DEFLATED) as z:
for f in files:
z.write(os.path.join(G, f), f)
check["ok"] = not check["problems"]
json.dump(check, open(os.path.join(FAB, "export-check.json"), "w"), indent=1)
print(json.dumps({k: check[k] for k in ("copperGerbers", "drill", "cpl", "bom", "problems", "ok")}, indent=1))
sys.exit(0 if check["ok"] else 1)