OverviewFilesCommitsIssuesDocsReleasesPeers

electrical-review / scripts / 04_battery.py

109 lines · python ref:
"""04 — Battery discharge / C-rate / runtime + Class-T fuse (NEC 240.6 / 690.71 / 480).

Confirms that at full AC output the battery current is within EVERY limit (inverter,
JK-BMS trip, EVE cell-honest), checks the surge transient, computes C-rate + runtime,
and finalizes the Class-T fuse coordinated with the cable from 03.

Run:  ~/.venvs/prsm-electrical/bin/python scripts/04_battery.py
"""
from common import (load_site, require, get, banner, result, dump_result, load_result)
from nec_tables import std_ocpd_at_least, T310_16


def main():
    cfg = load_site()
    banner("04  BATTERY  (discharge current, C-rate, runtime, Class-T fuse)")
    cond = load_result("03_conductors")["battery"]

    vnom = require(cfg, "battery.nominal_v")
    cap_ah = require(cfg, "battery.capacity_ah")
    usable_kwh = require(cfg, "battery.usable_kwh")
    dod = require(cfg, "battery.dod_design")
    ac_w = require(cfg, "inverter.ac_continuous_w")
    surge_w = require(cfg, "inverter.ac_surge_w")
    eff = require(cfg, "inverter.discharge_eff")

    inv_dis = require(cfg, "inverter.batt_max_discharge_a")
    inv_chg = require(cfg, "inverter.batt_max_charge_a")
    bms_trip = require(cfg, "battery.bms_discharge_ocp_a")
    bms_trip_delay = require(cfg, "battery.bms_ocp_delay_s")
    bms_sc = require(cfg, "battery.bms_short_circuit_a")
    bms_market = require(cfg, "battery.bms_continuous_a")
    cell_cont = require(cfg, "battery.cell_continuous_a")

    # --- continuous discharge @ full AC ---
    i_dis = ac_w / eff / vnom
    result("inverter AC continuous", ac_w, "W")
    result("battery discharge current", round(i_dis, 1), "A", "ac_w/eff/Vnom @ 6kW")
    print("\n  Limit stack (continuous discharge must clear ALL):")
    limits = [("inverter max discharge", inv_dis),
              ("JK-BMS OCP trip (300s)", bms_trip),
              ("EVE cell-honest (0.5P)", cell_cont),
              ("JK-BMS marketed max", bms_market)]
    binding = min(limits, key=lambda x: x[1])
    for name, lim in limits:
        result(f"  vs {name}", f"{round(i_dis,1)} / {lim}", "A",
               f"{'PASS' if i_dis <= lim else 'FAIL'}  ({100*i_dis/lim:.0f}%)")
    result("binding limit", f"{binding[0]} = {binding[1]} A", "",
           f"headroom {binding[1]-i_dis:.0f} A ({100*(binding[1]-i_dis)/binding[1]:.0f}%)")

    # --- C-rate ---
    c_dis = i_dis / cap_ah
    c_chg_pv = inv_chg / cap_ah
    result("discharge C-rate", f"{c_dis:.2f}C", "", f"{round(i_dis,1)}A / {cap_ah}Ah (<=0.5C OK)")
    result("charge C-rate (PV max)", f"{c_chg_pv:.2f}C", "", f"{inv_chg}A / {cap_ah}Ah")

    # --- surge transient ---
    i_surge = surge_w / eff / vnom
    print("\n  Surge transient (inverter peak ~3.5 s):")
    result("  battery surge current", round(i_surge, 1), "A", f"{surge_w}W/eff/Vnom")
    result("  vs JK-BMS short-circuit", f"{round(i_surge,1)} / {bms_sc}", "A",
           f"{'under SC trip' if i_surge < bms_sc else 'OVER'} (3.5s << relevant)")
    result("  vs JK-BMS OCP trip", f"{round(i_surge,1)} > {bms_trip}", "A",
           f"but OCP delay {bms_trip_delay}s >> 3.5s -> no nuisance trip")
    surge_note = ("transient only; one pack cannot sustain >6kW continuously — "
                  "Phase-2 second pack relieves surge headroom")

    # --- runtime sanity ---
    print("\n  Runtime (sanity — winter off-grid still needs the generator):")
    rt_full = usable_kwh / (ac_w / 1000.0)
    rt_full_dod = usable_kwh * dod / (ac_w / 1000.0)
    rt_avg_dod = usable_kwh * dod / 1.5
    result("  @ full 6kW (100% / 80% DoD)", f"{rt_full:.2f} / {rt_full_dod:.2f}", "h")
    result("  @ 1.5kW avg load (80% DoD)", f"{rt_avg_dod:.1f}", "h")

    # --- Class-T fuse final ---
    print("\n  Class-T fuse selection (NEC 240.6 / 690.9 / 480.9):")
    req = i_dis * 1.25
    cable_awg = cond["final_gauge_AWG"]
    cable_amp = T310_16[cable_awg][1]
    fuse = std_ocpd_at_least(req)
    # fuse must be <= cable ampacity
    ok_cable = fuse <= cable_amp
    result("  required (125% x cont)", round(req, 1), "A", "240.6 next size up")
    result("  Class-T fuse", f"{fuse} A", "", "DC-rated, 20 kAIC")
    result("  cable coordination", f"{fuse}A <= {cable_awg} ampacity {cable_amp}A",
           "", "OK" if ok_cable else "UPSIZE CABLE")
    result("  fuse vs BMS SC trip", f"{fuse}A < {bms_sc}A SC / interrupt 20kAIC",
           "", "layered: BMS protects cells, fuse protects cable + fault current")

    verdict = i_dis <= binding[1] and ok_cable
    result("OVERALL battery verdict", "PASS" if verdict else "REVIEW")

    dump_result("04_battery", {
        "discharge_A_6kW": round(i_dis, 1),
        "limits_A": {n: l for n, l in limits},
        "binding_limit": {"name": binding[0], "A": binding[1],
                          "headroom_A": round(binding[1] - i_dis, 1)},
        "discharge_C": round(c_dis, 3), "charge_C_pv": round(c_chg_pv, 3),
        "surge_A": round(i_surge, 1), "surge_note": surge_note,
        "runtime_full_h": round(rt_full, 2), "runtime_full_80dod_h": round(rt_full_dod, 2),
        "runtime_1p5kw_80dod_h": round(rt_avg_dod, 1),
        "classT_fuse_A": fuse, "cable_AWG": cable_awg, "cable_ampacity_A": cable_amp,
        "fuse_cable_ok": ok_cable, "verdict": "PASS" if verdict else "REVIEW",
    })


if __name__ == "__main__":
    main()