main
John Lauer source at 0.1.25: the whole crate workspace, the flow, the skills and the docs f31cc25
"""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))