From 3e07d9b807e5fbc0814756ce0cdc8ed3f527e81f Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Wed, 8 Jul 2026 15:16:04 -0600 Subject: [PATCH 1/3] fit_sandia_field, rename fit_sandia to fit_sandia_lab --- .../source/reference/pv_modeling/inverter.rst | 3 +- pvlib/inverter.py | 167 +++++++++++++++++- tests/test_inverter.py | 19 ++ 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/docs/sphinx/source/reference/pv_modeling/inverter.rst b/docs/sphinx/source/reference/pv_modeling/inverter.rst index e6e623f28b..2d2a69e7a8 100644 --- a/docs/sphinx/source/reference/pv_modeling/inverter.rst +++ b/docs/sphinx/source/reference/pv_modeling/inverter.rst @@ -19,4 +19,5 @@ Functions for fitting inverter models .. autosummary:: :toctree: ../generated/ - inverter.fit_sandia + inverter.fit_sandia_lab + inverter.fit_sandia_field diff --git a/pvlib/inverter.py b/pvlib/inverter.py index 622fb9b958..b5635e373c 100644 --- a/pvlib/inverter.py +++ b/pvlib/inverter.py @@ -13,6 +13,9 @@ import numpy as np import pandas as pd from numpy.polynomial.polynomial import polyfit # different than np.polyfit +from scipy.optimize import minimize +import statsmodels.api as sm +from pvlib._deprecation import deprecated def _sandia_eff(v_dc, p_dc, inverter): @@ -445,9 +448,10 @@ def pvwatts_multi(pdc, pdc0, eta_inv_nom=0.96, eta_inv_ref=0.9637): return pvwatts(sum(pdc), pdc0, eta_inv_nom, eta_inv_ref) -def fit_sandia(ac_power, dc_power, dc_voltage, dc_voltage_level, p_ac_0, p_nt): +def fit_sandia_lab(ac_power, dc_power, dc_voltage, dc_voltage_level, p_ac_0, + p_nt): r''' - Determine parameters for the Sandia inverter model. + Determine parameters for the Sandia inverter model from laboratory data. Parameters ---------- @@ -551,3 +555,162 @@ def extract_c(x_d, add): # prepare dict and return return {'Paco': p_ac_0, 'Pdco': p_dc0, 'Vdco': v_nom, 'Pso': p_s0, 'C0': c0, 'C1': c1, 'C2': c2, 'C3': c3, 'Pnt': p_nt} + + +fit_sandia = deprecated(since="0.15.3", name="fit_sandia", + alternative="fit_sandia_lab")(fit_sandia_lab) + + +def _fit_sandia_field_a(pac, pdc, vdc, pac0, vdc0): + r''' Estimate Pdco, Pso and C0 for the Sandia inverter model from + time-series data. Uses robust regression on data greater than 5% of rated + Pac and DC voltage near nominal voltage vdc0. + + Parameters + ---------- + pac : numeric + Measured AC power (W) + pdc : numeric + Measured DC input power (W) + vdc + Measured DC input voltage (V) + pac0 : float + Rated AC power (W) + vdc0 : float + Nominal DC input voltage (V) + + Returns + ------- + dict + Parameters Pdco, Pso and C0 for the Sandia inverter model. + ''' + # select data. Avoid very low power, clipping and DC voltage far from + # nominal + u = (pac > 0.05*pac0) & (pac < pac0) & (np.abs(vdc - vdc0)/vdc0 < 0.05) + Y = pac[u] + X = np.array([pdc[u]**2, pdc[u]]).T + X = sm.add_constant(X) + rlm_model = sm.RLM(Y, X) + rlm_results = rlm_model.fit() + rlmp = np.array(rlm_results.params) + + p = {} + p['C0'] = rlmp[1] + p['Pso'] = (-rlmp[2] + np.sqrt(rlmp[2]**2 - 4*p['C0']*rlmp[0])) / \ + (2 * p['C0']) + C = pac0 + rlmp[2]*p['Pso'] + p['C0']*p['Pso']**2 + p['Pdco'] = (-rlmp[2] + np.sqrt(rlmp[2]**2 + 4*p['C0']*C)) / (2. * p['C0']) + return p + + +def _f2(params, pac0, vdc0, pdc0, ps0, C0, vdc, pdc, pac): + # objective function for _fit_sandia_field. Assumes Pdco, Pso and C0 + # are known. Returns the root sum of squared differences in AC power + # Input params = [C1, C2, C3] + p = {} + p['Paco'] = pac0 # AC power + p['Vdco'] = vdc0 # DC V + p['Pdco'] = pdc0 # DC power + p['Pso'] = ps0 # DC power, small + p['C0'] = C0 # unitless, tiny + p['C1'] = params[0] # 1/V, tiny + p['C2'] = params[1] # 1/V, tiny + p['C3'] = params[2] # 1/V tiny + diff = (_sandia_eff(vdc, pdc, p) - pac) + return np.sqrt(np.dot(diff, diff)) + + +def _fit_sandia_field_b(resid, pac, pdc, vdc, pac0, vdc0, pdc0, ps0, C0): + r''' Estimate C1, C2, and C3 for the Sandia inverter model from time-series + data. Estimates are conditional on parameters Pdco, Pso and C0. + + Parameters + ---------- + pac : numeric + Measured AC power (W) + pdc : numeric + Measured DC input power (W) + vdc + Measured DC input voltage (V) + pac0 : float + Rated AC power (W) + vdc0 : float + Nominal DC input voltage (V) + pdc0 : float + DC input power that produces rated AC power at nominal voltage (W) + ps0 : float + Start-up DC power (W) + C0 : float + Empirical coefficient + + Returns + ------- + dict + Parameters for the Sandia inverter model including C1, C2 and C3. + ''' + # select data. Avoid very low power and clipping + u = (pac > 0.05*pac0) & (pac < pac0) + + # initial guess + x0 = np.array([0., 0., 0.]) + + args = (pac0, vdc0, pdc0, ps0, C0, vdc[u], pdc[u], pac[u]) + options = {} + options['gtol'] = 1e-5 + result = minimize(_f2, x0, args=args, + method='BFGS', + options=options) + + params = result.x + p = {} + p['Paco'] = pac0 + p['Vdco'] = vdc0 + p['Pdco'] = pdc0 + p['Pso'] = ps0 + p['C0'] = C0 + p['C1'] = params[0] + p['C2'] = params[1] + p['C3'] = params[2] + + return p + + +def fit_sandia_field(pac, pdc, vdc, pac0, vdc0): + r''' Estimate parameters for the Sandia inverter model from time-series + data. + + Parameters + ---------- + pac : numeric + Measured AC power (W) + pdc : numeric + Measured DC input power (W) + vdc + Measured DC input voltage (V) + pac0 : float + Rated AC power (W) + vdc0 : float + Nominal DC input voltage (V) + + Returns + ------- + dict + Parameters for the Sandia inverter model. + + References + ---------- + .. [1] C. W. Hansen, K. S. Anderson, M. Theristis, "Fitting the Sandia + Inverter Model to Operational PV System Data", 54 IEEE Photovoltaic + Specialist Conference, New Orleans, USA. 2026 + .. [2] D. King, S. Gonzalez, G. Galbraith, W. Boyson, "Performance Model + for Grid-Connected Photovoltaic Inverters", Sandia National + Laboratories, Albuquerque, N.M., USA, SAND2007-5036, Sept. 2007. + :doi:`10.2172/920449` + + ''' + # get Pdco, Pso and C0 first + p = _fit_sandia_field_a(pac, pdc, vdc, pac0, vdc0) + # add C1, C2, C3 + p = _fit_sandia_field_b(_f2, pac, pdc, vdc, pac0, vdc0, + p['Pdco'], p['Pso'], p['C0']) + return p diff --git a/tests/test_inverter.py b/tests/test_inverter.py index 5794669ec9..b3c716de7a 100644 --- a/tests/test_inverter.py +++ b/tests/test_inverter.py @@ -211,3 +211,22 @@ def test_fit_sandia(infilen, expected): dc_voltage_level=curves['dc_voltage_level'], p_ac_0=expected['Paco'], p_nt=expected['Pnt']) assert expected == pytest.approx(result, rel=1e-3) + + +def test_fit_sandia_field(): + pdc = np.arange(start=100., stop=1300., step=100.) + vdc = np.array([550., 600., 650, 550., 600., 650, 550., 600., 650, + 550., 600., 650]) + params = {'Paco': 1200, 'Pdco': 1300, 'Pso': 10, 'C0': 1e-6, 'C1': 1e-7, + 'C2': 1e-7, 'C3': 1e-7, 'Vdco': 600} + # pac was computed with pvlib.inverter._sandia_eff + pac = np.array([83.6134, 176.535, 269.476, 362.442, 455.422, 548.421, + 641.45, 734.489, 827.547, 920.638, 1013.74, 1106.85]) + p = inverter.fit_sandia_field(pac, pdc, vdc, params['Paco'], + params['Vdco']) + # Pdco, Pso, C0 should be within 1% + for k in ['Pdco', 'Pso', 'C0']: + assert np.isclose(p[k], params[k], rtol=1e-2) + # looser tolerance for C1, C2, C3, so test AC power + pred_ac = inverter._sandia_eff(vdc, pdc, p) + assert_allclose(pred_ac, pac, rtol=2e-5) From 1e55ffed5144280b8a625e00133138ff4d6d2454 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Wed, 8 Jul 2026 15:31:51 -0600 Subject: [PATCH 2/3] statsmodels decorator --- pvlib/inverter.py | 6 +++++- tests/test_inverter.py | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/pvlib/inverter.py b/pvlib/inverter.py index b5635e373c..e0e0710bff 100644 --- a/pvlib/inverter.py +++ b/pvlib/inverter.py @@ -14,7 +14,6 @@ import pandas as pd from numpy.polynomial.polynomial import polyfit # different than np.polyfit from scipy.optimize import minimize -import statsmodels.api as sm from pvlib._deprecation import deprecated @@ -584,6 +583,11 @@ def _fit_sandia_field_a(pac, pdc, vdc, pac0, vdc0): dict Parameters Pdco, Pso and C0 for the Sandia inverter model. ''' + try: + import statsmodels.api as sm + except ImportError: + raise ImportError( + 'Parameter fitting requires statsmodels') # select data. Avoid very low power, clipping and DC voltage far from # nominal u = (pac > 0.05*pac0) & (pac < pac0) & (np.abs(vdc - vdc0)/vdc0 < 0.05) diff --git a/tests/test_inverter.py b/tests/test_inverter.py index b3c716de7a..8499faf1dd 100644 --- a/tests/test_inverter.py +++ b/tests/test_inverter.py @@ -1,10 +1,11 @@ import numpy as np import pandas as pd - -from .conftest import assert_series_equal from numpy.testing import assert_allclose +from .conftest import assert_series_equal from .conftest import TESTS_DATA_DIR +from .conftest import requires_statsmodels + import pytest from pvlib import inverter @@ -202,17 +203,18 @@ def test_pvwatts_multi(): 'Pso': 10., 'C0': 1e-6, 'C1': 1e-4, 'C2': 1e-2, 'C3': 1e-3, 'Pnt': 1.}), ]) -def test_fit_sandia(infilen, expected): +def test_fit_sandia_lab(infilen, expected): curves = pd.read_csv(infilen) dc_power = curves['ac_power'] / curves['efficiency'] - result = inverter.fit_sandia(ac_power=curves['ac_power'], - dc_power=dc_power, - dc_voltage=curves['dc_voltage'], - dc_voltage_level=curves['dc_voltage_level'], - p_ac_0=expected['Paco'], p_nt=expected['Pnt']) + result = inverter.fit_sandia_lab( + ac_power=curves['ac_power'], dc_power=dc_power, + dc_voltage=curves['dc_voltage'], + dc_voltage_level=curves['dc_voltage_level'], + p_ac_0=expected['Paco'], p_nt=expected['Pnt']) assert expected == pytest.approx(result, rel=1e-3) +@requires_statsmodels def test_fit_sandia_field(): pdc = np.arange(start=100., stop=1300., step=100.) vdc = np.array([550., 600., 650, 550., 600., 650, 550., 600., 650, From 9e217ae6e7bf286442cee4b55f383e71d6144696 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Wed, 8 Jul 2026 15:40:29 -0600 Subject: [PATCH 3/3] hush flake8 --- pvlib/inverter.py | 10 +++++----- tests/test_inverter.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pvlib/inverter.py b/pvlib/inverter.py index e0e0710bff..1d0cad4daa 100644 --- a/pvlib/inverter.py +++ b/pvlib/inverter.py @@ -590,7 +590,7 @@ def _fit_sandia_field_a(pac, pdc, vdc, pac0, vdc0): 'Parameter fitting requires statsmodels') # select data. Avoid very low power, clipping and DC voltage far from # nominal - u = (pac > 0.05*pac0) & (pac < pac0) & (np.abs(vdc - vdc0)/vdc0 < 0.05) + u = (pac > 0.05*pac0) & (pac < pac0) & (np.abs(vdc - vdc0)/vdc0 < 0.05) Y = pac[u] X = np.array([pdc[u]**2, pdc[u]]).T X = sm.add_constant(X) @@ -616,10 +616,10 @@ def _f2(params, pac0, vdc0, pdc0, ps0, C0, vdc, pdc, pac): p['Vdco'] = vdc0 # DC V p['Pdco'] = pdc0 # DC power p['Pso'] = ps0 # DC power, small - p['C0'] = C0 # unitless, tiny - p['C1'] = params[0] # 1/V, tiny + p['C0'] = C0 # unitless, tiny + p['C1'] = params[0] # 1/V, tiny p['C2'] = params[1] # 1/V, tiny - p['C3'] = params[2] # 1/V tiny + p['C3'] = params[2] # 1/V tiny diff = (_sandia_eff(vdc, pdc, p) - pac) return np.sqrt(np.dot(diff, diff)) @@ -653,7 +653,7 @@ def _fit_sandia_field_b(resid, pac, pdc, vdc, pac0, vdc0, pdc0, ps0, C0): Parameters for the Sandia inverter model including C1, C2 and C3. ''' # select data. Avoid very low power and clipping - u = (pac > 0.05*pac0) & (pac < pac0) + u = (pac > 0.05*pac0) & (pac < pac0) # initial guess x0 = np.array([0., 0., 0.]) diff --git a/tests/test_inverter.py b/tests/test_inverter.py index 8499faf1dd..ef28c6f3b1 100644 --- a/tests/test_inverter.py +++ b/tests/test_inverter.py @@ -207,7 +207,7 @@ def test_fit_sandia_lab(infilen, expected): curves = pd.read_csv(infilen) dc_power = curves['ac_power'] / curves['efficiency'] result = inverter.fit_sandia_lab( - ac_power=curves['ac_power'], dc_power=dc_power, + ac_power=curves['ac_power'], dc_power=dc_power, dc_voltage=curves['dc_voltage'], dc_voltage_level=curves['dc_voltage_level'], p_ac_0=expected['Paco'], p_nt=expected['Pnt']) @@ -218,7 +218,7 @@ def test_fit_sandia_lab(infilen, expected): def test_fit_sandia_field(): pdc = np.arange(start=100., stop=1300., step=100.) vdc = np.array([550., 600., 650, 550., 600., 650, 550., 600., 650, - 550., 600., 650]) + 550., 600., 650]) params = {'Paco': 1200, 'Pdco': 1300, 'Pso': 10, 'C0': 1e-6, 'C1': 1e-7, 'C2': 1e-7, 'C3': 1e-7, 'Vdco': 600} # pac was computed with pvlib.inverter._sandia_eff