ModelChain

The ModelChain class provides a high-level interface for standardized PV modeling. The class aims to automate much of the modeling process while providing user-control and remaining extensible. This guide aims to build users’ understanding of the ModelChain class. It assumes some familiarity with object-oriented code in Python, but most information should be understandable even without a solid understanding of classes.

A ModelChain is composed of a PVSystem object and a Location object. A PVSystem object represents an assembled collection of modules, inverters, etc., a Location object represents a particular place on the planet, and a ModelChain object describes the modeling chain used to calculate a system’s output at that location. The PVSystem and Location objects will be described in detail in another guide.

Modeling with a ModelChain typically involves 3 steps:

  1. Creating the ModelChain.
  2. Executing the ModelChain.run_model() method with prepared weather data.
  3. Examining the model results that run_model() stored in attributes of the ModelChain.

A simple ModelChain example

Before delving into the intricacies of ModelChain, we provide a brief example of the modeling steps using ModelChain. First, we import pvlib’s objects, module data, and inverter data.

In [1]: import pandas as pd

In [2]: import numpy as np

# pvlib imports
In [3]: import pvlib

In [4]: from pvlib.pvsystem import PVSystem

In [5]: from pvlib.location import Location

In [6]: from pvlib.modelchain import ModelChain

# load some module and inverter specifications
In [7]: sandia_modules = pvlib.pvsystem.retrieve_sam('SandiaMod')

In [8]: cec_inverters = pvlib.pvsystem.retrieve_sam('cecinverter')

In [9]: sandia_module = sandia_modules['Canadian_Solar_CS5P_220M___2009_']

In [10]: cec_inverter = cec_inverters['ABB__MICRO_0_25_I_OUTD_US_208_208V__CEC_2014_']

Now we create a Location object, a PVSystem object, and a ModelChain object.

In [11]: location = Location(latitude=32.2, longitude=-110.9)

In [12]: system = PVSystem(surface_tilt=20, surface_azimuth=200,
   ....:                   module_parameters=sandia_module,
   ....:                   inverter_parameters=cec_inverter)
   ....: 

In [13]: mc = ModelChain(system, location)

Printing a ModelChain object will display its models.

In [14]: print(mc)
ModelChain: 
  name: None
  orientation_strategy: None
  clearsky_model: ineichen
  transposition_model: haydavies
  solar_position_method: nrel_numpy
  airmass_model: kastenyoung1989
  dc_model: sapm
  ac_model: snlinverter
  aoi_model: sapm_aoi_loss
  spectral_model: sapm_spectral_loss
  temp_model: sapm_temp
  losses_model: no_extra_losses

Next, we run a model with some simple weather data.

In [15]: weather = pd.DataFrame([[1050, 1000, 100, 30, 5]],
   ....:                        columns=['ghi', 'dni', 'dhi', 'temp_air', 'wind_speed'],
   ....:                        index=[pd.Timestamp('20170401 1200', tz='US/Arizona')])
   ....: 

In [16]: mc.run_model(times=weather.index, weather=weather);

ModelChain stores the modeling results on a series of attributes. A few examples are shown below.

In [17]: mc.aoi
Out[17]: 
2017-04-01 12:00:00-07:00    15.929176
Name: aoi, dtype: float64
In [18]: mc.dc
Out[18]: 
                               i_sc      i_mp    ...          i_x      i_xx
2017-04-01 12:00:00-07:00  5.485958  4.860317    ...     5.363079  3.401315

[1 rows x 7 columns]
In [19]: mc.ac
Out[19]: 
2017-04-01 12:00:00-07:00    189.915445
dtype: float64

The remainder of this guide examines the ModelChain functionality and explores common pitfalls.

Defining a ModelChain

A ModelChain object is defined by:

  1. The properties of its PVSystem and Location objects
  2. The keyword arguments passed to it at construction

ModelChain uses the keyword arguments passed to it to determine the models for the simulation. The documentation describes the allowed values for each keyword argument. If a keyword argument is not supplied, ModelChain will attempt to infer the correct set of models by inspecting the Location and PVSystem attributes.

Below, we show some examples of how to define a ModelChain.

Let’s make the most basic Location and PVSystem objects and build from there.

In [20]: location = Location(32.2, -110.9)

In [21]: poorly_specified_system = PVSystem()

In [22]: print(location)
Location: 
  name: None
  latitude: 32.2
  longitude: -110.9
  altitude: 0
  tz: UTC

