diff --git a/CHANGELOG.md b/CHANGELOG.md index d66d6a8f0..14e55d34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# [Unreleased-main] - 2026-05-13 +# [Unreleased-main] - 2026-05-29 ## Added - Electricity consumption calculation of geothermal assets, using the defined COP. @@ -12,6 +12,7 @@ - Ramp constraints for heat producers are added. - Maximum and minimum temperature of heat sources are parsed from esdl - Warnings on potential causes of heat demand not being matched are added in the grow workflow +- A heat source asset is eligible for use only when its maximum temperature meets or exceeds the varying network supply temperatures ## Changed - Reduced the number of constraints required for headloss calculation with LINEARIZED_N_LINES_EQUALITY setting. diff --git a/src/mesido/heat_physics_mixin.py b/src/mesido/heat_physics_mixin.py index 907101fec..18a7e999d 100644 --- a/src/mesido/heat_physics_mixin.py +++ b/src/mesido/heat_physics_mixin.py @@ -1441,9 +1441,15 @@ def __source_heat_to_discharge_path_constraints(self, ensemble_member): f"{sup_carrier}_{supply_temperature}" ) + heat_out_expected = discharge * cp * rho * supply_temperature + if (0.0 < parameters[f"{s}.max_temperature"]) and ( + parameters[f"{s}.max_temperature"] < supply_temperature + ): + heat_out_expected = 0.0 + constraints.extend( self._symmetric_big_m_constraints( - heat_out - discharge * cp * rho * supply_temperature, + heat_out - heat_out_expected, (1.0 - sup_temperature_is_selected) * big_m, constraint_nominal, ) diff --git a/tests/models/unit_cases/case_1a/model/1a_with_2producers_and_supply_temperature_options.esdl b/tests/models/unit_cases/case_1a/model/1a_with_2producers_and_supply_temperature_options.esdl new file mode 100644 index 000000000..7cb04d252 --- /dev/null +++ b/tests/models/unit_cases/case_1a/model/1a_with_2producers_and_supply_temperature_options.esdl @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/models/unit_cases/case_1a/src/run_1a.py b/tests/models/unit_cases/case_1a/src/run_1a.py index bf6fa8995..d6128bb9b 100644 --- a/tests/models/unit_cases/case_1a/src/run_1a.py +++ b/tests/models/unit_cases/case_1a/src/run_1a.py @@ -3,6 +3,7 @@ from mesido.esdl.profile_parser import ProfileReaderFromFile from mesido.physics_mixin import PhysicsMixin from mesido.qth_not_maintained.qth_mixin import QTHMixin +from mesido.techno_economic_mixin import TechnoEconomicMixin import numpy as np @@ -100,6 +101,58 @@ def constraints(self, ensemble_member): return constraints +class HeatProblemTvarWithTechnoEconomicMixin(HeatProblemTvar, TechnoEconomicMixin): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.base_carrier_name = "Heat" + self.base_carrier_id = self._get_id_from_carrier_name(self.base_carrier_name) + + def temperature_regimes(self, carrier): + temperatures = [] + if carrier == self.base_carrier_id: + temperatures = self._get_related_carrier_temperatures(self.base_carrier_name) + return temperatures + + def _get_id_from_carrier_name(self, carrier_name: str): + """ + Return the id of the carrier with the given name. + + Parameters + ---------- + carrier_name : Name of the carrier for which the id is requested. + """ + + return next( + ( + carrier.get("id") + for carrier in self.esdl_carriers.values() + if carrier.get("name") == carrier_name + ), + None, + ) + + def _get_related_carrier_temperatures(self, carrier_name: str): + """ + Return the sorted temperatures of carriers related to the given carrier name. + + A carrier is considered related if its name starts with ``_`` + followed by a numeric suffix, for example ``Heat_70`` or ``Heat_90``. + + Parameters + ---------- + carrier_name : Base name of the carrier group. + """ + + return sorted( + carrier["temperature"] + for carrier in self.esdl_carriers.values() + if carrier_name + and carrier.get("name", "").startswith(f"{carrier_name}_") + and carrier["name"][len(f"{carrier_name}_") :].isdigit() + ) + + class QTHProblem( _GoalsAndOptions, QTHMixin, diff --git a/tests/test_varying_temperature.py b/tests/test_varying_temperature.py index 927037068..7170e0d44 100644 --- a/tests/test_varying_temperature.py +++ b/tests/test_varying_temperature.py @@ -12,6 +12,87 @@ class TestVaryingTemperature(TestCase): + def test_1a_heatsource_usage_based_on_varying_supply_temperature(self): + """ + This test is to check whether the optimizer selects the expected heat source + based on the network supply temperature. We set up a simple network with two residual + heat sources, one cheap and one expensive, where the cheap one has a maximum + temperature that is below the network supply temperature, and the expensive + one has a maximum temperature that is above the network supply temperature. + We expect the optimizer to use only the expensive heat source. + + In addition, the network has multiple supply temperature options, and we + expect the optimizer to select the lowest feasible supply temperature. + + Checks: + - Variable operational cost coefficients of heat producers + - Selection of the lowest feasible supply temperature from available options + - Maximum supply temperature of heat producers + - Check that the expensive heat source is used instead of the cheap heat source + """ + + import models.unit_cases.case_1a.src.run_1a as run_1a + from models.unit_cases.case_1a.src.run_1a import HeatProblemTvarWithTechnoEconomicMixin + + base_folder = Path(run_1a.__file__).resolve().parent.parent + + heat_problem = run_esdl_mesido_optimization( + HeatProblemTvarWithTechnoEconomicMixin, + base_folder=base_folder, + esdl_file_name="1a_with_2producers_and_supply_temperature_options.esdl", + esdl_parser=ESDLFileParser, + profile_reader=ProfileReaderFromFile, + input_timeseries_file="timeseries_import.xml", + ) + + results = heat_problem.extract_results() + parameters = heat_problem.parameters(0) + name_to_id_map = heat_problem.esdl_asset_name_to_id_map + + rh_cheap_id = name_to_id_map["ResidualHeat_cheap"] + rh_expensive_id = name_to_id_map["ResidualHeat_expensive"] + + # Check variable operational cost coefficients + np.testing.assert_array_less( + parameters[f"{rh_cheap_id}.variable_operational_cost_coefficient"], + parameters[f"{rh_expensive_id}.variable_operational_cost_coefficient"], + ) + + # Check that from the options the lowest supply temperature is selected + # for the network supply temperature + carrier_id_number = heat_problem._get_id_from_carrier_name("Heat") + temperature_regimes = heat_problem.temperature_regimes(carrier_id_number) + np.testing.assert_equal( + min(temperature_regimes), + results[f"{carrier_id_number}_temperature"], + ) + + # Check that the maximum supply temperature of the cheap heat source + # is below the supply temperature + np.testing.assert_equal( + heat_problem.esdl_assets[f"{rh_cheap_id}"].attributes["maxTemperature"], + parameters[f"{rh_cheap_id}.max_temperature"], + ) + np.testing.assert_array_less( + parameters[f"{rh_cheap_id}.max_temperature"], + results[f"{carrier_id_number}_temperature"], + ) + + # Check that the maximum supply temperature of the expensive heat source + # is above the supply temperature + np.testing.assert_equal( + heat_problem.esdl_assets[f"{rh_expensive_id}"].attributes["maxTemperature"], + parameters[f"{rh_expensive_id}.max_temperature"], + ) + np.testing.assert_array_less( + results[f"{carrier_id_number}_temperature"], + parameters[f"{rh_expensive_id}.max_temperature"], + ) + + # Check expensive heat source is used instead of cheap heat source + np.testing.assert_array_less(1e3, results[f"{rh_expensive_id}.Heat_source"]) + np.testing.assert_allclose(0.0, results[f"{rh_cheap_id}.Heat_source"]) + def test_1a_temperature_variation(self): """ This test is to check if the varying network temperature works as expected on a simple @@ -475,6 +556,7 @@ def test_heat_pump_varying_temperature(self): start_time = time.time() a = TestVaryingTemperature() + a.test_1a_heatsource_usage_based_on_varying_supply_temperature() a.test_1a_temperature_variation() a.test_3a_temperature_variation_supply() a.test_3a_temperature_variation_return()