OverviewFilesCommitsIssuesDocsReleasesPeers

electrical-review / scripts / 11_pathA.py

242 lines · python ref:
"""11 — Path A design (the SELECTED build): 2x EG4 6000XP (parallel), 28 panels (4x7S), 2-pack bank.

Owner's choices: 2 packs, battery adjacent (~10 ft), panels co-located on the 200 ft trench. 28 of the
31-panel pallet are wired (4x7S, 16.1 kW); the 3 remaining panels are held as field SPARES.

String safety is the already-validated Phase-1 7S result (430.1 V <= 480 V), replicated x4 across
the four MPPTs (2 per 6000XP). This script computes the Path-A CONDUCTORS and BATTERY bank for the
2-inverter topology (the bank busbar feeds two inverter inputs, so no single 250 A cable), the
production (PVWatts), and the NEC checklist. Conductor sizing reuses the validated size_run() from 03.

Run:  ~/.venvs/prsm-electrical/bin/python scripts/11_pathA.py
"""
import importlib
import os
import requests
from common import load_site, require, get, banner, result, dump_result, load_result
from nec_tables import T310_16, std_ocpd_at_least, egc_awg

_c = importlib.import_module("03_conductors")     # reuse validated NEC sizing
size_run, report, vdrop_pct = _c.size_run, _c.report, _c.vdrop_pct

HOSTS = ["https://developer.nrel.gov", "https://developer.nlr.gov"]
DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]


def pvwatts(kw, dc_ac, cfg):
    """Path-A production (PVWatts v8, BOM bifacial; same model as 02/15)."""
    key = os.environ.get("NREL_API_KEY", "DEMO_KEY")
    params = {"api_key": key, "system_capacity": kw, "module_type": 0, "array_type": 0,
              "tilt": require(cfg, "array.tilt_deg"), "azimuth": require(cfg, "array.azimuth_deg"),
              "losses": 14, "lat": require(cfg, "site.latitude"),
              "lon": require(cfg, "site.longitude"), "dc_ac_ratio": round(dc_ac, 3),
              "bifaciality": require(cfg, "panel.bifaciality"),       # BOM bifacial 0.80
              "gcr": require(cfg, "phase2.array.gcr")}                # MULTI-ROW Phase-2 (flagged)
    for host in HOSTS:
        try:
            r = requests.get(host + "/api/pvwatts/v8.json", params=params, timeout=30)
            if r.status_code == 200 and "outputs" in r.json():
                return r.json()["outputs"], host
        except Exception:  # noqa
            pass
    return None, None


def season(m):
    dec = m[11] / DAYS[11]
    winter = (m[0] + m[11]) / (DAYS[0] + DAYS[11])
    summer = sum(m[i] / DAYS[i] for i in (5, 6, 7)) / 3
    return dec, winter, summer


