board
Solar MPPT Power System
Public Unreviewedby TylerK
A 30 W solar battery charger with regulated 5 V and 3.3 V rails: an STM32G031 running perturb & observe MPPT and a CC/CV charge state machine, steering a TI LM5175 4-switch buck-boost through a single analog control input. Four-layer KiCad board, schematic and PCB generated from Python, with an electrothermal simulation run against the published board file.
main
TylerK
Board update: 4 unconnected items (from 13), 0 electrical and 0 mechanical DRC findings, silkscreen findings 73 -> 10;…
337e32d
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
#!/usr/bin/env python3
"""Silkscreen legibility pass: place reference designators so they are readable.
The board generator drops each footprint's reference designator wherever its
library happens to put it -- typically centred on the part. On a board this
dense that lands 22 of them on top of exposed pads and 27 of them on top of
each other, which is 49 of the 73 cosmetic DRC findings and, more to the
point, a silkscreen an assembler cannot read.
This pass moves each reference to the nearest position that collides with
nothing: not with mask-opened copper (KiCad's silk_over_copper rule is about
pads, not tracks -- a track is under solder mask), not with another
footprint's silk outline, and not with a reference already placed. References
that have nowhere to go at all are moved to F.Fab rather than left overlapping
something; the assembly drawing still carries them, which is what a real
layout does in a dense cluster.
Board-level section labels are handled the same way, and dropped if they
cannot be placed -- a heading printed across a pin field is worse than no
heading.
Nothing electrical is touched. Only silkscreen geometry moves.
"""
import math, re, sys
from collections import defaultdict
import geomcheck as G
PCB = ("/mnt/c/Users/kirkt/Documents/mppt-power-system-mcu/"
"mppt-power-system-mcu.kicad_pcb")
SILK = "F.SilkS"
CLEAR = 0.15 # mm of air we want around silk
# --------------------------------------------------------------------------
# geometry helpers
# --------------------------------------------------------------------------
def text_extent(s, size_x, size_y, thickness=0.15):
"""Bounding box of KiCad stroke text, measured from real KiCad plots.
Calibrated rather than guessed: a 2-character string at size 1.0 plots
1.619mm wide and a 3-character one 2.620mm, i.e. 1.0mm per character less
a constant 0.38mm, with the stroke thickness already inside that.
"""
n = max(len(s), 1)
return ((n - 0.38) * size_x + 0.15, size_y + 0.15)
class Box:
"""An axis-aligned box in board coordinates."""
__slots__ = ("x0", "y0", "x1", "y1")
def __init__(self, cx, cy, w, h):
self.x0, self.y0 = cx - w / 2, cy - h / 2
self.x1, self.y1 = cx + w / 2, cy + h / 2
def hits(self, o, pad=0.0):
return not (self.x1 + pad < o.x0 or o.x1 + pad < self.x0 or
self.y1 + pad < o.y0 or o.y1 + pad < self.y0)
def seg_box(x0, y0, x1, y1, w):
return Box((x0 + x1) / 2, (y0 + y1) / 2,
abs(x1 - x0) + w, abs(y1 - y0) + w)
def fp_world(fx, fy, frot, lx, ly):
th = math.radians(frot)
ct, st = math.cos(th), math.sin(th)
return fx + lx * ct + ly * st, fy + ly * ct - lx * st
# --------------------------------------------------------------------------
# reading the board
# --------------------------------------------------------------------------
def collect(text):
"""(obstacles, refs, labels) -- everything the placer has to respect."""
obstacles = [] # Box, things silk must not touch
refs = [] # placeable reference designators
labels = [] # placeable board-level headings
for blk in G._blocks(text, 'footprint'):
m_ref = re.search(r'\(property "Reference" "([^"]*)"', blk)
m_at = re.search(r'\(layer "[^"]*"\)\s*(?:\([^()]*\)\s*)*?\(uuid "[^"]*"\)'
r'\s*(?:\([^()]*\)\s*)*?\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)',
blk)
if not m_at:
continue
fx, fy = float(m_at.group(1)), float(m_at.group(2))
frot = float(m_at.group(3) or 0)
ref = m_ref.group(1) if m_ref else "?"
# exposed copper: pads carry a mask opening, tracks do not
for pb in G._blocks(blk, 'pad'):
ms = re.search(r'\(size ([-\d.]+) ([-\d.]+)\)', pb)
ma = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', pb)
if not (ms and ma):
continue
pw, ph = float(ms.group(1)), float(ms.group(2))
prot = float(ma.group(3) or 0)
x, y = fp_world(fx, fy, frot, float(ma.group(1)), float(ma.group(2)))
# pad shape is oriented by its OWN angle; use the enclosing box
if abs((prot % 180) - 90) < 1e-6:
pw, ph = ph, pw
obstacles.append(Box(x, y, pw, ph))
# this footprint's own silk outline
for tag in ("fp_line", "fp_rect", "fp_poly", "fp_circle", "fp_arc"):
for gb in G._blocks(blk, tag):
if f'"{SILK}"' not in gb:
continue
pts = [(float(a), float(b)) for a, b in
re.findall(r'\((?:start|end|center|mid|xy) '
r'([-\d.]+) ([-\d.]+)\)', gb)]
if not pts:
continue
w = re.search(r'\(width ([\d.]+)\)', gb)
w = float(w.group(1)) if w else 0.12
wp = [fp_world(fx, fy, frot, a, b) for a, b in pts]
xs = [q[0] for q in wp]; ys = [q[1] for q in wp]
obstacles.append(Box((min(xs) + max(xs)) / 2,
(min(ys) + max(ys)) / 2,
max(xs) - min(xs) + w,
max(ys) - min(ys) + w))
# the reference designator itself
m_prop = re.search(r'\(property "Reference" "[^"]*"\s*\(at ([-\d.]+) '
r'([-\d.]+)(?: ([-\d.]+))?\)', blk)
m_layer = re.search(r'\(property "Reference" "[^"]*".*?\(layer "([^"]+)"\)',
blk, re.S)
m_size = re.search(r'\(property "Reference" "[^"]*".*?\(size ([\d.]+) ([\d.]+)\)',
blk, re.S)
if m_prop and m_layer and m_layer.group(1) == SILK:
sx = float(m_size.group(1)) if m_size else 1.0
sy = float(m_size.group(2)) if m_size else 1.0
refs.append({
"ref": ref, "fx": fx, "fy": fy, "frot": frot,
"lx": float(m_prop.group(1)), "ly": float(m_prop.group(2)),
"lrot": float(m_prop.group(3) or 0),
"sx": sx, "sy": sy,
"span": text_extent(ref, sx, sy),
})
# board-level headings
for gb in G._blocks(text, 'gr_text'):
if f'"{SILK}"' not in gb:
continue
m_t = re.match(r'\(gr_text "([^"]*)"', gb)
m_a = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', gb)
m_s = re.search(r'\(size ([\d.]+) ([\d.]+)\)', gb)
if not (m_t and m_a):
continue
sx = float(m_s.group(1)) if m_s else 1.0
sy = float(m_s.group(2)) if m_s else 1.0
labels.append({"text": m_t.group(1), "x": float(m_a.group(1)),
"y": float(m_a.group(2)), "rot": float(m_a.group(3) or 0),
"sx": sx, "sy": sy,
"span": text_extent(m_t.group(1), sx, sy), "raw": gb})
return obstacles, refs, labels
# --------------------------------------------------------------------------
# placement
# --------------------------------------------------------------------------
def place(obstacles, refs):
"""Greedy: hardest-to-place first, nearest clear slot wins."""
placed = []
moved = hidden = kept = 0
# a reference boxed in on all sides is hardest, so do the crowded ones
# first while there is still empty board to put them in
def crowding(r):
cx, cy = fp_world(r["fx"], r["fy"], r["frot"], r["lx"], r["ly"])
b = Box(cx, cy, 6, 6)
return -sum(1 for o in obstacles if b.hits(o))
order = sorted(refs, key=crowding)
for r in order:
w, h = r["span"]
# Where it already sits, first. Searching outward from the footprint
# centre without trying the current position moves every single
# reference even when it was perfectly readable where the library put
# it -- 106 of 106 on the first run, which is churn, not a fix.
cur_x, cur_y = fp_world(r["fx"], r["fy"], r["frot"], r["lx"], r["ly"])
here = Box(cur_x, cur_y, w, h)
if not any(here.hits(o, CLEAR) for o in obstacles) and \
not any(here.hits(o, CLEAR) for o in placed):
placed.append(here); kept += 1
continue
best = None
# rings outward from the part, cardinal points first (they read best)
for dist in [x * 0.25 for x in range(0, 25)]:
cands = []
for ang in (270, 90, 0, 180, 315, 45, 225, 135):
a = math.radians(ang)
cands.append((r["fx"] + math.cos(a) * dist,
r["fy"] + math.sin(a) * dist, 0.0))
for cx, cy, trot in cands:
bx = Box(cx, cy, w, h)
if any(bx.hits(o, CLEAR) for o in obstacles):
continue
if any(bx.hits(o, CLEAR) for o in placed):
continue
best = (cx, cy, trot)
break
if best:
break
if best is None:
r["hide"] = True
hidden += 1
continue
cx, cy, trot = best
# store back as a footprint-relative offset
th = math.radians(r["frot"])
dx, dy = cx - r["fx"], cy - r["fy"]
r["nlx"] = dx * math.cos(th) - dy * math.sin(th)
r["nly"] = dx * math.sin(th) + dy * math.cos(th)
if abs(r["nlx"] - r["lx"]) > 1e-3 or abs(r["nly"] - r["ly"]) > 1e-3:
moved += 1
else:
kept += 1
placed.append(Box(cx, cy, w, h))
return moved, kept, hidden, placed
def place_labels(obstacles, placed, labels):
"""Headings: nudge along the board, drop if there is nowhere legible."""
moved = dropped = kept = 0
for L in labels:
w, h = L["span"]
b = Box(L["x"], L["y"], w, h)
if not any(b.hits(o, CLEAR) for o in obstacles + placed):
kept += 1
continue
best = None
for dy in [d * 0.5 for d in range(1, 25)]:
for sgn in (-1, 1):
cy = L["y"] + sgn * dy
bb = Box(L["x"], cy, w, h)
if 3 + h / 2 < cy < 132 - h / 2 and \
not any(bb.hits(o, CLEAR) for o in obstacles + placed):
best = cy; break
if best is not None:
break
if best is None:
L["drop"] = True; dropped += 1
else:
L["ny"] = best; moved += 1
placed.append(Box(L["x"], best, w, h))
return moved, kept, dropped
# --------------------------------------------------------------------------
# writing back
# --------------------------------------------------------------------------
def apply(text, refs, labels):
out = text
for r in refs:
pat = re.compile(r'(\(property "Reference" "' + re.escape(r["ref"])
+ r'"\s*\(at )([-\d.]+ [-\d.]+(?: [-\d.]+)?)(\))')
if r.get("hide"):
# keep it on the assembly drawing, off the silkscreen
m = re.search(r'(\(property "Reference" "' + re.escape(r["ref"])
+ r'".*?\(layer ")F\.SilkS(")', out, re.S)
if m:
out = out[:m.start()] + m.group(1) + "F.Fab" + m.group(2) + out[m.end():]
continue
if "nlx" not in r:
continue
out = pat.sub(lambda m: f'{m.group(1)}{r["nlx"]:.3f} {r["nly"]:.3f}{m.group(3)}',
out, count=1)
for L in labels:
if L.get("drop"):
out = out.replace("\n\t" + L["raw"], "", 1)
out = out.replace(L["raw"], "", 1)
elif "ny" in L:
new = re.sub(r'(\(at [-\d.]+ )[-\d.]+', lambda m: m.group(1) + f'{L["ny"]:.3f}',
L["raw"], count=1)
out = out.replace(L["raw"], new, 1)
return out
def main(path=PCB):
text = open(path, errors='replace').read()
obstacles, refs, labels = collect(text)
print(f"{len(refs)} reference designators, {len(labels)} board labels, "
f"{len(obstacles)} silk obstacles (pads + footprint outlines)")
moved, kept, hidden, placed = place(obstacles, refs)
print(f"references: {moved} moved, {kept} already clear, "
f"{hidden} moved to F.Fab (nowhere legible on silk)")
lmoved, lkept, ldropped = place_labels(obstacles, placed, labels)
print(f"labels: {lmoved} moved, {lkept} already clear, {ldropped} dropped")
open(path, "w").write(apply(text, refs, labels))
print("written")
return 0
if __name__ == "__main__":
sys.exit(main())