diff --git a/src/main/java/neqsim/process/controllerdevice/ModelPredictiveController.java b/src/main/java/neqsim/process/controllerdevice/ModelPredictiveController.java index 5910a50f8e..603f06c6f5 100644 --- a/src/main/java/neqsim/process/controllerdevice/ModelPredictiveController.java +++ b/src/main/java/neqsim/process/controllerdevice/ModelPredictiveController.java @@ -782,6 +782,8 @@ public void setPreferredControlVector(double... references) { * @deprecated Use {@link #setPreferredControlVector(double...)} to configure the nominal control * point. This method is retained for backwards compatibility with earlier snapshots of * the MPC implementation. + * + * @param references preferred control levels for the energy terms */ @Deprecated public void setEnergyReferenceVector(double... references) { @@ -1200,6 +1202,8 @@ public void setMoveLimits(double minDelta, double maxDelta) { /** * @deprecated Use {@link #setPreferredControlValue(double)} when configuring the MPC economic * target. This method is kept for compatibility with earlier code samples. + * + * @param reference preferred steady-state control value for the single-input controller */ @Deprecated public void setEnergyReference(double reference) { diff --git a/src/main/java/neqsim/process/equipment/diffpressure/DifferentialPressureFlowCalculator.java b/src/main/java/neqsim/process/equipment/diffpressure/DifferentialPressureFlowCalculator.java index e69b81c377..744b72c491 100644 --- a/src/main/java/neqsim/process/equipment/diffpressure/DifferentialPressureFlowCalculator.java +++ b/src/main/java/neqsim/process/equipment/diffpressure/DifferentialPressureFlowCalculator.java @@ -192,6 +192,14 @@ public static FlowCalculationResult calculate(double[] pressureBarg, double[] te /** * Convenience overload using default composition and normalisation. + * + * @param pressureBarg pressure values in barg + * @param temperatureC temperature values in degrees Celsius + * @param differentialPressureMbar differential pressure across restriction in mbar + * @param flowType device type (Venturi, Orifice, ISA1932, V-Cone, DallTube, Annubar, Nozzle, + * Simplified, Perrys-Orifice) + * @param flowData geometry parameters (see individual calculation methods) + * @return flow calculation result */ public static FlowCalculationResult calculate(double[] pressureBarg, double[] temperatureC, double[] differentialPressureMbar, String flowType, double[] flowData) { @@ -201,6 +209,13 @@ public static FlowCalculationResult calculate(double[] pressureBarg, double[] te /** * Convenience overload with default flow data and composition. + * + * @param pressureBarg pressure values in barg + * @param temperatureC temperature values in degrees Celsius + * @param differentialPressureMbar differential pressure across restriction in mbar + * @param flowType device type (Venturi, Orifice, ISA1932, V-Cone, DallTube, Annubar, Nozzle, + * Simplified, Perrys-Orifice) + * @return flow calculation result */ public static FlowCalculationResult calculate(double[] pressureBarg, double[] temperatureC, double[] differentialPressureMbar, String flowType) { diff --git a/src/main/java/neqsim/process/equipment/flare/Flare.java b/src/main/java/neqsim/process/equipment/flare/Flare.java index 44205f2b58..34b0635955 100644 --- a/src/main/java/neqsim/process/equipment/flare/Flare.java +++ b/src/main/java/neqsim/process/equipment/flare/Flare.java @@ -1,9 +1,16 @@ package neqsim.process.equipment.flare; +import java.io.Serializable; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; import neqsim.process.equipment.TwoPortEquipment; import neqsim.process.equipment.stream.Stream; import neqsim.process.equipment.stream.StreamInterface; +import neqsim.process.equipment.flare.dto.FlareCapacityDTO; +import neqsim.process.equipment.flare.dto.FlareDispersionSurrogateDTO; +import neqsim.process.equipment.flare.dto.FlarePerformanceDTO; import neqsim.thermo.system.SystemInterface; import neqsim.util.unit.PowerUnit; @@ -17,6 +24,15 @@ public class Flare extends TwoPortEquipment { private double heatDuty = 0.0; // J/s private double co2Emission = 0.0; // kg/s + private double flameHeight = 30.0; // m, default radiation height + private double radiantFraction = 0.18; // fraction of heat to radiation + private double tipDiameter = 0.3; // m, used for dispersion surrogate + + private double designHeatDutyCapacityW = Double.NaN; + private double designMassFlowCapacityKgS = Double.NaN; + private double designMolarFlowCapacityMoleS = Double.NaN; + + private transient CapacityCheckResult lastCapacityCheck = CapacityCheckResult.empty(); /** * Default constructor. @@ -64,6 +80,8 @@ public void run(UUID id) { outStream.setThermoSystem(thermoSystem); setCalculationIdentifier(id); + lastCapacityCheck = evaluateCapacityInternal(heatDuty, inStream.getFlowRate("kg/sec"), + inStream.getFlowRate("mole/sec")); } /** @@ -111,4 +129,453 @@ public double getCO2Emission(String unit) { return co2Emission; } } + + /** + * Set the effective flame centerline height used for radiation calculations. + * + * @param flameHeight flame height in meters + */ + public void setFlameHeight(double flameHeight) { + this.flameHeight = Math.max(0.0, flameHeight); + } + + /** + * Define the radiant fraction of the flare heat release used for point-source radiation. + * + * @param radiantFraction fraction (0-1) + */ + public void setRadiantFraction(double radiantFraction) { + if (Double.isNaN(radiantFraction)) { + return; + } + this.radiantFraction = Math.min(Math.max(radiantFraction, 0.0), 1.0); + } + + /** + * Set flare tip diameter used for exit velocity and dispersion surrogate estimates. + * + * @param tipDiameter tip diameter in meters + */ + public void setTipDiameter(double tipDiameter) { + this.tipDiameter = Math.max(1.0e-4, tipDiameter); + } + + /** + * Configure the design heat-release capacity for capacity validation. + * + * @param value heat-duty value + * @param unit engineering unit (W, kW, MW) + */ + public void setDesignHeatDutyCapacity(double value, String unit) { + if (Double.isNaN(value)) { + designHeatDutyCapacityW = Double.NaN; + return; + } + PowerUnit conv = new PowerUnit(value, unit); + designHeatDutyCapacityW = conv.getValue("W"); + } + + /** + * Configure the design mass-flow capacity for capacity validation. + * + * @param value mass-flow value + * @param unit supported units: kg/sec, kg/hr, kg/day + */ + public void setDesignMassFlowCapacity(double value, String unit) { + designMassFlowCapacityKgS = convertMassFlowToKgPerSec(value, unit); + } + + /** + * Configure the design molar-flow capacity for capacity validation. + * + * @param value molar-flow value + * @param unit supported units: mole/sec, kmole/hr + */ + public void setDesignMolarFlowCapacity(double value, String unit) { + designMolarFlowCapacityMoleS = convertMolarFlowToMolePerSec(value, unit); + } + + /** + * Estimate the flame radiation heat flux at a horizontal ground distance using the currently + * calculated heat duty. + * + * @param groundDistanceM horizontal distance from flare base [m] + * @return radiant heat flux [W/m2] + */ + public double estimateRadiationHeatFlux(double groundDistanceM) { + return estimateRadiationHeatFlux(heatDuty, groundDistanceM); + } + + /** + * Estimate the flame radiation heat flux at a horizontal ground distance for a specified heat + * duty. + * + * @param scenarioHeatDutyW heat duty in W + * @param groundDistanceM horizontal distance from flare base [m] + * @return radiant heat flux [W/m2] + */ + public double estimateRadiationHeatFlux(double scenarioHeatDutyW, double groundDistanceM) { + double radialDistance = Math + .sqrt(Math.max(0.0, groundDistanceM * groundDistanceM + flameHeight * flameHeight)); + if (radialDistance < 1.0e-6) { + return 0.0; + } + return radiantFraction * scenarioHeatDutyW / (4.0 * Math.PI * radialDistance * radialDistance); + } + + /** + * Determine the horizontal distance at which the radiation level drops to the specified + * threshold. + * + * @param fluxThresholdWm2 target heat flux [W/m2] + * @return horizontal distance from flare base [m] + */ + public double radiationDistanceForFlux(double fluxThresholdWm2) { + return radiationDistanceForFlux(heatDuty, fluxThresholdWm2); + } + + /** + * Determine the horizontal distance at which the radiation level drops to the specified + * threshold for a scenario heat duty. + * + * @param scenarioHeatDutyW heat duty in W + * @param fluxThresholdWm2 target heat flux [W/m2] + * @return horizontal distance from flare base [m] + */ + public double radiationDistanceForFlux(double scenarioHeatDutyW, double fluxThresholdWm2) { + if (fluxThresholdWm2 <= 0.0) { + return 0.0; + } + double numerator = radiantFraction * scenarioHeatDutyW; + if (numerator <= 0.0) { + return 0.0; + } + double radialDistanceSquared = numerator / (4.0 * Math.PI * fluxThresholdWm2); + double horizontalSquared = radialDistanceSquared - flameHeight * flameHeight; + if (horizontalSquared <= 0.0) { + return 0.0; + } + return Math.sqrt(horizontalSquared); + } + + /** + * Build a dispersion surrogate descriptor for the current operating point. + * + * @return surrogate DTO with momentum-like metrics + */ + public FlareDispersionSurrogateDTO getDispersionSurrogate() { + return getDispersionSurrogate(inStream != null ? inStream.getFlowRate("kg/sec") : 0.0, + inStream != null ? inStream.getFlowRate("mole/sec") : 0.0); + } + + /** + * Build a dispersion surrogate descriptor for a specified mass and molar rate. + * + * @param massRateKgS mass flow in kg/s + * @param molarRateMoleS molar flow in mole/s + * @return surrogate DTO with momentum-like metrics + */ + public FlareDispersionSurrogateDTO getDispersionSurrogate(double massRateKgS, + double molarRateMoleS) { + double density = (inStream != null) ? inStream.getThermoSystem().getDensity("kg/m3") : 1.0; + density = Math.max(1.0e-3, density); + double area = Math.PI * tipDiameter * tipDiameter / 4.0; + area = Math.max(1.0e-6, area); + double velocity = Math.max(0.0, massRateKgS / (density * area)); + double momentumFlux = density * velocity * velocity; + double massPerMomentum = (massRateKgS > 1.0e-12) ? momentumFlux / massRateKgS : 0.0; + double referenceMassRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + double standardVolumeRate = 0.0; + if (referenceMassRate > 1.0e-12) { + double refStdVolume = inStream.getFlowRate("Sm3/sec"); + standardVolumeRate = refStdVolume * massRateKgS / referenceMassRate; + } + + return new FlareDispersionSurrogateDTO(massRateKgS, molarRateMoleS, velocity, momentumFlux, + massPerMomentum, standardVolumeRate); + } + + /** + * Evaluate the current operation against configured design capacities. + * + * @return capacity check result + */ + public CapacityCheckResult evaluateCapacity() { + lastCapacityCheck = evaluateCapacityInternal(heatDuty, + inStream != null ? inStream.getFlowRate("kg/sec") : 0.0, + inStream != null ? inStream.getFlowRate("mole/sec") : 0.0); + return lastCapacityCheck; + } + + /** + * Evaluate a hypothetical load case against configured design capacities. + * + * @param scenarioHeatDutyW heat duty in W + * @param massRateKgS mass flow in kg/s + * @param molarRateMoleS molar flow in mole/s + * @return capacity check result for the specified scenario + */ + public CapacityCheckResult evaluateCapacity(double scenarioHeatDutyW, double massRateKgS, + double molarRateMoleS) { + return evaluateCapacityInternal(scenarioHeatDutyW, massRateKgS, molarRateMoleS); + } + + private CapacityCheckResult evaluateCapacityInternal(double scenarioHeatDutyW, + double massRateKgS, double molarRateMoleS) { + double heatCapacity = designHeatDutyCapacityW; + double massCapacity = designMassFlowCapacityKgS; + double molarCapacity = designMolarFlowCapacityMoleS; + + double heatUtil = (Double.isFinite(heatCapacity) && heatCapacity > 0.0) + ? scenarioHeatDutyW / heatCapacity + : Double.NaN; + double massUtil = (Double.isFinite(massCapacity) && massCapacity > 0.0) + ? massRateKgS / massCapacity + : Double.NaN; + double molarUtil = (Double.isFinite(molarCapacity) && molarCapacity > 0.0) + ? molarRateMoleS / molarCapacity + : Double.NaN; + + return new CapacityCheckResult(scenarioHeatDutyW, heatCapacity, massRateKgS, massCapacity, + molarRateMoleS, molarCapacity, heatUtil, massUtil, molarUtil); + } + + /** + * Latest computed capacity check result. + * + * @return last capacity result + */ + public CapacityCheckResult getLastCapacityCheck() { + return lastCapacityCheck; + } + + /** + * Produce a performance summary DTO for the current operating point. + * + * @return performance DTO containing emissions, radiation and capacity data + */ + public FlarePerformanceDTO getPerformanceSummary() { + double massRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + double molarRate = inStream != null ? inStream.getFlowRate("mole/sec") : 0.0; + return buildPerformanceSummary(getName(), heatDuty, massRate, molarRate, co2Emission, + inStream != null ? buildEmissionMap() : Collections.emptyMap()); + } + + /** + * Produce a performance summary DTO for a hypothetical load case. + * + * @param scenarioName label for the scenario + * @param scenarioHeatDutyW heat duty in W (if <=0 the value will be estimated from mass rate) + * @param massRateKgS mass flow in kg/s + * @param molarRateMoleS molar flow in mole/s (optional, negative to auto-estimate) + * @return performance DTO containing emissions, radiation and capacity data + */ + public FlarePerformanceDTO getPerformanceSummary(String scenarioName, double scenarioHeatDutyW, + double massRateKgS, double molarRateMoleS) { + double heat = scenarioHeatDutyW; + if (heat <= 0.0 && massRateKgS > 0.0) { + heat = estimateHeatDutyFromMassRate(massRateKgS); + } + double molarRate = molarRateMoleS; + if (molarRate <= 0.0 && massRateKgS > 0.0) { + molarRate = estimateMolarRateFromMassRate(massRateKgS); + } + double co2Rate = estimateCO2EmissionFromMassRate(massRateKgS); + Map emissions = inStream != null ? scaleEmissionMap(massRateKgS) + : Collections.emptyMap(); + return buildPerformanceSummary(scenarioName, heat, massRateKgS, molarRate, co2Rate, emissions); + } + + private FlarePerformanceDTO buildPerformanceSummary(String label, double scenarioHeatDutyW, + double massRateKgS, double molarRateMoleS, double co2RateKgS, + Map emissionMap) { + double flux30m = estimateRadiationHeatFlux(scenarioHeatDutyW, 30.0); + double distance4kW = radiationDistanceForFlux(scenarioHeatDutyW, 4000.0); + FlareDispersionSurrogateDTO dispersion = getDispersionSurrogate(massRateKgS, molarRateMoleS); + CapacityCheckResult capacity = evaluateCapacityInternal(scenarioHeatDutyW, massRateKgS, + molarRateMoleS); + + return new FlarePerformanceDTO(label, scenarioHeatDutyW, massRateKgS, molarRateMoleS, + co2RateKgS, flux30m, distance4kW, dispersion, emissionMap, capacity.toDTO()); + } + + private double estimateHeatDutyFromMassRate(double massRateKgS) { + double baseMassRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + if (baseMassRate > 1.0e-12) { + return heatDuty * (massRateKgS / baseMassRate); + } + return heatDuty; + } + + private double estimateMolarRateFromMassRate(double massRateKgS) { + double baseMassRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + double baseMolarRate = inStream != null ? inStream.getFlowRate("mole/sec") : 0.0; + if (baseMassRate > 1.0e-12 && baseMolarRate > 0.0) { + return baseMolarRate * (massRateKgS / baseMassRate); + } + double mw = inStream != null ? inStream.getThermoSystem().getMolarMass() : Double.NaN; + if (Double.isFinite(mw) && mw > 1.0e-12) { + return massRateKgS / mw; + } + return 0.0; + } + + private double estimateCO2EmissionFromMassRate(double massRateKgS) { + double baseMassRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + if (baseMassRate > 1.0e-12) { + return co2Emission * (massRateKgS / baseMassRate); + } + return co2Emission; + } + + private Map buildEmissionMap() { + Map emissionMap = new HashMap<>(); + emissionMap.put("CO2_kg_s", co2Emission); + emissionMap.put("HeatDuty_MW", heatDuty * 1.0e-6); + return emissionMap; + } + + private Map scaleEmissionMap(double massRateKgS) { + if (inStream == null) { + return Collections.singletonMap("CO2_kg_s", estimateCO2EmissionFromMassRate(massRateKgS)); + } + double baseMassRate = inStream.getFlowRate("kg/sec"); + if (baseMassRate <= 1.0e-12) { + return buildEmissionMap(); + } + Map emissionMap = new HashMap<>(); + for (Map.Entry entry : buildEmissionMap().entrySet()) { + emissionMap.put(entry.getKey(), entry.getValue() * massRateKgS / baseMassRate); + } + return emissionMap; + } + + private double convertMassFlowToKgPerSec(double value, String unit) { + if (Double.isNaN(value)) { + return Double.NaN; + } + switch (unit) { + case "kg/sec": + case "kg/s": + return value; + case "kg/hr": + return value / 3600.0; + case "kg/day": + return value / (3600.0 * 24.0); + default: + throw new IllegalArgumentException("Unsupported mass flow unit: " + unit); + } + } + + private double convertMolarFlowToMolePerSec(double value, String unit) { + if (Double.isNaN(value)) { + return Double.NaN; + } + switch (unit) { + case "mole/sec": + case "mol/sec": + return value; + case "kmole/hr": + case "kmol/hr": + return value * 1000.0 / 3600.0; + default: + throw new IllegalArgumentException("Unsupported molar flow unit: " + unit); + } + } + + /** + * Result object containing utilization against the configured design capacities. + */ + public static class CapacityCheckResult implements Serializable { + private static final long serialVersionUID = 1L; + + private final double heatDutyW; + private final double designHeatDutyW; + private final double massRateKgS; + private final double designMassRateKgS; + private final double molarRateMoleS; + private final double designMolarRateMoleS; + private final double heatUtilization; + private final double massUtilization; + private final double molarUtilization; + + CapacityCheckResult(double heatDutyW, double designHeatDutyW, double massRateKgS, + double designMassRateKgS, double molarRateMoleS, double designMolarRateMoleS, + double heatUtilization, double massUtilization, double molarUtilization) { + this.heatDutyW = heatDutyW; + this.designHeatDutyW = designHeatDutyW; + this.massRateKgS = massRateKgS; + this.designMassRateKgS = designMassRateKgS; + this.molarRateMoleS = molarRateMoleS; + this.designMolarRateMoleS = designMolarRateMoleS; + this.heatUtilization = heatUtilization; + this.massUtilization = massUtilization; + this.molarUtilization = molarUtilization; + } + + static CapacityCheckResult empty() { + return new CapacityCheckResult(0.0, Double.NaN, 0.0, Double.NaN, 0.0, Double.NaN, Double.NaN, + Double.NaN, Double.NaN); + } + + public double getHeatDutyW() { + return heatDutyW; + } + + public double getDesignHeatDutyW() { + return designHeatDutyW; + } + + public double getMassRateKgS() { + return massRateKgS; + } + + public double getDesignMassRateKgS() { + return designMassRateKgS; + } + + public double getMolarRateMoleS() { + return molarRateMoleS; + } + + public double getDesignMolarRateMoleS() { + return designMolarRateMoleS; + } + + public double getHeatUtilization() { + return heatUtilization; + } + + public double getMassUtilization() { + return massUtilization; + } + + public double getMolarUtilization() { + return molarUtilization; + } + + /** + * Determine if any configured capacity is overloaded ({@literal >} 1.0 utilization). + * + * @return true if overloaded + */ + public boolean isOverloaded() { + return exceeds(heatUtilization) || exceeds(massUtilization) || exceeds(molarUtilization); + } + + private boolean exceeds(double utilization) { + return Double.isFinite(utilization) && utilization > 1.0 + 1.0e-6; + } + + /** + * Convert to a simple DTO for reporting. + * + * @return DTO view of the capacity check + */ + public FlareCapacityDTO toDTO() { + return new FlareCapacityDTO(heatDutyW, designHeatDutyW, heatUtilization, massRateKgS, + designMassRateKgS, massUtilization, molarRateMoleS, designMolarRateMoleS, + molarUtilization, isOverloaded()); + } + } } diff --git a/src/main/java/neqsim/process/equipment/flare/dto/FlareCapacityDTO.java b/src/main/java/neqsim/process/equipment/flare/dto/FlareCapacityDTO.java new file mode 100644 index 0000000000..25783eff06 --- /dev/null +++ b/src/main/java/neqsim/process/equipment/flare/dto/FlareCapacityDTO.java @@ -0,0 +1,76 @@ +package neqsim.process.equipment.flare.dto; + +import java.io.Serializable; + +/** + * DTO describing utilization of a flare against its configured design capacities. + */ +public class FlareCapacityDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private final double heatDutyW; + private final double designHeatDutyW; + private final double heatUtilization; + private final double massRateKgS; + private final double designMassRateKgS; + private final double massUtilization; + private final double molarRateMoleS; + private final double designMolarRateMoleS; + private final double molarUtilization; + private final boolean overloaded; + + public FlareCapacityDTO(double heatDutyW, double designHeatDutyW, double heatUtilization, + double massRateKgS, double designMassRateKgS, double massUtilization, double molarRateMoleS, + double designMolarRateMoleS, double molarUtilization, boolean overloaded) { + this.heatDutyW = heatDutyW; + this.designHeatDutyW = designHeatDutyW; + this.heatUtilization = heatUtilization; + this.massRateKgS = massRateKgS; + this.designMassRateKgS = designMassRateKgS; + this.massUtilization = massUtilization; + this.molarRateMoleS = molarRateMoleS; + this.designMolarRateMoleS = designMolarRateMoleS; + this.molarUtilization = molarUtilization; + this.overloaded = overloaded; + } + + public double getHeatDutyW() { + return heatDutyW; + } + + public double getDesignHeatDutyW() { + return designHeatDutyW; + } + + public double getHeatUtilization() { + return heatUtilization; + } + + public double getMassRateKgS() { + return massRateKgS; + } + + public double getDesignMassRateKgS() { + return designMassRateKgS; + } + + public double getMassUtilization() { + return massUtilization; + } + + public double getMolarRateMoleS() { + return molarRateMoleS; + } + + public double getDesignMolarRateMoleS() { + return designMolarRateMoleS; + } + + public double getMolarUtilization() { + return molarUtilization; + } + + public boolean isOverloaded() { + return overloaded; + } +} diff --git a/src/main/java/neqsim/process/equipment/flare/dto/FlareDispersionSurrogateDTO.java b/src/main/java/neqsim/process/equipment/flare/dto/FlareDispersionSurrogateDTO.java new file mode 100644 index 0000000000..d45a54cd94 --- /dev/null +++ b/src/main/java/neqsim/process/equipment/flare/dto/FlareDispersionSurrogateDTO.java @@ -0,0 +1,52 @@ +package neqsim.process.equipment.flare.dto; + +import java.io.Serializable; + +/** + * DTO describing surrogate parameters used for dispersion screening of flare releases. + */ +public class FlareDispersionSurrogateDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private final double massRateKgS; + private final double molarRateMoleS; + private final double exitVelocityMs; + private final double momentumFlux; + private final double momentumPerMass; + private final double standardVolumeSm3PerSec; + + public FlareDispersionSurrogateDTO(double massRateKgS, double molarRateMoleS, + double exitVelocityMs, double momentumFlux, double momentumPerMass, + double standardVolumeSm3PerSec) { + this.massRateKgS = massRateKgS; + this.molarRateMoleS = molarRateMoleS; + this.exitVelocityMs = exitVelocityMs; + this.momentumFlux = momentumFlux; + this.momentumPerMass = momentumPerMass; + this.standardVolumeSm3PerSec = standardVolumeSm3PerSec; + } + + public double getMassRateKgS() { + return massRateKgS; + } + + public double getMolarRateMoleS() { + return molarRateMoleS; + } + + public double getExitVelocityMs() { + return exitVelocityMs; + } + + public double getMomentumFlux() { + return momentumFlux; + } + + public double getMomentumPerMass() { + return momentumPerMass; + } + + public double getStandardVolumeSm3PerSec() { + return standardVolumeSm3PerSec; + } +} diff --git a/src/main/java/neqsim/process/equipment/flare/dto/FlarePerformanceDTO.java b/src/main/java/neqsim/process/equipment/flare/dto/FlarePerformanceDTO.java new file mode 100644 index 0000000000..ecd0bf81f8 --- /dev/null +++ b/src/main/java/neqsim/process/equipment/flare/dto/FlarePerformanceDTO.java @@ -0,0 +1,91 @@ +package neqsim.process.equipment.flare.dto; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Map; + +/** + * DTO encapsulating emission, radiation, dispersion and capacity responses for a flare. + */ +public class FlarePerformanceDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private final String label; + private final double heatDutyW; + private final double massRateKgS; + private final double molarRateMoleS; + private final double co2EmissionKgS; + private final double heatFluxAt30mWm2; + private final double distanceTo4kWm2; + private final FlareDispersionSurrogateDTO dispersion; + private final Map emissions; + private final FlareCapacityDTO capacity; + + public FlarePerformanceDTO(String label, double heatDutyW, double massRateKgS, + double molarRateMoleS, double co2EmissionKgS, double heatFluxAt30mWm2, + double distanceTo4kWm2, FlareDispersionSurrogateDTO dispersion, + Map emissions, FlareCapacityDTO capacity) { + this.label = label; + this.heatDutyW = heatDutyW; + this.massRateKgS = massRateKgS; + this.molarRateMoleS = molarRateMoleS; + this.co2EmissionKgS = co2EmissionKgS; + this.heatFluxAt30mWm2 = heatFluxAt30mWm2; + this.distanceTo4kWm2 = distanceTo4kWm2; + this.dispersion = dispersion; + this.emissions = emissions == null ? Collections.emptyMap() : Collections.unmodifiableMap(emissions); + this.capacity = capacity; + } + + public String getLabel() { + return label; + } + + public double getHeatDutyW() { + return heatDutyW; + } + + public double getHeatDutyMW() { + return heatDutyW * 1.0e-6; + } + + public double getMassRateKgS() { + return massRateKgS; + } + + public double getMolarRateMoleS() { + return molarRateMoleS; + } + + public double getCo2EmissionKgS() { + return co2EmissionKgS; + } + + public double getCo2EmissionTonPerDay() { + return co2EmissionKgS * 86400.0 / 1000.0; + } + + public double getHeatFluxAt30mWm2() { + return heatFluxAt30mWm2; + } + + public double getDistanceTo4kWm2() { + return distanceTo4kWm2; + } + + public FlareDispersionSurrogateDTO getDispersion() { + return dispersion; + } + + public Map getEmissions() { + return emissions; + } + + public FlareCapacityDTO getCapacity() { + return capacity; + } + + public boolean isOverloaded() { + return capacity != null && capacity.isOverloaded(); + } +} diff --git a/src/main/java/neqsim/process/equipment/heatexchanger/UtilityStreamSpecification.java b/src/main/java/neqsim/process/equipment/heatexchanger/UtilityStreamSpecification.java index 9d820368f4..c83ae161ea 100644 --- a/src/main/java/neqsim/process/equipment/heatexchanger/UtilityStreamSpecification.java +++ b/src/main/java/neqsim/process/equipment/heatexchanger/UtilityStreamSpecification.java @@ -19,57 +19,104 @@ public class UtilityStreamSpecification implements Serializable { private double heatCapacityRate = Double.NaN; // W/K private double overallHeatTransferCoefficient = Double.NaN; // W/(m^2*K) - /** Returns the utility supply temperature in Kelvin. */ + /** + * Returns the utility supply temperature in Kelvin. + * + * @return supply temperature in Kelvin + */ public double getSupplyTemperature() { return supplyTemperature; } - /** Returns the utility return temperature in Kelvin. */ + /** + * Returns the utility return temperature in Kelvin. + * + * @return return temperature in Kelvin + */ public double getReturnTemperature() { return returnTemperature; } - /** Returns the minimum approach temperature in Kelvin. */ + /** + * Returns the minimum approach temperature in Kelvin. + * + * @return approach temperature in Kelvin + */ public double getApproachTemperature() { return approachTemperature; } - /** Returns the utility heat-capacity rate in W/K. */ + /** + * Returns the utility heat-capacity rate in W/K. + * + * @return heat-capacity rate in W/K + */ public double getHeatCapacityRate() { return heatCapacityRate; } - /** Returns the assumed overall heat-transfer coefficient in W/(m^2*K). */ + /** + * Returns the assumed overall heat-transfer coefficient in W/(m^2*K). + * + * @return overall heat-transfer coefficient in W/(m^2*K) + */ public double getOverallHeatTransferCoefficient() { return overallHeatTransferCoefficient; } - /** Set the utility supply temperature in Kelvin. */ + /** + * Set the utility supply temperature in Kelvin. + * + * @param temperature supply temperature in Kelvin + */ public void setSupplyTemperature(double temperature) { this.supplyTemperature = temperature; } - /** Set the utility supply temperature using the specified unit. */ + /** + * Set the utility supply temperature using the specified unit. + * + * @param temperature supply temperature value + * @param unit unit of the supplied temperature + */ public void setSupplyTemperature(double temperature, String unit) { this.supplyTemperature = new TemperatureUnit(temperature, unit).getValue("K"); } - /** Set the utility return temperature in Kelvin. */ + /** + * Set the utility return temperature in Kelvin. + * + * @param temperature return temperature in Kelvin + */ public void setReturnTemperature(double temperature) { this.returnTemperature = temperature; } - /** Set the utility return temperature using the specified unit. */ + /** + * Set the utility return temperature using the specified unit. + * + * @param temperature return temperature value + * @param unit unit of the supplied temperature + */ public void setReturnTemperature(double temperature, String unit) { this.returnTemperature = new TemperatureUnit(temperature, unit).getValue("K"); } - /** Set the minimum approach temperature (absolute difference) in Kelvin. */ + /** + * Set the minimum approach temperature (absolute difference) in Kelvin. + * + * @param approach approach temperature in Kelvin + */ public void setApproachTemperature(double approach) { this.approachTemperature = approach; } - /** Set the minimum approach temperature (absolute difference) using the specified unit. */ + /** + * Set the minimum approach temperature (absolute difference) using the specified unit. + * + * @param approach approach temperature value + * @param unit unit of the supplied temperature difference + */ public void setApproachTemperature(double approach, String unit) { switch (unit) { case "K": @@ -89,12 +136,20 @@ public void setApproachTemperature(double approach, String unit) { } } - /** Set the utility heat-capacity rate in W/K. */ + /** + * Set the utility heat-capacity rate in W/K. + * + * @param heatCapacityRate heat-capacity rate in W/K + */ public void setHeatCapacityRate(double heatCapacityRate) { this.heatCapacityRate = heatCapacityRate; } - /** Set the assumed overall heat-transfer coefficient in W/(m^2*K). */ + /** + * Set the assumed overall heat-transfer coefficient in W/(m^2*K). + * + * @param overallHeatTransferCoefficient overall heat-transfer coefficient in W/(m^2*K) + */ public void setOverallHeatTransferCoefficient(double overallHeatTransferCoefficient) { this.overallHeatTransferCoefficient = overallHeatTransferCoefficient; } diff --git a/src/main/java/neqsim/process/equipment/separator/Separator.java b/src/main/java/neqsim/process/equipment/separator/Separator.java index 8f89bd769f..baa166d5ba 100644 --- a/src/main/java/neqsim/process/equipment/separator/Separator.java +++ b/src/main/java/neqsim/process/equipment/separator/Separator.java @@ -790,6 +790,7 @@ public void setOrientation(String orientation) { * used for volume calculation, gas superficial velocity, and settling time. *

* + * @param level current liquid level inside the separator [m] * @return separator liquid area. */ public double liquidArea(double level) { @@ -939,6 +940,7 @@ private double clampLiquidHeight(double height) { * Vertical separators too. tol and maxIter are bisection loop parameters. *

* + * @param volumeTarget desired liquid volume to be held in the separator [m3] * @return liquid level in the separator */ public double levelFromVolume(double volumeTarget) { diff --git a/src/main/java/neqsim/process/equipment/valve/SafetyValve.java b/src/main/java/neqsim/process/equipment/valve/SafetyValve.java index 8998441c72..59022c8584 100644 --- a/src/main/java/neqsim/process/equipment/valve/SafetyValve.java +++ b/src/main/java/neqsim/process/equipment/valve/SafetyValve.java @@ -1,5 +1,10 @@ package neqsim.process.equipment.valve; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; import neqsim.process.equipment.stream.StreamInterface; import neqsim.process.mechanicaldesign.valve.SafetyValveMechanicalDesign; @@ -17,6 +22,8 @@ public class SafetyValve extends ThrottlingValve { private double pressureSpec = 10.0; private double fullOpenPressure = 10.0; + private final List relievingScenarios = new ArrayList<>(); + private String activeScenarioName; /** * Constructor for SafetyValve. @@ -46,6 +53,105 @@ public void initMechanicalDesign() { valveMechanicalDesign = new SafetyValveMechanicalDesign(this); } + /** + * Adds a relieving scenario definition to the valve. + * + * @param scenario the scenario to add + * @return the current valve instance for chaining + */ + public SafetyValve addScenario(RelievingScenario scenario) { + if (scenario == null) { + return this; + } + relievingScenarios.add(scenario); + if (activeScenarioName == null) { + activeScenarioName = scenario.getName(); + } + return this; + } + + /** + * Replace the relieving scenarios with the provided collection. + * + * @param scenarios scenarios to store on the valve + */ + public void setRelievingScenarios(List scenarios) { + relievingScenarios.clear(); + activeScenarioName = null; + if (scenarios != null) { + scenarios.forEach(this::addScenario); + } + } + + /** + * Removes all relieving scenarios from the valve. + */ + public void clearRelievingScenarios() { + relievingScenarios.clear(); + activeScenarioName = null; + } + + /** + * @return immutable view of configured scenarios + */ + public List getRelievingScenarios() { + return Collections.unmodifiableList(relievingScenarios); + } + + /** + * Sets the active relieving scenario by name. If the scenario does not exist the active + * definition is not changed. + * + * @param name scenario identifier + */ + public void setActiveScenario(String name) { + if (name == null) { + return; + } + boolean exists = relievingScenarios.stream().anyMatch(s -> s.getName().equals(name)); + if (exists) { + activeScenarioName = name; + } + } + + /** + * @return the name of the active scenario, if any + */ + public Optional getActiveScenarioName() { + return Optional.ofNullable(activeScenarioName); + } + + /** + * Returns the active relieving scenario or {@code Optional.empty()} if no scenario is active. + * + * @return optional containing the active scenario + */ + public Optional getActiveScenario() { + if (activeScenarioName == null) { + return Optional.empty(); + } + return relievingScenarios.stream().filter(s -> s.getName().equals(activeScenarioName)) + .findFirst(); + } + + /** + * Ensures that at least one scenario exists by creating a default vapor relieving scenario when + * no user supplied definitions are present. + */ + public void ensureDefaultScenario() { + if (!relievingScenarios.isEmpty()) { + return; + } + RelievingScenario scenario = new RelievingScenario.Builder("default") + .fluidService(FluidService.GAS) + .relievingStream(getInletStream()) + .setPressure(pressureSpec) + .overpressureFraction(0.1) + .backPressure(0.0) + .build(); + addScenario(scenario); + } + /** *

* Getter for the field pressureSpec. @@ -88,5 +194,159 @@ public double getFullOpenPressure() { */ public void setFullOpenPressure(double fullOpenPressure) { this.fullOpenPressure = fullOpenPressure; +} + + /** Supported fluid service categories used for selecting the sizing strategy. */ + public enum FluidService { + GAS, + LIQUID, + MULTIPHASE, + FIRE + } + + /** Available sizing standards for the relieving calculations. */ + public enum SizingStandard { + API_520, + ISO_4126 + } + + /** Immutable description of a relieving scenario. */ + public static final class RelievingScenario implements Serializable { + private static final long serialVersionUID = 1L; + + private final String name; + private final FluidService fluidService; + private final StreamInterface relievingStream; + private final Double setPressure; + private final double overpressureFraction; + private final double backPressure; + private final SizingStandard sizingStandard; + private final Double dischargeCoefficient; + private final Double backPressureCorrection; + private final Double installationCorrection; + + private RelievingScenario(Builder builder) { + this.name = builder.name; + this.fluidService = builder.fluidService; + this.relievingStream = builder.relievingStream; + this.setPressure = builder.setPressure; + this.overpressureFraction = builder.overpressureFraction; + this.backPressure = builder.backPressure; + this.sizingStandard = builder.sizingStandard; + this.dischargeCoefficient = builder.dischargeCoefficient; + this.backPressureCorrection = builder.backPressureCorrection; + this.installationCorrection = builder.installationCorrection; + } + + public String getName() { + return name; + } + + public FluidService getFluidService() { + return fluidService; + } + + public StreamInterface getRelievingStream() { + return relievingStream; + } + + public Optional getSetPressure() { + return Optional.ofNullable(setPressure); + } + + public double getOverpressureFraction() { + return overpressureFraction; + } + + public double getBackPressure() { + return backPressure; + } + + public SizingStandard getSizingStandard() { + return sizingStandard; + } + + public Optional getDischargeCoefficient() { + return Optional.ofNullable(dischargeCoefficient); + } + + public Optional getBackPressureCorrection() { + return Optional.ofNullable(backPressureCorrection); + } + + public Optional getInstallationCorrection() { + return Optional.ofNullable(installationCorrection); + } + + /** Builder for {@link RelievingScenario}. */ + public static final class Builder { + private final String name; + private FluidService fluidService = FluidService.GAS; + private StreamInterface relievingStream; + private Double setPressure; + private double overpressureFraction = 0.1; + private double backPressure = 0.0; + private SizingStandard sizingStandard = SizingStandard.API_520; + private Double dischargeCoefficient; + private Double backPressureCorrection; + private Double installationCorrection; + + public Builder(String name) { + this.name = name; + } + + public Builder fluidService(FluidService fluidService) { + if (fluidService != null) { + this.fluidService = fluidService; + } + return this; + } + + public Builder relievingStream(StreamInterface relievingStream) { + this.relievingStream = relievingStream; + return this; + } + + public Builder setPressure(double setPressure) { + this.setPressure = setPressure; + return this; + } + + public Builder overpressureFraction(double overpressureFraction) { + this.overpressureFraction = overpressureFraction; + return this; + } + + public Builder backPressure(double backPressure) { + this.backPressure = backPressure; + return this; + } + + public Builder sizingStandard(SizingStandard sizingStandard) { + if (sizingStandard != null) { + this.sizingStandard = sizingStandard; + } + return this; + } + + public Builder dischargeCoefficient(double dischargeCoefficient) { + this.dischargeCoefficient = dischargeCoefficient; + return this; + } + + public Builder backPressureCorrection(double backPressureCorrection) { + this.backPressureCorrection = backPressureCorrection; + return this; + } + + public Builder installationCorrection(double installationCorrection) { + this.installationCorrection = installationCorrection; + return this; + } + + public RelievingScenario build() { + return new RelievingScenario(this); + } + } } } diff --git a/src/main/java/neqsim/process/mechanicaldesign/DesignLimitData.java b/src/main/java/neqsim/process/mechanicaldesign/DesignLimitData.java new file mode 100644 index 0000000000..6704ed0469 --- /dev/null +++ b/src/main/java/neqsim/process/mechanicaldesign/DesignLimitData.java @@ -0,0 +1,135 @@ +package neqsim.process.mechanicaldesign; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Immutable mechanical design limits for a unit of equipment. + */ +public final class DesignLimitData implements Serializable { + private static final long serialVersionUID = 1L; + + /** Empty data set with undefined limits. */ + public static final DesignLimitData EMPTY = DesignLimitData.builder().build(); + + private final double maxPressure; + private final double minPressure; + private final double maxTemperature; + private final double minTemperature; + private final double corrosionAllowance; + private final double jointEfficiency; + + private DesignLimitData(Builder builder) { + this.maxPressure = builder.maxPressure; + this.minPressure = builder.minPressure; + this.maxTemperature = builder.maxTemperature; + this.minTemperature = builder.minTemperature; + this.corrosionAllowance = builder.corrosionAllowance; + this.jointEfficiency = builder.jointEfficiency; + } + + public static Builder builder() { + return new Builder(); + } + + public double getMaxPressure() { + return maxPressure; + } + + public double getMinPressure() { + return minPressure; + } + + public double getMaxTemperature() { + return maxTemperature; + } + + public double getMinTemperature() { + return minTemperature; + } + + public double getCorrosionAllowance() { + return corrosionAllowance; + } + + public double getJointEfficiency() { + return jointEfficiency; + } + + /** Builder for {@link DesignLimitData}. */ + public static final class Builder { + private double maxPressure = Double.NaN; + private double minPressure = Double.NaN; + private double maxTemperature = Double.NaN; + private double minTemperature = Double.NaN; + private double corrosionAllowance = Double.NaN; + private double jointEfficiency = Double.NaN; + + private Builder() {} + + public Builder maxPressure(double value) { + this.maxPressure = value; + return this; + } + + public Builder minPressure(double value) { + this.minPressure = value; + return this; + } + + public Builder maxTemperature(double value) { + this.maxTemperature = value; + return this; + } + + public Builder minTemperature(double value) { + this.minTemperature = value; + return this; + } + + public Builder corrosionAllowance(double value) { + this.corrosionAllowance = value; + return this; + } + + public Builder jointEfficiency(double value) { + this.jointEfficiency = value; + return this; + } + + public DesignLimitData build() { + return new DesignLimitData(this); + } + } + + @Override + public int hashCode() { + return Objects.hash(maxPressure, minPressure, maxTemperature, minTemperature, corrosionAllowance, + jointEfficiency); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof DesignLimitData)) { + return false; + } + DesignLimitData other = (DesignLimitData) obj; + return Double.doubleToLongBits(maxPressure) == Double.doubleToLongBits(other.maxPressure) + && Double.doubleToLongBits(minPressure) == Double.doubleToLongBits(other.minPressure) + && Double.doubleToLongBits(maxTemperature) == Double.doubleToLongBits(other.maxTemperature) + && Double.doubleToLongBits(minTemperature) == Double.doubleToLongBits(other.minTemperature) + && Double.doubleToLongBits(corrosionAllowance) == Double.doubleToLongBits(other.corrosionAllowance) + && Double.doubleToLongBits(jointEfficiency) == Double.doubleToLongBits(other.jointEfficiency); + } + + @Override + public String toString() { + return "DesignLimitData{" + "maxPressure=" + maxPressure + ", minPressure=" + minPressure + + ", maxTemperature=" + maxTemperature + ", minTemperature=" + minTemperature + + ", corrosionAllowance=" + corrosionAllowance + ", jointEfficiency=" + jointEfficiency + + '}'; + } +} diff --git a/src/main/java/neqsim/process/mechanicaldesign/MechanicalDesign.java b/src/main/java/neqsim/process/mechanicaldesign/MechanicalDesign.java index 5e3a4ddef7..135c8bff18 100644 --- a/src/main/java/neqsim/process/mechanicaldesign/MechanicalDesign.java +++ b/src/main/java/neqsim/process/mechanicaldesign/MechanicalDesign.java @@ -2,13 +2,19 @@ import java.awt.BorderLayout; import java.awt.Container; +import java.util.ArrayList; +import java.util.Collections; import java.util.Hashtable; +import java.util.List; import java.util.Objects; +import java.util.Optional; import javax.swing.JFrame; import javax.swing.JScrollPane; import javax.swing.JTable; import neqsim.process.costestimation.UnitCostEstimateBaseClass; import neqsim.process.equipment.ProcessEquipmentInterface; +import neqsim.process.mechanicaldesign.data.DatabaseMechanicalDesignDataSource; +import neqsim.process.mechanicaldesign.data.MechanicalDesignDataSource; import neqsim.process.mechanicaldesign.designstandards.AdsorptionDehydrationDesignStandard; import neqsim.process.mechanicaldesign.designstandards.CompressorDesignStandard; import neqsim.process.mechanicaldesign.designstandards.DesignStandard; @@ -101,8 +107,11 @@ public void setMaterialDesignStandard(MaterialPlateDesignStandard materialDesign new MaterialPlateDesignStandard(); private MaterialPipeDesignStandard materialPipeDesignStandard = new MaterialPipeDesignStandard(); private String construtionMaterial = "steel"; - private double corrosionAllowanse = 0.0; // mm + private double corrosionAllowance = 0.0; // mm private double pressureMarginFactor = 0.1; + private DesignLimitData designLimitData = DesignLimitData.EMPTY; + private MechanicalDesignMarginResult lastMarginResult = MechanicalDesignMarginResult.EMPTY; + private List designDataSources = new ArrayList<>(); public double innerDiameter = 0.0; public double outerDiameter = 0.0; /** Wall thickness in mm. */ @@ -135,6 +144,196 @@ public void setMaterialDesignStandard(MaterialPlateDesignStandard materialDesign public MechanicalDesign(ProcessEquipmentInterface processEquipment) { this.processEquipment = processEquipment; costEstimate = new UnitCostEstimateBaseClass(this); + initMechanicalDesign(); + } + + /** Initialize design data using configured data sources. */ + public void initMechanicalDesign() { + loadDesignLimits(); + } + + private void loadDesignLimits() { + String equipmentType = resolveEquipmentType(); + if (equipmentType.isEmpty()) { + designLimitData = DesignLimitData.EMPTY; + return; + } + + String companyIdentifier = Objects.toString(companySpecificDesignStandards, ""); + DesignLimitData loadedData = null; + for (MechanicalDesignDataSource dataSource : getActiveDesignDataSources()) { + Optional candidate = + dataSource.getDesignLimits(equipmentType, companyIdentifier); + if (candidate.isPresent()) { + loadedData = candidate.get(); + break; + } + } + + if (loadedData == null) { + designLimitData = DesignLimitData.EMPTY; + return; + } + + designLimitData = loadedData; + + if (!Double.isNaN(loadedData.getCorrosionAllowance())) { + corrosionAllowance = loadedData.getCorrosionAllowance(); + } + if (!Double.isNaN(loadedData.getJointEfficiency())) { + jointEfficiency = loadedData.getJointEfficiency(); + } + } + + private List getActiveDesignDataSources() { + if (designDataSources.isEmpty()) { + List defaults = new ArrayList<>(); + defaults.add(new DatabaseMechanicalDesignDataSource()); + return defaults; + } + return new ArrayList<>(designDataSources); + } + + private String resolveEquipmentType() { + if (processEquipment == null) { + return ""; + } + return processEquipment.getClass().getSimpleName(); + } + + /** + * Configure a single data source to load design limits from. + * + * @param dataSource data source to use, {@code null} clears existing sources. + */ + public void setDesignDataSource(MechanicalDesignDataSource dataSource) { + if (dataSource == null) { + designDataSources = new ArrayList<>(); + } else { + designDataSources = new ArrayList<>(); + designDataSources.add(dataSource); + } + initMechanicalDesign(); + } + + /** + * Configure the list of data sources to use when loading design limits. + * + * @param dataSources ordered list of data sources. + */ + public void setDesignDataSources(List dataSources) { + if (dataSources == null) { + designDataSources = new ArrayList<>(); + } else { + designDataSources = new ArrayList<>(dataSources); + } + initMechanicalDesign(); + } + + /** Add an additional data source used when loading design limits. */ + public void addDesignDataSource(MechanicalDesignDataSource dataSource) { + if (dataSource == null) { + return; + } + designDataSources.add(dataSource); + initMechanicalDesign(); + } + + /** + * Get the immutable list of configured data sources. + * + * @return list of data sources. + */ + public List getDesignDataSources() { + return Collections.unmodifiableList(designDataSources); + } + + public DesignLimitData getDesignLimitData() { + return designLimitData; + } + + public double getDesignMaxPressureLimit() { + return designLimitData.getMaxPressure(); + } + + public double getDesignMinPressureLimit() { + return designLimitData.getMinPressure(); + } + + public double getDesignMaxTemperatureLimit() { + return designLimitData.getMaxTemperature(); + } + + public double getDesignMinTemperatureLimit() { + return designLimitData.getMinTemperature(); + } + + public double getDesignCorrosionAllowance() { + return designLimitData.getCorrosionAllowance(); + } + + public double getDesignJointEfficiency() { + return designLimitData.getJointEfficiency(); + } + + /** + * Validate the current operating envelope against design limits. + * + * @return computed margin result. + */ + public MechanicalDesignMarginResult validateOperatingEnvelope() { + return validateOperatingEnvelope(maxOperationPressure, minOperationPressure, + maxOperationTemperature, minOperationTemperature, corrosionAllowance, jointEfficiency); + } + + /** + * Validate a specific operating envelope against design limits. + * + * @param operatingMaxPressure maximum operating pressure. + * @param operatingMinPressure minimum operating pressure. + * @param operatingMaxTemperature maximum operating temperature. + * @param operatingMinTemperature minimum operating temperature. + * @param operatingCorrosionAllowance corrosion allowance used in operation. + * @param operatingJointEfficiency joint efficiency achieved in operation. + * @return computed margin result. + */ + public MechanicalDesignMarginResult validateOperatingEnvelope(double operatingMaxPressure, + double operatingMinPressure, double operatingMaxTemperature, double operatingMinTemperature, + double operatingCorrosionAllowance, double operatingJointEfficiency) { + double maxPressureMargin = marginToUpperLimit(designLimitData.getMaxPressure(), + operatingMaxPressure); + double minPressureMargin = marginFromLowerLimit(operatingMinPressure, + designLimitData.getMinPressure()); + double maxTemperatureMargin = marginToUpperLimit(designLimitData.getMaxTemperature(), + operatingMaxTemperature); + double minTemperatureMargin = marginFromLowerLimit(operatingMinTemperature, + designLimitData.getMinTemperature()); + double corrosionMargin = marginFromLowerLimit(operatingCorrosionAllowance, + designLimitData.getCorrosionAllowance()); + double jointMargin = marginFromLowerLimit(operatingJointEfficiency, + designLimitData.getJointEfficiency()); + + lastMarginResult = new MechanicalDesignMarginResult(maxPressureMargin, minPressureMargin, + maxTemperatureMargin, minTemperatureMargin, corrosionMargin, jointMargin); + return lastMarginResult; + } + + public MechanicalDesignMarginResult getLastMarginResult() { + return lastMarginResult; + } + + private double marginToUpperLimit(double limit, double value) { + if (Double.isNaN(limit) || Double.isNaN(value)) { + return Double.NaN; + } + return limit - value; + } + + private double marginFromLowerLimit(double value, double limit) { + if (Double.isNaN(limit) || Double.isNaN(value)) { + return Double.NaN; + } + return value - limit; } /** @@ -378,24 +577,24 @@ public void setJointEfficiency(double jointEfficiency) { /** *

- * Getter for the field corrosionAllowanse. + * Getter for the field corrosionAllowance. *

* - * @return the corrosionAllowanse + * @return the corrosionAllowance */ - public double getCorrosionAllowanse() { - return corrosionAllowanse; + public double getCorrosionAllowance() { + return corrosionAllowance; } /** *

- * Setter for the field corrosionAllowanse. + * Setter for the field corrosionAllowance. *

* - * @param corrosionAllowanse the corrosionAllowanse to set + * @param corrosionAllowance the corrosionAllowance to set */ - public void setCorrosionAllowanse(double corrosionAllowanse) { - this.corrosionAllowanse = corrosionAllowanse; + public void setCorrosionAllowance(double corrosionAllowance) { + this.corrosionAllowance = corrosionAllowance; } /** @@ -450,9 +649,10 @@ public String getCompanySpecificDesignStandards() { * @param companySpecificDesignStandards the companySpecificDesignStandards to set */ public void setCompanySpecificDesignStandards(String companySpecificDesignStandards) { - this.companySpecificDesignStandards = companySpecificDesignStandards; + this.companySpecificDesignStandards = + companySpecificDesignStandards == null ? "" : companySpecificDesignStandards; - if (companySpecificDesignStandards.equals("StatoilTR")) { + if (this.companySpecificDesignStandards.equals("StatoilTR")) { getDesignStandard().put("pressure vessel design code", new PressureVesselDesignStandard("ASME - Pressure Vessel Code", this)); getDesignStandard().put("separator process design", @@ -477,7 +677,7 @@ public void setCompanySpecificDesignStandards(String companySpecificDesignStanda // setValveDesignStandard("TR1903_Statoil"); } else { System.out.println("using default mechanical design standards...no design standard " - + companySpecificDesignStandards); + + this.companySpecificDesignStandards); getDesignStandard().put("pressure vessel design code", new PressureVesselDesignStandard("ASME - Pressure Vessel Code", this)); getDesignStandard().put("separator process design", @@ -498,6 +698,7 @@ public void setCompanySpecificDesignStandards(String companySpecificDesignStanda new MaterialPipeDesignStandard("Statoil_TR1414", this)); } hasSetCompanySpecificDesignStandards = true; + initMechanicalDesign(); } /** @@ -1133,7 +1334,7 @@ public double getDefaultLiquidViscosity() { /** {@inheritDoc} */ @Override public int hashCode() { - return Objects.hash(companySpecificDesignStandards, construtionMaterial, corrosionAllowanse, + return Objects.hash(companySpecificDesignStandards, construtionMaterial, corrosionAllowance, costEstimate, designStandard, hasSetCompanySpecificDesignStandards, innerDiameter, jointEfficiency, materialPipeDesignStandard, materialPlateDesignStandard, maxDesignGassVolumeFlow, maxDesignOilVolumeFlow, maxDesignVolumeFlow, @@ -1160,8 +1361,8 @@ public boolean equals(Object obj) { MechanicalDesign other = (MechanicalDesign) obj; return Objects.equals(companySpecificDesignStandards, other.companySpecificDesignStandards) && Objects.equals(construtionMaterial, other.construtionMaterial) - && Double.doubleToLongBits(corrosionAllowanse) == Double - .doubleToLongBits(other.corrosionAllowanse) + && Double.doubleToLongBits(corrosionAllowance) == Double + .doubleToLongBits(other.corrosionAllowance) && Objects.equals(costEstimate, other.costEstimate) && Objects.equals(designStandard, other.designStandard) && hasSetCompanySpecificDesignStandards == other.hasSetCompanySpecificDesignStandards diff --git a/src/main/java/neqsim/process/mechanicaldesign/MechanicalDesignMarginResult.java b/src/main/java/neqsim/process/mechanicaldesign/MechanicalDesignMarginResult.java new file mode 100644 index 0000000000..2a0ff3af67 --- /dev/null +++ b/src/main/java/neqsim/process/mechanicaldesign/MechanicalDesignMarginResult.java @@ -0,0 +1,103 @@ +package neqsim.process.mechanicaldesign; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Result object describing operating margins relative to design limits. + */ +public final class MechanicalDesignMarginResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** Empty result with undefined margins. */ + public static final MechanicalDesignMarginResult EMPTY = new MechanicalDesignMarginResult( + Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN); + + private final double maxPressureMargin; + private final double minPressureMargin; + private final double maxTemperatureMargin; + private final double minTemperatureMargin; + private final double corrosionAllowanceMargin; + private final double jointEfficiencyMargin; + + public MechanicalDesignMarginResult(double maxPressureMargin, double minPressureMargin, + double maxTemperatureMargin, double minTemperatureMargin, double corrosionAllowanceMargin, + double jointEfficiencyMargin) { + this.maxPressureMargin = maxPressureMargin; + this.minPressureMargin = minPressureMargin; + this.maxTemperatureMargin = maxTemperatureMargin; + this.minTemperatureMargin = minTemperatureMargin; + this.corrosionAllowanceMargin = corrosionAllowanceMargin; + this.jointEfficiencyMargin = jointEfficiencyMargin; + } + + public double getMaxPressureMargin() { + return maxPressureMargin; + } + + public double getMinPressureMargin() { + return minPressureMargin; + } + + public double getMaxTemperatureMargin() { + return maxTemperatureMargin; + } + + public double getMinTemperatureMargin() { + return minTemperatureMargin; + } + + public double getCorrosionAllowanceMargin() { + return corrosionAllowanceMargin; + } + + public double getJointEfficiencyMargin() { + return jointEfficiencyMargin; + } + + /** + * @return true if all evaluated margins are non-negative or undefined. + */ + public boolean isWithinDesignEnvelope() { + return isNonNegativeOrNaN(maxPressureMargin) && isNonNegativeOrNaN(minPressureMargin) + && isNonNegativeOrNaN(maxTemperatureMargin) && isNonNegativeOrNaN(minTemperatureMargin) + && isNonNegativeOrNaN(corrosionAllowanceMargin) + && isNonNegativeOrNaN(jointEfficiencyMargin); + } + + private boolean isNonNegativeOrNaN(double value) { + return Double.isNaN(value) || value >= 0.0; + } + + @Override + public int hashCode() { + return Objects.hash(maxPressureMargin, minPressureMargin, maxTemperatureMargin, + minTemperatureMargin, corrosionAllowanceMargin, jointEfficiencyMargin); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof MechanicalDesignMarginResult)) { + return false; + } + MechanicalDesignMarginResult other = (MechanicalDesignMarginResult) obj; + return Double.doubleToLongBits(maxPressureMargin) == Double.doubleToLongBits(other.maxPressureMargin) + && Double.doubleToLongBits(minPressureMargin) == Double.doubleToLongBits(other.minPressureMargin) + && Double.doubleToLongBits(maxTemperatureMargin) == Double.doubleToLongBits(other.maxTemperatureMargin) + && Double.doubleToLongBits(minTemperatureMargin) == Double.doubleToLongBits(other.minTemperatureMargin) + && Double.doubleToLongBits(corrosionAllowanceMargin) == Double.doubleToLongBits(other.corrosionAllowanceMargin) + && Double.doubleToLongBits(jointEfficiencyMargin) == Double.doubleToLongBits(other.jointEfficiencyMargin); + } + + @Override + public String toString() { + return "MechanicalDesignMarginResult{" + "maxPressureMargin=" + maxPressureMargin + + ", minPressureMargin=" + minPressureMargin + ", maxTemperatureMargin=" + + maxTemperatureMargin + ", minTemperatureMargin=" + minTemperatureMargin + + ", corrosionAllowanceMargin=" + corrosionAllowanceMargin + ", jointEfficiencyMargin=" + + jointEfficiencyMargin + '}'; + } +} diff --git a/src/main/java/neqsim/process/mechanicaldesign/data/CsvMechanicalDesignDataSource.java b/src/main/java/neqsim/process/mechanicaldesign/data/CsvMechanicalDesignDataSource.java new file mode 100644 index 0000000000..ced996ff6c --- /dev/null +++ b/src/main/java/neqsim/process/mechanicaldesign/data/CsvMechanicalDesignDataSource.java @@ -0,0 +1,147 @@ +package neqsim.process.mechanicaldesign.data; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Optional; +import neqsim.process.mechanicaldesign.DesignLimitData; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Loads mechanical design limits from a CSV file. The file is expected to contain the columns + * {@code EQUIPMENTTYPE}, {@code COMPANY}, {@code MAXPRESSURE}, {@code MINPRESSURE}, + * {@code MAXTEMPERATURE}, {@code MINTEMPERATURE}, {@code CORROSIONALLOWANCE}, and + * {@code JOINTEFFICIENCY}. Column order is flexible as long as the header matches. + */ +public class CsvMechanicalDesignDataSource implements MechanicalDesignDataSource { + private static final Logger logger = LogManager.getLogger(CsvMechanicalDesignDataSource.class); + + private final Path csvPath; + + public CsvMechanicalDesignDataSource(Path csvPath) { + this.csvPath = csvPath; + } + + @Override + public Optional getDesignLimits(String equipmentTypeName, + String companyIdentifier) { + if (csvPath == null) { + return Optional.empty(); + } + + if (!Files.isReadable(csvPath)) { + logger.warn("Design limit CSV file {} is not readable", csvPath); + return Optional.empty(); + } + + String normalizedEquipment = normalize(equipmentTypeName); + String normalizedCompany = normalize(companyIdentifier); + + try (BufferedReader reader = Files.newBufferedReader(csvPath)) { + String header = reader.readLine(); + if (header == null) { + return Optional.empty(); + } + String[] columns = header.split(","); + ColumnIndex index = ColumnIndex.from(columns); + String line; + while ((line = reader.readLine()) != null) { + String[] tokens = line.split(","); + if (tokens.length < index.requiredLength()) { + continue; + } + if (!normalize(tokens[index.equipmentTypeIndex]).equals(normalizedEquipment)) { + continue; + } + if (!normalize(tokens[index.companyIndex]).equals(normalizedCompany)) { + continue; + } + return Optional.of(parse(tokens, index)); + } + } catch (IOException ex) { + logger.error("Failed to read mechanical design CSV {}", csvPath, ex); + } + return Optional.empty(); + } + + private DesignLimitData parse(String[] tokens, ColumnIndex index) { + DesignLimitData.Builder builder = DesignLimitData.builder(); + builder.maxPressure(parseDouble(tokens, index.maxPressureIndex)); + builder.minPressure(parseDouble(tokens, index.minPressureIndex)); + builder.maxTemperature(parseDouble(tokens, index.maxTemperatureIndex)); + builder.minTemperature(parseDouble(tokens, index.minTemperatureIndex)); + builder.corrosionAllowance(parseDouble(tokens, index.corrosionAllowanceIndex)); + builder.jointEfficiency(parseDouble(tokens, index.jointEfficiencyIndex)); + return builder.build(); + } + + private double parseDouble(String[] tokens, int index) { + if (index < 0 || index >= tokens.length) { + return Double.NaN; + } + try { + return Double.parseDouble(tokens[index].trim()); + } catch (NumberFormatException ex) { + return Double.NaN; + } + } + + private String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + private static final class ColumnIndex { + private final int equipmentTypeIndex; + private final int companyIndex; + private final int maxPressureIndex; + private final int minPressureIndex; + private final int maxTemperatureIndex; + private final int minTemperatureIndex; + private final int corrosionAllowanceIndex; + private final int jointEfficiencyIndex; + + private ColumnIndex(int equipmentTypeIndex, int companyIndex, int maxPressureIndex, + int minPressureIndex, int maxTemperatureIndex, int minTemperatureIndex, + int corrosionAllowanceIndex, int jointEfficiencyIndex) { + this.equipmentTypeIndex = equipmentTypeIndex; + this.companyIndex = companyIndex; + this.maxPressureIndex = maxPressureIndex; + this.minPressureIndex = minPressureIndex; + this.maxTemperatureIndex = maxTemperatureIndex; + this.minTemperatureIndex = minTemperatureIndex; + this.corrosionAllowanceIndex = corrosionAllowanceIndex; + this.jointEfficiencyIndex = jointEfficiencyIndex; + } + + static ColumnIndex from(String[] columns) { + int equipment = indexOf(columns, "EQUIPMENTTYPE"); + int company = indexOf(columns, "COMPANY"); + int maxPressure = indexOf(columns, "MAXPRESSURE"); + int minPressure = indexOf(columns, "MINPRESSURE"); + int maxTemperature = indexOf(columns, "MAXTEMPERATURE"); + int minTemperature = indexOf(columns, "MINTEMPERATURE"); + int corrosionAllowance = indexOf(columns, "CORROSIONALLOWANCE"); + int jointEfficiency = indexOf(columns, "JOINTEFFICIENCY"); + return new ColumnIndex(equipment, company, maxPressure, minPressure, maxTemperature, + minTemperature, corrosionAllowance, jointEfficiency); + } + + int requiredLength() { + return Math.max(Math.max(Math.max(Math.max(Math.max(Math.max(Math.max(equipmentTypeIndex, + companyIndex), maxPressureIndex), minPressureIndex), maxTemperatureIndex), + minTemperatureIndex), corrosionAllowanceIndex), jointEfficiencyIndex) + 1; + } + + private static int indexOf(String[] columns, String name) { + for (int i = 0; i < columns.length; i++) { + if (name.equalsIgnoreCase(columns[i].trim())) { + return i; + } + } + return -1; + } + } +} diff --git a/src/main/java/neqsim/process/mechanicaldesign/data/DatabaseMechanicalDesignDataSource.java b/src/main/java/neqsim/process/mechanicaldesign/data/DatabaseMechanicalDesignDataSource.java new file mode 100644 index 0000000000..618d48b448 --- /dev/null +++ b/src/main/java/neqsim/process/mechanicaldesign/data/DatabaseMechanicalDesignDataSource.java @@ -0,0 +1,87 @@ +package neqsim.process.mechanicaldesign.data; + +import java.sql.ResultSet; +import java.util.Locale; +import java.util.Optional; +import neqsim.process.mechanicaldesign.DesignLimitData; +import neqsim.util.database.NeqSimProcessDesignDataBase; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Reads mechanical design limits from the NeqSim process design database. + */ +public class DatabaseMechanicalDesignDataSource implements MechanicalDesignDataSource { + private static final Logger logger = LogManager.getLogger(DatabaseMechanicalDesignDataSource.class); + + private static final String QUERY_TEMPLATE = "SELECT SPECIFICATION, MAXVALUE, MINVALUE FROM " + + "TechnicalRequirements_Process WHERE EQUIPMENTTYPE='%s' AND Company='%s'"; + + @Override + public Optional getDesignLimits(String equipmentTypeName, + String companyIdentifier) { + if (equipmentTypeName == null || equipmentTypeName.isEmpty() || companyIdentifier == null + || companyIdentifier.isEmpty()) { + return Optional.empty(); + } + + String query = String.format(Locale.ROOT, QUERY_TEMPLATE, equipmentTypeName, companyIdentifier); + DesignLimitData.Builder builder = DesignLimitData.builder(); + boolean found = false; + + try (NeqSimProcessDesignDataBase database = new NeqSimProcessDesignDataBase(); + ResultSet dataSet = database.getResultSet(query)) { + while (dataSet.next()) { + String specification = dataSet.getString("SPECIFICATION"); + double maxValue = parseDouble(dataSet.getString("MAXVALUE")); + double minValue = parseDouble(dataSet.getString("MINVALUE")); + double representative = Double.isNaN(maxValue) ? minValue + : Double.isNaN(minValue) ? maxValue : (maxValue + minValue) / 2.0; + switch (specification) { + case "MaxPressure": + builder.maxPressure(representative); + found = true; + break; + case "MinPressure": + builder.minPressure(representative); + found = true; + break; + case "MaxTemperature": + builder.maxTemperature(representative); + found = true; + break; + case "MinTemperature": + builder.minTemperature(representative); + found = true; + break; + case "CorrosionAllowance": + builder.corrosionAllowance(representative); + found = true; + break; + case "JointEfficiency": + builder.jointEfficiency(representative); + found = true; + break; + default: + break; + } + } + } catch (Exception ex) { + logger.error("Unable to read design limits from database", ex); + return Optional.empty(); + } + + return found ? Optional.of(builder.build()) : Optional.empty(); + } + + private double parseDouble(String value) { + if (value == null) { + return Double.NaN; + } + try { + return Double.parseDouble(value); + } catch (NumberFormatException ex) { + return Double.NaN; + } + } +} diff --git a/src/main/java/neqsim/process/mechanicaldesign/data/MechanicalDesignDataSource.java b/src/main/java/neqsim/process/mechanicaldesign/data/MechanicalDesignDataSource.java new file mode 100644 index 0000000000..59d2f99ab7 --- /dev/null +++ b/src/main/java/neqsim/process/mechanicaldesign/data/MechanicalDesignDataSource.java @@ -0,0 +1,18 @@ +package neqsim.process.mechanicaldesign.data; + +import java.util.Optional; +import neqsim.process.mechanicaldesign.DesignLimitData; + +/** + * Data source used to supply mechanical design limits for process equipment. + */ +public interface MechanicalDesignDataSource { + /** + * Retrieve design limit data for a given equipment type and company identifier. + * + * @param equipmentTypeName canonical equipment type identifier (e.g. "Pipeline"). + * @param companyIdentifier company specific design code identifier. + * @return optional design limit data if available. + */ + Optional getDesignLimits(String equipmentTypeName, String companyIdentifier); +} diff --git a/src/main/java/neqsim/process/mechanicaldesign/designstandards/DesignStandard.java b/src/main/java/neqsim/process/mechanicaldesign/designstandards/DesignStandard.java index c8b6bc2a93..24e982d6c5 100644 --- a/src/main/java/neqsim/process/mechanicaldesign/designstandards/DesignStandard.java +++ b/src/main/java/neqsim/process/mechanicaldesign/designstandards/DesignStandard.java @@ -2,6 +2,7 @@ import java.util.Objects; import neqsim.process.mechanicaldesign.MechanicalDesign; +import neqsim.process.mechanicaldesign.MechanicalDesignMarginResult; /** *

@@ -93,6 +94,18 @@ public void setStandardName(String standardName) { this.standardName = standardName; } + /** + * Compute the safety margins for the associated equipment. + * + * @return margin result or {@link MechanicalDesignMarginResult#EMPTY} if unavailable. + */ + public MechanicalDesignMarginResult computeSafetyMargins() { + if (equipment == null) { + return MechanicalDesignMarginResult.EMPTY; + } + return equipment.validateOperatingEnvelope(); + } + /** {@inheritDoc} */ @Override public int hashCode() { diff --git a/src/main/java/neqsim/process/mechanicaldesign/designstandards/PipelineDesignStandard.java b/src/main/java/neqsim/process/mechanicaldesign/designstandards/PipelineDesignStandard.java index e9fdb5fc30..6c5fd560af 100644 --- a/src/main/java/neqsim/process/mechanicaldesign/designstandards/PipelineDesignStandard.java +++ b/src/main/java/neqsim/process/mechanicaldesign/designstandards/PipelineDesignStandard.java @@ -3,6 +3,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import neqsim.process.mechanicaldesign.MechanicalDesign; +import neqsim.process.mechanicaldesign.MechanicalDesignMarginResult; /** *

@@ -19,6 +20,7 @@ public class PipelineDesignStandard extends DesignStandard { static Logger logger = LogManager.getLogger(PipelineDesignStandard.class); double safetyFactor = 1.0; + private final MechanicalDesignMarginResult safetyMargins; /** *

@@ -30,6 +32,7 @@ public class PipelineDesignStandard extends DesignStandard { */ public PipelineDesignStandard(String name, MechanicalDesign equipmentInn) { super(name, equipmentInn); + safetyMargins = computeSafetyMargins(); // double wallT = 0; // double maxAllowableStress = equipment.getMaterialDesignStandard().getDivisionClass(); @@ -40,7 +43,7 @@ public PipelineDesignStandard(String name, MechanicalDesign equipmentInn) { new neqsim.util.database.NeqSimProcessDesignDataBase()) { try (java.sql.ResultSet dataSet = database.getResultSet( ("SELECT * FROM technicalrequirements_process WHERE EQUIPMENTTYPE='Pipeline' AND Company='" - + standardName + "'"))) { + + resolveCompanyIdentifier() + "'"))) { while (dataSet.next()) { String specName = dataSet.getString("SPECIFICATION"); if (specName.equals("safetyFactor")) { @@ -70,4 +73,21 @@ public double calcPipelineWallThickness() { return 0.01; } } + + /** + * Retrieve calculated safety margins for the pipeline. + * + * @return margin result. + */ + public MechanicalDesignMarginResult getSafetyMargins() { + return safetyMargins; + } + + private String resolveCompanyIdentifier() { + String identifier = equipment != null ? equipment.getCompanySpecificDesignStandards() : null; + if (identifier == null || identifier.isEmpty()) { + return standardName; + } + return identifier; + } } diff --git a/src/main/java/neqsim/process/mechanicaldesign/designstandards/PressureVesselDesignStandard.java b/src/main/java/neqsim/process/mechanicaldesign/designstandards/PressureVesselDesignStandard.java index 11dfc46d3a..2c11947e26 100644 --- a/src/main/java/neqsim/process/mechanicaldesign/designstandards/PressureVesselDesignStandard.java +++ b/src/main/java/neqsim/process/mechanicaldesign/designstandards/PressureVesselDesignStandard.java @@ -2,6 +2,7 @@ import neqsim.process.equipment.separator.Separator; import neqsim.process.mechanicaldesign.MechanicalDesign; +import neqsim.process.mechanicaldesign.MechanicalDesignMarginResult; /** *

@@ -14,6 +15,7 @@ public class PressureVesselDesignStandard extends DesignStandard { /** Serialization version UID. */ private static final long serialVersionUID = 1000; + private final MechanicalDesignMarginResult safetyMargins; /** *

@@ -25,6 +27,7 @@ public class PressureVesselDesignStandard extends DesignStandard { */ public PressureVesselDesignStandard(String name, MechanicalDesign equipmentInn) { super(name, equipmentInn); + safetyMargins = computeSafetyMargins(); } /** @@ -48,23 +51,27 @@ public double calcWallThickness() { wallT = equipment.getMaxOperationPressure() / 10.0 * separator.getInternalDiameter() * 1e3 / (2.0 * maxAllowableStress * jointEfficiency - 1.2 * equipment.getMaxOperationPressure() / 10.0) - + equipment.getCorrosionAllowanse(); + + equipment.getCorrosionAllowance(); } else if (standardName.equals("BS 5500 - Pressure Vessel")) { wallT = equipment.getMaxOperationPressure() / 10.0 * separator.getInternalDiameter() * 1e3 - / (2.0 * maxAllowableStress - jointEfficiency / 10.0) + equipment.getCorrosionAllowanse(); + / (2.0 * maxAllowableStress - jointEfficiency / 10.0) + equipment.getCorrosionAllowance(); } else if (standardName.equals("European Code")) { wallT = equipment.getMaxOperationPressure() / 10.0 * separator.getInternalDiameter() / 2.0 * 1e3 / (2.0 * maxAllowableStress * jointEfficiency - 0.2 * equipment.getMaxOperationPressure() / 10.0) - + equipment.getCorrosionAllowanse(); + + equipment.getCorrosionAllowance(); } else { wallT = equipment.getMaxOperationPressure() / 10.0 * separator.getInternalDiameter() / 2.0 * 1e3 / (2.0 * maxAllowableStress * jointEfficiency - 0.2 * equipment.getMaxOperationPressure() / 10.0) - + equipment.getCorrosionAllowanse(); + + equipment.getCorrosionAllowance(); } return wallT / 1000.0; // return wall thickness in meter } + + public MechanicalDesignMarginResult getSafetyMargins() { + return safetyMargins; + } } diff --git a/src/main/java/neqsim/process/mechanicaldesign/designstandards/SeparatorDesignStandard.java b/src/main/java/neqsim/process/mechanicaldesign/designstandards/SeparatorDesignStandard.java index a8e2320f86..575747c61f 100644 --- a/src/main/java/neqsim/process/mechanicaldesign/designstandards/SeparatorDesignStandard.java +++ b/src/main/java/neqsim/process/mechanicaldesign/designstandards/SeparatorDesignStandard.java @@ -4,6 +4,7 @@ import org.apache.logging.log4j.Logger; import neqsim.process.equipment.separator.SeparatorInterface; import neqsim.process.mechanicaldesign.MechanicalDesign; +import neqsim.process.mechanicaldesign.MechanicalDesignMarginResult; /** *

@@ -44,6 +45,7 @@ public void setFg(double Fg) { double gasLoadFactor = 0.11; private double Fg = 0.8; private double volumetricDesignFactor = 1.0; + private final MechanicalDesignMarginResult safetyMargins; /** *

@@ -55,13 +57,14 @@ public void setFg(double Fg) { */ public SeparatorDesignStandard(String name, MechanicalDesign equipmentInn) { super(name, equipmentInn); + safetyMargins = computeSafetyMargins(); try (neqsim.util.database.NeqSimProcessDesignDataBase database = new neqsim.util.database.NeqSimProcessDesignDataBase()) { java.sql.ResultSet dataSet = null; try { dataSet = database.getResultSet( ("SELECT * FROM technicalrequirements_process WHERE EQUIPMENTTYPE='Separator' AND Company='" - + standardName + "'")); + + resolveCompanyIdentifier() + "'")); while (dataSet.next()) { String specName = dataSet.getString("SPECIFICATION"); if (specName.equals("GasLoadFactor")) { @@ -163,4 +166,16 @@ public double getLiquidRetentionTime(String name, MechanicalDesign equipmentInn) } return retTime; } + + public MechanicalDesignMarginResult getSafetyMargins() { + return safetyMargins; + } + + private String resolveCompanyIdentifier() { + String identifier = equipment != null ? equipment.getCompanySpecificDesignStandards() : null; + if (identifier == null || identifier.isEmpty()) { + return standardName; + } + return identifier; + } } diff --git a/src/main/java/neqsim/process/mechanicaldesign/valve/SafetyValveMechanicalDesign.java b/src/main/java/neqsim/process/mechanicaldesign/valve/SafetyValveMechanicalDesign.java index f51b28a561..1c991cf0c2 100644 --- a/src/main/java/neqsim/process/mechanicaldesign/valve/SafetyValveMechanicalDesign.java +++ b/src/main/java/neqsim/process/mechanicaldesign/valve/SafetyValveMechanicalDesign.java @@ -1,8 +1,18 @@ package neqsim.process.mechanicaldesign.valve; +import java.io.Serializable; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; import neqsim.process.equipment.ProcessEquipmentInterface; import neqsim.process.equipment.stream.StreamInterface; import neqsim.process.equipment.valve.SafetyValve; +import neqsim.process.equipment.valve.SafetyValve.FluidService; +import neqsim.process.equipment.valve.SafetyValve.RelievingScenario; +import neqsim.process.equipment.valve.SafetyValve.SizingStandard; +import neqsim.thermo.system.SystemInterface; /** * Mechanical design for safety valves based on API 520 gas sizing. @@ -12,6 +22,10 @@ public class SafetyValveMechanicalDesign extends ValveMechanicalDesign { private static final long serialVersionUID = 1L; private double orificeArea = 0.0; // m^2 + private double controllingOrificeArea = 0.0; + private String controllingScenarioName; + private final Map strategies; + private Map scenarioResults = new LinkedHashMap<>(); /** *

@@ -22,6 +36,11 @@ public class SafetyValveMechanicalDesign extends ValveMechanicalDesign { */ public SafetyValveMechanicalDesign(ProcessEquipmentInterface equipment) { super(equipment); + strategies = new EnumMap<>(FluidService.class); + strategies.put(FluidService.GAS, new GasSizingStrategy()); + strategies.put(FluidService.LIQUID, new LiquidSizingStrategy()); + strategies.put(FluidService.MULTIPHASE, new MultiphaseSizingStrategy()); + strategies.put(FluidService.FIRE, new FireCaseSizingStrategy()); } /** @@ -48,25 +67,78 @@ public double calcGasOrificeAreaAPI520(double massFlow, double relievingPressure return numerator / denominator; } + private double calcGasOrificeAreaISO4126(double massFlow, double relievingPressure, + double relievingTemperature, double z, double molecularWeight, double k, double kd, double kb, + double kw) { + // ISO 4126 uses the same base expression as API 520 but typically with a lower discharge + // coefficient. + return calcGasOrificeAreaAPI520(massFlow, relievingPressure, relievingTemperature, z, + molecularWeight, k, kd, kb, kw); + } + + private double calcLiquidOrificeArea(double massFlow, double relievingPressure, double backPressure, + double density, double kd, double kb, double kw) { + double deltaP = Math.max(relievingPressure - backPressure, 1.0); + double denominator = kd * kb * kw * Math.sqrt(2.0 * density * deltaP); + return massFlow / denominator; + } + + private double calcHemMultiphaseOrificeArea(double massFlow, double relievingPressure, + double backPressure, double density, double kd, double kb, double kw) { + double deltaP = Math.max(relievingPressure - backPressure, 1.0); + double denominator = kd * kb * kw * Math.sqrt(density * deltaP); + return massFlow / denominator; + } + /** {@inheritDoc} */ @Override public void calcDesign() { SafetyValve valve = (SafetyValve) getProcessEquipment(); - StreamInterface inlet = valve.getInletStream(); + valve.ensureDefaultScenario(); + + Map newResults = new LinkedHashMap<>(); + double maxArea = 0.0; + String maxScenario = null; + double activeArea = 0.0; + String activeScenarioName = valve.getActiveScenarioName().orElse(null); + + for (RelievingScenario scenario : valve.getRelievingScenarios()) { + SizingContext context = buildContext(valve, scenario); + SafetyValveSizingStrategy strategy = strategies + .getOrDefault(scenario.getFluidService(), strategies.get(FluidService.GAS)); + double area = strategy.calculateOrificeArea(context); + boolean isActive = scenario.getName().equals(activeScenarioName) + || (activeScenarioName == null && newResults.isEmpty()); + if (isActive) { + activeArea = area; + activeScenarioName = scenario.getName(); + } + + if (area > maxArea) { + maxArea = area; + maxScenario = scenario.getName(); + } - double massFlow = inlet.getThermoSystem().getFlowRate("kg/sec"); - double relievingPressure = valve.getPressureSpec() * 1e5; // convert bar to Pa - double relievingTemperature = inlet.getTemperature(); - double z = inlet.getThermoSystem().getZ(); - double mw = inlet.getThermoSystem().getMolarMass(); // kg/mol - double k = inlet.getThermoSystem().getGamma(); + SafetyValveScenarioResult result = new SafetyValveScenarioResult(scenario.getName(), + scenario.getFluidService(), scenario.getSizingStandard(), area, context.setPressurePa, + context.relievingPressurePa, context.overpressureMarginPa, context.backPressurePa, + isActive, false); + newResults.put(scenario.getName(), result); + } - double kd = 0.975; - double kb = 1.0; - double kw = 1.0; + // Update controlling flags now that maximum is known + if (maxScenario != null) { + SafetyValveScenarioResult controlling = newResults.get(maxScenario); + if (controlling != null) { + newResults.put(maxScenario, + controlling.markControlling(true)); + } + } - orificeArea = calcGasOrificeAreaAPI520(massFlow, relievingPressure, relievingTemperature, z, mw, - k, kd, kb, kw); + this.scenarioResults = newResults; + this.orificeArea = activeArea; + this.controllingOrificeArea = maxArea; + this.controllingScenarioName = maxScenario; } /** @@ -77,4 +149,338 @@ public void calcDesign() { public double getOrificeArea() { return orificeArea; } + + /** + * @return the largest required orifice area across all configured scenarios + */ + public double getControllingOrificeArea() { + return controllingOrificeArea; + } + + /** + * @return the name of the scenario requiring the maximum area, or {@code null} if none + */ + public String getControllingScenarioName() { + return controllingScenarioName; + } + + /** + * Immutable view of scenario sizing results keyed by scenario name. + * + * @return map of results + */ + public Map getScenarioResults() { + return Collections.unmodifiableMap(scenarioResults); + } + + /** + * Convenience accessor returning a structured report suitable for higher-level analyzers. + * + * @return list of report entries preserving scenario insertion order + */ + public Map getScenarioReports() { + Map report = new LinkedHashMap<>(); + for (Map.Entry entry : scenarioResults.entrySet()) { + report.put(entry.getKey(), new SafetyValveScenarioReport(entry.getValue())); + } + return Collections.unmodifiableMap(report); + } + + private SizingContext buildContext(SafetyValve valve, RelievingScenario scenario) { + StreamInterface stream = Optional.ofNullable(scenario.getRelievingStream()) + .orElse(valve.getInletStream()); + if (stream == null) { + throw new IllegalStateException("Safety valve requires a stream for sizing calculations"); + } + + SystemInterface system = stream.getThermoSystem(); + double massFlow = system.getFlowRate("kg/sec"); + double relievingTemperature = system.getTemperature(); + double z = system.getZ(); + double mw = system.getMolarMass(); + double k = system.getGamma(); + double density = system.getDensity("kg/m3"); + + double setPressureBar = scenario.getSetPressure().orElse(valve.getPressureSpec()); + double setPressurePa = setPressureBar * 1.0e5; + double relievingPressurePa = setPressurePa * (1.0 + scenario.getOverpressureFraction()); + double overpressureMarginPa = relievingPressurePa - setPressurePa; + double backPressurePa = scenario.getBackPressure() * 1.0e5; + + double kd = scenario.getDischargeCoefficient() + .orElseGet(() -> defaultDischargeCoefficient(scenario)); + double kb = scenario.getBackPressureCorrection().orElse(1.0); + double kw = scenario.getInstallationCorrection().orElse(1.0); + + return new SizingContext(scenario, stream, massFlow, relievingTemperature, z, mw, k, density, + setPressurePa, relievingPressurePa, overpressureMarginPa, backPressurePa, kd, kb, kw); + } + + private double defaultDischargeCoefficient(RelievingScenario scenario) { + if (scenario.getFluidService() == FluidService.GAS + || scenario.getFluidService() == FluidService.FIRE) { + return scenario.getSizingStandard() == SizingStandard.ISO_4126 ? 0.9 : 0.975; + } + if (scenario.getFluidService() == FluidService.MULTIPHASE) { + return 0.85; + } + // Liquid service + return 0.62; + } + + /** Container holding data shared with the sizing strategies. */ + static final class SizingContext { + final RelievingScenario scenario; + final StreamInterface stream; + final double massFlow; + final double relievingTemperature; + final double z; + final double molecularWeight; + final double k; + final double density; + final double setPressurePa; + final double relievingPressurePa; + final double overpressureMarginPa; + final double backPressurePa; + final double dischargeCoefficient; + final double backPressureCorrection; + final double installationCorrection; + + SizingContext(RelievingScenario scenario, StreamInterface stream, double massFlow, + double relievingTemperature, double z, double molecularWeight, double k, double density, + double setPressurePa, double relievingPressurePa, double overpressureMarginPa, + double backPressurePa, double dischargeCoefficient, double backPressureCorrection, + double installationCorrection) { + this.scenario = scenario; + this.stream = stream; + this.massFlow = massFlow; + this.relievingTemperature = relievingTemperature; + this.z = z; + this.molecularWeight = molecularWeight; + this.k = k; + this.density = density; + this.setPressurePa = setPressurePa; + this.relievingPressurePa = relievingPressurePa; + this.overpressureMarginPa = overpressureMarginPa; + this.backPressurePa = backPressurePa; + this.dischargeCoefficient = dischargeCoefficient; + this.backPressureCorrection = backPressureCorrection; + this.installationCorrection = installationCorrection; + } + } + + private interface SafetyValveSizingStrategy extends Serializable { + double calculateOrificeArea(SizingContext context); + } + + private class GasSizingStrategy implements SafetyValveSizingStrategy { + @Override + public double calculateOrificeArea(SizingContext context) { + if (context.scenario.getSizingStandard() == SizingStandard.ISO_4126) { + return calcGasOrificeAreaISO4126(context.massFlow, context.relievingPressurePa, + context.relievingTemperature, context.z, context.molecularWeight, context.k, + context.dischargeCoefficient, context.backPressureCorrection, + context.installationCorrection); + } + return calcGasOrificeAreaAPI520(context.massFlow, context.relievingPressurePa, + context.relievingTemperature, context.z, context.molecularWeight, context.k, + context.dischargeCoefficient, context.backPressureCorrection, + context.installationCorrection); + } + } + + private class LiquidSizingStrategy implements SafetyValveSizingStrategy { + @Override + public double calculateOrificeArea(SizingContext context) { + double kd = context.dischargeCoefficient; + double kb = context.backPressureCorrection; + double kw = context.installationCorrection; + return calcLiquidOrificeArea(context.massFlow, context.relievingPressurePa, + context.backPressurePa, context.density, kd, kb, kw); + } + } + + private class MultiphaseSizingStrategy implements SafetyValveSizingStrategy { + @Override + public double calculateOrificeArea(SizingContext context) { + double kd = context.dischargeCoefficient; + double kb = context.backPressureCorrection; + double kw = context.installationCorrection; + return calcHemMultiphaseOrificeArea(context.massFlow, context.relievingPressurePa, + context.backPressurePa, context.density, kd, kb, kw); + } + } + + private class FireCaseSizingStrategy extends GasSizingStrategy { + private static final double FIRE_MARGIN_FACTOR = 1.1; + + @Override + public double calculateOrificeArea(SizingContext context) { + double baseArea = super.calculateOrificeArea(context); + return baseArea * FIRE_MARGIN_FACTOR; + } + } + + /** + * Detailed sizing outcome for a single scenario. + */ + public static final class SafetyValveScenarioResult { + private final String scenarioName; + private final FluidService fluidService; + private final SizingStandard sizingStandard; + private final double requiredOrificeArea; + private final double setPressurePa; + private final double relievingPressurePa; + private final double overpressureMarginPa; + private final double backPressurePa; + private final boolean activeScenario; + private final boolean controllingScenario; + + SafetyValveScenarioResult(String scenarioName, FluidService fluidService, + SizingStandard sizingStandard, double requiredOrificeArea, double setPressurePa, + double relievingPressurePa, double overpressureMarginPa, double backPressurePa, + boolean activeScenario, boolean controllingScenario) { + this.scenarioName = scenarioName; + this.fluidService = fluidService; + this.sizingStandard = sizingStandard; + this.requiredOrificeArea = requiredOrificeArea; + this.setPressurePa = setPressurePa; + this.relievingPressurePa = relievingPressurePa; + this.overpressureMarginPa = overpressureMarginPa; + this.backPressurePa = backPressurePa; + this.activeScenario = activeScenario; + this.controllingScenario = controllingScenario; + } + + SafetyValveScenarioResult markControlling(boolean controlling) { + return new SafetyValveScenarioResult(scenarioName, fluidService, sizingStandard, + requiredOrificeArea, setPressurePa, relievingPressurePa, overpressureMarginPa, + backPressurePa, activeScenario, controlling); + } + + public String getScenarioName() { + return scenarioName; + } + + public FluidService getFluidService() { + return fluidService; + } + + public SizingStandard getSizingStandard() { + return sizingStandard; + } + + public double getRequiredOrificeArea() { + return requiredOrificeArea; + } + + public double getSetPressurePa() { + return setPressurePa; + } + + public double getSetPressureBar() { + return setPressurePa / 1.0e5; + } + + public double getRelievingPressurePa() { + return relievingPressurePa; + } + + public double getRelievingPressureBar() { + return relievingPressurePa / 1.0e5; + } + + public double getOverpressureMarginPa() { + return overpressureMarginPa; + } + + public double getOverpressureMarginBar() { + return overpressureMarginPa / 1.0e5; + } + + public double getBackPressurePa() { + return backPressurePa; + } + + public double getBackPressureBar() { + return backPressurePa / 1.0e5; + } + + public boolean isActiveScenario() { + return activeScenario; + } + + public boolean isControllingScenario() { + return controllingScenario; + } + } + + /** + * Lightweight reporting view for consumption by analysis tools. + */ + public static final class SafetyValveScenarioReport { + private final String scenarioName; + private final FluidService fluidService; + private final SizingStandard sizingStandard; + private final double requiredOrificeArea; + private final double setPressureBar; + private final double relievingPressureBar; + private final double overpressureMarginBar; + private final double backPressureBar; + private final boolean activeScenario; + private final boolean controllingScenario; + + SafetyValveScenarioReport(SafetyValveScenarioResult result) { + this.scenarioName = result.getScenarioName(); + this.fluidService = result.getFluidService(); + this.sizingStandard = result.getSizingStandard(); + this.requiredOrificeArea = result.getRequiredOrificeArea(); + this.setPressureBar = result.getSetPressureBar(); + this.relievingPressureBar = result.getRelievingPressureBar(); + this.overpressureMarginBar = result.getOverpressureMarginBar(); + this.backPressureBar = result.getBackPressureBar(); + this.activeScenario = result.isActiveScenario(); + this.controllingScenario = result.isControllingScenario(); + } + + public String getScenarioName() { + return scenarioName; + } + + public FluidService getFluidService() { + return fluidService; + } + + public SizingStandard getSizingStandard() { + return sizingStandard; + } + + public double getRequiredOrificeArea() { + return requiredOrificeArea; + } + + public double getSetPressureBar() { + return setPressureBar; + } + + public double getRelievingPressureBar() { + return relievingPressureBar; + } + + public double getOverpressureMarginBar() { + return overpressureMarginBar; + } + + public double getBackPressureBar() { + return backPressureBar; + } + + public boolean isActiveScenario() { + return activeScenario; + } + + public boolean isControllingScenario() { + return controllingScenario; + } + } } diff --git a/src/main/java/neqsim/process/safety/DisposalNetwork.java b/src/main/java/neqsim/process/safety/DisposalNetwork.java new file mode 100644 index 0000000000..bedbc82122 --- /dev/null +++ b/src/main/java/neqsim/process/safety/DisposalNetwork.java @@ -0,0 +1,149 @@ +package neqsim.process.safety; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import neqsim.process.equipment.flare.Flare; +import neqsim.process.equipment.flare.dto.FlarePerformanceDTO; +import neqsim.process.safety.ProcessSafetyLoadCase.ReliefSourceLoad; +import neqsim.process.safety.dto.CapacityAlertDTO; +import neqsim.process.safety.dto.DisposalLoadCaseResultDTO; +import neqsim.process.safety.dto.DisposalNetworkSummaryDTO; + +/** + * Aggregates loads from multiple relief sources into disposal units and evaluates performance for + * each simultaneous load case. + */ +public class DisposalNetwork implements Serializable { + private static final long serialVersionUID = 1L; + + private final Map disposalUnits = new LinkedHashMap<>(); + private final Map sourceToDisposal = new LinkedHashMap<>(); + + public void registerDisposalUnit(Flare flare) { + Objects.requireNonNull(flare, "flare"); + disposalUnits.put(flare.getName(), flare); + } + + public void mapSourceToDisposal(String sourceId, String disposalUnitName) { + if (!disposalUnits.containsKey(disposalUnitName)) { + throw new IllegalArgumentException( + "Disposal unit not registered for mapping: " + disposalUnitName); + } + sourceToDisposal.put(sourceId, disposalUnitName); + } + + public DisposalNetworkSummaryDTO evaluate(List loadCases) { + if (loadCases == null || loadCases.isEmpty()) { + return new DisposalNetworkSummaryDTO(Collections.emptyList(), 0.0, 0.0, + Collections.emptyList()); + } + + List results = new ArrayList<>(); + List alerts = new ArrayList<>(); + double maxHeatDutyMW = 0.0; + double maxRadiationDistance = 0.0; + + for (ProcessSafetyLoadCase loadCase : loadCases) { + DisposalLoadCaseResultDTO dto = evaluateLoadCase(loadCase); + results.add(dto); + maxHeatDutyMW = Math.max(maxHeatDutyMW, dto.getTotalHeatDutyMW()); + maxRadiationDistance = Math.max(maxRadiationDistance, dto.getMaxRadiationDistanceM()); + alerts.addAll(dto.getAlerts()); + } + + return new DisposalNetworkSummaryDTO(results, maxHeatDutyMW, maxRadiationDistance, alerts); + } + + private DisposalLoadCaseResultDTO evaluateLoadCase(ProcessSafetyLoadCase loadCase) { + Map aggregated = new LinkedHashMap<>(); + + for (Map.Entry entry : loadCase.getReliefLoads().entrySet()) { + String sourceId = entry.getKey(); + String unitName = sourceToDisposal.get(sourceId); + if (unitName == null) { + continue; // source not mapped + } + AggregatedLoad load = aggregated.computeIfAbsent(unitName, k -> new AggregatedLoad()); + ReliefSourceLoad sourceLoad = entry.getValue(); + double massRate = sourceLoad.getMassRateKgS(); + load.massRate += massRate; + Double heatDuty = sourceLoad.getHeatDutyW(); + if (heatDuty != null) { + load.specifiedHeatDuty += heatDuty; + } else { + load.massWithoutHeatDuty += massRate; + } + Double molarRate = sourceLoad.getMolarRateMoleS(); + if (molarRate != null) { + load.specifiedMolarRate += molarRate; + } else { + load.massWithoutMolarRate += massRate; + } + } + + Map performance = new LinkedHashMap<>(); + List alerts = new ArrayList<>(); + double totalHeatDutyW = 0.0; + double maxRadiationDistance = 0.0; + + for (Map.Entry entry : aggregated.entrySet()) { + Flare flare = disposalUnits.get(entry.getKey()); + if (flare == null) { + continue; + } + AggregatedLoad load = entry.getValue(); + FlarePerformanceDTO basePerformance = flare.getPerformanceSummary(); + double heatDuty = load.specifiedHeatDuty; + if (load.massWithoutHeatDuty > 0.0) { + heatDuty += safeRatio(basePerformance.getHeatDutyW(), basePerformance.getMassRateKgS()) + * load.massWithoutHeatDuty; + } + if (heatDuty <= 0.0) { + heatDuty = basePerformance.getHeatDutyW() + * safeRatio(load.massRate, basePerformance.getMassRateKgS()); + } + double molarRate = load.specifiedMolarRate; + if (load.massWithoutMolarRate > 0.0) { + molarRate += safeRatio(basePerformance.getMolarRateMoleS(), + basePerformance.getMassRateKgS()) * load.massWithoutMolarRate; + } + if (molarRate <= 0.0) { + molarRate = basePerformance.getMolarRateMoleS() + * safeRatio(load.massRate, basePerformance.getMassRateKgS()); + } + + FlarePerformanceDTO dto = flare.getPerformanceSummary(loadCase.getName(), heatDuty, + load.massRate, molarRate); + performance.put(entry.getKey(), dto); + totalHeatDutyW += dto.getHeatDutyW(); + maxRadiationDistance = Math.max(maxRadiationDistance, dto.getDistanceTo4kWm2()); + if (dto.isOverloaded()) { + alerts.add(new CapacityAlertDTO(loadCase.getName(), entry.getKey(), + "Disposal unit capacity exceeded")); + } + } + + return new DisposalLoadCaseResultDTO(loadCase.getName(), performance, totalHeatDutyW * 1.0e-6, + maxRadiationDistance, alerts); + } + + private double safeRatio(double numerator, double denominator) { + if (!Double.isFinite(denominator) || Math.abs(denominator) < 1.0e-12) { + return 0.0; + } + return numerator / denominator; + } + + private static class AggregatedLoad { + double massRate; + double specifiedMolarRate; + double specifiedHeatDuty; + double massWithoutMolarRate; + double massWithoutHeatDuty; + } +} diff --git a/src/main/java/neqsim/process/safety/ProcessSafetyAnalysisSummary.java b/src/main/java/neqsim/process/safety/ProcessSafetyAnalysisSummary.java new file mode 100644 index 0000000000..43702fa0d2 --- /dev/null +++ b/src/main/java/neqsim/process/safety/ProcessSafetyAnalysisSummary.java @@ -0,0 +1,78 @@ +package neqsim.process.safety; + +import java.io.Serializable; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Summary of a {@link ProcessSafetyScenario} evaluation. + */ +public final class ProcessSafetyAnalysisSummary implements Serializable { + private static final long serialVersionUID = 1L; + + private final String scenarioName; + private final Set affectedUnits; + private final String conditionMonitorReport; + private final Map conditionMessages; + private final Map unitKpis; + + public ProcessSafetyAnalysisSummary(String scenarioName, Set affectedUnits, + String conditionMonitorReport, Map conditionMessages, + Map unitKpis) { + this.scenarioName = Objects.requireNonNull(scenarioName, "scenarioName"); + this.affectedUnits = Collections.unmodifiableSet(new LinkedHashSet<>(affectedUnits)); + this.conditionMonitorReport = conditionMonitorReport == null ? "" : conditionMonitorReport; + this.conditionMessages = Collections.unmodifiableMap(new LinkedHashMap<>(conditionMessages)); + this.unitKpis = Collections.unmodifiableMap(new LinkedHashMap<>(unitKpis)); + } + + public String getScenarioName() { + return scenarioName; + } + + public Set getAffectedUnits() { + return affectedUnits; + } + + public String getConditionMonitorReport() { + return conditionMonitorReport; + } + + public Map getConditionMessages() { + return conditionMessages; + } + + public Map getUnitKpis() { + return unitKpis; + } + + /** Snapshot of key KPIs for a unit. */ + public static final class UnitKpiSnapshot implements Serializable { + private static final long serialVersionUID = 1L; + private final double massBalance; + private final double pressure; + private final double temperature; + + public UnitKpiSnapshot(double massBalance, double pressure, double temperature) { + this.massBalance = massBalance; + this.pressure = pressure; + this.temperature = temperature; + } + + public double getMassBalance() { + return massBalance; + } + + public double getPressure() { + return pressure; + } + + public double getTemperature() { + return temperature; + } + } +} diff --git a/src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java b/src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java new file mode 100644 index 0000000000..25eecae084 --- /dev/null +++ b/src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java @@ -0,0 +1,147 @@ +package neqsim.process.safety; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import neqsim.process.equipment.flare.Flare; +import neqsim.process.safety.dto.DisposalNetworkSummaryDTO; +import neqsim.process.processmodel.ProcessSystem; +import neqsim.process.equipment.ProcessEquipmentInterface; + +/** + * High level helper coordinating load case evaluation for disposal networks. + */ +public class ProcessSafetyAnalyzer implements Serializable { + private static final long serialVersionUID = 1L; + + private final DisposalNetwork disposalNetwork = new DisposalNetwork(); + private final List loadCases = new ArrayList<>(); + private final ProcessSystem baseProcessSystem; + private final ProcessSafetyResultRepository resultRepository; + + public ProcessSafetyAnalyzer() { + this(null, null); + } + + public ProcessSafetyAnalyzer(ProcessSystem baseProcessSystem) { + this(baseProcessSystem, null); + } + + public ProcessSafetyAnalyzer(ProcessSystem baseProcessSystem, + ProcessSafetyResultRepository resultRepository) { + this.baseProcessSystem = baseProcessSystem == null ? null : baseProcessSystem.copy(); + this.resultRepository = resultRepository; + } + + public void registerDisposalUnit(Flare flare) { + disposalNetwork.registerDisposalUnit(flare); + } + + public void mapSourceToDisposal(String sourceId, String disposalUnitName) { + disposalNetwork.mapSourceToDisposal(sourceId, disposalUnitName); + } + + public void addLoadCase(ProcessSafetyLoadCase loadCase) { + loadCases.add(loadCase); + } + + public List getLoadCases() { + return Collections.unmodifiableList(loadCases); + } + + public DisposalNetworkSummaryDTO analyze() { + return disposalNetwork.evaluate(loadCases); + } + + public ProcessSafetyAnalysisSummary analyze(ProcessSafetyScenario scenario) { + Objects.requireNonNull(scenario, "scenario"); + ProcessSystem referenceSystem = requireBaseProcessSystem(); + ProcessSystem scenarioSystem = referenceSystem.copy(); + scenario.applyTo(scenarioSystem); + + ProcessSafetyAnalysisSummary summary = buildSummary(scenario, referenceSystem, scenarioSystem); + if (resultRepository != null) { + resultRepository.save(summary); + } + return summary; + } + + public List analyze( + Collection scenarios) { + Objects.requireNonNull(scenarios, "scenarios"); + List summaries = new ArrayList<>(); + for (ProcessSafetyScenario scenario : scenarios) { + summaries.add(analyze(scenario)); + } + return summaries; + } + + private ProcessSystem requireBaseProcessSystem() { + if (baseProcessSystem == null) { + throw new IllegalStateException("ProcessSystem must be provided to analyse scenarios."); + } + return baseProcessSystem; + } + + private ProcessSafetyAnalysisSummary buildSummary(ProcessSafetyScenario scenario, + ProcessSystem referenceSystem, ProcessSystem scenarioSystem) { + Set affectedUnits = new LinkedHashSet<>(scenario.getTargetUnits()); + Map conditionMessages = new LinkedHashMap<>(); + Map unitKpis = new LinkedHashMap<>(); + + for (String unitName : affectedUnits) { + ProcessEquipmentInterface scenarioUnit = scenarioSystem.getUnit(unitName); + if (scenarioUnit == null) { + continue; + } + ProcessEquipmentInterface referenceUnit = referenceSystem.getUnit(unitName); + if (referenceUnit != null) { + try { + scenarioUnit.runConditionAnalysis(referenceUnit); + } catch (RuntimeException ex) { + // Ignore condition analysis failures for individual units while still capturing KPIs. + } + } + + String message = scenarioUnit.getConditionAnalysisMessage(); + if (message == null) { + message = ""; + } + conditionMessages.put(unitName, message); + + double massBalance = captureSafely(() -> scenarioUnit.getMassBalance("kg/s")); + double pressure = captureSafely(scenarioUnit::getPressure); + double temperature = captureSafely(scenarioUnit::getTemperature); + unitKpis.put(unitName, + new ProcessSafetyAnalysisSummary.UnitKpiSnapshot(massBalance, pressure, temperature)); + } + + String conditionReport = conditionMessages.values().stream() + .filter(message -> message != null && !message.isEmpty()) + .collect(Collectors.joining(System.lineSeparator())); + + return new ProcessSafetyAnalysisSummary(scenario.getName(), affectedUnits, conditionReport, + conditionMessages, unitKpis); + } + + private double captureSafely(DoubleSupplierWithException supplier) { + try { + return supplier.getAsDouble(); + } catch (RuntimeException ex) { + return Double.NaN; + } + } + + @FunctionalInterface + private interface DoubleSupplierWithException { + double getAsDouble(); + } +} diff --git a/src/main/java/neqsim/process/safety/ProcessSafetyLoadCase.java b/src/main/java/neqsim/process/safety/ProcessSafetyLoadCase.java new file mode 100644 index 0000000000..f83ace9d94 --- /dev/null +++ b/src/main/java/neqsim/process/safety/ProcessSafetyLoadCase.java @@ -0,0 +1,61 @@ +package neqsim.process.safety; + +import java.io.Serializable; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Describes a simultaneous relief load case generated by the process safety analyser. + */ +public class ProcessSafetyLoadCase implements Serializable { + private static final long serialVersionUID = 1L; + + private final String name; + private final Map reliefLoads = new LinkedHashMap<>(); + + public ProcessSafetyLoadCase(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void addReliefSource(String sourceId, ReliefSourceLoad load) { + reliefLoads.put(sourceId, load); + } + + public Map getReliefLoads() { + return Collections.unmodifiableMap(reliefLoads); + } + + /** + * Relief load contribution for a source into the disposal network. + */ + public static class ReliefSourceLoad implements Serializable { + private static final long serialVersionUID = 1L; + + private final double massRateKgS; + private final Double heatDutyW; + private final Double molarRateMoleS; + + public ReliefSourceLoad(double massRateKgS, Double heatDutyW, Double molarRateMoleS) { + this.massRateKgS = massRateKgS; + this.heatDutyW = heatDutyW; + this.molarRateMoleS = molarRateMoleS; + } + + public double getMassRateKgS() { + return massRateKgS; + } + + public Double getHeatDutyW() { + return heatDutyW; + } + + public Double getMolarRateMoleS() { + return molarRateMoleS; + } + } +} diff --git a/src/main/java/neqsim/process/safety/ProcessSafetyResultRepository.java b/src/main/java/neqsim/process/safety/ProcessSafetyResultRepository.java new file mode 100644 index 0000000000..edb141d804 --- /dev/null +++ b/src/main/java/neqsim/process/safety/ProcessSafetyResultRepository.java @@ -0,0 +1,24 @@ +package neqsim.process.safety; + +import java.util.List; + +/** + * Optional persistence contract for safety analysis results. + */ +public interface ProcessSafetyResultRepository { + /** + * Persist a safety analysis summary. + * + * @param summary summary to persist + */ + void save(ProcessSafetyAnalysisSummary summary); + + /** + * Retrieve stored results. + * + * @return list of stored summaries (may be empty) + */ + default List findAll() { + return java.util.Collections.emptyList(); + } +} diff --git a/src/main/java/neqsim/process/safety/ProcessSafetyScenario.java b/src/main/java/neqsim/process/safety/ProcessSafetyScenario.java new file mode 100644 index 0000000000..3d5d0e2ba9 --- /dev/null +++ b/src/main/java/neqsim/process/safety/ProcessSafetyScenario.java @@ -0,0 +1,212 @@ +package neqsim.process.safety; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import neqsim.process.controllerdevice.ControllerDeviceInterface; +import neqsim.process.equipment.ProcessEquipmentBaseClass; +import neqsim.process.equipment.ProcessEquipmentInterface; +import neqsim.process.processmodel.ProcessSystem; + +/** + * Immutable description of a process safety scenario. + * + *

The scenario captures a set of perturbations such as blocked outlets or loss of utilities + * along with optional custom manipulators. The perturbations are applied to a scenario specific + * copy of a {@link ProcessSystem} prior to execution.

+ */ +public final class ProcessSafetyScenario implements Serializable { + private static final long serialVersionUID = 1L; + private static final Logger logger = LogManager.getLogger(ProcessSafetyScenario.class); + + private final String name; + private final List blockedOutletUnits; + private final List utilityLossUnits; + private final Map controllerSetPointOverrides; + private final Map> customManipulators; + + private ProcessSafetyScenario(Builder builder) { + this.name = builder.name; + this.blockedOutletUnits = Collections.unmodifiableList(new ArrayList<>(builder.blockedOutletUnits)); + this.utilityLossUnits = Collections.unmodifiableList(new ArrayList<>(builder.utilityLossUnits)); + this.controllerSetPointOverrides = + Collections.unmodifiableMap(new LinkedHashMap<>(builder.controllerSetPointOverrides)); + this.customManipulators = Collections.unmodifiableMap(new LinkedHashMap<>(builder.customManipulators)); + } + + public String getName() { + return name; + } + + public List getBlockedOutletUnits() { + return blockedOutletUnits; + } + + public List getUtilityLossUnits() { + return utilityLossUnits; + } + + public Map getControllerSetPointOverrides() { + return controllerSetPointOverrides; + } + + public Map> getCustomManipulators() { + return customManipulators; + } + + /** + * Apply the configured perturbations to the provided {@link ProcessSystem} instance. + * + * @param processSystem process system to manipulate + */ + public void applyTo(ProcessSystem processSystem) { + Objects.requireNonNull(processSystem, "processSystem"); + + for (String unitName : blockedOutletUnits) { + ProcessEquipmentInterface unit = processSystem.getUnit(unitName); + if (unit == null) { + logger.warn("Unable to block outlet for unit '{}' because it was not found in scenario '{}'.", + unitName, name); + continue; + } + unit.setSpecification("BLOCKED_OUTLET"); + unit.setRegulatorOutSignal(0.0); + deactivateController(unit); + markUnitInactive(unit); + } + + for (String unitName : utilityLossUnits) { + ProcessEquipmentInterface unit = processSystem.getUnit(unitName); + if (unit == null) { + logger.warn("Unable to mark utility loss for unit '{}' because it was not found in scenario '{}'.", + unitName, name); + continue; + } + unit.setSpecification("UTILITY_LOSS"); + unit.setRegulatorOutSignal(0.0); + deactivateController(unit); + markUnitInactive(unit); + } + + controllerSetPointOverrides.forEach((unitName, setPoint) -> { + ProcessEquipmentInterface unit = processSystem.getUnit(unitName); + if (unit == null) { + logger.warn("Unable to override controller set point for unit '{}' in scenario '{}' because the" + + " unit was not found.", unitName, name); + return; + } + ControllerDeviceInterface controller = unit.getController(); + if (controller == null) { + logger.warn("Unit '{}' in scenario '{}' has no controller to override.", unitName, name); + return; + } + controller.setControllerSetPoint(setPoint); + }); + + customManipulators.forEach((unitName, manipulator) -> { + ProcessEquipmentInterface unit = processSystem.getUnit(unitName); + if (unit == null) { + logger.warn("Unable to apply custom manipulator for unit '{}' in scenario '{}' because the unit" + + " was not found.", unitName, name); + return; + } + manipulator.accept(unit); + }); + } + + private void deactivateController(ProcessEquipmentInterface unit) { + ControllerDeviceInterface controller = unit.getController(); + if (controller != null && controller.isActive()) { + controller.setActive(false); + } + } + + private void markUnitInactive(ProcessEquipmentInterface unit) { + if (unit instanceof ProcessEquipmentBaseClass) { + ((ProcessEquipmentBaseClass) unit).isActive(false); + } + } + + /** + * Returns the combined set of unit names affected by the scenario. + * + * @return affected unit names + */ + public Set getTargetUnits() { + LinkedHashSet targets = new LinkedHashSet<>(); + targets.addAll(blockedOutletUnits); + targets.addAll(utilityLossUnits); + targets.addAll(controllerSetPointOverrides.keySet()); + targets.addAll(customManipulators.keySet()); + return Collections.unmodifiableSet(targets); + } + + public static Builder builder(String name) { + return new Builder(name); + } + + /** Builder for {@link ProcessSafetyScenario}. */ + public static final class Builder { + private final String name; + private final Set blockedOutletUnits = new LinkedHashSet<>(); + private final Set utilityLossUnits = new LinkedHashSet<>(); + private final Map controllerSetPointOverrides = new LinkedHashMap<>(); + private final Map> customManipulators = + new LinkedHashMap<>(); + + private Builder(String name) { + this.name = Objects.requireNonNull(name, "name"); + } + + public Builder blockOutlet(String unitName) { + Objects.requireNonNull(unitName, "unitName"); + blockedOutletUnits.add(unitName); + return this; + } + + public Builder blockOutlets(Collection unitNames) { + Objects.requireNonNull(unitNames, "unitNames"); + unitNames.stream().filter(Objects::nonNull).forEach(blockedOutletUnits::add); + return this; + } + + public Builder utilityLoss(String unitName) { + Objects.requireNonNull(unitName, "unitName"); + utilityLossUnits.add(unitName); + return this; + } + + public Builder utilityLosses(Collection unitNames) { + Objects.requireNonNull(unitNames, "unitNames"); + unitNames.stream().filter(Objects::nonNull).forEach(utilityLossUnits::add); + return this; + } + + public Builder controllerSetPoint(String unitName, double setPoint) { + Objects.requireNonNull(unitName, "unitName"); + controllerSetPointOverrides.put(unitName, setPoint); + return this; + } + + public Builder customManipulator(String unitName, Consumer manipulator) { + Objects.requireNonNull(unitName, "unitName"); + Objects.requireNonNull(manipulator, "manipulator"); + customManipulators.put(unitName, manipulator); + return this; + } + + public ProcessSafetyScenario build() { + return new ProcessSafetyScenario(this); + } + } +} diff --git a/src/main/java/neqsim/process/safety/dto/CapacityAlertDTO.java b/src/main/java/neqsim/process/safety/dto/CapacityAlertDTO.java new file mode 100644 index 0000000000..3096bdda8b --- /dev/null +++ b/src/main/java/neqsim/process/safety/dto/CapacityAlertDTO.java @@ -0,0 +1,32 @@ +package neqsim.process.safety.dto; + +import java.io.Serializable; + +/** + * Represents a validation alert indicating that a disposal unit is overloaded in a load case. + */ +public class CapacityAlertDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private final String loadCaseName; + private final String disposalUnitName; + private final String message; + + public CapacityAlertDTO(String loadCaseName, String disposalUnitName, String message) { + this.loadCaseName = loadCaseName; + this.disposalUnitName = disposalUnitName; + this.message = message; + } + + public String getLoadCaseName() { + return loadCaseName; + } + + public String getDisposalUnitName() { + return disposalUnitName; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/neqsim/process/safety/dto/DisposalLoadCaseResultDTO.java b/src/main/java/neqsim/process/safety/dto/DisposalLoadCaseResultDTO.java new file mode 100644 index 0000000000..1c085e26b5 --- /dev/null +++ b/src/main/java/neqsim/process/safety/dto/DisposalLoadCaseResultDTO.java @@ -0,0 +1,51 @@ +package neqsim.process.safety.dto; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import neqsim.process.equipment.flare.dto.FlarePerformanceDTO; + +/** + * Result of evaluating a disposal network for a single load case. + */ +public class DisposalLoadCaseResultDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private final String loadCaseName; + private final Map performanceByUnit; + private final double totalHeatDutyMW; + private final double maxRadiationDistanceM; + private final List alerts; + + public DisposalLoadCaseResultDTO(String loadCaseName, + Map performanceByUnit, double totalHeatDutyMW, + double maxRadiationDistanceM, List alerts) { + this.loadCaseName = loadCaseName; + this.performanceByUnit = performanceByUnit == null ? Collections.emptyMap() + : Collections.unmodifiableMap(performanceByUnit); + this.totalHeatDutyMW = totalHeatDutyMW; + this.maxRadiationDistanceM = maxRadiationDistanceM; + this.alerts = alerts == null ? Collections.emptyList() : Collections.unmodifiableList(alerts); + } + + public String getLoadCaseName() { + return loadCaseName; + } + + public Map getPerformanceByUnit() { + return performanceByUnit; + } + + public double getTotalHeatDutyMW() { + return totalHeatDutyMW; + } + + public double getMaxRadiationDistanceM() { + return maxRadiationDistanceM; + } + + public List getAlerts() { + return alerts; + } +} diff --git a/src/main/java/neqsim/process/safety/dto/DisposalNetworkSummaryDTO.java b/src/main/java/neqsim/process/safety/dto/DisposalNetworkSummaryDTO.java new file mode 100644 index 0000000000..a139cb6a32 --- /dev/null +++ b/src/main/java/neqsim/process/safety/dto/DisposalNetworkSummaryDTO.java @@ -0,0 +1,42 @@ +package neqsim.process.safety.dto; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +/** + * Summary of disposal network evaluation across all analysed load cases. + */ +public class DisposalNetworkSummaryDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private final List loadCaseResults; + private final double maxHeatDutyMW; + private final double maxRadiationDistanceM; + private final List alerts; + + public DisposalNetworkSummaryDTO(List loadCaseResults, + double maxHeatDutyMW, double maxRadiationDistanceM, List alerts) { + this.loadCaseResults = loadCaseResults == null ? Collections.emptyList() + : Collections.unmodifiableList(loadCaseResults); + this.maxHeatDutyMW = maxHeatDutyMW; + this.maxRadiationDistanceM = maxRadiationDistanceM; + this.alerts = alerts == null ? Collections.emptyList() : Collections.unmodifiableList(alerts); + } + + public List getLoadCaseResults() { + return loadCaseResults; + } + + public double getMaxHeatDutyMW() { + return maxHeatDutyMW; + } + + public double getMaxRadiationDistanceM() { + return maxRadiationDistanceM; + } + + public List getAlerts() { + return alerts; + } +} diff --git a/src/main/java/neqsim/process/util/report/safety/ProcessSafetyReport.java b/src/main/java/neqsim/process/util/report/safety/ProcessSafetyReport.java new file mode 100644 index 0000000000..fbfa63e14b --- /dev/null +++ b/src/main/java/neqsim/process/util/report/safety/ProcessSafetyReport.java @@ -0,0 +1,343 @@ +package neqsim.process.util.report.safety; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Immutable value object containing all information gathered by the + * {@link ProcessSafetyReportBuilder}. + */ +public final class ProcessSafetyReport { + /** Condition monitoring findings. */ + private final List conditionFindings; + /** Calculated safety margins for equipment. */ + private final List safetyMargins; + /** Metrics for relief and safety valves. */ + private final List reliefDeviceAssessments; + /** System level KPIs. */ + private final SystemKpiSnapshot systemKpis; + /** Serialized process snapshot produced by {@link neqsim.process.util.report.Report}. */ + private final String equipmentSnapshotJson; + /** Scenario label supplied by the caller. */ + private final String scenarioLabel; + /** Thresholds used when grading severities (copied for traceability). */ + private final ProcessSafetyThresholds thresholds; + + ProcessSafetyReport(String scenarioLabel, ProcessSafetyThresholds thresholds, + List conditionFindings, List safetyMargins, + List reliefDeviceAssessments, SystemKpiSnapshot systemKpis, + String equipmentSnapshotJson) { + this.scenarioLabel = scenarioLabel; + this.thresholds = thresholds == null ? new ProcessSafetyThresholds() : thresholds; + this.conditionFindings = conditionFindings == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(conditionFindings)); + this.safetyMargins = safetyMargins == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(safetyMargins)); + this.reliefDeviceAssessments = reliefDeviceAssessments == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(reliefDeviceAssessments)); + this.systemKpis = systemKpis; + this.equipmentSnapshotJson = equipmentSnapshotJson; + } + + public String getScenarioLabel() { + return scenarioLabel; + } + + public ProcessSafetyThresholds getThresholds() { + return thresholds; + } + + public List getConditionFindings() { + return conditionFindings; + } + + public List getSafetyMargins() { + return safetyMargins; + } + + public List getReliefDeviceAssessments() { + return reliefDeviceAssessments; + } + + public SystemKpiSnapshot getSystemKpis() { + return systemKpis; + } + + public String getEquipmentSnapshotJson() { + return equipmentSnapshotJson; + } + + /** + * Serialize the report to JSON. The structure is intentionally friendly for dashboards and audit + * archiving. + * + * @return JSON representation of the report + */ + public String toJson() { + Gson gson = new GsonBuilder().serializeSpecialFloatingPointValues().setPrettyPrinting().create(); + JsonObject root = new JsonObject(); + if (scenarioLabel != null) { + root.addProperty("scenario", scenarioLabel); + } + root.add("thresholds", gson.toJsonTree(thresholds)); + root.add("systemKpis", gson.toJsonTree(systemKpis)); + root.add("conditionFindings", gson.toJsonTree(conditionFindings)); + root.add("safetyMargins", gson.toJsonTree(safetyMargins)); + root.add("reliefDevices", gson.toJsonTree(reliefDeviceAssessments)); + if (equipmentSnapshotJson != null && !equipmentSnapshotJson.trim().isEmpty()) { + try { + JsonElement parsed = JsonParser.parseString(equipmentSnapshotJson); + root.add("equipment", parsed); + } catch (Exception parseException) { + root.addProperty("equipment", equipmentSnapshotJson); + } + } + return gson.toJson(root); + } + + /** + * Produce a CSV representation of the key findings. The CSV is organized with a single header + * allowing it to be imported into spreadsheets. + * + * @return CSV formatted summary + */ + public String toCsv() { + StringBuilder sb = new StringBuilder(); + sb.append("Category,Name,Metric,Value,Severity,Details\n"); + for (ConditionFinding finding : conditionFindings) { + appendCsvRow(sb, "ConditionMonitor", finding.getUnitName(), "Message", "", + finding.getSeverity(), finding.getMessage()); + } + for (SafetyMarginAssessment margin : safetyMargins) { + appendCsvRow(sb, "SafetyMargin", margin.getUnitName(), "Margin", + formatDouble(margin.getMarginFraction()), margin.getSeverity(), + String.format(Locale.ROOT, "design=%.3f bara, operating=%.3f bara", + margin.getDesignPressureBar(), margin.getOperatingPressureBar())); + } + for (ReliefDeviceAssessment relief : reliefDeviceAssessments) { + appendCsvRow(sb, "ReliefDevice", relief.getUnitName(), "Utilisation", + formatDouble(relief.getUtilisationFraction()), relief.getSeverity(), + String.format(Locale.ROOT, + "set=%.3f bara, relieving=%.3f bara, upstream=%.3f bara, massFlow=%.3f kg/hr", + relief.getSetPressureBar(), relief.getRelievingPressureBar(), + relief.getUpstreamPressureBar(), relief.getMassFlowRateKgPerHr())); + } + if (systemKpis != null) { + appendCsvRow(sb, "SystemKpi", scenarioLabel != null ? scenarioLabel : "process", + "EntropyChange", formatDouble(systemKpis.getEntropyChangeKjPerK()), + systemKpis.getEntropySeverity(), "kJ/K"); + appendCsvRow(sb, "SystemKpi", scenarioLabel != null ? scenarioLabel : "process", + "ExergyChange", formatDouble(systemKpis.getExergyChangeKj()), + systemKpis.getExergySeverity(), "kJ"); + } + return sb.toString(); + } + + private static void appendCsvRow(StringBuilder sb, String category, String name, String metric, + String value, SeverityLevel severity, String details) { + sb.append(escapeCsv(category)).append(',').append(escapeCsv(name)).append(',') + .append(escapeCsv(metric)).append(',').append(escapeCsv(value)).append(',') + .append(severity == null ? "" : severity.name()).append(',') + .append(escapeCsv(details)).append('\n'); + } + + private static String escapeCsv(String value) { + if (value == null) { + return ""; + } + String trimmed = value.trim(); + if (trimmed.contains(",") || trimmed.contains("\"") || trimmed.contains("\n")) { + return '"' + trimmed.replace("\"", "\"\"") + '"'; + } + return trimmed; + } + + private static String formatDouble(double value) { + if (Double.isNaN(value) || Double.isInfinite(value)) { + return ""; + } + return String.format(Locale.ROOT, "%.4f", value); + } + + /** + * Produce a map structure that can easily be consumed by dashboards or REST APIs. + * + * @return map representation of the report + */ + public Map toUiModel() { + Map model = new LinkedHashMap<>(); + if (scenarioLabel != null) { + model.put("scenario", scenarioLabel); + } + model.put("thresholds", thresholds); + model.put("systemKpis", systemKpis); + model.put("conditionFindings", conditionFindings); + model.put("safetyMargins", safetyMargins); + model.put("reliefDevices", reliefDeviceAssessments); + if (equipmentSnapshotJson != null && !equipmentSnapshotJson.trim().isEmpty()) { + model.put("equipmentJson", equipmentSnapshotJson); + } + return model; + } + + /** Represents a single condition monitoring finding. */ + public static final class ConditionFinding { + private final String unitName; + private final String message; + private final SeverityLevel severity; + + public ConditionFinding(String unitName, String message, SeverityLevel severity) { + this.unitName = unitName; + this.message = message; + this.severity = severity; + } + + public String getUnitName() { + return unitName; + } + + public String getMessage() { + return message; + } + + public SeverityLevel getSeverity() { + return severity; + } + } + + /** Captures the pressure margin for an equipment item. */ + public static final class SafetyMarginAssessment { + private final String unitName; + private final double designPressureBar; + private final double operatingPressureBar; + private final double marginFraction; + private final SeverityLevel severity; + private final String notes; + + public SafetyMarginAssessment(String unitName, double designPressureBar, + double operatingPressureBar, double marginFraction, SeverityLevel severity, String notes) { + this.unitName = unitName; + this.designPressureBar = designPressureBar; + this.operatingPressureBar = operatingPressureBar; + this.marginFraction = marginFraction; + this.severity = severity; + this.notes = notes; + } + + public String getUnitName() { + return unitName; + } + + public double getDesignPressureBar() { + return designPressureBar; + } + + public double getOperatingPressureBar() { + return operatingPressureBar; + } + + public double getMarginFraction() { + return marginFraction; + } + + public SeverityLevel getSeverity() { + return severity; + } + + public String getNotes() { + return notes; + } + } + + /** Summary of a relief valve evaluation. */ + public static final class ReliefDeviceAssessment { + private final String unitName; + private final double setPressureBar; + private final double relievingPressureBar; + private final double upstreamPressureBar; + private final double massFlowRateKgPerHr; + private final double utilisationFraction; + private final SeverityLevel severity; + + public ReliefDeviceAssessment(String unitName, double setPressureBar, double relievingPressureBar, + double upstreamPressureBar, double massFlowRateKgPerHr, double utilisationFraction, + SeverityLevel severity) { + this.unitName = unitName; + this.setPressureBar = setPressureBar; + this.relievingPressureBar = relievingPressureBar; + this.upstreamPressureBar = upstreamPressureBar; + this.massFlowRateKgPerHr = massFlowRateKgPerHr; + this.utilisationFraction = utilisationFraction; + this.severity = severity; + } + + public String getUnitName() { + return unitName; + } + + public double getSetPressureBar() { + return setPressureBar; + } + + public double getRelievingPressureBar() { + return relievingPressureBar; + } + + public double getUpstreamPressureBar() { + return upstreamPressureBar; + } + + public double getMassFlowRateKgPerHr() { + return massFlowRateKgPerHr; + } + + public double getUtilisationFraction() { + return utilisationFraction; + } + + public SeverityLevel getSeverity() { + return severity; + } + } + + /** Snapshot of aggregated system KPIs. */ + public static final class SystemKpiSnapshot { + private final double entropyChangeKjPerK; + private final double exergyChangeKj; + private final SeverityLevel entropySeverity; + private final SeverityLevel exergySeverity; + + public SystemKpiSnapshot(double entropyChangeKjPerK, double exergyChangeKj, + SeverityLevel entropySeverity, SeverityLevel exergySeverity) { + this.entropyChangeKjPerK = entropyChangeKjPerK; + this.exergyChangeKj = exergyChangeKj; + this.entropySeverity = entropySeverity; + this.exergySeverity = exergySeverity; + } + + public double getEntropyChangeKjPerK() { + return entropyChangeKjPerK; + } + + public double getExergyChangeKj() { + return exergyChangeKj; + } + + public SeverityLevel getEntropySeverity() { + return entropySeverity; + } + + public SeverityLevel getExergySeverity() { + return exergySeverity; + } + } +} diff --git a/src/main/java/neqsim/process/util/report/safety/ProcessSafetyReportBuilder.java b/src/main/java/neqsim/process/util/report/safety/ProcessSafetyReportBuilder.java new file mode 100644 index 0000000000..7b58b07dba --- /dev/null +++ b/src/main/java/neqsim/process/util/report/safety/ProcessSafetyReportBuilder.java @@ -0,0 +1,337 @@ +package neqsim.process.util.report.safety; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import neqsim.process.conditionmonitor.ConditionMonitor; +import neqsim.process.equipment.ProcessEquipmentInterface; +import neqsim.process.equipment.valve.SafetyReliefValve; +import neqsim.process.equipment.valve.SafetyValve; +import neqsim.process.equipment.valve.ValveInterface; +import neqsim.process.mechanicaldesign.MechanicalDesign; +import neqsim.process.processmodel.ProcessSystem; +import neqsim.process.util.report.Report; +import neqsim.process.util.report.ReportConfig; +import neqsim.process.util.report.safety.ProcessSafetyReport.ConditionFinding; +import neqsim.process.util.report.safety.ProcessSafetyReport.ReliefDeviceAssessment; +import neqsim.process.util.report.safety.ProcessSafetyReport.SafetyMarginAssessment; +import neqsim.process.util.report.safety.ProcessSafetyReport.SystemKpiSnapshot; + +/** + * Builder that collects safety information for a {@link ProcessSystem}. The builder aggregates + * condition monitoring messages, equipment safety margins, relief valve diagnostics and + * thermodynamic KPIs before serializing the result using the familiar {@link Report} utilities. + */ +public class ProcessSafetyReportBuilder { + private static final Logger logger = LogManager.getLogger(ProcessSafetyReportBuilder.class); + + private final ProcessSystem processSystem; + private ConditionMonitor conditionMonitor; + private ProcessSafetyThresholds thresholds; + private ReportConfig reportConfig; + private String scenarioLabel; + + /** + * Create a builder for the supplied process system. + * + * @param processSystem process to analyse + */ + public ProcessSafetyReportBuilder(ProcessSystem processSystem) { + this.processSystem = Objects.requireNonNull(processSystem, "processSystem"); + } + + /** + * Provide a pre-configured {@link ConditionMonitor}. If not supplied the builder will instantiate + * one based on the process itself. + * + * @param monitor monitor instance + * @return this builder + */ + public ProcessSafetyReportBuilder withConditionMonitor(ConditionMonitor monitor) { + this.conditionMonitor = monitor; + return this; + } + + /** + * Configure safety thresholds. + * + * @param thresholds thresholds + * @return this builder + */ + public ProcessSafetyReportBuilder withThresholds(ProcessSafetyThresholds thresholds) { + this.thresholds = thresholds; + return this; + } + + /** + * Configure report serialization options. + * + * @param cfg report configuration + * @return this builder + */ + public ProcessSafetyReportBuilder withReportConfig(ReportConfig cfg) { + this.reportConfig = cfg; + return this; + } + + /** + * Optional descriptive scenario label included in serialized output. + * + * @param label scenario label + * @return this builder + */ + public ProcessSafetyReportBuilder withScenarioLabel(String label) { + this.scenarioLabel = label; + return this; + } + + /** + * Build the {@link ProcessSafetyReport}. The method is thread-safe but will copy internal data to + * avoid exposing mutable state. + * + * @return built report + */ + public ProcessSafetyReport build() { + ProcessSafetyThresholds appliedThresholds = new ProcessSafetyThresholds(thresholds); + ConditionMonitor monitor = Optional.ofNullable(conditionMonitor) + .orElseGet(() -> new ConditionMonitor(processSystem)); + + // Run the condition monitoring analysis if not already performed. + try { + monitor.conditionAnalysis(); + } catch (Exception ex) { + logger.warn("Condition analysis failed", ex); + } + + List findings = collectConditionFindings(monitor); + List safetyMargins = collectSafetyMargins(appliedThresholds); + List reliefs = collectReliefAssessments(appliedThresholds); + SystemKpiSnapshot kpis = collectSystemKpis(appliedThresholds); + String equipmentJson = generateEquipmentJson(); + + return new ProcessSafetyReport(scenarioLabel, appliedThresholds, findings, safetyMargins, reliefs, + kpis, equipmentJson); + } + + private List collectConditionFindings(ConditionMonitor monitor) { + List findings = new ArrayList<>(); + String report = monitor.getReport(); + if (report == null || report.trim().isEmpty()) { + return findings; + } + + String[] segments = report.split("/"); + String currentUnit = null; + for (String segment : segments) { + String trimmed = segment == null ? null : segment.trim(); + if (trimmed == null || trimmed.isEmpty()) { + continue; + } + String lower = trimmed.toLowerCase(Locale.ROOT); + if (lower.endsWith("analysis started")) { + currentUnit = trimmed.split(" ", 2)[0]; + continue; + } + if (lower.endsWith("analysis ended")) { + currentUnit = null; + continue; + } + + SeverityLevel severity = classifyConditionSeverity(lower); + findings.add(new ConditionFinding(currentUnit, trimmed, severity)); + } + return findings; + } + + private SeverityLevel classifyConditionSeverity(String messageLower) { + if (messageLower == null) { + return SeverityLevel.NORMAL; + } + if (messageLower.contains("critical") || messageLower.contains("too high") + || messageLower.contains("error") || messageLower.contains("fail")) { + return SeverityLevel.CRITICAL; + } + if (messageLower.contains("warn") || messageLower.contains("deviation") + || messageLower.contains("monitor")) { + return SeverityLevel.WARNING; + } + return SeverityLevel.WARNING; + } + + private List collectSafetyMargins(ProcessSafetyThresholds appliedThresholds) { + List margins = new ArrayList<>(); + for (ProcessEquipmentInterface unit : processSystem.getUnitOperations()) { + MechanicalDesign design; + try { + design = unit.getMechanicalDesign(); + } catch (Exception ex) { + logger.debug("Mechanical design not available for {}", unit.getName(), ex); + continue; + } + if (design == null) { + continue; + } + double designPressure = design.getMaxDesignPressure(); + double operatingPressure; + try { + operatingPressure = unit.getPressure(); + } catch (Exception ex) { + logger.debug("Unable to obtain operating pressure for {}", unit.getName(), ex); + continue; + } + if (Double.isNaN(designPressure) || Double.isInfinite(designPressure) || designPressure == 0.0 + || Double.isNaN(operatingPressure) || Double.isInfinite(operatingPressure)) { + continue; + } + double marginFraction = (designPressure - operatingPressure) / designPressure; + SeverityLevel severity = gradeSafetyMargin(appliedThresholds, marginFraction); + String notes = marginFraction < 0 ? "Operating pressure above design" : null; + margins.add(new SafetyMarginAssessment(unit.getName(), designPressure, operatingPressure, + marginFraction, severity, notes)); + } + return margins; + } + + private SeverityLevel gradeSafetyMargin(ProcessSafetyThresholds thresholds, double marginFraction) { + if (Double.isNaN(marginFraction)) { + return SeverityLevel.NORMAL; + } + if (marginFraction <= thresholds.getMinSafetyMarginCritical()) { + return SeverityLevel.CRITICAL; + } + if (marginFraction <= thresholds.getMinSafetyMarginWarning()) { + return SeverityLevel.WARNING; + } + return SeverityLevel.NORMAL; + } + + private List collectReliefAssessments( + ProcessSafetyThresholds appliedThresholds) { + List reliefs = new ArrayList<>(); + for (ProcessEquipmentInterface unit : processSystem.getUnitOperations()) { + if (unit instanceof SafetyReliefValve) { + reliefs.add(buildReliefAssessment((SafetyReliefValve) unit, appliedThresholds)); + } else if (unit instanceof SafetyValve) { + reliefs.add(buildReliefAssessment((SafetyValve) unit, appliedThresholds)); + } + } + return reliefs; + } + + private ReliefDeviceAssessment buildReliefAssessment(SafetyReliefValve valve, + ProcessSafetyThresholds thresholds) { + double setPressure = valve.getSetPressureBar(); + double relievingPressure = safeDouble(valve::getRelievingPressureBar); + double upstreamPressure = safeDouble(valve::getInletPressure); + double massFlow = extractMassFlow(valve.getInletStream()); + double utilisation = normalizeOpenFraction(valve); + SeverityLevel severity = gradeUtilisation(thresholds, utilisation); + return new ReliefDeviceAssessment(valve.getName(), setPressure, relievingPressure, + upstreamPressure, massFlow, utilisation, severity); + } + + private ReliefDeviceAssessment buildReliefAssessment(SafetyValve valve, + ProcessSafetyThresholds thresholds) { + double setPressure = valve.getPressureSpec(); + double relievingPressure = setPressure; + double upstreamPressure = safeDouble(valve::getInletPressure); + double massFlow = extractMassFlow(valve.getInletStream()); + double utilisation = normalizeOpenFraction(valve); + SeverityLevel severity = gradeUtilisation(thresholds, utilisation); + return new ReliefDeviceAssessment(valve.getName(), setPressure, relievingPressure, + upstreamPressure, massFlow, utilisation, severity); + } + + private double safeDouble(DoubleSupplier supplier) { + try { + return supplier.get(); + } catch (Exception ex) { + logger.debug("Unable to evaluate metric", ex); + return Double.NaN; + } + } + + @FunctionalInterface + private interface DoubleSupplier { + double get() throws Exception; + } + + private double extractMassFlow(neqsim.process.equipment.stream.StreamInterface stream) { + if (stream == null || stream.getThermoSystem() == null) { + return Double.NaN; + } + try { + return stream.getThermoSystem().getFlowRate("kg/hr"); + } catch (Exception ex) { + logger.debug("Unable to obtain mass flow", ex); + return Double.NaN; + } + } + + private double normalizeOpenFraction(ValveInterface valve) { + if (valve == null) { + return Double.NaN; + } + try { + double percentOpen = valve.getPercentValveOpening(); + return Math.max(0.0, Math.min(1.0, percentOpen / 100.0)); + } catch (Exception ex) { + logger.debug("Unable to obtain valve opening", ex); + return Double.NaN; + } + } + + private SeverityLevel gradeUtilisation(ProcessSafetyThresholds thresholds, double utilisation) { + if (Double.isNaN(utilisation)) { + return SeverityLevel.NORMAL; + } + if (utilisation >= thresholds.getReliefUtilisationCritical()) { + return SeverityLevel.CRITICAL; + } + if (utilisation >= thresholds.getReliefUtilisationWarning()) { + return SeverityLevel.WARNING; + } + return SeverityLevel.NORMAL; + } + + private SystemKpiSnapshot collectSystemKpis(ProcessSafetyThresholds thresholds) { + double entropy = safeDouble(() -> processSystem.getEntropyProduction("kJ/K")); + double exergy = safeDouble(() -> processSystem.getExergyChange("kJ")); + SeverityLevel entropySeverity = gradeHighIsBad(thresholds.getEntropyChangeWarning(), + thresholds.getEntropyChangeCritical(), Math.abs(entropy)); + SeverityLevel exergySeverity = gradeHighIsBad(thresholds.getExergyChangeWarning(), + thresholds.getExergyChangeCritical(), Math.abs(exergy)); + return new SystemKpiSnapshot(entropy, exergy, entropySeverity, exergySeverity); + } + + private SeverityLevel gradeHighIsBad(double warningThreshold, double criticalThreshold, + double value) { + if (Double.isNaN(value)) { + return SeverityLevel.NORMAL; + } + if (value >= criticalThreshold) { + return SeverityLevel.CRITICAL; + } + if (value >= warningThreshold) { + return SeverityLevel.WARNING; + } + return SeverityLevel.NORMAL; + } + + private String generateEquipmentJson() { + if (reportConfig == null) { + return null; + } + try { + Report report = new Report(processSystem); + return report.generateJsonReport(reportConfig); + } catch (Exception ex) { + logger.warn("Unable to generate equipment JSON report", ex); + return null; + } + } +} diff --git a/src/main/java/neqsim/process/util/report/safety/ProcessSafetyThresholds.java b/src/main/java/neqsim/process/util/report/safety/ProcessSafetyThresholds.java new file mode 100644 index 0000000000..a2b0535e45 --- /dev/null +++ b/src/main/java/neqsim/process/util/report/safety/ProcessSafetyThresholds.java @@ -0,0 +1,111 @@ +package neqsim.process.util.report.safety; + +/** + * Configuration object containing threshold values that drive severity grading for safety + * reporting. + */ +public class ProcessSafetyThresholds { + private double entropyChangeWarning = 5.0; // kJ/K + private double entropyChangeCritical = 10.0; // kJ/K + private double exergyChangeWarning = 1.0e3; // kJ + private double exergyChangeCritical = 2.0e3; // kJ + private double minSafetyMarginWarning = 0.15; // 15 % remaining margin + private double minSafetyMarginCritical = 0.05; // 5 % remaining margin + private double reliefUtilisationWarning = 0.5; // 50 % open + private double reliefUtilisationCritical = 0.8; // 80 % open + + /** + * Create an instance with default thresholds. + */ + public ProcessSafetyThresholds() {} + + /** + * Copy constructor. + * + * @param other thresholds to copy + */ + public ProcessSafetyThresholds(ProcessSafetyThresholds other) { + if (other != null) { + entropyChangeWarning = other.entropyChangeWarning; + entropyChangeCritical = other.entropyChangeCritical; + exergyChangeWarning = other.exergyChangeWarning; + exergyChangeCritical = other.exergyChangeCritical; + minSafetyMarginWarning = other.minSafetyMarginWarning; + minSafetyMarginCritical = other.minSafetyMarginCritical; + reliefUtilisationWarning = other.reliefUtilisationWarning; + reliefUtilisationCritical = other.reliefUtilisationCritical; + } + } + + public double getEntropyChangeWarning() { + return entropyChangeWarning; + } + + public ProcessSafetyThresholds setEntropyChangeWarning(double entropyChangeWarning) { + this.entropyChangeWarning = entropyChangeWarning; + return this; + } + + public double getEntropyChangeCritical() { + return entropyChangeCritical; + } + + public ProcessSafetyThresholds setEntropyChangeCritical(double entropyChangeCritical) { + this.entropyChangeCritical = entropyChangeCritical; + return this; + } + + public double getExergyChangeWarning() { + return exergyChangeWarning; + } + + public ProcessSafetyThresholds setExergyChangeWarning(double exergyChangeWarning) { + this.exergyChangeWarning = exergyChangeWarning; + return this; + } + + public double getExergyChangeCritical() { + return exergyChangeCritical; + } + + public ProcessSafetyThresholds setExergyChangeCritical(double exergyChangeCritical) { + this.exergyChangeCritical = exergyChangeCritical; + return this; + } + + public double getMinSafetyMarginWarning() { + return minSafetyMarginWarning; + } + + public ProcessSafetyThresholds setMinSafetyMarginWarning(double minSafetyMarginWarning) { + this.minSafetyMarginWarning = minSafetyMarginWarning; + return this; + } + + public double getMinSafetyMarginCritical() { + return minSafetyMarginCritical; + } + + public ProcessSafetyThresholds setMinSafetyMarginCritical(double minSafetyMarginCritical) { + this.minSafetyMarginCritical = minSafetyMarginCritical; + return this; + } + + public double getReliefUtilisationWarning() { + return reliefUtilisationWarning; + } + + public ProcessSafetyThresholds setReliefUtilisationWarning(double reliefUtilisationWarning) { + this.reliefUtilisationWarning = reliefUtilisationWarning; + return this; + } + + public double getReliefUtilisationCritical() { + return reliefUtilisationCritical; + } + + public ProcessSafetyThresholds setReliefUtilisationCritical(double reliefUtilisationCritical) { + this.reliefUtilisationCritical = reliefUtilisationCritical; + return this; + } +} diff --git a/src/main/java/neqsim/process/util/report/safety/SeverityLevel.java b/src/main/java/neqsim/process/util/report/safety/SeverityLevel.java new file mode 100644 index 0000000000..62cf1e9e64 --- /dev/null +++ b/src/main/java/neqsim/process/util/report/safety/SeverityLevel.java @@ -0,0 +1,26 @@ +package neqsim.process.util.report.safety; + +/** + * Represents the severity level of a deviation detected in a safety report. + */ +public enum SeverityLevel { + /** Metric is within acceptable limits. */ + NORMAL, + /** Metric is drifting towards the configured limits. */ + WARNING, + /** Metric is outside the configured limits and requires immediate action. */ + CRITICAL; + + /** + * Combine two severities keeping the most critical one. + * + * @param other severity to combine with + * @return the highest severity + */ + public SeverityLevel combine(SeverityLevel other) { + if (other == null) { + return this; + } + return values()[Math.max(ordinal(), other.ordinal())]; + } +} diff --git a/src/main/java/neqsim/thermo/util/referenceequations/Ammonia2023.java b/src/main/java/neqsim/thermo/util/referenceequations/Ammonia2023.java index acc4e8ebae..38bb5a3a7d 100644 --- a/src/main/java/neqsim/thermo/util/referenceequations/Ammonia2023.java +++ b/src/main/java/neqsim/thermo/util/referenceequations/Ammonia2023.java @@ -99,6 +99,10 @@ public void setPhase(PhaseInterface phase) { /** * Solve for molar density given temperature (K) and pressure (Pa) using Newton iteration. + * + * @param T temperature in Kelvin + * @param p pressure in Pascal + * @return molar density in mol/m3 */ private double solveDensity(double T, double p) { boolean liquidGuess = @@ -233,7 +237,13 @@ private static class ResidualDerivs { double d2alpha_dDelta_dTau; } - /** Compute residual Helmholtz energy and derivatives. */ + /** + * Compute residual Helmholtz energy and derivatives. + * + * @param delta reduced density (rho / rhoCrit) + * @param tau inverse reduced temperature (Tcrit / T) + * @return container with residual Helmholtz energy derivatives + */ private static ResidualDerivs residual(double delta, double tau) { ResidualDerivs r = new ResidualDerivs(); @@ -316,7 +326,13 @@ private static class IdealDerivs { double d2alpha_dTau2; } - /** Compute ideal-gas Helmholtz energy and derivatives. */ + /** + * Compute ideal-gas Helmholtz energy and derivatives. + * + * @param delta reduced density (rho / rhoCrit) + * @param tau inverse reduced temperature (Tcrit / T) + * @return container with ideal Helmholtz derivatives + */ private static IdealDerivs ideal(double delta, double tau) { IdealDerivs id = new IdealDerivs(); id.alpha0 = Math.log(delta) + A1 + A2 * tau + C0 * Math.log(tau); diff --git a/src/main/java/neqsim/thermo/util/spanwagner/NeqSimSpanWagner.java b/src/main/java/neqsim/thermo/util/spanwagner/NeqSimSpanWagner.java index e7524e943c..25fe2cf0b0 100644 --- a/src/main/java/neqsim/thermo/util/spanwagner/NeqSimSpanWagner.java +++ b/src/main/java/neqsim/thermo/util/spanwagner/NeqSimSpanWagner.java @@ -64,7 +64,13 @@ private static class Derivs { double ar_dt; } - /** Evaluate ideal-gas contribution and derivatives. */ + /** + * Evaluate ideal-gas contribution and derivatives. + * + * @param delta reduced density (rho / rhoc) + * @param tau inverse reduced temperature (Tc / T) + * @param d holder for derivative terms that will be populated + */ private static void alpha0(double delta, double tau, Derivs d) { d.a0 = Math.log(delta) + LEAD_A1 + LEAD_A2 * tau + OFFSET_A1 + OFFSET_A2 * tau + LOGTAU_A * Math.log(tau); @@ -79,7 +85,13 @@ private static void alpha0(double delta, double tau, Derivs d) { } } - /** Evaluate residual contribution and derivatives (power + gaussian terms). */ + /** + * Evaluate residual contribution and derivatives (power + gaussian terms). + * + * @param delta reduced density (rho / rhoc) + * @param tau inverse reduced temperature (Tc / T) + * @param d holder for derivative terms that will be populated + */ private static void alphar(double delta, double tau, Derivs d) { for (int i = 0; i < N.length; i++) { double expc = L[i] == 0 ? 1.0 : Math.exp(-Math.pow(delta, L[i])); @@ -114,7 +126,14 @@ private static void alphar(double delta, double tau, Derivs d) { } } - /** Solve for reduced density delta given temperature and pressure. */ + /** + * Solve for reduced density delta given temperature and pressure. + * + * @param tau inverse reduced temperature (Tc / T) + * @param pressure system pressure in Pascal + * @param type phase type used for the initial guess + * @return reduced density + */ private static double density(double tau, double pressure, PhaseType type) { double delta; if (type == PhaseType.LIQUID) { @@ -141,8 +160,8 @@ private static double density(double tau, double pressure, PhaseType type) { * * @param temperature Kelvin * @param pressure Pascal + * @param type phase type for which properties are calculated * @return array [rho, Z, h, s, cp, cv, u, g, w] - * @param type a {@link neqsim.thermo.phase.PhaseType} object */ public static double[] getProperties(double temperature, double pressure, PhaseType type) { double tau = TC / temperature; diff --git a/src/test/java/neqsim/process/equipment/valve/SafetyValveMechanicalDesignTest.java b/src/test/java/neqsim/process/equipment/valve/SafetyValveMechanicalDesignTest.java index 803a41210e..558cff518a 100644 --- a/src/test/java/neqsim/process/equipment/valve/SafetyValveMechanicalDesignTest.java +++ b/src/test/java/neqsim/process/equipment/valve/SafetyValveMechanicalDesignTest.java @@ -1,21 +1,27 @@ package neqsim.process.equipment.valve; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Map; import org.junit.jupiter.api.Test; - import neqsim.process.equipment.stream.Stream; import neqsim.process.equipment.stream.StreamInterface; import neqsim.process.mechanicaldesign.valve.SafetyValveMechanicalDesign; +import neqsim.process.mechanicaldesign.valve.SafetyValveMechanicalDesign.SafetyValveScenarioReport; +import neqsim.process.mechanicaldesign.valve.SafetyValveMechanicalDesign.SafetyValveScenarioResult; +import neqsim.process.equipment.valve.SafetyValve.FluidService; +import neqsim.process.equipment.valve.SafetyValve.RelievingScenario; +import neqsim.process.equipment.valve.SafetyValve.SizingStandard; import neqsim.thermo.system.SystemInterface; import neqsim.thermo.system.SystemSrkEos; import neqsim.thermodynamicoperations.ThermodynamicOperations; -/** Test API 520 gas sizing for safety valves. */ +/** Tests for the safety valve mechanical design sizing strategies. */ public class SafetyValveMechanicalDesignTest { - @Test - void testApi520GasSizing() { - SystemInterface gas = new SystemSrkEos(300.0, 50.0); + + private SystemInterface createGasSystem(double temperature, double pressure, double flowRate) { + SystemInterface gas = new SystemSrkEos(temperature, pressure); gas.addComponent("methane", 1.0); gas.setMixingRule(2); ThermodynamicOperations ops = new ThermodynamicOperations(gas); @@ -24,30 +30,202 @@ void testApi520GasSizing() { } catch (Exception e) { throw new RuntimeException(e); } - gas.setTotalFlowRate(10.0, "kg/sec"); - StreamInterface inlet = new Stream("gas", gas); + gas.setTotalFlowRate(flowRate, "kg/sec"); + return gas; + } + + private SystemInterface createLiquidSystem(double temperature, double pressure, double flowRate) { + SystemInterface liquid = new SystemSrkEos(temperature, pressure); + liquid.addComponent("water", 1.0); + liquid.setMixingRule(2); + ThermodynamicOperations ops = new ThermodynamicOperations(liquid); + try { + ops.TPflash(); + } catch (Exception e) { + throw new RuntimeException(e); + } + liquid.setTotalFlowRate(flowRate, "kg/sec"); + return liquid; + } + + private SystemInterface createMultiphaseSystem(double temperature, double pressure, + double flowRate) { + SystemInterface fluid = new SystemSrkEos(temperature, pressure); + fluid.addComponent("methane", 0.6); + fluid.addComponent("water", 0.4); + fluid.setMixingRule(2); + ThermodynamicOperations ops = new ThermodynamicOperations(fluid); + try { + ops.TPflash(); + } catch (Exception e) { + throw new RuntimeException(e); + } + fluid.setTotalFlowRate(flowRate, "kg/sec"); + return fluid; + } + + private SafetyValve createValve(StreamInterface stream, double setPressure) { + SafetyValve valve = new SafetyValve("PSV", stream); + valve.setPressureSpec(setPressure); + valve.clearRelievingScenarios(); + return valve; + } + + private SafetyValveMechanicalDesign designFor(SafetyValve valve) { + return (SafetyValveMechanicalDesign) valve.getMechanicalDesign(); + } + + @Test + void gasApiStrategyMatchesManualCalculation() { + SystemInterface gas = createGasSystem(300.0, 50.0, 10.0); + StreamInterface stream = new Stream("gas", gas); + SafetyValve valve = createValve(stream, 50.0); - SafetyValve valve = new SafetyValve("PSV", inlet); - valve.setPressureSpec(50.0); + RelievingScenario scenario = new RelievingScenario.Builder("gasAPI") + .fluidService(FluidService.GAS) + .relievingStream(stream) + .setPressure(50.0) + .overpressureFraction(0.0) + .backPressure(0.0) + .sizingStandard(SizingStandard.API_520) + .build(); - SafetyValveMechanicalDesign design = - (SafetyValveMechanicalDesign) valve.getMechanicalDesign(); + valve.addScenario(scenario); + + SafetyValveMechanicalDesign design = designFor(valve); design.calcDesign(); - double area = design.getOrificeArea(); - double k = gas.getGamma(); - double z = gas.getZ(); - double mw = gas.getMolarMass(); - double R = 8.314; + SafetyValveScenarioResult result = design.getScenarioResults().get("gasAPI"); + double relievingPressure = 50.0 * 1e5; double kd = 0.975; double kb = 1.0; double kw = 1.0; - double relievingPressure = 50.0 * 1e5; - double relievingTemperature = 300.0; - double C = Math.sqrt(k) * Math.pow(2.0 / (k + 1.0), (k + 1.0) / (2.0 * (k - 1.0))); - double expected = 10.0 * Math.sqrt(z * R * relievingTemperature / mw) - / (kd * kb * kw * relievingPressure * C); + double expected = design.calcGasOrificeAreaAPI520(gas.getFlowRate("kg/sec"), relievingPressure, + gas.getTemperature(), gas.getZ(), gas.getMolarMass(), gas.getGamma(), kd, kb, kw); + + assertEquals(expected, result.getRequiredOrificeArea(), 1e-8); + assertEquals(expected, design.getOrificeArea(), 1e-8); + assertEquals(expected, design.getControllingOrificeArea(), 1e-8); + } + + @Test + void liquidSizingMatchesEnergyBalanceEquation() { + SystemInterface liquid = createLiquidSystem(298.15, 15.0, 5.0); + StreamInterface stream = new Stream("liquid", liquid); + SafetyValve valve = createValve(stream, 15.0); + + RelievingScenario scenario = new RelievingScenario.Builder("liquid") + .fluidService(FluidService.LIQUID) + .relievingStream(stream) + .setPressure(15.0) + .overpressureFraction(0.1) + .backPressure(1.0) + .build(); + + valve.addScenario(scenario); + + SafetyValveMechanicalDesign design = designFor(valve); + design.calcDesign(); + + SafetyValveScenarioResult result = design.getScenarioResults().get("liquid"); + double relievingPressure = 15.0 * 1e5 * 1.1; + double deltaP = relievingPressure - 1.0 * 1e5; + double kd = 0.62; + double kb = 1.0; + double kw = 1.0; + double expected = 5.0 / (kd * kb * kw * Math + .sqrt(2.0 * liquid.getDensity("kg/m3") * deltaP)); + + assertEquals(expected, result.getRequiredOrificeArea(), 1e-8); + assertEquals(expected, design.getOrificeArea(), 1e-8); + assertEquals(expected, design.getControllingOrificeArea(), 1e-8); + } + + @Test + void multiphaseSizingUsesHemApproximation() { + SystemInterface fluid = createMultiphaseSystem(290.0, 30.0, 8.0); + StreamInterface stream = new Stream("multiphase", fluid); + SafetyValve valve = createValve(stream, 30.0); + + RelievingScenario scenario = new RelievingScenario.Builder("multiphase") + .fluidService(FluidService.MULTIPHASE) + .relievingStream(stream) + .setPressure(30.0) + .overpressureFraction(0.05) + .backPressure(5.0) + .build(); + + valve.addScenario(scenario); + + SafetyValveMechanicalDesign design = designFor(valve); + design.calcDesign(); + + SafetyValveScenarioResult result = design.getScenarioResults().get("multiphase"); + double relievingPressure = 30.0 * 1e5 * 1.05; + double deltaP = relievingPressure - 5.0 * 1e5; + double kd = 0.85; + double kb = 1.0; + double kw = 1.0; + double expected = 8.0 / (kd * kb * kw * Math + .sqrt(fluid.getDensity("kg/m3") * deltaP)); + + assertEquals(expected, result.getRequiredOrificeArea(), 1e-8); + assertEquals(expected, design.getOrificeArea(), 1e-8); + assertEquals(expected, design.getControllingOrificeArea(), 1e-8); + } + + @Test + void fireCaseAppliesMarginAndSupportsScenarioSwitching() { + SystemInterface gas = createGasSystem(320.0, 60.0, 12.0); + StreamInterface fireStream = new Stream("fire", gas); + SafetyValve valve = createValve(fireStream, 60.0); + + RelievingScenario apiScenario = new RelievingScenario.Builder("apiGas") + .fluidService(FluidService.GAS) + .relievingStream(fireStream) + .setPressure(60.0) + .overpressureFraction(0.0) + .backPressure(0.0) + .sizingStandard(SizingStandard.API_520) + .build(); + + SystemInterface fireGas = createGasSystem(320.0, 60.0, 14.0); + StreamInterface fireScenarioStream = new Stream("fire-case", fireGas); + RelievingScenario fireScenario = new RelievingScenario.Builder("fire") + .fluidService(FluidService.FIRE) + .relievingStream(fireScenarioStream) + .setPressure(60.0) + .overpressureFraction(0.1) + .backPressure(2.0) + .sizingStandard(SizingStandard.API_520) + .build(); + + valve.addScenario(apiScenario); + valve.addScenario(fireScenario); + valve.setActiveScenario("fire"); + + SafetyValveMechanicalDesign design = designFor(valve); + design.calcDesign(); + + SafetyValveScenarioResult fireResult = design.getScenarioResults().get("fire"); + double relievingPressure = 60.0 * 1e5 * 1.1; + double kd = 0.975; + double kb = 1.0; + double kw = 1.0; + double baseArea = design.calcGasOrificeAreaAPI520(fireGas.getFlowRate("kg/sec"), + relievingPressure, fireGas.getTemperature(), fireGas.getZ(), fireGas.getMolarMass(), + fireGas.getGamma(), kd, kb, kw); + double expected = baseArea * 1.1; + + assertEquals(expected, fireResult.getRequiredOrificeArea(), 1e-8); + assertEquals(expected, design.getOrificeArea(), 1e-8); + assertEquals("fire", design.getControllingScenarioName()); + assertEquals(expected, design.getControllingOrificeArea(), 1e-8); - assertEquals(expected, area, 1e-8); + Map report = design.getScenarioReports(); + SafetyValveScenarioReport fireReport = report.get("fire"); + assertTrue(fireReport.isControllingScenario()); + assertEquals(60.0, fireReport.getSetPressureBar(), 1e-8); + assertEquals(6.0, fireReport.getOverpressureMarginBar(), 1e-8); } } diff --git a/src/test/java/neqsim/process/mechanicaldesign/MechanicalDesignDataSourceTest.java b/src/test/java/neqsim/process/mechanicaldesign/MechanicalDesignDataSourceTest.java new file mode 100644 index 0000000000..6f4615d674 --- /dev/null +++ b/src/test/java/neqsim/process/mechanicaldesign/MechanicalDesignDataSourceTest.java @@ -0,0 +1,84 @@ +package neqsim.process.mechanicaldesign; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.nio.file.Paths; +import neqsim.process.equipment.pipeline.Pipeline; +import neqsim.process.equipment.separator.Separator; +import neqsim.process.mechanicaldesign.MechanicalDesignMarginResult; +import neqsim.process.mechanicaldesign.data.CsvMechanicalDesignDataSource; +import neqsim.process.mechanicaldesign.pipeline.PipelineMechanicalDesign; +import neqsim.process.mechanicaldesign.separator.SeparatorMechanicalDesign; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MechanicalDesignDataSourceTest { + private static Path csvPath; + + @BeforeAll + static void setup() { + csvPath = Paths.get("src", "test", "resources", "design_limits_test.csv"); + } + + @Test + void pipelineDesignLoadsCsvAndComputesMargins() { + Pipeline pipeline = new Pipeline("TestPipeline"); + PipelineMechanicalDesign design = new PipelineMechanicalDesign(pipeline); + design.setDesignDataSource(new CsvMechanicalDesignDataSource(csvPath)); + design.setCompanySpecificDesignStandards("TestCo"); + design.setMaxOperationPressure(120.0); + design.setMinOperationPressure(15.0); + design.setMaxOperationTemperature(360.0); + design.setMinOperationTemperature(265.0); + design.setCorrosionAllowance(3.5); + design.setJointEfficiency(0.97); + + MechanicalDesignMarginResult margins = design.validateOperatingEnvelope(); + + assertEquals(150.0, design.getDesignMaxPressureLimit(), 1e-8); + assertEquals(10.0, design.getDesignMinPressureLimit(), 1e-8); + assertEquals(410.0, design.getDesignMaxTemperatureLimit(), 1e-8); + assertEquals(250.0, design.getDesignMinTemperatureLimit(), 1e-8); + assertEquals(3.0, design.getDesignCorrosionAllowance(), 1e-8); + assertEquals(0.95, design.getDesignJointEfficiency(), 1e-8); + + assertEquals(30.0, margins.getMaxPressureMargin(), 1e-8); + assertEquals(5.0, margins.getMinPressureMargin(), 1e-8); + assertEquals(50.0, margins.getMaxTemperatureMargin(), 1e-8); + assertEquals(15.0, margins.getMinTemperatureMargin(), 1e-8); + assertEquals(0.5, margins.getCorrosionAllowanceMargin(), 1e-8); + assertEquals(0.02, margins.getJointEfficiencyMargin(), 1e-8); + assertTrue(margins.isWithinDesignEnvelope()); + } + + @Test + void separatorDesignLoadsCsvAndComputesMargins() { + Separator separator = new Separator("TestSeparator"); + SeparatorMechanicalDesign design = new SeparatorMechanicalDesign(separator); + design.setDesignDataSource(new CsvMechanicalDesignDataSource(csvPath)); + design.setCompanySpecificDesignStandards("TestCo"); + design.setMaxOperationPressure(100.0); + design.setMinOperationPressure(7.0); + design.setMaxOperationTemperature(370.0); + design.setMinOperationTemperature(270.0); + design.setCorrosionAllowance(6.5); + design.setJointEfficiency(0.92); + + MechanicalDesignMarginResult margins = design.validateOperatingEnvelope(); + + assertEquals(110.0, design.getDesignMaxPressureLimit(), 1e-8); + assertEquals(5.0, design.getDesignMinPressureLimit(), 1e-8); + assertEquals(390.0, design.getDesignMaxTemperatureLimit(), 1e-8); + assertEquals(260.0, design.getDesignMinTemperatureLimit(), 1e-8); + + assertEquals(10.0, margins.getMaxPressureMargin(), 1e-8); + assertEquals(2.0, margins.getMinPressureMargin(), 1e-8); + assertEquals(20.0, margins.getMaxTemperatureMargin(), 1e-8); + assertEquals(10.0, margins.getMinTemperatureMargin(), 1e-8); + assertEquals(0.5, margins.getCorrosionAllowanceMargin(), 1e-8); + assertEquals(0.02, margins.getJointEfficiencyMargin(), 1e-8); + assertTrue(margins.isWithinDesignEnvelope()); + } +} diff --git a/src/test/java/neqsim/process/safety/ProcessSafetyAnalyzerTest.java b/src/test/java/neqsim/process/safety/ProcessSafetyAnalyzerTest.java new file mode 100644 index 0000000000..f7e9ad849d --- /dev/null +++ b/src/test/java/neqsim/process/safety/ProcessSafetyAnalyzerTest.java @@ -0,0 +1,188 @@ +package neqsim.process.safety; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import neqsim.NeqSimTest; +import neqsim.process.controllerdevice.ControllerDeviceBaseClass; +import neqsim.process.controllerdevice.ControllerDeviceInterface; +import neqsim.process.equipment.ProcessEquipmentBaseClass; +import neqsim.process.equipment.ProcessEquipmentInterface; +import neqsim.process.processmodel.ProcessSystem; +import neqsim.process.safety.ProcessSafetyAnalysisSummary.UnitKpiSnapshot; +import neqsim.thermo.system.SystemInterface; + +/** Tests for {@link ProcessSafetyAnalyzer}. */ +public class ProcessSafetyAnalyzerTest extends NeqSimTest { + + @Test + public void analyzeScenarioPersistsSummaryAndCapturesKpis() { + ProcessSystem base = new ProcessSystem("base"); + DummyUnit pump = new DummyUnit("pump1"); + pump.setMassBalance(42.0); + pump.setPressure(12.5); + pump.setTemperature(320.0); + pump.setController(new ControllerDeviceBaseClass("controller")); + base.add(pump); + + InMemoryResultRepository repository = new InMemoryResultRepository(); + ProcessSafetyAnalyzer analyzer = new ProcessSafetyAnalyzer(base, repository); + + ProcessSafetyScenario scenario = ProcessSafetyScenario.builder("Blocked pump") + .blockOutlet("pump1") + .controllerSetPoint("pump1", 5.0) + .build(); + + ProcessSafetyAnalysisSummary summary = analyzer.analyze(scenario); + + assertEquals("Blocked pump", summary.getScenarioName()); + assertEquals(1, repository.findAll().size()); + assertEquals(summary, repository.findAll().get(0)); + + assertTrue(summary.getConditionMessages().containsKey("pump1")); + assertFalse(summary.getConditionMessages().get("pump1").isEmpty()); + + UnitKpiSnapshot kpi = summary.getUnitKpis().get("pump1"); + assertNotNull(kpi); + assertEquals(42.0, kpi.getMassBalance(), 1e-6); + assertEquals(12.5, kpi.getPressure(), 1e-6); + assertEquals(320.0, kpi.getTemperature(), 1e-6); + } + + @Test + public void analyzeMultipleScenariosReturnsSummaries() { + ProcessSystem base = new ProcessSystem("base"); + DummyUnit cooler = new DummyUnit("cooler1"); + cooler.setMassBalance(10.0); + cooler.setPressure(5.0); + cooler.setTemperature(280.0); + cooler.setController(new ControllerDeviceBaseClass("controller")); + base.add(cooler); + + ProcessSafetyAnalyzer analyzer = new ProcessSafetyAnalyzer(base); + + List scenarios = new ArrayList<>(); + scenarios.add(ProcessSafetyScenario.builder("Utility loss").utilityLoss("cooler1").build()); + scenarios.add(ProcessSafetyScenario.builder("Controller change") + .controllerSetPoint("cooler1", 15.0) + .build()); + + List summaries = analyzer.analyze(scenarios); + + assertEquals(2, summaries.size()); + assertEquals("Utility loss", summaries.get(0).getScenarioName()); + assertEquals("Controller change", summaries.get(1).getScenarioName()); + } + + private static final class InMemoryResultRepository implements ProcessSafetyResultRepository { + private final List summaries = new ArrayList<>(); + + @Override + public void save(ProcessSafetyAnalysisSummary summary) { + summaries.add(summary); + } + + @Override + public List findAll() { + return new ArrayList<>(summaries); + } + } + + private static final class DummyUnit extends ProcessEquipmentBaseClass { + private static final long serialVersionUID = 1L; + + private double massBalance; + private double pressure; + private double temperature; + private double regulatorSignal; + private ControllerDeviceInterface controller; + + private DummyUnit(String name) { + super(name); + } + + @Override + public void run(UUID id) { + setCalculationIdentifier(id); + } + + @Override + public double getMassBalance(String unit) { + return massBalance; + } + + @Override + public double getPressure() { + return pressure; + } + + @Override + public double getPressure(String unit) { + return pressure; + } + + @Override + public void setPressure(double pressure) { + this.pressure = pressure; + } + + @Override + public double getTemperature() { + return temperature; + } + + @Override + public double getTemperature(String unit) { + return temperature; + } + + @Override + public void setTemperature(double temperature) { + this.temperature = temperature; + } + + @Override + public void setRegulatorOutSignal(double signal) { + this.regulatorSignal = signal; + } + + @Override + public void runConditionAnalysis(ProcessEquipmentInterface refExchanger) { + conditionAnalysisMessage = "Condition analysis for " + getName(); + } + + @Override + public String getConditionAnalysisMessage() { + return conditionAnalysisMessage; + } + + @Override + public SystemInterface getThermoSystem() { + return null; + } + + @Override + public ControllerDeviceInterface getController() { + return controller; + } + + @Override + public void setController(ControllerDeviceInterface controller) { + this.controller = controller; + } + + void setMassBalance(double massBalance) { + this.massBalance = massBalance; + } + + double getRegulatorSignal() { + return regulatorSignal; + } + } +} diff --git a/src/test/java/neqsim/process/util/report/safety/ProcessSafetyReportBuilderTest.java b/src/test/java/neqsim/process/util/report/safety/ProcessSafetyReportBuilderTest.java new file mode 100644 index 0000000000..a7d1cbeaf0 --- /dev/null +++ b/src/test/java/neqsim/process/util/report/safety/ProcessSafetyReportBuilderTest.java @@ -0,0 +1,151 @@ +package neqsim.process.util.report.safety; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import neqsim.process.conditionmonitor.ConditionMonitor; +import neqsim.process.equipment.ProcessEquipmentBaseClass; +import neqsim.process.equipment.compressor.Compressor; +import neqsim.process.equipment.separator.Separator; +import neqsim.process.equipment.stream.Stream; +import neqsim.process.equipment.valve.SafetyReliefValve; +import neqsim.process.processmodel.ProcessSystem; +import neqsim.thermo.system.SystemSrkEos; + +/** Integration tests for {@link ProcessSafetyReportBuilder}. */ +public class ProcessSafetyReportBuilderTest { + + @Test + public void testCriticalFindingsAreHighlighted() { + ScenarioFixtures upset = createScenario("upset", 75.0, 90.0); + + ProcessSafetyThresholds thresholds = new ProcessSafetyThresholds() + .setMinSafetyMarginWarning(0.20).setMinSafetyMarginCritical(0.05) + .setReliefUtilisationWarning(0.4).setReliefUtilisationCritical(0.7) + .setEntropyChangeWarning(0.1).setEntropyChangeCritical(1.0) + .setExergyChangeWarning(100.0).setExergyChangeCritical(500.0); + + ProcessSafetyReport report = new ProcessSafetyReportBuilder(upset.process) + .withScenarioLabel("compressor-upset").withConditionMonitor(upset.monitor) + .withThresholds(thresholds).build(); + + assertFalse(report.getConditionFindings().isEmpty(), "Condition findings should be present"); + assertEquals(SeverityLevel.CRITICAL, report.getConditionFindings().get(0).getSeverity()); + + ProcessSafetyReport.SafetyMarginAssessment margin = report.getSafetyMargins().stream() + .filter(m -> upset.compressor.getName().equals(m.getUnitName())).findFirst() + .orElseThrow(() -> new IllegalStateException( + "Expected safety margin assessment for " + upset.compressor.getName())); + assertEquals(SeverityLevel.CRITICAL, margin.getSeverity()); + + ProcessSafetyReport.ReliefDeviceAssessment reliefAssessment = report.getReliefDeviceAssessments() + .stream().filter(r -> upset.reliefValve.getName().equals(r.getUnitName())).findFirst() + .orElseThrow(() -> new IllegalStateException( + "Expected relief device assessment for " + upset.reliefValve.getName())); + assertEquals(SeverityLevel.CRITICAL, reliefAssessment.getSeverity()); + + assertNotNull(report.getSystemKpis()); + assertTrue(report.toJson().contains("compressor-upset")); + assertTrue(report.toCsv().startsWith("Category")); + assertTrue(report.toUiModel().containsKey("systemKpis")); + } + + @Test + public void testScenarioComparisonShowsDifferentSeverities() { + ScenarioFixtures baseline = createScenario("baseline", 60.0, 10.0); + ScenarioFixtures upset = createScenario("upset", 75.0, 90.0); + + ProcessSafetyThresholds thresholds = new ProcessSafetyThresholds() + .setMinSafetyMarginWarning(0.20).setMinSafetyMarginCritical(0.05) + .setReliefUtilisationWarning(0.4).setReliefUtilisationCritical(0.7); + + ProcessSafetyReport baselineReport = new ProcessSafetyReportBuilder(baseline.process) + .withScenarioLabel("baseline").withConditionMonitor(baseline.monitor) + .withThresholds(thresholds).build(); + + ProcessSafetyReport upsetReport = new ProcessSafetyReportBuilder(upset.process) + .withScenarioLabel("upset").withConditionMonitor(upset.monitor).withThresholds(thresholds) + .build(); + + Optional baselineMargin = baselineReport + .getSafetyMargins().stream() + .filter(m -> baseline.compressor.getName().equals(m.getUnitName())).findFirst(); + Optional upsetMargin = upsetReport.getSafetyMargins() + .stream().filter(m -> upset.compressor.getName().equals(m.getUnitName())).findFirst(); + + assertTrue(baselineMargin.isPresent()); + assertTrue(upsetMargin.isPresent()); + assertTrue(baselineMargin.get().getSeverity().ordinal() <= SeverityLevel.WARNING.ordinal()); + assertEquals(SeverityLevel.CRITICAL, upsetMargin.get().getSeverity()); + + Optional baselineRelief = baselineReport + .getReliefDeviceAssessments().stream() + .filter(r -> baseline.reliefValve.getName().equals(r.getUnitName())).findFirst(); + Optional upsetRelief = upsetReport + .getReliefDeviceAssessments().stream() + .filter(r -> upset.reliefValve.getName().equals(r.getUnitName())).findFirst(); + + assertTrue(baselineRelief.isPresent()); + assertTrue(upsetRelief.isPresent()); + assertTrue(baselineRelief.get().getSeverity().ordinal() <= SeverityLevel.WARNING.ordinal()); + assertEquals(SeverityLevel.CRITICAL, upsetRelief.get().getSeverity()); + } + + private ScenarioFixtures createScenario(String scenarioName, double outletPressureBar, + double valveOpeningPercent) { + SystemSrkEos fluid = new SystemSrkEos(298.15, 50.0); + fluid.addComponent("methane", 100.0); + fluid.addComponent("n-heptane", 5.0); + fluid.setMixingRule(2); + + ProcessSystem process = new ProcessSystem(); + + Stream feed = new Stream("feed", fluid); + feed.setPressure(50.0, "bara"); + feed.setTemperature(35.0, "C"); + feed.setFlowRate(1000.0, "kg/hr"); + + Separator separator = new Separator("separator", feed); + Compressor compressor = new Compressor("compressor", separator.getGasOutStream()); + compressor.setOutletPressure(outletPressureBar, "bara"); + compressor.initMechanicalDesign(); + compressor.getMechanicalDesign().setMaxOperationPressure(60.0); + + SafetyReliefValve reliefValve = new SafetyReliefValve("relief", compressor.getOutStream()); + reliefValve.setSetPressureBar(62.0); + reliefValve.setPercentValveOpening(valveOpeningPercent); + + process.add(feed); + process.add(separator); + process.add(compressor); + process.add(reliefValve); + process.run(); + + ConditionMonitor monitor = new ConditionMonitor(process); + ProcessSystem monitorProcess = monitor.getProcess(); + ProcessEquipmentBaseClass monitorCompressor = (ProcessEquipmentBaseClass) monitorProcess + .getUnit(compressor.getName()); + monitorCompressor.conditionAnalysisMessage = + scenarioName.equals("upset") ? "CRITICAL: High vibration detected" : "Monitoring"; + + return new ScenarioFixtures(process, monitor, compressor, reliefValve); + } + + private static final class ScenarioFixtures { + final ProcessSystem process; + final ConditionMonitor monitor; + final Compressor compressor; + final SafetyReliefValve reliefValve; + + ScenarioFixtures(ProcessSystem process, ConditionMonitor monitor, Compressor compressor, + SafetyReliefValve reliefValve) { + this.process = process; + this.monitor = monitor; + this.compressor = compressor; + this.reliefValve = reliefValve; + } + } +} diff --git a/src/test/resources/design_limits_test.csv b/src/test/resources/design_limits_test.csv new file mode 100644 index 0000000000..7693c42025 --- /dev/null +++ b/src/test/resources/design_limits_test.csv @@ -0,0 +1,3 @@ +EQUIPMENTTYPE,COMPANY,MAXPRESSURE,MINPRESSURE,MAXTEMPERATURE,MINTEMPERATURE,CORROSIONALLOWANCE,JOINTEFFICIENCY +Pipeline,TestCo,150.0,10.0,410.0,250.0,3.0,0.95 +Separator,TestCo,110.0,5.0,390.0,260.0,6.0,0.9