diff --git a/data/parameter_validation.csv b/data/parameter_validation.csv index 83acb36..85a741e 100644 --- a/data/parameter_validation.csv +++ b/data/parameter_validation.csv @@ -22,9 +22,10 @@ electricity_rate,"int,float",0,1,,,,, end_year,int,1998,2021,,,,, existing,bool,,,,,,, existing_components,dict,,,,,check_existing_components,,"Existing components can only include PV, Battery, Generator, or FuelTank objects, and have keys 'pv', 'batt', 'gen', or 'fuel_tank'." +existing_generator,bool,,,,,check_existing_generator,, filter_constraints,list,,,,,check_filter_constraints,,"The filter_constraints list must contain dictionaries with the format: {parameter, type, value} where parameter can be any of the following: capital_cost_usd, pv_area_ft2, annual_benefits_usd, simple_payback_yr, fuel_used_gal mean, fuel_used_gal most-conservative, pv_capacity, or pv_percent mean and type can be [max, min] and value is the maximum or minimum allowable value." fuel_curve_degree,int,1,3,,,,, -fuel_curve_model,dict,,,,,check_fuel_curve_model,,"The generator fuel curve model must be of the form {'1/4 Load (gal/hr)': val, '1/2 Load (gal/hr)': val, '3/4 Load (gal/hr)': val, +fuel_curve_model,dict,,,,,check_fuel_curve_model,,"The generator fuel curve model must be of the form {'1/4 Load (gal/hr)': val, '1/2 Load (gal/hr)': val, '3/4 Load (gal/hr)': val, 'Full Load (gal/hr)': val}" fuel_used_gal,"int,float",0,,,,,, gen_percent,"int,float",,,,,,, @@ -142,4 +143,4 @@ om_cost,"int,float",0,500,,,,, demand_rate_list,"int,float,list",,,,,check_demand_rate_list,,Demand charges must be in the form of a number or a list with 12 elements. demand_rate,"int,float",0,50,,,,, solar_source,str,,,"nsrdb,himawari",,check_solar_source,"start_year,end_year",The NSRDB dataset only contains data from 1998-2021. The Himawari dataset only contains data from 2016-2020. -scenario_criteria,str,,,"pv,gen",,,, \ No newline at end of file +scenario_criteria,str,,,"pv,generator",,,, diff --git a/main_files/main.py b/main_files/main.py index b664fe3..9bc36b3 100644 --- a/main_files/main.py +++ b/main_files/main.py @@ -86,6 +86,7 @@ def run_mcor(input_dict): net_metering_rate=net_metering_inputs["net_metering_rate"], demand_rate=demand_rate_inputs["demand_rate"], existing_components=existing_components_inputs["existing_components"], + existing_generator='existing_generator' in input_dict['existing_components_inputs'], output_tmy=True, validate=True, net_metering_limits=net_metering_inputs["net_metering_limits"], @@ -101,7 +102,9 @@ def run_mcor(input_dict): optim.get_load_profiles() # Run all simulations - optim.run_sims_par() + # optim.run_sims_par() + #run without multi-threading + optim.run_sims() # Filter and rank results optim.parse_results() @@ -202,7 +205,15 @@ def run_mcor(input_dict): # input_dict["existing_components_inputs"]["pv"] = PV(existing=True, pv_capacity=100, tilt=tilt, azimuth=azimuth, # module_capacity=0.360, module_area=3, spacing_buffer=2, # pv_tracking='fixed', pv_racking='ground') - # input_dict["existing_components_inputs"]["existing_components"] = {'pv': pv} + # input_dict["existing_components_inputs"]["existing_components"] = {"pv": ["pv"]} + + input_dict["existing_components_inputs"]["existing_generator"] = Generator(existing=True, rated_power=500, num_units=1, + fuel_curve_model={'1/4 Load (gal/hr)': 11, '1/2 Load (gal/hr)': 18.5, + '3/4 Load (gal/hr)': 26.4, 'Full Load (gal/hr)': 35.7}, + capital_cost=191000, ideal_minimum_load=0.3, + loading_level_to_add_unit=0.9, + loading_level_to_remove_unit=0.3, validate=True) + input_dict["existing_components_inputs"]["existing_components"] = {'generator': input_dict["existing_components_inputs"]["existing_generator"]} # Specific PV and battery sizes dictionary input_dict["specific_pv_battery_sizes_inputs"] = {} diff --git a/microgrid_optimizer.py b/microgrid_optimizer.py index 74541f2..ac37f01 100644 --- a/microgrid_optimizer.py +++ b/microgrid_optimizer.py @@ -279,6 +279,7 @@ def __init__(self, power_profiles, temp_profiles, night_profiles, net_metering_limits=None, generator_buffer=1.1, gen_power_percent=(), existing_components={}, + existing_generator=False, off_grid_load_profile=None, output_tmy=False, validate=True): @@ -301,6 +302,7 @@ def __init__(self, power_profiles, temp_profiles, night_profiles, self.generator_buffer = generator_buffer self.gen_power_percent = gen_power_percent self.existing_components = existing_components + self.existing_generator = existing_generator self.off_grid_load_profile = off_grid_load_profile self.output_tmy = output_tmy self.load_profiles = [] @@ -321,6 +323,7 @@ def __init__(self, power_profiles, temp_profiles, night_profiles, 'battery_params': battery_params, 'duration': duration, 'gen_power_percent': gen_power_percent, + 'existing_generator': existing_generator, 'dispatch_strategy': dispatch_strategy, 'batt_sizing_method': batt_sizing_method, 'system_costs': system_costs} @@ -391,7 +394,7 @@ def size_PV_for_netzero(self): # Calculate round-trip efficiency based on battery and inverter # efficiency system_rte = self.battery_params['one_way_battery_efficiency'] ** 2 \ - * self.battery_params['one_way_inverter_efficiency'] ** 2 + * self.battery_params['one_way_inverter_efficiency'] ** 2 # Calculate the amount of pv energy lost through # charging/discharging batteries at the net zero capacity @@ -464,7 +467,7 @@ def size_batt_for_no_pv_export(self, pv_sizes, load_profile): excess_pv['pv_base'] = self.tmy_solar.values for size in pv_sizes: excess_pv[int(size)] = excess_pv['pv_base'] * size - excess_pv['{}_exported'.format(int(size))] = excess_pv[int(size)]\ + excess_pv['{}_exported'.format(int(size))] = excess_pv[int(size)] \ - excess_pv['load'] excess_pv[excess_pv < 0] = 0 @@ -860,9 +863,14 @@ def aggregate_by_system(self, system, validate=True): log_error(message) raise Exception(message) - # Size and dispatch generator - simulation.size_single_generator( - self.system_costs['generator_costs'], validate=False) + if not self.existing_generator: + # Size and dispatch generator + simulation.size_single_generator( + self.system_costs['generator_costs'], validate=False) + else: + simulation.calc_existing_generator_dispatch( + self.system_costs['generator_costs'], validate=False + ) # Add the results to the lists results_summary['pv_percent'] += \ @@ -923,13 +931,14 @@ def aggregate_by_system(self, system, validate=True): results_summary['load_duration']['max_%_kW_not_met_max'] = \ max_kW_not_met.fillna(0).max(axis=1) - # Find the simulation with the largest generator and add that - # generator object to the system - max_gen_sim_num = \ - np.where(results_summary['generator_power_kW'] - == max(results_summary['generator_power_kW']))[0][0] - max_gen_sim = system.get_simulation(max_gen_sim_num) - system.add_component(max_gen_sim.generator_obj, validate=False) + if not self.existing_generator: + # Find the simulation with the largest generator and add that + # generator object to the system + max_gen_sim_num = \ + np.where(results_summary['generator_power_kW'] + == max(results_summary['generator_power_kW']))[0][0] + max_gen_sim = system.get_simulation(max_gen_sim_num) + system.add_component(max_gen_sim.generator_obj, validate=False) return results_summary, system @@ -988,7 +997,7 @@ def parse_results(self): system.set_outputs(results_summary, validate=False) # Turn results into a dataframe - system_row = pd.DataFrame.from_dict(results_summary).transpose().\ + system_row = pd.DataFrame.from_dict(results_summary).transpose(). \ stack() system_row.index = [' '.join(col).strip() for col in system_row.index.values] @@ -1194,12 +1203,12 @@ def plot_best_system(self, scenario_criteria='pv', scenario_num=None, validate=T if scenario_criteria == 'pv': # Find the outage periods with the max and min PV criteria_dict = {key: val.sum() for key, val - in enumerate(self.power_profiles)} + in enumerate(self.power_profiles)} scenario_label = 'Solar Irradiance Scenario' else: # Find the outage periods with the max and min generator runtime criteria_dict = {key: val for key, val - in enumerate(system.outputs['fuel_used_gal'])} + in enumerate(system.outputs['fuel_used_gal'])} scenario_label = 'Fuel Consumption Scenario' max_scenario = max(criteria_dict, key=criteria_dict.get) min_scenario = min(criteria_dict, key=criteria_dict.get) @@ -1297,7 +1306,7 @@ def plot_system_dispatch(self, num_systems=None, plot_per_fig=3, for plot_num, (system_name, _) in enumerate( self.results_grid.iloc[ fig_num * plot_per_fig:fig_num * plot_per_fig + num_plots]. - iterrows()): + iterrows()): # Plot the maximum PV outage dispatch ax = fig.add_subplot(num_plots, 2, plot_num * 2 + 1) self.get_system(system_name).plot_dispatch(max_pv, ax=ax) @@ -1396,13 +1405,13 @@ def format_inputs(self, spg): if 'generator' in self.existing_components: inputs['Existing Equipment'].loc['Generator'] = \ '{} units of {}kW'.format( - self.existing_components['generator'].num_units, - self.existing_components['generator'].rated_power) + self.existing_components['generator'].num_units, + self.existing_components['generator'].rated_power) if 'battery' in self.existing_components: inputs['Existing Equipment'].loc['Battery'] = \ '{}kW, {}kWh'.format( - self.existing_components['battery'].power, - self.existing_components['battery'].batt_capacity) + self.existing_components['battery'].power, + self.existing_components['battery'].batt_capacity) if 'fuel_tank' in self.existing_components: inputs['Existing Equipment'].loc['FuelTank'] = \ '{}gal'.format(self.existing_components['fuel_tank'].tank_size) @@ -1429,7 +1438,7 @@ def format_inputs(self, spg): assumptions['Generator'] = pd.DataFrame.from_dict( {'sizing buffer': '{:.0f}%'.format( - self.generator_buffer*100-100)}, orient='index') + self.generator_buffer * 100 - 100)}, orient='index') return inputs, assumptions @@ -1515,6 +1524,7 @@ def save_results_to_file(self, spg, filename='simulation_results'): for _ in self.gen_power_percent: units += ['kW', 'gallons', 'gallons', '$', '', '', '', 'kWh', 'kWh', 'kW', 'kW'] + format_results.loc['units'] = units format_results.columns = [col.replace('_', ' ').capitalize() @@ -1671,20 +1681,20 @@ def plot_compare_metrics(self, x_var='simple_payback_yr', # Check that vars exist in results grid if x_var not in self.results_grid.columns or y_var not in self.results_grid.columns: - return('ERROR: {} or {} are not valid output metrics, please ' - 'choose one of the following options: {}'.format( - x_var, y_var, ', '.join(self.results_grid.columns.values))) + return ('ERROR: {} or {} are not valid output metrics, please ' + 'choose one of the following options: {}'.format( + x_var, y_var, ', '.join(self.results_grid.columns.values))) # Make pv and batt sizes categorical, so exact sizes are shown # in the legend results_mod = self.results_grid.copy() results_mod['pv_capacity'] = results_mod['pv_capacity'].apply( - lambda x: str(int(x))+'kW') - pv_order = [str(elem2)+'kW' for elem2 in np.flipud( + lambda x: str(int(x)) + 'kW') + pv_order = [str(elem2) + 'kW' for elem2 in np.flipud( np.sort([int(elem[:-2]) for elem in results_mod['pv_capacity'].unique()]))] results_mod['battery_power'] = results_mod['battery_power'].apply( - lambda x: str(int(x))+'kW') - batt_order = [str(elem2)+'kW' for elem2 in np.flipud( + lambda x: str(int(x)) + 'kW') + batt_order = [str(elem2) + 'kW' for elem2 in np.flipud( np.sort([int(elem[:-2]) for elem in results_mod['battery_power'].unique()]))] # Create plot @@ -1767,7 +1777,7 @@ def get_electricity_rate(location, validate=True): # Return the electricity rate for that state in $/kWh return rates.set_index('Name').loc[ - state, 'Average retail price (cents/kWh)'] / 100 + state, 'Average retail price (cents/kWh)'] / 100 except Exception as e: # If there is an error, return the median electricity rate diff --git a/microgrid_simulator.py b/microgrid_simulator.py index de10e90..0ea325b 100644 --- a/microgrid_simulator.py +++ b/microgrid_simulator.py @@ -395,9 +395,7 @@ def size_single_generator(self, generator_options, validate=True): self.dispatch_df[['load_not_met']], self.duration, validate=False) self.load_duration_df = calculate_load_duration(grouped_load, validate=False) - def calc_existing_generator_dispatch(self, generator_options, - add_additional_generator=True, - validate=True): + def calc_existing_generator_dispatch(self, generator_options, validate=True): """ If there is an existing generator, determine how it meets the load and consumes fuel. @@ -412,12 +410,12 @@ def calc_existing_generator_dispatch(self, generator_options, # Validate input parameters if validate: - args_dict = {'generator_options': generator_options, - 'add_additional_generator': add_additional_generator} + args_dict = {'generator_costs': generator_options} validate_all_parameters(args_dict) # Get info from existing generator gen = self.system.components['generator'] + self.generator_power_kW = gen.rated_power # Create a temporary dataframe to hold load not met cropped at the existing generator @@ -435,68 +433,14 @@ def calc_existing_generator_dispatch(self, generator_options, self.dispatch_df.loc[self.dispatch_df['load_not_met_by_generator'] < 0, 'load_not_met_by_generator'] = 0 - # If the load cannot be fully met by the existing generator - if self.dispatch_df['load_not_met_by_generator'].sum() > 0: - - # If another generator can be added - if add_additional_generator: - - # Total rated power (including multiple units together) based on max unmet - # power - max_power = self.dispatch_df['load_not_met_by_generator'].max() - - # Find the smallest generator with sufficent rated power, assumes generators - # are sorted from smallest to largest - addl_gen = None - num_gen = 1 - while addl_gen is None: - # Find an appropriately sized generator - best_gen = generator_options[generator_options.index - * num_gen >= max_power - * self.generator_buffer] - - # If no single generator is large enough, increase the number of - # generators - if not len(best_gen): - num_gen += 1 - else: - # Create generator object - best_gen = best_gen.iloc[0] - self.generator_power_kW += best_gen.name*num_gen - addl_gen = Generator( - existing=False, rated_power=best_gen.name, - num_units=num_gen, - fuel_curve_model=best_gen[ - ['1/4 Load (gal/hr)', '1/2 Load (gal/hr)', - '3/4 Load (gal/hr)', 'Full Load (gal/hr)']].to_dict(), - capital_cost=best_gen['Cost (USD)'], - validate=False) - self.generator_obj = addl_gen - - # Calculate the load duration curve and fuel consumption for the additional - # generator - grouped_load, addl_fuel_used = \ - addl_gen.calculate_fuel_consumption( - self.dispatch_df[['load_not_met_by_generator']], - self.duration, validate=False) - self.load_duration_df = calculate_load_duration(grouped_load, - validate=False) - self.fuel_used_gal = existing_gen_fuel_used + addl_fuel_used - - # If another generator cannot be dispatched - else: - self.load_duration_df = pd.DataFrame( - 0, index=temp_load_duration_curve.index, - columns=temp_load_duration_curve.columns) - self.fuel_used_gal = existing_gen_fuel_used - + # If the existing generator can meet load, use empty load duration curve and existing # fuel used - else: - self.load_duration_df = pd.DataFrame( - 0, index=temp_load_duration_curve.index, - columns=temp_load_duration_curve.columns) - self.fuel_used_gal = existing_gen_fuel_used + + self.load_duration_df = pd.DataFrame( + 0, index=temp_load_duration_curve.index, + columns=temp_load_duration_curve.columns) + self.fuel_used_gal = existing_gen_fuel_used def get_load_breakdown(self): return self.load_breakdown @@ -639,7 +583,8 @@ def calculate_load_duration(grouped_load, validate=True): # Run the simulation sim.scale_power_profile() sim.calc_dispatch() - sim.size_single_generator(generator_options, validate=False) + # sim.size_single_generator(generator_options, validate=False) + # sim.calc_existing_generator_dispatch(generator_options, validate=False) # Plot dispatch sim.dispatch_df[['load', 'pv_power', 'delta_battery_power', 'load_not_met']].plot() diff --git a/validation.py b/validation.py index ff66996..1276a3e 100644 --- a/validation.py +++ b/validation.py @@ -208,7 +208,7 @@ def check_sitename(sitename, path, start_year, end_year): if sitename not in dirs: return False files = os.listdir(os.path.join(path, 'nrel', sitename)) - for year in range(start_year, end_year+1): + for year in range(start_year, end_year + 1): if '{}_{}.csv'.format(sitename, year) not in files: return False return True @@ -313,19 +313,23 @@ def check_existing_components(existing_components): """ # Check that keys are allowable - if len(set(existing_components.keys()) - {'pv', 'batt', 'gen', 'fuel_tank'}) > 0: + if len(set(existing_components.keys()) - {'pv', 'batt', 'generator', 'fuel_tank'}) > 0: return False # Check the datatype for each elem type_key = {'pv': 'microgrid_system.PV', 'batt': 'microgrid_system.Battery', - 'gen': 'microgrid_system.Generator', + 'generator': 'microgrid_system.Generator', 'fuel_tank': 'microgrid_system.FuelTank'} for key, val in existing_components.items(): validate_parameter(key, val, data_type=[type_key[key]]) return True +def check_existing_generator(existing_generator): + + return True + def check_net_metering_limits(net_metering_limits): """ Check that net metering limits have the format: {type: ['capacity_cap' or 'percent_of_load'], value: [ or ]} @@ -743,7 +747,7 @@ def check_temperature(temperature): converted_index = pd.to_datetime(temperature.index).map( lambda x: x.replace(year=2017)) except: - message = 'The annual temperature profile must have a datetime index,'\ + message = 'The annual temperature profile must have a datetime index,' \ ' and not contain leap days.' log_error(message) raise Exception(message) @@ -751,7 +755,7 @@ def check_temperature(temperature): # Check that the index has all of the expected values comp_index = pd.date_range(start='1/1/2017', end='1/1/2018', freq='H')[:-1] if len(set(comp_index).symmetric_difference(set(converted_index))): - message = 'The annual temperature profile must begin on January 1 at '\ + message = 'The annual temperature profile must begin on January 1 at ' \ '00:00:00 and have no missing values.' log_error(message) return False @@ -854,7 +858,7 @@ def check_inverter(inverter): **CONSTRAINTS_DICT['inverter_database']) inverter_name_params = CONSTRAINTS_DICT['inverter_name'] inverter_name_params['custom_args'] = {'inverter_database': - inverter['database']} + inverter['database']} validate_parameter('inverter_name', inverter['model'], **inverter_name_params) return True @@ -938,7 +942,7 @@ def check_solar_source(solar_source, start_year, end_year): if (solar_source == 'nsrdb') and \ (not {start_year, end_year}.issubset(set(range(1998, 2022)))): - message = "NREL's NSRDB dataset only covers years 1998-2021. "\ + message = "NREL's NSRDB dataset only covers years 1998-2021. " \ 'Please check the start/end years.' log_error(message) return False @@ -964,7 +968,7 @@ def annual_load_profile_warnings(annual_load_profile): # Check frequencies of data # Calculate load profile periodogram - freq, power = periodogram(annual_load_profile.values, fs=1/3600) + freq, power = periodogram(annual_load_profile.values, fs=1 / 3600) pg = pd.DataFrame(power, index=freq, columns=['power']) # Get the peak frequency @@ -998,9 +1002,9 @@ def annual_load_profile_warnings(annual_load_profile): if len(daily_sum_outliers) or len(daily_max_outliers) or len(daily_min_outliers): warning_message += 'There are suspicious values for the following ' \ 'days: {}. '.format( - set(pd.concat([daily_sum_outliers, - daily_min_outliers, - daily_max_outliers]).index)) + set(pd.concat([daily_sum_outliers, + daily_min_outliers, + daily_max_outliers]).index)) if int(min_period) not in [23, 24, 25] or int(second_period) not in range(165, 172): warning_message += 'The load profile does not have natural periods ' \ 'at 24 hours and/or 1 week. ' @@ -1035,8 +1039,8 @@ def normalize_profile(annual_load_profile): load_dow = load_season_norm.groupby('dow')['load'].median() load_season_weekly_norm = annual_load_profile.reset_index().apply( lambda x: x[annual_load_profile.name] - - load_monthly[x[annual_load_profile.index.name].month] - - load_dow[x[annual_load_profile.index.name].dayofweek], axis=1) + load_monthly[x[annual_load_profile.index.name].month] - + load_dow[x[annual_load_profile.index.name].dayofweek], axis=1) load_season_weekly_norm.index = annual_load_profile.index # Normalize hourly trend @@ -1046,9 +1050,9 @@ def normalize_profile(annual_load_profile): load_hourly = load_season_weekly_norm.groupby('hour')['load'].median() load_normalized = annual_load_profile.reset_index().apply( lambda x: x[annual_load_profile.name] - - load_monthly[x[annual_load_profile.index.name].month] - - load_dow[x[annual_load_profile.index.name].dayofweek] - - load_hourly[x[annual_load_profile.index.name].hour], axis=1) + load_monthly[x[annual_load_profile.index.name].month] - + load_dow[x[annual_load_profile.index.name].dayofweek] - + load_hourly[x[annual_load_profile.index.name].hour], axis=1) load_normalized.index = annual_load_profile.index return load_normalized @@ -1070,11 +1074,11 @@ def chauvenet_outliers(data): # For each data point, calculate the likelihood that that value is drawn # from the underlying distribution - data_df['num_stds'] = data_df['data'].apply(lambda x: np.abs((x-avg)/std)) - data_df['prob'] = data_df['num_stds'].apply(lambda x: (1-norm.cdf(x))*2) + data_df['num_stds'] = data_df['data'].apply(lambda x: np.abs((x - avg) / std)) + data_df['prob'] = data_df['num_stds'].apply(lambda x: (1 - norm.cdf(x)) * 2) # Determine if each point is an outlier - data_df['outlier'] = data_df['prob'].apply(lambda x: x < 1/(2*len(data_df))) + data_df['outlier'] = data_df['prob'].apply(lambda x: x < 1 / (2 * len(data_df))) return data_df[data_df['outlier']] @@ -1093,11 +1097,11 @@ def strings_warnings(strings, module_name, inverter_name): # Check that the DC/AC ratio is within a reasonable range string_warnings = '' dc = module_name['capacity'] * strings['mods_per_string'] * 1000 \ - * strings['strings_per_inv'] + * strings['strings_per_inv'] ac = inverter['Paco'] - if not 1 <= dc/ac <= 1.1: + if not 1 <= dc / ac <= 1.1: string_warnings += 'The DC/AC ratio for your PV system is {:.2f}. We recommend a ' \ - 'value between 1 and 1.1.'.format(dc/ac) + 'value between 1 and 1.1.'.format(dc / ac) # Check string parameters against inverter voltage and current system_voltage = module['V_oc_ref'] * strings['mods_per_string'] @@ -1142,6 +1146,7 @@ def location_warnings(latitude, longitude, timezone): 'check_fuel_curve_model': check_fuel_curve_model, 'check_outputs': check_outputs, 'check_existing_components': check_existing_components, + 'check_existing_generator': check_existing_generator, 'check_annual_load_profile': check_annual_load_profile, 'check_net_metering_limits': check_net_metering_limits, 'check_location': check_location,