Files
mangavalkandClaude Opus 5.5 cdf24e0b4b Heatsinks, wider 12 V tracks, design review; pause project
- PCB rev 0.1: Power3A class (1.5 mm) for the 12 V input path, Aavid
  clip-on heatsinks on Q3/Q5/Q6 (project footprint library), T-junction
  healing and via shrink in the router; DRC 0 errors
- Current-capacity and heatsink documentation
- CLAUDE.md with project decisions, workflow and gotchas
- docs/design-review.md: review of a module-only PSU (rev 0.2) and cost
  estimate
- Project paused for budget and safety reasons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:07:58 +02:00

262 lines
10 KiB
Python

"""Autoroute the PSU carrier board with Freerouting.
Run with KiCad's bundled Python (KiCad must not have the board open):
"C:/Users/patri/AppData/Local/Programs/KiCad/10.0/bin/python.exe" hardware/kicad/route_pcb.py
Exports a Specctra DSN, adds class-to-class clearance (HV <-> everything else 3 mm, matching
plasma_dmd_psu.kicad_dru, which Freerouting does not read), routes, and imports the session.
Existing tracks are removed first, so the result always comes from the current placement.
"""
import re
import subprocess
import sys
from pathlib import Path
import pcbnew
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
BOARD = HERE / "plasma_dmd_psu.kicad_pcb"
WORK = HERE / "route"
JAVA = Path("C:/Program Files/Eclipse Adoptium/jre-25.0.4.101-hotspot/bin/java.exe")
JAR = ROOT / "tools/freerouting/freerouting-2.5.0.jar"
PASSES = int(sys.argv[1]) if len(sys.argv) > 1 else 100
# Route with oversized tracks, then shrink them to the real width: every track then keeps
# (routing clearance + half the width difference) from its neighbours. This gets 3 mm HV <-> LV
# and 1.3 mm HV <-> HV while tracks can still squeeze out between transistor pins.
# class: (routing width um, routing clearance um, final width mm)
ROUTE = {"kicad_default": (1800, 250, 0.4), "Power": (2400, 300, 1.0), "Power3A": (2900, 300, 1.5),
"HV": (2100, 650, 0.6)}
VIA_ROUTE_UM, VIA_FINAL_MM, VIA_DRILL_MM = 2300, 1.0, 0.5 # same trick for vias
HV_TO_LV_UM = 2450 # + (2.1 - 0.6) / 2 after shrinking = 3.2 mm
def add_gnd_plane(board):
"""Bottom-layer GND pour over the whole board (custom rules keep it 3 mm from HV)."""
for z in list(board.Zones()):
board.Delete(z)
bb = board.GetBoardEdgesBoundingBox()
inset = pcbnew.FromMM(0.5)
x0, y0 = bb.GetX() + inset, bb.GetY() + inset
x1, y1 = bb.GetRight() - inset, bb.GetBottom() - inset
z = pcbnew.ZONE(board)
z.SetLayer(pcbnew.B_Cu)
z.SetNet(board.FindNet("/GND"))
z.SetLocalClearance(pcbnew.FromMM(0.5))
z.SetMinThickness(pcbnew.FromMM(0.5))
z.SetPadConnection(pcbnew.ZONE_CONNECTION_THERMAL)
z.SetThermalReliefGap(pcbnew.FromMM(0.5))
z.SetThermalReliefSpokeWidth(pcbnew.FromMM(0.8))
ol = z.Outline()
ol.NewOutline()
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
ol.Append(x, y)
board.Add(z)
def add_connector_stubs(board, ref="J2", length=5.0):
"""Fixed escape stubs on the panel connector: LV pins exit left, HV pins exit right.
Its 3.96 mm pitch is tighter than the router's HV <-> LV clearance, so the router could not
leave those pins by itself. GND pins connect to the bottom plane and need no stub.
"""
fp = board.FindFootprintByReference(ref)
# final widths: a router track must end on the stub copper itself, which still holds after shrinking
widths = {k: v[2] for k, v in ROUTE.items()}
for pad in fp.Pads():
net = pad.GetNet()
name = net.GetNetname() if net else ""
if not name or name == "/GND" or name.startswith("unconnected"):
continue
cls = pad.GetNetClassName()
sign = 1 if cls == "HV" else -1
p = pad.GetPosition()
t = pcbnew.PCB_TRACK(board)
t.SetStart(p)
t.SetEnd(pcbnew.VECTOR2I(p.x + sign * pcbnew.FromMM(length), p.y))
t.SetLayer(pcbnew.F_Cu)
t.SetWidth(pcbnew.FromMM(widths.get(cls, ROUTE["kicad_default"][2])))
t.SetNet(net)
t.SetLocked(True)
board.Add(t)
def _closest_on_segment(p, a, b):
ax, ay, bx, by = a.x, a.y, b.x, b.y
dx, dy = bx - ax, by - ay
L2 = dx * dx + dy * dy
u = 0.0 if L2 == 0 else max(0.0, min(1.0, ((p.x - ax) * dx + (p.y - ay) * dy) / L2))
cx, cy = ax + u * dx, ay + u * dy
return int(round(cx)), int(round(cy)), ((p.x - cx) ** 2 + (p.y - cy) ** 2) ** 0.5
def heal_t_junctions(board, reach_mm=1.6):
"""After shrinking, a track that joined another track mid-way (a T-junction) can end beside
the narrowed copper. Snap such dangling ends onto the centre line of the same-net track."""
traces = [t for t in board.GetTracks() if t.Type() == pcbnew.PCB_TRACE_T]
vias = [t for t in board.GetTracks() if t.Type() == pcbnew.PCB_VIA_T]
pads = [p for f in board.GetFootprints() for p in f.Pads()]
reach = pcbnew.FromMM(reach_mm)
fixed = 0
for t in traces:
for is_start in (True, False):
p = t.GetStart() if is_start else t.GetEnd()
net, layer = t.GetNetCode(), t.GetLayer()
if any(pd.GetNetCode() == net and pd.IsOnLayer(layer) and pd.HitTest(p) for pd in pads):
continue
if any(v.GetNetCode() == net and v.GetPosition() == p for v in vias):
continue
best, touching = None, False
for o in traces:
if o is t or o.GetNetCode() != net or o.GetLayer() != layer:
continue
if p in (o.GetStart(), o.GetEnd()):
touching = True
break
cx, cy, d = _closest_on_segment(p, o.GetStart(), o.GetEnd())
if d <= o.GetWidth() / 2:
touching = True
break
if d <= reach and (best is None or d < best[2]):
best = (cx, cy, d)
if touching or best is None:
continue
q = pcbnew.VECTOR2I(best[0], best[1])
if is_start:
t.SetStart(q)
else:
t.SetEnd(q)
fixed += 1
return fixed
def unconnected(board):
board.BuildConnectivity()
return board.GetConnectivity().GetUnconnectedCount(False)
def remove_dangling_stubs(board):
"""Drop duplicate escape stubs (the SES brings them back), then delete each stub whose
removal does not leave anything unconnected (the router may join a stub anywhere along it)."""
seen = set()
for t in [t for t in board.GetTracks() if t.Type() == pcbnew.PCB_TRACE_T and t.IsLocked()]:
key = (t.GetNetCode(), t.GetLayer(), t.GetStart().x, t.GetStart().y, t.GetEnd().x, t.GetEnd().y)
if key in seen:
board.Delete(t)
seen.add(key)
base = unconnected(board)
for t in [t for t in board.GetTracks() if t.Type() == pcbnew.PCB_TRACE_T and t.IsLocked()]:
s, e = t.GetStart(), t.GetEnd() # copy values: t is freed by Delete
keep = (s.x, s.y, e.x, e.y, t.GetLayer(), t.GetWidth(), t.GetNetCode())
board.Delete(t)
if unconnected(board) > base: # it was needed: put it back
n = pcbnew.PCB_TRACK(board)
n.SetStart(pcbnew.VECTOR2I(keep[0], keep[1])); n.SetEnd(pcbnew.VECTOR2I(keep[2], keep[3]))
n.SetLayer(keep[4]); n.SetWidth(keep[5])
board.Add(n)
n.SetNet(board.FindNet(keep[6])); n.SetLocked(True)
def patch_via_padstacks(text):
"""Enlarge the via pads in the DSN library (vias are set back to their real size after import)."""
out = []
in_via = False
for line in text.splitlines(keepends=True):
if line.lstrip().startswith('(padstack "Via'):
in_via = True
elif in_via and line.lstrip().startswith("(shape (circle"):
line = re.sub(r"(\(circle \S+ )[\d.]+", lambda m: m.group(1) + str(VIA_ROUTE_UM), line)
elif in_via and line.strip() == ")":
in_via = False
out.append(line)
return "".join(out)
def patch_class_rules(text):
"""Replace each class's width/clearance in the DSN with the routing values."""
out, pos = [], 0
for m in re.finditer(r"\(class (\S+) ", text):
name = m.group(1)
start = m.start()
rule = text.index("(rule", start)
end = text.index(")", text.index("(clearance", rule)) + 1
width, clear, _ = ROUTE[name]
block = text[rule:end]
block = re.sub(r"\(width [\d.]+\)", f"(width {width})", block)
block = re.sub(r"\(clearance [\d.]+\)", f"(clearance {clear})", block, count=1)
out.append(text[pos:rule] + block)
pos = end
out.append(text[pos:])
return "".join(out)
def shrink_tracks(board):
final = {("Default" if k == "kicad_default" else k): pcbnew.FromMM(v[2]) for k, v in ROUTE.items()}
for t in board.GetTracks():
if t.Type() == pcbnew.PCB_VIA_T:
t.SetWidth(pcbnew.FromMM(VIA_FINAL_MM))
t.SetDrill(pcbnew.FromMM(VIA_DRILL_MM))
elif t.Type() == pcbnew.PCB_TRACE_T:
name = t.GetNetClassName()
t.SetWidth(final.get(name, final["Default"]))
def main():
WORK.mkdir(exist_ok=True)
dsn, ses = WORK / "board.dsn", WORK / "board.ses"
board = pcbnew.LoadBoard(str(BOARD))
try:
tracks = list(board.GetTracks())
except TypeError:
tracks = []
for t in tracks:
board.Delete(t)
for z in list(board.Zones()):
board.Delete(z) # GND is routed as tracks; the pour is added afterwards
add_connector_stubs(board)
if not pcbnew.ExportSpecctraDSN(board, str(dsn)):
raise SystemExit("DSN export failed")
text = dsn.read_text(encoding="utf-8")
classes = re.findall(r"\(class (\S+) ", text)
text = patch_class_rules(text)
text = patch_via_padstacks(text)
extra = "".join(
f" (class_class (classes HV {c})\n (rule (clearance {HV_TO_LV_UM}))\n )\n"
for c in classes if c != "HV")
# insert after the last (class ...) block inside (network ...)
last = text.rfind(" (class ")
depth, i = 0, last
while True:
if text[i] == "(":
depth += 1
elif text[i] == ")":
depth -= 1
if depth == 0:
break
i += 1
text = text[:i + 1] + "\n" + extra + text[i + 1:]
dsn.write_text(text, encoding="utf-8")
ses.unlink(missing_ok=True)
subprocess.run([str(JAVA), "-jar", str(JAR), "-de", str(dsn), "-do", str(ses),
"-mp", str(PASSES), "-l", "en"], check=True, cwd=WORK) # logs stay in route/
if not ses.exists():
raise SystemExit("Freerouting produced no session file")
if not pcbnew.ImportSpecctraSES(board, str(ses)):
raise SystemExit("SES import failed")
shrink_tracks(board)
print("healed T-junctions:", heal_t_junctions(board))
remove_dangling_stubs(board)
add_gnd_plane(board)
pcbnew.ZONE_FILLER(board).Fill(board.Zones())
board.Save(str(BOARD))
print("routed and saved", BOARD.name)
if __name__ == "__main__":
main()