Skip to content

Commit acb35da

Browse files
authored
chore: use pipeline sections instead of pipelines for solvers (#1668)
The solvers got a new signature previously, accepting PipelineSections instead of Pipelines. A Pipeline consists of one or more pipeline sections. Each pipeline section is considered for now to be able to be handled separately, but in sequence, in according to the order in the (parent) pipeline. Both multipressuresolver and pipelinesectionsolver are able to handle pipeline sections, and we therefore supply a complete set of data/params with a pipeline section for resolving, and no need to know about the complete pipeline. In order to easily be able to set up the sections both when parsing yaml and when we actually get the timeseries data to solve it, we create the sections when parsing, and reuse the same sections when solving. Refs: equinor/ecalc-internal#2008
1 parent 1ef0f0b commit acb35da

10 files changed

Lines changed: 273 additions & 82 deletions

File tree

src/libecalc/ecalc_model/process_simulation.py

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,17 @@
1414
)
1515
from libecalc.ecalc_model.time_series_stream import TimeSeriesStream
1616
from libecalc.presentation.yaml.domain.time_series_expression import TimeSeriesExpression
17-
from libecalc.process.process_pipeline.process_pipeline import ProcessPipelineId
17+
from libecalc.process.process_pipeline.process_pipeline import (
18+
ProcessPipelineId,
19+
ProcessPipelineSectionId,
20+
ProcessUnitConnectionId,
21+
)
1822
from libecalc.process.process_pipeline.process_unit import ProcessUnitId
1923
from libecalc.process.process_solver.anti_surge.anti_surge_strategy import AntiSurgeType
2024
from libecalc.process.process_solver.configuration_handler import ConfigurationHandler
21-
from libecalc.process.process_solver.pressure_control.pressure_control_strategy import PressureControlType
25+
from libecalc.process.process_solver.pressure_control.pressure_control_strategy import (
26+
PressureControlType,
27+
)
2228
from libecalc.process.stream_distribution.common_stream_distribution import Overflow
2329

2430

@@ -52,60 +58,87 @@ class AntiSurgeConfig:
5258
@value_object
5359
class Constraint:
5460
outlet_pressure: TimeSeriesExpression
55-
pressure_control: PressureControlConfig
56-
anti_surge: AntiSurgeConfig
5761
target_process_unit_id: ProcessUnitId
62+
target_process_connection_id: ProcessUnitConnectionId # Currently the outlet of the process_unit above
5863

5964

6065
ProcessProblemId = NewType("ProcessProblemId", UUID)
66+
ProcessProblemSectionId = NewType("ProcessProblemSectionId", UUID)
6167

6268

63-
@value_object
64-
class ProcessProblemSection:
65-
"""A process section assembled for solver execution."""
69+
class ProcessProblemSection(Entity[ProcessProblemSectionId]):
70+
def __init__(
71+
self,
72+
process_pipeline_section_id: ProcessPipelineSectionId,
73+
constraint: Constraint,
74+
pressure_control: PressureControlConfig,
75+
anti_surge: AntiSurgeConfig,
76+
process_problem_section_id: ProcessProblemSectionId | None = None,
77+
):
78+
self._process_pipeline_section_id = process_pipeline_section_id
79+
self._constraint = constraint
80+
self._pressure_control = pressure_control
81+
self._anti_surge = anti_surge
82+
self._id: Final[ProcessProblemSectionId] = process_problem_section_id or ProcessProblemSection._create_id()
83+
84+
def get_id(self) -> ProcessProblemSectionId:
85+
return self._id
6686

67-
process_unit_ids: list[ProcessUnitId]
68-
configuration_handlers: Sequence[ConfigurationHandler]
69-
constraint: Constraint
87+
def get_process_pipeline_section_id(self) -> ProcessPipelineSectionId:
88+
return self._process_pipeline_section_id
7089

90+
def get_constraint(self) -> Constraint:
91+
return self._constraint
7192

