diff --git a/ci/win32/format.cmd b/ci/win32/format.cmd index 8a871a77..be50a1ab 100644 --- a/ci/win32/format.cmd +++ b/ci/win32/format.cmd @@ -5,5 +5,5 @@ pushd . cd /D "%~dp0" cd ..\..\ black ./src/omotes_simulator_core ./unit_test/ -isort --diff ./src/omotes_simulator_core ./unit_test/ +isort ./src/omotes_simulator_core ./unit_test/ popd diff --git a/ci/win32/lint.cmd b/ci/win32/lint.cmd index ed2448a6..d9012829 100644 --- a/ci/win32/lint.cmd +++ b/ci/win32/lint.cmd @@ -4,5 +4,5 @@ rem @echo off pushd . cd /D "%~dp0" cd ..\..\ -flake8 .\src\omotes_simulator_core +flake8 .\src\omotes_simulator_core .\unit_test popd diff --git a/ci/win32/typecheck.cmd b/ci/win32/typecheck.cmd index c7dd28cb..f54cb465 100644 --- a/ci/win32/typecheck.cmd +++ b/ci/win32/typecheck.cmd @@ -4,5 +4,5 @@ cd /D "%~dp0" cd ..\..\ call .\venv\Scripts\activate set PYTHONPATH=.\src\;%$PYTHONPATH% -python -m mypy ./src/ +python -m mypy ./src/omotes_simulator_core ./unit_test/ popd \ No newline at end of file diff --git a/src/omotes_simulator_core/adapter/transforms/controller_mappers/controller_storage_mapper.py b/src/omotes_simulator_core/adapter/transforms/controller_mappers/controller_storage_mapper.py index d1d0c89f..65ca0b19 100644 --- a/src/omotes_simulator_core/adapter/transforms/controller_mappers/controller_storage_mapper.py +++ b/src/omotes_simulator_core/adapter/transforms/controller_mappers/controller_storage_mapper.py @@ -48,8 +48,8 @@ def to_entity(self, esdl_asset: EsdlAssetObject) -> ControllerStorage: charge_power = esdl_asset.get_property( esdl_property_name="maxChargeRate", default_value=np.inf ) - temperature_in = esdl_asset.get_temperature("In", "Return") - temperature_out = esdl_asset.get_temperature("Out", "Supply") + temperature_in = esdl_asset.get_temperature("In", "Supply") + temperature_out = esdl_asset.get_temperature("Out", "Return") profile = pd.DataFrame() # esdl_asset.get_profile() contr_storage = ControllerStorage( name=esdl_asset.esdl_asset.name, diff --git a/src/omotes_simulator_core/adapter/transforms/esdl_asset_mapper.py b/src/omotes_simulator_core/adapter/transforms/esdl_asset_mapper.py index 4eb29cb3..b984643d 100644 --- a/src/omotes_simulator_core/adapter/transforms/esdl_asset_mapper.py +++ b/src/omotes_simulator_core/adapter/transforms/esdl_asset_mapper.py @@ -22,6 +22,9 @@ from omotes_simulator_core.adapter.transforms.esdl_asset_mappers.consumer_mapper import ( EsdlAssetConsumerMapper, ) +from omotes_simulator_core.adapter.transforms.esdl_asset_mappers.heat_buffer_mapper import ( + EsdlAssetHeatBufferMapper, +) from omotes_simulator_core.adapter.transforms.esdl_asset_mappers.heat_pump_mapper import ( EsdlAssetHeatPumpMapper, ) @@ -49,6 +52,8 @@ esdl.Pipe: EsdlAssetPipeMapper, esdl.HeatPump: EsdlAssetHeatPumpMapper, esdl.ATES: EsdlAssetAtesMapper, + esdl.WaterBuffer: EsdlAssetHeatBufferMapper, + esdl.HeatStorage: EsdlAssetHeatBufferMapper, } diff --git a/src/omotes_simulator_core/adapter/transforms/esdl_asset_mappers/heat_buffer_mapper.py b/src/omotes_simulator_core/adapter/transforms/esdl_asset_mappers/heat_buffer_mapper.py new file mode 100644 index 00000000..2dfbfb23 --- /dev/null +++ b/src/omotes_simulator_core/adapter/transforms/esdl_asset_mappers/heat_buffer_mapper.py @@ -0,0 +1,46 @@ +# Copyright (c) 2023. Deltares & TNO +# +# 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 . +"""Module containing the Esdl to HeatBuffer asset mapper class.""" + +from omotes_simulator_core.entities.assets.asset_abstract import AssetAbstract +from omotes_simulator_core.entities.assets.asset_defaults import HEAT_BUFFER_DEFAULTS +from omotes_simulator_core.entities.assets.esdl_asset_object import EsdlAssetObject +from omotes_simulator_core.entities.assets.heat_buffer import HeatBuffer +from omotes_simulator_core.simulation.mappers.mappers import EsdlMapperAbstract + + +class EsdlAssetHeatBufferMapper(EsdlMapperAbstract): + """Class to map an ESDL asset to a HeatBuffer entity class.""" + + def to_esdl(self, entity: HeatBuffer) -> EsdlAssetObject: + """Map a HeatBuffer entity to an EsdlAsset.""" + raise NotImplementedError("EsdlAssetHeatBufferMapper.to_esdl()") + + def to_entity(self, esdl_asset: EsdlAssetObject) -> AssetAbstract: + """Method to map an ESDL asset to a HeatBuffer entity class. + + :param EsdlAssetObject esdl_asset: Object to be converted to a HeatBuffer entity. + :return: Ates object. + """ + heat_buffer_entity = HeatBuffer( + asset_name=esdl_asset.esdl_asset.name, + asset_id=esdl_asset.esdl_asset.id, + port_ids=esdl_asset.get_port_ids(), + volume=esdl_asset.get_property( + esdl_property_name="volume", default_value=HEAT_BUFFER_DEFAULTS.volume + ), + ) + + return heat_buffer_entity diff --git a/src/omotes_simulator_core/adapter/transforms/string_to_esdl.py b/src/omotes_simulator_core/adapter/transforms/string_to_esdl.py index d420455f..7b013449 100644 --- a/src/omotes_simulator_core/adapter/transforms/string_to_esdl.py +++ b/src/omotes_simulator_core/adapter/transforms/string_to_esdl.py @@ -38,6 +38,8 @@ class StringEsdlAssetMapper: esdl.Joint: "joint", esdl.ATES: "storage", esdl.HeatPump: "pump", + esdl.HeatStorage: "storage", + esdl.WaterBuffer: "storage", esdl.HeatExchange: "heat_exchanger", } diff --git a/src/omotes_simulator_core/entities/assets/asset_abstract.py b/src/omotes_simulator_core/entities/assets/asset_abstract.py index f9ce9ea9..d01a4f45 100644 --- a/src/omotes_simulator_core/entities/assets/asset_abstract.py +++ b/src/omotes_simulator_core/entities/assets/asset_abstract.py @@ -16,6 +16,7 @@ """Abstract class for asset.""" from abc import ABC, abstractmethod +from datetime import datetime from pandas import DataFrame, concat @@ -65,6 +66,7 @@ def __init__(self, asset_name: str, asset_id: str, connected_ports: list[str]) - self.connected_ports = connected_ports self.outputs = [[] for _ in range(len(self.connected_ports))] self.time_step: float = 3600 # s + self.time = datetime.now() def __repr__(self) -> str: """Method to print string with the name of the asset.""" @@ -146,6 +148,13 @@ def set_time_step(self, time_step: float) -> None: """ self.time_step = time_step + def set_time(self, time: datetime) -> None: + """Placeholder to set the time for the asset. + + :param float time: The time to set for the asset. + """ + self.time = time + def is_converged(self) -> bool: """Check if the asset has converged. diff --git a/src/omotes_simulator_core/entities/assets/asset_defaults.py b/src/omotes_simulator_core/entities/assets/asset_defaults.py index c3accc3e..f10edd46 100644 --- a/src/omotes_simulator_core/entities/assets/asset_defaults.py +++ b/src/omotes_simulator_core/entities/assets/asset_defaults.py @@ -71,6 +71,14 @@ class AtesDefaults: maximum_flow_discharge: float = 200.0 # m3/h +@dataclass +class HeatBufferDefaults: + """Class containing the default values for Heat Buffer.""" + + volume: float = 1 # m3 + fill_level: float = 0.5 # fraction 0-1 + + @dataclass class HeatPumpDefaults: """Class containing the default values for a heat pump. @@ -113,6 +121,8 @@ class HeatExchangerDefaults: PROPERTY_DIAMETER = "diameter" PROPERTY_ROUGHNESS = "roughness" PROPERTY_ALPHA_VALUE = "alpha_value" +PROPERTY_VOLUME = "volume" +PROPERTY_FILL_LEVEL = "fill_level" PROPERTY_PRESSURE_LOSS = "pressure_loss" PROPERTY_PRESSURE_LOSS_PER_LENGTH = "pressure_loss_per_length" PROPERTY_HEAT_LOSS = "heat_loss" @@ -125,3 +135,4 @@ class HeatExchangerDefaults: # Static members PIPE_DEFAULTS = PipeDefaults() ATES_DEFAULTS = AtesDefaults() +HEAT_BUFFER_DEFAULTS = HeatBufferDefaults() diff --git a/src/omotes_simulator_core/entities/assets/controller/controller_storage.py b/src/omotes_simulator_core/entities/assets/controller/controller_storage.py index cbcca453..c3bdbe80 100644 --- a/src/omotes_simulator_core/entities/assets/controller/controller_storage.py +++ b/src/omotes_simulator_core/entities/assets/controller/controller_storage.py @@ -49,6 +49,8 @@ def __init__( self.temperature_out = temperature_out self.profile: pd.DataFrame = profile self.start_index = 0 + + # Theoretical maximum charge and discharge power of the storage. self.max_charge_power: float = max_charge_power self.max_discharge_power: float = max_discharge_power @@ -58,21 +60,28 @@ def get_heat_power(self, time: datetime.datetime) -> float: :param datetime.datetime time: Time for which to get the heat demand. :return: float with the heat demand. """ + # Check if the selected time is in the profile. + # TODO: Current implementation loops over the entire profile; should be improved! + # TODO: Unclear why there is a timestep of 1 hour in the profile. for index in range(self.start_index, len(self.profile)): if abs((self.profile["date"][index].to_pydatetime() - time).total_seconds()) < 3600: self.start_index = index if self.profile["values"][index] > self.max_charge_power: logging.warning( - f"Storage of {self.name} is higher than maximum charge power of asset" - f" at time {time}." + "Storage of %s is higher than maximum charge power of asset at time %s.", + self.name, + time, ) return self.max_charge_power elif self.profile["values"][index] < self.max_discharge_power: logging.warning( - f"Storage of {self.name} is higher than maximum discharge power of asset" - f" at time {time}." + "Storage of %s is higher than maximum discharge power of asset at time %s.", + self.name, + time, ) return self.max_discharge_power else: return float(self.profile["values"][index]) - return 0 + # TODO: The loop is not complete as the asset also has a fill-level that should not + # surpass the maximum fill-level. + return 0.0 diff --git a/src/omotes_simulator_core/entities/assets/heat_buffer.py b/src/omotes_simulator_core/entities/assets/heat_buffer.py new file mode 100644 index 00000000..b6b6be69 --- /dev/null +++ b/src/omotes_simulator_core/entities/assets/heat_buffer.py @@ -0,0 +1,250 @@ +# Copyright (c) 2023. Deltares & TNO +# +# 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 . + +"""Heat Buffer class.""" +from cmath import isinf +from datetime import datetime +from typing import Dict + +import numpy as np +from scipy.integrate import solve_ivp + +from omotes_simulator_core.entities.assets.asset_abstract import AssetAbstract +from omotes_simulator_core.entities.assets.asset_defaults import ( + DEFAULT_TEMPERATURE, + PROPERTY_HEAT_DEMAND, + PROPERTY_MASSFLOW, + PROPERTY_PRESSURE_RETURN, + PROPERTY_PRESSURE_SUPPLY, + PROPERTY_TEMPERATURE_IN, + PROPERTY_TEMPERATURE_OUT, +) +from omotes_simulator_core.entities.assets.esdl_asset_object import EsdlAssetObject +from omotes_simulator_core.entities.assets.utils import ( + heat_demand_and_temperature_to_mass_flow, +) +from omotes_simulator_core.solver.network.assets.production_asset import HeatBoundary +from omotes_simulator_core.solver.utils.fluid_properties import fluid_props + + +class HeatBuffer(AssetAbstract): + """A HeatBuffer represents an asset that stores heat. Thus, it has the possibility to supply \ + heat or consume heat for storage.""" + + temperature_supply: float + """The supply temperature of the asset [K].""" + + temperature_return: float + """The return temperature of the asset [K].""" + + thermal_power_allocation: float + """The thermal for injection (positive) or production (negative) by the asset [W].""" + + mass_flowrate: float + """The flow rate going in or out by the asset [kg/s].""" + + volume: float + """The volume of the heat storage [m3].""" + + energy: float + """The stored energy in the storage [Wh].""" + + def __init__( + self, + asset_name: str, + asset_id: str, + port_ids: list[str], + volume: float, + ) -> None: + """Initialize a HeatBuffer object. + + :param str asset_name: The name of the asset. + :param str asset_id: The unique identifier of the asset. + """ + super().__init__(asset_name=asset_name, asset_id=asset_id, connected_ports=port_ids) + + # Supply and return temperature of the asset [K] + self.temperature_in = DEFAULT_TEMPERATURE + self.temperature_out = DEFAULT_TEMPERATURE + + # Volume properties of the asset: volume [m3], fill level [fraction 0-1] + self.tank_volume = volume + self.num_layer = 5 + self.layer_volume = self.tank_volume / self.num_layer + self.density = fluid_props.get_density((self.temperature_in + self.temperature_out) / 2) + self.layer_mass = self.layer_volume * self.density + self.layer_temperature = np.ones(self.num_layer) * (DEFAULT_TEMPERATURE) + + # Thermal power allocation [W] for injection (positive) or production (negative) by the + # asset and mass flowrate [kg/s] going in or out by the asset + self.thermal_power_allocation = 0 + self.mass_flowrate = 0 + self.solver_asset = HeatBoundary(name=self.name, _id=self.asset_id) + + self.output: list = [] + self.first_time_step = True + self.energy_stored = 0.0 + self.current_time = datetime.now() + + def set_setpoints(self, setpoints: Dict) -> None: + """Placeholder to set the setpoints of an asset prior to a simulation. + + :param Dict setpoints: The setpoints that should be set for the asset. + The keys of the dictionary are the names of the setpoints and the values are the values + """ + # don't do any calculation if the time is still the same to avoid storage's state problem + if self.current_time == self.time: + return + self.current_time = self.time + # Default keys required + necessary_setpoints = { + PROPERTY_TEMPERATURE_IN, + PROPERTY_TEMPERATURE_OUT, + PROPERTY_HEAT_DEMAND, + } + # Dict to set + setpoints_set = set(setpoints.keys()) + # Check if all setpoints are in the setpoints + if necessary_setpoints.issubset(setpoints_set): + # negative is charging and positive is discharging + self.thermal_power_allocation = -setpoints[PROPERTY_HEAT_DEMAND] + + if self.first_time_step: + self.temperature_in = setpoints[PROPERTY_TEMPERATURE_IN] + self.temperature_out = setpoints[PROPERTY_TEMPERATURE_OUT] + self.first_time_step = False + else: + # After the first time step: use solver temperature + if self.thermal_power_allocation >= 0: + self.temperature_in = setpoints[PROPERTY_TEMPERATURE_IN] + self.temperature_out = self.solver_asset.get_temperature(1) + else: + self.temperature_in = self.solver_asset.get_temperature(0) + self.temperature_out = setpoints[PROPERTY_TEMPERATURE_OUT] + + self._calculate_massflowrate() + self._calculate_new_temperature() + self._set_solver_asset_setpoint() + else: + # Print missing setpoints + raise ValueError( + f"The setpoints {necessary_setpoints.difference(setpoints_set)} are missing." + ) + + def _calculate_massflowrate(self) -> None: + """Calculate mass flowrate of the asset.""" + self.mass_flowrate = heat_demand_and_temperature_to_mass_flow( + self.thermal_power_allocation, self.temperature_in, self.temperature_out + ) + if isinf(self.mass_flowrate): + self.mass_flowrate = 0 + + def _calculate_new_temperature(self) -> None: + """Calculate new temperature of the tank storage.""" + + def tank_ode_charge(t: float, T: np.ndarray) -> np.ndarray: + """ODE 1D tank storage for charging. + + T: array of layer temperatures [°K] + Returns: dT/dt for each layer + """ + dTdt = np.zeros_like(T) + + frac = abs(self.mass_flowrate) / (self.density * self.layer_volume) + + # --- Layer 0 (top layer) mixes with inlet flow --- + dTdt[0] = frac * (self.temperature_in - T[0]) + + # --- Remaining layers: plug-flow approximation --- + for i in range(1, self.num_layer): + dTdt[i] = frac * (T[i - 1] - T[i]) + return dTdt + + def tank_ode_discharge(t: float, T: np.ndarray) -> np.ndarray: + """ODE 1D tank storage for discharging. + + T: array of layer temperatures [°K] + Returns: dT/dt for each layer + """ + dTdt = np.zeros_like(T) + + frac = abs(self.mass_flowrate) / (self.density * self.layer_volume) + + # --- Layer -1 (bottom layer) mixes with inlet flow --- + dTdt[-1] = frac * (self.temperature_out - T[-1]) + + # --- Remaining layers: plug-flow approximation --- + for i in reversed(range(0, self.num_layer - 1)): + dTdt[i] = frac * (T[i + 1] - T[i]) + return dTdt + + if self.mass_flowrate >= 0: + + sol = solve_ivp( + tank_ode_charge, (0, self.time_step), self.layer_temperature, method="RK45" + ) + for i in range(self.num_layer): + self.layer_temperature[i] = sol.y[i][-1] + + self.temperature_out = float(self.layer_temperature[-1]) + + else: + sol = solve_ivp( + tank_ode_discharge, (0, self.time_step), self.layer_temperature, method="RK45" + ) + for i in range(self.num_layer): + self.layer_temperature[i] = sol.y[i][-1] + + self.temperature_in = float(self.layer_temperature[0]) + + self.energy_stored = ( + self.energy_stored + + self.mass_flowrate + * self.time_step + / 3600 + * 4180 + * (self.temperature_in - self.temperature_out) + ) + + def _set_solver_asset_setpoint(self) -> None: + """Set the setpoint of solver asset.""" + if self.mass_flowrate >= 0: + self.solver_asset.supply_temperature = self.temperature_out + else: + self.solver_asset.supply_temperature = self.temperature_in + + self.solver_asset.mass_flow_rate_set_point = self.mass_flowrate # type: ignore + + def add_physical_data(self, esdl_asset: EsdlAssetObject) -> None: + """Method to add physical data to the asset. + + :param EsdlAssetObject esdl_asset: The esdl asset object to add the physical data from. + :return: + """ + + def write_to_output(self) -> None: + """Placeholder to write the asset to the output. + + The output list is a list of dictionaries, where each dictionary + represents the output of its asset for a specific timestep. + """ + output_dict = { + PROPERTY_MASSFLOW: self.solver_asset.get_mass_flow_rate(1), + PROPERTY_PRESSURE_SUPPLY: self.solver_asset.get_pressure(0), + PROPERTY_PRESSURE_RETURN: self.solver_asset.get_pressure(1), + PROPERTY_TEMPERATURE_IN: self.solver_asset.get_temperature(0), + PROPERTY_TEMPERATURE_OUT: self.solver_asset.get_temperature(1), + } + self.output.append(output_dict) diff --git a/src/omotes_simulator_core/entities/heat_network.py b/src/omotes_simulator_core/entities/heat_network.py index bf9130fe..886c431a 100644 --- a/src/omotes_simulator_core/entities/heat_network.py +++ b/src/omotes_simulator_core/entities/heat_network.py @@ -55,6 +55,7 @@ def run_time_step( for py_asset in self.assets: if py_asset.asset_id in controller_input: py_asset.set_time_step(time_step) + py_asset.set_time(time) py_asset.set_setpoints(controller_input[py_asset.asset_id]) self.solver.solve() diff --git a/src/omotes_simulator_core/entities/network_controller.py b/src/omotes_simulator_core/entities/network_controller.py index 0ebda582..ac9563de 100644 --- a/src/omotes_simulator_core/entities/network_controller.py +++ b/src/omotes_simulator_core/entities/network_controller.py @@ -182,7 +182,6 @@ def get_total_discharge_storage(self) -> float: :return float: Total heat discharge of all storages. """ - # TODO add limit based on state of charge return float(sum([storage.max_discharge_power for storage in self.storages])) def get_total_charge_storage(self) -> float: diff --git a/testdata/test_heat_buffer.esdl b/testdata/test_heat_buffer.esdl new file mode 100644 index 00000000..924bb26a --- /dev/null +++ b/testdata/test_heat_buffer.esdl @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unit_test/adapters/transforms/controller_mappers/test_controller_storage_mapper.py b/unit_test/adapters/transforms/controller_mappers/test_controller_storage_mapper.py index e78f2be6..92c4db5d 100644 --- a/unit_test/adapters/transforms/controller_mappers/test_controller_storage_mapper.py +++ b/unit_test/adapters/transforms/controller_mappers/test_controller_storage_mapper.py @@ -47,7 +47,7 @@ def test_to_entity_method(self): controller_storage = self.mapper.to_entity(storage_assets[0]) # Assert - self.assertEqual(controller_storage.temperature_out, 353.15) - self.assertEqual(controller_storage.temperature_in, 313.15) + self.assertEqual(controller_storage.temperature_in, 353.15) + self.assertEqual(controller_storage.temperature_out, 313.15) self.assertEqual(controller_storage.max_charge_power, 11.61e6) self.assertEqual(controller_storage.max_discharge_power, 11.61e6) diff --git a/unit_test/entities/test_heat_buffer.py b/unit_test/entities/test_heat_buffer.py new file mode 100644 index 00000000..5e1fa1d2 --- /dev/null +++ b/unit_test/entities/test_heat_buffer.py @@ -0,0 +1,85 @@ +# Copyright (c) 2023. Deltares & TNO +# +# 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 . + +"""Test Heat Buffer entities.""" +import unittest +from datetime import datetime + +import numpy as np + +from omotes_simulator_core.entities.assets.asset_defaults import ( + PROPERTY_HEAT_DEMAND, + PROPERTY_TEMPERATURE_IN, + PROPERTY_TEMPERATURE_OUT, +) +from omotes_simulator_core.entities.assets.heat_buffer import HeatBuffer + + +class HeatBufferTest(unittest.TestCase): + """Testcase for HeatBuffer class.""" + + def setUp(self) -> None: + """Set up before each test case.""" + # Create a production cluster object + self.heat_buffer = HeatBuffer( + asset_name="heat_buffer", + asset_id="heat_buffer_id", + port_ids=["test1", "test2"], + volume=1, + ) + + def test_injection(self) -> None: + """Test injection to Heat Buffer.""" + # Arrange + setpoints = { + PROPERTY_HEAT_DEMAND: 5e3, + PROPERTY_TEMPERATURE_IN: 85 + 273.15, + PROPERTY_TEMPERATURE_OUT: 25 + 273.15, + } + + # Act + # charging for 1 day + for _ii in range(0, 24): + self.heat_buffer.first_time_step = True + self.heat_buffer.set_time(datetime(2023, 1, 1, _ii, 0, 0)) + self.heat_buffer.set_setpoints(setpoints=setpoints) + + # Assert + self.assertAlmostEqual(self.heat_buffer.layer_temperature[0], 358.14, delta=0.1) + self.assertAlmostEqual(self.heat_buffer.layer_temperature[-1], 354.16, delta=0.1) + + def test_production(self) -> None: + """Test production from Heat Buffer.""" + # Arrange + setpoints = { + PROPERTY_HEAT_DEMAND: -5e3, + PROPERTY_TEMPERATURE_IN: 85 + 273.15, + PROPERTY_TEMPERATURE_OUT: 25 + 273.15, + } + + self.heat_buffer.layer_temperature = np.array( + [358.14869781, 358.13745031, 358.08685218, 357.92903466, 357.54565629] + ) + + # Act + # discharging for 1 day + for _ii in range(0, 24): + self.heat_buffer.first_time_step = True + self.heat_buffer.set_time(datetime(2023, 1, 1, _ii, 0, 0)) + self.heat_buffer.set_setpoints(setpoints=setpoints) + + # Assert + self.assertAlmostEqual(self.heat_buffer.layer_temperature[0], 302.23, delta=0.1) + self.assertAlmostEqual(self.heat_buffer.layer_temperature[-1], 298.16, delta=0.1)