Skip to content

Commit

Permalink
Fix battery optimisation algorithm (#153)
Browse files Browse the repository at this point in the history
* Limit the discharge of storage to output capacity left
On the second iteration through the dispatchables (price sensitive) max_load_at should be limited by the output capacity left, not just the output capacity

* Use output effiency as a limiting factor of charging

* Add index as second level attribute to sort_by method for quintel/etengine#1343
Mac OS and Linux handle Ruby sort method differntly, by also sorting on the index the same sorting is forced for both operating systems

---------

Co-authored-by: Mathijs Bijkerk <mathijs.bijkerk@quintel.com>
  • Loading branch information
noracato and mabijkerk committed Aug 31, 2023
1 parent cb582c6 commit 421f3fb
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 42 deletions.
2 changes: 1 addition & 1 deletion lib/merit/cost_strategy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module Merit
# Contains classes which know how to calculate the cost of a producer. Each strategy should
# implement at least one method: "marginal_cost". This accetps an optional "point" argument
# implement at least one method: "marginal_cost". This accepts an optional "point" argument
# telling it for which hour in the year we want to calculate the cost.
#
# An optional "sortable_cost" method, with the same signature, is used to sort producers prior to
Expand Down
67 changes: 26 additions & 41 deletions lib/merit/flex/optimizing_storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,6 @@ def self.run(
lookbehind: 72,
output_efficiency: 1.0
)
input_efficiency, output_efficiency = normalized_efficiencies(output_efficiency)

# Creates curves which describe the maximum amount by which the battery can charge or
# discharge in each hour.
charging_target = build_target(charging_limit, input_capacity, data.length)
Expand All @@ -97,22 +95,23 @@ def self.run(

# Contains all hours where there is room to discharge, sorted in ascending order (hour of
# largest value is last).
charge_frames = frames.select { |f| discharging_target[f.index].positive? }.sort_by(&:value)
charge_frames = frames.select { |f| discharging_target[f.index].positive? }.sort_by{ |f| [f.value, f.index] }

# Keeps track of how much energy is stored in each hour.
reserve = Numo::DFloat.zeros(data.length)

while charge_frames.length.positive?
max_frame = charge_frames.pop

# Eventually will contain the amount of energy to be charged in the min frame and
# discharged at the max frame.
available_energy = discharging_target[max_frame.index]
# Eventually will contain the amount of energy to be discharged at the max frame.
available_output_energy = discharging_target[max_frame.index]

# The frame cannot be discharged any further.
next if available_energy.zero?
next if available_output_energy.zero?

# Only charge from an hour whose value is 95% or less than the max frame value.
# This effectively ensures that a discharge hour will not be matched to a charge
# hour of roughly the same value.
desired_low = max_frame.value * 0.95

# Contains the hour within the lookbehind period with the minimum value.
Expand All @@ -126,10 +125,13 @@ def self.run(

current = frames[min_index]

# Limit charging by the remaining volume in the frame.
available_energy = [volume - reserve[min_index], available_energy].min
# Limit charging by the remaining volume in the frame, combined with the
# efficiency to ensure we have enough in reserve to account for the losses.
available_output_energy = [
(volume - reserve[min_index]) * output_efficiency,
available_output_energy].min

next unless available_energy.positive? &&
next unless available_output_energy.positive? &&
charging_target[current.index].positive? &&
(!min_frame || current.value < min_frame.value) &&
current.value < desired_low
Expand All @@ -141,37 +143,36 @@ def self.run(
# on the max frame.
next if min_frame.nil?

# The amount of energy to be charged in the min frame and discharged at the max frame.
# Limited to 1/4 of the difference in order to assign frames back on to the stack to so
# that their energy may be more fairly shared with other nearby frames.
available_energy = [charging_target[min_frame.index], available_energy].min
# Limit discharging by input capacity left for charging
available_output_energy = [
(charging_target[min_frame.index] * output_efficiency),
available_output_energy].min

# The amount of energy to be charged in the min frame and discharged at the max frame.
# The amount of energy to be discharged at the max frame.
# Limited to 1/4 of the difference in order to assign frames back on to the stack to so
# that their energy may be more fairly shared with other nearby frames.
available_energy = [(max_frame.value - min_frame.value) / 4, available_energy].min
available_output_energy = [(max_frame.value - min_frame.value) / 4, available_output_energy].min

next if available_energy < 1e-5
next if available_output_energy < 1e-5

input_energy = available_output_energy / output_efficiency

# Add the charge and discharge to the reserve.
if min_frame.index > max_frame.index
# Wrapped from end of the year to the beginning
reserve[min_frame.index..-1] += available_energy
reserve[0...max_frame.index] += available_energy if max_frame.index.positive?
reserve[min_frame.index..-1] += input_energy
reserve[0...max_frame.index] += input_energy if max_frame.index.positive?
else
reserve[min_frame.index...max_frame.index] += available_energy
reserve[min_frame.index...max_frame.index] += input_energy
end

input_energy = available_energy * input_efficiency
output_energy = available_energy * output_efficiency

min_frame.value += input_energy
max_frame.value -= output_energy
max_frame.value -= available_output_energy

charging_target[min_frame.index] -= input_energy
discharging_target[min_frame.index] = 0 # Frame is no longer allowed to discharge.

discharging_target[max_frame.index] -= output_energy
discharging_target[max_frame.index] -= available_output_energy
charging_target[max_frame.index] = 0 # Frame is no longer allowed to charge.

next unless discharging_target[max_frame.index].positive?
Expand All @@ -189,22 +190,6 @@ def self.run(
reserve
end

# Determines the input and output efficiency of a battery based on a given output efficiency.
#
# The simulation of energy stored in the battery assumes energy in equals energy out. To
# adjust the residual load curve currently, we have to account for the output efficiency of
# the battery. This is done either by lowering the amount of energy discharged when the
# efficiency is lower than 1.0, or lowering the charge amount when greater than 1.0.
#
# Returns an array containing the input and output efficiencies.
def self.normalized_efficiencies(output_efficiency)
if output_efficiency > 1.0
[1.0 / output_efficiency, 1.0]
else
[1.0, output_efficiency]
end
end

# Builds a target curve for the battery to charge or discharge, limited by the given capacity.
#
# If an existing curve is given, it will be clipped to the capacity.
Expand Down
110 changes: 110 additions & 0 deletions spec/merit/flex/optimizing_storage_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,116 @@
end
end

context 'with [3000, 10000, ..., 5000, ...], input capacity 1000, ' \
'output capacity 1000, volume 10000, output efficiency 0.8' do
let(:reserve) do
described_class.run(
(([15_000] * 5 + [30_000]) + ([5_000] * 6 )) * 7,
output_capacity: 1000,
input_capacity: 1000,
volume: 5_000,
output_efficiency: 0.8
)
end

it 'allows up to 1000 hourly discharging' do
slice = reserve.to_a
deltas = slice.map.with_index { |value, index| value - reserve[index - 1] }

expect(deltas.min.round(2)).to eq((-1000 / 0.8).round(2))
end

it 'allows up to 1000 hourly charging' do
slice = reserve.to_a
deltas = slice.map.with_index { |value, index| value - reserve[index - 1] }

expect(deltas.max.round(2)).to eq(1000)
end
end

context 'with [3000, 10000, ..., 5000, ...], input capacity 2000, ' \
'output capacity 1000, volume 10000, output efficiency 0.75' do
let(:reserve) do
described_class.run(
(([3_000] + [10_000] * 5) + [5000] * 6) * 365,
output_capacity: 1000,
input_capacity: 2000,
volume: 10_000,
output_efficiency: 0.75
)
end

it 'allows up to 1000 hourly discharging' do
slice = reserve.to_a[0...24]
deltas = slice.map.with_index { |value, index| value - reserve[index - 1] }

expect(deltas.min.round(2)).to eq((-1000 / 0.75).round(2))
end

it 'allows up to 2000 hourly charging' do
slice = reserve.to_a[0...24]
deltas = slice.map.with_index { |value, index| value - reserve[index - 1] }

expect(deltas.max.round(2)).to eq(2000)
end
end

context 'with [3000, 10000, ..., 5000, ...], input capacity 2000, ' \
'output capacity 1000, volume 10000, output efficiency 1.25' do
let(:reserve) do
described_class.run(
(([3_000] + [10_000] * 5) + [5000] * 6) * 365,
output_capacity: 1000,
input_capacity: 1000,
volume: 10_000,
output_efficiency: 1.25
)
end

it 'allows up to 1000 hourly discharging' do
slice = reserve.to_a[0...24]
deltas = slice.map.with_index { |value, index| value - reserve[index - 1] }

expect(deltas.min.round(2)).to eq((-1000 / 1.25).round(2))
end

it 'allows up to 1000 hourly charging' do
slice = reserve.to_a[0...24]
deltas = slice.map.with_index { |value, index| value - reserve[index - 1] }

expect(deltas.max.round(2)).to eq(1000)
end
end

context 'with [12000, 10000, ..., 5000, ...], input capacity 1000, ' \
'output capacity 2000, volume 10000, output efficiency 0.75' do
let(:reserve) do
described_class.run(
(([12_000] + [10_000] * 5) + [5000] * 6) * 365,
output_capacity: output_capacity,
input_capacity: 1000,
volume: 10_000,
output_efficiency: 0.75
)
end

let(:output_capacity) { 2000 }

it 'allows up to 2000 hourly discharging' do
slice = reserve.to_a
deltas = slice.map.with_index { |value, index| value - reserve[index - 1] }

expect(deltas.min.round(2)).to eq((-output_capacity / 0.75).round(2))
end

it 'allows up to 1000 hourly charging' do
slice = reserve.to_a[0...24]
deltas = slice.map.with_index { |value, index| value - reserve[index - 1] }

expect(deltas.max.round(2)).to eq(1000)
end
end

context 'with [3000, 10000, ..., 5000, ...], input capacity 2000, ' \
'output capacity 1000, volume 10000' do
let(:reserve) do
Expand Down

0 comments on commit 421f3fb

Please sign in to comment.