astra/am3352-sbc

A 4-layer AM3352 single-board computer with 128‑MiB DDR3, PMIC power regulation, USB‑C 5‑V PD input, dual USB host ports, HDMI output with ESD protection, microSD/UART/JTAG interfaces, addressable RGB indicators, and a PWM-driven buzzer.

Version
0.1.19
License
unset
Stars
0

scripts/route-power-distribution.py

"""Route remaining supply components on outer copper, preserving saved signals.
Explicit traces carve clearance in regenerated pours. Uses independent Shapely
geometry for obstacles and refuses intersecting/under-clearance candidates.
"""
import json,math,heapq,os,time
from pathlib import Path
import numpy as np
from shapely.geometry import Point,Polygon,LineString,box
from shapely.affinity import rotate
from shapely.ops import unary_union
from shapely.strtree import STRtree
from shapely import contains_xy,prepare
ROOT=Path(__file__).resolve().parent.parent
c=json.loads((ROOT/'output/baseline.circuit.json').read_text())
m=json.loads((ROOT/'output/copper-net-map.json').read_text()); ids=m['identities']; labels={k:v.removeprefix('ROUTED_') for k,v in m['labels'].items()}
net=lambda e:ids.get(e.get(e['type']+'_id',''),'?'+e.get(e['type']+'_id',''))
sp={e['source_port_id']:e for e in c if e['type']=='source_port'}
sc={e['source_component_id']:e['name'] for e in c if e['type']=='source_component'}
pc={e['pcb_component_id']:e for e in c if e['type']=='pcb_component'}
ports={e['pcb_port_id']:e for e in c if e['type']=='pcb_port'}
esc=json.loads((ROOT/'design/power-escapes.json').read_text())
# Selected supplies do not use RAM transformed vias; resolve manual names by
# the source port's emitted index and verify exact coordinates before use.
terminals=[]
for p in ports.values():
 s=sp[p['source_port_id']]; ref=sc.get(s['source_component_id']);comp=pc[p['pcb_component_id']]
 if ref is None and s['source_component_id'].startswith('source_manually_placed_via_'):
  i=int(s['source_component_id'].rsplit('_',1)[1]); v=esc['vias'][i]
  if math.hypot(p['x']-v['x'],p['y']-v['y'])>1e-5:continue
  ref=v['name']
 if ref:terminals.append({'x':p['x'],'y':p['y'],'layers':p['layers'],'selector':f'.{ref} > .{s["name"]}','net':net(p),'rotation':comp.get('rotation',0),'origin':(comp.get('display_offset_x',comp['center']['x']),comp.get('display_offset_y',comp['center']['y'])),'port_id':p['pcb_port_id']})
# Geometry list (shape, layers, logical net). Treat drills/holes as obstacles.
geoms=[]
for e in c:
 typ=e['type'];g=None;layers=e.get('layers',[e.get('layer')]);owner=net(e)
 if os.environ.get('IGNORE_CPU_DECOUPLERS') and typ=='pcb_smtpad' and e.get('layer')=='bottom' and sc.get(pc[e['pcb_component_id']]['source_component_id'],'').startswith('C_U1_'):continue
 if typ=='pcb_trace':
  for a,b in zip(e['route'],e['route'][1:]):
   if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer'] and (a['x'],a['y'])!=(b['x'],b['y']):
    geoms.append((LineString([(a['x'],a['y']),(b['x'],b['y'])]).buffer(max(a['width'],b['width'])/2,quad_segs=12),[a['layer']],owner))
  continue
 if typ=='pcb_via':g=Point(e['x'],e['y']).buffer(e['outer_diameter']/2,quad_segs=32)
 elif typ=='pcb_keepout':
  if e['shape']!='rect':raise ValueError('Unsupported keepout shape')
  x=e['center']['x'];y=e['center']['y'];g=box(x-e['width']/2,y-e['height']/2,x+e['width']/2,y+e['height']/2);owner='keepout'
 elif typ in ('pcb_smtpad','pcb_plated_hole','pcb_hole'):
  x=e.get('x',e.get('center',{}).get('x'));y=e.get('y',e.get('center',{}).get('y'));shape=e.get('shape')
  if shape=='circle' or typ=='pcb_hole':g=Point(x,y).buffer(e.get('radius',e.get('outer_diameter',e.get('hole_diameter',0))/2),quad_segs=32)
  elif shape=='polygon':g=Polygon([(p['x'],p['y']) for p in e['points']])
  else:
   w=e.get('width',e.get('outer_width',e.get('rect_pad_width',0)));h=e.get('height',e.get('outer_height',e.get('rect_pad_height',0)))
   if not w or not h:raise ValueError(f'Unsupported pad {shape}')
   if shape in ['pill','rotated_pill']:
    r=e.get('radius',min(w,h)/2);g=box(x-w/2+r,y-h/2+r,x+w/2-r,y+h/2-r).buffer(r,quad_segs=24)
   else:g=box(x-w/2,y-h/2,x+w/2,y+h/2)
   angle=e.get('ccw_rotation',e.get('rect_ccw_rotation',0));g=rotate(g,angle,origin=(x,y))
  if typ=='pcb_hole':layers=['top','inner1','inner2','bottom'];owner='hole'
 if g is not None and not g.is_empty:geoms.append((g,layers,owner))