In [23]: print(poorly_specified_system)
PVSystem: 
  name: None
  surface_tilt: 0
  surface_azimuth: 180
  module: None
  inverter: None
  albedo: 0.25
  racking_model: open_rack_cell_glassback

These basic objects do not have enough information for ModelChain to be able to automatically determine its set of models, so the ModelChain will throw an error when we try to create it.

In [24]: ModelChain(poorly_specified_system, location)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-24-4e9151e6ff63> in <module>()
----> 1 ModelChain(poorly_specified_system, location)

~/checkouts/readthedocs.org/user_builds/wholmgren-pvlib-python-new/checkouts/extras/pvlib/modelchain.py in __init__(self, system, location, orientation_strategy, clearsky_model, transposition_model, solar_position_method, airmass_model, dc_model, ac_model, aoi_model, spectral_model, temp_model, losses_model, name, **kwargs)
    305 
    306         # calls setters
--> 307         self.dc_model = dc_model
    308         self.ac_model = ac_model
    309         self.aoi_model = aoi_model

~/checkouts/readthedocs.org/user_builds/wholmgren-pvlib-python-new/checkouts/extras/pvlib/modelchain.py in dc_model(self, model)
    360         # guess at model if None
    361         if model is None:
--> 362             self._dc_model, model = self.infer_dc_model()
    363 
    364         # Set model and validate parameters

~/checkouts/readthedocs.org/user_builds/wholmgren-pvlib-python-new/checkouts/extras/pvlib/modelchain.py in infer_dc_model(self)
    411             return self.pvwatts_dc, 'pvwatts'
    412         else:
--> 413             raise ValueError('could not infer DC model from '
    414                              'system.module_parameters')
    415 

ValueError: could not infer DC model from system.module_parameters

Next, we define a PVSystem with a module from the SAPM database and an inverter from the CEC database. ModelChain will examine the PVSystem object’s properties and determine that it should choose the SAPM DC model, AC model, AOI loss model, and spectral loss model.

In [25]: sapm_system = PVSystem(module_parameters=sandia_module, inverter_parameters=cec_inverter)

In [26]: mc = ModelChain(system, location)

In [27]: print(mc)
ModelChain: 
  name: None
  orientation_strategy: None
  clearsky_model: ineichen
  transposition_model: haydavies
  solar_position_method: nrel_numpy
  airmass_model: kastenyoung1989
  dc_model: sapm
  ac_model: snlinverter
  aoi_model: sapm_aoi_loss
  spectral_model: sapm_spectral_loss
  temp_model: sapm_temp
  losses_model: no_extra_losses
In [28]: mc.run_model(times=weather.index, weather=weather);

In [29]: mc.ac
Out[29]: 
2017-04-01 12:00:00-07:00    189.915445
dtype: float64

Alternatively, we could have specified single diode or PVWatts related information in the PVSystem construction. Here we pass PVWatts data to the PVSystem. ModelChain will automatically determine that it should choose PVWatts DC and AC models. ModelChain still needs us to specify aoi_model and spectral_model keyword arguments because the system.module_parameters dictionary does not contain enough information to determine which of those models to choose.

In [30]: pvwatts_system = PVSystem(module_parameters={'pdc0': 240, 'gamma_pdc': -0.004})

In [31]: mc = ModelChain(pvwatts_system, location,
   ....:                 aoi_model='physical', spectral_model='no_loss')
   ....: 

In [32]: print(mc)
ModelChain: 
  name: None
  orientation_strategy: None
  clearsky_model: ineichen
  transposition_model: haydavies
  solar_position_method: nrel_numpy
  airmass_model: kastenyoung1989
  dc_model: pvwatts_dc
  ac_model: pvwatts_inverter
  aoi_model: physical_aoi_loss
  spectral_model: no_spectral_loss
  temp_model: sapm_temp
  losses_model: no_extra_losses
In [33]: mc.run_model(times=weather.index, weather=weather);

In [34]: mc.ac
Out[34]: 
2017-04-01 12:00:00-07:00    198.519999
dtype: float64

User-supplied keyword arguments override ModelChain’s inspection methods. For example, we can tell ModelChain to use different loss functions for a PVSystem that contains SAPM-specific parameters.

In [35]: sapm_system = PVSystem(module_parameters=sandia_module, inverter_parameters=cec_inverter)

In [36]: mc = ModelChain(system, location, aoi_model='physical', spectral_model='no_loss')

