Handle missing hourly price data from ENTSOE API by interpolating values from adjacent time slots instead of returning nil.
167 lines
6.3 KiB
Ruby
167 lines
6.3 KiB
Ruby
#
|
|
# Obtain energy prices from Entsoe (transparancy platform)
|
|
# See https://transparency.entsoe.eu/content/static_content/Static%20content/web%20api/Guide.html
|
|
#
|
|
|
|
require 'open-uri'
|
|
require 'nokogiri'
|
|
|
|
class Entsoe
|
|
|
|
URL = 'https://web-api.tp.entsoe.eu'
|
|
|
|
attr_accessor :no_grid_charge_months, :zone
|
|
attr_reader :storage_cost
|
|
|
|
def initialize(zone = "Amsterdam", storage_cost = 0.05, api_key = "c2287e07-0c26-4950-b430-22b7f75a8f2e")
|
|
@api_key = api_key
|
|
@kwh_prices = {}
|
|
@storage_cost = storage_cost # how much does it cost to store 1 kwh in battery
|
|
@no_grid_charge_months = [5,6,7] # months where we assume there is enough surplus from sun power
|
|
@zone = zone
|
|
end
|
|
|
|
def price_at(formatted_hour)
|
|
unless @kwh_prices.key?(formatted_hour)
|
|
p "Fetching Entsoe tariffs for %s" % formatted_hour
|
|
prices = query_day_ahead_prices(Date.parse(formatted_hour))
|
|
@kwh_prices.merge!(hourly_format(prices))
|
|
end
|
|
@kwh_prices[formatted_hour]
|
|
end
|
|
|
|
def prices_at(date)
|
|
hour_start = date.beginning_of_day.in_time_zone(@zone)
|
|
day_end = hour_start.advance(days: 1)
|
|
result = {}
|
|
while(hour_start < day_end) do
|
|
formatted_hour = hour_start.strftime("%F %H")
|
|
result.merge!({ formatted_hour => price_at(formatted_hour)})
|
|
hour_start = hour_start.advance(:hours => 1)
|
|
end
|
|
result
|
|
end
|
|
|
|
def high_low_hours(date)
|
|
sorted_prices = prices_at(date).to_a.sort_by(&:last) # sort according to price
|
|
highest_hour = sorted_prices.last[0]
|
|
|
|
# Some months: do not charge; every hour that has cost > 0 is a high_hour
|
|
if @no_grid_charge_months.include?(date.month)
|
|
lowest_hour = "" # effectively no charging from grid
|
|
high_hours = sorted_prices.select{|p| p[1] > 0}.to_h.keys
|
|
else
|
|
lowest_hour = sorted_prices.first[0]
|
|
|
|
# calculate hours where rate > charge_rate + storage_cost (typically 0.05)
|
|
charge_rate = sorted_prices.first[1] # assume we charge at lowest hour
|
|
high_hours = sorted_prices.select{|p| p[1] > charge_rate + storage_cost}.to_h.keys
|
|
|
|
# reset lowest_hour (effectively not charging), when price difference smaller than storage_cost
|
|
highest_price = sorted_prices.last[1]
|
|
lowest_hour = "" if (highest_price-charge_rate) < storage_cost
|
|
end
|
|
|
|
return lowest_hour,highest_hour,high_hours
|
|
end
|
|
|
|
def query_day_ahead_prices(date)
|
|
start_date = date.beginning_of_day
|
|
end_date = date.end_of_day.advance(hours: -1)
|
|
|
|
# A44 - Document type => Price Document
|
|
# NL = '10YNL----------L'
|
|
|
|
domain = '10YNL----------L'
|
|
url = URL + "/api?securityToken=%s&documentType=A44&in_Domain=%s&out_Domain=%s&periodStart=%s&periodEnd=%s" % [@api_key, domain, domain, start_date.strftime("%Y%m%d%H%M"), end_date.strftime("%Y%m%d%H00")]
|
|
#p url
|
|
|
|
base_request(date, url)
|
|
end
|
|
|
|
|
|
private
|
|
|
|
def hourly_format(prices)
|
|
# in memory hash @kwh_prices is in @zone timezone
|
|
prices.to_a.map{|p| [p[0].in_time_zone(zone).strftime("%F %H"),p[1]]}.to_h
|
|
end
|
|
|
|
# get position and amount from XML snippet
|
|
# <Point>
|
|
# <position>1</position>
|
|
# <price.amount>196.23</price.amount>
|
|
# </Point>
|
|
#
|
|
# convert price to EUR per kwh, excluding VAT
|
|
#
|
|
def parse_point(xml)
|
|
return xml.xpath(".//xmlns:position").text.to_i, (xml.xpath(".//xmlns:price.amount").text.to_f/1000).round(5)
|
|
end
|
|
|
|
def base_request(date, url)
|
|
formatted_date = date.strftime("%F")
|
|
|
|
doc = Nokogiri::XML(URI.open(url))
|
|
|
|
#p "Entsoe prices: %s" % doc
|
|
|
|
prices = doc.xpath('.//xmlns:Point').map{|p| parse_point(p)}
|
|
begin
|
|
# get start_time (in UTC) from XML docment
|
|
start_time = DateTime.parse(doc.xpath('.//xmlns:period.timeInterval//xmlns:start').text)
|
|
|
|
# Get resolution to determine expected number of positions
|
|
resolution = doc.xpath('.//xmlns:resolution').text
|
|
interval_minutes = resolution == 'PT15M' ? 15 : 60
|
|
expected_positions = (24 * 60) / interval_minutes
|
|
|
|
# Create hash from available prices
|
|
price_hash = prices.to_h
|
|
|
|
# Fill in missing positions by interpolating from adjacent values
|
|
complete_prices = {}
|
|
(1..expected_positions).each do |position|
|
|
if price_hash.key?(position)
|
|
complete_prices[position] = price_hash[position]
|
|
else
|
|
# Find previous and next available prices for interpolation
|
|
prev_price = (position-1).downto(1).find { |p| price_hash.key?(p) }
|
|
next_price = (position+1).upto(expected_positions).find { |p| price_hash.key?(p) }
|
|
|
|
if prev_price && next_price
|
|
# Interpolate between previous and next
|
|
complete_prices[position] = ((price_hash[prev_price] + price_hash[next_price]) / 2.0).round(5)
|
|
elsif prev_price
|
|
# Use previous price as fallback
|
|
complete_prices[position] = price_hash[prev_price]
|
|
elsif next_price
|
|
# Use next price as fallback
|
|
complete_prices[position] = price_hash[next_price]
|
|
end
|
|
|
|
# Calculate the formatted hour for the warning message
|
|
hour_offset = interval_minutes == 15 ? (position - 1) / 4 : (position - 1)
|
|
missing_hour = start_time.advance(hours: hour_offset).in_time_zone(zone).strftime("%F %H")
|
|
p "Warning: Missing Entsoe data for #{missing_hour}, interpolated value: #{complete_prices[position]}"
|
|
end
|
|
end
|
|
|
|
#returns a hash with keys formatted "yyyy-mm-dd hr" and values price (per kwh)
|
|
# <position> tag runs from 1-96 for 15min intervals. Convert to hourly by taking first interval of each hour
|
|
if interval_minutes == 15
|
|
# For 15-minute intervals, use the first interval of each hour (positions 1, 5, 9, 13, ...)
|
|
complete_prices.select { |pos, _| (pos - 1) % 4 == 0 }
|
|
.map { |pos, price| [start_time.advance(hours: ((pos - 1) / 4)), price] }
|
|
.to_h
|
|
else
|
|
# For hourly data (position runs from 1-24, we need hours from 00-23)
|
|
complete_prices.map { |pos, price| [start_time.advance(hours: (pos - 1)), price] }.to_h
|
|
end
|
|
rescue Date::Error => e
|
|
p e.message
|
|
{}
|
|
end
|
|
end
|
|
end
|