def get_run_uuid(post, API_KEY, api_url): """ Function for posting job :param post: :param API_KEY: :param api_url: :return: job run_uuid """ post_url = api_url + '?api_key=' + API_KEY resp = requests.post(post_url, json=post) run_id = None if not resp.ok: logger.error("Status code {}. {}".format(resp.status_code, resp.content)) else: logger.info("Response OK from {}.".format(post_url)) run_id_dict = json.loads(resp.text) try: run_id = run_id_dict['run_uuid'] except KeyError: msg = "Response from {} did not contain run_uuid.".format(post_url) logger.error(msg) return run_id
def simulate(self, project_life: int = 25): """ Run the system and financial model """ if not self.system_model: return self.system_model.execute(0) if not self.financial_model: return self.financial_model.value("construction_financing_cost", self.get_construction_financing_cost()) self.financial_model.Revenue.ppa_soln_mode = 1 self.financial_model.Lifetime.system_use_lifetime_output = 1 self.financial_model.FinancialParameters.analysis_period = project_life single_year_gen = self.financial_model.SystemOutput.gen self.financial_model.SystemOutput.gen = list( single_year_gen) * project_life if self.name != "Grid": self.financial_model.SystemOutput.system_pre_curtailment_kwac = self.system_model.Outputs.gen * project_life self.financial_model.SystemOutput.annual_energy_pre_curtailment_ac = self.system_model.Outputs.annual_energy self.financial_model.execute(0) logger.info("{} simulation executed".format(self.name))
def __init__(self, lat, lon, interconnection_limit_kw: float, load_profile: Sequence, urdb_label: str, solar_model: PVPlant = None, wind_model: WindPlant = None, storage_model: Battery = None, fin_model: Singleowner = None, off_grid=False, fileout=None): """ Initialize REopt API call Parameters --------- lat: float The latitude lon: float The longitude wholesale_rate_above_site_load_us_dollars_per_kwh: float Price of electricity sold back to the grid above the site load, regardless of net metering interconnection_limit_kw: float The limit on system capacity size that can be interconnected to grid load_profile: list The kW demand of the site at every hour (length 8760) urdb_label: string The identifier of a urdb_rate to use *_model: PySAM models Models initialized with correct parameters fileout: string Filename where REopt results should be written """ self.latitude = lat self.longitude = lon self.interconnection_limit_kw = interconnection_limit_kw self.urdb_label = urdb_label self.load_profile = load_profile self.results = None self.api_key = get_developer_nrel_gov_key() # paths self.path_current = os.path.dirname(os.path.abspath(__file__)) self.path_results = os.path.join(self.path_current) self.path_rates = os.path.join(self.path_current, '..', 'resource_files', 'utility_rates') if not os.path.exists(self.path_rates): os.makedirs(self.path_rates) # self.fileout = os.path.join(self.path_results, 'REoptResults.json') if fileout is not None: self.fileout = fileout if off_grid: self.reopt_api_url = 'https://offgrid-electrolyzer-reopt-dev-api.its.nrel.gov/v1/job/' else: self.reopt_api_url = 'https://developer.nrel.gov/api/reopt/v1/job/' self.post = self.create_post(solar_model, wind_model, storage_model, fin_model) logger.info("Created REopt post")
def get_reopt_results(self, results_file=None): """ Function for posting job and polling results end-point :param post: :param results_file: :param API_KEY: :param api_url: :return: results dictionary / API response """ if not results_file: results_file = self.fileout run_id = self.get_run_uuid(self.post, API_KEY=self.api_key, api_url=self.reopt_api_url) if run_id is not None: results_url = self.reopt_api_url + '<run_uuid>/results/?api_key=' + self.api_key results = self.poller(url=results_url.replace('<run_uuid>', run_id)) with open(results_file, 'w') as fp: json.dump(obj=results, fp=fp) logger.info("Saved results to {}".format(results_file)) else: results = None logger.error("Unable to get results: no run_uuid from POST.") return results
def create_post(self, solar_model: SolarPlant, wind_model: WindPlant, batt_model: Battery, hybrid_fin: Singleowner): """ The HTTP POST required by REopt""" post = dict() post['Scenario'] = dict({'user_id': 'hybrid_systems'}) post['Scenario']['Site'] = dict({'latitude': self.latitude, 'longitude': self.longitude}) post['Scenario']['Site']['ElectricTariff'] = self.tariff(hybrid_fin) if self.load_profile is None: self.load_profile = 8760 * [0.0] post['Scenario']['Site']['LoadProfile'] = dict({'loads_kw': self.load_profile}) post['Scenario']['Site']['Financial'] = self.financial(hybrid_fin) post['Scenario']['Site']['PV'] = self.PV(solar_model) post['Scenario']['Site']['PV']['max_kw'] = self.interconnection_limit_kw post['Scenario']['Site']['Wind'] = self.Wind(wind_model) post['Scenario']['Site']['Wind']['max_kw'] = self.interconnection_limit_kw if batt_model is not None: post['Scenario']['Site']['Storage'] = self.Storage(batt_model) # write file to results for debugging post_path = os.path.join(self.path_results, 'post.json') if os.path.exists(post_path): with open(post_path, 'w') as outfile: json.dump(post, outfile) logger.info("Created REopt post, exported to " + post_path) return post
def _set_system_layout(self): if isinstance(self._system_model, pv_simple.Pvwattsv8): if self.parameters: self._system_model.SystemDesign.gcr = self.parameters.gcr if type(self.parameters) == PVGridParameters: self._system_model.SystemDesign.system_capacity = self.module_power * self.num_modules logger.info(f"Solar Layout set for {self.module_power * self.num_modules} kw") self._system_model.AdjustmentFactors.constant = self.flicker_loss * 100 # percent else: raise NotImplementedError("Modification of Detailed PV Layout not yet enabled")
def _set_system_layout(self): self._system_model.value("wind_farm_xCoordinates", self.turb_pos_x) self._system_model.value("wind_farm_yCoordinates", self.turb_pos_y) n_turbines = len(self.turb_pos_x) turb_rating = max( self._system_model.value("wind_turbine_powercurve_powerout")) self._system_model.value("system_capacity", n_turbines * turb_rating) logger.info( "Wind Layout set with {} turbines for {} kw system capacity". format(n_turbines, n_turbines * turb_rating))
def __init__(self, power_sources: dict, site: SiteInfo, interconnect_kw: float): """ Base class for simulating a hybrid power plant. Can be derived to add other sizing methods, financial analyses, methods for pre- or post-processing, etc :param power_sources: tuple of strings, float pairs names of power sources to include and their kw sizes choices include: ('solar', 'wind', 'geothermal', 'battery') :param site: Site layout, location and resource data :param interconnect_kw: float power limit of interconnect for the site """ self._fileout = Path.cwd() / "results" self.site = site self.interconnect_kw = interconnect_kw self.power_sources = dict() self.solar: Union[SolarPlant, None] = None self.wind: Union[WindPlant, None] = None self.grid: Union[Grid, None] = None if 'solar' in power_sources.keys(): self.solar = SolarPlant(self.site, power_sources['solar'] * 1000) self.power_sources['solar'] = self.solar logger.info( "Created HybridSystem.solar with system size {} mW".format( power_sources['solar'])) if 'wind' in power_sources.keys(): self.wind = WindPlant(self.site, power_sources['wind'] * 1000) self.power_sources['wind'] = self.wind logger.info( "Created HybridSystem.wind with system size {} mW".format( power_sources['wind'])) if 'geothermal' in power_sources.keys(): raise NotImplementedError("Geothermal plant not yet implemented") if 'battery' in power_sources: raise NotImplementedError("Battery not yet implemented") self.cost_model = None # performs interconnection and curtailment energy limits self.grid = Grid(self.site, interconnect_kw) self.outputs_factory = HybridSimulationOutput(power_sources)
def calculate_installed_cost(self): if not self.cost_model: raise RuntimeError( "'calculate_installed_cost' called before 'setup_cost_calculator'." ) solar_cost, wind_cost, total_cost = self.cost_model.calculate_total_costs( self.wind.system_capacity_kw / 1000, self.solar.system_capacity_kw / 1000) if self.solar: self.solar.set_total_installed_cost_dollars(solar_cost) if self.wind: self.wind.set_total_installed_cost_dollars(wind_cost) self.grid.set_total_installed_cost_dollars(total_cost) logger.info( "HybridSystem set hybrid total installed cost to to {}".format( total_cost))
def roll_timezone(self, roll_hours, timezone): """ :param roll_hours: :param timezone: :return: """ rollable_keys = ['dn', 'df', 'gh', 'wspd', 'tdry'] for key in rollable_keys: if any(k == key for k in rollable_keys): roll_range = range(0, -roll_hours + 1) weather_array = np.array(self._data[key]) weather_array_rolled = np.delete(weather_array, roll_range) weather_array_rolled = np.pad(weather_array_rolled, (0, -roll_hours + 1), 'constant') self._data[key] = weather_array_rolled.tolist() self._data['tz'] = timezone logger.info('Rolled solar data by {} hours for timezone {}'.format(roll_hours, timezone))
def __init__(self, lat, lon, year, path_resource="", filepath="", **kwargs): """ :param lat: float :param lon: float :param year: int :param path_resource: directory where to save downloaded files :param filepath: file path of resource file to load :param kwargs: """ super().__init__(lat, lon, year) if os.path.isdir(path_resource): self.path_resource = path_resource self.solar_attributes = 'ghi,dhi,dni,wind_speed,air_temperature,solar_zenith_angle' self.path_resource = os.path.join(self.path_resource, 'solar') # Force override any internal definitions if passed in self.__dict__.update(kwargs) # resource_files files if filepath == "": filepath = os.path.join(self.path_resource, str(lat) + "_" + str(lon) + "_psmv3_" + str(self.interval) + "_" + str( year) + ".csv") self.filename = filepath self.check_download_dir() if not os.path.isfile(self.filename): self.download_resource() self.format_data() logger.info("SolarResource: {}".format(self.filename))
def size_from_reopt(self): """ Calls ReOpt API for optimal sizing with system parameters for each power source. :return: """ if not self.site.urdb_label: raise ValueError("REopt run requires urdb_label") reopt = REopt(lat=self.site.lat, lon=self.site.lon, interconnection_limit_kw=self.interconnect_kw, load_profile=[0] * 8760, urdb_label=self.site.urdb_label, solar_model=self.solar, wind_model=self.wind, fin_model=self.grid.financial_model, fileout=str(self._fileout / "REoptResult.json")) results = reopt.get_reopt_results(force_download=False) wind_size_kw = results["outputs"]["Scenario"]["Site"]["Wind"][ "size_kw"] self.wind.system_capacity_closest_fit(wind_size_kw) solar_size_kw = results["outputs"]["Scenario"]["Site"]["PV"]["size_kw"] self.solar.system_capacity_kw = solar_size_kw logger.info("HybridSystem set system capacities to REopt output")
def get_reopt_results(self, force_download=False): """ Call the reopt_api Parameters --------- force_download: bool Whether to force a new api call if the results file already is on disk Returns ------ results: dict A dictionary of REopt results, as defined """ logger.info("REopt getting results") results = dict() success = os.path.isfile(self.fileout) if not success or force_download: post_url = self.reopt_api_post_url + '&api_key={api_key}'.format( api_key=get_developer_nrel_gov_key()) resp = requests.post(post_url, json.dumps(self.post), verify=False) if resp.ok: run_id_dict = json.loads(resp.text) try: run_id = run_id_dict['run_uuid'] except KeyError: msg = "Response from {} did not contain run_uuid.".format( post_url) raise KeyError(msg) poll_url = self.reopt_api_poll_url + '{run_uuid}/results/?api_key={api_key}'.format( run_uuid=run_id, api_key=get_developer_nrel_gov_key()) results = self.poller(url=poll_url) with open(self.fileout, 'w') as fp: json.dump(obj=results, fp=fp) logger.info("Received REopt response, exported to {}".format( self.fileout)) else: text = json.loads(resp.text) if "messages" in text.keys(): logger.error("REopt response reading error: " + str(text['messages'])) raise Exception(text["messages"]) resp.raise_for_status() elif success: with open(self.fileout, 'r') as fp: results = json.load(fp=fp) logger.info("Read REopt response from {}".format(self.fileout)) return results
def poller(url, poll_interval=5): """ Function for polling the REopt API results URL until status is not "Optimizing..." :param url: results url to poll :param poll_interval: seconds :return: dictionary response (once status is not "Optimizing...") """ key_error_count = 0 key_error_threshold = 3 status = "Optimizing..." logger.info("Polling {} for results with interval of {}s...".format(url, poll_interval)) while True: resp = requests.get(url=url, verify=False) resp_dict = json.loads(resp.content) try: status = resp_dict['outputs']['Scenario']['status'] except KeyError: key_error_count += 1 logger.info('KeyError count: {}'.format(key_error_count)) if key_error_count > key_error_threshold: logger.info('Breaking polling loop due to KeyError count threshold of {} exceeded.' .format(key_error_threshold)) break if status != "Optimizing...": break else: time.sleep(poll_interval) if not resp.ok: text = json.loads(resp.text) if "messages" in text.keys(): logger.error("REopt response reading error: " + str(text['messages'])) raise Exception(text["messages"]) resp.raise_for_status() return resp_dict
def set_total_installed_cost_dollars(self, total_installed_cost_dollars: float): self.financial_model.SystemCosts.total_installed_cost = total_installed_cost_dollars logger.info("{} set total_installed_cost to ${}".format( self.name, total_installed_cost_dollars))
def __init__(self, data, solar_resource_file="", wind_resource_file="", grid_resource_file="", hub_height=97, capacity_hours=[], desired_schedule=[]): """ Site specific information required by the hybrid simulation class and layout optimization. :param data: dict, containing the following keys: #. ``lat``: float, latitude [decimal degrees] #. ``lon``: float, longitude [decimal degrees] #. ``year``: int, year used to pull solar and/or wind resource data. If not provided, default is 2012 [-] #. ``elev``: float (optional), elevation (metadata purposes only) [m] #. ``tz``: int (optional), timezone code (metadata purposes only) [-] #. ``no_solar``: bool (optional), if ``True`` solar data download for site is skipped, otherwise solar resource is downloaded from NSRDB #. ``no_wind``: bool (optional), if ``True`` wind data download for site is skipped, otherwise wind resource is downloaded from wind-toolkit #. ``site_boundaries``: dict (optional), with the following keys: * ``verts``: list of list [x,y], site boundary vertices [m] * ``verts_simple``: list of list [x,y], simple site boundary vertices [m] #. ``urdb_label``: string (optional), `Link Utility Rate DataBase <https://openei.org/wiki/Utility_Rate_Database>`_ label for REopt runs .. TODO: Can we get rid of verts_simple and simplify site_boundaries :param solar_resource_file: string, location (path) and filename of solar resource file (if not downloading from NSRDB) :param wind_resource_file: string, location (path) and filename of wind resource file (if not downloading from wind-toolkit) :param grid_resource_file: string, location (path) and filename of grid pricing data :param hub_height: int (default = 97), turbine hub height for resource download [m] :param capacity_hours: list of booleans, (8760 length) ``True`` if the hour counts for capacity payments, ``False`` otherwise :param desired_schedule: list of floats, (8760 length) absolute desired load profile [MWe] """ set_nrel_key_dot_env() self.data = data if 'site_boundaries' in data: self.vertices = np.array([np.array(v) for v in data['site_boundaries']['verts']]) self.polygon: Polygon = Polygon(self.vertices) self.valid_region = self.polygon.buffer(1e-8) if 'lat' not in data or 'lon' not in data: raise ValueError("SiteInfo requires lat and lon") self.lat = data['lat'] self.lon = data['lon'] if 'year' not in data: data['year'] = 2012 if 'no_solar' not in data: data['no_solar'] = False if not data['no_solar']: self.solar_resource = SolarResource(data['lat'], data['lon'], data['year'], filepath=solar_resource_file) if 'no_wind' not in data: data['no_wind'] = False if not data['no_wind']: # TODO: allow hub height to be used as an optimization variable self.wind_resource = WindResource(data['lat'], data['lon'], data['year'], wind_turbine_hub_ht=hub_height, filepath=wind_resource_file) self.elec_prices = ElectricityPrices(data['lat'], data['lon'], data['year'], filepath=grid_resource_file) self.n_timesteps = len(self.solar_resource.data['gh']) // 8760 * 8760 self.n_periods_per_day = self.n_timesteps // 365 # TODO: Does not handle leap years well self.interval = int((60*24)/self.n_periods_per_day) self.urdb_label = data['urdb_label'] if 'urdb_label' in data.keys() else None if len(capacity_hours) == self.n_timesteps: self.capacity_hours = capacity_hours else: self.capacity_hours = [False] * self.n_timesteps # Desired load schedule for the system to dispatch against self.desired_schedule = desired_schedule self.follow_desired_schedule = len(desired_schedule) == self.n_timesteps if len(desired_schedule) > 0 and len(desired_schedule) != self.n_timesteps: raise ValueError('The provided desired schedule does not match length of the simulation horizon.') # FIXME: this a hack if 'no_wind' in data: logger.info("Set up SiteInfo with solar resource files: {}".format(self.solar_resource.filename)) else: logger.info( "Set up SiteInfo with solar and wind resource files: {}, {}".format(self.solar_resource.filename, self.wind_resource.filename))