OverviewFilesCommitsIssuesDocsReleasesPeers

electrical-review / scripts / 01_string_sizing.py

266 lines · python ref:
"""01 — PV string sizing. THE binding safety constraint (NEC 690.7).

Max modules-in-series so cold-weather Voc stays under the inverter's PV-voltage
ceiling, AND the operating MPP voltage stays inside the MPPT tracking window.

Three independent methods, must agree:
  (A) Analytic NEC 690.7 cold-Voc:  Voc(T) = Voc_stc * [1 + beta/100 * (Tmin - 25)]
      with Tmin = the lowest expected ambient (NSRDB extreme min if a key is
      available, else the documented NEK design low). This is the binding number.
  (B) vocmax / NSRDB — pulls the site-specific historical extreme-min air temp
      (NEC 690.7's "lowest expected ambient") to refine Tmin in (A).
  (C) pvlib De Soto single-diode — independent Voc(Tmin) cross-check, plus the
      operating Vmp(T) used for the MPPT-window check.

Run:  ~/.venvs/prsm-electrical/bin/python scripts/01_string_sizing.py
Optional: export NREL_API_KEY=...  (enables method B; verify host first)
"""
import math
import os
from common import load_site, require, get, banner, result, dump_result

DESIGN_HOT_CELL_C = 70.0   # conservative max open-rack cell temp for hot-Vmp >= MPPT min check
K_B = 1.380649e-23         # Boltzmann (J/K)
Q_E = 1.602176634e-19      # elementary charge (C)
DIODE_N = 1.1              # diode ideality for the bifacial Voc adder


def bifacial_voc_adder(cells_series, rear_frac, bifaciality, t_ref_C=25.0, n=DIODE_N):
    """Open-circuit voltage rise from rear-side photocurrent on a bifacial module:
    dVoc = Ns * n * (kT/q) * ln(1 + phi_eff), phi_eff = rear_frac * bifaciality.
    Evaluated at 25C (conservative-large adder). Adds to the cold-temp Voc."""
    vth = K_B * (t_ref_C + 273.15) / Q_E
    return cells_series * n * vth * math.log(1.0 + rear_frac * bifaciality)


def analytic_voc(voc_stc, beta_pct_per_C, t_cell_C):
    """NEC 690.7 linear temperature correction. beta in %/C (negative)."""
    return voc_stc * (1.0 + (beta_pct_per_C / 100.0) * (t_cell_C - 25.0))


def analytic_v(v_stc, tempco_pct_per_C, t_cell_C):
    return v_stc * (1.0 + (tempco_pct_per_C / 100.0) * (t_cell_C - 25.0))


def nsrdb_extreme_min(lat, lon):
    """Pull the site's NSRDB historical min air temperature (NEC 690.7 'lowest
    expected ambient'). Uses pvlib's PSM3 client directly (vocmax 1.0.4 is broken
    against pvlib 0.15 — its NSRDB pull is run separately in the pinned vocmax venv).
    Returns (tmin_C, source_str) or (None, reason)."""
    key = os.environ.get("NREL_API_KEY")
    email = os.environ.get("NREL_EMAIL")
    if not key:
        return None, "NREL_API_KEY not set (using documented design low)"
    if not email:
        return None, "NREL_EMAIL not set (NSRDB requires an email; using documented design low)"
    try:
        from pvlib import iotools
    except Exception as e:  # noqa
        return None, f"pvlib.iotools import failed: {e}"
    # pvlib renamed get_psm3 -> get_nsrdb_psm3; support both.
    fn = getattr(iotools, "get_nsrdb_psm3", None) or getattr(iotools, "get_psm3", None)
    if fn is None:
        return None, "no NSRDB PSM3 function in this pvlib"
    tmins = []
    for yr in ("2020", "2019", "2018"):  # multi-year for a robust extreme
        try:
            data, _ = fn(latitude=lat, longitude=lon, api_key=key, email=email,
                         names=yr, map_variables=True, leap_day=True)
            col = "temp_air" if "temp_air" in data else (
                "Temperature" if "Temperature" in data else None)
            if col:
                tmins.append(float(data[col].min()))
        except Exception as e:  # noqa
            if not tmins:
                return None, f"NSRDB fetch failed ({type(e).__name__}: {e})"
            break
    if not tmins:
        return None, "NSRDB returned no temperature data"
    return min(tmins), f"NSRDB PSM3 via pvlib ({len(tmins)} yr min)"


