diff --git a/docs/source/conf.py b/docs/source/conf.py index cd1cc24e..e10b92ce 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,7 +22,7 @@ author = 'Bart Doekemeijer, Paul Fleming, Eric Simley' # The full version, including alpha/beta/rc tags -release = '0.1' +release = '1.1' # -- General configuration --------------------------------------------------- diff --git a/examples/_legacy/sensitivity_analysis/simple_sobol_example.py b/examples/_legacy/sensitivity_analysis/simple_sobol_example.py index 8fc2c3c8..1f4ad567 100644 --- a/examples/_legacy/sensitivity_analysis/simple_sobol_example.py +++ b/examples/_legacy/sensitivity_analysis/simple_sobol_example.py @@ -5,7 +5,7 @@ import floris.tools as wfct from floris.logging_manager import configure_console_log -from pandas.core.base import DataError +from pandas.errors import DataError from flasc import floris_sensitivity_analysis as fsasa diff --git a/examples/demo_dataset/demo_floris_input.yaml b/examples/demo_dataset/demo_floris_input.yaml index 508ba743..e8e9b6bb 100644 --- a/examples/demo_dataset/demo_floris_input.yaml +++ b/examples/demo_dataset/demo_floris_input.yaml @@ -38,7 +38,7 @@ logging: level: INFO name: floris_input_file_example solver: - turbine_grid_points: 5 + turbine_grid_points: 3 type: turbine_grid wake: enable_secondary_steering: true diff --git a/examples/wake_steering_design/00_analyze_single_ws_vs_range.py b/examples/wake_steering_design/00_analyze_single_ws_vs_range.py index c003ff62..527ab420 100644 --- a/examples/wake_steering_design/00_analyze_single_ws_vs_range.py +++ b/examples/wake_steering_design/00_analyze_single_ws_vs_range.py @@ -15,8 +15,9 @@ from matplotlib import pyplot as plt from flasc.wake_steering.lookup_table_tools import get_yaw_angles_interpolant -from flasc.wake_steering.yaw_optimizer_visualization import plot_uplifts_by_atmospheric_conditions -from flasc.visualization import plot_floris_layout +from flasc.wake_steering.yaw_optimizer_visualization import \ + plot_uplifts_by_atmospheric_conditions, plot_offsets_wswd_heatmap, plot_offsets_wd +from flasc.visualization import plot_floris_layout, plot_layout_with_waking_directions from _local_helper_functions import load_floris, optimize_yaw_angles, evaluate_optimal_yaw_angles @@ -25,6 +26,7 @@ # Load FLORIS model and plot layout (and additional information) fi = load_floris() plot_floris_layout(fi) + plot_layout_with_waking_directions(fi, limit_dist_D=5, limit_num=3) # Compare optimizing over all wind speeds vs. optimizing over a single wind speed AEP_baseline_array = [] @@ -47,6 +49,18 @@ AEP_opt_array.append(AEP_opt) df_out_array.append(df_out.copy()) + # Vizualize optimal wake steering schedule for all wind speeds + # for a single turbine (index 2) + ax, cbar = plot_offsets_wswd_heatmap(df_offsets=df_opt, turb_id=2) + ax.set_title("T02 offset schedule") + + ax = plot_offsets_wd(df_offsets=df_opt, turb_id=2, ws_plot=[5, 12], + alpha=0.5) + ax = plot_offsets_wd(df_offsets=df_opt, turb_id=2, ws_plot=7.0, + color="C0", label="7.0 m/s", ax=ax) + ax.set_title("T02 offset schedule") + ax.legend() + # Calculate AEP uplifts uplift_one_ws = ( 100.0 * (AEP_opt_array[0] - AEP_baseline_array[0]) / diff --git a/flasc/dataframe_operations/dataframe_filtering.py b/flasc/dataframe_operations/dataframe_filtering.py index cca4d776..7b5eabcc 100644 --- a/flasc/dataframe_operations/dataframe_filtering.py +++ b/flasc/dataframe_operations/dataframe_filtering.py @@ -92,7 +92,7 @@ def plot_highlight_data_by_conds(df, conds, ti): conds = [conds] # Convert time arrays to a string with 'year+week' - tfull = [int('%04d%02d' % (i.year, i.week)) for i in df.time] + tfull = [int('%04d%02d' % (i.isocalendar().year, i.isocalendar().week)) for i in df.time] time_array = np.unique(tfull) # Get number of non-NaN entries before filtering @@ -113,7 +113,7 @@ def plot_highlight_data_by_conds(df, conds, ti): # Convert time array of occurrences to year+week no. conds_new = conds[ii] & (~conds_combined) conds_combined = (conds_combined | np.array(conds[ii], dtype=bool)) - subset_time_array = [int('%04d%02d' % (i.year, i.week)) for i in df.loc[conds_new, 'time']] + subset_time_array = [int('%04d%02d' % (i.isocalendar().year, i.isocalendar().week)) for i in df.loc[conds_new, 'time']] for iii in range(len(time_array)): # Count occurrences for condition diff --git a/flasc/energy_ratio/energy_ratio.py b/flasc/energy_ratio/energy_ratio.py index 84a17050..1371637d 100644 --- a/flasc/energy_ratio/energy_ratio.py +++ b/flasc/energy_ratio/energy_ratio.py @@ -15,7 +15,7 @@ import pandas as pd from floris.utilities import wrap_360 -from pandas.core.base import DataError +from pandas.errors import DataError from ..dataframe_operations import dataframe_manipulations as dfm from ..energy_ratio import energy_ratio_visualization as ervis @@ -479,16 +479,20 @@ def get_energy_ratio_fast( energy_ratios = df_summed.reset_index(drop=False) return energy_ratios - def plot_energy_ratio(self): + def plot_energy_ratio(self, hide_uq_labels=True): """This function plots the energy ratio against the wind direction, potentially with uncertainty bounds if N > 1 was specified by the user. One must first run get_energy_ratio() before attempting to plot the energy ratios. + Args: + hide_uq_labels (bool, optional): If true, do not specifically label + the confidence intervals in the plot + Returns: ax [plt.Axes]: Axis handle for the figure. """ - return ervis.plot(self.energy_ratio_out) + return ervis.plot(self.energy_ratio_out, hide_uq_labels=hide_uq_labels) # Support functions not included in energy_ratio class diff --git a/flasc/energy_ratio/energy_ratio_suite.py b/flasc/energy_ratio/energy_ratio_suite.py index 34b7851b..b29c8bbd 100644 --- a/flasc/energy_ratio/energy_ratio_suite.py +++ b/flasc/energy_ratio/energy_ratio_suite.py @@ -13,7 +13,7 @@ import numpy as np import pandas as pd -from pandas.core.base import DataError +from pandas.errors import DataError from scipy.interpolate import NearestNDInterpolator from ..energy_ratio import energy_ratio as er @@ -625,7 +625,7 @@ def get_energy_ratios_fast( return self.df_list - def plot_energy_ratios(self, superimpose=True): + def plot_energy_ratios(self, superimpose=True, hide_uq_labels=True): """This function plots the energy ratios of each dataset against the wind direction, potentially with uncertainty bounds if N > 1 was specified by the user. One must first run get_energy_ratios() @@ -636,6 +636,8 @@ def plot_energy_ratios(self, superimpose=True): of all datasets into the same figure. If False, will plot the energy ratio of each dataset into a separate figure. Defaults to True. + hide_uq_labels (bool, optional): If true, do not specifically label + the confidence intervals in the plot Returns: ax [plt.Axes]: Axis handle for the figure. @@ -643,12 +645,12 @@ def plot_energy_ratios(self, superimpose=True): if superimpose: results_array = [df["er_results"] for df in self.df_list] labels_array = [df["name"] for df in self.df_list] - fig, ax = vis.plot(results_array, labels_array) + fig, ax = vis.plot(results_array, labels_array, hide_uq_labels=hide_uq_labels) else: ax = [] for df in self.df_list: - fig, axi = vis.plot(df["er_results"], df["name"]) + fig, axi = vis.plot(df["er_results"], df["name"], hide_uq_labels=hide_uq_labels) ax.append(axi) return ax diff --git a/flasc/energy_ratio/energy_ratio_visualization.py b/flasc/energy_ratio/energy_ratio_visualization.py index 29fe217b..ac65248e 100644 --- a/flasc/energy_ratio/energy_ratio_visualization.py +++ b/flasc/energy_ratio/energy_ratio_visualization.py @@ -20,7 +20,7 @@ from floris import tools as wfct -def plot(energy_ratios, labels=None): +def plot(energy_ratios, labels=None, hide_uq_labels=True): """This function plots energy ratios against the reference wind direction. The plot may or may not include uncertainty bounds, depending on the information contained in the provided energy ratio @@ -43,6 +43,8 @@ def plot(energy_ratios, labels=None): with UQ. labels ([iteratible], optional): Label for each of the energy ratio dataframes. Defaults to None. + hide_uq_labels (bool, optional): If true, do not specifically label + the confidence intervals in the plot Returns: fig ([plt.Figure]): Figure in which energy ratios are plotted. @@ -60,6 +62,9 @@ def plot(energy_ratios, labels=None): else: uq_labels = ["%s confidence bounds" % lb for lb in labels] + if hide_uq_labels: + uq_labels = ['_nolegend_' for l in uq_labels] + N = len(energy_ratios) fig, ax = plt.subplots(nrows=2, sharex=True, figsize=(10, 5)) diff --git a/flasc/floris_tools.py b/flasc/floris_tools.py index 49d2dcb2..d2cdab90 100644 --- a/flasc/floris_tools.py +++ b/flasc/floris_tools.py @@ -15,7 +15,7 @@ import matplotlib.pyplot as plt import numpy as np import pandas as pd -from pandas.core.base import DataError +from pandas.errors import DataError from scipy import interpolate from time import perf_counter as timerpc diff --git a/flasc/model_estimation/floris_sensitivity_analysis.py b/flasc/model_estimation/floris_sensitivity_analysis.py index e82fafcd..c3a53ed1 100644 --- a/flasc/model_estimation/floris_sensitivity_analysis.py +++ b/flasc/model_estimation/floris_sensitivity_analysis.py @@ -18,7 +18,7 @@ from SALib.sample import saltelli from SALib.analyze import sobol -from pandas.core.base import DataError +from pandas.errors import DataError from .. import floris_tools as ftools diff --git a/flasc/optimization.py b/flasc/optimization.py index 9fc8b839..7c7fdb84 100644 --- a/flasc/optimization.py +++ b/flasc/optimization.py @@ -14,7 +14,7 @@ import copy from datetime import timedelta as td import numpy as np -from pandas.core.base import DataError +from pandas.errors import DataError import scipy.optimize as opt import scipy.stats as spst diff --git a/flasc/raw_data_handling/sqldatabase_management.py b/flasc/raw_data_handling/sqldatabase_management.py index ebbc62f1..b017f96f 100644 --- a/flasc/raw_data_handling/sqldatabase_management.py +++ b/flasc/raw_data_handling/sqldatabase_management.py @@ -100,9 +100,10 @@ def print_properties(self): print(" N.o. columns: %d." % len(cols)) print("") - def launch_gui(self): + def launch_gui(self, turbine_names=None, sort_columns=False): root = tk.Tk() - sql_db_explorer_gui(master=root, dbc=self) + + sql_db_explorer_gui(master=root, dbc=self, turbine_names=turbine_names, sort_columns=sort_columns) root.mainloop() def get_column_names(self, table_name): @@ -256,7 +257,7 @@ def send_data( class sql_db_explorer_gui: - def __init__(self, master, dbc): + def __init__(self, master, dbc, turbine_names = None, sort_columns=False): # Create the options container frame_1 = tk.Frame(master) @@ -408,6 +409,12 @@ def mapper_func(evt): # Set up the database connection self.dbc = dbc + # Save the turbine names + self.turbine_names = turbine_names + + # Save the sort columns + self.sort_columns = sort_columns + def channel_add(self): if self.N_channels < self.N_channels_max: ci = self.N_channels # New channel @@ -463,11 +470,23 @@ def load_data(self): ] col_mapping = dict(zip(old_col_names, new_col_names)) df = df.rename(columns=col_mapping) + + # If specific turbine names are supplied apply them here + if self.turbine_names is not None: + columns = df.columns + for t in range(len(self.turbine_names)): + columns = [c.replace('%03d' % t,self.turbine_names[t]) for c in columns] + df.columns = columns + df_array.append(df) # Merge dataframes self.df = pd.concat(df_array, axis=1).reset_index(drop=False) + # If sorting the columns do it now + if self.sort_columns: + self.df = self.df[sorted(self.df.columns)] + self.update_channel_cols() self.create_figures() # # Clear all axes diff --git a/flasc/time_operations.py b/flasc/time_operations.py index 11ee3bcb..bb69713b 100644 --- a/flasc/time_operations.py +++ b/flasc/time_operations.py @@ -31,12 +31,14 @@ def df_movingaverage( calc_median_min_max_std=False, return_index_mapping=False, ): - - # Copy and ensure dataframe is indexed by time - df = df_in.copy() - if "time" in df.columns: - df = df.set_index("time") - + """ + Note that median, minimum, and maximum do not handle angular + quantities and should be treated carefully. + Standard deviation handles angular quantities. + """ + + df = df_in.set_index('time').copy() + # Find non-angular columns if isinstance(cols_angular, bool): if cols_angular: @@ -44,112 +46,93 @@ def df_movingaverage( else: cols_angular = [] cols_regular = [c for c in df.columns if c not in cols_angular] - - # Now calculate cos and sin components for angular columns - sin_cols = ["{:s}_sin".format(c) for c in cols_angular] - cos_cols = ["{:s}_cos".format(c) for c in cols_angular] - df[sin_cols] = np.sin(df[cols_angular] * np.pi / 180.0) - df[cos_cols] = np.cos(df[cols_angular] * np.pi / 180.0) - - # Drop angular columns - df = df.drop(columns=cols_angular) - - # Now calculate rolling (moving) average - df_roll = df.rolling( - window_width, - center=center, - axis=0, - min_periods=min_periods - ) - - # First calculate mean values of non-angular columns - df_ma = df_roll[cols_regular].mean().copy() - - # Now add mean values of angular columns - df_ma[cols_angular] = wrap_360( - np.arctan2( - df_roll[sin_cols].mean().values, - df_roll[cos_cols].mean().values - ) * 180.0 / np.pi - ) - - # Figure out which indices/data points belong to each window - if (return_index_mapping or calc_median_min_max_std): - df_tmp = df_ma[[]].copy().reset_index(drop=False) - df_tmp["tmp"] = 1 - df_tmp = df_tmp.rolling(window_width, center=center, axis=0, on="time")["tmp"] - - # Grab index of first and last time entry for each window - windows_min = list(df_tmp.apply(lambda x: x.index[0]).astype(int)) - windows_max = list(df_tmp.apply(lambda x: x.index[-1]).astype(int)) - - # Now create a large array that contains the array of indices, with - # the values in each row corresponding to the indices upon which that - # row's moving/rolling average is based. Note that we purposely create - # a larger matrix than necessary, since some rows/windows rely on more - # data (indices) than others. This is the case e.g., at the start of - # the dataset, at the end, and when there are gaps in the data. We fill - # the remaining matrix entries with "-1". - dn = int(np.ceil(window_width/fsut.estimate_dt(df_in["time"]))) + 5 - data_indices = -1 * np.ones((df_ma.shape[0], dn), dtype=int) - for ii in range(len(windows_min)): - lb = windows_min[ii] - ub = windows_max[ii] - ind = np.arange(lb, ub + 1, dtype=int) - data_indices[ii, ind - lb] = ind - - # Calculate median, min, max, std if necessary - if calc_median_min_max_std: - # Append all current columns with "_mean" - df_ma.columns = ["{:s}_mean".format(c) for c in df_ma.columns] - - # Add statistics for regular columns - funs = ["median", "min", "max", "std"] - cols_reg_stats = ["_".join(i) for i in product(cols_regular, funs)] - df_ma[cols_reg_stats] = df_roll[cols_regular].agg(funs).copy() - - # Add statistics for angular columns - # Firstly, create matrix with indices for the mean values - data_indices_mean = np.tile(np.arange(0, df_ma.shape[0]), (dn, 1)).T - - # Grab raw and mean data and format as numpy arrays - D = df_in[cols_angular].values - M = df_ma[["{:s}_mean".format(c) for c in cols_angular]].values - - # Add NaN row as last row. This corresponds to the -1 indices - # that we use as placeholders. This way, those indices do not - # count towards the final statistics (median, min, max, std). - D = np.vstack([D, np.nan * np.ones(D.shape[1])]) - M = np.vstack([M, np.nan * np.ones(M.shape[1])]) - - # Now create a 3D matrix containing all values. The three dimensions - # come from: - # > [0] one dimension containing the rolling windows, - # > [1] one with the raw data underlying each rolling window, - # > [2] one for each angular column within the dataset - values = D[data_indices, :] - values_mean = M[data_indices_mean, :] - - # Center values around values_mean - values[values > (values_mean + 180.0)] += -360.0 - values[values < (values_mean - 180.0)] += 360.0 - - # Calculate statistical properties and wrap to [0, 360) - values_median = wrap_360(np.nanmedian(values, axis=1)) - values_min = wrap_360(np.nanmin(values, axis=1)) - values_max = wrap_360(np.nanmax(values, axis=1)) - values_std = wrap_360(np.nanstd(values, axis=1)) - - # Save to dataframe - df_ma[["{:s}_median".format(c) for c in cols_angular]] = values_median - df_ma[["{:s}_min".format(c) for c in cols_angular]] = values_min - df_ma[["{:s}_max".format(c) for c in cols_angular]] = values_max - df_ma[["{:s}_std".format(c) for c in cols_angular]] = values_std - - if return_index_mapping: - return df_ma, data_indices - - return df_ma + + # Save the full columns + full_columns = df.columns + + # Carry out the mean calculations + df_regular = (df + [cols_regular] # Select only non-angular columns + .rolling(window_width, + center=center, + axis=0, + min_periods=min_periods + ) + .mean() + ) + + + df_cos = (df + [cols_angular] # Select only angular columns + .pipe(lambda df_: np.cos(df_ * np.pi / 180.)) + .rolling(window_width, + center=center, + axis=0, + min_periods=min_periods + ) + .mean() + ) + + df_sin = (df + [cols_angular] # Select only angular columns + .pipe(lambda df_: np.sin(df_ * np.pi / 180.)) + .rolling(window_width, + center=center, + axis=0, + min_periods=min_periods + ) + .mean() + ) + + dfm = (df_regular + .join((np.arctan2(df_sin,df_cos) * 180. / np.pi) % 360) + [full_columns] # put back in order + ) + + if not calc_median_min_max_std: + + return dfm + + + if calc_median_min_max_std: # if including other statistics + + df_regular_stats = (df + .rolling(window_width, + center=center, + axis=0, + min_periods=min_periods + ) + .agg(["median", "min", "max", "std"]) + .pipe(lambda df_: flatten_cols(df_)) + ) + + # Apply scipy.stats.circstd() step by step for performance reasons + df_angular_std = (df_sin + .pow(2) + .add(df_cos.pow(2)) + .pow(1/2) # sqrt() + .apply(np.log) # log() + .mul(-2) + .pow(1/2) # sqrt() + .mul(180/np.pi) + .rename( + {c: c + '_std' for c in dfm.columns}, + axis='columns' + ) + ) + + # Merge the stats + df_stats = (df_regular_stats + [[c for c in df_regular_stats.columns if \ + c not in df_angular_std.columns]] + .join(df_angular_std) + ) + + # Now merge in means and return + return (dfm + .rename({c: c + '_mean' for c in dfm.columns},axis='columns') + .join(df_stats) + ) def df_downsample( @@ -300,10 +283,10 @@ def get_last_index(x): # df_out[["{:s}_std".format(c) for c in cols_angular]] = values_std # Rewrite to avoid fragmentation - df_out = pd.concat([df_out, pd.DataFrame(values_median, columns=["{:s}_median".format(c) for c in cols_angular])], axis=1) - df_out = pd.concat([df_out, pd.DataFrame(values_min, columns=["{:s}_min".format(c) for c in cols_angular])], axis=1) - df_out = pd.concat([df_out, pd.DataFrame(values_max, columns=["{:s}_max".format(c) for c in cols_angular])], axis=1) - df_out = pd.concat([df_out, pd.DataFrame(values_std, columns=["{:s}_std".format(c) for c in cols_angular])], axis=1) + df_out = pd.concat([df_out, pd.DataFrame(values_median, index=df_out.index, columns=["{:s}_median".format(c) for c in cols_angular])], axis=1) + df_out = pd.concat([df_out, pd.DataFrame(values_min, index=df_out.index, columns=["{:s}_min".format(c) for c in cols_angular])], axis=1) + df_out = pd.concat([df_out, pd.DataFrame(values_max, index=df_out.index, columns=["{:s}_max".format(c) for c in cols_angular])], axis=1) + df_out = pd.concat([df_out, pd.DataFrame(values_std, index=df_out.index, columns=["{:s}_std".format(c) for c in cols_angular])], axis=1) if center: # Shift time column towards center of the bin @@ -382,3 +365,10 @@ def df_resample_by_interpolation( df_res[c] = y return df_res + +# Function from "EFFECTIVE PANDAS" for flattening multi-level column names +def flatten_cols (df): + cols = ['_'. join(map(str , vals )) + for vals in df.columns.to_flat_index ()] + df.columns = cols + return df diff --git a/flasc/version.py b/flasc/version.py new file mode 100644 index 00000000..b123147e --- /dev/null +++ b/flasc/version.py @@ -0,0 +1 @@ +1.1 \ No newline at end of file diff --git a/flasc/visualization.py b/flasc/visualization.py index b411965a..382cb163 100644 --- a/flasc/visualization.py +++ b/flasc/visualization.py @@ -162,7 +162,8 @@ def plot_floris_layout(fi, turbine_names=None, plot_terrain=True): each entry being a string. It is recommended that this is something like one or two letters, and then a number to indicate the turbine. For example, A01, A02, A03, ... If None is specified, will assume - turbine names T01, T02, T03, .... Defaults to None. + turbine names T01, T02, T03, .... Defaults to None. To avoid printing + names, specify turbine_names=[]. plot_terrain (bool, optional): Plot the terrain as a colormap. Defaults to True. @@ -172,137 +173,468 @@ def plot_floris_layout(fi, turbine_names=None, plot_terrain=True): # Plot turbine configurations fig = plt.figure(figsize=(16, 8)) + # Get names if not provided if turbine_names is None: - nturbs = len(fi.layout_x) - turbine_names = ["T{:02d}".format(ti) for ti in range(nturbs)] - - plt.subplot(1, 2, 1) + turbine_names = generate_labels_with_hub_heights(fi) + ax = [None, None, None] - ax[0] = plt.gca() + ax[0] = fig.add_subplot(121) - hub_heights = fi.floris.farm.hub_heights.flatten() if plot_terrain: - cntr = ax[0].tricontourf( - fi.layout_x, - fi.layout_y, - hub_heights, - levels=14, - cmap="RdBu_r" - ) - fig.colorbar( - cntr, - ax=ax[0], - label='Terrain-corrected hub height (m)', - ticks=np.linspace( - np.min(hub_heights) - 10.0, - np.max(hub_heights) + 10.0, - 15, - ) - ) + plot_farm_terrain(fi, fig, ax[0]) + # Generate plotting dictionary based on turbine; plot locations turbine_types = ( [t["turbine_type"] for t in fi.floris.farm.turbine_definitions] ) turbine_types = np.array(turbine_types, dtype="str") - for tt in np.unique(turbine_types): - ids = (turbine_types == tt) - ax[0].plot(fi.layout_x[ids], fi.layout_y[ids], "o", label=tt) - - # Plot turbine names and hub heights - for ti in range(len(fi.layout_x)): - ax[0].text( - fi.layout_x[ti], - fi.layout_y[ti], - turbine_names[ti] + " ({:.1f} m)".format(hub_heights[ti]) - ) - - ax[0].axis("equal") + for ti, tt in enumerate(np.unique(turbine_types)): + plotting_dict = { + "turbine_indices" : np.array(range(len(fi.layout_x)))\ + [turbine_types == tt], + "turbine_names" : turbine_names, + "color" : "C%s" % ti, + "label" : tt + } + plot_layout_only(fi, plotting_dict, ax=ax[0]) ax[0].legend() - ax[0].grid(True) - ax[0].set_xlabel("x coordinate (m)") - ax[0].set_ylabel("y coordinate (m)") ax[0].set_title("Farm layout") - # Plot turbine power and thrust curves - plt.subplot(2, 2, 2) - ax[1] = plt.gca() - plt.subplot(2, 2, 4) - ax[2] = plt.gca() + # Power and thrust curve plots + ax[1] = fig.add_subplot(222) + ax[2] = fig.add_subplot(224) + # Identify unique power-thrust curves and group turbines accordingly - for ti in range(len(fi.layout_x)): - pt = fi.floris.farm.turbine_definitions[ti]["power_thrust_table"] - if ti == 0: - unique_pt = [pt] - unique_turbines = [[ti]] - continue - - # Check if power-thrust curve already exists somewhere - is_unique = True - for tii in range(len(unique_pt)): - if (unique_pt[tii] == pt): - unique_turbines[tii].append(ti) - is_unique = False - continue - - # If not, append as new entry - if is_unique: - unique_pt.append(pt) - unique_turbines.append([ti]) - - for tii, pt in enumerate(unique_pt): - # Convert a very long string of turbine identifiers to ranges, - # e.g., from "A01, A02, A03, A04" to "A01-A04" - labels = [turbine_names[i] for i in unique_turbines[tii]] - prev_turb_in_list = np.zeros(len(labels), dtype=bool) - next_turb_in_list = np.zeros(len(labels), dtype=bool) - for ii, lb in enumerate(labels): - # Split initial string from sequence of texts - idx = 0 - while lb[0:idx+1].isalpha(): - idx += 1 - - # Now check various choices of numbers, i.e., A001, A01, A1 - turb_prev_if_range = [ - lb[0:idx] + "{:01d}".format(int(lb[idx::]) - 1), - lb[0:idx] + "{:02d}".format(int(lb[idx::]) - 1), - lb[0:idx] + "{:03d}".format(int(lb[idx::]) - 1) - ] - turb_next_if_range = [ - lb[0:idx] + "{:01d}".format(int(lb[idx::]) + 1), - lb[0:idx] + "{:02d}".format(int(lb[idx::]) + 1), - lb[0:idx] + "{:03d}".format(int(lb[idx::]) + 1) - ] - - prev_turb_in_list[ii] = np.any([t in labels for t in turb_prev_if_range]) - next_turb_in_list[ii] = np.any([t in labels for t in turb_next_if_range]) - - # Remove label for turbines in the middle of ranges - for id in np.where(prev_turb_in_list & next_turb_in_list)[0]: - labels[id] = "" - - # Append a dash to labels for turbines at the start of a range - for id in np.where(~prev_turb_in_list & next_turb_in_list)[0]: - labels[id] += "-" - - # Append a comma to turbines at the end of a range - for id in np.where(~next_turb_in_list)[0]: - labels[id] += "," - - # Now join all strings to a single label and remove last comma - label = "".join(labels)[0:-1] - - # Plot power and thrust curves for groups of turbines - tn = fi.floris.farm.turbine_definitions[unique_turbines[tii][0]]["turbine_type"] - ax[1].plot(pt["wind_speed"], pt["power"], label=label + " ({:s})".format(tn)) - ax[2].plot(pt["wind_speed"], pt["thrust"], label=label + " ({:s})".format(tn)) - - ax[1].set_xlabel("Wind speed (m/s)") - ax[2].set_xlabel("Wind speed (m/s)") - ax[1].set_ylabel("Power coefficient (-)") - ax[2].set_ylabel("Thrust coefficient (-)") - ax[1].grid(True) - ax[2].grid(True) - ax[1].legend() - ax[2].legend() + unique_turbine_types, utt_ids = np.unique(turbine_types, return_index=True) + for ti, (tt, tti) in enumerate(zip(unique_turbine_types, utt_ids)): + pt = fi.floris.farm.turbine_definitions[tti]["power_thrust_table"] + + plotting_dict = { + "color" : "C%s" % ti, + "label" : tt + } + plot_power_curve_only(pt, plotting_dict, ax=ax[1]) + plot_thrust_curve_only(pt, plotting_dict, ax=ax[2]) return fig, ax + +def generate_default_labels(fi): + labels = ["T{0:02d}".format(ti) for ti in range(len(fi.layout_x))] + return labels + +def generate_labels_with_hub_heights(fi): + labels = ["T{0:02d} ({1:.1f} m)".format(ti, h) for ti, h in + enumerate(fi.floris.farm.hub_heights.flatten())] + return labels + +def plot_layout_only(fi, plotting_dict={}, ax=None): + """ + Plot the farm layout. + + Args: + plotting_dict: dictionary of plotting parameters, with the + following (optional) fields and their (default) values: + "turbine_indices" : (range(len(fi.layout_x))) (turbines to + plot, default to all turbines) + "turbine_names" : (["TX" for X in range(len(fi.layout_x)]) + "color" : ("black") + "marker" : (".") + "markersize" : (10) + "label" : (None) (for legend, if desired) + ax: axes to plot on (if None, creates figure and axes) + + Returns: + ax: the current axes for the layout plot + + turbine_names should be a complete list of all turbine names; only + those in turbine_indices will be plotted though. + """ + + # Generate axis, if needed + if ax is None: + fig = plt.figure(figsize=(8, 8)) + ax = fig.add_subplot(111) + + # Generate plotting dictionary + default_plotting_dict = { + "turbine_indices" : range(len(fi.layout_x)), + "turbine_names" : generate_default_labels(fi), + "color" : "black", + "marker" : ".", + "markersize" : 10, + "label" : None + } + plotting_dict = {**default_plotting_dict, **plotting_dict} + if len(plotting_dict["turbine_names"]) == 0: # empty list provided + plotting_dict["turbine_names"] = [""]*len(fi.layout_x) + + # Plot + ax.plot( + fi.layout_x[plotting_dict["turbine_indices"]], + fi.layout_y[plotting_dict["turbine_indices"]], + marker=plotting_dict["marker"], + markersize=plotting_dict["markersize"], + linestyle="None", + color=plotting_dict["color"], + label=plotting_dict["label"] + ) + + # Add labels to plot, if desired + for ti in plotting_dict["turbine_indices"]: + ax.text(fi.layout_x[ti], fi.layout_y[ti], + plotting_dict["turbine_names"][ti]) + + # Plot labels and aesthetics + ax.axis("equal") + ax.grid(True) + ax.set_xlabel("x coordinate (m)") + ax.set_ylabel("y coordinate (m)") + + return ax + +def plot_power_curve_only(pt, plotting_dict={}, ax=None): + """ + Generate plot of turbine power curve. + + Args: + pt: power-thrust table as a dictionary. Expected to contain + keys "wind_speed" and "power" + plotting_dict: dictionary of plotting parameters, with the + following (optional) fields and their (default) values: + "color" : ("black"), + "linestyle" : ("solid"), + "linewidth" : (2), + "label" : (None) + ax: axes to plot on (if None, creates figure and axes) + + Returns: + ax: the current axes for the power curve plot + """ + # Generate axis, if needed + if ax is None: + fig = plt.figure(figsize=(8, 8)) + ax = fig.add_subplot(111) + + default_plotting_dict = { + "color" : "black", + "linestyle" : "solid", + "linewidth" : 2, + "label" : None + } + plotting_dict = {**default_plotting_dict, **plotting_dict} + + # Plot power and thrust curves for groups of turbines + ax.plot(pt["wind_speed"], pt["power"], **plotting_dict) + ax.set_xlabel("Wind speed (m/s)") + ax.set_ylabel("Power coefficient (-)") + ax.set_xlim([pt["wind_speed"][0], pt["wind_speed"][-1]]) + ax.grid(True) + + return ax + +def plot_thrust_curve_only(pt, plotting_dict, ax=None): + """ + Generate plot of turbine thrust curve. + + Args: + pt: power-thrust table as a dictionary. Expected to contain + keys "wind_speed" and "thrust" + plotting_dict: dictionary of plotting parameters, with the + following (optional) fields and their (default) values: + "color" : ("black"), + "linestyle" : ("solid"), + "linewidth" : (2), + "label" : (None) + ax: axes to plot on (if None, creates figure and axes) + + Returns: + ax: the current axes for the thrust curve plot + """ + + # Generate axis, if needed + if ax is None: + fig = plt.figure(figsize=(8, 8)) + ax = fig.add_subplot(111) + + default_plotting_dict = { + "color" : "black", + "linestyle" : "solid", + "linewidth" : 2, + "label" : None + } + plotting_dict = {**default_plotting_dict, **plotting_dict} + + # Plot power and thrust curves for groups of turbines + ax.plot(pt["wind_speed"], pt["thrust"], **plotting_dict) + ax.set_xlabel("Wind speed (m/s)") + ax.set_ylabel("Thrust coefficient (-)") + ax.set_xlim([pt["wind_speed"][0], pt["wind_speed"][-1]]) + ax.grid(True) + + return ax + +def plot_farm_terrain(fi, fig, ax): + hub_heights = fi.floris.farm.hub_heights.flatten() + cntr = ax.tricontourf( + fi.layout_x, + fi.layout_y, + hub_heights, + levels=14, + cmap="RdBu_r" + ) + + fig.colorbar( + cntr, + ax=ax, + label='Terrain-corrected hub height (m)', + ticks=np.linspace( + np.min(hub_heights) - 10.0, + np.max(hub_heights) + 10.0, + 15, + ) + ) + +def plot_layout_with_waking_directions( + fi, + layout_plotting_dict={}, + wake_plotting_dict={}, + D=None, + limit_dist_D=None, + limit_dist_m=None, + limit_num=None, + ax=None + ): + """ + Plot waking directions and distances between turbines. + + Args: + fi: Instantiated FlorisInterface object + layout_plotting_dict: dictionary of plotting parameters for + turbine locations. Defaults to the defaults of + plot_layout_only. + wake_plotting_dict: dictionary of plotting parameters for the + waking directions, with the following (optional) fields and + their (default) values: + "color" : ("black"), + "linestyle" : ("solid"), + "linewidth" : (0.5) + D: rotor diamter. Defaults to the rotor diamter of the first + turbine in the Floris object. + limit_dist_D: limit on the distance between turbines to plot, + specified in rotor diamters. + limit_dist_m: limit on the distance between turbines to plot, + specified in meters. If specified, overrides limit_dist_D. + limit_num: limit on number of outgoing neighbors to include. + If specified, only the limit_num closest turbines are + plotted. However, directions already plotted from other + turbines are not considered in the count. + ax: axes to plot on (if None, creates figure and axes) + + Returns: + ax: the current axes for the thrust curve plot + """ + + ax = plot_layout_only(fi, plotting_dict=layout_plotting_dict, ax=ax) + + # Combine default plotting options + default_plotting_dict = { + "color" : "black", + "linestyle" : "solid", + "linewidth" : 0.5 + } + wake_plotting_dict = {**default_plotting_dict, **wake_plotting_dict} + + N_turbs = len(fi.floris.farm.turbine_definitions) + + if D is None: + D = fi.floris.farm.turbine_definitions[0]['rotor_diameter'] + # TODO: build out capability to use multiple diameters, if of interest. + # D = np.array([turb['rotor_diameter'] for turb in + # fi.floris.farm.turbine_definitions]) + #else: + #D = D*np.ones(N_turbs) + + dists_m = np.zeros((N_turbs, N_turbs)) + angles_d = np.zeros((N_turbs, N_turbs)) + + for i in range(N_turbs): + for j in range(N_turbs): + dists_m[i,j] = np.linalg.norm( + [fi.layout_x[i]-fi.layout_x[j], fi.layout_y[i]-fi.layout_y[j]] + ) + angles_d[i,j] = wake_angle( + fi.layout_x[i], fi.layout_y[i], fi.layout_x[j], fi.layout_y[j] + ) + + # Mask based on the limit distance (assumed to be in measurement D) + if limit_dist_D is not None and limit_dist_m is None: + limit_dist_m = limit_dist_D * D + if limit_dist_m is not None: + mask = dists_m > limit_dist_m + dists_m[mask] = np.nan + angles_d[mask] = np.nan + + # Handle default limit number case + if limit_num is None: + limit_num = -1 + + # Loop over pairs, plot + label_exists = np.full((N_turbs, N_turbs), False) + for i in range(N_turbs): + for j in range(N_turbs): + #import ipdb; ipdb.set_trace() + if ~np.isnan(dists_m[i, j]) and \ + dists_m[i, j] != 0.0 and \ + ~(dists_m[i, j] > np.sort(dists_m[i,:])[limit_num]): + + (l,) = ax.plot(fi.layout_x[[i,j]], fi.layout_y[[i,j]], + **wake_plotting_dict) + + # Only label in one direction + if ~label_exists[i,j]: + + linetext = "{0:.1f} D --- {1:.0f}/{2:.0f}".format( + dists_m[i,j] / D, + angles_d[i,j], + angles_d[j,i], + ) + + label_line( + l, linetext, ax, near_i=1, near_x=None, near_y=None, + rotation_offset=0 + ) + + label_exists[i,j] = True + label_exists[j,i] = True + + +def wake_angle(x_i, y_i, x_j, y_j): + """ + Get angles between turbines in wake direction + + Args: + x_i: x location of turbine i + y_i: y location of turbine i + x_j: x location of turbine j + y_j: y location of turbine j + + Returns: + wakeAngle (float): angle between turbines relative to compass + """ + wakeAngle = ( + np.arctan2(y_i - y_j, x_i - x_j) * 180.0 / np.pi + ) # Angle in normal cartesian coordinates + + # Convert angle to compass angle + wakeAngle = 270.0 - wakeAngle + if wakeAngle < 0: + wakeAngle = wakeAngle + 360.0 + if wakeAngle > 360: + wakeAngle = wakeAngle - 360.0 + + return wakeAngle + +def label_line( + line, + label_text, + ax, + near_i=None, + near_x=None, + near_y=None, + rotation_offset=0.0, + offset=(0, 0), +): + """ + [summary] + + Args: + line (matplotlib.lines.Line2D): line to label. + label_text (str): label to add to line. + ax (:py:class:`matplotlib.pyplot.axes` optional): figure axes. + near_i (int, optional): Catch line near index i. + Defaults to None. + near_x (float, optional): Catch line near coordinate x. + Defaults to None. + near_y (float, optional): Catch line near coordinate y. + Defaults to None. + rotation_offset (float, optional): label rotation in degrees. + Defaults to 0. + offset (tuple, optional): label offset from turbine location. + Defaults to (0, 0). + + Raises: + ValueError: ("Need one of near_i, near_x, near_y") raised if + insufficient information is passed in. + """ + + def put_label(i): + """ + Add a label to index. + + Args: + i (int): index to label. + """ + i = min(i, len(x) - 2) + dx = sx[i + 1] - sx[i] + dy = sy[i + 1] - sy[i] + rotation = np.rad2deg(np.arctan2(dy, dx)) + rotation_offset + pos = [(x[i] + x[i + 1]) / 2.0 + offset[0], (y[i] + y[i + 1]) / 2 + offset[1]] + plt.text( + pos[0], + pos[1], + label_text, + size=7, + rotation=rotation, + color=line.get_color(), + ha="center", + va="center", + bbox=dict(ec="1", fc="1", alpha=0.8), + ) + + # extract line data + x = line.get_xdata() + y = line.get_ydata() + + # define screen spacing + if ax.get_xscale() == "log": + sx = np.log10(x) + else: + sx = x + if ax.get_yscale() == "log": + sy = np.log10(y) + else: + sy = y + + # find index + if near_i is not None: + i = near_i + if i < 0: # sanitize negative i + i = len(x) + i + put_label(i) + elif near_x is not None: + for i in range(len(x) - 2): + if (x[i] < near_x and x[i + 1] >= near_x) or ( + x[i + 1] < near_x and x[i] >= near_x + ): + put_label(i) + elif near_y is not None: + for i in range(len(y) - 2): + if (y[i] < near_y and y[i + 1] >= near_y) or ( + y[i + 1] < near_y and y[i] >= near_y + ): + put_label(i) + else: + raise ValueError("Need one of near_i, near_x, near_y") + + + + + + + + + + + + + diff --git a/flasc/wake_steering/yaw_optimizer_visualization.py b/flasc/wake_steering/yaw_optimizer_visualization.py index 0b7e9c53..153d9dfa 100644 --- a/flasc/wake_steering/yaw_optimizer_visualization.py +++ b/flasc/wake_steering/yaw_optimizer_visualization.py @@ -150,3 +150,124 @@ def _plot_bins(x, y, yn, xlabel=None, ylabel=None, labels=None): ax[1].legend() return fig, ax + +def plot_offsets_wswd_heatmap(df_offsets, turb_id, ax=None): + """ + df_offsets should be a dataframe with columns: + - wind_direction, + - wind_speed, + - turbine identifiers (possibly multiple) + + Produces a heat map of the offsets for all wind directions and + wind speeds for turbine specified by turb_id. Dataframe is assumed + to contain individual turbine offsets in distinct columns (unlike + the yaw_angles_opt column from FLORIS. + + """ + + if type(turb_id) is int: + if "yaw_angles_opt" in df_offsets.columns: + offsets = np.vstack( + df_offsets.yaw_angles_opt.to_numpy() + )[:,turb_id] + df_offsets = pd.DataFrame({ + "wind_direction":df_offsets.wind_direction, + "wind_speed":df_offsets.wind_speed, + "yaw_offset":offsets + }) + turb_id = "yaw_offset" + else: + raise TypeError("Specify turb_id as a full string for the "+\ + "correct dataframe column.") + + ws_array = np.unique(df_offsets.wind_speed) + wd_array = np.unique(df_offsets.wind_direction) + + # Construct array of offets + offsets_array = np.zeros((len(ws_array), len(wd_array))) + for i, ws in enumerate(ws_array): + offsets_array[-i,:] = (df_offsets + [df_offsets.wind_speed == ws] + [turb_id] + .values + ) + + if ax == None: + fig, ax = plt.subplots(1,1) + d_wd = (wd_array[1]-wd_array[0])/2 + d_ws = (ws_array[1]-ws_array[0])/2 + im = ax.imshow( + offsets_array, interpolation=None, + extent=[wd_array[0]-d_wd, wd_array[-1]+d_wd, + ws_array[0]-d_ws, ws_array[-1]+d_ws], + aspect='auto' + ) + ax.set_xlabel('Wind direction') + ax.set_ylabel('Wind speed') + cbar = plt.colorbar(im, ax=ax, orientation='vertical') + cbar.set_label('Yaw offset') + + return ax, cbar + + +def plot_offsets_wd(df_offsets, turb_id, ws_plot, color="black", alpha=1.0, + label=None, ax=None): + """ + df_offsets should be a dataframe with columns: + - wind_direction, + - wind_speed, + - turbine identifiers (possibly multiple) + + if ws_plot is scalar, only that wind speed is plotted. If ws_plot is + a two-element tuple or list, that range of wind speeds is plotted. + + label only allowed is single wind speed is given. + """ + + if type(turb_id) is int: + if "yaw_angles_opt" in df_offsets.columns: + offsets = np.vstack( + df_offsets.yaw_angles_opt.to_numpy() + )[:,turb_id] + df_offsets = pd.DataFrame({ + "wind_direction":df_offsets.wind_direction, + "wind_speed":df_offsets.wind_speed, + "yaw_offset":offsets + }) + turb_id = "yaw_offset" + else: + raise TypeError("Specify turb_id as a full string for the "+\ + "correct dataframe column.") + + if hasattr(ws_plot, '__len__') and label is not None: + label = None + print("label option can only be used for signle wind speed plot.") + + ws_array = np.unique(df_offsets.wind_speed) + wd_array = np.unique(df_offsets.wind_direction) + + if hasattr(ws_plot, '__len__'): + offsets_list = [] + for ws in ws_array: + if ws >= ws_plot[0] and ws <= ws_plot[-1]: + offsets_list.append(df_offsets + [df_offsets.wind_speed == ws] + [turb_id] + .values + ) + else: + offsets_list = [df_offsets + [df_offsets.wind_speed == ws_plot] + [turb_id] + .values] + + if ax == None: + fig, ax = plt.subplots(1,1) + + for offsets in offsets_list: + ax.plot(wd_array, offsets, color=color, alpha=alpha, label=label) + + ax.set_xlabel('Wind direction') + ax.set_ylabel('Yaw offset') + + return ax diff --git a/requirements.txt b/requirements.txt index b283b7eb..ff4aea06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,15 @@ floris>=3.1 -feather-format>=0.4.1 -matplotlib>=3 -openoa>=2.0.1 -numpy==1.21 -numba>=0.55.0 -pandas>=1.3.0,<=1.4.0 -pyproj>=2.1 -pytest>=4 -SALib>=1.4.0.2 -scipy>=1.1 -sqlalchemy>=1.4.23 -streamlit>=0.89.0 -tkcalendar>=1.6.1 +feather-format +matplotlib +openoa +numpy +numba +pandas>=1.5 +pyproj +pytest +SALib +scipy +sqlalchemy +streamlit +tkcalendar +seaborn diff --git a/setup.py b/setup.py index 078d19d5..4348c2d4 100644 --- a/setup.py +++ b/setup.py @@ -1,29 +1,41 @@ - - """The setup script.""" +from pathlib import Path from setuptools import setup, find_packages -with open('README.rst') as readme_file: - readme = readme_file.read() +# Package meta-data. +NAME = "flasc" +DESCRIPTION = "FLASC provides a rich suite of analysis tools for SCADA data filtering & analysis, wind farm model validation, field experiment design, and field experiment monitoring." +URL = "https://github.com/NREL/flasc" +EMAIL = "paul.fleming@nrel.gov" +AUTHOR = "NREL National Wind Technology Center" -requirements = [ +# What packages are required for this module to be executed? +REQUIRED = [ 'floris>=3.1', - 'feather-format>=0.4.1', - 'matplotlib>=3', - 'numpy==1.21', - 'numba>=0.55.0', - 'openoa>=2.0.1', - 'pandas>=1.3.0,<=1.4.0', - 'pyproj>=2.1', - 'pytest>=4', - 'SALib>=1.4.0.2', - 'scipy>=1.1', - 'sqlalchemy>=1.4.23', - 'streamlit>=0.89.0', - 'tkcalendar>=1.6.1', + 'feather-format', + 'matplotlib', + 'numpy', + 'numba', + 'openoa', + 'pandas>=1.5', + 'pyproj', + 'pytest', + 'SALib', + 'scipy', + 'sqlalchemy', + 'streamlit', + 'tkcalendar', + 'seaborn' ] +ROOT = Path(__file__).parent +with open(ROOT / "flasc" / "version.py") as version_file: + VERSION = version_file.read().strip() + +with open('README.rst') as readme_file: + README = readme_file.read() + setup_requirements = [ # Placeholder ] @@ -33,13 +45,13 @@ ] setup( - name='flasc', - version='1.0', - description="FLASC provides a rich suite of analysis tools for SCADA data filtering & analysis, wind farm model validation, field experiment design, and field experiment monitoring.", - long_description=readme, - author="Bart Doekemeijer", - author_email='bart.doekemeijer@nrel.gov', - url='https://github.com/NREL/flasc', + name=NAME, + version=VERSION, + description=DESCRIPTION, + long_description=README, + author=AUTHOR, + author_email=EMAIL, + url=URL, packages=find_packages(include=['flasc']), entry_points={ 'console_scripts': [ @@ -47,7 +59,7 @@ ] }, include_package_data=True, - install_requires=requirements, + install_requires=REQUIRED, license="Apache Software License 2.0", zip_safe=False, keywords='flasc', diff --git a/tests/df_time_operations_test.py b/tests/df_time_operations_test.py index 1f287f2f..3e310a1a 100644 --- a/tests/df_time_operations_test.py +++ b/tests/df_time_operations_test.py @@ -66,28 +66,25 @@ def test_downsampling(self): def test_moving_average(self): df = load_data() - df_ma, data_indices = df_movingaverage( + df_ma = df_movingaverage( df_in=df, cols_angular=["wd_000"], window_width=td(seconds=5), min_periods=1, center=True, calc_median_min_max_std=True, - return_index_mapping=True, ) # Check solutions: for first row which just used one value for mov avg self.assertAlmostEqual(df_ma.iloc[0]["ws_000_mean"], 7.0) self.assertTrue(np.isnan(df_ma.iloc[0]["ws_000_std"])) - self.assertTrue(np.all(np.unique(data_indices[0, :]) == [-1, 0])) + #self.assertTrue(np.all(np.unique(data_indices[0, :]) == [-1, 0])) # Check solutions: second row with multiple values - self.assertTrue(np.all(np.unique(data_indices[1, :]) == [-1, 1, 2, 3])) self.assertAlmostEqual(df_ma.iloc[1]["wd_000_mean"], 359.667246, places=4) # confirm circular averaging - self.assertAlmostEqual(df_ma.iloc[1]["wd_000_std"], 2.624669, places=4) # confirm circular std + self.assertAlmostEqual(df_ma.iloc[1]["wd_000_std"], 2.625014, places=4) # confirm circular std # Check solutions: sixth row, for good measure - self.assertTrue(np.all(np.unique(data_indices[6, :]) == [-1, 6])) self.assertAlmostEqual(df_ma.iloc[6]["wd_000_mean"], 0.0) # confirm circular averaging self.assertTrue(np.isnan(df_ma.iloc[6]["ws_000_std"])) self.assertTrue(np.isnan(df_ma.iloc[6]["vane_000_std"])) diff --git a/tests/optimization_test.py b/tests/optimization_test.py index 644f42ac..a36fb8ef 100644 --- a/tests/optimization_test.py +++ b/tests/optimization_test.py @@ -1,6 +1,6 @@ import numpy as np import pandas as pd -from pandas.core.base import DataError +from pandas.errors import DataError import unittest from flasc.optimization import (