Skip to content

Commit

Permalink
Merge pull request #1730 from gridsingularity/bug/GSYE-679
Browse files Browse the repository at this point in the history
GSYE-679: Fix handling of case when the heat pump storage tank temper…
  • Loading branch information
hannesdiedrich authored Jan 23, 2024
2 parents 57b4c20 + 1c54ab4 commit a89cf46
Show file tree
Hide file tree
Showing 12 changed files with 78 additions and 81 deletions.
46 changes: 22 additions & 24 deletions src/gsy_e/models/strategy/energy_parameters/heat_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def __init__(
self._maximum_power_rating_kW = maximum_power_rating_kW
self._max_energy_consumption_kWh = (
maximum_power_rating_kW * self._slot_length.total_hours())
self.state = HeatPumpState(initial_temp_C, self._slot_length)
self.state = HeatPumpState(initial_temp_C, self._slot_length, min_temp_C)

def last_time_slot(self, current_market_slot: DateTime) -> DateTime:
"""Calculate the previous time slot from the current one."""
Expand Down Expand Up @@ -95,6 +95,10 @@ def _populate_state(self, time_slot: DateTime):
time_slot, self._calc_temp_decrease_K(time_slot))

self._calc_energy_demand(time_slot)
self._calculate_and_set_unmatched_demand(time_slot)

def _calculate_and_set_unmatched_demand(self, time_slot: DateTime):
pass

def _calc_cop(self, time_slot: DateTime) -> float:
pass
Expand Down Expand Up @@ -128,6 +132,8 @@ def event_traded_energy(self, time_slot: DateTime, energy_kWh: float):
self.state.update_temp_increase_K(
time_slot, self._calc_temp_increase_K(time_slot, energy_kWh))

self._calculate_and_set_unmatched_demand(time_slot)

def _decrement_posted_energy(self, time_slot: DateTime, energy_kWh: float):
updated_min_energy_demand_kWh = max(
0., self.get_min_energy_demand_kWh(time_slot) - energy_kWh)
Expand All @@ -140,6 +146,7 @@ def _decrement_posted_energy(self, time_slot: DateTime, energy_kWh: float):

class HeatPumpEnergyParameters(HeatPumpEnergyParametersBase):
"""Energy Parameters for the heat pump."""

