diff --git a/src/mesido/esdl/asset_to_component_base.py b/src/mesido/esdl/asset_to_component_base.py index 426308b4..532ab7f6 100644 --- a/src/mesido/esdl/asset_to_component_base.py +++ b/src/mesido/esdl/asset_to_component_base.py @@ -900,6 +900,7 @@ def _get_connected_i_nominal_and_max(self, asset: Asset) -> Tuple[float, float]: or asset.asset_type == "Electrolyzer" or asset.asset_type == "ElectricBoiler" or asset.asset_type == "HeatPump" + or asset.asset_type == "GeothermalSource" ): for port in asset.in_ports: if isinstance(port.carrier, esdl.ElectricityCommodity): diff --git a/src/mesido/esdl/esdl_heat_model.py b/src/mesido/esdl/esdl_heat_model.py index 2d3d1cc2..1af489b5 100644 --- a/src/mesido/esdl/esdl_heat_model.py +++ b/src/mesido/esdl/esdl_heat_model.py @@ -42,6 +42,7 @@ GasSubstation, GasTankStorage, GeothermalSource, + GeothermalSourceElec, HeatBuffer, HeatDemand, HeatExchanger, @@ -1450,8 +1451,24 @@ def convert_heat_source(self, asset: Asset) -> Tuple[Type[HeatSource], MODIFIERS f"{asset.asset_type} '{asset.name}' has no desired flow rate specified. " f"'{asset.name}' will not be actuated in a constant manner" ) - - return GeothermalSource, modifiers + modifiers["elec_power_nominal"] = max_supply + modifiers["cop"] = asset.attributes["COP"] if asset.attributes["COP"] else 0.0 + if len(asset.in_ports) == 2: + for port in asset.in_ports: + if isinstance(port.carrier, esdl.ElectricityCommodity): + min_voltage = port.carrier.voltage + i_max, i_nom = self._get_connected_i_nominal_and_max(asset) + modifiers.update( + min_voltage=min_voltage, + ElectricityIn=dict( + Power=dict(min=0.0, max=max_supply, nominal=max_supply / 2.0), + I=dict(min=0.0, max=i_max, nominal=i_nom), + V=dict(min=min_voltage, nominal=min_voltage), + ), + ) + return GeothermalSourceElec, modifiers + else: + return GeothermalSource, modifiers elif asset.asset_type == "HeatPump": modifiers["cop"] = asset.attributes["COP"] return AirWaterHeatPump, modifiers diff --git a/src/mesido/esdl/esdl_model_base.py b/src/mesido/esdl/esdl_model_base.py index 66b06d94..83a96247 100644 --- a/src/mesido/esdl/esdl_model_base.py +++ b/src/mesido/esdl/esdl_model_base.py @@ -180,7 +180,7 @@ def __set_primary_secondary_heat_ports(): f"milp(4) and electricity (1) ports" ) elif ( - asset.asset_type == "HeatPump" + (asset.asset_type == "HeatPump") and len(asset.out_ports) == 1 and len(asset.in_ports) in [1, 2] ): @@ -201,11 +201,12 @@ def __set_primary_secondary_heat_ports(): ) else: raise Exception( - f"{asset.name} has incorrect number of in/out ports. HeatPumps are allows " - f"to have 1 in and 1 out port for air-water HP, 2 in ports and 2 out ports " - f"when modelling a water-water HP, or 3 in ports and 2 out ports when the " - f"electricity connection of the water-water HP is modelled." + f"{asset.name} has incorrect number of in/out ports. HeatPumps allow " + f"to have 1 in and 1 out port for air-water HP, 2 in ports and 2 out " + f"ports when modelling a water-water HP, or 3 in ports and 2 out ports " + f"when the electricity connection of the water-water HP is modelled." ) + elif ( asset.asset_type == "GasHeater" and len(asset.out_ports) == 1 @@ -226,6 +227,7 @@ def __set_primary_secondary_heat_ports(): ) elif ( asset.asset_type == "ElectricBoiler" + or asset.asset_type == "GeothermalSource" and len(asset.out_ports) == 1 and len(asset.in_ports) == 2 ): @@ -258,6 +260,7 @@ def __set_primary_secondary_heat_ports(): raise Exception( f"{asset.name} must have one inport for electricity and one outport for gas" ) + elif ( asset.in_ports is None and isinstance(asset.out_ports[0].carrier, esdl.ElectricityCommodity) diff --git a/src/mesido/pycml/component_library/milp/__init__.py b/src/mesido/pycml/component_library/milp/__init__.py index 6557874c..77e4a40c 100644 --- a/src/mesido/pycml/component_library/milp/__init__.py +++ b/src/mesido/pycml/component_library/milp/__init__.py @@ -21,6 +21,7 @@ from .heat.cold_demand import ColdDemand from .heat.control_valve import ControlValve from .heat.geothermal_source import GeothermalSource +from .heat.geothermal_source_elec import GeothermalSourceElec from .heat.heat_buffer import HeatBuffer from .heat.heat_demand import HeatDemand from .heat.heat_exchanger import HeatExchanger @@ -68,6 +69,7 @@ "GasSubstation", "GasTankStorage", "GeothermalSource", + "GeothermalSourceElec", "HeatExchanger", "HeatFourPort", "HeatPipe", diff --git a/src/mesido/pycml/component_library/milp/heat/geothermal_source.py b/src/mesido/pycml/component_library/milp/heat/geothermal_source.py index a91a4099..b01049fb 100644 --- a/src/mesido/pycml/component_library/milp/heat/geothermal_source.py +++ b/src/mesido/pycml/component_library/milp/heat/geothermal_source.py @@ -1,3 +1,4 @@ +from mesido.pycml import Variable from mesido.pycml.pycml_mixin import add_variables_documentation_automatically from numpy import nan @@ -9,11 +10,14 @@ class GeothermalSource(HeatSource): """ The geothermal source component is used to model geothermal doublets. It is equivilent to a - normal source with the only difference being in the modelling of doublets. The main reason for - this component instead of using just a regular source is that to have the integer behaviour of - increasing the amount of doublets. In the HeatMixin an integer is created _aggregation_count to - model the amount of doublets and the maximum power will scale with this integer instead of - continuous. This will also ensure that the cost will scale with this integer. + normal source with the only difference being the modelling of doublets and power consumption. + The main reason for this component instead of using just a regular source is that to have the + integer behaviour of increasing the amount of doublets. In the HeatMixin an integer is created + _aggregation_count to model the amount of doublets and the maximum power will scale with this + integer instead of continuous. This will also ensure that the cost will scale with this integer. + The power consumption is computed through a COP calculation, directly linked to the heat source + production. COP is set to a default value of 0 in order to ensure power and its associated costs + are only inlcuded in the computations intentionally. Variables created: {add_variable_names_for_documentation_here} @@ -31,4 +35,13 @@ def __init__(self, name, **modifiers): self.target_flow_rate = nan self.single_doublet_power = nan + self.cop = nan + self.elec_power_nominal = nan self.nr_of_doublets = 1.0 + + self.add_variable(Variable, "Power_elec", min=0.0, nominal=self.elec_power_nominal) + + if self.cop == 0.0: + self.add_equation((self.Power_elec)) + else: + self.add_equation(((self.Power_elec * self.cop - self.Heat_source) / self.Heat_nominal)) diff --git a/src/mesido/pycml/component_library/milp/heat/geothermal_source_elec.py b/src/mesido/pycml/component_library/milp/heat/geothermal_source_elec.py new file mode 100644 index 00000000..8b9b6996 --- /dev/null +++ b/src/mesido/pycml/component_library/milp/heat/geothermal_source_elec.py @@ -0,0 +1,35 @@ +from mesido.pycml.component_library.milp.electricity.electricity_base import ElectricityPort +from mesido.pycml.component_library.milp.heat.geothermal_source import GeothermalSource +from mesido.pycml.pycml_mixin import add_variables_documentation_automatically + +from numpy import nan + + +@add_variables_documentation_automatically +class GeothermalSourceElec(GeothermalSource): + """ + The geothermal source electric asset is almost identical to the geothermal source one. The + only difference is that this one includes an electricity in port. The electricity power + calculation that it inherits from the geothermal source asset needs to be satisfied by an + electricity carrier supplied through this new in port. + + Variables created: + {add_variable_names_for_documentation_here} + + Parameters: + name : The name of the asset. \n + modifiers : Dictionary with asset information. + """ + + def __init__(self, name, **modifiers): + super().__init__( + name, + **modifiers, + ) + + self.component_subtype = "geothermal_source_elec" + self.min_voltage = nan + + self.add_variable(ElectricityPort, "ElectricityIn") + + self.add_equation(((self.ElectricityIn.Power - self.Power_elec) / self.elec_power_nominal)) diff --git a/tests/models/source_pipe_sink/model/sourcesink_with_geo.esdl b/tests/models/source_pipe_sink/model/sourcesink_with_geo.esdl new file mode 100644 index 00000000..f69ec905 --- /dev/null +++ b/tests/models/source_pipe_sink/model/sourcesink_with_geo.esdl @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/models/source_pipe_sink/model/sourcesink_with_geo_elec.esdl b/tests/models/source_pipe_sink/model/sourcesink_with_geo_elec.esdl new file mode 100644 index 00000000..c5afad36 --- /dev/null +++ b/tests/models/source_pipe_sink/model/sourcesink_with_geo_elec.esdl @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/models/source_pipe_sink/model/sourcesink_with_geo_elec_no_cop.esdl b/tests/models/source_pipe_sink/model/sourcesink_with_geo_elec_no_cop.esdl new file mode 100644 index 00000000..a164e3e6 --- /dev/null +++ b/tests/models/source_pipe_sink/model/sourcesink_with_geo_elec_no_cop.esdl @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_multicommodity.py b/tests/test_multicommodity.py index d38742a9..bbb2013d 100644 --- a/tests/test_multicommodity.py +++ b/tests/test_multicommodity.py @@ -324,8 +324,145 @@ def solver_options(self): np.testing.assert_allclose(var_opex_hp_calc, var_opex_hp) -if __name__ == "__main__": +class TestGeothermalSourceElec(TestCase): - test_cold_demand = TestMultiCommodityHeatPump() - test_cold_demand.test_air_to_water_heat_pump_elec_min_elec() - # test_cold_demand.test_heat_pump_elec_min_elec() + def test_geothermal_source(self): + """ + This tests the electric behavior of the regular geothermal source asset. + + It first does the standard checks, and then the equations that are added to the geothermal + asset, including the electricity power consumption. + + """ + import models.source_pipe_sink.src.double_pipe_heat as example + from models.source_pipe_sink.src.double_pipe_heat import SourcePipeSink + + base_folder = Path(example.__file__).resolve().parent.parent + + heat_problem = run_esdl_mesido_optimization( + SourcePipeSink, + base_folder=base_folder, + esdl_file_name="sourcesink_with_geo.esdl", + esdl_parser=ESDLFileParser, + profile_reader=ProfileReaderFromFile, + input_timeseries_file="timeseries_import.csv", + ) + results = heat_problem.extract_results() + parameters = heat_problem.parameters(0) + + # Standard checks. + demand_matching_test(heat_problem, results) + energy_conservation_test(heat_problem, results) + heat_to_discharge_test(heat_problem, results) + electric_power_conservation_test(heat_problem, results) + + # Equations check + np.testing.assert_allclose( + parameters["GeothermalSource_a77b.cop"] * results["GeothermalSource_a77b.Power_elec"], + results["GeothermalSource_a77b.Heat_source"], + ) + + # Variable operational cost check. + np.testing.assert_allclose( + parameters["GeothermalSource_a77b.variable_operational_cost_coefficient"] + * sum(results["GeothermalSource_a77b.Heat_source"][1:]) + / parameters["GeothermalSource_a77b.cop"], + results["GeothermalSource_a77b__variable_operational_cost"], + ) + + def test_geothermal_source_elec(self): + """ + This tests checks the electric geothermal producer asset with an electricity port. + + It does all the same checks as with the regular geothermal asset, plus tests on the + electricity in port. + """ + import models.source_pipe_sink.src.double_pipe_heat as example + from models.source_pipe_sink.src.double_pipe_heat import SourcePipeSink + + base_folder = Path(example.__file__).resolve().parent.parent + + heat_problem = run_esdl_mesido_optimization( + SourcePipeSink, + base_folder=base_folder, + esdl_file_name="sourcesink_with_geo_elec.esdl", + esdl_parser=ESDLFileParser, + profile_reader=ProfileReaderFromFile, + input_timeseries_file="timeseries_import.csv", + ) + results = heat_problem.extract_results() + parameters = heat_problem.parameters(0) + + # Standard checks. + demand_matching_test(heat_problem, results) + energy_conservation_test(heat_problem, results) + heat_to_discharge_test(heat_problem, results) + electric_power_conservation_test(heat_problem, results) + + # Equations check + np.testing.assert_allclose( + results["ElectricityProducer_4dde.ElectricityOut.Power"], + results["GeothermalSource_a77b.ElectricityIn.Power"], + ) + np.testing.assert_allclose( + parameters["GeothermalSource_a77b.cop"] * results["GeothermalSource_a77b.Power_elec"], + results["GeothermalSource_a77b.Heat_source"], + ) + + # Test electricity port + np.testing.assert_allclose( + results["GeothermalSource_a77b.ElectricityIn.Power"], + results["GeothermalSource_a77b.Power_elec"], + ) + + # Variable operational cost check. + np.testing.assert_allclose( + parameters["GeothermalSource_a77b.variable_operational_cost_coefficient"] + * sum(results["GeothermalSource_a77b.Heat_source"][1:]) + / parameters["GeothermalSource_a77b.cop"], + results["GeothermalSource_a77b__variable_operational_cost"], + ) + + def test_geothermal_source_elec_no_cop(self): + """ + This tests checks the electric geothermal producer asset when no cop is provided. + + It is the same test case as with the regular one, but in this case, the cop is not + provided and it assigned a value of 0.0, resulting in no power related costs. + """ + import models.source_pipe_sink.src.double_pipe_heat as example + from models.source_pipe_sink.src.double_pipe_heat import SourcePipeSink + + base_folder = Path(example.__file__).resolve().parent.parent + + heat_problem = run_esdl_mesido_optimization( + SourcePipeSink, + base_folder=base_folder, + esdl_file_name="sourcesink_with_geo_elec_no_cop.esdl", + esdl_parser=ESDLFileParser, + profile_reader=ProfileReaderFromFile, + input_timeseries_file="timeseries_import.csv", + ) + results = heat_problem.extract_results() + + # Standard checks. + demand_matching_test(heat_problem, results) + energy_conservation_test(heat_problem, results) + heat_to_discharge_test(heat_problem, results) + electric_power_conservation_test(heat_problem, results) + + # Equations check + np.testing.assert_allclose( + results["ElectricityProducer_4dde.ElectricityOut.Power"], + results["GeothermalSource_a77b.ElectricityIn.Power"], + ) + np.testing.assert_allclose(results["ElectricityProducer_4dde.ElectricityOut.Power"], 0.0) + + # Test electricity port + np.testing.assert_allclose( + results["GeothermalSource_a77b.ElectricityIn.Power"], + results["GeothermalSource_a77b.Power_elec"], + ) + + # Variable operational cost check. + np.testing.assert_allclose(0.0, results["GeothermalSource_a77b__variable_operational_cost"]) diff --git a/tests/utils_tests.py b/tests/utils_tests.py index 3946b4b8..d50ba5b6 100644 --- a/tests/utils_tests.py +++ b/tests/utils_tests.py @@ -374,6 +374,7 @@ def electric_power_conservation_test(solution, results, atol=1e-2): "elec_heat_source_elec", "heat_pump_elec", "air_water_heat_pump_elec", + "geothermal_source_elec", ] ) producers = solution.energy_system_components_get(["electricity_source"])