diff --git a/.github/workflows/test_pkg.yml b/.github/workflows/test_pkg.yml index 82b0179c..ff8fc605 100644 --- a/.github/workflows/test_pkg.yml +++ b/.github/workflows/test_pkg.yml @@ -89,6 +89,7 @@ jobs: pip install -r requirements.txt pip install -r tests/requirements.txt pip install nrel-pysam==$VER + conda install -c conda-forge ipopt - uses: actions/checkout@v3 - name: Checkout SAM @@ -142,6 +143,7 @@ jobs: pip install -r requirements.txt pip install -r tests/requirements.txt pip install nrel-pysam==$VER + conda install -c conda-forge ipopt - uses: actions/checkout@v3 - name: Checkout SAM diff --git a/.gitignore b/.gitignore index 71db5ce8..99891e9c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ files/defaults/ .mypy_cache *.zip +files/6parsolve_output \ No newline at end of file diff --git a/docs/helper-tools.rst b/docs/helper-tools.rst index 8e5126dd..e636bf28 100644 --- a/docs/helper-tools.rst +++ b/docs/helper-tools.rst @@ -1,54 +1,22 @@ Helper Functions (Tools) ========================= -These helper functions provide additional functionality for working with specific models. +PySAM includes several helper tools that provide additional functionality beyond the core simulation models. +These tools assist with data preparation, resource file handling, battery modeling, utility rate calculations, and photovoltaic module parameter fitting. -Resource Tools ---------------- +The tools covered in this section include: -Access resource tools with ``import PySAM.ResourceTools``. +- **Resource Tools**: Download and format solar and wind resource data from national databases +- **Battery Tools**: Utilities for battery system modeling and analysis +- **Load Tools**: Manipulate and analyze electrical load profiles +- **Utility Rate Tools**: Convert utility rate data from URDB to SAM format +- **CEC Module Parameter Solver**: Fit six-parameter single-diode model to photovoltaic module test data -These functions help with solar resource, wind resource and utility rate data, downloads and formatting. -See how to download solar resource data from the National Solar Radiation Database or wind resource data from the WindToolKit. -Use resource files directly in your PySAM models or input them as dictionaries. +.. toctree:: + :maxdepth: 1 -Please see an example of FetchResource: `FetchResourceFileExample.py `_ - -.. automodule:: files.ResourceTools - :members: - :undoc-members: - -Battery Tools ---------------- - -Access battery tools with ``import PySAM.BatteryTools``. - -.. automodule:: files.BatteryTools - :members: - :undoc-members: - -Load Tools ---------------- - -Access load tools with ``import PySAM.LoadTools``. - -These functions help manipulate load data for local analysis and the utility rate functions - -Please see an example of get_monthly_peaks: `LoadToolsExample.py `_ - -.. automodule:: files.LoadTools - :members: - :undoc-members: - -Utility Rate Tools ------------------- - -Access utility rate tools with ``import PySAM.UtilityRateTools``. - -These functions translate URDB data into the SAM format - -Please see an example of URDBv8_to_ElectricityRates `here `_ - -.. automodule:: files.UtilityRateTools - :members: - :undoc-members: + tools/ResourceTools + tools/BatteryTools + tools/LoadTools + tools/UtilityRateTools + tools/SixParSolve \ No newline at end of file diff --git a/docs/tools/BatteryTools.rst b/docs/tools/BatteryTools.rst new file mode 100644 index 00000000..acbd2f07 --- /dev/null +++ b/docs/tools/BatteryTools.rst @@ -0,0 +1,39 @@ +BatteryTools +============= + +Overview +-------- + +The BatteryTools module provides utilities for battery energy storage system modeling and analysis in PySAM. These tools help with battery sizing, dispatch strategy configuration, and performance analysis. + +Key features: + +- Battery sizing calculations +- Dispatch strategy helpers +- Battery lifetime and degradation analysis +- Utility functions for battery model configuration +- Integration with PySAM battery-enabled models (PVBattery, StandAloneBattery, etc.) + +BatteryTools is included with PySAM. Import it with: + +.. code-block:: python + + import PySAM.BatteryTools as battery + +Relevant Examples +------------------ + +1. **Battery Stateful Example** + + https://github.com/NatLabRockies/pysam/blob/main/Examples/PySAMWorkshop.ipynb + +2. **Battery Stateful with Custom Life Model Example** + + https://github.com/NatLabRockies/pysam/blob/main/Examples/BatteryStateful_CustomLifeModel.ipynb + +API Reference +------------- + +.. automodule:: files.BatteryTools + :members: + :undoc-members: \ No newline at end of file diff --git a/docs/tools/LoadTools.rst b/docs/tools/LoadTools.rst new file mode 100644 index 00000000..5fc6ad5b --- /dev/null +++ b/docs/tools/LoadTools.rst @@ -0,0 +1,35 @@ +LoadTools +========== + +Overview +-------- + +The LoadTools module provides utilities for manipulating and analyzing electrical load profile data. These tools help prepare load data for PySAM simulations, particularly for utility rate analysis and demand charge calculations. + +Key features: + +- Load profile manipulation and scaling +- Peak demand identification (monthly, annual) +- Time-of-use period assignment +- Load profile generation and synthesis +- Integration with utility rate structures + +LoadTools is included with PySAM. Import it with: + +.. code-block:: python + + import PySAM.LoadTools as load + +Relevant Examples +------------------ + +1. **LoadTools Example** + + https://github.com/NREL/pysam/blob/main/Examples/LoadToolsExample.py + +API Reference +------------- + +.. automodule:: files.LoadTools + :members: + :undoc-members: \ No newline at end of file diff --git a/docs/tools/ResourceTools.rst b/docs/tools/ResourceTools.rst new file mode 100644 index 00000000..ad4a3c83 --- /dev/null +++ b/docs/tools/ResourceTools.rst @@ -0,0 +1,76 @@ +ResourceTools +============== + +Overview +-------- + +The ResourceTools module provides utilities for downloading, formatting, and working with solar and wind resource data from national databases. These tools streamline the process of obtaining weather data needed for PySAM simulations. + +Key features: + +- Download solar resource data from the National Solar Radiation Database (NSRDB) +- Download wind resource data from the WIND Toolkit +- Format resource data for use in PySAM models +- Convert between resource file formats and Python dictionaries +- Fetch and parse utility rate data + +ResourceTools is included with PySAM. Import it with: + +.. code-block:: python + + import PySAM.ResourceTools as tools + +Data Sources +------------ + +National Solar Radiation Database (NSRDB) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The NSRDB provides solar and meteorological data for locations across the Americas. Data includes: + +- Global Horizontal Irradiance (GHI) +- Direct Normal Irradiance (DNI) +- Diffuse Horizontal Irradiance (DHI) +- Temperature, wind speed, and other meteorological variables +- Temporal resolution: 30-minute or hourly +- Spatial resolution: 4 km + +**Website:** https://nsrdb.nrel.gov/ + +WIND Toolkit +~~~~~~~~~~~~ + +The WIND Toolkit provides wind resource data for the continental United States. Data includes: + +- Wind speed at multiple heights +- Wind direction +- Temperature and pressure +- Temporal resolution: 5-minute +- Spatial resolution: 2 km + +**Website:** https://www.nrel.gov/grid/wind-toolkit.html + +API Key +------- + +To download resource data from NREL APIs, you need a free API key: + +1. Sign up at https://developer.nrel.gov/signup/ +2. You'll receive an API key via email +3. Use the key in the ``api_key`` parameter when fetching data + +**Rate Limits:** The API allows 1,000 requests per hour per API key. + +Relevant Examples +------------------ + +1. **FetchResourceFileExample.py** + + https://github.com/NREL/pysam/blob/main/Examples/FetchResourceFileExample.py + +API Reference +------------- + +.. automodule:: files.ResourceTools + :members: + :undoc-members: \ No newline at end of file diff --git a/docs/tools/SixParSolve.rst b/docs/tools/SixParSolve.rst new file mode 100644 index 00000000..70332e54 --- /dev/null +++ b/docs/tools/SixParSolve.rst @@ -0,0 +1,300 @@ +SixParSolve: CEC Module Parameter Solver +========================================== + +Overview +-------- + +The California Energy Commission (CEC) Performance Model uses the University of Wisconsin-Madison Solar Energy Laboratory's five-parameter single-diode model with a database of module parameters for modules from the database of eligible photovoltaic modules maintained by the California Energy Commission (CEC) for the California Solar Initiative. + +The five-parameter single-diode model calculates a module's current and voltage under a range of solar resource conditions (represented by an I-V curve) using an equivalent electrical circuit whose electrical properties can be determined from a set of five reference parameters. These five parameters, in turn, are determined from standard reference condition data provided by either the module manufacturer or an independent testing laboratory, such as the Arizona State University Photovoltaic Testing Laboratory. + +SAM's implementation extends the model to six parameters by adding an adjustment factor (``Adj``) for temperature coefficients, allowing for more accurate modeling of module performance across varying temperature conditions. +The model is described in DeSoto et al, 2006 "Improvement and validation of a model for photovoltaic array performance" and the implementation based on Dobos 2012 "An Improved Coefficient Calculator for the California Energy Commission 6 Parameter Photovoltaic Module Model". + +The SixParSolver.py script solves for six-parameter single-diode photovoltaic module model parameters using test data from the California Energy Commission (CEC) module database. +This method is based on Pyomo and IPOPT, and is an alternative to the SSC-based :doc:`/modules/SixParsolve` PySAM module. + +**Key Differences:** + +- **SixParSolve.py**: Python-based solver using Pyomo optimization framework with IPOPT. Suitable for solving a broader set of modules with configurable error tolerance. +- **PySAM.SixParsolve module**: SSC (System Simulation Core) wrapper that calls compiled C++ code. Faster for single module calculations but less flexible for finding solutions. + +The script solves for the following six parameters: + +1. **a** - Modified ideality factor (diode factor × thermal voltage) +2. **Il** - Light-generated current at reference conditions (A) +3. **Io** - Diode reverse saturation current at reference conditions (A) +4. **Rs** - Series resistance (Ω) +5. **Rsh** - Shunt resistance (Ω) +6. **Adj** - Adjustment factor for temperature coefficient (%) + +These parameters are determined by fitting the single-diode equivalent circuit model to standard test condition (STC) measurements: + +- Short-circuit current (I_sc) +- Open-circuit voltage (V_oc) +- Maximum power point current (I_mp) and voltage (V_mp) +- Temperature coefficients (alpha_sc, beta_oc, gamma_r) + +Parameter Fit Solutions +----------------------- + +The CEC module list will have some modules whose test data don't neatly fit into the 6-parameter module model. +The solver may still return a non-exact solution, but the curves will not fit well and should be visually examined. +In SAM, we'll still provide these approximate fits in the module library provided the error is not too large, +however the power production will be off. For these modules, the IPOPT solution may differ from the SSC solution. + +In a test with 21,598 modules, 10 had data errors. Of the remaining 21,588 modules, 21,577 were solved using PySAM.SixParSolve. +Of the 20,754 that were solved by both PySAM.SixParSolve and SSC.6parsolve, the ratio of the power production using the Python script vs SSC solution is shown in the below table: + +.. list-table:: Python vs SSC Solution Comparison + :header-rows: 1 + :widths: 30 30 30 + + * - Python/SSC Ratio + - Count + - Percent + * - [0.80, 0.85) + - 3 + - 0.01 + * - [0.85, 0.90) + - 1 + - 0.005 + * - [0.90, 0.95) + - 0 + - 0 + * - [0.95, 0.99) + - 28 + - 0.14 + * - [0.99, 1.00) + - 6,773 + - 432.63 + * - [1.00, 1.01) + - 13,499 + - 65.04 + * - [1.01, 1.05) + - 9 + - 0.04 + * - [1.05, 1.10) + - 418 + - 2.01 + * - [1.10, 1.15) + - 17 + - 0.08 + * - [1.15, 1.20) + - 4 + - 0.02 + * - ≥1.20 + - 1 + - 0.00 + * - **All** + - **20,754** + - **100.00** + +The majority of modules (97.68%) have a percent between 99% and 1.01%. + +Requirements +------------ + +Python Dependencies +~~~~~~~~~~~~~~~~~~~ + +:: + + pyomo >= 6.0 + pandas + numpy + matplotlib + openpyxl # For Excel file reading + +External Solver - IPOPT +~~~~~~~~~~~~~~~~~~~~~~~ + +**IPOPT** (Interior Point OPTimizer) is required for nonlinear optimization. There are two installation options: + +Option 1: Standard Build (conda-forge) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The conda-forge repository provides a standard build of IPOPT that works well for most use cases: + +.. code-block:: bash + + conda install -c conda-forge ipopt + +Option 2: High-Performance Build (Custom or IDAES) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For better performance, use an IPOPT build that uses custom linear solvers. The documentation for installing third party linear solvers can be found at the IPOPT website: https://coin-or.github.io/Ipopt/INSTALL.html + +The Institute for the Design of Advanced Energy Systems (IDAES) distributes optimized IPOPT binaries compiled with HSL linear solvers. + +**Download from:** https://github.com/IDAES/idaes-ext/releases + +Choose the appropriate file for your platform: + +- **Windows**: ``idaes-solvers-windows-x86_64.tar.gz`` +- **Linux**: ``idaes-solvers-ubuntu2004-x86_64.tar.gz`` (or other Linux variants) +- **macOS**: ``idaes-solvers-darwin-x86_64.tar.gz`` or ``idaes-solvers-darwin-aarch64.tar.gz`` (Apple Silicon) + +Installation Instructions +""""""""""""""""""""""""" + +**Windows:** + +1. Download and extract the appropriate ``.tar.gz`` file +2. Locate the ipopt binaries in the extracted files +3. Find your Python environment's ``Scripts`` directory: + + .. code-block:: + + python -c "import sys; import os; print(os.path.join(sys.prefix, 'Scripts'))" + +4. Copy ``ipopt.exe`` and any required DLL files to the ``Scripts`` directory +5. Verify installation: + + .. code-block:: + + ipopt -v + +**Unix (Linux/macOS):** + +1. Download and extract the appropriate ``.tar.gz`` file +2. Locate the ipopt binaries in the extracted files +3. Find your Python environment's ``bin`` directory: + + .. code-block:: bash + + python -c "import sys; import os; print(os.path.join(sys.prefix, 'bin'))" + +4. Or, to make IPOPT broadly available instead, create a symlink to the ``ipopt`` binary: + + .. code-block:: bash + + ln -s /path/to/extracted/ipopt $(python -c "import sys; import os; print(os.path.join(sys.prefix, 'bin'))")/ipopt + +5. Verify installation: + + .. code-block:: bash + + ipopt -v + +Usage +----- + +Basic Usage +~~~~~~~~~~~ + +.. code-block:: bash + + python SixParSolve.py path/to/PV_Module_List_Full_Data_ADA.xlsx + +The script expects an Excel file containing CEC module data downloaded from: +https://solarequipment.energy.ca.gov/Home/PVModuleList + +Output +~~~~~~ + +The script generates a CSV file: ``cec_modules_params_YYYY-MM-DD.csv`` + +Output includes: + +- All input test data columns +- Solved model parameters: ``a_py``, ``Il_py``, ``Io_py``, ``Rs_py``, ``Rsh_py``, ``Adj_py`` +- IV curve differences: ``d_Isc``, ``d_Imp``, ``d_Vmp``, ``d_Pmp`` (normalized errors) +- ``Error`` column indicating any solution failures + +Configuration +------------- + +There are various configuration parameters used in the script: + +.. code-block:: python + + plot_output_path=None # Set to a Path to enable IV curve plotting + run_parallel=True # Enable parallel processing + num_workers=8 # Parallel processing workers + il_scaling=1e8 # Scaling factor for Io parameter + rsh_scaling=1e-3 # Scaling factor for Rsh parameter + gamma_curve_dt=3 # Temperature interval for gamma fitting (K) + reduced_gamma_curve_dt=10 # An increased interval for a looser fit (K) + max_iter=3000 # Maximum IPOPT iterations + tolerance=1e-9 # Solver tolerance + infeasibility_threshold=0.5 # Maximum and sum normalized error threshold + +Solution Strategy +----------------- + +The script uses a three-pass solving approach to maximize success rate: + +Pass 1: Empirical Initial Guess +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Uses empirical relationships derived from previously-solved modules +- Initial guesses based on test data (V_oc, V_mp, I_sc, I_mp) + +Pass 2: Bootstrapping from Similar Modules +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- For unsolved modules, finds the closest solved module in parameter space +- Uses that solution as initial guess + +Pass 3: Reduced Temperature Sampling +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- For remaining unsolved modules +- Reduces temperature sampling interval to 10K (from 3K) +- Fewer constraints allow more modules to converge +- Slight reduction in accuracy for gamma_r fitting + +Optional: Approximate Solutions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``solve_approx()`` function can provide approximate solutions when exact convergence fails, using the ``solve_model_best_solution()`` approach. **Use with caution** - visual inspection of IV curves is recommended. + +Validation +---------- + +Solutions are validated using normalized errors between model predictions and test data: + +- ``d_Isc`` - Error in short-circuit current +- ``d_Imp`` - Error in maximum power point current +- ``d_Vmp`` - Error in maximum power point voltage +- ``d_Pmp`` - Error in maximum power + +Solutions with ``max(|errors|) > INFEASIBILITY_THRESHOLD`` or ``sum(|errors|) > INFEASIBILITY_THRESHOLD`` are marked as infeasible. + +Plotting IV Curves +------------------ + +This generates IV curve plots for each module showing: + +- Model-predicted curves at multiple irradiance and temperature conditions +- Reference points (I_sc, V_oc, maximum power point) as markers +- Comparison between initial guess and final solution + +Plots are saved to ``6parsolve_output/IV_curve_.png`` + +Troubleshooting +--------------- + +Solutions Not Converging +~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Check input data for NaN or invalid values +2. Verify V_oc > V_mp (physical constraint) +3. Adjust ``il_scaling`` and ``rsh_scaling`` if solution struggles +4. Try reducing ``gamma_curve_dt`` to 15 for difficult modules (less accurate gamma fitting) +5. Use bootstrapping approach with previously-solved modules +6. Consider relaxing ``infeasibility_threshold_max`` threshold for exploratory analysis + +Poorly-fit IV Curves +~~~~~~~~~~~~~~~~~~~~ + +Check test data quality from CEC database as quite a few modules may have inconsistent manufacturer data, +or data that doesn't neatly fit into the 6-parameter module model. + +API Reference +------------- + +.. automodule:: files.SixParSolve + :members: + :undoc-members: \ No newline at end of file diff --git a/docs/tools/UtilityRateTools.rst b/docs/tools/UtilityRateTools.rst new file mode 100644 index 00000000..389b1169 --- /dev/null +++ b/docs/tools/UtilityRateTools.rst @@ -0,0 +1,49 @@ +UtilityRateTools +================ + +Overview +-------- + +The UtilityRateTools module provides utilities for converting utility rate data from the Utility Rate Database (URDB) into the format required by SAM's utility rate models. These tools simplify the process of incorporating real-world utility rates into PySAM simulations. + +Key features: + +- Convert URDB v7 and v8 rate structures to SAM format +- Parse complex rate structures with multiple pricing tiers +- Handle time-of-use (TOU) rates and demand charges +- Support for net metering and export rates +- Simplify rate structure configuration for financial analysis + +UtilityRateTools is included with PySAM. Import it with: + +.. code-block:: python + + import PySAM.UtilityRateTools as urdb + +Utility Rate Database (URDB) +----------------------------- + +The Utility Rate Database (URDB) is a comprehensive database of utility rate structures maintained by NREL. It includes: + +- Over 6,000 utility rates +- Residential, commercial, and industrial rates +- Time-of-use schedules +- Demand charges +- Tiered energy rates +- Net metering policies + +**Access:** https://openei.org/wiki/Utility_Rate_Database + +Relevant Examples +------------------ + +1. **UtilityRatesExample** + + https://github.com/NREL/pysam/blob/main/Examples/UtilityRatesExample.py + +API Reference +------------- + +.. automodule:: files.UtilityRateTools + :members: + :undoc-members: diff --git a/files/SixParSolve.py b/files/SixParSolve.py new file mode 100644 index 00000000..07ae68d9 --- /dev/null +++ b/files/SixParSolve.py @@ -0,0 +1,1051 @@ +""" +CEC 6-Parameter PV Module Single-Diode Model Solver + +Solves for the six parameters (a, I_L, I_o, R_s, R_sh, Adjust) of the California +Energy Commission (CEC) photovoltaic module performance model using manufacturer +datasheet specifications as inputs. + +The model is based on the five-parameter single-diode equivalent circuit: + + I = I_L - I_o * (exp((V + I*R_s) / a) - 1) - (V + I*R_s) / R_sh + +where the five circuit parameters (a, I_L, I_o, R_s, R_sh) are determined at Standard +Test Conditions (STC: 1000 W/m^2, 25 C cell temperature, AM1.5G spectrum). A sixth +parameter "Adjust" scales the short-circuit and open-circuit temperature coefficients +(alpha_sc, beta_oc) to match the manufacturer's maximum power temperature coefficient +(gamma_Pmp). + +Temperature dependencies of the circuit parameters follow semiconductor physics, based on DeSoto: + - a(T) = a_ref * T / T_ref + - I_L(T) = I_L,ref + alpha_sc * (T - T_ref) + - I_o(T) = I_o,ref * (T/T_ref)^3 * exp(...) + - E_g(T) = E_g,ref * (1 - 0.0002677 * (T - T_ref)) + - R_s and R_sh are held constant at SRC values (Dobos simplification) + +The gamma_Pmp temperature coefficient is fitted by evaluating the maximum power point +over a range of temperatures (default: 10 C to 50 C at 3 C intervals) and averaging +the slope of P_max(T). + +References +---------- +[1] De Soto, W., Klein, S. A., and Beckman, W. A., 2006, "Improvement and Validation + of a Model for Photovoltaic Array Performance," Solar Energy, 80(1), pp. 78-88. + (Originally presented as: De Soto, W., 2004, "Improvement and Validation of a + Model for Photovoltaic Array Performance," M.S. thesis, University of + Wisconsin-Madison.) + +[2] Dobos, A. P., 2012, "An Improved Coefficient Calculator for the California + Energy Commission 6 Parameter Photovoltaic Module Model," Journal of Solar Energy + Engineering, 134(2), 021011. DOI: 10.1115/1.4005759 +""" + +from pathlib import Path +import sys +import pyomo.environ as pyo +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +from pyomo.util.infeasible import log_infeasible_constraints, log_infeasible_bounds +import logging +from datetime import datetime +import multiprocessing as mp + +NUM_OF_WORKERS = max(1, mp.cpu_count() - 2) +MAX_ITER = 3000 +INFEASIBILITY_THRESHOLD = 0.5 + +logging.getLogger('pyomo.core').setLevel(logging.ERROR) +solve_log = logging.getLogger('solve_log') +solve_log.setLevel(logging.INFO) +solve_log = logging.LoggerAdapter(solve_log, {"tag": solve_log}) + +IL_SCALING = 1e8 +RSH_SCALING = 1e-3 +test_data_cols = ['A_c', 'N_s', 'I_sc_ref', 'V_oc_ref', 'I_mp_ref', 'V_mp_ref', 'T_NOCT', + 'gamma_r', 'alpha_sc', 'beta_oc'] +model_param_cols = ["a_py", "Il_py", "Io_py", "Rs_py", "Rsh_py", "Adj_py"] +iv_diff_cols = ['d_Isc', 'd_Imp', 'd_Vmp', 'd_Pmp'] +solve_tracking_cols = ['solve_return_code', 'solve_pass'] + + +def current_at_voltage_cec(Vmodule, IL_ref, IO_ref, RS, A_ref, RSH_ref, I_mp_ref): + """ + Solve for current I at a given voltage V using Newton-Raphson on the single diode equation. + + Iteratively solves: + I = I_L - I_o * (exp((V + I*R_s) / a) - 1) - (V + I*R_s) / R_sh + + The Newton step uses the analytical derivative (Jacobian) of the implicit equation. + Used for plotting only, so numerical precision is only 1e-4. + Adapted from ssc/shared/6par_solve.h L 423. + + For better stability and precision, use pvlib.pvsystem.i_from_v(). + """ + F = 0 + Fprime = 0 + Iold = 0.0 + Inew = I_mp_ref + + it = 0 + maxit = 4000 + while (abs(Inew - Iold) > 1.0e-4 and it < maxit ): + Iold = Inew + + F = IL_ref - Iold - IO_ref * \ + (np.exp((Vmodule + Iold * RS) / A_ref) - 1.0) - \ + (Vmodule + Iold * RS) / RSH_ref + + Fprime = -1.0 - IO_ref * (RS / A_ref) * \ + np.exp((Vmodule + Iold * RS) / A_ref) - \ + (RS / RSH_ref) + + Inew = max(0.0, (Iold - (F / Fprime))) + + return Inew + +def cec_model_params_at_condition(model, Irr, T_cell_K): + """ + Compute temperature- and irradiance-adjusted single diode parameters. + + Applies the DeSoto/Dobos temperature and irradiance corrections to translate + the five STC circuit parameters to operating conditions: + - a(T) = a_ref * T / T_ref [Dobos eq.3] + - I_o(T) from semiconductor physics [Dobos eq.5] + - I_L(S,T) = S/S_ref * (I_L,ref + alpha_sc' * dT) [Dobos eq.4] + - R_sh(S) = R_sh,ref * S_ref / S [DeSoto Sec.3.5] + - R_s = R_s,ref (held constant) [Dobos simplification] + + Parameters + ---------- + model : pyo.ConcreteModel + Solved model with populated parameters. + Irr : float + Irradiance [W/m^2]. + T_cell_K : float + Cell temperature [K]. + + Returns + ------- + tuple : (IL_oper, IO_oper, Rs, A_oper, Rsh_oper) + """ + m = model.solver + Tc_ref = pyo.value(m.Tref) + a = pyo.value(m.par.a) + Io = pyo.value(m.par.Io) + eg0 = pyo.value(m.Egref) + Adj = pyo.value(m.par.Adj) + alpha = pyo.value(m.aIsc) + Il = pyo.value(m.par.Il) + Io = pyo.value(m.par.Io) + Rs = pyo.value(m.par.Rs) + Rsh = pyo.value(m.par.Rsh) + + I_ref = 1000 + muIsc = alpha * (1-Adj/100) + + A_oper = a * T_cell_K / Tc_ref + EG = eg0 * (1-0.0002677*(T_cell_K-Tc_ref)) + k = pyo.value(m.k) # Boltzmann constant eV/K + # instead of 1/KB, is 11600 in L129 + IO_oper = Io * np.power(T_cell_K/Tc_ref, 3) * np.exp(eg0 / (k*(Tc_ref)) - (EG / (k*(T_cell_K)))) + + Rsh_oper = Rsh*(I_ref/Irr) + IL_oper = Irr/I_ref *( Il + muIsc*(T_cell_K-Tc_ref) ) + if IL_oper < 0.0: + IL_oper = 0.0 + + return IL_oper, IO_oper, Rs, A_oper, Rsh_oper + + +def cec_model_ivcurve(model, Irr, T_cell_K, vmax, npts): + """ + Calculate the IV curve with voltage on x-axis and current on y-axis at the given irradiance and temperature + """ + I_mp_ref = pyo.value(model.solver.Imp) + IL_oper, IO_oper, Rs, A_oper, Rsh_oper = cec_model_params_at_condition(model, Irr, T_cell_K) + + y_I = [] + + V = np.linspace(0, vmax, npts) + for i, v in enumerate(V): + I = current_at_voltage_cec( v, IL_oper, IO_oper, Rs, A_oper, Rsh_oper, I_mp_ref ) + y_I.append(I) + return V, y_I + +def plot_iv_curve(model, linestyle='solid', label=None, plot_anchors=False): + """ + Plot IV curves for a range of conditions using a model from `create_model` with all the test and model parameters populated + + Model must have populated data from `test_data_cols` and `model_param_cols` + """ + curves = [ [ 1000, 2, 'black' ], + [ 1000, 25, 'red' ], + [ 1000, 47, 'orange' ], + [ 800, 0, 'blue' ], + [ 400, 0, 'green' ], + ] + + vmax = pyo.value(model.solver.Voc) + alpha_sc = pyo.value(model.solver.aIsc) + I_sc_ref = pyo.value(model.solver.Isc) + beta_oc = pyo.value(model.solver.bVoc) + V_oc_ref = pyo.value(model.solver.Voc) + gamma_r = pyo.value(model.solver.gPmp) + V_mp_ref = pyo.value(model.solver.Vmp) + I_mp_ref = pyo.value(model.solver.Imp) + + npts = 250 + for curve in curves: + Irr = curve[0] + Tc = curve[1] + + V_oc = beta_oc * (Tc - 25) + V_oc_ref + x_V, y_I = cec_model_ivcurve(model, Irr, Tc + 273.15, V_oc, npts) + plt.plot(x_V, y_I, label=f"{label} {Irr} W/m^2 {Tc} C", color=curve[2], linestyle=linestyle) + + if plot_anchors and Irr == 1000: + I_sc = alpha_sc * (Tc - 25) + I_sc_ref + plt.plot(0, I_sc, marker="o", markersize=10, alpha=0.5, color=curve[2]) + plt.plot(V_oc, 0, marker="v", markersize=10, alpha=0.5, color=curve[2]) + + P_mp = (gamma_r * (Tc - 25) * 1e-2 + 1) * (V_mp_ref * I_mp_ref) + mp_ind = np.argmax(x_V * y_I) + if Tc == 25: + I_mp = I_mp_ref + V_mp = V_mp_ref + else: + I_mp = y_I[mp_ind] + V_mp = P_mp / I_mp + plt.plot(V_mp, I_mp, marker="*", markersize=10, alpha=0.5, color=curve[2]) + + +def create_model(gamma_curve_dt=5): + """ + Create a Pyomo model for the CEC 6-parameter single-diode PV module model. + + Constructs a nonlinear system of equations (f0-f7) that determines the six circuit + parameters (a, I_L, I_o, R_s, R_sh, Adjust) from manufacturer datasheet values at + STC. The system consists of: + + f0-f3: Four constraints anchoring the I-V curve at STC key points + (short circuit, open circuit, max power point, dP/dV=0 at MPP). + [Dobos eqs.11-14+19; DeSoto eqs.4.7, 4.13-4.15] + f4: Open-circuit voltage temperature correction at T_ref + dT. + [Dobos eq.22] + f5/f6: Per-temperature MPP constraints (dP/dV=0 and I-V equation) with + temperature-adjusted a, I_o, I_L, E_g; R_s and R_sh held constant. + [Dobos eqs.23-24; DeSoto eqs.4.7, 4.13-4.15 with Ch.3-4 temp deps] + f7: Gamma (max power temperature coefficient) matching. + [Dobos eq.26] + + Parameters + ---------- + gamma_curve_dt : float, default 5 + Temperature step [K] for sampling the gamma fitting range (10 C to 50 C). + Smaller values give more temperature points (more f5/f6 constraints, better + gamma accuracy, harder to solve). Larger values (e.g. 10) reduce constraints + for easier convergence at the cost of gamma fitting precision. + + Returns + ------- + model : pyo.ConcreteModel + Pyomo model with all constraints defined. Call `set_parameters` to populate + datasheet inputs, then `set_initial_guess` and `solve_model` to solve. + """ + model = pyo.ConcreteModel() + model.solver = pyo.Block() + m = model.solver + + m.Vmp = pyo.Param(domain=pyo.NonNegativeReals, mutable=True) + m.Imp = pyo.Param(domain=pyo.NonNegativeReals, mutable=True) + m.Voc = pyo.Param(domain=pyo.NonNegativeReals, mutable=True) + m.Isc = pyo.Param(domain=pyo.NonNegativeReals, mutable=True) + m.aIsc = pyo.Param(domain=pyo.Reals, mutable=True, units=pyo.units.A/pyo.units.K) + m.bVoc = pyo.Param(domain=pyo.Reals, mutable=True, units=pyo.units.V/pyo.units.K) + m.gPmp = pyo.Param(domain=pyo.Reals, mutable=True, units=pyo.units.percent/pyo.units.K) + m.Egref = pyo.Param(domain=pyo.NonNegativeReals, mutable=True, initialize=1.121) + m.k = pyo.Param(domain=pyo.NonNegativeReals, mutable=True, initialize=8.617332478e-05, units=pyo.units.eV/pyo.units.K) + m.Tref = pyo.Param(domain=pyo.NonNegativeReals, mutable=True, units=pyo.units.K) + + # Six circuit parameters to solve for + m.par = pyo.Block() + m.par.a = pyo.Var(domain=pyo.NonNegativeReals, bounds=(0.05, 15), initialize=7.75) # Modified ideality factor [V], Dobos eq.2 + m.par.Il = pyo.Var(domain=pyo.NonNegativeReals, bounds=(0.01, 20), initialize=10.25) # Light current [A] + m.par.Io = pyo.Var(domain=pyo.NonNegativeReals, bounds=(1e-13, 1e-7), initialize=5e-8) # Diode reverse saturation current [A] + m.par.Rs = pyo.Var(domain=pyo.NonNegativeReals, bounds=(0.001, 75), initialize=32.5) # Series resistance [Ohm] + m.par.Rsh = pyo.Var(domain=pyo.NonNegativeReals, bounds=(1, 1e6), initialize=5e5) # Shunt resistance [Ohm] + m.par.Adj = pyo.Var(domain=pyo.Reals, bounds=(-100, 100), initialize=1) # Adjust parameter [%], Dobos eqs.8-9 + + # f0: Short-circuit condition (V=0, I=Isc). Single diode eq. evaluated at SC. + m.par.f0 = pyo.Constraint(expr=m.par.Il - m.par.Io*( pyo.exp( m.Isc*m.par.Rs / m.par.a ) - 1 ) - m.Isc*m.par.Rs/m.par.Rsh - m.Isc == 0) + # f1: Open-circuit condition (V=Voc, I=0). Single diode eq. evaluated at OC. + m.par.f1 = pyo.Constraint(expr=m.par.Io*( pyo.exp( m.Voc/m.par.a ) - 1 ) + m.Voc/m.par.Rsh -m.par.Il == 0) + # f2: Maximum power point condition (V=Vmp, I=Imp). Single diode eq. evaluated at MPP. + m.par.f2 = pyo.Constraint(expr=m.par.Il - m.par.Io*( pyo.exp( (m.Vmp + m.Imp*m.par.Rs) / m.par.a ) - 1 ) - (m.Vmp + m.Imp*m.par.Rs)/m.par.Rsh - m.Imp == 0) + # f3: MPP derivative constraint (dP/dV=0 at MPP) + m.par.f3 = pyo.Constraint(expr=m.Imp - m.Vmp*( + ( m.par.Io/m.par.a*pyo.exp( (m.Vmp + m.Imp*m.par.Rs)/m.par.a ) + 1/m.par.Rsh ) + /( 1 + m.par.Io*m.par.Rs/m.par.a*pyo.exp( (m.Vmp + m.Imp*m.par.Rs)/m.par.a ) + m.par.Rs/m.par.Rsh ) ) == 0) + + # f4: Open-circuit voltage temperature correction. + m.par.dT = pyo.Param(initialize=5) + + m.par.aT = pyo.Expression(expr=m.par.a*(m.Tref+m.par.dT)/m.Tref) # Dobos eq.3 + m.par.VocT = pyo.Expression(expr=m.bVoc*(1+m.par.Adj/100.0)*m.par.dT + m.Voc) # Dobos eq.21 (Voc at T') + m.par.Eg = pyo.Expression(expr=(1-0.0002677*m.par.dT)*m.Egref) # Dobos eq.6 + m.par.IoT = pyo.Expression(expr=m.par.Io*( (m.Tref+m.par.dT)/m.Tref )**3 *pyo.exp( 11600 * (m.Egref/m.Tref - m.par.Eg/(m.Tref+m.par.dT)))) # Dobos eq.5; 11600 ~= 1/k_B [K/eV] + m.par.f4 = pyo.Constraint(expr=m.par.Il+m.aIsc*(1-m.par.Adj/100)*m.par.dT - m.par.IoT*(pyo.exp( m.par.VocT/m.par.aT ) - 1 ) - m.par.VocT/m.par.Rsh == 0) # Dobos eq.22 + + # Gamma (maximum power temperature coefficient) fitting block. + # Evaluates P_max(T) over a range of temperatures by solving the single diode + # equation simultaneously with the MPP derivative condition at each temperature. + temperatures = np.arange(10 + 273.15, 50 + 273.15, gamma_curve_dt) + + def gamma_expr(b, t): + """Finite-difference gamma between adjacent temperature points [%/K]""" + return (b.pt[t].Pmp_Tc-b.pt[t-1].Pmp_Tc)*100/(m.Vmp*m.Imp*(b.pt[t].Tc-b.pt[t-1].Tc)) + + def gamma_blocks(b, i): + """Per-temperature block: solves for Vmp(T) and Imp(T) using temperature-adjusted parameters.""" + b.Tc = pyo.Param(initialize=temperatures[i - 1]) + b.Vmp_Tc = pyo.Var(domain=pyo.NonNegativeReals) + b.Imp_Tc = pyo.Var(domain=pyo.NonNegativeReals) + + # Temperature-adjusted circuit parameters at cell temperature Tc + b.a_Tc = pyo.Expression(expr=m.par.a * b.Tc / m.Tref) # Dobos eq.3; DeSoto: a = N_ser*n*kT/q, so a(T) = a_ref*T/T_ref + b.Eg_Tc = pyo.Expression(expr=m.Egref * (1-0.0002677*(b.Tc-m.Tref))) # Dobos eq.6; DeSoto Ch.4: linear bandgap approx for Si + b.Io_Tc = pyo.Expression(expr=m.par.Io* ( b.Tc/m.Tref)**3 * pyo.exp((1/m.k)*(m.Egref/m.Tref-b.Eg_Tc/b.Tc))) # Dobos eq.5; DeSoto Ch.3: semiconductor I_o(T) + b.Il_Tc = pyo.Expression(expr=m.par.Il + (m.aIsc*(1-m.par.Adj/100))*(b.Tc-m.Tref)) # Dobos eq.4+8; DeSoto eq.4.4 with Adjust-scaled alpha_sc + + b.f_5 = pyo.Constraint(expr=b.Imp_Tc - b.Vmp_Tc *( b.Io_Tc/b.a_Tc*pyo.exp( (b.Vmp_Tc+b.Imp_Tc*m.par.Rs)/b.a_Tc ) + 1/m.par.Rsh ) + / ( 1 + m.par.Rs/m.par.Rsh + b.Io_Tc*m.par.Rs/b.a_Tc*pyo.exp( (b.Vmp_Tc+b.Imp_Tc*m.par.Rs)/b.a_Tc ) ) == 0) + b.f_6 = pyo.Constraint(expr=b.Il_Tc - b.Io_Tc*(pyo.exp( (b.Vmp_Tc+b.Imp_Tc*m.par.Rs)/b.a_Tc ) - 1) - (b.Vmp_Tc + b.Imp_Tc*m.par.Rs)/m.par.Rsh - b.Imp_Tc == 0) + b.Pmp_Tc = pyo.Expression(expr=b.Vmp_Tc * b.Imp_Tc) # Dobos eq.25 + + nTc = len(temperatures) + m.gamma = pyo.Block() + g = m.gamma + g.i = pyo.RangeSet(nTc) + g.d_i = pyo.RangeSet(2, nTc) + g.pt = pyo.Block(g.i, rule=gamma_blocks) + + g.gamma_Tc = pyo.Expression(g.d_i, rule=gamma_expr) + g.gamma_avg = pyo.Expression(expr=pyo.summation(g.gamma_Tc) / len(g.d_i)) + + # f_7: Match modeled gamma to manufacturer-specified gamma_Pmp + g.f_7 = pyo.Constraint(expr=(g.gamma_avg - m.gPmp) == 0) + + # Sanity checks: verify that the solved parameters reproduce datasheet currents. + model.sanity = pyo.Block() + s = model.sanity + + # f_8: Solve for Imp independently at V=Vmp using the single diode eq. + s.Imp_calc = pyo.Var(domain=pyo.NonNegativeReals) + s.f_8 = pyo.Constraint(expr=m.par.Il - s.Imp_calc - m.par.Io * (pyo.exp((m.Vmp + s.Imp_calc * m.par.Rs) / m.par.a) - 1.0) - (m.Vmp + s.Imp_calc * m.par.Rs) / m.par.Rsh == 0) + + # f_9: Verify current at Voc is ~0 (should be negligible for a valid solution) + s.Ioc_calc = pyo.Var(domain=pyo.NonNegativeReals, initialize=0) + s.f_9 = pyo.Constraint(expr=m.par.Il - s.Ioc_calc - m.par.Io * (pyo.exp((m.Voc + s.Ioc_calc * m.par.Rs) / m.par.a) - 1.0) - (m.Voc + s.Ioc_calc * m.par.Rs) / m.par.Rsh == 0) + # examine solved modules + model.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) + return model + + +def solve_model(model, solver, tee=False): + """ + Solve the model with scaling factors, multiple tries and separating steps + + Solution may not be optimal! Solutions may have slight infeasibility. + Caller needs to check whether it is above an acceptable threshold using the log functions by setting tee=True, + or after the function, using `get_constraint_infeas` or `get_curve_diffs`. + """ + model.scaling_factor[model.solver.par.Io] = IL_SCALING + model.scaling_factor[model.solver.par.Rsh] = RSH_SCALING + + scaled_model = pyo.TransformationFactory('core.scale_model').create_using(model) + + scaled_model.obj_zero = pyo.Objective(rule=0) + res = None + try: + res = solver.solve(scaled_model, tee=tee) + except Exception as e: + if tee: + log_infeasible_bounds(scaled_model, logger=solve_log, tol=1e-7) + log_infeasible_constraints(scaled_model, logger=solve_log, tol=1e-7) + else: + return None, scaled_model, 0 + + if 'iterations exceeded' in res.solver.message.lower(): + solver.options["max_iter"] = MAX_ITER * 2 + try: + res = solver.solve(scaled_model, tee=tee) + except Exception as e: + if tee: + log_infeasible_bounds(scaled_model, logger=solve_log, tol=1e-7) + log_infeasible_constraints(scaled_model, logger=solve_log, tol=1e-7) + else: + return None, scaled_model, 0 + + # Exit status is 'Converged to a point of local infeasibility. Problem may be infeasible.' + # However, try to see if we can push the solution a little closer to fitting the IV curves before returning the approximate solution + elif 'locally infeasible' in res.solver.message.lower(): + try: + res = solver.solve(scaled_model, tee=tee) + except Exception: + return None, scaled_model, -1 + + if 'locally infeasible' in res.solver.message: + scaled_model.solver.gamma.deactivate() + try: + res = solver.solve(scaled_model, tee=tee) + except Exception: + return None, scaled_model, -2 + + scaled_model.solver.gamma.activate() + try: + res = solver.solve(scaled_model, tee=tee) + except Exception: + return None, scaled_model, -3 + if res: + pyo.TransformationFactory('core.scale_model').propagate_solution(scaled_model, model) + return res, scaled_model, 2 + else: + return None, scaled_model, -4 + + elif res is None or res.solver.status != 'ok': + if tee: + log_infeasible_bounds(scaled_model, logger=solve_log, tol=1e-7) + log_infeasible_constraints(scaled_model, logger=solve_log, tol=1e-7) + return None, scaled_model, -5 + + pyo.TransformationFactory('core.scale_model').propagate_solution(scaled_model, model) + return res, scaled_model, 4 + + +def get_iterations(log_file): + """ + Get the number of IPOPT iterations from the log file + """ + with open(log_file, 'r') as f: + for line in f: + if "Number of Iterations....:" in line: + it = line.split(": ")[1] + it_n = int(it) + return it_n + + +def get_constraint_infeas(model): + """ + Get the magnitude of infeasibility for each constraint in the pyomo model. Model can be the original or the scaled version + """ + if hasattr(model.solver.par, 'f0'): + vals = (pyo.value(model.solver.par.f0), pyo.value(model.solver.par.f1), pyo.value(model.solver.par.f2), pyo.value(model.solver.par.f3), pyo.value(model.solver.par.f4), pyo.value(model.solver.gamma.f_7)) + else: + if hasattr(model.solver.gamma, 'f_7'): + vals = (pyo.value(model.solver.par.scaled_f0), pyo.value(model.solver.par.scaled_f1), pyo.value(model.solver.par.scaled_f2), pyo.value(model.solver.par.scaled_f3), pyo.value(model.solver.par.scaled_f4), pyo.value(model.solver.gamma.f_7)) + else: + vals = (pyo.value(model.solver.par.scaled_f0), pyo.value(model.solver.par.scaled_f1), pyo.value(model.solver.par.scaled_f2), pyo.value(model.solver.par.scaled_f3), pyo.value(model.solver.par.scaled_f4)) + return [abs(v) for v in vals] + + +def solve_model_best_solution(model, solver, tee=False): + """ + Solve the model and return the best solution regardless of regardless of whether IPOPT has converged or exited gracefully + + Solution may not be optimal! Solutions may have a lot of infeasibility. + Caller needs to check whether it is above an acceptable threshold using the log functions by setting tee=True, + or after the function, using `get_constraint_infeas` or `get_curve_diffs`. + """ + try: + il_scaling = 10**min(12, -int(np.log10(pyo.value(model.solver.par.Io)))) + rsh_scaling = 10**min(5, -int(np.log10(pyo.value(model.solver.par.Rsh)))) + except Exception: + il_scaling = IL_SCALING + rsh_scaling = RSH_SCALING + model.scaling_factor[model.solver.par.Io] = il_scaling + model.scaling_factor[model.solver.par.Rsh] = rsh_scaling + + scaled_model = pyo.TransformationFactory('core.scale_model').create_using(model) + + if hasattr(scaled_model.solver, "f_0"): + scaled_model.obj_gamma = pyo.Objective(rule=scaled_model.solver.gamma.f_7 ** 0.5 + scaled_model.solver.f_0 ** 0.5) + elif hasattr(scaled_model.solver.gamma, "f_7"): + scaled_model.obj_gamma = pyo.Objective(rule=scaled_model.solver.gamma.f_7 ** 0.5) + + scaled_model.sanity.scaled_f_9.deactivate() + + res = None + try: + res = solver.solve(scaled_model, tee=tee, logfile='ipopt_output.log') + except Exception as e: + pass + + if res is not None and res.solver.status == "ok": + pyo.TransformationFactory('core.scale_model').propagate_solution(scaled_model, model) + return res, scaled_model, 1 + + it_n = get_iterations('ipopt_output.log') + + solver.options['max_iter'] = it_n - 1 + + try: + res = solver.solve(scaled_model, tee=tee, logfile='ipopt_output.log') + except Exception: + pass + + while 'infeasible' in res.solver.message: + it_n = get_iterations('ipopt_output.log') + solver.options['max_iter'] = it_n - 1 + res = solver.solve(scaled_model, tee=tee, logfile='ipopt_output.log') + + pyo.TransformationFactory('core.scale_model').propagate_solution(scaled_model, model) + + # get somewhat stable params + infeas = sum(get_constraint_infeas(model)) + attempts = 0 + while infeas > 10 and attempts < 10: + res = solver.solve(scaled_model, tee=tee, logfile='ipopt_output.log') + infeas = sum(get_constraint_infeas(scaled_model)) + pyo.TransformationFactory('core.scale_model').propagate_solution(scaled_model, model) + attempts += 1 + + return res, scaled_model, 2 + + +def set_parameters(m, r: pd.Series): + """ + Set the manufacturer datasheet inputs on the model.solver block. + + These are the STC key points and temperature coefficients from the CEC module + database that define the right-hand side of the constraint system [Dobos Table 1]: + Vmp, Imp, Voc, Isc - I-V curve key points at STC + alpha_sc - Temperature coefficient of Isc [A/K] + beta_oc - Temperature coefficient of Voc [V/K] + gamma_r - Temperature coefficient of Pmp [%/K] + T_ref = 298.15 K - STC cell temperature (25 C) + """ + try: + m.Vmp.set_value(r['V_mp_ref']) + m.Imp.set_value(r['I_mp_ref']) + m.Voc.set_value(r['V_oc_ref']) + m.Isc.set_value(r['I_sc_ref']) + m.aIsc.set_value(r['alpha_sc']) + m.bVoc.set_value(r['beta_oc']) + m.gPmp.set_value(r['gamma_r']) + m.Tref.set_value(25 + 273.15) + return True + except Exception: + return False + +def set_initial_guess(model, a, Il, Io, Rs, Rsh, Adj): + """ + Set initial values for the six circuit parameters before solving. + + Good initial guesses are critical for convergence of the nonlinear system. + """ + m = model.solver + m.par.a.set_value(a) + m.par.Il.set_value(Il) + m.par.Io.set_value(Io) + m.par.Rs.set_value(Rs) + m.par.Rsh.set_value(Rsh) + m.par.Adj.set_value(Adj) + model.sanity.Imp_calc.set_value(pyo.value(m.Imp)) + +# intercept, coefficients +Voc_Vmp_to_a = [0.22328699, 0.03106774, 0.00738466] +Isc_Imp_to_Il = [0.0153132, 0.96058469, 0.04133798] +Isc_Imp_Voc_Vmp_to_Rs = [0.28969838, 0.61785177, -0.6759943, 0.02241771, 0.0218622] + +def set_empirical_initial_guess(model): + """ + Set initial guesses using empirical regressions fitted to previously-solved modules. + + Uses linear regressions of datasheet values (Voc, Vmp, Isc, Imp) to predict + a, I_L, and R_s using coefficients fitted to the solver's own successfully-solved module database. + """ + m = model.solver + + Vmp = pyo.value(m.Vmp) + Imp = pyo.value(m.Imp) + Voc = pyo.value(m.Voc) + Isc = pyo.value(m.Isc) + aIsc = pyo.value(m.aIsc) + bVoc = pyo.value(m.bVoc) + gPmp = pyo.value(m.gPmp) + + a = Voc_Vmp_to_a[0] + Voc_Vmp_to_a[1] * Voc + Voc_Vmp_to_a[2] * Vmp + Il = Isc_Imp_to_Il[0] + Isc_Imp_to_Il[1] * Isc + Isc_Imp_to_Il[2] * Imp + Rs = Isc_Imp_Voc_Vmp_to_Rs[0] + Isc_Imp_Voc_Vmp_to_Rs[1] * Isc + Isc_Imp_Voc_Vmp_to_Rs[2] * Imp \ + + Isc_Imp_Voc_Vmp_to_Rs[3] * Voc + Isc_Imp_Voc_Vmp_to_Rs[4] * Vmp + + m.par.a.set_value(a) + m.par.Il.set_value(Il) + m.par.Rs.set_value(Rs) + for i in m.gamma.i: + m.gamma.pt[i].Vmp_Tc.set_value(Vmp) + m.gamma.pt[i].Imp_Tc.set_value(Imp) + + +def find_closest(df_solved: pd.DataFrame, r: pd.Series): + """ + Find the previously-solved module closest to the target in datasheet-parameter space. + + Uses Euclidean distance across (Vmp, Imp, Voc, Isc, alpha_sc, beta_oc, gamma_r) + to identify the best initial guess donor. + """ + diff = ( + (df_solved['V_mp_ref'] - r['V_mp_ref']) ** 2 + + (df_solved['I_mp_ref'] - r['I_mp_ref']) ** 2 + + (df_solved['V_oc_ref'] - r['V_oc_ref']) ** 2 + + (df_solved['I_sc_ref'] - r['I_sc_ref']) ** 2 + + (df_solved['alpha_sc'] - r['alpha_sc']) ** 2 + + (df_solved['beta_oc'] - r['beta_oc']) ** 2 + + (df_solved['gamma_r'] - r['gamma_r']) ** 2) + + params_closest = df_solved.iloc[diff.argmin()] + return params_closest + + +def get_params_from_model(model): + """ + Extract the six solved circuit parameters from the Pyomo model. + + Handles both the original and scaled (via core.scale_model) model representations, + un-doing the Io and Rsh scaling factors applied for numerical conditioning. + + Returns + ------- + tuple : (a, Il, Io, Rs, Rsh, Adj) + """ + if hasattr(model.solver.par, 'scaled_a'): + a = pyo.value(model.solver.par.scaled_a) + Il = pyo.value(model.solver.par.scaled_Il) + Io = pyo.value(model.solver.par.scaled_Io / IL_SCALING) + Rs = pyo.value(model.solver.par.scaled_Rs) + Rsh = pyo.value(model.solver.par.scaled_Rsh / RSH_SCALING) + Adj = pyo.value(model.solver.par.scaled_Adj) + else: + a = pyo.value(model.solver.par.a) + Il = pyo.value(model.solver.par.Il) + Io = pyo.value(model.solver.par.Io) + Rs = pyo.value(model.solver.par.Rs) + Rsh = pyo.value(model.solver.par.Rsh) + Adj = pyo.value(model.solver.par.Adj) + return a, Il, Io, Rs, Rsh, Adj + + +def get_curve_diffs(r, model): + """ + Calculate normalized deviations between the modeled and datasheet I-V curve at STC. + + Computes the I-V curve at STC (1000 W/m^2, 25 C) and measures relative errors + in Isc, Imp, Vmp, and Pmp against the manufacturer datasheet values. + + Returns + ------- + tuple : (d_Isc, d_Imp, d_Vmp, d_Pmp) — fractional deviations (unitless) + """ + x_V, y_I = cec_model_ivcurve(model, 1000, 25 + 273.15, r['V_oc_ref'], 150) + p = x_V * y_I + mp_ind = np.argmax(p) + d_I_sc = (y_I[0] - r['I_sc_ref']) / r['I_sc_ref'] + d_I_mp = (y_I[mp_ind] - r['I_mp_ref']) / r['I_mp_ref'] + d_V_mp = (x_V[mp_ind] - r['V_mp_ref']) / r['V_mp_ref'] + d_P_mp = (p[mp_ind] - r['V_mp_ref'] * r['I_mp_ref']) / (r['V_mp_ref'] * r['I_mp_ref']) + return d_I_sc, d_I_mp, d_V_mp, d_P_mp + + +def read_prepare_file(xlsx_file): + """ + Read the CEC Module Excel Spreadsheet and prepare it for solving. + + Reads the CEC PV module database (as published by the California Energy + Commission), drops non-essential columns, renames to internal conventions, + and converts alpha_sc and beta_oc from %/C to absolute units (A/K and V/K). + """ + all_cec_modules_df = pd.read_excel(xlsx_file, skiprows=list(range(0, 16)) + [17]) + all_cec_modules_df = all_cec_modules_df.drop(columns=["Description", 'Safety Certification', + 'Nameplate Pmax', 'Notes', + 'Design Qualification Certification\n(Optional Submission)', + 'Performance Evaluation (Optional Submission)', 'Family', + 'N_p', 'αIpmax', 'βVpmax', 'IPmax, low', 'VPmax, low', 'IPmax, NOCT', + 'VPmax, NOCT', 'Mounting', 'Type', 'Short Side', 'Long Side', + 'Geometric Multiplier', 'P2/Pref', 'CEC Listing Date', 'Last Update']) + all_cec_modules_df = all_cec_modules_df.rename(columns={ + 'Nameplate Isc': "I_sc_ref", 'Nameplate Voc': "V_oc_ref", + 'Nameplate Ipmax': "I_mp_ref", 'Nameplate Vpmax': "V_mp_ref", 'Average NOCT': "T_NOCT", + 'γPmax': "gamma_r", 'αIsc': "alpha_sc", + 'βVoc': "beta_oc", + }) + all_cec_modules_df[test_data_cols] = all_cec_modules_df[test_data_cols].astype(float) + all_cec_modules_df['alpha_sc'] *= all_cec_modules_df['I_sc_ref'] * 1e-2 + all_cec_modules_df['beta_oc'] *= all_cec_modules_df['V_oc_ref'] * 1e-2 + all_cec_modules_df = all_cec_modules_df.drop_duplicates() + + for col in model_param_cols: + all_cec_modules_df[col] = None + for col in solve_tracking_cols: + all_cec_modules_df[col] = None + return all_cec_modules_df + + +def _validate_module_row(r): + """Return an error string if the module row has invalid data, else None.""" + if r['V_mp_ref'] < 0: + return "Vmp < 0" + if r['V_oc_ref'] < r['V_mp_ref']: + return "Voc < Vmp" + return None + + +def _save_plot(r, i, plot_output_path): + """Save the current matplotlib figure with standard formatting.""" + plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left') + plt.xlabel("Voltage") + plt.ylabel("Current") + plt.title(f"{r['Manufacturer']} {r['Model Number']}") + plt.tight_layout() + plt.savefig(plot_output_path / f"IV_curve_{i}.png") + plt.close() + + +def _extract_results(r, model, scaled_model, solve_code, plot_output_path=None, i=None): + """ + Extract solved parameters and IV-curve deviations into the result row. + + Returns the updated row `r`. Sets r['Error'] if infeasibility exceeds threshold. + """ + r['solve_return_code'] = solve_code + + d_I_sc, d_I_mp, d_V_mp, d_P_mp = get_curve_diffs(r, model) + diffs = [d_I_sc, d_I_mp, d_V_mp, d_P_mp] + + infeas_sum = np.abs(diffs).sum() + infeas_max = np.abs(diffs).max() + + if infeas_max > INFEASIBILITY_THRESHOLD or infeas_sum > INFEASIBILITY_THRESHOLD: + r['Error'] = f"Infeasibility > {INFEASIBILITY_THRESHOLD}" + if plot_output_path: + plt.close() + return r + + a, Il, Io, Rs, Rsh, Adj = get_params_from_model(scaled_model) + for col, val in zip(iv_diff_cols, diffs): + r[col] = val + for col, val in zip(model_param_cols, [a, Il, Io, Rs, Rsh, Adj]): + r[col] = val + r['Error'] = None + + if plot_output_path: + _save_plot(r, i, plot_output_path) + return r + + +def run_solve(i, r, gamma_curve_dt, initial_guess_fn, solve_pass_label, + plot_output_path=None, solver=None, solve_fn=solve_model): + """ + Solve a single module's six CEC parameters. + + The solving strategy is controlled by the caller via: + + Parameters + ---------- + i : int + Row index (used for error messages and plot filenames). + r : pd.Series + Module datasheet row (modified in place with results). + gamma_curve_dt : float + Temperature step for gamma fitting (smaller = more precise, harder to solve). + initial_guess_fn : callable(model) -> None + Sets the initial guess on the model. Examples: + - ``set_empirical_initial_guess`` for first-pass solves + - ``lambda m: set_initial_guess(m, *closest[model_param_cols])`` for bootstrapping + solve_pass_label : str + Label stored in r['solve_pass'] for tracking which strategy produced the solution. + plot_output_path : Path or None + Directory for saving IV-curve plots. None disables plotting. + solver : pyomo SolverFactory or None + Reusable IPOPT solver instance. Created if None (costs startup time when + solving many modules — prefer passing a shared instance). + solve_fn : callable + The model-solving function to use (default: ``solve_model``). + """ + if solver is None: + solver = pyo.SolverFactory('ipopt') + solver.options["max_iter"] = MAX_ITER + + r['solve_pass'] = solve_pass_label + + error = _validate_module_row(r) + if error: + r['Error'] = error + return r + + model = create_model(gamma_curve_dt=gamma_curve_dt) + if not set_parameters(model.solver, r): + r['Error'] = f"Parameter missing or out of bounds for row {i}" + return r + + if plot_output_path: + plt.figure() + + initial_guess_fn(model) + + if plot_output_path: + plot_iv_curve(model, linestyle=(0, (1, 10)), label="Initial Guess") + + res, scaled_model, solve_code = solve_fn(model, solver, tee=False) + + if plot_output_path: + if res and res.solver.status == 'ok': + plot_iv_curve(model, linestyle='-', label="Optimal", plot_anchors=True) + else: + plot_iv_curve(model, linestyle='dotted', label="Approx", plot_anchors=True) + + return _extract_results(r, model, scaled_model, solve_code, plot_output_path, i) + + +def parallel_run(solve_fn, df, plot_output_path=None, extra_args=None): + """ + Run ``solve_fn`` in parallel across all rows of ``df``. + + Parameters + ---------- + solve_fn : callable(i, r, *extra_args, plot_output_path) -> pd.Series + Per-row solve function (e.g. ``_make_first_pass_fn``). + df : pd.DataFrame + Rows to solve. + plot_output_path : Path or None + Passed through to solve_fn. + extra_args : list or None + Additional positional args inserted between (i, r) and plot_output_path. + """ + extra_args = extra_args or [] + with mp.Pool(NUM_OF_WORKERS) as pool: + results = [ + pool.apply_async(solve_fn, [idx, row] + extra_args + [plot_output_path]) + for idx, row in df.iterrows() + ] + results = [res.get() for res in results] + return pd.DataFrame(results) + + +def sequential_run(solve_fn, df, plot_output_path=None, extra_args=None): + """ + Run ``solve_fn`` sequentially across all rows of ``df``, sharing a single + IPOPT solver instance for efficiency. + """ + solver = pyo.SolverFactory('ipopt') + extra_args = extra_args or [] + results = [] + for i, r in df.iterrows(): + row = solve_fn(i, r, *extra_args, plot_output_path=plot_output_path, solver=solver) + results.append(row) + return pd.DataFrame(results) + + +# --- Top-level solve wrappers (must be picklable for multiprocessing) --- + +def solve_first_pass(i, r, plot_output_path=None, solver=None): + """Solve a single module using empirical initial guesses (first pass).""" + return run_solve(i, r, + gamma_curve_dt=3, + initial_guess_fn=set_empirical_initial_guess, + solve_pass_label='first_pass', + plot_output_path=plot_output_path, + solver=solver) + + +def solve_bootstrapping(i, r, solved_df, plot_output_path=None, solver=None): + """Solve a single module using bootstrapped initial guesses from previously-solved modules.""" + cec_closest = find_closest(solved_df, r) + return run_solve(i, r, + gamma_curve_dt=3, + initial_guess_fn=lambda m: set_initial_guess(m, *cec_closest[model_param_cols]), + solve_pass_label='bootstrapping', + plot_output_path=plot_output_path, + solver=solver) + + +def solve_bootstrapping_reduced(i, r, solved_df, plot_output_path=None, solver=None): + """Solve a single module with bootstrapped guesses and reduced gamma temperature sampling.""" + cec_closest = find_closest(solved_df, r) + return run_solve(i, r, + gamma_curve_dt=10, + initial_guess_fn=lambda m: set_initial_guess(m, *cec_closest[model_param_cols]), + solve_pass_label='bootstrapping_reduced', + plot_output_path=plot_output_path, + solver=solver) + + +def solve_approx(solved_df, unsolved_df, plot_output_path=None): + """ + Solve unsolved modules with reduced gamma sampling and ``solve_model_best_solution``. + + Not recommended unless an approximate solution is acceptable. + Accuracy of IV curve to test data should be examined visually via + ``create_model_with_solution`` and ``plot_iv_curve``. + """ + solver = pyo.SolverFactory('ipopt') + results = [] + for i, r in unsolved_df.iterrows(): + cec_closest = find_closest(solved_df, r) + row = run_solve(i, r, + gamma_curve_dt=15, + initial_guess_fn=lambda m, cc=cec_closest: set_initial_guess(m, *cc[model_param_cols]), + solve_pass_label='approx', + plot_output_path=plot_output_path, + solver=solver, + solve_fn=solve_model_best_solution) + results.append(row) + return pd.DataFrame(results) + + +def create_model_with_solution(sol_row): + """ + Create a model pre-populated with a known solution for post-hoc analysis. + + Useful for plotting I-V curves or inspecting constraint residuals of a + previously-solved module. The model is not solved — the six parameters are + set directly from the input row. + + Parameters + ---------- + sol_row : pd.Series or pd.DataFrame + Must contain columns from both `test_data_cols` and `model_param_cols`. + """ + model = create_model(gamma_curve_dt=3) + if isinstance(sol_row, pd.Series): + if not set_parameters(model.solver, sol_row): + raise RuntimeError("Test data parameters could not be set") + set_initial_guess(model, *sol_row[model_param_cols]) + elif isinstance(sol_row, pd.DataFrame): + if not set_parameters(model.solver, sol_row.to_dict('records')[0]): + raise RuntimeError("Test data parameters could not be set") + set_initial_guess(model, *sol_row[model_param_cols].values[0]) + return model + + +def create_sam_library_file(df, date, version, filename_date): + """ + Export a dataframe of solved module parameters to a CSV file in the SAM library format. + + Parameters + ---------- + df : pd.DataFrame + Solved module parameters from the solver. + date : str + Date string for the "Date" column. + version : str + SAM version string for the "Version" column. + filename_date : str + Date string used in the output filename. + """ + + sam_library_df = df.copy() + sam_library_df = sam_library_df.rename(columns={ + 'a_py': 'a_ref', 'Il_py': 'I_L_ref', 'Io_py': 'I_o_ref', + 'Rs_py': 'R_s', 'Rsh_py': 'R_sh_ref', 'Adj_py': 'Adjust', + 'gamma_r': 'gamma_pmp', + }) + sam_library_df.insert(0, "Name", sam_library_df['Manufacturer'].astype(str) + " " + sam_library_df['Model Number'].astype(str)) + sam_library_df = sam_library_df.drop(columns=['Model Number', 'd_Isc', 'd_Imp', 'd_Vmp', 'd_Pmp', 'Error']) + + sam_library_df['Version'] = version + sam_library_df['Date'] = date + + headers = { "Name": ["Units", "[0]"], + "Manufacturer": ["", "lib_manufacturer"], + "Technology": ["", "cec_material"], + "Bifacial": ["", "lib_is_bifacial"], + "STC": ["", ""], + "PTC": ["", ""], + "A_c": ["m2", "cec_area"], + "Length": ["m", "lib_length"], + "Width": ["m", "lib_width"], + "N_s": ["", "cec_n_s"], + "I_sc_ref": ["A", "cec_i_sc_ref"], + "V_oc_ref": ["V", "cec_v_oc_ref"], + "I_mp_ref": ["A", "cec_i_mp_ref"], + "V_mp_ref": ["V", "cec_v_mp_ref"], + "alpha_sc": ["A/K", "cec_alpha_sc"], + "beta_oc": ["V/K", "cec_beta_oc"], + "T_NOCT": ["C", "cec_t_noct"], + "a_ref": ["V", "cec_a_ref"], + "I_L_ref": ["A", "cec_i_l_ref"], + "I_o_ref": ["A", "cec_i_o_ref"], + "R_s": ["Ohm", "cec_r_s"], + "R_sh_ref": ["Ohm", "cec_r_sh_ref"], + "Adjust": ["%", "cec_adjust"], + "gamma_pmp": ["%/K", "cec_gamma_pmp"], + "BIPV": ["", ""], + "Version": ["", ""], + "Date": ["", ""]} + + header_df = pd.DataFrame.from_dict(headers) + sam_library_df = pd.concat([header_df, sam_library_df]) + sam_library_df.to_csv(f"CEC Modules {filename_date}.csv", index=False) + + +def main(): + run_parallel = True + create_SAM_library = True + plot_output_path = None # Set to a Path to enable IV-curve plots + + if not pyo.SolverFactory('ipopt').available(): + raise RuntimeError("IPOPT solver not available. Install it to your Python environment from conda: `conda install -c conda-forge ipopt`") + + if len(sys.argv) > 1: + filename = Path(sys.argv[1]) + + if not filename.exists(): + raise RuntimeError(f"CEC Module Excel Spreadsheet file path does not exist. {filename}") + + filename_date = filename.stem.split("-") + filename_date = f"{filename_date[-3]}-{filename_date[-2]}-{filename_date[-1]}" + all_cec_modules_df = read_prepare_file(filename) + + if plot_output_path: + plot_output_path.mkdir(parents=True, exist_ok=True) + + run_fn = parallel_run if run_parallel else sequential_run + + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: Solving for {len(all_cec_modules_df)} Modules") + + # Pass 1: empirical initial guesses + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: Starting First Pass Solve") + all_cec_modules_df = run_fn(solve_first_pass, all_cec_modules_df, plot_output_path) + all_cec_modules_df.to_csv(f"cec_modules_params_{filename_date}.csv", index=False) + + solved_df = all_cec_modules_df[~all_cec_modules_df['Rsh_py'].isna()] + unsolved_df = all_cec_modules_df[all_cec_modules_df['Rsh_py'].isna()] + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: Solved {len(solved_df)}. {len(unsolved_df)} remaining.") + + # Pass 2: bootstrapped from closest solved module + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: Starting Second Pass Solve") + df = run_fn(solve_bootstrapping, unsolved_df, plot_output_path, extra_args=[solved_df]) + all_cec_modules_df = pd.concat([solved_df, df]).sort_index() + all_cec_modules_df.to_csv(f"cec_modules_params_{filename_date}.csv", index=False) + + solved_df = all_cec_modules_df[~all_cec_modules_df['Rsh_py'].isna()] + unsolved_df = all_cec_modules_df[all_cec_modules_df['Rsh_py'].isna()] + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: Solved {len(solved_df)}. {len(unsolved_df)} remaining.") + + # Pass 3: bootstrapped with reduced gamma temperature sampling + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: Starting Final Pass Solve") + df = run_fn(solve_bootstrapping_reduced, unsolved_df, plot_output_path, extra_args=[solved_df]) + all_cec_modules_df = pd.concat([solved_df, df]).sort_index() + all_cec_modules_df.to_csv(f"cec_modules_params_{filename_date}.csv", index=False) + + solved_df = all_cec_modules_df[~all_cec_modules_df['Rsh_py'].isna()] + unsolved_df = all_cec_modules_df[all_cec_modules_df['Rsh_py'].isna()] + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: Solved {len(solved_df)}. {len(unsolved_df)} unsolved.") + + if create_SAM_library: + create_sam_library_file(solved_df, "6/12/2025", "2025.4.16", filename_date) + + +if __name__ == "__main__": + main() + \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt index 038d4ac9..f6c9c60e 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,6 +1,9 @@ +matplotlib mypy numpy pandas +pint +pyomo pytest python-dotenv pympler diff --git a/tests/test_SixParSolve.py b/tests/test_SixParSolve.py new file mode 100644 index 00000000..91247401 --- /dev/null +++ b/tests/test_SixParSolve.py @@ -0,0 +1,99 @@ +import pytest +import sys +from pathlib import Path +sys.path.append(str(Path(__file__).parent.parent)) +from files.SixParSolve import * + +def define_default_model(set_initial_values=True): + # SunPower SPR-E19-310-COM + Vmp = 54.7 + Imp = 5.67 + Voc = 64.4 + Isc = 6.05 + bVoc = -0.175619 + aIsc = 0.00373527 + gamma = -0.386 + # Tref = 46 + 273.15 + Tref = 25 + 273.15 + + model = create_model() + m = model.solver + m.Vmp.set_value(Vmp) + m.Imp.set_value(Imp) + m.Voc.set_value(Voc) + m.Isc.set_value(Isc) + m.bVoc.set_value(bVoc) + m.aIsc.set_value(aIsc) + m.gPmp.set_value(gamma) + m.Tref.set_value(Tref) + + if set_initial_values: + a = 2.5776 + Il = 6.054 + Io = 8.360453e-11 + Rs = 3.081202e-01 + Rsh = 500.069 + Adj = 22.909 + m.par.a.set_value(a) + m.par.Il.set_value(Il) + m.par.Io.set_value(Io) + m.par.Rs.set_value(Rs) + m.par.Rsh.set_value(Rsh) + m.par.Adj.set_value(Adj) + return model + +def test_default_sam_cec_user(): + model = define_default_model() + + IL_oper, IO_oper, Rs, A_oper, Rsh_oper = cec_model_params_at_condition(model, 1000, 30+273.15) + assert IL_oper == pytest.approx(6.0683977849785, rel=1e-5) + assert IO_oper == pytest.approx(1.9115026653318136e-10, rel=1e-5) + assert Rs == pytest.approx(0.3081202, rel=1e-5) + assert A_oper == pytest.approx(2.6208265638101627, rel=1e-5) + assert Rsh_oper == pytest.approx(500.069, rel=1e-5) + + plot_iv_curve(model) + + +def test_cec_model_ivcurve_default(): + vmax = 64.4 + muIsc = 0.00287955 + EG = 1.12 + IL_oper = 6.05373 + IO_oper = 8.36045e-11 + Rs = 0.30812 + A_oper = 2.57764 + Rsh_oper = 500.069 + I_mp_ref = 5.67 + + y_I = [] + + V = np.linspace(0, vmax, 150) + for i, v in enumerate(V): + I = current_at_voltage_cec( v, IL_oper, IO_oper, Rs, A_oper, Rsh_oper, I_mp_ref ) + y_I.append(I) + + assert V[1] == pytest.approx(0.43221, rel=1e-3) + assert V[-1] == pytest.approx(64.4, rel=1e-3) + assert y_I[0] == pytest.approx(6.05, rel=1e-3) + assert y_I[1] == pytest.approx(6.04913, rel=1e-3) + assert y_I[-1] == pytest.approx(5.770225265737295e-06, rel=1e-3) + + +@pytest.mark.skipif(not pyo.SolverFactory('ipopt').available(), reason="requires ipopt solver") +def test_cec_model_solve(set_initial_values=True): + model = define_default_model(set_initial_values) + + solver = pyo.SolverFactory('ipopt') + solver.options["max_iter"] = 5000 + solver.options["halt_on_ampl_error"] = 'no' + + res, scaled_model = solve_model(model, solver, tee=True) + a = pyo.value(scaled_model.solver.par.scaled_a) + Il = pyo.value(scaled_model.solver.par.scaled_Il) + Io = pyo.value(scaled_model.solver.par.scaled_Io / IL_SCALING) + Rs = pyo.value(scaled_model.solver.par.scaled_Rs) + Rsh = pyo.value(scaled_model.solver.par.scaled_Rsh / RSH_SCALING) + Adj = pyo.value(scaled_model.solver.par.scaled_Adj) + + plot_iv_curve(model)