print('geometry loaded',len(geoms),flush=True)
# Small trace segments union by net/layer to keep each routing query fast.
merged=[]
for n in set(n for g,ls,n in geoms):
 for l in ['top','bottom']:
  parts=[g for g,ls,k in geoms if k==n and l in ls]
  if parts:merged.append((unary_union(parts),[l],n))
# Keep original all-layer geometry for supply physical-component discovery.
class DSU:
 def __init__(self,n):self.p=list(range(n))
 def find(self,i):
  while self.p[i]!=i:self.p[i]=self.p[self.p[i]];i=self.p[i]
  return i
 def join(self,a,b):self.p[self.find(a)]=self.find(b)
routes=[];reports=[];additional=[]
STEP=float(os.environ.get('POWER_GRID','.1'));W=round(99/STEP)+1;H=round(79/STEP)+1
xs=np.arange(W)*STEP-49.5;ys=np.arange(H)*STEP-39.5
net_names=os.environ.get('POWER_NETS','VDD_MPU,VDD_CORE,RTC_1V8,A3V3,V1V8,V3V3').split(',')
for name in net_names:
 started=time.monotonic();n=next(k for k,v in labels.items() if v==name)
 own=[(g,ls) for g,ls,k in geoms if k==n]; tree=STRtree([g for g,ls in own]);ds=DSU(len(own))
 for i,(g,ls) in enumerate(own):
  for j in tree.query(g.buffer(1e-7)):
   j=int(j)
   if j<i and set(ls)&set(own[j][1]) and g.distance(own[j][0])<1e-7:ds.join(i,j)
 ts=[t for t in terminals if t['net']==n]
 for t in ts:
  hit=next((int(i) for i in tree.query(Point(t['x'],t['y']).buffer(.002)) if set(t['layers'])&set(own[int(i)][1]) and own[int(i)][0].distance(Point(t['x'],t['y']))<.002),None)
  if hit is None:raise ValueError('No copper for '+t['selector'])
  t['component']=ds.find(hit)
 groups=sorted(set(t['component'] for t in ts));idx={g:i for i,g in enumerate(groups)};connected=DSU(len(groups))
 for t in ts:t['component']=idx[t['component']]
 # Route nearest components first; a branch can merge many pins via old copper.
 candidates=[]
 for i,a in enumerate(ts):
  for b in ts[:i]:
   if a['component']==b['component']:continue
   for l in set(a['layers'])&set(b['layers'])&{'top','bottom'}:
    candidates.append((math.hypot(a['x']-b['x'],a['y']-b['y']),0 if l=='top' else 1,a,b,l))
 candidates.sort(key=lambda e:e[:2]);attempted=set();added=0;failures=[]
 width=float(os.environ.get('POWER_WIDTH','.15'));margin=width/2+.102
 obstacles={}; masks={}
 for l in ['top','bottom']:
  obstacle=unary_union([g for g,ls,k in merged+additional if k!=n and l in ls]).buffer(margin,quad_segs=12)
  prepare(obstacle);obstacles[l]=obstacle
  masks[l]=contains_xy(obstacle,xs[None,:],ys[:,None])
 def xy(k):return (float(xs[k%W]),float(ys[k//W]))
 def grid(p):return round((p['y']+39.5)/STEP)*W+round((p['x']+49.5)/STEP)
 for dist,_,a,b,l in candidates:
  if connected.find(a['component'])==connected.find(b['component']):continue
  pair=tuple(sorted((connected.find(a['component']),connected.find(b['component']))))+(l,)
  # Allow different endpoint choices, but bound pathological searches.
  token=(a['selector'],b['selector'],l)
  if token in attempted:continue
  attempted.add(token)
  start=(a['x'],a['y']);goal=(b['x'],b['y']);obs=obstacles[l]
  def clear(p,q):return not obs.intersects(LineString([p,q]))
  points=None
  if clear(start,goal):points=[start,goal]
  elif dist<60:
   def anchors(p):
    k=grid({'x':p[0],'y':p[1]});x=k%W;y=k//W;out=[]
    for dy in range(-2,3):
     for dx in range(-2,3):
      nx=x+dx;ny=y+dy
      if 0<=nx<W and 0<=ny<H and not masks[l][ny,nx]:
       q=xy(ny*W+nx)
       if clear(p,q):out.append(ny*W+nx)
    return out
   starts=anchors(start);ends=set(anchors(goal));queue=[];cost={};parent={};seen=set()
   for k in starts:
    g=math.dist(start,xy(k));cost[k]=g;heapq.heappush(queue,(g+math.dist(xy(k),goal),g,k))
   while queue and len(seen)<int(os.environ.get('POWER_SEARCH_LIMIT','18000')):
    _,g,k=heapq.heappop(queue)
    if k in seen:continue
    seen.add(k)
    if k in ends:
     path=[k]
     while path[-1] in parent:path.append(parent[path[-1]])
     points=[start]+[xy(k) for k in reversed(path)]+[goal];break
    x=k%W;y=k//W;p=xy(k)
    for dx,dy in [(1,0),(-1,0),(0,1),(0,-1),(1,1),(-1,1),(1,-1),(-1,-1)]:
     xx=x+dx;yy=y+dy;key=yy*W+xx
     if not(0<=xx<W and 0<=yy<H) or masks[l][yy,xx] or key in seen:continue
     ng=g+STEP*math.hypot(dx,dy)
     if ng>=cost.get(key,math.inf):continue
     q=xy(key)
     if not clear(p,q):continue
     cost[key]=ng;parent[key]=k;heapq.heappush(queue,(ng+math.dist(q,goal),ng,key))
  if points is None:
   failures.append({'from':a['selector'],'to':b['selector'],'layer':l})
   if time.monotonic()-started>float(os.environ.get('POWER_NET_SECONDS','150')):break
   continue
  # Remove unnecessary bends only if the full copper segment clears obstacles.
  simp=[points[0]];i=0
  while i<len(points)-1:
   j=len(points)-1
   while j>i+1 and not clear(points[i],points[j]):j-=1
   if math.dist(simp[-1],points[j])>1e-8:simp.append(points[j])
   i=j
  if not all(clear(p,q) for p,q in zip(simp,simp[1:])):raise ValueError('Geometry validation failed')
  angle=-math.radians(a['rotation']);waypoints=[]
  for p in simp[1:-1]:
   dx=p[0]-a['origin'][0];dy=p[1]-a['origin'][1];waypoints.append({'x':dx*math.cos(angle)-dy*math.sin(angle),'y':dx*math.sin(angle)+dy*math.cos(angle)})
  routes.append({'name':f'POWER_DIST_{len(routes)}','net':name,'from':a['selector'],'to':b['selector'],'layer':l,'width':width,'waypoints':waypoints,'world':simp})
  additional.append((LineString(simp).buffer(width/2,quad_segs=24),[l],n));connected.join(a['component'],b['component']);added+=1
  print(name,'joined',added,'remaining',len(set(connected.find(i) for i in range(len(groups)))),'seconds',round(time.monotonic()-started,1),flush=True)
  (ROOT/'design/power-distribution.json').write_text(json.dumps(routes,indent=2)+'\n')
 remaining=len(set(connected.find(i) for i in range(len(groups))))
 reports.append({'net':name,'initialComponents':len(groups),'remainingComponents':remaining,'routesAdded':added,'failedAttempts':len(failures),'seconds':time.monotonic()-started,'width':width})
 (ROOT/'output/power-distribution-report.json').write_text(json.dumps(reports,indent=2)+'\n')
 print('NET',reports[-1],flush=True)
print('TOTAL',len(routes),flush=True)