72-
class ProcessProblem(Entity[ProcessProblemId]): # TODO: Rename to subproblem?
73-
# can a problem exist wo. a simulation? yes, e.g. get max rate ...
74-
# given a physical pipeline (a contained problem, such as a compressor train), the user needs to define strategies to find a solution for the sub problem
75-
# TODO: might have subproblems, or dependencies, but we may want to add those as problems that depend on each other and needs to be evaluated in a given order
93+
def get_pressure_control(self) -> PressureControlConfig:
94+
return self._pressure_control
7695

96+
def get_anti_surge(self) -> AntiSurgeConfig:
97+
return self._anti_surge
98+
99+
@classmethod
100+
def _create_id(cls: type[Self]) -> ProcessProblemSectionId:
101+
return ProcessProblemSectionId(ecalc_id_generator())
102+
103+
104+
class ProcessProblem(Entity[ProcessProblemId]):
77105
def __init__(
78106
self,
79-
process_problem_sections: Sequence[ProcessProblemSection],
80-
configuration_handlers: Sequence[ConfigurationHandler],
107+
process_problem_sections: Sequence[
108+
ProcessProblemSection
109+
], # Currently we consider that ProblemSections needs to be solved in sequence, therefore we have instance here and not ID
110+
configuration_handlers: Sequence[
111+
ConfigurationHandler
112+
], # inter or intra section config handlers, stored here for now
81113
process_pipeline_id: ProcessPipelineId,
82114
process_problem_id: ProcessProblemId | None = None,
83115
):
84-
self.process_problem_sections = process_problem_sections
85-
self.configuration_handlers = configuration_handlers
86-
self.process_pipeline_id = process_pipeline_id
116+
self._process_problem_sections = process_problem_sections
117+
self._configuration_handlers = configuration_handlers
118+
self._process_pipeline_id = process_pipeline_id
87119
self._id: Final[ProcessProblemId] = process_problem_id or ProcessProblem._create_id()
88120

89121
def get_id(self) -> ProcessProblemId:
90122
return self._id
91123

124+
def get_process_pipeline_id(self) -> ProcessPipelineId:
125+
return self._process_pipeline_id
126+
92127
@classmethod
93128
def _create_id(cls: type[Self]) -> ProcessProblemId:
94129
return ProcessProblemId(ecalc_id_generator())
95130

96-
def get_constraints(self) -> list[Constraint]:
97-
return [section.constraint for section in self.process_problem_sections]
131+
def get_process_problem_sections(self) -> Sequence[ProcessProblemSection]:
132+
return self._process_problem_sections
133+
134+
def get_configuration_handlers(self) -> Sequence[ConfigurationHandler]:
135+
return self._configuration_handlers
98136

99137

100138
ProcessSimulationId = NewType("ProcessSimulationId", UUID)
101139

102140

