Skip to content

feat(process): OutletFluidNotAchievableError and SpeedSolver EOS failure handling#1543

Open
olelod wants to merge 7 commits intomainfrom
feat/outlet-fluid-not-achievable-error
Open

feat(process): OutletFluidNotAchievableError and SpeedSolver EOS failure handling#1543
olelod wants to merge 7 commits intomainfrom
feat/outlet-fluid-not-achievable-error

Conversation

@olelod
Copy link
Copy Markdown
Contributor

@olelod olelod commented May 6, 2026

Type of Work

  • Minor: X.Y+1.Z. Minor changes, might ADD new input (YAML), or other backwards-compatible changes. Use feat:, fix:

Have you remembered and considered?

  • IF FEAT: I have remembered to update documentation
  • IF FIX OR FEAT: I have remembered to update manual changelog (docs/drafts/next.draft.md)
  • I have added tests (if not, comment why)
  • I have used conventional commits syntax
  • I have included the Github issue nr in the footer!

What is this PR all about?

SpeedSolver had no handling for EOS/PHflash failures inside the compressor. If calculate_outlet_pressure_and_stream() raised — due to NaN properties, non-convergence, or a Java exception from NeqSim near the dense/supercritical boundary — the exception escaped the solver uncaught. The except EcalcError guard in Compressor.propagate_stream() also missed Java exceptions, which surface as Py4JJavaError/JPype exceptions rather than EcalcError subclasses.

This PR introduces OutletFluidNotAchievableError (a typed ProcessError) raised by Compressor.propagate_stream() on any exception from the flash layer, and wires it into SpeedSolver at all three call sites (max-speed probe, min-speed probe, root-finding loop). When the boundary probe fails, the solver binary-searches for the highest/lowest EOS-valid speed before proceeding. A pre-check against the opposite boundary prevents the search from exhausting iterations when no speed in the range produces a valid outlet — which would otherwise raise DidNotConvergeError unhandled.

What else did you consider?

Catching only EcalcError at the flash boundary — but Java exceptions from NeqSim are not EcalcError subclasses, so this left the original bug in place for the cases that actually occur in production.

Handling the recovery inside Compressor rather than in the solver — but the solver already owns the speed-search logic and is the right place to decide what to do with a failed operating point.

Between the lines?

The min-speed upward search mirrors the max-speed downward search and uses the same BinarySearchStrategy semantics as the existing RateTooHighError recovery. The pre-checks are structurally necessary: BinarySearchStrategy.search() converges to max(x) where accepted=True, and if no point is ever accepted it exhausts max_iterations without a valid result.

Comment thread src/libecalc/process/process_units/compressor.py Outdated
Comment thread src/libecalc/process/process_pipeline/process_error.py Outdated
Comment thread src/libecalc/process/process_solver/solvers/speed_solver.py Outdated
Comment thread src/libecalc/process/process_solver/solvers/speed_solver.py Outdated
)
minimum_speed_configuration = SpeedConfiguration(speed=minimum_speed_within_capacity)
minimum_speed_outlet_stream = func(minimum_speed_configuration)
except OutletFluidNotAchievableError:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method is getting long, would it make sense to split it into (reusable) steps/phases - to easier understand/read each step?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes!

Comment thread src/libecalc/process/process_units/compressor.py Outdated
assert outlet_stream.pressure_bara == expected_pressure


class FluidNotAchievableProcessUnit(ProcessUnit):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this like a "probe" to test the stream?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it need to be a processunit? can we rather mock the propagate_stream method on a DummyProcessUnit with "no" logic, since what we are testing is that the "framework" around can handle the error state correctly?

@olelod olelod force-pushed the feat/outlet-fluid-not-achievable-error branch from 6bce7ae to ba42051 Compare May 7, 2026 09:45
@olelod olelod marked this pull request as ready for review May 7, 2026 10:55
@olelod olelod requested a review from a team as a code owner May 7, 2026 10:55
@olelod olelod force-pushed the feat/outlet-fluid-not-achievable-error branch from ba42051 to 5df72d6 Compare May 7, 2026 10:56
@olelod olelod force-pushed the feat/outlet-fluid-not-achievable-error branch from 5df72d6 to 754df3e Compare May 7, 2026 13:02
Copy link
Copy Markdown
Contributor

@kjbrak kjbrak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see the inline comments.

return Solution(
success=False,
configuration=configuration,
failure_event=OutletFluidNotAchievableEvent(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new failure event still needs a caller-side short-circuit. OutletPressureSolver.find_solution() currently treats every failed speed solution as a pressure-control case: it applies the returned speed, runs anti-surge, and then re-runs the pipeline. For an OUTLET_FLUID_NOT_ACHIEVABLE result, that speed is specifically not runnable, so the typed solver failure can become an escaping OutletFluidNotAchievableError again. Consider returning this failure directly from OutletPressureSolver before anti-surge/pressure-control handling, preserving speed_solution.failure_event and the speed configuration.

if isinstance(result, Solution):
return result
max_speed_configuration = result
maximum_speed_outlet_stream = get_outlet_stream(speed=result.speed)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When max speed fails EOS, this block adjusts max_speed_configuration to the highest EOS-feasible speed and computes maximum_speed_outlet_stream at that adjusted speed. If the target is still above that adjusted maximum, the failure branch below currently returns the original boundary max, which makes the returned configuration inconsistent with achievable_value and can cause callers to re-run the EOS-invalid speed. Consider returning max_speed_configuration from that target-not-achievable branch instead.

try:
get_outlet_stream(x)
return ACCEPT_AND_GO_HIGHER
except (OutletFluidNotAchievableError, RateTooHighError, RateTooLowError):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This search can still leak a capacity error when the lower end of the speed range is invalid for capacity while the upper end is invalid for EOS. For example, boundary.min may raise RateTooHighError, an intermediate speed may be valid, and high speeds may raise OutletFluidNotAchievableError; the pre-check only catches EOS failures, and treating RateTooHighError as "go lower" moves in the wrong direction for speed-dependent max-flow capacity. Consider first narrowing to the lowest capacity-feasible speed, then searching for the highest EOS-feasible speed within that narrowed interval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants