Skip to content

Commit b1141f9

Browse files
authored
feat(process_solver): MultiPressureSolver for N ordered pressure targets on a shared shaft (#1452)
Implements MultiPressureSolver for compressor trains where N segments share a shaft and must each meet an independent outlet pressure target (e.g. interstage pressure control).
1 parent 6bf7397 commit b1141f9

6 files changed

Lines changed: 672 additions & 9 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from collections.abc import Sequence
2+
from typing import Final
3+
4+
from libecalc.domain.component_validation_error import DomainValidationException
5+
from libecalc.domain.process.process_solver.float_constraint import FloatConstraint
6+
from libecalc.domain.process.process_solver.outlet_pressure_solver import OutletPressureSolver
7+
from libecalc.domain.process.process_solver.pressure_control.downstream_choke import (
8+
DownstreamChokePressureControlStrategy,
9+
)
10+
from libecalc.domain.process.process_solver.pressure_control.upstream_choke import UpstreamChokePressureControlStrategy
11+
from libecalc.domain.process.process_solver.process_runner import Configuration
12+
from libecalc.domain.process.process_solver.solver import Solution
13+
from libecalc.domain.process.process_solver.solvers.speed_solver import SpeedConfiguration
14+
from libecalc.domain.process.value_objects.fluid_stream import FluidStream
15+
16+
17+
class MultiPressureSolver:
18+
"""Tries to find the shaft speed satisfying N ordered pressure targets, one per segment. Segments share a
19+
single physical shaft. Each segment's runner covers a disjoint sub-sequence of the stream propagation chain.
20+
21+
Independent OutletPressureSolver per segment (sequential) against its own pressure target. This gives the
22+
speed each segment would require if unconstrained. The segment requiring the highest speed is the binding
23+
constraint. At binding speed, non-binding segments produce more pressure than needed and must have it reduced
24+
by their pressure-control strategy.
25+
26+
All segments are re-run in sequence at binding_speed. Anti-surge is applied first. If a segment's outlet
27+
still exceeds its target, the pressure-control strategy reduces it. The outlet of each segment feeds the inlet
28+
of the next. For all but one segment, speed changed from the individual evaluations. Therefore, recirculation
29+
is re-evaluated from scratch.
30+
"""
31+
32+
def __init__(
33+
self,
34+
segments: list[OutletPressureSolver],
35+
) -> None:
36+
if len(segments) < 2:
37+
raise DomainValidationException("MultiPressureSolver requires at least 2 segments.")
38+
shaft_ids = {segment.shaft_id for segment in segments}
39+
if len(shaft_ids) != 1:
40+
raise DomainValidationException("All segments must share the same shaft_id.")
41+
self._shaft_id: Final = next(iter(shaft_ids))
42+
self._validate_pressure_control_placement(segments)
43+
self._segments: Final = segments
44+
45+
@staticmethod
46+
def _validate_pressure_control_placement(segments: list[OutletPressureSolver]) -> None:
47+
"""Upstream choke is only valid on the first segment; downstream choke only on the last."""
48+
for i, segment in enumerate(segments):
49+
strategy = segment.pressure_control_strategy
50+
is_first = i == 0
51+
is_last = i == len(segments) - 1
52+
if isinstance(strategy, UpstreamChokePressureControlStrategy) and not is_first:
53+
raise DomainValidationException(
54+
f"UpstreamChokePressureControlStrategy is only valid for the first segment "
55+
f"(segment {i} of {len(segments)})."
56+
)
57+
if isinstance(strategy, DownstreamChokePressureControlStrategy) and not is_last:
58+
raise DomainValidationException(
59+
f"DownstreamChokePressureControlStrategy is only valid for the last segment "
60+
f"(segment {i} of {len(segments)})."
61+
)
62+
63+
@staticmethod
64+
def _extract_speed_configuration(solution: Solution[Sequence[Configuration]]) -> SpeedConfiguration:
65+
"""Find speed configuration in a sequence of configurations."""
66+
for config in solution.configuration:
67+
if isinstance(config.value, SpeedConfiguration):
68+
return config.value
69+
raise DomainValidationException("No SpeedConfiguration found in solution.")
70+
71+
def find_solution(
72+
self,
73+
pressure_targets: list[FloatConstraint],
74+
inlet_stream: FluidStream,
75+
) -> Solution[Sequence[Configuration]]:
76+
if len(pressure_targets) != len(self._segments):
77+
raise DomainValidationException(
78+
f"Number of pressure targets ({len(pressure_targets)}) must match "
79+
f"number of segments ({len(self._segments)})."
80+
)
81+
82+
speed_configurations: list[SpeedConfiguration] = []
83+
current_inlet = inlet_stream
84+
for segment, target in zip(self._segments, pressure_targets):
85+
solution_for_segment = segment.find_solution(pressure_constraint=target, inlet_stream=current_inlet)
86+
speed_configurations.append(self._extract_speed_configuration(solution_for_segment))
87+
segment.runner.apply_configurations(solution_for_segment.configuration)
88+
current_inlet = segment.runner.run(inlet_stream=current_inlet)
89+
90+
shaft_config = Configuration(
91+
simulation_unit_id=self._shaft_id,
92+
value=max(speed_configurations),
93+
)
94+
all_configurations: dict = {self._shaft_id: shaft_config}
95+
96+
current_inlet = inlet_stream
97+
overall_success = True
98+
99+
for segment, target in zip(self._segments, pressure_targets):
100+
segment.runner.apply_configuration(shaft_config)
101+
102+
segment.anti_surge_strategy.reset()
103+
anti_surge_solution = segment.anti_surge_strategy.apply(inlet_stream=current_inlet)
104+
segment.runner.apply_configurations(anti_surge_solution.configuration)
105+
for config in anti_surge_solution.configuration:
106+
all_configurations[config.simulation_unit_id] = config
107+
108+
outlet = segment.runner.run(inlet_stream=current_inlet)
109+
110+
if outlet.pressure_bara > target:
111+
pressure_control_solution = segment.pressure_control_strategy.apply(
112+
target_pressure=target,
113+
inlet_stream=current_inlet,
114+
)
115+
for config in pressure_control_solution.configuration:
116+
all_configurations[config.simulation_unit_id] = config
117+
segment.runner.apply_configurations(pressure_control_solution.configuration)
118+
outlet = segment.runner.run(inlet_stream=current_inlet)
119+
if not pressure_control_solution.success:
120+
overall_success = False
121+
elif outlet.pressure_bara < target:
122+
overall_success = False
123+
124+
current_inlet = outlet
125+
126+
return Solution(
127+
success=overall_success,
128+
configuration=list(all_configurations.values()),
129+
)

src/libecalc/domain/process/process_solver/outlet_pressure_solver.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from collections.abc import Sequence
22
from typing import Final
33

4-
from libecalc.domain.process.entities.shaft import Shaft
54
from libecalc.domain.process.entities.shaft.shaft import ShaftId
65
from libecalc.domain.process.process_solver.anti_surge.anti_surge_strategy import AntiSurgeStrategy
76
from libecalc.domain.process.process_solver.boundary import Boundary
@@ -37,14 +36,14 @@ class OutletPressureSolver:
3736

3837
def __init__(
3938
self,
40-
shaft: Shaft,
39+
shaft_id: ShaftId,
4140
runner: ProcessRunner,
4241
anti_surge_strategy: AntiSurgeStrategy,
4342
pressure_control_strategy: PressureControlStrategy,
4443
root_finding_strategy: RootFindingStrategy,
4544
speed_boundary: Boundary,
4645
) -> None:
47-
self._shaft: Final = shaft
46+
self._shaft_id: Final = shaft_id
4847
self._root_finding_strategy: Final = root_finding_strategy
4948
self._anti_surge_strategy: Final = anti_surge_strategy
5049
self._simulator: Final = runner
@@ -53,6 +52,22 @@ def __init__(
5352

5453
self._anti_surge_solution: Solution[Sequence[Configuration[RecirculationConfiguration]]] | None = None
5554

55+
@property
56+
def runner(self) -> ProcessRunner:
57+
return self._simulator
58+
59+
@property
60+
def anti_surge_strategy(self) -> AntiSurgeStrategy:
61+
return self._anti_surge_strategy
62+
63+
@property
64+
def pressure_control_strategy(self) -> PressureControlStrategy:
65+
return self._pressure_control_strategy
66+
67+
@property
68+
def shaft_id(self) -> ShaftId:
69+
return self._shaft_id
70+
5671
def _get_initial_speed_boundary(self) -> Boundary:
5772
return self._speed_boundary
5873

@@ -69,9 +84,7 @@ def _find_speed_solution(
6984
)
7085

7186
def speed_func(configuration: SpeedConfiguration) -> FluidStream:
72-
self._simulator.apply_configuration(
73-
Configuration(simulation_unit_id=self._shaft.get_id(), value=configuration)
74-
)
87+
self._simulator.apply_configuration(Configuration(simulation_unit_id=self._shaft_id, value=configuration))
7588
self._anti_surge_strategy.reset()
7689
try:
7790
return self._simulator.run(inlet_stream=inlet_stream)
@@ -103,8 +116,8 @@ def find_solution(
103116
"""
104117
configurations: dict[ShaftId | ProcessUnitId | ProcessSystemId, Configuration] = {}
105118
speed_solution = self._find_speed_solution(pressure_constraint=pressure_constraint, inlet_stream=inlet_stream)
106-
configurations[self._shaft.get_id()] = Configuration(
107-
simulation_unit_id=self._shaft.get_id(),
119+
configurations[self._shaft_id] = Configuration(
120+
simulation_unit_id=self._shaft_id,
108121
value=speed_solution.configuration,
109122
)
110123

src/libecalc/domain/process/process_solver/solvers/speed_solver.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
from collections.abc import Callable
33
from dataclasses import dataclass
4+
from functools import total_ordering
45

56
from libecalc.domain.process.process_solver.boundary import Boundary
67
from libecalc.domain.process.process_solver.search_strategies import RootFindingStrategy, SearchStrategy
@@ -11,10 +12,21 @@
1112
logger = logging.getLogger(__name__)
1213

1314

15+
@total_ordering
1416
@dataclass
1517
class SpeedConfiguration:
1618
speed: float
1719

20+
def __eq__(self, other: object) -> bool:
21+
if not isinstance(other, SpeedConfiguration):
22+
return NotImplemented
23+
return self.speed == other.speed
24+
25+
def __lt__(self, other: object) -> bool:
26+
if not isinstance(other, SpeedConfiguration):
27+
return NotImplemented
28+
return self.speed < other.speed
29+
1830

1931
class SpeedSolver(Solver[SpeedConfiguration]):
2032
def __init__(

tests/libecalc/domain/process/process_solver/conftest.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from libecalc.domain.process.process_solver.anti_surge.common_asv import CommonASVAntiSurgeStrategy
77
from libecalc.domain.process.process_solver.anti_surge.individual_asv import IndividualASVAntiSurgeStrategy
88
from libecalc.domain.process.process_solver.boundary import Boundary
9+
from libecalc.domain.process.process_solver.multi_pressure_solver import MultiPressureSolver
910
from libecalc.domain.process.process_solver.outlet_pressure_solver import OutletPressureSolver
1011
from libecalc.domain.process.process_solver.pressure_control.common_asv import CommonASVPressureControlStrategy
1112
from libecalc.domain.process.process_solver.pressure_control.downstream_choke import (
@@ -20,6 +21,7 @@
2021
from libecalc.domain.process.process_solver.process_runner import ProcessRunner
2122
from libecalc.domain.process.process_system.process_system import ProcessSystemId
2223
from libecalc.domain.process.process_system.process_unit import ProcessUnit, ProcessUnitId
24+
from libecalc.domain.process.value_objects.chart import ChartCurve
2325

2426

2527
@pytest.fixture
@@ -70,7 +72,7 @@ def create_outlet_pressure_solver(
7072
speed_boundary: Boundary,
7173
):
7274
return OutletPressureSolver(
73-
shaft=shaft,
75+
shaft_id=shaft.get_id(),
7476
runner=runner,
7577
anti_surge_strategy=anti_surge_strategy,
7678
pressure_control_strategy=pressure_control_strategy,
@@ -191,3 +193,33 @@ def create(
191193
)
192194

193195
return create
196+
197+
198+
@pytest.fixture
199+
def multi_pressure_solver_factory():
200+
def create_multi_pressure_solver(
201+
segments: list[OutletPressureSolver],
202+
) -> MultiPressureSolver:
203+
return MultiPressureSolver(segments=segments)
204+
205+
return create_multi_pressure_solver
206+
207+
208+
def make_variable_speed_chart_data(chart_data_factory, *, min_rate, max_rate, head_hi, head_lo, eff):
209+
return chart_data_factory.from_curves(
210+
curves=[
211+
ChartCurve(
212+
speed_rpm=75.0,
213+
rate_actual_m3_hour=[min_rate, max_rate],
214+
polytropic_head_joule_per_kg=[head_hi, head_lo],
215+
efficiency_fraction=[eff, eff],
216+
),
217+
ChartCurve(
218+
speed_rpm=105.0,
219+
rate_actual_m3_hour=[min_rate, max_rate],
220+
polytropic_head_joule_per_kg=[head_hi * 1.05, head_lo * 1.05],
221+
efficiency_fraction=[eff, eff],
222+
),
223+
],
224+
control_margin=0.0,
225+
)

0 commit comments

Comments
 (0)