main
John Lauer Publish independent Astra ESC source and full-pad Kelvin review lesson d057af8 22d ago
"""Fresh ESC candidate: declared anchors plus pad-aware courtyard packing.
Uses only unplaced fixture/state. No historical placement plan input.
"""
import json,math
from pathlib import Path
from fixture_geometry import parse
D=Path(__file__).parent
state=json.loads((D/'evidence/fresh-state.json').read_text()); fs={f['ref']:f for f in state['footprints']}
r=parse((D/'esc-g431-astra-independent.kicad_pcb').read_text());pads={}
for f in r.find_all('footprint'):
 ref=next(p.atom(2) for p in f.find_all('property') if p.value()=='Reference')
 pads[ref]=[(p.value(),p.find('at').number(1),p.find('at').number(2),p.find('net').value() if p.find('net') else '') for p in f.find_all('pad') if p.value()]
def rot(x,y,a):
 a=math.radians(a);return (x*math.cos(a)+y*math.sin(a),-x*math.sin(a)+y*math.cos(a))
def box(ref,x,y,a):
 f=fs[ref];b=f['courtyard'];pts=[rot(i-f['x'],j-f['y'],a-f['rotation']) for i in [b['minX'],b['maxX']] for j in [b['minY'],b['maxY']]]
 return (x+min(t[0] for t in pts),y+min(t[1] for t in pts),x+max(t[0] for t in pts),y+max(t[1] for t in pts))
placed={f['ref']:(f['x'],f['y'],f['rotation']) for f in fs.values() if f['locked']};boxes={k:box(k,*v) for k,v in placed.items()}
def legal(ref,x,y,a):
 b=box(ref,x,y,a)
 if b[0]<104.5 or b[1]<58.5 or b[2]>167.5 or b[3]>131.5:return False
 for c in boxes.values():
  if not(b[2]+.25<=c[0] or c[2]+.25<=b[0] or b[3]+.25<=c[1] or c[3]+.25<=b[1]):return False
 return True
def put(ref,x,y,a):
 assert legal(ref,x,y,a),(ref,x,y,a)
 placed[ref]=(x,y,a);boxes[ref]=box(ref,x,y,a)
anchors={'R32':(132,70,90),'U6':(126.5,70,90),'U5':(122,101,0),'U4':(140,98,0),'U2':(117,70,0),'U3':(116,77,0),'U1':(140,76,180),'C2':(143,84,0),'C3':(144,121,0),'J1':(115,116,0),'Y1':(114.5,100,0)}
for hi,lo,y in [('Q5','Q6',83),('Q3','Q4',97),('Q1','Q2',111)]:anchors[hi]=(154,y-3.6,180);anchors[lo]=(154,y+3.6,0)
for ref,p in anchors.items():
 if legal(ref,*p):put(ref,*p);continue
 options=[]
 for ix in range(-16,17):
  for iy in range(-16,17):
   x=p[0]+ix*.5;y=p[1]+iy*.5
   if legal(ref,x,y,p[2]):options.append((ix*ix+iy*iy,x,y,p[2]))
 if not options:raise RuntimeError(('anchor blocked',ref))
 _,x,y,a=min(options);print('anchor adjusted',ref,p,(x,y,a),flush=True);put(ref,x,y,a)
# Connection targets exclude planes so supply-net degree cannot pull all caps to one IC.
planes={'GND','GND_OUT','+VBAT','+3V3','+5V','+12V',''}
attachments={'C24':('U5','1'),'C25':('U5','24'),'C26':('U5','36'),'C27':('U5','48'),'C6':('U6','6'),'C44':('U6','6'),'C23':('U3','5'),'C30':('U5','24'),'C4':('U2','3'),'C5':('U2','3'),'C7':('U1','2'),'C8':('U1','3'),'C9':('U3','1'),'C10':('U2','4'),'C11':('U2','9'),'C20':('U4','7'),'C22':('U4','7')}
for cap,q in [('C14','Q1'),('C15','Q1'),('C16','Q3'),('C17','Q3'),('C18','Q5'),('C19','Q5'),('C31','Q1'),('C32','Q1'),('C33','Q3'),('C34','Q3'),('C37','Q5'),('C38','Q5')]:attachments[cap]=(q,'5')
def target(ref):
 if ref in attachments:
  other,pn=attachments[ref];pos=placed[other]
  n,px,py,net=next(p for p in pads[other] if p[0]==pn)
  dx,dy=rot(px,py,pos[2]);return [(pos[0]+dx,pos[1]+dy,net,other)]
 own={p[3] for p in pads[ref]}-planes
 targets=[]
 for other,pos in placed.items():
  for n,px,py,net in pads[other]:
   if net in own and not net.startswith('unconnected-'):
    dx,dy=rot(px,py,pos[2]);targets.append((pos[0]+dx,pos[1]+dy,net,other))
 return targets
remaining=[k for k in fs if k not in placed]
# Place testpoints and indicators last; preserve MCU escape band for unrelated parts.
remaining.sort(key=lambda k:(k.startswith(('TP','LED')), k not in attachments, -len(target(k)), k))
for ref in remaining:
 ts=target(ref);cx=sum(t[0] for t in ts)/len(ts) if ts else 127;cy=sum(t[1] for t in ts)/len(ts) if ts else 118
 candidates=[]
 for a in [0,90,180,270]:
  for ix in range(-36,37):
   for iy in range(-36,37):
    x=round(cx*2)/2+ix*.5;y=round(cy*2)/2+iy*.5
    if not legal(ref,x,y,a):continue
    b=box(ref,x,y,a)
    if not ts or not any(t[3]=='U5' for t in ts):
     if b[0]<129 and b[2]>115 and b[1]<108 and b[3]>94:continue
    cost=0
    for n,px,py,net in pads[ref]:
     dx,dy=rot(px,py,a);same=[math.hypot(x+dx-t[0],y+dy-t[1]) for t in ts if t[2]==net]
     if same:cost+=min(same)
    cost+=.05*math.hypot(x-cx,y-cy)
    candidates.append((cost,x,y,a))
 if not candidates:raise RuntimeError(('no legal spot',ref))
 _,x,y,a=min(candidates);put(ref,x,y,a)
plan=[dict(ref=k,x=v[0],y=v[1],rotation=v[2]) for k,v in placed.items() if not fs[k]['locked']]
(D/'evidence/fresh-plan.json').write_text(json.dumps(plan,indent=2));print('planned',len(plan),flush=True)