molecule
AFS_Adom flight computer molecule
Public Unreviewedby ae007
Active fin stabilization flight computer for a sounding rocket, a clean-room AI redesign of the Rice Eclipse AFS board: RP2350B, ICM-45686, KX134-1211, MMC5983MA, MS5611, TMP117, INA219A, 8 servos, 2S LiPo, USB-C; 4-layer Adom molecule, designed end to end by Claude through KiCad and adom-aiflow
main
ae007
Project page: AFS_Adom rev A (Rice Eclipse AFS clean-room AI redesign): highlight reel and narrated process video with…
cbe460e
7d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
"""Minimal KiCad s-expression toolkit for generating schematics and boards.
Tokens are kept raw (quoted strings keep their quotes) so library content can be
re-emitted verbatim.
"""
import re, uuid, math
TOK = re.compile(r'"(?:[^"\\]|\\.)*"|\(|\)|[^\s()"]+')
def parse(text):
stack = [[]]
for t in TOK.findall(text):
if t == '(':
stack.append([])
elif t == ')':
l = stack.pop()
stack[-1].append(l)
else:
stack[-1].append(t)
return stack[0]
def q(s):
"""Quote a string for s-expr output."""
s = str(s).replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
return '"' + s + '"'
def uq(t):
"""Unquote a raw token."""
if isinstance(t, str) and len(t) >= 2 and t[0] == '"' and t[-1] == '"':
return t[1:-1].replace('\\"', '"').replace('\\\\', '\\')
return t
def dump(node, ind=0):
"""Pretty-print a nested list as s-expr text."""
if not isinstance(node, list):
return str(node)
simple = all(not isinstance(x, list) for x in node)
if simple:
return '(' + ' '.join(str(x) for x in node) + ')'
out = '(' + ' '.join(str(x) for x in node if not isinstance(x, list))
for x in node:
if isinstance(x, list):
out += '\n' + '\t' * (ind + 1) + dump(x, ind + 1)
out += '\n' + '\t' * ind + ')'
return out
def find(node, name):
return [x for x in node if isinstance(x, list) and x and x[0] == name]
def find1(node, name):
f = find(node, name)
return f[0] if f else None
_NS = uuid.uuid5(uuid.NAMESPACE_URL, 'https://wiki.adom.inc/AFS_Adom')
_N = [0]
def nuid():
"""Deterministic UUIDs (uuid5 over a generation counter) so regenerated files keep their identities."""
_N[0] += 1
return str(uuid.uuid5(_NS, 'AFS_Adom:%d' % _N[0]))
def fmt(v):
v = round(float(v), 4)
if v == int(v):
return str(int(v))
return ('%.4f' % v).rstrip('0').rstrip('.')
class Pin:
def __init__(self, number, name, x, y, angle, length, etype, unit):
self.number, self.name, self.x, self.y = number, name, x, y
self.angle, self.length, self.etype, self.unit = angle, length, etype, unit
class SymLib:
"""Loads one or more .kicad_sym files; returns flattened symbols."""
def __init__(self):
self.syms = {} # name -> raw symbol list
self.libname = {}
def load(self, path, libname):
tree = parse(open(path, encoding='utf-8').read())[0]
for s in find(tree, 'symbol'):
n = uq(s[1])
self.syms[n] = s
self.libname[n] = libname
def flat(self, name):
"""Return a flattened copy (derived symbols merged into their parent)."""
import copy
s = copy.deepcopy(self.syms[name])
ext = find1(s, 'extends')
if ext is None:
return s
parent = self.flat(uq(ext[1]))
pname = uq(parent[1])
out = [x for x in parent if not (isinstance(x, list) and x[0] == 'property')]
# child's properties replace parent's; keep parent's properties the child lacks
cprops = {uq(p[1]): p for p in find(s, 'property')}
pprops = {uq(p[1]): p for p in find(parent, 'property')}
props = dict(pprops)
props.update(cprops)
out[1] = q(name)
# insert properties after the header tokens
head = [x for x in out if not isinstance(x, list)]
body = [x for x in out if isinstance(x, list)]
body_props = list(props.values())
rest = [x for x in body if x[0] != 'property']
# rename unit sub-symbols
for u in rest:
if u[0] == 'symbol':
un = uq(u[1])
if un.startswith(pname + '_'):
u[1] = q(name + un[len(pname):])
return head + body_props + rest
def pins(self, name, unit=None):
s = self.flat(name)
out = []
for sub in find(s, 'symbol'):
un = uq(sub[1])
m = re.search(r'_(\d+)_(\d+)$', un)
u = int(m.group(1)) if m else 0
if unit is not None and u not in (0, unit):
continue
for p in find(sub, 'pin'):
at = find1(p, 'at')
ln = find1(p, 'length')
out.append(Pin(uq(find1(p, 'number')[1]), uq(find1(p, 'name')[1]),
float(at[1]), float(at[2]), float(at[3]) if len(at) > 3 else 0,
float(ln[1]) if ln else 2.54, p[1], u))
return out
def power_flag(self, name):
s = self.flat(name)
return find1(s, 'power') is not None
def rot(x, y, r):
a = math.radians(r)
return (x * math.cos(a) - y * math.sin(a), x * math.sin(a) + y * math.cos(a))
class Schematic:
def __init__(self, project, filename, lib, root_uuid=None, sheet_uuid=None, paper='A3'):
self.project = project
self.filename = filename
self.lib = lib
self.uuid = sheet_uuid or nuid()
self.root_uuid = root_uuid or self.uuid
self.is_root = root_uuid is None
self.items = []
self.lib_symbols = {}
self.paper = paper
self.ref_count = {}
self.sheets = []
self.title = project
# ---- geometry helpers
def pin_abs(self, pin, X, Y, R):
"""Absolute connection point and outward unit vector (schematic frame, +y down)."""
px, py = rot(pin.x, pin.y, R)
ax, ay = X + px, Y - py
ang = (pin.angle + R + 180) % 360 # outward direction in lib frame
ox, oy = math.cos(math.radians(ang)), -math.sin(math.radians(ang))
return ax, ay, round(ox), round(oy)
def path(self):
return '/' + self.root_uuid if self.is_root else '/' + self.root_uuid + '/' + self.uuid
# ---- placing
def add_symbol(self, libname, symname, ref, value, at, R=0, footprint='', nets=None,
fields=None, unit=1, stub=2.54, label_kind='global', hide_fields=(),
dnp=False, in_bom=True, on_board=True):
"""Place a symbol and connect its pins by the `nets` map {pin_number: netname}.
Net names 'GND', '+3V3', '+5V' get power symbols; 'NC' gets a no-connect; other
names get a global label on a short stub wire.
"""
key = libname + ':' + symname
if key not in self.lib_symbols:
flat = self.lib.flat(symname)
flat[1] = q(key)
self.lib_symbols[key] = flat
X, Y = at
suid = nuid()
pins = self.lib.pins(symname, unit)
props = []
fields = dict(fields or {})
base = {'Reference': ref, 'Value': value, 'Footprint': footprint, 'Datasheet': fields.pop('Datasheet', ''),
'Description': fields.pop('Description', '')}
i = 0
for k, v in list(base.items()) + list(fields.items()):
hide = k not in ('Reference', 'Value') or k in hide_fields
props.append(['property', q(k), q(v), ['at', fmt(X + 2), fmt(Y - 1.27 * (3 - i)), '0'],
['effects', ['font', ['size', '1.27', '1.27']], ['justify', 'left']] + ([['hide', 'yes']] if hide else [])])
i += 1
sym = ['symbol', ['lib_id', q(key)], ['at', fmt(X), fmt(Y), fmt(R)], ['unit', str(unit)],
['exclude_from_sim', 'no'], ['in_bom', 'yes' if in_bom else 'no'], ['on_board', 'yes' if on_board else 'no'],
['dnp', 'yes' if dnp else 'no'], ['fields_autoplaced', 'yes'], ['uuid', q(suid)]] + props
for p in pins:
sym.append(['pin', q(p.number), ['uuid', q(nuid())]])
sym.append(['instances', ['project', q(self.project), ['path', q(self.path()), ['reference', q(ref)], ['unit', str(unit)]]]])
self.items.append(sym)
# connections
nets = nets or {}
for p in pins:
net = nets.get(p.number)
ax, ay, ox, oy = self.pin_abs(p, X, Y, R)
if net is None:
continue
if net == 'NC':
self.items.append(['no_connect', ['at', fmt(ax), fmt(ay)], ['uuid', q(nuid())]])
continue
ex, ey = ax + ox * stub, ay + oy * stub
if stub > 0:
self.items.append(['wire', ['pts', ['xy', fmt(ax), fmt(ay)], ['xy', fmt(ex), fmt(ey)]],
['stroke', ['width', '0'], ['type', 'default']], ['uuid', q(nuid())]])
if net in ('GND', '+3V3', '+5V'):
self.add_power(net, (ex, ey), (ox, oy))
else:
self.add_label(net, (ex, ey), (ox, oy), kind=label_kind)
return suid
def add_power(self, net, at, direction=(0, 1)):
"""Power symbol with its pin at `at`; direction is the stub's outward vector."""
ox, oy = direction
if net == 'GND':
R = {(0, 1): 0, (0, -1): 180, (1, 0): 270, (-1, 0): 90}[(ox, oy)]
else:
R = {(0, -1): 0, (0, 1): 180, (1, 0): 90, (-1, 0): 270}[(ox, oy)]
ref = self.next_ref('#PWR')
self.add_symbol('power', net, ref, net, at, R, stub=0, nets={})
# fix hidden reference for power symbols: KiCad expects hidden Reference
sym = self.items[-1]
for p in find(sym, 'property'):
if uq(p[1]) == 'Reference':
p.append(['hide', 'yes']) if not find1(p, 'hide') else None
eff = find1(p, 'effects')
if eff is not None and not find1(eff, 'hide'):
eff.append(['hide', 'yes'])
def add_pwr_flag(self, net, at):
X, Y = at
ref = self.next_ref('#FLG')
self.add_symbol('power', 'PWR_FLAG', ref, 'PWR_FLAG', (X, Y), 0, stub=2.54, nets={'1': net})
def next_ref(self, prefix):
self.ref_count[prefix] = self.ref_count.get(prefix, 0) + 1
return '%s%02d' % (prefix, self.ref_count[prefix])
def add_label(self, net, at, direction=(1, 0), kind='global'):
ox, oy = direction
R = {(1, 0): 0, (-1, 0): 180, (0, -1): 90, (0, 1): 270}[(ox, oy)]
X, Y = at
if kind == 'global':
self.items.append(['global_label', q(net), ['shape', 'bidirectional'], ['at', fmt(X), fmt(Y), str(R)],
['fields_autoplaced', 'yes'], ['effects', ['font', ['size', '1.27', '1.27']],
['justify', 'left' if R in (0, 90) else 'right']], ['uuid', q(nuid())],
['property', q('Intersheetrefs'), q('${INTERSHEET_REFS}'), ['at', fmt(X), fmt(Y), '0'],
['effects', ['font', ['size', '1.27', '1.27']], ['hide', 'yes']]]])
else:
self.items.append(['label', q(net), ['at', fmt(X), fmt(Y), str(R)], ['fields_autoplaced', 'yes'],
['effects', ['font', ['size', '1.27', '1.27']], ['justify', 'left' if R in (0, 90) else 'right']],
['uuid', q(nuid())]])
def add_wire(self, a, b):
self.items.append(['wire', ['pts', ['xy', fmt(a[0]), fmt(a[1])], ['xy', fmt(b[0]), fmt(b[1])]],
['stroke', ['width', '0'], ['type', 'default']], ['uuid', q(nuid())]])
def add_text(self, text, at, size=1.27):
self.items.append(['text', q(text), ['exclude_from_sim', 'no'], ['at', fmt(at[0]), fmt(at[1]), '0'],
['effects', ['font', ['size', fmt(size), fmt(size)]], ['justify', 'left', 'top']], ['uuid', q(nuid())]])
def add_sheet(self, name, filename, at, size, sheet_uuid, page):
X, Y = at
W, H = size
self.items.append(['sheet', ['at', fmt(X), fmt(Y)], ['size', fmt(W), fmt(H)], ['exclude_from_sim', 'no'],
['in_bom', 'yes'], ['on_board', 'yes'], ['dnp', 'no'], ['fields_autoplaced', 'yes'],
['stroke', ['width', '0.1524'], ['type', 'solid']], ['fill', ['color', '0', '0', '0', '0.0000']],
['uuid', q(sheet_uuid)],
['property', q('Sheetname'), q(name), ['at', fmt(X), fmt(Y - 0.7), '0'],
['effects', ['font', ['size', '1.27', '1.27']], ['justify', 'left', 'bottom']]],
['property', q('Sheetfile'), q(filename), ['at', fmt(X), fmt(Y + H + 0.6), '0'],
['effects', ['font', ['size', '1.27', '1.27']], ['justify', 'left', 'top']]],
['instances', ['project', q(self.project), ['path', q('/' + self.root_uuid), ['page', q(str(page))]]]]])
def write(self, path, page='1'):
tree = ['kicad_sch', ['version', '20250114'], ['generator', q('eeschema')], ['generator_version', q('9.0')],
['uuid', q(self.uuid)], ['paper', q(self.paper)],
['title_block', ['title', q(self.title)], ['date', q('2026-10-15')], ['rev', q('A')],
['company', q('AFS_Adom clean-room design')]],
['lib_symbols'] + list(self.lib_symbols.values())]
tree += self.items
if self.is_root:
tree.append(['sheet_instances', ['path', q('/'), ['page', q(page)]]])
tree.append(['embedded_fonts', 'no'])
open(path, 'w', encoding='utf-8').write(dump(tree) + '\n')