molecule
AFS_Adom flight computer molecule
Public Unreviewedby ae007
Active fin stabilization flight computer for a sounding rocket, a clean-room AI redesign of the Rice Eclipse AFS board: RP2350B, ICM-45686, KX134-1211, MMC5983MA, MS5611, TMP117, INA219A, 8 servos, 2S LiPo, USB-C; 4-layer Adom molecule, designed end to end by Claude through KiCad and adom-aiflow
main
ae007
Project page: AFS_Adom rev A (Rice Eclipse AFS clean-room AI redesign): highlight reel and narrated process video with…
cbe460e
7d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
"""Heatsink via planner checked against the LIVE board copper (kicad_routing_state), because the aiflow planner reads the run's
board file, which does not yet carry the live-landed routes. Writes aiflow/run/plan-thermal-vias.json in the aiflow plan shape.
Rules: via 0.6/0.3 (spec thermalVias), PCBWay clearance 0.127 (+0.05 margin), hole-to-hole 0.2794, inside the net's planned
pour outline on both outer layers, clear of every other net's live track, via and pad, clear of every drilled hole."""
import json, math, sys
SCR = '/tmp/claude-1001/-home-adom-project/8819fb5f-c2c6-4524-9528-99428cd6b88e/scratchpad'
live = json.load(open(SCR + '/live_state.json'))
board = json.load(open(SCR + '/AFS_Adom/aiflow/silk/board.json'))
spec = json.load(open(SCR + '/AFS_Adom/aiflow/spec.json'))
CLR = 0.127 + 0.05
HH = 0.2794 + 0.03
fps = {f['ref']: f for f in board['footprints']}
def seg_dist(p, a, b):
ax, ay, bx, by = a['x'], a['y'], b['x'], b['y']
dx, dy = bx - ax, by - ay
L2 = dx * dx + dy * dy
t = 0 if L2 == 0 else max(0, min(1, ((p[0] - ax) * dx + (p[1] - ay) * dy) / L2))
return math.dist(p, (ax + t * dx, ay + t * dy))
def box_dist(p, b):
dx = max(b[0] - p[0], 0, p[0] - b[2]); dy = max(b[1] - p[1], 0, p[1] - b[3])
return math.hypot(dx, dy)
def inside_poly(p, poly):
x, y = p; n = len(poly); ins = False
for i in range(n):
x1, y1 = poly[i]; x2, y2 = poly[(i + 1) % n]
if (y1 > y) != (y2 > y):
xi = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
if x < xi: ins = not ins
return ins
def poly_edge_dist(p, poly):
return min(seg_dist(p, dict(x=poly[i][0], y=poly[i][1]), dict(x=poly[(i + 1) % len(poly)][0], y=poly[(i + 1) % len(poly)][1])) for i in range(len(poly)))
def around_box(refs, margin):
bs = [fps[r]['courtyard']['F'] for r in refs]
return [min(b[0] for b in bs) - margin, min(b[1] for b in bs) - margin, max(b[2] for b in bs) + margin, max(b[3] for b in bs) + margin]
# pour regions by net and layer (rect boxes or polygons), as the spec describes them
regions = {}
for pr in spec['pours']:
for lay in pr['layers']:
if pr.get('polygon'): regions.setdefault((pr['net'], lay), []).append(('poly', pr['polygon']))
elif pr.get('around'): regions.setdefault((pr['net'], lay), []).append(('box', around_box(pr['around'], pr['margin'])))
elif pr.get('outline'): regions.setdefault((pr['net'], lay), []).append(('outline', board['outline']))
others = {} # higher-priority pours that carve the GND outline pour
for pr in spec['pours']:
if pr.get('outline'): continue
for lay in pr['layers']:
others.setdefault(lay, []).append(('poly', pr['polygon']) if pr.get('polygon') else ('box', around_box(pr['around'], pr['margin'])))
def in_region(p, net, lay, r):
ok = False
for kind, g in regions.get((net, lay), []):
if kind == 'poly' and inside_poly(p, g) and poly_edge_dist(p, g) >= r + CLR: ok = True
if kind == 'box' and g[0] + r + CLR <= p[0] <= g[2] - r - CLR and g[1] + r + CLR <= p[1] <= g[3] - r - CLR: ok = True
if kind == 'outline':
ok = g[0] + 1.0 <= p[0] <= g[2] - 1.0 and g[1] + 1.0 <= p[1] <= g[3] - 1.0
for k2, g2 in others.get(lay, []): # the GND pour stops at every other pour (priority), keep clear of them
if k2 == 'poly' and (inside_poly(p, g2) or poly_edge_dist(p, g2) < r + CLR + 0.25): ok = False
if k2 == 'box' and box_dist(p, g2) < r + CLR + 0.25 and not (p[0] < g2[0] - 5 or p[0] > g2[2] + 5): ok = False
return ok
def clear(p, net, r, drill, planned):
for s in live['segments']:
if s['net_name'] != net and s['layer'] in ('F.Cu', 'B.Cu', 'In1.Cu', 'In2.Cu') and seg_dist(p, s['start'], s['end']) < r + s['width'] / 2 + CLR: return False
for v in live['vias']:
q = (v['position']['x'], v['position']['y'])
if math.dist(p, q) < drill / 2 + v['drill'] / 2 + HH: return False
if v['net_name'] != net and math.dist(p, q) < r + v['size'] / 2 + CLR: return False
for f in board['footprints']:
for pd in f['pads']:
if pd['drill'] > 0 and math.dist(p, (pd['x'], pd['y'])) < drill / 2 + pd['drill'] / 2 + HH: return False
if pd['net'] != net and pd['faces'] and box_dist(p, pd['box']) < r + CLR: return False
if pd['net'] == net and pd['faces'] and box_dist(p, pd['box']) < 0.05 and f['ref'] not in ('U1',): return False # keep off same-net pads too (paste/mask)
for q in planned:
if math.dist(p, q) < 0.75: return False
return True
def plan_part(ref, net, centre, target, size, drill, radius, fixed=None):
r = size / 2; chosen = []
cands = fixed or [(centre[0] + i * 0.25, centre[1] + j * 0.25) for i in range(-int(radius * 4), int(radius * 4) + 1) for j in range(-int(radius * 4), int(radius * 4) + 1)]
cands = [c for c in cands if math.dist(c, centre) <= radius]
cands.sort(key=lambda c: math.dist(c, centre))
for c in cands:
if len(chosen) >= target: break
c = (round(c[0], 3), round(c[1], 3))
if not (in_region(c, net, 'F.Cu', r) and in_region(c, net, 'B.Cu', r)): continue
if not clear(c, net, r, drill, chosen + [q for q in ALL]): continue
chosen.append(c)
ALL.extend(chosen)
print(f'{ref:4} {net:6} target {target} planned {len(chosen)} {chosen}')
return [{"net": net, "paths": [[[x, y], {"layer": "B.Cu", "x": x, "y": y}]], "viaDrill": drill, "viaSize": size, "why": f"heatsink via for {ref}", "width": size} for x, y in chosen]
ALL = []
entries = []
tv = spec['thermalVias']
# U1: 3x3 field inside the 3.4 mm exposed pad (shifted 0.6 mm right: an I2C1_SCL B.Cu track runs at x=145.8 under the pad)
entries += plan_part('U1', 'GND', (147.4, 136.0), tv['U1']['count'], tv['U1']['size'], tv['U1']['drill'], 2.0, fixed=[(146.6 + i * 0.8, 135.0 + j) for j in range(3) for i in range(3)])
entries += plan_part('U5', 'GND', (111.05, 112.74), tv['U5']['count'], 0.6, 0.3, 4.0)
entries += plan_part('U6', 'GND', (111.05, 126.24), tv['U6']['count'], 0.6, 0.3, 4.0)
entries += plan_part('R1', 'VBAT', (123.56, 148.2), tv['R1']['count'], tv['R1']['size'], tv['R1']['drill'], 3.0)
entries += plan_part('Q1', 'VSYS', (127.34, 139.0), 6, 0.6, 0.3, 3.0)
plan = {"engine": "gen/tvias.py (AI heatsink-via plan checked against the live board copper, revision " + live['revision'][:12] + ")", "nets": entries}
json.dump(plan, open(SCR + '/AFS_Adom/aiflow/run/plan-thermal-vias.json', 'w'), indent=1)
print('wrote', len(entries), 'vias')