244 行
13 KiB
Python
244 行
13 KiB
Python
"""Native DRC/parity plus placement, direction, explicit USB pair and height audits.
|
|
|
|
Exit 0 means this placement checkpoint passes. --release additionally requires
|
|
completed copper connectivity, resolved ERC, impedance and height qualification.
|
|
"""
|
|
import argparse
|
|
from collections import Counter
|
|
import json
|
|
import math
|
|
import subprocess
|
|
|
|
import pcbnew as pcb
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from bootstrap_pcb import mating_check, verify_import
|
|
from check_hardware import Board
|
|
from kelvin_checks import check_measurement_branches
|
|
from optimize_pcb_layout import ROOT, OUT, box_mm, expand, intersects, point
|
|
|
|
CLI = 'D:/KiCad/bin/kicad-cli.exe'
|
|
|
|
|
|
def run(args,output):
|
|
result = subprocess.run([CLI,*args],capture_output=True,encoding='utf-8',errors='replace',timeout=180)
|
|
output.with_suffix(output.suffix+'.log').write_text(result.stdout+result.stderr,encoding='utf-8')
|
|
if result.returncode or not output.exists():
|
|
raise RuntimeError(f'KiCad failed ({result.returncode}): {output}; {result.stdout[-600:]}')
|
|
|
|
|
|
def preview(board,number):
|
|
scale=9
|
|
im=Image.new('RGB',(228*scale,147*scale),'#f6f8fa')
|
|
d=ImageDraw.Draw(im)
|
|
def pos(x,y): return ((x-46)*scale,(y-43)*scale)
|
|
def rect(b): return (*pos(b[0],b[1]),*pos(b[2],b[3]))
|
|
d.rounded_rectangle(rect((50,50,270,185)),radius=5*scale,fill='#e7f0ec',outline='#34483c',width=2)
|
|
font=ImageFont.truetype('C:/Windows/Fonts/arial.ttf',9)
|
|
titlefont=ImageFont.truetype('C:/Windows/Fonts/arial.ttf',19)
|
|
stage = 'PARTIAL LOCAL ROUTING' if list(board.GetTracks()) else 'UNROUTED'
|
|
d.text(pos(51,45),f'PhoneRail Board {number} | {stage} | common top-view coordinates',font=titlefont,fill='#19272a')
|
|
for z in board.Zones():
|
|
if z.GetIsRuleArea():
|
|
b=box_mm(z)
|
|
d.rectangle(rect(b),outline='#bf4d70',width=2)
|
|
if 'HEIGHT' in z.GetZoneName() or 'RF_' in z.GetZoneName():
|
|
d.text(pos(b[0],b[1]),z.GetZoneName().replace('R2_',''),font=font,fill='#a23151')
|
|
for t in board.GetTracks():
|
|
if isinstance(t, pcb.PCB_VIA):
|
|
continue
|
|
a, b = t.GetStart(), t.GetEnd()
|
|
d.line([pos(pcb.ToMM(a.x),pcb.ToMM(a.y)),pos(pcb.ToMM(b.x),pcb.ToMM(b.y))],
|
|
fill='#d22e32' if t.GetLayer() == pcb.F_Cu else '#2358b0',
|
|
width=max(1,round(pcb.ToMM(t.GetWidth())*scale)))
|
|
for f in board.GetFootprints():
|
|
b=box_mm(f)
|
|
if f.GetSheetfile():
|
|
color = '#c8d8ed' if f.GetReference().startswith('U') else '#dcd5bb'
|
|
if f.GetReference().startswith('J'): color='#bfc6cc'
|
|
if f.GetReference().startswith(('L','Rs')): color='#e5c7ab'
|
|
d.rectangle(rect(b),fill=color,outline='#596474',width=1)
|
|
for p in f.Pads():
|
|
b=box_mm(p)
|
|
fill='#b0692c' if p.GetNetname()=='GND' else '#c64343'
|
|
if p.GetShape()==pcb.PAD_SHAPE_CIRCLE: d.ellipse(rect(b),fill=fill)
|
|
else: d.rectangle(rect(b),fill=fill)
|
|
drill=p.GetDrillSize()
|
|
if drill.x:
|
|
x,y=point(p); radius=pcb.ToMM(drill.x)/2
|
|
d.ellipse(rect((x-radius,y-radius,x+radius,y+radius)),fill='#f6f8fa')
|
|
if f.Reference().IsVisible():
|
|
x,y=point(f.Reference())
|
|
d.text(pos(x,y),f.GetReference(),font=font,fill='#172329',anchor='mm')
|
|
im.save(OUT/f'Board{number}-placement.png')
|
|
|
|
|
|
def distances(parts,n):
|
|
checks=[]
|
|
def distance(label,ref,pin,other,max_mm,other_pin=None):
|
|
a=next(p for p in parts[ref].Pads() if p.GetNumber()==str(pin))
|
|
pads=[p for p in parts[other].Pads() if (p.GetNumber()==str(other_pin) if other_pin else p.GetNetname()==a.GetNetname())]
|
|
assert pads, (label,ref,pin,other)
|
|
actual=min((a.GetPosition()-p.GetPosition()).EuclideanNorm()/1e6 for p in pads)
|
|
checks.append({'check':label,'from':f'{ref}.{pin}','to':other,'distance_mm':round(actual,3),
|
|
'max_mm':max_mm,'pass':actual<=max_mm})
|
|
if n==1:
|
|
for ch in range(1,6):
|
|
distance(f'CH{ch} VIN bypass',f'U{ch}',4,f'C{ch}01',5)
|
|
distance(f'CH{ch} bootstrap',f'U{ch}',13,f'C{ch}05',5)
|
|
distance(f'CH{ch} switch to inductor',f'U{ch}',11,f'L{ch}01',5)
|
|
distance(f'CH{ch} feedback top',f'U{ch}',7,f'R{ch}01',5)
|
|
distance(f'CH{ch} feedback bottom',f'U{ch}',7,f'R{ch}02',5)
|
|
distance(f'CH{ch} Kelvin positive',f'U{ch+7}',10,f'Rs{ch}01',10)
|
|
distance(f'CH{ch} Kelvin negative',f'U{ch+7}',9,f'Rs{ch}01',10)
|
|
distance(f'CH{ch} INA bypass',f'U{ch+7}',6,f'C{ch}13',5)
|
|
else:
|
|
for ref,pin in [('C742',25),('C743',62),('C744',5),('C745',10),('C746',24),
|
|
('C747',46),('C748',52),('C749',57),('C750',64)]:
|
|
distance(f'Hub bypass {ref}','U32',pin,ref,5)
|
|
distance('Hub crystal XTAL1','U32',61,'Y1',7)
|
|
distance('Hub crystal XTAL2','U32',60,'Y1',7)
|
|
for ch in range(1,6):
|
|
distance(f'CH{ch} tail ESD DM',f'J7{ch}',4,f'U5{ch}',10)
|
|
distance(f'CH{ch} battery ceramic',f'J8{ch}',1,f'C{802+(ch-1)*10}',12)
|
|
return checks
|
|
|
|
|
|
def routing(board,contract,n):
|
|
errors=[]
|
|
tracks={}
|
|
transverse=[]
|
|
for t in board.GetTracks():
|
|
tracks.setdefault(t.GetNetname(),[]).append(t)
|
|
if isinstance(t,pcb.PCB_VIA): continue
|
|
a,b=t.GetStart(),t.GetEnd()
|
|
dx,dy=abs(pcb.ToMM(a.x-b.x)),abs(pcb.ToMM(a.y-b.y))
|
|
if t.GetLayer() not in (pcb.F_Cu,pcb.B_Cu):
|
|
errors.append({'kind':'inner_signal_track','uuid':t.m_Uuid.AsString()})
|
|
continue
|
|
wrong=dy if t.GetLayer()==pcb.F_Cu else dx
|
|
if wrong>.001 and t.m_Uuid.AsString() not in contract['direction']['exceptions']:
|
|
transverse.append(t)
|
|
# Join contiguous nonpreferred segments so splitting a long wrong-way route
|
|
# into short pieces does not bypass the escape-length limit.
|
|
pending=set(range(len(transverse)))
|
|
while pending:
|
|
group={pending.pop()}
|
|
changed=True
|
|
while changed:
|
|
changed=False
|
|
endpoints={(transverse[i].GetNetname(),transverse[i].GetLayer(),q.x,q.y)
|
|
for i in group for q in (transverse[i].GetStart(),transverse[i].GetEnd())}
|
|
for i in list(pending):
|
|
t=transverse[i]
|
|
if any((t.GetNetname(),t.GetLayer(),q.x,q.y) in endpoints for q in (t.GetStart(),t.GetEnd())):
|
|
pending.remove(i);group.add(i);changed=True
|
|
length=sum(pcb.ToMM(transverse[i].GetLength()) for i in group)
|
|
if length>contract['direction']['max_transverse_escape_mm']:
|
|
errors.append({'kind':'direction','uuids':[transverse[i].m_Uuid.AsString() for i in group],
|
|
'net':transverse[next(iter(group))].GetNetname(),'length_mm':round(length,3)})
|
|
pairs=[]
|
|
for pair in contract['boards'][f'PhoneRail-Board{n}']['usb_pairs']:
|
|
info=dict(pair)
|
|
lengths=[]
|
|
via_counts=[]
|
|
for net in pair.values():
|
|
lengths.append(sum(pcb.ToMM(t.GetLength()) for t in tracks.get(net,[]) if not isinstance(t,pcb.PCB_VIA)))
|
|
via_counts.append(sum(isinstance(t,pcb.PCB_VIA) for t in tracks.get(net,[])))
|
|
info.update(copper_length_mm=lengths,via_counts=via_counts,
|
|
status='UNROUTED' if not any(lengths) else 'PARTIAL_OR_ROUTED_REQUIRES_CONNECTIVITY_CHECK')
|
|
if abs(lengths[0]-lengths[1])>contract['usb']['max_pair_skew_mm']:
|
|
errors.append({'kind':'usb_skew','pair':pair,'lengths':lengths})
|
|
if max(via_counts)>contract['usb']['max_vias_per_leg'] or via_counts[0]!=via_counts[1]:
|
|
errors.append({'kind':'usb_vias','pair':pair,'counts':via_counts})
|
|
pairs.append(info)
|
|
measurements=check_measurement_branches(board,contract.get('measurement_branches',[]) if n==1 else [])
|
|
errors.extend(measurements['errors'])
|
|
return {'errors':errors,'explicit_usb_pairs':pairs,'measurement_branches':measurements,
|
|
'note':'Track-only lengths exclude package/connector delay and vertical via delay; impedance and coupling require routed review'}
|
|
|
|
|
|
def main():
|
|
ap=argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument('--release',action='store_true')
|
|
args=ap.parse_args()
|
|
contract=json.loads((ROOT/'pcb-routing-rules.json').read_text(encoding='utf-8'))
|
|
result={'status':'PLACEMENT_CHECKPOINT_NOT_PRODUCTION_RELEASE','boards':{}}
|
|
boards=[]
|
|
errors=[]
|
|
for n in (1,2):
|
|
name=f'PhoneRail-Board{n}'
|
|
path=ROOT/name/(name+'.kicad_pcb')
|
|
project=json.loads(path.with_suffix('.kicad_pro').read_text(encoding='utf-8'))
|
|
patterns={v['pattern']:v['netclass'] for v in project['net_settings']['netclass_patterns']}
|
|
expected=contract['boards'][name]['net_classes']
|
|
assert all(patterns.get(k)==v for k,v in expected.items() if v!='Default'),'Net-class rules have drifted'
|
|
b=pcb.LoadBoard(str(path)); boards.append(b)
|
|
source=Board(n); source.compile(CLI,OUT)
|
|
parity=verify_import(b,source,n,allow_routes=True)
|
|
parts={f.GetReference():f for f in b.GetFootprints()}
|
|
if n == 1:
|
|
mechanical=json.loads((ROOT/'pcb-mechanical.json').read_text(encoding='utf-8'))
|
|
input_spec=mechanical['input_connector_kicad']
|
|
connector=parts[input_spec['reference']]
|
|
assert list(point(connector)) == input_spec['position_mm'], 'Input connector moved from reviewed board-edge position'
|
|
assert connector.GetOrientationDegrees() % 360 == input_spec['angle_deg'] % 360, 'Input connector must open toward the left edge'
|
|
before=pcb.LoadBoard(str(OUT/'before'/path.name))
|
|
anchors={f.GetReference():f for f in before.GetFootprints() if f.IsLocked()}
|
|
for ref,f in anchors.items():
|
|
current=parts[ref]
|
|
assert point(f)==point(current) and f.GetOrientationDegrees()==current.GetOrientationDegrees() and f.GetLayer()==current.GetLayer(),ref
|
|
assert [(p.GetNumber(),point(p),p.GetNetname()) for p in f.Pads()]==[(p.GetNumber(),point(p),p.GetNetname()) for p in current.Pads()],ref
|
|
out_of_board=[r for r,f in parts.items() if not (50<=point(f)[0]<=270 and 50<=point(f)[1]<=185)]
|
|
assert not out_of_board,out_of_board
|
|
# Native class evaluation is checked after loading the associated project.
|
|
drc=OUT/f'Board{n}-drc.json'
|
|
run(['pcb','drc','--format','json','--schematic-parity','-o',str(drc),str(path)],drc)
|
|
data=json.loads(drc.read_text(encoding='utf-8'))
|
|
metrics=distances(parts,n)
|
|
route=routing(b,contract,n)
|
|
for face,layers in [('top','F.Cu,F.Silkscreen,Edge.Cuts'),('bottom','B.Cu,B.Silkscreen,Edge.Cuts')]:
|
|
svg=OUT/f'Board{n}-{face}.svg'
|
|
run(['pcb','export','svg','--mode-single','--layers',layers,'--page-size-mode','2',
|
|
'--exclude-drawing-sheet','-o',str(svg),str(path)],svg)
|
|
preview(b,n)
|
|
entry={'electrical_footprints':len(source.parts),'all_inside_board':True,'unchanged_anchors':len(anchors),
|
|
'pad_net_assignments_checked':parity['pad_net_assignments_checked'],
|
|
'drc_violations':dict(Counter(v['type'] for v in data.get('violations',[]))),
|
|
'schematic_parity':data.get('schematic_parity',[]),
|
|
'drc_report_unconnected_items':len(data.get('unconnected_items',[])),
|
|
'critical_distances':metrics,'routing':route,'erc':source.erc,'tracks':len(list(b.GetTracks()))}
|
|
b.BuildConnectivity()
|
|
b.GetConnectivity().RecalculateRatsnest()
|
|
entry['unconnected_items']=b.GetConnectivity().GetUnconnectedCount(False)
|
|
entry['unconnected_count_source']='Native connectivity engine; DRC report list may be incomplete'
|
|
result['boards'][name]=entry
|
|
if data.get('violations') or data.get('schematic_parity') or route['errors'] or any(not m['pass'] for m in metrics):
|
|
errors.append(name)
|
|
print(name,'DRC',entry['drc_violations'],'unconnected',entry['unconnected_items'],
|
|
'distance failures',[m for m in metrics if not m['pass']],flush=True)
|
|
result['mated_pins_checked']=mating_check(*boards)
|
|
lower={f.GetReference():f for f in boards[0].GetFootprints()}
|
|
upper={f.GetReference():f for f in boards[1].GetFootprints()}
|
|
collisions=[]
|
|
for ch in range(1,6):
|
|
ref=f'C{801+(ch-1)*10}'
|
|
rect=expand(box_mm(upper[ref]),1)
|
|
collisions.extend((ref,r) for r,f in lower.items() if f.GetLayer()==pcb.F_Cu and intersects(rect,box_mm(f)))
|
|
result['tall_cap_projection_collisions']=collisions
|
|
if collisions:errors.append('height projection')
|
|
result['checkpoint_passed']=not errors
|
|
if any(v['tracks'] for v in result['boards'].values()):
|
|
result['status']='PARTIAL_ROUTING_CHECKPOINT_NOT_PRODUCTION_RELEASE'
|
|
total_airwires=sum(v['unconnected_items'] for v in result['boards'].values())
|
|
result['release_blockers']=[f'{total_airwires} current unconnected items',
|
|
'Full routing, reference planes and thermal copper not implemented',
|
|
'ERC pin review remains open','Controlled impedance stackup unconfirmed',
|
|
'Complete height/antenna/connector assembly and worst-case thermal qualification remain open']
|
|
(OUT/'verification.json').write_text(json.dumps(result,indent=2,ensure_ascii=False)+'\n',encoding='utf-8')
|
|
if errors or args.release:raise SystemExit(1)
|
|
|
|
|
|
if __name__=='__main__':
|
|
main()
|