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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
#!/usr/bin/env python3
"""Close specific links at higher resolution than the autorouter's grid.
The three links left unrouted on this board all fail the same way: the pin is
walled in on F.Cu and cannot reach a point where a via fits. That is not a
congestion problem -- giving the router a whole spare plane layer placed none
of them -- it is a RESOLUTION problem. The escape corridor down the middle of a
0.65mm-pitch pad is about 0.1mm wider than the trace needs: real, but finer
than a 0.1mm raster can represent, so rounding the pad centre to a cell closes
it.
That is also exactly why a human closes these in the interactive editor in a
couple of minutes. They are not smarter than the router; they work at nanometre
resolution instead of on a 0.1mm grid.
So this does the same thing the same way: it re-rasterises the board at a finer
pitch and routes only the named links, at minimum width, against every piece of
copper already present. Everything else on the board is left exactly as it is,
and the result is checked with exact geometry before it is written.
"""
import json, math, os, sys
import geomcheck as G
import route2 as R
FINE_GRID = 0.025 # mm per cell, against the router's 0.1mm
# The links the autorouter could not close, read from what it actually
# reported rather than hand-listed. A hand-written list is correct for exactly
# one placement: re-place the board and it names links that now route fine
# while missing the ones that do not, and the failure is silent -- the script
# reports success having closed nothing that needed closing.
def _failed_links():
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"route2_failed.json")
try:
with open(path) as fh:
return [tuple(x) for x in json.load(fh)]
except (OSError, ValueError):
return []
def _stranded_gnd():
"""Stranded ground pads, as links from the pad to the nearest GND via."""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"stranded_gnd_pads.json")
try:
with open(path) as fh:
return [("GND", f"@{x},{y}", "@GNDVIA") for x, y in json.load(fh)]
except (OSError, ValueError):
return []
# Links to route as an ADDITIONAL parallel path even though both ends are
# already connected. The 0.1mm stitcher gives up on these -- it reported "no
# direct path U9.4 -> L4.1, 4.5mm apart" on a 3.3V switch node whose entire
# copper is 4.6mm of 0.3mm trace -- but the same corridor question at 0.025mm
# is a different question, and it is the one the hand-router exists to answer.
PARALLEL_LINKS = [
("Net-(U9-SW)", "U9.4", "L4.1"), # 3.3V POL switch node, 1A
]
LINKS = _failed_links() + _stranded_gnd() + PARALLEL_LINKS or [
("Net-(U9-SW)", "U9.4", "L4.1"), # 3.3V buck switch node
("Net-(U4-SS)", "U4.8", "C13.1"), # LM5175 soft-start
("Net-(U4-SLOPE)", "U4.7", "R11.1"), # LM5175 slope compensation
# U9's ground pin sits in a 1.6 x 0.4mm sliver of the top pour that is too
# small for any via, so its only path to the ground planes was through a
# fragment connected to nothing. A short trace to the main pour fixes it;
# GND is normally a plane net and never routed, which is exactly why this
# one went unnoticed until the pour's own filled regions were inspected.
]
def use_fine_grid(pitch):
"""Re-scale the router's raster. Its helpers read these at call time."""
R.GRID = pitch
R.NX = int(R.BOARD_W / pitch) + 2
R.NY = int(R.BOARD_H / pitch) + 2
R.RASTER_MARGIN = pitch / 2
def nearest_gnd_via(path, pad, _c={}):
"""A pseudo-pad at the closest ground via, for routing a stranded pin to."""
if path not in _c:
_c[path] = [o for o in G.load_board(path)
if o.kind == "via" and o.net == "GND"]
best = None
for o in _c[path]:
d = math.hypot(o.ax - pad["x"], o.ay - pad["y"])
if best is None or d < best[0]:
best = (d, o)
if not best:
return None
o = best[1]
return {"ref": "VIA", "num": "0", "net": "GND",
"x": o.ax, "y": o.ay, "ax": o.ax, "ay": o.ay,
"w": o.r * 2, "h": o.r * 2, "angle": 0.0, "any_layer": True,
"layers": tuple(G.ALL_CU), "drill": o.hole * 2,
"radius": o.r, "narrow": o.r * 2}
def already_joined(path, net, ta, tb, _cache={}):
"""Are these two pads already one piece of copper? Exact geometry."""
if path not in _cache:
_cache[path] = G.load_board(path)
objs = [o for o in _cache[path] if o.net == net]
idx = {id(o): i for i, o in enumerate(objs)}
parent = list(range(len(objs)))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]; i = parent[i]
return i
for i, o in enumerate(objs):
for j in range(i + 1, len(objs)):
q = objs[j]
if find(i) == find(j) or not (set(o.layers) & set(q.layers)):
continue
if o.copper_gap(q) <= 1e-6:
parent[find(i)] = find(j)
ia = next((i for i, o in enumerate(objs) if o.tag == ta), None)
ib = next((i for i, o in enumerate(objs) if o.tag == tb), None)
return ia is not None and ib is not None and find(ia) == find(ib)
def load_router(path):
"""A Router holding every piece of copper already on the board."""
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: # no fanout pass; route from the pad
p["ax"], p["ay"] = p["x"], p["y"]
n_seg = n_via = 0
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))
n_seg += 1
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)
n_via += 1
print(f"raster at {R.GRID}mm ({R.NX}x{R.NY}) from {n_seg} tracks + "
f"{n_via} vias", flush=True)
return rt, uniq, text
def main(path=R.PCB_PATH):
use_fine_grid(FINE_GRID)
rt, pads, text = load_router(path)
by_tag = {f'{p["ref"]}.{p["num"]}': p for p in pads}
base_s, base_v = len(rt.segments), len(rt.vias)
closed, failed = [], []
for net, ta, tb in LINKS:
if ta.startswith("@"):
# a bare coordinate: the stranded ground pad pourstitch found
tx, ty = (float(v) for v in ta[1:].split(","))
a = min((p for p in pads
if p["net"] == "GND" and "F.Cu" in p["layers"]),
key=lambda p: math.hypot(p["x"] - tx, p["y"] - ty),
default=None)
if a and math.hypot(a["x"] - tx, a["y"] - ty) > 0.6:
a = None
else:
a = by_tag.get(ta)
if tb == "@GNDVIA":
# Target the nearest existing ground via rather than a pad. A
# stranded ground pin does not need to reach any particular part,
# it needs to reach the planes, and a via is where it does that.
b = nearest_gnd_via(path, a) if a else None
if b:
tb = f'via({b["x"]:.2f},{b["y"]:.2f})'
else:
b = by_tag.get(tb)
if not a or not b:
print(f"{net}: {ta} or {tb} not found"); failed.append((net, ta, tb))
continue
force_parallel = (net, ta, tb) in PARALLEL_LINKS
if (not force_parallel and tb.startswith("via(") is False
and already_joined(path, net, ta, tb)):
print(f"{net}: {ta} -> {tb} already connected -- skipped")
continue
span = math.hypot(a["x"] - b["x"], a["y"] - b["y"])
s0, v0 = len(rt.segments), len(rt.vias)
path_found = w = None
for w, mm in ((R.SIGNAL_W, 12.0), (0.2, 12.0), (R.MIN_W, 12.0),
(R.MIN_W, 25.0), (R.MIN_W, 45.0)):
path_found = rt.route(net, a, b, w, R.ROUTE_LAYERS + [R.ESCAPE_LAYER],
margin_mm=mm)
if path_found:
break
if not path_found:
print(f"{net}: {ta} -> {tb} ({span:.1f}mm) still will not route")
failed.append((net, ta, tb))
continue
lys = (R.ROUTE_LAYERS + [R.ESCAPE_LAYER]
if max(q[0] for q in path_found) >= len(R.ROUTE_LAYERS)
else R.ROUTE_LAYERS)
rt.commit(net, path_found, w, lys, a, b)
run = sum(math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1])
for s in rt.segments[s0:])
print(f"{net}: {ta} -> {tb} CLOSED at {w}mm -- {run:.1f}mm of copper, "
f"{len(rt.segments) - s0} segments + {len(rt.vias) - v0} vias",
flush=True)
closed.append((net, ta, tb))
if not closed:
print("nothing closed"); return 1
rt.segments = rt.segments[base_s:]
rt.vias = rt.vias[base_v:]
body = rt.emit()
stripped = text.rstrip()
idx = stripped.rfind('\n')
open(path, "w").write(stripped[:idx + 1] + '\n'.join(body) + '\n'
+ stripped[idx + 1:] + '\n')
v = G.violations(G.load_board(path))
print(f"\nclosed {len(closed)}/{len(LINKS)}; "
f"exact different-net violations after hand-routing: {len(v)}")
for gap, rule, why, x, y in v[:6]:
print(f" gap {gap:+.3f}/{rule} {x.kind}[{x.net}] vs {y.kind}[{y.net}]")
return len(v)
if __name__ == "__main__":
sys.exit(main())