From b6ada5883d1254ab457421c58ff5195c30fb5bb4 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:16:40 +0100 Subject: [PATCH 01/12] Add process safety analyzer and scenario support --- .../safety/ProcessSafetyAnalysisSummary.java | 78 +++++++ .../process/safety/ProcessSafetyAnalyzer.java | 137 +++++++++++ .../safety/ProcessSafetyResultRepository.java | 24 ++ .../process/safety/ProcessSafetyScenario.java | 212 ++++++++++++++++++ .../safety/ProcessSafetyAnalyzerTest.java | 188 ++++++++++++++++ 5 files changed, 639 insertions(+) create mode 100644 src/main/java/neqsim/process/safety/ProcessSafetyAnalysisSummary.java create mode 100644 src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java create mode 100644 src/main/java/neqsim/process/safety/ProcessSafetyResultRepository.java create mode 100644 src/main/java/neqsim/process/safety/ProcessSafetyScenario.java create mode 100644 src/test/java/neqsim/process/safety/ProcessSafetyAnalyzerTest.java 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..80ffe99213 --- /dev/null +++ b/src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java @@ -0,0 +1,137 @@ +package neqsim.process.safety; + +import java.io.Serializable; +import java.util.ArrayList; +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.UUID; +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.processmodel.ProcessSystem; + +/** + * Executes {@link ProcessSafetyScenario}s by creating scenario specific copies of a base + * {@link ProcessSystem}, applying perturbations and running condition monitoring to produce + * summaries that can optionally be persisted via a {@link ProcessSafetyResultRepository}. + */ +public class ProcessSafetyAnalyzer implements Serializable { + private static final long serialVersionUID = 1L; + private static final Logger logger = LogManager.getLogger(ProcessSafetyAnalyzer.class); + + private final ProcessSystem baseProcessSystem; + private final ProcessSafetyResultRepository resultRepository; + + public ProcessSafetyAnalyzer(ProcessSystem baseProcessSystem) { + this(baseProcessSystem, null); + } + + public ProcessSafetyAnalyzer(ProcessSystem baseProcessSystem, + ProcessSafetyResultRepository resultRepository) { + this.baseProcessSystem = Objects.requireNonNull(baseProcessSystem, "baseProcessSystem"); + this.resultRepository = resultRepository; + } + + public List analyze(List scenarios) { + Objects.requireNonNull(scenarios, "scenarios"); + List summaries = new ArrayList<>(); + for (ProcessSafetyScenario scenario : scenarios) { + summaries.add(analyze(scenario)); + } + return summaries; + } + + public ProcessSafetyAnalysisSummary analyze(ProcessSafetyScenario scenario) { + Objects.requireNonNull(scenario, "scenario"); + + ConditionMonitor monitor = new ConditionMonitor(baseProcessSystem); + ProcessSystem scenarioProcess = monitor.getProcess(); + scenario.applyTo(scenarioProcess); + + try { + scenarioProcess.run(UUID.randomUUID()); + } catch (RuntimeException ex) { + logger.error("Scenario '{}' failed to run: {}", scenario.getName(), ex.getMessage(), ex); + throw ex; + } + + monitor.conditionAnalysis(); + + String report = sanitizeReport(monitor.getReport()); + Set affectedUnits = new LinkedHashSet<>(scenario.getTargetUnits()); + if (affectedUnits.isEmpty()) { + affectedUnits.addAll(scenarioProcess.getAllUnitNames()); + } + Map kpis = + collectUnitKpis(scenarioProcess, affectedUnits); + Map conditionMessages = collectConditionMessages(scenarioProcess, affectedUnits); + + ProcessSafetyAnalysisSummary summary = + new ProcessSafetyAnalysisSummary(scenario.getName(), affectedUnits, report, conditionMessages, + kpis); + if (resultRepository != null) { + resultRepository.save(summary); + } + return summary; + } + + private Map collectUnitKpis( + ProcessSystem scenarioProcess, Set affectedUnits) { + Map kpis = new LinkedHashMap<>(); + for (String unitName : affectedUnits) { + ProcessEquipmentInterface unit = scenarioProcess.getUnit(unitName); + if (unit == null) { + continue; + } + double massBalance = safeEvaluate(() -> unit.getMassBalance("kg/hr")); + double pressure = safeEvaluate(unit::getPressure); + double temperature = safeEvaluate(unit::getTemperature); + kpis.put(unitName, + new ProcessSafetyAnalysisSummary.UnitKpiSnapshot(massBalance, pressure, temperature)); + } + return kpis; + } + + private Map collectConditionMessages(ProcessSystem scenarioProcess, + Set affectedUnits) { + Map messages = new LinkedHashMap<>(); + for (String unitName : affectedUnits) { + ProcessEquipmentInterface unit = scenarioProcess.getUnit(unitName); + if (unit == null) { + continue; + } + messages.put(unitName, nullToEmpty(unit.getConditionAnalysisMessage())); + } + return messages; + } + + private String sanitizeReport(String report) { + if (report == null) { + return ""; + } + return report.startsWith("null") ? report.substring(4) : report; + } + + private String nullToEmpty(String message) { + return message == null ? "" : message; + } + + private double safeEvaluate(DoubleSupplier supplier) { + try { + return supplier.getAsDouble(); + } catch (RuntimeException ex) { + logger.debug("Failed to evaluate KPI: {}", ex.getMessage()); + return Double.NaN; + } + } + + @FunctionalInterface + private interface DoubleSupplier extends Serializable { + double getAsDouble(); + } +} 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/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; + } + } +} From 79eb56a6b3b145b4d7e038b1ccdfc16df2cb0585 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:17:40 +0100 Subject: [PATCH 02/12] Add configurable mechanical design limits and validation --- .../mechanicaldesign/DesignLimitData.java | 135 ++++++++++++ .../mechanicaldesign/MechanicalDesign.java | 207 +++++++++++++++++- .../MechanicalDesignMarginResult.java | 103 +++++++++ .../data/CsvMechanicalDesignDataSource.java | 147 +++++++++++++ .../DatabaseMechanicalDesignDataSource.java | 87 ++++++++ .../data/MechanicalDesignDataSource.java | 18 ++ .../designstandards/DesignStandard.java | 13 ++ .../PipelineDesignStandard.java | 22 +- .../PressureVesselDesignStandard.java | 7 + .../SeparatorDesignStandard.java | 17 +- .../MechanicalDesignDataSourceTest.java | 84 +++++++ src/test/resources/design_limits_test.csv | 3 + 12 files changed, 838 insertions(+), 5 deletions(-) create mode 100644 src/main/java/neqsim/process/mechanicaldesign/DesignLimitData.java create mode 100644 src/main/java/neqsim/process/mechanicaldesign/MechanicalDesignMarginResult.java create mode 100644 src/main/java/neqsim/process/mechanicaldesign/data/CsvMechanicalDesignDataSource.java create mode 100644 src/main/java/neqsim/process/mechanicaldesign/data/DatabaseMechanicalDesignDataSource.java create mode 100644 src/main/java/neqsim/process/mechanicaldesign/data/MechanicalDesignDataSource.java create mode 100644 src/test/java/neqsim/process/mechanicaldesign/MechanicalDesignDataSourceTest.java create mode 100644 src/test/resources/design_limits_test.csv 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..07ca989a79 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; @@ -103,6 +109,9 @@ public void setMaterialDesignStandard(MaterialPlateDesignStandard materialDesign private String construtionMaterial = "steel"; private double corrosionAllowanse = 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())) { + corrosionAllowanse = 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, corrosionAllowanse, 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; } /** @@ -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(); } /** 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..ae02870489 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(); } /** @@ -67,4 +70,8 @@ public double calcWallThickness() { } 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/test/java/neqsim/process/mechanicaldesign/MechanicalDesignDataSourceTest.java b/src/test/java/neqsim/process/mechanicaldesign/MechanicalDesignDataSourceTest.java new file mode 100644 index 0000000000..fd4e248aa2 --- /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.setCorrosionAllowanse(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.setCorrosionAllowanse(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/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 From 660a33520acca96fd754b27214922f6e80879fe6 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:18:22 +0100 Subject: [PATCH 03/12] Refactor safety valve sizing strategies --- .../process/equipment/valve/SafetyValve.java | 260 +++++++++++ .../valve/SafetyValveMechanicalDesign.java | 429 +++++++++++++++++- .../SafetyValveMechanicalDesignTest.java | 222 ++++++++- 3 files changed, 877 insertions(+), 34 deletions(-) diff --git a/src/main/java/neqsim/process/equipment/valve/SafetyValve.java b/src/main/java/neqsim/process/equipment/valve/SafetyValve.java index 8998441c72..59022c8584 100644 --- a/src/main/java/neqsim/process/equipment/valve/SafetyValve.java +++ b/src/main/java/neqsim/process/equipment/valve/SafetyValve.java @@ -1,5 +1,10 @@ package neqsim.process.equipment.valve; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; import neqsim.process.equipment.stream.StreamInterface; import neqsim.process.mechanicaldesign.valve.SafetyValveMechanicalDesign; @@ -17,6 +22,8 @@ public class SafetyValve extends ThrottlingValve { private double pressureSpec = 10.0; private double fullOpenPressure = 10.0; + private final List relievingScenarios = new ArrayList<>(); + private String activeScenarioName; /** * Constructor for SafetyValve. @@ -46,6 +53,105 @@ public void initMechanicalDesign() { valveMechanicalDesign = new SafetyValveMechanicalDesign(this); } + /** + * Adds a relieving scenario definition to the valve. + * + * @param scenario the scenario to add + * @return the current valve instance for chaining + */ + public SafetyValve addScenario(RelievingScenario scenario) { + if (scenario == null) { + return this; + } + relievingScenarios.add(scenario); + if (activeScenarioName == null) { + activeScenarioName = scenario.getName(); + } + return this; + } + + /** + * Replace the relieving scenarios with the provided collection. + * + * @param scenarios scenarios to store on the valve + */ + public void setRelievingScenarios(List scenarios) { + relievingScenarios.clear(); + activeScenarioName = null; + if (scenarios != null) { + scenarios.forEach(this::addScenario); + } + } + + /** + * Removes all relieving scenarios from the valve. + */ + public void clearRelievingScenarios() { + relievingScenarios.clear(); + activeScenarioName = null; + } + + /** + * @return immutable view of configured scenarios + */ + public List getRelievingScenarios() { + return Collections.unmodifiableList(relievingScenarios); + } + + /** + * Sets the active relieving scenario by name. If the scenario does not exist the active + * definition is not changed. + * + * @param name scenario identifier + */ + public void setActiveScenario(String name) { + if (name == null) { + return; + } + boolean exists = relievingScenarios.stream().anyMatch(s -> s.getName().equals(name)); + if (exists) { + activeScenarioName = name; + } + } + + /** + * @return the name of the active scenario, if any + */ + public Optional getActiveScenarioName() { + return Optional.ofNullable(activeScenarioName); + } + + /** + * Returns the active relieving scenario or {@code Optional.empty()} if no scenario is active. + * + * @return optional containing the active scenario + */ + public Optional getActiveScenario() { + if (activeScenarioName == null) { + return Optional.empty(); + } + return relievingScenarios.stream().filter(s -> s.getName().equals(activeScenarioName)) + .findFirst(); + } + + /** + * Ensures that at least one scenario exists by creating a default vapor relieving scenario when + * no user supplied definitions are present. + */ + public void ensureDefaultScenario() { + if (!relievingScenarios.isEmpty()) { + return; + } + RelievingScenario scenario = new RelievingScenario.Builder("default") + .fluidService(FluidService.GAS) + .relievingStream(getInletStream()) + .setPressure(pressureSpec) + .overpressureFraction(0.1) + .backPressure(0.0) + .build(); + addScenario(scenario); + } + /** *

* Getter for the field pressureSpec. @@ -88,5 +194,159 @@ public double getFullOpenPressure() { */ public void setFullOpenPressure(double fullOpenPressure) { this.fullOpenPressure = fullOpenPressure; +} + + /** Supported fluid service categories used for selecting the sizing strategy. */ + public enum FluidService { + GAS, + LIQUID, + MULTIPHASE, + FIRE + } + + /** Available sizing standards for the relieving calculations. */ + public enum SizingStandard { + API_520, + ISO_4126 + } + + /** Immutable description of a relieving scenario. */ + public static final class RelievingScenario implements Serializable { + private static final long serialVersionUID = 1L; + + private final String name; + private final FluidService fluidService; + private final StreamInterface relievingStream; + private final Double setPressure; + private final double overpressureFraction; + private final double backPressure; + private final SizingStandard sizingStandard; + private final Double dischargeCoefficient; + private final Double backPressureCorrection; + private final Double installationCorrection; + + private RelievingScenario(Builder builder) { + this.name = builder.name; + this.fluidService = builder.fluidService; + this.relievingStream = builder.relievingStream; + this.setPressure = builder.setPressure; + this.overpressureFraction = builder.overpressureFraction; + this.backPressure = builder.backPressure; + this.sizingStandard = builder.sizingStandard; + this.dischargeCoefficient = builder.dischargeCoefficient; + this.backPressureCorrection = builder.backPressureCorrection; + this.installationCorrection = builder.installationCorrection; + } + + public String getName() { + return name; + } + + public FluidService getFluidService() { + return fluidService; + } + + public StreamInterface getRelievingStream() { + return relievingStream; + } + + public Optional getSetPressure() { + return Optional.ofNullable(setPressure); + } + + public double getOverpressureFraction() { + return overpressureFraction; + } + + public double getBackPressure() { + return backPressure; + } + + public SizingStandard getSizingStandard() { + return sizingStandard; + } + + public Optional getDischargeCoefficient() { + return Optional.ofNullable(dischargeCoefficient); + } + + public Optional getBackPressureCorrection() { + return Optional.ofNullable(backPressureCorrection); + } + + public Optional getInstallationCorrection() { + return Optional.ofNullable(installationCorrection); + } + + /** Builder for {@link RelievingScenario}. */ + public static final class Builder { + private final String name; + private FluidService fluidService = FluidService.GAS; + private StreamInterface relievingStream; + private Double setPressure; + private double overpressureFraction = 0.1; + private double backPressure = 0.0; + private SizingStandard sizingStandard = SizingStandard.API_520; + private Double dischargeCoefficient; + private Double backPressureCorrection; + private Double installationCorrection; + + public Builder(String name) { + this.name = name; + } + + public Builder fluidService(FluidService fluidService) { + if (fluidService != null) { + this.fluidService = fluidService; + } + return this; + } + + public Builder relievingStream(StreamInterface relievingStream) { + this.relievingStream = relievingStream; + return this; + } + + public Builder setPressure(double setPressure) { + this.setPressure = setPressure; + return this; + } + + public Builder overpressureFraction(double overpressureFraction) { + this.overpressureFraction = overpressureFraction; + return this; + } + + public Builder backPressure(double backPressure) { + this.backPressure = backPressure; + return this; + } + + public Builder sizingStandard(SizingStandard sizingStandard) { + if (sizingStandard != null) { + this.sizingStandard = sizingStandard; + } + return this; + } + + public Builder dischargeCoefficient(double dischargeCoefficient) { + this.dischargeCoefficient = dischargeCoefficient; + return this; + } + + public Builder backPressureCorrection(double backPressureCorrection) { + this.backPressureCorrection = backPressureCorrection; + return this; + } + + public Builder installationCorrection(double installationCorrection) { + this.installationCorrection = installationCorrection; + return this; + } + + public RelievingScenario build() { + return new RelievingScenario(this); + } + } } } diff --git a/src/main/java/neqsim/process/mechanicaldesign/valve/SafetyValveMechanicalDesign.java b/src/main/java/neqsim/process/mechanicaldesign/valve/SafetyValveMechanicalDesign.java index f51b28a561..9a60053c2b 100644 --- a/src/main/java/neqsim/process/mechanicaldesign/valve/SafetyValveMechanicalDesign.java +++ b/src/main/java/neqsim/process/mechanicaldesign/valve/SafetyValveMechanicalDesign.java @@ -1,8 +1,17 @@ package neqsim.process.mechanicaldesign.valve; +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 +21,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 +35,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 +66,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 +148,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 { + 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/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); } } From ece2a720b734138737e1f10144a2371ee39cbf70 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:18:41 +0100 Subject: [PATCH 04/12] Add flare capacity metrics and disposal network analysis --- .../neqsim/process/equipment/flare/Flare.java | 467 ++++++++++++++++++ .../equipment/flare/dto/FlareCapacityDTO.java | 76 +++ .../dto/FlareDispersionSurrogateDTO.java | 52 ++ .../flare/dto/FlarePerformanceDTO.java | 91 ++++ .../process/safety/DisposalNetwork.java | 132 +++++ .../process/safety/ProcessSafetyAnalyzer.java | 38 ++ .../process/safety/ProcessSafetyLoadCase.java | 61 +++ .../process/safety/dto/CapacityAlertDTO.java | 32 ++ .../safety/dto/DisposalLoadCaseResultDTO.java | 51 ++ .../safety/dto/DisposalNetworkSummaryDTO.java | 42 ++ 10 files changed, 1042 insertions(+) create mode 100644 src/main/java/neqsim/process/equipment/flare/dto/FlareCapacityDTO.java create mode 100644 src/main/java/neqsim/process/equipment/flare/dto/FlareDispersionSurrogateDTO.java create mode 100644 src/main/java/neqsim/process/equipment/flare/dto/FlarePerformanceDTO.java create mode 100644 src/main/java/neqsim/process/safety/DisposalNetwork.java create mode 100644 src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java create mode 100644 src/main/java/neqsim/process/safety/ProcessSafetyLoadCase.java create mode 100644 src/main/java/neqsim/process/safety/dto/CapacityAlertDTO.java create mode 100644 src/main/java/neqsim/process/safety/dto/DisposalLoadCaseResultDTO.java create mode 100644 src/main/java/neqsim/process/safety/dto/DisposalNetworkSummaryDTO.java diff --git a/src/main/java/neqsim/process/equipment/flare/Flare.java b/src/main/java/neqsim/process/equipment/flare/Flare.java index 44205f2b58..447a838179 100644 --- a/src/main/java/neqsim/process/equipment/flare/Flare.java +++ b/src/main/java/neqsim/process/equipment/flare/Flare.java @@ -1,9 +1,16 @@ package neqsim.process.equipment.flare; +import java.io.Serializable; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; import neqsim.process.equipment.TwoPortEquipment; import neqsim.process.equipment.stream.Stream; import neqsim.process.equipment.stream.StreamInterface; +import neqsim.process.equipment.flare.dto.FlareCapacityDTO; +import neqsim.process.equipment.flare.dto.FlareDispersionSurrogateDTO; +import neqsim.process.equipment.flare.dto.FlarePerformanceDTO; import neqsim.thermo.system.SystemInterface; import neqsim.util.unit.PowerUnit; @@ -17,6 +24,15 @@ public class Flare extends TwoPortEquipment { private double heatDuty = 0.0; // J/s private double co2Emission = 0.0; // kg/s + private double flameHeight = 30.0; // m, default radiation height + private double radiantFraction = 0.18; // fraction of heat to radiation + private double tipDiameter = 0.3; // m, used for dispersion surrogate + + private double designHeatDutyCapacityW = Double.NaN; + private double designMassFlowCapacityKgS = Double.NaN; + private double designMolarFlowCapacityMoleS = Double.NaN; + + private transient CapacityCheckResult lastCapacityCheck = CapacityCheckResult.empty(); /** * Default constructor. @@ -64,6 +80,8 @@ public void run(UUID id) { outStream.setThermoSystem(thermoSystem); setCalculationIdentifier(id); + lastCapacityCheck = evaluateCapacityInternal(heatDuty, inStream.getFlowRate("kg/sec"), + inStream.getFlowRate("mole/sec")); } /** @@ -111,4 +129,453 @@ public double getCO2Emission(String unit) { return co2Emission; } } + + /** + * Set the effective flame centerline height used for radiation calculations. + * + * @param flameHeight flame height in meters + */ + public void setFlameHeight(double flameHeight) { + this.flameHeight = Math.max(0.0, flameHeight); + } + + /** + * Define the radiant fraction of the flare heat release used for point-source radiation. + * + * @param radiantFraction fraction (0-1) + */ + public void setRadiantFraction(double radiantFraction) { + if (Double.isNaN(radiantFraction)) { + return; + } + this.radiantFraction = Math.min(Math.max(radiantFraction, 0.0), 1.0); + } + + /** + * Set flare tip diameter used for exit velocity and dispersion surrogate estimates. + * + * @param tipDiameter tip diameter in meters + */ + public void setTipDiameter(double tipDiameter) { + this.tipDiameter = Math.max(1.0e-4, tipDiameter); + } + + /** + * Configure the design heat-release capacity for capacity validation. + * + * @param value heat-duty value + * @param unit engineering unit (W, kW, MW) + */ + public void setDesignHeatDutyCapacity(double value, String unit) { + if (Double.isNaN(value)) { + designHeatDutyCapacityW = Double.NaN; + return; + } + PowerUnit conv = new PowerUnit(value, unit); + designHeatDutyCapacityW = conv.getValue("W"); + } + + /** + * Configure the design mass-flow capacity for capacity validation. + * + * @param value mass-flow value + * @param unit supported units: kg/sec, kg/hr, kg/day + */ + public void setDesignMassFlowCapacity(double value, String unit) { + designMassFlowCapacityKgS = convertMassFlowToKgPerSec(value, unit); + } + + /** + * Configure the design molar-flow capacity for capacity validation. + * + * @param value molar-flow value + * @param unit supported units: mole/sec, kmole/hr + */ + public void setDesignMolarFlowCapacity(double value, String unit) { + designMolarFlowCapacityMoleS = convertMolarFlowToMolePerSec(value, unit); + } + + /** + * Estimate the flame radiation heat flux at a horizontal ground distance using the currently + * calculated heat duty. + * + * @param groundDistanceM horizontal distance from flare base [m] + * @return radiant heat flux [W/m2] + */ + public double estimateRadiationHeatFlux(double groundDistanceM) { + return estimateRadiationHeatFlux(heatDuty, groundDistanceM); + } + + /** + * Estimate the flame radiation heat flux at a horizontal ground distance for a specified heat + * duty. + * + * @param scenarioHeatDutyW heat duty in W + * @param groundDistanceM horizontal distance from flare base [m] + * @return radiant heat flux [W/m2] + */ + public double estimateRadiationHeatFlux(double scenarioHeatDutyW, double groundDistanceM) { + double radialDistance = Math + .sqrt(Math.max(0.0, groundDistanceM * groundDistanceM + flameHeight * flameHeight)); + if (radialDistance < 1.0e-6) { + return 0.0; + } + return radiantFraction * scenarioHeatDutyW / (4.0 * Math.PI * radialDistance * radialDistance); + } + + /** + * Determine the horizontal distance at which the radiation level drops to the specified + * threshold. + * + * @param fluxThresholdWm2 target heat flux [W/m2] + * @return horizontal distance from flare base [m] + */ + public double radiationDistanceForFlux(double fluxThresholdWm2) { + return radiationDistanceForFlux(heatDuty, fluxThresholdWm2); + } + + /** + * Determine the horizontal distance at which the radiation level drops to the specified + * threshold for a scenario heat duty. + * + * @param scenarioHeatDutyW heat duty in W + * @param fluxThresholdWm2 target heat flux [W/m2] + * @return horizontal distance from flare base [m] + */ + public double radiationDistanceForFlux(double scenarioHeatDutyW, double fluxThresholdWm2) { + if (fluxThresholdWm2 <= 0.0) { + return 0.0; + } + double numerator = radiantFraction * scenarioHeatDutyW; + if (numerator <= 0.0) { + return 0.0; + } + double radialDistanceSquared = numerator / (4.0 * Math.PI * fluxThresholdWm2); + double horizontalSquared = radialDistanceSquared - flameHeight * flameHeight; + if (horizontalSquared <= 0.0) { + return 0.0; + } + return Math.sqrt(horizontalSquared); + } + + /** + * Build a dispersion surrogate descriptor for the current operating point. + * + * @return surrogate DTO with momentum-like metrics + */ + public FlareDispersionSurrogateDTO getDispersionSurrogate() { + return getDispersionSurrogate(inStream != null ? inStream.getFlowRate("kg/sec") : 0.0, + inStream != null ? inStream.getFlowRate("mole/sec") : 0.0); + } + + /** + * Build a dispersion surrogate descriptor for a specified mass and molar rate. + * + * @param massRateKgS mass flow in kg/s + * @param molarRateMoleS molar flow in mole/s + * @return surrogate DTO with momentum-like metrics + */ + public FlareDispersionSurrogateDTO getDispersionSurrogate(double massRateKgS, + double molarRateMoleS) { + double density = (inStream != null) ? inStream.getThermoSystem().getDensity("kg/m3") : 1.0; + density = Math.max(1.0e-3, density); + double area = Math.PI * tipDiameter * tipDiameter / 4.0; + area = Math.max(1.0e-6, area); + double velocity = Math.max(0.0, massRateKgS / (density * area)); + double momentumFlux = density * velocity * velocity; + double massPerMomentum = (massRateKgS > 1.0e-12) ? momentumFlux / massRateKgS : 0.0; + double referenceMassRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + double standardVolumeRate = 0.0; + if (referenceMassRate > 1.0e-12) { + double refStdVolume = inStream.getFlowRate("Sm3/sec"); + standardVolumeRate = refStdVolume * massRateKgS / referenceMassRate; + } + + return new FlareDispersionSurrogateDTO(massRateKgS, molarRateMoleS, velocity, momentumFlux, + massPerMomentum, standardVolumeRate); + } + + /** + * Evaluate the current operation against configured design capacities. + * + * @return capacity check result + */ + public CapacityCheckResult evaluateCapacity() { + lastCapacityCheck = evaluateCapacityInternal(heatDuty, + inStream != null ? inStream.getFlowRate("kg/sec") : 0.0, + inStream != null ? inStream.getFlowRate("mole/sec") : 0.0); + return lastCapacityCheck; + } + + /** + * Evaluate a hypothetical load case against configured design capacities. + * + * @param scenarioHeatDutyW heat duty in W + * @param massRateKgS mass flow in kg/s + * @param molarRateMoleS molar flow in mole/s + * @return capacity check result for the specified scenario + */ + public CapacityCheckResult evaluateCapacity(double scenarioHeatDutyW, double massRateKgS, + double molarRateMoleS) { + return evaluateCapacityInternal(scenarioHeatDutyW, massRateKgS, molarRateMoleS); + } + + private CapacityCheckResult evaluateCapacityInternal(double scenarioHeatDutyW, + double massRateKgS, double molarRateMoleS) { + double heatCapacity = designHeatDutyCapacityW; + double massCapacity = designMassFlowCapacityKgS; + double molarCapacity = designMolarFlowCapacityMoleS; + + double heatUtil = (Double.isFinite(heatCapacity) && heatCapacity > 0.0) + ? scenarioHeatDutyW / heatCapacity + : Double.NaN; + double massUtil = (Double.isFinite(massCapacity) && massCapacity > 0.0) + ? massRateKgS / massCapacity + : Double.NaN; + double molarUtil = (Double.isFinite(molarCapacity) && molarCapacity > 0.0) + ? molarRateMoleS / molarCapacity + : Double.NaN; + + return new CapacityCheckResult(scenarioHeatDutyW, heatCapacity, massRateKgS, massCapacity, + molarRateMoleS, molarCapacity, heatUtil, massUtil, molarUtil); + } + + /** + * Latest computed capacity check result. + * + * @return last capacity result + */ + public CapacityCheckResult getLastCapacityCheck() { + return lastCapacityCheck; + } + + /** + * Produce a performance summary DTO for the current operating point. + * + * @return performance DTO containing emissions, radiation and capacity data + */ + public FlarePerformanceDTO getPerformanceSummary() { + double massRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + double molarRate = inStream != null ? inStream.getFlowRate("mole/sec") : 0.0; + return buildPerformanceSummary(getName(), heatDuty, massRate, molarRate, co2Emission, + inStream != null ? buildEmissionMap() : Collections.emptyMap()); + } + + /** + * Produce a performance summary DTO for a hypothetical load case. + * + * @param scenarioName label for the scenario + * @param scenarioHeatDutyW heat duty in W (if <=0 the value will be estimated from mass rate) + * @param massRateKgS mass flow in kg/s + * @param molarRateMoleS molar flow in mole/s (optional, negative to auto-estimate) + * @return performance DTO containing emissions, radiation and capacity data + */ + public FlarePerformanceDTO getPerformanceSummary(String scenarioName, double scenarioHeatDutyW, + double massRateKgS, double molarRateMoleS) { + double heat = scenarioHeatDutyW; + if (heat <= 0.0 && massRateKgS > 0.0) { + heat = estimateHeatDutyFromMassRate(massRateKgS); + } + double molarRate = molarRateMoleS; + if (molarRate <= 0.0 && massRateKgS > 0.0) { + molarRate = estimateMolarRateFromMassRate(massRateKgS); + } + double co2Rate = estimateCO2EmissionFromMassRate(massRateKgS); + Map emissions = inStream != null ? scaleEmissionMap(massRateKgS) + : Collections.emptyMap(); + return buildPerformanceSummary(scenarioName, heat, massRateKgS, molarRate, co2Rate, emissions); + } + + private FlarePerformanceDTO buildPerformanceSummary(String label, double scenarioHeatDutyW, + double massRateKgS, double molarRateMoleS, double co2RateKgS, + Map emissionMap) { + double flux30m = estimateRadiationHeatFlux(scenarioHeatDutyW, 30.0); + double distance4kW = radiationDistanceForFlux(scenarioHeatDutyW, 4000.0); + FlareDispersionSurrogateDTO dispersion = getDispersionSurrogate(massRateKgS, molarRateMoleS); + CapacityCheckResult capacity = evaluateCapacityInternal(scenarioHeatDutyW, massRateKgS, + molarRateMoleS); + + return new FlarePerformanceDTO(label, scenarioHeatDutyW, massRateKgS, molarRateMoleS, + co2RateKgS, flux30m, distance4kW, dispersion, emissionMap, capacity.toDTO()); + } + + private double estimateHeatDutyFromMassRate(double massRateKgS) { + double baseMassRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + if (baseMassRate > 1.0e-12) { + return heatDuty * (massRateKgS / baseMassRate); + } + return heatDuty; + } + + private double estimateMolarRateFromMassRate(double massRateKgS) { + double baseMassRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + double baseMolarRate = inStream != null ? inStream.getFlowRate("mole/sec") : 0.0; + if (baseMassRate > 1.0e-12 && baseMolarRate > 0.0) { + return baseMolarRate * (massRateKgS / baseMassRate); + } + double mw = inStream != null ? inStream.getThermoSystem().getMolarMass() : Double.NaN; + if (Double.isFinite(mw) && mw > 1.0e-12) { + return massRateKgS / mw; + } + return 0.0; + } + + private double estimateCO2EmissionFromMassRate(double massRateKgS) { + double baseMassRate = inStream != null ? inStream.getFlowRate("kg/sec") : 0.0; + if (baseMassRate > 1.0e-12) { + return co2Emission * (massRateKgS / baseMassRate); + } + return co2Emission; + } + + private Map buildEmissionMap() { + Map emissionMap = new HashMap<>(); + emissionMap.put("CO2_kg_s", co2Emission); + emissionMap.put("HeatDuty_MW", heatDuty * 1.0e-6); + return emissionMap; + } + + private Map scaleEmissionMap(double massRateKgS) { + if (inStream == null) { + return Collections.singletonMap("CO2_kg_s", estimateCO2EmissionFromMassRate(massRateKgS)); + } + double baseMassRate = inStream.getFlowRate("kg/sec"); + if (baseMassRate <= 1.0e-12) { + return buildEmissionMap(); + } + Map emissionMap = new HashMap<>(); + for (Map.Entry entry : buildEmissionMap().entrySet()) { + emissionMap.put(entry.getKey(), entry.getValue() * massRateKgS / baseMassRate); + } + return emissionMap; + } + + private double convertMassFlowToKgPerSec(double value, String unit) { + if (Double.isNaN(value)) { + return Double.NaN; + } + switch (unit) { + case "kg/sec": + case "kg/s": + return value; + case "kg/hr": + return value / 3600.0; + case "kg/day": + return value / (3600.0 * 24.0); + default: + throw new IllegalArgumentException("Unsupported mass flow unit: " + unit); + } + } + + private double convertMolarFlowToMolePerSec(double value, String unit) { + if (Double.isNaN(value)) { + return Double.NaN; + } + switch (unit) { + case "mole/sec": + case "mol/sec": + return value; + case "kmole/hr": + case "kmol/hr": + return value * 1000.0 / 3600.0; + default: + throw new IllegalArgumentException("Unsupported molar flow unit: " + unit); + } + } + + /** + * Result object containing utilization against the configured design capacities. + */ + public static class CapacityCheckResult implements Serializable { + private static final long serialVersionUID = 1L; + + private final double heatDutyW; + private final double designHeatDutyW; + private final double massRateKgS; + private final double designMassRateKgS; + private final double molarRateMoleS; + private final double designMolarRateMoleS; + private final double heatUtilization; + private final double massUtilization; + private final double molarUtilization; + + CapacityCheckResult(double heatDutyW, double designHeatDutyW, double massRateKgS, + double designMassRateKgS, double molarRateMoleS, double designMolarRateMoleS, + double heatUtilization, double massUtilization, double molarUtilization) { + this.heatDutyW = heatDutyW; + this.designHeatDutyW = designHeatDutyW; + this.massRateKgS = massRateKgS; + this.designMassRateKgS = designMassRateKgS; + this.molarRateMoleS = molarRateMoleS; + this.designMolarRateMoleS = designMolarRateMoleS; + this.heatUtilization = heatUtilization; + this.massUtilization = massUtilization; + this.molarUtilization = molarUtilization; + } + + static CapacityCheckResult empty() { + return new CapacityCheckResult(0.0, Double.NaN, 0.0, Double.NaN, 0.0, Double.NaN, Double.NaN, + Double.NaN, Double.NaN); + } + + public double getHeatDutyW() { + return heatDutyW; + } + + public double getDesignHeatDutyW() { + return designHeatDutyW; + } + + public double getMassRateKgS() { + return massRateKgS; + } + + public double getDesignMassRateKgS() { + return designMassRateKgS; + } + + public double getMolarRateMoleS() { + return molarRateMoleS; + } + + public double getDesignMolarRateMoleS() { + return designMolarRateMoleS; + } + + public double getHeatUtilization() { + return heatUtilization; + } + + public double getMassUtilization() { + return massUtilization; + } + + public double getMolarUtilization() { + return molarUtilization; + } + + /** + * Determine if any configured capacity is overloaded (> 1.0 utilization). + * + * @return true if overloaded + */ + public boolean isOverloaded() { + return exceeds(heatUtilization) || exceeds(massUtilization) || exceeds(molarUtilization); + } + + private boolean exceeds(double utilization) { + return Double.isFinite(utilization) && utilization > 1.0 + 1.0e-6; + } + + /** + * Convert to a simple DTO for reporting. + * + * @return DTO view of the capacity check + */ + public FlareCapacityDTO toDTO() { + return new FlareCapacityDTO(heatDutyW, designHeatDutyW, heatUtilization, massRateKgS, + designMassRateKgS, massUtilization, molarRateMoleS, designMolarRateMoleS, + molarUtilization, isOverloaded()); + } + } } diff --git a/src/main/java/neqsim/process/equipment/flare/dto/FlareCapacityDTO.java b/src/main/java/neqsim/process/equipment/flare/dto/FlareCapacityDTO.java new file mode 100644 index 0000000000..25783eff06 --- /dev/null +++ b/src/main/java/neqsim/process/equipment/flare/dto/FlareCapacityDTO.java @@ -0,0 +1,76 @@ +package neqsim.process.equipment.flare.dto; + +import java.io.Serializable; + +/** + * DTO describing utilization of a flare against its configured design capacities. + */ +public class FlareCapacityDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private final double heatDutyW; + private final double designHeatDutyW; + private final double heatUtilization; + private final double massRateKgS; + private final double designMassRateKgS; + private final double massUtilization; + private final double molarRateMoleS; + private final double designMolarRateMoleS; + private final double molarUtilization; + private final boolean overloaded; + + public FlareCapacityDTO(double heatDutyW, double designHeatDutyW, double heatUtilization, + double massRateKgS, double designMassRateKgS, double massUtilization, double molarRateMoleS, + double designMolarRateMoleS, double molarUtilization, boolean overloaded) { + this.heatDutyW = heatDutyW; + this.designHeatDutyW = designHeatDutyW; + this.heatUtilization = heatUtilization; + this.massRateKgS = massRateKgS; + this.designMassRateKgS = designMassRateKgS; + this.massUtilization = massUtilization; + this.molarRateMoleS = molarRateMoleS; + this.designMolarRateMoleS = designMolarRateMoleS; + this.molarUtilization = molarUtilization; + this.overloaded = overloaded; + } + + public double getHeatDutyW() { + return heatDutyW; + } + + public double getDesignHeatDutyW() { + return designHeatDutyW; + } + + public double getHeatUtilization() { + return heatUtilization; + } + + public double getMassRateKgS() { + return massRateKgS; + } + + public double getDesignMassRateKgS() { + return designMassRateKgS; + } + + public double getMassUtilization() { + return massUtilization; + } + + public double getMolarRateMoleS() { + return molarRateMoleS; + } + + public double getDesignMolarRateMoleS() { + return designMolarRateMoleS; + } + + public double getMolarUtilization() { + return molarUtilization; + } + + public boolean isOverloaded() { + return overloaded; + } +} diff --git a/src/main/java/neqsim/process/equipment/flare/dto/FlareDispersionSurrogateDTO.java b/src/main/java/neqsim/process/equipment/flare/dto/FlareDispersionSurrogateDTO.java new file mode 100644 index 0000000000..d45a54cd94 --- /dev/null +++ b/src/main/java/neqsim/process/equipment/flare/dto/FlareDispersionSurrogateDTO.java @@ -0,0 +1,52 @@ +package neqsim.process.equipment.flare.dto; + +import java.io.Serializable; + +/** + * DTO describing surrogate parameters used for dispersion screening of flare releases. + */ +public class FlareDispersionSurrogateDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private final double massRateKgS; + private final double molarRateMoleS; + private final double exitVelocityMs; + private final double momentumFlux; + private final double momentumPerMass; + private final double standardVolumeSm3PerSec; + + public FlareDispersionSurrogateDTO(double massRateKgS, double molarRateMoleS, + double exitVelocityMs, double momentumFlux, double momentumPerMass, + double standardVolumeSm3PerSec) { + this.massRateKgS = massRateKgS; + this.molarRateMoleS = molarRateMoleS; + this.exitVelocityMs = exitVelocityMs; + this.momentumFlux = momentumFlux; + this.momentumPerMass = momentumPerMass; + this.standardVolumeSm3PerSec = standardVolumeSm3PerSec; + } + + public double getMassRateKgS() { + return massRateKgS; + } + + public double getMolarRateMoleS() { + return molarRateMoleS; + } + + public double getExitVelocityMs() { + return exitVelocityMs; + } + + public double getMomentumFlux() { + return momentumFlux; + } + + public double getMomentumPerMass() { + return momentumPerMass; + } + + public double getStandardVolumeSm3PerSec() { + return standardVolumeSm3PerSec; + } +} diff --git a/src/main/java/neqsim/process/equipment/flare/dto/FlarePerformanceDTO.java b/src/main/java/neqsim/process/equipment/flare/dto/FlarePerformanceDTO.java new file mode 100644 index 0000000000..ecd0bf81f8 --- /dev/null +++ b/src/main/java/neqsim/process/equipment/flare/dto/FlarePerformanceDTO.java @@ -0,0 +1,91 @@ +package neqsim.process.equipment.flare.dto; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Map; + +/** + * DTO encapsulating emission, radiation, dispersion and capacity responses for a flare. + */ +public class FlarePerformanceDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private final String label; + private final double heatDutyW; + private final double massRateKgS; + private final double molarRateMoleS; + private final double co2EmissionKgS; + private final double heatFluxAt30mWm2; + private final double distanceTo4kWm2; + private final FlareDispersionSurrogateDTO dispersion; + private final Map emissions; + private final FlareCapacityDTO capacity; + + public FlarePerformanceDTO(String label, double heatDutyW, double massRateKgS, + double molarRateMoleS, double co2EmissionKgS, double heatFluxAt30mWm2, + double distanceTo4kWm2, FlareDispersionSurrogateDTO dispersion, + Map emissions, FlareCapacityDTO capacity) { + this.label = label; + this.heatDutyW = heatDutyW; + this.massRateKgS = massRateKgS; + this.molarRateMoleS = molarRateMoleS; + this.co2EmissionKgS = co2EmissionKgS; + this.heatFluxAt30mWm2 = heatFluxAt30mWm2; + this.distanceTo4kWm2 = distanceTo4kWm2; + this.dispersion = dispersion; + this.emissions = emissions == null ? Collections.emptyMap() : Collections.unmodifiableMap(emissions); + this.capacity = capacity; + } + + public String getLabel() { + return label; + } + + public double getHeatDutyW() { + return heatDutyW; + } + + public double getHeatDutyMW() { + return heatDutyW * 1.0e-6; + } + + public double getMassRateKgS() { + return massRateKgS; + } + + public double getMolarRateMoleS() { + return molarRateMoleS; + } + + public double getCo2EmissionKgS() { + return co2EmissionKgS; + } + + public double getCo2EmissionTonPerDay() { + return co2EmissionKgS * 86400.0 / 1000.0; + } + + public double getHeatFluxAt30mWm2() { + return heatFluxAt30mWm2; + } + + public double getDistanceTo4kWm2() { + return distanceTo4kWm2; + } + + public FlareDispersionSurrogateDTO getDispersion() { + return dispersion; + } + + public Map getEmissions() { + return emissions; + } + + public FlareCapacityDTO getCapacity() { + return capacity; + } + + public boolean isOverloaded() { + return capacity != null && capacity.isOverloaded(); + } +} diff --git a/src/main/java/neqsim/process/safety/DisposalNetwork.java b/src/main/java/neqsim/process/safety/DisposalNetwork.java new file mode 100644 index 0000000000..12d0659907 --- /dev/null +++ b/src/main/java/neqsim/process/safety/DisposalNetwork.java @@ -0,0 +1,132 @@ +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(); + load.massRate += sourceLoad.getMassRateKgS(); + if (sourceLoad.getHeatDutyW() != null) { + load.heatDuty += sourceLoad.getHeatDutyW(); + } + if (sourceLoad.getMolarRateMoleS() != null) { + load.molarRate += sourceLoad.getMolarRateMoleS(); + } + } + + 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.heatDuty; + if (heatDuty <= 0.0) { + heatDuty = basePerformance.getHeatDutyW() + * safeRatio(load.massRate, basePerformance.getMassRateKgS()); + } + double molarRate = load.molarRate; + 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 molarRate; + double heatDuty; + } +} 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..b9d6feafe1 --- /dev/null +++ b/src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java @@ -0,0 +1,38 @@ +package neqsim.process.safety; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import neqsim.process.equipment.flare.Flare; +import neqsim.process.safety.dto.DisposalNetworkSummaryDTO; + +/** + * 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<>(); + + 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); + } +} 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/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; + } +} From 8aac8bcf6e314b4dcc18614bc47b94e6c98b3885 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:18:59 +0100 Subject: [PATCH 05/12] Add process safety reporting module and tests --- .../report/safety/ProcessSafetyReport.java | 343 ++++++++++++++++++ .../safety/ProcessSafetyReportBuilder.java | 337 +++++++++++++++++ .../safety/ProcessSafetyThresholds.java | 111 ++++++ .../util/report/safety/SeverityLevel.java | 26 ++ .../ProcessSafetyReportBuilderTest.java | 148 ++++++++ 5 files changed, 965 insertions(+) create mode 100644 src/main/java/neqsim/process/util/report/safety/ProcessSafetyReport.java create mode 100644 src/main/java/neqsim/process/util/report/safety/ProcessSafetyReportBuilder.java create mode 100644 src/main/java/neqsim/process/util/report/safety/ProcessSafetyThresholds.java create mode 100644 src/main/java/neqsim/process/util/report/safety/SeverityLevel.java create mode 100644 src/test/java/neqsim/process/util/report/safety/ProcessSafetyReportBuilderTest.java 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..95b10b6559 --- /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.isBlank()) { + 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.isBlank()) { + 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..ff9786fcce --- /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.isBlank()) { + 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/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..17370d26b5 --- /dev/null +++ b/src/test/java/neqsim/process/util/report/safety/ProcessSafetyReportBuilderTest.java @@ -0,0 +1,148 @@ +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(); + assertEquals(SeverityLevel.CRITICAL, margin.getSeverity()); + + ProcessSafetyReport.ReliefDeviceAssessment reliefAssessment = report.getReliefDeviceAssessments() + .stream().filter(r -> upset.reliefValve.getName().equals(r.getUnitName())).findFirst() + .orElseThrow(); + 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; + } + } +} From 8d8cef927a69e8cc4c274d4c2d9d6b6e45cbc795 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:24:55 +0100 Subject: [PATCH 06/12] Add missing Javadoc tags for legacy APIs --- .../ModelPredictiveController.java | 4 +++ .../DifferentialPressureFlowCalculator.java | 15 +++++++++++ .../equipment/separator/Separator.java | 2 ++ .../util/spanwagner/NeqSimSpanWagner.java | 27 ++++++++++++++++--- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/main/java/neqsim/process/controllerdevice/ModelPredictiveController.java b/src/main/java/neqsim/process/controllerdevice/ModelPredictiveController.java index 5910a50f8e..603f06c6f5 100644 --- a/src/main/java/neqsim/process/controllerdevice/ModelPredictiveController.java +++ b/src/main/java/neqsim/process/controllerdevice/ModelPredictiveController.java @@ -782,6 +782,8 @@ public void setPreferredControlVector(double... references) { * @deprecated Use {@link #setPreferredControlVector(double...)} to configure the nominal control * point. This method is retained for backwards compatibility with earlier snapshots of * the MPC implementation. + * + * @param references preferred control levels for the energy terms */ @Deprecated public void setEnergyReferenceVector(double... references) { @@ -1200,6 +1202,8 @@ public void setMoveLimits(double minDelta, double maxDelta) { /** * @deprecated Use {@link #setPreferredControlValue(double)} when configuring the MPC economic * target. This method is kept for compatibility with earlier code samples. + * + * @param reference preferred steady-state control value for the single-input controller */ @Deprecated public void setEnergyReference(double reference) { diff --git a/src/main/java/neqsim/process/equipment/diffpressure/DifferentialPressureFlowCalculator.java b/src/main/java/neqsim/process/equipment/diffpressure/DifferentialPressureFlowCalculator.java index e69b81c377..744b72c491 100644 --- a/src/main/java/neqsim/process/equipment/diffpressure/DifferentialPressureFlowCalculator.java +++ b/src/main/java/neqsim/process/equipment/diffpressure/DifferentialPressureFlowCalculator.java @@ -192,6 +192,14 @@ public static FlowCalculationResult calculate(double[] pressureBarg, double[] te /** * Convenience overload using default composition and normalisation. + * + * @param pressureBarg pressure values in barg + * @param temperatureC temperature values in degrees Celsius + * @param differentialPressureMbar differential pressure across restriction in mbar + * @param flowType device type (Venturi, Orifice, ISA1932, V-Cone, DallTube, Annubar, Nozzle, + * Simplified, Perrys-Orifice) + * @param flowData geometry parameters (see individual calculation methods) + * @return flow calculation result */ public static FlowCalculationResult calculate(double[] pressureBarg, double[] temperatureC, double[] differentialPressureMbar, String flowType, double[] flowData) { @@ -201,6 +209,13 @@ public static FlowCalculationResult calculate(double[] pressureBarg, double[] te /** * Convenience overload with default flow data and composition. + * + * @param pressureBarg pressure values in barg + * @param temperatureC temperature values in degrees Celsius + * @param differentialPressureMbar differential pressure across restriction in mbar + * @param flowType device type (Venturi, Orifice, ISA1932, V-Cone, DallTube, Annubar, Nozzle, + * Simplified, Perrys-Orifice) + * @return flow calculation result */ public static FlowCalculationResult calculate(double[] pressureBarg, double[] temperatureC, double[] differentialPressureMbar, String flowType) { diff --git a/src/main/java/neqsim/process/equipment/separator/Separator.java b/src/main/java/neqsim/process/equipment/separator/Separator.java index 8f89bd769f..baa166d5ba 100644 --- a/src/main/java/neqsim/process/equipment/separator/Separator.java +++ b/src/main/java/neqsim/process/equipment/separator/Separator.java @@ -790,6 +790,7 @@ public void setOrientation(String orientation) { * used for volume calculation, gas superficial velocity, and settling time. *

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

* + * @param volumeTarget desired liquid volume to be held in the separator [m3] * @return liquid level in the separator */ public double levelFromVolume(double volumeTarget) { diff --git a/src/main/java/neqsim/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; From 93e8467897a277a7961936692292c5af0bc125f9 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:27:57 +0100 Subject: [PATCH 07/12] Improve disposal load estimation for missing data --- .../process/safety/DisposalNetwork.java | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/main/java/neqsim/process/safety/DisposalNetwork.java b/src/main/java/neqsim/process/safety/DisposalNetwork.java index 12d0659907..bedbc82122 100644 --- a/src/main/java/neqsim/process/safety/DisposalNetwork.java +++ b/src/main/java/neqsim/process/safety/DisposalNetwork.java @@ -70,12 +70,19 @@ private DisposalLoadCaseResultDTO evaluateLoadCase(ProcessSafetyLoadCase loadCas } AggregatedLoad load = aggregated.computeIfAbsent(unitName, k -> new AggregatedLoad()); ReliefSourceLoad sourceLoad = entry.getValue(); - load.massRate += sourceLoad.getMassRateKgS(); - if (sourceLoad.getHeatDutyW() != null) { - load.heatDuty += sourceLoad.getHeatDutyW(); + double massRate = sourceLoad.getMassRateKgS(); + load.massRate += massRate; + Double heatDuty = sourceLoad.getHeatDutyW(); + if (heatDuty != null) { + load.specifiedHeatDuty += heatDuty; + } else { + load.massWithoutHeatDuty += massRate; } - if (sourceLoad.getMolarRateMoleS() != null) { - load.molarRate += sourceLoad.getMolarRateMoleS(); + Double molarRate = sourceLoad.getMolarRateMoleS(); + if (molarRate != null) { + load.specifiedMolarRate += molarRate; + } else { + load.massWithoutMolarRate += massRate; } } @@ -91,12 +98,20 @@ private DisposalLoadCaseResultDTO evaluateLoadCase(ProcessSafetyLoadCase loadCas } AggregatedLoad load = entry.getValue(); FlarePerformanceDTO basePerformance = flare.getPerformanceSummary(); - double heatDuty = load.heatDuty; + 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.molarRate; + 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()); @@ -126,7 +141,9 @@ private double safeRatio(double numerator, double denominator) { private static class AggregatedLoad { double massRate; - double molarRate; - double heatDuty; + double specifiedMolarRate; + double specifiedHeatDuty; + double massWithoutMolarRate; + double massWithoutHeatDuty; } } From 4644005925a0c8f90fb5035a97211992f6f0600f Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:35:32 +0100 Subject: [PATCH 08/12] Fix Javadoc warnings and errors --- .../neqsim/process/equipment/flare/Flare.java | 2 +- .../UtilityStreamSpecification.java | 81 ++++++++++++++++--- .../util/referenceequations/Ammonia2023.java | 20 ++++- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/src/main/java/neqsim/process/equipment/flare/Flare.java b/src/main/java/neqsim/process/equipment/flare/Flare.java index 447a838179..34b0635955 100644 --- a/src/main/java/neqsim/process/equipment/flare/Flare.java +++ b/src/main/java/neqsim/process/equipment/flare/Flare.java @@ -555,7 +555,7 @@ public double getMolarUtilization() { } /** - * Determine if any configured capacity is overloaded (> 1.0 utilization). + * Determine if any configured capacity is overloaded ({@literal >} 1.0 utilization). * * @return true if overloaded */ diff --git a/src/main/java/neqsim/process/equipment/heatexchanger/UtilityStreamSpecification.java b/src/main/java/neqsim/process/equipment/heatexchanger/UtilityStreamSpecification.java index 9d820368f4..c83ae161ea 100644 --- a/src/main/java/neqsim/process/equipment/heatexchanger/UtilityStreamSpecification.java +++ b/src/main/java/neqsim/process/equipment/heatexchanger/UtilityStreamSpecification.java @@ -19,57 +19,104 @@ public class UtilityStreamSpecification implements Serializable { private double heatCapacityRate = Double.NaN; // W/K private double overallHeatTransferCoefficient = Double.NaN; // W/(m^2*K) - /** Returns the utility supply temperature in Kelvin. */ + /** + * Returns the utility supply temperature in Kelvin. + * + * @return supply temperature in Kelvin + */ public double getSupplyTemperature() { return supplyTemperature; } - /** Returns the utility return temperature in Kelvin. */ + /** + * Returns the utility return temperature in Kelvin. + * + * @return return temperature in Kelvin + */ public double getReturnTemperature() { return returnTemperature; } - /** Returns the minimum approach temperature in Kelvin. */ + /** + * Returns the minimum approach temperature in Kelvin. + * + * @return approach temperature in Kelvin + */ public double getApproachTemperature() { return approachTemperature; } - /** Returns the utility heat-capacity rate in W/K. */ + /** + * Returns the utility heat-capacity rate in W/K. + * + * @return heat-capacity rate in W/K + */ public double getHeatCapacityRate() { return heatCapacityRate; } - /** Returns the assumed overall heat-transfer coefficient in W/(m^2*K). */ + /** + * Returns the assumed overall heat-transfer coefficient in W/(m^2*K). + * + * @return overall heat-transfer coefficient in W/(m^2*K) + */ public double getOverallHeatTransferCoefficient() { return overallHeatTransferCoefficient; } - /** Set the utility supply temperature in Kelvin. */ + /** + * Set the utility supply temperature in Kelvin. + * + * @param temperature supply temperature in Kelvin + */ public void setSupplyTemperature(double temperature) { this.supplyTemperature = temperature; } - /** Set the utility supply temperature using the specified unit. */ + /** + * Set the utility supply temperature using the specified unit. + * + * @param temperature supply temperature value + * @param unit unit of the supplied temperature + */ public void setSupplyTemperature(double temperature, String unit) { this.supplyTemperature = new TemperatureUnit(temperature, unit).getValue("K"); } - /** Set the utility return temperature in Kelvin. */ + /** + * Set the utility return temperature in Kelvin. + * + * @param temperature return temperature in Kelvin + */ public void setReturnTemperature(double temperature) { this.returnTemperature = temperature; } - /** Set the utility return temperature using the specified unit. */ + /** + * Set the utility return temperature using the specified unit. + * + * @param temperature return temperature value + * @param unit unit of the supplied temperature + */ public void setReturnTemperature(double temperature, String unit) { this.returnTemperature = new TemperatureUnit(temperature, unit).getValue("K"); } - /** Set the minimum approach temperature (absolute difference) in Kelvin. */ + /** + * Set the minimum approach temperature (absolute difference) in Kelvin. + * + * @param approach approach temperature in Kelvin + */ public void setApproachTemperature(double approach) { this.approachTemperature = approach; } - /** Set the minimum approach temperature (absolute difference) using the specified unit. */ + /** + * Set the minimum approach temperature (absolute difference) using the specified unit. + * + * @param approach approach temperature value + * @param unit unit of the supplied temperature difference + */ public void setApproachTemperature(double approach, String unit) { switch (unit) { case "K": @@ -89,12 +136,20 @@ public void setApproachTemperature(double approach, String unit) { } } - /** Set the utility heat-capacity rate in W/K. */ + /** + * Set the utility heat-capacity rate in W/K. + * + * @param heatCapacityRate heat-capacity rate in W/K + */ public void setHeatCapacityRate(double heatCapacityRate) { this.heatCapacityRate = heatCapacityRate; } - /** Set the assumed overall heat-transfer coefficient in W/(m^2*K). */ + /** + * Set the assumed overall heat-transfer coefficient in W/(m^2*K). + * + * @param overallHeatTransferCoefficient overall heat-transfer coefficient in W/(m^2*K) + */ public void setOverallHeatTransferCoefficient(double overallHeatTransferCoefficient) { this.overallHeatTransferCoefficient = overallHeatTransferCoefficient; } diff --git a/src/main/java/neqsim/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); From 0ca58370d5b9dde8cf89eec979dec24b60d3720f Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:43:51 +0100 Subject: [PATCH 09/12] Enhance process safety analyzer scenario support --- .../process/safety/ProcessSafetyAnalyzer.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java b/src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java index b9d6feafe1..25eecae084 100644 --- a/src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java +++ b/src/main/java/neqsim/process/safety/ProcessSafetyAnalyzer.java @@ -2,10 +2,19 @@ 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. @@ -15,6 +24,22 @@ public class ProcessSafetyAnalyzer implements Serializable { 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); @@ -35,4 +60,88 @@ public List getLoadCases() { 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(); + } } From 87465ce546a285be114af086f9cdc7fde1cb5b80 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sat, 1 Nov 2025 16:56:02 +0100 Subject: [PATCH 10/12] Add distillation solver metrics and feed tests --- .../distillation/DistillationColumn.java | 467 +++++++++++------- .../distillation/DistillationColumnTest.java | 57 +++ 2 files changed, 339 insertions(+), 185 deletions(-) diff --git a/src/main/java/neqsim/process/equipment/distillation/DistillationColumn.java b/src/main/java/neqsim/process/equipment/distillation/DistillationColumn.java index 5139e73bb9..6ae489b69e 100644 --- a/src/main/java/neqsim/process/equipment/distillation/DistillationColumn.java +++ b/src/main/java/neqsim/process/equipment/distillation/DistillationColumn.java @@ -1,6 +1,7 @@ package neqsim.process.equipment.distillation; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -69,6 +70,14 @@ public enum SolverType { /** Relaxation factor used when {@link SolverType#DAMPED_SUBSTITUTION} is active. */ private double relaxationFactor = 0.5; + /** Minimum relaxation factor used when adaptive damping scales down the step. */ + private double minAdaptiveRelaxation = 0.1; + /** Maximum relaxation factor allowed by the adaptive controller. */ + private double maxAdaptiveRelaxation = 1.0; + /** Factor used to expand the relaxation factor when residuals shrink. */ + private double relaxationIncreaseFactor = 1.2; + /** Factor used to shrink the relaxation factor when residuals grow. */ + private double relaxationDecreaseFactor = 0.5; Mixer feedmixer = new Mixer("temp mixer"); double bottomTrayPressure = -1.0; @@ -88,6 +97,17 @@ public enum SolverType { */ private double err = 1.0e10; + /** Last number of iterations executed by the active solver. */ + private int lastIterationCount = 0; + /** Last recorded average temperature residual in Kelvin. */ + private double lastTemperatureResidual = 0.0; + /** Last recorded relative mass balance residual. */ + private double lastMassResidual = 0.0; + /** Last recorded relative enthalpy residual. */ + private double lastEnergyResidual = 0.0; + /** Duration of the latest solve step in seconds. */ + private double lastSolveTimeSeconds = 0.0; + /** * Instead of Map<Integer,StreamInterface>, we store a list of feed streams per tray number. * This allows multiple feeds to the same tray. @@ -169,6 +189,20 @@ public void addFeedStream(StreamInterface inputStream, int feedTrayNumber) { setDoInitializion(true); } + /** + * Return the feed streams connected to a given tray. + * + * @param feedTrayNumber tray index where feeds are connected + * @return immutable view of feed streams connected to the tray + */ + public List getFeedStreams(int feedTrayNumber) { + List feeds = feedStreams.get(feedTrayNumber); + if (feeds == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(feeds); + } + /** * Prepare the column for calculation by estimating tray temperatures and linking streams between * trays. @@ -188,6 +222,7 @@ public void init() { // If feedStreams is empty, nothing to do if (feedStreams.isEmpty()) { + resetLastSolveMetrics(); return; } @@ -288,25 +323,31 @@ public void init() { *

* Solve the column until tray temperatures converge. * - * The method applies sequential substitution. Pressures are set linearly between bottom and top. - * Each iteration performs an upward sweep where liquid flows downward followed by a downward - * sweep where vapour flows upward. The sum of absolute temperature changes is used as error - * measure. + * The method applies sequential substitution with an adaptive relaxation controller. Pressures + * are set linearly between bottom and top. Each iteration performs an upward sweep where liquid + * flows downward followed by a downward sweep where vapour flows upward. Tray temperatures and + * inter-tray stream flow rates are relaxed if the combined temperature, mass and energy residuals + * grow, providing basic line-search behaviour. *

*/ @Override public void run(UUID id) { - if (solverType == SolverType.DAMPED_SUBSTITUTION) { - runDamped(id); - return; - } + double initialRelaxation = + solverType == SolverType.DAMPED_SUBSTITUTION ? relaxationFactor : 1.0; + solveSequential(id, initialRelaxation); + } + /** + * Execute the sequential substitution solver with an adaptive relaxation controller. + * + * @param id calculation identifier + * @param initialRelaxation relaxation factor applied to the first iteration + */ + private void solveSequential(UUID id, double initialRelaxation) { if (feedStreams.isEmpty()) { - // no feeds, nothing to do return; } - // Find the *lowest* tray number among feed trays, for reference. int firstFeedTrayNumber = feedStreams.keySet().stream().min(Integer::compareTo).get(); if (bottomTrayPressure < 0) { @@ -320,16 +361,11 @@ public void run(UUID id) { if (numberOfTrays > 1) { dp = (bottomTrayPressure - topTrayPressure) / (numberOfTrays - 1.0); } - // Set tray pressures linearly from bottom to top for (int i = 0; i < numberOfTrays; i++) { trays.get(i).setPressure(bottomTrayPressure - i * dp); } - // numberOfFeedsUsed[i] will track how many streams we have assigned - // to tray i so we call getStream(...) index properly int[] numeroffeeds = new int[numberOfTrays]; - - // For each tray in feedStreams, pass each feed into that tray for (Entry> entry : feedStreams.entrySet()) { int feedTrayNumber = entry.getKey(); List trayFeeds = entry.getValue(); @@ -340,7 +376,6 @@ public void run(UUID id) { } } - // If there's only one tray total, just run it if (numberOfTrays == 1) { trays.get(0).run(id); gasOutStream.setThermoSystem(trays.get(0).getGasOutStream().getThermoSystem().clone()); @@ -348,95 +383,143 @@ public void run(UUID id) { gasOutStream.setCalculationIdentifier(id); liquidOutStream.setCalculationIdentifier(id); setCalculationIdentifier(id); + lastIterationCount = 1; + lastTemperatureResidual = 0.0; + lastMassResidual = 0.0; + lastEnergyResidual = 0.0; + lastSolveTimeSeconds = 0.0; return; } - // If we haven't done an init or we added feeds, do it now if (isDoInitializion()) { this.init(); } err = 1.0e10; - double errOld; int iter = 0; double massErr = 1.0e10; double energyErr = 1.0e10; + double previousCombinedResidual = Double.POSITIVE_INFINITY; + + long startTime = System.nanoTime(); - // We'll use this array to measure temperature changes in each iteration double[] oldtemps = new double[numberOfTrays]; + StreamInterface[] previousGasStreams = new StreamInterface[numberOfTrays]; + StreamInterface[] previousLiquidStreams = new StreamInterface[numberOfTrays]; + + double relaxation = Math.max(minAdaptiveRelaxation, + Math.min(maxAdaptiveRelaxation, initialRelaxation)); - // Make sure feed tray is up to date trays.get(firstFeedTrayNumber).run(id); - // Start the iterative solution + int iterationLimit = Math.max(maxNumberOfIterations, numberOfTrays * 3); + do { iter++; - errOld = err; - err = 0.0; - // Snapshot old temperatures + for (int i = 0; i < numberOfTrays; i++) { oldtemps[i] = trays.get(i).getThermoSystem().getTemperature(); } - // Move upward (bottom to feed tray) + StreamInterface[] currentGasStreams = new StreamInterface[numberOfTrays]; + StreamInterface[] currentLiquidStreams = new StreamInterface[numberOfTrays]; + for (int i = firstFeedTrayNumber; i > 1; i--) { - int replaceStream1 = trays.get(i - 1).getNumberOfInputStreams() - 1; - trays.get(i - 1).replaceStream(replaceStream1, trays.get(i).getLiquidOutStream()); - trays.get(i - 1).setPressure(bottomTrayPressure - (i - 1) * dp); + int replaceStream = trays.get(i - 1).getNumberOfInputStreams() - 1; + StreamInterface relaxedLiquid = applyRelaxation(previousLiquidStreams[i], + trays.get(i).getLiquidOutStream(), relaxation); + trays.get(i - 1).replaceStream(replaceStream, relaxedLiquid); + currentLiquidStreams[i] = relaxedLiquid.clone(); trays.get(i - 1).run(id); } - // reboiler tray hooking up to tray 1 int streamNumb = trays.get(0).getNumberOfInputStreams() - 1; - trays.get(0).setPressure(bottomTrayPressure); - trays.get(0).replaceStream(streamNumb, trays.get(1).getLiquidOutStream()); + StreamInterface reboilerFeed = applyRelaxation(previousLiquidStreams[1], + trays.get(1).getLiquidOutStream(), relaxation); + trays.get(0).replaceStream(streamNumb, reboilerFeed); + currentLiquidStreams[1] = reboilerFeed.clone(); trays.get(0).run(id); - // Move downward (feed tray to top) for (int i = 1; i <= numberOfTrays - 1; i++) { - // The top tray has 1 input less int replaceStream = trays.get(i).getNumberOfInputStreams() - 2; if (i == (numberOfTrays - 1)) { replaceStream = trays.get(i).getNumberOfInputStreams() - 1; } - trays.get(i).replaceStream(replaceStream, trays.get(i - 1).getGasOutStream()); + StreamInterface relaxedGas = applyRelaxation(previousGasStreams[i - 1], + trays.get(i - 1).getGasOutStream(), relaxation); + trays.get(i).replaceStream(replaceStream, relaxedGas); + currentGasStreams[i - 1] = relaxedGas.clone(); trays.get(i).run(id); } - // Then from top minus 1 down to feed tray for (int i = numberOfTrays - 2; i >= firstFeedTrayNumber; i--) { int replaceStream = trays.get(i).getNumberOfInputStreams() - 1; - trays.get(i).replaceStream(replaceStream, trays.get(i + 1).getLiquidOutStream()); + StreamInterface relaxedLiquid = applyRelaxation(previousLiquidStreams[i + 1], + trays.get(i + 1).getLiquidOutStream(), relaxation); + trays.get(i).replaceStream(replaceStream, relaxedLiquid); + currentLiquidStreams[i + 1] = relaxedLiquid.clone(); trays.get(i).run(id); } - // Sum up changes in temperature + double temperatureResidual = 0.0; + double effectiveRelaxation = Math.max(0.0, Math.min(1.0, relaxation)); for (int i = 0; i < numberOfTrays; i++) { - err += Math.abs(oldtemps[i] - trays.get(i).getThermoSystem().getTemperature()); + double updated = trays.get(i).getThermoSystem().getTemperature(); + double newTemp = oldtemps[i] + effectiveRelaxation * (updated - oldtemps[i]); + trays.get(i).setTemperature(newTemp); + temperatureResidual += Math.abs(newTemp - oldtemps[i]); } + temperatureResidual /= Math.max(1, numberOfTrays); + err = temperatureResidual; massErr = getMassBalanceError(); energyErr = getEnergyBalanceError(); - logger.info("error iteration = " + iter + " err = " + err + " massErr= " + massErr - + " energyErr= " + energyErr); + double combinedResidual = Math.max( + Math.max(err / temperatureTolerance, massErr / massBalanceTolerance), + energyErr / enthalpyBalanceTolerance); + + if (combinedResidual > previousCombinedResidual * 1.05) { + relaxation = Math.max(minAdaptiveRelaxation, relaxation * relaxationDecreaseFactor); + } else if (combinedResidual < previousCombinedResidual * 0.95) { + relaxation = Math.min(maxAdaptiveRelaxation, relaxation * relaxationIncreaseFactor); + } + + previousCombinedResidual = combinedResidual; + + for (int i = 0; i < numberOfTrays; i++) { + if (currentGasStreams[i] != null) { + previousGasStreams[i] = currentGasStreams[i]; + } + if (currentLiquidStreams[i] != null) { + previousLiquidStreams[i] = currentLiquidStreams[i]; + } + } + + logger.info("iteration {} relaxation={} tempErr={} massErr={} energyErr={}", iter, relaxation, + err, massErr, energyErr); } while ((err > temperatureTolerance || massErr > massBalanceTolerance - || energyErr > enthalpyBalanceTolerance) && err < errOld && iter < maxNumberOfIterations); + || energyErr > enthalpyBalanceTolerance) && iter < iterationLimit); + + lastIterationCount = iter; + lastTemperatureResidual = err; + lastMassResidual = massErr; + lastEnergyResidual = energyErr; + lastSolveTimeSeconds = (System.nanoTime() - startTime) / 1.0e9; - // Once converged, fill final gasOut/liquidOut streams gasOutStream .setThermoSystem(trays.get(numberOfTrays - 1).getGasOutStream().getThermoSystem().clone()); gasOutStream.setCalculationIdentifier(id); liquidOutStream.setThermoSystem(trays.get(0).getLiquidOutStream().getThermoSystem().clone()); liquidOutStream.setCalculationIdentifier(id); - // Mark everything as solved for (int i = 0; i < numberOfTrays; i++) { trays.get(i).setCalculationIdentifier(id); } setCalculationIdentifier(id); } + /** * Solve the column using a simple Broyden mixing of tray temperatures. * @@ -444,6 +527,7 @@ public void run(UUID id) { */ public void runBroyden(UUID id) { if (feedStreams.isEmpty()) { + resetLastSolveMetrics(); return; } @@ -479,6 +563,8 @@ public void runBroyden(UUID id) { trays.get(firstFeedTrayNumber).run(id); + long startTime = System.nanoTime(); + do { iter++; errOld = err; @@ -529,6 +615,12 @@ public void runBroyden(UUID id) { } while ((err > temperatureTolerance || massErr > massBalanceTolerance || energyErr > enthalpyBalanceTolerance) && err < errOld && iter < maxNumberOfIterations); + lastIterationCount = iter; + lastTemperatureResidual = err; + lastMassResidual = massErr; + lastEnergyResidual = energyErr; + lastSolveTimeSeconds = (System.nanoTime() - startTime) / 1.0e9; + gasOutStream .setThermoSystem(trays.get(numberOfTrays - 1).getGasOutStream().getThermoSystem().clone()); gasOutStream.setCalculationIdentifier(id); @@ -542,115 +634,15 @@ public void runBroyden(UUID id) { } /** - * Solve the column using a damped sequential substitution scheme. - * - *

- * This method implements a simple relaxation (or damping) of the tray temperatures between - * iterations. Literature on tray based distillation modelling (e.g. Seader, Henley and Roper, - * Separation Process Principles) recommends damping to avoid oscillations and improve - * convergence when applying the classic inside-out algorithm. A relaxation factor of 1.0 - * reproduces the behaviour of {@link #run(UUID)} while smaller factors provide additional - * stability. - *

+ * Solve the column using the adaptive sequential substitution scheme with a damped starting step. * * @param id calculation identifier */ private void runDamped(UUID id) { - if (feedStreams.isEmpty()) { - return; - } - - int firstFeedTrayNumber = feedStreams.keySet().stream().min(Integer::compareTo).get(); - - if (bottomTrayPressure < 0) { - bottomTrayPressure = getTray(firstFeedTrayNumber).getStream(0).getPressure(); - } - if (topTrayPressure < 0) { - topTrayPressure = getTray(firstFeedTrayNumber).getStream(0).getPressure(); - } - - double dp = 0.0; - if (numberOfTrays > 1) { - dp = (bottomTrayPressure - topTrayPressure) / (numberOfTrays - 1.0); - } - for (int i = 0; i < numberOfTrays; i++) { - trays.get(i).setPressure(bottomTrayPressure - i * dp); - } - - if (isDoInitializion()) { - this.init(); - } - - err = 1.0e10; - double errOld; - int iter = 0; - double massErr = 1.0e10; - double energyErr = 1.0e10; - - double[] oldtemps = new double[numberOfTrays]; - - trays.get(firstFeedTrayNumber).run(id); - - do { - iter++; - errOld = err; - err = 0.0; - for (int i = 0; i < numberOfTrays; i++) { - oldtemps[i] = trays.get(i).getThermoSystem().getTemperature(); - } - - for (int i = firstFeedTrayNumber; i > 1; i--) { - int replaceStream1 = trays.get(i - 1).getNumberOfInputStreams() - 1; - trays.get(i - 1).replaceStream(replaceStream1, trays.get(i).getLiquidOutStream()); - trays.get(i - 1).run(id); - } - - int streamNumb = trays.get(0).getNumberOfInputStreams() - 1; - trays.get(0).replaceStream(streamNumb, trays.get(1).getLiquidOutStream()); - trays.get(0).run(id); - - for (int i = 1; i <= numberOfTrays - 1; i++) { - int replaceStream = trays.get(i).getNumberOfInputStreams() - 2; - if (i == (numberOfTrays - 1)) { - replaceStream = trays.get(i).getNumberOfInputStreams() - 1; - } - trays.get(i).replaceStream(replaceStream, trays.get(i - 1).getGasOutStream()); - trays.get(i).run(id); - } - - for (int i = numberOfTrays - 2; i >= firstFeedTrayNumber; i--) { - int replaceStream = trays.get(i).getNumberOfInputStreams() - 1; - trays.get(i).replaceStream(replaceStream, trays.get(i + 1).getLiquidOutStream()); - trays.get(i).run(id); - } - - for (int i = 0; i < numberOfTrays; i++) { - double updated = trays.get(i).getThermoSystem().getTemperature(); - double newTemp = oldtemps[i] + relaxationFactor * (updated - oldtemps[i]); - trays.get(i).setTemperature(newTemp); - err += Math.abs(newTemp - oldtemps[i]); - } - - massErr = getMassBalanceError(); - energyErr = getEnergyBalanceError(); - - logger.info("error iteration = " + iter + " err = " + err + " massErr= " + massErr - + " energyErr= " + energyErr); - } while ((err > temperatureTolerance || massErr > massBalanceTolerance - || energyErr > enthalpyBalanceTolerance) && err < errOld && iter < maxNumberOfIterations); - - gasOutStream - .setThermoSystem(trays.get(numberOfTrays - 1).getGasOutStream().getThermoSystem().clone()); - gasOutStream.setCalculationIdentifier(id); - liquidOutStream.setThermoSystem(trays.get(0).getLiquidOutStream().getThermoSystem().clone()); - liquidOutStream.setCalculationIdentifier(id); - - for (int i = 0; i < numberOfTrays; i++) { - trays.get(i).setCalculationIdentifier(id); - } - setCalculationIdentifier(id); + solveSequential(id, relaxationFactor); } + /** {@inheritDoc} */ @Override @ExcludeFromJacocoGeneratedReport @@ -753,6 +745,78 @@ public boolean solved() { return (err < temperatureTolerance); } + /** + * Retrieve the iteration count of the most recent solve. + * + * @return iteration count + */ + public int getLastIterationCount() { + return lastIterationCount; + } + + /** + * Retrieve the latest average temperature residual in Kelvin. + * + * @return average temperature residual + */ + public double getLastTemperatureResidual() { + return lastTemperatureResidual; + } + + /** + * Retrieve the latest relative mass residual. + * + * @return relative mass balance residual + */ + public double getLastMassResidual() { + return lastMassResidual; + } + + /** + * Retrieve the latest relative enthalpy residual. + * + * @return relative enthalpy residual + */ + public double getLastEnergyResidual() { + return lastEnergyResidual; + } + + /** + * Retrieve the duration of the most recent solve in seconds. + * + * @return solve time in seconds + */ + public double getLastSolveTimeSeconds() { + return lastSolveTimeSeconds; + } + + /** + * Access the configured relative mass balance tolerance. + * + * @return mass balance tolerance + */ + public double getMassBalanceTolerance() { + return massBalanceTolerance; + } + + /** + * Access the configured relative enthalpy balance tolerance. + * + * @return enthalpy balance tolerance + */ + public double getEnthalpyBalanceTolerance() { + return enthalpyBalanceTolerance; + } + + /** + * Access the configured average temperature tolerance. + * + * @return temperature tolerance in Kelvin + */ + public double getTemperatureTolerance() { + return temperatureTolerance; + } + /** *

* Setter for the field maxNumberOfIterations. @@ -935,33 +999,6 @@ public void setEnthalpyBalanceTolerance(double tol) { this.enthalpyBalanceTolerance = tol; } - /** - * Get temperature convergence tolerance. - * - * @return tolerance value - */ - public double getTemperatureTolerance() { - return temperatureTolerance; - } - - /** - * Get mass balance convergence tolerance. - * - * @return tolerance value - */ - public double getMassBalanceTolerance() { - return massBalanceTolerance; - } - - /** - * Get enthalpy balance convergence tolerance. - * - * @return tolerance value - */ - public double getEnthalpyBalanceTolerance() { - return enthalpyBalanceTolerance; - } - /** * Check mass balance for all components. * @@ -1034,11 +1071,9 @@ public boolean componentMassBalanceCheck(String componentName) { } /** - *

- * getMassBalanceError. - *

+ * Calculate the relative mass balance error across the column. * - * @return a double + * @return maximum of tray-wise and overall relative mass imbalance */ public double getMassBalanceError() { double[] massInput = new double[numberOfTrays]; @@ -1054,17 +1089,26 @@ public double getMassBalanceError() { massOutput[i] += trays.get(i).getLiquidOutStream().getFlowRate("kg/hr"); massBalance[i] = massInput[i] - massOutput[i]; } - double massError = 0.0; + double trayRelativeError = 0.0; + double totalInlet = 0.0; + double totalResidual = 0.0; for (int i = 0; i < numberOfTrays; i++) { - massError += Math.abs(massBalance[i]); + double inlet = Math.abs(massInput[i]); + double imbalance = Math.abs(massBalance[i]); + if (inlet > 1e-12) { + trayRelativeError = Math.max(trayRelativeError, imbalance / inlet); + } + totalInlet += inlet; + totalResidual += imbalance; } - return massError; + double columnRelative = totalInlet > 1e-12 ? totalResidual / totalInlet : totalResidual; + return Math.max(trayRelativeError, columnRelative); } /** - * Calculates the total enthalpy imbalance across all trays. + * Calculates the relative enthalpy imbalance across all trays. * - * @return the summed absolute enthalpy imbalance + * @return maximum of tray-wise and overall relative enthalpy imbalance */ public double getEnergyBalanceError() { double[] energyInput = new double[numberOfTrays]; @@ -1080,11 +1124,64 @@ public double getEnergyBalanceError() { energyOutput[i] += trays.get(i).getLiquidOutStream().getFluid().getEnthalpy(); energyBalance[i] = energyInput[i] - energyOutput[i]; } - double energyError = 0.0; + double trayRelativeError = 0.0; + double totalInlet = 0.0; + double totalResidual = 0.0; for (int i = 0; i < numberOfTrays; i++) { - energyError += Math.abs(energyBalance[i]); + double inlet = Math.abs(energyInput[i]); + double imbalance = Math.abs(energyBalance[i]); + if (inlet > 1e-12) { + trayRelativeError = Math.max(trayRelativeError, imbalance / inlet); + } + totalInlet += inlet; + totalResidual += imbalance; + } + double columnRelative = totalInlet > 1e-12 ? totalResidual / totalInlet : totalResidual; + return Math.max(trayRelativeError, columnRelative); + } + + /** + * Blend the current stream update with the previous iterate using the provided relaxation factor. + * + * @param previous stream from the previous iteration (may be {@code null}) + * @param current current iteration stream + * @param relaxation relaxation factor applied to the update + * @return relaxed stream instance to be used in the next tear + */ + private StreamInterface applyRelaxation(StreamInterface previous, StreamInterface current, + double relaxation) { + StreamInterface relaxed = current.clone(); + if (previous == null) { + relaxed.run(); + return relaxed; } - return energyError; + + double step = Math.max(0.0, Math.min(1.0, relaxation)); + double previousFlow = previous.getFlowRate("kg/hr"); + double currentFlow = current.getFlowRate("kg/hr"); + double mixedFlow = previousFlow + step * (currentFlow - previousFlow); + relaxed.setFlowRate(mixedFlow, "kg/hr"); + + double mixedTemperature = previous.getTemperature("K") + + step * (current.getTemperature("K") - previous.getTemperature("K")); + relaxed.setTemperature(mixedTemperature, "K"); + + double mixedPressure = previous.getPressure("bara") + + step * (current.getPressure("bara") - previous.getPressure("bara")); + relaxed.setPressure(mixedPressure, "bara"); + + relaxed.run(); + + return relaxed; + } + + /** Reset cached solve metrics when no calculation is performed. */ + private void resetLastSolveMetrics() { + lastIterationCount = 0; + lastTemperatureResidual = 0.0; + lastMassResidual = 0.0; + lastEnergyResidual = 0.0; + lastSolveTimeSeconds = 0.0; } /** diff --git a/src/test/java/neqsim/process/equipment/distillation/DistillationColumnTest.java b/src/test/java/neqsim/process/equipment/distillation/DistillationColumnTest.java index 8a76dea8e9..37400ecdaf 100644 --- a/src/test/java/neqsim/process/equipment/distillation/DistillationColumnTest.java +++ b/src/test/java/neqsim/process/equipment/distillation/DistillationColumnTest.java @@ -1,6 +1,8 @@ package neqsim.process.equipment.distillation; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.UUID; import org.junit.jupiter.api.Test; import neqsim.process.equipment.stream.Stream; import neqsim.process.equipment.stream.StreamInterface; @@ -212,6 +214,61 @@ public void debutanizerTest() { assertEquals(0.0, massbalance, 0.2); } + @Test + public void adaptiveSolverRecordsSolveMetrics() { + SystemInterface simpleSystem = new SystemSrkEos(298.15, 5.0); + simpleSystem.addComponent("methane", 1.0); + simpleSystem.addComponent("ethane", 1.0); + simpleSystem.createDatabase(true); + simpleSystem.setMixingRule("classic"); + + Stream feed = new Stream("metricsFeed", simpleSystem); + feed.run(); + + DistillationColumn column = new DistillationColumn("metrics column", 1, true, true); + column.addFeedStream(feed, 1); + column.run(); + + DistillationColumn broydenColumn = new DistillationColumn("metrics column broyden", 1, true, true); + Stream broydenFeed = new Stream("metricsFeedBroyden", simpleSystem.clone()); + broydenFeed.run(); + broydenColumn.addFeedStream(broydenFeed, 1); + broydenColumn.runBroyden(UUID.randomUUID()); + + assertTrue(column.getLastIterationCount() > 0); + assertTrue(column.getLastTemperatureResidual() >= 0.0); + assertTrue(Double.isFinite(column.getLastMassResidual())); + assertTrue(Double.isFinite(column.getLastEnergyResidual())); + assertTrue(column.getLastSolveTimeSeconds() >= 0.0); + assertTrue(Double.isFinite(broydenColumn.getLastMassResidual())); + assertTrue(Double.isFinite(broydenColumn.getLastEnergyResidual())); + } + + @Test + public void multipleFeedsOnDifferentTraysAreHandled() { + SystemInterface simpleSystem = new SystemSrkEos(298.15, 5.0); + simpleSystem.addComponent("methane", 1.0); + simpleSystem.addComponent("ethane", 1.0); + simpleSystem.createDatabase(true); + simpleSystem.setMixingRule("classic"); + + Stream feedOne = new Stream("feedOne", simpleSystem.clone()); + feedOne.run(); + Stream feedTwo = new Stream("feedTwo", simpleSystem.clone()); + feedTwo.run(); + Stream feedThree = new Stream("feedThree", simpleSystem.clone()); + feedThree.run(); + + DistillationColumn column = new DistillationColumn("feed tracking", 3, true, true); + column.addFeedStream(feedOne, 1); + column.addFeedStream(feedTwo, 1); + column.addFeedStream(feedThree, 3); + + assertEquals(2, column.getFeedStreams(1).size()); + assertEquals(1, column.getFeedStreams(3).size()); + assertEquals(0, column.getFeedStreams(2).size()); + } + /** * */ From 186a93084c3fb3d4a6242f9196064e05075c3dd7 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sun, 2 Nov 2025 07:13:13 +0100 Subject: [PATCH 11/12] Fix ProcessSystem replacement and align equipment factory --- docs/equipment_factory.md | 34 +++ .../process/equipment/EquipmentEnum.java | 3 +- .../process/equipment/EquipmentFactory.java | 217 ++++++++++++------ .../process/processmodel/ProcessSystem.java | 14 +- .../equipment/EquipmentFactoryTest.java | 78 +++++++ .../ProcessSystemReplaceObjectTest.java | 44 ++++ 6 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 docs/equipment_factory.md create mode 100644 src/test/java/neqsim/process/equipment/EquipmentFactoryTest.java create mode 100644 src/test/java/neqsim/process/processmodel/ProcessSystemReplaceObjectTest.java diff --git a/docs/equipment_factory.md b/docs/equipment_factory.md new file mode 100644 index 0000000000..0726a14ce1 --- /dev/null +++ b/docs/equipment_factory.md @@ -0,0 +1,34 @@ +# Equipment factory usage + +The `EquipmentFactory` provides a single entry-point for instantiating process equipment that can +be automatically wired into a `ProcessSystem`. The factory supports every value listed in +`EquipmentEnum`, including the energy storage and production classes (`WindTurbine`, +`BatteryStorage`, and `SolarPanel`). + +## Basic creation + +```java +ProcessEquipmentInterface pump = EquipmentFactory.createEquipment("pump1", EquipmentEnum.Pump); +ProcessEquipmentInterface stream = EquipmentFactory.createEquipment("feed", "stream"); +``` + +The string based overload is tolerant of the common aliases that existed historically (for example +`valve` and `separator_3phase`). Unknown identifiers now throw an exception instead of silently +creating the wrong equipment. + +## Equipment with mandatory collaborators + +Some equipment types cannot be instantiated without additional collaborators. The factory now +prevents creation of partially initialised objects and exposes dedicated helpers instead: + +```java +StreamInterface motive = new Stream("motive"); +StreamInterface suction = new Stream("suction"); +Ejector ejector = EquipmentFactory.createEjector("ej-1", motive, suction); + +SystemInterface reservoirFluid = new SystemSrkEos(273.15, 100.0); +ReservoirCVDsim cvd = EquipmentFactory.createReservoirCVDsim("cvd", reservoirFluid); +``` + +Attempting to create these units through the generic method now results in an informative exception +message that points to the correct helper method. diff --git a/src/main/java/neqsim/process/equipment/EquipmentEnum.java b/src/main/java/neqsim/process/equipment/EquipmentEnum.java index f93802ab6d..d2f294b486 100644 --- a/src/main/java/neqsim/process/equipment/EquipmentEnum.java +++ b/src/main/java/neqsim/process/equipment/EquipmentEnum.java @@ -12,7 +12,8 @@ public enum EquipmentEnum { Splitter, Reactor, Column, ThreePhaseSeparator, Recycle, Ejector, GORfitter, Adjuster, SetPoint, FlowRateAdjuster, Calculator, Expander, SimpleTEGAbsorber, Tank, ComponentSplitter, ReservoirCVDsim, ReservoirDiffLibsim, VirtualStream, ReservoirTPsim, SimpleReservoir, Manifold, - Flare, FlareStack, FuelCell, CO2Electrolyzer, Electrolyzer; + Flare, FlareStack, FuelCell, CO2Electrolyzer, Electrolyzer, WindTurbine, BatteryStorage, + SolarPanel; /** {@inheritDoc} */ @Override diff --git a/src/main/java/neqsim/process/equipment/EquipmentFactory.java b/src/main/java/neqsim/process/equipment/EquipmentFactory.java index c06b21250a..d055da9f22 100644 --- a/src/main/java/neqsim/process/equipment/EquipmentFactory.java +++ b/src/main/java/neqsim/process/equipment/EquipmentFactory.java @@ -1,5 +1,6 @@ package neqsim.process.equipment; +import java.util.Objects; import neqsim.process.equipment.absorber.SimpleTEGAbsorber; import neqsim.process.equipment.battery.BatteryStorage; import neqsim.process.equipment.compressor.Compressor; @@ -7,23 +8,27 @@ import neqsim.process.equipment.electrolyzer.CO2Electrolyzer; import neqsim.process.equipment.electrolyzer.Electrolyzer; import neqsim.process.equipment.expander.Expander; +import neqsim.process.equipment.flare.Flare; +import neqsim.process.equipment.flare.FlareStack; import neqsim.process.equipment.heatexchanger.Cooler; import neqsim.process.equipment.heatexchanger.HeatExchanger; import neqsim.process.equipment.heatexchanger.Heater; import neqsim.process.equipment.manifold.Manifold; import neqsim.process.equipment.mixer.Mixer; +import neqsim.process.equipment.powergeneration.FuelCell; import neqsim.process.equipment.powergeneration.SolarPanel; +import neqsim.process.equipment.powergeneration.WindTurbine; import neqsim.process.equipment.pump.Pump; import neqsim.process.equipment.reservoir.ReservoirCVDsim; import neqsim.process.equipment.reservoir.ReservoirDiffLibsim; import neqsim.process.equipment.reservoir.ReservoirTPsim; import neqsim.process.equipment.reservoir.SimpleReservoir; -import neqsim.process.equipment.powergeneration.WindTurbine; import neqsim.process.equipment.separator.Separator; import neqsim.process.equipment.separator.ThreePhaseSeparator; import neqsim.process.equipment.splitter.ComponentSplitter; import neqsim.process.equipment.splitter.Splitter; import neqsim.process.equipment.stream.Stream; +import neqsim.process.equipment.stream.StreamInterface; import neqsim.process.equipment.stream.VirtualStream; import neqsim.process.equipment.tank.Tank; import neqsim.process.equipment.util.Adjuster; @@ -33,116 +38,190 @@ import neqsim.process.equipment.util.Recycle; import neqsim.process.equipment.util.SetPoint; import neqsim.process.equipment.valve.ThrottlingValve; -import neqsim.process.equipment.flare.Flare; -import neqsim.process.equipment.flare.FlareStack; +import neqsim.thermo.system.SystemInterface; /** - *

- * EquipmentFactory class. - *

- * - * @author esol + * Factory for creating process equipment. */ -public class EquipmentFactory { +public final class EquipmentFactory { + + private EquipmentFactory() {} + /** - *

- * createEquipment. - *

+ * Creates a piece of equipment based on the provided type. * - * @param name a {@link java.lang.String} object - * @param equipmentType a {@link java.lang.String} object - * @return a {@link neqsim.process.equipment.ProcessEquipmentInterface} object + * @param name name to assign to the equipment + * @param equipmentType equipment type identifier + * @return the created equipment instance */ public static ProcessEquipmentInterface createEquipment(String name, String equipmentType) { if (equipmentType == null || equipmentType.trim().isEmpty()) { throw new IllegalArgumentException("Equipment type cannot be null or empty"); } - String normalizedType = equipmentType.trim().toLowerCase(); - switch (normalizedType) { - case "throttlingvalve": + String normalized = equipmentType.trim().toLowerCase(); + switch (normalized) { case "valve": + return createEquipment(name, EquipmentEnum.ThrottlingValve); + case "separator_3phase": + case "separator3phase": + case "threephaseseparator": + return createEquipment(name, EquipmentEnum.ThreePhaseSeparator); + case "co₂electrolyzer": + case "co2electrolyser": + case "co2electrolyzer": + return createEquipment(name, EquipmentEnum.CO2Electrolyzer); + case "windturbine": + return createEquipment(name, EquipmentEnum.WindTurbine); + case "batterystorage": + return createEquipment(name, EquipmentEnum.BatteryStorage); + case "solarpanel": + return createEquipment(name, EquipmentEnum.SolarPanel); + default: + EquipmentEnum enumType = resolveEquipmentEnum(equipmentType); + return createEquipment(name, enumType); + } + } + + /** + * Creates a piece of equipment based on {@link EquipmentEnum}. + * + * @param name name to assign + * @param equipmentType {@link EquipmentEnum} + * @return the created equipment + */ + public static ProcessEquipmentInterface createEquipment(String name, EquipmentEnum equipmentType) { + Objects.requireNonNull(equipmentType, "equipmentType"); + + switch (equipmentType) { + case ThrottlingValve: return new ThrottlingValve(name); - case "stream": + case Stream: return new Stream(name); - case "compressor": + case Compressor: return new Compressor(name); - case "pump": + case Pump: return new Pump(name); - case "separator": + case Separator: return new Separator(name); - case "heatexchanger": + case HeatExchanger: return new HeatExchanger(name); - case "mixer": + case Mixer: return new Mixer(name); - case "splitter": + case Splitter: return new Splitter(name); - case "cooler": + case Cooler: return new Cooler(name); - case "heater": + case Heater: return new Heater(name); - case "recycle": + case Recycle: return new Recycle(name); - case "threephaseseparator": - case "separator_3phase": + case ThreePhaseSeparator: return new ThreePhaseSeparator(name); - case "ejector": - // Requires motiveStream and suctionStream, placeholders added - return new Ejector(name, null, null); - case "gorfitter": - // Requires stream, placeholder added - return new GORfitter(name, null); - case "adjuster": + case Ejector: + throw new IllegalArgumentException( + "Ejector requires motive and suction streams. Use createEjector instead."); + case GORfitter: + throw new IllegalArgumentException( + "GORfitter requires an inlet stream. Use createGORfitter instead."); + case Adjuster: return new Adjuster(name); - case "setpoint": + case SetPoint: return new SetPoint(name); - case "flowrateadjuster": + case FlowRateAdjuster: return new FlowRateAdjuster(name); - case "calculator": + case Calculator: return new Calculator(name); - case "expander": + case Expander: return new Expander(name); - case "simpletegabsorber": + case SimpleTEGAbsorber: return new SimpleTEGAbsorber(name); - case "tank": + case Tank: return new Tank(name); - case "componentsplitter": + case ComponentSplitter: return new ComponentSplitter(name); - case "reservoircvdsim": - // Requires reservoirFluid, placeholder added - return new ReservoirCVDsim(name, null); - case "reservoirdifflibsim": - // Requires reservoirFluid, placeholder added - return new ReservoirDiffLibsim(name, null); - case "virtualstream": + case ReservoirCVDsim: + throw new IllegalArgumentException( + "ReservoirCVDsim requires a reservoir fluid. Use createReservoirCVDsim instead."); + case ReservoirDiffLibsim: + throw new IllegalArgumentException( + "ReservoirDiffLibsim requires a reservoir fluid. Use createReservoirDiffLibsim instead."); + case VirtualStream: return new VirtualStream(name); - case "reservoirtpsim": - // Requires reservoirFluid, placeholder added - return new ReservoirTPsim(name, null); - case "simplereservoir": + case ReservoirTPsim: + throw new IllegalArgumentException( + "ReservoirTPsim requires a reservoir fluid. Use createReservoirTPsim instead."); + case SimpleReservoir: return new SimpleReservoir(name); - case "manifold": + case Manifold: return new Manifold(name); - case "flare": + case Flare: return new Flare(name); - case "flarestack": + case FlareStack: return new FlareStack(name); - case "electrolyzer": - return new Electrolyzer(name); - case "co2electrolyzer": - case "co₂electrolyzer": - case "co2electrolyser": + case FuelCell: + return new FuelCell(name); + case CO2Electrolyzer: return new CO2Electrolyzer(name); - case "windturbine": + case Electrolyzer: + return new Electrolyzer(name); + case WindTurbine: return new WindTurbine(name); - case "batterystorage": + case BatteryStorage: return new BatteryStorage(name); - case "solarpanel": + case SolarPanel: return new SolarPanel(name); - - // Add other equipment types here default: - throw new IllegalArgumentException("Unknown equipment type: " + equipmentType); + throw new IllegalArgumentException( + "Unsupported equipment type: " + equipmentType.name()); + } + } + + private static EquipmentEnum resolveEquipmentEnum(String equipmentType) { + String sanitized = equipmentType.replaceAll("[\\s_-]", ""); + for (EquipmentEnum value : EquipmentEnum.values()) { + if (value.name().equalsIgnoreCase(equipmentType) + || value.name().equalsIgnoreCase(sanitized)) { + return value; + } + } + throw new IllegalArgumentException("Unknown equipment type: " + equipmentType); + } + + public static Ejector createEjector(String name, StreamInterface motiveStream, + StreamInterface suctionStream) { + if (motiveStream == null || suctionStream == null) { + throw new IllegalArgumentException("Ejector requires both motive and suction streams"); + } + return new Ejector(name, motiveStream, suctionStream); + } + + public static GORfitter createGORfitter(String name, StreamInterface stream) { + if (stream == null) { + throw new IllegalArgumentException("GORfitter requires a non-null inlet stream"); + } + return new GORfitter(name, stream); + } + + public static ReservoirCVDsim createReservoirCVDsim(String name, SystemInterface reservoirFluid) { + if (reservoirFluid == null) { + throw new IllegalArgumentException("ReservoirCVDsim requires a reservoir fluid"); + } + return new ReservoirCVDsim(name, reservoirFluid); + } + + public static ReservoirDiffLibsim createReservoirDiffLibsim(String name, + SystemInterface reservoirFluid) { + if (reservoirFluid == null) { + throw new IllegalArgumentException("ReservoirDiffLibsim requires a reservoir fluid"); + } + return new ReservoirDiffLibsim(name, reservoirFluid); + } + + public static ReservoirTPsim createReservoirTPsim(String name, SystemInterface reservoirFluid) { + if (reservoirFluid == null) { + throw new IllegalArgumentException("ReservoirTPsim requires a reservoir fluid"); } + return new ReservoirTPsim(name, reservoirFluid); } } diff --git a/src/main/java/neqsim/process/processmodel/ProcessSystem.java b/src/main/java/neqsim/process/processmodel/ProcessSystem.java index 499ef98f64..513f6d38c4 100644 --- a/src/main/java/neqsim/process/processmodel/ProcessSystem.java +++ b/src/main/java/neqsim/process/processmodel/ProcessSystem.java @@ -260,7 +260,7 @@ public int getUnitNumber(String name) { return i; } } - return 0; + return -1; } /** @@ -272,7 +272,17 @@ public int getUnitNumber(String name) { * @param operation a {@link neqsim.process.equipment.ProcessEquipmentBaseClass} object */ public void replaceObject(String unitName, ProcessEquipmentBaseClass operation) { - unitOperations.set(getUnitNumber(name), operation); + Objects.requireNonNull(unitName, "unitName"); + Objects.requireNonNull(operation, "operation"); + + int index = getUnitNumber(unitName); + if (index < 0 || index >= unitOperations.size() || getUnit(unitName) == null) { + throw new IllegalArgumentException( + "No process equipment named '" + unitName + "' exists in this ProcessSystem"); + } + + operation.setName(unitName); + unitOperations.set(index, operation); } /** diff --git a/src/test/java/neqsim/process/equipment/EquipmentFactoryTest.java b/src/test/java/neqsim/process/equipment/EquipmentFactoryTest.java new file mode 100644 index 0000000000..fb809de679 --- /dev/null +++ b/src/test/java/neqsim/process/equipment/EquipmentFactoryTest.java @@ -0,0 +1,78 @@ +package neqsim.process.equipment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import neqsim.process.equipment.ejector.Ejector; +import neqsim.process.equipment.powergeneration.WindTurbine; +import neqsim.process.equipment.reservoir.ReservoirCVDsim; +import neqsim.process.equipment.stream.Stream; +import neqsim.process.equipment.stream.StreamInterface; +import neqsim.process.equipment.util.GORfitter; +import neqsim.process.equipment.valve.ThrottlingValve; +import neqsim.process.equipment.ProcessEquipmentInterface; +import neqsim.thermo.system.SystemInterface; +import neqsim.thermo.system.SystemSrkEos; + +/** + * Tests for {@link EquipmentFactory}. + */ +public class EquipmentFactoryTest extends neqsim.NeqSimTest { + + @Test + public void createEquipmentFromEnum() { + ProcessEquipmentInterface equipment = + EquipmentFactory.createEquipment("valve1", EquipmentEnum.ThrottlingValve); + + assertInstanceOf(ThrottlingValve.class, equipment); + assertEquals("valve1", equipment.getName()); + } + + @Test + public void createEquipmentFromStringAlias() { + ProcessEquipmentInterface equipment = EquipmentFactory.createEquipment("wt", "windturbine"); + + assertInstanceOf(WindTurbine.class, equipment); + assertEquals("wt", equipment.getName()); + } + + @Test + public void ejectorRequiresStreams() { + assertThrows(IllegalArgumentException.class, () -> EquipmentFactory.createEquipment("ej", "ejector")); + + StreamInterface motive = new Stream("motive"); + StreamInterface suction = new Stream("suction"); + + Ejector ejector = EquipmentFactory.createEjector("ej", motive, suction); + assertEquals("ej", ejector.getName()); + } + + @Test + public void gorfitterRequiresInletStream() { + assertThrows(IllegalArgumentException.class, + () -> EquipmentFactory.createEquipment("gor", EquipmentEnum.GORfitter)); + + StreamInterface inlet = new Stream("inlet"); + GORfitter fitter = EquipmentFactory.createGORfitter("gor", inlet); + assertEquals("gor", fitter.getName()); + } + + @Test + public void reservoirSimRequiresFluid() { + assertThrows(IllegalArgumentException.class, + () -> EquipmentFactory.createEquipment("cvd", EquipmentEnum.ReservoirCVDsim)); + + SystemInterface fluid = new SystemSrkEos(273.15, 100.0); + fluid.addComponent("methane", 1.0); + fluid.createDatabase(true); + fluid.setMixingRule(2); + + ReservoirCVDsim simulator = EquipmentFactory.createReservoirCVDsim("cvd", fluid); + assertNotNull(simulator); + assertEquals("cvd", simulator.getName()); + } +} diff --git a/src/test/java/neqsim/process/processmodel/ProcessSystemReplaceObjectTest.java b/src/test/java/neqsim/process/processmodel/ProcessSystemReplaceObjectTest.java new file mode 100644 index 0000000000..5ba70b06b5 --- /dev/null +++ b/src/test/java/neqsim/process/processmodel/ProcessSystemReplaceObjectTest.java @@ -0,0 +1,44 @@ +package neqsim.process.processmodel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import neqsim.process.equipment.heatexchanger.Cooler; +import neqsim.process.equipment.pump.Pump; +import neqsim.process.equipment.separator.Separator; + +/** + * Tests for {@link ProcessSystem#replaceObject(String, neqsim.process.equipment.ProcessEquipmentBaseClass)}. + */ +public class ProcessSystemReplaceObjectTest extends neqsim.NeqSimTest { + + @Test + public void replaceExistingUnitKeepsPositionAndName() { + ProcessSystem system = new ProcessSystem(); + system.add(new Separator("first")); + system.add(new Cooler("second")); + + Pump replacement = new Pump("replacement"); + + system.replaceObject("second", replacement); + + assertSame(replacement, system.getUnitOperations().get(1)); + assertEquals("second", replacement.getName(), "Replacement should adopt the original unit name"); + assertSame(replacement, system.getUnit("second")); + assertNotNull(system.getUnit("first")); + } + + @Test + public void replaceNonExistingUnitThrows() { + ProcessSystem system = new ProcessSystem(); + system.add(new Separator("first")); + + Pump replacement = new Pump("replacement"); + + assertThrows(IllegalArgumentException.class, () -> system.replaceObject("unknown", replacement)); + } +} From dd435a747d2268316e51004e03ad60764ad70554 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Sun, 2 Nov 2025 07:35:46 +0100 Subject: [PATCH 12/12] Enhance DEXPI interoperability profile --- docs/dexpi-reader.md | 56 ++++++-- .../process/processmodel/DexpiMetadata.java | 79 +++++++++++ .../processmodel/DexpiRoundTripProfile.java | 123 ++++++++++++++++++ .../process/processmodel/DexpiXmlReader.java | 96 ++++++++++++-- .../process/processmodel/DexpiXmlWriter.java | 78 +++++++++-- .../processmodel/DexpiXmlReaderTest.java | 74 +++++++++++ 6 files changed, 470 insertions(+), 36 deletions(-) create mode 100644 src/main/java/neqsim/process/processmodel/DexpiMetadata.java create mode 100644 src/main/java/neqsim/process/processmodel/DexpiRoundTripProfile.java diff --git a/docs/dexpi-reader.md b/docs/dexpi-reader.md index d9be10804e..75a2d84110 100644 --- a/docs/dexpi-reader.md +++ b/docs/dexpi-reader.md @@ -3,8 +3,8 @@ The `DexpiXmlReader` utility converts [DEXPI](https://dexpi.org/) XML P&ID exports into [`ProcessSystem`](../src/main/java/neqsim/process/processmodel/ProcessSystem.java) models. It recognises major equipment such as pumps, heat exchangers, tanks and control valves as well as -piping segments, which are imported as runnable `DexpiStream` units tagged with the source line -number. +complex reactors, compressors and inline analysers. Piping segments are imported as runnable +`DexpiStream` units tagged with the source line number. ## Usage @@ -34,8 +34,20 @@ Each imported equipment item is represented as a lightweight `DexpiProcessUnit` original DEXPI class together with the mapped `EquipmentEnum` category and contextual information like line numbers or fluid codes. Piping segments become `DexpiStream` objects that clone the pressure, temperature and flow settings from the template stream (or a built-in methane/ethane -fallback), allowing the resulting `ProcessSystem` to perform full thermodynamic calculations when -`run()` is invoked. +fallback). When available, the reader honours the recommended metadata exported by NeqSim so +pressure, temperature and flow values embedded in DEXPI documents override the template defaults. +The resulting `ProcessSystem` can therefore perform full thermodynamic calculations when `run()` is +invoked without requiring downstream tooling to remap metadata. + +### Metadata conventions + +Both the reader and writer share the [`DexpiMetadata`](../src/main/java/neqsim/process/processmodel/DexpiMetadata.java) +constants that describe the recommended generic attributes for DEXPI exchanges. Equipment exports +include tag names, line numbers and fluid codes, while piping segments also carry segment numbers +and operating pressure/temperature/flow triples (together with their units). Downstream tools can +consult `DexpiMetadata.recommendedStreamAttributes()` and +`DexpiMetadata.recommendedEquipmentAttributes()` to understand the minimal metadata sets guaranteed +by NeqSim. ### Exporting back to DEXPI @@ -54,8 +66,29 @@ The writer groups all discovered `DexpiStream` segments by line number (or fluid not available) to generate simple `` elements with associated `` children. Equipment and valves are exported as `` and `` elements that preserve the original tag names, line numbers and fluid codes via -`GenericAttribute` entries. The resulting XML focuses on the metadata required to rehydrate the -process structure and is intentionally compact to ease downstream tooling consumption. +`GenericAttribute` entries. Stream metadata is enriched with operating pressure, temperature and flow +values (stored in the default NeqSim units, but accompanied by explicit `Unit` annotations) so that +downstream thermodynamic simulators can reproduce NeqSim's state without bespoke mappings. + +Each piping network is also labelled with a `NeqSimGroupingKey` generic attribute so that +visualisation libraries—such as [pyDEXPI](https://github.com/process-intelligence-research/pyDEXPI) +or Graphviz exports—can easily recreate line-centric layouts without additional heuristics. + +### Round-trip profile + +To codify the minimal metadata required for reliable imports/exports NeqSim exposes the +[`DexpiRoundTripProfile`](../src/main/java/neqsim/process/processmodel/DexpiRoundTripProfile.java) +utility. The `minimalRunnableProfile` validates that a process contains runnable `DexpiStream` +segments (with line/fluid references and operating conditions), tagged equipment and at least one +piece of equipment alongside the piping network. Regression tests enforce this profile on the +reference training case and the re-imported export artefacts to guarantee round-trip fidelity. + +### Security considerations + +Both the reader and writer configure their XML factories with hardened defaults: secure-processing +is enabled, external entity resolution is disabled and `ACCESS_EXTERNAL_DTD` / +`ACCESS_EXTERNAL_SCHEMA` properties are cleared. These guardrails mirror the guidance in the +regression tests and should be preserved if the parsing/serialisation logic is extended. ## Tested example @@ -65,8 +98,9 @@ training case provided by the [DEXPI Training Test Cases repository](https://gitlab.com/dexpi/TrainingTestCases/-/tree/master/dexpi%201.3/example%20pids) and verifies that the expected equipment (two heat exchangers, two pumps, a tank, valves and piping segments) are discovered. The regression additionally seeds the import with an example NeqSim feed -stream and confirms that the generated streams remain active after `process.run()`. A companion test -exports the imported process with `DexpiXmlWriter`, then parses the generated XML with a hardened DOM -builder to confirm that the document contains equipment, piping components and -`PipingNetworkSystem`/`PipingNetworkSegment` structures ready for downstream DEXPI tooling such as -pyDEXPI. +stream and confirms that the generated streams remain active after `process.run()`. Companion +assertions enforce the `DexpiRoundTripProfile` and check that exported metadata (pressure, +temperature, flow and units) survives a round-trip reload. A companion test exports the imported +process with `DexpiXmlWriter`, then parses the generated XML with a hardened DOM builder to confirm +that the document contains equipment, piping components and `PipingNetworkSystem`/ +`PipingNetworkSegment` structures ready for downstream DEXPI tooling such as pyDEXPI. diff --git a/src/main/java/neqsim/process/processmodel/DexpiMetadata.java b/src/main/java/neqsim/process/processmodel/DexpiMetadata.java new file mode 100644 index 0000000000..405885eec4 --- /dev/null +++ b/src/main/java/neqsim/process/processmodel/DexpiMetadata.java @@ -0,0 +1,79 @@ +package neqsim.process.processmodel; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Shared constants describing the recommended DEXPI metadata handled by the reader and writer. + */ +public final class DexpiMetadata { + private DexpiMetadata() {} + + /** Generic attribute containing the tag name of an equipment item. */ + public static final String TAG_NAME = "TagNameAssignmentClass"; + + /** Generic attribute containing a line number reference. */ + public static final String LINE_NUMBER = "LineNumberAssignmentClass"; + + /** Generic attribute containing a fluid code reference. */ + public static final String FLUID_CODE = "FluidCodeAssignmentClass"; + + /** Generic attribute containing the segment number of a piping network segment. */ + public static final String SEGMENT_NUMBER = "SegmentNumberAssignmentClass"; + + /** Generic attribute containing the operating pressure value of a segment. */ + public static final String OPERATING_PRESSURE_VALUE = "OperatingPressureValue"; + + /** Generic attribute containing the unit of the operating pressure value of a segment. */ + public static final String OPERATING_PRESSURE_UNIT = "OperatingPressureUnit"; + + /** Generic attribute containing the operating temperature value of a segment. */ + public static final String OPERATING_TEMPERATURE_VALUE = "OperatingTemperatureValue"; + + /** Generic attribute containing the unit of the operating temperature value of a segment. */ + public static final String OPERATING_TEMPERATURE_UNIT = "OperatingTemperatureUnit"; + + /** Generic attribute containing the operating flow value of a segment. */ + public static final String OPERATING_FLOW_VALUE = "OperatingFlowValue"; + + /** Generic attribute containing the unit of the operating flow value of a segment. */ + public static final String OPERATING_FLOW_UNIT = "OperatingFlowUnit"; + + /** Default pressure unit written to DEXPI documents. */ + public static final String DEFAULT_PRESSURE_UNIT = "bara"; + + /** Default temperature unit written to DEXPI documents. */ + public static final String DEFAULT_TEMPERATURE_UNIT = "C"; + + /** Default volumetric flow unit written to DEXPI documents. */ + public static final String DEFAULT_FLOW_UNIT = "MSm3/day"; + + private static final Set RECOMMENDED_STREAM_ATTRIBUTES = + Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(LINE_NUMBER, FLUID_CODE, + SEGMENT_NUMBER, OPERATING_PRESSURE_VALUE, OPERATING_PRESSURE_UNIT, + OPERATING_TEMPERATURE_VALUE, OPERATING_TEMPERATURE_UNIT, OPERATING_FLOW_VALUE, + OPERATING_FLOW_UNIT))); + + private static final Set RECOMMENDED_EQUIPMENT_ATTRIBUTES = Collections + .unmodifiableSet(new LinkedHashSet<>(Arrays.asList(TAG_NAME, LINE_NUMBER, FLUID_CODE))); + + /** + * Returns the recommended generic attributes that should accompany DEXPI piping segments. + * + * @return immutable set of attribute names + */ + public static Set recommendedStreamAttributes() { + return RECOMMENDED_STREAM_ATTRIBUTES; + } + + /** + * Returns the recommended generic attributes that should accompany DEXPI equipment items. + * + * @return immutable set of attribute names + */ + public static Set recommendedEquipmentAttributes() { + return RECOMMENDED_EQUIPMENT_ATTRIBUTES; + } +} diff --git a/src/main/java/neqsim/process/processmodel/DexpiRoundTripProfile.java b/src/main/java/neqsim/process/processmodel/DexpiRoundTripProfile.java new file mode 100644 index 0000000000..d5d234d916 --- /dev/null +++ b/src/main/java/neqsim/process/processmodel/DexpiRoundTripProfile.java @@ -0,0 +1,123 @@ +package neqsim.process.processmodel; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Describes validation profiles for round-tripping DEXPI data through NeqSim. + */ +public final class DexpiRoundTripProfile { + private final String name; + + private DexpiRoundTripProfile(String name) { + this.name = name; + } + + /** + * Returns a minimal profile guaranteeing that imported data can be executed and exported. + * + * @return profile enforcing stream metadata and equipment tagging + */ + public static DexpiRoundTripProfile minimalRunnableProfile() { + return Holder.MINIMAL_RUNNABLE; + } + + /** + * Validates the supplied process system against the profile. + * + * @param processSystem process system produced from DEXPI data + * @return validation result indicating success and listing violations + */ + public ValidationResult validate(ProcessSystem processSystem) { + Objects.requireNonNull(processSystem, "processSystem"); + List violations = new ArrayList<>(); + + long streamCount = processSystem.getUnitOperations().stream() + .filter(DexpiStream.class::isInstance).count(); + if (streamCount == 0) { + violations.add("Process must contain at least one DexpiStream"); + } + + List streams = processSystem.getUnitOperations().stream() + .filter(DexpiStream.class::isInstance).map(DexpiStream.class::cast).collect(Collectors.toList()); + for (DexpiStream stream : streams) { + if (isBlank(stream.getName())) { + violations.add("DexpiStream is missing a name"); + } + if (isBlank(stream.getLineNumber()) && isBlank(stream.getFluidCode())) { + violations.add("DexpiStream " + stream.getName() + + " requires a line number or fluid code to preserve connectivity"); + } + if (Double.isNaN(stream.getPressure(DexpiMetadata.DEFAULT_PRESSURE_UNIT))) { + violations.add("DexpiStream " + stream.getName() + " is missing operating pressure metadata"); + } + if (Double.isNaN(stream.getTemperature(DexpiMetadata.DEFAULT_TEMPERATURE_UNIT))) { + violations.add("DexpiStream " + stream.getName() + " is missing operating temperature metadata"); + } + if (Double.isNaN(stream.getFlowRate(DexpiMetadata.DEFAULT_FLOW_UNIT))) { + violations.add("DexpiStream " + stream.getName() + " is missing operating flow metadata"); + } + if (!stream.isActive()) { + violations.add("DexpiStream " + stream.getName() + " must be active after simulation"); + } + } + + List units = processSystem.getUnitOperations().stream() + .filter(DexpiProcessUnit.class::isInstance).map(DexpiProcessUnit.class::cast) + .collect(Collectors.toList()); + for (DexpiProcessUnit unit : units) { + if (isBlank(unit.getName())) { + violations.add("DexpiProcessUnit is missing a tag"); + } + if (unit.getMappedEquipment() == null) { + violations.add("DexpiProcessUnit " + unit.getName() + " lacks a mapped equipment enum"); + } + if (isBlank(unit.getDexpiClass())) { + violations.add("DexpiProcessUnit " + unit.getName() + " does not expose its original DEXPI class"); + } + } + + boolean hasEquipment = processSystem.getUnitOperations().stream() + .anyMatch(unit -> unit instanceof DexpiProcessUnit); + if (!hasEquipment) { + violations.add("Process must contain at least one DexpiProcessUnit"); + } + + return new ValidationResult(violations.isEmpty(), Collections.unmodifiableList(violations)); + } + + /** Profile validation result. */ + public static final class ValidationResult { + private final boolean successful; + private final List violations; + + private ValidationResult(boolean successful, List violations) { + this.successful = successful; + this.violations = violations; + } + + /** Indicates whether validation succeeded. */ + public boolean isSuccessful() { + return successful; + } + + /** Detailed violations preventing the process from satisfying the profile. */ + public List getViolations() { + return violations; + } + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private static final class Holder { + private static final DexpiRoundTripProfile MINIMAL_RUNNABLE = + new DexpiRoundTripProfile("minimalRunnable"); + + private Holder() {} + } +} diff --git a/src/main/java/neqsim/process/processmodel/DexpiXmlReader.java b/src/main/java/neqsim/process/processmodel/DexpiXmlReader.java index dd1769ae59..30274b8d45 100644 --- a/src/main/java/neqsim/process/processmodel/DexpiXmlReader.java +++ b/src/main/java/neqsim/process/processmodel/DexpiXmlReader.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.BiConsumer; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; @@ -36,16 +37,30 @@ public final class DexpiXmlReader { static { Map equipmentMap = new HashMap<>(); equipmentMap.put("PlateHeatExchanger", EquipmentEnum.HeatExchanger); + equipmentMap.put("ShellAndTubeHeatExchanger", EquipmentEnum.HeatExchanger); equipmentMap.put("TubularHeatExchanger", EquipmentEnum.HeatExchanger); + equipmentMap.put("AirCooledHeatExchanger", EquipmentEnum.HeatExchanger); equipmentMap.put("CentrifugalPump", EquipmentEnum.Pump); equipmentMap.put("ReciprocatingPump", EquipmentEnum.Pump); + equipmentMap.put("CentrifugalCompressor", EquipmentEnum.Compressor); + equipmentMap.put("ReciprocatingCompressor", EquipmentEnum.Compressor); equipmentMap.put("Tank", EquipmentEnum.Tank); + equipmentMap.put("StirredTankReactor", EquipmentEnum.Reactor); + equipmentMap.put("PlugFlowReactor", EquipmentEnum.Reactor); + equipmentMap.put("PackedBedReactor", EquipmentEnum.Reactor); + equipmentMap.put("InlineAnalyzer", EquipmentEnum.Calculator); + equipmentMap.put("GasAnalyzer", EquipmentEnum.Calculator); + equipmentMap.put("Spectrometer", EquipmentEnum.Calculator); EQUIPMENT_CLASS_MAP = Collections.unmodifiableMap(equipmentMap); Map pipingMap = new HashMap<>(); pipingMap.put("GlobeValve", EquipmentEnum.ThrottlingValve); pipingMap.put("ButterflyValve", EquipmentEnum.ThrottlingValve); pipingMap.put("CheckValve", EquipmentEnum.ThrottlingValve); + pipingMap.put("ControlValve", EquipmentEnum.ThrottlingValve); + pipingMap.put("PressureSafetyValve", EquipmentEnum.ThrottlingValve); + pipingMap.put("PressureReliefValve", EquipmentEnum.ThrottlingValve); + pipingMap.put("PressureReducingValve", EquipmentEnum.ThrottlingValve); PIPING_COMPONENT_MAP = Collections.unmodifiableMap(pipingMap); } @@ -207,7 +222,7 @@ private static void addEquipmentUnits(Document document, ProcessSystem processSy continue; } - String baseName = firstNonEmpty(getGenericAttribute(element, "TagNameAssignmentClass"), + String baseName = firstNonEmpty(attributeValue(element, DexpiMetadata.TAG_NAME), element.getAttribute("ID")); addDexpiUnit(processSystem, element, equipmentEnum, baseName, element.getAttribute("ComponentClass")); @@ -228,8 +243,8 @@ private static void addPipingComponents(Document document, ProcessSystem process } String baseName = firstNonEmpty( - getGenericAttribute(element, "PipingComponentNumberAssignmentClass"), - getGenericAttribute(element, "TagNameAssignmentClass"), element.getAttribute("ID")); + attributeValue(element, "PipingComponentNumberAssignmentClass"), + attributeValue(element, DexpiMetadata.TAG_NAME), element.getAttribute("ID")); addDexpiUnit(processSystem, element, equipmentEnum, baseName, element.getAttribute("ComponentClass")); } @@ -244,7 +259,7 @@ private static void addPipingSegments(Document document, ProcessSystem processSy continue; } Element element = (Element) node; - String baseName = firstNonEmpty(getGenericAttribute(element, "SegmentNumberAssignmentClass"), + String baseName = firstNonEmpty(attributeValue(element, DexpiMetadata.SEGMENT_NUMBER), element.getAttribute("ID")); addDexpiStream(processSystem, element, templateStream, baseName); } @@ -254,9 +269,8 @@ private static void addDexpiStream(ProcessSystem processSystem, Element element, Stream templateStream, String baseName) { String contextualName = prependLineOrFluid(element, baseName); String uniqueName = ensureUniqueName(processSystem, contextualName); - String lineNumber = findAttributeInAncestors(element, "LineNumberAssignmentClass"); - String fluidCode = firstNonEmpty(getGenericAttribute(element, "FluidCodeAssignmentClass"), - findAttributeInAncestors(element, "FluidCodeAssignmentClass")); + String lineNumber = attributeValue(element, DexpiMetadata.LINE_NUMBER); + String fluidCode = attributeValue(element, DexpiMetadata.FLUID_CODE); SystemInterface baseFluid = templateStream.getThermoSystem(); SystemInterface fluid = baseFluid == null ? createDefaultFluid() : baseFluid.clone(); @@ -264,6 +278,14 @@ private static void addDexpiStream(ProcessSystem processSystem, Element element, DexpiStream stream = new DexpiStream(uniqueName, fluid, element.getAttribute("ComponentClass"), lineNumber, fluidCode); stream.setSpecification(templateStream.getSpecification()); + stream.setPressure(templateStream.getPressure(DexpiMetadata.DEFAULT_PRESSURE_UNIT), + DexpiMetadata.DEFAULT_PRESSURE_UNIT); + stream.setTemperature(templateStream.getTemperature(DexpiMetadata.DEFAULT_TEMPERATURE_UNIT), + DexpiMetadata.DEFAULT_TEMPERATURE_UNIT); + stream.setFlowRate(templateStream.getFlowRate(DexpiMetadata.DEFAULT_FLOW_UNIT), + DexpiMetadata.DEFAULT_FLOW_UNIT); + + applyStreamMetadata(element, stream); processSystem.addUnit(uniqueName, stream); } @@ -271,9 +293,8 @@ private static void addDexpiUnit(ProcessSystem processSystem, Element element, EquipmentEnum equipmentEnum, String baseName, String componentClass) { String contextualName = prependLineOrFluid(element, baseName); String uniqueName = ensureUniqueName(processSystem, contextualName); - String lineNumber = findAttributeInAncestors(element, "LineNumberAssignmentClass"); - String fluidCode = firstNonEmpty(getGenericAttribute(element, "FluidCodeAssignmentClass"), - findAttributeInAncestors(element, "FluidCodeAssignmentClass")); + String lineNumber = attributeValue(element, DexpiMetadata.LINE_NUMBER); + String fluidCode = attributeValue(element, DexpiMetadata.FLUID_CODE); DexpiProcessUnit unit = new DexpiProcessUnit(uniqueName, componentClass, equipmentEnum, lineNumber, fluidCode); processSystem.addUnit(uniqueName, unit); @@ -281,12 +302,11 @@ private static void addDexpiUnit(ProcessSystem processSystem, Element element, private static String prependLineOrFluid(Element element, String baseName) { String trimmedBase = baseName == null ? "" : baseName.trim(); - String lineNumber = findAttributeInAncestors(element, "LineNumberAssignmentClass"); + String lineNumber = attributeValue(element, DexpiMetadata.LINE_NUMBER); if (!isBlank(lineNumber)) { return lineNumber.trim() + "-" + trimmedBase; } - String fluidCode = firstNonEmpty(getGenericAttribute(element, "FluidCodeAssignmentClass"), - findAttributeInAncestors(element, "FluidCodeAssignmentClass")); + String fluidCode = attributeValue(element, DexpiMetadata.FLUID_CODE); if (!isBlank(fluidCode)) { return fluidCode.trim() + "-" + trimmedBase; } @@ -358,6 +378,56 @@ private static String getGenericAttribute(Element element, String attributeName) return null; } + private static String attributeValue(Element element, String attributeName) { + return firstNonEmpty(getGenericAttribute(element, attributeName), + findAttributeInAncestors(element, attributeName)); + } + + private static void applyStreamMetadata(Element element, DexpiStream stream) { + applyNumericAttribute(element, DexpiMetadata.OPERATING_PRESSURE_VALUE, + DexpiMetadata.OPERATING_PRESSURE_UNIT, stream::setPressure, + DexpiMetadata.DEFAULT_PRESSURE_UNIT); + applyNumericAttribute(element, DexpiMetadata.OPERATING_TEMPERATURE_VALUE, + DexpiMetadata.OPERATING_TEMPERATURE_UNIT, stream::setTemperature, + DexpiMetadata.DEFAULT_TEMPERATURE_UNIT); + applyNumericAttribute(element, DexpiMetadata.OPERATING_FLOW_VALUE, + DexpiMetadata.OPERATING_FLOW_UNIT, stream::setFlowRate, DexpiMetadata.DEFAULT_FLOW_UNIT); + } + + private static void applyNumericAttribute(Element element, String valueAttribute, + String unitAttribute, BiConsumer consumer, String defaultUnit) { + String valueText = firstNonEmpty(getGenericAttribute(element, valueAttribute), + findAttributeInAncestors(element, valueAttribute)); + Double value = parseNumeric(valueText); + if (value == null) { + return; + } + String unit = firstNonEmpty(getGenericAttribute(element, unitAttribute), + findAttributeInAncestors(element, unitAttribute), defaultUnit); + consumer.accept(value, unit); + } + + private static Double parseNumeric(String valueText) { + if (isBlank(valueText)) { + return null; + } + String trimmed = valueText.trim(); + try { + return Double.parseDouble(trimmed); + } catch (NumberFormatException ex) { + int spaceIndex = trimmed.indexOf(' '); + if (spaceIndex > 0) { + String candidate = trimmed.substring(0, spaceIndex); + try { + return Double.parseDouble(candidate); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + } + private static List directChildElements(Element element, String tagName) { if (element == null) { return Collections.emptyList(); diff --git a/src/main/java/neqsim/process/processmodel/DexpiXmlWriter.java b/src/main/java/neqsim/process/processmodel/DexpiXmlWriter.java index 75691036b4..9ba9d57fc4 100644 --- a/src/main/java/neqsim/process/processmodel/DexpiXmlWriter.java +++ b/src/main/java/neqsim/process/processmodel/DexpiXmlWriter.java @@ -6,6 +6,8 @@ import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; import java.time.LocalDate; import java.time.LocalTime; import java.time.format.DateTimeFormatter; @@ -13,13 +15,16 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.regex.Pattern; +import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.TransformerFactoryConfigurationError; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; @@ -37,6 +42,13 @@ */ public final class DexpiXmlWriter { private static final Pattern NON_IDENTIFIER = Pattern.compile("[^A-Za-z0-9_-]"); + private static final ThreadLocal DECIMAL_FORMAT = ThreadLocal.withInitial(() -> { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(Locale.ROOT); + DecimalFormat format = new DecimalFormat("0.############", symbols); + format.setMaximumFractionDigits(12); + format.setGroupingUsed(false); + return format; + }); private DexpiXmlWriter() {} @@ -105,10 +117,16 @@ public static void write(ProcessSystem processSystem, OutputStream outputStream) private static Document createDocument() throws IOException { try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(false); - factory.setExpandEntityReferences(false); - factory.setXIncludeAware(false); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setNamespaceAware(false); + factory.setExpandEntityReferences(false); + factory.setXIncludeAware(false); DocumentBuilder builder = factory.newDocumentBuilder(); return builder.newDocument(); } catch (ParserConfigurationException e) { @@ -150,11 +168,11 @@ private static void appendProcessUnit(Document document, Element parent, uniqueIdentifier(elementName, processUnit.getName(), usedIds)); Element genericAttributes = document.createElement("GenericAttributes"); - appendGenericAttribute(document, genericAttributes, "TagNameAssignmentClass", + appendGenericAttribute(document, genericAttributes, DexpiMetadata.TAG_NAME, processUnit.getName()); - appendGenericAttribute(document, genericAttributes, "LineNumberAssignmentClass", + appendGenericAttribute(document, genericAttributes, DexpiMetadata.LINE_NUMBER, processUnit.getLineNumber()); - appendGenericAttribute(document, genericAttributes, "FluidCodeAssignmentClass", + appendGenericAttribute(document, genericAttributes, DexpiMetadata.FLUID_CODE, processUnit.getFluidCode()); if (genericAttributes.hasChildNodes()) { @@ -173,10 +191,11 @@ private static void appendPipingNetworkSystem(Document document, Element parent, Element systemAttributes = document.createElement("GenericAttributes"); String lineNumber = streams.stream().map(DexpiStream::getLineNumber) .filter(value -> !isBlank(value)).findFirst().orElse(null); - appendGenericAttribute(document, systemAttributes, "LineNumberAssignmentClass", lineNumber); + appendGenericAttribute(document, systemAttributes, DexpiMetadata.LINE_NUMBER, lineNumber); String fluidCode = streams.stream().map(DexpiStream::getFluidCode) .filter(value -> !isBlank(value)).findFirst().orElse(null); - appendGenericAttribute(document, systemAttributes, "FluidCodeAssignmentClass", fluidCode); + appendGenericAttribute(document, systemAttributes, DexpiMetadata.FLUID_CODE, fluidCode); + appendGenericAttribute(document, systemAttributes, "NeqSimGroupingKey", key); if (systemAttributes.hasChildNodes()) { systemElement.appendChild(systemAttributes); } @@ -197,12 +216,26 @@ private static void appendPipingNetworkSegment(Document document, Element parent uniqueIdentifier("Segment", stream.getName(), usedIds)); Element genericAttributes = document.createElement("GenericAttributes"); - appendGenericAttribute(document, genericAttributes, "SegmentNumberAssignmentClass", + appendGenericAttribute(document, genericAttributes, DexpiMetadata.SEGMENT_NUMBER, stream.getName()); - appendGenericAttribute(document, genericAttributes, "LineNumberAssignmentClass", + appendGenericAttribute(document, genericAttributes, DexpiMetadata.LINE_NUMBER, stream.getLineNumber()); - appendGenericAttribute(document, genericAttributes, "FluidCodeAssignmentClass", + appendGenericAttribute(document, genericAttributes, DexpiMetadata.FLUID_CODE, stream.getFluidCode()); + appendNumericAttribute(document, genericAttributes, DexpiMetadata.OPERATING_PRESSURE_VALUE, + stream.getPressure(DexpiMetadata.DEFAULT_PRESSURE_UNIT), + DexpiMetadata.DEFAULT_PRESSURE_UNIT); + appendGenericAttribute(document, genericAttributes, DexpiMetadata.OPERATING_PRESSURE_UNIT, + DexpiMetadata.DEFAULT_PRESSURE_UNIT); + appendNumericAttribute(document, genericAttributes, DexpiMetadata.OPERATING_TEMPERATURE_VALUE, + stream.getTemperature(DexpiMetadata.DEFAULT_TEMPERATURE_UNIT), + DexpiMetadata.DEFAULT_TEMPERATURE_UNIT); + appendGenericAttribute(document, genericAttributes, DexpiMetadata.OPERATING_TEMPERATURE_UNIT, + DexpiMetadata.DEFAULT_TEMPERATURE_UNIT); + appendNumericAttribute(document, genericAttributes, DexpiMetadata.OPERATING_FLOW_VALUE, + stream.getFlowRate(DexpiMetadata.DEFAULT_FLOW_UNIT), DexpiMetadata.DEFAULT_FLOW_UNIT); + appendGenericAttribute(document, genericAttributes, DexpiMetadata.OPERATING_FLOW_UNIT, + DexpiMetadata.DEFAULT_FLOW_UNIT); if (genericAttributes.hasChildNodes()) { segmentElement.appendChild(genericAttributes); } @@ -212,15 +245,31 @@ private static void appendPipingNetworkSegment(Document document, Element parent private static void appendGenericAttribute(Document document, Element parent, String name, String value) { + appendGenericAttribute(document, parent, name, value, null); + } + + private static void appendGenericAttribute(Document document, Element parent, String name, + String value, String unit) { if (isBlank(value)) { return; } Element attribute = document.createElement("GenericAttribute"); attribute.setAttribute("Name", name); attribute.setAttribute("Value", value.trim()); + if (!isBlank(unit)) { + attribute.setAttribute("Unit", unit.trim()); + } parent.appendChild(attribute); } + private static void appendNumericAttribute(Document document, Element parent, String name, + double value, String unit) { + if (Double.isNaN(value) || Double.isInfinite(value)) { + return; + } + appendGenericAttribute(document, parent, name, DECIMAL_FORMAT.get().format(value), unit); + } + private static String defaultComponentClass(EquipmentEnum mapped, String elementName) { if (mapped == null) { return elementName; @@ -304,6 +353,9 @@ private static void writeDocument(Document document, OutputStream outputStream) throws IOException { try { TransformerFactory factory = TransformerFactory.newInstance(); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); Transformer transformer = factory.newTransformer(); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); @@ -311,6 +363,8 @@ private static void writeDocument(Document document, OutputStream outputStream) transformer.transform(new DOMSource(document), new StreamResult(outputStream)); } catch (TransformerException e) { throw new IOException("Unable to serialize DEXPI document", e); + } catch (TransformerFactoryConfigurationError e) { + throw new IOException("Unable to configure XML transformer", e); } } } diff --git a/src/test/java/neqsim/process/processmodel/DexpiXmlReaderTest.java b/src/test/java/neqsim/process/processmodel/DexpiXmlReaderTest.java index 7479027af9..e85d7cb34a 100644 --- a/src/test/java/neqsim/process/processmodel/DexpiXmlReaderTest.java +++ b/src/test/java/neqsim/process/processmodel/DexpiXmlReaderTest.java @@ -11,6 +11,7 @@ import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Element; import org.w3c.dom.Document; import org.w3c.dom.NodeList; import org.junit.jupiter.api.Test; @@ -82,6 +83,11 @@ void convertsDexpiExampleIntoRunnableProcess() throws Exception { .filter(DexpiStream.class::isInstance).map(DexpiStream.class::cast) .filter(Stream::isActive).count(); assertTrue(activeStreams > 0, "At least one imported stream should calculate a TP flash"); + + DexpiRoundTripProfile.ValidationResult result = + DexpiRoundTripProfile.minimalRunnableProfile().validate(processSystem); + assertTrue(result.isSuccessful(), + () -> "Minimal runnable profile violations: " + result.getViolations()); } } @@ -113,6 +119,51 @@ void exportsProcessToDexpiXmlForExampleProcess() throws Exception { assertTrue(systems.getLength() > 0, "Export should include piping network systems"); assertTrue(equipment.getLength() + pipingComponents.getLength() > 5, "Export should include multiple pieces of equipment and piping components"); + + Element firstSegment = (Element) pipingSegments.item(0); + Element segmentAttributes = findGenericAttributes(firstSegment); + assertNotNull(segmentAttributes, + "Piping segments should carry generic attributes with operating metadata"); + assertTrue(hasGenericAttribute(segmentAttributes, DexpiMetadata.OPERATING_PRESSURE_VALUE), + "Segments should include operating pressure values"); + assertTrue(hasGenericAttribute(segmentAttributes, DexpiMetadata.OPERATING_TEMPERATURE_VALUE), + "Segments should include operating temperature values"); + assertTrue(hasGenericAttribute(segmentAttributes, DexpiMetadata.OPERATING_FLOW_VALUE), + "Segments should include operating flow values"); + + Element pressureAttribute = getGenericAttribute(segmentAttributes, + DexpiMetadata.OPERATING_PRESSURE_VALUE); + assertNotNull(pressureAttribute, "Pressure attribute must be present"); + assertTrue(pressureAttribute.hasAttribute("Unit"), + "Pressure attribute should declare a unit"); + + Stream roundTripTemplate = createExampleFeedStream(); + roundTripTemplate.setPressure(5.0, DexpiMetadata.DEFAULT_PRESSURE_UNIT); + roundTripTemplate.setTemperature(10.0, DexpiMetadata.DEFAULT_TEMPERATURE_UNIT); + roundTripTemplate.setFlowRate(5.0, DexpiMetadata.DEFAULT_FLOW_UNIT); + + try (InputStream reimportStream = Files.newInputStream(exportPath)) { + ProcessSystem roundTripped = DexpiXmlReader.read(reimportStream, roundTripTemplate); + roundTripped.run(); + + DexpiRoundTripProfile.ValidationResult roundTripValidation = + DexpiRoundTripProfile.minimalRunnableProfile().validate(roundTripped); + assertTrue(roundTripValidation.isSuccessful(), + () -> "Round-trip profile violations: " + roundTripValidation.getViolations()); + + DexpiStream exportedStream = (DexpiStream) roundTripped.getUnit("47121-S1"); + if (exportedStream != null) { + assertEquals(templateStream.getPressure(DexpiMetadata.DEFAULT_PRESSURE_UNIT), + exportedStream.getPressure(DexpiMetadata.DEFAULT_PRESSURE_UNIT), 1e-9, + "Pressure metadata should survive round-trip"); + assertEquals(templateStream.getTemperature(DexpiMetadata.DEFAULT_TEMPERATURE_UNIT), + exportedStream.getTemperature(DexpiMetadata.DEFAULT_TEMPERATURE_UNIT), 1e-9, + "Temperature metadata should survive round-trip"); + assertEquals(templateStream.getFlowRate(DexpiMetadata.DEFAULT_FLOW_UNIT), + exportedStream.getFlowRate(DexpiMetadata.DEFAULT_FLOW_UNIT), 1e-9, + "Flow metadata should survive round-trip"); + } + } } } @@ -131,4 +182,27 @@ private Document parseExport(Path exportPath) throws Exception { return builder.parse(exportStream); } } + + private Element findGenericAttributes(Element element) { + NodeList nodes = element.getElementsByTagName("GenericAttributes"); + if (nodes.getLength() == 0) { + return null; + } + return (Element) nodes.item(0); + } + + private boolean hasGenericAttribute(Element parent, String name) { + return getGenericAttribute(parent, name) != null; + } + + private Element getGenericAttribute(Element parent, String name) { + NodeList attributes = parent.getElementsByTagName("GenericAttribute"); + for (int i = 0; i < attributes.getLength(); i++) { + Element attribute = (Element) attributes.item(i); + if (name.equals(attribute.getAttribute("Name"))) { + return attribute; + } + } + return null; + } }