"""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()