shibosoftwaredev/f1c100s-linux-nema17-stepper-controller

A four-layer NEMA 17 motor-controller PCB combining an Allwinner F1C100S Linux computer, USB‑PD/buck power system, W5500 Ethernet, STM32 real-time motion control, DRV8825 stepper driver, protection/temperature/current telemetry, USB interfaces, and local LED/buzzer diagnostics.

Version
0.4.56
License
unset
Stars
0

scripts/prepare-software-current.py

"""Bounded DAC addition. Preserve every existing placement and unrelated net.
Writes isolated routing candidates only; native/emitted-copper audits follow.
"""
from route_geometry import *
from collections import defaultdict, Counter
from shapely.strtree import STRtree

old=load_circuit('baseline/v055-circuit.json')
c=load_circuit('routing/review-placement.circuit.json')
assert not [e for e in c if e['type'].endswith('_error')]
oldroot=connectivity(old);root=connectivity(c)
original_map=json.loads(Path('../../work/nema17-clean-schematic-v054-IAQpFd/routing/net-map.json').read_text())
oldmap=original_map['roots']
oldports={e['source_port_id']:e for e in old if e['type']=='source_port'}
parts={e['source_component_id']:e['name'] for e in c if e['type']=='source_component'}
sources={e['source_trace_id']:e for e in c if e['type']=='source_trace'}
named={e.get('name'):e for e in sources.values()}
ports={e['pcb_port_id']:e for e in c if e['type']=='pcb_port'}
by_source={e['source_port_id']:e for e in ports.values()}
sp={e['source_port_id']:e for e in c if e['type']=='source_port'}
def terminal(part,pin):
    return next(e for e in sp.values() if parts[e['source_component_id']]==part and
                (e.get('pin_number')==pin or e.get('name')==pin or str(pin) in e.get('port_hints',[])))
outroot=root(terminal('U_CURRENT_DAC','VOUT')['source_port_id'])
newlabel='N'+str(max(int(n[1:]) for n in oldmap.values())+1)
votes=defaultdict(Counter)
for p in sp:
    if p in oldports and root(p)!=outroot:
        votes[root(p)][oldmap.get(oldroot(p),'NC_'+oldroot(p))]+=1
mapping={r:next(iter(v)) for r,v in votes.items()}
assert all(len(v)==1 for v in votes.values()),'No existing independent nets may be merged'
mapping[outroot]=newlabel
assert all(root(p) in mapping for p in sp)
netmap={**original_map,'roots':mapping,'names':{}}
for e in c:
    if e['type']=='source_net':netmap['names'].setdefault(root(e['source_net_id']),[]).append(e['name'])
Path('routing/net-map.json').write_text(json.dumps(netmap,indent=2)+'\n')
original=json.loads(Path('../../work/nema17-clean-schematic-v054-IAQpFd/routing/complete-routes.json').read_text())
retired={named[n]['source_trace_id'] for n in ['DRV_REF_FEED','DRV_REF_DIV']}
retained=[t for t in original if t['source_trace_id'] not in retired]
Path('reports/software-current-i2c-rework.json').write_text('[]\n')
# Same-width 0.50 mm outer-layer input-feed detour; x=2.0 leaves the existing
# ground stitch at (2.6,-12.7) clear. Every other original power segment stays.
detour=[(6.4,-13.55),(6.4,-13.4),(2.0,-13.4),(2.0,-12.4),(-.3,-12.4),(-.65,-12.3),(-2.7,-12.3),(-2.7,-12.55)]
patched=0
for t in retained:
    if t['source_trace_id']!=named['BUCK_IN_CAP']['source_trace_id']:continue
    compact=[]
    for p in t['route']:
        if compact and p['route_type']=='wire' and all(p.get(k)==compact[-1].get(k) for k in ['route_type','layer','width','x','y']):compact[-1]={**compact[-1],**p}
        else:compact.append(p)
    route=[];skip=set()
    for i,p in enumerate(compact):
        if i in skip:continue
        route.append(p)
        if i+1>=len(compact):continue
        q=compact[i+1]
        if p['route_type']==q['route_type']=='wire' and p['layer']==q['layer']=='bottom' and (p['x'],p['y'])==detour[0] and (q['x'],q['y'])==(-2.4,-13.55):
            assert (compact[i+2]['x'],compact[i+2]['y'])==detour[-1]
            route.extend(dict(route_type='wire',layer='bottom',width=.5,x=x,y=y) for x,y in detour[1:-1]);skip.add(i+1);patched+=1
    t['route']=route
