文件
phonerail/kicad/scripts/optimize_pcb_layout.py

375 行
20 KiB
Python

"""Create R2 functional placement from schematic connectivity and mechanical anchors.
Run with KiCad Python. The source snapshot is the unrouted native KiCad board;
no coordinates, copper, or routes are read from the legacy layout.
"""
from collections import defaultdict
import hashlib
import json
import math
from pathlib import Path
import shutil
import pcbnew as pcb
from bootstrap_pcb import ROOT, CONFIG, xy, keepout, label, pth_apertures
from schematic_maintenance import parse, children, child
OUT = ROOT / 'out/pcb-layout-r2'
REVISION = 'R2-FUNCTIONAL-PLACEMENT'
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def box_mm(item):
b = item.GetBoundingBox(False, False) if isinstance(item, pcb.FOOTPRINT) else item.GetBoundingBox()
return tuple(pcb.ToMM(v) for v in (b.GetLeft(), b.GetTop(), b.GetRight(), b.GetBottom()))
def expand(box, d):
a, b, c, e = box
return a-d, b-d, c+d, e+d
def intersects(a, b):
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
def point(item):
q = item.GetPosition()
return pcb.ToMM(q.x), pcb.ToMM(q.y)
class Layout:
def __init__(self, number):
self.number = number
self.name = f'PhoneRail-Board{number}'
self.path = ROOT / self.name / (self.name + '.kicad_pcb')
text = (OUT / 'before' / self.path.name).read_text(encoding='utf-8')
edits = [(node.start,node.end) for node in children(parse(text),'gr_text')
if float(child(node,'at')[1]) > 270 or 'UNROUTED' in node[1]]
for start,end in sorted(edits,reverse=True):
text = text[:start]+text[end:]
source = OUT / ('input-'+self.path.name)
source.write_text(text,encoding='utf-8')
self.board = pcb.LoadBoard(str(source))
assert not list(self.board.GetTracks()), 'This placement tool requires an unrouted source'
self.parts = {f.GetReference(): f for f in self.board.GetFootprints()}
self.done = {r for r, f in self.parts.items() if f.IsLocked()}
self.obstacles = []
self.placements = {}
for r in self.done:
f = self.parts[r]
both = any(p.GetAttribute() in (pcb.PAD_ATTRIB_PTH, pcb.PAD_ATTRIB_NPTH) for p in f.Pads())
self.obstacles.append((r, expand(box_mm(f), .35), -1 if both else f.GetLayer()))
for z in self.board.Zones():
if z.GetIsRuleArea() and z.GetDoNotAllowFootprints():
self.obstacles.append((z.GetZoneName(), expand(box_mm(z), .25),
-1 if z.GetLayerSet().Contains(pcb.F_Cu) else pcb.B_Cu))
def reserve(self, name, rect, layers, copper=False, footprints=True):
keepout(self.board, name, rect, layers, copper)
z = list(self.board.Zones())[-1]
z.SetDoNotAllowFootprints(footprints)
if footprints:
self.obstacles.append((name, rect, -1 if len(layers) > 1 else layers[0]))
def put(self, ref, target, rotation=0, radius=8, step=.5, bottom=None, reason='functional placement'):
assert ref not in self.done, (ref, 'already placed')
f = self.parts[ref]
bottom = self.number == 2 if bottom is None else bottom
if f.IsFlipped() != bottom:
f.Flip(f.GetPosition(), pcb.FLIP_DIRECTION_TOP_BOTTOM)
rotations = rotation if isinstance(rotation, tuple) else (rotation,)
layer = pcb.B_Cu if bottom else pcb.F_Cu
candidates = [(0, 0)]
for k in range(1, int(radius / step)+1):
candidates.extend((dx*step, dy*step) for dx in range(-k, k+1) for dy in (-k, k))
candidates.extend((dx*step, dy*step) for dy in range(-k+1, k) for dx in (-k, k))
candidates.sort(key=lambda q: q[0]**2+q[1]**2)
winner = None
for dx, dy in candidates:
for rot in rotations:
f.SetPosition(xy(target[0]+dx, target[1]+dy))
f.SetOrientationDegrees(rot)
b = box_mm(f)
if b[0] < 51 or b[1] < 51 or b[2] > (271 if ref == 'J22' else 269) or b[3] > 184:
continue
if any((ol == -1 or ol == layer) and intersects(b, ob) for _, ob, ol in self.obstacles):
continue
winner = (b, rot)
break
if winner:
break
if winner is None:
raise RuntimeError(f'No legal location for {ref} near {target}')
b, rot = winner
f.SetLocked(False)
f.SetField('Placement', REVISION)
f.SetField('LayoutIntent', reason)
for field in f.GetFields():
if field.GetName() not in {'Reference', 'Value'}:
field.SetVisible(False)
f.Value().SetVisible(False)
f.Reference().SetLayer(pcb.B_SilkS if bottom else pcb.F_SilkS)
f.Reference().SetMirrored(bottom)
f.Reference().SetTextAngle(pcb.EDA_ANGLE(0, pcb.DEGREES_T))
f.Reference().SetTextSize(xy(.8, .8))
f.Reference().SetTextThickness(pcb.FromMM(.12))
f.Reference().SetPosition(xy((b[0]+b[2])/2, b[1]-.7))
self.done.add(ref)
both = any(p.GetAttribute() == pcb.PAD_ATTRIB_PTH for p in f.Pads())
self.obstacles.append((ref, expand(b, .45), -1 if both else layer))
self.placements[ref] = {'xy_mm': point(f), 'rotation': rot, 'layer': self.board.GetLayerName(layer),
'target_displacement_mm': round(math.hypot(dx,dy),3), 'intent': reason}
return f
def near(self, ref, anchor, pin=None, offset=(0,0), **kwargs):
f = self.parts[anchor]
obj = next(p for p in f.Pads() if p.GetNumber() == str(pin)) if pin else f
x, y = point(obj)
return self.put(ref, (x+offset[0], y+offset[1]), rotation=(0,90,180,270), **kwargs)
def finish(self):
missing = set(self.parts) - self.done
assert not missing, sorted(missing)
self.board.GetTitleBlock().SetRevision(REVISION)
self.board.GetTitleBlock().SetTitle(f'PhoneRail Board {self.number} - functional placement / unrouted')
label(self.board, f'BOARD {self.number} R2 - PLACED / UNROUTED', 160, 46, 1.5)
label(self.board, 'F.Cu: horizontal | B.Cu: vertical | local escapes require review', 160, 189, 1)
place_silkscreen(self.board)
candidate = OUT / self.path.name
pcb.SaveBoard(str(candidate), self.board)
candidate.write_text(pth_apertures(candidate.read_text(encoding='utf-8')), encoding='utf-8')
return self.board
def place_silkscreen(board, refs=None):
# Fit references in actual free board space without suppressing DRC severities.
occupied = defaultdict(list)
for f in board.GetFootprints():
for p in f.Pads():
for copper, silk in ((pcb.F_Cu,pcb.F_SilkS),(pcb.B_Cu,pcb.B_SilkS)):
if p.GetLayerSet().Contains(copper):
occupied[silk].append(expand(box_mm(p), .2))
for shape in f.GraphicalItems():
if shape.GetLayer() in (pcb.F_SilkS, pcb.B_SilkS):
occupied[shape.GetLayer()].append(expand(box_mm(shape), .12))
if refs is not None and f.GetReference() not in refs and f.Reference().IsVisible():
occupied[f.Reference().GetLayer()].append(expand(box_mm(f.Reference()), .12))
for f in sorted(board.GetFootprints(), key=lambda x:x.GetReference()):
t = f.Reference()
if not t.IsVisible() or (refs is not None and f.GetReference() not in refs):
continue
layer = t.GetLayer()
b = box_mm(f)
cx, cy = (b[0]+b[2])/2, (b[1]+b[3])/2
t.SetTextAngle(pcb.EDA_ANGLE(0,pcb.DEGREES_T))
found = False
for d in (.8,1.3,1.8,2.5,3.5,5,7):
positions = [(cx,b[1]-d),(cx,b[3]+d),(b[0]-d-1.5,cy),(b[2]+d+1.5,cy)]
positions += [(cx+dx,b[1]-d) for dx in (-3,-2,-1,1,2,3)]
positions += [(cx+dx,b[3]+d) for dx in (-3,-2,-1,1,2,3)]
for x,y in positions:
t.SetPosition(xy(x,y))
tb = box_mm(t)
if tb[0]<51 or tb[2]>269 or tb[1]<51 or tb[3]>184:
continue
if not any(intersects(tb, q) for q in occupied[layer]):
occupied[layer].append(expand(tb,.12))
found = True
break
if found:
break
if not found:
t.SetLayer(pcb.B_Fab if f.IsFlipped() else pcb.F_Fab)
t.SetPosition(xy(cx,cy))
f.SetField('ReferenceOnFabOnly','Dense area: refer to assembly drawing')
for field in f.GetFields():
if field.GetName()=='ReferenceOnFabOnly':
field.SetVisible(False)
field.SetMirrored(f.IsFlipped())
def carrier():
s = Layout(2)
# Bottom-side electronics stay clear of the lower board's outward-facing antenna.
s.reserve('R2_ESP32_ANTENNA_PROJECTION', (232,175,258,184), [pcb.F_Cu,pcb.In1_Cu,pcb.In2_Cu,pcb.B_Cu], True)
s.put('U32',(174,122),90,reason='Hub centered between five ports; upstream exit faces east')
s.near('C749','U32',57,offset=(2,-2),radius=3,reason='Prioritize hub bypass beside upstream PHY supply pin')
s.put('U31',(152,122),0,reason='Hub LDO between BTB supply and hub')
s.put('U21',(203,130),0,reason='595 serial input adjacent to J32 control pins')
s.put('U22',(184,135),0,reason='595 chain toward lower phone sites')
s.put('U33',(164,137),0,reason='595 chain end and hub reset')
s.put('U34',(239,154),0,reason='Carrier temperature sensor away from LDO and bulk capacitors')
for ch in range(1,6):
# Rotate this functional cell with the mechanical phone orientation.
sign = 1 if ch <= 3 else -1
bat, tail = f'J8{ch}',f'J7{ch}'
bx,by = point(s.parts[bat])
tx,ty = point(s.parts[tail])
base = 800+(ch-1)*10
s.put(f'C{base+1}',(bx+sign*12,by+sign*2),0, radius=12,
reason='10.5mm bulk capacitor beside battery; lower-board projection reserved')
for k, offset in [(2,(7,-5)),(3,(7,-2)),(4,(7,1)),(5,(7,4)),(6,(5,-6))]:
s.put(f'C{base+k}',(bx+sign*offset[0],by+sign*offset[1]),(0,90,180,270),radius=10,
reason='Battery ceramic bank outside ribbon lane')
for ref,pin in [(f'R{780+ch}',3),(f'R{790+ch}',4)]:
s.near(ref,bat,pin,reason='Battery identification resistor at connector')
s.put(f'U{50+ch}',(tx+sign*3,ty-sign*5),0,radius=8,
reason='USB ESD flow-through device adjacent to tail connector')
s.put(f'U{23+ch}',(tx+sign*10,ty-sign*7),0,radius=10,
reason='Per-port VBUS switch near tail; bulk current separated from USB pair')
for prefix,pin in [('C70',5),('C71',1),('C72',4),('R70',6)]:
ref = f'{prefix}{ch}'
if ref in s.parts:
s.near(ref,f'U{23+ch}',pin,reason='VBUS switch local decoupling/timing/default state')
for ref,pin in [(f'R{760+ch}',22),(f'R{770+ch}',14)]:
s.near(ref,tail,pin,reason='Tail accessory termination')
# Keep EDL service connector reachable from the side of each site.
s.put(f'J6{ch}',(bx-sign*10,by-sign*9),0,radius=10,reason='EDL service connector beside battery site')
s.near(f'U4{ch}',f'J6{ch}',offset=(0,-sign*6),radius=10,reason='EDL analog switch at service connector')
s.near(f'C{760+ch}',f'U4{ch}',5,reason='EDL switch supply bypass')
s.near(f'R{740+ch}',f'U4{ch}',6,reason='EDL enable defaults low')
for k in range(3):
q = f'Q{ch}{k+3:02}'
contact = f'J{41+(ch-1)*4+k}'
s.near(q,contact,offset=(-sign*3,sign*7),radius=14,reason='Key MOS close to its phone contact')
for suffix in (20+2*k,21+2*k):
s.near(f'R{ch}{suffix}',q,1,reason='Key gate resistor/pulldown at MOS')
for ref,anchor,pin in [('C736','U31',3),('C737','U31',2),('C760','U34',8),
('C604','U21',16),('C605','U22',16),('C739','U33',16),('R609','U21',13)]:
s.near(ref,anchor,pin,reason='Supply bypass / control bias at IC pin')
s.near('Y1','U32',61,offset=(0,-4),radius=8,reason='24MHz crystal immediately at hub oscillator pins')
for ref,pin in [('C740',1),('C741',3)]:
s.near(ref,'Y1',pin,reason='Crystal load capacitor; isolated ground return')
hub_pins = {'C742':25,'C743':62,'C744':5,'C745':10,'C746':24,'C747':46,'C748':52,
'C750':64,'C751':64,'R708':63,'R709':44,'R710':44,'R711':41,
'R712':42,'R714':54,'R715':56,'R716':43,'R719':40,'R720':45}
for ref,pin in hub_pins.items():
s.near(ref,'U32',pin,radius=12,reason='Hub pin-local bypass or configuration resistor')
s.near('R717','J23','A5',offset=(-5,0),reason='USB-C CC termination')
s.near('R718','J23','B5',offset=(-5,0),reason='USB-C CC termination')
s.near('R721','J23','A4B9',offset=(-8,0),reason='Upstream VBUS detection divider')
s.near('R722','R721',2,reason='Upstream VBUS detection divider return')
return s
def power_cell(s,ch,origin):
x,y = origin
buck, efuse, ina = f'U{ch}',f'U{ch+13}',f'U{ch+7}'
s.put(buck,(x,y),0,radius=0,reason=f'CH{ch} buck; VIN west, SW east, feedback south')
s.put(f'L{ch}01',(x+7,y),0,radius=1,reason='Inductor directly at SW; horizontal hot path')
s.put(f'C{ch}06',(x+14,y-2),90,radius=2,reason='First buck output capacitor immediately after inductor')
s.put(f'C{ch}07',(x+17,y-2),90,radius=2,reason='Parallel buck output capacitor')
s.put(efuse,(x+20,y+5),90,radius=3,reason='Output protection between buck capacitor and shunt')
s.put(f'Rs{ch}01',(x+26,y+9),180,radius=3,reason='4A output shunt; Kelvin pickup on inner pad edges')
s.put(ina,(x+26,y+15),0,radius=3,reason='INA inputs face shunt; digital bus exits away from power path')
for k,dy in [(1,-2),(2,1),(3,4),(4,-4)]:
s.put(f'C{ch}{k:02}',(x-5,y+dy),(0,180,90,270),radius=5,reason='Local VIN ceramic hot-loop bank')
s.near(f'C{ch}05',buck,13,offset=(2,-2),radius=5,reason='Bootstrap capacitor directly between BOOT and SW')
s.near(f'C{ch}08',efuse,11,reason='eFuse input bypass')
for ref,pin in [(f'R{ch}01',7),(f'R{ch}02',7),(f'R{ch}03',1),(f'R{ch}04',10),
(f'R{ch}05',10),(f'R{ch}06',8),(f'C{ch}09',9),(f'C{ch}10',8),
(f'C{ch}11',8),(f'R{ch}07',7)]:
s.near(ref,buck,pin,radius=9,reason='Buck quiet feedback/compensation/soft-start component')
s.near(f'C{ch}12',f'R{ch}07',2,reason='DAC injection low-pass at feedback branch')
s.near(f'R{ch}08',f'C{ch}12',1,reason='DAC injection series resistor')
for k,pin in [(9,15),(10,15),(11,17),(12,19),(13,20),(14,14),(15,2),(16,3),(17,3)]:
s.near(f'R{ch}{k:02}',efuse,pin,radius=10,reason='eFuse OVP/ILIM/PG threshold or default bias')
s.near(f'C{ch}14',efuse,18,reason='eFuse ramp timing capacitor')
s.near(f'C{ch}13',ina,6,reason='INA226 local supply bypass')
s.near(f'Q{ch}01',efuse,14,offset=(-4,4),reason='Discharge interlock local to eFuse enable')
s.near(f'Q{ch}02',f'Q{ch}01',3,offset=(0,5),reason='Discharge MOS isolated from quiet feedback')
s.near(f'R{ch}18',f'Q{ch}01',3,reason='Discharge gate bias')
s.near(f'R{ch}19',f'Q{ch}02',3,offset=(5,4),radius=10,reason='Pulse discharge resistor with open thermal surroundings')
def lower(upper):
s = Layout(1)
for ch in range(1,6):
ref = f'C{801+(ch-1)*10}'
s.reserve(f'R2_HEIGHT_{ref}_10.5mm',expand(box_mm(upper.parts[ref]),1),[pcb.F_Cu])
s.reserve('R2_RF_COPPER_VOID',(232,175,258,184),[pcb.F_Cu,pcb.In1_Cu,pcb.In2_Cu,pcb.B_Cu],True,False)
s.obstacles.append(('RF_NEIGHBOUR_CLEARANCE',(232,175,258,184),pcb.F_Cu))
# The module itself extends into the copper-only antenna area.
rf = s.obstacles.pop()
s.put('U20',(247,165),180,radius=0,reason='MCU at board edge; antenna toward free south-east corner')
s.obstacles.append(rf)
s.put('J22',(264.866,151),90,radius=0,reason='USB debug connector opens at right edge')
s.put('J1',(63,67),90,radius=0,reason='12V input at left edge; fuse and TVS directly inboard')
s.put('J26',(59,148),270,radius=0,reason='Intake fan cable entry at left edge')
s.put('J27',(260,99),90,radius=0,reason='Exhaust fan cable entry at right edge')
# Three upper and two lower cells follow connector destinations, not old coordinates.
for ch,origin in [(1,(81,91)),(2,(137,86)),(3,(213,89)),(4,(102,141)),(5,(179,141))]:
power_cell(s,ch,origin)
for ref,position,rot in [('F1',(74,65),0),('Q1',(82,65),90),('D1',(87,57),0),
('C2',(98,59),0),('C3',(108,59),0),('Rs6',(118,62),0),
('U13',(119,69),0),('U19',(142,62),0),('L2',(149,62),0),
('U30',(169,62),0),('L3',(177,62),0),('Q3',(189,58),90),
('U29',(190,67),0),('U23',(150,157),0),('U6',(135,170),0),
('U7',(209,169),0),('Q7',(74,153),0),('Q8',(79,160),0),
('Q6',(85,159),0),('Q9',(72,166),0),('Q10',(251,111),0)]:
s.put(ref,position,rot,radius=6,reason='Functional input / auxiliary supply / control / fan zone')
for i,ref in enumerate(('R9A','R9B','R9C','R9D','R9E')):
s.put(ref,(204+i*10,58),0,radius=2,reason='Series bus discharge resistors spaced along ventilated north edge')
assignments = {
'C1':('Q1',4),'DZ1':('Q1',4),'R1':('Q1',4),'C4':('Q1',5),'C5':('Rs6',1),'C6':('Rs6',1),
'C7':('U19',3),'C8':('U19',6),'C9':('L2',2),'C10':('L2',2),'C11':('U13',6),
'R2':('U19',4),'R3':('U19',4),'R4':('U29',1),'R5':('U29',1),'R6':('Q3',4),
'C731':('U30',3),'C732':('U30',3),'C733':('U30',6),'C734':('L3',2),'C735':('L3',2),
'R706':('U30',4),'R707':('U30',4),
'C601':('U20',2),'C602':('U20',2),'C603':('U20',3),'R601':('U20',3),
'R602':('J22','A5'),'R603':('J22','B5'),'R604':('U20',22),'R605':('U20',8),
'R606':('U23',9),'R607':('U23',10),'R608':('U23',1),
'C606':('U23',24),'C607':('U6',15),'C608':('U6',13),'C609':('U6',16),
'C610':('U7',15),'C611':('U7',13),'C612':('U7',16),'R614':('U6',16),
'R615':('U6',5),'R616':('U7',16),'R617':('U7',5),
'C738':('Q7',3),'D2':('Q7',3),'DZ3':('Q7',1),'R740':('Q7',1),'R741':('Q8',3),
'R742':('Q8',1),'R743':('Q8',1),'R733':('Q6',1),'R734':('Q6',1),
'R744':('J26',3),'R745':('Q9',1),'R746':('Q9',1),'R747':('Q9',3),
'R748':('J27',3),'R749':('Q10',1),'R750':('Q10',1),'R751':('Q10',3)}
for ref,(anchor,pin) in assignments.items():
s.near(ref,anchor,pin,radius=12,reason='Pin-local support circuitry')
s.put('RT601',(92,102),0,radius=8,reason='Thermistor near CH1 hot area; avoid direct copper heat spreading')
s.put('RT602',(220,99),0,radius=8,reason='Thermistor near CH3 hot area; avoid direct copper heat spreading')
for ref,pin in [('R618',39),('C613',39),('R619',38),('C614',38)]:
s.near(ref,'U20',pin,radius=10,reason='ADC thermistor filter at MCU input')
return s
def main():
OUT.mkdir(parents=True,exist_ok=True)
before = OUT / 'before'
manifest = OUT / 'placement.json'
previous = json.loads(manifest.read_text(encoding='utf-8')) if manifest.exists() else {}
for n in (1,2):
name = f'PhoneRail-Board{n}'
for ext in ('.kicad_pcb','.kicad_pro','.kicad_dru'):
path = ROOT/name/(name+ext)
expected = previous.get('generated_files',{}).get(str(path.relative_to(ROOT)))
if expected and digest(path) != expected:
raise RuntimeError(f'Refusing to overwrite subsequent edits: {path}')
before.mkdir(exist_ok=True)
backup = before/path.name
if not backup.exists():
shutil.copy2(path,backup)
upper = carrier()
lower_board = lower(upper)
reports = {}
for s in (lower_board,upper):
s.finish()
reports[s.name] = {'moved':len(s.placements),'placements':s.placements}
print(s.name,'placed',len(s.placements),flush=True)
for s in (lower_board,upper):
shutil.copy2(OUT/s.path.name,s.path)
reports['status'] = REVISION + '_UNROUTED_NOT_RELEASED'
reports['generated_files'] = {str(s.path.relative_to(ROOT)):digest(s.path) for s in (lower_board,upper)}
manifest.write_text(json.dumps(reports,indent=2)+'\n',encoding='utf-8')
if __name__ == '__main__':
main()