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)