文件
phonerail/kicad/scripts/verify_pcb_layout.py

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()