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