From c801f3f0fa4b967c15c5122ac8997695f38bae12 Mon Sep 17 00:00:00 2001 From: Jostein Solaas <33114722+jsolaas@users.noreply.github.com> Date: Wed, 18 Oct 2023 15:56:07 +0200 Subject: [PATCH] feat: support name for crossover streams (#236) Allow handling of crossover streams in multiple streams. --- src/libecalc/common/string_utils.py | 1 + .../core/consumers/consumer_system.py | 52 +++++++++-------- src/libecalc/dto/components.py | 11 +++- .../consumer_system_v2_dto.py | 23 ++++++-- .../data/consumer_system_v2.yaml | 12 +++- .../system/yaml_compressor_system.py | 17 ++++-- .../components/system/yaml_pump_system.py | 17 ++++-- .../yaml_system_component_conditions.py | 52 +++++++++++++++-- .../libecalc/common/test_string_utils.py | 8 +-- .../core/consumers/test_consumer_utils.py | 34 ++++++++++- .../test_json_schema_changed/schemas.json | 30 +++++++++- .../test_system_component_conditions.py | 56 +++++++++++++++++++ 12 files changed, 260 insertions(+), 53 deletions(-) create mode 100644 src/tests/libecalc/input/yaml_types/system/test_system_component_conditions.py diff --git a/src/libecalc/common/string_utils.py b/src/libecalc/common/string_utils.py index a91bda4a76..e30f836ea0 100644 --- a/src/libecalc/common/string_utils.py +++ b/src/libecalc/common/string_utils.py @@ -34,4 +34,5 @@ def to_camel_case(string: str) -> str: """ string_split = string.replace("__", "_").split("_") + string_split = [word for word in string_split if len(word) > 0] # Allow names such as 'from_' return string_split[0] + "".join(word[0].upper() + word[1:] for word in string_split[1:]) diff --git a/src/libecalc/core/consumers/consumer_system.py b/src/libecalc/core/consumers/consumer_system.py index 6a29b78571..59360009d8 100644 --- a/src/libecalc/core/consumers/consumer_system.py +++ b/src/libecalc/core/consumers/consumer_system.py @@ -4,7 +4,7 @@ from collections import defaultdict from datetime import datetime from functools import reduce -from typing import Dict, List, Protocol, Tuple, TypeVar, Union +from typing import Dict, List, Optional, Protocol, Tuple, TypeVar, Union import networkx as nx import numpy as np @@ -47,8 +47,14 @@ def get_subset_for_timestep(self, current_timestep: datetime) -> Self: ... +class Crossover(Protocol): + stream_name: Optional[str] + from_component_id: str + to_component_id: str + + class SystemComponentConditions(Protocol): - crossover: List[int] + crossover: List[Crossover] class SystemOperationalSettings(Protocol): @@ -95,13 +101,12 @@ def _get_operational_settings_adjusted_for_crossover( ) adjusted_operational_settings = [] - crossover_streams_map: Dict[int, List[Stream]] = { - consumer_index: [] for consumer_index in range(len(self._consumers)) + crossover_streams_map: Dict[str, List[Stream]] = {consumer.id: [] for consumer in self._consumers} + crossover_definitions_map: Dict[str, Crossover] = { + crossover_stream.from_component_id: crossover_stream + for crossover_stream in self._component_conditions.crossover } - # Converting from index 1 to index 0. - crossover = [crossover_flow_to_index - 1 for crossover_flow_to_index in self._component_conditions.crossover] - for consumer in sorted_consumers: consumer_index = self._consumers.index(consumer) consumer_operational_settings = operational_setting.get_consumer_operational_settings( @@ -109,17 +114,15 @@ def _get_operational_settings_adjusted_for_crossover( ) inlet_streams = list(consumer_operational_settings.inlet_streams) - crossover_to = crossover[consumer_index] - has_crossover_out = crossover_to >= 0 + # Currently only implemented for one crossover out per consumer + crossover_stream_definition = crossover_definitions_map.get(consumer.id) + has_crossover_out = crossover_stream_definition is not None if has_crossover_out: - consumer = self._consumers[consumer_index] max_rate = consumer.get_max_rate(consumer_operational_settings) - crossover_stream, inlet_streams = ConsumerSystem._get_crossover_streams(max_rate, inlet_streams) + crossover_streams_map[crossover_stream_definition.to_component_id].append(crossover_stream) - crossover_streams_map[crossover_to].append(crossover_stream) - - consumer_operational_settings.inlet_streams = [*inlet_streams, *crossover_streams_map[consumer_index]] + consumer_operational_settings.inlet_streams = [*inlet_streams, *crossover_streams_map[consumer.id]] adjusted_operational_settings.append(consumer_operational_settings) # This is a hack to return operational settings in the original order of the consumers. This way we can @@ -256,29 +259,30 @@ def collect_consumer_results( return list(consumer_results.values()) @staticmethod - def _topologically_sort_consumers_by_crossover(crossover: List[int], consumers: List[Consumer]) -> List[Consumer]: + def _topologically_sort_consumers_by_crossover( + crossover: List[Crossover], consumers: List[Consumer] + ) -> List[Consumer]: """Topological sort of the graph created by crossover. This makes it possible for us to evaluate each consumer with the correct rate directly. Parameters ---------- - crossover: list of indexes in consumers for crossover. 0 means no cross-over, 1 refers to first consumer etc. + crossover: list of crossover stream definitions consumers ------- List of topological sorted consumers """ - zero_indexed_crossover = [i - 1 for i in crossover] # Use zero-index graph = nx.DiGraph() - for consumer_index in range(len(consumers)): - graph.add_node(consumer_index) + for consumer in consumers: + graph.add_node(consumer.id) + + for crossover_stream in crossover: + graph.add_edge(crossover_stream.from_component_id, crossover_stream.to_component_id) - for this, other in enumerate(zero_indexed_crossover): - # No crossover (0) => -1 in zero-indexed crossover - if other >= 0: - graph.add_edge(this, other) + consumers_by_id = {consumer.id: consumer for consumer in consumers} - return [consumers[consumer_index] for consumer_index in nx.topological_sort(graph)] + return [consumers_by_id[consumer_id] for consumer_id in nx.topological_sort(graph)] @staticmethod def _get_crossover_streams(max_rate: List[float], inlet_streams: List[Stream]) -> Tuple[Stream, List[Stream]]: diff --git a/src/libecalc/dto/components.py b/src/libecalc/dto/components.py index 98c30390b2..010208e8a4 100644 --- a/src/libecalc/dto/components.py +++ b/src/libecalc/dto/components.py @@ -190,8 +190,17 @@ class PumpSystemOperationalSetting(EcalcBaseModel): outlet_pressures: List[Expression] +class Crossover(EcalcBaseModel): + class Config: + allow_population_by_field_name = True + + stream_name: Optional[str] = Field(None) + from_component_id: str + to_component_id: str + + class SystemComponentConditions(EcalcBaseModel): - crossover: List[int] + crossover: List[Crossover] class CompressorSystem(BaseConsumer): diff --git a/src/libecalc/fixtures/cases/consumer_system_v2/consumer_system_v2_dto.py b/src/libecalc/fixtures/cases/consumer_system_v2/consumer_system_v2_dto.py index 20204bf8e0..22be5a3e3a 100644 --- a/src/libecalc/fixtures/cases/consumer_system_v2/consumer_system_v2_dto.py +++ b/src/libecalc/fixtures/cases/consumer_system_v2/consumer_system_v2_dto.py @@ -3,6 +3,7 @@ import pytest from libecalc import dto +from libecalc.common.string_utils import generate_id from libecalc.dto import ( CompressorSystemCompressor, CompressorSystemConsumerFunction, @@ -17,7 +18,7 @@ FuelTypeUserDefinedCategoryType, InstallationUserDefinedCategoryType, ) -from libecalc.dto.components import SystemComponentConditions +from libecalc.dto.components import Crossover, SystemComponentConditions from libecalc.dto.types import ConsumptionType, EnergyUsageType from libecalc.expression import Expression from libecalc.fixtures.case_types import DTOCase @@ -196,7 +197,10 @@ consumes=ConsumptionType.FUEL, fuel=fuel, component_conditions=SystemComponentConditions( - crossover=[0, 1, 1], + crossover=[ + Crossover(from_component_id=generate_id("compressor2"), to_component_id=generate_id("compressor1")), + Crossover(from_component_id=generate_id("compressor3"), to_component_id=generate_id("compressor1")), + ], ), operational_settings={ datetime(2022, 1, 1, 0, 0): [ @@ -231,7 +235,10 @@ consumes=ConsumptionType.FUEL, fuel=fuel, component_conditions=SystemComponentConditions( - crossover=[0, 1, 1], + crossover=[ + Crossover(from_component_id=generate_id("compressor2"), to_component_id=generate_id("compressor1")), + Crossover(from_component_id=generate_id("compressor3"), to_component_id=generate_id("compressor1")), + ], ), operational_settings={ datetime(2022, 1, 1, 0, 0): [ @@ -283,7 +290,10 @@ consumes=ConsumptionType.FUEL, fuel=fuel, component_conditions=SystemComponentConditions( - crossover=[0, 1, 1], + crossover=[ + Crossover(from_component_id=generate_id("compressor2"), to_component_id=generate_id("compressor1")), + Crossover(from_component_id=generate_id("compressor3"), to_component_id=generate_id("compressor1")), + ], ), operational_settings={ datetime(2022, 1, 1, 0, 0): [ @@ -378,7 +388,10 @@ regularity=regularity, consumes=dto.types.ConsumptionType.ELECTRICITY, component_conditions=SystemComponentConditions( - crossover=[0, 1, 1], + crossover=[ + Crossover(from_component_id=generate_id("pump2"), to_component_id=generate_id("pump1")), + Crossover(from_component_id=generate_id("pump3"), to_component_id=generate_id("pump1")), + ], ), operational_settings={ datetime(2022, 1, 1, 0, 0): [ diff --git a/src/libecalc/fixtures/cases/consumer_system_v2/data/consumer_system_v2.yaml b/src/libecalc/fixtures/cases/consumer_system_v2/data/consumer_system_v2.yaml index e1ed5da8c3..cccf368064 100644 --- a/src/libecalc/fixtures/cases/consumer_system_v2/data/consumer_system_v2.yaml +++ b/src/libecalc/fixtures/cases/consumer_system_v2/data/consumer_system_v2.yaml @@ -80,7 +80,11 @@ INSTALLATIONS: - NAME: pump3 ENERGY_USAGE_MODEL: pump_single_speed COMPONENT_CONDITIONS: - CROSSOVER: [ 0, 1, 1 ] + CROSSOVER: + - FROM: pump2 + TO: pump1 + - FROM: pump3 + TO: pump1 OPERATIONAL_SETTINGS: - RATES: [ 4000000, 5000000, 6000000 ] INLET_PRESSURE: 50 @@ -126,7 +130,11 @@ INSTALLATIONS: - NAME: compressor3 ENERGY_USAGE_MODEL: compressor_sampled_1d COMPONENT_CONDITIONS: - CROSSOVER: [ 0, 1, 1 ] + CROSSOVER: + - FROM: compressor2 + TO: compressor1 + - FROM: compressor3 + TO: compressor1 OPERATIONAL_SETTINGS: - RATES: [ 1000000, 6000000, 6000000 ] INLET_PRESSURE: 50 diff --git a/src/libecalc/input/yaml_types/components/system/yaml_compressor_system.py b/src/libecalc/input/yaml_types/components/system/yaml_compressor_system.py index 0422343d87..c844e0c4e8 100644 --- a/src/libecalc/input/yaml_types/components/system/yaml_compressor_system.py +++ b/src/libecalc/input/yaml_types/components/system/yaml_compressor_system.py @@ -4,7 +4,7 @@ from libecalc import dto from libecalc.common.time_utils import Period, define_time_model_for_period from libecalc.dto.base import ComponentType -from libecalc.dto.components import SystemComponentConditions +from libecalc.dto.components import Crossover, SystemComponentConditions from libecalc.dto.types import ConsumptionType from libecalc.expression import Expression from libecalc.expression.expression import ExpressionType @@ -140,15 +140,24 @@ def to_dto( for compressor in self.consumers ] + compressor_name_to_id_map = {compressor.name: compressor.id for compressor in compressors} + if self.component_conditions is not None: component_conditions = SystemComponentConditions( - crossover=self.component_conditions.crossover + crossover=[ + Crossover( + from_component_id=compressor_name_to_id_map[crossover_stream.from_], + to_component_id=compressor_name_to_id_map[crossover_stream.to], + stream_name=crossover_stream.name, + ) + for crossover_stream in self.component_conditions.crossover + ] if self.component_conditions.crossover is not None - else [0] * number_of_compressors, + else [], ) else: component_conditions = SystemComponentConditions( - crossover=[0] * number_of_compressors, + crossover=[], ) return dto.components.CompressorSystem( diff --git a/src/libecalc/input/yaml_types/components/system/yaml_pump_system.py b/src/libecalc/input/yaml_types/components/system/yaml_pump_system.py index 2a667aaed5..db0171c8ed 100644 --- a/src/libecalc/input/yaml_types/components/system/yaml_pump_system.py +++ b/src/libecalc/input/yaml_types/components/system/yaml_pump_system.py @@ -4,7 +4,7 @@ from libecalc import dto from libecalc.common.time_utils import Period, define_time_model_for_period from libecalc.dto.base import ComponentType -from libecalc.dto.components import SystemComponentConditions +from libecalc.dto.components import Crossover, SystemComponentConditions from libecalc.dto.types import ConsumptionType from libecalc.expression import Expression from libecalc.expression.expression import ExpressionType @@ -170,15 +170,24 @@ def to_dto( for pump in self.consumers ] + pump_name_to_id_map = {pump.name: pump.id for pump in pumps} + if self.component_conditions is not None: component_conditions = SystemComponentConditions( - crossover=self.component_conditions.crossover + crossover=[ + Crossover( + from_component_id=pump_name_to_id_map[crossover_stream.from_], + to_component_id=pump_name_to_id_map[crossover_stream.to], + stream_name=crossover_stream.name, + ) + for crossover_stream in self.component_conditions.crossover + ] if self.component_conditions.crossover is not None - else [0] * number_of_pumps, + else [], ) else: component_conditions = SystemComponentConditions( - crossover=[0] * number_of_pumps, + crossover=[], ) return dto.components.PumpSystem( diff --git a/src/libecalc/input/yaml_types/components/system/yaml_system_component_conditions.py b/src/libecalc/input/yaml_types/components/system/yaml_system_component_conditions.py index e0bcf35637..421e897a4e 100644 --- a/src/libecalc/input/yaml_types/components/system/yaml_system_component_conditions.py +++ b/src/libecalc/input/yaml_types/components/system/yaml_system_component_conditions.py @@ -1,11 +1,35 @@ from typing import List, Optional +from libecalc.common.string_utils import get_duplicates from libecalc.input.yaml_types import YamlBase -from pydantic import Field +from pydantic import Field, validator + + +class YamlCrossover(YamlBase): + class Config: + allow_population_by_field_name = True + + name: str = Field( + None, + title="NAME", + description="The name of the stream. " + "Can be used to identify the crossover stream in multiple streams compressor train", + ) + from_: str = Field( + ..., + title="FROM", + description="Target component for crossover", + alias="FROM", + ) + to: str = Field( + ..., + title="TO", + description="Target component for crossover", + ) class YamlSystemComponentConditions(YamlBase): - crossover: Optional[List[int]] = Field( + crossover: Optional[List[YamlCrossover]] = Field( None, title="Crossover", description=( @@ -23,9 +47,29 @@ class YamlSystemComponentConditions(YamlBase): "Example 1:\n" "Two consumers where there is a cross-over such that if the rate for the first consumer exceeds its capacity," " the excess rate will be processed by the second consumer. The second consumer can not cross-over to anyone.\n" - "CROSSOVER: [2, 0]\n" + "CROSSOVER: \n" + " - FROM: consumer1 \n" + " TO: consumer2 \n" "Example 2:\n" "The first and second consumers may both send exceeding rate to the third consumer if their capacity is exceeded.\n" - "CROSSOVER: [3,3,0]" + "CROSSOVER: \n" + " - FROM: consumer1 \n" + " TO: consumer3 \n" + " - FROM: consumer2 \n" + " TO: consumer3 \n" ), ) + + @validator("crossover") + def ensure_one_crossover_out(cls, crossover: Optional[List[YamlCrossover]]): + if crossover is None: + return None + crossover_out = [c.from_ for c in crossover] + unique_crossover_out = set(crossover_out) + if len(unique_crossover_out) != len(crossover_out): + raise ValueError( + f"Only one crossover per consumer is currently supported. Component(s) with several crossover streams " + f"are {', '.join(sorted(get_duplicates(crossover_out)))}" + ) + + return crossover diff --git a/src/tests/libecalc/common/test_string_utils.py b/src/tests/libecalc/common/test_string_utils.py index 2b8b1dd13f..ca65b8344f 100644 --- a/src/tests/libecalc/common/test_string_utils.py +++ b/src/tests/libecalc/common/test_string_utils.py @@ -24,10 +24,10 @@ def test_multiple_strings(self): ("m_c", "mC"), ("m_C", "mC"), ("M_C", "MC"), - # ("_C", "C"), - # ("_c", "C"), - # ("m_", "m"), - # ("M_", "M"), + ("_C", "C"), + ("_c", "c"), + ("m_", "m"), + ("M_", "M"), ("my_cAmeLCase", "myCAmeLCase"), ] diff --git a/src/tests/libecalc/core/consumers/test_consumer_utils.py b/src/tests/libecalc/core/consumers/test_consumer_utils.py index 41baa8b1a2..3a5710b6a7 100644 --- a/src/tests/libecalc/core/consumers/test_consumer_utils.py +++ b/src/tests/libecalc/core/consumers/test_consumer_utils.py @@ -1,25 +1,53 @@ +from dataclasses import dataclass + import numpy as np +from libecalc.common.string_utils import generate_id from libecalc.core.consumers.consumer_system import ConsumerSystem from libecalc.core.consumers.legacy_consumer.consumer_function.utils import ( apply_condition, ) +from libecalc.dto.components import Crossover + + +@dataclass +class ConsumerMock: + name: str + + @property + def id(self): + return generate_id(self.name) def test_topologically_sort_consumers_by_crossover(): - unsorted_consumers = [ + unsorted_consumer_names = [ "Consumer 1 with no crossover", "Consumer 2 with crossover to consumer 3", "Consumer 3 with crossover to consumer 1", ] - sorted_consumers = [ + sorted_consumer_names = [ "Consumer 2 with crossover to consumer 3", "Consumer 3 with crossover to consumer 1", "Consumer 1 with no crossover", ] + unsorted_consumers = [ConsumerMock(name=name) for name in unsorted_consumer_names] + sorted_consumers = [ConsumerMock(name=name) for name in sorted_consumer_names] + assert ( - ConsumerSystem._topologically_sort_consumers_by_crossover(crossover=[0, 3, 1], consumers=unsorted_consumers) + ConsumerSystem._topologically_sort_consumers_by_crossover( + crossover=[ + Crossover( + from_component_id=generate_id("Consumer 2 with crossover to consumer 3"), + to_component_id=generate_id("Consumer 3 with crossover to consumer 1"), + ), + Crossover( + from_component_id=generate_id("Consumer 3 with crossover to consumer 1"), + to_component_id=generate_id("Consumer 1 with no crossover"), + ), + ], + consumers=unsorted_consumers, + ) == sorted_consumers ) diff --git a/src/tests/libecalc/input/validation/snapshots/test_validation_json_schemas/test_json_schema_changed/schemas.json b/src/tests/libecalc/input/validation/snapshots/test_validation_json_schemas/test_json_schema_changed/schemas.json index 1466c3471c..84a26ec960 100644 --- a/src/tests/libecalc/input/validation/snapshots/test_validation_json_schemas/test_json_schema_changed/schemas.json +++ b/src/tests/libecalc/input/validation/snapshots/test_validation_json_schemas/test_json_schema_changed/schemas.json @@ -280,6 +280,32 @@ "title": "YamlCompressorTabularModel", "type": "object" }, + "YamlCrossover": { + "additionalProperties": false, + "properties": { + "FROM": { + "description": "Target component for crossover", + "title": "FROM", + "type": "string" + }, + "NAME": { + "description": "The name of the stream. Can be used to identify the crossover stream in multiple streams compressor train", + "title": "NAME", + "type": "string" + }, + "TO": { + "description": "Target component for crossover", + "title": "TO", + "type": "string" + } + }, + "required": [ + "FROM", + "TO" + ], + "title": "YamlCrossover", + "type": "object" + }, "YamlDefaultTimeSeriesCollection": { "additionalProperties": false, "properties": { @@ -1397,9 +1423,9 @@ "additionalProperties": false, "properties": { "CROSSOVER": { - "description": "CROSSOVER specifies if rates are to be crossed over to another consumer if rate capacity is exceeded. If the energy consumption calculation is not successful for a consumer, and the consumer has a valid cross-over defined, the consumer will be allocated its maximum rate and the exceeding rate will be added to the cross-over consumer.\nTo avoid loops, a consumer can only be either receiving or giving away rate. For a cross-over to be valid, the discharge pressure at the consumer \"receiving\" overshooting rate must be higher than or equal to the discharge pressure of the \"sending\" consumer. This is because it is possible to choke pressure down to meet the outlet pressure in a flow line with lower pressure, but not possible to \"pressure up\" in the crossover flow line.\nSome examples show how the crossover logic works:\nCrossover is given as and list of integer values for the first position is the first consumer, second position is the second consumer, etc. The number specifies which consumer to send cross-over flow to, and 0 signifies no cross-over possible. Note that we use 1-index here.\nExample 1:\nTwo consumers where there is a cross-over such that if the rate for the first consumer exceeds its capacity, the excess rate will be processed by the second consumer. The second consumer can not cross-over to anyone.\nCROSSOVER: [2, 0]\nExample 2:\nThe first and second consumers may both send exceeding rate to the third consumer if their capacity is exceeded.\nCROSSOVER: [3,3,0]", + "description": "CROSSOVER specifies if rates are to be crossed over to another consumer if rate capacity is exceeded. If the energy consumption calculation is not successful for a consumer, and the consumer has a valid cross-over defined, the consumer will be allocated its maximum rate and the exceeding rate will be added to the cross-over consumer.\nTo avoid loops, a consumer can only be either receiving or giving away rate. For a cross-over to be valid, the discharge pressure at the consumer \"receiving\" overshooting rate must be higher than or equal to the discharge pressure of the \"sending\" consumer. This is because it is possible to choke pressure down to meet the outlet pressure in a flow line with lower pressure, but not possible to \"pressure up\" in the crossover flow line.\nSome examples show how the crossover logic works:\nCrossover is given as and list of integer values for the first position is the first consumer, second position is the second consumer, etc. The number specifies which consumer to send cross-over flow to, and 0 signifies no cross-over possible. Note that we use 1-index here.\nExample 1:\nTwo consumers where there is a cross-over such that if the rate for the first consumer exceeds its capacity, the excess rate will be processed by the second consumer. The second consumer can not cross-over to anyone.\nCROSSOVER: \n - FROM: consumer1 \n TO: consumer2 \nExample 2:\nThe first and second consumers may both send exceeding rate to the third consumer if their capacity is exceeded.\nCROSSOVER: \n - FROM: consumer1 \n TO: consumer3 \n - FROM: consumer2 \n TO: consumer3 \n", "items": { - "type": "integer" + "$ref": "#/definitions/YamlCrossover" }, "title": "Crossover", "type": "array" diff --git a/src/tests/libecalc/input/yaml_types/system/test_system_component_conditions.py b/src/tests/libecalc/input/yaml_types/system/test_system_component_conditions.py new file mode 100644 index 0000000000..e1cbf3bed2 --- /dev/null +++ b/src/tests/libecalc/input/yaml_types/system/test_system_component_conditions.py @@ -0,0 +1,56 @@ +import pytest +from libecalc.input.yaml_types.components.system.yaml_system_component_conditions import ( + YamlCrossover, + YamlSystemComponentConditions, +) +from pydantic import ValidationError + + +class TestSystemComponentConditions: + def test_validation_error_on_several_crossover_out_for_one_consumer(self): + """ + Currently not allowed to have several crossover out from a consumer, as we have not implemented any logic to + pick which stream/pipe to fill. + """ + + with pytest.raises(ValidationError) as exc_info: + YamlSystemComponentConditions( + crossover=[ + YamlCrossover( + from_="consumer1", + to="consumer2", + ), + YamlCrossover( + from_="consumer1", + to="consumer3", + ), + YamlCrossover( + from_="consumer2", + to="consumer3", + ), + YamlCrossover( + from_="consumer2", + to="consumer4", + ), + ] + ) + assert str(exc_info.value) == ( + "1 validation error for YamlSystemComponentConditions\n" + "CROSSOVER\n" + " Only one crossover per consumer is currently supported. Component(s) with " + "several crossover streams are consumer1, consumer2 (type=value_error)" + ) + + def test_valid_crossover(self): + YamlSystemComponentConditions( + crossover=[ + YamlCrossover( + from_="consumer1", + to="consumer2", + ), + YamlCrossover( + from_="consumer2", + to="consumer3", + ), + ] + )