In [37]: print(mc)
ModelChain: 
  name: None
  orientation_strategy: None
  clearsky_model: ineichen
  transposition_model: haydavies
  solar_position_method: nrel_numpy
  airmass_model: kastenyoung1989
  dc_model: sapm
  ac_model: snlinverter
  aoi_model: physical_aoi_loss
  spectral_model: no_spectral_loss
  temp_model: sapm_temp
  losses_model: no_extra_losses
In [38]: mc.run_model(times=weather.index, weather=weather);

In [39]: mc.ac
Out[39]: 
2017-04-01 12:00:00-07:00    191.991429
dtype: float64

Of course, these choices can also lead to failure when executing run_model() if your system objects do not contain the required parameters for running the model.

Demystifying ModelChain internals

The ModelChain class has a lot going in inside it in order to make users’ code as simple as possible.

The key parts of ModelChain are:

  1. The ModelChain.run_model() method
  2. A set of methods that wrap and call the PVSystem methods.
  3. A set of methods that inspect user-supplied objects to determine the appropriate default models.

run_model

Most users will only interact with the run_model() method. The run_model() method, shown below, calls a series of methods to complete the modeling steps. The first method, prepare_inputs(), computes parameters such as solar position, airmass, angle of incidence, and plane of array irradiance. The prepare_inputs() method also assigns default values for irradiance (clear sky), temperature (20 C), and wind speed (0 m/s) if these inputs are not provided.

Next, run_model() calls the wrapper methods for AOI loss, spectral loss, effective irradiance, cell temperature, DC power, AC power, and other losses. These methods are assigned to standard names, as described in the next section.

The methods called by run_model() store their results in a series of ModelChain attributes: times, solar_position, airmass, irradiance, total_irrad, effective_irradiance, weather, temps, aoi, aoi_modifier, spectral_modifier, dc, ac, losses.

In [40]: mc.run_model??
Signature: mc.run_model(times=None, weather=None)
Source:   
    def run_model(self, times=None, weather=None):
        """
        Run the model.

        Parameters
        ----------
        times : None or DatetimeIndex, default None
            Times at which to evaluate the model. Can be None if
            attribute `times` is already set.
        weather : None or DataFrame, default None
            If None, assumes air temperature is 20 C, wind speed is 0
            m/s and irradiation calculated from clear sky data. Column
            names must be 'wind_speed', 'temp_air', 'dni', 'ghi', 'dhi'.
            Do not pass incomplete irradiation data. Use method
            :py:meth:`~pvlib.modelchain.ModelChain.complete_irradiance`
            instead.

        Returns
        -------
        self

        Assigns attributes: times, solar_position, airmass, irradiance,
        total_irrad, effective_irradiance, weather, temps, aoi,
        aoi_modifier, spectral_modifier, dc, ac, losses.
        """

        self.prepare_inputs(times, weather)
        self.aoi_model()
        self.spectral_model()
        self.effective_irradiance_model()
        self.temp_model()
        self.dc_model()
        self.ac_model()
        self.losses_model()

        return self
File:      ~/checkouts/readthedocs.org/user_builds/wholmgren-pvlib-python-new/checkouts/extras/pvlib/modelchain.py
Type:      method

Finally, the complete_irradiance() method is available for calculating the full set of GHI, DNI, or DHI if only two of these three series are provided. The completed dataset can then be passed to run_model().

Wrapping methods into a unified API

Readers may notice that the source code of the ModelChain.run_model method is model-agnostic. ModelChain.run_model calls generic methods such as self.dc_model rather than a specific model such as singlediode. So how does the ModelChain.run_model know what models it’s supposed to run? The answer comes in two parts, and allows us to explore more of the ModelChain API along the way.

First, ModelChain has a set of methods that wrap the PVSystem methods that perform the calculations (or further wrap the pvsystem.py module’s functions). Each of these methods takes the same arguments (self) and sets the same attributes, thus creating a uniform API. For example, the ModelChain.pvwatts_dc method is shown below. Its only argument is self, and it sets the dc attribute.

In [41]: mc.pvwatts_dc??
Signature: mc.pvwatts_dc()
Docstring: <no docstring>
Source:   
    def pvwatts_dc(self):
        self.dc = self.system.pvwatts_dc(self.effective_irradiance,
                                         self.temps['temp_cell'])
        return self
File:      ~/checkouts/readthedocs.org/user_builds/wholmgren-pvlib-python-new/checkouts/extras/pvlib/modelchain.py
Type:      method

