diff --git a/.github/skills/neqsim-compressor-antisurge-recycle/SKILL.md b/.github/skills/neqsim-compressor-antisurge-recycle/SKILL.md index 15778d62ec5..52604889e8b 100644 --- a/.github/skills/neqsim-compressor-antisurge-recycle/SKILL.md +++ b/.github/skills/neqsim-compressor-antisurge-recycle/SKILL.md @@ -21,6 +21,24 @@ This skill provides patterns for anti-surge protection, minimum-speed recycle co Standards: **API 617 / API 619**, **IEC 60534 / ISA-75**, **ISO 50001 / ISO 14064** (energy & carbon emissions). +## Compressor pressure-target solves + +For `Compressor.setSolveSpeed(true)`, check `isSpeedSolveConverged()` after `run()` +before accepting an optimization point. Read actual pressure from the outlet +stream and the signed pressure error in bar from `getSpeedSolvePressureResidual()`. +`getOutletPressure()` retains the requested setpoint, including after failure. +The returned state uses the fixed-speed forward calculation, including anti-surge +flow changes; these can leave the requested pressure unmet. `setLimitSpeed(false)` +allows extrapolation, so pressure convergence does not establish mechanical +feasibility. Check speed and operating-envelope constraints separately. +`ProductionOptimizer` treats `Compressor.SpeedSolveException` as an infeasible +trial and continues searching without evaluating stale outputs; unrelated +exceptions propagate. Its hard `compressorSpeedSolve` constraint also rejects +returned unconverged results even with capacity analysis enabled. Keep this gate +when integrating chart-based compressor models into optimizers. +See [compressor curves](../../../docs/process/equipment/compressor_curves.md#speed-calculation-from-operating-point) +and `CompressorSpeedSolveConsistencyTest` for the tested contract. + ## Key Java Class: `MinimumSpeedRecycleControllerStructure` `neqsim.process.controllerdevice.structure.MinimumSpeedRecycleControllerStructure` coordinates pressure control between compressor speed and recycle-valve opening when the compressor reaches its minimum speed. diff --git a/docs/development/TASK_LOG.md b/docs/development/TASK_LOG.md index 6405ab46583..f1934014642 100644 --- a/docs/development/TASK_LOG.md +++ b/docs/development/TASK_LOG.md @@ -48,6 +48,12 @@ requirement`, or `confidential compressor route`. **Solution:** `LoopedPipeNetwork.calculateHeadLossCompressor`, `MappedCompressorAcceptanceTest`, and the SURF integration guide. **Notes:** A bounded chart speed search requested 100 bara and returned an internally consistent 11.647013866 bara forward state with `solved() == false`, while the network labelled it a valid chart result and retained 53.131953657 kW. Require the existing equipment convergence contract, an enabled chart, finite non-negative power, and positive finite outlet pressure/temperature before accepting mapped evidence. Reuse the existing infeasible-edge path to invalidate power and clear previous thermal state. Four regressions first establish a valid state, then exercise an unsolved result, chart disablement, non-finite power, and non-finite temperature. Keep the common network boundary in PR #4328 and the compressor's speed-search implementation in PR #4326; no system-specific solver or new constraint framework is introduced. +### 2026-10-14 — Preserve compressor convergence requirements during optimization +**Type:** E (Feature) +**Keywords:** compressor, speed solve, invalid chart, optimizer, infeasible trials, lift curves, CI, 4326 +**Solution:** `Compressor.SpeedSolveException`, `ProductionOptimizer` candidate rejection and hard convergence gate, and conditional chart-setup process runs in `FlowRateOptimizer`. +**Notes:** Reproduced all three failing optimizer cases on PR #4326 before repair. A no-valid-map trial is rejected without reading stale objectives or downstream constraints; unrelated exceptions still propagate. Capacity analysis and optional output validation cannot bypass the pressure-target convergence requirement. Repeated table setup reuses existing charts without rerunning an invalid preceding probe. Updated compressor/optimization guides, the compressor skill and the generated coverage inventory; regression tests cover stale outputs, hard convergence, recovery and chart reuse. + ### 2026-10-14 — Repair shared CI failures and preserve workflow diagnostics **Type:** G (Workflow) **Keywords:** PR 4315, hermetic tests, evidence-source-cues, slow shards, required checks, Surefire, CI diff --git a/docs/process/equipment/compressor_curves.md b/docs/process/equipment/compressor_curves.md index 67e55e7c17a..d790eed4694 100644 --- a/docs/process/equipment/compressor_curves.md +++ b/docs/process/equipment/compressor_curves.md @@ -1099,7 +1099,41 @@ accepted speed is retained once the pressure residual is within 0.001 bar. The reported speed, chart head, efficiency and power therefore describe the same iterate. Re-running an unchanged inlet and target does not take an additional Newton step after convergence, which could otherwise move a capacity-limited -operating point across its limit. Configured speed limits still apply. +operating point across its limit. + +The outlet stream is always calculated from the returned speed using the same +chart-forward calculation as fixed-speed operation, including guide-vane and +anti-surge effects. An unreachable pressure target must not be interpreted as a +successful operating point just because `run()` returns: + +- `isSpeedSolveConverged()` reports whether the final outlet pressure is within + 0.001 bar of the requested target. +- `getSpeedSolvePressureResidual()` returns achieved minus requested pressure in + bar, or `NaN` when no speed-solve result is available for the latest run. +- `solved()` and `isSimulationValid()` are false when an enabled chart speed solve + misses its target; `getSimulationValidationErrors()` explains the failure. +- Read achieved pressure from `getOutletStream().getPressure("bara")`. + `getOutletPressure()` retains the requested setpoint in its configured unit, + so repeated runs continue to solve the same target, including after failure. + +With `setLimitSpeed(true)`, the search is limited to the configured minimum and +maximum speeds. A result at a speed limit is still marked non-converged if it +does not deliver the target. With `setLimitSpeed(false)` (the default), speed +extrapolation remains allowed for compatibility. Pressure convergence alone +does not establish that speed, surge, stonewall or power constraints are met; +check those constraints separately before accepting an operating point. + +If the search does not converge, the solver uses its best finite evaluated speed +and reports the actual forward result with a warning. This is not proof that +the target is globally unreachable on a non-monotonic map. A search with no valid +chart operating point raises `Compressor.SpeedSolveException` (an +`IllegalStateException`) instead of publishing a target-pressure result. +`ProductionOptimizer` rejects this specific failed trial and continues searching; +unrelated calculation exceptions still propagate. Its `compressorSpeedSolve` +hard constraint also rejects an unmet target when capacity analysis is enabled or +optional output validation is disabled. It does not evaluate objectives or +downstream constraints on a failed trial's stale outlet state. Diagnostics reset +at the start of every run, including fixed-speed runs. When you need to determine the compressor speed required to achieve a specific operating point (flow and head), NeqSim provides a robust algorithm that works both within the defined curve range and with extrapolation beyond it. diff --git a/docs/process/optimization/flow-rate-optimization.md b/docs/process/optimization/flow-rate-optimization.md index a851e91cee9..5b636ce0535 100644 --- a/docs/process/optimization/flow-rate-optimization.md +++ b/docs/process/optimization/flow-rate-optimization.md @@ -31,6 +31,18 @@ This is essential for: ## Key Features +Compressor pressure-target failures are infeasible operating points. The shared +`ProductionOptimizer` handles `Compressor.SpeedSolveException` without aborting +the search and records a hard `compressorSpeedSolve` constraint. It also rejects +a returned compressor result whose pressure target did not converge, even when +capacity analysis is enabled. Objectives and downstream constraints are not read +from a failed calculation; unrelated exceptions remain visible to callers. + +Automatic chart setup reruns the process only when it generates a chart. This +avoids redundant process solves between capacity, lift-curve and performance +tables, including when a preceding sweep ended on an invalid chart trial. The +next table evaluates its own requested operating points. + | Feature | Description | |---------|-------------| | **Pressure boundary search** | Find max flow at given inlet/outlet pressures | diff --git a/src/main/java/neqsim/process/equipment/compressor/Compressor.java b/src/main/java/neqsim/process/equipment/compressor/Compressor.java index 2ffe045453c..7fedad60540 100644 --- a/src/main/java/neqsim/process/equipment/compressor/Compressor.java +++ b/src/main/java/neqsim/process/equipment/compressor/Compressor.java @@ -56,6 +56,29 @@ public class Compressor extends TwoPortEquipment /** Logger object for class. */ static Logger logger = LogManager.getLogger(Compressor.class); + /** + * Signals that a pressure-target search found no valid chart state for the current inlet. + * + *

+ * Optimizers may reject this operating point without hiding unrelated calculation errors. + *

