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
"""1920x1080 analysis drawings from the adom-fields solve (fields.json + tex/): current density per outer layer and the GND
plane, temperature rise, with the colour scale and the per-net verdicts. PIL only."""
import json, os, sys
from PIL import Image, ImageDraw, ImageFont
SCR = '/tmp/claude-1001/-home-adom-project/8819fb5f-c2c6-4524-9528-99428cd6b88e/scratchpad'
FD = SCR + '/AFS_Adom/aiflow/run/fields'
OUT = SCR + '/AFS_Adom/aiflow/run'
f = json.load(open(FD + '/fields.json'))
sc = f['scales']; bd = f['board']
try:
    FONT = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 22); SMALL = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 17); BIG = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 30)
except Exception:
    FONT = SMALL = BIG = ImageFont.load_default()


def colour_bar(tex, raw, w=360, h=22):
    """Colour scale sampled from the rendered texture against its raw values (the solver's own colormap)."""
    im = Image.open(FD + '/tex/' + tex).convert('RGBA'); rw = Image.open(FD + '/tex/' + raw)
    px = im.load(); rp = rw.load(); W, H = im.size
    samples = {}
    for y in range(0, H, 2):
        for x in range(0, W, 2):
            v = rp[x, y]
            if px[x, y][3] > 0 and v not in samples: samples[v] = px[x, y]
    if not samples: return Image.new('RGBA', (w, h), (80, 80, 80, 255)), 0, 1
    keys = sorted(samples); lo, hi = keys[0], keys[-1]
    bar = Image.new('RGBA', (w, h)); bp = bar.load()
    for x in range(w):
        v = lo + (hi - lo) * x / max(1, w - 1)
        k = min(keys, key=lambda kk: abs(kk - v)); c = samples[k]
        for y in range(h): bp[x, y] = c
    return bar, lo, hi


def panel(canvas, d, tex, raw, x, y, title, unit, vmax, scale=1.0):
    im = Image.open(FD + '/tex/' + tex).convert('RGBA')
    if scale != 1.0: im = im.resize((int(im.width * scale), int(im.height * scale)), Image.NEAREST)
    bg = Image.new('RGBA', im.size, (18, 18, 22, 255)); bg.alpha_composite(im); canvas.paste(bg, (x, y))
    d.rectangle([x - 1, y - 1, x + im.width, y + im.height], outline=(120, 120, 130))
    d.text((x, y - 28), title, fill=(235, 235, 240), font=FONT)
    bar, lo, hi = colour_bar(tex, raw)
    canvas.paste(bar, (x, y + im.height + 8))
    d.text((x, y + im.height + 32), f'0', fill=(200, 200, 210), font=SMALL)
    d.text((x + bar.width - 110, y + im.height + 32), f'{vmax:.3g} {unit}', fill=(200, 200, 210), font=SMALL)
    return im.height


def draw(kind):
    canvas = Image.new('RGBA', (1920, 1080), (10, 10, 14, 255)); d = ImageDraw.Draw(canvas)
    nets = f['nets'] if isinstance(f['nets'], list) else list(f['nets'].values())
    if kind == 'current':
        d.text((40, 24), f"AFS_Adom: current density on the copper (adom-fields solve, {bd['cellMm']} mm cells, {bd['copperUm']:.0f} um)", fill=(255, 255, 255), font=BIG)
        d.text((40, 64), f"peak {sc['jMax']:.0f} A/mm2 (threshold 60 A/mm2 = IPC-2221 external at 35 um for 10 C); every loaded net, source and sink pins from the spec; colour = |J| on that layer", fill=(190, 190, 200), font=SMALL)
        ph = 0
        for i, (tex, raw, title) in enumerate([('J-F_Cu.png', 'J-F_Cu.raw.png', 'F.Cu'), ('J-B_Cu.png', 'J-B_Cu.raw.png', 'B.Cu'), ('J-In1_Cu.png', 'J-In1_Cu.raw.png', 'In1.Cu (GND plane)'), ('J-In2_Cu.png', 'J-In2_Cu.raw.png', 'In2.Cu (+3V3 plane)')]):
            if os.path.exists(FD + '/tex/' + tex):
                ph = panel(canvas, d, tex, raw, 40 + i * 470, 130, title, 'A/mm2', sc['jMax'])
        y = 130 + ph + 70
        d.text((40, y), 'Loaded nets (amps, worst density, verdict):', fill=(255, 255, 255), font=FONT); y += 32
        for n in nets:
            name = n.get('net') or n.get('name'); amps = n.get('amps'); jmax = n.get('jMax') or n.get('peakJ') or n.get('maxJ')
            iss = [i for i in f['issues'] if i.get('net') == name and i['kind'].startswith('current')]
            verdict = ('; '.join(sorted({i['severity'] + ' ' + i['kind'] for i in iss})) or 'ok')
            d.text((40, y), f"{name:9} {amps if amps is not None else '-':>5} A   peak {jmax:.0f} A/mm2 on {n.get('peakLayer', '?')}   {verdict}", fill=(230, 230, 235) if verdict == 'ok' else (255, 200, 120), font=SMALL); y += 24
            if y > 1050: break
        out = OUT + '/analysis-current-density.png'
    else:
        d.text((40, 24), f"AFS_Adom: temperature rise over ambient (adom-fields; hot parts from spec.hot plus Joule heat)", fill=(255, 255, 255), font=BIG)
        d.text((40, 64), f"peak rise {sc['tMax']:.1f} C still air, {sc['tMovingMax']:.1f} C moving air; budget {f.get('hotMaxRiseC', 30)} C", fill=(190, 190, 200), font=SMALL)
        ph = 0
        for i, (tex, raw, title) in enumerate([('T-F_Cu.png', 'T-F_Cu.raw.png', 'rise, still air (F.Cu)'), ('Tm-F_Cu.png', 'Tm-F_Cu.raw.png', 'rise, moving air (F.Cu)'), ('q-F_Cu.png', 'q-F_Cu.raw.png', 'heat flow in the copper (F.Cu)')]):
            if os.path.exists(FD + '/tex/' + tex):
                ph = panel(canvas, d, tex, raw, 40 + i * 620, 130, title, 'C' if tex.startswith('T') else 'W/mm', sc['tMax'] if tex == 'T-F_Cu.png' else sc['tMovingMax'] if tex.startswith('Tm') else sc['qMax'], scale=1.3)
        y = 130 + ph + 70
        d.text((40, y), 'Hot parts (watts, vias, rise still / moving air):', fill=(255, 255, 255), font=FONT); y += 32
        for c in f['chips']:
            ok = c['riseC'] <= f.get('hotMaxRiseC', 30)
            d.text((40, y), f"{c['reference']:4} {c['tabNet']:6} {c['watts']:.2f} W  vias {c['vias']}  rise {c['riseC']:.1f} C / {c['riseMovingC']:.1f} C  {'ok' if ok else 'over the 30 C still-air budget'}", fill=(230, 230, 235) if ok else (255, 200, 120), font=SMALL); y += 24
        out = OUT + '/analysis-thermal.png'
    canvas.convert('RGB').save(out); print('wrote', out)


draw(sys.argv[1] if len(sys.argv) > 1 else 'current')