astra/nema17-stepper-motor-controller-spring-clamp-rp2040

A 4-layer RP2040 controller board that negotiates USB‑C PD motor power and drives a NEMA17 stepper via a DRV8825, with current sensing, thermal shutdown, RGB status LED, buzzer alarm, optional AS5600 encoder, protection/filtering, and test points.

Version
1.4.0
License
unset
Stars
0

scripts/route-local.py

"""Reroute one stored connection with clearance-aware, through-via-only grid search.

Requires numpy and shapely. Always re-render the plan and run the independent DRC
and Gerber checks afterward. No net endpoints or manufacturing rules are changed.
"""
import json,sys,math,heapq,time
from pathlib import Path
import numpy as np
import shapely
from shapely.geometry import Point,LineString,Polygon,box
from shapely.affinity import rotate,translate
from shapely.ops import unary_union
folder=Path(sys.argv[1]); target=sys.argv[2]; width=float(sys.argv[3]) if len(sys.argv)>3 else .15
plan=json.loads((folder/'route-plan.json').read_text());j=json.loads((folder/'circuit.json').read_text())
pt={t['pcb_trace_id']:t for t in plan['traces']}; ct={t['pcb_trace_id']:t for t in j if t['type']=='pcb_trace'}
trace=pt.get(target) or next(t for t in plan['traces'] if t['pcb_trace_id'].endswith(target));target=trace['pcb_trace_id']
parent={}
def root(x):
 parent.setdefault(x,x)
 if parent[x]!=x:parent[x]=root(parent[x])
 return parent[x]
for e in j:
 ids=e.get('connected_source_port_ids',[])+e.get('connected_source_net_ids',[]) if e['type']=='source_trace' else e.get('source_port_ids',[]) if e['type']=='source_component_internal_connection' else []
 for i in ids[1:]:parent[root(i)]=root(ids[0])
sp={e['source_trace_id']:root(e['connected_source_port_ids'][0]) for e in j if e['type']=='source_trace' and e.get('connected_source_port_ids')}
ports={e['pcb_port_id']:root(e['source_port_id']) for e in j if e['type']=='pcb_port' and e.get('source_port_id')}
def tnet(t):
 if t.get('source_trace_id') in sp:return sp[t['source_trace_id']]
 for p in t.get('connectsTo',[]):
  if p in ports:return ports[p]
 return t['pcb_trace_id']
ripup=set(next((a.split('=',1)[1].split(',') for a in sys.argv if a.startswith('--ripup=')),[]))
start,end=trace['route'][0],trace['route'][-1]
endpoint_points=[Point(p['x'],p['y']) for p in (start,end)]
endpoint_holes=[]
via_only_obstacles=[]
net=tnet(ct[target]);layers=['top','inner1','inner2','bottom'];shapes={l:[] for l in layers};owners={l:[] for l in layers};holes=[]
def add(l,g,owner):shapes[l].append(g);owners[l].append(owner)
def pad(e,prefix=''):
 shape=e.get('shape',e.get('hole_shape','circle'));x=e.get('x',0);y=e.get('y',0)
 if shape=='circle':g=Point(0,0).buffer(e.get(prefix+'diameter',e.get('radius',0)*2)/2)
 elif 'polygon' in shape:g=Polygon([(p['x'],p['y']) for p in e['points']])
 else:
  w,h=e[prefix+'width'],e[prefix+'height'];r=min(w,h)/2 if 'pill' in shape else e.get('corner_radius',0)
  if r:
   g=box(-w/2+r,-h/2+r,w/2-r,h/2-r).buffer(r) if w>2*r and h>2*r else LineString([(-w/2+r,-h/2+r),(w/2-r,h/2-r)]).buffer(r)
  else:g=box(-w/2,-h/2,w/2,h/2)
 return translate(rotate(g,e.get('ccw_rotation',0),origin=(0,0)),x,y)
for e in j:
 typ=e['type']
 if typ=='pcb_smtpad':
  if ports.get(e.get('pcb_port_id'))!=net:add(e['layer'],pad(e),e['pcb_smtpad_id'])
  elif not any(pad(e).covers(p) for p in endpoint_points):via_only_obstacles.append(pad(e))
 elif typ=='pcb_plated_hole':
  if ports.get(e.get('pcb_port_id'))!=net:
   for l in e['layers']:add(l,pad(e,'outer_'),e['pcb_plated_hole_id'])
  else:
   hole=pad(e,'hole_')
   # A wire terminates at the plated barrel centre; only new vias must avoid its drill.
   if any(hole.covers(p) for p in endpoint_points):endpoint_holes.append(hole)
   else:holes.append(hole)
 elif typ=='pcb_hole':holes.append(pad(e,'hole_') if 'hole_diameter' in e else pad(e))
 elif typ=='pcb_via' and e.get('pcb_trace_id') not in pt:
  if tnet(ct[e['pcb_trace_id']])==net if e.get('pcb_trace_id') in ct else ports.get(e.get('pcb_port_id'))==net:continue
  for l in layers:add(l,Point(e['x'],e['y']).buffer(e['outer_diameter']/2),e['pcb_via_id'])
