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