+ * + * @author Even Solbraa + * @version 1.0 + */ + public static class SpeedSolveException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + /** + * Creates a failed chart-search diagnostic. + * + * @param compressorName name of the compressor whose search failed + */ + public SpeedSolveException(String compressorName) { + super("Speed solver found no finite chart operating point for " + compressorName); + } + } + /** * Calculation strategy used when the compressor participates in a dynamic process. */ @@ -102,6 +125,12 @@ public enum TransientCalculationMode { public double dH = 0.0; public double inletEnthalpy = 0; private boolean solveSpeed = false; + /** Whether the latest speed solve delivered its requested pressure. */ + private boolean speedSolveConverged = false; + /** Achieved minus requested pressure in bar for the latest speed solve. */ + private double speedSolvePressureResidual = Double.NaN; + /** Absolute pressure tolerance for the speed solver, in bar. */ + private static final double SPEED_SOLVE_PRESSURE_TOLERANCE = 1.0e-3; public double pressure = 0.0; private double speed = 3000; private double maxspeed = 30000; @@ -140,6 +169,14 @@ public enum TransientCalculationMode { private double lastInletFlowRate = Double.NaN; private double[] lastInletComposition = null; private double lastOutletPressure = Double.NaN; + /** Unit of the cached pressure setpoint. */ + private String lastPressureUnit = null; + /** Minimum speed used by the cached speed solve. */ + private double lastMinimumSpeed = Double.NaN; + /** Maximum speed used by the cached speed solve. */ + private double lastMaximumSpeed = Double.NaN; + /** Whether speed bounds were enforced by the cached speed solve. */ + private boolean lastLimitSpeed = false; private double lastSpeed = Double.NaN; private double lastCompressionRatio = Double.NaN; private double lastOutTemperature = Double.NaN; @@ -720,12 +757,14 @@ public boolean needRecalculation() { } SystemInterface inletSystem = inStream.getThermoSystem(); if (inletSystem.getTemperature() != lastInletTemperature || inletSystem.getPressure() != lastInletPressure - || pressure != lastOutletPressure || speed != lastSpeed || compressionRatio != lastCompressionRatio - || outTemperature != lastOutTemperature || isentropicEfficiency != lastIsentropicEfficiency - || polytropicEfficiency != lastPolytropicEfficiency || dH != lastPower - || numberOfCompressorCalcSteps != lastNumberOfCompressorCalcSteps || useOutTemperature != lastUseOutTemperature - || useCompressionRatio != lastUseCompressionRatio || usePolytropicCalc != lastUsePolytropicCalc - || powerSet != lastPowerSet || calcPressureOut != lastCalcPressureOut || solveSpeed != lastSolveSpeed + || pressure != lastOutletPressure || !java.util.Objects.equals(pressureUnit, lastPressureUnit) + || minspeed != lastMinimumSpeed || maxspeed != lastMaximumSpeed || limitSpeed != lastLimitSpeed + || speed != lastSpeed || compressionRatio != lastCompressionRatio || outTemperature != lastOutTemperature + || isentropicEfficiency != lastIsentropicEfficiency || polytropicEfficiency != lastPolytropicEfficiency + || dH != lastPower || numberOfCompressorCalcSteps != lastNumberOfCompressorCalcSteps + || useOutTemperature != lastUseOutTemperature || useCompressionRatio != lastUseCompressionRatio + || usePolytropicCalc != lastUsePolytropicCalc || powerSet != lastPowerSet + || calcPressureOut != lastCalcPressureOut || solveSpeed != lastSolveSpeed || compressorChart.isUseCompressorChart() != lastUseCompressorChart || useEnergyEfficiencyChart != lastUseEnergyEfficiencyChart || useRigorousPolytropicMethod != lastUseRigorousPolytropicMethod || useGERG2008 != lastUseGERG2008 @@ -760,6 +799,10 @@ private void updateRecalculationState() { lastInletFlowRate = inletSystem.getFlowRate("kg/hr"); lastInletComposition = inletSystem.getMolarComposition(); lastOutletPressure = pressure; + lastPressureUnit = pressureUnit; + lastMinimumSpeed = minspeed; + lastMaximumSpeed = maxspeed; + lastLimitSpeed = limitSpeed; lastSpeed = speed; lastCompressionRatio = compressionRatio; lastOutTemperature = outTemperature; @@ -1022,6 +1065,8 @@ private double getEntropyFlashTolerance(double targetEntropy) { /** {@inheritDoc} */ @Override public void run(UUID id) { + speedSolveConverged = false; + speedSolvePressureResidual = Double.NaN; thermoSystem = inStream.getThermoSystem().clone(); sanitizeEfficiencyStateForRun(); @@ -1259,8 +1304,11 @@ public void run(UUID id) { } if (compressorChart.isUseCompressorChart()) { if (solveSpeed) { - double targetPressure = getOutletPressure(); // Desired outlet pressure - double tolerance = 1e-3; // Tolerance for pressure difference (0.001 bar) + double targetPressure = new neqsim.util.unit.PressureUnit(pressure, pressureUnit).getValue("bara"); + if (!Double.isFinite(targetPressure) || targetPressure <= 0.0) { + throw new IllegalArgumentException("Speed-solve target pressure must be finite and positive"); + } + double tolerance = SPEED_SOLVE_PRESSURE_TOLERANCE; double minSpeed = getMinimumSpeed(); // Minimum speed for the compressor double maxSpeed = getMaximumSpeed(); // Maximum speed for the compressor double currentSpeed = getSpeed(); // Initial guess for speed @@ -1274,6 +1322,11 @@ public void run(UUID id) { if (currentSpeed < chartMinSpeed * 0.5 || currentSpeed > chartMaxSpeed * 2.0) { currentSpeed = chartRefSpeed; } + if (limitSpeed) { + currentSpeed = Math.max(minSpeed, Math.min(maxSpeed, currentSpeed)); + } + double bestSpeed = Double.NaN; + double bestPressureError = Double.POSITIVE_INFINITY; double maxIterations = 200; // Maximum number of iterations double deltaSpeed = 100.0; // Small increment for numerical derivative @@ -1311,24 +1364,21 @@ public void run(UUID id) { z_inlet = VegaProps[1]; } - double polytropEff = getCompressorChart().getPolytropicEfficiency(actualFlowRate, currentSpeed); - setPolytropicEfficiency(polytropEff / 100.0); - if (polytropEff <= 0.0) { - polytropEff = 0.01; - setPolytropicEfficiency(0.01); - } - if (polytropEff > 100.0) { - polytropEff = 100; - setPolytropicEfficiency(100.0); + double polytropEff = applyInletGuideVaneEfficiency( + getCompressorChart().getPolytropicEfficiency(actualFlowRate, currentSpeed)); + if (!Double.isFinite(polytropEff) || polytropEff <= 0.0 || polytropEff > 100.0) { + break; } + setPolytropicEfficiency(polytropEff / 100.0); - polytropicHead = getCompressorChart().getPolytropicHead(actualFlowRate, currentSpeed); + polytropicHead = applyInletGuideVaneHead( + getCompressorChart().getPolytropicHead(actualFlowRate, currentSpeed)); double temperature_inlet = thermoSystem.getTemperature(); double n = 1.0 / (1.0 - (kappa - 1.0) / kappa * 1.0 / (polytropEff / 100.0)); polytropicFluidHead = (getCompressorChart().getHeadUnit().equals("meter")) ? polytropicHead / 1000.0 * 9.81 : polytropicHead; - if (polytropicFluidHead <= 0.0) { - polytropicFluidHead = 0.0001; + if (!Double.isFinite(polytropicFluidHead) || polytropicFluidHead < 0.0) { + break; } double pressureRatio = Math.pow( (polytropicFluidHead * 1000.0 @@ -1336,12 +1386,25 @@ public void run(UUID id) { / (n / (n - 1.0) * z_inlet * ThermodynamicConstantsInterface.R * (temperature_inlet) / MW), n / (n - 1.0)); double currentPressure = thermoSystem.getPressure() * pressureRatio; + if (!Double.isFinite(currentPressure) || currentPressure <= 0.0) { + break; + } + double pressureError = Math.abs(currentPressure - targetPressure); + if (pressureError < bestPressureError) { + bestPressureError = pressureError; + bestSpeed = currentSpeed; + } + + // Retain an accepted root before attempting another derivative or speed update. + if (pressureError <= tolerance) { + break; + } // Calculate the derivative of pressure with respect to speed - double polytropEffDelta = getCompressorChart().getPolytropicEfficiency(actualFlowRate, - currentSpeed + deltaSpeed); - double polytropicHeadDelta = getCompressorChart().getPolytropicHead(actualFlowRate, - currentSpeed + deltaSpeed); + double polytropEffDelta = applyInletGuideVaneEfficiency( + getCompressorChart().getPolytropicEfficiency(actualFlowRate, currentSpeed + deltaSpeed)); + double polytropicHeadDelta = applyInletGuideVaneHead( + getCompressorChart().getPolytropicHead(actualFlowRate, currentSpeed + deltaSpeed)); double nDelta = 1.0 / (1.0 - (kappa - 1.0) / kappa * 1.0 / (polytropEffDelta / 100.0)); double polytropicFluidHeadDelta = (getCompressorChart().getHeadUnit().equals("meter")) ? polytropicHeadDelta / 1000.0 * 9.81 @@ -1354,6 +1417,9 @@ public void run(UUID id) { double pressureNew = thermoSystem.getPressure() * pressureRatioDelta; double dPressure_dSpeed = (pressureNew - currentPressure) / deltaSpeed; + if (!Double.isFinite(dPressure_dSpeed)) { + break; + } // Handle case where derivative is too small or wrong sign if (Math.abs(dPressure_dSpeed) < 1e-6) { @@ -1376,11 +1442,7 @@ public void run(UUID id) { speedUpdate = Math.signum(speedUpdate) * maxSpeedUpdate; } - // Head, efficiency and power below describe currentSpeed. Do not move an already accepted root: doing so - // reports a different speed and lets repeated runs drift across an equipment capacity boundary. - if (Math.abs(currentPressure - targetPressure) > tolerance) { - currentSpeed += relaxationFactor * speedUpdate; - } + currentSpeed += relaxationFactor * speedUpdate; if (currentSpeed < 0) { if (minSpeed > 1) { currentSpeed = minSpeed; @@ -1392,79 +1454,24 @@ public void run(UUID id) { deltaSpeed = deltaSpeed / 2; } - powerSet = true; - dH = polytropicFluidHead * 1000.0 * thermoSystem.getMolarMass() / getPolytropicEfficiency() - * thermoSystem.getTotalNumberOfMoles(); // Check if speed is within bounds if (currentSpeed < minSpeed || currentSpeed > maxSpeed) { if (limitSpeed) { - setSolveSpeed(false); - setCalcPressureOut(true); - if (currentSpeed > maxSpeed) { - setSpeed(maxSpeed); - } else if (currentSpeed < minSpeed) { - setSpeed(minSpeed); - } - run(); - setSolveSpeed(true); - setCalcPressureOut(false); - getThermoSystem().setMultiPhaseCheck(originalMultiPhaseCheck); + setSpeed(Math.max(minSpeed, Math.min(maxSpeed, currentSpeed))); + runAtSolvedSpeed(id, targetPressure); return; - } else { - // throw new IllegalArgumentException( - // "Speed out of bounds during Newton-Raphson iteration."); } - // throw new IllegalArgumentException( - // "Speed out of bounds during Newton-Raphson iteration."); - } - - // Check for convergence - if (Math.abs(currentPressure - targetPressure) <= tolerance) { - setSpeed(currentSpeed); // Update the final speed - break; } iteration++; } - if (iteration == maxIterations) { - // Did not converge, but use the best estimate and log a warning - logger.warn("Newton-Raphson speed solver did not fully converge after " + maxIterations - + " iterations. Using best estimate speed: " + currentSpeed); - setSpeed(currentSpeed); - } - - // Anti-surge check after speed solver convergence - if (getAntiSurge().isActive()) { - double actualFlowRate = thermoSystem.getFlowRate("m3/hr"); - double z_inlet = thermoSystem.getZ(); - if (useGERG2008 && inStream.getThermoSystem().getNumberOfPhases() == 1) { - double[] gergProps = getThermoSystem().getPhase(0).getProperties_GERG2008(); - actualFlowRate *= gergProps[1] / z_inlet; - } - if (useLeachman && inStream.getThermoSystem().getNumberOfPhases() == 1) { - double[] leachmanProps = getThermoSystem().getPhase(0).getProperties_Leachman(); - actualFlowRate *= leachmanProps[1] / z_inlet; - } - if (useVega && inStream.getThermoSystem().getNumberOfPhases() == 1) { - double[] vegaProps = getThermoSystem().getPhase(0).getProperties_Vega(); - actualFlowRate *= vegaProps[1] / z_inlet; - } - surgeCheck = isSurge(polytropicHead, actualFlowRate); - if (surgeCheck) { - logger.info("Anti-surge activated in speed-solve mode. Surge flow: " - + getCompressorChart().getSurgeCurve().getSurgeFlow(polytropicFluidHead) + " Am3/hr, actual flow: " - + actualFlowRate + " m3/hr"); - thermoSystem.setTotalFlowRate(getAntiSurge().getSurgeControlFactor() - * getCompressorChart().getSurgeCurve().getSurgeFlow(polytropicFluidHead), "Am3/hr"); - thermoSystem.init(3); - fractionAntiSurge = thermoSystem.getTotalNumberOfMoles() / orginalMolarFLow - 1.0; - getAntiSurge().setCurrentSurgeFraction(fractionAntiSurge); - // Recompute dH with the updated flow - dH = polytropicFluidHead * 1000.0 * thermoSystem.getMolarMass() / getPolytropicEfficiency() - * thermoSystem.getTotalNumberOfMoles(); - } + if (!Double.isFinite(bestSpeed)) { + throw new SpeedSolveException(getName()); } + setSpeed(bestSpeed); + runAtSolvedSpeed(id, targetPressure); + return; } else { do { double actualFlowRate = thermoSystem.getFlowRate("m3/hr"); @@ -1807,6 +1814,41 @@ public void run(UUID id) { evaluateCapacityConstraints(); } + /** + * Evaluate the selected speed through the normal fixed-speed path, preserving the requested setpoint and modes. This + * keeps pressure, temperature, head and power consistent even when the search misses its target. + * + * @param id calculation identifier shared with the calling run + * @param targetPressure requested absolute pressure in bara + */ + private void runAtSolvedSpeed(UUID id, double targetPressure) { + double requestedPressure = pressure; + String requestedPressureUnit = pressureUnit; + boolean requestedCalcPressureOut = calcPressureOut; + try { + solveSpeed = false; + calcPressureOut = false; + // The chart-forward pressure calculation uses bara internally. + pressureUnit = "bara"; + pressure = targetPressure; + run(id); + speedSolvePressureResidual = outStream.getPressure("bara") - targetPressure; + speedSolveConverged = Double.isFinite(speedSolvePressureResidual) + && Math.abs(speedSolvePressureResidual) <= SPEED_SOLVE_PRESSURE_TOLERANCE && Double.isFinite(dH) + && Double.isFinite(outStream.getTemperature()); + if (!speedSolveConverged) { + logger.warn("Compressor {} speed solve missed target {} bara: achieved {} bara at {} rpm (residual {} bar)", + getName(), targetPressure, outStream.getPressure("bara"), speed, speedSolvePressureResidual); + } + } finally { + solveSpeed = true; + calcPressureOut = requestedCalcPressureOut; + pressure = requestedPressure; + pressureUnit = requestedPressureUnit; + } + updateRecalculationState(); + } + private boolean useEnergyEfficiencyChart() { return useEnergyEfficiencyChart; } @@ -4134,6 +4176,32 @@ public void setSolveSpeed(boolean solveSpeed) { this.solveSpeed = solveSpeed; } + /** + * Check whether the latest speed solve delivered the requested pressure within 0.001 bar. This does not assert + * mechanical or operating-envelope feasibility. Returns false before a speed solve or after any other run mode. When + * speed limiting is disabled, pressure convergence can occur outside the speed limits. + * + * @return true only for a finite final speed-solve result within pressure tolerance + */ + public boolean isSpeedSolveConverged() { + return speedSolveConverged; + } + + /** + * Get achieved minus requested outlet pressure for the latest speed solve. + * + * @return signed pressure residual in bar, or NaN when the latest run produced no speed-solve result + */ + public double getSpeedSolvePressureResidual() { + return speedSolvePressureResidual; + } + + /** {@inheritDoc} */ + @Override + public boolean solved() { + return super.solved() && (!solveSpeed || !compressorChart.isUseCompressorChart() || speedSolveConverged); + } + /** * isCalcPressureOut. * @@ -4162,7 +4230,9 @@ public boolean isLimitSpeed() { } /** - * Sets whether the compressor speed should be limited. + * Sets whether the pressure-target speed search should enforce the configured speed bounds. When false, extrapolation + * beyond the bounds is allowed for compatibility. Pressure convergence does not imply that the resulting speed is + * mechanically feasible. * * @param limitSpeed {@code true} to limit the compressor speed, {@code false} otherwise. */ @@ -5670,6 +5740,7 @@ public double getMaxUtilization() { *
  • Outlet temperature greater than inlet (compression heats gas)
  • *
  • Pressure ratio greater than 1.0
  • *
  • Non-NaN values for key properties
  • + *
  • Pressure-target convergence when chart speed solving is enabled
  • *
  • Speed within compressor chart limits (if chart is active)
  • *
  • Operating point not in surge region (if surge curve is defined)
  • *
  • Operating point not beyond stonewall/choke (if stonewall curve is defined)
  • @@ -5677,6 +5748,9 @@ public double getMaxUtilization() { */ @Override public boolean isSimulationValid() { + if (solveSpeed && compressorChart.isUseCompressorChart() && !speedSolveConverged) { + return false; + } // Check basic thermodynamic system if (thermoSystem == null) { return false; @@ -5779,6 +5853,10 @@ public boolean isSimulationValid() { @Override public List getSimulationValidationErrors() { List errors = new ArrayList(); + if (solveSpeed && compressorChart.isUseCompressorChart() && !speedSolveConverged) { + errors.add(getName() + ": Speed solve did not converge to the requested outlet pressure (residual " + + speedSolvePressureResidual + " bar)"); + } if (thermoSystem == null) { errors.add(getName() + ": No thermodynamic system - simulation not run"); diff --git a/src/main/java/neqsim/process/util/optimizer/FlowRateOptimizer.java b/src/main/java/neqsim/process/util/optimizer/FlowRateOptimizer.java index 3f14140f50c..ba56faa0d8a 100644 --- a/src/main/java/neqsim/process/util/optimizer/FlowRateOptimizer.java +++ b/src/main/java/neqsim/process/util/optimizer/FlowRateOptimizer.java @@ -739,15 +739,18 @@ public void configureProcessCompressorCharts() { } List compressors = getProcessCompressors(); + boolean chartsChanged = false; for (Compressor comp : compressors) { - configureCompressorChart(comp); + chartsChanged |= configureCompressorChart(comp); } - - // Re-run the process after chart setup - if (mode == Mode.PROCESS_SYSTEM) { - processSystem.run(); - } else { - processModel.run(); + // Existing charts remain valid after a sweep. Its last rejected probe need not be runnable, + // and rerunning it here would prevent the next table from evaluating its own operating points. + if (chartsChanged) { + if (mode == Mode.PROCESS_SYSTEM) { + processSystem.run(); + } else { + processModel.run(); + } } // Reinitialize constraints after chart setup @@ -760,8 +763,9 @@ public void configureProcessCompressorCharts() { * Configures a single compressor with a performance chart. * * @param comp the compressor to configure + * @return true when a chart was generated */ - private void configureCompressorChart(Compressor comp) { + private boolean configureCompressorChart(Compressor comp) { CompressorChartInterface chart = comp.getCompressorChart(); // Check if chart needs to be generated @@ -787,7 +791,9 @@ private void configureCompressorChart(Compressor comp) { logger.info("Chart generated for {} with {} speed lines, max speed set to {} RPM", comp.getName(), numberOfChartSpeeds, maxSpeed); + return true; } + return false; } /** diff --git a/src/main/java/neqsim/process/util/optimizer/ProductionOptimizer.java b/src/main/java/neqsim/process/util/optimizer/ProductionOptimizer.java index ce561fad7c4..5b256570ed2 100644 --- a/src/main/java/neqsim/process/util/optimizer/ProductionOptimizer.java +++ b/src/main/java/neqsim/process/util/optimizer/ProductionOptimizer.java @@ -18,6 +18,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import neqsim.process.equipment.ProcessEquipmentInterface; +import neqsim.process.equipment.compressor.Compressor; import neqsim.process.equipment.distillation.DistillationColumn; import neqsim.process.equipment.pipeline.PipeBeggsAndBrills; import neqsim.process.equipment.stream.StreamInterface; @@ -1092,7 +1093,8 @@ public OptimizationConfig selectedPointReplays(int replays) { * *

    * When enabled (default), the optimizer will mark operating points as infeasible if any equipment reports invalid - * simulation results (e.g., negative power in compressors, NaN values, etc.). + * simulation results (e.g., negative power in compressors, NaN values, etc.). Failed compressor pressure-target + * solves are always rejected, independently of this optional validation. *

    * * @param reject true to reject invalid simulations @@ -3370,6 +3372,16 @@ private Evaluation evaluateProcess(ProcessSystem process, OptimizationConfig con boolean simulationValid = true; List validationErrors = new ArrayList<>(); for (ProcessEquipmentInterface unit : process.getUnitOperations()) { + if (unit instanceof Compressor) { + Compressor compressor = (Compressor) unit; + if (compressor.isSolveSpeed() && compressor.getCompressorChart().isUseCompressorChart() + && !compressor.isSpeedSolveConverged()) { + // A missed pressure target is a failed solve, even when capacity analysis is enabled or optional + // output validation is disabled. Envelope constraints cannot certify an unconverged calculation. + return failedSpeedSolveEvaluation(decisionVariables, + "Speed solve did not converge for " + compressor.getName()); + } + } if (!unit.isSimulationValid()) { // If equipment has capacity constraints enabled, let the constraint system // handle envelope violations through high utilization rather than rejecting @@ -4085,10 +4097,38 @@ private Evaluation evaluateCandidateInternal(ProcessSystem process, List decisions, String diagnostic) { + ConstraintStatus status = new ConstraintStatus("compressorSpeedSolve", ConstraintSeverity.HARD, -1.0, 0.0, + diagnostic); + return new Evaluation(9.99, null, Collections.emptyList(), Collections.singletonList(status), + Collections.emptyMap(), decisions, false, false, Double.NEGATIVE_INFINITY); + } + private String buildVectorCacheKey(double[] candidate) { // A convergence tolerance is not an evidence-equivalence tolerance. Nearby points can straddle a hard limit, // and each cached evaluation retains the exact decisions that produced it. diff --git a/src/main/resources/neqsim/mcp/engineering-coverage.json b/src/main/resources/neqsim/mcp/engineering-coverage.json index c35f6c84261..37036214278 100644 --- a/src/main/resources/neqsim/mcp/engineering-coverage.json +++ b/src/main/resources/neqsim/mcp/engineering-coverage.json @@ -2403,7 +2403,7 @@ {"id":"neqsim.process.equipment.compressor.BoundaryCurve","kind":"class","source":"src/main/java/neqsim/process/equipment/compressor/BoundaryCurve.java","sourceDigest":"0727c7d3b435ba59d443af8b21acad1e77aaee3cd17c1e1d06da4c6ad8f62f4e","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.equipment.compressor.BoundaryCurveInterface","kind":"interface","source":"src/main/java/neqsim/process/equipment/compressor/BoundaryCurveInterface.java","sourceDigest":"135b4afddd9df72f762fbc3c32c175e172bb59a7ad67a7a1df68b4dc3deaf09d","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.equipment.compressor.CompressionTrainAssessment","kind":"class","source":"src/main/java/neqsim/process/equipment/compressor/CompressionTrainAssessment.java","sourceDigest":"f28943d6e13d41e863c38733f522bfeba7bad5e5d451ef5fc1bdf81427d00a9e","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, - {"id":"neqsim.process.equipment.compressor.Compressor","kind":"class","source":"src/main/java/neqsim/process/equipment/compressor/Compressor.java","sourceDigest":"6b4344b142b0acd47b13702d4c09df8e31bb838d9229d6cec1ff410859e65da1","domain":"process","skills":["neqsim-advanced-control-mpc-and-virtual-sensing","neqsim-agentic-process-optimization","neqsim-api-patterns","neqsim-autonomous-investigation","neqsim-capability-map","neqsim-capacity-and-utilization-analysis","neqsim-capacity-increase-screening","neqsim-ccs-hydrogen","neqsim-compressor-antisurge-recycle","neqsim-controllability-operability","neqsim-dynamic-equipment-implementation","neqsim-dynamic-simulation","neqsim-hazid-fmea-eta-fta","neqsim-input-validation","neqsim-model-calibration-and-data-reconciliation","neqsim-notebook-patterns","neqsim-operational-risk-and-safety-validation","neqsim-optimization-and-doe","neqsim-physics-explanations","neqsim-pid-process-operations","neqsim-pipeline-and-terminal-networks","neqsim-plant-data","neqsim-platform-modeling","neqsim-power-generation","neqsim-process-extraction","neqsim-process-modeling","neqsim-process-safety","neqsim-production-optimization","neqsim-regression-baselines","neqsim-root-cause-analysis","neqsim-rotating-equipment-design","neqsim-standards-lookup","neqsim-stid-retriever","neqsim-task-workflow","neqsim-technical-document-reading","neqsim-troubleshooting","neqsim-unisim-reader","neqsim-utility-design"],"agents":["capability-scout","capacity-increase-screening","ccs-hydrogen","consequence-analysis","continuous-improvement","control-system","documentation","dynamic-equipment","emissions-environmental","engineering-deliverables","extract-process","field-development","flow-assurance","gas-processing-refining","gas-quality","literature-scout","lng-value-chain","mechanical-design","neqsim-test","network-energy-systems","notebook-example","optimize","optimize-processmodel","plant-data","process-model","production-chemistry","pvt-simulation","reaction-engineering","root-cause","rotating-equipment","safety-depressuring","solve-process","solve-task","standards-review","technical-reader","thermo-fluid","unisim-reader","utility-design"],"capabilities":["process-compression","equipment-sizing"],"disposition":"registered_anchor"}, + {"id":"neqsim.process.equipment.compressor.Compressor","kind":"class","source":"src/main/java/neqsim/process/equipment/compressor/Compressor.java","sourceDigest":"494b76dd39a7652fd848a43b37f6c4d582c3d219bfb13c43035362e205d41b76","domain":"process","skills":["neqsim-advanced-control-mpc-and-virtual-sensing","neqsim-agentic-process-optimization","neqsim-api-patterns","neqsim-autonomous-investigation","neqsim-capability-map","neqsim-capacity-and-utilization-analysis","neqsim-capacity-increase-screening","neqsim-ccs-hydrogen","neqsim-compressor-antisurge-recycle","neqsim-controllability-operability","neqsim-dynamic-equipment-implementation","neqsim-dynamic-simulation","neqsim-hazid-fmea-eta-fta","neqsim-input-validation","neqsim-model-calibration-and-data-reconciliation","neqsim-notebook-patterns","neqsim-operational-risk-and-safety-validation","neqsim-optimization-and-doe","neqsim-physics-explanations","neqsim-pid-process-operations","neqsim-pipeline-and-terminal-networks","neqsim-plant-data","neqsim-platform-modeling","neqsim-power-generation","neqsim-process-extraction","neqsim-process-modeling","neqsim-process-safety","neqsim-production-optimization","neqsim-regression-baselines","neqsim-root-cause-analysis","neqsim-rotating-equipment-design","neqsim-standards-lookup","neqsim-stid-retriever","neqsim-task-workflow","neqsim-technical-document-reading","neqsim-troubleshooting","neqsim-unisim-reader","neqsim-utility-design"],"agents":["capability-scout","capacity-increase-screening","ccs-hydrogen","consequence-analysis","continuous-improvement","control-system","documentation","dynamic-equipment","emissions-environmental","engineering-deliverables","extract-process","field-development","flow-assurance","gas-processing-refining","gas-quality","literature-scout","lng-value-chain","mechanical-design","neqsim-test","network-energy-systems","notebook-example","optimize","optimize-processmodel","plant-data","process-model","production-chemistry","pvt-simulation","reaction-engineering","root-cause","rotating-equipment","safety-depressuring","solve-process","solve-task","standards-review","technical-reader","thermo-fluid","unisim-reader","utility-design"],"capabilities":["process-compression","equipment-sizing"],"disposition":"registered_anchor"}, {"id":"neqsim.process.equipment.compressor.CompressorAntiSurgeApplication","kind":"class","source":"src/main/java/neqsim/process/equipment/compressor/CompressorAntiSurgeApplication.java","sourceDigest":"5aa397c2c4fd56e0511650727a3e7fe28f2cda6b6d5bea5deaa25b1298192186","domain":"process","skills":["neqsim-dynamic-simulation","neqsim-process-modeling"],"agents":["capability-scout","capacity-increase-screening","control-system","dynamic-equipment","engineering-deliverables","gas-processing-refining","lng-value-chain","mechanical-design","neqsim-test","network-energy-systems","notebook-example","process-model","rotating-equipment","solve-process","solve-task","unisim-reader"],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.equipment.compressor.CompressorCatalog","kind":"class","source":"src/main/java/neqsim/process/equipment/compressor/CompressorCatalog.java","sourceDigest":"26260299b788fa7cb5ddb61e2cab0c593fe3d36f842be46896d73084188c8791","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.equipment.compressor.CompressorCatalogEntry","kind":"class","source":"src/main/java/neqsim/process/equipment/compressor/CompressorCatalogEntry.java","sourceDigest":"069d425795887c2b7af82ab4388a6132244bef34a1b95fe3ed3a5e97c00b416f","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, @@ -4055,7 +4055,7 @@ {"id":"neqsim.process.util.optimizer.EclipseVFPExporter","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/EclipseVFPExporter.java","sourceDigest":"65f2878ffb1b401471bf26c3c9253941fc4fa30d50784b76cec8c0993282c843","domain":"process","skills":["neqsim-optimization-and-doe","neqsim-production-optimization"],"agents":["capacity-increase-screening","field-development","optimize","optimize-processmodel"],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.EquipmentCapacityConstraintResolver","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/EquipmentCapacityConstraintResolver.java","sourceDigest":"5f006f0b472c6e0f579bf68b7ef42bfa3d46e55b71677b4814f30d5760f9176a","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.FlowRateOptimizationResult","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/FlowRateOptimizationResult.java","sourceDigest":"f75b7b79dcf04d55d70790be1c73afd69279ef4a5c1712c9ee3cecbbc1d38766","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, - {"id":"neqsim.process.util.optimizer.FlowRateOptimizer","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/FlowRateOptimizer.java","sourceDigest":"a669f4c5dff6e175d1a47522c82f4f87ea3e271aa98446cfb0c22efa52a79fb7","domain":"process","skills":["neqsim-optimization-and-doe","neqsim-production-optimization"],"agents":["capacity-increase-screening","field-development","optimize","optimize-processmodel"],"capabilities":[],"disposition":"review_required"}, + {"id":"neqsim.process.util.optimizer.FlowRateOptimizer","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/FlowRateOptimizer.java","sourceDigest":"8e7b47bd473bae55cd55099019f98f78966dca699caffb2a7d08e6baf7fba9cc","domain":"process","skills":["neqsim-optimization-and-doe","neqsim-production-optimization"],"agents":["capacity-increase-screening","field-development","optimize","optimize-processmodel"],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.FluidMagicInput","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/FluidMagicInput.java","sourceDigest":"5371bf7e97feef00613b31110974a23d49335298d7f261f6027082cc6d269df7","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.InstalledCapacityTableLoader","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/InstalledCapacityTableLoader.java","sourceDigest":"5101262d5be8368204476a7403969ed7f3634780faa34f87f864ebe4a1cda011","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.InstalledEquipmentCapacityEvidence","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/InstalledEquipmentCapacityEvidence.java","sourceDigest":"24d1dc5d4d22b5243491c16a21e4bfd8b58dd83818c9c046ebfcd99f51fc2c26","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, @@ -4103,7 +4103,7 @@ {"id":"neqsim.process.util.optimizer.ProductionImpactAnalyzer","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProductionImpactAnalyzer.java","sourceDigest":"3561a9106dcdc41ee3cd670cf8cc1fb536afeba5219687a8d363806b5b931b85","domain":"process","skills":["neqsim-optimization-and-doe"],"agents":["capacity-increase-screening","optimize","optimize-processmodel"],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.ProductionImpactResult","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProductionImpactResult.java","sourceDigest":"ba42639101f15c7a96975b56ed21c16d2f1ea9b2ccfc50666c92869c0faab49a","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.ProductionOptimizationSpecLoader","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProductionOptimizationSpecLoader.java","sourceDigest":"b33713b2e355cdbb37bdbf4ac58e99270eae9bb34a5d63ca782b4e5cb4173702","domain":"process","skills":["neqsim-optimization-and-doe"],"agents":["capacity-increase-screening","optimize","optimize-processmodel"],"capabilities":[],"disposition":"review_required"}, - {"id":"neqsim.process.util.optimizer.ProductionOptimizer","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProductionOptimizer.java","sourceDigest":"1409441c09e025f7d402987d9f6c397a39c376f63761ea7c2cd2225d92af0de9","domain":"process","skills":["neqsim-capacity-and-utilization-analysis","neqsim-optimization-and-doe"],"agents":["capacity-increase-screening","network-energy-systems","optimize","optimize-processmodel"],"capabilities":[],"disposition":"review_required"}, + {"id":"neqsim.process.util.optimizer.ProductionOptimizer","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProductionOptimizer.java","sourceDigest":"faba52e13ac98e74a74d5931ba9b1e228eeaf84f3846f6d486351a136695788c","domain":"process","skills":["neqsim-capacity-and-utilization-analysis","neqsim-compressor-antisurge-recycle","neqsim-optimization-and-doe"],"agents":["capacity-increase-screening","network-energy-systems","optimize","optimize-processmodel","rotating-equipment"],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.RecombinationFlashGenerator","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/RecombinationFlashGenerator.java","sourceDigest":"2d73d45843e79399846948144ae60c6d41bf4279a8371b195315bd8239a4de8e","domain":"process","skills":["neqsim-production-optimization"],"agents":["field-development"],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.SQPoptimizer","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/SQPoptimizer.java","sourceDigest":"b22c69868b5eeb0b5fa24b689cf618dfec3790ba4feecb0b3f65b7f100b1f0e6","domain":"process","skills":["neqsim-agentic-process-optimization","neqsim-capacity-and-utilization-analysis","neqsim-optimization-and-doe"],"agents":["capacity-increase-screening","continuous-improvement","network-energy-systems","optimize","optimize-processmodel"],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.SensitivityAnalysis","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/SensitivityAnalysis.java","sourceDigest":"379d98d0c6dfbb4c9d401a198d13f3555c4c90f0bf89819dfd47bff389da9099","domain":"process","skills":["neqsim-optimization-and-doe"],"agents":["capacity-increase-screening","optimize","optimize-processmodel"],"capabilities":[],"disposition":"review_required"}, diff --git a/src/test/java/neqsim/process/equipment/compressor/CompressorChartTest.java b/src/test/java/neqsim/process/equipment/compressor/CompressorChartTest.java index 70f7eee36e6..c24413a59d5 100644 --- a/src/test/java/neqsim/process/equipment/compressor/CompressorChartTest.java +++ b/src/test/java/neqsim/process/equipment/compressor/CompressorChartTest.java @@ -465,8 +465,10 @@ public void runCurveTestMaxMinSpeed() { comp1.setSpeed(15000); comp1.setLimitSpeed(false); comp1.run(); - Assertions.assertEquals(106.16307446, comp1.getOutletStream().getPressure("bara"), 0.1); - Assertions.assertEquals(7000, comp1.getSpeed(), 0.1); + // The limited run must retain the requested 100 bara target for the next solve. + Assertions.assertEquals(100.0, comp1.getOutletStream().getPressure("bara"), 0.001); + Assertions.assertTrue(comp1.getSpeed() < comp1.getMinimumSpeed()); + Assertions.assertTrue(comp1.isSpeedSolveConverged()); comp1.setOutletPressure(90); comp1.setMinimumSpeed(0); diff --git a/src/test/java/neqsim/process/equipment/compressor/CompressorSpeedSolveConsistencyTest.java b/src/test/java/neqsim/process/equipment/compressor/CompressorSpeedSolveConsistencyTest.java new file mode 100644 index 00000000000..25771c57316 --- /dev/null +++ b/src/test/java/neqsim/process/equipment/compressor/CompressorSpeedSolveConsistencyTest.java @@ -0,0 +1,285 @@ +package neqsim.process.equipment.compressor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import neqsim.NeqSimTest; +import neqsim.process.equipment.stream.Stream; +import neqsim.thermo.system.SystemSrkEos; + +/** + * Regression coverage for speed-solve pressure consistency (issue #4321). + * + * @author Even Solbraa + * @version 1.0 + */ +class CompressorSpeedSolveConsistencyTest extends NeqSimTest { + /** + * Build the public synthetic chart and fluid from the issue. + * + * @return a configured fixed-speed compressor + */ + private Compressor createCompressor() { + return createCompressor("kJ/kg"); + } + + /** + * Build equivalent chart data in either supported head unit. + * + * @param headUnit chart head unit, kJ/kg or meter + * @return configured compressor + */ + private Compressor createCompressor(String headUnit) { + SystemSrkEos fluid = new SystemSrkEos(300.0, 4.0); + fluid.addComponent("methane", 0.7); + fluid.addComponent("ethane", 0.2); + fluid.addComponent("propane", 0.1); + fluid.setMixingRule("classic"); + Stream feed = new Stream("feed", fluid); + feed.setFlowRate(1000.0, "kg/hr"); + feed.run(); + Compressor compressor = new Compressor("compressor", feed); + compressor.setUsePolytropicCalc(true); + double[][] flow = {{100.0, 140.0, 180.0}, {200.0, 260.0, 320.0}, {300.0, 380.0, 460.0}}; + double[][] head = {{100.0, 80.0, 60.0}, {180.0, 140.0, 100.0}, {260.0, 200.0, 140.0}}; + if ("meter".equals(headUnit)) { + for (double[] curve : head) { + for (int i = 0; i < curve.length; i++) { + curve[i] *= 1000.0 / 9.81; + } + } + } + double[][] efficiency = {{70.0, 71.0, 70.0}, {72.0, 73.0, 72.0}, {74.0, 75.0, 74.0}}; + compressor.getCompressorChart().setCurves(new double[] {25.0, 300.0, 1.0, 1.0}, + new double[] {1000.0, 2000.0, 3000.0}, flow, head, flow, efficiency); + compressor.getCompressorChart().setHeadUnit(headUnit); + compressor.setSpeed(2000.0); + compressor.setMinimumSpeed(1000.0); + compressor.setMaximumSpeed(3000.0); + return compressor; + } + + /** + * A returned result must agree with a separate fixed-speed calculation, including failed searches. + * + * @param target requested pressure in bara + */ + @ParameterizedTest + @ValueSource(doubles = {6.0, 8.0, 14.0}) + void outletMatchesForwardReplay(double target) { + Compressor compressor = createCompressor(); + compressor.setOutletPressure(target, "bara"); + compressor.setSolveSpeed(true); + UUID id = UUID.randomUUID(); + compressor.run(id); + Compressor forward = createCompressor(); + forward.setSpeed(compressor.getSpeed()); + forward.run(); + assertEquals(forward.getOutletStream().getPressure("bara"), compressor.getOutletStream().getPressure("bara"), + 1.0e-9); + assertEquals(forward.getPower(), compressor.getPower(), 1.0e-6); + assertEquals(forward.getOutletStream().getTemperature(), compressor.getOutletStream().getTemperature(), 1.0e-7); + assertEquals(target == 14.0, compressor.isSpeedSolveConverged()); + assertEquals(target == 14.0, compressor.solved()); + assertEquals(target == 14.0, compressor.isSimulationValid()); + if (target != 14.0) { + assertTrue(compressor.getSimulationValidationErrors().stream() + .anyMatch(error -> error.contains("Speed solve did not converge"))); + } + assertEquals(compressor.getOutletStream().getPressure("bara") - target, compressor.getSpeedSolvePressureResidual(), + 1.0e-12); + assertEquals(target, compressor.getOutletPressure(), 0.0); + assertEquals(id, compressor.getCalculationIdentifier()); + assertEquals(id, compressor.getOutletStream().getCalculationIdentifier()); + assertFalse(compressor.needRecalculation()); + assertTrue(Double.isFinite(compressor.getPower())); + assertEquals(1000.0, compressor.getOutletStream().getFlowRate("kg/hr"), 1.0e-6); + org.apache.logging.log4j.LogManager.getLogger(getClass()).info( + "Target {} bara: speed {} rpm, achieved {} bara, converged {}", target, compressor.getSpeed(), + compressor.getOutletStream().getPressure("bara"), compressor.isSpeedSolveConverged()); + } + + /** Failed searches must not replace the target or leave stale successful diagnostics. */ + @Test + void repeatedRunsKeepTargetAndResetDiagnostics() { + Compressor compressor = createCompressor(); + assertFalse(compressor.isSpeedSolveConverged()); + assertTrue(Double.isNaN(compressor.getSpeedSolvePressureResidual())); + compressor.setSolveSpeed(true); + for (double target : new double[] {6.0, 14.0, 8.0, 14.0}) { + compressor.setOutletPressure(target, "bara"); + compressor.run(); + assertEquals(target == 14.0, compressor.isSpeedSolveConverged()); + compressor.run(); + assertEquals(target, compressor.getOutletPressure(), 0.0); + assertEquals(target == 14.0, compressor.isSpeedSolveConverged()); + } + compressor.setSolveSpeed(false); + compressor.run(); + assertFalse(compressor.isSpeedSolveConverged()); + assertTrue(Double.isNaN(compressor.getSpeedSolvePressureResidual())); + assertTrue(compressor.solved()); + } + + /** + * A bounded solve must publish the boundary's forward result without losing the target or run mode. + * + * @param target requested pressure in bara + */ + @ParameterizedTest + @ValueSource(doubles = {6.0, 100.0}) + void speedLimitProducesConsistentFailure(double target) { + Compressor compressor = createCompressor(); + compressor.setMinimumSpeed(2000.0); + compressor.setMaximumSpeed(2000.0); + compressor.setLimitSpeed(true); + compressor.setSolveSpeed(true); + compressor.setOutletPressure(target, "bara"); + compressor.run(); + Compressor forward = createCompressor(); + forward.run(); + assertEquals(2000.0, compressor.getSpeed(), 0.0); + assertEquals(forward.getOutletStream().getPressure("bara"), compressor.getOutletStream().getPressure("bara"), + 1.0e-9); + assertEquals(forward.getPower(), compressor.getPower(), 1.0e-6); + assertFalse(compressor.isSpeedSolveConverged()); + assertFalse(compressor.solved()); + assertTrue(compressor.isSolveSpeed()); + assertFalse(compressor.isCalcPressureOut()); + assertEquals(target, compressor.getOutletPressure(), 0.0); + compressor.run(); + assertFalse(compressor.isSpeedSolveConverged()); + assertEquals(target, compressor.getOutletPressure(), 0.0); + } + + /** Disabling limits retains the documented ability to extrapolate beyond the speed bounds. */ + @Test + void unlimitedSearchCanConvergeOutsideBounds() { + Compressor compressor = createCompressor(); + compressor.setMaximumSpeed(2100.0); + compressor.setOutletPressure(14.0, "bara"); + compressor.setSolveSpeed(true); + compressor.run(); + assertTrue(compressor.isSpeedSolveConverged()); + assertTrue(compressor.getSpeed() > compressor.getMaximumSpeed()); + } + + /** Setpoint conversion and residuals must use absolute pressure while retaining the caller's unit. */ + @Test + void targetInMpaMatchesBaraAndSurvivesReplay() { + Compressor compressor = createCompressor(); + compressor.setOutletPressure(1.4, "MPa"); + compressor.setSolveSpeed(true); + for (int i = 0; i < 2; i++) { + compressor.run(); + assertTrue(compressor.isSpeedSolveConverged()); + assertEquals(14.0, compressor.getOutletStream().getPressure("bara"), 0.001); + assertEquals(1.4, compressor.getOutletPressure(), 0.0); + assertEquals(compressor.getOutletStream().getPressure("bara") - 14.0, compressor.getSpeedSolvePressureResidual(), + 1.0e-12); + } + } + + /** A guide-vane correction must be included in both the search and its final forward result. */ + @Test + void guideVaneSolveMatchesForwardModel() { + Compressor forward = createCompressor(); + forward.setInletGuideVaneOpening(0.9); + forward.setSpeed(2200.0); + forward.run(); + Compressor compressor = createCompressor(); + compressor.setInletGuideVaneOpening(0.9); + compressor.setOutletPressure(forward.getOutletStream().getPressure("bara"), "bara"); + compressor.setSolveSpeed(true); + compressor.run(); + assertTrue(compressor.isSpeedSolveConverged()); + assertEquals(2200.0, compressor.getSpeed(), 0.2); + } + + /** Anti-surge flow changes must be reflected in the final state and pressure-convergence decision. */ + @Test + void antiSurgeResultMatchesForwardReplay() { + Compressor compressor = createCompressor(); + Compressor forward = createCompressor(); + for (Compressor machine : new Compressor[] {compressor, forward}) { + machine.getCompressorChart().getSurgeCurve().setCurve(new double[] {25.0, 300.0, 1.0, 1.0}, + new double[] {390.0, 400.0, 410.0}, new double[] {100.0, 200.0, 300.0}); + machine.getAntiSurge().setActive(true); + } + compressor.setOutletPressure(14.0, "bara"); + compressor.setSolveSpeed(true); + compressor.run(); + forward.setSpeed(compressor.getSpeed()); + forward.run(); + assertEquals(forward.getOutletStream().getPressure("bara"), compressor.getOutletStream().getPressure("bara"), + 1.0e-9); + assertEquals(forward.getPower(), compressor.getPower(), 1.0e-6); + assertFalse(compressor.isSpeedSolveConverged()); + assertTrue(compressor.getAntiSurge().getCurrentSurgeFraction() > 0.0); + assertEquals(1000.0, compressor.getOutletStream().getFlowRate("kg/hr"), 1.0e-6); + } + + /** Invalid targets and invalid chart values cannot reuse a previous success. */ + @Test + void invalidSearchDoesNotReportConvergence() { + Compressor compressor = createCompressor(); + compressor.setOutletPressure(14.0, "bara"); + compressor.setSolveSpeed(true); + compressor.run(); + assertTrue(compressor.isSpeedSolveConverged()); + compressor.setOutletPressure(Double.NaN, "bara"); + assertThrows(IllegalArgumentException.class, () -> compressor.run()); + assertFalse(compressor.isSpeedSolveConverged()); + assertTrue(Double.isNaN(compressor.getSpeedSolvePressureResidual())); + compressor.setOutletPressure(14.0, "bara"); + compressor.setSpeed(Double.NaN); + assertThrows(IllegalStateException.class, () -> compressor.run()); + assertFalse(compressor.isSpeedSolveConverged()); + assertTrue(compressor.isSolveSpeed()); + } + + /** Changing bounds or setpoint units must invalidate the cached pressure-target calculation. */ + @Test + void speedLimitsAndUnitsInvalidateCachedResult() { + Compressor compressor = createCompressor(); + compressor.setSolveSpeed(true); + compressor.setOutletPressure(14.0, "bara"); + compressor.run(); + assertFalse(compressor.needRecalculation()); + compressor.setMaximumSpeed(2100.0); + assertTrue(compressor.needRecalculation()); + compressor.run(); + compressor.setMinimumSpeed(1100.0); + assertTrue(compressor.needRecalculation()); + compressor.run(); + compressor.setLimitSpeed(true); + assertTrue(compressor.needRecalculation()); + compressor.run(); + assertFalse(compressor.isSpeedSolveConverged()); + assertEquals(14.0, compressor.getOutletPressure(), 0.0); + compressor.setOutletPressure(14.0, "barg"); + assertTrue(compressor.needRecalculation()); + } + + /** Equivalent meter and kJ/kg maps must give the same solve result and forward power. */ + @Test + void meterHeadMapMatchesEnergyHeadMap() { + Compressor energyHead = createCompressor(); + Compressor meterHead = createCompressor("meter"); + for (Compressor compressor : new Compressor[] {energyHead, meterHead}) { + compressor.setOutletPressure(14.0, "bara"); + compressor.setSolveSpeed(true); + compressor.run(); + assertTrue(compressor.isSpeedSolveConverged()); + } + assertEquals(energyHead.getSpeed(), meterHead.getSpeed(), 1.0e-8); + assertEquals(energyHead.getPower(), meterHead.getPower(), 1.0e-6); + assertEquals(energyHead.getOutletStream().getPressure("bara"), meterHead.getOutletStream().getPressure("bara"), + 1.0e-9); + } +} diff --git a/src/test/java/neqsim/process/util/optimizer/BottleneckAnalysisOptimizerTest.java b/src/test/java/neqsim/process/util/optimizer/BottleneckAnalysisOptimizerTest.java index 4335ac53a60..2eb9fa601c1 100644 --- a/src/test/java/neqsim/process/util/optimizer/BottleneckAnalysisOptimizerTest.java +++ b/src/test/java/neqsim/process/util/optimizer/BottleneckAnalysisOptimizerTest.java @@ -409,7 +409,8 @@ public void testCompressorInvalidWhenOutsideChartRange() { boolean processRejectedExtremeFlow = processRunFailure != null && processRunFailure.getMessage() != null && processRunFailure.getMessage().contains("Failed to run unit operation") && (processRunFailure.getMessage().contains("Compressor Outlet Manifold") - || rejectedNegativeOutletPipePressure(processRunFailure)); + || rejectedNegativeOutletPipePressure(processRunFailure) + || rejectedInvalidCompressorMap(processRunFailure)); if (processRunFailure != null && !processRejectedExtremeFlow) { throw processRunFailure; } @@ -429,6 +430,7 @@ public void testCompressorInvalidWhenOutsideChartRange() { // At very high flow, compressors should either: // - Have zero/negative polytropic head // - Have speed outside chart range + // - Reject a pressure-target search with no finite chart operating point // - Cause the downstream manifold run to reject the infeasible operating point Assertions.assertTrue(anyInvalid || !ups3Errors.isEmpty() || processRejectedExtremeFlow, "At extreme flow rates, compressors should show invalid simulation or validation errors"); @@ -455,6 +457,26 @@ private boolean rejectedNegativeOutletPipePressure(RuntimeException failure) { return false; } + /** + * Recognizes a failed compressor speed search at extreme flow, without accepting unrelated process failures. + * + * @param failure process run failure + * @return true only for a named compressor with the specific invalid-map cause and failed convergence diagnostics + */ + private boolean rejectedInvalidCompressorMap(RuntimeException failure) { + for (Compressor compressor : new Compressor[] {ups1Comp, ups2Comp, ups3Comp}) { + if (!failure.getMessage().equals("Failed to run unit operation " + compressor.getName())) { + continue; + } + Throwable cause = failure.getCause(); + if (cause instanceof Compressor.SpeedSolveException && cause.getMessage() + .equals("Speed solver found no finite chart operating point for " + compressor.getName())) { + return !compressor.isSpeedSolveConverged() && !compressor.solved() && !compressor.isSimulationValid(); + } + } + return false; + } + /** * Tests production optimizer with BINARY_FEASIBILITY search mode. This test validates that the optimizer properly * detects and handles compressor operating envelope violations through isSimulationValid(). diff --git a/src/test/java/neqsim/process/util/optimizer/FlowRateOptimizerTest.java b/src/test/java/neqsim/process/util/optimizer/FlowRateOptimizerTest.java index b7aecdd037f..f5b4c89d4bf 100644 --- a/src/test/java/neqsim/process/util/optimizer/FlowRateOptimizerTest.java +++ b/src/test/java/neqsim/process/util/optimizer/FlowRateOptimizerTest.java @@ -1,6 +1,7 @@ package neqsim.process.util.optimizer; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -836,6 +837,19 @@ private ProcessSystem createTwoStageCompressionProcess() { // ============ Professional Lift Curve Generation Tests ============ + /** Existing maps can be reused after a sweep has left the process at an invalid trial. */ + @Test + public void chartSetupDoesNotRerunRejectedTrialWhenMapsAlreadyExist() { + ProcessSystem process = createTwoStageCompressionProcess(); + FlowRateOptimizer optimizer = new FlowRateOptimizer(process, "Feed Stream", "2nd Stage Compressor"); + optimizer.configureProcessCompressorCharts(); + Stream feed = (Stream) process.getUnit("Feed Stream"); + feed.setFlowRate(1.0e9, "kg/hr"); + RuntimeException failure = assertThrows(RuntimeException.class, () -> process.run()); + assertTrue(failure.getCause() instanceof Compressor.SpeedSolveException); + assertDoesNotThrow(() -> optimizer.configureProcessCompressorCharts()); + } + @Test public void testProfessionalLiftCurveGeneration() { logger.info("\n=== Professional Lift Curve Generation Test ==="); diff --git a/src/test/java/neqsim/process/util/optimizer/ProductionOptimizerCompressorFailureTest.java b/src/test/java/neqsim/process/util/optimizer/ProductionOptimizerCompressorFailureTest.java new file mode 100644 index 00000000000..bddcfa1546c --- /dev/null +++ b/src/test/java/neqsim/process/util/optimizer/ProductionOptimizerCompressorFailureTest.java @@ -0,0 +1,122 @@ +package neqsim.process.util.optimizer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import neqsim.NeqSimTest; +import neqsim.process.equipment.compressor.Compressor; +import neqsim.process.equipment.stream.Stream; +import neqsim.process.processmodel.ProcessSystem; +import neqsim.process.util.optimizer.ProductionOptimizer.ObjectiveType; +import neqsim.process.util.optimizer.ProductionOptimizer.OptimizationConfig; +import neqsim.process.util.optimizer.ProductionOptimizer.OptimizationObjective; +import neqsim.process.util.optimizer.ProductionOptimizer.OptimizationResult; +import neqsim.thermo.system.SystemSrkEos; + +/** + * Ensures failed compressor calculations cannot supply feasible optimizer evidence. + * + * @author Even Solbraa + * @version 1.0 + */ +class ProductionOptimizerCompressorFailureTest extends NeqSimTest { + /** + * Creates a public synthetic compressor map with a reachable 14 bara target at its design feed. + * + * @return the configured compressor + */ + private Compressor createCompressor() { + SystemSrkEos fluid = new SystemSrkEos(300.0, 4.0); + fluid.addComponent("methane", 0.7); + fluid.addComponent("ethane", 0.2); + fluid.addComponent("propane", 0.1); + fluid.setMixingRule("classic"); + Stream feed = new Stream("feed", fluid); + feed.setFlowRate(1000.0, "kg/hr"); + feed.run(); + Compressor compressor = new Compressor("compressor", feed); + compressor.setUsePolytropicCalc(true); + double[][] flow = {{100.0, 140.0, 180.0}, {200.0, 260.0, 320.0}, {300.0, 380.0, 460.0}}; + double[][] head = {{100.0, 80.0, 60.0}, {180.0, 140.0, 100.0}, {260.0, 200.0, 140.0}}; + double[][] efficiency = {{70.0, 71.0, 70.0}, {72.0, 73.0, 72.0}, {74.0, 75.0, 74.0}}; + compressor.getCompressorChart().setCurves(new double[] {25.0, 300.0, 1.0, 1.0}, + new double[] {1000.0, 2000.0, 3000.0}, flow, head, flow, efficiency); + compressor.getCompressorChart().setHeadUnit("kJ/kg"); + compressor.setSpeed(2000.0); + compressor.setMinimumSpeed(1000.0); + compressor.setMaximumSpeed(3000.0); + compressor.setSolveSpeed(true); + compressor.setOutletPressure(14.0); + return compressor; + } + + /** A rejected chart trial must be infeasible without evaluating stale objectives. */ + @Test + void invalidChartCandidateDoesNotAbortSearchOrEvaluateStaleResults() { + Compressor compressor = createCompressor(); + compressor.getInletStream().setFlowRate(1.0e9, "kg/hr"); + compressor.getInletStream().run(); + assertThrows(Compressor.SpeedSolveException.class, () -> compressor.run()); + ProcessSystem process = new ProcessSystem(); + process.add(compressor.getInletStream()); + process.add(compressor); + AtomicInteger objectiveCalls = new AtomicInteger(); + OptimizationObjective objective = new OptimizationObjective("power", ps -> { + objectiveCalls.incrementAndGet(); + return compressor.getPower(); + }, 1.0, ObjectiveType.MINIMIZE); + OptimizationConfig config = new OptimizationConfig(1.0e9, 1.0e9).rateUnit("kg/hr"); + OptimizationResult result = new ProductionOptimizer().optimize(process, compressor.getInletStream(), config, + Collections.singletonList(objective), Collections.emptyList()); + assertFalse(result.isFeasible()); + assertTrue(result.getObjectiveValues().isEmpty()); + assertEquals(0, objectiveCalls.get()); + assertEquals("compressorSpeedSolve", result.getConstraintStatuses().get(0).getName()); + assertTrue(result.getConstraintStatuses().get(0).getDescription().contains("compressor")); + assertTrue(result.getIterationHistory().stream().noneMatch(ProductionOptimizer.IterationRecord::isFeasible)); + assertEquals(1.0e9, result.getDecisionVariables().get("feed"), 1.0e-6); + } + + /** Capacity-envelope handling and optional validation cannot hide a missed pressure target. */ + @Test + void unconvergedSpeedIsHardFailureWithCapacityAnalysisEnabled() { + Compressor compressor = createCompressor(); + compressor.setOutletPressure(1000.0); + compressor.setLimitSpeed(true); + compressor.setCapacityAnalysisEnabled(true); + ProcessSystem process = new ProcessSystem(); + process.add(compressor.getInletStream()); + process.add(compressor); + OptimizationConfig config = new OptimizationConfig(1000.0, 1000.0).rateUnit("kg/hr").rejectInvalidSimulations(false) + .defaultUtilizationLimit(100.0); + OptimizationResult result = new ProductionOptimizer().optimize(process, compressor.getInletStream(), config, null, + null); + assertFalse(compressor.isSpeedSolveConverged()); + assertFalse(result.isFeasible()); + assertEquals("compressorSpeedSolve", result.getConstraintStatuses().get(0).getName()); + assertTrue(result.getConstraintStatuses().get(0).getMargin() < 0.0); + } + + /** A later valid trial must recover normally after the map rejected another operating point. */ + @Test + void failedChartDoesNotPoisonLaterValidCandidate() { + Compressor compressor = createCompressor(); + compressor.getInletStream().setFlowRate(1.0e9, "kg/hr"); + compressor.getInletStream().run(); + assertThrows(Compressor.SpeedSolveException.class, () -> compressor.run()); + ProcessSystem process = new ProcessSystem(); + process.add(compressor.getInletStream()); + process.add(compressor); + OptimizationConfig config = new OptimizationConfig(1000.0, 1000.0).rateUnit("kg/hr").defaultUtilizationLimit(100.0); + OptimizationResult result = new ProductionOptimizer().optimize(process, compressor.getInletStream(), config, null, + null); + assertTrue(compressor.isSpeedSolveConverged()); + assertTrue(result.isFeasible(), result.getInfeasibilityDiagnosis()); + assertEquals(14.0, compressor.getOutletStream().getPressure("bara"), 0.001); + assertEquals(1000.0, compressor.getOutletStream().getFlowRate("kg/hr"), 1.0e-6); + } +}