Skip to main content

feos_core/phase_equilibria/
px_flashes.rs

1#![expect(clippy::toplevel_ref_arg)]
2use super::PhaseEquilibrium;
3use crate::errors::FeosResult;
4use crate::state::State;
5use crate::{Composition, FeosError, ReferenceSystem, SolverOptions, Total, Verbosity};
6use nalgebra::allocator::Allocator;
7use nalgebra::{DefaultAllocator, Dim, DimAdd, OVector, U1, U2, U3, stack, vector};
8use num_dual::linalg::LU;
9use num_dual::{
10    Dual, Dual64, DualNum, DualStruct, Gradients, first_derivative, implicit_derivative_sp, partial,
11};
12use quantity::{Density, MolarEnergy, MolarEntropy, Pressure, Quantity, SIUnit, Temperature};
13
14const MAX_ITER_PX: usize = 20;
15const TOL_PX: f64 = 1e-11;
16
17type PXVars<N> = <N as DimAdd<U3>>::Output;
18type TPVars<N> = <N as DimAdd<U2>>::Output;
19
20impl<E: Total<N, D>, N: Gradients + DimAdd<U2> + DimAdd<U3>, D: DualNum<f64> + Copy>
21    PhaseEquilibrium<E, 2, N, D>
22where
23    DefaultAllocator: Allocator<N>
24        + Allocator<N, N>
25        + Allocator<PXVars<N>>
26        + Allocator<U1, PXVars<N>>
27        + Allocator<PXVars<N>, PXVars<N>>
28        + Allocator<TPVars<N>>
29        + Allocator<U1, TPVars<N>>
30        + Allocator<TPVars<N>, TPVars<N>>,
31    PXVars<N>: Gradients,
32    TPVars<N>: Gradients,
33{
34    /// Perform a ph-flash calculation. An initial temperature is required
35    /// and the system needs to be in the two-phase region at that initial
36    /// temperature.
37    ///
38    /// based on Michelsen's work [State function based flash specifications](https://doi.org/10.1016/S0378-3812(99)00092-8)
39    pub fn ph_flash<X: Composition<D, N>>(
40        eos: &E,
41        pressure: Pressure<D>,
42        molar_enthalpy: MolarEnergy<D>,
43        feed: X,
44        initial_temperature: Temperature,
45        options: SolverOptions,
46    ) -> FeosResult<Self> {
47        PhaseEquilibrium::px_flash(
48            eos,
49            pressure,
50            molar_enthalpy,
51            feed,
52            initial_temperature,
53            options,
54        )
55    }
56
57    /// Perform a ps-flash calculation. An initial temperature is required
58    /// and the system needs to be in the two-phase region at that initial
59    /// temperature.
60    ///
61    /// based on Michelsen's work [State function based flash specifications](https://doi.org/10.1016/S0378-3812(99)00092-8)
62    pub fn ps_flash<X: Composition<D, N>>(
63        eos: &E,
64        pressure: Pressure<D>,
65        molar_entropy: MolarEntropy<D>,
66        feed: X,
67        initial_temperature: Temperature,
68        options: SolverOptions,
69    ) -> FeosResult<Self> {
70        PhaseEquilibrium::px_flash(
71            eos,
72            pressure,
73            molar_entropy,
74            feed,
75            initial_temperature,
76            options,
77        )
78    }
79
80    // Generic implementation of ph and ps flashes.
81    fn px_flash<X: Composition<D, N>, U: PXFlash>(
82        eos: &E,
83        pressure: Pressure<D>,
84        specification: Quantity<D, U>,
85        feed: X,
86        initial_temperature: Temperature,
87        options: SolverOptions,
88    ) -> FeosResult<Self>
89    where
90        Quantity<D, U>: ReferenceSystem<Inner = D>,
91        Quantity<Dual64, U>: ReferenceSystem<Inner = Dual64>,
92    {
93        let (max_iter, tol, verbosity) = options.unwrap_or(MAX_ITER_PX, TOL_PX);
94        let (molefracs, total_moles) = feed.into_molefracs(eos)?;
95
96        // initialize with a tp flash
97        let eos_f64 = eos.re_total();
98        let vle = PhaseEquilibrium::tp_flash(
99            &eos_f64,
100            initial_temperature,
101            pressure.re(),
102            molefracs.map(|x| x.re()),
103            None,
104            Default::default(),
105            None,
106        )?;
107
108        // extract specifications
109        let p = pressure.into_reduced().re();
110        let hs = specification.into_reduced().re();
111        let z = molefracs.map(|x| x.re());
112        let specs = (p, hs, z.clone());
113
114        // extract variables
115        let t = initial_temperature.into_reduced();
116        let beta = vle.vapor_phase_fraction();
117        let rho_v = vle.vapor().density.into_reduced();
118        let rho_l = vle.liquid().partial_density().into_reduced();
119        let mut vars = stack![rho_l; vector![t, beta, rho_v]];
120        let mut old_res = None;
121
122        log_iter!(
123            verbosity,
124            " iter |  method  | temperature |    residual    |  phase I mole fractions  |  phase II mole fractions  "
125        );
126        log_iter!(verbosity, "{:-<102}", "");
127        log_iter!(
128            verbosity,
129            " {:4} |          | {:9.5} |                | {:10.8?} | {:10.8?}",
130            0,
131            Temperature::from_reduced(t),
132            (&rho_l / rho_l.sum() + (&z - &rho_l / rho_l.sum()) / beta).as_slice(),
133            (&rho_l / rho_l.sum()).as_slice(),
134        );
135
136        // iterate
137        for k in 0..max_iter {
138            // always try a Newton step first
139            let (grad, new_vars) = U::newton_step(&eos_f64, &vars, &specs)?;
140            let new_res = grad.norm();
141            let (method, res) = if let Some(r) = old_res
142                && r < new_res
143            {
144                // if the residual is not reduced, reject the step and do a tp-flash instead
145                vars = U::tp_step(&eos_f64, &vars, &specs)?;
146                ("Tp-flash", None)
147            } else {
148                vars = new_vars;
149                ("Newton", Some(new_res))
150            };
151
152            if let Verbosity::Iter = verbosity {
153                let (t, _, _, _, x, y) = unpack_variables(&z, &vars);
154                log_iter!(
155                    verbosity,
156                    " {:4} | {:^8} | {:9.5} | {} | {:10.8?} | {:10.8?}",
157                    k + 1,
158                    method,
159                    Temperature::from_reduced(t),
160                    res.map_or(String::from("              "), |r| format!("{r:14.8e}")),
161                    y.as_slice(),
162                    x.as_slice(),
163                );
164            }
165
166            if let Some(res) = res
167                && res < tol
168            {
169                log_result!(
170                    verbosity,
171                    "px flash: calculation converged in {} step(s)\n",
172                    k + 1
173                );
174
175                // implicit differentiation
176                let specs = (
177                    pressure.into_reduced(),
178                    specification.into_reduced(),
179                    molefracs.clone(),
180                );
181                let vars = implicit_derivative_sp(
182                    |variables, specifications| {
183                        U::state_function(&eos.lift_total(), variables, specifications)
184                    },
185                    vars,
186                    &specs,
187                );
188                let (t, beta, rho_l, rho_v, x, y) = unpack_variables(&molefracs, &vars);
189
190                // store results in PhaseEquilibrium
191                let liquid = State::new(
192                    eos,
193                    Temperature::from_reduced(t),
194                    Density::from_reduced(rho_l),
195                    x,
196                )?;
197                let vapor = State::new(
198                    eos,
199                    Temperature::from_reduced(t),
200                    Density::from_reduced(rho_v),
201                    y,
202                )?;
203                return Ok(PhaseEquilibrium::with_vapor_phase_fraction(
204                    vapor,
205                    liquid,
206                    beta,
207                    total_moles,
208                ));
209            }
210            old_res = res;
211        }
212        Err(FeosError::NotConverged("px flash".to_owned()))
213    }
214}
215
216fn unpack_variables<D: DualNum<f64> + Copy, N: Dim + DimAdd<U3>>(
217    molefracs: &OVector<D, N>,
218    variables: &OVector<D, PXVars<N>>,
219) -> (D, D, D, D, OVector<D, N>, OVector<D, N>)
220where
221    DefaultAllocator: Allocator<N> + Allocator<PXVars<N>>,
222{
223    let n = molefracs.len();
224    let rho_i_l = variables.rows_generic(0, N::from_usize(n)).clone_owned();
225    let [[t, beta, rho_v]] = variables.rows_generic(n, U3).clone_owned().data.0;
226    let rho_l = rho_i_l.sum();
227    let x = rho_i_l / rho_l;
228    let y = &x + (molefracs - &x) / beta;
229    (t, beta, rho_l, rho_v, x, y)
230}
231
232fn unpack_tp_variables<D: DualNum<f64> + Copy, N: Dim + DimAdd<U2>>(
233    molefracs: &OVector<D, N>,
234    variables: &OVector<D, TPVars<N>>,
235) -> (D, D, D, OVector<D, N>, OVector<D, N>)
236where
237    DefaultAllocator: Allocator<N> + Allocator<TPVars<N>>,
238{
239    let n = molefracs.len();
240    let rho_i_l = variables.rows_generic(0, N::from_usize(n)).clone_owned();
241    let [[beta, rho_v]] = variables.rows_generic(n, U2).clone_owned().data.0;
242    let rho_l = rho_i_l.sum();
243    let x = rho_i_l / rho_l;
244    let y = &x + (molefracs - &x) / beta;
245    (beta, rho_l, rho_v, x, y)
246}
247
248trait PXFlash: Sized + Copy {
249    // potential function for which the flash solution is a saddle point.
250    fn state_function<E: Total<N, D>, N: Dim + DimAdd<U3>, D: DualNum<f64> + Copy>(
251        eos: &E,
252        variables: OVector<D, PXVars<N>>,
253        args: &(D, D, OVector<D, N>),
254    ) -> D
255    where
256        DefaultAllocator: Allocator<N> + Allocator<PXVars<N>>;
257
258    fn evaluate_property<E: Total<N, D>, N: Gradients, D: DualNum<f64> + Copy>(
259        vle: &PhaseEquilibrium<E, 2, N, D>,
260    ) -> Quantity<D, Self>
261    where
262        DefaultAllocator: Allocator<N>;
263
264    // the potential function for a tp-flash specification (Q = A + V*p_spec)
265    fn tp_state_function<E: Total<N, D>, N: Dim + DimAdd<U2>, D: DualNum<f64> + Copy>(
266        eos: &E,
267        variables: OVector<D, TPVars<N>>,
268        &(t, p, ref z): &(D, D, OVector<D, N>),
269    ) -> D
270    where
271        DefaultAllocator: Allocator<N> + Allocator<TPVars<N>>,
272    {
273        let (beta, rho_l, rho_v, x, y) = unpack_tp_variables(z, &variables);
274        let potential = |molefracs, rho: D, t| {
275            let v = rho.recip();
276            let a_res = eos.residual_helmholtz_energy(t, v, &molefracs);
277            let a_ig = eos.ideal_gas_molar_helmholtz_energy(t, v, &molefracs);
278            a_res + a_ig + v * p
279        };
280        potential(y, rho_v, t) * beta + potential(x, rho_l, t) * (-beta + 1.0)
281    }
282
283    // An undamped Newton step for the gradients of the potential function.
284    // Because the ps and ph flashes are saddle points rather then extrema,
285    // the value of the potential can not be used as convergence criterion.
286    #[expect(clippy::type_complexity)]
287    fn newton_step<E: Total<N, D>, N: Dim + DimAdd<U3>, D: DualNum<f64> + Copy>(
288        eos: &E,
289        variables: &OVector<D, PXVars<N>>,
290        specifications: &(D, D, OVector<D, N>),
291    ) -> FeosResult<(OVector<D, PXVars<N>>, OVector<D, PXVars<N>>)>
292    where
293        DefaultAllocator: Allocator<N> + Allocator<PXVars<N>> + Allocator<PXVars<N>, PXVars<N>>,
294        PXVars<N>: Gradients,
295    {
296        let (_, grad, hess) = PXVars::<N>::hessian(
297            |variables, specifications| {
298                Self::state_function(&eos.lift_total(), variables, specifications)
299            },
300            variables,
301            specifications,
302        );
303        let dx = LU::new(hess)?.solve(&grad);
304        Ok((grad, variables - &dx))
305    }
306
307    // A much slower but more robust step that calculates the implicit
308    // derivative of the temperature only (which is well behaved
309    // according to Michelsen) and then calculates all other variables
310    // from a tp-flash.
311    fn tp_step<E: Total<N, f64>, N: Gradients + DimAdd<U2> + DimAdd<U3>>(
312        eos: &E,
313        variables: &OVector<f64, PXVars<N>>,
314        &(p, hs_spec, ref z): &(f64, f64, OVector<f64, N>),
315    ) -> FeosResult<OVector<f64, PXVars<N>>>
316    where
317        Quantity<Dual64, Self>: ReferenceSystem<Inner = Dual64>,
318        DefaultAllocator: Allocator<N>
319            + Allocator<N, N>
320            + Allocator<PXVars<N>>
321            + Allocator<U1, TPVars<N>>
322            + Allocator<TPVars<N>>
323            + Allocator<TPVars<N>, TPVars<N>>,
324        TPVars<N>: Gradients,
325    {
326        let (mut t, beta, rho_l, rho_v, x, y) = unpack_variables(z, variables);
327        let rho_i_l = rho_l * x;
328        let (hs, dhs) = first_derivative(
329            partial(
330                |t: Dual<_, _>, args: &(_, OVector<_, _>)| {
331                    let &(p, ref z) = args;
332                    let args = (t, p, z.clone_owned());
333
334                    // implicit differentiation of the tp stationarity condition
335                    // to obtain the derivative of the other variables w.r.t. t
336                    let tp_vars = implicit_derivative_sp(
337                        |variables, args| {
338                            Self::tp_state_function(&eos.lift_total().lift_total(), variables, args)
339                        },
340                        stack![rho_i_l; vector![beta, rho_v]],
341                        &args,
342                    );
343                    let (beta, rho_l, rho_v, x, y) = unpack_tp_variables(z, &tp_vars);
344
345                    // Evaluation of the enthalpy/entropy including the derivatives.
346                    let liquid = State::new(
347                        &eos.lift_total(),
348                        Temperature::from_reduced(t),
349                        Density::from_reduced(rho_l),
350                        x,
351                    )?;
352                    let vapor = State::new(
353                        &eos.lift_total(),
354                        Temperature::from_reduced(t),
355                        Density::from_reduced(rho_v),
356                        y,
357                    )?;
358                    Ok::<_, FeosError>(
359                        Self::evaluate_property(&PhaseEquilibrium::with_vapor_phase_fraction(
360                            vapor, liquid, beta, None,
361                        ))
362                        .into_reduced(),
363                    )
364                },
365                &(p, z.clone_owned()),
366            ),
367            t,
368        )?;
369
370        // Newton step for the temperature
371        t -= (hs - hs_spec) / dhs;
372
373        // pack variables into PhaseEquilibrium for initial values
374        let liquid = State::new_density(
375            eos,
376            Temperature::from_reduced(t),
377            Density::from_reduced(rho_i_l),
378        )?;
379        let vapor = State::new(
380            eos,
381            Temperature::from_reduced(t),
382            Density::from_reduced(rho_v),
383            y,
384        )?;
385        let vle = PhaseEquilibrium::with_vapor_phase_fraction(vapor, liquid, beta, None);
386
387        // tp-flash for all other variables
388        let vle = PhaseEquilibrium::tp_flash(
389            eos,
390            Temperature::from_reduced(t),
391            Pressure::from_reduced(p),
392            z,
393            Some(&vle),
394            Default::default(),
395            None,
396        )?;
397        let beta = vle.vapor_phase_fraction();
398        let rho_v = vle.vapor().density.into_reduced();
399        let rho_l = vle.liquid().partial_density().into_reduced();
400        Ok(stack![rho_l; vector![t, beta, rho_v]])
401    }
402}
403
404impl PXFlash for SIUnit<-2, 2, 1, 0, 0, -1, 0> {
405    // the potential function for a ph-flash specification (Q = (A + V*p_spec - H_spec) / T)
406    fn state_function<E: Total<N, D>, N: Dim + DimAdd<U3>, D: DualNum<f64> + Copy>(
407        eos: &E,
408        variables: OVector<D, PXVars<N>>,
409        &(p, h, ref z): &(D, D, OVector<D, N>),
410    ) -> D
411    where
412        DefaultAllocator: Allocator<N> + Allocator<PXVars<N>>,
413    {
414        let (t, beta, rho_l, rho_v, x, y) = unpack_variables(z, &variables);
415        let potential = |molefracs, rho: D, t| {
416            let v = rho.recip();
417            let a_res = eos.residual_helmholtz_energy(t, v, &molefracs);
418            let a_ig = eos.ideal_gas_molar_helmholtz_energy(t, v, &molefracs);
419            (a_res + a_ig + v * p - h) / t
420        };
421        potential(y, rho_v, t) * beta + potential(x, rho_l, t) * (-beta + 1.0)
422    }
423
424    fn evaluate_property<E: Total<N, D>, N: Gradients, D: DualNum<f64> + Copy>(
425        vle: &PhaseEquilibrium<E, 2, N, D>,
426    ) -> Quantity<D, Self>
427    where
428        DefaultAllocator: Allocator<N>,
429    {
430        vle.molar_enthalpy()
431    }
432}
433
434impl PXFlash for SIUnit<-2, 2, 1, 0, -1, -1, 0> {
435    // the potential function for a ps-flash specification (Q = A + T*S_spec + V*p_spec)
436    fn state_function<E: Total<N, D>, N: Dim + DimAdd<U3>, D: DualNum<f64> + Copy>(
437        eos: &E,
438        variables: OVector<D, PXVars<N>>,
439        &(p, s, ref z): &(D, D, OVector<D, N>),
440    ) -> D
441    where
442        DefaultAllocator: Allocator<N> + Allocator<PXVars<N>>,
443    {
444        let (t, beta, rho_l, rho_v, x, y) = unpack_variables(z, &variables);
445        let potential = |molefracs, rho: D, t| {
446            let v = rho.recip();
447            let a_res = eos.residual_helmholtz_energy(t, v, &molefracs);
448            let a_ig = eos.ideal_gas_molar_helmholtz_energy(t, v, &molefracs);
449            // Division by t.re() is done to ensure that the state function has the same
450            // units (and in conclusion same order of magnitude) as the ph state function.
451            // This allows using the same toelrances for both methods.
452            (a_res + a_ig + t * s + v * p) / t.re()
453        };
454        potential(y, rho_v, t) * beta + potential(x, rho_l, t) * (-beta + 1.0)
455    }
456
457    fn evaluate_property<E: Total<N, D>, N: Gradients, D: DualNum<f64> + Copy>(
458        vle: &PhaseEquilibrium<E, 2, N, D>,
459    ) -> Quantity<D, Self>
460    where
461        DefaultAllocator: Allocator<N>,
462    {
463        vle.molar_entropy()
464    }
465}