Skip to content

Commit 80a8132

Browse files
authored
Add oil assay fluid characterization support (#1639)
* Add oil assay fluid characterization support * update oil assay
1 parent 3aa5f83 commit 80a8132

File tree

4 files changed

+524
-1
lines changed

4 files changed

+524
-1
lines changed
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
package neqsim.thermo.characterization;
2+
3+
import java.io.Serializable;
4+
import java.util.ArrayList;
5+
import java.util.Collection;
6+
import java.util.Collections;
7+
import java.util.List;
8+
import java.util.Objects;
9+
import org.apache.logging.log4j.LogManager;
10+
import org.apache.logging.log4j.Logger;
11+
import neqsim.thermo.system.SystemInterface;
12+
13+
/**
14+
* Utility for characterising an oil system from assay information.
15+
*/
16+
public class OilAssayCharacterisation implements Cloneable, Serializable {
17+
private static final long serialVersionUID = 1000L;
18+
private static final Logger logger = LogManager.getLogger(OilAssayCharacterisation.class);
19+
private static final double FRACTION_TOLERANCE = 1e-10;
20+
private static final double KELVIN_OFFSET = 273.15;
21+
private static final double WATER_DENSITY_60F_G_CC = 0.999016; // API definition reference
22+
// density.
23+
24+
private transient SystemInterface system;
25+
private double totalAssayMass = 1.0; // kg basis when converting mass fraction to moles.
26+
private List<AssayCut> cuts = new ArrayList<>();
27+
28+
public OilAssayCharacterisation(SystemInterface system) {
29+
setThermoSystem(system);
30+
}
31+
32+
public void setThermoSystem(SystemInterface system) {
33+
this.system = Objects.requireNonNull(system, "system");
34+
}
35+
36+
public double getTotalAssayMass() {
37+
return totalAssayMass;
38+
}
39+
40+
public void setTotalAssayMass(double totalAssayMass) {
41+
if (!(totalAssayMass > 0.0)) {
42+
throw new IllegalArgumentException("Total assay mass must be positive");
43+
}
44+
this.totalAssayMass = totalAssayMass;
45+
}
46+
47+
public void clearCuts() {
48+
cuts.clear();
49+
}
50+
51+
public void addCut(AssayCut cut) {
52+
cuts.add(Objects.requireNonNull(cut, "cut"));
53+
}
54+
55+
public void addCuts(Collection<AssayCut> cuts) {
56+
if (cuts == null) {
57+
return;
58+
}
59+
for (AssayCut cut : cuts) {
60+
addCut(cut);
61+
}
62+
}
63+
64+
public List<AssayCut> getCuts() {
65+
return Collections.unmodifiableList(cuts);
66+
}
67+
68+
public void apply() {
69+
if (system == null) {
70+
throw new IllegalStateException("Thermodynamic system not attached to assay data");
71+
}
72+
if (cuts.isEmpty()) {
73+
logger.warn("No assay cuts supplied – nothing to characterise");
74+
return;
75+
}
76+
77+
double[] massFractions = resolveMassFractions();
78+
for (int i = 0; i < cuts.size(); i++) {
79+
AssayCut cut = cuts.get(i);
80+
double massFraction = massFractions[i];
81+
if (!(massFraction > FRACTION_TOLERANCE)) {
82+
continue;
83+
}
84+
85+
double density = cut.resolveDensity();
86+
double molarMass;
87+
if (cut.hasMolarMass()) {
88+
// Use explicit molar mass - no boiling point needed
89+
molarMass = cut.resolveMolarMass(0.0, 0.0);
90+
} else {
91+
// Calculate molar mass from density and boiling point
92+
double boilingPoint = cut.resolveAverageBoilingPoint();
93+
molarMass = cut.resolveMolarMass(density, boilingPoint);
94+
}
95+
double moles = totalAssayMass * massFraction / molarMass;
96+
97+
if (moles <= 0.0 || Double.isNaN(moles) || Double.isInfinite(moles)) {
98+
throw new IllegalStateException(
99+
"Calculated mole amount for assay cut " + cut.getName() + " is not finite");
100+
}
101+
102+
system.addTBPfraction(cut.getName(), moles, molarMass, density);
103+
}
104+
}
105+
106+
private double[] resolveMassFractions() {
107+
double[] massFractions = new double[cuts.size()];
108+
double specifiedMass = 0.0;
109+
double volumeMass = 0.0;
110+
boolean hasVolumeFractions = false;
111+
112+
for (int i = 0; i < cuts.size(); i++) {
113+
AssayCut cut = cuts.get(i);
114+
if (cut.hasMassFraction()) {
115+
double massFraction = cut.getMassFraction();
116+
specifiedMass += massFraction;
117+
massFractions[i] = massFraction;
118+
} else if (cut.hasVolumeFraction()) {
119+
hasVolumeFractions = true;
120+
double density = cut.resolveDensity();
121+
volumeMass += cut.getVolumeFraction() * density;
122+
} else {
123+
throw new IllegalStateException(
124+
"Assay cut " + cut.getName() + " must define a mass or volume fraction");
125+
}
126+
}
127+
128+
if (specifiedMass > 1.0 + 1e-6) {
129+
throw new IllegalStateException("Specified mass fractions exceed unity: " + specifiedMass);
130+
}
131+
132+
double remainingMass = Math.max(0.0, 1.0 - specifiedMass);
133+
134+
if (hasVolumeFractions) {
135+
if (!(volumeMass > 0.0)) {
136+
throw new IllegalStateException("Unable to derive mass fractions from volume data");
137+
}
138+
for (int i = 0; i < cuts.size(); i++) {
139+
AssayCut cut = cuts.get(i);
140+
if (!cut.hasMassFraction() && cut.hasVolumeFraction()) {
141+
double density = cut.resolveDensity();
142+
double cutMass = cut.getVolumeFraction() * density;
143+
massFractions[i] = cutMass / volumeMass * remainingMass;
144+
}
145+
}
146+
}
147+
148+
double totalMassFraction = 0.0;
149+
for (double fraction : massFractions) {
150+
totalMassFraction += fraction;
151+
}
152+
153+
if (!(totalMassFraction > 0.0)) {
154+
throw new IllegalStateException("No valid mass fractions derived from assay data");
155+
}
156+
157+
if (Math.abs(totalMassFraction - 1.0) > 1.0e-8) {
158+
for (int i = 0; i < massFractions.length; i++) {
159+
massFractions[i] /= totalMassFraction;
160+
}
161+
}
162+
return massFractions;
163+
}
164+
165+
@Override
166+
public OilAssayCharacterisation clone() {
167+
try {
168+
OilAssayCharacterisation clone = (OilAssayCharacterisation) super.clone();
169+
clone.cuts = new ArrayList<>();
170+
for (AssayCut cut : cuts) {
171+
clone.cuts.add(cut.clone());
172+
}
173+
clone.system = system;
174+
return clone;
175+
} catch (CloneNotSupportedException ex) {
176+
throw new IllegalStateException("Clone not supported", ex);
177+
}
178+
}
179+
180+
public static final class AssayCut implements Cloneable, Serializable {
181+
private static final long serialVersionUID = 1000L;
182+
private final String name;
183+
private Double massFraction;
184+
private Double volumeFraction;
185+
private Double density;
186+
private Double apiGravity;
187+
private Double averageBoilingPointKelvin;
188+
private Double molarMass;
189+
190+
public AssayCut(String name) {
191+
this.name = Objects.requireNonNull(name, "name");
192+
}
193+
194+
public String getName() {
195+
return name;
196+
}
197+
198+
public AssayCut withMassFraction(double massFraction) {
199+
this.massFraction = sanitiseFraction(massFraction);
200+
return this;
201+
}
202+
203+
public AssayCut withWeightPercent(double weightPercent) {
204+
this.massFraction = sanitiseFraction(weightPercent / 100.0);
205+
return this;
206+
}
207+
208+
public AssayCut withVolumeFraction(double volumeFraction) {
209+
this.volumeFraction = sanitiseFraction(volumeFraction);
210+
return this;
211+
}
212+
213+
public AssayCut withVolumePercent(double volumePercent) {
214+
this.volumeFraction = sanitiseFraction(volumePercent / 100.0);
215+
return this;
216+
}
217+
218+
public AssayCut withDensity(double density) {
219+
if (!(density > 0.0)) {
220+
throw new IllegalArgumentException("Density must be positive");
221+
}
222+
this.density = density;
223+
return this;
224+
}
225+
226+
public AssayCut withApiGravity(double apiGravity) {
227+
if (!(apiGravity > 0.0)) {
228+
throw new IllegalArgumentException("API gravity must be positive");
229+
}
230+
this.apiGravity = apiGravity;
231+
return this;
232+
}
233+
234+
public AssayCut withAverageBoilingPointKelvin(double temperatureKelvin) {
235+
if (!(temperatureKelvin > 0.0)) {
236+
throw new IllegalArgumentException("Boiling point must be positive");
237+
}
238+
this.averageBoilingPointKelvin = temperatureKelvin;
239+
return this;
240+
}
241+
242+
public AssayCut withAverageBoilingPointCelsius(double temperatureCelsius) {
243+
return withAverageBoilingPointKelvin(temperatureCelsius + KELVIN_OFFSET);
244+
}
245+
246+
public AssayCut withAverageBoilingPointFahrenheit(double temperatureFahrenheit) {
247+
double temperatureCelsius = (temperatureFahrenheit - 32.0) * 5.0 / 9.0;
248+
return withAverageBoilingPointKelvin(temperatureCelsius + KELVIN_OFFSET);
249+
}
250+
251+
public AssayCut withMolarMass(double molarMass) {
252+
if (!(molarMass > 0.0)) {
253+
throw new IllegalArgumentException("Molar mass must be positive");
254+
}
255+
this.molarMass = molarMass;
256+
return this;
257+
}
258+
259+
public boolean hasMassFraction() {
260+
return massFraction != null;
261+
}
262+
263+
public double getMassFraction() {
264+
if (massFraction == null) {
265+
throw new IllegalStateException("Mass fraction not set");
266+
}
267+
return massFraction;
268+
}
269+
270+
public boolean hasVolumeFraction() {
271+
return volumeFraction != null;
272+
}
273+
274+
public double getVolumeFraction() {
275+
if (volumeFraction == null) {
276+
throw new IllegalStateException("Volume fraction not set");
277+
}
278+
return volumeFraction;
279+
}
280+
281+
public boolean hasMolarMass() {
282+
return molarMass != null;
283+
}
284+
285+
public double resolveDensity() {
286+
if (density != null) {
287+
return density;
288+
}
289+
if (apiGravity != null) {
290+
double specificGravity = 141.5 / (apiGravity + 131.5);
291+
return specificGravity * WATER_DENSITY_60F_G_CC;
292+
}
293+
throw new IllegalStateException("Density or API gravity required for cut " + name);
294+
}
295+
296+
public double resolveAverageBoilingPoint() {
297+
if (averageBoilingPointKelvin == null) {
298+
throw new IllegalStateException("Average boiling point missing for cut " + name);
299+
}
300+
return averageBoilingPointKelvin;
301+
}
302+
303+
public double resolveMolarMass(double density, double boilingPointKelvin) {
304+
if (molarMass != null) {
305+
return molarMass;
306+
}
307+
if (!(density > 0.0) || !(boilingPointKelvin > 0.0)) {
308+
throw new IllegalStateException(
309+
"Cannot derive molar mass without density and boiling point");
310+
}
311+
double exponent = 2.3776;
312+
double densityExponent = 0.9371;
313+
double molarMassKgPerMol =
314+
5.805e-5 * Math.pow(boilingPointKelvin, exponent) / Math.pow(density, densityExponent);
315+
return molarMassKgPerMol;
316+
}
317+
318+
@Override
319+
public AssayCut clone() throws CloneNotSupportedException {
320+
return (AssayCut) super.clone();
321+
}
322+
323+
private static double sanitiseFraction(double fraction) {
324+
if (fraction < 0.0) {
325+
throw new IllegalArgumentException("Fraction cannot be negative");
326+
}
327+
double candidate = fraction;
328+
if (candidate > 1.0 + 1e-9) {
329+
candidate = candidate / 100.0;
330+
}
331+
if (candidate < 0.0 || candidate > 1.0 + 1e-9) {
332+
throw new IllegalArgumentException("Fraction must be between 0 and 1");
333+
}
334+
return candidate;
335+
}
336+
}
337+
}

src/main/java/neqsim/thermo/system/SystemInterface.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import neqsim.physicalproperties.interfaceproperties.InterphasePropertiesInterface;
66
import neqsim.physicalproperties.system.PhysicalPropertyModel;
77
import neqsim.thermo.ThermodynamicConstantsInterface;
8+
import neqsim.thermo.characterization.OilAssayCharacterisation;
89
import neqsim.thermo.characterization.PseudoComponentCombiner;
910
import neqsim.thermo.characterization.WaxModelInterface;
1011
import neqsim.thermo.component.ComponentInterface;
@@ -1611,6 +1612,15 @@ public default int getPhaseNumberOfPhase(String phaseTypeName) {
16111612
*/
16121613
public double getVolumeFraction(int phaseNumber);
16131614

1615+
/**
1616+
* <p>
1617+
* getOilAssayCharacterisation.
1618+
* </p>
1619+
*
1620+
* @return a {@link neqsim.thermo.characterization.OilAssayCharacterisation} object
1621+
*/
1622+
public OilAssayCharacterisation getOilAssayCharacterisation();
1623+
16141624
/**
16151625
* <p>
16161626
* getWaxCharacterisation.

0 commit comments

Comments
 (0)