From 25d301874f692498e565f13e958626cfc57340e0 Mon Sep 17 00:00:00 2001 From: Philipp Rehner <69816385+prehner@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:00:47 +0100 Subject: [PATCH 01/11] Update quantity to 0.13 and remove typenum dependency (#328) --- CHANGELOG.md | 4 ++ Cargo.toml | 3 +- crates/feos-core/Cargo.toml | 1 - .../src/equation_of_state/residual.rs | 5 +- crates/feos-core/src/lib.rs | 55 +++++++++++-------- .../src/phase_equilibria/bubble_dew.rs | 3 +- crates/feos-core/src/state/builder.rs | 13 ++--- crates/feos-core/src/state/cache.rs | 4 +- crates/feos-core/src/state/mod.rs | 13 ++--- crates/feos-dft/Cargo.toml | 1 - crates/feos-dft/src/adsorption/pore.rs | 4 +- crates/feos-dft/src/pdgt.rs | 6 +- crates/feos-dft/src/profile/mod.rs | 7 +-- crates/feos/Cargo.toml | 1 - crates/feos/benches/dft_pore.rs | 3 +- crates/feos/benches/dual_numbers.rs | 3 +- crates/feos/benches/state_properties.rs | 5 +- crates/feos/src/association/mod.rs | 9 ++- crates/feos/src/epcsaft/eos/mod.rs | 7 +-- crates/feos/src/gc_pcsaft/eos/dispersion.rs | 5 +- crates/feos/src/gc_pcsaft/eos/hard_chain.rs | 5 +- crates/feos/src/gc_pcsaft/eos/mod.rs | 5 +- crates/feos/src/ideal_gas/dippr.rs | 7 +-- crates/feos/src/ideal_gas/joback.rs | 5 +- crates/feos/src/multiparameter/mod.rs | 5 +- crates/feos/src/pcsaft/eos/mod.rs | 14 ++--- crates/feos/src/pets/eos/mod.rs | 5 +- crates/feos/src/uvtheory/eos/mod.rs | 13 ++--- crates/feos/tests/gc_pcsaft/binary.rs | 5 +- crates/feos/tests/gc_pcsaft/dft.rs | 9 ++- crates/feos/tests/pcsaft/critical_point.rs | 5 +- crates/feos/tests/pcsaft/dft.rs | 11 ++-- .../tests/pcsaft/state_creation_mixture.rs | 7 +-- .../feos/tests/pcsaft/state_creation_pure.rs | 11 ++-- .../tests/saftvrmie/critical_properties.rs | 3 +- py-feos/Cargo.toml | 1 - py-feos/src/dft/profile.rs | 16 +++--- py-feos/src/eos/mod.rs | 4 +- py-feos/src/phase_equilibria.rs | 13 ++--- py-feos/src/state.rs | 13 +++-- 40 files changed, 149 insertions(+), 160 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c4b7d03..f395eb34a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Breaking] +### Packaging +- Updated `quantity` dependency to 0.13 and removed the `typenum` dependency. [#323](https://github.com/feos-org/feos/pull/323) + ## [Unreleased] ### Added - Add Rayon global thread pool control via `FEOS_MAX_THREADS` and `set_num_threads()`/ `get_num_threads()` to Python. [#346](https://github.com/feos-org/feos/pull/346) diff --git a/Cargo.toml b/Cargo.toml index 5f4bd6586..de98c170f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ keywords = [ categories = ["science"] [workspace.dependencies] -quantity = "0.12" +quantity = "0.13" num-dual = "0.13" ndarray = "0.17" nalgebra = "0.34" @@ -33,7 +33,6 @@ serde = "1.0" serde_json = "1.0" indexmap = "2.0" itertools = "0.14" -typenum = "1.16" rayon = "1.11" petgraph = "0.8" rustdct = "0.7" diff --git a/crates/feos-core/Cargo.toml b/crates/feos-core/Cargo.toml index 94717bdb3..f33cd1255 100644 --- a/crates/feos-core/Cargo.toml +++ b/crates/feos-core/Cargo.toml @@ -25,7 +25,6 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } indexmap = { workspace = true, features = ["serde"] } rayon = { workspace = true, optional = true } -typenum = { workspace = true } itertools = { workspace = true } [dev-dependencies] diff --git a/crates/feos-core/src/equation_of_state/residual.rs b/crates/feos-core/src/equation_of_state/residual.rs index 1fe7bb56c..326aff883 100644 --- a/crates/feos-core/src/equation_of_state/residual.rs +++ b/crates/feos-core/src/equation_of_state/residual.rs @@ -3,9 +3,10 @@ use nalgebra::{DVector, DefaultAllocator, Dim, Dyn, OMatrix, OVector, U1, alloca use num_dual::{DualNum, Gradients, partial, partial2, second_derivative, third_derivative}; use quantity::ad::first_derivative; use quantity::*; -use std::ops::Deref; +use std::ops::{Deref, Div}; use std::sync::Arc; -use typenum::Quot; + +type Quot = >::Output; /// Molar weight of all components. /// diff --git a/crates/feos-core/src/lib.rs b/crates/feos-core/src/lib.rs index b883193ca..b51ae0653 100644 --- a/crates/feos-core/src/lib.rs +++ b/crates/feos-core/src/lib.rs @@ -3,7 +3,6 @@ #![warn(clippy::allow_attributes)] use quantity::{Quantity, SIUnit}; use std::ops::{Div, Mul}; -use typenum::Integer; /// Print messages with level `Verbosity::Iter` or higher. #[macro_export] @@ -132,20 +131,20 @@ const fn powi(x: f64, n: i32) -> f64 { /// Conversion between reduced units and SI units. pub trait ReferenceSystem { type Inner; - type T: Integer; - type L: Integer; - type M: Integer; - type I: Integer; - type THETA: Integer; - type N: Integer; - type J: Integer; - const FACTOR: f64 = powi(REFERENCE_VALUES[0], Self::T::I32) - * powi(REFERENCE_VALUES[1], Self::L::I32) - * powi(REFERENCE_VALUES[2], Self::M::I32) - * powi(REFERENCE_VALUES[3], Self::I::I32) - * powi(REFERENCE_VALUES[4], Self::THETA::I32) - * powi(REFERENCE_VALUES[5], Self::N::I32) - * powi(REFERENCE_VALUES[6], Self::J::I32); + const T: i8; + const L: i8; + const M: i8; + const I: i8; + const THETA: i8; + const N: i8; + const J: i8; + const FACTOR: f64 = powi(REFERENCE_VALUES[0], Self::T as i32) + * powi(REFERENCE_VALUES[1], Self::L as i32) + * powi(REFERENCE_VALUES[2], Self::M as i32) + * powi(REFERENCE_VALUES[3], Self::I as i32) + * powi(REFERENCE_VALUES[4], Self::THETA as i32) + * powi(REFERENCE_VALUES[5], Self::N as i32) + * powi(REFERENCE_VALUES[6], Self::J as i32); fn from_reduced(value: Self::Inner) -> Self where @@ -161,17 +160,25 @@ pub trait ReferenceSystem { } /// Conversion to and from reduced units -impl - ReferenceSystem for Quantity> +impl< + Inner, + const T: i8, + const L: i8, + const M: i8, + const I: i8, + const THETA: i8, + const N: i8, + const J: i8, +> ReferenceSystem for Quantity> { type Inner = Inner; - type T = T; - type L = L; - type M = M; - type I = I; - type THETA = THETA; - type N = N; - type J = J; + const T: i8 = T; + const L: i8 = L; + const M: i8 = M; + const I: i8 = I; + const THETA: i8 = THETA; + const N: i8 = N; + const J: i8 = J; fn from_reduced(value: Inner) -> Self where Inner: Mul, diff --git a/crates/feos-core/src/phase_equilibria/bubble_dew.rs b/crates/feos-core/src/phase_equilibria/bubble_dew.rs index 9433720cf..a50cc46a1 100644 --- a/crates/feos-core/src/phase_equilibria/bubble_dew.rs +++ b/crates/feos-core/src/phase_equilibria/bubble_dew.rs @@ -12,7 +12,6 @@ use ndarray::Array1; use num_dual::linalg::LU; use num_dual::{DualNum, DualStruct, Gradients}; use quantity::{Density, Dimensionless, Moles, Pressure, Quantity, RGAS, SIUnit, Temperature}; -use typenum::{N1, N2, P1, Z0}; const MAX_ITER_INNER: usize = 5; const TOL_INNER: f64 = 1e-9; @@ -94,7 +93,7 @@ impl + Copy> TemperatureOrPressure for Temperature { // used instead of the explicit unit. Maybe the type is too complicated for the // compiler? impl + Copy> TemperatureOrPressure - for Quantity> + for Quantity> { type Other = Temperature; const IDENTIFIER: &'static str = "pressure"; diff --git a/crates/feos-core/src/state/builder.rs b/crates/feos-core/src/state/builder.rs index 02c61c93d..c418a7c1f 100644 --- a/crates/feos-core/src/state/builder.rs +++ b/crates/feos-core/src/state/builder.rs @@ -14,25 +14,24 @@ use quantity::*; /// # use quantity::*; /// # use nalgebra::dvector; /// # use approx::assert_relative_eq; -/// # use typenum::P3; /// # fn main() -> FeosResult<()> { /// // Create a state for given T,V,N /// let eos = &PengRobinson::new(PengRobinsonParameters::new_simple(&[369.8], &[41.9 * 1e5], &[0.15], &[15.0])?); /// let state = StateBuilder::new(&eos) /// .temperature(300.0 * KELVIN) -/// .volume(12.5 * METER.powi::()) +/// .volume(12.5 * METER.powi::<3>()) /// .moles(&(dvector![2.5] * MOL)) /// .build()?; -/// assert_eq!(state.density, 0.2 * MOL / METER.powi::()); +/// assert_eq!(state.density, 0.2 * MOL / METER.powi::<3>()); /// /// // For a pure component, the composition does not need to be specified. /// let eos = &PengRobinson::new(PengRobinsonParameters::new_simple(&[369.8], &[41.9 * 1e5], &[0.15], &[15.0])?); /// let state = StateBuilder::new(&eos) /// .temperature(300.0 * KELVIN) -/// .volume(12.5 * METER.powi::()) +/// .volume(12.5 * METER.powi::<3>()) /// .total_moles(2.5 * MOL) /// .build()?; -/// assert_eq!(state.density, 0.2 * MOL / METER.powi::()); +/// assert_eq!(state.density, 0.2 * MOL / METER.powi::<3>()); /// /// // The state can be constructed without providing any extensive property. /// let eos = &PengRobinson::new( @@ -45,10 +44,10 @@ use quantity::*; /// ); /// let state = StateBuilder::new(&eos) /// .temperature(300.0 * KELVIN) -/// .partial_density(&(dvector![0.2, 0.6] * MOL / METER.powi::())) +/// .partial_density(&(dvector![0.2, 0.6] * MOL / METER.powi::<3>())) /// .build()?; /// assert_relative_eq!(state.molefracs, dvector![0.25, 0.75]); -/// assert_relative_eq!(state.density, 0.8 * MOL / METER.powi::()); +/// assert_relative_eq!(state.density, 0.8 * MOL / METER.powi::<3>()); /// # Ok(()) /// # } /// ``` diff --git a/crates/feos-core/src/state/cache.rs b/crates/feos-core/src/state/cache.rs index 2ee54a737..b9a1326f7 100644 --- a/crates/feos-core/src/state/cache.rs +++ b/crates/feos-core/src/state/cache.rs @@ -1,8 +1,10 @@ use nalgebra::allocator::Allocator; use nalgebra::{DefaultAllocator, Dim, OVector, Scalar}; use quantity::*; +use std::ops::Sub; use std::sync::OnceLock; -use typenum::Diff; + +type Diff = >::Output; #[derive(Clone, Debug)] #[expect(clippy::type_complexity)] diff --git a/crates/feos-core/src/state/mod.rs b/crates/feos-core/src/state/mod.rs index 4f896d72e..5da7a43c5 100644 --- a/crates/feos-core/src/state/mod.rs +++ b/crates/feos-core/src/state/mod.rs @@ -798,12 +798,11 @@ mod critical_point; mod tests { use super::*; use nalgebra::dvector; - use typenum::P3; #[test] fn test_validate() { let temperature = 298.15 * KELVIN; - let density = 3000.0 * MOL / METER.powi::(); + let density = 3000.0 * MOL / METER.powi::<3>(); let molefracs = dvector![0.03, 0.02, 0.05]; assert!(validate(temperature, density, &molefracs).is_ok()); } @@ -811,7 +810,7 @@ mod tests { #[test] fn test_negative_temperature() { let temperature = -298.15 * KELVIN; - let density = 3000.0 * MOL / METER.powi::(); + let density = 3000.0 * MOL / METER.powi::<3>(); let molefracs = dvector![0.03, 0.02, 0.05]; assert!(validate(temperature, density, &molefracs).is_err()); } @@ -819,7 +818,7 @@ mod tests { #[test] fn test_nan_temperature() { let temperature = f64::NAN * KELVIN; - let density = 3000.0 * MOL / METER.powi::(); + let density = 3000.0 * MOL / METER.powi::<3>(); let molefracs = dvector![0.03, 0.02, 0.05]; assert!(validate(temperature, density, &molefracs).is_err()); } @@ -827,7 +826,7 @@ mod tests { #[test] fn test_negative_mole_number() { let temperature = 298.15 * KELVIN; - let density = 3000.0 * MOL / METER.powi::(); + let density = 3000.0 * MOL / METER.powi::<3>(); let molefracs = dvector![-0.03, 0.02, 0.05]; assert!(validate(temperature, density, &molefracs).is_err()); } @@ -835,7 +834,7 @@ mod tests { #[test] fn test_nan_mole_number() { let temperature = 298.15 * KELVIN; - let density = 3000.0 * MOL / METER.powi::(); + let density = 3000.0 * MOL / METER.powi::<3>(); let molefracs = dvector![f64::NAN, 0.02, 0.05]; assert!(validate(temperature, density, &molefracs).is_err()); } @@ -843,7 +842,7 @@ mod tests { #[test] fn test_negative_density() { let temperature = 298.15 * KELVIN; - let density = -3000.0 * MOL / METER.powi::(); + let density = -3000.0 * MOL / METER.powi::<3>(); let molefracs = dvector![0.01, 0.02, 0.05]; assert!(validate(temperature, density, &molefracs).is_err()); } diff --git a/crates/feos-dft/Cargo.toml b/crates/feos-dft/Cargo.toml index 9a010add0..2e9e52a7f 100644 --- a/crates/feos-dft/Cargo.toml +++ b/crates/feos-dft/Cargo.toml @@ -25,7 +25,6 @@ num-traits = { workspace = true } libm = { workspace = true } gauss-quad = { workspace = true, optional = true } petgraph = { workspace = true } -typenum = { workspace = true } feos-core = { workspace = true } diff --git a/crates/feos-dft/src/adsorption/pore.rs b/crates/feos-dft/src/adsorption/pore.rs index c7602b7d5..faf19e62d 100644 --- a/crates/feos-dft/src/adsorption/pore.rs +++ b/crates/feos-dft/src/adsorption/pore.rs @@ -19,12 +19,12 @@ use quantity::{ Temperature, Volume, }; use rustdct::DctNum; -use typenum::Diff; +use std::ops::Sub; const POTENTIAL_OFFSET: f64 = 2.0; const DEFAULT_GRID_POINTS: usize = 2048; -pub type _HenryCoefficient = Diff<_Moles, _Pressure>; +pub type _HenryCoefficient = <_Moles as Sub<_Pressure>>::Output; pub type HenryCoefficient = Quantity; /// Parameters required to specify a 1D pore. diff --git a/crates/feos-dft/src/pdgt.rs b/crates/feos-dft/src/pdgt.rs index d3a9fa30a..47ad359eb 100644 --- a/crates/feos-dft/src/pdgt.rs +++ b/crates/feos-dft/src/pdgt.rs @@ -10,7 +10,9 @@ use quantity::{ Temperature, }; use std::ops::{Add, AddAssign, Sub}; -use typenum::{Diff, P2, Sum}; + +type Sum = >::Output; +type Diff = >::Output; type _InfluenceParameter = Diff, _Density>; type InfluenceParameter = Quantity; @@ -218,7 +220,7 @@ pub trait PdgtFunctionalProperties: HelmholtzEnergyFunctional { // calculate interfacial width let w_temp = integrate_trapezoidal(&rho_r * &*z * z_int, dx); - *w = (24.0 * (w_temp - 0.5 * ze.powi::())).sqrt(); + *w = (24.0 * (w_temp - 0.5 * ze.powi::<2>())).sqrt(); // shift density profile *z -= ze; diff --git a/crates/feos-dft/src/profile/mod.rs b/crates/feos-dft/src/profile/mod.rs index 7bc9b7ac9..edac0e7b2 100644 --- a/crates/feos-dft/src/profile/mod.rs +++ b/crates/feos-dft/src/profile/mod.rs @@ -12,7 +12,6 @@ use num_dual::DualNum; use quantity::{_Volume, DEGREES, Density, Length, Moles, Quantity, Temperature, Volume}; use std::ops::{Add, MulAssign}; use std::sync::Arc; -use typenum::Sum; mod properties; @@ -304,7 +303,7 @@ where pub fn integrate, U>( &self, profile: &Quantity, U>, - ) -> Quantity> + ) -> Quantity>::Output> where _Volume: Add, { @@ -322,7 +321,7 @@ where pub fn integrate_comp, U>( &self, profile: &Quantity, U>, - ) -> Quantity, Sum<_Volume, U>> + ) -> Quantity, <_Volume as Add>::Output> where _Volume: Add, { @@ -335,7 +334,7 @@ where pub fn integrate_segments, U>( &self, profile: &Quantity, U>, - ) -> Quantity, Sum<_Volume, U>> + ) -> Quantity, <_Volume as Add>::Output> where _Volume: Add, { diff --git a/crates/feos/Cargo.toml b/crates/feos/Cargo.toml index 5965e4354..28e6e6109 100644 --- a/crates/feos/Cargo.toml +++ b/crates/feos/Cargo.toml @@ -26,7 +26,6 @@ serde_json = { workspace = true } indexmap = { workspace = true } rayon = { workspace = true, optional = true } itertools = { workspace = true } -typenum = { workspace = true } feos-core = { workspace = true } feos-derive = { workspace = true, optional = true } diff --git a/crates/feos/benches/dft_pore.rs b/crates/feos/benches/dft_pore.rs index 440c3648e..667c5ff12 100644 --- a/crates/feos/benches/dft_pore.rs +++ b/crates/feos/benches/dft_pore.rs @@ -10,7 +10,6 @@ use feos::hard_sphere::{FMTFunctional, FMTVersion}; use feos::pcsaft::{PcSaftFunctional, PcSaftParameters}; use nalgebra::dvector; use quantity::{ANGSTROM, KELVIN, NAV}; -use typenum::P3; fn fmt(c: &mut Criterion) { let mut group = c.benchmark_group("DFT_pore_fmt"); @@ -23,7 +22,7 @@ fn fmt(c: &mut Criterion) { None, None, ); - let bulk = State::new_pure(&func, KELVIN, 0.75 / NAV / ANGSTROM.powi::()).unwrap(); + let bulk = State::new_pure(&func, KELVIN, 0.75 / NAV / ANGSTROM.powi::<3>()).unwrap(); group.bench_function("liquid", |b| { b.iter(|| pore.initialize(&bulk, None, None).unwrap().solve(None)) }); diff --git a/crates/feos/benches/dual_numbers.rs b/crates/feos/benches/dual_numbers.rs index 6ec66a63c..454c6828f 100644 --- a/crates/feos/benches/dual_numbers.rs +++ b/crates/feos/benches/dual_numbers.rs @@ -13,7 +13,6 @@ use feos_core::parameter::PureRecord; use nalgebra::{DVector, Dyn, dvector}; use num_dual::{Dual2_64, Dual3_64, Dual64, DualNum, HyperDual64}; use quantity::*; -use typenum::P3; /// Helper function to create a state for given parameters. /// - temperature is 80% of critical temperature, @@ -129,7 +128,7 @@ fn methane_co2_pcsaft(c: &mut Criterion) { // 230 K, 50 bar, x0 = 0.15 let temperature = 230.0 * KELVIN; - let density = 24.16896 * KILO * MOL / METER.powi::(); + let density = 24.16896 * KILO * MOL / METER.powi::<3>(); let volume = 10.0 * MOL / density; let x = dvector![0.15, 0.85]; let moles = &x * 10.0 * MOL; diff --git a/crates/feos/benches/state_properties.rs b/crates/feos/benches/state_properties.rs index 401addf03..916f094ed 100644 --- a/crates/feos/benches/state_properties.rs +++ b/crates/feos/benches/state_properties.rs @@ -5,7 +5,6 @@ use feos::core::{Contributions, Residual, State}; use feos::pcsaft::{PcSaft, PcSaftParameters}; use nalgebra::{DVector, dvector}; use quantity::*; -use typenum::P3; /// Evaluate a property of a state given the EoS, the property to compute, /// temperature, volume, moles, and the contributions to consider. @@ -42,7 +41,7 @@ fn properties_pcsaft(c: &mut Criterion) { .unwrap(); let eos = PcSaft::new(parameters); let t = 300.0 * KELVIN; - let density = 71.18 * KILO * MOL / METER.powi::(); + let density = 71.18 * KILO * MOL / METER.powi::<3>(); let v = 100.0 * MOL / density; let x = dvector![1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]; let m = &x * 100.0 * MOL; @@ -92,7 +91,7 @@ fn properties_pcsaft_polar(c: &mut Criterion) { .unwrap(); let eos = PcSaft::new(parameters); let t = 300.0 * KELVIN; - let density = 71.18 * KILO * MOL / METER.powi::(); + let density = 71.18 * KILO * MOL / METER.powi::<3>(); let v = 100.0 * MOL / density; let x = dvector![1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]; let m = &x * 100.0 * MOL; diff --git a/crates/feos/src/association/mod.rs b/crates/feos/src/association/mod.rs index a12e12e14..72bd97a38 100644 --- a/crates/feos/src/association/mod.rs +++ b/crates/feos/src/association/mod.rs @@ -18,7 +18,7 @@ pub use dft::YuWuAssociationFunctional; /// [AssociationStrength::association_strength] multiplies the model-specific /// site-site association strength [AssociationStrength::association_strength_ij] /// with the contact value of the hard-sphere pair correlation function. -/// +/// /// For implementations that require a different form, /// [AssociationStrength::association_strength] can be overwritten. pub trait AssociationStrength: HardSphereProperties { @@ -463,7 +463,6 @@ mod tests_gc_pcsaft { use nalgebra::dvector; use num_dual::Dual64; use quantity::{METER, MOL, PASCAL, Pressure}; - use typenum::P3; #[test] fn test_assoc_propanol() { @@ -471,7 +470,7 @@ mod tests_gc_pcsaft { let params = GcPcSaftEosParameters::new(¶meters); let contrib = Association::new(50, 1e-10); let temperature = 300.0; - let volume = Dual64::from_re(METER.powi::().to_reduced()).derivative(); + let volume = Dual64::from_re(METER.powi::<3>().to_reduced()).derivative(); let moles = Dual64::from_re((1.5 * MOL).to_reduced()); let molar_volume = volume / moles; let state = StateHD::new( @@ -499,7 +498,7 @@ mod tests_gc_pcsaft { let params = GcPcSaftEosParameters::new(¶meters); let contrib = Association::new_cross_association(50, 1e-10); let temperature = 300.0; - let volume = Dual64::from_re(METER.powi::().to_reduced()).derivative(); + let volume = Dual64::from_re(METER.powi::<3>().to_reduced()).derivative(); let moles = Dual64::from_re((1.5 * MOL).to_reduced()); let molar_volume = volume / moles; let state = StateHD::new( @@ -527,7 +526,7 @@ mod tests_gc_pcsaft { let params = GcPcSaftEosParameters::new(¶meters); let contrib = Association::new(50, 1e-10); let temperature = 300.0; - let volume = Dual64::from_re(METER.powi::().to_reduced()).derivative(); + let volume = Dual64::from_re(METER.powi::<3>().to_reduced()).derivative(); let moles = (dvector![1.5, 2.5] * MOL).to_reduced().map(Dual64::from_re); let total_moles = moles.sum(); let molar_volume = volume / total_moles; diff --git a/crates/feos/src/epcsaft/eos/mod.rs b/crates/feos/src/epcsaft/eos/mod.rs index 06aa87b63..03a2713d0 100644 --- a/crates/feos/src/epcsaft/eos/mod.rs +++ b/crates/feos/src/epcsaft/eos/mod.rs @@ -173,13 +173,12 @@ mod tests { use approx::assert_relative_eq; use feos_core::*; use nalgebra::dvector; - use typenum::P3; #[test] fn ideal_gas_pressure() { let e = ElectrolytePcSaft::new(propane_parameters()).unwrap(); let t = 200.0 * KELVIN; - let v = 1e-3 * METER.powi::(); + let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&&e, t, v, &n).unwrap(); let p_ig = s.total_moles * RGAS * t / v; @@ -195,7 +194,7 @@ mod tests { fn ideal_gas_heat_capacity_joback() { let e = ElectrolytePcSaft::new(propane_parameters()).unwrap(); let t = 200.0 * KELVIN; - let v = 1e-3 * METER.powi::(); + let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&&e, t, v, &n).unwrap(); let p_ig = s.total_moles * RGAS * t / v; @@ -282,7 +281,7 @@ mod tests { let e2 = ElectrolytePcSaft::new(butane_parameters()).unwrap(); let e12 = ElectrolytePcSaft::new(propane_butane_parameters()).unwrap(); let t = 300.0 * KELVIN; - let v = 0.02456883872966545 * METER.powi::(); + let v = 0.02456883872966545 * METER.powi::<3>(); let m1 = dvector![2.0] * MOL; let m1m = dvector![2.0, 0.0] * MOL; let m2m = dvector![0.0, 2.0] * MOL; diff --git a/crates/feos/src/gc_pcsaft/eos/dispersion.rs b/crates/feos/src/gc_pcsaft/eos/dispersion.rs index a1a8169bf..3f464507d 100644 --- a/crates/feos/src/gc_pcsaft/eos/dispersion.rs +++ b/crates/feos/src/gc_pcsaft/eos/dispersion.rs @@ -129,13 +129,12 @@ mod test { use nalgebra::dvector; use num_dual::Dual64; use quantity::{METER, MOL, PASCAL, Pressure}; - use typenum::P3; #[test] fn test_dispersion_propane() { let parameters = propane(); let temperature = 300.0; - let volume = Dual64::from_re(METER.powi::().to_reduced()).derivative(); + let volume = Dual64::from_re(METER.powi::<3>().to_reduced()).derivative(); let moles = Dual64::from_re((1.5 * MOL).to_reduced()); let molar_volume = volume / moles; let state = StateHD::new( @@ -153,7 +152,7 @@ mod test { fn test_dispersion_propanol() { let parameters = GcPcSaftEosParameters::new(&propanol()); let temperature = 300.0; - let volume = Dual64::from_re(METER.powi::().to_reduced()).derivative(); + let volume = Dual64::from_re(METER.powi::<3>().to_reduced()).derivative(); let moles = Dual64::from_re((1.5 * MOL).to_reduced()); let molar_volume = volume / moles; let state = StateHD::new( diff --git a/crates/feos/src/gc_pcsaft/eos/hard_chain.rs b/crates/feos/src/gc_pcsaft/eos/hard_chain.rs index 50b3c4afa..f67dffda4 100644 --- a/crates/feos/src/gc_pcsaft/eos/hard_chain.rs +++ b/crates/feos/src/gc_pcsaft/eos/hard_chain.rs @@ -42,13 +42,12 @@ mod test { use nalgebra::dvector; use num_dual::Dual64; use quantity::{METER, MOL, PASCAL, Pressure}; - use typenum::P3; #[test] fn test_hc_propane() { let parameters = propane(); let temperature = 300.0; - let volume = METER.powi::().to_reduced(); + let volume = METER.powi::<3>().to_reduced(); let volume = Dual64::from_re(volume).derivative(); let moles = (1.5 * MOL).to_reduced(); let state = StateHD::new( @@ -70,7 +69,7 @@ mod test { fn test_hc_propanol() { let parameters = GcPcSaftEosParameters::new(&propanol()); let temperature = 300.0; - let volume = METER.powi::().to_reduced(); + let volume = METER.powi::<3>().to_reduced(); let volume = Dual64::from_re(volume).derivative(); let moles = (1.5 * MOL).to_reduced(); let state = StateHD::new( diff --git a/crates/feos/src/gc_pcsaft/eos/mod.rs b/crates/feos/src/gc_pcsaft/eos/mod.rs index 4198da197..ae6acf960 100644 --- a/crates/feos/src/gc_pcsaft/eos/mod.rs +++ b/crates/feos/src/gc_pcsaft/eos/mod.rs @@ -150,13 +150,12 @@ mod test { use nalgebra::dvector; use num_dual::Dual64; use quantity::{METER, MOL, PASCAL, Pressure}; - use typenum::P3; #[test] fn hs_propane() { let parameters = propane(); let temperature = 300.0; - let volume = Dual64::from_re(METER.powi::().to_reduced()).derivative(); + let volume = Dual64::from_re(METER.powi::<3>().to_reduced()).derivative(); let moles = Dual64::from_re((1.5 * MOL).to_reduced()); let molar_volume = volume / moles; let state = StateHD::new( @@ -174,7 +173,7 @@ mod test { fn hs_propanol() { let parameters = GcPcSaftEosParameters::new(&propanol()); let temperature = 300.0; - let volume = Dual64::from_re(METER.powi::().to_reduced()).derivative(); + let volume = Dual64::from_re(METER.powi::<3>().to_reduced()).derivative(); let moles = Dual64::from_re((1.5 * MOL).to_reduced()); let molar_volume = volume / moles; let state = StateHD::new( diff --git a/crates/feos/src/ideal_gas/dippr.rs b/crates/feos/src/ideal_gas/dippr.rs index 927dd2e07..7553e376c 100644 --- a/crates/feos/src/ideal_gas/dippr.rs +++ b/crates/feos/src/ideal_gas/dippr.rs @@ -159,7 +159,6 @@ mod tests { use feos_core::{Contributions, EquationOfState, StateBuilder}; use num_dual::first_derivative; use quantity::*; - use typenum::P3; use super::*; @@ -173,7 +172,7 @@ mod tests { let dippr = Dippr::new(DipprParameters::new_pure(record.clone())?); let eos = EquationOfState::ideal_gas(dippr.clone()); let temperature = 300.0 * KELVIN; - let volume = METER.powi::(); + let volume = METER.powi::<3>(); let state = StateBuilder::new(&&eos) .temperature(temperature) .volume(volume) @@ -217,7 +216,7 @@ mod tests { let dippr = Dippr::new(DipprParameters::new_pure(record.clone())?); let eos = EquationOfState::ideal_gas(dippr.clone()); let temperature = 300.0 * KELVIN; - let volume = METER.powi::(); + let volume = METER.powi::<3>(); let state = StateBuilder::new(&&eos) .temperature(temperature) .volume(volume) @@ -263,7 +262,7 @@ mod tests { let dippr = Dippr::new(DipprParameters::new_pure(record.clone())?); let eos = EquationOfState::ideal_gas(dippr.clone()); let temperature = 20.0 * KELVIN; - let volume = METER.powi::(); + let volume = METER.powi::<3>(); let state = StateBuilder::new(&&eos) .temperature(temperature) .volume(volume) diff --git a/crates/feos/src/ideal_gas/joback.rs b/crates/feos/src/ideal_gas/joback.rs index bef674722..742c6ef2e 100644 --- a/crates/feos/src/ideal_gas/joback.rs +++ b/crates/feos/src/ideal_gas/joback.rs @@ -179,7 +179,6 @@ mod tests { use nalgebra::dvector; use quantity::*; use std::collections::HashMap; - use typenum::P3; use super::*; @@ -263,7 +262,7 @@ mod tests { let pr = PureRecord::new(Identifier::default(), 1.0, jr); let joback = Joback::new(JobackParameters::new_pure(pr)?); let eos = EquationOfState::ideal_gas(joback); - let state = State::new_pure(&&eos, 1000.0 * KELVIN, 1.0 * MOL / METER.powi::())?; + let state = State::new_pure(&&eos, 1000.0 * KELVIN, 1.0 * MOL / METER.powi::<3>())?; assert!( ((state.molar_isobaric_heat_capacity(Contributions::IdealGas) / (JOULE / MOL / KELVIN)) @@ -294,7 +293,7 @@ mod tests { )?); let eos = EquationOfState::ideal_gas(joback.clone()); let temperature = 300.0 * KELVIN; - let volume = METER.powi::(); + let volume = METER.powi::<3>(); let moles = &dvector![1.0, 3.0] * MOL; let state = StateBuilder::new(&&eos) .temperature(temperature) diff --git a/crates/feos/src/multiparameter/mod.rs b/crates/feos/src/multiparameter/mod.rs index f8ab47634..68bbd98b8 100644 --- a/crates/feos/src/multiparameter/mod.rs +++ b/crates/feos/src/multiparameter/mod.rs @@ -138,7 +138,6 @@ mod test { use nalgebra::{Dyn, SVector, U2, dvector}; use num_dual::{Dual2Vec, hessian}; use quantity::{GRAM, KELVIN, KILO, KILOGRAM, METER, MOL, RGAS}; - use typenum::P3; use super::*; @@ -262,7 +261,7 @@ mod test { #[test] fn test_ideal_gas_hack() { let t = 647. * KELVIN; - let rho = 358. * KILOGRAM / METER.powi::(); + let rho = 358. * KILOGRAM / METER.powi::<3>(); let eos = &water(); let mw = eos.molar_weight.get(0); let moles = dvector![1.8] * MOL; @@ -270,7 +269,7 @@ mod test { let phi_feos = (a_feos / RGAS / moles.sum() / t).into_value(); println!("A: {a_feos}"); println!("phi(feos): {phi_feos}"); - let delta = (rho / (eos.rhoc * MOL / METER.powi::() * mw)).into_value(); + let delta = (rho / (eos.rhoc * MOL / METER.powi::<3>() * mw)).into_value(); let tau = (eos.tc * KELVIN / t).into_value(); let phi = eos.ideal_gas[0] .terms diff --git a/crates/feos/src/pcsaft/eos/mod.rs b/crates/feos/src/pcsaft/eos/mod.rs index aea6b6890..6b0aa8782 100644 --- a/crates/feos/src/pcsaft/eos/mod.rs +++ b/crates/feos/src/pcsaft/eos/mod.rs @@ -9,7 +9,6 @@ use num_dual::{DualNum, partial2}; use quantity::ad::first_derivative; use quantity::*; use std::f64::consts::{FRAC_PI_6, PI}; -use typenum::P2; pub(crate) mod dispersion; pub(crate) mod hard_chain; @@ -241,7 +240,7 @@ impl EntropyScaling for PcSaft { let tr = (temperature / p.epsilon_k[i] / KELVIN).into_value(); 5.0 / 16.0 * (mw.get(i) * KB / NAV * temperature / PI).sqrt() / omega22(tr) - / (p.sigma[i] * ANGSTROM).powi::() + / (p.sigma[i] * ANGSTROM).powi::<2>() }) .collect(); let mut ce_mix = 0.0 * MILLI * PASCAL * SECOND; @@ -291,7 +290,7 @@ impl EntropyScaling for PcSaft { let res: Vec<_> = (0..self.components()) .map(|i| { let tr = (temperature / p.epsilon_k[i] / KELVIN).into_value(); - 3.0 / 8.0 / (p.sigma[i] * ANGSTROM).powi::() / omega11(tr) / (density * NAV) + 3.0 / 8.0 / (p.sigma[i] * ANGSTROM).powi::<2>() / omega11(tr) / (density * NAV) * (temperature * RGAS / PI / mw.get(i) / p.m[i]).sqrt() }) .collect(); @@ -393,13 +392,12 @@ mod tests { use feos_core::*; use nalgebra::dvector; use quantity::{BAR, KELVIN, METER, PASCAL, RGAS}; - use typenum::{P2, P3}; #[test] fn ideal_gas_pressure() { let e = &propane_parameters(); let t = 200.0 * KELVIN; - let v = 1e-3 * METER.powi::(); + let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&e, t, v, &n).unwrap(); let p_ig = s.total_moles * RGAS * t / v; @@ -415,7 +413,7 @@ mod tests { fn ideal_gas_heat_capacity_joback() { let e = &propane_parameters(); let t = 200.0 * KELVIN; - let v = 1e-3 * METER.powi::(); + let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&e, t, v, &n).unwrap(); let p_ig = s.total_moles * RGAS * t / v; @@ -504,7 +502,7 @@ mod tests { let e2 = &butane_parameters(); let e12 = &propane_butane_parameters(); let t = 300.0 * KELVIN; - let v = 0.02456883872966545 * METER.powi::(); + let v = 0.02456883872966545 * METER.powi::<3>(); let m1 = dvector![2.0] * MOL; let m1m = dvector![2.0, 0.0] * MOL; let m2m = dvector![0.0, 2.0] * MOL; @@ -577,7 +575,7 @@ mod tests { let s = State::new_npt(&e, t, p, &n, None)?; assert_relative_eq!( s.diffusion(), - 0.01505 * (CENTI * METER).powi::() / SECOND, + 0.01505 * (CENTI * METER).powi::<2>() / SECOND, epsilon = 1e-5 ); assert_relative_eq!( diff --git a/crates/feos/src/pets/eos/mod.rs b/crates/feos/src/pets/eos/mod.rs index 0ad191726..f1d1d76cb 100644 --- a/crates/feos/src/pets/eos/mod.rs +++ b/crates/feos/src/pets/eos/mod.rs @@ -144,13 +144,12 @@ mod tests { use feos_core::{Contributions, PhaseEquilibrium, State, StateHD}; use nalgebra::dvector; use quantity::{BAR, KELVIN, METER, MOL, RGAS}; - use typenum::P3; #[test] fn ideal_gas_pressure() { let e = &Pets::new(argon_parameters()); let t = 200.0 * KELVIN; - let v = 1e-3 * METER.powi::(); + let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&e, t, v, &n).unwrap(); let p_ig = s.total_moles * RGAS * t / v; @@ -212,7 +211,7 @@ mod tests { let e2 = &Pets::new(krypton_parameters()); let e12 = &Pets::new(argon_krypton_parameters()); let t = 300.0 * KELVIN; - let v = 0.02456883872966545 * METER.powi::(); + let v = 0.02456883872966545 * METER.powi::<3>(); let m1 = dvector![2.0] * MOL; let m1m = dvector![2.0, 0.0] * MOL; let m2m = dvector![0.0, 2.0] * MOL; diff --git a/crates/feos/src/uvtheory/eos/mod.rs b/crates/feos/src/uvtheory/eos/mod.rs index bc38dcd33..9fa963ae2 100644 --- a/crates/feos/src/uvtheory/eos/mod.rs +++ b/crates/feos/src/uvtheory/eos/mod.rs @@ -110,7 +110,6 @@ mod test { use feos_core::{FeosResult, State}; use nalgebra::dvector; use quantity::{ANGSTROM, KELVIN, MOL, NAV, RGAS}; - use typenum::P3; #[test] fn helmholtz_energy_pure_wca() -> FeosResult<()> { @@ -123,7 +122,7 @@ mod test { let reduced_density = 1.0; let temperature = reduced_temperature * eps_k * KELVIN; let moles = dvector![2.0] * MOL; - let volume = (sig * ANGSTROM).powi::() / reduced_density * NAV * 2.0 * MOL; + let volume = (sig * ANGSTROM).powi::<3>() / reduced_density * NAV * 2.0 * MOL; let s = State::new_nvt(&eos, temperature, volume, &moles).unwrap(); let a = (s.residual_molar_helmholtz_energy() / (RGAS * temperature)).into_value(); assert_relative_eq!(a, 2.972986567516, max_relative = 1e-12); //wca @@ -147,7 +146,7 @@ mod test { let reduced_density = 1.0; let temperature = reduced_temperature * eps_k * KELVIN; let moles = dvector![2.0] * MOL; - let volume = (sig * ANGSTROM).powi::() / reduced_density * NAV * 2.0 * MOL; + let volume = (sig * ANGSTROM).powi::<3>() / reduced_density * NAV * 2.0 * MOL; let s = State::new_nvt(&eos, temperature, volume, &moles).unwrap(); let a = (s.residual_molar_helmholtz_energy() / (RGAS * temperature)).into_value(); @@ -173,7 +172,7 @@ mod test { let reduced_density = 0.5; let temperature = reduced_temperature * eps_k * KELVIN; let moles = dvector![2.0] * MOL; - let volume = (sig * ANGSTROM).powi::() / reduced_density * NAV * 2.0 * MOL; + let volume = (sig * ANGSTROM).powi::<3>() / reduced_density * NAV * 2.0 * MOL; let s = State::new_nvt(&eos, temperature, volume, &moles).unwrap(); let a = (s.residual_molar_helmholtz_energy() / (RGAS * temperature)).into_value(); dbg!(a); @@ -209,7 +208,7 @@ mod test { let reduced_density = 1.0; let moles = dvector![1.7, 0.3] * MOL; let total_moles = moles.sum(); - let volume = (sig_x * ANGSTROM).powi::() / reduced_density * NAV * total_moles; + let volume = (sig_x * ANGSTROM).powi::<3>() / reduced_density * NAV * total_moles; // EoS let options = UVTheoryOptions { @@ -242,7 +241,7 @@ mod test { let reduced_density = 0.9; let moles = dvector![0.4, 0.6] * MOL; let total_moles = moles.sum(); - let volume = (p.sigma[0] * ANGSTROM).powi::() / reduced_density * NAV * total_moles; + let volume = (p.sigma[0] * ANGSTROM).powi::<3>() / reduced_density * NAV * total_moles; // EoS let eos_wca = &UVTheory::new(parameters); @@ -267,7 +266,7 @@ mod test { // state let reduced_temperature = 1.5; let t_x = reduced_temperature * p.epsilon_k[0] * KELVIN; - let sigma_x_3 = (0.4 + 0.6 * 8.0) * ANGSTROM.powi::(); + let sigma_x_3 = (0.4 + 0.6 * 8.0) * ANGSTROM.powi::<3>(); let density = 0.52000000000000002 / sigma_x_3; let moles = dvector![0.4, 0.6] * MOL; let total_moles = moles.sum(); diff --git a/crates/feos/tests/gc_pcsaft/binary.rs b/crates/feos/tests/gc_pcsaft/binary.rs index 0419a8987..e9b9ed434 100644 --- a/crates/feos/tests/gc_pcsaft/binary.rs +++ b/crates/feos/tests/gc_pcsaft/binary.rs @@ -6,7 +6,6 @@ use feos_core::parameter::IdentifierOption; use feos_core::{Contributions, FeosResult, State}; use nalgebra::dvector; use quantity::{KELVIN, METER, MOL}; -use typenum::P3; #[test] fn test_binary() -> FeosResult<()> { @@ -70,9 +69,9 @@ fn test_polar_term() -> FeosResult<()> { let eos1 = &GcPcSaft::new(parameters1); let eos2 = &GcPcSaft::new(parameters2); let moles = dvector![0.5, 0.5] * MOL; - let p1 = State::new_nvt(&eos1, 300.0 * KELVIN, METER.powi::(), &moles)? + let p1 = State::new_nvt(&eos1, 300.0 * KELVIN, METER.powi::<3>(), &moles)? .pressure(Contributions::Total); - let p2 = State::new_nvt(&eos2, 300.0 * KELVIN, METER.powi::(), &moles)? + let p2 = State::new_nvt(&eos2, 300.0 * KELVIN, METER.powi::<3>(), &moles)? .pressure(Contributions::Total); println!("{p1} {p2}"); assert_eq!(p1, p2); diff --git a/crates/feos/tests/gc_pcsaft/dft.rs b/crates/feos/tests/gc_pcsaft/dft.rs index 5d245bfb1..a9813c56e 100644 --- a/crates/feos/tests/gc_pcsaft/dft.rs +++ b/crates/feos/tests/gc_pcsaft/dft.rs @@ -10,7 +10,6 @@ use feos_dft::{DFTSolver, Geometry}; use nalgebra::dvector; use quantity::*; use std::error::Error; -use typenum::P3; #[test] #[allow(non_snake_case)] @@ -40,7 +39,7 @@ fn test_bulk_implementation() -> Result<(), Box> { let eos = GcPcSaft::new(parameters); let func = GcPcSaftFunctional::new(parameters_func); let t = 200.0 * KELVIN; - let v = 0.002 * METER.powi::() * NAV / NAV_old; + let v = 0.002 * METER.powi::<3>() * NAV / NAV_old; let n = dvector![1.5] * MOL; let state_eos = State::new_nvt(&&eos, t, v, &n)?; let state_func = State::new_nvt(&&func, t, v, &n)?; @@ -123,7 +122,7 @@ fn test_bulk_association() -> Result<(), Box> { let func = GcPcSaftFunctional::new(func_parameters); let t = 200.0 * KELVIN; - let v = 0.002 * METER.powi::(); + let v = 0.002 * METER.powi::<3>(); let n = dvector![1.5] * MOL; let state_eos = State::new_nvt(&&eos, t, v, &n)?; let state_func = State::new_nvt(&&func, t, v, &n)?; @@ -171,13 +170,13 @@ fn test_dft() -> Result<(), Box> { assert_relative_eq!( vle.vapor().density, - 12.8820179191167643 * MOL / METER.powi::() * NAV_old / NAV, + 12.8820179191167643 * MOL / METER.powi::<3>() * NAV_old / NAV, max_relative = 1e-13, ); assert_relative_eq!( vle.liquid().density, - 13.2705903446123212 * KILO * MOL / METER.powi::() * NAV_old / NAV, + 13.2705903446123212 * KILO * MOL / METER.powi::<3>() * NAV_old / NAV, max_relative = 1e-13, ); diff --git a/crates/feos/tests/pcsaft/critical_point.rs b/crates/feos/tests/pcsaft/critical_point.rs index 48d36045c..9183e5e5d 100644 --- a/crates/feos/tests/pcsaft/critical_point.rs +++ b/crates/feos/tests/pcsaft/critical_point.rs @@ -6,7 +6,6 @@ use nalgebra::dvector; use quantity::*; use std::error::Error; use std::sync::Arc; -use typenum::P3; #[test] fn test_critical_point_pure() -> Result<(), Box> { @@ -22,7 +21,7 @@ fn test_critical_point_pure() -> Result<(), Box> { assert_relative_eq!(cp.temperature, 375.12441 * KELVIN, max_relative = 1e-8); assert_relative_eq!( cp.density, - 4733.00377 * MOL / METER.powi::(), + 4733.00377 * MOL / METER.powi::<3>(), max_relative = 1e-6 ); Ok(()) @@ -43,7 +42,7 @@ fn test_critical_point_mix() -> Result<(), Box> { assert_relative_eq!(cp.temperature, 407.93481 * KELVIN, max_relative = 1e-8); assert_relative_eq!( cp.density, - 4265.50745 * MOL / METER.powi::(), + 4265.50745 * MOL / METER.powi::<3>(), max_relative = 1e-6 ); Ok(()) diff --git a/crates/feos/tests/pcsaft/dft.rs b/crates/feos/tests/pcsaft/dft.rs index b6c6f3880..e8d057e47 100644 --- a/crates/feos/tests/pcsaft/dft.rs +++ b/crates/feos/tests/pcsaft/dft.rs @@ -12,7 +12,6 @@ use nalgebra::dvector; use ndarray::Axis; use quantity::*; use std::error::Error; -use typenum::P3; fn parameters(comp: &str) -> FeosResult { PcSaftParameters::from_json( @@ -35,7 +34,7 @@ fn test_bulk_implementations() -> Result<(), Box> { let func_full = PcSaftFunctional::new_full(parameters("water_np")?, FMTVersion::KierlikRosinberg); let t = 300.0 * KELVIN; - let v = 0.002 * METER.powi::() * NAV / NAV_old; + let v = 0.002 * METER.powi::<3>() * NAV / NAV_old; let n = dvector![1.5] * MOL; let state = State::new_nvt(&&eos, t, v, &n)?; let state_pure = State::new_nvt(&&func_pure, t, v, &n)?; @@ -135,7 +134,7 @@ fn test_dft_propane() -> Result<(), Box> { (&func_full_vec).solve_pdgt(&vle_full_vec, 198, 0, None)?.1 ); - let vapor_density = 12.2557486248527745 * MOL / METER.powi::() * NAV_old / NAV; + let vapor_density = 12.2557486248527745 * MOL / METER.powi::<3>() * NAV_old / NAV; assert_relative_eq!( vle_pure.vapor().density, vapor_density, @@ -152,7 +151,7 @@ fn test_dft_propane() -> Result<(), Box> { max_relative = 1e-13, ); - let liquid_density = 13.8941749145544549 * KILO * MOL / METER.powi::() * NAV_old / NAV; + let liquid_density = 13.8941749145544549 * KILO * MOL / METER.powi::<3>() * NAV_old / NAV; assert_relative_eq!( vle_pure.liquid().density, liquid_density, @@ -254,7 +253,7 @@ fn test_dft_water() -> Result<(), Box> { vle_full_vec.liquid().density ); - let vapor_density = 75.8045715345905222 * MOL / METER.powi::() * NAV_old / NAV; + let vapor_density = 75.8045715345905222 * MOL / METER.powi::<3>() * NAV_old / NAV; assert_relative_eq!( vle_pure.vapor().density, vapor_density, @@ -266,7 +265,7 @@ fn test_dft_water() -> Result<(), Box> { max_relative = 1e-13, ); - let liquid_density = 47.8480850281608454 * KILO * MOL / METER.powi::() * NAV_old / NAV; + let liquid_density = 47.8480850281608454 * KILO * MOL / METER.powi::<3>() * NAV_old / NAV; assert_relative_eq!( vle_pure.liquid().density, liquid_density, diff --git a/crates/feos/tests/pcsaft/state_creation_mixture.rs b/crates/feos/tests/pcsaft/state_creation_mixture.rs index e30d4058c..b7b93c73a 100644 --- a/crates/feos/tests/pcsaft/state_creation_mixture.rs +++ b/crates/feos/tests/pcsaft/state_creation_mixture.rs @@ -6,7 +6,6 @@ use feos_core::{Contributions, EquationOfState, FeosResult, StateBuilder}; use nalgebra::dvector; use quantity::*; use std::error::Error; -use typenum::P3; fn propane_butane_parameters() -> FeosResult<(PcSaftParameters, Vec)> { let saft = PcSaftParameters::from_json( @@ -61,7 +60,7 @@ fn pressure_entropy_molefracs() -> Result<(), Box> { fn volume_temperature_molefracs() -> Result<(), Box> { let saft = PcSaft::new(propane_butane_parameters()?.0); let temperature = 300.0 * KELVIN; - let volume = 1.5e-3 * METER.powi::(); + let volume = 1.5e-3 * METER.powi::<3>(); let moles = MOL; let x = dvector![0.3, 0.7]; let state = StateBuilder::new(&&saft) @@ -79,7 +78,7 @@ fn temperature_partial_density() -> Result<(), Box> { let saft = PcSaft::new(propane_butane_parameters()?.0); let temperature = 300.0 * KELVIN; let x = dvector![0.3, 0.7]; - let partial_density = x.clone() * MOL / METER.powi::(); + let partial_density = x.clone() * MOL / METER.powi::<3>(); let density = partial_density.sum(); let state = StateBuilder::new(&&saft) .temperature(temperature) @@ -98,7 +97,7 @@ fn temperature_density_molefracs() -> Result<(), Box> { let saft = PcSaft::new(propane_butane_parameters()?.0); let temperature = 300.0 * KELVIN; let x = dvector![0.3, 0.7]; - let density = MOL / METER.powi::(); + let density = MOL / METER.powi::<3>(); let state = StateBuilder::new(&&saft) .temperature(temperature) .density(density) diff --git a/crates/feos/tests/pcsaft/state_creation_pure.rs b/crates/feos/tests/pcsaft/state_creation_pure.rs index c8fea1780..d5bc9769e 100644 --- a/crates/feos/tests/pcsaft/state_creation_pure.rs +++ b/crates/feos/tests/pcsaft/state_creation_pure.rs @@ -7,7 +7,6 @@ use feos_core::{ }; use quantity::*; use std::error::Error; -use typenum::P3; fn propane_parameters() -> FeosResult<(PcSaftParameters, Vec)> { let saft = PcSaftParameters::from_json( @@ -29,7 +28,7 @@ fn propane_parameters() -> FeosResult<(PcSaftParameters, Vec)> { fn temperature_volume() -> Result<(), Box> { let saft = PcSaft::new(propane_parameters()?.0); let temperature = 300.0 * KELVIN; - let volume = 1.5e-3 * METER.powi::(); + let volume = 1.5e-3 * METER.powi::<3>(); let moles = MOL; let state = StateBuilder::new(&&saft) .temperature(temperature) @@ -44,7 +43,7 @@ fn temperature_volume() -> Result<(), Box> { fn temperature_density() -> Result<(), Box> { let saft = PcSaft::new(propane_parameters()?.0); let temperature = 300.0 * KELVIN; - let density = MOL / METER.powi::(); + let density = MOL / METER.powi::<3>(); let state = StateBuilder::new(&&saft) .temperature(temperature) .density(density) @@ -58,7 +57,7 @@ fn temperature_total_moles_volume() -> Result<(), Box> { let saft = PcSaft::new(propane_parameters()?.0); let temperature = 300.0 * KELVIN; let total_moles = MOL; - let volume = METER.powi::(); + let volume = METER.powi::<3>(); let state = StateBuilder::new(&&saft) .temperature(temperature) .volume(volume) @@ -74,7 +73,7 @@ fn temperature_total_moles_density() -> Result<(), Box> { let saft = PcSaft::new(propane_parameters()?.0); let temperature = 300.0 * KELVIN; let total_moles = MOL; - let density = MOL / METER.powi::(); + let density = MOL / METER.powi::<3>(); let state = StateBuilder::new(&&saft) .temperature(temperature) .density(density) @@ -131,7 +130,7 @@ fn pressure_temperature_initial_density() -> Result<(), Box> { let state = StateBuilder::new(&&saft) .temperature(temperature) .pressure(pressure) - .initial_density(MOL / METER.powi::()) + .initial_density(MOL / METER.powi::<3>()) .build()?; assert_relative_eq!( state.pressure(Contributions::Total), diff --git a/crates/feos/tests/saftvrmie/critical_properties.rs b/crates/feos/tests/saftvrmie/critical_properties.rs index 3a4a585ed..c78eb110a 100644 --- a/crates/feos/tests/saftvrmie/critical_properties.rs +++ b/crates/feos/tests/saftvrmie/critical_properties.rs @@ -3,12 +3,11 @@ use feos::saftvrmie::{SaftVRMie, test_utils}; use feos_core::{SolverOptions, State}; use quantity::*; use std::collections::HashMap; -use typenum::P3; /// Critical data reported in Lafitte et al. pub fn critical_data() -> HashMap<&'static str, (Temperature, Pressure, MassDensity)> { let mut data = HashMap::new(); - let kg_m3 = KILOGRAM / METER.powi::(); + let kg_m3 = KILOGRAM / METER.powi::<3>(); let mpa = MEGA * PASCAL; let k = KELVIN; diff --git a/py-feos/Cargo.toml b/py-feos/Cargo.toml index 0ab58879b..c06369b72 100644 --- a/py-feos/Cargo.toml +++ b/py-feos/Cargo.toml @@ -35,7 +35,6 @@ serde_json = { workspace = true } indexmap = { workspace = true } rayon = { workspace = true, optional = true } itertools = { workspace = true } -typenum = { workspace = true } paste = { workspace = true } feos = { workspace = true } diff --git a/py-feos/src/dft/profile.rs b/py-feos/src/dft/profile.rs index 20fd4ed19..426d5b099 100644 --- a/py-feos/src/dft/profile.rs +++ b/py-feos/src/dft/profile.rs @@ -1,5 +1,7 @@ macro_rules! impl_profile { ($struct:ident, $arr:ident, $arr2:ident, $si_arr:ident, $si_arr2:ident, $py_arr2:ident, [$([$ind:expr, $ax:ident]),+]$(, $si_arr3:ident)?) => { + + #[pymethods] impl $struct { /// Calculate the residual for the given profile. @@ -118,7 +120,7 @@ macro_rules! impl_profile { fn entropy_density( &mut self, contributions: PyContributions, - ) -> PyResult>, Volume>> { + ) -> PyResult<> as std::ops::Div>::Output> { Ok(self.0.profile.entropy_density(contributions.into()).map_err(PyFeosError::from)?) } @@ -166,33 +168,33 @@ macro_rules! impl_profile { } $( #[getter] - fn get_drho_dmu(&self) -> PyResult>, MolarEnergy>> { + fn get_drho_dmu(&self) -> PyResult<> as std::ops::Div>::Output> { Ok(self.0.profile.drho_dmu().map_err(PyFeosError::from)?) } )? #[getter] - fn get_dn_dmu(&self) -> PyResult>, MolarEnergy>> { + fn get_dn_dmu(&self) -> PyResult<> as std::ops::Div>::Output> { Ok(self.0.profile.dn_dmu().map_err(PyFeosError::from)?) } #[getter] - fn get_drho_dp(&self) -> PyResult>, Pressure>> { + fn get_drho_dp(&self) -> PyResult<> as std::ops::Div>::Output> { Ok(self.0.profile.drho_dp().map_err(PyFeosError::from)?) } #[getter] - fn get_dn_dp(&self) -> PyResult>, Pressure>> { + fn get_dn_dp(&self) -> PyResult<> as std::ops::Div>::Output> { Ok(self.0.profile.dn_dp().map_err(PyFeosError::from)?) } #[getter] - fn get_drho_dt(&self) -> PyResult>, Temperature>> { + fn get_drho_dt(&self) -> PyResult<> as std::ops::Div>::Output> { Ok(self.0.profile.drho_dt().map_err(PyFeosError::from)?) } #[getter] - fn get_dn_dt(&self) -> PyResult>, Temperature>> { + fn get_dn_dt(&self) -> PyResult<> as std::ops::Div>::Output> { Ok(self.0.profile.dn_dt().map_err(PyFeosError::from)?) } } diff --git a/py-feos/src/eos/mod.rs b/py-feos/src/eos/mod.rs index 86b6a1447..1147ac020 100644 --- a/py-feos/src/eos/mod.rs +++ b/py-feos/src/eos/mod.rs @@ -7,8 +7,10 @@ use nalgebra::{DVector, DVectorView, Dyn}; use numpy::{PyArray1, PyReadonlyArray1, ToPyArray}; use pyo3::prelude::*; use quantity::*; +use std::ops::Div; use std::sync::Arc; -use typenum::Quot; + +type Quot = >::Output; mod constructors; #[cfg(feature = "epcsaft")] diff --git a/py-feos/src/phase_equilibria.rs b/py-feos/src/phase_equilibria.rs index 77f95df3d..c3786e300 100644 --- a/py-feos/src/phase_equilibria.rs +++ b/py-feos/src/phase_equilibria.rs @@ -1,10 +1,10 @@ use crate::{ - eos::{parse_molefracs, PyEquationOfState}, + PyVerbosity, + eos::{PyEquationOfState, parse_molefracs}, error::PyFeosError, ideal_gas::IdealGasModel, residual::ResidualModel, state::{PyContributions, PyState, PyStateVec}, - PyVerbosity, }; use feos_core::{ Contributions, EquationOfState, PhaseDiagram, PhaseDiagramHetero, PhaseEquilibrium, ResidualDyn, @@ -17,7 +17,6 @@ use pyo3::prelude::*; use quantity::*; use std::ops::Deref; use std::sync::Arc; -use typenum::P3; /// A thermodynamic two phase equilibrium state. #[pyclass(name = "PhaseEquilibrium")] @@ -1082,7 +1081,7 @@ impl PyPhaseDiagram { self.0 .liquid() .density() - .convert_to(MOL / METER.powi::()) + .convert_to(MOL / METER.powi::<3>()) .into_raw_vec_and_offset() .0, ); @@ -1091,7 +1090,7 @@ impl PyPhaseDiagram { self.0 .vapor() .density() - .convert_to(MOL / METER.powi::()) + .convert_to(MOL / METER.powi::<3>()) .into_raw_vec_and_offset() .0, ); @@ -1137,7 +1136,7 @@ impl PyPhaseDiagram { self.0 .liquid() .mass_density() - .convert_to(KILOGRAM / METER.powi::()) + .convert_to(KILOGRAM / METER.powi::<3>()) .into_raw_vec_and_offset() .0, ); @@ -1146,7 +1145,7 @@ impl PyPhaseDiagram { self.0 .vapor() .mass_density() - .convert_to(KILOGRAM / METER.powi::()) + .convert_to(KILOGRAM / METER.powi::<3>()) .into_raw_vec_and_offset() .0, ); diff --git a/py-feos/src/state.rs b/py-feos/src/state.rs index 2b5492d85..e7b54211c 100644 --- a/py-feos/src/state.rs +++ b/py-feos/src/state.rs @@ -1,7 +1,7 @@ use crate::eos::parse_molefracs; use crate::{ - eos::PyEquationOfState, error::PyFeosError, ideal_gas::IdealGasModel, residual::ResidualModel, - PyVerbosity, + PyVerbosity, eos::PyEquationOfState, error::PyFeosError, ideal_gas::IdealGasModel, + residual::ResidualModel, }; use feos_core::{ Contributions, DensityInitialization, EquationOfState, FeosError, ResidualDyn, State, StateVec, @@ -13,9 +13,10 @@ use pyo3::exceptions::{PyIndexError, PyValueError}; use pyo3::prelude::*; use quantity::*; use std::collections::HashMap; -use std::ops::{Deref, Neg, Sub}; +use std::ops::{Deref, Div, Neg, Sub}; use std::sync::Arc; -use typenum::{Quot, P3}; + +type Quot = >::Output; type DpDn = Quantity>::Output>; type InvT = Quantity::Output>; @@ -1633,7 +1634,7 @@ impl PyStateVec { String::from("density"), states .density() - .convert_to(MOL / METER.powi::()) + .convert_to(MOL / METER.powi::<3>()) .into_raw_vec_and_offset() .0, ); @@ -1658,7 +1659,7 @@ impl PyStateVec { String::from("mass density"), states .mass_density() - .convert_to(KILOGRAM / METER.powi::()) + .convert_to(KILOGRAM / METER.powi::<3>()) .into_raw_vec_and_offset() .0, ); From 0a47f60b0a20e2a0ff8e30537eeccdeefb519483 Mon Sep 17 00:00:00 2001 From: Philipp Rehner Date: Tue, 27 Jan 2026 09:50:15 +0100 Subject: [PATCH 02/11] Extend tp-flash to static arrays and AD --- .github/workflows/test.yml | 4 +- .github/workflows/wheels.yml | 4 +- CHANGELOG.md | 3 + crates/feos-core/src/phase_equilibria/mod.rs | 11 +- .../phase_equilibria/stability_analysis.rs | 25 ++- .../src/phase_equilibria/tp_flash.rs | 156 ++++++++++++++---- crates/feos/src/pcsaft/eos/mod.rs | 58 ++++++- 7 files changed, 207 insertions(+), 54 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c7b1ad4a..6ea6192e7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Test on: push: - branches: [main] + branches: [main, development] pull_request: - branches: [main] + branches: [main, development] env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c2fab4c05..41f501d43 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -1,9 +1,9 @@ name: Build Wheels on: push: - branches: [main] + branches: [main, development] pull_request: - branches: [main] + branches: [main, development] jobs: linux: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index f395eb34a..3ea62b79e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Breaking] +### Added +- Extended tp-flash algorithm to static numbers of components and enabled automatic differentiation for binary systems. [#336](https://github.com/feos-org/feos/pull/336) + ### Packaging - Updated `quantity` dependency to 0.13 and removed the `typenum` dependency. [#323](https://github.com/feos-org/feos/pull/323) diff --git a/crates/feos-core/src/phase_equilibria/mod.rs b/crates/feos-core/src/phase_equilibria/mod.rs index b27d97fbb..9683e49a1 100644 --- a/crates/feos-core/src/phase_equilibria/mod.rs +++ b/crates/feos-core/src/phase_equilibria/mod.rs @@ -3,8 +3,8 @@ use crate::errors::{FeosError, FeosResult}; use crate::state::{DensityInitialization, State}; use crate::{Contributions, ReferenceSystem}; use nalgebra::allocator::Allocator; -use nalgebra::{DVector, DefaultAllocator, Dim, Dyn, OVector}; -use num_dual::{DualNum, DualStruct}; +use nalgebra::{DefaultAllocator, Dim, Dyn, OVector}; +use num_dual::{DualNum, DualStruct, Gradients}; use quantity::{Energy, Moles, Pressure, RGAS, Temperature}; use std::fmt; use std::fmt::Write; @@ -168,7 +168,10 @@ where } } -impl PhaseEquilibrium { +impl, N: Gradients, const P: usize> PhaseEquilibrium +where + DefaultAllocator: Allocator, +{ pub(super) fn update_pressure( mut self, temperature: Temperature, @@ -189,7 +192,7 @@ impl PhaseEquilibrium { pub(super) fn update_moles( &mut self, pressure: Pressure, - moles: [&Moles>; P], + moles: [&Moles>; P], ) -> FeosResult<()> { for (i, s) in self.0.iter_mut().enumerate() { *s = State::new_npt( diff --git a/crates/feos-core/src/phase_equilibria/stability_analysis.rs b/crates/feos-core/src/phase_equilibria/stability_analysis.rs index e9af96e8e..69988f8c2 100644 --- a/crates/feos-core/src/phase_equilibria/stability_analysis.rs +++ b/crates/feos-core/src/phase_equilibria/stability_analysis.rs @@ -3,7 +3,9 @@ use crate::equation_of_state::Residual; use crate::errors::{FeosError, FeosResult}; use crate::state::{Contributions, DensityInitialization, State}; use crate::{ReferenceSystem, SolverOptions, Verbosity}; -use nalgebra::{DMatrix, DVector}; +use nalgebra::allocator::Allocator; +use nalgebra::{DefaultAllocator, OMatrix, OVector, U1}; +use num_dual::Gradients; use num_dual::linalg::LU; use num_dual::linalg::smallest_ev; use quantity::Moles; @@ -16,7 +18,10 @@ const MINIMIZE_KMAX: usize = 100; const ZERO_TPD: f64 = -1E-08; /// # Stability analysis -impl State { +impl, N: Gradients> State +where + DefaultAllocator: Allocator + Allocator, +{ /// Determine if the state is stable, i.e. if a phase split should /// occur or not. pub fn is_stable(&self, options: SolverOptions) -> FeosResult { @@ -26,7 +31,7 @@ impl State { /// Perform a stability analysis. The result is a list of [State]s with /// negative tangent plane distance (i.e. lower Gibbs energy) that can be /// used as initial estimates for a phase equilibrium calculation. - pub fn stability_analysis(&self, options: SolverOptions) -> FeosResult>> { + pub fn stability_analysis(&self, options: SolverOptions) -> FeosResult>> { let mut result = Vec::new(); for i_trial in 0..self.eos.components() + 1 { let phase = if i_trial == self.eos.components() { @@ -59,8 +64,9 @@ impl State { Ok(result) } - fn define_trial_state(&self, dominant_component: usize) -> FeosResult> { + fn define_trial_state(&self, dominant_component: usize) -> FeosResult> { let x_feed = &self.molefracs; + let (n, _) = x_feed.shape_generic(); let (x_trial, phase) = if dominant_component == self.eos.components() { // try an ideal vapor phase @@ -70,7 +76,7 @@ impl State { // try each component as nearly pure phase let factor = (1.0 - X_DOMINANT) / (x_feed.sum() - x_feed[dominant_component]); ( - DVector::from_fn(self.eos.components(), |i, _| { + OVector::from_fn_generic(n, U1, |i, _| { if i == dominant_component { X_DOMINANT } else { @@ -92,7 +98,7 @@ impl State { fn minimize_tpd( &self, - trial: &mut State, + trial: &mut State, options: SolverOptions, ) -> FeosResult<(Option, usize)> { let (max_iter, tol, verbosity) = options.unwrap_or(MINIMIZE_KMAX, MINIMIZE_TOL); @@ -154,9 +160,10 @@ impl State { Err(FeosError::NotConverged(String::from("stability analysis"))) } - fn stability_newton_step(&mut self, di: &DVector, tpd: &mut f64) -> FeosResult { + fn stability_newton_step(&mut self, di: &OVector, tpd: &mut f64) -> FeosResult { // save old values let tpd_old = *tpd; + let (n, _) = di.shape_generic(); // calculate residual and ideal hesse matrix let mut hesse = (self.dln_phi_dnj() * Moles::from_reduced(1.0)).into_value(); @@ -166,7 +173,7 @@ impl State { let sq_y = y.map(f64::sqrt); let gradient = (&ln_y + &lnphi - di).component_mul(&sq_y); - let hesse_ig = DMatrix::identity(self.eos.components(), self.eos.components()); + let hesse_ig = OMatrix::identity_generic(n, n); for i in 0..self.eos.components() { hesse.column_mut(i).component_mul_assign(&(sq_y[i] * &sq_y)); if y[i] > f64::EPSILON { @@ -181,7 +188,7 @@ impl State { // ! (3) objective function (tpd) does not descent // !----------------------------------------------------------------------------- let mut adjust_hessian = true; - let mut hessian: DMatrix; + let mut hessian: OMatrix; let mut eta_h = 1.0; while adjust_hessian { diff --git a/crates/feos-core/src/phase_equilibria/tp_flash.rs b/crates/feos-core/src/phase_equilibria/tp_flash.rs index baefea80b..cd9d9bea3 100644 --- a/crates/feos-core/src/phase_equilibria/tp_flash.rs +++ b/crates/feos-core/src/phase_equilibria/tp_flash.rs @@ -2,16 +2,22 @@ use super::PhaseEquilibrium; use crate::equation_of_state::Residual; use crate::errors::{FeosError, FeosResult}; use crate::state::{Contributions, State}; -use crate::{SolverOptions, Verbosity}; -use nalgebra::{DVector, Matrix3, Matrix4xX}; -use num_dual::{Dual, DualNum, first_derivative}; -use quantity::{Dimensionless, Moles, Pressure, Temperature}; +use crate::{ReferenceSystem, SolverOptions, Verbosity}; +use nalgebra::allocator::Allocator; +use nalgebra::{DefaultAllocator, Dim, Matrix3, OVector, SVector, U1, U2, vector}; +use num_dual::{ + Dual, Dual2Vec, DualNum, DualStruct, Gradients, first_derivative, implicit_derivative_sp, +}; +use quantity::{Dimensionless, MOL, MolarVolume, Moles, Pressure, Quantity, Temperature}; const MAX_ITER_TP: usize = 400; const TOL_TP: f64 = 1e-8; /// # Flash calculations -impl PhaseEquilibrium { +impl, N: Gradients> PhaseEquilibrium +where + DefaultAllocator: Allocator + Allocator, +{ /// Perform a Tp-flash calculation. If no initial values are /// given, the solution is initialized using a stability analysis. /// @@ -21,8 +27,8 @@ impl PhaseEquilibrium { eos: &E, temperature: Temperature, pressure: Pressure, - feed: &Moles>, - initial_state: Option<&PhaseEquilibrium>, + feed: &Moles>, + initial_state: Option<&PhaseEquilibrium>, options: SolverOptions, non_volatile_components: Option>, ) -> FeosResult { @@ -34,8 +40,76 @@ impl PhaseEquilibrium { } } +impl, D: DualNum + Copy> PhaseEquilibrium { + /// Perform a Tp-flash calculation for a binary mixture. + /// Compared to the version of the algorithm for a generic + /// number of components ([tp_flash](PhaseEquilibrium::tp_flash)), + /// this can be used in combination with automatic differentiation. + pub fn tp_flash_binary( + eos: &E, + temperature: Temperature, + pressure: Pressure, + feed: &Moles>, + options: SolverOptions, + ) -> FeosResult { + let z = feed.get(0).convert_into(feed.get(0) + feed.get(1)); + let total_moles = feed.sum(); + let moles = vector![z.re(), 1.0 - z.re()] * MOL; + let vle_re = State::new_npt(&eos.re(), temperature.re(), pressure.re(), &moles, None)? + .tp_flash(None, options, None)?; + + // implicit differentiation + + // specifications + let t = temperature.into_reduced(); + let p = pressure.into_reduced(); + + // molar volume and composition of the two phases + let variables = SVector::from([ + vle_re.liquid().density.into_reduced().recip(), + vle_re.vapor().density.into_reduced().recip(), + vle_re.liquid().molefracs[0], + vle_re.vapor().molefracs[0], + ]); + + // calculate derivatives for molar volumes and compositions (first component) + // with respect to t, p, or z or equation of state parameters + // using implicit differentiation of the minimum in the Gibbs energy + let [[v_l, v_v, x, y]] = implicit_derivative_sp( + |variables, &[t, p, z]: &[_; 3]| { + let [[v_l, v_v, x, y]] = variables.data.0; + let beta = (z - x) / (y - x); + let eos = eos.lift(); + let molar_gibbs_energy = |x: Dual2Vec<_, _, _>, v| { + let molefracs = vector![x, -x + 1.0]; + let a_res = eos.residual_molar_helmholtz_energy(t, v, &molefracs); + let a_ig = (x * (x / v).ln() - (x - 1.0) * ((-x + 1.0) / v).ln() - 1.0) * t; + a_res + a_ig + v * p + }; + // g = a + pv is the potential function for a tp flash using a Helmholtz energy model + // see https://www.sciencedirect.com/science/article/pii/S0378381299000928 + molar_gibbs_energy(y, v_v) * beta - molar_gibbs_energy(x, v_l) * (beta - 1.0) + }, + variables, + &[t, p, z], + ) + .data + .0; + let beta = (z - x) / (y - x); + let state = |x: D, v, phi| { + let volume = MolarVolume::from_reduced(v * phi) * total_moles; + let moles = Quantity::new(vector![x, -x + 1.0] * phi * total_moles.convert_into(MOL)); + State::new_nvt(eos, temperature, volume, &moles) + }; + Ok(Self([state(y, v_v, beta)?, state(x, v_l, -beta + 1.0)?])) + } +} + /// # Flash calculations -impl State { +impl, N: Gradients> State +where + DefaultAllocator: Allocator + Allocator, +{ /// Perform a Tp-flash calculation using the [State] as feed. /// If no initial values are given, the solution is initialized /// using a stability analysis. @@ -44,10 +118,10 @@ impl State { /// containing non-volatile components (e.g. ions). pub fn tp_flash( &self, - initial_state: Option<&PhaseEquilibrium>, + initial_state: Option<&PhaseEquilibrium>, options: SolverOptions, non_volatile_components: Option>, - ) -> FeosResult> { + ) -> FeosResult> { // initialization if let Some(init) = initial_state { let vle = self.tp_flash_( @@ -76,10 +150,10 @@ impl State { pub fn tp_flash_( &self, - mut new_vle_state: PhaseEquilibrium, + mut new_vle_state: PhaseEquilibrium, options: SolverOptions, non_volatile_components: Option>, - ) -> FeosResult> { + ) -> FeosResult> { // set options let (max_iter, tol, verbosity) = options.unwrap_or(MAX_ITER_TP, TOL_TP); @@ -92,8 +166,8 @@ impl State { verbosity, " {:4} | | {:10.8?} | {:10.8?}", 0, - new_vle_state.vapor().molefracs.data.as_vec(), - new_vle_state.liquid().molefracs.data.as_vec(), + new_vle_state.vapor().molefracs.as_slice(), + new_vle_state.liquid().molefracs.as_slice(), ); let mut iter = 0; @@ -169,7 +243,7 @@ impl State { Ok(new_vle_state) } - fn tangent_plane_distance(&self, trial_state: &State) -> f64 { + fn tangent_plane_distance(&self, trial_state: &State) -> f64 { let ln_phi_z = self.ln_phi(); let ln_phi_w = trial_state.ln_phi(); let z = &self.molefracs; @@ -178,20 +252,23 @@ impl State { } } -impl PhaseEquilibrium { +impl, N: Gradients> PhaseEquilibrium +where + DefaultAllocator: Allocator + Allocator, +{ fn accelerated_successive_substitution( &mut self, - feed_state: &State, + feed_state: &State, iter: &mut usize, max_iter: usize, tol: f64, verbosity: Verbosity, non_volatile_components: &Option>, ) -> FeosResult<()> { + let (n, _) = feed_state.molefracs.shape_generic(); for _ in 0..max_iter { // do 5 successive substitution steps and check for convergence - let mut k_vec = Matrix4xX::zeros(self.vapor().eos.components()); - // let mut k_vec = Array::zeros((4, self.vapor().eos.components())); + let mut k_vec = std::array::repeat(OVector::zeros_generic(n, U1)); if self.successive_substitution( feed_state, 5, @@ -213,16 +290,19 @@ impl PhaseEquilibrium { let gibbs = self.total_gibbs_energy(); // extrapolate K values - let delta_vec = k_vec.rows_range(1..) - k_vec.rows_range(..3); - let delta = Matrix3::from_fn(|i, j| delta_vec.row(i).dot(&delta_vec.row(j))); + let delta_vec = [ + &k_vec[1] - &k_vec[0], + &k_vec[2] - &k_vec[1], + &k_vec[3] - &k_vec[2], + ]; + let delta = Matrix3::from_fn(|i, j| delta_vec[i].dot(&delta_vec[j])); let d = delta[(0, 1)] * delta[(0, 1)] - delta[(0, 0)] * delta[(1, 1)]; let a = (delta[(0, 2)] * delta[(0, 1)] - delta[(1, 2)] * delta[(0, 0)]) / d; let b = (delta[(1, 2)] * delta[(0, 1)] - delta[(0, 2)] * delta[(1, 1)]) / d; - let mut k = (k_vec.row(3) - + ((b * delta_vec.row(1) + (a + b) * delta_vec.row(2)) / (1.0 - a - b))) - .map(f64::exp) - .transpose(); + let mut k = (&k_vec[3] + + ((b * &delta_vec[1] + (a + b) * &delta_vec[2]) / (1.0 - a - b))) + .map(f64::exp); // Set k = 0 for non-volatile components if let Some(nvc) = non_volatile_components.as_ref() { @@ -245,10 +325,10 @@ impl PhaseEquilibrium { #[expect(clippy::too_many_arguments)] fn successive_substitution( &mut self, - feed_state: &State, + feed_state: &State, iterations: usize, iter: &mut usize, - k_vec: &mut Option<&mut Matrix4xX>, + k_vec: &mut Option<&mut [OVector; 4]>, abs_tol: f64, verbosity: Verbosity, non_volatile_components: &Option>, @@ -278,8 +358,8 @@ impl PhaseEquilibrium { " {:4} | {:14.8e} | {:.8?} | {:.8?}", iter, res, - self.vapor().molefracs.data.as_vec(), - self.liquid().molefracs.data.as_vec(), + self.vapor().molefracs.as_slice(), + self.liquid().molefracs.as_slice(), ); if res < abs_tol { return Ok(true); @@ -289,16 +369,13 @@ impl PhaseEquilibrium { if let Some(k_vec) = k_vec && i >= iterations - 3 { - k_vec.set_row( - i + 3 - iterations, - &k.map(|ki| if ki > 0.0 { ki.ln() } else { 0.0 }).transpose(), - ); + k_vec[i + 3 - iterations] = k.map(|ki| if ki > 0.0 { ki.ln() } else { 0.0 }); } } Ok(false) } - fn update_states(&mut self, feed_state: &State, k: &DVector) -> FeosResult<()> { + fn update_states(&mut self, feed_state: &State, k: &OVector) -> FeosResult<()> { // calculate vapor phase fraction using Rachford-Rice algorithm let mut beta = self.vapor_phase_fraction(); beta = rachford_rice(&feed_state.molefracs, k, Some(beta))?; @@ -314,7 +391,7 @@ impl PhaseEquilibrium { Ok(()) } - fn vle_init_stability(feed_state: &State) -> FeosResult<(Self, Option)> { + fn vle_init_stability(feed_state: &State) -> FeosResult<(Self, Option)> { let mut stable_states = feed_state.stability_analysis(SolverOptions::default())?; let state1 = stable_states.pop(); let state2 = stable_states.pop(); @@ -331,7 +408,14 @@ impl PhaseEquilibrium { } } -fn rachford_rice(feed: &DVector, k: &DVector, beta_in: Option) -> FeosResult { +fn rachford_rice( + feed: &OVector, + k: &OVector, + beta_in: Option, +) -> FeosResult +where + DefaultAllocator: Allocator, +{ const MAX_ITER: usize = 10; const ABS_TOL: f64 = 1e-6; diff --git a/crates/feos/src/pcsaft/eos/mod.rs b/crates/feos/src/pcsaft/eos/mod.rs index 6b0aa8782..6514be72d 100644 --- a/crates/feos/src/pcsaft/eos/mod.rs +++ b/crates/feos/src/pcsaft/eos/mod.rs @@ -596,7 +596,7 @@ mod tests_parameter_fit { use super::*; use approx::assert_relative_eq; use feos_core::DensityInitialization::Liquid; - use feos_core::{Contributions, PropertiesAD, ReferenceSystem}; + use feos_core::{Contributions, PropertiesAD, ReferenceSystem, SolverOptions}; use feos_core::{FeosResult, ParametersAD, PhaseEquilibrium, State}; use nalgebra::{U1, U3, U8, vector}; use num_dual::{DualStruct, DualVec, partial}; @@ -1023,4 +1023,60 @@ mod tests_parameter_fit { assert_relative_eq!(grad, dt_h, max_relative = 1e-7); Ok(()) } + + #[test] + fn test_tp_flash() -> FeosResult<()> { + let (pcsaft, _) = pcsaft_binary()?; + let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let temperature = 500.0 * KELVIN; + let pressure = 44.6 * BAR; + let x = vector![0.5, 0.5]; + let vle = PhaseEquilibrium::tp_flash_binary( + &pcsaft_ad, + Temperature::from_inner(&temperature), + Pressure::from_inner(&pressure), + &Moles::from_inner(&(x * MOL)), + SolverOptions { + verbosity: feos_core::Verbosity::Iter, + tol: Some(1e-10), + ..Default::default() + }, + )?; + let beta = vle + .vapor() + .total_moles + .convert_into(vle.vapor().total_moles + vle.liquid().total_moles); + let (beta, [[grad]]) = (beta.re, beta.eps.unwrap_generic(U1, U1).data.0); + + println!("{beta:.5}"); + println!("{grad:.5?}"); + + let (params, mut kij) = pcsaft.0; + let h = 1e-7; + kij += h; + let pcsaft_h = PcSaftBinary::new(params, kij); + let vle = PhaseEquilibrium::tp_flash_binary( + &pcsaft_h, + temperature, + pressure, + &(x * MOL), + SolverOptions { + tol: Some(1e-10), + ..Default::default() + }, + )?; + let beta_h = vle + .vapor() + .total_moles + .convert_into(vle.vapor().total_moles + vle.liquid().total_moles); + let dbeta_h = (beta_h - beta) / h; + println!( + "k_ij: {:11.5} {:11.5} {:.3e}", + dbeta_h, + grad, + ((dbeta_h - grad) / grad).abs() + ); + assert_relative_eq!(grad, dbeta_h, max_relative = 1e-4); + Ok(()) + } } From cd3e2160aa7d8d49e18d319c3d5b9fe407ac0059 Mon Sep 17 00:00:00 2001 From: Philipp Rehner Date: Tue, 27 Jan 2026 15:57:59 +0100 Subject: [PATCH 03/11] Enable AD for pure-component VLEs with given pressure --- CHANGELOG.md | 2 + crates/feos-core/src/ad/mod.rs | 66 ++- .../src/equation_of_state/residual.rs | 36 +- crates/feos-core/src/phase_equilibria/mod.rs | 16 +- .../src/phase_equilibria/vle_pure.rs | 432 ++++++++++-------- .../src/state/residual_properties.rs | 9 - crates/feos/src/pcsaft/eos/mod.rs | 44 ++ py-feos/src/ad/mod.rs | 28 +- py-feos/src/lib.rs | 1 + 9 files changed, 413 insertions(+), 221 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea62b79e..444607c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Breaking] ### Added - Extended tp-flash algorithm to static numbers of components and enabled automatic differentiation for binary systems. [#336](https://github.com/feos-org/feos/pull/336) +- Rewrote `PhaseEquilibrium::pure_p` to mirror `pure_t` and enable automatic differentiation. [#337](https://github.com/feos-org/feos/pull/337) +- Added `boiling_temperature` to the list of properties for parallel evaluations of gradients. [#337](https://github.com/feos-org/feos/pull/337) ### Packaging - Updated `quantity` dependency to 0.13 and removed the `typenum` dependency. [#323](https://github.com/feos-org/feos/pull/323) diff --git a/crates/feos-core/src/ad/mod.rs b/crates/feos-core/src/ad/mod.rs index ce92d05d3..308e722d1 100644 --- a/crates/feos-core/src/ad/mod.rs +++ b/crates/feos-core/src/ad/mod.rs @@ -4,7 +4,7 @@ use crate::{FeosResult, PhaseEquilibrium, ReferenceSystem, Residual}; use nalgebra::{Const, SVector, U1, U2}; #[cfg(feature = "rayon")] use ndarray::{Array1, Array2, ArrayView2, Zip}; -use num_dual::{Derivative, DualSVec, DualStruct}; +use num_dual::{Derivative, DualNum, DualSVec, DualStruct, first_derivative, partial2}; use quantity::{Density, Pressure, Temperature}; #[cfg(feature = "rayon")] use quantity::{KELVIN, KILO, METER, MOL, PASCAL}; @@ -66,6 +66,50 @@ pub trait PropertiesAD { Ok(Pressure::from_reduced(p)) } + fn boiling_temperature( + &self, + pressure: Pressure, + ) -> FeosResult>> + where + Self: Residual>, + { + let eos_f64 = self.re(); + let (temperature, [vapor_density, liquid_density]) = + PhaseEquilibrium::pure_p(&eos_f64, pressure, None, Default::default())?; + + // implicit differentiation is implemented here instead of just calling pure_t with dual + // numbers, because for the first derivative, we can avoid calculating density derivatives. + let t = temperature.into_reduced(); + let v1 = 1.0 / liquid_density.to_reduced(); + let v2 = 1.0 / vapor_density.to_reduced(); + let p = pressure.into_reduced(); + let t = Gradient::from(t); + let t = t + { + let v1 = Gradient::from(v1); + let v2 = Gradient::from(v2); + let p = Gradient::from(p); + let x = Self::pure_molefracs(); + + let residual_entropy = |v| { + let (a, s) = first_derivative( + partial2( + |t, &v, x| self.lift().residual_molar_helmholtz_energy(t, v, x), + &v, + &x, + ), + t, + ); + (a, -s) + }; + let (a1, s1) = residual_entropy(v1); + let (a2, s2) = residual_entropy(v2); + + let ln_rho = (v1 / v2).ln(); + (p * (v2 - v1) + (a2 - a1 + t * ln_rho)) / (s2 - s1 - ln_rho) + }; + Ok(Temperature::from_reduced(t)) + } + fn equilibrium_liquid_density( &self, temperature: Temperature, @@ -111,6 +155,26 @@ pub trait PropertiesAD { ) } + #[cfg(feature = "rayon")] + fn boiling_temperature_parallel( + parameter_names: [String; P], + parameters: ArrayView2, + input: ArrayView2, + ) -> (Array1, Array2, Array1) + where + Self: ParametersAD<1>, + { + parallelize::<_, Self, _, _>( + parameter_names, + parameters, + input, + |eos: &Self::Lifted>, inp| { + eos.boiling_temperature(inp[0] * PASCAL) + .map(|p| p.convert_into(KELVIN)) + }, + ) + } + #[cfg(feature = "rayon")] fn liquid_density_parallel( parameter_names: [String; P], diff --git a/crates/feos-core/src/equation_of_state/residual.rs b/crates/feos-core/src/equation_of_state/residual.rs index 326aff883..9de69d3db 100644 --- a/crates/feos-core/src/equation_of_state/residual.rs +++ b/crates/feos-core/src/equation_of_state/residual.rs @@ -1,6 +1,9 @@ use crate::{FeosError, FeosResult, ReferenceSystem, state::StateHD}; +use nalgebra::SVector; use nalgebra::{DVector, DefaultAllocator, Dim, Dyn, OMatrix, OVector, U1, allocator::Allocator}; -use num_dual::{DualNum, Gradients, partial, partial2, second_derivative, third_derivative}; +use num_dual::{ + DualNum, Gradients, hessian, partial, partial2, second_derivative, third_derivative, +}; use quantity::ad::first_derivative; use quantity::*; use std::ops::{Deref, Div}; @@ -313,12 +316,41 @@ where molar_volume, ); ( - a * density, + a, -da + temperature * density, molar_volume * molar_volume * d2a + temperature, ) } + /// calculates a_res, p, s_res, dp_drho, dp_dt + fn p_dpdrho_dpdt( + &self, + temperature: D, + density: D, + molefracs: &OVector, + ) -> (D, D, D, D, D) { + let molar_volume = density.recip(); + let (a, da, d2a) = hessian::<_, _, _, nalgebra::U2, _>( + partial( + |vt: SVector<_, 2>, x: &OVector<_, N>| { + let [[v, t]] = vt.data.0; + self.lift().residual_molar_helmholtz_energy(t, v, x) + }, + molefracs, + ), + &SVector::from([molar_volume, temperature]), + ); + let [[da_dv, da_dt]] = da.data.0; + let [[d2a_dv2, d2a_dvdt], _] = d2a.data.0; + ( + a, + -da_dv + temperature * density, + -da_dt, + molar_volume * molar_volume * d2a_dv2 + temperature, + -d2a_dvdt + density, + ) + } + /// calculates p, dp_drho, d2p_drho2 fn p_dpdrho_d2pdrho2( &self, diff --git a/crates/feos-core/src/phase_equilibria/mod.rs b/crates/feos-core/src/phase_equilibria/mod.rs index 9683e49a1..4a10aa0fe 100644 --- a/crates/feos-core/src/phase_equilibria/mod.rs +++ b/crates/feos-core/src/phase_equilibria/mod.rs @@ -1,5 +1,5 @@ use crate::equation_of_state::Residual; -use crate::errors::{FeosError, FeosResult}; +use crate::errors::FeosResult; use crate::state::{DensityInitialization, State}; use crate::{Contributions, ReferenceSystem}; use nalgebra::allocator::Allocator; @@ -44,12 +44,6 @@ pub struct PhaseEquilibrium + C where DefaultAllocator: Allocator; -// impl Clone for PhaseEquilibrium { -// fn clone(&self) -> Self { -// Self(self.0.clone()) -// } -// } - impl fmt::Display for PhaseEquilibrium { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for (i, s) in self.0.iter().enumerate() { @@ -224,14 +218,6 @@ impl, N: Dim> PhaseEquilibrium where DefaultAllocator: Allocator, { - pub(super) fn check_trivial_solution(self) -> FeosResult { - if Self::is_trivial_solution(self.vapor(), self.liquid()) { - Err(FeosError::TrivialSolution) - } else { - Ok(self) - } - } - /// Check if the two states form a trivial solution pub fn is_trivial_solution(state1: &State, state2: &State) -> bool { let rho1 = state1.molefracs.clone() * state1.density.into_reduced(); diff --git a/crates/feos-core/src/phase_equilibria/vle_pure.rs b/crates/feos-core/src/phase_equilibria/vle_pure.rs index 5e58601af..e3575c094 100644 --- a/crates/feos-core/src/phase_equilibria/vle_pure.rs +++ b/crates/feos-core/src/phase_equilibria/vle_pure.rs @@ -5,40 +5,37 @@ use crate::errors::{FeosError, FeosResult}; use crate::state::{Contributions, DensityInitialization, State}; use crate::{ReferenceSystem, SolverOptions, TemperatureOrPressure, Verbosity}; use nalgebra::allocator::Allocator; -use nalgebra::{DVector, DefaultAllocator, Dim, dvector}; -use num_dual::{DualNum, DualStruct, Gradients}; -use quantity::{Density, Pressure, RGAS, Temperature}; +use nalgebra::{DVector, DefaultAllocator, Dim, SVector, U1, U2}; +use num_dual::{DualNum, DualStruct, Gradients, gradient, partial}; +use quantity::{Density, Pressure, Temperature}; const SCALE_T_NEW: f64 = 0.7; const MAX_ITER_PURE: usize = 50; const TOL_PURE: f64 = 1e-12; /// # Pure component phase equilibria -impl PhaseEquilibrium { +impl, N: Gradients, D: DualNum + Copy> PhaseEquilibrium +where + DefaultAllocator: Allocator + Allocator + Allocator, +{ /// Calculate a phase equilibrium for a pure component. - pub fn pure( + pub fn pure>( eos: &E, temperature_or_pressure: TP, initial_state: Option<&Self>, options: SolverOptions, ) -> FeosResult { - if let Some(t) = temperature_or_pressure.temperature() { + let (t, rho) = if let Some(t) = temperature_or_pressure.temperature() { let (_, rho) = Self::pure_t(eos, t, initial_state, options)?; - Ok(Self(rho.map(|r| { - State::new_intensive(eos, t, r, &dvector![1.0]).unwrap() - }))) + (t, rho) } else if let Some(p) = temperature_or_pressure.pressure() { - Self::pure_p(eos, p, initial_state, options) + Self::pure_p(eos, p, initial_state, options)? } else { unreachable!() - } + }; + Ok(Self(rho.map(|r| State::new_pure(eos, t, r).unwrap()))) } -} -impl, N: Gradients, D: DualNum + Copy> PhaseEquilibrium -where - DefaultAllocator: Allocator, -{ /// Calculate a phase equilibrium for a pure component /// and given temperature. pub fn pure_t( @@ -89,9 +86,9 @@ where for _ in 0..D::NDERIV { let v_l = liquid_density.recip(); let v_v = vapor_density.recip(); - let (f_l, p_l, dp_l) = eos.p_dpdrho(t, liquid_density, &x); - let (f_v, p_v, dp_v) = eos.p_dpdrho(t, vapor_density, &x); - pressure = -(f_l * v_l - f_v * v_v + t * (v_v / v_l).ln()) / (v_l - v_v); + let (a_l, p_l, dp_l) = eos.p_dpdrho(t, liquid_density, &x); + let (a_v, p_v, dp_v) = eos.p_dpdrho(t, vapor_density, &x); + pressure = -(a_l - a_v + t * (v_v / v_l).ln()) / (v_l - v_v); liquid_density += (pressure - p_l) / dp_l; vapor_density += (pressure - p_v) / dp_v; } @@ -133,15 +130,14 @@ where for i in 1..=max_iter { // calculate properties - let (f_l_res, p_l, p_rho_l) = eos.p_dpdrho(temperature, liquid_density, &x); - let (f_v_res, p_v, p_rho_v) = eos.p_dpdrho(temperature, vapor_density, &x); + let (a_l_res, p_l, p_rho_l) = eos.p_dpdrho(temperature, liquid_density, &x); + let (a_v_res, p_v, p_rho_v) = eos.p_dpdrho(temperature, vapor_density, &x); // Estimate the new pressure let v_v = vapor_density.recip(); let v_l = liquid_density.recip(); let delta_v = v_v - v_l; - let delta_a = - f_v_res * v_v - f_l_res * v_l + temperature * (vapor_density / liquid_density).ln(); + let delta_a = a_v_res - a_l_res + temperature * (vapor_density / liquid_density).ln(); let mut p_new = -delta_a / delta_v; // If the pressure becomes negative, assume the gas phase is ideal. The @@ -238,198 +234,248 @@ where Ok((p, [rho_v, rho_l])) } -impl PhaseEquilibrium { - fn new_pt(eos: &E, temperature: Temperature, pressure: Pressure) -> FeosResult { - let liquid = State::new_xpt( - eos, - temperature, - pressure, - &dvector![1.0], - Some(DensityInitialization::Liquid), - )?; - let vapor = State::new_xpt( - eos, - temperature, - pressure, - &dvector![1.0], - Some(DensityInitialization::Vapor), - )?; - Ok(Self([vapor, liquid])) - } - +impl, N: Gradients, D: DualNum + Copy> PhaseEquilibrium +where + DefaultAllocator: Allocator + Allocator + Allocator, +{ /// Calculate a phase equilibrium for a pure component /// and given pressure. - fn pure_p( + pub fn pure_p( eos: &E, - pressure: Pressure, + pressure: Pressure, initial_state: Option<&Self>, options: SolverOptions, - ) -> FeosResult { - let (max_iter, tol, verbosity) = options.unwrap_or(MAX_ITER_PURE, TOL_PURE); + ) -> FeosResult<(Temperature, [Density; 2])> { + let eos_f64 = eos.re(); + let p = pressure.into_reduced(); // Initialize the phase equilibrium - let mut vle = match initial_state { - Some(init) => init - .clone() - .update_pressure(init.vapor().temperature, pressure)?, - None => PhaseEquilibrium::init_pure_p(eos, pressure)?, + let vle = match initial_state { + Some(init) => ( + init.vapor().temperature.into_reduced().re(), + [ + init.vapor().density.into_reduced().re(), + init.liquid().density.into_reduced().re(), + ], + ), + None => init_pure_p(&eos_f64, pressure.re())?, }; + let (t, [rho_v, rho_l]) = iterate_pure_p(&eos_f64, p.re(), vle, options)?; + // Implicit differentiation + let mut temperature = D::from(t); + let mut vapor_density = D::from(rho_v); + let mut liquid_density = D::from(rho_l); + let x = E::pure_molefracs(); + for _ in 0..D::NDERIV { + let v_l = liquid_density.recip(); + let v_v = vapor_density.recip(); + let (a_l, p_l, s_l, p_rho_l, p_t_l) = + eos.p_dpdrho_dpdt(temperature, liquid_density, &x); + let (a_v, p_v, s_v, p_rho_v, p_t_v) = eos.p_dpdrho_dpdt(temperature, vapor_density, &x); + let ln_rho = (v_l / v_v).ln(); + let delta_t = + (p * (v_v - v_l) + (a_v - a_l + temperature * ln_rho)) / (s_v - s_l - ln_rho); + temperature += delta_t; + liquid_density += (p - p_l - p_t_l * delta_t) / p_rho_l; + vapor_density += (p - p_v - p_t_v * delta_t) / p_rho_v; + } + Ok(( + Temperature::from_reduced(temperature), + [ + Density::from_reduced(vapor_density), + Density::from_reduced(liquid_density), + ], + )) + } +} + +/// Calculate a phase equilibrium for a pure component +/// and given pressure. +fn iterate_pure_p, N: Dim>( + eos: &E, + pressure: f64, + (mut temperature, [mut vapor_density, mut liquid_density]): (f64, [f64; 2]), + options: SolverOptions, +) -> FeosResult<(f64, [f64; 2])> +where + DefaultAllocator: Allocator, +{ + let (max_iter, tol, verbosity) = options.unwrap_or(MAX_ITER_PURE, TOL_PURE); + let x = E::pure_molefracs(); + + log_iter!( + verbosity, + " iter | residual | temperature | liquid density | vapor density " + ); + log_iter!(verbosity, "{:-<89}", ""); + log_iter!( + verbosity, + " {:4} | | {:13.8} | {:12.8} | {:12.8}", + 0, + Temperature::from_reduced(temperature), + Density::from_reduced(liquid_density), + Density::from_reduced(vapor_density) + ); + for i in 1..=max_iter { + // calculate properties + let (a_l_res, p_l, s_l_res, p_rho_l, p_t_l) = + eos.p_dpdrho_dpdt(temperature, liquid_density, &x); + let (a_v_res, p_v, s_v_res, p_rho_v, p_t_v) = + eos.p_dpdrho_dpdt(temperature, vapor_density, &x); + + // calculate the molar volumes + let v_l = liquid_density.recip(); + let v_v = vapor_density.recip(); + + // estimate the temperature steps + let ln_rho = (v_l / v_v).ln(); + let delta_t = (pressure * (v_v - v_l) + (a_v_res - a_l_res + temperature * ln_rho)) + / (s_v_res - s_l_res - ln_rho); + temperature += delta_t; + + // calculate Newton steps for the densities and update state. + let rho_l = liquid_density + (pressure - p_l - p_t_l * delta_t) / p_rho_l; + let rho_v = vapor_density + (pressure - p_v - p_t_v * delta_t) / p_rho_v; + + if rho_l.is_sign_negative() || rho_v.is_sign_negative() || delta_t.abs() > 1.0 { + // if densities are negative or the temperature step is large use density iteration instead + liquid_density = _density_iteration( + eos, + temperature, + pressure, + &x, + DensityInitialization::InitialDensity(liquid_density), + )?; + vapor_density = _density_iteration( + eos, + temperature, + pressure, + &x, + DensityInitialization::InitialDensity(vapor_density), + )?; + } else { + liquid_density = rho_l; + vapor_density = rho_v; + } + + // check for trivial solution + if (vapor_density / liquid_density - 1.0).abs() < TRIVIAL_REL_DEVIATION { + return Err(FeosError::TrivialSolution); + } + + // check for convergence + let res = delta_t.abs(); log_iter!( verbosity, - " iter | residual | temperature | liquid density | vapor density " - ); - log_iter!(verbosity, "{:-<89}", ""); - log_iter!( - verbosity, - " {:4} | | {:13.8} | {:12.8} | {:12.8}", - 0, - vle.vapor().temperature, - vle.liquid().density, - vle.vapor().density + " {:4} | {:14.8e} | {:13.8} | {:12.8} | {:12.8}", + i, + res, + Temperature::from_reduced(temperature), + Density::from_reduced(liquid_density), + Density::from_reduced(vapor_density) ); - for i in 1..=max_iter { - // calculate the pressures and derivatives - let (p_l, p_rho_l) = vle.liquid().p_dpdrho(); - let (p_v, p_rho_v) = vle.vapor().p_dpdrho(); - let p_t_l = vle.liquid().dp_dt(Contributions::Total); - let p_t_v = vle.vapor().dp_dt(Contributions::Total); - - // calculate the residual molar entropies (already cached) - let s_l_res = vle.liquid().residual_molar_entropy(); - let s_v_res = vle.vapor().residual_molar_entropy(); - - // calculate the residual molar Helmholtz energies (already cached) - let a_l_res = vle.liquid().residual_molar_helmholtz_energy(); - let a_v_res = vle.vapor().residual_molar_helmholtz_energy(); - - // calculate the molar volumes - let v_l = 1.0 / vle.liquid().density; - let v_v = 1.0 / vle.vapor().density; - - // estimate the temperature steps - let kt = RGAS * vle.vapor().temperature; - let ln_rho = (v_l / v_v).into_value().ln(); - let delta_t = (pressure * (v_v - v_l) + (a_v_res - a_l_res + kt * ln_rho)) - / (s_v_res - s_l_res - RGAS * ln_rho); - let t_new = vle.vapor().temperature + delta_t; - - // calculate Newton steps for the densities and update state. - let rho_l = vle.liquid().density + (pressure - p_l - p_t_l * delta_t) / p_rho_l; - let rho_v = vle.vapor().density + (pressure - p_v - p_t_v * delta_t) / p_rho_v; - - if rho_l.is_sign_negative() - || rho_v.is_sign_negative() - || delta_t.abs() > Temperature::from_reduced(1.0) - { - // if densities are negative or the temperature step is large use density iteration instead - vle = vle - .update_pressure(t_new, pressure)? - .check_trivial_solution()?; - } else { - // update state - vle = Self([ - State::new_pure(eos, t_new, rho_v)?, - State::new_pure(eos, t_new, rho_l)?, - ]); - } - - // check for convergence - let res = delta_t.abs(); - log_iter!( + if res < temperature * tol { + log_result!( verbosity, - " {:4} | {:14.8e} | {:13.8} | {:12.8} | {:12.8}", - i, - res, - vle.vapor().temperature, - vle.liquid().density, - vle.vapor().density + "PhaseEquilibrium::pure_p: calculation converged in {} step(s)\n", + i ); - if res < vle.vapor().temperature * tol { - log_result!( - verbosity, - "PhaseEquilibrium::pure_p: calculation converged in {} step(s)\n", - i - ); - return Ok(vle); - } + return Ok((temperature, [vapor_density, liquid_density])); } - Err(FeosError::NotConverged("pure_p".to_owned())) } + Err(FeosError::NotConverged("pure_p".to_owned())) +} - /// Initialize a new VLE for a pure substance for a given pressure. - fn init_pure_p(eos: &E, pressure: Pressure) -> FeosResult { - let trial_temperatures = [ - Temperature::from_reduced(300.0), - Temperature::from_reduced(500.0), - Temperature::from_reduced(200.0), - ]; - let x = dvector![1.0]; - let mut vle = None; - let mut t0 = Temperature::from_reduced(1.0); - for t in trial_temperatures.iter() { - t0 = *t; - let _vle = PhaseEquilibrium::new_pt(eos, *t, pressure)?; - if !Self::is_trivial_solution(_vle.vapor(), _vle.liquid()) { - return Ok(_vle); +/// Initialize a new VLE for a pure substance for a given pressure. +fn init_pure_p, N: Gradients>( + eos: &E, + pressure: Pressure, +) -> FeosResult<(f64, [f64; 2])> +where + DefaultAllocator: Allocator + Allocator + Allocator, +{ + let trial_temperatures = [300.0, 500.0, 200.0]; + let p = pressure.into_reduced(); + let x = E::pure_molefracs(); + let mut vle = None; + for t in trial_temperatures { + let liquid_density = _density_iteration(eos, t, p, &x, DensityInitialization::Liquid)?; + let vapor_density = _density_iteration(eos, t, p, &x, DensityInitialization::Vapor)?; + let _vle = (t, [vapor_density, liquid_density]); + if (vapor_density / liquid_density - 1.0).abs() >= TRIVIAL_REL_DEVIATION { + return Ok(_vle); + } + vle = Some(_vle); + } + let Some((t0, [mut rho_v, mut rho_l])) = vle else { + unreachable!() + }; + let [mut t_v, mut t_l] = [t0, t0]; + + let cp = State::critical_point(eos, None, None, None, SolverOptions::default())?; + let cp_density = cp.density.into_reduced(); + if pressure > cp.pressure(Contributions::Total) { + return Err(FeosError::SuperCritical); + }; + + if rho_v < cp_density { + // reduce temperature of liquid phase... + for _ in 0..8 { + t_l *= SCALE_T_NEW; + rho_l = _density_iteration(eos, t_l, p, &x, DensityInitialization::Liquid)?; + if rho_l > cp_density { + break; } - vle = Some(_vle); } - - let cp = State::critical_point(eos, None, None, None, SolverOptions::default())?; - if pressure > cp.pressure(Contributions::Total) { - return Err(FeosError::SuperCritical); - }; - if let Some(mut e) = vle { - if e.vapor().density < cp.density { - for _ in 0..8 { - t0 *= SCALE_T_NEW; - e.0[1] = - State::new_xpt(eos, t0, pressure, &x, Some(DensityInitialization::Liquid))?; - if e.liquid().density > cp.density { - break; - } - } - } else { - for _ in 0..8 { - t0 /= SCALE_T_NEW; - e.0[0] = - State::new_xpt(eos, t0, pressure, &x, Some(DensityInitialization::Vapor))?; - if e.vapor().density < cp.density { - break; - } - } + } else { + // ...or increase temperature of vapor phase + for _ in 0..8 { + t_v /= SCALE_T_NEW; + rho_v = _density_iteration(eos, t_v, p, &x, DensityInitialization::Vapor)?; + if rho_v < cp_density { + break; } + } + } - for _ in 0..20 { - let h = |s: &State<_>| s.residual_enthalpy() + s.total_moles * RGAS * s.temperature; - t0 = (h(e.vapor()) - h(e.liquid())) - / (e.vapor().residual_entropy() - - e.liquid().residual_entropy() - - RGAS - * e.vapor().total_moles - * ((e.vapor().density / e.liquid().density).into_value().ln())); - let trial_state = - State::new_xpt(eos, t0, pressure, &x, Some(DensityInitialization::Vapor))?; - if trial_state.density < cp.density { - e.0[0] = trial_state; - } - let trial_state = - State::new_xpt(eos, t0, pressure, &x, Some(DensityInitialization::Liquid))?; - if trial_state.density > cp.density { - e.0[1] = trial_state; - } - if e.liquid().temperature == e.vapor().temperature { - return Ok(e); - } - } - Err(FeosError::IterationFailed( - "new_init_p: could not find proper initial state".to_owned(), - )) - } else { - unreachable!() + // determine new temperatures and assign them to either the liquid or the vapor phase until + // both phases have the same temperature + for _ in 0..20 { + let h_s = |t, v| { + let (a_res, da_res) = gradient::<_, _, _, U2, _>( + partial( + |t_v: SVector<_, _>, x| { + let [[t, v]] = t_v.data.0; + eos.lift().residual_molar_helmholtz_energy(t, v, x) + }, + &x, + ), + &SVector::from([t, v]), + ); + let [[da_res_dt, da_res_dv]] = da_res.data.0; + (a_res - t * da_res_dt - v * da_res_dv + t, -da_res_dt) + }; + let (h_l, s_l_res) = h_s(t_l, rho_l.recip()); + let (h_v, s_v_res) = h_s(t_v, rho_v.recip()); + let t = (h_v - h_l) / (s_v_res - s_l_res - (rho_v / rho_l).ln()); + let trial_density = _density_iteration(eos, t, p, &x, DensityInitialization::Vapor)?; + if trial_density < cp_density { + rho_v = trial_density; + t_v = t; + } + let trial_density = _density_iteration(eos, t, p, &x, DensityInitialization::Liquid)?; + if trial_density > cp_density { + rho_l = trial_density; + t_l = t; + } + if t_l == t_v { + return Ok((t_l, [rho_v, rho_l])); } } + Err(FeosError::IterationFailed( + "new_init_p: could not find proper initial state".to_owned(), + )) } impl PhaseEquilibrium { @@ -453,7 +499,7 @@ impl PhaseEquilibrium { .map(|i| { let pure_eos = eos.subset(&[i]); PhaseEquilibrium::pure_p(&pure_eos, pressure, None, SolverOptions::default()) - .map(|vle| vle.vapor().temperature) + .map(|(t, _)| t) .ok() }) .collect() diff --git a/crates/feos-core/src/state/residual_properties.rs b/crates/feos-core/src/state/residual_properties.rs index 554facf08..53695fdbe 100644 --- a/crates/feos-core/src/state/residual_properties.rs +++ b/crates/feos-core/src/state/residual_properties.rs @@ -233,15 +233,6 @@ where .into_value() } - // This function is designed specifically for use in density iterations - pub(crate) fn p_dpdrho(&self) -> (Pressure, as Div>>::Output) { - let dp_dv = self.dp_dv(Contributions::Total); - ( - self.pressure(Contributions::Total), - (-self.volume * dp_dv / self.density), - ) - } - /// Partial molar volume: $v_i=\left(\frac{\partial V}{\partial N_i}\right)_{T,p,N_j}$ pub fn partial_molar_volume(&self) -> MolarVolume> { -self.dp_dni(Contributions::Total) / self.dp_dv(Contributions::Total) diff --git a/crates/feos/src/pcsaft/eos/mod.rs b/crates/feos/src/pcsaft/eos/mod.rs index 6514be72d..45d3635da 100644 --- a/crates/feos/src/pcsaft/eos/mod.rs +++ b/crates/feos/src/pcsaft/eos/mod.rs @@ -685,6 +685,50 @@ mod tests_parameter_fit { Ok(()) } + #[test] + fn test_boiling_temperature_derivatives_fit() -> FeosResult<()> { + let pcsaft = pcsaft_non_assoc(); + let pcsaft_ad = pcsaft.named_derivatives(["m", "sigma", "epsilon_k"]); + let pressure = BAR; + let t = pcsaft_ad.boiling_temperature(pressure)?; + let t = t.convert_into(KELVIN); + let (t, grad) = (t.re, t.eps.unwrap_generic(U3, U1)); + + println!("{t:.5}"); + println!("{grad:.5?}"); + + let (t_check, _) = PhaseEquilibrium::pure_p( + &pcsaft_ad, + Pressure::from_inner(&pressure), + None, + Default::default(), + )?; + let t_check = t_check.convert_into(KELVIN); + let (t_check, grad_check) = (t_check.re, t_check.eps.unwrap_generic(U3, U1)); + println!("{t_check:.5}"); + println!("{grad_check:.5?}"); + assert_relative_eq!(t, t_check, max_relative = 1e-15); + assert_relative_eq!(grad, grad_check, max_relative = 1e-15); + + for (i, par) in ["m", "sigma", "epsilon_k"].into_iter().enumerate() { + let mut params = pcsaft.0; + let h = params[i] * 1e-8; + params[i] += h; + let pcsaft_h = PcSaftPure(params); + let (t_h, _) = PhaseEquilibrium::pure_p(&pcsaft_h, pressure, None, Default::default())?; + let dt_h = (t_h.convert_into(KELVIN) - t) / h; + let dt = grad[i]; + println!( + "{par:12}: {:11.5} {:11.5} {:.3e}", + dt_h, + dt, + ((dt_h - dt) / dt).abs() + ); + assert_relative_eq!(dt, dt_h, max_relative = 1e-6); + } + Ok(()) + } + #[test] fn test_equilibrium_liquid_density_derivatives_fit() -> FeosResult<()> { let pcsaft = pcsaft_non_assoc(); diff --git a/py-feos/src/ad/mod.rs b/py-feos/src/ad/mod.rs index 1286f6c14..c18d1eacb 100644 --- a/py-feos/src/ad/mod.rs +++ b/py-feos/src/ad/mod.rs @@ -57,6 +57,32 @@ pub fn vapor_pressure_derivatives<'py>( _vapor_pressure_derivatives(model, parameter_names, parameters, input) } +/// Calculate boiling temperatures and derivatives w.r.t. model parameters. +/// +/// Parameters +/// ---------- +/// model: EquationOfStateAD +/// The equation of state to use. +/// parameter_names: List[string] +/// The name of the parameters for which derivatives are calculated. +/// parameters: np.ndarray[float] +/// The parameters for every data point. +/// input: np.ndarray[float] +/// The pressure (in Pa) for every data point. +/// +/// Returns +/// ------- +/// (np.ndarray[float], np.ndarray[float], np.ndarray[bool]): The boiling temperature (in K), gradients, and convergence status. +#[pyfunction] +pub fn boiling_temperature_derivatives<'py>( + model: PyEquationOfStateAD, + parameter_names: Bound<'py, PyAny>, + parameters: PyReadonlyArray2, + input: PyReadonlyArray2, +) -> GradResult<'py> { + _boiling_temperature_derivatives(model, parameter_names, parameters, input) +} + /// Calculate liquid densities and derivatives w.r.t. model parameters. /// /// Parameters @@ -222,7 +248,7 @@ macro_rules! impl_evaluate_gradients { impl_evaluate_gradients!( pure, - [vapor_pressure, liquid_density, equilibrium_liquid_density], + [vapor_pressure, boiling_temperature, liquid_density, equilibrium_liquid_density], {PcSaftNonAssoc: PcSaftPure, PcSaftFull: PcSaftPure} ); diff --git a/py-feos/src/lib.rs b/py-feos/src/lib.rs index 0886c3eb0..948a24d6b 100644 --- a/py-feos/src/lib.rs +++ b/py-feos/src/lib.rs @@ -189,6 +189,7 @@ fn feos(m: &Bound<'_, PyModule>) -> PyResult<()> { #[cfg(feature = "ad")] { m.add_function(wrap_pyfunction!(ad::vapor_pressure_derivatives, m)?)?; + m.add_function(wrap_pyfunction!(ad::boiling_temperature_derivatives, m)?)?; m.add_function(wrap_pyfunction!(ad::liquid_density_derivatives, m)?)?; m.add_function(wrap_pyfunction!( ad::equilibrium_liquid_density_derivatives, From d97cb980dde161f56488a8b08852208b186e883b Mon Sep 17 00:00:00 2001 From: Philipp Rehner Date: Tue, 27 Jan 2026 09:50:59 +0100 Subject: [PATCH 04/11] More rigorous treatment of extensive states --- crates/feos-core/src/ad/mod.rs | 18 +- crates/feos-core/src/cubic.rs | 2 +- crates/feos-core/src/density_iteration.rs | 2 +- crates/feos-core/src/equation_of_state/mod.rs | 17 +- .../src/equation_of_state/residual.rs | 180 +++---- crates/feos-core/src/errors.rs | 2 +- crates/feos-core/src/lib.rs | 32 +- .../src/phase_equilibria/bubble_dew.rs | 61 +-- crates/feos-core/src/phase_equilibria/mod.rs | 76 +-- .../phase_equilibria/phase_diagram_binary.rs | 93 ++-- .../phase_equilibria/phase_diagram_pure.rs | 4 +- .../src/phase_equilibria/phase_envelope.rs | 8 +- .../phase_equilibria/stability_analysis.rs | 10 +- .../src/phase_equilibria/tp_flash.rs | 32 +- .../src/phase_equilibria/vle_pure.rs | 19 +- crates/feos-core/src/state/builder.rs | 251 ---------- crates/feos-core/src/state/cache.rs | 14 +- crates/feos-core/src/state/composition.rs | 304 +++++++++++ crates/feos-core/src/state/critical_point.rs | 41 +- crates/feos-core/src/state/mod.rs | 473 +++++++----------- crates/feos-core/src/state/properties.rs | 106 ++-- .../src/state/residual_properties.rs | 276 +++++----- crates/feos-core/src/state/statevec.rs | 2 +- crates/feos-derive/src/residual.rs | 20 +- crates/feos-dft/src/adsorption/mod.rs | 93 ++-- crates/feos-dft/src/adsorption/pore.rs | 9 +- crates/feos-dft/src/interface/mod.rs | 13 +- crates/feos-dft/src/pdgt.rs | 6 +- crates/feos-dft/src/profile/mod.rs | 13 +- crates/feos-dft/src/profile/properties.rs | 4 +- .../src/solvation/pair_correlation.rs | 6 +- crates/feos/benches/contributions.rs | 2 +- crates/feos/benches/dft_pore.rs | 9 +- crates/feos/benches/dual_numbers.rs | 10 +- crates/feos/benches/dual_numbers_saftvrmie.rs | 10 +- crates/feos/benches/state_creation.rs | 10 +- crates/feos/src/epcsaft/eos/mod.rs | 6 +- crates/feos/src/gc_pcsaft/eos/ad.rs | 2 +- crates/feos/src/ideal_gas/dippr.rs | 20 +- crates/feos/src/ideal_gas/joback.rs | 12 +- crates/feos/src/lib.rs | 2 +- crates/feos/src/multiparameter/mod.rs | 15 +- crates/feos/src/pcsaft/eos/mod.rs | 77 ++- crates/feos/src/pcsaft/eos/pcsaft_binary.rs | 2 +- crates/feos/src/pcsaft/eos/pcsaft_pure.rs | 4 +- crates/feos/src/pets/eos/mod.rs | 2 +- crates/feos/src/uvtheory/eos/mod.rs | 5 +- crates/feos/tests/gc_pcsaft/binary.rs | 4 +- crates/feos/tests/gc_pcsaft/dft.rs | 11 +- crates/feos/tests/pcsaft/critical_point.rs | 8 +- crates/feos/tests/pcsaft/dft.rs | 28 +- crates/feos/tests/pcsaft/properties.rs | 18 +- .../tests/pcsaft/state_creation_mixture.rs | 43 +- .../feos/tests/pcsaft/state_creation_pure.rs | 202 +++----- .../tests/saftvrmie/critical_properties.rs | 2 +- py-feos/src/ad/mod.rs | 14 +- py-feos/src/dft/adsorption/mod.rs | 52 +- py-feos/src/eos/mod.rs | 167 ++++--- py-feos/src/parameter/mod.rs | 1 + py-feos/src/phase_equilibria.rs | 26 +- py-feos/src/state.rs | 116 ++--- 61 files changed, 1437 insertions(+), 1630 deletions(-) delete mode 100644 crates/feos-core/src/state/builder.rs create mode 100644 crates/feos-core/src/state/composition.rs diff --git a/crates/feos-core/src/ad/mod.rs b/crates/feos-core/src/ad/mod.rs index 308e722d1..aae9fc198 100644 --- a/crates/feos-core/src/ad/mod.rs +++ b/crates/feos-core/src/ad/mod.rs @@ -57,8 +57,8 @@ pub trait PropertiesAD { let v2 = Gradient::from(v2); let x = Self::pure_molefracs(); - let a1 = self.residual_molar_helmholtz_energy(t, v1, &x); - let a2 = self.residual_molar_helmholtz_energy(t, v2, &x); + let a1 = self.residual_helmholtz_energy(t, v1, &x); + let a2 = self.residual_helmholtz_energy(t, v2, &x); (a1, a2) }; @@ -93,7 +93,7 @@ pub trait PropertiesAD { let residual_entropy = |v| { let (a, s) = first_derivative( partial2( - |t, &v, x| self.lift().residual_molar_helmholtz_energy(t, v, x), + |t, &v, x| self.lift().residual_helmholtz_energy(t, v, x), &v, &x, ), @@ -248,16 +248,16 @@ pub trait PropertiesAD { let y = y.map(Gradient::from); let x = liquid_molefracs.map(Gradient::from); - let a_v = self.residual_molar_helmholtz_energy(t, v_v, &y); + let a_v = self.residual_helmholtz_energy(t, v_v, &y); let (p_l, mu_res_l, dp_l, dmu_l) = self.dmu_dv(t, v_l, &x); let vi_l = dmu_l / dp_l; let v_l = vi_l.dot(&y); let a_l = (mu_res_l - vi_l * p_l).dot(&y); (a_l, a_v, v_l, v_v) }; - let rho_l = vle.liquid().partial_density.to_reduced(); + let rho_l = vle.liquid().partial_density().to_reduced(); let rho_l = [rho_l[0], rho_l[1]]; - let rho_v = vle.vapor().partial_density.to_reduced(); + let rho_v = vle.vapor().partial_density().to_reduced(); let rho_v = [rho_v[0], rho_v[1]]; let p = -(a_v - a_l + t * (y[0] * (rho_v[0] / rho_l[0]).ln() + y[1] * (rho_v[1] / rho_l[1]).ln() - 1.0)) @@ -298,16 +298,16 @@ pub trait PropertiesAD { let x = x.map(Gradient::from); let y = vapor_molefracs.map(Gradient::from); - let a_l = self.residual_molar_helmholtz_energy(t, v_l, &x); + let a_l = self.residual_helmholtz_energy(t, v_l, &x); let (p_v, mu_res_v, dp_v, dmu_v) = self.dmu_dv(t, v_v, &y); let vi_v = dmu_v / dp_v; let v_v = vi_v.dot(&x); let a_v = (mu_res_v - vi_v * p_v).dot(&x); (a_l, a_v, v_l, v_v) }; - let rho_l = vle.liquid().partial_density.to_reduced(); + let rho_l = vle.liquid().partial_density().to_reduced(); let rho_l = [rho_l[0], rho_l[1]]; - let rho_v = vle.vapor().partial_density.to_reduced(); + let rho_v = vle.vapor().partial_density().to_reduced(); let rho_v = [rho_v[0], rho_v[1]]; let p = -(a_l - a_v + t * (x[0] * (rho_l[0] / rho_v[0]).ln() + x[1] * (rho_l[1] / rho_v[1]).ln() - 1.0)) diff --git a/crates/feos-core/src/cubic.rs b/crates/feos-core/src/cubic.rs index 562422b05..a12f615e5 100644 --- a/crates/feos-core/src/cubic.rs +++ b/crates/feos-core/src/cubic.rs @@ -221,7 +221,7 @@ mod tests { let parameters = PengRobinsonParameters::new_pure(propane)?; let pr = PengRobinson::new(parameters); let options = SolverOptions::new().verbosity(Verbosity::Iter); - let cp = State::critical_point(&&pr, None, None, None, options)?; + let cp = State::critical_point(&&pr, (), None, None, options)?; println!("{} {}", cp.temperature, cp.pressure(Contributions::Total)); assert_relative_eq!(cp.temperature, tc * KELVIN, max_relative = 1e-4); assert_relative_eq!( diff --git a/crates/feos-core/src/density_iteration.rs b/crates/feos-core/src/density_iteration.rs index a54cfd8b9..aaee68ec1 100644 --- a/crates/feos-core/src/density_iteration.rs +++ b/crates/feos-core/src/density_iteration.rs @@ -87,7 +87,7 @@ where let (a_res, da_res) = first_derivative( |molar_volume| { eos.lift() - .residual_molar_helmholtz_energy(t, molar_volume, &x) + .residual_helmholtz_energy(t, molar_volume, &x) }, molar_volume, ); diff --git a/crates/feos-core/src/equation_of_state/mod.rs b/crates/feos-core/src/equation_of_state/mod.rs index fa72f6997..dc849068f 100644 --- a/crates/feos-core/src/equation_of_state/mod.rs +++ b/crates/feos-core/src/equation_of_state/mod.rs @@ -1,9 +1,10 @@ -use crate::{ReferenceSystem, StateHD}; +use crate::ReferenceSystem; +use crate::state::StateHD; use nalgebra::{ Const, DVector, DefaultAllocator, Dim, Dyn, OVector, SVector, U1, allocator::Allocator, }; use num_dual::DualNum; -use quantity::{Energy, MolarEnergy, Moles, Temperature, Volume}; +use quantity::{Dimensionless, MolarEnergy, MolarVolume, Temperature}; use std::ops::Deref; mod residual; @@ -164,17 +165,17 @@ where fn ideal_gas_helmholtz_energy + Copy>( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, - ) -> Energy { + volume: MolarVolume, + moles: &OVector, + ) -> MolarEnergy { let total_moles = moles.sum(); let molefracs = moles / total_moles; - let molar_volume = volume / total_moles; + let molar_volume = volume.into_reduced() / total_moles; MolarEnergy::from_reduced(self.ideal_gas_molar_helmholtz_energy( temperature.into_reduced(), - molar_volume.into_reduced(), + molar_volume, &molefracs, - )) * total_moles + )) * Dimensionless::new(total_moles) } } diff --git a/crates/feos-core/src/equation_of_state/residual.rs b/crates/feos-core/src/equation_of_state/residual.rs index 9de69d3db..af5fc88f6 100644 --- a/crates/feos-core/src/equation_of_state/residual.rs +++ b/crates/feos-core/src/equation_of_state/residual.rs @@ -1,6 +1,7 @@ -use crate::{FeosError, FeosResult, ReferenceSystem, state::StateHD}; -use nalgebra::SVector; -use nalgebra::{DVector, DefaultAllocator, Dim, Dyn, OMatrix, OVector, U1, allocator::Allocator}; +use crate::state::StateHD; +use crate::{Composition, FeosResult, ReferenceSystem}; +use nalgebra::allocator::Allocator; +use nalgebra::{DVector, DefaultAllocator, Dim, Dyn, OMatrix, OVector, SVector, U1, U2}; use num_dual::{ DualNum, Gradients, hessian, partial, partial2, second_derivative, third_derivative, }; @@ -143,74 +144,42 @@ where .fold(D::zero(), |acc, (_, a)| acc + a) } - /// Evaluate the molar Helmholtz energy of each individual contribution - /// and return them together with a string representation of the contribution. - fn molar_helmholtz_energy_contributions( + /// Evaluate the Helmholtz energy of each individual contribution and return them + /// together with a string representation of the contribution. + fn helmholtz_energy_contributions( &self, temperature: D, - molar_volume: D, - molefracs: &OVector, + volume: D, + moles: &OVector, ) -> Vec<(&'static str, D)> { - let state = StateHD::new(temperature, molar_volume, molefracs); + let state = StateHD::new(temperature, volume, moles); self.reduced_helmholtz_energy_density_contributions(&state) .into_iter() - .map(|(n, f)| (n, f * temperature * molar_volume)) + .map(|(n, f)| (n, f * temperature * volume)) .collect() } - /// Evaluate the residual molar Helmholtz energy $a^\mathrm{res}$. - fn residual_molar_helmholtz_energy( - &self, - temperature: D, - molar_volume: D, - molefracs: &OVector, - ) -> D { - let state = StateHD::new(temperature, molar_volume, molefracs); - self.reduced_residual_helmholtz_energy_density(&state) * temperature * molar_volume - } - /// Evaluate the residual Helmholtz energy $A^\mathrm{res}$. fn residual_helmholtz_energy(&self, temperature: D, volume: D, moles: &OVector) -> D { - let state = StateHD::new_density(temperature, &(moles / volume)); + let state = StateHD::new(temperature, volume, moles); self.reduced_residual_helmholtz_energy_density(&state) * temperature * volume } - /// Evaluate the residual Helmholtz energy $A^\mathrm{res}$. - fn residual_helmholtz_energy_unit( + /// Evaluate the residual molar Helmholtz energy $a^\mathrm{res}$. + /// + /// The molefracs are treated as independently variable in order to + /// calculate derivatives like the chemical potential. + fn residual_molar_helmholtz_energy( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, - ) -> Energy { - let temperature = temperature.into_reduced(); - let total_moles = moles.sum(); - let molar_volume = (volume / total_moles).into_reduced(); - let molefracs = moles / total_moles; - let state = StateHD::new(temperature, molar_volume, &molefracs); - Pressure::from_reduced(self.reduced_residual_helmholtz_energy_density(&state) * temperature) - * volume - } - - /// Check if the provided optional molar concentration is consistent with the - /// equation of state. - /// - /// In general, the number of elements in `molefracs` needs to match the number - /// of components of the equation of state. For a pure component, however, - /// no molefracs need to be provided. - fn validate_molefracs(&self, molefracs: &Option>) -> FeosResult> { - let l = molefracs.as_ref().map_or(1, |m| m.len()); - if self.components() == l { - match molefracs { - Some(m) => Ok(m.clone()), - None => Ok(OVector::from_element_generic( - N::from_usize(1), - U1, - D::one(), - )), - } - } else { - Err(FeosError::IncompatibleComponents(self.components(), l)) - } + molar_volume: MolarVolume, + molefracs: &OVector, + ) -> MolarEnergy { + MolarEnergy::from_reduced(self.residual_helmholtz_energy( + temperature.into_reduced(), + molar_volume.into_reduced(), + molefracs, + )) } /// Calculate the maximum density. @@ -219,18 +188,18 @@ where /// equilibria and other iterations. It is not explicitly meant to /// be a mathematical limit for the density (if those exist in the /// equation of state anyways). - fn max_density(&self, molefracs: &Option>) -> FeosResult> { - let x = self.validate_molefracs(molefracs)?; + fn max_density>(&self, composition: X) -> FeosResult> { + let (x, _) = composition.into_molefracs(self)?; Ok(Density::from_reduced(self.compute_max_density(&x))) } /// Calculate the second virial coefficient $B(T)$ - fn second_virial_coefficient( + fn second_virial_coefficient>( &self, temperature: Temperature, - molefracs: &Option>, - ) -> MolarVolume { - let x = self.validate_molefracs(molefracs).unwrap(); + composition: X, + ) -> FeosResult> { + let (x, _) = composition.into_molefracs(self)?; let (_, _, d2f) = second_derivative( partial2( |rho, &t, x| { @@ -244,16 +213,16 @@ where D::from(0.0), ); - Quantity::from_reduced(d2f * 0.5) + Ok(Quantity::from_reduced(d2f * 0.5)) } /// Calculate the third virial coefficient $C(T)$ - fn third_virial_coefficient( + fn third_virial_coefficient>( &self, temperature: Temperature, - molefracs: &Option>, - ) -> Quot, Density> { - let x = self.validate_molefracs(molefracs).unwrap(); + composition: X, + ) -> FeosResult, Density>> { + let (x, _) = composition.into_molefracs(self)?; let (_, _, _, d3f) = third_derivative( partial2( |rho, &t, x| { @@ -267,36 +236,43 @@ where D::from(0.0), ); - Quantity::from_reduced(d3f / 3.0) + Ok(Quantity::from_reduced(d3f / 3.0)) } /// Calculate the temperature derivative of the second virial coefficient $B'(T)$ - fn second_virial_coefficient_temperature_derivative( + fn second_virial_coefficient_temperature_derivative>( &self, temperature: Temperature, - molefracs: &Option>, - ) -> Quot, Temperature> { + composition: X, + ) -> FeosResult, Temperature>> { + let (molefracs, _) = composition.into_molefracs(self)?; let (_, db_dt) = first_derivative( partial( - |t, x| self.lift().second_virial_coefficient(t, x), - molefracs, + |t, x: &OVector<_, _>| self.lift().second_virial_coefficient(t, x).unwrap(), + &molefracs, ), temperature, ); - db_dt + Ok(db_dt) } /// Calculate the temperature derivative of the third virial coefficient $C'(T)$ - fn third_virial_coefficient_temperature_derivative( + #[expect(clippy::type_complexity)] + fn third_virial_coefficient_temperature_derivative>( &self, temperature: Temperature, - molefracs: &Option>, - ) -> Quot, Density>, Temperature> { + composition: X, + ) -> FeosResult, Density>, Temperature>> { + let (molefracs, _) = composition.into_molefracs(self)?; let (_, dc_dt) = first_derivative( - partial(|t, x| self.lift().third_virial_coefficient(t, x), molefracs), + partial( + // TODO: Fallible partial would be nice here... + |t, x: &OVector<_, _>| self.lift().third_virial_coefficient(t, x).unwrap(), + &molefracs, + ), temperature, ); - dc_dt + Ok(dc_dt) } // The following methods are used in phase equilibrium algorithms @@ -306,10 +282,7 @@ where let molar_volume = density.recip(); let (a, da, d2a) = second_derivative( partial2( - |molar_volume, &t, x| { - self.lift() - .residual_molar_helmholtz_energy(t, molar_volume, x) - }, + |molar_volume, &t, x| self.lift().residual_helmholtz_energy(t, molar_volume, x), &temperature, molefracs, ), @@ -330,11 +303,11 @@ where molefracs: &OVector, ) -> (D, D, D, D, D) { let molar_volume = density.recip(); - let (a, da, d2a) = hessian::<_, _, _, nalgebra::U2, _>( + let (a, da, d2a) = hessian::<_, _, _, U2, _>( partial( |vt: SVector<_, 2>, x: &OVector<_, N>| { let [[v, t]] = vt.data.0; - self.lift().residual_molar_helmholtz_energy(t, v, x) + self.lift().residual_helmholtz_energy(t, v, x) }, molefracs, ), @@ -361,10 +334,7 @@ where let molar_volume = density.recip(); let (_, da, d2a, d3a) = third_derivative( partial2( - |molar_volume, &t, x| { - self.lift() - .residual_molar_helmholtz_energy(t, molar_volume, x) - }, + |molar_volume, &t, x| self.lift().residual_helmholtz_energy(t, molar_volume, x), &temperature, molefracs, ), @@ -455,22 +425,22 @@ where fn viscosity_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &OVector, ) -> Viscosity; fn viscosity_correlation(&self, s_res: D, x: &OVector) -> D; fn diffusion_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &OVector, ) -> Diffusivity; fn diffusion_correlation(&self, s_res: D, x: &OVector) -> D; fn thermal_conductivity_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &OVector, ) -> ThermalConductivity; fn thermal_conductivity_correlation(&self, s_res: D, x: &OVector) -> D; } @@ -483,10 +453,11 @@ where fn viscosity_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &OVector, ) -> Viscosity { - self.deref().viscosity_reference(temperature, volume, moles) + self.deref() + .viscosity_reference(temperature, molar_volume, molefracs) } fn viscosity_correlation(&self, s_res: D, x: &OVector) -> D { self.deref().viscosity_correlation(s_res, x) @@ -494,10 +465,11 @@ where fn diffusion_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &OVector, ) -> Diffusivity { - self.deref().diffusion_reference(temperature, volume, moles) + self.deref() + .diffusion_reference(temperature, molar_volume, molefracs) } fn diffusion_correlation(&self, s_res: D, x: &OVector) -> D { self.deref().diffusion_correlation(s_res, x) @@ -505,11 +477,11 @@ where fn thermal_conductivity_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &OVector, ) -> ThermalConductivity { self.deref() - .thermal_conductivity_reference(temperature, volume, moles) + .thermal_conductivity_reference(temperature, molar_volume, molefracs) } fn thermal_conductivity_correlation(&self, s_res: D, x: &OVector) -> D { self.deref().thermal_conductivity_correlation(s_res, x) diff --git a/crates/feos-core/src/errors.rs b/crates/feos-core/src/errors.rs index 24834480f..f02946bc0 100644 --- a/crates/feos-core/src/errors.rs +++ b/crates/feos-core/src/errors.rs @@ -22,7 +22,7 @@ pub enum FeosError { IncompatibleComponents(usize, usize), #[error("Invalid state in {0}: {1} = {2}.")] InvalidState(String, String, f64), - #[error("Undetermined state: {0}.")] + #[error("Undetermined state: {0}")] UndeterminedState(String), #[error("System is supercritical.")] SuperCritical, diff --git a/crates/feos-core/src/lib.rs b/crates/feos-core/src/lib.rs index b51ae0653..a2552007d 100644 --- a/crates/feos-core/src/lib.rs +++ b/crates/feos-core/src/lib.rs @@ -41,7 +41,7 @@ pub use errors::{FeosError, FeosResult}; #[cfg(feature = "ndarray")] pub use phase_equilibria::{PhaseDiagram, PhaseDiagramHetero}; pub use phase_equilibria::{PhaseEquilibrium, TemperatureOrPressure}; -pub use state::{Contributions, DensityInitialization, State, StateBuilder, StateHD, StateVec}; +pub use state::{Composition, Contributions, DensityInitialization, State, StateHD, StateVec}; /// Level of detail in the iteration output. #[derive(Copy, Clone, PartialOrd, PartialEq, Eq, Default)] @@ -205,7 +205,7 @@ impl< mod tests { use crate::Contributions; use crate::FeosResult; - use crate::StateBuilder; + use crate::State; use crate::cubic::*; use crate::equation_of_state::{EquationOfState, IdealGas}; use crate::parameter::*; @@ -268,20 +268,12 @@ mod tests { let parameters = PengRobinsonParameters::new_pure(propane.clone())?; let residual = PengRobinson::new(parameters); - let sr = StateBuilder::new(&&residual) - .temperature(300.0 * KELVIN) - .pressure(1.0 * BAR) - .total_moles(2.0 * MOL) - .build()?; + let sr = State::new_npt(&&residual, 300.0 * KELVIN, 1.0 * BAR, 2.0 * MOL, None)?; let parameters = PengRobinsonParameters::new_pure(propane.clone())?; let residual = PengRobinson::new(parameters); let eos = EquationOfState::new(vec![NoIdealGas], residual); - let s = StateBuilder::new(&&eos) - .temperature(300.0 * KELVIN) - .pressure(1.0 * BAR) - .total_moles(2.0 * MOL) - .build()?; + let s = State::new_npt(&&eos, 300.0 * KELVIN, 1.0 * BAR, 2.0 * MOL, None)?; // pressure assert_relative_eq!( @@ -348,7 +340,7 @@ mod tests { ); assert_relative_eq!( s.gibbs_energy(Contributions::Residual) - - s.total_moles + - s.total_moles() * RGAS * s.temperature * s.compressibility(Contributions::Total).ln(), @@ -424,13 +416,13 @@ mod tests { max_relative = 1e-15 ); assert_relative_eq!( - s.dp_dni(Contributions::Total), - sr.dp_dni(Contributions::Total), + s.n_dp_dni(Contributions::Total), + sr.n_dp_dni(Contributions::Total), max_relative = 1e-15 ); assert_relative_eq!( - s.dp_dni(Contributions::Residual), - sr.dp_dni(Contributions::Residual), + s.n_dp_dni(Contributions::Residual), + sr.n_dp_dni(Contributions::Residual), max_relative = 1e-15 ); @@ -448,8 +440,8 @@ mod tests { max_relative = 1e-15 ); assert_relative_eq!( - s.dmu_dni(Contributions::Residual), - sr.dmu_dni(Contributions::Residual), + s.n_dmu_dni(Contributions::Residual), + sr.n_dmu_dni(Contributions::Residual), max_relative = 1e-15 ); assert_relative_eq!( @@ -462,7 +454,7 @@ mod tests { assert_relative_eq!(s.ln_phi(), sr.ln_phi(), max_relative = 1e-15); assert_relative_eq!(s.dln_phi_dt(), sr.dln_phi_dt(), max_relative = 1e-15); assert_relative_eq!(s.dln_phi_dp(), sr.dln_phi_dp(), max_relative = 1e-15); - assert_relative_eq!(s.dln_phi_dnj(), sr.dln_phi_dnj(), max_relative = 1e-15); + assert_relative_eq!(s.n_dln_phi_dnj(), sr.n_dln_phi_dnj(), max_relative = 1e-15); assert_relative_eq!( s.thermodynamic_factor(), sr.thermodynamic_factor(), diff --git a/crates/feos-core/src/phase_equilibria/bubble_dew.rs b/crates/feos-core/src/phase_equilibria/bubble_dew.rs index a50cc46a1..91ad57d3d 100644 --- a/crates/feos-core/src/phase_equilibria/bubble_dew.rs +++ b/crates/feos-core/src/phase_equilibria/bubble_dew.rs @@ -11,7 +11,7 @@ use nalgebra::{DMatrix, DVector, DefaultAllocator, Dim, Dyn, OVector, U1}; use ndarray::Array1; use num_dual::linalg::LU; use num_dual::{DualNum, DualStruct, Gradients}; -use quantity::{Density, Dimensionless, Moles, Pressure, Quantity, RGAS, SIUnit, Temperature}; +use quantity::{Density, Dimensionless, Pressure, Quantity, RGAS, SIUnit, Temperature}; const MAX_ITER_INNER: usize = 5; const TOL_INNER: f64 = 1e-9; @@ -333,7 +333,7 @@ where ) }; } - let state1 = State::new_intensive( + let state1 = State::new( eos, Temperature::from_reduced(t), Density::from_reduced(molar_volume.recip()), @@ -341,11 +341,11 @@ where )?; let rho2_total = rho2.sum(); let x2 = rho2 / rho2_total; - let state2 = State::new_intensive( + let state2 = State::new( eos, Temperature::from_reduced(t), Density::from_reduced(rho2_total), - &x2, + x2, )?; Ok(PhaseEquilibrium(if bubble { @@ -578,8 +578,8 @@ where } else { let mut t = temperature.into_reduced(); let mut p = pressure.into_reduced(); - let mut molar_volume = state1.density.into_reduced().recip(); - let mut rho2 = state2.partial_density.to_reduced(); + let mut molar_volume = state1.molar_volume.into_reduced(); + let mut rho2 = state2.partial_density().to_reduced(); let err = if iterate_p { Self::newton_step_t( &state1.eos, @@ -603,8 +603,19 @@ where }; *temperature = Temperature::from_reduced(t); *pressure = Pressure::from_reduced(p); - state1.density = Density::from_reduced(molar_volume.recip()); - state2.partial_density = Density::from_reduced(rho2); + state1 = State::new( + &state1.eos, + *temperature, + Density::from_reduced(molar_volume.recip()), + molefracs_spec, + )?; + let density = rho2.sum(); + state2 = State::new( + &state2.eos, + *temperature, + Density::from_reduced(density), + rho2 / density, + )?; Ok(err) }?; @@ -627,7 +638,7 @@ where ); Ok(( state1.density.into_reduced().recip(), - state2.partial_density.to_reduced(), + state2.partial_density().to_reduced(), )) } else { // not converged, return error @@ -752,7 +763,7 @@ where liquid_molefracs: &OVector, ) -> FeosResult<(Pressure, OVector)> { let density = 0.75 * Density::from_reduced(eos.compute_max_density(liquid_molefracs)); - let liquid = State::new_intensive(eos, temperature, density, liquid_molefracs)?; + let liquid = State::new(eos, temperature, density, liquid_molefracs)?; let v_l = liquid.partial_molar_volume(); let p_l = liquid.pressure(Contributions::Total); let mu_l = liquid.residual_chemical_potential(); @@ -776,7 +787,7 @@ where let mut x = vapor_molefracs.clone(); for _ in 0..5 { let density = Density::from_reduced(0.75 * eos.compute_max_density(&x)); - let liquid = State::new_intensive(eos, temperature, density, &x)?; + let liquid = State::new(eos, temperature, density, x)?; let v_l = liquid.partial_molar_volume(); let p_l = liquid.pressure(Contributions::Total); let mu_l = liquid.residual_chemical_potential(); @@ -804,7 +815,7 @@ where temperature: Temperature, molefracs: &OVector, ) -> FeosResult { - let [sp_v, sp_l] = State::spinodal(eos, temperature, Some(molefracs), Default::default())?; + let [sp_v, sp_l] = State::spinodal(eos, temperature, molefracs, Default::default())?; let pv = sp_v.pressure(Contributions::Total); let pl = sp_l.pressure(Contributions::Total); Ok(0.5 * (Pressure::from_reduced(0.0).max(pl) + pv)) @@ -818,7 +829,7 @@ where vapor_molefracs: Option<&OVector>, ) -> FeosResult<[State; 2]> { let liquid_state = - State::new_xpt(eos, temperature, pressure, liquid_molefracs, Some(Liquid))?; + State::new_npt(eos, temperature, pressure, liquid_molefracs, Some(Liquid))?; let xv = match vapor_molefracs { Some(xv) => xv.clone(), None => liquid_state @@ -826,7 +837,7 @@ where .map(f64::exp) .component_mul(liquid_molefracs), }; - let vapor_state = State::new_xpt(eos, temperature, pressure, &xv, Some(Vapor))?; + let vapor_state = State::new_npt(eos, temperature, pressure, xv, Some(Vapor))?; Ok([liquid_state, vapor_state]) } @@ -837,13 +848,7 @@ where vapor_molefracs: &OVector, liquid_molefracs: Option<&OVector>, ) -> FeosResult<[State; 2]> { - let vapor_state = State::new_npt( - eos, - temperature, - pressure, - &Moles::from_reduced(vapor_molefracs.clone()), - Some(Vapor), - )?; + let vapor_state = State::new_npt(eos, temperature, pressure, vapor_molefracs, Some(Vapor))?; let xl = match liquid_molefracs { Some(xl) => xl.clone(), None => { @@ -851,13 +856,13 @@ where .ln_phi() .map(f64::exp) .component_mul(vapor_molefracs); - let liquid_state = State::new_xpt(eos, temperature, pressure, &xl, Some(Liquid))?; + let liquid_state = State::new_npt(eos, temperature, pressure, xl, Some(Liquid))?; (vapor_state.ln_phi() - liquid_state.ln_phi()) .map(f64::exp) .component_mul(vapor_molefracs) } }; - let liquid_state = State::new_xpt(eos, temperature, pressure, &xl, Some(Liquid))?; + let liquid_state = State::new_npt(eos, temperature, pressure, xl, Some(Liquid))?; Ok([vapor_state, liquid_state]) } @@ -866,20 +871,20 @@ where pressure: Pressure, state1: &mut State, state2: &mut State, - moles_state2: Option<&Moles>>, + molefracs_state2: Option<&OVector>, ) -> FeosResult<()> { *state1 = State::new_npt( &state1.eos, temperature, pressure, - &state1.moles, + &state1.molefracs, Some(InitialDensity(state1.density)), )?; *state2 = State::new_npt( &state2.eos, temperature, pressure, - moles_state2.unwrap_or(&state2.moles), + molefracs_state2.unwrap_or(&state2.molefracs), Some(InitialDensity(state2.density)), )?; Ok(()) @@ -908,11 +913,11 @@ where "", "" ); - *state2 = State::new_xpt( + *state2 = State::new_npt( &state2.eos, state2.temperature, state2.pressure(Contributions::Total), - &x2, + x2, Some(InitialDensity(state2.density)), )?; Ok(err_out) diff --git a/crates/feos-core/src/phase_equilibria/mod.rs b/crates/feos-core/src/phase_equilibria/mod.rs index 4a10aa0fe..95671deb2 100644 --- a/crates/feos-core/src/phase_equilibria/mod.rs +++ b/crates/feos-core/src/phase_equilibria/mod.rs @@ -126,39 +126,41 @@ where Self([vapor, liquid]) } - /// Creates a new PhaseEquilibrium that contains two states at the - /// specified temperature, pressure and molefracs. - /// - /// The constructor can be used in custom phase equilibrium solvers or, - /// e.g., to generate initial guesses for an actual VLE solver. - /// In general, the two states generated are NOT in an equilibrium. - pub fn new_xpt( - eos: &E, - temperature: Temperature, - pressure: Pressure, - vapor_molefracs: &OVector, - liquid_molefracs: &OVector, - ) -> FeosResult { - let liquid = State::new_xpt( - eos, - temperature, - pressure, - liquid_molefracs, - Some(DensityInitialization::Liquid), - )?; - let vapor = State::new_xpt( - eos, - temperature, - pressure, - vapor_molefracs, - Some(DensityInitialization::Vapor), - )?; - Ok(Self([vapor, liquid])) - } - - pub(super) fn vapor_phase_fraction(&self) -> f64 { - (self.vapor().total_moles / (self.vapor().total_moles + self.liquid().total_moles)) - .into_value() + // /// Creates a new PhaseEquilibrium that contains two states at the + // /// specified temperature, pressure and molefracs. + // /// + // /// The constructor can be used in custom phase equilibrium solvers or, + // /// e.g., to generate initial guesses for an actual VLE solver. + // /// In general, the two states generated are NOT in an equilibrium. + // pub fn new_xpt( + // eos: &E, + // temperature: Temperature, + // pressure: Pressure, + // vapor_molefracs: &OVector, + // liquid_molefracs: &OVector, + // ) -> FeosResult { + // let liquid = State::new_xpt( + // eos, + // temperature, + // pressure, + // liquid_molefracs, + // Some(DensityInitialization::Liquid), + // )?; + // let vapor = State::new_xpt( + // eos, + // temperature, + // pressure, + // vapor_molefracs, + // Some(DensityInitialization::Vapor), + // )?; + // Ok(Self([vapor, liquid])) + // } + + pub(super) fn vapor_phase_fraction(&self) -> Option { + self.vapor() + .total_moles + .zip(self.liquid().total_moles) + .map(|(v, l)| (v / (l + v)).into_value()) } } @@ -176,7 +178,7 @@ where &s.eos, temperature, pressure, - &s.moles, + &*s, Some(DensityInitialization::InitialDensity(s.density)), )?; } @@ -203,10 +205,10 @@ where // Total Gibbs energy excluding the constant contribution RT sum_i N_i ln(\Lambda_i^3) pub(super) fn total_gibbs_energy(&self) -> Energy { self.0.iter().fold(Energy::from_reduced(0.0), |acc, s| { - let ln_rho_m1 = s.partial_density.to_reduced().map(|r| r.ln() - 1.0); + let ln_rho_m1 = s.partial_density().to_reduced().map(|r| r.ln() - 1.0); acc + s.residual_helmholtz_energy() - + s.pressure(Contributions::Total) * s.volume - + RGAS * s.temperature * s.total_moles * s.molefracs.dot(&ln_rho_m1) + + s.pressure(Contributions::Total) * s.volume() + + RGAS * s.temperature * s.total_moles() * s.molefracs.dot(&ln_rho_m1) }) } } diff --git a/crates/feos-core/src/phase_equilibria/phase_diagram_binary.rs b/crates/feos-core/src/phase_equilibria/phase_diagram_binary.rs index aaa717858..92e6932a5 100644 --- a/crates/feos-core/src/phase_equilibria/phase_diagram_binary.rs +++ b/crates/feos-core/src/phase_equilibria/phase_diagram_binary.rs @@ -1,7 +1,7 @@ use super::bubble_dew::TemperatureOrPressure; use super::{PhaseDiagram, PhaseEquilibrium}; use crate::errors::{FeosError, FeosResult}; -use crate::state::{Contributions, DensityInitialization::Vapor, State, StateBuilder}; +use crate::state::{Contributions, DensityInitialization::Vapor, State}; use crate::{ReferenceSystem, Residual, SolverOptions, Subset}; use nalgebra::{DVector, dvector, matrix, stack, vector}; use ndarray::{Array1, s}; @@ -558,21 +558,21 @@ impl PhaseEquilibrium { let p0 = (vle1.vapor().pressure(Contributions::Total) + vle2.vapor().pressure(Contributions::Total)) * 0.5; - let nv0 = (&vle1.vapor().moles + &vle2.vapor().moles) * 0.5; - let mut v = State::new_npt(eos, temperature, p0, &nv0, Some(Vapor))?; + let y0 = (&vle1.vapor().molefracs + &vle2.vapor().molefracs) * 0.5; + let mut v = State::new_npt(eos, temperature, p0, y0, Some(Vapor))?; for _ in 0..options.max_iter.unwrap_or(MAX_ITER_HETERO) { // calculate properties - let dmu_drho_l1 = (l1.dmu_dni(Contributions::Total) * l1.volume).to_reduced(); - let dmu_drho_l2 = (l2.dmu_dni(Contributions::Total) * l2.volume).to_reduced(); - let dmu_drho_v = (v.dmu_dni(Contributions::Total) * v.volume).to_reduced(); - let dp_drho_l1 = (l1.dp_dni(Contributions::Total) * l1.volume) + let dmu_drho_l1 = (l1.n_dmu_dni(Contributions::Total) * l1.molar_volume).to_reduced(); + let dmu_drho_l2 = (l2.n_dmu_dni(Contributions::Total) * l2.molar_volume).to_reduced(); + let dmu_drho_v = (v.n_dmu_dni(Contributions::Total) * v.molar_volume).to_reduced(); + let dp_drho_l1 = (l1.n_dp_dni(Contributions::Total) * l1.molar_volume) .to_reduced() .transpose(); - let dp_drho_l2 = (l2.dp_dni(Contributions::Total) * l2.volume) + let dp_drho_l2 = (l2.n_dp_dni(Contributions::Total) * l2.molar_volume) .to_reduced() .transpose(); - let dp_drho_v = (v.dp_dni(Contributions::Total) * v.volume) + let dp_drho_v = (v.n_dp_dni(Contributions::Total) * v.molar_volume) .to_reduced() .transpose(); let mu_l1_res = l1.residual_chemical_potential().to_reduced(); @@ -585,15 +585,15 @@ impl PhaseEquilibrium { // calculate residual let delta_l1v_mu_ig = (RGAS * v.temperature).to_reduced() * (l1 - .partial_density + .partial_density() .to_reduced() - .component_div(&v.partial_density.to_reduced())) + .component_div(&v.partial_density().to_reduced())) .map(f64::ln); let delta_l2v_mu_ig = (RGAS * v.temperature).to_reduced() * (l2 - .partial_density + .partial_density() .to_reduced() - .component_div(&v.partial_density.to_reduced())) + .component_div(&v.partial_density().to_reduced())) .map(f64::ln); let res = stack![ mu_l1_res - &mu_v_res + delta_l1v_mu_ig; @@ -620,11 +620,11 @@ impl PhaseEquilibrium { // apply Newton step let rho_l1 = - &l1.partial_density - &Density::from_reduced(dx.rows_range(0..2).into_owned()); + &l1.partial_density() - &Density::from_reduced(dx.rows_range(0..2).into_owned()); let rho_l2 = - &l2.partial_density - &Density::from_reduced(dx.rows_range(2..4).into_owned()); + &l2.partial_density() - &Density::from_reduced(dx.rows_range(2..4).into_owned()); let rho_v = - &v.partial_density - &Density::from_reduced(dx.rows_range(4..6).into_owned()); + &v.partial_density() - &Density::from_reduced(dx.rows_range(4..6).into_owned()); // check for negative densities for i in 0..2 { @@ -639,18 +639,9 @@ impl PhaseEquilibrium { } // update states - l1 = StateBuilder::new(eos) - .temperature(temperature) - .partial_density(&rho_l1) - .build()?; - l2 = StateBuilder::new(eos) - .temperature(temperature) - .partial_density(&rho_l2) - .build()?; - v = StateBuilder::new(eos) - .temperature(temperature) - .partial_density(&rho_v) - .build()?; + l1 = State::new_density(eos, temperature, rho_l1)?; + l2 = State::new_density(eos, temperature, rho_l2)?; + v = State::new_density(eos, temperature, rho_v)?; } Err(FeosError::NotConverged(String::from( "PhaseEquilibrium::heteroazeotrope_t", @@ -680,24 +671,24 @@ impl PhaseEquilibrium { let mut l1 = vle1.liquid().clone(); let mut l2 = vle2.liquid().clone(); let t0 = (vle1.vapor().temperature + vle2.vapor().temperature) * 0.5; - let nv0 = (&vle1.vapor().moles + &vle2.vapor().moles) * 0.5; - let mut v = State::new_npt(eos, t0, pressure, &nv0, Some(Vapor))?; + let y0 = (&vle1.vapor().molefracs + &vle2.vapor().molefracs) * 0.5; + let mut v = State::new_npt(eos, t0, pressure, y0, Some(Vapor))?; for _ in 0..options.max_iter.unwrap_or(MAX_ITER_HETERO) { // calculate properties - let dmu_drho_l1 = (l1.dmu_dni(Contributions::Total) * l1.volume).to_reduced(); - let dmu_drho_l2 = (l2.dmu_dni(Contributions::Total) * l2.volume).to_reduced(); - let dmu_drho_v = (v.dmu_dni(Contributions::Total) * v.volume).to_reduced(); + let dmu_drho_l1 = (l1.n_dmu_dni(Contributions::Total) * l1.molar_volume).to_reduced(); + let dmu_drho_l2 = (l2.n_dmu_dni(Contributions::Total) * l2.molar_volume).to_reduced(); + let dmu_drho_v = (v.n_dmu_dni(Contributions::Total) * v.molar_volume).to_reduced(); let dmu_res_dt_l1 = (l1.dmu_res_dt()).to_reduced(); let dmu_res_dt_l2 = (l2.dmu_res_dt()).to_reduced(); let dmu_res_dt_v = (v.dmu_res_dt()).to_reduced(); - let dp_drho_l1 = (l1.dp_dni(Contributions::Total) * l1.volume) + let dp_drho_l1 = (l1.n_dp_dni(Contributions::Total) * l1.molar_volume) .to_reduced() .transpose(); - let dp_drho_l2 = (l2.dp_dni(Contributions::Total) * l2.volume) + let dp_drho_l2 = (l2.n_dp_dni(Contributions::Total) * l2.molar_volume) .to_reduced() .transpose(); - let dp_drho_v = (v.dp_dni(Contributions::Total) * v.volume) + let dp_drho_v = (v.n_dp_dni(Contributions::Total) * v.molar_volume) .to_reduced() .transpose(); let dp_dt_l1 = (l1.dp_dt(Contributions::Total)).to_reduced(); @@ -712,14 +703,14 @@ impl PhaseEquilibrium { // calculate residual let delta_l1v_dmu_ig_dt = l1 - .partial_density + .partial_density() .to_reduced() - .component_div(&v.partial_density.to_reduced()) + .component_div(&v.partial_density().to_reduced()) .map(f64::ln); let delta_l2v_dmu_ig_dt = l2 - .partial_density + .partial_density() .to_reduced() - .component_div(&v.partial_density.to_reduced()) + .component_div(&v.partial_density().to_reduced()) .map(f64::ln); let delta_l1v_mu_ig = (RGAS * v.temperature).to_reduced() * &delta_l1v_dmu_ig_dt; let delta_l2v_mu_ig = (RGAS * v.temperature).to_reduced() * &delta_l2v_dmu_ig_dt; @@ -749,10 +740,11 @@ impl PhaseEquilibrium { // apply Newton step let rho_l1 = - l1.partial_density - Density::from_reduced(dx.rows_range(0..2).into_owned()); + l1.partial_density() - Density::from_reduced(dx.rows_range(0..2).into_owned()); let rho_l2 = - l2.partial_density - Density::from_reduced(dx.rows_range(2..4).into_owned()); - let rho_v = v.partial_density - Density::from_reduced(dx.rows_range(4..6).into_owned()); + l2.partial_density() - Density::from_reduced(dx.rows_range(2..4).into_owned()); + let rho_v = + v.partial_density() - Density::from_reduced(dx.rows_range(4..6).into_owned()); let t = v.temperature - Temperature::from_reduced(dx[6]); // check for negative densities and temperatures @@ -769,18 +761,9 @@ impl PhaseEquilibrium { } // update states - l1 = StateBuilder::new(eos) - .temperature(t) - .partial_density(&rho_l1) - .build()?; - l2 = StateBuilder::new(eos) - .temperature(t) - .partial_density(&rho_l2) - .build()?; - v = StateBuilder::new(eos) - .temperature(t) - .partial_density(&rho_v) - .build()?; + l1 = State::new_density(eos, t, rho_l1)?; + l2 = State::new_density(eos, t, rho_l2)?; + v = State::new_density(eos, t, rho_v)?; } Err(FeosError::NotConverged(String::from( "PhaseEquilibrium::heteroazeotrope_p", diff --git a/crates/feos-core/src/phase_equilibria/phase_diagram_pure.rs b/crates/feos-core/src/phase_equilibria/phase_diagram_pure.rs index daeb12132..2382a974c 100644 --- a/crates/feos-core/src/phase_equilibria/phase_diagram_pure.rs +++ b/crates/feos-core/src/phase_equilibria/phase_diagram_pure.rs @@ -37,7 +37,7 @@ impl PhaseDiagram { let sc = State::critical_point( eos, - None, + (), critical_temperature, None, SolverOptions::default(), @@ -104,7 +104,7 @@ impl PhaseDiagram { { let sc = State::critical_point( eos, - None, + (), critical_temperature, None, SolverOptions::default(), diff --git a/crates/feos-core/src/phase_equilibria/phase_envelope.rs b/crates/feos-core/src/phase_equilibria/phase_envelope.rs index 89610e538..0f01e7288 100644 --- a/crates/feos-core/src/phase_equilibria/phase_envelope.rs +++ b/crates/feos-core/src/phase_equilibria/phase_envelope.rs @@ -20,7 +20,7 @@ impl PhaseDiagram { let sc = State::critical_point( eos, - Some(molefracs), + molefracs, critical_temperature, None, SolverOptions::default(), @@ -69,7 +69,7 @@ impl PhaseDiagram { let sc = State::critical_point( eos, - Some(molefracs), + molefracs, critical_temperature, None, SolverOptions::default(), @@ -134,7 +134,7 @@ impl PhaseDiagram { let sc = State::critical_point( eos, - Some(molefracs), + molefracs, critical_temperature, None, SolverOptions::default(), @@ -145,7 +145,7 @@ impl PhaseDiagram { let temperatures = Temperature::linspace(min_temperature, max_temperature, npoints - 1); for ti in &temperatures { - let spinodal = State::spinodal(eos, ti, Some(molefracs), options).ok(); + let spinodal = State::spinodal(eos, ti, molefracs, options).ok(); if let Some(spinodal) = spinodal { states.push(PhaseEquilibrium(spinodal)); } diff --git a/crates/feos-core/src/phase_equilibria/stability_analysis.rs b/crates/feos-core/src/phase_equilibria/stability_analysis.rs index 69988f8c2..950f05026 100644 --- a/crates/feos-core/src/phase_equilibria/stability_analysis.rs +++ b/crates/feos-core/src/phase_equilibria/stability_analysis.rs @@ -91,7 +91,7 @@ where &self.eos, self.temperature, self.pressure(Contributions::Total), - &Moles::from_reduced(x_trial), + Moles::from_reduced(x_trial), Some(phase), ) } @@ -122,7 +122,7 @@ where &trial.eos, trial.temperature, trial.pressure(Contributions::Total), - &Moles::from_reduced(y), + Moles::from_reduced(y), Some(DensityInitialization::InitialDensity(trial.density)), )?; if (i > 4 && error > scaled_tol) || (tpd > tpd_old + 1E-05 && i > 2) { @@ -166,9 +166,9 @@ where let (n, _) = di.shape_generic(); // calculate residual and ideal hesse matrix - let mut hesse = (self.dln_phi_dnj() * Moles::from_reduced(1.0)).into_value(); + let mut hesse = self.n_dln_phi_dnj() / self.total_moles().into_reduced(); let lnphi = self.ln_phi(); - let y = self.moles.to_reduced(); + let y = self.moles().into_reduced(); let ln_y = y.map(|y| if y > f64::EPSILON { y.ln() } else { 0.0 }); let sq_y = y.map(f64::sqrt); let gradient = (&ln_y + &lnphi - di).component_mul(&sq_y); @@ -228,7 +228,7 @@ where &self.eos, self.temperature, self.pressure(Contributions::Total), - &Moles::from_reduced(y), + Moles::from_reduced(y), Some(DensityInitialization::InitialDensity(self.density)), )?; } diff --git a/crates/feos-core/src/phase_equilibria/tp_flash.rs b/crates/feos-core/src/phase_equilibria/tp_flash.rs index cd9d9bea3..e955acaf6 100644 --- a/crates/feos-core/src/phase_equilibria/tp_flash.rs +++ b/crates/feos-core/src/phase_equilibria/tp_flash.rs @@ -55,7 +55,7 @@ impl, D: DualNum + Copy> PhaseEquilibrium { let z = feed.get(0).convert_into(feed.get(0) + feed.get(1)); let total_moles = feed.sum(); let moles = vector![z.re(), 1.0 - z.re()] * MOL; - let vle_re = State::new_npt(&eos.re(), temperature.re(), pressure.re(), &moles, None)? + let vle_re = State::new_npt(&eos.re(), temperature.re(), pressure.re(), moles, None)? .tp_flash(None, options, None)?; // implicit differentiation @@ -82,7 +82,7 @@ impl, D: DualNum + Copy> PhaseEquilibrium { let eos = eos.lift(); let molar_gibbs_energy = |x: Dual2Vec<_, _, _>, v| { let molefracs = vector![x, -x + 1.0]; - let a_res = eos.residual_molar_helmholtz_energy(t, v, &molefracs); + let a_res = eos.residual_helmholtz_energy(t, v, &molefracs); let a_ig = (x * (x / v).ln() - (x - 1.0) * ((-x + 1.0) / v).ln() - 1.0) * t; a_res + a_ig + v * p }; @@ -99,7 +99,7 @@ impl, D: DualNum + Copy> PhaseEquilibrium { let state = |x: D, v, phi| { let volume = MolarVolume::from_reduced(v * phi) * total_moles; let moles = Quantity::new(vector![x, -x + 1.0] * phi * total_moles.convert_into(MOL)); - State::new_nvt(eos, temperature, volume, &moles) + State::new_nvt(eos, temperature, volume, moles) }; Ok(Self([state(y, v_v, beta)?, state(x, v_l, -beta + 1.0)?])) } @@ -184,7 +184,9 @@ where )?; // check convergence - let beta = new_vle_state.vapor_phase_fraction(); + // unwrap is safe here, because after the first successive substitution step the + // phase amounts in new_vle_state are known. + let beta = new_vle_state.vapor_phase_fraction().unwrap(); let tpd = [ self.tangent_plane_distance(new_vle_state.vapor()), self.tangent_plane_distance(new_vle_state.liquid()), @@ -377,16 +379,22 @@ where fn update_states(&mut self, feed_state: &State, k: &OVector) -> FeosResult<()> { // calculate vapor phase fraction using Rachford-Rice algorithm - let mut beta = self.vapor_phase_fraction(); - beta = rachford_rice(&feed_state.molefracs, k, Some(beta))?; + let beta = self.vapor_phase_fraction(); + let beta = rachford_rice(&feed_state.molefracs, k, beta)?; // update VLE - let v = feed_state.moles.clone().component_mul(&Dimensionless::new( - k.map(|k| beta * k / (1.0 - beta + beta * k)), - )); - let l = feed_state.moles.clone().component_mul(&Dimensionless::new( - k.map(|k| (1.0 - beta) / (1.0 - beta + beta * k)), - )); + let v = feed_state + .moles() + .clone() + .component_mul(&Dimensionless::new( + k.map(|k| beta * k / (1.0 - beta + beta * k)), + )); + let l = feed_state + .moles() + .clone() + .component_mul(&Dimensionless::new( + k.map(|k| (1.0 - beta) / (1.0 - beta + beta * k)), + )); self.update_moles(feed_state.pressure(Contributions::Total), [&v, &l])?; Ok(()) } diff --git a/crates/feos-core/src/phase_equilibria/vle_pure.rs b/crates/feos-core/src/phase_equilibria/vle_pure.rs index e3575c094..63756fd5b 100644 --- a/crates/feos-core/src/phase_equilibria/vle_pure.rs +++ b/crates/feos-core/src/phase_equilibria/vle_pure.rs @@ -3,7 +3,7 @@ use crate::density_iteration::{_density_iteration, _pressure_spinodal}; use crate::equation_of_state::{Residual, Subset}; use crate::errors::{FeosError, FeosResult}; use crate::state::{Contributions, DensityInitialization, State}; -use crate::{ReferenceSystem, SolverOptions, TemperatureOrPressure, Verbosity}; +use crate::{Composition, ReferenceSystem, SolverOptions, TemperatureOrPressure, Verbosity}; use nalgebra::allocator::Allocator; use nalgebra::{DVector, DefaultAllocator, Dim, SVector, U1, U2}; use num_dual::{DualNum, DualStruct, Gradients, gradient, partial}; @@ -17,6 +17,7 @@ const TOL_PURE: f64 = 1e-12; impl, N: Gradients, D: DualNum + Copy> PhaseEquilibrium where DefaultAllocator: Allocator + Allocator + Allocator, + (): Composition + Composition, { /// Calculate a phase equilibrium for a pure component. pub fn pure>( @@ -207,7 +208,7 @@ where let x = E::pure_molefracs(); let v = (0.75 * eos.compute_max_density(&x)).recip(); let t = temperature.into_reduced(); - let a_res = eos.residual_molar_helmholtz_energy(t, v, &x); + let a_res = eos.residual_helmholtz_energy(t, v, &x); let p = t / v * (a_res / t - 1.0).exp(); let rho_v = p / t; let rho_l = v.recip(); @@ -237,6 +238,7 @@ where impl, N: Gradients, D: DualNum + Copy> PhaseEquilibrium where DefaultAllocator: Allocator + Allocator + Allocator, + (): Composition, { /// Calculate a phase equilibrium for a pure component /// and given pressure. @@ -394,6 +396,7 @@ fn init_pure_p, N: Gradients>( ) -> FeosResult<(f64, [f64; 2])> where DefaultAllocator: Allocator + Allocator + Allocator, + (): Composition, { let trial_temperatures = [300.0, 500.0, 200.0]; let p = pressure.into_reduced(); @@ -413,7 +416,7 @@ where }; let [mut t_v, mut t_l] = [t0, t0]; - let cp = State::critical_point(eos, None, None, None, SolverOptions::default())?; + let cp = State::critical_point(eos, (), None, None, SolverOptions::default())?; let cp_density = cp.density.into_reduced(); if pressure > cp.pressure(Contributions::Total) { return Err(FeosError::SuperCritical); @@ -447,7 +450,7 @@ where partial( |t_v: SVector<_, _>, x| { let [[t, v]] = t_v.data.0; - eos.lift().residual_molar_helmholtz_energy(t, v, x) + eos.lift().residual_helmholtz_energy(t, v, x) }, &x, ), @@ -525,17 +528,17 @@ impl PhaseEquilibrium { let mut molefracs_liquid = molefracs_vapor.clone(); molefracs_vapor[i] = 1.0; molefracs_liquid[i] = 1.0; - let vapor = State::new_intensive( + let vapor = State::new( eos, vle_pure.vapor().temperature, vle_pure.vapor().density, - &molefracs_vapor, + molefracs_vapor, )?; - let liquid = State::new_intensive( + let liquid = State::new( eos, vle_pure.liquid().temperature, vle_pure.liquid().density, - &molefracs_liquid, + molefracs_liquid, )?; Ok(PhaseEquilibrium::from_states(vapor, liquid)) }) diff --git a/crates/feos-core/src/state/builder.rs b/crates/feos-core/src/state/builder.rs deleted file mode 100644 index c418a7c1f..000000000 --- a/crates/feos-core/src/state/builder.rs +++ /dev/null @@ -1,251 +0,0 @@ -use super::{DensityInitialization, State}; -use crate::Total; -use crate::equation_of_state::Residual; -use crate::errors::FeosResult; -use nalgebra::DVector; -use quantity::*; - -/// A simple tool to construct [State]s with arbitrary input parameters. -/// -/// # Examples -/// ``` -/// # use feos_core::{FeosResult, StateBuilder}; -/// # use feos_core::cubic::{PengRobinson, PengRobinsonParameters}; -/// # use quantity::*; -/// # use nalgebra::dvector; -/// # use approx::assert_relative_eq; -/// # fn main() -> FeosResult<()> { -/// // Create a state for given T,V,N -/// let eos = &PengRobinson::new(PengRobinsonParameters::new_simple(&[369.8], &[41.9 * 1e5], &[0.15], &[15.0])?); -/// let state = StateBuilder::new(&eos) -/// .temperature(300.0 * KELVIN) -/// .volume(12.5 * METER.powi::<3>()) -/// .moles(&(dvector![2.5] * MOL)) -/// .build()?; -/// assert_eq!(state.density, 0.2 * MOL / METER.powi::<3>()); -/// -/// // For a pure component, the composition does not need to be specified. -/// let eos = &PengRobinson::new(PengRobinsonParameters::new_simple(&[369.8], &[41.9 * 1e5], &[0.15], &[15.0])?); -/// let state = StateBuilder::new(&eos) -/// .temperature(300.0 * KELVIN) -/// .volume(12.5 * METER.powi::<3>()) -/// .total_moles(2.5 * MOL) -/// .build()?; -/// assert_eq!(state.density, 0.2 * MOL / METER.powi::<3>()); -/// -/// // The state can be constructed without providing any extensive property. -/// let eos = &PengRobinson::new( -/// PengRobinsonParameters::new_simple( -/// &[369.8, 305.4], -/// &[41.9 * 1e5, 48.2 * 1e5], -/// &[0.15, 0.10], -/// &[15.0, 30.0] -/// )? -/// ); -/// let state = StateBuilder::new(&eos) -/// .temperature(300.0 * KELVIN) -/// .partial_density(&(dvector![0.2, 0.6] * MOL / METER.powi::<3>())) -/// .build()?; -/// assert_relative_eq!(state.molefracs, dvector![0.25, 0.75]); -/// assert_relative_eq!(state.density, 0.8 * MOL / METER.powi::<3>()); -/// # Ok(()) -/// # } -/// ``` -#[derive(Clone)] -pub struct StateBuilder<'a, E, const IG: bool> { - eos: &'a E, - temperature: Option, - volume: Option, - density: Option, - partial_density: Option<&'a Density>>, - total_moles: Option, - moles: Option<&'a Moles>>, - molefracs: Option<&'a DVector>, - pressure: Option, - molar_enthalpy: Option, - molar_entropy: Option, - molar_internal_energy: Option, - density_initialization: Option, - initial_temperature: Option, -} - -impl<'a, E: Residual> StateBuilder<'a, E, false> { - /// Create a new `StateBuilder` for the given equation of state. - pub fn new(eos: &'a E) -> Self { - StateBuilder { - eos, - temperature: None, - volume: None, - density: None, - partial_density: None, - total_moles: None, - moles: None, - molefracs: None, - pressure: None, - molar_enthalpy: None, - molar_entropy: None, - molar_internal_energy: None, - density_initialization: None, - initial_temperature: None, - } - } -} - -impl<'a, E: Residual, const IG: bool> StateBuilder<'a, E, IG> { - /// Provide the temperature for the new state. - pub fn temperature(mut self, temperature: Temperature) -> Self { - self.temperature = Some(temperature); - self - } - - /// Provide the volume for the new state. - pub fn volume(mut self, volume: Volume) -> Self { - self.volume = Some(volume); - self - } - - /// Provide the density for the new state. - pub fn density(mut self, density: Density) -> Self { - self.density = Some(density); - self - } - - /// Provide partial densities for the new state. - pub fn partial_density(mut self, partial_density: &'a Density>) -> Self { - self.partial_density = Some(partial_density); - self - } - - /// Provide the total moles for the new state. - pub fn total_moles(mut self, total_moles: Moles) -> Self { - self.total_moles = Some(total_moles); - self - } - - /// Provide the moles for the new state. - pub fn moles(mut self, moles: &'a Moles>) -> Self { - self.moles = Some(moles); - self - } - - /// Provide the molefracs for the new state. - pub fn molefracs(mut self, molefracs: &'a DVector) -> Self { - self.molefracs = Some(molefracs); - self - } - - /// Provide the pressure for the new state. - pub fn pressure(mut self, pressure: Pressure) -> Self { - self.pressure = Some(pressure); - self - } - - /// Specify a vapor state. - pub fn vapor(mut self) -> Self { - self.density_initialization = Some(DensityInitialization::Vapor); - self - } - - /// Specify a liquid state. - pub fn liquid(mut self) -> Self { - self.density_initialization = Some(DensityInitialization::Liquid); - self - } - - /// Provide an initial density used in density iterations. - pub fn initial_density(mut self, initial_density: Density) -> Self { - self.density_initialization = Some(DensityInitialization::InitialDensity(initial_density)); - self - } -} - -impl<'a, E: Total, const IG: bool> StateBuilder<'a, E, IG> { - /// Provide the molar enthalpy for the new state. - pub fn molar_enthalpy(mut self, molar_enthalpy: MolarEnergy) -> StateBuilder<'a, E, true> { - self.molar_enthalpy = Some(molar_enthalpy); - self.convert() - } - - /// Provide the molar entropy for the new state. - pub fn molar_entropy(mut self, molar_entropy: MolarEntropy) -> StateBuilder<'a, E, true> { - self.molar_entropy = Some(molar_entropy); - self.convert() - } - - /// Provide the molar internal energy for the new state. - pub fn molar_internal_energy( - mut self, - molar_internal_energy: MolarEnergy, - ) -> StateBuilder<'a, E, true> { - self.molar_internal_energy = Some(molar_internal_energy); - self.convert() - } - - /// Provide an initial temperature used in the Newton solver. - pub fn initial_temperature( - mut self, - initial_temperature: Temperature, - ) -> StateBuilder<'a, E, true> { - self.initial_temperature = Some(initial_temperature); - self.convert() - } - - fn convert(self) -> StateBuilder<'a, E, true> { - StateBuilder { - eos: self.eos, - temperature: self.temperature, - volume: self.volume, - density: self.density, - partial_density: self.partial_density, - total_moles: self.total_moles, - moles: self.moles, - molefracs: self.molefracs, - pressure: self.pressure, - molar_enthalpy: self.molar_enthalpy, - molar_entropy: self.molar_entropy, - molar_internal_energy: self.molar_internal_energy, - density_initialization: self.density_initialization, - initial_temperature: self.initial_temperature, - } - } -} - -impl StateBuilder<'_, E, false> { - /// Try to build the state with the given inputs. - pub fn build(self) -> FeosResult> { - State::new( - self.eos, - self.temperature, - self.volume, - self.density, - self.partial_density, - self.total_moles, - self.moles, - self.molefracs, - self.pressure, - self.density_initialization, - ) - } -} - -impl StateBuilder<'_, E, true> { - /// Try to build the state with the given inputs. - pub fn build(self) -> FeosResult> { - State::new_full( - self.eos, - self.temperature, - self.volume, - self.density, - self.partial_density, - self.total_moles, - self.moles, - self.molefracs, - self.pressure, - self.molar_enthalpy, - self.molar_entropy, - self.molar_internal_energy, - self.density_initialization, - self.initial_temperature, - ) - } -} diff --git a/crates/feos-core/src/state/cache.rs b/crates/feos-core/src/state/cache.rs index b9a1326f7..cefc798d8 100644 --- a/crates/feos-core/src/state/cache.rs +++ b/crates/feos-core/src/state/cache.rs @@ -12,17 +12,17 @@ pub struct Cache where DefaultAllocator: Allocator, { - pub a: OnceLock>, - pub da_dt: OnceLock>, + pub a: OnceLock>, + pub da_dt: OnceLock>, pub da_dv: OnceLock>, pub da_dn: OnceLock>>, - pub d2a_dt2: OnceLock>>, - pub d2a_dv2: OnceLock>>, + pub d2a_dt2: OnceLock>>, + pub d2a_dv2: OnceLock>>, pub d2a_dtdv: OnceLock>>, pub d2a_dndt: OnceLock, Diff<_MolarEnergy, _Temperature>>>, - pub d2a_dndv: OnceLock, Diff<_MolarEnergy, _Volume>>>, - pub d3a_dt3: OnceLock, _Temperature>>>, - pub d3a_dv3: OnceLock, _Volume>>>, + pub d2a_dndv: OnceLock, Diff<_Energy, _Volume>>>, + pub d3a_dt3: OnceLock, _Temperature>>>, + pub d3a_dv3: OnceLock, _MolarVolume>>>, } impl Cache diff --git a/crates/feos-core/src/state/composition.rs b/crates/feos-core/src/state/composition.rs new file mode 100644 index 000000000..0ad47ac9e --- /dev/null +++ b/crates/feos-core/src/state/composition.rs @@ -0,0 +1,304 @@ +use super::State; +use crate::equation_of_state::Residual; +use crate::{FeosError, FeosResult}; +use nalgebra::allocator::Allocator; +use nalgebra::{DefaultAllocator, Dim, Dyn, OVector, U1, U2, dvector, vector}; +use num_dual::{DualNum, DualStruct}; +use quantity::{Density, Moles, Quantity, SIUnit}; + +pub trait Composition + Copy, N: Dim> +where + DefaultAllocator: Allocator, +{ + #[expect(clippy::type_complexity)] + fn into_molefracs>( + self, + eos: &E, + ) -> FeosResult<(OVector, Option>)>; + fn density(&self) -> Option> { + None + } +} + +pub trait FullComposition + Copy, N: Dim>: Composition +where + DefaultAllocator: Allocator, +{ + fn into_moles>(self, eos: &E) -> FeosResult<(OVector, Moles)>; +} + +// trivial implementations +impl + Copy, N: Dim> Composition for (OVector, Moles) +where + DefaultAllocator: Allocator, +{ + fn into_molefracs>( + self, + _: &E, + ) -> FeosResult<(OVector, Option>)> { + Ok((self.0, Some(self.1))) + } +} + +impl + Copy, N: Dim> FullComposition for (OVector, Moles) +where + DefaultAllocator: Allocator, +{ + fn into_moles>(self, _: &E) -> FeosResult<(OVector, Moles)> { + Ok((self.0, self.1)) + } +} + +impl + Copy, N: Dim> Composition for (OVector, Option>) +where + DefaultAllocator: Allocator, +{ + fn into_molefracs>( + self, + _: &E, + ) -> FeosResult<(OVector, Option>)> { + Ok((self.0, self.1)) + } +} + +// copy the composition from a given state +impl + Copy, N: Dim> Composition for &State +where + DefaultAllocator: Allocator, +{ + fn into_molefracs>( + self, + _: &E1, + ) -> FeosResult<(OVector, Option>)> { + Ok(((self.molefracs.clone()), self.total_moles)) + } +} + +// a pure component needs no specification +impl + Copy> Composition for () { + fn into_molefracs>( + self, + _: &E, + ) -> FeosResult<(OVector, Option>)> { + Ok(((vector![D::one()]), None)) + } +} +impl + Copy> Composition for () { + fn into_molefracs>( + self, + eos: &E, + ) -> FeosResult<(OVector, Option>)> { + if eos.components() == 1 { + Ok(((dvector![D::one()]), None)) + } else { + Err(FeosError::UndeterminedState( + "The composition needs to be specified for a system with more than one component." + .into(), + )) + } + } +} + +// a binary mixture can be specified by a scalar (x1) +impl + Copy> Composition for D { + fn into_molefracs>( + self, + _: &E, + ) -> FeosResult<(OVector, Option>)> { + Ok(((vector![self, -self + 1.0]), None)) + } +} + +// this cannot be implemented generically for D due to mising specialization +impl Composition for f64 { + fn into_molefracs( + self, + eos: &E, + ) -> FeosResult<(OVector, Option)> { + if eos.components() == 2 { + Ok(((dvector![self, 1.0 - self]), None)) + } else { + Err(FeosError::UndeterminedState(format!( + "A scalar ({}) can only be used to specify a binary mixture!", + self + ))) + } + } +} + +// a pure component can be specified by the total mole number +impl + Copy> Composition for Moles { + fn into_molefracs>( + self, + _: &E, + ) -> FeosResult<(OVector, Option>)> { + Ok(((vector![D::one()]), Some(self))) + } +} + +impl + Copy> FullComposition for Moles { + fn into_moles>(self, _: &E) -> FeosResult<(OVector, Moles)> { + Ok(((vector![D::one()]), self)) + } +} + +impl + Copy> Composition for Moles { + fn into_molefracs>( + self, + eos: &E, + ) -> FeosResult<(OVector, Option>)> { + if eos.components() == 1 { + Ok(((dvector![D::one()]), Some(self))) + } else { + Err(FeosError::UndeterminedState(format!( + "A single mole number ({}) can only be used to specify a pure component!", + self.re() + ))) + } + } +} + +impl + Copy> FullComposition for Moles { + fn into_moles>(self, eos: &E) -> FeosResult<(OVector, Moles)> { + if eos.components() == 1 { + Ok(((dvector![D::one()]), self)) + } else { + Err(FeosError::UndeterminedState(format!( + "A single mole number ({}) can only be used to specify a pure component!", + self.re() + ))) + } + } +} + +// the mixture can be specified by its molefractions +// +// for a dynamic number of components, it is also possible to specify only the +// N-1 first components +impl + Copy, N: Dim> Composition for OVector +where + DefaultAllocator: Allocator, +{ + fn into_molefracs>( + self, + eos: &E, + ) -> FeosResult<(OVector, Option>)> { + (&self).into_molefracs(eos) + } +} + +impl + Copy, N: Dim> Composition for &OVector +where + DefaultAllocator: Allocator, +{ + fn into_molefracs>( + self, + eos: &E, + ) -> FeosResult<(OVector, Option>)> { + let sum = self.sum(); + if eos.components() == self.len() { + Ok(((self.clone() / sum), None)) + } else if eos.components() == self.len() + 1 { + let mut x = OVector::zeros_generic(N::from_usize(eos.components()), U1); + for i in 0..self.len() { + x[i] = self[i]; + } + x[self.len()] = -sum + 1.0; + Ok(((x), None)) + } else { + Err(FeosError::UndeterminedState(format!( + "The length of the composition vector ({}) does not match the number of components ({})!", + self.len(), + eos.components() + ))) + } + } +} + +// the mixture can be specified by its moles +impl + Copy, N: Dim> Composition for Moles> +where + DefaultAllocator: Allocator, +{ + fn into_molefracs>( + self, + eos: &E, + ) -> FeosResult<(OVector, Option>)> { + (&self).into_molefracs(eos) + } +} + +impl + Copy, N: Dim> FullComposition for Moles> +where + DefaultAllocator: Allocator, +{ + fn into_moles>(self, eos: &E) -> FeosResult<(OVector, Moles)> { + (&self).into_moles(eos) + } +} + +impl + Copy, N: Dim> Composition for &Moles> +where + DefaultAllocator: Allocator, +{ + fn into_molefracs>( + self, + eos: &E, + ) -> FeosResult<(OVector, Option>)> { + if eos.components() == self.len() { + let total_moles = self.sum(); + Ok(((self.convert_to(total_moles)), Some(total_moles))) + } else { + Err(FeosError::UndeterminedState(format!( + "The length of the composition vector ({}) does not match the number of components ({})!", + self.len(), + eos.components() + ))) + } + } +} + +impl + Copy, N: Dim> FullComposition for &Moles> +where + DefaultAllocator: Allocator, +{ + fn into_moles>(self, eos: &E) -> FeosResult<(OVector, Moles)> { + if eos.components() == self.len() { + let total_moles = self.sum(); + Ok(((self.convert_to(total_moles)), total_moles)) + } else { + Err(FeosError::UndeterminedState(format!( + "The length of the composition vector ({}) does not match the number of components ({})!", + self.len(), + eos.components() + ))) + } + } +} + +// the mixture can be specified with the partial density +impl + Copy, N: Dim> Composition + for Quantity, SIUnit<0, -3, 0, 0, 0, 1, 0>> +where + DefaultAllocator: Allocator, +{ + fn into_molefracs>( + self, + eos: &E, + ) -> FeosResult<(OVector, Option>)> { + if eos.components() == self.len() { + let density = self.sum(); + Ok(((self.convert_to(density)), None)) + } else { + panic!( + "The length of the composition vector ({}) does not match the number of components ({})!", + self.len(), + eos.components() + ) + } + } + + fn density(&self) -> Option> { + Some(self.sum()) + } +} diff --git a/crates/feos-core/src/state/critical_point.rs b/crates/feos-core/src/state/critical_point.rs index e77b95299..4de9f9562 100644 --- a/crates/feos-core/src/state/critical_point.rs +++ b/crates/feos-core/src/state/critical_point.rs @@ -1,4 +1,4 @@ -use super::{DensityInitialization, State}; +use super::{Composition, DensityInitialization, State}; use crate::equation_of_state::Residual; use crate::errors::{FeosError, FeosResult}; use crate::{ReferenceSystem, SolverOptions, Subset, TemperatureOrPressure, Verbosity}; @@ -31,14 +31,14 @@ impl State { let pure_eos = eos.subset(&[i]); let cp = State::critical_point( &pure_eos, - None, + (), initial_temperature, initial_density, options, )?; let mut molefracs = DVector::zeros(eos.components()); molefracs[i] = 1.0; - State::new_intensive(eos, cp.temperature, cp.density, &molefracs) + State::new(eos, cp.temperature, cp.density, molefracs) }) .collect() } @@ -81,7 +81,7 @@ where ); let density = rho[0] + rho[1]; let molefracs = OVector::from_fn_generic(n, U1, |i, _| rho[i] / density); - Self::new_intensive(eos, t, Density::from_reduced(density), &molefracs) + Self::new(eos, t, Density::from_reduced(density), molefracs) } else if let Some(p) = temperature_or_pressure.pressure() { let x = critical_point_binary_p( &eos_re, @@ -103,22 +103,22 @@ where let density = trho[1] + trho[2]; let molefracs = OVector::from_fn_generic(n, U1, |i, _| trho[i + 1] / density); let t = Temperature::from_reduced(trho[0]); - Self::new_intensive(eos, t, Density::from_reduced(density), &molefracs) + Self::new(eos, t, Density::from_reduced(density), molefracs) } else { unreachable!() } } /// Calculate the critical point of a system for given moles. - pub fn critical_point( + pub fn critical_point>( eos: &E, - molefracs: Option<&OVector>, + composition: X, initial_temperature: Option, initial_density: Option, options: SolverOptions, ) -> FeosResult { let eos_re = eos.re(); - let molefracs = molefracs.map_or_else(E::pure_molefracs, |x| x.clone()); + let (molefracs, total_moles) = composition.into_molefracs(eos)?; let x = &molefracs.map(|x| x.re()); let rho_init = initial_density.map(|r| r.into_reduced()); let trial_temperatures = [300.0, 700.0, 500.0]; @@ -144,11 +144,11 @@ where t_rho[1], &molefracs, ); - Self::new_intensive( + Self::new( eos, Temperature::from_reduced(temperature), Density::from_reduced(density), - &molefracs, + (molefracs, total_moles), ) } } @@ -411,18 +411,18 @@ impl, N: Gradients> State where DefaultAllocator: Allocator + Allocator + Allocator, { - pub fn spinodal( + pub fn spinodal + Clone>( eos: &E, temperature: Temperature, - molefracs: Option<&OVector>, + composition: X, options: SolverOptions, ) -> FeosResult<[Self; 2]> { - let critical_point = Self::critical_point(eos, molefracs, None, None, options)?; - let molefracs = molefracs.map_or_else(E::pure_molefracs, |x| x.clone()); + let critical_point = Self::critical_point(eos, composition, None, None, options)?; + let molefracs = &critical_point.molefracs; let spinodal_vapor = Self::calculate_spinodal( eos, temperature, - &molefracs, + molefracs, DensityInitialization::Vapor, options, )?; @@ -430,7 +430,7 @@ where let spinodal_liquid = Self::calculate_spinodal( eos, temperature, - &molefracs, + molefracs, DensityInitialization::InitialDensity(rho), options, )?; @@ -500,12 +500,7 @@ where "Spinodal calculation converged in {} step(s)\n", i ); - return Self::new_intensive( - eos, - temperature, - Density::from_reduced(rho), - molefracs, - ); + return Self::new(eos, temperature, Density::from_reduced(rho), molefracs); } } Err(FeosError::SuperCritical) @@ -571,7 +566,7 @@ where // calculate pressure let a = partial2( - |v, &t, x| eos.lift().residual_molar_helmholtz_energy(t, v, x), + |v, &t, x| eos.lift().residual_helmholtz_energy(t, v, x), &temperature, &molefracs, ); diff --git a/crates/feos-core/src/state/mod.rs b/crates/feos-core/src/state/mod.rs index 5da7a43c5..ec99d4f19 100644 --- a/crates/feos-core/src/state/mod.rs +++ b/crates/feos-core/src/state/mod.rs @@ -11,19 +11,19 @@ use crate::equation_of_state::Residual; use crate::errors::{FeosError, FeosResult}; use crate::{ReferenceSystem, Total}; use nalgebra::allocator::Allocator; -use nalgebra::{DefaultAllocator, Dim, Dyn, OVector, U1}; +use nalgebra::{DefaultAllocator, Dim, Dyn, OVector}; use num_dual::*; use quantity::*; use std::fmt; use std::ops::Sub; -mod builder; mod cache; +mod composition; mod properties; mod residual_properties; mod statevec; -pub use builder::StateBuilder; pub(crate) use cache::Cache; +pub use composition::{Composition, FullComposition}; pub use statevec::StateVec; /// Possible contributions that can be computed. @@ -70,8 +70,6 @@ where { /// temperature in Kelvin pub temperature: D, - // /// volume in Angstrom^3 - // pub molar_volume: D, /// mole fractions pub molefracs: OVector, /// partial number densities in Angstrom^-3 @@ -82,15 +80,9 @@ impl + Copy> StateHD where DefaultAllocator: Allocator, { - /// Create a new `StateHD` for given temperature, molar volume and composition. - pub fn new(temperature: D, molar_volume: D, molefracs: &OVector) -> Self { - let partial_density = molefracs / molar_volume; - - Self { - temperature, - molefracs: molefracs.clone(), - partial_density, - } + /// Create a new `StateHD` for given temperature, volume and composition. + pub fn new(temperature: D, volume: D, moles: &OVector) -> Self { + Self::new_density(temperature, &(moles / volume)) } /// Create a new `StateHD` for given temperature and partial densities @@ -144,7 +136,7 @@ where /// + [State constructors](#state-constructors) /// + [Stability analysis](#stability-analysis) /// + [Flash calculations](#flash-calculations) -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct State + Copy = f64> where DefaultAllocator: Allocator, @@ -153,14 +145,10 @@ where pub eos: E, /// Temperature $T$ pub temperature: Temperature, - /// Volume $V$ - pub volume: Volume, - /// Mole numbers $N_i$ - pub moles: Moles>, + /// Molar volume $v=\frac{V}{N}$ + pub molar_volume: MolarVolume, /// Total number of moles $N=\sum_iN_i$ - pub total_moles: Moles, - /// Partial densities $\rho_i=\frac{N_i}{V}$ - pub partial_density: Density>, + pub total_moles: Option>, /// Total density $\rho=\frac{N}{V}=\sum_i\rho_i$ pub density: Density, /// Mole fractions $x_i=\frac{N_i}{N}=\frac{\rho_i}{\rho}$ @@ -169,22 +157,38 @@ where cache: Cache, } -impl + Copy> Clone for State +impl + Copy> State where DefaultAllocator: Allocator, { - fn clone(&self) -> Self { - Self { - eos: self.eos.clone(), - temperature: self.temperature, - volume: self.volume, - moles: self.moles.clone(), - total_moles: self.total_moles, - partial_density: self.partial_density.clone(), - density: self.density, - molefracs: self.molefracs.clone(), - cache: self.cache.clone(), - } + /// Set the total amount of substance to the given value. + /// + /// This method does not introduce inconsistencies, because the + /// total moles are the only field that stores information about + /// the size of the state. + pub fn set_total_moles(mut self, total_moles: Moles) -> State { + self.total_moles = Some(total_moles); + self + } + + /// Partial densities $\rho_i=\frac{N_i}{V}$ + pub fn partial_density(&self) -> Density> { + Dimensionless::new(&self.molefracs) * self.density + } + + /// Mole numbers $N_i$ + pub fn moles(&self) -> Moles> { + Dimensionless::new(&self.molefracs) * self.total_moles() + } + + /// Total moles $N=\sum_iN_i$ + pub fn total_moles(&self) -> Moles { + self.total_moles.expect("Extensive properties can only be evaluated for states that are initialized with extensive properties!") + } + + /// Volume $V$ + pub fn volume(&self) -> Volume { + self.molar_volume * self.total_moles() } } @@ -221,83 +225,76 @@ where /// This function will perform a validation of the given properties, i.e. test for signs /// and if values are finite. It will **not** validate physics, i.e. if the resulting /// densities are below the maximum packing fraction. - pub fn new_nvt( + pub fn new_nvt>( eos: &E, temperature: Temperature, volume: Volume, - moles: &Moles>, + composition: X, ) -> FeosResult { - let total_moles = moles.sum(); - let molefracs = (moles / total_moles).into_value(); + let (molefracs, total_moles) = composition.into_moles(eos)?; let density = total_moles / volume; - validate(temperature, density, &molefracs)?; + Self::new(eos, temperature, density, (molefracs, total_moles)) + } - Ok(Self::new_unchecked( - eos, - temperature, - density, - total_moles, - &molefracs, - )) + /// Return a new `State` given a temperature and the partial density of all components. + /// + /// This function will perform a validation of the given properties, i.e. test for signs + /// and if values are finite. It will **not** validate physics, i.e. if the resulting + /// densities are below the maximum packing fraction. + pub fn new_density( + eos: &E, + temperature: Temperature, + partial_density: Density>, + ) -> FeosResult { + let density = partial_density.sum(); + Self::new(eos, temperature, density, partial_density) } - /// Return a new `State` for which the total amount of substance is unspecified. + /// Return a new `State` for a pure component given a temperature and a density. /// - /// Internally the total number of moles will be set to 1 mol. + /// This function will perform a validation of the given properties, i.e. test for signs + /// and if values are finite. It will **not** validate physics, i.e. if the resulting + /// densities are below the maximum packing fraction. + pub fn new_pure(eos: &E, temperature: Temperature, density: Density) -> FeosResult + where + (): Composition, + { + Self::new(eos, temperature, density, ()) + } + + /// Return a new `State` given a temperature, a density and the composition. /// /// This function will perform a validation of the given properties, i.e. test for signs /// and if values are finite. It will **not** validate physics, i.e. if the resulting /// densities are below the maximum packing fraction. - pub fn new_intensive( + pub fn new>( eos: &E, temperature: Temperature, density: Density, - molefracs: &OVector, + composition: X, ) -> FeosResult { - validate(temperature, density, molefracs)?; - let total_moles = Moles::new(D::one()); - Ok(Self::new_unchecked( - eos, - temperature, - density, - total_moles, - molefracs, - )) + let (molefracs, total_moles) = composition.into_molefracs(eos)?; + Self::_new(eos, temperature, density, molefracs, total_moles) } - fn new_unchecked( + fn _new( eos: &E, temperature: Temperature, density: Density, - total_moles: Moles, - molefracs: &OVector, - ) -> Self { - let volume = total_moles / density; - let moles = Dimensionless::new(molefracs.clone()) * total_moles; - let partial_density = moles.clone() / volume; - - State { + molefracs: OVector, + total_moles: Option>, + ) -> FeosResult { + let molar_volume = density.inv(); + validate(temperature, density, &molefracs)?; + Ok(State { eos: eos.clone(), temperature, - volume, - moles, - total_moles, - partial_density, + molar_volume, density, - molefracs: molefracs.clone(), + molefracs, + total_moles, cache: Cache::new(), - } - } - - /// Return a new `State` for a pure component given a temperature and a density. The moles - /// are set to the reference value for each component. - /// - /// This function will perform a validation of the given properties, i.e. test for signs - /// and if values are finite. It will **not** validate physics, i.e. if the resulting - /// densities are below the maximum packing fraction. - pub fn new_pure(eos: &E, temperature: Temperature, density: Density) -> FeosResult { - let molefracs = OVector::from_element_generic(N::from_usize(1), U1, D::one()); - Self::new_intensive(eos, temperature, density, &molefracs) + }) } /// Return a new `State` for the combination of inputs. @@ -313,200 +310,105 @@ where /// # Errors /// /// When the state cannot be created using the combination of inputs. - #[expect(clippy::too_many_arguments)] - pub fn new( + pub fn build>( eos: &E, - temperature: Option>, + temperature: Temperature, volume: Option>, density: Option>, - partial_density: Option<&Density>>, - total_moles: Option>, - moles: Option<&Moles>>, - molefracs: Option<&OVector>, + composition: X, pressure: Option>, density_initialization: Option, ) -> FeosResult { - Self::_new( + Self::_build( eos, temperature, volume, density, - partial_density, - total_moles, - moles, - molefracs, + composition, pressure, density_initialization, )? - .map_err(|_| FeosError::UndeterminedState(String::from("Missing input parameters."))) + .ok_or_else(|| FeosError::UndeterminedState(String::from("Missing input parameters."))) } - #[expect(clippy::too_many_arguments)] - #[expect(clippy::type_complexity)] - fn _new( + fn _build>( eos: &E, - temperature: Option>, + temperature: Temperature, volume: Option>, density: Option>, - partial_density: Option<&Density>>, - total_moles: Option>, - moles: Option<&Moles>>, - molefracs: Option<&OVector>, + composition: X, pressure: Option>, density_initialization: Option, - ) -> FeosResult>>>> { - // check for density - if density.and(partial_density).is_some() { + ) -> FeosResult> { + // check if density is given twice + if density.and(composition.density()).is_some() { return Err(FeosError::UndeterminedState(String::from( "Both density and partial density given.", ))); } - let rho = density.or_else(|| partial_density.map(|pd| pd.sum())); - - // check for total moles - if moles.and(total_moles).is_some() { - return Err(FeosError::UndeterminedState(String::from( - "Both moles and total moles given.", - ))); - } - let mut n = total_moles.or_else(|| moles.map(|m| m.sum())); - - // check if total moles can be inferred from volume - if rho.and(n).and(volume).is_some() { - return Err(FeosError::UndeterminedState(String::from( + let density = density.or_else(|| composition.density()); + + // unwrap composition + let (x, n) = composition.into_molefracs(eos)?; + + let t = temperature; + let di = density_initialization; + // find the appropriate state constructor + match (volume, density, n, pressure) { + (None, None, None, None) => Ok(None), + (None, None, Some(_), None) => Ok(None), + (Some(_), None, None, None) => Ok(None), + (None, None, _, Some(p)) => State::new_npt(eos, t, p, (x, n), di).map(Some), + (None, Some(d), _, None) => State::new(eos, t, d, (x, n)).map(Some), + (Some(v), None, None, Some(p)) => State::new_tpvx(eos, t, p, v, x, di).map(Some), + (Some(v), None, Some(n), None) => State::new_nvt(eos, t, v, (x, n)).map(Some), + (Some(v), Some(d), None, None) => State::new_nvt(eos, t, v, (x, d * v)).map(Some), + (Some(_), Some(_), Some(_), _) => Err(FeosError::UndeterminedState(String::from( "Density is overdetermined.", - ))); + ))), + (_, _, _, Some(_)) => Err(FeosError::UndeterminedState(String::from( + "Pressure is overdetermined.", + ))), } - n = n.or_else(|| rho.and_then(|d| volume.map(|v| v * d))); - - // check for composition - if partial_density.and(moles).is_some() { - return Err(FeosError::UndeterminedState(String::from( - "Composition is overdetermined.", - ))); - } - let x = partial_density - .map(|pd| pd / pd.sum()) - .or_else(|| moles.map(|ms| ms / ms.sum())) - .map(Quantity::into_value); - let x_u = match (x, molefracs, eos.components()) { - (Some(_), Some(_), _) => { - return Err(FeosError::UndeterminedState(String::from( - "Composition is overdetermined.", - ))); - } - (Some(x), None, _) => x, - (None, Some(x), _) => x.clone(), - (None, None, 1) => OVector::from_element_generic(N::from_usize(1), U1, D::from(1.0)), - _ => { - return Err(FeosError::UndeterminedState(String::from( - "Missing composition.", - ))); - } - }; - let x_u = &x_u / x_u.sum(); - - // If no extensive property is given, moles is set to the reference value. - if let (None, None) = (volume, n) { - n = Some(Moles::from_reduced(D::from(1.0))) - } - let n_i = n.map(|n| Dimensionless::new(&x_u) * n); - let v = volume.or_else(|| rho.and_then(|d| n.map(|n| n / d))); - - // check if new state can be created using default constructor - if let (Some(v), Some(t), Some(n_i)) = (v, temperature, &n_i) { - return Ok(Ok(State::new_nvt(eos, t, v, n_i)?)); - } - - // Check if new state can be created using density iteration - if let (Some(p), Some(t), Some(n_i)) = (pressure, temperature, &n_i) { - return Ok(Ok(State::new_npt(eos, t, p, n_i, density_initialization)?)); - } - if let (Some(p), Some(t), Some(v)) = (pressure, temperature, v) { - return Ok(Ok(State::new_npvx( - eos, - t, - p, - v, - &x_u, - density_initialization, - )?)); - } - Ok(Err(n_i.to_owned())) } /// Return a new `State` using a density iteration. [DensityInitialization] is used to /// influence the calculation with respect to the possible solutions. - pub fn new_npt( + pub fn new_npt>( eos: &E, temperature: Temperature, pressure: Pressure, - moles: &Moles>, - density_initialization: Option, - ) -> FeosResult { - let total_moles = moles.sum(); - let molefracs = (moles / total_moles).into_value(); - let density = Self::new_xpt( - eos, - temperature, - pressure, - &molefracs, - density_initialization, - )? - .density; - Ok(Self::new_unchecked( - eos, - temperature, - density, - total_moles, - &molefracs, - )) - } - - /// Return a new `State` using a density iteration. [DensityInitialization] is used to - /// influence the calculation with respect to the possible solutions. - pub fn new_xpt( - eos: &E, - temperature: Temperature, - pressure: Pressure, - molefracs: &OVector, + composition: X, density_initialization: Option, ) -> FeosResult { + let (molefracs, total_moles) = composition.into_molefracs(eos)?; density_iteration( eos, temperature, pressure, - molefracs, + &molefracs, density_initialization, ) - .and_then(|density| Self::new_intensive(eos, temperature, density, molefracs)) + .and_then(|density| Self::_new(eos, temperature, density, molefracs, total_moles)) } /// Return a new `State` for given pressure $p$, volume $V$, temperature $T$ and composition $x_i$. - pub fn new_npvx( + pub fn new_tpvx( eos: &E, temperature: Temperature, pressure: Pressure, volume: Volume, - molefracs: &OVector, + molefracs: OVector, density_initialization: Option, ) -> FeosResult { - let density = Self::new_xpt( + let density = density_iteration( eos, temperature, pressure, - molefracs, + &molefracs, density_initialization, - )? - .density; - let total_moles = density * volume; - Ok(Self::new_unchecked( - eos, - temperature, - density, - total_moles, - molefracs, - )) + )?; + Self::new_nvt(eos, temperature, volume, (molefracs, density * volume)) } } @@ -529,15 +431,12 @@ where /// /// When the state cannot be created using the combination of inputs. #[expect(clippy::too_many_arguments)] - pub fn new_full( + pub fn build_full + Clone>( eos: &E, temperature: Option>, volume: Option>, density: Option>, - partial_density: Option<&Density>>, - total_moles: Option>, - moles: Option<&Moles>>, - molefracs: Option<&OVector>, + composition: X, pressure: Option>, molar_enthalpy: Option>, molar_entropy: Option>, @@ -545,38 +444,42 @@ where density_initialization: Option, initial_temperature: Option>, ) -> FeosResult { - let state = Self::_new( - eos, - temperature, - volume, - density, - partial_density, - total_moles, - moles, - molefracs, - pressure, - density_initialization, - )?; + let state = if let Some(temperature) = temperature { + Self::_build( + eos, + temperature, + volume, + density, + composition.clone(), + pressure, + density_initialization, + )? + } else { + None + }; let ti = initial_temperature; match state { - Ok(state) => Ok(state), - Err(n_i) => { + Some(state) => Ok(state), + None => { // Check if new state can be created using molar_enthalpy and temperature - if let (Some(p), Some(h), Some(n_i)) = (pressure, molar_enthalpy, &n_i) { - return State::new_nph(eos, p, h, n_i, density_initialization, ti); + if let (Some(p), Some(h)) = (pressure, molar_enthalpy) { + return State::new_nph(eos, p, h, composition, density_initialization, ti); } - if let (Some(p), Some(s), Some(n_i)) = (pressure, molar_entropy, &n_i) { - return State::new_nps(eos, p, s, n_i, density_initialization, ti); + if let (Some(p), Some(s)) = (pressure, molar_entropy) { + return State::new_nps(eos, p, s, composition, density_initialization, ti); } - if let (Some(t), Some(h), Some(n_i)) = (temperature, molar_enthalpy, &n_i) { - return State::new_nth(eos, t, h, n_i, density_initialization); + if let (Some(t), Some(h)) = (temperature, molar_enthalpy) { + return State::new_nth(eos, t, h, composition, density_initialization); } - if let (Some(t), Some(s), Some(n_i)) = (temperature, molar_entropy, &n_i) { - return State::new_nts(eos, t, s, n_i, density_initialization); + if let (Some(t), Some(s)) = (temperature, molar_entropy) { + return State::new_nts(eos, t, s, composition, density_initialization); } - if let (Some(u), Some(v), Some(n_i)) = (molar_internal_energy, volume, &n_i) { - return State::new_nvu(eos, v, u, n_i, ti); + if let (Some(u), Some(v)) = (molar_internal_energy, volume) { + let (molefracs, total_moles) = composition.into_molefracs(eos)?; + if let Some(n) = total_moles { + return State::new_nvu(eos, v, u, (molefracs, n), ti); + } } Err(FeosError::UndeterminedState(String::from( "Missing input parameters.", @@ -586,18 +489,18 @@ where } /// Return a new `State` for given pressure $p$ and molar enthalpy $h$. - pub fn new_nph( + pub fn new_nph + Clone>( eos: &E, pressure: Pressure, molar_enthalpy: MolarEnergy, - moles: &Moles>, + composition: X, density_initialization: Option, initial_temperature: Option>, ) -> FeosResult { let t0 = initial_temperature.unwrap_or(Temperature::from_reduced(D::from(298.15))); let mut density = density_initialization; let f = |x0| { - let s = State::new_npt(eos, x0, pressure, moles, density)?; + let s = State::new_npt(eos, x0, pressure, composition.clone(), density)?; let dfx = s.molar_isobaric_heat_capacity(Contributions::Total); let fx = s.molar_enthalpy(Contributions::Total) - molar_enthalpy; density = Some(DensityInitialization::InitialDensity(s.density.re())); @@ -607,28 +510,27 @@ where } /// Return a new `State` for given temperature $T$ and molar enthalpy $h$. - pub fn new_nth( + pub fn new_nth + Clone>( eos: &E, temperature: Temperature, molar_enthalpy: MolarEnergy, - moles: &Moles>, + composition: X, density_initialization: Option, ) -> FeosResult { - let x = moles.convert_to(moles.sum()); + let (x, _) = composition.clone().into_molefracs(eos)?; let rho0 = match density_initialization { Some(DensityInitialization::InitialDensity(r)) => { Density::from_reduced(D::from(r.into_reduced())) } - Some(DensityInitialization::Liquid) => eos.max_density(&Some(x))?, - Some(DensityInitialization::Vapor) => eos.max_density(&Some(x))? * 1.0e-5, - None => eos.max_density(&Some(x))? * 0.01, + Some(DensityInitialization::Liquid) => eos.max_density(&x)?, + Some(DensityInitialization::Vapor) => eos.max_density(&x)? * 1.0e-5, + None => eos.max_density(&x)? * 0.01, }; - let n_inv = moles.sum().inv(); - let f = |x0| { - let s = State::new_nvt(eos, temperature, moles.sum() / x0, moles)?; - let dfx = -s.volume / s.density - * n_inv - * (s.volume * s.dp_dv(Contributions::Total) + let f = |rho| { + let s = State::new(eos, temperature, rho, composition.clone())?; + let dfx = -s.molar_volume + * s.molar_volume + * (s.molar_volume * s.dp_dv(Contributions::Total) + temperature * s.dp_dt(Contributions::Total)); let fx = s.molar_enthalpy(Contributions::Total) - molar_enthalpy; Ok((fx, dfx, s)) @@ -637,26 +539,25 @@ where } /// Return a new `State` for given temperature $T$ and molar entropy $s$. - pub fn new_nts( + pub fn new_nts + Clone>( eos: &E, temperature: Temperature, molar_entropy: MolarEntropy, - moles: &Moles>, + composition: X, density_initialization: Option, ) -> FeosResult { - let x = moles.convert_to(moles.sum()); + let (x, _) = composition.clone().into_molefracs(eos)?; let rho0 = match density_initialization { Some(DensityInitialization::InitialDensity(r)) => { Density::from_reduced(D::from(r.into_reduced())) } - Some(DensityInitialization::Liquid) => eos.max_density(&Some(x))?, - Some(DensityInitialization::Vapor) => eos.max_density(&Some(x))? * 1.0e-5, - None => eos.max_density(&Some(x))? * 0.01, + Some(DensityInitialization::Liquid) => eos.max_density(&x)?, + Some(DensityInitialization::Vapor) => eos.max_density(&x)? * 1.0e-5, + None => eos.max_density(&x)? * 0.01, }; - let n_inv = moles.sum().inv(); - let f = |x0| { - let s = State::new_nvt(eos, temperature, moles.sum() / x0, moles)?; - let dfx = -n_inv * s.volume / s.density * s.dp_dt(Contributions::Total); + let f = |rho| { + let s = State::new(eos, temperature, rho, composition.clone())?; + let dfx = -s.molar_volume * s.molar_volume * s.dp_dt(Contributions::Total); let fx = s.molar_entropy(Contributions::Total) - molar_entropy; Ok((fx, dfx, s)) }; @@ -664,18 +565,18 @@ where } /// Return a new `State` for given pressure $p$ and molar entropy $s$. - pub fn new_nps( + pub fn new_nps + Clone>( eos: &E, pressure: Pressure, molar_entropy: MolarEntropy, - moles: &Moles>, + composition: X, density_initialization: Option, initial_temperature: Option>, ) -> FeosResult { let t0 = initial_temperature.unwrap_or(Temperature::from_reduced(D::from(298.15))); let mut density = density_initialization; let f = |x0| { - let s = State::new_npt(eos, x0, pressure, moles, density)?; + let s = State::new_npt(eos, x0, pressure, composition.clone(), density)?; let dfx = s.molar_isobaric_heat_capacity(Contributions::Total) / s.temperature; let fx = s.molar_entropy(Contributions::Total) - molar_entropy; density = Some(DensityInitialization::InitialDensity(s.density.re())); @@ -685,16 +586,16 @@ where } /// Return a new `State` for given volume $V$ and molar internal energy $u$. - pub fn new_nvu( + pub fn new_nvu + Clone>( eos: &E, volume: Volume, molar_internal_energy: MolarEnergy, - moles: &Moles>, + composition: X, initial_temperature: Option>, ) -> FeosResult { let t0 = initial_temperature.unwrap_or(Temperature::from_reduced(D::from(298.15))); let f = |x0| { - let s = State::new_nvt(eos, x0, volume, moles)?; + let s = State::new_nvt(eos, x0, volume, composition.clone())?; let fx = s.molar_internal_energy(Contributions::Total) - molar_internal_energy; let dfx = s.molar_isochoric_heat_capacity(Contributions::Total); Ok((fx, dfx, s)) diff --git a/crates/feos-core/src/state/properties.rs b/crates/feos-core/src/state/properties.rs index f45bfb751..c93c1e77d 100644 --- a/crates/feos-core/src/state/properties.rs +++ b/crates/feos-core/src/state/properties.rs @@ -20,11 +20,11 @@ where let ideal_gas = || { quantity::ad::gradient_copy( partial2( - |n, &t, &v| self.eos.ideal_gas_helmholtz_energy(t, v, &n), + |n: Dimensionless<_>, &t, &v| self.eos.ideal_gas_helmholtz_energy(t, v, &n), &self.temperature, - &self.volume, + &self.molar_volume, ), - &self.moles, + &Dimensionless::new(self.molefracs.clone()), ) .1 }; @@ -37,10 +37,15 @@ where let ideal_gas = || { quantity::ad::partial_hessian_copy( partial( - |(n, t), &v| self.eos.ideal_gas_helmholtz_energy(t, v, &n), - &self.volume, + |(n, t): (Dimensionless<_>, _), &v| { + self.eos.ideal_gas_helmholtz_energy(t, v, &n) + }, + &self.molar_volume, + ), + ( + &Dimensionless::new(self.molefracs.clone()), + self.temperature, ), - (&self.moles, self.temperature), ) .3 }; @@ -49,7 +54,7 @@ where /// Molar isochoric heat capacity: $c_v=\left(\frac{\partial u}{\partial T}\right)_{V,N_i}$ pub fn molar_isochoric_heat_capacity(&self, contributions: Contributions) -> MolarEntropy { - self.temperature * self.ds_dt(contributions) / self.total_moles + self.temperature * self.ds_dt(contributions) } /// Partial derivative of the molar isochoric heat capacity w.r.t. temperature: $\left(\frac{\partial c_V}{\partial T}\right)_{V,N_i}$ @@ -57,8 +62,7 @@ where &self, contributions: Contributions, ) -> as Div>>::Output { - (self.temperature * self.d2s_dt2(contributions) + self.ds_dt(contributions)) - / self.total_moles + self.temperature * self.d2s_dt2(contributions) + self.ds_dt(contributions) } /// Molar isobaric heat capacity: $c_p=\left(\frac{\partial h}{\partial T}\right)_{p,N_i}$ @@ -66,7 +70,7 @@ where match contributions { Contributions::Residual => self.residual_molar_isobaric_heat_capacity(), _ => { - self.temperature / self.total_moles + self.temperature * (self.ds_dt(contributions) - (self.dp_dt(contributions) * self.dp_dt(contributions)) / self.dp_dv(contributions)) @@ -76,13 +80,18 @@ where /// Entropy: $S=-\left(\frac{\partial A}{\partial T}\right)_{V,N_i}$ pub fn entropy(&self, contributions: Contributions) -> Entropy { - let residual = || self.residual_entropy(); + self.molar_entropy(contributions) * self.total_moles() + } + + /// Molar entropy: $s=\frac{S}{N}$ + pub fn molar_entropy(&self, contributions: Contributions) -> MolarEntropy { + let residual = || self.residual_molar_entropy(); let ideal_gas = || { -quantity::ad::first_derivative( partial2( |t, &v, n| self.eos.ideal_gas_helmholtz_energy(t, v, n), - &self.volume, - &self.moles, + &self.molar_volume, + &self.molefracs, ), self.temperature, ) @@ -91,29 +100,24 @@ where Self::contributions(ideal_gas, residual, contributions) } - /// Molar entropy: $s=\frac{S}{N}$ - pub fn molar_entropy(&self, contributions: Contributions) -> MolarEntropy { - self.entropy(contributions) / self.total_moles - } - /// Partial molar entropy: $s_i=\left(\frac{\partial S}{\partial N_i}\right)_{T,p,N_j}$ pub fn partial_molar_entropy(&self) -> MolarEntropy> { let c = Contributions::Total; - -(self.dmu_dt(c) + self.dp_dni(c) * (self.dp_dt(c) / self.dp_dv(c))) + -(self.dmu_dt(c) + self.n_dp_dni(c) * (self.dp_dt(c) / self.dp_dv(c))) } - /// Partial derivative of the entropy w.r.t. temperature: $\left(\frac{\partial S}{\partial T}\right)_{V,N_i}$ + /// Partial derivative of the molar entropy w.r.t. temperature: $\left(\frac{\partial s}{\partial T}\right)_{V,N_i}$ pub fn ds_dt( &self, contributions: Contributions, - ) -> as Div>>::Output { + ) -> as Div>>::Output { let residual = || self.ds_res_dt(); let ideal_gas = || { -quantity::ad::second_derivative( partial2( |t, &v, n| self.eos.ideal_gas_helmholtz_energy(t, v, n), - &self.volume, - &self.moles, + &self.molar_volume, + &self.molefracs, ), self.temperature, ) @@ -122,18 +126,18 @@ where Self::contributions(ideal_gas, residual, contributions) } - /// Second partial derivative of the entropy w.r.t. temperature: $\left(\frac{\partial^2 S}{\partial T^2}\right)_{V,N_i}$ + /// Second partial derivative of the molar entropy w.r.t. temperature: $\left(\frac{\partial^2 s}{\partial T^2}\right)_{V,N_i}$ pub fn d2s_dt2( &self, contributions: Contributions, - ) -> < as Div>>::Output as Div>>::Output { + ) -> < as Div>>::Output as Div>>::Output { let residual = || self.d2s_res_dt2(); let ideal_gas = || { -quantity::ad::third_derivative( partial2( |t, &v, n| self.eos.ideal_gas_helmholtz_energy(t, v, n), - &self.volume, - &self.moles, + &self.molar_volume, + &self.molefracs, ), self.temperature, ) @@ -144,14 +148,14 @@ where /// Enthalpy: $H=A+TS+pV$ pub fn enthalpy(&self, contributions: Contributions) -> Energy { - self.temperature * self.entropy(contributions) - + self.helmholtz_energy(contributions) - + self.pressure(contributions) * self.volume + self.molar_enthalpy(contributions) * self.total_moles() } /// Molar enthalpy: $h=\frac{H}{N}$ pub fn molar_enthalpy(&self, contributions: Contributions) -> MolarEnergy { - self.enthalpy(contributions) / self.total_moles + self.temperature * self.molar_entropy(contributions) + + self.molar_helmholtz_energy(contributions) + + self.pressure(contributions) * self.molar_volume } /// Partial molar enthalpy: $h_i=\left(\frac{\partial H}{\partial N_i}\right)_{T,p,N_j}$ @@ -163,13 +167,18 @@ where /// Helmholtz energy: $A$ pub fn helmholtz_energy(&self, contributions: Contributions) -> Energy { - let residual = || self.residual_helmholtz_energy(); + self.molar_helmholtz_energy(contributions) * self.total_moles() + } + + /// Molar Helmholtz energy: $a=\frac{A}{N}$ + pub fn molar_helmholtz_energy(&self, contributions: Contributions) -> MolarEnergy { + let residual = || self.residual_molar_helmholtz_energy(); let ideal_gas = || { quantity::ad::zeroth_derivative( partial2( |t, &v, n| self.eos.ideal_gas_helmholtz_energy(t, v, n), - &self.volume, - &self.moles, + &self.molar_volume, + &self.molefracs, ), self.temperature, ) @@ -177,43 +186,40 @@ where Self::contributions(ideal_gas, residual, contributions) } - /// Molar Helmholtz energy: $a=\frac{A}{N}$ - pub fn molar_helmholtz_energy(&self, contributions: Contributions) -> MolarEnergy { - self.helmholtz_energy(contributions) / self.total_moles - } - /// Internal energy: $U=A+TS$ pub fn internal_energy(&self, contributions: Contributions) -> Energy { - self.temperature * self.entropy(contributions) + self.helmholtz_energy(contributions) + self.molar_internal_energy(contributions) * self.total_moles() } /// Molar internal energy: $u=\frac{U}{N}$ pub fn molar_internal_energy(&self, contributions: Contributions) -> MolarEnergy { - self.internal_energy(contributions) / self.total_moles + self.temperature * self.molar_entropy(contributions) + + self.molar_helmholtz_energy(contributions) } /// Gibbs energy: $G=A+pV$ pub fn gibbs_energy(&self, contributions: Contributions) -> Energy { - self.pressure(contributions) * self.volume + self.helmholtz_energy(contributions) + self.molar_gibbs_energy(contributions) * self.total_moles() } /// Molar Gibbs energy: $g=\frac{G}{N}$ pub fn molar_gibbs_energy(&self, contributions: Contributions) -> MolarEnergy { - self.gibbs_energy(contributions) / self.total_moles + self.pressure(contributions) * self.molar_volume + + self.molar_helmholtz_energy(contributions) } /// Joule Thomson coefficient: $\mu_{JT}=\left(\frac{\partial T}{\partial p}\right)_{H,N_i}$ pub fn joule_thomson(&self) -> as Div>>::Output { let c = Contributions::Total; - -(self.volume + self.temperature * self.dp_dt(c) / self.dp_dv(c)) - / (self.total_moles * self.molar_isobaric_heat_capacity(c)) + -(self.molar_volume + self.temperature * self.dp_dt(c) / self.dp_dv(c)) + / self.molar_isobaric_heat_capacity(c) } /// Isentropic compressibility: $\kappa_s=-\frac{1}{V}\left(\frac{\partial V}{\partial p}\right)_{S,N_i}$ pub fn isentropic_compressibility(&self) -> InvP { let c = Contributions::Total; -self.molar_isochoric_heat_capacity(c) - / (self.molar_isobaric_heat_capacity(c) * self.dp_dv(c) * self.volume) + / (self.molar_isobaric_heat_capacity(c) * self.dp_dv(c) * self.molar_volume) } /// Isenthalpic compressibility: $\kappa_H=-\frac{1}{V}\left(\frac{\partial V}{\partial p}\right)_{H,N_i}$ @@ -224,7 +230,7 @@ where /// Thermal expansivity: $\alpha_p=-\frac{1}{V}\left(\frac{\partial V}{\partial T}\right)_{p,N_i}$ pub fn thermal_expansivity(&self) -> InvT { let c = Contributions::Total; - -self.dp_dt(c) / self.dp_dv(c) / self.volume + -self.dp_dt(c) / (self.dp_dv(c) * self.molar_volume) } /// Grueneisen parameter: $\phi=V\left(\frac{\partial p}{\partial U}\right)_{V,n_i}=\frac{v}{c_v}\left(\frac{\partial p}{\partial T}\right)_{v,n_i}=\frac{\rho}{T}\left(\frac{\partial T}{\partial \rho}\right)_{s, n_i}$ @@ -251,11 +257,7 @@ where )); } if let Contributions::Residual | Contributions::Total = contributions { - res.extend( - self.eos - .lift() - .molar_helmholtz_energy_contributions(t, v, &x), - ); + res.extend(self.eos.lift().helmholtz_energy_contributions(t, v, &x)); } res.into_iter() .map(|(s, v)| (s, MolarEnergy::from_reduced(v.eps))) diff --git a/crates/feos-core/src/state/residual_properties.rs b/crates/feos-core/src/state/residual_properties.rs index 53695fdbe..4a6b8f015 100644 --- a/crates/feos-core/src/state/residual_properties.rs +++ b/crates/feos-core/src/state/residual_properties.rs @@ -7,11 +7,8 @@ use num_dual::{Dual, DualNum, Gradients, partial, partial2}; use quantity::*; use std::ops::{Add, Div, Neg, Sub}; -type DpDn = Quantity>::Output>; -type DeDn = Quantity>::Output>; type InvT = Quantity::Output>; type InvP = Quantity::Output>; -type InvM = Quantity::Output>; type POverT = Quantity>::Output>; /// # State properties @@ -38,25 +35,33 @@ where /// Residual Helmholtz energy $A^\text{res}$ pub fn residual_helmholtz_energy(&self) -> Energy { - *self.cache.a.get_or_init(|| { - self.eos - .residual_helmholtz_energy_unit(self.temperature, self.volume, &self.moles) - }) + self.residual_molar_helmholtz_energy() * self.total_moles() } /// Residual molar Helmholtz energy $a^\text{res}$ pub fn residual_molar_helmholtz_energy(&self) -> MolarEnergy { - self.residual_helmholtz_energy() / self.total_moles + *self.cache.a.get_or_init(|| { + self.eos.residual_molar_helmholtz_energy( + self.temperature, + self.molar_volume, + &self.molefracs, + ) + }) } /// Residual entropy $S^\text{res}=\left(\frac{\partial A^\text{res}}{\partial T}\right)_{V,N_i}$ pub fn residual_entropy(&self) -> Entropy { + self.residual_molar_entropy() * self.total_moles() + } + + /// Residual molar entropy $s^\text{res}=\left(\frac{\partial a^\text{res}}{\partial T}\right)_{V,N_i}$ + pub fn residual_molar_entropy(&self) -> MolarEntropy { -*self.cache.da_dt.get_or_init(|| { let (a, da_dt) = quantity::ad::first_derivative( partial2( - |t, &v, n| self.eos.lift().residual_helmholtz_energy_unit(t, v, n), - &self.volume, - &self.moles, + |t, &v, n| self.eos.lift().residual_molar_helmholtz_energy(t, v, n), + &self.molar_volume, + &self.molefracs, ), self.temperature, ); @@ -65,11 +70,6 @@ where }) } - /// Residual entropy $s^\text{res}=\left(\frac{\partial a^\text{res}}{\partial T}\right)_{V,N_i}$ - pub fn residual_molar_entropy(&self) -> MolarEntropy { - self.residual_entropy() / self.total_moles - } - /// Pressure: $p=-\left(\frac{\partial A}{\partial V}\right)_{T,N_i}$ pub fn pressure(&self, contributions: Contributions) -> Pressure { let ideal_gas = || self.density * RGAS * self.temperature; @@ -77,11 +77,11 @@ where -*self.cache.da_dv.get_or_init(|| { let (a, da_dv) = quantity::ad::first_derivative( partial2( - |v, &t, n| self.eos.lift().residual_helmholtz_energy_unit(t, v, n), + |v, &t, n| self.eos.lift().residual_molar_helmholtz_energy(t, v, n), &self.temperature, - &self.moles, + &self.molefracs, ), - self.volume, + self.molar_volume, ); let _ = self.cache.a.set(a); da_dv @@ -97,11 +97,13 @@ where .get_or_init(|| { let (a, mu) = quantity::ad::gradient_copy( partial2( - |n, &t, &v| self.eos.lift().residual_helmholtz_energy_unit(t, v, &n), + |n: Dimensionless<_>, &t, &v| { + self.eos.lift().residual_molar_helmholtz_energy(t, v, &n) + }, &self.temperature, - &self.volume, + &self.molar_volume, ), - &self.moles, + &Dimensionless::new(self.molefracs.clone()), ); let _ = self.cache.a.set(a); mu @@ -116,18 +118,21 @@ where // pressure derivatives - /// Partial derivative of pressure w.r.t. volume: $\left(\frac{\partial p}{\partial V}\right)_{T,N_i}$ - pub fn dp_dv(&self, contributions: Contributions) -> as Div>>::Output { - let ideal_gas = || -self.density * RGAS * self.temperature / self.volume; + /// Partial derivative of pressure w.r.t. molar volume: $\left(\frac{\partial p}{\partial v}\right)_{T,N_i}$ + pub fn dp_dv( + &self, + contributions: Contributions, + ) -> as Div>>::Output { + let ideal_gas = || -self.density * RGAS * self.temperature / self.molar_volume; let residual = || { -*self.cache.d2a_dv2.get_or_init(|| { let (a, da_dv, d2a_dv2) = quantity::ad::second_derivative( partial2( - |v, &t, n| self.eos.lift().residual_helmholtz_energy_unit(t, v, n), + |v, &t, n| self.eos.lift().residual_molar_helmholtz_energy(t, v, n), &self.temperature, - &self.moles, + &self.molefracs, ), - self.volume, + self.molar_volume, ); let _ = self.cache.a.set(a); let _ = self.cache.da_dv.set(da_dv); @@ -142,7 +147,7 @@ where &self, contributions: Contributions, ) -> as Div>>::Output { - -self.volume / self.density * self.dp_dv(contributions) + -self.molar_volume / self.density * self.dp_dv(contributions) } /// Partial derivative of pressure w.r.t. temperature: $\left(\frac{\partial p}{\partial T}\right)_{V,N_i}$ @@ -152,10 +157,10 @@ where -*self.cache.d2a_dtdv.get_or_init(|| { let (a, da_dt, da_dv, d2a_dtdv) = quantity::ad::second_partial_derivative( partial( - |(t, v), n| self.eos.lift().residual_helmholtz_energy_unit(t, v, n), - &self.moles, + |(t, v), n| self.eos.lift().residual_molar_helmholtz_energy(t, v, n), + &self.molefracs, ), - (self.temperature, self.volume), + (self.temperature, self.molar_volume), ); let _ = self.cache.a.set(a); let _ = self.cache.da_dt.set(da_dt); @@ -166,18 +171,23 @@ where Self::contributions(ideal_gas, residual, contributions) } - /// Partial derivative of pressure w.r.t. moles: $\left(\frac{\partial p}{\partial N_i}\right)_{T,V,N_j}$ - pub fn dp_dni(&self, contributions: Contributions) -> DpDn> { + /// Partial derivative of pressure w.r.t. moles: $N\left(\frac{\partial p}{\partial N_i}\right)_{T,V,N_j}$ + pub fn n_dp_dni(&self, contributions: Contributions) -> Pressure> { let residual = -self .cache .d2a_dndv .get_or_init(|| { let (a, da_dn, da_dv, dmu_dv) = quantity::ad::partial_hessian_copy( partial( - |(n, v), &t| self.eos.lift().residual_helmholtz_energy_unit(t, v, &n), + |(n, v): (Dimensionless<_>, _), &t| { + self.eos.lift().residual_molar_helmholtz_energy(t, v, &n) + }, &self.temperature, ), - (&self.moles, self.volume), + ( + &Dimensionless::new(self.molefracs.clone()), + self.molar_volume, + ), ); let _ = self.cache.a.set(a); let _ = self.cache.da_dn.set(da_dn); @@ -186,7 +196,7 @@ where }) .clone(); let (r, c) = residual.shape_generic(); - let ideal_gas = || self.temperature / self.volume * RGAS; + let ideal_gas = || self.temperature * self.density * RGAS; Quantity::from_fn_generic(r, c, |i, _| { Self::contributions(ideal_gas, || residual.get(i), contributions) }) @@ -196,18 +206,19 @@ where pub fn d2p_dv2( &self, contributions: Contributions, - ) -> < as Div>>::Output as Div>>::Output { - let ideal_gas = - || self.density * RGAS * self.temperature / (self.volume * self.volume) * 2.0; + ) -> < as Div>>::Output as Div>>::Output { + let ideal_gas = || { + self.density * RGAS * self.temperature / (self.molar_volume * self.molar_volume) * 2.0 + }; let residual = || { -*self.cache.d3a_dv3.get_or_init(|| { let (a, da_dv, d2a_dv2, d3a_dv3) = quantity::ad::third_derivative( partial2( - |v, &t, n| self.eos.lift().residual_helmholtz_energy_unit(t, v, n), + |v, &t, n| self.eos.lift().residual_molar_helmholtz_energy(t, v, n), &self.temperature, - &self.moles, + &self.molefracs, ), - self.volume, + self.molar_volume, ); let _ = self.cache.a.set(a); let _ = self.cache.da_dv.set(da_dv); @@ -223,59 +234,62 @@ where &self, contributions: Contributions, ) -> < as Div>>::Output as Div>>::Output { - self.volume / (self.density * self.density) - * (self.volume * self.d2p_dv2(contributions) + self.dp_dv(contributions) * 2.0) + self.molar_volume.powi::<3>() + * (self.molar_volume * self.d2p_dv2(contributions) + self.dp_dv(contributions) * 2.0) } /// Structure factor: $S(0)=k_BT\left(\frac{\partial\rho}{\partial p}\right)_{T,N_i}$ pub fn structure_factor(&self) -> D { - -(self.temperature * self.density * RGAS / (self.volume * self.dp_dv(Contributions::Total))) - .into_value() + -(self.temperature * self.density * RGAS + / (self.molar_volume * self.dp_dv(Contributions::Total))) + .into_value() } /// Partial molar volume: $v_i=\left(\frac{\partial V}{\partial N_i}\right)_{T,p,N_j}$ pub fn partial_molar_volume(&self) -> MolarVolume> { - -self.dp_dni(Contributions::Total) / self.dp_dv(Contributions::Total) + -self.n_dp_dni(Contributions::Total) / self.dp_dv(Contributions::Total) } - /// Partial derivative of chemical potential w.r.t. moles: $\left(\frac{\partial\mu_i}{\partial N_j}\right)_{T,V,N_k}$ - pub fn dmu_dni(&self, contributions: Contributions) -> DeDn> + /// Partial derivative of chemical potential w.r.t. moles: $N\left(\frac{\partial\mu_i}{\partial N_j}\right)_{T,V,N_k}$ + pub fn n_dmu_dni(&self, contributions: Contributions) -> MolarEnergy> where DefaultAllocator: Allocator, { let (a, da_dn, d2a_dn2) = quantity::ad::hessian_copy( partial2( - |n, &t, &v| self.eos.lift().residual_helmholtz_energy_unit(t, v, &n), + |n: Dimensionless<_>, &t, &v| { + self.eos.lift().residual_molar_helmholtz_energy(t, v, &n) + }, &self.temperature, - &self.volume, + &self.molar_volume, ), - &self.moles, + &Dimensionless::new(self.molefracs.clone()), ); let _ = self.cache.a.set(a); let _ = self.cache.da_dn.set(da_dn); let residual = || d2a_dn2; let ideal_gas = || { Dimensionless::new(OMatrix::from_diagonal(&self.molefracs.map(|x| x.recip()))) - * (self.temperature * RGAS / self.total_moles) + * (self.temperature * RGAS) }; Self::contributions(ideal_gas, residual, contributions) } /// Isothermal compressibility: $\kappa_T=-\frac{1}{V}\left(\frac{\partial V}{\partial p}\right)_{T,N_i}$ pub fn isothermal_compressibility(&self) -> InvP { - -(self.dp_dv(Contributions::Total) * self.volume).inv() + -(self.dp_dv(Contributions::Total) * self.molar_volume).inv() } // entropy derivatives - /// Partial derivative of the residual entropy w.r.t. temperature: $\left(\frac{\partial S^\text{res}}{\partial T}\right)_{V,N_i}$ - pub fn ds_res_dt(&self) -> as Div>>::Output { + /// Partial derivative of the residual molar entropy w.r.t. temperature: $\left(\frac{\partial s^\text{res}}{\partial T}\right)_{V,N_i}$ + pub fn ds_res_dt(&self) -> as Div>>::Output { -*self.cache.d2a_dt2.get_or_init(|| { let (a, da_dt, d2a_dt2) = quantity::ad::second_derivative( partial2( - |t, &v, n| self.eos.lift().residual_helmholtz_energy_unit(t, v, n), - &self.volume, - &self.moles, + |t, &v, n| self.eos.lift().residual_molar_helmholtz_energy(t, v, n), + &self.molar_volume, + &self.molefracs, ), self.temperature, ); @@ -285,16 +299,16 @@ where }) } - /// Second partial derivative of the residual entropy w.r.t. temperature: $\left(\frac{\partial^2S^\text{res}}{\partial T^2}\right)_{V,N_i}$ + /// Second partial derivative of the residual molar entropy w.r.t. temperature: $\left(\frac{\partial^2s^\text{res}}{\partial T^2}\right)_{V,N_i}$ pub fn d2s_res_dt2( &self, - ) -> < as Div>>::Output as Div>>::Output { + ) -> < as Div>>::Output as Div>>::Output { -*self.cache.d3a_dt3.get_or_init(|| { let (a, da_dt, d2a_dt2, d3a_dt3) = quantity::ad::third_derivative( partial2( - |t, &v, n| self.eos.lift().residual_helmholtz_energy_unit(t, v, n), - &self.volume, - &self.moles, + |t, &v, n| self.eos.lift().residual_molar_helmholtz_energy(t, v, n), + &self.molar_volume, + &self.molefracs, ), self.temperature, ); @@ -312,10 +326,15 @@ where .get_or_init(|| { let (a, da_dn, da_dt, d2a_dndt) = quantity::ad::partial_hessian_copy( partial( - |(n, t), &v| self.eos.lift().residual_helmholtz_energy_unit(t, v, &n), - &self.volume, + |(n, t): (Dimensionless<_>, _), &v| { + self.eos.lift().residual_molar_helmholtz_energy(t, v, &n) + }, + &self.molar_volume, + ), + ( + &Dimensionless::new(self.molefracs.clone()), + self.temperature, ), - (&self.moles, self.temperature), ); let _ = self.cache.a.set(a); let _ = self.cache.da_dn.set(da_dn); @@ -350,17 +369,19 @@ where .add_scalar(-self.pressure(Contributions::Total).inv()) } - /// Partial derivative of the logarithm of the fugacity coefficient w.r.t. moles: $\left(\frac{\partial\ln\varphi_i}{\partial N_j}\right)_{T,p,N_k}$ - pub fn dln_phi_dnj(&self) -> InvM> + /// Partial derivative of the logarithm of the fugacity coefficient w.r.t. moles: $N\left(\frac{\partial\ln\varphi_i}{\partial N_j}\right)_{T,p,N_k}$ + pub fn n_dln_phi_dnj(&self) -> OMatrix where DefaultAllocator: Allocator, { - let dmu_dni = self.dmu_dni(Contributions::Residual); - let dp_dni = self.dp_dni(Contributions::Total); + let dmu_dni = self.n_dmu_dni(Contributions::Residual); + let dp_dni = self.n_dp_dni(Contributions::Total); let dp_dv = self.dp_dv(Contributions::Total); let (r, c) = dmu_dni.shape_generic(); let dp_dn_2 = Quantity::from_fn_generic(r, c, |i, j| dp_dni.get(i) * dp_dni.get(j)); - ((dmu_dni + dp_dn_2 / dp_dv) / (self.temperature * RGAS)).add_scalar(self.total_moles.inv()) + ((dmu_dni + dp_dn_2 / dp_dv) / (self.temperature * RGAS)) + .into_value() + .add_scalar(D::from(1.0)) } } @@ -371,11 +392,11 @@ impl State { (0..self.eos.components()) .map(|i| { let eos = self.eos.subset(&[i]); - let state = State::new_xpt( + let state = State::new_npt( &eos, self.temperature, pressure, - &dvector![1.0], + dvector![1.0], Some(crate::DensityInitialization::Liquid), )?; Ok(state.ln_phi()[0]) @@ -424,12 +445,7 @@ impl State { }?; // Calculate the liquid state including the Henry components - let liquid = State::new_nvt( - eos, - temperature, - vle.liquid().volume, - &(molefracs * vle.liquid().total_moles), - )?; + let liquid = State::new(eos, temperature, vle.liquid().density, molefracs.clone())?; // Calculate the vapor state including the Henry components let mut molefracs_vapor = molefracs.clone(); @@ -437,12 +453,7 @@ impl State { .into_iter() .zip(&vle.vapor().molefracs) .for_each(|(i, &y)| molefracs_vapor[i] = y); - let vapor = State::new_nvt( - eos, - temperature, - vle.vapor().volume, - &(molefracs_vapor * vle.vapor().total_moles), - )?; + let vapor = State::new(eos, temperature, vle.vapor().density, molefracs.clone())?; // Determine the Henry's law coefficients and return only those of the Henry components let p = vle.vapor().pressure(Contributions::Total).into_reduced(); @@ -465,11 +476,10 @@ impl State { impl State { /// Thermodynamic factor: $\Gamma_{ij}=\delta_{ij}+x_i\left(\frac{\partial\ln\varphi_i}{\partial x_j}\right)_{T,p,\Sigma}$ pub fn thermodynamic_factor(&self) -> DMatrix { - let dln_phi_dnj = (self.dln_phi_dnj() * Moles::from_reduced(1.0)).into_value(); - let moles = &self.molefracs * self.total_moles.into_reduced(); + let dln_phi_dnj = self.n_dln_phi_dnj(); let n = self.eos.components() - 1; DMatrix::from_fn(n, n, |i, j| { - moles[i] * (dln_phi_dnj[(i, j)] - dln_phi_dnj[(i, n)]) + if i == j { 1.0 } else { 0.0 } + dln_phi_dnj[(i, j)] - dln_phi_dnj[(i, n)] + if i == j { 1.0 } else { 0.0 } }) } } @@ -480,63 +490,62 @@ where { /// Residual molar isochoric heat capacity: $c_v^\text{res}=\left(\frac{\partial u^\text{res}}{\partial T}\right)_{V,N_i}$ pub fn residual_molar_isochoric_heat_capacity(&self) -> MolarEntropy { - self.ds_res_dt() * self.temperature / self.total_moles + self.ds_res_dt() * self.temperature } /// Partial derivative of the residual molar isochoric heat capacity w.r.t. temperature: $\left(\frac{\partial c_V^\text{res}}{\partial T}\right)_{V,N_i}$ pub fn dc_v_res_dt(&self) -> as Div>>::Output { - (self.temperature * self.d2s_res_dt2() + self.ds_res_dt()) / self.total_moles + self.temperature * self.d2s_res_dt2() + self.ds_res_dt() } /// Residual molar isobaric heat capacity: $c_p^\text{res}=\left(\frac{\partial h^\text{res}}{\partial T}\right)_{p,N_i}$ pub fn residual_molar_isobaric_heat_capacity(&self) -> MolarEntropy { let dp_dt = self.dp_dt(Contributions::Total); - self.temperature / self.total_moles - * (self.ds_res_dt() - dp_dt * dp_dt / self.dp_dv(Contributions::Total)) + self.temperature * (self.ds_res_dt() - dp_dt * dp_dt / self.dp_dv(Contributions::Total)) - RGAS } /// Residual enthalpy: $H^\text{res}(T,p,\mathbf{n})=A^\text{res}+TS^\text{res}+p^\text{res}V$ pub fn residual_enthalpy(&self) -> Energy { - self.temperature * self.residual_entropy() - + self.residual_helmholtz_energy() - + self.pressure(Contributions::Residual) * self.volume + self.residual_molar_enthalpy() * self.total_moles() } /// Residual molar enthalpy: $h^\text{res}(T,p,\mathbf{n})=a^\text{res}+Ts^\text{res}+p^\text{res}v$ pub fn residual_molar_enthalpy(&self) -> MolarEnergy { - self.residual_enthalpy() / self.total_moles + self.temperature * self.residual_molar_entropy() + + self.residual_molar_helmholtz_energy() + + self.pressure(Contributions::Residual) * self.molar_volume } /// Residual internal energy: $U^\text{res}(T,V,\mathbf{n})=A^\text{res}+TS^\text{res}$ pub fn residual_internal_energy(&self) -> Energy { - self.temperature * self.residual_entropy() + self.residual_helmholtz_energy() + self.residual_molar_internal_energy() * self.total_moles() } /// Residual molar internal energy: $u^\text{res}(T,V,\mathbf{n})=a^\text{res}+Ts^\text{res}$ pub fn residual_molar_internal_energy(&self) -> MolarEnergy { - self.residual_internal_energy() / self.total_moles + self.temperature * self.residual_molar_entropy() + self.residual_molar_helmholtz_energy() } /// Residual Gibbs energy: $G^\text{res}(T,p,\mathbf{n})=A^\text{res}+p^\text{res}V-NRT \ln Z$ pub fn residual_gibbs_energy(&self) -> Energy { - self.pressure(Contributions::Residual) * self.volume + self.residual_helmholtz_energy() - - self.total_moles - * RGAS - * self.temperature - * Dimensionless::new(self.compressibility(Contributions::Total).ln()) + self.residual_molar_gibbs_energy() * self.total_moles() } /// Residual Gibbs energy: $g^\text{res}(T,p,\mathbf{n})=a^\text{res}+p^\text{res}v-RT \ln Z$ pub fn residual_molar_gibbs_energy(&self) -> MolarEnergy { - self.residual_gibbs_energy() / self.total_moles + self.pressure(Contributions::Residual) * self.molar_volume + + self.residual_molar_helmholtz_energy() + - self.temperature + * RGAS + * Dimensionless::new(self.compressibility(Contributions::Total).ln()) } /// Molar Helmholtz energy $a^\text{res}$ evaluated for each residual contribution of the equation of state. pub fn residual_molar_helmholtz_energy_contributions( &self, ) -> Vec<(&'static str, MolarEnergy)> { - let residual_contributions = self.eos.molar_helmholtz_energy_contributions( + let residual_contributions = self.eos.helmholtz_energy_contributions( self.temperature.into_reduced(), self.density.into_reduced().recip(), &self.molefracs, @@ -557,10 +566,7 @@ where let v = Dual::from_re(self.temperature.into_reduced()); let mut x = self.molefracs.map(Dual::from_re); x[component].eps = D::one(); - let contributions = self - .eos - .lift() - .molar_helmholtz_energy_contributions(t, v, &x); + let contributions = self.eos.lift().helmholtz_energy_contributions(t, v, &x); let mut res = Vec::with_capacity(contributions.len()); for (s, v) in contributions { res.push((s, MolarEnergy::from_reduced(v.eps))); @@ -573,10 +579,7 @@ where let t = Dual::from_re(self.temperature.into_reduced()); let v = Dual::from_re(self.density.into_reduced().recip()).derivative(); let x = self.molefracs.map(Dual::from_re); - let contributions = self - .eos - .lift() - .molar_helmholtz_energy_contributions(t, v, &x); + let contributions = self.eos.lift().helmholtz_energy_contributions(t, v, &x); let mut res = Vec::with_capacity(contributions.len() + 1); res.push(("Ideal gas", self.density * RGAS * self.temperature)); for (s, v) in contributions { @@ -599,12 +602,15 @@ where /// Mass of each component: $m_i=n_iMW_i$ pub fn mass(&self) -> Mass> { - self.eos.molar_weight().component_mul(&self.moles) + self.eos + .molar_weight() + .component_mul(&Dimensionless::new(self.molefracs.clone())) + * self.total_moles() } /// Total mass: $m=\sum_im_i=nMW$ pub fn total_mass(&self) -> Mass { - self.total_moles * self.total_molar_weight() + self.total_molar_weight() * self.total_moles() } /// Mass density: $\rho^{(m)}=\frac{m}{V}$ @@ -614,7 +620,10 @@ where /// Mass fractions: $w_i=\frac{m_i}{m}$ pub fn massfracs(&self) -> OVector { - (self.mass() / self.total_mass()).into_value() + self.eos + .molar_weight() + .convert_into(self.total_molar_weight()) + .component_mul(&self.molefracs) } } @@ -630,7 +639,7 @@ where pub fn viscosity(&self) -> Viscosity { let s = self.residual_molar_entropy().into_reduced(); self.eos - .viscosity_reference(self.temperature, self.volume, &self.moles) + .viscosity_reference(self.temperature, self.molar_volume, &self.molefracs) * Dimensionless::new(self.eos.viscosity_correlation(s, &self.molefracs).exp()) } @@ -646,14 +655,14 @@ where /// Return the viscosity reference as used in entropy scaling. pub fn viscosity_reference(&self) -> Viscosity { self.eos - .viscosity_reference(self.temperature, self.volume, &self.moles) + .viscosity_reference(self.temperature, self.molar_volume, &self.molefracs) } /// Return the diffusion via entropy scaling. pub fn diffusion(&self) -> Diffusivity { let s = self.residual_molar_entropy().into_reduced(); self.eos - .diffusion_reference(self.temperature, self.volume, &self.moles) + .diffusion_reference(self.temperature, self.molar_volume, &self.molefracs) * Dimensionless::new(self.eos.diffusion_correlation(s, &self.molefracs).exp()) } @@ -669,19 +678,21 @@ where /// Return the diffusion reference as used in entropy scaling. pub fn diffusion_reference(&self) -> Diffusivity { self.eos - .diffusion_reference(self.temperature, self.volume, &self.moles) + .diffusion_reference(self.temperature, self.molar_volume, &self.molefracs) } /// Return the thermal conductivity via entropy scaling. pub fn thermal_conductivity(&self) -> ThermalConductivity { let s = self.residual_molar_entropy().into_reduced(); - self.eos - .thermal_conductivity_reference(self.temperature, self.volume, &self.moles) - * Dimensionless::new( - self.eos - .thermal_conductivity_correlation(s, &self.molefracs) - .exp(), - ) + self.eos.thermal_conductivity_reference( + self.temperature, + self.molar_volume, + &self.molefracs, + ) * Dimensionless::new( + self.eos + .thermal_conductivity_correlation(s, &self.molefracs) + .exp(), + ) } /// Return the logarithm of the reduced thermal conductivity. @@ -696,7 +707,10 @@ where /// Return the thermal conductivity reference as used in entropy scaling. pub fn thermal_conductivity_reference(&self) -> ThermalConductivity { - self.eos - .thermal_conductivity_reference(self.temperature, self.volume, &self.moles) + self.eos.thermal_conductivity_reference( + self.temperature, + self.molar_volume, + &self.molefracs, + ) } } diff --git a/crates/feos-core/src/state/statevec.rs b/crates/feos-core/src/state/statevec.rs index a62fe9413..63d51b3fd 100644 --- a/crates/feos-core/src/state/statevec.rs +++ b/crates/feos-core/src/state/statevec.rs @@ -63,7 +63,7 @@ impl StateVec<'_, E> { pub fn moles(&self) -> Moles> { Moles::from_shape_fn((self.0.len(), self.0[0].eos.components()), |(i, j)| { - self.0[i].moles.get(j) + self.0[i].moles().get(j) }) } diff --git a/crates/feos-derive/src/residual.rs b/crates/feos-derive/src/residual.rs index 12e4fe862..df3f5367f 100644 --- a/crates/feos-derive/src/residual.rs +++ b/crates/feos-derive/src/residual.rs @@ -1,4 +1,4 @@ -use super::{implement, OPT_IMPLS}; +use super::{OPT_IMPLS, implement}; use quote::quote; use syn::DeriveInput; @@ -201,19 +201,19 @@ fn impl_entropy_scaling( let name = &v.ident; if implement("entropy_scaling", v, &OPT_IMPLS)? { etar.push(quote! { - Self::#name(eos) => eos.viscosity_reference(temperature, volume, moles) + Self::#name(eos) => eos.viscosity_reference(temperature, molar_volume, molefracs) }); etac.push(quote! { Self::#name(eos) => eos.viscosity_correlation(s_res, x) }); dr.push(quote! { - Self::#name(eos) => eos.diffusion_reference(temperature, volume, moles) + Self::#name(eos) => eos.diffusion_reference(temperature, molar_volume, molefracs) }); dc.push(quote! { Self::#name(eos) => eos.diffusion_correlation(s_res, x) }); thcr.push(quote! { - Self::#name(eos) => eos.thermal_conductivity_reference(temperature, volume, moles) + Self::#name(eos) => eos.thermal_conductivity_reference(temperature, molar_volume, molefracs) }); thcc.push(quote! { Self::#name(eos) => eos.thermal_conductivity_correlation(s_res, x) @@ -245,8 +245,8 @@ fn impl_entropy_scaling( fn viscosity_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &DVector, ) -> Viscosity { match self { #(#etar,)* @@ -262,8 +262,8 @@ fn impl_entropy_scaling( fn diffusion_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &DVector, ) -> Diffusivity { match self { #(#dr,)* @@ -279,8 +279,8 @@ fn impl_entropy_scaling( fn thermal_conductivity_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &DVector, ) -> ThermalConductivity { match self { #(#thcr,)* diff --git a/crates/feos-dft/src/adsorption/mod.rs b/crates/feos-dft/src/adsorption/mod.rs index 9962f27a5..bf9ddabe0 100644 --- a/crates/feos-dft/src/adsorption/mod.rs +++ b/crates/feos-dft/src/adsorption/mod.rs @@ -1,11 +1,12 @@ //! Adsorption profiles and isotherms. use super::functional::HelmholtzEnergyFunctional; use super::solver::DFTSolver; +use feos_core::DensityInitialization::{Liquid, Vapor}; use feos_core::{ - Contributions, DensityInitialization, FeosError, FeosResult, ReferenceSystem, SolverOptions, - State, StateBuilder, + Composition, Contributions, DensityInitialization, FeosError, FeosResult, ReferenceSystem, + SolverOptions, State, }; -use nalgebra::{DMatrix, DVector}; +use nalgebra::{DMatrix, DVector, Dyn}; use ndarray::{Array1, Array2, Dimension, Ix1, Ix3, RemoveAxis}; use quantity::{Energy, MolarEnergy, Moles, Pressure, Temperature}; use std::iter; @@ -53,12 +54,12 @@ where } /// Calculate an adsorption isotherm (starting at low pressure) - pub fn adsorption_isotherm>( + pub fn adsorption_isotherm, X: Composition + Clone>( functional: &F, temperature: Temperature, pressure: &Pressure>, pore: &S, - molefracs: &Option>, + composition: X, solver: Option<&DFTSolver>, ) -> FeosResult> { Self::isotherm( @@ -66,19 +67,19 @@ where temperature, pressure, pore, - molefracs, + composition, DensityInitialization::Vapor, solver, ) } /// Calculate an desorption isotherm (starting at high pressure) - pub fn desorption_isotherm>( + pub fn desorption_isotherm, X: Composition + Clone>( functional: &F, temperature: Temperature, pressure: &Pressure>, pore: &S, - molefracs: &Option>, + composition: X, solver: Option<&DFTSolver>, ) -> FeosResult> { let pressure = pressure.into_iter().rev().collect(); @@ -87,7 +88,7 @@ where temperature, &pressure, pore, - molefracs, + composition, DensityInitialization::Liquid, solver, )?; @@ -98,12 +99,12 @@ where } /// Calculate an equilibrium isotherm - pub fn equilibrium_isotherm>( + pub fn equilibrium_isotherm, X: Composition + Clone>( functional: &F, temperature: Temperature, pressure: &Pressure>, pore: &S, - molefracs: &Option>, + composition: X, solver: Option<&DFTSolver>, ) -> FeosResult> { let (p_min, p_max) = (pressure.get(0), pressure.get(pressure.len() - 1)); @@ -113,7 +114,7 @@ where p_min, p_max, pore, - molefracs, + composition.clone(), solver, SolverOptions::default(), ); @@ -132,7 +133,7 @@ where temperature, &p_ads, pore, - molefracs, + composition.clone(), solver, )? .profiles; @@ -141,7 +142,7 @@ where temperature, &p_des, pore, - molefracs, + composition, solver, )? .profiles; @@ -155,7 +156,7 @@ where temperature, pressure, pore, - molefracs, + composition.clone(), solver, )?; let desorption = Self::desorption_isotherm( @@ -163,7 +164,7 @@ where temperature, pressure, pore, - molefracs, + composition, solver, )?; let omega_a = adsorption.grand_potential(); @@ -181,25 +182,24 @@ where } } - fn isotherm>( + fn isotherm, X: Composition + Clone>( functional: &F, temperature: Temperature, pressure: &Pressure>, pore: &S, - molefracs: &Option>, + composition: X, density_initialization: DensityInitialization, solver: Option<&DFTSolver>, ) -> FeosResult> { - let x = functional.validate_molefracs(molefracs)?; let mut profiles: Vec>> = Vec::with_capacity(pressure.len()); // On the first iteration, initialize the density profile according to the direction // and calculate the external potential once. - let mut bulk = State::new_xpt( + let mut bulk = State::new_npt( functional, temperature, pressure.get(0), - &x, + composition.clone(), Some(density_initialization), )?; if functional.components() > 1 && !bulk.is_stable(SolverOptions::default())? { @@ -215,11 +215,13 @@ where let mut old_density = Some(&profile.density); for i in 0..pressure.len() { - let mut bulk = StateBuilder::new(functional) - .temperature(temperature) - .pressure(pressure.get(i)) - .molefracs(&x) - .build()?; + let mut bulk = State::new_npt( + functional, + temperature, + pressure.get(i), + composition.clone(), + None, + )?; if functional.components() > 1 && !bulk.is_stable(SolverOptions::default())? { bulk = bulk .tp_flash(None, SolverOptions::default(), None)? @@ -243,37 +245,21 @@ where /// Calculate the phase transition from an empty to a filled pore. #[expect(clippy::too_many_arguments)] - pub fn phase_equilibrium>( + pub fn phase_equilibrium, X: Composition + Clone>( functional: &F, temperature: Temperature, p_min: Pressure, p_max: Pressure, pore: &S, - molefracs: &Option>, + composition: X, solver: Option<&DFTSolver>, options: SolverOptions, ) -> FeosResult> { - let x = functional.validate_molefracs(molefracs)?; - + let x = composition; // calculate density profiles for the minimum and maximum pressure - let vapor_bulk = StateBuilder::new(functional) - .temperature(temperature) - .pressure(p_min) - .molefracs(&x) - .vapor() - .build()?; - let bulk_init = StateBuilder::new(functional) - .temperature(temperature) - .pressure(p_max) - .molefracs(&x) - .liquid() - .build()?; - let liquid_bulk = StateBuilder::new(functional) - .temperature(temperature) - .pressure(p_max) - .molefracs(&x) - .vapor() - .build()?; + let vapor_bulk = State::new_npt(functional, temperature, p_min, x.clone(), Some(Vapor))?; + let bulk_init = State::new_npt(functional, temperature, p_max, x.clone(), Some(Liquid))?; + let liquid_bulk = State::new_npt(functional, temperature, p_max, x.clone(), Some(Vapor))?; let mut vapor = pore.initialize(&vapor_bulk, None, None)?.solve(solver)?; let mut liquid = pore.initialize(&bulk_init, None, None)?.solve(solver)?; @@ -287,18 +273,13 @@ where / (n_dp_drho_v / vapor_bulk.density - n_dp_drho_l / liquid_bulk.density); // update filled pore with limited step size - let mut bulk = StateBuilder::new(functional) - .temperature(temperature) - .pressure(p_max) - .molefracs(&x) - .vapor() - .build()?; + let mut bulk = State::new_npt(functional, temperature, p_max, x.clone(), Some(Vapor))?; let rho0 = liquid_bulk.density; let steps = (10.0 * (rho - rho0) / rho0).into_value().abs().ceil() as usize; let delta_rho = (rho - rho0) / steps as f64; for i in 1..=steps { let rho_i = rho0 + i as f64 * delta_rho; - bulk = State::new_intensive(functional, temperature, rho_i, &x)?; + bulk = State::new(functional, temperature, rho_i, x.clone())?; liquid = liquid.update_bulk(&bulk).solve(solver)?; } @@ -324,7 +305,7 @@ where rho += delta_rho; // update bulk phase - bulk = State::new_intensive(functional, temperature, rho, &x)?; + bulk = State::new(functional, temperature, rho, x.clone())?; } Err(FeosError::NotConverged( "Adsorption::phase_equilibrium".into(), diff --git a/crates/feos-dft/src/adsorption/pore.rs b/crates/feos-dft/src/adsorption/pore.rs index faf19e62d..ddf2f7479 100644 --- a/crates/feos-dft/src/adsorption/pore.rs +++ b/crates/feos-dft/src/adsorption/pore.rs @@ -6,9 +6,7 @@ use crate::functional_contribution::FunctionalContribution; use crate::geometry::{Axis, Geometry, Grid}; use crate::profile::{DFTProfile, MAX_POTENTIAL}; use crate::solver::DFTSolver; -use feos_core::{ - Contributions, FeosResult, ReferenceSystem, ResidualDyn, State, StateBuilder, StateHD, -}; +use feos_core::{Contributions, FeosResult, ReferenceSystem, ResidualDyn, State, StateHD}; use nalgebra::{DVector, dvector}; use ndarray::prelude::*; use ndarray::{Axis as Axis_nd, RemoveAxis}; @@ -69,10 +67,7 @@ pub trait PoreSpecification { where D::Larger: Dimension, { - let bulk = StateBuilder::new(&&Helium) - .temperature(298.0 * KELVIN) - .density(Density::from_reduced(1.0)) - .build()?; + let bulk = State::new_pure(&&Helium, 298.0 * KELVIN, Density::from_reduced(1.0))?; let pore = self.initialize(&bulk, None, None)?; let pot = Dimensionless::from_reduced( pore.profile diff --git a/crates/feos-dft/src/interface/mod.rs b/crates/feos-dft/src/interface/mod.rs index 6697535d0..1e9f02b99 100644 --- a/crates/feos-dft/src/interface/mod.rs +++ b/crates/feos-dft/src/interface/mod.rs @@ -84,8 +84,8 @@ impl PlanarInterface { let reduced_temperature = (vle.vapor().temperature / critical_temperature).into_value(); profile.profile.density = Density::from_shape_fn(profile.profile.density.raw_dim(), |(i, z)| { - let rho_v = profile.vle.vapor().partial_density.get(indices[i]); - let rho_l = profile.vle.liquid().partial_density.get(indices[i]); + let rho_v = profile.vle.vapor().partial_density().get(indices[i]); + let rho_l = profile.vle.liquid().partial_density().get(indices[i]); 0.5 * (rho_l - rho_v) * (sign * (profile.profile.grid.grids()[0][z] - z0) / 3.0 * (2.4728 - 2.3625 * reduced_temperature)) @@ -343,8 +343,9 @@ fn interp_symmetric( radius: Length, ) -> FeosResult>> { let reduced_density = Array2::from_shape_fn(rho_pdgt.raw_dim(), |(i, j)| { - ((rho_pdgt.get((i, j)) - vle_pdgt.vapor().partial_density.get(i)) - / (vle_pdgt.liquid().partial_density.get(i) - vle_pdgt.vapor().partial_density.get(i))) + ((rho_pdgt.get((i, j)) - vle_pdgt.vapor().partial_density().get(i)) + / (vle_pdgt.liquid().partial_density().get(i) + - vle_pdgt.vapor().partial_density().get(i))) .into_value() - 0.5 }); @@ -371,8 +372,8 @@ fn interp_symmetric( reduced_density.raw_dim(), |(i, j)| { reduced_density[(i, j)] - * (vle.liquid().partial_density.get(i) - vle.vapor().partial_density.get(i)) - + vle.vapor().partial_density.get(i) + * (vle.liquid().partial_density().get(i) - vle.vapor().partial_density().get(i)) + + vle.vapor().partial_density().get(i) }, )) } diff --git a/crates/feos-dft/src/pdgt.rs b/crates/feos-dft/src/pdgt.rs index 47ad359eb..eb7cfe0f2 100644 --- a/crates/feos-dft/src/pdgt.rs +++ b/crates/feos-dft/src/pdgt.rs @@ -185,7 +185,7 @@ pub trait PdgtFunctionalProperties: HelmholtzEnergyFunctional { let mu_res = vle.vapor().residual_chemical_potential(); for i in 0..self.components() { let rhoi = density.index_axis(Axis(0), i).to_owned(); - let rhoi_b = vle.vapor().partial_density.get(i); + let rhoi_b = vle.vapor().partial_density().get(i); let mui_res = mu_res.get(i); let kt = RGAS * vle.vapor().temperature; delta_omega += @@ -198,8 +198,8 @@ pub trait PdgtFunctionalProperties: HelmholtzEnergyFunctional { let drho = gradient( &density, -dx, - &vle.liquid().partial_density, - &vle.vapor().partial_density, + &vle.liquid().partial_density(), + &vle.vapor().partial_density(), ); // calculate integrand diff --git a/crates/feos-dft/src/profile/mod.rs b/crates/feos-dft/src/profile/mod.rs index edac0e7b2..941a3818b 100644 --- a/crates/feos-dft/src/profile/mod.rs +++ b/crates/feos-dft/src/profile/mod.rs @@ -232,7 +232,7 @@ where let mut bonds = bulk.eos.bond_integrals(t, &exp_dfdrho, convolver.as_ref()); bonds *= &exp_dfdrho; let mut density = Array::zeros(external_potential.raw_dim()); - let bulk_density = bulk.partial_density.to_reduced(); + let bulk_density = bulk.partial_density().into_reduced(); for (s, &c) in bulk.eos.component_index().iter().enumerate() { density.index_axis_mut(Axis_nd(0), s).assign( &(bonds.index_axis(Axis_nd(0), s).map(|is| is.min(1.0)) * bulk_density[c]), @@ -373,7 +373,7 @@ where pub fn residual(&self, log: bool) -> FeosResult<(Array, Array1, f64)> { // Read from profile let density = self.density.to_reduced(); - let partial_density = self.bulk.partial_density.to_reduced(); + let partial_density = self.bulk.partial_density().into_reduced(); let bulk_density = self .bulk .eos @@ -484,7 +484,7 @@ where // Read from profile let component_index = self.bulk.eos.component_index().into_owned(); let mut density = self.density.to_reduced(); - let partial_density = self.bulk.partial_density.to_reduced(); + let partial_density = self.bulk.partial_density().into_reduced(); let mut bulk_density = component_index .iter() .map(|&i| partial_density[i]) @@ -495,13 +495,12 @@ where // Update profile self.density = Density::from_reduced(density); - let volume = Volume::from_reduced(1.0); - let mut moles = self.bulk.moles.clone(); + let mut partial_density = self.bulk.partial_density(); bulk_density .into_iter() .enumerate() - .for_each(|(i, r)| moles.set(component_index[i], Density::from_reduced(r) * volume)); - self.bulk = State::new_nvt(&self.bulk.eos, self.bulk.temperature, volume, &moles)?; + .for_each(|(i, r)| partial_density.set(component_index[i], Density::from_reduced(r))); + self.bulk = State::new_density(&self.bulk.eos, self.bulk.temperature, partial_density)?; Ok(()) } diff --git a/crates/feos-dft/src/profile/properties.rs b/crates/feos-dft/src/profile/properties.rs index 225967409..6095fb172 100644 --- a/crates/feos-dft/src/profile/properties.rs +++ b/crates/feos-dft/src/profile/properties.rs @@ -298,7 +298,7 @@ where { fn density_derivative(&self, lhs: &Array) -> FeosResult> { let rho = self.density.to_reduced(); - let partial_density = self.bulk.partial_density.to_reduced(); + let partial_density = self.bulk.partial_density().into_reduced(); let rho_bulk = self .bulk .eos @@ -402,7 +402,7 @@ where dfdrho += &(&self.external_potential * t).mapv(|v| Dual64::from(v) / t_dual); // calculate bulk functional derivative - let partial_density = self.bulk.partial_density.to_reduced(); + let partial_density = self.bulk.partial_density().into_reduced(); let rho_bulk: Array1<_> = self .bulk .eos diff --git a/crates/feos-dft/src/solvation/pair_correlation.rs b/crates/feos-dft/src/solvation/pair_correlation.rs index d901887cb..25849af4f 100644 --- a/crates/feos-dft/src/solvation/pair_correlation.rs +++ b/crates/feos-dft/src/solvation/pair_correlation.rs @@ -59,12 +59,10 @@ impl PairCorrelation { self.profile.solve(solver, debug)?; // calculate pair correlation function + let partial_density = self.profile.bulk.partial_density(); self.pair_correlation_function = Some(Array::from_shape_fn( self.profile.density.raw_dim(), - |(i, j)| { - (self.profile.density.get((i, j)) / self.profile.bulk.partial_density.get(i)) - .into_value() - }, + |(i, j)| (self.profile.density.get((i, j)) / partial_density.get(i)).into_value(), )); // calculate self solvation free energy diff --git a/crates/feos/benches/contributions.rs b/crates/feos/benches/contributions.rs index 3b2e8b367..3e74bf68c 100644 --- a/crates/feos/benches/contributions.rs +++ b/crates/feos/benches/contributions.rs @@ -74,7 +74,7 @@ fn pcsaft(c: &mut Criterion) { State::new_npt(&&eos, t, p, &moles, Some(DensityInitialization::Liquid)).unwrap(); let temperature = Dual64::from(state.temperature.into_reduced()).derivative(); let molar_volume = Dual::from(1.0 / state.density.into_reduced()); - let moles = state.moles.to_reduced().map(Dual::from); + let moles = state.moles().to_reduced().map(Dual::from); // let state_hd = state.derive1(Derivative::DT); let name1 = comp1.identifier.name.as_deref().unwrap(); let name2 = comp2.identifier.name.as_deref().unwrap(); diff --git a/crates/feos/benches/dft_pore.rs b/crates/feos/benches/dft_pore.rs index 667c5ff12..98dcba4e2 100644 --- a/crates/feos/benches/dft_pore.rs +++ b/crates/feos/benches/dft_pore.rs @@ -2,7 +2,7 @@ //! in pores at different conditions. use criterion::{Criterion, criterion_group, criterion_main}; use feos::core::parameter::IdentifierOption; -use feos::core::{PhaseEquilibrium, State, StateBuilder}; +use feos::core::{PhaseEquilibrium, State}; use feos::dft::adsorption::{ExternalPotential, Pore1D, PoreSpecification}; use feos::dft::{DFTSolver, Geometry}; use feos::gc_pcsaft::{GcPcSaftFunctional, GcPcSaftParameters}; @@ -80,11 +80,8 @@ fn pcsaft(c: &mut Criterion) { group.bench_function("butane_pentane_liquid", |b| { b.iter(|| pore.initialize(bulk, None, None).unwrap().solve(None)) }); - let bulk = StateBuilder::new(&func) - .temperature(300.0 * KELVIN) - .partial_density(&(&vle.vapor().partial_density * 0.2)) - .build() - .unwrap(); + let bulk = + State::new_density(&func, 300.0 * KELVIN, vle.vapor().partial_density() * 0.2).unwrap(); group.bench_function("butane_pentane_vapor", |b| { b.iter(|| pore.initialize(&bulk, None, None).unwrap().solve(None)) }); diff --git a/crates/feos/benches/dual_numbers.rs b/crates/feos/benches/dual_numbers.rs index 454c6828f..c3c1bd07f 100644 --- a/crates/feos/benches/dual_numbers.rs +++ b/crates/feos/benches/dual_numbers.rs @@ -21,9 +21,9 @@ use quantity::*; fn state_pcsaft(n: usize, eos: &PcSaft) -> State<&PcSaft> { let moles = DVector::from_element(n, 1.0 / n as f64) * 10.0 * MOL; let molefracs = (&moles / moles.sum()).into_value(); - let cp = State::critical_point(&eos, Some(&molefracs), None, None, Default::default()).unwrap(); + let cp = State::critical_point(&eos, molefracs, None, None, Default::default()).unwrap(); let temperature = 0.8 * cp.temperature; - State::new_nvt(&eos, temperature, cp.volume, &moles).unwrap() + State::new_nvt(&eos, temperature, cp.volume(), moles).unwrap() } /// Residual Helmholtz energy given an equation of state and a StateHD. @@ -152,11 +152,11 @@ enum Derivative { /// Creates a [StateHD] cloning temperature, volume and moles. fn derive0(state: &State) -> StateHD { - let total_moles = state.total_moles.into_reduced(); + let total_moles = state.total_moles().into_reduced(); StateHD::new( state.temperature.into_reduced(), - state.volume.into_reduced() / total_moles, - &(state.moles.to_reduced() / total_moles), + state.volume().into_reduced() / total_moles, + &(state.moles().to_reduced() / total_moles), ) } diff --git a/crates/feos/benches/dual_numbers_saftvrmie.rs b/crates/feos/benches/dual_numbers_saftvrmie.rs index 0d6329974..e7409e307 100644 --- a/crates/feos/benches/dual_numbers_saftvrmie.rs +++ b/crates/feos/benches/dual_numbers_saftvrmie.rs @@ -18,9 +18,9 @@ use quantity::*; /// - molefracs (or moles) for equimolar mixture. fn state_saftvrmie(n: usize, eos: &SaftVRMie) -> State<&SaftVRMie> { let molefracs = DVector::from_element(n, 1.0 / n as f64); - let cp = State::critical_point(&eos, Some(&molefracs), None, None, Default::default()).unwrap(); + let cp = State::critical_point(&eos, &molefracs, None, None, Default::default()).unwrap(); let temperature = 0.8 * cp.temperature; - State::new_nvt(&eos, temperature, cp.volume, &(molefracs * 10. * MOL)).unwrap() + State::new_nvt(&eos, temperature, cp.volume(), &(molefracs * 10. * MOL)).unwrap() } /// Residual Helmholtz energy given an equation of state and a StateHD. @@ -100,11 +100,11 @@ enum Derivative { /// Creates a [StateHD] cloning temperature, volume and moles. fn derive0(state: &State) -> StateHD { - let total_moles = state.total_moles.into_reduced(); + let total_moles = state.total_moles().into_reduced(); StateHD::new( state.temperature.into_reduced(), - state.volume.into_reduced() / total_moles, - &(state.moles.to_reduced() / total_moles), + state.volume().into_reduced() / total_moles, + &(state.moles().to_reduced() / total_moles), ) } diff --git a/crates/feos/benches/state_creation.rs b/crates/feos/benches/state_creation.rs index 6110abd68..3aa08778e 100644 --- a/crates/feos/benches/state_creation.rs +++ b/crates/feos/benches/state_creation.rs @@ -22,7 +22,7 @@ fn npt( } /// Evaluate critical point constructor -fn critical_point((eos, n): (&E, Option<&DVector>)) { +fn critical_point((eos, n): (&E, &DVector)) { State::critical_point(eos, n, None, None, Default::default()).unwrap(); } @@ -69,7 +69,7 @@ fn bench_states(c: &mut Criterion, group_name: &str, eos: &E) { let ncomponents = eos.components(); let x = DVector::from_element(ncomponents, 1.0 / ncomponents as f64); let n = &x * 100.0 * MOL; - let crit = State::critical_point(eos, Some(&x), None, None, Default::default()).unwrap(); + let crit = State::critical_point(eos, &x, None, None, Default::default()).unwrap(); let vle = if ncomponents == 1 { PhaseEquilibrium::pure(eos, crit.temperature * 0.95, None, Default::default()).unwrap() } else { @@ -77,7 +77,7 @@ fn bench_states(c: &mut Criterion, group_name: &str, eos: &E) { eos, crit.temperature, crit.pressure(Contributions::Total) * 0.95, - &crit.moles, + &crit.moles(), None, Default::default(), None, @@ -108,9 +108,7 @@ fn bench_states(c: &mut Criterion, group_name: &str, eos: &E) { )) }) }); - group.bench_function("critical_point", |b| { - b.iter(|| critical_point((eos, Some(&x)))) - }); + group.bench_function("critical_point", |b| b.iter(|| critical_point((eos, &x)))); if ncomponents == 2 { group.bench_function("critical_point_binary_t", |b| { b.iter(|| critical_point_binary((eos, crit.temperature))) diff --git a/crates/feos/src/epcsaft/eos/mod.rs b/crates/feos/src/epcsaft/eos/mod.rs index 03a2713d0..2edba6f69 100644 --- a/crates/feos/src/epcsaft/eos/mod.rs +++ b/crates/feos/src/epcsaft/eos/mod.rs @@ -181,7 +181,7 @@ mod tests { let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&&e, t, v, &n).unwrap(); - let p_ig = s.total_moles * RGAS * t / v; + let p_ig = s.total_moles() * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), @@ -197,7 +197,7 @@ mod tests { let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&&e, t, v, &n).unwrap(); - let p_ig = s.total_moles * RGAS * t / v; + let p_ig = s.total_moles() * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), @@ -269,7 +269,7 @@ mod tests { fn critical_point() { let e = ElectrolytePcSaft::new(propane_parameters()).unwrap(); let t = 300.0 * KELVIN; - let cp = State::critical_point(&&e, None, Some(t), None, Default::default()); + let cp = State::critical_point(&&e, (), Some(t), None, Default::default()); if let Ok(v) = cp { assert_relative_eq!(v.temperature, 375.1244078318015 * KELVIN, epsilon = 1e-8) } diff --git a/crates/feos/src/gc_pcsaft/eos/ad.rs b/crates/feos/src/gc_pcsaft/eos/ad.rs index 252f9a061..72b251eaa 100644 --- a/crates/feos/src/gc_pcsaft/eos/ad.rs +++ b/crates/feos/src/gc_pcsaft/eos/ad.rs @@ -510,7 +510,7 @@ pub mod test { let eos_ad = GcPcSaftAD(params); let moles = vector![1.3] * KILO * MOL; - let state = State::new_nvt(&eos_ad, temperature, volume, &moles)?; + let state = State::new_nvt(&eos_ad, temperature, volume, moles)?; let a_ad = state.residual_molar_helmholtz_energy(); let mu_ad = state.residual_chemical_potential(); let p_ad = state.pressure(Total); diff --git a/crates/feos/src/ideal_gas/dippr.rs b/crates/feos/src/ideal_gas/dippr.rs index 7553e376c..89824b07a 100644 --- a/crates/feos/src/ideal_gas/dippr.rs +++ b/crates/feos/src/ideal_gas/dippr.rs @@ -156,7 +156,7 @@ impl IdealGas for Dippr { mod tests { use approx::assert_relative_eq; use feos_core::parameter::{Identifier, PureRecord}; - use feos_core::{Contributions, EquationOfState, StateBuilder}; + use feos_core::{Contributions, EquationOfState, State}; use num_dual::first_derivative; use quantity::*; @@ -173,11 +173,7 @@ mod tests { let eos = EquationOfState::ideal_gas(dippr.clone()); let temperature = 300.0 * KELVIN; let volume = METER.powi::<3>(); - let state = StateBuilder::new(&&eos) - .temperature(temperature) - .volume(volume) - .total_moles(MOL) - .build()?; + let state = State::new_nvt(&&eos, temperature, volume, MOL)?; let t = temperature.convert_to(KELVIN); let c_p_direct = record.model_record.c_p(t); @@ -217,11 +213,7 @@ mod tests { let eos = EquationOfState::ideal_gas(dippr.clone()); let temperature = 300.0 * KELVIN; let volume = METER.powi::<3>(); - let state = StateBuilder::new(&&eos) - .temperature(temperature) - .volume(volume) - .total_moles(MOL) - .build()?; + let state = State::new_nvt(&&eos, temperature, volume, MOL)?; let t = temperature.convert_to(KELVIN); let c_p_direct = record.model_record.c_p(t); @@ -263,11 +255,7 @@ mod tests { let eos = EquationOfState::ideal_gas(dippr.clone()); let temperature = 20.0 * KELVIN; let volume = METER.powi::<3>(); - let state = StateBuilder::new(&&eos) - .temperature(temperature) - .volume(volume) - .total_moles(MOL) - .build()?; + let state = State::new_nvt(&&eos, temperature, volume, MOL)?; let t = temperature.convert_to(KELVIN); let c_p_direct = record.model_record.c_p(t); diff --git a/crates/feos/src/ideal_gas/joback.rs b/crates/feos/src/ideal_gas/joback.rs index 742c6ef2e..8f2279d5b 100644 --- a/crates/feos/src/ideal_gas/joback.rs +++ b/crates/feos/src/ideal_gas/joback.rs @@ -172,10 +172,8 @@ const KB: f64 = 1.38064852e-23; #[cfg(test)] mod tests { use approx::assert_relative_eq; - use feos_core::{ - Contributions, EquationOfState, State, StateBuilder, - parameter::{ChemicalRecord, GroupCount, Identifier, PureRecord, SegmentRecord}, - }; + use feos_core::parameter::{ChemicalRecord, GroupCount, Identifier, PureRecord, SegmentRecord}; + use feos_core::{Contributions, EquationOfState, State}; use nalgebra::dvector; use quantity::*; use std::collections::HashMap; @@ -295,11 +293,7 @@ mod tests { let temperature = 300.0 * KELVIN; let volume = METER.powi::<3>(); let moles = &dvector![1.0, 3.0] * MOL; - let state = StateBuilder::new(&&eos) - .temperature(temperature) - .volume(volume) - .moles(&moles) - .build()?; + let state = State::new_nvt(&&eos, temperature, volume, moles)?; println!( "{} {}", Joback::molar_isobaric_heat_capacity(&joback, temperature, &state.molefracs)?, diff --git a/crates/feos/src/lib.rs b/crates/feos/src/lib.rs index eab5ae128..0fbdffa71 100644 --- a/crates/feos/src/lib.rs +++ b/crates/feos/src/lib.rs @@ -23,7 +23,7 @@ //! let saft = PcSaft::new(parameters); //! //! // Define thermodynamic conditions. -//! let critical_point = State::critical_point(&&saft, Some(&dvector![1.0]), None, None, Default::default())?; +//! let critical_point = State::critical_point(&&saft, dvector![1.0], None, None, Default::default())?; //! //! // Compute properties. //! let p = critical_point.pressure(Contributions::Total); diff --git a/crates/feos/src/multiparameter/mod.rs b/crates/feos/src/multiparameter/mod.rs index 68bbd98b8..b4a6c4f9f 100644 --- a/crates/feos/src/multiparameter/mod.rs +++ b/crates/feos/src/multiparameter/mod.rs @@ -265,8 +265,13 @@ mod test { let eos = &water(); let mw = eos.molar_weight.get(0); let moles = dvector![1.8] * MOL; - let a_feos = eos.ideal_gas_helmholtz_energy(t, moles.sum() * mw / rho, &moles); - let phi_feos = (a_feos / RGAS / moles.sum() / t).into_value(); + let total_moles = moles.sum(); + let a_feos = eos.ideal_gas_helmholtz_energy( + t, + moles.sum() * mw / rho / total_moles, + &moles.convert_into(total_moles), + ); + let phi_feos = (a_feos / RGAS / t).into_value(); println!("A: {a_feos}"); println!("phi(feos): {phi_feos}"); let delta = (rho / (eos.rhoc * MOL / METER.powi::<3>() * mw)).into_value(); @@ -288,15 +293,15 @@ mod test { ..Default::default() }; let cp: State<_, Dyn, f64> = - State::critical_point(&eos, None, Some(647. * KELVIN), None, options).unwrap(); + State::critical_point(&eos, (), Some(647. * KELVIN), None, options).unwrap(); println!("{cp}"); assert_relative_eq!(cp.temperature, eos.tc * KELVIN, max_relative = 1e-13); let cp: State<_, Dyn, f64> = - State::critical_point(&eos, None, None, None, Default::default()).unwrap(); + State::critical_point(&eos, (), None, None, Default::default()).unwrap(); println!("{cp}"); assert_relative_ne!(cp.temperature, eos.tc * KELVIN, max_relative = 1e-13); let cp: State<_, Dyn, f64> = - State::critical_point(&eos, None, Some(700.0 * KELVIN), None, Default::default()) + State::critical_point(&eos, (), Some(700.0 * KELVIN), None, Default::default()) .unwrap(); println!("{cp}"); assert_relative_eq!(cp.temperature, eos.tc * KELVIN, max_relative = 1e-13) diff --git a/crates/feos/src/pcsaft/eos/mod.rs b/crates/feos/src/pcsaft/eos/mod.rs index 45d3635da..752e7c6f6 100644 --- a/crates/feos/src/pcsaft/eos/mod.rs +++ b/crates/feos/src/pcsaft/eos/mod.rs @@ -229,12 +229,11 @@ impl EntropyScaling for PcSaft { fn viscosity_reference( &self, temperature: Temperature, - _: Volume, - moles: &Moles>, + _: MolarVolume, + molefracs: &DVector, ) -> Viscosity { let p = &self.params; let mw = &self.parameters.molar_weight; - let x = (moles / moles.sum()).into_value(); let ce: Vec<_> = (0..self.components()) .map(|i| { let tr = (temperature / p.epsilon_k[i] / KELVIN).into_value(); @@ -247,14 +246,15 @@ impl EntropyScaling for PcSaft { for i in 0..self.components() { let denom: f64 = (0..self.components()) .map(|j| { - x[j] * (1.0 - + (ce[i] / ce[j]).into_value().sqrt() - * (mw.get(j) / mw.get(i)).powf(1.0 / 4.0)) - .powi(2) + molefracs[j] + * (1.0 + + (ce[i] / ce[j]).into_value().sqrt() + * (mw.get(j) / mw.get(i)).powf(1.0 / 4.0)) + .powi(2) / (8.0 * (1.0 + (mw.get(i) / mw.get(j)).into_value())).sqrt() }) .sum(); - ce_mix += ce[i] * x[i] / denom + ce_mix += ce[i] * molefracs[i] / denom } ce_mix } @@ -278,19 +278,19 @@ impl EntropyScaling for PcSaft { fn diffusion_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + _: &DVector, ) -> Diffusivity { if self.components() != 1 { panic!("Diffusion coefficients in PC-SAFT are only implemented for pure components!"); } let p = &self.params; let mw = &self.parameters.molar_weight; - let density = moles.sum() / volume; let res: Vec<_> = (0..self.components()) .map(|i| { let tr = (temperature / p.epsilon_k[i] / KELVIN).into_value(); - 3.0 / 8.0 / (p.sigma[i] * ANGSTROM).powi::<2>() / omega11(tr) / (density * NAV) + 3.0 / 8.0 / (p.sigma[i] * ANGSTROM).powi::<2>() / omega11(tr) + * (molar_volume / NAV) * (temperature * RGAS / PI / mw.get(i) / p.m[i]).sqrt() }) .collect(); @@ -321,8 +321,8 @@ impl EntropyScaling for PcSaft { fn thermal_conductivity_reference( &self, temperature: Temperature, - volume: Volume, - moles: &Moles>, + molar_volume: MolarVolume, + molefracs: &DVector, ) -> ThermalConductivity { if self.components() != 1 { panic!("Thermal conductivity in PC-SAFT is only implemented for pure components!"); @@ -331,9 +331,9 @@ impl EntropyScaling for PcSaft { let mws = self.molar_weight(); let (_, s_res) = first_derivative( partial2( - |t, &v, n| -self.residual_helmholtz_energy_unit(t, v, n) / n.sum(), - &volume, - moles, + |t, &v, n| -self.residual_molar_helmholtz_energy(t, v, n), + &molar_volume, + molefracs, ), temperature, ); @@ -399,8 +399,8 @@ mod tests { let t = 200.0 * KELVIN; let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; - let s = State::new_nvt(&e, t, v, &n).unwrap(); - let p_ig = s.total_moles * RGAS * t / v; + let s = State::new_nvt(&e, t, v, n).unwrap(); + let p_ig = s.total_moles() * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), @@ -415,8 +415,8 @@ mod tests { let t = 200.0 * KELVIN; let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; - let s = State::new_nvt(&e, t, v, &n).unwrap(); - let p_ig = s.total_moles * RGAS * t / v; + let s = State::new_nvt(&e, t, v, n).unwrap(); + let p_ig = s.total_moles() * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), @@ -463,7 +463,7 @@ mod tests { let t = 300.0 * KELVIN; let p = BAR; let m = dvector![1.5] * MOL; - let s = State::new_npt(&e, t, p, &m, None); + let s = State::new_npt(&e, t, p, m, None); let p_calc = if let Ok(state) = s { state.pressure(Contributions::Total) } else { @@ -490,7 +490,7 @@ mod tests { fn critical_point() { let e = &propane_parameters(); let t = 300.0 * KELVIN; - let cp = State::critical_point(&e, None, Some(t), None, Default::default()); + let cp = State::critical_point(&e, (), Some(t), None, Default::default()); if let Ok(v) = cp { assert_relative_eq!(v.temperature, 375.1244078318015 * KELVIN, epsilon = 1e-8) } @@ -507,9 +507,9 @@ mod tests { let m1m = dvector![2.0, 0.0] * MOL; let m2m = dvector![0.0, 2.0] * MOL; let s1 = State::new_nvt(&e1, t, v, &m1).unwrap(); - let s2 = State::new_nvt(&e2, t, v, &m1).unwrap(); - let s1m = State::new_nvt(&e12, t, v, &m1m).unwrap(); - let s2m = State::new_nvt(&e12, t, v, &m2m).unwrap(); + let s2 = State::new_nvt(&e2, t, v, m1).unwrap(); + let s1m = State::new_nvt(&e12, t, v, m1m).unwrap(); + let s2m = State::new_nvt(&e12, t, v, m2m).unwrap(); assert_relative_eq!( s1.pressure(Contributions::Total), s1m.pressure(Contributions::Total), @@ -528,7 +528,7 @@ mod tests { let t = 300.0 * KELVIN; let p = BAR; let n = dvector![1.0] * MOL; - let s = State::new_npt(&e, t, p, &n, None)?; + let s = State::new_npt(&e, t, p, n, None)?; assert_relative_eq!( s.viscosity(), 0.00797 * MILLI * PASCAL * SECOND, @@ -536,7 +536,7 @@ mod tests { ); assert_relative_eq!( s.ln_viscosity_reduced(), - (s.viscosity() / e.viscosity_reference(s.temperature, s.volume, &s.moles)) + (s.viscosity() / e.viscosity_reference(s.temperature, s.molar_volume, &s.molefracs)) .into_value() .ln(), epsilon = 1e-15 @@ -553,15 +553,15 @@ mod tests { let t = 303.15 * KELVIN; let p = 500.0 * BAR; let n = dvector![0.25, 0.75] * MOL; - let viscosity_mix = State::new_npt(&e, t, p, &n, None)?.viscosity(); + let viscosity_mix = State::new_npt(&e, t, p, n, None)?.viscosity(); let viscosity_paper = 0.68298 * MILLI * PASCAL * SECOND; assert_relative_eq!(viscosity_paper, viscosity_mix, epsilon = 1e-8); // Make sure pure substance case is recovered let n_pseudo_mix = dvector![1.0, 0.0] * MOL; - let viscosity_pseudo_mix = State::new_npt(&e, t, p, &n_pseudo_mix, None)?.viscosity(); + let viscosity_pseudo_mix = State::new_npt(&e, t, p, n_pseudo_mix, None)?.viscosity(); let n_nonane = dvector![1.0] * MOL; - let viscosity_nonane = State::new_npt(&nonane, t, p, &n_nonane, None)?.viscosity(); + let viscosity_nonane = State::new_npt(&nonane, t, p, n_nonane, None)?.viscosity(); assert_relative_eq!(viscosity_pseudo_mix, viscosity_nonane, epsilon = 1e-15); Ok(()) } @@ -572,7 +572,7 @@ mod tests { let t = 300.0 * KELVIN; let p = BAR; let n = dvector![1.0] * MOL; - let s = State::new_npt(&e, t, p, &n, None)?; + let s = State::new_npt(&e, t, p, n, None)?; assert_relative_eq!( s.diffusion(), 0.01505 * (CENTI * METER).powi::<2>() / SECOND, @@ -580,7 +580,7 @@ mod tests { ); assert_relative_eq!( s.ln_diffusion_reduced(), - (s.diffusion() / e.diffusion_reference(s.temperature, s.volume, &s.moles)) + (s.diffusion() / e.diffusion_reference(s.temperature, s.molar_volume, &s.molefracs)) .into_value() .ln(), epsilon = 1e-15 @@ -787,14 +787,9 @@ mod tests_parameter_fit { let h = params[i] * 1e-7; params[i] += h; let pcsaft_h = PcSaftPure(params); - let rho_h = State::new_xpt( - &pcsaft_h, - temperature, - pressure, - &vector![1.0], - Some(Liquid), - )? - .density; + let rho_h = + State::new_npt(&pcsaft_h, temperature, pressure, vector![1.0], Some(Liquid))? + .density; let drho_h = (rho_h.convert_into(MOL / LITER) - rho) / h; let drho = grad[i]; println!( diff --git a/crates/feos/src/pcsaft/eos/pcsaft_binary.rs b/crates/feos/src/pcsaft/eos/pcsaft_binary.rs index 3eecc9db4..71b15ae88 100644 --- a/crates/feos/src/pcsaft/eos/pcsaft_binary.rs +++ b/crates/feos/src/pcsaft/eos/pcsaft_binary.rs @@ -557,7 +557,7 @@ pub mod test { let h_feos = state.residual_molar_enthalpy(); let moles = vector![1.3, 2.5] * KILO * MOL; - let state = State::new_nvt(&pcsaft, temperature, volume, &moles)?; + let state = State::new_nvt(&pcsaft, temperature, volume, moles)?; let a_ad = state.residual_molar_helmholtz_energy(); let mu_ad = state.residual_chemical_potential(); let p_ad = state.pressure(Total); diff --git a/crates/feos/src/pcsaft/eos/pcsaft_pure.rs b/crates/feos/src/pcsaft/eos/pcsaft_pure.rs index baa5bc10f..f9ea2983f 100644 --- a/crates/feos/src/pcsaft/eos/pcsaft_pure.rs +++ b/crates/feos/src/pcsaft/eos/pcsaft_pure.rs @@ -228,9 +228,9 @@ impl ParametersAD<1> for PcSaftPure { #[cfg(test)] pub mod test { - use crate::pcsaft::PcSaftRecord; use super::super::{PcSaft, PcSaftAssociationRecord, PcSaftParameters}; use super::*; + use crate::pcsaft::PcSaftRecord; use approx::assert_relative_eq; use feos_core::parameter::{AssociationRecord, PureRecord}; use feos_core::{Contributions::Total, FeosResult, State}; @@ -278,7 +278,7 @@ pub mod test { let h_feos = state.residual_molar_enthalpy(); let moles = vector![1.3] * KILO * MOL; - let state = State::new_nvt(&pcsaft, temperature, volume, &moles)?; + let state = State::new_nvt(&pcsaft, temperature, volume, moles)?; let a_ad = state.residual_molar_helmholtz_energy(); let mu_ad = state.residual_chemical_potential(); let p_ad = state.pressure(Total); diff --git a/crates/feos/src/pets/eos/mod.rs b/crates/feos/src/pets/eos/mod.rs index f1d1d76cb..e26b94960 100644 --- a/crates/feos/src/pets/eos/mod.rs +++ b/crates/feos/src/pets/eos/mod.rs @@ -152,7 +152,7 @@ mod tests { let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&e, t, v, &n).unwrap(); - let p_ig = s.total_moles * RGAS * t / v; + let p_ig = s.total_moles() * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), diff --git a/crates/feos/src/uvtheory/eos/mod.rs b/crates/feos/src/uvtheory/eos/mod.rs index 9fa963ae2..8a660db81 100644 --- a/crates/feos/src/uvtheory/eos/mod.rs +++ b/crates/feos/src/uvtheory/eos/mod.rs @@ -246,8 +246,9 @@ mod test { // EoS let eos_wca = &UVTheory::new(parameters); let state_wca = State::new_nvt(&eos_wca, t_x, volume, &moles).unwrap(); - let a_wca = (state_wca.residual_helmholtz_energy() / (RGAS * t_x * state_wca.total_moles)) - .into_value(); + let a_wca = (state_wca.residual_helmholtz_energy() + / (RGAS * t_x * state_wca.total_moles())) + .into_value(); assert_relative_eq!(a_wca, -0.597791038364405, max_relative = 1e-5); Ok(()) diff --git a/crates/feos/tests/gc_pcsaft/binary.rs b/crates/feos/tests/gc_pcsaft/binary.rs index e9b9ed434..ce627ec8c 100644 --- a/crates/feos/tests/gc_pcsaft/binary.rs +++ b/crates/feos/tests/gc_pcsaft/binary.rs @@ -30,9 +30,9 @@ fn test_binary() -> FeosResult<()> { #[cfg(feature = "dft")] let func = &GcPcSaftFunctional::new(parameters_func); let molefracs = dvector![0.5, 0.5]; - let cp = State::critical_point(&eos, Some(&molefracs), None, None, Default::default())?; + let cp = State::critical_point(&eos, &molefracs, None, None, Default::default())?; #[cfg(feature = "dft")] - let cp_func = State::critical_point(&func, Some(&molefracs), None, None, Default::default())?; + let cp_func = State::critical_point(&func, molefracs, None, None, Default::default())?; println!("{}", cp.temperature); #[cfg(feature = "dft")] println!("{}", cp_func.temperature); diff --git a/crates/feos/tests/gc_pcsaft/dft.rs b/crates/feos/tests/gc_pcsaft/dft.rs index a9813c56e..ce5d103cb 100644 --- a/crates/feos/tests/gc_pcsaft/dft.rs +++ b/crates/feos/tests/gc_pcsaft/dft.rs @@ -3,7 +3,7 @@ use approx::assert_relative_eq; use feos::gc_pcsaft::{GcPcSaft, GcPcSaftFunctional, GcPcSaftParameters}; use feos_core::parameter::{ChemicalRecord, Identifier, IdentifierOption, SegmentRecord}; -use feos_core::{PhaseEquilibrium, State, StateBuilder, Verbosity}; +use feos_core::{PhaseEquilibrium, State, Verbosity}; use feos_dft::adsorption::{ExternalPotential, Pore1D, PoreSpecification}; use feos_dft::interface::PlanarInterface; use feos_dft::{DFTSolver, Geometry}; @@ -158,7 +158,7 @@ fn test_dft() -> Result<(), Box> { let t = 200.0 * KELVIN; let w = 150.0 * ANGSTROM; let points = 2048; - let tc = State::critical_point(&&func, None, None, None, Default::default())?.temperature; + let tc = State::critical_point(&&func, (), None, None, Default::default())?.temperature; let vle = PhaseEquilibrium::pure(&&func, t, None, Default::default())?; let profile = PlanarInterface::from_tanh(&vle, points, w, tc, false).solve(None)?; println!( @@ -216,10 +216,7 @@ fn test_dft_assoc() -> Result<(), Box> { let solver = DFTSolver::new(Some(Verbosity::Iter)) .picard_iteration(None, None, Some(1e-5), Some(0.05)) .anderson_mixing(None, None, None, None, None); - let bulk = StateBuilder::new(&func) - .temperature(t) - .pressure(5.0 * BAR) - .build()?; + let bulk = State::new_npt(&func, t, 5.0 * BAR, (), None)?; Pore1D::new( Geometry::Cartesian, 20.0 * ANGSTROM, @@ -252,7 +249,7 @@ fn test_dft_newton() -> Result<(), Box> { let t = 200.0 * KELVIN; let w = 150.0 * ANGSTROM; let points = 512; - let tc = State::critical_point(&&func, None, None, None, Default::default())?.temperature; + let tc = State::critical_point(&&func, (), None, None, Default::default())?.temperature; let vle = PhaseEquilibrium::pure(&&func, t, None, Default::default())?; let solver = DFTSolver::new(Some(Verbosity::Iter)) .picard_iteration(None, Some(10), None, None) diff --git a/crates/feos/tests/pcsaft/critical_point.rs b/crates/feos/tests/pcsaft/critical_point.rs index 9183e5e5d..f0f435b13 100644 --- a/crates/feos/tests/pcsaft/critical_point.rs +++ b/crates/feos/tests/pcsaft/critical_point.rs @@ -17,7 +17,7 @@ fn test_critical_point_pure() -> Result<(), Box> { )?; let saft = PcSaft::new(params); let t = 300.0 * KELVIN; - let cp = State::critical_point(&&saft, None, Some(t), None, Default::default())?; + let cp = State::critical_point(&&saft, (), Some(t), None, Default::default())?; assert_relative_eq!(cp.temperature, 375.12441 * KELVIN, max_relative = 1e-8); assert_relative_eq!( cp.density, @@ -38,7 +38,7 @@ fn test_critical_point_mix() -> Result<(), Box> { let saft = PcSaft::new(params); let t = 300.0 * KELVIN; let molefracs = dvector![0.5, 0.5]; - let cp = State::critical_point(&&saft, Some(&molefracs), Some(t), None, Default::default())?; + let cp = State::critical_point(&&saft, molefracs, Some(t), None, Default::default())?; assert_relative_eq!(cp.temperature, 407.93481 * KELVIN, max_relative = 1e-8); assert_relative_eq!( cp.density, @@ -64,10 +64,10 @@ fn test_critical_point_limits() -> Result<(), Box> { let cp_pure = State::critical_point_pure(&saft, None, None, options)?; println!("{} {}", cp_pure[0], cp_pure[1]); let molefracs = dvector![0.0, 1.0]; - let cp_2 = State::critical_point(&saft, Some(&molefracs), None, None, options)?; + let cp_2 = State::critical_point(&saft, &molefracs, None, None, options)?; println!("{}", cp_2); let molefracs = dvector![1.0, 0.0]; - let cp_1 = State::critical_point(&saft, Some(&molefracs), None, None, options)?; + let cp_1 = State::critical_point(&saft, &molefracs, None, None, options)?; println!("{}", cp_1); assert_eq!(cp_pure[0].temperature, cp_1.temperature); assert_eq!(cp_pure[0].density, cp_1.density); diff --git a/crates/feos/tests/pcsaft/dft.rs b/crates/feos/tests/pcsaft/dft.rs index e8d057e47..b04a40aa1 100644 --- a/crates/feos/tests/pcsaft/dft.rs +++ b/crates/feos/tests/pcsaft/dft.rs @@ -103,7 +103,7 @@ fn test_dft_propane() -> Result<(), Box> { let t = 200.0 * KELVIN; let w = 150.0 * ANGSTROM; let points = 2048; - let tc = State::critical_point(&&func_pure, None, None, None, Default::default())?.temperature; + let tc = State::critical_point(&&func_pure, (), None, None, Default::default())?.temperature; let vle_pure = PhaseEquilibrium::pure(&&func_pure, t, None, Default::default())?; let vle_full = PhaseEquilibrium::pure(&&func_full, t, None, Default::default())?; let vle_full_vec = PhaseEquilibrium::pure(&&func_full_vec, t, None, Default::default())?; @@ -213,7 +213,7 @@ fn test_dft_propane_newton() -> Result<(), Box> { let t = 200.0 * KELVIN; let w = 150.0 * ANGSTROM; let points = 512; - let tc = State::critical_point(&&func, None, None, None, Default::default())?.temperature; + let tc = State::critical_point(&&func, (), None, None, Default::default())?.temperature; let vle = PhaseEquilibrium::pure(&&func, t, None, Default::default())?; let solver = DFTSolver::new(Some(Verbosity::Iter)).newton(None, None, None, None); PlanarInterface::from_tanh(&vle, points, w, tc, false).solve(Some(&solver))?; @@ -234,7 +234,7 @@ fn test_dft_water() -> Result<(), Box> { let t = 400.0 * KELVIN; let w = 120.0 * ANGSTROM; let points = 2048; - let tc = State::critical_point(&&func_pure, None, None, None, Default::default())?.temperature; + let tc = State::critical_point(&&func_pure, (), None, None, Default::default())?.temperature; let vle_pure = PhaseEquilibrium::pure(&&func_pure, t, None, Default::default())?; let vle_full_vec = PhaseEquilibrium::pure(&&func_full_vec, t, None, Default::default())?; let profile_pure = PlanarInterface::from_tanh(&vle_pure, points, w, tc, false).solve(None)?; @@ -334,33 +334,39 @@ fn test_entropy_bulk_values() -> Result<(), Box> { println!("\nResidual:\n{s_res:?}"); println!( "liquid: {:?}, vapor: {:?}", - profile.vle.liquid().entropy(Contributions::Residual) / profile.vle.liquid().volume, - profile.vle.vapor().entropy(Contributions::Residual) / profile.vle.vapor().volume + profile.vle.liquid().molar_entropy(Contributions::Residual) + / profile.vle.liquid().molar_volume, + profile.vle.vapor().molar_entropy(Contributions::Residual) + / profile.vle.vapor().molar_volume ); println!("\nTotal:\n{s_tot:?}"); println!( "liquid: {:?}, vapor: {:?}", - profile.vle.liquid().entropy(Contributions::Total) / profile.vle.liquid().volume, - profile.vle.vapor().entropy(Contributions::Total) / profile.vle.vapor().volume + profile.vle.liquid().molar_entropy(Contributions::Total) + / profile.vle.liquid().molar_volume, + profile.vle.vapor().molar_entropy(Contributions::Total) / profile.vle.vapor().molar_volume ); assert_relative_eq!( s_res.get(0), - profile.vle.liquid().entropy(Contributions::Residual) / profile.vle.liquid().volume, + profile.vle.liquid().molar_entropy(Contributions::Residual) + / profile.vle.liquid().molar_volume, max_relative = 1e-8, ); assert_relative_eq!( s_res.get(2047), - profile.vle.vapor().entropy(Contributions::Residual) / profile.vle.vapor().volume, + profile.vle.vapor().molar_entropy(Contributions::Residual) + / profile.vle.vapor().molar_volume, max_relative = 1e-8, ); assert_relative_eq!( s_tot.get(0), - profile.vle.liquid().entropy(Contributions::Total) / profile.vle.liquid().volume, + profile.vle.liquid().molar_entropy(Contributions::Total) + / profile.vle.liquid().molar_volume, max_relative = 1e-8, ); assert_relative_eq!( s_tot.get(2047), - profile.vle.vapor().entropy(Contributions::Total) / profile.vle.vapor().volume, + profile.vle.vapor().molar_entropy(Contributions::Total) / profile.vle.vapor().molar_volume, max_relative = 1e-8, ); Ok(()) diff --git a/crates/feos/tests/pcsaft/properties.rs b/crates/feos/tests/pcsaft/properties.rs index 3b05c86e1..489760e5e 100644 --- a/crates/feos/tests/pcsaft/properties.rs +++ b/crates/feos/tests/pcsaft/properties.rs @@ -1,7 +1,7 @@ use approx::assert_relative_eq; use feos::pcsaft::{PcSaft, PcSaftParameters}; use feos_core::parameter::IdentifierOption; -use feos_core::{Residual, StateBuilder}; +use feos_core::{DensityInitialization::Vapor, Residual, State}; use nalgebra::dvector; use quantity::*; use std::error::Error; @@ -18,18 +18,8 @@ fn test_dln_phi_dp() -> Result<(), Box> { let t = 300.0 * KELVIN; let p = BAR; let h = 1e-1 * PASCAL; - let s = StateBuilder::new(&&saft) - .temperature(t) - .pressure(p) - .molefracs(&dvector![0.5, 0.5]) - .vapor() - .build()?; - let sh = StateBuilder::new(&&saft) - .temperature(t) - .pressure(p + h) - .molefracs(&dvector![0.5, 0.5]) - .vapor() - .build()?; + let s = State::new_npt(&&saft, t, p, dvector![0.5, 0.5], Some(Vapor))?; + let sh = State::new_npt(&&saft, t, p + h, dvector![0.5, 0.5], Some(Vapor))?; let ln_phi = s.ln_phi()[0]; let ln_phi_h = sh.ln_phi()[0]; @@ -48,7 +38,7 @@ fn test_virial_is_not_nan() -> Result<(), Box> { IdentifierOption::Name, )?; let saft = &PcSaft::new(params); - let virial_b = saft.second_virial_coefficient(300.0 * KELVIN, &None); + let virial_b = saft.second_virial_coefficient(300.0 * KELVIN, ())?; assert!(!virial_b.is_nan()); Ok(()) } diff --git a/crates/feos/tests/pcsaft/state_creation_mixture.rs b/crates/feos/tests/pcsaft/state_creation_mixture.rs index b7b93c73a..46e0442b8 100644 --- a/crates/feos/tests/pcsaft/state_creation_mixture.rs +++ b/crates/feos/tests/pcsaft/state_creation_mixture.rs @@ -2,7 +2,7 @@ use approx::assert_relative_eq; use feos::ideal_gas::{Joback, JobackParameters}; use feos::pcsaft::{PcSaft, PcSaftParameters}; use feos_core::parameter::IdentifierOption; -use feos_core::{Contributions, EquationOfState, FeosResult, StateBuilder}; +use feos_core::{Contributions, EquationOfState, FeosResult, State}; use nalgebra::dvector; use quantity::*; use std::error::Error; @@ -31,17 +31,9 @@ fn pressure_entropy_molefracs() -> Result<(), Box> { let pressure = BAR; let temperature = 300.0 * KELVIN; let x = dvector![0.3, 0.7]; - let state = StateBuilder::new(&&eos) - .temperature(temperature) - .pressure(pressure) - .molefracs(&x) - .build()?; + let state = State::new_npt(&&eos, temperature, pressure, &x, None)?; let molar_entropy = state.molar_entropy(Contributions::Total); - let state = StateBuilder::new(&&eos) - .pressure(pressure) - .molar_entropy(molar_entropy) - .molefracs(&x) - .build()?; + let state = State::new_nps(&&eos, pressure, molar_entropy, x, None, None)?; assert_relative_eq!( state.molar_entropy(Contributions::Total), molar_entropy, @@ -63,13 +55,8 @@ fn volume_temperature_molefracs() -> Result<(), Box> { let volume = 1.5e-3 * METER.powi::<3>(); let moles = MOL; let x = dvector![0.3, 0.7]; - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .volume(volume) - .total_moles(moles) - .molefracs(&x) - .build()?; - assert_relative_eq!(state.volume, volume, max_relative = 1e-10); + let state = State::new_nvt(&&saft, temperature, volume, (x, moles))?; + assert_relative_eq!(state.volume(), volume, max_relative = 1e-10); Ok(()) } @@ -80,15 +67,9 @@ fn temperature_partial_density() -> Result<(), Box> { let x = dvector![0.3, 0.7]; let partial_density = x.clone() * MOL / METER.powi::<3>(); let density = partial_density.sum(); - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .partial_density(&partial_density) - .build()?; + let state = State::new_density(&&saft, temperature, partial_density)?; assert_relative_eq!(x, state.molefracs, max_relative = 1e-10); assert_relative_eq!(density, state.density, max_relative = 1e-10); - // Zip::from(&state.partial_density.to_reduced(reference)) - // .and(&partial_density.into_value()?) - // .for_each(|&r1, &r2| assert_relative_eq!(r1, r2, max_relative = 1e-10)); Ok(()) } @@ -98,11 +79,7 @@ fn temperature_density_molefracs() -> Result<(), Box> { let temperature = 300.0 * KELVIN; let x = dvector![0.3, 0.7]; let density = MOL / METER.powi::<3>(); - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .density(density) - .molefracs(&x) - .build()?; + let state = State::new(&&saft, temperature, density, &x)?; state .molefracs .iter() @@ -118,11 +95,7 @@ fn temperature_pressure_molefracs() -> Result<(), Box> { let temperature = 300.0 * KELVIN; let pressure = BAR; let x = dvector![0.3, 0.7]; - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .pressure(pressure) - .molefracs(&x) - .build()?; + let state = State::new_npt(&&saft, temperature, pressure, &x, None)?; state .molefracs .iter() diff --git a/crates/feos/tests/pcsaft/state_creation_pure.rs b/crates/feos/tests/pcsaft/state_creation_pure.rs index d5bc9769e..cda35b75f 100644 --- a/crates/feos/tests/pcsaft/state_creation_pure.rs +++ b/crates/feos/tests/pcsaft/state_creation_pure.rs @@ -1,12 +1,10 @@ use approx::assert_relative_eq; use feos::ideal_gas::{Joback, JobackParameters}; use feos::pcsaft::{PcSaft, PcSaftParameters}; +use feos_core::DensityInitialization::{InitialDensity, Liquid, Vapor}; use feos_core::parameter::IdentifierOption; -use feos_core::{ - Contributions, EquationOfState, FeosResult, PhaseEquilibrium, State, StateBuilder, Total, -}; +use feos_core::{Contributions, EquationOfState, FeosResult, PhaseEquilibrium, State, Total}; use quantity::*; -use std::error::Error; fn propane_parameters() -> FeosResult<(PcSaftParameters, Vec)> { let saft = PcSaftParameters::from_json( @@ -25,77 +23,59 @@ fn propane_parameters() -> FeosResult<(PcSaftParameters, Vec)> { } #[test] -fn temperature_volume() -> Result<(), Box> { +fn temperature_volume() -> FeosResult<()> { let saft = PcSaft::new(propane_parameters()?.0); let temperature = 300.0 * KELVIN; let volume = 1.5e-3 * METER.powi::<3>(); let moles = MOL; - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .volume(volume) - .total_moles(moles) - .build()?; - assert_relative_eq!(state.volume, volume, max_relative = 1e-10); + let state = State::new_nvt(&&saft, temperature, volume, moles)?; + assert_relative_eq!(state.volume(), volume, max_relative = 1e-10); Ok(()) } #[test] -fn temperature_density() -> Result<(), Box> { +fn temperature_density() -> FeosResult<()> { let saft = PcSaft::new(propane_parameters()?.0); let temperature = 300.0 * KELVIN; let density = MOL / METER.powi::<3>(); - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .density(density) - .build()?; + let state = State::new_pure(&&saft, temperature, density)?; assert_relative_eq!(state.density, density, max_relative = 1e-10); Ok(()) } #[test] -fn temperature_total_moles_volume() -> Result<(), Box> { +fn temperature_total_moles_volume() -> FeosResult<()> { let saft = PcSaft::new(propane_parameters()?.0); let temperature = 300.0 * KELVIN; let total_moles = MOL; let volume = METER.powi::<3>(); - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .volume(volume) - .total_moles(total_moles) - .build()?; - assert_relative_eq!(state.volume, volume, max_relative = 1e-10); - assert_relative_eq!(state.total_moles, total_moles, max_relative = 1e-10); + let state = State::new_nvt(&&saft, temperature, volume, total_moles)?; + assert_relative_eq!(state.volume(), volume, max_relative = 1e-10); + assert_relative_eq!(state.total_moles(), total_moles, max_relative = 1e-10); Ok(()) } #[test] -fn temperature_total_moles_density() -> Result<(), Box> { +fn temperature_total_moles_density() -> FeosResult<()> { let saft = PcSaft::new(propane_parameters()?.0); let temperature = 300.0 * KELVIN; let total_moles = MOL; let density = MOL / METER.powi::<3>(); - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .density(density) - .total_moles(total_moles) - .build()?; + let state = State::new_pure(&&saft, temperature, density)?.set_total_moles(total_moles); assert_relative_eq!(state.density, density, max_relative = 1e-10); - assert_relative_eq!(state.total_moles, total_moles, max_relative = 1e-10); - assert_relative_eq!(state.volume, total_moles / density, max_relative = 1e-10); + assert_relative_eq!(state.total_moles(), total_moles, max_relative = 1e-10); + assert_relative_eq!(state.volume(), total_moles / density, max_relative = 1e-10); Ok(()) } // Pressure constructors #[test] -fn pressure_temperature() -> Result<(), Box> { +fn pressure_temperature() -> FeosResult<()> { let saft = PcSaft::new(propane_parameters()?.0); let pressure = BAR; let temperature = 300.0 * KELVIN; - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .pressure(pressure) - .build()?; + let state = State::new_npt(&&saft, temperature, pressure, (), None)?; assert_relative_eq!( state.pressure(Contributions::Total), pressure, @@ -105,15 +85,11 @@ fn pressure_temperature() -> Result<(), Box> { } #[test] -fn pressure_temperature_phase() -> Result<(), Box> { +fn pressure_temperature_phase() -> FeosResult<()> { let saft = PcSaft::new(propane_parameters()?.0); let pressure = BAR; let temperature = 300.0 * KELVIN; - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .pressure(pressure) - .liquid() - .build()?; + let state = State::new_npt(&&saft, temperature, pressure, (), Some(Liquid))?; assert_relative_eq!( state.pressure(Contributions::Total), pressure, @@ -123,15 +99,12 @@ fn pressure_temperature_phase() -> Result<(), Box> { } #[test] -fn pressure_temperature_initial_density() -> Result<(), Box> { +fn pressure_temperature_initial_density() -> FeosResult<()> { let saft = PcSaft::new(propane_parameters()?.0); let pressure = BAR; let temperature = 300.0 * KELVIN; - let state = StateBuilder::new(&&saft) - .temperature(temperature) - .pressure(pressure) - .initial_density(MOL / METER.powi::<3>()) - .build()?; + let init = Some(InitialDensity(MOL / METER.powi::<3>())); + let state = State::new_npt(&&saft, temperature, pressure, (), init)?; assert_relative_eq!( state.pressure(Contributions::Total), pressure, @@ -141,17 +114,13 @@ fn pressure_temperature_initial_density() -> Result<(), Box> { } #[test] -fn pressure_enthalpy_vapor() -> Result<(), Box> { +fn pressure_enthalpy_vapor() -> FeosResult<()> { let (saft_params, joback) = propane_parameters()?; let saft = PcSaft::new(saft_params); let eos = EquationOfState::new(joback, saft); let pressure = 0.3 * BAR; let molar_enthalpy = 2000.0 * JOULE / MOL; - let state = StateBuilder::new(&&eos) - .pressure(pressure) - .molar_enthalpy(molar_enthalpy) - .vapor() - .build()?; + let state = State::new_nph(&&eos, pressure, molar_enthalpy, (), Some(Vapor), None)?; assert_relative_eq!( state.molar_enthalpy(Contributions::Total), molar_enthalpy, @@ -163,11 +132,7 @@ fn pressure_enthalpy_vapor() -> Result<(), Box> { max_relative = 1e-10 ); - let state = StateBuilder::new(&&eos) - .volume(state.volume) - .temperature(state.temperature) - .moles(&state.moles) - .build()?; + let state = State::new(&&eos, state.temperature, state.density, state.molefracs)?; assert_relative_eq!( state.molar_enthalpy(Contributions::Total), molar_enthalpy, @@ -182,24 +147,22 @@ fn pressure_enthalpy_vapor() -> Result<(), Box> { } #[test] -fn density_internal_energy() -> Result<(), Box> { +fn density_internal_energy() -> FeosResult<()> { let (saft_params, joback) = propane_parameters()?; let saft = PcSaft::new(saft_params); let eos = EquationOfState::new(joback, saft); let pressure = 5.0 * BAR; let temperature = 315.0 * KELVIN; let total_moles = 2.5 * MOL; - let state = StateBuilder::new(&&eos) - .pressure(pressure) - .temperature(temperature) - .total_moles(total_moles) - .build()?; + let state = State::new_npt(&&eos, temperature, pressure, total_moles, None)?; let molar_internal_energy = state.molar_internal_energy(Contributions::Total); - let state_nvu = StateBuilder::new(&&eos) - .volume(state.volume) - .molar_internal_energy(molar_internal_energy) - .total_moles(total_moles) - .build()?; + let state_nvu = State::new_nvu( + &&eos, + state.volume(), + molar_internal_energy, + total_moles, + None, + )?; assert_relative_eq!( molar_internal_energy, state_nvu.molar_internal_energy(Contributions::Total), @@ -211,19 +174,21 @@ fn density_internal_energy() -> Result<(), Box> { } #[test] -fn pressure_enthalpy_total_moles_vapor() -> Result<(), Box> { +fn pressure_enthalpy_total_moles_vapor() -> FeosResult<()> { let (saft_params, joback) = propane_parameters()?; let saft = PcSaft::new(saft_params); let eos = EquationOfState::new(joback, saft); let pressure = 0.3 * BAR; let molar_enthalpy = 2000.0 * JOULE / MOL; let total_moles = 2.5 * MOL; - let state = StateBuilder::new(&&eos) - .pressure(pressure) - .molar_enthalpy(molar_enthalpy) - .total_moles(total_moles) - .vapor() - .build()?; + let state = State::new_nph( + &&eos, + pressure, + molar_enthalpy, + total_moles, + Some(Vapor), + None, + )?; assert_relative_eq!( state.molar_enthalpy(Contributions::Total), molar_enthalpy, @@ -235,11 +200,12 @@ fn pressure_enthalpy_total_moles_vapor() -> Result<(), Box> { max_relative = 1e-10 ); - let state = StateBuilder::new(&&eos) - .volume(state.volume) - .temperature(state.temperature) - .total_moles(state.total_moles) - .build()?; + let state = State::new_nvt( + &&eos, + state.temperature, + state.volume(), + state.total_moles(), + )?; assert_relative_eq!( state.molar_enthalpy(Contributions::Total), molar_enthalpy, @@ -254,17 +220,13 @@ fn pressure_enthalpy_total_moles_vapor() -> Result<(), Box> { } #[test] -fn pressure_entropy_vapor() -> Result<(), Box> { +fn pressure_entropy_vapor() -> FeosResult<()> { let (saft_params, joback) = propane_parameters()?; let saft = PcSaft::new(saft_params); let eos = EquationOfState::new(joback, saft); let pressure = 0.3 * BAR; let molar_entropy = -2.0 * JOULE / MOL / KELVIN; - let state = StateBuilder::new(&&eos) - .pressure(pressure) - .molar_entropy(molar_entropy) - .vapor() - .build()?; + let state = State::new_nps(&&eos, pressure, molar_entropy, (), Some(Vapor), None)?; assert_relative_eq!( state.molar_entropy(Contributions::Total), molar_entropy, @@ -276,11 +238,7 @@ fn pressure_entropy_vapor() -> Result<(), Box> { max_relative = 1e-10 ); - let state = StateBuilder::new(&&eos) - .volume(state.volume) - .temperature(state.temperature) - .moles(&state.moles) - .build()?; + let state = State::new(&&eos, state.temperature, state.density, state.molefracs)?; assert_relative_eq!( state.molar_entropy(Contributions::Total), molar_entropy, @@ -295,24 +253,20 @@ fn pressure_entropy_vapor() -> Result<(), Box> { } #[test] -fn temperature_entropy_vapor() -> Result<(), Box> { +fn temperature_entropy_vapor() -> FeosResult<()> { let (saft_params, joback) = propane_parameters()?; let saft = PcSaft::new(saft_params); let eos = EquationOfState::new(joback, saft); let pressure = 3.0 * BAR; let temperature = 315.15 * KELVIN; let total_moles = 3.0 * MOL; - let state = StateBuilder::new(&&eos) - .temperature(temperature) - .pressure(pressure) - .total_moles(total_moles) - .build()?; + let state = State::new_npt(&&eos, temperature, pressure, total_moles, None)?; let s = State::new_nts( &&eos, temperature, state.molar_entropy(Contributions::Total), - &state.moles, + state.moles(), None, )?; assert_relative_eq!( @@ -354,7 +308,7 @@ fn assert_multiple_states( } #[test] -fn test_consistency() -> Result<(), Box> { +fn test_consistency() -> FeosResult<()> { let (saft_params, joback) = propane_parameters()?; let saft = PcSaft::new(saft_params); let eos = EquationOfState::new(joback, saft); @@ -362,10 +316,7 @@ fn test_consistency() -> Result<(), Box> { let pressures = [1.0 * BAR, 2.0 * BAR, 3.0 * BAR]; for (&temperature, &pressure) in temperatures.iter().zip(pressures.iter()) { - let state = StateBuilder::new(&&eos) - .pressure(pressure) - .temperature(temperature) - .build()?; + let state = State::new_npt(&&eos, temperature, pressure, (), None)?; assert_relative_eq!( state.pressure(Contributions::Total), pressure, @@ -379,49 +330,26 @@ fn test_consistency() -> Result<(), Box> { let molar_entropy = state.molar_entropy(Contributions::Total); let density = state.density; - let state_tv = StateBuilder::new(&&eos) - .temperature(temperature) - .density(density) - .build()?; + let state_tv = State::new_pure(&&eos, temperature, density)?; let vle = PhaseEquilibrium::pure(&&eos, temperature, None, Default::default()); let eos = &eos; - let builder = if let Ok(ps) = vle { + let phase = if let Ok(ps) = vle { let p_sat = ps.liquid().pressure(Contributions::Total); - if pressure > p_sat { - StateBuilder::new(&eos).liquid() - } else { - StateBuilder::new(&eos).vapor() - } + if pressure > p_sat { Liquid } else { Vapor } } else { - StateBuilder::new(&eos).vapor() + Vapor }; - let state_ts = builder - .clone() - .temperature(temperature) - .molar_entropy(molar_entropy) - .build()?; + let state_ts = State::new_nts(&eos, temperature, molar_entropy, (), Some(phase))?; - let state_ps = builder - .clone() - .pressure(pressure) - .molar_entropy(molar_entropy) - .build()?; + let state_ps = State::new_nps(&eos, pressure, molar_entropy, (), Some(phase), None)?; dbg!("ph"); - let state_ph = builder - .clone() - .pressure(pressure) - .molar_enthalpy(molar_enthalpy) - .build()?; + let state_ph = State::new_nph(&eos, pressure, molar_enthalpy, (), Some(phase), None)?; dbg!("th"); - let state_th = builder - .clone() - .temperature(temperature) - .molar_enthalpy(molar_enthalpy) - .build()?; + let state_th = State::new_nth(&eos, temperature, molar_enthalpy, (), Some(phase))?; dbg!("assertions"); assert_multiple_states( diff --git a/crates/feos/tests/saftvrmie/critical_properties.rs b/crates/feos/tests/saftvrmie/critical_properties.rs index c78eb110a..1f3334ea3 100644 --- a/crates/feos/tests/saftvrmie/critical_properties.rs +++ b/crates/feos/tests/saftvrmie/critical_properties.rs @@ -52,7 +52,7 @@ fn critical_properties_pure() { let option = SolverOptions::default(); let p = parameters.remove(name).unwrap(); let eos = SaftVRMie::new(p); - let cp = State::critical_point(&&eos, None, t0, None, option).unwrap(); + let cp = State::critical_point(&&eos, (), t0, None, option).unwrap(); assert_relative_eq!(cp.temperature, data.0, max_relative = 2e-3); assert_relative_eq!( cp.pressure(feos_core::Contributions::Total), diff --git a/py-feos/src/ad/mod.rs b/py-feos/src/ad/mod.rs index c18d1eacb..75958d7ca 100644 --- a/py-feos/src/ad/mod.rs +++ b/py-feos/src/ad/mod.rs @@ -50,7 +50,7 @@ type GradResult<'py> = ( #[pyfunction] pub fn vapor_pressure_derivatives<'py>( model: PyEquationOfStateAD, - parameter_names: Bound<'py, PyAny>, + parameter_names: &Bound<'py, PyAny>, parameters: PyReadonlyArray2, input: PyReadonlyArray2, ) -> GradResult<'py> { @@ -102,7 +102,7 @@ pub fn boiling_temperature_derivatives<'py>( #[pyfunction] pub fn liquid_density_derivatives<'py>( model: PyEquationOfStateAD, - parameter_names: Bound<'py, PyAny>, + parameter_names: &Bound<'py, PyAny>, parameters: PyReadonlyArray2, input: PyReadonlyArray2, ) -> GradResult<'py> { @@ -128,7 +128,7 @@ pub fn liquid_density_derivatives<'py>( #[pyfunction] pub fn equilibrium_liquid_density_derivatives<'py>( model: PyEquationOfStateAD, - parameter_names: Bound<'py, PyAny>, + parameter_names: &Bound<'py, PyAny>, parameters: PyReadonlyArray2, input: PyReadonlyArray2, ) -> GradResult<'py> { @@ -155,7 +155,7 @@ pub fn equilibrium_liquid_density_derivatives<'py>( #[pyfunction] pub fn bubble_point_pressure_derivatives<'py>( model: PyEquationOfStateAD, - parameter_names: Bound<'py, PyAny>, + parameter_names: &Bound<'py, PyAny>, parameters: PyReadonlyArray2, input: PyReadonlyArray2, ) -> GradResult<'py> { @@ -182,7 +182,7 @@ pub fn bubble_point_pressure_derivatives<'py>( #[pyfunction] pub fn dew_point_pressure_derivatives<'py>( model: PyEquationOfStateAD, - parameter_names: Bound<'py, PyAny>, + parameter_names: &Bound<'py, PyAny>, parameters: PyReadonlyArray2, input: PyReadonlyArray2, ) -> GradResult<'py> { @@ -195,7 +195,7 @@ macro_rules! expand_models { #[pyfunction] fn [<_ $prop _derivatives>]<'py>( model: PyEquationOfStateAD, - parameter_names: Bound<'py, PyAny>, + parameter_names: &Bound<'py, PyAny>, parameters: PyReadonlyArray2, input: PyReadonlyArray2, ) -> GradResult<'py> { @@ -220,7 +220,7 @@ macro_rules! impl_evaluate_gradients { expand_models!($enum, $prop, $($model: $type),*); paste!( fn $prop<'py, R: ParametersAD<$n>>( - parameter_names: Bound<'py, PyAny>, + parameter_names: &Bound<'py, PyAny>, parameters: PyReadonlyArray2, input: PyReadonlyArray2, ) -> ( diff --git a/py-feos/src/dft/adsorption/mod.rs b/py-feos/src/dft/adsorption/mod.rs index 1f006be0a..1c5040003 100644 --- a/py-feos/src/dft/adsorption/mod.rs +++ b/py-feos/src/dft/adsorption/mod.rs @@ -1,9 +1,9 @@ use super::PyDFTSolver; -use crate::eos::{parse_molefracs, PyEquationOfState}; +use crate::PyVerbosity; +use crate::eos::{Compositions, PyEquationOfState}; use crate::error::PyFeosError; use crate::ideal_gas::IdealGasModel; use crate::residual::ResidualModel; -use crate::PyVerbosity; use feos_core::EquationOfState; use feos_dft::adsorption::{Adsorption, Adsorption1D, Adsorption3D}; use nalgebra::DMatrix; @@ -45,8 +45,8 @@ macro_rules! impl_adsorption_isotherm { /// The pressures for which the profiles are calculated. /// pore : Pore /// The pore parameters. - /// molefracs: numpy.ndarray[float], optional - /// For a mixture, the molefracs of the bulk system. + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional + /// The composition of the mixture. /// solver: DFTSolver, optional /// Custom solver options. /// @@ -55,14 +55,14 @@ macro_rules! impl_adsorption_isotherm { /// Adsorption /// #[staticmethod] - #[pyo3(text_signature = "(functional, temperature, pressure, pore, molefracs=None, solver=None)")] - #[pyo3(signature = (functional, temperature, pressure, pore, molefracs=None, solver=None))] + #[pyo3(text_signature = "(functional, temperature, pressure, pore, composition=None, solver=None)")] + #[pyo3(signature = (functional, temperature, pressure, pore, composition=None, solver=None))] fn adsorption_isotherm( functional: &PyEquationOfState, temperature: Temperature, pressure: Pressure>, pore: &$py_pore, - molefracs: Option>, + composition: Option<&Bound<'_, PyAny>>, solver: Option, ) -> PyResult { Ok(Self(Adsorption::adsorption_isotherm( @@ -70,7 +70,7 @@ macro_rules! impl_adsorption_isotherm { temperature, &pressure, &pore.0, - &parse_molefracs(molefracs), + Compositions::try_from(composition)?, solver.map(|s| s.0).as_ref(), ).map_err(PyFeosError::from)?)) } @@ -89,8 +89,8 @@ macro_rules! impl_adsorption_isotherm { /// The pressures for which the profiles are calculated. /// pore : Pore /// The pore parameters. - /// molefracs: numpy.ndarray[float], optional - /// For a mixture, the molefracs of the bulk system. + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional + /// The composition of the mixture. /// solver: DFTSolver, optional /// Custom solver options. /// @@ -99,14 +99,14 @@ macro_rules! impl_adsorption_isotherm { /// Adsorption /// #[staticmethod] - #[pyo3(text_signature = "(functional, temperature, pressure, pore, molefracs=None, solver=None)")] - #[pyo3(signature = (functional, temperature, pressure, pore, molefracs=None, solver=None))] + #[pyo3(text_signature = "(functional, temperature, pressure, pore, composition=None, solver=None)")] + #[pyo3(signature = (functional, temperature, pressure, pore, composition=None, solver=None))] fn desorption_isotherm( functional: &PyEquationOfState, temperature: Temperature, pressure: Pressure>, pore: &$py_pore, - molefracs: Option>, + composition: Option<&Bound<'_, PyAny>>, solver: Option, ) -> PyResult { Ok(Self(Adsorption::desorption_isotherm( @@ -114,7 +114,7 @@ macro_rules! impl_adsorption_isotherm { temperature, &pressure, &pore.0, - &parse_molefracs(molefracs), + Compositions::try_from(composition)?, solver.map(|s| s.0).as_ref(), ).map_err(PyFeosError::from)?)) } @@ -136,8 +136,8 @@ macro_rules! impl_adsorption_isotherm { /// The pressures for which the profiles are calculated. /// pore : Pore /// The pore parameters. - /// molefracs: numpy.ndarray[float], optional - /// For a mixture, the molefracs of the bulk system. + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional + /// The composition of the mixture. /// solver: DFTSolver, optional /// Custom solver options. /// @@ -146,14 +146,14 @@ macro_rules! impl_adsorption_isotherm { /// Adsorption /// #[staticmethod] - #[pyo3(text_signature = "(functional, temperature, pressure, pore, molefracs=None, solver=None)")] - #[pyo3(signature = (functional, temperature, pressure, pore, molefracs=None, solver=None))] + #[pyo3(text_signature = "(functional, temperature, pressure, pore, composition=None, solver=None)")] + #[pyo3(signature = (functional, temperature, pressure, pore, composition=None, solver=None))] fn equilibrium_isotherm( functional: &PyEquationOfState, temperature: Temperature, pressure: Pressure>, pore: &$py_pore, - molefracs: Option>, + composition: Option<&Bound<'_, PyAny>>, solver: Option, ) -> PyResult { Ok(Self(Adsorption::equilibrium_isotherm( @@ -161,7 +161,7 @@ macro_rules! impl_adsorption_isotherm { temperature, &pressure, &pore.0, - &parse_molefracs(molefracs), + Compositions::try_from(composition)?, solver.map(|s| s.0).as_ref(), ).map_err(PyFeosError::from)?)) } @@ -180,8 +180,8 @@ macro_rules! impl_adsorption_isotherm { /// A suitable upper limit for the pressure. /// pore : Pore /// The pore parameters. - /// molefracs: numpy.ndarray[float], optional - /// For a mixture, the molefracs of the bulk system. + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional + /// The composition of the mixture. /// solver: DFTSolver, optional /// Custom solver options. /// max_iter : int, optional @@ -196,8 +196,8 @@ macro_rules! impl_adsorption_isotherm { /// Adsorption /// #[staticmethod] - #[pyo3(text_signature = "(functional, temperature, p_min, p_max, pore, molefracs=None, solver=None, max_iter=None, tol=None, verbosity=None)")] - #[pyo3(signature = (functional, temperature, p_min, p_max, pore, molefracs=None, solver=None, max_iter=None, tol=None, verbosity=None))] + #[pyo3(text_signature = "(functional, temperature, p_min, p_max, pore, composition=None, solver=None, max_iter=None, tol=None, verbosity=None)")] + #[pyo3(signature = (functional, temperature, p_min, p_max, pore, composition=None, solver=None, max_iter=None, tol=None, verbosity=None))] #[expect(clippy::too_many_arguments)] fn phase_equilibrium( functional: &PyEquationOfState, @@ -205,7 +205,7 @@ macro_rules! impl_adsorption_isotherm { p_min: Pressure, p_max: Pressure, pore: &$py_pore, - molefracs: Option>, + composition: Option<&Bound<'_, PyAny>>, solver: Option, max_iter: Option, tol: Option, @@ -217,7 +217,7 @@ macro_rules! impl_adsorption_isotherm { p_min, p_max, &pore.0, - &parse_molefracs(molefracs), + Compositions::try_from(composition)?, solver.map(|s| s.0).as_ref(), (max_iter, tol, verbosity.map(|v| v.into())).into(), ).map_err(PyFeosError::from)?)) diff --git a/py-feos/src/eos/mod.rs b/py-feos/src/eos/mod.rs index 1147ac020..cd50de42f 100644 --- a/py-feos/src/eos/mod.rs +++ b/py-feos/src/eos/mod.rs @@ -3,9 +3,9 @@ use crate::ideal_gas::IdealGasModel; use crate::residual::ResidualModel; use feos_core::*; use indexmap::IndexMap; -use nalgebra::{DVector, DVectorView, Dyn}; +use nalgebra::{DVector, DVectorView, Dyn, U1}; use numpy::{PyArray1, PyReadonlyArray1, ToPyArray}; -use pyo3::prelude::*; +use pyo3::{exceptions::PyValueError, prelude::*}; use quantity::*; use std::ops::Div; use std::sync::Arc; @@ -58,17 +58,17 @@ impl PyEquationOfState { /// /// Parameters /// ---------- - /// molefracs : np.ndarray[float], optional + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional /// The composition of the mixture. /// /// Returns /// ------- /// SINumber - #[pyo3(text_signature = "(molefracs=None)", signature = (molefracs=None))] - fn max_density(&self, molefracs: Option>) -> PyResult { + #[pyo3(text_signature = "(composition=None)", signature = (composition=None))] + fn max_density(&self, composition: Option<&Bound<'_, PyAny>>) -> PyResult { Ok(self .0 - .max_density(&parse_molefracs(molefracs)) + .max_density(Compositions::try_from(composition)?) .map_err(PyFeosError::from)?) } @@ -78,20 +78,22 @@ impl PyEquationOfState { /// ---------- /// temperature : SINumber /// The temperature for which B should be computed. - /// molefracs : np.ndarray[float], optional + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional /// The composition of the mixture. /// /// Returns /// ------- /// SINumber - #[pyo3(text_signature = "(temperature, molefracs=None)", signature = (temperature, molefracs=None))] + #[pyo3(text_signature = "(temperature, composition=None)", signature = (temperature, composition=None))] fn second_virial_coefficient( &self, temperature: Temperature, - molefracs: Option>, - ) -> Quot { - self.0 - .second_virial_coefficient(temperature, &parse_molefracs(molefracs)) + composition: Option<&Bound<'_, PyAny>>, + ) -> PyResult> { + Ok(self + .0 + .second_virial_coefficient(temperature, Compositions::try_from(composition)?) + .map_err(PyFeosError::from)?) } /// Calculate the third Virial coefficient C(T,x). @@ -100,20 +102,22 @@ impl PyEquationOfState { /// ---------- /// temperature : SINumber /// The temperature for which C should be computed. - /// molefracs : np.ndarray[float], optional + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional /// The composition of the mixture. /// /// Returns /// ------- /// SINumber - #[pyo3(text_signature = "(temperature, molefracs=None)", signature = (temperature, molefracs=None))] + #[pyo3(text_signature = "(temperature, composition=None)", signature = (temperature, composition=None))] fn third_virial_coefficient( &self, temperature: Temperature, - molefracs: Option>, - ) -> Quot, Density> { - self.0 - .third_virial_coefficient(temperature, &parse_molefracs(molefracs)) + composition: Option<&Bound<'_, PyAny>>, + ) -> PyResult, Density>> { + Ok(self + .0 + .third_virial_coefficient(temperature, Compositions::try_from(composition)?) + .map_err(PyFeosError::from)?) } /// Calculate the derivative of the second Virial coefficient B(T,x) @@ -123,22 +127,25 @@ impl PyEquationOfState { /// ---------- /// temperature : SINumber /// The temperature for which B' should be computed. - /// molefracs : np.ndarray[float], optional + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional /// The composition of the mixture. /// /// Returns /// ------- /// SINumber - #[pyo3(text_signature = "(temperature, molefracs=None)", signature = (temperature, molefracs=None))] + #[pyo3(text_signature = "(temperature, composition=None)", signature = (temperature, composition=None))] fn second_virial_coefficient_temperature_derivative( &self, temperature: Temperature, - molefracs: Option>, - ) -> Quot, Temperature> { - self.0.second_virial_coefficient_temperature_derivative( - temperature, - &parse_molefracs(molefracs), - ) + composition: Option<&Bound<'_, PyAny>>, + ) -> PyResult, Temperature>> { + Ok(self + .0 + .second_virial_coefficient_temperature_derivative( + temperature, + Compositions::try_from(composition)?, + ) + .map_err(PyFeosError::from)?) } /// Calculate the derivative of the third Virial coefficient C(T,x) @@ -148,22 +155,26 @@ impl PyEquationOfState { /// ---------- /// temperature : SINumber /// The temperature for which C' should be computed. - /// molefracs : np.ndarray[float], optional + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional /// The composition of the mixture. /// /// Returns /// ------- /// SINumber - #[pyo3(text_signature = "(temperature, molefracs=None)", signature = (temperature, molefracs=None))] + #[expect(clippy::type_complexity)] + #[pyo3(text_signature = "(temperature, composition=None)", signature = (temperature, composition=None))] fn third_virial_coefficient_temperature_derivative( &self, temperature: Temperature, - molefracs: Option>, - ) -> Quot, Density>, Temperature> { - self.0.third_virial_coefficient_temperature_derivative( - temperature, - &parse_molefracs(molefracs), - ) + composition: Option<&Bound<'_, PyAny>>, + ) -> PyResult, Density>, Temperature>> { + Ok(self + .0 + .third_virial_coefficient_temperature_derivative( + temperature, + Compositions::try_from(composition)?, + ) + .map_err(PyFeosError::from)?) } } @@ -192,36 +203,64 @@ pub(crate) fn parse_molefracs(molefracs: Option>) -> Optio }) } -// impl_state_entropy_scaling!(EquationOfState, ResidualModel>, PyEquationOfState); -// impl_phase_equilibrium!(EquationOfState, ResidualModel>, PyEquationOfState); - -// #[cfg(feature = "estimator")] -// impl_estimator!(EquationOfState, ResidualModel>, PyEquationOfState); -// #[cfg(all(feature = "estimator", feature = "pcsaft"))] -// impl_estimator_entropy_scaling!(EquationOfState, ResidualModel>, PyEquationOfState); - -// #[pymodule] -// pub fn eos(m: &Bound<'_, PyModule>) -> PyResult<()> { -// m.add_class::()?; -// m.add_class::()?; - -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; +#[derive(Clone)] +pub enum Compositions { + None, + Scalar(f64), + TotalMoles(Moles), + Molefracs(DVector), + Moles(Moles>), + PartialDensity(Density>), +} -// #[cfg(feature = "estimator")] -// m.add_wrapped(wrap_pymodule!(estimator_eos))?; +impl Composition for Compositions { + fn into_molefracs>( + self, + eos: &E, + ) -> FeosResult<(DVector, Option>)> { + match self { + Self::None => ().into_molefracs(eos), + Self::Scalar(x) => x.into_molefracs(eos), + Self::TotalMoles(total_moles) => total_moles.into_molefracs(eos), + Self::Molefracs(molefracs) => molefracs.into_molefracs(eos), + Self::Moles(moles) => moles.into_molefracs(eos), + Self::PartialDensity(partial_density) => partial_density.into_molefracs(eos), + } + } -// Ok(()) -// } + fn density(&self) -> Option> { + if let Self::PartialDensity(partial_density) = self { + partial_density.density() + } else { + None + } + } +} -// #[cfg(feature = "estimator")] -// #[pymodule] -// pub fn estimator_eos(m: &Bound<'_, PyModule>) -> PyResult<()> { -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::() -// } +impl TryFrom>> for Compositions { + type Error = PyErr; + fn try_from(composition: Option<&Bound<'_, PyAny>>) -> PyResult { + let Some(composition) = composition else { + return Ok(Compositions::None); + }; + if let Ok(x) = composition.extract::>() + && let Some(x) = x.try_as_matrix::() + { + Ok(Compositions::Molefracs(x.clone_owned())) + } else if let Ok(x) = composition.extract::>() { + Ok(Compositions::Molefracs(DVector::from_vec(x))) + } else if let Ok(x) = composition.extract::() { + Ok(Compositions::Scalar(x)) + } else if let Ok(n) = composition.extract::>>() { + Ok(Compositions::Moles(n)) + } else if let Ok(n) = composition.extract::() { + Ok(Compositions::TotalMoles(n)) + } else if let Ok(rho) = composition.extract::>>() { + Ok(Compositions::PartialDensity(rho)) + } else { + Err(PyErr::new::(format!( + "failed to parse value '{composition}' as composition." + ))) + } + } +} diff --git a/py-feos/src/parameter/mod.rs b/py-feos/src/parameter/mod.rs index 115f8f79a..aabbed5ab 100644 --- a/py-feos/src/parameter/mod.rs +++ b/py-feos/src/parameter/mod.rs @@ -557,6 +557,7 @@ impl PyGcParameters { .map_err(PyFeosError::from)?) } + #[cfg(feature = "gc_pcsaft")] pub fn try_convert_heterosegmented( self, ) -> PyResult> diff --git a/py-feos/src/phase_equilibria.rs b/py-feos/src/phase_equilibria.rs index c3786e300..38d0d2d12 100644 --- a/py-feos/src/phase_equilibria.rs +++ b/py-feos/src/phase_equilibria.rs @@ -61,7 +61,7 @@ impl PyPhaseEquilibrium { #[pyo3(signature = (eos, temperature_or_pressure, initial_state=None, max_iter=None, tol=None, verbosity=None))] pub(crate) fn pure( eos: &PyEquationOfState, - temperature_or_pressure: Bound<'_, PyAny>, + temperature_or_pressure: &Bound<'_, PyAny>, initial_state: Option<&PyPhaseEquilibrium>, max_iter: Option, tol: Option, @@ -198,9 +198,9 @@ impl PyPhaseEquilibrium { #[expect(clippy::too_many_arguments)] pub(crate) fn bubble_point<'py>( eos: &PyEquationOfState, - temperature_or_pressure: Bound<'_, PyAny>, + temperature_or_pressure: &Bound<'_, PyAny>, liquid_molefracs: PyReadonlyArray1<'py, f64>, - tp_init: Option>, + tp_init: Option<&Bound<'_, PyAny>>, vapor_molefracs: Option>, max_iter_inner: Option, max_iter_outer: Option, @@ -286,9 +286,9 @@ impl PyPhaseEquilibrium { #[expect(clippy::too_many_arguments)] pub(crate) fn dew_point<'py>( eos: &PyEquationOfState, - temperature_or_pressure: Bound<'_, PyAny>, + temperature_or_pressure: &Bound<'_, PyAny>, vapor_molefracs: PyReadonlyArray1<'py, f64>, - tp_init: Option>, + tp_init: Option<&Bound<'_, PyAny>>, liquid_molefracs: Option>, max_iter_inner: Option, max_iter_outer: Option, @@ -398,7 +398,7 @@ impl PyPhaseEquilibrium { #[staticmethod] fn vle_pure_comps( eos: &PyEquationOfState, - temperature_or_pressure: Bound<'_, PyAny>, + temperature_or_pressure: &Bound<'_, PyAny>, ) -> PyResult>> { if let Ok(t) = temperature_or_pressure.extract::() { Ok(PhaseEquilibrium::vle_pure_comps(&eos.0, t) @@ -514,9 +514,9 @@ impl PyPhaseEquilibrium { #[expect(clippy::too_many_arguments)] fn heteroazeotrope( eos: &PyEquationOfState, - temperature_or_pressure: Bound<'_, PyAny>, + temperature_or_pressure: &Bound<'_, PyAny>, x_init: (f64, f64), - tp_init: Option>, + tp_init: Option<&Bound<'_, PyAny>>, max_iter: Option, tol: Option, verbosity: Option, @@ -1224,7 +1224,7 @@ impl PyPhaseDiagram { #[expect(clippy::too_many_arguments)] pub(crate) fn binary_vle( eos: &PyEquationOfState, - temperature_or_pressure: Bound<'_, PyAny>, + temperature_or_pressure: &Bound<'_, PyAny>, npoints: Option, x_lle: Option<(f64, f64)>, max_iter_inner: Option, @@ -1299,7 +1299,7 @@ impl PyPhaseDiagram { #[pyo3(signature = (eos, temperature_or_pressure, feed, min_tp, max_tp, npoints=None))] pub(crate) fn lle( eos: &PyEquationOfState, - temperature_or_pressure: Bound<'_, PyAny>, + temperature_or_pressure: &Bound<'_, PyAny>, feed: Moles>, min_tp: Bound<'_, PyAny>, max_tp: Bound<'_, PyAny>, @@ -1388,10 +1388,10 @@ impl PyPhaseDiagram { #[expect(clippy::too_many_arguments)] pub(crate) fn binary_vlle( eos: &PyEquationOfState, - temperature_or_pressure: Bound<'_, PyAny>, + temperature_or_pressure: &Bound<'_, PyAny>, x_lle: (f64, f64), - tp_lim_lle: Option>, - tp_init_vlle: Option>, + tp_lim_lle: Option<&Bound<'_, PyAny>>, + tp_init_vlle: Option<&Bound<'_, PyAny>>, npoints_vle: Option, npoints_lle: Option, max_iter_inner: Option, diff --git a/py-feos/src/state.rs b/py-feos/src/state.rs index e7b54211c..6d5ff09f8 100644 --- a/py-feos/src/state.rs +++ b/py-feos/src/state.rs @@ -1,4 +1,4 @@ -use crate::eos::parse_molefracs; +use crate::eos::{Compositions, parse_molefracs}; use crate::{ PyVerbosity, eos::PyEquationOfState, error::PyFeosError, ideal_gas::IdealGasModel, residual::ResidualModel, @@ -13,15 +13,13 @@ use pyo3::exceptions::{PyIndexError, PyValueError}; use pyo3::prelude::*; use quantity::*; use std::collections::HashMap; -use std::ops::{Deref, Div, Neg, Sub}; +use std::ops::{Deref, Div, Neg}; use std::sync::Arc; type Quot = >::Output; -type DpDn = Quantity>::Output>; type InvT = Quantity::Output>; type InvP = Quantity::Output>; -type InvM = Quantity::Output>; /// Possible contributions that can be computed. #[derive(Clone, Copy, PartialEq)] @@ -69,14 +67,8 @@ impl From for Contributions { /// Volume. /// density : SINumber, optional /// Molar density. -/// partial_density : SIArray1, optional -/// Partial molar densities. -/// total_moles : SINumber, optional -/// Total amount of substance (of a mixture). -/// moles : SIArray1, optional -/// Amount of substance for each component. -/// molefracs : numpy.ndarray[float] -/// Molar fraction of each component. +/// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional +/// Composition of the mixture. /// pressure : SINumber, optional /// Pressure. /// molar_enthalpy : SINumber, optional @@ -110,19 +102,16 @@ pub struct PyState(pub State, ResidualMod impl PyState { #[new] #[pyo3( - text_signature = "(eos, temperature=None, volume=None, density=None, partial_density=None, total_moles=None, moles=None, molefracs=None, pressure=None, molar_enthalpy=None, molar_entropy=None, molar_internal_energy=None, density_initialization=None, initial_temperature=None)" + text_signature = "(eos, temperature=None, volume=None, density=None, composition=None, pressure=None, molar_enthalpy=None, molar_entropy=None, molar_internal_energy=None, density_initialization=None, initial_temperature=None)" )] - #[pyo3(signature = (eos, temperature=None, volume=None, density=None, partial_density=None, total_moles=None, moles=None, molefracs=None, pressure=None, molar_enthalpy=None, molar_entropy=None, molar_internal_energy=None, density_initialization=None, initial_temperature=None))] + #[pyo3(signature = (eos, temperature=None, volume=None, density=None, composition=None, pressure=None, molar_enthalpy=None, molar_entropy=None, molar_internal_energy=None, density_initialization=None, initial_temperature=None))] #[expect(clippy::too_many_arguments)] pub fn new<'py>( eos: &PyEquationOfState, temperature: Option, volume: Option, density: Option, - partial_density: Option>>, - total_moles: Option, - moles: Option>>, - molefracs: Option>, + composition: Option<&Bound<'py, PyAny>>, pressure: Option, molar_enthalpy: Option, molar_entropy: Option, @@ -130,7 +119,7 @@ impl PyState { density_initialization: Option<&Bound<'py, PyAny>>, initial_temperature: Option, ) -> PyResult { - let x = parse_molefracs(molefracs); + let composition = Compositions::try_from(composition)?; let density_init = if let Some(di) = density_initialization { if let Ok(d) = di.extract::().as_deref() { match d { @@ -147,25 +136,23 @@ impl PyState { } } else { Ok(None) - }; - let s = State::new_full( - &eos.0, - temperature, - volume, - density, - partial_density.as_ref(), - total_moles, - moles.as_ref(), - x.as_ref(), - pressure, - molar_enthalpy, - molar_entropy, - molar_internal_energy, - density_init?, - initial_temperature, - ) - .map_err(PyFeosError::from)?; - Ok(Self(s)) + }?; + Ok(Self( + State::build_full( + &eos.0, + temperature, + volume, + density, + composition, + pressure, + molar_enthalpy, + molar_entropy, + molar_internal_energy, + density_init, + initial_temperature, + ) + .map_err(PyFeosError::from)?, + )) } /// Return a list of thermodynamic state at critical conditions @@ -233,12 +220,12 @@ impl PyState { /// State : State at critical conditions. #[staticmethod] #[pyo3( - text_signature = "(eos, molefracs=None, initial_temperature=None, initial_density=None, max_iter=None, tol=None, verbosity=None)" + text_signature = "(eos, composition=None, initial_temperature=None, initial_density=None, max_iter=None, tol=None, verbosity=None)" )] - #[pyo3(signature = (eos, molefracs=None, initial_temperature=None, initial_density=None, max_iter=None, tol=None, verbosity=None))] + #[pyo3(signature = (eos, composition=None, initial_temperature=None, initial_density=None, max_iter=None, tol=None, verbosity=None))] fn critical_point<'py>( eos: &PyEquationOfState, - molefracs: Option>, + composition: Option<&Bound<'py, PyAny>>, initial_temperature: Option, initial_density: Option, max_iter: Option, @@ -248,7 +235,7 @@ impl PyState { Ok(PyState( State::critical_point( &eos.0, - parse_molefracs(molefracs).as_ref(), + Compositions::try_from(composition)?, initial_temperature, initial_density, (max_iter, tol, verbosity.map(|v| v.into())).into(), @@ -287,7 +274,7 @@ impl PyState { #[expect(clippy::too_many_arguments)] fn critical_point_binary( eos: &PyEquationOfState, - temperature_or_pressure: Bound<'_, PyAny>, + temperature_or_pressure: &Bound<'_, PyAny>, initial_temperature: Option, initial_molefracs: Option<[f64; 2]>, initial_density: Option, @@ -353,13 +340,13 @@ impl PyState { /// (State, State): Spinodal states. #[staticmethod] #[pyo3( - text_signature = "(eos, temperature, molefracs=None, max_iter=None, tol=None, verbosity=None)" + text_signature = "(eos, temperature, composition=None, max_iter=None, tol=None, verbosity=None)" )] - #[pyo3(signature = (eos, temperature, molefracs=None, max_iter=None, tol=None, verbosity=None))] + #[pyo3(signature = (eos, temperature, composition=None, max_iter=None, tol=None, verbosity=None))] fn spinodal<'py>( eos: &PyEquationOfState, temperature: Temperature, - molefracs: Option>, + composition: Option<&Bound<'py, PyAny>>, max_iter: Option, tol: Option, verbosity: Option, @@ -367,7 +354,7 @@ impl PyState { let [state1, state2] = State::spinodal( &eos.0, temperature, - parse_molefracs(molefracs).as_ref(), + Compositions::try_from(composition)?, (max_iter, tol, verbosity.map(|v| v.into())).into(), ) .map_err(PyFeosError::from)?; @@ -476,7 +463,7 @@ impl PyState { self.0.compressibility(contributions.into()) } - /// Return partial derivative of pressure w.r.t. volume. + /// Return partial derivative of pressure w.r.t. molar volume. /// /// Parameters /// ---------- @@ -488,7 +475,7 @@ impl PyState { /// ------- /// SINumber #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn dp_dv(&self, contributions: PyContributions) -> Quot { + fn dp_dv(&self, contributions: PyContributions) -> Quot { self.0.dp_dv(contributions.into()) } @@ -536,11 +523,11 @@ impl PyState { /// ------- /// SIArray1 #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn dp_dni(&self, contributions: PyContributions) -> DpDn> { - self.0.dp_dni(contributions.into()) + fn dp_dni(&self, contributions: PyContributions) -> Pressure> { + self.0.n_dp_dni(contributions.into()) } - /// Return second partial derivative of pressure w.r.t. volume. + /// Return second partial derivative of pressure w.r.t. molar volume. /// /// Parameters /// ---------- @@ -552,7 +539,10 @@ impl PyState { /// ------- /// SINumber #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn d2p_dv2(&self, contributions: PyContributions) -> Quot, Volume> { + fn d2p_dv2( + &self, + contributions: PyContributions, + ) -> Quot, MolarVolume> { self.0.d2p_dv2(contributions.into()) } @@ -652,8 +642,8 @@ impl PyState { /// ------- /// SIArray2 #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn dmu_dni(&self, contributions: PyContributions) -> Quot>, Moles> { - self.0.dmu_dni(contributions.into()) + fn n_dmu_dni(&self, contributions: PyContributions) -> MolarEnergy> { + self.0.n_dmu_dni(contributions.into()) } /// Return logarithmic fugacity coefficient. @@ -771,8 +761,8 @@ impl PyState { /// Returns /// ------- /// SIArray2 - fn dln_phi_dnj(&self) -> InvM> { - self.0.dln_phi_dnj() + fn n_dln_phi_dnj<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray2> { + self.0.n_dln_phi_dnj().to_pyarray(py) } /// Return thermodynamic factor. @@ -848,7 +838,7 @@ impl PyState { self.0.entropy(contributions.into()) } - /// Return derivative of entropy with respect to temperature. + /// Return derivative of molar entropy with respect to temperature. /// /// Parameters /// ---------- @@ -860,7 +850,7 @@ impl PyState { /// ------- /// SINumber #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn ds_dt(&self, contributions: PyContributions) -> Quot { + fn ds_dt(&self, contributions: PyContributions) -> Quot { self.0.ds_dt(contributions.into()) } @@ -1357,7 +1347,7 @@ impl PyState { #[getter] fn get_total_moles(&self) -> Moles { - self.0.total_moles + self.0.total_moles() } #[getter] @@ -1367,7 +1357,7 @@ impl PyState { #[getter] fn get_volume(&self) -> Volume { - self.0.volume + self.0.volume() } #[getter] @@ -1377,12 +1367,12 @@ impl PyState { #[getter] fn get_moles(&self) -> Moles> { - self.0.moles.clone() + self.0.moles() } #[getter] fn get_partial_density(&self) -> Density> { - self.0.partial_density.clone() + self.0.partial_density() } #[getter] From c33df5910cff0b02fc73f8a6738c17965be6c79c Mon Sep 17 00:00:00 2001 From: Philipp Rehner Date: Tue, 27 Jan 2026 12:36:56 +0100 Subject: [PATCH 05/11] include phase fraction in PhaseEquilibrium --- .github/workflows/test.yml | 2 +- .github/workflows/wheels.yml | 2 +- CHANGELOG.md | 9 +- crates/feos-core/src/ad/mod.rs | 32 ++- crates/feos-core/src/errors.rs | 4 + crates/feos-core/src/lib.rs | 22 +- .../src/phase_equilibria/bubble_dew.rs | 63 +++--- crates/feos-core/src/phase_equilibria/mod.rs | 200 +++++++++--------- .../phase_equilibria/phase_diagram_binary.rs | 16 +- .../phase_equilibria/phase_diagram_pure.rs | 4 +- .../src/phase_equilibria/phase_envelope.rs | 60 +++--- .../phase_equilibria/stability_analysis.rs | 6 +- .../src/phase_equilibria/tp_flash.rs | 161 +++++++++----- .../src/phase_equilibria/vle_pure.rs | 17 +- crates/feos-core/src/state/composition.rs | 94 +------- crates/feos-core/src/state/mod.rs | 37 ++-- crates/feos-core/src/state/properties.rs | 22 +- .../src/state/residual_properties.rs | 51 +++-- crates/feos-core/src/state/statevec.rs | 15 +- crates/feos/benches/contributions.rs | 2 +- crates/feos/benches/dft_pore.rs | 12 +- crates/feos/benches/dual_numbers.rs | 10 +- crates/feos/benches/dual_numbers_saftvrmie.rs | 13 +- crates/feos/benches/state_creation.rs | 2 +- crates/feos/src/epcsaft/eos/mod.rs | 4 +- crates/feos/src/pcsaft/eos/mod.rs | 82 +++---- crates/feos/src/pets/eos/mod.rs | 2 +- crates/feos/src/uvtheory/eos/mod.rs | 4 +- .../feos/tests/pcsaft/stability_analysis.rs | 5 +- .../tests/pcsaft/state_creation_mixture.rs | 2 +- .../feos/tests/pcsaft/state_creation_pure.rs | 18 +- py-feos/src/ad/mod.rs | 2 +- py-feos/src/eos/mod.rs | 12 -- py-feos/src/phase_equilibria.rs | 107 +++------- py-feos/src/state.rs | 99 ++++++--- 35 files changed, 573 insertions(+), 620 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6ea6192e7..6c35188d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: push: - branches: [main, development] + branches: [main] pull_request: branches: [main, development] diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 41f501d43..197da9586 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -1,7 +1,7 @@ name: Build Wheels on: push: - branches: [main, development] + branches: [main] pull_request: branches: [main, development] jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 444607c36..513a7cc1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Breaking] ### Added - Extended tp-flash algorithm to static numbers of components and enabled automatic differentiation for binary systems. [#336](https://github.com/feos-org/feos/pull/336) -- Rewrote `PhaseEquilibrium::pure_p` to mirror `pure_t` and enable automatic differentiation. [#337](https://github.com/feos-org/feos/pull/337) +- Rewrote `PhaseEquilibrium::pure_p` to mirror `pure_t` and enabled automatic differentiation. [#337](https://github.com/feos-org/feos/pull/337) - Added `boiling_temperature` to the list of properties for parallel evaluations of gradients. [#337](https://github.com/feos-org/feos/pull/337) +- Added the `Composition` trait to allow more flexibility in the creation of states and phase equilibria. [#330](https://github.com/feos-org/feos/pull/330) + +### Changed +- Removed any assumptions about the total number of moles in a `State` or `PhaseEquilibrium`. Evaluating extensive properties now returns a `Result`. [#330](https://github.com/feos-org/feos/pull/330) + +### Removed +- Removed the `StateBuilder` struct, because it is mostly obsolete with the addition of the `Composition` trait. [#330](https://github.com/feos-org/feos/pull/330) ### Packaging - Updated `quantity` dependency to 0.13 and removed the `typenum` dependency. [#323](https://github.com/feos-org/feos/pull/323) diff --git a/crates/feos-core/src/ad/mod.rs b/crates/feos-core/src/ad/mod.rs index aae9fc198..f48fb153c 100644 --- a/crates/feos-core/src/ad/mod.rs +++ b/crates/feos-core/src/ad/mod.rs @@ -1,6 +1,6 @@ use crate::DensityInitialization::Liquid; use crate::density_iteration::density_iteration; -use crate::{FeosResult, PhaseEquilibrium, ReferenceSystem, Residual}; +use crate::{Composition, FeosResult, PhaseEquilibrium, ReferenceSystem, Residual}; use nalgebra::{Const, SVector, U1, U2}; #[cfg(feature = "rayon")] use ndarray::{Array1, Array2, ArrayView2, Zip}; @@ -215,20 +215,21 @@ pub trait PropertiesAD { ) } - fn bubble_point_pressure( + fn bubble_point_pressure>( &self, temperature: Temperature, pressure: Option, - liquid_molefracs: SVector, + liquid_molefracs: X, ) -> FeosResult>> where Self: Residual>, { let eos_f64 = self.re(); + let (liquid_molefracs, _) = liquid_molefracs.into_molefracs(&eos_f64)?; let vle = PhaseEquilibrium::bubble_point( &eos_f64, temperature, - &liquid_molefracs, + liquid_molefracs, pressure, None, Default::default(), @@ -265,20 +266,21 @@ pub trait PropertiesAD { Ok(Pressure::from_reduced(p)) } - fn dew_point_pressure( + fn dew_point_pressure>( &self, temperature: Temperature, pressure: Option, - vapor_molefracs: SVector, + vapor_molefracs: X, ) -> FeosResult>> where Self: Residual>, { let eos_f64 = self.re(); + let (vapor_molefracs, _) = vapor_molefracs.into_molefracs(&eos_f64)?; let vle = PhaseEquilibrium::dew_point( &eos_f64, temperature, - &vapor_molefracs, + vapor_molefracs, pressure, None, Default::default(), @@ -329,12 +331,8 @@ pub trait PropertiesAD { parameters, input, |eos: &Self::Lifted>, inp| { - eos.bubble_point_pressure( - inp[0] * KELVIN, - Some(inp[2] * PASCAL), - SVector::from([inp[1], 1.0 - inp[1]]), - ) - .map(|p| p.convert_into(PASCAL)) + eos.bubble_point_pressure(inp[0] * KELVIN, Some(inp[2] * PASCAL), inp[1]) + .map(|p| p.convert_into(PASCAL)) }, ) } @@ -353,12 +351,8 @@ pub trait PropertiesAD { parameters, input, |eos: &Self::Lifted>, inp| { - eos.dew_point_pressure( - inp[0] * KELVIN, - Some(inp[2] * PASCAL), - SVector::from([inp[1], 1.0 - inp[1]]), - ) - .map(|p| p.convert_into(PASCAL)) + eos.dew_point_pressure(inp[0] * KELVIN, Some(inp[2] * PASCAL), inp[1]) + .map(|p| p.convert_into(PASCAL)) }, ) } diff --git a/crates/feos-core/src/errors.rs b/crates/feos-core/src/errors.rs index f02946bc0..eb7ea4147 100644 --- a/crates/feos-core/src/errors.rs +++ b/crates/feos-core/src/errors.rs @@ -24,6 +24,10 @@ pub enum FeosError { InvalidState(String, String, f64), #[error("Undetermined state: {0}")] UndeterminedState(String), + #[error( + "Extensive properties can only be evaluated for states that are initialized with extensive properties." + )] + IntensiveState, #[error("System is supercritical.")] SuperCritical, #[error("No phase split according to stability analysis.")] diff --git a/crates/feos-core/src/lib.rs b/crates/feos-core/src/lib.rs index a2552007d..48b8306f3 100644 --- a/crates/feos-core/src/lib.rs +++ b/crates/feos-core/src/lib.rs @@ -299,8 +299,8 @@ mod tests { // residual properties assert_relative_eq!( - s.helmholtz_energy(Contributions::Residual), - sr.residual_helmholtz_energy(), + s.helmholtz_energy(Contributions::Residual)?, + sr.residual_helmholtz_energy()?, max_relative = 1e-15 ); assert_relative_eq!( @@ -309,8 +309,8 @@ mod tests { max_relative = 1e-15 ); assert_relative_eq!( - s.entropy(Contributions::Residual), - sr.residual_entropy(), + s.entropy(Contributions::Residual)?, + sr.residual_entropy()?, max_relative = 1e-15 ); assert_relative_eq!( @@ -319,8 +319,8 @@ mod tests { max_relative = 1e-15 ); assert_relative_eq!( - s.enthalpy(Contributions::Residual), - sr.residual_enthalpy(), + s.enthalpy(Contributions::Residual)?, + sr.residual_enthalpy()?, max_relative = 1e-15 ); assert_relative_eq!( @@ -329,8 +329,8 @@ mod tests { max_relative = 1e-15 ); assert_relative_eq!( - s.internal_energy(Contributions::Residual), - sr.residual_internal_energy(), + s.internal_energy(Contributions::Residual)?, + sr.residual_internal_energy()?, max_relative = 1e-15 ); assert_relative_eq!( @@ -339,12 +339,12 @@ mod tests { max_relative = 1e-15 ); assert_relative_eq!( - s.gibbs_energy(Contributions::Residual) - - s.total_moles() + s.gibbs_energy(Contributions::Residual)? + - s.total_moles()? * RGAS * s.temperature * s.compressibility(Contributions::Total).ln(), - sr.residual_gibbs_energy(), + sr.residual_gibbs_energy()?, max_relative = 1e-15 ); assert_relative_eq!( diff --git a/crates/feos-core/src/phase_equilibria/bubble_dew.rs b/crates/feos-core/src/phase_equilibria/bubble_dew.rs index 91ad57d3d..05dd42849 100644 --- a/crates/feos-core/src/phase_equilibria/bubble_dew.rs +++ b/crates/feos-core/src/phase_equilibria/bubble_dew.rs @@ -4,7 +4,7 @@ use crate::state::{ Contributions, DensityInitialization::{InitialDensity, Liquid, Vapor}, }; -use crate::{ReferenceSystem, Residual, SolverOptions, State, Verbosity}; +use crate::{Composition, ReferenceSystem, Residual, SolverOptions, State, Verbosity}; use nalgebra::allocator::Allocator; use nalgebra::{DMatrix, DVector, DefaultAllocator, Dim, Dyn, OVector, U1}; #[cfg(feature = "ndarray")] @@ -141,10 +141,10 @@ where { /// Calculate a phase equilibrium for a given temperature /// or pressure and composition of the liquid phase. - pub fn bubble_point>( + pub fn bubble_point, X: Composition>( eos: &E, temperature_or_pressure: TP, - liquid_molefracs: &OVector, + liquid_molefracs: X, tp_init: Option, vapor_molefracs: Option<&OVector>, options: (SolverOptions, SolverOptions), @@ -162,10 +162,10 @@ where /// Calculate a phase equilibrium for a given temperature /// or pressure and composition of the vapor phase. - pub fn dew_point>( + pub fn dew_point, X: Composition>( eos: &E, temperature_or_pressure: TP, - vapor_molefracs: &OVector, + vapor_molefracs: X, tp_init: Option, liquid_molefracs: Option<&OVector>, options: (SolverOptions, SolverOptions), @@ -181,35 +181,43 @@ where ) } - pub(super) fn bubble_dew_point>( + pub(super) fn bubble_dew_point, X: Composition>( eos: &E, temperature_or_pressure: TP, - vapor_molefracs: &OVector, + vapor_molefracs: X, tp_init: Option, liquid_molefracs: Option<&OVector>, bubble: bool, options: (SolverOptions, SolverOptions), ) -> FeosResult { - let (temperature, pressure, iterate_p) = - temperature_or_pressure.temperature_pressure(tp_init); - Self::bubble_dew_point_tp( - eos, - temperature, - pressure, - vapor_molefracs, - liquid_molefracs, - bubble, - iterate_p, - options, - ) + if eos.components() == 1 { + let mut vle = Self::pure(eos, temperature_or_pressure, None, options.1)?; + if bubble { + vle.phase_fractions = [D::from(0.0), D::from(1.0)]; + } + Ok(vle) + } else { + let (temperature, pressure, iterate_p) = + temperature_or_pressure.temperature_pressure(tp_init); + Self::bubble_dew_point_tp( + eos, + temperature, + pressure, + vapor_molefracs, + liquid_molefracs, + bubble, + iterate_p, + options, + ) + } } #[expect(clippy::too_many_arguments)] - fn bubble_dew_point_tp( + fn bubble_dew_point_tp>( eos: &E, temperature: Option>, pressure: Option>, - molefracs_spec: &OVector, + composition: X, molefracs_init: Option<&OVector>, bubble: bool, iterate_p: bool, @@ -218,6 +226,7 @@ where let eos_re = eos.re(); let mut temperature_re = temperature.map(|t| t.re()); let mut pressure_re = pressure.map(|p| p.re()); + let (molefracs_spec, total_moles) = composition.into_molefracs(eos)?; let molefracs_spec_re = molefracs_spec.map(|x| x.re()); let (v1, rho2) = if iterate_p { // temperature is specified @@ -315,7 +324,7 @@ where Self::newton_step_t( eos, t, - molefracs_spec, + &molefracs_spec, &mut p, &mut molar_volume, &mut rho2, @@ -325,7 +334,7 @@ where Self::newton_step_p( eos, &mut t, - molefracs_spec, + &molefracs_spec, p, &mut molar_volume, &mut rho2, @@ -348,11 +357,11 @@ where x2, )?; - Ok(PhaseEquilibrium(if bubble { - [state2, state1] + Ok(if bubble { + PhaseEquilibrium::with_vapor_phase_fraction(state2, state1, D::from(0.0), total_moles) } else { - [state1, state2] - })) + PhaseEquilibrium::with_vapor_phase_fraction(state1, state2, D::from(1.0), total_moles) + }) } fn newton_step_t( diff --git a/crates/feos-core/src/phase_equilibria/mod.rs b/crates/feos-core/src/phase_equilibria/mod.rs index 95671deb2..461a1a901 100644 --- a/crates/feos-core/src/phase_equilibria/mod.rs +++ b/crates/feos-core/src/phase_equilibria/mod.rs @@ -1,11 +1,12 @@ +use crate::FeosError; use crate::equation_of_state::Residual; use crate::errors::FeosResult; -use crate::state::{DensityInitialization, State}; -use crate::{Contributions, ReferenceSystem}; +use crate::state::State; +use crate::{Contributions::Total as Tot, ReferenceSystem, Total}; use nalgebra::allocator::Allocator; -use nalgebra::{DefaultAllocator, Dim, Dyn, OVector}; -use num_dual::{DualNum, DualStruct, Gradients}; -use quantity::{Energy, Moles, Pressure, RGAS, Temperature}; +use nalgebra::{DefaultAllocator, Dim, Dyn}; +use num_dual::{DualNum, Gradients}; +use quantity::{Dimensionless, Energy, Entropy, MolarEnergy, MolarEntropy, Moles}; use std::fmt; use std::fmt::Write; @@ -38,15 +39,18 @@ pub use phase_diagram_pure::PhaseDiagram; /// + [Pure component phase equilibria](#pure-component-phase-equilibria) /// + [Utility functions](#utility-functions) #[derive(Debug, Clone)] -pub struct PhaseEquilibrium + Copy = f64>( - pub [State; P], -) +pub struct PhaseEquilibrium + Copy = f64> where - DefaultAllocator: Allocator; + DefaultAllocator: Allocator, +{ + states: [State; P], + pub phase_fractions: [D; P], + total_moles: Option>, +} impl fmt::Display for PhaseEquilibrium { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for (i, s) in self.0.iter().enumerate() { + for (i, s) in self.states.iter().enumerate() { writeln!(f, "phase {i}: {s}")?; } Ok(()) @@ -55,9 +59,9 @@ impl fmt::Display for PhaseEquilibrium { impl PhaseEquilibrium { pub fn _repr_markdown_(&self) -> String { - if self.0[0].eos.components() == 1 { + if self.states[0].eos.components() == 1 { let mut res = "||temperature|density|\n|-|-|-|\n".to_string(); - for (i, s) in self.0.iter().enumerate() { + for (i, s) in self.states.iter().enumerate() { writeln!( res, "|phase {}|{:.5}|{:.5}|", @@ -70,7 +74,7 @@ impl PhaseEquilibrium { res } else { let mut res = "||temperature|density|molefracs|\n|-|-|-|-|\n".to_string(); - for (i, s) in self.0.iter().enumerate() { + for (i, s) in self.states.iter().enumerate() { writeln!( res, "|phase {}|{:.5}|{:.5}|{:.5?}|", @@ -91,125 +95,115 @@ where DefaultAllocator: Allocator, { pub fn vapor(&self) -> &State { - &self.0[0] + &self.states[0] } pub fn liquid(&self) -> &State { - &self.0[1] + &self.states[1] + } + + pub fn vapor_phase_fraction(&self) -> D { + self.phase_fractions[0] } } impl PhaseEquilibrium { pub fn vapor(&self) -> &State { - &self.0[0] + &self.states[0] } pub fn liquid1(&self) -> &State { - &self.0[1] + &self.states[1] } pub fn liquid2(&self) -> &State { - &self.0[2] + &self.states[2] } } -impl, N: Dim> PhaseEquilibrium +impl, N: Dim, D: DualNum + Copy> PhaseEquilibrium where DefaultAllocator: Allocator, { - pub(super) fn from_states(state1: State, state2: State) -> Self { - let (vapor, liquid) = if state1.density.re() < state2.density.re() { - (state1, state2) - } else { - (state2, state1) - }; - Self([vapor, liquid]) - } - - // /// Creates a new PhaseEquilibrium that contains two states at the - // /// specified temperature, pressure and molefracs. - // /// - // /// The constructor can be used in custom phase equilibrium solvers or, - // /// e.g., to generate initial guesses for an actual VLE solver. - // /// In general, the two states generated are NOT in an equilibrium. - // pub fn new_xpt( - // eos: &E, - // temperature: Temperature, - // pressure: Pressure, - // vapor_molefracs: &OVector, - // liquid_molefracs: &OVector, - // ) -> FeosResult { - // let liquid = State::new_xpt( - // eos, - // temperature, - // pressure, - // liquid_molefracs, - // Some(DensityInitialization::Liquid), - // )?; - // let vapor = State::new_xpt( - // eos, - // temperature, - // pressure, - // vapor_molefracs, - // Some(DensityInitialization::Vapor), - // )?; - // Ok(Self([vapor, liquid])) - // } - - pub(super) fn vapor_phase_fraction(&self) -> Option { - self.vapor() - .total_moles - .zip(self.liquid().total_moles) - .map(|(v, l)| (v / (l + v)).into_value()) + pub(super) fn single_phase(state: State) -> Self { + let total_moles = state.total_moles; + Self::with_vapor_phase_fraction(state.clone(), state, D::from(1.0), total_moles) + } + + pub(super) fn two_phase(vapor: State, liquid: State) -> Self { + let (beta, total_moles) = + if let (Some(nv), Some(nl)) = (vapor.total_moles, liquid.total_moles) { + (nv.convert_into(nl + nv), Some(nl + nv)) + } else { + (D::from(1.0), None) + }; + Self::with_vapor_phase_fraction(vapor, liquid, beta, total_moles) + } + + pub(super) fn with_vapor_phase_fraction( + vapor: State, + liquid: State, + vapor_phase_fraction: D, + total_moles: Option>, + ) -> Self { + Self { + states: [vapor, liquid], + phase_fractions: [vapor_phase_fraction, -vapor_phase_fraction + 1.0], + total_moles, + } } } -impl, N: Gradients, const P: usize> PhaseEquilibrium +impl, N: Dim, D: DualNum + Copy> PhaseEquilibrium where DefaultAllocator: Allocator, { - pub(super) fn update_pressure( - mut self, - temperature: Temperature, - pressure: Pressure, - ) -> FeosResult { - for s in self.0.iter_mut() { - *s = State::new_npt( - &s.eos, - temperature, - pressure, - &*s, - Some(DensityInitialization::InitialDensity(s.density)), - )?; - } - Ok(self) - } - - pub(super) fn update_moles( - &mut self, - pressure: Pressure, - moles: [&Moles>; P], - ) -> FeosResult<()> { - for (i, s) in self.0.iter_mut().enumerate() { - *s = State::new_npt( - &s.eos, - s.temperature, - pressure, - moles[i], - Some(DensityInitialization::InitialDensity(s.density)), - )?; + pub(super) fn new( + vapor: State, + liquid1: State, + liquid2: State, + ) -> Self { + Self { + states: [vapor, liquid1, liquid2], + phase_fractions: [D::from(1.0), D::from(0.0), D::from(0.0)], + total_moles: None, } - Ok(()) + } +} + +impl, N: Gradients, const P: usize, D: DualNum + Copy> + PhaseEquilibrium +where + DefaultAllocator: Allocator, +{ + pub fn total_moles(&self) -> FeosResult> { + self.total_moles.ok_or(FeosError::IntensiveState) + } + + pub fn molar_enthalpy(&self) -> MolarEnergy { + self.states + .iter() + .zip(&self.phase_fractions) + .map(|(s, x)| s.molar_enthalpy(Tot) * Dimensionless::new(x)) + .reduce(|a, b| a + b) + .unwrap() + } + + pub fn enthalpy(&self) -> FeosResult> { + Ok(self.total_moles()? * self.molar_enthalpy()) + } + + pub fn molar_entropy(&self) -> MolarEntropy { + self.states + .iter() + .zip(&self.phase_fractions) + .map(|(s, x)| s.molar_entropy(Tot) * Dimensionless::new(x)) + .reduce(|a, b| a + b) + .unwrap() } - // Total Gibbs energy excluding the constant contribution RT sum_i N_i ln(\Lambda_i^3) - pub(super) fn total_gibbs_energy(&self) -> Energy { - self.0.iter().fold(Energy::from_reduced(0.0), |acc, s| { - let ln_rho_m1 = s.partial_density().to_reduced().map(|r| r.ln() - 1.0); - acc + s.residual_helmholtz_energy() - + s.pressure(Contributions::Total) * s.volume() - + RGAS * s.temperature * s.total_moles() * s.molefracs.dot(&ln_rho_m1) - }) + pub fn entropy(&self) -> FeosResult> { + Ok(self.total_moles()? * self.molar_entropy()) } } diff --git a/crates/feos-core/src/phase_equilibria/phase_diagram_binary.rs b/crates/feos-core/src/phase_equilibria/phase_diagram_binary.rs index 92e6932a5..81742b808 100644 --- a/crates/feos-core/src/phase_equilibria/phase_diagram_binary.rs +++ b/crates/feos-core/src/phase_equilibria/phase_diagram_binary.rs @@ -63,8 +63,9 @@ impl PhaseDiagram { None, SolverOptions::default(), )?; - let cp_vle = PhaseEquilibrium::from_states(cp.clone(), cp.clone()); - ([0.0, cp.molefracs[0]], (vle2, cp_vle), bubble) + let x_max = cp.molefracs[0]; + let cp_vle = PhaseEquilibrium::single_phase(cp); + ([0.0, x_max], (vle2, cp_vle), bubble) } [None, Some(vle1)] => { let cp = State::critical_point_binary( @@ -75,8 +76,9 @@ impl PhaseDiagram { None, SolverOptions::default(), )?; - let cp_vle = PhaseEquilibrium::from_states(cp.clone(), cp.clone()); - ([1.0, cp.molefracs[0]], (vle1, cp_vle), bubble) + let x_min = cp.molefracs[0]; + let cp_vle = PhaseEquilibrium::single_phase(cp); + ([1.0, x_min], (vle1, cp_vle), bubble) } [Some(vle2), Some(vle1)] => ([0.0, 1.0], (vle2, vle1), true), }; @@ -201,7 +203,7 @@ fn iterate_vle( let vle = PhaseEquilibrium::bubble_dew_point( eos, tp, - &dvector![*xi, 1.0 - xi], + dvector![*xi, 1.0 - xi], tp_old, y_old.as_ref(), bubble, @@ -604,7 +606,7 @@ impl PhaseEquilibrium { // check for convergence if res.norm() < options.tol.unwrap_or(TOL_HETERO) { - return Ok(Self([v, l1, l2])); + return Ok(Self::new(v, l1, l2)); } // calculate Jacobian @@ -724,7 +726,7 @@ impl PhaseEquilibrium { // check for convergence if res.norm() < options.tol.unwrap_or(TOL_HETERO) { - return Ok(Self([v, l1, l2])); + return Ok(Self::new(v, l1, l2)); } let jacobian = stack![ diff --git a/crates/feos-core/src/phase_equilibria/phase_diagram_pure.rs b/crates/feos-core/src/phase_equilibria/phase_diagram_pure.rs index 2382a974c..ab2c37cd0 100644 --- a/crates/feos-core/src/phase_equilibria/phase_diagram_pure.rs +++ b/crates/feos-core/src/phase_equilibria/phase_diagram_pure.rs @@ -54,7 +54,7 @@ impl PhaseDiagram { states.push(vle.clone()); } } - states.push(PhaseEquilibrium::from_states(sc.clone(), sc)); + states.push(PhaseEquilibrium::single_phase(sc)); Ok(PhaseDiagram::new(states)) } @@ -127,7 +127,7 @@ impl PhaseDiagram { .collect() }); - states.push(PhaseEquilibrium::from_states(sc.clone(), sc)); + states.push(PhaseEquilibrium::single_phase(sc)); Ok(PhaseDiagram::new(states)) } } diff --git a/crates/feos-core/src/phase_equilibria/phase_envelope.rs b/crates/feos-core/src/phase_equilibria/phase_envelope.rs index 0f01e7288..7f118a3cd 100644 --- a/crates/feos-core/src/phase_equilibria/phase_envelope.rs +++ b/crates/feos-core/src/phase_equilibria/phase_envelope.rs @@ -1,16 +1,16 @@ use super::{PhaseDiagram, PhaseEquilibrium}; -use crate::SolverOptions; use crate::equation_of_state::Residual; use crate::errors::FeosResult; use crate::state::{Contributions, State}; -use nalgebra::DVector; +use crate::{Composition, SolverOptions}; +use nalgebra::Dyn; use quantity::{Pressure, Temperature}; impl PhaseDiagram { /// Calculate the bubble point line of a mixture with given composition. - pub fn bubble_point_line( + pub fn bubble_point_line + Clone>( eos: &E, - molefracs: &DVector, + composition: X, min_temperature: Temperature, npoints: usize, critical_temperature: Option, @@ -20,7 +20,7 @@ impl PhaseDiagram { let sc = State::critical_point( eos, - molefracs, + composition.clone(), critical_temperature, None, SolverOptions::default(), @@ -40,7 +40,7 @@ impl PhaseDiagram { vle = PhaseEquilibrium::bubble_point( eos, ti, - molefracs, + composition.clone(), p_init, vapor_molefracs, options, @@ -51,15 +51,15 @@ impl PhaseDiagram { states.push(vle.clone()); } } - states.push(PhaseEquilibrium::from_states(sc.clone(), sc)); + states.push(PhaseEquilibrium::single_phase(sc)); Ok(PhaseDiagram::new(states)) } /// Calculate the dew point line of a mixture with given composition. - pub fn dew_point_line( + pub fn dew_point_line + Clone>( eos: &E, - molefracs: &DVector, + composition: X, min_temperature: Temperature, npoints: usize, critical_temperature: Option, @@ -69,7 +69,7 @@ impl PhaseDiagram { let sc = State::critical_point( eos, - molefracs, + composition.clone(), critical_temperature, None, SolverOptions::default(), @@ -86,9 +86,15 @@ impl PhaseDiagram { .as_ref() .map(|vle| vle.vapor().pressure(Contributions::Total)); let liquid_molefracs = vle.as_ref().map(|vle| &vle.liquid().molefracs); - vle = - PhaseEquilibrium::dew_point(eos, ti, molefracs, p_init, liquid_molefracs, options) - .ok(); + vle = PhaseEquilibrium::dew_point( + eos, + ti, + composition.clone(), + p_init, + liquid_molefracs, + options, + ) + .ok(); if let Some(vle) = vle.as_ref() { states.push(vle.clone()); } @@ -108,23 +114,29 @@ impl PhaseDiagram { for pi in &pressures { let t_init = vle.as_ref().map(|vle| vle.vapor().temperature); let liquid_molefracs = vle.as_ref().map(|vle| &vle.liquid().molefracs); - vle = - PhaseEquilibrium::dew_point(eos, pi, molefracs, t_init, liquid_molefracs, options) - .ok(); + vle = PhaseEquilibrium::dew_point( + eos, + pi, + composition.clone(), + t_init, + liquid_molefracs, + options, + ) + .ok(); if let Some(vle) = vle.as_ref() { states.push(vle.clone()); } } - states.push(PhaseEquilibrium::from_states(sc.clone(), sc)); + states.push(PhaseEquilibrium::single_phase(sc)); Ok(PhaseDiagram::new(states)) } /// Calculate the spinodal lines for a mixture with fixed composition. - pub fn spinodal( + pub fn spinodal + Clone>( eos: &E, - molefracs: &DVector, + composition: X, min_temperature: Temperature, npoints: usize, critical_temperature: Option, @@ -134,7 +146,7 @@ impl PhaseDiagram { let sc = State::critical_point( eos, - molefracs, + composition.clone(), critical_temperature, None, SolverOptions::default(), @@ -145,12 +157,12 @@ impl PhaseDiagram { let temperatures = Temperature::linspace(min_temperature, max_temperature, npoints - 1); for ti in &temperatures { - let spinodal = State::spinodal(eos, ti, molefracs, options).ok(); - if let Some(spinodal) = spinodal { - states.push(PhaseEquilibrium(spinodal)); + let spinodal = State::spinodal(eos, ti, composition.clone(), options).ok(); + if let Some([sp_v, sp_l]) = spinodal { + states.push(PhaseEquilibrium::two_phase(sp_v, sp_l)); } } - states.push(PhaseEquilibrium::from_states(sc.clone(), sc)); + states.push(PhaseEquilibrium::single_phase(sc)); Ok(PhaseDiagram::new(states)) } diff --git a/crates/feos-core/src/phase_equilibria/stability_analysis.rs b/crates/feos-core/src/phase_equilibria/stability_analysis.rs index 950f05026..ec2329da6 100644 --- a/crates/feos-core/src/phase_equilibria/stability_analysis.rs +++ b/crates/feos-core/src/phase_equilibria/stability_analysis.rs @@ -166,9 +166,11 @@ where let (n, _) = di.shape_generic(); // calculate residual and ideal hesse matrix - let mut hesse = self.n_dln_phi_dnj() / self.total_moles().into_reduced(); + // TODO: this should not require extensive properties, but I couldn't rewrite it + // quickly without breaking it. + let mut hesse = self.n_dln_phi_dnj() / self.total_moles().unwrap().into_reduced(); let lnphi = self.ln_phi(); - let y = self.moles().into_reduced(); + let y = self.moles().unwrap().into_reduced(); let ln_y = y.map(|y| if y > f64::EPSILON { y.ln() } else { 0.0 }); let sq_y = y.map(f64::sqrt); let gradient = (&ln_y + &lnphi - di).component_mul(&sq_y); diff --git a/crates/feos-core/src/phase_equilibria/tp_flash.rs b/crates/feos-core/src/phase_equilibria/tp_flash.rs index e955acaf6..88b30679e 100644 --- a/crates/feos-core/src/phase_equilibria/tp_flash.rs +++ b/crates/feos-core/src/phase_equilibria/tp_flash.rs @@ -2,13 +2,13 @@ use super::PhaseEquilibrium; use crate::equation_of_state::Residual; use crate::errors::{FeosError, FeosResult}; use crate::state::{Contributions, State}; -use crate::{ReferenceSystem, SolverOptions, Verbosity}; +use crate::{Composition, DensityInitialization, ReferenceSystem, SolverOptions, Verbosity}; use nalgebra::allocator::Allocator; use nalgebra::{DefaultAllocator, Dim, Matrix3, OVector, SVector, U1, U2, vector}; use num_dual::{ Dual, Dual2Vec, DualNum, DualStruct, Gradients, first_derivative, implicit_derivative_sp, }; -use quantity::{Dimensionless, MOL, MolarVolume, Moles, Pressure, Quantity, Temperature}; +use quantity::{MolarEnergy, MolarVolume, Pressure, RGAS, Temperature}; const MAX_ITER_TP: usize = 400; const TOL_TP: f64 = 1e-8; @@ -23,11 +23,11 @@ where /// /// The algorithm can be use to calculate phase equilibria of systems /// containing non-volatile components (e.g. ions). - pub fn tp_flash( + pub fn tp_flash>( eos: &E, temperature: Temperature, pressure: Pressure, - feed: &Moles>, + feed: X, initial_state: Option<&PhaseEquilibrium>, options: SolverOptions, non_volatile_components: Option>, @@ -45,17 +45,16 @@ impl, D: DualNum + Copy> PhaseEquilibrium { /// Compared to the version of the algorithm for a generic /// number of components ([tp_flash](PhaseEquilibrium::tp_flash)), /// this can be used in combination with automatic differentiation. - pub fn tp_flash_binary( + pub fn tp_flash_binary>( eos: &E, temperature: Temperature, pressure: Pressure, - feed: &Moles>, + feed: X, options: SolverOptions, ) -> FeosResult { - let z = feed.get(0).convert_into(feed.get(0) + feed.get(1)); - let total_moles = feed.sum(); - let moles = vector![z.re(), 1.0 - z.re()] * MOL; - let vle_re = State::new_npt(&eos.re(), temperature.re(), pressure.re(), moles, None)? + let (feed, total_moles) = feed.into_molefracs(eos)?; + let z = feed[0]; + let vle_re = State::new_npt(&eos.re(), temperature.re(), pressure.re(), z.re(), None)? .tp_flash(None, options, None)?; // implicit differentiation @@ -96,12 +95,16 @@ impl, D: DualNum + Copy> PhaseEquilibrium { .data .0; let beta = (z - x) / (y - x); - let state = |x: D, v, phi| { - let volume = MolarVolume::from_reduced(v * phi) * total_moles; - let moles = Quantity::new(vector![x, -x + 1.0] * phi * total_moles.convert_into(MOL)); - State::new_nvt(eos, temperature, volume, moles) + let state = |x: D, v| { + let density = MolarVolume::from_reduced(v).inv(); + State::new(eos, temperature, density, x) }; - Ok(Self([state(y, v_v, beta)?, state(x, v_l, -beta + 1.0)?])) + Ok(Self::with_vapor_phase_fraction( + state(y, v_v)?, + state(x, v_l)?, + beta, + total_moles, + )) } } @@ -123,13 +126,15 @@ where non_volatile_components: Option>, ) -> FeosResult> { // initialization - if let Some(init) = initial_state { - let vle = self.tp_flash_( - init.clone() - .update_pressure(self.temperature, self.pressure(Contributions::Total))?, - options, - non_volatile_components.clone(), - ); + if let Some(initial_state) = initial_state { + let mut init = initial_state.clone(); + init.update_states( + self, + initial_state.vapor().molefracs.clone(), + initial_state.liquid().molefracs.clone(), + initial_state.vapor_phase_fraction(), + )?; + let vle = self.tp_flash_(init, options, non_volatile_components.clone()); if vle.is_ok() { return vle; } @@ -184,14 +189,12 @@ where )?; // check convergence - // unwrap is safe here, because after the first successive substitution step the - // phase amounts in new_vle_state are known. - let beta = new_vle_state.vapor_phase_fraction().unwrap(); let tpd = [ self.tangent_plane_distance(new_vle_state.vapor()), self.tangent_plane_distance(new_vle_state.liquid()), ]; - let dg = (1.0 - beta) * tpd[1] + beta * tpd[0]; + let b = new_vle_state.phase_fractions; + let dg = b[0] * tpd[0] + b[1] * tpd[1]; // fix if only tpd[1] is positive if tpd[0] < 0.0 && dg >= 0.0 { @@ -200,7 +203,7 @@ where if let Some(nvc) = non_volatile_components.as_ref() { nvc.iter().for_each(|&c| k[c] = 0.0); } - new_vle_state.update_states(self, &k)?; + new_vle_state.rachford_rice_inplace(self, &k)?; new_vle_state.successive_substitution( self, 1, @@ -219,7 +222,7 @@ where if let Some(nvc) = non_volatile_components.as_ref() { nvc.iter().for_each(|&c| k[c] = 0.0); } - new_vle_state.update_states(self, &k)?; + new_vle_state.rachford_rice_inplace(self, &k)?; new_vle_state.successive_substitution( self, 1, @@ -289,7 +292,7 @@ where } // calculate total Gibbs energy before the extrapolation - let gibbs = self.total_gibbs_energy(); + let gibbs = self.molar_gibbs_energy(); // extrapolate K values let delta_vec = [ @@ -316,8 +319,8 @@ where // calculate new states let mut trial_vle_state = self.clone(); - trial_vle_state.update_states(feed_state, &k)?; - if trial_vle_state.total_gibbs_energy() < gibbs { + trial_vle_state.rachford_rice_inplace(feed_state, &k)?; + if trial_vle_state.molar_gibbs_energy() < gibbs { *self = trial_vle_state; } } @@ -367,7 +370,7 @@ where return Ok(true); } - self.update_states(feed_state, &k)?; + self.rachford_rice_inplace(feed_state, &k)?; if let Some(k_vec) = k_vec && i >= iterations - 3 { @@ -377,25 +380,41 @@ where Ok(false) } - fn update_states(&mut self, feed_state: &State, k: &OVector) -> FeosResult<()> { + fn rachford_rice_inplace( + &mut self, + feed_state: &State, + k: &OVector, + ) -> FeosResult<()> { // calculate vapor phase fraction using Rachford-Rice algorithm - let beta = self.vapor_phase_fraction(); - let beta = rachford_rice(&feed_state.molefracs, k, beta)?; - - // update VLE - let v = feed_state - .moles() - .clone() - .component_mul(&Dimensionless::new( - k.map(|k| beta * k / (1.0 - beta + beta * k)), - )); - let l = feed_state - .moles() - .clone() - .component_mul(&Dimensionless::new( - k.map(|k| (1.0 - beta) / (1.0 - beta + beta * k)), - )); - self.update_moles(feed_state.pressure(Contributions::Total), [&v, &l])?; + let (b, [v, l]) = + rachford_rice(&feed_state.molefracs, k, Some(self.vapor_phase_fraction()))?; + self.update_states(feed_state, v, l, b) + } + + fn update_states( + &mut self, + feed_state: &State, + vapor_molefracs: OVector, + liquid_molefracs: OVector, + beta: f64, + ) -> FeosResult<()> { + let vapor = State::new_npt( + &feed_state.eos, + feed_state.temperature, + feed_state.pressure(Contributions::Total), + vapor_molefracs, + Some(DensityInitialization::InitialDensity(self.vapor().density)), + )?; + let liquid = State::new_npt( + &feed_state.eos, + feed_state.temperature, + feed_state.pressure(Contributions::Total), + liquid_molefracs, + Some(DensityInitialization::InitialDensity(self.liquid().density)), + )?; + + *self = Self::with_vapor_phase_fraction(vapor, liquid, beta, feed_state.total_moles); + Ok(()) } @@ -404,23 +423,42 @@ where let state1 = stable_states.pop(); let state2 = stable_states.pop(); if let Some(s1) = state1 { - let init1 = Self::from_states(s1.clone(), feed_state.clone()); - if let Some(s2) = state2 { - Ok((Self::from_states(s1, s2), Some(init1))) + let init1 = if s1.density < feed_state.density { + Self::two_phase(s1.clone(), feed_state.clone()) } else { - Ok((init1, None)) - } + Self::two_phase(feed_state.clone(), s1.clone()) + }; + let init2 = state2.map(|s2| { + if s1.density < s2.density { + Self::two_phase(s1.clone(), s2.clone()) + } else { + Self::two_phase(s2.clone(), s1.clone()) + } + }); + Ok((init1, init2)) } else { Err(FeosError::NoPhaseSplit) } } + + // Total molar Gibbs energy excluding the constant contribution RT sum_i x_i ln(\Lambda_i^3) + fn molar_gibbs_energy(&self) -> MolarEnergy { + self.states + .iter() + .fold(MolarEnergy::from_reduced(0.0), |acc, s| { + let ln_rho_m1 = s.partial_density().to_reduced().map(|r| r.ln() - 1.0); + acc + s.residual_molar_helmholtz_energy() + + s.pressure(Contributions::Total) * s.molar_volume + + RGAS * s.temperature * s.molefracs.dot(&ln_rho_m1) + }) + } } fn rachford_rice( feed: &OVector, k: &OVector, beta_in: Option, -) -> FeosResult +) -> FeosResult<(f64, [OVector; 2])> where DefaultAllocator: Allocator, { @@ -494,9 +532,16 @@ where beta = 0.5 * (beta_min + beta_max); } if dbeta.abs() < ABS_TOL { - return Ok(beta); + // update VLE + let v = feed.component_mul(&k.map(|k| beta * k / (1.0 - beta + beta * k))); + let l = feed.component_mul(&k.map(|k| (1.0 - beta) / (1.0 - beta + beta * k))); + return Ok((beta, [v, l])); } } - Ok(beta) + // update VLE + let v = feed.component_mul(&k.map(|k| beta * k / (1.0 - beta + beta * k))); + let l = feed.component_mul(&k.map(|k| (1.0 - beta) / (1.0 - beta + beta * k))); + + Ok((beta, [v, l])) } diff --git a/crates/feos-core/src/phase_equilibria/vle_pure.rs b/crates/feos-core/src/phase_equilibria/vle_pure.rs index 63756fd5b..f58fc6e21 100644 --- a/crates/feos-core/src/phase_equilibria/vle_pure.rs +++ b/crates/feos-core/src/phase_equilibria/vle_pure.rs @@ -3,7 +3,7 @@ use crate::density_iteration::{_density_iteration, _pressure_spinodal}; use crate::equation_of_state::{Residual, Subset}; use crate::errors::{FeosError, FeosResult}; use crate::state::{Contributions, DensityInitialization, State}; -use crate::{Composition, ReferenceSystem, SolverOptions, TemperatureOrPressure, Verbosity}; +use crate::{ReferenceSystem, SolverOptions, TemperatureOrPressure, Verbosity}; use nalgebra::allocator::Allocator; use nalgebra::{DVector, DefaultAllocator, Dim, SVector, U1, U2}; use num_dual::{DualNum, DualStruct, Gradients, gradient, partial}; @@ -17,7 +17,6 @@ const TOL_PURE: f64 = 1e-12; impl, N: Gradients, D: DualNum + Copy> PhaseEquilibrium where DefaultAllocator: Allocator + Allocator + Allocator, - (): Composition + Composition, { /// Calculate a phase equilibrium for a pure component. pub fn pure>( @@ -26,7 +25,7 @@ where initial_state: Option<&Self>, options: SolverOptions, ) -> FeosResult { - let (t, rho) = if let Some(t) = temperature_or_pressure.temperature() { + let (t, [rho_v, rho_l]) = if let Some(t) = temperature_or_pressure.temperature() { let (_, rho) = Self::pure_t(eos, t, initial_state, options)?; (t, rho) } else if let Some(p) = temperature_or_pressure.pressure() { @@ -34,7 +33,11 @@ where } else { unreachable!() }; - Ok(Self(rho.map(|r| State::new_pure(eos, t, r).unwrap()))) + let x = E::pure_molefracs(); + Ok(Self::two_phase( + State::new(eos, t, rho_v, &x)?, + State::new(eos, t, rho_l, x)?, + )) } /// Calculate a phase equilibrium for a pure component @@ -238,7 +241,6 @@ where impl, N: Gradients, D: DualNum + Copy> PhaseEquilibrium where DefaultAllocator: Allocator + Allocator + Allocator, - (): Composition, { /// Calculate a phase equilibrium for a pure component /// and given pressure. @@ -396,7 +398,6 @@ fn init_pure_p, N: Gradients>( ) -> FeosResult<(f64, [f64; 2])> where DefaultAllocator: Allocator + Allocator + Allocator, - (): Composition, { let trial_temperatures = [300.0, 500.0, 200.0]; let p = pressure.into_reduced(); @@ -416,7 +417,7 @@ where }; let [mut t_v, mut t_l] = [t0, t0]; - let cp = State::critical_point(eos, (), None, None, SolverOptions::default())?; + let cp = State::critical_point(eos, &x, None, None, SolverOptions::default())?; let cp_density = cp.density.into_reduced(); if pressure > cp.pressure(Contributions::Total) { return Err(FeosError::SuperCritical); @@ -540,7 +541,7 @@ impl PhaseEquilibrium { vle_pure.liquid().density, molefracs_liquid, )?; - Ok(PhaseEquilibrium::from_states(vapor, liquid)) + Ok(PhaseEquilibrium::two_phase(vapor, liquid)) }) .ok() }) diff --git a/crates/feos-core/src/state/composition.rs b/crates/feos-core/src/state/composition.rs index 0ad47ac9e..f6a190e33 100644 --- a/crates/feos-core/src/state/composition.rs +++ b/crates/feos-core/src/state/composition.rs @@ -4,7 +4,7 @@ use crate::{FeosError, FeosResult}; use nalgebra::allocator::Allocator; use nalgebra::{DefaultAllocator, Dim, Dyn, OVector, U1, U2, dvector, vector}; use num_dual::{DualNum, DualStruct}; -use quantity::{Density, Moles, Quantity, SIUnit}; +use quantity::Moles; pub trait Composition + Copy, N: Dim> where @@ -15,16 +15,6 @@ where self, eos: &E, ) -> FeosResult<(OVector, Option>)>; - fn density(&self) -> Option> { - None - } -} - -pub trait FullComposition + Copy, N: Dim>: Composition -where - DefaultAllocator: Allocator, -{ - fn into_moles>(self, eos: &E) -> FeosResult<(OVector, Moles)>; } // trivial implementations @@ -40,15 +30,6 @@ where } } -impl + Copy, N: Dim> FullComposition for (OVector, Moles) -where - DefaultAllocator: Allocator, -{ - fn into_moles>(self, _: &E) -> FeosResult<(OVector, Moles)> { - Ok((self.0, self.1)) - } -} - impl + Copy, N: Dim> Composition for (OVector, Option>) where DefaultAllocator: Allocator, @@ -136,12 +117,6 @@ impl + Copy> Composition for Moles { } } -impl + Copy> FullComposition for Moles { - fn into_moles>(self, _: &E) -> FeosResult<(OVector, Moles)> { - Ok(((vector![D::one()]), self)) - } -} - impl + Copy> Composition for Moles { fn into_molefracs>( self, @@ -158,19 +133,6 @@ impl + Copy> Composition for Moles { } } -impl + Copy> FullComposition for Moles { - fn into_moles>(self, eos: &E) -> FeosResult<(OVector, Moles)> { - if eos.components() == 1 { - Ok(((dvector![D::one()]), self)) - } else { - Err(FeosError::UndeterminedState(format!( - "A single mole number ({}) can only be used to specify a pure component!", - self.re() - ))) - } - } -} - // the mixture can be specified by its molefractions // // for a dynamic number of components, it is also possible to specify only the @@ -228,15 +190,6 @@ where } } -impl + Copy, N: Dim> FullComposition for Moles> -where - DefaultAllocator: Allocator, -{ - fn into_moles>(self, eos: &E) -> FeosResult<(OVector, Moles)> { - (&self).into_moles(eos) - } -} - impl + Copy, N: Dim> Composition for &Moles> where DefaultAllocator: Allocator, @@ -257,48 +210,3 @@ where } } } - -impl + Copy, N: Dim> FullComposition for &Moles> -where - DefaultAllocator: Allocator, -{ - fn into_moles>(self, eos: &E) -> FeosResult<(OVector, Moles)> { - if eos.components() == self.len() { - let total_moles = self.sum(); - Ok(((self.convert_to(total_moles)), total_moles)) - } else { - Err(FeosError::UndeterminedState(format!( - "The length of the composition vector ({}) does not match the number of components ({})!", - self.len(), - eos.components() - ))) - } - } -} - -// the mixture can be specified with the partial density -impl + Copy, N: Dim> Composition - for Quantity, SIUnit<0, -3, 0, 0, 0, 1, 0>> -where - DefaultAllocator: Allocator, -{ - fn into_molefracs>( - self, - eos: &E, - ) -> FeosResult<(OVector, Option>)> { - if eos.components() == self.len() { - let density = self.sum(); - Ok(((self.convert_to(density)), None)) - } else { - panic!( - "The length of the composition vector ({}) does not match the number of components ({})!", - self.len(), - eos.components() - ) - } - } - - fn density(&self) -> Option> { - Some(self.sum()) - } -} diff --git a/crates/feos-core/src/state/mod.rs b/crates/feos-core/src/state/mod.rs index ec99d4f19..716468aa8 100644 --- a/crates/feos-core/src/state/mod.rs +++ b/crates/feos-core/src/state/mod.rs @@ -23,7 +23,7 @@ mod properties; mod residual_properties; mod statevec; pub(crate) use cache::Cache; -pub use composition::{Composition, FullComposition}; +pub use composition::Composition; pub use statevec::StateVec; /// Possible contributions that can be computed. @@ -177,18 +177,18 @@ where } /// Mole numbers $N_i$ - pub fn moles(&self) -> Moles> { - Dimensionless::new(&self.molefracs) * self.total_moles() + pub fn moles(&self) -> FeosResult>> { + Ok(Dimensionless::new(&self.molefracs) * self.total_moles()?) } /// Total moles $N=\sum_iN_i$ - pub fn total_moles(&self) -> Moles { - self.total_moles.expect("Extensive properties can only be evaluated for states that are initialized with extensive properties!") + pub fn total_moles(&self) -> FeosResult> { + self.total_moles.ok_or(FeosError::IntensiveState) } /// Volume $V$ - pub fn volume(&self) -> Volume { - self.molar_volume * self.total_moles() + pub fn volume(&self) -> FeosResult> { + Ok(self.molar_volume * self.total_moles()?) } } @@ -225,13 +225,19 @@ where /// This function will perform a validation of the given properties, i.e. test for signs /// and if values are finite. It will **not** validate physics, i.e. if the resulting /// densities are below the maximum packing fraction. - pub fn new_nvt>( + pub fn new_nvt>( eos: &E, temperature: Temperature, volume: Volume, composition: X, ) -> FeosResult { - let (molefracs, total_moles) = composition.into_moles(eos)?; + let (molefracs, total_moles) = composition.into_molefracs(eos)?; + let Some(total_moles) = total_moles else { + return Err(FeosError::UndeterminedState( + "Missing total mole number in the specification!".into(), + )); + }; + let density = total_moles / volume; Self::new(eos, temperature, density, (molefracs, total_moles)) } @@ -247,7 +253,8 @@ where partial_density: Density>, ) -> FeosResult { let density = partial_density.sum(); - Self::new(eos, temperature, density, partial_density) + let molefracs = partial_density.convert_into(density); + Self::new(eos, temperature, density, molefracs) } /// Return a new `State` for a pure component given a temperature and a density. @@ -340,14 +347,6 @@ where pressure: Option>, density_initialization: Option, ) -> FeosResult> { - // check if density is given twice - if density.and(composition.density()).is_some() { - return Err(FeosError::UndeterminedState(String::from( - "Both density and partial density given.", - ))); - } - let density = density.or_else(|| composition.density()); - // unwrap composition let (x, n) = composition.into_molefracs(eos)?; @@ -586,7 +585,7 @@ where } /// Return a new `State` for given volume $V$ and molar internal energy $u$. - pub fn new_nvu + Clone>( + pub fn new_nvu + Clone>( eos: &E, volume: Volume, molar_internal_energy: MolarEnergy, diff --git a/crates/feos-core/src/state/properties.rs b/crates/feos-core/src/state/properties.rs index c93c1e77d..fa0f53b4a 100644 --- a/crates/feos-core/src/state/properties.rs +++ b/crates/feos-core/src/state/properties.rs @@ -1,6 +1,6 @@ use super::{Contributions, State}; use crate::equation_of_state::{Molarweight, Total}; -use crate::{ReferenceSystem, Residual}; +use crate::{FeosResult, ReferenceSystem, Residual}; use nalgebra::allocator::Allocator; use nalgebra::{DefaultAllocator, OVector}; use num_dual::{Dual, DualNum, Gradients, partial, partial2}; @@ -79,8 +79,8 @@ where } /// Entropy: $S=-\left(\frac{\partial A}{\partial T}\right)_{V,N_i}$ - pub fn entropy(&self, contributions: Contributions) -> Entropy { - self.molar_entropy(contributions) * self.total_moles() + pub fn entropy(&self, contributions: Contributions) -> FeosResult> { + Ok(self.molar_entropy(contributions) * self.total_moles()?) } /// Molar entropy: $s=\frac{S}{N}$ @@ -147,8 +147,8 @@ where } /// Enthalpy: $H=A+TS+pV$ - pub fn enthalpy(&self, contributions: Contributions) -> Energy { - self.molar_enthalpy(contributions) * self.total_moles() + pub fn enthalpy(&self, contributions: Contributions) -> FeosResult> { + Ok(self.molar_enthalpy(contributions) * self.total_moles()?) } /// Molar enthalpy: $h=\frac{H}{N}$ @@ -166,8 +166,8 @@ where } /// Helmholtz energy: $A$ - pub fn helmholtz_energy(&self, contributions: Contributions) -> Energy { - self.molar_helmholtz_energy(contributions) * self.total_moles() + pub fn helmholtz_energy(&self, contributions: Contributions) -> FeosResult> { + Ok(self.molar_helmholtz_energy(contributions) * self.total_moles()?) } /// Molar Helmholtz energy: $a=\frac{A}{N}$ @@ -187,8 +187,8 @@ where } /// Internal energy: $U=A+TS$ - pub fn internal_energy(&self, contributions: Contributions) -> Energy { - self.molar_internal_energy(contributions) * self.total_moles() + pub fn internal_energy(&self, contributions: Contributions) -> FeosResult> { + Ok(self.molar_internal_energy(contributions) * self.total_moles()?) } /// Molar internal energy: $u=\frac{U}{N}$ @@ -198,8 +198,8 @@ where } /// Gibbs energy: $G=A+pV$ - pub fn gibbs_energy(&self, contributions: Contributions) -> Energy { - self.molar_gibbs_energy(contributions) * self.total_moles() + pub fn gibbs_energy(&self, contributions: Contributions) -> FeosResult> { + Ok(self.molar_gibbs_energy(contributions) * self.total_moles()?) } /// Molar Gibbs energy: $g=\frac{G}{N}$ diff --git a/crates/feos-core/src/state/residual_properties.rs b/crates/feos-core/src/state/residual_properties.rs index 4a6b8f015..184f1aa53 100644 --- a/crates/feos-core/src/state/residual_properties.rs +++ b/crates/feos-core/src/state/residual_properties.rs @@ -34,8 +34,8 @@ where } /// Residual Helmholtz energy $A^\text{res}$ - pub fn residual_helmholtz_energy(&self) -> Energy { - self.residual_molar_helmholtz_energy() * self.total_moles() + pub fn residual_helmholtz_energy(&self) -> FeosResult> { + Ok(self.residual_molar_helmholtz_energy() * self.total_moles()?) } /// Residual molar Helmholtz energy $a^\text{res}$ @@ -50,8 +50,8 @@ where } /// Residual entropy $S^\text{res}=\left(\frac{\partial A^\text{res}}{\partial T}\right)_{V,N_i}$ - pub fn residual_entropy(&self) -> Entropy { - self.residual_molar_entropy() * self.total_moles() + pub fn residual_entropy(&self) -> FeosResult> { + Ok(self.residual_molar_entropy() * self.total_moles()?) } /// Residual molar entropy $s^\text{res}=\left(\frac{\partial a^\text{res}}{\partial T}\right)_{V,N_i}$ @@ -431,18 +431,14 @@ impl State { .unzip(); let solvent_molefracs = DVector::from_vec(solvent_molefracs); let solvent = eos.subset(&solvent_comps); - let vle = if solvent_comps.len() == 1 { - PhaseEquilibrium::pure(&solvent, temperature, None, Default::default()) - } else { - PhaseEquilibrium::bubble_point( - &solvent, - temperature, - &solvent_molefracs, - None, - None, - Default::default(), - ) - }?; + let vle = PhaseEquilibrium::bubble_point( + &solvent, + temperature, + &solvent_molefracs, + None, + None, + Default::default(), + )?; // Calculate the liquid state including the Henry components let liquid = State::new(eos, temperature, vle.liquid().density, molefracs.clone())?; @@ -506,8 +502,8 @@ where } /// Residual enthalpy: $H^\text{res}(T,p,\mathbf{n})=A^\text{res}+TS^\text{res}+p^\text{res}V$ - pub fn residual_enthalpy(&self) -> Energy { - self.residual_molar_enthalpy() * self.total_moles() + pub fn residual_enthalpy(&self) -> FeosResult> { + Ok(self.residual_molar_enthalpy() * self.total_moles()?) } /// Residual molar enthalpy: $h^\text{res}(T,p,\mathbf{n})=a^\text{res}+Ts^\text{res}+p^\text{res}v$ @@ -518,8 +514,8 @@ where } /// Residual internal energy: $U^\text{res}(T,V,\mathbf{n})=A^\text{res}+TS^\text{res}$ - pub fn residual_internal_energy(&self) -> Energy { - self.residual_molar_internal_energy() * self.total_moles() + pub fn residual_internal_energy(&self) -> FeosResult> { + Ok(self.residual_molar_internal_energy() * self.total_moles()?) } /// Residual molar internal energy: $u^\text{res}(T,V,\mathbf{n})=a^\text{res}+Ts^\text{res}$ @@ -528,8 +524,8 @@ where } /// Residual Gibbs energy: $G^\text{res}(T,p,\mathbf{n})=A^\text{res}+p^\text{res}V-NRT \ln Z$ - pub fn residual_gibbs_energy(&self) -> Energy { - self.residual_molar_gibbs_energy() * self.total_moles() + pub fn residual_gibbs_energy(&self) -> FeosResult> { + Ok(self.residual_molar_gibbs_energy() * self.total_moles()?) } /// Residual Gibbs energy: $g^\text{res}(T,p,\mathbf{n})=a^\text{res}+p^\text{res}v-RT \ln Z$ @@ -601,16 +597,17 @@ where } /// Mass of each component: $m_i=n_iMW_i$ - pub fn mass(&self) -> Mass> { - self.eos + pub fn mass(&self) -> FeosResult>> { + Ok(self + .eos .molar_weight() .component_mul(&Dimensionless::new(self.molefracs.clone())) - * self.total_moles() + * self.total_moles()?) } /// Total mass: $m=\sum_im_i=nMW$ - pub fn total_mass(&self) -> Mass { - self.total_molar_weight() * self.total_moles() + pub fn total_mass(&self) -> FeosResult> { + Ok(self.total_molar_weight() * self.total_moles()?) } /// Mass density: $\rho^{(m)}=\frac{m}{V}$ diff --git a/crates/feos-core/src/state/statevec.rs b/crates/feos-core/src/state/statevec.rs index 63d51b3fd..8a498e2db 100644 --- a/crates/feos-core/src/state/statevec.rs +++ b/crates/feos-core/src/state/statevec.rs @@ -2,6 +2,8 @@ use super::Contributions; use super::State; #[cfg(feature = "ndarray")] +use crate::FeosResult; +#[cfg(feature = "ndarray")] use crate::equation_of_state::{Molarweight, Residual, Total}; #[cfg(feature = "ndarray")] use ndarray::{Array1, Array2}; @@ -61,10 +63,15 @@ impl StateVec<'_, E> { Density::from_shape_fn(self.0.len(), |i| self.0[i].density) } - pub fn moles(&self) -> Moles> { - Moles::from_shape_fn((self.0.len(), self.0[0].eos.components()), |(i, j)| { - self.0[i].moles().get(j) - }) + pub fn moles(&self) -> FeosResult>> { + if let Err(e) = self.0[0].moles() { + Err(e) + } else { + Ok(Moles::from_shape_fn( + (self.0.len(), self.0[0].eos.components()), + |(i, j)| self.0[i].moles().unwrap().get(j), + )) + } } pub fn molefracs(&self) -> Array2 { diff --git a/crates/feos/benches/contributions.rs b/crates/feos/benches/contributions.rs index 3e74bf68c..7dc8332c2 100644 --- a/crates/feos/benches/contributions.rs +++ b/crates/feos/benches/contributions.rs @@ -74,7 +74,7 @@ fn pcsaft(c: &mut Criterion) { State::new_npt(&&eos, t, p, &moles, Some(DensityInitialization::Liquid)).unwrap(); let temperature = Dual64::from(state.temperature.into_reduced()).derivative(); let molar_volume = Dual::from(1.0 / state.density.into_reduced()); - let moles = state.moles().to_reduced().map(Dual::from); + let moles = state.moles().unwrap().to_reduced().map(Dual::from); // let state_hd = state.derive1(Derivative::DT); let name1 = comp1.identifier.name.as_deref().unwrap(); let name2 = comp2.identifier.name.as_deref().unwrap(); diff --git a/crates/feos/benches/dft_pore.rs b/crates/feos/benches/dft_pore.rs index 98dcba4e2..4c5b3d7ba 100644 --- a/crates/feos/benches/dft_pore.rs +++ b/crates/feos/benches/dft_pore.rs @@ -67,15 +67,9 @@ fn pcsaft(c: &mut Criterion) { ) .unwrap(); let func = &PcSaftFunctional::new(parameters); - let vle = PhaseEquilibrium::bubble_point( - &func, - 300.0 * KELVIN, - &dvector![0.5, 0.5], - None, - None, - Default::default(), - ) - .unwrap(); + let vle = + PhaseEquilibrium::bubble_point(&func, 300.0 * KELVIN, 0.5, None, None, Default::default()) + .unwrap(); let bulk = vle.liquid(); group.bench_function("butane_pentane_liquid", |b| { b.iter(|| pore.initialize(bulk, None, None).unwrap().solve(None)) diff --git a/crates/feos/benches/dual_numbers.rs b/crates/feos/benches/dual_numbers.rs index c3c1bd07f..eb2eff2e4 100644 --- a/crates/feos/benches/dual_numbers.rs +++ b/crates/feos/benches/dual_numbers.rs @@ -20,10 +20,9 @@ use quantity::*; /// - molefracs (or moles) for equimolar mixture. fn state_pcsaft(n: usize, eos: &PcSaft) -> State<&PcSaft> { let moles = DVector::from_element(n, 1.0 / n as f64) * 10.0 * MOL; - let molefracs = (&moles / moles.sum()).into_value(); - let cp = State::critical_point(&eos, molefracs, None, None, Default::default()).unwrap(); + let cp = State::critical_point(&eos, &moles, None, None, Default::default()).unwrap(); let temperature = 0.8 * cp.temperature; - State::new_nvt(&eos, temperature, cp.volume(), moles).unwrap() + State::new_nvt(&eos, temperature, cp.volume().unwrap(), moles).unwrap() } /// Residual Helmholtz energy given an equation of state and a StateHD. @@ -152,11 +151,10 @@ enum Derivative { /// Creates a [StateHD] cloning temperature, volume and moles. fn derive0(state: &State) -> StateHD { - let total_moles = state.total_moles().into_reduced(); StateHD::new( state.temperature.into_reduced(), - state.volume().into_reduced() / total_moles, - &(state.moles().to_reduced() / total_moles), + state.molar_volume.into_reduced(), + &state.molefracs, ) } diff --git a/crates/feos/benches/dual_numbers_saftvrmie.rs b/crates/feos/benches/dual_numbers_saftvrmie.rs index e7409e307..80f4da09d 100644 --- a/crates/feos/benches/dual_numbers_saftvrmie.rs +++ b/crates/feos/benches/dual_numbers_saftvrmie.rs @@ -20,7 +20,13 @@ fn state_saftvrmie(n: usize, eos: &SaftVRMie) -> State<&SaftVRMie> { let molefracs = DVector::from_element(n, 1.0 / n as f64); let cp = State::critical_point(&eos, &molefracs, None, None, Default::default()).unwrap(); let temperature = 0.8 * cp.temperature; - State::new_nvt(&eos, temperature, cp.volume(), &(molefracs * 10. * MOL)).unwrap() + State::new_nvt( + &eos, + temperature, + cp.volume().unwrap(), + &(molefracs * 10. * MOL), + ) + .unwrap() } /// Residual Helmholtz energy given an equation of state and a StateHD. @@ -100,11 +106,10 @@ enum Derivative { /// Creates a [StateHD] cloning temperature, volume and moles. fn derive0(state: &State) -> StateHD { - let total_moles = state.total_moles().into_reduced(); StateHD::new( state.temperature.into_reduced(), - state.volume().into_reduced() / total_moles, - &(state.moles().to_reduced() / total_moles), + state.molar_volume.into_reduced(), + &state.molefracs, ) } diff --git a/crates/feos/benches/state_creation.rs b/crates/feos/benches/state_creation.rs index 3aa08778e..eb0c07bcd 100644 --- a/crates/feos/benches/state_creation.rs +++ b/crates/feos/benches/state_creation.rs @@ -77,7 +77,7 @@ fn bench_states(c: &mut Criterion, group_name: &str, eos: &E) { eos, crit.temperature, crit.pressure(Contributions::Total) * 0.95, - &crit.moles(), + &crit.molefracs, None, Default::default(), None, diff --git a/crates/feos/src/epcsaft/eos/mod.rs b/crates/feos/src/epcsaft/eos/mod.rs index 2edba6f69..5a0a8c81f 100644 --- a/crates/feos/src/epcsaft/eos/mod.rs +++ b/crates/feos/src/epcsaft/eos/mod.rs @@ -181,7 +181,7 @@ mod tests { let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&&e, t, v, &n).unwrap(); - let p_ig = s.total_moles() * RGAS * t / v; + let p_ig = s.total_moles().unwrap() * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), @@ -197,7 +197,7 @@ mod tests { let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&&e, t, v, &n).unwrap(); - let p_ig = s.total_moles() * RGAS * t / v; + let p_ig = s.total_moles().unwrap() * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), diff --git a/crates/feos/src/pcsaft/eos/mod.rs b/crates/feos/src/pcsaft/eos/mod.rs index 752e7c6f6..37a885248 100644 --- a/crates/feos/src/pcsaft/eos/mod.rs +++ b/crates/feos/src/pcsaft/eos/mod.rs @@ -394,35 +394,37 @@ mod tests { use quantity::{BAR, KELVIN, METER, PASCAL, RGAS}; #[test] - fn ideal_gas_pressure() { + fn ideal_gas_pressure() -> FeosResult<()> { let e = &propane_parameters(); let t = 200.0 * KELVIN; let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&e, t, v, n).unwrap(); - let p_ig = s.total_moles() * RGAS * t / v; + let p_ig = s.total_moles()? * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), s.pressure(Contributions::Total), epsilon = 1e-10 ); + Ok(()) } #[test] - fn ideal_gas_heat_capacity_joback() { + fn ideal_gas_heat_capacity_joback() -> FeosResult<()> { let e = &propane_parameters(); let t = 200.0 * KELVIN; let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&e, t, v, n).unwrap(); - let p_ig = s.total_moles() * RGAS * t / v; + let p_ig = s.total_moles()? * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), s.pressure(Contributions::Total), epsilon = 1e-10 ); + Ok(()) } #[test] @@ -599,7 +601,7 @@ mod tests_parameter_fit { use feos_core::{Contributions, PropertiesAD, ReferenceSystem, SolverOptions}; use feos_core::{FeosResult, ParametersAD, PhaseEquilibrium, State}; use nalgebra::{U1, U3, U8, vector}; - use num_dual::{DualStruct, DualVec, partial}; + use num_dual::{Dual64, DualStruct, DualVec, partial}; use quantity::{BAR, KELVIN, LITER, MOL, PASCAL}; fn pcsaft_non_assoc() -> PcSaftPure { @@ -787,9 +789,7 @@ mod tests_parameter_fit { let h = params[i] * 1e-7; params[i] += h; let pcsaft_h = PcSaftPure(params); - let rho_h = - State::new_npt(&pcsaft_h, temperature, pressure, vector![1.0], Some(Liquid))? - .density; + let rho_h = State::new_npt(&pcsaft_h, temperature, pressure, (), Some(Liquid))?.density; let drho_h = (rho_h.convert_into(MOL / LITER) - rho) / h; let drho = grad[i]; println!( @@ -823,7 +823,7 @@ mod tests_parameter_fit { let p_h = PhaseEquilibrium::bubble_point( &pcsaft_h, temperature, - &x, + x, None, None, Default::default(), @@ -846,7 +846,7 @@ mod tests_parameter_fit { let (pcsaft, _) = pcsaft_binary()?; let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); let temperature = 500.0 * KELVIN; - let y = vector![0.5, 0.5]; + let y = 0.5; let p = pcsaft_ad.dew_point_pressure(temperature, None, y)?; let p = p.convert_into(BAR); let (p, [[grad]]) = (p.re, p.eps.unwrap_generic(U1, U1).data.0); @@ -858,16 +858,10 @@ mod tests_parameter_fit { let h = 1e-7; kij += h; let pcsaft_h = PcSaftBinary::new(params, kij); - let p_h = PhaseEquilibrium::dew_point( - &pcsaft_h, - temperature, - &y, - None, - None, - Default::default(), - )? - .vapor() - .pressure(Contributions::Total); + let p_h = + PhaseEquilibrium::dew_point(&pcsaft_h, temperature, y, None, None, Default::default())? + .vapor() + .pressure(Contributions::Total); let dp_h = (p_h.convert_into(BAR) - p) / h; println!( "k_ij: {:11.5} {:11.5} {:.3e}", @@ -885,11 +879,11 @@ mod tests_parameter_fit { let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); let pressure = Pressure::from_reduced(DualVec::from(45. * BAR.into_reduced())); let t_init = Temperature::from_reduced(DualVec::from(500.0)); - let x = vector![0.5, 0.5].map(DualVec::from); + let x = DualVec::from(0.5); let t = PhaseEquilibrium::bubble_point( &pcsaft_ad, pressure, - &x, + x, Some(t_init), None, Default::default(), @@ -909,7 +903,7 @@ mod tests_parameter_fit { let t_h = PhaseEquilibrium::bubble_point( &pcsaft_h, pressure.re(), - &x.map(|x| x.re()), + x.re(), Some(t_init.re()), None, Default::default(), @@ -933,11 +927,11 @@ mod tests_parameter_fit { let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); let pressure = Pressure::from_reduced(DualVec::from(45. * BAR.into_reduced())); let t_init = Temperature::from_reduced(DualVec::from(500.0)); - let x = vector![0.5, 0.5].map(DualVec::from); + let x = DualVec::from(0.5); let t = PhaseEquilibrium::dew_point( &pcsaft_ad, pressure, - &x, + x, Some(t_init), None, Default::default(), @@ -957,7 +951,7 @@ mod tests_parameter_fit { let t_h = PhaseEquilibrium::dew_point( &pcsaft_h, pressure.re(), - &x.map(|x| x.re()), + x.re(), Some(t_init.re()), None, Default::default(), @@ -980,10 +974,10 @@ mod tests_parameter_fit { let (pcsaft, _) = pcsaft_binary()?; let pcsaft_ad = pcsaft; let mut temperature = 500.0 * KELVIN; - let x = vector![0.5, 0.5]; + let x = 0.5; let (p, grad) = first_derivative( partial( - |t, x| { + |t, &x: &Dual64| { let eos = pcsaft_ad.lift(); PhaseEquilibrium::bubble_point(&eos, t, x, None, None, Default::default()) .unwrap() @@ -1003,7 +997,7 @@ mod tests_parameter_fit { let p_h = PhaseEquilibrium::bubble_point( &pcsaft_ad, temperature, - &x, + x, None, None, Default::default(), @@ -1017,7 +1011,7 @@ mod tests_parameter_fit { grad, ((dp_h - grad).convert_into(grad)).abs() ); - assert_relative_eq!(grad, dp_h, max_relative = 1e-7); + assert_relative_eq!(grad, dp_h, max_relative = 2e-7); Ok(()) } @@ -1027,10 +1021,10 @@ mod tests_parameter_fit { let pcsaft_ad = pcsaft; let mut pressure = 45. * BAR; let t0 = Some(500. * KELVIN); - let x = vector![0.5, 0.5]; + let x = 0.5; let (t, grad) = first_derivative( partial2( - |p, x, &t0| { + |p, &x: &Dual64, &t0| { let eos = pcsaft_ad.lift(); PhaseEquilibrium::bubble_point(&eos, p, x, t0, None, Default::default()) .unwrap() @@ -1049,7 +1043,7 @@ mod tests_parameter_fit { let h = 1e-5 * BAR; pressure += h; let t_h = - PhaseEquilibrium::bubble_point(&pcsaft_ad, pressure, &x, t0, None, Default::default())? + PhaseEquilibrium::bubble_point(&pcsaft_ad, pressure, x, t0, None, Default::default())? .vapor() .temperature; let dt_h = (t_h - t) / h; @@ -1059,7 +1053,7 @@ mod tests_parameter_fit { grad, ((dt_h - grad).convert_into(grad)).abs() ); - assert_relative_eq!(grad, dt_h, max_relative = 1e-7); + assert_relative_eq!(grad, dt_h, max_relative = 2e-7); Ok(()) } @@ -1069,22 +1063,19 @@ mod tests_parameter_fit { let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); let temperature = 500.0 * KELVIN; let pressure = 44.6 * BAR; - let x = vector![0.5, 0.5]; + let x = 0.5; let vle = PhaseEquilibrium::tp_flash_binary( &pcsaft_ad, Temperature::from_inner(&temperature), Pressure::from_inner(&pressure), - &Moles::from_inner(&(x * MOL)), + DualVec::from(x), SolverOptions { verbosity: feos_core::Verbosity::Iter, - tol: Some(1e-10), + tol: Some(1e-12), ..Default::default() }, )?; - let beta = vle - .vapor() - .total_moles - .convert_into(vle.vapor().total_moles + vle.liquid().total_moles); + let beta = vle.vapor_phase_fraction(); let (beta, [[grad]]) = (beta.re, beta.eps.unwrap_generic(U1, U1).data.0); println!("{beta:.5}"); @@ -1098,16 +1089,13 @@ mod tests_parameter_fit { &pcsaft_h, temperature, pressure, - &(x * MOL), + x, SolverOptions { - tol: Some(1e-10), + tol: Some(1e-12), ..Default::default() }, )?; - let beta_h = vle - .vapor() - .total_moles - .convert_into(vle.vapor().total_moles + vle.liquid().total_moles); + let beta_h = vle.vapor_phase_fraction(); let dbeta_h = (beta_h - beta) / h; println!( "k_ij: {:11.5} {:11.5} {:.3e}", diff --git a/crates/feos/src/pets/eos/mod.rs b/crates/feos/src/pets/eos/mod.rs index e26b94960..5d9b9a666 100644 --- a/crates/feos/src/pets/eos/mod.rs +++ b/crates/feos/src/pets/eos/mod.rs @@ -152,7 +152,7 @@ mod tests { let v = 1e-3 * METER.powi::<3>(); let n = dvector![1.0] * MOL; let s = State::new_nvt(&e, t, v, &n).unwrap(); - let p_ig = s.total_moles() * RGAS * t / v; + let p_ig = s.total_moles().unwrap() * RGAS * t / v; assert_relative_eq!(s.pressure(Contributions::IdealGas), p_ig, epsilon = 1e-10); assert_relative_eq!( s.pressure(Contributions::IdealGas) + s.pressure(Contributions::Residual), diff --git a/crates/feos/src/uvtheory/eos/mod.rs b/crates/feos/src/uvtheory/eos/mod.rs index 8a660db81..1957af6e8 100644 --- a/crates/feos/src/uvtheory/eos/mod.rs +++ b/crates/feos/src/uvtheory/eos/mod.rs @@ -246,9 +246,7 @@ mod test { // EoS let eos_wca = &UVTheory::new(parameters); let state_wca = State::new_nvt(&eos_wca, t_x, volume, &moles).unwrap(); - let a_wca = (state_wca.residual_helmholtz_energy() - / (RGAS * t_x * state_wca.total_moles())) - .into_value(); + let a_wca = (state_wca.residual_molar_helmholtz_energy() / (RGAS * t_x)).into_value(); assert_relative_eq!(a_wca, -0.597791038364405, max_relative = 1e-5); Ok(()) diff --git a/crates/feos/tests/pcsaft/stability_analysis.rs b/crates/feos/tests/pcsaft/stability_analysis.rs index bf31ee24d..dc1cf71d0 100644 --- a/crates/feos/tests/pcsaft/stability_analysis.rs +++ b/crates/feos/tests/pcsaft/stability_analysis.rs @@ -1,7 +1,6 @@ use feos::pcsaft::{PcSaft, PcSaftParameters}; use feos_core::parameter::IdentifierOption; use feos_core::{DensityInitialization, PhaseEquilibrium, SolverOptions, State}; -use nalgebra::dvector; use quantity::*; use std::error::Error; @@ -18,7 +17,7 @@ fn test_stability_analysis() -> Result<(), Box> { &&mix, 300.0 * KELVIN, 1.0 * BAR, - &(dvector![0.5, 0.5] * MOL), + 0.5, Some(DensityInitialization::Liquid), )?; let options = SolverOptions { @@ -38,7 +37,7 @@ fn test_stability_analysis() -> Result<(), Box> { let vle = PhaseEquilibrium::bubble_point( &&mix, 300.0 * KELVIN, - &dvector![0.5, 0.5], + 0.5, Some(6.0 * BAR), None, (options, options), diff --git a/crates/feos/tests/pcsaft/state_creation_mixture.rs b/crates/feos/tests/pcsaft/state_creation_mixture.rs index 46e0442b8..f74ed559b 100644 --- a/crates/feos/tests/pcsaft/state_creation_mixture.rs +++ b/crates/feos/tests/pcsaft/state_creation_mixture.rs @@ -56,7 +56,7 @@ fn volume_temperature_molefracs() -> Result<(), Box> { let moles = MOL; let x = dvector![0.3, 0.7]; let state = State::new_nvt(&&saft, temperature, volume, (x, moles))?; - assert_relative_eq!(state.volume(), volume, max_relative = 1e-10); + assert_relative_eq!(state.volume()?, volume, max_relative = 1e-10); Ok(()) } diff --git a/crates/feos/tests/pcsaft/state_creation_pure.rs b/crates/feos/tests/pcsaft/state_creation_pure.rs index cda35b75f..81ef57168 100644 --- a/crates/feos/tests/pcsaft/state_creation_pure.rs +++ b/crates/feos/tests/pcsaft/state_creation_pure.rs @@ -29,7 +29,7 @@ fn temperature_volume() -> FeosResult<()> { let volume = 1.5e-3 * METER.powi::<3>(); let moles = MOL; let state = State::new_nvt(&&saft, temperature, volume, moles)?; - assert_relative_eq!(state.volume(), volume, max_relative = 1e-10); + assert_relative_eq!(state.volume()?, volume, max_relative = 1e-10); Ok(()) } @@ -50,8 +50,8 @@ fn temperature_total_moles_volume() -> FeosResult<()> { let total_moles = MOL; let volume = METER.powi::<3>(); let state = State::new_nvt(&&saft, temperature, volume, total_moles)?; - assert_relative_eq!(state.volume(), volume, max_relative = 1e-10); - assert_relative_eq!(state.total_moles(), total_moles, max_relative = 1e-10); + assert_relative_eq!(state.volume()?, volume, max_relative = 1e-10); + assert_relative_eq!(state.total_moles()?, total_moles, max_relative = 1e-10); Ok(()) } @@ -63,8 +63,8 @@ fn temperature_total_moles_density() -> FeosResult<()> { let density = MOL / METER.powi::<3>(); let state = State::new_pure(&&saft, temperature, density)?.set_total_moles(total_moles); assert_relative_eq!(state.density, density, max_relative = 1e-10); - assert_relative_eq!(state.total_moles(), total_moles, max_relative = 1e-10); - assert_relative_eq!(state.volume(), total_moles / density, max_relative = 1e-10); + assert_relative_eq!(state.total_moles()?, total_moles, max_relative = 1e-10); + assert_relative_eq!(state.volume()?, total_moles / density, max_relative = 1e-10); Ok(()) } @@ -158,7 +158,7 @@ fn density_internal_energy() -> FeosResult<()> { let molar_internal_energy = state.molar_internal_energy(Contributions::Total); let state_nvu = State::new_nvu( &&eos, - state.volume(), + state.volume()?, molar_internal_energy, total_moles, None, @@ -203,8 +203,8 @@ fn pressure_enthalpy_total_moles_vapor() -> FeosResult<()> { let state = State::new_nvt( &&eos, state.temperature, - state.volume(), - state.total_moles(), + state.volume()?, + state.total_moles()?, )?; assert_relative_eq!( state.molar_enthalpy(Contributions::Total), @@ -266,7 +266,7 @@ fn temperature_entropy_vapor() -> FeosResult<()> { &&eos, temperature, state.molar_entropy(Contributions::Total), - state.moles(), + state.moles()?, None, )?; assert_relative_eq!( diff --git a/py-feos/src/ad/mod.rs b/py-feos/src/ad/mod.rs index 75958d7ca..3f775a2da 100644 --- a/py-feos/src/ad/mod.rs +++ b/py-feos/src/ad/mod.rs @@ -76,7 +76,7 @@ pub fn vapor_pressure_derivatives<'py>( #[pyfunction] pub fn boiling_temperature_derivatives<'py>( model: PyEquationOfStateAD, - parameter_names: Bound<'py, PyAny>, + parameter_names: &Bound<'py, PyAny>, parameters: PyReadonlyArray2, input: PyReadonlyArray2, ) -> GradResult<'py> { diff --git a/py-feos/src/eos/mod.rs b/py-feos/src/eos/mod.rs index cd50de42f..0bc7f1a00 100644 --- a/py-feos/src/eos/mod.rs +++ b/py-feos/src/eos/mod.rs @@ -210,7 +210,6 @@ pub enum Compositions { TotalMoles(Moles), Molefracs(DVector), Moles(Moles>), - PartialDensity(Density>), } impl Composition for Compositions { @@ -224,15 +223,6 @@ impl Composition for Compositions { Self::TotalMoles(total_moles) => total_moles.into_molefracs(eos), Self::Molefracs(molefracs) => molefracs.into_molefracs(eos), Self::Moles(moles) => moles.into_molefracs(eos), - Self::PartialDensity(partial_density) => partial_density.into_molefracs(eos), - } - } - - fn density(&self) -> Option> { - if let Self::PartialDensity(partial_density) = self { - partial_density.density() - } else { - None } } } @@ -255,8 +245,6 @@ impl TryFrom>> for Compositions { Ok(Compositions::Moles(n)) } else if let Ok(n) = composition.extract::() { Ok(Compositions::TotalMoles(n)) - } else if let Ok(rho) = composition.extract::>>() { - Ok(Compositions::PartialDensity(rho)) } else { Err(PyErr::new::(format!( "failed to parse value '{composition}' as composition." diff --git a/py-feos/src/phase_equilibria.rs b/py-feos/src/phase_equilibria.rs index 38d0d2d12..c6c857506 100644 --- a/py-feos/src/phase_equilibria.rs +++ b/py-feos/src/phase_equilibria.rs @@ -1,6 +1,6 @@ use crate::{ PyVerbosity, - eos::{PyEquationOfState, parse_molefracs}, + eos::{Compositions, PyEquationOfState, parse_molefracs}, error::PyFeosError, ideal_gas::IdealGasModel, residual::ResidualModel, @@ -108,8 +108,8 @@ impl PyPhaseEquilibrium { /// The system temperature. /// pressure : SINumber /// The system pressure. - /// feed : SIArray1 - /// Feed composition (units of amount of substance). + /// feed : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float] + /// Feed composition. /// initial_state : PhaseEquilibrium, optional /// A phase equilibrium used as initial guess. /// Can speed up convergence. @@ -138,7 +138,7 @@ impl PyPhaseEquilibrium { eos: &PyEquationOfState, temperature: Temperature, pressure: Pressure, - feed: Moles>, + feed: &Bound<'_, PyAny>, initial_state: Option<&PyPhaseEquilibrium>, max_iter: Option, tol: Option, @@ -150,7 +150,7 @@ impl PyPhaseEquilibrium { &eos.0, temperature, pressure, - &feed, + Compositions::try_from(Some(feed))?, initial_state.map(|s| &s.0), (max_iter, tol, verbosity.map(|v| v.into())).into(), non_volatile_components, @@ -168,7 +168,7 @@ impl PyPhaseEquilibrium { /// The equation of state. /// temperature_or_pressure : SINumber /// The system temperature_or_pressure. - /// liquid_molefracs : numpy.ndarray + /// liquid_molefracs : float | numpy.ndarray[float] | SIArray1 | list[float] /// The mole fraction of the liquid phase. /// tp_init : SINumber, optional /// The system pressure/temperature used as starting @@ -196,12 +196,12 @@ impl PyPhaseEquilibrium { )] #[pyo3(signature = (eos, temperature_or_pressure, liquid_molefracs, tp_init=None, vapor_molefracs=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None))] #[expect(clippy::too_many_arguments)] - pub(crate) fn bubble_point<'py>( + pub(crate) fn bubble_point( eos: &PyEquationOfState, temperature_or_pressure: &Bound<'_, PyAny>, - liquid_molefracs: PyReadonlyArray1<'py, f64>, + liquid_molefracs: &Bound<'_, PyAny>, tp_init: Option<&Bound<'_, PyAny>>, - vapor_molefracs: Option>, + vapor_molefracs: Option>, max_iter_inner: Option, max_iter_outer: Option, tol_inner: Option, @@ -214,7 +214,7 @@ impl PyPhaseEquilibrium { PhaseEquilibrium::bubble_point( &eos.0, t, - &parse_molefracs(Some(liquid_molefracs)).unwrap(), + Compositions::try_from(Some(liquid_molefracs))?, tp_init.map(|p| p.extract()).transpose()?, x.as_ref(), ( @@ -229,7 +229,7 @@ impl PyPhaseEquilibrium { PhaseEquilibrium::bubble_point( &eos.0, p, - &parse_molefracs(Some(liquid_molefracs)).unwrap(), + Compositions::try_from(Some(liquid_molefracs))?, tp_init.map(|p| p.extract()).transpose()?, x.as_ref(), ( @@ -256,7 +256,7 @@ impl PyPhaseEquilibrium { /// The equation of state. /// temperature_or_pressure : SINumber /// The system temperature or pressure. - /// vapor_molefracs : numpy.ndarray + /// vapor_molefracs : float | numpy.ndarray[float] | SIArray1 | list[float] /// The mole fraction of the vapor phase. /// tp_init : SINumber, optional /// The system pressure/temperature used as starting @@ -284,12 +284,12 @@ impl PyPhaseEquilibrium { )] #[pyo3(signature = (eos, temperature_or_pressure, vapor_molefracs, tp_init=None, liquid_molefracs=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None))] #[expect(clippy::too_many_arguments)] - pub(crate) fn dew_point<'py>( + pub(crate) fn dew_point( eos: &PyEquationOfState, temperature_or_pressure: &Bound<'_, PyAny>, - vapor_molefracs: PyReadonlyArray1<'py, f64>, + vapor_molefracs: &Bound<'_, PyAny>, tp_init: Option<&Bound<'_, PyAny>>, - liquid_molefracs: Option>, + liquid_molefracs: Option>, max_iter_inner: Option, max_iter_outer: Option, tol_inner: Option, @@ -302,7 +302,7 @@ impl PyPhaseEquilibrium { PhaseEquilibrium::dew_point( &eos.0, t, - &parse_molefracs(Some(vapor_molefracs)).unwrap(), + Compositions::try_from(Some(vapor_molefracs))?, tp_init.map(|p| p.extract()).transpose()?, x.as_ref(), ( @@ -317,7 +317,7 @@ impl PyPhaseEquilibrium { PhaseEquilibrium::dew_point( &eos.0, p, - &parse_molefracs(Some(vapor_molefracs)).unwrap(), + Compositions::try_from(Some(vapor_molefracs))?, tp_init.map(|p| p.extract()).transpose()?, x.as_ref(), ( @@ -335,43 +335,6 @@ impl PyPhaseEquilibrium { } } - // /// Creates a new PhaseEquilibrium that contains two states at the - // /// specified temperature, pressure and moles. - // /// - // /// The constructor can be used in custom phase equilibrium solvers or, - // /// e.g., to generate initial guesses for an actual VLE solver. - // /// In general, the two states generated are NOT in an equilibrium. - // /// - // /// Parameters - // /// ---------- - // /// eos : EquationOfState - // /// The equation of state. - // /// temperature : SINumber - // /// The system temperature. - // /// pressure : SINumber - // /// The system pressure. - // /// vapor_moles : SIArray1 - // /// Amount of substance of the vapor phase. - // /// liquid_moles : SIArray1 - // /// Amount of substance of the liquid phase. - // /// - // /// Returns - // /// ------- - // /// PhaseEquilibrium - // #[staticmethod] - // pub(crate) fn new_npt( - // eos: &PyEquationOfState, - // temperature: Temperature, - // pressure: Pressure, - // vapor_moles: Moles>, - // liquid_moles: Moles>, - // ) -> PyResult { - // Ok(Self( - // PhaseEquilibrium::new_xpt(&eos.0, temperature, pressure, &vapor_moles, &liquid_moles) - // .map_err(PyFeosError::from)?, - // )) - // } - #[getter] fn get_vapor(&self) -> PyState { PyState(self.0.vapor().clone()) @@ -804,8 +767,8 @@ impl PyPhaseDiagram { /// ---------- /// eos: Eos /// The equation of state. - /// molefracs: np.ndarray[float] - /// The composition of the liquid phase. + /// composition : float | numpy.ndarray[float] | SIArray1 | list[float] + /// Composition of the mixture. /// min_temperature: SINumber /// The lower limit for the temperature. /// npoints: int @@ -830,13 +793,13 @@ impl PyPhaseDiagram { /// PhaseDiagram #[staticmethod] #[pyo3( - text_signature = "(eos, molefracs, min_temperature, npoints, critical_temperature=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None)" + text_signature = "(eos, composition, min_temperature, npoints, critical_temperature=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None)" )] - #[pyo3(signature = (eos, molefracs, min_temperature, npoints, critical_temperature=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None))] + #[pyo3(signature = (eos, composition, min_temperature, npoints, critical_temperature=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None))] #[expect(clippy::too_many_arguments)] pub(crate) fn bubble_point_line<'py>( eos: &PyEquationOfState, - molefracs: PyReadonlyArray1<'py, f64>, + composition: &Bound<'py, PyAny>, min_temperature: Temperature, npoints: usize, critical_temperature: Option, @@ -848,7 +811,7 @@ impl PyPhaseDiagram { ) -> PyResult { let dia = PhaseDiagram::bubble_point_line( &eos.0, - &parse_molefracs(Some(molefracs)).unwrap(), + Compositions::try_from(Some(composition))?, min_temperature, npoints, critical_temperature, @@ -871,8 +834,8 @@ impl PyPhaseDiagram { /// ---------- /// eos: Eos /// The equation of state. - /// molefracs: np.ndarray[float] - /// The composition of the vapor phase. + /// composition : float | numpy.ndarray[float] | SIArray1 | list[float] + /// Composition of the mixture. /// min_temperature: SINumber /// The lower limit for the temperature. /// npoints: int @@ -897,13 +860,13 @@ impl PyPhaseDiagram { /// PhaseDiagram #[staticmethod] #[pyo3( - text_signature = "(eos, molefracs, min_temperature, npoints, critical_temperature=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None)" + text_signature = "(eos, composition, min_temperature, npoints, critical_temperature=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None)" )] - #[pyo3(signature = (eos, molefracs, min_temperature, npoints, critical_temperature=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None))] + #[pyo3(signature = (eos, composition, min_temperature, npoints, critical_temperature=None, max_iter_inner=None, max_iter_outer=None, tol_inner=None, tol_outer=None, verbosity=None))] #[expect(clippy::too_many_arguments)] pub(crate) fn dew_point_line<'py>( eos: &PyEquationOfState, - molefracs: PyReadonlyArray1<'py, f64>, + composition: &Bound<'py, PyAny>, min_temperature: Temperature, npoints: usize, critical_temperature: Option, @@ -915,7 +878,7 @@ impl PyPhaseDiagram { ) -> PyResult { let dia = PhaseDiagram::dew_point_line( &eos.0, - &parse_molefracs(Some(molefracs)).unwrap(), + Compositions::try_from(Some(composition))?, min_temperature, npoints, critical_temperature, @@ -934,8 +897,8 @@ impl PyPhaseDiagram { /// ---------- /// eos: Eos /// The equation of state. - /// molefracs: np.ndarray[float] - /// The composition of the mixture. + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float] + /// Composition of the mixture. /// min_temperature: SINumber /// The lower limit for the temperature. /// npoints: int @@ -956,13 +919,13 @@ impl PyPhaseDiagram { /// PhaseDiagram #[staticmethod] #[pyo3( - text_signature = "(eos, molefracs, min_temperature, npoints, critical_temperature=None, max_iter=None, tol=None, verbosity=None)" + text_signature = "(eos, composition, min_temperature, npoints, critical_temperature=None, max_iter=None, tol=None, verbosity=None)" )] - #[pyo3(signature = (eos, molefracs, min_temperature, npoints, critical_temperature=None, max_iter=None, tol=None, verbosity=None))] + #[pyo3(signature = (eos, composition, min_temperature, npoints, critical_temperature=None, max_iter=None, tol=None, verbosity=None))] #[expect(clippy::too_many_arguments)] pub(crate) fn spinodal<'py>( eos: &PyEquationOfState, - molefracs: PyReadonlyArray1<'py, f64>, + composition: &Bound<'py, PyAny>, min_temperature: Temperature, npoints: usize, critical_temperature: Option, @@ -972,7 +935,7 @@ impl PyPhaseDiagram { ) -> PyResult { let dia = PhaseDiagram::spinodal( &eos.0, - &parse_molefracs(Some(molefracs)).unwrap(), + Compositions::try_from(Some(composition))?, min_temperature, npoints, critical_temperature, diff --git a/py-feos/src/state.rs b/py-feos/src/state.rs index 6d5ff09f8..761055a3b 100644 --- a/py-feos/src/state.rs +++ b/py-feos/src/state.rs @@ -110,7 +110,7 @@ impl PyState { eos: &PyEquationOfState, temperature: Option, volume: Option, - density: Option, + mut density: Option, composition: Option<&Bound<'py, PyAny>>, pressure: Option, molar_enthalpy: Option, @@ -119,7 +119,15 @@ impl PyState { density_initialization: Option<&Bound<'py, PyAny>>, initial_temperature: Option, ) -> PyResult { - let composition = Compositions::try_from(composition)?; + // partial density is supported here as a special case + let composition = if let Some(composition) = composition + && let Ok(rho) = composition.extract::>>() + { + density = Some(rho.sum()); + Compositions::Molefracs(rho.convert_into(density.unwrap())) + } else { + Compositions::try_from(composition)? + }; let density_init = if let Some(di) = density_initialization { if let Ok(d) = di.extract::().as_deref() { match d { @@ -203,9 +211,8 @@ impl PyState { /// ---------- /// eos: EquationOfState /// The equation of state to use. - /// molefracs: np.ndarray[float], optional - /// Molar composition. - /// Only optional for a pure component. + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional + /// Composition of the mixture. /// initial_temperature: SINumber, optional /// The initial temperature. /// max_iter : int, optional @@ -325,9 +332,8 @@ impl PyState { /// The equation of state to use. /// temperature: SINumber /// The temperature. - /// molefracs: np.ndarray[float], optional - /// Molar composition. - /// Only optional for a pure component. + /// composition : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float], optional + /// Composition of the mixture. /// max_iter : int, optional /// The maximum number of iterations. /// tol: float, optional @@ -834,8 +840,11 @@ impl PyState { /// ------- /// SINumber #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn entropy(&self, contributions: PyContributions) -> Entropy { - self.0.entropy(contributions.into()) + fn entropy(&self, contributions: PyContributions) -> PyResult { + self.0 + .entropy(contributions.into()) + .map_err(PyFeosError::from) + .map_err(PyErr::from) } /// Return derivative of molar entropy with respect to temperature. @@ -891,8 +900,11 @@ impl PyState { /// ------- /// SINumber #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn enthalpy(&self, contributions: PyContributions) -> Energy { - self.0.enthalpy(contributions.into()) + fn enthalpy(&self, contributions: PyContributions) -> PyResult { + self.0 + .enthalpy(contributions.into()) + .map_err(PyFeosError::from) + .map_err(PyErr::from) } /// Return molar enthalpy. @@ -932,8 +944,11 @@ impl PyState { /// ------- /// SINumber #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn helmholtz_energy(&self, contributions: PyContributions) -> Energy { - self.0.helmholtz_energy(contributions.into()) + fn helmholtz_energy(&self, contributions: PyContributions) -> PyResult { + self.0 + .helmholtz_energy(contributions.into()) + .map_err(PyFeosError::from) + .map_err(PyErr::from) } /// Return molar Helmholtz energy. @@ -973,8 +988,11 @@ impl PyState { /// ------- /// SINumber #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn gibbs_energy(&self, contributions: PyContributions) -> Energy { - self.0.gibbs_energy(contributions.into()) + fn gibbs_energy(&self, contributions: PyContributions) -> PyResult { + self.0 + .gibbs_energy(contributions.into()) + .map_err(PyFeosError::from) + .map_err(PyErr::from) } /// Return molar Gibbs energy. @@ -1005,8 +1023,11 @@ impl PyState { /// ------- /// SINumber #[pyo3(signature = (contributions=PyContributions::Total), text_signature = "($self, contributions)")] - fn internal_energy(&self, contributions: PyContributions) -> Energy { - self.0.internal_energy(contributions.into()) + fn internal_energy(&self, contributions: PyContributions) -> PyResult { + self.0 + .internal_energy(contributions.into()) + .map_err(PyFeosError::from) + .map_err(PyErr::from) } /// Return molar internal energy. @@ -1111,8 +1132,11 @@ impl PyState { /// Returns /// ------- /// SIArray1 - fn mass(&self) -> Mass> { - self.0.mass() + fn mass(&self) -> PyResult>> { + self.0 + .mass() + .map_err(PyFeosError::from) + .map_err(PyErr::from) } /// Returns system's total mass. @@ -1120,8 +1144,11 @@ impl PyState { /// Returns /// ------- /// SINumber - fn total_mass(&self) -> Mass { - self.0.total_mass() + fn total_mass(&self) -> PyResult { + self.0 + .total_mass() + .map_err(PyFeosError::from) + .map_err(PyErr::from) } /// Returns system's mass density. @@ -1346,8 +1373,11 @@ impl PyState { } #[getter] - fn get_total_moles(&self) -> Moles { - self.0.total_moles() + fn get_total_moles(&self) -> PyResult { + self.0 + .total_moles() + .map_err(PyFeosError::from) + .map_err(PyErr::from) } #[getter] @@ -1356,8 +1386,11 @@ impl PyState { } #[getter] - fn get_volume(&self) -> Volume { - self.0.volume() + fn get_volume(&self) -> PyResult { + self.0 + .volume() + .map_err(PyFeosError::from) + .map_err(PyErr::from) } #[getter] @@ -1366,8 +1399,11 @@ impl PyState { } #[getter] - fn get_moles(&self) -> Moles> { - self.0.moles() + fn get_moles(&self) -> PyResult>> { + self.0 + .moles() + .map_err(PyFeosError::from) + .map_err(PyErr::from) } #[getter] @@ -1541,8 +1577,11 @@ impl PyStateVec { } #[getter] - fn get_moles(&self) -> Moles> { - StateVec::from(self).moles() + fn get_moles(&self) -> PyResult>> { + StateVec::from(self) + .moles() + .map_err(PyFeosError::from) + .map_err(PyErr::from) } #[getter] From daa26a755567c2b0afc5fe1524ac24815cb39a0f Mon Sep 17 00:00:00 2001 From: Philipp Rehner <69816385+prehner@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:28:56 +0200 Subject: [PATCH 06/11] Implement ph and ps flashes for binary mixtures (#338) --- CHANGELOG.md | 5 +- crates/feos-core/src/equation_of_state/mod.rs | 115 +++-- crates/feos-core/src/lib.rs | 4 +- crates/feos-core/src/phase_equilibria/mod.rs | 42 +- .../src/phase_equilibria/px_flashes.rs | 465 ++++++++++++++++++ crates/feos-core/src/state/composition.rs | 31 +- crates/feos-core/src/state/mod.rs | 66 +-- crates/feos-core/src/state/properties.rs | 18 +- crates/feos-derive/src/ideal_gas.rs | 2 +- crates/feos-dft/src/profile/properties.rs | 2 +- crates/feos/src/ideal_gas/joback.rs | 34 +- crates/feos/src/multiparameter/mod.rs | 4 +- crates/feos/tests/pcsaft/mod.rs | 1 + crates/feos/tests/pcsaft/px_flashes.rs | 138 ++++++ crates/feos/tests/pcsaft/tp_flash.rs | 14 +- docs/recipes/index.md | 1 + .../recipes_phase_equilibrium_flash.ipynb | 149 ++++++ py-feos/src/phase_equilibria.rs | 152 ++++++ 18 files changed, 1110 insertions(+), 133 deletions(-) create mode 100644 crates/feos-core/src/phase_equilibria/px_flashes.rs create mode 100644 crates/feos/tests/pcsaft/px_flashes.rs create mode 100644 docs/recipes/recipes_phase_equilibrium_flash.ipynb diff --git a/CHANGELOG.md b/CHANGELOG.md index 513a7cc1e..3d69100a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rewrote `PhaseEquilibrium::pure_p` to mirror `pure_t` and enabled automatic differentiation. [#337](https://github.com/feos-org/feos/pull/337) - Added `boiling_temperature` to the list of properties for parallel evaluations of gradients. [#337](https://github.com/feos-org/feos/pull/337) - Added the `Composition` trait to allow more flexibility in the creation of states and phase equilibria. [#330](https://github.com/feos-org/feos/pull/330) +- Added `PhaseEquilibrium::ph_flash` and `PhaseEquilibrium::ps_flash`. [#338](https://github.com/feos-org/feos/pull/338) +- Added getters for `vapor_phase_fraction`, `molar_enthalpy`, `molar_entropy`, `total_moles`, `enthalpy`, and `entropy` to `PhaseEquilibrium`. [#338](https://github.com/feos-org/feos/pull/338) ### Changed - Removed any assumptions about the total number of moles in a `State` or `PhaseEquilibrium`. Evaluating extensive properties now returns a `Result`. [#330](https://github.com/feos-org/feos/pull/330) +- Redesigned the `IdealGas` trait and added `IdealGasAD` in analogy to `ResidualDyn` and `Residual`. [#330](https://github.com/feos-org/feos/pull/330) ### Removed - Removed the `StateBuilder` struct, because it is mostly obsolete with the addition of the `Composition` trait. [#330](https://github.com/feos-org/feos/pull/330) ### Packaging -- Updated `quantity` dependency to 0.13 and removed the `typenum` dependency. [#323](https://github.com/feos-org/feos/pull/323) +- Updated `quantity` dependency to 0.13 and removed the `typenum` dependency. [#328](https://github.com/feos-org/feos/pull/328) ## [Unreleased] ### Added diff --git a/crates/feos-core/src/equation_of_state/mod.rs b/crates/feos-core/src/equation_of_state/mod.rs index dc849068f..14e45c07a 100644 --- a/crates/feos-core/src/equation_of_state/mod.rs +++ b/crates/feos-core/src/equation_of_state/mod.rs @@ -1,7 +1,7 @@ use crate::ReferenceSystem; use crate::state::StateHD; use nalgebra::{ - Const, DVector, DefaultAllocator, Dim, Dyn, OVector, SVector, U1, allocator::Allocator, + Const, DVector, DefaultAllocator, Dim, Dyn, OVector, SVector, allocator::Allocator, }; use num_dual::DualNum; use quantity::{Dimensionless, MolarEnergy, MolarVolume, Temperature}; @@ -114,11 +114,28 @@ impl, D>, D: DualNum + Copy, const N: usize> } /// Ideal gas Helmholtz energy contribution. -pub trait IdealGas { +pub trait IdealGas { /// Implementation of an ideal gas model in terms of the /// logarithm of the cubic thermal de Broglie wavelength /// in units ln(A³) for each component in the system. - fn ln_lambda3 + Copy>(&self, temperature: D2) -> D2; + fn ln_lambda3 + Copy>(&self, temperature: D) -> D; + + /// The name of the ideal gas model. + fn ideal_gas_model(&self) -> &'static str; +} + +/// Ideal gas Helmholtz energy contribution with automatic differentiation with +/// respect to parameters. +pub trait IdealGasAD: Clone { + type Real: IdealGasAD; + type Lifted + Copy>: IdealGasAD; + fn re(&self) -> Self::Real; + fn lift + Copy>(&self) -> Self::Lifted; + + /// Implementation of an ideal gas model in terms of the + /// logarithm of the cubic thermal de Broglie wavelength + /// in units ln(A³) for each component in the system. + fn ln_lambda3(&self, temperature: D) -> D; /// The name of the ideal gas model. fn ideal_gas_model(&self) -> &'static str; @@ -129,45 +146,44 @@ pub trait Total + Copy = f64>: Residual where DefaultAllocator: Allocator, { - type IdealGas: IdealGas; + type RealTotal: Total; + type LiftedTotal + Copy>: Total; + fn re_total(&self) -> Self::RealTotal; + fn lift_total + Copy>(&self) -> Self::LiftedTotal; fn ideal_gas_model(&self) -> &'static str; - fn ideal_gas(&self) -> impl Iterator; - - fn ln_lambda3 + Copy>(&self, temperature: D2) -> OVector { - OVector::from_iterator_generic( - N::from_usize(self.components()), - U1, - self.ideal_gas().map(|i| i.ln_lambda3(temperature)), - ) - } + fn ln_lambda3(&self, temperature: D) -> OVector; - fn ideal_gas_molar_helmholtz_energy + Copy>( + fn ideal_gas_molar_helmholtz_energy( &self, - temperature: D2, - molar_volume: D2, - molefracs: &OVector, - ) -> D2 { + temperature: D, + molar_volume: D, + molefracs: &OVector, + ) -> D { let partial_density = molefracs / molar_volume; - let mut res = D2::from(0.0); - for (i, &r) in self.ideal_gas().zip(partial_density.iter()) { + let mut res = D::from(0.0); + for (&l, &r) in self + .ln_lambda3(temperature) + .iter() + .zip(partial_density.iter()) + { let ln_rho_m1 = if r.re() == 0.0 { - D2::from(0.0) + D::from(0.0) } else { r.ln() - 1.0 }; - res += r * (i.ln_lambda3(temperature) + ln_rho_m1) + res += r * (l + ln_rho_m1) } res * molar_volume * temperature } - fn ideal_gas_helmholtz_energy + Copy>( + fn ideal_gas_helmholtz_energy( &self, - temperature: Temperature, - volume: MolarVolume, - moles: &OVector, - ) -> MolarEnergy { + temperature: Temperature, + volume: MolarVolume, + moles: &OVector, + ) -> MolarEnergy { let total_moles = moles.sum(); let molefracs = moles / total_moles; let molar_volume = volume.into_reduced() / total_moles; @@ -180,32 +196,59 @@ where } impl< - I: IdealGas + Clone + 'static, + I: IdealGas + 'static, C: Deref, R>> + Clone, R: ResidualDyn + 'static, -> Total for C + D: DualNum + Copy, +> Total for C { - type IdealGas = I; + type RealTotal = Self; + type LiftedTotal + Copy> = Self; + fn re_total(&self) -> Self::RealTotal { + self.clone() + } + fn lift_total + Copy>(&self) -> Self::LiftedTotal { + self.clone() + } fn ideal_gas_model(&self) -> &'static str { self.ideal_gas[0].ideal_gas_model() } - fn ideal_gas(&self) -> impl Iterator { - self.ideal_gas.iter() + fn ln_lambda3(&self, temperature: D) -> DVector { + DVector::from_vec( + self.ideal_gas + .iter() + .map(|i| i.ln_lambda3(temperature)) + .collect(), + ) } } -impl + Clone, R: Residual, D>, D: DualNum + Copy, const N: usize> +impl, R: Residual, D>, D: DualNum + Copy, const N: usize> Total, D> for EquationOfState<[I; N], R> { - type IdealGas = I; + type RealTotal = EquationOfState<[I::Real; N], R::Real>; + type LiftedTotal + Copy> = + EquationOfState<[I::Lifted; N], R::Lifted>; + fn re_total(&self) -> Self::RealTotal { + EquationOfState::new( + self.ideal_gas.each_ref().map(|i| i.re()), + self.residual.re(), + ) + } + fn lift_total + Copy>(&self) -> Self::LiftedTotal { + EquationOfState::new( + self.ideal_gas.each_ref().map(|i| i.lift()), + self.residual.lift(), + ) + } fn ideal_gas_model(&self) -> &'static str { self.ideal_gas[0].ideal_gas_model() } - fn ideal_gas(&self) -> impl Iterator { - self.ideal_gas.iter() + fn ln_lambda3(&self, temperature: D) -> SVector { + SVector::from(self.ideal_gas.each_ref().map(|i| i.ln_lambda3(temperature))) } } diff --git a/crates/feos-core/src/lib.rs b/crates/feos-core/src/lib.rs index 48b8306f3..cc280cf0a 100644 --- a/crates/feos-core/src/lib.rs +++ b/crates/feos-core/src/lib.rs @@ -34,8 +34,8 @@ mod phase_equilibria; mod state; pub use ad::{ParametersAD, PropertiesAD}; pub use equation_of_state::{ - EntropyScaling, EquationOfState, IdealGas, Molarweight, NoResidual, Residual, ResidualDyn, - Subset, Total, + EntropyScaling, EquationOfState, IdealGas, IdealGasAD, Molarweight, NoResidual, Residual, + ResidualDyn, Subset, Total, }; pub use errors::{FeosError, FeosResult}; #[cfg(feature = "ndarray")] diff --git a/crates/feos-core/src/phase_equilibria/mod.rs b/crates/feos-core/src/phase_equilibria/mod.rs index 461a1a901..5ed15a767 100644 --- a/crates/feos-core/src/phase_equilibria/mod.rs +++ b/crates/feos-core/src/phase_equilibria/mod.rs @@ -10,7 +10,15 @@ use quantity::{Dimensionless, Energy, Entropy, MolarEnergy, MolarEntropy, Moles} use std::fmt; use std::fmt::Write; +// with empty lines to not mess up the order in the documentation +mod vle_pure; + mod bubble_dew; + +mod tp_flash; + +mod px_flashes; + #[cfg(feature = "ndarray")] mod phase_diagram_binary; #[cfg(feature = "ndarray")] @@ -18,8 +26,7 @@ mod phase_diagram_pure; #[cfg(feature = "ndarray")] mod phase_envelope; mod stability_analysis; -mod tp_flash; -mod vle_pure; + pub use bubble_dew::TemperatureOrPressure; #[cfg(feature = "ndarray")] pub use phase_diagram_binary::PhaseDiagramHetero; @@ -33,22 +40,25 @@ pub use phase_diagram_pure::PhaseDiagram; /// /// ## Contents /// +/// + [Pure component phase equilibria](#pure-component-phase-equilibria) /// + [Bubble and dew point calculations](#bubble-and-dew-point-calculations) -/// + [Heteroazeotropes](#heteroazeotropes) /// + [Flash calculations](#flash-calculations) -/// + [Pure component phase equilibria](#pure-component-phase-equilibria) +/// + [Heteroazeotropes](#heteroazeotropes) /// + [Utility functions](#utility-functions) #[derive(Debug, Clone)] pub struct PhaseEquilibrium + Copy = f64> where DefaultAllocator: Allocator, { - states: [State; P], + pub states: [State; P], pub phase_fractions: [D; P], total_moles: Option>, } -impl fmt::Display for PhaseEquilibrium { +impl, N: Dim, const P: usize> fmt::Display for PhaseEquilibrium +where + DefaultAllocator: Allocator, +{ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for (i, s) in self.states.iter().enumerate() { writeln!(f, "phase {i}: {s}")?; @@ -125,12 +135,12 @@ impl, N: Dim, D: DualNum + Copy> PhaseEquilibrium, { - pub(super) fn single_phase(state: State) -> Self { + pub fn single_phase(state: State) -> Self { let total_moles = state.total_moles; Self::with_vapor_phase_fraction(state.clone(), state, D::from(1.0), total_moles) } - pub(super) fn two_phase(vapor: State, liquid: State) -> Self { + pub fn two_phase(vapor: State, liquid: State) -> Self { let (beta, total_moles) = if let (Some(nv), Some(nl)) = (vapor.total_moles, liquid.total_moles) { (nv.convert_into(nl + nv), Some(nl + nv)) @@ -140,7 +150,7 @@ where Self::with_vapor_phase_fraction(vapor, liquid, beta, total_moles) } - pub(super) fn with_vapor_phase_fraction( + pub fn with_vapor_phase_fraction( vapor: State, liquid: State, vapor_phase_fraction: D, @@ -158,11 +168,7 @@ impl, N: Dim, D: DualNum + Copy> PhaseEquilibrium, { - pub(super) fn new( - vapor: State, - liquid1: State, - liquid2: State, - ) -> Self { + pub fn new(vapor: State, liquid1: State, liquid2: State) -> Self { Self { states: [vapor, liquid1, liquid2], phase_fractions: [D::from(1.0), D::from(0.0), D::from(0.0)], @@ -171,7 +177,7 @@ where } } -impl, N: Gradients, const P: usize, D: DualNum + Copy> +impl, N: Gradients, const P: usize, D: DualNum + Copy> PhaseEquilibrium where DefaultAllocator: Allocator, @@ -179,7 +185,13 @@ where pub fn total_moles(&self) -> FeosResult> { self.total_moles.ok_or(FeosError::IntensiveState) } +} +impl, N: Gradients, const P: usize, D: DualNum + Copy> + PhaseEquilibrium +where + DefaultAllocator: Allocator, +{ pub fn molar_enthalpy(&self) -> MolarEnergy { self.states .iter() diff --git a/crates/feos-core/src/phase_equilibria/px_flashes.rs b/crates/feos-core/src/phase_equilibria/px_flashes.rs new file mode 100644 index 000000000..aadeffb76 --- /dev/null +++ b/crates/feos-core/src/phase_equilibria/px_flashes.rs @@ -0,0 +1,465 @@ +#![expect(clippy::toplevel_ref_arg)] +use super::PhaseEquilibrium; +use crate::errors::FeosResult; +use crate::state::State; +use crate::{Composition, FeosError, ReferenceSystem, SolverOptions, Total, Verbosity}; +use nalgebra::allocator::Allocator; +use nalgebra::{DefaultAllocator, Dim, DimAdd, OVector, U1, U2, U3, stack, vector}; +use num_dual::linalg::LU; +use num_dual::{ + Dual, Dual64, DualNum, DualStruct, Gradients, first_derivative, implicit_derivative_sp, partial, +}; +use quantity::{Density, MolarEnergy, MolarEntropy, Pressure, Quantity, SIUnit, Temperature}; + +const MAX_ITER_PX: usize = 20; +const TOL_PX: f64 = 1e-11; + +type PXVars = >::Output; +type TPVars = >::Output; + +impl, N: Gradients + DimAdd + DimAdd, D: DualNum + Copy> + PhaseEquilibrium +where + DefaultAllocator: Allocator + + Allocator + + Allocator> + + Allocator> + + Allocator, PXVars> + + Allocator> + + Allocator> + + Allocator, TPVars>, + PXVars: Gradients, + TPVars: Gradients, +{ + /// Perform a ph-flash calculation. An initial temperature is required + /// and the system needs to be in the two-phase region at that initial + /// temperature. + /// + /// based on Michelsen's work [State function based flash specifications](https://doi.org/10.1016/S0378-3812(99)00092-8) + pub fn ph_flash>( + eos: &E, + pressure: Pressure, + molar_enthalpy: MolarEnergy, + feed: X, + initial_temperature: Temperature, + options: SolverOptions, + ) -> FeosResult { + PhaseEquilibrium::px_flash( + eos, + pressure, + molar_enthalpy, + feed, + initial_temperature, + options, + ) + } + + /// Perform a ps-flash calculation. An initial temperature is required + /// and the system needs to be in the two-phase region at that initial + /// temperature. + /// + /// based on Michelsen's work [State function based flash specifications](https://doi.org/10.1016/S0378-3812(99)00092-8) + pub fn ps_flash>( + eos: &E, + pressure: Pressure, + molar_entropy: MolarEntropy, + feed: X, + initial_temperature: Temperature, + options: SolverOptions, + ) -> FeosResult { + PhaseEquilibrium::px_flash( + eos, + pressure, + molar_entropy, + feed, + initial_temperature, + options, + ) + } + + // Generic implementation of ph and ps flashes. + fn px_flash, U: PXFlash>( + eos: &E, + pressure: Pressure, + specification: Quantity, + feed: X, + initial_temperature: Temperature, + options: SolverOptions, + ) -> FeosResult + where + Quantity: ReferenceSystem, + Quantity: ReferenceSystem, + { + let (max_iter, tol, verbosity) = options.unwrap_or(MAX_ITER_PX, TOL_PX); + let (molefracs, total_moles) = feed.into_molefracs(eos)?; + + // initialize with a tp flash + let eos_f64 = eos.re_total(); + let vle = PhaseEquilibrium::tp_flash( + &eos_f64, + initial_temperature, + pressure.re(), + molefracs.map(|x| x.re()), + None, + Default::default(), + None, + )?; + + // extract specifications + let p = pressure.into_reduced().re(); + let hs = specification.into_reduced().re(); + let z = molefracs.map(|x| x.re()); + let specs = (p, hs, z.clone()); + + // extract variables + let t = initial_temperature.into_reduced(); + let beta = vle.vapor_phase_fraction(); + let rho_v = vle.vapor().density.into_reduced(); + let rho_l = vle.liquid().partial_density().into_reduced(); + let mut vars = stack![rho_l; vector![t, beta, rho_v]]; + let mut old_res = None; + + log_iter!( + verbosity, + " iter | method | temperature | residual | phase I mole fractions | phase II mole fractions " + ); + log_iter!(verbosity, "{:-<102}", ""); + log_iter!( + verbosity, + " {:4} | | {:9.5} | | {:10.8?} | {:10.8?}", + 0, + Temperature::from_reduced(t), + (&rho_l / rho_l.sum() + (&z - &rho_l / rho_l.sum()) / beta).as_slice(), + (&rho_l / rho_l.sum()).as_slice(), + ); + + // iterate + for k in 0..max_iter { + // always try a Newton step first + let (grad, new_vars) = U::newton_step(&eos_f64, &vars, &specs)?; + let new_res = grad.norm(); + let (method, res) = if let Some(r) = old_res + && r < new_res + { + // if the residual is not reduced, reject the step and do a tp-flash instead + vars = U::tp_step(&eos_f64, &vars, &specs)?; + ("Tp-flash", None) + } else { + vars = new_vars; + ("Newton", Some(new_res)) + }; + + if let Verbosity::Iter = verbosity { + let (t, _, _, _, x, y) = unpack_variables(&z, &vars); + log_iter!( + verbosity, + " {:4} | {:^8} | {:9.5} | {} | {:10.8?} | {:10.8?}", + k + 1, + method, + Temperature::from_reduced(t), + res.map_or(String::from(" "), |r| format!("{r:14.8e}")), + y.as_slice(), + x.as_slice(), + ); + } + + if let Some(res) = res + && res < tol + { + log_result!( + verbosity, + "px flash: calculation converged in {} step(s)\n", + k + 1 + ); + + // implicit differentiation + let specs = ( + pressure.into_reduced(), + specification.into_reduced(), + molefracs.clone(), + ); + let vars = implicit_derivative_sp( + |variables, specifications| { + U::state_function(&eos.lift_total(), variables, specifications) + }, + vars, + &specs, + ); + let (t, beta, rho_l, rho_v, x, y) = unpack_variables(&molefracs, &vars); + + // store results in PhaseEquilibrium + let liquid = State::new( + eos, + Temperature::from_reduced(t), + Density::from_reduced(rho_l), + x, + )?; + let vapor = State::new( + eos, + Temperature::from_reduced(t), + Density::from_reduced(rho_v), + y, + )?; + return Ok(PhaseEquilibrium::with_vapor_phase_fraction( + vapor, + liquid, + beta, + total_moles, + )); + } + old_res = res; + } + Err(FeosError::NotConverged("px flash".to_owned())) + } +} + +fn unpack_variables + Copy, N: Dim + DimAdd>( + molefracs: &OVector, + variables: &OVector>, +) -> (D, D, D, D, OVector, OVector) +where + DefaultAllocator: Allocator + Allocator>, +{ + let n = molefracs.len(); + let rho_i_l = variables.rows_generic(0, N::from_usize(n)).clone_owned(); + let [[t, beta, rho_v]] = variables.rows_generic(n, U3).clone_owned().data.0; + let rho_l = rho_i_l.sum(); + let x = rho_i_l / rho_l; + let y = &x + (molefracs - &x) / beta; + (t, beta, rho_l, rho_v, x, y) +} + +fn unpack_tp_variables + Copy, N: Dim + DimAdd>( + molefracs: &OVector, + variables: &OVector>, +) -> (D, D, D, OVector, OVector) +where + DefaultAllocator: Allocator + Allocator>, +{ + let n = molefracs.len(); + let rho_i_l = variables.rows_generic(0, N::from_usize(n)).clone_owned(); + let [[beta, rho_v]] = variables.rows_generic(n, U2).clone_owned().data.0; + let rho_l = rho_i_l.sum(); + let x = rho_i_l / rho_l; + let y = &x + (molefracs - &x) / beta; + (beta, rho_l, rho_v, x, y) +} + +trait PXFlash: Sized + Copy { + // potential function for which the flash solution is a saddle point. + fn state_function, N: Dim + DimAdd, D: DualNum + Copy>( + eos: &E, + variables: OVector>, + args: &(D, D, OVector), + ) -> D + where + DefaultAllocator: Allocator + Allocator>; + + fn evaluate_property, N: Gradients, D: DualNum + Copy>( + vle: &PhaseEquilibrium, + ) -> Quantity + where + DefaultAllocator: Allocator; + + // the potential function for a tp-flash specification (Q = A + V*p_spec) + fn tp_state_function, N: Dim + DimAdd, D: DualNum + Copy>( + eos: &E, + variables: OVector>, + &(t, p, ref z): &(D, D, OVector), + ) -> D + where + DefaultAllocator: Allocator + Allocator>, + { + let (beta, rho_l, rho_v, x, y) = unpack_tp_variables(z, &variables); + let potential = |molefracs, rho: D, t| { + let v = rho.recip(); + let a_res = eos.residual_helmholtz_energy(t, v, &molefracs); + let a_ig = eos.ideal_gas_molar_helmholtz_energy(t, v, &molefracs); + a_res + a_ig + v * p + }; + potential(y, rho_v, t) * beta + potential(x, rho_l, t) * (-beta + 1.0) + } + + // An undamped Newton step for the gradients of the potential function. + // Because the ps and ph flashes are saddle points rather then extrema, + // the value of the potential can not be used as convergence criterion. + #[expect(clippy::type_complexity)] + fn newton_step, N: Dim + DimAdd, D: DualNum + Copy>( + eos: &E, + variables: &OVector>, + specifications: &(D, D, OVector), + ) -> FeosResult<(OVector>, OVector>)> + where + DefaultAllocator: Allocator + Allocator> + Allocator, PXVars>, + PXVars: Gradients, + { + let (_, grad, hess) = PXVars::::hessian( + |variables, specifications| { + Self::state_function(&eos.lift_total(), variables, specifications) + }, + variables, + specifications, + ); + let dx = LU::new(hess)?.solve(&grad); + Ok((grad, variables - &dx)) + } + + // A much slower but more robust step that calculates the implicit + // derivative of the temperature only (which is well behaved + // according to Michelsen) and then calculates all other variables + // from a tp-flash. + fn tp_step, N: Gradients + DimAdd + DimAdd>( + eos: &E, + variables: &OVector>, + &(p, hs_spec, ref z): &(f64, f64, OVector), + ) -> FeosResult>> + where + Quantity: ReferenceSystem, + DefaultAllocator: Allocator + + Allocator + + Allocator> + + Allocator> + + Allocator> + + Allocator, TPVars>, + TPVars: Gradients, + { + let (mut t, beta, rho_l, rho_v, x, y) = unpack_variables(z, variables); + let rho_i_l = rho_l * x; + let (hs, dhs) = first_derivative( + partial( + |t: Dual<_, _>, args: &(_, OVector<_, _>)| { + let &(p, ref z) = args; + let args = (t, p, z.clone_owned()); + + // implicit differentiation of the tp stationarity condition + // to obtain the derivative of the other variables w.r.t. t + let tp_vars = implicit_derivative_sp( + |variables, args| { + Self::tp_state_function(&eos.lift_total().lift_total(), variables, args) + }, + stack![rho_i_l; vector![beta, rho_v]], + &args, + ); + let (beta, rho_l, rho_v, x, y) = unpack_tp_variables(z, &tp_vars); + + // Evaluation of the enthalpy/entropy including the derivatives. + let liquid = State::new( + &eos.lift_total(), + Temperature::from_reduced(t), + Density::from_reduced(rho_l), + x, + )?; + let vapor = State::new( + &eos.lift_total(), + Temperature::from_reduced(t), + Density::from_reduced(rho_v), + y, + )?; + Ok::<_, FeosError>( + Self::evaluate_property(&PhaseEquilibrium::with_vapor_phase_fraction( + vapor, liquid, beta, None, + )) + .into_reduced(), + ) + }, + &(p, z.clone_owned()), + ), + t, + )?; + + // Newton step for the temperature + t -= (hs - hs_spec) / dhs; + + // pack variables into PhaseEquilibrium for initial values + let liquid = State::new_density( + eos, + Temperature::from_reduced(t), + Density::from_reduced(rho_i_l), + )?; + let vapor = State::new( + eos, + Temperature::from_reduced(t), + Density::from_reduced(rho_v), + y, + )?; + let vle = PhaseEquilibrium::with_vapor_phase_fraction(vapor, liquid, beta, None); + + // tp-flash for all other variables + let vle = PhaseEquilibrium::tp_flash( + eos, + Temperature::from_reduced(t), + Pressure::from_reduced(p), + z, + Some(&vle), + Default::default(), + None, + )?; + let beta = vle.vapor_phase_fraction(); + let rho_v = vle.vapor().density.into_reduced(); + let rho_l = vle.liquid().partial_density().into_reduced(); + Ok(stack![rho_l; vector![t, beta, rho_v]]) + } +} + +impl PXFlash for SIUnit<-2, 2, 1, 0, 0, -1, 0> { + // the potential function for a ph-flash specification (Q = (A + V*p_spec - H_spec) / T) + fn state_function, N: Dim + DimAdd, D: DualNum + Copy>( + eos: &E, + variables: OVector>, + &(p, h, ref z): &(D, D, OVector), + ) -> D + where + DefaultAllocator: Allocator + Allocator>, + { + let (t, beta, rho_l, rho_v, x, y) = unpack_variables(z, &variables); + let potential = |molefracs, rho: D, t| { + let v = rho.recip(); + let a_res = eos.residual_helmholtz_energy(t, v, &molefracs); + let a_ig = eos.ideal_gas_molar_helmholtz_energy(t, v, &molefracs); + (a_res + a_ig + v * p - h) / t + }; + potential(y, rho_v, t) * beta + potential(x, rho_l, t) * (-beta + 1.0) + } + + fn evaluate_property, N: Gradients, D: DualNum + Copy>( + vle: &PhaseEquilibrium, + ) -> Quantity + where + DefaultAllocator: Allocator, + { + vle.molar_enthalpy() + } +} + +impl PXFlash for SIUnit<-2, 2, 1, 0, -1, -1, 0> { + // the potential function for a ps-flash specification (Q = A + T*S_spec + V*p_spec) + fn state_function, N: Dim + DimAdd, D: DualNum + Copy>( + eos: &E, + variables: OVector>, + &(p, s, ref z): &(D, D, OVector), + ) -> D + where + DefaultAllocator: Allocator + Allocator>, + { + let (t, beta, rho_l, rho_v, x, y) = unpack_variables(z, &variables); + let potential = |molefracs, rho: D, t| { + let v = rho.recip(); + let a_res = eos.residual_helmholtz_energy(t, v, &molefracs); + let a_ig = eos.ideal_gas_molar_helmholtz_energy(t, v, &molefracs); + // Division by t.re() is done to ensure that the state function has the same + // units (and in conclusion same order of magnitude) as the ph state function. + // This allows using the same toelrances for both methods. + (a_res + a_ig + t * s + v * p) / t.re() + }; + potential(y, rho_v, t) * beta + potential(x, rho_l, t) * (-beta + 1.0) + } + + fn evaluate_property, N: Gradients, D: DualNum + Copy>( + vle: &PhaseEquilibrium, + ) -> Quantity + where + DefaultAllocator: Allocator, + { + vle.molar_entropy() + } +} diff --git a/crates/feos-core/src/state/composition.rs b/crates/feos-core/src/state/composition.rs index f6a190e33..973af8ffb 100644 --- a/crates/feos-core/src/state/composition.rs +++ b/crates/feos-core/src/state/composition.rs @@ -1,4 +1,3 @@ -use super::State; use crate::equation_of_state::Residual; use crate::{FeosError, FeosResult}; use nalgebra::allocator::Allocator; @@ -6,10 +5,27 @@ use nalgebra::{DefaultAllocator, Dim, Dyn, OVector, U1, U2, dvector, vector}; use num_dual::{DualNum, DualStruct}; use quantity::Moles; +/// Trait to generalize over different input types for the composition of +/// a state. +/// +/// The trait is implemented for the following data types: +/// +/// |components|input|total_moles?|comment| +/// |:-:|-|-|-| +/// |1|`()`|-|| +/// |1|`Moles`|✅| +/// |2|`f64`|-| +/// |N|`OVector`|-| +/// |N|`&OVector`|-| +/// |N|`OVector`|-|`Dyn` only| +/// |N|`&OVector`|-|`Dyn` only| +/// |N|`Moles>`|✅| +/// |N|`&Moles>`|✅| pub trait Composition + Copy, N: Dim> where DefaultAllocator: Allocator, { + /// Convert the composition into molefracs and total moles if possible. #[expect(clippy::type_complexity)] fn into_molefracs>( self, @@ -42,19 +58,6 @@ where } } -// copy the composition from a given state -impl + Copy, N: Dim> Composition for &State -where - DefaultAllocator: Allocator, -{ - fn into_molefracs>( - self, - _: &E1, - ) -> FeosResult<(OVector, Option>)> { - Ok(((self.molefracs.clone()), self.total_moles)) - } -} - // a pure component needs no specification impl + Copy> Composition for () { fn into_molefracs>( diff --git a/crates/feos-core/src/state/mod.rs b/crates/feos-core/src/state/mod.rs index 716468aa8..daa7a7ff4 100644 --- a/crates/feos-core/src/state/mod.rs +++ b/crates/feos-core/src/state/mod.rs @@ -306,17 +306,9 @@ where /// Return a new `State` for the combination of inputs. /// - /// The function attempts to create a new state using the given input values. If the state - /// is overdetermined, it will choose a method based on the following hierarchy. - /// 1. Create a state non-iteratively from the set of $T$, $V$, $\rho$, $\rho_i$, $N$, $N_i$ and $x_i$. - /// 2. Use a density iteration for a given pressure. - /// - /// The [StateBuilder] provides a convenient way of calling this function without the need to provide - /// all the optional input values. - /// /// # Errors /// - /// When the state cannot be created using the combination of inputs. + /// When the state cannot be created using the combination of inputs (is over- or underdetermined). pub fn build>( eos: &E, temperature: Temperature, @@ -417,18 +409,9 @@ where { /// Return a new `State` for the combination of inputs. /// - /// The function attempts to create a new state using the given input values. If the state - /// is overdetermined, it will choose a method based on the following hierarchy. - /// 1. Create a state non-iteratively from the set of $T$, $V$, $\rho$, $\rho_i$, $N$, $N_i$ and $x_i$. - /// 2. Use a density iteration for a given pressure. - /// 3. Determine the state using a Newton iteration from (in this order): $(p, h)$, $(p, s)$, $(T, h)$, $(T, s)$, $(V, u)$ - /// - /// The [StateBuilder] provides a convenient way of calling this function without the need to provide - /// all the optional input values. - /// /// # Errors /// - /// When the state cannot be created using the combination of inputs. + /// When the state cannot be created using the combination of inputs (is over- or underdetermined). #[expect(clippy::too_many_arguments)] pub fn build_full + Clone>( eos: &E, @@ -461,28 +444,33 @@ where match state { Some(state) => Ok(state), None => { - // Check if new state can be created using molar_enthalpy and temperature - if let (Some(p), Some(h)) = (pressure, molar_enthalpy) { - return State::new_nph(eos, p, h, composition, density_initialization, ti); - } - if let (Some(p), Some(s)) = (pressure, molar_entropy) { - return State::new_nps(eos, p, s, composition, density_initialization, ti); - } - if let (Some(t), Some(h)) = (temperature, molar_enthalpy) { - return State::new_nth(eos, t, h, composition, density_initialization); - } - if let (Some(t), Some(s)) = (temperature, molar_entropy) { - return State::new_nts(eos, t, s, composition, density_initialization); - } - if let (Some(u), Some(v)) = (molar_internal_energy, volume) { - let (molefracs, total_moles) = composition.into_molefracs(eos)?; - if let Some(n) = total_moles { - return State::new_nvu(eos, v, u, (molefracs, n), ti); + match ( + temperature, + pressure, + volume, + molar_enthalpy, + molar_entropy, + molar_internal_energy, + ) { + (Some(t), None, None, Some(h), None, None) => { + State::new_nth(eos, t, h, composition, density_initialization) + } + (Some(t), None, None, None, Some(s), None) => { + State::new_nts(eos, t, s, composition, density_initialization) + } + (None, Some(p), None, Some(h), None, None) => { + State::new_nph(eos, p, h, composition, density_initialization, ti) + } + (None, Some(p), None, None, Some(s), None) => { + State::new_nps(eos, p, s, composition, density_initialization, ti) + } + (None, None, Some(v), None, None, Some(u)) => { + State::new_nvu(eos, v, u, composition, ti) } + _ => Err(FeosError::UndeterminedState(String::from( + "Missing input parameters.", + ))), } - Err(FeosError::UndeterminedState(String::from( - "Missing input parameters.", - ))) } } } diff --git a/crates/feos-core/src/state/properties.rs b/crates/feos-core/src/state/properties.rs index fa0f53b4a..132bad936 100644 --- a/crates/feos-core/src/state/properties.rs +++ b/crates/feos-core/src/state/properties.rs @@ -20,7 +20,9 @@ where let ideal_gas = || { quantity::ad::gradient_copy( partial2( - |n: Dimensionless<_>, &t, &v| self.eos.ideal_gas_helmholtz_energy(t, v, &n), + |n: Dimensionless<_>, &t, &v| { + self.eos.lift_total().ideal_gas_helmholtz_energy(t, v, &n) + }, &self.temperature, &self.molar_volume, ), @@ -38,7 +40,7 @@ where quantity::ad::partial_hessian_copy( partial( |(n, t): (Dimensionless<_>, _), &v| { - self.eos.ideal_gas_helmholtz_energy(t, v, &n) + self.eos.lift_total().ideal_gas_helmholtz_energy(t, v, &n) }, &self.molar_volume, ), @@ -89,7 +91,7 @@ where let ideal_gas = || { -quantity::ad::first_derivative( partial2( - |t, &v, n| self.eos.ideal_gas_helmholtz_energy(t, v, n), + |t, &v, n| self.eos.lift_total().ideal_gas_helmholtz_energy(t, v, n), &self.molar_volume, &self.molefracs, ), @@ -115,7 +117,7 @@ where let ideal_gas = || { -quantity::ad::second_derivative( partial2( - |t, &v, n| self.eos.ideal_gas_helmholtz_energy(t, v, n), + |t, &v, n| self.eos.lift_total().ideal_gas_helmholtz_energy(t, v, n), &self.molar_volume, &self.molefracs, ), @@ -135,7 +137,7 @@ where let ideal_gas = || { -quantity::ad::third_derivative( partial2( - |t, &v, n| self.eos.ideal_gas_helmholtz_energy(t, v, n), + |t, &v, n| self.eos.lift_total().ideal_gas_helmholtz_energy(t, v, n), &self.molar_volume, &self.molefracs, ), @@ -176,7 +178,7 @@ where let ideal_gas = || { quantity::ad::zeroth_derivative( partial2( - |t, &v, n| self.eos.ideal_gas_helmholtz_energy(t, v, n), + |t, &v, n| self.eos.lift_total().ideal_gas_helmholtz_energy(t, v, n), &self.molar_volume, &self.molefracs, ), @@ -253,7 +255,9 @@ where if let Contributions::IdealGas | Contributions::Total = contributions { res.push(( self.eos.ideal_gas_model(), - self.eos.ideal_gas_molar_helmholtz_energy(t, v, &x), + self.eos + .lift_total() + .ideal_gas_molar_helmholtz_energy(t, v, &x), )); } if let Contributions::Residual | Contributions::Total = contributions { diff --git a/crates/feos-derive/src/ideal_gas.rs b/crates/feos-derive/src/ideal_gas.rs index 06196e82c..f9b5eb671 100644 --- a/crates/feos-derive/src/ideal_gas.rs +++ b/crates/feos-derive/src/ideal_gas.rs @@ -42,7 +42,7 @@ fn impl_ideal_gas( }); quote! { impl IdealGas for IdealGasModel { - fn ln_lambda3 + Copy>(&self, temperature: D) -> D { + fn ln_lambda3 + Copy>(&self, temperature: D) -> D { match self { #(#ln_lambda3,)* } diff --git a/crates/feos-dft/src/profile/properties.rs b/crates/feos-dft/src/profile/properties.rs index 6095fb172..d542b27da 100644 --- a/crates/feos-dft/src/profile/properties.rs +++ b/crates/feos-dft/src/profile/properties.rs @@ -184,7 +184,7 @@ where temperature: Dual64, density: &Array, ) -> Array { - let lambda = self.bulk.eos.ln_lambda3(temperature); + let lambda = self.bulk.eos.lift_total().ln_lambda3(temperature); let mut phi = Array::zeros(density.raw_dim().remove_axis(Axis(0))); for (i, rhoi) in density.outer_iter().enumerate() { phi += &rhoi.mapv(|rhoi| (lambda[i] + rhoi.ln() - 1.0) * rhoi); diff --git a/crates/feos/src/ideal_gas/joback.rs b/crates/feos/src/ideal_gas/joback.rs index 8f2279d5b..e30c630b2 100644 --- a/crates/feos/src/ideal_gas/joback.rs +++ b/crates/feos/src/ideal_gas/joback.rs @@ -1,12 +1,13 @@ //! Implementation of the ideal gas heat capacity (de Broglie wavelength) //! of [Joback and Reid, 1987](https://doi.org/10.1080/00986448708960487). use feos_core::parameter::{FromSegments, Parameters}; -use feos_core::{FeosResult, IdealGas, ReferenceSystem}; +use feos_core::{FeosResult, IdealGas, IdealGasAD, ReferenceSystem}; use nalgebra::DVector; use num_dual::*; use quantity::{MolarEntropy, Temperature}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::ops::Mul; /// Coefficients used in the Joback model. /// @@ -96,9 +97,9 @@ impl Joback { } } -impl + Copy> IdealGas for Joback { - fn ln_lambda3 + Copy>(&self, temperature: D2) -> D2 { - let [a, b, c, d, e] = self.0.each_ref().map(D2::from_inner); +impl + Copy> Joback { + fn ln_lambda3 + Copy + Mul>(&self, temperature: D2) -> D2 { + let [a, b, c, d, e] = self.0; let t = temperature; let t2 = t * t; let t4 = t2 * t2; @@ -115,6 +116,31 @@ impl + Copy> IdealGas for Joback { + (t / T0).ln() * a; (h - t * s) / (t * RGAS) + f } +} + +impl IdealGas for Joback { + fn ln_lambda3 + Copy>(&self, temperature: D) -> D { + self.ln_lambda3(temperature) + } + + fn ideal_gas_model(&self) -> &'static str { + "Ideal gas (Joback)" + } +} + +impl + Copy> IdealGasAD for Joback { + type Real = Joback; + type Lifted + Copy> = Joback; + fn re(&self) -> Self::Real { + Joback(self.0.each_ref().map(D::re)) + } + fn lift + Copy>(&self) -> Self::Lifted { + Joback(self.0.each_ref().map(D2::from_inner)) + } + + fn ln_lambda3(&self, temperature: D) -> D { + self.ln_lambda3(temperature) + } fn ideal_gas_model(&self) -> &'static str { "Ideal gas (Joback)" diff --git a/crates/feos/src/multiparameter/mod.rs b/crates/feos/src/multiparameter/mod.rs index b4a6c4f9f..fe30093ac 100644 --- a/crates/feos/src/multiparameter/mod.rs +++ b/crates/feos/src/multiparameter/mod.rs @@ -118,10 +118,10 @@ impl Subset for MultiParameter { } impl IdealGas for MultiParameterIdealGas { - fn ln_lambda3 + Copy>(&self, temperature: D2) -> D2 { + fn ln_lambda3 + Copy>(&self, temperature: D) -> D { let tau = temperature.recip() * self.tc; // bit of a hack to convert from phi^0 into ln Lambda^3 - let delta = D2::from(E / (6.02214076e-7 * self.rhoc)); + let delta = D::from(E / (6.02214076e-7 * self.rhoc)); self.terms.iter().map(|r| r.evaluate(delta, tau)).sum() } diff --git a/crates/feos/tests/pcsaft/mod.rs b/crates/feos/tests/pcsaft/mod.rs index 6193aca74..732e6c0c5 100644 --- a/crates/feos/tests/pcsaft/mod.rs +++ b/crates/feos/tests/pcsaft/mod.rs @@ -1,6 +1,7 @@ mod critical_point; mod dft; mod properties; +mod px_flashes; mod stability_analysis; mod state_creation_mixture; mod state_creation_pure; diff --git a/crates/feos/tests/pcsaft/px_flashes.rs b/crates/feos/tests/pcsaft/px_flashes.rs new file mode 100644 index 000000000..4cfb179b6 --- /dev/null +++ b/crates/feos/tests/pcsaft/px_flashes.rs @@ -0,0 +1,138 @@ +use approx::assert_relative_eq; +use feos::ideal_gas::Joback; +use feos::pcsaft::PcSaftBinary; +use feos_core::{ + Contributions, EquationOfState, FeosResult, IdealGasAD, ParametersAD, PhaseEquilibrium, + ReferenceSystem, SolverOptions, Verbosity, +}; +use nalgebra::U1; +use num_dual::{DualStruct, DualVec}; +use quantity::*; + +#[test] +fn test_ph_flash() -> FeosResult<()> { + let params = [ + [1.5, 3.4, 180.0, 2.2, 0.03, 2500., 2.0, 1.0], + [4.5, 3.6, 250.0, 1.2, 0.015, 1500., 1.0, 2.0], + ]; + let kij = 0.15; + let pcsaft = PcSaftBinary::new(params, kij); + let joback = [ + Joback([380., 0.0, 0.0, 0.0, 0.0]), + Joback([210., 0.0, 0.0, 0.0, 0.0]), + ]; + let eos = EquationOfState::new(joback.clone(), pcsaft); + let p = 50.0 * BAR; + let t0 = Some(500.0 * KELVIN); + let x = 0.3; + let dew = PhaseEquilibrium::dew_point(&eos, p, x, t0, None, Default::default())?; + let bubble = PhaseEquilibrium::bubble_point(&eos, p, x, t0, None, Default::default())?; + let h = 0.2 * dew.molar_enthalpy() + 0.8 * bubble.molar_enthalpy(); + let t0 = 0.8 * dew.vapor().temperature + 0.2 * bubble.vapor().temperature; + let options = SolverOptions { + verbosity: Verbosity::Iter, + ..Default::default() + }; + let vle = PhaseEquilibrium::ph_flash(&eos, p, h, x, t0, options)?; + println!("{vle}"); + println!("{h}\n{}", vle.molar_enthalpy()); + assert_relative_eq!(h, vle.molar_enthalpy(), max_relative = 1e-10); + + let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let joback_ad = joback.each_ref().map(|j| j.lift()); + let eos_ad = EquationOfState::new(joback_ad, pcsaft_ad); + let vle_ad = PhaseEquilibrium::ph_flash( + &eos_ad, + Pressure::from_inner(&p), + MolarEnergy::from_inner(&h), + DualVec::from_inner(&x), + t0, + Default::default(), + )?; + let [[dt]] = vle_ad + .vapor() + .temperature + .into_reduced() + .eps + .unwrap_generic(U1, U1) + .data + .0; + println!("{dt}"); + + let dkij = 1e-7; + let pcsaft_h = PcSaftBinary::new(params, kij + dkij); + let eos_h = EquationOfState::new(joback.clone(), pcsaft_h); + let vle_h = PhaseEquilibrium::ph_flash(&eos_h, p, h, x, t0, Default::default())?; + let dt_h = (vle_h.vapor().temperature - vle.vapor().temperature).into_reduced() / dkij; + println!("{dt_h}"); + assert_relative_eq!(dt, dt_h, max_relative = 1e-4); + + Ok(()) +} + +#[test] +fn test_ps_flash() -> FeosResult<()> { + let params = [ + [1.5, 3.4, 180.0, 2.2, 0.03, 2500., 2.0, 1.0], + [4.5, 3.6, 250.0, 1.2, 0.015, 1500., 1.0, 2.0], + ]; + let kij = 0.15; + let pcsaft = PcSaftBinary::new(params, kij); + let joback = [ + Joback([380., 0.0, 0.0, 0.0, 0.0]), + Joback([210., 0.0, 0.0, 0.0, 0.0]), + ]; + let eos = EquationOfState::new(joback.clone(), pcsaft); + let p = 50.0 * BAR; + println!( + "{}", + PhaseEquilibrium::bubble_point(&eos, 500.0 * KELVIN, 0.5, None, None, Default::default())? + .vapor() + .pressure(Contributions::Total) + ); + let t0 = Some(500.0 * KELVIN); + let x = 0.3; + let dew = PhaseEquilibrium::dew_point(&eos, p, x, t0, None, Default::default())?; + let bubble = PhaseEquilibrium::bubble_point(&eos, p, x, t0, None, Default::default())?; + let s = 0.2 * dew.molar_entropy() + 0.8 * bubble.molar_entropy(); + let t0 = 0.8 * dew.vapor().temperature + 0.2 * bubble.vapor().temperature; + let options = SolverOptions { + verbosity: Verbosity::Iter, + ..Default::default() + }; + let vle = PhaseEquilibrium::ps_flash(&eos, p, s, x, t0, options)?; + println!("{vle}"); + println!("{s}\n{}", vle.molar_entropy()); + assert_relative_eq!(s, vle.molar_entropy(), max_relative = 1e-10); + + let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let joback_ad = joback.each_ref().map(|j| j.lift()); + let eos_ad = EquationOfState::new(joback_ad, pcsaft_ad); + let vle_ad = PhaseEquilibrium::ps_flash( + &eos_ad, + Pressure::from_inner(&p), + MolarEntropy::from_inner(&s), + DualVec::from_inner(&x), + t0, + Default::default(), + )?; + let [[dt]] = vle_ad + .vapor() + .temperature + .into_reduced() + .eps + .unwrap_generic(U1, U1) + .data + .0; + println!("{dt}"); + + let dkij = 1e-7; + let pcsaft_h = PcSaftBinary::new(params, kij + dkij); + let eos_h = EquationOfState::new(joback.clone(), pcsaft_h); + let vle_h = PhaseEquilibrium::ps_flash(&eos_h, p, s, x, t0, Default::default())?; + let dt_h = (vle_h.vapor().temperature - vle.vapor().temperature).into_reduced() / dkij; + println!("{dt_h}"); + assert_relative_eq!(dt, dt_h, max_relative = 1e-4); + + Ok(()) +} diff --git a/crates/feos/tests/pcsaft/tp_flash.rs b/crates/feos/tests/pcsaft/tp_flash.rs index b31a14998..0f28e0c2e 100644 --- a/crates/feos/tests/pcsaft/tp_flash.rs +++ b/crates/feos/tests/pcsaft/tp_flash.rs @@ -32,15 +32,7 @@ fn test_tp_flash() -> FeosResult<()> { println!("{p_propane} {p_butane} {x1} {y1} {z1}"); let mix = PcSaft::new(read_params(vec!["propane", "butane"])?); let options = SolverOptions::new().max_iter(100).tol(1e-12); - let vle = PhaseEquilibrium::tp_flash( - &&mix, - t, - p, - &(dvector![z1, 1.0 - z1] * MOL), - None, - options, - None, - )?; + let vle = PhaseEquilibrium::tp_flash(&&mix, t, p, z1, None, options, None)?; println!( "x1: {}, y1: {}", vle.liquid().molefracs[0], @@ -85,7 +77,7 @@ fn test_tp_flash_zero_component() -> FeosResult<()> { &&eos_full, 300.0 * KELVIN, 1.2 * BAR, - &(dvector![0.0, 0.5, 0.5] * MOL), + dvector![0.0, 0.5, 0.5], None, options, None, @@ -94,7 +86,7 @@ fn test_tp_flash_zero_component() -> FeosResult<()> { &&eos_binary, 300.0 * KELVIN, 1.2 * BAR, - &(dvector![0.5, 0.5] * MOL), + dvector![0.5, 0.5], None, options, None, diff --git a/docs/recipes/index.md b/docs/recipes/index.md index 5a7a95086..65b3a340a 100644 --- a/docs/recipes/index.md +++ b/docs/recipes/index.md @@ -12,6 +12,7 @@ If you are looking for tutorials with explanations, see the [tutorials](/tutoria recipes_critical_point_pure recipes_p_sat_t_boil recipes_phase_equilibrium_pure + recipes_phase_equilibrium_flash recipes_phase_diagram_pure recipes_automatic_differentiation ``` diff --git a/docs/recipes/recipes_phase_equilibrium_flash.ipynb b/docs/recipes/recipes_phase_equilibrium_flash.ipynb new file mode 100644 index 000000000..60158b47f --- /dev/null +++ b/docs/recipes/recipes_phase_equilibrium_flash.ipynb @@ -0,0 +1,149 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "8bec74cc", + "metadata": {}, + "outputs": [], + "source": [ + "import si_units as si\n", + "import feos\n", + "\n", + "parameters = feos.Parameters.from_json(\n", + " substances=['methanol', '1-propanol'], \n", + " pure_path='../../parameters/pcsaft/gross2002.json'\n", + ")\n", + "ideal_gas_parameters = feos.Parameters.from_json(\n", + " substances=['methanol', '1-propanol'], \n", + " pure_path='../../parameters/ideal_gas/poling2000.json'\n", + ")\n", + "eos = feos.EquationOfState.pcsaft(parameters).dippr(ideal_gas_parameters)" + ] + }, + { + "cell_type": "markdown", + "id": "0ace1cfd", + "metadata": {}, + "source": [ + "## Tp-flash" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e11fa945", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " dew point pressure: 0.6356 bar\n", + "bubble point pressure: 0.9245 bar\n" + ] + }, + { + "data": { + "text/markdown": [ + "||temperature|density|molefracs|\n", + "|-|-|-|-|\n", + "|phase 1|350.00000 K|28.75751 mol/m³|[0.59847, 0.40153]|\n", + "|phase 2|350.00000 K|14.29164 kmol/m³|[0.28954, 0.71046]|\n" + ], + "text/plain": [ + "phase 0: T = 350.00000 K, ρ = 28.75751 mol/m³, x = [0.59847, 0.40153]\n", + "phase 1: T = 350.00000 K, ρ = 14.29164 kmol/m³, x = [0.28954, 0.71046]" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x1 = 0.4\n", + "temperature = 350*si.KELVIN\n", + "\n", + "p_bubble = feos.PhaseEquilibrium.bubble_point(eos, temperature, x1).liquid.pressure()\n", + "p_dew = feos.PhaseEquilibrium.dew_point(eos, temperature, x1).vapor.pressure()\n", + "print(f\"bubble point pressure: {p_bubble/si.BAR:.4} bar\")\n", + "print(f\" dew point pressure: {p_dew/si.BAR:.4} bar\")\n", + "\n", + "feos.PhaseEquilibrium.tp_flash(eos, temperature, 0.8*si.BAR, x1)" + ] + }, + { + "cell_type": "markdown", + "id": "73c81e37", + "metadata": {}, + "source": [ + "## ph-flash" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3acf88f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bubble point\ttemperature: 352.06 K\tenthalpy: -36.5900 kJ/mol\n", + " dew point\ttemperature: 361.09 K\tenthalpy: 3.9800 kJ/mol\n" + ] + }, + { + "data": { + "text/markdown": [ + "||temperature|density|molefracs|\n", + "|-|-|-|-|\n", + "|phase 1|360.47941 K|34.58586 mol/m³|[0.42362, 0.57638]|\n", + "|phase 2|360.47941 K|13.28059 kmol/m³|[0.17482, 0.82518]|\n" + ], + "text/plain": [ + "phase 0: T = 360.47941 K, ρ = 34.58586 mol/m³, x = [0.42362, 0.57638]\n", + "phase 1: T = 360.47941 K, ρ = 13.28059 kmol/m³, x = [0.17482, 0.82518]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x1 = 0.4\n", + "pressure = si.BAR\n", + "bubble = feos.PhaseEquilibrium.bubble_point(eos, pressure, x1, 300*si.KELVIN).liquid\n", + "dew = feos.PhaseEquilibrium.dew_point(eos, pressure, x1, 300*si.KELVIN).vapor\n", + "print(f\"bubble point\\ttemperature: {bubble.temperature/si.KELVIN:.2f} K\\tenthalpy: {bubble.molar_enthalpy()/(si.KILO*si.JOULE/si.MOL):8.4f} kJ/mol\")\n", + "print(f\" dew point\\ttemperature: {dew.temperature/si.KELVIN:.2f} K\\tenthalpy: {dew.molar_enthalpy()/(si.KILO*si.JOULE/si.MOL):8.4f} kJ/mol\")\n", + "\n", + "feos.PhaseEquilibrium.ph_flash(eos, pressure, 0*si.JOULE/si.MOL, x1, 356*si.KELVIN)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "feos_devel", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/py-feos/src/phase_equilibria.rs b/py-feos/src/phase_equilibria.rs index c6c857506..feaa38930 100644 --- a/py-feos/src/phase_equilibria.rs +++ b/py-feos/src/phase_equilibria.rs @@ -159,6 +159,128 @@ impl PyPhaseEquilibrium { )) } + /// Create a liquid and vapor state in equilibrium + /// for given pressure, enthalpy and feed composition. + /// + /// Can also be used to calculate liquid liquid phase separation. + /// + /// Parameters + /// ---------- + /// eos : EquationOfState + /// The equation of state. + /// pressure : SINumber + /// The system pressure. + /// molar_enthalpy : SINumber + /// The molar enthalpy of the system. + /// feed : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float] + /// Feed composition. + /// initial_temperature : SINumber + /// The system temperature. + /// max_iter : int, optional + /// The maximum number of iterations. + /// tol: float, optional + /// The solution tolerance. + /// verbosity : Verbosity, optional + /// The verbosity. + /// + /// Returns + /// ------- + /// PhaseEquilibrium + /// + /// Raises + /// ------ + /// RuntimeError + /// When pressure iteration fails or no phase equilibrium is found. + #[staticmethod] + #[pyo3( + text_signature = "(eos, pressure, molar_enthalpy, feed, initial_temperature, max_iter=None, tol=None, verbosity=None)" + )] + #[pyo3(signature = (eos, pressure, molar_enthalpy, feed, initial_temperature, max_iter=None, tol=None, verbosity=None))] + #[expect(clippy::too_many_arguments)] + pub(crate) fn ph_flash( + eos: &PyEquationOfState, + pressure: Pressure, + molar_enthalpy: MolarEnergy, + feed: &Bound<'_, PyAny>, + initial_temperature: Temperature, + max_iter: Option, + tol: Option, + verbosity: Option, + ) -> PyResult { + Ok(Self( + PhaseEquilibrium::ph_flash( + &eos.0, + pressure, + molar_enthalpy, + Compositions::try_from(Some(feed))?, + initial_temperature, + (max_iter, tol, verbosity.map(|v| v.into())).into(), + ) + .map_err(PyFeosError::from)?, + )) + } + + /// Create a liquid and vapor state in equilibrium + /// for given pressure, entropy and feed composition. + /// + /// Can also be used to calculate liquid liquid phase separation. + /// + /// Parameters + /// ---------- + /// eos : EquationOfState + /// The equation of state. + /// pressure : SINumber + /// The system pressure. + /// molar_entropy : SINumber + /// The molar entropy of the system. + /// feed : float | SINumber | numpy.ndarray[float] | SIArray1 | list[float] + /// Feed composition. + /// initial_temperature : SINumber + /// The system temperature. + /// max_iter : int, optional + /// The maximum number of iterations. + /// tol: float, optional + /// The solution tolerance. + /// verbosity : Verbosity, optional + /// The verbosity. + /// + /// Returns + /// ------- + /// PhaseEquilibrium + /// + /// Raises + /// ------ + /// RuntimeError + /// When pressure iteration fails or no phase equilibrium is found. + #[staticmethod] + #[pyo3( + text_signature = "(eos, pressure, molar_entropy, feed, initial_temperature, max_iter=None, tol=None, verbosity=None)" + )] + #[pyo3(signature = (eos, pressure, molar_entropy, feed, initial_temperature, max_iter=None, tol=None, verbosity=None))] + #[expect(clippy::too_many_arguments)] + pub(crate) fn ps_flash( + eos: &PyEquationOfState, + pressure: Pressure, + molar_entropy: MolarEntropy, + feed: &Bound<'_, PyAny>, + initial_temperature: Temperature, + max_iter: Option, + tol: Option, + verbosity: Option, + ) -> PyResult { + Ok(Self( + PhaseEquilibrium::ps_flash( + &eos.0, + pressure, + molar_entropy, + Compositions::try_from(Some(feed))?, + initial_temperature, + (max_iter, tol, verbosity.map(|v| v.into())).into(), + ) + .map_err(PyFeosError::from)?, + )) + } + /// Compute a phase equilibrium for given temperature /// or pressure and liquid mole fractions. /// @@ -345,6 +467,36 @@ impl PyPhaseEquilibrium { PyState(self.0.liquid().clone()) } + #[getter] + fn get_vapor_phase_fraction(&self) -> f64 { + self.0.vapor_phase_fraction() + } + + #[getter] + fn get_total_moles(&self) -> PyResult { + Ok(self.0.total_moles().map_err(PyFeosError::from)?) + } + + #[getter] + fn get_molar_enthalpy(&self) -> MolarEnergy { + self.0.molar_enthalpy() + } + + #[getter] + fn get_enthalpy(&self) -> PyResult { + Ok(self.0.enthalpy().map_err(PyFeosError::from)?) + } + + #[getter] + fn get_molar_entropy(&self) -> MolarEntropy { + self.0.molar_entropy() + } + + #[getter] + fn get_entropy(&self) -> PyResult { + Ok(self.0.entropy().map_err(PyFeosError::from)?) + } + /// Calculate the pure component vapor-liquid equilibria for all /// components in the system. /// From c543b2db06d3ffb1583919270486d0b552dfc07a Mon Sep 17 00:00:00 2001 From: Gernot Bauer Date: Fri, 20 Mar 2026 01:59:04 +0100 Subject: [PATCH 07/11] Rebase on development --- crates/feos-core/Cargo.toml | 5 +- crates/feos-core/src/ad/mod.rs | 104 +- .../src/ad/parameter_optimization/dataset.rs | 795 +++++++++++++++ .../src/ad/parameter_optimization/loss.rs | 69 ++ .../src/ad/parameter_optimization/mod.rs | 15 + .../src/ad/parameter_optimization/solver.rs | 903 +++++++++++++++++ crates/feos-core/src/lib.rs | 2 + crates/feos/src/pcsaft/eos/mod.rs | 20 +- crates/feos/src/pcsaft/eos/pcsaft_binary.rs | 66 +- crates/feos/src/pcsaft/eos/pcsaft_pure.rs | 50 +- py-feos/src/ad/mod.rs | 7 + py-feos/src/ad/solver.rs | 947 ++++++++++++++++++ py-feos/src/lib.rs | 21 +- 13 files changed, 2915 insertions(+), 89 deletions(-) create mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/loss.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/mod.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/solver.rs create mode 100644 py-feos/src/ad/solver.rs diff --git a/crates/feos-core/Cargo.toml b/crates/feos-core/Cargo.toml index f33cd1255..2250a07c9 100644 --- a/crates/feos-core/Cargo.toml +++ b/crates/feos-core/Cargo.toml @@ -25,7 +25,10 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } indexmap = { workspace = true, features = ["serde"] } rayon = { workspace = true, optional = true } +csv = { version = "1", optional = true } +typenum = { workspace = true } itertools = { workspace = true } +levenberg-marquardt = "0.15.0" [dev-dependencies] approx = { workspace = true } @@ -34,4 +37,4 @@ quantity = { workspace = true, features = ["approx"] } [features] default = [] ndarray = ["dep:ndarray", "quantity/ndarray"] -rayon = ["dep:rayon", "ndarray/rayon"] +rayon = ["dep:rayon", "ndarray/rayon", "dep:csv"] diff --git a/crates/feos-core/src/ad/mod.rs b/crates/feos-core/src/ad/mod.rs index f48fb153c..e0cc32625 100644 --- a/crates/feos-core/src/ad/mod.rs +++ b/crates/feos-core/src/ad/mod.rs @@ -1,10 +1,13 @@ -use crate::DensityInitialization::Liquid; +#[cfg(feature = "rayon")] +pub mod parameter_optimization; + use crate::density_iteration::density_iteration; +use crate::DensityInitialization::Liquid; use crate::{Composition, FeosResult, PhaseEquilibrium, ReferenceSystem, Residual}; use nalgebra::{Const, SVector, U1, U2}; #[cfg(feature = "rayon")] use ndarray::{Array1, Array2, ArrayView2, Zip}; -use num_dual::{Derivative, DualNum, DualSVec, DualStruct, first_derivative, partial2}; +use num_dual::{first_derivative, partial2, Derivative, DualNum, DualSVec, DualStruct}; use quantity::{Density, Pressure, Temperature}; #[cfg(feature = "rayon")] use quantity::{KELVIN, KILO, METER, MOL, PASCAL}; @@ -12,24 +15,64 @@ use quantity::{KELVIN, KILO, METER, MOL, PASCAL}; type Gradient = DualSVec; /// A model that can be evaluated with derivatives of its parameters. -pub trait ParametersAD: for<'a> From<&'a [f64]> + Residual> { - /// Return a mutable reference to the parameter named by `index` from the parameter set. - fn index_parameters_mut<'a, const P: usize>( - eos: &'a mut Self::Lifted>, - index: &str, - ) -> &'a mut Gradient

; +pub trait ParametersAD: Residual> { + /// Build the model by requesting each parameter by name. + /// + /// Call `f(name, differentiable)` for each parameter. The order of calls + /// defines the canonical parameter order. This is the single source of + /// truth for parameter names, ordering, differentiability, and mapping + /// to the internal model structure. + /// + /// Set `differentiable` to `false` for discrete or structurally fixed + /// parameters (e.g. critical parameters in cubics or association site counts). + fn build + Copy>( + f: impl FnMut(&'static str, bool) -> D, + ) -> Self::Lifted; - /// Return the parameters with the appropriate derivatives. - fn named_derivatives( - &self, - parameter_names: [&str; P], + /// Canonical parameter names in the order defined by [`build`](Self::build). + fn parameter_names() -> Vec<&'static str> { + let mut names = Vec::new(); + let _ = Self::build(|name, _| { + names.push(name); + 0.0_f64 + }); + names + } + + /// Parameter names that can be differentiated, in canonical order. + fn differentiable_parameters() -> Vec<&'static str> { + let mut names = Vec::new(); + let _ = Self::build(|name, differentiable| { + if differentiable { + names.push(name); + } + 0.0_f64 + }); + names + } + + /// Construct the model with derivative seeds for the `P` named parameters. + /// + /// - `parameter_values`: all parameter values in the canonical order + /// defined by [`build`](Self::build). + /// - `derivative_names`: names of the parameters to differentiate with + /// respect to. Gradient component `i` corresponds to + /// `derivative_names[i]`. + /// + fn seed_derivatives( + parameter_values: &[f64], + derivative_names: [&str; P], ) -> Self::Lifted> { - let mut eos = self.lift::>(); - for (i, p) in parameter_names.into_iter().enumerate() { - Self::index_parameters_mut(&mut eos, p).eps = - Derivative::derivative_generic(Const::

, U1, i) - } - eos + let mut idx = 0; + Self::build(|name, _differentiable| { + let i = idx; + idx += 1; + let mut d = Gradient::

::from(parameter_values[i]); + if let Some(seed_idx) = derivative_names.iter().position(|&n| n == name) { + d.eps = Derivative::derivative_generic(Const::

, U1, seed_idx); + } + d + }) } } @@ -376,20 +419,21 @@ where .par_map_collect(|par, inp| { let par = par.as_slice().expect("Parameter array is not contiguous!"); let inp = inp.as_slice().expect("Input array is not contiguous!"); - let eos = E::from(par).named_derivatives(parameter_names); + let eos = E::seed_derivatives(par, parameter_names); f(&eos, inp) }); + let n = parameters.nrows(); let status = value_dual.iter().map(|p| p.is_ok()).collect(); - let value_dual: Array1<_> = value_dual.into_iter().flatten().collect(); - let mut value = Array1::zeros(value_dual.len()); - let mut grad = Array2::zeros([value_dual.len(), P]); - Zip::from(grad.rows_mut()) - .and(&mut value) - .and(&value_dual) - .for_each(|mut grad, p, p_dual| { - *p = p_dual.re; - let eps = p_dual.eps.unwrap_generic(Const::

, U1).data.0[0].to_vec(); - grad.assign(&Array1::from(eps)); - }); + let mut value = Array1::from_elem(n, f64::NAN); + let mut grad = Array2::zeros([n, P]); + for (i, result) in value_dual.into_iter().enumerate() { + if let Ok(p_dual) = result { + value[i] = p_dual.re; + let eps = p_dual.eps.unwrap_generic(Const::

, U1); + for (g, &e) in grad.row_mut(i).iter_mut().zip(eps.data.0[0].iter()) { + *g = e; + } + } + } (value, grad, status) } diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset.rs b/crates/feos-core/src/ad/parameter_optimization/dataset.rs new file mode 100644 index 000000000..e7ae6fe05 --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/dataset.rs @@ -0,0 +1,795 @@ +use crate::{ParametersAD, PropertiesAD}; +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::{io, path::Path}; + +/// Per-dataset evaluation result: inputs, experimental target, model +/// prediction, and derived statistics at a given set of parameters. +/// +/// Produced by [`crate::Regressor::evaluate_datasets`]. +/// Fully serializable and convertablec into any tabular format (CSV, JSON, DataFrame). +#[derive(Debug, Serialize)] +pub struct DatasetResult { + /// Dataset name (default property name or user-supplied). + pub name: String, + /// Input column names and their values. + /// In the order they appear in record struct of Dataset. + pub inputs: Vec<(&'static str, Vec)>, + /// Name of the target property column. + pub target_name: &'static str, + /// Experimental target values. + pub target: Vec, + /// Values at the given parameters predicted by the model. + /// `NaN` for points where calculations did not converge. + pub predicted: Vec, + /// Whether the calculation converged for each point. + pub converged: Vec, + /// Relative deviation `(predicted − target) / target`. + /// `NaN` for non-converged points. + pub relative_deviation: Vec, +} + +/// Container for experimental data that can be evaluated +/// with models that implement [`ParametersAD`]. +pub trait Dataset { + /// Record of the property (row in a CSV file) + type Record: DeserializeOwned; + + /// Dataset from a vector of records. + fn from_records(records: Vec) -> Self; + + /// Parse a CSV file and build the dataset. + /// + /// Column headers must match the field names of [`Self::Record`]. + fn from_csv(path: &Path) -> Result + where + Self: Sized, + { + let records = csv::Reader::from_path(path)? + .deserialize() + .collect::, _>>()?; + Ok(Self::from_records(records)) + } + + /// Parse CSV data from sources that implement [`std::io::Read`]. + /// + /// Useful for reading from in memory buffers, network streams, + /// or compressed sources. + /// + /// Column headers must match the field names of [`Self::Record`]. + fn from_reader(reader: R) -> Result + where + Self: Sized, + { + let records = csv::Reader::from_reader(reader) + .deserialize() + .collect::, _>>()?; + Ok(Self::from_records(records)) + } + + /// Inputs for (parallel) model evaluation, shape `[n_points, k]`. + fn inputs(&self) -> ArrayView2<'_, f64>; + + /// Target values, shape `[n_points]`. + fn target(&self) -> ArrayView1<'_, f64>; + + /// Property name used for logging and diagnostics. + fn name(&self) -> &str; + + /// Names of the input columns, in the order they appear in [`Self::inputs`]. + /// + /// These match the non-target field names of [`Self::Record`]. + fn input_names() -> &'static [&'static str] + where + Self: Sized; + + /// Name of the target property column. + /// + /// Matches the target field name of [`Self::Record`] (e.g. + /// `"vapor_pressure_pa"` for [`VaporPressureDataset`]). + fn target_name() -> &'static str + where + Self: Sized; + + /// Method for model evaluation of this property. + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1); + + /// Evaluate the property and its parameter gradients at the given parameters. + /// + /// - `param_names`: names of the `P` model parameters that are differentiated. + /// Length determines the compile-time `P` through runtime dispatch. + /// - `params`: the full parameter vector (not just the fitted subset). + /// `seed_derivatives` seeds only the entries listed in `param_names`. + /// + /// Returns `(predicted, gradients, converged)`: + /// - `predicted`: shape `[n_points]`, computed property values in SI units. + /// - `gradients`: shape `[n_points, P]`, ∂property/∂param for each point. + /// - `converged`: shape `[n_points]`, `true` where the property calculation + /// succeeded. + /// + /// Non-converged state points yield NaN for predictions and zero for gradients. + fn compute>( + &self, + param_names: &[String], + params: &[f64], + ) -> (Array1, Array2, Array1) { + let n = self.inputs().nrows(); + + // Repeat parameters for each data point. + let parameters = Array2::from_shape_fn((n, params.len()), |(_, j)| params[j]); + + // Translate slice to const array for the const-generic call. + fn to_const(names: &[String]) -> [String; P] { + names.to_vec().try_into().expect("parameter count mismatch") + } + + match param_names.len() { + 1 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 2 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 3 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 4 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 5 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 6 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 7 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 8 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + n => panic!("too many parameters: {n} (max 8)"), + } + } +} + +/// Record for vapor pressure data. +/// +/// Expected column headers: `temperature_k`, `vapor_pressure_pa`. +#[derive(Deserialize, Serialize)] +pub struct VaporPressureRecord { + pub temperature_k: f64, + pub vapor_pressure_pa: f64, +} + +/// Vapor pressure dataset for pure substances. +/// +/// Inputs: `[[T1], [T2], ...]` (temperature in K) +/// Target: vapor pressure in Pa +#[derive(Clone)] +pub struct VaporPressureDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl Dataset<1> for VaporPressureDataset { + type Record = VaporPressureRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 1), |(i, _)| records[i].temperature_k); + let target = Array1::from_iter(records.iter().map(|r| r.vapor_pressure_pa)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("vapor pressure") + } + + fn input_names() -> &'static [&'static str] { + &["temperature_k"] + } + + fn target_name() -> &'static str { + "vapor_pressure_pa" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::vapor_pressure_parallel(names, parameters, inputs) + } +} + +/// Liquid density record at given temperature and pressure. +/// +/// Expected column headers: `temperature_k`, `pressure_pa`, `liquid_density_molm3`. +#[derive(Deserialize, Serialize)] +pub struct LiquidDensityRecord { + pub temperature_k: f64, + pub pressure_pa: f64, + pub liquid_density_molm3: f64, +} + +/// Liquid density dataset for pure substances at given temperature and pressure. +/// +/// Inputs: `[[T1, p1], [T2, p2], ...]` (K, Pa) +/// Target: molar density in kmol/m³ +#[derive(Clone)] +pub struct LiquidDensityDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl Dataset<1> for LiquidDensityDataset { + type Record = LiquidDensityRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 2), |(i, j)| match j { + 0 => records[i].temperature_k, + _ => records[i].pressure_pa, + }); + let target = Array1::from_iter(records.iter().map(|r| r.liquid_density_molm3)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("liquid density") + } + + fn input_names() -> &'static [&'static str] { + &["temperature_k", "pressure_pa"] + } + + fn target_name() -> &'static str { + "liquid_density_molm3" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::liquid_density_parallel(names, parameters, inputs) + } +} + +/// Equilibrium liquid density record (saturated liquid). +/// +/// Expected column headers: `temperature_k`, `liquid_density_molm3`. +#[derive(Deserialize, Serialize)] +pub struct EquilibriumLiquidDensityRecord { + pub temperature_k: f64, + pub liquid_density_molm3: f64, +} + +/// Liquid density (at equilibrium) dataset for pure substances. +/// +/// Inputs: `[[T1], [T2], ...]` (temperature in K) +/// Target: molar density in kmol/m³ +#[derive(Clone)] +pub struct EquilibriumLiquidDensityDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl Dataset<1> for EquilibriumLiquidDensityDataset { + type Record = EquilibriumLiquidDensityRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 1), |(i, _)| records[i].temperature_k); + let target = Array1::from_iter(records.iter().map(|r| r.liquid_density_molm3)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("equilibrium liquid density") + } + + fn input_names() -> &'static [&'static str] { + &["temperature_k"] + } + + fn target_name() -> &'static str { + "liquid_density_molm3" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::equilibrium_liquid_density_parallel(names, parameters, inputs) + } +} + +/// Bubble point pressure record for binary mixtures. +/// +/// Expected column headers: `temperature_k`, `liquid_molefrac_1`, +/// `bubble_pressure_pa`. The pressure column doubles as the initial guess +/// passed to the VLE solver. +#[derive(Deserialize, Serialize)] +pub struct BubblePointRecord { + pub temperature_k: f64, + pub liquid_molefrac_1: f64, + pub bubble_pressure_pa: f64, +} + +/// Bubble point pressure dataset for binary mixtures. +/// +/// Inputs: `[[T1, x11, p1], ...]` (K, -, Pa) +/// Target: bubble point pressure in Pa +#[derive(Clone)] +pub struct BubblePointDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl Dataset<2> for BubblePointDataset { + type Record = BubblePointRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 3), |(i, j)| match j { + 0 => records[i].temperature_k, + 1 => records[i].liquid_molefrac_1, + _ => records[i].bubble_pressure_pa, + }); + let target = Array1::from_iter(records.iter().map(|r| r.bubble_pressure_pa)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("bubble point pressure") + } + + fn input_names() -> &'static [&'static str] { + &["temperature_k", "liquid_molefrac_1"] + } + + fn target_name() -> &'static str { + "bubble_pressure_pa" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::bubble_point_pressure_parallel(names, parameters, inputs) + } +} + +/// Dew point pressure record for binary mixtures. +/// +/// Expected column headers: `temperature_k`, `vapor_molefrac_1`, +/// `dew_pressure_pa`. The pressure column doubles as the initial guess +/// passed to the VLE solver. +#[derive(Deserialize, Serialize)] +pub struct DewPointRecord { + pub temperature_k: f64, + pub vapor_molefrac_1: f64, + pub dew_pressure_pa: f64, +} + +/// Dew point pressure dataset for binary mixtures. +/// +/// Inputs: `[[T1, y11, p1], ...]` (K, -, Pa) +/// Target: dew point pressure in Pa +#[derive(Clone)] +pub struct DewPointDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl Dataset<2> for DewPointDataset { + type Record = DewPointRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 3), |(i, j)| match j { + 0 => records[i].temperature_k, + 1 => records[i].vapor_molefrac_1, + _ => records[i].dew_pressure_pa, + }); + let target = Array1::from_iter(records.iter().map(|r| r.dew_pressure_pa)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("dew point pressure") + } + + fn input_names() -> &'static [&'static str] { + &["temperature_k", "vapor_molefrac_1"] + } + + fn target_name() -> &'static str { + "dew_pressure_pa" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::dew_point_pressure_parallel(names, parameters, inputs) + } +} + +macro_rules! impl_with_name { + ($($ty:ty),+ $(,)?) => { + $( + impl $ty { + /// Override the dataset name used in solver diagnostics and results. + /// + /// The name must be unique within a solver — [`Solver::new`] returns + /// [`FittingError::DuplicateDatasetName`] if two datasets share the + /// same name (whether default or user-supplied). + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + } + )+ + }; +} + +impl_with_name!( + VaporPressureDataset, + LiquidDensityDataset, + EquilibriumLiquidDensityDataset, + BubblePointDataset, + DewPointDataset, +); + +/// Collection of pure-component datasets. +/// +/// Wraps any [`Dataset<1>`] implementor so that mixed-property lists can be +/// stored in a `Vec` without requiring trait objects. +#[derive(Clone)] +pub enum PureDataset { + VaporPressure(VaporPressureDataset), + LiquidDensity(LiquidDensityDataset), + EquilibriumLiquidDensity(EquilibriumLiquidDensityDataset), +} + +impl PureDataset { + pub fn target(&self) -> ArrayView1<'_, f64> { + match self { + Self::VaporPressure(d) => d.target(), + Self::LiquidDensity(d) => d.target(), + Self::EquilibriumLiquidDensity(d) => d.target(), + } + } + + pub fn name(&self) -> &str { + match self { + Self::VaporPressure(d) => d.name(), + Self::LiquidDensity(d) => d.name(), + Self::EquilibriumLiquidDensity(d) => d.name(), + } + } + + pub fn inputs(&self) -> ArrayView2<'_, f64> { + match self { + Self::VaporPressure(d) => d.inputs(), + Self::LiquidDensity(d) => d.inputs(), + Self::EquilibriumLiquidDensity(d) => d.inputs(), + } + } + + pub fn input_names(&self) -> &'static [&'static str] { + match self { + Self::VaporPressure(_) => VaporPressureDataset::input_names(), + Self::LiquidDensity(_) => LiquidDensityDataset::input_names(), + Self::EquilibriumLiquidDensity(_) => EquilibriumLiquidDensityDataset::input_names(), + } + } + + pub fn target_name(&self) -> &'static str { + match self { + Self::VaporPressure(_) => VaporPressureDataset::target_name(), + Self::LiquidDensity(_) => LiquidDensityDataset::target_name(), + Self::EquilibriumLiquidDensity(_) => EquilibriumLiquidDensityDataset::target_name(), + } + } + + pub fn compute>( + &self, + param_names: &[String], + params: &[f64], + ) -> (Array1, Array2, Array1) { + match self { + Self::VaporPressure(d) => d.compute::(param_names, params), + Self::LiquidDensity(d) => d.compute::(param_names, params), + Self::EquilibriumLiquidDensity(d) => d.compute::(param_names, params), + } + } +} + +/// Collection of binary mixture datasets. +/// +/// Wraps any [`Dataset<2>`] implementor so that mixed-property lists can be +/// stored in a `Vec` without requiring trait objects. +#[derive(Clone)] +pub enum BinaryDataset { + BubblePoint(BubblePointDataset), + DewPoint(DewPointDataset), +} + +impl BinaryDataset { + pub fn target(&self) -> ArrayView1<'_, f64> { + match self { + Self::BubblePoint(d) => d.target(), + Self::DewPoint(d) => d.target(), + } + } + + pub fn name(&self) -> &str { + match self { + Self::BubblePoint(d) => d.name(), + Self::DewPoint(d) => d.name(), + } + } + + pub fn inputs(&self) -> ArrayView2<'_, f64> { + match self { + Self::BubblePoint(d) => d.inputs(), + Self::DewPoint(d) => d.inputs(), + } + } + + pub fn input_names(&self) -> &'static [&'static str] { + match self { + Self::BubblePoint(_) => BubblePointDataset::input_names(), + Self::DewPoint(_) => DewPointDataset::input_names(), + } + } + + pub fn target_name(&self) -> &'static str { + match self { + Self::BubblePoint(_) => BubblePointDataset::target_name(), + Self::DewPoint(_) => DewPointDataset::target_name(), + } + } + + pub fn compute>( + &self, + param_names: &[String], + params: &[f64], + ) -> (Array1, Array2, Array1) { + match self { + Self::BubblePoint(d) => d.compute::(param_names, params), + Self::DewPoint(d) => d.compute::(param_names, params), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + // Helper: build a Cursor from a string literal. + fn csv(s: &str) -> Cursor<&[u8]> { + Cursor::new(s.as_bytes()) + } + + #[test] + fn vapor_pressure_from_reader() { + let data = "\ +temperature_k,vapor_pressure_pa +300.0,3540.0 +350.0,41682.0 +400.0,245600.0 +"; + let ds = VaporPressureDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.target().len(), 3); + assert_eq!(ds.inputs().nrows(), 3); + assert_eq!(ds.inputs().ncols(), 1); + + // temperatures land in the single input column + assert_eq!(ds.inputs()[[0, 0]], 300.0); + assert_eq!(ds.inputs()[[1, 0]], 350.0); + assert_eq!(ds.inputs()[[2, 0]], 400.0); + + // experimental values + assert_eq!(ds.target()[0], 3540.0); + assert_eq!(ds.target()[1], 41682.0); + assert_eq!(ds.target()[2], 245600.0); + + assert_eq!(ds.name(), "vapor pressure"); + } + + #[test] + fn vapor_pressure_from_records() { + let records = vec![ + VaporPressureRecord { + temperature_k: 300.0, + vapor_pressure_pa: 3540.0, + }, + VaporPressureRecord { + temperature_k: 350.0, + vapor_pressure_pa: 41682.0, + }, + ]; + let ds = VaporPressureDataset::from_records(records); + assert_eq!(ds.inputs()[[0, 0]], 300.0); + assert_eq!(ds.target()[1], 41682.0); + } + + #[test] + fn liquid_density_from_reader() { + let data = "\ +temperature_k,pressure_pa,liquid_density_molm3 +300.0,101325.0,15.2 +320.0,200000.0,14.8 +"; + let ds = LiquidDensityDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.inputs().nrows(), 2); + assert_eq!(ds.inputs().ncols(), 2); + + // column 0: temperature, column 1: pressure + assert_eq!(ds.inputs()[[0, 0]], 300.0); + assert_eq!(ds.inputs()[[0, 1]], 101325.0); + assert_eq!(ds.inputs()[[1, 0]], 320.0); + assert_eq!(ds.inputs()[[1, 1]], 200000.0); + + assert_eq!(ds.target()[0], 15.2); + assert_eq!(ds.target()[1], 14.8); + + assert_eq!(ds.name(), "liquid density"); + } + + #[test] + fn liquid_density_from_records() { + let records = vec![LiquidDensityRecord { + temperature_k: 300.0, + pressure_pa: 101325.0, + liquid_density_molm3: 15.2, + }]; + let ds = LiquidDensityDataset::from_records(records); + assert_eq!(ds.inputs()[[0, 1]], 101325.0); + assert_eq!(ds.target()[0], 15.2); + } + + #[test] + fn equilibrium_liquid_density_from_reader() { + let data = "\ +temperature_k,liquid_density_molm3 +290.0,15.5 +310.0,14.9 +330.0,14.1 +"; + let ds = EquilibriumLiquidDensityDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.inputs().ncols(), 1); + assert_eq!(ds.inputs().nrows(), 3); + assert_eq!(ds.inputs()[[2, 0]], 330.0); + assert_eq!(ds.target()[2], 14.1); + assert_eq!(ds.name(), "equilibrium liquid density"); + } + + #[test] + fn bubble_point_from_reader() { + let data = "\ +temperature_k,liquid_molefrac_1,bubble_pressure_pa +300.0,0.3,500000.0 +320.0,0.5,800000.0 +"; + let ds = BubblePointDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.inputs().ncols(), 3); + assert_eq!(ds.inputs().nrows(), 2); + + // column 0: T, column 1: x1, column 2: pressure (initial guess) + assert_eq!(ds.inputs()[[0, 0]], 300.0); + assert_eq!(ds.inputs()[[0, 1]], 0.3); + assert_eq!(ds.inputs()[[0, 2]], 500000.0); + + // experimental = bubble pressure + assert_eq!(ds.target()[0], 500000.0); + assert_eq!(ds.target()[1], 800000.0); + + assert_eq!(ds.name(), "bubble point pressure"); + } + + #[test] + fn dew_point_from_reader() { + let data = "\ +temperature_k,vapor_molefrac_1,dew_pressure_pa +310.0,0.7,400000.0 +330.0,0.9,700000.0 +"; + let ds = DewPointDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.inputs().ncols(), 3); + assert_eq!(ds.inputs()[[0, 0]], 310.0); + assert_eq!(ds.inputs()[[0, 1]], 0.7); + assert_eq!(ds.inputs()[[0, 2]], 400000.0); + assert_eq!(ds.target()[1], 700000.0); + assert_eq!(ds.name(), "dew point pressure"); + } + + #[test] + fn dew_point_pressure_doubles_as_initial_guess() { + let records = vec![DewPointRecord { + temperature_k: 310.0, + vapor_molefrac_1: 0.7, + dew_pressure_pa: 400000.0, + }]; + let ds = DewPointDataset::from_records(records); + assert_eq!(ds.inputs()[[0, 2]], ds.target()[0]); + } + + #[test] + fn missing_column_returns_error() { + // CSV is missing the vapor_pressure_pa column entirely. + let data = "temperature_k\n300.0\n"; + let result = VaporPressureDataset::from_reader(csv(data)); + assert!(result.is_err()); + } + + #[test] + fn wrong_type_returns_error() { + // pressure is not a float + let data = "temperature_k,vapor_pressure_pa\n300.0,not_a_number\n"; + let result = VaporPressureDataset::from_reader(csv(data)); + assert!(result.is_err()); + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/loss.rs b/crates/feos-core/src/ad/parameter_optimization/loss.rs new file mode 100644 index 000000000..35f2decde --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/loss.rs @@ -0,0 +1,69 @@ +/// Loss function applied to the dimensionless relative residual +/// `r = (calc − exp) / exp` during LM fitting. +#[derive(Debug, Clone)] +pub enum LossFunction { + /// L2 loss (all weights are 1.0) + L2, + /// Huber loss with dimensionless threshold `delta`. + /// + /// Points with `|r_rel| ≤ delta` are treated as L2. + /// Beyond that, the effective residual is `sqrt(delta * |r_rel|)` (linear loss). + Huber { delta: f64 }, +} + +impl Default for LossFunction { + fn default() -> Self { + Self::L2 + } +} + +impl LossFunction { + /// IRLS weight `w = ρ'(r) / r`. + /// + /// - L2: 1.0 + /// - Huber: `min(delta / |r|, 1.0)` + pub fn irls_weight(&self, r: f64) -> f64 { + match self { + LossFunction::L2 => 1.0, + LossFunction::Huber { delta } => (delta / r.abs()).min(1.0), + } + } + + /// Square root of weight function. + pub fn irls_weight_sqrt(&self, r: f64) -> f64 { + self.irls_weight(r).sqrt() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn huber_within_threshold() { + // inside threshold, huber is L2 and weight is 1.0 + let loss = LossFunction::Huber { delta: 0.1 }; + assert_eq!(loss.irls_weight(0.05), 1.0); + assert_eq!(loss.irls_weight(0.1), 1.0); + assert_eq!(loss.irls_weight_sqrt(0.1), 1.0); + } + + #[test] + fn huber_beyond_threshold() { + // above threshold, weight is decreased. + let loss = LossFunction::Huber { delta: 0.1 }; + // |r| = 0.2 → w = 0.1/0.2 = 0.5 + let w = loss.irls_weight(0.2); + assert!((w - 0.5).abs() < 1e-12); + // sqrt variant + let ws = loss.irls_weight_sqrt(0.2); + assert!((ws - 0.5f64.sqrt()).abs() < 1e-12); + } + + #[test] + fn huber_negative_residual() { + // w(-r) has to be w(r) + let loss = LossFunction::Huber { delta: 0.1 }; + assert_eq!(loss.irls_weight(-0.2), loss.irls_weight(0.2)); + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/mod.rs b/crates/feos-core/src/ad/parameter_optimization/mod.rs new file mode 100644 index 000000000..534ebfd4c --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/mod.rs @@ -0,0 +1,15 @@ +pub mod dataset; +pub mod loss; +pub mod solver; + +pub use dataset::{ + BinaryDataset, BubblePointDataset, BubblePointRecord, Dataset, DatasetResult, + DewPointDataset, DewPointRecord, EquilibriumLiquidDensityDataset, + EquilibriumLiquidDensityRecord, LiquidDensityDataset, LiquidDensityRecord, PureDataset, + VaporPressureDataset, VaporPressureRecord, +}; +pub use loss::LossFunction; +pub use solver::{ + BinaryRegressor, DynSolver, FittingError, NonConvergenceStrategy, PureRegressor, Regressor, + FitConfig, FitResult, +}; diff --git a/crates/feos-core/src/ad/parameter_optimization/solver.rs b/crates/feos-core/src/ad/parameter_optimization/solver.rs new file mode 100644 index 000000000..e5f5ddd4e --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/solver.rs @@ -0,0 +1,903 @@ +use std::collections::HashMap; +use std::marker::PhantomData; + +use levenberg_marquardt::{LeastSquaresProblem, LevenbergMarquardt, MinimizationReport}; +use nalgebra::{DMatrix, DVector, Dyn, Owned}; +use ndarray::{Array1, Array2, s}; +use thiserror::Error; + +use crate::ParametersAD; + +use super::dataset::{BinaryDataset, DatasetResult, PureDataset}; +use super::loss::LossFunction; + +/// Error kinds for parameter fitting and data handling. +#[derive(Debug, Error)] +pub enum FittingError { + #[error("at least one dataset must be provided")] + EmptyDatasets, + + #[error("unknown parameter '{name}', valid names: {valid:?}")] + UnknownParameter { + name: String, + valid: Vec<&'static str>, + }, + + #[error("missing initial value for parameter '{name}'")] + MissingParameter { name: String }, + + #[error( + "parameter '{name}' has initial value 0.0; \ + the solver normalizes by dividing through the initial value, \ + so zero freezes the parameter. Use a small non-zero starting \ + value instead." + )] + ZeroParameter { name: String }, + + #[error("parameter '{name}' appears more than once")] + DuplicateParameter { name: String }, + + #[error( + "parameter '{name}' is not differentiable; \ + differentiable parameters: {differentiable:?}" + )] + NonDifferentiableParameter { + name: String, + differentiable: Vec<&'static str>, + }, + + #[error( + "dataset name '{name}' is used more than once; \ + please assign a unique name to each dataset" + )] + DuplicateDatasetName { name: String }, +} + +/// Variants for how to deal with non-converged calculations. +#[derive(Debug, Clone)] +pub enum NonConvergenceStrategy { + /// Exclude non-converged points from the optimisation entirely. + /// Both the residual and the Jacobian row are set to zero. + Ignore, + + /// Assign a fixed dimensionless relative residual to non-converged points. + /// E.g. `Penalty(10.0)` corresponds to a 1000 % relative error. + /// The Jacobian row is zero which creates a residual/Jacobian mismatch + /// might be an issue for the trust region when convergence changes discontinuously. + Penalty(f64), + + /// Scale the penalty by `factor × max |r_rel|` of currently converged + /// points. Non-converged points always look proportionally worse than + /// the worst converged one. Falls back to `factor` when no points have + /// converged. + AdaptivePenalty(f64), +} + +impl Default for NonConvergenceStrategy { + fn default() -> Self { + Self::Penalty(10.0) + } +} + +/// Levenberg-Marquardt solver hyperparameters. +#[derive(Debug, Clone)] +pub struct FitConfig { + /// Terminate when the actual and predicted relative reductions in the + /// objective function are both ≤ `ftol`. Default: 1e-8. + pub ftol: f64, + /// Terminate when the relative change in parameters between + /// iterations is ≤ `xtol`. Default: 1e-8. + pub xtol: f64, + /// Terminate when the residual vector and every Jacobian column are nearly + /// orthogonal, i.e. the cosine of the angle between `r` and each `J[:,j]` + /// is ≤ `gtol`. This is a scale-invariant first-order optimality check. + /// Set to `0.0` to disable. Default: 1e-8. + pub gtol: f64, + /// Factor for the initial trust-region step bound. + /// Should lie in `[0.1, 100]`. Smaller values are more conservative and + /// prevent the large initial steps that can cause VLE solvers to diverge. + /// Default: 0.1. + pub stepbound: f64, + /// Maximum number of residual evaluations = `patience × (n_params + 1)`. + /// Default: 100. + pub patience: usize, + /// Rescale parameters internally using the running maximum of each + /// Jacobian column norm. Default: true. + pub scale_diag: bool, + /// Strategy for non-converged calculations. + /// Default: [`NonConvergenceStrategy::Penalty(10.0)`]. + pub strategy: NonConvergenceStrategy, +} + +impl Default for FitConfig { + fn default() -> Self { + Self { + ftol: 1e-8, + xtol: 1e-8, + gtol: 1e-8, + stepbound: 0.1, + patience: 100, + scale_diag: true, + strategy: NonConvergenceStrategy::default(), + } + } +} + +/// Post-fit diagnostics produced by [`Regressor::to_result`]. +/// +/// For model evaluation against the stored data (predictions, per-dataset +/// comparison, Fisher information), use [`Regressor::evaluate_datasets`] by +/// passing [`FitResult::all_parameters`] back to the regressor. +pub struct FitResult { + /// Optimal physical parameters in canonical order. + /// Includes fitted and non-fitted parameters. + pub optimal_params: Vec, + /// Names of the parameters that were optimised. + pub fitted_param_names: Vec, + /// All canonical parameter names in order as defined by the model. + pub all_param_names: Vec, + /// Per-dataset average absolute relative deviation in percent. + /// `None` if no data points converged for that dataset. + pub aad_per_dataset: Vec>, + /// Dataset names in order. + pub dataset_names: Vec, + /// Per-dataset `(n_converged, n_total)`. + pub convergence_stats: Vec<(usize, usize)>, + /// Whether LM reported successful convergence. + pub converged: bool, + /// Description of why the optimiser stopped. + pub termination_reason: String, + /// Number of residual evaluations performed by LM. + pub n_evaluations: usize, + /// Final LM objective. + pub objective_function: f64, + /// Wall-clock time spent inside LM. + pub elapsed: std::time::Duration, +} + +impl FitResult { + /// All parameters as `{name: value}`. + /// + /// Fitted parameters at their optimal values, all others at their initial values. + /// Pass this directly to [`Regressor::evaluate_datasets`] to get per-dataset + /// predictions at the fitted optimum. + pub fn all_parameters(&self) -> HashMap { + self.all_param_names + .iter() + .zip(self.optimal_params.iter()) + .map(|(name, &val)| (name.clone(), val)) + .collect() + } + + /// Optimal parameters for the fitted subset as `{name: value}`. + pub fn optimal_params_dict(&self) -> HashMap { + self.fitted_param_names + .iter() + .filter_map(|name| { + self.all_param_names + .iter() + .position(|n| n == name) + .map(|idx| (name.clone(), self.optimal_params[idx])) + }) + .collect() + } +} + +/// Levenberg-Marquardt fitting problem. +pub struct Regressor { + /// Collections of experimental data. + pub(crate) datasets: Vec, + /// A single weight per dataset applied + /// to all residuals and Jacobian entries for that set. + pub(crate) weights: Vec, + /// Parameter names in canonical order. + pub(crate) param_names: Vec, + /// Indices of parameters that are adjusted. + pub(crate) fitted_indices: Vec, + /// All parameters. + pub(crate) base_params: Vec, + /// Scaling factor to switch between EoS input and + /// optimizer input. + pub(crate) normalization: Vec, + /// Loss function applied to residuals. + pub(crate) loss: LossFunction, + /// Strategy for dealing with non-converged calculations. + pub(crate) strategy: NonConvergenceStrategy, + /// Parameter storage for the solver. + pub(crate) params: DVector, + /// Cached predictions, i.e. EoS evaluations. Per dataset. + pub(crate) cached_predicted: Vec>, + /// Cached gradients of EoS evaluations w.r.t. EoS parameters. + /// Per Dataset. + pub(crate) cached_gradients: Vec>, + /// Cached convergence-status of each data point. Per Dataset. + pub(crate) cached_converged: Vec>, + /// Cached IRLS weight. + pub(crate) cached_loss_w_sqrt: Vec>, + /// Penalty for non-converged points, updated by `update_cache`. + /// For `Penalty(p)` this is always `p`; for `AdaptivePenalty(f)` it + /// tracks `f × max |r_rel|` over the most recent converged points. + pub(crate) cached_penalty: f64, + pub(crate) _phantom: PhantomData, +} + +pub type PureRegressor = Regressor; +pub type BinaryRegressor = Regressor; + +/// Empty placeholder needed by [`DynSolver::fit`] for mem::take. +impl Default for Regressor { + fn default() -> Self { + Self { + datasets: Vec::new(), + weights: Vec::new(), + param_names: Vec::new(), + fitted_indices: Vec::new(), + base_params: Vec::new(), + normalization: Vec::new(), + loss: LossFunction::default(), + strategy: NonConvergenceStrategy::default(), + params: DVector::zeros(0), + cached_predicted: Vec::new(), + cached_gradients: Vec::new(), + cached_converged: Vec::new(), + cached_loss_w_sqrt: Vec::new(), + cached_penalty: 0.0, + _phantom: PhantomData, + } + } +} + +impl Regressor { + /// Set per-dataset weights (default 1.0 for all). + /// + /// Weights are not normalized in this function. + /// + /// # Panics + /// + /// Panics if `weights.len()` does not match the number of datasets, or if + /// any weight is not positive. + pub fn with_weights(mut self, weights: Vec) -> Self { + assert_eq!(weights.len(), self.datasets.len()); + assert!( + weights.iter().all(|&w| w > 0.0), + "all dataset weights must be positive, got: {weights:?}" + ); + self.weights = weights; + self + } + + /// Optimal physical parameters with non-fitted entries at their initial values. + pub fn optimal_params(&self) -> Vec { + let mut result = self.base_params.clone(); + for (j, &idx) in self.fitted_indices.iter().enumerate() { + result[idx] = self.params[j] * self.normalization[j]; + } + result + } + + /// Predicted values across all datasets at current parameters. + /// + /// Non-converged entries are `NaN`; use [`Regressor::convergence_mask`] to filter. + pub fn predicted(&self) -> Array1 { + let n_total: usize = self.cached_predicted.iter().map(|p| p.len()).sum(); + let mut out = Array1::zeros(n_total); + let mut offset = 0; + for pred in &self.cached_predicted { + let n = pred.len(); + out.slice_mut(s![offset..offset + n]).assign(pred); + offset += n; + } + out + } + + /// Convergence mask across all datasets. + pub fn convergence_mask(&self) -> Array1 { + let n_total: usize = self.cached_converged.iter().map(|c| c.len()).sum(); + let mut out = Array1::from_elem(n_total, false); + let mut offset = 0; + for conv in &self.cached_converged { + let n = conv.len(); + out.slice_mut(s![offset..offset + n]).assign(conv); + offset += n; + } + out + } + + /// Per-dataset `(n_converged, n_total)`. + pub fn convergence_stats(&self) -> Vec<(usize, usize)> { + self.cached_converged + .iter() + .map(|conv| (conv.iter().filter(|&&c| c).count(), conv.len())) + .collect() + } +} + +macro_rules! impl_solver { + ($n:literal, $dataset:ty) => { + impl> Regressor { + /// Construct a new solver. + /// + /// - `datasets`: one or more property datasets. + /// - `params`: full initial parameter set keyed by name. + /// - `fit`: names of the parameters to optimise. + pub fn new( + datasets: Vec<$dataset>, + params: HashMap, + fit: &[&str], + ) -> Result { + if datasets.is_empty() { + return Err(FittingError::EmptyDatasets); + } + + // Reject duplicate dataset names + let mut seen_names = std::collections::HashSet::new(); + for d in &datasets { + if !seen_names.insert(d.name().to_string()) { + return Err(FittingError::DuplicateDatasetName { + name: d.name().to_string(), + }); + } + } + + // Get parameter names from model in canonical order (expected by model) + let canonical = T::parameter_names(); + let differentiable = T::differentiable_parameters(); + + // Collect indices of parameters that are fitted + let mut fitted_indices = Vec::with_capacity(fit.len()); + for &name in fit { + if fitted_indices.iter().any(|&prev| canonical[prev] == name) { + return Err(FittingError::DuplicateParameter { + name: name.to_string(), + }); + } + let i = canonical.iter().position(|&n| n == name).ok_or_else(|| { + FittingError::UnknownParameter { + name: name.to_string(), + valid: canonical.clone(), + } + })?; + if !differentiable.contains(&name) { + return Err(FittingError::NonDifferentiableParameter { + name: name.to_string(), + differentiable: differentiable.clone(), + }); + } + fitted_indices.push(i); + } + + // Get values for all parameters in canonical order. + let mut full_params = Vec::with_capacity(canonical.len()); + for &name in &canonical { + let v = params + .get(name) + .ok_or_else(|| FittingError::MissingParameter { + name: name.to_string(), + })?; + full_params.push(*v); + } + + let param_names: Vec = fit.iter().map(|s| s.to_string()).collect(); + let p = param_names.len(); + let n_ds = datasets.len(); + + // We normalize parameters in optimizer state to be in the order of 1. + // Hence, scaling factors are the inital parameters. + let normalization: Vec = + fitted_indices.iter().map(|&i| full_params[i]).collect(); + + // Initial fitting parameters with 0.0 are rejected + // since we divide for normalization. + for (j, &idx) in fitted_indices.iter().enumerate() { + if normalization[j] == 0.0 { + return Err(FittingError::ZeroParameter { + name: canonical[idx].to_string(), + }); + } + } + + let mut solver = Self { + datasets, + weights: vec![1.0; n_ds], + param_names, + fitted_indices, + base_params: full_params, + normalization, + loss: LossFunction::default(), + strategy: NonConvergenceStrategy::default(), + params: DVector::from_element(p, 1.0f64), + cached_predicted: vec![Array1::zeros(0); n_ds], + cached_gradients: vec![Array2::zeros((0, p)); n_ds], + cached_converged: vec![Array1::from_elem(0, false); n_ds], + cached_loss_w_sqrt: vec![Array1::zeros(0); n_ds], + cached_penalty: 0.0, + _phantom: PhantomData, + }; + solver.update_cache(); + Ok(solver) + } + + /// Set the loss function. + pub fn with_loss(mut self, loss: LossFunction) -> Self { + self.loss = loss; + self.recompute_loss_weights(); + self + } + + /// Run Levenberg-Marquardt to convergence. + pub fn fit(mut self, config: FitConfig) -> (Self, MinimizationReport) { + // overwrite strategy or nonconverging states + self.strategy = config.strategy.clone(); + LevenbergMarquardt::new() + .with_ftol(config.ftol) + .with_xtol(config.xtol) + .with_gtol(config.gtol) + .with_stepbound(config.stepbound) + .with_patience(config.patience) + .with_scale_diag(config.scale_diag) + .minimize(self) + } + + /// Evaluate all datasets at the given full parameters (EoS ready). + /// + /// Returns `(predicted, gradients, converged)` concatenated across + /// all datasets. Gradients are w.r.t. the fitted parameters only. + pub fn predict(&self, params: &[f64]) -> (Array1, Array2, Array1) { + let p = self.param_names.len(); + let n_total: usize = self.datasets.iter().map(|d| d.target().len()).sum(); + let mut pred = Array1::zeros(n_total); + let mut grad = Array2::zeros((n_total, p)); + let mut conv = Array1::from_elem(n_total, false); + let mut offset = 0; + for d in &self.datasets { + let (dp, dg, dc) = d.compute::(&self.param_names, params); + let n = dp.len(); + pred.slice_mut(s![offset..offset + n]).assign(&dp); + grad.slice_mut(s![offset..offset + n, ..]).assign(&dg); + conv.slice_mut(s![offset..offset + n]).assign(&dc); + offset += n; + } + (pred, grad, conv) + } + + /// Evaluate each dataset individually and return structured + /// per-dataset results. + /// + /// Pass [`FitResult::all_parameters`] as `params` to inspect the + /// model at the fitted optimum. + pub fn evaluate_datasets(&self, params: &[f64]) -> Vec { + self.datasets + .iter() + .map(|dataset| { + let (pred, _grad, conv) = dataset.compute::(&self.param_names, params); + let target = dataset.target(); + let n = target.len(); + let inputs = dataset + .input_names() + .iter() + .enumerate() + .map(|(col, &name)| { + let col_data: Vec = + (0..n).map(|row| dataset.inputs()[[row, col]]).collect(); + (name, col_data) + }) + .collect(); + let relative_deviation: Vec = (0..n) + .map(|i| { + if conv[i] { + (pred[i] - target[i]) / target[i] + } else { + f64::NAN + } + }) + .collect(); + DatasetResult { + name: dataset.name().to_string(), + inputs, + target_name: dataset.target_name(), + target: target.to_vec(), + predicted: pred.to_vec(), + converged: conv.to_vec(), + relative_deviation, + } + }) + .collect() + } + + /// Extract [`FitResult`] from the current solver state and a [`MinimizationReport`]. + pub fn to_result(&self, report: &MinimizationReport) -> FitResult { + FitResult { + optimal_params: self.optimal_params(), + fitted_param_names: self.param_names.clone(), + all_param_names: T::parameter_names().iter().map(|s| s.to_string()).collect(), + aad_per_dataset: self.aad_per_dataset(), + dataset_names: self.dataset_names().iter().map(|s| s.to_string()).collect(), + convergence_stats: self.convergence_stats(), + converged: report.termination.was_successful(), + termination_reason: format!("{:?}", report.termination), + n_evaluations: report.number_of_evaluations, + objective_function: report.objective_function, + elapsed: std::time::Duration::ZERO, + } + } + + /// Target values across all datasets in residual-vector order. + pub fn target(&self) -> Array1 { + let n_total: usize = self.datasets.iter().map(|d| d.target().len()).sum(); + let mut out = Array1::zeros(n_total); + let mut offset = 0; + for dataset in &self.datasets { + let exp = dataset.target(); + let n = exp.len(); + out.slice_mut(s![offset..offset + n]).assign(&exp); + offset += n; + } + out + } + + /// Names of all datasets. + pub fn dataset_names(&self) -> Vec<&str> { + self.datasets.iter().map(|d| d.name()).collect() + } + + /// Per-dataset average absolute relative deviation in percent. + pub fn aad_per_dataset(&self) -> Vec> { + self.datasets + .iter() + .enumerate() + .map(|(k, dataset)| { + let exp = dataset.target(); + let pred = &self.cached_predicted[k]; + let conv = &self.cached_converged[k]; + let n_conv = conv.iter().filter(|&&c| c).count(); + if n_conv == 0 { + return None; + } + Some( + pred.iter() + .zip(exp.iter()) + .zip(conv.iter()) + .filter(|(_, c)| **c) + .map(|((p, e), _)| ((p - e) / e).abs()) + .sum::() + / n_conv as f64 + * 100.0, + ) + }) + .collect() + } + + /// Unweighted relative Jacobian in physical parameter space + /// + /// Shape: `[n_converged, P]` + /// Row: `(1/exp_i) · ∂property_i/∂x_physical` + /// FIM: `J^T J` + /// + /// The Jacobian is in physical parameter space so that `J^T J` can + /// used without any rescaling. Includes converged points only. + /// The Jacobian used during LM iterations is scaled according to + /// parameter normalization and loss definition. + pub fn fim_jacobian(&self) -> Option> { + let n_conv: usize = self + .cached_converged + .iter() + .flat_map(|c| c.iter()) + .filter(|&&c| c) + .count(); + if n_conv == 0 { + return None; + } + + let p = self.params.len(); + let mut jac = DMatrix::zeros(n_conv, p); + + let converged_pts: Vec<(usize, usize)> = self + .datasets + .iter() + .enumerate() + .flat_map(|(k, dataset)| { + (0..dataset.target().len()) + .filter(move |&i| self.cached_converged[k][i]) + .map(move |i| (k, i)) + }) + .collect(); + + for j in 0..p { + for (row, &(k, i)) in converged_pts.iter().enumerate() { + let exp = self.datasets[k].target(); + jac[(row, j)] = self.cached_gradients[k][[i, j]] / exp[i]; + } + } + Some(jac) + } + + /// Penalty value for non-converged points under the current strategy. + fn recompute_loss_weights(&mut self) { + for (k, dataset) in self.datasets.iter().enumerate() { + let exp = dataset.target(); + let pred = &self.cached_predicted[k]; + let conv = &self.cached_converged[k]; + self.cached_loss_w_sqrt[k] = Array1::from_iter((0..exp.len()).map(|i| { + if conv[i] { + self.loss.irls_weight_sqrt((pred[i] - exp[i]) / exp[i]) + } else { + 0.0 + } + })); + } + } + + fn residuals_impl(&self) -> Option> { + let n_total: usize = self.datasets.iter().map(|d| d.target().len()).sum(); + if n_total == 0 { + return None; + } + + let mut out = DVector::zeros(n_total); + let mut offset = 0; + for (k, dataset) in self.datasets.iter().enumerate() { + let exp = dataset.target(); + let pred = &self.cached_predicted[k]; + let conv = &self.cached_converged[k]; + let w_sqrt = &self.cached_loss_w_sqrt[k]; + let weight = self.weights[k]; + for i in 0..exp.len() { + out[offset + i] = if conv[i] { + weight * w_sqrt[i] * (pred[i] - exp[i]) / exp[i] + } else { + weight * self.cached_penalty + }; + } + offset += exp.len(); + } + Some(out) + } + + /// Jacobian for LM iteration. + /// + /// Needs derivatives that account for + /// - using relative deviation + /// - using normalized parameters + /// - using (optional) non L2 loss + /// - using weights for whole parameter sets. + fn jacobian_impl(&self) -> Option> { + let n_total: usize = self.datasets.iter().map(|d| d.target().len()).sum(); + let p = self.params.len(); + if n_total == 0 || p == 0 { + return None; + } + + let mut jac = DMatrix::zeros(n_total, p); + let mut offset = 0; + + // This part is a bit ugly + // We have to convert between ndarray and nalgebra memory layouts. + // Results from EoS calls: ndarray + // Format needed by LM: nalgebra + for (k, dataset) in self.datasets.iter().enumerate() { + let exp = dataset.target(); + let conv = &self.cached_converged[k]; + let w_sqrt = &self.cached_loss_w_sqrt[k]; + let weight = self.weights[k]; + let grad = &self.cached_gradients[k]; + let n = exp.len(); + + let mut jac_block = jac.rows_mut(offset, n); + + // Lenthy zip - but no additional allocations! + let row_iter = exp + .iter() + .zip(conv.iter()) + .zip(w_sqrt.iter()) + .zip(grad.outer_iter()) + .zip(jac_block.row_iter_mut()); + + for ((((&e, &c), &w), grad_row), mut jac_row) in row_iter { + if !c { + continue; + } + // factor covers: + // - dataset weight + // - division by experimental value + // - irls weight (1.0 for L2 loss) + let factor = weight * w / e; + for (jac_val, &g) in jac_row.iter_mut().zip(grad_row.iter()) { + *jac_val = factor * g; + } + } + offset += n; + } + + // Apply parameter scaling. + for (mut col, &norm) in jac.column_iter_mut().zip(self.normalization.iter()) { + for val in col.iter_mut() { + *val *= norm; + } + } + + Some(jac) + } + + /// Populates the caches when parameters are set in LM iteration. + /// + /// Using the cache, we only have to compute the residual and gradients once. + /// Scaling factors and weights are applied in the residual and jacobian functions. + fn update_cache(&mut self) { + // Compute physical parameters: x_physical[j] = x_opt[j] * normalization[j], + // embedded into the full M-dimensional parameter vector. + let mut full_params = self.base_params.clone(); + for (j, &idx) in self.fitted_indices.iter().enumerate() { + full_params[idx] = self.params[j] * self.normalization[j]; + } + + for (k, dataset) in self.datasets.iter().enumerate() { + let (pred, grad, conv) = dataset.compute::(&self.param_names, &full_params); + let exp = dataset.target(); + self.cached_loss_w_sqrt[k] = Array1::from_iter((0..exp.len()).map(|i| { + if conv[i] { + self.loss.irls_weight_sqrt((pred[i] - exp[i]) / exp[i]) + } else { + 0.0 + } + })); + self.cached_predicted[k] = pred; + self.cached_gradients[k] = grad; + self.cached_converged[k] = conv; + } + + // Update cached_penalty from the freshly computed predictions. + // For AdaptivePenalty this is a cheap O(n) scan over hot cache; + // for the other variants it is a direct read of the strategy. + self.cached_penalty = match &self.strategy { + NonConvergenceStrategy::Ignore => 0.0, + NonConvergenceStrategy::Penalty(p) => *p, + NonConvergenceStrategy::AdaptivePenalty(factor) => { + let mut max_r = 0.0f64; + let mut any_converged = false; + for (k, dataset) in self.datasets.iter().enumerate() { + let exp = dataset.target(); + let pred = &self.cached_predicted[k]; + let conv = &self.cached_converged[k]; + for i in 0..exp.len() { + if conv[i] { + any_converged = true; + max_r = max_r.max(((pred[i] - exp[i]) / exp[i]).abs()); + } + } + } + if any_converged { + max_r * factor + } else { + *factor + } + } + }; + } + } + + impl> LeastSquaresProblem for Regressor { + type ParameterStorage = Owned; + type ResidualStorage = Owned; + type JacobianStorage = Owned; + + fn set_params(&mut self, params: &DVector) { + self.params.copy_from(params); + self.update_cache(); + } + fn params(&self) -> DVector { + self.params.clone() + } + fn residuals(&self) -> Option> { + self.residuals_impl() + } + fn jacobian(&self) -> Option> { + self.jacobian_impl() + } + } + }; +} + +impl_solver!(1, PureDataset); +impl_solver!(2, BinaryDataset); + +/// Object-safe interface for type-erased solvers. +/// +/// A `Box` can hold any concrete `Regressor` without +/// exposing the EoS type. +/// +/// This is useful when models are created at runtime (used in py-feos). +/// +/// # Example +/// +/// ```ignore +/// let mut solver: Box = match model_name { +/// "eos_1" => Box::new(PureRegressor::::new(ds, params, &fit)?), +/// "eos_2" => Box::new(PureRegressor::::new(ds, params, &fit)?), +/// _ => panic!("unknown model"), +/// }; +/// let result = solver.fit(FitConfig::default(), None); +/// println!("AAD: {:?}", result.aad_per_dataset); +/// ``` +pub trait DynSolver: Send + Sync { + /// Evaluate all datasets at the given full physical parameters. + /// + /// Returns `(predicted, gradients, converged)` concatenated across + /// all datasets. Gradients are w.r.t. the fitted parameters only. + fn predict(&self, params: &[f64]) -> (Array1, Array2, Array1); + + /// Run Levenberg-Marquardt optimisation and return a [`FitResult`]. + /// + /// Does not consume the solver. This is different from LM. + fn fit(&mut self, config: FitConfig, loss: Option) -> FitResult; + + /// Concatenated target values across all datasets. + fn target(&self) -> Array1; + + /// All canonical parameter names in order. + fn all_param_names(&self) -> Vec; + + /// Names of the parameters being optimised. + fn fitted_param_names(&self) -> Vec; + + /// Dataset names in order. + fn dataset_names(&self) -> Vec; + + /// Evaluate each dataset at the given full parameters and return + /// structured per-dataset results. + /// + /// See [`Regressor::evaluate_datasets`] for details. + fn evaluate_datasets(&self, params: &[f64]) -> Vec; +} + +macro_rules! impl_dyn_solver { + ($n:literal, $dataset:ty) => { + impl DynSolver for Regressor + where + T: ParametersAD<$n> + Send + Sync + 'static, + { + fn predict(&self, params: &[f64]) -> (Array1, Array2, Array1) { + self.predict(params) + } + + fn fit(&mut self, config: FitConfig, loss: Option) -> FitResult { + let mut solver = std::mem::take(self); + if let Some(l) = loss { + solver = solver.with_loss(l); + } + let t0 = std::time::Instant::now(); + let (solved, report) = solver.fit(config); + let elapsed = t0.elapsed(); + let mut result = solved.to_result(&report); + result.elapsed = elapsed; + *self = solved; + result + } + + fn target(&self) -> Array1 { + self.target() + } + + fn all_param_names(&self) -> Vec { + T::parameter_names().iter().map(|s| s.to_string()).collect() + } + + fn fitted_param_names(&self) -> Vec { + self.param_names.clone() + } + + fn dataset_names(&self) -> Vec { + self.dataset_names() + .iter() + .map(|s: &&str| s.to_string()) + .collect() + } + + fn evaluate_datasets(&self, params: &[f64]) -> Vec { + self.evaluate_datasets(params) + } + } + }; +} + +impl_dyn_solver!(1, PureDataset); +impl_dyn_solver!(2, BinaryDataset); diff --git a/crates/feos-core/src/lib.rs b/crates/feos-core/src/lib.rs index cc280cf0a..b9655b46e 100644 --- a/crates/feos-core/src/lib.rs +++ b/crates/feos-core/src/lib.rs @@ -33,6 +33,8 @@ pub mod parameter; mod phase_equilibria; mod state; pub use ad::{ParametersAD, PropertiesAD}; +#[cfg(feature = "rayon")] +pub use ad::parameter_optimization; pub use equation_of_state::{ EntropyScaling, EquationOfState, IdealGas, IdealGasAD, Molarweight, NoResidual, Residual, ResidualDyn, Subset, Total, diff --git a/crates/feos/src/pcsaft/eos/mod.rs b/crates/feos/src/pcsaft/eos/mod.rs index 37a885248..fd532f08e 100644 --- a/crates/feos/src/pcsaft/eos/mod.rs +++ b/crates/feos/src/pcsaft/eos/mod.rs @@ -604,6 +604,10 @@ mod tests_parameter_fit { use num_dual::{Dual64, DualStruct, DualVec, partial}; use quantity::{BAR, KELVIN, LITER, MOL, PASCAL}; + fn flat_binary_params(b: &PcSaftBinary) -> Vec { + b.0 .0.iter().flatten().copied().chain(std::iter::once(b.0 .1)).collect() + } + fn pcsaft_non_assoc() -> PcSaftPure { let m = 1.5; let sigma = 3.4; @@ -626,7 +630,7 @@ mod tests_parameter_fit { "nb", ]; let (pcsaft, _) = pcsaft()?; - let pcsaft_ad = pcsaft.named_derivatives(pcsaft_params); + let pcsaft_ad = PcSaftPure::::seed_derivatives(&pcsaft.0, pcsaft_params); let temperature = 250.0 * KELVIN; let p = pcsaft_ad.vapor_pressure(temperature)?; let p = p.convert_into(PASCAL); @@ -658,7 +662,7 @@ mod tests_parameter_fit { #[test] fn test_vapor_pressure_derivatives_fit() -> FeosResult<()> { let pcsaft = pcsaft_non_assoc(); - let pcsaft_ad = pcsaft.named_derivatives(["m", "sigma", "epsilon_k"]); + let pcsaft_ad = PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); let temperature = 150.0 * KELVIN; let p = pcsaft_ad.vapor_pressure(temperature)?; let p = p.convert_into(PASCAL); @@ -734,7 +738,7 @@ mod tests_parameter_fit { #[test] fn test_equilibrium_liquid_density_derivatives_fit() -> FeosResult<()> { let pcsaft = pcsaft_non_assoc(); - let pcsaft_ad = pcsaft.named_derivatives(["m", "sigma", "epsilon_k"]); + let pcsaft_ad = PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); let temperature = 150.0 * KELVIN; let (p, rho) = pcsaft_ad.equilibrium_liquid_density(temperature)?; let p = p.convert_into(PASCAL); @@ -774,7 +778,7 @@ mod tests_parameter_fit { #[test] fn test_liquid_density_derivatives_fit() -> FeosResult<()> { let pcsaft = pcsaft_non_assoc(); - let pcsaft_ad = pcsaft.named_derivatives(["m", "sigma", "epsilon_k"]); + let pcsaft_ad = PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); let temperature = 150.0 * KELVIN; let pressure = BAR; let rho = pcsaft_ad.liquid_density(temperature, pressure)?; @@ -806,7 +810,7 @@ mod tests_parameter_fit { #[test] fn test_bubble_point_pressure() -> FeosResult<()> { let (pcsaft, _) = pcsaft_binary()?; - let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); let temperature = 500.0 * KELVIN; let x = vector![0.5, 0.5]; let p = pcsaft_ad.bubble_point_pressure(temperature, None, x)?; @@ -844,7 +848,7 @@ mod tests_parameter_fit { #[test] fn test_dew_point_pressure() -> FeosResult<()> { let (pcsaft, _) = pcsaft_binary()?; - let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); let temperature = 500.0 * KELVIN; let y = 0.5; let p = pcsaft_ad.dew_point_pressure(temperature, None, y)?; @@ -876,7 +880,7 @@ mod tests_parameter_fit { #[test] fn test_bubble_point_temperature() -> FeosResult<()> { let (pcsaft, _) = pcsaft_binary()?; - let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); let pressure = Pressure::from_reduced(DualVec::from(45. * BAR.into_reduced())); let t_init = Temperature::from_reduced(DualVec::from(500.0)); let x = DualVec::from(0.5); @@ -924,7 +928,7 @@ mod tests_parameter_fit { #[test] fn test_dew_point_temperature() -> FeosResult<()> { let (pcsaft, _) = pcsaft_binary()?; - let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); let pressure = Pressure::from_reduced(DualVec::from(45. * BAR.into_reduced())); let t_init = Temperature::from_reduced(DualVec::from(500.0)); let x = DualVec::from(0.5); diff --git a/crates/feos/src/pcsaft/eos/pcsaft_binary.rs b/crates/feos/src/pcsaft/eos/pcsaft_binary.rs index 71b15ae88..f6c83258b 100644 --- a/crates/feos/src/pcsaft/eos/pcsaft_binary.rs +++ b/crates/feos/src/pcsaft/eos/pcsaft_binary.rs @@ -2,7 +2,7 @@ use super::dispersion::{A0, A1, A2, B0, B1, B2}; use super::polar::{AD, BD, CD}; use feos_core::{ParametersAD, Residual, StateHD}; use nalgebra::{SVector, U2}; -use num_dual::{DualNum, DualSVec64, DualVec, jacobian}; +use num_dual::{DualNum, DualVec, jacobian}; use std::f64::consts::{FRAC_PI_6, PI}; const PI_SQ_43: f64 = 4.0 / 3.0 * PI * PI; @@ -38,26 +38,58 @@ impl + Copy, const N: usize> From<&[f64]> for PcSaftBinary } impl ParametersAD<2> for PcSaftBinary { - fn index_parameters_mut<'a, const P: usize>( - eos: &'a mut Self::Lifted>, - index: &str, - ) -> &'a mut DualSVec64

{ - match index { - "k_ij" => &mut eos.0.1, - _ => panic!("{index} is not a valid binary PC-SAFT parameter!"), - } + fn build + Copy>( + mut f: impl FnMut(&'static str, bool) -> D, + ) -> PcSaftBinary { + PcSaftBinary::new( + [ + [ + f("m1", true), + f("sigma1", true), + f("epsilon_k1", true), + f("mu1", true), + ], + [ + f("m2", true), + f("sigma2", true), + f("epsilon_k2", true), + f("mu2", true), + ], + ], + f("k_ij", true), + ) } } impl ParametersAD<2> for PcSaftBinary { - fn index_parameters_mut<'a, const P: usize>( - eos: &'a mut Self::Lifted>, - index: &str, - ) -> &'a mut DualSVec64

{ - match index { - "k_ij" => &mut eos.0.1, - _ => panic!("{index} is not a valid binary PC-SAFT parameter!"), - } + fn build + Copy>( + mut f: impl FnMut(&'static str, bool) -> D, + ) -> PcSaftBinary { + PcSaftBinary::new( + [ + [ + f("m1", true), + f("sigma1", true), + f("epsilon_k1", true), + f("mu1", true), + f("kappa_ab1", true), + f("epsilon_k_ab1", true), + f("na1", false), + f("nb1", false), + ], + [ + f("m2", true), + f("sigma2", true), + f("epsilon_k2", true), + f("mu2", true), + f("kappa_ab2", true), + f("epsilon_k_ab2", true), + f("na2", false), + f("nb2", false), + ], + ], + f("k_ij", true), + ) } } diff --git a/crates/feos/src/pcsaft/eos/pcsaft_pure.rs b/crates/feos/src/pcsaft/eos/pcsaft_pure.rs index f9ea2983f..d6ad49bac 100644 --- a/crates/feos/src/pcsaft/eos/pcsaft_pure.rs +++ b/crates/feos/src/pcsaft/eos/pcsaft_pure.rs @@ -2,7 +2,7 @@ use super::dispersion::{A0, A1, A2, B0, B1, B2}; use super::polar::{AD, BD, CD}; use feos_core::{ParametersAD, Residual, StateHD}; use nalgebra::{SVector, U1}; -use num_dual::{DualNum, DualSVec64}; +use num_dual::DualNum; use std::f64::consts::{FRAC_PI_6, PI}; const PI_SQ_43: f64 = 4.0 / 3.0 * PI * PI; @@ -193,36 +193,32 @@ impl + Copy, const N: usize> From<&[f64]> for PcSaftPure { } impl ParametersAD<1> for PcSaftPure { - fn index_parameters_mut<'a, const P: usize>( - eos: &'a mut Self::Lifted>, - index: &str, - ) -> &'a mut DualSVec64

{ - match index { - "m" => &mut eos.0[0], - "sigma" => &mut eos.0[1], - "epsilon_k" => &mut eos.0[2], - "mu" => &mut eos.0[3], - _ => panic!("{index} is not a valid PC-SAFT parameter!"), - } + fn build + Copy>( + mut f: impl FnMut(&'static str, bool) -> D, + ) -> PcSaftPure { + PcSaftPure([ + f("m", true), + f("sigma", true), + f("epsilon_k", true), + f("mu", true), + ]) } } impl ParametersAD<1> for PcSaftPure { - fn index_parameters_mut<'a, const P: usize>( - eos: &'a mut Self::Lifted>, - index: &str, - ) -> &'a mut DualSVec64

{ - match index { - "m" => &mut eos.0[0], - "sigma" => &mut eos.0[1], - "epsilon_k" => &mut eos.0[2], - "mu" => &mut eos.0[3], - "kappa_ab" => &mut eos.0[4], - "epsilon_k_ab" => &mut eos.0[5], - "na" => &mut eos.0[6], - "nb" => &mut eos.0[7], - _ => panic!("{index} is not a valid PC-SAFT parameter!"), - } + fn build + Copy>( + mut f: impl FnMut(&'static str, bool) -> D, + ) -> PcSaftPure { + PcSaftPure([ + f("m", true), + f("sigma", true), + f("epsilon_k", true), + f("mu", true), + f("kappa_ab", true), + f("epsilon_k_ab", true), + f("na", false), + f("nb", false), + ]) } } diff --git a/py-feos/src/ad/mod.rs b/py-feos/src/ad/mod.rs index 3f775a2da..7fb941a67 100644 --- a/py-feos/src/ad/mod.rs +++ b/py-feos/src/ad/mod.rs @@ -4,6 +4,13 @@ use numpy::{PyArray1, PyArray2, PyReadonlyArray2, ToPyArray}; use paste::paste; use pyo3::prelude::*; +pub mod solver; +pub use solver::{ + PyBinaryRegressor, PyBubblePointDataset, PyDewPointDataset, PyEquilibriumLiquidDensityDataset, + PyLiquidDensityDataset, PyLossFunction, PyNonConvergenceStrategy, PyPureRegressor, + PyFitConfig, PyFitResult, PyVaporPressureDataset, +}; + #[pyclass(name = "EquationOfStateAD", eq, eq_int)] #[derive(Clone, Copy, PartialEq)] pub enum PyEquationOfStateAD { diff --git a/py-feos/src/ad/solver.rs b/py-feos/src/ad/solver.rs new file mode 100644 index 000000000..37a9c400f --- /dev/null +++ b/py-feos/src/ad/solver.rs @@ -0,0 +1,947 @@ +use std::collections::HashMap; + +use feos::pcsaft::{PcSaftBinary, PcSaftPure}; +use feos_core::parameter_optimization::{ + BinaryDataset, BubblePointDataset, BubblePointRecord, Dataset, DewPointDataset, DewPointRecord, + DynSolver, EquilibriumLiquidDensityDataset, EquilibriumLiquidDensityRecord, FitConfig, + FitResult, FittingError, LiquidDensityDataset, LiquidDensityRecord, LossFunction, + NonConvergenceStrategy, PureDataset, Regressor, VaporPressureDataset, VaporPressureRecord, +}; +use ndarray::Array2; +use numpy::{PyArray1, PyArray2, ToPyArray}; +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use super::PyEquationOfStateAD; + +macro_rules! py_dataset { + ( + $py_name:ident, + $rust_type:ty, + $display:expr, + $csv_doc:expr + ) => { + #[doc = concat!("CSV columns: ``", $csv_doc, "``")] + #[pyclass(name = $display)] + pub struct $py_name { + pub(crate) inner: $rust_type, + } + + #[pymethods] + impl $py_name { + /// Load from a CSV file. + /// + /// Args: + /// path (str): Path to the CSV file. + /// name (str, optional): Dataset name used in solver diagnostics + /// and results. Must be unique within a solver; defaults to + /// the property name (e.g. `"vapor pressure"`). + #[staticmethod] + #[pyo3(signature = (path, name=None))] + pub fn from_csv(path: &str, name: Option<&str>) -> PyResult { + <$rust_type>::from_csv(std::path::Path::new(path)) + .map(|mut inner| { + if let Some(n) = name { + inner = inner.with_name(n); + } + Self { inner } + }) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + /// Property name. + #[getter] + pub fn name(&self) -> &str { + Dataset::name(&self.inner) + } + + /// Number of data points. + pub fn __len__(&self) -> usize { + Dataset::target(&self.inner).len() + } + + /// Target values. + pub fn target<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1> { + Dataset::target(&self.inner).to_owned().to_pyarray(py) + } + + pub fn __repr__(&self) -> String { + format!("{}(n={})", $display, Dataset::target(&self.inner).len()) + } + } + }; +} + +py_dataset!( + PyVaporPressureDataset, + VaporPressureDataset, + "VaporPressureDataset", + "temperature_k, vapor_pressure_pa" +); + +#[pymethods] +impl PyVaporPressureDataset { + /// Construct from numpy arrays. + /// + /// Args: + /// temperature_k (np.ndarray): Temperatures in K. + /// vapor_pressure_pa (np.ndarray): Vapor pressures in Pa. + /// name (str, optional): Dataset name (must be unique within a solver). + #[new] + #[pyo3(signature = (temperature_k, vapor_pressure_pa, name=None))] + pub fn new( + temperature_k: Vec, + vapor_pressure_pa: Vec, + name: Option<&str>, + ) -> PyResult { + if temperature_k.len() != vapor_pressure_pa.len() { + return Err(PyValueError::new_err( + "temperature_k and vapor_pressure_pa must have the same length", + )); + } + let records = temperature_k + .into_iter() + .zip(vapor_pressure_pa) + .map(|(t, p)| VaporPressureRecord { + temperature_k: t, + vapor_pressure_pa: p, + }) + .collect(); + let mut inner = VaporPressureDataset::from_records(records); + if let Some(n) = name { + inner = inner.with_name(n); + } + Ok(Self { inner }) + } +} + +py_dataset!( + PyLiquidDensityDataset, + LiquidDensityDataset, + "LiquidDensityDataset", + "temperature_k, pressure_pa, liquid_density_molm3" +); + +#[pymethods] +impl PyLiquidDensityDataset { + /// Construct from numpy arrays. + /// + /// Args: + /// temperature_k (np.ndarray): Temperatures in K. + /// pressure_pa (np.ndarray): Pressures in Pa. + /// liquid_density_molm3 (np.ndarray): Liquid molar densities in mol/m³. + /// name (str, optional): Dataset name (must be unique within a solver). + #[new] + #[pyo3(signature = (temperature_k, pressure_pa, liquid_density_molm3, name=None))] + pub fn new( + temperature_k: Vec, + pressure_pa: Vec, + liquid_density_molm3: Vec, + name: Option<&str>, + ) -> PyResult { + let n = temperature_k.len(); + if pressure_pa.len() != n || liquid_density_molm3.len() != n { + return Err(PyValueError::new_err( + "all arrays must have the same length", + )); + } + let records = (0..n) + .map(|i| LiquidDensityRecord { + temperature_k: temperature_k[i], + pressure_pa: pressure_pa[i], + liquid_density_molm3: liquid_density_molm3[i], + }) + .collect(); + let mut inner = LiquidDensityDataset::from_records(records); + if let Some(n) = name { + inner = inner.with_name(n); + } + Ok(Self { inner }) + } +} + +py_dataset!( + PyEquilibriumLiquidDensityDataset, + EquilibriumLiquidDensityDataset, + "EquilibriumLiquidDensityDataset", + "temperature_k, liquid_density_molm3" +); + +#[pymethods] +impl PyEquilibriumLiquidDensityDataset { + /// Construct from numpy arrays. + /// + /// Args: + /// temperature_k (np.ndarray): Temperatures in K. + /// liquid_density_molm3 (np.ndarray): Saturated liquid molar densities in mol/m³. + /// name (str, optional): Dataset name (must be unique within a solver). + #[new] + #[pyo3(signature = (temperature_k, liquid_density_molm3, name=None))] + pub fn new( + temperature_k: Vec, + liquid_density_molm3: Vec, + name: Option<&str>, + ) -> PyResult { + if temperature_k.len() != liquid_density_molm3.len() { + return Err(PyValueError::new_err( + "temperature_k and liquid_density_molm3 must have the same length", + )); + } + let records = temperature_k + .into_iter() + .zip(liquid_density_molm3) + .map(|(t, rho)| EquilibriumLiquidDensityRecord { + temperature_k: t, + liquid_density_molm3: rho, + }) + .collect(); + let mut inner = EquilibriumLiquidDensityDataset::from_records(records); + if let Some(n) = name { + inner = inner.with_name(n); + } + Ok(Self { inner }) + } +} + +py_dataset!( + PyBubblePointDataset, + BubblePointDataset, + "BubblePointDataset", + "temperature_k, liquid_molefrac_1, bubble_pressure_pa" +); + +#[pymethods] +impl PyBubblePointDataset { + /// Construct from numpy arrays. + /// + /// Args: + /// temperature_k (np.ndarray): Temperatures in K. + /// liquid_molefrac_1 (np.ndarray): Liquid-phase mole fractions of component 1. + /// bubble_pressure_pa (np.ndarray): Bubble point pressures in Pa. + /// Also used as the initial VLE solver guess. + /// name (str, optional): Dataset name (must be unique within a solver). + #[new] + #[pyo3(signature = (temperature_k, liquid_molefrac_1, bubble_pressure_pa, name=None))] + pub fn new( + temperature_k: Vec, + liquid_molefrac_1: Vec, + bubble_pressure_pa: Vec, + name: Option<&str>, + ) -> PyResult { + let n = temperature_k.len(); + if liquid_molefrac_1.len() != n || bubble_pressure_pa.len() != n { + return Err(PyValueError::new_err( + "all arrays must have the same length", + )); + } + let records = (0..n) + .map(|i| BubblePointRecord { + temperature_k: temperature_k[i], + liquid_molefrac_1: liquid_molefrac_1[i], + bubble_pressure_pa: bubble_pressure_pa[i], + }) + .collect(); + let mut inner = BubblePointDataset::from_records(records); + if let Some(n) = name { + inner = inner.with_name(n); + } + Ok(Self { inner }) + } +} + +py_dataset!( + PyDewPointDataset, + DewPointDataset, + "DewPointDataset", + "temperature_k, vapor_molefrac_1, dew_pressure_pa" +); + +#[pymethods] +impl PyDewPointDataset { + /// Construct from numpy arrays. + /// + /// Args: + /// temperature_k (np.ndarray): Temperatures in K. + /// vapor_molefrac_1 (np.ndarray): Vapor-phase mole fractions of component 1. + /// dew_pressure_pa (np.ndarray): Dew point pressures in Pa. + /// Also used as the initial VLE solver guess. + /// name (str, optional): Dataset name (must be unique within a solver). + #[new] + #[pyo3(signature = (temperature_k, vapor_molefrac_1, dew_pressure_pa, name=None))] + pub fn new( + temperature_k: Vec, + vapor_molefrac_1: Vec, + dew_pressure_pa: Vec, + name: Option<&str>, + ) -> PyResult { + let n = temperature_k.len(); + if vapor_molefrac_1.len() != n || dew_pressure_pa.len() != n { + return Err(PyValueError::new_err( + "all arrays must have the same length", + )); + } + let records = (0..n) + .map(|i| DewPointRecord { + temperature_k: temperature_k[i], + vapor_molefrac_1: vapor_molefrac_1[i], + dew_pressure_pa: dew_pressure_pa[i], + }) + .collect(); + let mut inner = DewPointDataset::from_records(records); + if let Some(n) = name { + inner = inner.with_name(n); + } + Ok(Self { inner }) + } +} + +/// Loss function applied to the dimensionless relative residual during fitting. +/// +/// Examples: +/// >>> LossFunction.l2() +/// LossFunction.L2 +/// >>> LossFunction.huber(0.1) +/// LossFunction.Huber(delta=0.1) +#[pyclass(name = "LossFunction")] +pub struct PyLossFunction { + pub(crate) inner: LossFunction, +} + +#[pymethods] +impl PyLossFunction { + /// L2 loss: all residuals weighted equally. + #[staticmethod] + pub fn l2() -> Self { + Self { + inner: LossFunction::L2, + } + } + + /// Huber loss: down-weights points with relative error > `delta`. + /// + /// Args: + /// delta (float): Dimensionless threshold on the relative residual + /// ``r = (calc − exp) / exp``. E.g. ``0.1`` means 10 %. + #[staticmethod] + pub fn huber(delta: f64) -> PyResult { + if delta <= 0.0 { + return Err(PyValueError::new_err("Huber delta must be > 0")); + } + Ok(Self { + inner: LossFunction::Huber { delta }, + }) + } + + pub fn __repr__(&self) -> String { + match &self.inner { + LossFunction::L2 => "LossFunction.L2".to_string(), + LossFunction::Huber { delta } => format!("LossFunction.Huber(delta={delta})"), + } + } +} + +/// Strategy for handling data points where EoS evaluation did not converge. +/// +/// Examples: +/// >>> NonConvergenceStrategy.ignore() +/// NonConvergenceStrategy.Ignore +/// >>> NonConvergenceStrategy.penalty(10.0) +/// NonConvergenceStrategy.Penalty(10.0) +/// >>> NonConvergenceStrategy.adaptive_penalty(5.0) +/// NonConvergenceStrategy.AdaptivePenalty(factor=5.0) +#[pyclass(name = "NonConvergenceStrategy")] +pub struct PyNonConvergenceStrategy { + pub(crate) inner: NonConvergenceStrategy, +} + +#[pymethods] +impl PyNonConvergenceStrategy { + /// Exclude non-converged points entirely (zero residual and Jacobian). + #[staticmethod] + pub fn ignore() -> Self { + Self { + inner: NonConvergenceStrategy::Ignore, + } + } + + /// Apply a fixed dimensionless relative residual penalty. + /// + /// Args: + /// value (float): Penalty value, e.g. `10.0` = 1000 % relative error. + #[staticmethod] + pub fn penalty(value: f64) -> PyResult { + if value <= 0.0 { + return Err(PyValueError::new_err("value must be > 0")); + } + Ok(Self { + inner: NonConvergenceStrategy::Penalty(value), + }) + } + + /// Penalty scaled by `factor × max |r_rel|` over converged points. + /// + /// Falls back to `factor` when no points converged. + /// + /// Args: + /// factor (float): Scaling factor, e.g. `5.0`. + #[staticmethod] + pub fn adaptive_penalty(factor: f64) -> PyResult { + if factor <= 0.0 { + return Err(PyValueError::new_err("factor must be > 0")); + } + Ok(Self { + inner: NonConvergenceStrategy::AdaptivePenalty(factor), + }) + } + + pub fn __repr__(&self) -> String { + match &self.inner { + NonConvergenceStrategy::Ignore => "NonConvergenceStrategy.Ignore".to_string(), + NonConvergenceStrategy::Penalty(v) => { + format!("NonConvergenceStrategy.Penalty({v})") + } + NonConvergenceStrategy::AdaptivePenalty(f) => { + format!("NonConvergenceStrategy.AdaptivePenalty(factor={f})") + } + } + } +} + +/// Levenberg-Marquardt hyperparameters. +/// +/// Args: +/// ftol (float): Terminate when the relative reduction in the objective +/// function is ≤ ``ftol``. Default: 1e-8. +/// xtol (float): Terminate when the relative change in parameters between +/// iterations is ≤ ``xtol``. Default: 1e-8. +/// gtol (float): Terminate when the residual and all Jacobian columns are +/// nearly orthogonal (scale-invariant first-order optimality check). +/// Set to ``0.0`` to disable. Default: 1e-8. +/// stepbound (float): Factor for the initial trust-region step bound, in +/// ``[0.1, 100]``. Smaller values are more conservative. Default: 0.1. +/// patience (int): Maximum number of residual evaluations = +/// ``patience × (n_params + 1)``. Default: 100. +/// scale_diag (bool): Rescale parameters internally using Jacobian column +/// norms. Default: True. +/// strategy (NonConvergenceStrategy, optional): How to treat non-converged +/// EoS evaluations. Default: ``NonConvergenceStrategy.penalty(10.0)``. +#[pyclass(name = "FitConfig")] +#[derive(Clone)] +pub struct PyFitConfig { + pub(crate) inner: FitConfig, +} + +#[pymethods] +impl PyFitConfig { + #[new] + #[pyo3(signature = (ftol=1e-8, xtol=1e-8, gtol=1e-8, stepbound=0.1, patience=100, scale_diag=true, strategy=None))] + pub fn new( + ftol: f64, + xtol: f64, + gtol: f64, + stepbound: f64, + patience: usize, + scale_diag: bool, + strategy: Option>, + ) -> Self { + Self { + inner: FitConfig { + ftol, + xtol, + gtol, + stepbound, + patience, + scale_diag, + strategy: strategy + .map(|s| s.borrow().inner.clone()) + .unwrap_or_default(), + }, + } + } + + #[getter] + pub fn ftol(&self) -> f64 { + self.inner.ftol + } + #[getter] + pub fn xtol(&self) -> f64 { + self.inner.xtol + } + #[getter] + pub fn gtol(&self) -> f64 { + self.inner.gtol + } + #[getter] + pub fn stepbound(&self) -> f64 { + self.inner.stepbound + } + #[getter] + pub fn patience(&self) -> usize { + self.inner.patience + } + #[getter] + pub fn scale_diag(&self) -> bool { + self.inner.scale_diag + } + #[getter] + pub fn strategy(&self) -> PyNonConvergenceStrategy { + PyNonConvergenceStrategy { + inner: self.inner.strategy.clone(), + } + } + + pub fn __repr__(&self) -> String { + format!( + "FitConfig(ftol={}, xtol={}, gtol={}, stepbound={}, patience={}, \ + scale_diag={}, strategy={})", + self.inner.ftol, + self.inner.xtol, + self.inner.gtol, + self.inner.stepbound, + self.inner.patience, + self.inner.scale_diag, + PyNonConvergenceStrategy { + inner: self.inner.strategy.clone() + } + .__repr__(), + ) + } +} + +/// Results from a completed parameter fit. +/// +/// Attributes: +/// optimal_params (list[float]): Optimal physical parameters in canonical +/// order. +/// fitted_param_names (list[str]): Names of the parameters that were +/// optimised. +/// all_param_names (list[str]): All canonical parameter names in order. +/// aad_per_dataset (list[float | None]): Per-dataset average absolute +/// relative deviation in percent. +/// dataset_names (list[str]): Dataset names in order. +/// convergence_stats (list[tuple[int, int]]): Per-dataset +/// `(n_converged, n_total)`. +/// converged (bool): Whether LM reported successful convergence. +/// n_evaluations (int): Number of residual evaluations performed by LM. +/// objective_function (float): Final value of the LM objective. +#[pyclass(name = "FitResult")] +pub struct PyFitResult { + pub(crate) inner: FitResult, +} + +#[pymethods] +impl PyFitResult { + #[getter] + pub fn optimal_params(&self) -> Vec { + self.inner.optimal_params.clone() + } + #[getter] + pub fn fitted_param_names(&self) -> Vec { + self.inner.fitted_param_names.clone() + } + #[getter] + pub fn all_param_names(&self) -> Vec { + self.inner.all_param_names.clone() + } + #[getter] + pub fn aad_per_dataset(&self) -> Vec> { + self.inner.aad_per_dataset.clone() + } + #[getter] + pub fn dataset_names(&self) -> Vec { + self.inner.dataset_names.clone() + } + #[getter] + pub fn convergence_stats(&self) -> Vec<(usize, usize)> { + self.inner.convergence_stats.clone() + } + #[getter] + pub fn converged(&self) -> bool { + self.inner.converged + } + /// Description of why the optimiser stopped. + #[getter] + pub fn termination_reason(&self) -> &str { + &self.inner.termination_reason + } + #[getter] + pub fn n_evaluations(&self) -> usize { + self.inner.n_evaluations + } + #[getter] + pub fn objective_function(&self) -> f64 { + self.inner.objective_function + } + /// Wall-clock time spent inside the LM optimiser, in milliseconds. + #[getter] + pub fn elapsed_ms(&self) -> f64 { + self.inner.elapsed.as_secs_f64() * 1000.0 + } + + /// All parameters as `{name: value}`. + /// + /// Fitted parameters at thei optimal values, all others at their initial values. + /// + /// Note: + /// + /// The dict is returned in canonical parameter order. + /// This means `list(result.all_parameters().values())` + /// is a valid argument to :meth:`PureRegressor.predict`. + /// + /// Pass this directly to :meth:`PureRegressor.evaluate_datasets` or + /// :meth:`BinaryRegressor.evaluate_datasets` to compare model predictions + /// against the experimental data at the fitted optimum. + pub fn all_parameters<'py>(&self, py: Python<'py>) -> PyResult> { + let d = PyDict::new(py); + for (name, &val) in self + .inner + .all_param_names + .iter() + .zip(self.inner.optimal_params.iter()) + { + d.set_item(name, val)?; + } + Ok(d) + } + + /// Fitted parameters as `{name: value}`, in canonical order. + /// + /// Only the parameters that were optimised are included. + /// For all parameters (including fixed ones), use :meth:`all_parameters`. + pub fn optimal_params_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let d = PyDict::new(py); + for name in &self.inner.fitted_param_names { + if let Some(pos) = self.inner.all_param_names.iter().position(|n| n == name) { + d.set_item(name, self.inner.optimal_params[pos])?; + } + } + Ok(d) + } + + pub fn __repr__(&self) -> String { + let aad_strs: Vec = self + .inner + .aad_per_dataset + .iter() + .zip(self.inner.dataset_names.iter()) + .map(|(aad, name)| match aad { + Some(v) => format!("{name}: {v:.2}%"), + None => format!("{name}: no convergence"), + }) + .collect(); + format!( + "FitResult(converged={}, n_eval={}, elapsed={:.1}ms, aad=[{}])", + self.inner.converged, + self.inner.n_evaluations, + self.inner.elapsed.as_secs_f64() * 1000.0, + aad_strs.join(", ") + ) + } +} + +macro_rules! solver_methods { + ($py_type:ty, $display:expr) => { + #[pymethods] + impl $py_type { + /// Evaluate the model at the given physical parameters. + /// + /// Args: + /// params (list[float]): Full parameter vector in canonical + /// order (`all_param_names`). + /// + /// Returns: + /// tuple[np.ndarray, np.ndarray, np.ndarray]: + /// `(predicted, gradients, converged)` where + /// `gradients` has shape `[n_points, n_fitted_params]`. + pub fn predict<'py>( + &self, + py: Python<'py>, + params: Vec, + ) -> ( + Bound<'py, PyArray1>, + Bound<'py, PyArray2>, + Bound<'py, PyArray1>, + ) { + let (pred, grad, conv) = self.inner.predict(¶ms); + ( + pred.to_pyarray(py), + grad.to_pyarray(py), + conv.to_pyarray(py), + ) + } + + /// Target values across all datasets (concatenated). + pub fn target<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray1> { + self.inner.target().to_pyarray(py) + } + + /// Evaluate each dataset at the given parameters. + /// + /// Returns a list of dicts where each contains: + /// + /// - Input columns keyed by their property name + /// - The experimental target column, keyed by its property name + /// - The model predictions, keyed as `"_predicted"` + /// - `"relative_deviation"` — `(predicted − target) / target` + /// - `"converged"` — boolean mask + /// + /// Args: + /// params (dict[str, float]): All EOS parameters as returned by + /// :attr:`FitResult.all_parameters`. + /// + /// Returns: + /// list[dict[str, np.ndarray]]: One dict per dataset, in the + /// same order as the datasets passed to the constructor. + /// + /// Examples: + /// >>> result = regressor.fit() + /// >>> ds = regressor.evaluate_datasets(result.all_parameters) + /// >>> dfs = [pd.DataFrame(d) for d in ds] # one pandas.DataFrame per Dataset + pub fn evaluate_datasets<'py>( + &self, + py: Python<'py>, + params: HashMap, + ) -> PyResult>> { + // Build the full param vector in canonical order. + let names = self.inner.all_param_names(); + let param_vec: Result, _> = names + .iter() + .map(|n| { + params.get(n).copied().ok_or_else(|| { + PyValueError::new_err(format!("missing parameter '{n}'")) + }) + }) + .collect(); + let param_vec = param_vec?; + + let results = self.inner.evaluate_datasets(¶m_vec); + + // Create Python dictionaries + results + .into_iter() + .map(|r| { + let d = PyDict::new(py); + for (name, col) in r.inputs { + d.set_item(name, col.to_pyarray(py))?; + } + d.set_item(r.target_name, r.target.to_pyarray(py))?; + d.set_item( + format!("{}_predicted", r.target_name), + r.predicted.to_pyarray(py), + )?; + d.set_item("relative_deviation", r.relative_deviation.to_pyarray(py))?; + d.set_item("converged", r.converged.to_pyarray(py))?; + Ok(d) + }) + .collect() + } + + /// Run Levenberg-Marquardt optimisation. + /// + /// Args: + /// config (FitConfig, optional): Regressor hyperparameters + /// (including non-convergence strategy). + /// loss (LossFunction, optional): Loss function applied to + /// relative residuals. + /// + /// Returns: + /// FitResult + #[pyo3(signature = (config=None, loss=None))] + pub fn fit( + &mut self, + config: Option, + loss: Option>, + ) -> PyFitResult { + let config = config.map(|c| c.inner).unwrap_or_default(); + let loss = loss.map(|l| l.borrow().inner.clone()); + PyFitResult { + inner: self.inner.fit(config, loss), + } + } + + /// All canonical parameter names in order. + #[getter] + pub fn all_param_names(&self) -> Vec { + self.inner.all_param_names() + } + + /// Names of the parameters being optimised. + #[getter] + pub fn fitted_param_names(&self) -> Vec { + self.inner.fitted_param_names() + } + + /// Dataset names in order. + #[getter] + pub fn dataset_names(&self) -> Vec { + self.inner.dataset_names() + } + + pub fn __repr__(&self) -> String { + format!( + "{}(params={:?}, fitting={:?}, datasets=[{}])", + $display, + self.inner.all_param_names(), + self.inner.fitted_param_names(), + self.inner + .dataset_names() + .iter() + .map(|s| format!("\"{s}\"")) + .collect::>() + .join(", ") + ) + } + } + }; +} + +/// Levenberg-Marquardt solver for pure-component parameter fitting. +/// +/// Args: +/// model (EquationOfStateAD): Equation of state to use. +/// datasets (list): One or more datasets for pure substance properties. +/// params (dict[str, float]): Initial values for all parameters keyed by name. +/// fit (list[str]): Names of the parameters to optimise. +/// +/// Examples: +/// >>> vp = VaporPressureDataset.from_csv("vp.csv") +/// >>> rho = LiquidDensityDataset.from_csv("rho.csv") +/// >>> solver = PureRegressor( +/// ... model=EquationOfStateAD.PcSaftNonAssoc, +/// ... datasets=[vp, rho], +/// ... params={"m": 2.5, "sigma": 3.4, "epsilon_k": 280.0, "mu": 0.0}, +/// ... fit=["sigma", "epsilon_k"], +/// ... ) +/// >>> result = solver.fit() +#[pyclass(name = "PureRegressor")] +pub struct PyPureRegressor { + inner: Box, +} + +#[pymethods] +impl PyPureRegressor { + #[new] + #[pyo3(signature = (model, datasets, params, fit))] + pub fn new( + model: PyEquationOfStateAD, + datasets: Vec>, + params: HashMap, + fit: Vec, + ) -> PyResult { + let ds = extract_pure_datasets(&datasets)?; + let fit_strs: Vec<&str> = fit.iter().map(|s| s.as_str()).collect(); + let inner: Box = match model { + PyEquationOfStateAD::PcSaftNonAssoc => Box::new( + Regressor::, _>::new(ds, params, &fit_strs) + .map_err(|e: FittingError| PyValueError::new_err(e.to_string()))?, + ), + PyEquationOfStateAD::PcSaftFull => Box::new( + Regressor::, _>::new(ds, params, &fit_strs) + .map_err(|e: FittingError| PyValueError::new_err(e.to_string()))?, + ), + }; + Ok(Self { inner }) + } +} + +solver_methods!(PyPureRegressor, "PureRegressor"); + +/// Levenberg-Marquardt solver for binary-mixture parameter fitting. +/// +/// Args: +/// model (EquationOfStateAD): Equation of state to use. +/// datasets (list): One or more datasets for mixture properties. +/// params (dict[str, float]): Initial values for all parameters keyed by name. +/// fit (list[str]): Names of the parameters to optimise. +/// +/// Examples: +/// >>> bp = BubblePointDataset.from_csv("bubble.csv") +/// >>> solver = BinaryRegressor( +/// ... model=EquationOfStateAD.PcSaftNonAssoc, +/// ... datasets=[bp], +/// ... params={ +/// ... "m1": 3.0, +/// ... # more parameters for substance 1 and 2 +/// ... "k_ij": 0.01 +/// ... }, +/// ... fit=["k_ij"], +/// ... ) +/// >>> result = solver.fit() +#[pyclass(name = "BinaryRegressor")] +pub struct PyBinaryRegressor { + inner: Box, +} + +#[pymethods] +impl PyBinaryRegressor { + #[new] + #[pyo3(signature = (model, datasets, params, fit))] + pub fn new( + model: PyEquationOfStateAD, + datasets: Vec>, + params: HashMap, + fit: Vec, + ) -> PyResult { + let ds = extract_binary_datasets(&datasets)?; + let fit_strs: Vec<&str> = fit.iter().map(|s| s.as_str()).collect(); + let inner: Box = match model { + PyEquationOfStateAD::PcSaftNonAssoc => Box::new( + Regressor::, _>::new(ds, params, &fit_strs) + .map_err(|e: FittingError| PyValueError::new_err(e.to_string()))?, + ), + PyEquationOfStateAD::PcSaftFull => Box::new( + Regressor::, _>::new(ds, params, &fit_strs) + .map_err(|e: FittingError| PyValueError::new_err(e.to_string()))?, + ), + }; + Ok(Self { inner }) + } +} + +solver_methods!(PyBinaryRegressor, "BinaryRegressor"); + +/// Downcast a Python list to `Vec`. +fn extract_pure_datasets(list: &[Bound<'_, PyAny>]) -> PyResult> { + if list.is_empty() { + return Err(PyValueError::new_err("datasets list must not be empty")); + } + list.iter() + .map(|d| { + if let Ok(vp) = d.extract::>() { + Ok(PureDataset::VaporPressure(vp.inner.clone())) + } else if let Ok(rho) = d.extract::>() { + Ok(PureDataset::LiquidDensity(rho.inner.clone())) + } else if let Ok(eq) = d.extract::>() { + Ok(PureDataset::EquilibriumLiquidDensity(eq.inner.clone())) + } else { + Err(PyTypeError::new_err(format!( + "expected VaporPressureDataset, LiquidDensityDataset, or \ + EquilibriumLiquidDensityDataset; got {}", + d.get_type().name()? + ))) + } + }) + .collect() +} + +/// Downcast a Python list to `Vec`. +fn extract_binary_datasets(list: &[Bound<'_, PyAny>]) -> PyResult> { + if list.is_empty() { + return Err(PyValueError::new_err("datasets list must not be empty")); + } + list.iter() + .map(|d| { + if let Ok(bp) = d.extract::>() { + Ok(BinaryDataset::BubblePoint(bp.inner.clone())) + } else if let Ok(dp) = d.extract::>() { + Ok(BinaryDataset::DewPoint(dp.inner.clone())) + } else { + Err(PyTypeError::new_err(format!( + "expected BubblePointDataset or DewPointDataset; got {}", + d.get_type().name()? + ))) + } + }) + .collect() +} diff --git a/py-feos/src/lib.rs b/py-feos/src/lib.rs index 948a24d6b..1e6fcf752 100644 --- a/py-feos/src/lib.rs +++ b/py-feos/src/lib.rs @@ -179,12 +179,6 @@ fn feos(m: &Bound<'_, PyModule>) -> PyResult<()> { // Equation of state m.add_class::()?; - // // Estimator - // m.add_class::()?; - // m.add_class::()?; - // m.add_class::()?; - // m.add_class::()?; - // AD #[cfg(feature = "ad")] { @@ -198,6 +192,21 @@ fn feos(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(ad::bubble_point_pressure_derivatives, m)?)?; m.add_function(wrap_pyfunction!(ad::dew_point_pressure_derivatives, m)?)?; m.add_class::()?; + + // Datasets + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + // Regressor + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; } #[cfg(feature = "dft")] From 3300c67aa67a9ca81bdc0ceaa64fd8c3fad44b66 Mon Sep 17 00:00:00 2001 From: Gernot Bauer Date: Wed, 22 Apr 2026 16:36:16 +0200 Subject: [PATCH 08/11] Added heat of vaporization and residual heat capacity datasets and AD methods --- crates/feos-core/Cargo.toml | 1 - crates/feos-core/src/ad/mod.rs | 102 +++++++++++- .../src/ad/parameter_optimization/dataset.rs | 152 ++++++++++++++++++ .../src/ad/parameter_optimization/mod.rs | 13 +- 4 files changed, 256 insertions(+), 12 deletions(-) diff --git a/crates/feos-core/Cargo.toml b/crates/feos-core/Cargo.toml index 2250a07c9..26a6c8a5e 100644 --- a/crates/feos-core/Cargo.toml +++ b/crates/feos-core/Cargo.toml @@ -26,7 +26,6 @@ serde_json = { workspace = true, features = ["preserve_order"] } indexmap = { workspace = true, features = ["serde"] } rayon = { workspace = true, optional = true } csv = { version = "1", optional = true } -typenum = { workspace = true } itertools = { workspace = true } levenberg-marquardt = "0.15.0" diff --git a/crates/feos-core/src/ad/mod.rs b/crates/feos-core/src/ad/mod.rs index e0cc32625..bfd86de82 100644 --- a/crates/feos-core/src/ad/mod.rs +++ b/crates/feos-core/src/ad/mod.rs @@ -1,16 +1,16 @@ #[cfg(feature = "rayon")] pub mod parameter_optimization; -use crate::density_iteration::density_iteration; use crate::DensityInitialization::Liquid; -use crate::{Composition, FeosResult, PhaseEquilibrium, ReferenceSystem, Residual}; +use crate::density_iteration::density_iteration; +use crate::{Composition, FeosResult, PhaseEquilibrium, ReferenceSystem, Residual, State}; use nalgebra::{Const, SVector, U1, U2}; #[cfg(feature = "rayon")] use ndarray::{Array1, Array2, ArrayView2, Zip}; -use num_dual::{first_derivative, partial2, Derivative, DualNum, DualSVec, DualStruct}; -use quantity::{Density, Pressure, Temperature}; +use num_dual::{Derivative, DualNum, DualSVec, DualStruct, first_derivative, partial2}; +use quantity::{Density, MolarEnergy, MolarEntropy, Pressure, Temperature}; #[cfg(feature = "rayon")] -use quantity::{KELVIN, KILO, METER, MOL, PASCAL}; +use quantity::{JOULE, KELVIN, KILO, METER, MOL, PASCAL}; type Gradient = DualSVec; @@ -178,6 +178,58 @@ pub trait PropertiesAD { density_iteration(self, t, p, &x, Some(Liquid)) } + /// Residual isobaric molar heat capacity of the liquid phase at the given + /// temperature and pressure. + fn residual_isobaric_heat_capacity( + &self, + temperature: Temperature, + pressure: Pressure, + ) -> FeosResult>> + where + Self: Residual>, + { + let x = Self::pure_molefracs(); + let t = Temperature::from_inner(&temperature); + let p = Pressure::from_inner(&pressure); + let density = density_iteration(self, t, p, &x, Some(Liquid))?; + let state = State::new_pure(self, t, density)?; + Ok(state.residual_molar_isobaric_heat_capacity()) + } + + fn enthalpy_of_vaporization( + &self, + temperature: Temperature, + ) -> FeosResult>> + where + Self: Residual>, + { + let t = Temperature::from_inner(&temperature); + let (_, [vapor_density, liquid_density]) = + PhaseEquilibrium::pure_t(self, t, None, Default::default())?; + + let v1 = liquid_density.into_reduced().recip(); + let v2 = vapor_density.into_reduced().recip(); + let x = Self::pure_molefracs(); + let t = t.into_reduced(); + let residual_entropy = |v| { + let (_a, s) = first_derivative( + partial2( + |t, &v, x| self.lift().residual_helmholtz_energy(t, v, x), + &v, + &x, + ), + t, + ); + -s + }; + + let s1 = residual_entropy(v1); + let s2 = residual_entropy(v2); + + let dh = t * ((v2 / v1).ln() + s2 - s1); + Ok(MolarEnergy::from_reduced(dh)) + } + #[cfg(feature = "rayon")] fn vapor_pressure_parallel( parameter_names: [String; P], @@ -238,6 +290,46 @@ pub trait PropertiesAD { ) } + #[cfg(feature = "rayon")] + fn residual_isobaric_heat_capacity_parallel( + parameter_names: [String; P], + parameters: ArrayView2, + input: ArrayView2, + ) -> (Array1, Array2, Array1) + where + Self: ParametersAD<1>, + { + parallelize::<_, Self, _, _>( + parameter_names, + parameters, + input, + |eos: &Self::Lifted>, inp| { + eos.residual_isobaric_heat_capacity(inp[0] * KELVIN, inp[1] * PASCAL) + .map(|cp| cp.convert_into(JOULE / (MOL * KELVIN))) + }, + ) + } + + #[cfg(feature = "rayon")] + fn enthalpy_of_vaporization_parallel( + parameter_names: [String; P], + parameters: ArrayView2, + input: ArrayView2, + ) -> (Array1, Array2, Array1) + where + Self: ParametersAD<1>, + { + parallelize::<_, Self, _, _>( + parameter_names, + parameters, + input, + |eos: &Self::Lifted>, inp| { + eos.enthalpy_of_vaporization(inp[0] * KELVIN) + .map(|dh| dh.convert_into(JOULE / MOL)) + }, + ) + } + #[cfg(feature = "rayon")] fn equilibrium_liquid_density_parallel( parameter_names: [String; P], diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset.rs b/crates/feos-core/src/ad/parameter_optimization/dataset.rs index e7ae6fe05..246a73df9 100644 --- a/crates/feos-core/src/ad/parameter_optimization/dataset.rs +++ b/crates/feos-core/src/ad/parameter_optimization/dataset.rs @@ -333,6 +333,138 @@ impl Dataset<1> for EquilibriumLiquidDensityDataset { } } +/// Record for enthalpy of vaporization data. +/// +/// Expected column headers: `temperature_k`, `dh_j_mol`. +#[derive(Deserialize, Serialize)] +pub struct EnthalpyOfVaporizationRecord { + pub temperature_k: f64, + pub dh_vap_j_mol: f64, +} + +/// Enthalpy of vaporization dataset for pure substances. +/// +/// Inputs: `[[T1], [T2], ...]` (temperature in K) +/// Target: enthalpy of vaporization in J/mol +#[derive(Clone)] +pub struct EnthalpyOfVaporizationDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl Dataset<1> for EnthalpyOfVaporizationDataset { + type Record = EnthalpyOfVaporizationRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 1), |(i, _)| records[i].temperature_k); + let target = Array1::from_iter(records.iter().map(|r| r.dh_vap_j_mol)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("enthalpy of vaporization") + } + + fn input_names() -> &'static [&'static str] { + &["temperature_k"] + } + + fn target_name() -> &'static str { + "dh_vap_j_mol" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::enthalpy_of_vaporization_parallel(names, parameters, inputs) + } +} + +/// Record for residual isobaric molar heat capacity data (liquid phase). +/// +/// Expected column headers: `temperature_k`, `pressure_pa`, `cp_res_j_molk`. +#[derive(Deserialize, Serialize)] +pub struct ResidualIsobaricHeatCapacityRecord { + pub temperature_k: f64, + pub pressure_pa: f64, + pub cp_res_j_molk: f64, +} + +/// Residual isobaric molar heat capacity dataset (liquid phase). +/// +/// Inputs: `[[T1, p1], [T2, p2], ...]` (K, Pa) +/// Target: residual molar isobaric heat capacity in J/(mol·K) +#[derive(Clone)] +pub struct ResidualIsobaricHeatCapacityDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl Dataset<1> for ResidualIsobaricHeatCapacityDataset { + type Record = ResidualIsobaricHeatCapacityRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 2), |(i, j)| match j { + 0 => records[i].temperature_k, + _ => records[i].pressure_pa, + }); + let target = Array1::from_iter(records.iter().map(|r| r.cp_res_j_molk)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + + fn name(&self) -> &str { + self.name + .as_deref() + .unwrap_or("residual isobaric heat capacity") + } + + fn input_names() -> &'static [&'static str] { + &["temperature_k", "pressure_pa"] + } + + fn target_name() -> &'static str { + "cp_res_j_molk" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::residual_isobaric_heat_capacity_parallel(names, parameters, inputs) + } +} + /// Bubble point pressure record for binary mixtures. /// /// Expected column headers: `temperature_k`, `liquid_molefrac_1`, @@ -491,6 +623,8 @@ impl_with_name!( VaporPressureDataset, LiquidDensityDataset, EquilibriumLiquidDensityDataset, + EnthalpyOfVaporizationDataset, + ResidualIsobaricHeatCapacityDataset, BubblePointDataset, DewPointDataset, ); @@ -504,6 +638,8 @@ pub enum PureDataset { VaporPressure(VaporPressureDataset), LiquidDensity(LiquidDensityDataset), EquilibriumLiquidDensity(EquilibriumLiquidDensityDataset), + EnthalpyOfVaporization(EnthalpyOfVaporizationDataset), + ResidualIsobaricHeatCapacity(ResidualIsobaricHeatCapacityDataset), } impl PureDataset { @@ -512,6 +648,8 @@ impl PureDataset { Self::VaporPressure(d) => d.target(), Self::LiquidDensity(d) => d.target(), Self::EquilibriumLiquidDensity(d) => d.target(), + Self::EnthalpyOfVaporization(d) => d.target(), + Self::ResidualIsobaricHeatCapacity(d) => d.target(), } } @@ -520,6 +658,8 @@ impl PureDataset { Self::VaporPressure(d) => d.name(), Self::LiquidDensity(d) => d.name(), Self::EquilibriumLiquidDensity(d) => d.name(), + Self::EnthalpyOfVaporization(d) => d.name(), + Self::ResidualIsobaricHeatCapacity(d) => d.name(), } } @@ -528,6 +668,8 @@ impl PureDataset { Self::VaporPressure(d) => d.inputs(), Self::LiquidDensity(d) => d.inputs(), Self::EquilibriumLiquidDensity(d) => d.inputs(), + Self::EnthalpyOfVaporization(d) => d.inputs(), + Self::ResidualIsobaricHeatCapacity(d) => d.inputs(), } } @@ -536,6 +678,10 @@ impl PureDataset { Self::VaporPressure(_) => VaporPressureDataset::input_names(), Self::LiquidDensity(_) => LiquidDensityDataset::input_names(), Self::EquilibriumLiquidDensity(_) => EquilibriumLiquidDensityDataset::input_names(), + Self::EnthalpyOfVaporization(_) => EquilibriumLiquidDensityDataset::input_names(), + Self::ResidualIsobaricHeatCapacity(_) => { + ResidualIsobaricHeatCapacityDataset::input_names() + } } } @@ -544,6 +690,10 @@ impl PureDataset { Self::VaporPressure(_) => VaporPressureDataset::target_name(), Self::LiquidDensity(_) => LiquidDensityDataset::target_name(), Self::EquilibriumLiquidDensity(_) => EquilibriumLiquidDensityDataset::target_name(), + Self::EnthalpyOfVaporization(_) => EnthalpyOfVaporizationDataset::target_name(), + Self::ResidualIsobaricHeatCapacity(_) => { + ResidualIsobaricHeatCapacityDataset::target_name() + } } } @@ -556,6 +706,8 @@ impl PureDataset { Self::VaporPressure(d) => d.compute::(param_names, params), Self::LiquidDensity(d) => d.compute::(param_names, params), Self::EquilibriumLiquidDensity(d) => d.compute::(param_names, params), + Self::EnthalpyOfVaporization(d) => d.compute::(param_names, params), + Self::ResidualIsobaricHeatCapacity(d) => d.compute::(param_names, params), } } } diff --git a/crates/feos-core/src/ad/parameter_optimization/mod.rs b/crates/feos-core/src/ad/parameter_optimization/mod.rs index 534ebfd4c..26d25b965 100644 --- a/crates/feos-core/src/ad/parameter_optimization/mod.rs +++ b/crates/feos-core/src/ad/parameter_optimization/mod.rs @@ -3,13 +3,14 @@ pub mod loss; pub mod solver; pub use dataset::{ - BinaryDataset, BubblePointDataset, BubblePointRecord, Dataset, DatasetResult, - DewPointDataset, DewPointRecord, EquilibriumLiquidDensityDataset, - EquilibriumLiquidDensityRecord, LiquidDensityDataset, LiquidDensityRecord, PureDataset, - VaporPressureDataset, VaporPressureRecord, + BinaryDataset, BubblePointDataset, BubblePointRecord, Dataset, DatasetResult, DewPointDataset, + DewPointRecord, EnthalpyOfVaporizationDataset, EnthalpyOfVaporizationRecord, + EquilibriumLiquidDensityDataset, EquilibriumLiquidDensityRecord, LiquidDensityDataset, + LiquidDensityRecord, PureDataset, ResidualIsobaricHeatCapacityDataset, + ResidualIsobaricHeatCapacityRecord, VaporPressureDataset, VaporPressureRecord, }; pub use loss::LossFunction; pub use solver::{ - BinaryRegressor, DynSolver, FittingError, NonConvergenceStrategy, PureRegressor, Regressor, - FitConfig, FitResult, + BinaryRegressor, DynSolver, FitConfig, FitResult, FittingError, NonConvergenceStrategy, + PureRegressor, Regressor, }; From 2721b2c5e1eec395ec59bf37e0d0dfd1ae4d92c8 Mon Sep 17 00:00:00 2001 From: Gernot Bauer Date: Wed, 22 Apr 2026 16:40:00 +0200 Subject: [PATCH 09/11] Added python bindings for heat of vaporization and isobaric heat capacity datasets. --- py-feos/src/ad/mod.rs | 63 +++++++++++++++++++++-- py-feos/src/ad/solver.rs | 105 +++++++++++++++++++++++++++++++++++++-- py-feos/src/lib.rs | 10 ++++ 3 files changed, 169 insertions(+), 9 deletions(-) diff --git a/py-feos/src/ad/mod.rs b/py-feos/src/ad/mod.rs index 7fb941a67..a2dc0c1fa 100644 --- a/py-feos/src/ad/mod.rs +++ b/py-feos/src/ad/mod.rs @@ -6,9 +6,10 @@ use pyo3::prelude::*; pub mod solver; pub use solver::{ - PyBinaryRegressor, PyBubblePointDataset, PyDewPointDataset, PyEquilibriumLiquidDensityDataset, - PyLiquidDensityDataset, PyLossFunction, PyNonConvergenceStrategy, PyPureRegressor, - PyFitConfig, PyFitResult, PyVaporPressureDataset, + PyBinaryRegressor, PyBubblePointDataset, PyDewPointDataset, PyEnthalpyOfVaporizationDataset, + PyEquilibriumLiquidDensityDataset, PyFitConfig, PyFitResult, PyLiquidDensityDataset, + PyLossFunction, PyNonConvergenceStrategy, PyPureRegressor, PyResidualIsobaricHeatCapacityDataset, + PyVaporPressureDataset, }; #[pyclass(name = "EquationOfStateAD", eq, eq_int)] @@ -142,6 +143,60 @@ pub fn equilibrium_liquid_density_derivatives<'py>( _equilibrium_liquid_density_derivatives(model, parameter_names, parameters, input) } +/// Calculate enthalpy of vaporization and derivatives w.r.t. model parameters. +/// +/// Parameters +/// ---------- +/// model: EquationOfStateAD +/// The equation of state to use. +/// parameter_names: List[string] +/// The name of the parameters for which derivatives are calculated. +/// parameters: np.ndarray[float] +/// The parameters for every data point. +/// input: np.ndarray[float] +/// The temperature (in K) for every data point. +/// +/// Returns +/// ------- +/// (np.ndarray[float], np.ndarray[float], np.ndarray[bool]): +/// The enthalpies of vaporization (in J/mol), gradients, and convergence status. +#[pyfunction] +pub fn enthalpy_of_vaporization_derivatives<'py>( + model: PyEquationOfStateAD, + parameter_names: &Bound<'py, PyAny>, + parameters: PyReadonlyArray2, + input: PyReadonlyArray2, +) -> GradResult<'py> { + _enthalpy_of_vaporization_derivatives(model, parameter_names, parameters, input) +} + +/// Calculate residual isobaric molar heat capacities (liquid phase) and derivatives w.r.t. model parameters. +/// +/// Parameters +/// ---------- +/// model: EquationOfStateAD +/// The equation of state to use. +/// parameter_names: List[string] +/// The name of the parameters for which derivatives are calculated. +/// parameters: np.ndarray[float] +/// The parameters for every data point. +/// input: np.ndarray[float] +/// The temperature (in K) and pressure (in Pa) for every data point. +/// +/// Returns +/// ------- +/// (np.ndarray[float], np.ndarray[float], np.ndarray[bool]): +/// The residual isobaric heat capacities (in J/(mol·K)), gradients, and convergence status. +#[pyfunction] +pub fn residual_isobaric_heat_capacity_derivatives<'py>( + model: PyEquationOfStateAD, + parameter_names: &Bound<'py, PyAny>, + parameters: PyReadonlyArray2, + input: PyReadonlyArray2, +) -> GradResult<'py> { + _residual_isobaric_heat_capacity_derivatives(model, parameter_names, parameters, input) +} + /// Calculate bubble point pressures of binary mixtures and derivatives w.r.t. model parameters. /// /// Parameters @@ -255,7 +310,7 @@ macro_rules! impl_evaluate_gradients { impl_evaluate_gradients!( pure, - [vapor_pressure, boiling_temperature, liquid_density, equilibrium_liquid_density], + [vapor_pressure, boiling_temperature, liquid_density, equilibrium_liquid_density, enthalpy_of_vaporization, residual_isobaric_heat_capacity], {PcSaftNonAssoc: PcSaftPure, PcSaftFull: PcSaftPure} ); diff --git a/py-feos/src/ad/solver.rs b/py-feos/src/ad/solver.rs index 37a9c400f..87f1b7b3f 100644 --- a/py-feos/src/ad/solver.rs +++ b/py-feos/src/ad/solver.rs @@ -3,9 +3,11 @@ use std::collections::HashMap; use feos::pcsaft::{PcSaftBinary, PcSaftPure}; use feos_core::parameter_optimization::{ BinaryDataset, BubblePointDataset, BubblePointRecord, Dataset, DewPointDataset, DewPointRecord, - DynSolver, EquilibriumLiquidDensityDataset, EquilibriumLiquidDensityRecord, FitConfig, - FitResult, FittingError, LiquidDensityDataset, LiquidDensityRecord, LossFunction, - NonConvergenceStrategy, PureDataset, Regressor, VaporPressureDataset, VaporPressureRecord, + DynSolver, EnthalpyOfVaporizationDataset, EnthalpyOfVaporizationRecord, + EquilibriumLiquidDensityDataset, EquilibriumLiquidDensityRecord, FitConfig, FitResult, + FittingError, LiquidDensityDataset, LiquidDensityRecord, LossFunction, NonConvergenceStrategy, + PureDataset, Regressor, ResidualIsobaricHeatCapacityDataset, + ResidualIsobaricHeatCapacityRecord, VaporPressureDataset, VaporPressureRecord, }; use ndarray::Array2; use numpy::{PyArray1, PyArray2, ToPyArray}; @@ -204,6 +206,94 @@ impl PyEquilibriumLiquidDensityDataset { } } +py_dataset!( + PyEnthalpyOfVaporizationDataset, + EnthalpyOfVaporizationDataset, + "EnthalpyOfVaporizationDataset", + "temperature_k, dh_vap_j_mol" +); + +#[pymethods] +impl PyEnthalpyOfVaporizationDataset { + /// Construct from numpy arrays. + /// + /// Args: + /// temperature_k (np.ndarray): Temperatures in K. + /// dh_vap_j_mol (np.ndarray): Enthalpy of vaporization in J/mol. + /// name (str, optional): Dataset name (must be unique within a solver). + #[new] + #[pyo3(signature = (temperature_k, dh_vap_j_mol, name=None))] + pub fn new( + temperature_k: Vec, + dh_vap_j_mol: Vec, + name: Option<&str>, + ) -> PyResult { + if temperature_k.len() != dh_vap_j_mol.len() { + return Err(PyValueError::new_err( + "temperature_k and dh_vap_j_mol must have the same length", + )); + } + let records = temperature_k + .into_iter() + .zip(dh_vap_j_mol) + .map(|(t, p)| EnthalpyOfVaporizationRecord { + temperature_k: t, + dh_vap_j_mol: p, + }) + .collect(); + let mut inner = EnthalpyOfVaporizationDataset::from_records(records); + if let Some(n) = name { + inner = inner.with_name(n); + } + Ok(Self { inner }) + } +} + +py_dataset!( + PyResidualIsobaricHeatCapacityDataset, + ResidualIsobaricHeatCapacityDataset, + "ResidualIsobaricHeatCapacityDataset", + "temperature_k, pressure_pa, cp_res_j_molk" +); + +#[pymethods] +impl PyResidualIsobaricHeatCapacityDataset { + /// Construct from numpy arrays. + /// + /// Args: + /// temperature_k (np.ndarray): Temperatures in K. + /// pressure_pa (np.ndarray): Pressures in Pa. + /// cp_res_j_molk (np.ndarray): Residual isobaric molar heat capacities in J/(mol·K). + /// name (str, optional): Dataset name (must be unique within a solver). + #[new] + #[pyo3(signature = (temperature_k, pressure_pa, cp_res_j_molk, name=None))] + pub fn new( + temperature_k: Vec, + pressure_pa: Vec, + cp_res_j_molk: Vec, + name: Option<&str>, + ) -> PyResult { + let n = temperature_k.len(); + if pressure_pa.len() != n || cp_res_j_molk.len() != n { + return Err(PyValueError::new_err( + "all arrays must have the same length", + )); + } + let records = (0..n) + .map(|i| ResidualIsobaricHeatCapacityRecord { + temperature_k: temperature_k[i], + pressure_pa: pressure_pa[i], + cp_res_j_molk: cp_res_j_molk[i], + }) + .collect(); + let mut inner = ResidualIsobaricHeatCapacityDataset::from_records(records); + if let Some(n) = name { + inner = inner.with_name(n); + } + Ok(Self { inner }) + } +} + py_dataset!( PyBubblePointDataset, BubblePointDataset, @@ -914,10 +1004,15 @@ fn extract_pure_datasets(list: &[Bound<'_, PyAny>]) -> PyResult Ok(PureDataset::LiquidDensity(rho.inner.clone())) } else if let Ok(eq) = d.extract::>() { Ok(PureDataset::EquilibriumLiquidDensity(eq.inner.clone())) + } else if let Ok(eq) = d.extract::>() { + Ok(PureDataset::EnthalpyOfVaporization(eq.inner.clone())) + } else if let Ok(cp) = d.extract::>() { + Ok(PureDataset::ResidualIsobaricHeatCapacity(cp.inner.clone())) } else { Err(PyTypeError::new_err(format!( - "expected VaporPressureDataset, LiquidDensityDataset, or \ - EquilibriumLiquidDensityDataset; got {}", + "expected VaporPressureDataset, LiquidDensityDataset, \ + EquilibriumLiquidDensityDataset, EnthalpyOfVaporizationDataset, or \ + ResidualIsobaricHeatCapacityDataset; got {}", d.get_type().name()? ))) } diff --git a/py-feos/src/lib.rs b/py-feos/src/lib.rs index 1e6fcf752..5b25d3e41 100644 --- a/py-feos/src/lib.rs +++ b/py-feos/src/lib.rs @@ -189,6 +189,14 @@ fn feos(m: &Bound<'_, PyModule>) -> PyResult<()> { ad::equilibrium_liquid_density_derivatives, m )?)?; + m.add_function(wrap_pyfunction!( + ad::enthalpy_of_vaporization_derivatives, + m + )?)?; + m.add_function(wrap_pyfunction!( + ad::residual_isobaric_heat_capacity_derivatives, + m + )?)?; m.add_function(wrap_pyfunction!(ad::bubble_point_pressure_derivatives, m)?)?; m.add_function(wrap_pyfunction!(ad::dew_point_pressure_derivatives, m)?)?; m.add_class::()?; @@ -197,6 +205,8 @@ fn feos(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; From fe81486d444469b870f80bfb23f416efef78c1b6 Mon Sep 17 00:00:00 2001 From: Gernot Bauer Date: Mon, 27 Apr 2026 22:12:13 +0200 Subject: [PATCH 10/11] Fixed some bugs, generalised loss and residual function application, structured dataset caches, added examples. --- crates/feos-core/Cargo.toml | 4 +- crates/feos-core/src/ad/mod.rs | 55 +- .../src/ad/parameter_optimization/dataset.rs | 947 ------------------ .../dataset/bubble_point.rs | 70 ++ .../dataset/dew_point.rs | 70 ++ .../dataset/enthalpy_of_vaporization.rs | 64 ++ .../dataset/equilibrium_liquid_density.rs | 64 ++ .../dataset/liquid_density.rs | 68 ++ .../ad/parameter_optimization/dataset/mod.rs | 381 +++++++ .../residual_isobaric_heat_capacity.rs | 70 ++ .../dataset/vapor_pressure.rs | 64 ++ .../src/ad/parameter_optimization/loss.rs | 16 +- .../src/ad/parameter_optimization/mod.rs | 10 +- .../{solver.rs => regressor.rs} | 499 +++++---- .../src/ad/parameter_optimization/residual.rs | 55 + crates/feos-core/src/density_iteration.rs | 5 +- crates/feos-core/src/lib.rs | 4 +- crates/feos-derive/src/dft.rs | 2 +- crates/feos-derive/src/lib.rs | 2 +- .../feos-dft/src/adsorption/fea_potential.rs | 2 +- crates/feos/src/gc_pcsaft/dft/mod.rs | 5 +- crates/feos/src/pcsaft/dft/mod.rs | 3 +- crates/feos/src/pcsaft/eos/mod.rs | 28 +- .../pcsaft/parameter_adjustment/README.md | 21 +- .../data/hexane_liquid_density_si.csv | 275 +++++ .../data/hexane_vapor_pressure_si.csv | 252 +++++ .../parameter_adjustment/fit_hexane_ad.py | 74 ++ .../fit_hexane_ad_scipy.py | 149 +++ .../parameter_adjustment/fit_hexane_scipy.py | 167 +++ py-feos/src/ad/mod.rs | 10 +- py-feos/src/ad/{solver.rs => regressor.rs} | 427 +++++--- .../src/dft/adsorption/external_potential.rs | 2 +- py-feos/src/dft/mod.rs | 274 ++--- py-feos/src/eos/epcsaft.rs | 2 +- py-feos/src/eos/pcsaft.rs | 4 +- py-feos/src/eos/uvtheory.rs | 2 +- py-feos/src/lib.rs | 4 +- py-feos/src/parameter/chemical_record.rs | 2 +- py-feos/src/user_defined.rs | 26 +- 39 files changed, 2598 insertions(+), 1581 deletions(-) delete mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset/bubble_point.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset/dew_point.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset/enthalpy_of_vaporization.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset/equilibrium_liquid_density.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset/liquid_density.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset/mod.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset/residual_isobaric_heat_capacity.rs create mode 100644 crates/feos-core/src/ad/parameter_optimization/dataset/vapor_pressure.rs rename crates/feos-core/src/ad/parameter_optimization/{solver.rs => regressor.rs} (65%) create mode 100644 crates/feos-core/src/ad/parameter_optimization/residual.rs create mode 100644 examples/pcsaft/parameter_adjustment/data/hexane_liquid_density_si.csv create mode 100644 examples/pcsaft/parameter_adjustment/data/hexane_vapor_pressure_si.csv create mode 100644 examples/pcsaft/parameter_adjustment/fit_hexane_ad.py create mode 100644 examples/pcsaft/parameter_adjustment/fit_hexane_ad_scipy.py create mode 100644 examples/pcsaft/parameter_adjustment/fit_hexane_scipy.py rename py-feos/src/ad/{solver.rs => regressor.rs} (70%) diff --git a/crates/feos-core/Cargo.toml b/crates/feos-core/Cargo.toml index 26a6c8a5e..39d478398 100644 --- a/crates/feos-core/Cargo.toml +++ b/crates/feos-core/Cargo.toml @@ -35,5 +35,5 @@ quantity = { workspace = true, features = ["approx"] } [features] default = [] -ndarray = ["dep:ndarray", "quantity/ndarray"] -rayon = ["dep:rayon", "ndarray/rayon", "dep:csv"] +ndarray = ["dep:ndarray", "quantity/ndarray", "dep:csv"] +rayon = ["dep:rayon", "ndarray/rayon", "ndarray"] diff --git a/crates/feos-core/src/ad/mod.rs b/crates/feos-core/src/ad/mod.rs index bfd86de82..3014bdb6e 100644 --- a/crates/feos-core/src/ad/mod.rs +++ b/crates/feos-core/src/ad/mod.rs @@ -1,15 +1,15 @@ -#[cfg(feature = "rayon")] +#[cfg(feature = "ndarray")] pub mod parameter_optimization; use crate::DensityInitialization::Liquid; use crate::density_iteration::density_iteration; use crate::{Composition, FeosResult, PhaseEquilibrium, ReferenceSystem, Residual, State}; use nalgebra::{Const, SVector, U1, U2}; -#[cfg(feature = "rayon")] +#[cfg(feature = "ndarray")] use ndarray::{Array1, Array2, ArrayView2, Zip}; use num_dual::{Derivative, DualNum, DualSVec, DualStruct, first_derivative, partial2}; use quantity::{Density, MolarEnergy, MolarEntropy, Pressure, Temperature}; -#[cfg(feature = "rayon")] +#[cfg(feature = "ndarray")] use quantity::{JOULE, KELVIN, KILO, METER, MOL, PASCAL}; type Gradient = DualSVec; @@ -230,7 +230,7 @@ pub trait PropertiesAD { Ok(MolarEnergy::from_reduced(dh)) } - #[cfg(feature = "rayon")] + #[cfg(feature = "ndarray")] fn vapor_pressure_parallel( parameter_names: [String; P], parameters: ArrayView2, @@ -239,7 +239,7 @@ pub trait PropertiesAD { where Self: ParametersAD<1>, { - parallelize::<_, Self, _, _>( + vectorize::<_, Self, _, _>( parameter_names, parameters, input, @@ -250,7 +250,7 @@ pub trait PropertiesAD { ) } - #[cfg(feature = "rayon")] + #[cfg(feature = "ndarray")] fn boiling_temperature_parallel( parameter_names: [String; P], parameters: ArrayView2, @@ -259,7 +259,7 @@ pub trait PropertiesAD { where Self: ParametersAD<1>, { - parallelize::<_, Self, _, _>( + vectorize::<_, Self, _, _>( parameter_names, parameters, input, @@ -270,7 +270,7 @@ pub trait PropertiesAD { ) } - #[cfg(feature = "rayon")] + #[cfg(feature = "ndarray")] fn liquid_density_parallel( parameter_names: [String; P], parameters: ArrayView2, @@ -279,7 +279,7 @@ pub trait PropertiesAD { where Self: ParametersAD<1>, { - parallelize::<_, Self, _, _>( + vectorize::<_, Self, _, _>( parameter_names, parameters, input, @@ -290,7 +290,7 @@ pub trait PropertiesAD { ) } - #[cfg(feature = "rayon")] + #[cfg(feature = "ndarray")] fn residual_isobaric_heat_capacity_parallel( parameter_names: [String; P], parameters: ArrayView2, @@ -299,7 +299,7 @@ pub trait PropertiesAD { where Self: ParametersAD<1>, { - parallelize::<_, Self, _, _>( + vectorize::<_, Self, _, _>( parameter_names, parameters, input, @@ -310,7 +310,7 @@ pub trait PropertiesAD { ) } - #[cfg(feature = "rayon")] + #[cfg(feature = "ndarray")] fn enthalpy_of_vaporization_parallel( parameter_names: [String; P], parameters: ArrayView2, @@ -319,7 +319,7 @@ pub trait PropertiesAD { where Self: ParametersAD<1>, { - parallelize::<_, Self, _, _>( + vectorize::<_, Self, _, _>( parameter_names, parameters, input, @@ -330,7 +330,7 @@ pub trait PropertiesAD { ) } - #[cfg(feature = "rayon")] + #[cfg(feature = "ndarray")] fn equilibrium_liquid_density_parallel( parameter_names: [String; P], parameters: ArrayView2, @@ -339,7 +339,7 @@ pub trait PropertiesAD { where Self: ParametersAD<1>, { - parallelize::<_, Self, _, _>( + vectorize::<_, Self, _, _>( parameter_names, parameters, input, @@ -452,7 +452,7 @@ pub trait PropertiesAD { Ok(Pressure::from_reduced(p)) } - #[cfg(feature = "rayon")] + #[cfg(feature = "ndarray")] fn bubble_point_pressure_parallel( parameter_names: [String; P], parameters: ArrayView2, @@ -461,7 +461,7 @@ pub trait PropertiesAD { where Self: ParametersAD<2>, { - parallelize::<_, Self, _, _>( + vectorize::<_, Self, _, _>( parameter_names, parameters, input, @@ -472,7 +472,7 @@ pub trait PropertiesAD { ) } - #[cfg(feature = "rayon")] + #[cfg(feature = "ndarray")] fn dew_point_pressure_parallel( parameter_names: [String; P], parameters: ArrayView2, @@ -481,7 +481,7 @@ pub trait PropertiesAD { where Self: ParametersAD<2>, { - parallelize::<_, Self, _, _>( + vectorize::<_, Self, _, _>( parameter_names, parameters, input, @@ -495,8 +495,8 @@ pub trait PropertiesAD { impl PropertiesAD for T {} -#[cfg(feature = "rayon")] -fn parallelize, const N: usize, const P: usize>( +#[cfg(feature = "ndarray")] +fn vectorize, const N: usize, const P: usize>( parameter_names: [String; P], parameters: ArrayView2, input: ArrayView2, @@ -506,6 +506,8 @@ where F: Fn(&E::Lifted>, &[f64]) -> FeosResult> + Sync, { let parameter_names = parameter_names.each_ref().map(|s| s as &str); + + #[cfg(feature = "rayon")] let value_dual = Zip::from(parameters.rows()) .and(input.rows()) .par_map_collect(|par, inp| { @@ -514,6 +516,17 @@ where let eos = E::seed_derivatives(par, parameter_names); f(&eos, inp) }); + + #[cfg(not(feature = "rayon"))] + let value_dual = Zip::from(parameters.rows()) + .and(input.rows()) + .map_collect(|par, inp| { + let par = par.as_slice().expect("Parameter array is not contiguous!"); + let inp = inp.as_slice().expect("Input array is not contiguous!"); + let eos = E::seed_derivatives(par, parameter_names); + f(&eos, inp) + }); + let n = parameters.nrows(); let status = value_dual.iter().map(|p| p.is_ok()).collect(); let mut value = Array1::from_elem(n, f64::NAN); diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset.rs b/crates/feos-core/src/ad/parameter_optimization/dataset.rs deleted file mode 100644 index 246a73df9..000000000 --- a/crates/feos-core/src/ad/parameter_optimization/dataset.rs +++ /dev/null @@ -1,947 +0,0 @@ -use crate::{ParametersAD, PropertiesAD}; -use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; -use std::{io, path::Path}; - -/// Per-dataset evaluation result: inputs, experimental target, model -/// prediction, and derived statistics at a given set of parameters. -/// -/// Produced by [`crate::Regressor::evaluate_datasets`]. -/// Fully serializable and convertablec into any tabular format (CSV, JSON, DataFrame). -#[derive(Debug, Serialize)] -pub struct DatasetResult { - /// Dataset name (default property name or user-supplied). - pub name: String, - /// Input column names and their values. - /// In the order they appear in record struct of Dataset. - pub inputs: Vec<(&'static str, Vec)>, - /// Name of the target property column. - pub target_name: &'static str, - /// Experimental target values. - pub target: Vec, - /// Values at the given parameters predicted by the model. - /// `NaN` for points where calculations did not converge. - pub predicted: Vec, - /// Whether the calculation converged for each point. - pub converged: Vec, - /// Relative deviation `(predicted − target) / target`. - /// `NaN` for non-converged points. - pub relative_deviation: Vec, -} - -/// Container for experimental data that can be evaluated -/// with models that implement [`ParametersAD`]. -pub trait Dataset { - /// Record of the property (row in a CSV file) - type Record: DeserializeOwned; - - /// Dataset from a vector of records. - fn from_records(records: Vec) -> Self; - - /// Parse a CSV file and build the dataset. - /// - /// Column headers must match the field names of [`Self::Record`]. - fn from_csv(path: &Path) -> Result - where - Self: Sized, - { - let records = csv::Reader::from_path(path)? - .deserialize() - .collect::, _>>()?; - Ok(Self::from_records(records)) - } - - /// Parse CSV data from sources that implement [`std::io::Read`]. - /// - /// Useful for reading from in memory buffers, network streams, - /// or compressed sources. - /// - /// Column headers must match the field names of [`Self::Record`]. - fn from_reader(reader: R) -> Result - where - Self: Sized, - { - let records = csv::Reader::from_reader(reader) - .deserialize() - .collect::, _>>()?; - Ok(Self::from_records(records)) - } - - /// Inputs for (parallel) model evaluation, shape `[n_points, k]`. - fn inputs(&self) -> ArrayView2<'_, f64>; - - /// Target values, shape `[n_points]`. - fn target(&self) -> ArrayView1<'_, f64>; - - /// Property name used for logging and diagnostics. - fn name(&self) -> &str; - - /// Names of the input columns, in the order they appear in [`Self::inputs`]. - /// - /// These match the non-target field names of [`Self::Record`]. - fn input_names() -> &'static [&'static str] - where - Self: Sized; - - /// Name of the target property column. - /// - /// Matches the target field name of [`Self::Record`] (e.g. - /// `"vapor_pressure_pa"` for [`VaporPressureDataset`]). - fn target_name() -> &'static str - where - Self: Sized; - - /// Method for model evaluation of this property. - fn call_model, const P: usize>( - names: [String; P], - parameters: ArrayView2, - inputs: ArrayView2, - ) -> (Array1, Array2, Array1); - - /// Evaluate the property and its parameter gradients at the given parameters. - /// - /// - `param_names`: names of the `P` model parameters that are differentiated. - /// Length determines the compile-time `P` through runtime dispatch. - /// - `params`: the full parameter vector (not just the fitted subset). - /// `seed_derivatives` seeds only the entries listed in `param_names`. - /// - /// Returns `(predicted, gradients, converged)`: - /// - `predicted`: shape `[n_points]`, computed property values in SI units. - /// - `gradients`: shape `[n_points, P]`, ∂property/∂param for each point. - /// - `converged`: shape `[n_points]`, `true` where the property calculation - /// succeeded. - /// - /// Non-converged state points yield NaN for predictions and zero for gradients. - fn compute>( - &self, - param_names: &[String], - params: &[f64], - ) -> (Array1, Array2, Array1) { - let n = self.inputs().nrows(); - - // Repeat parameters for each data point. - let parameters = Array2::from_shape_fn((n, params.len()), |(_, j)| params[j]); - - // Translate slice to const array for the const-generic call. - fn to_const(names: &[String]) -> [String; P] { - names.to_vec().try_into().expect("parameter count mismatch") - } - - match param_names.len() { - 1 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), - 2 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), - 3 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), - 4 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), - 5 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), - 6 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), - 7 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), - 8 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), - n => panic!("too many parameters: {n} (max 8)"), - } - } -} - -/// Record for vapor pressure data. -/// -/// Expected column headers: `temperature_k`, `vapor_pressure_pa`. -#[derive(Deserialize, Serialize)] -pub struct VaporPressureRecord { - pub temperature_k: f64, - pub vapor_pressure_pa: f64, -} - -/// Vapor pressure dataset for pure substances. -/// -/// Inputs: `[[T1], [T2], ...]` (temperature in K) -/// Target: vapor pressure in Pa -#[derive(Clone)] -pub struct VaporPressureDataset { - inputs: Array2, - target: Array1, - name: Option, -} - -impl Dataset<1> for VaporPressureDataset { - type Record = VaporPressureRecord; - - fn from_records(records: Vec) -> Self { - let n = records.len(); - let inputs = Array2::from_shape_fn((n, 1), |(i, _)| records[i].temperature_k); - let target = Array1::from_iter(records.iter().map(|r| r.vapor_pressure_pa)); - Self { - inputs, - target, - name: None, - } - } - - fn inputs(&self) -> ArrayView2<'_, f64> { - self.inputs.view() - } - - fn target(&self) -> ArrayView1<'_, f64> { - self.target.view() - } - - fn name(&self) -> &str { - self.name.as_deref().unwrap_or("vapor pressure") - } - - fn input_names() -> &'static [&'static str] { - &["temperature_k"] - } - - fn target_name() -> &'static str { - "vapor_pressure_pa" - } - - fn call_model, const P: usize>( - names: [String; P], - parameters: ArrayView2, - inputs: ArrayView2, - ) -> (Array1, Array2, Array1) { - T::vapor_pressure_parallel(names, parameters, inputs) - } -} - -/// Liquid density record at given temperature and pressure. -/// -/// Expected column headers: `temperature_k`, `pressure_pa`, `liquid_density_molm3`. -#[derive(Deserialize, Serialize)] -pub struct LiquidDensityRecord { - pub temperature_k: f64, - pub pressure_pa: f64, - pub liquid_density_molm3: f64, -} - -/// Liquid density dataset for pure substances at given temperature and pressure. -/// -/// Inputs: `[[T1, p1], [T2, p2], ...]` (K, Pa) -/// Target: molar density in kmol/m³ -#[derive(Clone)] -pub struct LiquidDensityDataset { - inputs: Array2, - target: Array1, - name: Option, -} - -impl Dataset<1> for LiquidDensityDataset { - type Record = LiquidDensityRecord; - - fn from_records(records: Vec) -> Self { - let n = records.len(); - let inputs = Array2::from_shape_fn((n, 2), |(i, j)| match j { - 0 => records[i].temperature_k, - _ => records[i].pressure_pa, - }); - let target = Array1::from_iter(records.iter().map(|r| r.liquid_density_molm3)); - Self { - inputs, - target, - name: None, - } - } - - fn inputs(&self) -> ArrayView2<'_, f64> { - self.inputs.view() - } - - fn target(&self) -> ArrayView1<'_, f64> { - self.target.view() - } - - fn name(&self) -> &str { - self.name.as_deref().unwrap_or("liquid density") - } - - fn input_names() -> &'static [&'static str] { - &["temperature_k", "pressure_pa"] - } - - fn target_name() -> &'static str { - "liquid_density_molm3" - } - - fn call_model, const P: usize>( - names: [String; P], - parameters: ArrayView2, - inputs: ArrayView2, - ) -> (Array1, Array2, Array1) { - T::liquid_density_parallel(names, parameters, inputs) - } -} - -/// Equilibrium liquid density record (saturated liquid). -/// -/// Expected column headers: `temperature_k`, `liquid_density_molm3`. -#[derive(Deserialize, Serialize)] -pub struct EquilibriumLiquidDensityRecord { - pub temperature_k: f64, - pub liquid_density_molm3: f64, -} - -/// Liquid density (at equilibrium) dataset for pure substances. -/// -/// Inputs: `[[T1], [T2], ...]` (temperature in K) -/// Target: molar density in kmol/m³ -#[derive(Clone)] -pub struct EquilibriumLiquidDensityDataset { - inputs: Array2, - target: Array1, - name: Option, -} - -impl Dataset<1> for EquilibriumLiquidDensityDataset { - type Record = EquilibriumLiquidDensityRecord; - - fn from_records(records: Vec) -> Self { - let n = records.len(); - let inputs = Array2::from_shape_fn((n, 1), |(i, _)| records[i].temperature_k); - let target = Array1::from_iter(records.iter().map(|r| r.liquid_density_molm3)); - Self { - inputs, - target, - name: None, - } - } - - fn inputs(&self) -> ArrayView2<'_, f64> { - self.inputs.view() - } - fn target(&self) -> ArrayView1<'_, f64> { - self.target.view() - } - fn name(&self) -> &str { - self.name.as_deref().unwrap_or("equilibrium liquid density") - } - - fn input_names() -> &'static [&'static str] { - &["temperature_k"] - } - - fn target_name() -> &'static str { - "liquid_density_molm3" - } - - fn call_model, const P: usize>( - names: [String; P], - parameters: ArrayView2, - inputs: ArrayView2, - ) -> (Array1, Array2, Array1) { - T::equilibrium_liquid_density_parallel(names, parameters, inputs) - } -} - -/// Record for enthalpy of vaporization data. -/// -/// Expected column headers: `temperature_k`, `dh_j_mol`. -#[derive(Deserialize, Serialize)] -pub struct EnthalpyOfVaporizationRecord { - pub temperature_k: f64, - pub dh_vap_j_mol: f64, -} - -/// Enthalpy of vaporization dataset for pure substances. -/// -/// Inputs: `[[T1], [T2], ...]` (temperature in K) -/// Target: enthalpy of vaporization in J/mol -#[derive(Clone)] -pub struct EnthalpyOfVaporizationDataset { - inputs: Array2, - target: Array1, - name: Option, -} - -impl Dataset<1> for EnthalpyOfVaporizationDataset { - type Record = EnthalpyOfVaporizationRecord; - - fn from_records(records: Vec) -> Self { - let n = records.len(); - let inputs = Array2::from_shape_fn((n, 1), |(i, _)| records[i].temperature_k); - let target = Array1::from_iter(records.iter().map(|r| r.dh_vap_j_mol)); - Self { - inputs, - target, - name: None, - } - } - - fn inputs(&self) -> ArrayView2<'_, f64> { - self.inputs.view() - } - - fn target(&self) -> ArrayView1<'_, f64> { - self.target.view() - } - - fn name(&self) -> &str { - self.name.as_deref().unwrap_or("enthalpy of vaporization") - } - - fn input_names() -> &'static [&'static str] { - &["temperature_k"] - } - - fn target_name() -> &'static str { - "dh_vap_j_mol" - } - - fn call_model, const P: usize>( - names: [String; P], - parameters: ArrayView2, - inputs: ArrayView2, - ) -> (Array1, Array2, Array1) { - T::enthalpy_of_vaporization_parallel(names, parameters, inputs) - } -} - -/// Record for residual isobaric molar heat capacity data (liquid phase). -/// -/// Expected column headers: `temperature_k`, `pressure_pa`, `cp_res_j_molk`. -#[derive(Deserialize, Serialize)] -pub struct ResidualIsobaricHeatCapacityRecord { - pub temperature_k: f64, - pub pressure_pa: f64, - pub cp_res_j_molk: f64, -} - -/// Residual isobaric molar heat capacity dataset (liquid phase). -/// -/// Inputs: `[[T1, p1], [T2, p2], ...]` (K, Pa) -/// Target: residual molar isobaric heat capacity in J/(mol·K) -#[derive(Clone)] -pub struct ResidualIsobaricHeatCapacityDataset { - inputs: Array2, - target: Array1, - name: Option, -} - -impl Dataset<1> for ResidualIsobaricHeatCapacityDataset { - type Record = ResidualIsobaricHeatCapacityRecord; - - fn from_records(records: Vec) -> Self { - let n = records.len(); - let inputs = Array2::from_shape_fn((n, 2), |(i, j)| match j { - 0 => records[i].temperature_k, - _ => records[i].pressure_pa, - }); - let target = Array1::from_iter(records.iter().map(|r| r.cp_res_j_molk)); - Self { - inputs, - target, - name: None, - } - } - - fn inputs(&self) -> ArrayView2<'_, f64> { - self.inputs.view() - } - - fn target(&self) -> ArrayView1<'_, f64> { - self.target.view() - } - - fn name(&self) -> &str { - self.name - .as_deref() - .unwrap_or("residual isobaric heat capacity") - } - - fn input_names() -> &'static [&'static str] { - &["temperature_k", "pressure_pa"] - } - - fn target_name() -> &'static str { - "cp_res_j_molk" - } - - fn call_model, const P: usize>( - names: [String; P], - parameters: ArrayView2, - inputs: ArrayView2, - ) -> (Array1, Array2, Array1) { - T::residual_isobaric_heat_capacity_parallel(names, parameters, inputs) - } -} - -/// Bubble point pressure record for binary mixtures. -/// -/// Expected column headers: `temperature_k`, `liquid_molefrac_1`, -/// `bubble_pressure_pa`. The pressure column doubles as the initial guess -/// passed to the VLE solver. -#[derive(Deserialize, Serialize)] -pub struct BubblePointRecord { - pub temperature_k: f64, - pub liquid_molefrac_1: f64, - pub bubble_pressure_pa: f64, -} - -/// Bubble point pressure dataset for binary mixtures. -/// -/// Inputs: `[[T1, x11, p1], ...]` (K, -, Pa) -/// Target: bubble point pressure in Pa -#[derive(Clone)] -pub struct BubblePointDataset { - inputs: Array2, - target: Array1, - name: Option, -} - -impl Dataset<2> for BubblePointDataset { - type Record = BubblePointRecord; - - fn from_records(records: Vec) -> Self { - let n = records.len(); - let inputs = Array2::from_shape_fn((n, 3), |(i, j)| match j { - 0 => records[i].temperature_k, - 1 => records[i].liquid_molefrac_1, - _ => records[i].bubble_pressure_pa, - }); - let target = Array1::from_iter(records.iter().map(|r| r.bubble_pressure_pa)); - Self { - inputs, - target, - name: None, - } - } - - fn inputs(&self) -> ArrayView2<'_, f64> { - self.inputs.view() - } - fn target(&self) -> ArrayView1<'_, f64> { - self.target.view() - } - fn name(&self) -> &str { - self.name.as_deref().unwrap_or("bubble point pressure") - } - - fn input_names() -> &'static [&'static str] { - &["temperature_k", "liquid_molefrac_1"] - } - - fn target_name() -> &'static str { - "bubble_pressure_pa" - } - - fn call_model, const P: usize>( - names: [String; P], - parameters: ArrayView2, - inputs: ArrayView2, - ) -> (Array1, Array2, Array1) { - T::bubble_point_pressure_parallel(names, parameters, inputs) - } -} - -/// Dew point pressure record for binary mixtures. -/// -/// Expected column headers: `temperature_k`, `vapor_molefrac_1`, -/// `dew_pressure_pa`. The pressure column doubles as the initial guess -/// passed to the VLE solver. -#[derive(Deserialize, Serialize)] -pub struct DewPointRecord { - pub temperature_k: f64, - pub vapor_molefrac_1: f64, - pub dew_pressure_pa: f64, -} - -/// Dew point pressure dataset for binary mixtures. -/// -/// Inputs: `[[T1, y11, p1], ...]` (K, -, Pa) -/// Target: dew point pressure in Pa -#[derive(Clone)] -pub struct DewPointDataset { - inputs: Array2, - target: Array1, - name: Option, -} - -impl Dataset<2> for DewPointDataset { - type Record = DewPointRecord; - - fn from_records(records: Vec) -> Self { - let n = records.len(); - let inputs = Array2::from_shape_fn((n, 3), |(i, j)| match j { - 0 => records[i].temperature_k, - 1 => records[i].vapor_molefrac_1, - _ => records[i].dew_pressure_pa, - }); - let target = Array1::from_iter(records.iter().map(|r| r.dew_pressure_pa)); - Self { - inputs, - target, - name: None, - } - } - - fn inputs(&self) -> ArrayView2<'_, f64> { - self.inputs.view() - } - fn target(&self) -> ArrayView1<'_, f64> { - self.target.view() - } - fn name(&self) -> &str { - self.name.as_deref().unwrap_or("dew point pressure") - } - - fn input_names() -> &'static [&'static str] { - &["temperature_k", "vapor_molefrac_1"] - } - - fn target_name() -> &'static str { - "dew_pressure_pa" - } - - fn call_model, const P: usize>( - names: [String; P], - parameters: ArrayView2, - inputs: ArrayView2, - ) -> (Array1, Array2, Array1) { - T::dew_point_pressure_parallel(names, parameters, inputs) - } -} - -macro_rules! impl_with_name { - ($($ty:ty),+ $(,)?) => { - $( - impl $ty { - /// Override the dataset name used in solver diagnostics and results. - /// - /// The name must be unique within a solver — [`Solver::new`] returns - /// [`FittingError::DuplicateDatasetName`] if two datasets share the - /// same name (whether default or user-supplied). - pub fn with_name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - } - )+ - }; -} - -impl_with_name!( - VaporPressureDataset, - LiquidDensityDataset, - EquilibriumLiquidDensityDataset, - EnthalpyOfVaporizationDataset, - ResidualIsobaricHeatCapacityDataset, - BubblePointDataset, - DewPointDataset, -); - -/// Collection of pure-component datasets. -/// -/// Wraps any [`Dataset<1>`] implementor so that mixed-property lists can be -/// stored in a `Vec` without requiring trait objects. -#[derive(Clone)] -pub enum PureDataset { - VaporPressure(VaporPressureDataset), - LiquidDensity(LiquidDensityDataset), - EquilibriumLiquidDensity(EquilibriumLiquidDensityDataset), - EnthalpyOfVaporization(EnthalpyOfVaporizationDataset), - ResidualIsobaricHeatCapacity(ResidualIsobaricHeatCapacityDataset), -} - -impl PureDataset { - pub fn target(&self) -> ArrayView1<'_, f64> { - match self { - Self::VaporPressure(d) => d.target(), - Self::LiquidDensity(d) => d.target(), - Self::EquilibriumLiquidDensity(d) => d.target(), - Self::EnthalpyOfVaporization(d) => d.target(), - Self::ResidualIsobaricHeatCapacity(d) => d.target(), - } - } - - pub fn name(&self) -> &str { - match self { - Self::VaporPressure(d) => d.name(), - Self::LiquidDensity(d) => d.name(), - Self::EquilibriumLiquidDensity(d) => d.name(), - Self::EnthalpyOfVaporization(d) => d.name(), - Self::ResidualIsobaricHeatCapacity(d) => d.name(), - } - } - - pub fn inputs(&self) -> ArrayView2<'_, f64> { - match self { - Self::VaporPressure(d) => d.inputs(), - Self::LiquidDensity(d) => d.inputs(), - Self::EquilibriumLiquidDensity(d) => d.inputs(), - Self::EnthalpyOfVaporization(d) => d.inputs(), - Self::ResidualIsobaricHeatCapacity(d) => d.inputs(), - } - } - - pub fn input_names(&self) -> &'static [&'static str] { - match self { - Self::VaporPressure(_) => VaporPressureDataset::input_names(), - Self::LiquidDensity(_) => LiquidDensityDataset::input_names(), - Self::EquilibriumLiquidDensity(_) => EquilibriumLiquidDensityDataset::input_names(), - Self::EnthalpyOfVaporization(_) => EquilibriumLiquidDensityDataset::input_names(), - Self::ResidualIsobaricHeatCapacity(_) => { - ResidualIsobaricHeatCapacityDataset::input_names() - } - } - } - - pub fn target_name(&self) -> &'static str { - match self { - Self::VaporPressure(_) => VaporPressureDataset::target_name(), - Self::LiquidDensity(_) => LiquidDensityDataset::target_name(), - Self::EquilibriumLiquidDensity(_) => EquilibriumLiquidDensityDataset::target_name(), - Self::EnthalpyOfVaporization(_) => EnthalpyOfVaporizationDataset::target_name(), - Self::ResidualIsobaricHeatCapacity(_) => { - ResidualIsobaricHeatCapacityDataset::target_name() - } - } - } - - pub fn compute>( - &self, - param_names: &[String], - params: &[f64], - ) -> (Array1, Array2, Array1) { - match self { - Self::VaporPressure(d) => d.compute::(param_names, params), - Self::LiquidDensity(d) => d.compute::(param_names, params), - Self::EquilibriumLiquidDensity(d) => d.compute::(param_names, params), - Self::EnthalpyOfVaporization(d) => d.compute::(param_names, params), - Self::ResidualIsobaricHeatCapacity(d) => d.compute::(param_names, params), - } - } -} - -/// Collection of binary mixture datasets. -/// -/// Wraps any [`Dataset<2>`] implementor so that mixed-property lists can be -/// stored in a `Vec` without requiring trait objects. -#[derive(Clone)] -pub enum BinaryDataset { - BubblePoint(BubblePointDataset), - DewPoint(DewPointDataset), -} - -impl BinaryDataset { - pub fn target(&self) -> ArrayView1<'_, f64> { - match self { - Self::BubblePoint(d) => d.target(), - Self::DewPoint(d) => d.target(), - } - } - - pub fn name(&self) -> &str { - match self { - Self::BubblePoint(d) => d.name(), - Self::DewPoint(d) => d.name(), - } - } - - pub fn inputs(&self) -> ArrayView2<'_, f64> { - match self { - Self::BubblePoint(d) => d.inputs(), - Self::DewPoint(d) => d.inputs(), - } - } - - pub fn input_names(&self) -> &'static [&'static str] { - match self { - Self::BubblePoint(_) => BubblePointDataset::input_names(), - Self::DewPoint(_) => DewPointDataset::input_names(), - } - } - - pub fn target_name(&self) -> &'static str { - match self { - Self::BubblePoint(_) => BubblePointDataset::target_name(), - Self::DewPoint(_) => DewPointDataset::target_name(), - } - } - - pub fn compute>( - &self, - param_names: &[String], - params: &[f64], - ) -> (Array1, Array2, Array1) { - match self { - Self::BubblePoint(d) => d.compute::(param_names, params), - Self::DewPoint(d) => d.compute::(param_names, params), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - - // Helper: build a Cursor from a string literal. - fn csv(s: &str) -> Cursor<&[u8]> { - Cursor::new(s.as_bytes()) - } - - #[test] - fn vapor_pressure_from_reader() { - let data = "\ -temperature_k,vapor_pressure_pa -300.0,3540.0 -350.0,41682.0 -400.0,245600.0 -"; - let ds = VaporPressureDataset::from_reader(csv(data)).unwrap(); - - assert_eq!(ds.target().len(), 3); - assert_eq!(ds.inputs().nrows(), 3); - assert_eq!(ds.inputs().ncols(), 1); - - // temperatures land in the single input column - assert_eq!(ds.inputs()[[0, 0]], 300.0); - assert_eq!(ds.inputs()[[1, 0]], 350.0); - assert_eq!(ds.inputs()[[2, 0]], 400.0); - - // experimental values - assert_eq!(ds.target()[0], 3540.0); - assert_eq!(ds.target()[1], 41682.0); - assert_eq!(ds.target()[2], 245600.0); - - assert_eq!(ds.name(), "vapor pressure"); - } - - #[test] - fn vapor_pressure_from_records() { - let records = vec![ - VaporPressureRecord { - temperature_k: 300.0, - vapor_pressure_pa: 3540.0, - }, - VaporPressureRecord { - temperature_k: 350.0, - vapor_pressure_pa: 41682.0, - }, - ]; - let ds = VaporPressureDataset::from_records(records); - assert_eq!(ds.inputs()[[0, 0]], 300.0); - assert_eq!(ds.target()[1], 41682.0); - } - - #[test] - fn liquid_density_from_reader() { - let data = "\ -temperature_k,pressure_pa,liquid_density_molm3 -300.0,101325.0,15.2 -320.0,200000.0,14.8 -"; - let ds = LiquidDensityDataset::from_reader(csv(data)).unwrap(); - - assert_eq!(ds.inputs().nrows(), 2); - assert_eq!(ds.inputs().ncols(), 2); - - // column 0: temperature, column 1: pressure - assert_eq!(ds.inputs()[[0, 0]], 300.0); - assert_eq!(ds.inputs()[[0, 1]], 101325.0); - assert_eq!(ds.inputs()[[1, 0]], 320.0); - assert_eq!(ds.inputs()[[1, 1]], 200000.0); - - assert_eq!(ds.target()[0], 15.2); - assert_eq!(ds.target()[1], 14.8); - - assert_eq!(ds.name(), "liquid density"); - } - - #[test] - fn liquid_density_from_records() { - let records = vec![LiquidDensityRecord { - temperature_k: 300.0, - pressure_pa: 101325.0, - liquid_density_molm3: 15.2, - }]; - let ds = LiquidDensityDataset::from_records(records); - assert_eq!(ds.inputs()[[0, 1]], 101325.0); - assert_eq!(ds.target()[0], 15.2); - } - - #[test] - fn equilibrium_liquid_density_from_reader() { - let data = "\ -temperature_k,liquid_density_molm3 -290.0,15.5 -310.0,14.9 -330.0,14.1 -"; - let ds = EquilibriumLiquidDensityDataset::from_reader(csv(data)).unwrap(); - - assert_eq!(ds.inputs().ncols(), 1); - assert_eq!(ds.inputs().nrows(), 3); - assert_eq!(ds.inputs()[[2, 0]], 330.0); - assert_eq!(ds.target()[2], 14.1); - assert_eq!(ds.name(), "equilibrium liquid density"); - } - - #[test] - fn bubble_point_from_reader() { - let data = "\ -temperature_k,liquid_molefrac_1,bubble_pressure_pa -300.0,0.3,500000.0 -320.0,0.5,800000.0 -"; - let ds = BubblePointDataset::from_reader(csv(data)).unwrap(); - - assert_eq!(ds.inputs().ncols(), 3); - assert_eq!(ds.inputs().nrows(), 2); - - // column 0: T, column 1: x1, column 2: pressure (initial guess) - assert_eq!(ds.inputs()[[0, 0]], 300.0); - assert_eq!(ds.inputs()[[0, 1]], 0.3); - assert_eq!(ds.inputs()[[0, 2]], 500000.0); - - // experimental = bubble pressure - assert_eq!(ds.target()[0], 500000.0); - assert_eq!(ds.target()[1], 800000.0); - - assert_eq!(ds.name(), "bubble point pressure"); - } - - #[test] - fn dew_point_from_reader() { - let data = "\ -temperature_k,vapor_molefrac_1,dew_pressure_pa -310.0,0.7,400000.0 -330.0,0.9,700000.0 -"; - let ds = DewPointDataset::from_reader(csv(data)).unwrap(); - - assert_eq!(ds.inputs().ncols(), 3); - assert_eq!(ds.inputs()[[0, 0]], 310.0); - assert_eq!(ds.inputs()[[0, 1]], 0.7); - assert_eq!(ds.inputs()[[0, 2]], 400000.0); - assert_eq!(ds.target()[1], 700000.0); - assert_eq!(ds.name(), "dew point pressure"); - } - - #[test] - fn dew_point_pressure_doubles_as_initial_guess() { - let records = vec![DewPointRecord { - temperature_k: 310.0, - vapor_molefrac_1: 0.7, - dew_pressure_pa: 400000.0, - }]; - let ds = DewPointDataset::from_records(records); - assert_eq!(ds.inputs()[[0, 2]], ds.target()[0]); - } - - #[test] - fn missing_column_returns_error() { - // CSV is missing the vapor_pressure_pa column entirely. - let data = "temperature_k\n300.0\n"; - let result = VaporPressureDataset::from_reader(csv(data)); - assert!(result.is_err()); - } - - #[test] - fn wrong_type_returns_error() { - // pressure is not a float - let data = "temperature_k,vapor_pressure_pa\n300.0,not_a_number\n"; - let result = VaporPressureDataset::from_reader(csv(data)); - assert!(result.is_err()); - } -} diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset/bubble_point.rs b/crates/feos-core/src/ad/parameter_optimization/dataset/bubble_point.rs new file mode 100644 index 000000000..ee977ef35 --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/dataset/bubble_point.rs @@ -0,0 +1,70 @@ +use crate::{ParametersAD, PropertiesAD}; +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use serde::{Deserialize, Serialize}; + +use super::Dataset; + +/// The pressure column doubles as the initial guess passed to the VLE solver. +#[derive(Deserialize, Serialize)] +pub struct BubblePointRecord { + pub temperature_k: f64, + pub liquid_molefrac_1: f64, + pub bubble_pressure_pa: f64, +} + +#[derive(Clone)] +pub struct BubblePointDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl BubblePointDataset { + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + +impl Dataset<2> for BubblePointDataset { + type Record = BubblePointRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 3), |(i, j)| match j { + 0 => records[i].temperature_k, + 1 => records[i].liquid_molefrac_1, + _ => records[i].bubble_pressure_pa, + }); + let target = Array1::from_iter(records.iter().map(|r| r.bubble_pressure_pa)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("bubble point pressure") + } + fn input_names() -> &'static [&'static str] { + &["temperature_k", "liquid_molefrac_1"] + } + fn target_name() -> &'static str { + "bubble_pressure_pa" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::bubble_point_pressure_parallel(names, parameters, inputs) + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset/dew_point.rs b/crates/feos-core/src/ad/parameter_optimization/dataset/dew_point.rs new file mode 100644 index 000000000..ac10448b2 --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/dataset/dew_point.rs @@ -0,0 +1,70 @@ +use crate::{ParametersAD, PropertiesAD}; +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use serde::{Deserialize, Serialize}; + +use super::Dataset; + +/// The pressure column doubles as the initial guess passed to the VLE solver. +#[derive(Deserialize, Serialize)] +pub struct DewPointRecord { + pub temperature_k: f64, + pub vapor_molefrac_1: f64, + pub dew_pressure_pa: f64, +} + +#[derive(Clone)] +pub struct DewPointDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl DewPointDataset { + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + +impl Dataset<2> for DewPointDataset { + type Record = DewPointRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 3), |(i, j)| match j { + 0 => records[i].temperature_k, + 1 => records[i].vapor_molefrac_1, + _ => records[i].dew_pressure_pa, + }); + let target = Array1::from_iter(records.iter().map(|r| r.dew_pressure_pa)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("dew point pressure") + } + fn input_names() -> &'static [&'static str] { + &["temperature_k", "vapor_molefrac_1"] + } + fn target_name() -> &'static str { + "dew_pressure_pa" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::dew_point_pressure_parallel(names, parameters, inputs) + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset/enthalpy_of_vaporization.rs b/crates/feos-core/src/ad/parameter_optimization/dataset/enthalpy_of_vaporization.rs new file mode 100644 index 000000000..263213a5b --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/dataset/enthalpy_of_vaporization.rs @@ -0,0 +1,64 @@ +use crate::{ParametersAD, PropertiesAD}; +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use serde::{Deserialize, Serialize}; + +use super::Dataset; + +#[derive(Deserialize, Serialize)] +pub struct EnthalpyOfVaporizationRecord { + pub temperature_k: f64, + pub dh_vap_j_mol: f64, +} + +#[derive(Clone)] +pub struct EnthalpyOfVaporizationDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl EnthalpyOfVaporizationDataset { + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + +impl Dataset<1> for EnthalpyOfVaporizationDataset { + type Record = EnthalpyOfVaporizationRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 1), |(i, _)| records[i].temperature_k); + let target = Array1::from_iter(records.iter().map(|r| r.dh_vap_j_mol)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("enthalpy of vaporization") + } + fn input_names() -> &'static [&'static str] { + &["temperature_k"] + } + fn target_name() -> &'static str { + "dh_vap_j_mol" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::enthalpy_of_vaporization_parallel(names, parameters, inputs) + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset/equilibrium_liquid_density.rs b/crates/feos-core/src/ad/parameter_optimization/dataset/equilibrium_liquid_density.rs new file mode 100644 index 000000000..87622c074 --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/dataset/equilibrium_liquid_density.rs @@ -0,0 +1,64 @@ +use crate::{ParametersAD, PropertiesAD}; +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use serde::{Deserialize, Serialize}; + +use super::Dataset; + +#[derive(Deserialize, Serialize)] +pub struct EquilibriumLiquidDensityRecord { + pub temperature_k: f64, + pub liquid_density_kmol_m3: f64, +} + +#[derive(Clone)] +pub struct EquilibriumLiquidDensityDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl EquilibriumLiquidDensityDataset { + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + +impl Dataset<1> for EquilibriumLiquidDensityDataset { + type Record = EquilibriumLiquidDensityRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 1), |(i, _)| records[i].temperature_k); + let target = Array1::from_iter(records.iter().map(|r| r.liquid_density_kmol_m3)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("equilibrium liquid density") + } + fn input_names() -> &'static [&'static str] { + &["temperature_k"] + } + fn target_name() -> &'static str { + "liquid_density_kmol_m3" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::equilibrium_liquid_density_parallel(names, parameters, inputs) + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset/liquid_density.rs b/crates/feos-core/src/ad/parameter_optimization/dataset/liquid_density.rs new file mode 100644 index 000000000..b21aafcdc --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/dataset/liquid_density.rs @@ -0,0 +1,68 @@ +use crate::{ParametersAD, PropertiesAD}; +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use serde::{Deserialize, Serialize}; + +use super::Dataset; + +#[derive(Deserialize, Serialize)] +pub struct LiquidDensityRecord { + pub temperature_k: f64, + pub pressure_pa: f64, + pub liquid_density_kmol_m3: f64, +} + +#[derive(Clone)] +pub struct LiquidDensityDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl LiquidDensityDataset { + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + +impl Dataset<1> for LiquidDensityDataset { + type Record = LiquidDensityRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 2), |(i, j)| match j { + 0 => records[i].temperature_k, + _ => records[i].pressure_pa, + }); + let target = Array1::from_iter(records.iter().map(|r| r.liquid_density_kmol_m3)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("liquid density") + } + fn input_names() -> &'static [&'static str] { + &["temperature_k", "pressure_pa"] + } + fn target_name() -> &'static str { + "liquid_density_kmol_m3" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::liquid_density_parallel(names, parameters, inputs) + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset/mod.rs b/crates/feos-core/src/ad/parameter_optimization/dataset/mod.rs new file mode 100644 index 000000000..b0bf6f11b --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/dataset/mod.rs @@ -0,0 +1,381 @@ +mod bubble_point; +mod dew_point; +mod enthalpy_of_vaporization; +mod equilibrium_liquid_density; +mod liquid_density; +mod residual_isobaric_heat_capacity; +mod vapor_pressure; + +pub use bubble_point::{BubblePointDataset, BubblePointRecord}; +pub use dew_point::{DewPointDataset, DewPointRecord}; +pub use enthalpy_of_vaporization::{EnthalpyOfVaporizationDataset, EnthalpyOfVaporizationRecord}; +pub use equilibrium_liquid_density::{ + EquilibriumLiquidDensityDataset, EquilibriumLiquidDensityRecord, +}; +pub use liquid_density::{LiquidDensityDataset, LiquidDensityRecord}; +pub use residual_isobaric_heat_capacity::{ + ResidualIsobaricHeatCapacityDataset, ResidualIsobaricHeatCapacityRecord, +}; +pub use vapor_pressure::{VaporPressureDataset, VaporPressureRecord}; + +use crate::ParametersAD; +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::{io, path::Path}; + +/// Per-dataset evaluation result: inputs, experimental target, model +/// prediction, and derived statistics at a given set of parameters. +/// +/// Produced by [`crate::Regressor::evaluate_datasets`]. +/// Fully serializable and convertable into any tabular format (CSV, JSON, DataFrame). +#[derive(Debug, Serialize)] +pub struct DatasetResult { + /// Dataset name (default property name or user-supplied). + pub name: String, + /// Input column names and their values, in the order they appear in the record struct. + pub inputs: Vec<(&'static str, Vec)>, + /// Name of the target property column. + pub target_name: &'static str, + /// Experimental target values. + pub target: Vec, + /// Values at the given parameters predicted by the model. + /// `NaN` for points where calculations did not converge. + pub predicted: Vec, + /// Whether the calculation converged for each point. + pub converged: Vec, + /// Relative deviation `(predicted − target) / target`. + /// `NaN` for non-converged points. + pub relative_deviation: Vec, +} + +/// Container for experimental data that can be evaluated +/// with models that implement [`ParametersAD`]. +pub trait Dataset { + /// Record of the property (row in a CSV file). + type Record: DeserializeOwned; + + /// Dataset from a vector of records. + fn from_records(records: Vec) -> Self; + + /// Parse a CSV file and build the dataset. + /// + /// Column headers must match the field names of [`Self::Record`]. + fn from_csv(path: &Path) -> Result + where + Self: Sized, + { + let records = csv::Reader::from_path(path)? + .deserialize() + .collect::, _>>()?; + Ok(Self::from_records(records)) + } + + /// Parse CSV data from any source that implements [`std::io::Read`]. + /// + /// Column headers must match the field names of [`Self::Record`]. + fn from_reader(reader: R) -> Result + where + Self: Sized, + { + let records = csv::Reader::from_reader(reader) + .deserialize() + .collect::, _>>()?; + Ok(Self::from_records(records)) + } + + /// Inputs for (parallel) model evaluation, shape `[n_points, k]`. + fn inputs(&self) -> ArrayView2<'_, f64>; + + /// Target values, shape `[n_points]`. + fn target(&self) -> ArrayView1<'_, f64>; + + /// Property name used for logging and diagnostics. + fn name(&self) -> &str; + + /// Names of the input columns, in the order they appear in [`Self::inputs`]. + fn input_names() -> &'static [&'static str] + where + Self: Sized; + + /// Name of the target property column. + fn target_name() -> &'static str + where + Self: Sized; + + /// Method for model evaluation of this property. + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1); + + /// Evaluate the property and its parameter gradients at the given parameters. + /// + /// - `param_names`: names of the `P` parameters being differentiated. + /// - `params`: the full parameter vector; only entries listed in `param_names` are seeded. + /// + /// Returns `(predicted, gradients, converged)`: + /// - `predicted`: shape `[n_points]`, in SI units. + /// - `gradients`: shape `[n_points, P]`. + /// - `converged`: shape `[n_points]`. + fn compute>( + &self, + param_names: &[String], + params: &[f64], + ) -> (Array1, Array2, Array1) { + let n = self.inputs().nrows(); + let parameters = Array2::from_shape_fn((n, params.len()), |(_, j)| params[j]); + + fn to_const(names: &[String]) -> [String; P] { + names.to_vec().try_into().expect("parameter count mismatch") + } + + match param_names.len() { + 1 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 2 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 3 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 4 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 5 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 6 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 7 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + 8 => Self::call_model::(to_const(param_names), parameters.view(), self.inputs()), + n => panic!("too many parameters: {n} (max 8)"), + } + } +} + +macro_rules! impl_dataset_enum { + ($n:literal, $enum:ident, [ $( ($variant:ident, $ty:ty) ),+ $(,)? ]) => { + impl $enum { + pub fn target(&self) -> ArrayView1<'_, f64> { + match self { $( Self::$variant(d) => d.target(), )+ } + } + pub fn name(&self) -> &str { + match self { $( Self::$variant(d) => d.name(), )+ } + } + pub fn inputs(&self) -> ArrayView2<'_, f64> { + match self { $( Self::$variant(d) => d.inputs(), )+ } + } + pub fn input_names(&self) -> &'static [&'static str] { + match self { $( Self::$variant(_) => <$ty>::input_names(), )+ } + } + pub fn target_name(&self) -> &'static str { + match self { $( Self::$variant(_) => <$ty>::target_name(), )+ } + } + pub fn compute>( + &self, + param_names: &[String], + params: &[f64], + ) -> (Array1, Array2, Array1) { + match self { $( Self::$variant(d) => d.compute::(param_names, params), )+ } + } + } + }; +} + +/// Collection of pure-component datasets. +/// +/// Wraps any [`Dataset<1>`] implementor so that mixed-property lists can be +/// stored in a `Vec` without requiring trait objects. +#[derive(Clone)] +pub enum PureDataset { + VaporPressure(VaporPressureDataset), + LiquidDensity(LiquidDensityDataset), + EquilibriumLiquidDensity(EquilibriumLiquidDensityDataset), + EnthalpyOfVaporization(EnthalpyOfVaporizationDataset), + ResidualIsobaricHeatCapacity(ResidualIsobaricHeatCapacityDataset), +} + +impl_dataset_enum!( + 1, + PureDataset, + [ + (VaporPressure, VaporPressureDataset), + (LiquidDensity, LiquidDensityDataset), + (EquilibriumLiquidDensity, EquilibriumLiquidDensityDataset), + (EnthalpyOfVaporization, EnthalpyOfVaporizationDataset), + ( + ResidualIsobaricHeatCapacity, + ResidualIsobaricHeatCapacityDataset + ), + ] +); + +/// Collection of binary mixture datasets. +/// +/// Wraps any [`Dataset<2>`] implementor so that mixed-property lists can be +/// stored in a `Vec` without requiring trait objects. +#[derive(Clone)] +pub enum BinaryDataset { + BubblePoint(BubblePointDataset), + DewPoint(DewPointDataset), +} + +impl_dataset_enum!( + 2, + BinaryDataset, + [ + (BubblePoint, BubblePointDataset), + (DewPoint, DewPointDataset), + ] +); + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn csv(s: &str) -> Cursor<&[u8]> { + Cursor::new(s.as_bytes()) + } + + #[test] + fn vapor_pressure_from_reader() { + let data = "\ +temperature_k,vapor_pressure_pa +300.0,3540.0 +350.0,41682.0 +400.0,245600.0 +"; + let ds = VaporPressureDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.target().len(), 3); + assert_eq!(ds.inputs().nrows(), 3); + assert_eq!(ds.inputs().ncols(), 1); + assert_eq!(ds.inputs()[[0, 0]], 300.0); + assert_eq!(ds.inputs()[[1, 0]], 350.0); + assert_eq!(ds.inputs()[[2, 0]], 400.0); + assert_eq!(ds.target()[0], 3540.0); + assert_eq!(ds.target()[1], 41682.0); + assert_eq!(ds.target()[2], 245600.0); + assert_eq!(ds.name(), "vapor pressure"); + } + + #[test] + fn vapor_pressure_from_records() { + let records = vec![ + VaporPressureRecord { + temperature_k: 300.0, + vapor_pressure_pa: 3540.0, + }, + VaporPressureRecord { + temperature_k: 350.0, + vapor_pressure_pa: 41682.0, + }, + ]; + let ds = VaporPressureDataset::from_records(records); + assert_eq!(ds.inputs()[[0, 0]], 300.0); + assert_eq!(ds.target()[1], 41682.0); + } + + #[test] + fn liquid_density_from_reader() { + let data = "\ +temperature_k,pressure_pa,liquid_density_kmol_m3 +300.0,101325.0,15.2 +320.0,200000.0,14.8 +"; + let ds = LiquidDensityDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.inputs().nrows(), 2); + assert_eq!(ds.inputs().ncols(), 2); + assert_eq!(ds.inputs()[[0, 0]], 300.0); + assert_eq!(ds.inputs()[[0, 1]], 101325.0); + assert_eq!(ds.inputs()[[1, 0]], 320.0); + assert_eq!(ds.inputs()[[1, 1]], 200000.0); + assert_eq!(ds.target()[0], 15.2); + assert_eq!(ds.target()[1], 14.8); + assert_eq!(ds.name(), "liquid density"); + } + + #[test] + fn liquid_density_from_records() { + let records = vec![LiquidDensityRecord { + temperature_k: 300.0, + pressure_pa: 101325.0, + liquid_density_kmol_m3: 15.2, + }]; + let ds = LiquidDensityDataset::from_records(records); + assert_eq!(ds.inputs()[[0, 1]], 101325.0); + assert_eq!(ds.target()[0], 15.2); + } + + #[test] + fn equilibrium_liquid_density_from_reader() { + let data = "\ +temperature_k,liquid_density_kmol_m3 +290.0,15.5 +310.0,14.9 +330.0,14.1 +"; + let ds = EquilibriumLiquidDensityDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.inputs().ncols(), 1); + assert_eq!(ds.inputs().nrows(), 3); + assert_eq!(ds.inputs()[[2, 0]], 330.0); + assert_eq!(ds.target()[2], 14.1); + assert_eq!(ds.name(), "equilibrium liquid density"); + } + + #[test] + fn bubble_point_from_reader() { + let data = "\ +temperature_k,liquid_molefrac_1,bubble_pressure_pa +300.0,0.3,500000.0 +320.0,0.5,800000.0 +"; + let ds = BubblePointDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.inputs().ncols(), 3); + assert_eq!(ds.inputs().nrows(), 2); + assert_eq!(ds.inputs()[[0, 0]], 300.0); + assert_eq!(ds.inputs()[[0, 1]], 0.3); + assert_eq!(ds.inputs()[[0, 2]], 500000.0); + assert_eq!(ds.target()[0], 500000.0); + assert_eq!(ds.target()[1], 800000.0); + assert_eq!(ds.name(), "bubble point pressure"); + } + + #[test] + fn dew_point_from_reader() { + let data = "\ +temperature_k,vapor_molefrac_1,dew_pressure_pa +310.0,0.7,400000.0 +330.0,0.9,700000.0 +"; + let ds = DewPointDataset::from_reader(csv(data)).unwrap(); + + assert_eq!(ds.inputs().ncols(), 3); + assert_eq!(ds.inputs()[[0, 0]], 310.0); + assert_eq!(ds.inputs()[[0, 1]], 0.7); + assert_eq!(ds.inputs()[[0, 2]], 400000.0); + assert_eq!(ds.target()[1], 700000.0); + assert_eq!(ds.name(), "dew point pressure"); + } + + #[test] + fn dew_point_pressure_doubles_as_initial_guess() { + let records = vec![DewPointRecord { + temperature_k: 310.0, + vapor_molefrac_1: 0.7, + dew_pressure_pa: 400000.0, + }]; + let ds = DewPointDataset::from_records(records); + assert_eq!(ds.inputs()[[0, 2]], ds.target()[0]); + } + + #[test] + fn missing_column_returns_error() { + let data = "temperature_k\n300.0\n"; + let result = VaporPressureDataset::from_reader(csv(data)); + assert!(result.is_err()); + } + + #[test] + fn wrong_type_returns_error() { + let data = "temperature_k,vapor_pressure_pa\n300.0,not_a_number\n"; + let result = VaporPressureDataset::from_reader(csv(data)); + assert!(result.is_err()); + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset/residual_isobaric_heat_capacity.rs b/crates/feos-core/src/ad/parameter_optimization/dataset/residual_isobaric_heat_capacity.rs new file mode 100644 index 000000000..15784bb09 --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/dataset/residual_isobaric_heat_capacity.rs @@ -0,0 +1,70 @@ +use crate::{ParametersAD, PropertiesAD}; +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use serde::{Deserialize, Serialize}; + +use super::Dataset; + +#[derive(Deserialize, Serialize)] +pub struct ResidualIsobaricHeatCapacityRecord { + pub temperature_k: f64, + pub pressure_pa: f64, + pub cp_res_j_molk: f64, +} + +#[derive(Clone)] +pub struct ResidualIsobaricHeatCapacityDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl ResidualIsobaricHeatCapacityDataset { + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + +impl Dataset<1> for ResidualIsobaricHeatCapacityDataset { + type Record = ResidualIsobaricHeatCapacityRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 2), |(i, j)| match j { + 0 => records[i].temperature_k, + _ => records[i].pressure_pa, + }); + let target = Array1::from_iter(records.iter().map(|r| r.cp_res_j_molk)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name + .as_deref() + .unwrap_or("residual isobaric heat capacity") + } + fn input_names() -> &'static [&'static str] { + &["temperature_k", "pressure_pa"] + } + fn target_name() -> &'static str { + "cp_res_j_molk" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::residual_isobaric_heat_capacity_parallel(names, parameters, inputs) + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/dataset/vapor_pressure.rs b/crates/feos-core/src/ad/parameter_optimization/dataset/vapor_pressure.rs new file mode 100644 index 000000000..145e6f4d1 --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/dataset/vapor_pressure.rs @@ -0,0 +1,64 @@ +use crate::{ParametersAD, PropertiesAD}; +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use serde::{Deserialize, Serialize}; + +use super::Dataset; + +#[derive(Deserialize, Serialize)] +pub struct VaporPressureRecord { + pub temperature_k: f64, + pub vapor_pressure_pa: f64, +} + +#[derive(Clone)] +pub struct VaporPressureDataset { + inputs: Array2, + target: Array1, + name: Option, +} + +impl VaporPressureDataset { + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + +impl Dataset<1> for VaporPressureDataset { + type Record = VaporPressureRecord; + + fn from_records(records: Vec) -> Self { + let n = records.len(); + let inputs = Array2::from_shape_fn((n, 1), |(i, _)| records[i].temperature_k); + let target = Array1::from_iter(records.iter().map(|r| r.vapor_pressure_pa)); + Self { + inputs, + target, + name: None, + } + } + + fn inputs(&self) -> ArrayView2<'_, f64> { + self.inputs.view() + } + fn target(&self) -> ArrayView1<'_, f64> { + self.target.view() + } + fn name(&self) -> &str { + self.name.as_deref().unwrap_or("vapor pressure") + } + fn input_names() -> &'static [&'static str] { + &["temperature_k"] + } + fn target_name() -> &'static str { + "vapor_pressure_pa" + } + + fn call_model, const P: usize>( + names: [String; P], + parameters: ArrayView2, + inputs: ArrayView2, + ) -> (Array1, Array2, Array1) { + T::vapor_pressure_parallel(names, parameters, inputs) + } +} diff --git a/crates/feos-core/src/ad/parameter_optimization/loss.rs b/crates/feos-core/src/ad/parameter_optimization/loss.rs index 35f2decde..232c59839 100644 --- a/crates/feos-core/src/ad/parameter_optimization/loss.rs +++ b/crates/feos-core/src/ad/parameter_optimization/loss.rs @@ -1,13 +1,13 @@ -/// Loss function applied to the dimensionless relative residual -/// `r = (calc − exp) / exp` during LM fitting. +/// Loss function applied to the residual. +/// +/// Functions are not directly applied to a given residual. +/// Instead, they define the IRLS weight which is multiplied +/// by the residual to obtain the loss. #[derive(Debug, Clone)] pub enum LossFunction { /// L2 loss (all weights are 1.0) L2, /// Huber loss with dimensionless threshold `delta`. - /// - /// Points with `|r_rel| ≤ delta` are treated as L2. - /// Beyond that, the effective residual is `sqrt(delta * |r_rel|)` (linear loss). Huber { delta: f64 }, } @@ -18,18 +18,22 @@ impl Default for LossFunction { } impl LossFunction { - /// IRLS weight `w = ρ'(r) / r`. + /// Iteratively reweighted least squares weight (inverse prefactor of the residual). /// /// - L2: 1.0 /// - Huber: `min(delta / |r|, 1.0)` + #[inline(always)] pub fn irls_weight(&self, r: f64) -> f64 { match self { + // L2: weight is constant (1.0). LossFunction::L2 => 1.0, + // Huber: weight depends on current residual magnitude and delta. LossFunction::Huber { delta } => (delta / r.abs()).min(1.0), } } /// Square root of weight function. + #[inline(always)] pub fn irls_weight_sqrt(&self, r: f64) -> f64 { self.irls_weight(r).sqrt() } diff --git a/crates/feos-core/src/ad/parameter_optimization/mod.rs b/crates/feos-core/src/ad/parameter_optimization/mod.rs index 26d25b965..d539fc769 100644 --- a/crates/feos-core/src/ad/parameter_optimization/mod.rs +++ b/crates/feos-core/src/ad/parameter_optimization/mod.rs @@ -1,6 +1,7 @@ pub mod dataset; pub mod loss; -pub mod solver; +pub mod regressor; +pub mod residual; pub use dataset::{ BinaryDataset, BubblePointDataset, BubblePointRecord, Dataset, DatasetResult, DewPointDataset, @@ -10,7 +11,8 @@ pub use dataset::{ ResidualIsobaricHeatCapacityRecord, VaporPressureDataset, VaporPressureRecord, }; pub use loss::LossFunction; -pub use solver::{ - BinaryRegressor, DynSolver, FitConfig, FitResult, FittingError, NonConvergenceStrategy, - PureRegressor, Regressor, +pub use regressor::{ + BinaryRegressor, DynRegressor, NonConvergenceStrategy, ParameterOptimizationError, + PureRegressor, Regressor, RegressorConfig, RegressorResult, }; +pub use residual::ResidualFunction; diff --git a/crates/feos-core/src/ad/parameter_optimization/solver.rs b/crates/feos-core/src/ad/parameter_optimization/regressor.rs similarity index 65% rename from crates/feos-core/src/ad/parameter_optimization/solver.rs rename to crates/feos-core/src/ad/parameter_optimization/regressor.rs index e5f5ddd4e..07723704d 100644 --- a/crates/feos-core/src/ad/parameter_optimization/solver.rs +++ b/crates/feos-core/src/ad/parameter_optimization/regressor.rs @@ -3,17 +3,18 @@ use std::marker::PhantomData; use levenberg_marquardt::{LeastSquaresProblem, LevenbergMarquardt, MinimizationReport}; use nalgebra::{DMatrix, DVector, Dyn, Owned}; -use ndarray::{Array1, Array2, s}; +use ndarray::{Array1, Array2, Zip, s}; use thiserror::Error; use crate::ParametersAD; use super::dataset::{BinaryDataset, DatasetResult, PureDataset}; use super::loss::LossFunction; +use super::residual::ResidualFunction; /// Error kinds for parameter fitting and data handling. #[derive(Debug, Error)] -pub enum FittingError { +pub enum ParameterOptimizationError { #[error("at least one dataset must be provided")] EmptyDatasets, @@ -51,6 +52,12 @@ pub enum FittingError { please assign a unique name to each dataset" )] DuplicateDatasetName { name: String }, + + #[error("weights length {got} does not match number of datasets {expected}")] + WeightsLengthMismatch { got: usize, expected: usize }, + + #[error("all dataset weights must be positive, got: {weights:?}")] + NonPositiveWeight { weights: Vec }, } /// Variants for how to deal with non-converged calculations. @@ -65,12 +72,6 @@ pub enum NonConvergenceStrategy { /// The Jacobian row is zero which creates a residual/Jacobian mismatch /// might be an issue for the trust region when convergence changes discontinuously. Penalty(f64), - - /// Scale the penalty by `factor × max |r_rel|` of currently converged - /// points. Non-converged points always look proportionally worse than - /// the worst converged one. Falls back to `factor` when no points have - /// converged. - AdaptivePenalty(f64), } impl Default for NonConvergenceStrategy { @@ -81,24 +82,22 @@ impl Default for NonConvergenceStrategy { /// Levenberg-Marquardt solver hyperparameters. #[derive(Debug, Clone)] -pub struct FitConfig { - /// Terminate when the actual and predicted relative reductions in the - /// objective function are both ≤ `ftol`. Default: 1e-8. +pub struct RegressorConfig { + /// Terminate when actual and predicted relative reductions in the + /// objective function are both <= `ftol`. Default: 1e-8. pub ftol: f64, /// Terminate when the relative change in parameters between - /// iterations is ≤ `xtol`. Default: 1e-8. + /// iterations is <= `xtol`. Default: 1e-8. pub xtol: f64, /// Terminate when the residual vector and every Jacobian column are nearly - /// orthogonal, i.e. the cosine of the angle between `r` and each `J[:,j]` - /// is ≤ `gtol`. This is a scale-invariant first-order optimality check. - /// Set to `0.0` to disable. Default: 1e-8. + /// orthogonal. Set to `0.0` to disable. Default: 1e-8. pub gtol: f64, /// Factor for the initial trust-region step bound. - /// Should lie in `[0.1, 100]`. Smaller values are more conservative and - /// prevent the large initial steps that can cause VLE solvers to diverge. + /// Should lie in `[0.1, 100]`. Small values prevent large + /// initial steps that can cause (e.g. VLE) solvers to diverge. /// Default: 0.1. pub stepbound: f64, - /// Maximum number of residual evaluations = `patience × (n_params + 1)`. + /// Maximum number of residual evaluations = `patience (n_params + 1)`. /// Default: 100. pub patience: usize, /// Rescale parameters internally using the running maximum of each @@ -109,7 +108,7 @@ pub struct FitConfig { pub strategy: NonConvergenceStrategy, } -impl Default for FitConfig { +impl Default for RegressorConfig { fn default() -> Self { Self { ftol: 1e-8, @@ -127,8 +126,8 @@ impl Default for FitConfig { /// /// For model evaluation against the stored data (predictions, per-dataset /// comparison, Fisher information), use [`Regressor::evaluate_datasets`] by -/// passing [`FitResult::all_parameters`] back to the regressor. -pub struct FitResult { +/// passing [`RegressorResult::all_parameters`] back to the regressor. +pub struct RegressorResult { /// Optimal physical parameters in canonical order. /// Includes fitted and non-fitted parameters. pub optimal_params: Vec, @@ -155,7 +154,7 @@ pub struct FitResult { pub elapsed: std::time::Duration, } -impl FitResult { +impl RegressorResult { /// All parameters as `{name: value}`. /// /// Fitted parameters at their optimal values, all others at their initial values. @@ -183,13 +182,35 @@ impl FitResult { } } +pub struct DatasetCache { + /// Number of datapoints in this dataset. + pub datapoints: usize, + /// Relative weight assigned by the user for this dataset. + pub user_weight: f64, + /// Effective per-residual scale factor: `sqrt(user_weight / datapoints)`. + /// + /// Multiplying each residual by this value makes the dataset's contribution + /// to the LM objective equal to `user_weight * MSE`, so equal user weights + /// yield equal objective contributions regardless of dataset size. + pub dataset_weight: f64, + /// Predicted values for this dataset. + pub predicted: Array1, + /// Gradients of the model w.r.t. model parameters. + pub gradients: Array2, + /// Whether the optimization converged for each datapoint. + pub converged: Array1, + /// Residuals of the model for this dataset defined via `ResidualFunction`. + pub residuals: Array1, + /// Weight weight defined via `LossFunction`. + pub loss_w_sqrt: Array1, +} + /// Levenberg-Marquardt fitting problem. pub struct Regressor { /// Collections of experimental data. pub(crate) datasets: Vec, - /// A single weight per dataset applied - /// to all residuals and Jacobian entries for that set. - pub(crate) weights: Vec, + /// Caching for each dataset. + pub(crate) caches: Vec, /// Parameter names in canonical order. pub(crate) param_names: Vec, /// Indices of parameters that are adjusted. @@ -199,71 +220,79 @@ pub struct Regressor { /// Scaling factor to switch between EoS input and /// optimizer input. pub(crate) normalization: Vec, + /// Residual function applied to predictions vs. targets. + pub(crate) residual_fn: ResidualFunction, /// Loss function applied to residuals. - pub(crate) loss: LossFunction, + pub(crate) loss_fn: LossFunction, /// Strategy for dealing with non-converged calculations. pub(crate) strategy: NonConvergenceStrategy, /// Parameter storage for the solver. pub(crate) params: DVector, - /// Cached predictions, i.e. EoS evaluations. Per dataset. - pub(crate) cached_predicted: Vec>, - /// Cached gradients of EoS evaluations w.r.t. EoS parameters. - /// Per Dataset. - pub(crate) cached_gradients: Vec>, - /// Cached convergence-status of each data point. Per Dataset. - pub(crate) cached_converged: Vec>, - /// Cached IRLS weight. - pub(crate) cached_loss_w_sqrt: Vec>, - /// Penalty for non-converged points, updated by `update_cache`. - /// For `Penalty(p)` this is always `p`; for `AdaptivePenalty(f)` it - /// tracks `f × max |r_rel|` over the most recent converged points. - pub(crate) cached_penalty: f64, pub(crate) _phantom: PhantomData, } pub type PureRegressor = Regressor; pub type BinaryRegressor = Regressor; -/// Empty placeholder needed by [`DynSolver::fit`] for mem::take. +/// Empty placeholder needed by [`DynRegressor::fit`] for mem::take. impl Default for Regressor { fn default() -> Self { Self { datasets: Vec::new(), - weights: Vec::new(), + caches: Vec::new(), param_names: Vec::new(), fitted_indices: Vec::new(), base_params: Vec::new(), normalization: Vec::new(), - loss: LossFunction::default(), + residual_fn: ResidualFunction::RelativeDifference, + loss_fn: LossFunction::default(), strategy: NonConvergenceStrategy::default(), params: DVector::zeros(0), - cached_predicted: Vec::new(), - cached_gradients: Vec::new(), - cached_converged: Vec::new(), - cached_loss_w_sqrt: Vec::new(), - cached_penalty: 0.0, _phantom: PhantomData, } } } impl Regressor { - /// Set per-dataset weights (default 1.0 for all). + /// Set the residual function (default: [`ResidualFunction::RelativeDifference`]). + pub fn with_residual_fn(mut self, residual_fn: ResidualFunction) -> Self { + self.residual_fn = residual_fn; + self + } + + /// Set per-dataset weights (default: 1.0 for all datasets). /// - /// Weights are not normalized in this function. + /// Each weight scales that dataset's contribution to the mean squared residual + /// in the objective. Two datasets with equal weights contribute equally to the + /// objective regardless of their number of data points. /// - /// # Panics + /// Weights are not normalized; only their ratios matter. /// - /// Panics if `weights.len()` does not match the number of datasets, or if - /// any weight is not positive. - pub fn with_weights(mut self, weights: Vec) -> Self { - assert_eq!(weights.len(), self.datasets.len()); - assert!( - weights.iter().all(|&w| w > 0.0), - "all dataset weights must be positive, got: {weights:?}" - ); - self.weights = weights; - self + /// # Errors + /// + /// Returns [`ParameterOptimizationError::WeightsLengthMismatch`] if `weights.len()` does not + /// match the number of datasets, or [`ParameterOptimizationError::NonPositiveWeight`] if any + /// weight is not positive. + pub fn with_weights(mut self, weights: Vec) -> Result { + if weights.len() != self.datasets.len() { + return Err(ParameterOptimizationError::WeightsLengthMismatch { + got: weights.len(), + expected: self.datasets.len(), + }); + } + if weights.iter().any(|&w| w <= 0.0) { + return Err(ParameterOptimizationError::NonPositiveWeight { weights }); + } + + // write weights to caches + self.caches + .iter_mut() + .zip(weights.iter()) + .for_each(|(cache, &w)| { + cache.user_weight = w; + cache.dataset_weight = (w / cache.datapoints as f64).sqrt(); + }); + Ok(self) } /// Optimal physical parameters with non-fitted entries at their initial values. @@ -279,12 +308,12 @@ impl Regressor { /// /// Non-converged entries are `NaN`; use [`Regressor::convergence_mask`] to filter. pub fn predicted(&self) -> Array1 { - let n_total: usize = self.cached_predicted.iter().map(|p| p.len()).sum(); + let n_total: usize = self.caches.iter().map(|c| c.datapoints).sum(); let mut out = Array1::zeros(n_total); let mut offset = 0; - for pred in &self.cached_predicted { - let n = pred.len(); - out.slice_mut(s![offset..offset + n]).assign(pred); + for c in &self.caches { + let n = c.datapoints; + out.slice_mut(s![offset..offset + n]).assign(&c.predicted); offset += n; } out @@ -292,12 +321,12 @@ impl Regressor { /// Convergence mask across all datasets. pub fn convergence_mask(&self) -> Array1 { - let n_total: usize = self.cached_converged.iter().map(|c| c.len()).sum(); + let n_total: usize = self.caches.iter().map(|c| c.datapoints).sum(); let mut out = Array1::from_elem(n_total, false); let mut offset = 0; - for conv in &self.cached_converged { - let n = conv.len(); - out.slice_mut(s![offset..offset + n]).assign(conv); + for c in &self.caches { + let n = c.datapoints; + out.slice_mut(s![offset..offset + n]).assign(&c.converged); offset += n; } out @@ -305,14 +334,14 @@ impl Regressor { /// Per-dataset `(n_converged, n_total)`. pub fn convergence_stats(&self) -> Vec<(usize, usize)> { - self.cached_converged + self.caches .iter() - .map(|conv| (conv.iter().filter(|&&c| c).count(), conv.len())) + .map(|c| (c.converged.iter().filter(|&&c| c).count(), c.datapoints)) .collect() } } -macro_rules! impl_solver { +macro_rules! impl_regressor { ($n:literal, $dataset:ty) => { impl> Regressor { /// Construct a new solver. @@ -324,16 +353,16 @@ macro_rules! impl_solver { datasets: Vec<$dataset>, params: HashMap, fit: &[&str], - ) -> Result { + ) -> Result { if datasets.is_empty() { - return Err(FittingError::EmptyDatasets); + return Err(ParameterOptimizationError::EmptyDatasets); } // Reject duplicate dataset names let mut seen_names = std::collections::HashSet::new(); for d in &datasets { if !seen_names.insert(d.name().to_string()) { - return Err(FittingError::DuplicateDatasetName { + return Err(ParameterOptimizationError::DuplicateDatasetName { name: d.name().to_string(), }); } @@ -347,18 +376,18 @@ macro_rules! impl_solver { let mut fitted_indices = Vec::with_capacity(fit.len()); for &name in fit { if fitted_indices.iter().any(|&prev| canonical[prev] == name) { - return Err(FittingError::DuplicateParameter { + return Err(ParameterOptimizationError::DuplicateParameter { name: name.to_string(), }); } let i = canonical.iter().position(|&n| n == name).ok_or_else(|| { - FittingError::UnknownParameter { + ParameterOptimizationError::UnknownParameter { name: name.to_string(), valid: canonical.clone(), } })?; if !differentiable.contains(&name) { - return Err(FittingError::NonDifferentiableParameter { + return Err(ParameterOptimizationError::NonDifferentiableParameter { name: name.to_string(), differentiable: differentiable.clone(), }); @@ -369,17 +398,16 @@ macro_rules! impl_solver { // Get values for all parameters in canonical order. let mut full_params = Vec::with_capacity(canonical.len()); for &name in &canonical { - let v = params - .get(name) - .ok_or_else(|| FittingError::MissingParameter { + let v = params.get(name).ok_or_else(|| { + ParameterOptimizationError::MissingParameter { name: name.to_string(), - })?; + } + })?; full_params.push(*v); } let param_names: Vec = fit.iter().map(|s| s.to_string()).collect(); let p = param_names.len(); - let n_ds = datasets.len(); // We normalize parameters in optimizer state to be in the order of 1. // Hence, scaling factors are the inital parameters. @@ -390,27 +418,37 @@ macro_rules! impl_solver { // since we divide for normalization. for (j, &idx) in fitted_indices.iter().enumerate() { if normalization[j] == 0.0 { - return Err(FittingError::ZeroParameter { + return Err(ParameterOptimizationError::ZeroParameter { name: canonical[idx].to_string(), }); } } + let caches = datasets + .iter() + .map(|ds| DatasetCache { + datapoints: ds.target().len(), + user_weight: 1.0, + dataset_weight: 1.0 / (ds.target().len() as f64).sqrt(), + predicted: Array1::zeros(ds.target().len()), + gradients: Array2::zeros((ds.target().len(), p)), + converged: Array1::from_elem(ds.target().len(), false), + residuals: Array1::zeros(ds.target().len()), + loss_w_sqrt: Array1::zeros(ds.target().len()), + }) + .collect(); + let mut solver = Self { datasets, - weights: vec![1.0; n_ds], param_names, fitted_indices, base_params: full_params, normalization, - loss: LossFunction::default(), + residual_fn: ResidualFunction::RelativeDifference, + loss_fn: LossFunction::default(), strategy: NonConvergenceStrategy::default(), params: DVector::from_element(p, 1.0f64), - cached_predicted: vec![Array1::zeros(0); n_ds], - cached_gradients: vec![Array2::zeros((0, p)); n_ds], - cached_converged: vec![Array1::from_elem(0, false); n_ds], - cached_loss_w_sqrt: vec![Array1::zeros(0); n_ds], - cached_penalty: 0.0, + caches, _phantom: PhantomData, }; solver.update_cache(); @@ -418,14 +456,13 @@ macro_rules! impl_solver { } /// Set the loss function. - pub fn with_loss(mut self, loss: LossFunction) -> Self { - self.loss = loss; - self.recompute_loss_weights(); + pub fn with_loss(mut self, loss_fn: LossFunction) -> Self { + self.loss_fn = loss_fn; self } /// Run Levenberg-Marquardt to convergence. - pub fn fit(mut self, config: FitConfig) -> (Self, MinimizationReport) { + pub fn fit(mut self, config: RegressorConfig) -> (Self, MinimizationReport) { // overwrite strategy or nonconverging states self.strategy = config.strategy.clone(); LevenbergMarquardt::new() @@ -463,7 +500,7 @@ macro_rules! impl_solver { /// Evaluate each dataset individually and return structured /// per-dataset results. /// - /// Pass [`FitResult::all_parameters`] as `params` to inspect the + /// Pass [`RegressorResult::all_parameters`] as `params` to inspect the /// model at the fitted optimum. pub fn evaluate_datasets(&self, params: &[f64]) -> Vec { self.datasets @@ -504,9 +541,9 @@ macro_rules! impl_solver { .collect() } - /// Extract [`FitResult`] from the current solver state and a [`MinimizationReport`]. - pub fn to_result(&self, report: &MinimizationReport) -> FitResult { - FitResult { + /// Extract [`RegressorResult`] from the current solver state and a [`MinimizationReport`]. + pub fn to_result(&self, report: &MinimizationReport) -> RegressorResult { + RegressorResult { optimal_params: self.optimal_params(), fitted_param_names: self.param_names.clone(), all_param_names: T::parameter_names().iter().map(|s| s.to_string()).collect(), @@ -521,7 +558,7 @@ macro_rules! impl_solver { } } - /// Target values across all datasets in residual-vector order. + /// Target values across all datasets. pub fn target(&self) -> Array1 { let n_total: usize = self.datasets.iter().map(|d| d.target().len()).sum(); let mut out = Array1::zeros(n_total); @@ -544,18 +581,18 @@ macro_rules! impl_solver { pub fn aad_per_dataset(&self) -> Vec> { self.datasets .iter() - .enumerate() - .map(|(k, dataset)| { - let exp = dataset.target(); - let pred = &self.cached_predicted[k]; - let conv = &self.cached_converged[k]; + .zip(self.caches.iter()) + .map(|(ds, c)| { + let target = ds.target(); + let pred = &c.predicted; + let conv = &c.converged; let n_conv = conv.iter().filter(|&&c| c).count(); if n_conv == 0 { return None; } Some( pred.iter() - .zip(exp.iter()) + .zip(target.iter()) .zip(conv.iter()) .filter(|(_, c)| **c) .map(|((p, e), _)| ((p - e) / e).abs()) @@ -567,88 +604,33 @@ macro_rules! impl_solver { .collect() } - /// Unweighted relative Jacobian in physical parameter space - /// - /// Shape: `[n_converged, P]` - /// Row: `(1/exp_i) · ∂property_i/∂x_physical` - /// FIM: `J^T J` - /// - /// The Jacobian is in physical parameter space so that `J^T J` can - /// used without any rescaling. Includes converged points only. - /// The Jacobian used during LM iterations is scaled according to - /// parameter normalization and loss definition. - pub fn fim_jacobian(&self) -> Option> { - let n_conv: usize = self - .cached_converged - .iter() - .flat_map(|c| c.iter()) - .filter(|&&c| c) - .count(); - if n_conv == 0 { - return None; - } - - let p = self.params.len(); - let mut jac = DMatrix::zeros(n_conv, p); - - let converged_pts: Vec<(usize, usize)> = self - .datasets - .iter() - .enumerate() - .flat_map(|(k, dataset)| { - (0..dataset.target().len()) - .filter(move |&i| self.cached_converged[k][i]) - .map(move |i| (k, i)) - }) - .collect(); - - for j in 0..p { - for (row, &(k, i)) in converged_pts.iter().enumerate() { - let exp = self.datasets[k].target(); - jac[(row, j)] = self.cached_gradients[k][[i, j]] / exp[i]; - } - } - Some(jac) - } - - /// Penalty value for non-converged points under the current strategy. - fn recompute_loss_weights(&mut self) { - for (k, dataset) in self.datasets.iter().enumerate() { - let exp = dataset.target(); - let pred = &self.cached_predicted[k]; - let conv = &self.cached_converged[k]; - self.cached_loss_w_sqrt[k] = Array1::from_iter((0..exp.len()).map(|i| { - if conv[i] { - self.loss.irls_weight_sqrt((pred[i] - exp[i]) / exp[i]) - } else { - 0.0 - } - })); - } - } - fn residuals_impl(&self) -> Option> { - let n_total: usize = self.datasets.iter().map(|d| d.target().len()).sum(); + let n_total: usize = self.caches.iter().map(|c| c.datapoints).sum(); if n_total == 0 { return None; } + let penalty = match self.strategy { + NonConvergenceStrategy::Ignore => 0.0, + NonConvergenceStrategy::Penalty(p) => p, + }; + let mut out = DVector::zeros(n_total); let mut offset = 0; - for (k, dataset) in self.datasets.iter().enumerate() { - let exp = dataset.target(); - let pred = &self.cached_predicted[k]; - let conv = &self.cached_converged[k]; - let w_sqrt = &self.cached_loss_w_sqrt[k]; - let weight = self.weights[k]; - for i in 0..exp.len() { + + for cache in self.caches.iter() { + let dataset_weight = cache.dataset_weight; + let conv = &cache.converged; + let w_sqrt = &cache.loss_w_sqrt; + + for i in 0..cache.datapoints { out[offset + i] = if conv[i] { - weight * w_sqrt[i] * (pred[i] - exp[i]) / exp[i] + dataset_weight * cache.residuals[i] * w_sqrt[i] } else { - weight * self.cached_penalty + dataset_weight * penalty }; } - offset += exp.len(); + offset += cache.datapoints; } Some(out) } @@ -656,7 +638,7 @@ macro_rules! impl_solver { /// Jacobian for LM iteration. /// /// Needs derivatives that account for - /// - using relative deviation + /// - the residual function /// - using normalized parameters /// - using (optional) non L2 loss /// - using weights for whole parameter sets. @@ -670,39 +652,31 @@ macro_rules! impl_solver { let mut jac = DMatrix::zeros(n_total, p); let mut offset = 0; - // This part is a bit ugly - // We have to convert between ndarray and nalgebra memory layouts. - // Results from EoS calls: ndarray - // Format needed by LM: nalgebra - for (k, dataset) in self.datasets.iter().enumerate() { - let exp = dataset.target(); - let conv = &self.cached_converged[k]; - let w_sqrt = &self.cached_loss_w_sqrt[k]; - let weight = self.weights[k]; - let grad = &self.cached_gradients[k]; - let n = exp.len(); + for (dataset, cache) in self.datasets.iter().zip(self.caches.iter()) { + let target = dataset.target(); + let conv = &cache.converged; + let w_sqrt = &cache.loss_w_sqrt; + let dataset_weight = cache.dataset_weight; + let n = target.len(); + + let mut grad = cache.gradients.clone(); + self.residual_fn.jacobian_transform( + cache.predicted.view(), + target, + conv.view(), + &mut grad, + ); let mut jac_block = jac.rows_mut(offset, n); - - // Lenthy zip - but no additional allocations! - let row_iter = exp - .iter() - .zip(conv.iter()) - .zip(w_sqrt.iter()) - .zip(grad.outer_iter()) - .zip(jac_block.row_iter_mut()); - - for ((((&e, &c), &w), grad_row), mut jac_row) in row_iter { - if !c { + for i in 0..n { + if !conv[i] { continue; } - // factor covers: - // - dataset weight - // - division by experimental value - // - irls weight (1.0 for L2 loss) - let factor = weight * w / e; - for (jac_val, &g) in jac_row.iter_mut().zip(grad_row.iter()) { - *jac_val = factor * g; + let total_weight = dataset_weight * w_sqrt[i]; + let grad_row = grad.row(i); + let mut jac_row = jac_block.row_mut(i); + for j in 0..p { + jac_row[j] = total_weight * grad_row[j]; } } offset += n; @@ -723,55 +697,38 @@ macro_rules! impl_solver { /// Using the cache, we only have to compute the residual and gradients once. /// Scaling factors and weights are applied in the residual and jacobian functions. fn update_cache(&mut self) { - // Compute physical parameters: x_physical[j] = x_opt[j] * normalization[j], - // embedded into the full M-dimensional parameter vector. + // precompute full parameter vector let mut full_params = self.base_params.clone(); for (j, &idx) in self.fitted_indices.iter().enumerate() { full_params[idx] = self.params[j] * self.normalization[j]; } - for (k, dataset) in self.datasets.iter().enumerate() { - let (pred, grad, conv) = dataset.compute::(&self.param_names, &full_params); - let exp = dataset.target(); - self.cached_loss_w_sqrt[k] = Array1::from_iter((0..exp.len()).map(|i| { - if conv[i] { - self.loss.irls_weight_sqrt((pred[i] - exp[i]) / exp[i]) - } else { - 0.0 - } - })); - self.cached_predicted[k] = pred; - self.cached_gradients[k] = grad; - self.cached_converged[k] = conv; - } + let residual_fn = &self.residual_fn; + let loss_fn = &self.loss_fn; - // Update cached_penalty from the freshly computed predictions. - // For AdaptivePenalty this is a cheap O(n) scan over hot cache; - // for the other variants it is a direct read of the strategy. - self.cached_penalty = match &self.strategy { - NonConvergenceStrategy::Ignore => 0.0, - NonConvergenceStrategy::Penalty(p) => *p, - NonConvergenceStrategy::AdaptivePenalty(factor) => { - let mut max_r = 0.0f64; - let mut any_converged = false; - for (k, dataset) in self.datasets.iter().enumerate() { - let exp = dataset.target(); - let pred = &self.cached_predicted[k]; - let conv = &self.cached_converged[k]; - for i in 0..exp.len() { - if conv[i] { - any_converged = true; - max_r = max_r.max(((pred[i] - exp[i]) / exp[i]).abs()); - } + for (dataset, cache) in self.datasets.iter().zip(self.caches.iter_mut()) { + let (pred, grad, conv) = dataset.compute::(&self.param_names, &full_params); + let target = dataset.target(); + + Zip::from(&mut cache.residuals) + .and(&mut cache.loss_w_sqrt) + .and(target) + .and(&pred) + .and(&conv) + .for_each(|r, w, &t, &p, &c| { + if c { + *r = residual_fn.residual(p, t); + *w = loss_fn.irls_weight_sqrt(*r); + } else { + *r = 0.0; + *w = 0.0; } - } - if any_converged { - max_r * factor - } else { - *factor - } - } - }; + }); + + cache.predicted = pred; + cache.gradients = grad; + cache.converged = conv; + } } } @@ -797,38 +754,37 @@ macro_rules! impl_solver { }; } -impl_solver!(1, PureDataset); -impl_solver!(2, BinaryDataset); +impl_regressor!(1, PureDataset); +impl_regressor!(2, BinaryDataset); -/// Object-safe interface for type-erased solvers. +/// Object-safe interface for type-erased regressors. /// -/// A `Box` can hold any concrete `Regressor` without -/// exposing the EoS type. +/// A `Box` can hold any `Regressor`. /// /// This is useful when models are created at runtime (used in py-feos). /// /// # Example /// /// ```ignore -/// let mut solver: Box = match model_name { +/// let mut reg: Box = match model_name { /// "eos_1" => Box::new(PureRegressor::::new(ds, params, &fit)?), /// "eos_2" => Box::new(PureRegressor::::new(ds, params, &fit)?), /// _ => panic!("unknown model"), /// }; -/// let result = solver.fit(FitConfig::default(), None); +/// let result = reg.fit(RegressorConfig::default(), None); /// println!("AAD: {:?}", result.aad_per_dataset); /// ``` -pub trait DynSolver: Send + Sync { - /// Evaluate all datasets at the given full physical parameters. +pub trait DynRegressor: Send + Sync { + /// Evaluate all datasets at the given physical parameters. /// /// Returns `(predicted, gradients, converged)` concatenated across /// all datasets. Gradients are w.r.t. the fitted parameters only. fn predict(&self, params: &[f64]) -> (Array1, Array2, Array1); - /// Run Levenberg-Marquardt optimisation and return a [`FitResult`]. + /// Run Levenberg-Marquardt, return a [`RegressorResult`]. /// /// Does not consume the solver. This is different from LM. - fn fit(&mut self, config: FitConfig, loss: Option) -> FitResult; + fn fit(&mut self, config: RegressorConfig, loss: Option) -> RegressorResult; /// Concatenated target values across all datasets. fn target(&self) -> Array1; @@ -842,6 +798,13 @@ pub trait DynSolver: Send + Sync { /// Dataset names in order. fn dataset_names(&self) -> Vec; + /// Current physical parameters in canonical order. + /// + /// Before [`fit`](Self::fit) is called this returns the initial values. + /// After fitting it returns the optimal values for the fitted parameters + /// and the initial values for the fixed ones. + fn optimal_params(&self) -> Vec; + /// Evaluate each dataset at the given full parameters and return /// structured per-dataset results. /// @@ -849,9 +812,9 @@ pub trait DynSolver: Send + Sync { fn evaluate_datasets(&self, params: &[f64]) -> Vec; } -macro_rules! impl_dyn_solver { +macro_rules! impl_dyn_regressor { ($n:literal, $dataset:ty) => { - impl DynSolver for Regressor + impl DynRegressor for Regressor where T: ParametersAD<$n> + Send + Sync + 'static, { @@ -859,7 +822,11 @@ macro_rules! impl_dyn_solver { self.predict(params) } - fn fit(&mut self, config: FitConfig, loss: Option) -> FitResult { + fn fit( + &mut self, + config: RegressorConfig, + loss: Option, + ) -> RegressorResult { let mut solver = std::mem::take(self); if let Some(l) = loss { solver = solver.with_loss(l); @@ -892,6 +859,10 @@ macro_rules! impl_dyn_solver { .collect() } + fn optimal_params(&self) -> Vec { + self.optimal_params() + } + fn evaluate_datasets(&self, params: &[f64]) -> Vec { self.evaluate_datasets(params) } @@ -899,5 +870,5 @@ macro_rules! impl_dyn_solver { }; } -impl_dyn_solver!(1, PureDataset); -impl_dyn_solver!(2, BinaryDataset); +impl_dyn_regressor!(1, PureDataset); +impl_dyn_regressor!(2, BinaryDataset); diff --git a/crates/feos-core/src/ad/parameter_optimization/residual.rs b/crates/feos-core/src/ad/parameter_optimization/residual.rs new file mode 100644 index 000000000..18493955d --- /dev/null +++ b/crates/feos-core/src/ad/parameter_optimization/residual.rs @@ -0,0 +1,55 @@ +use ndarray::{Array2, ArrayView1, Zip}; + +pub enum ResidualFunction { + /// Computes the difference between the prediction and target values. + Difference, + /// Computes the log difference between the prediction and target values. + LogDifference, + /// Computes the relative difference between the prediction and target values. + RelativeDifference, +} + +impl ResidualFunction { + #[inline(always)] + pub fn residual(&self, prediction: f64, target: f64) -> f64 { + match self { + Self::Difference => prediction - target, + Self::LogDifference => prediction.ln() - target.ln(), + Self::RelativeDifference => (prediction - target) / target, + } + } + + /// Transforms the jacobian in-place based on the residual function. + /// + /// Non-converged entries are skipped. + pub fn jacobian_transform( + &self, + prediction: ArrayView1, + target: ArrayView1, + converged: ArrayView1, + jacobian: &mut Array2, + ) { + let iter = Zip::from(jacobian.rows_mut()) + .and(prediction) + .and(target) + .and(converged); + + match self { + Self::Difference => {} + Self::LogDifference => { + iter.for_each(|mut row, &f, &_y, &conv| { + if conv { + row.mapv_inplace(|g| g / f); + } + }); + } + Self::RelativeDifference => { + iter.for_each(|mut row, &_f, &y, &conv| { + if conv { + row.mapv_inplace(|g| g / y); + } + }); + } + } + } +} diff --git a/crates/feos-core/src/density_iteration.rs b/crates/feos-core/src/density_iteration.rs index aaee68ec1..e27ca0fba 100644 --- a/crates/feos-core/src/density_iteration.rs +++ b/crates/feos-core/src/density_iteration.rs @@ -85,10 +85,7 @@ where let t = Dual::from_re(temperature); let x = molefracs.map(Dual::from); let (a_res, da_res) = first_derivative( - |molar_volume| { - eos.lift() - .residual_helmholtz_energy(t, molar_volume, &x) - }, + |molar_volume| eos.lift().residual_helmholtz_energy(t, molar_volume, &x), molar_volume, ); a_res - da_res * molar_volume + temperature * density.ln() diff --git a/crates/feos-core/src/lib.rs b/crates/feos-core/src/lib.rs index b9655b46e..97f27af9b 100644 --- a/crates/feos-core/src/lib.rs +++ b/crates/feos-core/src/lib.rs @@ -32,9 +32,9 @@ mod errors; pub mod parameter; mod phase_equilibria; mod state; -pub use ad::{ParametersAD, PropertiesAD}; -#[cfg(feature = "rayon")] +#[cfg(feature = "ndarray")] pub use ad::parameter_optimization; +pub use ad::{ParametersAD, PropertiesAD}; pub use equation_of_state::{ EntropyScaling, EquationOfState, IdealGas, IdealGasAD, Molarweight, NoResidual, Residual, ResidualDyn, Subset, Total, diff --git a/crates/feos-derive/src/dft.rs b/crates/feos-derive/src/dft.rs index a9ceb4f6a..d7dcd8e63 100644 --- a/crates/feos-derive/src/dft.rs +++ b/crates/feos-derive/src/dft.rs @@ -1,4 +1,4 @@ -use crate::{implement, OPT_IMPLS}; +use crate::{OPT_IMPLS, implement}; use quote::quote; use syn::DeriveInput; diff --git a/crates/feos-derive/src/lib.rs b/crates/feos-derive/src/lib.rs index aa3cfc281..bbb416099 100644 --- a/crates/feos-derive/src/lib.rs +++ b/crates/feos-derive/src/lib.rs @@ -8,7 +8,7 @@ use ideal_gas::expand_ideal_gas; use proc_macro::TokenStream; use residual::expand_residual; use subset::expand_subset; -use syn::{parse_macro_input, DeriveInput}; +use syn::{DeriveInput, parse_macro_input}; mod dft; mod functional_contribution; diff --git a/crates/feos-dft/src/adsorption/fea_potential.rs b/crates/feos-dft/src/adsorption/fea_potential.rs index ef4b76964..a3fefdc91 100644 --- a/crates/feos-dft/src/adsorption/fea_potential.rs +++ b/crates/feos-dft/src/adsorption/fea_potential.rs @@ -1,6 +1,6 @@ use super::pore3d::{calculate_distance2, evaluate_lj_potential}; -use crate::profile::{CUTOFF_RADIUS, MAX_POTENTIAL}; use crate::Geometry; +use crate::profile::{CUTOFF_RADIUS, MAX_POTENTIAL}; use feos_core::ReferenceSystem; use gauss_quad::GaussLegendre; use ndarray::{Array1, Array2, Zip}; diff --git a/crates/feos/src/gc_pcsaft/dft/mod.rs b/crates/feos/src/gc_pcsaft/dft/mod.rs index 5005fa315..18ba4c793 100644 --- a/crates/feos/src/gc_pcsaft/dft/mod.rs +++ b/crates/feos/src/gc_pcsaft/dft/mod.rs @@ -1,6 +1,6 @@ use super::eos::GcPcSaftOptions; use super::record::GcPcSaftAssociationRecord; -use crate::association::{Association, YuWuAssociationFunctional, AssociationStrength}; +use crate::association::{Association, AssociationStrength, YuWuAssociationFunctional}; use crate::gc_pcsaft::GcPcSaftParameters; use crate::hard_sphere::{FMTContribution, FMTVersion, HardSphereProperties, MonomerShape}; use feos_core::{FeosResult, Molarweight, ResidualDyn, StateHD, Subset}; @@ -108,7 +108,8 @@ impl HelmholtzEnergyFunctionalDyn for GcPcSaftFunctional { fn contributions<'a>(&'a self) -> impl Iterator> { let mut contributions = Vec::with_capacity(4); - let assoc = YuWuAssociationFunctional::new(&self.params, &self.parameters, self.association); + let assoc = + YuWuAssociationFunctional::new(&self.params, &self.parameters, self.association); // Hard sphere contribution let hs = FMTContribution::new(&self.params, self.fmt_version); diff --git a/crates/feos/src/pcsaft/dft/mod.rs b/crates/feos/src/pcsaft/dft/mod.rs index a7f6b4566..b3afa5435 100644 --- a/crates/feos/src/pcsaft/dft/mod.rs +++ b/crates/feos/src/pcsaft/dft/mod.rs @@ -103,7 +103,8 @@ impl HelmholtzEnergyFunctionalDyn for PcSaftFunctional { fn contributions<'a>(&'a self) -> impl Iterator> { let mut contributions = Vec::with_capacity(4); - let assoc = YuWuAssociationFunctional::new(&self.params, &self.parameters, self.association); + let assoc = + YuWuAssociationFunctional::new(&self.params, &self.parameters, self.association); if matches!( self.fmt_version, diff --git a/crates/feos/src/pcsaft/eos/mod.rs b/crates/feos/src/pcsaft/eos/mod.rs index fd532f08e..9ce1f2b5d 100644 --- a/crates/feos/src/pcsaft/eos/mod.rs +++ b/crates/feos/src/pcsaft/eos/mod.rs @@ -605,7 +605,12 @@ mod tests_parameter_fit { use quantity::{BAR, KELVIN, LITER, MOL, PASCAL}; fn flat_binary_params(b: &PcSaftBinary) -> Vec { - b.0 .0.iter().flatten().copied().chain(std::iter::once(b.0 .1)).collect() + b.0.0 + .iter() + .flatten() + .copied() + .chain(std::iter::once(b.0.1)) + .collect() } fn pcsaft_non_assoc() -> PcSaftPure { @@ -662,7 +667,8 @@ mod tests_parameter_fit { #[test] fn test_vapor_pressure_derivatives_fit() -> FeosResult<()> { let pcsaft = pcsaft_non_assoc(); - let pcsaft_ad = PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); + let pcsaft_ad = + PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); let temperature = 150.0 * KELVIN; let p = pcsaft_ad.vapor_pressure(temperature)?; let p = p.convert_into(PASCAL); @@ -738,7 +744,8 @@ mod tests_parameter_fit { #[test] fn test_equilibrium_liquid_density_derivatives_fit() -> FeosResult<()> { let pcsaft = pcsaft_non_assoc(); - let pcsaft_ad = PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); + let pcsaft_ad = + PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); let temperature = 150.0 * KELVIN; let (p, rho) = pcsaft_ad.equilibrium_liquid_density(temperature)?; let p = p.convert_into(PASCAL); @@ -778,7 +785,8 @@ mod tests_parameter_fit { #[test] fn test_liquid_density_derivatives_fit() -> FeosResult<()> { let pcsaft = pcsaft_non_assoc(); - let pcsaft_ad = PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); + let pcsaft_ad = + PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); let temperature = 150.0 * KELVIN; let pressure = BAR; let rho = pcsaft_ad.liquid_density(temperature, pressure)?; @@ -810,7 +818,8 @@ mod tests_parameter_fit { #[test] fn test_bubble_point_pressure() -> FeosResult<()> { let (pcsaft, _) = pcsaft_binary()?; - let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); + let pcsaft_ad = + PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); let temperature = 500.0 * KELVIN; let x = vector![0.5, 0.5]; let p = pcsaft_ad.bubble_point_pressure(temperature, None, x)?; @@ -848,7 +857,8 @@ mod tests_parameter_fit { #[test] fn test_dew_point_pressure() -> FeosResult<()> { let (pcsaft, _) = pcsaft_binary()?; - let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); + let pcsaft_ad = + PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); let temperature = 500.0 * KELVIN; let y = 0.5; let p = pcsaft_ad.dew_point_pressure(temperature, None, y)?; @@ -880,7 +890,8 @@ mod tests_parameter_fit { #[test] fn test_bubble_point_temperature() -> FeosResult<()> { let (pcsaft, _) = pcsaft_binary()?; - let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); + let pcsaft_ad = + PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); let pressure = Pressure::from_reduced(DualVec::from(45. * BAR.into_reduced())); let t_init = Temperature::from_reduced(DualVec::from(500.0)); let x = DualVec::from(0.5); @@ -928,7 +939,8 @@ mod tests_parameter_fit { #[test] fn test_dew_point_temperature() -> FeosResult<()> { let (pcsaft, _) = pcsaft_binary()?; - let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); + let pcsaft_ad = + PcSaftBinary::::seed_derivatives(&flat_binary_params(&pcsaft), ["k_ij"]); let pressure = Pressure::from_reduced(DualVec::from(45. * BAR.into_reduced())); let t_init = Temperature::from_reduced(DualVec::from(500.0)); let x = DualVec::from(0.5); diff --git a/examples/pcsaft/parameter_adjustment/README.md b/examples/pcsaft/parameter_adjustment/README.md index 18911cd70..6dd36c3be 100644 --- a/examples/pcsaft/parameter_adjustment/README.md +++ b/examples/pcsaft/parameter_adjustment/README.md @@ -1,5 +1,22 @@ -# Example notebooks for adjusting PC-SAFT parameters to experimental data +# Adjusting PC-SAFT parameters to experimental data + +## Notebooks - Adjust **PC-SAFT parameters** $m$, $\sigma$ and $\epsilon_k$ for a pure substance. [🠒 Notebook](adjust_non_polar_non_asssociating.ipynb) - Adjust **binary PC-SAFT parameter** $k_{ij}$ to VLE data. [🠒 Notebook](adjust_kij.ipynb) -- Adjust **entropy scaling** correlation parameters for the viscosity of a pure substance using PC-SAFT. [🠒 Notebook](adjust_viscosity_correlation.ipynb) \ No newline at end of file +- Adjust **entropy scaling** correlation parameters for the viscosity of a pure substance using PC-SAFT. [🠒 Notebook](adjust_viscosity_correlation.ipynb) + +## Python scripts + +Self-contained scripts fit the same hexane data (vapor pressure + liquid density) with +the same initial parameters and Huber loss ($\delta = 0.05$), so their results and +performance can be compared directly. + +| Script | Solver | Jacobian | +|--------|--------|----------| +| [`fit_hexane_ad.py`](fit_hexane_ad.py) | Levenberg-Marquardt via `PureRegressor` | exact, via automatic differentiation | +| [`fit_hexane_scipy.py`](fit_hexane_scipy.py) | `scipy.optimize.least_squares` | finite differences | +| [`fit_hexane_ad_scipy.py`](fit_hexane_ad_scipy.py) | `scipy.optimize.least_squares` | exact, via `PureRegressor.predict` | + +`fit_hexane_ad_scipy.py` uses `PureRegressor` as a compute engine only — data loading +and AD-based gradient evaluation — while delegating the optimisation loop to scipy. diff --git a/examples/pcsaft/parameter_adjustment/data/hexane_liquid_density_si.csv b/examples/pcsaft/parameter_adjustment/data/hexane_liquid_density_si.csv new file mode 100644 index 000000000..eab996b42 --- /dev/null +++ b/examples/pcsaft/parameter_adjustment/data/hexane_liquid_density_si.csv @@ -0,0 +1,275 @@ +temperature_k,pressure_pa,liquid_density_kmol_m3 +350.0,300000.0,7.001210488443843 +350.0,400000.0,6.9521243680994855 +350.0,500000.0,7.083271714761814 +350.0,600000.0,6.950605570193676 +350.0,700000.0,7.018327582506981 +350.0,800000.0,7.149777208486209 +350.0,900000.0,7.087531439407134 +350.0,1000000.0,7.078254920395067 +350.0,1100000.0,7.018751320710853 +350.0,1200000.0,7.014891570178662 +350.0,1300000.0,7.060140644340311 +350.0,1400000.0,7.040692499138969 +350.0,1500000.0,6.949371333365814 +350.0,1600000.0,7.176487433224479 +350.0,1700000.0,6.9948539562094005 +350.0,1800000.0,7.132720882914794 +350.0,1900000.0,6.978629836229118 +350.0,2000000.0,7.146711466836169 +350.0,2100000.0,7.176715492425876 +350.0,2200000.0,7.085778738092916 +350.0,2300000.0,7.040627030741701 +350.0,2400000.0,7.128087322583774 +350.0,2500000.0,7.069226720157454 +350.0,2600000.0,7.064449467010018 +350.0,2700000.0,6.971647283010562 +350.0,2800000.0,7.036108253226357 +350.0,2900000.0,7.040699735045252 +350.0,3000000.0,7.179295419989278 +350.0,3100000.0,7.165333544915564 +350.0,3200000.0,7.222239997465963 +350.0,3300000.0,7.305402542559887 +350.0,3400000.0,6.99309846925473 +350.0,3500000.0,7.160702441823115 +350.0,3600000.0,7.112160226898258 +350.0,3700000.0,7.107274609107515 +350.0,3800000.0,7.198907034685025 +350.0,3900000.0,7.067000985094092 +350.0,4000000.0,7.093518352237815 +350.0,4100000.0,7.211370337981172 +350.0,4200000.0,7.12622995491142 +350.0,4300000.0,7.162485376616234 +350.0,4400000.0,7.103617176558871 +350.0,4500000.0,7.075268509281439 +350.0,4600000.0,7.203336669774025 +350.0,4700000.0,7.258520962148245 +350.0,4800000.0,7.029702772724246 +350.0,4900000.0,7.062037160055758 +350.0,5000000.0,7.0669694556173095 +400.0,500000.0,6.425288139277845 +400.0,600000.0,6.452902722363471 +400.0,700000.0,6.366468730266217 +400.0,800000.0,6.236390194773374 +400.0,900000.0,6.329051219914626 +400.0,1000000.0,6.345974583934298 +400.0,1100000.0,6.185584007451268 +400.0,1200000.0,6.355982738987618 +400.0,1300000.0,6.376798786794841 +400.0,1400000.0,6.5404500179672045 +400.0,1500000.0,6.448002891687235 +400.0,1600000.0,6.4439552897721075 +400.0,1700000.0,6.382886044708666 +400.0,1800000.0,6.382187259029582 +400.0,1900000.0,6.489133814184842 +400.0,2000000.0,6.44542476774911 +400.0,2100000.0,6.426456626884955 +400.0,2200000.0,6.388044839330632 +400.0,2300000.0,6.505323216135093 +400.0,2400000.0,6.578365961209362 +400.0,2500000.0,6.419045717840369 +400.0,2600000.0,6.419452614619455 +400.0,2700000.0,6.456420550367546 +400.0,2800000.0,6.48076955861523 +400.0,2900000.0,6.538951720860583 +400.0,3000000.0,6.485850788592476 +400.0,3100000.0,6.440022671068399 +400.0,3200000.0,6.410594554839002 +400.0,3300000.0,6.45351219239132 +400.0,3400000.0,6.537159471403206 +400.0,3500000.0,6.495625330091825 +400.0,3600000.0,6.622837806201901 +400.0,3700000.0,6.52716204339086 +400.0,3800000.0,6.489156294750243 +400.0,3900000.0,6.501149741672746 +400.0,4000000.0,6.581469070638578 +400.0,4100000.0,6.519870793188601 +400.0,4200000.0,6.496065555110696 +400.0,4300000.0,6.6052464116888405 +400.0,4400000.0,6.544358448238239 +400.0,4500000.0,6.511157708056343 +400.0,4600000.0,6.475864954999324 +400.0,4700000.0,6.617538405516087 +400.0,4800000.0,6.6919785740650735 +400.0,4900000.0,6.546896852720851 +400.0,5000000.0,6.511605689941834 +450.0,1700000.0,5.621973304680421 +450.0,1800000.0,5.560253137818118 +450.0,1900000.0,5.625456310031412 +450.0,2000000.0,5.627924381806751 +450.0,2100000.0,5.714839254610797 +450.0,2200000.0,5.680095949317156 +450.0,2300000.0,5.592229009691533 +450.0,2400000.0,5.700294298340813 +450.0,2500000.0,5.637659619662609 +450.0,2600000.0,5.695719011129443 +450.0,2700000.0,5.681015692880695 +450.0,2800000.0,5.605441333818278 +450.0,2900000.0,5.619597565463315 +450.0,3000000.0,5.744474160208617 +450.0,3100000.0,5.799923895416572 +450.0,3200000.0,5.76840895487691 +450.0,3300000.0,5.681324724084675 +450.0,3400000.0,5.692550801914942 +450.0,3500000.0,5.739835750573229 +450.0,3600000.0,5.832374186193623 +450.0,3700000.0,5.739129676160303 +450.0,3800000.0,5.859835490620235 +450.0,3900000.0,5.757347459843955 +450.0,4000000.0,5.84935365879376 +450.0,4100000.0,5.727079959785357 +450.0,4200000.0,5.84847990969803 +450.0,4300000.0,6.106385367392379 +450.0,4400000.0,5.785223874372897 +450.0,4500000.0,5.838257658479018 +450.0,4600000.0,5.76760351519516 +450.0,4700000.0,5.865580291244478 +450.0,4800000.0,5.884700315912959 +450.0,4900000.0,5.866510227962425 +450.0,5000000.0,5.82036438444488 +450.0,5100000.0,5.8098117859748255 +450.0,5200000.0,5.91468679857276 +450.0,5300000.0,5.8453548200931795 +450.0,5400000.0,5.869678948894117 +450.0,5500000.0,5.814442411476562 +450.0,5600000.0,5.845910370689001 +450.0,5700000.0,5.836944836447107 +450.0,5800000.0,6.012558197629647 +450.0,5900000.0,5.92809413879931 +450.0,6000000.0,5.912672590952471 +450.0,6100000.0,5.854300297714795 +450.0,6200000.0,5.9519939516747336 +450.0,6300000.0,5.782334077362324 +450.0,6400000.0,5.8974873531192635 +450.0,6500000.0,5.8546796573284245 +450.0,6600000.0,6.041636944474535 +450.0,6700000.0,5.9926104750537785 +450.0,6800000.0,5.922127401409818 +450.0,6900000.0,5.998936020353747 +450.0,7000000.0,5.962931010683103 +450.0,7100000.0,5.980528203981572 +450.0,7200000.0,5.8345532795261486 +450.0,7300000.0,5.954524587849179 +450.0,7400000.0,6.006898756522551 +450.0,7500000.0,5.981688356421181 +500.0,3000000.0,4.339371637987871 +500.0,3100000.0,4.2865439668396075 +500.0,3200000.0,4.415724071264885 +500.0,3300000.0,4.380099257164524 +500.0,3400000.0,4.527721128330377 +500.0,3500000.0,4.5230693520022545 +500.0,3600000.0,4.522394722903794 +500.0,3700000.0,4.553402099840729 +500.0,3800000.0,4.612791805825364 +500.0,3900000.0,4.558456285244192 +500.0,4000000.0,4.636804421570239 +500.0,4100000.0,4.623570387612833 +500.0,4200000.0,4.679811372455123 +500.0,4300000.0,4.698275178295381 +500.0,4400000.0,4.725412474381398 +500.0,4500000.0,4.70349807001966 +500.0,4600000.0,4.842282241696605 +500.0,4700000.0,4.777997744131587 +500.0,4800000.0,4.851129204736031 +500.0,4900000.0,4.838906605714415 +500.0,5000000.0,4.85758942271342 +500.0,5100000.0,4.879718686307074 +500.0,5200000.0,4.840280866731093 +500.0,5300000.0,4.943102878013313 +500.0,5400000.0,4.8188839684417015 +500.0,5500000.0,4.9771881940536415 +500.0,5600000.0,5.012275322471255 +500.0,5700000.0,4.934118344521862 +500.0,5800000.0,5.01261330858607 +500.0,5900000.0,4.9856417645037965 +500.0,6000000.0,4.961642826036186 +500.0,6100000.0,5.122385594333205 +500.0,6200000.0,5.124365477875797 +500.0,6300000.0,4.994951043822446 +500.0,6400000.0,5.081516780271552 +500.0,6500000.0,5.069874851229551 +500.0,6600000.0,5.135655173000717 +500.0,6700000.0,5.1058160786036355 +500.0,6800000.0,5.064692861624403 +500.0,6900000.0,5.145404194988644 +500.0,7000000.0,5.069945549501574 +500.0,7100000.0,5.431349976323908 +500.0,7200000.0,5.167463541260346 +500.0,7300000.0,5.046820822298044 +500.0,7400000.0,5.264366162482095 +500.0,7500000.0,5.12864295201395 +500.0,7600000.0,5.198492029844091 +500.0,7700000.0,5.243132141108439 +500.0,7800000.0,5.168056517581716 +500.0,7900000.0,5.230098821110501 +500.0,8000000.0,5.199598919282649 +500.0,8100000.0,5.281188642809297 +500.0,8200000.0,5.279324697833235 +500.0,8300000.0,5.303632814652151 +500.0,8400000.0,5.2119156519125145 +500.0,8500000.0,5.256750908873426 +500.0,8600000.0,5.26873153492597 +500.0,8700000.0,5.230707321560369 +500.0,8800000.0,5.2998957735805385 +500.0,8900000.0,5.2488483983710745 +500.0,9000000.0,5.3822508323775 +500.0,9100000.0,5.499521523153374 +500.0,9200000.0,5.308652716063211 +500.0,9300000.0,5.296430552110213 +500.0,9400000.0,5.388675927919708 +500.0,9500000.0,5.361601402568782 +500.0,9600000.0,5.385826959759192 +500.0,9700000.0,5.343860328007611 +500.0,9800000.0,5.312633148398678 +500.0,9900000.0,5.428274499345689 +500.0,10000000.0,5.464282375648608 +500.0,10100000.0,5.385640236372317 +500.0,10200000.0,5.410254316600269 +500.0,10300000.0,5.446495377198169 +500.0,10400000.0,5.3323224746479045 +500.0,10500000.0,5.4173877316003 +500.0,10600000.0,5.363608686958759 +500.0,10700000.0,5.475205344293642 +500.0,10800000.0,5.41296395168624 +500.0,10900000.0,5.417147879543886 +500.0,11000000.0,5.465627418605521 +500.0,11100000.0,5.486471333117467 +500.0,11200000.0,5.512324606164393 +500.0,11300000.0,5.417665748198448 +500.0,11400000.0,5.479777105344541 +500.0,11500000.0,5.575508467048529 +500.0,11600000.0,5.468670517058631 +500.0,11700000.0,5.531643373449521 +500.0,11800000.0,5.524638982178248 +500.0,11900000.0,5.560131797227363 +500.0,12000000.0,5.456588589374344 +500.0,12100000.0,5.554945071548189 +500.0,12200000.0,5.527463472664187 +500.0,12300000.0,5.51445234965569 +500.0,12400000.0,5.73522121829668 +500.0,12500000.0,5.430014349416378 +500.0,12600000.0,5.475181680241351 +500.0,12700000.0,5.576294985065622 +500.0,12800000.0,5.650724905100086 +500.0,12900000.0,5.55315106920163 +500.0,13000000.0,5.480541136669293 +500.0,13100000.0,5.604442565093204 +500.0,13200000.0,5.600733963513108 +500.0,13300000.0,5.814162039748673 +500.0,13400000.0,5.571235736717521 +500.0,13500000.0,5.513090770073048 +500.0,13600000.0,5.542578958361567 +500.0,13700000.0,5.6382331756935296 +500.0,13800000.0,5.652854451294129 +500.0,13900000.0,5.673718897682621 +500.0,14000000.0,5.605579308921127 +500.0,14100000.0,5.814260204175523 +500.0,14200000.0,5.517622635549179 +500.0,14300000.0,5.611844835490296 +500.0,14400000.0,5.588914630575443 +500.0,14500000.0,5.667263330872355 +500.0,14600000.0,5.635120793104598 +500.0,14700000.0,5.584460200288177 +500.0,14800000.0,5.591750975952256 +500.0,14900000.0,5.697498955715081 +500.0,15000000.0,5.68042055707363 diff --git a/examples/pcsaft/parameter_adjustment/data/hexane_vapor_pressure_si.csv b/examples/pcsaft/parameter_adjustment/data/hexane_vapor_pressure_si.csv new file mode 100644 index 000000000..fae87281a --- /dev/null +++ b/examples/pcsaft/parameter_adjustment/data/hexane_vapor_pressure_si.csv @@ -0,0 +1,252 @@ +temperature_k,vapor_pressure_pa +250.0,1562.6538644807258 +251.0,1655.851868310787 +252.0,1886.2713380483237 +253.0,2019.5009312443806 +254.0,2140.8100064740215 +255.0,2258.397818142488 +256.0,2319.211229778488 +257.0,2313.863645036879 +258.0,2698.183507914522 +259.0,2749.720168967758 +260.0,2750.984241348488 +261.0,3192.3754462118836 +262.0,3227.0479254044258 +263.0,3423.394564662559 +264.0,3581.6593066161745 +265.0,3982.130257011554 +266.0,3880.356021111405 +267.0,3338.1663444762103 +268.0,4925.628510630349 +269.0,4642.9650619494 +270.0,3822.8727040364897 +271.0,5369.729434854844 +272.0,6009.712788999235 +273.0,5625.484510060916 +274.0,6041.610653983126 +275.0,7348.325954362277 +276.0,6972.351302965968 +277.0,7113.258978100548 +278.0,8396.85108698708 +279.0,7922.430507369979 +280.0,8291.115060166312 +281.0,8710.396482906219 +282.0,9945.452146684209 +283.0,10066.801949498436 +284.0,8079.497124352136 +285.0,10915.526901919946 +286.0,13649.006091362948 +287.0,11469.232381802794 +288.0,13129.313718605104 +289.0,12355.77625919186 +290.0,13645.105860313392 +291.0,14990.466624684504 +292.0,15070.469510914329 +293.0,16873.377854471088 +294.0,17461.3121787716 +295.0,17287.294835028166 +296.0,19300.237448388678 +297.0,18823.63676809931 +298.0,20966.641481965984 +299.0,21608.701003276576 +300.0,21944.081744856896 +301.0,22368.96900688335 +302.0,24078.326736353607 +303.0,25206.30161782479 +304.0,26500.27962837446 +305.0,28564.853863412205 +306.0,28041.94904194651 +307.0,28633.537103322393 +308.0,32677.23640168957 +309.0,30702.81345646014 +310.0,33228.32624198513 +311.0,33289.060339436604 +312.0,33871.71930744544 +313.0,36648.27167225716 +314.0,37298.968602031426 +315.0,41553.10453496118 +316.0,33648.895113228486 +317.0,38952.45084031131 +318.0,46208.77638480658 +319.0,44505.26368459835 +320.0,44769.76417996147 +321.0,50460.84665925219 +322.0,52678.94600613381 +323.0,53196.90962302289 +324.0,58013.040910742566 +325.0,56753.205219296535 +326.0,63139.27643016901 +327.0,62495.63285251474 +328.0,60733.49590457212 +329.0,66700.73485882625 +330.0,67345.77923765608 +331.0,54778.77844992014 +332.0,73163.41450844675 +333.0,78202.21288432364 +334.0,78180.08214364845 +335.0,82889.41941658761 +336.0,89212.11938388835 +337.0,86205.8808047026 +338.0,93002.21170740905 +339.0,87605.78853035923 +340.0,97952.24339538251 +341.0,100447.97571699649 +342.0,97202.57755345022 +343.0,132754.68335971073 +344.0,107122.94247652654 +345.0,114488.98665155964 +346.0,105648.68972927619 +347.0,123323.28243392882 +348.0,122742.1966598291 +349.0,132006.98319143103 +350.0,127174.74578288938 +351.0,129925.38971124002 +352.0,148996.26496022573 +353.0,141301.53458434332 +354.0,136035.60169466093 +355.0,157795.5492534477 +356.0,159248.7142548855 +357.0,160601.3219225147 +358.0,162555.18290334663 +359.0,169811.92467104807 +360.0,177188.74518987138 +361.0,171907.70253885613 +362.0,188614.59331084564 +363.0,179849.45405244123 +364.0,204517.46921517176 +365.0,194537.61470226722 +366.0,199024.15891679752 +367.0,205370.57591453477 +368.0,227293.9405322121 +369.0,215318.76979675388 +370.0,233255.03932823217 +371.0,201631.3397240711 +372.0,161106.426660287 +373.0,233429.27596313338 +374.0,245853.80176460525 +375.0,248620.53112475597 +376.0,254402.70223890807 +377.0,301361.7646610056 +378.0,255433.40609122557 +379.0,290218.2222091758 +380.0,318278.8150858369 +381.0,319798.2157633814 +382.0,329021.6590867629 +383.0,216568.67646546313 +384.0,303752.1270732949 +385.0,350686.6230617499 +386.0,335313.68758778996 +387.0,302823.49863100087 +388.0,339119.42804562685 +389.0,353359.0102906167 +390.0,385433.4096291113 +391.0,381778.58298094216 +392.0,392438.71490104625 +393.0,409932.46598937036 +394.0,393763.4406563903 +395.0,460620.51429871464 +396.0,388146.4192177562 +397.0,436538.84111381444 +398.0,425720.9575579353 +399.0,428126.4332813597 +400.0,462253.32646416 +401.0,456995.96255953034 +402.0,494122.28360815434 +403.0,483383.29715695477 +404.0,575018.6934075737 +405.0,507208.7729752317 +406.0,514274.7717351433 +407.0,612756.1297304884 +408.0,837292.0048242806 +409.0,563582.8304483339 +410.0,568675.5951589446 +411.0,584272.3294636776 +412.0,561268.8298072137 +413.0,614073.826470473 +414.0,647125.3005138078 +415.0,682340.6606374835 +416.0,612097.2948964505 +417.0,627312.3785260139 +418.0,666630.5761805691 +419.0,722791.8268383972 +420.0,765921.8514149383 +421.0,570365.7319678915 +422.0,695093.0616606963 +423.0,705810.6357107403 +424.0,751180.8290511704 +425.0,787338.9655501897 +426.0,769715.4773303714 +427.0,810484.1538384409 +428.0,835910.4943573483 +429.0,839684.1750122658 +430.0,817620.7071211546 +431.0,878960.7842901481 +432.0,896348.6903937106 +433.0,1237207.6327591934 +434.0,964878.65487643 +435.0,1789212.7628037755 +436.0,1308792.3313731363 +437.0,1072081.2792556575 +438.0,991730.9837516478 +439.0,958091.995744328 +440.0,1048317.8917511534 +441.0,1042191.5405080478 +442.0,1045835.1402222164 +443.0,2017205.2647103178 +444.0,1013714.1396684374 +445.0,1114917.6746176272 +446.0,1153327.6169381323 +447.0,1202391.9851037594 +448.0,1204343.700340002 +449.0,1215243.1325290268 +450.0,1138786.9645187326 +451.0,1303794.2911333768 +452.0,1311253.6787070888 +453.0,1243810.4905361293 +454.0,881313.464096937 +455.0,1424776.4289110894 +456.0,1418076.3628520446 +457.0,1360445.4691248627 +458.0,1361046.6867072335 +459.0,1425590.6967164518 +460.0,1538162.1705581804 +461.0,1483738.661909832 +462.0,1466963.3412703716 +463.0,1458053.9996665707 +464.0,1441207.6566288946 +465.0,1562338.9104533608 +466.0,1564652.9674976019 +467.0,1676806.4783671726 +468.0,1775750.473998638 +469.0,1620748.3531986123 +470.0,1841964.6310984131 +471.0,1636004.1160479952 +472.0,1730375.7007159889 +473.0,1757710.7927431439 +474.0,1787987.2990538217 +475.0,1563776.589971964 +476.0,2031557.9260048196 +477.0,1966579.469385739 +478.0,1894832.081337918 +479.0,1907616.0519186014 +480.0,2002739.6725163553 +481.0,2120076.0921199215 +482.0,862590.9101209659 +483.0,3024145.827121653 +484.0,2128685.5558597893 +485.0,2293442.9260978405 +486.0,2115544.9311047727 +487.0,2307532.8778011207 +488.0,2315693.8799577025 +489.0,2480315.2426526602 +490.0,2311265.0414816975 +491.0,2467450.4571287828 +492.0,2340976.3637895416 +493.0,2564265.978559386 +494.0,2202936.6985054766 +495.0,2434684.427086878 +496.0,2590915.196334932 +497.0,2476844.3715283796 +498.0,2535428.50103745 +499.0,2461351.181358102 +500.0,2806176.5367392707 diff --git a/examples/pcsaft/parameter_adjustment/fit_hexane_ad.py b/examples/pcsaft/parameter_adjustment/fit_hexane_ad.py new file mode 100644 index 000000000..5e2ff47a4 --- /dev/null +++ b/examples/pcsaft/parameter_adjustment/fit_hexane_ad.py @@ -0,0 +1,74 @@ +""" +PC-SAFT parameter fitting for hexane using the AD-based regressor. + +Fits m, sigma, and epsilon_k to vapor-pressure and liquid-density data +using the Levenberg-Marquardt algorithm with automatic differentiation. + +Run with: + uv run python fit_hexane_ad.py +""" + +import os + +from feos import ( + EquationOfStateAD, + LiquidDensityDataset, + LossFunction, + PureRegressor, + RegressorConfig, + VaporPressureDataset, +) + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") + + +def main(): + vp = VaporPressureDataset.from_csv( + os.path.join(DATA_DIR, "hexane_vapor_pressure_si.csv"), + name="vapor pressure", + ) + rho = LiquidDensityDataset.from_csv( + os.path.join(DATA_DIR, "hexane_liquid_density_si.csv"), + name="liquid density", + ) + print(f"Loaded {len(vp)} vapor-pressure and {len(rho)} liquid-density points.") + + # All four PC-SAFT parameters must be supplied; mu is fixed at 0. + # The "fit" list controls which are optimised. + reg = PureRegressor( + model=EquationOfStateAD.PcSaftNonAssoc, + datasets=[vp, rho], + params={"m": 3.0, "sigma": 3.8, "epsilon_k": 240.0, "mu": 0.0}, + fit=["m", "sigma", "epsilon_k"], + weights=[1.0, 1.0], + ) + + config = RegressorConfig( + ftol=1e-8, xtol=1e-8, gtol=1e-8, stepbound=0.1, patience=200 + ) + result = reg.fit(config=config, loss=LossFunction.huber(0.05)) + + print(f"\nConverged : {result.converged} ({result.termination_reason})") + print(f"Evaluations: {result.n_evaluations} elapsed: {result.elapsed_ms:.1f} ms") + + print("\nOptimal parameters:") + for name, val in result.optimal_params_dict().items(): + print(f" {name:<12} = {val:.6g}") + + print("\nAAD per dataset:") + for name, aad in zip(result.dataset_names, result.aad_per_dataset): + if aad is not None: + print(f" {name:<20} {aad:.2f} %") + else: + print(f" {name:<20} no convergence") + + datasets = reg.evaluate_datasets() # uses fitted params automatically + print("\nPer-dataset convergence:") + for name, ds in zip(result.dataset_names, datasets): + n_conv = sum(ds["converged"]) + n_total = len(ds["converged"]) + print(f" {name:<20} {n_conv}/{n_total} converged") + + +if __name__ == "__main__": + main() diff --git a/examples/pcsaft/parameter_adjustment/fit_hexane_ad_scipy.py b/examples/pcsaft/parameter_adjustment/fit_hexane_ad_scipy.py new file mode 100644 index 000000000..b7d4515a3 --- /dev/null +++ b/examples/pcsaft/parameter_adjustment/fit_hexane_ad_scipy.py @@ -0,0 +1,149 @@ +""" +PC-SAFT parameter fitting for hexane: AD gradients from PureRegressor, scipy as solver. + +PureRegressor handles data loading and evaluates the model + exact AD Jacobian. +A thin cached wrapper bridges that to scipy.optimize.least_squares. + +Run with: + uv run python fit_hexane_ad_scipy.py +""" + +import os +import time + +import numpy as np +from feos import ( + EquationOfStateAD, + LiquidDensityDataset, + PureRegressor, + VaporPressureDataset, +) +from scipy.optimize import least_squares + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +PENALTY = 10.0 + + +class RegressorPredictor: + """Wraps a PureRegressor as a cached cost/jac computation for scipy. + + PureRegressor.predict() computes model values and exact AD gradients + w.r.t. the fitted parameters in one parallel pass. + Scipy requires separate cost and jac calls, so this class caches + the result of a single evaluation of both so that each the first call + to cost or jac triggers a model evaluation, and subsequent calls reuse + the cached result. + """ + + def __init__(self, regressor: PureRegressor, all_params: dict): + self.regressor = regressor + all_names = regressor.all_param_names + fit_names = regressor.fitted_param_names + self._fit_indices = [all_names.index(n) for n in fit_names] + self._base = np.array([all_params[n] for n in all_names]) + + n = len(regressor.target()) + p = len(fit_names) + self._last_x: np.ndarray | None = None + self._prediction: np.ndarray = np.empty(n) + self._grad: np.ndarray = np.empty((n, p)) + self._conv: np.ndarray = np.zeros(n, dtype=bool) + + def _full_params(self, x: np.ndarray) -> np.ndarray: + """Insert the fitted values x into the full canonical parameter vector.""" + full = self._base.copy() + for j, idx in enumerate(self._fit_indices): + full[idx] = x[j] + return full + + def _evaluate(self, x: np.ndarray) -> None: + if self._last_x is None or not np.array_equal(x, self._last_x): + self._last_x = x.copy() + full = self._full_params(x) + self._prediction, self._grad, self._conv = self.regressor.predict( + list(full) + ) + + def cost(self, x: np.ndarray) -> np.ndarray: + self._evaluate(x) + target = self.regressor.target() + return np.where(self._conv, (self._prediction - target) / target, PENALTY) + + def jac(self, x: np.ndarray) -> np.ndarray: + self._evaluate(x) + target = self.regressor.target() + j = self._grad / target[:, np.newaxis] + j[~self._conv] = 0.0 + return j + + +def main(): + vp = VaporPressureDataset.from_csv( + os.path.join(DATA_DIR, "hexane_vapor_pressure_si.csv"), + name="vapor pressure", + ) + rho = LiquidDensityDataset.from_csv( + os.path.join(DATA_DIR, "hexane_liquid_density_si.csv"), + name="liquid density", + ) + print(f"Loaded {len(vp)} vapor-pressure and {len(rho)} liquid-density points.") + + initial = {"m": 3.0, "sigma": 3.8, "epsilon_k": 240.0, "mu": 0.0} + fit = ["m", "sigma", "epsilon_k"] + reg = PureRegressor( + model=EquationOfStateAD.PcSaftNonAssoc, + datasets=[vp, rho], + params=initial, + fit=fit, + weights=[1.0, 1.0], + ) + + predictor = RegressorPredictor(reg, initial) + x0 = np.array([initial[n] for n in fit]) + t0 = time.perf_counter() + result = least_squares( + predictor.cost, + x0, + jac=predictor.jac, + loss="huber", + f_scale=0.05, + ftol=1e-8, + xtol=1e-8, + gtol=1e-8, + max_nfev=200 * (len(x0) + 1), + method="trf", + ) + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + + converged = result.success or result.cost < 1e-8 + print(f"\nConverged : {converged} (status {result.status}: {result.message})") + print(f"Evaluations: {result.nfev} elapsed: {elapsed_ms:.1f} ms") + + print("\nOptimal parameters:") + for name, val in zip(fit, result.x): + print(f" {name:<12} = {val:.6g}") + + r = predictor.cost(result.x) + conv = predictor._conv + n_vp = len(vp) + r_vp, r_rho = r[:n_vp], r[n_vp:] + conv_vp, conv_rho = conv[:n_vp], conv[n_vp:] + + print("\nAAD per dataset:") + for label, rv, cv in [ + ("vapor pressure", r_vp, conv_vp), + ("liquid density", r_rho, conv_rho), + ]: + converged_r = rv[cv & (rv != PENALTY)] + if len(converged_r): + print(f" {label:<20} {np.abs(converged_r).mean() * 100:.2f} %") + else: + print(f" {label:<20} no convergence") + + print("\nPer-dataset convergence:") + for label, cv in [("vapor pressure", conv_vp), ("liquid density", conv_rho)]: + print(f" {label:<20} {cv.sum()}/{len(cv)} converged") + + +if __name__ == "__main__": + main() diff --git a/examples/pcsaft/parameter_adjustment/fit_hexane_scipy.py b/examples/pcsaft/parameter_adjustment/fit_hexane_scipy.py new file mode 100644 index 000000000..412bac3b5 --- /dev/null +++ b/examples/pcsaft/parameter_adjustment/fit_hexane_scipy.py @@ -0,0 +1,167 @@ +""" +PC-SAFT parameter fitting for hexane using scipy.optimize.least_squares. + +Uses the same data, initial parameters, and Huber loss (delta=0.05) as +fit_hexane_ad.py so that both approaches can be compared directly. + +Run with: + uv run python fit_hexane_scipy.py +""" + +import os +import time + +import numpy as np +import si_units as si +from feos import ( + EquationOfState, + Identifier, + Parameters, + PhaseEquilibrium, + PureRecord, + State, +) +from scipy.optimize import least_squares + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +M_HEXANE = 86.177 # g/mol + + +def build_eos(m, sigma, epsilon_k): + ident = Identifier(name="hexane") + rec = PureRecord( + ident, molarweight=M_HEXANE, m=m, sigma=sigma, epsilon_k=epsilon_k, mu=0.0 + ) + return EquationOfState.pcsaft(Parameters.new_pure(rec)) + + +def load_vp(): + t, p = [], [] + with open(os.path.join(DATA_DIR, "hexane_vapor_pressure_si.csv")) as f: + next(f) # header + for line in f: + ti, pi = line.split(",") + t.append(float(ti)) + p.append(float(pi)) + return np.array(t), np.array(p) + + +def load_rho(): + t, p, rho = [], [], [] + with open(os.path.join(DATA_DIR, "hexane_liquid_density_si.csv")) as f: + next(f) # header + for line in f: + ti, pi, ri = line.split(",") + t.append(float(ti)) + p.append(float(pi)) + rho.append(float(ri)) + return np.array(t), np.array(p), np.array(rho) + + +PENALTY = 10.0 + + +def compute_vp_residuals(eos, t_arr, psat_exp): + res = np.full(len(t_arr), PENALTY) + for i, (t, psat) in enumerate(zip(t_arr, psat_exp)): + try: + vle = PhaseEquilibrium.pure(eos, t * si.KELVIN) + psat_calc = vle.liquid.pressure() / si.PASCAL + res[i] = (psat_calc - psat) / psat + except Exception: + pass + return res + + +def compute_rho_residuals(eos, t_arr, p_arr, rho_exp): + res = np.full(len(t_arr), PENALTY) + for i, (t, p, rho) in enumerate(zip(t_arr, p_arr, rho_exp)): + try: + state = State( + eos, + temperature=t * si.KELVIN, + pressure=p * si.PASCAL, + density_initialization="liquid", + ) + rho_calc = state.density / (si.KILO * si.MOL / si.METER**3) + res[i] = (rho_calc - rho) / rho + except Exception: + pass + return res + + +def residuals(params, t_vp, psat_exp, t_rho, p_rho, rho_exp): + eos = build_eos(*params) + r_vp = compute_vp_residuals(eos, t_vp, psat_exp) + r_rho = compute_rho_residuals(eos, t_rho, p_rho, rho_exp) + return np.concatenate([r_vp, r_rho]) + + +def aad(r, mask): + converged = mask & (r != PENALTY) + if not converged.any(): + return None + return np.abs(r[converged]).mean() * 100.0 + + +def main(): + t_vp, psat_exp = load_vp() + t_rho, p_rho, rho_exp = load_rho() + print(f"Loaded {len(t_vp)} vapor-pressure and {len(t_rho)} liquid-density points.") + + x0 = [3.0, 3.8, 240.0] # m, sigma, epsilon_k — same as fit_hexane_ad.py + + t0 = time.perf_counter() + result = least_squares( + residuals, + x0, + loss="huber", + f_scale=0.05, + ftol=1e-8, + xtol=1e-8, + gtol=1e-8, + args=(t_vp, psat_exp, t_rho, p_rho, rho_exp), + max_nfev=200 * (len(x0) + 1), + method="trf", + ) + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + + m_opt, sigma_opt, eps_opt = result.x + converged = result.success or result.cost < 1e-8 + + print(f"\nConverged : {converged} (status {result.status}: {result.message})") + print(f"Evaluations: {result.nfev} elapsed: {elapsed_ms:.1f} ms") + + print("\nOptimal parameters:") + print(f" {'m':<12} = {m_opt:.6g}") + print(f" {'sigma':<12} = {sigma_opt:.6g}") + print(f" {'epsilon_k':<12} = {eps_opt:.6g}") + + # AAD from unweighted relative residuals at optimum (no loss applied) + eos_opt = build_eos(*result.x) + r_vp = compute_vp_residuals(eos_opt, t_vp, psat_exp) + r_rho = compute_rho_residuals(eos_opt, t_rho, p_rho, rho_exp) + conv_vp = r_vp != PENALTY + conv_rho = r_rho != PENALTY + + print("\nAAD per dataset:") + aad_vp = aad(r_vp, conv_vp) + aad_rho = aad(r_rho, conv_rho) + print( + f" {'vapor pressure':<20} {aad_vp:.2f} %" + if aad_vp + else f" {'vapor pressure':<20} no convergence" + ) + print( + f" {'liquid density':<20} {aad_rho:.2f} %" + if aad_rho + else f" {'liquid density':<20} no convergence" + ) + + print("\nPer-dataset convergence:") + print(f" {'vapor pressure':<20} {conv_vp.sum()}/{len(conv_vp)} converged") + print(f" {'liquid density':<20} {conv_rho.sum()}/{len(conv_rho)} converged") + + +if __name__ == "__main__": + main() diff --git a/py-feos/src/ad/mod.rs b/py-feos/src/ad/mod.rs index a2dc0c1fa..e80c48f2f 100644 --- a/py-feos/src/ad/mod.rs +++ b/py-feos/src/ad/mod.rs @@ -4,12 +4,12 @@ use numpy::{PyArray1, PyArray2, PyReadonlyArray2, ToPyArray}; use paste::paste; use pyo3::prelude::*; -pub mod solver; -pub use solver::{ +pub mod regressor; +pub use regressor::{ PyBinaryRegressor, PyBubblePointDataset, PyDewPointDataset, PyEnthalpyOfVaporizationDataset, - PyEquilibriumLiquidDensityDataset, PyFitConfig, PyFitResult, PyLiquidDensityDataset, - PyLossFunction, PyNonConvergenceStrategy, PyPureRegressor, PyResidualIsobaricHeatCapacityDataset, - PyVaporPressureDataset, + PyEquilibriumLiquidDensityDataset, PyLiquidDensityDataset, PyLossFunction, + PyNonConvergenceStrategy, PyPureRegressor, PyRegressorConfig, PyRegressorResult, + PyResidualIsobaricHeatCapacityDataset, PyVaporPressureDataset, }; #[pyclass(name = "EquationOfStateAD", eq, eq_int)] diff --git a/py-feos/src/ad/solver.rs b/py-feos/src/ad/regressor.rs similarity index 70% rename from py-feos/src/ad/solver.rs rename to py-feos/src/ad/regressor.rs index 87f1b7b3f..ffca17d09 100644 --- a/py-feos/src/ad/solver.rs +++ b/py-feos/src/ad/regressor.rs @@ -3,14 +3,14 @@ use std::collections::HashMap; use feos::pcsaft::{PcSaftBinary, PcSaftPure}; use feos_core::parameter_optimization::{ BinaryDataset, BubblePointDataset, BubblePointRecord, Dataset, DewPointDataset, DewPointRecord, - DynSolver, EnthalpyOfVaporizationDataset, EnthalpyOfVaporizationRecord, - EquilibriumLiquidDensityDataset, EquilibriumLiquidDensityRecord, FitConfig, FitResult, - FittingError, LiquidDensityDataset, LiquidDensityRecord, LossFunction, NonConvergenceStrategy, - PureDataset, Regressor, ResidualIsobaricHeatCapacityDataset, - ResidualIsobaricHeatCapacityRecord, VaporPressureDataset, VaporPressureRecord, + DynRegressor, EnthalpyOfVaporizationDataset, EnthalpyOfVaporizationRecord, + EquilibriumLiquidDensityDataset, EquilibriumLiquidDensityRecord, ParameterOptimizationError, + LiquidDensityDataset, LiquidDensityRecord, LossFunction, NonConvergenceStrategy, PureDataset, + Regressor, RegressorConfig, RegressorResult, ResidualFunction, + ResidualIsobaricHeatCapacityDataset, ResidualIsobaricHeatCapacityRecord, VaporPressureDataset, + VaporPressureRecord, }; -use ndarray::Array2; -use numpy::{PyArray1, PyArray2, ToPyArray}; +use numpy::{PyArray1, PyArray2, PyReadonlyArray1, ToPyArray}; use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::PyDict; @@ -36,8 +36,8 @@ macro_rules! py_dataset { /// /// Args: /// path (str): Path to the CSV file. - /// name (str, optional): Dataset name used in solver diagnostics - /// and results. Must be unique within a solver; defaults to + /// name (str, optional): Dataset name used in regressor diagnostics + /// and results. Must be unique within a regressor; defaults to /// the property name (e.g. `"vapor pressure"`). #[staticmethod] #[pyo3(signature = (path, name=None))] @@ -89,23 +89,25 @@ impl PyVaporPressureDataset { /// Args: /// temperature_k (np.ndarray): Temperatures in K. /// vapor_pressure_pa (np.ndarray): Vapor pressures in Pa. - /// name (str, optional): Dataset name (must be unique within a solver). + /// name (str, optional): Dataset name (must be unique within a regressor). #[new] #[pyo3(signature = (temperature_k, vapor_pressure_pa, name=None))] pub fn new( - temperature_k: Vec, - vapor_pressure_pa: Vec, + temperature_k: PyReadonlyArray1, + vapor_pressure_pa: PyReadonlyArray1, name: Option<&str>, ) -> PyResult { + let temperature_k = temperature_k.as_array(); + let vapor_pressure_pa = vapor_pressure_pa.as_array(); if temperature_k.len() != vapor_pressure_pa.len() { return Err(PyValueError::new_err( "temperature_k and vapor_pressure_pa must have the same length", )); } let records = temperature_k - .into_iter() - .zip(vapor_pressure_pa) - .map(|(t, p)| VaporPressureRecord { + .iter() + .zip(vapor_pressure_pa.iter()) + .map(|(&t, &p)| VaporPressureRecord { temperature_k: t, vapor_pressure_pa: p, }) @@ -122,7 +124,7 @@ py_dataset!( PyLiquidDensityDataset, LiquidDensityDataset, "LiquidDensityDataset", - "temperature_k, pressure_pa, liquid_density_molm3" + "temperature_k, pressure_pa, liquid_density_kmol_m3" ); #[pymethods] @@ -132,27 +134,33 @@ impl PyLiquidDensityDataset { /// Args: /// temperature_k (np.ndarray): Temperatures in K. /// pressure_pa (np.ndarray): Pressures in Pa. - /// liquid_density_molm3 (np.ndarray): Liquid molar densities in mol/m³. - /// name (str, optional): Dataset name (must be unique within a solver). + /// liquid_density_kmol_m3 (np.ndarray): Liquid molar densities in kmol/m³. + /// name (str, optional): Dataset name (must be unique within a regressor). #[new] - #[pyo3(signature = (temperature_k, pressure_pa, liquid_density_molm3, name=None))] + #[pyo3(signature = (temperature_k, pressure_pa, liquid_density_kmol_m3, name=None))] pub fn new( - temperature_k: Vec, - pressure_pa: Vec, - liquid_density_molm3: Vec, + temperature_k: PyReadonlyArray1, + pressure_pa: PyReadonlyArray1, + liquid_density_kmol_m3: PyReadonlyArray1, name: Option<&str>, ) -> PyResult { + let temperature_k = temperature_k.as_array(); + let pressure_pa = pressure_pa.as_array(); + let liquid_density_kmol_m3 = liquid_density_kmol_m3.as_array(); let n = temperature_k.len(); - if pressure_pa.len() != n || liquid_density_molm3.len() != n { + if pressure_pa.len() != n || liquid_density_kmol_m3.len() != n { return Err(PyValueError::new_err( "all arrays must have the same length", )); } - let records = (0..n) - .map(|i| LiquidDensityRecord { - temperature_k: temperature_k[i], - pressure_pa: pressure_pa[i], - liquid_density_molm3: liquid_density_molm3[i], + let records = temperature_k + .iter() + .zip(pressure_pa.iter()) + .zip(liquid_density_kmol_m3.iter()) + .map(|((&t, &p), &rho)| LiquidDensityRecord { + temperature_k: t, + pressure_pa: p, + liquid_density_kmol_m3: rho, }) .collect(); let mut inner = LiquidDensityDataset::from_records(records); @@ -167,7 +175,7 @@ py_dataset!( PyEquilibriumLiquidDensityDataset, EquilibriumLiquidDensityDataset, "EquilibriumLiquidDensityDataset", - "temperature_k, liquid_density_molm3" + "temperature_k, liquid_density_kmol_m3" ); #[pymethods] @@ -176,26 +184,28 @@ impl PyEquilibriumLiquidDensityDataset { /// /// Args: /// temperature_k (np.ndarray): Temperatures in K. - /// liquid_density_molm3 (np.ndarray): Saturated liquid molar densities in mol/m³. - /// name (str, optional): Dataset name (must be unique within a solver). + /// liquid_density_kmol_m3 (np.ndarray): Saturated liquid molar densities in kmol/m³. + /// name (str, optional): Dataset name (must be unique within a regressor). #[new] - #[pyo3(signature = (temperature_k, liquid_density_molm3, name=None))] + #[pyo3(signature = (temperature_k, liquid_density_kmol_m3, name=None))] pub fn new( - temperature_k: Vec, - liquid_density_molm3: Vec, + temperature_k: PyReadonlyArray1, + liquid_density_kmol_m3: PyReadonlyArray1, name: Option<&str>, ) -> PyResult { - if temperature_k.len() != liquid_density_molm3.len() { + let temperature_k = temperature_k.as_array(); + let liquid_density_kmol_m3 = liquid_density_kmol_m3.as_array(); + if temperature_k.len() != liquid_density_kmol_m3.len() { return Err(PyValueError::new_err( - "temperature_k and liquid_density_molm3 must have the same length", + "temperature_k and liquid_density_kmol_m3 must have the same length", )); } let records = temperature_k - .into_iter() - .zip(liquid_density_molm3) - .map(|(t, rho)| EquilibriumLiquidDensityRecord { + .iter() + .zip(liquid_density_kmol_m3.iter()) + .map(|(&t, &rho)| EquilibriumLiquidDensityRecord { temperature_k: t, - liquid_density_molm3: rho, + liquid_density_kmol_m3: rho, }) .collect(); let mut inner = EquilibriumLiquidDensityDataset::from_records(records); @@ -220,23 +230,25 @@ impl PyEnthalpyOfVaporizationDataset { /// Args: /// temperature_k (np.ndarray): Temperatures in K. /// dh_vap_j_mol (np.ndarray): Enthalpy of vaporization in J/mol. - /// name (str, optional): Dataset name (must be unique within a solver). + /// name (str, optional): Dataset name (must be unique within a regressor). #[new] #[pyo3(signature = (temperature_k, dh_vap_j_mol, name=None))] pub fn new( - temperature_k: Vec, - dh_vap_j_mol: Vec, + temperature_k: PyReadonlyArray1, + dh_vap_j_mol: PyReadonlyArray1, name: Option<&str>, ) -> PyResult { + let temperature_k = temperature_k.as_array(); + let dh_vap_j_mol = dh_vap_j_mol.as_array(); if temperature_k.len() != dh_vap_j_mol.len() { return Err(PyValueError::new_err( "temperature_k and dh_vap_j_mol must have the same length", )); } let records = temperature_k - .into_iter() - .zip(dh_vap_j_mol) - .map(|(t, p)| EnthalpyOfVaporizationRecord { + .iter() + .zip(dh_vap_j_mol.iter()) + .map(|(&t, &p)| EnthalpyOfVaporizationRecord { temperature_k: t, dh_vap_j_mol: p, }) @@ -264,26 +276,32 @@ impl PyResidualIsobaricHeatCapacityDataset { /// temperature_k (np.ndarray): Temperatures in K. /// pressure_pa (np.ndarray): Pressures in Pa. /// cp_res_j_molk (np.ndarray): Residual isobaric molar heat capacities in J/(mol·K). - /// name (str, optional): Dataset name (must be unique within a solver). + /// name (str, optional): Dataset name (must be unique within a regressor). #[new] #[pyo3(signature = (temperature_k, pressure_pa, cp_res_j_molk, name=None))] pub fn new( - temperature_k: Vec, - pressure_pa: Vec, - cp_res_j_molk: Vec, + temperature_k: PyReadonlyArray1, + pressure_pa: PyReadonlyArray1, + cp_res_j_molk: PyReadonlyArray1, name: Option<&str>, ) -> PyResult { + let temperature_k = temperature_k.as_array(); + let pressure_pa = pressure_pa.as_array(); + let cp_res_j_molk = cp_res_j_molk.as_array(); let n = temperature_k.len(); if pressure_pa.len() != n || cp_res_j_molk.len() != n { return Err(PyValueError::new_err( "all arrays must have the same length", )); } - let records = (0..n) - .map(|i| ResidualIsobaricHeatCapacityRecord { - temperature_k: temperature_k[i], - pressure_pa: pressure_pa[i], - cp_res_j_molk: cp_res_j_molk[i], + let records = temperature_k + .iter() + .zip(pressure_pa.iter()) + .zip(cp_res_j_molk.iter()) + .map(|((&t, &p), &cp)| ResidualIsobaricHeatCapacityRecord { + temperature_k: t, + pressure_pa: p, + cp_res_j_molk: cp, }) .collect(); let mut inner = ResidualIsobaricHeatCapacityDataset::from_records(records); @@ -310,26 +328,32 @@ impl PyBubblePointDataset { /// liquid_molefrac_1 (np.ndarray): Liquid-phase mole fractions of component 1. /// bubble_pressure_pa (np.ndarray): Bubble point pressures in Pa. /// Also used as the initial VLE solver guess. - /// name (str, optional): Dataset name (must be unique within a solver). + /// name (str, optional): Dataset name (must be unique within a regressor). #[new] #[pyo3(signature = (temperature_k, liquid_molefrac_1, bubble_pressure_pa, name=None))] pub fn new( - temperature_k: Vec, - liquid_molefrac_1: Vec, - bubble_pressure_pa: Vec, + temperature_k: PyReadonlyArray1, + liquid_molefrac_1: PyReadonlyArray1, + bubble_pressure_pa: PyReadonlyArray1, name: Option<&str>, ) -> PyResult { + let temperature_k = temperature_k.as_array(); + let liquid_molefrac_1 = liquid_molefrac_1.as_array(); + let bubble_pressure_pa = bubble_pressure_pa.as_array(); let n = temperature_k.len(); if liquid_molefrac_1.len() != n || bubble_pressure_pa.len() != n { return Err(PyValueError::new_err( "all arrays must have the same length", )); } - let records = (0..n) - .map(|i| BubblePointRecord { - temperature_k: temperature_k[i], - liquid_molefrac_1: liquid_molefrac_1[i], - bubble_pressure_pa: bubble_pressure_pa[i], + let records = temperature_k + .iter() + .zip(liquid_molefrac_1.iter()) + .zip(bubble_pressure_pa.iter()) + .map(|((&t, &x), &p)| BubblePointRecord { + temperature_k: t, + liquid_molefrac_1: x, + bubble_pressure_pa: p, }) .collect(); let mut inner = BubblePointDataset::from_records(records); @@ -356,26 +380,32 @@ impl PyDewPointDataset { /// vapor_molefrac_1 (np.ndarray): Vapor-phase mole fractions of component 1. /// dew_pressure_pa (np.ndarray): Dew point pressures in Pa. /// Also used as the initial VLE solver guess. - /// name (str, optional): Dataset name (must be unique within a solver). + /// name (str, optional): Dataset name (must be unique within a regressor). #[new] #[pyo3(signature = (temperature_k, vapor_molefrac_1, dew_pressure_pa, name=None))] pub fn new( - temperature_k: Vec, - vapor_molefrac_1: Vec, - dew_pressure_pa: Vec, + temperature_k: PyReadonlyArray1, + vapor_molefrac_1: PyReadonlyArray1, + dew_pressure_pa: PyReadonlyArray1, name: Option<&str>, ) -> PyResult { + let temperature_k = temperature_k.as_array(); + let vapor_molefrac_1 = vapor_molefrac_1.as_array(); + let dew_pressure_pa = dew_pressure_pa.as_array(); let n = temperature_k.len(); if vapor_molefrac_1.len() != n || dew_pressure_pa.len() != n { return Err(PyValueError::new_err( "all arrays must have the same length", )); } - let records = (0..n) - .map(|i| DewPointRecord { - temperature_k: temperature_k[i], - vapor_molefrac_1: vapor_molefrac_1[i], - dew_pressure_pa: dew_pressure_pa[i], + let records = temperature_k + .iter() + .zip(vapor_molefrac_1.iter()) + .zip(dew_pressure_pa.iter()) + .map(|((&t, &y), &p)| DewPointRecord { + temperature_k: t, + vapor_molefrac_1: y, + dew_pressure_pa: p, }) .collect(); let mut inner = DewPointDataset::from_records(records); @@ -438,8 +468,6 @@ impl PyLossFunction { /// NonConvergenceStrategy.Ignore /// >>> NonConvergenceStrategy.penalty(10.0) /// NonConvergenceStrategy.Penalty(10.0) -/// >>> NonConvergenceStrategy.adaptive_penalty(5.0) -/// NonConvergenceStrategy.AdaptivePenalty(factor=5.0) #[pyclass(name = "NonConvergenceStrategy")] pub struct PyNonConvergenceStrategy { pub(crate) inner: NonConvergenceStrategy, @@ -469,31 +497,12 @@ impl PyNonConvergenceStrategy { }) } - /// Penalty scaled by `factor × max |r_rel|` over converged points. - /// - /// Falls back to `factor` when no points converged. - /// - /// Args: - /// factor (float): Scaling factor, e.g. `5.0`. - #[staticmethod] - pub fn adaptive_penalty(factor: f64) -> PyResult { - if factor <= 0.0 { - return Err(PyValueError::new_err("factor must be > 0")); - } - Ok(Self { - inner: NonConvergenceStrategy::AdaptivePenalty(factor), - }) - } - pub fn __repr__(&self) -> String { - match &self.inner { + match self.inner { NonConvergenceStrategy::Ignore => "NonConvergenceStrategy.Ignore".to_string(), NonConvergenceStrategy::Penalty(v) => { format!("NonConvergenceStrategy.Penalty({v})") } - NonConvergenceStrategy::AdaptivePenalty(f) => { - format!("NonConvergenceStrategy.AdaptivePenalty(factor={f})") - } } } } @@ -516,14 +525,14 @@ impl PyNonConvergenceStrategy { /// norms. Default: True. /// strategy (NonConvergenceStrategy, optional): How to treat non-converged /// EoS evaluations. Default: ``NonConvergenceStrategy.penalty(10.0)``. -#[pyclass(name = "FitConfig")] +#[pyclass(name = "RegressorConfig")] #[derive(Clone)] -pub struct PyFitConfig { - pub(crate) inner: FitConfig, +pub struct PyRegressorConfig { + pub(crate) inner: RegressorConfig, } #[pymethods] -impl PyFitConfig { +impl PyRegressorConfig { #[new] #[pyo3(signature = (ftol=1e-8, xtol=1e-8, gtol=1e-8, stepbound=0.1, patience=100, scale_diag=true, strategy=None))] pub fn new( @@ -536,7 +545,7 @@ impl PyFitConfig { strategy: Option>, ) -> Self { Self { - inner: FitConfig { + inner: RegressorConfig { ftol, xtol, gtol, @@ -583,7 +592,7 @@ impl PyFitConfig { pub fn __repr__(&self) -> String { format!( - "FitConfig(ftol={}, xtol={}, gtol={}, stepbound={}, patience={}, \ + "RegressorConfig(ftol={}, xtol={}, gtol={}, stepbound={}, patience={}, \ scale_diag={}, strategy={})", self.inner.ftol, self.inner.xtol, @@ -615,13 +624,13 @@ impl PyFitConfig { /// converged (bool): Whether LM reported successful convergence. /// n_evaluations (int): Number of residual evaluations performed by LM. /// objective_function (float): Final value of the LM objective. -#[pyclass(name = "FitResult")] -pub struct PyFitResult { - pub(crate) inner: FitResult, +#[pyclass(name = "RegressorResult")] +pub struct PyRegressorResult { + pub(crate) inner: RegressorResult, } #[pymethods] -impl PyFitResult { +impl PyRegressorResult { #[getter] pub fn optimal_params(&self) -> Vec { self.inner.optimal_params.clone() @@ -671,7 +680,7 @@ impl PyFitResult { /// All parameters as `{name: value}`. /// - /// Fitted parameters at thei optimal values, all others at their initial values. + /// Fitted parameters at their optimal values, all others at their initial values. /// /// Note: /// @@ -721,7 +730,7 @@ impl PyFitResult { }) .collect(); format!( - "FitResult(converged={}, n_eval={}, elapsed={:.1}ms, aad=[{}])", + "RegressorResult(converged={}, n_eval={}, elapsed={:.1}ms, aad=[{}])", self.inner.converged, self.inner.n_evaluations, self.inner.elapsed.as_secs_f64() * 1000.0, @@ -730,7 +739,7 @@ impl PyFitResult { } } -macro_rules! solver_methods { +macro_rules! regressor_methods { ($py_type:ty, $display:expr) => { #[pymethods] impl $py_type { @@ -777,8 +786,11 @@ macro_rules! solver_methods { /// - `"converged"` — boolean mask /// /// Args: - /// params (dict[str, float]): All EOS parameters as returned by - /// :attr:`FitResult.all_parameters`. + /// params (dict[str, float], optional): All EOS parameters keyed by + /// name, as returned by :attr:`RegressorResult.all_parameters`. + /// If omitted, the regressor's current parameters are used — i.e. + /// the optimal values after :meth:`fit`, or the initial values + /// before fitting. /// /// Returns: /// list[dict[str, np.ndarray]]: One dict per dataset, in the @@ -786,24 +798,29 @@ macro_rules! solver_methods { /// /// Examples: /// >>> result = regressor.fit() - /// >>> ds = regressor.evaluate_datasets(result.all_parameters) - /// >>> dfs = [pd.DataFrame(d) for d in ds] # one pandas.DataFrame per Dataset + /// >>> ds = regressor.evaluate_datasets() # uses fitted params + /// >>> ds = regressor.evaluate_datasets(result.all_parameters) # explicit + /// >>> dfs = [pd.DataFrame(d) for d in ds] + #[pyo3(signature = (params=None))] pub fn evaluate_datasets<'py>( &self, py: Python<'py>, - params: HashMap, + params: Option>, ) -> PyResult>> { - // Build the full param vector in canonical order. - let names = self.inner.all_param_names(); - let param_vec: Result, _> = names - .iter() - .map(|n| { - params.get(n).copied().ok_or_else(|| { - PyValueError::new_err(format!("missing parameter '{n}'")) - }) - }) - .collect(); - let param_vec = param_vec?; + let param_vec = match params { + None => self.inner.optimal_params(), + Some(map) => { + let names = self.inner.all_param_names(); + names + .iter() + .map(|n| { + map.get(n).copied().ok_or_else(|| { + PyValueError::new_err(format!("missing parameter '{n}'")) + }) + }) + .collect::>>()? + } + }; let results = self.inner.evaluate_datasets(¶m_vec); @@ -830,22 +847,22 @@ macro_rules! solver_methods { /// Run Levenberg-Marquardt optimisation. /// /// Args: - /// config (FitConfig, optional): Regressor hyperparameters + /// config (RegressorConfig, optional): Regressor hyperparameters /// (including non-convergence strategy). /// loss (LossFunction, optional): Loss function applied to /// relative residuals. /// /// Returns: - /// FitResult + /// RegressorResult #[pyo3(signature = (config=None, loss=None))] pub fn fit( &mut self, - config: Option, + config: Option, loss: Option>, - ) -> PyFitResult { + ) -> PyRegressorResult { let config = config.map(|c| c.inner).unwrap_or_default(); let loss = loss.map(|l| l.borrow().inner.clone()); - PyFitResult { + PyRegressorResult { inner: self.inner.fit(config, loss), } } @@ -886,68 +903,132 @@ macro_rules! solver_methods { }; } -/// Levenberg-Marquardt solver for pure-component parameter fitting. +fn validate_weights(weights: &[f64], n_datasets: usize) -> PyResult<()> { + if weights.len() != n_datasets { + return Err(PyValueError::new_err(format!( + "weights has length {} but there are {} datasets", + weights.len(), + n_datasets + ))); + } + if weights.iter().any(|&w| w <= 0.0) { + return Err(PyValueError::new_err( + "all dataset weights must be positive", + )); + } + Ok(()) +} + +fn parse_residual_fn(s: &str) -> PyResult { + match s { + "difference" => Ok(ResidualFunction::Difference), + "log_difference" => Ok(ResidualFunction::LogDifference), + "relative_difference" => Ok(ResidualFunction::RelativeDifference), + _ => Err(PyValueError::new_err(format!( + "unknown residual function '{s}'; valid: 'difference', 'log_difference', 'relative_difference'" + ))), + } +} + +/// Levenberg-Marquardt regressor for pure-component parameter fitting. /// /// Args: /// model (EquationOfStateAD): Equation of state to use. /// datasets (list): One or more datasets for pure substance properties. /// params (dict[str, float]): Initial values for all parameters keyed by name. /// fit (list[str]): Names of the parameters to optimise. +/// residual (str, optional): Residual function. Possible values are +/// ``"relative_difference"`` (default), ``"log_difference"``, or ``"difference"``. +/// weights (list[float], optional): Per-dataset weights, one per dataset. +/// All weights must be positive. Default: 1.0 for every dataset. /// /// Examples: /// >>> vp = VaporPressureDataset.from_csv("vp.csv") /// >>> rho = LiquidDensityDataset.from_csv("rho.csv") -/// >>> solver = PureRegressor( +/// >>> reg = PureRegressor( /// ... model=EquationOfStateAD.PcSaftNonAssoc, /// ... datasets=[vp, rho], /// ... params={"m": 2.5, "sigma": 3.4, "epsilon_k": 280.0, "mu": 0.0}, /// ... fit=["sigma", "epsilon_k"], +/// ... weights=[1.0, 2.0], /// ... ) -/// >>> result = solver.fit() +/// >>> result = reg.fit() #[pyclass(name = "PureRegressor")] pub struct PyPureRegressor { - inner: Box, + inner: Box, } #[pymethods] impl PyPureRegressor { #[new] - #[pyo3(signature = (model, datasets, params, fit))] + #[pyo3(signature = (model, datasets, params, fit, residual=None, weights=None))] pub fn new( model: PyEquationOfStateAD, datasets: Vec>, params: HashMap, fit: Vec, + residual: Option<&str>, + weights: Option>, ) -> PyResult { let ds = extract_pure_datasets(&datasets)?; + if let Some(ref w) = weights { + validate_weights(w, ds.len())?; + } let fit_strs: Vec<&str> = fit.iter().map(|s| s.as_str()).collect(); - let inner: Box = match model { - PyEquationOfStateAD::PcSaftNonAssoc => Box::new( - Regressor::, _>::new(ds, params, &fit_strs) - .map_err(|e: FittingError| PyValueError::new_err(e.to_string()))?, - ), - PyEquationOfStateAD::PcSaftFull => Box::new( - Regressor::, _>::new(ds, params, &fit_strs) - .map_err(|e: FittingError| PyValueError::new_err(e.to_string()))?, - ), + let residual_fn = residual.map(parse_residual_fn).transpose()?; + let inner: Box = match model { + PyEquationOfStateAD::PcSaftNonAssoc => { + let r = Regressor::, _>::new(ds, params, &fit_strs) + .map_err(|e: ParameterOptimizationError| PyValueError::new_err(e.to_string()))?; + let r = match residual_fn { + Some(rf) => r.with_residual_fn(rf), + None => r, + }; + let r = match weights { + Some(w) => r + .with_weights(w) + .map_err(|e: ParameterOptimizationError| PyValueError::new_err(e.to_string()))?, + None => r, + }; + Box::new(r) + } + PyEquationOfStateAD::PcSaftFull => { + let r = Regressor::, _>::new(ds, params, &fit_strs) + .map_err(|e: ParameterOptimizationError| PyValueError::new_err(e.to_string()))?; + let r = match residual_fn { + Some(rf) => r.with_residual_fn(rf), + None => r, + }; + let r = match weights { + Some(w) => r + .with_weights(w) + .map_err(|e: ParameterOptimizationError| PyValueError::new_err(e.to_string()))?, + None => r, + }; + Box::new(r) + } }; Ok(Self { inner }) } } -solver_methods!(PyPureRegressor, "PureRegressor"); +regressor_methods!(PyPureRegressor, "PureRegressor"); -/// Levenberg-Marquardt solver for binary-mixture parameter fitting. +/// Levenberg-Marquardt regressor for binary-mixture parameter fitting. /// /// Args: /// model (EquationOfStateAD): Equation of state to use. /// datasets (list): One or more datasets for mixture properties. /// params (dict[str, float]): Initial values for all parameters keyed by name. /// fit (list[str]): Names of the parameters to optimise. +/// residual (str, optional): Residual function — ``"relative_difference"`` +/// (default), ``"log_difference"``, or ``"difference"``. +/// weights (list[float], optional): Per-dataset weights, one per dataset. +/// All weights must be positive. Default: 1.0 for every dataset. /// /// Examples: /// >>> bp = BubblePointDataset.from_csv("bubble.csv") -/// >>> solver = BinaryRegressor( +/// >>> reg = BinaryRegressor( /// ... model=EquationOfStateAD.PcSaftNonAssoc, /// ... datasets=[bp], /// ... params={ @@ -957,39 +1038,67 @@ solver_methods!(PyPureRegressor, "PureRegressor"); /// ... }, /// ... fit=["k_ij"], /// ... ) -/// >>> result = solver.fit() +/// >>> result = reg.fit() #[pyclass(name = "BinaryRegressor")] pub struct PyBinaryRegressor { - inner: Box, + inner: Box, } #[pymethods] impl PyBinaryRegressor { #[new] - #[pyo3(signature = (model, datasets, params, fit))] + #[pyo3(signature = (model, datasets, params, fit, residual=None, weights=None))] pub fn new( model: PyEquationOfStateAD, datasets: Vec>, params: HashMap, fit: Vec, + residual: Option<&str>, + weights: Option>, ) -> PyResult { let ds = extract_binary_datasets(&datasets)?; + if let Some(ref w) = weights { + validate_weights(w, ds.len())?; + } let fit_strs: Vec<&str> = fit.iter().map(|s| s.as_str()).collect(); - let inner: Box = match model { - PyEquationOfStateAD::PcSaftNonAssoc => Box::new( - Regressor::, _>::new(ds, params, &fit_strs) - .map_err(|e: FittingError| PyValueError::new_err(e.to_string()))?, - ), - PyEquationOfStateAD::PcSaftFull => Box::new( - Regressor::, _>::new(ds, params, &fit_strs) - .map_err(|e: FittingError| PyValueError::new_err(e.to_string()))?, - ), + let residual_fn = residual.map(parse_residual_fn).transpose()?; + let inner: Box = match model { + PyEquationOfStateAD::PcSaftNonAssoc => { + let r = Regressor::, _>::new(ds, params, &fit_strs) + .map_err(|e: ParameterOptimizationError| PyValueError::new_err(e.to_string()))?; + let r = match residual_fn { + Some(rf) => r.with_residual_fn(rf), + None => r, + }; + let r = match weights { + Some(w) => r + .with_weights(w) + .map_err(|e: ParameterOptimizationError| PyValueError::new_err(e.to_string()))?, + None => r, + }; + Box::new(r) + } + PyEquationOfStateAD::PcSaftFull => { + let r = Regressor::, _>::new(ds, params, &fit_strs) + .map_err(|e: ParameterOptimizationError| PyValueError::new_err(e.to_string()))?; + let r = match residual_fn { + Some(rf) => r.with_residual_fn(rf), + None => r, + }; + let r = match weights { + Some(w) => r + .with_weights(w) + .map_err(|e: ParameterOptimizationError| PyValueError::new_err(e.to_string()))?, + None => r, + }; + Box::new(r) + } }; Ok(Self { inner }) } } -solver_methods!(PyBinaryRegressor, "BinaryRegressor"); +regressor_methods!(PyBinaryRegressor, "BinaryRegressor"); /// Downcast a Python list to `Vec`. fn extract_pure_datasets(list: &[Bound<'_, PyAny>]) -> PyResult> { diff --git a/py-feos/src/dft/adsorption/external_potential.rs b/py-feos/src/dft/adsorption/external_potential.rs index bf4d6e209..64ed431c6 100644 --- a/py-feos/src/dft/adsorption/external_potential.rs +++ b/py-feos/src/dft/adsorption/external_potential.rs @@ -1,7 +1,7 @@ use feos_dft::adsorption::ExternalPotential; use ndarray::Array2; -use numpy::prelude::*; use numpy::PyArray1; +use numpy::prelude::*; use pyo3::prelude::*; use quantity::Length; diff --git a/py-feos/src/dft/mod.rs b/py-feos/src/dft/mod.rs index 5986ee0d0..19f2ad33a 100644 --- a/py-feos/src/dft/mod.rs +++ b/py-feos/src/dft/mod.rs @@ -1,137 +1,137 @@ -use crate::eos::{parse_molefracs, PyEquationOfState}; -use crate::ideal_gas::IdealGasModel; -use crate::residual::ResidualModel; -use feos::hard_sphere::{FMTFunctional, FMTVersion}; -use feos_core::{EquationOfState, ResidualDyn}; -use feos_dft::Geometry; -use numpy::PyReadonlyArray1; -use pyo3::prelude::*; -use pyo3::pyclass; -use std::sync::Arc; - -mod adsorption; -mod interface; -mod profile; -mod solvation; -mod solver; - -pub(crate) use adsorption::{ - PyAdsorption1D, PyAdsorption3D, PyExternalPotential, PyPore1D, PyPore2D, PyPore3D, -}; -pub(crate) use interface::{PyPlanarInterface, PySurfaceTensionDiagram}; -pub(crate) use solvation::{PyPairCorrelation, PySolvationProfile}; -pub(crate) use solver::{PyDFTSolver, PyDFTSolverLog}; - -/// Geometries of individual axes. -#[derive(Clone, Copy, PartialEq)] -#[pyclass(name = "Geometry", eq, eq_int)] -pub enum PyGeometry { - Cartesian, - Cylindrical, - Spherical, -} - -impl From for PyGeometry { - fn from(geometry: Geometry) -> Self { - match geometry { - Geometry::Cartesian => PyGeometry::Cartesian, - Geometry::Cylindrical => PyGeometry::Cylindrical, - Geometry::Spherical => PyGeometry::Spherical, - } - } -} - -impl From for Geometry { - fn from(geometry: PyGeometry) -> Self { - match geometry { - PyGeometry::Cartesian => Geometry::Cartesian, - PyGeometry::Cylindrical => Geometry::Cylindrical, - PyGeometry::Spherical => Geometry::Spherical, - } - } -} - -/// Different versions of fundamental measure theory. -#[derive(Clone, Copy, PartialEq)] -#[pyclass(name = "FMTVersion", eq, eq_int)] -pub enum PyFMTVersion { - /// White Bear ([Roth et al., 2002](https://doi.org/10.1088/0953-8984/14/46/313)) or modified ([Yu and Wu, 2002](https://doi.org/10.1063/1.1520530)) fundamental measure theory - WhiteBear, - /// Scalar fundamental measure theory by [Kierlik and Rosinberg, 1990](https://doi.org/10.1103/PhysRevA.42.3382) - KierlikRosinberg, - /// Anti-symmetric White Bear fundamental measure theory ([Rosenfeld et al., 1997](https://doi.org/10.1103/PhysRevE.55.4245)) and SI of ([Kessler et al., 2021](https://doi.org/10.1016/j.micromeso.2021.111263)) - AntiSymWhiteBear, -} - -impl From for PyFMTVersion { - fn from(fmt_version: FMTVersion) -> Self { - match fmt_version { - FMTVersion::WhiteBear => PyFMTVersion::WhiteBear, - FMTVersion::KierlikRosinberg => PyFMTVersion::KierlikRosinberg, - FMTVersion::AntiSymWhiteBear => PyFMTVersion::AntiSymWhiteBear, - } - } -} - -impl From for FMTVersion { - fn from(fmt_version: PyFMTVersion) -> Self { - match fmt_version { - PyFMTVersion::WhiteBear => FMTVersion::WhiteBear, - PyFMTVersion::KierlikRosinberg => FMTVersion::KierlikRosinberg, - PyFMTVersion::AntiSymWhiteBear => FMTVersion::AntiSymWhiteBear, - } - } -} - -/// Collection of Helmholtz energy functionals. -#[pyclass(name = "HelmholtzEnergyFunctional")] -#[derive(Clone)] -pub struct PyHelmholtzEnergyFunctional; - -#[pymethods] -impl PyHelmholtzEnergyFunctional { - /// Helmholtz energy functional for hard sphere systems. - /// - /// Parameters - /// ---------- - /// sigma : numpy.ndarray[float] - /// The diameters of the hard spheres in Angstrom. - /// fmt_version : FMTVersion - /// The specific variant of the FMT term. - /// - /// Returns - /// ------- - /// HelmholtzEnergyFunctional - #[staticmethod] - fn fmt(sigma: PyReadonlyArray1, fmt_version: PyFMTVersion) -> PyResult { - let func = ResidualModel::FmtFunctional(FMTFunctional::new( - parse_molefracs(Some(sigma)).unwrap(), - fmt_version.into(), - )); - let ideal_gas = vec![IdealGasModel::NoModel; func.components()]; - Ok(PyEquationOfState(Arc::new(EquationOfState::new( - ideal_gas, func, - )))) - } -} - -// #[pymodule] -// pub fn dft(m: &Bound<'_, PyModule>) -> PyResult<()> { -// m.add_class::()?; -// m.add_class::()?; - -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; -// m.add_class::()?; - -// Ok(()) -// } +use crate::eos::{PyEquationOfState, parse_molefracs}; +use crate::ideal_gas::IdealGasModel; +use crate::residual::ResidualModel; +use feos::hard_sphere::{FMTFunctional, FMTVersion}; +use feos_core::{EquationOfState, ResidualDyn}; +use feos_dft::Geometry; +use numpy::PyReadonlyArray1; +use pyo3::prelude::*; +use pyo3::pyclass; +use std::sync::Arc; + +mod adsorption; +mod interface; +mod profile; +mod solvation; +mod solver; + +pub(crate) use adsorption::{ + PyAdsorption1D, PyAdsorption3D, PyExternalPotential, PyPore1D, PyPore2D, PyPore3D, +}; +pub(crate) use interface::{PyPlanarInterface, PySurfaceTensionDiagram}; +pub(crate) use solvation::{PyPairCorrelation, PySolvationProfile}; +pub(crate) use solver::{PyDFTSolver, PyDFTSolverLog}; + +/// Geometries of individual axes. +#[derive(Clone, Copy, PartialEq)] +#[pyclass(name = "Geometry", eq, eq_int)] +pub enum PyGeometry { + Cartesian, + Cylindrical, + Spherical, +} + +impl From for PyGeometry { + fn from(geometry: Geometry) -> Self { + match geometry { + Geometry::Cartesian => PyGeometry::Cartesian, + Geometry::Cylindrical => PyGeometry::Cylindrical, + Geometry::Spherical => PyGeometry::Spherical, + } + } +} + +impl From for Geometry { + fn from(geometry: PyGeometry) -> Self { + match geometry { + PyGeometry::Cartesian => Geometry::Cartesian, + PyGeometry::Cylindrical => Geometry::Cylindrical, + PyGeometry::Spherical => Geometry::Spherical, + } + } +} + +/// Different versions of fundamental measure theory. +#[derive(Clone, Copy, PartialEq)] +#[pyclass(name = "FMTVersion", eq, eq_int)] +pub enum PyFMTVersion { + /// White Bear ([Roth et al., 2002](https://doi.org/10.1088/0953-8984/14/46/313)) or modified ([Yu and Wu, 2002](https://doi.org/10.1063/1.1520530)) fundamental measure theory + WhiteBear, + /// Scalar fundamental measure theory by [Kierlik and Rosinberg, 1990](https://doi.org/10.1103/PhysRevA.42.3382) + KierlikRosinberg, + /// Anti-symmetric White Bear fundamental measure theory ([Rosenfeld et al., 1997](https://doi.org/10.1103/PhysRevE.55.4245)) and SI of ([Kessler et al., 2021](https://doi.org/10.1016/j.micromeso.2021.111263)) + AntiSymWhiteBear, +} + +impl From for PyFMTVersion { + fn from(fmt_version: FMTVersion) -> Self { + match fmt_version { + FMTVersion::WhiteBear => PyFMTVersion::WhiteBear, + FMTVersion::KierlikRosinberg => PyFMTVersion::KierlikRosinberg, + FMTVersion::AntiSymWhiteBear => PyFMTVersion::AntiSymWhiteBear, + } + } +} + +impl From for FMTVersion { + fn from(fmt_version: PyFMTVersion) -> Self { + match fmt_version { + PyFMTVersion::WhiteBear => FMTVersion::WhiteBear, + PyFMTVersion::KierlikRosinberg => FMTVersion::KierlikRosinberg, + PyFMTVersion::AntiSymWhiteBear => FMTVersion::AntiSymWhiteBear, + } + } +} + +/// Collection of Helmholtz energy functionals. +#[pyclass(name = "HelmholtzEnergyFunctional")] +#[derive(Clone)] +pub struct PyHelmholtzEnergyFunctional; + +#[pymethods] +impl PyHelmholtzEnergyFunctional { + /// Helmholtz energy functional for hard sphere systems. + /// + /// Parameters + /// ---------- + /// sigma : numpy.ndarray[float] + /// The diameters of the hard spheres in Angstrom. + /// fmt_version : FMTVersion + /// The specific variant of the FMT term. + /// + /// Returns + /// ------- + /// HelmholtzEnergyFunctional + #[staticmethod] + fn fmt(sigma: PyReadonlyArray1, fmt_version: PyFMTVersion) -> PyResult { + let func = ResidualModel::FmtFunctional(FMTFunctional::new( + parse_molefracs(Some(sigma)).unwrap(), + fmt_version.into(), + )); + let ideal_gas = vec![IdealGasModel::NoModel; func.components()]; + Ok(PyEquationOfState(Arc::new(EquationOfState::new( + ideal_gas, func, + )))) + } +} + +// #[pymodule] +// pub fn dft(m: &Bound<'_, PyModule>) -> PyResult<()> { +// m.add_class::()?; +// m.add_class::()?; + +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; +// m.add_class::()?; + +// Ok(()) +// } diff --git a/py-feos/src/eos/epcsaft.rs b/py-feos/src/eos/epcsaft.rs index a1ccf46e1..63722e6c1 100644 --- a/py-feos/src/eos/epcsaft.rs +++ b/py-feos/src/eos/epcsaft.rs @@ -49,7 +49,7 @@ impl PyEquationOfState { _ => { return Err(PyErr::new::( r#"epcsaft_variant must be "advanced" or "revised""#.to_string(), - )) + )); } }; diff --git a/py-feos/src/eos/pcsaft.rs b/py-feos/src/eos/pcsaft.rs index 51dbdf406..220bc8c13 100644 --- a/py-feos/src/eos/pcsaft.rs +++ b/py-feos/src/eos/pcsaft.rs @@ -52,7 +52,7 @@ impl PyEquationOfState { _ => { return Err(PyErr::new::( r#"dq_variant must be "dq35" or "dq44""#, - )) + )); } }; let options = PcSaftOptions { @@ -119,7 +119,7 @@ impl PyHelmholtzEnergyFunctional { _ => { return Err(PyErr::new::( r#"dq_variant must be "dq35" or "dq44""#.to_string(), - )) + )); } }; let options = PcSaftOptions { diff --git a/py-feos/src/eos/uvtheory.rs b/py-feos/src/eos/uvtheory.rs index b5ce5b1e6..d2b847368 100644 --- a/py-feos/src/eos/uvtheory.rs +++ b/py-feos/src/eos/uvtheory.rs @@ -39,7 +39,7 @@ impl PyEquationOfState { _ => { return Err(PyErr::new::( r#"perturbation must be "BH", "WCA" or "WCA_B3""#.to_string(), - )) + )); } }; let options = UVTheoryOptions { diff --git a/py-feos/src/lib.rs b/py-feos/src/lib.rs index 5b25d3e41..7922787e7 100644 --- a/py-feos/src/lib.rs +++ b/py-feos/src/lib.rs @@ -213,8 +213,8 @@ fn feos(m: &Bound<'_, PyModule>) -> PyResult<()> { // Regressor m.add_class::()?; m.add_class::()?; - m.add_class::()?; - m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; } diff --git a/py-feos/src/parameter/chemical_record.rs b/py-feos/src/parameter/chemical_record.rs index b086a0f0f..81bb734a4 100644 --- a/py-feos/src/parameter/chemical_record.rs +++ b/py-feos/src/parameter/chemical_record.rs @@ -1,4 +1,4 @@ -use super::fragmentation::{fragment_molecule, PySmartsRecord}; +use super::fragmentation::{PySmartsRecord, fragment_molecule}; use super::identifier::{PyIdentifier, PyIdentifierOption}; use crate::error::PyFeosError; use feos_core::parameter::{ChemicalRecord, Identifier}; diff --git a/py-feos/src/user_defined.rs b/py-feos/src/user_defined.rs index 38bbe38ca..641238f0b 100644 --- a/py-feos/src/user_defined.rs +++ b/py-feos/src/user_defined.rs @@ -14,7 +14,10 @@ impl PyIdealGas { pub fn new(obj: Bound<'_, PyAny>) -> PyResult { let attr = obj.hasattr("ln_lambda3")?; if !attr { - panic!("{}", "Python Class has to have a method 'ln_lambda3' with signature:\n\tdef ln_lambda3(self, temperature: HD) -> HD\nwhere 'HD' has to be any (hyper-) dual number.") + panic!( + "{}", + "Python Class has to have a method 'ln_lambda3' with signature:\n\tdef ln_lambda3(self, temperature: HD) -> HD\nwhere 'HD' has to be any (hyper-) dual number." + ) } Ok(Self(obj.unbind())) } @@ -57,23 +60,34 @@ impl PyResidual { pub fn new(obj: Bound<'_, PyAny>) -> PyResult { let attr = obj.hasattr("components")?; if !attr { - panic!("Python Class has to have a method 'components' with signature:\n\tdef signature(self) -> int") + panic!( + "Python Class has to have a method 'components' with signature:\n\tdef signature(self) -> int" + ) } let attr = obj.hasattr("subset")?; if !attr { - panic!("Python Class has to have a method 'subset' with signature:\n\tdef subset(self, component_list: List[int]) -> Self") + panic!( + "Python Class has to have a method 'subset' with signature:\n\tdef subset(self, component_list: List[int]) -> Self" + ) } let attr = obj.hasattr("molar_weight")?; if !attr { - panic!("Python Class has to have a method 'molar_weight' with signature:\n\tdef molar_weight(self) -> SIArray1\nwhere the size of the returned array has to be 'components'.") + panic!( + "Python Class has to have a method 'molar_weight' with signature:\n\tdef molar_weight(self) -> SIArray1\nwhere the size of the returned array has to be 'components'." + ) } let attr = obj.hasattr("max_density")?; if !attr { - panic!("Python Class has to have a method 'max_density' with signature:\n\tdef max_density(self, moles: numpy.ndarray[float]) -> float\nwhere the size of the input array has to be 'components'.") + panic!( + "Python Class has to have a method 'max_density' with signature:\n\tdef max_density(self, moles: numpy.ndarray[float]) -> float\nwhere the size of the input array has to be 'components'." + ) } let attr = obj.hasattr("helmholtz_energy")?; if !attr { - panic!("{}", "Python Class has to have a method 'helmholtz_energy' with signature:\n\tdef helmholtz_energy(self, state: StateHD) -> HD\nwhere 'HD' has to be any of {{float, Dual64, HyperDual64, HyperDualDual64, Dual3Dual64, Dual3_64}}.") + panic!( + "{}", + "Python Class has to have a method 'helmholtz_energy' with signature:\n\tdef helmholtz_energy(self, state: StateHD) -> HD\nwhere 'HD' has to be any of {{float, Dual64, HyperDual64, HyperDualDual64, Dual3Dual64, Dual3_64}}." + ) } Ok(Self(obj.unbind())) } From da7e2b061edee104259afa848c4d5f9274238d97 Mon Sep 17 00:00:00 2001 From: Gernot Bauer Date: Mon, 27 Apr 2026 23:53:23 +0200 Subject: [PATCH 11/11] Fixed tests --- crates/feos/src/pcsaft/eos/mod.rs | 10 ++++++---- crates/feos/tests/pcsaft/px_flashes.rs | 10 ++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/feos/src/pcsaft/eos/mod.rs b/crates/feos/src/pcsaft/eos/mod.rs index 9ce1f2b5d..d52638642 100644 --- a/crates/feos/src/pcsaft/eos/mod.rs +++ b/crates/feos/src/pcsaft/eos/mod.rs @@ -700,7 +700,7 @@ mod tests_parameter_fit { #[test] fn test_boiling_temperature_derivatives_fit() -> FeosResult<()> { let pcsaft = pcsaft_non_assoc(); - let pcsaft_ad = pcsaft.named_derivatives(["m", "sigma", "epsilon_k"]); + let pcsaft_ad = PcSaftPure::::seed_derivatives(&pcsaft.0, ["m", "sigma", "epsilon_k"]); let pressure = BAR; let t = pcsaft_ad.boiling_temperature(pressure)?; let t = t.convert_into(KELVIN); @@ -1076,7 +1076,11 @@ mod tests_parameter_fit { #[test] fn test_tp_flash() -> FeosResult<()> { let (pcsaft, _) = pcsaft_binary()?; - let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let (params, mut kij) = pcsaft.0; + let mut flat_params: Vec = params[0].to_vec(); + flat_params.extend_from_slice(¶ms[1]); + flat_params.push(kij); + let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_params, ["k_ij"]); let temperature = 500.0 * KELVIN; let pressure = 44.6 * BAR; let x = 0.5; @@ -1096,8 +1100,6 @@ mod tests_parameter_fit { println!("{beta:.5}"); println!("{grad:.5?}"); - - let (params, mut kij) = pcsaft.0; let h = 1e-7; kij += h; let pcsaft_h = PcSaftBinary::new(params, kij); diff --git a/crates/feos/tests/pcsaft/px_flashes.rs b/crates/feos/tests/pcsaft/px_flashes.rs index 4cfb179b6..0f5e53bac 100644 --- a/crates/feos/tests/pcsaft/px_flashes.rs +++ b/crates/feos/tests/pcsaft/px_flashes.rs @@ -38,7 +38,10 @@ fn test_ph_flash() -> FeosResult<()> { println!("{h}\n{}", vle.molar_enthalpy()); assert_relative_eq!(h, vle.molar_enthalpy(), max_relative = 1e-10); - let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let mut flat_params: Vec = params[0].to_vec(); + flat_params.extend_from_slice(¶ms[1]); + flat_params.push(kij); + let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_params, ["k_ij"]); let joback_ad = joback.each_ref().map(|j| j.lift()); let eos_ad = EquationOfState::new(joback_ad, pcsaft_ad); let vle_ad = PhaseEquilibrium::ph_flash( @@ -105,7 +108,10 @@ fn test_ps_flash() -> FeosResult<()> { println!("{s}\n{}", vle.molar_entropy()); assert_relative_eq!(s, vle.molar_entropy(), max_relative = 1e-10); - let pcsaft_ad = pcsaft.named_derivatives(["k_ij"]); + let mut flat_params: Vec = params[0].to_vec(); + flat_params.extend_from_slice(¶ms[1]); + flat_params.push(kij); + let pcsaft_ad = PcSaftBinary::::seed_derivatives(&flat_params, ["k_ij"]); let joback_ad = joback.each_ref().map(|j| j.lift()); let eos_ad = EquationOfState::new(joback_ad, pcsaft_ad); let vle_ad = PhaseEquilibrium::ps_flash(