molecule
ESC G431
Public Made by Adomby adom
Single-motor ESC for the drone test cell: STM32G431 + DRV8300 driving a 3-phase bridge of six BSC016N06NS MOSFETs, TPSM365 buck to 12 V, INA181 low-side current sense (25 mV/A, 131 A full-scale). Runs AM32. 64 x 74 mm, 4-layer.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
import sys,json,math,uuid,pathlib,re
import numpy as np
from shapely.geometry import Point,box,LineString,Polygon
from shapely.ops import unary_union,polygonize,nearest_points
from shapely.affinity import rotate,translate
sys.path.insert(0,'/home/adom/aiflow-esc-astra');import copper_geometry as c
D=pathlib.Path(__file__).parent;src=D.parent/'components/quality-audit/completion/board/esc-g431-astra-components.kicad_pcb';root=c.parse(src)
edges=[c.arc(n) if n[0]=='gr_arc' else LineString([c.xy(n,'start'),c.xy(n,'end')]) for n in root if isinstance(n,list) and c.one(n,'layer')==['Edge.Cuts']];outline=max(polygonize(unary_union(edges)),key=lambda p:p.area).buffer(-.5)
obs={'F.SilkS':[],'B.SilkS':[]};parts=[]
for f in c.sub(root,'footprint'):
p={x[1]:x[2] for x in c.sub(f,'property')};at=c.one(f,'at');x,y=map(float,at[:2]);ang=float(at[2]) if len(at)>2 else 0
def world(g):return translate(rotate(g,-ang,origin=(0,0)),x,y)
local=[];footpads=[]
for n in f:
if not isinstance(n,list):continue
layer=c.one(n,'layer')
if n[0] in ['fp_line','fp_rect'] and layer and layer[0] in ['F.Fab']:
a=c.xy(n,'start');b=c.xy(n,'end');local.extend([a,b])
if n[0]=='fp_circle' and layer and layer[0] in ['F.Fab']:
a=c.xy(n,'center');b=c.xy(n,'end');rr=math.dist(a,b);local.extend([(a[0]-rr,a[1]-rr),(a[0]+rr,a[1]+rr)])
for n in f:
if isinstance(n,list) and n and c.one(n,'layer')==['F.SilkS'] and not (p['Reference'].startswith(('R','C')) and p['Reference'] not in ['C2','C3'] and n[0] in ['fp_line','fp_rect']):
if n[0]=='fp_line':obs['F.SilkS'].append(world(LineString([c.xy(n,'start'),c.xy(n,'end')])).buffer(.12))
elif n[0]=='fp_circle':
center=c.xy(n,'center');rr=math.dist(center,c.xy(n,'end'));obs['F.SilkS'].append(world(Point(*center).buffer(rr).boundary).buffer(.12))
for pad in c.sub(f,'pad'):
px,py=map(float,c.one(pad,'at')[:2]);w,h=map(float,c.one(pad,'size'));a=c.one(pad,'at');pa=float(a[2]) if len(a)>2 else ang
px,py=world(Point(px,py)).coords[0];g=translate(rotate(box(-w/2,-h/2,w/2,h/2),-pa,origin=(0,0)),px,py).buffer(.15)
footpads.append(g)
layers=c.one(pad,'layers')
for side in obs:
if side[0]+'.Mask' in layers or '*.Mask' in layers or pad[2]=='thru_hole':obs[side].append(g)
if local:
xx,yy=zip(*local);g=world(box(min(xx),min(yy),max(xx),max(yy))).buffer(.06);obs['F.SilkS'].append(g)
else:g=Point(x,y).buffer(.35)
g=unary_union([g]+footpads).convex_hull
parts.append(dict(ref=p['Reference'],value=p.get('Value',''),x=x,y=y,geom=g,nets=[(a[1],c.net(a)) for a in c.sub(f,'pad')]))
# Existing visible text is reserved, not erased.
for f in c.sub(root,'footprint'):
at=c.one(f,'at');x,y=map(float,at[:2]);ang=float(at[2]) if len(at)>2 else 0
for n in c.sub(f,'property')+c.sub(f,'fp_text'):
layer=c.one(n,'layer');hide=c.one(n,'hide')
if not layer or layer[0] not in obs or hide==['yes'] or 'hide' in n:continue
a=c.one(n,'at');px,py=map(float,a[:2]);t=n[2];size=c.one(c.one(c.one(n,'effects'),'font'),'size') or ['1','1'];sz=float(size[0]);g=translate(rotate(box(-len(t)*sz*.4,-sz*.7,len(t)*sz*.4,sz*.7),-ang,origin=(0,0)),x+px*math.cos(math.radians(ang))+py*math.sin(math.radians(ang)),y-px*math.sin(math.radians(ang))+py*math.cos(math.radians(ang)));obs[layer[0]].append(g)
fixed={s:unary_union(v) for s,v in obs.items()};placed={s:[] for s in obs};labels=[];missing=[];leaders=[]
def add(text,x,y,size,side='F.SilkS',source='',role='label'):
# conservative whole-text envelope (native stroke-font DRC remains authoritative)
w=max(.6,len(text)*size*.82);h=size*1.5;g=box(x-w/2,y-h/2,x+w/2,y+h/2)
if not outline.contains(g) or fixed[side].intersects(g) or any(z.intersects(g.buffer(.12)) for z in placed[side]):return False
placed[side].append(g);labels.append(dict(text=text,x=round(x,3),y=round(y,3),size=size,layer=side,source=source,role=role));return True
def clean(net):
return {'/DRV_SHA':'PHASE A','/DRV_SHB':'PHASE B','/DRV_SHC':'PHASE C','/TIM15_CH1_DSHOT':'DSHOT','/USART1_TX':'UART TX','/USART1_RX':'UART RX'}.get(net,net.lstrip('/'))
def close_label(p,text,size,role,allow_leader=True):
side='F.SilkS';lines=text.split('\n');w=max(.6,max(map(len,lines))*size*.82);h=size*(1.5*len(lines))
xmin,ymin,xmax,ymax=p['geom'].bounds;candidates=[]
for angle in [0,90]:
if angle==90:w,h=h,w
for gap in [.15,.3,.5,.8,1.2,1.7,2.3,3.0]:
for offset in [0,-.4,.4,-.8,.8,-1.2,1.2]:
for xx,yy in [(p['x']+offset,ymin-h/2-gap),(p['x']+offset,ymax+h/2+gap),(xmin-w/2-gap,p['y']+offset),(xmax+w/2+gap,p['y']+offset)]:
g=box(xx-w/2,yy-h/2,xx+w/2,yy+h/2)
if not outline.contains(g) or fixed[side].intersects(g) or any(z.intersects(g.buffer(.12)) for z in placed[side]):continue
d=g.distance(p['geom']);others=c.shapely.distance(g,np.array([q['geom'] for q in parts if q['ref']!=p['ref']],dtype=object))
# A nearby label must be closer to its own part; otherwise it needs an explicit leader.
need=d>.55 or (len(others)>0 and min(others)<d-.08)
line=None
if need:
if not allow_leader:continue
aa,bb=nearest_points(g,p['geom']);v=LineString([aa,bb]);length=v.length
if length<.30:continue
line=LineString([v.interpolate(.12),v.interpolate(length-.15)])
if line.buffer(.065).intersects(fixed[side]) or any(line.buffer(.15).intersects(z) for z in placed[side]):continue
candidates.append((need,d+math.hypot(xx-p['x'],yy-p['y'])*.05,xx,yy,g,line,angle))
if not candidates:return False
_,d,xx,yy,g,line,angle=min(candidates,key=lambda v:(v[0],v[1]));placed[side].append(g)
item=dict(text=text,x=round(xx,3),y=round(yy,3),size=size,layer=side,source=p['ref']+' actual board properties/pad nets',role=role,reference=p['ref'],angle=angle,bodyGapMm=round(g.distance(p['geom']),3),leader=line is not None)
labels.append(item)
if line is not None:
leaders.append(dict(reference=p['ref'],start=list(line.coords[0]),end=list(line.coords[-1])));placed[side].append(line.buffer(.12))
return True
# Repeat the numbered J1 pinout on the front's free service-label area.
add('J1 PINOUT',157,68,.6,source='J1 actual pad nets',role='interface key')
j=next(p for p in parts if p['ref']=='J1')
for ii,(pin,net) in enumerate(j['nets']):
label=pin+' '+('NC' if net.startswith('unconnected') else clean(net).replace('UART ','U'))
add(label,153.2+(ii%2)*7.5,69.2+(ii//2)*.85,.4,source='J1 pad '+pin+' actual net',role='interface key')
# Reference association takes precedence over values and decorative text.
for p in sorted(parts,key=lambda p:(0 if p['ref'].startswith('TP') else 1 if p['ref'].startswith(('MC','MP','J')) else 2,p['geom'].area,p['ref'])):
r=p['ref'];v=p['value'];text=r
if r.startswith(('MC','MP')):text=r+'\n'+clean(p['nets'][0][1])
elif r.startswith('TP'):text=r+'\n'+v.replace('PHASE ','PH ').replace('UART_','U')
elif r=='J1':text='J1\nSTDC14'
elif r=='SW1':text='SW1\nRESET'
elif r.startswith('LED'):text=r+'\n'+v
ok=False
for size in [.6,.5,.4,.3,.25]:
if close_label(p,text,size,'near reference/function'):
ok=True;break
if not ok:missing.append(dict(ref=r,text=text,reason='no unambiguous nearby label or clear short leader'))
# Values only where an adjacent association remains clear after ALL references are placed.
for p in parts:
r=p['ref'];v=p['value']
if not r.startswith(('R','C')):continue
own=next((x for x in labels if x.get('reference')==r),None)
if not own or own.get('angle',0):continue
value=v+('F' if r.startswith('C') and v[-1:] in ['u','n','p'] else '')
# The key on the back provides values when there is no immediately adjacent space.
for sz in [.5,.3,.2]:
value_placed=False
for dy in [own['size']*.75+sz*.75+.22,-own['size']*.75-sz*.75-.22]:
x,y=own['x'],own['y']+dy;g=box(x-len(value)*sz*.82/2,y-sz*.75,x+len(value)*sz*.82/2,y+sz*.75)
if g.distance(p['geom'])>1.8:continue
if add(value,x,y,sz,source=r+' actual board value',role='value paired to reference'):
value_placed=True;break
if value_placed:break
add('ESC-G431',157,69,1.1,source='project identity',role='identity')
add('MOTOR CONTROL',157,71,.65,source='project identity',role='identity')
add('ASTRA-S1',157,72.4,.65,source='silkscreen revision',role='revision')
# Back-side manual. At y~70..120 the only obstacles are sparse through-holes.
S='B.SilkS'
def back(text,x,y,size=.65,role='service-key'):return add(text,x,y,size,S,'actual board properties and numbered pad nets',role)
back('ADOM ESC-G431',136,67,1.5,'identity')
back('3-PHASE MOTOR CONTROLLER',136,69.4,.85)
back('ASTRA-S1 | SERVICE REFERENCE',136,71.2,.7)
back('wiki.adom.inc/adom/esc-g431',136,73,.65)
back('POWER: MC1 +VBAT | MC3 GND_OUT',136,76,.75)
back('MOTOR: MC5 A MC9 B MC11 C',136,77.7,.75)
back('CHECK POLARITY BEFORE APPLYING POWER',136,79.4,.65)
back('Operating limits: see approved project specification',136,81,.5)
# machine connector key and STDC14, columns short to keep exact pad mapping
back('MACHINE CONTACTS',119,84,.8);back('J1 STDC14 / SWD',151,84,.8)
contacts=[p for p in parts if p['ref'].startswith('MC')];contacts.sort(key=lambda p:int(p['ref'][2:]))
for i,p in enumerate(contacts):
col=i//12;row=i%12;back(p['ref']+' '+clean(p['nets'][0][1]),114+col*18,86+row*1.25,.5)
j=next(p for p in parts if p['ref']=='J1')
for i,(pin,net) in enumerate(j['nets']):back(pin+' '+('NC' if net.startswith('unconnected') else clean(net)),153,86+i*1.12,.55)
back('MC4/6/7/13/14/18/19/22/23: GND',124,102,.5)
back('MP1 / MP2 / MP3 / MP4: GND',124,103.4,.55)
back('SW1 RESET | JP1 GND / 5V_EN',140,105,.6)
back('LED1/2 GREEN | LED3 BLUE',136,127,.5)
back('COMPONENT VALUES',136,107,.8)
# compact value key retains all exact ref/value pairs in grouped lines
for prefix,x,count in [('R',115,34),('C',135,45)]:
values=[p for p in parts if re.fullmatch(prefix+r'\d+',p['ref'])];values.sort(key=lambda p:int(p['ref'][1:]))
for i in range(0,len(values),3):
text=' '.join(p['ref']+' '+p['value'] for p in values[i:i+3]);back(text,x,108.7+(i//3)*1.05,.5)
for i,p in enumerate(sorted([p for p in parts if p['ref'].startswith(('U','Q','D','Y','FB'))],key=lambda p:p['ref'])):
back(p['ref']+' '+p['value'],156,108+i*1.07,.5)
back('Values are nominal; ratings and tolerances are in the BOM.',136,125.8,.5)
back('Power off before wiring. Use a current-limited first power-up.',136,130,.5)
# Append graphics only: original file and every original S-expression remain untouched.
raw=src.read_text();new=[]
# Remove only redundant silk outlines from nonpolar small R/C footprints.
# Keep all source bytes outside those exact balanced-expression spans.
def spans(text):
stack=[];quoted=False;escaped=False;out=[]
for i,ch in enumerate(text):
if quoted:
if escaped:escaped=False
elif ch=='\\':escaped=True
elif ch=='"':quoted=False
elif ch=='"':quoted=True
elif ch=='(':stack.append(i)
elif ch==')':
a=stack.pop();out.append((a,i+1,len(stack)))
return out
cuts=[]
for a,b,depth in spans(raw):
if depth!=1 or not raw[a:b].startswith('(footprint '):continue
block=raw[a:b];m=re.search(r'\(property "Reference" "([^"]+)"',block)
if not m or not m[1].startswith(('R','C')) or m[1] in ['C2','C3']:continue
for aa,bb,dd in spans(block):
node=block[aa:bb]
if dd==1 and node.startswith(('(fp_line','(fp_rect')) and '(layer "F.SilkS")' in node:cuts.append((a+aa,a+bb))
for a,b in sorted(cuts,reverse=True):raw=raw[:a]+raw[b:]
for l in labels:
mirror=' (justify mirror)' if l['layer']=='B.SilkS' else ''
new.append(f'(gr_text {json.dumps(l["text"])} (at {l["x"]} {l["y"]} {l.get("angle",0)}) (layer "{l["layer"]}") (uuid "{uuid.uuid5(uuid.NAMESPACE_URL,json.dumps(l,sort_keys=True))}") (effects (font (size {l["size"]} {l["size"]}) (thickness {0.10 if l["size"]>=.8 else min(.08,l["size"]*.15)})){mirror}))')
for line in leaders:
new.append(f'(gr_line (start {line["start"][0]:.4f} {line["start"][1]:.4f}) (end {line["end"][0]:.4f} {line["end"][1]:.4f}) (stroke (width 0.10) (type default)) (layer "F.SilkS") (uuid "{uuid.uuid5(uuid.NAMESPACE_URL,json.dumps(line,sort_keys=True))}"))')
out=D/'esc-g431-astra-silkscreen-v11.kicad_pcb' ;out.write_text(raw.rstrip()[:-1]+'\n'+'\n'.join(new)+'\n)\n');parsed=c.parse(out)
def strip_silk(n):
if not isinstance(n,list):return n
if n and n[0] in ['gr_text','gr_line','fp_line','fp_rect'] and c.one(n,'layer') in [['F.SilkS'],['B.SilkS']]:return None
return [v for x in n if (v:=strip_silk(x)) is not None]
assert strip_silk(parsed)==strip_silk(root)
(D/'labels.json').write_text(json.dumps({'board':str(out),'source':str(src),'nonSilkscreenDataUnchanged':True,'removedRedundantPassiveSilkStrokes':len(cuts),'labels':labels,'leaders':leaders,'omittedNearby':missing,'notes':['0.5mm text is the requested 3rd party fab target; physical coupon not tested.','No unverified voltage/current ratings printed.','Conservative geometry placement pending native DRC and render.']},indent=2))
print(len(labels), 'labels', len(missing),'nearby deferred')