From a7c322918353ea5cc12150399c08e79dd37d5e48 Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Wed, 13 May 2026 08:55:12 +0200 Subject: [PATCH 01/17] GSYE-891: Fix some small things in the heat pump classes and adapt MinimiseHeatpumpSwitchStrategy to be open for inheritance by the SorTES child --- .../energy_parameters/heatpump/heat_pump.py | 3 +- src/gsy_e/models/strategy/heat_pump.py | 9 ++---- .../strategy/heat_pump_soc_management.py | 31 ++++++++++++++----- .../models/strategy/state/heatpump_state.py | 3 +- 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/gsy_e/models/strategy/energy_parameters/heatpump/heat_pump.py b/src/gsy_e/models/strategy/energy_parameters/heatpump/heat_pump.py index 2ad277771..1ecd07c57 100644 --- a/src/gsy_e/models/strategy/energy_parameters/heatpump/heat_pump.py +++ b/src/gsy_e/models/strategy/energy_parameters/heatpump/heat_pump.py @@ -383,7 +383,8 @@ def combined_state(self) -> CombinedHeatpumpTanksState: """Combined heatpump and tanks state.""" return self._state - def last_time_slot(self, current_market_slot: DateTime) -> DateTime: + @staticmethod + def last_time_slot(current_market_slot: DateTime) -> DateTime: """Calculate the previous time slot from the current one.""" return current_market_slot - GlobalConfig.slot_length diff --git a/src/gsy_e/models/strategy/heat_pump.py b/src/gsy_e/models/strategy/heat_pump.py index 65295fb20..0193a55c4 100644 --- a/src/gsy_e/models/strategy/heat_pump.py +++ b/src/gsy_e/models/strategy/heat_pump.py @@ -2,8 +2,7 @@ from typing import Dict, TYPE_CHECKING, Optional, Union, List from decimal import Decimal -from gsy_framework.constants_limits import ConstSettings -from gsy_framework.constants_limits import FLOATING_POINT_TOLERANCE +from gsy_framework.constants_limits import ConstSettings, FLOATING_POINT_TOLERANCE from gsy_framework.data_classes import Trade, TraderDetails from gsy_framework.enums import AvailableMarketTypes from gsy_framework.exceptions import GSyException @@ -272,8 +271,7 @@ def __init__( ) def _init_price_params(self, order_updater_parameters, preferred_buying_rate): - self.use_default_updater_params: bool = not order_updater_parameters - if self.use_default_updater_params: + if not order_updater_parameters: order_updater_parameters = { AvailableMarketTypes.SPOT: HeatPumpOrderUpdaterParameters() } @@ -423,8 +421,7 @@ def serialize(self): } def _init_price_params(self, order_updater_parameters): - self.use_default_updater_params: bool = not order_updater_parameters - if self.use_default_updater_params: + if not order_updater_parameters: order_updater_parameters = { AvailableMarketTypes.SPOT: HeatPumpOrderUpdaterParameters() } diff --git a/src/gsy_e/models/strategy/heat_pump_soc_management.py b/src/gsy_e/models/strategy/heat_pump_soc_management.py index 4f96d7b26..fc8ab1145 100644 --- a/src/gsy_e/models/strategy/heat_pump_soc_management.py +++ b/src/gsy_e/models/strategy/heat_pump_soc_management.py @@ -73,7 +73,7 @@ def event_activate(self): else: assert False, "GlobalConfig.market_maker_rate was not initiated yet." - def calculate(self, time_slot: DateTime, _buy_rate: float = 0.0): + def calculate(self, time_slot: DateTime, buy_rate: float = 0.0): """ Calculate the bid energy depending on the current state of the heat pump, the current SOC and whether the market maker rate is cheap or expensive compared to the average market @@ -83,13 +83,19 @@ def calculate(self, time_slot: DateTime, _buy_rate: float = 0.0): if not self._is_time_for_state_change(time_slot): # If state change is not possible, but at the same time the soc is below the min or # above the max SOC value of the tank, then maintain the SOC. - if self._charger.get_average_soc(time_slot) >= self.MAX_SOC_TOLERANCE: + if ( + self._get_tank_soc(time_slot) >= self.MAX_SOC_TOLERANCE + and self._current_state == HeatPumpChargingState.CHARGE + ): target_state = HeatPumpChargingState.MAINTAIN_SOC - if self._charger.get_average_soc(time_slot) <= self.MIN_SOC_TOLERANCE: + if ( + self._get_tank_soc(time_slot) <= self.MIN_SOC_TOLERANCE + and self._current_state == HeatPumpChargingState.DISCHARGE + ): target_state = HeatPumpChargingState.MAINTAIN_SOC else: # If the state change is possible, check the market maker rate to set the new state - target_state = self._should_charge_or_discharge(time_slot) + target_state = self._should_charge_or_discharge(time_slot, buy_rate) if self._current_state != target_state: # If the target state is the same as the current state, do nothing. Otherwise, # update the current state and the last switch timestamp. @@ -98,6 +104,9 @@ def calculate(self, time_slot: DateTime, _buy_rate: float = 0.0): return self._get_energy_from_target_state(target_state, time_slot) + def _get_tank_soc(self, time_slot: DateTime) -> float: + return self._charger.get_average_soc(time_slot) + def _is_time_for_state_change(self, time_slot: DateTime) -> bool: if not self._last_switch: # If the last_switch has not been set yet, the simulation is starting and no switch has @@ -119,20 +128,26 @@ def _get_energy_from_target_state( return self._energy_params.get_min_energy_demand_kWh(time_slot) return self._energy_params.get_energy_demand_kWh(time_slot) - def _should_charge_or_discharge(self, time_slot: DateTime) -> HeatPumpChargingState: - if GlobalConfig.market_maker_rate[time_slot] <= self._average_rate: + def _should_charge_or_discharge( + self, time_slot: DateTime, buy_rate: float + ) -> HeatPumpChargingState: + + if self._is_energy_affordable(time_slot, buy_rate): # If the market maker rate is lower than the average rate, charge except if the SOC is # too high. - if self._charger.get_average_soc(time_slot) >= self.MAX_SOC_TOLERANCE: + if self._get_tank_soc(time_slot) >= self.MAX_SOC_TOLERANCE: return HeatPumpChargingState.MAINTAIN_SOC return HeatPumpChargingState.CHARGE # If the market maker rate is higher than the average rate, discharge except if the SOC # is too low. - if self._charger.get_average_soc(time_slot) > self.MIN_SOC_TOLERANCE: + if self._get_tank_soc(time_slot) > self.MIN_SOC_TOLERANCE: return HeatPumpChargingState.DISCHARGE return HeatPumpChargingState.MAINTAIN_SOC + def _is_energy_affordable(self, time_slot: DateTime, _buy_rate: float) -> bool: + return GlobalConfig.market_maker_rate[time_slot] <= self._average_rate + def heat_pump_soc_management_factory( energy_params: "HeatPumpEnergyParameters", preferred_buying_rate: float diff --git a/src/gsy_e/models/strategy/state/heatpump_state.py b/src/gsy_e/models/strategy/state/heatpump_state.py index 9cfc2cd40..a1419a661 100644 --- a/src/gsy_e/models/strategy/state/heatpump_state.py +++ b/src/gsy_e/models/strategy/state/heatpump_state.py @@ -81,7 +81,8 @@ def set_cop(self, time_slot: DateTime, cop: float): """Set cop for the given time slot.""" self._cop[time_slot] = cop - def _last_time_slot(self, current_market_slot: DateTime) -> DateTime: + @staticmethod + def _last_time_slot(current_market_slot: DateTime) -> DateTime: return current_market_slot - GlobalConfig.slot_length @staticmethod From ca1f6f0a12456d3f8dbc12cf9f9ad7d6ab4b96c8 Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Wed, 13 May 2026 08:56:23 +0200 Subject: [PATCH 02/17] GSYE-891: Add first version of SorTES tank heat pump --- src/gsy_e/constants.py | 14 + .../sim_results/file_export_endpoints.py | 50 ++ .../strategy/heatpump_with_sortes_tank.py | 535 ++++++++++++++++++ .../average_trading_profile_sortes.csv | 87 +++ .../setup/strategy_tests/heat_pump_sortes.py | 78 +++ 5 files changed, 764 insertions(+) create mode 100644 src/gsy_e/models/strategy/heatpump_with_sortes_tank.py create mode 100644 src/gsy_e/resources/average_trading_profile_sortes.csv create mode 100644 src/gsy_e/setup/strategy_tests/heat_pump_sortes.py diff --git a/src/gsy_e/constants.py b/src/gsy_e/constants.py index 523e461ae..3c5759487 100644 --- a/src/gsy_e/constants.py +++ b/src/gsy_e/constants.py @@ -107,3 +107,17 @@ class HeatPumpSettingsDefaultParameters: # Set the precision of the decimal numbers used in the simulation. getcontext().prec = 12 + + +class SorTesConfiguration: + """Collection of SorTes heat tank configuration parameters.""" + + MINUTES_BEFORE_SWITCH_ALLOWED = 2 * 60 + MIN_SOC_TOLERANCE = 10 + MAX_SOC_TOLERANCE = 90 + CAPACITY_KWH = 25 + COP_HEAT_SOURCE = 1 + COP_CONDENSER = 1 + COP_EVAPORATOR = 1 + CONVERSION_CHARGE_CONDENSER_POWER = 1 / 1.2 + CONVERSION_DISCHARGE_EVAPORATOR_POWER = 1 / 1.2 diff --git a/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py b/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py index 00120f645..222abe805 100644 --- a/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py +++ b/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py @@ -33,6 +33,7 @@ MultipleTankHeatPumpStrategy, HeatPumpStrategyWithoutTanks, ) +from gsy_e.models.strategy.heatpump_with_sortes_tank import HeatPumpWithSorTesTankStrategy from gsy_e.models.strategy.load_hours import LoadHoursStrategy from gsy_e.models.strategy.pv import PVStrategy from gsy_e.models.strategy.storage import StorageStrategy @@ -50,6 +51,14 @@ def is_heatpump_strategy_without_tanks(area: Area): ) +def is_heatpump_strategy_with_sortes_tank(area: Area): + """todo""" + return isinstance( + area.strategy, + HeatPumpWithSorTesTankStrategy, + ) + + def is_heatpump_strategy_with_tanks(area: Area): """Return if area has a heat pump strategy.""" return isinstance( @@ -407,6 +416,45 @@ def _row(self, slot, market): return rows +class HeatPumpWithSortesTankDataExporter(BaseDataExporter): + """Data exporter dedicated to heat pump areas""" + + def __init__(self, area, past_markets): + assert is_heatpump_strategy_with_sortes_tank(area) + self.area = area + self.past_markets = past_markets + + @property + def labels(self) -> List: + return [ + "slot", + "energy traded [kWh]", + "COP", + "heat demand [kJ]", + "soc %", + ] + + @property + def rows(self) -> List: + return [self._row(market.time_slot, market) for market in self.past_markets] + + def _traded(self, market): + return ( + market.traded_energy[self.area.name] if self.area.name in market.traded_energy else 0 + ) + + def _row(self, slot, market): + rows = [slot] + hp_stats = self.area.strategy.state.get_results_dict(slot) + rows += [ + round(self._traded(market), ROUND_TOLERANCE_EXPORT), + round(hp_stats["cop"], ROUND_TOLERANCE_EXPORT), + round(hp_stats["heat_demand_kJ"], ROUND_TOLERANCE_EXPORT), + round(hp_stats["soc"], ROUND_TOLERANCE_EXPORT), + ] + return rows + + class FileExportEndpoints: """Handle data preparation for csv-file and plot export.""" @@ -439,6 +487,8 @@ def export_data_factory( return HeatPumpDataExporter(area, area.parent.past_markets) if is_heatpump_strategy_without_tanks(area): return HeatPumpWithoutTanksDataExporter(area, area.parent.past_markets) + if is_heatpump_strategy_with_sortes_tank(area): + return HeatPumpWithSortesTankDataExporter(area, area.parent.past_markets) return LeafDataExporter(area, area.parent.past_markets) if past_market_type == AvailableMarketTypes.BALANCING: return BalancingDataExporter(area.past_balancing_markets) diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py new file mode 100644 index 000000000..df70bff11 --- /dev/null +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -0,0 +1,535 @@ +from decimal import Decimal +from typing import TYPE_CHECKING, Optional, Union + +import numpy as np +from gsy_framework.constants_limits import GlobalConfig, ConstSettings, FLOATING_POINT_TOLERANCE +from gsy_framework.enums import AvailableMarketTypes, HeatPumpSourceType +from gsy_framework.read_user_profile import InputProfileTypes +from gsy_framework.utils import ( + convert_kJ_to_kWh, + convert_pendulum_to_str_in_dict, + convert_str_to_pendulum_in_dict, + convert_kW_to_kWh, +) +from pendulum import DateTime + +import gsy_e.constants as constants +from gsy_e.models.strategy.energy_parameters.heatpump.cop_models import ( + COPModelType, + cop_model_factory, +) +from gsy_e.models.strategy.heat_pump import HeatPumpOrderUpdaterParameters, HeatPumpStrategyBase +from gsy_e.models.strategy.heat_pump_soc_management import ( + MinimiseHeatpumpSwitchStrategy, + HeatPumpChargingState, +) +from gsy_e.models.strategy.state.heatpump_state import delete_time_slots_in_state +from gsy_e.models.strategy.strategy_profile import profile_factory, StrategyProfileBase + +if TYPE_CHECKING: + from gsy_e.models.market import MarketBase + + +class SorTesPerformanceMaps: + CHARGING_POWER_MAP = { + 5: 3.8, + 10: 3.5, + 15: 3.3, + 20: 3.0, + 25: 2.6, + 30: 2.2, + 35: 1.8, + 40: 1.5, + 45: 0.5, + } # ambient_temp [K]: power [kW] + DISCHARGING_POWER_MAP = { + 5: 3.0, + 10: 3.5, + 15: 4, + 20: 4.3, + 25: 4.8, + } # ambient_temp [K]: power [kW] + + @classmethod + def get_power_charging(cls, temperature: float) -> float: + return cls._get_power(temperature, cls.CHARGING_POWER_MAP) + + @classmethod + def get_power_discharging(cls, temperature: float) -> float: + return cls._get_power(temperature, cls.DISCHARGING_POWER_MAP) + + @classmethod + def _get_power(cls, temperature: float, power_table: dict) -> float: + temps = np.array(sorted(power_table.keys()), dtype=float) + powers = np.array([power_table[t] for t in sorted(power_table.keys())], dtype=float) + + return float( + np.interp(temperature, temps, powers, left=None, right=None) + if temps[0] <= temperature <= temps[-1] + else cls._extrapolate(temperature, temps, powers) + ) + + @staticmethod + def _extrapolate(temperature: float, temps: np.ndarray, powers: np.ndarray) -> float: + """Linear extrapolation using the two nearest edge points.""" + if temperature < temps[0]: + # Use the first two points for left extrapolation + slope = (powers[1] - powers[0]) / (temps[1] - temps[0]) + return powers[0] + slope * (temperature - temps[0]) + else: + # Use the last two points for right extrapolation + slope = (powers[-1] - powers[-2]) / (temps[-1] - temps[-2]) + return powers[-1] + slope * (temperature - temps[-1]) + + +class SorTesTankMinimiseSwitchStrategy(MinimiseHeatpumpSwitchStrategy): + MINUTES_BEFORE_SWITCH_ALLOWED = constants.SorTesConfiguration.MINUTES_BEFORE_SWITCH_ALLOWED + MIN_SOC_TOLERANCE = constants.SorTesConfiguration.MIN_SOC_TOLERANCE + MAX_SOC_TOLERANCE = constants.SorTesConfiguration.MAX_SOC_TOLERANCE + + def __init__( + self, + energy_params: "SorTesTankEnergyParameters", + average_trade_rate: Union[str, float, dict], + ): + self._last_switch: Optional[DateTime] = None + self._energy_params = energy_params + self._current_state = HeatPumpChargingState.MAINTAIN_SOC + self._average_trade_rate = profile_factory( + average_trade_rate, None, profile_type=InputProfileTypes.IDENTITY + ) + + @property + def current_state(self) -> HeatPumpChargingState: + return self._current_state + + def _get_tank_soc(self, time_slot: DateTime): + return self._energy_params.get_soc(time_slot) + + def _is_energy_affordable(self, time_slot: DateTime, _buy_rate: float) -> bool: + return ( + self._average_trade_rate.get_value(time_slot) + < GlobalConfig.market_maker_rate[time_slot] + ) + + def event_activate(self): + self._average_trade_rate.read_or_rotate_profiles() + + def event_market_slot(self): + self._average_trade_rate.read_or_rotate_profiles() + + +class SorTesTankState: + + def __init__(self): + self._soc: dict[DateTime, float] = {} + self._cop: dict[DateTime, float] = {} # in percent + self._energy_demand_kWh: dict[DateTime, float] = {} # electricity + self._heat_demand_kJ: dict[DateTime, float] = {} + self._min_energy_demand_kWh: dict[DateTime, float] = {} + self._max_energy_demand_kWh: dict[DateTime, float] = {} + self._total_traded_energy_kWh: float = 0 # for KPI calculation + + def activate(self): + self._soc[GlobalConfig.start_date] = constants.SorTesConfiguration.MIN_SOC_TOLERANCE + + def set_heat_demand_kJ(self, time_slot: DateTime, heat_demand_kJ: float): + """Set heat demand for the given time slot.""" + self._heat_demand_kJ[time_slot] = heat_demand_kJ + + def get_heat_demand_kJ(self, time_slot: DateTime) -> float: + """Return the heat demand in J for a given time slot.""" + return self._heat_demand_kJ.get(time_slot, 0) + + def get_soc(self, time_slot: DateTime) -> float: + return self._soc.get(time_slot, 0) + + def set_soc(self, time_slot: DateTime, soc: float): + self._soc[time_slot] = soc + + def get_cop(self, time_slot: DateTime) -> float: + return self._cop.get(time_slot, 0) + + def set_cop(self, time_slot: DateTime, cop: float): + self._cop[time_slot] = cop + + def get_min_energy_demand_kWh(self, time_slot: DateTime) -> float: + """Return the minimal energy demanded for a given time slot.""" + return self._min_energy_demand_kWh.get(time_slot, 0) + + def get_max_energy_demand_kWh(self, time_slot: DateTime) -> float: + """Return the maximal energy demanded for a given time slot.""" + return self._max_energy_demand_kWh.get(time_slot, 0) + + def get_energy_demand_kWh(self, time_slot: DateTime) -> float: + """Return the energy demanded for a given time slot.""" + return self._energy_demand_kWh.get(time_slot, 0) + + 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 + + def set_max_energy_demand_kWh(self, time_slot: DateTime, energy_kWh: float): + """Set the maximal energy demanded for a given time slot.""" + self._max_energy_demand_kWh[time_slot] = energy_kWh + + def set_energy_demand_kWh(self, time_slot: DateTime, energy_kWh: float): + """Set the minimal energy demanded for a given time slot.""" + self._energy_demand_kWh[time_slot] = energy_kWh + + def delete_past_state_values(self, current_time_slot: Optional[DateTime] = None): + if not current_time_slot or constants.RETAIN_PAST_MARKET_STRATEGIES_STATE: + return + last_time_slot = self._last_time_slot(current_time_slot) + self._delete_time_slots(self._cop, last_time_slot) + self._delete_time_slots(self._energy_demand_kWh, last_time_slot) + self._delete_time_slots(self._min_energy_demand_kWh, last_time_slot) + self._delete_time_slots(self._max_energy_demand_kWh, last_time_slot) + + def increase_total_traded_energy_kWh(self, energy_kWh: float): + """Add to the total traded energy of the heatpump for a given time slot.""" + self._total_traded_energy_kWh += energy_kWh + + def get_state(self) -> dict: + return { + "soc": convert_pendulum_to_str_in_dict(self._soc), + "cop": convert_pendulum_to_str_in_dict(self._cop), + "energy_demand_kWh": convert_pendulum_to_str_in_dict(self._energy_demand_kWh), + "min_energy_demand_kWh": convert_pendulum_to_str_in_dict(self._min_energy_demand_kWh), + "max_energy_demand_kWh": convert_pendulum_to_str_in_dict(self._max_energy_demand_kWh), + "total_traded_energy_kWh": self._total_traded_energy_kWh, + } + + def restore_state(self, state_dict: dict): + self._soc = convert_str_to_pendulum_in_dict(state_dict["soc"]) + self._cop = convert_str_to_pendulum_in_dict(state_dict["cop"]) + self._energy_demand_kWh = convert_str_to_pendulum_in_dict(state_dict["energy_demand_kWh"]) + self._min_energy_demand_kWh = convert_str_to_pendulum_in_dict( + state_dict["min_energy_demand_kWh"] + ) + self._max_energy_demand_kWh = convert_str_to_pendulum_in_dict( + state_dict["max_energy_demand_kWh"] + ) + self._total_traded_energy_kWh = state_dict["total_traded_energy_kWh"] + + def get_results_dict(self, time_slot: DateTime) -> dict: + return { + "cop": self.get_cop(time_slot), + "energy_demand_kWh": self.get_energy_demand_kWh(time_slot), + "total_traded_energy_kWh": self._total_traded_energy_kWh, + "heat_demand_kJ": self.get_heat_demand_kJ(time_slot), + "soc": self.get_soc(time_slot), + } + + @staticmethod + def _delete_time_slots(profile: dict, current_time_stamp: DateTime): + delete_time_slots_in_state(profile, current_time_stamp) + + @staticmethod + def _last_time_slot(current_market_slot: DateTime) -> DateTime: + return current_market_slot - GlobalConfig.slot_length + + +class SorTesTankEnergyParameters: + + def __init__( + self, + heat_demand_Q_profile: Union[str, float, dict], + ambient_temp_C_profile: Union[str, float, dict], + target_temp_C_profile: Union[str, float, dict], + average_trade_rate: Union[str, float, dict], + source_type: HeatPumpSourceType = ConstSettings.HeatPumpSettings.SOURCE_TYPE, + ): + self._state = SorTesTankState() + + self._heat_demand_Q_J: StrategyProfileBase = profile_factory( + heat_demand_Q_profile, None, profile_type=InputProfileTypes.IDENTITY + ) + self._ambient_temp_C: StrategyProfileBase = profile_factory( + ambient_temp_C_profile, None, profile_type=InputProfileTypes.IDENTITY + ) + self._target_temp_C: StrategyProfileBase = profile_factory( + target_temp_C_profile, None, profile_type=InputProfileTypes.IDENTITY + ) + self._capacity_kWh: float = constants.SorTesConfiguration.CAPACITY_KWH + self._cop_model = cop_model_factory(COPModelType.UNIVERSAL, source_type) + self._bought_energy_kWh = 0.0 + + self._soc_management = SorTesTankMinimiseSwitchStrategy(self, average_trade_rate) + + @property + def state(self) -> SorTesTankState: + return self._state + + @property + def soc_management(self) -> SorTesTankMinimiseSwitchStrategy: + return self._soc_management + + def event_market_cycle(self, current_time_slot: DateTime): + # Order matters here + self._soc_management.event_market_slot() + self._rotate_profiles(current_time_slot) + self._populate_state(current_time_slot) + + def event_activate(self): + self._soc_management.event_activate() + self._rotate_profiles() + self._state.activate() + + def event_traded_energy(self, time_slot: DateTime, energy_kWh: float): + """React to an event_traded_energy.""" + self._bought_energy_kWh += energy_kWh + self._decrement_posted_energy(time_slot, energy_kWh) + + def get_soc(self, time_slot: DateTime): + return self._state.get_soc(time_slot) + + def get_energy_demand_kWh(self, time_slot: DateTime): + return self._state.get_energy_demand_kWh(time_slot) + + def get_min_energy_demand_kWh(self, time_slot: DateTime): + return self._state.get_min_energy_demand_kWh(time_slot) + + def get_max_energy_demand_kWh(self, time_slot: DateTime): + return self._state.get_max_energy_demand_kWh(time_slot) + + def _populate_state(self, time_slot: DateTime): + # order matters! + self._update_last_time_slot_data(time_slot) + self._calc_and_set_cop(time_slot) + self._state.set_heat_demand_kJ( + time_slot, self._heat_demand_Q_J.get_value(time_slot) / 1000.0 + ) + self._calc_and_set_energy_demand(time_slot) + + def _calc_and_set_cop(self, time_slot: DateTime): + cop = self._cop_model.calc_cop( + source_temp_C=self._ambient_temp_C.get_value(time_slot), + condenser_temp_C=self._target_temp_C.get_value(time_slot), + ) + self._state.set_cop(time_slot, cop) + + def _decrement_posted_energy(self, time_slot: DateTime, energy_kWh: float): + updated_energy_demand_kWh = max(0.0, self.get_energy_demand_kWh(time_slot) - energy_kWh) + updated_min_energy_demand_kWh = max( + 0.0, self.get_min_energy_demand_kWh(time_slot) - energy_kWh + ) + updated_max_energy_demand_kWh = max( + 0.0, self.get_max_energy_demand_kWh(time_slot) - energy_kWh + ) + self._state.set_energy_demand_kWh(time_slot, updated_energy_demand_kWh) + self._state.set_min_energy_demand_kWh(time_slot, updated_min_energy_demand_kWh) + self._state.set_max_energy_demand_kWh(time_slot, updated_max_energy_demand_kWh) + + self._state.increase_total_traded_energy_kWh(energy_kWh) + + def _calc_and_set_energy_demand(self, time_slot: DateTime): + energy_demand_kWh = convert_kJ_to_kWh( + self._heat_demand_Q_J.get_value(time_slot) / 1000 + ) / self._state.get_cop(time_slot) + self._state.set_energy_demand_kWh(time_slot, energy_demand_kWh) + self._state.set_min_energy_demand_kWh( + time_slot, self._calc_energy_to_buy_minimum(time_slot) + ) + self._state.set_max_energy_demand_kWh( + time_slot, self._calc_energy_to_buy_maximum(time_slot) + ) + + def _get_performance_energy_charge_kWh(self, time_slot: DateTime) -> float: + charge_power_kW = SorTesPerformanceMaps.get_power_charging( + self._ambient_temp_C.get_value(time_slot) + 5 + ) + return convert_kW_to_kWh(charge_power_kW, GlobalConfig.slot_length) + + def _get_performance_energy_discharge_kWh(self, time_slot: DateTime) -> float: + discharge_power_kW = SorTesPerformanceMaps.get_power_discharging( + self._ambient_temp_C.get_value(time_slot) + 5 + ) + return convert_kW_to_kWh(discharge_power_kW, GlobalConfig.slot_length) + + def _calc_condenser_electricity_kWh(self, charging_energy_kWh: float) -> float: + return ( + charging_energy_kWh + / constants.SorTesConfiguration.COP_CONDENSER + * constants.SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER + ) + + def _calc_evaporator_electricity_kWh(self, discharging_energy_kWh: float) -> float: + return ( + discharging_energy_kWh + / constants.SorTesConfiguration.COP_EVAPORATOR + * constants.SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER + ) + + def _get_total_electricity_demand_for_time_slot_kWh(self, time_slot: DateTime) -> float: + return convert_kJ_to_kWh(self._state.get_heat_demand_kJ(time_slot)) / self._state.get_cop( + time_slot + ) + + def _calc_heat_capacity_into_electricity_kWh(self, heat_capacity: float) -> float: + return heat_capacity / constants.SorTesConfiguration.COP_HEAT_SOURCE + + def _calc_available_free_storage_kWh(self, time_slot: DateTime) -> float: + charge_energy_kWh = self._get_performance_energy_charge_kWh(time_slot) + # add the charge energy to the capacity be able to charge above the maximum + available_heat_storage_kWh = ( + (constants.SorTesConfiguration.MAX_SOC_TOLERANCE - self._state.get_soc(time_slot)) + / 100 + ) * self._capacity_kWh + charge_energy_kWh + + if available_heat_storage_kWh < charge_energy_kWh: + return 0 + return charge_energy_kWh + + def _calc_available_stored_heat_kWh(self, time_slot: DateTime) -> float: + discharge_energy_kWh = self._get_performance_energy_discharge_kWh(time_slot) + # add the discharge energy to the capacity be able to discharge below the minimum + stored_heat_kWh = ( + (self._state.get_soc(time_slot) - constants.SorTesConfiguration.MIN_SOC_TOLERANCE) + / 100 + ) * self._capacity_kWh + discharge_energy_kWh + + if stored_heat_kWh < discharge_energy_kWh: + return 0 + return discharge_energy_kWh + + def _calc_energy_to_buy_maximum(self, time_slot: DateTime) -> float: + available_heat_storage_kWh = self._calc_available_free_storage_kWh(time_slot) + return ( + self._get_total_electricity_demand_for_time_slot_kWh(time_slot) + + self._calc_heat_capacity_into_electricity_kWh(available_heat_storage_kWh) + + self._calc_condenser_electricity_kWh(available_heat_storage_kWh) + ) + + def _calc_energy_to_buy_minimum(self, time_slot: DateTime) -> float: + available_stored_heat_kWh = self._calc_available_stored_heat_kWh(time_slot) + energy_to_be_bought_for_heat = min( + 0, + abs( + self._get_total_electricity_demand_for_time_slot_kWh(time_slot) + - self._calc_heat_capacity_into_electricity_kWh(available_stored_heat_kWh) + ), + ) + + return energy_to_be_bought_for_heat + self._calc_evaporator_electricity_kWh( + available_stored_heat_kWh + ) + + def _rotate_profiles(self, time_slot: Optional[DateTime] = None): + self._state.delete_past_state_values(time_slot) + self._heat_demand_Q_J.read_or_rotate_profiles() + self._ambient_temp_C.read_or_rotate_profiles() + + def _charge_or_discharge_tank(self, time_slot: DateTime): + electricity_demand_kWh = self._get_total_electricity_demand_for_time_slot_kWh( + self.last_time_slot(time_slot) + ) + net_traded_energy_kWh = self._bought_energy_kWh - electricity_demand_kWh + + if ( + self.soc_management.current_state == HeatPumpChargingState.CHARGE + and net_traded_energy_kWh > FLOATING_POINT_TOLERANCE + ): + self._charge(net_traded_energy_kWh, time_slot) + elif ( + self.soc_management.current_state == HeatPumpChargingState.DISCHARGE + and net_traded_energy_kWh > FLOATING_POINT_TOLERANCE + ): + self._discharge(self._bought_energy_kWh, time_slot) + else: + self._no_charge(time_slot) + + def _charge(self, energy_kWh: float, time_slot: DateTime): + charge_energy = self._get_performance_energy_charge_kWh(self.last_time_slot(time_slot)) + condenser_energy_kWh = ( + charge_energy * constants.SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER + ) + heat_energy_kWh = energy_kWh * constants.SorTesConfiguration.COP_HEAT_SOURCE + assert ( + abs(condenser_energy_kWh + charge_energy - heat_energy_kWh) < FLOATING_POINT_TOLERANCE + ) + + self._update_soc(time_slot, charge_energy) + + def _discharge(self, energy_kWh: float, time_slot: DateTime): + discharge_energy_kWh = self._get_performance_energy_discharge_kWh( + self.last_time_slot(time_slot) + ) + evaporator_energy_kWh = ( + discharge_energy_kWh + * constants.SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER + ) + assert energy_kWh == evaporator_energy_kWh + + self._update_soc(time_slot, -discharge_energy_kWh) + + def _update_soc(self, time_slot: DateTime, heat_energy_kWh: float): + old_charge = self._state.get_soc(self.last_time_slot(time_slot)) / 100 * self._capacity_kWh + new_charge = old_charge + heat_energy_kWh + new_soc = new_charge / self._capacity_kWh + self._state.set_soc(time_slot, new_soc * 100) + + def _no_charge(self, time_slot: DateTime): + self._state.set_soc(time_slot, self._state.get_soc(self.last_time_slot(time_slot))) + + def _update_last_time_slot_data(self, time_slot: DateTime): + last_time_slot = self.last_time_slot(time_slot) + if last_time_slot not in self._ambient_temp_C.profile: + return + self._charge_or_discharge_tank(time_slot) + + self._bought_energy_kWh = 0.0 + + @staticmethod + def last_time_slot(current_market_slot: DateTime) -> DateTime: + """Calculate the previous time slot from the current one.""" + return current_market_slot - GlobalConfig.slot_length + + +class HeatPumpWithSorTesTankStrategy(HeatPumpStrategyBase): + + def __init__( + self, + heat_demand_Q_profile: Union[str, float, dict], + ambient_temp_C_profile: Union[str, float, dict], + target_temp_C_profile: Union[str, float, dict], + average_trade_rate: Union[str, float, dict], + source_type: HeatPumpSourceType = ConstSettings.HeatPumpSettings.SOURCE_TYPE, + order_updater_parameters: dict[ + AvailableMarketTypes, HeatPumpOrderUpdaterParameters + ] = None, + ): + + self._init_price_params(order_updater_parameters) + + self._energy_params = SorTesTankEnergyParameters( + heat_demand_Q_profile=heat_demand_Q_profile, + ambient_temp_C_profile=ambient_temp_C_profile, + target_temp_C_profile=target_temp_C_profile, + average_trade_rate=average_trade_rate, + source_type=source_type, + ) + + def post_order( + self, market: "MarketBase", market_slot: DateTime, order_rate: float = None, **kwargs + ): + if not order_rate: + order_rate = self._order_updaters[market][market_slot].get_energy_rate(self.area.now) + else: + order_rate = Decimal(order_rate) + order_energy_kWh = Decimal( + self._energy_params.soc_management.calculate(market_slot, float(order_rate)) + ) + self._post_order(market, market_slot, order_energy_kWh, order_rate) + + def _init_price_params(self, order_updater_parameters): + if not order_updater_parameters: + order_updater_parameters = { + AvailableMarketTypes.SPOT: HeatPumpOrderUpdaterParameters() + } + + super().__init__(order_updater_parameters=order_updater_parameters) + + @property + def state(self) -> SorTesTankState: + return self._energy_params.state diff --git a/src/gsy_e/resources/average_trading_profile_sortes.csv b/src/gsy_e/resources/average_trading_profile_sortes.csv new file mode 100644 index 000000000..1b76198da --- /dev/null +++ b/src/gsy_e/resources/average_trading_profile_sortes.csv @@ -0,0 +1,87 @@ +slot,rate [ct./kWh] +2026-05-12T00:00,30.0 +2026-05-12T00:15,30.0 +2026-05-12T00:30,30.0 +2026-05-12T00:45,30.0 +2026-05-12T01:00,30.0 +2026-05-12T01:15,30.0 +2026-05-12T01:30,30.0 +2026-05-12T01:45,30.0 +2026-05-12T02:00,30.0 +2026-05-12T02:15,30.0 +2026-05-12T02:30,30.0 +2026-05-12T02:45,30.0 +2026-05-12T03:00,30.0 +2026-05-12T03:15,30.0 +2026-05-12T03:30,30.0 +2026-05-12T03:45,30.0 +2026-05-12T04:00,30.0 +2026-05-12T04:15,30.0 +2026-05-12T04:30,30.0 +2026-05-12T04:45,30.0 +2026-05-12T05:00,30.0 +2026-05-12T05:15,30.0 +2026-05-12T05:30,30.0 +2026-05-12T05:45,30.0 +2026-05-12T06:00,30.0 +2026-05-12T06:15,30.0 +2026-05-12T06:30,30.0 +2026-05-12T06:45,30.0 +2026-05-12T07:00,30.0 +2026-05-12T07:15,30.0 +2026-05-12T07:30,30.0 +2026-05-12T07:45,30.0 +2026-05-12T08:00,22.5 +2026-05-12T08:15,22.5 +2026-05-12T08:30,22.5 +2026-05-12T08:45,22.5 +2026-05-12T09:00,15.0 +2026-05-12T09:15,15.0 +2026-05-12T09:30,15.0 +2026-05-12T09:45,15.0 +2026-05-12T10:00,15.0 +2026-05-12T10:15,15.0 +2026-05-12T10:30,15.0 +2026-05-12T10:45,15.0 +2026-05-12T11:00,15.0 +2026-05-12T11:15,15.0 +2026-05-12T11:30,15.0 +2026-05-12T11:45,15.0 +2026-05-12T12:00,15.0 +2026-05-12T12:15,15.0 +2026-05-12T12:30,15.0 +2026-05-12T12:45,15.0 +2026-05-12T13:00,15.0 +2026-05-12T13:15,15.0 +2026-05-12T13:30,15.0 +2026-05-12T13:45,15.0 +2026-05-12T14:00,15.0 +2026-05-12T14:15,15.0 +2026-05-12T14:30,15.0 +2026-05-12T14:45,15.0 +2026-05-12T15:00,15.0 +2026-05-12T15:15,15.0 +2026-05-12T15:30,15.0 +2026-05-12T15:45,15.0 +2026-05-12T16:00,15.0 +2026-05-12T16:15,15.0 +2026-05-12T16:30,15.0 +2026-05-12T16:45,30.0 +2026-05-12T17:00,30.0 +2026-05-12T17:15,30.0 +2026-05-12T17:30,30.0 +2026-05-12T17:45,30.0 +2026-05-12T18:00,30.0 +2026-05-12T18:15,30.0 +2026-05-12T18:30,30.0 +2026-05-12T18:45,30.0 +2026-05-12T19:00,30.0 +2026-05-12T19:15,30.0 +2026-05-12T19:30,30.0 +2026-05-12T19:45,30.0 +2026-05-12T20:00,30.0 +2026-05-12T20:15,30.0 +2026-05-12T20:30,30.0 +2026-05-12T20:45,30.0 +2026-05-12T21:00,30.0 +2026-05-12T21:15,30.0 diff --git a/src/gsy_e/setup/strategy_tests/heat_pump_sortes.py b/src/gsy_e/setup/strategy_tests/heat_pump_sortes.py new file mode 100644 index 000000000..2f6fe1ef3 --- /dev/null +++ b/src/gsy_e/setup/strategy_tests/heat_pump_sortes.py @@ -0,0 +1,78 @@ +""" +Copyright 2018 Grid Singularity +This file is part of Grid Singularity Exchange. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import os + +from gsy_framework.constants_limits import ConstSettings + +from gsy_e.gsy_e_core.util import gsye_root_path +from gsy_e.models.area import Area +from gsy_e.models.strategy.heatpump_with_sortes_tank import HeatPumpWithSorTesTankStrategy +from gsy_e.models.strategy.infinite_bus import InfiniteBusStrategy +from gsy_e.models.strategy.pv import PVStrategy + +ConstSettings.MASettings.MARKET_TYPE = 2 +ConstSettings.GeneralSettings.DEFAULT_UPDATE_INTERVAL = 5 + + +def get_setup(config): + area = Area( + "Grid", + [ + Area( + "House 1", + [ + Area( + "Sortes", + strategy=HeatPumpWithSorTesTankStrategy( + heat_demand_Q_profile=4.8 / 4 * 3600 * 1000, + ambient_temp_C_profile=10, + target_temp_C_profile=35, + average_trade_rate=os.path.join( + gsye_root_path, "resources", "average_trading_profile_sortes.csv" + ), + ), + ), + ], + grid_fee_percentage=0, + grid_fee_constant=0, + ), + Area( + "House 2", + [ + Area( + "H2 PV", + strategy=PVStrategy( + capacity_kW=20, + panel_count=1, + initial_selling_rate=24, + final_selling_rate=0, + ), + ), + ], + grid_fee_percentage=0, + grid_fee_constant=0, + ), + Area( + "Infinite Bus", + strategy=InfiniteBusStrategy(energy_sell_rate=30, energy_buy_rate=0), + ), + ], + config=config, + ) + return area From 7dabf550e1ed5bbc209f8701d397782fa4707a93 Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Wed, 13 May 2026 11:36:39 +0200 Subject: [PATCH 03/17] GSYE-891: Fix some issues in the SorTesTankEnergyParameters and adapt for Pylint --- src/gsy_e/constants.py | 2 +- .../sim_results/file_export_endpoints.py | 4 +- .../strategy/heatpump_with_sortes_tank.py | 199 ++++++++++-------- .../setup/strategy_tests/heat_pump_sortes.py | 6 +- 4 files changed, 119 insertions(+), 92 deletions(-) diff --git a/src/gsy_e/constants.py b/src/gsy_e/constants.py index 3c5759487..19757a011 100644 --- a/src/gsy_e/constants.py +++ b/src/gsy_e/constants.py @@ -118,6 +118,6 @@ class SorTesConfiguration: CAPACITY_KWH = 25 COP_HEAT_SOURCE = 1 COP_CONDENSER = 1 - COP_EVAPORATOR = 1 + COP_EVAPORATOR = 1 # to be updated CONVERSION_CHARGE_CONDENSER_POWER = 1 / 1.2 CONVERSION_DISCHARGE_EVAPORATOR_POWER = 1 / 1.2 diff --git a/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py b/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py index 222abe805..8ffb122dd 100644 --- a/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py +++ b/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py @@ -52,7 +52,7 @@ def is_heatpump_strategy_without_tanks(area: Area): def is_heatpump_strategy_with_sortes_tank(area: Area): - """todo""" + """Return if area has a heat pump strategy with Sortes tanks connected.""" return isinstance( area.strategy, HeatPumpWithSorTesTankStrategy, @@ -432,6 +432,7 @@ def labels(self) -> List: "COP", "heat demand [kJ]", "soc %", + "total_charged_energy_kWh", ] @property @@ -451,6 +452,7 @@ def _row(self, slot, market): round(hp_stats["cop"], ROUND_TOLERANCE_EXPORT), round(hp_stats["heat_demand_kJ"], ROUND_TOLERANCE_EXPORT), round(hp_stats["soc"], ROUND_TOLERANCE_EXPORT), + round(hp_stats["total_charge_energy_kWh"], ROUND_TOLERANCE_EXPORT), ] return rows diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index df70bff11..c5ca24957 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -1,5 +1,6 @@ from decimal import Decimal from typing import TYPE_CHECKING, Optional, Union +from logging import getLogger import numpy as np from gsy_framework.constants_limits import GlobalConfig, ConstSettings, FLOATING_POINT_TOLERANCE @@ -13,7 +14,7 @@ ) from pendulum import DateTime -import gsy_e.constants as constants +from gsy_e.constants import SorTesConfiguration, RETAIN_PAST_MARKET_STRATEGIES_STATE from gsy_e.models.strategy.energy_parameters.heatpump.cop_models import ( COPModelType, cop_model_factory, @@ -23,14 +24,20 @@ MinimiseHeatpumpSwitchStrategy, HeatPumpChargingState, ) -from gsy_e.models.strategy.state.heatpump_state import delete_time_slots_in_state +from gsy_e.models.strategy.state.heatpump_state import ( + HeatPumpStateBase, +) from gsy_e.models.strategy.strategy_profile import profile_factory, StrategyProfileBase if TYPE_CHECKING: from gsy_e.models.market import MarketBase +log = getLogger(__name__) + class SorTesPerformanceMaps: + """Calculate the charging and discharging performance of the SorTES tank""" + CHARGING_POWER_MAP = { 5: 3.8, 10: 3.5, @@ -52,10 +59,12 @@ class SorTesPerformanceMaps: @classmethod def get_power_charging(cls, temperature: float) -> float: + """Return the charging power.""" return cls._get_power(temperature, cls.CHARGING_POWER_MAP) @classmethod def get_power_discharging(cls, temperature: float) -> float: + """Return the discharging power.""" return cls._get_power(temperature, cls.DISCHARGING_POWER_MAP) @classmethod @@ -76,17 +85,19 @@ def _extrapolate(temperature: float, temps: np.ndarray, powers: np.ndarray) -> f # Use the first two points for left extrapolation slope = (powers[1] - powers[0]) / (temps[1] - temps[0]) return powers[0] + slope * (temperature - temps[0]) - else: - # Use the last two points for right extrapolation - slope = (powers[-1] - powers[-2]) / (temps[-1] - temps[-2]) - return powers[-1] + slope * (temperature - temps[-1]) + # Use the last two points for right extrapolation + slope = (powers[-1] - powers[-2]) / (temps[-1] - temps[-2]) + return powers[-1] + slope * (temperature - temps[-1]) class SorTesTankMinimiseSwitchStrategy(MinimiseHeatpumpSwitchStrategy): - MINUTES_BEFORE_SWITCH_ALLOWED = constants.SorTesConfiguration.MINUTES_BEFORE_SWITCH_ALLOWED - MIN_SOC_TOLERANCE = constants.SorTesConfiguration.MIN_SOC_TOLERANCE - MAX_SOC_TOLERANCE = constants.SorTesConfiguration.MAX_SOC_TOLERANCE + """Minimise number of switches between charging and discharging of the SorTES tank""" + + MINUTES_BEFORE_SWITCH_ALLOWED = SorTesConfiguration.MINUTES_BEFORE_SWITCH_ALLOWED + MIN_SOC_TOLERANCE = SorTesConfiguration.MIN_SOC_TOLERANCE + MAX_SOC_TOLERANCE = SorTesConfiguration.MAX_SOC_TOLERANCE + # pylint: disable=super-init-not-called def __init__( self, energy_params: "SorTesTankEnergyParameters", @@ -101,6 +112,7 @@ def __init__( @property def current_state(self) -> HeatPumpChargingState: + """Return the current charging state of the tank.""" return self._current_state def _get_tank_soc(self, time_slot: DateTime): @@ -113,13 +125,18 @@ def _is_energy_affordable(self, time_slot: DateTime, _buy_rate: float) -> bool: ) def event_activate(self): + """Perform commands on event activate.""" self._average_trade_rate.read_or_rotate_profiles() def event_market_slot(self): + """Perform commands on event market cycle.""" self._average_trade_rate.read_or_rotate_profiles() -class SorTesTankState: +class SorTesTankState(HeatPumpStateBase): + """State class of Sortes tank state.""" + + # pylint: disable=too-many-instance-attributes, super-init-not-called def __init__(self): self._soc: dict[DateTime, float] = {} @@ -129,30 +146,24 @@ def __init__(self): self._min_energy_demand_kWh: dict[DateTime, float] = {} self._max_energy_demand_kWh: dict[DateTime, float] = {} self._total_traded_energy_kWh: float = 0 # for KPI calculation + self._total_charged_energy_kWh: float = 0 - def activate(self): - self._soc[GlobalConfig.start_date] = constants.SorTesConfiguration.MIN_SOC_TOLERANCE - - def set_heat_demand_kJ(self, time_slot: DateTime, heat_demand_kJ: float): - """Set heat demand for the given time slot.""" - self._heat_demand_kJ[time_slot] = heat_demand_kJ + def update_total_charged_energy_kWh(self, charged_energy_kWh: float): + """Update the total charged energy.""" + self._total_charged_energy_kWh += charged_energy_kWh - def get_heat_demand_kJ(self, time_slot: DateTime) -> float: - """Return the heat demand in J for a given time slot.""" - return self._heat_demand_kJ.get(time_slot, 0) + def activate(self): + """Perform commands on event activate.""" + self._soc[GlobalConfig.start_date] = SorTesConfiguration.MIN_SOC_TOLERANCE def get_soc(self, time_slot: DateTime) -> float: + """Return the soc value for the given time slot.""" return self._soc.get(time_slot, 0) def set_soc(self, time_slot: DateTime, soc: float): + """Set soc value for the given time slot.""" self._soc[time_slot] = soc - def get_cop(self, time_slot: DateTime) -> float: - return self._cop.get(time_slot, 0) - - def set_cop(self, time_slot: DateTime, cop: float): - self._cop[time_slot] = cop - def get_min_energy_demand_kWh(self, time_slot: DateTime) -> float: """Return the minimal energy demanded for a given time slot.""" return self._min_energy_demand_kWh.get(time_slot, 0) @@ -161,10 +172,6 @@ def get_max_energy_demand_kWh(self, time_slot: DateTime) -> float: """Return the maximal energy demanded for a given time slot.""" return self._max_energy_demand_kWh.get(time_slot, 0) - def get_energy_demand_kWh(self, time_slot: DateTime) -> float: - """Return the energy demanded for a given time slot.""" - return self._energy_demand_kWh.get(time_slot, 0) - 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 @@ -173,24 +180,24 @@ def set_max_energy_demand_kWh(self, time_slot: DateTime, energy_kWh: float): """Set the maximal energy demanded for a given time slot.""" self._max_energy_demand_kWh[time_slot] = energy_kWh - def set_energy_demand_kWh(self, time_slot: DateTime, energy_kWh: float): - """Set the minimal energy demanded for a given time slot.""" - self._energy_demand_kWh[time_slot] = energy_kWh - def delete_past_state_values(self, current_time_slot: Optional[DateTime] = None): - if not current_time_slot or constants.RETAIN_PAST_MARKET_STRATEGIES_STATE: + """Delete past state values.""" + if not current_time_slot or RETAIN_PAST_MARKET_STRATEGIES_STATE: return last_time_slot = self._last_time_slot(current_time_slot) self._delete_time_slots(self._cop, last_time_slot) + self._delete_time_slots(self._soc, last_time_slot) self._delete_time_slots(self._energy_demand_kWh, last_time_slot) self._delete_time_slots(self._min_energy_demand_kWh, last_time_slot) self._delete_time_slots(self._max_energy_demand_kWh, last_time_slot) + self._delete_time_slots(self._heat_demand_kJ, last_time_slot) def increase_total_traded_energy_kWh(self, energy_kWh: float): """Add to the total traded energy of the heatpump for a given time slot.""" self._total_traded_energy_kWh += energy_kWh def get_state(self) -> dict: + """Return the state.""" return { "soc": convert_pendulum_to_str_in_dict(self._soc), "cop": convert_pendulum_to_str_in_dict(self._cop), @@ -198,9 +205,11 @@ def get_state(self) -> dict: "min_energy_demand_kWh": convert_pendulum_to_str_in_dict(self._min_energy_demand_kWh), "max_energy_demand_kWh": convert_pendulum_to_str_in_dict(self._max_energy_demand_kWh), "total_traded_energy_kWh": self._total_traded_energy_kWh, + "total_charge_energy_kWh": self._total_charged_energy_kWh, } def restore_state(self, state_dict: dict): + """Restore the state.""" self._soc = convert_str_to_pendulum_in_dict(state_dict["soc"]) self._cop = convert_str_to_pendulum_in_dict(state_dict["cop"]) self._energy_demand_kWh = convert_str_to_pendulum_in_dict(state_dict["energy_demand_kWh"]) @@ -211,26 +220,23 @@ def restore_state(self, state_dict: dict): state_dict["max_energy_demand_kWh"] ) self._total_traded_energy_kWh = state_dict["total_traded_energy_kWh"] + self._total_charged_energy_kWh = state_dict["total_charge_energy_kWh"] - def get_results_dict(self, time_slot: DateTime) -> dict: + def get_results_dict(self, current_time_slot: DateTime) -> dict: + """Return the results of the given time slot.""" return { - "cop": self.get_cop(time_slot), - "energy_demand_kWh": self.get_energy_demand_kWh(time_slot), + "cop": self.get_cop(current_time_slot), "total_traded_energy_kWh": self._total_traded_energy_kWh, - "heat_demand_kJ": self.get_heat_demand_kJ(time_slot), - "soc": self.get_soc(time_slot), + "heat_demand_kJ": self.get_heat_demand_kJ(current_time_slot), + "soc": self.get_soc(current_time_slot), + "total_charge_energy_kWh": self._total_charged_energy_kWh, } - @staticmethod - def _delete_time_slots(profile: dict, current_time_stamp: DateTime): - delete_time_slots_in_state(profile, current_time_stamp) - - @staticmethod - def _last_time_slot(current_market_slot: DateTime) -> DateTime: - return current_market_slot - GlobalConfig.slot_length - class SorTesTankEnergyParameters: + """Energy Parameters for the SorTes Tank heat pump""" + + # pylint: disable=too-many-instance-attributes def __init__( self, @@ -240,6 +246,7 @@ def __init__( average_trade_rate: Union[str, float, dict], source_type: HeatPumpSourceType = ConstSettings.HeatPumpSettings.SOURCE_TYPE, ): + # pylint: disable=too-many-arguments, too-many-positional-arguments self._state = SorTesTankState() self._heat_demand_Q_J: StrategyProfileBase = profile_factory( @@ -251,7 +258,7 @@ def __init__( self._target_temp_C: StrategyProfileBase = profile_factory( target_temp_C_profile, None, profile_type=InputProfileTypes.IDENTITY ) - self._capacity_kWh: float = constants.SorTesConfiguration.CAPACITY_KWH + self._capacity_kWh: float = SorTesConfiguration.CAPACITY_KWH self._cop_model = cop_model_factory(COPModelType.UNIVERSAL, source_type) self._bought_energy_kWh = 0.0 @@ -259,19 +266,23 @@ def __init__( @property def state(self) -> SorTesTankState: + """Return the state.""" return self._state @property def soc_management(self) -> SorTesTankMinimiseSwitchStrategy: + """Return the soc management.""" return self._soc_management def event_market_cycle(self, current_time_slot: DateTime): + """Runs on market_cycle event.""" # Order matters here self._soc_management.event_market_slot() self._rotate_profiles(current_time_slot) self._populate_state(current_time_slot) def event_activate(self): + """Runs on activate event.""" self._soc_management.event_activate() self._rotate_profiles() self._state.activate() @@ -282,15 +293,19 @@ def event_traded_energy(self, time_slot: DateTime, energy_kWh: float): self._decrement_posted_energy(time_slot, energy_kWh) def get_soc(self, time_slot: DateTime): + """Return the soc of the SorTes tank""" return self._state.get_soc(time_slot) def get_energy_demand_kWh(self, time_slot: DateTime): + """Return the energy_demand kWh.""" return self._state.get_energy_demand_kWh(time_slot) def get_min_energy_demand_kWh(self, time_slot: DateTime): + """Return the min_energy_demand kWh.""" return self._state.get_min_energy_demand_kWh(time_slot) def get_max_energy_demand_kWh(self, time_slot: DateTime): + """Return the max_energy_demand kWh.""" return self._state.get_max_energy_demand_kWh(time_slot) def _populate_state(self, time_slot: DateTime): @@ -337,44 +352,44 @@ def _calc_and_set_energy_demand(self, time_slot: DateTime): def _get_performance_energy_charge_kWh(self, time_slot: DateTime) -> float: charge_power_kW = SorTesPerformanceMaps.get_power_charging( - self._ambient_temp_C.get_value(time_slot) + 5 + self._ambient_temp_C.get_value(time_slot) + 5 # todo: temp addition TDB ) return convert_kW_to_kWh(charge_power_kW, GlobalConfig.slot_length) def _get_performance_energy_discharge_kWh(self, time_slot: DateTime) -> float: discharge_power_kW = SorTesPerformanceMaps.get_power_discharging( - self._ambient_temp_C.get_value(time_slot) + 5 + self._ambient_temp_C.get_value(time_slot) + 5 # todo: temp addition TDB ) return convert_kW_to_kWh(discharge_power_kW, GlobalConfig.slot_length) def _calc_condenser_electricity_kWh(self, charging_energy_kWh: float) -> float: return ( charging_energy_kWh - / constants.SorTesConfiguration.COP_CONDENSER - * constants.SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER + / SorTesConfiguration.COP_CONDENSER + * SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER ) def _calc_evaporator_electricity_kWh(self, discharging_energy_kWh: float) -> float: return ( discharging_energy_kWh - / constants.SorTesConfiguration.COP_EVAPORATOR - * constants.SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER + / SorTesConfiguration.COP_EVAPORATOR + * SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER ) def _get_total_electricity_demand_for_time_slot_kWh(self, time_slot: DateTime) -> float: + # we need this function in order to also access the demand after trading return convert_kJ_to_kWh(self._state.get_heat_demand_kJ(time_slot)) / self._state.get_cop( time_slot ) def _calc_heat_capacity_into_electricity_kWh(self, heat_capacity: float) -> float: - return heat_capacity / constants.SorTesConfiguration.COP_HEAT_SOURCE + return heat_capacity / SorTesConfiguration.COP_HEAT_SOURCE def _calc_available_free_storage_kWh(self, time_slot: DateTime) -> float: charge_energy_kWh = self._get_performance_energy_charge_kWh(time_slot) - # add the charge energy to the capacity be able to charge above the maximum + # add the charge energy to the capacity be able to reach the maximum SOC tolerance available_heat_storage_kWh = ( - (constants.SorTesConfiguration.MAX_SOC_TOLERANCE - self._state.get_soc(time_slot)) - / 100 + (SorTesConfiguration.MAX_SOC_TOLERANCE - self._state.get_soc(time_slot)) / 100 ) * self._capacity_kWh + charge_energy_kWh if available_heat_storage_kWh < charge_energy_kWh: @@ -383,10 +398,9 @@ def _calc_available_free_storage_kWh(self, time_slot: DateTime) -> float: def _calc_available_stored_heat_kWh(self, time_slot: DateTime) -> float: discharge_energy_kWh = self._get_performance_energy_discharge_kWh(time_slot) - # add the discharge energy to the capacity be able to discharge below the minimum + # add the discharge energy to the capacity be able to reach the minimum SOC tolerance stored_heat_kWh = ( - (self._state.get_soc(time_slot) - constants.SorTesConfiguration.MIN_SOC_TOLERANCE) - / 100 + (self._state.get_soc(time_slot) - SorTesConfiguration.MIN_SOC_TOLERANCE) / 100 ) * self._capacity_kWh + discharge_energy_kWh if stored_heat_kWh < discharge_energy_kWh: @@ -403,13 +417,18 @@ def _calc_energy_to_buy_maximum(self, time_slot: DateTime) -> float: def _calc_energy_to_buy_minimum(self, time_slot: DateTime) -> float: available_stored_heat_kWh = self._calc_available_stored_heat_kWh(time_slot) - energy_to_be_bought_for_heat = min( - 0, - abs( - self._get_total_electricity_demand_for_time_slot_kWh(time_slot) - - self._calc_heat_capacity_into_electricity_kWh(available_stored_heat_kWh) - ), - ) + energy_to_be_bought_for_heat = self._get_total_electricity_demand_for_time_slot_kWh( + time_slot + ) - self._calc_heat_capacity_into_electricity_kWh(available_stored_heat_kWh) + + if energy_to_be_bought_for_heat < FLOATING_POINT_TOLERANCE: + # corner case when the demand is lower than the discharging energy + log.warning( + "The heat demand is lower than the discharging energy: %s, %s", + self._get_total_electricity_demand_for_time_slot_kWh(time_slot), + self._calc_heat_capacity_into_electricity_kWh(available_stored_heat_kWh), + ) + energy_to_be_bought_for_heat = 0 return energy_to_be_bought_for_heat + self._calc_evaporator_electricity_kWh( available_stored_heat_kWh @@ -431,37 +450,39 @@ def _charge_or_discharge_tank(self, time_slot: DateTime): and net_traded_energy_kWh > FLOATING_POINT_TOLERANCE ): self._charge(net_traded_energy_kWh, time_slot) - elif ( - self.soc_management.current_state == HeatPumpChargingState.DISCHARGE - and net_traded_energy_kWh > FLOATING_POINT_TOLERANCE - ): - self._discharge(self._bought_energy_kWh, time_slot) - else: + elif self.soc_management.current_state == HeatPumpChargingState.DISCHARGE: + self._discharge(net_traded_energy_kWh, time_slot) + elif self.soc_management.current_state == HeatPumpChargingState.MAINTAIN_SOC: self._no_charge(time_slot) + else: + assert False, "should never reach this point" - def _charge(self, energy_kWh: float, time_slot: DateTime): - charge_energy = self._get_performance_energy_charge_kWh(self.last_time_slot(time_slot)) + def _charge(self, net_traded_energy_kWh: float, time_slot: DateTime): + charge_energy_kWh = self._get_performance_energy_charge_kWh(self.last_time_slot(time_slot)) condenser_energy_kWh = ( - charge_energy * constants.SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER + charge_energy_kWh * SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER ) - heat_energy_kWh = energy_kWh * constants.SorTesConfiguration.COP_HEAT_SOURCE + heat_energy_kWh = net_traded_energy_kWh * SorTesConfiguration.COP_HEAT_SOURCE assert ( - abs(condenser_energy_kWh + charge_energy - heat_energy_kWh) < FLOATING_POINT_TOLERANCE + abs(condenser_energy_kWh + charge_energy_kWh - heat_energy_kWh) + < FLOATING_POINT_TOLERANCE ) - self._update_soc(time_slot, charge_energy) + self._update_soc(time_slot, charge_energy_kWh) + self._state.update_total_charged_energy_kWh(charge_energy_kWh) - def _discharge(self, energy_kWh: float, time_slot: DateTime): + def _discharge(self, net_traded_energy_kWh: float, time_slot: DateTime): + assert net_traded_energy_kWh < FLOATING_POINT_TOLERANCE discharge_energy_kWh = self._get_performance_energy_discharge_kWh( self.last_time_slot(time_slot) ) evaporator_energy_kWh = ( - discharge_energy_kWh - * constants.SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER + discharge_energy_kWh * SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER ) - assert energy_kWh == evaporator_energy_kWh + assert (net_traded_energy_kWh - evaporator_energy_kWh) < FLOATING_POINT_TOLERANCE self._update_soc(time_slot, -discharge_energy_kWh) + self._state.update_total_charged_energy_kWh(-discharge_energy_kWh) def _update_soc(self, time_slot: DateTime, heat_energy_kWh: float): old_charge = self._state.get_soc(self.last_time_slot(time_slot)) / 100 * self._capacity_kWh @@ -487,6 +508,7 @@ def last_time_slot(current_market_slot: DateTime) -> DateTime: class HeatPumpWithSorTesTankStrategy(HeatPumpStrategyBase): + """Strategy class for a heat pump that is connected to a SorTES tank""" def __init__( self, @@ -499,6 +521,7 @@ def __init__( AvailableMarketTypes, HeatPumpOrderUpdaterParameters ] = None, ): + # pylint: disable=too-many-arguments, too-many-positional-arguments, super-init-not-called self._init_price_params(order_updater_parameters) @@ -522,6 +545,10 @@ def post_order( ) self._post_order(market, market_slot, order_energy_kWh, order_rate) + @property + def state(self) -> SorTesTankState: + return self._energy_params.state + def _init_price_params(self, order_updater_parameters): if not order_updater_parameters: order_updater_parameters = { @@ -529,7 +556,3 @@ def _init_price_params(self, order_updater_parameters): } super().__init__(order_updater_parameters=order_updater_parameters) - - @property - def state(self) -> SorTesTankState: - return self._energy_params.state diff --git a/src/gsy_e/setup/strategy_tests/heat_pump_sortes.py b/src/gsy_e/setup/strategy_tests/heat_pump_sortes.py index 2f6fe1ef3..0b84b61c1 100644 --- a/src/gsy_e/setup/strategy_tests/heat_pump_sortes.py +++ b/src/gsy_e/setup/strategy_tests/heat_pump_sortes.py @@ -29,6 +29,8 @@ ConstSettings.MASettings.MARKET_TYPE = 2 ConstSettings.GeneralSettings.DEFAULT_UPDATE_INTERVAL = 5 +# Attention: Set the start date to 2026-05-12 for the average trading rate profile! + def get_setup(config): area = Area( @@ -40,9 +42,9 @@ def get_setup(config): Area( "Sortes", strategy=HeatPumpWithSorTesTankStrategy( - heat_demand_Q_profile=4.8 / 4 * 3600 * 1000, + heat_demand_Q_profile=20 * 3600 * 1000, ambient_temp_C_profile=10, - target_temp_C_profile=35, + target_temp_C_profile=30, average_trade_rate=os.path.join( gsye_root_path, "resources", "average_trading_profile_sortes.csv" ), From c58b317b5759664d84b95578045a361f5b95dfd2 Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Wed, 13 May 2026 14:36:33 +0200 Subject: [PATCH 04/17] GSYE-891: Add unit tests from AI --- .../strategy/heatpump_with_sortes_tank.py | 4 +- tests/strategies/test_sortes_heat_pump.py | 594 ++++++++++++++++++ 2 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 tests/strategies/test_sortes_heat_pump.py diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index c5ca24957..e5785fb01 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -14,7 +14,7 @@ ) from pendulum import DateTime -from gsy_e.constants import SorTesConfiguration, RETAIN_PAST_MARKET_STRATEGIES_STATE +from gsy_e.constants import SorTesConfiguration, RETAIN_PAST_MARKET_STRATEGIES_STATE, DEFAULT_COP from gsy_e.models.strategy.energy_parameters.heatpump.cop_models import ( COPModelType, cop_model_factory, @@ -155,6 +155,7 @@ def update_total_charged_energy_kWh(self, charged_energy_kWh: float): def activate(self): """Perform commands on event activate.""" self._soc[GlobalConfig.start_date] = SorTesConfiguration.MIN_SOC_TOLERANCE + self._cop[GlobalConfig.start_date] = DEFAULT_COP def get_soc(self, time_slot: DateTime) -> float: """Return the soc value for the given time slot.""" @@ -438,6 +439,7 @@ def _rotate_profiles(self, time_slot: Optional[DateTime] = None): self._state.delete_past_state_values(time_slot) self._heat_demand_Q_J.read_or_rotate_profiles() self._ambient_temp_C.read_or_rotate_profiles() + self._target_temp_C.read_or_rotate_profiles() def _charge_or_discharge_tank(self, time_slot: DateTime): electricity_demand_kWh = self._get_total_electricity_demand_for_time_slot_kWh( diff --git a/tests/strategies/test_sortes_heat_pump.py b/tests/strategies/test_sortes_heat_pump.py new file mode 100644 index 000000000..714d4f74e --- /dev/null +++ b/tests/strategies/test_sortes_heat_pump.py @@ -0,0 +1,594 @@ +# pylint: disable=protected-access, attribute-defined-outside-init, too-many-public-methods +import math +from unittest.mock import MagicMock, patch + +from gsy_framework.constants_limits import ConstSettings, GlobalConfig +from gsy_framework.enums import AvailableMarketTypes +from pendulum import UTC, duration, today + +from gsy_e.constants import SorTesConfiguration +from gsy_e.models.area import Area +from gsy_e.models.strategy.heat_pump import HeatPumpOrderUpdaterParameters +from gsy_e.models.strategy.heat_pump_soc_management import HeatPumpChargingState +from gsy_e.models.strategy.heatpump_with_sortes_tank import ( + HeatPumpWithSorTesTankStrategy, + SorTesPerformanceMaps, + SorTesTankEnergyParameters, + SorTesTankMinimiseSwitchStrategy, + SorTesTankState, +) +from gsy_e.models.strategy.strategy_profile import global_objects + +START_TIME_SLOT = today(tz=UTC) +SLOT_LENGTH = duration(minutes=15) +NEXT_SLOT = START_TIME_SLOT + SLOT_LENGTH + + +class TestSorTesPerformanceMaps: + + def test_get_power_charging_at_exact_table_temperatures(self): + assert math.isclose(SorTesPerformanceMaps.get_power_charging(5), 3.8) + assert math.isclose(SorTesPerformanceMaps.get_power_charging(20), 3.0) + assert math.isclose(SorTesPerformanceMaps.get_power_charging(45), 0.5) + + def test_get_power_charging_interpolates_between_table_temperatures(self): + # Between 10 (3.5 kW) and 15 (3.3 kW), midpoint at 12.5 → 3.4 + power = SorTesPerformanceMaps.get_power_charging(12.5) + assert math.isclose(power, 3.4, abs_tol=1e-9) + + def test_get_power_charging_extrapolates_below_range(self): + # Slope from (5, 3.8) to (10, 3.5): -0.06/°C; at 0°C: 3.8 + (-0.06) * (0-5) = 4.1 + power = SorTesPerformanceMaps.get_power_charging(0) + assert math.isclose(power, 4.1, abs_tol=1e-9) + + def test_get_power_charging_extrapolates_above_range(self): + # Slope from (40, 1.5) to (45, 0.5): -0.2/°C; at 50°C: 0.5 + (-0.2) * (50-45) = -0.5 + power = SorTesPerformanceMaps.get_power_charging(50) + assert math.isclose(power, -0.5, abs_tol=1e-9) + + def test_get_power_discharging_at_exact_table_temperatures(self): + assert math.isclose(SorTesPerformanceMaps.get_power_discharging(5), 3.0) + assert math.isclose(SorTesPerformanceMaps.get_power_discharging(15), 4.0) + assert math.isclose(SorTesPerformanceMaps.get_power_discharging(25), 4.8) + + def test_get_power_discharging_interpolates_between_table_temperatures(self): + # Between 5 (3.0 kW) and 10 (3.5 kW), midpoint at 7.5 → 3.25 + power = SorTesPerformanceMaps.get_power_discharging(7.5) + assert math.isclose(power, 3.25, abs_tol=1e-9) + + def test_get_power_discharging_extrapolates_below_range(self): + # Slope from (5, 3.0) to (10, 3.5): 0.1/°C; at 0°C: 3.0 + 0.1 * (0-5) = 2.5 + power = SorTesPerformanceMaps.get_power_discharging(0) + assert math.isclose(power, 2.5, abs_tol=1e-9) + + def test_get_power_discharging_extrapolates_above_range(self): + # Slope from (20, 4.3) to (25, 4.8): 0.1/°C; at 30°C: 4.8 + 0.1 * (30-25) = 5.3 + power = SorTesPerformanceMaps.get_power_discharging(30) + assert math.isclose(power, 5.3, abs_tol=1e-9) + + def test_charging_power_decreases_with_temperature(self): + powers = [SorTesPerformanceMaps.get_power_charging(t) for t in range(5, 46, 5)] + assert all(powers[i] > powers[i + 1] for i in range(len(powers) - 1)) + + def test_discharging_power_increases_with_temperature(self): + powers = [SorTesPerformanceMaps.get_power_discharging(t) for t in range(5, 26, 5)] + assert all(powers[i] < powers[i + 1] for i in range(len(powers) - 1)) + + +class TestSorTesTankMinimiseSwitchStrategy: + + def setup_method(self): + self._original_market_maker_rate = GlobalConfig.market_maker_rate + self._original_slot_length = GlobalConfig.slot_length + self._original_start_date = GlobalConfig.start_date + GlobalConfig.slot_length = SLOT_LENGTH + GlobalConfig.market_maker_rate = { + START_TIME_SLOT: 30, + NEXT_SLOT: 20, + } + GlobalConfig.start_date = START_TIME_SLOT + self._energy_params_mock = MagicMock() + self._energy_params_mock.get_soc = MagicMock(return_value=50.0) + + def teardown_method(self): + GlobalConfig.market_maker_rate = self._original_market_maker_rate + GlobalConfig.slot_length = self._original_slot_length + GlobalConfig.start_date = self._original_start_date + + def _create_strategy(self, average_trade_rate=25.0): + strategy = SorTesTankMinimiseSwitchStrategy(self._energy_params_mock, average_trade_rate) + # print(strategy._average_trade_rate.profile) + return strategy + + def test_initial_state_is_maintain_soc(self): + strategy = self._create_strategy() + assert strategy.current_state == HeatPumpChargingState.MAINTAIN_SOC + + def test_current_state_property_returns_current_state(self): + strategy = self._create_strategy() + strategy._current_state = HeatPumpChargingState.CHARGE + assert strategy.current_state == HeatPumpChargingState.CHARGE + + def test_get_tank_soc_delegates_to_energy_params(self): + strategy = self._create_strategy() + self._energy_params_mock.get_soc = MagicMock(return_value=42.0) + soc = strategy._get_tank_soc(START_TIME_SLOT) + assert math.isclose(soc, 42.0) + self._energy_params_mock.get_soc.assert_called_once_with(START_TIME_SLOT) + + def test_is_energy_affordable_true_when_avg_rate_below_market_maker_rate(self): + # average_trade_rate=25, market_maker_rate[START_TIME_SLOT]=30 → 25 < 30 → True + strategy = self._create_strategy(average_trade_rate=25.0) + strategy.event_activate() + assert strategy._is_energy_affordable(START_TIME_SLOT, 0.0) is True + + def test_is_energy_affordable_false_when_avg_rate_above_market_maker_rate(self): + # average_trade_rate=35, market_maker_rate[START_TIME_SLOT]=30 → 35 < 30 → False + strategy = self._create_strategy(average_trade_rate=35.0) + strategy.event_activate() + assert strategy._is_energy_affordable(START_TIME_SLOT, 0.0) is False + + def test_event_activate_calls_read_or_rotate_profiles(self): + strategy = self._create_strategy() + with patch.object(strategy._average_trade_rate, "read_or_rotate_profiles") as mock_rotate: + strategy.event_activate() + mock_rotate.assert_called_once() + + def test_event_market_slot_calls_read_or_rotate_profiles(self): + strategy = self._create_strategy() + strategy.event_activate() + with patch.object(strategy._average_trade_rate, "read_or_rotate_profiles") as mock_rotate: + strategy.event_market_slot() + mock_rotate.assert_called_once() + + def test_minutes_before_switch_taken_from_sortes_configuration(self): + assert ( + SorTesTankMinimiseSwitchStrategy.MINUTES_BEFORE_SWITCH_ALLOWED + == SorTesConfiguration.MINUTES_BEFORE_SWITCH_ALLOWED + ) + + def test_soc_tolerances_taken_from_sortes_configuration(self): + assert ( + SorTesTankMinimiseSwitchStrategy.MIN_SOC_TOLERANCE + == SorTesConfiguration.MIN_SOC_TOLERANCE + ) + assert ( + SorTesTankMinimiseSwitchStrategy.MAX_SOC_TOLERANCE + == SorTesConfiguration.MAX_SOC_TOLERANCE + ) + + +class TestSorTesTankState: + + def setup_method(self): + self._original_start_date = GlobalConfig.start_date + self._original_slot_length = GlobalConfig.slot_length + GlobalConfig.slot_length = SLOT_LENGTH + GlobalConfig.start_date = START_TIME_SLOT + self._state = SorTesTankState() + + def teardown_method(self): + GlobalConfig.start_date = self._original_start_date + GlobalConfig.slot_length = self._original_slot_length + + def test_activate_sets_initial_soc_to_min_soc_tolerance(self): + self._state.activate() + assert math.isclose( + self._state.get_soc(START_TIME_SLOT), SorTesConfiguration.MIN_SOC_TOLERANCE + ) + + def test_get_soc_returns_zero_for_unknown_time_slot(self): + assert self._state.get_soc(START_TIME_SLOT) == 0.0 + + def test_set_and_get_soc_round_trips(self): + self._state.set_soc(START_TIME_SLOT, 55.0) + assert math.isclose(self._state.get_soc(START_TIME_SLOT), 55.0) + + def test_set_and_get_min_energy_demand_kWh(self): + self._state.set_min_energy_demand_kWh(START_TIME_SLOT, 1.5) + assert math.isclose(self._state.get_min_energy_demand_kWh(START_TIME_SLOT), 1.5) + + def test_get_min_energy_demand_kWh_returns_zero_for_unknown_time_slot(self): + assert self._state.get_min_energy_demand_kWh(START_TIME_SLOT) == 0.0 + + def test_set_and_get_max_energy_demand_kWh(self): + self._state.set_max_energy_demand_kWh(START_TIME_SLOT, 3.0) + assert math.isclose(self._state.get_max_energy_demand_kWh(START_TIME_SLOT), 3.0) + + def test_get_max_energy_demand_kWh_returns_zero_for_unknown_time_slot(self): + assert self._state.get_max_energy_demand_kWh(START_TIME_SLOT) == 0.0 + + def test_update_total_charged_energy_kWh_accumulates_over_calls(self): + self._state.update_total_charged_energy_kWh(2.0) + self._state.update_total_charged_energy_kWh(3.0) + assert math.isclose(self._state.get_state()["total_charge_energy_kWh"], 5.0) + + def test_increase_total_traded_energy_kWh_accumulates_over_calls(self): + self._state.increase_total_traded_energy_kWh(4.0) + self._state.increase_total_traded_energy_kWh(1.0) + assert math.isclose(self._state.get_state()["total_traded_energy_kWh"], 5.0) + + def test_delete_past_state_values_removes_slots_older_than_current(self): + self._state.set_soc(START_TIME_SLOT, 50.0) + self._state.set_soc(NEXT_SLOT, 60.0) + # current_time_slot is two slots ahead; START_TIME_SLOT should be removed + self._state.delete_past_state_values(NEXT_SLOT + SLOT_LENGTH) + assert self._state.get_soc(START_TIME_SLOT) == 0.0 + assert math.isclose(self._state.get_soc(NEXT_SLOT), 60.0) + + def test_delete_past_state_values_is_noop_when_no_time_slot_given(self): + self._state.set_soc(START_TIME_SLOT, 50.0) + self._state.delete_past_state_values(None) + assert math.isclose(self._state.get_soc(START_TIME_SLOT), 50.0) + + def test_get_state_contains_all_required_keys(self): + state = self._state.get_state() + for key in ( + "soc", + "cop", + "energy_demand_kWh", + "min_energy_demand_kWh", + "max_energy_demand_kWh", + "total_traded_energy_kWh", + "total_charge_energy_kWh", + ): + assert key in state + + def test_restore_state_round_trips_all_numeric_fields(self): + self._state.set_soc(START_TIME_SLOT, 70.0) + self._state.set_cop(START_TIME_SLOT, 4.0) + self._state.set_energy_demand_kWh(START_TIME_SLOT, 1.5) + self._state.set_min_energy_demand_kWh(START_TIME_SLOT, 0.5) + self._state.set_max_energy_demand_kWh(START_TIME_SLOT, 2.0) + self._state.increase_total_traded_energy_kWh(3.0) + self._state.update_total_charged_energy_kWh(2.0) + + state_dict = self._state.get_state() + restored = SorTesTankState() + restored.restore_state(state_dict) + + assert math.isclose(restored.get_soc(START_TIME_SLOT), 70.0) + assert math.isclose(restored.get_cop(START_TIME_SLOT), 4.0) + assert math.isclose(restored.get_energy_demand_kWh(START_TIME_SLOT), 1.5) + assert math.isclose(restored.get_min_energy_demand_kWh(START_TIME_SLOT), 0.5) + assert math.isclose(restored.get_max_energy_demand_kWh(START_TIME_SLOT), 2.0) + restored_dict = restored.get_state() + assert math.isclose(restored_dict["total_traded_energy_kWh"], 3.0) + assert math.isclose(restored_dict["total_charge_energy_kWh"], 2.0) + + def test_get_results_dict_returns_correct_values(self): + self._state.set_cop(START_TIME_SLOT, 3.2) + self._state.set_soc(START_TIME_SLOT, 55.0) + self._state.set_heat_demand_kJ(START_TIME_SLOT, 100.0) + result = self._state.get_results_dict(START_TIME_SLOT) + assert math.isclose(result["cop"], 3.2) + assert math.isclose(result["soc"], 55.0) + assert math.isclose(result["heat_demand_kJ"], 100.0) + assert "total_traded_energy_kWh" in result + assert "total_charge_energy_kWh" in result + + def test_delete_past_state_values_clears_all_time_series_attributes(self): + self._state.set_soc(START_TIME_SLOT, 50.0) + self._state.set_cop(START_TIME_SLOT, 3.0) + self._state.set_heat_demand_kJ(START_TIME_SLOT, 100.0) + self._state.set_energy_demand_kWh(START_TIME_SLOT, 2.0) + self._state.set_min_energy_demand_kWh(START_TIME_SLOT, 1.0) + self._state.set_max_energy_demand_kWh(START_TIME_SLOT, 3.0) + self._state.delete_past_state_values(NEXT_SLOT + SLOT_LENGTH) + assert self._state.get_soc(START_TIME_SLOT) == 0.0 + assert self._state.get_cop(START_TIME_SLOT) == 0.0 + assert self._state.get_heat_demand_kJ(START_TIME_SLOT) == 0.0 + assert self._state.get_energy_demand_kWh(START_TIME_SLOT) == 0.0 + assert self._state.get_min_energy_demand_kWh(START_TIME_SLOT) == 0.0 + assert self._state.get_max_energy_demand_kWh(START_TIME_SLOT) == 0.0 + + +AMBIENT_TEMP_C = 15.0 +TARGET_TEMP_C = 50.0 +# delta = 35 → COP = 6.08 - 0.09*35 + 0.0005*35^2 = 6.08 - 3.15 + 0.6125 = 3.5425 +EXPECTED_COP = 6.08 - 0.09 * 35 + 0.0005 * 35**2 + +# SorTesPerformanceMaps.get_power_charging(AMBIENT_TEMP_C + 5) = get_power_charging(20) = 3.0 kW +# With 15-min slot: 3.0 kW * 0.25 h = 0.75 kWh +EXPECTED_CHARGE_ENERGY_KWH = 3.0 * 0.25 + +# SorTesPerformanceMaps.get_power_discharging(AMBIENT_TEMP_C + 5) +# = get_power_discharging(20) = 4.3 kW +# With 15-min slot: 4.3 kW * 0.25 h = 1.075 kWh +EXPECTED_DISCHARGE_ENERGY_KWH = 4.3 * 0.25 + + +class TestSorTesTankEnergyParameters: + + def setup_method(self): + self._original_start_date = GlobalConfig.start_date + self._original_slot_length = GlobalConfig.slot_length + self._original_market_maker_rate = GlobalConfig.market_maker_rate + self._original_timestamp = global_objects.profiles_handler.current_timestamp + GlobalConfig.slot_length = SLOT_LENGTH + GlobalConfig.start_date = START_TIME_SLOT + GlobalConfig.market_maker_rate = { + START_TIME_SLOT: 30, + NEXT_SLOT: 30, + } + global_objects.profiles_handler._update_current_time(timestamp=START_TIME_SLOT) + + def teardown_method(self): + GlobalConfig.start_date = self._original_start_date + GlobalConfig.slot_length = self._original_slot_length + GlobalConfig.market_maker_rate = self._original_market_maker_rate + global_objects.profiles_handler._update_current_time(timestamp=self._original_timestamp) + + def _create_energy_params( + self, + heat_demand_Q_profile=None, + ambient_temp_C_profile=None, + target_temp_C_profile=None, + average_trade_rate=25.0, + ): + if heat_demand_Q_profile is None: + heat_demand_Q_profile = {START_TIME_SLOT: 3600.0, NEXT_SLOT: 3600.0} + if ambient_temp_C_profile is None: + ambient_temp_C_profile = {START_TIME_SLOT: AMBIENT_TEMP_C, NEXT_SLOT: AMBIENT_TEMP_C} + if target_temp_C_profile is None: + target_temp_C_profile = {START_TIME_SLOT: TARGET_TEMP_C, NEXT_SLOT: TARGET_TEMP_C} + return SorTesTankEnergyParameters( + heat_demand_Q_profile=heat_demand_Q_profile, + ambient_temp_C_profile=ambient_temp_C_profile, + target_temp_C_profile=target_temp_C_profile, + average_trade_rate=average_trade_rate, + ) + + def test_state_property_returns_sortes_tank_state_instance(self): + ep = self._create_energy_params() + assert isinstance(ep.state, SorTesTankState) + + def test_soc_management_property_returns_switch_strategy_instance(self): + ep = self._create_energy_params() + assert isinstance(ep.soc_management, SorTesTankMinimiseSwitchStrategy) + + def test_event_activate_sets_initial_soc_at_start_date(self): + ep = self._create_energy_params() + ep.event_activate() + assert math.isclose( + ep.state.get_soc(START_TIME_SLOT), SorTesConfiguration.MIN_SOC_TOLERANCE + ) + + def test_get_soc_delegates_to_state(self): + ep = self._create_energy_params() + ep.state.set_soc(START_TIME_SLOT, 45.0) + assert math.isclose(ep.get_soc(START_TIME_SLOT), 45.0) + + def test_get_energy_demand_kWh_delegates_to_state(self): + ep = self._create_energy_params() + ep.state.set_energy_demand_kWh(START_TIME_SLOT, 2.0) + assert math.isclose(ep.get_energy_demand_kWh(START_TIME_SLOT), 2.0) + + def test_get_min_energy_demand_kWh_delegates_to_state(self): + ep = self._create_energy_params() + ep.state.set_min_energy_demand_kWh(START_TIME_SLOT, 0.5) + assert math.isclose(ep.get_min_energy_demand_kWh(START_TIME_SLOT), 0.5) + + def test_get_max_energy_demand_kWh_delegates_to_state(self): + ep = self._create_energy_params() + ep.state.set_max_energy_demand_kWh(START_TIME_SLOT, 3.0) + assert math.isclose(ep.get_max_energy_demand_kWh(START_TIME_SLOT), 3.0) + + def test_last_time_slot_returns_slot_minus_slot_length(self): + assert SorTesTankEnergyParameters.last_time_slot(NEXT_SLOT) == START_TIME_SLOT + + def test_event_traded_energy_decrements_all_demand_values(self): + ep = self._create_energy_params() + ep.state.set_energy_demand_kWh(START_TIME_SLOT, 5.0) + ep.state.set_min_energy_demand_kWh(START_TIME_SLOT, 2.0) + ep.state.set_max_energy_demand_kWh(START_TIME_SLOT, 8.0) + ep.event_traded_energy(START_TIME_SLOT, 3.0) + assert math.isclose(ep.get_energy_demand_kWh(START_TIME_SLOT), 2.0) + assert math.isclose(ep.get_min_energy_demand_kWh(START_TIME_SLOT), 0.0) + assert math.isclose(ep.get_max_energy_demand_kWh(START_TIME_SLOT), 5.0) + + def test_event_traded_energy_clamps_demand_to_zero_when_trade_exceeds_demand(self): + ep = self._create_energy_params() + ep.state.set_energy_demand_kWh(START_TIME_SLOT, 1.0) + ep.state.set_min_energy_demand_kWh(START_TIME_SLOT, 0.5) + ep.state.set_max_energy_demand_kWh(START_TIME_SLOT, 1.0) + ep.event_traded_energy(START_TIME_SLOT, 10.0) + assert ep.get_energy_demand_kWh(START_TIME_SLOT) == 0.0 + assert ep.get_min_energy_demand_kWh(START_TIME_SLOT) == 0.0 + assert ep.get_max_energy_demand_kWh(START_TIME_SLOT) == 0.0 + + def test_event_traded_energy_accumulates_total_traded(self): + ep = self._create_energy_params() + ep.state.set_energy_demand_kWh(START_TIME_SLOT, 5.0) + ep.state.set_min_energy_demand_kWh(START_TIME_SLOT, 5.0) + ep.state.set_max_energy_demand_kWh(START_TIME_SLOT, 5.0) + ep.event_traded_energy(START_TIME_SLOT, 2.0) + ep.event_traded_energy(START_TIME_SLOT, 1.5) + assert math.isclose(ep.state.get_state()["total_traded_energy_kWh"], 3.5) + + def test_event_market_cycle_sets_positive_energy_demand_in_state(self): + ep = self._create_energy_params() + ep.event_activate() + ep.event_market_cycle(START_TIME_SLOT) + assert ep.get_energy_demand_kWh(START_TIME_SLOT) > 0 + + def test_event_market_cycle_sets_cop_for_time_slot(self): + ep = self._create_energy_params() + ep.event_activate() + ep.event_market_cycle(START_TIME_SLOT) + assert math.isclose(ep.state.get_cop(START_TIME_SLOT), EXPECTED_COP, abs_tol=1e-6) + + def test_event_market_cycle_sets_heat_demand_in_state(self): + heat_demand_J = 3600.0 # 3.6 kJ = 0.001 kWh + ep = self._create_energy_params( + heat_demand_Q_profile={START_TIME_SLOT: heat_demand_J, NEXT_SLOT: heat_demand_J} + ) + ep.event_activate() + ep.event_market_cycle(START_TIME_SLOT) + assert math.isclose( + ep.state.get_heat_demand_kJ(START_TIME_SLOT), heat_demand_J / 1000.0, abs_tol=1e-9 + ) + + def test_event_market_cycle_min_demand_not_greater_than_max_demand(self): + ep = self._create_energy_params() + ep.event_activate() + ep.event_market_cycle(START_TIME_SLOT) + assert ep.get_min_energy_demand_kWh(NEXT_SLOT) <= ep.get_max_energy_demand_kWh(NEXT_SLOT) + + def test_event_market_cycle_max_demand_exceeds_base_electricity_demand(self): + # max includes storage charging on top of baseline heat demand + ep = self._create_energy_params() + ep.event_activate() + ep.event_market_cycle(START_TIME_SLOT) + assert ep.get_max_energy_demand_kWh(NEXT_SLOT) >= ep.get_energy_demand_kWh(NEXT_SLOT) + + def test_no_charge_maintains_soc_from_previous_slot(self): + ep = self._create_energy_params() + ep.event_activate() + # State starts at MIN_SOC_TOLERANCE; soc_management defaults to MAINTAIN_SOC + ep.event_market_cycle(NEXT_SLOT) + expected_soc = SorTesConfiguration.MIN_SOC_TOLERANCE + assert math.isclose(ep.get_soc(NEXT_SLOT), expected_soc) + + def test_charge_increases_soc(self): + ep = self._create_energy_params() + ep.event_activate() + ep.soc_management._current_state = HeatPumpChargingState.CHARGE + # Simulate that we already bought more than needed for baseline heat + # charge_energy = 0.75 kWh; condenser = charge * CONVERSION_CHARGE_CONDENSER = 0.625 kWh + # heat_energy = net_traded * COP_HEAT_SOURCE = (condenser + charge) * 1 = 1.375 kWh + charge_kWh = EXPECTED_CHARGE_ENERGY_KWH + condenser_kWh = charge_kWh * SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER + ep._bought_energy_kWh = charge_kWh + condenser_kWh # net positive after heat demand + # Call _update_last_time_slot_data which also resets _bought_energy_kWh + ep._ambient_temp_C.profile[START_TIME_SLOT] = AMBIENT_TEMP_C + ep._charge_or_discharge_tank(NEXT_SLOT) + assert ep.get_soc(NEXT_SLOT) > SorTesConfiguration.MIN_SOC_TOLERANCE + + def test_discharge_decreases_soc(self): + ep = self._create_energy_params() + ep.event_activate() + # Set SOC high enough to discharge + high_soc = 50.0 + ep.state.set_soc(START_TIME_SLOT, high_soc) + ep.soc_management._current_state = HeatPumpChargingState.DISCHARGE + # Simulate negative net energy (bought less than needed) + discharge_kWh = EXPECTED_DISCHARGE_ENERGY_KWH + evaporator_kWh = discharge_kWh * SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER + ep._bought_energy_kWh = -evaporator_kWh # net negative + ep._ambient_temp_C.profile[START_TIME_SLOT] = AMBIENT_TEMP_C + ep._charge_or_discharge_tank(NEXT_SLOT) + assert ep.get_soc(NEXT_SLOT) < high_soc + + +class TestHeatPumpWithSorTesTankStrategy: + + def setup_method(self): + self._original_start_date = GlobalConfig.start_date + self._original_slot_length = GlobalConfig.slot_length + self._original_market_maker_rate = GlobalConfig.market_maker_rate + self._original_market_type = ConstSettings.MASettings.MARKET_TYPE + self._original_timestamp = global_objects.profiles_handler.current_timestamp + GlobalConfig.slot_length = SLOT_LENGTH + GlobalConfig.start_date = START_TIME_SLOT + GlobalConfig.market_maker_rate = { + START_TIME_SLOT: 30, + NEXT_SLOT: 30, + } + ConstSettings.MASettings.MARKET_TYPE = 2 + global_objects.profiles_handler._update_current_time(timestamp=START_TIME_SLOT) + + def teardown_method(self): + GlobalConfig.start_date = self._original_start_date + GlobalConfig.slot_length = self._original_slot_length + GlobalConfig.market_maker_rate = self._original_market_maker_rate + ConstSettings.MASettings.MARKET_TYPE = self._original_market_type + global_objects.profiles_handler._update_current_time(timestamp=self._original_timestamp) + + def _create_strategy(self, **kwargs): + defaults = { + "heat_demand_Q_profile": {START_TIME_SLOT: 3600.0, NEXT_SLOT: 3600.0}, + "ambient_temp_C_profile": {START_TIME_SLOT: AMBIENT_TEMP_C, NEXT_SLOT: AMBIENT_TEMP_C}, + "target_temp_C_profile": {START_TIME_SLOT: TARGET_TEMP_C, NEXT_SLOT: TARGET_TEMP_C}, + "average_trade_rate": 25.0, + } + defaults.update(kwargs) + return HeatPumpWithSorTesTankStrategy(**defaults) + + def _create_activated_strategy(self, **kwargs): + strategy = self._create_strategy(**kwargs) + strategy_area = Area("sortes_hp", strategy=strategy) + area = Area("grid", children=[strategy_area]) + area.config.start_date = START_TIME_SLOT + area.config.end_date = area.config.start_date.add(days=1) + area.activate() + return strategy, area + + def test_state_property_returns_sortes_tank_state(self): + strategy = self._create_strategy() + assert isinstance(strategy.state, SorTesTankState) + + def test_energy_params_is_sortes_tank_energy_parameters(self): + strategy = self._create_strategy() + assert isinstance(strategy._energy_params, SorTesTankEnergyParameters) + + def test_init_with_no_order_updater_params_uses_spot_default(self): + strategy = self._create_strategy() + assert AvailableMarketTypes.SPOT in strategy._order_updater_params + assert isinstance( + strategy._order_updater_params[AvailableMarketTypes.SPOT], + HeatPumpOrderUpdaterParameters, + ) + + def test_init_with_custom_order_updater_params_stores_them(self): + custom_params = { + AvailableMarketTypes.SPOT: HeatPumpOrderUpdaterParameters( + initial_rate=5, final_rate=25 + ) + } + strategy = self._create_strategy(order_updater_parameters=custom_params) + assert strategy._order_updater_params == custom_params + + def test_event_market_cycle_creates_order_updater_for_spot_market(self): + strategy, area = self._create_activated_strategy() + strategy._energy_params.get_max_energy_demand_kWh = MagicMock(return_value=1.0) + strategy.event_market_cycle() + market_object = area.spot_market + assert len(strategy._order_updaters[market_object].keys()) == 1 + + def test_event_market_cycle_posts_bid_on_spot_market(self): + strategy, area = self._create_activated_strategy() + energy_to_buy = 2.5 + strategy._energy_params.get_max_energy_demand_kWh = MagicMock(return_value=energy_to_buy) + strategy.event_market_cycle() + bids = list(area.spot_market.bids.values()) + assert len(bids) == 1 + assert math.isclose(bids[0].energy, energy_to_buy) + + def test_post_order_with_explicit_rate_calls_internal_post_order(self): + strategy, area = self._create_activated_strategy() + market = area.spot_market + market_slot = area.spot_market.time_slot + strategy._energy_params.soc_management.calculate = MagicMock(return_value=1.5) + strategy._post_order = MagicMock() + strategy.post_order(market, market_slot, order_rate=15.0) + strategy._post_order.assert_called_once() + + def test_post_order_uses_soc_management_calculate_for_energy(self): + strategy, area = self._create_activated_strategy() + market = area.spot_market + market_slot = area.spot_market.time_slot + expected_energy = 2.0 + strategy._energy_params.soc_management.calculate = MagicMock(return_value=expected_energy) + captured_args = {} + + def capture_post_order(_mkt, _slot, energy, _rate): + captured_args["energy"] = float(energy) + + strategy._post_order = capture_post_order + strategy.post_order(market, market_slot, order_rate=15.0) + assert math.isclose(captured_args["energy"], expected_energy) + + def test_state_is_same_object_as_energy_params_state(self): + strategy = self._create_strategy() + assert strategy.state is strategy._energy_params.state From 0d73c49d9c6b1c3f6a1665c1721fe380c39d5704 Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Wed, 13 May 2026 14:42:58 +0200 Subject: [PATCH 05/17] GSYE-891: Slight changes to the unit tests --- tests/strategies/test_sortes_heat_pump.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/strategies/test_sortes_heat_pump.py b/tests/strategies/test_sortes_heat_pump.py index 714d4f74e..2b39677f9 100644 --- a/tests/strategies/test_sortes_heat_pump.py +++ b/tests/strategies/test_sortes_heat_pump.py @@ -96,9 +96,7 @@ def teardown_method(self): GlobalConfig.start_date = self._original_start_date def _create_strategy(self, average_trade_rate=25.0): - strategy = SorTesTankMinimiseSwitchStrategy(self._energy_params_mock, average_trade_rate) - # print(strategy._average_trade_rate.profile) - return strategy + return SorTesTankMinimiseSwitchStrategy(self._energy_params_mock, average_trade_rate) def test_initial_state_is_maintain_soc(self): strategy = self._create_strategy() @@ -463,7 +461,7 @@ def test_charge_increases_soc(self): # Call _update_last_time_slot_data which also resets _bought_energy_kWh ep._ambient_temp_C.profile[START_TIME_SLOT] = AMBIENT_TEMP_C ep._charge_or_discharge_tank(NEXT_SLOT) - assert ep.get_soc(NEXT_SLOT) > SorTesConfiguration.MIN_SOC_TOLERANCE + assert ep.get_soc(NEXT_SLOT) == 13 def test_discharge_decreases_soc(self): ep = self._create_energy_params() @@ -478,7 +476,7 @@ def test_discharge_decreases_soc(self): ep._bought_energy_kWh = -evaporator_kWh # net negative ep._ambient_temp_C.profile[START_TIME_SLOT] = AMBIENT_TEMP_C ep._charge_or_discharge_tank(NEXT_SLOT) - assert ep.get_soc(NEXT_SLOT) < high_soc + assert ep.get_soc(NEXT_SLOT) == 45.7 class TestHeatPumpWithSorTesTankStrategy: From 77900335186c8cade57aeab384de039896ca844c Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Mon, 18 May 2026 13:30:45 +0200 Subject: [PATCH 06/17] GSYE-891: Fix corner case heat-demand < energy-extracted from sortes --- src/gsy_e/constants.py | 5 ++-- .../strategy/heatpump_with_sortes_tank.py | 26 +++++++++++++------ tests/strategies/test_sortes_heat_pump.py | 2 +- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/gsy_e/constants.py b/src/gsy_e/constants.py index 19757a011..6cbeca31a 100644 --- a/src/gsy_e/constants.py +++ b/src/gsy_e/constants.py @@ -112,12 +112,13 @@ class HeatPumpSettingsDefaultParameters: class SorTesConfiguration: """Collection of SorTes heat tank configuration parameters.""" - MINUTES_BEFORE_SWITCH_ALLOWED = 2 * 60 + MINUTES_BEFORE_SWITCH_ALLOWED = 4 * 60 MIN_SOC_TOLERANCE = 10 MAX_SOC_TOLERANCE = 90 CAPACITY_KWH = 25 COP_HEAT_SOURCE = 1 COP_CONDENSER = 1 - COP_EVAPORATOR = 1 # to be updated + COP_EVAPORATOR = 5 # to be updated CONVERSION_CHARGE_CONDENSER_POWER = 1 / 1.2 CONVERSION_DISCHARGE_EVAPORATOR_POWER = 1 / 1.2 + AMBIENT_TEMPERATURE_CORRECTION = 5 diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index e5785fb01..415f40156 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -1,6 +1,7 @@ from decimal import Decimal from typing import TYPE_CHECKING, Optional, Union from logging import getLogger +from math import isclose import numpy as np from gsy_framework.constants_limits import GlobalConfig, ConstSettings, FLOATING_POINT_TOLERANCE @@ -353,13 +354,15 @@ def _calc_and_set_energy_demand(self, time_slot: DateTime): def _get_performance_energy_charge_kWh(self, time_slot: DateTime) -> float: charge_power_kW = SorTesPerformanceMaps.get_power_charging( - self._ambient_temp_C.get_value(time_slot) + 5 # todo: temp addition TDB + self._ambient_temp_C.get_value(time_slot) + + SorTesConfiguration.AMBIENT_TEMPERATURE_CORRECTION ) return convert_kW_to_kWh(charge_power_kW, GlobalConfig.slot_length) def _get_performance_energy_discharge_kWh(self, time_slot: DateTime) -> float: discharge_power_kW = SorTesPerformanceMaps.get_power_discharging( - self._ambient_temp_C.get_value(time_slot) + 5 # todo: temp addition TDB + self._ambient_temp_C.get_value(time_slot) + - SorTesConfiguration.AMBIENT_TEMPERATURE_CORRECTION ) return convert_kW_to_kWh(discharge_power_kW, GlobalConfig.slot_length) @@ -418,9 +421,11 @@ def _calc_energy_to_buy_maximum(self, time_slot: DateTime) -> float: def _calc_energy_to_buy_minimum(self, time_slot: DateTime) -> float: available_stored_heat_kWh = self._calc_available_stored_heat_kWh(time_slot) - energy_to_be_bought_for_heat = self._get_total_electricity_demand_for_time_slot_kWh( - time_slot - ) - self._calc_heat_capacity_into_electricity_kWh(available_stored_heat_kWh) + electricity_demand_kWh = self._get_total_electricity_demand_for_time_slot_kWh(time_slot) + energy_to_be_bought_for_heat = ( + electricity_demand_kWh + - self._calc_heat_capacity_into_electricity_kWh(available_stored_heat_kWh) + ) if energy_to_be_bought_for_heat < FLOATING_POINT_TOLERANCE: # corner case when the demand is lower than the discharging energy @@ -429,7 +434,7 @@ def _calc_energy_to_buy_minimum(self, time_slot: DateTime) -> float: self._get_total_electricity_demand_for_time_slot_kWh(time_slot), self._calc_heat_capacity_into_electricity_kWh(available_stored_heat_kWh), ) - energy_to_be_bought_for_heat = 0 + return electricity_demand_kWh return energy_to_be_bought_for_heat + self._calc_evaporator_electricity_kWh( available_stored_heat_kWh @@ -447,6 +452,13 @@ def _charge_or_discharge_tank(self, time_slot: DateTime): ) net_traded_energy_kWh = self._bought_energy_kWh - electricity_demand_kWh + def _is_net_traded_energy_zero(): + return isclose(net_traded_energy_kWh, 0, abs_tol=FLOATING_POINT_TOLERANCE) + + if _is_net_traded_energy_zero(): + self._no_charge(time_slot) + return + if ( self.soc_management.current_state == HeatPumpChargingState.CHARGE and net_traded_energy_kWh > FLOATING_POINT_TOLERANCE @@ -454,8 +466,6 @@ def _charge_or_discharge_tank(self, time_slot: DateTime): self._charge(net_traded_energy_kWh, time_slot) elif self.soc_management.current_state == HeatPumpChargingState.DISCHARGE: self._discharge(net_traded_energy_kWh, time_slot) - elif self.soc_management.current_state == HeatPumpChargingState.MAINTAIN_SOC: - self._no_charge(time_slot) else: assert False, "should never reach this point" diff --git a/tests/strategies/test_sortes_heat_pump.py b/tests/strategies/test_sortes_heat_pump.py index 2b39677f9..57fdfcfea 100644 --- a/tests/strategies/test_sortes_heat_pump.py +++ b/tests/strategies/test_sortes_heat_pump.py @@ -476,7 +476,7 @@ def test_discharge_decreases_soc(self): ep._bought_energy_kWh = -evaporator_kWh # net negative ep._ambient_temp_C.profile[START_TIME_SLOT] = AMBIENT_TEMP_C ep._charge_or_discharge_tank(NEXT_SLOT) - assert ep.get_soc(NEXT_SLOT) == 45.7 + assert ep.get_soc(NEXT_SLOT) == 46.5 class TestHeatPumpWithSorTesTankStrategy: From 864b29c89d0b3d66c196cf0e463673ed1082f4fd Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Mon, 18 May 2026 13:31:28 +0200 Subject: [PATCH 07/17] GSYE-891: Remove HeatPumpChargingState.MAINTAIN_SOC from the MINUTES_BEFORE_SWITCH_ALLOWED rule --- src/gsy_e/models/strategy/heat_pump_soc_management.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/gsy_e/models/strategy/heat_pump_soc_management.py b/src/gsy_e/models/strategy/heat_pump_soc_management.py index fc8ab1145..807d5735c 100644 --- a/src/gsy_e/models/strategy/heat_pump_soc_management.py +++ b/src/gsy_e/models/strategy/heat_pump_soc_management.py @@ -113,7 +113,11 @@ def _is_time_for_state_change(self, time_slot: DateTime) -> bool: # occurred yet. Change state if needed. self._last_switch = time_slot return True - if time_slot - self._last_switch < duration(minutes=self.MINUTES_BEFORE_SWITCH_ALLOWED): + if ( + self._current_state != HeatPumpChargingState.MAINTAIN_SOC + and time_slot - self._last_switch + < duration(minutes=self.MINUTES_BEFORE_SWITCH_ALLOWED) + ): # If not enough time has passed since the last switch, do not allow state change. return False # Otherwise, allow the state change. From 7a02b207896cf85bf8ae7207290d74467890e390 Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Mon, 18 May 2026 15:00:01 +0200 Subject: [PATCH 08/17] GSYE-891: Add time horizont for SorTesTankMinimiseSwitchStrategy._is_energy_affordable --- src/gsy_e/constants.py | 3 ++- .../models/strategy/heatpump_with_sortes_tank.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/gsy_e/constants.py b/src/gsy_e/constants.py index 6cbeca31a..978c12f46 100644 --- a/src/gsy_e/constants.py +++ b/src/gsy_e/constants.py @@ -112,7 +112,8 @@ class HeatPumpSettingsDefaultParameters: class SorTesConfiguration: """Collection of SorTes heat tank configuration parameters.""" - MINUTES_BEFORE_SWITCH_ALLOWED = 4 * 60 + MINUTES_BEFORE_SWITCH_ALLOWED = 2 * 60 + MINUTES_TIME_HORIZONT_LOW_RATES = 1 * 60 MIN_SOC_TOLERANCE = 10 MAX_SOC_TOLERANCE = 90 CAPACITY_KWH = 25 diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index 415f40156..33d799daa 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -120,9 +120,15 @@ def _get_tank_soc(self, time_slot: DateTime): return self._energy_params.get_soc(time_slot) def _is_energy_affordable(self, time_slot: DateTime, _buy_rate: float) -> bool: - return ( - self._average_trade_rate.get_value(time_slot) - < GlobalConfig.market_maker_rate[time_slot] + rates_in_time_horizont = [ + value + for ts, value in self._average_trade_rate.profile.items() + if time_slot + <= ts + < time_slot.add(minutes=SorTesConfiguration.MINUTES_TIME_HORIZONT_LOW_RATES) + ] + return all( + value < GlobalConfig.market_maker_rate[time_slot] for value in rates_in_time_horizont ) def event_activate(self): From b0f89ef074ed7dac0d9947cecdbad1295a8bc2dd Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Tue, 19 May 2026 09:09:58 +0200 Subject: [PATCH 09/17] GSYE-891: Exchange comparison with MMR to preferred bying rate --- src/gsy_e/constants.py | 3 ++- .../models/strategy/heatpump_with_sortes_tank.py | 2 +- tests/strategies/test_sortes_heat_pump.py | 12 ++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/gsy_e/constants.py b/src/gsy_e/constants.py index 978c12f46..909499d68 100644 --- a/src/gsy_e/constants.py +++ b/src/gsy_e/constants.py @@ -112,8 +112,9 @@ class HeatPumpSettingsDefaultParameters: class SorTesConfiguration: """Collection of SorTes heat tank configuration parameters.""" - MINUTES_BEFORE_SWITCH_ALLOWED = 2 * 60 + MINUTES_BEFORE_SWITCH_ALLOWED = 1 * 60 MINUTES_TIME_HORIZONT_LOW_RATES = 1 * 60 + PREFERRED_BUYING_RATE = 20 MIN_SOC_TOLERANCE = 10 MAX_SOC_TOLERANCE = 90 CAPACITY_KWH = 25 diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index 33d799daa..bb56ae440 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -128,7 +128,7 @@ def _is_energy_affordable(self, time_slot: DateTime, _buy_rate: float) -> bool: < time_slot.add(minutes=SorTesConfiguration.MINUTES_TIME_HORIZONT_LOW_RATES) ] return all( - value < GlobalConfig.market_maker_rate[time_slot] for value in rates_in_time_horizont + value < SorTesConfiguration.PREFERRED_BUYING_RATE for value in rates_in_time_horizont ) def event_activate(self): diff --git a/tests/strategies/test_sortes_heat_pump.py b/tests/strategies/test_sortes_heat_pump.py index 57fdfcfea..395450cfa 100644 --- a/tests/strategies/test_sortes_heat_pump.py +++ b/tests/strategies/test_sortes_heat_pump.py @@ -114,14 +114,14 @@ def test_get_tank_soc_delegates_to_energy_params(self): assert math.isclose(soc, 42.0) self._energy_params_mock.get_soc.assert_called_once_with(START_TIME_SLOT) - def test_is_energy_affordable_true_when_avg_rate_below_market_maker_rate(self): - # average_trade_rate=25, market_maker_rate[START_TIME_SLOT]=30 → 25 < 30 → True - strategy = self._create_strategy(average_trade_rate=25.0) + def test_is_energy_affordable_true_when_avg_rate_below_preferred_buying_rate(self): + # average_trade_rate=19, preferred_buying_rate=20 → 19 < 20 → True + strategy = self._create_strategy(average_trade_rate=19.0) strategy.event_activate() assert strategy._is_energy_affordable(START_TIME_SLOT, 0.0) is True - def test_is_energy_affordable_false_when_avg_rate_above_market_maker_rate(self): - # average_trade_rate=35, market_maker_rate[START_TIME_SLOT]=30 → 35 < 30 → False + def test_is_energy_affordable_false_when_avg_rate_above_preferred_buying_rate(self): + # average_trade_rate=35, preferred_buying_rate=20 → 35 < 20 → False strategy = self._create_strategy(average_trade_rate=35.0) strategy.event_activate() assert strategy._is_energy_affordable(START_TIME_SLOT, 0.0) is False @@ -508,7 +508,7 @@ def _create_strategy(self, **kwargs): "heat_demand_Q_profile": {START_TIME_SLOT: 3600.0, NEXT_SLOT: 3600.0}, "ambient_temp_C_profile": {START_TIME_SLOT: AMBIENT_TEMP_C, NEXT_SLOT: AMBIENT_TEMP_C}, "target_temp_C_profile": {START_TIME_SLOT: TARGET_TEMP_C, NEXT_SLOT: TARGET_TEMP_C}, - "average_trade_rate": 25.0, + "average_trade_rate": 19.0, } defaults.update(kwargs) return HeatPumpWithSorTesTankStrategy(**defaults) From de7d499832b89604c1aa8a6542bdd035b8b4d16e Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Tue, 19 May 2026 11:25:42 +0200 Subject: [PATCH 10/17] Rename column in Sortes export file in order to align with Fractles export files --- src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py b/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py index 8ffb122dd..d729c624c 100644 --- a/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py +++ b/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py @@ -16,7 +16,7 @@ along with this program. If not, see . """ -# pylint: disable=too-many-return-statements, broad-exception-raised +# pylint: disable=too-many-return-statements, broad-exception-raised, too-many-locals from abc import ABC, abstractmethod from typing import Dict, List @@ -431,7 +431,7 @@ def labels(self) -> List: "energy traded [kWh]", "COP", "heat demand [kJ]", - "soc %", + "SOC", "total_charged_energy_kWh", ] From 095993ae2906b2315555a763aff0b39fe1df2baf Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Thu, 21 May 2026 13:49:03 +0200 Subject: [PATCH 11/17] GSYE-891: Fix bug in energy assertions ans also correct the condenser COP --- src/gsy_e/constants.py | 4 ++-- .../models/strategy/heatpump_with_sortes_tank.py | 8 ++------ tests/strategies/test_sortes_heat_pump.py | 12 ++++++++++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/gsy_e/constants.py b/src/gsy_e/constants.py index 909499d68..d7d1f2bda 100644 --- a/src/gsy_e/constants.py +++ b/src/gsy_e/constants.py @@ -119,8 +119,8 @@ class SorTesConfiguration: MAX_SOC_TOLERANCE = 90 CAPACITY_KWH = 25 COP_HEAT_SOURCE = 1 - COP_CONDENSER = 1 - COP_EVAPORATOR = 5 # to be updated + COP_CONDENSER = 5 # todo: to be updated + COP_EVAPORATOR = 1 CONVERSION_CHARGE_CONDENSER_POWER = 1 / 1.2 CONVERSION_DISCHARGE_EVAPORATOR_POWER = 1 / 1.2 AMBIENT_TEMPERATURE_CORRECTION = 5 diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index bb56ae440..8bd9faef2 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -477,9 +477,7 @@ def _is_net_traded_energy_zero(): def _charge(self, net_traded_energy_kWh: float, time_slot: DateTime): charge_energy_kWh = self._get_performance_energy_charge_kWh(self.last_time_slot(time_slot)) - condenser_energy_kWh = ( - charge_energy_kWh * SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER - ) + condenser_energy_kWh = self._calc_condenser_electricity_kWh(charge_energy_kWh) heat_energy_kWh = net_traded_energy_kWh * SorTesConfiguration.COP_HEAT_SOURCE assert ( abs(condenser_energy_kWh + charge_energy_kWh - heat_energy_kWh) @@ -494,9 +492,7 @@ def _discharge(self, net_traded_energy_kWh: float, time_slot: DateTime): discharge_energy_kWh = self._get_performance_energy_discharge_kWh( self.last_time_slot(time_slot) ) - evaporator_energy_kWh = ( - discharge_energy_kWh * SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER - ) + evaporator_energy_kWh = self._calc_evaporator_electricity_kWh(discharge_energy_kWh) assert (net_traded_energy_kWh - evaporator_energy_kWh) < FLOATING_POINT_TOLERANCE self._update_soc(time_slot, -discharge_energy_kWh) diff --git a/tests/strategies/test_sortes_heat_pump.py b/tests/strategies/test_sortes_heat_pump.py index 395450cfa..c68eb8b51 100644 --- a/tests/strategies/test_sortes_heat_pump.py +++ b/tests/strategies/test_sortes_heat_pump.py @@ -456,7 +456,11 @@ def test_charge_increases_soc(self): # charge_energy = 0.75 kWh; condenser = charge * CONVERSION_CHARGE_CONDENSER = 0.625 kWh # heat_energy = net_traded * COP_HEAT_SOURCE = (condenser + charge) * 1 = 1.375 kWh charge_kWh = EXPECTED_CHARGE_ENERGY_KWH - condenser_kWh = charge_kWh * SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER + condenser_kWh = ( + charge_kWh + * SorTesConfiguration.CONVERSION_CHARGE_CONDENSER_POWER + / SorTesConfiguration.COP_CONDENSER + ) ep._bought_energy_kWh = charge_kWh + condenser_kWh # net positive after heat demand # Call _update_last_time_slot_data which also resets _bought_energy_kWh ep._ambient_temp_C.profile[START_TIME_SLOT] = AMBIENT_TEMP_C @@ -472,7 +476,11 @@ def test_discharge_decreases_soc(self): ep.soc_management._current_state = HeatPumpChargingState.DISCHARGE # Simulate negative net energy (bought less than needed) discharge_kWh = EXPECTED_DISCHARGE_ENERGY_KWH - evaporator_kWh = discharge_kWh * SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER + evaporator_kWh = ( + discharge_kWh + * SorTesConfiguration.CONVERSION_DISCHARGE_EVAPORATOR_POWER + / SorTesConfiguration.COP_EVAPORATOR + ) ep._bought_energy_kWh = -evaporator_kWh # net negative ep._ambient_temp_C.profile[START_TIME_SLOT] = AMBIENT_TEMP_C ep._charge_or_discharge_tank(NEXT_SLOT) From 0f2b27a23f1b2d0aa6e84539c487540027b2facc Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Fri, 22 May 2026 09:53:05 +0200 Subject: [PATCH 12/17] GSYE-891: Adapt corner case logging in _calc_energy_to_buy_minimum in the sortes HP --- .../models/strategy/heatpump_with_sortes_tank.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index 8bd9faef2..4a5820eee 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -428,17 +428,17 @@ def _calc_energy_to_buy_maximum(self, time_slot: DateTime) -> float: def _calc_energy_to_buy_minimum(self, time_slot: DateTime) -> float: available_stored_heat_kWh = self._calc_available_stored_heat_kWh(time_slot) electricity_demand_kWh = self._get_total_electricity_demand_for_time_slot_kWh(time_slot) - energy_to_be_bought_for_heat = ( - electricity_demand_kWh - - self._calc_heat_capacity_into_electricity_kWh(available_stored_heat_kWh) + storage_capacity_electric_kWh = self._calc_heat_capacity_into_electricity_kWh( + available_stored_heat_kWh ) + energy_to_be_bought_for_heat = electricity_demand_kWh - storage_capacity_electric_kWh - if energy_to_be_bought_for_heat < FLOATING_POINT_TOLERANCE: + if energy_to_be_bought_for_heat < FLOATING_POINT_TOLERANCE < storage_capacity_electric_kWh: # corner case when the demand is lower than the discharging energy log.warning( "The heat demand is lower than the discharging energy: %s, %s", - self._get_total_electricity_demand_for_time_slot_kWh(time_slot), - self._calc_heat_capacity_into_electricity_kWh(available_stored_heat_kWh), + electricity_demand_kWh, + storage_capacity_electric_kWh, ) return electricity_demand_kWh From 720eacf147f7ab20db21b80c82ee0182f2086231 Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Fri, 22 May 2026 11:09:25 +0200 Subject: [PATCH 13/17] GSYE-891: Add serializor for sortes heat pump --- .../strategy/heatpump_with_sortes_tank.py | 18 ++++++- tests/strategies/test_sortes_heat_pump.py | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index 4a5820eee..e6eceb6f6 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -256,7 +256,7 @@ def __init__( ): # pylint: disable=too-many-arguments, too-many-positional-arguments self._state = SorTesTankState() - + self._source_type = source_type self._heat_demand_Q_J: StrategyProfileBase = profile_factory( heat_demand_Q_profile, None, profile_type=InputProfileTypes.IDENTITY ) @@ -277,6 +277,15 @@ def state(self) -> SorTesTankState: """Return the state.""" return self._state + def serialize(self): + """Return dict with the current energy parameter values.""" + return { + "heat_demand_Q_J": self._heat_demand_Q_J.input_profile, + "ambient_temp_C": self._ambient_temp_C.input_profile, + "target_temp_C": self._target_temp_C.input_profile, + "source_type": self._source_type, + } + @property def soc_management(self) -> SorTesTankMinimiseSwitchStrategy: """Return the soc management.""" @@ -547,6 +556,13 @@ def __init__( source_type=source_type, ) + def serialize(self): + """Serialize strategy parameters.""" + return { + **self._energy_params.serialize(), + **self._order_updater_params.get(AvailableMarketTypes.SPOT).serialize(), + } + def post_order( self, market: "MarketBase", market_slot: DateTime, order_rate: float = None, **kwargs ): diff --git a/tests/strategies/test_sortes_heat_pump.py b/tests/strategies/test_sortes_heat_pump.py index c68eb8b51..adc416c35 100644 --- a/tests/strategies/test_sortes_heat_pump.py +++ b/tests/strategies/test_sortes_heat_pump.py @@ -427,6 +427,31 @@ def test_event_market_cycle_sets_heat_demand_in_state(self): ep.state.get_heat_demand_kJ(START_TIME_SLOT), heat_demand_J / 1000.0, abs_tol=1e-9 ) + def test_serialize_contains_required_keys(self): + ep = self._create_energy_params() + result = ep.serialize() + for key in ("heat_demand_Q_J", "ambient_temp_C", "target_temp_C", "source_type"): + assert key in result + + def test_serialize_source_type_matches_default(self): + ep = self._create_energy_params() + result = ep.serialize() + assert result["source_type"] == ConstSettings.HeatPumpSettings.SOURCE_TYPE + + def test_serialize_input_profiles_match_constructor_arguments(self): + heat_profile = {START_TIME_SLOT: 1000.0, NEXT_SLOT: 2000.0} + ambient_profile = {START_TIME_SLOT: 10.0, NEXT_SLOT: 12.0} + target_profile = {START_TIME_SLOT: 45.0, NEXT_SLOT: 50.0} + ep = self._create_energy_params( + heat_demand_Q_profile=heat_profile, + ambient_temp_C_profile=ambient_profile, + target_temp_C_profile=target_profile, + ) + result = ep.serialize() + assert result["heat_demand_Q_J"] == heat_profile + assert result["ambient_temp_C"] == ambient_profile + assert result["target_temp_C"] == target_profile + def test_event_market_cycle_min_demand_not_greater_than_max_demand(self): ep = self._create_energy_params() ep.event_activate() @@ -598,3 +623,25 @@ def capture_post_order(_mkt, _slot, energy, _rate): def test_state_is_same_object_as_energy_params_state(self): strategy = self._create_strategy() assert strategy.state is strategy._energy_params.state + + def test_serialize_contains_all_energy_param_keys(self): + strategy = self._create_strategy() + result = strategy.serialize() + for key in ("heat_demand_Q_J", "ambient_temp_C", "target_temp_C", "source_type"): + assert key in result + + def test_serialize_contains_all_order_updater_keys(self): + strategy = self._create_strategy() + result = strategy.serialize() + for key in ( + "update_interval", + "initial_buying_rate", + "final_buying_rate", + "use_market_maker_rate", + ): + assert key in result + + def test_serialize_source_type_matches_default(self): + strategy = self._create_strategy() + result = strategy.serialize() + assert result["source_type"] == ConstSettings.HeatPumpSettings.SOURCE_TYPE From 5e53114dfff315f39106e8b66ab62e9419392091 Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Wed, 27 May 2026 10:39:27 +0200 Subject: [PATCH 14/17] GSYE-891: Add _handle_state_at_soc_limits in order to seperate functionality in the MinimizeSwitchStrategies --- .../strategy/heat_pump_soc_management.py | 23 +++++++++++-------- .../strategy/heatpump_with_sortes_tank.py | 16 +++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/gsy_e/models/strategy/heat_pump_soc_management.py b/src/gsy_e/models/strategy/heat_pump_soc_management.py index 807d5735c..0f1689bcc 100644 --- a/src/gsy_e/models/strategy/heat_pump_soc_management.py +++ b/src/gsy_e/models/strategy/heat_pump_soc_management.py @@ -83,16 +83,7 @@ def calculate(self, time_slot: DateTime, buy_rate: float = 0.0): if not self._is_time_for_state_change(time_slot): # If state change is not possible, but at the same time the soc is below the min or # above the max SOC value of the tank, then maintain the SOC. - if ( - self._get_tank_soc(time_slot) >= self.MAX_SOC_TOLERANCE - and self._current_state == HeatPumpChargingState.CHARGE - ): - target_state = HeatPumpChargingState.MAINTAIN_SOC - if ( - self._get_tank_soc(time_slot) <= self.MIN_SOC_TOLERANCE - and self._current_state == HeatPumpChargingState.DISCHARGE - ): - target_state = HeatPumpChargingState.MAINTAIN_SOC + target_state = self._handle_state_at_soc_limits(time_slot, target_state) else: # If the state change is possible, check the market maker rate to set the new state target_state = self._should_charge_or_discharge(time_slot, buy_rate) @@ -104,6 +95,18 @@ def calculate(self, time_slot: DateTime, buy_rate: float = 0.0): return self._get_energy_from_target_state(target_state, time_slot) + def _handle_state_at_soc_limits( + self, time_slot: DateTime, current_state: HeatPumpChargingState + ) -> HeatPumpChargingState: + # If state change is not possible, but at the same time the soc is below the min or + # above the max SOC value of the tank, then maintain the SOC. + target_state = current_state + if self._get_tank_soc(time_slot) >= self.MAX_SOC_TOLERANCE: + target_state = HeatPumpChargingState.MAINTAIN_SOC + if self._get_tank_soc(time_slot) <= self.MIN_SOC_TOLERANCE: + target_state = HeatPumpChargingState.MAINTAIN_SOC + return target_state + def _get_tank_soc(self, time_slot: DateTime) -> float: return self._charger.get_average_soc(time_slot) diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index e6eceb6f6..846fc592a 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -139,6 +139,22 @@ def event_market_slot(self): """Perform commands on event market cycle.""" self._average_trade_rate.read_or_rotate_profiles() + def _handle_state_at_soc_limits( + self, time_slot: DateTime, current_state: HeatPumpChargingState + ) -> HeatPumpChargingState: + target_state = current_state + if ( + self._get_tank_soc(time_slot) >= self.MAX_SOC_TOLERANCE + and self._current_state == HeatPumpChargingState.CHARGE + ): + target_state = HeatPumpChargingState.MAINTAIN_SOC + if ( + self._get_tank_soc(time_slot) <= self.MIN_SOC_TOLERANCE + and self._current_state == HeatPumpChargingState.DISCHARGE + ): + target_state = HeatPumpChargingState.MAINTAIN_SOC + return target_state + class SorTesTankState(HeatPumpStateBase): """State class of Sortes tank state.""" From e94029101808d920cc15e176c1a517af09caba9d Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Wed, 27 May 2026 13:42:23 +0200 Subject: [PATCH 15/17] GSYE-891: Improve export CSV files for Sortes HP --- .../sim_results/file_export_endpoints.py | 6 +- .../strategy/heatpump_with_sortes_tank.py | 36 ++++-- tests/strategies/test_sortes_heat_pump.py | 112 ++++++++++++++++-- 3 files changed, 134 insertions(+), 20 deletions(-) diff --git a/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py b/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py index d729c624c..718cf5f52 100644 --- a/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py +++ b/src/gsy_e/gsy_e_core/sim_results/file_export_endpoints.py @@ -432,7 +432,8 @@ def labels(self) -> List: "COP", "heat demand [kJ]", "SOC", - "total_charged_energy_kWh", + "auxiliary_energy_kWh", + "energy_used_for_dis_charging_kWh", ] @property @@ -452,7 +453,8 @@ def _row(self, slot, market): round(hp_stats["cop"], ROUND_TOLERANCE_EXPORT), round(hp_stats["heat_demand_kJ"], ROUND_TOLERANCE_EXPORT), round(hp_stats["soc"], ROUND_TOLERANCE_EXPORT), - round(hp_stats["total_charge_energy_kWh"], ROUND_TOLERANCE_EXPORT), + round(hp_stats["auxiliary_energy_kWh"], 4), + round(hp_stats["energy_used_for_dis_charging_kWh"], 4), ] return rows diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index 846fc592a..0d767ea27 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -166,20 +166,32 @@ def __init__(self): self._cop: dict[DateTime, float] = {} # in percent self._energy_demand_kWh: dict[DateTime, float] = {} # electricity self._heat_demand_kJ: dict[DateTime, float] = {} + self._auxiliary_energy_kWh: dict[DateTime, float] = {} + self._energy_used_for_dis_charging_kWh: dict[DateTime, float] = {} self._min_energy_demand_kWh: dict[DateTime, float] = {} self._max_energy_demand_kWh: dict[DateTime, float] = {} self._total_traded_energy_kWh: float = 0 # for KPI calculation - self._total_charged_energy_kWh: float = 0 - - def update_total_charged_energy_kWh(self, charged_energy_kWh: float): - """Update the total charged energy.""" - self._total_charged_energy_kWh += charged_energy_kWh def activate(self): """Perform commands on event activate.""" self._soc[GlobalConfig.start_date] = SorTesConfiguration.MIN_SOC_TOLERANCE self._cop[GlobalConfig.start_date] = DEFAULT_COP + def set_auxiliary_energy_kWh(self, time_slot: DateTime, energy_kWh): + """Set auxiliary energy.""" + self._auxiliary_energy_kWh[time_slot] = energy_kWh + + def _get_auxiliary_energy_kWh(self, time_slot: DateTime) -> float: + return self._auxiliary_energy_kWh.get(time_slot, 0) + + def update_energy_used_for_dis_charging_kWh(self, time_slot: DateTime, energy_kWh: float): + """Update the charged or discharged energy.""" + self._energy_used_for_dis_charging_kWh[time_slot] = energy_kWh + + def _get_energy_used_for_dis_charging_kWh(self, time_slot: DateTime) -> float: + """Get the charged or discharged energy.""" + return self._energy_used_for_dis_charging_kWh.get(time_slot, 0) + def get_soc(self, time_slot: DateTime) -> float: """Return the soc value for the given time slot.""" return self._soc.get(time_slot, 0) @@ -229,7 +241,6 @@ def get_state(self) -> dict: "min_energy_demand_kWh": convert_pendulum_to_str_in_dict(self._min_energy_demand_kWh), "max_energy_demand_kWh": convert_pendulum_to_str_in_dict(self._max_energy_demand_kWh), "total_traded_energy_kWh": self._total_traded_energy_kWh, - "total_charge_energy_kWh": self._total_charged_energy_kWh, } def restore_state(self, state_dict: dict): @@ -244,7 +255,6 @@ def restore_state(self, state_dict: dict): state_dict["max_energy_demand_kWh"] ) self._total_traded_energy_kWh = state_dict["total_traded_energy_kWh"] - self._total_charged_energy_kWh = state_dict["total_charge_energy_kWh"] def get_results_dict(self, current_time_slot: DateTime) -> dict: """Return the results of the given time slot.""" @@ -253,7 +263,10 @@ def get_results_dict(self, current_time_slot: DateTime) -> dict: "total_traded_energy_kWh": self._total_traded_energy_kWh, "heat_demand_kJ": self.get_heat_demand_kJ(current_time_slot), "soc": self.get_soc(current_time_slot), - "total_charge_energy_kWh": self._total_charged_energy_kWh, + "energy_used_for_dis_charging_kWh": self._get_energy_used_for_dis_charging_kWh( + current_time_slot + ), + "auxiliary_energy_kWh": self._get_auxiliary_energy_kWh(current_time_slot), } @@ -510,7 +523,8 @@ def _charge(self, net_traded_energy_kWh: float, time_slot: DateTime): ) self._update_soc(time_slot, charge_energy_kWh) - self._state.update_total_charged_energy_kWh(charge_energy_kWh) + self._state.update_energy_used_for_dis_charging_kWh(time_slot, charge_energy_kWh) + self._state.set_auxiliary_energy_kWh(time_slot, condenser_energy_kWh) def _discharge(self, net_traded_energy_kWh: float, time_slot: DateTime): assert net_traded_energy_kWh < FLOATING_POINT_TOLERANCE @@ -521,9 +535,11 @@ def _discharge(self, net_traded_energy_kWh: float, time_slot: DateTime): assert (net_traded_energy_kWh - evaporator_energy_kWh) < FLOATING_POINT_TOLERANCE self._update_soc(time_slot, -discharge_energy_kWh) - self._state.update_total_charged_energy_kWh(-discharge_energy_kWh) + self._state.update_energy_used_for_dis_charging_kWh(time_slot, -discharge_energy_kWh) + self._state.set_auxiliary_energy_kWh(time_slot, evaporator_energy_kWh) def _update_soc(self, time_slot: DateTime, heat_energy_kWh: float): + # heat_energy_kWh can be both positive (charging) and negative (discharging) old_charge = self._state.get_soc(self.last_time_slot(time_slot)) / 100 * self._capacity_kWh new_charge = old_charge + heat_energy_kWh new_soc = new_charge / self._capacity_kWh diff --git a/tests/strategies/test_sortes_heat_pump.py b/tests/strategies/test_sortes_heat_pump.py index adc416c35..aadfcd75b 100644 --- a/tests/strategies/test_sortes_heat_pump.py +++ b/tests/strategies/test_sortes_heat_pump.py @@ -155,6 +155,83 @@ def test_soc_tolerances_taken_from_sortes_configuration(self): == SorTesConfiguration.MAX_SOC_TOLERANCE ) + def test_handle_state_at_soc_limits_charge_at_max_soc_returns_maintain_soc(self): + strategy = self._create_strategy() + strategy._current_state = HeatPumpChargingState.CHARGE + self._energy_params_mock.get_soc = MagicMock( + return_value=SorTesConfiguration.MAX_SOC_TOLERANCE + ) + result = strategy._handle_state_at_soc_limits( + START_TIME_SLOT, HeatPumpChargingState.CHARGE + ) + assert result == HeatPumpChargingState.MAINTAIN_SOC + + def test_handle_state_at_soc_limits_charge_above_max_soc_returns_maintain_soc(self): + strategy = self._create_strategy() + strategy._current_state = HeatPumpChargingState.CHARGE + self._energy_params_mock.get_soc = MagicMock( + return_value=SorTesConfiguration.MAX_SOC_TOLERANCE + 1 + ) + result = strategy._handle_state_at_soc_limits( + START_TIME_SLOT, HeatPumpChargingState.CHARGE + ) + assert result == HeatPumpChargingState.MAINTAIN_SOC + + def test_handle_state_at_soc_limits_discharge_at_min_soc_returns_maintain_soc(self): + strategy = self._create_strategy() + strategy._current_state = HeatPumpChargingState.DISCHARGE + self._energy_params_mock.get_soc = MagicMock( + return_value=SorTesConfiguration.MIN_SOC_TOLERANCE + ) + result = strategy._handle_state_at_soc_limits( + START_TIME_SLOT, HeatPumpChargingState.DISCHARGE + ) + assert result == HeatPumpChargingState.MAINTAIN_SOC + + def test_handle_state_at_soc_limits_discharge_below_min_soc_returns_maintain_soc(self): + strategy = self._create_strategy() + strategy._current_state = HeatPumpChargingState.DISCHARGE + self._energy_params_mock.get_soc = MagicMock( + return_value=SorTesConfiguration.MIN_SOC_TOLERANCE - 1 + ) + result = strategy._handle_state_at_soc_limits( + START_TIME_SLOT, HeatPumpChargingState.DISCHARGE + ) + assert result == HeatPumpChargingState.MAINTAIN_SOC + + def test_handle_state_at_soc_limits_within_bounds_returns_input_state(self): + strategy = self._create_strategy() + strategy._current_state = HeatPumpChargingState.CHARGE + self._energy_params_mock.get_soc = MagicMock(return_value=50.0) + result = strategy._handle_state_at_soc_limits( + START_TIME_SLOT, HeatPumpChargingState.CHARGE + ) + assert result == HeatPumpChargingState.CHARGE + + def test_handle_state_at_soc_limits_max_soc_but_not_charging_preserves_state(self): + # At max SOC but internal state is MAINTAIN_SOC → no override + strategy = self._create_strategy() + strategy._current_state = HeatPumpChargingState.MAINTAIN_SOC + self._energy_params_mock.get_soc = MagicMock( + return_value=SorTesConfiguration.MAX_SOC_TOLERANCE + ) + result = strategy._handle_state_at_soc_limits( + START_TIME_SLOT, HeatPumpChargingState.MAINTAIN_SOC + ) + assert result == HeatPumpChargingState.MAINTAIN_SOC + + def test_handle_state_at_soc_limits_min_soc_but_not_discharging_preserves_state(self): + # At min SOC but internal state is CHARGE → no discharge override fires + strategy = self._create_strategy() + strategy._current_state = HeatPumpChargingState.CHARGE + self._energy_params_mock.get_soc = MagicMock( + return_value=SorTesConfiguration.MIN_SOC_TOLERANCE + ) + result = strategy._handle_state_at_soc_limits( + START_TIME_SLOT, HeatPumpChargingState.CHARGE + ) + assert result == HeatPumpChargingState.CHARGE + class TestSorTesTankState: @@ -196,10 +273,29 @@ def test_set_and_get_max_energy_demand_kWh(self): def test_get_max_energy_demand_kWh_returns_zero_for_unknown_time_slot(self): assert self._state.get_max_energy_demand_kWh(START_TIME_SLOT) == 0.0 - def test_update_total_charged_energy_kWh_accumulates_over_calls(self): - self._state.update_total_charged_energy_kWh(2.0) - self._state.update_total_charged_energy_kWh(3.0) - assert math.isclose(self._state.get_state()["total_charge_energy_kWh"], 5.0) + def test_set_and_get_auxiliary_energy_kWh(self): + self._state.set_auxiliary_energy_kWh(START_TIME_SLOT, 1.25) + result = self._state.get_results_dict(START_TIME_SLOT) + assert math.isclose(result["auxiliary_energy_kWh"], 1.25) + + def test_get_auxiliary_energy_kWh_returns_zero_for_unknown_time_slot(self): + result = self._state.get_results_dict(START_TIME_SLOT) + assert result["auxiliary_energy_kWh"] == 0.0 + + def test_update_and_get_energy_used_for_dis_charging_kWh(self): + self._state.update_energy_used_for_dis_charging_kWh(START_TIME_SLOT, 2.5) + result = self._state.get_results_dict(START_TIME_SLOT) + assert math.isclose(result["energy_used_for_dis_charging_kWh"], 2.5) + + def test_get_energy_used_for_dis_charging_kWh_returns_zero_for_unknown_time_slot(self): + result = self._state.get_results_dict(START_TIME_SLOT) + assert result["energy_used_for_dis_charging_kWh"] == 0.0 + + def test_update_energy_used_for_dis_charging_kWh_supports_negative_values(self): + # discharging stores negative values + self._state.update_energy_used_for_dis_charging_kWh(START_TIME_SLOT, -1.5) + result = self._state.get_results_dict(START_TIME_SLOT) + assert math.isclose(result["energy_used_for_dis_charging_kWh"], -1.5) def test_increase_total_traded_energy_kWh_accumulates_over_calls(self): self._state.increase_total_traded_energy_kWh(4.0) @@ -228,7 +324,6 @@ def test_get_state_contains_all_required_keys(self): "min_energy_demand_kWh", "max_energy_demand_kWh", "total_traded_energy_kWh", - "total_charge_energy_kWh", ): assert key in state @@ -239,7 +334,6 @@ def test_restore_state_round_trips_all_numeric_fields(self): self._state.set_min_energy_demand_kWh(START_TIME_SLOT, 0.5) self._state.set_max_energy_demand_kWh(START_TIME_SLOT, 2.0) self._state.increase_total_traded_energy_kWh(3.0) - self._state.update_total_charged_energy_kWh(2.0) state_dict = self._state.get_state() restored = SorTesTankState() @@ -252,18 +346,20 @@ def test_restore_state_round_trips_all_numeric_fields(self): assert math.isclose(restored.get_max_energy_demand_kWh(START_TIME_SLOT), 2.0) restored_dict = restored.get_state() assert math.isclose(restored_dict["total_traded_energy_kWh"], 3.0) - assert math.isclose(restored_dict["total_charge_energy_kWh"], 2.0) def test_get_results_dict_returns_correct_values(self): self._state.set_cop(START_TIME_SLOT, 3.2) self._state.set_soc(START_TIME_SLOT, 55.0) self._state.set_heat_demand_kJ(START_TIME_SLOT, 100.0) + self._state.update_energy_used_for_dis_charging_kWh(START_TIME_SLOT, 1.0) + self._state.set_auxiliary_energy_kWh(START_TIME_SLOT, 0.5) result = self._state.get_results_dict(START_TIME_SLOT) assert math.isclose(result["cop"], 3.2) assert math.isclose(result["soc"], 55.0) assert math.isclose(result["heat_demand_kJ"], 100.0) assert "total_traded_energy_kWh" in result - assert "total_charge_energy_kWh" in result + assert math.isclose(result["energy_used_for_dis_charging_kWh"], 1.0) + assert math.isclose(result["auxiliary_energy_kWh"], 0.5) def test_delete_past_state_values_clears_all_time_series_attributes(self): self._state.set_soc(START_TIME_SLOT, 50.0) From efe2125c90990ca76ab1c05fb24ab3340c6b1f1b Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Wed, 27 May 2026 15:35:01 +0200 Subject: [PATCH 16/17] GSYE-891: Only log corner case when energy_to_be_bought_for_heat is larger than 0 --- .../models/strategy/heatpump_with_sortes_tank.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py index 0d767ea27..c731079bf 100644 --- a/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py +++ b/src/gsy_e/models/strategy/heatpump_with_sortes_tank.py @@ -473,11 +473,12 @@ def _calc_energy_to_buy_minimum(self, time_slot: DateTime) -> float: if energy_to_be_bought_for_heat < FLOATING_POINT_TOLERANCE < storage_capacity_electric_kWh: # corner case when the demand is lower than the discharging energy - log.warning( - "The heat demand is lower than the discharging energy: %s, %s", - electricity_demand_kWh, - storage_capacity_electric_kWh, - ) + if energy_to_be_bought_for_heat > FLOATING_POINT_TOLERANCE: + log.warning( + "The heat demand is lower than the discharging energy: %s, %s", + electricity_demand_kWh, + storage_capacity_electric_kWh, + ) return electricity_demand_kWh return energy_to_be_bought_for_heat + self._calc_evaporator_electricity_kWh( From 991d9d95e3ab6c48029f3ea7b16f862883da3f63 Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Fri, 29 May 2026 15:29:53 +0200 Subject: [PATCH 17/17] GSYE-891: Change COp value for dry cooler --- src/gsy_e/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gsy_e/constants.py b/src/gsy_e/constants.py index d7d1f2bda..c1d16681b 100644 --- a/src/gsy_e/constants.py +++ b/src/gsy_e/constants.py @@ -119,7 +119,7 @@ class SorTesConfiguration: MAX_SOC_TOLERANCE = 90 CAPACITY_KWH = 25 COP_HEAT_SOURCE = 1 - COP_CONDENSER = 5 # todo: to be updated + COP_CONDENSER = 35 # nominal value for SHSL-D1-005-1x350 COP_EVAPORATOR = 1 CONVERSION_CHARGE_CONDENSER_POWER = 1 / 1.2 CONVERSION_DISCHARGE_EVAPORATOR_POWER = 1 / 1.2