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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
#!/usr/bin/env python3
"""Add a direct parallel path for a net the electrothermal model says is long.
The charge path /VBATT runs from the charge-current shunt RS2 to the battery
connector J2. The router builds a net as a chain, so that path physically
visits every other /VBATT pad on the way -- both POL buck inputs, the bulk
caps -- and the DC model measured the result at 92.0 mOhm / 230.0 mV / 575 mW
at the 2.5A charge current. That is a routing-LENGTH problem, not a width
problem: the trace is already at its full 0.8mm policy width.
The fix is not to rip up a chain that is already clearance-clean. Both ends
are the same copper net, so an additional direct path between them runs
electrically in PARALLEL with the long one, and parallel resistance is
dominated by the smaller term. Nothing existing is moved or removed.
The new path is routed by the same obstacle-aware router, against a raster
rebuilt from every piece of copper already on the board, and verified with
exact geometry before it is written.
"""
import math, re, sys
import numpy as np
import geomcheck as G
import route2 as R
# (net, ref.pad, ref.pad, width). One entry per case the electrothermal model
# measured as resistance-dominated, using that case's OWN terminal pads -- so
# the path added is exactly the path the model integrates along. Ordered worst
# first, because each one is routed against the copper the previous ones left.
#
# Every entry is a PARALLEL path: both ends already sit on the same copper net,
# so this only ever lowers end-to-end resistance, and a link that cannot be
# routed is skipped rather than forced.
LINKS = [
# Switch nodes. These carry the full inductor current and their terminals
# are the two ends the electrothermal model measures between, so a direct
# parallel run is measured exactly where the loss is. Revision 6's buck
# switch node came out at 53.9mOhm and 245 A/mm^2 -- the placement put Q2's
# source and L2 far enough apart that the router chained them through thin
# B.Cu -- which is far and away the worst figure on the board.
("Net-(J1-Pin_1)","J1.1", "F1.1", 2.0), # panel input, connector to fuse, 5A
("Net-(Q2-S)", "Q2.3", "L2.1", 2.0), # LM5175 buck switch node, 5A
("Net-(L3-Pad1)", "U8.1", "L3.1", 1.2), # 5V POL switch node, 2A
("Net-(U9-SW)", "U9.4", "L4.1", 0.8), # 3V3 POL switch node, 1A
("Net-(Q4-D)", "Q4.2", "RS2.1", 1.6), # raw VBATT, worst case on the board
("/VBATT", "RS2.2", "U9.3", 1.0), # battery bus -> 3V3 POL input
("/VBATT", "RS2.2", "U8.10", 1.2), # battery bus -> 5V POL input
("/V3V3_OUT", "FB2.2", "J4.1", 0.8), # 3V3 rail -> output connector
("Net-(C32-Pad1)", "L4.2", "FB2.1", 0.8), # 3V3 inductor -> output bead
("Net-(U8-VOS)", "L3.2", "FB1.1", 1.2), # 5V inductor -> output bead
("/V5V_OUT", "FB1.2", "J3.1", 1.2), # 5V rail -> output connector
("/VBATT", "RS2.2", "J2.1", 1.6), # charge shunt -> battery connector
]
def load_router():
"""A Router whose raster holds every piece of copper already on the board."""
text = open(R.PCB_PATH, errors='replace').read()
pads = R.parse_pads(text)
seen, uniq = set(), []
for p in pads:
# The layer set is part of the identity. U4's exposed pad has an
# F.Cu pad and a B.Cu pad at the SAME coordinates with the same pad
# number, and keying on (ref, num, x, y) alone threw the bottom one
# away as a duplicate -- so the router never saw it, and the B.Cu
# escape routes ran straight through it.
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: they are already cut
p["ax"], p["ay"] = p["x"], p["y"]
objs = G.load_board(R.PCB_PATH)
n_seg = n_via = 0
for o in objs:
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 rebuilt from {n_seg} existing tracks + {n_via} vias")
return rt, uniq, text
def main():
rt, pads, text = load_router()
by_tag = {f'{p["ref"]}.{p["num"]}': p for p in pads}
added = []
for net, ta, tb, width in LINKS:
a, b = by_tag.get(ta), by_tag.get(tb)
if not a or not b:
print(f"{net}: {ta} or {tb} not on this board -- skipped")
continue
direct = math.hypot(a["x"] - b["x"], a["y"] - b["y"])
s0, v0 = len(rt.segments), len(rt.vias)
# Escalate width AND search window. The two longest paths on this
# board -- the battery bus feeds to both POL bucks, 66mm and 84mm --
# were being skipped at a 60mm window, and skipping them costs real
# resistance: the 5V POL feed measured 21mOhm with its parallel path
# and 60mOhm without.
path = None
# Widths escalate; the WINDOW does not. Pushing the search window to
# 110mm and 160mm was tried to rescue the two longest paths and simply
# does not terminate in reasonable time -- at that size the window is
# the whole board on three layers. Those two paths are not short of
# window, they are blocked, and a wider search only pays for
# discovering that more slowly.
# Step DOWN gradually. This used to read (width, 0.6, 0.5, 0.4), so a
# 2.0mm request that did not fit fell straight to 0.6mm with nothing in
# between -- and 0.6mm of parallel copper on a 5A net is barely a
# parallel path. Revision 7's buck switch node ended up with 44.8mm of
# it. Every width a fab will panelise happily belongs in this ladder.
ladder = [(w, 60.0) for w in (width, 2.0, 1.6, 1.2, 1.0, 0.8, 0.6, 0.5, 0.4)
if w <= width]
for w, mm in ladder:
path = rt.route(net, a, b, w, R.ROUTE_LAYERS + [R.ESCAPE_LAYER],
margin_mm=mm)
if path:
break
if not path:
print(f"{net}: no direct path {ta} -> {tb} "
f"({direct:.1f}mm apart) -- skipped")
continue
lys = (R.ROUTE_LAYERS + [R.ESCAPE_LAYER]
if max(q[0] for q in path) >= len(R.ROUTE_LAYERS)
else R.ROUTE_LAYERS)
rt.commit(net, path, 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}: added a {w}mm parallel path {ta} -> {tb} -- "
f"{run:.1f}mm of copper over a {direct:.1f}mm straight line, "
f"{len(rt.segments) - s0} segments + {len(rt.vias) - v0} vias")
added.append((s0, v0))
if not added:
print("nothing added")
return 0
first_s, first_v = added[0]
print(f"\nadded {len(added)} parallel path(s)")
rt.segments = rt.segments[first_s:]
rt.vias = rt.vias[first_v:]
body = rt.emit()
stripped = text.rstrip()
idx = stripped.rfind('\n')
new = stripped[:idx + 1] + '\n'.join(body) + '\n' + stripped[idx + 1:] + '\n'
open(R.PCB_PATH, "w").write(new)
v = G.violations(G.load_board(R.PCB_PATH))
print(f"exact different-net violations after stitching: {len(v)}")
return len(v)
if __name__ == "__main__":
sys.exit(main())