The ModelChain.pvwatts_dc method calls the pvwatts_dc method of the PVSystem object that we supplied using data that is stored in its own effective_irradiance and temps attributes. Then it assigns the result to the dc attribute of the ModelChain object. The code below shows a simple example of this.

# make the objects
In [42]: pvwatts_system = PVSystem(module_parameters={'pdc0': 240, 'gamma_pdc': -0.004})

In [43]: mc = ModelChain(pvwatts_system, location,
   ....:                 aoi_model='no_loss', spectral_model='no_loss')
   ....: 

# manually assign data to the attributes that ModelChain.pvwatts_dc will need.
# for standard workflows, run_model would assign these attributes.
In [44]: mc.effective_irradiance = pd.Series(1000, index=[pd.Timestamp('20170401 1200-0700')])

In [45]: mc.temps = pd.DataFrame({'temp_cell': 50, 'temp_module': 50}, index=[pd.Timestamp('20170401 1200-0700')])

# run ModelChain.pvwatts_dc and look at the result
In [46]: mc.pvwatts_dc();

In [47]: mc.dc
Out[47]: 
2017-04-01 12:00:00-07:00    216.0
dtype: float64

The ModelChain.sapm method works similarly to the ModelChain.pvwatts_dc method. It calls the PVSystem.sapm method using stored data, then assigns the result to the dc attribute. The ModelChain.sapm method differs from the ModelChain.pvwatts_dc method in three notable ways. First, the PVSystem.sapm method expects different units for effective irradiance, so ModelChain handles the conversion for us. Second, the PVSystem.sapm method (and the PVSystem.singlediode method) returns a DataFrame with current, voltage, and power parameters rather than a simple Series of power. Finally, this current and voltage information allows the SAPM and single diode model paths to support the concept of modules in series and parallel, which is handled by the PVSystem.scale_voltage_current_power method.

In [48]: mc.sapm??
Signature: mc.sapm()
Docstring: <no docstring>
Source:   
    def sapm(self):
        self.dc = self.system.sapm(self.effective_irradiance/1000.,
                                   self.temps['temp_cell'])

        self.dc = self.system.scale_voltage_current_power(self.dc)

        return self
File:      ~/checkouts/readthedocs.org/user_builds/wholmgren-pvlib-python-new/checkouts/extras/pvlib/modelchain.py
Type:      method
# make the objects
In [49]: sapm_system = PVSystem(module_parameters=sandia_module, inverter_parameters=cec_inverter)

In [50]: mc = ModelChain(sapm_system, location)

# manually assign data to the attributes that ModelChain.sapm will need.
# for standard workflows, run_model would assign these attributes.
In [51]: mc.effective_irradiance = pd.Series(1000, index=[pd.Timestamp('20170401 1200-0700')])

In [52]: mc.temps = pd.DataFrame({'temp_cell': 50, 'temp_module': 50}, index=[pd.Timestamp('20170401 1200-0700')])

# run ModelChain.sapm and look at the result
In [53]: mc.sapm();

In [54]: mc.dc
Out[54]: 
                              i_sc      i_mp    ...          i_x      i_xx
2017-04-01 12:00:00-07:00  5.14168  4.566863    ...     5.025377  3.219662

[1 rows x 7 columns]

We’ve established that the ModelChain.pvwatts_dc and ModelChain.sapm have the same API: they take the same arugments (self) and they both set the dc attribute.* Because the methods have the same API, we can call them in the same way. ModelChain includes a large number of methods that perform the same API-unification roles for each modeling step.

Again, so how does the ModelChain.run_model know which models it’s supposed to run?

At object construction, ModelChain assigns the desired model’s method (e.g. ModelChain.pvwatts_dc) to the corresponding generic attribute (e.g. ModelChain.dc_model) using a method described in the next section.

In [55]: pvwatts_system = PVSystem(module_parameters={'pdc0': 240, 'gamma_pdc': -0.004})

In [56]: mc = ModelChain(pvwatts_system, location,
   ....:                 aoi_model='no_loss', spectral_model='no_loss')
   ....: 

In [57]: mc.dc_model.__func__
Out[57]: <function pvlib.modelchain.ModelChain.pvwatts_dc(self)>

The ModelChain.run_model method can ignorantly call self.dc_module because the API is the same for all methods that may be assigned to this attribute.

* some readers may object that the API is not actually the same because the type of the dc attribute is different (Series vs. DataFrame)!

