diff --git a/src/libecalc/dto/components.py b/src/libecalc/dto/components.py index fa2aa7da0..902a637ab 100644 --- a/src/libecalc/dto/components.py +++ b/src/libecalc/dto/components.py @@ -300,6 +300,14 @@ class GeneratorSet(BaseEquipment): Field(discriminator="component_type"), ] ] = Field(default_factory=list) + cable_loss: Optional[ExpressionType] = Field( + None, + title="CABLE_LOSS", + description="Power loss in cables from shore. " "Used to calculate onshore delivery/power supply onshore.", + ) + max_usage_from_shore: Optional[ExpressionType] = Field( + None, title="MAX_USAGE_FROM_SHORE", description="The peak load/effect that is expected for one hour, per year." + ) _validate_genset_temporal_models = field_validator("generator_set_model", "fuel")(validate_temporal_model) @field_validator("user_defined_category", mode="before") diff --git a/src/libecalc/fixtures/__init__.py b/src/libecalc/fixtures/__init__.py index e4269eb24..6c0e29292 100644 --- a/src/libecalc/fixtures/__init__.py +++ b/src/libecalc/fixtures/__init__.py @@ -7,6 +7,7 @@ ) from .cases.consumer_with_time_slots_models import * # noqa: F403 from .cases.ltp_export import ltp_export_yaml +from .cases.ltp_export.ltp_power_from_shore_yaml import ltp_pfs_yaml_factory from .cases.minimal import * # noqa: F403 from .compressor_process_simulations.compressor_process_simulations import * # noqa: F403 from .conftest import ( diff --git a/src/libecalc/fixtures/cases/ltp_export/data/sim/cable_loss.csv b/src/libecalc/fixtures/cases/ltp_export/data/sim/cable_loss.csv new file mode 100644 index 000000000..bec1ac310 --- /dev/null +++ b/src/libecalc/fixtures/cases/ltp_export/data/sim/cable_loss.csv @@ -0,0 +1,11 @@ +DATE,CABLE_LOSS_FACTOR +01.01.2021,0.1 +01.01.2022,0.1 +01.01.2023,0.1 +01.01.2024,0.1 +01.01.2025,0.1 +01.01.2026,0.1 +01.01.2027,0.1 +01.01.2028,0.1 +01.01.2029,0.1 +01.01.2030,0.1 diff --git a/src/libecalc/fixtures/cases/ltp_export/ltp_power_from_shore_yaml.py b/src/libecalc/fixtures/cases/ltp_export/ltp_power_from_shore_yaml.py new file mode 100644 index 000000000..bc71db7fb --- /dev/null +++ b/src/libecalc/fixtures/cases/ltp_export/ltp_power_from_shore_yaml.py @@ -0,0 +1,98 @@ +from pathlib import Path + +import pytest +import yaml + +from libecalc.common.time_utils import Frequency +from libecalc.dto import ResultOptions +from libecalc.expression.expression import ExpressionType +from libecalc.fixtures.case_types import DTOCase +from libecalc.presentation.yaml.mappers.variables_mapper import map_yaml_to_variables +from libecalc.presentation.yaml.model import PyYamlYamlModel, YamlModel +from libecalc.presentation.yaml.parse_input import map_yaml_to_dto + + +@pytest.fixture +def ltp_pfs_yaml_factory(): + def _ltp_pfs_yaml_factory( + regularity: float, + cable_loss: ExpressionType, + max_usage_from_shore: ExpressionType, + load_direct_consumer: float, + path: Path, + ) -> DTOCase: + input_text = f""" + START: 2025-01-01 + END: 2030-01-01 + TIME_SERIES: + - NAME: CABLE_LOSS + TYPE: DEFAULT + FILE: data/sim/cable_loss.csv + FACILITY_INPUTS: + - NAME: generator_energy_function + FILE: 'data/einput/genset_17MW.csv' + TYPE: ELECTRICITY2FUEL + - NAME: pfs_energy_function + FILE: 'data/einput/onshore_power.csv' + TYPE: ELECTRICITY2FUEL + FUEL_TYPES: + - NAME: fuel1 + EMISSIONS: + - NAME: co2 + FACTOR: 2 + - NAME: ch4 + FACTOR: 0.005 + - NAME: nmvoc + FACTOR: 0.002 + - NAME: nox + FACTOR: 0.001 + + INSTALLATIONS: + - NAME: minimal_installation + HCEXPORT: 0 + FUEL: fuel1 + CATEGORY: FIXED + REGULARITY: {regularity} + GENERATORSETS: + - NAME: generator1 + ELECTRICITY2FUEL: + 2025-01-01: generator_energy_function + 2027-01-01: pfs_energy_function + CATEGORY: + 2025-01-01: TURBINE-GENERATOR + 2027-01-01: POWER-FROM-SHORE + CABLE_LOSS: {cable_loss} + MAX_USAGE_FROM_SHORE: {max_usage_from_shore} + + CONSUMERS: # electrical energy consumers + - NAME: base_load + CATEGORY: BASE-LOAD + ENERGY_USAGE_MODEL: + TYPE: DIRECT + LOAD: {load_direct_consumer} + + + """ + + yaml_text = yaml.safe_load(input_text) + configuration = PyYamlYamlModel( + internal_datamodel=yaml_text, + instantiated_through_read=True, + ) + + path = path + + resources = YamlModel._read_resources(yaml_configuration=configuration, working_directory=path) + variables = map_yaml_to_variables( + configuration, + resources=resources, + result_options=ResultOptions( + start=configuration.start, + end=configuration.end, + output_frequency=Frequency.YEAR, + ), + ) + yaml_model = map_yaml_to_dto(configuration=configuration, resources=resources, name="ltp_export") + return DTOCase(ecalc_model=yaml_model, variables=variables) + + return _ltp_pfs_yaml_factory diff --git a/src/libecalc/presentation/exporter/configs/configs.py b/src/libecalc/presentation/exporter/configs/configs.py index e158347d1..33541d4b8 100644 --- a/src/libecalc/presentation/exporter/configs/configs.py +++ b/src/libecalc/presentation/exporter/configs/configs.py @@ -20,6 +20,8 @@ EmissionQuery, FuelConsumerPowerConsumptionQuery, FuelQuery, + MaxUsageFromShoreQuery, + PowerSupplyOnshoreQuery, ) """ @@ -634,6 +636,22 @@ def aggregator(frequency: Frequency) -> Aggregator: producer_categories=["POWER-FROM-SHORE"], ), ), + Applier( + name="powerSupplyOnshore", + title="Power Supply Onshore", + unit=Unit.GIGA_WATT_HOURS, + query=PowerSupplyOnshoreQuery( + producer_categories=["POWER-FROM-SHORE"], + ), + ), + Applier( + name="fromShorePeakMaximum", + title="Max Usage from Shore", + unit=Unit.GIGA_WATT_HOURS, + query=MaxUsageFromShoreQuery( + producer_categories=["POWER-FROM-SHORE"], + ), + ), Applier( name="steamTurbineGeneratorConsumption", title="Total Electricity Consumed From Steam Turbine Generators", diff --git a/src/libecalc/presentation/exporter/queries.py b/src/libecalc/presentation/exporter/queries.py index cdc38f8bd..15a74705c 100644 --- a/src/libecalc/presentation/exporter/queries.py +++ b/src/libecalc/presentation/exporter/queries.py @@ -6,15 +6,18 @@ import libecalc.dto from libecalc.application.graph_result import GraphResult from libecalc.common.decorators.feature_flags import Feature +from libecalc.common.list.list_utils import array_to_list from libecalc.common.temporal_model import TemporalExpression, TemporalModel from libecalc.common.time_utils import Frequency, resample_time_steps from libecalc.common.units import Unit from libecalc.common.utils.rates import ( TimeSeriesFloat, TimeSeriesRate, + TimeSeriesStreamDayRate, TimeSeriesVolumes, ) from libecalc.core.result import GeneratorSetResult +from libecalc.expression import Expression class Query(abc.ABC): @@ -308,6 +311,172 @@ def query( return aggregated_result_volume if aggregated_result_volume else None +class PowerSupplyOnshoreQuery(Query): + """GenSet only (ie el producers).""" + + def __init__(self, installation_category: Optional[str] = None, producer_categories: Optional[List[str]] = None): + self.installation_category = installation_category + self.producer_categories = producer_categories + + def query( + self, + installation_graph: GraphResult, + unit: Unit, + frequency: Frequency, + ) -> Optional[Dict[datetime, float]]: + installation_dto = installation_graph.graph.get_node(installation_graph.graph.root) + + installation_time_steps = installation_graph.timesteps + time_steps = resample_time_steps( + frequency=frequency, + time_steps=installation_time_steps, + ) + + regularity = TimeSeriesFloat( + timesteps=installation_time_steps, + values=TemporalExpression.evaluate( + temporal_expression=TemporalModel(installation_dto.regularity), + variables_map=installation_graph.variables_map, + ), + unit=Unit.NONE, + ) + + aggregated_result: DefaultDict[datetime, float] = defaultdict(float) + aggregated_result_volume = {} + unit_in = None + + if self.installation_category is None or installation_dto.user_defined_category == self.installation_category: + for fuel_consumer in installation_dto.fuel_consumers: + if isinstance(fuel_consumer, libecalc.dto.GeneratorSet) and fuel_consumer.cable_loss is not None: + temporal_category = TemporalModel(fuel_consumer.user_defined_category) + for period, category in temporal_category.items(): + if self.producer_categories is None or category in self.producer_categories: + fuel_consumer_result: GeneratorSetResult = installation_graph.get_energy_result( + fuel_consumer.id + ) + + cable_loss = Expression.evaluate( + fuel_consumer.cable_loss, + variables=installation_graph.variables_map.variables, + fill_length=len(installation_graph.variables_map.time_vector), + ) + + fuel_consumer_result.power.values = fuel_consumer_result.power.values * (1 + cable_loss) + + cumulative_volumes_gwh = ( + TimeSeriesRate.from_timeseries_stream_day_rate( + fuel_consumer_result.power, regularity=regularity + ) + .for_period(period) + .to_volumes() + ) + + unit_in = cumulative_volumes_gwh.unit + + for timestep, cumulative_volume_gwh in cumulative_volumes_gwh.datapoints(): + aggregated_result[timestep] += cumulative_volume_gwh + + if aggregated_result: + sorted_result = dict(dict(sorted(zip(aggregated_result.keys(), aggregated_result.values()))).items()) + sorted_result = {**dict.fromkeys(installation_time_steps, 0.0), **sorted_result} + date_keys = list(sorted_result.keys()) + + reindexed_result = ( + TimeSeriesVolumes(timesteps=date_keys, values=list(sorted_result.values())[:-1], unit=unit_in) + .to_unit(Unit.GIGA_WATT_HOURS) + .reindex(time_steps) + .fill_nan(0) + ) + + aggregated_result_volume = { + reindexed_result.timesteps[i]: reindexed_result.values[i] for i in range(len(reindexed_result)) + } + return aggregated_result_volume if aggregated_result_volume else None + + +class MaxUsageFromShoreQuery(Query): + """GenSet only (ie el producers).""" + + def __init__(self, installation_category: Optional[str] = None, producer_categories: Optional[List[str]] = None): + self.installation_category = installation_category + self.producer_categories = producer_categories + + def query( + self, + installation_graph: GraphResult, + unit: Unit, + frequency: Frequency, + ) -> Optional[Dict[datetime, float]]: + installation_dto = installation_graph.graph.get_node(installation_graph.graph.root) + + installation_time_steps = installation_graph.timesteps + time_steps = resample_time_steps( + frequency=frequency, + time_steps=installation_time_steps, + ) + + regularity = TimeSeriesFloat( + timesteps=installation_time_steps, + values=TemporalExpression.evaluate( + temporal_expression=TemporalModel(installation_dto.regularity), + variables_map=installation_graph.variables_map, + ), + unit=Unit.NONE, + ) + + aggregated_result: DefaultDict[datetime, float] = defaultdict(float) + aggregated_result_volume = {} + unit_in = None + + if self.installation_category is None or installation_dto.user_defined_category == self.installation_category: + for fuel_consumer in installation_dto.fuel_consumers: + if ( + isinstance(fuel_consumer, libecalc.dto.GeneratorSet) + and fuel_consumer.max_usage_from_shore is not None + ): + temporal_category = TemporalModel(fuel_consumer.user_defined_category) + for period, category in temporal_category.items(): + if self.producer_categories is None or category in self.producer_categories: + installation_graph.get_energy_result(fuel_consumer.id) + + max_usage_from_shore = TimeSeriesStreamDayRate( + values=array_to_list( + Expression.evaluate( + fuel_consumer.max_usage_from_shore, + variables=installation_graph.variables_map.variables, + fill_length=len(installation_graph.variables_map.time_vector), + ) + ), + unit=Unit.MEGA_WATT, + timesteps=installation_graph.variables_map.time_vector, + ) + + results = TimeSeriesRate.from_timeseries_stream_day_rate( + max_usage_from_shore, regularity=regularity + ).for_period(period) + + unit_in = results.unit + + for timestep, result in results.datapoints(): + aggregated_result[timestep] += result + + if aggregated_result: + sorted_result = dict(dict(sorted(zip(aggregated_result.keys(), aggregated_result.values()))).items()) + sorted_result = {**dict.fromkeys(installation_time_steps, 0.0), **sorted_result} + date_keys = list(sorted_result.keys()) + + reindexed_result = ( + TimeSeriesVolumes(timesteps=date_keys, values=list(sorted_result.values())[:-1], unit=unit_in) + .reindex(time_steps) + .fill_nan(0) + ) + + aggregated_result_volume = { + reindexed_result.timesteps[i]: reindexed_result.values[i] for i in range(len(reindexed_result)) + } + return aggregated_result_volume if aggregated_result_volume else None + + class FuelConsumerPowerConsumptionQuery(Query): def __init__(self, consumer_categories: Optional[List[str]] = None, installation_category: Optional[str] = None): self.consumer_categories = consumer_categories diff --git a/src/libecalc/presentation/yaml/mappers/component_mapper.py b/src/libecalc/presentation/yaml/mappers/component_mapper.py index e37c8adc0..d1fa7156d 100644 --- a/src/libecalc/presentation/yaml/mappers/component_mapper.py +++ b/src/libecalc/presentation/yaml/mappers/component_mapper.py @@ -6,7 +6,7 @@ from libecalc import dto from libecalc.common.logger import logger from libecalc.common.time_utils import Period, define_time_model_for_period -from libecalc.dto.base import ComponentType +from libecalc.dto.base import ComponentType, ConsumerUserDefinedCategoryType from libecalc.dto.types import ConsumerType, ConsumptionType, EnergyModelType from libecalc.dto.utils.validators import convert_expression from libecalc.expression import Expression @@ -216,6 +216,16 @@ def from_yaml_to_dto( ) for consumer in data.get(EcalcYamlKeywords.consumers, []) ] + user_defined_category = define_time_model_for_period( + data.get(EcalcYamlKeywords.user_defined_tag), target_period=self._target_period + ) + cable_loss = None + max_usage_from_shore = None + + if ConsumerUserDefinedCategoryType.POWER_FROM_SHORE in user_defined_category.values(): + cable_loss = convert_expression(data.get(EcalcYamlKeywords.cable_loss)) + max_usage_from_shore = convert_expression(data.get(EcalcYamlKeywords.max_usage_from_shore)) + try: generator_set_name = data.get(EcalcYamlKeywords.name) return dto.GeneratorSet( @@ -224,9 +234,9 @@ def from_yaml_to_dto( regularity=regularity, generator_set_model=generator_set_model, consumers=consumers, - user_defined_category=define_time_model_for_period( - data.get(EcalcYamlKeywords.user_defined_tag), target_period=self._target_period - ), + user_defined_category=user_defined_category, + cable_loss=cable_loss, + max_usage_from_shore=max_usage_from_shore, ) except ValidationError as e: raise DtoValidationError(data=data, validation_error=e) from e diff --git a/src/libecalc/presentation/yaml/yaml_keywords.py b/src/libecalc/presentation/yaml/yaml_keywords.py index 4991f1e36..b2762f611 100644 --- a/src/libecalc/presentation/yaml/yaml_keywords.py +++ b/src/libecalc/presentation/yaml/yaml_keywords.py @@ -63,6 +63,8 @@ class EcalcYamlKeywords: conditions = "CONDITIONS" consumers = "CONSUMERS" el2fuel = "ELECTRICITY2FUEL" + cable_loss = "CABLE_LOSS" + max_usage_from_shore = "MAX_USAGE_FROM_SHORE" emission_factor = "FACTOR" emissions = "EMISSIONS" energy_model = "ENERGYFUNCTION" diff --git a/src/libecalc/presentation/yaml/yaml_types/components/yaml_generator_set.py b/src/libecalc/presentation/yaml/yaml_types/components/yaml_generator_set.py index 58fd77205..1b2528bc8 100644 --- a/src/libecalc/presentation/yaml/yaml_types/components/yaml_generator_set.py +++ b/src/libecalc/presentation/yaml/yaml_types/components/yaml_generator_set.py @@ -1,11 +1,12 @@ -from typing import List, Union +from typing import List, Optional, Union -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from typing_extensions import Annotated from libecalc.common.discriminator_fallback import DiscriminatorWithFallback from libecalc.dto.base import ConsumerUserDefinedCategoryType from libecalc.dto.utils.validators import ComponentNameStr +from libecalc.expression.expression import ExpressionType from libecalc.presentation.yaml.yaml_types import YamlBase from libecalc.presentation.yaml.yaml_types.components.legacy.yaml_electricity_consumer import ( YamlElectricityConsumer, @@ -39,6 +40,14 @@ class YamlGeneratorSet(YamlBase): description="Specifies the correlation between the electric power delivered and the fuel burned by a " "generator set.\n\n$ECALC_DOCS_KEYWORDS_URL/ELECTRICITY2FUEL", ) + cable_loss: Optional[ExpressionType] = Field( + None, title="CABLE_LOSS", description="Cable loss from shore, fraction of from shore consumption" + ) + max_usage_from_shore: Optional[ExpressionType] = Field( + None, + title="MAX_USAGE_FROM_SHORE", + description="The peak load/effect that is expected for one hour, per year (MW)", + ) consumers: List[ Annotated[ Union[ @@ -53,3 +62,21 @@ class YamlGeneratorSet(YamlBase): title="CONSUMERS", description="Consumers getting electrical power from the generator set.\n\n$ECALC_DOCS_KEYWORDS_URL/CONSUMERS", ) + + @model_validator(mode="after") + def check_power_from_shore(self): + if self.cable_loss is not None or self.max_usage_from_shore is not None: + if isinstance(self.category, ConsumerUserDefinedCategoryType): + if self.category is not ConsumerUserDefinedCategoryType.POWER_FROM_SHORE: + raise ValueError( + f"{self.cable_loss.title} and {self.max_usage_from_shore.title} are only valid for the " + f"category {ConsumerUserDefinedCategoryType.POWER_FROM_SHORE}, not for " + f"{self.category}." + ) + else: + if ConsumerUserDefinedCategoryType.POWER_FROM_SHORE not in self.category.values(): + raise ValueError( + f"{self.cable_loss.title} and {self.max_usage_from_shore.title} are only valid for the " + f"category {ConsumerUserDefinedCategoryType.POWER_FROM_SHORE}." + ) + return self diff --git a/src/tests/libecalc/output/results/test_ltp.py b/src/tests/libecalc/output/results/test_ltp.py index 00989c41c..557342ca4 100644 --- a/src/tests/libecalc/output/results/test_ltp.py +++ b/src/tests/libecalc/output/results/test_ltp.py @@ -1,10 +1,13 @@ from datetime import datetime +from pathlib import Path import pandas as pd import pytest from libecalc import dto +from libecalc.common.time_utils import calculate_delta_days from libecalc.common.units import Unit from libecalc.common.utils.rates import RateType +from libecalc.fixtures.cases import ltp_export from libecalc.fixtures.cases.ltp_export.installation_setup import ( expected_boiler_fuel_consumption, expected_ch4_from_diesel, @@ -428,3 +431,61 @@ def test_electrical_and_mechanical_power_installation(): # Verify that electrical power equals genset power, and mechanical power equals power from gas driven compressor: assert power_generator_set == power_electrical_installation assert power_fuel_driven_compressor == power_mechanical_installation + + +def test_power_from_shore(ltp_pfs_yaml_factory): + """Test power from shore output for LTP export.""" + + time_vector_yearly = pd.date_range(datetime(2025, 1, 1), datetime(2030, 1, 1), freq="YS").to_pydatetime().tolist() + + dto.VariablesMap(time_vector=time_vector_yearly, variables={}) + regularity = 0.2 + load = 10 + cable_loss = 0.1 + max_from_shore = 12 + + dto_case = ltp_pfs_yaml_factory( + regularity=regularity, + cable_loss=cable_loss, + max_usage_from_shore=max_from_shore, + load_direct_consumer=load, + path=Path(ltp_export.__path__[0]), + ) + + dto_case.ecalc_model.model_validate(dto_case.ecalc_model) + + dto_case_csv = ltp_pfs_yaml_factory( + regularity=regularity, + cable_loss="CABLE_LOSS;CABLE_LOSS_FACTOR", + max_usage_from_shore=max_from_shore, + load_direct_consumer=load, + path=Path(ltp_export.__path__[0]), + ) + + ltp_result = get_consumption( + model=dto_case.ecalc_model, variables=dto_case.variables, time_vector=time_vector_yearly + ) + ltp_result_csv = get_consumption( + model=dto_case_csv.ecalc_model, variables=dto_case.variables, time_vector=time_vector_yearly + ) + + power_from_shore_consumption = get_sum_ltp_column(ltp_result=ltp_result, installation_nr=0, ltp_column_nr=1) + power_supply_onshore = get_sum_ltp_column(ltp_result=ltp_result, installation_nr=0, ltp_column_nr=2) + max_usage_from_shore = get_sum_ltp_column(ltp_result=ltp_result, installation_nr=0, ltp_column_nr=3) + + power_supply_onshore_csv = get_sum_ltp_column(ltp_result=ltp_result_csv, installation_nr=0, ltp_column_nr=2) + + # In the temporal model, the category is POWER_FROM_SHORE the last three years, within the period 2025 - 2030: + delta_days = calculate_delta_days(time_vector_yearly)[2:5] + + # Check that power from shore consumption is correct + assert power_from_shore_consumption == sum([load * days * regularity * 24 / 1000 for days in delta_days]) + + # Check that power supply onshore is power from shore consumption * (1 + cable loss) + assert power_supply_onshore == sum([load * (1 + cable_loss) * days * regularity * 24 / 1000 for days in delta_days]) + + # Check that max usage from shore is just a report of the input + assert max_usage_from_shore == max_from_shore * 3 + + # Check that reading cable loss from csv-file gives same result as using constant + assert power_supply_onshore == power_supply_onshore_csv