diff --git a/src/omotes_simulator_core/adapter/transforms/controller_mapper.py b/src/omotes_simulator_core/adapter/transforms/controller_mapper.py index 8fdfaaa3..13865607 100644 --- a/src/omotes_simulator_core/adapter/transforms/controller_mapper.py +++ b/src/omotes_simulator_core/adapter/transforms/controller_mapper.py @@ -243,7 +243,7 @@ def heat_transfer_assets_to_network( belongs = False for network in network_list: if belongs_to_network(heat_transfer_asset.id + "_secondary", network, graph): - network.heat_transfer_primary.append(heat_transfer_asset) + network.heat_transfer_secondary.append(heat_transfer_asset) belongs = True break if not belongs: diff --git a/src/omotes_simulator_core/adapter/transforms/controller_mappers/controller_heat_transfer_mapper.py b/src/omotes_simulator_core/adapter/transforms/controller_mappers/controller_heat_transfer_mapper.py index 93ebd9c7..829cadf1 100644 --- a/src/omotes_simulator_core/adapter/transforms/controller_mappers/controller_heat_transfer_mapper.py +++ b/src/omotes_simulator_core/adapter/transforms/controller_mappers/controller_heat_transfer_mapper.py @@ -24,6 +24,7 @@ ) from omotes_simulator_core.entities.assets.controller.controller_heat_transfer import ( ControllerHeatTransferAsset, + HeatTransferAssetType, ) from omotes_simulator_core.entities.assets.esdl_asset_object import EsdlAssetObject from omotes_simulator_core.simulation.mappers.mappers import EsdlMapperAbstract @@ -46,10 +47,15 @@ def to_entity(self, esdl_asset: EsdlAssetObject) -> ControllerHeatTransferAsset: coefficient_of_performance = esdl_asset.get_property( esdl_property_name="COP", default_value=HeatPumpDefaults.coefficient_of_performance ) + max_electrical_power = esdl_asset.get_property( + esdl_property_name="power", default_value=None + ) contr_heat_transfer = ControllerHeatTransferAsset( name=esdl_asset.esdl_asset.name, identifier=esdl_asset.esdl_asset.id, factor=coefficient_of_performance, + heat_transfer_type=HeatTransferAssetType.HEAT_PUMP, + max_electrical_power=max_electrical_power, ) return contr_heat_transfer @@ -76,5 +82,6 @@ def to_entity(self, esdl_asset: EsdlAssetObject) -> ControllerHeatTransferAsset: name=esdl_asset.esdl_asset.name, identifier=esdl_asset.esdl_asset.id, factor=coefficient_of_performance, + heat_transfer_type=HeatTransferAssetType.HEAT_EXCHANGER, ) return contr_heat_transfer diff --git a/src/omotes_simulator_core/adapter/transforms/esdl_asset_mappers/heat_pump_mapper.py b/src/omotes_simulator_core/adapter/transforms/esdl_asset_mappers/heat_pump_mapper.py index 240f3e0d..27a3ba91 100644 --- a/src/omotes_simulator_core/adapter/transforms/esdl_asset_mappers/heat_pump_mapper.py +++ b/src/omotes_simulator_core/adapter/transforms/esdl_asset_mappers/heat_pump_mapper.py @@ -44,6 +44,7 @@ def to_entity(self, esdl_asset: EsdlAssetObject) -> AssetAbstract: coefficient_of_performance=esdl_asset.get_property( "COP", HeatPumpDefaults.coefficient_of_performance ), + maximum_electrical_power=esdl_asset.get_property("power", None), ) elif hp_ports == 2: # Air to water heat pump case. heatpump_entity = AirToWaterHeatPump( # type:ignore diff --git a/src/omotes_simulator_core/entities/assets/asset_defaults.py b/src/omotes_simulator_core/entities/assets/asset_defaults.py index d5b2eb91..da075063 100644 --- a/src/omotes_simulator_core/entities/assets/asset_defaults.py +++ b/src/omotes_simulator_core/entities/assets/asset_defaults.py @@ -88,10 +88,14 @@ class AtesDefaults: class HeatPumpDefaults: """Class containing the default values for a heat pump. + The Coefficient of Performance (COP) is defined as the ratio of heat output to electricity + input: COP = Q_heat_output / electricity_in. For example, a COP of 4 means 1 unit of electricity + produces 4 units of heat by extracting 3 units from the source. + :param float coefficient_of_performance: The coefficient of performance of the heat pump [-]. """ - coefficient_of_performance: float = 1 - 1 / 4.0 + coefficient_of_performance: float = 4.0 @dataclass diff --git a/src/omotes_simulator_core/entities/assets/controller/controller_heat_transfer.py b/src/omotes_simulator_core/entities/assets/controller/controller_heat_transfer.py index f7ce1e68..dc81aa9d 100644 --- a/src/omotes_simulator_core/entities/assets/controller/controller_heat_transfer.py +++ b/src/omotes_simulator_core/entities/assets/controller/controller_heat_transfer.py @@ -14,6 +14,8 @@ # along with this program. If not, see . """Module containing the class for a heat trasnfer asset.""" +from enum import Enum + import numpy as np from omotes_simulator_core.entities.assets.asset_defaults import ( @@ -29,17 +31,42 @@ ) +class HeatTransferAssetType(Enum): + """Enum to distinguish heat transfer asset types.""" + + HEAT_PUMP = "heat_pump" + HEAT_EXCHANGER = "heat_exchanger" + + class ControllerHeatTransferAsset(AssetControllerAbstract): """Class for controlling a heat transfer asset.""" - def __init__(self, name: str, identifier: str, factor: float): + heat_transfer_type: HeatTransferAssetType + """Type of heat transfer asset (heat pump or heat exchanger).""" + + max_electrical_power: float | None + """Maximum electrical power of the heat pump [W]. None means no limit.""" + + def __init__( + self, + name: str, + identifier: str, + factor: float, + heat_transfer_type: HeatTransferAssetType, + max_electrical_power: float | None = None, + ): """Constructor of the class, which sets all attributes. :param str name: Name of the consumer. :param str identifier: Unique identifier of the consumer. + :param float factor: The COP (heat pump) or efficiency (heat exchanger) factor. + :param HeatTransferAssetType heat_transfer_type: Type of heat transfer asset. + :param float | None max_electrical_power: Maximum electrical power for heat pump [W]. """ super().__init__(name, identifier) self.factor = factor + self.heat_transfer_type = heat_transfer_type + self.max_electrical_power = max_electrical_power def set_asset(self, heat_demand: float) -> dict[str, dict[str, float]]: """Method to set the asset to the given heat demand. diff --git a/src/omotes_simulator_core/entities/assets/heat_pump.py b/src/omotes_simulator_core/entities/assets/heat_pump.py index 6f298ab4..62ce88dd 100644 --- a/src/omotes_simulator_core/entities/assets/heat_pump.py +++ b/src/omotes_simulator_core/entities/assets/heat_pump.py @@ -29,6 +29,7 @@ PROPERTY_TEMPERATURE_IN, PROPERTY_TEMPERATURE_OUT, SECONDARY, + HeatPumpDefaults, ) from omotes_simulator_core.entities.assets.utils import heat_demand_and_temperature_to_mass_flow from omotes_simulator_core.solver.network.assets.heat_transfer_asset import HeatTransferAsset @@ -66,18 +67,49 @@ class HeatPump(AssetAbstract): coefficient_of_performance: float """Coefficient of perfomance for the heat pump.""" + maximum_electrical_power: float | None + """Maximum electrical input power of the heat pump [W]. """ + + _heat_demand_secondary_capped: float | None + """Capped secondary heat demand setpoint [W].""" + def __init__( self, asset_name: str, asset_id: str, connected_ports: list[str], - coefficient_of_performance: float = 1 - 1 / 4.0, + coefficient_of_performance: float = HeatPumpDefaults.coefficient_of_performance, + maximum_electrical_power: float | None = None, ) -> None: - """Initialize a new HeatPump instance. + r"""Initialize a new HeatPump instance. + + The heat transfer coefficient of the heat pump is derived from the coefficient of + performance using the relationship between the cold side (primary side) and hot + side (secondary side). + + **Relationship between the COP and the heat transfer coefficient as defined in the heat + transfer asset:** + + Starting from the energy balance and COP definition: + + .. math:: + + Q_{hot} = W_{el} + Q_{cold}\\ + c = \frac{Q_{cold}}{Q_{hot}} \\ + W_{el} = \frac{Q_{hot}}{COP}\\ + Q_{hot} = \frac{Q_{hot}}{COP} + Q_{cold}\\ + 1 = \frac{1}{COP} + c\\ + c = 1 - \frac{1}{COP} + + The dimensionless ratio :math:`c = Q_{cold}/Q_{hot}` is used in the heat transfer asset as + the heat transfer coefficient to couple the primary and secondary sides. + :param asset_name: The name of the asset. :param asset_id: The unique identifier of the asset. :connected_ports: The unique identifiers of the ports of the asset. + :param coefficient_of_performance: The COP of the heat pump [-]. + :param maximum_electrical_power: Maximum electrical input power [W]. """ super().__init__( asset_name=asset_name, @@ -86,6 +118,9 @@ def __init__( ) # Set the coefficient of performance self.coefficient_of_performance = coefficient_of_performance + self.maximum_electrical_power = maximum_electrical_power + self._heat_demand_secondary_capped = None + self._power_cap_warning_logged = False # Define solver asset self.solver_asset = HeatTransferAsset( @@ -93,7 +128,7 @@ def __init__( _id=self.asset_id, pre_scribe_mass_flow_secondary=False, pressure_set_point_secondary=DEFAULT_PRESSURE, - heat_transfer_coefficient=self.coefficient_of_performance, + heat_transfer_coefficient=1.0 - 1.0 / self.coefficient_of_performance, ) def _set_setpoints_secondary(self, setpoints_secondary: Dict) -> None: @@ -124,8 +159,27 @@ def _set_setpoints_secondary(self, setpoints_secondary: Dict) -> None: # Assign setpoints to the HeatPump asset self.temperature_in_secondary = setpoints_secondary[SECONDARY + PROPERTY_TEMPERATURE_IN] self.temperature_out_secondary = setpoints_secondary[SECONDARY + PROPERTY_TEMPERATURE_OUT] + heat_demand_secondary = setpoints_secondary[SECONDARY + PROPERTY_HEAT_DEMAND] + + # Limit heat demand based on maximum electrical power cap + if self.maximum_electrical_power is not None: + required_electric_power = heat_demand_secondary / self.coefficient_of_performance + if abs(required_electric_power) > self.maximum_electrical_power: + capped_heat_demand_secondary = ( + self.maximum_electrical_power * self.coefficient_of_performance + ) + if not self._power_cap_warning_logged: + logger.warning( + f"Maximum electrical power exceeded for heat pump: {self.name}. " + f"Maximum electrical power of {self.maximum_electrical_power} W is used. ", + extra={"esdl_object_id": self.asset_id}, + ) + self._power_cap_warning_logged = True + heat_demand_secondary = -capped_heat_demand_secondary + self._heat_demand_secondary_capped = capped_heat_demand_secondary + self.mass_flow_secondary = heat_demand_and_temperature_to_mass_flow( - thermal_demand=setpoints_secondary[SECONDARY + PROPERTY_HEAT_DEMAND], + thermal_demand=heat_demand_secondary, temperature_in=self.temperature_in_secondary, temperature_out=self.temperature_out_secondary, ) @@ -190,10 +244,15 @@ def set_setpoints(self, setpoints: Dict) -> None: :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 """ - # Set the setpoints for the primary side of the heat pump - self._set_setpoints_primary(setpoints_primary=setpoints) - # Set the setpoints for the secondary side of the heat pump + # Set the secondary side first to apply electrical power capping if necessary. self._set_setpoints_secondary(setpoints_secondary=setpoints) + # Set primary setpoint based on capped secondary side using c=Q_cold/Q_hot = 1 - 1/COP. + setpoints_primary = dict(setpoints) + if self._heat_demand_secondary_capped is not None: + setpoints_primary[PRIMARY + PROPERTY_HEAT_DEMAND] = ( + self._heat_demand_secondary_capped * (1.0 - 1.0 / self.coefficient_of_performance) + ) + self._set_setpoints_primary(setpoints_primary=setpoints_primary) def write_to_output(self) -> None: """Get output power and electricity consumption of the asset. @@ -227,4 +286,6 @@ def postprocess(self) -> None: :return: None """ - pass + # Reset the power cap warning flag and heat demand cap for the next time step + self._power_cap_warning_logged = False + self._heat_demand_secondary_capped = None diff --git a/src/omotes_simulator_core/entities/network_controller.py b/src/omotes_simulator_core/entities/network_controller.py index 7e5d5392..1e647097 100644 --- a/src/omotes_simulator_core/entities/network_controller.py +++ b/src/omotes_simulator_core/entities/network_controller.py @@ -23,6 +23,9 @@ PROPERTY_TEMPERATURE_IN, PROPERTY_TEMPERATURE_OUT, ) +from omotes_simulator_core.entities.assets.controller.controller_heat_transfer import ( + HeatTransferAssetType, +) from omotes_simulator_core.entities.assets.controller.controller_network import ControllerNetwork from omotes_simulator_core.entities.heat_network import HeatNetwork from omotes_simulator_core.entities.network_controller_abstract import NetworkControllerAbstract @@ -162,6 +165,30 @@ def update_setpoints(self, time: datetime.datetime) -> dict: for storage in network.storages: total_heat_supply -= asset_setpoints[storage.id][PROPERTY_HEAT_DEMAND] + for asset in network.heat_transfer_assets_sec: + if ( + asset.heat_transfer_type == HeatTransferAssetType.HEAT_PUMP + and asset.max_electrical_power is not None + ): + max_secondary = asset.max_electrical_power * asset.factor + requested_secondary = abs(total_heat_supply) + if requested_secondary > max_secondary: + # Scale down consumers in this network proportionally + scale_factor = max_secondary / requested_secondary + for consumer in network.consumers: + if consumer.id in asset_setpoints: + current = asset_setpoints[consumer.id][PROPERTY_HEAT_DEMAND] + scaled = current * scale_factor + asset_setpoints[consumer.id][PROPERTY_HEAT_DEMAND] = scaled + # Recalculate total_heat_supply after scaling + total_heat_supply = 0 + for producer in network.producers: + total_heat_supply -= asset_setpoints[producer.id][PROPERTY_HEAT_DEMAND] + for consumer in network.consumers: + total_heat_supply -= asset_setpoints[consumer.id][PROPERTY_HEAT_DEMAND] + for storage in network.storages: + total_heat_supply -= asset_setpoints[storage.id][PROPERTY_HEAT_DEMAND] + # this might look weird, but we know there is only one primary or secondary asset. # So we can directly set it. for asset in network.heat_transfer_assets_prim: diff --git a/src/omotes_simulator_core/solver/network/assets/heat_transfer_asset.py b/src/omotes_simulator_core/solver/network/assets/heat_transfer_asset.py index 8f17d98f..1dd60dc7 100644 --- a/src/omotes_simulator_core/solver/network/assets/heat_transfer_asset.py +++ b/src/omotes_simulator_core/solver/network/assets/heat_transfer_asset.py @@ -691,12 +691,9 @@ def get_electric_power_consumption(self) -> float: """Calculate the electric power consumption of the heat transfer asset. The electric power consumption is calculated as the absolute difference between the - heat power on the primary and secondary side, divided by the heat transfer coefficient. + heat power on the primary and secondary side. :return: float The electric power consumption of the heat transfer asset. """ - return ( - abs(self.get_heat_power_primary() - self.get_heat_power_secondary()) - / self.heat_transfer_coefficient - ) + return abs(abs(self.get_heat_power_primary()) - abs(self.get_heat_power_secondary())) diff --git a/unit_test/entities/controller/test_controller_new_class.py b/unit_test/entities/controller/test_controller_new_class.py index aaf724d7..8cef8290 100644 --- a/unit_test/entities/controller/test_controller_new_class.py +++ b/unit_test/entities/controller/test_controller_new_class.py @@ -25,6 +25,7 @@ from omotes_simulator_core.entities.assets.controller.controller_consumer import ControllerConsumer from omotes_simulator_core.entities.assets.controller.controller_heat_transfer import ( ControllerHeatTransferAsset, + HeatTransferAssetType, ) from omotes_simulator_core.entities.assets.controller.controller_network import ControllerNetwork from omotes_simulator_core.entities.assets.controller.controller_producer import ControllerProducer @@ -171,9 +172,17 @@ def setup_update_set_points(self): self.storage1.temperature_in = 40 # Create heat transfer assets - heatpump = ControllerHeatTransferAsset(name="heatpump1", identifier="heatpump1", factor=5.0) + heatpump = ControllerHeatTransferAsset( + name="heatpump1", + identifier="heatpump1", + factor=5.0, + heat_transfer_type=HeatTransferAssetType.HEAT_PUMP, + ) heatpump2 = ControllerHeatTransferAsset( - name="heatpump2", identifier="heatpump2", factor=1.0 + name="heatpump2", + identifier="heatpump2", + factor=1.0, + heat_transfer_type=HeatTransferAssetType.HEAT_PUMP, ) # Build basic network structure diff --git a/unit_test/entities/test_heat_pump.py b/unit_test/entities/test_heat_pump.py index c491964f..8d6cbd76 100644 --- a/unit_test/entities/test_heat_pump.py +++ b/unit_test/entities/test_heat_pump.py @@ -57,7 +57,7 @@ def setUp(self) -> None: self.heat_pump.solver_asset.get_index_matrix( property_name="internal_energy", connection_point=0, use_relative_indexing=False ) - ] = 5.0 + ] = 10.0 self.heat_pump.solver_asset.prev_sol[ self.heat_pump.solver_asset.get_index_matrix( property_name="mass_flow_rate", connection_point=0, use_relative_indexing=False @@ -68,7 +68,7 @@ def setUp(self) -> None: self.heat_pump.solver_asset.get_index_matrix( property_name="internal_energy", connection_point=1, use_relative_indexing=False ) - ] = 10.0 + ] = 5.0 self.heat_pump.solver_asset.prev_sol[ self.heat_pump.solver_asset.get_index_matrix( @@ -222,6 +222,6 @@ def test_write_to_output(self): self.heat_pump.write_to_output() # Assert - self.assertEqual(self.heat_pump.outputs[1][-1][PROPERTY_HEAT_POWER_PRIMARY], 10.0) - self.assertEqual(self.heat_pump.outputs[1][-1][PROPERTY_ELECTRICITY_CONSUMPTION], 1.0) + self.assertEqual(self.heat_pump.outputs[1][-1][PROPERTY_HEAT_POWER_PRIMARY], -10.0) + self.assertEqual(self.heat_pump.outputs[1][-1][PROPERTY_ELECTRICITY_CONSUMPTION], 5.0) self.assertEqual(self.heat_pump.outputs[0][-1][PROPERTY_HEAT_POWER_SECONDARY], 5.0) diff --git a/unit_test/solver/network/assets/test_heat_transfer_asset.py b/unit_test/solver/network/assets/test_heat_transfer_asset.py index 691cdf80..c23118da 100644 --- a/unit_test/solver/network/assets/test_heat_transfer_asset.py +++ b/unit_test/solver/network/assets/test_heat_transfer_asset.py @@ -449,18 +449,17 @@ def test_get_heat_power_secondary(self): def test_get_electric_power_consumption(self): """Test get_electric_power_consumption method.""" # Arrange - self.asset.heat_transfer_coefficient = 5.0 # --- Primary side self.asset.prev_sol[ self.asset.get_index_matrix( property_name="internal_energy", connection_point=0, use_relative_indexing=False ) - ] = 5.0 + ] = 10.0 self.asset.prev_sol[ self.asset.get_index_matrix( property_name="internal_energy", connection_point=1, use_relative_indexing=False ) - ] = 10.0 + ] = 5.0 self.asset.prev_sol[ self.asset.get_index_matrix( property_name="mass_flow_rate", connection_point=0, use_relative_indexing=False @@ -472,12 +471,12 @@ def test_get_electric_power_consumption(self): self.asset.get_index_matrix( property_name="internal_energy", connection_point=2, use_relative_indexing=False ) - ] = 15.0 + ] = 20.0 self.asset.prev_sol[ self.asset.get_index_matrix( property_name="internal_energy", connection_point=3, use_relative_indexing=False ) - ] = 20.0 + ] = 15.0 self.asset.prev_sol[ self.asset.get_index_matrix( property_name="mass_flow_rate", connection_point=2, use_relative_indexing=False @@ -488,4 +487,4 @@ def test_get_electric_power_consumption(self): electric_power = self.asset.get_electric_power_consumption() # Assert - self.assertEqual(electric_power, 1.0) + self.assertEqual(electric_power, 5.0)