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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
#!/usr/bin/env python3
"""Stitch every region of the top ground pour to the planes, and remove any
ground via that ended up too close to foreign copper.
Earlier versions of this pass reconstructed the pour the way KiCad fills it and
stitched the regions of that model. That is close but not the same thing --
KiCad also applies min_thickness, island removal and thermal relief -- and it
left two regions unstitched however many vias were thrown at the problem.
So this reads KiCad's ACTUAL fill. After `--refill-zones --save-board` the
board file carries the zone's `filled_polygon` blocks, one per region, and the
question becomes exact: for each region, is there a ground via inside it? A
region with no via is real ground copper with no path to the planes, which on a
switching board is return current with nowhere to go.
"""
import json, math, os, re, sys
import geomcheck as G
import route2 as R
# Segments to narrow so the ground pour can flow past them, as
# (x0, y0, x1, y1, layer, new_width). A pour region that has no room for a via
# is usually not short of area -- it is pinched somewhere, and the pinch is one
# track that got widened into the gap. Here a 0.60mm /VBATT run alongside U9
# squeezed the corridor past U9's ground pad to 0.22mm, under the zone's own
# 0.25mm min_thickness, so KiCad simply stopped filling and left that pad's
# copper as an island. Giving 0.35mm of width back on one 1.4mm segment opens
# it; the resistance that costs is immaterial next to a regulator whose ground
# pin is connected to nothing.
POUR_RELIEF = [
(130.05, 44.50, 130.05, 45.90, "F.Cu", 0.25),
]
def apply_relief(path):
text = open(path, errors='replace').read()
pat = re.compile(
r'(\(segment\s+\(start ([-\d.]+) ([-\d.]+)\)\s+\(end ([-\d.]+) ([-\d.]+)\)'
r'\s+\(width )([\d.]+)(\)\s+\(layer "([^"]+)"\))')
want = {(round(a, 3), round(b, 3), round(c, 3), round(d, 3), L): w
for a, b, c, d, L, w in POUR_RELIEF}
n = [0]
def sub(m):
k = (round(float(m.group(2)), 3), round(float(m.group(3)), 3),
round(float(m.group(4)), 3), round(float(m.group(5)), 3), m.group(8))
k2 = (k[2], k[3], k[0], k[1], k[4])
for key in (k, k2):
if key in want and float(m.group(6)) > want[key]:
n[0] += 1
return f"{m.group(1)}{want[key]:g}{m.group(7)}"
return m.group(0)
out = pat.sub(sub, text)
if n[0]:
with open(path, "w") as fh:
fh.write(out)
return n[0]
def f_cu_regions(text):
"""KiCad's own filled regions for the F.Cu ground zone."""
out = []
for z in G._blocks(text, 'zone'):
m = re.search(r'\(layer "([^"]+)"\)', z)
if not m or m.group(1) != "F.Cu":
continue
for fp in G._blocks(z, 'filled_polygon'):
pts = [(float(a), float(b))
for a, b in re.findall(r'\(xy ([-\d.]+) ([-\d.]+)\)', fp)]
if len(pts) >= 3:
out.append(pts)
return out
def inside(poly, x, y):
"""Ray-cast point-in-polygon."""
n, c = len(poly), False
j = n - 1
for i in range(n):
xi, yi = poly[i]; xj, yj = poly[j]
if ((yi > y) != (yj > y)) and \
(x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-18) + xi):
c = not c
j = i
return c
def build(path):
text = open(path, errors='replace').read()
pads = R.parse_pads(text)
seen, uniq = set(), []
for p in pads:
k = (p["ref"], p["num"], round(p["x"], 3), round(p["y"], 3), p["layers"])
if k in seen:
continue
seen.add(k); uniq.append(p)
rt = R.Router(uniq)
rt.stamp_pads()
for p in uniq:
p["ax"], p["ay"] = p["x"], p["y"]
for o in G.load_board(path):
if o.kind == "track":
R.stamp_segment(rt.occ[o.layers[0]], R.to_cell(o.ax, o.ay),
R.to_cell(o.bx, o.by), o.r, rt.nid(o.net))
elif o.kind == "via":
cx, cy = R.to_cell(o.ax, o.ay)
for L in rt.layers:
R.stamp(rt.occ[L], cx, cy, o.r, rt.nid(o.net))
R.stamp(rt.holes, cx, cy, o.hole, True)
return rt, uniq, text
def drop_bad_vias(path):
"""Delete any via whose copper is inside the clearance rule."""
objs = G.load_board(path)
bad = set()
for gap, rule, why, a, b in G.violations(objs):
for o in (a, b):
if o.kind == "via":
bad.add((round(o.ax, 4), round(o.ay, 4)))
if not bad:
return 0
text = open(path, errors='replace').read()
out, removed = [], 0
for blk in G._blocks(text, 'via'):
m = re.search(r'\(at ([-\d.]+) ([-\d.]+)\)', blk)
if m and (round(float(m.group(1)), 4), round(float(m.group(2)), 4)) in bad:
text = text.replace("\n\t" + blk, "", 1)
removed += 1
with open(path, "w") as fh:
fh.write(text)
return removed
# Nets whose copper must never be narrowed to make room for a pour, whatever
# it costs the ground fill: the panel and charge paths carry the board's real
# current and their width is the result of a measured widening pass.
NO_NARROW_MIN_A = 3.0
def auto_relief(path, stranded, reach=1.6, floor=0.25):
"""Widen a pinched pour corridor by narrowing whatever pinches it.
A pour region with no room for a via is usually not short of area -- it is
pinched, and the pinch is one track that a widening pass grew into the gap.
Revision 5 fixed exactly this by hand, with the offending segment's
coordinates written into a table; that table is worth nothing the moment
the board is re-placed. This finds the segment instead: the widest
different-net track running alongside the stranded region, on a net whose
current can spare the copper, narrowed to the board minimum.
"""
from route2 import NET_CURRENT_A
text = open(path, errors="replace").read()
objs = G.load_board(path)
picks = {}
for x0, x1, y0, y1 in stranded:
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
best = None
for o in objs:
if o.kind != "track" or o.net == "GND" or "F.Cu" not in o.layers:
continue
if NET_CURRENT_A.get(o.net, 0.0) >= NO_NARROW_MIN_A:
continue
w = o.r * 2
if w <= floor + 1e-9:
continue
# distance from the segment to the region's box, roughly
mx = min(max(cx, min(o.ax, o.bx)), max(o.ax, o.bx))
my = min(max(cy, min(o.ay, o.by)), max(o.ay, o.by))
d = math.hypot(mx - cx, my - cy) - max(x1 - x0, y1 - y0) / 2
if d < reach and (best is None or w > best[0]):
best = (w, o)
if best:
o = best[1]
picks[(round(o.ax, 3), round(o.ay, 3), round(o.bx, 3),
round(o.by, 3), o.layers[0])] = floor
if not picks:
return 0
pat = re.compile(
r'(\(segment\s+\(start ([-\d.]+) ([-\d.]+)\)\s+\(end ([-\d.]+) ([-\d.]+)\)'
r'\s+\(width )([\d.]+)(\)\s+\(layer "([^"]+)"\))')
n = [0]
def sub(m):
k = (round(float(m.group(2)), 3), round(float(m.group(3)), 3),
round(float(m.group(4)), 3), round(float(m.group(5)), 3), m.group(8))
k2 = (k[2], k[3], k[0], k[1], k[4])
for key in (k, k2):
if key in picks and float(m.group(6)) > picks[key]:
n[0] += 1
return f"{m.group(1)}{picks[key]:g}{m.group(7)}"
return m.group(0)
out = pat.sub(sub, text)
if n[0]:
with open(path, "w") as fh:
fh.write(out)
return n[0]
def _gnd_pads(path, _c={}):
if path not in _c:
_c[path] = [(o.ax, o.ay) for o in G.load_board(path)
if o.kind == "pad" and o.net == "GND" and "F.Cu" in o.layers]
return _c[path]
def main(path=R.PCB_PATH):
n_relief = apply_relief(path)
if n_relief:
print(f"narrowed {n_relief} segment(s) to unpinch the ground pour")
n_drop = drop_bad_vias(path)
if n_drop:
print(f"removed {n_drop} via(s) that sat inside the clearance rule")
rt, uniq, text = build(path)
regions = f_cu_regions(text)
gnd_vias = [(v[0], v[1]) for v in
[(o.ax, o.ay, o.net) for o in G.load_board(path)
if o.kind == "via"] if v[2] == "GND"]
print(f"F.Cu pour: {len(regions)} filled region(s), "
f"{len(gnd_vias)} ground vias on the board")
base_s, base_v = len(rt.segments), len(rt.vias)
added = unstitched = 0
stranded = []
stranded_pads = []
all_gnd_pads = _gnd_pads(path)
def gnd_pads_in(poly):
return [q for q in all_gnd_pads if inside(poly, q[0], q[1])]
for i, poly in enumerate(regions):
if any(inside(poly, x, y) for x, y in gnd_vias):
continue
unstitched += 1
xs = [p[0] for p in poly]; ys = [p[1] for p in poly]
placed = False
step = 0.25
y = min(ys)
while y <= max(ys) and not placed:
x = min(xs)
while x <= max(xs) and not placed:
if inside(poly, x, y):
for vs, vd in ((R.GND_VIA_SIZE, R.GND_VIA_DRILL),
(R.SIG_VIA_SIZE, R.SIG_VIA_DRILL)):
if rt.via_fits(x, y, "GND", vs, vd):
rt.place_via(x, y, "GND", vs, vd)
gnd_vias.append((x, y))
added += 1; placed = True
print(f" region {i}: via at ({x:.2f},{y:.2f})")
break
x += step
y += step
if not placed:
# Last resort: put the via IN the stranded ground pad.
#
# These regions are a single F.Cu ground pad marooned in a sliver
# of pour a couple of millimetres across, with every approach
# blocked by other nets' escapes. There is nowhere in the sliver
# for a via to stand clear of the pad -- but it does not need to
# stand clear of it. The pad is GND and so is the via, so the only
# clearances that matter are to OTHER nets, and a via inside its
# own ground pad is ordinary practice on a four-layer board: it is
# how an SMD ground pad reaches the planes at all. Without it the
# pad's only ground path is a piece of copper connected to
# nothing, which is a floating ground pin on a switching board.
for gp in gnd_pads_in(poly):
for vs, vd in ((R.SIG_VIA_SIZE, R.SIG_VIA_DRILL),):
if rt.via_fits(gp[0], gp[1], "GND", vs, vd):
rt.place_via(gp[0], gp[1], "GND", vs, vd)
gnd_vias.append(gp)
added += 1; placed = True
print(f" region {i}: via placed IN the ground pad "
f"at ({gp[0]:.2f},{gp[1]:.2f})")
break
if placed:
break
if not placed:
print(f" region {i}: NO ROOM for a via "
f"(bbox x[{min(xs):.1f},{max(xs):.1f}] "
f"y[{min(ys):.1f},{max(ys):.1f}])")
stranded.append((min(xs), max(xs), min(ys), max(ys)))
for gp in gnd_pads_in(poly):
stranded_pads.append([round(gp[0], 3), round(gp[1], 3)])
print(f"{unstitched} region(s) had no via; {added} via(s) placed")
# Hand the still-stranded ground pads to the hand-router. A via cannot
# reach the planes from inside these slivers -- a via is a barrel through
# the whole stack and there are inner-layer traces directly beneath them --
# but a short GND track along F.Cu to the main pour does the same job.
# GND is a plane net and is never routed, which is exactly why nothing had
# tried this: the one case where a ground net needs an actual trace is the
# case where its pour has been cut off.
sp = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"stranded_gnd_pads.json")
with open(sp, "w") as fh:
json.dump(stranded_pads, fh)
if stranded_pads:
print(f" {len(stranded_pads)} ground pad(s) still stranded -- "
f"written to stranded_gnd_pads.json for the hand-router")
if stranded:
n_rel = auto_relief(path, stranded)
if n_rel:
print(f" narrowed {n_rel} segment(s) to open those corridors -- "
f"re-fill the zones and run this again to stitch them")
if added:
rt.segments = rt.segments[base_s:]
rt.vias = rt.vias[base_v:]
body = rt.emit()
stripped = text.rstrip()
idx = stripped.rfind('\n')
with open(path, "w") as fh:
fh.write(stripped[:idx + 1] + '\n'.join(body) + '\n'
+ stripped[idx + 1:] + '\n')
v = G.violations(G.load_board(path))
print(f"exact different-net violations: {len(v)}")
return len(v)
if __name__ == "__main__":
sys.exit(main())