103141
class ProcessSimulation(Entity[ProcessSimulationId]): # process_model?
104-
"""
105-
TODO: one or more subproblems, where we first need to find the stream distribution before looking at each subproblem separately
106-
quit and notify as soon as we notice we are not able to find a solution, or always finish?
107-
"""
108-
109142
def __init__(
110143
self,
111144
name: str,

src/libecalc/presentation/yaml/mappers/process_simulation_mapper.py

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import assert_never, get_args
22

33
from libecalc.common.errors.ecalc_validation_error import EcalcValidationException
4-
from libecalc.common.errors.exceptions import InvalidResourceException
4+
from libecalc.common.errors.exceptions import InvalidResourceException, ProgrammingError
55
from libecalc.common.time_utils import Period
66
from libecalc.common.units import Unit
77
from libecalc.common.variables import ExpressionEvaluator
@@ -63,6 +63,7 @@
6363
from libecalc.process.process_pipeline.process_pipeline import (
6464
ProcessPipeline,
6565
ProcessPipelineId,
66+
ProcessPipelineSection,
6667
)
6768
from libecalc.process.process_pipeline.process_unit import ProcessUnit, ProcessUnitId
6869
from libecalc.process.process_solver.feasibility_solver import FeasibilitySolver
@@ -336,9 +337,12 @@ def map_process_simulation(
336337
assert isinstance(compressor, Compressor)
337338
shaft.connect(compressor)
338339

340+
# Shaft is currently (potentially) an inter-section configuration handler
339341
problem_configuration_handlers.append(shaft)
340342

341-
process_units: list[ProcessUnit] = []
343+
# Since we, in addition to intra-section connection, have inter-section connections, we keep them separate
344+
# from section, and keep track of them at pipeline level
345+
process_pipeline_sections: list[ProcessPipelineSection] = []
342346
process_problem_sections: list[ProcessProblemSection] = []
343347
pipeline_constraints = yaml_process_simulation.constraints.get(item.name)
344348

@@ -354,30 +358,57 @@ def map_process_simulation(
354358
mapped_sections=mapped_sections, fluid_service=self._fluid_service
355359
)
356360

357-
for mapped_section, assembled_section in zip(mapped_sections, assembled_sections, strict=True):
358-
constraint = Constraint(
359-
outlet_pressure=TimeSeriesExpression(
360-
expression=mapped_section.constraint.outlet_pressure,
361-
expression_evaluator=self._expression_evaluator,
362-
),
363-
pressure_control=PressureControlConfig(type=mapped_section.constraint.pressure_control),
364-
anti_surge=AntiSurgeConfig(mapped_section.constraint.anti_surge),
365-
target_process_unit_id=mapped_section.target_process_unit_id,
366-
)
361+
# Set up pipeline and pipeline sections
362+
for nr, assembled_section in enumerate(assembled_sections):
363+
process_section_process_units = list(assembled_section.process_units)
364+
if nr == 0: # first section
365+
process_section_process_units.insert(0, Inlet())
366+
if nr == len(mapped_sections) - 1: # last section
367+
process_section_process_units.append(Outlet())
368+
369+
process_pipeline_sections.append(ProcessPipelineSection(process_units=process_section_process_units))
370+
371+
# TODO: We should move this class to this module/layer
372+
# in particular because it creates necessary connections, which means that they will get new IDs
373+
process_pipeline = ProcessPipeline(
374+
name=item.name,
375+
process_pipeline_sections=process_pipeline_sections,
376+
)
377+
378+
# Set up problem and problem sections
379+
for pipeline_section, mapped_section, assembled_section in zip(
380+
process_pipeline_sections, mapped_sections, assembled_sections, strict=True
381+
):
382+
try:
383+
constraint = Constraint(
384+
outlet_pressure=TimeSeriesExpression(
385+
expression=mapped_section.constraint.outlet_pressure,
386+
expression_evaluator=self._expression_evaluator,
387+
),
388+
target_process_unit_id=mapped_section.target_process_unit_id,
389+
target_process_connection_id=next(
390+
process_unit_connection.get_id()
391+
for process_unit_connection in process_pipeline.get_process_unit_connections()
392+
if process_unit_connection.get_from_process_unit_id()
393+
== mapped_section.target_process_unit_id
394+
),
395+
)
396+
except StopIteration:
397+
raise ProgrammingError(
398+
f"Not able to set constraint on OUTLET of process unit '{mapped_section.target_process_unit_id}' because the Connection ID was not found."
399+
) from None
400+
367401
process_problem_sections.append(
368402
ProcessProblemSection(
369-
process_unit_ids=[u.get_id() for u in assembled_section.process_units],
370-
configuration_handlers=assembled_section.configuration_handlers,
403+
process_pipeline_section_id=pipeline_section.get_id(),
404+
# configuration_handlers=assembled_section.configuration_handlers, # TODO: We currently store config handlers at problem level. They may be inter or intra section ...
371405
constraint=constraint,
406+
pressure_control=PressureControlConfig(type=mapped_section.constraint.pressure_control),
407+
anti_surge=AntiSurgeConfig(mapped_section.constraint.anti_surge),
372408
)
373409
)
374-
process_units.extend(assembled_section.process_units)
375-
376-
# A pipeline must have a start and an end - always add process units for that (ie owner of inlet and outlet streams)
377-
process_units.append(Outlet())
378-
process_units.insert(0, Inlet())
379-
380-
process_pipeline = ProcessPipeline(name=item.name, stream_propagators=process_units)
410+
# Choke and recirculation configuration handlers are currently intra-section config handlers
411+
problem_configuration_handlers.extend(assembled_section.configuration_handlers)
381412

382413
predefined_configurations[process_pipeline.get_id()] = problem_time_series_configurations
383414

src/libecalc/process/process_pipeline/process_pipeline.py

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,83 @@
44

55
from libecalc.common.ddd.entity import Entity
66
from libecalc.common.utils.ecalc_uuid import ecalc_id_generator
7-
from libecalc.process.process_pipeline.process_unit import ProcessUnit
7+
from libecalc.process.process_pipeline.process_unit import ProcessUnit, ProcessUnitId
88

99
ProcessPipelineId = NewType("ProcessPipelineId", UUID)
10+
ProcessPipelineSectionId = NewType("ProcessPipelineSectionId", UUID)
11+
ProcessUnitConnectionId = NewType("ProcessUnitConnectionId", UUID)
12+
13+
14+
class ProcessUnitConnection(Entity[ProcessUnitConnectionId]):
15+
def __init__(
16+
self,
17+
from_process_unit_id: ProcessUnitId,
18+
to_process_unit_id: ProcessUnitId,
19+
process_unit_connection_id: ProcessUnitConnectionId | None = None,
20+
):
21+
self._from_process_unit_id = from_process_unit_id
22+
self._to_process_unit_id = to_process_unit_id
23+
self._id: Final[ProcessUnitConnectionId] = process_unit_connection_id or ProcessUnitConnection._create_id()
24+
25+
def get_id(self) -> ProcessUnitConnectionId:
26+
return self._id
27+
28+
def get_from_process_unit_id(self) -> ProcessUnitId:
29+
return self._from_process_unit_id
30+
31+
def get_to_process_unit_id(self) -> ProcessUnitId:
32+
return self._to_process_unit_id
33+
34+
@classmethod
35+
def _create_id(cls: type[Self]) -> ProcessUnitConnectionId:
36+
return ProcessUnitConnectionId(ecalc_id_generator())
37+
38+
def __str__(self):
39+
return f"ProcessUnitConnection(process_unit_connection_id={self._id}, from_process_unit_id={self._from_process_unit_id}, to_process_unit_id={self._to_process_unit_id})"
40+
41+
42+
class ProcessPipelineSection(Entity[ProcessPipelineSectionId]):
43+
def __init__(
44+
self,
45+
process_units: Sequence[ProcessUnit], # TODO: Reassure they are in order
46+
process_pipeline_section_id: ProcessPipelineSectionId | None = None,
47+
):
48+
self._process_units = process_units
49+
self._id: Final[ProcessPipelineSectionId] = process_pipeline_section_id or ProcessPipelineSection._create_id()
50+
51+
def get_id(self) -> ProcessPipelineSectionId:
52+
return self._id
53+
54+
def get_process_units(self) -> list[ProcessUnit]:
55+
return list(self._process_units)
56+
57+
@classmethod
58+
def _create_id(cls: type[Self]) -> ProcessPipelineSectionId:
59+
return ProcessPipelineSectionId(ecalc_id_generator())
60+
61+
def __str__(self):
62+
return f"ProcessPipelineSection(process_pipeline_section_id={self._id}, process_units={self._process_units})"
1063

1164

1265
class ProcessPipeline(Entity[ProcessPipelineId]):
66+
"""
67+
TODO: We define this class in process, but we do not use it here. We use it in the ephemeral mapping layer,
68+
when storing in db And some testing. We should move it.
69+
In particular because it creates the necessary connections, which means that they will get new IDs
70+
"""
71+
1372
def __init__(
14-
self, name: str, stream_propagators: Sequence[ProcessUnit], process_pipeline_id: ProcessPipelineId | None = None
73+
self,
74+
name: str,
75+
process_pipeline_sections: Sequence[ProcessPipelineSection],
76+
process_pipeline_id: ProcessPipelineId | None = None,
1577
):
1678
self._name = name
17-
self._stream_propagators = stream_propagators
79+
self._process_pipeline_sections = process_pipeline_sections
80+
self._process_unit_connections = ProcessPipeline._create_process_unit_connections(
81+
process_pipeline_sections=process_pipeline_sections
82+
)
83+
self._process_pipeline_id = process_pipeline_id
1884
self._id: Final[ProcessPipelineId] = process_pipeline_id or ProcessPipeline._create_id()
1985

2086
def get_id(self) -> ProcessPipelineId:
@@ -23,12 +89,63 @@ def get_id(self) -> ProcessPipelineId:
2389
def get_name(self) -> str:
2490
return self._name
2591

26-
def get_process_units(self) -> list[ProcessUnit]:
27-
return list(self._stream_propagators)
92+
def get_process_pipeline_sections(self) -> Sequence[ProcessPipelineSection]:
93+
return self._process_pipeline_sections
94+
95+
def get_process_unit_connections(self) -> Sequence[ProcessUnitConnection]:
96+
return self._process_unit_connections
97+
98+
def get_process_units(self) -> Sequence[ProcessUnit]:
99+
return [
100+
process_unit
101+
for process_section in self.get_process_pipeline_sections()
102+
for process_unit in process_section.get_process_units()
103+
]
28104

29105
@classmethod
30106
def _create_id(cls: type[Self]) -> ProcessPipelineId:
31107
return ProcessPipelineId(ecalc_id_generator())
32108

109+
@staticmethod
110+
def _create_process_unit_connections(
111+
process_pipeline_sections: Sequence[ProcessPipelineSection],
112+
) -> Sequence[ProcessUnitConnection]:
113+
"""
114+
Connections kept at this level for now. Could potentially be handled at section level, which makes more sense,
115+
if we define the owner of a connection to be the process unit with the OUTLET (the last process unit will then not
116+
own any connections, as it doesn't have an outlet ...). In this class we can therefore just gather/build connections
117+
from the process unit sections, either represented as connections or just "outlet"s, which is what we need to store
118+
stream info on. inlet is just the result from previous process unit or section or sth else. The parameter basically.
119+
120+
Currently we keep it here though, because inlet and outlet are "equivalent", and we therefore have
121+
intra and inter connections between process units, ie. across sections. So, we need to make a decision on who,
122+
is the owner - the section or the pipeline. Since it is just an identifier based on the surrogate of from and to,
123+
with a unique id, with no extra information, we can generate it on the fly.
124+
125+
Private, because this should be internal information handled by pipeline, and only exposed for read
126+
127+
Args:
128+
process_pipeline_sections:
129+
130+
Returns:
131+
132+
"""
133+
process_unit_connections: list[ProcessUnitConnection] = []
134+
135+
previous_process_unit: ProcessUnit | None = None
136+
for process_section in process_pipeline_sections: # Ordered, in sequence!
137+
for process_unit in process_section.get_process_units(): # Ordered, in sequence!
138+
if previous_process_unit is not None:
139+
process_unit_connections.append(
140+
ProcessUnitConnection(
141+
from_process_unit_id=previous_process_unit.get_id(),
142+
to_process_unit_id=process_unit.get_id(),
143+
)
144+
)
145+
146+
previous_process_unit = process_unit
147+
148+
return process_unit_connections
149+
33150
def __str__(self):
34-
return f"ProcessPipeline(process_pipeline_id={self._id}, name={self._name}, stream_propagators={self._stream_propagators})"
151+
return f"ProcessPipeline(process_pipeline_id={self._id}, name={self._name}, process_pipeline_sections={self._process_pipeline_sections})"

0 commit comments

Comments
 (0)