Skip to main content

feos_core/phase_equilibria/
tp_flash.rs

1use super::PhaseEquilibrium;
2use crate::equation_of_state::Residual;
3use crate::errors::{FeosError, FeosResult};
4use crate::state::{Contributions, State};
5use crate::{Composition, DensityInitialization, ReferenceSystem, SolverOptions, Verbosity};
6use nalgebra::allocator::Allocator;
7use nalgebra::{DefaultAllocator, Dim, Matrix3, OVector, SVector, U1, U2, vector};
8use num_dual::{
9    Dual, Dual2Vec, DualNum, DualStruct, Gradients, first_derivative, implicit_derivative_sp,
10};
11use quantity::{MolarEnergy, MolarVolume, Pressure, RGAS, Temperature};
12
13const MAX_ITER_TP: usize = 400;
14const TOL_TP: f64 = 1e-8;
15
16/// # Flash calculations
17impl<E: Residual<N>, N: Gradients> PhaseEquilibrium<E, 2, N>
18where
19    DefaultAllocator: Allocator<N> + Allocator<N, N>,
20{
21    /// Perform a Tp-flash calculation. If no initial values are
22    /// given, the solution is initialized using a stability analysis.
23    ///
24    /// The algorithm can be use to calculate phase equilibria of systems
25    /// containing non-volatile components (e.g. ions).
26    pub fn tp_flash<X: Composition<f64, N>>(
27        eos: &E,
28        temperature: Temperature,
29        pressure: Pressure,
30        feed: X,
31        initial_state: Option<&PhaseEquilibrium<E, 2, N>>,
32        options: SolverOptions,
33        non_volatile_components: Option<Vec<usize>>,
34    ) -> FeosResult<Self> {
35        State::new_npt(eos, temperature, pressure, feed, None)?.tp_flash(
36            initial_state,
37            options,
38            non_volatile_components,
39        )
40    }
41}
42
43impl<E: Residual<U2, D>, D: DualNum<f64> + Copy> PhaseEquilibrium<E, 2, U2, D> {
44    /// Perform a Tp-flash calculation for a binary mixture.
45    /// Compared to the version of the algorithm for a generic
46    /// number of components ([tp_flash](PhaseEquilibrium::tp_flash)),
47    /// this can be used in combination with automatic differentiation.
48    pub fn tp_flash_binary<X: Composition<D, U2>>(
49        eos: &E,
50        temperature: Temperature<D>,
51        pressure: Pressure<D>,
52        feed: X,
53        options: SolverOptions,
54    ) -> FeosResult<Self> {
55        let (feed, total_moles) = feed.into_molefracs(eos)?;
56        let z = feed[0];
57        let vle_re = State::new_npt(&eos.re(), temperature.re(), pressure.re(), z.re(), None)?
58            .tp_flash(None, options, None)?;
59
60        // implicit differentiation
61
62        // specifications
63        let t = temperature.into_reduced();
64        let p = pressure.into_reduced();
65
66        // molar volume and composition of the two phases
67        let variables = SVector::from([
68            vle_re.liquid().density.into_reduced().recip(),
69            vle_re.vapor().density.into_reduced().recip(),
70            vle_re.liquid().molefracs[0],
71            vle_re.vapor().molefracs[0],
72        ]);
73
74        // calculate derivatives for molar volumes and compositions (first component)
75        // with respect to t, p, or z or equation of state parameters
76        // using implicit differentiation of the minimum in the Gibbs energy
77        let [[v_l, v_v, x, y]] = implicit_derivative_sp(
78            |variables, &[t, p, z]: &[_; 3]| {
79                let [[v_l, v_v, x, y]] = variables.data.0;
80                let beta = (z - x) / (y - x);
81                let eos = eos.lift();
82                let molar_gibbs_energy = |x: Dual2Vec<_, _, _>, v| {
83                    let molefracs = vector![x, -x + 1.0];
84                    let a_res = eos.residual_helmholtz_energy(t, v, &molefracs);
85                    let a_ig = (x * (x / v).ln() - (x - 1.0) * ((-x + 1.0) / v).ln() - 1.0) * t;
86                    a_res + a_ig + v * p
87                };
88                // g = a + pv is the potential function for a tp flash using a Helmholtz energy model
89                // see https://www.sciencedirect.com/science/article/pii/S0378381299000928
90                molar_gibbs_energy(y, v_v) * beta - molar_gibbs_energy(x, v_l) * (beta - 1.0)
91            },
92            variables,
93            &[t, p, z],
94        )
95        .data
96        .0;
97        let beta = (z - x) / (y - x);
98        let state = |x: D, v| {
99            let density = MolarVolume::from_reduced(v).inv();
100            State::new(eos, temperature, density, x)
101        };
102        Ok(Self::with_vapor_phase_fraction(
103            state(y, v_v)?,
104            state(x, v_l)?,
105            beta,
106            total_moles,
107        ))
108    }
109}
110
111/// # Flash calculations
112impl<E: Residual<N>, N: Gradients> State<E, N>
113where
114    DefaultAllocator: Allocator<N> + Allocator<N, N>,
115{
116    /// Perform a Tp-flash calculation using the [State] as feed.
117    /// If no initial values are given, the solution is initialized
118    /// using a stability analysis.
119    ///
120    /// The algorithm can be use to calculate phase equilibria of systems
121    /// containing non-volatile components (e.g. ions).
122    pub fn tp_flash(
123        &self,
124        initial_state: Option<&PhaseEquilibrium<E, 2, N>>,
125        options: SolverOptions,
126        non_volatile_components: Option<Vec<usize>>,
127    ) -> FeosResult<PhaseEquilibrium<E, 2, N>> {
128        // initialization
129        if let Some(initial_state) = initial_state {
130            let mut init = initial_state.clone();
131            init.update_states(
132                self,
133                initial_state.vapor().molefracs.clone(),
134                initial_state.liquid().molefracs.clone(),
135                initial_state.vapor_phase_fraction(),
136            )?;
137            let vle = self.tp_flash_(init, options, non_volatile_components.clone());
138            if vle.is_ok() {
139                return vle;
140            }
141        }
142
143        let (init1, init2) = PhaseEquilibrium::vle_init_stability(self)?;
144        let vle = self.tp_flash_(init1, options, non_volatile_components.clone());
145        if vle.is_ok() {
146            return vle;
147        }
148
149        if let Some(init2) = init2 {
150            self.tp_flash_(init2, options, non_volatile_components)
151        } else {
152            vle
153        }
154    }
155
156    pub fn tp_flash_(
157        &self,
158        mut new_vle_state: PhaseEquilibrium<E, 2, N>,
159        options: SolverOptions,
160        non_volatile_components: Option<Vec<usize>>,
161    ) -> FeosResult<PhaseEquilibrium<E, 2, N>> {
162        // set options
163        let (max_iter, tol, verbosity) = options.unwrap_or(MAX_ITER_TP, TOL_TP);
164
165        log_iter!(
166            verbosity,
167            " iter |    residual    |  phase I mole fractions  |  phase II mole fractions  "
168        );
169        log_iter!(verbosity, "{:-<77}", "");
170        log_iter!(
171            verbosity,
172            " {:4} |                | {:10.8?} | {:10.8?}",
173            0,
174            new_vle_state.vapor().molefracs.as_slice(),
175            new_vle_state.liquid().molefracs.as_slice(),
176        );
177
178        let mut iter = 0;
179        if non_volatile_components.is_none() {
180            // 3 steps of successive substitution
181            new_vle_state.successive_substitution(
182                self,
183                3,
184                &mut iter,
185                &mut None,
186                tol,
187                verbosity,
188                &non_volatile_components,
189            )?;
190
191            // check convergence
192            let tpd = [
193                self.tangent_plane_distance(new_vle_state.vapor()),
194                self.tangent_plane_distance(new_vle_state.liquid()),
195            ];
196            let b = new_vle_state.phase_fractions;
197            let dg = b[0] * tpd[0] + b[1] * tpd[1];
198
199            // fix if only tpd[1] is positive
200            if tpd[0] < 0.0 && dg >= 0.0 {
201                let mut k = (self.ln_phi() - new_vle_state.vapor().ln_phi()).map(f64::exp);
202                // Set k = 0 for non-volatile components
203                if let Some(nvc) = non_volatile_components.as_ref() {
204                    nvc.iter().for_each(|&c| k[c] = 0.0);
205                }
206                new_vle_state.rachford_rice_inplace(self, &k)?;
207                new_vle_state.successive_substitution(
208                    self,
209                    1,
210                    &mut iter,
211                    &mut None,
212                    tol,
213                    verbosity,
214                    &non_volatile_components,
215                )?;
216            }
217
218            // fix if only tpd[0] is positive
219            if tpd[1] < 0.0 && dg >= 0.0 {
220                let mut k = (new_vle_state.liquid().ln_phi() - self.ln_phi()).map(f64::exp);
221                // Set k = 0 for non-volatile components
222                if let Some(nvc) = non_volatile_components.as_ref() {
223                    nvc.iter().for_each(|&c| k[c] = 0.0);
224                }
225                new_vle_state.rachford_rice_inplace(self, &k)?;
226                new_vle_state.successive_substitution(
227                    self,
228                    1,
229                    &mut iter,
230                    &mut None,
231                    tol,
232                    verbosity,
233                    &non_volatile_components,
234                )?;
235            }
236        }
237
238        //continue with accelerated successive subsitution
239        new_vle_state.accelerated_successive_substitution(
240            self,
241            &mut iter,
242            max_iter,
243            tol,
244            verbosity,
245            &non_volatile_components,
246        )?;
247
248        Ok(new_vle_state)
249    }
250
251    fn tangent_plane_distance(&self, trial_state: &State<E, N>) -> f64 {
252        let ln_phi_z = self.ln_phi();
253        let ln_phi_w = trial_state.ln_phi();
254        let z = &self.molefracs;
255        let w = &trial_state.molefracs;
256        w.dot(&(w.map(f64::ln) + ln_phi_w - z.map(f64::ln) - ln_phi_z))
257    }
258}
259
260impl<E: Residual<N>, N: Gradients> PhaseEquilibrium<E, 2, N>
261where
262    DefaultAllocator: Allocator<N> + Allocator<N, N>,
263{
264    fn accelerated_successive_substitution(
265        &mut self,
266        feed_state: &State<E, N>,
267        iter: &mut usize,
268        max_iter: usize,
269        tol: f64,
270        verbosity: Verbosity,
271        non_volatile_components: &Option<Vec<usize>>,
272    ) -> FeosResult<()> {
273        let (n, _) = feed_state.molefracs.shape_generic();
274        for _ in 0..max_iter {
275            // do 5 successive substitution steps and check for convergence
276            let mut k_vec = std::array::repeat(OVector::zeros_generic(n, U1));
277            if self.successive_substitution(
278                feed_state,
279                5,
280                iter,
281                &mut Some(&mut k_vec),
282                tol,
283                verbosity,
284                non_volatile_components,
285            )? {
286                log_result!(
287                    verbosity,
288                    "Tp flash: calculation converged in {} step(s)\n",
289                    iter
290                );
291                return Ok(());
292            }
293
294            // calculate total Gibbs energy before the extrapolation
295            let gibbs = self.molar_gibbs_energy();
296
297            // extrapolate K values
298            let delta_vec = [
299                &k_vec[1] - &k_vec[0],
300                &k_vec[2] - &k_vec[1],
301                &k_vec[3] - &k_vec[2],
302            ];
303            let delta = Matrix3::from_fn(|i, j| delta_vec[i].dot(&delta_vec[j]));
304            let d = delta[(0, 1)] * delta[(0, 1)] - delta[(0, 0)] * delta[(1, 1)];
305            let a = (delta[(0, 2)] * delta[(0, 1)] - delta[(1, 2)] * delta[(0, 0)]) / d;
306            let b = (delta[(1, 2)] * delta[(0, 1)] - delta[(0, 2)] * delta[(1, 1)]) / d;
307
308            let mut k = (&k_vec[3]
309                + ((b * &delta_vec[1] + (a + b) * &delta_vec[2]) / (1.0 - a - b)))
310                .map(f64::exp);
311
312            // Set k = 0 for non-volatile components
313            if let Some(nvc) = non_volatile_components.as_ref() {
314                nvc.iter().for_each(|&c| k[c] = 0.0);
315            }
316            if !k.iter().all(|i| i.is_finite()) {
317                continue;
318            }
319
320            // calculate new states
321            let mut trial_vle_state = self.clone();
322            trial_vle_state.rachford_rice_inplace(feed_state, &k)?;
323            if trial_vle_state.molar_gibbs_energy() < gibbs {
324                *self = trial_vle_state;
325            }
326        }
327        Err(FeosError::NotConverged("TP flash".to_owned()))
328    }
329
330    #[expect(clippy::too_many_arguments)]
331    fn successive_substitution(
332        &mut self,
333        feed_state: &State<E, N>,
334        iterations: usize,
335        iter: &mut usize,
336        k_vec: &mut Option<&mut [OVector<f64, N>; 4]>,
337        abs_tol: f64,
338        verbosity: Verbosity,
339        non_volatile_components: &Option<Vec<usize>>,
340    ) -> FeosResult<bool> {
341        for i in 0..iterations {
342            let ln_phi_v = self.vapor().ln_phi();
343            let ln_phi_l = self.liquid().ln_phi();
344            let mut k = (&ln_phi_l - &ln_phi_v).map(f64::exp);
345
346            // Set k = 0 for non-volatile components
347            if let Some(nvc) = non_volatile_components.as_ref() {
348                nvc.iter().for_each(|&c| k[c] = 0.0);
349            }
350
351            // check for convergence
352            *iter += 1;
353            let mut res_vec = k.component_mul(&self.liquid().molefracs) - &self.vapor().molefracs;
354
355            // Set residuum to 0 for non-volatile components
356            if let Some(nvc) = non_volatile_components.as_ref() {
357                nvc.iter().for_each(|&c| res_vec[c] = 0.0);
358            }
359
360            let res = res_vec.norm();
361            log_iter!(
362                verbosity,
363                " {:4} | {:14.8e} | {:.8?} | {:.8?}",
364                iter,
365                res,
366                self.vapor().molefracs.as_slice(),
367                self.liquid().molefracs.as_slice(),
368            );
369            if res < abs_tol {
370                return Ok(true);
371            }
372
373            self.rachford_rice_inplace(feed_state, &k)?;
374            if let Some(k_vec) = k_vec
375                && i >= iterations - 3
376            {
377                k_vec[i + 3 - iterations] = k.map(|ki| if ki > 0.0 { ki.ln() } else { 0.0 });
378            }
379        }
380        Ok(false)
381    }
382
383    fn rachford_rice_inplace(
384        &mut self,
385        feed_state: &State<E, N>,
386        k: &OVector<f64, N>,
387    ) -> FeosResult<()> {
388        // calculate vapor phase fraction using Rachford-Rice algorithm
389        let (b, [v, l]) =
390            rachford_rice(&feed_state.molefracs, k, Some(self.vapor_phase_fraction()))?;
391        self.update_states(feed_state, v, l, b)
392    }
393
394    fn update_states(
395        &mut self,
396        feed_state: &State<E, N>,
397        vapor_molefracs: OVector<f64, N>,
398        liquid_molefracs: OVector<f64, N>,
399        beta: f64,
400    ) -> FeosResult<()> {
401        let vapor = State::new_npt(
402            &feed_state.eos,
403            feed_state.temperature,
404            feed_state.pressure(Contributions::Total),
405            vapor_molefracs,
406            Some(DensityInitialization::InitialDensity(self.vapor().density)),
407        )?;
408        let liquid = State::new_npt(
409            &feed_state.eos,
410            feed_state.temperature,
411            feed_state.pressure(Contributions::Total),
412            liquid_molefracs,
413            Some(DensityInitialization::InitialDensity(self.liquid().density)),
414        )?;
415
416        *self = Self::with_vapor_phase_fraction(vapor, liquid, beta, feed_state.total_moles);
417
418        Ok(())
419    }
420
421    fn vle_init_stability(feed_state: &State<E, N>) -> FeosResult<(Self, Option<Self>)> {
422        let mut stable_states = feed_state.stability_analysis(SolverOptions::default())?;
423        let state1 = stable_states.pop();
424        let state2 = stable_states.pop();
425        if let Some(s1) = state1 {
426            let init1 = if s1.density < feed_state.density {
427                Self::two_phase(s1.clone(), feed_state.clone())
428            } else {
429                Self::two_phase(feed_state.clone(), s1.clone())
430            };
431            let init2 = state2.map(|s2| {
432                if s1.density < s2.density {
433                    Self::two_phase(s1.clone(), s2.clone())
434                } else {
435                    Self::two_phase(s2.clone(), s1.clone())
436                }
437            });
438            Ok((init1, init2))
439        } else {
440            Err(FeosError::NoPhaseSplit)
441        }
442    }
443
444    // Total molar Gibbs energy excluding the constant contribution RT sum_i x_i ln(\Lambda_i^3)
445    fn molar_gibbs_energy(&self) -> MolarEnergy {
446        self.states
447            .iter()
448            .fold(MolarEnergy::from_reduced(0.0), |acc, s| {
449                let ln_rho_m1 = s.partial_density().to_reduced().map(|r| r.ln() - 1.0);
450                acc + s.residual_molar_helmholtz_energy()
451                    + s.pressure(Contributions::Total) * s.molar_volume
452                    + RGAS * s.temperature * s.molefracs.dot(&ln_rho_m1)
453            })
454    }
455}
456
457fn rachford_rice<N: Dim>(
458    feed: &OVector<f64, N>,
459    k: &OVector<f64, N>,
460    beta_in: Option<f64>,
461) -> FeosResult<(f64, [OVector<f64, N>; 2])>
462where
463    DefaultAllocator: Allocator<N>,
464{
465    const MAX_ITER: usize = 10;
466    const ABS_TOL: f64 = 1e-6;
467
468    // check if solution exists
469    let (mut beta_min, mut beta_max) = if feed.dot(k) > 1.0
470        && feed
471            .component_div(k)
472            .iter()
473            .filter(|x| !x.is_nan())
474            .sum::<f64>()
475            > 1.0
476    {
477        (0.0, 1.0)
478    } else {
479        return Err(FeosError::IterationFailed(String::from("rachford_rice")));
480    };
481
482    // look for tighter bounds
483    for (&k, &f) in k.iter().zip(feed.iter()) {
484        if k > 1.0 {
485            let b = (k * f - 1.0) / (k - 1.0);
486            if b > beta_min {
487                beta_min = b;
488            }
489        }
490        if k < 1.0 {
491            let b = (1.0 - f) / (1.0 - k);
492            if b < beta_max {
493                beta_max = b;
494            }
495        }
496    }
497
498    // initialize
499    let mut beta = 0.5 * (beta_min + beta_max);
500    if let Some(b) = beta_in
501        && b > beta_min
502        && b < beta_max
503    {
504        beta = b;
505    }
506    let g = feed.dot(&k.map(|k| (k - 1.0) / (1.0 - beta + beta * k)));
507    if g > 0.0 {
508        beta_min = beta
509    } else {
510        beta_max = beta
511    }
512
513    // iterate
514    for _ in 0..MAX_ITER {
515        let (g, dg) = first_derivative(
516            |beta| {
517                let frac = k.map(|k| (-beta + beta * k + 1.0).recip() * (k - 1.0));
518                feed.map(Dual::from).dot(&frac)
519            },
520            beta,
521        );
522        if g > 0.0 {
523            beta_min = beta;
524        } else {
525            beta_max = beta;
526        }
527
528        let dbeta = g / dg;
529        beta -= dbeta;
530
531        if beta < beta_min || beta > beta_max {
532            beta = 0.5 * (beta_min + beta_max);
533        }
534        if dbeta.abs() < ABS_TOL {
535            // update VLE
536            let v = feed.component_mul(&k.map(|k| beta * k / (1.0 - beta + beta * k)));
537            let l = feed.component_mul(&k.map(|k| (1.0 - beta) / (1.0 - beta + beta * k)));
538            return Ok((beta, [v, l]));
539        }
540    }
541
542    // update VLE
543    let v = feed.component_mul(&k.map(|k| beta * k / (1.0 - beta + beta * k)));
544    let l = feed.component_mul(&k.map(|k| (1.0 - beta) / (1.0 - beta + beta * k)));
545
546    Ok((beta, [v, l]))
547}