app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Publish 0.2.50
01ab14c
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
"""Issues #139 and #140 end to end on tests/fixtures/bottom-side.kicad_pcb: parts on both sides of the
board (U1 and R2 on B.Cu, SMD pads with copper on B.Cu only).
#139: `route` plans every net, `gate` applies the plan (no "pad U1.x has no copper on F.Cu"), `route
--variants 2` gates every variant, and `land route` sends the bridge a plan whose bottom-pad paths name
B.Cu. #140: with R2 parked outside the outline, `place check`, `route` and `land` name it as a finding
(never a refusal), `route` reports its nets unrouted with the reason (no panic), and `place pack` names
the part it found no spot for.
A fake kicad-cli answers DRC with no violations and a fake adom-bridge records every call, so this runs
with no CAD and no desktop. Usage: python3 tests/bottom-side-workflow.py <adom-aiflow binary>"""
import json, os, pathlib, subprocess, sys, tempfile
binary = str(pathlib.Path(sys.argv[1]).resolve())
fixture = pathlib.Path(__file__).resolve().parent / 'fixtures' / 'bottom-side.kicad_pcb'
FAKE_BRIDGE = r'''#!/usr/bin/env python3
import json, os, sys
a = sys.argv[1:]
i = a.index("--target")
verb, args = a[i + 2], json.loads(a[i + 3])
with open(os.environ["FAKE_LOG"], "a") as f: f.write(json.dumps({"verb": verb, "args": args}) + "\n")
print(json.dumps({"success": True, "status": "ok", "revision": "r1"}))
'''
FAKE_CLI = ('#!/usr/bin/env python3\nimport sys, json\na = sys.argv[1:]\n'
'if "--help" in a: print("--refill-zones"); sys.exit(0)\n'
'out = a[a.index("--output") + 1]\njson.dump({"violations": [], "unconnected_items": []}, open(out, "w"))\n')
passed = 0
def check(cond, what, out=''):
global passed
if not cond:
sys.exit(f'FAIL: {what}\n{out}')
passed += 1
def layer_walk(board_pads, plan):
"""Every pad a path reaches with the layer the path is on there; returns the ones with no copper there."""
bad = []
for e in plan['nets']:
for path in e['paths']:
def wp(v):
key = v if isinstance(v, str) else (v.get('pad') if isinstance(v, dict) else None)
return key, (v.get('layer') if isinstance(v, dict) else None)
layer = e.get('layer') or wp(path[0])[1]
if layer is None:
layer = 'F.Cu'
for v in path:
k, l = wp(v)
if l: break
if k and board_pads[k] in ('F', 'B'):
layer = board_pads[k] + '.Cu'; break
for v in path:
k, l = wp(v)
if l: layer = l
if k and board_pads[k] != 'both' and board_pads[k] + '.Cu' != layer:
bad.append((k, layer, e['net']))
return bad
# pad sides of the fixture: the THT pads of J1/J2 are on both, U1 and R2 on B, R3 on F
sides = {f'{r}.{n}': s for r, s, ns in (('J1', 'both', '12'), ('J2', 'both', '12'), ('U1', 'B', '1234'), ('R2', 'B', '12'), ('R3', 'F', '12')) for n in ns}
with tempfile.TemporaryDirectory(prefix='aiflow-bottom-') as td:
p = pathlib.Path(td)
(p / 'b.kicad_pcb').write_text(fixture.read_text())
(p / 'spec.json').write_text(json.dumps({"board": "bottom-side fixture", "clearance": 0.2}))
bindir = p / 'bin'; bindir.mkdir()
(bindir / 'adom-bridge').write_text(FAKE_BRIDGE); (bindir / 'adom-bridge').chmod(0o755)
(bindir / 'kicad-cli').write_text(FAKE_CLI); (bindir / 'kicad-cli').chmod(0o755)
log = p / 'bridge.jsonl'
env = dict(os.environ, AIFLOW_DASHBOARD='off', AIFLOW_NO_PART_SEARCH='1', AIFLOW_NO_PART_LOOKUP='1',
ADOM_AIFLOW_KICAD_CLI=str(bindir / 'kicad-cli'), FAKE_LOG=str(log), PATH=f"{bindir}:{os.environ['PATH']}")
env.pop('KICAD_REMOTE_TARGET', None)
def call(run, *args):
return subprocess.run([binary, '--run', str(run), '--ai-thread', 'bottom-test', *args], env=env, capture_output=True, text=True, timeout=300)
# ---- issue #139: route, gate, variants, land on a board with bottom parts
run = p / 'run'
r = call(run, 'start', '--board', str(p / 'b.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test', '--target', 'fakebox', '--remote-board', 'C:/boards/b.kicad_pcb')
check(r.returncode == 0, 'start', r.stdout + r.stderr)
r = call(run, 'route')
check(r.returncode == 0, 'route on a board with bottom parts', r.stdout + r.stderr)
o = json.loads((run / 'run.json').read_text())['outcomes']['route']
check(o['unrouted'] == 0, 'every connection routed', json.dumps(o))
plan = json.loads((run / 'plan-route.json').read_text())
check(not layer_walk(sides, plan), 'every pad reached on a layer it has copper on', json.dumps(layer_walk(sides, plan)))
check('B.Cu' in json.dumps(plan), 'bottom copper in the plan')
r = call(run, 'gate')
out = r.stdout + r.stderr
check(r.returncode == 0 and 'no copper on' not in out, 'gate applies the plan', out)
g = json.loads((run / 'run.json').read_text())['outcomes']['gate']
check(g.get('newErrors') == 0 and g.get('unconnected') == 0, 'gate clean with the fake DRC', json.dumps(g)[:600])
r = call(run, 'route', '--variants', '2')
out = r.stdout + r.stderr
check(r.returncode == 0 and 'no-apply' not in out and 'does not apply' not in out, 'every variant applies', out)
v = json.loads((run / 'run.json').read_text())['outcomes']['route']['variants']
check(all(x['planError'] is None and x['gate'] is not None for x in v['variants']), 'variants gated', json.dumps(v)[:800])
check(call(run, 'gate').returncode == 0, 'gate after variants')
r = call(run, 'land', 'route', '--pause-ms', '0')
out = r.stdout + r.stderr
check(r.returncode == 0, 'land route', out)
sent = [json.loads(l) for l in log.read_text().splitlines() if l.strip()]
routes = [s['args'] for s in sent if s['verb'] == 'kicad_route_net']
check(routes, 'kicad_route_net sent', json.dumps(sent)[:800])
landed = {'nets': [{k: a[k] for k in a if k in ('net', 'layer', 'paths')} for a in routes]}
check(not layer_walk(sides, landed), 'landed paths reach bottom pads on B.Cu', json.dumps(layer_walk(sides, landed)))
# ---- issue #140: R2 parked outside the outline
off = fixture.read_text().replace('(footprint "T:R2"\n\t\t(layer "B.Cu")\n\t\t(at 21 6)', '(footprint "T:R2"\n\t\t(layer "B.Cu")\n\t\t(at 61 46)')
check(off != fixture.read_text(), 'fixture edit')
(p / 'off.kicad_pcb').write_text(off)
run2 = p / 'run2'
check(call(run2, 'start', '--board', str(p / 'off.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test', '--target', 'fakebox', '--remote-board', 'C:/boards/off.kicad_pcb').returncode == 0, 'start off')
# pack: R2's wish has no room (radius 0 on top of J2), so pack places 1 of 2 and names R2
(p / 'wish.json').write_text(json.dumps([{"ref": "R3", "x": 21, "y": 15, "rot": [0], "radius": 1.0}, {"ref": "R2", "x": 27, "y": 8, "rot": [0], "radius": 0.0}]))
r = call(run2, 'place', 'pack', '--wish', str(p / 'wish.json'), '--out', str(p / 'moves.json'))
out = r.stdout + r.stderr
check(r.returncode == 0 and 'legal spots for 1 of 2 parts (no spot for R2' in out, 'pack names the part it left out', out)
check('R2 is outside the board outline' in out, 'pack names the part outside', out)
r = call(run2, 'place', 'check', '--moves', str(p / 'moves.json'))
out = r.stdout + r.stderr
check(r.returncode == 0 and 'R2 is outside the board outline' in out and 'FINDING' in out, 'place check reports R2, never refuses', out)
r = call(run2, 'route')
out = r.stdout + r.stderr
check(r.returncode == 0 and 'panicked' not in out, 'route does not panic', out)
check('R2 is outside the board outline' in out, 'route names R2 before routing', out)
o = json.loads((run2 / 'run.json').read_text())['outcomes']['route']
reasons = [d[1][1] for d in o['detail']]
check(o['unrouted'] >= 2 and sum(w.startswith('R2 is outside the board outline') for w in reasons) == 2, 'R2 nets unrouted with the reason', json.dumps(o['detail']))
r = call(run2, 'place', 'land', '--moves', str(p / 'moves.json'), '--pause-ms', '0')
out = r.stdout + r.stderr
check(r.returncode == 0 and 'R2 is outside the board outline' in out, 'place land names R2 and lands', out)
print(f'OK bottom-side-workflow: {passed} checks passed')