OverviewFilesCommitsIssuesDocsReleasesPeers

electrical-review / scripts / 03_conductors.py

191 lines · python ref:
"""03 — Conductor ampacity + voltage drop (NEC 690.8 / 310.15 / 240.4 / Ch.9 T8).

For each run: corrected continuous current -> required ampacity after 310.15 derates
-> smallest compliant copper gauge (honoring 240.4(D)) -> upsize if voltage drop
exceeds target -> OCPD (240.6) -> EGC (250.122). All NEC tables in nec_tables.py.

Run:  ~/.venvs/prsm-electrical/bin/python scripts/03_conductors.py
"""
from common import (load_site, require, get, banner, result, dump_result, load_result)
from nec_tables import (T310_16, T8_DCRES, fill_factor, temp_factor, AWG_ORDER,
                        std_ocpd_at_least, egc_awg, max_ocpd_240_4d)


def vdrop_pct(awg, length_ft, amps, vref, n_conductors=2):
    """Voltage drop %, NEC Ch.9 Table 8 DC resistance (75C). n_conductors=2 for a
    2-wire DC or 1-phase circuit (out and back)."""
    r = T8_DCRES[awg] / 1000.0  # ohm/ft
    drop = n_conductors * length_ft * amps * r
    return 100.0 * drop / vref, drop


def pick_gauge(required_term_a, required_derated_a, ambient_c, ccc, term_col="75",
               start="14"):
    """Smallest copper AWG whose 75C(term) ampacity >= required_term_a AND whose
    90C ampacity x temp x fill >= required_derated_a."""
    tf = temp_factor(ambient_c, "90")
    ff = fill_factor(ccc)
    for awg in AWG_ORDER[AWG_ORDER.index(start):]:
        amp_term = T310_16[awg][{"60": 0, "75": 1, "90": 2}[term_col]]
        amp_derated = T310_16[awg][2] * tf * ff
        if amp_term >= required_term_a - 1e-9 and amp_derated >= required_derated_a - 1e-9:
            return awg, amp_term, amp_derated, tf, ff
    raise SystemExit("no gauge in table satisfies ampacity; extend table")


def size_run(name, *, mode, current, vref, length_ft, target_pct, ambient_c, ccc,
             isc=None, term_col="75", set_ocpd=True):
    """mode 'pv' -> 690.8(A)(1)+(B); mode 'continuous' -> 125% continuous (210/215/480)."""
    if mode == "pv":
        max_current = isc * 1.25                     # 690.8(A)(1)
        req_term = max_current * 1.25                # 690.8(B)(1): 125% at terminal rating
        req_derated = max_current                    # 690.8(B)(2): after conditions of use
        load_for_vd = current                        # Imp for voltage drop
        ocpd_basis = max_current
    else:  # continuous
        req_term = current * 1.25                    # 215.2/210.19: 125% continuous
        req_derated = current * 1.25
        load_for_vd = current
        ocpd_basis = current * 1.25

    awg, amp_term, amp_derated, tf, ff = pick_gauge(req_term, req_derated, ambient_c, ccc, term_col)

    # upsize for voltage drop
    vd_awg = awg
    vd_pct, vd_v = vdrop_pct(vd_awg, length_ft, load_for_vd, vref)
    while vd_pct > target_pct and AWG_ORDER.index(vd_awg) < len(AWG_ORDER) - 1:
        vd_awg = AWG_ORDER[AWG_ORDER.index(vd_awg) + 1]
        vd_pct, vd_v = vdrop_pct(vd_awg, length_ft, load_for_vd, vref)
    final = vd_awg

    ocpd = std_ocpd_at_least(ocpd_basis) if set_ocpd else None
    # 240.4(D): OCPD must not exceed the conductor's small-conductor ceiling.
    # Upsize the conductor until it can be protected by the required OCPD.
    if ocpd is not None:
        while (max_ocpd_240_4d(final, T310_16[final][1]) < ocpd
               and AWG_ORDER.index(final) < len(AWG_ORDER) - 1):
            final = AWG_ORDER[AWG_ORDER.index(final) + 1]
        ceil = max_ocpd_240_4d(final, T310_16[final][1])
        ocpd_note = "OK" if ocpd <= ceil else f"OCPD {ocpd}>ceil {ceil}"
    else:
        ocpd_note = "n/a"

    # recompute voltage drop for the final (possibly upsized) gauge
    vd_pct, vd_v = vdrop_pct(final, length_ft, load_for_vd, vref)
    driver = ("ampacity" if final == awg
              else "voltage-drop" if final == vd_awg else "240.4(D) OCPD")
    egc = egc_awg(ocpd) if ocpd else None

    res = {
        "run": name, "mode": mode, "length_ft": length_ft, "vref_V": vref,
        "current_A": round(current, 2),
        "req_ampacity_term_A": round(req_term, 1),
        "req_ampacity_derated_A": round(req_derated, 1),
        "ambient_C": ambient_c, "ccc": ccc, "temp_factor90": tf, "fill_factor": ff,
        "ampacity_gauge": awg, "ampacity_75C_A": amp_term,
        "ampacity_derated90_A": round(amp_derated, 1),
        "final_gauge_AWG": final, "gauge_driver": driver,
        "final_ampacity_75C_A": T310_16[final][1],
        "final_ampacity_derated90_A": round(T310_16[final][2] * tf * ff, 1),
        "vdrop_pct": round(vd_pct, 3), "vdrop_V": round(vd_v, 3),
        "target_pct": target_pct,
        "ocpd_A": ocpd, "ocpd_note": ocpd_note, "egc_AWG": egc,
    }
    return res