# pylint: disable=too-many-instance-attributes, too-many-arguments
def __init__(
self,
Expand All @@ -166,6 +173,8 @@ def __init__(
external_temp_C_profile, external_temp_C_profile_uuid,
profile_type=InputProfileTypes.IDENTITY)

self.min_temp_C = min_temp_C # for usage in the strategy

def serialize(self):
"""Return dict with the current energy parameter values."""
return {
Expand Down Expand Up @@ -196,43 +205,23 @@ def _calc_energy_to_buy_maximum(self, time_slot: DateTime) -> float:

assert max_energy_consumption > -FLOATING_POINT_TOLERANCE
if max_energy_consumption > self._max_energy_consumption_kWh:
# demand is higher than max_power_rating
self.state.update_unmatched_demand_kWh(
time_slot, max_energy_consumption - self._max_energy_consumption_kWh)
return self._max_energy_consumption_kWh
return max_energy_consumption

def _calc_energy_to_buy_minimum(self, time_slot: DateTime) -> float:
max_temp_decrease_allowed = (
self.state.get_storage_temp_C(time_slot) - self._min_temp_C)
temp_diff = self.state.get_temp_decrease_K(time_slot)
if abs(temp_diff - max_temp_decrease_allowed) < -FLOATING_POINT_TOLERANCE:
return 0
min_energy_consumption = (
self._temp_diff_to_Q_kWh(temp_diff) / self.state.get_cop(time_slot))

if min_energy_consumption > self._max_energy_consumption_kWh:
# demand is higher than max_power_rating
self.state.update_unmatched_demand_kWh(
time_slot, min_energy_consumption - self._max_energy_consumption_kWh)
return self._max_energy_consumption_kWh
return min_energy_consumption

def _calc_temp_decrease_K(self, time_slot: DateTime) -> float:
demanded_temp_decrease_K = self._Q_kWh_to_temp_diff(self._calc_Q_from_energy_kWh(
return self._Q_kWh_to_temp_diff(self._calc_Q_from_energy_kWh(
time_slot, self._consumption_kWh.profile[time_slot]))
if self.state.get_storage_temp_C(time_slot) - demanded_temp_decrease_K < self._min_temp_C:
actual_temp_decrease = self.state.get_storage_temp_C(time_slot) - self._min_temp_C
unmatched_demand_kWh = self._temp_diff_to_Q_kWh(
demanded_temp_decrease_K - actual_temp_decrease) / self.state.get_cop(time_slot)
self.state.update_unmatched_demand_kWh(time_slot, unmatched_demand_kWh)
else:
actual_temp_decrease = demanded_temp_decrease_K

return actual_temp_decrease

def _calc_temp_increase_K(self, time_slot: DateTime, traded_energy_kWh: float) -> float:
self.state.update_unmatched_demand_kWh(time_slot, -traded_energy_kWh)
return self._Q_kWh_to_temp_diff(self._calc_Q_from_energy_kWh(time_slot, traded_energy_kWh))

def _populate_state(self, time_slot: DateTime):
Expand All @@ -257,8 +246,17 @@ def _cop_model(self, temp_current: float, temp_ambient: float) -> float:
"""COP model following https://www.nature.com/articles/s41597-019-0199-y"""
delta_temp = temp_current - temp_ambient
if self._source_type == HeatPumpSourceType.AIR.value:
return 6.08 - 0.09 * delta_temp + 0.0005 * delta_temp**2
return 6.08 - 0.09 * delta_temp + 0.0005 * delta_temp ** 2
if self._source_type == HeatPumpSourceType.GROUND.value:
return 10.29 - 0.21 * delta_temp + 0.0012 * delta_temp**2
return 10.29 - 0.21 * delta_temp + 0.0012 * delta_temp ** 2

raise HeatPumpEnergyParametersException("HeatPumpSourceType not supported")

def _calculate_and_set_unmatched_demand(self, time_slot: DateTime) -> None:
temp_balance = (
self.state.get_temp_increase_K(time_slot) -
self.state.get_temp_decrease_K(time_slot))
if temp_balance < FLOATING_POINT_TOLERANCE:
unmatched_energy_demand = self._temp_diff_to_Q_kWh(
abs(temp_balance)) / self.state.get_cop(time_slot)
self.state.set_unmatched_demand_kWh(time_slot, unmatched_energy_demand)
6 changes: 3 additions & 3 deletions src/gsy_e/models/strategy/energy_parameters/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,15 @@ def set_energy_measurement_kWh(self, time_slot: DateTime) -> None:

self.state.set_energy_measurement_kWh(simulated_measured_energy_kWh, time_slot)

def update_energy_requirement(self, time_slot, overwrite=False):
def update_energy_requirement(self, time_slot):
"""Update the energy requirement and desired energy from the state class."""
self.energy_per_slot_Wh = convert_W_to_Wh(
self.avg_power_W, self._area.config.slot_length)
if self.allowed_operating_hours(time_slot):
desired_energy_Wh = self.energy_per_slot_Wh
else:
desired_energy_Wh = 0.0
self.state.set_desired_energy(desired_energy_Wh, time_slot, overwrite)
self.state.set_desired_energy(desired_energy_Wh, time_slot)

def allowed_operating_hours(self, time_slot):
"""Check if timeslot inside allowed operating hours."""
Expand Down Expand Up @@ -202,7 +202,7 @@ def reset(self, time_slot: DateTime, **kwargs) -> None:
self.energy_profile.input_profile = kwargs["daily_load_profile"]
self.energy_profile.read_or_rotate_profiles(reconfigure=True)

def update_energy_requirement(self, time_slot, overwrite=False):
def update_energy_requirement(self, time_slot):
if not self.energy_profile.profile:
raise GSyException(
"Load tries to set its energy forecasted requirement "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,11 @@ def _calc_temp_decrease_K(self, time_slot: DateTime) -> float:
self.state.get_storage_temp_C(time_slot) - temp_decrease_C)
if new_temperature_without_operation_C < self._min_temp_C:
temp_decrease_C = 0.0
self._calculate_unmatched_demand(time_slot)
self._calculate_and_set_unmatched_demand(time_slot)
assert temp_decrease_C <= 0.0
return abs(temp_decrease_C)

def _calculate_unmatched_demand(self, time_slot: DateTime):
def _calculate_and_set_unmatched_demand(self, time_slot: DateTime):
solver = HeatpumpStorageEnergySolver(
tank_volume_l=self._tank_volume_l,
current_storage_temp_C=self.state.get_storage_temp_C(time_slot),
Expand Down
5 changes: 5 additions & 0 deletions src/gsy_e/models/strategy/heat_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,12 @@ def remove_open_orders(self, market: "MarketBase", market_slot: DateTime):
market.delete_bid(bid)

def _get_energy_buy_energy(self, buy_rate: float, market_slot: DateTime) -> float:
TEMPERATURE_TOLERANCE_C = 2
if buy_rate > self.preferred_buying_rate:
temp_decrease = self._energy_params.state.get_temp_decrease_K(market_slot)
if self.state.get_storage_temp_C(market_slot) - temp_decrease >= (
self._energy_params.min_temp_C + TEMPERATURE_TOLERANCE_C):
return 0
return self._energy_params.get_min_energy_demand_kWh(market_slot)
return self._energy_params.get_max_energy_demand_kWh(market_slot)

Expand Down
1 change: 0 additions & 1 deletion src/gsy_e/models/strategy/load_hours.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ def event_activate(self, **kwargs):
self._calculate_active_markets()
self.event_activate_price()
self.bid_update.update_and_populate_price_settings(self.area)
self._update_energy_requirement_in_state()
self._future_market_strategy.update_and_populate_price_settings(self)

def _cycle_energy_parameters(self):
Expand Down
4 changes: 2 additions & 2 deletions src/gsy_e/models/strategy/predefined_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,14 @@ def _update_energy_requirement_spot_market(self):
self._energy_params.energy_profile.read_or_rotate_profiles()

slot_time = self.area.spot_market.time_slot
self._energy_params.update_energy_requirement(slot_time, self.owner.name)
self._energy_params.update_energy_requirement(slot_time)

self._update_energy_requirement_future_markets()

def _update_energy_requirement_future_markets(self):
"""Update energy requirements in the future markets."""
for time_slot in self.area.future_market_time_slots:
self._energy_params.update_energy_requirement(time_slot, self.owner.name)
self._energy_params.update_energy_requirement(time_slot)

def area_reconfigure_event(self, *args, **kwargs):
"""Reconfigure the device properties at runtime using the provided arguments."""
Expand Down
2 changes: 1 addition & 1 deletion src/gsy_e/models/strategy/scm/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def activate(self, area: "AreaBase") -> None:

def _update_energy_requirement(self, area: "AreaBase") -> None:
self._energy_params.energy_profile.read_or_rotate_profiles()
self._energy_params.update_energy_requirement(area.current_market_time_slot, area.name)
self._energy_params.update_energy_requirement(area.current_market_time_slot)

def market_cycle(self, area: "AreaBase") -> None:
"""Update the load forecast and measurements for the next/previous market slot."""
Expand Down
30 changes: 19 additions & 11 deletions src/gsy_e/models/strategy/state/heat_pump_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,25 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from collections import defaultdict
from logging import getLogger
from typing import Dict

from gsy_framework.utils import (
convert_pendulum_to_str_in_dict, convert_str_to_pendulum_in_dict)
from pendulum import DateTime, duration

from gsy_e import constants
from gsy_e.constants import FLOATING_POINT_TOLERANCE
from gsy_e.models.strategy.state.base_states import StateInterface, UnexpectedStateException
from gsy_e.models.strategy.state.base_states import StateInterface

log = getLogger(__name__)


class HeatPumpState(StateInterface):
# pylint: disable=too-many-instance-attributes, too-many-public-methods
"""State for the heat pump strategy."""

def __init__(
self, initial_temp_C: float, slot_length: duration):
self, initial_temp_C: float, slot_length: duration, min_storage_temp_C: float):
# the defaultdict was only selected for the initial slot
self._storage_temp_C: Dict[DateTime, float] = defaultdict(lambda: initial_temp_C)
self._min_energy_demand_kWh: Dict[DateTime, float] = {}
Expand All @@ -45,8 +47,9 @@ def __init__(
self._cop: Dict[DateTime, float] = defaultdict(lambda: 0)
self._condenser_temp_C: Dict[DateTime, float] = defaultdict(lambda: 0)
self._heat_demand_J: Dict[DateTime, float] = defaultdict(lambda: 0)
self._total_traded_energy_kWh: float = 0.0
self._total_traded_energy_kWh: float = 0
self._slot_length = slot_length
self._min_storage_temp_C = min_storage_temp_C

def get_storage_temp_C(self, time_slot: DateTime) -> float:
"""Return temperature of storage for a time slot in degree celsius."""
Expand All @@ -57,15 +60,20 @@ def update_storage_temp(self, current_time_slot: DateTime):
new_temp = (self.get_storage_temp_C(self._last_time_slot(current_time_slot))
- self.get_temp_decrease_K(self._last_time_slot(current_time_slot))
+ self.get_temp_increase_K(self._last_time_slot(current_time_slot)))
if new_temp < -FLOATING_POINT_TOLERANCE:
raise UnexpectedStateException("Storage of heat pump should not drop below zero.")
if new_temp < self._min_storage_temp_C:
new_temp = self._min_storage_temp_C
log.warning("Storage tank temperature dropped below minimum, setting to minimum.")
self._storage_temp_C[current_time_slot] = new_temp

def update_unmatched_demand_kWh(self, current_time_slot: DateTime, energy_kWh: float):
"""Update unmatched demand while ensuring always positive numbers."""
updated_unmatched_demand = self._unmatched_demand_kWh[current_time_slot] + energy_kWh
self._unmatched_demand_kWh[current_time_slot] = max(0., updated_unmatched_demand)

def set_unmatched_demand_kWh(self, current_time_slot: DateTime, energy_kWh: float):
"""Set unmatched demand while ensuring always positive numbers."""
self._unmatched_demand_kWh[current_time_slot] = max(0., energy_kWh)

def set_min_energy_demand_kWh(self, time_slot: DateTime, energy_kWh: float):
"""Set the minimal energy demanded for a given time slot."""
self._min_energy_demand_kWh[time_slot] = energy_kWh
Expand All @@ -80,11 +88,7 @@ def increase_total_traded_energy_kWh(self, energy_kWh: float):

def set_temp_decrease_K(self, time_slot: DateTime, temp_diff_K: float):
"""Set the temperature decrease for a given time slot."""
temp_decrease = temp_diff_K
# Do not allow temperature to go below zero
if self._storage_temp_C[time_slot] - temp_diff_K < -FLOATING_POINT_TOLERANCE:
temp_decrease = self._storage_temp_C[time_slot]
self._temp_decrease_K[time_slot] = temp_decrease
self._temp_decrease_K[time_slot] = temp_diff_K

def set_energy_consumption_kWh(self, time_slot: DateTime, energy_kWh: float):
"""Set the energy consumption of the heatpump for a given time slot."""
Expand Down Expand Up @@ -160,6 +164,8 @@ def get_state(self) -> Dict:
"condenser_temp_C": convert_pendulum_to_str_in_dict(self._condenser_temp_C),
"heat_demand_J": convert_pendulum_to_str_in_dict(self._heat_demand_J),
"total_traded_energy_kWh": self._total_traded_energy_kWh,
"slot_length": self._slot_length.total_seconds(),
"min_storage_temp_C": self._min_storage_temp_C
}

def restore_state(self, state_dict: Dict):
Expand All @@ -178,6 +184,8 @@ def restore_state(self, state_dict: Dict):
self._condenser_temp_C = convert_str_to_pendulum_in_dict(state_dict["condenser_temp_C"])
self._heat_demand_J = convert_str_to_pendulum_in_dict(state_dict["heat_demand_J"])
self._total_traded_energy_kWh = state_dict["total_traded_energy_kWh"]
self._slot_length = duration(seconds=state_dict["slot_length"])
self._min_storage_temp_C = state_dict["min_storage_temp_C"]

def delete_past_state_values(self, current_time_slot: DateTime):
if not current_time_slot or constants.RETAIN_PAST_MARKET_STRATEGIES_STATE:
Expand Down
39 changes: 8 additions & 31 deletions tests/strategies/energy_parameters/test_heat_pump.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# pylint: disable=protected-access
from math import isclose
from unittest.mock import Mock, call

import pytest
from gsy_framework.constants_limits import GlobalConfig, TIME_ZONE
Expand Down Expand Up @@ -34,6 +33,7 @@ def fixture_heatpump_energy_params() -> HeatPumpEnergyParameters:
max_temp_C=60,
initial_temp_C=20,
tank_volume_l=500,
maximum_power_rating_kW=30,
external_temp_C_profile=external_temp_profile,
consumption_kWh_profile=consumption_profile,
)
Expand Down Expand Up @@ -64,12 +64,13 @@ def test_event_market_cycle_populates_state(energy_params):
assert CURRENT_MARKET_SLOT not in energy_params.state._min_energy_demand_kWh
assert CURRENT_MARKET_SLOT not in energy_params.state._max_energy_demand_kWh
energy_params.event_market_cycle(CURRENT_MARKET_SLOT)
assert energy_params.state._temp_decrease_K[CURRENT_MARKET_SLOT] == 10.0
assert isclose(energy_params.state._temp_decrease_K[CURRENT_MARKET_SLOT],
56.4, abs_tol=1e-3)
assert energy_params.state._storage_temp_C[CURRENT_MARKET_SLOT] == 20
assert isclose(energy_params.state._min_energy_demand_kWh[CURRENT_MARKET_SLOT],
0.8865112724493694)
5, abs_tol=1e-3)
assert isclose(energy_params.state._max_energy_demand_kWh[CURRENT_MARKET_SLOT],
3.0)
8.546, abs_tol=1e-3)

@staticmethod
def test_event_traded_energy_decrements_posted_energy(energy_params):
Expand All @@ -94,39 +95,15 @@ def test_event_traded_energy_updates_temp_increase(energy_params):
@staticmethod
def test_get_min_energy_demand_kWh_returns_correct_value(energy_params):
energy_params.event_market_cycle(CURRENT_MARKET_SLOT)
energy_params.get_min_energy_demand_kWh(CURRENT_MARKET_SLOT)
assert isclose(energy_params.get_min_energy_demand_kWh(CURRENT_MARKET_SLOT),
0.8865112724493694)
5, abs_tol=1e-3)

