diff --git a/.gitignore b/.gitignore index e8824258..806429ed 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,9 @@ dmypy.json # VSCode .vscode/ +# Emacs +*~ + # Ignore Pandas _libs files pandas/_libs/ diff --git a/landbosse/landbosse_omdao/CsvGenerator.py b/landbosse/landbosse_omdao/CsvGenerator.py new file mode 100644 index 00000000..a25a03ab --- /dev/null +++ b/landbosse/landbosse_omdao/CsvGenerator.py @@ -0,0 +1,113 @@ +import pandas as pd + + +class CsvGenerator: + """ + This class generates CSV files. + """ + + def __init__(self, file_ops): + """ + Parameters + ---------- + file_ops : XlsxFileOperations + An instance of XlsxFileOperations to manage file names. + """ + self.file_ops = file_ops + + def create_details_dataframe(self, details): + """ + This writes the details .csv. + + Parameters + ---------- + details : list[dict] + A list of dictionaries to be converted into a Pandas dataframe + + Returns + ------- + pd.DataFrame + The dataframe that can be written to a .csv file. + """ + + # This the list of details to write to the .csv + details_to_write_to_csv = [] + for row in details: + new_row = {} + new_row["Project ID with serial"] = row["project_id_with_serial"] + new_row["Module"] = row["module"] + new_row["Variable name"] = row["variable_df_key_col_name"] + new_row["Unit"] = row["unit"] + + value = row["value"] + value_is_number = self._is_numeric(value) + if value_is_number: + new_row["Numeric value"] = value + else: + new_row["Non-numeric value"] = value + + # If there is a last_number, which means this is a dataframe row that has a number + # at the end, write this into the numeric value column. This overrides automatic + # type detection. + + if "last_number" in row: + new_row["Numeric value"] = row["last_number"] + + details_to_write_to_csv.append(new_row) + + details = pd.DataFrame(details_to_write_to_csv) + + return details + + def create_costs_dataframe(self, costs): + """ + Parameters + ---------- + costs : list[dict] + The list of dictionaries of costs. + + Returns + ------- + pd.DataFrame + A dataframe to be written as a .csv + """ + new_rows = [] + for row in costs: + new_row = { + "Project ID with serial": row["project_id_with_serial"], + "Number of turbines": row["num_turbines"], + "Turbine rating MW": row["turbine_rating_MW"], + "Rotor diameter m": row["rotor_diameter_m"], + "Module": row["module"], + "Type of cost": row["type_of_cost"], + "Cost per turbine": row["cost_per_turbine"], + "Cost per project": row["cost_per_project"], + "Cost per kW": row["usd_per_kw_per_project"], + } + new_rows.append(new_row) + costs_df = pd.DataFrame(new_rows) + return costs_df + + def _is_numeric(self, value): + """ + This method tests if a value is a numeric (that is, can be parsed + by float()) or non numeric (which cannot be parsed). + + The decision from this method determines whether values go into + the numeric or non-numeric columns. + + Parameters + ---------- + value + The value to be tested. + + Returns + ------- + bool + True if the value is numeric, False otherwise. + """ + try: + float(value) + except ValueError: + return False + return True diff --git a/landbosse/landbosse_omdao/GridSearchTree.py b/landbosse/landbosse_omdao/GridSearchTree.py new file mode 100644 index 00000000..48bb215b --- /dev/null +++ b/landbosse/landbosse_omdao/GridSearchTree.py @@ -0,0 +1,165 @@ +import numpy as np +import pandas as pd + +""" +This module contains the logic to handle a tree to compute +points in an N-dimensional parametric search space. +""" + + +class GridSearchTreeNode: + """ + This just contains information about a node in the grid + search tree. + """ + + def __init__(self): + self.cell_specification = None + self.children = [] + self.value = None + + +class GridSearchTree: + """ + This class implements a k-ary tree to compute possible + combinations of points in a N-dimensional parametric + search space. + """ + + def __init__(self, parametric_list): + """ + This simply sets the parametric_list. See the first dataframe + described in the docstring of XlsxReader.create_parametric_value_list() + + Parameters + ---------- + parametric_list : pandas.DataFrame + The dataframe of the parametrics list. + """ + self.parametric_list = parametric_list + + def build_grid_tree_and_return_grid(self): + """ + See the dataframes in XlsxReader.create_parametric_value_list() + for context. + + This builds a tree of points in the search space and traverse + it to find points on the grid. + + Returns + ------- + """ + + # Build the tree. Its leaf nodes contain the values for each + # point in the grid. + root = self.build_tree() + + # Recursions of the traversal method needs to start with an empty + # list. + grid = self.dfs_search_tree(root, traversal=[]) + return grid + + def build_tree(self, depth=0, root=None): + """ + This method builds a k-ary tree to contain cell_specifications and + their values. + + Callers from outside this method shouldn't override the defaults + for the parameters. These parameters are to manage the recursion, + and are supplied by this method when it invokes itself. + + Parameters + ---------- + root : GridSearchTreeNode + The root of the subtree. At the start of iteration, at the + root of the whole tree, this should be None. + + depth : int + The level of the tree currently being built. This is + also the row number in the dataframe from which the tree + is being built. + + Returns + ------- + GridSearchTreeNode + The root of the tree just built. + """ + row = self.parametric_list.iloc[depth] + cell_specification = f"{row['Dataframe name']}/{row['Row name']}/{row['Column name']}" + + # First, make an iterable of the range we are going to be using. + if "Value list" in row and not pd.isnull(row["Value list"]): + values = [float(value) for value in row["Value list"].split(",")] + else: + start = row["Min"] + end = row["Max"] + step = row["Step"] + values = np.arange(start, end + step, step) + + if root == None: + root = GridSearchTreeNode() + + # Putting the stop at end + step ensures the end value is in the sequence + # + # Append children for each value in the parametric step sequence. + + for value in values: + child = GridSearchTreeNode() + child.value = value + child.cell_specification = cell_specification + root.children.append(child) + + # If there are more levels of variables to add, recurse + # down 1 level. + if len(self.parametric_list) > depth + 1: + self.build_tree(depth + 1, child) + + return root + + def dfs_search_tree(self, root, traversal, path=None): + """ + This does a depth first search traversal of the GridSearchTree + specified by the root parameter. It stores the node it encounters + in the list referenced by traversal. + + There is a distinction from normal DFS traversals: Only leaf nodes + are recorded in the traversal. This means that only nodes that have + a complete list of cell specifications and values are returned. + + Parameters + ---------- + root : GridSearchTreeNode + The root of the + + traversal : list + The nodes traversed on the tree. When this method is called + by an external caller, this should be an empty list ([]) + + path : list + This shouldn't be manipulated except by this method itself. + It is for storing the paths to the leaf nodes. + + Returns + ------- + list + A list of dictionaries that hold the cell specifications and + values of each leaf node. + """ + + path = [] if path is None else path[:] + + if root.cell_specification is not None: + path.append( + { + "cell_specification": root.cell_specification, + "value": root.value, + } + ) + + if len(root.children) == 0: + traversal.append(path) + + for child in root.children: + self.dfs_search_tree(child, traversal, path) + + return traversal diff --git a/landbosse/landbosse_omdao/OpenMDAODataframeCache.py b/landbosse/landbosse_omdao/OpenMDAODataframeCache.py new file mode 100644 index 00000000..46632834 --- /dev/null +++ b/landbosse/landbosse_omdao/OpenMDAODataframeCache.py @@ -0,0 +1,109 @@ +import os + +import warnings + +with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="numpy.ufunc size changed") + import pandas as pd + + +# The library path is where to find the default input data for LandBOSSE. +ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) +if ROOT.endswith('wisdem'): + library_path = os.path.join(ROOT, "library", "landbosse") +else: + library_path = os.path.join(ROOT, "project_input_template", "project_data") + + +class OpenMDAODataframeCache: + """ + This class does not need to be instantiated. This means that the + cache is shared throughout all parts of the code that needs access + to any part of the project_data .xlsx files. + + This class is made to read all sheets from xlsx files and store those + sheets as dictionaries. This is so .xlsx files only need to be parsed + once. + + One of the use cases for this dataframe cache is in parallel process + execution using ProcessPoolExecutor. Alternatively, once code use + the ThreadPoolExecutor (though that wouldn't give the same advantages + of paralelization). + + Regardless of which executor is used, care must be taken that one thread + or process cannot mutate the dataframes of another process. So, this + class make copies of dataframes so the callables running from the + executor cannot overwrite each other's data. + """ + + # _cache is a class attribute that holds the cache of sheets and their + # dataframes + _cache = {} + + @classmethod + def read_all_sheets_from_xlsx(cls, xlsx_basename, xlsx_path=None): + """ + If the .xlsx file specified by .xlsx_basename has been read before + (meaning it is stored as a key on cls._cache), a copy of all the + dataframes stored under that sheet name is returned. See the note + about copying in the class docstring for why copies are being made. + + If the xlsx_basename has not been read before, all the sheets are + read and copies are returned. The sheets are stored on the dictionary + cache. + + Parameters + ---------- + xlsx_basename : str + The base name of the xlsx file to read. This name should + not include the .xlsx at the end of the filename. This class + uses XlsxFileOperations to find the dataframes in the + project_data directory. The xlsx_basename becomes the key + in the dictionary used to access all the sheets in the + named .xlsx file. + + xlsx_path : str + The path from which to read the .xlsx file. This parameter + has the default value of the library path variable above. + + Returns + ------- + dict + A dictionary of dataframes. Keys on the dictionary are names of + sheets and values in the dictionary are dataframes in that + .xlsx file. + """ + if xlsx_basename in cls._cache: + original = cls._cache[xlsx_basename] + return cls.copy_dataframes(original) + + if xlsx_path is None: + xlsx_filename = os.path.join(library_path, f"{xlsx_basename}.xlsx") + else: + xlsx_filename = os.path.join(xlsx_path, f"{xlsx_basename}.xlsx") + + xlsx = pd.ExcelFile(xlsx_filename, engine='openpyxl') + sheets_dict = {sheet_name: xlsx.parse(sheet_name) for sheet_name in xlsx.sheet_names} + for sheet_name in xlsx.sheet_names: + sheets_dict[sheet_name].dropna(inplace=True, how='all') + cls._cache[xlsx_basename] = sheets_dict + return cls.copy_dataframes(sheets_dict) + + @classmethod + def copy_dataframes(cls, dict_of_dataframes): + """ + This copies a dictionary of dataframes. See the class docstring for an + explanation of why this copying is taking place. + + Parameters + ---------- + dict_of_dataframes : dict + The dictionary of dataframes to copy. + + Returns + ------- + dict + Keys are the same as the original dictionary of dataframes. + Values are copies of the origin dataframes. + """ + return {xlsx_basename: df.copy() for xlsx_basename, df in dict_of_dataframes.items()} diff --git a/landbosse/landbosse_omdao/WeatherWindowCSVReader.py b/landbosse/landbosse_omdao/WeatherWindowCSVReader.py new file mode 100644 index 00000000..90104262 --- /dev/null +++ b/landbosse/landbosse_omdao/WeatherWindowCSVReader.py @@ -0,0 +1,179 @@ +from math import ceil + +import pandas as pd + + +SEASON_WINTER = "winter" +SEASON_SPRING = "spring" +SEASON_SUMMER = "summer" +SEASON_FALL = "fall" + + +month_numbers_to_seasons = { + 1: SEASON_WINTER, + 2: SEASON_WINTER, + 3: SEASON_WINTER, + 4: SEASON_SPRING, + 5: SEASON_SPRING, + 6: SEASON_SPRING, + 7: SEASON_SUMMER, + 8: SEASON_SUMMER, + 9: SEASON_SUMMER, + 10: SEASON_FALL, + 11: SEASON_FALL, + 12: SEASON_FALL, +} + + +def read_weather_window(weather_data, local_timezone="America/Denver"): + """ + This function converts a wind toolkit (WTK) formatted dataframe into + a dataframe suitable for calculations. + + The .csv should have the first 5 columns of: + + Date, Temp C, Pressure atm, Direction deg, Speed m per s + + Other columns are ignored, headers are ignored and the first + four lines are skipped. Dates in this file are assumed to be + UTC. All columns which contain numeric only data are cast to + float. + + It parses the local version of the date into year, month, day, + hour. It also labels hours between 8 and 18 inclusive as 'normal' + and hours from 18 to 7 as 'long'. + + The columns returned in the dataframe are: + + 'Date UTC': The date and time in UTC of the measurements in that row. + + 'Date': The date, localized to the timezone specified in the + local_timezone parameter. See parameter list below. + + 'Year': An integer of the year of the local time zone date + + 'Month': An integer of the month of the local time zone date + + 'Day': An integer of the day of the local time zone date + + 'Hour': An integer of the hour of the local time zone date + + 'Time window': If the integer hour is between 8 and 18 inclusive, + this is 'normal'. For hours outside of that range, this is + 'long'. + + 'Season': Season of the year. Months 1, 2, 3 are winter; months 4, 5, 6 + are spring; months 7, 8, 9 are summer; months 10, 11, 12 are fall. + + 'Pressure atm': Air pressure in atm. + + 'Direction deg': Wind direction in degrees. + + 'Speed m per s': Wind speed in meters per second. + + Parameters + ---------- + filename : str + The filename to read for the csv. + + local_timezone : str + The local timezone. The is a TZ database name for the timezone. + Find the TZ database listing at + https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + + Returns + ------- + pd.DataFrame + A pandas data frame made from the CSV + """ + # set column names for weather data and keep only the renamed columns + column_names = weather_data[4:].columns + renamed_columns = { + column_names[0]: "Date UTC", + column_names[1]: "Temp C", + column_names[2]: "Pressure atm", + column_names[3]: "Direction deg", + column_names[4]: "Speed m per s", + } + weather_data = weather_data[4:].rename(columns=renamed_columns) + weather_data = weather_data.reset_index(drop=True) + weather_data = weather_data[renamed_columns.values()] + + # Parse the datetime data and localize it to UTC + weather_data["Date UTC"] = pd.to_datetime(weather_data["Date UTC"]).dt.tz_localize("UTC") + + # Convert UTC to local time + weather_data["Date"] = weather_data["Date UTC"].dt.tz_convert(local_timezone) + + # Extract month, day, hour from the local date + weather_data["Month"] = weather_data["Date"].dt.month + weather_data["Day"] = weather_data["Date"].dt.day + weather_data["Hour"] = weather_data["Date"].dt.hour + + # The original date columns are now redundant. Drop them. + weather_data.drop(columns=["Date", "Date UTC"]) + + # create time window for normal (8am to 6pm) versus long (24 hour) time window for operation + weather_data["Time window"] = weather_data["Hour"].between(8, 18, inclusive=True) + boolean_dictionary = {True: "normal", False: "long"} + weather_data["Time window"] = weather_data["Time window"].map(boolean_dictionary) + + # Add a seasons column + weather_data["Season"] = weather_data["Month"].map(month_numbers_to_seasons) + + # Cast the columns that are numeric to float64 + columns_to_cast = ["Pressure atm", "Direction deg", "Speed m per s"] + for column_to_cast in columns_to_cast: + weather_data[column_to_cast] = pd.to_numeric(weather_data[column_to_cast], downcast="float") + + # return the result + return weather_data + + +def extend_weather_window(weather_window_df, months_of_weather_data_needed): + """ + This function extends a weather window by duplicating the rows to create + a weather window that spans the needed number of months. + + Suppose that a weather window is 8760 hours long, but that 72 months + (52560 hours) are needed. This method will create a new dataframe + that has the original 8760 hours duplicated 6 times. + + However, if the number of needed months is less than or equal to the + duration of the weather window, a reference to the original weather + window is returned. Also, the number of rows returned will always be + a multiple of the number of rows in the original data frame. + + If rows are added to the weather window, they are added to a new dataframe. + The weather window is not modified in place. + + Parameters + ---------- + weather_window_df : pd.DataFrame + The original weather window. + + months_of_weather_data_needed : int + The number of months of weather data needed. Each month is approximated + to have 730 hours. + + Returns + ------- + pd.DataFrame + If the weather window accommodates the necessary number of months, then + a reference to the original dataframe is returned. Otherwise, a reference + to a new dataframe that has a row count that is a multiple of the number + of rows in the original weather window. + """ + hours_per_month = 730 + hours_of_weather_data_needed = hours_per_month * months_of_weather_data_needed + hours_of_weather_data_available = len(weather_window_df) + + if hours_of_weather_data_needed <= hours_of_weather_data_available: + return weather_window_df + + number_of_windows_needed = int(ceil(hours_of_weather_data_needed / hours_of_weather_data_available)) + weather_window_as_list = weather_window_df.to_dict(orient="records") + weather_window_repeated_as_list = weather_window_as_list * number_of_windows_needed + result = pd.DataFrame(weather_window_repeated_as_list) + + return result diff --git a/landbosse/landbosse_omdao/XlsxOperationException.py b/landbosse/landbosse_omdao/XlsxOperationException.py new file mode 100644 index 00000000..63060f1e --- /dev/null +++ b/landbosse/landbosse_omdao/XlsxOperationException.py @@ -0,0 +1,12 @@ +class XlsxOperationException(Exception): + """ + This exception is raised for errors that occur when processing .xlsx + spreadsheet files. + + It has no custom implmentation. It is here to provide a class that + can be specifically caught in an except statement. + + See also: https://docs.python.org/3/library/exceptions.html + """ + + pass diff --git a/landbosse/landbosse_omdao/XlsxValidator.py b/landbosse/landbosse_omdao/XlsxValidator.py new file mode 100644 index 00000000..5d4fc220 --- /dev/null +++ b/landbosse/landbosse_omdao/XlsxValidator.py @@ -0,0 +1,102 @@ +import pandas as pd + +class XlsxValidator: + """ + XlsxValidator is for comparing the results of a previous model run + to the results of a current model run. + """ + + def compare_expected_to_actual(self, expected_xlsx, actual_module_type_operation_list, validation_output_xlsx): + """ + This compares the expected costs as calculated by a prior model run + with the actual results from a current model run. + + It compares the results row by row and prints any differences. + + Parameters + ---------- + expected_xlsx : str + The absolute filename of the expected output .xlsx file. + + actual_module_type_operation_list : str + The module_type_operation_list as returned by a subclass of + XlsxManagerRunner. + + validation_output_xlsx : str + The absolute pathname to the output file with the comparison + results. + + Returns + ------- + bool + True if the expected and actual results are equal. It returns + False otherwise. + """ + # First, make the list of dictionaries into a dataframe, and drop + # the raw_cost and raw_cost_total_or_per_turbine columns. + actual_df = pd.DataFrame(actual_module_type_operation_list) + actual_df.drop(['raw_cost', 'raw_cost_total_or_per_turbine'], axis=1, inplace=True) + expected_df = pd.read_excel(expected_xlsx, 'costs_by_module_type_operation', engine='openpyxl') + #expected_df = expected_df.dropna(inplace=True, how='all') + expected_df.rename(columns={ + 'Project ID with serial': 'project_id_with_serial', + 'Number of turbines': 'num_turbines', + 'Turbine rating MW': 'turbine_rating_MW', + 'Module': 'module', + 'Operation ID': 'operation_id', + 'Type of cost': 'type_of_cost', + 'Cost per turbine': 'cost_per_turbine', + 'Cost per project': 'cost_per_project', + 'USD/kW per project': 'usd_per_kw_per_project' + }, inplace=True) + + cost_per_project_actual = actual_df[ + ['cost_per_project', 'project_id_with_serial', 'module', 'operation_id', 'type_of_cost']] + cost_per_project_expected = expected_df[ + ['cost_per_project', 'project_id_with_serial', 'module', 'operation_id', 'type_of_cost']] + + comparison = cost_per_project_actual.merge( + cost_per_project_expected, + on=['project_id_with_serial', 'module', 'operation_id', 'type_of_cost']) + + comparison.rename(columns={'cost_per_project_x': 'cost_per_project_actual', + 'cost_per_project_y': 'cost_per_project_expected'}, inplace=True) + + comparison['difference_validation'] = comparison['cost_per_project_actual'] - comparison['cost_per_project_expected'] + + # Regardless of the outcome, write the end result of the comparison + # to the validation output file. + columns_for_comparison_output = [ + 'project_id_with_serial', + 'module', + 'operation_id', + 'type_of_cost', + 'cost_per_project_actual', + 'cost_per_project_expected', + 'difference_validation' + ] + comparison.to_excel(validation_output_xlsx, index=False, columns=columns_for_comparison_output) + + # If the comparison dataframe is empty, that means there are no common + # projects in the expected data that match the actual data. + if len(comparison) < 1: + print('=' * 80) + print('Validation error: There are no common projects between actual and expected data.') + print('=' * 80) + return False + + # Find all rows where the difference is unequal to 0. These are rows + # that failed validation. Note that, after the join, the rows may be + # in a different order than the originals. + # + # Round the difference to a given number of decimal places. + failed_rows = comparison[comparison['difference_validation'].round(decimals=4) != 0] + + if len(failed_rows) > 0: + print('=' * 80) + print('The following rows failed validation:') + print(failed_rows) + print('=' * 80) + return False + else: + return True diff --git a/landbosse/landbosse_omdao/__init__.py b/landbosse/landbosse_omdao/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/landbosse/landbosse_omdao/landbosse.py b/landbosse/landbosse_omdao/landbosse.py new file mode 100644 index 00000000..d1bf9d6f --- /dev/null +++ b/landbosse/landbosse_omdao/landbosse.py @@ -0,0 +1,788 @@ +import openmdao.api as om +from math import ceil +import numpy as np +import warnings + +with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="numpy.ufunc size changed") + import pandas as pd + +from landbosse.model.Manager import Manager +from landbosse.model.DefaultMasterInputDict import DefaultMasterInputDict +from landbosse.landbosse_omdao.OpenMDAODataframeCache import OpenMDAODataframeCache +from landbosse.landbosse_omdao.WeatherWindowCSVReader import read_weather_window + +use_default_component_data = -1.0 + + +class LandBOSSE(om.Group): + def setup(self): + + # Add a tower section height variable. The default value of 30 m is for transportable tower sections. + self.set_input_defaults("tower_section_length_m", 30.0, units="m") + self.set_input_defaults("blade_drag_coefficient", use_default_component_data) # Unitless + self.set_input_defaults("blade_lever_arm", use_default_component_data, units="m") + self.set_input_defaults("blade_install_cycle_time", use_default_component_data, units="h") + self.set_input_defaults("blade_offload_hook_height", use_default_component_data, units="m") + self.set_input_defaults("blade_offload_cycle_time", use_default_component_data, units="h") + self.set_input_defaults("blade_drag_multiplier", use_default_component_data) # Unitless + + self.set_input_defaults("turbine_spacing_rotor_diameters", 4) + self.set_input_defaults("row_spacing_rotor_diameters", 10) + self.set_input_defaults("commissioning_pct", 0.01) + self.set_input_defaults("decommissioning_pct", 0.15) + self.set_input_defaults("trench_len_to_substation_km", 50.0, units="km") + self.set_input_defaults("interconnect_voltage_kV", 130.0, units="kV") + + self.set_input_defaults("foundation_height", 0.0, units="m") + self.set_input_defaults("blade_mass", 8000.0, units="kg") + self.set_input_defaults("hub_mass", 15.4e3, units="kg") + self.set_input_defaults("nacelle_mass", 50e3, units="kg") + self.set_input_defaults("tower_mass", 240e3, units="kg") + self.set_input_defaults("turbine_rating_MW", 1500.0, units="kW") + + self.add_subsystem("landbosse", LandBOSSE_API(), promotes=["*"]) + + +class LandBOSSE_API(om.ExplicitComponent): + def setup(self): + # Clear the cache + OpenMDAODataframeCache._cache = {} + + self.setup_inputs() + self.setup_outputs() + self.setup_discrete_outputs() + self.setup_discrete_inputs_that_are_not_dataframes() + self.setup_discrete_inputs_that_are_dataframes() + + def setup_inputs(self): + """ + This method sets up the inputs. + """ + self.add_input("blade_drag_coefficient", use_default_component_data) # Unitless + self.add_input("blade_lever_arm", use_default_component_data, units="m") + self.add_input("blade_install_cycle_time", use_default_component_data, units="h") + self.add_input("blade_offload_hook_height", use_default_component_data, units="m") + self.add_input("blade_offload_cycle_time", use_default_component_data, units="h") + self.add_input("blade_drag_multiplier", use_default_component_data) # Unitless + + # Even though LandBOSSE doesn't use foundation height, TowerSE does, + # and foundation height can be used with hub height to calculate + # tower height. + + self.add_input("foundation_height", 0.0, units="m") + + self.add_input("tower_section_length_m", 30.0, units="m") + self.add_input("nacelle_mass", 0.0, units="kg") + self.add_input("tower_mass", 0.0, units="kg") + + # A discrete input below, number_of_blades, gives the number of blades + # on the rotor. + # + # The total mass of the rotor nacelle assembly (RNA) is the following + # sum: + # + # (blade_mass * number_of_blades) + nac_mass + hub_mass + + self.add_input("blade_mass", use_default_component_data, units="kg", desc="The mass of one rotor blade.") + + self.add_input("hub_mass", use_default_component_data, units="kg", desc="Mass of the rotor hub") + + self.add_input( + "crane_breakdown_fraction", + val=0.0, + desc="0 means the crane is never broken down. 1 means it is broken down every turbine.", + ) + + self.add_input("construct_duration", val=9, desc="Total project construction time (months)") + self.add_input("hub_height_meters", val=80, units="m", desc="Hub height m") + self.add_input("rotor_diameter_m", val=77, units="m", desc="Rotor diameter m") + self.add_input("wind_shear_exponent", val=0.2, desc="Wind shear exponent") + self.add_input("turbine_rating_MW", val=1.5, units="MW", desc="Turbine rating MW") + self.add_input("fuel_cost_usd_per_gal", val=1.5, desc="Fuel cost USD/gal") + + self.add_input( + "breakpoint_between_base_and_topping_percent", val=0.8, desc="Breakpoint between base and topping (percent)" + ) + + # Could not place units in turbine_spacing_rotor_diameters + self.add_input("turbine_spacing_rotor_diameters", desc="Turbine spacing (times rotor diameter)", val=4) + + self.add_input("depth", units="m", desc="Foundation depth m", val=2.36) + self.add_input("rated_thrust_N", units="N", desc="Rated Thrust (N)", val=5.89e5) + + # Can't set units + self.add_input("bearing_pressure_n_m2", desc="Bearing Pressure (n/m2)", val=191521) + + self.add_input("gust_velocity_m_per_s", units="m/s", desc="50-year Gust Velocity (m/s)", val=59.5) + self.add_input("road_length_adder_m", units="m", desc="Road length adder (m)", val=5000) + + # Can't set units + self.add_input("fraction_new_roads", desc="Percent of roads that will be constructed (0.0 - 1.0)", val=0.33) + + self.add_input("road_quality", desc="Road Quality (0-1)", val=0.6) + self.add_input("line_frequency_hz", units="Hz", desc="Line Frequency (Hz)", val=60) + + # Can't set units + self.add_input("row_spacing_rotor_diameters", desc="Row spacing (times rotor diameter)", val=10) + + self.add_input( + "trench_len_to_substation_km", units="km", desc="Combined Homerun Trench Length to Substation (km)", val=50 + ) + self.add_input("distance_to_interconnect_mi", units="mi", desc="Distance to interconnect (miles)", val=5) + self.add_input("interconnect_voltage_kV", units="kV", desc="Interconnect Voltage (kV)", val=130) + self.add_input( + "critical_speed_non_erection_wind_delays_m_per_s", + units="m/s", + desc="Non-Erection Wind Delay Critical Speed (m/s)", + val=15, + ) + self.add_input( + "critical_height_non_erection_wind_delays_m", + units="m", + desc="Non-Erection Wind Delay Critical Height (m)", + val=10, + ) + self.add_discrete_input("road_distributed_winnd", val=False) + self.add_input("road_width_ft", units="ft", desc="Road width (ft)", val=20) + self.add_input("road_thickness", desc="Road thickness (in)", val=8) + self.add_input("crane_width", units="m", desc="Crane width (m)", val=12.2) + self.add_input("overtime_multiplier", desc="Overtime multiplier", val=1.4) + self.add_input("markup_contingency", desc="Markup contingency", val=0.03) + self.add_input("markup_warranty_management", desc="Markup warranty management", val=0.0002) + self.add_input("markup_sales_and_use_tax", desc="Markup sales and use tax", val=0) + self.add_input("markup_overhead", desc="Markup overhead", val=0.05) + self.add_input("markup_profit_margin", desc="Markup profit margin", val=0.05) + self.add_input("Mass tonne", val=(1.0,), desc="", units="t") + self.add_input( + "development_labor_cost_usd", val=1e6, desc="The cost of labor in the development phase", units="USD" + ) + # Disabled due to Pandas conflict right now. + self.add_input("labor_cost_multiplier", val=1.0, desc="Labor cost multiplier") + + self.add_input("commissioning_pct", 0.01) + self.add_input("decommissioning_pct", 0.15) + + def setup_discrete_inputs_that_are_not_dataframes(self): + """ + This method sets up the discrete inputs that aren't dataframes. + """ + self.add_discrete_input("num_turbines", val=100, desc="Number of turbines in project") + + # Since 3 blades are so common on rotors, that is a reasonable default + # value that will not need to be checked during component list + # assembly. + + self.add_discrete_input("number_of_blades", val=3, desc="Number of blades on the rotor") + + self.add_discrete_input( + "user_defined_home_run_trench", val=0, desc="Flag for user-defined home run trench length (0 = no; 1 = yes)" + ) + + self.add_discrete_input( + "allow_same_flag", + val=False, + desc="Allow same crane for base and topping (True or False)", + ) + + self.add_discrete_input( + "hour_day", + desc="Dictionary of normal and long hours for construction in a day in the form of {'long': 24, 'normal': 10}", + val={"long": 24, "normal": 10}, + ) + + self.add_discrete_input( + "time_construct", + desc="One of the keys in the hour_day dictionary to specify how many hours per day construction happens.", + val="normal", + ) + + self.add_discrete_input( + "user_defined_distance_to_grid_connection", + desc="Flag for user-defined home run trench length (True or False)", + val=False, + ) + + # Could not place units in rate_of_deliveries + self.add_discrete_input("rate_of_deliveries", val=10, desc="Rate of deliveries (turbines per week)") + + self.add_discrete_input("new_switchyard", desc="New Switchyard (True or False)", val=True) + self.add_discrete_input("num_hwy_permits", desc="Number of highway permits", val=10) + self.add_discrete_input("num_access_roads", desc="Number of access roads", val=2) + + def setup_discrete_inputs_that_are_dataframes(self): + """ + This sets up the default inputs that are dataframes. They are separate + because they hold the project data and the way we need to hold their + data is different. They have defaults loaded at the top of the file + which can be overridden outside by setting the properties listed + below. + """ + # Read in default sheets for project data + default_project_data = OpenMDAODataframeCache.read_all_sheets_from_xlsx("ge15_public") + + self.add_discrete_input( + "site_facility_building_area_df", + val=default_project_data["site_facility_building_area"], + desc="site_facility_building_area DataFrame", + ) + + self.add_discrete_input( + "components", + val=default_project_data["components"], + desc="Dataframe of components for tower, blade, nacelle", + ) + + self.add_discrete_input( + "crane_specs", val=default_project_data["crane_specs"], desc="Dataframe of specifications of cranes" + ) + + self.add_discrete_input( + "weather_window", + val=read_weather_window(default_project_data["weather_window"]), + desc="Dataframe of wind toolkit data", + ) + + self.add_discrete_input("crew", val=default_project_data["crew"], desc="Dataframe of crew configurations") + + self.add_discrete_input( + "crew_price", + val=default_project_data["crew_price"], + desc="Dataframe of costs per hour for each type of worker.", + ) + + self.add_discrete_input( + "equip", val=default_project_data["equip"], desc="Collections of equipment to perform erection operations." + ) + + self.add_discrete_input( + "equip_price", val=default_project_data["equip_price"], desc="Prices for various type of equipment." + ) + + self.add_discrete_input("rsmeans", val=default_project_data["rsmeans"], desc="RSMeans price data") + + self.add_discrete_input( + "cable_specs", val=default_project_data["cable_specs"], desc="cable specs for collection system" + ) + + self.add_discrete_input( + "material_price", + val=default_project_data["material_price"], + desc="Prices of materials for foundations and roads", + ) + + self.add_discrete_input("project_data", val=default_project_data, desc="Dictionary of all dataframes of data") + + def setup_outputs(self): + """ + This method sets up the continuous outputs. This is where total costs + and installation times go. + + To see how cost totals are calculated see, the compute_total_bos_costs + method below. + """ + self.add_output( + "bos_capex", 0.0, units="USD", desc="Total BOS CAPEX not including commissioning or decommissioning." + ) + self.add_output( + "bos_capex_kW", + 0.0, + units="USD/kW", + desc="Total BOS CAPEX per kW not including commissioning or decommissioning.", + ) + self.add_output( + "total_capex", 0.0, units="USD", desc="Total BOS CAPEX including commissioning and decommissioning." + ) + self.add_output( + "total_capex_kW", + 0.0, + units="USD/kW", + desc="Total BOS CAPEX per kW including commissioning and decommissioning.", + ) + self.add_output("installation_capex", 0.0, units="USD", desc="Total foundation and erection installation cost.") + self.add_output( + "installation_capex_kW", 0.0, units="USD", desc="Total foundation and erection installation cost per kW." + ) + self.add_output("installation_time_months", 0.0, desc="Total balance of system installation time (months).") + + def setup_discrete_outputs(self): + """ + This method sets up discrete outputs. + """ + self.add_discrete_output( + "landbosse_costs_by_module_type_operation", desc="The costs by module, type and operation", val=None + ) + + self.add_discrete_output( + "landbosse_details_by_module", + desc="The details from the run of LandBOSSE. This includes some costs, but mostly other things", + val=None, + ) + + self.add_discrete_output("erection_crane_choice", desc="The crane choices for erection.", val=None) + + self.add_discrete_output( + "erection_component_name_topvbase", + desc="List of components and whether they are a topping or base operation", + val=None, + ) + + self.add_discrete_output( + "erection_components", desc="List of components with their values modified from the defaults.", val=None + ) + + def compute(self, inputs, outputs, discrete_inputs=None, discrete_outputs=None): + """ + This runs the ErectionCost module using the inputs and outputs into and + out of this module. + + Note: inputs, discrete_inputs are not dictionaries. They do support + [] notation. inputs is of class 'openmdao.vectors.default_vector.DefaultVector' + discrete_inputs is of class openmdao.core.component._DictValues. Other than + [] brackets, they do not behave like dictionaries. See the following + documentation for details. + + http://openmdao.org/twodocs/versions/latest/_srcdocs/packages/vectors/default_vector.html + https://mdolab.github.io/OpenAeroStruct/_modules/openmdao/core/component.html + + Parameters + ---------- + inputs : openmdao.vectors.default_vector.DefaultVector + A dictionary-like object with NumPy arrays that hold float + inputs. Note that since these are NumPy arrays, they + need indexing to pull out simple float64 values. + + outputs : openmdao.vectors.default_vector.DefaultVector + A dictionary-like object to store outputs. + + discrete_inputs : openmdao.core.component._DictValues + A dictionary-like with the non-numeric inputs (like + pandas.DataFrame) + + discrete_outputs : openmdao.core.component._DictValues + A dictionary-like for non-numeric outputs (like + pandas.DataFrame) + """ + + # Put the inputs together and run all the modules + master_output_dict = dict() + master_input_dict = self.prepare_master_input_dictionary(inputs, discrete_inputs) + manager = Manager(master_input_dict, master_output_dict) + result = manager.execute_landbosse("WISDEM") + + # Check if everything executed correctly + if result != 0: + raise Exception("LandBOSSE didn't execute correctly") + + # Gather the cost and detail outputs + + costs_by_module_type_operation = self.gather_costs_from_master_output_dict(master_output_dict) + discrete_outputs["landbosse_costs_by_module_type_operation"] = costs_by_module_type_operation + + details = self.gather_details_from_master_output_dict(master_output_dict) + discrete_outputs["landbosse_details_by_module"] = details + + # This is where we have access to the modified components, so put those + # in the outputs of the component + discrete_outputs["erection_components"] = master_input_dict["components"] + + # Now get specific outputs. These have been refactored to methods that work + # with each module so as to keep this method as compact as possible. + self.gather_specific_erection_outputs(master_output_dict, outputs, discrete_outputs) + + # Compute the total BOS costs + self.compute_total_bos_costs(costs_by_module_type_operation, master_output_dict, inputs, outputs) + + def prepare_master_input_dictionary(self, inputs, discrete_inputs): + """ + This prepares a master input dictionary by applying all the necessary + modifications to the inputs. + + Parameters + ---------- + inputs : openmdao.vectors.default_vector.DefaultVector + A dictionary-like object with NumPy arrays that hold float + inputs. Note that since these are NumPy arrays, they + need indexing to pull out simple float64 values. + + discrete_inputs : openmdao.core.component._DictValues + A dictionary-like with the non-numeric inputs (like + pandas.DataFrame) + + Returns + ------- + dict + The prepared master input to go to the Manager. + """ + inputs_dict = {key: inputs[key][0] for key in inputs.keys()} + discrete_inputs_dict = {key: value for key, value in discrete_inputs.items()} + incomplete_input_dict = {**inputs_dict, **discrete_inputs_dict} + + # Modify the default component data if needed and copy it into the + # appropriate values of the input dictionary. + modified_components = self.modify_component_lists(inputs, discrete_inputs) + incomplete_input_dict["project_data"]["components"] = modified_components + incomplete_input_dict["components"] = modified_components + + # FoundationCost needs to have all the component data split into separate + # NumPy arrays. + incomplete_input_dict["component_data"] = modified_components + for component in incomplete_input_dict["component_data"].keys(): + incomplete_input_dict[component] = np.array(incomplete_input_dict["component_data"][component]) + + # These are aliases because parts of the code call the same thing by + # difference names. + incomplete_input_dict["crew_cost"] = discrete_inputs["crew_price"] + incomplete_input_dict["cable_specs_pd"] = discrete_inputs["cable_specs"] + + # read in RSMeans per diem: + crew_cost = discrete_inputs["crew_price"] + crew_cost = crew_cost.set_index("Labor type ID", drop=False) + incomplete_input_dict["rsmeans_per_diem"] = crew_cost.loc["RSMeans", "Per diem USD per day"] + + # Calculate project size in megawatts + incomplete_input_dict["project_size_megawatts"] = float( + discrete_inputs["num_turbines"] * inputs["turbine_rating_MW"] + ) + + # Needed to avoid distributed wind keys + incomplete_input_dict["road_distributed_wind"] = False + + defaults = DefaultMasterInputDict() + master_input_dict = defaults.populate_input_dict(incomplete_input_dict) + + return master_input_dict + + def gather_costs_from_master_output_dict(self, master_output_dict): + """ + This method extract all the cost_by_module_type_operation lists for + output in an Excel file. + + It finds values for the keys ending in '_module_type_operation'. It + then concatenates them together so they can be easily written to + a .csv or .xlsx + + On every row, it includes the: + Rotor diameter m + Turbine rating MW + Number of turbines + + This enables easy mapping of new columns if need be. The columns have + spaces in the names so that they can be easily written to a user-friendly + output. + + Parameters + ---------- + runs_dict : dict + Values are the names of the projects. Keys are the lists of + dictionaries that are lines for the .csv + + Returns + ------- + list + List of dicts to write to the .csv. + """ + line_items = [] + + # Gather the lists of costs + cost_lists = [value for key, value in master_output_dict.items() if key.endswith("_module_type_operation")] + + # Flatten the list of lists that is the result of the gathering + for cost_list in cost_lists: + line_items.extend(cost_list) + + # Filter out the keys needed and rename them to meaningful values + final_costs = [] + for line_item in line_items: + item = { + "Module": line_item["module"], + "Type of cost": line_item["type_of_cost"], + "Cost / kW": line_item["usd_per_kw_per_project"], + "Cost / project": line_item["cost_per_project"], + "Cost / turbine": line_item["cost_per_turbine"], + "Number of turbines": line_item["num_turbines"], + "Rotor diameter (m)": line_item["rotor_diameter_m"], + "Turbine rating (MW)": line_item["turbine_rating_MW"], + "Project ID with serial": line_item["project_id_with_serial"], + } + final_costs.append(item) + + return final_costs + + def gather_details_from_master_output_dict(self, master_output_dict): + """ + This extracts the detail lists from all the modules to output + the detailed non-cost data from the model run. + + Parameters + ---------- + master_output_dict : dict + The master output dict with the finished module output in it. + + Returns + ------- + list + List of dicts with detailed data. + """ + line_items = [] + + # Gather the lists of costs + details_lists = [value for key, value in master_output_dict.items() if key.endswith("_csv")] + + # Flatten the list of lists + for details_list in details_lists: + line_items.extend(details_list) + + return line_items + + def gather_specific_erection_outputs(self, master_output_dict, outputs, discrete_outputs): + """ + This method gathers specific outputs from the ErectionCost module and places + them on the outputs. + + The method does not return anything. Rather, it places the outputs directly + on the continuous of discrete outputs. + + Parameters + ---------- + master_output_dict: dict + The master output dictionary out of LandBOSSE + + outputs : openmdao.vectors.default_vector.DefaultVector + A dictionary-like object to store outputs. + + discrete_outputs : openmdao.core.component._DictValues + A dictionary-like for non-numeric outputs (like + pandas.DataFrame) + """ + discrete_outputs["erection_crane_choice"] = master_output_dict["crane_choice"] + discrete_outputs["erection_component_name_topvbase"] = master_output_dict["component_name_topvbase"] + + def compute_total_bos_costs(self, costs_by_module_type_operation, master_output_dict, inputs, outputs): + """ + This computes the total BOS costs from the master output dictionary + and places them on the necessary outputs. + + Parameters + ---------- + costs_by_module_type_operation: List[Dict[str, Any]] + The lists of costs by module, type and operation. + + master_output_dict: Dict[str, Any] + The master output dictionary from the run. Used to obtain the + construction time, + + outputs : openmdao.vectors.default_vector.DefaultVector + The outputs in which to place the results of the computations + """ + bos_per_kw = 0.0 + bos_per_project = 0.0 + installation_per_project = 0.0 + installation_per_kW = 0.0 + + for row in costs_by_module_type_operation: + bos_per_kw += row["Cost / kW"] + bos_per_project += row["Cost / project"] + if row["Module"] in ["ErectionCost", "FoundationCost"]: + installation_per_project += row["Cost / project"] + installation_per_kW += row["Cost / kW"] + + commissioning_pct = inputs["commissioning_pct"] + decommissioning_pct = inputs["decommissioning_pct"] + + commissioning_per_project = bos_per_project * commissioning_pct + decomissioning_per_project = bos_per_project * decommissioning_pct + commissioning_per_kW = bos_per_kw * commissioning_pct + decomissioning_per_kW = bos_per_kw * decommissioning_pct + + outputs["total_capex_kW"] = np.round(bos_per_kw + commissioning_per_kW + decomissioning_per_kW, 0) + outputs["total_capex"] = np.round(bos_per_project + commissioning_per_project + decomissioning_per_project, 0) + outputs["bos_capex"] = round(bos_per_project, 0) + outputs["bos_capex_kW"] = round(bos_per_kw, 0) + outputs["installation_capex"] = round(installation_per_project, 0) + outputs["installation_capex_kW"] = round(installation_per_kW, 0) + + actual_construction_months = master_output_dict["actual_construction_months"] + outputs["installation_time_months"] = round(actual_construction_months, 0) + + def modify_component_lists(self, inputs, discrete_inputs): + """ + This method modifies the previously loaded default component lists with + data about blades, tower sections, if they have been provided as input + to the component. + + It only modifies the project component data if default data for the proper + inputs have been overridden. + + The default blade data is assumed to be the first component that begins + with the word "Blade" + + This should take mass from the tower in WISDEM. Ideally, this should have + an input for transportable tower 4.3, large diameter steel tower LDST 6.2m, or + unconstrained key stone tower. Or give warnings about the boundaries + that we assume. + + Parameters + ---------- + inputs : openmdao.vectors.default_vector.DefaultVector + A dictionary-like object with NumPy arrays that hold float + inputs. Note that since these are NumPy arrays, they + need indexing to pull out simple float64 values. + + discrete_inputs : openmdao.core.component._DictValues + A dictionary-like with the non-numeric inputs (like + pandas.DataFrame) + + Returns + ------- + pd.DataFrame + The dataframe with the modified components. + """ + input_components = discrete_inputs["components"] + + # This list is a sequence of pd.Series instances that have the + # specifications of each component. + output_components_list = [] + + # Need to convert kg to tonnes + kg_per_tonne = 1000 + + # Get the hub height + hub_height_meters = inputs["hub_height_meters"][0] + + # Make the nacelle. This does not include the hub or blades. + nacelle_mass_kg = inputs["nacelle_mass"][0] + nacelle = input_components[input_components["Component"].str.startswith("Nacelle")].iloc[0].copy() + if inputs["nacelle_mass"] != use_default_component_data: + nacelle["Mass tonne"] = nacelle_mass_kg / kg_per_tonne + nacelle["Component"] = "Nacelle" + nacelle["Lift height m"] = hub_height_meters + output_components_list.append(nacelle) + + # Make the hub + hub_mass_kg = inputs["hub_mass"][0] + hub = input_components[input_components["Component"].str.startswith("Hub")].iloc[0].copy() + hub["Lift height m"] = hub_height_meters + if hub_mass_kg != use_default_component_data: + hub["Mass tonne"] = hub_mass_kg / kg_per_tonne + output_components_list.append(hub) + + # Make blades + blade = input_components[input_components["Component"].str.startswith("Blade")].iloc[0].copy() + + # There is always a hub height, so use that as the lift height + blade["Lift height m"] = hub_height_meters + + if inputs["blade_drag_coefficient"][0] != use_default_component_data: + blade["Coeff drag"] = inputs["blade_drag_coefficient"][0] + + if inputs["blade_lever_arm"][0] != use_default_component_data: + blade["Lever arm m"] = inputs["blade_lever_arm"][0] + + if inputs["blade_install_cycle_time"][0] != use_default_component_data: + blade["Cycle time installation hrs"] = inputs["blade_install_cycle_time"][0] + + if inputs["blade_offload_hook_height"][0] != use_default_component_data: + blade["Offload hook height m"] = hub_height_meters + + if inputs["blade_offload_cycle_time"][0] != use_default_component_data: + blade["Offload cycle time hrs"] = inputs["blade_offload_cycle_time"] + + if inputs["blade_drag_multiplier"][0] != use_default_component_data: + blade["Multiplier drag rotor"] = inputs["blade_drag_multiplier"] + + if inputs["blade_mass"][0] != use_default_component_data: + blade["Mass tonne"] = inputs["blade_mass"][0] / kg_per_tonne + + # Assume that number_of_blades always has a reasonable value. It's + # default count when the discrete input is declared of 3 is always + # reasonable unless overridden by another input. + + number_of_blades = discrete_inputs["number_of_blades"] + for i in range(number_of_blades): + component = f"Blade {i + 1}" + blade_i = blade.copy() + blade_i["Component"] = component + output_components_list.append(blade_i) + + # Make tower sections + tower_mass_tonnes = inputs["tower_mass"][0] / kg_per_tonne + tower_height_m = hub_height_meters - inputs["foundation_height"][0] + default_tower_section = input_components[input_components["Component"].str.startswith("Tower")].iloc[0] + tower_sections = self.make_tower_sections(tower_mass_tonnes, tower_height_m, default_tower_section) + output_components_list.extend(tower_sections) + + # Make the output component dataframe and return it. + output_components = pd.DataFrame(output_components_list) + return output_components + + @staticmethod + def make_tower_sections(tower_mass_tonnes, tower_height_m, default_tower_section): + """ + This makes tower sections for a transportable tower. + + Approximations: + + - Weight is distributed uniformly among the sections + + - The number of sections is either the maximum allowed by mass or + the maximum allowed by height, to maintain transportability. + + For each tower section, calculate: + - lift height + - lever arm + - surface area + + The rest of values should remain at their defaults. + + Note: Tower sections are constrained in maximum diameter to 4.5 m. + However, their surface area is calculated with a 1.3 m radius + to agree more closely with empirical data. Also, tower sections + are approximated as cylinders. + + Parameters + ---------- + tower_mass_tonnes: float + The total tower mass in tonnes + + tower_height_m: float + The total height of the tower in meters. + + default_tower_section: pd.Series + There are a number of values that are kept constant in creating + the tower sections. This series holds the values. + + Returns + ------- + List[pd.Series] + A list of series to be appended onto an output component list. + It is not a dataframe, because it is faster to append to a list + and make a dataframe once. + """ + tower_radius = 1.3 + + number_of_sections = max(ceil(tower_height_m / 30), ceil(tower_mass_tonnes / 80)) + + tower_section_height_m = tower_height_m / number_of_sections + + tower_section_mass = tower_mass_tonnes / number_of_sections + + tower_section_surface_area_m2 = np.pi * tower_section_height_m * (tower_radius ** 2) + + sections = [] + for i in range(number_of_sections): + lift_height_m = (i * tower_section_height_m) + tower_section_height_m + lever_arm = (i * tower_section_height_m) + (0.5 * tower_section_height_m) + name = f"Tower {i + 1}" + + section = default_tower_section.copy() + section["Component"] = name + section["Mass tonne"] = tower_section_mass + section["Lift height m"] = lift_height_m + section["Surface area sq m"] = tower_section_surface_area_m2 + section["Section height m"] = tower_section_height_m + section["Lever arm m"] = lever_arm + + sections.append(section) + + return sections diff --git a/landbosse/tests/landbosse_omdao/__init__.py b/landbosse/tests/landbosse_omdao/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/landbosse/tests/landbosse_omdao/test_landbosse.py b/landbosse/tests/landbosse_omdao/test_landbosse.py new file mode 100644 index 00000000..6d07ec60 --- /dev/null +++ b/landbosse/tests/landbosse_omdao/test_landbosse.py @@ -0,0 +1,108 @@ +import pandas as pd +import pytest +import openmdao.api as om +from landbosse.landbosse_omdao.landbosse import LandBOSSE +from landbosse.landbosse_omdao.OpenMDAODataframeCache import OpenMDAODataframeCache + + +@pytest.fixture +def landbosse_costs_by_module_type_operation(): + """ + Executes LandBOSSE and extracts cost output for the regression + test. + """ + prob = om.Problem() + prob.model = LandBOSSE() + prob.setup() + prob.run_model() + # prob.model.list_inputs(units=True) + landbosse_costs_by_module_type_operation = prob["landbosse_costs_by_module_type_operation"] + return landbosse_costs_by_module_type_operation + + + +def compare_expected_to_actual(expected_df, actual_module_type_operation_list, validation_output_csv): + """ + This compares the expected costs as calculated by a prior model run + with the actual results from a current model run. + + It compares the results row by row and prints any differences. + + Parameters + ---------- + expected_df : pd.DataFrame + The absolute filename of the expected output .xlsx file. + + actual_module_type_operation_list : str + The module_type_operation_list as returned by a subclass of + XlsxManagerRunner. + + validation_output_xlsx : str + The absolute pathname to the output file with the comparison + results. + + Returns + ------- + bool + True if the expected and actual results are equal. It returns + False otherwise. + """ + # First, make the list of dictionaries into a dataframe, and drop + # the raw_cost and raw_cost_total_or_per_turbine columns. + actual_df = pd.DataFrame(actual_module_type_operation_list) + + columns_to_compare = ["Cost / project", "Project ID with serial", "Module", "Type of cost"] + cost_per_project_actual = actual_df[columns_to_compare] + cost_per_project_expected = expected_df[columns_to_compare] + + comparison = cost_per_project_actual.merge( + cost_per_project_expected, on=["Project ID with serial", "Module", "Type of cost"] + ) + + comparison.rename( + columns={"Cost / project_x": "Cost / project actual", "Cost / project_y": "Cost / project expected"}, + inplace=True, + ) + + comparison["% delta"] = (comparison["Cost / project actual"] / comparison["Cost / project expected"] - 1) * 100 + + comparison.to_csv(validation_output_csv, index=False) + + # If the comparison dataframe is empty, that means there are no common + # projects in the expected data that match the actual data. + if len(comparison) < 1: + print("=" * 80) + print("Validation error: There are no common projects between actual and expected data.") + print("=" * 80) + return False + + # Find all rows where the difference is unequal to 0. These are rows + # that failed validation. Note that, after the join, the rows may be + # in a different order than the originals. + # + # Round the difference to a given number of decimal places. + failed_rows = comparison[~pd.isnull(comparison["% delta"]) & comparison["% delta"].round(decimals=4) != 0] + + if len(failed_rows) > 0: + print("=" * 80) + print("The following rows failed validation:") + print(failed_rows) + print("=" * 80) + return False + else: + return True + + + +def test_landbosse(landbosse_costs_by_module_type_operation): + """ + This runs the regression test by comparing against the expected validation + data. + """ + OpenMDAODataframeCache._cache = {} # Clear the cache + expected_validation_data_sheets = OpenMDAODataframeCache.read_all_sheets_from_xlsx("ge15_expected_validation") + costs_by_module_type_operation = expected_validation_data_sheets["costs_by_module_type_operation"] + result = compare_expected_to_actual( + costs_by_module_type_operation, landbosse_costs_by_module_type_operation, "test.csv" + ) + assert result diff --git a/project_input_template/project_data/ge15_expected_validation.xlsx b/project_input_template/project_data/ge15_expected_validation.xlsx new file mode 100644 index 00000000..b804fac0 Binary files /dev/null and b/project_input_template/project_data/ge15_expected_validation.xlsx differ