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
Revision 4: first-build audit. Bypass caps assigned to their own supply pin (worst distance 62.5mm -> 10.0mm), signal v…
daee549
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
#!/usr/bin/env python3
"""First-build audit: the checks that decide whether this board works when it
is actually assembled, rather than whether it passes DRC.
DRC answers "can this be manufactured without shorting". It says nothing about
whether every schematic net actually made it onto the copper, whether a
regulator has a decoupling capacitor near enough to matter, whether the drills
are inside the fab's capability, or whether an exposed thermal pad is tied to
the plane. Those are the things that stop a board working the first time, and
none of them are DRC rules.
Everything here is measured from the board file and the netlist. Nothing is
asserted.
"""
import json, math, os, re, sys
from collections import defaultdict
import geomcheck as G
HERE = os.path.dirname(os.path.abspath(__file__))
PCB = ("/mnt/c/Users/kirkt/Documents/mppt-power-system-mcu/"
"mppt-power-system-mcu.kicad_pcb")
# Conservative prototype-fab capability. Deliberately the common denominator of
# JLCPCB / PCBWay / OSHPark 4-layer standard process rather than any one
# vendor's best case -- a board that clears this is makeable anywhere.
FAB = {
"min_track_mm": 0.127, # 5 mil
"min_clearance_mm": 0.127,
"min_drill_mm": 0.20,
"min_annular_ring_mm": 0.125, # a 0.45mm via on a 0.20mm drill
"min_edge_clearance_mm": 0.25,
}
POWER_PIN_HINTS = ("VCC", "VDD", "VIN", "VI", "AVDD", "VBAT", "VS", "VP")
def load_netlist():
"""Netlist, with net names normalised the way the board file spells them.
KiCad escapes '/' as '{slash}' inside footprint pads but not inside
tracks; the netlist export carries the escaped spelling. Comparing the two
raw makes every net with a '/' in its name look absent from the board.
"""
n = json.load(open(os.path.join(HERE, "mppt_mcu_netlist.json")))
pins = {ref: {p: G.netname(v) for p, v in d.items()}
for ref, d in n["ref_pin_nets"].items()}
return n["components"], pins
def board_objects(path):
return G.load_board(path)
def board_edge(path):
"""The Edge.Cuts rectangle, as (x0, y0, x1, y1)."""
txt = open(path, errors='replace').read()
xs, ys = [], []
for tag in ("gr_rect", "gr_line", "gr_poly"):
for blk in G._blocks(txt, tag):
if '"Edge.Cuts"' not in blk:
continue
for a, b in re.findall(r'\((?:start|end|xy) ([-\d.]+) ([-\d.]+)\)', blk):
xs.append(float(a)); ys.append(float(b))
if not xs:
return None
return min(xs), min(ys), max(xs), max(ys)
# --------------------------------------------------------------------------
def check_net_parity(objs, ref_pin_nets):
"""Every schematic net, with every pad it should have, present on copper."""
want = defaultdict(set)
for ref, pins in ref_pin_nets.items():
for pin, net in pins.items():
if net and not net.startswith("unconnected-"):
want[net].add(f"{ref}.{pin}")
have = defaultdict(set)
for o in objs:
if o.kind == "pad" and o.tag and o.net:
have[o.net].add(o.tag)
missing_nets, missing_pads = [], []
for net, pads in sorted(want.items()):
if net not in have:
missing_nets.append(net); continue
gone = pads - have[net]
if gone:
missing_pads.append((net, sorted(gone)))
return missing_nets, missing_pads
def check_single_pad_nets(ref_pin_nets):
"""A net that reaches exactly one pin connects nothing."""
count = defaultdict(list)
for ref, pins in ref_pin_nets.items():
for pin, net in pins.items():
if net and not net.startswith("unconnected-"):
count[net].append(f"{ref}.{pin}")
return sorted((n, p) for n, p in count.items() if len(p) == 1)
def check_net_continuity(objs, bucket=2.0):
"""Is every net one continuous piece of copper?
This is the first-build question, and it is asked here with exact geometry
rather than by trusting any one tool: two objects are joined when their
copper actually overlaps (touching, gap <= 0), a via joins every layer it
passes, and every pad of a net must end up in the same component.
KiCad's own connectivity says the same thing, but it is worth having an
independent answer -- the electrothermal model rasterises at 0.2mm and has
reported a net as split that both KiCad and this check call continuous.
"""
by_net = defaultdict(list)
for o in objs:
if o.net and o.net != "GND": # GND is planes; not a trace question
by_net[o.net].append(o)
split = []
for net, items in sorted(by_net.items()):
n = len(items)
parent = list(range(n))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]; i = parent[i]
return i
def union(i, j):
a, b = find(i), find(j)
if a != b: parent[a] = b
grid = defaultdict(list)
for i, o in enumerate(items):
x0, y0, x1, y1 = o.bbox()
for gx in range(int(x0 // bucket), int(x1 // bucket) + 1):
for gy in range(int(y0 // bucket), int(y1 // bucket) + 1):
grid[(gx, gy)].append(i)
for cell in grid.values():
for ii in range(len(cell)):
for jj in range(ii + 1, len(cell)):
a, b = items[cell[ii]], items[cell[jj]]
if find(cell[ii]) == find(cell[jj]):
continue
if not set(a.layers) & set(b.layers):
continue
if a.copper_gap(b) <= 1e-6:
union(cell[ii], cell[jj])
pads = [(i, o) for i, o in enumerate(items) if o.kind == "pad"]
if len(pads) < 2:
continue
roots = defaultdict(list)
for i, o in pads:
roots[find(i)].append(o.tag or "?")
if len(roots) > 1:
split.append((net, [sorted(v) for v in roots.values()]))
return split
def check_fab(objs, path):
"""Track width, drill, annular ring and edge clearance against FAB."""
issues = defaultdict(list)
for o in objs:
if o.kind == "track":
w = o.r * 2
if w < FAB["min_track_mm"] - 1e-9:
issues["track too narrow"].append(f"{w:.3f}mm on {o.net}")
if o.hole:
d = o.hole * 2
if d < FAB["min_drill_mm"] - 1e-9:
issues["drill too small"].append(
f"{d:.3f}mm {o.kind} {o.tag or o.net}")
if o.kind == "via":
ring = o.r - o.hole
if ring < FAB["min_annular_ring_mm"] - 1e-9:
issues["annular ring too thin"].append(
f"{ring:.3f}mm via on {o.net}")
edge = board_edge(path)
if edge:
x0, y0, x1, y1 = edge
m = FAB["min_edge_clearance_mm"]
for o in objs:
if not o.net:
continue
bx0, by0, bx1, by1 = o.bbox()
if (bx0 < x0 + m or by0 < y0 + m or bx1 > x1 - m or by1 > y1 - m):
issues["copper near board edge"].append(
f"{o.kind} {o.tag or o.net} at "
f"({bx0:.1f},{by0:.1f})-({bx1:.1f},{by1:.1f})")
return issues, edge
def check_decoupling(objs, comps, ref_pin_nets, max_mm=10.0):
"""Does every IC have a bypass capacitor physically near it?
Measured per IC, not per pin, and deliberately so. The netlist carries pin
NUMBERS, not pin names, so there is no reliable way to tell a supply pin
from a current-sense input from the netlist alone -- an earlier version of
this check treated the INA180s' shunt inputs as supply pins and reported
problems that were not there. What can be measured honestly is whether a
GND-referenced capacitor that shares a net with the part is close to it,
which is the question that actually matters: an IC with no local capacitor
is the failure mode.
"""
pad_xy = defaultdict(list)
for o in objs:
if o.kind == "pad" and o.tag:
pad_xy[o.tag.split(".")[0]].append((o.ax, o.ay))
cen = {r: (sum(p[0] for p in v) / len(v), sum(p[1] for p in v) / len(v))
for r, v in pad_xy.items()}
bypass = {r for r, pins in ref_pin_nets.items()
if r.startswith("C") and len(pins) == 2
and "GND" in pins.values()}
out = []
for ic, pins in sorted(ref_pin_nets.items()):
if not ic.startswith("U") or len(pins) < 3 or ic not in cen:
continue
nets = set(pins.values())
best = None
for c in bypass:
if c not in cen:
continue
if not (nets & (set(ref_pin_nets[c].values()) - {"GND"})):
continue
d = math.hypot(cen[ic][0] - cen[c][0], cen[ic][1] - cen[c][1])
if best is None or d < best[0]:
best = (d, c)
if best is None:
out.append((ic, None, None, float("inf")))
else:
out.append((ic, best[1], comps.get(best[1], {}).get("value", "?"),
best[0]))
return [r for r in out if r[3] > max_mm], out
def check_thermal_pads(objs, ref_pin_nets):
"""An exposed pad must be tied to its net by vias, not just by its own face."""
eps = defaultdict(list)
for o in objs:
if o.kind == "pad" and o.tag:
ref, _, num = o.tag.partition(".")
# an exposed pad is a large pad; collect the big ones per footprint
if o.rect and (o.rect.hw * 2) > 1.6 and (o.rect.hh * 2) > 1.6:
eps[(ref, num)].append(o)
vias = [o for o in objs if o.kind == "via"]
out = []
for (ref, num), pads in sorted(eps.items()):
if not ref.startswith("U"):
continue
net = pads[0].net
if net != "GND":
# A non-GND tab (a SOT-223 regulator's output, say) has no plane
# of its own net to via down to, so "no vias" is correct for it,
# not a finding. It needs copper AREA, which is a pour question.
continue
n_via = 0
for p in pads:
x0, y0, x1, y1 = p.bbox()
for v in vias:
if v.net == net and x0 <= v.ax <= x1 and y0 <= v.ay <= y1:
n_via += 1
# a thru-hole pad in the same group is itself a thermal via
n_pth = sum(1 for p in pads if p.hole)
out.append((f"{ref}.{num}", net, len(pads), n_via, n_pth))
return out
def check_courtyard_to_edge(objs, edge):
"""Nothing should overhang the board outline."""
if not edge:
return []
x0, y0, x1, y1 = edge
bad = []
for o in objs:
if o.kind != "pad":
continue
bx0, by0, bx1, by1 = o.bbox()
if bx0 < x0 or by0 < y0 or bx1 > x1 or by1 > y1:
bad.append(o.tag)
return bad
def main(path=PCB):
comps, ref_pin_nets = load_netlist()
objs = board_objects(path)
print(f"board: {len(objs)} copper objects, {len(comps)} components\n")
problems = 0
print("== schematic -> PCB net parity ==")
mn, mp = check_net_parity(objs, ref_pin_nets)
if mn:
problems += len(mn)
print(f" MISSING NETS ({len(mn)}): {mn[:8]}")
if mp:
problems += len(mp)
for net, pads in mp[:10]:
print(f" MISSING PADS on {net}: {pads}")
if not mn and not mp:
print(" every schematic net and pad is present on the board")
print("\n== net continuity (exact geometry, independent of KiCad) ==")
split = check_net_continuity(objs)
if split:
problems += len(split)
for net, groups in split[:10]:
print(f" SPLIT {net}: " + " | ".join(",".join(g[:4]) for g in groups))
else:
print(" every non-GND net is one continuous piece of copper")
print("\n== nets that reach only one pin ==")
single = check_single_pad_nets(ref_pin_nets)
if single:
problems += len(single)
for n, p in single[:10]:
print(f" {n} -> {p}")
else:
print(" none")
print("\n== fab capability ==")
issues, edge = check_fab(objs, path)
if issues:
for k, v in issues.items():
problems += len(v)
print(f" {k}: {len(v)}")
for x in v[:5]:
print(f" {x}")
else:
print(f" clears the conservative prototype process "
f"(track>={FAB['min_track_mm']}mm, drill>={FAB['min_drill_mm']}mm, "
f"ring>={FAB['min_annular_ring_mm']}mm)")
if edge:
print(f" board outline: {edge[2]-edge[0]:.1f} x {edge[3]-edge[1]:.1f} mm")
print("\n== decoupling: nearest bypass cap to each IC ==")
far, allrows = check_decoupling(objs, comps, ref_pin_nets)
for ic, cap, val, d in sorted(allrows, key=lambda t: -t[3]):
if cap is None:
print(f" {ic:<4s} NO GND-referenced capacitor shares a net with it")
else:
print(f" {ic:<4s} -> {cap:>4s} ({val:>5s}) at {d:5.1f}mm")
problems += len(far)
if not far:
print(" every IC has a bypass capacitor within 10mm")
print("\n== exposed / thermal pads ==")
for tag, net, npads, nvia, npth in check_thermal_pads(objs, ref_pin_nets):
flag = "" if (nvia + npth) else " <-- NO VIAS"
print(f" {tag:<8s} {net:<8s} {npads} pad(s), {nvia} via(s), "
f"{npth} thru-hole thermal pad(s){flag}")
print("\n== pads overhanging the board outline ==")
over = check_courtyard_to_edge(objs, edge)
if over:
problems += len(over)
print(f" {len(over)}: {over[:10]}")
else:
print(" none")
print(f"\n>>> audit problems needing attention: {problems}")
return problems
if __name__ == "__main__":
sys.exit(0 if main() == 0 else 1)