From 9816a3fb497e64cf7f40b3bb5fe86bb844b4b603 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Wed, 2 Aug 2023 15:41:55 -0500 Subject: [PATCH 01/65] Adding functions, docstrings draft --- pvlib/iam.py | 287 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) diff --git a/pvlib/iam.py b/pvlib/iam.py index ca8f89468e..6eb12f72ef 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -11,6 +11,7 @@ import numpy as np import pandas as pd import functools +from scipy.optimize import minimize from pvlib.tools import cosd, sind # a dict of required parameter names for each IAM model @@ -904,3 +905,289 @@ def schlick_diffuse(surface_tilt): cug = pd.Series(cug, surface_tilt.index) return cuk, cug + + +# ---------------------------------------------------------------- + + +def _get_model(model_name): + # check that model is implemented + model_dict = {'ashrae': ashrae, 'martin_ruiz': martin_ruiz, + 'physical': physical} + try: + model = model_dict[model_name] + except KeyError: + raise NotImplementedError(f"The {model_name} model has not been \ + implemented") + + return model + + +def _check_params(model_name, params): + # check that the parameters passed in with the model + # belong to the model + param_dict = {'ashrae': {'b'}, 'martin_ruiz': {'a_r'}, + 'physical': {'n', 'K', 'L'}} + expected_params = param_dict[model_name] + + if set(params.keys()) != expected_params: + raise ValueError(f"The {model_name} model was expecting to be passed \ + {', '.join(list(param_dict[model_name]))}, but \ + was handed {', '.join(list(params.keys()))}") + + +def _truncated_weight(aoi, max_angle=70): + return [1 if angle <= max_angle else 0 for angle in aoi] + + +def _residual(aoi, source_iam, target, target_params, + weight_function=_truncated_weight, + weight_args=None): + # computes a sum of weighted differences between the source model + # and target model, using the provided weight function + + if weight_args == None: + weight_args = {} + + weight = weight_function(aoi, **weight_args) + + # check that weight_function is behaving as expected + if np.shape(aoi) != np.shape(weight): + assert weight_function != _truncated_weight + raise ValueError('The provided custom weight function is not \ + returning an object with the right shape. Please \ + refer to the docstrings for a more detailed \ + discussion about passing custom weight functions.') + + diff = np.abs(source_iam - np.nan_to_num(target(aoi, *target_params))) + return np.sum(diff * weight) + + +def _ashrae_to_physical(aoi, ashrae_iam, options): + # the ashrae model has an x-intercept less than 90 + # we solve for this intercept, and choose n so that the physical + # model will have the same x-intercept + int_idx = np.argwhere(ashrae_iam == 0.0).flatten()[0] + intercept = aoi[int_idx] + n = sind(intercept) + + # with n fixed, we will optimize for L (recall that K and L always + # appear in the physical model as a product, so it is enough to + # optimize for just L, and to fix K=4) + + # we will pass n to the optimizer to simplify things later on, + # but because we are setting (n, n) as the bounds, the optimizer + # will leave n fixed + bounds = [(0, 0.08), (n, n)] + guess = [0.002, n] + + def residual_function(target_params): + L, n = target_params + return _residual(aoi, ashrae_iam, physical, [n, 4, L], **options) + + return residual_function, guess, bounds + + +def _martin_ruiz_to_physical(aoi, martin_ruiz_iam, options): + # we will optimize for both n and L (recall that K and L always + # appear in the physical model as a product, so it is enough to + # optimize for just L, and to fix K=4) + bounds = [(0, 0.08), (1+1e-08, 2)] + guess = [0.002, 1+1e-08] + + # the product of K and L is more important in determining an initial + # guess for the location of the minimum, so we pass L in first + def residual_function(target_params): + L, n = target_params + return _residual(aoi, martin_ruiz_iam, physical, [n, 4, L], **options) + + return residual_function, guess, bounds + + +def _minimize(residual_function, guess, bounds): + optimize_result = minimize(residual_function, guess, method="powell", + bounds=bounds) + + if not optimize_result.success: + try: + message = "Optimizer exited unsuccessfully:" \ + + optimize_result.message + except AttributeError: + message = "Optimizer exited unsuccessfully: \ + No message explaining the failure was returned. \ + If you would like to see this message, please \ + update your scipy version (try version 1.8.0 \ + or beyond)." + raise RuntimeError(message) + + return optimize_result + + +def _process_return(target_name, optimize_result): + if target_name == "ashrae": + target_params = {'b': optimize_result.x} + + elif target_name == "martin_ruiz": + target_params = {'a_r': optimize_result.x} + + elif target_name == "physical": + L, n = optimize_result.x + target_params = {'n': n, 'K': 4, 'L': L} + + return target_params + + +def convert(source_name, source_params, target_name, options=None): + """ + Given a source model and its parameters, determines the best + parameters for the target model so that the models behave + similarly. (FIXME) + + Parameters + ---------- + source_name : str + Name of source model. Must be 'ashrae', 'martin_ruiz', or + 'physical'. + + source_params : dict + A dictionary of parameters for the source model. See table + below to get keys needed for each model. (Note that the keys + for the physical model are case-sensitive!) + + +--------------+----------+ + | source model | keys | + +==============+==========+ + | ashrae | b | + +--------------+----------+ + | martin_ruiz | a_r | + +--------------+----------+ + | physical | n, K, L | + +--------------+----------+ + + target_name : str + Name of target model. Must be 'ashrae', 'martin_ruiz', or + 'physical'. + + options : dict, optional + A dictionary that allows passing a custom weight function and + arguments to the (default or custom) weight function. Possible + keys are 'weight_function' and 'weight_args' + + weight_function : function + A function that outputs an array of weights to use + when computing residuals between models. + + Requirements: + ------------- + 1. Must accept aoi as first argument. (aoi is a numpy + array, and it is handed to the function internally.) + 2. Any other arguments must be keyword arguments. (These + will be passed by the user in weight_args, see below.) + 3. Must return an array-like object with the same shape + as aoi. + + weight_args : dict + A dictionary containing all keyword arguments for the + weight function. If using the default weight function, + the only keyword argument is max_angle. + + FIXME there needs to be more information about the default + weight function, so people don't have to go digging through the + private functions. + + Default value of options is None (leaving as default will use + default weight function `pvlib.iam._truncated_weight`). + * FIXME if name of default function changes * + + Returns + ------- + dict + Parameters for target model that best match the behavior of the + given source model. Key names are given in the table below. + (Note that the keys for the physical model are case-sensitive!) + + +--------------+----------+ + | target model | keys | + +==============+==========+ + | ashrae | b | + +--------------+----------+ + | martin_ruiz | a_r | + +--------------+----------+ + | physical | n, K, L | + +--------------+----------+ + + References + ---------- + .. [1] TODO + + See Also + -------- + pvlib.iam.fit + pvlib.iam.ashrae + pvlib.iam.martin_ruiz + pvlib.iam.physical + """ + + source = _get_model(source_name) + target = _get_model(target_name) + + # if no options were passed in, we will use the default arguments + if options == None: + options = {} + + aoi = np.linspace(0, 90, 100) + _check_params(source_name, source_params) + source_iam = source(aoi, **source_params) + + if target_name == "physical": + # we can do some special set-up to improve the fit when the + # target model is physical + if source_name == "ashrae": + residual_function, guess, bounds = \ + _ashrae_to_physical(aoi, source_iam, options) + elif source_name == "martin_ruiz": + residual_function, guess, bounds = \ + _martin_ruiz_to_physical(aoi, source_iam, options) + + else: + # otherwise, target model is ashrae or martin_ruiz, and scipy + # does fine without any special set-up + bounds = [(0, 1)] + guess = [1e-08] + def residual_function(target_param): + return _residual(aoi, source_iam, target, target_param, **options) + + optimize_result = _minimize(residual_function, guess, bounds) + + return _process_return(target_name, optimize_result) + + +def fit(measured_aoi, measured_iam, target_name, options=None): + # given measured aoi and iam data and a target model, finds + # parameters for target model that best fit measured data + target = _get_model(target_name) + + # if no options were passed in, we will use the default arguments + if options == None: + options = {} + + if target_name == "physical": + bounds = [(0, 0.08), (1+1e-08, 2)] + guess = [0.002, 1+1e-08] + + def residual_function(target_params): + L, n = target_params + return _residual(measured_aoi, measured_iam, target, [n, 4, L], + **options) + + else: # target_name == martin_ruiz or target_name == ashrae + bounds = [(0, 1)] + guess = [1e-08] + def residual_function(target_param): + return _residual(measured_aoi, measured_iam, target, target_param, + **options) + + optimize_result = _minimize(residual_function, guess, bounds) + + return _process_return(target_name, optimize_result) + From dacc1f01d6ba6d1f54f4c9f6e5ac0b5ed64c2ffc Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Wed, 2 Aug 2023 16:32:26 -0500 Subject: [PATCH 02/65] Updating imports, adding docstrings --- .../reflections/plot_convert_iam_models.py | 215 ++++++++++++++++++ pvlib/iam.py | 92 +++++++- 2 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 docs/examples/reflections/plot_convert_iam_models.py diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py new file mode 100644 index 0000000000..aa84fca216 --- /dev/null +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -0,0 +1,215 @@ + +""" +IAM Model Conversion +==================== + +""" + +# %% +# Introductory text blurb (TODO) + +import numpy as np +import matplotlib.pyplot as plt + +from pvlib.iam import (ashrae, martin_ruiz, physical, convert, fit) + +# %% +# Converting from one model to another +# ------------------------------------ +# +# Here we'll show how to convert from the Martin-Ruiz model to both the +# Physical and Ashrae models. + +# compute martin_ruiz iam for given parameter +aoi = np.linspace(0, 90, 100) +martin_ruiz_params = {'a_r': 0.16} +martin_ruiz_iam = martin_ruiz(aoi, **martin_ruiz_params) + +# get parameters for physical model, compute physical iam +physical_params = convert('martin_ruiz', martin_ruiz_params, 'physical') +physical_iam = physical(aoi, **physical_params) + +# get parameters for ashrae model, compute ashrae iam +ashrae_params = convert('martin_ruiz', martin_ruiz_params, 'ashrae') +ashrae_iam = ashrae(aoi, **ashrae_params) + +fig, (ax1, ax2) = plt.subplots(1, 2, sharey=True) + +# plot aoi vs iam curves +ax1.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') +ax1.plot(aoi, physical_iam, label='Physical') +ax1.set_xlabel('AOI (degrees)') +ax1.set_title('Martin-Ruiz to Physical') +ax1.legend() + +ax2.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') +ax2.plot(aoi, ashrae_iam, label='Ashrae') +ax2.set_xlabel('AOI (degrees)') +ax2.set_title('Martin-Ruiz to Ashrae') +ax2.legend() + +ax1.set_ylabel('IAM') +plt.show() + + +# %% +# Fitting measured data to a model +# -------------------------------- +# +# Here, we'll show how to fit measured data to a model. In this case, +# we'll use perturbed output from the Martin-Ruiz model to mimic +# measured data and then we'll fit this to the Physical model. + +from random import uniform + +# create perturbed iam data +aoi = np.linspace(0, 90, 100) +params = {'a_r': 0.16} +iam = martin_ruiz(aoi, **params) +data = iam * np.array([uniform(0.98, 1.02) for _ in range(len(iam))]) + +# get parameters for physical model that fits this data +physical_params = convert('martin_ruiz', martin_ruiz_params, 'physical') + +# compute physical iam +physical_iam = physical(aoi, **physical_params) + +# plot aoi vs iam curve +plt.scatter(aoi, data, c='darkorange', label='Perturbed data') +plt.plot(aoi, physical_iam, label='Physical') +plt.xlabel('AOI (degrees)') +plt.ylabel('IAM') +plt.title('Fitting data to Physical model') +plt.legend() +plt.show() + + +# %% +# Options for the weight function +# ------------------------------- +# +# When converting between the various IAM models implemented in pvlib, +# :py:func:`pvlib.iam.convert` allows us to pass in a custom weight +# function. This function is used when computing the residuals between +# the original (source) model and the target model. In some cases, the choice +# of weight function has a minimal effect on the outputted parameters for the +# target model. This is especially true when there is a choice of parameters +# for the target model that matches the source model very well. +# +# However, in cases where this fit is not as strong, our choice of weight +# function can have a large impact on what parameters are returned for the +# target function. What weight function we choose in these cases will depend on +# how we intend to use the target model. +# +# Here we'll show examples of both of these cases, starting with an example +# where the choice of weight function does not have much impact. In doing +# so, we'll also show how to pass arguments to the default weight function, +# as well as pass in a custom weight function of our choice. + +from pvlib.tools import cosd + +# compute martin_ruiz iam for given parameter +aoi = np.linspace(0, 90, 100) +martin_ruiz_params = {'a_r': 0.16} +martin_ruiz_iam = martin_ruiz(aoi, **martin_ruiz_params) + + +# get parameters for physical models ... + +# ... using default weight function +physical_params_default = convert('martin_ruiz', martin_ruiz_params, 'physical') +physical_iam_default = physical(aoi, **physical_params_default) + +# ... using default weight function with different max_angle +options = {'weight_args': {'max_angle': 50}} +physical_params_diff_max_angle = convert('martin_ruiz', martin_ruiz_params, + 'physical', options=options) +physical_iam_diff_max_angle = physical(aoi, **physical_params_diff_max_angle) + +# ... using custom weight function +def cos_weight(aoi): + return cosd(aoi) +options = {'weight_function': cos_weight} +physical_params_custom = convert('martin_ruiz', martin_ruiz_params, 'physical', + options=options) +physical_iam_custom = physical(aoi, **physical_params_custom) + +# plot aoi vs iam curve +plt.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') +plt.plot(aoi, physical_iam_default, label='Default weight function') +plt.plot(aoi, physical_iam_diff_max_angle, label='Different max angle') +plt.plot(aoi, physical_iam_custom, label='Custom weight function') +plt.xlabel('AOI (degrees)') +plt.ylabel('IAM') +plt.title('Martin-Ruiz to Physical') +plt.legend() +plt.show() + +# %% +# For this choice of source and target models, the weight function has little +# to no effect on the target model's outputted parameters. In this case, it +# is reasonable to use the default weight function with its default arguments. +# +# Now we'll look at an example where the weight function does affect the +# output. + +# get parameters for ashrae models ... + +# ... using default weight function +ashrae_params_default = convert('martin_ruiz', martin_ruiz_params, 'ashrae') +ashrae_iam_default = ashrae(aoi, **ashrae_params_default) + +# ... using default weight function with different max_angle +options = {'weight_args': {'max_angle': 50}} +ashrae_params_diff_max_angle = convert('martin_ruiz', martin_ruiz_params, + 'ashrae', options=options) +ashrae_iam_diff_max_angle = ashrae(aoi, **ashrae_params_diff_max_angle) + +# ... using custom weight function +def cos_weight(aoi): + return cosd(aoi) +options = {'weight_function': cos_weight} +ashrae_params_custom = convert('martin_ruiz', martin_ruiz_params, 'ashrae', + options=options) +ashrae_iam_custom = ashrae(aoi, **ashrae_params_custom) + +# plot aoi vs iam curve +plt.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') +plt.plot(aoi, ashrae_iam_default, label='Default weight function') +plt.plot(aoi, ashrae_iam_diff_max_angle, label='Different max angle') +plt.plot(aoi, ashrae_iam_custom, label='Custom weight function') +plt.xlabel('AOI (degrees)') +plt.ylabel('IAM') +plt.title('Martin-Ruiz to Ashrae') +plt.legend() +plt.show() + +# %% +# In this case, each of these outputted target models looks quite different. +# +# The default setup focuses on matching IAM from 0 to 70 degrees, but because +# this Martin-Ruiz model is not very compatible with the Ashrae model, we +# sacrifice matching the curve tightly starting at 15 degrees, in order to +# minimize the residuals close to 70 degrees. +# +# When we changed the parameters we passed to the default weight function, we +# told it to focus on matching IAM from 0 to 50 degrees instead. The outputted +# model is a tight fit for AOI up to about 50, but it does not match the +# Martin-Ruiz model at all after this. +# +# The custom weight function cares (to varying degrees) about the entire range +# of AOI, from 0 to 90. For this reason, we see that it is not a tight fit +# anywhere except for small AOI, as it is attempting to minimize the residuals +# across the entire curve. +# +# Finding the right weight function and parameters in such cases will require +# knowing where you want the target model to be more accurate, and will likely +# require some experimentation. + + +# %% +# The default weight function +# --------------------------- +# +# TODO + diff --git a/pvlib/iam.py b/pvlib/iam.py index 6eb12f72ef..1b7f8a0eae 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1039,9 +1039,8 @@ def _process_return(target_name, optimize_result): def convert(source_name, source_params, target_name, options=None): """ - Given a source model and its parameters, determines the best - parameters for the target model so that the models behave - similarly. (FIXME) + Given a source model and its parameters and a target model, finds + parameters for target model that best fit source model. Parameters ---------- @@ -1091,13 +1090,13 @@ def convert(source_name, source_params, target_name, options=None): weight function. If using the default weight function, the only keyword argument is max_angle. - FIXME there needs to be more information about the default + TODO there needs to be more information about the default weight function, so people don't have to go digging through the private functions. Default value of options is None (leaving as default will use default weight function `pvlib.iam._truncated_weight`). - * FIXME if name of default function changes * + * TODO if name of default function changes * Returns ------- @@ -1163,8 +1162,87 @@ def residual_function(target_param): def fit(measured_aoi, measured_iam, target_name, options=None): - # given measured aoi and iam data and a target model, finds - # parameters for target model that best fit measured data + """ + Given measured aoi and measured iam data and a target model, finds + parameters for target model that best fit measured data. + + Parameters + ---------- + measured_aoi : array-like + # TODO check that Pandas, np arrays and list work + Array of angle of incidence (aoi) values associated with + the measured IAM data. + + measured_iam : array-like + # TODO check that Pandas, np arrays and list work + # TODO add note about 1 dimensional? + Array of measured IAM values. + + target_name : str + Name of target model. Must be 'ashrae', 'martin_ruiz', or + 'physical'. + + options : dict, optional + A dictionary that allows passing a custom weight function and + arguments to the (default or custom) weight function. Possible + keys are 'weight_function' and 'weight_args' + + weight_function : function + A function that outputs an array of weights to use + when computing residuals between models. + + Requirements: + ------------- + 1. Must accept aoi as first argument. (aoi is a numpy + array, and it is handed to the function internally.) + 2. Any other arguments must be keyword arguments. (These + will be passed by the user in weight_args, see below.) + 3. Must return an array-like object with the same shape + as aoi. + + weight_args : dict + A dictionary containing all keyword arguments for the + weight function. If using the default weight function, + the only keyword argument is max_angle. + + TODO there needs to be more information about the default + weight function, so people don't have to go digging through the + private functions. + + Default value of options is None (leaving as default will use + default weight function `pvlib.iam._truncated_weight`). + * TODO if name of default function changes * + + + Returns + ------- + dict + Parameters for target model that best match the behavior of the + given data. Key names are given in the table below. (Note that + the keys for the physical model are case-sensitive!) + + +--------------+----------+ + | target model | keys | + +==============+==========+ + | ashrae | b | + +--------------+----------+ + | martin_ruiz | a_r | + +--------------+----------+ + | physical | n, K, L | + +--------------+----------+ + + References + ---------- + .. [1] TODO + + See Also + -------- + pvlib.iam.convert + pvlib.iam.ashrae + pvlib.iam.martin_ruiz + pvlib.iam.physical + """ + target = _get_model(target_name) # if no options were passed in, we will use the default arguments From fc968af2d6dff05e856c65a6480b68944885f1df Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Wed, 2 Aug 2023 16:57:34 -0500 Subject: [PATCH 03/65] Updated rst file --- docs/sphinx/source/reference/pv_modeling/iam.rst | 2 ++ pvlib/iam.py | 7 ++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/sphinx/source/reference/pv_modeling/iam.rst b/docs/sphinx/source/reference/pv_modeling/iam.rst index 1871f9b4a2..e12a0c519e 100644 --- a/docs/sphinx/source/reference/pv_modeling/iam.rst +++ b/docs/sphinx/source/reference/pv_modeling/iam.rst @@ -17,3 +17,5 @@ Incident angle modifiers iam.marion_integrate iam.schlick iam.schlick_diffuse + iam.convert + iam.fit diff --git a/pvlib/iam.py b/pvlib/iam.py index 1b7f8a0eae..3604960e3a 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1169,13 +1169,10 @@ def fit(measured_aoi, measured_iam, target_name, options=None): Parameters ---------- measured_aoi : array-like - # TODO check that Pandas, np arrays and list work - Array of angle of incidence (aoi) values associated with - the measured IAM data. + Array of angle of incidence values associated with the + measured IAM data. measured_iam : array-like - # TODO check that Pandas, np arrays and list work - # TODO add note about 1 dimensional? Array of measured IAM values. target_name : str From 441d8cc8e6fad832e3518d0e9e7c833d71f97c68 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Thu, 3 Aug 2023 16:23:42 -0500 Subject: [PATCH 04/65] Make linter edits --- .../reflections/plot_convert_iam_models.py | 18 +++++++++--------- pvlib/iam.py | 14 ++++++++------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index aa84fca216..756f964d70 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -9,8 +9,10 @@ # Introductory text blurb (TODO) import numpy as np +from random import uniform import matplotlib.pyplot as plt +from pvlib.tools import cosd from pvlib.iam import (ashrae, martin_ruiz, physical, convert, fit) # %% @@ -60,8 +62,6 @@ # we'll use perturbed output from the Martin-Ruiz model to mimic # measured data and then we'll fit this to the Physical model. -from random import uniform - # create perturbed iam data aoi = np.linspace(0, 90, 100) params = {'a_r': 0.16} @@ -69,7 +69,7 @@ data = iam * np.array([uniform(0.98, 1.02) for _ in range(len(iam))]) # get parameters for physical model that fits this data -physical_params = convert('martin_ruiz', martin_ruiz_params, 'physical') +physical_params = fit(aoi, data, 'physical') # compute physical iam physical_iam = physical(aoi, **physical_params) @@ -106,8 +106,6 @@ # so, we'll also show how to pass arguments to the default weight function, # as well as pass in a custom weight function of our choice. -from pvlib.tools import cosd - # compute martin_ruiz iam for given parameter aoi = np.linspace(0, 90, 100) martin_ruiz_params = {'a_r': 0.16} @@ -117,7 +115,8 @@ # get parameters for physical models ... # ... using default weight function -physical_params_default = convert('martin_ruiz', martin_ruiz_params, 'physical') +physical_params_default = convert('martin_ruiz', martin_ruiz_params, + 'physical') physical_iam_default = physical(aoi, **physical_params_default) # ... using default weight function with different max_angle @@ -129,6 +128,7 @@ # ... using custom weight function def cos_weight(aoi): return cosd(aoi) + options = {'weight_function': cos_weight} physical_params_custom = convert('martin_ruiz', martin_ruiz_params, 'physical', options=options) @@ -162,15 +162,16 @@ def cos_weight(aoi): # ... using default weight function with different max_angle options = {'weight_args': {'max_angle': 50}} ashrae_params_diff_max_angle = convert('martin_ruiz', martin_ruiz_params, - 'ashrae', options=options) + 'ashrae', options=options) ashrae_iam_diff_max_angle = ashrae(aoi, **ashrae_params_diff_max_angle) # ... using custom weight function def cos_weight(aoi): return cosd(aoi) + options = {'weight_function': cos_weight} ashrae_params_custom = convert('martin_ruiz', martin_ruiz_params, 'ashrae', - options=options) + options=options) ashrae_iam_custom = ashrae(aoi, **ashrae_params_custom) # plot aoi vs iam curve @@ -212,4 +213,3 @@ def cos_weight(aoi): # --------------------------- # # TODO - diff --git a/pvlib/iam.py b/pvlib/iam.py index 3604960e3a..698202fe75 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -946,7 +946,7 @@ def _residual(aoi, source_iam, target, target_params, # computes a sum of weighted differences between the source model # and target model, using the provided weight function - if weight_args == None: + if weight_args is None: weight_args = {} weight = weight_function(aoi, **weight_args) @@ -1011,7 +1011,7 @@ def _minimize(residual_function, guess, bounds): if not optimize_result.success: try: message = "Optimizer exited unsuccessfully:" \ - + optimize_result.message + + optimize_result.message except AttributeError: message = "Optimizer exited unsuccessfully: \ No message explaining the failure was returned. \ @@ -1131,7 +1131,7 @@ def convert(source_name, source_params, target_name, options=None): target = _get_model(target_name) # if no options were passed in, we will use the default arguments - if options == None: + if options is None: options = {} aoi = np.linspace(0, 90, 100) @@ -1153,6 +1153,7 @@ def convert(source_name, source_params, target_name, options=None): # does fine without any special set-up bounds = [(0, 1)] guess = [1e-08] + def residual_function(target_param): return _residual(aoi, source_iam, target, target_param, **options) @@ -1243,7 +1244,7 @@ def fit(measured_aoi, measured_iam, target_name, options=None): target = _get_model(target_name) # if no options were passed in, we will use the default arguments - if options == None: + if options is None: options = {} if target_name == "physical": @@ -1255,9 +1256,11 @@ def residual_function(target_params): return _residual(measured_aoi, measured_iam, target, [n, 4, L], **options) - else: # target_name == martin_ruiz or target_name == ashrae + # otherwise, target_name is martin_ruiz or ashrae + else: bounds = [(0, 1)] guess = [1e-08] + def residual_function(target_param): return _residual(measured_aoi, measured_iam, target, target_param, **options) @@ -1265,4 +1268,3 @@ def residual_function(target_param): optimize_result = _minimize(residual_function, guess, bounds) return _process_return(target_name, optimize_result) - From 77641d0adfb8b476eb790ed7203919f7fe924eb1 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Fri, 4 Aug 2023 09:15:12 -0500 Subject: [PATCH 05/65] Docstring experiment --- pvlib/iam.py | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 698202fe75..a89ac7c36f 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1183,32 +1183,31 @@ def fit(measured_aoi, measured_iam, target_name, options=None): options : dict, optional A dictionary that allows passing a custom weight function and arguments to the (default or custom) weight function. Possible - keys are 'weight_function' and 'weight_args' + keys are ``weight_function`` and ``weight_args``. weight_function : function A function that outputs an array of weights to use when computing residuals between models. Requirements: - ------------- - 1. Must accept aoi as first argument. (aoi is a numpy - array, and it is handed to the function internally.) - 2. Any other arguments must be keyword arguments. (These - will be passed by the user in weight_args, see below.) - 3. Must return an array-like object with the same shape - as aoi. + 1. Must accept aoi as first argument. (aoi is a numpy + array, and it is handed to the function internally.) + 2. Any other arguments must be keyword arguments. (These + will be passed by the user in weight_args, see below.) + 3. Must return an array-like object with the same shape + as aoi. weight_args : dict A dictionary containing all keyword arguments for the weight function. If using the default weight function, - the only keyword argument is max_angle. + the only keyword argument is ``max_angle``. - TODO there needs to be more information about the default + * TODO there needs to be more information about the default weight function, so people don't have to go digging through the - private functions. + private functions. * Default value of options is None (leaving as default will use - default weight function `pvlib.iam._truncated_weight`). + default weight function ``iam._truncated_weight``). * TODO if name of default function changes * @@ -1219,15 +1218,19 @@ def fit(measured_aoi, measured_iam, target_name, options=None): given data. Key names are given in the table below. (Note that the keys for the physical model are case-sensitive!) - +--------------+----------+ - | target model | keys | - +==============+==========+ - | ashrae | b | - +--------------+----------+ - | martin_ruiz | a_r | - +--------------+----------+ - | physical | n, K, L | - +--------------+----------+ +
+ + +--------------+----------------------+ + | target model | keys | + +==============+======================+ + | ashrae | ``b`` | + +--------------+----------------------+ + | martin_ruiz | ``a_r`` | + +--------------+----------------------+ + | physical | ``n``, ``K``, ``L`` | + +--------------+----------------------+ + +
References ---------- From d7cbf5eed149db953fc26a6df4775433e2d4f94d Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Fri, 4 Aug 2023 15:06:33 -0500 Subject: [PATCH 06/65] Added tests --- pvlib/tests/test_iam.py | 122 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 3d017ef7c2..dde4af01cc 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -395,3 +395,125 @@ def test_schlick_diffuse(): assert_series_equal(pd.Series(expected_sky, idx), actual_sky) assert_series_equal(pd.Series(expected_ground, idx), actual_ground, rtol=1e-6) + + +def test_convert(): + # expected value calculated from computing residual function over + # a range of inputs, and taking minimum of these values + expected_a_r = 0.17589859805082217 + actual_params_dict = _iam.convert('physical', + {'n': 1.5, 'K': 4.5, 'L': 0.004}, + 'martin_ruiz') + actual_a_r = actual_params_dict['a_r'] + + assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) + + +# I may also need to write tests that use _ashrae_to_physical and +# _martin_ruiz_to_physical TODO + + +def test_convert_custom_weight_func(): + # define custom weight function that takes in other arguments + def scaled_weight(aoi, scalar): + return scalar * aoi + + # expected value calculated from computing residual function over + # a range of inputs, and taking minimum of these values + expected_a_r = 0.17478448342232694 + + options={'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} + actual_params_dict = _iam.convert('physical', + {'n': 1.5, 'K': 4.5, 'L': 0.004}, + 'martin_ruiz', options=options) + actual_a_r = actual_params_dict['a_r'] + + assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) + + +def test_convert_model_not_implemented(): + with pytest.raises(NotImplementedError, match='model has not been'): + _iam.convert('ashrae', {'b': 0.1}, 'foo') + + +def test_convert_wrong_model_parameters(): + with pytest.raises(ValueError, match='model was expecting'): + _iam.convert('ashrae', {'B': 0.1}, 'physical') + + +def test_convert_wrong_custom_weight_func(): + def wrong_weight(aoi): + return 0 + + with pytest.raises(ValueError, match='custom weight function'): + _iam.convert('ashrae', {'b': 0.1}, 'physical', + options={'weight_function': wrong_weight}) + + +def test_convert__minimize_fails(): + # to make scipy.optimize.minimize fail, we'll pass in a nonsense + # weight function that only outputs nans + def nan_weight(aoi): + return [float('nan')]*len(aoi) + + with pytest.raises(RuntimeError, match='Optimizer exited unsuccessfully'): + _iam.convert('ashrae', {'b': 0.1}, 'physical', + options={'weight_function': nan_weight}) + + +def test_fit(): + aoi = np.linspace(0, 90, 5) + perturb = np.array([1.2, 1.01, 0.95, 1, 0.98]) + perturbed_iam = _iam.martin_ruiz(aoi, a_r=0.14) * perturb + + expected_a_r = 0.14 + actual_param_dict = _iam.fit(aoi, perturbed_iam, 'martin_ruiz') + actual_a_r = actual_param_dict['a_r'] + + assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) + + +# Testing different input types for fit? TODO + +def test_fit_custom_weight_func(): + # define custom weight function that takes in other arguments + def scaled_weight(aoi, scalar): + return scalar * aoi + + aoi = np.linspace(0, 90, 5) + perturb = np.array([1.2, 1.01, 0.95, 1, 0.98]) + perturbed_iam = _iam.martin_ruiz(aoi, a_r=0.14) * perturb + + expected_a_r = 0.14 + + options={'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} + actual_param_dict = _iam.fit(aoi, perturbed_iam, 'martin_ruiz', + options=options) + actual_a_r = actual_param_dict['a_r'] + + assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) + + +def test_fit_model_not_implemented(): + with pytest.raises(NotImplementedError, match='model has not been'): + _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'foo') + + +def test_fit_wrong_custom_weight_func(): + def wrong_weight(aoi): + return 0 + + with pytest.raises(ValueError, match='custom weight function'): + _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'physical', + options={'weight_function': wrong_weight}) + + +def test_fit__minimize_fails(): + # to make scipy.optimize.minimize fail, we'll pass in a nonsense + # weight function that only outputs nans + def nan_weight(aoi): + return [float('nan')]*len(aoi) + + with pytest.raises(RuntimeError, match='Optimizer exited unsuccessfully'): + _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'physical', + options={'weight_function': nan_weight}) From b4dcecf50763edb330565d09b207dd0b7c02e8f6 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Fri, 4 Aug 2023 15:17:16 -0500 Subject: [PATCH 07/65] More linter edits --- pvlib/tests/test_iam.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index dde4af01cc..c5742a6adc 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -422,7 +422,7 @@ def scaled_weight(aoi, scalar): # a range of inputs, and taking minimum of these values expected_a_r = 0.17478448342232694 - options={'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} + options = {'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} actual_params_dict = _iam.convert('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'martin_ruiz', options=options) @@ -486,7 +486,7 @@ def scaled_weight(aoi, scalar): expected_a_r = 0.14 - options={'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} + options = {'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} actual_param_dict = _iam.fit(aoi, perturbed_iam, 'martin_ruiz', options=options) actual_a_r = actual_param_dict['a_r'] @@ -516,4 +516,4 @@ def nan_weight(aoi): with pytest.raises(RuntimeError, match='Optimizer exited unsuccessfully'): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'physical', - options={'weight_function': nan_weight}) + options={'weight_function': nan_weight}) From 6d054f4fdc4837c40b4a59459d7a72ef1936a3bd Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 8 Aug 2023 08:56:16 -0500 Subject: [PATCH 08/65] Docstrings and linter edits --- .../reflections/plot_convert_iam_models.py | 6 +- pvlib/iam.py | 55 +++++++++---------- pvlib/tests/test_iam.py | 2 +- 3 files changed, 29 insertions(+), 34 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 756f964d70..b965e2067d 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -126,8 +126,7 @@ physical_iam_diff_max_angle = physical(aoi, **physical_params_diff_max_angle) # ... using custom weight function -def cos_weight(aoi): - return cosd(aoi) +cos_weight = lambda aoi : cosd(aoi) options = {'weight_function': cos_weight} physical_params_custom = convert('martin_ruiz', martin_ruiz_params, 'physical', @@ -166,8 +165,7 @@ def cos_weight(aoi): ashrae_iam_diff_max_angle = ashrae(aoi, **ashrae_params_diff_max_angle) # ... using custom weight function -def cos_weight(aoi): - return cosd(aoi) +cos_weight = lambda aoi : cosd(aoi) options = {'weight_function': cos_weight} ashrae_params_custom = convert('martin_ruiz', martin_ruiz_params, 'ashrae', diff --git a/pvlib/iam.py b/pvlib/iam.py index a89ac7c36f..6a55ca75a9 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1164,8 +1164,9 @@ def residual_function(target_param): def fit(measured_aoi, measured_iam, target_name, options=None): """ - Given measured aoi and measured iam data and a target model, finds - parameters for target model that best fit measured data. + Given measured angle of incidence values and measured IAM data and + a target model, finds parameters for target model that best fit the + measured data. Parameters ---------- @@ -1177,60 +1178,56 @@ def fit(measured_aoi, measured_iam, target_name, options=None): Array of measured IAM values. target_name : str - Name of target model. Must be 'ashrae', 'martin_ruiz', or - 'physical'. + Name of target model. Must be ``'ashrae'``, ``'martin_ruiz'``, + or ``'physical'``. options : dict, optional A dictionary that allows passing a custom weight function and arguments to the (default or custom) weight function. Possible - keys are ``weight_function`` and ``weight_args``. + keys are `weight_function` and `weight_args`. weight_function : function A function that outputs an array of weights to use when computing residuals between models. Requirements: - 1. Must accept aoi as first argument. (aoi is a numpy - array, and it is handed to the function internally.) + 1. Must accept ``aoi`` as first argument. (``aoi`` is a + numpy array, and it is handed to the function + internally.) 2. Any other arguments must be keyword arguments. (These will be passed by the user in weight_args, see below.) 3. Must return an array-like object with the same shape - as aoi. + as ``aoi``. weight_args : dict A dictionary containing all keyword arguments for the weight function. If using the default weight function, the only keyword argument is ``max_angle``. - * TODO there needs to be more information about the default + *TODO there needs to be more information about the default weight function, so people don't have to go digging through the - private functions. * + private functions.* - Default value of options is None (leaving as default will use - default weight function ``iam._truncated_weight``). - * TODO if name of default function changes * + Default value of `options` is None (leaving as default will use + default weight function `iam._truncated_weight`). + + *TODO if name of default function changes* Returns ------- dict Parameters for target model that best match the behavior of the - given data. Key names are given in the table below. (Note that - the keys for the physical model are case-sensitive!) - -
- - +--------------+----------------------+ - | target model | keys | - +==============+======================+ - | ashrae | ``b`` | - +--------------+----------------------+ - | martin_ruiz | ``a_r`` | - +--------------+----------------------+ - | physical | ``n``, ``K``, ``L`` | - +--------------+----------------------+ - -
+ given data. + + If target model is ``'ashrae'``, the dictionary will contain + the key ``'b'``. + + If target model is ``'martin_ruiz'``, the dictionary will + contain the key ``'a_r'``. + + If target model is ``'physical'``, the dictionary will + contain the keys ``'n'``, ``'K'``, and ``'L'``. References ---------- diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index c5742a6adc..ec2bc84e6e 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -516,4 +516,4 @@ def nan_weight(aoi): with pytest.raises(RuntimeError, match='Optimizer exited unsuccessfully'): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'physical', - options={'weight_function': nan_weight}) + options={'weight_function': nan_weight}) From 21a66ee3132f9c8abf83e840550850d03cb52468 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 8 Aug 2023 10:17:25 -0500 Subject: [PATCH 09/65] Docstrings and linter --- docs/examples/reflections/plot_convert_iam_models.py | 6 ++---- pvlib/iam.py | 9 +-------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index b965e2067d..3c5d6aa750 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -126,9 +126,8 @@ physical_iam_diff_max_angle = physical(aoi, **physical_params_diff_max_angle) # ... using custom weight function -cos_weight = lambda aoi : cosd(aoi) +options = {'weight_function': lambda aoi : cosd(aoi)} -options = {'weight_function': cos_weight} physical_params_custom = convert('martin_ruiz', martin_ruiz_params, 'physical', options=options) physical_iam_custom = physical(aoi, **physical_params_custom) @@ -165,9 +164,8 @@ ashrae_iam_diff_max_angle = ashrae(aoi, **ashrae_params_diff_max_angle) # ... using custom weight function -cos_weight = lambda aoi : cosd(aoi) +options = {'weight_function': lambda aoi : cosd(aoi)} -options = {'weight_function': cos_weight} ashrae_params_custom = convert('martin_ruiz', martin_ruiz_params, 'ashrae', options=options) ashrae_iam_custom = ashrae(aoi, **ashrae_params_custom) diff --git a/pvlib/iam.py b/pvlib/iam.py index 6a55ca75a9..14069337a6 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1195,7 +1195,7 @@ def fit(measured_aoi, measured_iam, target_name, options=None): numpy array, and it is handed to the function internally.) 2. Any other arguments must be keyword arguments. (These - will be passed by the user in weight_args, see below.) + will be passed by the user in `weight_args`, see below.) 3. Must return an array-like object with the same shape as ``aoi``. @@ -1204,16 +1204,9 @@ def fit(measured_aoi, measured_iam, target_name, options=None): weight function. If using the default weight function, the only keyword argument is ``max_angle``. - *TODO there needs to be more information about the default - weight function, so people don't have to go digging through the - private functions.* - Default value of `options` is None (leaving as default will use default weight function `iam._truncated_weight`). - *TODO if name of default function changes* - - Returns ------- dict From b4714a47a3d9164c44c55734bb880005dcb12549 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 8 Aug 2023 10:20:34 -0500 Subject: [PATCH 10/65] LINTER --- docs/examples/reflections/plot_convert_iam_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 3c5d6aa750..26c10e35de 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -126,7 +126,7 @@ physical_iam_diff_max_angle = physical(aoi, **physical_params_diff_max_angle) # ... using custom weight function -options = {'weight_function': lambda aoi : cosd(aoi)} +options = {'weight_function': lambda aoi: cosd(aoi)} physical_params_custom = convert('martin_ruiz', martin_ruiz_params, 'physical', options=options) @@ -164,7 +164,7 @@ ashrae_iam_diff_max_angle = ashrae(aoi, **ashrae_params_diff_max_angle) # ... using custom weight function -options = {'weight_function': lambda aoi : cosd(aoi)} +options = {'weight_function': lambda aoi: cosd(aoi)} ashrae_params_custom = convert('martin_ruiz', martin_ruiz_params, 'ashrae', options=options) From 96de7d9a9692289631f9f980318f9d08c3f8d0fd Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 8 Aug 2023 10:40:28 -0500 Subject: [PATCH 11/65] Docstrings edit --- pvlib/iam.py | 82 +++++++++++++++++++++++----------------------------- 1 file changed, 36 insertions(+), 46 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 14069337a6..2edfd64cd2 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1045,75 +1045,65 @@ def convert(source_name, source_params, target_name, options=None): Parameters ---------- source_name : str - Name of source model. Must be 'ashrae', 'martin_ruiz', or - 'physical'. + Name of source model. Must be ``'ashrae'``, ``'martin_ruiz'``, or + ``'physical'``. source_params : dict - A dictionary of parameters for the source model. See table - below to get keys needed for each model. (Note that the keys - for the physical model are case-sensitive!) - - +--------------+----------+ - | source model | keys | - +==============+==========+ - | ashrae | b | - +--------------+----------+ - | martin_ruiz | a_r | - +--------------+----------+ - | physical | n, K, L | - +--------------+----------+ + A dictionary of parameters for the source model. + + If source model is ``'ashrae'``, the dictionary must contain + the key ``'b'``. + + If source model is ``'martin_ruiz'``, the dictionary must + contain the key ``'a_r'``. + + If source model is ``'physical'``, the dictionary must + contain the keys ``'n'``, ``'K'``, and ``'L'``. target_name : str - Name of target model. Must be 'ashrae', 'martin_ruiz', or - 'physical'. + Name of target model. Must be ``'ashrae'``, ``'martin_ruiz'``, or + ``'physical'``. options : dict, optional A dictionary that allows passing a custom weight function and arguments to the (default or custom) weight function. Possible - keys are 'weight_function' and 'weight_args' + keys are ``'weight_function'`` and ``'weight_args'``. weight_function : function A function that outputs an array of weights to use when computing residuals between models. Requirements: - ------------- - 1. Must accept aoi as first argument. (aoi is a numpy - array, and it is handed to the function internally.) - 2. Any other arguments must be keyword arguments. (These - will be passed by the user in weight_args, see below.) - 3. Must return an array-like object with the same shape - as aoi. + 1. Must accept ``aoi`` as first argument. (``aoi`` is a + numpy array, and it is handed to the function + internally.) + 2. Any other arguments must be keyword arguments. (These + will be passed by the user in `weight_args`, see below.) + 3. Must return an array-like object with the same shape + as ``aoi``. weight_args : dict A dictionary containing all keyword arguments for the weight function. If using the default weight function, - the only keyword argument is max_angle. - - TODO there needs to be more information about the default - weight function, so people don't have to go digging through the - private functions. + the only keyword argument is ``max_angle``. - Default value of options is None (leaving as default will use - default weight function `pvlib.iam._truncated_weight`). - * TODO if name of default function changes * + Default value of `options` is None (leaving as default will use + default weight function `iam._truncated_weight`). Returns ------- dict Parameters for target model that best match the behavior of the - given source model. Key names are given in the table below. - (Note that the keys for the physical model are case-sensitive!) - - +--------------+----------+ - | target model | keys | - +==============+==========+ - | ashrae | b | - +--------------+----------+ - | martin_ruiz | a_r | - +--------------+----------+ - | physical | n, K, L | - +--------------+----------+ + given source model. + + If target model is ``'ashrae'``, the dictionary will contain + the key ``'b'``. + + If target model is ``'martin_ruiz'``, the dictionary will + contain the key ``'a_r'``. + + If target model is ``'physical'``, the dictionary will + contain the keys ``'n'``, ``'K'``, and ``'L'``. References ---------- @@ -1184,7 +1174,7 @@ def fit(measured_aoi, measured_iam, target_name, options=None): options : dict, optional A dictionary that allows passing a custom weight function and arguments to the (default or custom) weight function. Possible - keys are `weight_function` and `weight_args`. + keys are ``'weight_function'`` and ``'weight_args'``. weight_function : function A function that outputs an array of weights to use From de206292128022c5ce24d7e1e0b9a8505e296b8b Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 8 Aug 2023 16:28:35 -0500 Subject: [PATCH 12/65] Added more tests --- pvlib/tests/test_iam.py | 64 ++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index ec2bc84e6e..7260fd9ff9 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -397,20 +397,66 @@ def test_schlick_diffuse(): rtol=1e-6) +# --------------------------------------------------------------------- + + + def test_convert(): # expected value calculated from computing residual function over # a range of inputs, and taking minimum of these values - expected_a_r = 0.17589859805082217 - actual_params_dict = _iam.convert('physical', - {'n': 1.5, 'K': 4.5, 'L': 0.004}, - 'martin_ruiz') - actual_a_r = actual_params_dict['a_r'] - assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) + aoi = np.linspace(0, 90, 100) + + # convert physical to martin_ruiz + source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} + source_iam = _iam.physical(aoi, **source_params) + expected_min_res = 0.06203538904787109 + + actual_dict = _iam.convert('physical', source_params, 'martin_ruiz') + actual_params_list = [actual_dict[key] for key in actual_dict] + actual_min_res = _iam._residual(aoi, source_iam, _iam.martin_ruiz, + actual_params_list) + + assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) + + # convert physical to ashrae + source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} + source_iam = _iam.physical(aoi, **source_params) + expected_min_res = 0.4958798773107619 -# I may also need to write tests that use _ashrae_to_physical and -# _martin_ruiz_to_physical TODO + actual_dict = _iam.convert('physical', source_params, 'ashrae') + actual_params_list = [actual_dict[key] for key in actual_dict] + actual_min_res = _iam._residual(aoi, source_iam, _iam.ashrae, + actual_params_list) + + assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) + + + # convert ashrae to physical (tests _ashrae_to_physical) + source_params = {'b': 0.15} + source_iam = _iam.ashrae(aoi, **source_params) + expected_min_res = 0.0893334068539472 + + actual_dict = _iam.convert('ashrae', source_params, 'physical') + actual_params_list = [actual_dict[key] for key in actual_dict] + actual_min_res = _iam._residual(aoi, source_iam, _iam.physical, + actual_params_list) + + assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) + + + # convert martin_ruiz to physical (tests _martin_ruiz_to_physical) + source_params = {'a_r': 0.14} + source_iam = _iam.martin_ruiz(aoi, **source_params) + expected_min_res = 0.010777136524633524 + + actual_dict = _iam.convert('martin_ruiz', {'a_r': 0.14}, 'physical') + actual_params_list = [actual_dict[key] for key in actual_dict] + actual_min_res = _iam._residual(aoi, source_iam, _iam.physical, + actual_params_list) + + assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) def test_convert_custom_weight_func(): @@ -473,8 +519,6 @@ def test_fit(): assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) -# Testing different input types for fit? TODO - def test_fit_custom_weight_func(): # define custom weight function that takes in other arguments def scaled_weight(aoi, scalar): From 91f811e28924c312f888a92ff63fc042aaa3221b Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 8 Aug 2023 16:32:20 -0500 Subject: [PATCH 13/65] Annihilate spaces --- pvlib/tests/test_iam.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 7260fd9ff9..96d279b5a4 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -397,10 +397,6 @@ def test_schlick_diffuse(): rtol=1e-6) -# --------------------------------------------------------------------- - - - def test_convert(): # expected value calculated from computing residual function over # a range of inputs, and taking minimum of these values @@ -419,7 +415,6 @@ def test_convert(): assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) - # convert physical to ashrae source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} source_iam = _iam.physical(aoi, **source_params) @@ -432,7 +427,6 @@ def test_convert(): assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) - # convert ashrae to physical (tests _ashrae_to_physical) source_params = {'b': 0.15} source_iam = _iam.ashrae(aoi, **source_params) @@ -445,7 +439,6 @@ def test_convert(): assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) - # convert martin_ruiz to physical (tests _martin_ruiz_to_physical) source_params = {'a_r': 0.14} source_iam = _iam.martin_ruiz(aoi, **source_params) From e4d4cdc6c9001b2e19904f46187c825cde908e23 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Fri, 11 Aug 2023 13:33:37 -0500 Subject: [PATCH 14/65] Spacing --- pvlib/iam.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 2edfd64cd2..582ca6f958 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -907,9 +907,6 @@ def schlick_diffuse(surface_tilt): return cuk, cug -# ---------------------------------------------------------------- - - def _get_model(model_name): # check that model is implemented model_dict = {'ashrae': ashrae, 'martin_ruiz': martin_ruiz, From 3789890b8cfee9435b6490d40dfce605f3cd12a2 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 11:18:25 -0500 Subject: [PATCH 15/65] Changed default weight function --- pvlib/iam.py | 59 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 582ca6f958..bdfc311a10 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -933,12 +933,12 @@ def _check_params(model_name, params): was handed {', '.join(list(params.keys()))}") -def _truncated_weight(aoi, max_angle=70): - return [1 if angle <= max_angle else 0 for angle in aoi] +def _sin_weight(aoi): + return 1 - sind(aoi) def _residual(aoi, source_iam, target, target_params, - weight_function=_truncated_weight, + weight_function=_sin_weight, weight_args=None): # computes a sum of weighted differences between the source model # and target model, using the provided weight function @@ -948,9 +948,16 @@ def _residual(aoi, source_iam, target, target_params, weight = weight_function(aoi, **weight_args) + # if aoi contains values outside of interval (0, 90), annihilate + # the associated weights (we don't want IAM values from AOI outside + # of (0, 90) to affect the fit; this is a possible issue when using + # `iam.fit`, but not an issue when using `iam.convert`, since in + # that case aoi is defined internally) + weight = weight * np.logical_and(aoi >= 0 and aoi <= 90).astype(int) + # check that weight_function is behaving as expected if np.shape(aoi) != np.shape(weight): - assert weight_function != _truncated_weight + assert weight_function != _sin_weight raise ValueError('The provided custom weight function is not \ returning an object with the right shape. Please \ refer to the docstrings for a more detailed \ @@ -1063,8 +1070,14 @@ def convert(source_name, source_params, target_name, options=None): options : dict, optional A dictionary that allows passing a custom weight function and - arguments to the (default or custom) weight function. Possible - keys are ``'weight_function'`` and ``'weight_args'``. + arguments to the (default or custom) weight function. + + Default value of `options` is None. (Leaving as default will use + default weight function `iam._sin_weight`, which is the function + ``f(x) = 1 - sin(x)``.) + + Possible keys of `options` are ``'weight_function'`` and + ``'weight_args'``. weight_function : function A function that outputs an array of weights to use @@ -1073,7 +1086,7 @@ def convert(source_name, source_params, target_name, options=None): Requirements: 1. Must accept ``aoi`` as first argument. (``aoi`` is a numpy array, and it is handed to the function - internally.) + internally. Its elements range from 0 to 90.) 2. Any other arguments must be keyword arguments. (These will be passed by the user in `weight_args`, see below.) 3. Must return an array-like object with the same shape @@ -1081,11 +1094,9 @@ def convert(source_name, source_params, target_name, options=None): weight_args : dict A dictionary containing all keyword arguments for the - weight function. If using the default weight function, - the only keyword argument is ``max_angle``. + weight function. - Default value of `options` is None (leaving as default will use - default weight function `iam._truncated_weight`). + The default weight function has no additional arguments. Returns ------- @@ -1170,29 +1181,35 @@ def fit(measured_aoi, measured_iam, target_name, options=None): options : dict, optional A dictionary that allows passing a custom weight function and - arguments to the (default or custom) weight function. Possible - keys are ``'weight_function'`` and ``'weight_args'``. + arguments to the (default or custom) weight function. + + Default value of `options` is None. (Leaving as default will use + default weight function `iam._sin_weight`, which is the function + ``f(x) = 1 - sin(x)``.) + + Possible keys of `options` are ``'weight_function'`` and + ``'weight_args'``. weight_function : function A function that outputs an array of weights to use when computing residuals between models. Requirements: - 1. Must accept ``aoi`` as first argument. (``aoi`` is a - numpy array, and it is handed to the function - internally.) + 1. Must accept `measured_aoi` as first argument. 2. Any other arguments must be keyword arguments. (These will be passed by the user in `weight_args`, see below.) 3. Must return an array-like object with the same shape - as ``aoi``. + as `measured_aoi`. + + Note: when `measured_aoi` contains elements outside of the + closed interval [0, 90], the associated weights are overwritten + by zero, regardless of the weight function. weight_args : dict A dictionary containing all keyword arguments for the - weight function. If using the default weight function, - the only keyword argument is ``max_angle``. + weight function. - Default value of `options` is None (leaving as default will use - default weight function `iam._truncated_weight`). + The default weight function has no additional arguments. Returns ------- From 2efa83045039f7feffe612957629e49af1c1d173 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 11:25:31 -0500 Subject: [PATCH 16/65] Silence numpy warning --- pvlib/iam.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index bdfc311a10..dadc03b6c7 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1009,8 +1009,9 @@ def residual_function(target_params): def _minimize(residual_function, guess, bounds): - optimize_result = minimize(residual_function, guess, method="powell", - bounds=bounds) + with np.errstate(invalid='ignore'): + optimize_result = minimize(residual_function, guess, method="powell", + bounds=bounds) if not optimize_result.success: try: From c5c2d0943456d3a3d2fa95f8a7f3444fcd3eb446 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 13:44:38 -0500 Subject: [PATCH 17/65] Updating tests to work with new default --- pvlib/iam.py | 14 +++++++------- pvlib/tests/test_iam.py | 36 +++++++++++++++++++++--------------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index dadc03b6c7..ee26d6f4a6 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -948,13 +948,6 @@ def _residual(aoi, source_iam, target, target_params, weight = weight_function(aoi, **weight_args) - # if aoi contains values outside of interval (0, 90), annihilate - # the associated weights (we don't want IAM values from AOI outside - # of (0, 90) to affect the fit; this is a possible issue when using - # `iam.fit`, but not an issue when using `iam.convert`, since in - # that case aoi is defined internally) - weight = weight * np.logical_and(aoi >= 0 and aoi <= 90).astype(int) - # check that weight_function is behaving as expected if np.shape(aoi) != np.shape(weight): assert weight_function != _sin_weight @@ -963,6 +956,13 @@ def _residual(aoi, source_iam, target, target_params, refer to the docstrings for a more detailed \ discussion about passing custom weight functions.') + # if aoi contains values outside of interval (0, 90), annihilate + # the associated weights (we don't want IAM values from AOI outside + # of (0, 90) to affect the fit; this is a possible issue when using + # `iam.fit`, but not an issue when using `iam.convert`, since in + # that case aoi is defined internally) + weight = weight * np.logical_and(aoi >= 0, aoi <= 90).astype(int) + diff = np.abs(source_iam - np.nan_to_num(target(aoi, *target_params))) return np.sum(diff * weight) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 96d279b5a4..b3807ecb9e 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -406,7 +406,7 @@ def test_convert(): # convert physical to martin_ruiz source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} source_iam = _iam.physical(aoi, **source_params) - expected_min_res = 0.06203538904787109 + expected_min_res = 0.02193164055914381 actual_dict = _iam.convert('physical', source_params, 'martin_ruiz') actual_params_list = [actual_dict[key] for key in actual_dict] @@ -418,7 +418,7 @@ def test_convert(): # convert physical to ashrae source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} source_iam = _iam.physical(aoi, **source_params) - expected_min_res = 0.4958798773107619 + expected_min_res = 0.14929293811214253 actual_dict = _iam.convert('physical', source_params, 'ashrae') actual_params_list = [actual_dict[key] for key in actual_dict] @@ -430,7 +430,7 @@ def test_convert(): # convert ashrae to physical (tests _ashrae_to_physical) source_params = {'b': 0.15} source_iam = _iam.ashrae(aoi, **source_params) - expected_min_res = 0.0893334068539472 + expected_min_res = 0.024075681431174032 actual_dict = _iam.convert('ashrae', source_params, 'physical') actual_params_list = [actual_dict[key] for key in actual_dict] @@ -442,7 +442,7 @@ def test_convert(): # convert martin_ruiz to physical (tests _martin_ruiz_to_physical) source_params = {'a_r': 0.14} source_iam = _iam.martin_ruiz(aoi, **source_params) - expected_min_res = 0.010777136524633524 + expected_min_res = 0.004162175187057447 actual_dict = _iam.convert('martin_ruiz', {'a_r': 0.14}, 'physical') actual_params_list = [actual_dict[key] for key in actual_dict] @@ -453,21 +453,27 @@ def test_convert(): def test_convert_custom_weight_func(): + aoi = np.linspace(0, 90, 100) + + # convert physical to martin_ruiz, using custom weight function + source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} + source_iam = _iam.physical(aoi, **source_params) + # define custom weight function that takes in other arguments def scaled_weight(aoi, scalar): return scalar * aoi # expected value calculated from computing residual function over # a range of inputs, and taking minimum of these values - expected_a_r = 0.17478448342232694 + expected_min_res = 18.051468686279726 options = {'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} - actual_params_dict = _iam.convert('physical', - {'n': 1.5, 'K': 4.5, 'L': 0.004}, - 'martin_ruiz', options=options) - actual_a_r = actual_params_dict['a_r'] + actual_dict = _iam.convert('physical', source_params, 'martin_ruiz', + options=options) + actual_min_res = _iam._residual(aoi, source_iam, _iam.martin_ruiz, + [actual_dict['a_r']], **options) - assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) + assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) def test_convert_model_not_implemented(): @@ -506,8 +512,9 @@ def test_fit(): perturbed_iam = _iam.martin_ruiz(aoi, a_r=0.14) * perturb expected_a_r = 0.14 - actual_param_dict = _iam.fit(aoi, perturbed_iam, 'martin_ruiz') - actual_a_r = actual_param_dict['a_r'] + + actual_dict = _iam.fit(aoi, perturbed_iam, 'martin_ruiz') + actual_a_r = actual_dict['a_r'] assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) @@ -524,9 +531,8 @@ def scaled_weight(aoi, scalar): expected_a_r = 0.14 options = {'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} - actual_param_dict = _iam.fit(aoi, perturbed_iam, 'martin_ruiz', - options=options) - actual_a_r = actual_param_dict['a_r'] + actual_dict = _iam.fit(aoi, perturbed_iam, 'martin_ruiz', options=options) + actual_a_r = actual_dict['a_r'] assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) From af65bc0629ca94dab77c2eb537d2aecbc82de791 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 13:48:42 -0500 Subject: [PATCH 18/65] Forgot a comment --- pvlib/tests/test_iam.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index b3807ecb9e..1c08dc5dba 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -398,6 +398,10 @@ def test_schlick_diffuse(): def test_convert(): + # we'll compare residuals rather than coefficient values + # we only care about how close the fit of the conversion is, so the + # specific coefficients that get us there is less important + # expected value calculated from computing residual function over # a range of inputs, and taking minimum of these values From d505ac92a423c69bc665c38c31deedd02a22aa4e Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 14:56:40 -0500 Subject: [PATCH 19/65] Return dict contains scalars now, instead of arrays --- pvlib/iam.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index ee26d6f4a6..d034aa14a9 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1030,10 +1030,10 @@ def _minimize(residual_function, guess, bounds): def _process_return(target_name, optimize_result): if target_name == "ashrae": - target_params = {'b': optimize_result.x} + target_params = {'b': optimize_result.x.item()} elif target_name == "martin_ruiz": - target_params = {'a_r': optimize_result.x} + target_params = {'a_r': optimize_result.x.item()} elif target_name == "physical": L, n = optimize_result.x From 109e20e44b2dc3f436defa32c5f8d4fb5dba7a25 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 15:28:34 -0500 Subject: [PATCH 20/65] Adding option to not fix n --- pvlib/iam.py | 59 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index d034aa14a9..5e71d9b612 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -967,23 +967,31 @@ def _residual(aoi, source_iam, target, target_params, return np.sum(diff * weight) -def _ashrae_to_physical(aoi, ashrae_iam, options): - # the ashrae model has an x-intercept less than 90 - # we solve for this intercept, and choose n so that the physical - # model will have the same x-intercept - int_idx = np.argwhere(ashrae_iam == 0.0).flatten()[0] - intercept = aoi[int_idx] - n = sind(intercept) - - # with n fixed, we will optimize for L (recall that K and L always - # appear in the physical model as a product, so it is enough to - # optimize for just L, and to fix K=4) +def _ashrae_to_physical(aoi, ashrae_iam, options, fix_n): + if fix_n: + # the ashrae model has an x-intercept less than 90 + # we solve for this intercept, and fix n so that the physical + # model will have the same x-intercept + int_idx = np.argwhere(ashrae_iam == 0.0).flatten()[0] + intercept = aoi[int_idx] + n = sind(intercept) + + # with n fixed, we will optimize for L (recall that K and L always + # appear in the physical model as a product, so it is enough to + # optimize for just L, and to fix K=4) + + # we will pass n to the optimizer to simplify things later on, + # but because we are setting (n, n) as the bounds, the optimizer + # will leave n fixed + bounds = [(0, 0.08), (n, n)] + guess = [0.002, n] - # we will pass n to the optimizer to simplify things later on, - # but because we are setting (n, n) as the bounds, the optimizer - # will leave n fixed - bounds = [(0, 0.08), (n, n)] - guess = [0.002, n] + else: + # we don't fix n, so physical won't have same x-intercept as ashrae + # the fit will be worse, but the parameters returned for the physical + # model will be more realistic + bounds = [(0, 0.08), (1+1e-08, 2)] + guess = [0.002, 1+1e-08] def residual_function(target_params): L, n = target_params @@ -1042,7 +1050,7 @@ def _process_return(target_name, optimize_result): return target_params -def convert(source_name, source_params, target_name, options=None): +def convert(source_name, source_params, target_name, options=None, fix_n=None): """ Given a source model and its parameters and a target model, finds parameters for target model that best fit source model. @@ -1099,6 +1107,19 @@ def convert(source_name, source_params, target_name, options=None): The default weight function has no additional arguments. + fix_n : bool, optional + A flag to determine which method is used when converting from the + ASHRAE model to the physical model. The default value is None. + + When ``source_name`` is ``'ashrae'`` and ``target_name`` is + ``'physical'``, if ``fix_n`` is ``True`` or None, + :py:func:`iam.convert` will fix ``n`` so that the returned physical + model has the same x-intercept as the inputted ASHRAE model. + Fixing ``n`` like this improves the fit of the conversion, but often + returns unrealistic values for the parameters of the physical model. If + users want parameters that better match the real world, they should + set ``fix_n`` to False. + Returns ------- dict @@ -1141,8 +1162,10 @@ def convert(source_name, source_params, target_name, options=None): # we can do some special set-up to improve the fit when the # target model is physical if source_name == "ashrae": + if fix_n is None: + fix_n = True residual_function, guess, bounds = \ - _ashrae_to_physical(aoi, source_iam, options) + _ashrae_to_physical(aoi, source_iam, options, fix_n) elif source_name == "martin_ruiz": residual_function, guess, bounds = \ _martin_ruiz_to_physical(aoi, source_iam, options) From 554d862b175f56186c20d002921396acd35bb9a7 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 16:41:22 -0500 Subject: [PATCH 21/65] Adding straggler tests --- pvlib/tests/test_iam.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 1c08dc5dba..2a1f5780b4 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -443,6 +443,20 @@ def test_convert(): assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) + # convert ashrae to physical, without fixing n + source_params = {'b': 0.15} + source_iam = _iam.ashrae(aoi, **source_params) + expected_min_res = 0.06684694222023173 + + actual_dict = _iam.convert('ashrae', source_params, 'physical', fix_n=False) + actual_params_list = [actual_dict[key] for key in actual_dict] + actual_min_res = _iam._residual(aoi, source_iam, _iam.physical, + actual_params_list) + + # not sure why, but could never get convert residual and expected residual + # to be closer than 0.00025ish, hence why atol=1e-03 + assert np.isclose(expected_min_res, actual_min_res, atol=1e-03) + # convert martin_ruiz to physical (tests _martin_ruiz_to_physical) source_params = {'a_r': 0.14} source_iam = _iam.martin_ruiz(aoi, **source_params) @@ -564,3 +578,10 @@ def nan_weight(aoi): with pytest.raises(RuntimeError, match='Optimizer exited unsuccessfully'): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'physical', options={'weight_function': nan_weight}) + +def test__residual_zero_outside_range(): + # check that _residual annihilates any weights that come from aoi + # outside of interval [0, 90] (this is important for `iam.fit`, when + # the `measured_aoi` contains angles outside this range + residual = _iam._residual(101, _iam.ashrae(101), _iam.martin_ruiz, [0.16]) + assert residual == 0.0 From e8d83b69af6271bbc0e21c275760153aa9935da4 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 16:44:00 -0500 Subject: [PATCH 22/65] Removing examples specific to old default weight function --- .../reflections/plot_convert_iam_models.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 26c10e35de..33a47b291e 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -119,12 +119,6 @@ 'physical') physical_iam_default = physical(aoi, **physical_params_default) -# ... using default weight function with different max_angle -options = {'weight_args': {'max_angle': 50}} -physical_params_diff_max_angle = convert('martin_ruiz', martin_ruiz_params, - 'physical', options=options) -physical_iam_diff_max_angle = physical(aoi, **physical_params_diff_max_angle) - # ... using custom weight function options = {'weight_function': lambda aoi: cosd(aoi)} @@ -135,7 +129,6 @@ # plot aoi vs iam curve plt.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') plt.plot(aoi, physical_iam_default, label='Default weight function') -plt.plot(aoi, physical_iam_diff_max_angle, label='Different max angle') plt.plot(aoi, physical_iam_custom, label='Custom weight function') plt.xlabel('AOI (degrees)') plt.ylabel('IAM') @@ -157,12 +150,6 @@ ashrae_params_default = convert('martin_ruiz', martin_ruiz_params, 'ashrae') ashrae_iam_default = ashrae(aoi, **ashrae_params_default) -# ... using default weight function with different max_angle -options = {'weight_args': {'max_angle': 50}} -ashrae_params_diff_max_angle = convert('martin_ruiz', martin_ruiz_params, - 'ashrae', options=options) -ashrae_iam_diff_max_angle = ashrae(aoi, **ashrae_params_diff_max_angle) - # ... using custom weight function options = {'weight_function': lambda aoi: cosd(aoi)} @@ -173,7 +160,6 @@ # plot aoi vs iam curve plt.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') plt.plot(aoi, ashrae_iam_default, label='Default weight function') -plt.plot(aoi, ashrae_iam_diff_max_angle, label='Different max angle') plt.plot(aoi, ashrae_iam_custom, label='Custom weight function') plt.xlabel('AOI (degrees)') plt.ylabel('IAM') From ee9c68680a28084318da59b2f8591e9271ace4f9 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 16:47:47 -0500 Subject: [PATCH 23/65] Linter nitpicks --- pvlib/tests/test_iam.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 2a1f5780b4..a65f1778fa 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -448,7 +448,8 @@ def test_convert(): source_iam = _iam.ashrae(aoi, **source_params) expected_min_res = 0.06684694222023173 - actual_dict = _iam.convert('ashrae', source_params, 'physical', fix_n=False) + actual_dict = _iam.convert('ashrae', source_params, 'physical', + fix_n=False) actual_params_list = [actual_dict[key] for key in actual_dict] actual_min_res = _iam._residual(aoi, source_iam, _iam.physical, actual_params_list) @@ -579,6 +580,7 @@ def nan_weight(aoi): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'physical', options={'weight_function': nan_weight}) + def test__residual_zero_outside_range(): # check that _residual annihilates any weights that come from aoi # outside of interval [0, 90] (this is important for `iam.fit`, when From e95993d69e88c8996a473d3fac46a8fd29942df0 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 17:03:39 -0500 Subject: [PATCH 24/65] Update docstrings --- pvlib/iam.py | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 5e71d9b612..3d6b6cf6fb 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1079,12 +1079,9 @@ def convert(source_name, source_params, target_name, options=None, fix_n=None): options : dict, optional A dictionary that allows passing a custom weight function and - arguments to the (default or custom) weight function. - - Default value of `options` is None. (Leaving as default will use - default weight function `iam._sin_weight`, which is the function - ``f(x) = 1 - sin(x)``.) - + arguments to the (default or custom) weight function. Default value of + `options` is None. (Leaving as default will use default weight function + ``iam._sin_weight``, which is the function ``f(x) = 1 - sin(x)``.) Possible keys of `options` are ``'weight_function'`` and ``'weight_args'``. @@ -1103,22 +1100,21 @@ def convert(source_name, source_params, target_name, options=None, fix_n=None): weight_args : dict A dictionary containing all keyword arguments for the - weight function. - - The default weight function has no additional arguments. + weight function. The default weight function has no additional + arguments. fix_n : bool, optional A flag to determine which method is used when converting from the ASHRAE model to the physical model. The default value is None. - When ``source_name`` is ``'ashrae'`` and ``target_name`` is - ``'physical'``, if ``fix_n`` is ``True`` or None, + When `source_name` is ``'ashrae'`` and `target_name` is + ``'physical'``, if `fix_n` is ``True`` or None, :py:func:`iam.convert` will fix ``n`` so that the returned physical model has the same x-intercept as the inputted ASHRAE model. Fixing ``n`` like this improves the fit of the conversion, but often returns unrealistic values for the parameters of the physical model. If users want parameters that better match the real world, they should - set ``fix_n`` to False. + set `fix_n` to False. Returns ------- @@ -1205,12 +1201,9 @@ def fit(measured_aoi, measured_iam, target_name, options=None): options : dict, optional A dictionary that allows passing a custom weight function and - arguments to the (default or custom) weight function. - - Default value of `options` is None. (Leaving as default will use - default weight function `iam._sin_weight`, which is the function - ``f(x) = 1 - sin(x)``.) - + arguments to the (default or custom) weight function. Default value of + `options` is None. (Leaving as default will use default weight function + ``iam._sin_weight``, which is the function ``f(x) = 1 - sin(x)``.) Possible keys of `options` are ``'weight_function'`` and ``'weight_args'``. @@ -1231,9 +1224,8 @@ def fit(measured_aoi, measured_iam, target_name, options=None): weight_args : dict A dictionary containing all keyword arguments for the - weight function. - - The default weight function has no additional arguments. + weight function. The default weight function has no additional + arguments. Returns ------- From 991e962a0e03cad8e0c00bf11dba9145de69637a Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 17:15:01 -0500 Subject: [PATCH 25/65] Experimenting with example --- .../reflections/plot_convert_iam_models.py | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 33a47b291e..8c8e475627 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -35,7 +35,7 @@ ashrae_params = convert('martin_ruiz', martin_ruiz_params, 'ashrae') ashrae_iam = ashrae(aoi, **ashrae_params) -fig, (ax1, ax2) = plt.subplots(1, 2, sharey=True) +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(4,4), sharey=True) # plot aoi vs iam curves ax1.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') @@ -88,13 +88,13 @@ # Options for the weight function # ------------------------------- # -# When converting between the various IAM models implemented in pvlib, -# :py:func:`pvlib.iam.convert` allows us to pass in a custom weight -# function. This function is used when computing the residuals between -# the original (source) model and the target model. In some cases, the choice -# of weight function has a minimal effect on the outputted parameters for the -# target model. This is especially true when there is a choice of parameters -# for the target model that matches the source model very well. +# Both :py:func:`pvlib.iam.convert` and :py:func:`pvlib.iam.fit` allow us to +# pass in a custom weight function. These functions are used when computing +# residuals between the original (source or measured) model and the target +# model. In some cases, the choice of weight function has a minimal effect +# on the behavior of the returned target model. This is especially true when +# there is a choice of parameters for the target model that matches the source +# model very well. # # However, in cases where this fit is not as strong, our choice of weight # function can have a large impact on what parameters are returned for the @@ -103,15 +103,13 @@ # # Here we'll show examples of both of these cases, starting with an example # where the choice of weight function does not have much impact. In doing -# so, we'll also show how to pass arguments to the default weight function, -# as well as pass in a custom weight function of our choice. +# so, we'll show how to pass in a custom weight function of our choice. # compute martin_ruiz iam for given parameter aoi = np.linspace(0, 90, 100) martin_ruiz_params = {'a_r': 0.16} martin_ruiz_iam = martin_ruiz(aoi, **martin_ruiz_params) - # get parameters for physical models ... # ... using default weight function @@ -169,22 +167,6 @@ # %% # In this case, each of these outputted target models looks quite different. -# -# The default setup focuses on matching IAM from 0 to 70 degrees, but because -# this Martin-Ruiz model is not very compatible with the Ashrae model, we -# sacrifice matching the curve tightly starting at 15 degrees, in order to -# minimize the residuals close to 70 degrees. -# -# When we changed the parameters we passed to the default weight function, we -# told it to focus on matching IAM from 0 to 50 degrees instead. The outputted -# model is a tight fit for AOI up to about 50, but it does not match the -# Martin-Ruiz model at all after this. -# -# The custom weight function cares (to varying degrees) about the entire range -# of AOI, from 0 to 90. For this reason, we see that it is not a tight fit -# anywhere except for small AOI, as it is attempting to minimize the residuals -# across the entire curve. -# # Finding the right weight function and parameters in such cases will require # knowing where you want the target model to be more accurate, and will likely # require some experimentation. From 484cb5a3c8ebde5a4489b2f9c46769c666331245 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Tue, 22 Aug 2023 17:43:01 -0500 Subject: [PATCH 26/65] Adjusting figure size --- docs/examples/reflections/plot_convert_iam_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 8c8e475627..e4fb978da8 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -35,7 +35,7 @@ ashrae_params = convert('martin_ruiz', martin_ruiz_params, 'ashrae') ashrae_iam = ashrae(aoi, **ashrae_params) -fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(4,4), sharey=True) +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4), sharey=True) # plot aoi vs iam curves ax1.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') From 47ebdacd6e4630017139ee1fb834e790f853f441 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Wed, 23 Aug 2023 10:52:40 -0500 Subject: [PATCH 27/65] Edit gallery example --- .../reflections/plot_convert_iam_models.py | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index e4fb978da8..8d0f4400c9 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -20,7 +20,7 @@ # ------------------------------------ # # Here we'll show how to convert from the Martin-Ruiz model to both the -# Physical and Ashrae models. +# physical and ASHRAE models. # compute martin_ruiz iam for given parameter aoi = np.linspace(0, 90, 100) @@ -39,15 +39,15 @@ # plot aoi vs iam curves ax1.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') -ax1.plot(aoi, physical_iam, label='Physical') +ax1.plot(aoi, physical_iam, label='physical') ax1.set_xlabel('AOI (degrees)') -ax1.set_title('Martin-Ruiz to Physical') +ax1.set_title('Martin-Ruiz to physical') ax1.legend() ax2.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') -ax2.plot(aoi, ashrae_iam, label='Ashrae') +ax2.plot(aoi, ashrae_iam, label='ASHRAE') ax2.set_xlabel('AOI (degrees)') -ax2.set_title('Martin-Ruiz to Ashrae') +ax2.set_title('Martin-Ruiz to ASHRAE') ax2.legend() ax1.set_ylabel('IAM') @@ -60,7 +60,7 @@ # # Here, we'll show how to fit measured data to a model. In this case, # we'll use perturbed output from the Martin-Ruiz model to mimic -# measured data and then we'll fit this to the Physical model. +# measured data and then we'll fit this to the physical model. # create perturbed iam data aoi = np.linspace(0, 90, 100) @@ -76,26 +76,32 @@ # plot aoi vs iam curve plt.scatter(aoi, data, c='darkorange', label='Perturbed data') -plt.plot(aoi, physical_iam, label='Physical') +plt.plot(aoi, physical_iam, label='physical') plt.xlabel('AOI (degrees)') plt.ylabel('IAM') -plt.title('Fitting data to Physical model') +plt.title('Fitting data to physical model') plt.legend() plt.show() # %% -# Options for the weight function -# ------------------------------- +# The weight function +# ------------------- +# Both :py:func:`pvlib.iam.convert` and :py:func:`pvlib.iam.fit` use +# a weight function when computing residuals between the original (source or +# measured) model and the target model. The default option for this weight +# function is $1 - \sin(x)$. The reasons for this choice of default are given +# in TODO. # -# Both :py:func:`pvlib.iam.convert` and :py:func:`pvlib.iam.fit` allow us to -# pass in a custom weight function. These functions are used when computing -# residuals between the original (source or measured) model and the target -# model. In some cases, the choice of weight function has a minimal effect -# on the behavior of the returned target model. This is especially true when -# there is a choice of parameters for the target model that matches the source -# model very well. +# We can also choose to pass in a custom weight function, instead. # +# ### Options for the weight function +# +# In some cases, the choice of weight function has a minimal effect on the +# behavior of the returned target model. This is especially true when there is +# a choice of parameters for the target model that matches the source model +# very well. + # However, in cases where this fit is not as strong, our choice of weight # function can have a large impact on what parameters are returned for the # target function. What weight function we choose in these cases will depend on @@ -130,7 +136,7 @@ plt.plot(aoi, physical_iam_custom, label='Custom weight function') plt.xlabel('AOI (degrees)') plt.ylabel('IAM') -plt.title('Martin-Ruiz to Physical') +plt.title('Martin-Ruiz to physical') plt.legend() plt.show() @@ -161,7 +167,7 @@ plt.plot(aoi, ashrae_iam_custom, label='Custom weight function') plt.xlabel('AOI (degrees)') plt.ylabel('IAM') -plt.title('Martin-Ruiz to Ashrae') +plt.title('Martin-Ruiz to ASHRAE') plt.legend() plt.show() @@ -171,9 +177,3 @@ # knowing where you want the target model to be more accurate, and will likely # require some experimentation. - -# %% -# The default weight function -# --------------------------- -# -# TODO From 317fb35bdc57e8ef57707c221dc74548e95370e1 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Wed, 23 Aug 2023 11:25:44 -0500 Subject: [PATCH 28/65] Fixing bounds --- pvlib/iam.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 3d6b6cf6fb..5f15d8a5ab 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -990,7 +990,7 @@ def _ashrae_to_physical(aoi, ashrae_iam, options, fix_n): # we don't fix n, so physical won't have same x-intercept as ashrae # the fit will be worse, but the parameters returned for the physical # model will be more realistic - bounds = [(0, 0.08), (1+1e-08, 2)] + bounds = [(0, 0.08), (1, 2)] guess = [0.002, 1+1e-08] def residual_function(target_params): @@ -1004,7 +1004,7 @@ def _martin_ruiz_to_physical(aoi, martin_ruiz_iam, options): # we will optimize for both n and L (recall that K and L always # appear in the physical model as a product, so it is enough to # optimize for just L, and to fix K=4) - bounds = [(0, 0.08), (1+1e-08, 2)] + bounds = [(0, 0.08), (1, 2)] guess = [0.002, 1+1e-08] # the product of K and L is more important in determining an initial @@ -1169,7 +1169,7 @@ def convert(source_name, source_params, target_name, options=None, fix_n=None): else: # otherwise, target model is ashrae or martin_ruiz, and scipy # does fine without any special set-up - bounds = [(0, 1)] + bounds = [(1e-08, 1)] guess = [1e-08] def residual_function(target_param): @@ -1261,7 +1261,7 @@ def fit(measured_aoi, measured_iam, target_name, options=None): options = {} if target_name == "physical": - bounds = [(0, 0.08), (1+1e-08, 2)] + bounds = [(0, 0.08), (1, 2)] guess = [0.002, 1+1e-08] def residual_function(target_params): @@ -1271,7 +1271,7 @@ def residual_function(target_params): # otherwise, target_name is martin_ruiz or ashrae else: - bounds = [(0, 1)] + bounds = [(1e-08, 1)] guess = [1e-08] def residual_function(target_param): From 3996cab14a4a7854b3c876ee09afc009c17d4ec2 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Wed, 23 Aug 2023 11:41:39 -0500 Subject: [PATCH 29/65] Linter --- docs/examples/reflections/plot_convert_iam_models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 8d0f4400c9..ff6f4fd70b 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -176,4 +176,3 @@ # Finding the right weight function and parameters in such cases will require # knowing where you want the target model to be more accurate, and will likely # require some experimentation. - From ba87f7e642310b23afca98ce47736c611b4371c2 Mon Sep 17 00:00:00 2001 From: Abigail Jones Date: Wed, 23 Aug 2023 11:48:24 -0500 Subject: [PATCH 30/65] Example experimentation --- docs/examples/reflections/plot_convert_iam_models.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index ff6f4fd70b..669d1288f9 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -35,7 +35,7 @@ ashrae_params = convert('martin_ruiz', martin_ruiz_params, 'ashrae') ashrae_iam = ashrae(aoi, **ashrae_params) -fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4), sharey=True) +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 5), sharey=True) # plot aoi vs iam curves ax1.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') @@ -95,13 +95,11 @@ # # We can also choose to pass in a custom weight function, instead. # -# ### Options for the weight function -# # In some cases, the choice of weight function has a minimal effect on the # behavior of the returned target model. This is especially true when there is # a choice of parameters for the target model that matches the source model # very well. - +# # However, in cases where this fit is not as strong, our choice of weight # function can have a large impact on what parameters are returned for the # target function. What weight function we choose in these cases will depend on From 529e512c00c2d724ac42bc411606f50c39bd7712 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Fri, 1 Sep 2023 08:50:11 -0600 Subject: [PATCH 31/65] exact ashrae intercept --- pvlib/iam.py | 13 +++++++++---- pvlib/tests/test_iam.py | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 90a1c7ae78..e1654778a7 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1003,13 +1003,17 @@ def _residual(aoi, source_iam, target, target_params, return np.sum(diff * weight) -def _ashrae_to_physical(aoi, ashrae_iam, options, fix_n): +def _get_ashrae_int(b): + # find x-intercept of ashrae model + return np.rad2deg(np.arccos(b / (1 + b))) + + +def _ashrae_to_physical(aoi, ashrae_iam, options, fix_n, b): if fix_n: # the ashrae model has an x-intercept less than 90 # we solve for this intercept, and fix n so that the physical # model will have the same x-intercept - int_idx = np.argwhere(ashrae_iam == 0.0).flatten()[0] - intercept = aoi[int_idx] + intercept = _get_ashrae_int(b) n = sind(intercept) # with n fixed, we will optimize for L (recall that K and L always @@ -1197,7 +1201,8 @@ def convert(source_name, source_params, target_name, options=None, fix_n=None): if fix_n is None: fix_n = True residual_function, guess, bounds = \ - _ashrae_to_physical(aoi, source_iam, options, fix_n) + _ashrae_to_physical(aoi, source_iam, options, fix_n, + source_params['b']) elif source_name == "martin_ruiz": residual_function, guess, bounds = \ _martin_ruiz_to_physical(aoi, source_iam, options) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index a65f1778fa..74e12dc832 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -434,7 +434,7 @@ def test_convert(): # convert ashrae to physical (tests _ashrae_to_physical) source_params = {'b': 0.15} source_iam = _iam.ashrae(aoi, **source_params) - expected_min_res = 0.024075681431174032 + expected_min_res = 0.0216 actual_dict = _iam.convert('ashrae', source_params, 'physical') actual_params_list = [actual_dict[key] for key in actual_dict] From 3fc2c007ecea5677bebd0ebc43780d54575c16d9 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 12 Sep 2023 08:52:44 -0600 Subject: [PATCH 32/65] editing docstrings mostly --- .../reflections/plot_convert_iam_models.py | 117 ++++++++++-------- pvlib/iam.py | 104 ++++++---------- 2 files changed, 105 insertions(+), 116 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 669d1288f9..cad7ab7225 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -1,12 +1,29 @@ """ -IAM Model Conversion -==================== +IAM Model Conversion and Fitting +================================ + +Illustrates how to convert from one IAM model to a different model using +pvlib.iam.convert, and how to fit an IAM model to data using +pvlib.iam.fit """ # %% -# Introductory text blurb (TODO) +# An incidence angle modifier (IAM) model quantifies the fraction of direct +# irradiance is that is reflected away from a module's surface. Three popular +# IAM models are Martin-Ruiz py:func:`~pvlib.iam.martin_ruiz`, physical +# py:func:`~pvlib.iam.physical`, and ASHRAE `py:func:~pvlib.iam.ashrae`. +# Each model requires one or more parameters. +# +# Here, we show how to use +# py:func:`~pvlib.iam.convert` to estimate parameters for a desired target +# IAM model from a source IAM model. We also show how to use +# py:func:`~pvlib.iam.fit` to estimate a model's parameters from data. +# +# Model conversion and model fitting require a weight function that assigns +# more influence to some AOI values than others. We illustrate how to provide +# a custom weight function to py:func:`~pvlib.iam.convert`. import numpy as np from random import uniform @@ -16,38 +33,38 @@ from pvlib.iam import (ashrae, martin_ruiz, physical, convert, fit) # %% -# Converting from one model to another -# ------------------------------------ +# Converting from one IAM model to another model +# ---------------------------------------------- # -# Here we'll show how to convert from the Martin-Ruiz model to both the -# physical and ASHRAE models. +# Here we'll show how to convert from the Martin-Ruiz model to the +# physical and the ASHRAE models. -# compute martin_ruiz iam for given parameter +# Compute IAM values using the martin_ruiz model. aoi = np.linspace(0, 90, 100) martin_ruiz_params = {'a_r': 0.16} martin_ruiz_iam = martin_ruiz(aoi, **martin_ruiz_params) -# get parameters for physical model, compute physical iam +# Get parameters for the physical model and compute IAM using these parameters. physical_params = convert('martin_ruiz', martin_ruiz_params, 'physical') physical_iam = physical(aoi, **physical_params) -# get parameters for ashrae model, compute ashrae iam +# Get parameters for the ASHRAE model and compute IAm using these parameters. ashrae_params = convert('martin_ruiz', martin_ruiz_params, 'ashrae') ashrae_iam = ashrae(aoi, **ashrae_params) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 5), sharey=True) -# plot aoi vs iam curves +# Plot each model's IAM vs. angle-of-incidence (AOI). ax1.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') ax1.plot(aoi, physical_iam, label='physical') ax1.set_xlabel('AOI (degrees)') -ax1.set_title('Martin-Ruiz to physical') +ax1.set_title('Convert from Martin-Ruiz to physical') ax1.legend() ax2.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') ax2.plot(aoi, ashrae_iam, label='ASHRAE') ax2.set_xlabel('AOI (degrees)') -ax2.set_title('Martin-Ruiz to ASHRAE') +ax2.set_title('Convert from Martin-Ruiz to ASHRAE') ax2.legend() ax1.set_ylabel('IAM') @@ -55,31 +72,31 @@ # %% -# Fitting measured data to a model -# -------------------------------- +# Fitting an IAM model to data +# ---------------------------- # -# Here, we'll show how to fit measured data to a model. In this case, -# we'll use perturbed output from the Martin-Ruiz model to mimic -# measured data and then we'll fit this to the physical model. +# Here, we'll show how to fit an IAM model to data. +# We'll generate some data by perturbing output from the Martin-Ruiz model to mimic +# measured data and then we'll fit the physical model to the perturbed data. -# create perturbed iam data +# Create and perturb IAM data. aoi = np.linspace(0, 90, 100) params = {'a_r': 0.16} iam = martin_ruiz(aoi, **params) data = iam * np.array([uniform(0.98, 1.02) for _ in range(len(iam))]) -# get parameters for physical model that fits this data +# Get parameters for the physical model by fitting to the perturbed data. physical_params = fit(aoi, data, 'physical') -# compute physical iam +# Compute IAM with the fitted physical model parameters. physical_iam = physical(aoi, **physical_params) -# plot aoi vs iam curve -plt.scatter(aoi, data, c='darkorange', label='Perturbed data') +# Plot IAM vs. AOI +plt.scatter(aoi, data, c='darkorange', label='Data') plt.plot(aoi, physical_iam, label='physical') plt.xlabel('AOI (degrees)') plt.ylabel('IAM') -plt.title('Fitting data to physical model') +plt.title('Fitting the physical model to data') plt.legend() plt.show() @@ -88,47 +105,42 @@ # The weight function # ------------------- # Both :py:func:`pvlib.iam.convert` and :py:func:`pvlib.iam.fit` use -# a weight function when computing residuals between the original (source or -# measured) model and the target model. The default option for this weight -# function is $1 - \sin(x)$. The reasons for this choice of default are given -# in TODO. -# -# We can also choose to pass in a custom weight function, instead. +# a weight function when computing residuals between the two models, or +# between a model and data. The default weight +# function is $1 - \sin(aoi)$. We can instead pass a custom weight function +# to either :py:func:`pvlib.iam.convert` and :py:func:`pvlib.iam.fit`. # # In some cases, the choice of weight function has a minimal effect on the -# behavior of the returned target model. This is especially true when there is -# a choice of parameters for the target model that matches the source model -# very well. -# -# However, in cases where this fit is not as strong, our choice of weight -# function can have a large impact on what parameters are returned for the -# target function. What weight function we choose in these cases will depend on -# how we intend to use the target model. +# returned model parameters. This is especially true when converting between +# the Martin-Ruize and physical models, because the curves described by these models +# can match quite closely. However, when conversion involves the ASHRAE model, the choice of weight +# function can have a meaningful effect on the returned parameters for the +# target model. # # Here we'll show examples of both of these cases, starting with an example # where the choice of weight function does not have much impact. In doing # so, we'll show how to pass in a custom weight function of our choice. -# compute martin_ruiz iam for given parameter +# Compute IAM using the Martin-Ruiz model. aoi = np.linspace(0, 90, 100) martin_ruiz_params = {'a_r': 0.16} martin_ruiz_iam = martin_ruiz(aoi, **martin_ruiz_params) -# get parameters for physical models ... +# Get parameters for the physical model ... -# ... using default weight function +# ... using the default weight function. physical_params_default = convert('martin_ruiz', martin_ruiz_params, 'physical') physical_iam_default = physical(aoi, **physical_params_default) -# ... using custom weight function +# ... using a custom weight function. options = {'weight_function': lambda aoi: cosd(aoi)} physical_params_custom = convert('martin_ruiz', martin_ruiz_params, 'physical', options=options) physical_iam_custom = physical(aoi, **physical_params_custom) -# plot aoi vs iam curve +# Plot IAM vs AOI. plt.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') plt.plot(aoi, physical_iam_default, label='Default weight function') plt.plot(aoi, physical_iam_custom, label='Custom weight function') @@ -140,26 +152,25 @@ # %% # For this choice of source and target models, the weight function has little -# to no effect on the target model's outputted parameters. In this case, it -# is reasonable to use the default weight function with its default arguments. +# effect on the target model's parameters. # # Now we'll look at an example where the weight function does affect the # output. -# get parameters for ashrae models ... +# Get parameters for the ASHRAE model ... -# ... using default weight function +# ... using the default weight function. ashrae_params_default = convert('martin_ruiz', martin_ruiz_params, 'ashrae') ashrae_iam_default = ashrae(aoi, **ashrae_params_default) -# ... using custom weight function +# ... using a custom weight function. options = {'weight_function': lambda aoi: cosd(aoi)} ashrae_params_custom = convert('martin_ruiz', martin_ruiz_params, 'ashrae', options=options) ashrae_iam_custom = ashrae(aoi, **ashrae_params_custom) -# plot aoi vs iam curve +# Plot IAM vs AOI. plt.plot(aoi, martin_ruiz_iam, label='Martin-Ruiz') plt.plot(aoi, ashrae_iam_default, label='Default weight function') plt.plot(aoi, ashrae_iam_custom, label='Custom weight function') @@ -170,7 +181,9 @@ plt.show() # %% -# In this case, each of these outputted target models looks quite different. +# In this case, each of the two ASHRAE looks quite different. # Finding the right weight function and parameters in such cases will require -# knowing where you want the target model to be more accurate, and will likely -# require some experimentation. +# knowing where you want the target model to be more accurate. The default +# weight function was chosen because it yielded IAM models that produce +# similar annual insolation for a simulated PV system TODO add reference. + diff --git a/pvlib/iam.py b/pvlib/iam.py index 4eeba085ba..e65f87531b 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -857,7 +857,7 @@ def schlick_diffuse(surface_tilt): Unlike the Fresnel reflection factor itself, Schlick's approximation can be integrated analytically to derive a closed-form equation for diffuse IAM factors for the portions of the sky and ground visible - from a tilted surface if isotropic distributions are assumed. + from a tilted surface if isotropic distributions are assumed. This function implements the integration of the Schlick approximation provided by Xie et al. [2]_. @@ -959,13 +959,10 @@ def _get_model(model_name): def _check_params(model_name, params): # check that the parameters passed in with the model # belong to the model - param_dict = {'ashrae': {'b'}, 'martin_ruiz': {'a_r'}, - 'physical': {'n', 'K', 'L'}} - expected_params = param_dict[model_name] - - if set(params.keys()) != expected_params: + exp_params = _IAM_MODEL_PARAMS[model_name] + if set(params.keys()) != exp_params: raise ValueError(f"The {model_name} model was expecting to be passed \ - {', '.join(list(param_dict[model_name]))}, but \ + {', '.join(list(exp_params))}, but \ was handed {', '.join(list(params.keys()))}") @@ -984,14 +981,6 @@ def _residual(aoi, source_iam, target, target_params, weight = weight_function(aoi, **weight_args) - # check that weight_function is behaving as expected - if np.shape(aoi) != np.shape(weight): - assert weight_function != _sin_weight - raise ValueError('The provided custom weight function is not \ - returning an object with the right shape. Please \ - refer to the docstrings for a more detailed \ - discussion about passing custom weight functions.') - # if aoi contains values outside of interval (0, 90), annihilate # the associated weights (we don't want IAM values from AOI outside # of (0, 90) to affect the fit; this is a possible issue when using @@ -1090,7 +1079,7 @@ def _process_return(target_name, optimize_result): return target_params -def convert(source_name, source_params, target_name, options=None, fix_n=None): +def convert(source_name, source_params, target_name, options=None, fix_n=True): """ Given a source model and its parameters and a target model, finds parameters for target model that best fit source model. @@ -1119,9 +1108,7 @@ def convert(source_name, source_params, target_name, options=None, fix_n=None): options : dict, optional A dictionary that allows passing a custom weight function and - arguments to the (default or custom) weight function. Default value of - `options` is None. (Leaving as default will use default weight function - ``iam._sin_weight``, which is the function ``f(x) = 1 - sin(x)``.) + arguments for the weight function. Possible keys of `options` are ``'weight_function'`` and ``'weight_args'``. @@ -1130,37 +1117,33 @@ def convert(source_name, source_params, target_name, options=None, fix_n=None): when computing residuals between models. Requirements: - 1. Must accept ``aoi`` as first argument. (``aoi`` is a - numpy array, and it is handed to the function - internally. Its elements range from 0 to 90.) - 2. Any other arguments must be keyword arguments. (These - will be passed by the user in `weight_args`, see below.) - 3. Must return an array-like object with the same shape - as ``aoi``. - - weight_args : dict - A dictionary containing all keyword arguments for the - weight function. The default weight function has no additional - arguments. - - fix_n : bool, optional + 1. Must have ``aoi`` as the first positional argument. + 2. Any other arguments must be keyword arguments, + defined in `weight_args`. + 3. Must return a float or an array-like object with the + same shape as ``aoi``. + + weight_args : dict, optional + A dictionary containing keyword arguments for the + weight function. + + fix_n : bool, default True A flag to determine which method is used when converting from the - ASHRAE model to the physical model. The default value is None. + ASHRAE model to the physical model. When `source_name` is ``'ashrae'`` and `target_name` is - ``'physical'``, if `fix_n` is ``True`` or None, + ``'physical'``, if `fix_n` is ``True``, :py:func:`iam.convert` will fix ``n`` so that the returned physical model has the same x-intercept as the inputted ASHRAE model. Fixing ``n`` like this improves the fit of the conversion, but often - returns unrealistic values for the parameters of the physical model. If - users want parameters that better match the real world, they should + returns unrealistic values for the parameters of the physical model. + If more physically meaningful parameters are wanted, set `fix_n` to False. Returns ------- dict - Parameters for target model that best match the behavior of the - given source model. + Parameters for the target model. If target model is ``'ashrae'``, the dictionary will contain the key ``'b'``. @@ -1171,6 +1154,10 @@ def convert(source_name, source_params, target_name, options=None, fix_n=None): If target model is ``'physical'``, the dictionary will contain the keys ``'n'``, ``'K'``, and ``'L'``. + Notes + ----- + The default weight function is ``f(aoi) = 1 - sin(aoi)``. + References ---------- .. [1] TODO @@ -1198,8 +1185,6 @@ def convert(source_name, source_params, target_name, options=None, fix_n=None): # we can do some special set-up to improve the fit when the # target model is physical if source_name == "ashrae": - if fix_n is None: - fix_n = True residual_function, guess, bounds = \ _ashrae_to_physical(aoi, source_iam, options, fix_n, source_params['b']) @@ -1223,18 +1208,17 @@ def residual_function(target_param): def fit(measured_aoi, measured_iam, target_name, options=None): """ - Given measured angle of incidence values and measured IAM data and - a target model, finds parameters for target model that best fit the + Finds parameters for target model that best fit the measured data. Parameters ---------- measured_aoi : array-like - Array of angle of incidence values associated with the - measured IAM data. + Angle of incidence values associated with the + measured IAM values. measured_iam : array-like - Array of measured IAM values. + IAM values. target_name : str Name of target model. Must be ``'ashrae'``, ``'martin_ruiz'``, @@ -1242,9 +1226,7 @@ def fit(measured_aoi, measured_iam, target_name, options=None): options : dict, optional A dictionary that allows passing a custom weight function and - arguments to the (default or custom) weight function. Default value of - `options` is None. (Leaving as default will use default weight function - ``iam._sin_weight``, which is the function ``f(x) = 1 - sin(x)``.) + arguments for the weight function. Possible keys of `options` are ``'weight_function'`` and ``'weight_args'``. @@ -1253,26 +1235,20 @@ def fit(measured_aoi, measured_iam, target_name, options=None): when computing residuals between models. Requirements: - 1. Must accept `measured_aoi` as first argument. - 2. Any other arguments must be keyword arguments. (These - will be passed by the user in `weight_args`, see below.) - 3. Must return an array-like object with the same shape - as `measured_aoi`. - - Note: when `measured_aoi` contains elements outside of the - closed interval [0, 90], the associated weights are overwritten - by zero, regardless of the weight function. + 1. Must have ``aoi`` as the first positional argument. + 2. Any other arguments must be keyword arguments, + defined in `weight_args`. + 3. Must return a float or an array-like object with the + same shape as ``aoi``. - weight_args : dict - A dictionary containing all keyword arguments for the - weight function. The default weight function has no additional - arguments. + weight_args : dict, optional + A dictionary containing keyword arguments for the + weight function. Returns ------- dict - Parameters for target model that best match the behavior of the - given data. + Parameters for target model. If target model is ``'ashrae'``, the dictionary will contain the key ``'b'``. From a9f9b74725d201d19b6fde1cf304325fe833779e Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 12 Sep 2023 08:56:22 -0600 Subject: [PATCH 33/65] whatsnew --- docs/sphinx/source/whatsnew/v0.10.2.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.10.2.rst b/docs/sphinx/source/whatsnew/v0.10.2.rst index 6288b077fc..864e91863d 100644 --- a/docs/sphinx/source/whatsnew/v0.10.2.rst +++ b/docs/sphinx/source/whatsnew/v0.10.2.rst @@ -22,6 +22,8 @@ Enhancements * Added :py:func:`~pvlib.iam.interp` option as AOI losses model in :py:class:`pvlib.modelchain.ModelChain` and :py:class:`pvlib.pvsystem.PVSystem`. (:issue:`1742`, :pull:`1832`) +* Added :py:func:`~pvlib.iam.convert` and :py:func:`~pvlib.iam.fit` that + convert between IAM models, and that fit an IAM model to data. (:issue:`1824`, :pull:`1827`) Bug fixes ~~~~~~~~~ @@ -64,3 +66,5 @@ Contributors * Anton Driesse (:ghuser:`adriesse`) * Lukas Grossar (:ghuser:`tongpu`) * Areeba Turabi (:ghuser:`aturabi`) +* Cliff Hansen (:ghuser:`cwhanse`) + From ac160b8847df7ec69fae01e96306c2a86a596204 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 12 Sep 2023 09:08:28 -0600 Subject: [PATCH 34/65] fix errors --- .../examples/reflections/plot_convert_iam_models.py | 13 ++++++------- pvlib/tests/test_iam.py | 9 --------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index cad7ab7225..2fe9f01eeb 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -12,7 +12,7 @@ # %% # An incidence angle modifier (IAM) model quantifies the fraction of direct # irradiance is that is reflected away from a module's surface. Three popular -# IAM models are Martin-Ruiz py:func:`~pvlib.iam.martin_ruiz`, physical +# IAM models are Martin-Ruiz py:func:`~pvlib.iam.martin_ruiz`, physical # py:func:`~pvlib.iam.physical`, and ASHRAE `py:func:~pvlib.iam.ashrae`. # Each model requires one or more parameters. # @@ -75,7 +75,7 @@ # Fitting an IAM model to data # ---------------------------- # -# Here, we'll show how to fit an IAM model to data. +# Here, we'll show how to fit an IAM model to data. # We'll generate some data by perturbing output from the Martin-Ruiz model to mimic # measured data and then we'll fit the physical model to the perturbed data. @@ -112,10 +112,10 @@ # # In some cases, the choice of weight function has a minimal effect on the # returned model parameters. This is especially true when converting between -# the Martin-Ruize and physical models, because the curves described by these models -# can match quite closely. However, when conversion involves the ASHRAE model, the choice of weight -# function can have a meaningful effect on the returned parameters for the -# target model. +# the Martin-Ruize and physical models, because the curves described by these +# models can match quite closely. However, when conversion involves the ASHRAE +# model, the choice of weight function can have a meaningful effect on the +# returned parameters for the target model. # # Here we'll show examples of both of these cases, starting with an example # where the choice of weight function does not have much impact. In doing @@ -186,4 +186,3 @@ # knowing where you want the target model to be more accurate. The default # weight function was chosen because it yielded IAM models that produce # similar annual insolation for a simulated PV system TODO add reference. - diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 74e12dc832..7da3ba47e7 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -505,15 +505,6 @@ def test_convert_wrong_model_parameters(): _iam.convert('ashrae', {'B': 0.1}, 'physical') -def test_convert_wrong_custom_weight_func(): - def wrong_weight(aoi): - return 0 - - with pytest.raises(ValueError, match='custom weight function'): - _iam.convert('ashrae', {'b': 0.1}, 'physical', - options={'weight_function': wrong_weight}) - - def test_convert__minimize_fails(): # to make scipy.optimize.minimize fail, we'll pass in a nonsense # weight function that only outputs nans From 536cb9f6607e27fd39637936b3b2f1495355ea87 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 12 Sep 2023 09:15:07 -0600 Subject: [PATCH 35/65] remove test for weight function size --- pvlib/tests/test_iam.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 7da3ba47e7..2dcad7e326 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -552,15 +552,6 @@ def test_fit_model_not_implemented(): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'foo') -def test_fit_wrong_custom_weight_func(): - def wrong_weight(aoi): - return 0 - - with pytest.raises(ValueError, match='custom weight function'): - _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'physical', - options={'weight_function': wrong_weight}) - - def test_fit__minimize_fails(): # to make scipy.optimize.minimize fail, we'll pass in a nonsense # weight function that only outputs nans From 2882912a81f6293b2e7917e78a02cee425b38c7a Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 12 Sep 2023 09:38:07 -0600 Subject: [PATCH 36/65] editing --- .../reflections/plot_convert_iam_models.py | 8 +++++--- pvlib/iam.py | 20 +++++++++---------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 2fe9f01eeb..e0422a1ff1 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -76,8 +76,9 @@ # ---------------------------- # # Here, we'll show how to fit an IAM model to data. -# We'll generate some data by perturbing output from the Martin-Ruiz model to mimic -# measured data and then we'll fit the physical model to the perturbed data. +# We'll generate some data by perturbing output from the Martin-Ruiz model to +# mimic measured data and then we'll fit the physical model to the perturbed +# data. # Create and perturb IAM data. aoi = np.linspace(0, 90, 100) @@ -115,7 +116,8 @@ # the Martin-Ruize and physical models, because the curves described by these # models can match quite closely. However, when conversion involves the ASHRAE # model, the choice of weight function can have a meaningful effect on the -# returned parameters for the target model. +# returned parameters for the +# target model. # # Here we'll show examples of both of these cases, starting with an example # where the choice of weight function does not have much impact. In doing diff --git a/pvlib/iam.py b/pvlib/iam.py index e65f87531b..030a2a5eff 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1082,12 +1082,12 @@ def _process_return(target_name, optimize_result): def convert(source_name, source_params, target_name, options=None, fix_n=True): """ Given a source model and its parameters and a target model, finds - parameters for target model that best fit source model. + parameters for the target model that best fit the source model. Parameters ---------- source_name : str - Name of source model. Must be ``'ashrae'``, ``'martin_ruiz'``, or + Name of the source model. Must be ``'ashrae'``, ``'martin_ruiz'``, or ``'physical'``. source_params : dict @@ -1103,13 +1103,13 @@ def convert(source_name, source_params, target_name, options=None, fix_n=True): contain the keys ``'n'``, ``'K'``, and ``'L'``. target_name : str - Name of target model. Must be ``'ashrae'``, ``'martin_ruiz'``, or + Name of the target model. Must be ``'ashrae'``, ``'martin_ruiz'``, or ``'physical'``. options : dict, optional A dictionary that allows passing a custom weight function and arguments for the weight function. - Possible keys of `options` are ``'weight_function'`` and + Keys of `options` can be ``'weight_function'`` and ``'weight_args'``. weight_function : function @@ -1119,7 +1119,7 @@ def convert(source_name, source_params, target_name, options=None, fix_n=True): Requirements: 1. Must have ``aoi`` as the first positional argument. 2. Any other arguments must be keyword arguments, - defined in `weight_args`. + defined in ``weight_args``. 3. Must return a float or an array-like object with the same shape as ``aoi``. @@ -1131,7 +1131,7 @@ def convert(source_name, source_params, target_name, options=None, fix_n=True): A flag to determine which method is used when converting from the ASHRAE model to the physical model. - When `source_name` is ``'ashrae'`` and `target_name` is + When ``source_name`` is ``'ashrae'`` and ``target_name`` is ``'physical'``, if `fix_n` is ``True``, :py:func:`iam.convert` will fix ``n`` so that the returned physical model has the same x-intercept as the inputted ASHRAE model. @@ -1156,7 +1156,7 @@ def convert(source_name, source_params, target_name, options=None, fix_n=True): Notes ----- - The default weight function is ``f(aoi) = 1 - sin(aoi)``. + The default weight function is :math:`f(aoi) = 1 - sin(aoi)`. References ---------- @@ -1221,13 +1221,13 @@ def fit(measured_aoi, measured_iam, target_name, options=None): IAM values. target_name : str - Name of target model. Must be ``'ashrae'``, ``'martin_ruiz'``, + Name of the target model. Must be ``'ashrae'``, ``'martin_ruiz'``, or ``'physical'``. options : dict, optional A dictionary that allows passing a custom weight function and arguments for the weight function. - Possible keys of `options` are ``'weight_function'`` and + Keys of `options` can be ``'weight_function'`` and ``'weight_args'``. weight_function : function @@ -1237,7 +1237,7 @@ def fit(measured_aoi, measured_iam, target_name, options=None): Requirements: 1. Must have ``aoi`` as the first positional argument. 2. Any other arguments must be keyword arguments, - defined in `weight_args`. + defined in ``weight_args``. 3. Must return a float or an array-like object with the same shape as ``aoi``. From 9bb36b5b6d8f9db0357f67fdd69d7866da4dbfed Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 12 Sep 2023 16:27:50 -0600 Subject: [PATCH 37/65] simplify weight function --- pvlib/iam.py | 94 ++++++++++++----------------------------- pvlib/tests/test_iam.py | 26 ++++++------ 2 files changed, 39 insertions(+), 81 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 030a2a5eff..8db61b1748 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -971,25 +971,21 @@ def _sin_weight(aoi): def _residual(aoi, source_iam, target, target_params, - weight_function=_sin_weight, - weight_args=None): + weight=_sin_weight): # computes a sum of weighted differences between the source model # and target model, using the provided weight function - if weight_args is None: - weight_args = {} - - weight = weight_function(aoi, **weight_args) + weights = weight(aoi) # if aoi contains values outside of interval (0, 90), annihilate # the associated weights (we don't want IAM values from AOI outside # of (0, 90) to affect the fit; this is a possible issue when using # `iam.fit`, but not an issue when using `iam.convert`, since in # that case aoi is defined internally) - weight = weight * np.logical_and(aoi >= 0, aoi <= 90).astype(int) + weights = weights * np.logical_and(aoi >= 0, aoi <= 90).astype(int) diff = np.abs(source_iam - np.nan_to_num(target(aoi, *target_params))) - return np.sum(diff * weight) + return np.sum(diff * weights) def _get_ashrae_int(b): @@ -997,7 +993,7 @@ def _get_ashrae_int(b): return np.rad2deg(np.arccos(b / (1 + b))) -def _ashrae_to_physical(aoi, ashrae_iam, options, fix_n, b): +def _ashrae_to_physical(aoi, ashrae_iam, weight, fix_n, b): if fix_n: # the ashrae model has an x-intercept less than 90 # we solve for this intercept, and fix n so that the physical @@ -1024,12 +1020,12 @@ def _ashrae_to_physical(aoi, ashrae_iam, options, fix_n, b): def residual_function(target_params): L, n = target_params - return _residual(aoi, ashrae_iam, physical, [n, 4, L], **options) + return _residual(aoi, ashrae_iam, physical, [n, 4, L], weight) return residual_function, guess, bounds -def _martin_ruiz_to_physical(aoi, martin_ruiz_iam, options): +def _martin_ruiz_to_physical(aoi, martin_ruiz_iam, weight): # we will optimize for both n and L (recall that K and L always # appear in the physical model as a product, so it is enough to # optimize for just L, and to fix K=4) @@ -1040,7 +1036,7 @@ def _martin_ruiz_to_physical(aoi, martin_ruiz_iam, options): # guess for the location of the minimum, so we pass L in first def residual_function(target_params): L, n = target_params - return _residual(aoi, martin_ruiz_iam, physical, [n, 4, L], **options) + return _residual(aoi, martin_ruiz_iam, physical, [n, 4, L], weight) return residual_function, guess, bounds @@ -1079,7 +1075,7 @@ def _process_return(target_name, optimize_result): return target_params -def convert(source_name, source_params, target_name, options=None, fix_n=True): +def convert(source_name, source_params, target_name, weight=None, fix_n=True): """ Given a source model and its parameters and a target model, finds parameters for the target model that best fit the source model. @@ -1106,26 +1102,10 @@ def convert(source_name, source_params, target_name, options=None, fix_n=True): Name of the target model. Must be ``'ashrae'``, ``'martin_ruiz'``, or ``'physical'``. - options : dict, optional - A dictionary that allows passing a custom weight function and - arguments for the weight function. - Keys of `options` can be ``'weight_function'`` and - ``'weight_args'``. - - weight_function : function - A function that outputs an array of weights to use - when computing residuals between models. - - Requirements: - 1. Must have ``aoi`` as the first positional argument. - 2. Any other arguments must be keyword arguments, - defined in ``weight_args``. - 3. Must return a float or an array-like object with the - same shape as ``aoi``. - - weight_args : dict, optional - A dictionary containing keyword arguments for the - weight function. + weight : function, optional + A single-argument function of AOI that calculates weights for the + residuals between models. Must return a float or an array-like object. + The default weight function is :math:`f(aoi) = 1 - sin(aoi)`. fix_n : bool, default True A flag to determine which method is used when converting from the @@ -1154,10 +1134,6 @@ def convert(source_name, source_params, target_name, options=None, fix_n=True): If target model is ``'physical'``, the dictionary will contain the keys ``'n'``, ``'K'``, and ``'L'``. - Notes - ----- - The default weight function is :math:`f(aoi) = 1 - sin(aoi)`. - References ---------- .. [1] TODO @@ -1174,8 +1150,8 @@ def convert(source_name, source_params, target_name, options=None, fix_n=True): target = _get_model(target_name) # if no options were passed in, we will use the default arguments - if options is None: - options = {} + if weight is None: + weight = _sin_weight aoi = np.linspace(0, 90, 100) _check_params(source_name, source_params) @@ -1186,11 +1162,11 @@ def convert(source_name, source_params, target_name, options=None, fix_n=True): # target model is physical if source_name == "ashrae": residual_function, guess, bounds = \ - _ashrae_to_physical(aoi, source_iam, options, fix_n, + _ashrae_to_physical(aoi, source_iam, weight, fix_n, source_params['b']) elif source_name == "martin_ruiz": residual_function, guess, bounds = \ - _martin_ruiz_to_physical(aoi, source_iam, options) + _martin_ruiz_to_physical(aoi, source_iam, weight) else: # otherwise, target model is ashrae or martin_ruiz, and scipy @@ -1199,14 +1175,14 @@ def convert(source_name, source_params, target_name, options=None, fix_n=True): guess = [1e-08] def residual_function(target_param): - return _residual(aoi, source_iam, target, target_param, **options) + return _residual(aoi, source_iam, target, target_param, weight) optimize_result = _minimize(residual_function, guess, bounds) return _process_return(target_name, optimize_result) -def fit(measured_aoi, measured_iam, target_name, options=None): +def fit(measured_aoi, measured_iam, target_name, weight=None): """ Finds parameters for target model that best fit the measured data. @@ -1224,26 +1200,10 @@ def fit(measured_aoi, measured_iam, target_name, options=None): Name of the target model. Must be ``'ashrae'``, ``'martin_ruiz'``, or ``'physical'``. - options : dict, optional - A dictionary that allows passing a custom weight function and - arguments for the weight function. - Keys of `options` can be ``'weight_function'`` and - ``'weight_args'``. - - weight_function : function - A function that outputs an array of weights to use - when computing residuals between models. - - Requirements: - 1. Must have ``aoi`` as the first positional argument. - 2. Any other arguments must be keyword arguments, - defined in ``weight_args``. - 3. Must return a float or an array-like object with the - same shape as ``aoi``. - - weight_args : dict, optional - A dictionary containing keyword arguments for the - weight function. + weight : function, optional + A single-argument function of AOI that calculates weights for the + residuals between models. Must return a float or an array-like object. + The default weight function is :math:`f(aoi) = 1 - sin(aoi)`. Returns ------- @@ -1274,8 +1234,8 @@ def fit(measured_aoi, measured_iam, target_name, options=None): target = _get_model(target_name) # if no options were passed in, we will use the default arguments - if options is None: - options = {} + if weight is None: + weight = _sin_weight if target_name == "physical": bounds = [(0, 0.08), (1, 2)] @@ -1284,7 +1244,7 @@ def fit(measured_aoi, measured_iam, target_name, options=None): def residual_function(target_params): L, n = target_params return _residual(measured_aoi, measured_iam, target, [n, 4, L], - **options) + weight) # otherwise, target_name is martin_ruiz or ashrae else: @@ -1293,7 +1253,7 @@ def residual_function(target_params): def residual_function(target_param): return _residual(measured_aoi, measured_iam, target, target_param, - **options) + weight) optimize_result = _minimize(residual_function, guess, bounds) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 2dcad7e326..d84cc96ddd 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -479,18 +479,17 @@ def test_convert_custom_weight_func(): source_iam = _iam.physical(aoi, **source_params) # define custom weight function that takes in other arguments - def scaled_weight(aoi, scalar): - return scalar * aoi + def scaled_weight(aoi): + return 2. * aoi # expected value calculated from computing residual function over # a range of inputs, and taking minimum of these values expected_min_res = 18.051468686279726 - options = {'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} actual_dict = _iam.convert('physical', source_params, 'martin_ruiz', - options=options) + weight=scaled_weight) actual_min_res = _iam._residual(aoi, source_iam, _iam.martin_ruiz, - [actual_dict['a_r']], **options) + [actual_dict['a_r']], scaled_weight) assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) @@ -509,11 +508,10 @@ def test_convert__minimize_fails(): # to make scipy.optimize.minimize fail, we'll pass in a nonsense # weight function that only outputs nans def nan_weight(aoi): - return [float('nan')]*len(aoi) + return np.nan with pytest.raises(RuntimeError, match='Optimizer exited unsuccessfully'): - _iam.convert('ashrae', {'b': 0.1}, 'physical', - options={'weight_function': nan_weight}) + _iam.convert('ashrae', {'b': 0.1}, 'physical', weight=nan_weight) def test_fit(): @@ -531,8 +529,8 @@ def test_fit(): def test_fit_custom_weight_func(): # define custom weight function that takes in other arguments - def scaled_weight(aoi, scalar): - return scalar * aoi + def scaled_weight(aoi): + return 2. * aoi aoi = np.linspace(0, 90, 5) perturb = np.array([1.2, 1.01, 0.95, 1, 0.98]) @@ -540,8 +538,8 @@ def scaled_weight(aoi, scalar): expected_a_r = 0.14 - options = {'weight_function': scaled_weight, 'weight_args': {'scalar': 2}} - actual_dict = _iam.fit(aoi, perturbed_iam, 'martin_ruiz', options=options) + actual_dict = _iam.fit(aoi, perturbed_iam, 'martin_ruiz', + weight=scaled_weight) actual_a_r = actual_dict['a_r'] assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) @@ -556,11 +554,11 @@ def test_fit__minimize_fails(): # to make scipy.optimize.minimize fail, we'll pass in a nonsense # weight function that only outputs nans def nan_weight(aoi): - return [float('nan')]*len(aoi) + return np.nan with pytest.raises(RuntimeError, match='Optimizer exited unsuccessfully'): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'physical', - options={'weight_function': nan_weight}) + weight=nan_weight) def test__residual_zero_outside_range(): From fc1316c8447a20209cdf3c6dc2b4c48eadf0b89b Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Mon, 16 Oct 2023 16:00:23 -0600 Subject: [PATCH 38/65] improve martin_ruiz to physical, generalize tests --- pvlib/iam.py | 60 +++++++++++++++------ pvlib/tests/test_iam.py | 116 ++++++++++++++++------------------------ 2 files changed, 89 insertions(+), 87 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 1adc89dd1e..438b098ee0 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1008,15 +1008,15 @@ def _ashrae_to_physical(aoi, ashrae_iam, weight, fix_n, b): # we will pass n to the optimizer to simplify things later on, # but because we are setting (n, n) as the bounds, the optimizer # will leave n fixed - bounds = [(0, 0.08), (n, n)] + bounds = [(1e-6, 0.08), (n, n)] guess = [0.002, n] else: # we don't fix n, so physical won't have same x-intercept as ashrae # the fit will be worse, but the parameters returned for the physical # model will be more realistic - bounds = [(0, 0.08), (1, 2)] - guess = [0.002, 1+1e-08] + bounds = [(1e-6, 0.08), (0.8, 2)] # L, n + guess = [0.002, 1.0] def residual_function(target_params): L, n = target_params @@ -1025,26 +1025,41 @@ def residual_function(target_params): return residual_function, guess, bounds -def _martin_ruiz_to_physical(aoi, martin_ruiz_iam, weight): +def _martin_ruiz_to_physical(aoi, martin_ruiz_iam, weight, a_r): # we will optimize for both n and L (recall that K and L always # appear in the physical model as a product, so it is enough to # optimize for just L, and to fix K=4) - bounds = [(0, 0.08), (1, 2)] - guess = [0.002, 1+1e-08] + # set lower bound for n at 1.0 so that x-intercept will be at 90 + # order for Powell's method depends on a_r value + bounds = [(1e-6, 0.08), (1.05, 2)] # L, n + guess = [0.002, 1.1] # L, n + # get better results if we reverse order to n, L at high a_r + if a_r > 0.22: + bounds.reverse() + guess.reverse() # the product of K and L is more important in determining an initial # guess for the location of the minimum, so we pass L in first def residual_function(target_params): - L, n = target_params + # unpack target_params for either search order + if target_params[0] < target_params[1]: + # L will always be less than n + L, n = target_params + else: + n, L = target_params return _residual(aoi, martin_ruiz_iam, physical, [n, 4, L], weight) return residual_function, guess, bounds -def _minimize(residual_function, guess, bounds): +def _minimize(residual_function, guess, bounds, xtol): + if xtol is not None: + options = {'xtol': xtol} + else: + options = None with np.errstate(invalid='ignore'): optimize_result = minimize(residual_function, guess, method="powell", - bounds=bounds) + bounds=bounds, options=options) if not optimize_result.success: try: @@ -1070,12 +1085,16 @@ def _process_return(target_name, optimize_result): elif target_name == "physical": L, n = optimize_result.x + # have to unpack order because search order may be different + if L > n: + L, n = n, L target_params = {'n': n, 'K': 4, 'L': L} return target_params -def convert(source_name, source_params, target_name, weight=None, fix_n=True): +def convert(source_name, source_params, target_name, weight=None, fix_n=True, + xtol=None): """ Given a source model and its parameters and a target model, finds parameters for the target model that best fit the source model. @@ -1120,6 +1139,9 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True): If more physically meaningful parameters are wanted, set `fix_n` to False. + xtol : float, optional + Passed to scipy.optimize.minimize. + Returns ------- dict @@ -1166,23 +1188,24 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True): source_params['b']) elif source_name == "martin_ruiz": residual_function, guess, bounds = \ - _martin_ruiz_to_physical(aoi, source_iam, weight) - + _martin_ruiz_to_physical(aoi, source_iam, weight, + source_params['a_r']) + else: # otherwise, target model is ashrae or martin_ruiz, and scipy # does fine without any special set-up - bounds = [(1e-08, 1)] - guess = [1e-08] + bounds = [(1e-04, 1)] + guess = [1e-03] def residual_function(target_param): return _residual(aoi, source_iam, target, target_param, weight) - optimize_result = _minimize(residual_function, guess, bounds) + optimize_result = _minimize(residual_function, guess, bounds, xtol=xtol) return _process_return(target_name, optimize_result) -def fit(measured_aoi, measured_iam, target_name, weight=None): +def fit(measured_aoi, measured_iam, target_name, weight=None, xtol=None): """ Finds parameters for target model that best fit the measured data. @@ -1205,6 +1228,9 @@ def fit(measured_aoi, measured_iam, target_name, weight=None): residuals between models. Must return a float or an array-like object. The default weight function is :math:`f(aoi) = 1 - sin(aoi)`. + xtol : float, optional + Passed to scipy.optimize.minimize. + Returns ------- dict @@ -1255,6 +1281,6 @@ def residual_function(target_param): return _residual(measured_aoi, measured_iam, target, target_param, weight) - optimize_result = _minimize(residual_function, guess, bounds) + optimize_result = _minimize(residual_function, guess, bounds, xtol) return _process_return(target_name, optimize_result) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index d84cc96ddd..ee9e179f09 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -396,79 +396,55 @@ def test_schlick_diffuse(): assert_series_equal(pd.Series(expected_ground, idx), actual_ground, rtol=1e-6) - -def test_convert(): - # we'll compare residuals rather than coefficient values - # we only care about how close the fit of the conversion is, so the - # specific coefficients that get us there is less important - - # expected value calculated from computing residual function over - # a range of inputs, and taking minimum of these values - - aoi = np.linspace(0, 90, 100) - - # convert physical to martin_ruiz - source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} - source_iam = _iam.physical(aoi, **source_params) - expected_min_res = 0.02193164055914381 - - actual_dict = _iam.convert('physical', source_params, 'martin_ruiz') - actual_params_list = [actual_dict[key] for key in actual_dict] - actual_min_res = _iam._residual(aoi, source_iam, _iam.martin_ruiz, - actual_params_list) - - assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) - - # convert physical to ashrae - source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} - source_iam = _iam.physical(aoi, **source_params) - expected_min_res = 0.14929293811214253 - - actual_dict = _iam.convert('physical', source_params, 'ashrae') - actual_params_list = [actual_dict[key] for key in actual_dict] - actual_min_res = _iam._residual(aoi, source_iam, _iam.ashrae, - actual_params_list) - - assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) - - # convert ashrae to physical (tests _ashrae_to_physical) - source_params = {'b': 0.15} - source_iam = _iam.ashrae(aoi, **source_params) - expected_min_res = 0.0216 - - actual_dict = _iam.convert('ashrae', source_params, 'physical') - actual_params_list = [actual_dict[key] for key in actual_dict] - actual_min_res = _iam._residual(aoi, source_iam, _iam.physical, - actual_params_list) - - assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) - +@pytest.mark.parametrize('source,source_params,target,expected', [ + ('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'martin_ruiz', + {'a_r': 0.173972}), + ('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'ashrae', + {'b': 0.043925}), + ('ashrae', {'b': 0.15}, 'physical', + {'n': 0.991457, 'K': 4, 'L': 0.0378127}), + ('ashrae', {'b': 0.15}, 'martin_ruiz', {'a_r': 0.30284}), + ('martin_ruiz', {'a_r': 0.15}, 'physical', + {'n': 1.240655, 'K': 4, 'L': 0.00278196}), + ('martin_ruiz', {'a_r': 0.15}, 'ashrae', {'b': 0.026147})]) +def test_convert(source, source_params, target, expected): + target_params = _iam.convert(source, source_params, target) + exp = [expected[k] for k in expected] + tar = [target_params[k] for k in expected] + assert_allclose(exp, tar, rtol=1e-05) + + +@pytest.mark.parametrize('source,source_params', [ + ('ashrae', {'b': 0.15}), + ('ashrae', {'b': 0.05}), + ('martin_ruiz', {'a_r': 0.15})]) +def test_convert_recover(source, source_params): + # convert isn't set up to handle both source and target = 'physical' + target_params = _iam.convert(source, source_params, source, xtol=1e-8) + exp = [source_params[k] for k in source_params] + tar = [target_params[k] for k in source_params] + assert_allclose(exp, tar, rtol=1e-05) + + +def test_convert_ashrae_physical_no_fix_n(): # convert ashrae to physical, without fixing n source_params = {'b': 0.15} - source_iam = _iam.ashrae(aoi, **source_params) - expected_min_res = 0.06684694222023173 - - actual_dict = _iam.convert('ashrae', source_params, 'physical', - fix_n=False) - actual_params_list = [actual_dict[key] for key in actual_dict] - actual_min_res = _iam._residual(aoi, source_iam, _iam.physical, - actual_params_list) + target_params = _iam.convert('ashrae', source_params, 'physical', + fix_n=False) + expected = {'n': 0.989039, 'K': 4, 'L': 0.0373608} + exp = [expected[k] for k in expected] + tar = [target_params[k] for k in expected] + assert_allclose(exp, tar, rtol=1e-05) - # not sure why, but could never get convert residual and expected residual - # to be closer than 0.00025ish, hence why atol=1e-03 - assert np.isclose(expected_min_res, actual_min_res, atol=1e-03) - # convert martin_ruiz to physical (tests _martin_ruiz_to_physical) - source_params = {'a_r': 0.14} - source_iam = _iam.martin_ruiz(aoi, **source_params) - expected_min_res = 0.004162175187057447 - - actual_dict = _iam.convert('martin_ruiz', {'a_r': 0.14}, 'physical') - actual_params_list = [actual_dict[key] for key in actual_dict] - actual_min_res = _iam._residual(aoi, source_iam, _iam.physical, - actual_params_list) - - assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) +def test_convert_xtol(): + source_params = {'b': 0.15} + target_params = _iam.convert('ashrae', source_params, 'physical', + xtol=1e-12) + expected = {'n': 0.9914568913905548, 'K': 4, 'L': 0.037812698547748186} + exp = [expected[k] for k in expected] + tar = [target_params[k] for k in expected] + assert_allclose(exp, tar, rtol=1e-10) def test_convert_custom_weight_func(): @@ -491,7 +467,7 @@ def scaled_weight(aoi): actual_min_res = _iam._residual(aoi, source_iam, _iam.martin_ruiz, [actual_dict['a_r']], scaled_weight) - assert np.isclose(expected_min_res, actual_min_res, atol=1e-04) + assert np.isclose(expected_min_res, actual_min_res, atol=1e-08) def test_convert_model_not_implemented(): From 935443b48d5cbb03519ef022582ae6b7f4a6ab1f Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Mon, 16 Oct 2023 16:19:50 -0600 Subject: [PATCH 39/65] fix examples, split convert and fit examples --- .../reflections/plot_convert_iam_models.py | 75 ++++------------ .../reflections/plot_fit_iam_models.py | 86 +++++++++++++++++++ pvlib/iam.py | 10 +-- pvlib/tests/test_iam.py | 9 +- 4 files changed, 115 insertions(+), 65 deletions(-) create mode 100644 docs/examples/reflections/plot_fit_iam_models.py diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index e0422a1ff1..4c0ba3d604 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -1,11 +1,10 @@ """ -IAM Model Conversion and Fitting -================================ +IAM Model Conversion +==================== Illustrates how to convert from one IAM model to a different model using -pvlib.iam.convert, and how to fit an IAM model to data using -pvlib.iam.fit +pvlib.iam.convert """ @@ -18,19 +17,16 @@ # # Here, we show how to use # py:func:`~pvlib.iam.convert` to estimate parameters for a desired target -# IAM model from a source IAM model. We also show how to use -# py:func:`~pvlib.iam.fit` to estimate a model's parameters from data. -# -# Model conversion and model fitting require a weight function that assigns -# more influence to some AOI values than others. We illustrate how to provide -# a custom weight function to py:func:`~pvlib.iam.convert`. +# IAM model from a source IAM model. Model conversion requires a weight +# function that assigns more influence to some AOI values than others. +# We illustrate how to provide a custom weight function to +# py:func:`~pvlib.iam.convert`. import numpy as np -from random import uniform import matplotlib.pyplot as plt from pvlib.tools import cosd -from pvlib.iam import (ashrae, martin_ruiz, physical, convert, fit) +from pvlib.iam import (ashrae, martin_ruiz, physical, convert) # %% # Converting from one IAM model to another model @@ -48,7 +44,7 @@ physical_params = convert('martin_ruiz', martin_ruiz_params, 'physical') physical_iam = physical(aoi, **physical_params) -# Get parameters for the ASHRAE model and compute IAm using these parameters. +# Get parameters for the ASHRAE model and compute IAM using these parameters. ashrae_params = convert('martin_ruiz', martin_ruiz_params, 'ashrae') ashrae_iam = ashrae(aoi, **ashrae_params) @@ -71,53 +67,20 @@ plt.show() -# %% -# Fitting an IAM model to data -# ---------------------------- -# -# Here, we'll show how to fit an IAM model to data. -# We'll generate some data by perturbing output from the Martin-Ruiz model to -# mimic measured data and then we'll fit the physical model to the perturbed -# data. - -# Create and perturb IAM data. -aoi = np.linspace(0, 90, 100) -params = {'a_r': 0.16} -iam = martin_ruiz(aoi, **params) -data = iam * np.array([uniform(0.98, 1.02) for _ in range(len(iam))]) - -# Get parameters for the physical model by fitting to the perturbed data. -physical_params = fit(aoi, data, 'physical') - -# Compute IAM with the fitted physical model parameters. -physical_iam = physical(aoi, **physical_params) - -# Plot IAM vs. AOI -plt.scatter(aoi, data, c='darkorange', label='Data') -plt.plot(aoi, physical_iam, label='physical') -plt.xlabel('AOI (degrees)') -plt.ylabel('IAM') -plt.title('Fitting the physical model to data') -plt.legend() -plt.show() - - # %% # The weight function # ------------------- -# Both :py:func:`pvlib.iam.convert` and :py:func:`pvlib.iam.fit` use -# a weight function when computing residuals between the two models, or -# between a model and data. The default weight -# function is $1 - \sin(aoi)$. We can instead pass a custom weight function -# to either :py:func:`pvlib.iam.convert` and :py:func:`pvlib.iam.fit`. +# :py:func:`pvlib.iam.convert` uses a weight function when computing residuals +# between the two models. The default weight +# function is :math:`1 - \sin(aoi)`. We can instead pass a custom weight function +# to :py:func:`pvlib.iam.convert`. # # In some cases, the choice of weight function has a minimal effect on the # returned model parameters. This is especially true when converting between -# the Martin-Ruize and physical models, because the curves described by these +# the Martin-Ruiz and physical models, because the curves described by these # models can match quite closely. However, when conversion involves the ASHRAE # model, the choice of weight function can have a meaningful effect on the -# returned parameters for the -# target model. +# returned parameters for the target model. # # Here we'll show examples of both of these cases, starting with an example # where the choice of weight function does not have much impact. In doing @@ -136,10 +99,10 @@ physical_iam_default = physical(aoi, **physical_params_default) # ... using a custom weight function. -options = {'weight_function': lambda aoi: cosd(aoi)} +weight_function = lambda aoi: cosd(aoi) physical_params_custom = convert('martin_ruiz', martin_ruiz_params, 'physical', - options=options) + weight=weight_function) physical_iam_custom = physical(aoi, **physical_params_custom) # Plot IAM vs AOI. @@ -166,10 +129,10 @@ ashrae_iam_default = ashrae(aoi, **ashrae_params_default) # ... using a custom weight function. -options = {'weight_function': lambda aoi: cosd(aoi)} +weight_function = lambda aoi: cosd(aoi) ashrae_params_custom = convert('martin_ruiz', martin_ruiz_params, 'ashrae', - options=options) + weight=weight_function) ashrae_iam_custom = ashrae(aoi, **ashrae_params_custom) # Plot IAM vs AOI. diff --git a/docs/examples/reflections/plot_fit_iam_models.py b/docs/examples/reflections/plot_fit_iam_models.py new file mode 100644 index 0000000000..cf5b581f17 --- /dev/null +++ b/docs/examples/reflections/plot_fit_iam_models.py @@ -0,0 +1,86 @@ + +""" +IAM Model Conversion and Fitting +================================ + +Illustrates how to fit an IAM model to data using pvlib.iam.fit + +""" + +# %% +# An incidence angle modifier (IAM) model quantifies the fraction of direct +# irradiance is that is reflected away from a module's surface. Three popular +# IAM models are Martin-Ruiz py:func:`~pvlib.iam.martin_ruiz`, physical +# py:func:`~pvlib.iam.physical`, and ASHRAE `py:func:~pvlib.iam.ashrae`. +# Each model requires one or more parameters. +# +# Here, we show how to use +# py:func:`~pvlib.iam.fit` to estimate a model's parameters from data. +# +# Model fitting require a weight function that assigns +# more influence to some AOI values than others. We illustrate how to provide +# a custom weight function to py:func:`~pvlib.iam.fit`. + +import numpy as np +from random import uniform +import matplotlib.pyplot as plt + +from pvlib.tools import cosd +from pvlib.iam import (martin_ruiz, physical, fit) + + +# %% +# Fitting an IAM model to data +# ---------------------------- +# +# Here, we'll show how to fit an IAM model to data. +# We'll generate some data by perturbing output from the Martin-Ruiz model to +# mimic measured data and then we'll fit the physical model to the perturbed +# data. + +# Create and perturb IAM data. +aoi = np.linspace(0, 90, 100) +params = {'a_r': 0.16} +iam = martin_ruiz(aoi, **params) +data = iam * np.array([uniform(0.98, 1.02) for _ in range(len(iam))]) + +# Get parameters for the physical model by fitting to the perturbed data. +physical_params = fit(aoi, data, 'physical') + +# Compute IAM with the fitted physical model parameters. +physical_iam = physical(aoi, **physical_params) + +# Plot IAM vs. AOI +plt.scatter(aoi, data, c='darkorange', label='Data') +plt.plot(aoi, physical_iam, label='physical') +plt.xlabel('AOI (degrees)') +plt.ylabel('IAM') +plt.title('Fitting the physical model to data') +plt.legend() +plt.show() + + +# %% +# The weight function +# ------------------- +# :py:func:`pvlib.iam.fit` uses a weight function when computing residuals +# between the model abd data. The default weight +# function is :math:`1 - \sin(aoi)`. We can instead pass a custom weight +# function to :py:func:`pvlib.iam.fit`. +# + +# Define a custom weight function. +weight_function = lambda aoi: cosd(aoi) + +physical_params_custom = fit(aoi, data, 'physical', weight=weight_function) + +physical_iam_custom = physical(aoi, **physical_params_custom) + +# Plot IAM vs AOI. +plt.plot(aoi, data, label='Data (from Martin-Ruiz model)') +plt.plot(aoi, physical_iam, label='Default weight function') +plt.plot(aoi, physical_iam_custom, label='Custom weight function') +plt.xlabel('AOI (degrees)') +plt.ylabel('IAM') +plt.legend() +plt.show() diff --git a/pvlib/iam.py b/pvlib/iam.py index 438b098ee0..9185be2408 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -12,7 +12,7 @@ import pandas as pd import functools from scipy.optimize import minimize -from pvlib.tools import cosd, sind +from pvlib.tools import cosd, sind, acosd # a dict of required parameter names for each IAM model # keys are the function names for the IAM models @@ -990,7 +990,7 @@ def _residual(aoi, source_iam, target, target_params, def _get_ashrae_int(b): # find x-intercept of ashrae model - return np.rad2deg(np.arccos(b / (1 + b))) + return acosd(b / (1 + b)) def _ashrae_to_physical(aoi, ashrae_iam, weight, fix_n, b): @@ -1190,7 +1190,7 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, residual_function, guess, bounds = \ _martin_ruiz_to_physical(aoi, source_iam, weight, source_params['a_r']) - + else: # otherwise, target model is ashrae or martin_ruiz, and scipy # does fine without any special set-up @@ -1214,10 +1214,10 @@ def fit(measured_aoi, measured_iam, target_name, weight=None, xtol=None): ---------- measured_aoi : array-like Angle of incidence values associated with the - measured IAM values. + measured IAM values. [degrees] measured_iam : array-like - IAM values. + IAM values. [unitless] target_name : str Name of the target model. Must be ``'ashrae'``, ``'martin_ruiz'``, diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index ee9e179f09..fedb4c8847 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -265,10 +265,10 @@ def test_marion_diffuse_model(mocker): assert physical_spy.call_count == 3 for k, v in ashrae_expected.items(): - np.testing.assert_allclose(ashrae_actual[k], v) + assert_allclose(ashrae_actual[k], v) for k, v in physical_expected.items(): - np.testing.assert_allclose(physical_actual[k], v) + assert_allclose(physical_actual[k], v) def test_marion_diffuse_kwargs(): @@ -281,7 +281,7 @@ def test_marion_diffuse_kwargs(): actual = _iam.marion_diffuse('ashrae', 20, b=0.04) for k, v in expected.items(): - np.testing.assert_allclose(actual[k], v) + assert_allclose(actual[k], v) def test_marion_diffuse_invalid(): @@ -396,6 +396,7 @@ def test_schlick_diffuse(): assert_series_equal(pd.Series(expected_ground, idx), actual_ground, rtol=1e-6) + @pytest.mark.parametrize('source,source_params,target,expected', [ ('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'martin_ruiz', {'a_r': 0.173972}), @@ -425,7 +426,7 @@ def test_convert_recover(source, source_params): tar = [target_params[k] for k in source_params] assert_allclose(exp, tar, rtol=1e-05) - + def test_convert_ashrae_physical_no_fix_n(): # convert ashrae to physical, without fixing n source_params = {'b': 0.15} From c9f697d661089b7ec40509e0e0441879b7cda59d Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Mon, 16 Oct 2023 16:30:16 -0600 Subject: [PATCH 40/65] linter, improve coverage --- docs/examples/reflections/plot_convert_iam_models.py | 11 +++++------ docs/examples/reflections/plot_fit_iam_models.py | 3 ++- pvlib/tests/test_iam.py | 9 +++++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 4c0ba3d604..26c9ce1e51 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -72,8 +72,8 @@ # ------------------- # :py:func:`pvlib.iam.convert` uses a weight function when computing residuals # between the two models. The default weight -# function is :math:`1 - \sin(aoi)`. We can instead pass a custom weight function -# to :py:func:`pvlib.iam.convert`. +# function is :math:`1 - \sin(aoi)`. We can instead pass a custom weight +# function to :py:func:`pvlib.iam.convert`. # # In some cases, the choice of weight function has a minimal effect on the # returned model parameters. This is especially true when converting between @@ -99,7 +99,8 @@ physical_iam_default = physical(aoi, **physical_params_default) # ... using a custom weight function. -weight_function = lambda aoi: cosd(aoi) +def weight_function(aoi): + return cosd(aoi) physical_params_custom = convert('martin_ruiz', martin_ruiz_params, 'physical', weight=weight_function) @@ -128,9 +129,7 @@ ashrae_params_default = convert('martin_ruiz', martin_ruiz_params, 'ashrae') ashrae_iam_default = ashrae(aoi, **ashrae_params_default) -# ... using a custom weight function. -weight_function = lambda aoi: cosd(aoi) - +# ... using the custom weight function ashrae_params_custom = convert('martin_ruiz', martin_ruiz_params, 'ashrae', weight=weight_function) ashrae_iam_custom = ashrae(aoi, **ashrae_params_custom) diff --git a/docs/examples/reflections/plot_fit_iam_models.py b/docs/examples/reflections/plot_fit_iam_models.py index cf5b581f17..f5574cf65d 100644 --- a/docs/examples/reflections/plot_fit_iam_models.py +++ b/docs/examples/reflections/plot_fit_iam_models.py @@ -70,7 +70,8 @@ # # Define a custom weight function. -weight_function = lambda aoi: cosd(aoi) +def weight_function(aoi): + return cosd(aoi) physical_params_custom = fit(aoi, data, 'physical', weight=weight_function) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index fedb4c8847..4b03b08ec5 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -438,6 +438,15 @@ def test_convert_ashrae_physical_no_fix_n(): assert_allclose(exp, tar, rtol=1e-05) +def test_convert_reverse_order_in_physical(): + source_params = {'b': 0.25} + target_params = _iam.convert('ashrae', source_params, 'physical') + expected = {'n': 0.979796, 'K': 4, 'L': 0.0615837} + exp = [expected[k] for k in expected] + tar = [target_params[k] for k in expected] + assert_allclose(exp, tar, rtol=1e-5) + + def test_convert_xtol(): source_params = {'b': 0.15} target_params = _iam.convert('ashrae', source_params, 'physical', From 88a9dfc69f886913d4de4d25450edb31a1204e75 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Mon, 16 Oct 2023 16:41:22 -0600 Subject: [PATCH 41/65] spacing --- docs/examples/reflections/plot_convert_iam_models.py | 10 ++++++---- docs/examples/reflections/plot_fit_iam_models.py | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 26c9ce1e51..ddbb5b4e62 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -11,16 +11,16 @@ # %% # An incidence angle modifier (IAM) model quantifies the fraction of direct # irradiance is that is reflected away from a module's surface. Three popular -# IAM models are Martin-Ruiz py:func:`~pvlib.iam.martin_ruiz`, physical -# py:func:`~pvlib.iam.physical`, and ASHRAE `py:func:~pvlib.iam.ashrae`. +# IAM models are Martin-Ruiz :py:func:`~pvlib.iam.martin_ruiz`, physical +# :py:func:`~pvlib.iam.physical`, and ASHRAE `py:func:~pvlib.iam.ashrae`. # Each model requires one or more parameters. # # Here, we show how to use -# py:func:`~pvlib.iam.convert` to estimate parameters for a desired target +# :py:func:`~pvlib.iam.convert` to estimate parameters for a desired target # IAM model from a source IAM model. Model conversion requires a weight # function that assigns more influence to some AOI values than others. # We illustrate how to provide a custom weight function to -# py:func:`~pvlib.iam.convert`. +# :py:func:`~pvlib.iam.convert`. import numpy as np import matplotlib.pyplot as plt @@ -98,10 +98,12 @@ 'physical') physical_iam_default = physical(aoi, **physical_params_default) + # ... using a custom weight function. def weight_function(aoi): return cosd(aoi) + physical_params_custom = convert('martin_ruiz', martin_ruiz_params, 'physical', weight=weight_function) physical_iam_custom = physical(aoi, **physical_params_custom) diff --git a/docs/examples/reflections/plot_fit_iam_models.py b/docs/examples/reflections/plot_fit_iam_models.py index f5574cf65d..5301b4a9f5 100644 --- a/docs/examples/reflections/plot_fit_iam_models.py +++ b/docs/examples/reflections/plot_fit_iam_models.py @@ -10,16 +10,16 @@ # %% # An incidence angle modifier (IAM) model quantifies the fraction of direct # irradiance is that is reflected away from a module's surface. Three popular -# IAM models are Martin-Ruiz py:func:`~pvlib.iam.martin_ruiz`, physical -# py:func:`~pvlib.iam.physical`, and ASHRAE `py:func:~pvlib.iam.ashrae`. +# IAM models are Martin-Ruiz :py:func:`~pvlib.iam.martin_ruiz`, physical +# :py:func:`~pvlib.iam.physical`, and ASHRAE `py:func:~pvlib.iam.ashrae`. # Each model requires one or more parameters. # # Here, we show how to use -# py:func:`~pvlib.iam.fit` to estimate a model's parameters from data. +# :py:func:`~pvlib.iam.fit` to estimate a model's parameters from data. # # Model fitting require a weight function that assigns # more influence to some AOI values than others. We illustrate how to provide -# a custom weight function to py:func:`~pvlib.iam.fit`. +# a custom weight function to :py:func:`~pvlib.iam.fit`. import numpy as np from random import uniform @@ -73,6 +73,7 @@ def weight_function(aoi): return cosd(aoi) + physical_params_custom = fit(aoi, data, 'physical', weight=weight_function) physical_iam_custom = physical(aoi, **physical_params_custom) From 8ace9d66eded4094a411d8f2462651cde8a4ae41 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Mon, 16 Oct 2023 16:44:27 -0600 Subject: [PATCH 42/65] fix reverse order test --- pvlib/tests/test_iam.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 4b03b08ec5..78b6842a8a 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -439,9 +439,9 @@ def test_convert_ashrae_physical_no_fix_n(): def test_convert_reverse_order_in_physical(): - source_params = {'b': 0.25} - target_params = _iam.convert('ashrae', source_params, 'physical') - expected = {'n': 0.979796, 'K': 4, 'L': 0.0615837} + source_params = {'a_r': 0.25} + target_params = _iam.convert('martin_ruiz', source_params, 'physical') + expected = {'n': 1.681051, 'K': 4, 'L': 0.0707148} exp = [expected[k] for k in expected] tar = [target_params[k] for k in expected] assert_allclose(exp, tar, rtol=1e-5) From 3475bf4de21688612bf54f78b69b2990ebbb0e21 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 17 Oct 2023 08:51:01 -0600 Subject: [PATCH 43/65] improve examples --- .../reflections/plot_convert_iam_models.py | 4 +-- .../reflections/plot_fit_iam_models.py | 27 ++++++++++++------- pvlib/iam.py | 3 +-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index ddbb5b4e62..8a8331b302 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -4,7 +4,7 @@ ==================== Illustrates how to convert from one IAM model to a different model using -pvlib.iam.convert +:py:func:`~pvlib.iam.convert` """ @@ -12,7 +12,7 @@ # An incidence angle modifier (IAM) model quantifies the fraction of direct # irradiance is that is reflected away from a module's surface. Three popular # IAM models are Martin-Ruiz :py:func:`~pvlib.iam.martin_ruiz`, physical -# :py:func:`~pvlib.iam.physical`, and ASHRAE `py:func:~pvlib.iam.ashrae`. +# :py:func:`~pvlib.iam.physical`, and ASHRAE py:func:`~pvlib.iam.ashrae`. # Each model requires one or more parameters. # # Here, we show how to use diff --git a/docs/examples/reflections/plot_fit_iam_models.py b/docs/examples/reflections/plot_fit_iam_models.py index 5301b4a9f5..a0e3687dce 100644 --- a/docs/examples/reflections/plot_fit_iam_models.py +++ b/docs/examples/reflections/plot_fit_iam_models.py @@ -1,9 +1,9 @@ """ -IAM Model Conversion and Fitting +IAM Model Fitting ================================ -Illustrates how to fit an IAM model to data using pvlib.iam.fit +Illustrates how to fit an IAM model to data using :py:func:`~pvlib.iam.fit` """ @@ -11,7 +11,7 @@ # An incidence angle modifier (IAM) model quantifies the fraction of direct # irradiance is that is reflected away from a module's surface. Three popular # IAM models are Martin-Ruiz :py:func:`~pvlib.iam.martin_ruiz`, physical -# :py:func:`~pvlib.iam.physical`, and ASHRAE `py:func:~pvlib.iam.ashrae`. +# :py:func:`~pvlib.iam.physical`, and ASHRAE py:func:`~pvlib.iam.ashrae`. # Each model requires one or more parameters. # # Here, we show how to use @@ -79,10 +79,19 @@ def weight_function(aoi): physical_iam_custom = physical(aoi, **physical_params_custom) # Plot IAM vs AOI. -plt.plot(aoi, data, label='Data (from Martin-Ruiz model)') -plt.plot(aoi, physical_iam, label='Default weight function') -plt.plot(aoi, physical_iam_custom, label='Custom weight function') -plt.xlabel('AOI (degrees)') -plt.ylabel('IAM') -plt.legend() +fig, ax = plt.subplots(2, 1, figsize=(5, 8)) +ax[0].plot(aoi, data, '.', label='Data (from Martin-Ruiz model)') +ax[0].plot(aoi, physical_iam, label='With default weight function') +ax[0].plot(aoi, physical_iam_custom, label='With custom weight function') +ax[0].set_xlabel('AOI (degrees)') +ax[0].set_ylabel('IAM') +ax[0].legend() + +ax[1].plot(aoi, physical_iam_custom - physical_iam, label='Custom - default') +ax[1].set_xlabel('AOI (degrees)') +ax[1].set_ylabel('Diff. in IAM') +ax[1].legend() +plt.tight_layout() plt.show() + +print("") \ No newline at end of file diff --git a/pvlib/iam.py b/pvlib/iam.py index 9185be2408..b7e2fb5ab0 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1096,8 +1096,7 @@ def _process_return(target_name, optimize_result): def convert(source_name, source_params, target_name, weight=None, fix_n=True, xtol=None): """ - Given a source model and its parameters and a target model, finds - parameters for the target model that best fit the source model. + Convert a source IAM model to a target IAM model. Parameters ---------- From f216d94fd7430b7c8fd876718b6bcdde5c341a39 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 17 Oct 2023 10:36:48 -0600 Subject: [PATCH 44/65] print parameters --- docs/examples/reflections/plot_fit_iam_models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/examples/reflections/plot_fit_iam_models.py b/docs/examples/reflections/plot_fit_iam_models.py index a0e3687dce..28da2fd6bf 100644 --- a/docs/examples/reflections/plot_fit_iam_models.py +++ b/docs/examples/reflections/plot_fit_iam_models.py @@ -94,4 +94,5 @@ def weight_function(aoi): plt.tight_layout() plt.show() -print("") \ No newline at end of file +print("Parameters with default weights: " + str(physical_params)) +print("Parameters with custom weights: " + str(physical_params_custom)) From cb4cb05e6ac6dbb327fc913832515a53b94e24db Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 17 Oct 2023 13:42:26 -0600 Subject: [PATCH 45/65] whatsnew --- docs/sphinx/source/whatsnew/v0.10.3.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.10.3.rst b/docs/sphinx/source/whatsnew/v0.10.3.rst index 0853254983..06c840ac12 100644 --- a/docs/sphinx/source/whatsnew/v0.10.3.rst +++ b/docs/sphinx/source/whatsnew/v0.10.3.rst @@ -12,6 +12,8 @@ Enhancements * :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance` and :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance_poa` now include shaded fraction in returned variables. (:pull:`1871`) +* Added :py:func:`~pvlib.iam.convert` and :py:func:`~pvlib.iam.fit` that + convert between IAM models, and that fit an IAM model to data. (:issue:`1824`, :pull:`1827`) Bug fixes ~~~~~~~~~ @@ -32,3 +34,5 @@ Contributors * Miguel Sánchez de León Peque (:ghuser:`Peque`) * Will Hobbs (:ghuser:`williamhobbs`) * Anton Driesse (:ghuser:`adriesse`) +* Abigail Jones (:ghuser:`ajonesr`) +* Cliff Hansen (:ghuser:`cwhanse`) From ed357311b584392714dd8d86b36e64d9efb46349 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 17 Oct 2023 14:11:24 -0600 Subject: [PATCH 46/65] remove v0.10.2 whatsnew --- docs/sphinx/source/whatsnew/v0.10.2.rst | 92 ------------------------- 1 file changed, 92 deletions(-) delete mode 100644 docs/sphinx/source/whatsnew/v0.10.2.rst diff --git a/docs/sphinx/source/whatsnew/v0.10.2.rst b/docs/sphinx/source/whatsnew/v0.10.2.rst deleted file mode 100644 index 1fa0651449..0000000000 --- a/docs/sphinx/source/whatsnew/v0.10.2.rst +++ /dev/null @@ -1,92 +0,0 @@ -.. _whatsnew_01020: - - -v0.10.2 (September 21, 2023) ----------------------------- - - -Enhancements -~~~~~~~~~~~~ -* Added .pan/.ond reader function :py:func:`pvlib.iotools.read_panond`. (:issue:`1747`, :pull:`1749`) -* Added support for dates to be specified as strings in the iotools get functions: - :py:func:`~pvlib.iotools.get_pvgis_hourly`, :py:func:`~pvlib.iotools.get_cams`, - :py:func:`~pvlib.iotools.get_bsrn`, and :py:func:`~pvlib.iotools.read_midc_raw_data_from_nrel`. - (:pull:`1800`) -* Added support for asymmetric limiting angles in :py:func:`pvlib.tracking.singleaxis` - and :py:class:`~pvlib.pvsystem.SingleAxisTrackerMount`. (:issue:`1777`, :pull:`1809`, :pull:`1852`) -* Added option to infer threshold values for - :py:func:`pvlib.clearsky.detect_clearsky` (:issue:`1808`, :pull:`1784`) -* Added a continuous version of the Erbs diffuse-fraction/decomposition model. - :py:func:`pvlib.irradiance.erbs_driesse` (:issue:`1755`, :pull:`1834`) -* Added :py:func:`~pvlib.iam.interp` option as AOI losses model in - :py:class:`pvlib.modelchain.ModelChain` and - :py:class:`pvlib.pvsystem.PVSystem`. (:issue:`1742`, :pull:`1832`) -* Added :py:func:`~pvlib.iam.convert` and :py:func:`~pvlib.iam.fit` that - convert between IAM models, and that fit an IAM model to data. (:issue:`1824`, :pull:`1827`) - :py:class:`~pvlib.modelchain.ModelChain` and - :py:class:`~pvlib.pvsystem.PVSystem`. (:issue:`1742`, :pull:`1832`) -* :py:class:`~pvlib.pvsystem.PVSystem` objects with a single - :py:class:`~pvlib.pvsystem.Array` can now be created without wrapping the - ``Array`` in a list first. (:issue:`1831`, :pull:`1854`) - - -Bug fixes -~~~~~~~~~ -* :py:func:`~pvlib.iotools.get_psm3` no longer incorrectly returns clear-sky - DHI instead of clear-sky GHI when requesting ``ghi_clear``. (:pull:`1819`) -* :py:func:`pvlib.singlediode.bishop88` with ``method='newton'`` no longer - crashes when passed ``pandas.Series`` of length one. - (:issue:`1787`, :pull:`1822`) -* :py:class:`~pvlib.pvsystem.PVSystem` now correctly passes ``n_ar`` module - parameter to :py:func:`pvlib.iam.physical` when this IAM model is specified - or inferred. (:pull:`1832`) - - -Testing -~~~~~~~ -* Added GitHub action to lint file changes with Flake8, replacing Stickler-CI. - (:issue:`776`, :issue:`1722`, :pull:`1723`, :pull:`1786`) - - -Documentation -~~~~~~~~~~~~~ -* Added docstring detail for :py:func:`pvlib.iam.schlick_diffuse`. - (:issue:`1811`, :pull:`1812`) -* Specified that :py:func:`pvlib.singlediode.bishop88`, - :py:func:`pvlib.singlediode.bishop88_i_from_v`, and - :py:func:`pvlib.singlediode.bishop88_v_from_i` parameters ``breakdown_factor``, - ``breakdown_voltage``, and ``breakdown_exp`` should be floats. - (:issue:`1820`, :pull:`1821`) -* Fix and update example in :py:func:`pvlib.pvsystem.retrieve_sam`. - (:issue:`1741`, :pull:`1833`) -* Fix error in :py:func:`pvlib.iotools.get_pvgis_hourly` documentation of ``surface_azimuth``. - (:issue:`1724`, :pull:`1838`) -* Update definition of ``snow_events`` parameter for :py:func:`pvlib.snow.loss_townsend`. - (:issue:`1839`, :pull:`1840`) -* Added gallery example demonstrating how horizon profile data from :py:func:`pvlib.iotools.get_pvgis_horizon`, - can be used to apply horizon shading to time series dni and global poa data. (:pull:`1849`) - - -Contributors -~~~~~~~~~~~~ -* Connor Krening (:ghuser:`ckrening`) -* Adam R. Jensen (:ghuser:`AdamRJensen`) -* Michal Arieli (:ghuser:`MichalArieli`) -* Abigail Jones (:ghuser:`ajonesr`) -* Taos Transue (:ghuser:`reepoi`) -* Echedey Luis (:ghuser:`echedey-ls`) -* Todd Karin (:ghuser:`toddkarin`) -* NativeSci (:ghuser:`nativesci`) -* Anton Driesse (:ghuser:`adriesse`) -* Lukas Grossar (:ghuser:`tongpu`) -* Areeba Turabi (:ghuser:`aturabi`) -* Cliff Hansen (:ghuser:`cwhanse`) - -* Saurabh Aneja (:ghuser:`spaneja`) -* Miroslav Šedivý (:ghuser:`eumiro`) -* kjsauer (:ghuser:`kjsauer`) -* Jules Chéron (:ghuser:`jules-ch`) -* Cliff Hansen (:ghuser:`cwhanse`) -* Will Holmgren (:ghuser:`wholmgren`) -* Mark Mikofski (:ghuser:`mikofski`) -* Kevin Anderson (:ghuser:`kandersolar`) From fdcc9523295f1e3eaaf1cc4606cd72428ac2516b Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 17 Oct 2023 14:14:19 -0600 Subject: [PATCH 47/65] Revert "remove v0.10.2 whatsnew" This reverts commit ed357311b584392714dd8d86b36e64d9efb46349. --- docs/sphinx/source/whatsnew/v0.10.2.rst | 92 +++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/sphinx/source/whatsnew/v0.10.2.rst diff --git a/docs/sphinx/source/whatsnew/v0.10.2.rst b/docs/sphinx/source/whatsnew/v0.10.2.rst new file mode 100644 index 0000000000..1fa0651449 --- /dev/null +++ b/docs/sphinx/source/whatsnew/v0.10.2.rst @@ -0,0 +1,92 @@ +.. _whatsnew_01020: + + +v0.10.2 (September 21, 2023) +---------------------------- + + +Enhancements +~~~~~~~~~~~~ +* Added .pan/.ond reader function :py:func:`pvlib.iotools.read_panond`. (:issue:`1747`, :pull:`1749`) +* Added support for dates to be specified as strings in the iotools get functions: + :py:func:`~pvlib.iotools.get_pvgis_hourly`, :py:func:`~pvlib.iotools.get_cams`, + :py:func:`~pvlib.iotools.get_bsrn`, and :py:func:`~pvlib.iotools.read_midc_raw_data_from_nrel`. + (:pull:`1800`) +* Added support for asymmetric limiting angles in :py:func:`pvlib.tracking.singleaxis` + and :py:class:`~pvlib.pvsystem.SingleAxisTrackerMount`. (:issue:`1777`, :pull:`1809`, :pull:`1852`) +* Added option to infer threshold values for + :py:func:`pvlib.clearsky.detect_clearsky` (:issue:`1808`, :pull:`1784`) +* Added a continuous version of the Erbs diffuse-fraction/decomposition model. + :py:func:`pvlib.irradiance.erbs_driesse` (:issue:`1755`, :pull:`1834`) +* Added :py:func:`~pvlib.iam.interp` option as AOI losses model in + :py:class:`pvlib.modelchain.ModelChain` and + :py:class:`pvlib.pvsystem.PVSystem`. (:issue:`1742`, :pull:`1832`) +* Added :py:func:`~pvlib.iam.convert` and :py:func:`~pvlib.iam.fit` that + convert between IAM models, and that fit an IAM model to data. (:issue:`1824`, :pull:`1827`) + :py:class:`~pvlib.modelchain.ModelChain` and + :py:class:`~pvlib.pvsystem.PVSystem`. (:issue:`1742`, :pull:`1832`) +* :py:class:`~pvlib.pvsystem.PVSystem` objects with a single + :py:class:`~pvlib.pvsystem.Array` can now be created without wrapping the + ``Array`` in a list first. (:issue:`1831`, :pull:`1854`) + + +Bug fixes +~~~~~~~~~ +* :py:func:`~pvlib.iotools.get_psm3` no longer incorrectly returns clear-sky + DHI instead of clear-sky GHI when requesting ``ghi_clear``. (:pull:`1819`) +* :py:func:`pvlib.singlediode.bishop88` with ``method='newton'`` no longer + crashes when passed ``pandas.Series`` of length one. + (:issue:`1787`, :pull:`1822`) +* :py:class:`~pvlib.pvsystem.PVSystem` now correctly passes ``n_ar`` module + parameter to :py:func:`pvlib.iam.physical` when this IAM model is specified + or inferred. (:pull:`1832`) + + +Testing +~~~~~~~ +* Added GitHub action to lint file changes with Flake8, replacing Stickler-CI. + (:issue:`776`, :issue:`1722`, :pull:`1723`, :pull:`1786`) + + +Documentation +~~~~~~~~~~~~~ +* Added docstring detail for :py:func:`pvlib.iam.schlick_diffuse`. + (:issue:`1811`, :pull:`1812`) +* Specified that :py:func:`pvlib.singlediode.bishop88`, + :py:func:`pvlib.singlediode.bishop88_i_from_v`, and + :py:func:`pvlib.singlediode.bishop88_v_from_i` parameters ``breakdown_factor``, + ``breakdown_voltage``, and ``breakdown_exp`` should be floats. + (:issue:`1820`, :pull:`1821`) +* Fix and update example in :py:func:`pvlib.pvsystem.retrieve_sam`. + (:issue:`1741`, :pull:`1833`) +* Fix error in :py:func:`pvlib.iotools.get_pvgis_hourly` documentation of ``surface_azimuth``. + (:issue:`1724`, :pull:`1838`) +* Update definition of ``snow_events`` parameter for :py:func:`pvlib.snow.loss_townsend`. + (:issue:`1839`, :pull:`1840`) +* Added gallery example demonstrating how horizon profile data from :py:func:`pvlib.iotools.get_pvgis_horizon`, + can be used to apply horizon shading to time series dni and global poa data. (:pull:`1849`) + + +Contributors +~~~~~~~~~~~~ +* Connor Krening (:ghuser:`ckrening`) +* Adam R. Jensen (:ghuser:`AdamRJensen`) +* Michal Arieli (:ghuser:`MichalArieli`) +* Abigail Jones (:ghuser:`ajonesr`) +* Taos Transue (:ghuser:`reepoi`) +* Echedey Luis (:ghuser:`echedey-ls`) +* Todd Karin (:ghuser:`toddkarin`) +* NativeSci (:ghuser:`nativesci`) +* Anton Driesse (:ghuser:`adriesse`) +* Lukas Grossar (:ghuser:`tongpu`) +* Areeba Turabi (:ghuser:`aturabi`) +* Cliff Hansen (:ghuser:`cwhanse`) + +* Saurabh Aneja (:ghuser:`spaneja`) +* Miroslav Šedivý (:ghuser:`eumiro`) +* kjsauer (:ghuser:`kjsauer`) +* Jules Chéron (:ghuser:`jules-ch`) +* Cliff Hansen (:ghuser:`cwhanse`) +* Will Holmgren (:ghuser:`wholmgren`) +* Mark Mikofski (:ghuser:`mikofski`) +* Kevin Anderson (:ghuser:`kandersolar`) From 9b1cfd8e3516d61fa7aff27d8ec3eabaeee2a24c Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 17 Oct 2023 14:18:47 -0600 Subject: [PATCH 48/65] put v0.10.2.rst right again --- docs/sphinx/source/whatsnew/v0.10.2.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.10.2.rst b/docs/sphinx/source/whatsnew/v0.10.2.rst index 1fa0651449..3b82d98613 100644 --- a/docs/sphinx/source/whatsnew/v0.10.2.rst +++ b/docs/sphinx/source/whatsnew/v0.10.2.rst @@ -19,10 +19,6 @@ Enhancements * Added a continuous version of the Erbs diffuse-fraction/decomposition model. :py:func:`pvlib.irradiance.erbs_driesse` (:issue:`1755`, :pull:`1834`) * Added :py:func:`~pvlib.iam.interp` option as AOI losses model in - :py:class:`pvlib.modelchain.ModelChain` and - :py:class:`pvlib.pvsystem.PVSystem`. (:issue:`1742`, :pull:`1832`) -* Added :py:func:`~pvlib.iam.convert` and :py:func:`~pvlib.iam.fit` that - convert between IAM models, and that fit an IAM model to data. (:issue:`1824`, :pull:`1827`) :py:class:`~pvlib.modelchain.ModelChain` and :py:class:`~pvlib.pvsystem.PVSystem`. (:issue:`1742`, :pull:`1832`) * :py:class:`~pvlib.pvsystem.PVSystem` objects with a single @@ -80,8 +76,6 @@ Contributors * Anton Driesse (:ghuser:`adriesse`) * Lukas Grossar (:ghuser:`tongpu`) * Areeba Turabi (:ghuser:`aturabi`) -* Cliff Hansen (:ghuser:`cwhanse`) - * Saurabh Aneja (:ghuser:`spaneja`) * Miroslav Šedivý (:ghuser:`eumiro`) * kjsauer (:ghuser:`kjsauer`) From 38bfb582309d2f782993a80888177eaa040326a2 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 17 Oct 2023 15:37:14 -0600 Subject: [PATCH 49/65] require scipy>=1.5.0 --- pvlib/iam.py | 143 +++++++++++++++++++++++----------------- pvlib/tests/conftest.py | 7 ++ pvlib/tests/test_iam.py | 16 ++++- 3 files changed, 104 insertions(+), 62 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index b7e2fb5ab0..ff048708f1 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -11,6 +11,7 @@ import numpy as np import pandas as pd import functools +import warnings from scipy.optimize import minimize from pvlib.tools import cosd, sind, acosd @@ -1093,6 +1094,18 @@ def _process_return(target_name, optimize_result): return target_params +def _min_scipy(): + '''convert and fit require scipy>=1.5.0 + ''' + from scipy import __version__ + major, minor = __version__.split('.')[:2] + if (int(major) >= 1) & (int(minor) >= 5): + return True + else: + warnings.warn('iam.convert and iam.fit require scipy>=1.5.0') + return False + + def convert(source_name, source_params, target_name, weight=None, fix_n=True, xtol=None): """ @@ -1166,42 +1179,46 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, pvlib.iam.martin_ruiz pvlib.iam.physical """ - - source = _get_model(source_name) - target = _get_model(target_name) - - # if no options were passed in, we will use the default arguments - if weight is None: - weight = _sin_weight - - aoi = np.linspace(0, 90, 100) - _check_params(source_name, source_params) - source_iam = source(aoi, **source_params) - - if target_name == "physical": - # we can do some special set-up to improve the fit when the - # target model is physical - if source_name == "ashrae": - residual_function, guess, bounds = \ - _ashrae_to_physical(aoi, source_iam, weight, fix_n, - source_params['b']) - elif source_name == "martin_ruiz": - residual_function, guess, bounds = \ - _martin_ruiz_to_physical(aoi, source_iam, weight, - source_params['a_r']) + if _min_scipy(): + + source = _get_model(source_name) + target = _get_model(target_name) + + # if no options were passed in, we will use the default arguments + if weight is None: + weight = _sin_weight + + aoi = np.linspace(0, 90, 100) + _check_params(source_name, source_params) + source_iam = source(aoi, **source_params) + + if target_name == "physical": + # we can do some special set-up to improve the fit when the + # target model is physical + if source_name == "ashrae": + residual_function, guess, bounds = \ + _ashrae_to_physical(aoi, source_iam, weight, fix_n, + source_params['b']) + elif source_name == "martin_ruiz": + residual_function, guess, bounds = \ + _martin_ruiz_to_physical(aoi, source_iam, weight, + source_params['a_r']) + + else: + # otherwise, target model is ashrae or martin_ruiz, and scipy + # does fine without any special set-up + bounds = [(1e-04, 1)] + guess = [1e-03] + + def residual_function(target_param): + return _residual(aoi, source_iam, target, target_param, weight) + + optimize_result = _minimize(residual_function, guess, bounds, xtol=xtol) + + return _process_return(target_name, optimize_result) else: - # otherwise, target model is ashrae or martin_ruiz, and scipy - # does fine without any special set-up - bounds = [(1e-04, 1)] - guess = [1e-03] - - def residual_function(target_param): - return _residual(aoi, source_iam, target, target_param, weight) - - optimize_result = _minimize(residual_function, guess, bounds, xtol=xtol) - - return _process_return(target_name, optimize_result) + return {} def fit(measured_aoi, measured_iam, target_name, weight=None, xtol=None): @@ -1255,31 +1272,35 @@ def fit(measured_aoi, measured_iam, target_name, weight=None, xtol=None): pvlib.iam.martin_ruiz pvlib.iam.physical """ - - target = _get_model(target_name) - - # if no options were passed in, we will use the default arguments - if weight is None: - weight = _sin_weight - - if target_name == "physical": - bounds = [(0, 0.08), (1, 2)] - guess = [0.002, 1+1e-08] - - def residual_function(target_params): - L, n = target_params - return _residual(measured_aoi, measured_iam, target, [n, 4, L], - weight) - - # otherwise, target_name is martin_ruiz or ashrae + if _min_scipy(): + + target = _get_model(target_name) + + # if no options were passed in, we will use the default arguments + if weight is None: + weight = _sin_weight + + if target_name == "physical": + bounds = [(0, 0.08), (1, 2)] + guess = [0.002, 1+1e-08] + + def residual_function(target_params): + L, n = target_params + return _residual(measured_aoi, measured_iam, target, [n, 4, L], + weight) + + # otherwise, target_name is martin_ruiz or ashrae + else: + bounds = [(1e-08, 1)] + guess = [0.05] + + def residual_function(target_param): + return _residual(measured_aoi, measured_iam, target, target_param, + weight) + + optimize_result = _minimize(residual_function, guess, bounds, xtol) + + return _process_return(target_name, optimize_result) else: - bounds = [(1e-08, 1)] - guess = [1e-08] - - def residual_function(target_param): - return _residual(measured_aoi, measured_iam, target, target_param, - weight) - - optimize_result = _minimize(residual_function, guess, bounds, xtol) - - return _process_return(target_name, optimize_result) + return {} + \ No newline at end of file diff --git a/pvlib/tests/conftest.py b/pvlib/tests/conftest.py index 15b0cd70e8..db00f89ed0 100644 --- a/pvlib/tests/conftest.py +++ b/pvlib/tests/conftest.py @@ -160,6 +160,13 @@ def has_numba(): requires_pysam = pytest.mark.skipif(not has_pysam, reason="requires PySAM") +from pvlib.iam import _min_scipy +iam_scipy_ok = _min_scipy() + +requires_scipy_150 = pytest.mark.skipif(not iam_scipy_ok, + reason="requires scipy>=1.5.0") + + @pytest.fixture() def golden(): return Location(39.742476, -105.1786, 'America/Denver', 1830.14) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 78b6842a8a..a7710fc408 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -8,7 +8,7 @@ import pandas as pd import pytest -from .conftest import assert_series_equal +from .conftest import (assert_series_equal, requires_scipy_150) from numpy.testing import assert_allclose from pvlib import iam as _iam @@ -397,6 +397,7 @@ def test_schlick_diffuse(): rtol=1e-6) +@requires_scipy_150 @pytest.mark.parametrize('source,source_params,target,expected', [ ('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'martin_ruiz', {'a_r': 0.173972}), @@ -415,6 +416,7 @@ def test_convert(source, source_params, target, expected): assert_allclose(exp, tar, rtol=1e-05) +@requires_scipy_150 @pytest.mark.parametrize('source,source_params', [ ('ashrae', {'b': 0.15}), ('ashrae', {'b': 0.05}), @@ -427,6 +429,7 @@ def test_convert_recover(source, source_params): assert_allclose(exp, tar, rtol=1e-05) +@requires_scipy_150 def test_convert_ashrae_physical_no_fix_n(): # convert ashrae to physical, without fixing n source_params = {'b': 0.15} @@ -438,6 +441,7 @@ def test_convert_ashrae_physical_no_fix_n(): assert_allclose(exp, tar, rtol=1e-05) +@requires_scipy_150 def test_convert_reverse_order_in_physical(): source_params = {'a_r': 0.25} target_params = _iam.convert('martin_ruiz', source_params, 'physical') @@ -447,6 +451,7 @@ def test_convert_reverse_order_in_physical(): assert_allclose(exp, tar, rtol=1e-5) +@requires_scipy_150 def test_convert_xtol(): source_params = {'b': 0.15} target_params = _iam.convert('ashrae', source_params, 'physical', @@ -457,6 +462,7 @@ def test_convert_xtol(): assert_allclose(exp, tar, rtol=1e-10) +@requires_scipy_150 def test_convert_custom_weight_func(): aoi = np.linspace(0, 90, 100) @@ -480,16 +486,19 @@ def scaled_weight(aoi): assert np.isclose(expected_min_res, actual_min_res, atol=1e-08) +@requires_scipy_150 def test_convert_model_not_implemented(): with pytest.raises(NotImplementedError, match='model has not been'): _iam.convert('ashrae', {'b': 0.1}, 'foo') +@requires_scipy_150 def test_convert_wrong_model_parameters(): with pytest.raises(ValueError, match='model was expecting'): _iam.convert('ashrae', {'B': 0.1}, 'physical') +@requires_scipy_150 def test_convert__minimize_fails(): # to make scipy.optimize.minimize fail, we'll pass in a nonsense # weight function that only outputs nans @@ -500,6 +509,7 @@ def nan_weight(aoi): _iam.convert('ashrae', {'b': 0.1}, 'physical', weight=nan_weight) +@requires_scipy_150 def test_fit(): aoi = np.linspace(0, 90, 5) perturb = np.array([1.2, 1.01, 0.95, 1, 0.98]) @@ -513,6 +523,7 @@ def test_fit(): assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) +@requires_scipy_150 def test_fit_custom_weight_func(): # define custom weight function that takes in other arguments def scaled_weight(aoi): @@ -531,11 +542,13 @@ def scaled_weight(aoi): assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) +@requires_scipy_150 def test_fit_model_not_implemented(): with pytest.raises(NotImplementedError, match='model has not been'): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'foo') +@requires_scipy_150 def test_fit__minimize_fails(): # to make scipy.optimize.minimize fail, we'll pass in a nonsense # weight function that only outputs nans @@ -547,6 +560,7 @@ def nan_weight(aoi): weight=nan_weight) +@requires_scipy_150 def test__residual_zero_outside_range(): # check that _residual annihilates any weights that come from aoi # outside of interval [0, 90] (this is important for `iam.fit`, when From 04121de155493b4f119fd2014bf032e7b1261852 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 17 Oct 2023 15:57:22 -0600 Subject: [PATCH 50/65] linter --- pvlib/iam.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index ff048708f1..a4b6a45559 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1183,15 +1183,15 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, source = _get_model(source_name) target = _get_model(target_name) - + # if no options were passed in, we will use the default arguments if weight is None: weight = _sin_weight - + aoi = np.linspace(0, 90, 100) _check_params(source_name, source_params) source_iam = source(aoi, **source_params) - + if target_name == "physical": # we can do some special set-up to improve the fit when the # target model is physical @@ -1203,18 +1203,19 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, residual_function, guess, bounds = \ _martin_ruiz_to_physical(aoi, source_iam, weight, source_params['a_r']) - + else: # otherwise, target model is ashrae or martin_ruiz, and scipy # does fine without any special set-up bounds = [(1e-04, 1)] guess = [1e-03] - + def residual_function(target_param): return _residual(aoi, source_iam, target, target_param, weight) - - optimize_result = _minimize(residual_function, guess, bounds, xtol=xtol) - + + optimize_result = _minimize(residual_function, guess, bounds, + xtol=xtol) + return _process_return(target_name, optimize_result) else: @@ -1275,32 +1276,31 @@ def fit(measured_aoi, measured_iam, target_name, weight=None, xtol=None): if _min_scipy(): target = _get_model(target_name) - + # if no options were passed in, we will use the default arguments if weight is None: weight = _sin_weight - + if target_name == "physical": bounds = [(0, 0.08), (1, 2)] guess = [0.002, 1+1e-08] - + def residual_function(target_params): L, n = target_params return _residual(measured_aoi, measured_iam, target, [n, 4, L], weight) - + # otherwise, target_name is martin_ruiz or ashrae else: bounds = [(1e-08, 1)] guess = [0.05] - + def residual_function(target_param): - return _residual(measured_aoi, measured_iam, target, target_param, - weight) - + return _residual(measured_aoi, measured_iam, target, + target_param, weight) + optimize_result = _minimize(residual_function, guess, bounds, xtol) - + return _process_return(target_name, optimize_result) else: return {} - \ No newline at end of file From 69cd00acec96eb2b08fa0d47716b7e6eb508f6e1 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 17 Oct 2023 16:04:24 -0600 Subject: [PATCH 51/65] linter --- pvlib/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/tests/conftest.py b/pvlib/tests/conftest.py index db00f89ed0..522ca17e74 100644 --- a/pvlib/tests/conftest.py +++ b/pvlib/tests/conftest.py @@ -10,6 +10,7 @@ import pvlib from pvlib.location import Location +from pvlib.iam import _min_scipy pvlib_base_version = Version(Version(pvlib.__version__).base_version) @@ -160,7 +161,6 @@ def has_numba(): requires_pysam = pytest.mark.skipif(not has_pysam, reason="requires PySAM") -from pvlib.iam import _min_scipy iam_scipy_ok = _min_scipy() requires_scipy_150 = pytest.mark.skipif(not iam_scipy_ok, From e5cd24b471a52c9223f5b41d7390d22f0991827c Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 28 Nov 2023 09:01:58 -0700 Subject: [PATCH 52/65] suggestions from review --- .../reflections/plot_convert_iam_models.py | 2 +- .../reflections/plot_fit_iam_models.py | 2 +- pvlib/iam.py | 19 +++++++++---------- pvlib/tests/test_iam.py | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 8a8331b302..3a5c1c1490 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -12,7 +12,7 @@ # An incidence angle modifier (IAM) model quantifies the fraction of direct # irradiance is that is reflected away from a module's surface. Three popular # IAM models are Martin-Ruiz :py:func:`~pvlib.iam.martin_ruiz`, physical -# :py:func:`~pvlib.iam.physical`, and ASHRAE py:func:`~pvlib.iam.ashrae`. +# :py:func:`~pvlib.iam.physical`, and ASHRAE :py:func:`~pvlib.iam.ashrae`. # Each model requires one or more parameters. # # Here, we show how to use diff --git a/docs/examples/reflections/plot_fit_iam_models.py b/docs/examples/reflections/plot_fit_iam_models.py index 28da2fd6bf..7bdd00d035 100644 --- a/docs/examples/reflections/plot_fit_iam_models.py +++ b/docs/examples/reflections/plot_fit_iam_models.py @@ -11,7 +11,7 @@ # An incidence angle modifier (IAM) model quantifies the fraction of direct # irradiance is that is reflected away from a module's surface. Three popular # IAM models are Martin-Ruiz :py:func:`~pvlib.iam.martin_ruiz`, physical -# :py:func:`~pvlib.iam.physical`, and ASHRAE py:func:`~pvlib.iam.ashrae`. +# :py:func:`~pvlib.iam.physical`, and ASHRAE :py:func:`~pvlib.iam.ashrae`. # Each model requires one or more parameters. # # Here, we show how to use diff --git a/pvlib/iam.py b/pvlib/iam.py index a4b6a45559..12cd02a9f1 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -989,7 +989,7 @@ def _residual(aoi, source_iam, target, target_params, return np.sum(diff * weights) -def _get_ashrae_int(b): +def _get_ashrae_intercept(b): # find x-intercept of ashrae model return acosd(b / (1 + b)) @@ -999,7 +999,7 @@ def _ashrae_to_physical(aoi, ashrae_iam, weight, fix_n, b): # the ashrae model has an x-intercept less than 90 # we solve for this intercept, and fix n so that the physical # model will have the same x-intercept - intercept = _get_ashrae_int(b) + intercept = _get_ashrae_intercept(b) n = sind(intercept) # with n fixed, we will optimize for L (recall that K and L always @@ -1222,10 +1222,9 @@ def residual_function(target_param): return {} -def fit(measured_aoi, measured_iam, target_name, weight=None, xtol=None): +def fit(measured_aoi, measured_iam, model_name, weight=None, xtol=None): """ - Finds parameters for target model that best fit the - measured data. + Find model parameters that best fit the data. Parameters ---------- @@ -1236,8 +1235,8 @@ def fit(measured_aoi, measured_iam, target_name, weight=None, xtol=None): measured_iam : array-like IAM values. [unitless] - target_name : str - Name of the target model. Must be ``'ashrae'``, ``'martin_ruiz'``, + model_name : str + Name of the model to be fit. Must be ``'ashrae'``, ``'martin_ruiz'``, or ``'physical'``. weight : function, optional @@ -1275,13 +1274,13 @@ def fit(measured_aoi, measured_iam, target_name, weight=None, xtol=None): """ if _min_scipy(): - target = _get_model(target_name) + target = _get_model(model_name) # if no options were passed in, we will use the default arguments if weight is None: weight = _sin_weight - if target_name == "physical": + if model_name == "physical": bounds = [(0, 0.08), (1, 2)] guess = [0.002, 1+1e-08] @@ -1301,6 +1300,6 @@ def residual_function(target_param): optimize_result = _minimize(residual_function, guess, bounds, xtol) - return _process_return(target_name, optimize_result) + return _process_return(model_name, optimize_result) else: return {} diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index a7710fc408..20e43d2509 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -8,7 +8,7 @@ import pandas as pd import pytest -from .conftest import (assert_series_equal, requires_scipy_150) +from .conftest import assert_series_equal, requires_scipy_150 from numpy.testing import assert_allclose from pvlib import iam as _iam From 6ce34e431d5c2505524b68f5a43077943917cbe4 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 28 Nov 2023 09:15:48 -0700 Subject: [PATCH 53/65] add reference --- pvlib/iam.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 12cd02a9f1..d1c1fa8b76 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1170,7 +1170,9 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, References ---------- - .. [1] TODO + .. [1] Jones, A. R., Hansen, C. W., Anderson, K. S. Parameter estimation + for incidence angle modifier models for photovoltaic modules. Sandia + report SAND2023-13944 (2023). See Also -------- @@ -1263,7 +1265,9 @@ def fit(measured_aoi, measured_iam, model_name, weight=None, xtol=None): References ---------- - .. [1] TODO + .. [1] Jones, A. R., Hansen, C. W., Anderson, K. S. Parameter estimation + for incidence angle modifier models for photovoltaic modules. Sandia + report SAND2023-13944 (2023). See Also -------- From 743931d0feb3f4df351b21d3f4725f94641cffbe Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 28 Nov 2023 10:12:43 -0700 Subject: [PATCH 54/65] edits to examples --- .../reflections/plot_convert_iam_models.py | 17 ++++++++++++----- .../examples/reflections/plot_fit_iam_models.py | 6 +++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 3a5c1c1490..9478b9290b 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -4,21 +4,21 @@ ==================== Illustrates how to convert from one IAM model to a different model using -:py:func:`~pvlib.iam.convert` +:py:func:`~pvlib.iam.convert`. """ # %% # An incidence angle modifier (IAM) model quantifies the fraction of direct -# irradiance is that is reflected away from a module's surface. Three popular +# irradiance that is reflected away from a module's surface. Three popular # IAM models are Martin-Ruiz :py:func:`~pvlib.iam.martin_ruiz`, physical # :py:func:`~pvlib.iam.physical`, and ASHRAE :py:func:`~pvlib.iam.ashrae`. # Each model requires one or more parameters. # # Here, we show how to use # :py:func:`~pvlib.iam.convert` to estimate parameters for a desired target -# IAM model from a source IAM model. Model conversion requires a weight -# function that assigns more influence to some AOI values than others. +# IAM model from a source IAM model. Model conversion uses a weight +# function that can assign more influence to some AOI values than others. # We illustrate how to provide a custom weight function to # :py:func:`~pvlib.iam.convert`. @@ -151,4 +151,11 @@ def weight_function(aoi): # Finding the right weight function and parameters in such cases will require # knowing where you want the target model to be more accurate. The default # weight function was chosen because it yielded IAM models that produce -# similar annual insolation for a simulated PV system TODO add reference. +# similar annual insolation for a simulated PV system. + +# %% +# Reference +# --------- +# .. [1] Jones, A. R., Hansen, C. W., Anderson, K. S. Parameter estimation +# for incidence angle modifier models for photovoltaic modules. Sandia +# report SAND2023-13944 (2023). diff --git a/docs/examples/reflections/plot_fit_iam_models.py b/docs/examples/reflections/plot_fit_iam_models.py index 7bdd00d035..1b2dbd1474 100644 --- a/docs/examples/reflections/plot_fit_iam_models.py +++ b/docs/examples/reflections/plot_fit_iam_models.py @@ -3,7 +3,7 @@ IAM Model Fitting ================================ -Illustrates how to fit an IAM model to data using :py:func:`~pvlib.iam.fit` +Illustrates how to fit an IAM model to data using :py:func:`~pvlib.iam.fit`. """ @@ -17,7 +17,7 @@ # Here, we show how to use # :py:func:`~pvlib.iam.fit` to estimate a model's parameters from data. # -# Model fitting require a weight function that assigns +# Model fitting require a weight function that can assign # more influence to some AOI values than others. We illustrate how to provide # a custom weight function to :py:func:`~pvlib.iam.fit`. @@ -64,7 +64,7 @@ # The weight function # ------------------- # :py:func:`pvlib.iam.fit` uses a weight function when computing residuals -# between the model abd data. The default weight +# between the model and data. The default weight # function is :math:`1 - \sin(aoi)`. We can instead pass a custom weight # function to :py:func:`pvlib.iam.fit`. # From fe9a39c0957ac5a230e7866bb00c816dc484945e Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 28 Nov 2023 14:03:33 -0700 Subject: [PATCH 55/65] add note to convert --- pvlib/iam.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pvlib/iam.py b/pvlib/iam.py index d1c1fa8b76..610373fc47 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1168,6 +1168,15 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, If target model is ``'physical'``, the dictionary will contain the keys ``'n'``, ``'K'``, and ``'L'``. + Note + ---- + Target model parameters are determined by minimizing + + .. math:: + + \Sum_{\theta=0}^90 weight \times \left(\theta \right) + * \left(\|source\left(\theta\right) - target\left(\theta\right) \|) + References ---------- .. [1] Jones, A. R., Hansen, C. W., Anderson, K. S. Parameter estimation From d56cbcf76e42b9dd3ba47e0974fe4669760c534a Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 28 Nov 2023 14:11:15 -0700 Subject: [PATCH 56/65] edit note on convert --- pvlib/iam.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 610373fc47..5d6959f0c1 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1174,8 +1174,9 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, .. math:: - \Sum_{\theta=0}^90 weight \times \left(\theta \right) - * \left(\|source\left(\theta\right) - target\left(\theta\right) \|) + \Sum_{\theta=0}^90 weight \times \\left(\theta \\right) + * \\left(\\| source \\left(\theta \\right) + - target \\left(\theta \\right) \\| \\right) References ---------- From 2a0b81552f94770e692dd4363309d7f8ff4755b1 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 28 Nov 2023 14:42:02 -0700 Subject: [PATCH 57/65] edit both notes --- pvlib/iam.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 5d6959f0c1..6f339147a7 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1174,9 +1174,8 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, .. math:: - \Sum_{\theta=0}^90 weight \times \\left(\theta \\right) - * \\left(\\| source \\left(\theta \\right) - - target \\left(\theta \\right) \\| \\right) + \\Sum_{\\theta=0}^{90} weight \\left(\\theta \\right) \\times + \\| source \\left(\\theta \\right) - target \\left(\\theta \\right) \\| References ---------- @@ -1279,6 +1278,15 @@ def fit(measured_aoi, measured_iam, model_name, weight=None, xtol=None): for incidence angle modifier models for photovoltaic modules. Sandia report SAND2023-13944 (2023). + Note + ---- + Model parameters are determined by minimizing + + .. math:: + + \\Sum_{measured AOI} weight \\left( AOI \\right) \\times + \\| measured IAM \\left( AOI \\right) - model \\left(\\AOI \\right) \\| + See Also -------- pvlib.iam.convert From 4e165a0d328c20abb653cb7088a805cc57882696 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 28 Nov 2023 16:37:07 -0700 Subject: [PATCH 58/65] polish the notes --- pvlib/iam.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 6f339147a7..3648ca9b1f 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1174,7 +1174,7 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, .. math:: - \\Sum_{\\theta=0}^{90} weight \\left(\\theta \\right) \\times + \Sum_{\\theta=0}^{90} weight \\left(\\theta \\right) \\times \\| source \\left(\\theta \\right) - target \\left(\\theta \\right) \\| References @@ -1284,8 +1284,8 @@ def fit(measured_aoi, measured_iam, model_name, weight=None, xtol=None): .. math:: - \\Sum_{measured AOI} weight \\left( AOI \\right) \\times - \\| measured IAM \\left( AOI \\right) - model \\left(\\AOI \\right) \\| + \Sum_{measured AOI} weight \\left( AOI \\right) \\times + \\| measured IAM \\left( AOI \\right) - model \\left( AOI \\right) \\| See Also -------- From d3d8cfdba91ee0c2a8d2d34d840eebc34593d472 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 28 Nov 2023 16:49:15 -0700 Subject: [PATCH 59/65] sum not Sum --- pvlib/iam.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 3648ca9b1f..bfa5967c95 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1174,7 +1174,7 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, .. math:: - \Sum_{\\theta=0}^{90} weight \\left(\\theta \\right) \\times + \sum_{\\theta=0}^{90} weight \\left(\\theta \\right) \\times \\| source \\left(\\theta \\right) - target \\left(\\theta \\right) \\| References @@ -1284,7 +1284,7 @@ def fit(measured_aoi, measured_iam, model_name, weight=None, xtol=None): .. math:: - \Sum_{measured AOI} weight \\left( AOI \\right) \\times + \sum_{measured AOI} weight \\left( AOI \\right) \\times \\| measured IAM \\left( AOI \\right) - model \\left( AOI \\right) \\| See Also From 313386cbca975f52bdf472909a168021c7c86d6d Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Wed, 29 Nov 2023 11:12:23 -0700 Subject: [PATCH 60/65] edits --- pvlib/iam.py | 49 +++++++++++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index bfa5967c95..507a4a12f8 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -951,8 +951,8 @@ def _get_model(model_name): try: model = model_dict[model_name] except KeyError: - raise NotImplementedError(f"The {model_name} model has not been \ - implemented") + raise NotImplementedError(f"The {model_name} model has not been " + "implemented") return model @@ -962,9 +962,9 @@ def _check_params(model_name, params): # belong to the model exp_params = _IAM_MODEL_PARAMS[model_name] if set(params.keys()) != exp_params: - raise ValueError(f"The {model_name} model was expecting to be passed \ - {', '.join(list(exp_params))}, but \ - was handed {', '.join(list(params.keys()))}") + raise ValueError(f"The {model_name} model was expecting to be passed " + "{', '.join(list(exp_params))}, but " + "was handed {', '.join(list(params.keys()))}") def _sin_weight(aoi): @@ -1106,8 +1106,8 @@ def _min_scipy(): return False -def convert(source_name, source_params, target_name, weight=None, fix_n=True, - xtol=None): +def convert(source_name, source_params, target_name, weight=_sin_weight, + fix_n=True, xtol=None): """ Convert a source IAM model to a target IAM model. @@ -1134,9 +1134,9 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, ``'physical'``. weight : function, optional - A single-argument function of AOI that calculates weights for the - residuals between models. Must return a float or an array-like object. - The default weight function is :math:`f(aoi) = 1 - sin(aoi)`. + A single-argument function of AOI (degrees) that calculates weights for + the residuals between models. Must return a float or an array-like + object. The default weight function is :math:`f(aoi) = 1 - sin(aoi)`. fix_n : bool, default True A flag to determine which method is used when converting from the @@ -1174,9 +1174,11 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, .. math:: - \sum_{\\theta=0}^{90} weight \\left(\\theta \\right) \\times + \\sum_{\\theta=0}^{90} weight \\left(\\theta \\right) \\times \\| source \\left(\\theta \\right) - target \\left(\\theta \\right) \\| - + + The sum is over :math:`\\theta = 0, 1, 2, ..., 90`. + References ---------- .. [1] Jones, A. R., Hansen, C. W., Anderson, K. S. Parameter estimation @@ -1195,10 +1197,6 @@ def convert(source_name, source_params, target_name, weight=None, fix_n=True, source = _get_model(source_name) target = _get_model(target_name) - # if no options were passed in, we will use the default arguments - if weight is None: - weight = _sin_weight - aoi = np.linspace(0, 90, 100) _check_params(source_name, source_params) source_iam = source(aoi, **source_params) @@ -1233,7 +1231,7 @@ def residual_function(target_param): return {} -def fit(measured_aoi, measured_iam, model_name, weight=None, xtol=None): +def fit(measured_aoi, measured_iam, model_name, weight=_sin_weight, xtol=None): """ Find model parameters that best fit the data. @@ -1251,9 +1249,9 @@ def fit(measured_aoi, measured_iam, model_name, weight=None, xtol=None): or ``'physical'``. weight : function, optional - A single-argument function of AOI that calculates weights for the - residuals between models. Must return a float or an array-like object. - The default weight function is :math:`f(aoi) = 1 - sin(aoi)`. + A single-argument function of AOI (degrees) that calculates weights for + the residuals between models. Must return a float or an array-like + object. The default weight function is :math:`f(aoi) = 1 - sin(aoi)`. xtol : float, optional Passed to scipy.optimize.minimize. @@ -1284,8 +1282,11 @@ def fit(measured_aoi, measured_iam, model_name, weight=None, xtol=None): .. math:: - \sum_{measured AOI} weight \\left( AOI \\right) \\times - \\| measured IAM \\left( AOI \\right) - model \\left( AOI \\right) \\| + \\sum_{AOI} weight \\left( AOI \\right) \\times + \\| IAM \\left( AOI \\right) - model \\left( AOI \\right) \\| + + The sum is over ``measured_aoi`` and :math:`IAM \\left( AOI \\right)` + is ``measured_IAM``. See Also -------- @@ -1298,10 +1299,6 @@ def fit(measured_aoi, measured_iam, model_name, weight=None, xtol=None): target = _get_model(model_name) - # if no options were passed in, we will use the default arguments - if weight is None: - weight = _sin_weight - if model_name == "physical": bounds = [(0, 0.08), (1, 2)] guess = [0.002, 1+1e-08] From 32aa64ac9e18474a3a25fd9fabe05665b3d48600 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Wed, 29 Nov 2023 14:53:34 -0700 Subject: [PATCH 61/65] remove test for scipy --- pvlib/iam.py | 110 ++++++++++++++++------------------------ pvlib/tests/conftest.py | 8 +-- pvlib/tests/test_iam.py | 16 +----- 3 files changed, 47 insertions(+), 87 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 507a4a12f8..2413607ea8 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1094,18 +1094,6 @@ def _process_return(target_name, optimize_result): return target_params -def _min_scipy(): - '''convert and fit require scipy>=1.5.0 - ''' - from scipy import __version__ - major, minor = __version__.split('.')[:2] - if (int(major) >= 1) & (int(minor) >= 5): - return True - else: - warnings.warn('iam.convert and iam.fit require scipy>=1.5.0') - return False - - def convert(source_name, source_params, target_name, weight=_sin_weight, fix_n=True, xtol=None): """ @@ -1192,43 +1180,38 @@ def convert(source_name, source_params, target_name, weight=_sin_weight, pvlib.iam.martin_ruiz pvlib.iam.physical """ - if _min_scipy(): - - source = _get_model(source_name) - target = _get_model(target_name) - - aoi = np.linspace(0, 90, 100) - _check_params(source_name, source_params) - source_iam = source(aoi, **source_params) - - if target_name == "physical": - # we can do some special set-up to improve the fit when the - # target model is physical - if source_name == "ashrae": - residual_function, guess, bounds = \ - _ashrae_to_physical(aoi, source_iam, weight, fix_n, - source_params['b']) - elif source_name == "martin_ruiz": - residual_function, guess, bounds = \ - _martin_ruiz_to_physical(aoi, source_iam, weight, - source_params['a_r']) - - else: - # otherwise, target model is ashrae or martin_ruiz, and scipy - # does fine without any special set-up - bounds = [(1e-04, 1)] - guess = [1e-03] + source = _get_model(source_name) + target = _get_model(target_name) + + aoi = np.linspace(0, 90, 100) + _check_params(source_name, source_params) + source_iam = source(aoi, **source_params) + + if target_name == "physical": + # we can do some special set-up to improve the fit when the + # target model is physical + if source_name == "ashrae": + residual_function, guess, bounds = \ + _ashrae_to_physical(aoi, source_iam, weight, fix_n, + source_params['b']) + elif source_name == "martin_ruiz": + residual_function, guess, bounds = \ + _martin_ruiz_to_physical(aoi, source_iam, weight, + source_params['a_r']) - def residual_function(target_param): - return _residual(aoi, source_iam, target, target_param, weight) + else: + # otherwise, target model is ashrae or martin_ruiz, and scipy + # does fine without any special set-up + bounds = [(1e-04, 1)] + guess = [1e-03] - optimize_result = _minimize(residual_function, guess, bounds, - xtol=xtol) + def residual_function(target_param): + return _residual(aoi, source_iam, target, target_param, weight) - return _process_return(target_name, optimize_result) + optimize_result = _minimize(residual_function, guess, bounds, + xtol=xtol) - else: - return {} + return _process_return(target_name, optimize_result) def fit(measured_aoi, measured_iam, model_name, weight=_sin_weight, xtol=None): @@ -1295,30 +1278,27 @@ def fit(measured_aoi, measured_iam, model_name, weight=_sin_weight, xtol=None): pvlib.iam.martin_ruiz pvlib.iam.physical """ - if _min_scipy(): + target = _get_model(model_name) - target = _get_model(model_name) + if model_name == "physical": + bounds = [(0, 0.08), (1, 2)] + guess = [0.002, 1+1e-08] - if model_name == "physical": - bounds = [(0, 0.08), (1, 2)] - guess = [0.002, 1+1e-08] + def residual_function(target_params): + L, n = target_params + return _residual(measured_aoi, measured_iam, target, [n, 4, L], + weight) - def residual_function(target_params): - L, n = target_params - return _residual(measured_aoi, measured_iam, target, [n, 4, L], - weight) + # otherwise, target_name is martin_ruiz or ashrae + else: + bounds = [(1e-08, 1)] + guess = [0.05] - # otherwise, target_name is martin_ruiz or ashrae - else: - bounds = [(1e-08, 1)] - guess = [0.05] + def residual_function(target_param): + return _residual(measured_aoi, measured_iam, target, + target_param, weight) - def residual_function(target_param): - return _residual(measured_aoi, measured_iam, target, - target_param, weight) + optimize_result = _minimize(residual_function, guess, bounds, xtol) - optimize_result = _minimize(residual_function, guess, bounds, xtol) + return _process_return(model_name, optimize_result) - return _process_return(model_name, optimize_result) - else: - return {} diff --git a/pvlib/tests/conftest.py b/pvlib/tests/conftest.py index 522ca17e74..f579ef45f2 100644 --- a/pvlib/tests/conftest.py +++ b/pvlib/tests/conftest.py @@ -10,7 +10,7 @@ import pvlib from pvlib.location import Location -from pvlib.iam import _min_scipy + pvlib_base_version = Version(Version(pvlib.__version__).base_version) @@ -161,12 +161,6 @@ def has_numba(): requires_pysam = pytest.mark.skipif(not has_pysam, reason="requires PySAM") -iam_scipy_ok = _min_scipy() - -requires_scipy_150 = pytest.mark.skipif(not iam_scipy_ok, - reason="requires scipy>=1.5.0") - - @pytest.fixture() def golden(): return Location(39.742476, -105.1786, 'America/Denver', 1830.14) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 20e43d2509..78b6842a8a 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -8,7 +8,7 @@ import pandas as pd import pytest -from .conftest import assert_series_equal, requires_scipy_150 +from .conftest import assert_series_equal from numpy.testing import assert_allclose from pvlib import iam as _iam @@ -397,7 +397,6 @@ def test_schlick_diffuse(): rtol=1e-6) -@requires_scipy_150 @pytest.mark.parametrize('source,source_params,target,expected', [ ('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'martin_ruiz', {'a_r': 0.173972}), @@ -416,7 +415,6 @@ def test_convert(source, source_params, target, expected): assert_allclose(exp, tar, rtol=1e-05) -@requires_scipy_150 @pytest.mark.parametrize('source,source_params', [ ('ashrae', {'b': 0.15}), ('ashrae', {'b': 0.05}), @@ -429,7 +427,6 @@ def test_convert_recover(source, source_params): assert_allclose(exp, tar, rtol=1e-05) -@requires_scipy_150 def test_convert_ashrae_physical_no_fix_n(): # convert ashrae to physical, without fixing n source_params = {'b': 0.15} @@ -441,7 +438,6 @@ def test_convert_ashrae_physical_no_fix_n(): assert_allclose(exp, tar, rtol=1e-05) -@requires_scipy_150 def test_convert_reverse_order_in_physical(): source_params = {'a_r': 0.25} target_params = _iam.convert('martin_ruiz', source_params, 'physical') @@ -451,7 +447,6 @@ def test_convert_reverse_order_in_physical(): assert_allclose(exp, tar, rtol=1e-5) -@requires_scipy_150 def test_convert_xtol(): source_params = {'b': 0.15} target_params = _iam.convert('ashrae', source_params, 'physical', @@ -462,7 +457,6 @@ def test_convert_xtol(): assert_allclose(exp, tar, rtol=1e-10) -@requires_scipy_150 def test_convert_custom_weight_func(): aoi = np.linspace(0, 90, 100) @@ -486,19 +480,16 @@ def scaled_weight(aoi): assert np.isclose(expected_min_res, actual_min_res, atol=1e-08) -@requires_scipy_150 def test_convert_model_not_implemented(): with pytest.raises(NotImplementedError, match='model has not been'): _iam.convert('ashrae', {'b': 0.1}, 'foo') -@requires_scipy_150 def test_convert_wrong_model_parameters(): with pytest.raises(ValueError, match='model was expecting'): _iam.convert('ashrae', {'B': 0.1}, 'physical') -@requires_scipy_150 def test_convert__minimize_fails(): # to make scipy.optimize.minimize fail, we'll pass in a nonsense # weight function that only outputs nans @@ -509,7 +500,6 @@ def nan_weight(aoi): _iam.convert('ashrae', {'b': 0.1}, 'physical', weight=nan_weight) -@requires_scipy_150 def test_fit(): aoi = np.linspace(0, 90, 5) perturb = np.array([1.2, 1.01, 0.95, 1, 0.98]) @@ -523,7 +513,6 @@ def test_fit(): assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) -@requires_scipy_150 def test_fit_custom_weight_func(): # define custom weight function that takes in other arguments def scaled_weight(aoi): @@ -542,13 +531,11 @@ def scaled_weight(aoi): assert np.isclose(expected_a_r, actual_a_r, atol=1e-04) -@requires_scipy_150 def test_fit_model_not_implemented(): with pytest.raises(NotImplementedError, match='model has not been'): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'foo') -@requires_scipy_150 def test_fit__minimize_fails(): # to make scipy.optimize.minimize fail, we'll pass in a nonsense # weight function that only outputs nans @@ -560,7 +547,6 @@ def nan_weight(aoi): weight=nan_weight) -@requires_scipy_150 def test__residual_zero_outside_range(): # check that _residual annihilates any weights that come from aoi # outside of interval [0, 90] (this is important for `iam.fit`, when From 8df7bf66b4e51850204c31521def18d4ddcd958c Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Wed, 29 Nov 2023 15:03:46 -0700 Subject: [PATCH 62/65] edits from review --- docs/examples/reflections/plot_convert_iam_models.py | 3 ++- docs/examples/reflections/plot_fit_iam_models.py | 7 ++++--- pvlib/iam.py | 2 -- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 9478b9290b..4140230a46 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -99,7 +99,8 @@ physical_iam_default = physical(aoi, **physical_params_default) -# ... using a custom weight function. +# ... using a custom weight function. The weight function must take ``aoi`` +# as it's argument and return a vector of the same length as ``aoi``. def weight_function(aoi): return cosd(aoi) diff --git a/docs/examples/reflections/plot_fit_iam_models.py b/docs/examples/reflections/plot_fit_iam_models.py index 1b2dbd1474..4597999b44 100644 --- a/docs/examples/reflections/plot_fit_iam_models.py +++ b/docs/examples/reflections/plot_fit_iam_models.py @@ -38,8 +38,8 @@ # mimic measured data and then we'll fit the physical model to the perturbed # data. -# Create and perturb IAM data. -aoi = np.linspace(0, 90, 100) +# Create some IAM data. +aoi = np.linspace(0, 85, 10) params = {'a_r': 0.16} iam = martin_ruiz(aoi, **params) data = iam * np.array([uniform(0.98, 1.02) for _ in range(len(iam))]) @@ -69,7 +69,8 @@ # function to :py:func:`pvlib.iam.fit`. # -# Define a custom weight function. +# Define a custom weight function. The weight function must take ``aoi`` +# as it's argument and return a vector of the same length as ``aoi``. def weight_function(aoi): return cosd(aoi) diff --git a/pvlib/iam.py b/pvlib/iam.py index 2413607ea8..94ec7b5af5 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -11,7 +11,6 @@ import numpy as np import pandas as pd import functools -import warnings from scipy.optimize import minimize from pvlib.tools import cosd, sind, acosd @@ -1301,4 +1300,3 @@ def residual_function(target_param): optimize_result = _minimize(residual_function, guess, bounds, xtol) return _process_return(model_name, optimize_result) - From 6250182f0a232c947c987490162f08978266f7ee Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Wed, 29 Nov 2023 17:23:38 -0700 Subject: [PATCH 63/65] its not it's --- docs/examples/reflections/plot_convert_iam_models.py | 2 +- docs/examples/reflections/plot_fit_iam_models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples/reflections/plot_convert_iam_models.py b/docs/examples/reflections/plot_convert_iam_models.py index 4140230a46..6b0ec78ab3 100644 --- a/docs/examples/reflections/plot_convert_iam_models.py +++ b/docs/examples/reflections/plot_convert_iam_models.py @@ -100,7 +100,7 @@ # ... using a custom weight function. The weight function must take ``aoi`` -# as it's argument and return a vector of the same length as ``aoi``. +# as its argument and return a vector of the same length as ``aoi``. def weight_function(aoi): return cosd(aoi) diff --git a/docs/examples/reflections/plot_fit_iam_models.py b/docs/examples/reflections/plot_fit_iam_models.py index 4597999b44..6feb84423a 100644 --- a/docs/examples/reflections/plot_fit_iam_models.py +++ b/docs/examples/reflections/plot_fit_iam_models.py @@ -70,7 +70,7 @@ # # Define a custom weight function. The weight function must take ``aoi`` -# as it's argument and return a vector of the same length as ``aoi``. +# as its argument and return a vector of the same length as ``aoi``. def weight_function(aoi): return cosd(aoi) From f9c8888357afbceefc3510e36b2c18bd87d68f92 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Mon, 18 Dec 2023 10:32:09 -0700 Subject: [PATCH 64/65] change internal linspace to one degree intervals --- pvlib/iam.py | 2 +- pvlib/tests/test_iam.py | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index f3283b515c..f88224d1e4 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1182,7 +1182,7 @@ def convert(source_name, source_params, target_name, weight=_sin_weight, source = _get_model(source_name) target = _get_model(target_name) - aoi = np.linspace(0, 90, 100) + aoi = np.linspace(0, 90, 90) _check_params(source_name, source_params) source_iam = source(aoi, **source_params) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 78b6842a8a..fb65f2fe38 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -399,15 +399,15 @@ def test_schlick_diffuse(): @pytest.mark.parametrize('source,source_params,target,expected', [ ('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'martin_ruiz', - {'a_r': 0.173972}), + {'a_r': 0.174098}), ('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'ashrae', - {'b': 0.043925}), + {'b': 0.043858}), ('ashrae', {'b': 0.15}, 'physical', - {'n': 0.991457, 'K': 4, 'L': 0.0378127}), - ('ashrae', {'b': 0.15}, 'martin_ruiz', {'a_r': 0.30284}), + {'n': 0.991457, 'K': 4, 'L': 0.037789}), + ('ashrae', {'b': 0.15}, 'martin_ruiz', {'a_r': 0.302886}), ('martin_ruiz', {'a_r': 0.15}, 'physical', - {'n': 1.240655, 'K': 4, 'L': 0.00278196}), - ('martin_ruiz', {'a_r': 0.15}, 'ashrae', {'b': 0.026147})]) + {'n': 1.240906, 'K': 4, 'L': 0.002769962}), + ('martin_ruiz', {'a_r': 0.15}, 'ashrae', {'b': 0.026102})]) def test_convert(source, source_params, target, expected): target_params = _iam.convert(source, source_params, target) exp = [expected[k] for k in expected] @@ -421,7 +421,7 @@ def test_convert(source, source_params, target, expected): ('martin_ruiz', {'a_r': 0.15})]) def test_convert_recover(source, source_params): # convert isn't set up to handle both source and target = 'physical' - target_params = _iam.convert(source, source_params, source, xtol=1e-8) + target_params = _iam.convert(source, source_params, source, xtol=1e-7) exp = [source_params[k] for k in source_params] tar = [target_params[k] for k in source_params] assert_allclose(exp, tar, rtol=1e-05) @@ -432,7 +432,7 @@ def test_convert_ashrae_physical_no_fix_n(): source_params = {'b': 0.15} target_params = _iam.convert('ashrae', source_params, 'physical', fix_n=False) - expected = {'n': 0.989039, 'K': 4, 'L': 0.0373608} + expected = {'n': 0.988947, 'K': 4, 'L': 0.037360} exp = [expected[k] for k in expected] tar = [target_params[k] for k in expected] assert_allclose(exp, tar, rtol=1e-05) @@ -441,7 +441,7 @@ def test_convert_ashrae_physical_no_fix_n(): def test_convert_reverse_order_in_physical(): source_params = {'a_r': 0.25} target_params = _iam.convert('martin_ruiz', source_params, 'physical') - expected = {'n': 1.681051, 'K': 4, 'L': 0.0707148} + expected = {'n': 1.682894, 'K': 4, 'L': 0.071484} exp = [expected[k] for k in expected] tar = [target_params[k] for k in expected] assert_allclose(exp, tar, rtol=1e-5) @@ -450,15 +450,15 @@ def test_convert_reverse_order_in_physical(): def test_convert_xtol(): source_params = {'b': 0.15} target_params = _iam.convert('ashrae', source_params, 'physical', - xtol=1e-12) - expected = {'n': 0.9914568913905548, 'K': 4, 'L': 0.037812698547748186} + xtol=1e-8) + expected = {'n': 0.9914568914, 'K': 4, 'L': 0.03778927497} exp = [expected[k] for k in expected] tar = [target_params[k] for k in expected] - assert_allclose(exp, tar, rtol=1e-10) + assert_allclose(exp, tar, rtol=1e-6) def test_convert_custom_weight_func(): - aoi = np.linspace(0, 90, 100) + aoi = np.linspace(0, 90, 90) # convert physical to martin_ruiz, using custom weight function source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} @@ -470,14 +470,14 @@ def scaled_weight(aoi): # expected value calculated from computing residual function over # a range of inputs, and taking minimum of these values - expected_min_res = 18.051468686279726 + expected_min_res = 16.1977487 actual_dict = _iam.convert('physical', source_params, 'martin_ruiz', weight=scaled_weight) actual_min_res = _iam._residual(aoi, source_iam, _iam.martin_ruiz, [actual_dict['a_r']], scaled_weight) - assert np.isclose(expected_min_res, actual_min_res, atol=1e-08) + assert np.isclose(expected_min_res, actual_min_res, atol=1e-06) def test_convert_model_not_implemented(): From 79af432f6842ec8d0e0c99db441c13db0350be78 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Mon, 18 Dec 2023 13:04:23 -0700 Subject: [PATCH 65/65] use linspace(0, 90, 91) --- pvlib/iam.py | 2 +- pvlib/tests/test_iam.py | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index f88224d1e4..83b8955e2f 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -1182,7 +1182,7 @@ def convert(source_name, source_params, target_name, weight=_sin_weight, source = _get_model(source_name) target = _get_model(target_name) - aoi = np.linspace(0, 90, 90) + aoi = np.linspace(0, 90, 91) _check_params(source_name, source_params) source_iam = source(aoi, **source_params) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index fb65f2fe38..f5ca231bd4 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -399,15 +399,15 @@ def test_schlick_diffuse(): @pytest.mark.parametrize('source,source_params,target,expected', [ ('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'martin_ruiz', - {'a_r': 0.174098}), + {'a_r': 0.174037}), ('physical', {'n': 1.5, 'K': 4.5, 'L': 0.004}, 'ashrae', - {'b': 0.043858}), + {'b': 0.042896}), ('ashrae', {'b': 0.15}, 'physical', - {'n': 0.991457, 'K': 4, 'L': 0.037789}), - ('ashrae', {'b': 0.15}, 'martin_ruiz', {'a_r': 0.302886}), + {'n': 0.991457, 'K': 4, 'L': 0.037813}), + ('ashrae', {'b': 0.15}, 'martin_ruiz', {'a_r': 0.302390}), ('martin_ruiz', {'a_r': 0.15}, 'physical', - {'n': 1.240906, 'K': 4, 'L': 0.002769962}), - ('martin_ruiz', {'a_r': 0.15}, 'ashrae', {'b': 0.026102})]) + {'n': 1.240190, 'K': 4, 'L': 0.002791055}), + ('martin_ruiz', {'a_r': 0.15}, 'ashrae', {'b': 0.025458})]) def test_convert(source, source_params, target, expected): target_params = _iam.convert(source, source_params, target) exp = [expected[k] for k in expected] @@ -432,7 +432,7 @@ def test_convert_ashrae_physical_no_fix_n(): source_params = {'b': 0.15} target_params = _iam.convert('ashrae', source_params, 'physical', fix_n=False) - expected = {'n': 0.988947, 'K': 4, 'L': 0.037360} + expected = {'n': 0.989019, 'K': 4, 'L': 0.037382} exp = [expected[k] for k in expected] tar = [target_params[k] for k in expected] assert_allclose(exp, tar, rtol=1e-05) @@ -441,24 +441,24 @@ def test_convert_ashrae_physical_no_fix_n(): def test_convert_reverse_order_in_physical(): source_params = {'a_r': 0.25} target_params = _iam.convert('martin_ruiz', source_params, 'physical') - expected = {'n': 1.682894, 'K': 4, 'L': 0.071484} + expected = {'n': 1.691398, 'K': 4, 'L': 0.071633} exp = [expected[k] for k in expected] tar = [target_params[k] for k in expected] - assert_allclose(exp, tar, rtol=1e-5) + assert_allclose(exp, tar, rtol=1e-05) def test_convert_xtol(): source_params = {'b': 0.15} target_params = _iam.convert('ashrae', source_params, 'physical', xtol=1e-8) - expected = {'n': 0.9914568914, 'K': 4, 'L': 0.03778927497} + expected = {'n': 0.9914568914, 'K': 4, 'L': 0.0378126985} exp = [expected[k] for k in expected] tar = [target_params[k] for k in expected] assert_allclose(exp, tar, rtol=1e-6) def test_convert_custom_weight_func(): - aoi = np.linspace(0, 90, 90) + aoi = np.linspace(0, 90, 91) # convert physical to martin_ruiz, using custom weight function source_params = {'n': 1.5, 'K': 4.5, 'L': 0.004} @@ -470,7 +470,7 @@ def scaled_weight(aoi): # expected value calculated from computing residual function over # a range of inputs, and taking minimum of these values - expected_min_res = 16.1977487 + expected_min_res = 16.39724 actual_dict = _iam.convert('physical', source_params, 'martin_ruiz', weight=scaled_weight)