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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
"""AFS_Adom silkscreen step: label manifest -> candidate search (adom-aiflow silkscreen-layout) -> native apply through
the KiCad bridge (kicad_silk_fields_batch / kicad_silk_text_batch / kicad_silk_graphics_batch) -> whole-board audit input.
Phases (run in order; every phase writes its files under AFS_Adom/aiflow/silk/):
python3 silk.py parse <board.kicad_pcb> footprints, pads, vias, holes, existing silk -> board.json
python3 silk.py manifest label manifest (what each label says and why) -> manifest.json
python3 silk.py measure native text bounds through kicad_text_bounds -> bounds.json (estimate fallback flagged)
python3 silk.py solve per-face silkscreen-layout inputs + results -> layout_F.json / layout_B.json
python3 silk.py batches bridge batch payloads -> apply_fields.json, apply_text_F.json, apply_text_B.json, apply_graphics.json
python3 silk.py audit silkscreen-audit input from the applied layout -> audit.json
Board frame: mm, +y down (KiCad). Rotation convention as in pcb.py: abs = (cx + px cos r + py sin r, cy - px sin r + py cos r).
"""
import json, math, os, re, subprocess, sys
SCR = '/tmp/claude-1001/-home-adom-project/8819fb5f-c2c6-4524-9528-99428cd6b88e/scratchpad'
PRJ = SCR + '/AFS_Adom'
OUT = PRJ + '/aiflow/silk'
BOARD_WIN = 'C:/Users/angus/Documents/aiflow/AFS_Adom/AFS_Adom.kicad_pcb'
THREAD = 'AFS_Adom-flight-computer'
os.makedirs(OUT, exist_ok=True)
# ----------------------------------------------------------------------------------------------- s-expression parser
def parse_sexp(text):
tokens = re.findall(r'"(?:[^"\\]|\\.)*"|\(|\)|[^\s()]+', text)
stack = [[]]
for t in tokens:
if t == '(':
stack.append([])
elif t == ')':
node = stack.pop(); stack[-1].append(node)
else:
if t.startswith('"'):
t = t[1:-1].replace('\\"', '"').replace('\\\\', '\\')
stack[-1].append(t)
return stack[0]
def kids(node, key):
return [n for n in node[1:] if isinstance(n, list) and n and n[0] == key]
def kid(node, key, default=None):
k = kids(node, key)
return k[0] if k else default
def rot_abs(cx, cy, px, py, r):
c, s = math.cos(math.radians(r)), math.sin(math.radians(r))
return (cx + px * c + py * s, cy - px * s + py * c)
def bbox_of(points):
xs = [p[0] for p in points]; ys = [p[1] for p in points]
return [min(xs), min(ys), max(xs), max(ys)]
def rect_box(cx, cy, w, h, ang):
hw, hh = w / 2, h / 2
pts = [rot_abs(cx, cy, px, py, ang) for px, py in ((-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh))]
return bbox_of(pts)
# ----------------------------------------------------------------------------------------------- parse
FAB_BODY = {'J1'}
def phase_parse(path):
tree = parse_sexp(open(path, encoding='utf-8').read())
pcb = tree[0]
board = dict(footprints=[], vias=[], texts=[], lines=[], outline=None, source=path)
edge = []
for n in pcb[1:]:
if not isinstance(n, list):
continue
k = n[0]
if k == 'footprint':
board['footprints'].append(parse_footprint(n))
elif k == 'via':
at = kid(n, 'at'); size = float(kid(n, 'size')[1]); drill = float(kid(n, 'drill')[1])
net = kid(n, 'net'); board['vias'].append(dict(x=float(at[1]), y=float(at[2]), size=size, drill=drill, net=net[1] if net else ''))
elif k == 'gr_text':
at = kid(n, 'at'); layer = kid(n, 'layer')[1]; eff = kid(n, 'effects'); font = kid(eff, 'font') if eff else None
size = float(kid(font, 'size')[1]) if font else 1.0; th = float(kid(font, 'thickness')[1]) if font and kid(font, 'thickness') else 0.15
just = kid(eff, 'justify') if eff else None
board['texts'].append(dict(text=n[1], x=float(at[1]), y=float(at[2]), rot=float(at[3]) if len(at) > 3 else 0.0, layer=layer, size=size, stroke=th,
uuid=kid(n, 'uuid')[1], mirror=bool(just and 'mirror' in just)))
elif k in ('gr_line', 'gr_rect', 'gr_arc', 'gr_poly', 'gr_circle'):
layer = kid(n, 'layer')[1]
if layer == 'Edge.Cuts':
for key in ('start', 'end', 'mid'):
p = kid(n, key)
if p: edge.append((float(p[1]), float(p[2])))
pts = kid(n, 'pts')
if pts:
for xy in kids(pts, 'xy'): edge.append((float(xy[1]), float(xy[2])))
else:
if k == 'gr_line':
s, e = kid(n, 'start'), kid(n, 'end'); w = float(kid(kid(n, 'stroke'), 'width')[1])
board['lines'].append(dict(layer=layer, box=bbox_of([(float(s[1]), float(s[2])), (float(e[1]), float(e[2]))]), width=w, uuid=kid(n, 'uuid')[1]))
board['outline'] = bbox_of(edge) if edge else None
json.dump(board, open(OUT + '/board.json', 'w'), indent=0)
print('parsed', len(board['footprints']), 'footprints', len(board['vias']), 'vias', len(board['texts']), 'texts', 'outline', board['outline'])
return board
def parse_footprint(n):
name = n[1]; layer = kid(n, 'layer')[1]; at = kid(n, 'at'); cx, cy = float(at[1]), float(at[2]); rot = float(at[3]) if len(at) > 3 else 0.0
props = {}
for p in kids(n, 'property'):
pat = kid(p, 'at'); pl = kid(p, 'layer'); hide = kid(p, 'hide'); eff = kid(p, 'effects')
font = kid(eff, 'font') if eff else None
props[p[1]] = dict(text=p[2], uuid=kid(p, 'uuid')[1], x=float(pat[1]) if pat else 0.0, y=float(pat[2]) if pat else 0.0,
rot=float(pat[3]) if pat and len(pat) > 3 else 0.0, layer=pl[1] if pl else None,
hidden=bool(hide and hide[1] == 'yes'), size=float(kid(font, 'size')[1]) if font and kid(font, 'size') else 1.0)
crt = {'F': [], 'B': []}; fab = []
for g in kids(n, 'fp_line') + kids(n, 'fp_rect'):
lay = kid(g, 'layer')[1]
if lay.endswith('.CrtYd'):
s, e = kid(g, 'start'), kid(g, 'end')
crt[lay[0]] += [rot_abs(cx, cy, float(s[1]), float(s[2]), rot), rot_abs(cx, cy, float(e[1]), float(e[2]), rot)]
elif lay == 'F.Fab':
s, e = kid(g, 'start'), kid(g, 'end')
fab += [rot_abs(cx, cy, float(s[1]), float(s[2]), rot), rot_abs(cx, cy, float(e[1]), float(e[2]), rot)]
for g in kids(n, 'fp_poly'):
lay = kid(g, 'layer')[1]
if lay.endswith('.CrtYd'):
crt[lay[0]] += [rot_abs(cx, cy, float(xy[1]), float(xy[2]), rot) for xy in kids(kid(g, 'pts'), 'xy')]
for g in kids(n, 'fp_circle'):
lay = kid(g, 'layer')[1]
if lay.endswith('.CrtYd'):
c, e = kid(g, 'center'), kid(g, 'end'); r = math.dist((float(c[1]), float(c[2])), (float(e[1]), float(e[2])))
ccx, ccy = rot_abs(cx, cy, float(c[1]), float(c[2]), rot)
crt[lay[0]] += [(ccx - r, ccy - r), (ccx + r, ccy + r)]
pads = []
for p in kids(n, 'pad'):
pat = kid(p, 'at'); px, py = float(pat[1]), float(pat[2]); pang = float(pat[3]) if len(pat) > 3 else rot
size = kid(p, 'size'); w, h = float(size[1]), float(size[2])
layers = kid(p, 'layers')[1:]; drill = kid(p, 'drill')
ax, ay = rot_abs(cx, cy, px, py, rot)
faces = []
if any(l in ('F.Cu', '*.Cu', 'F.Mask', '*.Mask') for l in layers): faces.append('F')
if any(l in ('B.Cu', '*.Cu', 'B.Mask', '*.Mask') for l in layers): faces.append('B')
pads.append(dict(number=p[1], type=p[2], shape=p[3], x=ax, y=ay, box=rect_box(ax, ay, w, h, pang), faces=faces,
drill=float(drill[1]) if drill and len(drill) > 1 and re.match(r'^[\d.]+$', drill[1]) else 0.0, net=(kid(p, 'net') or ['', ''])[-1]))
fp = dict(name=name, layer=layer, x=cx, y=cy, rot=rot, ref=props['Reference']['text'], value=props['Value']['text'], props=props, pads=pads,
courtyard={k: (bbox_of(v) if v else None) for k, v in crt.items()}, fab=(bbox_of(fab) if fab else None))
if fp['ref'] in FAB_BODY and fp['fab']:
# fitted body = the F.Fab box (AI-created placeholder STEP box over the fab outline); pads stay separate obstacles
fp['courtyardFull'] = fp['courtyard']['F']; fp['courtyard']['F'] = [min(fp['fab'][0], min(p['box'][0] for p in pads)), min(fp['fab'][1], min(p['box'][1] for p in pads)),
max(fp['fab'][2], max(p['box'][2] for p in pads)), max(fp['fab'][3], max(p['box'][3] for p in pads))]
fp['courtyard']['F'] = fp['fab']; fp['bodySource'] = 'F.Fab box (placeholder model); pads as separate obstacles'
if not fp['courtyard']['F'] and pads:
b = bbox_of([(p['box'][0], p['box'][1]) for p in pads] + [(p['box'][2], p['box'][3]) for p in pads])
fp['courtyard']['F'] = [b[0] - 0.25, b[1] - 0.25, b[2] + 0.25, b[3] + 0.25]; fp['courtyardSource'] = 'pads+0.25'
return fp
# ----------------------------------------------------------------------------------------------- manifest
REF_H, VAL_H = 0.8, 0.8 # PCBWay rule pack: silk text >= 0.8 mm high, stroke >= 0.15 mm (min-silk-thickness)
STROKE = {1.2: 0.18, 1.0: 0.15, 0.8: 0.15}
CONTACT_SHORT = {'MC9': 'TX', 'MC10': 'RX', 'MC11': 'SWCLK', 'MC12': 'SWDIO', 'MC14': 'SERVO1'}
CONTACT_FN = {'MC1': 'VBAT_IN', 'MC2': 'GND', 'MC3': 'VBAT', 'MC4': 'GND', 'MC5': '+5V', 'MC6': 'GND', 'MC7': '+3V3', 'MC8': 'GND',
'MC9': 'UART0_TX', 'MC10': 'UART0_RX', 'MC11': 'SWCLK', 'MC12': 'SWDIO', 'MC13': 'RUN', 'MC14': 'SERVO1_5V'}
HEADER_PINS = {'J12': ['SWCLK', 'SWDIO', '3V3', 'GND'], 'J13': ['TX', 'RX', '3V3', 'GND'], 'J14': ['SCL', 'SDA', '3V3', 'GND'], 'J15': ['SCL', 'SDA', '3V3', 'GND'],
'J16': ['MISO', 'CS', 'SCK', 'MOSI'], 'J17': ['IO28', 'IO29', 'IO30', 'IO31'], 'J18': ['3V3', 'GND'], 'J19': ['3V3', 'GND'],
'J20': ['5V', 'GND'], 'J21': ['5V', 'GND'], 'J1': ['+', '-']}
for i in range(1, 9):
HEADER_PINS['J%d' % (i + 1)] = ['S', 'VBAT', 'GND']
HEADER_NAME = {'J1': 'BATT 2S 6-8.4V', 'J10': 'USB-C 5V', 'J11': 'microSD', 'J12': 'SWD', 'J13': 'UART0', 'J14': 'I2C0', 'J15': 'I2C1', 'J16': 'SPI1',
'J17': 'GPIO', 'J18': '3V3 OUT', 'J19': '3V3 OUT', 'J20': '5V OUT', 'J21': '5V OUT'}
for i in range(1, 9):
HEADER_NAME['J%d' % (i + 1)] = 'SERVO%d' % i
VALUE_TEXT = {'XT30 BATT': 'XT30', 'MachinePinMediumShort': 'GND', 'RED VBAT': 'VBAT', 'YEL VBUS': 'VBUS', 'GRN 5V': '5V', 'GRN 3V3': '3V3', 'BLU USER': 'USER'}
IDENTITY_TOP = [('F', 'AFS_Adom rev A', 1.0), ('F', 'Active Fin Stabilization flight computer', 0.8),
('B', 'RP2350B flight computer', 0.8), ('B', 'ICM-45686 KX134 MMC5983 MS5611: SPI1', 0.8), ('B', 'TMP117 INA219: I2C0', 0.8),
('B', '8 servo outputs, 5V logic', 0.8), ('B', 'S = signal, VBAT = raw 2S, GND', 0.8), ('B', 'in: USB-C 5V or 2S LiPo 6-8.4V', 0.8)]
def legend_lines(ref):
pins = HEADER_PINS[ref]; items = [f'{i} {pn}' for i, pn in enumerate(pins, 1)]
if len(items) <= 2: return [' '.join(items)]
half = (len(items) + 1) // 2
return [' '.join(items[:half]), ' '.join(items[half:])]
def phase_manifest():
board = json.load(open(OUT + '/board.json'))
tps = dict(json.load(open(PRJ + '/aiflow/testpoints.json')))
labels = []
def add(**kw):
labels.append(kw)
for fp in board['footprints']:
ref, val = fp['ref'], fp['value']
kind = re.match(r'[A-Za-z]+', ref).group(0)
if kind in ('MC', 'MP'):
fn = CONTACT_SHORT.get(ref) or CONTACT_FN.get(ref, 'GND')
for face in ('F', 'B'):
add(id=f'{ref}-{face}', kind='contact', ref=ref, face=face, texts=[dict(role='reference', text=ref, height=REF_H), dict(role='function', text=fn, height=VAL_H)], net=CONTACT_FN.get(ref, 'GND'),
policy='outside-first', source='design.py MOLECULE_CONTACTS / molecule corner pins (GND)')
elif kind == 'TP':
add(id=f'{ref}-F', kind='testpoint', ref=ref, face='F', texts=[dict(role='reference', text=f'{ref} {tps[ref]}', height=VAL_H)], textsAlt=[dict(role='reference', text=ref, height=0.8)], source='aiflow/testpoints.json')
elif kind == 'H':
add(id=f'{ref}-F', kind='hole', ref=ref, face='F', texts=[dict(role='reference', text=f'{ref} M3', height=0.8)], source='mounting hole, M3')
elif kind == 'J':
name = HEADER_NAME.get(ref, val)
add(id=f'{ref}-F', kind='header', ref=ref, face='F', texts=[dict(role='reference', text=ref, height=REF_H), dict(role='value', text=name, height=0.8)], source='design.py connectors sheet')
pins = HEADER_PINS.get(ref)
if pins:
for i, pn in enumerate(pins, 1):
pad = next(p for p in fp['pads'] if p['number'] == str(i))
for face in (('F', 'B') if 'B' in pad['faces'] else ('F',)):
add(id=f'{ref}.{i}-{face}', kind='pin', ref=ref, pin=str(i), face=face, texts=[dict(role='pin', text=pn, height=VAL_H)], pad=pad, net=pad['net'],
source=f'pad {ref}.{i} net {pad["net"]}')
for face in (('F', 'B') if 'B' in fp['pads'][0]['faces'] else ('F',)):
add(id=f'{ref}-legend-{face}', kind='legend', ref=ref, face=face, texts=[dict(role='legend', text=t, height=VAL_H) for t in legend_lines(ref)],
textsAlt=[dict(role='legend', text=f'{i} {pn}', height=VAL_H) for i, pn in enumerate(pins, 1)], source=f'pin key for {ref}: pad numbers and nets', fallback=True)
if 'B' in fp['pads'][0]['faces']:
add(id=f'{ref}-B', kind='header', ref=ref, face='B', texts=[dict(role='reference', text=ref, height=0.8), dict(role='value', text=name, height=VAL_H)], source='through-hole header, repeated on the back face')
else:
vtext = VALUE_TEXT.get(val, val)
add(id=f'{ref}-F', kind='part', ref=ref, face='F', texts=[dict(role='reference', text=ref, height=REF_H), dict(role='value', text=vtext, height=VAL_H)], source=f'{ref} value {val}')
tpl = sorted(tps.items(), key=lambda kv: int(kv[0][2:]))
for part in range(0, len(tpl), 3):
chunk = tpl[part:part + 3]
add(id=f'TPKEY{part // 3}-B', kind='block', ref=None, face='B', texts=[dict(role='key', text=f'{r} {n}', height=0.8) for r, n in chunk],
source='aiflow/testpoints.json: reference -> net key on the back face (supplementary to the top-face labels)', section='tp-key')
for face in ('F', 'B'):
rows = [dict(role='identity', text=t, height=h) for ff, t, h in IDENTITY_TOP if ff == face]
for k in range(0, len(rows), 2):
add(id=f'ID{k // 2}-{face}', kind='block', ref=None, face=face, texts=rows[k:k + 2], source='project brief / schematic root note (left-aligned two-line blocks)', section='identity')
add(id='BOOTSEL-note-B', kind='note', ref='SW1', face='B', texts=[dict(role='note', text='hold BOOTSEL + press RESET = USB boot', height=0.8)], source='RP2350 datasheet BOOTSEL')
json.dump(labels, open(OUT + '/manifest.json', 'w'), indent=0)
from collections import Counter
print('manifest', len(labels), Counter(l['kind'] for l in labels))
return labels
# ----------------------------------------------------------------------------------------------- measure
def est_box(text, h):
"""Estimate of KiCad stroke-font extents (newstroke, thickness ~0.15 h): ~0.86 h per glyph, 0.3 h for a space."""
w = sum(0.3 * h if ch == ' ' else 0.86 * h for ch in text) + 0.15 * h
return [w, h * 1.2]
def bridge(verb, args):
r = subprocess.run(['adom-bridge', '--ai-thread', THREAD, verb, json.dumps(args)], capture_output=True, text=True, timeout=200)
try:
return json.loads(r.stdout)
except Exception:
return dict(success=False, raw=r.stdout[-2000:], err=r.stderr[-2000:])
def phase_measure():
labels = json.load(open(OUT + '/manifest.json'))
board = json.load(open(OUT + '/board.json'))
uniq = {}
for l in labels:
for t in l['texts'] + l.get('textsAlt', []):
for h in sorted(set(font_ladder(t['height']) + [0.8, 1.0])):
uniq[(t['text'], h)] = None
for t in board['texts']:
uniq[(t['text'], t['size'])] = None
keys = list(uniq)
bounds = {}
measured = 0
for i in range(0, len(keys), 80):
chunk = keys[i:i + 80]
res = bridge('kicad_text_bounds', dict(filePath=BOARD_WIN, texts=[dict(id=str(j), text=k[0], x=146.0, y=134.0, height=k[1], width=k[1], stroke=STROKE.get(k[1], 0.15), align='center') for j, k in enumerate(chunk)]))
items = res.get('texts') or res.get('bounds') or res.get('results') or []
if not res.get('success') or not items:
print('measure chunk failed, estimate used:', json.dumps(res)[:300])
for k in chunk:
bounds['%s|%s' % k] = dict(w=est_box(*k)[0], h=est_box(*k)[1], native=False)
continue
for j, k in enumerate(chunk):
it = next((x for x in items if str(x.get('id')) == str(j)), items[j] if j < len(items) else None)
box = it.get('box') or it.get('bounds') or it.get('bbox') if it else None
if isinstance(box, dict):
box = [box.get('xmin', box.get('x1')), box.get('ymin', box.get('y1')), box.get('xmax', box.get('x2')), box.get('ymax', box.get('y2'))]
if box and None not in box:
bounds['%s|%s' % k] = dict(w=box[2] - box[0], h=box[3] - box[1], native=True, raw=it); measured += 1
else:
bounds['%s|%s' % k] = dict(w=est_box(*k)[0], h=est_box(*k)[1], native=False, raw=it)
json.dump(bounds, open(OUT + '/bounds.json', 'w'), indent=0)
print('measured', measured, 'of', len(keys), 'native' if measured == len(keys) else 'PARTLY ESTIMATED')
if keys:
print('sample', keys[0], bounds['%s|%s' % keys[0]])
def font_ladder(h):
# PCBWay standard silk: 0.8 mm minimum height, 0.15 mm minimum stroke (board setup + rule pack); no smaller text
if h >= 1.0: return [1.0, 0.8]
return [0.8]
# ----------------------------------------------------------------------------------------------- solve
PAD_MARGIN = 0.25 # silk to exposed copper/mask opening
BODY_MARGIN = 0.1
VIA_MARGIN = 0.2
EDGE_MARGIN = 0.6
GAP = 0.05 # between reference and value lines (native boxes already carry the font's padding)
def text_box(bounds, text, h):
b = bounds['%s|%s' % (text, h)]
return b['w'], b['h']
def obstacles_for(board, face, bounds):
obs = []
for fp in board['footprints']:
c = fp['courtyard'].get(face)
if c and fp['layer'][0] == face:
obs.append(dict(id=f"{fp['ref']}:body", kind='body', layer=f'{face}.SilkS', box=[c[0] - BODY_MARGIN, c[1] - BODY_MARGIN, c[2] + BODY_MARGIN, c[3] + BODY_MARGIN]))
for p in fp['pads']:
if face in p['faces']:
b = p['box']
obs.append(dict(id=f"{fp['ref']}.{p['number']}", kind='pad', layer=f'{face}.SilkS', box=[b[0] - PAD_MARGIN, b[1] - PAD_MARGIN, b[2] + PAD_MARGIN, b[3] + PAD_MARGIN]))
for i, v in enumerate(board['vias']):
r = v['drill'] / 2 + 0.15 # vias are tented on both faces (board tenting): the hole is the keepout, not the annular ring
obs.append(dict(id=f"via{i}:{v['x']:.2f},{v['y']:.2f}", kind='via', layer=f'{face}.SilkS', box=[v['x'] - r, v['y'] - r, v['x'] + r, v['y'] + r]))
# the four 2 mm corner arcs: a label inside the rectangle but outside the arc is clipped by the board edge
ox = board['outline']; R = 2.0
for cx, cy, n in ((ox[0], ox[1], 'tl'), (ox[2], ox[1], 'tr'), (ox[0], ox[3], 'bl'), (ox[2], ox[3], 'br')):
obs.append(dict(id=f'corner:{n}', kind='edge', layer=f'{face}.SilkS', box=[min(cx, cx + (R if cx == ox[0] else -R)), min(cy, cy + (R if cy == ox[1] else -R)), max(cx, cx + (R if cx == ox[0] else -R)), max(cy, cy + (R if cy == ox[1] else -R))]))
for t in board['texts']:
if t['layer'] == f'{face}.SilkS':
w, h = text_box(bounds, t['text'], t['size'])
obs.append(dict(id=f"text:{t['uuid'][:8]}", kind='silk', layer=f'{face}.SilkS', box=[t['x'] - w / 2 - 0.1, t['y'] - h / 2 - 0.1, t['x'] + w / 2 + 0.1, t['y'] + h / 2 + 0.1]))
for l in board['lines']:
if l['layer'] == f'{face}.SilkS':
b = l['box']; m = l['width'] / 2 + 0.1
obs.append(dict(id=f"line:{l['uuid'][:8]}", kind='silk', layer=f'{face}.SilkS', box=[b[0] - m, b[1] - m, b[2] + m, b[3] + m]))
return obs
def pair_candidates(label, anchor_box, anchor, bounds, outline):
"""Candidates for a stacked reference/value pair (or a single text) around a box: 4 sides x offsets x font ladder."""
cands = []
texts = label['texts']
ladders = [font_ladder(t['height']) for t in texts]
combos = []
for i in range(max(len(l) for l in ladders)):
combos.append([l[min(i, len(l) - 1)] for l in ladders])
if len(texts) == 2 and texts[1]['role'] != 'legend':
combos.append([0.8, None]) # reference only, last resort
x0, y0, x1, y1 = anchor_box
for fonts in combos:
rows = []
for t, f in zip(texts, fonts):
if f is None: continue
w, h = text_box(bounds, t['text'], f)
rows.append((t, f, w, h))
W = max(r[2] for r in rows); H = sum(r[3] for r in rows) + GAP * (len(rows) - 1)
penalty = 0 if len(rows) == len(texts) else 400
places = []
OFF = BODY_MARGIN + 0.12 + 0.08
wide = texts[0]['role'] == 'legend'
for dx in ((0, -1.0, 1.0, -2.0, 2.0, -3.0, 3.0, -4.0, 4.0, -5.5, 5.5, -7.0, 7.0) if wide else (0, -1.0, 1.0, -2.0, 2.0, -3.0, 3.0, -4.0, 4.0)):
for extra in ((0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0) if wide else (0, 0.5, 1.0, 1.5, 2.0)):
places.append(('top', anchor[0] + dx, y0 - OFF - extra - H / 2)); places.append(('bottom', anchor[0] + dx, y1 + OFF + extra + H / 2))
for dy in ((0, -1.0, 1.0, -2.0, 2.0, -3.0, 3.0, -4.5, 4.5) if wide else (0, -1.0, 1.0, -2.0, 2.0, -3.0, 3.0)):
for extra in ((0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0) if wide else (0, 0.5, 1.0, 1.5, 2.0)):
places.append(('left', x0 - OFF - extra - W / 2, anchor[1] + dy)); places.append(('right', x1 + OFF + extra + W / 2, anchor[1] + dy))
if len(rows) == 1:
t, f, w, h = rows[0]
for extra in (0, 0.5, 1.0, 1.5):
for d in (0, -0.6, 0.6):
for side, cx, cy in (('topR', anchor[0] + d, y0 - OFF - extra - w / 2), ('bottomR', anchor[0] + d, y1 + OFF + extra + w / 2), ('leftR', x0 - OFF - extra - h / 2, anchor[1] + d), ('rightR', x1 + OFF + extra + h / 2, anchor[1] + d)):
box = [cx - h / 2, cy - w / 2, cx + h / 2, cy + w / 2]
cands.append(dict(font=f, position=[round(cx, 3), round(cy, 3)], box=[round(v, 3) for v in box], boxes=[[round(v, 3) for v in box]], side=side, qualityPenalty=penalty + 4,
children=[dict(role=t['role'], text=t['text'], size=f, x=round(cx, 3), y=round(cy, 3), w=h, h=w, rotation=90)]))
for side, cx, cy in places:
boxes = []; children = []; yy = cy - H / 2
for t, f, w, h in rows:
boxes.append([cx - w / 2, yy, cx + w / 2, yy + h])
children.append(dict(role=t['role'], text=t['text'], size=f, x=round(cx, 3), y=round(yy + h / 2, 3), w=w, h=h))
yy += h + GAP
box = [cx - W / 2, cy - H / 2, cx + W / 2, cy + H / 2]
cands.append(dict(font=min(r[1] for r in rows), position=[round(cx, 3), round(cy, 3)], box=[round(v, 3) for v in box], boxes=[[round(v, 3) for v in b] for b in boxes],
children=children, side=side, qualityPenalty=penalty + (0 if side in ('top', 'bottom') else 5)))
return cands
def contact_candidates(label, fp, bounds, outline):
"""Machine contacts: outside-first (between the contact and the nearest edge), then inside; stacked ref/function."""
c = fp['courtyard']['F']; cx, cy = fp['x'], fp['y']
dists = {'left': cx - outline[0], 'right': outline[2] - cx, 'top': cy - outline[1], 'bottom': outline[3] - cy}
near = min(dists, key=dists.get)
cands = pair_candidates(label, c, (cx, cy), bounds, outline)
for cd in cands:
if cd['side'] == near: cd['qualityPenalty'] = cd.get('qualityPenalty', 0) - 30 # outside-first
cd['qualityPenalty'] = cd.get('qualityPenalty', 0) + 5 * abs(cd['position'][0] - cx) * (1 if near in ('left', 'right') else 0)
# rotated pair (text running away from the edge) for contacts in a dense row along the top/bottom edge
if near in ('top', 'bottom'):
OFF = BODY_MARGIN + 0.12 + 0.08
for fr, ff in ((0.8, 0.8), (0.8, None)):
wr, hr = text_box(bounds, label['texts'][0]['text'], fr)
cols = [(label['texts'][0], fr, wr, hr)]
if ff:
wf, hf = text_box(bounds, label['texts'][1]['text'], ff); cols.append((label['texts'][1], ff, wf, hf))
W = sum(cl[3] for cl in cols) + GAP * (len(cols) - 1); H = max(cl[2] for cl in cols)
for extra in (0, 0.5, 1.0, 1.5, 2.0, 2.5):
for dx in (0, -0.5, 0.5, -1.0, 1.0):
cy0 = (c[1] - OFF - extra - H / 2) if near == 'bottom' else (c[3] + OFF + extra + H / 2)
xx = cx + dx - W / 2; boxes = []; children = []
for t, f, w, h in cols:
boxes.append([xx, cy0 - w / 2, xx + h, cy0 + w / 2]); children.append(dict(role=t['role'], text=t['text'], size=f, x=round(xx + h / 2, 3), y=round(cy0, 3), w=h, h=w, rotation=90)); xx += h + GAP
cands.append(dict(font=min(cl[1] for cl in cols), position=[round(cx + dx, 3), round(cy0, 3)], box=[round(v, 3) for v in [cx + dx - W / 2, cy0 - H / 2, cx + dx + W / 2, cy0 + H / 2]],
boxes=[[round(v, 3) for v in b] for b in boxes], children=children, side='inside-rotated', qualityPenalty=8 + (0 if ff else 400)))
return cands
def pin_candidates(label, fp, bounds):
"""Pin labels: outside the header's courtyard, in line with the pad; text rotated 90 deg when the pin row runs across so
neighbouring pin labels at 2.0 / 2.54 mm pitch stay apart; unrotated beside a vertical row."""
p = label['pad']; cands = []
px, py = p['x'], p['y']; c = fp['courtyard']['F'] or fp['courtyard']['B']
OFF = BODY_MARGIN + 0.12 + 0.08
text = label['texts'][0]['text']
for f in font_ladder(VAL_H):
w, h = text_box(bounds, text, f)
opts = []
for extra in (0, 0.4, 0.8, 1.2):
# rotated (vertical text) above / below the courtyard at the pad's x
opts.append(('below', 90, px, c[3] + OFF + extra + w / 2, h, w)); opts.append(('above', 90, px, c[1] - OFF - extra - w / 2, h, w))
# plain text left / right of the courtyard at the pad's y
opts.append(('right', 0, c[2] + OFF + extra + w / 2, py, w, h)); opts.append(('left', 0, c[0] - OFF - extra - w / 2, py, w, h))
# plain text above / below (2-pin headers and wide pitches)
opts.append(('belowH', 0, px, c[3] + OFF + extra + h / 2, w, h)); opts.append(('aboveH', 0, px, c[1] - OFF - extra - h / 2, w, h))
for side, rot, cx, cy, bw, bh in opts:
box = [cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2]
cands.append(dict(font=f, position=[round(cx, 3), round(cy, 3)], box=[round(v, 3) for v in box], side=side,
children=[dict(role='pin', text=text, size=f, x=round(cx, 3), y=round(cy, 3), w=bw, h=bh, rotation=rot)], qualityPenalty=(0 if rot == 0 else 2)))
return cands
def identity_candidates(label, bounds, outline, grid=2.0):
"""Identity / note lines: a coarse grid over the board interior, preferring open space near the board centre-left."""
cands = []
t = label['texts'][0]
for f in font_ladder(t['height']):
w, h = text_box(bounds, t['text'], f)
y = outline[1] + 3.0
while y < outline[3] - 3.0:
x = outline[0] + 3.0 + w / 2
while x < outline[2] - 3.0 - w / 2:
box = [x - w / 2, y - h / 2, x + w / 2, y + h / 2]
cands.append(dict(font=f, position=[round(x, 3), round(y, 3)], box=[round(v, 3) for v in box], children=[dict(role=t['role'], text=t['text'], size=f, x=round(x, 3), y=round(y, 3), w=w, h=h)], qualityPenalty=0))
x += grid
y += grid
return cands
def prefilter(groups, obs, bnd, gap=0.12, keep=150):
"""Drop candidates that leave the board or hit a fixed obstacle (grid-bucketed), keep the best `keep` per label by
font then distance, so the solver's own pass is small. Same test as the solver's (rect overlap with its clearance)."""
cell = 2.0
grid = {}
for o in obs:
b = o['box']
for ix in range(int((b[0] - gap) // cell), int((b[2] + gap) // cell) + 1):
for iy in range(int((b[1] - gap) // cell), int((b[3] + gap) // cell) + 1):
grid.setdefault((ix, iy), []).append(b)
def clear(r):
if not (r[0] >= bnd[0] and r[1] >= bnd[1] and r[2] <= bnd[2] and r[3] <= bnd[3]): return False
seen = set()
for ix in range(int((r[0] - gap) // cell), int((r[2] + gap) // cell) + 1):
for iy in range(int((r[1] - gap) // cell), int((r[3] + gap) // cell) + 1):
for b in grid.get((ix, iy), ()):
k = id(b)
if k in seen: continue
seen.add(k)
if not (r[2] + gap <= b[0] or b[2] + gap <= r[0] or r[3] + gap <= b[1] or b[3] + gap <= r[1]): return False
return True
total = 0; kept = 0
for g in groups:
ok = [c for c in g['candidates'] if all(clear(r) for r in c.get('boxes', [c['box']]))]
total += len(g['candidates'])
ok.sort(key=lambda c: (-c['font'] * 100 + c.get('qualityPenalty', 0), math.dist(c['position'], g['anchor'])))
# keep alternatives at every font size
byfont = {}
for c in ok: byfont.setdefault(c['font'], []).append(c)
sel = []
for f in sorted(byfont, reverse=True):
# round-robin over sides so every direction keeps alternatives, nearest first within a side
sides = {}
for c in byfont[f]: sides.setdefault(c.get('side', ''), []).append(c)
quota = max(6, keep // max(1, len(byfont))); picked = []
while len(picked) < quota and any(sides.values()):
for k in list(sides):
if sides[k]: picked.append(sides[k].pop(0))
if len(picked) >= quota: break
sel += picked
g['candidates'] = sel[:keep] if len(sel) > keep else sel
g['clearCandidates'] = len(ok)
kept += len(g['candidates'])
print('prefilter', total, '->', kept, 'candidates;', sum(1 for g in groups if not g['candidates']), 'labels with no clear candidate')
def block_candidates(label, bounds, outline, grid=2.0):
"""A documentation block: its rows stacked, left-aligned, as one candidate per grid position; the whole block shrinks
together down the font ladder (heading stays larger than the body)."""
cands = []
ladders = [font_ladder(t['height']) for t in label['texts']]
for k in range(max(len(x) for x in ladders)):
fonts = [x[min(k, len(x) - 1)] for x in ladders]
rows = []
for t, f in zip(label['texts'], fonts):
w, h = text_box(bounds, t['text'], f); rows.append((t, f, w, h))
W = max(r[2] for r in rows); H = sum(r[3] for r in rows) + GAP * (len(rows) - 1)
y = outline[1] + 3.0 + H / 2
while y < outline[3] - 3.0 - H / 2:
x = outline[0] + 3.0
while x < outline[2] - 3.0 - W:
boxes = []; children = []; yy = y - H / 2
for t, f, w, h in rows:
boxes.append([x, yy, x + w, yy + h]); children.append(dict(role=t['role'], text=t['text'], size=f, x=round(x + w / 2, 3), y=round(yy + h / 2, 3), w=w, h=h)); yy += h + GAP
cands.append(dict(font=min(fonts), position=[round(x + W / 2, 3), round(y, 3)], box=[round(v, 3) for v in [x, y - H / 2, x + W, y + H / 2]], boxes=[[round(v, 3) for v in b] for b in boxes], children=children, qualityPenalty=0))
x += grid
y += grid
return cands
def phase_solve():
os.makedirs(PRJ + '/aiflow/run/silkscreen-dashboard', exist_ok=True)
board = json.load(open(OUT + '/board.json')); bounds = json.load(open(OUT + '/bounds.json')); labels = json.load(open(OUT + '/manifest.json'))
outline = board['outline']; fps = {fp['ref']: fp for fp in board['footprints']}
labels_by_id = {l['id']: l for l in labels}
results = {}
for face in ('F', 'B'):
obs = obstacles_for(board, face, bounds)
groups = []
use_legend = set()
for attempt in range(3):
groups = []
for l in labels:
if l['face'] != face: continue
if l['kind'] == 'legend' and l['ref'] not in use_legend: continue
if l['kind'] == 'pin' and l['ref'] in use_legend: continue
fp = fps.get(l['ref']) if l.get('ref') else None
if l['kind'] == 'contact':
cands = contact_candidates(l, fp, bounds, outline); anchor = [fp['x'], fp['y']]
elif l['kind'] == 'pin':
cands = pin_candidates(l, fp, bounds); anchor = [l['pad']['x'], l['pad']['y']]
elif l['kind'] in ('identity',):
cands = identity_candidates(l, bounds, outline); anchor = [146.0, 134.0]
elif l['kind'] == 'block':
cands = block_candidates(l, bounds, outline); anchor = [146.0, 134.0]
elif l['kind'] == 'note':
cands = identity_candidates(l, bounds, outline, grid=1.5); anchor = [fp['x'], fp['y']]
elif l['kind'] == 'legend':
c = fp['courtyard'].get('F') or fp['courtyard'].get('B')
cands = pair_candidates(l, c, (fp['x'], fp['y']), bounds, outline); anchor = [fp['x'], fp['y']]
for cd in cands: cd['qualityPenalty'] = cd.get('qualityPenalty', 0) - 400 if len(cd['children']) == len(l['texts']) else 1e6
if l.get('textsAlt'):
alt = pair_candidates(dict(l, texts=l['textsAlt']), c, (fp['x'], fp['y']), bounds, outline)
for cd in alt: cd['qualityPenalty'] = cd.get('qualityPenalty', 0) - 395 if len(cd['children']) == len(l['textsAlt']) else 1e6
cands += alt
cands = [cd for cd in cands if cd['qualityPenalty'] < 1e5]
else:
c = fp['courtyard'].get('F') or fp['courtyard'].get('B')
cands = pair_candidates(l, c, (fp['x'], fp['y']), bounds, outline); anchor = [fp['x'], fp['y']]
if l.get('textsAlt'):
alt = pair_candidates(dict(l, texts=l['textsAlt']), c, (fp['x'], fp['y']), bounds, outline)
for cd in alt: cd['qualityPenalty'] = cd.get('qualityPenalty', 0) + 300
cands += alt
diag = math.dist((c[0], c[1]), (c[2], c[3])) if l['kind'] in ('part', 'header', 'contact', 'testpoint', 'hole') and (c := (fp['courtyard'].get('F') or fp['courtyard'].get('B'))) else 0
g = dict(id=l['id'], anchor=[round(anchor[0], 3), round(anchor[1], 3)], candidates=cands, maxDistance=(60 if l['kind'] in ('identity', 'block') else 12 if l['kind'] == 'note' else 5 if l['kind'] == 'pin' else max(3.5, diag / 2 + 2.5)))
if l['kind'] == 'identity':
g['orderGroup'] = 'identity'; g['order'] = l['order']
groups.append(g)
bnd = [outline[0] + EDGE_MARGIN, outline[1] + EDGE_MARGIN, outline[2] - EDGE_MARGIN, outline[3] - EDGE_MARGIN]
prefilter(groups, obs, bnd)
res = run_solver(face, bnd, obs, groups)
bad = {labels_by_id[u]['ref'] for u in res['unresolved'] if labels_by_id[u]['kind'] == 'pin'}
if not bad or attempt == 2: break
print(face, 'pin labels do not fit for', sorted(bad), '-> pin key legend instead')
use_legend |= bad
results[face] = res
print(face, 'placed', len(res['placements']) - len(res['unresolved']), 'unresolved', res['unresolved'][:40])
return results
def run_solver(face, bnd, obs, groups):
spec = dict(bounds=bnd, obstacles=[dict(box=o['box'], id=o['id']) for o in obs], labels=groups, clearance=0.12, beamWidth=80, candidatesPerLabel=100, fontWeight=100, distanceWeight=6, rowWeight=0,
boundsSource='kicad_text_bounds native metrics where measured (bounds.json native flag), conservative courtyard/pad/via rectangles')
json.dump(spec, open(OUT + f'/layout_in_{face}.json', 'w'))
if os.path.exists(OUT + f'/layout_{face}.json'): os.remove(OUT + f'/layout_{face}.json')
r = subprocess.run(['adom-aiflow', '--ai-thread', THREAD, '--run', PRJ + '/aiflow/run', 'silkscreen-layout', '--input', OUT + f'/layout_in_{face}.json', '--out', OUT + f'/layout_{face}.json',
'--events', PRJ + '/aiflow/run/silkscreen-dashboard/events.jsonl'], capture_output=True, text=True, cwd=PRJ)
print(face, r.stdout.strip()[:200].replace('\n', ' '))
if not os.path.exists(OUT + f'/layout_{face}.json'):
raise SystemExit('solver wrote nothing: ' + r.stdout + r.stderr)
return json.load(open(OUT + f'/layout_{face}.json'))
# ----------------------------------------------------------------------------------------------- batches
def phase_batches():
board = json.load(open(OUT + '/board.json')); labels = {l['id']: l for l in json.load(open(OUT + '/manifest.json'))}
fps = {fp['ref']: fp for fp in board['footprints']}
fields = []; texts = {'F': [], 'B': []}; applied = []
for face in ('F', 'B'):
if not os.path.exists(OUT + f'/layout_{face}.json'):
raise SystemExit('solver wrote nothing: ' + r.stdout + r.stderr)
res = json.load(open(OUT + f'/layout_{face}.json'))
for pl in res['placements']:
if pl.get('unresolved'): continue
l = labels[pl['id']]
for ch in pl['children']:
item = dict(label=pl['id'], role=ch['role'], text=ch['text'], x=ch['x'], y=ch['y'], size=ch['size'], layer=f'{face}.SilkS', box=[ch['x'] - ch['w'] / 2, ch['y'] - ch['h'] / 2, ch['x'] + ch['w'] / 2, ch['y'] + ch['h'] / 2])
if l['kind'] in ('part', 'header', 'contact') and face == 'F' and ch['role'] in ('reference', 'value', 'function') and l['ref'] in fps and not (l['kind'] == 'contact' and ch['role'] == 'function'):
prop = fps[l['ref']]['props']['Reference' if ch['role'] == 'reference' else 'Value']
if prop['text'] == ch['text']:
fields.append(dict(id=prop['uuid'], x=round(ch['x'], 3), y=round(ch['y'], 3), rotation=0, layer='F.SilkS', height=ch['size'], width=ch['size'], stroke=STROKE.get(ch['size'], 0.15), align='center', visible=True))
item['via'] = 'field'; item['uuid'] = prop['uuid']; applied.append(item); continue
texts[face].append(dict(text=ch['text'], layer=f'{face}.SilkS', x=round(ch['x'], 3), y=round(ch['y'], 3), rotation=ch.get('rotation', 0), height=ch['size'], width=ch['size'], stroke=STROKE.get(ch['size'], 0.15), align='center', mirrored=(face == 'B')))
item['via'] = 'text'; applied.append(item)
json.dump(dict(filePath=BOARD_WIN, update=fields), open(OUT + '/apply_fields.json', 'w'), indent=0)
for face in ('F', 'B'):
json.dump(dict(filePath=BOARD_WIN, create=texts[face]), open(OUT + f'/apply_text_{face}.json', 'w'), indent=0)
json.dump(applied, open(OUT + '/applied.json', 'w'), indent=0)
print('fields', len(fields), 'texts F', len(texts['F']), 'texts B', len(texts['B']))
# ----------------------------------------------------------------------------------------------- audit
def phase_audit():
board = json.load(open(OUT + '/board.json')); bounds = json.load(open(OUT + '/bounds.json')); applied = json.load(open(OUT + '/applied.json'))
inventory = []; obstacles = []; marks = []
for fp in board['footprints']:
face = fp['layer'][0]
layers = [f'{face}.SilkS']
inventory.append(dict(id=f"{fp['ref']}:body", kind='body', layers=layers))
c = fp['courtyard'][face]
obstacles.append(dict(id=f"{fp['ref']}:body", kind='body', layer=f'{face}.SilkS', box=c))
for p in fp['pads']:
inventory.append(dict(id=f"{fp['ref']}.{p['number']}", kind='pad', layers=[f'{f}.SilkS' for f in p['faces']]))
for f in p['faces']:
obstacles.append(dict(id=f"{fp['ref']}.{p['number']}", kind='pad', layer=f'{f}.SilkS', box=p['box']))
if p['drill'] > 0:
r = p['drill'] / 2
inventory.append(dict(id=f"{fp['ref']}.{p['number']}:hole", kind='hole', layers=['F.SilkS', 'B.SilkS']))
for f in ('F', 'B'):
obstacles.append(dict(id=f"{fp['ref']}.{p['number']}:hole", kind='hole', layer=f'{f}.SilkS', box=[p['x'] - r, p['y'] - r, p['x'] + r, p['y'] + r]))
for i, v in enumerate(board['vias']):
vid = f"via{i}:{v['x']:.2f},{v['y']:.2f}"; r = v['size'] / 2 + 0.02 # the audit box covers the whole via
inventory.append(dict(id=vid, kind='via', layers=['F.SilkS', 'B.SilkS']))
for f in ('F', 'B'):
obstacles.append(dict(id=vid, kind='via', layer=f'{f}.SilkS', box=[v['x'] - r, v['y'] - r, v['x'] + r, v['y'] + r]))
for a in applied:
marks.append(dict(id=f"{a['label']}:{a['role']}", layer=a['layer'], box=[round(v, 3) for v in a['box']], text=a['text'], size=a['size']))
applied_uuids = {a.get('uuid') for a in applied if a.get('uuid')}
applied_pos = {(round(a['x'], 2), round(a['y'], 2), a['text']) for a in applied}
for t in board['texts']:
if t['layer'].endswith('.SilkS'):
if t['uuid'] in applied_uuids or (round(t['x'], 2), round(t['y'], 2), t['text']) in applied_pos:
continue # already in marks with the solver's box (rotation and face known)
w, h = text_box(bounds, t['text'], t['size'])
if abs(t['rot']) % 180 == 90: w, h = h, w
marks.append(dict(id=f"text:{t['uuid'][:8]}", layer=t['layer'], box=[t['x'] - w / 2, t['y'] - h / 2, t['x'] + w / 2, t['y'] + h / 2], text=t['text'], size=t['size']))
audit = dict(schemaVersion=1, board=BOARD_WIN, inventory=inventory, obstacles=obstacles, marks=marks,
provenance='parse of the saved board (footprint courtyards, pads with mask, drills, vias); text boxes from kicad_text_bounds where native (bounds.json)')
json.dump(audit, open(OUT + '/audit.json', 'w'))
print('audit inventory', len(inventory), 'obstacles', len(obstacles), 'marks', len(marks))
if __name__ == '__main__':
ph = sys.argv[1]
if ph == 'parse': phase_parse(sys.argv[2])
elif ph == 'manifest': phase_manifest()
elif ph == 'measure': phase_measure()
elif ph == 'solve': phase_solve()
elif ph == 'batches': phase_batches()
elif ph == 'audit': phase_audit()