main
noah amfm-receiver-molecule: KiCad project, fab files, 3D and README (6/7) 53707c4 1d ago
"""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)