diff --git a/docs/REFERENCE_MANUAL_INDEX.md b/docs/REFERENCE_MANUAL_INDEX.md
index 217ce11d6e..3a27cdad4a 100644
--- a/docs/REFERENCE_MANUAL_INDEX.md
+++ b/docs/REFERENCE_MANUAL_INDEX.md
@@ -159,6 +159,7 @@ NeqSim is distributed under the Apache-2.0 license and can be used via:
| Mercury Thermodynamics | [docs/thermo/mercury_thermodynamics.md](thermo/mercury_thermodynamics) | Mercury-focused SRK-TwuCoon-Statoil-EOS usage, TPflash setup, and thesis-linked BIP/correlation guidance |
| **Søreide-Whitson Model** | [docs/thermo/SoreideWhitsonModel.md](thermo/SoreideWhitsonModel) | **Gas solubility in brine** - Modified PR EoS with salinity effects, used in NeqSimLive for produced water emissions |
| GERG-2008 | [docs/thermo/gerg2008_eoscg.md](thermo/gerg2008_eoscg) | GERG-2008, GERG-2008-H2, GERG-2008-NH3, and EOS-CG equations of state |
+| Thermodynamic Benchmarks | [docs/thermo/thermodynamic_benchmarks.md](thermo/thermodynamic_benchmarks) | Experimental-data benchmarks with provenance, uncertainty-aware metrics, and H2-CO2 phase-equilibrium data |
| GERG-2008-NH3 Notebook | [docs/examples/GERG2008_NH3_Ammonia_Properties.ipynb](https://github.com/equinor/neqsim/blob/master/docs/examples/GERG2008_NH3_Ammonia_Properties.ipynb) | Ammonia properties with Gao EOS — density validation, mixture properties, isotherms |
| Mixing Rules | [docs/thermo/mixing_rules_guide.md](thermo/mixing_rules_guide) | Mixing rules and BIPs |
| Mixing Rule Package | [docs/thermo/mixingrule/README.md](thermo/mixingrule/) | Mixing rule implementations |
diff --git a/docs/thermo/thermodynamic_benchmarks.md b/docs/thermo/thermodynamic_benchmarks.md
new file mode 100644
index 0000000000..86d2115cbd
--- /dev/null
+++ b/docs/thermo/thermodynamic_benchmarks.md
@@ -0,0 +1,99 @@
+---
+title: "Thermodynamic Model Benchmarks"
+description: "Auditable comparison of NeqSim property models with published experimental data."
+---
+
+# Thermodynamic model benchmarks
+
+NeqSim provides a reusable benchmark framework in
+`neqsim.thermo.util.benchmark.ThermodynamicBenchmark`. It keeps state,
+composition, property units, experimental uncertainty, citation, DOI, and data
+reuse information together with every comparison.
+
+The framework reports:
+
+- average absolute relative deviation (AARD);
+- signed bias;
+- root-mean-square relative error;
+- maximum absolute relative error;
+- uncertainty-normalized residuals when experimental uncertainty is available;
+- all point-level experimental and predicted values.
+
+## Hydrogen-containing CO2 benchmark
+
+`H2CO2PhaseEquilibriumData.load()` reads 24 bubble- and dew-point values from
+Tables IV and V of:
+
+> Zhang et al. (2026), *Measurement of phase equilibrium characteristics and
+> equation-of-state applicability for hydrogen-containing CO2 systems*,
+> International Journal of Fluid Engineering 3, 013903.
+> [doi:10.1063/5.0288386](https://doi.org/10.1063/5.0288386)
+
+The systems cover:
+
+- 96 mol% CO2 + 4 mol% H2;
+- 96 mol% CO2 + 2 mol% H2 + 2 mol% N2;
+- temperatures from -30 to 20 °C;
+- bubble pressures from 13.8 to 64.3 bara;
+- dew pressures from 65.3 to 112.1 bara.
+
+The article does not report pointwise pressure uncertainties. NeqSim records
+these as unavailable instead of assigning an unsupported uncertainty.
+
+## Java example
+
+```java
+ThermodynamicBenchmark.Dataset dataset = H2CO2PhaseEquilibriumData.load();
+NeqSimPhaseEquilibriumPrediction prediction =
+ new NeqSimPhaseEquilibriumPrediction(
+ NeqSimPhaseEquilibriumPrediction.Model.GERG_2008_H2);
+
+ThermodynamicBenchmark.Report report =
+ ThermodynamicBenchmark.run("GERG-2008-H2", dataset, prediction);
+
+double aardPercent = report.getAverageAbsoluteRelativeDeviationPercent();
+double biasPercent = report.getBiasPercent();
+```
+
+Supported configurations are SRK, PR, standard GERG-2008, and
+GERG-2008-H2. Cubic models use NeqSim database interaction parameters and
+mixing rule 2. The GERG-2008-H2 configuration enables the hydrogen-enhanced
+binary parameters and departure functions.
+
+The experimental pressure is used only as a numerical starting point. It is
+not returned by the prediction adapter and does not alter model parameters.
+
+## Constant H2-CO2 kij regression
+
+`BinaryInteractionParameterFitter` performs bounded golden-section regression
+of one constant cubic-EOS binary interaction parameter. Its objective is the
+unweighted mean squared relative pressure error. The calculation below used
+the 12 binary CO2-H2 points for calibration and retained the 12 ternary
+CO2-H2-N2 points as a holdout. CO2-N2 and H2-N2 interaction parameters remained
+at their NeqSim database values.
+
+| Model | Fitted H2-CO2 kij | Binary calibration RMSRE | Binary calibration AARD | Ternary holdout AARD | Database-kij binary AARD |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| SRK | -0.47728 | 140.68% | 115.64% | 89.06% | 167.69% |
+| PR | -0.37523 | 120.26% | 92.87% | 88.03% | 142.00% |
+
+Regression bounds were -0.5 to 0.2, the parameter tolerance was `1e-4`, and
+the maximum was 20 objective evaluations. These are in-sample fitted values,
+not literature parameters.
+
+The fit reduces the selected objective, but the remaining errors and large
+negative parameters are unacceptable for a general NeqSim default. The fitted
+values are therefore documented for reproducibility but are not installed in
+the interaction-parameter database. This result indicates that one constant
+H2-CO2 kij in the current cubic-model setup cannot represent these bubble and
+dew boundaries adequately. Before considering a database change, investigate
+the point-level residuals, flash formulation, temperature dependence, and
+additional independent binary data.
+
+## Interpretation
+
+A low aggregate deviation is not sufficient evidence of universal model
+validity. Inspect bubble and dew results separately, examine signed residuals
+against temperature and composition, and retain the source validity range.
+Do not tune binary interaction parameters without recording their provenance
+and validating them on data not used for regression.
diff --git a/src/main/java/neqsim/thermo/util/benchmark/BinaryInteractionParameterFitter.java b/src/main/java/neqsim/thermo/util/benchmark/BinaryInteractionParameterFitter.java
new file mode 100644
index 0000000000..edad4f752a
--- /dev/null
+++ b/src/main/java/neqsim/thermo/util/benchmark/BinaryInteractionParameterFitter.java
@@ -0,0 +1,163 @@
+package neqsim.thermo.util.benchmark;
+
+import java.util.ArrayList;
+import java.util.List;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Dataset;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Point;
+
+/**
+ * Bounded one-dimensional regression of a constant cubic-EOS binary interaction parameter.
+ *
+ *
+ * The objective is the mean squared relative error. This gives bubble- and dew-point pressures comparable influence
+ * even when their absolute pressure levels differ.
+ *
+ */
+public final class BinaryInteractionParameterFitter {
+ /** Creates model predictions for a trial binary interaction parameter. */
+ public interface PredictionFactory {
+ /**
+ * Creates a prediction function for one trial parameter.
+ *
+ * @param binaryInteractionParameter trial dimensionless binary interaction parameter
+ * @return model prediction function
+ */
+ ThermodynamicBenchmark.Prediction create(double binaryInteractionParameter);
+ }
+
+ /** Immutable scalar-regression result. */
+ public static final class Result {
+ private final double binaryInteractionParameter;
+ private final double rootMeanSquareRelativeErrorPercent;
+ private final int objectiveEvaluations;
+
+ private Result(double binaryInteractionParameter, double rootMeanSquareRelativeErrorPercent,
+ int objectiveEvaluations) {
+ this.binaryInteractionParameter = binaryInteractionParameter;
+ this.rootMeanSquareRelativeErrorPercent = rootMeanSquareRelativeErrorPercent;
+ this.objectiveEvaluations = objectiveEvaluations;
+ }
+
+ /** @return fitted dimensionless binary interaction parameter */
+ public double getBinaryInteractionParameter() {
+ return binaryInteractionParameter;
+ }
+
+ /** @return in-sample root-mean-square relative error in percent */
+ public double getRootMeanSquareRelativeErrorPercent() {
+ return rootMeanSquareRelativeErrorPercent;
+ }
+
+ /** @return number of objective evaluations */
+ public int getObjectiveEvaluations() {
+ return objectiveEvaluations;
+ }
+ }
+
+ private final Dataset dataset;
+ private final PredictionFactory predictionFactory;
+
+ /**
+ * Creates a scalar binary-interaction-parameter regression.
+ *
+ * @param dataset experimental calibration dataset
+ * @param predictionFactory model adapter factory
+ */
+ public BinaryInteractionParameterFitter(Dataset dataset, PredictionFactory predictionFactory) {
+ if (dataset == null || predictionFactory == null) {
+ throw new IllegalArgumentException("Dataset and prediction factory are required");
+ }
+ this.dataset = dataset;
+ this.predictionFactory = predictionFactory;
+ }
+
+ /**
+ * Fits within a closed interval using golden-section minimization.
+ *
+ * @param lowerBound inclusive lower parameter bound
+ * @param upperBound inclusive upper parameter bound
+ * @param tolerance parameter-space convergence tolerance
+ * @param maximumEvaluations maximum objective evaluations
+ * @return fitted parameter and in-sample objective
+ * @throws Exception when a model prediction fails
+ */
+ public Result fit(double lowerBound, double upperBound, double tolerance, int maximumEvaluations) throws Exception {
+ if (!Double.isFinite(lowerBound) || !Double.isFinite(upperBound) || lowerBound >= upperBound
+ || !Double.isFinite(tolerance) || tolerance <= 0.0 || maximumEvaluations < 3) {
+ throw new IllegalArgumentException("Valid bounds, tolerance, and evaluation limit are required");
+ }
+
+ double left = lowerBound;
+ double right = upperBound;
+ double inverseGoldenRatio = (Math.sqrt(5.0) - 1.0) / 2.0;
+ double innerLeft = right - inverseGoldenRatio * (right - left);
+ double innerRight = left + inverseGoldenRatio * (right - left);
+ double objectiveLeft = objective(innerLeft);
+ double objectiveRight = objective(innerRight);
+ int evaluations = 2;
+
+ while (right - left > tolerance && evaluations < maximumEvaluations) {
+ if (objectiveLeft <= objectiveRight) {
+ right = innerRight;
+ innerRight = innerLeft;
+ objectiveRight = objectiveLeft;
+ innerLeft = right - inverseGoldenRatio * (right - left);
+ objectiveLeft = objective(innerLeft);
+ } else {
+ left = innerLeft;
+ innerLeft = innerRight;
+ objectiveLeft = objectiveRight;
+ innerRight = left + inverseGoldenRatio * (right - left);
+ objectiveRight = objective(innerRight);
+ }
+ evaluations++;
+ }
+
+ double fittedParameter;
+ double fittedObjective;
+ if (objectiveLeft <= objectiveRight) {
+ fittedParameter = innerLeft;
+ fittedObjective = objectiveLeft;
+ } else {
+ fittedParameter = innerRight;
+ fittedObjective = objectiveRight;
+ }
+ return new Result(fittedParameter, 100.0 * Math.sqrt(fittedObjective), evaluations);
+ }
+
+ /**
+ * Returns a dataset containing only selected point indexes.
+ *
+ * @param source source dataset
+ * @param indexes zero-based point indexes
+ * @param name descriptive subset name
+ * @return immutable dataset subset retaining source provenance
+ */
+ public static Dataset subset(Dataset source, List indexes, String name) {
+ if (source == null || indexes == null || indexes.isEmpty() || name == null || name.trim().isEmpty()) {
+ throw new IllegalArgumentException("Source, indexes, and subset name are required");
+ }
+ List points = new ArrayList();
+ for (Integer index : indexes) {
+ if (index == null || index < 0 || index >= source.getPoints().size()) {
+ throw new IllegalArgumentException("Subset index is outside the source dataset");
+ }
+ points.add(source.getPoints().get(index));
+ }
+ return new Dataset(name, source.getCitation(), source.getDoi(), source.getLicense(), points);
+ }
+
+ private double objective(double binaryInteractionParameter) throws Exception {
+ ThermodynamicBenchmark.Prediction prediction = predictionFactory.create(binaryInteractionParameter);
+ double squaredRelativeErrorSum = 0.0;
+ for (Point point : dataset.getPoints()) {
+ double predictedValue = prediction.predict(point);
+ if (!Double.isFinite(predictedValue)) {
+ throw new IllegalStateException("Non-finite prediction during parameter regression");
+ }
+ double relativeError = (predictedValue - point.getExperimentalValue()) / point.getExperimentalValue();
+ squaredRelativeErrorSum += relativeError * relativeError;
+ }
+ return squaredRelativeErrorSum / dataset.getPoints().size();
+ }
+}
diff --git a/src/main/java/neqsim/thermo/util/benchmark/H2CO2PhaseEquilibriumData.java b/src/main/java/neqsim/thermo/util/benchmark/H2CO2PhaseEquilibriumData.java
new file mode 100644
index 0000000000..abd3458052
--- /dev/null
+++ b/src/main/java/neqsim/thermo/util/benchmark/H2CO2PhaseEquilibriumData.java
@@ -0,0 +1,90 @@
+package neqsim.thermo.util.benchmark;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Dataset;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Point;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Property;
+
+/** Published H2-CO2 and H2-N2-CO2 phase-equilibrium benchmark datasets. */
+public final class H2CO2PhaseEquilibriumData {
+ private static final String RESOURCE = "/data/thermo/benchmark/zhang2026_h2_co2_phase_equilibrium.csv";
+ private static final String DOI = "10.1063/5.0288386";
+ private static final String CITATION = "Zhang et al. (2026), Measurement of phase equilibrium characteristics and "
+ + "equation-of-state applicability for hydrogen-containing CO2 systems, "
+ + "International Journal of Fluid Engineering 3, 013903.";
+ private static final String LICENSE = "Values transcribed from Tables IV-V of the cited open article; cite the original source.";
+
+ private H2CO2PhaseEquilibriumData() {
+ }
+
+ /**
+ * Loads all published binary and ternary bubble- and dew-point data.
+ *
+ *
+ * The article does not report pointwise pressure uncertainty. The framework therefore records uncertainty as
+ * unavailable rather than inventing a value.
+ *
+ *
+ * @return phase-equilibrium dataset containing 24 points
+ * @throws IOException if the packaged resource cannot be read
+ */
+ public static Dataset load() throws IOException {
+ InputStream input = H2CO2PhaseEquilibriumData.class.getResourceAsStream(RESOURCE);
+ if (input == null) {
+ throw new IOException("Missing benchmark resource " + RESOURCE);
+ }
+ List points = new ArrayList();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
+ String line = reader.readLine();
+ if (line == null) {
+ throw new IOException("Empty benchmark resource " + RESOURCE);
+ }
+ int lineNumber = 1;
+ while ((line = reader.readLine()) != null) {
+ lineNumber++;
+ if (line.trim().isEmpty() || line.trim().startsWith("#")) {
+ continue;
+ }
+ points.add(parseCsvRow(line, lineNumber));
+ }
+ }
+ return new Dataset("Zhang 2026 hydrogen-containing CO2 phase equilibrium", CITATION, DOI, LICENSE, points);
+ }
+
+ static Point parseCsvRow(String line, int lineNumber) throws IOException {
+ String[] values = line.split(",", -1);
+ if (values.length != 8) {
+ throw new IOException("Expected 8 CSV columns at line " + lineNumber + " but found " + values.length);
+ }
+ try {
+ double temperatureK = Double.parseDouble(values[1]) + 273.15;
+ double pressureBara = Double.parseDouble(values[3]) * 10.0;
+ Map composition = new LinkedHashMap();
+ composition.put("CO2", Double.parseDouble(values[4]));
+ composition.put("hydrogen", Double.parseDouble(values[5]));
+ double nitrogenFraction = Double.parseDouble(values[6]);
+ if (nitrogenFraction > 0.0) {
+ composition.put("nitrogen", nitrogenFraction);
+ }
+ Property property;
+ if ("bubble".equals(values[2])) {
+ property = Property.BUBBLE_POINT_PRESSURE;
+ } else if ("dew".equals(values[2])) {
+ property = Property.DEW_POINT_PRESSURE;
+ } else {
+ throw new IOException("Unknown phase-equilibrium property " + values[2] + " at line " + lineNumber);
+ }
+ return new Point(property, temperatureK, pressureBara, pressureBara, Double.NaN, "bara", composition);
+ } catch (NumberFormatException exception) {
+ throw new IOException("Malformed numeric value at line " + lineNumber + ": " + line, exception);
+ }
+ }
+}
diff --git a/src/main/java/neqsim/thermo/util/benchmark/NeqSimPhaseEquilibriumPrediction.java b/src/main/java/neqsim/thermo/util/benchmark/NeqSimPhaseEquilibriumPrediction.java
new file mode 100644
index 0000000000..61af5d56a8
--- /dev/null
+++ b/src/main/java/neqsim/thermo/util/benchmark/NeqSimPhaseEquilibriumPrediction.java
@@ -0,0 +1,110 @@
+package neqsim.thermo.util.benchmark;
+
+import java.util.Map;
+import neqsim.thermo.system.SystemGERG2008Eos;
+import neqsim.thermo.system.SystemInterface;
+import neqsim.thermo.system.SystemPrEos;
+import neqsim.thermo.system.SystemSrkEos;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Point;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Prediction;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Property;
+import neqsim.thermodynamicoperations.ThermodynamicOperations;
+
+/** NeqSim bubble- and dew-point prediction adapter for experimental benchmark points. */
+public final class NeqSimPhaseEquilibriumPrediction implements Prediction {
+ /** Supported equation-of-state configurations. */
+ public enum Model {
+ /** Soave-Redlich-Kwong with database interaction parameters. */
+ SRK,
+ /** Peng-Robinson with database interaction parameters. */
+ PR,
+ /** Standard GERG-2008. */
+ GERG_2008,
+ /** GERG-2008-H2 with hydrogen-enhanced binary parameters and departure functions. */
+ GERG_2008_H2
+ }
+
+ private final Model model;
+ private final double hydrogenCarbonDioxideKij;
+
+ /**
+ * Creates a phase-equilibrium prediction adapter.
+ *
+ * @param model equation-of-state configuration
+ */
+ public NeqSimPhaseEquilibriumPrediction(Model model) {
+ this(model, Double.NaN);
+ }
+
+ /**
+ * Creates a cubic-EOS phase-equilibrium prediction adapter with a custom constant H2-CO2 interaction parameter.
+ *
+ * @param model cubic equation-of-state configuration
+ * @param hydrogenCarbonDioxideKij dimensionless constant H2-CO2 binary interaction parameter
+ */
+ public NeqSimPhaseEquilibriumPrediction(Model model, double hydrogenCarbonDioxideKij) {
+ if (model == null) {
+ throw new IllegalArgumentException("Model is required");
+ }
+ if (Double.isFinite(hydrogenCarbonDioxideKij) && model != Model.SRK && model != Model.PR) {
+ throw new IllegalArgumentException("A custom kij is supported only for SRK and PR");
+ }
+ this.model = model;
+ this.hydrogenCarbonDioxideKij = hydrogenCarbonDioxideKij;
+ }
+
+ /** @return configured equation-of-state model */
+ public Model getModel() {
+ return model;
+ }
+
+ /** @return configured H2-CO2 interaction parameter, or NaN when database values are used */
+ public double getHydrogenCarbonDioxideKij() {
+ return hydrogenCarbonDioxideKij;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public double predict(Point point) throws Exception {
+ if (point.getProperty() != Property.BUBBLE_POINT_PRESSURE && point.getProperty() != Property.DEW_POINT_PRESSURE) {
+ throw new IllegalArgumentException("Only bubble- and dew-point pressures are supported");
+ }
+ SystemInterface system = createSystem(point.getTemperatureK(), point.getPressureBara());
+ for (Map.Entry component : point.getComposition().entrySet()) {
+ system.addComponent(component.getKey(), component.getValue());
+ }
+ if (model == Model.SRK || model == Model.PR) {
+ system.createDatabase(true);
+ system.setMixingRule(2);
+ if (Double.isFinite(hydrogenCarbonDioxideKij)) {
+ system.setBinaryInteractionParameter("hydrogen", "CO2", hydrogenCarbonDioxideKij);
+ }
+ }
+ ThermodynamicOperations operations = new ThermodynamicOperations(system);
+ if (point.getProperty() == Property.BUBBLE_POINT_PRESSURE) {
+ operations.bubblePointPressureFlash(false);
+ } else {
+ operations.dewPointPressureFlash();
+ }
+ double pressure = system.getPressure(point.getUnit());
+ if (!Double.isFinite(pressure) || pressure <= 0.0) {
+ throw new IllegalStateException(
+ model + " returned invalid " + point.getProperty() + " at " + point.getTemperatureK() + " K");
+ }
+ return pressure;
+ }
+
+ private SystemInterface createSystem(double temperatureK, double pressureBara) {
+ if (model == Model.SRK) {
+ return new SystemSrkEos(temperatureK, pressureBara);
+ }
+ if (model == Model.PR) {
+ return new SystemPrEos(temperatureK, pressureBara);
+ }
+ SystemGERG2008Eos system = new SystemGERG2008Eos(temperatureK, pressureBara);
+ if (model == Model.GERG_2008_H2) {
+ system.useHydrogenEnhancedModel();
+ }
+ return system;
+ }
+}
diff --git a/src/main/java/neqsim/thermo/util/benchmark/ThermodynamicBenchmark.java b/src/main/java/neqsim/thermo/util/benchmark/ThermodynamicBenchmark.java
new file mode 100644
index 0000000000..a6744df564
--- /dev/null
+++ b/src/main/java/neqsim/thermo/util/benchmark/ThermodynamicBenchmark.java
@@ -0,0 +1,328 @@
+package neqsim.thermo.util.benchmark;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Reusable, uncertainty-aware comparison of thermodynamic model predictions with experimental data.
+ *
+ *
+ * Experimental values, units, composition, uncertainty, and provenance remain attached to every point so benchmark
+ * results are auditable and suitable for regression testing.
+ *
+ */
+public final class ThermodynamicBenchmark {
+ private ThermodynamicBenchmark() {
+ }
+
+ /** Thermodynamic properties supported by the benchmark framework. */
+ public enum Property {
+ /** Bubble-point pressure. */
+ BUBBLE_POINT_PRESSURE,
+ /** Dew-point pressure. */
+ DEW_POINT_PRESSURE,
+ /** Mass density. */
+ DENSITY,
+ /** Speed of sound. */
+ SPEED_OF_SOUND,
+ /** Dynamic viscosity. */
+ VISCOSITY,
+ /** Thermal conductivity. */
+ THERMAL_CONDUCTIVITY,
+ /** Isobaric heat capacity. */
+ HEAT_CAPACITY_CP,
+ /** Water content. */
+ WATER_CONTENT
+ }
+
+ /** Immutable experimental benchmark point. */
+ public static final class Point {
+ private final Property property;
+ private final double temperatureK;
+ private final double pressureBara;
+ private final double experimentalValue;
+ private final double standardUncertainty;
+ private final String unit;
+ private final Map composition;
+
+ /**
+ * Creates an experimental point.
+ *
+ * @param property measured property
+ * @param temperatureK absolute temperature in K
+ * @param pressureBara absolute pressure in bara
+ * @param experimentalValue measured value in {@code unit}
+ * @param standardUncertainty standard uncertainty in {@code unit}, or {@link Double#NaN}
+ * @param unit unit of the measured value
+ * @param composition mole-fraction composition
+ */
+ public Point(Property property, double temperatureK, double pressureBara, double experimentalValue,
+ double standardUncertainty, String unit, Map composition) {
+ if (property == null || unit == null || unit.trim().isEmpty() || composition == null || composition.isEmpty()) {
+ throw new IllegalArgumentException("Property, unit, and composition are required");
+ }
+ if (!Double.isFinite(temperatureK) || temperatureK <= 0.0 || !Double.isFinite(pressureBara) || pressureBara <= 0.0
+ || !Double.isFinite(experimentalValue) || experimentalValue == 0.0) {
+ throw new IllegalArgumentException("Temperature, pressure, and experimental value must be physical and finite");
+ }
+ if (!Double.isNaN(standardUncertainty) && (!Double.isFinite(standardUncertainty) || standardUncertainty < 0.0)) {
+ throw new IllegalArgumentException("Standard uncertainty must be non-negative or NaN");
+ }
+ double compositionSum = 0.0;
+ Map compositionCopy = new LinkedHashMap();
+ for (Map.Entry entry : composition.entrySet()) {
+ if (entry.getKey() == null || entry.getKey().trim().isEmpty() || entry.getValue() == null
+ || !Double.isFinite(entry.getValue()) || entry.getValue() < 0.0) {
+ throw new IllegalArgumentException("Composition entries must be named and non-negative");
+ }
+ compositionCopy.put(entry.getKey(), entry.getValue());
+ compositionSum += entry.getValue();
+ }
+ if (Math.abs(compositionSum - 1.0) > 1.0e-8) {
+ throw new IllegalArgumentException("Mole fractions must sum to one");
+ }
+ this.property = property;
+ this.temperatureK = temperatureK;
+ this.pressureBara = pressureBara;
+ this.experimentalValue = experimentalValue;
+ this.standardUncertainty = standardUncertainty;
+ this.unit = unit;
+ this.composition = Collections.unmodifiableMap(compositionCopy);
+ }
+
+ /** @return measured property */
+ public Property getProperty() {
+ return property;
+ }
+
+ /** @return absolute temperature in K */
+ public double getTemperatureK() {
+ return temperatureK;
+ }
+
+ /** @return absolute pressure in bara */
+ public double getPressureBara() {
+ return pressureBara;
+ }
+
+ /** @return experimental value */
+ public double getExperimentalValue() {
+ return experimentalValue;
+ }
+
+ /** @return reported standard uncertainty, or NaN when unavailable */
+ public double getStandardUncertainty() {
+ return standardUncertainty;
+ }
+
+ /** @return property unit */
+ public String getUnit() {
+ return unit;
+ }
+
+ /** @return immutable mole-fraction composition */
+ public Map getComposition() {
+ return composition;
+ }
+ }
+
+ /** Immutable experimental dataset with source provenance. */
+ public static final class Dataset {
+ private final String name;
+ private final String citation;
+ private final String doi;
+ private final String license;
+ private final List points;
+
+ /**
+ * Creates a dataset.
+ *
+ * @param name dataset name
+ * @param citation full source citation
+ * @param doi source DOI
+ * @param license reuse statement for the encoded data
+ * @param points experimental points
+ */
+ public Dataset(String name, String citation, String doi, String license, List points) {
+ if (name == null || name.trim().isEmpty() || citation == null || doi == null || license == null || points == null
+ || points.isEmpty()) {
+ throw new IllegalArgumentException("Dataset metadata and points are required");
+ }
+ this.name = name;
+ this.citation = citation;
+ this.doi = doi;
+ this.license = license;
+ this.points = Collections.unmodifiableList(new ArrayList(points));
+ }
+
+ /** @return dataset name */
+ public String getName() {
+ return name;
+ }
+
+ /** @return source citation */
+ public String getCitation() {
+ return citation;
+ }
+
+ /** @return source DOI */
+ public String getDoi() {
+ return doi;
+ }
+
+ /** @return data reuse statement */
+ public String getLicense() {
+ return license;
+ }
+
+ /** @return immutable experimental points */
+ public List getPoints() {
+ return points;
+ }
+ }
+
+ /** Supplies a model prediction for one experimental point. */
+ public interface Prediction {
+ /**
+ * Predicts the property represented by a point.
+ *
+ * @param point experimental point defining state and composition
+ * @return predicted value in the point's unit
+ * @throws Exception when the model cannot calculate the point
+ */
+ double predict(Point point) throws Exception;
+ }
+
+ /** Result for one experimental point. */
+ public static final class Row {
+ private final Point point;
+ private final double predictedValue;
+ private final double signedRelativeErrorPercent;
+ private final double uncertaintyNormalizedResidual;
+
+ private Row(Point point, double predictedValue) {
+ this.point = point;
+ this.predictedValue = predictedValue;
+ this.signedRelativeErrorPercent = 100.0 * (predictedValue - point.getExperimentalValue())
+ / point.getExperimentalValue();
+ double uncertainty = point.getStandardUncertainty();
+ this.uncertaintyNormalizedResidual = Double.isFinite(uncertainty) && uncertainty > 0.0
+ ? (predictedValue - point.getExperimentalValue()) / uncertainty
+ : Double.NaN;
+ }
+
+ /** @return experimental point */
+ public Point getPoint() {
+ return point;
+ }
+
+ /** @return predicted value */
+ public double getPredictedValue() {
+ return predictedValue;
+ }
+
+ /** @return signed relative error in percent */
+ public double getSignedRelativeErrorPercent() {
+ return signedRelativeErrorPercent;
+ }
+
+ /** @return residual divided by standard uncertainty, or NaN when unavailable */
+ public double getUncertaintyNormalizedResidual() {
+ return uncertaintyNormalizedResidual;
+ }
+ }
+
+ /** Immutable aggregate benchmark report. */
+ public static final class Report {
+ private final String modelName;
+ private final Dataset dataset;
+ private final List rows;
+ private final double averageAbsoluteRelativeDeviationPercent;
+ private final double biasPercent;
+ private final double rootMeanSquareRelativeErrorPercent;
+ private final double maximumAbsoluteRelativeErrorPercent;
+
+ private Report(String modelName, Dataset dataset, List rows) {
+ this.modelName = modelName;
+ this.dataset = dataset;
+ this.rows = Collections.unmodifiableList(new ArrayList(rows));
+ double absoluteErrorSum = 0.0;
+ double signedErrorSum = 0.0;
+ double squaredErrorSum = 0.0;
+ double maximumError = 0.0;
+ for (Row row : rows) {
+ double error = row.getSignedRelativeErrorPercent();
+ absoluteErrorSum += Math.abs(error);
+ signedErrorSum += error;
+ squaredErrorSum += error * error;
+ maximumError = Math.max(maximumError, Math.abs(error));
+ }
+ this.averageAbsoluteRelativeDeviationPercent = absoluteErrorSum / rows.size();
+ this.biasPercent = signedErrorSum / rows.size();
+ this.rootMeanSquareRelativeErrorPercent = Math.sqrt(squaredErrorSum / rows.size());
+ this.maximumAbsoluteRelativeErrorPercent = maximumError;
+ }
+
+ /** @return model name */
+ public String getModelName() {
+ return modelName;
+ }
+
+ /** @return source dataset */
+ public Dataset getDataset() {
+ return dataset;
+ }
+
+ /** @return immutable point results */
+ public List getRows() {
+ return rows;
+ }
+
+ /** @return average absolute relative deviation in percent */
+ public double getAverageAbsoluteRelativeDeviationPercent() {
+ return averageAbsoluteRelativeDeviationPercent;
+ }
+
+ /** @return mean signed relative error in percent */
+ public double getBiasPercent() {
+ return biasPercent;
+ }
+
+ /** @return root mean square relative error in percent */
+ public double getRootMeanSquareRelativeErrorPercent() {
+ return rootMeanSquareRelativeErrorPercent;
+ }
+
+ /** @return maximum absolute relative error in percent */
+ public double getMaximumAbsoluteRelativeErrorPercent() {
+ return maximumAbsoluteRelativeErrorPercent;
+ }
+ }
+
+ /**
+ * Runs a model over all points in a dataset.
+ *
+ * @param modelName auditable model name
+ * @param dataset experimental dataset
+ * @param prediction prediction implementation
+ * @return aggregate report
+ * @throws Exception when any prediction fails or is non-finite
+ */
+ public static Report run(String modelName, Dataset dataset, Prediction prediction) throws Exception {
+ if (modelName == null || modelName.trim().isEmpty() || dataset == null || prediction == null) {
+ throw new IllegalArgumentException("Model name, dataset, and prediction are required");
+ }
+ List rows = new ArrayList();
+ for (Point point : dataset.getPoints()) {
+ double predictedValue = prediction.predict(point);
+ if (!Double.isFinite(predictedValue)) {
+ throw new IllegalStateException("Non-finite prediction for " + point.getProperty());
+ }
+ rows.add(new Row(point, predictedValue));
+ }
+ return new Report(modelName, dataset, rows);
+ }
+}
diff --git a/src/main/resources/data/thermo/benchmark/zhang2026_h2_co2_phase_equilibrium.csv b/src/main/resources/data/thermo/benchmark/zhang2026_h2_co2_phase_equilibrium.csv
new file mode 100644
index 0000000000..1cae18319c
--- /dev/null
+++ b/src/main/resources/data/thermo/benchmark/zhang2026_h2_co2_phase_equilibrium.csv
@@ -0,0 +1,25 @@
+system,temperature_C,property,pressure_MPa,x_CO2,x_H2,x_N2,source_table
+CO2_H2_96_4,-30,bubble,1.45,0.96,0.04,0.00,IV
+CO2_H2_96_4,-30,dew,11.21,0.96,0.04,0.00,IV
+CO2_H2_96_4,-20,bubble,2.14,0.96,0.04,0.00,IV
+CO2_H2_96_4,-20,dew,9.72,0.96,0.04,0.00,IV
+CO2_H2_96_4,-10,bubble,2.76,0.96,0.04,0.00,IV
+CO2_H2_96_4,-10,dew,9.11,0.96,0.04,0.00,IV
+CO2_H2_96_4,0,bubble,3.65,0.96,0.04,0.00,IV
+CO2_H2_96_4,0,dew,8.87,0.96,0.04,0.00,IV
+CO2_H2_96_4,10,bubble,4.85,0.96,0.04,0.00,IV
+CO2_H2_96_4,10,dew,8.61,0.96,0.04,0.00,IV
+CO2_H2_96_4,20,bubble,6.43,0.96,0.04,0.00,IV
+CO2_H2_96_4,20,dew,8.51,0.96,0.04,0.00,IV
+CO2_H2_N2_96_2_2,-30,bubble,1.38,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,-30,dew,6.67,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,-20,bubble,1.95,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,-20,dew,6.53,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,-10,bubble,2.86,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,-10,dew,6.56,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,0,bubble,3.97,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,0,dew,6.85,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,10,bubble,4.92,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,10,dew,7.21,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,20,bubble,6.28,0.96,0.02,0.02,V
+CO2_H2_N2_96_2_2,20,dew,8.09,0.96,0.02,0.02,V
diff --git a/src/test/java/neqsim/thermo/util/benchmark/ThermodynamicBenchmarkTest.java b/src/test/java/neqsim/thermo/util/benchmark/ThermodynamicBenchmarkTest.java
new file mode 100644
index 0000000000..9a56ed33fd
--- /dev/null
+++ b/src/test/java/neqsim/thermo/util/benchmark/ThermodynamicBenchmarkTest.java
@@ -0,0 +1,147 @@
+package neqsim.thermo.util.benchmark;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Dataset;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Point;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Property;
+import neqsim.thermo.util.benchmark.ThermodynamicBenchmark.Report;
+
+/** Tests for reusable thermodynamic benchmark infrastructure and published H2-CO2 data. */
+class ThermodynamicBenchmarkTest {
+ @Test
+ void loadsPublishedH2CO2DatasetWithProvenance() throws Exception {
+ Dataset dataset = H2CO2PhaseEquilibriumData.load();
+
+ assertEquals(24, dataset.getPoints().size());
+ assertEquals("10.1063/5.0288386", dataset.getDoi());
+ assertTrue(dataset.getCitation().contains("Zhang"));
+
+ Point firstPoint = dataset.getPoints().get(0);
+ assertEquals(Property.BUBBLE_POINT_PRESSURE, firstPoint.getProperty());
+ assertEquals(243.15, firstPoint.getTemperatureK(), 1.0e-12);
+ assertEquals(14.5, firstPoint.getExperimentalValue(), 1.0e-12);
+ assertEquals(0.96, firstPoint.getComposition().get("CO2"), 1.0e-12);
+ assertEquals(0.04, firstPoint.getComposition().get("hydrogen"), 1.0e-12);
+
+ Point finalPoint = dataset.getPoints().get(23);
+ assertEquals(Property.DEW_POINT_PRESSURE, finalPoint.getProperty());
+ assertEquals(80.9, finalPoint.getExperimentalValue(), 1.0e-12);
+ assertEquals(0.02, finalPoint.getComposition().get("nitrogen"), 1.0e-12);
+ }
+
+ @Test
+ void reportsMalformedNumericCsvRowsAsIoFailures() {
+ IOException exception = assertThrows(IOException.class,
+ () -> H2CO2PhaseEquilibriumData.parseCsvRow("binary,bad,bubble,1.45,0.96,0.04,0.0,bara", 12));
+
+ assertTrue(exception.getMessage().contains("line 12"));
+ assertTrue(exception.getCause() instanceof NumberFormatException);
+ }
+
+ @Test
+ void calculatesAardBiasRmsAndMaximumError() throws Exception {
+ Dataset dataset = H2CO2PhaseEquilibriumData.load();
+ Report report = ThermodynamicBenchmark.run("synthetic +2 percent", dataset,
+ point -> point.getExperimentalValue() * 1.02);
+
+ assertEquals(2.0, report.getAverageAbsoluteRelativeDeviationPercent(), 1.0e-12);
+ assertEquals(2.0, report.getBiasPercent(), 1.0e-12);
+ assertEquals(2.0, report.getRootMeanSquareRelativeErrorPercent(), 1.0e-12);
+ assertEquals(2.0, report.getMaximumAbsoluteRelativeErrorPercent(), 1.0e-12);
+ assertEquals(24, report.getRows().size());
+ }
+
+ @Test
+ void calculatesUncertaintyNormalizedResidualWhenAvailable() throws Exception {
+ Map composition = new LinkedHashMap();
+ composition.put("CO2", 0.96);
+ composition.put("hydrogen", 0.04);
+ Point point = new Point(Property.BUBBLE_POINT_PRESSURE, 273.15, 36.5, 36.5, 0.5, "bara", composition);
+ Dataset dataset = new Dataset("uncertainty test", "test citation", "10.0000/test", "test data",
+ java.util.Collections.singletonList(point));
+
+ Report report = ThermodynamicBenchmark.run("test model", dataset, pointValue -> 37.5);
+
+ assertEquals(2.0, report.getRows().get(0).getUncertaintyNormalizedResidual(), 1.0e-12);
+ }
+
+ @Test
+ void rejectsCompositionThatDoesNotSumToOne() {
+ Map composition = new LinkedHashMap();
+ composition.put("CO2", 0.90);
+ composition.put("hydrogen", 0.04);
+
+ assertThrows(IllegalArgumentException.class,
+ () -> new Point(Property.BUBBLE_POINT_PRESSURE, 273.15, 36.5, 36.5, Double.NaN, "bara", composition));
+ }
+
+ @Test
+ void rejectsInvalidStateValueAndUncertainty() {
+ Map composition = new LinkedHashMap();
+ composition.put("CO2", 0.96);
+ composition.put("hydrogen", 0.04);
+
+ assertThrows(IllegalArgumentException.class,
+ () -> new Point(Property.BUBBLE_POINT_PRESSURE, 273.15, 0.0, 36.5, Double.NaN, "bara", composition));
+ assertThrows(IllegalArgumentException.class,
+ () -> new Point(Property.BUBBLE_POINT_PRESSURE, 273.15, 36.5, 0.0, Double.NaN, "bara", composition));
+ assertThrows(IllegalArgumentException.class,
+ () -> new Point(Property.BUBBLE_POINT_PRESSURE, 273.15, 36.5, 36.5, -0.1, "bara", composition));
+ }
+
+ @Test
+ void exposesConfiguredNeqSimModel() {
+ NeqSimPhaseEquilibriumPrediction prediction = new NeqSimPhaseEquilibriumPrediction(
+ NeqSimPhaseEquilibriumPrediction.Model.GERG_2008_H2);
+
+ assertEquals(NeqSimPhaseEquilibriumPrediction.Model.GERG_2008_H2, prediction.getModel());
+ }
+
+ @Test
+ void returnsPredictionInPointPressureUnit() throws Exception {
+ Point source = H2CO2PhaseEquilibriumData.load().getPoints().get(0);
+ Point pressureInMegapascal = new Point(source.getProperty(), source.getTemperatureK(), source.getPressureBara(),
+ source.getExperimentalValue() / 10.0, source.getStandardUncertainty(), "MPa", source.getComposition());
+ NeqSimPhaseEquilibriumPrediction prediction = new NeqSimPhaseEquilibriumPrediction(
+ NeqSimPhaseEquilibriumPrediction.Model.SRK);
+
+ double predictionBara = prediction.predict(source);
+ double predictionMegapascal = prediction.predict(pressureInMegapascal);
+
+ assertTrue(Double.isFinite(predictionMegapascal));
+ assertEquals(predictionBara, predictionMegapascal * 10.0, 1.0e-8);
+ }
+
+ @Test
+ void fitsScalarObjectiveWithinBounds() throws Exception {
+ Dataset dataset = H2CO2PhaseEquilibriumData.load();
+ BinaryInteractionParameterFitter fitter = new BinaryInteractionParameterFitter(dataset,
+ parameter -> point -> point.getExperimentalValue() * (1.0 + parameter - 0.125));
+
+ BinaryInteractionParameterFitter.Result result = fitter.fit(-0.2, 0.3, 1.0e-8, 50);
+
+ assertEquals(0.125, result.getBinaryInteractionParameter(), 1.0e-6);
+ assertTrue(result.getRootMeanSquareRelativeErrorPercent() < 1.0e-5);
+ }
+
+ @Test
+ void rejectsCustomKijForNonCubicModel() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new NeqSimPhaseEquilibriumPrediction(NeqSimPhaseEquilibriumPrediction.Model.GERG_2008_H2, 0.1));
+ }
+
+ @Test
+ void exposesCustomKijForCubicModel() {
+ NeqSimPhaseEquilibriumPrediction prediction = new NeqSimPhaseEquilibriumPrediction(
+ NeqSimPhaseEquilibriumPrediction.Model.PR, -0.1);
+
+ assertEquals(-0.1, prediction.getHydrogenCarbonDioxideKij(), 1.0e-12);
+ }
+}