molecule
AM/FM Receiver Molecule
Public Made by Adomby adom
USB-powered AM/FM receiver on an Adom Molecule: discrete BJT and op-amp superhet (10.7 MHz and 455 kHz IFs), STM32G0B1 tuning, self-calibration and USB audio over one USB-C cable. 96 x 96 mm pin grid, 4 layers, KiCad 10.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
"""Generate the hierarchical KiCad schematic from design.DESIGN."""
import os, sys, math, textwrap, datetime
from collections import OrderedDict
from schgen import SheetWriter, LibCache, uid, snap, rot, plan_runs, POWER_LIB
from design import DESIGN, POWER_NETS
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.abspath(os.path.join(HERE, '..'))
PROJECT = 'amfm_receiver'
LIBS = LibCache({'AMFM': os.path.join(OUT, 'lib', 'AMFM.kicad_sym'),
'AdomMachine': os.path.join(OUT, 'lib', 'AdomMachine.kicad_sym')})
ROOT_UUID = uid(PROJECT, 'root')
TITLE = 'AM/FM Receiver Molecule - discrete BJT/op-amp radio, STM32 USB audio'
REV = 'B (molecule)'
DATE = datetime.date.today().isoformat()
COMPANY = 'Adom'
PAPERS = {'A3': (420.0, 297.0), 'A2': (594.0, 420.0), 'A1': (841.0, 594.0)}
CHAR_W = 1.05 # approx width of one 1.27 mm character
STUB = 2.54
def sheet_uuid(key):
return uid(PROJECT, 'sheet', key)
def file_uuid(key):
return uid(PROJECT, 'file', key)
TWO_PIN_VERT = {'Device:R', 'Device:C', 'Device:L', 'Device:FerriteBead', 'Device:Polyfuse'}
TWO_PIN_HORZ = {'Device:LED', 'Device:D_Schottky', 'Device:D_Capacitance', 'Device:Crystal'}
TRANSISTORS = {'Transistor_BJT:MMBT3904', 'Transistor_BJT:MMBT3906', 'Transistor_BJT:BC847BS'}
def is_power(net):
return net in POWER_NETS
def choose_rotation(part):
lid, pins = part.lib_id, part.pins
if lid in TWO_PIN_VERT:
a, b = pins['1'], pins['2']
if b == 'GND' or (is_power(a) and a != 'GND'):
return 0
if a == 'GND' or (is_power(b) and b != 'GND'):
return 180
return 90
if lid in TWO_PIN_HORZ:
k, a = pins['1'], pins['2']
if k == 'GND':
return 90
if a == 'GND':
return 270
return 0
return 0
def pin_list(part, unit, r, x=0.0, y=0.0):
out = []
for p in LIBS.pins(part.lib_id):
if p['unit'] not in (0, unit) or p['convert'] not in (0, 1):
continue
dx, dy = rot(p['x'], -p['y'], r)
a = math.radians(p['angle'] + 180)
ox, oy = rot(round(math.cos(a)), -round(math.sin(a)), r)
d = {(1, 0): 'R', (-1, 0): 'L', (0, -1): 'U', (0, 1): 'D'}[(ox, oy)]
out.append((p, snap(x + dx), snap(y + dy), d))
return out
def body_box(part, unit, r):
"""Approximate symbol body box from the inner ends of the pins."""
xs, ys = [], []
for p, ax, ay, d in pin_list(part, unit, r):
L = p['length']
ix, iy = {'R': (-L, 0), 'L': (L, 0), 'U': (0, L), 'D': (0, -L)}[d]
xs.append(ax + ix)
ys.append(ay + iy)
if not xs:
return (-2.6, -2.6, 2.6, 2.6)
x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys)
if x1 - x0 < 2.6:
c = (x0 + x1) / 2
x0, x1 = c - 1.3, c + 1.3
if y1 - y0 < 2.6:
c = (y0 + y1) / 2
y0, y1 = c - 1.3, c + 1.3
return (x0, y0, x1, y1)
def lib_field(lib_id, name):
from sexp import find
s = LIBS.get(lib_id)
for pr in s:
if isinstance(pr, list) and pr and pr[0] == 'property' and pr[1] == name:
at = find(pr, 'at')
eff = find(pr, 'effects')
j = find(eff, 'justify') if eff else None
just = ' '.join(str(t) for t in j[1:]) if j else None
return float(at[1]), float(at[2]), float(at[3]) if len(at) > 3 else 0.0, just
return 0.0, 0.0, 0.0, None
def text_box(x, y, s, just, h=1.27):
w = len(s) * CHAR_W
if just and 'left' in just:
x0 = x
elif just and 'right' in just:
x0 = x - w
else:
x0 = x - w / 2
return (x0, y - h * 0.7, x0 + w, y + h * 0.7)
def field_positions(part, unit, r, runs):
"""((x, y, angle, justify) for Reference, same for Value), relative to the symbol origin.
KiCad applies the symbol rotation to field angles, so 90/270 symbols get angle 90."""
lid = part.lib_id
fa = 90 if r in (90, 270) else 0
if lid in TWO_PIN_VERT:
if r in (0, 180):
return (2.2, -1.3, fa, 'left'), (2.2, 1.3, fa, 'left')
return (0, -2.4, fa, None), (0, 2.4, fa, None)
if lid in TWO_PIN_HORZ:
if r in (0, 180):
return (0, -2.9, fa, None), (0, 2.9, fa, None)
return (2.6, -1.3, fa, 'left'), (2.6, 1.3, fa, 'left')
if lid == 'Connector:TestPoint':
return (1.8, -3.4, 0, 'left'), (1.8, -1.6, 0, 'left')
if lid == 'Mechanical:MountingHole':
return (3.2, -0.8, 0, 'left'), (3.2, 1.2, 0, 'left')
if lid in TRANSISTORS:
return (5.2, -1.3, 0, 'left'), (5.2, 1.3, 0, 'left')
# generic multi-pin symbol: choose a free corner above / below the body
bx0, by0, bx1, by1 = body_box(part, unit, r)
tw = max(len(part.ref), len(part.value)) * CHAR_W + 1.0
top_x = [m[0] for side, members, *_ in runs if side == 'U' for m in members]
bot_x = [m[0] for side, members, *_ in runs if side == 'D' for m in members]
def free(xa, xb, xs):
return all(not (xa - 2.8 <= x <= xb + 2.8) for x in xs)
cands = [('above', 'left', bx0), ('above', 'right', bx1 - tw), ('below', 'left', bx0),
('below', 'right', bx1 - tw)]
for where, _, xa in cands:
xs = top_x if where == 'above' else bot_x
if free(xa, xa + tw, xs):
if where == 'above':
return (xa, by0 - 3.4, 0, 'left'), (xa, by0 - 1.3, 0, 'left')
return (xa, by1 + 1.8, 0, 'left'), (xa, by1 + 3.9, 0, 'left')
return (bx1 + 1.0, by0 - 3.4, 0, 'left'), (bx1 + 1.0, by0 - 1.3, 0, 'left')
def cell_geometry(part, unit, r, is_glob):
"""Return (bbox, fields) for a symbol unit placed at the origin."""
pins = pin_list(part, unit, r)
ncs, runs = plan_runs(part, pins, part.pins, STUB)
bx0, by0, bx1, by1 = body_box(part, unit, r)
boxes = [(bx0, by0, bx1, by1)]
for p, ax, ay, d in pins:
boxes.append((ax, ay, ax, ay))
for side, members, L, stub_ends, (ex, ey), net in runs:
for sx, sy in stub_ends:
boxes.append((sx, sy, sx, sy))
if is_power(net):
up = not (net == 'GND' or side == 'D' and net != 'GND')
if net == 'GND':
up = side == 'U'
else:
up = side != 'D'
h = 4.8
w = max(2.6, len(net) * CHAR_W / 2 + 0.3)
boxes.append((ex - w, ey - h if up else ey, ex + w, ey if up else ey + h))
else:
n = len(net) * CHAR_W + (3.2 if is_glob(net) else 0.8)
if side == 'L':
boxes.append((ex - n, ey - 1.4, ex, ey + 1.0))
else:
boxes.append((ex, ey - 1.4, ex + n, ey + 1.0))
rf, vf = field_positions(part, unit, r, runs)
for (fx, fy, fa, fj), s in ((rf, part.ref), (vf, part.value)):
boxes.append(text_box(fx, fy, s, fj))
x0 = min(b[0] for b in boxes) - 1.3
y0 = min(b[1] for b in boxes) - 1.3
x1 = max(b[2] for b in boxes) + 1.3
y1 = max(b[3] for b in boxes) + 1.3
return (x0, y0, x1, y1), (rf, vf)
class Cell:
def __init__(self, part, unit, r, bbox, fields):
self.part, self.unit, self.r, self.bbox, self.fields = part, unit, r, bbox, fields
def generate():
D = DESIGN
net_sheets = {}
for p in D.parts:
for pin, n in p.pins.items():
if n is not None:
net_sheets.setdefault(n, set()).add(p.sheet)
is_glob = lambda n: (not is_power(n)) and len(net_sheets.get(n, ())) > 1
sheet_keys = list(D.sheets.keys())
writers = {}
for si, key in enumerate(sheet_keys, start=2):
sh = D.sheets[key]
path = '/%s/%s' % (ROOT_UUID, sheet_uuid(key))
w = SheetWriter(PROJECT, ROOT_UUID, path, file_uuid(key), LIBS, page_label=str(si))
writers[key] = w
blocks = OrderedDict((b, []) for b in sh['blocks'])
for p in (p for p in D.parts if p.sheet == key):
r = choose_rotation(p)
for u in p.units:
bbox, fields = cell_geometry(p, u, r, is_glob)
blocks[p.block].append(Cell(p, u, r, bbox, fields))
# ---- pack cells inside each block (rows)
block_layout = OrderedDict()
for bkey, cells in blocks.items():
if not cells:
continue
area = sum((c.bbox[2] - c.bbox[0]) * (c.bbox[3] - c.bbox[1]) for c in cells)
maxw = max(c.bbox[2] - c.bbox[0] for c in cells)
target_w = max(maxw, min(190.0, (area * 2.0) ** 0.5))
rows, row, rw = [], [], 0.0
for c in cells:
cw = c.bbox[2] - c.bbox[0]
if row and rw + cw > target_w:
rows.append(row)
row, rw = [], 0.0
row.append(c)
rw += cw
if row:
rows.append(row)
y, bw, placed = 0.0, 0.0, []
for row in rows:
x = 0.0
rh = max(c.bbox[3] - c.bbox[1] for c in row)
for c in row:
placed.append((c, x - c.bbox[0], y - c.bbox[1]))
x += c.bbox[2] - c.bbox[0]
bw = max(bw, x)
y += rh
title = sh['blocks'][bkey]['title']
notes = sh['blocks'][bkey]['notes']
bw = max(bw, len(title) * 1.75 + 4)
note_lines = textwrap.wrap(notes, max(40, int(bw / 1.3))) if notes else []
head_h = 7.0 + 3.0 * len(note_lines)
block_layout[bkey] = dict(placed=placed, w=bw + 6.0, h=y + head_h + 4.0, head_h=head_h,
title=title, notes=note_lines)
# ---- shelf-pack blocks on the paper (avoid the title block)
for paper in ('A3', 'A2', 'A1'):
PW, PH = PAPERS[paper]
x0, y0 = 12.7, 33.02
xmax, ymax = PW - 12.7, PH - 12.7
tb_x, tb_y = PW - 125.0, PH - 45.0
pos, ok = {}, True
cx, cy, shelf_h = x0, y0, 0.0
for bkey, bl in block_layout.items():
if cx + bl['w'] > xmax and cx > x0:
cx, cy, shelf_h = x0, cy + shelf_h + 5.08, 0.0
if cy + bl['h'] > ymax or (cy + bl['h'] > tb_y and cx + bl['w'] > tb_x):
if cx > x0:
cx, cy, shelf_h = x0, cy + shelf_h + 5.08, 0.0
if cy + bl['h'] > ymax or (cy + bl['h'] > tb_y and cx + bl['w'] > tb_x):
ok = False
break
pos[bkey] = (cx, cy)
cx += bl['w'] + 5.08
shelf_h = max(shelf_h, bl['h'])
if ok:
break
w.paper = paper
w.text(sh['title'], 12.7, 17.78, size=3.5, bold=True)
for i, ln in enumerate(textwrap.wrap(sh['notes'], int((PW - 30) / 1.45))):
w.text(ln, 12.7, 23.5 + i * 2.8, size=1.6)
for bkey, bl in block_layout.items():
bx, by = (snap(v) for v in pos[bkey])
w.rect(bx, by, snap(bx + bl['w']), snap(by + bl['h']))
w.text(bl['title'], bx + 2.0, by + 5.0, size=2.2, bold=True)
for i, ln in enumerate(bl['notes']):
w.text(ln, bx + 2.0, by + 8.6 + i * 3.0, size=1.5)
for c, rx, ry in bl['placed']:
sx = round(round((bx + 3.0 + rx) / 2.54) * 2.54, 4)
sy = round(round((by + bl['head_h'] + ry) / 2.54) * 2.54, 4)
rf, vf = c.fields
w.place(c.part, c.unit, sx, sy, c.r, c.part.pins, is_glob, stub=STUB,
ref_pos=(sx + rf[0], sy + rf[1], rf[2], rf[3]),
val_pos=(sx + vf[0], sy + vf[1], vf[2], vf[3]))
if key == 'power':
fx, fy = (PW - 95.0, 20.32)
for i, n in enumerate(['VBUS', '+5V', '+5VA', '+3.3VA', 'GND']):
w.pwr_flag(n, snap(fx + i * 15.24), fy)
# ---------------------------------------------------------------- root sheet
root = SheetWriter(PROJECT, ROOT_UUID, '/' + ROOT_UUID, ROOT_UUID, LIBS, page_label='1')
root.paper = 'A3'
root.text('AM/FM RECEIVER MOLECULE - discrete BJT + op-amp radio, STM32G0B1 USB audio', 12.7, 20.32,
size=4.0, bold=True)
layout = [('fmfe', 20.32, 35.56), ('amfe', 20.32, 104.14), ('if', 149.86, 35.56),
('amif', 279.4, 35.56), ('fmif', 279.4, 104.14), ('tune', 149.86, 104.14),
('power', 279.4, 172.72), ('mol', 20.32, 172.72)]
order = {k: i for i, k in enumerate(sheet_keys, start=2)}
for key, x, y in layout:
sh = D.sheets[key]
root.sheet_symbol(sh['name'], sh['file'], x, y, 114.3, 55.88, sheet_uuid(key), order[key])
root.text(sh['title'], x + 2, y + 54.0, size=1.8, bold=True)
for i, ln in enumerate(textwrap.wrap(sh['notes'], 74)[:12]):
root.text(ln, x + 2, y + 5 + i * 3.0, size=1.4)
notes = [
'SIGNAL FLOW',
'ANT -> diplexer -> FM: 88-108 MHz RF amp (tracked varactor tank) -> BJT mixer <- 98.7-118.7 MHz VCO',
' -> AM: 0.53-1.7 MHz preamp -> BJT mixer <- 11.23-12.4 MHz VCO (up-conversion)',
'both -> 10.695 MHz IF (2 ceramic filters + BJT amp) -> BJT mixer <- 10.24 MHz XO -> 455 kHz',
'AM: ceramic filter -> 2x diff-pair VGA -> post-amp -> diode detector -> op-amp AGC & 5 kHz LPF',
'FM: BJT limiter -> squarer -> diode-pump pulse-count detector -> op-amp integrator -> 15 kHz LPF',
'STM32G0B1: ADC samples AM_AUDIO / FM_AUDIO; DAC1/DAC2 tune the VCOs; PWM tunes the FM preselector;',
'TIM2_ETR counts the limited IF; TIM1 injects 40 MHz-crystal-derived markers at ANT for self-calibration.',
'USB-C only: UAC audio + CDC control port + DFU runtime; a blank chip boots into ROM USB DFU.',
'',
'POWER: USB-C 5 V or VIN machine pin (B5819W OR-ing, 500 mA PTC). AP2112K 3.3 V. +5VA for analog/RF.',
'Band switching: PNP high-side switches (MCU open-drain) power only the active front end.',
'ADOM MOLECULE: large/standard GND machine pins 96 x 96 mm, 28 medium contacts on the 2 mm grid (all optional).',
'PCB: 4 layers - F.Cu signal / In1.Cu solid GND / In2.Cu power / B.Cu signal + GND pour.',
]
for i, ln in enumerate(notes):
root.text(ln, 20.32, 236.22 + i * 3.9, size=1.9 if i == 0 else 1.6, bold=(i == 0))
tb = dict(title=TITLE, date=DATE, rev=REV, company=COMPANY,
comments=['4-layer PCB, JLCPCB-compatible rules', 'Generated from tools/design.py (single source of truth)'])
files = {PROJECT + '.kicad_sch': root.render(is_root=True, title_block=tb)}
for key, w in writers.items():
sh = D.sheets[key]
t = dict(tb)
t['title'] = sh['title']
files[sh['file']] = w.render(is_root=False, title_block=t)
for fn, content in files.items():
with open(os.path.join(OUT, fn), 'w') as f:
f.write(content)
return files
if __name__ == '__main__':
for fn in generate():
print('wrote', fn)