def pvlib_voc_vmp(cfg, t_cold_C):
    """Independent single-diode cross-check via the CEC 6-parameter model
    (fit_cec_sam, backed by the SAM solver in PySAM — more robust than fit_desoto).
    Returns dict or {'error':...}."""
    try:
        from pvlib.ivtools.sdm import fit_cec_sam
        from pvlib.pvsystem import calcparams_cec, singlediode
    except Exception as e:  # noqa
        return {"error": f"pvlib import failed: {e}"}
    voc = require(cfg, "panel.voc_v"); vmp = require(cfg, "panel.vmp_v")
    isc = require(cfg, "panel.isc_a"); imp = require(cfg, "panel.imp_a")
    a_sc = require(cfg, "panel.isc_tempco_pct_per_C") / 100.0 * isc      # A/C
    b_voc = require(cfg, "panel.voc_tempco_pct_per_C") / 100.0 * voc     # V/C
    g_pmp = require(cfg, "panel.pmax_tempco_pct_per_C")                  # %/C
    cells = int(require(cfg, "panel.cells"))
    try:
        I_L, I_o, R_s, R_sh, a_ref, Adj = fit_cec_sam(
            celltype="monoSi", v_mp=vmp, i_mp=imp, v_oc=voc, i_sc=isc,
            alpha_sc=a_sc, beta_voc=b_voc, gamma_pmp=g_pmp, cells_in_series=cells)

        def vov(temp_c, g=1000.0):
            p = calcparams_cec(g, temp_c, alpha_sc=a_sc, a_ref=a_ref, I_L_ref=I_L,
                               I_o_ref=I_o, R_sh_ref=R_sh, R_s=R_s, Adjust=Adj)
            sd = singlediode(*p)
            return float(sd["v_oc"]), float(sd["v_mp"])

        voc_cold, vmp_cold = vov(t_cold_C)
        _, vmp_hot = vov(DESIGN_HOT_CELL_C)
        return {"voc_cold_pvlib": voc_cold, "vmp_cold_pvlib": vmp_cold,
                "vmp_hot_pvlib": vmp_hot, "method": "pvlib CEC single-diode (fit_cec_sam)"}
    except Exception as e:  # noqa
        return {"error": f"fit/solve failed ({type(e).__name__}: {e})"}


