OverviewFilesCommitsIssuesDocsReleasesPeers

electrical-review / scripts / 09_phase2.py

135 lines · python ref:
"""09 — Phase-2 sizing + cost: wire the REMAINING pallet panels to meet the load.

Phase-1 wired 14 of the 31-panel Waaree pallet (8.05 kW) and that already MAXES the
single EG4 6000XP PV input (8 kW usable/unit). To use the remaining 17 panels (9.78 kW
-> 17.8 kW full pallet) you must add INVERTER capacity; the panels themselves are already
paid for (sunk in the pallet) = $0 additional.

Computes (grounded): full-pallet production (PVWatts), coverage vs the real Hardwick load
(08), and battery autonomy. Cost figures are ESTIMATES (sourced: Swarm §10 / Key Decisions),
not computed — clearly labeled.

Run:  ~/.venvs/prsm-electrical/bin/python scripts/09_phase2.py   (uses NREL_API_KEY or DEMO_KEY)
"""
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]

# --- COST ESTIMATES (USD) — NOT computed; sourced as noted. Ranges (lo, hi). ---
INV_6000XP = 1549            # Key Decisions (retail)
PARALLEL_KIT = 150           # parallel comms/cabling kit (est)
DOCAN = (1758, 2000)         # Key Decisions base SKU (heating is propane/separate)
BOS_PH2 = (800, 1500)        # PV wire for 17 panels, DC disc, battery interconnects, AC upsize, lugs/MC4 (est)


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),
              "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 rng(lo, hi):
    return f"${lo:,.0f}-{hi:,.0f}"


def main():
    cfg = load_site()
    banner("09  PHASE-2 — use remaining pallet to meet the load (sizing + cost)")
    load = load_result("08_load_actual")
    pmp = require(cfg, "panel.pmp_w")

    n_total, n_ph1 = 31, int(require(cfg, "array.module_count"))
    n_remaining = n_total - n_ph1
    kw_ph1 = round(n_ph1 * pmp / 1000, 2)
    kw_full = round(n_total * pmp / 1000, 2)
    result("pallet / Phase-1 / remaining", f"{n_total} / {n_ph1} / {n_remaining}", "panels")
    result("array kW: Phase-1 -> full", f"{kw_ph1} -> {kw_full}", "kW DC")
    result("CONSTRAINT", "1× 6000XP PV input = 8 kW usable", "",
           "Phase-1 8.05 kW already maxes it -> Phase-2 needs more inverter")

    # --- production at full pallet (12 kW AC from expanded inverter) ---
    out, host = pvwatts(kw_full, kw_full / 12.0, cfg)
    if out is None:
        raise SystemExit("PVWatts unavailable; set NREL_API_KEY and retry.")
    m = out["ac_monthly"]
    ann = out["ac_annual"]
    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
    print(f"\n  FULL-PALLET PRODUCTION ({kw_full} kW, via {host.split('//')[1]}):")
    result("  annual", round(ann), "kWh/yr", f"vs load {load['last12_kwh_yr']} kWh/yr")
    result("  array = % of annual load", round(100 * ann / load["last12_kwh_yr"]), "%",
           "now EXCEEDS load (was 79% at Phase-1)")
    result("  Dec / winter avg", f"{dec:.1f} / {winter:.1f}", "kWh/day",
           f"vs winter load {load['seasonal_daily_kwh']['winter']}")
    result("  summer avg", round(summer, 1), "kWh/day",
           f"vs summer load {load['seasonal_daily_kwh']['summer']} / Aug {load['august_peak_kwh_day']}")

    # coverage vs load by season (solar-only, usage band 0.85)
    print("\n  COVERAGE vs load (full pallet, x0.85 usable):")
    seas = load["seasonal_daily_kwh"]
    for name, prod_d, ld in [("winter", winter, seas["winter"]),
                             ("summer", summer, seas["summer"]),
                             ("Aug peak", summer, load["august_peak_kwh_day"])]:
        cov = 100 * (0.85 * prod_d) / ld
        result(f"  {name}", f"{0.85*prod_d:.0f} usable vs {ld} load", "kWh/day",
               f"{cov:.0f}% ({'MET' if cov >= 100 else 'gen/short'})")

    # --- battery autonomy at the load ---
    usable_pack = round(require(cfg, "battery.usable_kwh") * require(cfg, "battery.dod_design"), 2)
    avg = load["daily_avg_kwh"]; peak = load["august_peak_kwh_day"]
    print(f"\n  BATTERY AUTONOMY ({usable_pack} kWh usable/pack @ 80% DoD):")
    print(f"  {'packs':>6}{'usable':>9}{'days@avg':>11}{'days@peak':>11}")
    for n in (1, 2, 3):
        u = usable_pack * n
        print(f"  {n:>6}{u:>7.1f}kWh{u/avg:>10.2f}{u/peak:>11.2f}")
    result("Swarm §6 battery target", "24-30 kWh usable", "", "= 2 packs (25.7 usable); 3 for ~1 day @ avg")

    # --- COST (estimates) ---
    print("\n  PHASE-2 ADDED COST (estimates; energy/autonomy above are computed):")
    result("  remaining 17 panels", "$0", "", "already paid (sunk in pallet)")
    par_inv = INV_6000XP + PARALLEL_KIT
    result("  inverter: parallel 2nd 6000XP", f"${par_inv:,}", "", "12 kW AC / 16 kW PV (Key Dec §76)")
    result("  battery: +1 pack (→2 total)", rng(*DOCAN), "", "25.7 kWh usable (Swarm §6 min)")
    result("  battery: +2 packs (→3 total)", rng(2*DOCAN[0], 2*DOCAN[1]), "", "38.6 kWh usable, ~1 day @ avg")
    result("  BoS (PV wire/disc/cables/AC upsize)", rng(*BOS_PH2))

    # bundle (Path A: 2nd 6000XP + 2 packs)
    lean_lo = par_inv + DOCAN[0] + BOS_PH2[0]
    lean_hi = par_inv + DOCAN[1] + BOS_PH2[1]
    print()
    result("  BUNDLE — Path A (parallel 6000XP + 1 pack)", rng(lean_lo, lean_hi), "",
           "2 packs, 12 kW AC; generator covers deep autonomy")

    dump_result("09_phase2", {
        "remaining_panels": n_remaining, "kw_full_pallet": kw_full,
        "bifaciality": require(cfg, "panel.bifaciality"), "gcr": require(cfg, "phase2.array.gcr"),
        "full_pallet_annual_kwh": round(ann), "array_pct_of_load": round(100 * ann / load["last12_kwh_yr"]),
        "dec_kwh_day": round(dec, 1), "winter_kwh_day": round(winter, 1), "summer_kwh_day": round(summer, 1),
        "panels_added_cost": 0,
        "inverter_parallel_6000xp": par_inv,
        "battery_plus1_pack": list(DOCAN), "battery_plus2_packs": [2*DOCAN[0], 2*DOCAN[1]],
        "bos": list(BOS_PH2),
        "bundle_pathA_usd": [lean_lo, lean_hi],
        "note": "panels $0 (owned); cost figures estimates (Swarm §10/Key Decisions); energy computed",
    })


if __name__ == "__main__":
    main()