# ********************************************************************************* # URBANopt™, Copyright (c) 2019-2021, Alliance for Sustainable Energy, LLC, and other # contributors. All rights reserved. # Redistribution and use in source and binary forms, with or without modification, # are permitted provided that the following conditions are met: # Redistributions of source code must retain the above copyright notice, this list # of conditions and the following disclaimer. # Redistributions in binary form must reproduce the above copyright notice, this # list of conditions and the following disclaimer in the documentation and/or other # materials provided with the distribution. # Neither the name of the copyright holder nor the names of its contributors may be # used to endorse or promote products derived from this software without specific # prior written permission. # Redistribution of this software, without modification, must refer to the software # by the same designation. Redistribution of a modified version of this software # (i) may not refer to the modified version by the same designation, or by any # confusingly similar designation, and (ii) must refer to the underlying software # originally provided by Alliance as “URBANopt”. Except to comply with the foregoing, # the term “URBANopt”, or any confusingly similar designation may not be used to # refer to any modified version of this software or any modified version of the # underlying software originally provided by Alliance without the prior written # consent of Alliance. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. # IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE # OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED # OF THE POSSIBILITY OF SUCH DAMAGE. # ********************************************************************************* require 'net/https' require 'openssl' require 'uri' require 'uri' require 'json' require 'securerandom' require 'certified' require_relative '../../../developer_nrel_key' require 'urbanopt/reopt/reopt_logger' module URBANopt # :nodoc: module REopt # :nodoc: class REoptLiteAPI ## # \REoptLiteAPI manages submitting optimization tasks to the \REopt Lite API and recieving results. # Results can either be sourced from the production \REopt Lite API with an API key from developer.nrel.gov, or from # a version running at localhost. ## # # [*parameters:*] # # * +use_localhost+ - _Bool_ - If this is true, requests will be sent to a version of the \REopt Lite API running on localhost. Default is false, such that the production version of \REopt Lite is accessed. # * +nrel_developer_key+ - _String_ - API key used to access the \REopt Lite APi. Required only if localhost is false. Obtain from https://developer.nrel.gov/signup/ ## def initialize(nrel_developer_key = nil, use_localhost = false) @use_localhost = use_localhost if @use_localhost @uri_submit = URI.parse('http//:127.0.0.1:8000/v1/job/') @uri_submit_outagesimjob = URI.parse('http//:127.0.0.1:8000/v1/outagesimjob/') else if [nil, '', ''].include? nrel_developer_key if [nil, '', ''].include? DEVELOPER_NREL_KEY raise 'A developer.nrel.gov API key is required. Please see https://developer.nrel.gov/signup/ then update the file urbanopt-reopt-gem/developer_nrel_key.rb' else nrel_developer_key = DEVELOPER_NREL_KEY end end @nrel_developer_key = nrel_developer_key @uri_submit = URI.parse("https://developer.nrel.gov/api/reopt/v1/job/?api_key=#{@nrel_developer_key}") @uri_submit_outagesimjob = URI.parse("https://developer.nrel.gov/api/reopt/v1/outagesimjob/?api_key=#{@nrel_developer_key}") # initialize @@logger @@logger ||= URBANopt::REopt.reopt_logger end end ## # URL of the results end point for a specific optimization task ## # # [*parameters:*] # # * +run_uuid+ - _String_ - Unique run_uuid obtained from the \REopt Lite job submittal URL for a specific optimization task. # # [*return:*] _URI_ - Returns URI object for use in calling the \REopt Lite results endpoint for a specifc optimization task. ## def uri_results(run_uuid) # :nodoc: if @use_localhost return URI.parse("http://127.0.0.1:8000/v1/job/#{run_uuid}/results") end return URI.parse("https://developer.nrel.gov/api/reopt/v1/job/#{run_uuid}/results?api_key=#{@nrel_developer_key}") end ## # URL of the resilience statistics end point for a specific optimization task ## # # [*parameters:*] # # * +run_uuid+ - _String_ - Resilience statistics for a unique run_uuid obtained from the \REopt Lite job submittal URL for a specific optimization task. # # [*return:*] _URI_ - Returns URI object for use in calling the \REopt Lite resilience statistics endpoint for a specifc optimization task. ## def uri_resilience(run_uuid) # :nodoc: if @use_localhost return URI.parse("http://127.0.0.1:8000/v1/job/#{run_uuid}/resilience_stats") end return URI.parse("https://developer.nrel.gov/api/reopt/v1/job/#{run_uuid}/resilience_stats?api_key=#{@nrel_developer_key}") end def make_request(http, r, max_tries = 3) result = nil tries = 0 while tries < max_tries begin result = http.request(r) # Result codes sourced from https://developer.nrel.gov/docs/errors/ if result.code == '429' @@logger.fatal('Exceeded the REopt-Lite API limit of 300 requests per hour') puts 'Using the URBANopt CLI to submit a Scenario optimization counts as one request per scenario' puts 'Using the URBANopt CLI to submit a Feature optimization counts as one request per feature' abort('Please wait and try again once the time period has elapsed. The URBANopt CLI flag --reopt-keep-existing can be used to resume the optimization') elsif (result.code != '201') && (result.code != '200') # Anything in the 200s is success @@logger.debug("REopt-Lite has returned a '#{result.code}' status code. Visit https://developer.nrel.gov/docs/errors/ for more status code information") # display error messages json_res = JSON.parse(result.body, allow_nan: true) json_res['messages'].delete('warnings') if json_res['messages']['warnings'] json_res['messages'].delete('Deprecations') if json_res['messages']['Deprecations'] if json_res['messages'] @@logger.error("MESSAGES: #{json_res['messages']}") end end tries = max_tries rescue StandardError => e @@logger.debug("error from REopt lite API: #{e}") if tries + 1 < max_tries @@logger.debug('trying again...') else @@logger.debug('max tries reached!') return result end tries += 1 end end return result end ## # Checks if a optimization task can be submitted to the \REopt Lite API ## # # [*parameters:*] # # * +data+ - _Hash_ - Default \REopt Lite formatted post containing at least all the required parameters. # # [*return:*] _Bool_ - Returns true if the post succeeeds. Otherwise returns false. ## def check_connection(data) header = { 'Content-Type' => 'application/json' } http = Net::HTTP.new(@uri_submit.host, @uri_submit.port) if !@use_localhost http.use_ssl = true end post_request = Net::HTTP::Post.new(@uri_submit, header) post_request.body = ::JSON.generate(data, allow_nan: true) # Send the request response = make_request(http, post_request) if !response.is_a?(Net::HTTPSuccess) @@logger.error('Check_connection Failed') raise 'Check_connection Failed' end return true end ## # Completes a \REopt Lite optimization. From a formatted hash, an optimization task is submitted to the API. # Results are polled at 5 second interval until they are ready or an error is returned from the API. Results # are written to disk. ## # # [*parameters:*] # # * +reopt_input+ - _Hash_ - \REopt Lite formatted post containing at least required parameters. # * +filename+ - _String_ - Path to file that will be created containing the full \REopt Lite response. # # [*return:*] _Bool_ - Returns true if the post succeeeds. Otherwise returns false. ## def resilience_request(run_uuid, filename) if File.directory? filename if run_uuid.nil? run_uuid = 'error' end if run_uuid.downcase.include? 'error' run_uuid = "error#{SecureRandom.uuid}" end filename = File.join(filename, "#{run_uuid}_resilience.json") @@logger.info("REopt results saved to #{filename}") end # Submit Job @@logger.info("Submitting Resilience Statistics job for #{run_uuid}") header = { 'Content-Type' => 'application/json' } http = Net::HTTP.new(@uri_submit_outagesimjob.host, @uri_submit_outagesimjob.port) if !@use_localhost http.use_ssl = true end post_request = Net::HTTP::Post.new(@uri_submit_outagesimjob, header) post_request.body = ::JSON.generate({ 'run_uuid' => run_uuid, 'bau' => false }, allow_nan: true) submit_response = make_request(http, post_request) @@logger.debug(submit_response.body) # Fetch Results uri = uri_resilience(run_uuid) http = Net::HTTP.new(uri.host, uri.port) if !@use_localhost http.use_ssl = true end # Wait a few seconds for the REopt database to update before GETing results sleep 5 get_request = Net::HTTP::Get.new(uri.request_uri) response = make_request(http, get_request, 8) # Set a limit on retries when 404s are returned from REopt API elapsed_time = 0 max_elapsed_time = 60 * 5 # If database still hasn't updated, wait a little longer and try again while (elapsed_time < max_elapsed_time) && (response && response.code == '404') response = make_request(http, get_request) @@logger.warn('GET request was too fast for REOpt-Lite API. Retrying...') elapsed_time += 5 sleep 5 end data = JSON.parse(response.body, allow_nan: true) text = JSON.pretty_generate(data) begin File.open(filename, 'w+') do |f| f.puts(text) end rescue StandardError => e @@logger.error("Cannot write - #{filename}") @@logger.error("ERROR: #{e}") end if response.code == '200' return data end @@logger.error("Error from REopt API - #{data['Error']}") return {} end ## # Completes a \REopt Lite optimization. From a formatted hash, an optimization task is submitted to the API. # Results are polled at 5 second interval until they are ready or an error is returned from the API. Results # are written to disk. ## # # [*parameters:*] # # * +reopt_input+ - _Hash_ - \REopt Lite formatted post containing at least required parameters. # * +filename+ - _String_ - Path to file that will be created containing the full \REopt Lite response. # # [*return:*] _Bool_ - Returns true if the post succeeeds. Otherwise returns false. ## def reopt_request(reopt_input, filename) description = reopt_input[:Scenario][:description] @@logger.info("Submitting #{description} to REopt Lite API") # Format the request header = { 'Content-Type' => 'application/json' } http = Net::HTTP.new(@uri_submit.host, @uri_submit.port) if !@use_localhost http.use_ssl = true end post_request = Net::HTTP::Post.new(@uri_submit, header) post_request.body = ::JSON.generate(reopt_input, allow_nan: true) # Send the request response = make_request(http, post_request) if !response.is_a?(Net::HTTPSuccess) @@logger.error('make_request Failed') raise 'Check_connection Failed' end # Get UUID run_uuid = JSON.parse(response.body, allow_nan: true)['run_uuid'] if File.directory? filename if run_uuid.nil? run_uuid = 'error' end if run_uuid.downcase.include? 'error' run_uuid = "error#{SecureRandom.uuid}" end filename = File.join(filename, "#{description}_#{run_uuid}.json") @@logger.info("REopt results saved to #{filename}") end text = JSON.parse(response.body, allow_nan: true) if response.code != '201' File.open(filename, 'w+') do |f| f.puts(JSON.pretty_generate(text)) end raise "Error in REopt optimization post - see #{filename}" end # Poll results until ready or error occurs status = 'Optimizing...' uri = uri_results(run_uuid) http = Net::HTTP.new(uri.host, uri.port) if !@use_localhost http.use_ssl = true end get_request = Net::HTTP::Get.new(uri.request_uri) while status == 'Optimizing...' response = make_request(http, get_request) data = JSON.parse(response.body, allow_nan: true) if data['outputs']['Scenario']['Site']['PV'].is_a?(Array) pv_sizes = 0 data['outputs']['Scenario']['Site']['PV'].each do |x| pv_sizes += x['size_kw'].to_f end else pv_sizes = data['outputs']['Scenario']['Site']['PV']['size_kw'] || 0 end sizes = pv_sizes + (data['outputs']['Scenario']['Site']['Storage']['size_kw'] || 0) + (data['outputs']['Scenario']['Site']['Wind']['size_kw'] || 0) + (data['outputs']['Scenario']['Site']['Generator']['size_kw'] || 0) status = data['outputs']['Scenario']['status'] sleep 5 end _max_retry = 5 _tries = 0 (check_complete = sizes == 0) && ((data['outputs']['Scenario']['Site']['Financial']['npv_us_dollars'] || 0) > 0) while (_tries < _max_retry) && check_complete sleep 3 response = make_request(http, get_request) data = JSON.parse(response.body, allow_nan: true) if data['outputs']['Scenario']['Site']['PV'].is_a?(Array) pv_sizes = 0 data['outputs']['Scenario']['Site']['PV'].each do |x| pv_sizes += x['size_kw'].to_f end else pv_sizes = data['outputs']['Scenario']['Site']['PV']['size_kw'] || 0 end sizes = pv_sizes + (data['outputs']['Scenario']['Site']['Storage']['size_kw'] || 0) + (data['outputs']['Scenario']['Site']['Wind']['size_kw'] || 0) + (data['outputs']['Scenario']['Site']['Generator']['size_kw'] || 0) (check_complete = sizes == 0) && ((data['outputs']['Scenario']['Site']['Financial']['npv_us_dollars'] || 0) > 0) _tries += 1 end data = JSON.parse(response.body, allow_nan: true) text = JSON.pretty_generate(data) begin File.open(filename, 'w+') do |f| f.puts(text) end rescue StandardError @@logger.error("Cannot write - #{filename}") end if status == 'optimal' return data end error_message = data['messages']['error'] raise "Error from REopt API - #{error_message}" end end end end