diff --git a/CHANGELOG.md b/CHANGELOG.md index 324d58b73..5814fffaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Added - ESDL profile reading and writing for different types: influxdb and postgres, and inline (in ESDL file). +- Import (a subclass of electricity source) is supported in Mesido. ## Changed - Code generalization in asset sizing mixin diff --git a/examples/heating_and_cooling/model/Heating and cooling and elec network with return network with costs.esdl b/examples/heating_and_cooling/model/Heating and cooling and elec network with return network with costs.esdl index ae90d942a..c10a26002 100644 --- a/examples/heating_and_cooling/model/Heating and cooling and elec network with return network with costs.esdl +++ b/examples/heating_and_cooling/model/Heating and cooling and elec network with return network with costs.esdl @@ -1,9 +1,9 @@ - + - + @@ -287,10 +287,6 @@ - - - - @@ -298,9 +294,13 @@ - + + + + + diff --git a/examples/heating_and_cooling/src/run_case.py b/examples/heating_and_cooling/src/run_case.py index 8c3ced4c1..7261eb845 100644 --- a/examples/heating_and_cooling/src/run_case.py +++ b/examples/heating_and_cooling/src/run_case.py @@ -87,12 +87,7 @@ def heating_cooling_case(self): np.testing.assert_array_less(1e3, results[f"{a_1_id}__fixed_operational_cost"]) np.testing.assert_array_less(1e3, results[f"{hp_1_id}__investment_cost"]) np.testing.assert_array_less(1e3, results[f"{hp_1_id}__installation_cost"]) - # TODO: The heat pump __variable_operational_cost check below will fail due the heat pump - # variable cost not including the electricty profile. The intend of this example case - # is to include a elect cost profile which still has to be - # accounted for via the "Import" asset. Once this asset is catered for in MESIDO it has to - # replace the elect producer in this example. - np.testing.assert_array_less(1e3, results[f"{hp_1_id}__variable_operational_cost"]) + np.testing.assert_array_less(2, results[f"{hp_1_id}__variable_operational_cost"]) np.testing.assert_array_less(1e3, results[f"{hp_1_id}__fixed_operational_cost"]) np.testing.assert_array_less(1e3, results[f"{ac_1_id}__investment_cost"]) np.testing.assert_array_less(1e3, results[f"{ac_1_id}__installation_cost"]) diff --git a/src/mesido/esdl/asset_to_component_base.py b/src/mesido/esdl/asset_to_component_base.py index ced0e9aec..2ac845b76 100644 --- a/src/mesido/esdl/asset_to_component_base.py +++ b/src/mesido/esdl/asset_to_component_base.py @@ -295,6 +295,11 @@ class _AssetToComponentBase: "fixedMaintenanceCosts": "optional", "fixedOperationalCosts": "optional", }, + "electricity_import": { + "investmentCosts": "optional", + "installationCosts": "optional", + "variableOperationalCosts": "optional", + }, "heat_source": { # Includes GeothermalSource, ResidualHeatSource, HeatProducer, # GasHeater, ElectricBoiler "investmentCosts": "required", @@ -349,6 +354,8 @@ class _AssetToComponentBase: "CoolingDemand": "heat_demand", "Electrolyzer": "electrolyzer", "ElectricBoiler": "heat_source", + "Import": "electricity_import", + "ElectricityProducer": "electricity_source", "GasDemand": "gas_demand", "GasHeater": "heat_source", "GasStorage": "gas_tank_storage", diff --git a/src/mesido/esdl/esdl_heat_model.py b/src/mesido/esdl/esdl_heat_model.py index 33d02e760..ef9f2a8a7 100644 --- a/src/mesido/esdl/esdl_heat_model.py +++ b/src/mesido/esdl/esdl_heat_model.py @@ -60,6 +60,7 @@ Transformer, WindPark, ) +from mesido.pycml.component_library.milp.electricity.electricity_import import ElectricityImport # Importing workflow utilities at module import time can create circular # imports when workflows import ESDL mixins. Import locally where needed. @@ -1903,7 +1904,8 @@ def convert_import(self, asset: Asset) -> Tuple[Any, MODIFIERS]: if isinstance(asset.out_ports[0].carrier, esdl.esdl.GasCommodity): return self.convert_gas_source(asset) elif isinstance(asset.out_ports[0].carrier, esdl.esdl.ElectricityCommodity): - return self.convert_electricity_source(asset) + _, modifiers = self.convert_electricity_source(asset) + return ElectricityImport, modifiers else: raise RuntimeError( f"Commodity of type {type(asset.out_ports[0].carrier)} for asset Import " diff --git a/src/mesido/financial_mixin.py b/src/mesido/financial_mixin.py index 14f1cfdb2..14f86c417 100644 --- a/src/mesido/financial_mixin.py +++ b/src/mesido/financial_mixin.py @@ -1163,9 +1163,15 @@ def __variable_operational_cost_constraints(self, ensemble_member): variable_operational_cost_coefficient = parameters[ # euro / Wh f"{es}.variable_operational_cost_coefficient" ] + + price_profile = self.__get_electricity_price_profile_or_zero() + sum_ = ca.sum1( variable_operational_cost_coefficient * elec_produced_w[1:] * timesteps_hr ) # [euro/Wh] * [W] * [hr] + if es in self.energy_system_components.get("electricity_import", []): + sum_ += ca.sum1(price_profile[1:] * elec_produced_w[1:] * timesteps_hr) + constraints.append(((variable_operational_cost - sum_) / nominal, 0.0, 0.0)) # for a in self.heat_network_components.get("ates", []): diff --git a/src/mesido/pycml/component_library/milp/electricity/electricity_import.py b/src/mesido/pycml/component_library/milp/electricity/electricity_import.py new file mode 100644 index 000000000..d3b378eab --- /dev/null +++ b/src/mesido/pycml/component_library/milp/electricity/electricity_import.py @@ -0,0 +1,24 @@ +from mesido.pycml.pycml_mixin import add_variables_documentation_automatically + +from .. import ElectricitySource + + +@add_variables_documentation_automatically +class ElectricityImport(ElectricitySource): + """ + The electricity import is an electricity source component used to import electrical power and + provide that to the energy system, which can handle production profiles and electricity price + profiles. + + 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 = "electricity_import" diff --git a/tests/models/unit_cases_electricity/source_sink_cable/input/timeseries_with_e_price.csv b/tests/models/unit_cases_electricity/source_sink_cable/input/timeseries_with_e_price.csv new file mode 100644 index 000000000..a1f08c1a5 --- /dev/null +++ b/tests/models/unit_cases_electricity/source_sink_cable/input/timeseries_with_e_price.csv @@ -0,0 +1,4 @@ +DateTime,ElectricityDemand_2af6,Electricity +1-1-2019 00:00,500,1.00E-04 +1-1-2019 01:00,1000,2.00E-04 +1-1-2019 02:00,1500,1.00E-04 diff --git a/tests/models/unit_cases_electricity/source_sink_cable/model/electricity_import_and_e_price.esdl b/tests/models/unit_cases_electricity/source_sink_cable/model/electricity_import_and_e_price.esdl new file mode 100644 index 000000000..d40a30618 --- /dev/null +++ b/tests/models/unit_cases_electricity/source_sink_cable/model/electricity_import_and_e_price.esdl @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/models/unit_cases_electricity/source_sink_cable/src/example.py b/tests/models/unit_cases_electricity/source_sink_cable/src/example.py index 296e85bb9..1d66e0295 100644 --- a/tests/models/unit_cases_electricity/source_sink_cable/src/example.py +++ b/tests/models/unit_cases_electricity/source_sink_cable/src/example.py @@ -1,3 +1,4 @@ +from mesido.esdl.esdl_additional_vars_mixin import ESDLAdditionalVarsMixin from mesido.esdl.esdl_mixin import ESDLMixin from mesido.esdl.esdl_parser import ESDLFileParser from mesido.esdl.profile_parser import ProfileReaderFromFile @@ -149,6 +150,15 @@ class ElectricityProblem( pass +class ElectricityProblemPriceProfile(ESDLAdditionalVarsMixin, ElectricityProblem): + """ + Problem to check the behaviour of a electricity import, cable, demand network. + ESDLAdditionalVarsMixin is needed to read e-price profile in input csv + """ + + pass + + class ElectricityProblemMaxCurr( PhysicsMixin, LinearizedOrderGoalProgrammingMixin, diff --git a/tests/test_electric_source_sink.py b/tests/test_electric_source_sink.py index 618534c8f..1cd7b1e06 100644 --- a/tests/test_electric_source_sink.py +++ b/tests/test_electric_source_sink.py @@ -7,7 +7,11 @@ import numpy as np -from utils_tests import demand_matching_test, electric_power_conservation_test +from utils_tests import ( + cost_calculation_test, + demand_matching_test, + electric_power_conservation_test, +) # TODO: still have to make test where elecitricity direction is switched: # e.g. 2 nodes, with at each node a producer and consumer, first one node medium demand, second @@ -76,6 +80,46 @@ def test_source_sink_pv_csv_profile(self): # Check electricity producers max sizes are equal np.testing.assert_allclose(results[f"{pv_id}__max_size"], results[f"{e_prod_id}__max_size"]) + def test_electricity_import_sink(self): + """ + Tests for an electricity network that consist out of electricity import, cable and sink. + + Checks: + - Check for energy conservation with consumed power, lost power and imported power. + - Check that variable operation cost of electricity import is calculated via electricity + price profile, variable operational cost coefficient and imported power. + """ + + import models.unit_cases_electricity.source_sink_cable.src.example as example + from models.unit_cases_electricity.source_sink_cable.src.example import ( + ElectricityProblemPriceProfile, + ) + + base_folder = Path(example.__file__).resolve().parent.parent + + solution = run_esdl_mesido_optimization( + ElectricityProblemPriceProfile, + base_folder=base_folder, + esdl_file_name="electricity_import_and_e_price.esdl", + esdl_parser=ESDLFileParser, + profile_reader=ProfileReaderFromFile, + input_timeseries_file="timeseries_with_e_price.csv", + ) + results = solution.extract_results() + name_to_id_map = solution.esdl_asset_name_to_id_map + + import_id = name_to_id_map["Import"] + + # Check energy conservation + electric_power_conservation_test(solution, results) + + # Check variable operation cost calculation + np.testing.assert_array_less(0.2, results[f"{import_id}__variable_operational_cost"]) + np.testing.assert_array_less( + 0.00005, solution.get_timeseries("Electricity.price_profile").values + ) + cost_calculation_test(solution, results) + def test_source_sink(self): """ Tests for an electricity network that consist out of a source, a cable and a sink. diff --git a/tests/utils_tests.py b/tests/utils_tests.py index e9eb501b1..81fd1ad0f 100644 --- a/tests/utils_tests.py +++ b/tests/utils_tests.py @@ -12,8 +12,6 @@ import numpy as np -from rtctools.optimization.timeseries import Timeseries - def __get_out_port_temp_profile(solution, asset_name, asset_type): """ @@ -218,7 +216,7 @@ def heat_to_discharge_test(solution, results, atol=1e-2, rtol=1.0e-4): if temp_profile is not None: supply_t = temp_profile.values supply_temp_profiles.append(temp_profile.values) - print(d, max(abs(results[f"{d}.HeatOut.Heat"] - results[f"{d}.Q"] * rho * cp * supply_t))) + # print(d, max(abs(results[f"{d}.HeatOut.Heat"] - results[f"{d}.Q"] * rho * cp * supply_t))) np.testing.assert_allclose( results[f"{d}.HeatOut.Heat"], @@ -741,7 +739,6 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato assets = [ *solution.energy_system_components.get("heat_source", []), *solution.energy_system_components.get("ates", []), - *solution.energy_system_components.get("low_temperature_ates", []), *solution.energy_system_components.get("heat_pump", []), *solution.energy_system_components.get("pump", []), *solution.energy_system_components.get("heat_exchanger", []), @@ -749,6 +746,7 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato *solution.energy_system_components.get("airco", []), *solution.energy_system_components.get("heat_demand", []), *solution.energy_system_components.get("cold_demand", []), + *solution.energy_system_components.get("electricity_source", []), *transport_assets, ] @@ -759,17 +757,20 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato for name, edr_class_name in _AssetToComponentBase.STEEL_S1_PIPE_EDR_ASSETS.items() ] + assets_may_have_electricity_price_profile = [ + *solution.energy_system_components.get("heat_source_elec", []), + *solution.energy_system_components.get("air_water_heat_pump", []), + *[ + hp_asset + for hp_asset in solution.energy_system_components.get("heat_pump", []) + if hp_asset not in solution.energy_system_components.get("heat_pump_elec", []) + ], + *solution.energy_system_components.get("electricity_import", []), + ] + edr_pipes = json.load(open(Path(__file__).parent.parent / "src/mesido/esdl/_edr_pipes.json")) - if len(solution.get_electricity_carriers().keys()) == 1: - try: - price_profile = solution.get_timeseries( - f"{list(solution.get_electricity_carriers().values())[0]['name']}.price_profile" - ) - except KeyError: - price_profile = Timeseries(solution.times(), np.zeros(len(solution.times()))) - else: - price_profile = Timeseries(solution.times(), np.zeros(len(solution.times()))) + price_profile = solution._FinancialMixin__get_electricity_price_profile_or_zero() total_investment_cost = 0.0 total_installation_cost = 0.0 @@ -785,7 +786,10 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato ): pass elif costs_esdl_asset is None: - continue + if asset in assets_may_have_electricity_price_profile: + pass # Assets include e-price in var-opex, but no esdl cost attributes are defined + elif asset not in assets_may_have_electricity_price_profile: + continue # Investment Cost investment_cost = 0.0 @@ -798,8 +802,10 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato edr_pipes, parameters[f"{asset}.diameter"] ) else: - investment_cost_info = _get_esdl_scaled_cost( - costs_esdl_asset.investmentCosts, esdl.UnitEnum.WATT + investment_cost_info = ( + _get_esdl_scaled_cost(costs_esdl_asset.investmentCosts, esdl.UnitEnum.WATT) + if costs_esdl_asset is not None + else 0.0 ) if asset in solution.energy_system_components.get("heat_buffer", []): np.testing.assert_allclose( @@ -847,8 +853,8 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato else: if results[f"{asset}__max_size"] > 1e-8: installation_cost = ( - costs_esdl_asset.installationCosts.value - if costs_esdl_asset.installationCosts is not None + _get_esdl_scaled_cost(costs_esdl_asset.installationCosts, esdl.UnitEnum.WATT) + if costs_esdl_asset is not None else 0.0 ) np.testing.assert_allclose(installation_cost, results[f"{asset}__installation_cost"]) @@ -859,11 +865,15 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato if asset in transport_assets: np.testing.assert_allclose(0.0, results[f"{asset}__fixed_operational_cost"]) else: - fix_op_costs_esdl = _get_esdl_scaled_cost( - costs_esdl_asset.fixedOperationalCosts, esdl.UnitEnum.WATT + fix_op_costs_esdl = ( + _get_esdl_scaled_cost(costs_esdl_asset.fixedOperationalCosts, esdl.UnitEnum.WATT) + if costs_esdl_asset is not None + else 0.0 ) - fix_maint_costs_esdl = _get_esdl_scaled_cost( - costs_esdl_asset.fixedMaintenanceCosts, esdl.UnitEnum.WATT + fix_maint_costs_esdl = ( + _get_esdl_scaled_cost(costs_esdl_asset.fixedMaintenanceCosts, esdl.UnitEnum.WATT) + if costs_esdl_asset is not None + else 0.0 ) np.testing.assert_allclose( parameters[f"{asset}.fixed_operational_cost_coefficient"], @@ -884,8 +894,12 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato if asset in transport_assets: np.testing.assert_allclose(0.0, results[f"{asset}__variable_operational_cost"]) else: - var_op_costs_esdl = _get_esdl_scaled_cost( - costs_esdl_asset.variableOperationalCosts, esdl.UnitEnum.WATTHOUR + var_op_costs_esdl = ( + _get_esdl_scaled_cost( + costs_esdl_asset.variableOperationalCosts, esdl.UnitEnum.WATTHOUR + ) + if costs_esdl_asset is not None + else 0.0 ) np.testing.assert_allclose( parameters[f"{asset}.variable_operational_cost_coefficient"], var_op_costs_esdl @@ -899,7 +913,7 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato if parameters[f"{asset}.include_head_loss_variables"]: pump_power = results[f"{asset}.Pump_power"] eff = parameters[f"{asset}.pump_efficiency"] - pump_cost = sum(price_profile.values[1:] * pump_power[1:] * timesteps_hr / eff) + pump_cost = sum(price_profile[1:] * pump_power[1:] * timesteps_hr / eff) else: pump_cost = 0.0 @@ -913,7 +927,7 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato if parameters[f"{asset}.include_head_loss_variables"]: pump_power = results[f"{asset}.Pump_power"] eff = parameters[f"{asset}.pump_efficiency"] - pump_cost = sum(price_profile.values[1:] * pump_power[1:] * timesteps_hr / eff) + pump_cost = sum(price_profile[1:] * pump_power[1:] * timesteps_hr / eff) else: pump_cost = 0.0 @@ -947,6 +961,8 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato nominator_vector = results[f"{asset}.Heat_demand"] elif asset in solution.energy_system_components.get("cold_demand", []): nominator_vector = results[f"{asset}.Cold_demand"] + elif asset in solution.energy_system_components.get("electricity_source", []): + nominator_vector = results[f"{asset}.Electricity_source"] # [W] else: raise AssertionError( f"Asset '{esdl_asset.name}' is not handled in the variable operational" @@ -959,13 +975,11 @@ def cost_calculation_test(solution, results, check_objective_function=False, ato variable_operational_cost += pump_cost - if (len(solution.get_electricity_carriers().keys()) > 0) and asset in [ - *solution.energy_system_components.get("heat_source_elec", []), - *solution.energy_system_components.get("air_water_heat_pump", []), - *solution.energy_system_components.get("heat_pump", []), - ]: + if ( + len(solution.get_electricity_carriers().keys()) > 0 + ) and asset in assets_may_have_electricity_price_profile: variable_operational_cost += sum( - price_profile.values[1:] * nominator_vector[1:] * timesteps_hr / denominator + price_profile[1:] * nominator_vector[1:] * timesteps_hr / denominator ) np.testing.assert_allclose(