@staticmethod
def test_get_max_energy_demand_kWh_returns_correct_value(energy_params):
energy_params.event_market_cycle(CURRENT_MARKET_SLOT)
energy_params.get_max_energy_demand_kWh(CURRENT_MARKET_SLOT)
assert (energy_params.get_max_energy_demand_kWh(CURRENT_MARKET_SLOT) ==
3.0)

@staticmethod
def test__calc_temp_decrease_K_sets_unmatched_demand(energy_params):
energy_params.state.update_unmatched_demand_kWh = Mock()
energy_params.event_market_cycle(CURRENT_MARKET_SLOT)
assert energy_params.state.update_unmatched_demand_kWh.call_count == 2
assert energy_params.state.update_unmatched_demand_kWh.call_args_list == [
call(CURRENT_MARKET_SLOT, 4.113488727550631),
call(CURRENT_MARKET_SLOT, 1.4325563622468467)
]

@staticmethod
@pytest.mark.parametrize("maximum_power_rating_kW, expected_unmatched_demand",
[[3, 3.5460],
[5, 2.113]])
def test__calc_temp_increase_K_sets_unmatched_demand(
energy_params, maximum_power_rating_kW, expected_unmatched_demand):
energy_params._max_energy_consumption_kWh = maximum_power_rating_kW
energy_params.event_market_cycle(CURRENT_MARKET_SLOT)
energy_params.event_traded_energy(CURRENT_MARKET_SLOT, 2)
assert isclose(
energy_params.state._unmatched_demand_kWh[CURRENT_MARKET_SLOT],
expected_unmatched_demand, abs_tol=1e-3)
assert isclose(energy_params.get_max_energy_demand_kWh(CURRENT_MARKET_SLOT),
8.546, abs_tol=1e-3)

@staticmethod
def test_event_market_cycle_calculates_and_sets_cop(energy_params):
Expand Down
Loading

0 comments on commit a89cf46

Please sign in to comment.