assert patched==1,patched
for t in retained:
    s=sources[t['source_trace_id']]
    n=mapping[root((s.get('connected_source_port_ids',[])+s.get('connected_source_net_ids',[]))[0])]
    if n=='N101':
        for p in t['route']:
            if p['route_type']=='wire' and p['layer']=='bottom' and (p['x'],p['y'])==(-2.7,-12.55):p['y']=-12.49
# Give the new ground stitch clearance on inner1. This is a same-width,
# no-via local detour of STATUS_DATA_BUFFER_OUT, not a motor-current route.
telemetry_detours=[]
for t in retained:
    out=[]
    for i,p in enumerate(t['route']):
        out.append(p)
        if i+1>=len(t['route']):continue
        q=t['route'][i+1]
        if p['route_type']==q['route_type']=='wire' and p['layer']==q['layer']=='inner1' and (p['x'],p['y'])==(-1.4827,-11.7985) and (q['x'],q['y'])==(-4.1963,-14.5221):
            out.append(dict(route_type='wire',layer='inner1',width=p['width'],x=-1.4827,y=-13.1))
            telemetry_detours.append(dict(source=t['source_trace_id'],width=p['width']))
    t['route']=out
assert telemetry_detours
Path('reports/software-current-status-detour.json').write_text(json.dumps(telemetry_detours,indent=2)+'\n')
# Move the existing hardware-enable elbow, at its original width and on the
# same layer. The ~0.3 mm net length increase opens inner1 for SDA; it does not
# alter the PG/reset/sleep circuit, GPIO assignment or add a transition.
enable_detours=[]
for t in retained:
    s=sources[t['source_trace_id']]
    n=mapping[root((s.get('connected_source_port_ids',[])+s.get('connected_source_net_ids',[]))[0])]
    if n!='N168':continue
    route=[]
    for p in t['route']:
        if p['route_type']=='wire' and p['layer']=='inner1' and (p['x'],p['y'])==(5.75,-13.95):
            p={**p,'x':6.85,'y':-13.8}
            enable_detours.append(dict(source=t['source_trace_id'],width=p['width']))
        if route and p['route_type']==route[-1]['route_type']=='wire' and p['layer']==route[-1]['layer']=='inner1' and (route[-1]['x'],route[-1]['y'])==(5.75,-16) and (p['x'],p['y'])==(6.85,-13.8):
            route.extend(dict(route_type='wire',layer='inner1',width=p['width'],x=x,y=y) for x,y in [(5.75,-15.65),(6.3,-15.65),(6.85,-15.45)])
        if route and p['route_type']==route[-1]['route_type']=='wire' and p['layer']==route[-1]['layer']=='inner1' and (p['x'],p['y'])==(5.75,-16) and (route[-1]['x'],route[-1]['y'])==(6.85,-13.8):
            route.extend(dict(route_type='wire',layer='inner1',width=p['width'],x=x,y=y) for x,y in [(6.85,-15.45),(6.3,-15.65),(5.75,-15.65)])
        route.append(p)
    t['route']=route
assert enable_detours
Path('reports/software-current-enable-detour.json').write_text(json.dumps(enable_detours,indent=2)+'\n')
# Keep this existing 0.25 mm low-voltage feed on bottom at the same width.
# Raising only its elbow opens the actual drill-clear reference escape.
reference_escape_detours=[]
for t in retained:
    s=sources[t['source_trace_id']]
    n=mapping[root((s.get('connected_source_port_ids',[])+s.get('connected_source_net_ids',[]))[0])]
    if n!='N118':continue
    for p in t['route']:
        if p['route_type']=='wire' and p['layer']=='bottom' and (p['x'],p['y'])==(4.3638,-10.4):
            assert p['width']==.25
            reference_escape_detours.append(dict(source=t['source_trace_id'],name=s.get('name'),width=p['width']))
            p['x']=4.0;p['y']=-9.8
assert reference_escape_detours
Path('reports/software-current-reference-escape-detour.json').write_text(json.dumps(reference_escape_detours,indent=2)+'\n')
# The original capacitor moves closer to its W5500 rail pin, retaining its
# exact catalog part. Raise only its former 0.12 mm ground-junction elbow.
for t in retained:
    s=sources[t['source_trace_id']]
    n=mapping[root((s.get('connected_source_port_ids',[])+s.get('connected_source_net_ids',[]))[0])]
    if n!='N62':continue
    for p in t['route']:
        if p['route_type']=='wire' and p['layer']=='bottom' and p['y']==-9.8 and p['x'] in [4.4692,4.69]:
            assert p['width']<.275
            p['y']=-9.5
pcnames={e['pcb_component_id']:parts[e['source_component_id']] for e in c if e['type']=='pcb_component'}
newpads=[]
for e in c:
    if e['type']=='pcb_smtpad' and pcnames[e['pcb_component_id']] in ['U_CURRENT_DAC','C_CURRENT_DAC','R_REF_TOP']:
        newpads.append((mapping[root(ports[e['pcb_port_id']]['source_port_id'])],e['layer'],geometry(e),e['pcb_smtpad_id']))
ground_removed=[];pieces=[]
new_vdda_feed=LineString([(-3.2,-14.38),(-3.7,-14.05),(-4.7,-14.05),(-4.7,-11.685),(-3.65,-11.685)]).buffer(.075)
new_vdda_feed=new_vdda_feed.union(LineString([(-1.899096,-13.30004),(-2.6,-13.6),(-3.2,-14.38)]).buffer(.075))
for t in retained:
    s=sources[t['source_trace_id']]
    n=mapping[root((s.get('connected_source_port_ids',[])+s.get('connected_source_net_ids',[]))[0])]
    if n!='N62':pieces.append(t);continue
    piece=[]
    for a,b in zip(t['route'],t['route'][1:]):
        cut=False
        if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer'] and math.dist((a['x'],a['y']),(b['x'],b['y']))>1e-8:
            g=LineString([(a['x'],a['y']),(b['x'],b['y'])]).buffer(max(a['width'],b['width'])/2)
            cut=any(nn!=n and a['layer']==l and g.distance(h)<.1003 for nn,l,h,name in newpads)
            cut=cut or (a['layer']=='bottom' and g.distance(new_vdda_feed)<.1003)
        if cut:
            assert max(a['width'],b['width'])<.275,('Never retire original wide ground copper',t['source_trace_id'],a,b)
            ground_removed.append(dict(source=t['source_trace_id'],a=a,b=b))
            if len(piece)>1:pieces.append({**t,'route':piece})
            piece=[]
        else:
            if not piece:piece=[a]
            piece.append(b)
    if len(piece)>1:pieces.append({**t,'route':piece})
retained=pieces
Path('reports/software-current-ground-rework.json').write_text(json.dumps(ground_removed,indent=2)+'\n')
# The old reference via is gone. Its ground clearance arc can now take the
# shorter left-side path, leaving the new SDA stitch clear without changing
# ground width or moving any other signal route.
arc_start=(5.6722,-14.613);arc_end=(4.0027,-13.0027);arc_rework=[];arc_pieces=[]
for t in retained:
    route=t['route'];a=[i for i,p in enumerate(route) if p['route_type']=='wire' and p['layer']=='top' and math.dist((p['x'],p['y']),arc_start)<1e-6]
    b=[i for i,p in enumerate(route) if p['route_type']=='wire' and p['layer']=='top' and math.dist((p['x'],p['y']),arc_end)<1e-6]
    if not a or not b:arc_pieces.append(t);continue
    s=sources[t['source_trace_id']]
    assert mapping[root((s.get('connected_source_port_ids',[])+s.get('connected_source_net_ids',[]))[0])]=='N62'
    lo,hi=min(a+b),max(a+b);width=route[lo]['width']
    arc_rework.append(dict(source=t['source_trace_id'],before=route[lo:hi+1],replacement='Real ground stitch at (6.42,-14.65)',width=width))
    for piece in [route[:lo+1],route[hi:]]:
        if len(piece)>1:arc_pieces.append({**t,'route':piece})
assert arc_rework,'Expected the declared original reference-via ground arc'
retained=arc_pieces
Path('reports/software-current-ground-arc-rework.json').write_text(json.dumps(arc_rework,indent=2)+'\n')
# Via-first fanout, selected against exact all-layer copper/drill obstacles.
# These are real authored stubs and full-stack vias, not logical graph links.
for name,pin,via in [('DRV_REF_FEED',1,(-4.15,-15.1)),('DRV_REF_DIV',2,(-5.75,-13.1))]:
    p=by_source[terminal('R_REF_TOP',pin)['source_port_id']]
    retained.append(dict(type='pcb_trace',source_trace_id=named[name]['source_trace_id'],route=[
        dict(route_type='wire',layer='top',width=.1 if pin==1 else .15,x=p['x'],y=p['y'],start_pcb_port_id=p['pcb_port_id']),
        *([dict(route_type='wire',layer='top',width=.1,x=p['x'],y=-15.1)] if pin==1 else []),
        dict(route_type='wire',layer='top',width=.1 if pin==1 else .15,x=via[0],y=via[1]),
        dict(route_type='via',x=via[0],y=via[1],from_layer='top',to_layer='bottom',hole_diameter=.3,outer_diameter=.45)]))
# Preserve the reference pin's original top-side escape, terminating at its
# relocated real through-via rather than fencing off the two I2C routes.
p=by_source[terminal('U_MOTOR','BVREF')['source_port_id']]
retained.append(dict(type='pcb_trace',source_trace_id=named['DRV_REF_DIV']['source_trace_id'],route=[
    # AVREF and BVREF are the same net. Leave north between their own
    # lands, along the clear corridor beside (not through) the ground EP.
    dict(route_type='wire',layer='top',width=.15,x=p['x'],y=p['y'],start_pcb_port_id=p['pcb_port_id']),
    *[dict(route_type='wire',layer='top',width=.15,x=x,y=y) for x,y in [(4.4,p['y']),(4.4,-10.8)]]]))
# The existing bypass moves less than 0.08 mm. Extend its original copper
# endpoints to its real new pads; no existing network is disconnected.
old_by_source={e['source_port_id']:e for e in old if e['type']=='pcb_port'}
for partname,name,pin,width in [('C_U_2V5_IN','C_U_2V5_IN_SUPPLY',1,.3),('C_U_2V5_IN','C_U_2V5_IN_GROUND',2,.3)]:
    sid=terminal(partname,pin)['source_port_id'];a=old_by_source[sid];b=by_source[sid]
    retained.append(dict(type='pcb_trace',source_trace_id=named[name]['source_trace_id'],route=[
        dict(route_type='wire',layer='bottom',width=width,x=a['x'],y=a['y']),
        dict(route_type='wire',layer='bottom',width=width,x=b['x'],y=b['y'],end_pcb_port_id=b['pcb_port_id'])]))
def author(config):
    layer=config.get('layer','bottom')
    route=[dict(route_type='wire',layer=layer,width=config['width'],x=x,y=y) for x,y in config['points']]
    if config.get('via'):
        x,y=config['points'][-1]
        route.append(dict(route_type='via',x=x,y=y,from_layer=layer,to_layer='top' if layer=='bottom' else 'bottom',hole_diameter=.3,outer_diameter=.45))
    retained.append(dict(type='pcb_trace',source_trace_id=named[config['name']]['source_trace_id'],route=route))
for spec in [
    dict(name='DRV_REF_FEED',width=.1,points=[(-1.899096,-15.19996),(-2.5,-15.44),(-4.15,-15.44),(-4.15,-15.1)],via=True),
    dict(name='CURRENT_DAC_SCL',width=.15,points=[(.399096,-14.25),(1.15,-14.59),(6.05,-14.59),(6.05,-15.2),(6.25,-15.2)],via=True),
    dict(name='CURRENT_DAC_SDA',width=.15,points=[(.399096,-13.30004),(1.3,-13.6),(1.4,-14.15),(6.4,-14.15),(6.4,-14.25)],via=True),
    dict(name='CURRENT_DAC_SUPPLY',width=.15,points=[(-1.899096,-13.30004),(-2.6,-13.6),(-3.2,-14.38)]),
    dict(name='CURRENT_DAC_BYPASS_GROUND',width=.15,points=[(-3.2,-15.04),(-2.7,-15.04),(-2.6,-14.6),(-1.899096,-14.25)]),
    dict(name='CURRENT_DAC_ADDRESS',width=.15,points=[(.399096,-15.19996),(-.5,-14.8),(-1.899096,-14.25)]),
    dict(name='CURRENT_DAC_GROUND',width=.15,points=[(-1.899096,-14.25),(-.75,-14.0),(-.85,-12.9)]),
    dict(name='CURRENT_DAC_VDDA',width=.15,points=[(-3.2,-14.38),(-3.7,-14.05),(-4.7,-14.05),(-4.7,-11.685),(-3.65,-11.685)]),
    dict(name='DRV_GND1',width=.25,layer='top',points=[(5.425036,-14.365755),(5.425036,-13.0),(8.14,-13.0),(8.14,-13.58)]),
    dict(name='C_U_2V5_IN_GROUND',width=.3,points=[(-3.65,-13.335),(-2.81,-13.1)],via=True),
    dict(name='C_U_2V5_IN_GROUND',width=.25,layer='top',points=[(-2.81,-13.1),(-1.4,-12.7)]),
    dict(name='DRV_REF_DIV',width=.15,layer='top',points=[(4.4,-10.8),(4.35,-10.55)],via=True),
    dict(name='DRV_REF_DIV',width=.15,points=[(9.2,-10.4)],via=True),
    dict(name='DRV_REF_DIV',width=.15,points=[(6.55,-16.8)],via=True),
    dict(name='ETH_RAIL_A_C1',width=.25,points=[(1.8,-9.665),(3.2499,-9.2861)]),
    dict(name='C_ETH_1_GND',width=.15,points=[(1.8,-10.685),(3.15,-11.1)]),
]:author(spec)
conflicts=[]
for t in retained:
    s=sources[t['source_trace_id']]
    n=mapping[root((s.get('connected_source_port_ids',[])+s.get('connected_source_net_ids',[]))[0])]
    for p in t['route']:
        if p['route_type']=='via':
            for nn,l,g,name in newpads:
                if g.distance(Point(p['x'],p['y']))<.331:
                    conflicts.append(dict(kind='via',net=n,point=[p['x'],p['y']],pad=name))
    for a,b in zip(t['route'],t['route'][1:]):
        if a['route_type']!=b['route_type'] or a['route_type']!='wire' or a['layer']!=b['layer']:continue
        if math.dist((a['x'],a['y']),(b['x'],b['y']))<1e-8:continue
        g=LineString([(a['x'],a['y']),(b['x'],b['y'])]).buffer(max(a['width'],b['width'])/2)
        for nn,l,h,name in newpads:
            if n!=nn and a['layer']==l and g.distance(h)<.1001:
                conflicts.append(dict(kind='wire',net=n,source=s.get('name'),pad=name,
                    layer=l,width=max(a['width'],b['width']),points=[[a['x'],a['y']],[b['x'],b['y']]]))
Path('reports/software-current-pad-conflicts.json').write_text(json.dumps(conflicts,indent=2)+'\n')
print(json.dumps(dict(conflicts=len(conflicts),uniqueConflicts=len({json.dumps(x,sort_keys=True) for x in conflicts}),dacOutputNet=newlabel)))
for row in list({json.dumps(x,sort_keys=True):x for x in conflicts}.values())[:20]:print(json.dumps(row))
if conflicts:raise SystemExit(1)
Path('routing/software-current-retained.json').write_text(json.dumps(retained,indent=2)+'\n')
jobs=[]
for name in ['CURRENT_DAC_SCL','CURRENT_DAC_SDA','DRV_REF_DIV','DRV_REF_FEED','CURRENT_DAC_SUPPLY',
             'CURRENT_DAC_BYPASS_GROUND','CURRENT_DAC_ADDRESS','CURRENT_DAC_GROUND','CURRENT_DAC_VDDA']:
    if name=='DRV_REF_DIV':continue
    job=dict(source=named[name]['source_trace_id'])
    if name=='DRV_REF_FEED':
        job['start']=dict(x=-4.15,y=-15.1,layers=LAYERS,pcb_port_id='authored_dac_output_escape')
        job['target']={**by_source[terminal('R_REF_TOP',1)['source_port_id']],'connected_tree':True}
    elif name=='DRV_REF_DIV':
        # Start this remaining segment at the actual authored through-via.
        # The pad-to-via metal is already present above; no new drill is
        # needed merely to reuse that existing full-stack layer transition.
        job['start']=dict(x=-.95,y=-19.65,layers=LAYERS,pcb_port_id='authored_reference_output_escape')
        job['target']={**by_source[terminal('C_REF',1)['source_port_id']],'connected_tree':True}
    elif name=='CURRENT_DAC_GROUND':
        job['start']=by_source[terminal('U_CURRENT_DAC','VSS')['source_port_id']]
        job['target']={**by_source[terminal('C_REF',2)['source_port_id']],'connected_tree':True}
    elif name in ['CURRENT_DAC_SCL','CURRENT_DAC_SDA']:
        x,y=(6.25,-15.2) if name=='CURRENT_DAC_SCL' else (6.4,-14.25)
        job['start']=dict(x=x,y=y,layers=LAYERS,pcb_port_id='authored_'+name+'_escape')
        job['target']={**by_source[terminal('U_MOTION',16 if name=='CURRENT_DAC_SCL' else 17)['source_port_id']],'connected_tree':True}
        if name=='CURRENT_DAC_SCL':job['constraints']=dict(allowed_layers=['inner2'])
    jobs.append(job)
jobs.append(dict(source=named['DRV_REF_DIV']['source_trace_id'],
    start=by_source[terminal('C_REF',1)['source_port_id']],
    target=dict(x=6.55,y=-16.8,layers=LAYERS,pcb_port_id='authored_reference_inner_transition',connected_tree=True),
    constraints=dict(allowed_layers=['bottom'])))
jobs.append(dict(source=named['DRV_REF_DIV']['source_trace_id'],
    start=dict(x=4.35,y=-10.55,layers=['inner2'],pcb_port_id='authored_driver_reference_escape'),
    target=dict(x=9.2,y=-10.4,layers=LAYERS,pcb_port_id='authored_reference_north_transition',connected_tree=True),
    constraints=dict(allowed_layers=['inner2'])))
jobs.append(dict(source=named['DRV_REF_DIV']['source_trace_id'],
    start=dict(x=9.2,y=-10.4,layers=['inner1'],pcb_port_id='authored_reference_north_transition'),
    target={**by_source[terminal('R_REF_BOTTOM',1)['source_port_id']],'connected_tree':True},
    constraints=dict(allowed_layers=['inner1'])))
jobs.append(dict(source=named['DRV_REF_DIV']['source_trace_id'],
    start=dict(x=10.7,y=-17.1,layers=['top'],pcb_port_id='existing_reference_via'),
    target=dict(x=6.55,y=-16.8,layers=LAYERS,pcb_port_id='authored_reference_inner_transition',connected_tree=True),
    constraints=dict(allowed_layers=['top'])))
jobs.append(dict(source=named['DRV_REF_DIV']['source_trace_id'],
    start=dict(x=6.55,y=-16.8,layers=['inner2'],pcb_port_id='authored_reference_inner_transition'),
    target=dict(x=-5.75,y=-13.1,layers=LAYERS,pcb_port_id='authored_reference_resistor_output',connected_tree=True),
    constraints=dict(allowed_layers=['inner2'])))
jobs.append(dict(source=named['DRV_REF_BOTTOM']['source_trace_id'],
    start=by_source[terminal('R_REF_BOTTOM',1)['source_port_id']],
    target={**by_source[terminal('C_REF',1)['source_port_id']],'connected_tree':True}))
jobs.append(dict(source=named['N_GND']['source_trace_id'],
    start=by_source[named['N_GND']['connected_source_port_ids'][0]],
    target={**next(p for p in ports.values() if abs(p['x']+10)<1e-6 and abs(p['y']-8.24)<1e-6),'connected_tree':True}))
for name,points,pin in [('CURRENT_DAC_SCL',[(13.5,-20)],16),
                        ('CURRENT_DAC_SDA',[(11.25,-19.9),(16.5,-19)],17)]:
    for x,y in points:
        jobs.append(dict(source=named[name]['source_trace_id'],
            start=dict(x=x,y=y,layers=LAYERS,pcb_port_id='original_i2c_via_'+str(x)),
            target={**by_source[terminal('U_MOTION',pin)['source_port_id']],'connected_tree':True}))
i2c_job_ids={named[name]['source_trace_id'] for name in ['CURRENT_DAC_SCL','CURRENT_DAC_SDA']}
jobs.sort(key=lambda job:0 if job['source'] in i2c_job_ids else 1)
plan=dict(prefix='software-current',placement='routing/review-placement.circuit.json',
          retained='routing/software-current-retained.json',extent=[-20.9,-20.9,20.9,20.9],jobs=jobs)
Path('routing/software-current-plan.json').write_text(json.dumps(plan,indent=2)+'\n')
Path('routing/software-current-retained-plan.json').write_text(json.dumps({**plan,'prefix':'software-current-retained','jobs':[]},indent=2)+'\n')