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
Board update: 4 unconnected items (from 13), 0 electrical and 0 mechanical DRC findings, silkscreen findings 73 -> 10;…
337e32d
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
#!/usr/bin/env python3
"""Read a .kicad_mod footprint file and emit placed (footprint ...) instances
for a .kicad_pcb, the same way symlib.py handles schematic symbols."""
import re, os, uuid as U
def uid(): return str(U.uuid4())
class FpLib:
def __init__(self, mod_paths):
"""mod_paths: {"Package_SO:SOIC-14_3.9x8.7mm_P1.27mm": "/path/to/file.kicad_mod", ...}"""
self.src = {}
for lib_id, path in mod_paths.items():
self.src[lib_id] = open(path, errors='replace').read()
self.net_num = {"": 0} # net name -> net number, assigned in first-seen order
def net_id(self, name):
"""Look up (assigning if new) the net number for a net name. Used so pads,
zones, tracks and vias all reference the SAME numbered net table -- tracks
(unlike pads/zones) only accept a bare (net N), no name, so a real top-level
(net N "name") table is required once any copper routing is added."""
name = name or ""
if name not in self.net_num:
self.net_num[name] = len(self.net_num)
return self.net_num[name]
def net_table(self):
"""(net N "name") lines for every net seen so far, in assigned-number order,
for the board header."""
items = sorted(self.net_num.items(), key=lambda kv: kv[1])
return '\n'.join(f'\t(net {n} "{name}")' for name, n in items)
def pads(self, lib_id):
"""[(number, x, y, angle_or_None)] in the footprint's own frame (0-rotation)."""
t = self.src[lib_id]
out = []
for m in re.finditer(r'\(pad "(\w+)" \w+ \w+\s*\n\s*\(at ([-\d.]+) ([-\d.]+)(?: ([\d.-]+))?\)', t):
num, x, y, ang = m.groups()
out.append((num, float(x), float(y), float(ang) if ang else 0.0))
return out
def pads_full(self, lib_id):
"""[(number, x, y, angle_or_None, layers_str)] -- like pads() but also returns
the pad's (layers ...) string, so a router can tell SMD (F.Cu-only) pads from
THT (*.Cu, any layer) pads."""
t = self.src[lib_id]
out = []
for m in re.finditer(
r'\(pad "(\w+)" \w+ \w+\s*\n\s*\(at ([-\d.]+) ([-\d.]+)(?: ([\d.-]+))?\)\n'
r'\s*\(size [^\n]*\)\n\s*(?:\(drill[^\n]*\)\n\s*)?\(layers ([^)]*)\)',
t):
num, x, y, ang, layers = m.groups()
out.append((num, float(x), float(y), float(ang) if ang else 0.0, layers))
return out
@staticmethod
def _absolutize_pad_angle(pad_block, frot):
"""Bake the footprint's rotation into the pad's own angle.
KiCad stores a pad's `(at x y angle)` angle ABSOLUTELY, in board
coordinates -- the footprint's rotation moves a pad's POSITION but does
not orient its SHAPE. Verified directly against KiCad 10 rather than
assumed: a `(size 3 0.6)` SMD rect inside a footprint placed at 90
degrees still plots 3.000mm wide by 0.600mm tall, and DRC measures it
that way too, while the same footprint's fp_line and fp_rect graphics
DO rotate. Copying library pad blocks verbatim therefore left 21 pads
across Q1-Q5 and D2-D4 -- the power FETs and switching diodes -- rotated
90 degrees away from where the layout intends them, on the real
fabricated copper, with DRC perfectly happy about it.
Writing the angle absolutely is what KiCad's own editor does when you
rotate a footprint, and it makes the file, the plot, KiCad's DRC and
this project's geometry checker all agree.
"""
if not frot:
return pad_block
m = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', pad_block)
if not m:
return pad_block
ang = (float(m.group(3) or 0) + frot) % 360
new_at = (f'(at {m.group(1)} {m.group(2)}'
+ (f' {ang:g}' if ang else '') + ')')
return pad_block[:m.start()] + new_at + pad_block[m.end():]
def instance(self, lib_id, ref, value, x, y, rot, net_by_pad, footprint_field=None,
renumber=None):
"""net_by_pad: {pad_number: net_name or None}. Returns the (footprint ...) text.
renumber: {physical_pad_number: schematic_pin_number}, for footprints whose
left-to-right/geometric pad order doesn't match the schematic symbol's own
pin numbering (e.g. a generic TO-92 NPN symbol numbered B-C-E vs a real
part's physical E-B-C pinout). KiCad's own schematic<->PCB parity check
assumes pad number == schematic pin number 1:1; the fix is to relabel
which NUMBER a given physical pad carries (keeping its real x,y position),
not to quietly swap nets onto the wrong-numbered pad - that satisfies our
own net wiring but not KiCad's parity check, and more importantly would
leave the pad NUMBER lying about which real pin it is if anyone reads the
footprint directly.
"""
renumber = renumber or {}
t = self.src[lib_id]
body = t.split('\n', 1)[1] # drop the opening (footprint "name"
body = body.rsplit('\n)', 1)[0] # drop the final closing paren
body = re.sub(r'^\t\(version [^\n]*\n', '', body)
body = re.sub(r'^\t\(generator [^\n]*\n', '', body)
body = re.sub(r'^\t\(generator_version [^\n]*\n', '', body)
body = re.sub(r'^\t\(layer "F\.Cu"\)\n', '', body, count=1)
from symlib import SymLib
forms = SymLib._forms(body)
out_forms = []
def close_with_uuid(block, extra=''):
idx = block.rstrip().rfind('\n')
head, tail = block[:idx+1], block[idx+1:]
indent = re.match(r'\t*', tail).group(0)
return head + (extra) + f'{indent}\t(uuid "{uid()}")\n' + tail
for f in forms:
kind = re.match(r'\((\w+)', f).group(1)
if kind == 'property':
name = re.match(r'\(property "([^"]+)"', f).group(1)
if name == 'Reference':
f = re.sub(r'\(property "Reference" "[^"]*"', f'(property "Reference" "{ref}"', f)
elif name == 'Value':
f = re.sub(r'\(property "Value" "[^"]*"', f'(property "Value" "{value}"', f)
if not re.search(r'\(uuid ', f):
f = close_with_uuid(f)
out_forms.append(f)
elif kind == 'pad':
phys_num = re.match(r'\(pad "(\w*)"', f).group(1) # some footprints have a
# nameless mechanical/keepout pad ("") - never carries a net
num = renumber.get(phys_num, phys_num)
if num != phys_num:
f = re.sub(r'^\(pad "\w*"', f'(pad "{num}"', f, count=1)
net = net_by_pad.get(num) if num else None
extra = f'\t\t(net {self.net_id(net)} "{net}")\n' if net else ''
f = self._absolutize_pad_angle(f, rot)
f = close_with_uuid(f, extra)
out_forms.append(f)
elif kind in ('fp_line', 'fp_rect', 'fp_circle', 'fp_poly', 'fp_arc', 'fp_text'):
if not re.search(r'\(uuid ', f):
f = close_with_uuid(f)
out_forms.append(f)
else:
out_forms.append(f)
new_body = '\n'.join('\t' + x for x in out_forms)
rot_txt = f' {rot:g}' if rot else ''
header = (f'\t(footprint "{lib_id}"\n'
f'\t\t(layer "F.Cu")\n'
f'\t\t(uuid "{uid()}")\n'
f'\t\t(at {x:.3f} {y:.3f}{rot_txt})\n')
return header + new_body + '\n\t)'