same_net_vias=[]
for old in ct.values():
 if old['pcb_trace_id']==target or old['pcb_trace_id'] in ripup:continue
 t=pt.get(old['pcb_trace_id'],old)
 if tnet(old)==net:
  # Same-net wires may merge, but independently drilled vias still need spacing.
  for a in t['route']:
   if a['route_type']=='via':same_net_vias.append(a)
  continue
 for a,b in zip(t['route'],t['route'][1:]):
  if a['route_type']=='wire' and b['route_type']=='wire' and a['layer']==b['layer']:
   add(a['layer'],LineString([(a['x'],a['y']),(b['x'],b['y'])]).buffer(max(a['width'],b['width'])/2),t['pcb_trace_id'])
 for a in t['route']:
  if a['route_type']=='via':
   for l in layers:add(l,Point(a['x'],a['y']).buffer(a.get('via_diameter',.45)/2),t['pcb_trace_id'])
if any(a.startswith('--probe=') for a in sys.argv):
 x,y=map(float,next(a for a in sys.argv if a.startswith('--probe=')).split('=')[1].split(','));q=Point(x,y)
 print('PROBE',sorted(set((oid,l,round(g.distance(q),4)) for l in layers for g,oid in zip(shapes[l],owners[l]) if g.distance(q)<.338)),flush=True)
 sys.exit(0)
start,end=trace['route'][0],trace['route'][-1]
if '--debug' in sys.argv:
 for p in trace['route']:
  if p['route_type']=='via':
   pt0=Point(p['x'],p['y']);near=sorted([(g.distance(pt0),l,g.centroid.x,g.centroid.y) for l,ss in shapes.items() for g in ss])[:3];print('Original via',p['x'],p['y'],'nearby',near,flush=True)
step=.05;clearance=.105
escape_width=float(next((a.split('=',1)[1] for a in sys.argv if a.startswith('--escape-width=')),str(width)))
escape_radius=float(next((a.split('=',1)[1] for a in sys.argv if a.startswith('--escape-radius=')),'1'))
sx,sy=start['x'],start['y'];ex,ey=end['x'],end['y']
# Keep the grid aligned with the starting pad to preserve fine-pitch escapes.
minx=sx+math.floor((-21-sx)/step)*step;miny=sy+math.floor((-21-sy)/step)*step
nx=math.ceil((21-minx)/step)+1;ny=math.ceil((21-miny)/step)+1
xs=minx+np.arange(nx)*step;ys=miny+np.arange(ny)*step
xx,yy=np.meshgrid(xs,ys);points=shapely.points(xx.ravel(),yy.ravel())
board=next(e for e in j if e['type']=='pcb_board');outline=board.get('outline')
boundary=Polygon([(p['x'],p['y']) for p in outline]) if outline else box(-21.15,-21.16,21.15,21.16)
inside=shapely.contains(boundary.buffer(-.3-width/2),points).reshape(ny,nx)
mask=[];obstacles=[]
for l in layers:
 g=unary_union(shapes[l]+holes);obstacles.append(g)
 inflated=g.buffer(clearance+width/2+.008);shapely.prepare(inflated)
 allowed=~shapely.contains(inflated,points).reshape(ny,nx)
 if escape_width<width:
  near_endpoint=((xx-sx)**2+(yy-sy)**2<escape_radius**2)|((xx-ex)**2+(yy-ey)**2<escape_radius**2)
  narrow=g.buffer(clearance+escape_width/2+.008);shapely.prepare(narrow)
  allowed|=near_endpoint & ~shapely.contains(narrow,points).reshape(ny,nx)
 mask.append(inside & allowed)
viaArea=unary_union(obstacles+endpoint_holes+via_only_obstacles+[Point(p['x'],p['y']).buffer(p.get('via_diameter',.45)/2) for p in same_net_vias]).buffer(clearance+.225+.008);shapely.prepare(viaArea)
viaMask=inside & ~shapely.contains(viaArea,points).reshape(ny,nx)
# Preserve legal existing via sites even when they fall between grid samples.
viaOverrides={}
for p in trace['route']+same_net_vias:
 if p['route_type']!='via':continue
 if any(1e-7<math.hypot(p['x']-v['x'],p['y']-v['y'])<.55 for v in same_net_vias):continue
 if all(Point(p['x'],p['y']).distance(g)>=clearance+.225-1e-7 for g in obstacles+endpoint_holes+via_only_obstacles):
  i=round((p['x']-minx)/step);k=round((p['y']-miny)/step)
  viaMask[k,i]=True;viaOverrides[i,k]=(p['x'],p['y'])
si,sj=round((sx-minx)/step),round((sy-miny)/step);ei,ej=round((ex-minx)/step),round((ey-miny)/step)
sl=layers.index(start['layer']);el=layers.index(end['layer']);mask[sl][sj,si]=True
# A destination grid point must also have a clear short segment to the exact pad.
goals=set()
for i in range(ei-2,ei+3):
 for k in range(ej-2,ej+3):
  if mask[el][k,i] and LineString([(xs[i],ys[k]),(ex,ey)]).distance(obstacles[el])>=clearance+escape_width/2:goals.add((i,k,el))
