|
| 1 | +"""Regression test for IndividualASVPressureControlStrategy success-flag bug. |
| 2 | +
|
| 3 | +Bug: IndividualASVPressureControlStrategy.apply returned |
| 4 | + Solution(success=outlet_stream == target_pressure, ...) |
| 5 | +where outlet_stream is a FluidStream and target_pressure is a FloatConstraint. |
| 6 | +Because these are different types, Python's equality check always returns False |
| 7 | +(no __eq__ override bridges them), so the strategy always reported success=False |
| 8 | +even when the recirculation was correctly adjusted and the target was met. |
| 9 | +
|
| 10 | +Fix: compare outlet_stream.pressure_bara == target_pressure, which uses |
| 11 | +FloatConstraint.__eq__ (isclose, abs_tol=1e-3). |
| 12 | +
|
| 13 | +Secondary bug also fixed (covered by the same test): the boundary for |
| 14 | +RecirculationSolver was computed via run(to_id=compressor_id), which traverses |
| 15 | +through the RecirculationLoop mixer and returns a stream whose rate already |
| 16 | +includes the current recirculation. After _minimum_achievable_pressure sets |
| 17 | +recirculation to its maximum, the boundary collapses to [0, 0], preventing |
| 18 | +the solver from finding a valid recirculation. |
| 19 | +Fix: use run(to_id=recirculation_loop_id) to stop before the mixer. |
| 20 | +""" |
| 21 | + |
| 22 | +import pytest |
| 23 | + |
| 24 | +from libecalc.domain.process.process_solver.float_constraint import FloatConstraint |
| 25 | +from libecalc.domain.process.value_objects.chart import ChartCurve |
| 26 | + |
| 27 | + |
| 28 | +@pytest.mark.parametrize("target_pressure_bara", [50.0, 70.0, 87.0]) |
| 29 | +def test_individual_asv_pressure_control_reaches_target_pressure( |
| 30 | + target_pressure_bara, |
| 31 | + stream_factory, |
| 32 | + chart_data_factory, |
| 33 | + fluid_service, |
| 34 | + stage_units_factory, |
| 35 | + with_individual_asv, |
| 36 | + process_runner_factory, |
| 37 | + individual_asv_pressure_control_strategy_factory, |
| 38 | +): |
| 39 | + """IndividualASVPressureControlStrategy must report success=True when the target is met. |
| 40 | +
|
| 41 | + On main: two bugs cause failure: |
| 42 | + 1. Boundary collapse: the per-stage boundary is computed via run(to_id=compressor_id), |
| 43 | + which traverses through the RecirculationLoop mixer. After _minimum_achievable_pressure |
| 44 | + sets recirculation to its maximum, the stream at the compressor already includes that |
| 45 | + max recirculation, making boundary.max = max_std - (inlet + max_recirc) = 0. The |
| 46 | + RecirculationSolver receives boundary=[0, 0] and raises DidNotConvergeError. |
| 47 | + 2. Wrong success flag: even if the solver ran, success=outlet_stream == target_pressure |
| 48 | + compares FluidStream to FloatConstraint — always False. |
| 49 | +
|
| 50 | + With fix: stable boundary (run stops before mixer), correct type comparison. |
| 51 | + """ |
| 52 | + temperature = 300.0 |
| 53 | + inlet_standard_rate = 500_000.0 # sm3/day |
| 54 | + inlet_pressure = 30.0 # bara |
| 55 | + |
| 56 | + inlet_stream = stream_factory( |
| 57 | + standard_rate_m3_per_day=inlet_standard_rate, |
| 58 | + pressure_bara=inlet_pressure, |
| 59 | + temperature_kelvin=temperature, |
| 60 | + ) |
| 61 | + q0 = float(inlet_stream.volumetric_rate_m3_per_hour) |
| 62 | + |
| 63 | + # Chart: min_rate = 2*q0 (inlet is BELOW min → ASV recirculation always needed). |
| 64 | + # At min speed (75 RPM): |
| 65 | + # - Outlet at surge flow (2*q0): head=head_hi=150k → outlet ≈ 89 bara |
| 66 | + # - Max recirculation: flow = 8*q0, head=head_lo=40k → outlet ≈ 41 bara |
| 67 | + # All three target pressures (50, 70, 87) lie strictly between 41 and 89 bara. |
| 68 | + chart_data = chart_data_factory.from_curves( |
| 69 | + curves=[ |
| 70 | + ChartCurve( |
| 71 | + speed_rpm=75.0, |
| 72 | + rate_actual_m3_hour=[q0 * 2, q0 * 8], |
| 73 | + polytropic_head_joule_per_kg=[150_000.0, 40_000.0], |
| 74 | + efficiency_fraction=[0.75, 0.75], |
| 75 | + ), |
| 76 | + ChartCurve( |
| 77 | + speed_rpm=105.0, |
| 78 | + rate_actual_m3_hour=[q0 * 2, q0 * 8], |
| 79 | + polytropic_head_joule_per_kg=[150_000.0 * 1.05, 40_000.0 * 1.05], |
| 80 | + efficiency_fraction=[0.75, 0.75], |
| 81 | + ), |
| 82 | + ], |
| 83 | + control_margin=0.0, |
| 84 | + ) |
| 85 | + |
| 86 | + from libecalc.domain.process.entities.shaft import VariableSpeedShaft |
| 87 | + |
| 88 | + shaft = VariableSpeedShaft() |
| 89 | + shaft.set_speed(75.0) |
| 90 | + |
| 91 | + units = stage_units_factory(chart_data=chart_data, shaft=shaft, temperature_kelvin=temperature) |
| 92 | + wrapped_units, loop_ids, compressors = with_individual_asv(units) |
| 93 | + runner = process_runner_factory(units=wrapped_units, shaft=shaft) |
| 94 | + |
| 95 | + strategy = individual_asv_pressure_control_strategy_factory( |
| 96 | + runner=runner, |
| 97 | + recirculation_loop_ids=loop_ids, |
| 98 | + compressors=compressors, |
| 99 | + ) |
| 100 | + |
| 101 | + target = FloatConstraint(target_pressure_bara) |
| 102 | + solution = strategy.apply(target_pressure=target, inlet_stream=inlet_stream) |
| 103 | + |
| 104 | + assert solution.success is True, ( |
| 105 | + f"Expected success=True for target={target_pressure_bara} bara, got False. " |
| 106 | + "This indicates IndividualASVPressureControlStrategy compared FluidStream == FloatConstraint " |
| 107 | + "(always False) instead of outlet_stream.pressure_bara == target_pressure." |
| 108 | + ) |
| 109 | + |
| 110 | + runner.apply_configurations(solution.configuration) |
| 111 | + outlet = runner.run(inlet_stream=inlet_stream) |
| 112 | + |
| 113 | + assert outlet.pressure_bara == pytest.approx( |
| 114 | + target_pressure_bara, rel=1e-3 |
| 115 | + ), f"Expected outlet {target_pressure_bara} bara but got {outlet.pressure_bara:.4f} bara." |
0 commit comments