diff --git a/docs/sphinx/source/api.rst b/docs/sphinx/source/api.rst index 6e93dc5807..c37bedf7df 100644 --- a/docs/sphinx/source/api.rst +++ b/docs/sphinx/source/api.rst @@ -211,15 +211,17 @@ wrap the functions listed below. See its documentation for details. pvsystem.PVSystem pvsystem.LocalizedPVSystem -AOI modifiers -------------- +Incident angle modifiers +------------------------ .. autosummary:: :toctree: generated/ - pvsystem.physicaliam - pvsystem.ashraeiam - pvsystem.sapm_aoi_loss + iam.physical + iam.ashrae + iam.martin_ruiz + iam.sapm + iam.interp PV temperature models --------------------- diff --git a/docs/sphinx/source/whatsnew/v0.7.0.rst b/docs/sphinx/source/whatsnew/v0.7.0.rst index d358c88249..c2b2c34533 100644 --- a/docs/sphinx/source/whatsnew/v0.7.0.rst +++ b/docs/sphinx/source/whatsnew/v0.7.0.rst @@ -69,6 +69,20 @@ API Changes - `modelchain.basic_chain` has a new required argument `temperature_model_parameters`. +* Changes related to IAM (AOI loss) functions (:issue:`680`): + * Changes to functions + - Moved functions from `pvsystem.py` to `iam.py`. `pvsystem` IAM + functions are deprecated and will be removed in v0.8. + - Functions are renamed to a consistent pattern: + - `pvsystem.physicaliam` is `iam.physical` + - `pvsystem.ashraeiam` is `iam.ashrae` + - `pvsystem.sapm_aoi_loss` is `iam.sapm` + - Created dict `iam.IAM_MODEL_PARAMS` to aid in identifying IAM models + * Changes to `PVSystem` class + - IAM models are provided by `PVSystem.get_iam` with kwarg `iam_model`. + - Methods `PVSystem.ashraeiam`, `PVSystem.physicaliam` and + `PVSystem.sapm_aoi_loss` are deprecated and will be removed in v0.8. + * Calling :py:func:`pvlib.pvsystem.retrieve_sam` with no parameters will raise an exception instead of displaying a dialog. * The `times` keyword argument has been deprecated in the @@ -83,8 +97,9 @@ API Changes Enhancements ~~~~~~~~~~~~ -* Created two new incidence angle modifier functions: :py:func:`pvlib.pvsystem.iam_martin_ruiz` - and :py:func:`pvlib.pvsystem.iam_interp`. (:issue:`751`) +* Created two new incidence angle modifier (IAM) functions: + :py:func:`pvlib.iam.martin_ruiz` and :py:func:`pvlib.iam.interp`. (:issue:`751`) +* Add the `martin_ruiz` IAM function as an option for `ModelChain.aoi_model`. * Updated the file for module parameters for the CEC model, from the SAM file dated 2017-6-5 to the SAM file dated 2019-03-05. (:issue:`761`) * Updated the file for inverter parameters for the CEC model, from the SAM file @@ -96,6 +111,7 @@ Enhancements the single diode equation to an IV curve. * Add :py:func:`~pvlib.ivtools.fit_sdm_cec_sam`, a wrapper for the CEC single diode model fitting function '6parsolve' from NREL's System Advisor Model. +* Add `timeout` to :py:func:`pvlib.iotools.get_psm3`. Bug fixes ~~~~~~~~~ @@ -113,6 +129,8 @@ Testing in NSRDB (:issue:`733`) * Added tests for methods in bifacial.py. * Added tests for changes to cell temperature models. +* Added tests for changes to IAM models. +* Added test for `ModelChain.infer_aoi_model`. Documentation ~~~~~~~~~~~~~ @@ -132,11 +150,6 @@ Removal of prior version deprecations * Removed `ModelChain.prepare_inputs` clearsky assumption when no irradiance data was provided. -Enhancements -~~~~~~~~~~~~ -* Add `timeout` to :py:func:`pvlib.iotools.get_psm3`. - - Contributors ~~~~~~~~~~~~ * Mark Campanellli (:ghuser:`markcampanelli`) diff --git a/pvlib/iam.py b/pvlib/iam.py new file mode 100644 index 0000000000..3d869f298d --- /dev/null +++ b/pvlib/iam.py @@ -0,0 +1,422 @@ +r""" +The ``iam`` module contains functions that implement models for the incidence +angle modifier (IAM). The IAM quantifies the fraction of direct irradiance on +a module's front surface that is transmitted through the module materials to +the cells. Stated differently, the quantity 1 - IAM is the fraction of direct +irradiance that is reflected away or absorbed by the module's front materials. +IAM is typically a function of the angle of incidence (AOI) of the direct +irradiance to the module's surface. +""" + +import numpy as np +import pandas as pd +from pvlib.tools import cosd, sind, tand, asind + + +# a dict of required parameter names for each IAM model +# keys are the function names for the IAM models +IAM_MODEL_PARAMS = { + 'ashrae': set(['b']), + 'physical': set(['n', 'K', 'L']), + 'martin_ruiz': set(['a_r']), + 'sapm': set(['B0', 'B1', 'B2', 'B3', 'B4', 'B5']), + 'interp': set([]) +} + + +def ashrae(aoi, b=0.05): + r""" + Determine the incidence angle modifier using the ASHRAE transmission + model. + + The ASHRAE (American Society of Heating, Refrigeration, and Air + Conditioning Engineers) transmission model is developed in + [1], and in [2]. The model has been used in software such as PVSyst [3]. + + Parameters + ---------- + aoi : numeric + The angle of incidence (AOI) between the module normal vector and the + sun-beam vector in degrees. Angles of nan will result in nan. + + b : float, default 0.05 + A parameter to adjust the incidence angle modifier as a function of + angle of incidence. Typical values are on the order of 0.05 [3]. + + Returns + ------- + iam : numeric + The incident angle modifier (IAM). Returns zero for all abs(aoi) >= 90 + and for all ``iam`` values that would be less than 0. + + Notes + ----- + The incidence angle modifier is calculated as + + .. math:: + + IAM = 1 - b (\sec(aoi) - 1) + + As AOI approaches 90 degrees, the model yields negative values for IAM; + negative IAM values are set to zero in this implementation. + + References + ---------- + [1] Souka A.F., Safwat H.H., "Determination of the optimum + orientations for the double exposure flat-plate collector and its + reflections". Solar Energy vol .10, pp 170-174. 1966. + + [2] ASHRAE standard 93-77 + + [3] PVsyst Contextual Help. + https://files.pvsyst.com/help/index.html?iam_loss.htm retrieved on + October 14, 2019 + + See Also + -------- + iam.physical + iam.martin_ruiz + iam.interp + """ + + iam = 1 - b * ((1 / np.cos(np.radians(aoi)) - 1)) + aoi_gte_90 = np.full_like(aoi, False, dtype='bool') + np.greater_equal(np.abs(aoi), 90, where=~np.isnan(aoi), out=aoi_gte_90) + iam = np.where(aoi_gte_90, 0, iam) + iam = np.maximum(0, iam) + + if isinstance(aoi, pd.Series): + iam = pd.Series(iam, index=aoi.index) + + return iam + + +def physical(aoi, n=1.526, K=4., L=0.002): + r""" + Determine the incidence angle modifier using refractive index ``n``, + extinction coefficient ``K``, and glazing thickness ``L``. + + ``iam.physical`` calculates the incidence angle modifier as described in + [1], Section 3. The calculation is based on a physical model of absorbtion + and transmission through a transparent cover. + + Parameters + ---------- + aoi : numeric + The angle of incidence between the module normal vector and the + sun-beam vector in degrees. Angles of 0 are replaced with 1e-06 + to ensure non-nan results. Angles of nan will result in nan. + + n : numeric, default 1.526 + The effective index of refraction (unitless). Reference [1] + indicates that a value of 1.526 is acceptable for glass. + + K : numeric, default 4.0 + The glazing extinction coefficient in units of 1/meters. + Reference [1] indicates that a value of 4 is reasonable for + "water white" glass. + + L : numeric, default 0.002 + The glazing thickness in units of meters. Reference [1] + indicates that 0.002 meters (2 mm) is reasonable for most + glass-covered PV panels. + + Returns + ------- + iam : numeric + The incident angle modifier + + Notes + ----- + The pvlib python authors believe that Eqn. 14 in [1] is + incorrect, which presents :math:`\theta_{r} = \arcsin(n \sin(AOI))`. + Here, :math:`\theta_{r} = \arcsin(1/n \times \sin(AOI))` + + References + ---------- + [1] W. De Soto et al., "Improvement and validation of a model for + photovoltaic array performance", Solar Energy, vol 80, pp. 78-88, + 2006. + + [2] Duffie, John A. & Beckman, William A.. (2006). Solar Engineering + of Thermal Processes, third edition. [Books24x7 version] Available + from http://common.books24x7.com/toc.aspx?bookid=17160. + + See Also + -------- + iam.martin_ruiz + iam.ashrae + iam.interp + iam.sapm + """ + zeroang = 1e-06 + + # hold a new reference to the input aoi object since we're going to + # overwrite the aoi reference below, but we'll need it for the + # series check at the end of the function + aoi_input = aoi + + aoi = np.where(aoi == 0, zeroang, aoi) + + # angle of reflection + thetar_deg = asind(1.0 / n * (sind(aoi))) + + # reflectance and transmittance for normal incidence light + rho_zero = ((1-n) / (1+n)) ** 2 + tau_zero = np.exp(-K*L) + + # reflectance for parallel and perpendicular polarized light + rho_para = (tand(thetar_deg - aoi) / tand(thetar_deg + aoi)) ** 2 + rho_perp = (sind(thetar_deg - aoi) / sind(thetar_deg + aoi)) ** 2 + + # transmittance for non-normal light + tau = np.exp(-K * L / cosd(thetar_deg)) + + # iam is ratio of non-normal to normal incidence transmitted light + # after deducting the reflected portion of each + iam = ((1 - (rho_para + rho_perp) / 2) / (1 - rho_zero) * tau / tau_zero) + + with np.errstate(invalid='ignore'): + # angles near zero produce nan, but iam is defined as one + small_angle = 1e-06 + iam = np.where(np.abs(aoi) < small_angle, 1.0, iam) + + # angles at 90 degrees can produce tiny negative values, + # which should be zero. this is a result of calculation precision + # rather than the physical model + iam = np.where(iam < 0, 0, iam) + + # for light coming from behind the plane, none can enter the module + iam = np.where(aoi > 90, 0, iam) + + if isinstance(aoi_input, pd.Series): + iam = pd.Series(iam, index=aoi_input.index) + + return iam + + +def martin_ruiz(aoi, a_r=0.16): + r''' + Determine the incidence angle modifier (IAM) using the Martin + and Ruiz incident angle model. + + Parameters + ---------- + aoi : numeric, degrees + The angle of incidence between the module normal vector and the + sun-beam vector in degrees. + + a_r : numeric + The angular losses coefficient described in equation 3 of [1]. + This is an empirical dimensionless parameter. Values of ``a_r`` are + generally on the order of 0.08 to 0.25 for flat-plate PV modules. + + Returns + ------- + iam : numeric + The incident angle modifier(s) + + Notes + ----- + `martin_ruiz` calculates the incidence angle modifier (IAM) as described in + [1]. The information required is the incident angle (AOI) and the angular + losses coefficient (a_r). Note that [1] has a corrigendum [2] which makes + the document much simpler to understand. + + The incident angle modifier is defined as + + .. math:: + + IAM = \frac{1 - \exp(-\cos(\frac{aoi}{a_r}))} + {1 - \exp(\frac{-1}{a_r}} + + which is presented as :math:`AL(\alpha) = 1 - IAM` in equation 4 of [1], + with :math:`\alpha` representing the angle of incidence AOI. Thus IAM = 1 + at AOI = 0, and IAM = 0 at AOI = 90. This equation is only valid for + -90 <= aoi <= 90, therefore `iam` is constrained to 0.0 outside this + interval. + + References + ---------- + [1] N. Martin and J. M. Ruiz, "Calculation of the PV modules angular + losses under field conditions by means of an analytical model", Solar + Energy Materials & Solar Cells, vol. 70, pp. 25-38, 2001. + + [2] N. Martin and J. M. Ruiz, "Corrigendum to 'Calculation of the PV + modules angular losses under field conditions by means of an + analytical model'", Solar Energy Materials & Solar Cells, vol. 110, + pp. 154, 2013. + + See Also + -------- + iam.physical + iam.ashrae + iam.interp + iam.sapm + ''' + # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019 + + aoi_input = aoi + + aoi = np.asanyarray(aoi) + a_r = np.asanyarray(a_r) + + if np.any(np.less_equal(a_r, 0)): + raise RuntimeError("The parameter 'a_r' cannot be zero or negative.") + + with np.errstate(invalid='ignore'): + iam = (1 - np.exp(-cosd(aoi) / a_r)) / (1 - np.exp(-1 / a_r)) + iam = np.where(np.abs(aoi) >= 90.0, 0.0, iam) + + if isinstance(aoi_input, pd.Series): + iam = pd.Series(iam, index=aoi_input.index) + + return iam + + +def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): + r''' + Determine the incidence angle modifier (IAM) by interpolating a set of + reference values, which are usually measured values. + + Parameters + ---------- + aoi : numeric + The angle of incidence between the module normal vector and the + sun-beam vector [degrees]. + + theta_ref : numeric + Vector of angles at which the IAM is known [degrees]. + + iam_ref : numeric + IAM values for each angle in ``theta_ref`` [unitless]. + + method : str, default 'linear' + Specifies the interpolation method. + Useful options are: 'linear', 'quadratic','cubic'. + See scipy.interpolate.interp1d for more options. + + normalize : boolean, default True + When true, the interpolated values are divided by the interpolated + value at zero degrees. This ensures that ``iam``=1.0 at normal + incidence. + + Returns + ------- + iam : numeric + The incident angle modifier(s) [unitless] + + Notes + ----- + ``theta_ref`` must have two or more points and may span any range of + angles. Typically there will be a dozen or more points in the range 0-90 + degrees. Beyond the range of ``theta_ref``, IAM values are extrapolated, + but constrained to be non-negative. + + The sign of ``aoi`` is ignored; only the magnitude is used. + + See Also + -------- + iam.physical + iam.ashrae + iam.martin_ruiz + iam.sapm + ''' + # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019 + + from scipy.interpolate import interp1d + + # Scipy doesn't give the clearest feedback, so check number of points here. + MIN_REF_VALS = {'linear': 2, 'quadratic': 3, 'cubic': 4, 1: 2, 2: 3, 3: 4} + + if len(theta_ref) < MIN_REF_VALS.get(method, 2): + raise ValueError("Too few reference points defined " + "for interpolation method '%s'." % method) + + if np.any(np.less(iam_ref, 0)): + raise ValueError("Negative value(s) found in 'iam_ref'. " + "This is not physically possible.") + + interpolator = interp1d(theta_ref, iam_ref, kind=method, + fill_value='extrapolate') + aoi_input = aoi + + aoi = np.asanyarray(aoi) + aoi = np.abs(aoi) + iam = interpolator(aoi) + iam = np.clip(iam, 0, None) + + if normalize: + iam /= interpolator(0) + + if isinstance(aoi_input, pd.Series): + iam = pd.Series(iam, index=aoi_input.index) + + return iam + + +def sapm(aoi, module, upper=None): + r""" + Determine the incidence angle modifier (IAM) using the SAPM model. + + Parameters + ---------- + aoi : numeric + Angle of incidence in degrees. Negative input angles will return + zeros. + + module : dict-like + A dict or Series with the SAPM IAM model parameters. + See the :py:func:`sapm` notes section for more details. + + upper : None or float, default None + Upper limit on the results. + + Returns + ------- + iam : numeric + The SAPM angle of incidence loss coefficient, termed F2 in [1]. + + Notes + ----- + The SAPM [1] traditionally does not define an upper limit on the AOI + loss function and values slightly exceeding 1 may exist for moderate + angles of incidence (15-40 degrees). However, users may consider + imposing an upper limit of 1. + + References + ---------- + [1] King, D. et al, 2004, "Sandia Photovoltaic Array Performance + Model", SAND Report 3535, Sandia National Laboratories, Albuquerque, + NM. + + [2] B.H. King et al, "Procedure to Determine Coefficients for the + Sandia Array Performance Model (SAPM)," SAND2016-5284, Sandia + National Laboratories (2016). + + [3] B.H. King et al, "Recent Advancements in Outdoor Measurement + Techniques for Angle of Incidence Effects," 42nd IEEE PVSC (2015). + DOI: 10.1109/PVSC.2015.7355849 + + See Also + -------- + iam.physical + iam.ashrae + iam.martin_ruiz + iam.interp + """ + + aoi_coeff = [module['B5'], module['B4'], module['B3'], module['B2'], + module['B1'], module['B0']] + + iam = np.polyval(aoi_coeff, aoi) + iam = np.clip(iam, 0, upper) + # nan tolerant masking + aoi_lt_0 = np.full_like(aoi, False, dtype='bool') + np.less(aoi, 0, where=~np.isnan(aoi), out=aoi_lt_0) + iam = np.where(aoi_lt_0, 0, iam) + + if isinstance(aoi, pd.Series): + iam = pd.Series(iam, aoi.index) + + return iam diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 6aad00c1dd..16bd7ba426 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -271,8 +271,8 @@ class ModelChain(object): aoi_model: None, str, or function, default None If None, the model will be inferred from the contents of system.module_parameters. Valid strings are 'physical', - 'ashrae', 'sapm', 'no_loss'. The ModelChain instance will be - passed as the first argument to a user-defined function. + 'ashrae', 'sapm', 'martin_ruiz', 'no_loss'. The ModelChain instance + will be passed as the first argument to a user-defined function. spectral_model: None, str, or function, default None If None, the model will be inferred from the contents of @@ -540,6 +540,8 @@ def aoi_model(self, model): self._aoi_model = self.physical_aoi_loss elif model == 'sapm': self._aoi_model = self.sapm_aoi_loss + elif model == 'martin_ruiz': + self._aoi_model = self.martin_ruiz_aoi_loss elif model == 'no_loss': self._aoi_model = self.no_aoi_loss else: @@ -555,24 +557,31 @@ def infer_aoi_model(self): return self.sapm_aoi_loss elif set(['b']) <= params: return self.ashrae_aoi_loss + elif set(['a_r']) <= params: + return self.martin_ruiz_aoi_loss else: raise ValueError('could not infer AOI model from ' 'system.module_parameters. Check that the ' 'system.module_parameters contain parameters for ' - 'the physical, aoi, or ashrae model; explicitly ' - 'set model with aoi_model kwarg; or set ' - 'aoi_model="no_loss".') + '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.ashraeiam(self.aoi) + self.aoi_modifier = self.system.get_iam(self.aoi, iam_model='ashrae') return self def physical_aoi_loss(self): - self.aoi_modifier = self.system.physicaliam(self.aoi) + self.aoi_modifier = self.system.get_iam(self.aoi, iam_model='physical') return self def sapm_aoi_loss(self): - self.aoi_modifier = self.system.sapm_aoi_loss(self.aoi) + self.aoi_modifier = self.system.get_iam(self.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') return self def no_aoi_loss(self): diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index bebe2f1263..5eb66b34f5 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -13,9 +13,9 @@ from pvlib._deprecation import deprecated -from pvlib import (atmosphere, irradiance, singlediode as _singlediode, +from pvlib import (atmosphere, iam, irradiance, singlediode as _singlediode, temperature) -from pvlib.tools import _build_kwargs, cosd, asind, sind, tand +from pvlib.tools import _build_kwargs from pvlib.location import Location from pvlib._deprecation import pvlibDeprecationWarning @@ -25,7 +25,7 @@ 'sapm': set([ 'A0', 'A1', 'A2', 'A3', 'A4', 'B0', 'B1', 'B2', 'B3', 'B4', 'B5', 'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', - 'C7', 'Isco', 'Impo', 'Aisc', 'Aimp', 'Bvoco', + 'C7', 'Isco', 'Impo', 'Voco', 'Vmpo', 'Aisc', 'Aimp', 'Bvoco', 'Mbvoc', 'Bvmpo', 'Mbvmp', 'N', 'Cells_in_Series', 'IXO', 'IXXO', 'FD']), 'desoto': set([ @@ -316,52 +316,66 @@ def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, albedo=self.albedo, **kwargs) - def ashraeiam(self, aoi): + def get_iam(self, aoi, iam_model='physical'): """ - Determine the incidence angle modifier using - ``self.module_parameters['b']``, ``aoi``, - and the :py:func:`ashraeiam` function. + Determine the incidence angle modifier using the method specified by + ``iam_model``. - Uses default arguments if keys not in module_parameters. + Parameters for the selected IAM model are expected to be in + ``PVSystem.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 ------- - modifier : numeric + iam : numeric The AOI modifier. + + Raises + ------ + ValueError if `iam_model` is not a valid model name. """ - kwargs = _build_kwargs(['b'], self.module_parameters) + 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') - return ashraeiam(aoi, **kwargs) + def ashraeiam(self, aoi): + """ + Deprecated. Use ``PVSystem.get_iam`` instead. + """ + import warnings + warnings.warn('PVSystem.ashraeiam is deprecated and will be removed in' + 'v0.8, use PVSystem.get_iam instead', + pvlibDeprecationWarning) + return PVSystem.get_iam(self, aoi, iam_model='ashrae') def physicaliam(self, aoi): """ - Determine the incidence angle modifier using ``aoi``, - ``self.module_parameters['K']``, - ``self.module_parameters['L']``, - ``self.module_parameters['n']``, - and the - :py:func:`physicaliam` function. - - Uses default arguments if keys not in module_parameters. - - Parameters - ---------- - aoi : numeric - The angle of incidence in degrees. - - Returns - ------- - modifier : numeric - The AOI modifier. + Deprecated. Use ``PVSystem.get_iam`` instead. """ - kwargs = _build_kwargs(['K', 'L', 'n'], self.module_parameters) - - return physicaliam(aoi, **kwargs) + import warnings + warnings.warn('PVSystem.physicaliam is deprecated and will be removed' + ' in v0.8, use PVSystem.get_iam instead', + pvlibDeprecationWarning) + return PVSystem.get_iam(self, aoi, iam_model='physical') def calcparams_desoto(self, effective_irradiance, temp_cell, **kwargs): """ @@ -530,20 +544,13 @@ def sapm_spectral_loss(self, airmass_absolute): def sapm_aoi_loss(self, aoi): """ - Use the :py:func:`sapm_aoi_loss` function, the input parameters, - and ``self.module_parameters`` to calculate F2. - - Parameters - ---------- - aoi : numeric - Angle of incidence in degrees. - - Returns - ------- - F2 : numeric - The SAPM angle of incidence loss coefficient. + Deprecated. Use ``PVSystem.get_iam`` instead. """ - return sapm_aoi_loss(aoi, self.module_parameters) + import warnings + warnings.warn('PVSystem.sapm_aoi_loss is deprecated and will be' + ' removed in v0.8, use PVSystem.get_iam instead', + pvlibDeprecationWarning) + return PVSystem.get_iam(self, aoi, iam_model='sapm') def sapm_effective_irradiance(self, poa_direct, poa_diffuse, airmass_absolute, aoi, @@ -955,333 +962,6 @@ def systemdef(meta, surface_tilt, surface_azimuth, albedo, modules_per_string, return system -def ashraeiam(aoi, b=0.05): - ''' - Determine the incidence angle modifier using the ASHRAE transmission - model. - - ashraeiam calculates the incidence angle modifier as developed in - [1], and adopted by ASHRAE (American Society of Heating, - Refrigeration, and Air Conditioning Engineers) [2]. The model has - been used by model programs such as PVSyst [3]. - - Note: For incident angles near 90 degrees, this model has a - discontinuity which has been addressed in this function. - - Parameters - ---------- - aoi : numeric - The angle of incidence between the module normal vector and the - sun-beam vector in degrees. Angles of nan will result in nan. - - b : float, default 0.05 - A parameter to adjust the modifier as a function of angle of - incidence. Typical values are on the order of 0.05 [3]. - - Returns - ------- - IAM : numeric - The incident angle modifier calculated as 1-b*(sec(aoi)-1) as - described in [2,3]. - - Returns zeros for all abs(aoi) >= 90 and for all IAM values that - would be less than 0. - - References - ---------- - [1] Souka A.F., Safwat H.H., "Determination of the optimum - orientations for the double exposure flat-plate collector and its - reflections". Solar Energy vol .10, pp 170-174. 1966. - - [2] ASHRAE standard 93-77 - - [3] PVsyst Contextual Help. - http://files.pvsyst.com/help/index.html?iam_loss.htm retrieved on - September 10, 2012 - - See Also - -------- - irradiance.aoi - physicaliam - ''' - - iam = 1 - b * ((1 / np.cos(np.radians(aoi)) - 1)) - aoi_gte_90 = np.full_like(aoi, False, dtype='bool') - np.greater_equal(np.abs(aoi), 90, where=~np.isnan(aoi), out=aoi_gte_90) - iam = np.where(aoi_gte_90, 0, iam) - iam = np.maximum(0, iam) - - if isinstance(aoi, pd.Series): - iam = pd.Series(iam, index=aoi.index) - - return iam - - -def physicaliam(aoi, n=1.526, K=4., L=0.002): - ''' - Determine the incidence angle modifier using refractive index, - extinction coefficient, and glazing thickness. - - physicaliam calculates the incidence angle modifier as described in - De Soto et al. "Improvement and validation of a model for - photovoltaic array performance", section 3. The calculation is based - on a physical model of absorbtion and transmission through a - cover. - - Note: The authors of this function believe that eqn. 14 in [1] is - incorrect. This function uses the following equation in its place: - theta_r = arcsin(1/n * sin(aoi)) - - Parameters - ---------- - aoi : numeric - The angle of incidence between the module normal vector and the - sun-beam vector in degrees. Angles of 0 are replaced with 1e-06 - to ensure non-nan results. Angles of nan will result in nan. - - n : numeric, default 1.526 - The effective index of refraction (unitless). Reference [1] - indicates that a value of 1.526 is acceptable for glass. n must - be a numeric scalar or vector with all values >=0. If n is a - vector, it must be the same size as all other input vectors. - - K : numeric, default 4.0 - The glazing extinction coefficient in units of 1/meters. - Reference [1] indicates that a value of 4 is reasonable for - "water white" glass. K must be a numeric scalar or vector with - all values >=0. If K is a vector, it must be the same size as - all other input vectors. - - L : numeric, default 0.002 - The glazing thickness in units of meters. Reference [1] - indicates that 0.002 meters (2 mm) is reasonable for most - glass-covered PV panels. L must be a numeric scalar or vector - with all values >=0. If L is a vector, it must be the same size - as all other input vectors. - - Returns - ------- - iam : numeric - The incident angle modifier - - References - ---------- - [1] W. De Soto et al., "Improvement and validation of a model for - photovoltaic array performance", Solar Energy, vol 80, pp. 78-88, - 2006. - - [2] Duffie, John A. & Beckman, William A.. (2006). Solar Engineering - of Thermal Processes, third edition. [Books24x7 version] Available - from http://common.books24x7.com/toc.aspx?bookid=17160. - - See Also - -------- - getaoi - ephemeris - spa - ashraeiam - ''' - zeroang = 1e-06 - - # hold a new reference to the input aoi object since we're going to - # overwrite the aoi reference below, but we'll need it for the - # series check at the end of the function - aoi_input = aoi - - aoi = np.where(aoi == 0, zeroang, aoi) - - # angle of reflection - thetar_deg = asind(1.0 / n * (sind(aoi))) - - # reflectance and transmittance for normal incidence light - rho_zero = ((1-n) / (1+n)) ** 2 - tau_zero = np.exp(-K*L) - - # reflectance for parallel and perpendicular polarized light - rho_para = (tand(thetar_deg - aoi) / tand(thetar_deg + aoi)) ** 2 - rho_perp = (sind(thetar_deg - aoi) / sind(thetar_deg + aoi)) ** 2 - - # transmittance for non-normal light - tau = np.exp(-K * L / cosd(thetar_deg)) - - # iam is ratio of non-normal to normal incidence transmitted light - # after deducting the reflected portion of each - iam = ((1 - (rho_para + rho_perp) / 2) / (1 - rho_zero) * tau / tau_zero) - - with np.errstate(invalid='ignore'): - # angles near zero produce nan, but iam is defined as one - small_angle = 1e-06 - iam = np.where(np.abs(aoi) < small_angle, 1.0, iam) - - # angles at 90 degrees can produce tiny negative values, - # which should be zero. this is a result of calculation precision - # rather than the physical model - iam = np.where(iam < 0, 0, iam) - - # for light coming from behind the plane, none can enter the module - iam = np.where(aoi > 90, 0, iam) - - if isinstance(aoi_input, pd.Series): - iam = pd.Series(iam, index=aoi_input.index) - - return iam - - -def iam_martin_ruiz(aoi, a_r=0.16): - ''' - Determine the incidence angle modifier (iam) using the Martin - and Ruiz incident angle model. - - Parameters - ---------- - aoi : numeric, degrees - The angle of incidence between the module normal vector and the - sun-beam vector in degrees. Theta must be a numeric scalar or vector. - iam is 0 where |aoi| > 90. - - a_r : numeric - The angular losses coefficient described in equation 3 of [1]. - This is an empirical dimensionless parameter. Values of a_r are - generally on the order of 0.08 to 0.25 for flat-plate PV modules. - a_r must be a positive numeric scalar or vector (same length as aoi). - - Returns - ------- - iam : numeric - The incident angle modifier(s) - - Notes - ----- - iam_martin_ruiz calculates the incidence angle modifier (iam) - as described by Martin and Ruiz in [1]. The information - required is the incident angle (aoi) and the angular losses - coefficient (a_r). Please note that [1] has a corrigendum which makes - the document much simpler to understand. - - The incident angle modifier is defined as - [1-exp(-cos(aoi/ar))] / [1-exp(-1/ar)], which is - presented as AL(alpha) = 1 - IAM in equation 4 of [1]. Thus IAM is - equal to 1 at aoi = 0, and equal to 0 at aoi = 90. This equation is only - valid for -90 <= aoi <= 90, therefore iam must be constrained to 0.0 - beyond this range. - - References - ---------- - [1] N. Martin and J. M. Ruiz, "Calculation of the PV modules angular - losses under field conditions by means of an analytical model", Solar - Energy Materials & Solar Cells, vol. 70, pp. 25-38, 2001. - - [2] N. Martin and J. M. Ruiz, "Corrigendum to 'Calculation of the PV - modules angular losses under field conditions by means of an - analytical model'", Solar Energy Materials & Solar Cells, vol. 110, - pp. 154, 2013. - - See Also - -------- - physicaliam - ashraeiam - iam_interp - ''' - # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019 - - aoi_input = aoi - - aoi = np.asanyarray(aoi) - a_r = np.asanyarray(a_r) - - if np.any(np.less_equal(a_r, 0)): - raise RuntimeError("The parameter 'a_r' cannot be zero or negative.") - - with np.errstate(invalid='ignore'): - iam = (1 - np.exp(-cosd(aoi) / a_r)) / (1 - np.exp(-1 / a_r)) - iam = np.where(np.abs(aoi) >= 90.0, 0.0, iam) - - if isinstance(aoi_input, pd.Series): - iam = pd.Series(iam, index=aoi_input.index) - - return iam - - -def iam_interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): - ''' - Determine the incidence angle modifier (iam) by interpolating a set of - reference values, which are usually measured values. - - Parameters - ---------- - aoi : numeric, degrees - The angle of incidence between the module normal vector and the - sun-beam vector in degrees. - - theta_ref : numeric, degrees - Vector of angles at which the iam is known. - - iam_ref : numeric, unitless - iam values for each angle in theta_ref. - - method : str, default 'linear' - Specifies the interpolation method. - Useful options are: 'linear', 'quadratic','cubic'. - See scipy.interpolate.interp1d for more options. - - normalize : boolean - When true, the interpolated values are divided by the interpolated - value at zero degrees. This ensures that the iam at normal - incidence is equal to 1.0. - - Returns - ------- - iam : numeric - The incident angle modifier(s) - - Notes: - ------ - theta_ref must have two or more points and may span any range of angles. - Typically there will be a dozen or more points in the range 0-90 degrees. - iam beyond the range of theta_ref are extrapolated, but constrained to be - non-negative. - - The sign of aoi is ignored; only the magnitude is used. - - See Also - -------- - physicaliam - ashraeiam - iam_martin_ruiz - ''' - # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019 - - from scipy.interpolate import interp1d - - # Scipy doesn't give the clearest feedback, so check number of points here. - MIN_REF_VALS = {'linear': 2, 'quadratic': 3, 'cubic': 4, 1: 2, 2: 3, 3: 4} - - if len(theta_ref) < MIN_REF_VALS.get(method, 2): - raise ValueError("Too few reference points defined " - "for interpolation method '%s'." % method) - - if np.any(np.less(iam_ref, 0)): - raise ValueError("Negative value(s) found in 'iam_ref'. " - "This is not physically possible.") - - interpolator = interp1d(theta_ref, iam_ref, kind=method, - fill_value='extrapolate') - aoi_input = aoi - - aoi = np.asanyarray(aoi) - aoi = np.abs(aoi) - iam = interpolator(aoi) - iam = np.clip(iam, 0, None) - - if normalize: - iam /= interpolator(0) - - if isinstance(aoi_input, pd.Series): - iam = pd.Series(iam, index=aoi_input.index) - - return iam - - 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, @@ -1940,6 +1620,8 @@ def sapm(effective_irradiance, temp_cell, module): Imp, Vmp, Ix, and Ixx to effective irradiance Isco Short circuit current at reference condition (amps) Impo Maximum power current at reference condition (amps) + Voco Open circuit voltage at reference condition (amps) + Vmpo Maximum power voltage at reference condition (amps) Aisc Short circuit current temperature coefficient at reference condition (1/C) Aimp Maximum power current temperature coefficient at @@ -2153,67 +1835,6 @@ def sapm_spectral_loss(airmass_absolute, module): return spectral_loss -def sapm_aoi_loss(aoi, module, upper=None): - """ - Calculates the SAPM angle of incidence loss coefficient, F2. - - Parameters - ---------- - aoi : numeric - Angle of incidence in degrees. Negative input angles will return - zeros. - - module : dict-like - A dict, Series, or DataFrame defining the SAPM performance - parameters. See the :py:func:`sapm` notes section for more - details. - - upper : None or float, default None - Upper limit on the results. - - Returns - ------- - F2 : numeric - The SAPM angle of incidence loss coefficient. - - Notes - ----- - The SAPM traditionally does not define an upper limit on the AOI - loss function and values slightly exceeding 1 may exist for moderate - angles of incidence (15-40 degrees). However, users may consider - imposing an upper limit of 1. - - References - ---------- - [1] King, D. et al, 2004, "Sandia Photovoltaic Array Performance - Model", SAND Report 3535, Sandia National Laboratories, Albuquerque, - NM. - - [2] B.H. King et al, "Procedure to Determine Coefficients for the - Sandia Array Performance Model (SAPM)," SAND2016-5284, Sandia - National Laboratories (2016). - - [3] B.H. King et al, "Recent Advancements in Outdoor Measurement - Techniques for Angle of Incidence Effects," 42nd IEEE PVSC (2015). - DOI: 10.1109/PVSC.2015.7355849 - """ - - aoi_coeff = [module['B5'], module['B4'], module['B3'], module['B2'], - module['B1'], module['B0']] - - aoi_loss = np.polyval(aoi_coeff, aoi) - aoi_loss = np.clip(aoi_loss, 0, upper) - # nan tolerant masking - aoi_lt_0 = np.full_like(aoi, False, dtype='bool') - np.less(aoi, 0, where=~np.isnan(aoi), out=aoi_lt_0) - aoi_loss = np.where(aoi_lt_0, 0, aoi_loss) - - if isinstance(aoi, pd.Series): - aoi_loss = pd.Series(aoi_loss, aoi.index) - - return aoi_loss - - def sapm_effective_irradiance(poa_direct, poa_diffuse, airmass_absolute, aoi, module, reference_irradiance=1000): """ @@ -2249,7 +1870,7 @@ def sapm_effective_irradiance(poa_direct, poa_diffuse, airmass_absolute, aoi, """ F1 = sapm_spectral_loss(airmass_absolute, module) - F2 = sapm_aoi_loss(aoi, module) + F2 = iam.sapm(aoi, module) E0 = reference_irradiance @@ -3127,3 +2748,15 @@ def pvwatts_ac(pdc, pdc0, eta_inv_nom=0.96, eta_inv_ref=0.9637): pac = np.maximum(0, pac) # GH 541 return pac + + +ashraeiam = deprecated('0.7', alternative='iam.ashrae', name='ashraeiam', + removal='0.8')(iam.ashrae) + + +physicaliam = deprecated('0.7', alternative='iam.physical', name='physicaliam', + removal='0.8')(iam.physical) + + +sapm_aoi_loss = deprecated('0.7', alternative='iam.sapm', name='sapm_aoi_loss', + removal='0.8')(iam.sapm) diff --git a/pvlib/test/conftest.py b/pvlib/test/conftest.py index 8789bf99f4..9c838249a9 100644 --- a/pvlib/test/conftest.py +++ b/pvlib/test/conftest.py @@ -365,3 +365,50 @@ def sapm_temperature_cs5p_220m(): # SAPM temperature model parameters for Canadian_Solar_CS5P_220M # (glass/polymer) in open rack return {'a': -3.40641, 'b': -0.0842075, 'deltaT': 3} + + +@pytest.fixture(scope='function') +def sapm_module_params(): + """ + Define SAPM model parameters for Canadian Solar CS5P 220M module. + + The scope of the fixture is set to ``'function'`` to allow tests to modify + parameters if required without affecting other tests. + """ + parameters = {'Material': 'c-Si', + 'Cells_in_Series': 96, + 'Parallel_Strings': 1, + 'A0': 0.928385, + 'A1': 0.068093, + 'A2': -0.0157738, + 'A3': 0.0016606, + 'A4': -6.93E-05, + 'B0': 1, + 'B1': -0.002438, + 'B2': 0.0003103, + 'B3': -0.00001246, + 'B4': 2.11E-07, + 'B5': -1.36E-09, + 'C0': 1.01284, + 'C1': -0.0128398, + 'C2': 0.279317, + 'C3': -7.24463, + 'C4': 0.996446, + 'C5': 0.003554, + 'C6': 1.15535, + 'C7': -0.155353, + 'Isco': 5.09115, + 'Impo': 4.54629, + 'Voco': 59.2608, + 'Vmpo': 48.3156, + 'Aisc': 0.000397, + 'Aimp': 0.000181, + 'Bvoco': -0.21696, + 'Mbvoc': 0.0, + 'Bvmpo': -0.235488, + 'Mbvmp': 0.0, + 'N': 1.4032, + 'IXO': 4.97599, + 'IXXO': 3.18803, + 'FD': 1} + return parameters diff --git a/pvlib/test/test_iam.py b/pvlib/test/test_iam.py new file mode 100644 index 0000000000..0c51b005a1 --- /dev/null +++ b/pvlib/test/test_iam.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +""" +Created on Wed Oct 2 10:14:16 2019 + +@author: cwhanse +""" + +import numpy as np +import pandas as pd + +import pytest +from pandas.util.testing import assert_series_equal +from numpy.testing import assert_allclose + +from pvlib import iam as _iam +from conftest import needs_numpy_1_10, requires_scipy + + +@needs_numpy_1_10 +def test_ashrae(): + thetas = np.array([-90., -67.5, -45., -22.5, 0., 22.5, 45., 67.5, 89., 90., + np.nan]) + expected = np.array([0, 0.9193437, 0.97928932, 0.99588039, 1., 0.99588039, + 0.97928932, 0.9193437, 0, 0, np.nan]) + iam = _iam.ashrae(thetas, .05) + assert_allclose(iam, expected, equal_nan=True) + iam_series = _iam.ashrae(pd.Series(thetas)) + assert_series_equal(iam_series, pd.Series(expected)) + + +@needs_numpy_1_10 +def test_ashrae_scalar(): + thetas = -45. + iam = _iam.ashrae(thetas, .05) + expected = 0.97928932 + assert_allclose(iam, expected, equal_nan=True) + thetas = np.nan + iam = _iam.ashrae(thetas, .05) + expected = np.nan + assert_allclose(iam, expected, equal_nan=True) + + +@needs_numpy_1_10 +def test_physical(): + aoi = np.array([-90., -67.5, -45., -22.5, 0., 22.5, 45., 67.5, 90., + np.nan]) + expected = np.array([0, 0.8893998, 0.98797788, 0.99926198, 1, 0.99926198, + 0.98797788, 0.8893998, 0, np.nan]) + iam = _iam.physical(aoi, 1.526, 0.002, 4) + assert_allclose(iam, expected, equal_nan=True) + + # GitHub issue 397 + aoi = pd.Series(aoi) + iam = _iam.physical(aoi, 1.526, 0.002, 4) + expected = pd.Series(expected) + assert_series_equal(iam, expected) + + +@needs_numpy_1_10 +def test_physical_scalar(): + aoi = -45. + iam = _iam.physical(aoi, 1.526, 0.002, 4) + expected = 0.98797788 + assert_allclose(iam, expected, equal_nan=True) + aoi = np.nan + iam = _iam.physical(aoi, 1.526, 0.002, 4) + expected = np.nan + assert_allclose(iam, expected, equal_nan=True) + + +def test_martin_ruiz(): + + aoi = 45. + a_r = 0.16 + expected = 0.98986965 + + # will fail if default values change + iam = _iam.martin_ruiz(aoi) + assert_allclose(iam, expected) + # will fail if parameter names change + iam = _iam.martin_ruiz(aoi=aoi, a_r=a_r) + assert_allclose(iam, expected) + + a_r = 0.18 + aoi = [-100, -60, 0, 60, 100, np.nan, np.inf] + expected = [0.0, 0.9414631, 1.0, 0.9414631, 0.0, np.nan, 0.0] + + # check out of range of inputs as list + iam = _iam.martin_ruiz(aoi, a_r) + assert_allclose(iam, expected, equal_nan=True) + + # check out of range of inputs as array + iam = _iam.martin_ruiz(np.array(aoi), a_r) + assert_allclose(iam, expected, equal_nan=True) + + # check out of range of inputs as Series + aoi = pd.Series(aoi) + expected = pd.Series(expected) + iam = _iam.martin_ruiz(aoi, a_r) + assert_series_equal(iam, expected) + + # check exception clause + with pytest.raises(RuntimeError): + _iam.martin_ruiz(0.0, a_r=0.0) + + +@requires_scipy +def test_iam_interp(): + + aoi_meas = [0.0, 45.0, 65.0, 75.0] + iam_meas = [1.0, 0.9, 0.8, 0.6] + + # simple default linear method + aoi = 55.0 + expected = 0.85 + iam = _iam.interp(aoi, aoi_meas, iam_meas) + assert_allclose(iam, expected) + + # simple non-default method + aoi = 55.0 + expected = 0.8878062 + iam = _iam.interp(aoi, aoi_meas, iam_meas, method='cubic') + assert_allclose(iam, expected) + + # check with all reference values + aoi = aoi_meas + expected = iam_meas + iam = _iam.interp(aoi, aoi_meas, iam_meas) + assert_allclose(iam, expected) + + # check normalization and Series + aoi = pd.Series(aoi) + expected = pd.Series(expected) + iam_mult = np.multiply(0.9, iam_meas) + iam = _iam.interp(aoi, aoi_meas, iam_mult, normalize=True) + assert_series_equal(iam, expected) + + # check beyond reference values + aoi = [-45, 0, 45, 85, 90, 95, 100, 105, 110] + expected = [0.9, 1.0, 0.9, 0.4, 0.3, 0.2, 0.1, 0.0, 0.0] + iam = _iam.interp(aoi, aoi_meas, iam_meas) + assert_allclose(iam, expected) + + # check exception clause + with pytest.raises(ValueError): + _iam.interp(0.0, [0], [1]) + + # check exception clause + with pytest.raises(ValueError): + _iam.interp(0.0, [0, 90], [1, -1]) + + +@pytest.mark.parametrize('aoi,expected', [ + (45, 0.9975036250000002), + (np.array([[-30, 30, 100, np.nan]]), + np.array([[0, 1.007572, 0, np.nan]])), + (pd.Series([80]), pd.Series([0.597472])) +]) +def test_sapm(sapm_module_params, aoi, expected): + + out = _iam.sapm(aoi, sapm_module_params) + + if isinstance(aoi, pd.Series): + assert_series_equal(out, expected, check_less_precise=4) + else: + assert_allclose(out, expected, atol=1e-4) + + +def test_sapm_limits(): + module_parameters = {'B0': 5, 'B1': 0, 'B2': 0, 'B3': 0, 'B4': 0, 'B5': 0} + assert _iam.sapm(1, module_parameters) == 5 + + module_parameters = {'B0': 5, 'B1': 0, 'B2': 0, 'B3': 0, 'B4': 0, 'B5': 0} + assert _iam.sapm(1, module_parameters, upper=1) == 1 + + module_parameters = {'B0': -5, 'B1': 0, 'B2': 0, 'B3': 0, 'B4': 0, 'B5': 0} + assert _iam.sapm(1, module_parameters) == 0 diff --git a/pvlib/test/test_modelchain.py b/pvlib/test/test_modelchain.py index 4623d9fe45..c4d7c7aabe 100644 --- a/pvlib/test/test_modelchain.py +++ b/pvlib/test/test_modelchain.py @@ -3,7 +3,7 @@ import numpy as np import pandas as pd -from pvlib import modelchain, pvsystem, temperature +from pvlib import iam, modelchain, pvsystem, temperature from pvlib.modelchain import ModelChain from pvlib.pvsystem import PVSystem from pvlib.tracking import SingleAxisTracker @@ -16,11 +16,11 @@ from conftest import fail_on_pvlib_version, requires_scipy, requires_tables -@pytest.fixture -def system(sam_data, cec_inverter_parameters, sapm_temperature_cs5p_220m): - modules = sam_data['sandiamod'] +@pytest.fixture(scope='function') +def system(sapm_module_params, cec_inverter_parameters, + sapm_temperature_cs5p_220m): module = 'Canadian_Solar_CS5P_220M___2009_' - module_parameters = modules[module].copy() + module_parameters = sapm_module_params.copy() temp_model_params = sapm_temperature_cs5p_220m.copy() system = PVSystem(surface_tilt=32.2, surface_azimuth=180, module=module, @@ -101,7 +101,7 @@ def pvwatts_dc_snl_ac_system(cec_inverter_parameters): return system -@pytest.fixture +@pytest.fixture(scope="function") def pvwatts_dc_pvwatts_ac_system(sapm_temperature_cs5p_220m): module_parameters = {'pdc0': 220, 'gamma_pdc': -0.003} temp_model_params = sapm_temperature_cs5p_220m.copy() @@ -113,6 +113,21 @@ def pvwatts_dc_pvwatts_ac_system(sapm_temperature_cs5p_220m): return system +@pytest.fixture(scope="function") +def system_no_aoi(cec_module_cs5p_220m, sapm_temperature_cs5p_220m, + cec_inverter_parameters): + module_parameters = cec_module_cs5p_220m.copy() + module_parameters['EgRef'] = 1.121 + module_parameters['dEgdT'] = -0.0002677 + temp_model_params = sapm_temperature_cs5p_220m.copy() + inverter_parameters = cec_inverter_parameters.copy() + system = PVSystem(surface_tilt=32.2, surface_azimuth=180, + module_parameters=module_parameters, + temperature_model_parameters=temp_model_params, + inverter_parameters=inverter_parameters) + return system + + @pytest.fixture def location(): return Location(32.2, -111, altitude=700) @@ -405,14 +420,14 @@ def constant_aoi_loss(mc): mc.aoi_modifier = 0.9 -@pytest.mark.parametrize('aoi_model, method', [ - ('sapm', 'sapm_aoi_loss'), ('ashrae', 'ashraeiam'), - ('physical', 'physicaliam')]) -def test_aoi_models(system, location, aoi_model, method, weather, mocker): +@pytest.mark.parametrize('aoi_model', [ + 'sapm', 'ashrae', 'physical', 'martin_ruiz' +]) +def test_aoi_models(system, location, aoi_model, weather, mocker): mc = ModelChain(system, location, dc_model='sapm', aoi_model=aoi_model, spectral_model='no_loss') - m = mocker.spy(system, method) - mc.run_model(weather) + m = mocker.spy(system, 'get_iam') + mc.run_model(weather=weather) assert m.call_count == 1 assert isinstance(mc.ac, pd.Series) assert not mc.ac.empty @@ -442,6 +457,25 @@ def test_aoi_model_user_func(system, location, weather, mocker): assert mc.ac[1] < 1 +@pytest.mark.parametrize('aoi_model', [ + 'sapm', 'ashrae', 'physical', 'martin_ruiz' +]) +def test_infer_aoi_model(location, system_no_aoi, aoi_model): + for k in iam.IAM_MODEL_PARAMS[aoi_model]: + system_no_aoi.module_parameters.update({k: 1.0}) + mc = ModelChain(system_no_aoi, location, + orientation_strategy='None', + spectral_model='no_loss') + assert isinstance(mc, ModelChain) + + +def test_infer_aoi_model_invalid(location, system_no_aoi): + with pytest.raises(ValueError) as excinfo: + ModelChain(system_no_aoi, location, orientation_strategy='None', + spectral_model='no_loss') + assert 'could not infer AOI model' in str(excinfo.value) + + def constant_spectral_loss(mc): mc.spectral_modifier = 0.9 diff --git a/pvlib/test/test_pvsystem.py b/pvlib/test/test_pvsystem.py index 117b416915..8157c5704c 100644 --- a/pvlib/test/test_pvsystem.py +++ b/pvlib/test/test_pvsystem.py @@ -1,6 +1,5 @@ import inspect import os -import datetime from collections import OrderedDict import numpy as np @@ -12,10 +11,8 @@ from numpy.testing import assert_allclose from pvlib import pvsystem -from pvlib import clearsky -from pvlib import irradiance from pvlib import atmosphere -from pvlib import solarposition +from pvlib import iam as _iam from pvlib.location import Location from pvlib import temperature from pvlib._deprecation import pvlibDeprecationWarning @@ -82,162 +79,39 @@ def test_systemdef_dict(): assert expected == pvsystem.systemdef(meta, 5, 0, .1, 5, 5) -@needs_numpy_1_10 -def test_ashraeiam(): - thetas = np.array([-90. , -67.5, -45. , -22.5, 0. , 22.5, 45. , 67.5, 89., 90. , np.nan]) - iam = pvsystem.ashraeiam(thetas, .05) - expected = np.array([ 0, 0.9193437 , 0.97928932, 0.99588039, 1. , - 0.99588039, 0.97928932, 0.9193437 , 0, 0, np.nan]) - assert_allclose(iam, expected, equal_nan=True) - - -@needs_numpy_1_10 -def test_ashraeiam_scalar(): - thetas = -45. - iam = pvsystem.ashraeiam(thetas, .05) - expected = 0.97928932 - assert_allclose(iam, expected, equal_nan=True) - thetas = np.nan - iam = pvsystem.ashraeiam(thetas, .05) - expected = np.nan - assert_allclose(iam, expected, equal_nan=True) - - -def test_PVSystem_ashraeiam(mocker): - mocker.spy(pvsystem, 'ashraeiam') - module_parameters = pd.Series({'b': 0.05}) - system = pvsystem.PVSystem(module_parameters=module_parameters) - thetas = 1 - iam = system.ashraeiam(thetas) - pvsystem.ashraeiam.assert_called_once_with(thetas, b=0.05) - assert iam < 1. - - -@needs_numpy_1_10 -def test_physicaliam(): - aoi = np.array([-90. , -67.5, -45. , -22.5, 0. , 22.5, 45. , 67.5, 90. , np.nan]) - iam = pvsystem.physicaliam(aoi, 1.526, 0.002, 4) - expected = np.array([ 0, 0.8893998, 0.98797788, 0.99926198, 1, - 0.99926198, 0.98797788, 0.8893998, 0, np.nan]) - assert_allclose(iam, expected, equal_nan=True) - - # GitHub issue 397 - aoi = pd.Series(aoi) - iam = pvsystem.physicaliam(aoi, 1.526, 0.002, 4) - expected = pd.Series(expected) - assert_series_equal(iam, expected) - - -@needs_numpy_1_10 -def test_physicaliam_scalar(): - aoi = -45. - iam = pvsystem.physicaliam(aoi, 1.526, 0.002, 4) - expected = 0.98797788 - assert_allclose(iam, expected, equal_nan=True) - aoi = np.nan - iam = pvsystem.physicaliam(aoi, 1.526, 0.002, 4) - expected = np.nan - assert_allclose(iam, expected, equal_nan=True) - - -def test_PVSystem_physicaliam(mocker): - module_parameters = pd.Series({'K': 4, 'L': 0.002, 'n': 1.526}) - system = pvsystem.PVSystem(module_parameters=module_parameters) - mocker.spy(pvsystem, 'physicaliam') +@pytest.mark.parametrize('iam_model,model_params', [ + ('ashrae', {'b': 0.05}), + ('physical', {'K': 4, 'L': 0.002, 'n': 1.526}), + ('martin_ruiz', {'a_r': 0.16}), +]) +def test_PVSystem_get_iam(mocker, iam_model, model_params): + m = mocker.spy(_iam, iam_model) + system = pvsystem.PVSystem(module_parameters=model_params) thetas = 1 - iam = system.physicaliam(thetas) - pvsystem.physicaliam.assert_called_once_with(thetas, **module_parameters) + iam = system.get_iam(thetas, iam_model=iam_model) + m.assert_called_with(thetas, **model_params) assert iam < 1. -def test_iam_martin_ruiz(): - - aoi = 45. - a_r = 0.16 - expected = 0.98986965 - - # will fail of default values change - iam = pvsystem.iam_martin_ruiz(aoi) - assert_allclose(iam, expected) - # will fail of parameter names change - iam = pvsystem.iam_martin_ruiz(aoi=aoi, a_r=a_r) - assert_allclose(iam, expected) - - a_r = 0.18 - aoi = [-100, -60, 0, 60, 100, np.nan, np.inf] - expected = [0.0, 0.9414631, 1.0, 0.9414631, 0.0, np.nan, 0.0] - - # check out of range of inputs as list - iam = pvsystem.iam_martin_ruiz(aoi, a_r) - assert_allclose(iam, expected, equal_nan=True) - - # check out of range of inputs as array - iam = pvsystem.iam_martin_ruiz(np.array(aoi), a_r) - assert_allclose(iam, expected, equal_nan=True) - - # check out of range of inputs as Series - aoi = pd.Series(aoi) - expected = pd.Series(expected) - iam = pvsystem.iam_martin_ruiz(aoi, a_r) - assert_series_equal(iam, expected) - - # check exception clause - with pytest.raises(RuntimeError): - pvsystem.iam_martin_ruiz(0.0, a_r=0.0) - +def test_PVSystem_get_iam_sapm(sapm_module_params, mocker): + system = pvsystem.PVSystem(module_parameters=sapm_module_params) + mocker.spy(_iam, 'sapm') + aoi = 0 + out = system.get_iam(aoi, 'sapm') + _iam.sapm.assert_called_once_with(aoi, sapm_module_params) + assert_allclose(out, 1.0, atol=0.01) -@requires_scipy -def test_iam_interp(): - - aoi_meas = [0.0, 45.0, 65.0, 75.0] - iam_meas = [1.0, 0.9, 0.8, 0.6] - - # simple default linear method - aoi = 55.0 - expected = 0.85 - iam = pvsystem.iam_interp(aoi, aoi_meas, iam_meas) - assert_allclose(iam, expected) - - # simple non-default method - aoi = 55.0 - expected = 0.8878062 - iam = pvsystem.iam_interp(aoi, aoi_meas, iam_meas, method='cubic') - assert_allclose(iam, expected) - - # check with all reference values - aoi = aoi_meas - expected = iam_meas - iam = pvsystem.iam_interp(aoi, aoi_meas, iam_meas) - assert_allclose(iam, expected) - - # check normalization and Series - aoi = pd.Series(aoi) - expected = pd.Series(expected) - iam_mult = np.multiply(0.9, iam_meas) - iam = pvsystem.iam_interp(aoi, aoi_meas, iam_mult, normalize=True) - assert_series_equal(iam, expected) - - # check beyond reference values - aoi = [-45, 0, 45, 85, 90, 95, 100, 105, 110] - expected = [0.9, 1.0, 0.9, 0.4, 0.3, 0.2, 0.1, 0.0, 0.0] - iam = pvsystem.iam_interp(aoi, aoi_meas, iam_meas) - assert_allclose(iam, expected) - - # check exception clause - with pytest.raises(ValueError): - pvsystem.iam_interp(0.0, [0], [1]) - # check exception clause +def test_PVSystem_get_iam_interp(sapm_module_params, mocker): + system = pvsystem.PVSystem(module_parameters=sapm_module_params) with pytest.raises(ValueError): - pvsystem.iam_interp(0.0, [0, 90], [1, -1]) + system.get_iam(45, iam_model='interp') -@pytest.fixture(scope="session") -def sapm_module_params(sam_data): - modules = sam_data['sandiamod'] - module = 'Canadian_Solar_CS5P_220M___2009_' - module_parameters = modules[module] - return module_parameters +def test_PVSystem_get_iam_invalid(sapm_module_params, mocker): + system = pvsystem.PVSystem(module_parameters=sapm_module_params) + with pytest.raises(ValueError): + system.get_iam(45, iam_model='not_a_model') def test_retrieve_sam_raise_no_parameters(): @@ -356,9 +230,9 @@ def test_sapm(sapm_module_params): for k, v in expected.items(): assert_allclose(out[k], v, atol=1e-4) - # just make sure it works with a dict input + # just make sure it works with Series input pvsystem.sapm(effective_irradiance, temp_cell, - sapm_module_params.to_dict()) + pd.Series(sapm_module_params)) def test_PVSystem_sapm(sapm_module_params, mocker): @@ -420,42 +294,6 @@ def test_PVSystem_first_solar_spectral_loss(module_parameters, module_type, assert_allclose(out, 1, atol=0.5) -@pytest.mark.parametrize('aoi,expected', [ - (45, 0.9975036250000002), - (np.array([[-30, 30, 100, np.nan]]), - np.array([[0, 1.007572, 0, np.nan]])), - (pd.Series([80]), pd.Series([0.597472])) -]) -def test_sapm_aoi_loss(sapm_module_params, aoi, expected): - - out = pvsystem.sapm_aoi_loss(aoi, sapm_module_params) - - if isinstance(aoi, pd.Series): - assert_series_equal(out, expected, check_less_precise=4) - else: - assert_allclose(out, expected, atol=1e-4) - - -def test_sapm_aoi_loss_limits(): - module_parameters = {'B0': 5, 'B1': 0, 'B2': 0, 'B3': 0, 'B4': 0, 'B5': 0} - assert pvsystem.sapm_aoi_loss(1, module_parameters) == 5 - - module_parameters = {'B0': 5, 'B1': 0, 'B2': 0, 'B3': 0, 'B4': 0, 'B5': 0} - assert pvsystem.sapm_aoi_loss(1, module_parameters, upper=1) == 1 - - module_parameters = {'B0': -5, 'B1': 0, 'B2': 0, 'B3': 0, 'B4': 0, 'B5': 0} - assert pvsystem.sapm_aoi_loss(1, module_parameters) == 0 - - -def test_PVSystem_sapm_aoi_loss(sapm_module_params, mocker): - system = pvsystem.PVSystem(module_parameters=sapm_module_params) - mocker.spy(pvsystem, 'sapm_aoi_loss') - aoi = 0 - out = system.sapm_aoi_loss(aoi) - pvsystem.sapm_aoi_loss.assert_called_once_with(aoi, sapm_module_params) - assert_allclose(out, 1.0, atol=0.01) - - @pytest.mark.parametrize('test_input,expected', [ ([1000, 100, 5, 45, 1000], 1.1400510967821877), ([np.array([np.nan, 1000, 1000]), @@ -1627,10 +1465,39 @@ def test_deprecated_08(): with pytest.warns(pvlibDeprecationWarning): pvsystem.pvsyst_celltemp(1000, 25) module_parameters = {'R_sh_ref': 1, 'a_ref': 1, 'I_o_ref': 1, - 'alpha_sc': 1, 'I_L_ref': 1, 'R_s': 1} + 'alpha_sc': 1, 'I_L_ref': 1, 'R_s': 1, + 'B5': 0.0, 'B4': 0.0, 'B3': 0.0, 'B2': 0.0, + 'B1': 0.0, 'B0': 1.0, + 'b': 0.05, 'K': 4, 'L': 0.002, 'n': 1.526, + 'a_r': 0.16} + temp_model_params = temperature.TEMPERATURE_MODEL_PARAMETERS['sapm'][ + 'open_rack_glass_glass'] + # for missing temperature_model_parameters with pytest.warns(pvlibDeprecationWarning): pvsystem.PVSystem(module_parameters=module_parameters, racking_model='open', module_type='glass_glass') + pv = pvsystem.PVSystem(module_parameters=module_parameters, + temperature_model_parameters=temp_model_params, + racking_model='open', module_type='glass_glass') + # deprecated method PVSystem.ashraeiam + with pytest.warns(pvlibDeprecationWarning): + pv.ashraeiam(45) + # deprecated function ashraeiam + with pytest.warns(pvlibDeprecationWarning): + pvsystem.ashraeiam(45) + # deprecated method PVSystem.physicaliam + with pytest.warns(pvlibDeprecationWarning): + pv.physicaliam(45) + # deprecated function physicaliam + with pytest.warns(pvlibDeprecationWarning): + pvsystem.physicaliam(45) + # deprecated method PVSystem.sapm_aoi_loss + with pytest.warns(pvlibDeprecationWarning): + pv.sapm_aoi_loss(45) + # deprecated function sapm_aoi_loss + with pytest.warns(pvlibDeprecationWarning): + pvsystem.sapm_aoi_loss(45, {'B5': 0.0, 'B4': 0.0, 'B3': 0.0, 'B2': 0.0, + 'B1': 0.0, 'B0': 1.0}) @fail_on_pvlib_version('0.8')