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
Replace discrete-analog board with MCU/IC redesign: STM32G031 P&O MPPT + CC/CV firmware, LM5175 buck-boost, INA180 curr…
e48ba30
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
#!/usr/bin/env python3
"""Generator core for the MCU/IC-based solar MPPT power system schematic.
Unlike the old discrete-analog board's generator (schgen.py, which connects
pins purely with net-name labels), this module draws REAL wire segments
between pins -- the schematic is meant to be traceable by eye, the way a
hand-drawn schematic is. Net labels/power symbols are only used where that is
itself standard practice (global power/GND symbols with a short real wire
stub to the pin -- not a bare text label standing in for a wire).
"""
import math
import uuid as U
from symlib import SymLib
GRID = 1.27
def snap(v): return round(v / GRID) * GRID
def uid(): return str(U.uuid4())
def rot_pt(px, py, rot):
"""Rotate a symbol-local pin offset (y-up) by `rot` degrees CCW, then
flip to sheet space (y-down). Consistent with KiCad's own placement of
rotated symbol instances."""
r = math.radians(rot)
dx = px * math.cos(r) - py * math.sin(r)
dy = -(px * math.sin(r) + py * math.cos(r))
return dx, dy
def rot_dir(angle_deg, rot):
"""Outward direction unit vector (sheet space) of a pin whose local
KiCad pin angle is `angle_deg`, after the symbol is placed at `rot`."""
eff = math.radians(angle_deg + rot)
return math.cos(eff), -math.sin(eff)
class Sch:
def __init__(self, lib, project):
self.lib = lib
self.project = project
self.items = []
self.used = {}
self.root = uid()
self.pins_of = {} # ref -> {pin_key: (x, y, angle_deg)}
self.refs_seen = set()
self.pin_num_of = {} # (ref, any_key) -> canonical pin number
self._intent_parent = {} # (ref, num) -> parent, for intended-net union-find
self._named_net_repr = {} # net_name -> representative (ref, num) node
self._pending_wires = [] # deferred wire() calls, processed by finalize_wires()
self._emitted_segments = []
# ---- intended-connectivity tracking (verified against real geometry
# by verify_intent() below -- this is what caught the shared-stub-column
# short-circuit class of bug: two pins this code never asked to be tied
# together, but that ended up on the same net because their auto-routed
# wires happened to overlap) ------------------------------------------
def _canon(self, ref, key):
return (ref, self.pin_num_of[(ref, str(key))])
def _ifind(self, node):
self._intent_parent.setdefault(node, node)
while self._intent_parent[node] != node:
self._intent_parent[node] = self._intent_parent[self._intent_parent[node]]
node = self._intent_parent[node]
return node
def _iunion(self, a, b):
ra, rb = self._ifind(a), self._ifind(b)
if ra != rb:
self._intent_parent[ra] = rb
def intend(self, ref, pin, ref2, pin2):
self._iunion(self._canon(ref, pin), self._canon(ref2, pin2))
def intend_named(self, ref, pin, net_name):
node = self._canon(ref, pin)
rep = self._named_net_repr.setdefault(net_name, node)
self._iunion(node, rep)
def need(self, l, n):
self.used.setdefault((l, n), self.lib.lib_symbol_entry(l, n))
def place(self, l, n, unit, ref, value, x, y, rot=0, footprint='',
extra_props=None, hide_val=False, dnp=False):
assert ref not in self.refs_seen, f"duplicate reference {ref}"
self.refs_seen.add(ref)
self.need(l, n)
x, y = snap(x), snap(y)
pu = uid()
props = [('Reference', ref, False), ('Value', value, hide_val),
('Footprint', footprint, True), ('Datasheet', '', True)]
if extra_props:
props += [(k, v, True) for k, v in extra_props.items()]
s = ['\t(symbol', f'\t\t(lib_id "{l}:{n}")', f'\t\t(at {x:.2f} {y:.2f} {rot})']
s += [f'\t\t(unit {unit})', '\t\t(body_style 1)', '\t\t(exclude_from_sim no)',
'\t\t(in_bom yes)', '\t\t(on_board yes)', '\t\t(in_pos_files yes)',
f'\t\t(dnp {"yes" if dnp else "no"})', f'\t\t(uuid "{pu}")']
py = y - 10.0
for name, v, hide in props:
esc = v.replace('\\', '\\\\').replace('"', '\\"')
s.append(f'\t\t(property "{name}" "{esc}"')
s.append(f'\t\t\t(at {x:.2f} {py:.2f} 0)')
if hide: s.append('\t\t\t(hide yes)')
s.append('\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)')
s.append('\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)')
s.append('\t\t)')
py += 1.8
pins = self.lib.pins(l, n).get(unit, [])
for num, pname, *_ in pins:
s.append(f'\t\t(pin "{num}"\n\t\t\t(uuid "{uid()}")\n\t\t)')
s.append('\t\t(instances')
s.append(f'\t\t\t(project "{self.project}"')
s.append(f'\t\t\t\t(path "/{self.root}"')
s.append(f'\t\t\t\t\t(reference "{ref}")\n\t\t\t\t\t(unit {unit})\n\t\t\t\t)')
s.append('\t\t\t)\n\t\t)\n\t)')
self.items.append('\n'.join(s))
pmap = {}
for num, pname, et, px, py2, ang in pins:
dx, dy = rot_pt(px, py2, rot)
ddx, ddy = rot_dir(ang, rot)
abs_x, abs_y = snap(x + dx), snap(y + dy)
pmap[num] = (abs_x, abs_y, ddx, ddy)
self.pin_num_of[(ref, num)] = num
if pname:
# first-wins, matching pmap.setdefault() above: several real
# parts (e.g. TPS62130) reuse one name like "GND" or "VIN" on
# several distinct physical pins, so a later assignment here
# must NOT silently repoint the name at a different pin than
# the coordinate lookup resolves to (that mismatch made
# verify_intent() think a power()/connect() call landed on a
# different physical pin than the wire it actually drew).
if (ref, pname) not in self.pin_num_of:
self.pin_num_of[(ref, pname)] = num
pmap.setdefault(pname, (abs_x, abs_y, ddx, ddy))
self.pins_of[ref] = pmap
return pmap
def pin_xy(self, ref, key):
try:
return self.pins_of[ref][str(key)]
except KeyError:
return self.pins_of[ref][key]
def _obstacles_on(self, x1, y1, x2, y2, skip):
"""Every placed pin coordinate lying strictly between (x1,y1) and
(x2,y2) on an axis-aligned segment, excluding the segment's own
endpoints (`skip`, a set of (x,y) tuples). Used to catch a wire
silently passing through -- and shorting into -- some other
component's pin that just happens to sit on the same row/column
(this bit repeatedly: a straight or rail-drop wire to one pin of a
part routed right through that SAME part's other pin, or an
unrelated part's, because grid-snapped layouts make collinear pins
common)."""
eps = 0.02
hits = []
if abs(x1 - x2) < eps:
lo, hi = sorted([y1, y2])
for (px, py) in self._all_pin_coords():
if (px, py) in skip:
continue
if abs(px - x1) <= eps and lo + eps < py < hi - eps:
hits.append((px, py))
elif abs(y1 - y2) < eps:
lo, hi = sorted([x1, x2])
for (px, py) in self._all_pin_coords():
if (px, py) in skip:
continue
if abs(py - y1) <= eps and lo + eps < px < hi - eps:
hits.append((px, py))
return hits
def _all_pin_coords(self):
if not hasattr(self, '_pin_coord_cache') or self._pin_coord_cache_n != len(self.pins_of):
coords = set()
for pmap in self.pins_of.values():
for (x, y, dx, dy) in pmap.values():
coords.add((round(x, 2), round(y, 2)))
self._pin_coord_cache = coords
self._pin_coord_cache_n = len(self.pins_of)
return self._pin_coord_cache
# ---- drawing primitives ----------------------------------------
def wire(self, points):
"""Record a wire request. Emission is DEFERRED to finalize_wires()
(called once, after the whole schematic is placed) so the
obstacle check below sees every component's pins -- not just the
ones placed so far -- regardless of the order blocks were built in.
A component placed later in the script (e.g. an output cap added
after the switch-node wire it happens to sit under) is just as
real an obstacle as one placed earlier."""
self._pending_wires.append([(x, y) for x, y in points])
def finalize_wires(self):
"""Process every deferred wire() call: split into 2-point segments,
check each for obstacles (see _obstacles_on) against the COMPLETE
final pin set, and detour around any with a small perpendicular
jog -- otherwise the segment would electrically short into
whatever pin it silently passed over. Also checks each segment
against every OTHER call's segments for a collinear/overlapping
run belonging to a different intended net (two unrelated
connect()/net() calls choosing the same row or column for an
intermediate hop -- e.g. two different "route through a shared
clear lane at y=10" choices -- which shorts them together without
ever passing through a named pin at all). Call once, after the
last place()/connect()/etc., before render()."""
pending, self._pending_wires = self._pending_wires, []
for points in pending:
self._emit_wire_checked(points, tag=None)
def _seg_overlap(self, x1, y1, x2, y2, ox1, oy1, ox2, oy2, eps=0.02):
"""Do two axis-aligned segments overlap over a real span (not just
touch at a point)? Both must be on the same line (same X if both
vertical, same Y if both horizontal) with overlapping ranges."""
vert_a = abs(x1 - x2) < eps
vert_b = abs(ox1 - ox2) < eps
if vert_a != vert_b:
return False
if vert_a:
if abs(x1 - ox1) > eps:
return False
lo1, hi1 = sorted([y1, y2])
lo2, hi2 = sorted([oy1, oy2])
else:
if abs(y1 - oy1) > eps:
return False
lo1, hi1 = sorted([x1, x2])
lo2, hi2 = sorted([ox1, ox2])
lo, hi = max(lo1, lo2), min(hi1, hi2)
return hi - lo > eps # real overlap, not just a shared endpoint
def _emit_wire_checked(self, points, tag=None):
pts = [(snap(x), snap(y)) for x, y in points]
JOG = 1 * GRID # NOT 0.5*GRID: that lands exactly on a snap() rounding
# tie and gets rounded straight back to 0, silently
# cancelling the whole detour (found the hard way)
segs = [(p1, p2) for p1, p2 in zip(pts, pts[1:]) if p1 != p2]
for (x1, y1), (x2, y2) in segs:
hits = self._obstacles_on(x1, y1, x2, y2, skip={(x1, y1), (x2, y2)})
cross_wire_hit = False
if not hits and tag is not None:
for (ox1, oy1, ox2, oy2, otag) in self._emitted_segments:
if otag != tag and self._seg_overlap(x1, y1, x2, y2, ox1, oy1, ox2, oy2):
cross_wire_hit = True
break
if not hits and not cross_wire_hit:
self.items.append(
f'\t(wire\n\t\t(pts (xy {x1:.2f} {y1:.2f}) (xy {x2:.2f} {y2:.2f}))\n'
f'\t\t(stroke\n\t\t\t(width 0)\n\t\t\t(type default)\n\t\t)\n'
f'\t\t(uuid "{uid()}")\n\t)')
self._emitted_segments.append((x1, y1, x2, y2, tag))
continue
# Detour: step off the line by JOG for the whole span, clearing
# every obstacle on it regardless of where it sits, then return.
vertical = abs(x1 - x2) < 0.02
if vertical:
jx1, jy1 = snap(x1 + JOG), y1
jx2, jy2 = snap(x2 + JOG), y2
else:
jx1, jy1 = x1, snap(y1 + JOG)
jx2, jy2 = x2, snap(y2 + JOG)
detour = [(x1, y1), (jx1, jy1), (jx2, jy2), (x2, y2)]
for (ax, ay), (bx, by) in zip(detour, detour[1:]):
if (ax, ay) == (bx, by):
continue
self.items.append(
f'\t(wire\n\t\t(pts (xy {ax:.2f} {ay:.2f}) (xy {bx:.2f} {by:.2f}))\n'
f'\t\t(stroke\n\t\t\t(width 0)\n\t\t\t(type default)\n\t\t)\n'
f'\t\t(uuid "{uid()}")\n\t)')
self._emitted_segments.append((ax, ay, bx, by, tag))
def junction(self, x, y):
self.items.append(
f'\t(junction\n\t\t(at {snap(x):.2f} {snap(y):.2f})\n\t\t(diameter 0)\n'
f'\t\t(color 0 0 0 0)\n\t\t(uuid "{uid()}")\n\t)')
def label(self, text, x, y, rot=0, just='left bottom', kind='local'):
x, y = snap(x), snap(y)
esc = text.replace('\\', '\\\\').replace('"', '\\"')
tag = {'local': 'label', 'global': 'global_label', 'hier': 'hierarchical_label'}[kind]
extra = ''
if tag != 'label':
extra = '\n\t\t(shape input)'
self.items.append(
f'\t({tag} "{esc}"\n\t\t(at {x:.2f} {y:.2f} {rot}){extra}\n'
f'\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n\t\t\t)\n'
f'\t\t\t(justify {just})\n\t\t)\n\t\t(uuid "{uid()}")\n\t)')
def noconn(self, ref, pin_key):
x, y, *_ = self.pin_xy(ref, pin_key)
self.items.append(f'\t(no_connect\n\t\t(at {x:.2f} {y:.2f})\n\t\t(uuid "{uid()}")\n\t)')
def text(self, t, x, y, size=2.5, bold=True):
esc = t.replace('"', '\\"')
b = '\n\t\t\t\t(bold yes)' if bold else ''
self.items.append(
f'\t(text "{esc}"\n\t\t(exclude_from_sim no)\n\t\t(at {x:.2f} {y:.2f} 0)\n'
f'\t\t(effects\n\t\t\t(font\n\t\t\t\t(size {size} {size}){b}\n\t\t\t)\n'
f'\t\t\t(justify left bottom)\n\t\t)\n\t\t(uuid "{uid()}")\n\t)')
def rect_box(self, x0, y0, x1, y1):
self.items.append(
f'\t(rectangle\n\t\t(start {x0:.2f} {y0:.2f})\n\t\t(end {x1:.2f} {y1:.2f})\n'
f'\t\t(stroke\n\t\t\t(width 0.25)\n\t\t\t(type dash)\n\t\t)\n'
f'\t\t(fill\n\t\t\t(type none)\n\t\t)\n\t\t(uuid "{uid()}")\n\t)')
# ---- real-wire connection helpers -------------------------------
def connect(self, a, apin, b, bpin, bend='auto', stub=2 * GRID, via=None):
"""Draw a real wire between two placed pins. For a connection that
must cross a lot of empty distance (e.g. between blocks), pass
explicit `via=[(x,y), ...]` waypoints -- the naive 1-2 segment
auto-bend below is only safe for short, local, same-block hops: a
long automatic bend has a real chance of sweeping directly through
an unrelated component's pin that happens to share the bend's X or
Y coordinate, which is a silent short (found the hard way)."""
self.intend(a, apin, b, bpin)
ax, ay, adx, ady = self.pin_xy(a, apin)
bx, by, bdx, bdy = self.pin_xy(b, bpin)
if via:
# Stub out from each pin along its own outward direction first,
# then visit the explicit waypoints. Any consecutive pair that
# shares neither X nor Y (including a badly-chosen via point)
# gets an automatic L-corner inserted -- a straight pin->via
# jump is NOT guaranteed to be axis-aligned and previously
# produced silent diagonal "wires" (KiCad still draws them, but
# they are not a real orthogonal schematic connection).
a2 = (snap(ax + adx * stub), snap(ay + ady * stub))
b2 = (snap(bx + bdx * stub), snap(by + bdy * stub))
raw = [(ax, ay)]
if a2 != (ax, ay):
raw.append(a2)
raw += list(via)
raw.append(b2)
raw.append((bx, by))
pts = [raw[0]]
for p in raw[1:]:
x1, y1 = pts[-1]
x2, y2 = p
if abs(x1 - x2) > 1e-6 and abs(y1 - y2) > 1e-6:
pts.append((x2, y1)) # horizontal-then-vertical corner
if p != pts[-1]:
pts.append(p)
self.wire(pts)
return
if abs(ax - bx) < 1e-6 or abs(ay - by) < 1e-6:
self.wire([(ax, ay), (bx, by)])
return
# short outward stubs from each pin before the bend, for a cleaner look
a2 = (snap(ax + adx * stub), snap(ay + ady * stub))
b2 = (snap(bx + bdx * stub), snap(by + bdy * stub))
if bend == 'auto':
bend = 'h' if abs(a2[0] - b2[0]) >= abs(a2[1] - b2[1]) else 'v'
corner = (b2[0], a2[1]) if bend == 'h' else (a2[0], b2[1])
pts = [(ax, ay)]
if a2 != (ax, ay):
pts.append(a2)
if corner != pts[-1]:
pts.append(corner)
if b2 != corner:
pts.append(b2)
pts.append((bx, by))
# dedupe consecutive duplicate points
clean = [pts[0]]
for p in pts[1:]:
if p != clean[-1]:
clean.append(p)
self.wire(clean)
def net(self, pins):
"""Connect 3+ pins with a rail (anchored at the first pin's axis) +
junctions, or fall back to connect() for exactly 2."""
if len(pins) == 2:
(a, ap), (b, bp) = pins
self.connect(a, ap, b, bp)
return
for ref, key in pins[1:]:
self.intend(pins[0][0], pins[0][1], ref, key)
anchor = self.pin_xy(*pins[0])
rail_y = anchor[1]
for ref, key in pins[1:]:
x, y, *_ = self.pin_xy(ref, key)
if abs(y - rail_y) < 1e-6:
continue
# horizontal rail at rail_y spanning all pin x's
xs = [self.pin_xy(r, k)[0] for r, k in pins]
x0, x1 = min(xs), max(xs)
self.wire([(x0, rail_y), (x1, rail_y)])
for ref, key in pins:
x, y, *_ = self.pin_xy(ref, key)
if abs(y - rail_y) > 1e-6:
self.wire([(x, rail_y), (x, y)])
self.junction(x, rail_y)
def power(self, ref, pin_key, net_name, stub=1 * GRID):
"""Place a stock power symbol (power:GND, power:+5V, ...) near
ref.pin_key and wire a real stub to it. These are power_in-typed
and globally merge by name -- exactly what's wanted for GND and
other universally-shared rails. For a custom local rail that needs
an ERC-satisfying driver, use drive_rail() instead (a non-global
flag -- see its docstring for why)."""
self.intend_named(ref, pin_key, net_name)
x, y, dx, dy = self.pin_xy(ref, pin_key)
px, py = snap(x + dx * stub), snap(y + dy * stub)
pwr_rot = {(1, 0): 180, (-1, 0): 0, (0, -1): 90, (0, 1): 270}
key = (round(dx), round(dy))
rot = pwr_rot.get(key, 0)
sref = f'#PWR{len(self.refs_seen) + 1:03d}'
self.refs_seen.add(sref)
self.need('power', net_name)
pu = uid()
s = ['\t(symbol', f'\t\t(lib_id "power:{net_name}")', f'\t\t(at {px:.2f} {py:.2f} {rot})',
'\t\t(unit 1)', '\t\t(body_style 1)', '\t\t(exclude_from_sim no)',
'\t\t(in_bom yes)', '\t\t(on_board yes)', '\t\t(in_pos_files yes)',
'\t\t(dnp no)', f'\t\t(uuid "{pu}")']
for name, v, hide in [('Reference', sref, True), ('Value', net_name, False),
('Footprint', '', True), ('Datasheet', '', True)]:
s.append(f'\t\t(property "{name}" "{v}"')
s.append(f'\t\t\t(at {px:.2f} {py - 2.5:.2f} 0)')
if hide: s.append('\t\t\t(hide yes)')
s.append('\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)')
s.append('\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)')
s.append('\t\t)')
for num, *_ in self.lib.pins('power', net_name).get(1, []):
s.append(f'\t\t(pin "{num}"\n\t\t\t(uuid "{uid()}")\n\t\t)')
s.append('\t\t(instances')
s.append(f'\t\t\t(project "{self.project}"')
s.append(f'\t\t\t\t(path "/{self.root}"')
s.append(f'\t\t\t\t\t(reference "{sref}")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)')
s.append('\t\t\t)\n\t\t)\n\t)')
self.items.append('\n'.join(s))
self.wire([(x, y), (px, py)])
def drive_rail(self, ref, pin_key, net_name, stub=1 * GRID):
"""Mark `net_name` as ERC-driven at ref.pin_key with a LOCAL
(non-global) power_out flag -- deliberately NOT `(power global)`,
because KiCad 10's ERC treats every `(power global)` symbol as one
shared pool for the 'two power outputs on a net' check regardless
of name/value, so a global flag on one rail can falsely conflict
with a genuine regulator output pin on a totally unrelated rail
elsewhere in the design (confirmed empirically). A plain, per-net
local symbol avoids that: it only ties into the real net it's
physically wired to, via the normal wire/connect() graph."""
x, y, dx, dy = self.pin_xy(ref, pin_key)
px, py = snap(x + dx * stub), snap(y + dy * stub)
libname = f'DRV_{net_name}'
if ('power', libname) not in self.used:
body = (
f'\t(symbol "power:{libname}"\n'
f'\t\t(pin_numbers\n\t\t\t(hide yes)\n\t\t)\n'
f'\t\t(pin_names\n\t\t\t(offset 0)\n\t\t\t(hide yes)\n\t\t)\n'
f'\t\t(exclude_from_sim no)\n\t\t(in_bom yes)\n\t\t(on_board yes)\n'
f'\t\t(in_pos_files yes)\n\t\t(duplicate_pin_numbers_are_jumpers no)\n'
f'\t\t(property "Reference" "#DRV"\n\t\t\t(at 0 -3.81 0)\n\t\t\t(show_name no)\n'
f'\t\t\t(do_not_autoplace no)\n\t\t\t(hide yes)\n'
f'\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
f'\t\t(property "Value" "{net_name} (driven)"\n\t\t\t(at 0 3.556 0)\n\t\t\t(show_name no)\n'
f'\t\t\t(do_not_autoplace no)\n'
f'\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
f'\t\t(property "Footprint" ""\n\t\t\t(at 0 0 0)\n\t\t\t(show_name no)\n'
f'\t\t\t(do_not_autoplace no)\n\t\t\t(hide yes)\n'
f'\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
f'\t\t(symbol "{libname}_0_1"\n'
f'\t\t\t(polyline\n\t\t\t\t(pts (xy -0.762 1.27) (xy 0 2.54))\n'
f'\t\t\t\t(stroke\n\t\t\t\t\t(width 0)\n\t\t\t\t\t(type default)\n\t\t\t\t)\n'
f'\t\t\t\t(fill\n\t\t\t\t\t(type none)\n\t\t\t\t)\n\t\t\t)\n'
f'\t\t\t(polyline\n\t\t\t\t(pts (xy 0 2.54) (xy 0.762 1.27))\n'
f'\t\t\t\t(stroke\n\t\t\t\t\t(width 0)\n\t\t\t\t\t(type default)\n\t\t\t\t)\n'
f'\t\t\t\t(fill\n\t\t\t\t\t(type none)\n\t\t\t\t)\n\t\t\t)\n'
f'\t\t\t(polyline\n\t\t\t\t(pts (xy 0 0) (xy 0 2.54))\n'
f'\t\t\t\t(stroke\n\t\t\t\t\t(width 0)\n\t\t\t\t\t(type default)\n\t\t\t\t)\n'
f'\t\t\t\t(fill\n\t\t\t\t\t(type none)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
f'\t\t(symbol "{libname}_1_1"\n'
f'\t\t\t(pin power_out line\n\t\t\t\t(at 0 0 90)\n\t\t\t\t(length 0)\n'
f'\t\t\t\t(name "{libname}"\n\t\t\t\t\t(effects\n\t\t\t\t\t\t(font\n\t\t\t\t\t\t\t(size 1.27 1.27)\n'
f'\t\t\t\t\t\t)\n\t\t\t\t\t)\n\t\t\t\t)\n'
f'\t\t\t\t(number "1"\n\t\t\t\t\t(effects\n\t\t\t\t\t\t(font\n\t\t\t\t\t\t\t(size 1.27 1.27)\n'
f'\t\t\t\t\t\t)\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
f'\t\t(embedded_fonts no)\n\t)')
self.used[('power', libname)] = body
sref = f'#DRV{len(self.refs_seen) + 1:03d}'
self.refs_seen.add(sref)
pu = uid()
s = ['\t(symbol', f'\t\t(lib_id "power:{libname}")', f'\t\t(at {px:.2f} {py:.2f} 0)',
'\t\t(unit 1)', '\t\t(body_style 1)', '\t\t(exclude_from_sim no)',
'\t\t(in_bom yes)', '\t\t(on_board yes)', '\t\t(in_pos_files yes)',
'\t\t(dnp no)', f'\t\t(uuid "{pu}")']
for name, v, hide in [('Reference', sref, True), ('Value', f'{net_name} (driven)', True),
('Footprint', '', True), ('Datasheet', '', True)]:
s.append(f'\t\t(property "{name}" "{v}"')
s.append(f'\t\t\t(at {px:.2f} {py - 2.5:.2f} 0)')
if hide: s.append('\t\t\t(hide yes)')
s.append('\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)')
s.append('\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)')
s.append('\t\t)')
s.append('\t\t(pin "1"\n\t\t\t(uuid "' + uid() + '")\n\t\t)')
s.append('\t\t(instances')
s.append(f'\t\t\t(project "{self.project}"')
s.append(f'\t\t\t\t(path "/{self.root}"')
s.append(f'\t\t\t\t\t(reference "{sref}")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)')
s.append('\t\t\t)\n\t\t)\n\t)')
self.items.append('\n'.join(s))
self.wire([(x, y), (px, py)])
def pwr_flag(self, ref, pin_key, stub=2 * GRID):
"""Place a power:PWR_FLAG (power_out) on the same net as ref.pin_key.
Standard KiCad fix for the 'input power pin not driven' ERC rule on
nets whose only power symbols (GND, named rails) are themselves
power_in -- a real, deliberate ERC-satisfying marker, not a
connectivity shortcut (the real wire to the pin already exists)."""
x, y, dx, dy = self.pin_xy(ref, pin_key)
fx, fy = snap(x + dx * stub * 2), snap(y + dy * stub * 2)
sref = f'#DRV{len(self.refs_seen) + 1:03d}'
self.refs_seen.add(sref)
self.need('power', 'PWR_FLAG')
pu = uid()
s = ['\t(symbol', '\t\t(lib_id "power:PWR_FLAG")', f'\t\t(at {fx:.2f} {fy:.2f} 0)',
'\t\t(unit 1)', '\t\t(body_style 1)', '\t\t(exclude_from_sim no)',
'\t\t(in_bom yes)', '\t\t(on_board yes)', '\t\t(in_pos_files yes)',
'\t\t(dnp no)', f'\t\t(uuid "{pu}")']
for name, v, hide in [('Reference', sref, True), ('Value', 'PWR_FLAG', True),
('Footprint', '', True), ('Datasheet', '', True)]:
s.append(f'\t\t(property "{name}" "{v}"')
s.append(f'\t\t\t(at {fx:.2f} {fy - 2.5:.2f} 0)')
if hide: s.append('\t\t\t(hide yes)')
s.append('\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)')
s.append('\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)')
s.append('\t\t)')
for num, *_ in self.lib.pins('power', 'PWR_FLAG').get(1, []):
s.append(f'\t\t(pin "{num}"\n\t\t\t(uuid "{uid()}")\n\t\t)')
s.append('\t\t(instances')
s.append(f'\t\t\t(project "{self.project}"')
s.append(f'\t\t\t\t(path "/{self.root}"')
s.append(f'\t\t\t\t\t(reference "{sref}")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)')
s.append('\t\t\t)\n\t\t)\n\t)')
self.items.append('\n'.join(s))
self.wire([(x, y), (fx, fy)])
def verify_intent(self, raise_on_error=True):
"""Compare INTENDED connectivity (built up by connect/net/power calls
via self._intent_parent) against ACTUAL geometric connectivity (every
wire endpoint + any pin that lies ON a wire's segment, not just at
its ends). Reports every pair of pins that ended up on the same real
net without this code ever asking for that -- the class of bug this
whole method exists to catch: two unrelated pins whose auto-routed
stubs/bends happened to be collinear and overlapping, an accidental
dead short that plain ERC does not flag (pin-type rules don't catch
a passive-to-passive short) and that a screenshot only shows if you
happen to zoom into exactly the right spot."""
import re as _re
wires = []
for it in self.items:
m = _re.search(r'\(wire\s*\n\s*\(pts \(xy ([\d.-]+) ([\d.-]+)\) \(xy ([\d.-]+) ([\d.-]+)\)\)', it)
if m:
x1, y1, x2, y2 = map(float, m.groups())
wires.append(((round(x1, 2), round(y1, 2)), (round(x2, 2), round(y2, 2))))
def on_segment(px, py, x1, y1, x2, y2, eps=0.02):
if abs(x1 - x2) < eps:
if abs(px - x1) > eps:
return False
lo, hi = sorted([y1, y2])
return lo - eps <= py <= hi + eps
if abs(y1 - y2) < eps:
if abs(py - y1) > eps:
return False
lo, hi = sorted([x1, x2])
return lo - eps <= px <= hi + eps
return False
node_coord = {} # (ref, num) -> coord
for ref, pmap in self.pins_of.items():
for key, (x, y, dx, dy) in pmap.items():
num = self.pin_num_of.get((ref, key))
if num is not None:
node_coord[(ref, num)] = (round(x, 2), round(y, 2))
coord_nodes = {}
for node, c in node_coord.items():
coord_nodes.setdefault(c, []).append(node)
gparent = {}
cause = {} # coord -> (seg_a, seg_b) that first merged it into its group
def gfind(p):
gparent.setdefault(p, p)
while gparent[p] != p:
gparent[p] = gparent[gparent[p]]
p = gparent[p]
return p
def gunion(a, b, why=None):
ra, rb = gfind(a), gfind(b)
if ra != rb:
gparent[ra] = rb
if why:
cause[ra] = why
all_coords = list(coord_nodes.keys())
for a, b in wires:
gunion(a, b, (a, b))
for c in all_coords:
if c != a and c != b and on_segment(c[0], c[1], a[0], a[1], b[0], b[1]):
gunion(c, a, (a, b))
violations = []
seen_pairs = set()
nodes = list(node_coord.keys())
# group nodes by their geometric root, then check intent within each group
geo_groups = {}
for n in nodes:
c = node_coord[n]
geo_groups.setdefault(gfind(c), []).append(n)
for root, members in geo_groups.items():
if len(members) < 2:
continue
by_intent = {}
for n in members:
by_intent.setdefault(self._ifind(n), []).append(n)
if len(by_intent) > 1:
groups_desc = [sorted(f"{r}.{num}" for r, num in grp) for grp in by_intent.values()]
key = tuple(sorted(tuple(g) for g in groups_desc))
if key in seen_pairs:
continue
seen_pairs.add(key)
violations.append(groups_desc)
if violations:
msg = ["verify_intent: found unintended net merges (real shorts):"]
for groups_desc in violations:
msg.append(" MERGED (not asked for): " + " <-> ".join(" + ".join(g) for g in groups_desc))
report = "\n".join(msg)
if raise_on_error:
raise AssertionError(report)
print(report)
return violations
def render(self, w, h, title, rev, company, date):
out = ['(kicad_sch', '\t(version 20260306)', '\t(generator "eeschema")',
'\t(generator_version "10.0")', f'\t(uuid "{self.root}")',
f'\t(paper "User" {w} {h})', '\t(title_block',
f'\t\t(title "{title}")', f'\t\t(date "{date}")', f'\t\t(rev "{rev}")',
f'\t\t(company "{company}")', '\t)', '\t(lib_symbols']
out += list(self.used.values()); out.append('\t)')
out += self.items
out.append('\t(sheet_instances\n\t\t(path "/"\n\t\t\t(page "1")\n\t\t)\n\t)')
out.append('\t(embedded_fonts no)\n)')
return '\n'.join(out) + '\n'
def check_balance(text):
d, ins, esc = 0, False, False
for ch in text:
if esc: esc = False; continue
if ch == '\\': esc = True; continue
if ch == '"': ins = not ins; continue
if ins: continue
d += (ch == '(') - (ch == ')')
return d == 0