use super::types::NCAResult;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BioavailabilityResult {
pub f_auc_inf: Option<f64>,
pub f_auc_last: f64,
pub test_auc_inf_dn: Option<f64>,
pub ref_auc_inf_dn: Option<f64>,
pub test_auc_last_dn: f64,
pub ref_auc_last_dn: f64,
}
pub fn bioavailability(test: &NCAResult, reference: &NCAResult) -> Option<BioavailabilityResult> {
let test_dose = test.dose_amount.filter(|&d| d > 0.0)?;
let ref_dose = reference.dose_amount.filter(|&d| d > 0.0)?;
let test_auc_last_dn = test.exposure.auc_last / test_dose;
let ref_auc_last_dn = reference.exposure.auc_last / ref_dose;
let f_auc_last = if ref_auc_last_dn > 0.0 {
test_auc_last_dn / ref_auc_last_dn
} else {
f64::NAN
};
let (f_auc_inf, test_auc_inf_dn, ref_auc_inf_dn) =
match (test.exposure.auc_inf_obs, reference.exposure.auc_inf_obs) {
(Some(test_auc_inf), Some(ref_auc_inf)) => {
let test_dn = test_auc_inf / test_dose;
let ref_dn = ref_auc_inf / ref_dose;
let f = if ref_dn > 0.0 {
test_dn / ref_dn
} else {
f64::NAN
};
(Some(f), Some(test_dn), Some(ref_dn))
}
_ => (None, None, None),
};
Some(BioavailabilityResult {
f_auc_inf,
f_auc_last,
test_auc_inf_dn,
ref_auc_inf_dn,
test_auc_last_dn,
ref_auc_last_dn,
})
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BioequivalenceResult {
pub n: usize,
pub gmr_auc_last: f64,
pub ci_lower_auc_last: f64,
pub ci_upper_auc_last: f64,
pub gmr_auc_inf: Option<f64>,
pub ci_lower_auc_inf: Option<f64>,
pub ci_upper_auc_inf: Option<f64>,
pub ci_level: f64,
pub individual_f: Vec<f64>,
}
pub fn bioequivalence(
pairs: &[(NCAResult, NCAResult)],
ci_level: f64,
) -> Option<BioequivalenceResult> {
let f_values: Vec<f64> = pairs
.iter()
.filter_map(|(test, reference)| bioavailability(test, reference).map(|r| r.f_auc_last))
.filter(|f| f.is_finite() && *f > 0.0)
.collect();
let n = f_values.len();
if n < 2 {
return None;
}
let ln_f: Vec<f64> = f_values.iter().map(|f| f.ln()).collect();
let mean_ln = ln_f.iter().sum::<f64>() / n as f64;
let var_ln = ln_f.iter().map(|x| (x - mean_ln).powi(2)).sum::<f64>() / (n - 1) as f64;
let se_ln = (var_ln / n as f64).sqrt();
let alpha = 1.0 - ci_level;
let t_crit = t_quantile(1.0 - alpha / 2.0, (n - 1) as f64);
let gmr_auc_last = mean_ln.exp();
let ci_lower_auc_last = (mean_ln - t_crit * se_ln).exp();
let ci_upper_auc_last = (mean_ln + t_crit * se_ln).exp();
let f_inf_values: Vec<f64> = pairs
.iter()
.filter_map(|(test, reference)| bioavailability(test, reference).and_then(|r| r.f_auc_inf))
.filter(|f| f.is_finite() && *f > 0.0)
.collect();
let (gmr_auc_inf, ci_lower_auc_inf, ci_upper_auc_inf) = if f_inf_values.len() >= 2 {
let n_inf = f_inf_values.len();
let ln_f_inf: Vec<f64> = f_inf_values.iter().map(|f| f.ln()).collect();
let mean_ln_inf = ln_f_inf.iter().sum::<f64>() / n_inf as f64;
let var_ln_inf = ln_f_inf
.iter()
.map(|x| (x - mean_ln_inf).powi(2))
.sum::<f64>()
/ (n_inf - 1) as f64;
let se_ln_inf = (var_ln_inf / n_inf as f64).sqrt();
let t_crit_inf = t_quantile(1.0 - alpha / 2.0, (n_inf - 1) as f64);
(
Some(mean_ln_inf.exp()),
Some((mean_ln_inf - t_crit_inf * se_ln_inf).exp()),
Some((mean_ln_inf + t_crit_inf * se_ln_inf).exp()),
)
} else {
(None, None, None)
};
Some(BioequivalenceResult {
n,
gmr_auc_last,
ci_lower_auc_last,
ci_upper_auc_last,
gmr_auc_inf,
ci_lower_auc_inf,
ci_upper_auc_inf,
ci_level,
individual_f: f_values,
})
}
fn t_quantile(p: f64, df: f64) -> f64 {
use statrs::distribution::{ContinuousCDF, StudentsT};
StudentsT::new(0.0, 1.0, df).unwrap().inverse_cdf(p)
}
pub fn metabolite_parent_ratio(
parent: &NCAResult,
metabolite: &NCAResult,
) -> std::collections::HashMap<&'static str, f64> {
let mut ratios = std::collections::HashMap::new();
if parent.exposure.auc_last > 0.0 {
ratios.insert(
"auc_last_ratio",
metabolite.exposure.auc_last / parent.exposure.auc_last,
);
}
if let (Some(m_inf), Some(p_inf)) =
(metabolite.exposure.auc_inf_obs, parent.exposure.auc_inf_obs)
{
if p_inf > 0.0 {
ratios.insert("auc_inf_ratio", m_inf / p_inf);
}
}
if parent.exposure.cmax > 0.0 {
ratios.insert(
"cmax_ratio",
metabolite.exposure.cmax / parent.exposure.cmax,
);
}
ratios
}
pub fn compare(
test: &NCAResult,
reference: &NCAResult,
) -> std::collections::HashMap<&'static str, f64> {
let test_params = test.to_params();
let ref_params = reference.to_params();
let mut ratios = std::collections::HashMap::new();
for (&name, &ref_val) in &ref_params {
if ref_val.abs() > f64::EPSILON {
if let Some(&test_val) = test_params.get(name) {
ratios.insert(name, test_val / ref_val);
}
}
}
ratios
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::builder::SubjectBuilderExt;
use crate::nca::{NCAOptions, NCA};
use crate::Subject;
#[test]
fn test_bioavailability_basic() {
let oral = Subject::builder("oral")
.bolus(0.0, 100.0, 0)
.observation(0.0, 0.0, 0)
.observation(1.0, 5.0, 0)
.observation(2.0, 8.0, 0)
.observation(4.0, 4.0, 0)
.observation(8.0, 2.0, 0)
.observation(12.0, 1.0, 0)
.observation(24.0, 0.25, 0)
.build();
let iv = Subject::builder("iv")
.bolus(0.0, 100.0, 0)
.observation(0.0, 20.0, 0)
.observation(1.0, 15.0, 0)
.observation(2.0, 10.0, 0)
.observation(4.0, 5.0, 0)
.observation(8.0, 2.5, 0)
.observation(12.0, 1.25, 0)
.observation(24.0, 0.3, 0)
.build();
let opts = NCAOptions::default();
let oral_result = oral.nca(&opts).unwrap();
let iv_result = iv.nca(&opts).unwrap();
let f = bioavailability(&oral_result, &iv_result).unwrap();
assert!(
f.f_auc_last > 0.0 && f.f_auc_last < 1.0,
"F should be < 1 (lower oral exposure)"
);
let expected = oral_result.exposure.auc_last / iv_result.exposure.auc_last;
assert!((f.f_auc_last - expected).abs() < 1e-10);
}
#[test]
fn test_bioavailability_no_dose() {
let subject = Subject::builder("no_dose")
.observation(1.0, 10.0, 0)
.observation(2.0, 8.0, 0)
.build();
let opts = NCAOptions::default();
let result = subject.nca(&opts).unwrap();
assert!(bioavailability(&result, &result).is_none());
}
#[test]
fn test_t_quantile_accuracy() {
let cases = [
(5.0, 2.5706),
(10.0, 2.2281),
(30.0, 2.0423),
(120.0, 1.9799),
];
for (df, expected) in cases {
let got = t_quantile(0.975, df);
assert!(
(got - expected).abs() < 0.001,
"t(0.975, df={df}): got {got:.4}, expected {expected:.4}"
);
}
}
#[test]
fn test_metabolite_parent_ratio() {
let parent = Subject::builder("parent")
.bolus(0.0, 100.0, 0)
.observation(0.0, 0.0, 0)
.observation(1.0, 20.0, 0)
.observation(2.0, 15.0, 0)
.observation(4.0, 8.0, 0)
.observation(8.0, 4.0, 0)
.observation(12.0, 2.0, 0)
.observation(24.0, 0.5, 0)
.build();
let metabolite = Subject::builder("metabolite")
.bolus(0.0, 100.0, 0)
.observation(0.0, 0.0, 0)
.observation(1.0, 5.0, 0)
.observation(2.0, 8.0, 0)
.observation(4.0, 4.0, 0)
.observation(8.0, 2.0, 0)
.observation(12.0, 1.0, 0)
.observation(24.0, 0.25, 0)
.build();
let opts = NCAOptions::default();
let p = parent.nca(&opts).unwrap();
let m = metabolite.nca(&opts).unwrap();
let ratios = metabolite_parent_ratio(&p, &m);
assert!(ratios.contains_key("auc_last_ratio"));
assert!(ratios.contains_key("cmax_ratio"));
assert!(*ratios.get("cmax_ratio").unwrap() < 1.0);
assert!(*ratios.get("auc_last_ratio").unwrap() < 1.0);
}
#[test]
fn test_compare() {
let test_subj = Subject::builder("test")
.bolus(0.0, 100.0, 0)
.observation(0.0, 0.0, 0)
.observation(1.0, 10.0, 0)
.observation(2.0, 8.0, 0)
.observation(4.0, 4.0, 0)
.observation(8.0, 2.0, 0)
.observation(12.0, 1.0, 0)
.observation(24.0, 0.25, 0)
.build();
let ref_subj = Subject::builder("ref")
.bolus(0.0, 100.0, 0)
.observation(0.0, 0.0, 0)
.observation(1.0, 10.0, 0)
.observation(2.0, 8.0, 0)
.observation(4.0, 4.0, 0)
.observation(8.0, 2.0, 0)
.observation(12.0, 1.0, 0)
.observation(24.0, 0.25, 0)
.build();
let opts = NCAOptions::default();
let test_r = test_subj.nca(&opts).unwrap();
let ref_r = ref_subj.nca(&opts).unwrap();
let ratios = compare(&test_r, &ref_r);
for (&name, &ratio) in &ratios {
assert!(
(ratio - 1.0).abs() < 1e-10,
"ratio for {name} should be 1.0, got {ratio}"
);
}
assert!(ratios.contains_key("cmax"));
assert!(ratios.contains_key("auc_last"));
}
}