Files
Plasma-DMD/hardware/kicad/layout_pcb.py
T
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

276 lines
10 KiB
Python

"""Place footprints, draw the outline and silkscreen for the PSU carrier board.
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/layout_pcb.py
Coordinates below are board-local mm (origin = top-left corner of the board, y down).
Positions are the footprint origin (pad 1). Axial parts run left to right, pad 1 on the left.
"""
from pathlib import Path
import pcbnew
HERE = Path(__file__).parent
BOARD = HERE / "plasma_dmd_psu.kicad_pcb"
OX, OY = 100.0, 50.0 # board origin on the page
W, H = 170.0, 125.0 # board size
HV_X = 61.0 # LV | HV boundary (silkscreen)
# ref: (x, y, rotation_deg)
PLACE = {
# Phoenix MKDS terminals: wire entry is on the footprint's +y side, so top-edge blocks are
# rotated 180 (entry up) and bottom-edge blocks stay at 0 (entry down).
# ---- low-voltage side (left) -------------------------------------------------
"J1": (16.08, 6.5, 180), # 12 V in
"J3": (39.24, 6.5, 180), # Mini560
"J8": (57.16, 6.5, 180), # ESP32
"F1": (8.0, 22.0, 0),
"D1": (8.0, 31.0, 0),
"C1": (28.0, 35.0, 0),
"C2": (40.0, 22.0, 0),
"C3": (37.0, 34.0, 0),
"D3": (37.0, 41.0, 0),
"D2": (37.0, 47.0, 0),
"C4": (50.0, 41.0, 0),
"R1": (37.0, 53.0, 0),
"Q1": (44.0, 60.0, 0),
"JP1": (50.0, 72.0, 0),
"F2": (8.0, 76.0, 0),
"F3": (8.0, 90.0, 0),
"J4": (11.0, 118.5, 0), # relay module
"J6": (40.0, 118.5, 0), # isolated DC-DC input
# ---- positive HV (top right) -------------------------------------------------
"J5": (76.16, 6.5, 180), # NCH6100HV #1
"F4": (66.0, 20.0, 0),
"C5": (96.0, 20.0, 0),
"R2": (110.0, 20.0, 0),
"R3": (127.0, 20.0, 0),
"LED1": (148.0, 20.0, 0),
"D4": (68.0, 32.0, 0),
"R5": (80.0, 32.0, 0),
"R4": (102.0, 32.0, 0),
"Q2": (117.0, 33.0, 0),
"D5": (127.0, 32.0, 0),
"Q3": (81.0, 52.0, 0), # heatsink space above (tab side)
"C6": (98.0, 50.0, 0),
"R6": (119.0, 50.0, 0),
# ---- panel connector: on the LV/HV boundary, LV pins 4-7 face left, HV pins 1,2,8 right
"J2": (61.0, 78.0, 90),
# ---- negative HV (bottom right) ----------------------------------------------
"Q5": (81.0, 70.0, 0), # heatsink space above (tab side)
"Q6": (99.0, 70.0, 0),
"R13": (114.0, 62.0, 0), # 5 W, runs warm: keep clear of caps
"D7": (74.0, 83.5, 0),
"D8": (90.0, 83.0, 0),
"R12": (106.0, 83.0, 0),
"C8": (122.0, 83.0, 0),
"D6": (68.0, 93.0, 0),
"R10": (82.0, 93.0, 0),
"R9": (105.0, 93.0, 0),
"Q4": (120.0, 94.0, 0),
"R11": (130.0, 93.0, 0),
"C9": (68.0, 103.0, 0),
"C7": (92.0, 105.0, 0),
"R7": (106.0, 103.0, 0),
"R8": (122.0, 103.0, 0),
"LED2": (144.0, 103.0, 0),
"F5": (120.0, 113.5, 0),
"J7": (70.0, 118.5, 0), # NCH6100HV #2 output
# ---- heatsinks: see heatsink_for() ------------------------------------------------
# ---- mounting holes ------------------------------------------------------------
"H1": (4.0, 4.0, 0),
"H2": (W - 4.0, 4.0, 0),
"H3": (4.0, H - 4.0, 0),
"H4": (W - 4.0, H - 4.0, 0),
}
HEATSINKS = {"HS1": "Q3", "HS2": "Q5", "HS3": "Q6"}
def heatsink_for(q):
"""Aavid 576802B03900G on a TO-220-3_Vertical at rotation 0 (tab towards -y)."""
x, y, _ = PLACE[q]
return (x + 2.54, y - 3.6, 270)
for _hs, _q in HEATSINKS.items():
PLACE[_hs] = heatsink_for(_q)
SILK = [
# (text, x, y, size, layer)
("PLASMA DMD PSU rev 0.1", 24.0, 102.0, 1.5, "F.SilkS"),
("J1 12V IN", 13.5, 13.6, 1.0, "F.SilkS"),
("J3 MINI560", 31.6, 13.6, 1.0, "F.SilkS"),
("J8 ESP32", 52.1, 13.6, 1.0, "F.SilkS"),
("J5 HV MOD 1", 68.0, 13.6, 1.0, "F.SilkS"),
("J4 RELAY MODULE", 21.2, 111.2, 1.0, "F.SilkS"),
("J6 ISO DC-DC IN", 41.5, 111.2, 1.0, "F.SilkS"),
("J7 HV MOD 2 OUT", 72.5, 111.2, 1.0, "F.SilkS"),
("J2 PANEL", 61.0, 44.0, 1.0, "F.SilkS"),
("DANGER", 158.0, 38.0, 2.0, "F.SilkS"),
("HIGH VOLTAGE", 158.0, 42.0, 1.6, "F.SilkS"),
("up to 220V", 158.0, 45.5, 1.2, "F.SilkS"),
("DANGER HIGH VOLTAGE", 152.0, 70.0, 1.6, "B.SilkS"),
]
def mm(v):
return pcbnew.FromMM(v)
def pt(x, y):
return pcbnew.VECTOR2I(mm(OX + x), mm(OY + y))
def lib_dir(lib):
"""Project library (plasma_dmd.pretty next to the board) or a KiCad standard library."""
local = HERE / f"{lib}.pretty"
return local if local.exists() else FP_DIR / f"{lib}.pretty"
FP_DIR = Path(pcbnew.__file__).parents[3] / "share/kicad/footprints" # <KiCad>/10.0/share/...
def schematic_footprints():
"""ref -> footprint lib id, read from the schematic."""
import re
text = (HERE / "plasma_dmd_psu.kicad_sch").read_text(encoding="utf-8")
out = {}
for m in re.finditer(r'\(property "Reference" "([^"]+)".*?\(property "Footprint" "([^"]*)"', text, re.S):
out[m.group(1)] = m.group(2)
return out
def sync_footprints(board):
"""Swap footprints whose library id no longer matches the schematic (keeps ref, value, nets, link)."""
wanted = schematic_footprints()
for old in list(board.GetFootprints()):
ref = old.GetReference()
fpid = wanted.get(ref)
if not fpid or fpid == old.GetFPIDAsString():
continue
lib, name = fpid.split(":")
new = pcbnew.FootprintLoad(str(lib_dir(lib)), name)
new.SetFPID(pcbnew.LIB_ID(lib, name))
new.SetReference(ref)
new.SetValue(old.GetValue())
new.SetPath(old.GetPath())
new.SetSheetname(old.GetSheetname())
new.SetSheetfile(old.GetSheetfile())
nets = {p.GetNumber(): p.GetNet() for p in old.Pads()}
board.Add(new)
for p in new.Pads():
if p.GetNumber() in nets:
p.SetNet(nets[p.GetNumber()])
new.SetPosition(old.GetPosition())
board.Delete(old)
print("swapped", ref, "->", fpid)
def add_missing_footprints(board):
"""Add footprints for schematic symbols that are not on the board yet (like Update PCB, F8)."""
import gen_kicad
_, uuids = gen_kicad.existing_uuids()
present = {f.GetReference() for f in board.GetFootprints()}
for ref, lib_id, value, fpid, conns, _g in gen_kicad.PARTS:
if ref in present:
continue
lib, name = fpid.split(":")
fp = pcbnew.FootprintLoad(str(lib_dir(lib)), name)
fp.SetFPID(pcbnew.LIB_ID(lib, name))
fp.SetReference(ref)
fp.SetValue(value)
fp.SetPath(pcbnew.KIID_PATH("/" + uuids[ref]))
fp.SetSheetname("/")
fp.SetSheetfile("plasma_dmd_psu.kicad_sch")
board.Add(fp)
for pad in fp.Pads():
net = conns.get(pad.GetNumber())
if net:
pad.SetNet(board.FindNet("/" + net))
print("added", ref, "->", fpid)
def main():
board = pcbnew.LoadBoard(str(BOARD))
add_missing_footprints(board)
sync_footprints(board)
# clean previous layout output (outline, our silkscreen, tracks, vias, zones)
for d in list(board.GetDrawings()):
if d.GetLayer() in (pcbnew.Edge_Cuts, pcbnew.F_SilkS, pcbnew.B_SilkS):
board.Delete(d)
for getter in (board.GetTracks, board.Zones):
try:
items = list(getter())
except TypeError: # SWIG returns a bare object when the list is empty
items = []
for it in items:
board.Delete(it)
fps = {f.GetReference(): f for f in board.GetFootprints()}
missing = set(fps) - set(PLACE)
extra = set(PLACE) - set(fps)
if missing or extra:
raise SystemExit(f"placement table mismatch: missing {missing}, unknown {extra}")
for ref, (x, y, rot) in PLACE.items():
f = fps[ref]
if f.IsFlipped():
f.Flip(f.GetPosition(), False)
f.SetOrientationDegrees(rot)
f.SetPosition(pt(x, y))
fixed = ref.startswith("J") or (ref.startswith("H") and ref[1:].isdigit())
f.SetLocked(fixed)
if fixed:
f.Reference().SetVisible(False) # connectors are labelled by the SILK texts
if ref in HEATSINKS.values():
f.Reference().SetPosition(pt(x + 2.54, y + 3.2)) # below the pins, clear of the heatsink pin
if ref in HEATSINKS:
f.Reference().SetPosition(pt(x + 9.5, y + 2.0)) # beside the heatsink
# board outline with 2 mm corner radius
r = 2.0
def seg(x1, y1, x2, y2):
s = pcbnew.PCB_SHAPE(board, pcbnew.SHAPE_T_SEGMENT)
s.SetStart(pt(x1, y1)); s.SetEnd(pt(x2, y2))
s.SetLayer(pcbnew.Edge_Cuts); s.SetWidth(mm(0.1)); board.Add(s)
def arc(cx, cy, sx, sy, ang):
a = pcbnew.PCB_SHAPE(board, pcbnew.SHAPE_T_ARC)
a.SetCenter(pt(cx, cy)); a.SetStart(pt(sx, sy)); a.SetArcAngleAndEnd(pcbnew.EDA_ANGLE(ang, pcbnew.DEGREES_T))
a.SetLayer(pcbnew.Edge_Cuts); a.SetWidth(mm(0.1)); board.Add(a)
seg(r, 0, W - r, 0); seg(W, r, W, H - r); seg(W - r, H, r, H); seg(0, H - r, 0, r)
arc(r, r, 0, r, 90); arc(W - r, r, W - r, 0, 90); arc(W - r, H - r, W, H - r, 90); arc(r, H - r, r, H, 90)
# HV boundary: dashed line on silkscreen (gap where the panel connector J2 straddles it)
y = 15.0
while y < H - 3:
if 42.0 < y < 84.0:
y += 3.5
continue
s = pcbnew.PCB_SHAPE(board, pcbnew.SHAPE_T_SEGMENT)
s.SetStart(pt(HV_X, y)); s.SetEnd(pt(HV_X, min(y + 2.0, H - 3)))
s.SetLayer(pcbnew.F_SilkS); s.SetWidth(mm(0.3)); board.Add(s)
y += 3.5
for text, x, ty, size, layer in SILK:
t = pcbnew.PCB_TEXT(board)
t.SetText(text); t.SetPosition(pt(x, ty))
t.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size))); t.SetTextThickness(mm(size * 0.15))
t.SetLayer(board.GetLayerID(layer))
if layer.startswith("B."):
t.SetMirrored(True)
board.Add(t)
board.Save(str(BOARD))
# report pad positions of rotated connectors so orientation can be checked
for ref in ("J2", "J4", "J6", "J7"):
f = fps[ref]
print(ref, [(p.GetNumber(), round(pcbnew.ToMM(p.GetPosition().x) - OX, 2),
round(pcbnew.ToMM(p.GetPosition().y) - OY, 2)) for p in f.Pads()])
print("placed", len(PLACE), "footprints")
if __name__ == "__main__":
main()