use super::super::kinetic_methods::{
ConversionGrid, ConversionGridBuilder, GridInterpolation, KineticDataView, KineticMethod,
KineticRequirements, TGADomainError,
};
use super::Vyazovkin::VyazovkinSolver;
use super::kinetic_regression::{LinearRegressionResult, linear_regression};
use crate::Kinetics::experimental_kinetics::one_experiment_dataset::{
AffectedColumns, ColumnMeta, ColumnNature, ColumnOrigin, History, TGADataset, TGASchema, Unit,
};
use log::info;
use ndarray::Array1;
use polars::prelude::*;
use std::collections::HashMap;
use std::time::Instant;
pub const R: f64 = 8.314;
#[derive(Clone, Copy, Debug)]
pub enum IntIsoconversionalMethod {
KAS,
OFW,
Starink,
}
#[derive(Clone, Debug)]
pub struct IsoLayerResult {
pub eta: f64,
pub ea: f64,
pub k: Option<f64>,
pub regression: LinearRegressionResult,
}
#[derive(Clone, Debug)]
pub struct IsoconversionalResult {
pub method: &'static str,
pub layers: Vec<IsoLayerResult>,
}
impl IsoconversionalResult {
pub fn to_dataframe(&self) -> DataFrame {
let eta: Vec<f64> = self.layers.iter().map(|l| l.eta).collect();
let ea: Vec<f64> = self.layers.iter().map(|l| l.ea).collect();
let r2: Vec<f64> = self.layers.iter().map(|l| l.regression.r2).collect();
DataFrame::new_infer_height(vec![
Series::new("eta".into(), eta).into(),
Series::new("Ea".into(), ea).into(),
Series::new("R2".into(), r2).into(),
])
.unwrap()
}
pub fn to_tga_dataset(&self) -> Result<TGADataset, TGADomainError> {
let eta: Vec<f64> = self.layers.iter().map(|l| l.eta).collect();
let ea: Vec<f64> = self.layers.iter().map(|l| l.ea).collect();
let r2: Vec<f64> = self.layers.iter().map(|l| l.regression.r2).collect();
info!("found activasion energy vector of lengh {}", ea.len());
for (i, eta) in eta.iter().enumerate() {
println!("eta {:.2},Ea {:2}, r2 {:2}", eta, ea[i], r2[i]);
}
let df = DataFrame::new_infer_height(vec![
Series::new("eta".into(), eta).into(),
Series::new("Ea".into(), ea).into(),
Series::new("R2".into(), r2).into(),
])?;
let mut columns: HashMap<String, ColumnMeta> = HashMap::new();
columns.insert(
"eta".to_string(),
ColumnMeta {
name: "eta".to_string(),
unit: Unit::Dimensionless,
origin: ColumnOrigin::NumericDerived,
nature: ColumnNature::Conversion,
},
);
columns.insert(
"Ea".to_string(),
ColumnMeta {
name: "Ea".to_string(),
unit: Unit::Unknown,
origin: ColumnOrigin::NumericDerived,
nature: ColumnNature::ActivationEnergy,
},
);
columns.insert(
"R2".to_string(),
ColumnMeta {
name: "R2".to_string(),
unit: Unit::Dimensionless,
origin: ColumnOrigin::NumericDerived,
nature: ColumnNature::R2,
},
);
let schema = TGASchema {
columns,
time: None,
temperature: None,
mass: None,
alpha: Some("alpha".to_string()),
dm_dt: None,
eta: None,
deta_dt: None,
dalpha_dt: None,
dT_dt: None,
};
let mut dataset = TGADataset {
frame: df.lazy(),
schema,
oneframeplot: None,
history_of_operations: History::new(),
};
dataset.log_operation(
"create_from_isoconversional_result",
AffectedColumns::Specific(vec![
"alpha".to_string(),
"Ea".to_string(),
"R2".to_string(),
]),
None,
format!(
"Created dataset from isoconversional result ({})",
self.method
),
false,
);
Ok(dataset)
}
pub fn pretty_print_and_assert(
&self,
eta_min: f64,
eta_max: f64,
n: usize,
threshold: Option<f64>,
) {
use tabled::{Table, Tabled, settings::Style};
#[derive(Tabled)]
struct EaRow {
#[tabled(rename = "η")]
eta: String,
#[tabled(rename = "Ea (kJ/mol)")]
ea: String,
#[tabled(rename = "R²")]
r2: String,
}
let filtered: Vec<&IsoLayerResult> = self
.layers
.iter()
.filter(|layer| layer.eta >= eta_min && layer.eta <= eta_max)
.collect();
if filtered.is_empty() {
println!("No layers in the range [{}, {}]", eta_min, eta_max);
return;
}
let step = if filtered.len() <= n {
1
} else {
filtered.len() / n
};
let sampled: Vec<&IsoLayerResult> =
filtered.iter().step_by(step).take(n).copied().collect();
let rows: Vec<EaRow> = sampled
.clone()
.into_iter()
.map(|layer| EaRow {
eta: format!("{:.4}", layer.eta),
ea: format!("{:.2}", layer.ea / 1000.0),
r2: format!("{:.4}", layer.regression.r2),
})
.collect();
let table = Table::new(rows).with(Style::rounded()).to_string();
println!("{}", table);
if let Some(threshold) = threshold {
for layer in &sampled {
assert!(
layer.regression.r2 > threshold,
"R² = {} is not greater than {} for η = {}",
layer.regression.r2,
threshold,
layer.eta
);
}
}
}
}
#[derive(Clone, Debug)]
pub enum LogKind {
Ln,
Log10,
}
#[derive(Clone, Debug)]
pub struct IntegralIsoConfig {
pub exponent: f64,
pub log_kind: LogKind,
pub slope_factor: f64,
}
impl Default for IntegralIsoConfig {
fn default() -> Self {
Self {
exponent: 0.0,
log_kind: LogKind::Ln,
slope_factor: 0.0,
}
}
}
pub struct IntegralIsoconversionalSolver {
pub config: IntegralIsoConfig,
pub method: IntIsoconversionalMethod,
}
impl IntegralIsoconversionalSolver {
pub fn new(method: IntIsoconversionalMethod) -> Self {
match method {
IntIsoconversionalMethod::KAS => Self::kas(),
IntIsoconversionalMethod::Starink => Self::starink(),
IntIsoconversionalMethod::OFW => Self::ofw(),
}
}
fn method_name(&self) -> &'static str {
match self.method {
IntIsoconversionalMethod::KAS => "KAS",
IntIsoconversionalMethod::OFW => "Ozawa–Flynn–Wall",
IntIsoconversionalMethod::Starink => "Starink",
}
}
pub fn solve(&self, grid: &ConversionGrid) -> Result<IsoconversionalResult, TGADomainError> {
let n_eta = grid.eta.len();
let mut layers = Vec::with_capacity(n_eta);
for j in 0..n_eta {
let eta = grid.eta[j];
let inv_t = grid.inv_temperature.column(j);
let temp = grid.temperature.column(j);
let mut beta: Vec<f64> = Vec::with_capacity(grid.meta.len());
for m in &grid.meta {
match m.heating_rate {
Some(b) if b.is_finite() && b > 0.0 => beta.push(b),
_ => {
return Err(TGADomainError::InvalidOperation(
"Heating rate missing or invalid".into(),
));
}
}
}
let x = inv_t.to_owned();
let mut y = Array1::<f64>::zeros(beta.len());
for (i, b) in beta.iter().enumerate() {
let t = temp[i];
if !t.is_finite() || t <= 0.0 {
return Err(TGADomainError::InvalidOperation(
"Non-physical temperature encountered in grid".into(),
));
}
let val = b / t.powf(self.config.exponent);
let logv = match self.config.log_kind {
LogKind::Ln => val.ln(),
LogKind::Log10 => val.log10(),
};
if !logv.is_finite() {
return Err(TGADomainError::InvalidOperation(
"Log argument non-finite in isoconversional solver".into(),
));
}
y[i] = logv;
}
let reg = linear_regression(&x, &y);
let ea = reg.slope * self.config.slope_factor;
layers.push(IsoLayerResult {
eta,
ea,
k: None,
regression: reg,
});
}
Ok(IsoconversionalResult {
method: self.method_name(),
layers,
})
}
pub fn kas() -> Self {
Self {
config: IntegralIsoConfig {
exponent: 2.0,
log_kind: LogKind::Ln,
slope_factor: -R,
},
method: IntIsoconversionalMethod::KAS,
}
}
pub fn starink() -> Self {
Self {
config: IntegralIsoConfig {
exponent: 1.92,
log_kind: LogKind::Ln,
slope_factor: -R / 1.0008,
},
method: IntIsoconversionalMethod::Starink,
}
}
pub fn ofw() -> Self {
Self {
config: IntegralIsoConfig {
exponent: 0.0,
log_kind: LogKind::Ln,
slope_factor: -R / 1.052,
},
method: IntIsoconversionalMethod::OFW,
}
}
}
pub struct OFW {}
impl KineticMethod for OFW {
type Output = IsoconversionalResult;
fn name(&self) -> &'static str {
"Ozawa–Flynn–Wall"
}
fn compute(&self, data: &KineticDataView) -> Result<Self::Output, TGADomainError> {
let now = Instant::now();
let grid = ConversionGridBuilder::new()
.interpolation(GridInterpolation::Linear)
.build_nonisothermal(data)?;
println!("grid built in {} ms", now.elapsed().as_millis());
let now = Instant::now();
let solver = IntegralIsoconversionalSolver::new(IntIsoconversionalMethod::OFW);
let out = solver.solve(&grid);
println!("solver completed in {} ms", now.elapsed().as_millis());
out
}
fn requirements(&self) -> KineticRequirements {
KineticRequirements {
min_experiments: 3,
needs_conversion: true,
needs_conversion_rate: false,
needs_temperature: true,
needs_heating_rate: true,
}
}
fn required_columns_by_nature(&self) -> Vec<ColumnNature> {
vec![
ColumnNature::Conversion,
ColumnNature::Temperature,
ColumnNature::Time,
ColumnNature::ConversionRate,
]
}
}
pub struct KAS {}
impl KineticMethod for KAS {
type Output = IsoconversionalResult;
fn name(&self) -> &'static str {
"KAS"
}
fn compute(&self, data: &KineticDataView) -> Result<Self::Output, TGADomainError> {
let grid = ConversionGridBuilder::new()
.interpolation(GridInterpolation::Linear)
.build_nonisothermal(data)?;
let solver = IntegralIsoconversionalSolver::new(IntIsoconversionalMethod::KAS);
solver.solve(&grid)
}
fn requirements(&self) -> KineticRequirements {
KineticRequirements {
min_experiments: 3,
needs_conversion: true,
needs_conversion_rate: false,
needs_temperature: true,
needs_heating_rate: true,
}
}
fn required_columns_by_nature(&self) -> Vec<ColumnNature> {
vec![
ColumnNature::Conversion,
ColumnNature::Temperature,
ColumnNature::Time,
ColumnNature::ConversionRate,
]
}
}
pub struct Starink {}
impl KineticMethod for Starink {
type Output = IsoconversionalResult;
fn name(&self) -> &'static str {
"Ozawa–Flynn–Wall"
}
fn compute(&self, data: &KineticDataView) -> Result<Self::Output, TGADomainError> {
let now = Instant::now();
let grid = ConversionGridBuilder::new()
.interpolation(GridInterpolation::Linear)
.build_nonisothermal(data)?;
println!("grid built in {} ms", now.elapsed().as_millis());
let now = Instant::now();
let solver = IntegralIsoconversionalSolver::new(IntIsoconversionalMethod::Starink);
let out = solver.solve(&grid);
println!("solver completed in {} ms", now.elapsed().as_millis());
out
}
fn requirements(&self) -> KineticRequirements {
KineticRequirements {
min_experiments: 3,
needs_conversion: true,
needs_conversion_rate: false,
needs_temperature: true,
needs_heating_rate: true,
}
}
fn required_columns_by_nature(&self) -> Vec<ColumnNature> {
vec![
ColumnNature::Conversion,
ColumnNature::Temperature,
ColumnNature::Time,
ColumnNature::ConversionRate,
]
}
}
pub struct VyazovkinMethod {}
impl KineticMethod for VyazovkinMethod {
type Output = IsoconversionalResult;
fn name(&self) -> &'static str {
"Vyazovkin"
}
fn requirements(&self) -> KineticRequirements {
KineticRequirements {
min_experiments: 3,
needs_conversion: true,
needs_conversion_rate: false,
needs_temperature: true,
needs_heating_rate: true,
}
}
fn compute(&self, data: &KineticDataView) -> Result<Self::Output, TGADomainError> {
let grid = ConversionGridBuilder::default()
.with_dt_matrix()
.build_nonisothermal(data)?;
VyazovkinSolver::default().solve(&grid)
}
fn required_columns_by_nature(&self) -> Vec<ColumnNature> {
vec![
ColumnNature::Conversion,
ColumnNature::Temperature,
ColumnNature::Time,
ColumnNature::ConversionRate,
]
}
}