- 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>
276 lines
10 KiB
Python
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()
|