From a3405bb7c11b204d22c95f32aed33f81fbbff952 Mon Sep 17 00:00:00 2001 From: Jostein Solaas <33114722+jsolaas@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:00:20 +0100 Subject: [PATCH 1/6] chore: init mapping --- .../process_units/recirculation_loop.py | 36 +- .../anti_surge/individual_asv.py | 4 +- .../pressure_control/individual_asv.py | 12 +- .../process_solver/process_system_runner.py | 4 +- .../common_stream_distribution.py | 29 +- .../individual_stream_distribution.py | 15 + .../priorities_stream_distribution.py | 5 +- .../yaml/domain/reference_service.py | 18 + .../yaml/mappers/consumer_function_mapper.py | 22 +- .../presentation/yaml/mappers/fluid_mapper.py | 4 +- .../presentation/yaml/mappers/model.py | 4 +- .../yaml/mappers/process_simulation_mapper.py | 527 ++++++++++++++++++ src/libecalc/presentation/yaml/test.yaml | 83 --- .../yaml/yaml_models/yaml_model.py | 26 + .../yaml/yaml_reference_service.py | 64 ++- .../components/yaml_process_system.py | 6 +- .../yaml_types/streams/yaml_inlet_stream.py | 12 +- .../process_units/test_recirculation_loop.py | 13 +- .../input/mappers/test_consumer_chart.py | 8 +- .../input/mappers/test_model_mapper.py | 18 + 20 files changed, 758 insertions(+), 152 deletions(-) create mode 100644 src/libecalc/domain/process/stream_distribution/individual_stream_distribution.py create mode 100644 src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py delete mode 100644 src/libecalc/presentation/yaml/test.yaml diff --git a/src/libecalc/domain/process/entities/process_units/recirculation_loop.py b/src/libecalc/domain/process/entities/process_units/recirculation_loop.py index 7bec06674d..495289b838 100644 --- a/src/libecalc/domain/process/entities/process_units/recirculation_loop.py +++ b/src/libecalc/domain/process/entities/process_units/recirculation_loop.py @@ -57,7 +57,7 @@ class RecirculationLoop(ProcessSystem): def __init__( self, process_system_id: ProcessSystemId, - inner_process: ProcessSystem, + inner_process: ProcessSystem | ProcessUnit, fluid_service: FluidService, recirculation_rate: float = 0, ): @@ -77,20 +77,36 @@ def __init__( ) def _validate_inner_process(self): - assert isinstance(self._inner_process, ProcessSystem), "Recirculation loop should contain a ProcessSystem" - for process_unit in self._inner_process.get_process_units(): - if isinstance(process_unit, Mixer | Splitter): - raise DomainValidationException("Recirculation loop cannot contain splitters or mixers") + if not isinstance(self._inner_process, ProcessSystem | ProcessUnit): + raise DomainValidationException( + "Recirculation loop should contain a ProcessSystem with a compressor or a single compressor" + ) + if isinstance(self._inner_process, ProcessSystem): + for process_unit in self._inner_process.get_process_units(): + if isinstance(process_unit, Splitter | Mixer): + raise DomainValidationException("Recirculation loop cannot contain splitters or mixers") + if isinstance(self._inner_process, ProcessUnit): + if not isinstance(self._inner_process, Compressor): + raise DomainValidationException( + "Recirculation loop should contain a ProcessSystem with a compressor or a single compressor" + ) def get_id(self) -> ProcessSystemId: return self._id def get_process_units(self) -> Sequence[ProcessUnit | ProcessSystem]: - return [ - self._mixer, - *self._inner_process.get_process_units(), - self._splitter, - ] + if isinstance(self._inner_process, ProcessSystem): + return [ + self._mixer, + *self._inner_process.get_process_units(), + self._splitter, + ] + else: + return [ + self._mixer, + self._inner_process, + self._splitter, + ] def set_recirculation_rate(self, rate: float): self._mixer.set_mix_rate(rate) diff --git a/src/libecalc/domain/process/process_solver/anti_surge/individual_asv.py b/src/libecalc/domain/process/process_solver/anti_surge/individual_asv.py index 1cb91446c1..8f9f5ecdb7 100644 --- a/src/libecalc/domain/process/process_solver/anti_surge/individual_asv.py +++ b/src/libecalc/domain/process/process_solver/anti_surge/individual_asv.py @@ -23,8 +23,8 @@ class IndividualASVAntiSurgeStrategy(AntiSurgeStrategy): def __init__( self, - recirculation_loop_ids: list[ProcessSystemId], - compressors: list[Compressor], + recirculation_loop_ids: Sequence[ProcessSystemId], + compressors: Sequence[Compressor], simulator: ProcessRunner, ): assert len(recirculation_loop_ids) == len(compressors) diff --git a/src/libecalc/domain/process/process_solver/pressure_control/individual_asv.py b/src/libecalc/domain/process/process_solver/pressure_control/individual_asv.py index 406594216e..03f571c4e1 100644 --- a/src/libecalc/domain/process/process_solver/pressure_control/individual_asv.py +++ b/src/libecalc/domain/process/process_solver/pressure_control/individual_asv.py @@ -28,8 +28,8 @@ class IndividualASVPressureControlStrategy(PressureControlStrategy): def __init__( self, simulator: ProcessRunner, - recirculation_loop_ids: list[ProcessSystemId], - compressors: list[Compressor], + recirculation_loop_ids: Sequence[ProcessSystemId], + compressors: Sequence[Compressor], root_finding_strategy: RootFindingStrategy, ): self._simulator = simulator @@ -120,8 +120,8 @@ class IndividualASVRateControlStrategy(PressureControlStrategy): def __init__( self, simulator: ProcessRunner, - recirculation_loop_ids: list[ProcessSystemId], - compressors: list[Compressor], + recirculation_loop_ids: Sequence[ProcessSystemId], + compressors: Sequence[Compressor], ): self._simulator = simulator self._recirculation_loop_ids = recirculation_loop_ids @@ -206,8 +206,8 @@ def get_outlet_stream(rate_fraction: float) -> FluidStream: def _minimum_achievable_pressure( simulator: ProcessRunner, - recirculation_loop_ids: list[ProcessSystemId], - compressors: list[Compressor], + recirculation_loop_ids: Sequence[ProcessSystemId], + compressors: Sequence[Compressor], inlet_stream: FluidStream, ) -> Sequence[Configuration[RecirculationConfiguration | ChokeConfiguration]]: """Propagate with maximum recirculation on every stage to find the lowest achievable pressure.""" diff --git a/src/libecalc/domain/process/process_solver/process_system_runner.py b/src/libecalc/domain/process/process_solver/process_system_runner.py index e418a3bddf..c6ab840bf5 100644 --- a/src/libecalc/domain/process/process_solver/process_system_runner.py +++ b/src/libecalc/domain/process/process_solver/process_system_runner.py @@ -1,4 +1,4 @@ -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from libecalc.domain.process.entities.process_units.choke import Choke from libecalc.domain.process.entities.process_units.recirculation_loop import RecirculationLoop @@ -14,7 +14,7 @@ class ProcessSystemRunner(ProcessRunner): - def __init__(self, shaft: Shaft, units: list[ProcessUnit | ProcessSystem]): + def __init__(self, shaft: Shaft, units: Sequence[ProcessUnit | ProcessSystem]): self._shaft = shaft self._units = {unit.get_id(): unit for unit in units} self._configurations: dict[ProcessUnitId | ProcessSystemId | ShaftId, Configuration] = {} diff --git a/src/libecalc/domain/process/stream_distribution/common_stream_distribution.py b/src/libecalc/domain/process/stream_distribution/common_stream_distribution.py index 8efc5f2309..dd507983e3 100644 --- a/src/libecalc/domain/process/stream_distribution/common_stream_distribution.py +++ b/src/libecalc/domain/process/stream_distribution/common_stream_distribution.py @@ -1,12 +1,16 @@ -import abc from collections import defaultdict + +import abc +import networkx as nx from collections.abc import Hashable, Iterable from dataclasses import dataclass +from functools import cached_property from typing import Generic, TypeVar +from collections.abc import Mapping +from collections.abc import Callable -import networkx as nx -from libecalc.domain.process.stream_distribution.stream_distribution import StreamDistribution from libecalc.domain.component_validation_error import DomainValidationException +from libecalc.domain.process.stream_distribution.stream_distribution import StreamDistribution from libecalc.domain.process.value_objects.fluid_stream import FluidService, FluidStream @@ -27,16 +31,16 @@ class Overflow(Generic[T]): class CommonStreamDistribution(StreamDistribution, Generic[T]): def __init__( self, - inlet_stream: FluidStream, - items: dict[T, HasCapacity], + inlet_stream: FluidStream | Callable[[], FluidStream], + items: Mapping[T, HasCapacity], rate_fractions: list[float], overflows: list[Overflow[T]], fluid_service: FluidService, ): self._items = items - self._rates = [inlet_stream.standard_rate_sm3_per_day * rate_fraction for rate_fraction in rate_fractions] + self._rate_fractions = rate_fractions self._overflows = overflows - self._inlet_stream = inlet_stream + self._inlet_stream_input = inlet_stream self._fluid_service = fluid_service self._overflow_graph: nx.DiGraph[T] = nx.DiGraph() for item_id in self._items.keys(): @@ -48,6 +52,17 @@ def __init__( if not nx.is_directed_acyclic_graph(self._overflow_graph): raise DomainValidationException("Overflow can not be cyclic") + @cached_property + def _inlet_stream(self) -> FluidStream: + if callable(self._inlet_stream_input): + return self._inlet_stream_input() + else: + return self._inlet_stream_input + + @property + def _rates(self): + return [self._inlet_stream.standard_rate_sm3_per_day * rate_fraction for rate_fraction in self._rate_fractions] + def get_number_of_streams(self) -> int: return len(self._rates) diff --git a/src/libecalc/domain/process/stream_distribution/individual_stream_distribution.py b/src/libecalc/domain/process/stream_distribution/individual_stream_distribution.py new file mode 100644 index 0000000000..c81ba2a0f7 --- /dev/null +++ b/src/libecalc/domain/process/stream_distribution/individual_stream_distribution.py @@ -0,0 +1,15 @@ +from collections.abc import Sequence + +from libecalc.domain.process.stream_distribution.stream_distribution import StreamDistribution +from libecalc.domain.process.value_objects.fluid_stream import FluidStream + + +class IndividualStreamDistribution(StreamDistribution): + def __init__(self, streams: Sequence[FluidStream]): + self._streams = streams + + def get_number_of_streams(self) -> int: + return len(self._streams) + + def get_streams(self) -> list[FluidStream]: + return list(self._streams) diff --git a/src/libecalc/domain/process/stream_distribution/priorities_stream_distribution.py b/src/libecalc/domain/process/stream_distribution/priorities_stream_distribution.py index 2996bd07e3..4d8b784a5e 100644 --- a/src/libecalc/domain/process/stream_distribution/priorities_stream_distribution.py +++ b/src/libecalc/domain/process/stream_distribution/priorities_stream_distribution.py @@ -1,4 +1,5 @@ import abc +from collections.abc import Sequence from libecalc.domain.process.stream_distribution.stream_distribution import StreamDistribution from libecalc.domain.process.value_objects.fluid_stream import FluidStream @@ -9,7 +10,7 @@ class HasValidity(abc.ABC): def is_valid(self, inlet_stream: FluidStream) -> bool: ... -def find_first_valid(stream_distributions: list[StreamDistribution], items: list[HasValidity]) -> list[FluidStream]: +def find_first_valid(stream_distributions: list[StreamDistribution], items: Sequence[HasValidity]) -> list[FluidStream]: assert len(stream_distributions) > 0 for stream_distribution in stream_distributions: streams = stream_distribution.get_streams() @@ -20,7 +21,7 @@ def find_first_valid(stream_distributions: list[StreamDistribution], items: list class PrioritiesStreamDistribution(StreamDistribution): - def __init__(self, stream_distributions: list[StreamDistribution], items: list[HasValidity]): + def __init__(self, stream_distributions: list[StreamDistribution], items: Sequence[HasValidity]): self._stream_distributions = stream_distributions self._items = items diff --git a/src/libecalc/presentation/yaml/domain/reference_service.py b/src/libecalc/presentation/yaml/domain/reference_service.py index 2065f9fe69..1140d2fd3c 100644 --- a/src/libecalc/presentation/yaml/domain/reference_service.py +++ b/src/libecalc/presentation/yaml/domain/reference_service.py @@ -4,6 +4,11 @@ from libecalc.domain.component_validation_error import DomainValidationException from libecalc.presentation.yaml.mappers.yaml_path import YamlPath +from libecalc.presentation.yaml.yaml_types.components.yaml_process_system import ( + YamlCompressor, + YamlCompressorStageProcessSystem, + YamlSerialProcessSystem, +) from libecalc.presentation.yaml.yaml_types.facility_model.yaml_facility_model import ( YamlCompressorTabularModel, YamlGeneratorSetModel, @@ -24,6 +29,7 @@ YamlVariableSpeedCompressorTrain, YamlVariableSpeedCompressorTrainMultipleStreamsAndPressures, ) +from libecalc.presentation.yaml.yaml_types.streams.yaml_inlet_stream import YamlInletStream class InvalidReferenceException(DomainValidationException): @@ -72,3 +78,15 @@ def get_pump_model(self, reference: str) -> YamlPumpChartSingleSpeed | YamlPumpC @abc.abstractmethod def get_tabulated_model(self, reference: str) -> YamlTabularModel: ... + + @abc.abstractmethod + def get_process_system(self, reference: str) -> YamlSerialProcessSystem: ... + + @abc.abstractmethod + def get_compressor_stage(self, reference: str) -> YamlCompressorStageProcessSystem: ... + + @abc.abstractmethod + def get_compressor(self, reference: str) -> YamlCompressor: ... + + @abc.abstractmethod + def get_stream(self, reference: str) -> YamlInletStream: ... diff --git a/src/libecalc/presentation/yaml/mappers/consumer_function_mapper.py b/src/libecalc/presentation/yaml/mappers/consumer_function_mapper.py index 26d9740f98..abdc6e4e00 100644 --- a/src/libecalc/presentation/yaml/mappers/consumer_function_mapper.py +++ b/src/libecalc/presentation/yaml/mappers/consumer_function_mapper.py @@ -67,7 +67,7 @@ from libecalc.domain.time_series_flow_rate import TimeSeriesFlowRate from libecalc.domain.time_series_variable import TimeSeriesVariable from libecalc.expression import Expression -from libecalc.expression.expression import InvalidExpressionError +from libecalc.expression.expression import ExpressionType, InvalidExpressionError from libecalc.presentation.yaml.domain.ecalc_components import ( CompressorProcessSystemComponent, CompressorSampledComponent, @@ -90,16 +90,16 @@ _get_float_column_or_none, ) from libecalc.presentation.yaml.mappers.fluid_mapper import ( - _composition_fluid_model_mapper, - _predefined_fluid_model_mapper, + composition_fluid_model_mapper, + predefined_fluid_model_mapper, ) from libecalc.presentation.yaml.mappers.model import ( InvalidChartResourceException, _generic_from_design_point_compressor_chart_mapper, _pressure_control_mapper, - _single_speed_compressor_chart_mapper, - _variable_speed_compressor_chart_mapper, map_yaml_to_fixed_speed_pressure_control, + single_speed_compressor_chart_mapper, + variable_speed_compressor_chart_mapper, ) from libecalc.presentation.yaml.mappers.simplified_train_mapping_utils import ( CompressorOperationalTimeSeries, @@ -167,7 +167,7 @@ def __init__(self, actual: ConsumptionType, expected: ConsumptionType): super().__init__(message) -def _handle_condition_list(conditions: list[str]): +def handle_condition_list(conditions: list[ExpressionType]): conditions_with_parentheses = [f"({condition})" for condition in conditions] return " {*} ".join(conditions_with_parentheses) @@ -182,7 +182,7 @@ def _map_condition(energy_usage_model: ConditionedModel) -> str | int | float | condition_value = energy_usage_model.condition return condition_value elif energy_usage_model.conditions: - return _handle_condition_list(energy_usage_model.conditions) # type: ignore[arg-type] + return handle_condition_list(energy_usage_model.conditions) # type: ignore[arg-type] else: return None @@ -295,9 +295,9 @@ def _get_fluid_model(self, reference: str) -> FluidModel: model = self._reference_service.get_fluid(reference) try: if isinstance(model, YamlPredefinedFluidModel): - return _predefined_fluid_model_mapper(model) + return predefined_fluid_model_mapper(model) elif isinstance(model, YamlCompositionFluidModel): - return _composition_fluid_model_mapper(model) + return composition_fluid_model_mapper(model) else: assert_never(model) except ValidationError as ve: @@ -319,11 +319,11 @@ def _get_compressor_chart( assert isinstance(model, YamlSingleSpeedChart | YamlVariableSpeedChart) # Generic charts are handled separately try: if isinstance(model, YamlSingleSpeedChart): - return _single_speed_compressor_chart_mapper( + return single_speed_compressor_chart_mapper( model_config=model, resources=self._resources, control_margin=control_margin ) elif isinstance(model, YamlVariableSpeedChart): - return _variable_speed_compressor_chart_mapper( + return variable_speed_compressor_chart_mapper( model_config=model, resources=self._resources, control_margin=control_margin ) else: diff --git a/src/libecalc/presentation/yaml/mappers/fluid_mapper.py b/src/libecalc/presentation/yaml/mappers/fluid_mapper.py index fe246d9a52..de803deb66 100644 --- a/src/libecalc/presentation/yaml/mappers/fluid_mapper.py +++ b/src/libecalc/presentation/yaml/mappers/fluid_mapper.py @@ -87,7 +87,7 @@ } -def _predefined_fluid_model_mapper(model_config: YamlPredefinedFluidModel) -> FluidModel: +def predefined_fluid_model_mapper(model_config: YamlPredefinedFluidModel) -> FluidModel: predefined_composition_type = model_config.gas_type eos_model_type = model_config.eos_model return FluidModel( @@ -96,7 +96,7 @@ def _predefined_fluid_model_mapper(model_config: YamlPredefinedFluidModel) -> Fl ) -def _composition_fluid_model_mapper( +def composition_fluid_model_mapper( model_config: YamlCompositionFluidModel, ) -> FluidModel: user_defined_composition = model_config.composition diff --git a/src/libecalc/presentation/yaml/mappers/model.py b/src/libecalc/presentation/yaml/mappers/model.py index 05ed296c3b..1fa70d8c98 100644 --- a/src/libecalc/presentation/yaml/mappers/model.py +++ b/src/libecalc/presentation/yaml/mappers/model.py @@ -68,7 +68,7 @@ def _file_mark(self) -> FileMark | None: return None -def _single_speed_compressor_chart_mapper( +def single_speed_compressor_chart_mapper( model_config: YamlSingleSpeedChart, resources: Resources, control_margin: float | None, @@ -100,7 +100,7 @@ def _single_speed_compressor_chart_mapper( return chart_data -def _variable_speed_compressor_chart_mapper( +def variable_speed_compressor_chart_mapper( model_config: YamlVariableSpeedChart, resources: Resources, control_margin: float | None, diff --git a/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py new file mode 100644 index 0000000000..05a6b69526 --- /dev/null +++ b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py @@ -0,0 +1,527 @@ +from abc import ABC +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from typing import Literal, assert_never + +from libecalc.common.errors.exceptions import InvalidResourceException +from libecalc.common.time_utils import Period +from libecalc.common.units import Unit +from libecalc.common.variables import ExpressionEvaluator +from libecalc.domain.component_validation_error import DomainValidationException +from libecalc.domain.process.entities.process_units.choke import Choke +from libecalc.domain.process.entities.process_units.compressor import Compressor +from libecalc.domain.process.entities.process_units.recirculation_loop import RecirculationLoop +from libecalc.domain.process.entities.shaft import Shaft +from libecalc.domain.process.entities.shaft.shaft import VariableSpeedShaft +from libecalc.domain.process.process_solver.anti_surge.anti_surge_strategy import AntiSurgeStrategy +from libecalc.domain.process.process_solver.anti_surge.common_asv import CommonASVAntiSurgeStrategy +from libecalc.domain.process.process_solver.anti_surge.individual_asv import IndividualASVAntiSurgeStrategy +from libecalc.domain.process.process_solver.pressure_control.common_asv import CommonASVPressureControlStrategy +from libecalc.domain.process.process_solver.pressure_control.downstream_choke import ( + DownstreamChokePressureControlStrategy, +) +from libecalc.domain.process.process_solver.pressure_control.individual_asv import ( + IndividualASVPressureControlStrategy, + IndividualASVRateControlStrategy, +) +from libecalc.domain.process.process_solver.pressure_control.pressure_control_strategy import PressureControlStrategy +from libecalc.domain.process.process_solver.pressure_control.upstream_choke import UpstreamChokePressureControlStrategy +from libecalc.domain.process.process_solver.process_runner import ProcessRunner +from libecalc.domain.process.process_solver.process_system_runner import ProcessSystemRunner +from libecalc.domain.process.process_solver.search_strategies import ScipyRootFindingStrategy +from libecalc.domain.process.process_system.process_system import ( + ProcessSystem, + ProcessSystemId, + create_process_system_id, +) +from libecalc.domain.process.process_system.process_unit import ProcessUnitId, create_process_unit_id +from libecalc.domain.process.process_system.serial_process_system import SerialProcessSystem +from libecalc.domain.process.stream_distribution.common_stream_distribution import ( + CommonStreamDistribution, + HasCapacity, + Overflow, +) +from libecalc.domain.process.stream_distribution.individual_stream_distribution import IndividualStreamDistribution +from libecalc.domain.process.stream_distribution.priorities_stream_distribution import ( + HasValidity, + PrioritiesStreamDistribution, +) +from libecalc.domain.process.stream_distribution.stream_distribution import StreamDistribution +from libecalc.domain.process.value_objects.chart.chart import ChartData +from libecalc.domain.process.value_objects.fluid_stream import FluidModel, FluidService, FluidStream +from libecalc.domain.regularity import Regularity +from libecalc.domain.resource import Resources +from libecalc.domain.time_series_flow_rate import TimeSeriesFlowRate +from libecalc.expression.expression import ExpressionType +from libecalc.presentation.yaml.domain.expression_time_series_flow_rate import ExpressionTimeSeriesFlowRate +from libecalc.presentation.yaml.domain.reference_service import ReferenceService +from libecalc.presentation.yaml.domain.time_series_expression import TimeSeriesExpression +from libecalc.presentation.yaml.mappers.charts.user_defined_chart_data import UserDefinedChartData +from libecalc.presentation.yaml.mappers.consumer_function_mapper import handle_condition_list +from libecalc.presentation.yaml.mappers.fluid_mapper import ( + composition_fluid_model_mapper, + predefined_fluid_model_mapper, +) +from libecalc.presentation.yaml.mappers.model import InvalidChartResourceException +from libecalc.presentation.yaml.yaml_types.components.yaml_expression_type import YamlExpressionType +from libecalc.presentation.yaml.yaml_types.components.yaml_process_system import ( + ProcessSystemReference, + YamlCommonStreamDistribution, + YamlCompressor, + YamlCompressorModelChart, + YamlCompressorStageProcessSystem, + YamlIndividualStreamDistribution, + YamlProcessSimulation, + YamlSerialProcessSystem, +) +from libecalc.presentation.yaml.yaml_types.models import YamlFluidModel +from libecalc.presentation.yaml.yaml_types.models.yaml_compressor_stages import YamlControlMarginUnits +from libecalc.presentation.yaml.yaml_types.models.yaml_fluid import YamlCompositionFluidModel, YamlPredefinedFluidModel +from libecalc.presentation.yaml.yaml_types.streams.yaml_inlet_stream import YamlInletStream, YamlInletStreamRate +from libecalc.presentation.yaml.yaml_types.yaml_data_or_file import YamlFile + + +@dataclass +class PressureControlConfig: + type: Literal["UPSTREAM_CHOKE", "DOWNSTREAM_CHOKE", "COMMON_ASV", "INDIVIDUAL_ASV_RATE", "INDIVIDUAL_ASV_PRESSURE"] + choke_id: ProcessUnitId | None + recirculation_loop_ids: Sequence[ProcessSystemId] + + +class StreamDistributionItem(HasCapacity, HasValidity, ABC): ... + + +class CompressorTrainBuilder: + def __init__(self, compressors: Sequence[Compressor], fluid_service: FluidService): + self._fluid_service = fluid_service + self._process_system = SerialProcessSystem( + process_system_id=create_process_system_id(), propagators=compressors + ) + self._compressor_ids = [compressor.get_id() for compressor in compressors] + + def with_individual_asv(self) -> list[ProcessSystemId]: + recirculation_loop_ids = [create_process_system_id() for _ in range(len(self._compressor_ids))] + process_units = [ + RecirculationLoop( + process_system_id=recirculation_loop_id, inner_process=compressor, fluid_service=self._fluid_service + ) + for recirculation_loop_id, compressor in zip( + recirculation_loop_ids, self._process_system.get_process_units(), strict=True + ) + ] + self._process_system = SerialProcessSystem( + process_system_id=create_process_system_id(), propagators=process_units + ) + return recirculation_loop_ids + + def with_common_asv(self) -> ProcessSystemId: + recirculation_loop_id = create_process_system_id() + recirculation_loop = RecirculationLoop( + process_system_id=recirculation_loop_id, + inner_process=self._process_system, + fluid_service=self._fluid_service, + ) + self._process_system = SerialProcessSystem( + process_system_id=create_process_system_id(), propagators=[recirculation_loop] + ) + return recirculation_loop_id + + def with_upstream_choke(self) -> ProcessUnitId: + choke_id = create_process_unit_id() + self._process_system = SerialProcessSystem( + process_system_id=create_process_system_id(), + propagators=[ + Choke(process_unit_id=choke_id, fluid_service=self._fluid_service), + *self._process_system.get_process_units(), + ], + ) + return choke_id + + def with_downstream_choke(self) -> ProcessUnitId: + choke_id = create_process_unit_id() + self._process_system = SerialProcessSystem( + process_system_id=create_process_system_id(), + propagators=[ + *self._process_system.get_process_units(), + Choke(process_unit_id=choke_id, fluid_service=self._fluid_service), + ], + ) + return choke_id + + def build(self) -> ProcessSystem: + return self._process_system + + +class ProcessSimulationMapper: + def __init__( + self, + expression_evaluator: ExpressionEvaluator, + fluid_service: FluidService, + reference_service: ReferenceService, + process_simulation_period: Period, + resources: Resources, + ): + self._expression_evaluator = expression_evaluator.get_subset_for_period(process_simulation_period) + self._fluid_service = fluid_service + self._reference_service = reference_service + self._resources = resources + + def _resolve_train_reference(self, ref: str | YamlSerialProcessSystem) -> YamlSerialProcessSystem: + if isinstance(ref, str): + return self._reference_service.get_process_system(reference=ref) + else: + return ref + + def _resolve_compressor_stage_reference( + self, ref: str | YamlCompressorStageProcessSystem + ) -> YamlCompressorStageProcessSystem: + if isinstance(ref, str): + return self._reference_service.get_compressor_stage(reference=ref) + else: + return ref + + def _resolve_compressor_reference(self, ref: str | YamlCompressor) -> YamlCompressor: + if isinstance(ref, str): + return self._reference_service.get_compressor(reference=ref) + else: + return ref + + def _get_compressor_chart(self, yaml_compressor_model_chart: YamlCompressorModelChart) -> ChartData: + yaml_chart = yaml_compressor_model_chart.chart + yaml_curves = yaml_chart.curves + control_margin = yaml_compressor_model_chart.control_margin + control_margin_unit = ( + Unit.FRACTION if control_margin.unit == YamlControlMarginUnits.FRACTION else Unit.PERCENTAGE + ) + control_margin_fraction = control_margin_unit.to(Unit.FRACTION)(control_margin.value) + + if isinstance(yaml_curves, YamlFile): + resource_name = yaml_curves.file + resource = self._resources.get(resource_name) + if resource is None: + raise DomainValidationException(f"Resource '{resource_name}' not found for variable speed chart.") + try: + return UserDefinedChartData.from_resource( + resource, units=yaml_chart.units, is_single_speed=False, control_margin=control_margin_fraction + ) + except InvalidResourceException as e: + raise InvalidChartResourceException( + message=str(e), file_mark=e.file_mark, resource_name=resource_name + ) from e + else: + return UserDefinedChartData.from_yaml_curves( + yaml_curves, units=yaml_chart.units, control_margin=control_margin_fraction + ) + + def _get_compressor(self, yaml_compressor_stage: YamlCompressorStageProcessSystem, shaft: Shaft) -> Compressor: + # TODO: deal with stage + yaml_compressor = self._resolve_compressor_reference(yaml_compressor_stage.compressor) + + chart: ChartData = self._get_compressor_chart(yaml_compressor_model_chart=yaml_compressor.compressor_model) + + return Compressor( + process_unit_id=create_process_unit_id(), + compressor_chart=chart, + fluid_service=self._fluid_service, + shaft=shaft, + ) + + def _get_compressors(self, target: YamlSerialProcessSystem, shaft: Shaft) -> list[Compressor]: + return [ + self._get_compressor( + yaml_compressor_stage=self._resolve_compressor_stage_reference(yaml_compressor.target), shaft=shaft + ) + for yaml_compressor in target.items + ] + + def _resolve_stream_reference(self, ref: str | YamlInletStream) -> YamlInletStream: + if isinstance(ref, str): + return self._reference_service.get_stream(reference=ref) + else: + return ref + + def _resolve_fluid_model_reference(self, ref: str | YamlFluidModel) -> YamlFluidModel: + if isinstance(ref, str): + return self._reference_service.get_fluid(reference=ref) + else: + return ref + + def _map_conditions(self, condition: YamlExpressionType | None, conditions: list[YamlExpressionType] | None): + if condition: + assert isinstance(condition, ExpressionType) + return condition + else: + assert isinstance(conditions, list) + return handle_condition_list(conditions) + + def _get_regularity(self) -> Regularity: + return Regularity( + expression_evaluator=self._expression_evaluator, + target_period=self._expression_evaluator.get_period(), + ) + + def _map_rate(self, yaml_rate: YamlInletStreamRate) -> TimeSeriesFlowRate: + return ExpressionTimeSeriesFlowRate( + time_series_expression=TimeSeriesExpression( + expression_evaluator=self._expression_evaluator, + expression=yaml_rate.value, + condition=self._map_conditions(yaml_rate.condition, yaml_rate.conditions), + ), + consumption_rate_type=yaml_rate.type, + regularity=self._get_regularity(), + ) + + def _map_pressure(self, pressure: ExpressionType) -> TimeSeriesExpression: + return TimeSeriesExpression( + expression_evaluator=self._expression_evaluator, + expression=pressure, + ) + + def _map_temperature(self, temperature: ExpressionType) -> TimeSeriesExpression: + return TimeSeriesExpression( + expression_evaluator=self._expression_evaluator, + expression=temperature, + ) + + def _map_fluid_model(self, yaml_fluid_model: YamlFluidModel) -> FluidModel: + if isinstance(yaml_fluid_model, YamlPredefinedFluidModel): + return predefined_fluid_model_mapper(yaml_fluid_model) + elif isinstance(yaml_fluid_model, YamlCompositionFluidModel): + return composition_fluid_model_mapper(yaml_fluid_model) + else: + assert_never(yaml_fluid_model) + + def _map_single_stream( + self, rate: list[float], pressure: list[float], temperature: list[float], fluid_model: FluidModel, index: int + ): + return self._fluid_service.create_stream_from_standard_rate( + fluid_model=fluid_model, + pressure_bara=pressure[index], + temperature_kelvin=temperature[index], + standard_rate_m3_per_day=rate[index], + ) + + def _map_stream(self, yaml_stream: YamlInletStream) -> Iterable[tuple[Period, FluidStream]]: + rate = self._map_rate(yaml_stream.rate).get_stream_day_values() + pressure = self._map_pressure(yaml_stream.pressure).get_masked_values() + temperature = self._map_temperature(yaml_stream.temperature).get_masked_values() + fluid_model = self._map_fluid_model( + yaml_fluid_model=self._resolve_fluid_model_reference(yaml_stream.fluid_model) + ) + + for index, period in enumerate(self._expression_evaluator.get_periods().periods): + yield ( + period, + self._map_single_stream( + rate=rate, pressure=pressure, temperature=temperature, fluid_model=fluid_model, index=index + ), + ) + + def _map_common_stream_distribution( + self, + yaml_stream_distribution: YamlCommonStreamDistribution, + items: dict[ProcessSystemReference, StreamDistributionItem], + ) -> Iterable[tuple[Period, StreamDistribution]]: + inlet_stream = self._resolve_stream_reference(yaml_stream_distribution.inlet_stream) + + for index, (period, inlet_stream) in enumerate(self._map_stream(inlet_stream)): + settings = [] + for setting in yaml_stream_distribution.settings: + rate_fractions = [ + TimeSeriesExpression( + expression_evaluator=self._expression_evaluator, expression=rate_fraction + ).get_masked_values()[index] + for rate_fraction in setting.rate_fractions + ] + setting_distribution = CommonStreamDistribution( + fluid_service=self._fluid_service, + inlet_stream=inlet_stream, + overflows=[ + Overflow( + from_id=overflow.from_reference, + to_id=overflow.to_reference, + ) + for overflow in setting.overflow + ] + if setting.overflow is not None + else [], + rate_fractions=rate_fractions, + items=items, + ) + settings.append(setting_distribution) + yield ( + period, + PrioritiesStreamDistribution( + stream_distributions=settings, + items=list(items.values()), + ), + ) + + def _map_individual_stream_distribution( + self, yaml_stream_distribution: YamlIndividualStreamDistribution + ) -> Iterable[tuple[Period, StreamDistribution]]: + for period in self._expression_evaluator.get_periods().periods: + streams: list[FluidStream] = [] + for index, yaml_stream in enumerate(yaml_stream_distribution.inlet_streams): + yaml_stream = self._resolve_stream_reference(yaml_stream) + rate = self._map_rate(yaml_stream.rate).get_stream_day_values() + pressure = self._map_pressure(yaml_stream.pressure).get_masked_values() + temperature = self._map_temperature(yaml_stream.temperature).get_masked_values() + fluid_model = self._map_fluid_model( + yaml_fluid_model=self._resolve_fluid_model_reference(yaml_stream.fluid_model) + ) + streams.append( + self._map_single_stream( + rate=rate, + pressure=pressure, + temperature=temperature, + fluid_model=fluid_model, + index=index, + ) + ) + yield ( + period, + IndividualStreamDistribution( + streams=streams, + ), + ) + + def map_process_system( + self, + compressors: Sequence[Compressor], + pressure_control: Literal[ + "COMMON_ASV", "INDIVIDUAL_ASV_RATE", "INDIVIDUAL_ASV_PRESSURE", "DOWNSTREAM_CHOKE", "UPSTREAM_CHOKE" + ], + ) -> tuple[ProcessSystem, PressureControlConfig]: + builder = CompressorTrainBuilder(compressors=compressors, fluid_service=self._fluid_service) + + if pressure_control == "COMMON_ASV": + recirculation_loop_ids = [builder.with_common_asv()] + else: + recirculation_loop_ids = builder.with_individual_asv() + + choke_id = None + if pressure_control == "DOWNSTREAM_CHOKE": + choke_id = builder.with_downstream_choke() + elif pressure_control == "UPSTREAM_CHOKE": + choke_id = builder.with_upstream_choke() + + return builder.build(), PressureControlConfig( + type=pressure_control, + recirculation_loop_ids=recirculation_loop_ids, + choke_id=choke_id, + ) + + def map_anti_surge_strategy( + self, + simulator: ProcessRunner, + recirculation_loop_ids: Sequence[ProcessSystemId], + compressors: Sequence[Compressor], + recirculation_type: Literal["INDIVIDUAL_ASV", "COMMON_ASV"], + ) -> AntiSurgeStrategy: + if recirculation_type == "COMMON_ASV": + return CommonASVAntiSurgeStrategy( + simulator=simulator, + root_finding_strategy=ScipyRootFindingStrategy(), + first_compressor=compressors[0], + recirculation_loop_id=recirculation_loop_ids[0], + ) + elif recirculation_type == "INDIVIDUAL_ASV": + return IndividualASVAntiSurgeStrategy( + simulator=simulator, + recirculation_loop_ids=recirculation_loop_ids, + compressors=compressors, + ) + + assert_never(recirculation_type) + + def map_pressure_control_strategy( + self, + simulator: ProcessRunner, + recirculation_loop_ids: Sequence[ProcessSystemId], + choke_id: ProcessUnitId | None, + compressors: Sequence[Compressor], + pressure_control_type: Literal[ + "COMMON_ASV", "INDIVIDUAL_ASV_RATE", "INDIVIDUAL_ASV_PRESSURE", "DOWNSTREAM_CHOKE", "UPSTREAM_CHOKE" + ], + ) -> PressureControlStrategy: + if pressure_control_type == "COMMON_ASV": + assert len(recirculation_loop_ids) == 1 + return CommonASVPressureControlStrategy( + simulator=simulator, + first_compressor=compressors[0], + root_finding_strategy=ScipyRootFindingStrategy(), + recirculation_loop_id=recirculation_loop_ids[0], + ) + elif pressure_control_type == "INDIVIDUAL_ASV_RATE": + return IndividualASVRateControlStrategy( + simulator=simulator, + recirculation_loop_ids=recirculation_loop_ids, + compressors=compressors, + ) + elif pressure_control_type == "INDIVIDUAL_ASV_PRESSURE": + return IndividualASVPressureControlStrategy( + simulator=simulator, + recirculation_loop_ids=recirculation_loop_ids, + compressors=compressors, + root_finding_strategy=ScipyRootFindingStrategy(), + ) + elif pressure_control_type == "DOWNSTREAM_CHOKE": + assert choke_id is not None + return DownstreamChokePressureControlStrategy( + simulator=simulator, + choke_id=choke_id, + ) + elif pressure_control_type == "UPSTREAM_CHOKE": + assert choke_id is not None + return UpstreamChokePressureControlStrategy( + simulator=simulator, + choke_id=choke_id, + root_finding_strategy=ScipyRootFindingStrategy(), + ) + + assert_never(pressure_control_type) + + def map_process_simulation(self, yaml_process_simulation: YamlProcessSimulation): + targets = [] + for yaml_compressor_train_item in yaml_process_simulation.targets: + shaft = VariableSpeedShaft() + compressors = self._get_compressors( + self._resolve_train_reference(yaml_compressor_train_item.target), shaft=shaft + ) + process_system, pressure_control_config = self.map_process_system( + compressors=compressors, + pressure_control=yaml_process_simulation.pressure_control, + ) + targets.append(process_system) + runner = ProcessSystemRunner(units=process_system.get_process_units(), shaft=shaft) + + anti_surge_strategy = self.map_anti_surge_strategy( + simulator=runner, + recirculation_loop_ids=pressure_control_config.recirculation_loop_ids, + compressors=compressors, + recirculation_type="COMMON_ASV" if pressure_control_config.type == "COMMON_ASV" else "INDIVIDUAL_ASV", + ) + + pressure_control_strategy = self.map_pressure_control_strategy( + simulator=runner, + compressors=compressors, + recirculation_loop_ids=pressure_control_config.recirculation_loop_ids, + choke_id=pressure_control_config.choke_id, + pressure_control_type=pressure_control_config.type, + ) + + yaml_stream_distribution = yaml_process_simulation.stream_distribution + if yaml_stream_distribution.method == "COMMON_STREAM": + raise NotImplementedError("Missing HasValid and HasCapacity implementations") + # stream_distributions = self._map_common_stream_distribution( + # yaml_stream_distribution=yaml_stream_distribution, items=[] + # ) + elif yaml_stream_distribution.method == "INDIVIDUAL_STREAMS": + stream_distributions = self._map_individual_stream_distribution( + yaml_stream_distribution=yaml_stream_distribution + ) + else: + raise DomainValidationException( + f"Unsupported stream distribution type. (Got: {yaml_process_simulation.stream_distribution.method}" + ) diff --git a/src/libecalc/presentation/yaml/test.yaml b/src/libecalc/presentation/yaml/test.yaml deleted file mode 100644 index ccd3b7a5d5..0000000000 --- a/src/libecalc/presentation/yaml/test.yaml +++ /dev/null @@ -1,83 +0,0 @@ - - -COMPRESSOR_CHARTS: - - NAME: chart_vs_2010 - CHART_TYPE: VARIABLE_SPEED - CURVES: ... - UNITS: ... - - - NAME: chart_vs_2020 - CHART_TYPE: VARIABLE_SPEED - CURVES: ... - UNITS: ... - -COMPRESSOR_MODELS: - - NAME: compressor_1 - COMPRESSOR_CHART: - 2010-01-01: chart_vs_2010 - 2020-01-01: chart_vs_2020 - CONTROL_MARGIN: 0.10 - CONTROL_MARGIN_UNIT: FRACTION - - ... - - -PROCESS_SYSTEMS: - - NAME: stage_1 - TYPE: COMPRESSOR_STAGE - COMPRESSOR_MODEL: compressor_1 - INLET_TEMPERATURE: 50 - PRESSURE_DROP_AHEAD_OF_STAGE: 0.2 - - ... - - - NAME: compressor_train_1 - TRAIN_TYPE: COMMON_SHAFT - TYPE: SERIAL - ITEMS: - - TARGET: stage_1 - - TARGET: stage_2 - - - NAME: compressor_train_2 - TYPE: SERIAL - ITEMS: - - TARGET: stage_3 - - TARGET: stage_4 - - - NAME: station1 - TYPE: PARALLEL - ITEMS: - - NAME: train1 - TARGET: compressor_train_1 - - NAME: train2 - TARGET: compressor_train_2 - -INSTALLATIONS: - - NAME: inst1 - PROCESS_SIMULATIONS: - - NAME: sim_station1 - TARGET: station1 - CONSTRAINTS: - train1: - DISCHARGE_PRESSURE: SIM1;PD_T1 - train2: - DISCHARGE_PRESSURE: SIM1;PD_T2 - - STREAM_DISTRIBUTION: - METHOD: COMMON_STREAM - INLET_STREAM: Stream_1 - SETTINGS: - - RATE_FRACTIONS: [0.6, 0.4] - OVERFLOW: - - FROM: train1 - TO: train2 - - RATE_FRACTIONS: [1.0, 0.0] - - - NAME: sim_train2 - TARGET: compressor_train_2 - CONSTRAINTS: - DISCHARGE_PRESSURE: SIM1;PD_T1 - - STREAM_DISTRIBUTION: - METHOD: INDIVIDUAL_STREAMS - INLET_STREAMS: [Stream_1] diff --git a/src/libecalc/presentation/yaml/yaml_models/yaml_model.py b/src/libecalc/presentation/yaml/yaml_models/yaml_model.py index 40c4f8241c..afba0a47ef 100644 --- a/src/libecalc/presentation/yaml/yaml_models/yaml_model.py +++ b/src/libecalc/presentation/yaml/yaml_models/yaml_model.py @@ -13,9 +13,15 @@ from libecalc.presentation.yaml.yaml_keywords import EcalcYamlKeywords from libecalc.presentation.yaml.yaml_types.components.yaml_asset import YamlAsset from libecalc.presentation.yaml.yaml_types.components.yaml_installation import YamlInstallation +from libecalc.presentation.yaml.yaml_types.components.yaml_process_system import ( + YamlProcessSimulation, + YamlProcessSystem, + YamlProcessUnit, +) from libecalc.presentation.yaml.yaml_types.facility_model.yaml_facility_model import YamlFacilityModel from libecalc.presentation.yaml.yaml_types.fuel_type.yaml_fuel_type import YamlFuelType from libecalc.presentation.yaml.yaml_types.models import YamlConsumerModel +from libecalc.presentation.yaml.yaml_types.streams.yaml_inlet_stream import YamlInletStream from libecalc.presentation.yaml.yaml_types.time_series.yaml_time_series import ( YamlTimeSeriesCollection, ) @@ -69,6 +75,26 @@ def models(self) -> Iterable[YamlConsumerModel]: def fuel_types(self) -> Iterable[YamlFuelType]: pass + @property + @abc.abstractmethod + def inlet_streams(self) -> dict[str, YamlInletStream]: + pass + + @property + @abc.abstractmethod + def process_units(self) -> dict[str, YamlProcessUnit]: + pass + + @property + @abc.abstractmethod + def process_systems(self) -> dict[str, YamlProcessSystem]: + pass + + @property + @abc.abstractmethod + def process_simulations(self) -> Iterable[YamlProcessSimulation]: + pass + @property @abc.abstractmethod def installations(self) -> Iterable[YamlInstallation]: diff --git a/src/libecalc/presentation/yaml/yaml_reference_service.py b/src/libecalc/presentation/yaml/yaml_reference_service.py index b9ad212108..acf1109f5b 100644 --- a/src/libecalc/presentation/yaml/yaml_reference_service.py +++ b/src/libecalc/presentation/yaml/yaml_reference_service.py @@ -1,5 +1,4 @@ import logging -from collections.abc import Iterable from typing import Any, get_args from libecalc.common.errors.exceptions import EcalcError @@ -10,6 +9,14 @@ ) from libecalc.presentation.yaml.mappers.yaml_path import YamlPath from libecalc.presentation.yaml.yaml_models.yaml_model import YamlValidator +from libecalc.presentation.yaml.yaml_types.components.yaml_process_system import ( + YamlCompressor, + YamlCompressorStageProcessSystem, + YamlProcessSimulation, + YamlProcessSystem, + YamlProcessUnit, + YamlSerialProcessSystem, +) from libecalc.presentation.yaml.yaml_types.facility_model.yaml_facility_model import ( YamlFacilityModel, YamlGeneratorSetModel, @@ -26,12 +33,13 @@ YamlTurbine, ) from libecalc.presentation.yaml.yaml_types.models.yaml_enums import YamlModelType +from libecalc.presentation.yaml.yaml_types.streams.yaml_inlet_stream import YamlInletStream logger = logging.getLogger(__name__) YamlModel = YamlConsumerModel | YamlFacilityModel -ReferenceType = YamlModel | YamlFuelType +ReferenceType = YamlModel | YamlFuelType | YamlInletStream | YamlProcessSystem | YamlProcessSimulation | YamlProcessUnit # Some models are referenced by other models, for example a compressor model will reference compressor chart models # and fluid models. A compressor with turbine model will reference a compressor model and a turbine model @@ -63,10 +71,6 @@ def _model_parsing_order(model: YamlModel) -> int: raise EcalcError(title="Invalid model", message=msg) from e -def _sort_models(models: Iterable[YamlModel]): - return sorted(models, key=_model_parsing_order) - - Reference = str @@ -95,6 +99,30 @@ def __init__( references[fuel_type.name] = fuel_type reference_yaml_context[fuel_type.name] = fuel_type_path + streams_path = YamlPath(keys=("INLET_STREAMS",)) + for stream_key, stream in configuration.inlet_streams.items(): + stream_path = streams_path.append(stream_key) + references[stream.name] = stream + reference_yaml_context[stream.name] = stream_path + + process_units_path = YamlPath(keys=("PROCESS_UNITS",)) + for process_unit_key, process_unit in configuration.process_units.items(): + process_unit_path = process_units_path.append(process_unit_key) + references[process_unit.name] = process_unit + reference_yaml_context[process_unit.name] = process_unit_path + + process_systems_path = YamlPath(keys=("PROCESS_SYSTEMS",)) + for process_system_key, process_system in configuration.process_systems.items(): + process_system_path = process_systems_path.append(process_system_key) + references[process_system.name] = process_system + reference_yaml_context[process_system.name] = process_system_path + + process_simulations_path = YamlPath(keys=("PROCESS_SIMULATIONS",)) + for process_simulation_index, process_simulation in enumerate(configuration.process_simulations): + process_simulation_path = process_simulations_path.append(process_simulation_index) + references[process_simulation.name] = process_simulation + reference_yaml_context[process_simulation.name] = process_simulation_path + self._references = references self._references_yaml_context = reference_yaml_context @@ -156,3 +184,27 @@ def get_tabulated_model(self, reference: str) -> YamlTabularModel: if not isinstance(model, YamlTabularModel): raise InvalidReferenceException("tabulated", reference) return model + + def get_process_system(self, reference: str) -> YamlSerialProcessSystem: + model = self._resolve_yaml_reference(reference, "process system") + if not isinstance(model, YamlSerialProcessSystem): + raise InvalidReferenceException("process system", reference) + return model + + def get_compressor_stage(self, reference: str) -> YamlCompressorStageProcessSystem: + model = self._resolve_yaml_reference(reference, "compressor stage") + if not isinstance(model, YamlCompressorStageProcessSystem): + raise InvalidReferenceException("compressor stage", reference) + return model + + def get_compressor(self, reference: str) -> YamlCompressor: + model = self._resolve_yaml_reference(reference, "compressor") + if not isinstance(model, YamlCompressor): + raise InvalidReferenceException("compressor", reference) + return model + + def get_stream(self, reference: str) -> YamlInletStream: + model = self._resolve_yaml_reference(reference, "stream") + if not isinstance(model, YamlInletStream): + raise InvalidReferenceException("stream", reference) + return model diff --git a/src/libecalc/presentation/yaml/yaml_types/components/yaml_process_system.py b/src/libecalc/presentation/yaml/yaml_types/components/yaml_process_system.py index edf9eede3d..f7af6eb535 100644 --- a/src/libecalc/presentation/yaml/yaml_types/components/yaml_process_system.py +++ b/src/libecalc/presentation/yaml/yaml_types/components/yaml_process_system.py @@ -30,9 +30,6 @@ class YamlCompressorModelChart(YamlBase): control_margin: YamlControlMargin -YamlCompressorModel = YamlCompressorModelChart # Could add compressor_sampled as a model if needed - - ProcessUnitReference = str @@ -47,7 +44,7 @@ class YamlCompressor(YamlBase): description="Name of the model. See documentation for more information.", title="NAME", ) - compressor_model: YamlCompressorModel + compressor_model: YamlCompressorModelChart ProcessSystemReference: TypeAlias = str # TODO: validate correct reference @@ -122,6 +119,7 @@ class YamlProcessSimulation(YamlBase): name: str targets: list[YamlItem[YamlSerialProcessSystem]] = Field(..., title="TARGETS") stream_distribution: YamlStreamDistribution + pressure_control: Literal["COMMON_ASV", "INDIVIDUAL_ASV_RATE", "INDIVIDUAL_ASV_PRESSURE"] constraints: dict[ProcessSystemReference, YamlProcessConstraints] = Field( default_factory=dict, title="CONSTRAINTS", diff --git a/src/libecalc/presentation/yaml/yaml_types/streams/yaml_inlet_stream.py b/src/libecalc/presentation/yaml/yaml_types/streams/yaml_inlet_stream.py index ed2ec61469..9d87f2a323 100644 --- a/src/libecalc/presentation/yaml/yaml_types/streams/yaml_inlet_stream.py +++ b/src/libecalc/presentation/yaml/yaml_types/streams/yaml_inlet_stream.py @@ -3,6 +3,7 @@ from pydantic import ConfigDict, Field, model_validator +from ecalc_neqsim_wrapper.thermo import STANDARD_PRESSURE_BARA, STANDARD_TEMPERATURE_KELVIN from libecalc.common.utils.rates import RateType from libecalc.presentation.yaml.yaml_types import YamlBase from libecalc.presentation.yaml.yaml_types.components.yaml_expression_type import YamlExpressionType @@ -14,7 +15,6 @@ class YamlStreamRateUnit(str, enum.Enum): SM3_PER_DAY = "SM3_PER_DAY" - KG_PER_HOUR = "KG_PER_HOUR" class YamlInletStreamRate(YamlBase): @@ -24,7 +24,7 @@ class YamlInletStreamRate(YamlBase): unit: YamlStreamRateUnit = Field( ..., title="UNIT", - description="Rate unit. SM3_PER_DAY for standard volume, KG_PER_HOUR for mass, KMOL_PER_HOUR for molar rate.", + description="Rate unit. SM3_PER_DAY for standard volume.", ) type: Literal[RateType.STREAM_DAY, RateType.CALENDAR_DAY] = RateType.STREAM_DAY @@ -74,13 +74,13 @@ class YamlInletStream(YamlBase): description="Reference to a fluid model (e.g. defined in MODELS/FLUID_MODELS elsewhere).", ) - temperature: YamlExpressionType | None = Field( - None, + temperature: YamlExpressionType = Field( + STANDARD_TEMPERATURE_KELVIN, title="TEMPERATURE", description="Temperature in K. Optional; defaults to standard temperature if omitted.", ) - pressure: YamlExpressionType | None = Field( - None, + pressure: YamlExpressionType = Field( + STANDARD_PRESSURE_BARA, title="PRESSURE", description="Pressure in Pa. Optional; defaults to standard pressure if omitted.", ) diff --git a/tests/libecalc/domain/process/entities/process_units/test_recirculation_loop.py b/tests/libecalc/domain/process/entities/process_units/test_recirculation_loop.py index 187b69941b..26e9f43c81 100644 --- a/tests/libecalc/domain/process/entities/process_units/test_recirculation_loop.py +++ b/tests/libecalc/domain/process/entities/process_units/test_recirculation_loop.py @@ -1,5 +1,3 @@ -import uuid - import pytest from inline_snapshot import snapshot @@ -9,6 +7,7 @@ LegacySplitter, ) from libecalc.domain.process.entities.process_units.mixer import Mixer +from libecalc.domain.process.process_system.process_unit import create_process_unit_id from libecalc.domain.process.value_objects.fluid_stream import ( Fluid, FluidModel, @@ -60,7 +59,9 @@ def test_recirculation_loop_around_splitter_raises_exception(fluid_service, reci with pytest.raises(Exception) as exc_info: recirculation_loop_factory(inner_process=process_unit) - assert str(exc_info.value) == snapshot("Recirculation loop should contain a ProcessSystem") + assert str(exc_info.value) == snapshot( + "Recirculation loop should contain a ProcessSystem with a compressor or a single compressor" + ) @pytest.mark.inlinesnapshot @@ -73,7 +74,9 @@ def test_recirculation_loop_around_mixer_raises_exception( with pytest.raises(Exception) as exc_info: recirculation_loop_factory(inner_process=process_unit) - assert str(exc_info.value) == snapshot("Recirculation loop should contain a ProcessSystem") + assert str(exc_info.value) == snapshot( + "Recirculation loop should contain a ProcessSystem with a compressor or a single compressor" + ) @pytest.mark.inlinesnapshot @@ -87,7 +90,7 @@ def test_recirculation_loop_around_process_system_with_multiple_streams_raises_e ): liquid_remover = liquid_remover_factory() choke = choke_factory(pressure_change=2) - mixer = Mixer(process_unit_id=uuid.uuid4(), fluid_service=fluid_service) + mixer = Mixer(process_unit_id=create_process_unit_id(), fluid_service=fluid_service) process_system = process_system_factory(process_units=[liquid_remover, choke, mixer]) with pytest.raises(DomainValidationException) as exc_info: recirculation_loop_factory(inner_process=process_system) diff --git a/tests/libecalc/input/mappers/test_consumer_chart.py b/tests/libecalc/input/mappers/test_consumer_chart.py index a933e42ffa..e8e9c54975 100644 --- a/tests/libecalc/input/mappers/test_consumer_chart.py +++ b/tests/libecalc/input/mappers/test_consumer_chart.py @@ -7,7 +7,7 @@ ) from libecalc.presentation.yaml.mappers.model import ( InvalidChartResourceException, - _single_speed_compressor_chart_mapper, + single_speed_compressor_chart_mapper, ) from libecalc.presentation.yaml.yaml_entities import MemoryResource from libecalc.presentation.yaml.yaml_keywords import EcalcYamlKeywords @@ -128,7 +128,7 @@ def compressor_chart(): class TestCompressorChartSingleSpeed: def test_valid_with_speed(self, compressor_chart, chart_resource_with_speed): """Test that speed can be specified. Note: 1.0 and 1 is considered equal.""" - chart = _single_speed_compressor_chart_mapper( + chart = single_speed_compressor_chart_mapper( model_config=compressor_chart, resources={"compressorchart.csv": chart_resource_with_speed}, control_margin=None, @@ -143,7 +143,7 @@ def test_valid_with_speed(self, compressor_chart, chart_resource_with_speed): def test_valid_without_speed(self, compressor_chart, chart_resource_without_speed): """Test that speed can be specified. Note: 1.0 and 1 is considered equal.""" - chart = _single_speed_compressor_chart_mapper( + chart = single_speed_compressor_chart_mapper( model_config=compressor_chart, resources={"compressorchart.csv": chart_resource_without_speed}, control_margin=None, @@ -158,7 +158,7 @@ def test_valid_without_speed(self, compressor_chart, chart_resource_without_spee def test_invalid_unequal_speed(self, compressor_chart, chart_resource_unequal_speed): with pytest.raises(InvalidChartResourceException) as exception_info: - _single_speed_compressor_chart_mapper( + single_speed_compressor_chart_mapper( model_config=compressor_chart, resources={"compressorchart.csv": chart_resource_unequal_speed}, control_margin=None, diff --git a/tests/libecalc/input/mappers/test_model_mapper.py b/tests/libecalc/input/mappers/test_model_mapper.py index 52e2f0806d..0d014d40a4 100644 --- a/tests/libecalc/input/mappers/test_model_mapper.py +++ b/tests/libecalc/input/mappers/test_model_mapper.py @@ -8,6 +8,11 @@ from libecalc.presentation.yaml.mappers.consumer_function_mapper import CompressorModelMapper from libecalc.presentation.yaml.mappers.yaml_path import YamlPath from libecalc.presentation.yaml.yaml_entities import MemoryResource +from libecalc.presentation.yaml.yaml_types.components.yaml_process_system import ( + YamlCompressor, + YamlCompressorStageProcessSystem, + YamlSerialProcessSystem, +) from libecalc.presentation.yaml.yaml_types.facility_model.yaml_facility_model import ( YamlGeneratorSetModel, YamlPumpChartSingleSpeed, @@ -15,6 +20,7 @@ YamlTabularModel, ) from libecalc.presentation.yaml.yaml_types.models import YamlCompressorChart, YamlFluidModel, YamlTurbine +from libecalc.presentation.yaml.yaml_types.streams.yaml_inlet_stream import YamlInletStream class DirectReferenceService(ReferenceService): @@ -51,6 +57,18 @@ def get_pump_model(self, reference: str) -> YamlPumpChartSingleSpeed | YamlPumpC def get_tabulated_model(self, reference: str) -> YamlTabularModel: raise NotImplementedError() + def get_process_system(self, reference: str) -> YamlSerialProcessSystem: + raise NotImplementedError() + + def get_compressor_stage(self, reference: str) -> YamlCompressorStageProcessSystem: + raise NotImplementedError() + + def get_compressor(self, reference: str) -> YamlCompressor: + raise NotImplementedError() + + def get_stream(self, reference: str) -> YamlInletStream: + raise NotImplementedError() + class TestCompressorChartMapping: def test_compressor_chart_from_file_and_in_yaml_is_equal(self): From 192bf1ebd0eb6db3631258343dfa91dcda4e34bd Mon Sep 17 00:00:00 2001 From: Frode Helgetun Krogh <70878501+frodehk@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:38:20 +0100 Subject: [PATCH 2/6] refactor: implement has validtiy and capacity --- .../common_stream_distribution.py | 10 ++- .../yaml/mappers/process_simulation_mapper.py | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py diff --git a/src/libecalc/domain/process/stream_distribution/common_stream_distribution.py b/src/libecalc/domain/process/stream_distribution/common_stream_distribution.py index 8efc5f2309..4ae65ac5cb 100644 --- a/src/libecalc/domain/process/stream_distribution/common_stream_distribution.py +++ b/src/libecalc/domain/process/stream_distribution/common_stream_distribution.py @@ -12,7 +12,7 @@ class HasCapacity(abc.ABC): @abc.abstractmethod - def get_unhandled_rate(self, rate: float, pressure: float) -> float: ... + def get_unhandled_rate(self, inlet_stream: FluidStream) -> float: ... T = TypeVar("T", bound=Hashable) @@ -69,7 +69,13 @@ def _adjust_for_overflow(self) -> dict[T, float]: current_rate = rate + overflow_rate if overflow is not None: item = self._items[item_id] - unhandled_rate = item.get_unhandled_rate(current_rate, self._inlet_stream.pressure_bara) + stream = self._fluid_service.create_stream_from_standard_rate( + fluid_model=self._inlet_stream.fluid_model, + standard_rate_m3_per_day=current_rate, + temperature_kelvin=self._inlet_stream.temperature_kelvin, + pressure_bara=self._inlet_stream.pressure_bara, + ) + unhandled_rate = item.get_unhandled_rate(stream) handled_rate = current_rate - unhandled_rate overflow_map[overflow.to_id].append(unhandled_rate) adjusted_rates[item_id] = handled_rate diff --git a/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py new file mode 100644 index 0000000000..78e7546dcf --- /dev/null +++ b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py @@ -0,0 +1,66 @@ +from abc import ABC +from dataclasses import dataclass +from typing import Literal + +from libecalc.domain.process.entities.process_units.compressor import Compressor +from libecalc.domain.process.process_solver.float_constraint import FloatConstraint +from libecalc.domain.process.process_solver.outlet_pressure_solver import OutletPressureSolver +from libecalc.domain.process.process_solver.process_runner import ProcessRunner +from libecalc.domain.process.stream_distribution.common_stream_distribution import HasCapacity +from libecalc.domain.process.stream_distribution.priorities_stream_distribution import HasValidity +from libecalc.domain.process.value_objects.fluid_stream import FluidStream + + +@dataclass +class StreamDistributionItem(HasCapacity, HasValidity, ABC): + type: Literal["INDIVIDUAL_ASV", "COMMON_ASV"] + + +@dataclass +class CompressorTrainStreamDistributionItem(StreamDistributionItem): + """Connects a compressor train's solver to the stream distribution system.""" + + solver: OutletPressureSolver + pressure_constraint: FloatConstraint + compressors: list[Compressor] + runner: ProcessRunner + + def is_valid(self, inlet_stream: FluidStream) -> bool: + """Can the train operate at these inlet conditions?""" + return self.solver.find_solution( + pressure_constraint=self.pressure_constraint, + inlet_stream=inlet_stream, + ).success + + def get_unhandled_rate(self, inlet_stream: FluidStream) -> float: + """How much rate (sm³/day) exceeds this train's capacity?""" + max_rate = self.find_max_feasible_rate( + pressure_constraint=self.pressure_constraint, + inlet_stream=inlet_stream, + ) + return max(0.0, inlet_stream.standard_rate_sm3_per_day - max_rate) + + def find_max_feasible_rate( + self, + pressure_constraint: FloatConstraint, + inlet_stream: FluidStream, + ) -> float: + """Find the max standard rate this train can handle. + + Runs find_solution to set correct speed, then checks each + compressor's chart boundary at its actual inlet conditions. + """ + if self.solver.find_solution(pressure_constraint, inlet_stream).success: + return inlet_stream.standard_rate_sm3_per_day + + # Speed is now set. Find the bottleneck. Search for compressor with the lowest max rate + min_max_rate = float("inf") + for compressor in self.compressors: + compressor_inlet = self.runner.run( + inlet_stream=inlet_stream, + to_id=compressor.get_id(), + ) + max_rate = compressor.get_maximum_standard_rate(compressor_inlet) + min_max_rate = min(min_max_rate, max_rate) + + return max(0.0, min_max_rate) From aed8fddc52f2468d4a4a95a9909026601efdf278 Mon Sep 17 00:00:00 2001 From: Frode Helgetun Krogh <70878501+frodehk@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:04:49 +0100 Subject: [PATCH 3/6] test: update test --- tests/libecalc/application/test_stream_distribution.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/libecalc/application/test_stream_distribution.py b/tests/libecalc/application/test_stream_distribution.py index 5d9b903f5c..ac0c432915 100644 --- a/tests/libecalc/application/test_stream_distribution.py +++ b/tests/libecalc/application/test_stream_distribution.py @@ -8,6 +8,7 @@ Overflow, ) from libecalc.domain.component_validation_error import DomainValidationException +from libecalc.domain.process.value_objects.fluid_stream import FluidStream class Item(HasCapacity): @@ -15,8 +16,8 @@ def __init__(self, capacity: float): self.id = uuid4() self._capacity = capacity - def get_unhandled_rate(self, rate: float, pressure: float) -> float: - return max(0.0, rate - self._capacity) + def get_unhandled_rate(self, inlet_stream: FluidStream) -> float: + return max(0.0, inlet_stream.standard_rate_sm3_per_day - self._capacity) class TestCommonStreamDistribution: From b779bbb6864476866dfacca13557e11dd3b4e4ef Mon Sep 17 00:00:00 2001 From: Frode Helgetun Krogh <70878501+frodehk@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:52:30 +0100 Subject: [PATCH 4/6] chore: set speed explicitly --- .../yaml/mappers/process_simulation_mapper.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py index 78e7546dcf..2f11e66957 100644 --- a/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py +++ b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py @@ -1,7 +1,3 @@ -from abc import ABC -from dataclasses import dataclass -from typing import Literal - from libecalc.domain.process.entities.process_units.compressor import Compressor from libecalc.domain.process.process_solver.float_constraint import FloatConstraint from libecalc.domain.process.process_solver.outlet_pressure_solver import OutletPressureSolver @@ -11,38 +7,37 @@ from libecalc.domain.process.value_objects.fluid_stream import FluidStream -@dataclass -class StreamDistributionItem(HasCapacity, HasValidity, ABC): - type: Literal["INDIVIDUAL_ASV", "COMMON_ASV"] - - -@dataclass -class CompressorTrainStreamDistributionItem(StreamDistributionItem): +class CompressorTrainStreamDistributionItem(HasCapacity, HasValidity): """Connects a compressor train's solver to the stream distribution system.""" - solver: OutletPressureSolver - pressure_constraint: FloatConstraint - compressors: list[Compressor] - runner: ProcessRunner + def __init__( + self, + solver: OutletPressureSolver, + pressure_constraint: FloatConstraint, + compressors: list[Compressor], + runner: ProcessRunner, + ): + self._solver = solver + self._pressure_constraint = pressure_constraint + self._compressors = compressors + self._runner = runner def is_valid(self, inlet_stream: FluidStream) -> bool: """Can the train operate at these inlet conditions?""" - return self.solver.find_solution( - pressure_constraint=self.pressure_constraint, + return self._solver.find_solution( + pressure_constraint=self._pressure_constraint, inlet_stream=inlet_stream, ).success def get_unhandled_rate(self, inlet_stream: FluidStream) -> float: """How much rate (sm³/day) exceeds this train's capacity?""" max_rate = self.find_max_feasible_rate( - pressure_constraint=self.pressure_constraint, inlet_stream=inlet_stream, ) return max(0.0, inlet_stream.standard_rate_sm3_per_day - max_rate) def find_max_feasible_rate( self, - pressure_constraint: FloatConstraint, inlet_stream: FluidStream, ) -> float: """Find the max standard rate this train can handle. @@ -50,13 +45,18 @@ def find_max_feasible_rate( Runs find_solution to set correct speed, then checks each compressor's chart boundary at its actual inlet conditions. """ - if self.solver.find_solution(pressure_constraint, inlet_stream).success: + solution = self._solver.find_solution(self._pressure_constraint, inlet_stream) + if solution.success: return inlet_stream.standard_rate_sm3_per_day - # Speed is now set. Find the bottleneck. Search for compressor with the lowest max rate + # Apply the configuration from the (failed) solution to ensure + # speed and anti-surge are set before querying compressor charts. + self._runner.apply_configurations(solution.configuration) + + # Search for compressor with the lowest max rate min_max_rate = float("inf") - for compressor in self.compressors: - compressor_inlet = self.runner.run( + for compressor in self._compressors: + compressor_inlet = self._runner.run( inlet_stream=inlet_stream, to_id=compressor.get_id(), ) From 9f5b4655a4170027abe64944689458a317edc633 Mon Sep 17 00:00:00 2001 From: Frode Helgetun Krogh <70878501+frodehk@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:57:17 +0100 Subject: [PATCH 5/6] docs: update docstring --- .../yaml/mappers/process_simulation_mapper.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py index 2f11e66957..daa93386ac 100644 --- a/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py +++ b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py @@ -40,10 +40,12 @@ def find_max_feasible_rate( self, inlet_stream: FluidStream, ) -> float: - """Find the max standard rate this train can handle. + """ + Find the max standard rate this train can handle. - Runs find_solution to set correct speed, then checks each - compressor's chart boundary at its actual inlet conditions. + If the solver can meet the pressure constraint, the full inlet rate is feasible. + Otherwise, applies the solver's configuration (speed, anti-surge) and checks + each compressor's stone wall to find the bottleneck. """ solution = self._solver.find_solution(self._pressure_constraint, inlet_stream) if solution.success: From ee5465ed2e383fb826b19d22b83a184f80d51376 Mon Sep 17 00:00:00 2001 From: Frode Helgetun Krogh <70878501+frodehk@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:01:34 +0100 Subject: [PATCH 6/6] chore: update comment --- .../presentation/yaml/mappers/process_simulation_mapper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py index daa93386ac..d3af661ad7 100644 --- a/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py +++ b/src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py @@ -49,6 +49,7 @@ def find_max_feasible_rate( """ solution = self._solver.find_solution(self._pressure_constraint, inlet_stream) if solution.success: + # The train can handle the full rate — no need to search for a bottleneck. return inlet_stream.standard_rate_sm3_per_day # Apply the configuration from the (failed) solution to ensure