use crate::Kinetics::experimental_kinetics::experiment_series_main::TGASeries;
use crate::Kinetics::experimental_kinetics::kinetic_methods::kinetic_regression::{
LinearRegressionResult, linear_regression,
};
use crate::Kinetics::experimental_kinetics::kinetic_methods::{
KineticDataView, KineticMethod, KineticRequirements, TGADomainError,
};
use crate::Kinetics::experimental_kinetics::one_experiment_dataset::{ColumnNature, Unit};
use ndarray::Array1;
pub const R: f64 = 8.314;
#[derive(Debug, Clone)]
pub enum IsThisASublimationResult {
Yes(f64),
No(f64),
MayBe(f64),
}
impl IsThisASublimationResult {
pub fn from_r2(r2: f64) -> Self {
if r2 >= 0.99 {
Self::Yes(r2)
} else if r2 >= 0.90 {
Self::MayBe(r2)
} else {
Self::No(r2)
}
}
pub fn r2(&self) -> f64 {
match self {
Self::Yes(v) | Self::No(v) | Self::MayBe(v) => *v,
}
}
}
#[derive(Debug, Clone)]
pub struct SublimationResult {
pub experiment_id: String,
pub ea: f64,
pub k: f64,
pub regression: LinearRegressionResult,
pub resume: IsThisASublimationResult,
pub conversion: Vec<f64>,
pub fitted_conversion_rate: Vec<f64>,
}
impl Default for SublimationResult {
fn default() -> Self {
Self {
experiment_id: String::new(),
ea: 0.0,
k: 0.0,
regression: LinearRegressionResult::default(),
resume: IsThisASublimationResult::MayBe(0.0),
conversion: Vec::new(),
fitted_conversion_rate: Vec::new(),
}
}
}
pub struct SublimationMethod {
pub eta_min: f64,
pub eta_max: f64,
}
impl Default for SublimationMethod {
fn default() -> Self {
Self {
eta_min: 0.05,
eta_max: 0.95,
}
}
}
impl SublimationMethod {
pub fn new(eta_min: f64, eta_max: f64) -> Self {
Self { eta_min, eta_max }
}
pub fn solve(&self, data: &KineticDataView) -> Result<Vec<SublimationResult>, TGADomainError> {
if data.experiments.is_empty() {
return Err(TGADomainError::InvalidOperation(
"No experiments provided".into(),
));
}
let mut results = Vec::with_capacity(data.experiments.len());
for exp in &data.experiments {
let conv = &exp.conversion;
let temp = &exp.temperature;
let rate = &exp.conversion_rate;
if conv.is_empty() || temp.is_empty() || rate.is_empty() {
return Err(TGADomainError::InvalidOperation(
"Experiment has empty columns".into(),
));
}
let i_start = conv.iter().position(|&c| c >= self.eta_min).unwrap_or(0);
let i_end = conv
.iter()
.rposition(|&c| c <= self.eta_max)
.unwrap_or(conv.len() - 1);
if i_end <= i_start {
return Err(TGADomainError::InvalidOperation(format!(
"No data in conversion window [{}, {}] for experiment '{}'",
self.eta_min, self.eta_max, exp.meta.id
)));
}
let mut xs = Vec::with_capacity(i_end - i_start + 1);
let mut ys = Vec::with_capacity(i_end - i_start + 1);
for (&t, &r) in temp[i_start..=i_end]
.iter()
.zip(rate[i_start..=i_end].iter())
{
if !t.is_finite() || t <= 0.0 {
return Err(TGADomainError::InvalidOperation(
"Non-physical temperature".into(),
));
}
if r.is_finite() && r > 0.0 {
xs.push(1.0 / t);
ys.push(r.ln());
}
}
if xs.len() < 2 {
return Err(TGADomainError::InvalidOperation(format!(
"Not enough valid points for regression in experiment '{}'",
exp.meta.id
)));
}
let reg = linear_regression(&Array1::from(xs), &Array1::from(ys));
let ea = -R * reg.slope;
let k = reg.intercept.exp();
let fitted_conversion_rate = temp
.iter()
.map(|&t| k * (-ea / (R * t)).exp())
.collect::<Vec<f64>>();
let resume = IsThisASublimationResult::from_r2(reg.r2);
results.push(SublimationResult {
experiment_id: exp.meta.id.clone(),
ea,
k,
regression: reg,
resume,
conversion: conv.clone(),
fitted_conversion_rate,
});
}
Ok(results)
}
pub fn mean_results(
results: &[SublimationResult],
) -> Result<(f64, f64, IsThisASublimationResult), TGADomainError> {
if results.is_empty() {
return Err(TGADomainError::InvalidOperation(
"No results to aggregate".into(),
));
}
let n = results.len() as f64;
let mean_ea = results.iter().map(|r| r.ea).sum::<f64>() / n;
let mean_k = results.iter().map(|r| r.k).sum::<f64>() / n;
let mean_r2 = results.iter().map(|r| r.regression.r2).sum::<f64>() / n;
Ok((mean_ea, mean_k, IsThisASublimationResult::from_r2(mean_r2)))
}
pub fn push_fitted_rates_to_series(
results: &[SublimationResult],
series: &mut TGASeries,
) -> Result<(), TGADomainError> {
for res in results {
series.add_column_from_vec(
&res.experiment_id,
"fitted_sublim_rate",
Unit::PerSecond,
ColumnNature::ConversionRate,
res.fitted_conversion_rate.clone(),
)?;
}
Ok(())
}
}
impl KineticMethod for SublimationMethod {
type Output = Vec<SublimationResult>;
fn name(&self) -> &'static str {
"SublimationMethod"
}
fn requirements(&self) -> KineticRequirements {
KineticRequirements {
min_experiments: 1,
needs_conversion: true,
needs_conversion_rate: true,
needs_temperature: false,
needs_heating_rate: false,
}
}
fn required_columns_by_nature(&self) -> Vec<ColumnNature> {
vec![
ColumnNature::Conversion,
ColumnNature::Temperature,
ColumnNature::ConversionRate,
]
}
fn compute(&self, data: &KineticDataView) -> Result<Self::Output, TGADomainError> {
self.solve(data)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Kinetics::experimental_kinetics::experiment_series_main::{
ExperimentMeta, TGAExperiment,
};
use crate::Kinetics::experimental_kinetics::kinetic_methods::{
ExperimentData, KineticDataView,
};
use crate::Kinetics::experimental_kinetics::one_experiment_dataset::TGADataset;
use crate::Kinetics::experimental_kinetics::testing_mod::VirtualTGA;
use approx::assert_relative_eq;
fn synthetic_experiment(
k0: f64,
ea: f64,
t_start: f64,
t_end: f64,
n: usize,
id: &str,
) -> ExperimentData {
let temps: Vec<f64> = (0..n)
.map(|i| t_start + (t_end - t_start) * i as f64 / (n - 1) as f64)
.collect();
let rates: Vec<f64> = temps.iter().map(|&t| k0 * (-ea / (R * t)).exp()).collect();
let dt = (t_end - t_start) / (n - 1) as f64;
let mut conv = vec![0.0_f64; n];
for i in 1..n {
conv[i] = conv[i - 1] + 0.5 * (rates[i - 1] + rates[i]) * dt;
}
let max_conv = conv[n - 1].max(1e-30);
let conv: Vec<f64> = conv.iter().map(|&c| c / max_conv).collect();
let rates: Vec<f64> = rates.iter().map(|&r| r / max_conv).collect();
ExperimentData {
meta: ExperimentMeta {
id: id.to_string(),
heating_rate: None,
isothermal_temperature: None,
comment: None,
},
time: (0..n).map(|i| i as f64).collect(),
temperature: temps,
conversion: conv,
conversion_rate: rates,
mass: None,
mass_rate: None,
}
}
fn make_view(k0: f64, ea: f64, ids: &[&str]) -> KineticDataView {
KineticDataView {
experiments: ids
.iter()
.enumerate()
.map(|(i, &id)| {
synthetic_experiment(
k0,
ea,
400.0 + i as f64 * 20.0,
800.0 + i as f64 * 20.0,
500,
id,
)
})
.collect(),
}
}
#[test]
fn recovers_ea_single_experiment() {
let true_ea = 80_000.0;
let view = make_view(1e6, true_ea, &["exp1"]);
let results = SublimationMethod::new(0.05, 0.95)
.solve(&view)
.expect("solve failed");
assert_eq!(results.len(), 1);
assert_relative_eq!(results[0].ea, true_ea, epsilon = true_ea * 0.01);
assert!(
results[0].regression.r2 > 0.999,
"R²={}",
results[0].regression.r2
);
assert!(results[0].k > 0.0);
}
#[test]
fn fitted_curve_has_same_length_as_temperature() {
let view = make_view(1e5, 60_000.0, &["e1"]);
let n_temp = view.experiments[0].temperature.len();
let results = SublimationMethod::default().solve(&view).unwrap();
assert_eq!(results[0].fitted_conversion_rate.len(), n_temp);
}
#[test]
fn fitted_curve_matches_arrhenius() {
let true_ea = 70_000.0;
let true_k0 = 1e5_f64;
let view = make_view(true_k0, true_ea, &["e1"]);
let results = SublimationMethod::new(0.05, 0.95).solve(&view).unwrap();
let res = &results[0];
let t = 600.0_f64;
let expected = res.k * (-res.ea / (R * t)).exp();
let actual = res.k * (-res.ea / (R * t)).exp();
assert_relative_eq!(expected, actual, epsilon = 1e-10);
}
#[test]
fn returns_one_result_per_experiment() {
let view = make_view(1e6, 80_000.0, &["a", "b", "c"]);
let results = SublimationMethod::default().solve(&view).unwrap();
assert_eq!(results.len(), 3);
for (res, id) in results.iter().zip(["a", "b", "c"]) {
assert_eq!(res.experiment_id, id);
}
}
#[test]
fn classify_yes_for_perfect_sublimation() {
let view = make_view(1e5, 60_000.0, &["e1"]);
let results = SublimationMethod::default().solve(&view).unwrap();
assert!(
matches!(results[0].resume, IsThisASublimationResult::Yes(_)),
"expected Yes, got {:?}",
results[0].resume
);
}
#[test]
fn mean_results_averages_ea_and_k() {
let true_ea = 100_000.0;
let true_k0 = 1e8_f64;
let view = make_view(true_k0, true_ea, &["e1", "e2", "e3"]);
let results = SublimationMethod::new(0.05, 0.95).solve(&view).unwrap();
let (mean_ea, mean_k, verdict) = SublimationMethod::mean_results(&results).unwrap();
assert_relative_eq!(mean_ea, true_ea, epsilon = true_ea * 0.01);
assert!(mean_k > 0.0);
assert!(matches!(verdict, IsThisASublimationResult::Yes(_)));
}
#[test]
fn mean_results_errors_on_empty() {
assert!(SublimationMethod::mean_results(&[]).is_err());
}
#[test]
fn compute_returns_vec_of_results() {
let view = make_view(1e6, 80_000.0, &["e1", "e2"]);
let out = SublimationMethod::default().compute(&view).unwrap();
assert_eq!(out.len(), 2);
for r in &out {
assert!(r.regression.r2 > 0.999);
}
}
#[test]
fn push_fitted_rates_adds_column_to_series() {
let n = 500_usize;
let ids = ["exp_a", "exp_b"];
let view = make_view(1e6, 80_000.0, &ids);
let results = SublimationMethod::new(0.05, 0.95).solve(&view).unwrap();
let mut series = TGASeries::new();
for (exp_data, &id) in view.experiments.iter().zip(ids.iter()) {
let v = VirtualTGA {
time: exp_data.time.clone(),
temperature: exp_data.temperature.clone(),
mass: vec![1.0; n],
};
let ds = TGADataset::create_from_synthetic_data(&v).unwrap();
series.push(TGAExperiment::new(ds).with_id(id));
}
SublimationMethod::push_fitted_rates_to_series(&results, &mut series).unwrap();
for &id in &ids {
let cols = series.list_of_columns(id).unwrap();
assert!(
cols.contains(&"fitted_sublim_rate".to_string()),
"column missing for {id}"
);
}
}
}