def report(r):
    result(r["run"], f"{r['current_A']} A cont", "",
           f"{r['mode']} basis")
    result("  req ampacity (term/derated)",
           f"{r['req_ampacity_term_A']} / {r['req_ampacity_derated_A']}", "A",
           f"310.15(B) Tf={r['temp_factor90']} x 310.15(C) Ff={r['fill_factor']} @ {r['ambient_C']}C, {r['ccc']}CCC")
    result("  ampacity-min gauge", f"{r['ampacity_gauge']} ({r['ampacity_75C_A']}A@75C)")
    result("  FINAL gauge", f"{r['final_gauge_AWG']} Cu", "", f"driver: {r['gauge_driver']}")
    result("  voltage drop", f"{r['vdrop_pct']}%  ({r['vdrop_V']} V)", "",
           f"target <= {r['target_pct']}%  ({'PASS' if r['vdrop_pct']<=r['target_pct'] else 'FAIL'})  [Ch.9 T8]")
    if r["ocpd_A"]:
        result("  OCPD / EGC", f"{r['ocpd_A']} A  /  {r['egc_AWG']} AWG Cu",
               "", f"240.6 / 250.122 ({r['ocpd_note']})")


def main():
    cfg = load_site()
    banner("03  CONDUCTORS  (NEC 690.8 ampacity + 310.15 derates + voltage drop)")
    s01 = load_result("01_string")

    isc = require(cfg, "panel.isc_a")
    imp = require(cfg, "panel.imp_a")
    n_series = s01["n_series"]
    vmp_string = round(n_series * require(cfg, "panel.vmp_v"), 1)
    ac_w = require(cfg, "inverter.ac_continuous_w")
    ac_v = 240.0
    vnom_batt = require(cfg, "battery.nominal_v")
    eff = require(cfg, "inverter.discharge_eff")
    runs = cfg["runs_ft"]
    tgt = cfg["targets"]

    i_batt = ac_w / eff / vnom_batt
    i_ac = ac_w / ac_v
    result("PV Imp / Isc", f"{imp} / {isc}", "A")
    result("string Vmp (DC)", vmp_string, "V", f"{n_series}S")
    result("battery current @ 6kW", round(i_batt, 1), "A", "ac_w/eff/Vnom")
    result("AC output current", round(i_ac, 1), "A", "6kW/240V split-phase")
    print()

    results = {}

    # 1) PV string source/output, array -> inverter (one per MPPT). 2 strings in one
    #    trench conduit -> 4 CCC. Conservative ambient 40C for above-grade segments.
    dc_len = runs["combiner_to_inverter"]
    r_pv = size_run("PV string (array->inverter, per MPPT)", mode="pv",
                    current=imp, vref=vmp_string, length_ft=dc_len,
                    target_pct=tgt["vdrop_dc_pct_max"], ambient_c=40, ccc=4,
                    isc=isc, set_ocpd=False)
    report(r_pv)
    # DC voltage-drop sweep across owner's stated 150-250 ft range, chosen gauge
    sweep = {}
    for L in cfg.get("dc_run_sweep_ft", [dc_len]):
        p, v = vdrop_pct(r_pv["final_gauge_AWG"], L, imp, vmp_string)
        sweep[L] = round(p, 3)
    result("  DC Vdrop sweep 150/200/250 ft",
           " / ".join(f"{L}ft:{p}%" for L, p in sweep.items()), "",
           f"gauge {r_pv['final_gauge_AWG']} holds <= {tgt['vdrop_dc_pct_max']}%")
    r_pv["vdrop_sweep_pct"] = sweep
    results["pv_string"] = r_pv
    print()

    # 2) Battery -> inverter (high current, short). Indoor 30C, 2 CCC.
    r_batt = size_run("Battery <-> inverter", mode="continuous",
                      current=i_batt, vref=vnom_batt, length_ft=runs["inverter_to_battery"],
                      target_pct=tgt["vdrop_battery_pct_max"], ambient_c=30, ccc=2)
    report(r_batt)
    # recommended upsize for low loss / margin
    up = AWG_ORDER[AWG_ORDER.index(r_batt["final_gauge_AWG"]) + 2]
    up_pct, _ = vdrop_pct(up, runs["inverter_to_battery"], i_batt, vnom_batt)
    result("  recommended upsize", f"{up} Cu", "", f"Vdrop {round(up_pct,3)}% (margin + low loss)")
    r_batt["recommended_upsize_AWG"] = up
    results["battery"] = r_batt
    print()

    # 3) AC output -> subfed main panel. Indoor 30C, 2 CCC (L1/L2).
    r_ac = size_run("AC output (inverter->main panel)", mode="continuous",
                    current=i_ac, vref=ac_v, length_ft=runs["inverter_to_loads"],
                    target_pct=tgt["vdrop_ac_pct_max"], ambient_c=30, ccc=2)
    report(r_ac)
    results["ac_output"] = r_ac
    print()

    # 4) Battery Class-T fuse coordination (size in 04; here confirm cable >= fuse)
    batt_ocpd = r_batt["ocpd_A"]
    cable_amp = T310_16[r_batt["final_gauge_AWG"]][1]
    result("Battery Class-T fuse (prelim)", f"{batt_ocpd} A",
           "", f"<= cable {r_batt['final_gauge_AWG']} ampacity {cable_amp}A "
               f"({'OK' if batt_ocpd<=cable_amp else 'UPSIZE CABLE'}); final in 04_battery")

    dump_result("03_conductors", results)


if __name__ == "__main__":
    main()