app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
source at 0.1.25: the whole crate workspace, the flow, the skills and the docs
f31cc25
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
"""Analysis passes for the finish line: current capacity and thermal, from the routed and poured board.
0.1 is deliberately conservative and says so in its hints: it judges the tracks and vias by
IPC-2221's external-layer curve and the pours by presence, connection style and via count, not
by a field solve. A PASS here means "no obvious under-sized conductor and every hot tab has
copper"; a FAIL names the net or the part and what to change. The 0.2 pass (cross-sections
through the filled polygons, IR drop per net) replaces the pour heuristics.
"""
import json
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from escboard import Board # noqa: E402
MIL2_PER_MM2 = 1550.0031
def ipc2221_amps(width_mm, thickness_um, rise_c, external=True):
"""IPC-2221 track current for a temperature rise: I = k * dT^0.44 * A^0.725 (A in mil^2)."""
k = 0.048 if external else 0.024
a_mil2 = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2
return k * (rise_c ** 0.44) * (a_mil2 ** 0.725)
def rise_for(width_mm, thickness_um, amps, external=True):
k = 0.048 if external else 0.024
a_mil2 = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2
if a_mil2 <= 0 or amps <= 0:
return 0.0
return (amps / (k * a_mil2 ** 0.725)) ** (1 / 0.44)
def plan_widths(plan):
"""Per net: the widths used, the via count and the number of entries, from a routing plan."""
out = {}
for e in plan.get("nets", []):
d = out.setdefault(e["net"], {"widths": set(), "vias": 0, "viaSize": e.get("viaSize"), "viaDrill": e.get("viaDrill"), "kelvin": 0})
d["widths"].add(float(e["width"]))
if e.get("kelvin"):
d["kelvin"] += 1
for path in e.get("paths", []):
d["vias"] += sum(1 for w in path if isinstance(w, dict) and "layer" in w and "x" in w)
return out
def analyze_current(plan, spec, zone_state=None):
"""spec.loads: {net: {"amps": continuous A, "peakAmps"?: A, "maxRiseC"?: C}}; spec.copperUm outer thickness (35)."""
loads = spec.get("loads", {})
t_um = float(spec.get("copperUm", 35))
max_rise_default = float(spec.get("maxRiseC", 10))
widths = plan_widths(plan)
pours = {}
for z in (zone_state or {}).get("zones", []):
if z.get("type") == "copper" and z.get("net"):
pours.setdefault(z["net"], []).append(z)
results = []
ok = True
for net, ld in loads.items():
amps = float(ld.get("amps", 0))
max_rise = float(ld.get("maxRiseC", max_rise_default))
w = widths.get(net)
r = {"net": net, "amps": amps, "maxRiseC": max_rise}
if w is None:
r.update({"status": "no-copper", "hint": "net %s carries %.1f A but the routing plan has no copper for it" % (net, amps)})
ok = False
results.append(r)
continue
signal_widths = sorted(x for x in w["widths"] if x > 0.2)
narrowest = min(signal_widths) if signal_widths else min(w["widths"])
widest = max(w["widths"])
rise_narrow = rise_for(narrowest, t_um, amps)
rise_wide = rise_for(widest, t_um, amps)
pour_layers = sorted({l for z in pours.get(net, []) for l in z.get("layers", [])})
pour_area = round(sum(f.get("areaMm2", 0) for z in pours.get(net, []) for f in z.get("fills", [])), 1)
vias = w["vias"]
via_amps = 1.5 if (w.get("viaDrill") or 0.3) >= 0.4 else 1.0
via_capacity = vias * via_amps
r.update({"narrowestTrackMm": narrowest, "widestTrackMm": widest, "riseAtNarrowestC": round(rise_narrow, 1), "riseAtWidestC": round(rise_wide, 1),
"vias": vias, "viaCapacityA": via_capacity, "pourLayers": pour_layers, "pourFilledMm2": pour_area})
# judgement: a track alone must not be the only path unless it is within the rise; a pour on both outer
# layers carries the load if it exists and the vias between layers can carry it
if rise_wide <= max_rise:
r["status"] = "pass"; r["how"] = "the widest track carries the load within %.0f C" % max_rise
elif pour_layers and pour_area > 0:
if len(pour_layers) >= 2 and via_capacity < amps:
r["status"] = "fail"; r["hint"] = "%s is carried by pours on %s but only %d vias (%.0f A) join the layers for %.1f A: add stitching vias (0.8/0.4 mm, about 1.5 A each)" % (net, "+".join(pour_layers), vias, via_capacity, amps)
ok = False
else:
r["status"] = "pass"; r["how"] = "pour on %s (%.0f mm2 filled) carries the load; tracks are %.2f mm at most" % ("+".join(pour_layers), pour_area, widest)
else:
r["status"] = "fail"
need = None
for wmm in (0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0):
if rise_for(wmm, t_um, amps) <= max_rise:
need = wmm; break
r["hint"] = "%s carries %.1f A on a %.2f mm track (%.0f C rise, limit %.0f C) and has no pour: pour it on the outer layers or widen to %s mm" % (net, amps, widest, rise_wide, max_rise, need or ">6")
ok = False
results.append(r)
return ok, results
def analyze_thermal(plan, spec, zone_state=None, board=None):
"""spec.hot: {ref: {"watts": W, "tabNet": net, "tabPad"?: name, "maxThetaCPerW"?: C/W}}. 0.1: copper presence and vias."""
hot = spec.get("hot", {})
widths = plan_widths(plan)
pours = {}
for z in (zone_state or {}).get("zones", []):
if z.get("type") == "copper" and z.get("net"):
pours.setdefault(z["net"], []).append(z)
results = []
ok = True
for ref, h in hot.items():
net = h.get("tabNet")
watts = float(h.get("watts", 0))
r = {"ref": ref, "watts": watts, "tabNet": net}
zs = pours.get(net, [])
area = round(sum(f.get("areaMm2", 0) for z in zs for f in z.get("fills", [])), 1)
layers = sorted({l for z in zs for l in z.get("layers", [])})
solid = any("solid" in (z.get("name") or "").lower() or z.get("connection") == "solid" for z in zs)
vias = widths.get(net, {}).get("vias", 0)
# one square inch of 35 um copper is roughly 40 C/W in still air; scale by area, floor at the package's own theta
theta = 40.0 / max(area / 645.16, 0.05)
limit = float(h.get("maxThetaCPerW", spec.get("maxThetaCPerW", 60)))
r.update({"pourLayers": layers, "pourFilledMm2": area, "viasOnNet": vias, "thetaEstimateCPerW": round(theta, 1), "limitCPerW": limit, "riseEstimateC": round(theta * watts, 1)})
if not zs:
r["status"] = "fail"; r["hint"] = "%s dissipates %.1f W into %s but %s has no pour: pour the tab net on the outer layers with a solid connection and thermal vias in the tab" % (ref, watts, net, net)
ok = False
elif theta > limit:
r["status"] = "fail"; r["hint"] = "%s: %.0f mm2 of copper on %s gives about %.0f C/W (limit %.0f): widen the pour or add the other outer layer with vias" % (ref, area, net, theta, limit)
ok = False
elif vias == 0 and len(layers) >= 2:
r["status"] = "fail"; r["hint"] = "%s: the %s pour is on two layers but no via joins them near the tab: add thermal vias in or beside the tab" % (ref, net)
ok = False
else:
r["status"] = "pass"; r["how"] = "%.0f mm2 of %s copper on %s, about %.0f C/W, %d vias" % (area, net, "+".join(layers), theta, vias)
results.append(r)
return ok, results
if __name__ == "__main__":
plan = json.load(open(sys.argv[1])); spec = json.load(open(sys.argv[2]))
zs = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else None
ok, res = analyze_current(plan, spec, zs)
print(json.dumps({"pass": ok, "results": res}, indent=1))