OverviewFilesCommitsIssuesDocsReleasesPeers

electrical-review / scripts / 10_compare_inverter_paths.py

145 lines · python ref:
"""10 — Inverter-path comparison (script-computed numbers for the design tradeoff).

Path A: 2x EG4 6000XP in parallel, 28 panels = 4x7S (16.1 kW DC), 3 panels held as spares.
Path B: 1x EG4 18kPV, 31 panels = 8+8+8+7S (17.8 kW DC).  [the chosen design]

Both target 12 kW AC. Computes — for each path — production (PVWatts v8, same site/tilt/az),
coverage vs the real Hardwick load, PV-usable headroom, AC, and battery discharge limit/headroom.
String cold-Voc is pulled from the already-validated results (01 = 7S/430V, 01_phase2 = 8S/491.5V).
Cost is a LABELED estimate (Swarm §10), not computed.

Run:  ~/.venvs/prsm-electrical/bin/python scripts/10_compare_inverter_paths.py
"""
import os
import requests
from common import load_site, require, banner, result, dump_result, load_result

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):
    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)}
    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():
    base = load_site()                 # base inverter: = EG4 6000XP
    p2 = load_site(phase2=True)         # phase2.inverter = EG4 18kPV
    load = load_result("08_load_actual")
    pmp = require(base, "panel.pmp_w")
    vnom = require(base, "battery.nominal_v")
    npack = int(require(p2, "battery.pack_count"))           # 2 either way
    ac_w = 12000.0                                            # both target 12 kW AC
    yr_load = load["last12_kwh_yr"]; seas_load = load["seasonal_daily_kwh"]
    aug = load["august_peak_kwh_day"]; avg = load["daily_avg_kwh"]

    banner("10  INVERTER PATH COMPARISON  (Path A 2x6000XP/28 vs Path B 18kPV/31)")

    # ---- static specs from site.yaml (both inverters already confirmed) ----
    A = {"name": "A: 2x EG4 6000XP", "panels": 28, "strings": 4, "series": "4x7S",
         "kw": round(28 * pmp / 1000, 2),
         "pv_usable_w": 2 * require(base, "inverter.pv_max_power_w"),
         "ac_w": 2 * require(base, "inverter.ac_continuous_w"),
         "batt_lim_a": 2 * require(base, "inverter.batt_max_discharge_a"),
         "eff": require(base, "inverter.discharge_eff"),
         "string_voc": load_result("01_string")["string_cold_voc_V"],
         "voc_ceiling": require(base, "inverter.pv_design_voc_ceiling_v"),
         "mppts": 2 * require(base, "inverter.mppt_count"), "units": 2,
         "comms_links": 2, "spares": 3}
    B = {"name": "B: 1x EG4 18kPV", "panels": 31, "strings": 4, "series": "8+8+8+7S",
         "kw": round(31 * pmp / 1000, 2),
         "pv_usable_w": require(p2, "inverter.pv_max_power_w"),
         "ac_w": require(p2, "inverter.ac_continuous_w"),
         "batt_lim_a": require(p2, "inverter.batt_max_discharge_a"),
         "eff": require(p2, "inverter.discharge_eff"),
         "string_voc": load_result("01_string_phase2")["string_cold_voc_V"],
         "voc_ceiling": require(p2, "inverter.pv_design_voc_ceiling_v"),
         "mppts": require(p2, "inverter.mppt_count"), "units": 1,
         "comms_links": 1, "spares": 0}

    for P in (A, B):
        out, host = pvwatts(P["kw"], P["kw"] / 12.0, base)
        if out is None:
            raise SystemExit("PVWatts unavailable; set NREL_API_KEY and retry.")
        m = out["ac_monthly"]
        P["annual"] = round(out["ac_annual"])
        P["dec"], P["winter"], P["summer"] = (round(x, 1) for x in season(m))
        P["pct_load"] = round(100 * P["annual"] / yr_load)
        # solar-only seasonal coverage (x0.85 usable), same convention as 08/09
        P["cov_winter"] = round(100 * 0.85 * P["winter"] / seas_load["winter"])
        P["cov_summer"] = round(100 * 0.85 * P["summer"] / seas_load["summer"])
        P["cov_aug"] = round(100 * 0.85 * P["summer"] / aug)
        P["host"] = host.split("//")[1]
        # PV-usable headroom (array DC vs inverter usable PV)
        P["pv_headroom_w"] = P["pv_usable_w"] - P["kw"] * 1000
        # battery discharge at 12 kW
        ib = ac_w / P["eff"] / vnom
        P["i_bank"] = round(ib, 1)
        P["i_pack"] = round(ib / npack, 1)
        P["batt_headroom_a"] = round(P["batt_lim_a"] - ib, 1)

    # ---- print comparison ----
    rows = [
        ("panels wired / spares", f"{A['panels']} / {A['spares']}", f"{B['panels']} / {B['spares']}"),
        ("array DC", f"{A['kw']} kW", f"{B['kw']} kW"),
        ("string config / cold Voc", f"{A['series']} / {A['string_voc']}V (≤{A['voc_ceiling']})",
         f"{B['series']} / {B['string_voc']}V (≤{B['voc_ceiling']})"),
        ("MPPTs / units / comms links", f"{A['mppts']} / {A['units']} / {A['comms_links']}",
         f"{B['mppts']} / {B['units']} / {B['comms_links']}"),
        ("PV usable / headroom", f"{A['pv_usable_w']}W / {A['pv_headroom_w']:+.0f}W",
         f"{B['pv_usable_w']}W / {B['pv_headroom_w']:+.0f}W"),
        ("AC continuous", f"{A['ac_w']:.0f}W", f"{B['ac_w']:.0f}W"),
        ("battery discharge limit", f"{A['batt_lim_a']:.0f}A (2x140)", f"{B['batt_lim_a']:.0f}A"),
        ("12kW bank / per-pack draw", f"{A['i_bank']}/{A['i_pack']}A", f"{B['i_bank']}/{B['i_pack']}A"),
        ("battery limit headroom", f"{A['batt_headroom_a']:+.0f}A", f"{B['batt_headroom_a']:+.0f}A"),
        ("annual production", f"{A['annual']} kWh", f"{B['annual']} kWh"),
        ("  = % of load", f"{A['pct_load']}%", f"{B['pct_load']}%"),
        ("coverage winter/summer/Aug", f"{A['cov_winter']}/{A['cov_summer']}/{A['cov_aug']}%",
         f"{B['cov_winter']}/{B['cov_summer']}/{B['cov_aug']}%"),
        ("Dec kWh/day", f"{A['dec']}", f"{B['dec']}"),
    ]
    w = max(len(r[0]) for r in rows)
    print(f"\n  {'metric':<{w}}  {'PATH A (2x6000XP/28)':<26} {'PATH B (18kPV/31)':<22}")
    print("  " + "-" * (w + 50))
    for label, a, b in rows:
        print(f"  {label:<{w}}  {a:<26} {b:<22}")

    dprod = B["annual"] - A["annual"]
    print(f"\n  Production delta (B - A): +{dprod} kWh/yr ({round(100*dprod/A['annual'],1)}% more for B)")
    for P in (A, B):
        covs = [P["cov_winter"], P["cov_summer"], P["cov_aug"]]
        P["meets_all_seasons"] = all(c >= 100 for c in covs)
        short = [s for s, c in zip(("winter", "summer", "Aug"), covs) if c < 100]
        result(f"{P['name']} meets all seasons (solar x0.85)?",
               "YES" if P["meets_all_seasons"] else f"NO — short: {','.join(short)}",
               "", f"covers {P['cov_winter']}/{P['cov_summer']}/{P['cov_aug']}% W/S/Aug; "
                   f"battery+generator backfill the rest off-grid")
    result("cost delta (ESTIMATE)", "B ≈ +$1,900 vs A", "", "Swarm §10: 18kPV net swap vs 2nd 6000XP (~$1,700)")

    dump_result("10_compare", {"path_A": A, "path_B": B,
                               "production_delta_kwh": dprod,
                               "note": "production/loading computed (PVWatts + datasheet limits); cost is estimate"})


if __name__ == "__main__":
    main()