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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
#!/usr/bin/env python3
"""Post-route copper widening for the high-current nets, clearance-verified.
The problem this solves
-----------------------
The router picks one width per link and caps it at the narrowest pad the link
lands on:
cap = max(MIN_W, min(a["narrow"], b["narrow"]))
eff_w = min(width_for(net), cap)
That cap is correct AT THE PAD -- a 1.2mm trace cannot land on a 0.4mm pin
without bridging its neighbours -- but the router then applies it to the whole
run. So `/PANEL_PROT`, a 5A net whose width policy asks for 1.2mm, came out at
0.4mm along its entire length because somewhere on the chain it touches a
small pad. At 35um copper that is 0.014mm^2 of section carrying 5A: ~357
A/mm^2, against the ~120 A/mm^2 the electrothermal model already flagged as
this board's worst case.
The fix
-------
Width is a per-segment property, not a per-net one. This pass walks each
segment of each high-current net and grows it as far as the surrounding copper
actually allows, verified against exact geometry (geomcheck, no raster), then
writes the new widths back. Segments in open board area reach full policy
width; the short segments at the pads stay necked down, which is exactly where
the narrow cap was legitimate in the first place.
Nothing moves. Only widths change, and only when the widened shape has been
proven to still clear every different-net object around it.
"""
import math, re, sys, os
import geomcheck as G
PCB = ("/mnt/c/Users/kirkt/Documents/mppt-power-system-mcu/"
"mppt-power-system-mcu.kicad_pcb")
# the schematic's real per-net currents; width_for() is the ROUTER's target
from route2 import NET_CURRENT_A, width_for, CLEARANCE, MIN_W
# widths a fab will panelise happily; try widest first
LADDER = [2.5, 2.0, 1.6, 1.4, 1.2, 1.0, 0.8, 0.6, 0.5, 0.4, 0.3, 0.25, 0.2, 0.15]
def final_width(net):
"""The width this net's copper SHOULD be, once it is placed.
Deliberately more generous than the router's `width_for()`. Those are two
different jobs: the router's target has to be narrow enough that the link
actually routes at all, because a link that fails to route is worth far
less than a link that routes thin. This one is applied afterwards, when the
copper is already down and the surrounding geometry can be measured, and
every increase is individually verified -- so aiming high costs nothing
where there is no room and buys real section where there is.
Raising these targets changes no routing at all -- the router has already
run by this point -- so the only question each increase has to answer is
whether the copper around that one segment has room for it.
"""
a = NET_CURRENT_A.get(net, 0.0)
if a >= 5.0: return 2.5
if a >= 2.5: return 2.0
if a >= 2.0: return 1.6
if a >= 1.0: return 1.0
if a >= 0.5: return 0.8
return MIN_W
WIDEN_MARGIN = 0.1 # mm of headroom against checker-vs-DRC disagreement
def main(path=PCB, dry=False):
text = open(path, errors='replace').read()
objs = G.load_board(path)
idx = G.Index(objs)
targets = {n: final_width(n) for n in NET_CURRENT_A}
tracks = [o for o in objs if o.kind == "track"]
print(f"{len(tracks)} tracks, {len(targets)} current-carrying nets")
changes = {} # (x0,y0,x1,y1,layer,old_w) -> new_w
wid = {} # id(track object) -> its key in `changes`
grown = widened_mm = 0
per_net = {}
for o in tracks:
tgt = targets.get(o.net)
if tgt is None: continue
cur_w = o.r * 2
if cur_w >= tgt - 1e-9:
per_net.setdefault(o.net, []).append(cur_w)
continue
# how much room is there, really?
room = idx.min_gap(o, reach=max(2.0, tgt))
# Room is measured from the CURRENT edge, so the half-width may grow by
# (room - CLEARANCE) before the rule is violated -- plus a margin,
# because this measurement is a model and models are wrong somewhere.
# Widening right up to the limit put two pad-vs-track pairs 0.03-0.05mm
# inside KiCad's rule while this checker still read them as legal (it
# disagreed on an oval TO-220 pad by 0.0475mm). Spending 0.1mm of
# copper width to stay clear of the disagreement is a good trade.
allow = cur_w + 2 * (room - CLEARANCE - WIDEN_MARGIN)
best = cur_w
for w in LADDER:
if w > tgt or w <= cur_w: continue
if w <= allow + 1e-9:
best = w; break
if best > cur_w:
key = (o.ax, o.ay, o.bx, o.by, o.layers[0], cur_w)
changes[key] = best
wid[id(o)] = key
widened_mm += math.hypot(o.bx - o.ax, o.by - o.ay)
grown += 1
o.r = best / 2 # later segments see the new width
per_net.setdefault(o.net, []).append(best)
# ---- settle ----------------------------------------------------------
# Widening is order-dependent: segment A measures its room against B at
# B's CURRENT width, and if B is widened afterwards the pair ends up
# tighter than either measurement predicted. One pass duly produced two
# violations. Rather than try to predict every neighbour's final width,
# widen, look, and step the offenders back down until the board settles.
for settle in range(6):
bad = G.violations(objs)
hit = [(gap, o) for gap, rule, why, a, b in bad
for o in (a, b) if id(o) in wid]
if not hit:
if settle:
print(f" settled after {settle} shrink pass(es)")
break
for _gap, o in hit:
key = wid[id(o)]
cur = changes.get(key, o.r * 2)
lower = [w for w in LADDER if w < cur - 1e-9]
new_w = lower[0] if lower else None
if new_w is None or new_w <= key[5]:
changes.pop(key, None) # back to how it was routed
o.r = key[5] / 2
else:
changes[key] = new_w
o.r = new_w / 2
grown = len(changes)
else:
print(" WARNING: widening did not settle; leaving widths as routed")
changes = {}
print(f"\nwidened {grown} segments ({widened_mm:.1f}mm of copper)")
print(f"{'net':22s} {'target':>7s} {'min':>6s} {'median':>7s} {'max':>6s}")
for n in sorted(per_net, key=lambda k: -NET_CURRENT_A.get(k, 0)):
ws = sorted(per_net[n])
if not ws: continue
print(f"{n:22s} {targets[n]:7.2f} {ws[0]:6.2f} "
f"{ws[len(ws)//2]:7.2f} {ws[-1]:6.2f}")
if dry:
return 0
# ---- rewrite widths in place -----------------------------------------
pat = re.compile(
r'(\(segment\s+\(start ([-\d.]+) ([-\d.]+)\)\s+\(end ([-\d.]+) ([-\d.]+)\)'
r'\s+\(width )([\d.]+)(\)\s+\(layer "([^"]+)"\))')
hit = [0]
def sub(m):
key = (float(m.group(2)), float(m.group(3)),
float(m.group(4)), float(m.group(5)), m.group(8), float(m.group(6)))
if key in changes:
hit[0] += 1
return f"{m.group(1)}{changes[key]:g}{m.group(7)}"
return m.group(0)
out = pat.sub(sub, text)
print(f"\nrewrote {hit[0]} segment widths in the board file")
# hit[0] counts SUBSTITUTIONS, and several segments can share one key
# (same endpoints, layer and old width), so it legitimately exceeds the
# number of planned changes. Only a shortfall is a problem; the old test
# printed "-675 planned changes did not match -- widths left alone" on a
# run where every width had in fact been written.
if hit[0] < len(changes):
print(f" WARNING: {len(changes) - hit[0]} planned change(s) matched "
f"no segment text -- those widths were left alone")
open(path, "w").write(out)
# ---- prove it ---------------------------------------------------------
v = G.violations(G.load_board(path))
print(f"exact different-net violations after widening: {len(v)}")
return len(v)
if __name__ == "__main__":
sys.exit(main(dry="--dry" in sys.argv))