diff --git a/ci/requirements-py36-min.yml b/ci/requirements-py36-min.yml index 31e64f1ba0..29f63c1be1 100644 --- a/ci/requirements-py36-min.yml +++ b/ci/requirements-py36-min.yml @@ -13,6 +13,7 @@ dependencies: - pytz - requests - pip: + - dataclasses - numpy==1.12.0 - pandas==0.22.0 - scipy==1.2.0 diff --git a/ci/requirements-py36.yml b/ci/requirements-py36.yml index b799da7393..fb37fe8404 100644 --- a/ci/requirements-py36.yml +++ b/ci/requirements-py36.yml @@ -27,5 +27,6 @@ dependencies: - siphon # conda-forge - statsmodels - pip: + - dataclasses - nrel-pysam>=2.0 - pvfactors==1.4.1 diff --git a/docs/sphinx/source/api.rst b/docs/sphinx/source/api.rst index 2ec4e3f8ae..f043894ee6 100644 --- a/docs/sphinx/source/api.rst +++ b/docs/sphinx/source/api.rst @@ -20,9 +20,10 @@ corresponding procedural code. location.Location pvsystem.PVSystem + pvsystem.Array tracking.SingleAxisTracker modelchain.ModelChain - + modelchain.ModelChainResult Solar Position ============== @@ -583,6 +584,12 @@ Functions to assist with setting up ModelChains to run modelchain.ModelChain.prepare_inputs modelchain.ModelChain.prepare_inputs_from_poa +Results +------- + +Output from the running the ModelChain is stored in the +:py:attr:`modelchain.ModelChain.results` attribute. For more +information see :py:class:`modelchain.ModelChainResult`. Attributes ---------- diff --git a/docs/sphinx/source/conf.py b/docs/sphinx/source/conf.py index 37e642c039..f01446e813 100644 --- a/docs/sphinx/source/conf.py +++ b/docs/sphinx/source/conf.py @@ -388,6 +388,10 @@ def get_linenos(obj): lines, start = inspect.getsourcelines(obj) except TypeError: # obj is an attribute or None return None, None + except OSError: # obj listing cannot be found + # This happens for methods that are not explicitly defined + # such as the __init__ method for a dataclass + return None, None else: return start, start + len(lines) - 1 diff --git a/docs/sphinx/source/forecasts.rst b/docs/sphinx/source/forecasts.rst index e477d00be2..a89904eccd 100644 --- a/docs/sphinx/source/forecasts.rst +++ b/docs/sphinx/source/forecasts.rst @@ -471,7 +471,7 @@ Here's the forecast plane of array irradiance... .. ipython:: python - mc.total_irrad.plot(); + mc.results.total_irrad.plot(); @savefig poa_irrad.png width=6in plt.ylabel('Plane of array irradiance ($W/m^2$)'); plt.legend(loc='best'); @@ -482,7 +482,7 @@ Here's the forecast plane of array irradiance... .. ipython:: python - mc.cell_temperature.plot(); + mc.results.cell_temperature.plot(); @savefig pv_temps.png width=6in plt.ylabel('Cell Temperature (C)'); @suppress @@ -492,7 +492,7 @@ Here's the forecast plane of array irradiance... .. ipython:: python - mc.ac.fillna(0).plot(); + mc.results.ac.fillna(0).plot(); plt.ylim(0, None); @savefig ac_power.png width=6in plt.ylabel('AC Power (W)'); diff --git a/docs/sphinx/source/introtutorial.rst b/docs/sphinx/source/introtutorial.rst index d134d1fe07..6aab5492db 100644 --- a/docs/sphinx/source/introtutorial.rst +++ b/docs/sphinx/source/introtutorial.rst @@ -165,7 +165,7 @@ by examining the parameters defined for the module. # model results (ac, dc) and intermediates (aoi, temps, etc.) # assigned as mc object attributes mc.run_model(weather) - annual_energy = mc.ac.sum() + annual_energy = mc.results.ac.sum() energies[name] = annual_energy energies = pd.Series(energies) diff --git a/docs/sphinx/source/pvsystem.rst b/docs/sphinx/source/pvsystem.rst index 52b59746e7..58d943ac79 100644 --- a/docs/sphinx/source/pvsystem.rst +++ b/docs/sphinx/source/pvsystem.rst @@ -10,13 +10,27 @@ PVSystem from pvlib import pvsystem -The :py:class:`~pvlib.pvsystem.PVSystem` class wraps many of the -functions in the :py:mod:`~pvlib.pvsystem` module. This simplifies the -API by eliminating the need for a user to specify arguments such as -module and inverter properties when calling PVSystem methods. -:py:class:`~pvlib.pvsystem.PVSystem` is not better or worse than the -functions it wraps -- it is simply an alternative way of organizing -your data and calculations. +The :py:class:`~pvlib.pvsystem.PVSystem` represents one inverter and the +PV modules that supply DC power to the inverter. A PV system may be on fixed +mounting or single axis trackers. The :py:class:`~pvlib.pvsystem.PVSystem` +is supported by the :py:class:`~pvlib.pvsystem.Array` which represents the +PV modules in the :py:class:`~pvlib.pvsystem.PVSystem`. An instance of +:py:class:`~pvlib.pvsystem.PVSystem` has a single inverter, but can have +multiple instances of :py:class:`~pvlib.pvsystem.Array`. An instance of the +Array class represents a group of modules with the same orientation and +module type. Different instances of Array can have different tilt, orientation, +and number or type of modules. + +The :py:class:`~pvlib.pvsystem.PVSystem` class methods wrap many of the +functions in the :py:mod:`~pvlib.pvsystem` module. Similarly, +:py:class:`~pvlib.pvsystem.Array` wraps several functions with its class +methods. Methods that wrap functions have similar names as the wrapped functions. +This practice simplifies the API for :py:class:`~pvlib.pvsystem.PVSystem` +and :py:class:`~pvlib.pvsystem.Array` methods by eliminating the need to specify +arguments that are stored as attributes of these classes, such as +module and inverter properties. Using :py:class:`~pvlib.pvsystem.PVSystem` +is not better or worse than using the functions it wraps -- it is an +alternative way of organizing your data and calculations. This guide aims to build understanding of the PVSystem class. It assumes basic familiarity with object-oriented code in Python, but most @@ -40,26 +54,29 @@ data that influences the PV system (e.g. the weather). The data that represents the PV system is *intrinsic*. The data that influences the PV system is *extrinsic*. -Intrinsic data is stored in object attributes. For example, the data -that describes a PV system's module parameters is stored in -`PVSystem.module_parameters`. +Intrinsic data is stored in object attributes. For example, the parameters +that describe a PV system's modules and inverter are stored in +`PVSystem.module_parameters` and `PVSystem.inverter_parameters`. .. ipython:: python - module_parameters = {'pdc0': 10, 'gamma_pdc': -0.004} - system = pvsystem.PVSystem(module_parameters=module_parameters) - print(system.module_parameters) + module_parameters = {'pdc0': 5000, 'gamma_pdc': -0.004} + inverter_parameters = {'pdc0': 5000, 'eta_inv_nom': 0.96} + system = pvsystem.PVSystem(inverter_parameters=inverter_parameters, + module_parameters=module_parameters) + print(system.inverter_parameters) + -Extrinsic data is passed to a PVSystem as method arguments. For example, +Extrinsic data is passed to the arguments of PVSystem methods. For example, the :py:meth:`~pvlib.pvsystem.PVSystem.pvwatts_dc` method accepts extrinsic data irradiance and temperature. .. ipython:: python - pdc = system.pvwatts_dc(1000, 30) + pdc = system.pvwatts_dc(g_poa_effective=1000, temp_cell=30) print(pdc) -Methods attached to a PVSystem object wrap corresponding functions in +Methods attached to a PVSystem object wrap the corresponding functions in :py:mod:`~pvlib.pvsystem`. The methods simplify the argument list by using data stored in the PVSystem attributes. Compare the :py:meth:`~pvlib.pvsystem.PVSystem.pvwatts_dc` method signature to the @@ -90,71 +107,200 @@ Multiple methods may pull data from the same attribute. For example, the as well as the incidence angle modifier methods. +.. _multiarray: + +PVSystem and Arrays +------------------- + +The PVSystem class can represent a PV system with a single array of modules, +or with multiple arrays. For a PV system with a single array, the parameters +that describe the array can be provided directly to the PVSystem instand. +For example, the parameters that describe the array's modules are can be +passed to `PVSystem.module_parameters`: + +.. ipython:: python + + module_parameters = {'pdc0': 5000, 'gamma_pdc': -0.004} + inverter_parameters = {'pdc0': 5000, 'eta_inv_nom': 0.96} + system = pvsystem.PVSystem(module_parameters=module_parameters, + inverter_parameters=inverter_parameters) + print(system.module_parameters) + print(system.inverter_parameters) + + +A system with multiple arrays is specified by passing a list of +:py:class:`~pvlib.pvsystem.Array` to the :py:class:`~pvlib.pvsystem.PVSystem` +constructor. For a PV system with several arrays, the module parameters are +provided for each array, and the arrays are provided to +:py:class:`~pvlib.pvsystem.PVSystem` as a tuple or list of instances of +:py:class:`~pvlib.pvsystem.Array`: + +.. ipython:: python + + module_parameters = {'pdc0': 5000, 'gamma_pdc': -0.004} + array_one = pvsystem.Array(module_parameters=module_parameters) + array_two = pvsystem.Array(module_parameters=module_parameters) + system_two_arrays = pvsystem.PVSystem(arrays=[array_one, array_two], + inverter_parameters=inverter_parameters) + print(system_two_arrays.module_parameters) + print(system_two_arrays.inverter_parameters) + +Note that in the case of a PV system with multiple arrays, the +:py:class:`~pvlib.pvsystem.PVSystem` attribute `module_parameters` contains +a tuple with the `module_parameters` for each array. + +The :py:class:`~pvlib.pvsystem.Array` class includes those +:py:class:`~pvlib.pvsystem.PVSystem` attributes that may vary from array +to array. These attributes include `surface_tilt`, `surface_azimuth`, +`module_parameters`, `temperature_model_parameters`, `modules_per_string`, +`strings_per_inverter`, `albedo`, `surface_type`, `module_type`, and +`racking_model`. + +When instantiating a :py:class:`~pvlib.pvsystem.PVSystem` with a tuple or list +of :py:class:`~pvlib.pvsystem.Array`, each array parameter must be specified for +each instance of :py:class:`~pvlib.pvsystem.Array`. For example, if all arrays +are at the same tilt you must still specify the tilt value for +each array. When using :py:class:`~pvlib.pvsystem.Array` you shouldn't +also pass any array attributes to the `PVSystem` attributes; when Array instances +are provided to PVSystem, the PVSystem attributes are ignored. + + .. _pvsystemattributes: PVSystem attributes ------------------- -Here we review the most commonly used PVSystem attributes. Please see -the :py:class:`~pvlib.pvsystem.PVSystem` class documentation for a -comprehensive list. +Here we review the most commonly used PVSystem and Array attributes. +Please see the :py:class:`~pvlib.pvsystem.PVSystem` and +:py:class:`~pvlib.pvsystem.Array` class documentation for a +comprehensive list of attributes. + + +Tilt and azimuth +^^^^^^^^^^^^^^^^ -The first PVSystem parameters are `surface_tilt` and `surface_azimuth`. -These parameters are used in PVSystem methods such as -:py:meth:`~pvlib.pvsystem.PVSystem.get_aoi` and -:py:meth:`~pvlib.pvsystem.PVSystem.get_irradiance`. Angle of incidence -(AOI) calculations require `surface_tilt`, `surface_azimuth` and also -the sun position. The :py:meth:`~pvlib.pvsystem.PVSystem.get_aoi` method -uses the `surface_tilt` and `surface_azimuth` attributes in its PVSystem -object, and so requires only `solar_zenith` and `solar_azimuth` as -arguments. +The first parameters which describe the DC part of a PV system are the tilt +and azimuth of the modules. In the case of a PV system with a single array, +these parameters can be specified using the `PVSystem.surface_tilt` and +`PVSystem.surface_azimuth` attributes. .. ipython:: python - # 20 deg tilt, south-facing - system = pvsystem.PVSystem(surface_tilt=20, surface_azimuth=180) - print(system.surface_tilt, system.surface_azimuth) + # single south-facing array at 20 deg tilt + system_one_array = pvsystem.PVSystem(surface_tilt=20, surface_azimuth=180) + print(system_one_array.surface_tilt, system_one_array.surface_azimuth) + + +In the case of a PV system with several arrays, the parameters are specified +for each array using the attributes `Array.surface_tilt` and `Array.surface_azimuth`. + +.. ipython:: python + + array_one = pvsystem.Array(surface_tilt=30, surface_azimuth=90) + print(array_one.surface_tilt, array_one.surface_azimuth) + array_two = pvsystem.Array(surface_tilt=30, surface_azimuth=220) + system = pvsystem.PVSystem(arrays=[array_one, array_two]) + system.num_arrays + system.surface_tilt + system.surface_azimuth + + +The `surface_tilt` and `surface_azimuth` attributes are used in PVSystem +(or Array) methods such as :py:meth:`~pvlib.pvsystem.PVSystem.get_aoi` or +:py:meth:`~pvlib.pvsystem.Array.get_aoi`. The angle of incidence (AOI) +calculations require `surface_tilt`, `surface_azimuth` and the extrinsic +sun position. The `PVSystem` method :py:meth:`~pvlib.pvsystem.PVSystem.get_aoi` +uses the `surface_tilt` and `surface_azimuth` attributes from the +:py:class:`pvlib.pvsystem.PVSystem` instance, and so requires only `solar_zenith` +and `solar_azimuth` as arguments. + +.. ipython:: python + + # single south-facing array at 20 deg tilt + system_one_array = pvsystem.PVSystem(surface_tilt=20, surface_azimuth=180) + print(system_one_array.surface_tilt, system_one_array.surface_azimuth) # call get_aoi with solar_zenith, solar_azimuth - aoi = system.get_aoi(30, 180) + aoi = system_one_array.get_aoi(solar_zenith=30, solar_azimuth=180) print(aoi) +The `Array` method :py:meth:`~pvlib.pvsystem.Array.get_aoi` +operates in a similar manner. + +.. ipython:: python + + # two arrays each at 30 deg tilt with different facing + array_one = pvsystem.Array(surface_tilt=30, surface_azimuth=90) + array_one_aoi = array_one.get_aoi(solar_zenith=30, solar_azimuth=180) + print(array_one_aoi) + + +The `PVSystem` method :py:meth:`~pvlib.pvsystem.PVSystem.get_aoi` +operates on all `Array` instances in the `PVSystem`, whereas the the +`Array` method operates only on its `Array` instance. + +.. ipython:: python + + array_two = pvsystem.Array(surface_tilt=30, surface_azimuth=220) + system_multiarray = pvsystem.PVSystem(arrays=[array_one, array_two]) + print(system_multiarray.num_arrays) + # call get_aoi with solar_zenith, solar_azimuth + aoi = system_multiarray.get_aoi(solar_zenith=30, solar_azimuth=180) + print(aoi) + + +As a reminder, when the PV system includes more than one array, the output of the +`PVSystem` method :py:meth:`~pvlib.pvsystem.PVSystem.get_aoi` is a *tuple* with +the order of the elements corresponding to the order of the arrays. + +Other `PVSystem` and `Array` methods operate in a similar manner. When a `PVSystem` +method needs input for each array, the input is provided in a tuple: + +.. ipython:: python + + aoi = system.get_aoi(solar_zenith=30, solar_azimuth=180) + print(aoi) + system_multiarray.get_iam(aoi) + + +Module and inverter parameters +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + `module_parameters` and `inverter_parameters` contain the data necessary for computing DC and AC power using one of the available -PVSystem methods. These are typically specified using data from -the :py:func:`~pvlib.pvsystem.retrieve_sam` function: +PVSystem methods. Values for these attributes can be obtained from databases +included with pvlib python by using the :py:func:`~pvlib.pvsystem.retrieve_sam` function: .. ipython:: python + # Load the database of CEC module model parameters + modules = pvsystem.retrieve_sam('cecmod') # retrieve_sam returns a dict. the dict keys are module names, # and the values are model parameters for that module - modules = pvsystem.retrieve_sam('cecmod') module_parameters = modules['Canadian_Solar_Inc__CS5P_220M'] + # Load the database of CEC inverter model parameters inverters = pvsystem.retrieve_sam('cecinverter') inverter_parameters = inverters['ABB__MICRO_0_25_I_OUTD_US_208__208V_'] - system = pvsystem.PVSystem(module_parameters=module_parameters, inverter_parameters=inverter_parameters) + system_one_array = pvsystem.PVSystem(module_parameters=module_parameters, + inverter_parameters=inverter_parameters) The module and/or inverter parameters can also be specified manually. -This is useful for specifying modules and inverters that are not -included in the supplied databases. It is also useful for specifying -systems for use with the PVWatts models, as demonstrated in -:ref:`designphilosophy`. +This is useful for modules or inverters that are not +included in the supplied databases, or when using the PVWatts model, +as demonstrated in :ref:`designphilosophy`. -The `losses_parameters` attribute contains data that may be used with -methods that calculate system losses. At present, these methods include -only :py:meth:`PVSystem.pvwatts_losses -` and -:py:func:`pvsystem.pvwatts_losses `, but -we hope to add more related functions and methods in the future. + +Module strings +^^^^^^^^^^^^^^ The attributes `modules_per_string` and `strings_per_inverter` are used in the :py:meth:`~pvlib.pvsystem.PVSystem.scale_voltage_current_power` method. Some DC power models in :py:class:`~pvlib.modelchain.ModelChain` automatically call this method and make use of these attributes. As an -example, consider a system with 35 modules arranged into 5 strings of 7 -modules each. +example, consider a system with a single array comprising 35 modules +arranged into 5 strings of 7 modules each. .. ipython:: python @@ -166,6 +312,16 @@ modules each. print(data_scaled) +Losses +^^^^^^ + +The `losses_parameters` attribute contains data that may be used with +methods that calculate system losses. At present, these methods include +only :py:meth:`PVSystem.pvwatts_losses` and +:py:func:`pvsystem.pvwatts_losses`, but we hope to add more related functions +and methods in the future. + + .. _sat: SingleAxisTracker @@ -176,4 +332,4 @@ The :py:class:`~pvlib.tracking.SingleAxisTracker` is a subclass of includes a few more keyword arguments and attributes that are specific to trackers, plus the :py:meth:`~pvlib.tracking.SingleAxisTracker.singleaxis` method. It also -overrides the `get_aoi` and `get_irradiance` methods. \ No newline at end of file +overrides the `get_aoi` and `get_irradiance` methods. diff --git a/docs/sphinx/source/whatsnew/v0.9.0.rst b/docs/sphinx/source/whatsnew/v0.9.0.rst new file mode 100644 index 0000000000..f06b1d8140 --- /dev/null +++ b/docs/sphinx/source/whatsnew/v0.9.0.rst @@ -0,0 +1,71 @@ +.. _whatsnew_0900: + +v0.9.0 (MONTH DAY YEAR) +----------------------- + +Breaking changes +~~~~~~~~~~~~~~~~ + + +Deprecations +~~~~~~~~~~~~ +* The following ``ModelChain`` attributes are deprecated. They have been moved + to the :py:class:`~pvlib.modelchain.ModelChainResult` class that is + accessible via ``ModelChain.results`` + * ``ModelChain.ac`` + * ``ModelChain.airmass`` + * ``ModelChain.aoi`` + * ``ModelChain.aoi_modifier`` + * ``ModelChain.cell_temperature`` + * ``ModelChain.dc`` + * ``ModelChain.diode_params`` + * ``ModelChain.effective_irradiance`` + * ``ModelChain.solar_position`` + * ``ModelChain.spectral_modifier`` + * ``ModelChain.total_irrad`` + * ``ModelChain.tracking`` + +Enhancements +~~~~~~~~~~~~ +* In :py:class:`~pvlib.modelchain.ModelChain`, attributes which contain + output of models are now collected into ``ModelChain.results``. + (:pull:`1076`, :issue:`1067`) +* Added :py:class:`~pvlib.pvsystem.Array` class to represent an array of + modules separately from a :py:class:`~pvlib.pvsystem.PVSystem`. + (:pull:`1076`, :issue:`1067`) +* Added capability for modeling a PV system with multiple arrays in + :py:class:`~pvlib.pvsystem.PVSystem`. Updates the ``PVSystem`` API + to operate on and return tuples where each element of the tuple corresponds + to the input or output for a specific ``Array``. (:pull:`1076`, + :issue:`1067`) +* Support for systems with multiple ``Arrays`` added to + :py:class:`~pvlib.modelchain.ModelChain`. This includes substantial API + enhancements for accepting different weather input for each ``Array`` in the + system. (:pull:`1076`, :issue:`1067`) +* Support for :py:func:`~pvlib.inverter.sandia_multi` added to + :py:class:`~pvlib.pvsystem.PVSystem` and + :py:class:`~pvlib.modelchain.ModelChain` (as ``ac_model='sandia_multi'``). + (:pull:`1076`, :issue:`1067`) + +Bug fixes +~~~~~~~~~ + +Testing +~~~~~~~ + +Documentation +~~~~~~~~~~~~~ + +Requirements +~~~~~~~~~~~~ +* ``dataclasses`` is required for python 3.6 + +Contributors +~~~~~~~~~~~~ +* Will Holmgren (:ghuser:`wholmgren`) +* Cliff Hansen (:ghuser:`cwhanse`) +* Will Vining (:ghuser:`wfvining`) +* Anton Driesse (:ghuser:`adriesse`) +* Mark Mikofski (:ghuser:`mikofski`) +* Nate Croft (:ghuser:`ncroft-b4`) +* Kevin Anderson (:ghuser:`kanderso-nrel`) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 5c9ad84b37..c5414fdc12 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -7,8 +7,11 @@ """ from functools import partial +import itertools import warnings import pandas as pd +from dataclasses import dataclass, field +from typing import Union, Tuple, Optional, TypeVar from pvlib import (atmosphere, clearsky, inverter, pvsystem, solarposition, temperature, tools) @@ -260,11 +263,36 @@ def get_orientation(strategy, **kwargs): return surface_tilt, surface_azimuth +@dataclass +class ModelChainResult: + _T = TypeVar('T') + PerArray = Union[_T, Tuple[_T, ...]] + """Type for fields that vary between arrays""" + # system-level information + solar_position: Optional[pd.DataFrame] = field(default=None) + airmass: Optional[pd.DataFrame] = field(default=None) + ac: Optional[pd.Series] = field(default=None) + # per DC array information + tracking: Optional[pd.DataFrame] = field(default=None) + total_irrad: Optional[PerArray[pd.DataFrame]] = field(default=None) + aoi: Optional[PerArray[pd.Series]] = field(default=None) + aoi_modifier: Optional[PerArray[pd.Series]] = field(default=None) + spectral_modifier: Optional[PerArray[pd.Series]] = field(default=None) + cell_temperature: Optional[PerArray[pd.Series]] = field(default=None) + effective_irradiance: Optional[PerArray[pd.Series]] = field(default=None) + dc: Optional[PerArray[Union[pd.Series, pd.DataFrame]]] = \ + field(default=None) + diode_params: Optional[PerArray[pd.DataFrame]] = field(default=None) + + class ModelChain: """ The ModelChain class to provides a standardized, high-level interface for all of the modeling steps necessary for calculating PV - power from a time series of weather inputs. + power from a time series of weather inputs. The same models are applied + to all ``pvsystem.Array`` objects, so each Array must contain the + appropriate model parameters. For example, if ``dc_model='pvwatts'``, + then each ``Array.module_parameters`` must contain ``'pdc0'``. See https://pvlib-python.readthedocs.io/en/stable/modelchain.html for examples. @@ -335,6 +363,12 @@ class ModelChain: Name of ModelChain instance. """ + # list of deprecated attributes + _deprecated_attrs = ['solar_position', 'airmass', 'total_irrad', + 'aoi', 'aoi_modifier', 'spectral_modifier', + 'cell_temperature', 'effective_irradiance', + 'dc', 'ac', 'diode_params', 'tracking'] + def __init__(self, system, location, orientation_strategy=None, clearsky_model='ineichen', @@ -366,7 +400,8 @@ def __init__(self, system, location, self.weather = None self.times = None - self.solar_position = None + + self.results = ModelChainResult() if kwargs: warnings.warn( @@ -374,6 +409,27 @@ def __init__(self, system, location, 'removed in v0.9', pvlibDeprecationWarning ) + def __getattr__(self, key): + if key in ModelChain._deprecated_attrs: + msg = f'ModelChain.{key} is deprecated and will' \ + f' be removed in v0.10. Use' \ + f' ModelChain.results.{key} instead' + warnings.warn(msg, pvlibDeprecationWarning) + return getattr(self.results, key) + # __getattr__ is only called if __getattribute__ fails. + # In that case we should check if key is a deprecated attribute, + # and fail with an AttributeError if it is not. + raise AttributeError + + def __setattr__(self, key, value): + if key in ModelChain._deprecated_attrs: + msg = f'ModelChain.{key} is deprecated from v0.9. Use' \ + f' ModelChain.results.{key} instead' + warnings.warn(msg, pvlibDeprecationWarning) + setattr(self.results, key, value) + else: + super().__setattr__(key, value) + @classmethod def with_pvwatts(cls, system, location, orientation_strategy=None, @@ -589,12 +645,14 @@ def dc_model(self, model): model = model.lower() if model in _DC_MODEL_PARAMS.keys(): # validate module parameters - missing_params = (_DC_MODEL_PARAMS[model] - - set(self.system.module_parameters.keys())) + missing_params = ( + _DC_MODEL_PARAMS[model] - + _common_keys(self.system.module_parameters)) if missing_params: # some parameters are not in module.keys() raise ValueError(model + ' selected for the DC model but ' - 'one or more required parameters are ' - 'missing : ' + str(missing_params)) + 'one or more Arrays are missing ' + 'one or more required parameters ' + ' : ' + str(missing_params)) if model == 'sapm': self._dc_model = self.sapm elif model == 'desoto': @@ -611,8 +669,8 @@ def dc_model(self, model): self._dc_model = partial(model, self) def infer_dc_model(self): - """Infer DC power model from system attributes.""" - params = set(self.system.module_parameters.keys()) + """Infer DC power model from Array module parameters.""" + params = _common_keys(self.system.module_parameters) if {'A0', 'A1', 'C7'} <= params: return self.sapm, 'sapm' elif {'a_ref', 'I_L_ref', 'I_o_ref', 'R_sh_ref', 'R_s', @@ -632,31 +690,40 @@ def infer_dc_model(self): 'set the model with the dc_model kwarg.') def sapm(self): - self.dc = self.system.sapm(self.effective_irradiance, - self.cell_temperature) + self.results.dc = self.system.sapm(self.results.effective_irradiance, + self.results.cell_temperature) - self.dc = self.system.scale_voltage_current_power(self.dc) + self.results.dc = self.system.scale_voltage_current_power( + self.results.dc) return self def _singlediode(self, calcparams_model_function): - (photocurrent, saturation_current, resistance_series, - resistance_shunt, nNsVth) = ( - calcparams_model_function(self.effective_irradiance, - self.cell_temperature)) - - self.diode_params = pd.DataFrame({'I_L': photocurrent, - 'I_o': saturation_current, - 'R_s': resistance_series, - 'R_sh': resistance_shunt, - 'nNsVth': nNsVth}) - - self.dc = self.system.singlediode( - photocurrent, saturation_current, resistance_series, - resistance_shunt, nNsVth) - - self.dc = self.system.scale_voltage_current_power(self.dc).fillna(0) - + def _make_diode_params(photocurrent, saturation_current, + resistance_series, resistance_shunt, + nNsVth): + return pd.DataFrame( + {'I_L': photocurrent, 'I_o': saturation_current, + 'R_s': resistance_series, 'R_sh': resistance_shunt, + 'nNsVth': nNsVth} + ) + params = calcparams_model_function(self.results.effective_irradiance, + self.results.cell_temperature, + unwrap=False) + self.results.diode_params = tuple(itertools.starmap( + _make_diode_params, params)) + self.results.dc = tuple(itertools.starmap( + self.system.singlediode, params)) + self.results.dc = self.system.scale_voltage_current_power( + self.results.dc, + unwrap=False + ) + self.results.dc = tuple(dc.fillna(0) for dc in self.results.dc) + # If the system has one Array, unwrap the single return value + # to preserve the original behavior of ModelChain + if self.system.num_arrays == 1: + self.results.diode_params = self.results.diode_params[0] + self.results.dc = self.results.dc[0] return self def desoto(self): @@ -669,8 +736,8 @@ def pvsyst(self): return self._singlediode(self.system.calcparams_pvsyst) def pvwatts_dc(self): - self.dc = self.system.pvwatts_dc(self.effective_irradiance, - self.cell_temperature) + self.results.dc = self.system.pvwatts_dc( + self.results.effective_irradiance, self.results.cell_temperature) return self @property @@ -691,6 +758,8 @@ def ac_model(self, model): " ac_model = 'sandia' instead.", pvlibDeprecationWarning) self._ac_model = self.snlinverter + elif model == 'sandia_multi': + self._ac_model = self.sandia_multi_inverter elif model in ['adr', 'adrinverter']: if model == 'adrinverter': warnings.warn("ac_model = 'adrinverter' is deprecated and" @@ -708,28 +777,48 @@ def ac_model(self, model): def infer_ac_model(self): """Infer AC power model from system attributes.""" inverter_params = set(self.system.inverter_parameters.keys()) - if {'C0', 'C1', 'C2'} <= inverter_params: + if self.system.num_arrays > 1: + return self._infer_ac_model_multi(inverter_params) + if _snl_params(inverter_params): return self.snlinverter - elif {'ADRCoefficients'} <= inverter_params: + if _adr_params(inverter_params): return self.adrinverter - elif {'pdc0'} <= inverter_params: + if _pvwatts_params(inverter_params): return self.pvwatts_inverter - else: - raise ValueError('could not infer AC model from ' - 'system.inverter_parameters. Check ' - 'system.inverter_parameters or explicitly ' - 'set the model with the ac_model kwarg.') + raise ValueError('could not infer AC model from ' + 'system.inverter_parameters. Check ' + 'system.inverter_parameters or explicitly ' + 'set the model with the ac_model kwarg.') + + def _infer_ac_model_multi(self, inverter_params): + if _snl_params(inverter_params): + return self.sandia_multi_inverter + raise ValueError('could not infer multi-array AC model from ' + 'system.inverter_parameters. Not all ac models ' + 'support systems with mutiple Arrays. ' + 'Only sandia_multi supports multiple ' + 'Arrays. Check system.inverter_parameters or ' + 'explicitly set the model with the ac_model kwarg.') + + def sandia_multi_inverter(self): + self.results.ac = self.system.sandia_multi( + _tuple_from_dfs(self.results.dc, 'v_mp'), + _tuple_from_dfs(self.results.dc, 'p_mp') + ) + return self def snlinverter(self): - self.ac = self.system.snlinverter(self.dc['v_mp'], self.dc['p_mp']) + self.results.ac = self.system.snlinverter(self.results.dc['v_mp'], + self.results.dc['p_mp']) return self def adrinverter(self): - self.ac = self.system.adrinverter(self.dc['v_mp'], self.dc['p_mp']) + self.results.ac = self.system.adrinverter(self.results.dc['v_mp'], + self.results.dc['p_mp']) return self def pvwatts_inverter(self): - self.ac = self.system.pvwatts_ac(self.dc).fillna(0) + self.results.ac = self.system.pvwatts_ac(self.results.dc).fillna(0) return self @property @@ -758,7 +847,7 @@ def aoi_model(self, model): self._aoi_model = partial(model, self) def infer_aoi_model(self): - params = set(self.system.module_parameters.keys()) + params = _common_keys(self.system.module_parameters) if {'K', 'L', 'n'} <= params: return self.physical_aoi_loss elif {'B5', 'B4', 'B3', 'B2', 'B1', 'B0'} <= params: @@ -770,30 +859,38 @@ def infer_aoi_model(self): else: raise ValueError('could not infer AOI model from ' 'system.module_parameters. Check that the ' - 'system.module_parameters contain parameters for ' + 'module_parameters for all Arrays in ' + 'system.arrays contain parameters for ' 'the physical, aoi, ashrae or martin_ruiz model; ' 'explicitly set the model with the aoi_model ' 'kwarg; or set aoi_model="no_loss".') def ashrae_aoi_loss(self): - self.aoi_modifier = self.system.get_iam(self.aoi, iam_model='ashrae') + self.results.aoi_modifier = self.system.get_iam( + self.results.aoi, iam_model='ashrae') return self def physical_aoi_loss(self): - self.aoi_modifier = self.system.get_iam(self.aoi, iam_model='physical') + self.results.aoi_modifier = self.system.get_iam(self.results.aoi, + iam_model='physical') return self def sapm_aoi_loss(self): - self.aoi_modifier = self.system.get_iam(self.aoi, iam_model='sapm') + self.results.aoi_modifier = self.system.get_iam(self.results.aoi, + iam_model='sapm') return self def martin_ruiz_aoi_loss(self): - self.aoi_modifier = self.system.get_iam(self.aoi, - iam_model='martin_ruiz') + self.results.aoi_modifier = self.system.get_iam( + self.results.aoi, + iam_model='martin_ruiz') return self def no_aoi_loss(self): - self.aoi_modifier = 1.0 + if self.system.num_arrays == 1: + self.results.aoi_modifier = 1.0 + else: + self.results.aoi_modifier = (1.0,) * self.system.num_arrays return self @property @@ -819,7 +916,7 @@ def spectral_model(self, model): def infer_spectral_model(self): """Infer spectral model from system attributes.""" - params = set(self.system.module_parameters.keys()) + params = _common_keys(self.system.module_parameters) if {'A4', 'A3', 'A2', 'A1', 'A0'} <= params: return self.sapm_spectral_loss elif ((('Technology' in params or @@ -830,24 +927,28 @@ def infer_spectral_model(self): else: raise ValueError('could not infer spectral model from ' 'system.module_parameters. Check that the ' - 'system.module_parameters contain valid ' + 'module_parameters for all Arrays in ' + 'system.arrays contain valid ' 'first_solar_spectral_coefficients, a valid ' 'Material or Technology value, or set ' 'spectral_model="no_loss".') def first_solar_spectral_loss(self): - self.spectral_modifier = self.system.first_solar_spectral_loss( + self.results.spectral_modifier = self.system.first_solar_spectral_loss( self.weather['precipitable_water'], - self.airmass['airmass_absolute']) + self.results.airmass['airmass_absolute']) return self def sapm_spectral_loss(self): - self.spectral_modifier = self.system.sapm_spectral_loss( - self.airmass['airmass_absolute']) + self.results.spectral_modifier = self.system.sapm_spectral_loss( + self.results.airmass['airmass_absolute']) return self def no_spectral_loss(self): - self.spectral_modifier = 1 + if self.system.num_arrays == 1: + self.results.spectral_modifier = 1 + else: + self.results.spectral_modifier = (1,) * self.system.num_arrays return self @property @@ -874,16 +975,19 @@ def temperature_model(self, model): name_from_params = self.infer_temperature_model().__name__ if self._temperature_model.__name__ != name_from_params: raise ValueError( - 'Temperature model {} is inconsistent with ' - 'PVsystem.temperature_model_parameters {}'.format( - self._temperature_model.__name__, - self.system.temperature_model_parameters)) + f'Temperature model {self._temperature_model.__name__} is ' + f'inconsistent with PVSystem temperature model ' + f'parameters. All Arrays in system.arrays must have ' + f'consistent parameters. Common temperature model ' + f'parameters: ' + f'{_common_keys(self.system.temperature_model_parameters)}' + ) else: self._temperature_model = partial(model, self) def infer_temperature_model(self): """Infer temperature model from system attributes.""" - params = set(self.system.temperature_model_parameters.keys()) + params = _common_keys(self.system.temperature_model_parameters) # remove or statement in v0.9 if {'a', 'b', 'deltaT'} <= params or ( not params and self.system.racking_model is None @@ -896,33 +1000,46 @@ def infer_temperature_model(self): elif {'noct_installed'} <= params: return self.fuentes_temp else: - raise ValueError('could not infer temperature model from ' - 'system.temperature_module_parameters {}.' - .format(self.system.temperature_model_parameters)) + raise ValueError(f'could not infer temperature model from ' + f'system.temperature_model_parameters. Check ' + f'that all Arrays in system.arrays have ' + f'parameters for the same temperature model. ' + f'Common temperature model parameters: {params}.') + + def _set_celltemp(self, model): + """Set self.results.cell_temperature using the given cell + temperature model. - def sapm_temp(self): - self.cell_temperature = self.system.sapm_celltemp( - self.total_irrad['poa_global'], self.weather['temp_air'], - self.weather['wind_speed']) + Parameters + ---------- + model : function + A function that takes POA irradiance, air temperature, and + wind speed and returns cell temperature. `model` must accept + tuples or single values for each parameter where each element of + the tuple is the value for a different array in the system + (see :py:class:`pvlib.pvsystem.PVSystem` for more information). + + Returns + ------- + self + """ + poa = _tuple_from_dfs(self.results.total_irrad, 'poa_global') + temp_air = _tuple_from_dfs(self.weather, 'temp_air') + wind_speed = _tuple_from_dfs(self.weather, 'wind_speed') + self.results.cell_temperature = model(poa, temp_air, wind_speed) return self + def sapm_temp(self): + return self._set_celltemp(self.system.sapm_celltemp) + def pvsyst_temp(self): - self.cell_temperature = self.system.pvsyst_celltemp( - self.total_irrad['poa_global'], self.weather['temp_air'], - self.weather['wind_speed']) - return self + return self._set_celltemp(self.system.pvsyst_celltemp) def faiman_temp(self): - self.cell_temperature = self.system.faiman_celltemp( - self.total_irrad['poa_global'], self.weather['temp_air'], - self.weather['wind_speed']) - return self + return self._set_celltemp(self.system.faiman_celltemp) def fuentes_temp(self): - self.cell_temperature = self.system.fuentes_celltemp( - self.total_irrad['poa_global'], self.weather['temp_air'], - self.weather['wind_speed']) - return self + return self._set_celltemp(self.system.fuentes_celltemp) @property def losses_model(self): @@ -948,7 +1065,11 @@ def infer_losses_model(self): def pvwatts_losses(self): self.losses = (100 - self.system.pvwatts_losses()) / 100. - self.dc *= self.losses + if self.system.num_arrays > 1: + for dc in self.results.dc: + dc *= self.losses + else: + self.results.dc *= self.losses return self def no_extra_losses(self): @@ -956,10 +1077,23 @@ def no_extra_losses(self): return self def effective_irradiance_model(self): - fd = self.system.module_parameters.get('FD', 1.) - self.effective_irradiance = self.spectral_modifier * ( - self.total_irrad['poa_direct']*self.aoi_modifier + - fd*self.total_irrad['poa_diffuse']) + def _eff_irrad(module_parameters, total_irrad, spect_mod, aoi_mod): + fd = module_parameters.get('FD', 1.) + return spect_mod * (total_irrad['poa_direct'] * aoi_mod + + fd * total_irrad['poa_diffuse']) + if isinstance(self.results.total_irrad, tuple): + self.results.effective_irradiance = tuple( + _eff_irrad(array.module_parameters, ti, sm, am) for + array, ti, sm, am in zip( + self.system.arrays, self.results.total_irrad, + self.results.spectral_modifier, self.results.aoi_modifier)) + else: + self.results.effective_irradiance = _eff_irrad( + self.system.module_parameters, + self.results.total_irrad, + self.results.spectral_modifier, + self.results.aoi_modifier + ) return self def complete_irradiance(self, weather): @@ -974,16 +1108,26 @@ def complete_irradiance(self, weather): Parameters ---------- - weather : DataFrame + weather : DataFrame, or tuple or list of DataFrame Column names must be ``'dni'``, ``'ghi'``, ``'dhi'``, ``'wind_speed'``, ``'temp_air'``. All irradiance components are required. Air temperature of 20 C and wind speed of 0 m/s will be added to the DataFrame if not provided. + If `weather` is a tuple it must be the same length as the number + of Arrays in the system and the indices for each DataFrame must + be the same. Returns ------- self + Raises + ------ + ValueError + if the number of dataframes in `weather` is not the same as the + number of Arrays in the system or if the indices of all elements + of `weather` are not the same. + Notes ----- Assigns attributes: ``weather`` @@ -1005,12 +1149,25 @@ def complete_irradiance(self, weather): >>> mc = ModelChain(my_system, my_location) # doctest: +SKIP >>> mc.run_model(my_weather) # doctest: +SKIP """ - self.weather = weather + weather = _to_tuple(weather) + self._check_multiple_input(weather) + # Don't use ModelChain._assign_weather() here because it adds + # temperature and wind-speed columns which we do not need here. + self.weather = _copy(weather) + self._assign_times() + self.results.solar_position = self.location.get_solarposition( + self.times, method=self.solar_position_method) + + if isinstance(weather, tuple): + for w in self.weather: + self._complete_irradiance(w) + else: + self._complete_irradiance(self.weather) - self.solar_position = self.location.get_solarposition( - self.weather.index, method=self.solar_position_method) + return self - icolumns = set(self.weather.columns) + def _complete_irradiance(self, weather): + icolumns = set(weather.columns) wrn_txt = ("This function is not safe at the moment.\n" + "Results can be too high or negative.\n" + "Help to improve this function on github:\n" + @@ -1018,31 +1175,30 @@ def complete_irradiance(self, weather): if {'ghi', 'dhi'} <= icolumns and 'dni' not in icolumns: clearsky = self.location.get_clearsky( - self.weather.index, solar_position=self.solar_position) - self.weather.loc[:, 'dni'] = pvlib.irradiance.dni( - self.weather.loc[:, 'ghi'], self.weather.loc[:, 'dhi'], - self.solar_position.zenith, + weather.index, solar_position=self.results.solar_position) + weather.loc[:, 'dni'] = pvlib.irradiance.dni( + weather.loc[:, 'ghi'], weather.loc[:, 'dhi'], + self.results.solar_position.zenith, clearsky_dni=clearsky['dni'], clearsky_tolerance=1.1) elif {'dni', 'dhi'} <= icolumns and 'ghi' not in icolumns: warnings.warn(wrn_txt, UserWarning) - self.weather.loc[:, 'ghi'] = ( - self.weather.dni * tools.cosd(self.solar_position.zenith) + - self.weather.dhi) + weather.loc[:, 'ghi'] = ( + weather.dhi + weather.dni * + tools.cosd(self.results.solar_position.zenith) + ) elif {'dni', 'ghi'} <= icolumns and 'dhi' not in icolumns: warnings.warn(wrn_txt, UserWarning) - self.weather.loc[:, 'dhi'] = ( - self.weather.ghi - self.weather.dni * - tools.cosd(self.solar_position.zenith)) - - return self + weather.loc[:, 'dhi'] = ( + weather.ghi - weather.dni * + tools.cosd(self.results.solar_position.zenith)) def _prep_inputs_solar_pos(self, kwargs={}): """ Assign solar position """ - self.solar_position = self.location.get_solarposition( - self.weather.index, method=self.solar_position_method, + self.results.solar_position = self.location.get_solarposition( + self.times, method=self.solar_position_method, **kwargs) return self @@ -1050,32 +1206,34 @@ def _prep_inputs_airmass(self): """ Assign airmass """ - self.airmass = self.location.get_airmass( - solar_position=self.solar_position, model=self.airmass_model) + self.results.airmass = self.location.get_airmass( + solar_position=self.results.solar_position, + model=self.airmass_model) return self def _prep_inputs_tracking(self): """ Calculate tracker position and AOI """ - self.tracking = self.system.singleaxis( - self.solar_position['apparent_zenith'], - self.solar_position['azimuth']) - self.tracking['surface_tilt'] = ( - self.tracking['surface_tilt'] + self.results.tracking = self.system.singleaxis( + self.results.solar_position['apparent_zenith'], + self.results.solar_position['azimuth']) + self.results.tracking['surface_tilt'] = ( + self.results.tracking['surface_tilt'] .fillna(self.system.axis_tilt)) - self.tracking['surface_azimuth'] = ( - self.tracking['surface_azimuth'] + self.results.tracking['surface_azimuth'] = ( + self.results.tracking['surface_azimuth'] .fillna(self.system.axis_azimuth)) - self.aoi = self.tracking['aoi'] + self.results.aoi = self.results.tracking['aoi'] return self def _prep_inputs_fixed(self): """ Calculate AOI for fixed tilt system """ - self.aoi = self.system.get_aoi(self.solar_position['apparent_zenith'], - self.solar_position['azimuth']) + self.results.aoi = self.system.get_aoi( + self.results.solar_position['apparent_zenith'], + self.results.solar_position['azimuth']) return self def _verify_df(self, data, required): @@ -1090,26 +1248,62 @@ def _verify_df(self, data, required): ------ ValueError if any of required are not in data.columns. """ - if not set(required) <= set(data.columns): - raise ValueError( - f"Incomplete input data. Data needs to contain {required}. " - f"Detected data contains: {list(data.columns)}") - return + def _verify(data, index=None): + if not set(required) <= set(data.columns): + tuple_txt = "" if index is None else f"in element {index} " + raise ValueError( + "Incomplete input data. Data needs to contain " + f"{required}. Detected data {tuple_txt}contains: " + f"{list(data.columns)}") + if not isinstance(data, tuple): + _verify(data) + else: + for (i, array_data) in enumerate(data): + _verify(array_data, i) def _assign_weather(self, data): - key_list = [k for k in WEATHER_KEYS if k in data] - self.weather = data[key_list].copy() - if self.weather.get('wind_speed') is None: - self.weather['wind_speed'] = 0 - if self.weather.get('temp_air') is None: - self.weather['temp_air'] = 20 + def _build_weather(data): + key_list = [k for k in WEATHER_KEYS if k in data] + weather = data[key_list].copy() + if weather.get('wind_speed') is None: + weather['wind_speed'] = 0 + if weather.get('temp_air') is None: + weather['temp_air'] = 20 + return weather + if not isinstance(data, tuple): + self.weather = _build_weather(data) + else: + self.weather = tuple( + _build_weather(weather) for weather in data + ) return self def _assign_total_irrad(self, data): - key_list = [k for k in POA_KEYS if k in data] - self.total_irrad = data[key_list].copy() + def _build_irrad(data): + key_list = [k for k in POA_KEYS if k in data] + return data[key_list].copy() + if isinstance(data, tuple): + self.results.total_irrad = tuple( + _build_irrad(irrad_data) for irrad_data in data + ) + return self + self.results.total_irrad = _build_irrad(data) return self + def _assign_times(self): + """Assign self.times according the the index of self.weather. + + If there are multiple DataFrames in self.weather then the index + of the first one is assigned. It is assumed that the indices of + each data frame in `weather` are the same. This can be verified + by calling :py:func:`_all_same_index` or + :py:meth:`self._check_multiple_weather` before calling this method. + """ + if isinstance(self.weather, tuple): + self.times = self.weather[0].index + else: + self.times = self.weather.index + def prepare_inputs(self, weather): """ Prepare the solar position, irradiance, and weather inputs to @@ -1117,11 +1311,25 @@ def prepare_inputs(self, weather): Parameters ---------- - weather : DataFrame - Column names must be ``'dni'``, ``'ghi'``, ``'dhi'``, - ``'wind_speed'``, ``'temp_air'``. All irradiance components - are required. Air temperature of 20 C and wind speed - of 0 m/s will be added to the DataFrame if not provided. + weather : DataFrame, or tuple or list of DataFrame + Required column names include ``'dni'``, ``'ghi'``, ``'dhi'``. + Optional column names are ``'wind_speed'``, ``'temp_air'``; if not + provided, air temperature of 20 C and wind speed + of 0 m/s will be added to the DataFrame. + + If `weather` is a tuple or list, it must be of the same length and + order as the Arrays of the ModelChain's PVSystem. + + Raises + ------ + ValueError + If any `weather` DataFrame(s) is missing an irradiance component. + ValueError + If `weather` is a tuple or list and the DataFrames it contains have + different indices. + ValueError + If `weather` is a tuple or list with a different length than the + number of Arrays in the system. Notes ----- @@ -1132,15 +1340,17 @@ def prepare_inputs(self, weather): -------- ModelChain.complete_irradiance """ - + weather = _to_tuple(weather) + self._check_multiple_input(weather, strict=False) self._verify_df(weather, required=['ghi', 'dni', 'dhi']) self._assign_weather(weather) - - self.times = self.weather.index + self._assign_times() # build kwargs for solar position calculation try: - press_temp = _build_kwargs(['pressure', 'temp_air'], weather) + press_temp = _build_kwargs(['pressure', 'temp_air'], + weather[0] if isinstance(weather, tuple) + else weather) press_temp['temperature'] = press_temp.pop('temp_air') except KeyError: pass @@ -1156,26 +1366,52 @@ def prepare_inputs(self, weather): self._prep_inputs_tracking() get_irradiance = partial( self.system.get_irradiance, - self.tracking['surface_tilt'], - self.tracking['surface_azimuth'], - self.solar_position['apparent_zenith'], - self.solar_position['azimuth']) + self.results.tracking['surface_tilt'], + self.results.tracking['surface_azimuth'], + self.results.solar_position['apparent_zenith'], + self.results.solar_position['azimuth']) else: self._prep_inputs_fixed() get_irradiance = partial( self.system.get_irradiance, - self.solar_position['apparent_zenith'], - self.solar_position['azimuth']) - - self.total_irrad = get_irradiance( - self.weather['dni'], - self.weather['ghi'], - self.weather['dhi'], - airmass=self.airmass['airmass_relative'], + self.results.solar_position['apparent_zenith'], + self.results.solar_position['azimuth']) + + self.results.total_irrad = get_irradiance( + _tuple_from_dfs(self.weather, 'dni'), + _tuple_from_dfs(self.weather, 'ghi'), + _tuple_from_dfs(self.weather, 'dhi'), + airmass=self.results.airmass['airmass_relative'], model=self.transposition_model) return self + def _check_multiple_input(self, data, strict=True): + """Check that the number of elements in `data` is the same as + the number of Arrays in `self.system`. + + In most cases if ``self.system.num_arrays`` is greater than 1 we + want to raise an error when `data` is not a tuple; however, that + behavior can be suppressed by setting ``strict=False``. This is + useful for validating inputs such as GHI, DHI, DNI, wind speed, or + air temperature that can be applied a ``PVSystem`` as a system-wide + input. In this case we want to ensure that when a tuple is provided + it has the same length as the number of Arrays, but we do not want + to fail if the input is not a tuple. + """ + if (not strict or self.system.num_arrays == 1) \ + and not isinstance(data, tuple): + return + if strict and not isinstance(data, tuple): + raise TypeError("Input must be a tuple of length " + f"{self.system.num_arrays}, " + f"got {type(data).__name__}.") + if len(data) != self.system.num_arrays: + raise ValueError("Input must be same length as number of Arrays " + f"in system. Expected {self.system.num_arrays}, " + f"got {len(data)}.") + _all_same_index(data) + def prepare_inputs_from_poa(self, data): """ Prepare the solar position, irradiance and weather inputs to @@ -1183,7 +1419,7 @@ def prepare_inputs_from_poa(self, data): Parameters ---------- - data : DataFrame + data : DataFrame, or tuple or list of DataFrame Contains plane-of-array irradiance data. Required column names include ``'poa_global'``, ``'poa_direct'`` and ``'poa_diffuse'``. Columns with weather-related data are ssigned to the @@ -1191,6 +1427,15 @@ def prepare_inputs_from_poa(self, data): ``'wind_speed'`` are not provided, air temperature of 20 C and wind speed of 0 m/s are assumed. + If list or tuple, must be of the same length and order as the + Arrays of the ModelChain's PVSystem. + + Raises + ------ + ValueError + If the number of DataFrames passed in `data` is not the same + as the number of Arrays in the system. + Notes ----- Assigns attributes: ``weather``, ``total_irrad``, ``solar_position``, @@ -1200,7 +1445,8 @@ def prepare_inputs_from_poa(self, data): -------- pvlib.modelchain.ModelChain.prepare_inputs """ - + data = _to_tuple(data) + self._check_multiple_input(data) self._assign_weather(data) self._verify_df(data, required=['poa_global', 'poa_direct', @@ -1217,6 +1463,40 @@ def prepare_inputs_from_poa(self, data): return self + def _get_cell_temperature(self, data, + total_irrad, temperature_model_parameters): + """Extract the cell temperature data from a DataFrame. + + If 'cell_temperature' column exists then it is returned. If + 'module_temperature' column exists then it is used to calculate + the cell temperature. If neither column exists then None is + returned. + """ + if 'cell_temperature' in data: + return data['cell_temperature'] + # cell_temperature is not in input. Calculate cell_temperature using + # a temperature_model. + # If module_temperature is in input data we can use the SAPM cell + # temperature model. + if (('module_temperature' in data) and + (self.temperature_model == self.sapm_temp)): + # use SAPM cell temperature model only + return pvlib.temperature.sapm_cell_from_module( + module_temperature=data['module_temperature'], + poa_global=total_irrad['poa_global'], + deltaT=temperature_model_parameters['deltaT']) + + def _prepare_temperature_single_array(self, data): + """Set cell_temperature using a single weather data frame.""" + self.results.cell_temperature = self._get_cell_temperature( + data, + self.results.total_irrad, + self.system.temperature_model_parameters + ) + if self.results.cell_temperature is None: + self.temperature_model() + return self + def _prepare_temperature(self, data=None): """ Sets cell_temperature using inputs in data and the specified @@ -1238,29 +1518,50 @@ def _prepare_temperature(self, data=None): ------- self - Assigns attribute ``cell_temperature``. + Assigns attribute ``results.cell_temperature``. """ - if 'cell_temperature' in data: - self.cell_temperature = data['cell_temperature'] + if not isinstance(data, tuple) and self.system.num_arrays > 1: + data = (data,) * self.system.num_arrays + elif not isinstance(data, tuple): + return self._prepare_temperature_single_array(data) + given_cell_temperature = tuple(itertools.starmap( + self._get_cell_temperature, + zip(data, self.results.total_irrad, + self.system.temperature_model_parameters) + )) + # If cell temperature has been specified for all arrays return + # immediately and do not try to compute it. + if all(cell_temp is not None for cell_temp in given_cell_temperature): + self.results.cell_temperature = given_cell_temperature return self - - # cell_temperature is not in input. Calculate cell_temperature using - # a temperature_model. - # If module_temperature is in input data we can use the SAPM cell - # temperature model. - if (('module_temperature' in data) and - (self.temperature_model.__name__ == 'sapm_temp')): - # use SAPM cell temperature model only - self.cell_temperature = pvlib.temperature.sapm_cell_from_module( - module_temperature=data['module_temperature'], - poa_global=self.total_irrad['poa_global'], - deltaT=self.system.temperature_model_parameters['deltaT']) - return self - - # Calculate cell temperature from weather data. Cell temperature models - # expect total_irrad['poa_global']. + # Calculate cell temperature from weather data. If cell_temperature + # has not been provided for some arrays then it is computed with + # ModelChain.temperature_model(). Because this operates on all Arrays + # simultaneously, 'poa_global' must be known for all arrays, including + # those that have a known cell temperature. + try: + self._verify_df(self.results.total_irrad, ['poa_global']) + except ValueError: + # Provide a more informative error message. Because only + # run_model_from_effective_irradiance() can get to this point + # without known POA we can suggest a very specific remedy in the + # error message. + raise ValueError("Incomplete input data. Data must contain " + "'poa_global'. For systems with multiple Arrays " + "if you have provided 'cell_temperature' for " + "only a subset of Arrays you must provide " + "'poa_global' for all Arrays, including those " + "that have a known 'cell_temperature'.") self.temperature_model() + # replace calculated cell temperature with temperature given in `data` + # where available. + self.results.cell_temperature = tuple( + itertools.starmap( + lambda given, modeled: modeled if given is None else given, + zip(given_cell_temperature, self.results.cell_temperature) + ) + ) return self def run_model(self, weather): @@ -1270,7 +1571,7 @@ def run_model(self, weather): Parameters ---------- - weather : DataFrame + weather : DataFrame, or tuple or list of DataFrame Irradiance column names must include ``'dni'``, ``'ghi'``, and ``'dhi'``. If optional columns ``'temp_air'`` and ``'wind_speed'`` are not provided, air temperature of 20 C and wind speed of 0 m/s @@ -1279,10 +1580,21 @@ def run_model(self, weather): of `temperature_model`. If optional column `module_temperature` is provided, `temperature_model` must be ``'sapm'``. + If list or tuple, must be of the same length and order as the + Arrays of the ModelChain's PVSystem. + Returns ------- self + Raises + ------ + ValueError + If the number of DataFrames in `data` is different than the number + of Arrays in the PVSystem. + ValueError + If the DataFrames in `data` have different indexes. + Notes ----- Assigns attributes: ``solar_position``, ``airmass``, ``weather``, @@ -1295,6 +1607,7 @@ def run_model(self, weather): pvlib.modelchain.ModelChain.run_model_from_poa pvlib.modelchain.ModelChain.run_model_from_effective_irradiance """ + weather = _to_tuple(weather) self.prepare_inputs(weather) self.aoi_model() self.spectral_model() @@ -1314,7 +1627,7 @@ def run_model_from_poa(self, data): Parameters ---------- - data : DataFrame + data : DataFrame, or tuple or list of DataFrame Required column names include ``'poa_global'``, ``'poa_direct'`` and ``'poa_diffuse'``. If optional columns ``'temp_air'`` and ``'wind_speed'`` are not provided, air @@ -1324,10 +1637,23 @@ def run_model_from_poa(self, data): ``'module_temperature'`` is provided, `temperature_model` must be ``'sapm'``. + If the ModelChain's PVSystem has multiple arrays, `data` must be a + list or tuple with the same length and order as the PVsystem's + Arrays. Each element of `data` provides the irradiance and weather + for the corresponding array. + Returns ------- self + Raises + ------ + ValueError + If the number of DataFrames in `data` is different than the number + of Arrays in the PVSystem. + ValueError + If the DataFrames in `data` have different indexes. + Notes ----- Assigns attributes: ``solar_position``, ``airmass``, ``weather``, @@ -1340,7 +1666,7 @@ def run_model_from_poa(self, data): pvlib.modelchain.ModelChain.run_model pvlib.modelchain.ModelChain.run_model_from_effective_irradiance """ - + data = _to_tuple(data) self.prepare_inputs_from_poa(data) self.aoi_model() @@ -1357,7 +1683,7 @@ def _run_from_effective_irrad(self, data=None): Parameters ---------- - data : DataFrame, default None + data : DataFrame, or tuple of DataFrame, default None If optional column ``'cell_temperature'`` is provided, these values are used instead of `temperature_model`. If optional column `module_temperature` is provided, `temperature_model` must be @@ -1388,17 +1714,30 @@ def run_model_from_effective_irradiance(self, data=None): Parameters ---------- - data : DataFrame, default None + data : DataFrame, or list or tuple of DataFrame Required column is ``'effective_irradiance'``. If optional column ``'cell_temperature'`` is provided, these values are used instead of `temperature_model`. If optional column ``'module_temperature'`` is provided, `temperature_model` must be ``'sapm'``. + If the ModelChain's PVSystem has multiple arrays, `data` must be a + list or tuple with the same length and order as the PVsystem's + Arrays. Each element of `data` provides the irradiance and weather + for the corresponding array. + Returns ------- self + Raises + ------ + ValueError + If the number of DataFrames in `data` is different than the number + of Arrays in the PVSystem. + ValueError + If the DataFrames in `data` have different indexes. + Notes ----- Assigns attributes: ``weather``, ``total_irrad``, @@ -1407,13 +1746,79 @@ def run_model_from_effective_irradiance(self, data=None): See also -------- - pvlib.modelchain.ModelChain.run_model_from + pvlib.modelchain.ModelChain.run_model pvlib.modelchain.ModelChain.run_model_from_poa """ - + data = _to_tuple(data) + self._check_multiple_input(data) self._assign_weather(data) self._assign_total_irrad(data) - self.effective_irradiance = data['effective_irradiance'] + self.results.effective_irradiance = _tuple_from_dfs( + data, 'effective_irradiance') self._run_from_effective_irrad(data) return self + + +def _snl_params(inverter_params): + """Return True if `inverter_params` includes parameters for the + Sandia inverter model.""" + return {'C0', 'C1', 'C2'} <= inverter_params + + +def _adr_params(inverter_params): + """Return True if `inverter_params` includes parameters for the ADR + inverter model.""" + return {'ADRCoefficients'} <= inverter_params + + +def _pvwatts_params(inverter_params): + """Return True if `inverter_params` includes parameters for the + PVWatts inverter model.""" + return {'pdc0'} <= inverter_params + + +def _copy(data): + """Return a copy of each DataFrame in `data` if it is a tuple, + otherwise return a copy of `data`.""" + if not isinstance(data, tuple): + return data.copy() + return tuple(df.copy() for df in data) + + +def _all_same_index(data): + """Raise a ValueError if all DataFrames in `data` do not have the + same index.""" + indexes = map(lambda df: df.index, data) + next(indexes, None) + for index in indexes: + if not index.equals(data[0].index): + raise ValueError("Input DataFrames must have same index.") + + +def _common_keys(dicts): + """Return the intersection of the set of keys for each dictionary + in `dicts`""" + def _keys(x): + return set(x.keys()) + if isinstance(dicts, tuple): + return set.intersection(*map(_keys, dicts)) + return _keys(dicts) + + +def _tuple_from_dfs(dfs, name): + """Extract a column from each DataFrame in `dfs` if `dfs` is a tuple. + + Returns a tuple of Series if `dfs` is a tuple or a Series if `dfs` is + a DataFrame. + """ + if isinstance(dfs, tuple): + return tuple(df[name] for df in dfs) + else: + return dfs[name] + + +def _to_tuple(x): + if not isinstance(x, (tuple, list)): + return x + return tuple(x) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 8a20e5fdef..819d00c703 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -4,6 +4,7 @@ """ from collections import OrderedDict +import functools import io import os from urllib.request import urlopen @@ -52,6 +53,7 @@ def _combine_localized_attributes(pvsystem=None, location=None, **kwargs): """ if pvsystem is not None: pv_dict = pvsystem.__dict__ + pv_dict = {**pv_dict, **pv_dict['arrays'][0].__dict__} else: pv_dict = {} @@ -66,6 +68,26 @@ def _combine_localized_attributes(pvsystem=None, location=None, **kwargs): return new_kwargs +def _unwrap_single_value(func): + """Decorator for functions that return iterables. + + If the length of the iterable returned by `func` is 1, then + the single member of the iterable is returned. If the length is + greater than 1, then entire iterable is returned. + + Adds 'unwrap' as a keyword argument that can be set to False + to force the return value to be a tuple, regardless of its length. + """ + @functools.wraps(func) + def f(*args, **kwargs): + unwrap = kwargs.pop('unwrap', True) + x = func(*args, **kwargs) + if unwrap and len(x) == 1: + return x[0] + return x + return f + + # not sure if this belongs in the pvsystem module. # maybe something more like core.py? It may eventually grow to # import a lot more functionality from other modules. @@ -97,6 +119,23 @@ class PVSystem: Parameters ---------- + arrays : iterable of Array, optional + List of arrays that are part of the system. If not specified + a single array is created from the other parameters (e.g. + `surface_tilt`, `surface_azimuth`). If `arrays` is specified + the following parameters are ignored: + + - `surface_tilt` + - `surface_azimuth` + - `albedo` + - `surface_type` + - `module` + - `module_type` + - `module_parameters` + - `temperature_model_parameters` + - `modules_per_string` + - `strings_per_inverter` + surface_tilt: float or array-like, default 0 Surface tilt angles in decimal degrees. The tilt angle is defined as degrees from horizontal @@ -164,6 +203,7 @@ class PVSystem: """ def __init__(self, + arrays=None, surface_tilt=0, surface_azimuth=180, albedo=None, surface_type=None, module=None, module_type=None, @@ -174,34 +214,22 @@ def __init__(self, racking_model=None, losses_parameters=None, name=None, **kwargs): - self.surface_tilt = surface_tilt - self.surface_azimuth = surface_azimuth - - # could tie these together with @property - self.surface_type = surface_type - if albedo is None: - self.albedo = irradiance.SURFACE_ALBEDOS.get(surface_type, 0.25) - else: - self.albedo = albedo - - # could tie these together with @property - self.module = module - if module_parameters is None: - self.module_parameters = {} - else: - self.module_parameters = module_parameters - - self.module_type = module_type - self.racking_model = racking_model - - if temperature_model_parameters is None: - self.temperature_model_parameters = \ - self._infer_temperature_model_params() + if arrays is None: + self.arrays = (Array( + surface_tilt, + surface_azimuth, + albedo, + surface_type, + module, + module_type, + module_parameters, + temperature_model_parameters, + modules_per_string, + strings_per_inverter, + racking_model + ),) else: - self.temperature_model_parameters = temperature_model_parameters - - self.modules_per_string = modules_per_string - self.strings_per_inverter = strings_per_inverter + self.arrays = tuple(arrays) self.inverter = inverter if inverter_parameters is None: @@ -223,14 +251,50 @@ def __init__(self, ) def __repr__(self): - attrs = ['name', 'surface_tilt', 'surface_azimuth', 'module', - 'inverter', 'albedo', 'racking_model', 'module_type', - 'temperature_model_parameters'] - return ('PVSystem:\n ' + '\n '.join( - f'{attr}: {getattr(self, attr)}' for attr in attrs)) + repr = f'PVSystem:\n name: {self.name}\n ' + for array in self.arrays: + repr += '\n '.join(array.__repr__().split('\n')) + repr += '\n ' + repr += f'inverter: {self.inverter}' + return repr + + def _validate_per_array(self, values, system_wide=False): + """Check that `values` is a tuple of the same length as + `self.arrays`. + + If `values` is not a tuple it is packed in to a length-1 tuple before + the check. If the lengths are not the same a ValueError is raised, + otherwise the tuple `values` is returned. + + When `system_wide` is True and `values` is not a tuple, `values` + is replicated to a tuple of the same length as `self.arrays` and that + tuple is returned. + """ + if system_wide and not isinstance(values, tuple): + return (values,) * self.num_arrays + if not isinstance(values, tuple): + values = (values,) + if len(values) != len(self.arrays): + raise ValueError("Length mismatch for per-array parameter") + return values + + @_unwrap_single_value + def _infer_cell_type(self): + """ + Examines module_parameters and maps the Technology key for the CEC + database and the Material key for the Sandia database to a common + list of strings for cell type. + + Returns + ------- + cell_type: str + """ + return tuple(array._infer_cell_type() for array in self.arrays) + + @_unwrap_single_value def get_aoi(self, solar_zenith, solar_azimuth): - """Get the angle of incidence on the system. + """Get the angle of incidence on the Array(s) in the system. Parameters ---------- @@ -241,14 +305,14 @@ def get_aoi(self, solar_zenith, solar_azimuth): Returns ------- - aoi : Series + aoi : Series or tuple of Series The angle of incidence """ - aoi = irradiance.aoi(self.surface_tilt, self.surface_azimuth, - solar_zenith, solar_azimuth) - return aoi + return tuple(array.get_aoi(solar_zenith, solar_azimuth) + for array in self.arrays) + @_unwrap_single_value def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, dni_extra=None, airmass=None, model='haydavies', **kwargs): @@ -264,11 +328,11 @@ def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, Solar zenith angle. solar_azimuth : float or Series. Solar azimuth angle. - dni : float or Series + dni : float or Series or tuple of float or Series Direct Normal Irradiance - ghi : float or Series + ghi : float or Series or tuple of float or Series Global horizontal irradiance - dhi : float or Series + dhi : float or Series or tuple of float or Series Diffuse horizontal irradiance dni_extra : None, float or Series, default None Extraterrestrial direct normal irradiance @@ -280,29 +344,33 @@ def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, kwargs Extra parameters passed to :func:`irradiance.get_total_irradiance`. + Notes + ----- + Each of `dni`, `ghi`, and `dni` parameters may be passed as a tuple + to provide different irradiance for each array in the system. If not + passed as a tuple then the same value is used for input to each Array. + If passed as a tuple the length must be the same as the number of + Arrays. + Returns ------- - poa_irradiance : DataFrame + poa_irradiance : DataFrame or tuple of DataFrame Column names are: ``total, beam, sky, ground``. """ + dni = self._validate_per_array(dni, system_wide=True) + ghi = self._validate_per_array(ghi, system_wide=True) + dhi = self._validate_per_array(dhi, system_wide=True) + return tuple( + array.get_irradiance(solar_zenith, solar_azimuth, + dni, ghi, dhi, + dni_extra, airmass, model, + **kwargs) + for array, dni, ghi, dhi in zip( + self.arrays, dni, ghi, dhi + ) + ) - # not needed for all models, but this is easier - if dni_extra is None: - dni_extra = irradiance.get_extra_radiation(solar_zenith.index) - - if airmass is None: - airmass = atmosphere.get_relative_airmass(solar_zenith) - - return irradiance.get_total_irradiance(self.surface_tilt, - self.surface_azimuth, - solar_zenith, solar_azimuth, - dni, ghi, dhi, - dni_extra=dni_extra, - airmass=airmass, - model=model, - albedo=self.albedo, - **kwargs) - + @_unwrap_single_value def get_iam(self, aoi, iam_model='physical'): """ Determine the incidence angle modifier using the method specified by @@ -314,36 +382,27 @@ def get_iam(self, aoi, iam_model='physical'): Parameters ---------- - aoi : numeric + aoi : numeric or tuple of numeric The angle of incidence in degrees. aoi_model : string, default 'physical' The IAM model to be used. Valid strings are 'physical', 'ashrae', 'martin_ruiz' and 'sapm'. - Returns ------- - iam : numeric + iam : numeric or tuple of numeric The AOI modifier. Raises ------ - ValueError if `iam_model` is not a valid model name. + ValueError + if `iam_model` is not a valid model name. """ - model = iam_model.lower() - if model in ['ashrae', 'physical', 'martin_ruiz']: - param_names = iam._IAM_MODEL_PARAMS[model] - kwargs = _build_kwargs(param_names, self.module_parameters) - func = getattr(iam, model) - return func(aoi, **kwargs) - elif model == 'sapm': - return iam.sapm(aoi, self.module_parameters) - elif model == 'interp': - raise ValueError(model + ' is not implemented as an IAM model' - 'option for PVSystem') - else: - raise ValueError(model + ' is not a valid IAM model') + aoi = self._validate_per_array(aoi) + return tuple(array.get_iam(aoi, iam_model) + for array, aoi in zip(self.arrays, aoi)) + @_unwrap_single_value def calcparams_desoto(self, effective_irradiance, temp_cell, **kwargs): """ Use the :py:func:`calcparams_desoto` function, the input @@ -352,10 +411,10 @@ def calcparams_desoto(self, effective_irradiance, temp_cell, **kwargs): Parameters ---------- - effective_irradiance : numeric + effective_irradiance : numeric or tuple of numeric The irradiance (W/m2) that is converted to photocurrent. - temp_cell : float or Series + temp_cell : float or Series or tuple of float or Series The average cell temperature of cells within a module in C. **kwargs @@ -365,14 +424,26 @@ def calcparams_desoto(self, effective_irradiance, temp_cell, **kwargs): ------- See pvsystem.calcparams_desoto for details """ + effective_irradiance = self._validate_per_array(effective_irradiance) + temp_cell = self._validate_per_array(temp_cell) + + build_kwargs = functools.partial( + _build_kwargs, + ['a_ref', 'I_L_ref', 'I_o_ref', 'R_sh_ref', + 'R_s', 'alpha_sc', 'EgRef', 'dEgdT', + 'irrad_ref', 'temp_ref'] + ) - kwargs = _build_kwargs(['a_ref', 'I_L_ref', 'I_o_ref', 'R_sh_ref', - 'R_s', 'alpha_sc', 'EgRef', 'dEgdT', - 'irrad_ref', 'temp_ref'], - self.module_parameters) - - return calcparams_desoto(effective_irradiance, temp_cell, **kwargs) + return tuple( + calcparams_desoto( + effective_irradiance, temp_cell, + **build_kwargs(array.module_parameters) + ) + for array, effective_irradiance, temp_cell + in zip(self.arrays, effective_irradiance, temp_cell) + ) + @_unwrap_single_value def calcparams_cec(self, effective_irradiance, temp_cell, **kwargs): """ Use the :py:func:`calcparams_cec` function, the input @@ -381,10 +452,10 @@ def calcparams_cec(self, effective_irradiance, temp_cell, **kwargs): Parameters ---------- - effective_irradiance : numeric + effective_irradiance : numeric or tuple of numeric The irradiance (W/m2) that is converted to photocurrent. - temp_cell : float or Series + temp_cell : float or Series or tuple of float or Series The average cell temperature of cells within a module in C. **kwargs @@ -394,14 +465,26 @@ def calcparams_cec(self, effective_irradiance, temp_cell, **kwargs): ------- See pvsystem.calcparams_cec for details """ + effective_irradiance = self._validate_per_array(effective_irradiance) + temp_cell = self._validate_per_array(temp_cell) + + build_kwargs = functools.partial( + _build_kwargs, + ['a_ref', 'I_L_ref', 'I_o_ref', 'R_sh_ref', + 'R_s', 'alpha_sc', 'Adjust', 'EgRef', 'dEgdT', + 'irrad_ref', 'temp_ref'] + ) - kwargs = _build_kwargs(['a_ref', 'I_L_ref', 'I_o_ref', 'R_sh_ref', - 'R_s', 'alpha_sc', 'Adjust', 'EgRef', 'dEgdT', - 'irrad_ref', 'temp_ref'], - self.module_parameters) - - return calcparams_cec(effective_irradiance, temp_cell, **kwargs) + return tuple( + calcparams_cec( + effective_irradiance, temp_cell, + **build_kwargs(array.module_parameters) + ) + for array, effective_irradiance, temp_cell + in zip(self.arrays, effective_irradiance, temp_cell) + ) + @_unwrap_single_value def calcparams_pvsyst(self, effective_irradiance, temp_cell): """ Use the :py:func:`calcparams_pvsyst` function, the input @@ -410,26 +493,38 @@ def calcparams_pvsyst(self, effective_irradiance, temp_cell): Parameters ---------- - effective_irradiance : numeric + effective_irradiance : numeric or tuple of numeric The irradiance (W/m2) that is converted to photocurrent. - temp_cell : float or Series + temp_cell : float or Series or tuple of float or Series The average cell temperature of cells within a module in C. Returns ------- See pvsystem.calcparams_pvsyst for details """ + effective_irradiance = self._validate_per_array(effective_irradiance) + temp_cell = self._validate_per_array(temp_cell) + + build_kwargs = functools.partial( + _build_kwargs, + ['gamma_ref', 'mu_gamma', 'I_L_ref', 'I_o_ref', + 'R_sh_ref', 'R_sh_0', 'R_sh_exp', + 'R_s', 'alpha_sc', 'EgRef', + 'irrad_ref', 'temp_ref', + 'cells_in_series'] + ) - kwargs = _build_kwargs(['gamma_ref', 'mu_gamma', 'I_L_ref', 'I_o_ref', - 'R_sh_ref', 'R_sh_0', 'R_sh_exp', - 'R_s', 'alpha_sc', 'EgRef', - 'irrad_ref', 'temp_ref', - 'cells_in_series'], - self.module_parameters) - - return calcparams_pvsyst(effective_irradiance, temp_cell, **kwargs) + return tuple( + calcparams_pvsyst( + effective_irradiance, temp_cell, + **build_kwargs(array.module_parameters) + ) + for array, effective_irradiance, temp_cell + in zip(self.arrays, effective_irradiance, temp_cell) + ) + @_unwrap_single_value def sapm(self, effective_irradiance, temp_cell, **kwargs): """ Use the :py:func:`sapm` function, the input parameters, @@ -438,10 +533,10 @@ def sapm(self, effective_irradiance, temp_cell, **kwargs): Parameters ---------- - effective_irradiance : numeric + effective_irradiance : numeric or tuple of numeric The irradiance (W/m2) that is converted to photocurrent. - temp_cell : float or Series + temp_cell : float or Series or tuple of float or Series The average cell temperature of cells within a module in C. kwargs @@ -451,62 +546,75 @@ def sapm(self, effective_irradiance, temp_cell, **kwargs): ------- See pvsystem.sapm for details """ - return sapm(effective_irradiance, temp_cell, self.module_parameters) + effective_irradiance = self._validate_per_array(effective_irradiance) + temp_cell = self._validate_per_array(temp_cell) + return tuple( + sapm(effective_irradiance, temp_cell, array.module_parameters) + for array, effective_irradiance, temp_cell + in zip(self.arrays, effective_irradiance, temp_cell) + ) + + @_unwrap_single_value def sapm_celltemp(self, poa_global, temp_air, wind_speed): """Uses :py:func:`temperature.sapm_cell` to calculate cell temperatures. Parameters ---------- - poa_global : numeric + poa_global : numeric or tuple of numeric Total incident irradiance in W/m^2. - temp_air : numeric + temp_air : numeric or tuple of numeric Ambient dry bulb temperature in degrees C. - wind_speed : numeric + wind_speed : numeric or tuple of numeric Wind speed in m/s at a height of 10 meters. Returns ------- - numeric, values in degrees C. - """ - # warn user about change in default behavior in 0.9. - if (self.temperature_model_parameters == {} and self.module_type - is None and self.racking_model is None): - warnings.warn( - 'temperature_model_parameters, racking_model, and module_type ' - 'are not specified. Reverting to deprecated default: SAPM ' - 'cell temperature model parameters for a glass/glass module ' - 'in open racking. In v0.9, temperature_model_parameters or a ' - 'valid combination of racking_model and module_type will be ' - 'required.', - pvlibDeprecationWarning) - params = temperature._temperature_model_params( - 'sapm', 'open_rack_glass_glass') - self.temperature_model_parameters = params - - kwargs = _build_kwargs(['a', 'b', 'deltaT'], - self.temperature_model_parameters) - return temperature.sapm_cell(poa_global, temp_air, wind_speed, - **kwargs) + numeric or tuple of numeric + values in degrees C. - def _infer_temperature_model_params(self): - # try to infer temperature model parameters from from racking_model - # and module_type - param_set = f'{self.racking_model}_{self.module_type}' - if param_set in temperature.TEMPERATURE_MODEL_PARAMETERS['sapm']: - return temperature._temperature_model_params('sapm', param_set) - elif 'freestanding' in param_set: - return temperature._temperature_model_params('pvsyst', - 'freestanding') - elif 'insulated' in param_set: # after SAPM to avoid confusing keys - return temperature._temperature_model_params('pvsyst', - 'insulated') - else: - return {} + Notes + ----- + The `temp_air` and `wind_speed` parameters may be passed as tuples + to provide different values for each Array in the system. If not + passed as a tuple then the same value is used for input to each Array. + If passed as a tuple the length must be the same as the number of + Arrays. + """ + poa_global = self._validate_per_array(poa_global) + temp_air = self._validate_per_array(temp_air, system_wide=True) + wind_speed = self._validate_per_array(wind_speed, system_wide=True) + for array in self.arrays: + # warn user about change in default behavior in 0.9. + if (array.temperature_model_parameters == {} and array.module_type + is None and array.racking_model is None): + warnings.warn( + 'temperature_model_parameters, racking_model, and ' + 'module_type are not specified. Reverting to deprecated ' + 'default: SAPM cell temperature model parameters for a ' + 'glass/glass module in open racking. In v0.9, ' + 'temperature_model_parameters or a valid combination of ' + 'racking_model and module_type will be required.', + pvlibDeprecationWarning) + params = temperature._temperature_model_params( + 'sapm', 'open_rack_glass_glass') + array.temperature_model_parameters = params + + build_kwargs = functools.partial(_build_kwargs, ['a', 'b', 'deltaT']) + return tuple( + temperature.sapm_cell( + poa_global, temp_air, wind_speed, + **build_kwargs(array.temperature_model_parameters) + ) + for array, poa_global, temp_air, wind_speed in zip( + self.arrays, poa_global, temp_air, wind_speed + ) + ) + @_unwrap_single_value def sapm_spectral_loss(self, airmass_absolute): """ Use the :py:func:`sapm_spectral_loss` function, the input @@ -519,11 +627,15 @@ def sapm_spectral_loss(self, airmass_absolute): Returns ------- - F1 : numeric + F1 : numeric or tuple of numeric The SAPM spectral loss coefficient. """ - return sapm_spectral_loss(airmass_absolute, self.module_parameters) + return tuple( + sapm_spectral_loss(airmass_absolute, array.module_parameters) + for array in self.arrays + ) + @_unwrap_single_value def sapm_effective_irradiance(self, poa_direct, poa_diffuse, airmass_absolute, aoi, reference_irradiance=1000): @@ -534,99 +646,145 @@ def sapm_effective_irradiance(self, poa_direct, poa_diffuse, Parameters ---------- - poa_direct : numeric + poa_direct : numeric or tuple of numeric The direct irradiance incident upon the module. [W/m2] - poa_diffuse : numeric + poa_diffuse : numeric or tuple of numeric The diffuse irradiance incident on module. [W/m2] airmass_absolute : numeric Absolute airmass. [unitless] - aoi : numeric + aoi : numeric or tuple of numeric Angle of incidence. [degrees] Returns ------- - effective_irradiance : numeric + effective_irradiance : numeric or tuple of numeric The SAPM effective irradiance. [W/m2] """ - return sapm_effective_irradiance( - poa_direct, poa_diffuse, airmass_absolute, aoi, - self.module_parameters) + poa_direct = self._validate_per_array(poa_direct) + poa_diffuse = self._validate_per_array(poa_diffuse) + aoi = self._validate_per_array(aoi) + return tuple( + sapm_effective_irradiance( + poa_direct, poa_diffuse, airmass_absolute, aoi, + array.module_parameters) + for array, poa_direct, poa_diffuse, aoi + in zip(self.arrays, poa_direct, poa_diffuse, aoi) + ) + @_unwrap_single_value def pvsyst_celltemp(self, poa_global, temp_air, wind_speed=1.0): """Uses :py:func:`temperature.pvsyst_cell` to calculate cell temperature. Parameters ---------- - poa_global : numeric + poa_global : numeric or tuple of numeric Total incident irradiance in W/m^2. - temp_air : numeric + temp_air : numeric or tuple of numeric Ambient dry bulb temperature in degrees C. - wind_speed : numeric, default 1.0 + wind_speed : numeric or tuple of numeric, default 1.0 Wind speed in m/s measured at the same height for which the wind loss factor was determined. The default value is 1.0, which is the wind speed at module height used to determine NOCT. Returns ------- - numeric, values in degrees C. + numeric or tuple of numeric + values in degrees C. + + Notes + ----- + The `temp_air` and `wind_speed` parameters may be passed as tuples + to provide different values for each Array in the system. If not + passed as a tuple then the same value is used for input to each Array. + If passed as a tuple the length must be the same as the number of + Arrays. """ - kwargs = _build_kwargs(['eta_m', 'alpha_absorption'], - self.module_parameters) - kwargs.update(_build_kwargs(['u_c', 'u_v'], - self.temperature_model_parameters)) - return temperature.pvsyst_cell(poa_global, temp_air, wind_speed, - **kwargs) + poa_global = self._validate_per_array(poa_global) + temp_air = self._validate_per_array(temp_air, system_wide=True) + wind_speed = self._validate_per_array(wind_speed, system_wide=True) + + def build_celltemp_kwargs(array): + return {**_build_kwargs(['eta_m', 'alpha_absorption'], + array.module_parameters), + **_build_kwargs(['u_c', 'u_v'], + array.temperature_model_parameters)} + return tuple( + temperature.pvsyst_cell(poa_global, temp_air, wind_speed, + **build_celltemp_kwargs(array)) + for array, poa_global, temp_air, wind_speed in zip( + self.arrays, poa_global, temp_air, wind_speed + ) + ) + @_unwrap_single_value def faiman_celltemp(self, poa_global, temp_air, wind_speed=1.0): """ Use :py:func:`temperature.faiman` to calculate cell temperature. Parameters ---------- - poa_global : numeric + poa_global : numeric or tuple of numeric Total incident irradiance [W/m^2]. - temp_air : numeric + temp_air : numeric or tuple of numeric Ambient dry bulb temperature [C]. - wind_speed : numeric, default 1.0 + wind_speed : numeric or tuple of numeric, default 1.0 Wind speed in m/s measured at the same height for which the wind loss factor was determined. The default value 1.0 m/s is the wind speed at module height used to determine NOCT. [m/s] Returns ------- - numeric, values in degrees C. + numeric or tuple of numeric + values in degrees C. + + Notes + ----- + The `temp_air` and `wind_speed` parameters may be passed as tuples + to provide different values for each Array in the system. If not + passed as a tuple then the same value is used for input to each Array. + If passed as a tuple the length must be the same as the number of + Arrays. """ - kwargs = _build_kwargs(['u0', 'u1'], - self.temperature_model_parameters) - return temperature.faiman(poa_global, temp_air, wind_speed, - **kwargs) + poa_global = self._validate_per_array(poa_global) + temp_air = self._validate_per_array(temp_air, system_wide=True) + wind_speed = self._validate_per_array(wind_speed, system_wide=True) + return tuple( + temperature.faiman( + poa_global, temp_air, wind_speed, + **_build_kwargs( + ['u0', 'u1'], array.temperature_model_parameters)) + for array, poa_global, temp_air, wind_speed in zip( + self.arrays, poa_global, temp_air, wind_speed + ) + ) + @_unwrap_single_value def fuentes_celltemp(self, poa_global, temp_air, wind_speed): """ Use :py:func:`temperature.fuentes` to calculate cell temperature. Parameters ---------- - poa_global : pandas Series + poa_global : pandas Series or tuple of Series Total incident irradiance [W/m^2] - temp_air : pandas Series + temp_air : pandas Series or tuple of Series Ambient dry bulb temperature [C] - wind_speed : pandas Series + wind_speed : pandas Series or tuple of Series Wind speed [m/s] Returns ------- - temperature_cell : pandas Series + temperature_cell : Series or tuple of Series The modeled cell temperature [C] Notes @@ -637,18 +795,39 @@ def fuentes_celltemp(self, poa_global, temp_air, wind_speed): transposition. This method defaults to using ``self.surface_tilt``, but if you want to match the PVWatts behavior, you can override it by including a ``surface_tilt`` value in ``temperature_model_parameters``. + + Notes + ----- + The `temp_air` and `wind_speed` parameters may be passed as tuples + to provide different values for each Array in the system. If not + passed as a tuple then the same value is used for input to each Array. + If passed as a tuple the length must be the same as the number of + Arrays. """ - # default to using the PVSystem attribute, but allow user to + # default to using the Array attribute, but allow user to # override with a custom surface_tilt value - kwargs = {'surface_tilt': self.surface_tilt} - temp_model_kwargs = _build_kwargs([ - 'noct_installed', 'module_height', 'wind_height', 'emissivity', - 'absorption', 'surface_tilt', 'module_width', 'module_length'], - self.temperature_model_parameters) - kwargs.update(temp_model_kwargs) - return temperature.fuentes(poa_global, temp_air, wind_speed, - **kwargs) + poa_global = self._validate_per_array(poa_global) + temp_air = self._validate_per_array(temp_air, system_wide=True) + wind_speed = self._validate_per_array(wind_speed, system_wide=True) + + def _build_kwargs_fuentes(array): + kwargs = {'surface_tilt': array.surface_tilt} + temp_model_kwargs = _build_kwargs([ + 'noct_installed', 'module_height', 'wind_height', 'emissivity', + 'absorption', 'surface_tilt', 'module_width', 'module_length'], + array.temperature_model_parameters) + kwargs.update(temp_model_kwargs) + return kwargs + return tuple( + temperature.fuentes( + poa_global, temp_air, wind_speed, + **_build_kwargs_fuentes(array)) + for array, poa_global, temp_air, wind_speed in zip( + self.arrays, poa_global, temp_air, wind_speed + ) + ) + @_unwrap_single_value def first_solar_spectral_loss(self, pw, airmass_absolute): """ @@ -673,69 +852,30 @@ def first_solar_spectral_loss(self, pw, airmass_absolute): Returns ------- - modifier: array-like + modifier: array-like or tuple of array-like spectral mismatch factor (unitless) which can be multiplied with broadband irradiance reaching a module's cells to estimate effective irradiance, i.e., the irradiance that is converted to electrical current. """ - if 'first_solar_spectral_coefficients' in \ - self.module_parameters.keys(): - coefficients = \ - self.module_parameters['first_solar_spectral_coefficients'] - module_type = None - else: - module_type = self._infer_cell_type() - coefficients = None - - return atmosphere.first_solar_spectral_correction(pw, - airmass_absolute, - module_type, - coefficients) - - def _infer_cell_type(self): - - """ - Examines module_parameters and maps the Technology key for the CEC - database and the Material key for the Sandia database to a common - list of strings for cell type. - - Returns - ------- - cell_type: str - - """ - - _cell_type_dict = {'Multi-c-Si': 'multisi', - 'Mono-c-Si': 'monosi', - 'Thin Film': 'cigs', - 'a-Si/nc': 'asi', - 'CIS': 'cigs', - 'CIGS': 'cigs', - '1-a-Si': 'asi', - 'CdTe': 'cdte', - 'a-Si': 'asi', - '2-a-Si': None, - '3-a-Si': None, - 'HIT-Si': 'monosi', - 'mc-Si': 'multisi', - 'c-Si': 'multisi', - 'Si-Film': 'asi', - 'EFG mc-Si': 'multisi', - 'GaAs': None, - 'a-Si / mono-Si': 'monosi'} - - if 'Technology' in self.module_parameters.keys(): - # CEC module parameter set - cell_type = _cell_type_dict[self.module_parameters['Technology']] - elif 'Material' in self.module_parameters.keys(): - # Sandia module parameter set - cell_type = _cell_type_dict[self.module_parameters['Material']] - else: - cell_type = None - - return cell_type + def _spectral_correction(array): + if 'first_solar_spectral_coefficients' in \ + array.module_parameters.keys(): + coefficients = \ + array.module_parameters[ + 'first_solar_spectral_coefficients' + ] + module_type = None + else: + module_type = array._infer_cell_type() + coefficients = None + + return atmosphere.first_solar_spectral_correction( + pw, airmass_absolute, + module_type, coefficients + ) + return tuple(_spectral_correction(array) for array in self.arrays) def singlediode(self, photocurrent, saturation_current, resistance_series, resistance_shunt, nNsVth, @@ -766,6 +906,19 @@ def snlinverter(self, v_dc, p_dc): """ return inverter.sandia(v_dc, p_dc, self.inverter_parameters) + def sandia_multi(self, v_dc, p_dc): + """Uses :py:func:`pvlib.inverter.sandia_multi` to calculate AC power + based on ``self.inverter_parameters`` and the input voltage and power. + + The parameters `v_dc` and `p_dc` must be tuples with length equal to + ``self.num_arrays`` if the system has more than one array. + + See :py:func:`pvlib.inverter.sandia_multi` for details. + """ + v_dc = self._validate_per_array(v_dc) + p_dc = self._validate_per_array(p_dc) + return inverter.sandia_multi(v_dc, p_dc, self.inverter_parameters) + def adrinverter(self, v_dc, p_dc): """Uses :py:func:`pvlib.inverter.adr` to calculate AC power based on ``self.inverter_parameters`` and the input voltage and power. @@ -774,6 +927,7 @@ def adrinverter(self, v_dc, p_dc): """ return inverter.adr(v_dc, p_dc, self.inverter_parameters) + @_unwrap_single_value def scale_voltage_current_power(self, data): """ Scales the voltage, current, and power of the `data` DataFrame @@ -781,20 +935,24 @@ def scale_voltage_current_power(self, data): Parameters ---------- - data: DataFrame + data: DataFrame or tuple of DataFrame Must contain columns `'v_mp', 'v_oc', 'i_mp' ,'i_x', 'i_xx', 'i_sc', 'p_mp'`. Returns ------- - scaled_data: DataFrame + scaled_data: DataFrame or tuple of DataFrame A scaled copy of the input data. """ + data = self._validate_per_array(data) + return tuple( + scale_voltage_current_power(data, + voltage=array.modules_per_string, + current=array.strings) + for array, data in zip(self.arrays, data) + ) - return scale_voltage_current_power(data, - voltage=self.modules_per_string, - current=self.strings_per_inverter) - + @_unwrap_single_value def pvwatts_dc(self, g_poa_effective, temp_cell): """ Calcuates DC power according to the PVWatts model using @@ -803,12 +961,16 @@ def pvwatts_dc(self, g_poa_effective, temp_cell): See :py:func:`pvlib.pvsystem.pvwatts_dc` for details. """ - kwargs = _build_kwargs(['temp_ref'], self.module_parameters) - - return pvwatts_dc(g_poa_effective, temp_cell, - self.module_parameters['pdc0'], - self.module_parameters['gamma_pdc'], - **kwargs) + g_poa_effective = self._validate_per_array(g_poa_effective) + temp_cell = self._validate_per_array(temp_cell) + return tuple( + pvwatts_dc(g_poa_effective, temp_cell, + array.module_parameters['pdc0'], + array.module_parameters['gamma_pdc'], + **_build_kwargs(['temp_ref'], array.module_parameters)) + for array, g_poa_effective, temp_cell + in zip(self.arrays, g_poa_effective, temp_cell) + ) def pvwatts_losses(self): """ @@ -864,6 +1026,82 @@ def localize(self, location=None, latitude=None, longitude=None, return LocalizedPVSystem(pvsystem=self, location=location) + @property + @_unwrap_single_value + def module_parameters(self): + return tuple(array.module_parameters for array in self.arrays) + + @property + @_unwrap_single_value + def module(self): + return tuple(array.module for array in self.arrays) + + @property + @_unwrap_single_value + def module_type(self): + return tuple(array.module_type for array in self.arrays) + + @property + @_unwrap_single_value + def temperature_model_parameters(self): + return tuple(array.temperature_model_parameters + for array in self.arrays) + + @temperature_model_parameters.setter + def temperature_model_parameters(self, value): + for array in self.arrays: + array.temperature_model_parameters = value + + @property + @_unwrap_single_value + def surface_tilt(self): + return tuple(array.surface_tilt for array in self.arrays) + + @surface_tilt.setter + def surface_tilt(self, value): + for array in self.arrays: + array.surface_tilt = value + + @property + @_unwrap_single_value + def surface_azimuth(self): + return tuple(array.surface_azimuth for array in self.arrays) + + @surface_azimuth.setter + def surface_azimuth(self, value): + for array in self.arrays: + array.surface_azimuth = value + + @property + @_unwrap_single_value + def albedo(self): + return tuple(array.albedo for array in self.arrays) + + @property + @_unwrap_single_value + def racking_model(self): + return tuple(array.racking_model for array in self.arrays) + + @racking_model.setter + def racking_model(self, value): + for array in self.arrays: + array.racking_model = value + + @property + @_unwrap_single_value + def modules_per_string(self): + return tuple(array.modules_per_string for array in self.arrays) + + @property + @_unwrap_single_value + def strings_per_inverter(self): + return tuple(array.strings for array in self.arrays) + + @property + def num_arrays(self): + """The number of Arrays in the system.""" + return len(self.arrays) + @deprecated('0.8', alternative='PVSystem, Location, and ModelChain', name='LocalizedPVSystem', removal='0.9') @@ -898,6 +1136,285 @@ def __repr__(self): f'{attr}: {getattr(self, attr)}' for attr in attrs)) +class Array: + """ + An Array is a set of of modules at the same orientation. + + Specifically, an array is defined by tilt, azimuth, the + module parameters, the number of parallel strings of modules + and the number of modules on each string. + + Parameters + ---------- + surface_tilt: float or array-like, default 0 + Surface tilt angles in decimal degrees. + The tilt angle is defined as degrees from horizontal + (e.g. surface facing up = 0, surface facing horizon = 90) + + surface_azimuth: float or array-like, default 180 + Azimuth angle of the module surface. + North=0, East=90, South=180, West=270. + + albedo : None or float, default None + The ground albedo. If ``None``, will attempt to use + ``surface_type`` to look up an albedo value in + ``irradiance.SURFACE_ALBEDOS``. If a surface albedo + cannot be found then 0.25 is used. + + surface_type : None or string, default None + The ground surface type. See ``irradiance.SURFACE_ALBEDOS`` + for valid values. + + module : None or string, default None + The model name of the modules. + May be used to look up the module_parameters dictionary + via some other method. + + module_type : None or string, default None + Describes the module's construction. Valid strings are 'glass_polymer' + and 'glass_glass'. Used for cell and module temperature calculations. + + module_parameters : None, dict or Series, default None + Parameters for the module model, e.g., SAPM, CEC, or other. + + temperature_model_parameters : None, dict or Series, default None. + Parameters for the module temperature model, e.g., SAPM, Pvsyst, or + other. + + modules_per_string: int, default 1 + Number of modules per string in the array. + + strings: int, default 1 + Number of parallel strings in the array. + + racking_model : None or string, default None + Valid strings are 'open_rack', 'close_mount', and 'insulated_back'. + Used to identify a parameter set for the SAPM cell temperature model. + + """ + + def __init__(self, + surface_tilt=0, surface_azimuth=180, + albedo=None, surface_type=None, + module=None, module_type=None, + module_parameters=None, + temperature_model_parameters=None, + modules_per_string=1, strings=1, + racking_model=None, name=None): + self.surface_tilt = surface_tilt + self.surface_azimuth = surface_azimuth + + self.surface_type = surface_type + if albedo is None: + self.albedo = irradiance.SURFACE_ALBEDOS.get(surface_type, 0.25) + else: + self.albedo = albedo + + self.module = module + if module_parameters is None: + self.module_parameters = {} + else: + self.module_parameters = module_parameters + + self.module_type = module_type + self.racking_model = racking_model + + self.strings = strings + self.modules_per_string = modules_per_string + + if temperature_model_parameters is None: + self.temperature_model_parameters = \ + self._infer_temperature_model_params() + else: + self.temperature_model_parameters = temperature_model_parameters + + self.name = name + + def __repr__(self): + attrs = ['name', 'surface_tilt', 'surface_azimuth', 'module', + 'albedo', 'racking_model', 'module_type', + 'temperature_model_parameters', + 'strings', 'modules_per_string'] + return 'Array:\n ' + '\n '.join( + f'{attr}: {getattr(self, attr)}' for attr in attrs + ) + + def _infer_temperature_model_params(self): + # try to infer temperature model parameters from from racking_model + # and module_type + param_set = f'{self.racking_model}_{self.module_type}' + if param_set in temperature.TEMPERATURE_MODEL_PARAMETERS['sapm']: + return temperature._temperature_model_params('sapm', param_set) + elif 'freestanding' in param_set: + return temperature._temperature_model_params('pvsyst', + 'freestanding') + elif 'insulated' in param_set: # after SAPM to avoid confusing keys + return temperature._temperature_model_params('pvsyst', + 'insulated') + else: + return {} + + def _infer_cell_type(self): + + """ + Examines module_parameters and maps the Technology key for the CEC + database and the Material key for the Sandia database to a common + list of strings for cell type. + + Returns + ------- + cell_type: str + + """ + + _cell_type_dict = {'Multi-c-Si': 'multisi', + 'Mono-c-Si': 'monosi', + 'Thin Film': 'cigs', + 'a-Si/nc': 'asi', + 'CIS': 'cigs', + 'CIGS': 'cigs', + '1-a-Si': 'asi', + 'CdTe': 'cdte', + 'a-Si': 'asi', + '2-a-Si': None, + '3-a-Si': None, + 'HIT-Si': 'monosi', + 'mc-Si': 'multisi', + 'c-Si': 'multisi', + 'Si-Film': 'asi', + 'EFG mc-Si': 'multisi', + 'GaAs': None, + 'a-Si / mono-Si': 'monosi'} + + if 'Technology' in self.module_parameters.keys(): + # CEC module parameter set + cell_type = _cell_type_dict[self.module_parameters['Technology']] + elif 'Material' in self.module_parameters.keys(): + # Sandia module parameter set + cell_type = _cell_type_dict[self.module_parameters['Material']] + else: + cell_type = None + + return cell_type + + def get_aoi(self, solar_zenith, solar_azimuth): + """ + Get the angle of incidence on the array. + + Parameters + ---------- + solar_zenith : float or Series + Solar zenith angle. + solar_azimuth : float or Series + Solar azimuth angle + + Returns + ------- + aoi : Series + Then angle of incidence. + """ + return irradiance.aoi(self.surface_tilt, self.surface_azimuth, + solar_zenith, solar_azimuth) + + def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, + dni_extra=None, airmass=None, model='haydavies', + **kwargs): + """ + Get plane of array irradiance components. + + Uses the :py:func:`pvlib.irradiance.get_total_irradiance` function to + calculate the plane of array irradiance components for a surface + defined by ``self.surface_tilt`` and ``self.surface_azimuth`` with + albedo ``self.albedo``. + + Parameters + ---------- + solar_zenith : float or Series. + Solar zenith angle. + solar_azimuth : float or Series. + Solar azimuth angle. + dni : float or Series + Direct Normal Irradiance + ghi : float or Series + Global horizontal irradiance + dhi : float or Series + Diffuse horizontal irradiance + dni_extra : None, float or Series, default None + Extraterrestrial direct normal irradiance + airmass : None, float or Series, default None + Airmass + model : String, default 'haydavies' + Irradiance model. + + kwargs + Extra parameters passed to + :py:func:`pvlib.irradiance.get_total_irradiance`. + + Returns + ------- + poa_irradiance : DataFrame + Column names are: ``total, beam, sky, ground``. + """ + # not needed for all models, but this is easier + if dni_extra is None: + dni_extra = irradiance.get_extra_radiation(solar_zenith.index) + + if airmass is None: + airmass = atmosphere.get_relative_airmass(solar_zenith) + + return irradiance.get_total_irradiance(self.surface_tilt, + self.surface_azimuth, + solar_zenith, solar_azimuth, + dni, ghi, dhi, + dni_extra=dni_extra, + airmass=airmass, + model=model, + albedo=self.albedo, + **kwargs) + + def get_iam(self, aoi, iam_model='physical'): + """ + Determine the incidence angle modifier using the method specified by + ``iam_model``. + + Parameters for the selected IAM model are expected to be in + ``Array.module_parameters``. Default parameters are available for + the 'physical', 'ashrae' and 'martin_ruiz' models. + + Parameters + ---------- + aoi : numeric + The angle of incidence in degrees. + + aoi_model : string, default 'physical' + The IAM model to be used. Valid strings are 'physical', 'ashrae', + 'martin_ruiz' and 'sapm'. + + Returns + ------- + iam : numeric + The AOI modifier. + + Raises + ------ + ValueError + if `iam_model` is not a valid model name. + """ + model = iam_model.lower() + if model in ['ashrae', 'physical', 'martin_ruiz']: + param_names = iam._IAM_MODEL_PARAMS[model] + kwargs = _build_kwargs(param_names, self.module_parameters) + func = getattr(iam, model) + return func(aoi, **kwargs) + elif model == 'sapm': + return iam.sapm(aoi, self.module_parameters) + elif model == 'interp': + raise ValueError(model + ' is not implemented as an IAM model ' + 'option for Array') + else: + raise ValueError(model + ' is not a valid IAM model') + + def calcparams_desoto(effective_irradiance, temp_cell, alpha_sc, a_ref, I_L_ref, I_o_ref, R_sh_ref, R_s, EgRef=1.121, dEgdT=-0.0002677, diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 61c7227979..4e09f77f84 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -46,6 +46,33 @@ def cec_dc_snl_ac_system(cec_module_cs5p_220m, cec_inverter_parameters, return system +@pytest.fixture +def cec_dc_snl_ac_arrays(cec_module_cs5p_220m, cec_inverter_parameters, + sapm_temperature_cs5p_220m): + module_parameters = cec_module_cs5p_220m.copy() + module_parameters['b'] = 0.05 + module_parameters['EgRef'] = 1.121 + module_parameters['dEgdT'] = -0.0002677 + temp_model_params = sapm_temperature_cs5p_220m.copy() + array_one = pvsystem.Array( + surface_tilt=32.2, surface_azimuth=180, + module=module_parameters['Name'], + module_parameters=module_parameters.copy(), + temperature_model_parameters=temp_model_params.copy() + ) + array_two = pvsystem.Array( + surface_tilt=42.2, surface_azimuth=220, + module=module_parameters['Name'], + module_parameters=module_parameters.copy(), + temperature_model_parameters=temp_model_params.copy() + ) + system = PVSystem( + arrays=[array_one, array_two], + inverter_parameters=cec_inverter_parameters + ) + return system + + @pytest.fixture def cec_dc_native_snl_ac_system(cec_module_cs5p_220m, cec_inverter_parameters, sapm_temperature_cs5p_220m): @@ -74,6 +101,32 @@ def pvsyst_dc_snl_ac_system(pvsyst_module_params, cec_inverter_parameters, return system +@pytest.fixture +def pvsyst_dc_snl_ac_arrays(pvsyst_module_params, cec_inverter_parameters, + sapm_temperature_cs5p_220m): + module = 'PVsyst test module' + module_parameters = pvsyst_module_params + module_parameters['b'] = 0.05 + temp_model_params = sapm_temperature_cs5p_220m.copy() + array_one = pvsystem.Array( + surface_tilt=32.2, surface_azimuth=180, + module=module, + module_parameters=module_parameters.copy(), + temperature_model_parameters=temp_model_params.copy() + ) + array_two = pvsystem.Array( + surface_tilt=42.2, surface_azimuth=220, + module=module, + module_parameters=module_parameters.copy(), + temperature_model_parameters=temp_model_params.copy() + ) + system = PVSystem( + arrays=[array_one, array_two], + inverter_parameters=cec_inverter_parameters + ) + return system + + @pytest.fixture def cec_dc_adr_ac_system(sam_data, cec_module_cs5p_220m, sapm_temperature_cs5p_220m): @@ -197,6 +250,52 @@ def total_irrad(weather): 'poa_diffuse': [300., 200.]}, index=weather.index) +@pytest.fixture(scope='function') +def sapm_dc_snl_ac_system_Array(sapm_module_params, cec_inverter_parameters, + sapm_temperature_cs5p_220m): + module = 'Canadian_Solar_CS5P_220M___2009_' + module_parameters = sapm_module_params.copy() + temp_model_params = sapm_temperature_cs5p_220m.copy() + array_one = pvsystem.Array(surface_tilt=32, surface_azimuth=180, + albedo=0.2, module=module, + module_parameters=module_parameters, + temperature_model_parameters=temp_model_params, + modules_per_string=1, + strings=1) + array_two = pvsystem.Array(surface_tilt=15, surface_azimuth=180, + albedo=0.2, module=module, + module_parameters=module_parameters, + temperature_model_parameters=temp_model_params, + modules_per_string=1, + strings=1) + return PVSystem(arrays=[array_one, array_two], + inverter_parameters=cec_inverter_parameters) + + +@pytest.fixture(scope='function') +def sapm_dc_snl_ac_system_same_arrays(sapm_module_params, + cec_inverter_parameters, + sapm_temperature_cs5p_220m): + """A system with two identical arrays.""" + module = 'Canadian_Solar_CS5P_220M___2009_' + module_parameters = sapm_module_params.copy() + temp_model_params = sapm_temperature_cs5p_220m.copy() + array_one = pvsystem.Array(surface_tilt=32.2, surface_azimuth=180, + module=module, + module_parameters=module_parameters, + temperature_model_parameters=temp_model_params, + modules_per_string=1, + strings=1) + array_two = pvsystem.Array(surface_tilt=32.2, surface_azimuth=180, + module=module, + module_parameters=module_parameters, + temperature_model_parameters=temp_model_params, + modules_per_string=1, + strings=1) + return PVSystem(arrays=[array_one, array_two], + inverter_parameters=cec_inverter_parameters) + + def test_ModelChain_creation(sapm_dc_snl_ac_system, location): ModelChain(sapm_dc_snl_ac_system, location) @@ -235,13 +334,150 @@ def test_run_model_with_irradiance(sapm_dc_snl_ac_system, location): times = pd.date_range('20160101 1200-0700', periods=2, freq='6H') irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, index=times) - ac = mc.run_model(irradiance).ac + ac = mc.run_model(irradiance).results.ac expected = pd.Series(np.array([187.80746494643176, -0.02]), index=times) assert_series_equal(ac, expected) +@pytest.fixture(scope='function') +def multi_array_sapm_dc_snl_ac_system( + sapm_temperature_cs5p_220m, sapm_module_params, + cec_inverter_parameters): + module_parameters = sapm_module_params + temp_model_parameters = sapm_temperature_cs5p_220m.copy() + inverter_parameters = cec_inverter_parameters + array_one = pvsystem.Array( + surface_tilt=32.2, surface_azimuth=180, + module_parameters=module_parameters, + temperature_model_parameters=temp_model_parameters + ) + array_two = pvsystem.Array( + surface_tilt=32.2, surface_azimuth=220, + module_parameters=module_parameters, + temperature_model_parameters=temp_model_parameters + ) + two_array_system = PVSystem( + arrays=[array_one, array_two], + inverter_parameters=inverter_parameters + ) + array_one_system = PVSystem( + arrays=[array_one], + inverter_parameters=inverter_parameters + ) + array_two_system = PVSystem( + arrays=[array_two], + inverter_parameters=inverter_parameters + ) + return {'two_array_system': two_array_system, + 'array_one_system': array_one_system, + 'array_two_system': array_two_system} + + +def test_run_model_from_irradiance_arrays_no_loss( + multi_array_sapm_dc_snl_ac_system, location): + mc_both = ModelChain( + multi_array_sapm_dc_snl_ac_system['two_array_system'], + location, + aoi_model='no_loss', + spectral_model='no_loss', + losses_model='no_loss' + ) + mc_one = ModelChain( + multi_array_sapm_dc_snl_ac_system['array_one_system'], + location, + aoi_model='no_loss', + spectral_model='no_loss', + losses_model='no_loss' + ) + mc_two = ModelChain( + multi_array_sapm_dc_snl_ac_system['array_two_system'], + location, + aoi_model='no_loss', + spectral_model='no_loss', + losses_model='no_loss' + ) + times = pd.date_range('20160101 1200-0700', periods=2, freq='6H') + irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, + index=times) + mc_one.run_model(irradiance) + mc_two.run_model(irradiance) + mc_both.run_model(irradiance) + assert_frame_equal( + mc_both.results.dc[0], + mc_one.results.dc + ) + assert_frame_equal( + mc_both.results.dc[1], + mc_two.results.dc + ) + + +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_run_model_from_irradiance_arrays_no_loss_input_type( + multi_array_sapm_dc_snl_ac_system, location, input_type): + mc_both = ModelChain( + multi_array_sapm_dc_snl_ac_system['two_array_system'], + location, + aoi_model='no_loss', + spectral_model='no_loss', + losses_model='no_loss' + ) + mc_one = ModelChain( + multi_array_sapm_dc_snl_ac_system['array_one_system'], + location, + aoi_model='no_loss', + spectral_model='no_loss', + losses_model='no_loss' + ) + mc_two = ModelChain( + multi_array_sapm_dc_snl_ac_system['array_two_system'], + location, + aoi_model='no_loss', + spectral_model='no_loss', + losses_model='no_loss' + ) + times = pd.date_range('20160101 1200-0700', periods=2, freq='6H') + irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, + index=times) + mc_one.run_model(irradiance) + mc_two.run_model(irradiance) + mc_both.run_model(input_type((irradiance, irradiance))) + assert_frame_equal( + mc_both.results.dc[0], mc_one.results.dc + ) + assert_frame_equal( + mc_both.results.dc[1], mc_two.results.dc + ) + + +@pytest.mark.parametrize('inverter', ['adr', 'pvwatts']) +def test_ModelChain_invalid_inverter_params_arrays( + inverter, sapm_dc_snl_ac_system_same_arrays, + location, adr_inverter_parameters): + inverter_params = {'adr': adr_inverter_parameters, + 'pvwatts': {'pdc0': 220, 'eta_inv_nom': 0.95}} + sapm_dc_snl_ac_system_same_arrays.inverter_parameters = \ + inverter_params[inverter] + with pytest.raises(ValueError, + match=r'Only sandia_multi supports multiple Arrays\.'): + ModelChain(sapm_dc_snl_ac_system_same_arrays, location) + + +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_prepare_inputs_multi_weather( + sapm_dc_snl_ac_system_Array, location, input_type): + times = pd.date_range(start='20160101 1200-0700', + end='20160101 1800-0700', freq='6H') + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + weather = pd.DataFrame({'ghi': 1, 'dhi': 1, 'dni': 1}, + index=times) + mc.prepare_inputs(input_type((weather, weather))) + num_arrays = sapm_dc_snl_ac_system_Array.num_arrays + assert len(mc.results.total_irrad) == num_arrays + + def test_prepare_inputs_no_irradiance(sapm_dc_snl_ac_system, location): mc = ModelChain(sapm_dc_snl_ac_system, location) weather = pd.DataFrame() @@ -249,6 +485,81 @@ def test_prepare_inputs_no_irradiance(sapm_dc_snl_ac_system, location): mc.prepare_inputs(weather) +def test_prepare_inputs_arrays_one_missing_irradiance( + sapm_dc_snl_ac_system_Array, location): + """If any of the input DataFrames is missing a column then a + ValueError is raised.""" + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + weather = pd.DataFrame( + {'ghi': [1], 'dhi': [1], 'dni': [1]} + ) + weather_incomplete = pd.DataFrame( + {'ghi': [1], 'dhi': [1]} + ) + with pytest.raises(ValueError, + match=r"Incomplete input data\. .*"): + mc.prepare_inputs((weather, weather_incomplete)) + with pytest.raises(ValueError, + match=r"Incomplete input data\. .*"): + mc.prepare_inputs((weather_incomplete, weather)) + + +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_prepare_inputs_weather_wrong_length( + sapm_dc_snl_ac_system_Array, location, input_type): + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + weather = pd.DataFrame({'ghi': [1], 'dhi': [1], 'dni': [1]}) + with pytest.raises(ValueError, + match="Input must be same length as number of Arrays " + r"in system\. Expected 2, got 1\."): + mc.prepare_inputs(input_type((weather,))) + with pytest.raises(ValueError, + match="Input must be same length as number of Arrays " + r"in system\. Expected 2, got 3\."): + mc.prepare_inputs(input_type((weather, weather, weather))) + + +def test_ModelChain_times_error_arrays(sapm_dc_snl_ac_system_Array, location): + """ModelChain.times is assigned a single index given multiple weather + DataFrames. + """ + error_str = r"Input DataFrames must have same index\." + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + irradiance = {'ghi': [1, 2], 'dhi': [1, 2], 'dni': [1, 2]} + times_one = pd.date_range(start='1/1/2020', freq='6H', periods=2) + times_two = pd.date_range(start='1/1/2020 00:15', freq='6H', periods=2) + weather_one = pd.DataFrame(irradiance, index=times_one) + weather_two = pd.DataFrame(irradiance, index=times_two) + with pytest.raises(ValueError, match=error_str): + mc.prepare_inputs((weather_one, weather_two)) + # test with overlapping, but differently sized indices. + times_three = pd.date_range(start='1/1/2020', freq='6H', periods=3) + irradiance_three = irradiance + irradiance_three['ghi'].append(3) + irradiance_three['dhi'].append(3) + irradiance_three['dni'].append(3) + weather_three = pd.DataFrame(irradiance_three, index=times_three) + with pytest.raises(ValueError, match=error_str): + mc.prepare_inputs((weather_one, weather_three)) + + +def test_ModelChain_times_arrays(sapm_dc_snl_ac_system_Array, location): + """ModelChain.times is assigned a single index given multiple weather + DataFrames. + """ + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + irradiance_one = {'ghi': [1, 2], 'dhi': [1, 2], 'dni': [1, 2]} + irradiance_two = {'ghi': [2, 1], 'dhi': [2, 1], 'dni': [2, 1]} + times = pd.date_range(start='1/1/2020', freq='6H', periods=2) + weather_one = pd.DataFrame(irradiance_one, index=times) + weather_two = pd.DataFrame(irradiance_two, index=times) + mc.prepare_inputs((weather_one, weather_two)) + assert mc.times.equals(times) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc.prepare_inputs(weather_one) + assert mc.times.equals(times) + + @pytest.mark.parametrize("missing", ['dhi', 'ghi', 'dni']) def test_prepare_inputs_missing_irrad_component( sapm_dc_snl_ac_system, location, missing): @@ -259,13 +570,31 @@ def test_prepare_inputs_missing_irrad_component( mc.prepare_inputs(weather) +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_run_model_arrays_weather(sapm_dc_snl_ac_system_same_arrays, location, + input_type): + mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) + times = pd.date_range('20200101 1200-0700', periods=2, freq='2H') + weather_one = pd.DataFrame({'dni': [900, 800], + 'ghi': [600, 500], + 'dhi': [150, 100]}, + index=times) + weather_two = pd.DataFrame({'dni': [500, 400], + 'ghi': [300, 200], + 'dhi': [75, 65]}, + index=times) + mc.run_model(input_type((weather_one, weather_two))) + assert (mc.results.dc[0] != mc.results.dc[1]).all().all() + assert not mc.results.ac.empty + + def test_run_model_perez(sapm_dc_snl_ac_system, location): mc = ModelChain(sapm_dc_snl_ac_system, location, transposition_model='perez') times = pd.date_range('20160101 1200-0700', periods=2, freq='6H') irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, index=times) - ac = mc.run_model(irradiance).ac + ac = mc.run_model(irradiance).results.ac expected = pd.Series(np.array([187.94295642, -2.00000000e-02]), index=times) @@ -279,7 +608,7 @@ def test_run_model_gueymard_perez(sapm_dc_snl_ac_system, location): times = pd.date_range('20160101 1200-0700', periods=2, freq='6H') irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, index=times) - ac = mc.run_model(irradiance).ac + ac = mc.run_model(irradiance).results.ac expected = pd.Series(np.array([187.94317405, -2.00000000e-02]), index=times) @@ -300,7 +629,7 @@ def test_run_model_with_weather_sapm_temp(sapm_dc_snl_ac_system, location, # assert_series_equal on call_args assert_series_equal(m_sapm.call_args[0][1], weather['temp_air']) # temp assert_series_equal(m_sapm.call_args[0][2], weather['wind_speed']) # wind - assert not mc.ac.empty + assert not mc.results.ac.empty def test_run_model_with_weather_pvsyst_temp(sapm_dc_snl_ac_system, location, @@ -318,7 +647,7 @@ def test_run_model_with_weather_pvsyst_temp(sapm_dc_snl_ac_system, location, assert m_pvsyst.call_count == 1 assert_series_equal(m_pvsyst.call_args[0][1], weather['temp_air']) assert_series_equal(m_pvsyst.call_args[0][2], weather['wind_speed']) - assert not mc.ac.empty + assert not mc.results.ac.empty def test_run_model_with_weather_faiman_temp(sapm_dc_snl_ac_system, location, @@ -336,7 +665,7 @@ def test_run_model_with_weather_faiman_temp(sapm_dc_snl_ac_system, location, assert m_faiman.call_count == 1 assert_series_equal(m_faiman.call_args[0][1], weather['temp_air']) assert_series_equal(m_faiman.call_args[0][2], weather['wind_speed']) - assert not mc.ac.empty + assert not mc.results.ac.empty def test_run_model_with_weather_fuentes_temp(sapm_dc_snl_ac_system, location, @@ -353,7 +682,7 @@ def test_run_model_with_weather_fuentes_temp(sapm_dc_snl_ac_system, location, assert m_fuentes.call_count == 1 assert_series_equal(m_fuentes.call_args[0][1], weather['temp_air']) assert_series_equal(m_fuentes.call_args[0][2], weather['wind_speed']) - assert not mc.ac.empty + assert not mc.results.ac.empty def test_run_model_tracker(sapm_dc_snl_ac_system, location, weather, mocker): @@ -367,10 +696,12 @@ def test_run_model_tracker(sapm_dc_snl_ac_system, location, weather, mocker): mc = ModelChain(system, location) mc.run_model(weather) assert system.singleaxis.call_count == 1 - assert (mc.tracking.columns == ['tracker_theta', 'aoi', 'surface_azimuth', - 'surface_tilt']).all() - assert mc.ac[0] > 0 - assert np.isnan(mc.ac[1]) + assert (mc.results.tracking.columns == ['tracker_theta', + 'aoi', + 'surface_azimuth', + 'surface_tilt']).all() + assert mc.results.ac[0] > 0 + assert np.isnan(mc.results.ac[1]) def test__assign_total_irrad(sapm_dc_snl_ac_system, location, weather, @@ -378,7 +709,7 @@ def test__assign_total_irrad(sapm_dc_snl_ac_system, location, weather, data = pd.concat([weather, total_irrad], axis=1) mc = ModelChain(sapm_dc_snl_ac_system, location) mc._assign_total_irrad(data) - assert_frame_equal(mc.total_irrad, total_irrad) + assert_frame_equal(mc.results.total_irrad, total_irrad) def test_prepare_inputs_from_poa(sapm_dc_snl_ac_system, location, @@ -395,7 +726,55 @@ def test_prepare_inputs_from_poa(sapm_dc_snl_ac_system, location, # weather attribute assert_frame_equal(mc.weather, weather_expected) # total_irrad attribute - assert_frame_equal(mc.total_irrad, total_irrad) + assert_frame_equal(mc.results.total_irrad, total_irrad) + + +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_prepare_inputs_from_poa_multi_data( + sapm_dc_snl_ac_system_Array, location, total_irrad, weather, + input_type): + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + poa = pd.concat([weather, total_irrad], axis=1) + mc.prepare_inputs_from_poa(input_type((poa, poa))) + num_arrays = sapm_dc_snl_ac_system_Array.num_arrays + assert len(mc.results.total_irrad) == num_arrays + + +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_prepare_inputs_from_poa_wrong_number_arrays( + sapm_dc_snl_ac_system_Array, location, total_irrad, weather, + input_type): + len_error = r"Input must be same length as number of Arrays in system\. " \ + r"Expected 2, got [0-9]+\." + type_error = r"Input must be a tuple of length 2, got .*\." + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + poa = pd.concat([weather, total_irrad], axis=1) + with pytest.raises(TypeError, match=type_error): + mc.prepare_inputs_from_poa(poa) + with pytest.raises(ValueError, match=len_error): + mc.prepare_inputs_from_poa(input_type((poa,))) + with pytest.raises(ValueError, match=len_error): + mc.prepare_inputs_from_poa(input_type((poa, poa, poa))) + + +def test_prepare_inputs_from_poa_arrays_different_indices( + sapm_dc_snl_ac_system_Array, location, total_irrad, weather): + error_str = r"Input DataFrames must have same index\." + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + poa = pd.concat([weather, total_irrad], axis=1) + with pytest.raises(ValueError, match=error_str): + mc.prepare_inputs_from_poa((poa, poa.shift(periods=1, freq='6H'))) + + +def test_prepare_inputs_from_poa_arrays_missing_column( + sapm_dc_snl_ac_system_Array, location, weather, total_irrad): + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + poa = pd.concat([weather, total_irrad], axis=1) + with pytest.raises(ValueError, match=r"Incomplete input data\. " + r"Data needs to contain .*\. " + r"Detected data in element 1 " + r"contains: .*"): + mc.prepare_inputs_from_poa((poa, poa.drop(columns='poa_global'))) def test__prepare_temperature(sapm_dc_snl_ac_system, location, weather, @@ -409,25 +788,103 @@ def test__prepare_temperature(sapm_dc_snl_ac_system, location, weather, mc._assign_total_irrad(data) mc._prepare_temperature(data) expected = pd.Series([48.928025, 38.080016], index=data.index) - assert_series_equal(mc.cell_temperature, expected) + assert_series_equal(mc.results.cell_temperature, expected) data['module_temperature'] = [40., 30.] mc._prepare_temperature(data) expected = pd.Series([42.4, 31.5], index=data.index) - assert_series_equal(mc.cell_temperature, expected) + assert_series_equal(mc.results.cell_temperature, expected) data['cell_temperature'] = [50., 35.] mc._prepare_temperature(data) - assert_series_equal(mc.cell_temperature, data['cell_temperature']) + assert_series_equal(mc.results.cell_temperature, data['cell_temperature']) + + +def test__prepare_temperature_arrays_weather(sapm_dc_snl_ac_system_same_arrays, + location, weather, + total_irrad): + data = weather.copy() + data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad + data_two = data.copy() + mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location, + aoi_model='no_loss', spectral_model='no_loss') + # prepare_temperature expects mc.total_irrad and mc.weather to be set + mc._assign_weather((data, data_two)) + mc._assign_total_irrad((data, data_two)) + mc._prepare_temperature((data, data_two)) + expected = pd.Series([48.928025, 38.080016], index=data.index) + assert_series_equal(mc.results.cell_temperature[0], expected) + assert_series_equal(mc.results.cell_temperature[1], expected) + data['module_temperature'] = [40., 30.] + mc._prepare_temperature((data, data_two)) + expected = pd.Series([42.4, 31.5], index=data.index) + assert (mc.results.cell_temperature[1] != expected).all() + assert_series_equal(mc.results.cell_temperature[0], expected) + data['cell_temperature'] = [50., 35.] + mc._prepare_temperature((data, data_two)) + assert_series_equal( + mc.results.cell_temperature[0], data['cell_temperature']) + data_two['module_temperature'] = [40., 30.] + mc._prepare_temperature((data, data_two)) + assert_series_equal(mc.results.cell_temperature[1], expected) + assert_series_equal( + mc.results.cell_temperature[0], data['cell_temperature']) + data_two['cell_temperature'] = [10.0, 20.0] + mc._prepare_temperature((data, data_two)) + assert_series_equal( + mc.results.cell_temperature[1], data_two['cell_temperature']) + assert_series_equal( + mc.results.cell_temperature[0], data['cell_temperature']) + + +@pytest.mark.parametrize('temp_params,temp_model', + [({'a': -3.47, 'b': -.0594, 'deltaT': 3}, + ModelChain.sapm_temp), + ({'u_c': 29.0, 'u_v': 0}, + ModelChain.pvsyst_temp), + ({'u0': 25.0, 'u1': 6.84}, + ModelChain.faiman_temp), + ({'noct_installed': 45}, + ModelChain.fuentes_temp)]) +def test_temperature_models_arrays_multi_weather( + temp_params, temp_model, + sapm_dc_snl_ac_system_same_arrays, + location, weather, total_irrad): + sapm_dc_snl_ac_system_same_arrays.temperature_model_parameters = \ + temp_params + # set air temp so it does not default to the same value for both arrays + weather['temp_air'] = 25 + weather_one = weather + weather_two = weather.copy() * 0.5 + mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location, + aoi_model='no_loss', spectral_model='no_loss') + mc.prepare_inputs((weather_one, weather_two)) + temp_model(mc) + assert (mc.results.cell_temperature[0] + != mc.results.cell_temperature[1]).all() def test_run_model_from_poa(sapm_dc_snl_ac_system, location, total_irrad): mc = ModelChain(sapm_dc_snl_ac_system, location, aoi_model='no_loss', spectral_model='no_loss') - ac = mc.run_model_from_poa(total_irrad).ac + ac = mc.run_model_from_poa(total_irrad).results.ac expected = pd.Series(np.array([149.280238, 96.678385]), index=total_irrad.index) assert_series_equal(ac, expected) +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_run_model_from_poa_arrays(sapm_dc_snl_ac_system_Array, location, + weather, total_irrad, input_type): + data = weather.copy() + data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad + mc = ModelChain(sapm_dc_snl_ac_system_Array, location, aoi_model='no_loss', + spectral_model='no_loss') + mc.run_model_from_poa(input_type((data, data))) + # arrays have different orientation, but should give same dc power + # because we are the same passing POA irradiance and air + # temperature. + assert_frame_equal(mc.results.dc[0], mc.results.dc[1]) + + def test_run_model_from_poa_tracking(sapm_dc_snl_ac_system, location, total_irrad): system = SingleAxisTracker( @@ -438,9 +895,11 @@ def test_run_model_from_poa_tracking(sapm_dc_snl_ac_system, location, inverter_parameters=sapm_dc_snl_ac_system.inverter_parameters) mc = ModelChain(system, location, aoi_model='no_loss', spectral_model='no_loss') - ac = mc.run_model_from_poa(total_irrad).ac - assert (mc.tracking.columns == ['tracker_theta', 'aoi', 'surface_azimuth', - 'surface_tilt']).all() + ac = mc.run_model_from_poa(total_irrad).results.ac + assert (mc.results.tracking.columns == ['tracker_theta', + 'aoi', + 'surface_azimuth', + 'surface_tilt']).all() expected = pd.Series(np.array([149.280238, 96.678385]), index=total_irrad.index) assert_series_equal(ac, expected) @@ -453,15 +912,96 @@ def test_run_model_from_effective_irradiance(sapm_dc_snl_ac_system, location, data['effective_irradiance'] = data['poa_global'] mc = ModelChain(sapm_dc_snl_ac_system, location, aoi_model='no_loss', spectral_model='no_loss') - ac = mc.run_model_from_effective_irradiance(data).ac + ac = mc.run_model_from_effective_irradiance(data).results.ac expected = pd.Series(np.array([149.280238, 96.678385]), index=data.index) assert_series_equal(ac, expected) +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_run_model_from_effective_irradiance_arrays_error( + sapm_dc_snl_ac_system_Array, location, weather, total_irrad, + input_type): + data = weather.copy() + data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad + data['effetive_irradiance'] = data['poa_global'] + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + len_error = r"Input must be same length as number of Arrays in system\. " \ + r"Expected 2, got [0-9]+\." + type_error = r"Input must be a tuple of length 2, got DataFrame\." + with pytest.raises(TypeError, match=type_error): + mc.run_model_from_effective_irradiance(data) + with pytest.raises(ValueError, match=len_error): + mc.run_model_from_effective_irradiance(input_type((data,))) + with pytest.raises(ValueError, match=len_error): + mc.run_model_from_effective_irradiance(input_type((data, data, data))) + with pytest.raises(ValueError, + match=r"Input DataFrames must have same index\."): + mc.run_model_from_effective_irradiance( + (data, data.shift(periods=1, freq='6H')) + ) + + +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_run_model_from_effective_irradiance_arrays( + sapm_dc_snl_ac_system_Array, location, weather, total_irrad, + input_type): + data = weather.copy() + data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad + data['effective_irradiance'] = data['poa_global'] + data['cell_temperature'] = 40 + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc.run_model_from_effective_irradiance(input_type((data, data))) + # arrays have different orientation, but should give same dc power + # because we are the same passing effective irradiance and cell + # temperature. + assert_frame_equal(mc.results.dc[0], mc.results.dc[1]) + # test that unequal inputs create unequal results + data_two = data.copy() + data_two['effective_irradiance'] = data['poa_global'] * 0.5 + mc.run_model_from_effective_irradiance(input_type((data, data_two))) + assert (mc.results.dc[0] != mc.results.dc[1]).all().all() + + +def test_run_model_from_effective_irradiance_minimal_input( + sapm_dc_snl_ac_system, sapm_dc_snl_ac_system_Array, + location, total_irrad): + data = pd.DataFrame({'effective_irradiance': total_irrad['poa_global'], + 'cell_temperature': 40}, + index=total_irrad.index) + mc = ModelChain(sapm_dc_snl_ac_system, location) + mc.run_model_from_effective_irradiance(data) + # make sure, for a single Array, the result is the correct type and value + assert_series_equal(mc.results.cell_temperature, data['cell_temperature']) + assert not mc.results.dc.empty + assert not mc.results.ac.empty + # test with multiple arrays + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc.run_model_from_effective_irradiance((data, data)) + assert_frame_equal(mc.results.dc[0], mc.results.dc[1]) + assert not mc.results.ac.empty + + +def test_run_model_from_effective_irradiance_missing_poa( + sapm_dc_snl_ac_system_Array, location, total_irrad): + data_incomplete = pd.DataFrame( + {'effective_irradiance': total_irrad['poa_global'], + 'poa_global': total_irrad['poa_global']}, + index=total_irrad.index) + data_complete = pd.DataFrame( + {'effective_irradiance': total_irrad['poa_global'], + 'cell_temperature': 30}, + index=total_irrad.index) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + with pytest.raises(ValueError, + match="you must provide 'poa_global' for all Arrays"): + mc.run_model_from_effective_irradiance( + (data_complete, data_incomplete)) + + def poadc(mc): - mc.dc = mc.total_irrad['poa_global'] * 0.2 - mc.dc.name = None # assert_series_equal will fail without this + mc.results.dc = mc.results.total_irrad['poa_global'] * 0.2 + mc.results.dc.name = None # assert_series_equal will fail without this @pytest.mark.parametrize('dc_model', [ @@ -495,13 +1035,42 @@ def test_infer_dc_model(sapm_dc_snl_ac_system, cec_dc_snl_ac_system, # remove Adjust from model parameters for desoto, singlediode if dc_model in ['desoto', 'singlediode']: system.module_parameters.pop('Adjust') - m = mocker.spy(system, dc_model_function[dc_model]) + m = mocker.spy(pvsystem, dc_model_function[dc_model]) mc = ModelChain(system, location, aoi_model='no_loss', spectral_model='no_loss', temperature_model=temp_model_function[dc_model]) mc.run_model(weather) assert m.call_count == 1 - assert isinstance(mc.dc, (pd.Series, pd.DataFrame)) + assert isinstance(mc.results.dc, (pd.Series, pd.DataFrame)) + + +@pytest.mark.parametrize('dc_model', ['cec', 'desoto', 'pvsyst']) +def test_singlediode_dc_arrays(location, dc_model, + cec_dc_snl_ac_arrays, + pvsyst_dc_snl_ac_arrays, + weather): + systems = {'cec': cec_dc_snl_ac_arrays, + 'pvsyst': pvsyst_dc_snl_ac_arrays, + 'desoto': cec_dc_snl_ac_arrays} + temp_sapm = {'a': -3.40641, 'b': -0.0842075, 'deltaT': 3} + temp_pvsyst = {'u_c': 29.0, 'u_v': 0} + temp_model_params = {'cec': temp_sapm, + 'desoto': temp_sapm, + 'pvsyst': temp_pvsyst} + temp_model = {'cec': 'sapm', 'desoto': 'sapm', 'pvsyst': 'pvsyst'} + system = systems[dc_model] + system.temperature_model_parameters = temp_model_params[dc_model] + if dc_model == 'desoto': + for module_parameters in system.module_parameters: + module_parameters.pop('Adjust') + mc = ModelChain(system, location, + aoi_model='no_loss', spectral_model='no_loss', + temperature_model=temp_model[dc_model]) + mc.run_model(weather) + assert isinstance(mc.results.dc, tuple) + assert len(mc.results.dc) == system.num_arrays + for dc in mc.results.dc: + assert isinstance(dc, (pd.Series, pd.DataFrame)) @pytest.mark.parametrize('dc_model', ['sapm', 'cec', 'cec_native']) @@ -568,22 +1137,25 @@ def test_dc_model_user_func(pvwatts_dc_pvwatts_ac_system, location, weather, aoi_model='no_loss', spectral_model='no_loss') mc.run_model(weather) assert m.call_count == 1 - assert isinstance(mc.ac, (pd.Series, pd.DataFrame)) - assert not mc.ac.empty + assert isinstance(mc.results.ac, (pd.Series, pd.DataFrame)) + assert not mc.results.ac.empty def acdc(mc): - mc.ac = mc.dc + mc.results.ac = mc.results.dc -@pytest.mark.parametrize('ac_model', ['sandia', 'adr', 'pvwatts']) +@pytest.mark.parametrize('ac_model', ['sandia', 'adr', + 'pvwatts', 'sandia_multi']) def test_ac_models(sapm_dc_snl_ac_system, cec_dc_adr_ac_system, pvwatts_dc_pvwatts_ac_system, location, ac_model, weather, mocker): ac_systems = {'sandia': sapm_dc_snl_ac_system, + 'sandia_multi': sapm_dc_snl_ac_system, 'adr': cec_dc_adr_ac_system, 'pvwatts': pvwatts_dc_pvwatts_ac_system} ac_method_name = {'sandia': 'snlinverter', + 'sandia_multi': 'sandia_multi', 'adr': 'adrinverter', 'pvwatts': 'pvwatts_ac'} system = ac_systems[ac_model] @@ -593,9 +1165,9 @@ def test_ac_models(sapm_dc_snl_ac_system, cec_dc_adr_ac_system, m = mocker.spy(system, ac_method_name[ac_model]) mc.run_model(weather) assert m.call_count == 1 - assert isinstance(mc.ac, pd.Series) - assert not mc.ac.empty - assert mc.ac[1] < 1 + assert isinstance(mc.results.ac, pd.Series) + assert not mc.results.ac.empty + assert mc.results.ac[1] < 1 # TODO in v0.9: remove this test for a deprecation warning @@ -619,8 +1191,8 @@ def test_ac_model_user_func(pvwatts_dc_pvwatts_ac_system, location, weather, aoi_model='no_loss', spectral_model='no_loss') mc.run_model(weather) assert m.call_count == 1 - assert_series_equal(mc.ac, mc.dc) - assert not mc.ac.empty + assert_series_equal(mc.results.ac, mc.results.dc) + assert not mc.results.ac.empty def test_ac_model_not_a_model(pvwatts_dc_pvwatts_ac_system, location, weather): @@ -631,8 +1203,21 @@ def test_ac_model_not_a_model(pvwatts_dc_pvwatts_ac_system, location, weather): spectral_model='no_loss') +def test_infer_ac_model_invalid_params(location): + # only the keys are relevant here, using arbitrary values + module_parameters = {'pdc0': 1, 'gamma_pdc': 1} + system = pvsystem.PVSystem( + arrays=[pvsystem.Array( + module_parameters=module_parameters + )], + inverter_parameters={'foo': 1, 'bar': 2} + ) + with pytest.raises(ValueError, match='could not infer AC model'): + ModelChain(system, location) + + def constant_aoi_loss(mc): - mc.aoi_modifier = 0.9 + mc.results.aoi_modifier = 0.9 @pytest.mark.parametrize('aoi_model', [ @@ -645,20 +1230,20 @@ def test_aoi_models(sapm_dc_snl_ac_system, location, aoi_model, m = mocker.spy(sapm_dc_snl_ac_system, 'get_iam') mc.run_model(weather=weather) assert m.call_count == 1 - assert isinstance(mc.ac, pd.Series) - assert not mc.ac.empty - assert mc.ac[0] > 150 and mc.ac[0] < 200 - assert mc.ac[1] < 1 + assert isinstance(mc.results.ac, pd.Series) + assert not mc.results.ac.empty + assert mc.results.ac[0] > 150 and mc.results.ac[0] < 200 + assert mc.results.ac[1] < 1 def test_aoi_model_no_loss(sapm_dc_snl_ac_system, location, weather): mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm', aoi_model='no_loss', spectral_model='no_loss') mc.run_model(weather) - assert mc.aoi_modifier == 1.0 - assert not mc.ac.empty - assert mc.ac[0] > 150 and mc.ac[0] < 200 - assert mc.ac[1] < 1 + assert mc.results.aoi_modifier == 1.0 + assert not mc.results.ac.empty + assert mc.results.ac[0] > 150 and mc.results.ac[0] < 200 + assert mc.results.ac[1] < 1 def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker): @@ -667,10 +1252,10 @@ def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker): aoi_model=constant_aoi_loss, spectral_model='no_loss') mc.run_model(weather) assert m.call_count == 1 - assert mc.aoi_modifier == 0.9 - assert not mc.ac.empty - assert mc.ac[0] > 140 and mc.ac[0] < 200 - assert mc.ac[1] < 1 + assert mc.results.aoi_modifier == 0.9 + assert not mc.results.ac.empty + assert mc.results.ac[0] > 140 and mc.results.ac[0] < 200 + assert mc.results.ac[1] < 1 @pytest.mark.parametrize('aoi_model', [ @@ -693,7 +1278,7 @@ def test_infer_aoi_model_invalid(location, system_no_aoi): def constant_spectral_loss(mc): - mc.spectral_modifier = 0.9 + mc.results.spectral_modifier = 0.9 @pytest.mark.parametrize('spectral_model', [ @@ -705,13 +1290,13 @@ def test_spectral_models(sapm_dc_snl_ac_system, location, spectral_model, weather['precipitable_water'] = [0.3, 0.5] mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm', aoi_model='no_loss', spectral_model=spectral_model) - spectral_modifier = mc.run_model(weather).spectral_modifier + spectral_modifier = mc.run_model(weather).results.spectral_modifier assert isinstance(spectral_modifier, (pd.Series, float, int)) def constant_losses(mc): mc.losses = 0.9 - mc.dc *= mc.losses + mc.results.dc *= mc.losses def test_losses_models_pvwatts(pvwatts_dc_pvwatts_ac_system, location, weather, @@ -725,16 +1310,35 @@ def test_losses_models_pvwatts(pvwatts_dc_pvwatts_ac_system, location, weather, mc.run_model(weather) assert m.call_count == 1 m.assert_called_with(age=age) - assert isinstance(mc.ac, (pd.Series, pd.DataFrame)) - assert not mc.ac.empty + assert isinstance(mc.results.ac, (pd.Series, pd.DataFrame)) + assert not mc.results.ac.empty # check that we're applying correction to dc # GH 696 - dc_with_loss = mc.dc + dc_with_loss = mc.results.dc mc = ModelChain(pvwatts_dc_pvwatts_ac_system, location, dc_model='pvwatts', aoi_model='no_loss', spectral_model='no_loss', losses_model='no_loss') mc.run_model(weather) - assert not np.allclose(mc.dc, dc_with_loss, equal_nan=True) + assert not np.allclose(mc.results.dc, dc_with_loss, equal_nan=True) + + +def test_losses_models_pvwatts_arrays(multi_array_sapm_dc_snl_ac_system, + location, weather): + age = 1 + system_both = multi_array_sapm_dc_snl_ac_system['two_array_system'] + system_both.losses_parameters = dict(age=age) + mc = ModelChain(system_both, location, + aoi_model='no_loss', spectral_model='no_loss', + losses_model='pvwatts') + mc.run_model(weather) + dc_with_loss = mc.results.dc + mc = ModelChain(system_both, location, + aoi_model='no_loss', spectral_model='no_loss', + losses_model='no_loss') + mc.run_model(weather) + assert not np.allclose(mc.results.dc[0], dc_with_loss[0], equal_nan=True) + assert not np.allclose(mc.results.dc[1], dc_with_loss[1], equal_nan=True) + assert not mc.results.ac.empty def test_losses_models_ext_def(pvwatts_dc_pvwatts_ac_system, location, weather, @@ -745,9 +1349,9 @@ def test_losses_models_ext_def(pvwatts_dc_pvwatts_ac_system, location, weather, losses_model=constant_losses) mc.run_model(weather) assert m.call_count == 1 - assert isinstance(mc.ac, (pd.Series, pd.DataFrame)) + assert isinstance(mc.results.ac, (pd.Series, pd.DataFrame)) assert mc.losses == 0.9 - assert not mc.ac.empty + assert not mc.results.ac.empty def test_losses_models_no_loss(pvwatts_dc_pvwatts_ac_system, location, weather, @@ -801,6 +1405,17 @@ def test_bad_get_orientation(): modelchain.get_orientation('bad value') +# tests for PVSystem with multiple Arrays +def test_with_sapm_pvsystem_arrays(sapm_dc_snl_ac_system_Array, location, + weather): + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location, + ac_model='sandia_multi') + assert mc.dc_model == mc.sapm + assert mc.ac_model == mc.sandia_multi_inverter + mc.run_model(weather) + assert mc.results + + @fail_on_pvlib_version('0.9') @pytest.mark.parametrize('ac_model', ['snlinverter', 'adrinverter']) def test_deprecated_09(sapm_dc_snl_ac_system, cec_dc_adr_ac_system, @@ -824,6 +1439,16 @@ def test_ModelChain_kwargs_deprecated_09(sapm_dc_snl_ac_system, location): ModelChain(sapm_dc_snl_ac_system, location, arbitrary_kwarg='value') +@fail_on_pvlib_version('1.0') +def test_ModelChain_attributes_deprecated_10(sapm_dc_snl_ac_system, location): + match = 'Use ModelChain.results' + mc = ModelChain(sapm_dc_snl_ac_system, location) + with pytest.warns(pvlibDeprecationWarning, match=match): + mc.aoi + with pytest.warns(pvlibDeprecationWarning, match=match): + mc.aoi = 5 + + def test_basic_chain_required(sam_data, cec_inverter_parameters, sapm_temperature_cs5p_220m): times = pd.date_range(start='20160101 1200-0700', @@ -991,3 +1616,132 @@ def test_complete_irradiance(sapm_dc_snl_ac_system, location): assert_series_equal(mc.weather['dni'], pd.Series([49.756966, 62.153947], index=times, name='dni')) + + +@pytest.mark.filterwarnings("ignore:This function is not safe at the moment") +@pytest.mark.parametrize("input_type", [tuple, list]) +@requires_tables +def test_complete_irradiance_arrays( + sapm_dc_snl_ac_system_same_arrays, location, input_type): + """ModelChain.complete_irradiance can accept a tuple of weather + DataFrames.""" + times = pd.date_range(start='2020-01-01 0700-0700', periods=2, freq='H') + weather = pd.DataFrame({'dni': [2, 3], + 'dhi': [4, 6], + 'ghi': [9, 5]}, index=times) + mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) + with pytest.raises(ValueError, + match=r"Input DataFrames must have same index\."): + mc.complete_irradiance(input_type((weather, weather[1:]))) + mc.complete_irradiance(input_type((weather, weather))) + for mc_weather in mc.weather: + assert_series_equal(mc_weather['dni'], + pd.Series([2, 3], index=times, name='dni')) + assert_series_equal(mc_weather['dhi'], + pd.Series([4, 6], index=times, name='dhi')) + assert_series_equal(mc_weather['ghi'], + pd.Series([9, 5], index=times, name='ghi')) + mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) + mc.complete_irradiance(input_type((weather[['ghi', 'dhi']], + weather[['dhi', 'dni']]))) + assert 'dni' in mc.weather[0].columns + assert 'ghi' in mc.weather[1].columns + mc.complete_irradiance(input_type((weather, weather[['ghi', 'dni']]))) + assert_series_equal(mc.weather[0]['dhi'], + pd.Series([4, 6], index=times, name='dhi')) + assert_series_equal(mc.weather[0]['ghi'], + pd.Series([9, 5], index=times, name='ghi')) + assert_series_equal(mc.weather[0]['dni'], + pd.Series([2, 3], index=times, name='dni')) + assert 'dhi' in mc.weather[1].columns + + +@pytest.mark.parametrize("input_type", [tuple, list]) +def test_complete_irradiance_arrays_wrong_length( + sapm_dc_snl_ac_system_same_arrays, location, input_type): + mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) + times = pd.date_range(start='2020-01-01 0700-0700', periods=2, freq='H') + weather = pd.DataFrame({'dni': [2, 3], + 'dhi': [4, 6], + 'ghi': [9, 5]}, index=times) + error_str = "Input must be same length as number " \ + r"of Arrays in system\. Expected 2, got [0-9]+\." + with pytest.raises(ValueError, match=error_str): + mc.complete_irradiance(input_type((weather,))) + with pytest.raises(ValueError, match=error_str): + mc.complete_irradiance(input_type((weather, weather, weather))) + + +def test_unknown_attribute(sapm_dc_snl_ac_system, location): + mc = ModelChain(sapm_dc_snl_ac_system, location) + with pytest.raises(AttributeError): + mc.unknown_attribute + + +def test_inconsistent_array_params(location, + sapm_module_params, + cec_module_params): + module_error = ".* selected for the DC model but one or more Arrays are " \ + "missing one or more required parameters" + temperature_error = "could not infer temperature model from " \ + r"system\.temperature_model_parameters\. Check " \ + r"that all Arrays in system\.arrays have " \ + r"parameters for the same temperature model\. " \ + r"Common temperature model parameters: .*" + different_module_system = pvsystem.PVSystem( + arrays=[ + pvsystem.Array( + module_parameters=sapm_module_params), + pvsystem.Array( + module_parameters=cec_module_params), + pvsystem.Array( + module_parameters=cec_module_params)] + ) + with pytest.raises(ValueError, match=module_error): + ModelChain(different_module_system, location, dc_model='cec') + different_temp_system = pvsystem.PVSystem( + arrays=[ + pvsystem.Array( + module_parameters=cec_module_params, + temperature_model_parameters={'a': 1, + 'b': 1, + 'deltaT': 1}), + pvsystem.Array( + module_parameters=cec_module_params, + temperature_model_parameters={'a': 2, + 'b': 2, + 'deltaT': 2}), + pvsystem.Array( + module_parameters=cec_module_params, + temperature_model_parameters={'b': 3, 'deltaT': 3})] + ) + with pytest.raises(ValueError, match=temperature_error): + ModelChain(different_temp_system, location, + ac_model='sandia_multi', + aoi_model='no_loss', spectral_model='no_loss', + temperature_model='sapm') + + +def test_modelchain__common_keys(): + dictionary = {'a': 1, 'b': 1} + series = pd.Series(dictionary) + assert {'a', 'b'} == modelchain._common_keys( + {'a': 1, 'b': 1} + ) + assert {'a', 'b'} == modelchain._common_keys( + pd.Series({'a': 1, 'b': 1}) + ) + assert {'a', 'b'} == modelchain._common_keys( + (dictionary, series) + ) + no_a = dictionary.copy() + del no_a['a'] + assert {'b'} == modelchain._common_keys( + (dictionary, no_a) + ) + assert {'b'} == modelchain._common_keys( + (series, pd.Series(no_a)) + ) + assert {'b'} == modelchain._common_keys( + (series, no_a) + ) diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index 5675f8c3f7..cf9305f5ce 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -7,10 +7,12 @@ import pytest from conftest import assert_series_equal, assert_frame_equal from numpy.testing import assert_allclose +import unittest.mock as mock from pvlib import inverter, pvsystem from pvlib import atmosphere from pvlib import iam as _iam +from pvlib import irradiance from pvlib.location import Location from pvlib import temperature from pvlib._deprecation import pvlibDeprecationWarning @@ -32,6 +34,20 @@ def test_PVSystem_get_iam(mocker, iam_model, model_params): assert iam < 1. +def test_PVSystem_multi_array_get_iam(): + model_params = {'b': 0.05} + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(module_parameters=model_params), + pvsystem.Array(module_parameters=model_params)] + ) + iam = system.get_iam((1, 5), iam_model='ashrae') + assert len(iam) == 2 + assert iam[0] != iam[1] + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.get_iam((1,), iam_model='ashrae') + + def test_PVSystem_get_iam_sapm(sapm_module_params, mocker): system = pvsystem.PVSystem(module_parameters=sapm_module_params) mocker.spy(_iam, 'sapm') @@ -209,6 +225,29 @@ def test_PVSystem_sapm(sapm_module_params, mocker): assert_allclose(out['p_mp'], 100, atol=100) +def test_PVSystem_multi_array_sapm(sapm_module_params): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(module_parameters=sapm_module_params), + pvsystem.Array(module_parameters=sapm_module_params)] + ) + effective_irradiance = (100, 500) + temp_cell = (15, 25) + sapm_one, sapm_two = system.sapm(effective_irradiance, temp_cell) + assert sapm_one['p_mp'] != sapm_two['p_mp'] + sapm_one_flip, sapm_two_flip = system.sapm( + (effective_irradiance[1], effective_irradiance[0]), + (temp_cell[1], temp_cell[0]) + ) + assert sapm_one_flip['p_mp'] == sapm_two['p_mp'] + assert sapm_two_flip['p_mp'] == sapm_one['p_mp'] + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.sapm(effective_irradiance, 10) + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.sapm(500, temp_cell) + + @pytest.mark.parametrize('airmass,expected', [ (1.5, 1.00028714375), (np.array([[10, np.nan]]), np.array([[0.999535, 0]])), @@ -234,6 +273,15 @@ def test_PVSystem_sapm_spectral_loss(sapm_module_params, mocker): assert_allclose(out, 1, atol=0.5) +def test_PVSystem_multi_array_sapm_spectral_loss(sapm_module_params): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(module_parameters=sapm_module_params), + pvsystem.Array(module_parameters=sapm_module_params)] + ) + loss_one, loss_two = system.sapm_spectral_loss(2) + assert loss_one == loss_two + + # this test could be improved to cover all cell types. # could remove the need for specifying spectral coefficients if we don't # care about the return value at all @@ -257,6 +305,23 @@ def test_PVSystem_first_solar_spectral_loss(module_parameters, module_type, assert_allclose(out, 1, atol=0.5) +def test_PVSystem_multi_array_first_solar_spectral_loss(): + system = pvsystem.PVSystem( + arrays=[ + pvsystem.Array( + module_parameters={'Technology': 'mc-Si'}, + module_type='multisi' + ), + pvsystem.Array( + module_parameters={'Technology': 'mc-Si'}, + module_type='multisi' + ) + ] + ) + loss_one, loss_two = system.first_solar_spectral_loss(1, 3) + assert loss_one == loss_two + + @pytest.mark.parametrize('test_input,expected', [ ([1000, 100, 5, 45], 1140.0510967821877), ([np.array([np.nan, 1000, 1000]), @@ -297,6 +362,62 @@ def test_PVSystem_sapm_effective_irradiance(sapm_module_params, mocker): assert_allclose(out, expected, atol=0.1) +def test_PVSystem_multi_array_sapm_effective_irradiance(sapm_module_params): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(module_parameters=sapm_module_params), + pvsystem.Array(module_parameters=sapm_module_params)] + ) + poa_direct = (500, 900) + poa_diffuse = (50, 100) + aoi = (0, 10) + airmass_absolute = 1.5 + irrad_one, irrad_two = system.sapm_effective_irradiance( + poa_direct, poa_diffuse, airmass_absolute, aoi + ) + assert irrad_one != irrad_two + + +@pytest.fixture +def two_array_system(pvsyst_module_params, cec_module_params): + """Two-array PVSystem. + + Both arrays are identical. + """ + temperature_model = temperature.TEMPERATURE_MODEL_PARAMETERS['sapm'][ + 'open_rack_glass_glass' + ] + # Need u_v to be non-zero so wind-speed changes cell temperature + # under the pvsyst model. + temperature_model['u_v'] = 1.0 + temperature_model['noct_installed'] = 45 + module_params = {**pvsyst_module_params, **cec_module_params} + return pvsystem.PVSystem( + arrays=[ + pvsystem.Array( + temperature_model_parameters=temperature_model, + module_parameters=module_params + ), + pvsystem.Array( + temperature_model_parameters=temperature_model, + module_parameters=module_params + ) + ] + ) + + +@pytest.mark.parametrize("poa_direct, poa_diffuse, aoi", + [(20, (10, 10), (20, 20)), + ((20, 20), (10,), (20, 20)), + ((20, 20), (10, 10), 20)]) +def test_PVSystem_sapm_effective_irradiance_value_error( + poa_direct, poa_diffuse, aoi, two_array_system): + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + two_array_system.sapm_effective_irradiance( + poa_direct, poa_diffuse, 10, aoi + ) + + def test_PVSystem_sapm_celltemp(mocker): a, b, deltaT = (-3.47, -0.0594, 3) # open_rack_glass_glass temp_model_params = {'a': a, 'b': b, 'deltaT': deltaT} @@ -327,6 +448,21 @@ def test_PVSystem_sapm_celltemp_kwargs(mocker): assert_allclose(out, 57, atol=1) +def test_PVSystem_multi_array_sapm_celltemp_different_arrays(): + temp_model_one = temperature.TEMPERATURE_MODEL_PARAMETERS['sapm'][ + 'open_rack_glass_glass'] + temp_model_two = temperature.TEMPERATURE_MODEL_PARAMETERS['sapm'][ + 'close_mount_glass_glass'] + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(temperature_model_parameters=temp_model_one), + pvsystem.Array(temperature_model_parameters=temp_model_two)] + ) + temp_one, temp_two = system.sapm_celltemp( + (1000, 1000), 25, 1 + ) + assert temp_one != temp_two + + def test_PVSystem_pvsyst_celltemp(mocker): parameter_set = 'insulated' temp_model_params = temperature.TEMPERATURE_MODEL_PARAMETERS['pvsyst'][ @@ -360,6 +496,138 @@ def test_PVSystem_faiman_celltemp(mocker): assert_allclose(out, 56.4, atol=1) +@pytest.mark.parametrize("celltemp", + [pvsystem.PVSystem.faiman_celltemp, + pvsystem.PVSystem.pvsyst_celltemp, + pvsystem.PVSystem.sapm_celltemp, + pvsystem.PVSystem.fuentes_celltemp]) +def test_PVSystem_multi_array_celltemp_functions(celltemp, two_array_system): + times = pd.date_range(start='2020-08-25 11:00', freq='H', periods=3) + irrad_one = pd.Series(1000, index=times) + irrad_two = pd.Series(500, index=times) + temp_air = pd.Series(25, index=times) + wind_speed = pd.Series(1, index=times) + temp_one, temp_two = celltemp( + two_array_system, (irrad_one, irrad_two), temp_air, wind_speed) + assert (temp_one != temp_two).all() + + +@pytest.mark.parametrize("celltemp", + [pvsystem.PVSystem.faiman_celltemp, + pvsystem.PVSystem.pvsyst_celltemp, + pvsystem.PVSystem.sapm_celltemp, + pvsystem.PVSystem.fuentes_celltemp]) +def test_PVSystem_multi_array_celltemp_multi_temp(celltemp, two_array_system): + times = pd.date_range(start='2020-08-25 11:00', freq='H', periods=3) + irrad = pd.Series(1000, index=times) + temp_air_one = pd.Series(25, index=times) + temp_air_two = pd.Series(5, index=times) + wind_speed = pd.Series(1, index=times) + temp_one, temp_two = celltemp( + two_array_system, + (irrad, irrad), + (temp_air_one, temp_air_two), + wind_speed + ) + assert (temp_one != temp_two).all() + temp_one_swtich, temp_two_switch = celltemp( + two_array_system, + (irrad, irrad), + (temp_air_two, temp_air_one), + wind_speed + ) + assert_series_equal(temp_one, temp_two_switch) + assert_series_equal(temp_two, temp_one_swtich) + + +@pytest.mark.parametrize("celltemp", + [pvsystem.PVSystem.faiman_celltemp, + pvsystem.PVSystem.pvsyst_celltemp, + pvsystem.PVSystem.sapm_celltemp, + pvsystem.PVSystem.fuentes_celltemp]) +def test_PVSystem_multi_array_celltemp_multi_wind(celltemp, two_array_system): + times = pd.date_range(start='2020-08-25 11:00', freq='H', periods=3) + irrad = pd.Series(1000, index=times) + temp_air = pd.Series(25, index=times) + wind_speed_one = pd.Series(1, index=times) + wind_speed_two = pd.Series(5, index=times) + temp_one, temp_two = celltemp( + two_array_system, + (irrad, irrad), + temp_air, + (wind_speed_one, wind_speed_two) + ) + assert (temp_one != temp_two).all() + temp_one_swtich, temp_two_switch = celltemp( + two_array_system, + (irrad, irrad), + temp_air, + (wind_speed_two, wind_speed_one) + ) + assert_series_equal(temp_one, temp_two_switch) + assert_series_equal(temp_two, temp_one_swtich) + + +@pytest.mark.parametrize("celltemp", + [pvsystem.PVSystem.faiman_celltemp, + pvsystem.PVSystem.pvsyst_celltemp, + pvsystem.PVSystem.sapm_celltemp, + pvsystem.PVSystem.fuentes_celltemp]) +def test_PVSystem_multi_array_celltemp_temp_too_short( + celltemp, two_array_system): + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + celltemp(two_array_system, (1000, 1000), (1,), 1) + + +@pytest.mark.parametrize("celltemp", + [pvsystem.PVSystem.faiman_celltemp, + pvsystem.PVSystem.pvsyst_celltemp, + pvsystem.PVSystem.sapm_celltemp, + pvsystem.PVSystem.fuentes_celltemp]) +def test_PVSystem_multi_array_celltemp_temp_too_long( + celltemp, two_array_system): + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + celltemp(two_array_system, (1000, 1000), (1, 1, 1), 1) + + +@pytest.mark.parametrize("celltemp", + [pvsystem.PVSystem.faiman_celltemp, + pvsystem.PVSystem.pvsyst_celltemp, + pvsystem.PVSystem.sapm_celltemp, + pvsystem.PVSystem.fuentes_celltemp]) +def test_PVSystem_multi_array_celltemp_wind_too_short( + celltemp, two_array_system): + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + celltemp(two_array_system, (1000, 1000), 25, (1,)) + + +@pytest.mark.parametrize("celltemp", + [pvsystem.PVSystem.faiman_celltemp, + pvsystem.PVSystem.pvsyst_celltemp, + pvsystem.PVSystem.sapm_celltemp, + pvsystem.PVSystem.fuentes_celltemp]) +def test_PVSystem_multi_array_celltemp_wind_too_long( + celltemp, two_array_system): + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + celltemp(two_array_system, (1000, 1000), 25, (1, 1, 1)) + + +@pytest.mark.parametrize("celltemp", + [pvsystem.PVSystem.faiman_celltemp, + pvsystem.PVSystem.pvsyst_celltemp, + pvsystem.PVSystem.fuentes_celltemp, + pvsystem.PVSystem.sapm_celltemp]) +def test_PVSystem_multi_array_celltemp_poa_length_mismatch( + celltemp, two_array_system): + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + celltemp(two_array_system, 1000, 25, 1) + + def test_PVSystem_fuentes_celltemp(mocker): noct_installed = 45 temp_model_params = {'noct_installed': noct_installed} @@ -404,19 +672,30 @@ def test_PVSystem_fuentes_celltemp_override(mocker): assert spy.call_args[1]['surface_tilt'] == 30 -def test__infer_temperature_model_params(): - system = pvsystem.PVSystem(module_parameters={}, - racking_model='open_rack', - module_type='glass_polymer') +def test_Array__infer_temperature_model_params(): + array = pvsystem.Array(module_parameters={}, + racking_model='open_rack', + module_type='glass_polymer') expected = temperature.TEMPERATURE_MODEL_PARAMETERS[ 'sapm']['open_rack_glass_polymer'] - assert expected == system._infer_temperature_model_params() - system = pvsystem.PVSystem(module_parameters={}, - racking_model='freestanding', - module_type='glass_polymer') + assert expected == array._infer_temperature_model_params() + array = pvsystem.Array(module_parameters={}, + racking_model='freestanding', + module_type='glass_polymer') expected = temperature.TEMPERATURE_MODEL_PARAMETERS[ 'pvsyst']['freestanding'] - assert expected == system._infer_temperature_model_params() + assert expected == array._infer_temperature_model_params() + array = pvsystem.Array(module_parameters={}, + racking_model='insulated', + module_type=None) + expected = temperature.TEMPERATURE_MODEL_PARAMETERS[ + 'pvsyst']['insulated'] + assert expected == array._infer_temperature_model_params() + + +def test_Array__infer_cell_type(): + array = pvsystem.Array(module_parameters={}) + assert array._infer_cell_type() is None def test_calcparams_desoto(cec_module_params): @@ -564,6 +843,29 @@ def test_PVSystem_calcparams_pvsyst(pvsyst_module_params, mocker): assert_allclose(nNsVth, np.array([1.6186, 1.7961]), atol=0.1) +@pytest.mark.parametrize('calcparams', [pvsystem.PVSystem.calcparams_pvsyst, + pvsystem.PVSystem.calcparams_desoto, + pvsystem.PVSystem.calcparams_cec]) +def test_PVSystem_multi_array_calcparams(calcparams, two_array_system): + params_one, params_two = calcparams( + two_array_system, (1000, 500), (30, 20) + ) + assert params_one != params_two + + +@pytest.mark.parametrize('calcparams, irrad, celltemp', + [ (f, irrad, celltemp) + for f in (pvsystem.PVSystem.calcparams_desoto, + pvsystem.PVSystem.calcparams_cec, + pvsystem.PVSystem.calcparams_pvsyst) + for irrad, celltemp in [(1, (1, 1)), ((1, 1), 1)]]) +def test_PVSystem_multi_array_calcparams_value_error( + calcparams, irrad, celltemp, two_array_system): + with pytest.raises(ValueError, + match='Length mismatch for per-array parameter'): + calcparams(two_array_system, irrad, celltemp) + + @pytest.fixture(params=[ { # Can handle all python scalar inputs 'Rsh': 20., @@ -1055,6 +1357,26 @@ def test_PVSystem_scale_voltage_current_power(mocker): m.assert_called_once_with(data, voltage=2, current=3) +def test_PVSystem_multi_scale_voltage_current_power(mocker): + data = (1, 2) + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(modules_per_string=2, strings=3), + pvsystem.Array(modules_per_string=3, strings=5)] + ) + m = mocker.patch( + 'pvlib.pvsystem.scale_voltage_current_power', autospec=True + ) + system.scale_voltage_current_power(data) + m.assert_has_calls( + [mock.call(1, voltage=2, current=3), + mock.call(2, voltage=3, current=5)], + any_order=True + ) + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.scale_voltage_current_power(None) + + def test_PVSystem_snlinverter(cec_inverter_parameters): system = pvsystem.PVSystem( inverter=cec_inverter_parameters['Name'], @@ -1068,6 +1390,50 @@ def test_PVSystem_snlinverter(cec_inverter_parameters): assert_series_equal(pacs, pd.Series([-0.020000, 132.004308, 250.000000])) +def test_PVSystem_sandia_multi(cec_inverter_parameters): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(), pvsystem.Array()], + inverter=cec_inverter_parameters['Name'], + inverter_parameters=cec_inverter_parameters, + ) + vdcs = pd.Series(np.linspace(0, 50, 3)) + idcs = pd.Series(np.linspace(0, 11, 3)) / 2 + pdcs = idcs * vdcs + pacs = system.sandia_multi((vdcs, vdcs), (pdcs, pdcs)) + assert_series_equal(pacs, pd.Series([-0.020000, 132.004308, 250.000000])) + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.sandia_multi(vdcs, (pdcs, pdcs)) + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.sandia_multi(vdcs, (pdcs,)) + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.sandia_multi((vdcs, vdcs), (pdcs, pdcs, pdcs)) + + +def test_PVSystem_sandia_multi_single_array(cec_inverter_parameters): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array()], + inverter=cec_inverter_parameters['Name'], + inverter_parameters=cec_inverter_parameters, + ) + vdcs = pd.Series(np.linspace(0, 50, 3)) + idcs = pd.Series(np.linspace(0, 11, 3)) + pdcs = idcs * vdcs + + pacs = system.sandia_multi(vdcs, pdcs) + assert_series_equal(pacs, pd.Series([-0.020000, 132.004308, 250.000000])) + pacs = system.sandia_multi((vdcs,), (pdcs,)) + assert_series_equal(pacs, pd.Series([-0.020000, 132.004308, 250.000000])) + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.sandia_multi((vdcs, vdcs), pdcs) + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.sandia_multi((vdcs,), (pdcs, pdcs)) + + def test_PVSystem_creation(): pv_system = pvsystem.PVSystem(module='blah', inverter='blarg') # ensure that parameter attributes are dict-like. GH 294 @@ -1075,12 +1441,35 @@ def test_PVSystem_creation(): pv_system.inverter_parameters['Paco'] = 1 +def test_PVSystem_multiple_array_creation(): + array_one = pvsystem.Array(surface_tilt=32) + array_two = pvsystem.Array(surface_tilt=15, module_parameters={'pdc0': 1}) + pv_system = pvsystem.PVSystem(arrays=[array_one, array_two]) + assert pv_system.surface_tilt == (32, 15) + assert pv_system.surface_azimuth == (180, 180) + assert pv_system.module_parameters == ({}, {'pdc0': 1}) + assert pv_system.arrays == (array_one, array_two) + with pytest.raises(TypeError): + pvsystem.PVSystem(arrays=array_one) + + def test_PVSystem_get_aoi(): system = pvsystem.PVSystem(surface_tilt=32, surface_azimuth=135) aoi = system.get_aoi(30, 225) assert np.round(aoi, 4) == 42.7408 +def test_PVSystem_multiple_array_get_aoi(): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(surface_tilt=15, surface_azimuth=135), + pvsystem.Array(surface_tilt=32, surface_azimuth=135)] + ) + aoi_one, aoi_two = system.get_aoi(30, 225) + assert np.round(aoi_two, 4) == 42.7408 + assert aoi_two != aoi_one + assert aoi_one > 0 + + def test_PVSystem_get_irradiance(): system = pvsystem.PVSystem(surface_tilt=32, surface_azimuth=135) times = pd.date_range(start='20160101 1200-0700', @@ -1108,6 +1497,172 @@ def test_PVSystem_get_irradiance(): assert_frame_equal(irradiance, expected, check_less_precise=2) +def test_PVSystem_get_irradiance_model(mocker): + spy_perez = mocker.spy(irradiance, 'perez') + spy_haydavies = mocker.spy(irradiance, 'haydavies') + system = pvsystem.PVSystem(surface_tilt=32, surface_azimuth=135) + times = pd.date_range(start='20160101 1200-0700', + end='20160101 1800-0700', freq='6H') + location = Location(latitude=32, longitude=-111) + solar_position = location.get_solarposition(times) + irrads = pd.DataFrame({'dni': [900, 0], 'ghi': [600, 0], 'dhi': [100, 0]}, + index=times) + system.get_irradiance(solar_position['apparent_zenith'], + solar_position['azimuth'], + irrads['dni'], + irrads['ghi'], + irrads['dhi']) + spy_haydavies.assert_called_once() + system.get_irradiance(solar_position['apparent_zenith'], + solar_position['azimuth'], + irrads['dni'], + irrads['ghi'], + irrads['dhi'], + model='perez') + spy_perez.assert_called_once() + + +def test_PVSystem_multi_array_get_irradiance(): + array_one = pvsystem.Array(surface_tilt=32, surface_azimuth=135) + array_two = pvsystem.Array(surface_tilt=5, surface_azimuth=150) + system = pvsystem.PVSystem(arrays=[array_one, array_two]) + location = Location(latitude=32, longitude=-111) + times = pd.date_range(start='20160101 1200-0700', + end='20160101 1800-0700', freq='6H') + solar_position = location.get_solarposition(times) + irrads = pd.DataFrame({'dni': [900, 0], 'ghi': [600, 0], 'dhi': [100, 0]}, + index=times) + array_one_expected = array_one.get_irradiance( + solar_position['apparent_zenith'], + solar_position['azimuth'], + irrads['dni'], irrads['ghi'], irrads['dhi'] + ) + array_two_expected = array_two.get_irradiance( + solar_position['apparent_zenith'], + solar_position['azimuth'], + irrads['dni'], irrads['ghi'], irrads['dhi'] + ) + array_one_irrad, array_two_irrad = system.get_irradiance( + solar_position['apparent_zenith'], + solar_position['azimuth'], + irrads['dni'], irrads['ghi'], irrads['dhi'] + ) + assert_frame_equal( + array_one_irrad, array_one_expected, check_less_precise=2 + ) + assert_frame_equal( + array_two_irrad, array_two_expected, check_less_precise=2 + ) + + +def test_PVSystem_multi_array_get_irradiance_multi_irrad(): + """Test a system with two identical arrays but different irradiance. + + Because only the irradiance is different we expect the same output + when only one GHI/DHI/DNI input is given, but different output + for each array when different GHI/DHI/DNI input is given. For the later + case we verify that the correct irradiance data is passed to each array. + """ + array_one = pvsystem.Array() + array_two = pvsystem.Array() + system = pvsystem.PVSystem(arrays=[array_one, array_two]) + location = Location(latitude=32, longitude=-111) + times = pd.date_range(start='20160101 1200-0700', + end='20160101 1800-0700', freq='6H') + solar_position = location.get_solarposition(times) + irrads = pd.DataFrame({'dni': [900, 0], 'ghi': [600, 0], 'dhi': [100, 0]}, + index=times) + irrads_two = pd.DataFrame( + {'dni': [0, 900], 'ghi': [0, 600], 'dhi': [0, 100]}, + index=times + ) + array_irrad = system.get_irradiance( + solar_position['apparent_zenith'], + solar_position['azimuth'], + (irrads['dhi'], irrads['dhi']), + (irrads['ghi'], irrads['ghi']), + (irrads['dni'], irrads['dni']) + ) + assert_frame_equal(array_irrad[0], array_irrad[1]) + array_irrad = system.get_irradiance( + solar_position['apparent_zenith'], + solar_position['azimuth'], + (irrads['dhi'], irrads_two['dhi']), + (irrads['ghi'], irrads_two['ghi']), + (irrads['dni'], irrads_two['dni']) + ) + array_one_expected = array_one.get_irradiance( + solar_position['apparent_zenith'], + solar_position['azimuth'], + irrads['dhi'], irrads['ghi'], irrads['dni'] + ) + array_two_expected = array_two.get_irradiance( + solar_position['apparent_zenith'], + solar_position['azimuth'], + irrads_two['dhi'], irrads_two['ghi'], irrads_two['dni'] + ) + assert not array_irrad[0].equals(array_irrad[1]) + assert_frame_equal(array_irrad[0], array_one_expected) + assert_frame_equal(array_irrad[1], array_two_expected) + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.get_irradiance( + solar_position['apparent_zenith'], + solar_position['azimuth'], + (irrads['dhi'], irrads_two['dhi'], irrads['dhi']), + (irrads['ghi'], irrads_two['ghi']), + irrads['dni'] + ) + array_irrad = system.get_irradiance( + solar_position['apparent_zenith'], + solar_position['azimuth'], + (irrads['dhi'], irrads_two['dhi']), + irrads['ghi'], + irrads['dni'] + ) + assert_frame_equal(array_irrad[0], array_one_expected) + assert not array_irrad[0].equals(array_irrad[1]) + + +def test_PVSystem_change_surface_azimuth(): + system = pvsystem.PVSystem(surface_azimuth=180) + assert system.surface_azimuth == 180 + system.surface_azimuth = 90 + assert system.surface_azimuth == 90 + + +def test_PVSystem_get_albedo(two_array_system): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(albedo=0.5)] + ) + assert system.albedo == 0.5 + assert two_array_system.albedo == (0.25, 0.25) + + +def test_PVSystem_modules_per_string(): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(modules_per_string=1), + pvsystem.Array(modules_per_string=2)] + ) + assert system.modules_per_string == (1, 2) + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(modules_per_string=5)] + ) + assert system.modules_per_string == 5 + + +def test_PVSystem_strings_per_inverter(): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(strings=2), + pvsystem.Array(strings=1)] + ) + assert system.strings_per_inverter == (2, 1) + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(strings=5)] + ) + assert system.strings_per_inverter == 5 + + @fail_on_pvlib_version('0.9') def test_PVSystem_localize_with_location(): system = pvsystem.PVSystem(module='blah', inverter='blarg') @@ -1140,17 +1695,56 @@ def test_PVSystem___repr__(): expected = """PVSystem: name: pv ftw - surface_tilt: 0 - surface_azimuth: 180 - module: blah - inverter: blarg - albedo: 0.25 - racking_model: None - module_type: None - temperature_model_parameters: {'a': -3.56}""" + Array: + name: None + surface_tilt: 0 + surface_azimuth: 180 + module: blah + albedo: 0.25 + racking_model: None + module_type: None + temperature_model_parameters: {'a': -3.56} + strings: 1 + modules_per_string: 1 + inverter: blarg""" assert system.__repr__() == expected +def test_PVSystem_multi_array___repr__(): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(surface_tilt=30, surface_azimuth=100), + pvsystem.Array(surface_tilt=20, surface_azimuth=220, + name='foo')], + inverter='blarg', + ) + expected = """PVSystem: + name: None + Array: + name: None + surface_tilt: 30 + surface_azimuth: 100 + module: None + albedo: 0.25 + racking_model: None + module_type: None + temperature_model_parameters: {} + strings: 1 + modules_per_string: 1 + Array: + name: foo + surface_tilt: 20 + surface_azimuth: 220 + module: None + albedo: 0.25 + racking_model: None + module_type: None + temperature_model_parameters: {} + strings: 1 + modules_per_string: 1 + inverter: blarg""" + assert expected == system.__repr__() + + @fail_on_pvlib_version('0.9') def test_PVSystem_localize___repr__(): system = pvsystem.PVSystem( @@ -1177,6 +1771,31 @@ def test_PVSystem_localize___repr__(): assert localized_system.__repr__() == expected +def test_Array___repr__(): + array = pvsystem.Array( + surface_tilt=10, surface_azimuth=100, + albedo=0.15, module_type='glass_glass', + temperature_model_parameters={'a': -3.56}, + racking_model='close_mount', + module_parameters={'foo': 'bar'}, + modules_per_string=100, + strings=10, module='baz', + name='biz' + ) + expected = """Array: + name: biz + surface_tilt: 10 + surface_azimuth: 100 + module: baz + albedo: 0.15 + racking_model: close_mount + module_type: glass_glass + temperature_model_parameters: {'a': -3.56} + strings: 10 + modules_per_string: 100""" + assert array.__repr__() == expected + + # we could retest each of the models tested above # when they are attached to LocalizedPVSystem, but # that's probably not necessary at this point. @@ -1305,6 +1924,59 @@ def test_PVSystem_pvwatts_dc_kwargs(mocker): assert_allclose(expected, out, atol=10) +def test_PVSystem_multiple_array_pvwatts_dc(): + array_one_module_parameters = { + 'pdc0': 100, 'gamma_pdc': -0.003, 'temp_ref': 20 + } + array_one = pvsystem.Array( + module_parameters=array_one_module_parameters + ) + array_two_module_parameters = { + 'pdc0': 150, 'gamma_pdc': -0.002, 'temp_ref': 25 + } + array_two = pvsystem.Array( + module_parameters=array_two_module_parameters + ) + system = pvsystem.PVSystem(arrays=[array_one, array_two]) + irrad_one = 900 + irrad_two = 500 + temp_cell_one = 30 + temp_cell_two = 20 + expected_one = pvsystem.pvwatts_dc(irrad_one, temp_cell_one, + **array_one_module_parameters) + expected_two = pvsystem.pvwatts_dc(irrad_two, temp_cell_two, + **array_two_module_parameters) + dc_one, dc_two = system.pvwatts_dc((irrad_one, irrad_two), + (temp_cell_one, temp_cell_two)) + assert dc_one == expected_one + assert dc_two == expected_two + + +def test_PVSystem_multiple_array_pvwatts_dc_value_error(): + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(), pvsystem.Array(), pvsystem.Array()] + ) + error_message = 'Length mismatch for per-array parameter' + with pytest.raises(ValueError, match=error_message): + system.pvwatts_dc(10, (1, 1, 1)) + with pytest.raises(ValueError, match=error_message): + system.pvwatts_dc((10, 10), (1, 1, 1)) + with pytest.raises(ValueError, match=error_message): + system.pvwatts_dc((10, 10, 10, 10), (1, 1, 1)) + with pytest.raises(ValueError, match=error_message): + system.pvwatts_dc((1, 1, 1), 1) + with pytest.raises(ValueError, match=error_message): + system.pvwatts_dc((1, 1, 1), (1,)) + with pytest.raises(ValueError, match=error_message): + system.pvwatts_dc((1,), 1) + with pytest.raises(ValueError, match=error_message): + system.pvwatts_dc((1, 1, 1, 1), (1, 1)) + with pytest.raises(ValueError, match=error_message): + system.pvwatts_dc(2, 3) + with pytest.raises(ValueError, match=error_message): + # ValueError is raised for non-tuple iterable with correct length + system.pvwatts_dc((1, 1, 1), pd.Series([1, 2, 3])) + def test_PVSystem_pvwatts_losses(mocker): mocker.spy(pvsystem, 'pvwatts_losses') system = make_pvwatts_system_defaults() @@ -1336,6 +2008,13 @@ def test_PVSystem_pvwatts_ac_kwargs(mocker): assert out < pdc +def test_PVSystem_num_arrays(): + system_one = pvsystem.PVSystem() + system_two = pvsystem.PVSystem(arrays=[pvsystem.Array(), pvsystem.Array()]) + assert system_one.num_arrays == 1 + assert system_two.num_arrays == 2 + + def test_combine_loss_factors(): test_index = pd.date_range(start='1990/01/01T12:00', periods=365, freq='D') loss_1 = pd.Series(.10, index=test_index) diff --git a/pvlib/tests/test_tracking.py b/pvlib/tests/test_tracking.py index 897c64baa0..77c0f3a06e 100644 --- a/pvlib/tests/test_tracking.py +++ b/pvlib/tests/test_tracking.py @@ -8,7 +8,7 @@ import pvlib from pvlib.location import Location -from pvlib import tracking +from pvlib import tracking, pvsystem from conftest import DATA_DIR SINGLEAXIS_COL_ORDER = ['tracker_theta', 'aoi', @@ -303,6 +303,35 @@ def test_SingleAxisTracker_creation(): assert system.inverter == 'blarg' +def test_SingleAxisTracker_one_array_only(): + system = tracking.SingleAxisTracker( + arrays=[pvsystem.Array( + module='foo', + surface_tilt=None, + surface_azimuth=None + )] + ) + assert system.module == 'foo' + with pytest.raises(ValueError, + match="SingleAxisTracker does not support " + r"multiple arrays\."): + tracking.SingleAxisTracker( + arrays=[pvsystem.Array(module='foo'), + pvsystem.Array(module='bar')] + ) + with pytest.raises(ValueError, + match="Array must not have surface_tilt "): + tracking.SingleAxisTracker(arrays=[pvsystem.Array(module='foo')]) + with pytest.raises(ValueError, + match="Array must not have surface_tilt "): + tracking.SingleAxisTracker( + arrays=[pvsystem.Array(surface_azimuth=None)]) + with pytest.raises(ValueError, + match="Array must not have surface_tilt "): + tracking.SingleAxisTracker( + arrays=[pvsystem.Array(surface_tilt=None)]) + + def test_SingleAxisTracker_tracking(): system = tracking.SingleAxisTracker(max_angle=90, axis_tilt=30, axis_azimuth=180, gcr=2.0/7.0, @@ -451,14 +480,18 @@ def test_SingleAxisTracker___repr__(): gcr: 0.25 cross_axis_tilt: 0.0 name: None - surface_tilt: None - surface_azimuth: None - module: blah - inverter: blarg - albedo: 0.25 - racking_model: None - module_type: None - temperature_model_parameters: {'a': -3.56}""" + Array: + name: None + surface_tilt: None + surface_azimuth: None + module: blah + albedo: 0.25 + racking_model: None + module_type: None + temperature_model_parameters: {'a': -3.56} + strings: 1 + modules_per_string: 1 + inverter: blarg""" assert system.__repr__() == expected @@ -476,14 +509,18 @@ def test_LocalizedSingleAxisTracker___repr__(): gcr: 0.25 cross_axis_tilt: 0.0 name: None - surface_tilt: None - surface_azimuth: None - module: blah + Array: + name: None + surface_tilt: None + surface_azimuth: None + module: blah + albedo: 0.25 + racking_model: None + module_type: None + temperature_model_parameters: {'a': -3.56} + strings: 1 + modules_per_string: 1 inverter: blarg - albedo: 0.25 - racking_model: None - module_type: None - temperature_model_parameters: {'a': -3.56} latitude: 32 longitude: -111 altitude: 0 diff --git a/pvlib/tracking.py b/pvlib/tracking.py index 73bc79ceb4..fa435cceeb 100644 --- a/pvlib/tracking.py +++ b/pvlib/tracking.py @@ -57,7 +57,17 @@ class SingleAxisTracker(PVSystem): `cross_axis_tilt`. [degrees] **kwargs - Passed to :py:class:`~pvlib.pvsystem.PVSystem`. + Passed to :py:class:`~pvlib.pvsystem.PVSystem`. If the `arrays` + parameter is specified it must have only a single Array. Furthermore + if a :py:class:`~pvlib.pvsystem.Array` is provided it must have + ``surface_tilt`` and ``surface_azimuth`` equal to None. + + Raises + ------ + ValueError + If more than one Array is specified. + ValueError + If an Array is provided with a surface tilt or azimuth not None. See also -------- @@ -69,6 +79,20 @@ class SingleAxisTracker(PVSystem): def __init__(self, axis_tilt=0, axis_azimuth=0, max_angle=90, backtrack=True, gcr=2.0/7.0, cross_axis_tilt=0.0, **kwargs): + arrays = kwargs.get('arrays', []) + if len(arrays) > 1: + raise ValueError("SingleAxisTracker does not support " + "multiple arrays.") + elif len(arrays) == 1: + surface_tilt = arrays[0].surface_tilt + surface_azimuth = arrays[0].surface_azimuth + if surface_tilt is not None or surface_azimuth is not None: + raise ValueError( + "Array must not have surface_tilt or " + "surface_azimuth assigned. You must pass an " + "Array with these fields set to None." + ) + self.axis_tilt = axis_tilt self.axis_azimuth = axis_azimuth self.max_angle = max_angle diff --git a/setup.py b/setup.py index e2bacea7a8..a876f0e995 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import os +import sys try: from setuptools import setup @@ -42,6 +43,11 @@ 'pytz', 'requests', 'scipy >= 1.2.0'] + +# include dataclasses as a dependency only on python 3.6 +if sys.version_info.major == 3 and sys.version_info.minor == 6: + INSTALL_REQUIRES.append('dataclasses') + TESTS_REQUIRE = ['nose', 'pytest', 'pytest-cov', 'pytest-mock', 'pytest-timeout', 'pytest-rerunfailures', 'pytest-remotedata'] EXTRAS_REQUIRE = {