From c463ba8b8bdb5e7333b9401a9a49ae4d21475bb6 Mon Sep 17 00:00:00 2001 From: Jostein Solaas Date: Tue, 19 Nov 2024 21:04:04 +0100 Subject: [PATCH] refactor: implement emitter interface Move emission calculation into Emitters --- src/libecalc/application/energy/emitter.py | 25 +++ .../application/energy/energy_component.py | 2 +- src/libecalc/application/energy_calculator.py | 46 +----- src/libecalc/core/models/fuel.py | 84 ---------- src/libecalc/dto/components.py | 145 +++++++++++++++++- .../emitters/yaml_venting_emitter.py | 52 ++++++- tests/libecalc/core/models/test_fuel_model.py | 6 +- 7 files changed, 226 insertions(+), 134 deletions(-) create mode 100644 src/libecalc/application/energy/emitter.py delete mode 100644 src/libecalc/core/models/fuel.py diff --git a/src/libecalc/application/energy/emitter.py b/src/libecalc/application/energy/emitter.py new file mode 100644 index 0000000000..f3a70caa8e --- /dev/null +++ b/src/libecalc/application/energy/emitter.py @@ -0,0 +1,25 @@ +import abc +from typing import Optional + +from libecalc.application.energy.component_energy_context import ComponentEnergyContext +from libecalc.application.energy.energy_model import EnergyModel +from libecalc.common.variables import ExpressionEvaluator +from libecalc.core.result.emission import EmissionResult + + +class Emitter(abc.ABC): + """ + Something that emits something. + """ + + @property + @abc.abstractmethod + def id(self) -> str: ... + + @abc.abstractmethod + def evaluate_emissions( + self, + energy_context: ComponentEnergyContext, + energy_model: EnergyModel, + expression_evaluator: ExpressionEvaluator, + ) -> Optional[dict[str, EmissionResult]]: ... diff --git a/src/libecalc/application/energy/energy_component.py b/src/libecalc/application/energy/energy_component.py index b248b9eec5..6437c22ae2 100644 --- a/src/libecalc/application/energy/energy_component.py +++ b/src/libecalc/application/energy/energy_component.py @@ -16,4 +16,4 @@ class EnergyComponent(abc.ABC): def id(self) -> str: ... @abc.abstractmethod - def evaluate(self, energy_context: ComponentEnergyContext) -> EcalcModelResult: ... + def evaluate_energy_usage(self, energy_context: ComponentEnergyContext) -> EcalcModelResult: ... diff --git a/src/libecalc/application/energy_calculator.py b/src/libecalc/application/energy_calculator.py index 47d8abc146..0302db4296 100644 --- a/src/libecalc/application/energy_calculator.py +++ b/src/libecalc/application/energy_calculator.py @@ -3,12 +3,10 @@ from functools import reduce from typing import Optional -import numpy as np - import libecalc.dto.components from libecalc.application.energy.component_energy_context import ComponentEnergyContext +from libecalc.application.energy.emitter import Emitter from libecalc.application.energy.energy_model import EnergyModel -from libecalc.common.consumption_type import ConsumptionType from libecalc.common.math.numbers import Numbers from libecalc.common.priorities import PriorityID from libecalc.common.priority_optimizer import PriorityOptimizer @@ -22,13 +20,9 @@ from libecalc.core.consumers.generator_set import Genset from libecalc.core.consumers.legacy_consumer.component import Consumer from libecalc.core.consumers.legacy_consumer.consumer_function_mapper import EnergyModelMapper -from libecalc.core.models.fuel import FuelModel from libecalc.core.models.generator import GeneratorModelSampled from libecalc.core.result import ComponentResult, EcalcModelResult from libecalc.core.result.emission import EmissionResult -from libecalc.dto.components import ( - ConsumerSystem as ConsumerSystemDTO, -) from libecalc.dto.components import ( ElectricityConsumer as ElectricityConsumerDTO, ) @@ -38,10 +32,6 @@ from libecalc.dto.components import ( GeneratorSet as GeneratorSetDTO, ) -from libecalc.presentation.yaml.yaml_types.emitters.yaml_venting_emitter import ( - YamlDirectTypeEmitter, - YamlOilTypeEmitter, -) class Context(ComponentEnergyContext): @@ -230,34 +220,14 @@ def evaluate_emissions(self) -> dict[str, dict[str, EmissionResult]]: """ emission_results: dict[str, dict[str, EmissionResult]] = {} for energy_component in self._energy_model.get_energy_components(): - if isinstance(energy_component, FuelConsumerDTO | GeneratorSetDTO): - fuel_model = FuelModel(energy_component.fuel) - fuel_usage = self._get_context(energy_component.id).get_fuel_usage() - emission_results[energy_component.id] = fuel_model.evaluate_emissions( - expression_evaluator=self._expression_evaluator, - fuel_rate=np.asarray(fuel_usage.values), - ) - elif isinstance(energy_component, ConsumerSystemDTO): - if energy_component.consumes == ConsumptionType.FUEL: - fuel_model = FuelModel(energy_component.fuel) - fuel_usage = self._get_context(energy_component.id).get_fuel_usage() - emission_results[energy_component.id] = fuel_model.evaluate_emissions( - expression_evaluator=self._expression_evaluator, - fuel_rate=np.asarray(fuel_usage.values), - ) - elif isinstance(energy_component, YamlDirectTypeEmitter | YamlOilTypeEmitter): - venting_emitter_results = {} - emission_rates = energy_component.get_emissions( + if isinstance(energy_component, Emitter): + emission_result = energy_component.evaluate_emissions( + energy_context=self._get_context(energy_component.id), + energy_model=self._energy_model, expression_evaluator=self._expression_evaluator, - regularity=self._energy_model.get_regularity(energy_component.id), ) - for emission_name, emission_rate in emission_rates.items(): - emission_result = EmissionResult( - name=emission_name, - periods=self._expression_evaluator.get_periods(), - rate=emission_rate, - ) - venting_emitter_results[emission_name] = emission_result - emission_results[energy_component.id] = venting_emitter_results + if emission_result is not None: + emission_results[energy_component.id] = emission_result + return Numbers.format_results_to_precision(emission_results, precision=6) diff --git a/src/libecalc/core/models/fuel.py b/src/libecalc/core/models/fuel.py deleted file mode 100644 index b81e24a1a2..0000000000 --- a/src/libecalc/core/models/fuel.py +++ /dev/null @@ -1,84 +0,0 @@ -import numpy as np -from numpy.typing import NDArray - -from libecalc.common.logger import logger -from libecalc.common.time_utils import Period, Periods -from libecalc.common.units import Unit -from libecalc.common.utils.rates import TimeSeriesStreamDayRate -from libecalc.common.variables import ExpressionEvaluator -from libecalc.core.result.emission import EmissionResult -from libecalc.dto import FuelType - - -class FuelModel: - """A function to evaluate fuel related attributes for different time period - For each period, there is a data object with expressions for fuel related - attributes which may be evaluated for some variables and a fuel_rate. - """ - - def __init__(self, fuel_time_function_dict: dict[Period, FuelType]): - logger.debug("Creating fuel model") - self.temporal_fuel_model = fuel_time_function_dict - - def evaluate_emissions( - self, expression_evaluator: ExpressionEvaluator, fuel_rate: NDArray[np.float64] - ) -> dict[str, EmissionResult]: - """Evaluate fuel related expressions and results for a TimeSeriesCollection and a - fuel_rate array. - - First the fuel parameters are calculated by evaluating the fuel expressions and - the time_series object. - - Then the resulting emission volume is calculated based on the fuel rate: - - emission_rate = emission_factor * fuel_rate - - This is done per time period and all fuel related results both in terms of - fuel types and time periods, are merged into one common fuel collection results object. - - The length of the fuel_rate array must equal the length of the global list of periods. - It is assumed that the fuel_rate array origins from calculations based on the same time_series - object and thus will have the same length when used in this method. - """ - logger.debug("Evaluating fuel usage and emissions") - - # Creating a pseudo-default dict with all the emitters as keys. This is to handle changes in a temporal model. - emissions = { - emission_name: EmissionResult.create_empty(name=emission_name, periods=Periods([])) - for emission_name in { - emission.name for _, model in self.temporal_fuel_model.items() for emission in model.emissions - } - } - - for temporal_period, model in self.temporal_fuel_model.items(): - if Period.intersects(temporal_period, expression_evaluator.get_period()): - start_index, end_index = temporal_period.get_period_indices(expression_evaluator.get_periods()) - variables_map_this_period = expression_evaluator.get_subset( - start_index=start_index, - end_index=end_index, - ) - fuel_rate_this_period = fuel_rate[start_index:end_index] - for emission in model.emissions: - factor = variables_map_this_period.evaluate(expression=emission.factor) - - emission_rate_kg_per_day = fuel_rate_this_period * factor - emission_rate_tons_per_day = Unit.KILO_PER_DAY.to(Unit.TONS_PER_DAY)(emission_rate_kg_per_day) - - result = EmissionResult( - name=emission.name, - periods=variables_map_this_period.get_periods(), - rate=TimeSeriesStreamDayRate( - periods=variables_map_this_period.get_periods(), - values=emission_rate_tons_per_day.tolist(), - unit=Unit.TONS_PER_DAY, - ), - ) - - emissions[emission.name].extend(result) - - for name in emissions: - if name not in [emission.name for emission in model.emissions]: - emissions[name].extend( - EmissionResult.create_empty(name=name, periods=variables_map_this_period.get_periods()) - ) - - return dict(sorted(emissions.items())) diff --git a/src/libecalc/dto/components.py b/src/libecalc/dto/components.py index 6bdf1f7eb5..b5206a16ff 100644 --- a/src/libecalc/dto/components.py +++ b/src/libecalc/dto/components.py @@ -3,16 +3,21 @@ from datetime import datetime from typing import Annotated, Any, Literal, Optional, TypeVar, Union +import numpy as np from pydantic import ConfigDict, Field, field_validator, model_validator from pydantic_core.core_schema import ValidationInfo +from libecalc.application.energy.component_energy_context import ComponentEnergyContext +from libecalc.application.energy.emitter import Emitter +from libecalc.application.energy.energy_model import EnergyModel from libecalc.common.component_type import ComponentType from libecalc.common.consumption_type import ConsumptionType from libecalc.common.energy_usage_type import EnergyUsageType +from libecalc.common.logger import logger from libecalc.common.priorities import Priorities from libecalc.common.stream_conditions import TimeSeriesStreamConditions from libecalc.common.string.string_utils import generate_id, get_duplicates -from libecalc.common.time_utils import Period +from libecalc.common.time_utils import Period, Periods from libecalc.common.units import Unit from libecalc.common.utils.rates import ( RateType, @@ -20,6 +25,7 @@ TimeSeriesStreamDayRate, ) from libecalc.common.variables import ExpressionEvaluator +from libecalc.core.result.emission import EmissionResult from libecalc.dto.base import ( EcalcBaseModel, ) @@ -156,7 +162,7 @@ def check_energy_usage_model(cls, energy_usage_model): return energy_usage_model -class FuelConsumer(BaseConsumer): +class FuelConsumer(BaseConsumer, Emitter): component_type: Literal[ ComponentType.COMPRESSOR, ComponentType.GENERIC, @@ -167,11 +173,26 @@ class FuelConsumer(BaseConsumer): energy_usage_model: dict[Period, FuelEnergyUsageModel] _validate_fuel_consumer_temporal_models = field_validator("energy_usage_model", "fuel")(validate_temporal_model) - _check_model_energy_usage = field_validator("energy_usage_model")( lambda data: check_model_energy_usage_type(data, EnergyUsageType.FUEL) ) + def evaluate_emissions( + self, + energy_context: ComponentEnergyContext, + energy_model: EnergyModel, + expression_evaluator: ExpressionEvaluator, + ) -> Optional[dict[str, EmissionResult]]: + fuel_model = FuelModel(self.fuel) + fuel_usage = energy_context.get_fuel_usage() + + assert fuel_usage is not None + + return fuel_model.evaluate_emissions( + expression_evaluator=expression_evaluator, + fuel_rate=fuel_usage.values, + ) + @field_validator("energy_usage_model", mode="before") @classmethod def check_energy_usage_model(cls, energy_usage_model, info: ValidationInfo): @@ -273,7 +294,7 @@ class SystemComponentConditions(EcalcBaseModel): crossover: list[Crossover] -class ConsumerSystem(BaseConsumer): +class ConsumerSystem(BaseConsumer, Emitter): component_type: Literal[ComponentType.CONSUMER_SYSTEM_V2] = Field( ComponentType.CONSUMER_SYSTEM_V2, title="TYPE", @@ -283,6 +304,27 @@ class ConsumerSystem(BaseConsumer): stream_conditions_priorities: Priorities[SystemStreamConditions] consumers: Union[list[CompressorComponent], list[PumpComponent]] + def is_fuel_consumer(self) -> bool: + return self.consumes == ConsumptionType.FUEL + + def evaluate_emissions( + self, + energy_context: ComponentEnergyContext, + energy_model: EnergyModel, + expression_evaluator: ExpressionEvaluator, + ) -> Optional[dict[str, EmissionResult]]: + if self.is_fuel_consumer(): + assert self.fuel is not None + fuel_model = FuelModel(self.fuel) + fuel_usage = energy_context.get_fuel_usage() + + assert fuel_usage is not None + + return fuel_model.evaluate_emissions( + expression_evaluator=expression_evaluator, + fuel_rate=fuel_usage.values, + ) + def get_graph(self) -> ComponentGraph: graph = ComponentGraph() graph.add_node(self) @@ -340,7 +382,7 @@ def evaluate_stream_conditions( return dict(parsed_priorities) -class GeneratorSet(BaseEquipment): +class GeneratorSet(BaseEquipment, Emitter): component_type: Literal[ComponentType.GENERATOR_SET] = ComponentType.GENERATOR_SET fuel: dict[Period, FuelType] generator_set_model: dict[Period, GeneratorSetSampled] @@ -358,6 +400,23 @@ class GeneratorSet(BaseEquipment): 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." ) + + def evaluate_emissions( + self, + energy_context: ComponentEnergyContext, + energy_model: EnergyModel, + expression_evaluator: ExpressionEvaluator, + ) -> Optional[dict[str, EmissionResult]]: + fuel_model = FuelModel(self.fuel) + fuel_usage = energy_context.get_fuel_usage() + + assert fuel_usage is not None + + return fuel_model.evaluate_emissions( + expression_evaluator=expression_evaluator, + fuel_rate=fuel_usage.values, + ) + _validate_genset_temporal_models = field_validator("generator_set_model", "fuel")(validate_temporal_model) @field_validator("user_defined_category", mode="before") @@ -564,3 +623,79 @@ def _convert_keys_in_dictionary_from_str_to_periods(data: dict[Union[str, Period } else: return data + + +class FuelModel: + """A function to evaluate fuel related attributes for different time period + For each period, there is a data object with expressions for fuel related + attributes which may be evaluated for some variables and a fuel_rate. + """ + + def __init__(self, fuel_time_function_dict: dict[Period, FuelType]): + logger.debug("Creating fuel model") + self.temporal_fuel_model = fuel_time_function_dict + + def evaluate_emissions( + self, expression_evaluator: ExpressionEvaluator, fuel_rate: list[float] + ) -> dict[str, EmissionResult]: + """Evaluate fuel related expressions and results for a TimeSeriesCollection and a + fuel_rate array. + + First the fuel parameters are calculated by evaluating the fuel expressions and + the time_series object. + + Then the resulting emission volume is calculated based on the fuel rate: + - emission_rate = emission_factor * fuel_rate + + This is done per time period and all fuel related results both in terms of + fuel types and time periods, are merged into one common fuel collection results object. + + The length of the fuel_rate array must equal the length of the global list of periods. + It is assumed that the fuel_rate array origins from calculations based on the same time_series + object and thus will have the same length when used in this method. + """ + logger.debug("Evaluating fuel usage and emissions") + + fuel_rate = np.asarray(fuel_rate) + + # Creating a pseudo-default dict with all the emitters as keys. This is to handle changes in a temporal model. + emissions = { + emission_name: EmissionResult.create_empty(name=emission_name, periods=Periods([])) + for emission_name in { + emission.name for _, model in self.temporal_fuel_model.items() for emission in model.emissions + } + } + + for temporal_period, model in self.temporal_fuel_model.items(): + if Period.intersects(temporal_period, expression_evaluator.get_period()): + start_index, end_index = temporal_period.get_period_indices(expression_evaluator.get_periods()) + variables_map_this_period = expression_evaluator.get_subset( + start_index=start_index, + end_index=end_index, + ) + fuel_rate_this_period = fuel_rate[start_index:end_index] + for emission in model.emissions: + factor = variables_map_this_period.evaluate(expression=emission.factor) + + emission_rate_kg_per_day = fuel_rate_this_period * factor + emission_rate_tons_per_day = Unit.KILO_PER_DAY.to(Unit.TONS_PER_DAY)(emission_rate_kg_per_day) + + result = EmissionResult( + name=emission.name, + periods=variables_map_this_period.get_periods(), + rate=TimeSeriesStreamDayRate( + periods=variables_map_this_period.get_periods(), + values=emission_rate_tons_per_day.tolist(), + unit=Unit.TONS_PER_DAY, + ), + ) + + emissions[emission.name].extend(result) + + for name in emissions: + if name not in [emission.name for emission in model.emissions]: + emissions[name].extend( + EmissionResult.create_empty(name=name, periods=variables_map_this_period.get_periods()) + ) + + return dict(sorted(emissions.items())) diff --git a/src/libecalc/presentation/yaml/yaml_types/emitters/yaml_venting_emitter.py b/src/libecalc/presentation/yaml/yaml_types/emitters/yaml_venting_emitter.py index 3bab3bf262..a537c2926d 100644 --- a/src/libecalc/presentation/yaml/yaml_types/emitters/yaml_venting_emitter.py +++ b/src/libecalc/presentation/yaml/yaml_types/emitters/yaml_venting_emitter.py @@ -1,6 +1,6 @@ import enum from datetime import datetime -from typing import Annotated, Literal, Union +from typing import Annotated, Literal, Optional, Union import numpy as np from pydantic import ( @@ -10,6 +10,9 @@ ) from pydantic_core.core_schema import ValidationInfo +from libecalc.application.energy.component_energy_context import ComponentEnergyContext +from libecalc.application.energy.emitter import Emitter +from libecalc.application.energy.energy_model import EnergyModel from libecalc.common.component_type import ComponentType from libecalc.common.string.string_utils import generate_id from libecalc.common.temporal_model import TemporalModel @@ -21,6 +24,7 @@ TimeSeriesStreamDayRate, ) from libecalc.common.variables import ExpressionEvaluator +from libecalc.core.result.emission import EmissionResult from libecalc.dto.types import ConsumerUserDefinedCategoryType from libecalc.dto.utils.validators import ComponentNameStr, convert_expression from libecalc.expression import Expression @@ -79,7 +83,7 @@ def check_name(cls, name, info: ValidationInfo): return name.lower() -class YamlDirectTypeEmitter(YamlBase): +class YamlDirectTypeEmitter(YamlBase, Emitter): model_config = ConfigDict(title="VentingEmitter") @property @@ -116,6 +120,27 @@ def id(self) -> str: description="The emissions for the emitter of type DIRECT_EMISSION", ) + def evaluate_emissions( + self, + energy_context: ComponentEnergyContext, + energy_model: EnergyModel, + expression_evaluator: ExpressionEvaluator, + ) -> Optional[dict[str, EmissionResult]]: + venting_emitter_results = {} + emission_rates = self.get_emissions( + expression_evaluator=expression_evaluator, + regularity=energy_model.get_regularity(self.id), + ) + + for emission_name, emission_rate in emission_rates.items(): + emission_result = EmissionResult( + name=emission_name, + periods=expression_evaluator.get_periods(), + rate=emission_rate, + ) + venting_emitter_results[emission_name] = emission_result + return venting_emitter_results + def get_emissions( self, expression_evaluator: ExpressionEvaluator, regularity: dict[datetime, Expression] ) -> dict[str, TimeSeriesStreamDayRate]: @@ -161,7 +186,7 @@ def check_user_defined_category(cls, category, info: ValidationInfo): return category -class YamlOilTypeEmitter(YamlBase): +class YamlOilTypeEmitter(YamlBase, Emitter): model_config = ConfigDict(title="VentingEmitter") @property @@ -198,6 +223,27 @@ def id(self) -> str: description="The volume rate and emissions for the emitter of type OIL_VOLUME", ) + def evaluate_emissions( + self, + energy_context: ComponentEnergyContext, + energy_model: EnergyModel, + expression_evaluator: ExpressionEvaluator, + ) -> Optional[dict[str, EmissionResult]]: + venting_emitter_results = {} + emission_rates = self.get_emissions( + expression_evaluator=expression_evaluator, + regularity=energy_model.get_regularity(self.id), + ) + + for emission_name, emission_rate in emission_rates.items(): + emission_result = EmissionResult( + name=emission_name, + periods=expression_evaluator.get_periods(), + rate=emission_rate, + ) + venting_emitter_results[emission_name] = emission_result + return venting_emitter_results + def get_emissions( self, expression_evaluator: ExpressionEvaluator, diff --git a/tests/libecalc/core/models/test_fuel_model.py b/tests/libecalc/core/models/test_fuel_model.py index 71d63149b6..167c7ea4eb 100644 --- a/tests/libecalc/core/models/test_fuel_model.py +++ b/tests/libecalc/core/models/test_fuel_model.py @@ -8,7 +8,7 @@ from libecalc.common.units import Unit from libecalc.common.utils.rates import RateType, TimeSeriesRate from libecalc.common.variables import VariablesMap -from libecalc.core.models.fuel import FuelModel +from libecalc.dto.components import FuelModel from libecalc.expression import Expression @@ -30,7 +30,7 @@ def test_fuel_model(): variables_map = VariablesMap(time_vector=timesteps) emissions = fuel_model.evaluate_emissions( expression_evaluator=variables_map, - fuel_rate=np.asarray([1, 2, 3]), + fuel_rate=[1, 2, 3], ) emission_result = emissions["co2"] @@ -79,7 +79,7 @@ def test_temporal_fuel_model(): datetime(2003, 1, 1), ] ), - fuel_rate=np.asarray([1, 2, 3]), + fuel_rate=[1, 2, 3], ) # We should have both CO2 and CH4 as emissions