def main():
    cfg = load_site()                              # base inverter: = EG4 6000XP (per-unit specs)
    banner("11  PATH A — 2x EG4 6000XP (parallel), 28 panels (4x7S), 2-pack bank")
    s01 = load_result("01_string")                 # validated Phase-1 7S string
    load = load_result("08_load_actual")

    pmp = require(cfg, "panel.pmp_w"); isc = require(cfg, "panel.isc_a")
    imp = require(cfg, "panel.imp_a"); vmp = require(cfg, "panel.vmp_v")
    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")

    # --- topology constants ---
    n_inv, npack = 2, 2
    n_str, n_series, panels = 4, 7, 28
    kw = round(panels * pmp / 1000, 2)
    spares = 31 - panels

    # per-unit 6000XP specs
    ac_unit = require(cfg, "inverter.ac_continuous_w")            # 6000
    ac_a_unit = require(cfg, "inverter.ac_cont_a_per_leg")        # 25
    surge_unit = require(cfg, "inverter.ac_surge_w")             # 12000 (3.5 s)
    eff = require(cfg, "inverter.discharge_eff")                 # 0.93
    inv_dis_unit = require(cfg, "inverter.batt_max_discharge_a")  # 140
    inv_chg_unit = require(cfg, "inverter.batt_max_charge_a")     # 125
    pv_unit = require(cfg, "inverter.pv_max_power_w")             # 8000
    i_in_max = require(cfg, "inverter.pv_max_input_a_per_mppt")   # 17
    isc_in_max = require(cfg, "inverter.pv_max_isc_a_per_mppt")   # 25
    voc_ceiling = require(cfg, "inverter.pv_design_voc_ceiling_v")  # 480
    bms_trip = require(cfg, "battery.bms_discharge_ocp_a")        # 150
    bms_delay = require(cfg, "battery.bms_ocp_delay_s")          # 300
    bms_sc = require(cfg, "battery.bms_short_circuit_a")         # 300
    cell_cont = require(cfg, "battery.cell_continuous_a")        # 157

    # aggregates
    ac_total = n_inv * ac_unit; ac_a_total = n_inv * ac_a_unit
    inv_dis_total = n_inv * inv_dis_unit; inv_chg_total = n_inv * inv_chg_unit
    pv_usable = n_inv * pv_unit; surge_total = n_inv * surge_unit
    runs = get(cfg, "phase2.runs_ft") or cfg["runs_ft"]
    tgt = cfg["targets"]
    pv_vref = round(n_series * vmp, 1)                            # 7S Vmp = 307 V

    result("config", f"{n_inv}x EG4 6000XP // {panels} panels ({n_str}x{n_series}S) // {npack} packs")
    result("array DC / spares held", f"{kw} kW / {spares} panels", "", "3 panels reserved as field spares")
    result("string cold Voc (validated 7S)", f"{s01['string_cold_voc_V']} V", "",
           f"<= {voc_ceiling} V design (PASS, {voc_ceiling - s01['string_cold_voc_V']:.0f} V margin); same module/ceiling as Phase 1")
    result("AC / PV-usable / batt limit", f"{ac_total:.0f}W / {pv_usable:.0f}W / {inv_dis_total:.0f}A",
           "", f"2x(6000W / 8000W / 140A); array {kw*1000:.0f}W DC vs {pv_usable:.0f}W usable")

    results = {"meta": {"path": "A", "inverters": n_inv, "panels": panels, "spares": spares,
                        "kw": kw, "strings": n_str, "n_series": n_series,
                        "string_cold_voc_V": s01["string_cold_voc_V"], "voc_ceiling_V": voc_ceiling,
                        "array_dc_W": panels * pmp, "pv_usable_W": pv_usable}}

    # ---------- conductors ----------
    print("\n  CONDUCTORS (NEC 690.8 / 310.15 / 240.4 / Ch.9 T8):")
    # 1) PV string homeruns (4 strings -> 8 CCC in the trench), worst-case 7S vref
    ccc_pv = 2 * n_str
    r_pv = size_run("PV string (array->6000XP MPPT, per string)", mode="pv",
                    current=imp, vref=pv_vref, length_ft=runs["combiner_to_inverter"],
                    target_pct=tgt["vdrop_dc_pct_max"], ambient_c=40, ccc=ccc_pv, isc=isc, set_ocpd=False)
    report(r_pv)
    sweep = {L: round(vdrop_pct(r_pv["final_gauge_AWG"], L, imp, pv_vref)[0], 3)
             for L in cfg.get("dc_run_sweep_ft", [200])}
    result("  DC Vdrop sweep", " / ".join(f"{L}ft:{p}%" for L, p in sweep.items()), "",
           f"{r_pv['final_gauge_AWG']}Cu, {ccc_pv} CCC; 1 string/MPPT -> NO source fuse (690.9(A))")
    r_pv["vdrop_sweep_pct"] = sweep; r_pv["ccc"] = ccc_pv; r_pv["source_fuse_required"] = False
    results["pv_string"] = r_pv
    print()

    # 2) AC: each 6000XP output (25 A) + the combined feed (50 A) at the parallel busbar
    r_ac_unit = size_run("AC per-unit (6000XP->AC combiner)", mode="continuous",
                         current=ac_a_unit, vref=240.0, length_ft=runs["inverter_to_loads"],
                         target_pct=tgt["vdrop_ac_pct_max"], ambient_c=30, ccc=2)
    report(r_ac_unit)
    r_ac_comb = size_run("AC combined (busbar->main panel)", mode="continuous",
                         current=ac_a_total, vref=240.0, length_ft=runs["inverter_to_loads"],
                         target_pct=tgt["vdrop_ac_pct_max"], ambient_c=30, ccc=2)
    report(r_ac_comb)
    results["ac_per_unit"] = r_ac_unit; results["ac_combined"] = r_ac_comb
    print()

    # 3) Battery bank: busbar fed by 2 packs, feeding 2 inverter inputs.
    i_bank = ac_total / eff / vnom                     # 12 kW from the bank
    i_pack = i_bank / npack                            # per pack
    i_inv = i_bank / n_inv                             # per inverter input
    r_pack = size_run("Battery per-pack (pack->busbar)", mode="continuous",
                      current=i_pack, vref=vnom, length_ft=runs["pack_to_busbar"],
                      target_pct=tgt["vdrop_battery_pct_max"], ambient_c=30, ccc=2)
    report(r_pack)
    r_inv = size_run("Battery per-inverter (busbar->6000XP)", mode="continuous",
                     current=i_inv, vref=vnom, length_ft=runs["inverter_to_battery"],
                     target_pct=tgt["vdrop_battery_pct_max"], ambient_c=30, ccc=2)
    report(r_inv)
    # NEC 690.8(B)/705/706.31: busbar + bank disconnect >= 125% of continuous bank current.
    busbar_min_A = i_bank * 1.25                                   # 252 A * 1.25 = 315 A
    busbar_std_A = std_ocpd_at_least(busbar_min_A)                 # next standard rating (350 A)
    result("bank busbar / disconnect", f">= {busbar_min_A:.0f} A -> {busbar_std_A} A rated", "",
           f"125% x {i_bank:.0f}A continuous (690.8(B)); 2 packs in ({i_pack:.0f}A ea) + 2 inverters out "
           f"({i_inv:.0f}A ea) — no single cable, but busbar/disconnect carry the full {i_bank:.0f}A bank")
    results["battery_per_pack"] = r_pack; results["battery_per_inverter"] = r_inv
    results["bank"] = {"i_bank_A": round(i_bank, 1), "i_pack_A": round(i_pack, 1),
                       "i_inv_A": round(i_inv, 1),
                       "busbar_min_A": round(busbar_min_A, 1), "busbar_A": busbar_std_A}
    print()

    # ---------- battery limits / C-rate / surge / autonomy / fusing ----------
    print("  BATTERY (limits, C-rate, surge, autonomy, Class-T):")
    result("bank vs total inverter limit", f"{i_bank:.1f} / {inv_dis_total}", "A",
           f"{'PASS' if i_bank <= inv_dis_total else 'FAIL'} (headroom {inv_dis_total - i_bank:.0f}A, {100*(inv_dis_total-i_bank)/inv_dis_total:.0f}%)")
    result("per-inverter vs 6000XP limit", f"{i_inv:.1f} / {inv_dis_unit}", "A",
           f"{'PASS' if i_inv <= inv_dis_unit else 'FAIL'} (headroom {inv_dis_unit - i_inv:.0f}A) — same as Phase-1 single pack")
    pack_limits = [("JK-BMS OCP (300s)", bms_trip), ("EVE cell 0.5P", cell_cont)]
    pb = min(pack_limits, key=lambda x: x[1])
    for nm, lim in pack_limits:
        result(f"per-pack vs {nm}", f"{i_pack:.1f} / {lim}", "A",
               f"{'PASS' if i_pack <= lim else 'FAIL'} ({100*i_pack/lim:.0f}%)")
    c_dis = i_pack / cap_ah; c_chg = (inv_chg_total / npack) / cap_ah
    result("per-pack C-rate dis / chg", f"{c_dis:.2f}C / {c_chg:.2f}C", "", "<= 0.5C OK")

    i_surge_bank = surge_total / eff / vnom; i_surge_pack = i_surge_bank / npack
    result("surge (2x12kW, 3.5s)", f"bank {i_surge_bank:.0f}A -> {i_surge_pack:.0f}A/pack", "",
           f"> {bms_trip}A OCP but 3.5s << {bms_delay}s delay & < {bms_sc}A SC -> transient, no trip")

    bank_kwh = usable_kwh * npack; bank_dod = bank_kwh * dod
    avg = load["daily_avg_kwh"]; peak = load["august_peak_kwh_day"]
    result("autonomy", f"{bank_dod:.1f} kWh usable -> {bank_dod/avg:.2f} d @avg / {bank_dod/peak:.2f} d @peak",
           "", f"{npack} packs @ 80% DoD; generator backfills deep autonomy")

    req_pack = i_pack * 1.25
    pca = r_pack["final_gauge_AWG"]; pamp = T310_16[pca][1]; pack_fuse = std_ocpd_at_least(req_pack)
    n_classT = npack + n_inv
    result("Class-T fusing", f"{n_classT}x {pack_fuse}A (2 packs + 2 inverter inputs)", "",
           f">= 125%x{i_pack:.0f}A; <= {pca} cable {pamp}A; each 6000XP battery input fused per its manual")

    results["limits"] = {"bank_limit_A": inv_dis_total, "bank_headroom_A": round(inv_dis_total - i_bank, 1),
                         "per_inv_limit_A": inv_dis_unit, "per_inv_headroom_A": round(inv_dis_unit - i_inv, 1),
                         "per_pack_binding": pb[0], "per_pack_binding_A": pb[1],
                         "per_pack_headroom_A": round(pb[1] - i_pack, 1),
                         "c_dis": round(c_dis, 3), "c_chg": round(c_chg, 3),
                         "surge_bank_A": round(i_surge_bank, 1), "surge_pack_A": round(i_surge_pack, 1),
                         "surge_duration_s": 3.5,
                         "bank_usable_kwh_80dod": round(bank_dod, 2),
                         "days_avg": round(bank_dod / avg, 2), "days_peak": round(bank_dod / peak, 2),
                         "classT_fuse_A": pack_fuse, "classT_count": n_classT}

    # ---------- production (PVWatts v8, BOM bifacial) ----------
    out, host = pvwatts(kw, kw / 12.0, cfg)
    if out is None:
        raise SystemExit("PVWatts unavailable; set NREL_API_KEY and retry.")
    yr_load = load["last12_kwh_yr"]; seas_load = load["seasonal_daily_kwh"]
    aug = load["august_peak_kwh_day"]
    m = out["ac_monthly"]; annual = round(out["ac_annual"])
    dec_p, winter_p, summer_p = season(m)
    pct_load = round(100 * annual / yr_load)
    cov_w = round(100 * 0.85 * winter_p / seas_load["winter"])
    cov_s = round(100 * 0.85 * summer_p / seas_load["summer"])
    cov_a = round(100 * 0.85 * summer_p / aug)
    meets = all(c >= 100 for c in (cov_w, cov_s, cov_a))
    result("production (PVWatts v8, BOM bifacial)", f"{annual} kWh/yr ({pct_load}% of load)", "",
           f"coverage W/S/Aug {cov_w}/{cov_s}/{cov_a}% (Aug short — battery+gen backfill)")
    results["production"] = {"annual_kwh": annual, "pct_load": pct_load,
                             "cov_winter": cov_w, "cov_summer": cov_s, "cov_aug": cov_a,
                             "meets_all_seasons": meets, "host": host.split("//")[1]}

    # ---------- NEC checklist (Path A final state) ----------
    checks = [
        ("690.7", "PASS", f"Max system voltage {s01['string_cold_voc_V']}V (7S) <= 480V design / 500V abs (validated x4 strings)."),
        ("690.8(A)", "PASS", f"Per string Isc x1.25 = {round(isc*1.25,1)}A; 1 string/MPPT -> Isc {isc}A <= 25A MPPT rating, Imp {imp}A <= 17A input."),
        ("690.9(A) Ex", "PASS", "NO PV source fusing: exactly 1 string per MPPT (no parallel strings) on either 6000XP."),
        ("690.9 / 710.15", "PASS", f"AC OCPD: per-unit {r_ac_unit['ocpd_A']}A 2-pole x2 (8AWG) at the parallel busbar; combined {r_ac_comb['ocpd_A']}A (4AWG) to main panel."),
        ("690.11/690.41(B)", "PASS", "Each 6000XP has integrated PV AFCI + GFDI/isolation (x2 units)."),
        ("690.12", "VERIFY-AHJ", "Each 6000XP supplies an RSD initiator if required; same ground-mount/AHJ question as Phase 1."),
        ("690.13/690.15", "PASS", "Each 6000XP integrated DC disconnect; bank + per-pack + per-inverter disconnects."),
        ("690.9/480.9/706.31", "PASS", f"{n_classT}x {pack_fuse}A Class-T (2 packs + 2 inverter inputs), 20kAIC, <= 2/0 cable 175A."),
        ("705", "N/A", "Off-grid. Two inverters PARALLELED (EG4 parallel kit), not utility-interactive; main panel subfed, main breaker locked off."),
        ("706.7", "PASS", f"2-pack bank on a busbar feeding 2 inverters; bank {i_bank:.0f}A split {i_pack:.0f}A/pack (17% under 150A BMS) and {i_inv:.0f}A/inverter (14A under 140A)."),
        ("706.x comms", "VERIFY", "ONE master BMS-CAN link (master pack -> master 6000XP); master shares battery data to slave 6000XP over the EG4 inverter parallel/comms kit; packs daisy-chain pack-to-pack via RS485. NOT two CAN-to-both-inverters links (bus-collision risk). Open-loop fallback viable."),
        ("Redundancy", "NOTE", "N+1: one 6000XP can keep ~half the system (8kW PV / 6kW AC) running if the other fails."),
        ("690.53/54/56", "ACTION", f"Labels: max system voltage {s01['string_cold_voc_V']}V, 2x inverter output ratings, disconnects, battery hazard."),
    ]
    print("\n  NEC CHECKLIST (Path A):")
    for sec, st, ev in checks:
        print(f"    {sec:<20} {st:<11} {ev}")
    results["nec_checks"] = [{"section": s, "status": st, "evidence": e} for s, st, e in checks]

    dump_result("11_pathA", results)


if __name__ == "__main__":
    main()