Inferring models

How does ModelChain infer the appropriate model types? ModelChain uses a series of methods (ModelChain.infer_dc_model, ModelChain.infer_ac_model, etc.) that examine the user-supplied PVSystem object. The inference methods use set logic to assign one of the model-specific methods, such as ModelChain.sapm or ModelChain.snlinverter, to the universal method names ModelChain.dc_model and ModelChain.ac_model. A few examples are shown below.

In [58]: mc.infer_dc_model??
Signature: mc.infer_dc_model()
Docstring: <no docstring>
Source:   
    def infer_dc_model(self):
        params = set(self.system.module_parameters.keys())
        if set(['A0', 'A1', 'C7']) <= params:
            return self.sapm, 'sapm'
        elif set(['a_ref', 'I_L_ref', 'I_o_ref', 'R_sh_ref',
                  'R_s', 'Adjust']) <= params:
            return self.cec, 'cec'
        elif set(['a_ref', 'I_L_ref', 'I_o_ref', 'R_sh_ref',
                  'R_s']) <= params:
            return self.desoto, 'desoto'
        elif set(['gamma_ref', 'mu_gamma', 'I_L_ref', 'I_o_ref',
                  'R_sh_ref', 'R_sh_0', 'R_sh_exp', 'R_s']) <= params:
            return self.pvsyst, 'pvsyst'
        elif set(['pdc0', 'gamma_pdc']) <= params:
            return self.pvwatts_dc, 'pvwatts'
        else:
            raise ValueError('could not infer DC model from '
                             'system.module_parameters')
File:      ~/checkouts/readthedocs.org/user_builds/wholmgren-pvlib-python-new/checkouts/extras/pvlib/modelchain.py
Type:      method
In [59]: mc.infer_ac_model??
Signature: mc.infer_ac_model()
Docstring: <no docstring>
Source:   
    def infer_ac_model(self):
        inverter_params = set(self.system.inverter_parameters.keys())
        module_params = set(self.system.module_parameters.keys())
        if set(['C0', 'C1', 'C2']) <= inverter_params:
            return self.snlinverter
        elif set(['ADRCoefficients']) <= inverter_params:
            return self.adrinverter
        elif set(['pdc0']) <= module_params:
            return self.pvwatts_inverter
        else:
            raise ValueError('could not infer AC model from '
                             'system.inverter_parameters')
File:      ~/checkouts/readthedocs.org/user_builds/wholmgren-pvlib-python-new/checkouts/extras/pvlib/modelchain.py
Type:      method

User-defined models

Users may also write their own functions and pass them as arguments to ModelChain. The first argument of the function must be a ModelChain instance. For example, the functions below implement the PVUSA model and a wrapper function appropriate for use with ModelChain. This follows the pattern of implementing the core models using the simplest possible functions, and then implementing wrappers to make them easier to use in specific applications. Of course, you could implement it in a single function if you wanted to.

In [60]: def pvusa(poa_global, wind_speed, temp_air, a, b, c, d):
   ....:     """
   ....:     Calculates system power according to the PVUSA equation
   ....: 

In [60]: def pvusa_mc_wrapper(mc):
   ....:     mc.dc = pvusa(mc.total_irrad['poa_global'], mc.weather['wind_speed'], mc.weather['temp_air'],
   ....:                   mc.system.module_parameters['a'], mc.system.module_parameters['b'],
   ....:                   mc.system.module_parameters['c'], mc.system.module_parameters['d'])
   ....: 

    # returning mc is optional, but enables method chaining
In [60]: def pvusa_ac_mc_wrapper(mc):
   ....:     mc.ac = mc.dc
   ....:     return mc
   ....: 
In [60]: module_parameters = {'a': 0.2, 'b': 0.00001, 'c': 0.001, 'd': -0.00005}

In [60]: pvusa_system = PVSystem(module_parameters=module_parameters)

In [60]: mc = ModelChain(pvusa_system, location,
   ....:                 dc_model=pvusa_mc_wrapper, ac_model=pvusa_ac_mc_wrapper,
   ....:                 aoi_model='no_loss', spectral_model='no_loss')
   ....: 

A ModelChain object uses Python’s functools.partial function to assign itself as the argument to the user-supplied functions.

In [60]: mc.dc_model.func

The end result is that ModelChain.run_model works as expected!

In [60]: mc.run_model(times=weather.index, weather=weather);

In [60]: mc.dc