Skip to main content

feos_core/phase_equilibria/
phase_diagram_binary.rs

1use super::bubble_dew::TemperatureOrPressure;
2use super::{PhaseDiagram, PhaseEquilibrium};
3use crate::errors::{FeosError, FeosResult};
4use crate::state::{Contributions, DensityInitialization::Vapor, State};
5use crate::{ReferenceSystem, Residual, SolverOptions, Subset};
6use nalgebra::{DVector, dvector, matrix, stack, vector};
7use ndarray::{Array1, s};
8use num_dual::linalg::LU;
9use num_dual::{Dual64, DualNum, first_derivative, partial, partial2};
10use quantity::{Density, Moles, Pressure, RGAS, Temperature};
11
12const DEFAULT_POINTS: usize = 51;
13
14impl<E: Residual + Subset> PhaseDiagram<E, 2> {
15    /// Create a new binary phase diagram exhibiting a
16    /// vapor/liquid equilibrium.
17    ///
18    /// If a heteroazeotrope occurs and the composition of the liquid
19    /// phases are known, they can be passed as `x_lle` to avoid
20    /// the calculation of unstable branches.
21    pub fn binary_vle<TP: TemperatureOrPressure>(
22        eos: &E,
23        temperature_or_pressure: TP,
24        npoints: Option<usize>,
25        x_lle: Option<(f64, f64)>,
26        bubble_dew_options: (SolverOptions, SolverOptions),
27    ) -> FeosResult<Self> {
28        let npoints = npoints.unwrap_or(DEFAULT_POINTS);
29
30        // calculate boiling temperature/vapor pressure of pure components
31        let vle_sat = PhaseEquilibrium::vle_pure_comps(eos, temperature_or_pressure);
32        let vle_sat = [vle_sat[1].clone(), vle_sat[0].clone()];
33
34        // Only calculate up to specified compositions
35        if let Some(x_lle) = x_lle {
36            let [states1, states2] = Self::calculate_vlle(
37                eos,
38                temperature_or_pressure,
39                npoints,
40                x_lle,
41                vle_sat,
42                bubble_dew_options,
43            )?;
44
45            let states = states1
46                .into_iter()
47                .chain(states2.into_iter().rev())
48                .collect();
49            return Ok(Self { states });
50        }
51
52        // use dew point when calculating a supercritical tx diagram
53        let bubble = temperature_or_pressure.temperature().is_some();
54
55        // look for supercritical components
56        let (x_lim, vle_lim, bubble) = match vle_sat {
57            [None, None] => return Err(FeosError::SuperCritical),
58            [Some(vle2), None] => {
59                let cp = State::critical_point_binary(
60                    eos,
61                    temperature_or_pressure,
62                    None,
63                    None,
64                    None,
65                    SolverOptions::default(),
66                )?;
67                let x_max = cp.molefracs[0];
68                let cp_vle = PhaseEquilibrium::single_phase(cp);
69                ([0.0, x_max], (vle2, cp_vle), bubble)
70            }
71            [None, Some(vle1)] => {
72                let cp = State::critical_point_binary(
73                    eos,
74                    temperature_or_pressure,
75                    None,
76                    None,
77                    None,
78                    SolverOptions::default(),
79                )?;
80                let x_min = cp.molefracs[0];
81                let cp_vle = PhaseEquilibrium::single_phase(cp);
82                ([1.0, x_min], (vle1, cp_vle), bubble)
83            }
84            [Some(vle2), Some(vle1)] => ([0.0, 1.0], (vle2, vle1), true),
85        };
86
87        let mut states = iterate_vle(
88            eos,
89            temperature_or_pressure,
90            &x_lim,
91            vle_lim.0,
92            Some(vle_lim.1),
93            npoints,
94            bubble,
95            bubble_dew_options,
96        );
97        if !bubble {
98            states = states.into_iter().rev().collect();
99        }
100        let states = check_for_vlle(temperature_or_pressure, states, npoints, bubble_dew_options);
101        Ok(Self { states })
102    }
103
104    fn calculate_vlle<TP: TemperatureOrPressure>(
105        eos: &E,
106        tp: TP,
107        npoints: usize,
108        x_lle: (f64, f64),
109        vle_sat: [Option<PhaseEquilibrium<E, 2>>; 2],
110        bubble_dew_options: (SolverOptions, SolverOptions),
111    ) -> FeosResult<[Vec<PhaseEquilibrium<E, 2>>; 2]> {
112        match vle_sat {
113            [Some(vle2), Some(vle1)] => {
114                let states1 = iterate_vle(
115                    eos,
116                    tp,
117                    &[0.0, x_lle.0],
118                    vle2,
119                    None,
120                    npoints / 2,
121                    true,
122                    bubble_dew_options,
123                );
124                let states2 = iterate_vle(
125                    eos,
126                    tp,
127                    &[1.0, x_lle.1],
128                    vle1,
129                    None,
130                    npoints - npoints / 2,
131                    true,
132                    bubble_dew_options,
133                );
134                Ok([states1, states2])
135            }
136            _ => Err(FeosError::SuperCritical),
137        }
138    }
139
140    /// Create a new phase diagram using Tp flash calculations.
141    ///
142    /// The usual use case for this function is the calculation of
143    /// liquid-liquid phase diagrams, but it can be used for vapor-
144    /// liquid diagrams as well, as long as the feed composition is
145    /// in a two phase region.
146    pub fn lle<TP: TemperatureOrPressure>(
147        eos: &E,
148        temperature_or_pressure: TP,
149        feed: &Moles<DVector<f64>>,
150        min_tp: TP::Other,
151        max_tp: TP::Other,
152        npoints: Option<usize>,
153    ) -> FeosResult<Self> {
154        let npoints = npoints.unwrap_or(DEFAULT_POINTS);
155        let mut states = Vec::with_capacity(npoints);
156
157        let (t_vec, p_vec) = temperature_or_pressure.linspace(min_tp, max_tp, npoints);
158        let mut vle = None;
159        for i in 0..npoints {
160            let (t, p) = (t_vec.get(i), p_vec.get(i));
161            vle = PhaseEquilibrium::tp_flash(
162                eos,
163                t,
164                p,
165                feed,
166                vle.as_ref(),
167                SolverOptions::default(),
168                None,
169            )
170            .ok();
171            if let Some(vle) = vle.as_ref() {
172                states.push(vle.clone());
173            }
174        }
175        Ok(Self { states })
176    }
177}
178
179#[expect(clippy::too_many_arguments)]
180fn iterate_vle<E: Residual + Subset, TP: TemperatureOrPressure>(
181    eos: &E,
182    tp: TP,
183    x_lim: &[f64],
184    vle_0: PhaseEquilibrium<E, 2>,
185    vle_1: Option<PhaseEquilibrium<E, 2>>,
186    npoints: usize,
187    bubble: bool,
188    bubble_dew_options: (SolverOptions, SolverOptions),
189) -> Vec<PhaseEquilibrium<E, 2>> {
190    let mut vle_vec = Vec::with_capacity(npoints);
191
192    let x = Array1::linspace(x_lim[0], x_lim[1], npoints);
193    let x = if vle_1.is_some() {
194        x.slice(s![1..-1])
195    } else {
196        x.slice(s![1..])
197    };
198
199    let tp_0 = Some(TP::from_state(vle_0.vapor()));
200    let mut tp_old = tp_0;
201    let mut y_old = None;
202    vle_vec.push(vle_0);
203    for xi in x {
204        let vle = PhaseEquilibrium::bubble_dew_point(
205            eos,
206            tp,
207            dvector![*xi, 1.0 - xi],
208            tp_old,
209            y_old.as_ref(),
210            bubble,
211            bubble_dew_options,
212        );
213
214        if let Ok(vle) = vle {
215            y_old = Some(if bubble {
216                vle.vapor().molefracs.clone()
217            } else {
218                vle.liquid().molefracs.clone()
219            });
220            tp_old = Some(TP::from_state(vle.vapor()));
221            vle_vec.push(vle.clone());
222        } else {
223            y_old = None;
224            tp_old = tp_0;
225        }
226    }
227    if let Some(vle_1) = vle_1 {
228        vle_vec.push(vle_1);
229    }
230
231    vle_vec
232}
233
234fn check_for_vlle<E: Residual + Subset, TP: TemperatureOrPressure>(
235    tp: TP,
236    states: Vec<PhaseEquilibrium<E, 2>>,
237    npoints: usize,
238    bubble_dew_options: (SolverOptions, SolverOptions),
239) -> Vec<PhaseEquilibrium<E, 2>> {
240    let n = states.len();
241    let p: Vec<_> = states
242        .iter()
243        .map(|s| s.vapor().pressure(Contributions::Total))
244        .collect();
245    let t: Vec<_> = states.iter().map(|s| s.vapor().temperature).collect();
246    let x: Vec<_> = states.iter().map(|s| s.liquid().molefracs[0]).collect();
247    let y: Vec<_> = states.iter().map(|s| s.vapor().molefracs[0]).collect();
248
249    // Determine if the dew line intersects with itself
250    if let Some(t) = tp.temperature()
251        && p[1] > p[0]
252        && p[n - 2] > p[n - 1]
253    {
254        let [mut i, mut j] = [0, n - 1];
255        while i != j {
256            if p[i] > p[j] {
257                j -= 1;
258            } else {
259                i += 1
260            }
261            if y[j] < y[i] {
262                // intersection found!
263                let (xj, yj, pj) = if j >= n - 2 {
264                    // Use Henry constant of component 2
265                    let k_inf = (states[n - 1].liquid().ln_phi() - states[n - 1].vapor().ln_phi())
266                        .map(f64::exp)[1];
267                    (
268                        [1.0, 1.0 - 1.0 / k_inf],
269                        [1.0, 0.0],
270                        [p[n - 1], p[n - 1] * (2.0 - 1.0 / k_inf)],
271                    )
272                } else {
273                    // or interpolate linearly
274                    ([x[j + 1], x[j]], [y[j + 1], y[j]], [p[j + 1], p[j]])
275                };
276                let (xi, yi, pi) = if i == 1 {
277                    // Use Henry constant of component 1
278                    let k_inf =
279                        (states[0].liquid().ln_phi() - states[0].vapor().ln_phi()).map(f64::exp)[0];
280                    (
281                        [0.0, 1.0 / k_inf],
282                        [0.0, 1.0],
283                        [p[0], p[0] * (2.0 - 1.0 / k_inf)],
284                    )
285                } else {
286                    // or interpolate linearly
287                    ([x[i - 1], x[i]], [y[i - 1], y[i]], [p[i - 1], p[i]])
288                };
289                // calculate intersection
290                let a = matrix![yi[1] - yi[0], yj[0] - yj[1];
291                                (pi[1] - pi[0]).into_reduced(), (pj[0] - pj[1]).into_reduced()];
292                let b = vector![yj[0] - yi[0], (pj[0] - pi[0]).into_reduced()];
293                let [[r, s]] = LU::new(a).unwrap().solve(&b).data.0;
294                let (xi, xj, p) = (
295                    xi[0] + r * (xi[1] - xi[0]),
296                    xj[0] + s * (xj[1] - xj[0]),
297                    pi[0] + r * (pi[1] - pi[0]),
298                );
299                let Ok(vlle) = PhaseEquilibrium::heteroazeotrope(
300                    &states[0].liquid().eos,
301                    t,
302                    (xi, xj),
303                    Some(p),
304                    Default::default(),
305                    bubble_dew_options,
306                ) else {
307                    return states;
308                };
309                let x_hetero = (vlle.liquid1().molefracs[0], vlle.liquid2().molefracs[0]);
310                return PhaseDiagram::binary_vle(
311                    &states[0].liquid().eos,
312                    tp,
313                    Some(npoints),
314                    Some(x_hetero),
315                    bubble_dew_options,
316                )
317                .map_or(states, |dia| dia.states);
318            }
319        }
320    } else if let Some(p) = tp.pressure()
321        && t[1] < t[0]
322        && t[n - 2] < t[n - 1]
323    {
324        let [mut i, mut j] = [0, n - 1];
325        while i != j {
326            if t[i] < t[j] {
327                j -= 1;
328            } else {
329                i += 1
330            }
331            if y[j] < y[i] {
332                // intersection found!
333                let (xj, yj, tj) = if j == n - 2 {
334                    // Use Henry constant of component 2
335                    let vle = &states[n - 1];
336                    let k_inf = (vle.liquid().ln_phi() - vle.vapor().ln_phi()).map(f64::exp)[1];
337                    let dh = vle.vapor().residual_molar_enthalpy()
338                        - vle.liquid().residual_molar_enthalpy();
339                    let dv = 1.0 / vle.vapor().density - 1.0 / vle.liquid().density;
340                    let pdv_dh = (p * dv).convert_into(dh);
341                    (
342                        [1.0, 1.0 - 1.0 / k_inf],
343                        [1.0, 0.0],
344                        [t[n - 1], t[n - 1] * (1.0 - (k_inf - 1.0) / k_inf * pdv_dh)],
345                    )
346                } else {
347                    // or interpolate linearly
348                    ([x[j + 1], x[j]], [y[j + 1], y[j]], [t[j + 1], t[j]])
349                };
350                let (xi, yi, ti) = if i == 1 {
351                    // Use Henry constant of component 1
352                    let vle = &states[0];
353                    let k_inf = (vle.liquid().ln_phi() - vle.vapor().ln_phi()).map(f64::exp)[0];
354                    let dh = vle.vapor().residual_molar_enthalpy()
355                        - vle.liquid().residual_molar_enthalpy();
356                    let dv = 1.0 / vle.vapor().density - 1.0 / vle.liquid().density;
357                    let pdv_dh = (p * dv).convert_into(dh);
358                    (
359                        [0.0, 1.0 / k_inf],
360                        [0.0, 1.0],
361                        [t[0], t[0] * (1.0 - (k_inf - 1.0) / k_inf * pdv_dh)],
362                    )
363                } else {
364                    // or interpolate linearly
365                    ([x[i - 1], x[i]], [y[i - 1], y[i]], [t[i - 1], t[i]])
366                };
367                // calculate intersection
368                let a = matrix![yi[1] - yi[0], yj[0] - yj[1];
369                                (ti[1] - ti[0]).into_reduced(), (tj[0] - tj[1]).into_reduced()];
370                let b = vector![yj[0] - yi[0], (tj[0] - ti[0]).into_reduced()];
371                let [[r, s]] = LU::new(a).unwrap().solve(&b).data.0;
372                let (xi, xj, t) = (
373                    xi[0] + r * (xi[1] - xi[0]),
374                    xj[0] + s * (xj[1] - xj[0]),
375                    ti[0] + r * (ti[1] - ti[0]),
376                );
377                let Ok(vlle) = PhaseEquilibrium::heteroazeotrope(
378                    &states[0].liquid().eos,
379                    p,
380                    (xi, xj),
381                    Some(t),
382                    Default::default(),
383                    bubble_dew_options,
384                ) else {
385                    return states;
386                };
387                let x_hetero = (vlle.liquid1().molefracs[0], vlle.liquid2().molefracs[0]);
388                return PhaseDiagram::binary_vle(
389                    &states[0].liquid().eos,
390                    tp,
391                    Some(npoints),
392                    Some(x_hetero),
393                    bubble_dew_options,
394                )
395                .map_or(states, |dia| dia.states);
396            }
397        }
398    }
399    states
400}
401
402/// Phase diagram (Txy or pxy) for a system with heteroazeotropic phase behavior.
403pub struct PhaseDiagramHetero<E> {
404    pub vle1: PhaseDiagram<E, 2>,
405    pub vle2: PhaseDiagram<E, 2>,
406    pub lle: Option<PhaseDiagram<E, 2>>,
407}
408
409impl<E: Residual + Subset> PhaseDiagram<E, 2> {
410    /// Create a new binary phase diagram exhibiting a
411    /// vapor/liquid/liquid equilibrium.
412    ///
413    /// The `x_lle` parameter is used as initial values for the calculation
414    /// of the heteroazeotrope.
415    #[expect(clippy::too_many_arguments)]
416    pub fn binary_vlle<TP: TemperatureOrPressure>(
417        eos: &E,
418        temperature_or_pressure: TP,
419        x_lle: (f64, f64),
420        tp_lim_lle: Option<TP::Other>,
421        tp_init_vlle: Option<TP::Other>,
422        npoints_vle: Option<usize>,
423        npoints_lle: Option<usize>,
424        bubble_dew_options: (SolverOptions, SolverOptions),
425    ) -> FeosResult<PhaseDiagramHetero<E>> {
426        let npoints_vle = npoints_vle.unwrap_or(DEFAULT_POINTS);
427
428        // calculate pure components
429        let vle_sat = PhaseEquilibrium::vle_pure_comps(eos, temperature_or_pressure);
430        let vle_sat = [vle_sat[1].clone(), vle_sat[0].clone()];
431
432        // calculate heteroazeotrope
433        let vlle = PhaseEquilibrium::heteroazeotrope(
434            eos,
435            temperature_or_pressure,
436            x_lle,
437            tp_init_vlle,
438            SolverOptions::default(),
439            bubble_dew_options,
440        )?;
441        let x_hetero = (vlle.liquid1().molefracs[0], vlle.liquid2().molefracs[0]);
442
443        // calculate vapor liquid equilibria
444        let [dia1, dia2] = PhaseDiagram::calculate_vlle(
445            eos,
446            temperature_or_pressure,
447            npoints_vle,
448            x_hetero,
449            vle_sat,
450            bubble_dew_options,
451        )?;
452
453        // calculate liquid liquid equilibrium
454        let lle = tp_lim_lle
455            .map(|tp_lim| {
456                let tp_hetero = TP::from_state(vlle.vapor());
457                let x_feed = 0.5 * (x_hetero.0 + x_hetero.1);
458                let feed = Moles::from_reduced(dvector![x_feed, 1.0 - x_feed]);
459                PhaseDiagram::lle(
460                    eos,
461                    temperature_or_pressure,
462                    &feed,
463                    tp_lim,
464                    tp_hetero,
465                    npoints_lle,
466                )
467            })
468            .transpose()?;
469
470        Ok(PhaseDiagramHetero {
471            vle1: PhaseDiagram::new(dia1),
472            vle2: PhaseDiagram::new(dia2),
473            lle,
474        })
475    }
476}
477
478impl<E: Clone> PhaseDiagramHetero<E> {
479    pub fn vle(&self) -> PhaseDiagram<E, 2> {
480        PhaseDiagram::new(
481            self.vle1
482                .states
483                .iter()
484                .chain(self.vle2.states.iter().rev())
485                .cloned()
486                .collect(),
487        )
488    }
489}
490
491const MAX_ITER_HETERO: usize = 50;
492const TOL_HETERO: f64 = 1e-8;
493
494/// # Heteroazeotropes
495impl<E: Residual> PhaseEquilibrium<E, 3> {
496    /// Calculate a heteroazeotrope (three phase equilbrium) for a binary
497    /// system and given temperature or pressure.
498    pub fn heteroazeotrope<TP: TemperatureOrPressure>(
499        eos: &E,
500        temperature_or_pressure: TP,
501        x_init: (f64, f64),
502        tp_init: Option<TP::Other>,
503        options: SolverOptions,
504        bubble_dew_options: (SolverOptions, SolverOptions),
505    ) -> FeosResult<Self> {
506        let (temperature, pressure, iterate_p) =
507            temperature_or_pressure.temperature_pressure(tp_init);
508        if iterate_p {
509            PhaseEquilibrium::heteroazeotrope_t(
510                eos,
511                temperature.unwrap(),
512                x_init,
513                pressure,
514                options,
515                bubble_dew_options,
516            )
517        } else {
518            PhaseEquilibrium::heteroazeotrope_p(
519                eos,
520                pressure.unwrap(),
521                x_init,
522                temperature,
523                options,
524                bubble_dew_options,
525            )
526        }
527    }
528
529    /// Calculate a heteroazeotrope (three phase equilbrium) for a binary
530    /// system and given temperature.
531    #[expect(clippy::toplevel_ref_arg)]
532    fn heteroazeotrope_t(
533        eos: &E,
534        temperature: Temperature,
535        x_init: (f64, f64),
536        p_init: Option<Pressure>,
537        options: SolverOptions,
538        bubble_dew_options: (SolverOptions, SolverOptions),
539    ) -> FeosResult<Self> {
540        // calculate initial values using bubble point
541        let x1 = dvector![x_init.0, 1.0 - x_init.0];
542        let x2 = dvector![x_init.1, 1.0 - x_init.1];
543        let vle1 = PhaseEquilibrium::bubble_point(
544            eos,
545            temperature,
546            &x1,
547            p_init,
548            None,
549            bubble_dew_options,
550        )?;
551        let vle2 = PhaseEquilibrium::bubble_point(
552            eos,
553            temperature,
554            &x2,
555            p_init,
556            None,
557            bubble_dew_options,
558        )?;
559        let mut l1 = vle1.liquid().clone();
560        let mut l2 = vle2.liquid().clone();
561        let p0 = (vle1.vapor().pressure(Contributions::Total)
562            + vle2.vapor().pressure(Contributions::Total))
563            * 0.5;
564        let y0 = (&vle1.vapor().molefracs + &vle2.vapor().molefracs) * 0.5;
565        let mut v = State::new_npt(eos, temperature, p0, y0, Some(Vapor))?;
566
567        for _ in 0..options.max_iter.unwrap_or(MAX_ITER_HETERO) {
568            // calculate properties
569            let dmu_drho_l1 = (l1.n_dmu_dni(Contributions::Total) * l1.molar_volume).to_reduced();
570            let dmu_drho_l2 = (l2.n_dmu_dni(Contributions::Total) * l2.molar_volume).to_reduced();
571            let dmu_drho_v = (v.n_dmu_dni(Contributions::Total) * v.molar_volume).to_reduced();
572            let dp_drho_l1 = (l1.n_dp_dni(Contributions::Total) * l1.molar_volume)
573                .to_reduced()
574                .transpose();
575            let dp_drho_l2 = (l2.n_dp_dni(Contributions::Total) * l2.molar_volume)
576                .to_reduced()
577                .transpose();
578            let dp_drho_v = (v.n_dp_dni(Contributions::Total) * v.molar_volume)
579                .to_reduced()
580                .transpose();
581            let mu_l1_res = l1.residual_chemical_potential().to_reduced();
582            let mu_l2_res = l2.residual_chemical_potential().to_reduced();
583            let mu_v_res = v.residual_chemical_potential().to_reduced();
584            let p_l1 = l1.pressure(Contributions::Total).to_reduced();
585            let p_l2 = l2.pressure(Contributions::Total).to_reduced();
586            let p_v = v.pressure(Contributions::Total).to_reduced();
587
588            // calculate residual
589            let delta_l1v_mu_ig = (RGAS * v.temperature).to_reduced()
590                * (l1
591                    .partial_density()
592                    .to_reduced()
593                    .component_div(&v.partial_density().to_reduced()))
594                .map(f64::ln);
595            let delta_l2v_mu_ig = (RGAS * v.temperature).to_reduced()
596                * (l2
597                    .partial_density()
598                    .to_reduced()
599                    .component_div(&v.partial_density().to_reduced()))
600                .map(f64::ln);
601            let res = stack![
602                mu_l1_res - &mu_v_res + delta_l1v_mu_ig;
603                mu_l2_res - &mu_v_res + delta_l2v_mu_ig;
604                vector![p_l1 - p_v];
605                vector![p_l2 - p_v]
606            ];
607
608            // check for convergence
609            if res.norm() < options.tol.unwrap_or(TOL_HETERO) {
610                return Ok(Self::new(v, l1, l2));
611            }
612
613            // calculate Jacobian
614            let jacobian = stack![
615                dmu_drho_l1, 0          , -&dmu_drho_v;
616                0          , dmu_drho_l2, -dmu_drho_v;
617                dp_drho_l1 , 0          , -&dp_drho_v;
618                0          , dp_drho_l2 , -dp_drho_v
619            ];
620
621            // calculate Newton step
622            let dx = LU::new(jacobian)?.solve(&res);
623
624            // apply Newton step
625            let rho_l1 =
626                &l1.partial_density() - &Density::from_reduced(dx.rows_range(0..2).into_owned());
627            let rho_l2 =
628                &l2.partial_density() - &Density::from_reduced(dx.rows_range(2..4).into_owned());
629            let rho_v =
630                &v.partial_density() - &Density::from_reduced(dx.rows_range(4..6).into_owned());
631
632            // check for negative densities
633            for i in 0..2 {
634                if rho_l1.get(i).is_sign_negative()
635                    || rho_l2.get(i).is_sign_negative()
636                    || rho_v.get(i).is_sign_negative()
637                {
638                    return Err(FeosError::IterationFailed(String::from(
639                        "PhaseEquilibrium::heteroazeotrope_t",
640                    )));
641                }
642            }
643
644            // update states
645            l1 = State::new_density(eos, temperature, rho_l1)?;
646            l2 = State::new_density(eos, temperature, rho_l2)?;
647            v = State::new_density(eos, temperature, rho_v)?;
648        }
649        Err(FeosError::NotConverged(String::from(
650            "PhaseEquilibrium::heteroazeotrope_t",
651        )))
652    }
653
654    /// Calculate a heteroazeotrope (three phase equilbrium) for a binary
655    /// system and given pressure.
656    #[expect(clippy::toplevel_ref_arg)]
657    fn heteroazeotrope_p(
658        eos: &E,
659        pressure: Pressure,
660        x_init: (f64, f64),
661        t_init: Option<Temperature>,
662        options: SolverOptions,
663        bubble_dew_options: (SolverOptions, SolverOptions),
664    ) -> FeosResult<Self> {
665        let p = pressure.to_reduced();
666
667        // calculate initial values using bubble point
668        let x1 = dvector![x_init.0, 1.0 - x_init.0];
669        let x2 = dvector![x_init.1, 1.0 - x_init.1];
670        let vle1 =
671            PhaseEquilibrium::bubble_point(eos, pressure, &x1, t_init, None, bubble_dew_options)?;
672        let vle2 =
673            PhaseEquilibrium::bubble_point(eos, pressure, &x2, t_init, None, bubble_dew_options)?;
674        let mut l1 = vle1.liquid().clone();
675        let mut l2 = vle2.liquid().clone();
676        let t0 = (vle1.vapor().temperature + vle2.vapor().temperature) * 0.5;
677        let y0 = (&vle1.vapor().molefracs + &vle2.vapor().molefracs) * 0.5;
678        let mut v = State::new_npt(eos, t0, pressure, y0, Some(Vapor))?;
679
680        for _ in 0..options.max_iter.unwrap_or(MAX_ITER_HETERO) {
681            // calculate properties
682            let dmu_drho_l1 = (l1.n_dmu_dni(Contributions::Total) * l1.molar_volume).to_reduced();
683            let dmu_drho_l2 = (l2.n_dmu_dni(Contributions::Total) * l2.molar_volume).to_reduced();
684            let dmu_drho_v = (v.n_dmu_dni(Contributions::Total) * v.molar_volume).to_reduced();
685            let dmu_res_dt_l1 = (l1.dmu_res_dt()).to_reduced();
686            let dmu_res_dt_l2 = (l2.dmu_res_dt()).to_reduced();
687            let dmu_res_dt_v = (v.dmu_res_dt()).to_reduced();
688            let dp_drho_l1 = (l1.n_dp_dni(Contributions::Total) * l1.molar_volume)
689                .to_reduced()
690                .transpose();
691            let dp_drho_l2 = (l2.n_dp_dni(Contributions::Total) * l2.molar_volume)
692                .to_reduced()
693                .transpose();
694            let dp_drho_v = (v.n_dp_dni(Contributions::Total) * v.molar_volume)
695                .to_reduced()
696                .transpose();
697            let dp_dt_l1 = (l1.dp_dt(Contributions::Total)).to_reduced();
698            let dp_dt_l2 = (l2.dp_dt(Contributions::Total)).to_reduced();
699            let dp_dt_v = (v.dp_dt(Contributions::Total)).to_reduced();
700            let mu_l1_res = l1.residual_chemical_potential().to_reduced();
701            let mu_l2_res = l2.residual_chemical_potential().to_reduced();
702            let mu_v_res = v.residual_chemical_potential().to_reduced();
703            let p_l1 = l1.pressure(Contributions::Total).to_reduced();
704            let p_l2 = l2.pressure(Contributions::Total).to_reduced();
705            let p_v = v.pressure(Contributions::Total).to_reduced();
706
707            // calculate residual
708            let delta_l1v_dmu_ig_dt = l1
709                .partial_density()
710                .to_reduced()
711                .component_div(&v.partial_density().to_reduced())
712                .map(f64::ln);
713            let delta_l2v_dmu_ig_dt = l2
714                .partial_density()
715                .to_reduced()
716                .component_div(&v.partial_density().to_reduced())
717                .map(f64::ln);
718            let delta_l1v_mu_ig = (RGAS * v.temperature).to_reduced() * &delta_l1v_dmu_ig_dt;
719            let delta_l2v_mu_ig = (RGAS * v.temperature).to_reduced() * &delta_l2v_dmu_ig_dt;
720            let res = stack![
721                mu_l1_res - &mu_v_res + delta_l1v_mu_ig;
722                mu_l2_res - &mu_v_res + delta_l2v_mu_ig;
723                vector![p_l1 - p];
724                vector![p_l2 - p];
725                vector![p_v - p]
726            ];
727
728            // check for convergence
729            if res.norm() < options.tol.unwrap_or(TOL_HETERO) {
730                return Ok(Self::new(v, l1, l2));
731            }
732
733            let jacobian = stack![
734                dmu_drho_l1, 0, -&dmu_drho_v, dmu_res_dt_l1 - &dmu_res_dt_v + delta_l1v_dmu_ig_dt;
735                0, dmu_drho_l2, -dmu_drho_v, dmu_res_dt_l2 - &dmu_res_dt_v + delta_l2v_dmu_ig_dt;
736                dp_drho_l1, 0, 0, vector![dp_dt_l1];
737                0, dp_drho_l2, 0, vector![dp_dt_l2];
738                0, 0, dp_drho_v, vector![dp_dt_v]
739            ];
740
741            // calculate Newton step
742            let dx = LU::new(jacobian)?.solve(&res);
743
744            // apply Newton step
745            let rho_l1 =
746                l1.partial_density() - Density::from_reduced(dx.rows_range(0..2).into_owned());
747            let rho_l2 =
748                l2.partial_density() - Density::from_reduced(dx.rows_range(2..4).into_owned());
749            let rho_v =
750                v.partial_density() - Density::from_reduced(dx.rows_range(4..6).into_owned());
751            let t = v.temperature - Temperature::from_reduced(dx[6]);
752
753            // check for negative densities and temperatures
754            for i in 0..2 {
755                if rho_l1.get(i).is_sign_negative()
756                    || rho_l2.get(i).is_sign_negative()
757                    || rho_v.get(i).is_sign_negative()
758                    || t.is_sign_negative()
759                {
760                    return Err(FeosError::IterationFailed(String::from(
761                        "PhaseEquilibrium::heteroazeotrope_p",
762                    )));
763                }
764            }
765
766            // update states
767            l1 = State::new_density(eos, t, rho_l1)?;
768            l2 = State::new_density(eos, t, rho_l2)?;
769            v = State::new_density(eos, t, rho_v)?;
770        }
771        Err(FeosError::NotConverged(String::from(
772            "PhaseEquilibrium::heteroazeotrope_p",
773        )))
774    }
775}
776
777/// # Azeotrope detection
778impl<E: Residual + Subset> PhaseEquilibrium<E, 2> {
779    /// Calculate the azeotropic state in a binary system. If no azeotrope is
780    /// expected, the function returns `None`.
781    pub fn binary_azeotrope<TP: TemperatureOrPressure>(
782        eos: &E,
783        temperature_or_pressure: TP,
784    ) -> FeosResult<Option<Self>> {
785        // determine the VLEs of both pure components
786        let vle = Self::vle_pure_comps(eos, temperature_or_pressure);
787
788        // check that there are exactly two components
789        let mut iter = vle.into_iter();
790        let (Some(vle1), Some(vle2), None) = (iter.next(), iter.next(), iter.next()) else {
791            return Err(FeosError::IncompatibleComponents(eos.components(), 2));
792        };
793
794        // check that both pure components are subcritical (or converge)
795        let (Some(vle1), Some(vle2)) = (vle1, vle2) else {
796            return Err(FeosError::SuperCritical);
797        };
798
799        // calculate the Henry coefficient and vapor pressures of both binary end systems
800        let henry1 =
801            State::henrys_law_constant(eos, vle1.liquid().temperature, &vle1.liquid().molefracs)?
802                [0];
803        let psat1 = vle1.liquid().pressure(Contributions::Total);
804        let henry2 =
805            State::henrys_law_constant(eos, vle2.liquid().temperature, &vle2.liquid().molefracs)?
806                [0];
807        let psat2 = vle2.liquid().pressure(Contributions::Total);
808
809        // calculate the relative volatility at both ends of the phase diagram
810        // logarithms of alpha values are used so that the algorithm behaves exactly the same
811        // when the two components are flipped
812        let ln_alpha1 = henry2.convert_into(psat2).ln();
813        let ln_alpha2 = psat1.convert_into(henry1).ln();
814
815        // check whether we expect an azeotrope (technically only a necessary criterion)
816        if ln_alpha1 * ln_alpha2 > 0.0 {
817            return Ok(None);
818        }
819
820        // estimate the azeotropic composition from a straight line in ln(alpha(x))
821        let x0 = -ln_alpha1 / (ln_alpha2 - ln_alpha1);
822
823        // solve for the azeotropic composition and return the corresponding VLE state
824        let (temperature, pressure, iterate_t) = temperature_or_pressure.temperature_pressure(None);
825        (if iterate_t {
826            Self::iterate_azeotrope_t(eos, temperature.unwrap(), x0, 10, 1e-10)
827        } else {
828            let t_init = vle1.liquid().temperature.min(vle2.liquid().temperature);
829            Self::iterate_azeotrope_p(eos, pressure.unwrap(), x0, t_init, 10, 1e-10)
830        })
831        .map(Some)
832    }
833
834    fn iterate_azeotrope_t(
835        eos: &E,
836        temperature: Temperature,
837        x0: f64,
838        max_iter: usize,
839        tol: f64,
840    ) -> FeosResult<Self> {
841        let x = Self::azeotrope_newton(
842            partial(
843                |x: Dual64, &t: &Temperature<_>| {
844                    PhaseEquilibrium::bubble_point(
845                        &eos.lift(),
846                        t,
847                        &dvector![x, -x + 1.0],
848                        None,
849                        None,
850                        Default::default(),
851                    )
852                    .map(|vle| {
853                        (vle.vapor().molefracs[0]
854                            / vle.liquid().molefracs[0]
855                            / (vle.vapor().molefracs[1] / vle.liquid().molefracs[1]))
856                            .ln()
857                    })
858                },
859                &temperature,
860            ),
861            x0,
862            max_iter,
863            tol,
864        )?;
865        PhaseEquilibrium::bubble_point(eos, temperature, x, None, None, Default::default())
866    }
867
868    fn iterate_azeotrope_p(
869        eos: &E,
870        pressure: Pressure,
871        x0: f64,
872        t_init: Temperature,
873        max_iter: usize,
874        tol: f64,
875    ) -> FeosResult<Self> {
876        let x = Self::azeotrope_newton(
877            partial2(
878                |x: Dual64, &p: &Pressure<_>, &t_init| {
879                    PhaseEquilibrium::bubble_point(
880                        &eos.lift(),
881                        p,
882                        &dvector![x, -x + 1.0],
883                        Some(t_init),
884                        None,
885                        Default::default(),
886                    )
887                    .map(|vle| {
888                        (vle.vapor().molefracs[0]
889                            / vle.liquid().molefracs[0]
890                            / (vle.vapor().molefracs[1] / vle.liquid().molefracs[1]))
891                            .ln()
892                    })
893                },
894                &pressure,
895                &t_init,
896            ),
897            x0,
898            max_iter,
899            tol,
900        )?;
901        PhaseEquilibrium::bubble_point(eos, pressure, x, Some(t_init), None, Default::default())
902    }
903
904    fn azeotrope_newton<F: Fn(Dual64) -> FeosResult<Dual64>>(
905        f: F,
906        x0: f64,
907        max_iter: usize,
908        tol: f64,
909    ) -> FeosResult<f64> {
910        let mut x = x0;
911        for _ in 0..max_iter {
912            let (f, df) = first_derivative(&f, x)?;
913            x -= f / df;
914            if f.abs() < tol {
915                return Ok(x);
916            }
917        }
918        Err(FeosError::NotConverged("binary_azeotrope".into()))
919    }
920}