def main():
    cfg = load_site()
    banner("01  PV STRING SIZING  (NEC 690.7 cold-Voc + MPPT window)")

    lat = require(cfg, "site.latitude"); lon = require(cfg, "site.longitude")
    voc = require(cfg, "panel.voc_v"); vmp = require(cfg, "panel.vmp_v")
    pmp = require(cfg, "panel.pmp_w"); imp = require(cfg, "panel.imp_a")
    isc = require(cfg, "panel.isc_a")
    b_voc = require(cfg, "panel.voc_tempco_pct_per_C")
    a_isc = require(cfg, "panel.isc_tempco_pct_per_C")
    g_pmp = require(cfg, "panel.pmax_tempco_pct_per_C")
    inv_abs = require(cfg, "inverter.pv_max_voc_v")          # absolute do-not-exceed (500 P1 / 600 P2)
    inv_design = require(cfg, "inverter.pv_design_voc_ceiling_v")  # design ceiling (480 P1 / 550 P2 fault)
    mppt_lo = require(cfg, "inverter.mppt_v_min")
    mppt_hi = require(cfg, "inverter.mppt_v_max")
    n_mppt = int(require(cfg, "inverter.mppt_count"))
    modules = int(require(cfg, "array.module_count"))
    t_low_doc = require(cfg, "site.design_low_temp_C")
    # EG4 6000XP per-MPPT scalars (one string per MPPT)
    p_mppt_max = require(cfg, "inverter.pv_max_power_w_per_mppt")
    p_mppt_os = require(cfg, "inverter.pv_rec_max_power_w") / 2.0
    i_in_max = require(cfg, "inverter.pv_max_input_a_per_mppt")
    isc_in_max = require(cfg, "inverter.pv_max_isc_a_per_mppt")

    result("site", f"{lat},{lon}")
    result("panel Voc / Vmp / Pmp", f"{voc} / {vmp} / {pmp}", "V,V,W")
    result("Voc tempco (beta)", b_voc, "%/C")
    result("inverter Voc ceiling", f"{inv_design} design / {inv_abs} absolute", "V")
    result("MPPT tracking window", f"{mppt_lo}-{mppt_hi}", "V")

    # --- Tmin: documented vs NSRDB; use the colder (conservative) ---------------
    t_nsrdb, src = nsrdb_extreme_min(lat, lon)
    if t_nsrdb is not None:
        t_min = min(t_low_doc, t_nsrdb)
        result("Tmin NSRDB", round(t_nsrdb, 1), "C", src)
    else:
        t_min = t_low_doc
        result("Tmin NSRDB", "unavailable", "", src)
    result("Tmin used (binding)", t_min, "C",
           "colder of documented/NSRDB; NEC 690.7 lowest expected ambient")

    # --- (A) analytic cold-Voc per module + Vmp temp behavior -------------------
    voc_cold = analytic_voc(voc, b_voc, t_min)
    beta_vmp_pct = g_pmp - a_isc            # Vmp tempco ~ Pmp tempco - Imp(~Isc) tempco
    vmp_cold = analytic_v(vmp, beta_vmp_pct, t_min)
    vmp_hot = analytic_v(vmp, beta_vmp_pct, DESIGN_HOT_CELL_C)
    result("Voc(module, Tmin) analytic", round(voc_cold, 2), "V", "690.7 linear (temp only)")
    result("derived Vmp tempco", round(beta_vmp_pct, 3), "%/C", "gamma(Pmp)-alpha(Isc)")

    # bifacial rear-gain raises open-circuit voltage — add to the cold Voc for the
    # binding ceiling check (per adversarial review; worst-case cold + bifacial).
    cells_s = int(get(cfg, "panel.cells_electrical_series", 72))
    rear_frac = float(get(cfg, "panel.bifacial_rear_fraction", 0.0))
    bif = float(get(cfg, "panel.bifaciality", 0.0))
    delta_bif = bifacial_voc_adder(cells_s, rear_frac, bif) if rear_frac else 0.0
    voc_cold_design = voc_cold + delta_bif
    result("bifacial Voc adder", f"+{delta_bif:.2f}", "V/module",
           f"rear {rear_frac} x bifaciality {bif}; Ns={cells_s}")
    result("Voc(module) cold+bifacial", round(voc_cold_design, 2), "V",
           "BINDING value for ceiling check")

    # --- (C) pvlib cross-check --------------------------------------------------
    pv = pvlib_voc_vmp(cfg, t_min)
    if "error" in pv:
        result("pvlib cross-check", "FAILED", "", pv["error"])
    else:
        result("Voc(module, Tmin) pvlib", round(pv["voc_cold_pvlib"], 2), "V", pv["method"])
        diff = pv["voc_cold_pvlib"] - voc_cold
        result("  analytic vs pvlib dVoc", f"{diff:+.2f}", "V",
               "agreement check (should be < ~2 V)")

    # --- string length sweep ----------------------------------------------------
    print("\n  Cold-Voc string check (binding safety constraint):")
    print(f"  {'N':>3} {'Voc_cold[V]':>12} {f'<={inv_design:.0f}?':>8} {f'<={inv_abs:.0f}?':>8} "
          f"{f'margin{inv_design:.0f}':>11}")
    max_ok = 0
    rows = []
    for n in range(4, 11):
        sv = n * voc_cold_design
        ok_design = sv <= inv_design
        ok_abs = sv <= inv_abs
        if ok_design:
            max_ok = n
        rows.append({"N": n, "voc_cold_string_V": round(sv, 1),
                     "le_design": ok_design, "le_abs": ok_abs,
                     "margin_to_design_V": round(inv_design - sv, 1)})
        print(f"  {n:>3} {sv:>12.1f} {str(ok_design):>8} {str(ok_abs):>8} "
              f"{inv_design - sv:>+11.1f}")
    result(f"MAX modules/series (<={inv_design:.0f}V)", max_ok, "", "NEC 690.7 binding result")

    # --- recommended design: fill the 2 MPPTs evenly ----------------------------
    per_mppt = modules // n_mppt
    n_series = per_mppt          # one string per MPPT (EG4 'no combiner needed')
    string_voc_cold = n_series * voc_cold_design
    string_vmp_cold = n_series * vmp_cold
    string_vmp_hot = n_series * vmp_hot
    string_pmp = n_series * pmp

    print("\n  Recommended configuration:")
    result("topology", f"{n_series}S x 1P per MPPT, {n_mppt} MPPTs = {n_series*n_mppt} modules")
    result("string cold Voc", round(string_voc_cold, 1), "V",
           f"<= {inv_design} design  ({'PASS' if string_voc_cold<=inv_design else 'FAIL'})")
    result("string cold Voc vs 500 abs", f"{string_voc_cold:.1f}/{inv_abs}",
           "V", f"{(inv_abs-string_voc_cold):.0f} V margin")
    result("string cold Vmp (analytic)", round(string_vmp_cold, 1), "V",
           f"<= {mppt_hi} MPPT max ({'PASS' if string_vmp_cold<=mppt_hi else 'FAIL'})")
    if "error" not in pv:
        result("string cold Vmp (pvlib)", round(n_series*pv['vmp_cold_pvlib'], 1), "V",
               f"<= {mppt_hi} ({'PASS' if n_series*pv['vmp_cold_pvlib']<=mppt_hi else 'CHECK'})")
        result("string hot Vmp (pvlib)", round(n_series*pv['vmp_hot_pvlib'], 1), "V",
               f">= {mppt_lo} MPPT min ({'PASS' if n_series*pv['vmp_hot_pvlib']>=mppt_lo else 'FAIL'})")
    result("string hot Vmp (analytic)", round(string_vmp_hot, 1), "V",
           f">= {mppt_lo} ({'PASS' if string_vmp_hot>=mppt_lo else 'FAIL'})")

    # --- per-MPPT power / current -----------------------------------------------
    print("\n  Per-MPPT loading:")
    result("string power (DC)", round(string_pmp), "W",
           f"vs {p_mppt_max} usable / {p_mppt_os:.0f} oversize cap")
    result("string Imp", imp, "A", f"vs {i_in_max} max input ({'PASS' if imp<=i_in_max else 'FAIL'})")
    result("string Isc", isc, "A", f"vs {isc_in_max} Isc rating ({'PASS' if isc<=isc_in_max else 'FAIL'})")
    result("array DC total", round(string_pmp*n_mppt), "W")

    verdict = (string_voc_cold <= inv_design and string_vmp_hot >= mppt_lo
               and imp <= i_in_max and isc <= isc_in_max)
    result("OVERALL string verdict", "PASS" if verdict else "REVIEW")

    dump_result("01_string", {
        "tmin_C": t_min, "tmin_source": src if t_nsrdb is None else "NSRDB",
        "voc_cold_module_temp_V": round(voc_cold, 2),
        "bifacial_adder_V": round(delta_bif, 2),
        "voc_cold_module_V": round(voc_cold_design, 2),
        "voc_cold_module_pvlib_V": pv.get("voc_cold_pvlib"),
        "max_modules_series_le_480": max_ok,
        "n_series": n_series, "n_strings": n_mppt, "modules_total": n_series*n_mppt,
        "string_cold_voc_V": round(string_voc_cold, 1),
        "inv_design_ceiling_V": inv_design, "inv_abs_ceiling_V": inv_abs,
        "margin_to_480_V": round(inv_design - string_voc_cold, 1),
        "margin_to_500_V": round(inv_abs - string_voc_cold, 1),
        "string_cold_vmp_V": round(string_vmp_cold, 1),
        "string_hot_vmp_V": round(string_vmp_hot, 1),
        "mppt_window_V": [mppt_lo, mppt_hi],
        "string_power_W": round(string_pmp), "string_imp_A": imp, "string_isc_A": isc,
        "array_dc_W": round(string_pmp*n_mppt),
        "verdict": "PASS" if verdict else "REVIEW",
        "sweep": rows,
    })


if __name__ == "__main__":
    main()