print('Net',net,'start',start,'end',end,'goal cells',len(goals),'start distance',Point(sx,sy).distance(obstacles[sl]),'end distance',Point(ex,ey).distance(obstacles[el]),flush=True)
if '--debug' in sys.argv:
 from collections import deque
 cells={(si,sj)};qq=deque(cells);vv=[]
 while qq:
  i,k=qq.popleft()
  if viaMask[k,i]:vv.append((float(xs[i]),float(ys[k])))
  for a,b in [(i-1,k),(i+1,k),(i,k-1),(i,k+1)]:
   if 0<=a<nx and 0<=b<ny and mask[sl][b,a] and (a,b) not in cells:cells.add((a,b));qq.append((a,b))
 print('Start top region',len(cells),'via candidates',len(vv),vv[:5],flush=True)
 candidates=[];allCopper=unary_union(obstacles)
 for i,k in cells:
  p=Point(xs[i],ys[k])
  if p.distance(obstacles[sl])<.33:continue
  candidates.append((p.distance(allCopper),i,k))
 for d,i,k in sorted(candidates,reverse=True)[:3]:
  p=Point(xs[i],ys[k]);near=sorted((g.distance(p),l,oid) for l in layers for g,oid in zip(shapes[l],owners[l]))[:4]
  print('Potential via',float(xs[i]),float(ys[k]),d,'blockers',near,flush=True)
if not goals:raise RuntimeError('No clear destination escape')
def heuristic(i,k,z):
 dx=abs(i-ei);dy=abs(k-ej);return max(dx,dy)+.41421356*min(dx,dy)+(80 if z!=el else 0)
startkey=(si,sj,sl);dist={startkey:0};prev={};queue=[(heuristic(*startkey),0,startkey)];visited=0;beg=time.time();found=None
dirs=[(a,b,math.hypot(a,b)) for a in [-1,0,1] for b in [-1,0,1] if a or b]
while queue:
 _,cost,key=heapq.heappop(queue)
 if cost!=dist.get(key):continue
 if key in goals:found=key;break
 visited+=1
 if visited%100000==0:print('visited',visited,'seconds',round(time.time()-beg,1),flush=True)
 if time.time()-beg>120:raise RuntimeError('Local search timeout')
 i,k,z=key;neighbors=[]
 for di,dk,w in dirs:
  ni,nk=i+di,k+dk
  if 0<=ni<nx and 0<=nk<ny and mask[z][nk,ni] and (not(di and dk) or (mask[z][k,ni] and mask[z][nk,i])):neighbors.append(((ni,nk,z),w))
 if viaMask[k,i]:
  for nz in range(4):
   if nz!=z and mask[nz][k,i]:neighbors.append(((i,k,nz),80))
 for nxt,w in neighbors:
  nc=cost+w
  if nc<dist.get(nxt,math.inf):dist[nxt]=nc;prev[nxt]=key;heapq.heappush(queue,(nc+heuristic(*nxt),nc,nxt))
if found is None:raise RuntimeError('No route found')
path=[found]
while path[-1]!=startkey:path.append(prev[path[-1]])
path.reverse()
# Remove collinear grid points, then exact-check each segment independently.
simple=[path[0]]
for k in range(1,len(path)-1):
 a,b,c=path[k-1:k+2]
 if escape_width<width or (b[0]-a[0],b[1]-a[1],b[2]-a[2])!=(c[0]-b[0],c[1]-b[1],c[2]-b[2]):simple.append(b)
simple.append(path[-1]);route=[]
for k,(i,h,z) in enumerate(simple):
 x=float(xs[i]);y=float(ys[h]);l=layers[z]
 if (i,h) in viaOverrides and ((k and simple[k-1][2]!=z) or (k+1<len(simple) and simple[k+1][2]!=z)):x,y=viaOverrides[i,h]
 if k and simple[k-1][2]!=z:route.append({'route_type':'via','x':x,'y':y,'from_layer':'top','to_layer':'bottom','via_diameter':.45,'via_hole_diameter':.3})
 route.append({'route_type':'wire','x':x,'y':y,'width':width,'layer':l})
route[0]={**route[0],'x':sx,'y':sy};route.append({'route_type':'wire','x':ex,'y':ey,'width':width,'layer':end['layer']})
for point in route:
 if point['route_type']=='wire' and min(math.hypot(point['x']-sx,point['y']-sy),math.hypot(point['x']-ex,point['y']-ey))<escape_radius and escape_width<width:point['width']=escape_width
for a,b in zip(route,route[1:]):
 if a['route_type']=='wire' and b['route_type']=='wire':
  distance=LineString([(a['x'],a['y']),(b['x'],b['y'])]).distance(obstacles[layers.index(a['layer'])])
  if distance<clearance+max(a['width'],b['width'])/2-1e-7:raise RuntimeError(f'Exact clearance failure: {distance}')
trace['route']=route
(folder/'route-plan.json').write_text(json.dumps(plan,indent=2))
print('Routed',target,'width',width,'points',len(route),'vias',sum(p['route_type']=='via' for p in route),'visited',visited,'seconds',round(time.time()-beg,1),flush=True)