use super::observation::ObservationProfile as Profile;
use crate::data::observation_error::ObservationError;
use super::types::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct LambdaZResult {
pub lambda_z: f64,
pub intercept: f64,
pub r_squared: f64,
pub adj_r_squared: f64,
pub n_points: usize,
pub time_first: f64,
pub time_last: f64,
pub clast_pred: f64,
}
impl From<LambdaZResult> for RegressionStats {
fn from(lz: LambdaZResult) -> Self {
let half_life = std::f64::consts::LN_2 / lz.lambda_z;
let span = lz.time_last - lz.time_first;
let corrxy = if lz.r_squared >= 0.0 {
-(lz.r_squared.sqrt())
} else {
f64::NAN
};
RegressionStats {
r_squared: lz.r_squared,
adj_r_squared: lz.adj_r_squared,
corrxy,
n_points: lz.n_points,
time_first: lz.time_first,
time_last: lz.time_last,
span_ratio: span / half_life,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LambdaZCandidate {
pub n_points: usize,
pub start_idx: usize,
pub end_idx: usize,
pub start_time: f64,
pub end_time: f64,
pub lambda_z: f64,
pub half_life: f64,
pub intercept: f64,
pub r_squared: f64,
pub adj_r_squared: f64,
pub span_ratio: f64,
pub auc_inf: f64,
pub auc_pct_extrap: f64,
pub is_selected: bool,
}
pub(crate) fn lambda_z_candidates(
profile: &Profile,
options: &LambdaZOptions,
auc_last: f64,
) -> Vec<LambdaZCandidate> {
let start_idx = if options.include_tmax {
0
} else {
profile.cmax_idx + 1
};
if profile.tlast_idx < start_idx + options.min_points - 1 {
return Vec::new();
}
let max_n = if let Some(max) = options.max_points {
(profile.tlast_idx - start_idx + 1).min(max)
} else {
profile.tlast_idx - start_idx + 1
};
let clast_obs = profile.concentrations[profile.tlast_idx];
let mut candidates = Vec::new();
let mut best_idx: Option<usize> = None;
let mut best_score = f64::NEG_INFINITY;
for n_points in options.min_points..=max_n {
let first_idx = profile.tlast_idx - n_points + 1;
if first_idx < start_idx {
continue;
}
if let Some(result) = fit_lambda_z(profile, first_idx, profile.tlast_idx, options) {
let hl = std::f64::consts::LN_2 / result.lambda_z;
let span = result.time_last - result.time_first;
let span_ratio = span / hl;
let auc_inf_val = auc_inf(auc_last, clast_obs, result.lambda_z);
let extrap_pct = auc_extrap_pct(auc_last, auc_inf_val);
let candidate = LambdaZCandidate {
n_points: result.n_points,
start_idx: first_idx,
end_idx: profile.tlast_idx,
start_time: result.time_first,
end_time: result.time_last,
lambda_z: result.lambda_z,
half_life: hl,
intercept: result.intercept,
r_squared: result.r_squared,
adj_r_squared: result.adj_r_squared,
span_ratio,
auc_inf: auc_inf_val,
auc_pct_extrap: extrap_pct,
is_selected: false,
};
let qualifies =
result.r_squared >= options.min_r_squared && span_ratio >= options.min_span_ratio;
if qualifies {
let factor = options.adj_r_squared_factor;
let score = match options.method {
LambdaZMethod::AdjR2 => result.adj_r_squared + factor * result.n_points as f64,
_ => result.r_squared,
};
if score > best_score {
best_score = score;
best_idx = Some(candidates.len());
}
}
candidates.push(candidate);
}
}
if let Some(idx) = best_idx {
candidates[idx].is_selected = true;
}
candidates
}
pub(crate) fn lambda_z(profile: &Profile, options: &LambdaZOptions) -> Option<LambdaZResult> {
let start_idx = if options.include_tmax {
0
} else {
profile.cmax_idx + 1
};
if profile.tlast_idx < start_idx + options.min_points - 1 {
return None;
}
match options.method {
LambdaZMethod::Manual(n) => lambda_z_with_n_points(profile, start_idx, n, options),
LambdaZMethod::R2 | LambdaZMethod::AdjR2 => lambda_z_best_fit(profile, start_idx, options),
}
}
fn lambda_z_with_n_points(
profile: &Profile,
start_idx: usize,
n_points: usize,
options: &LambdaZOptions,
) -> Option<LambdaZResult> {
if n_points < options.min_points {
return None;
}
let first_idx = profile.tlast_idx.saturating_sub(n_points - 1);
if first_idx < start_idx {
return None;
}
fit_lambda_z(profile, first_idx, profile.tlast_idx, options)
}
fn lambda_z_best_fit(
profile: &Profile,
_start_idx: usize,
options: &LambdaZOptions,
) -> Option<LambdaZResult> {
let candidates = lambda_z_candidates(profile, options, 0.0);
let selected = candidates.iter().find(|c| c.is_selected)?;
let clast_pred =
(selected.intercept - selected.lambda_z * profile.times[selected.end_idx]).exp();
Some(LambdaZResult {
lambda_z: selected.lambda_z,
intercept: selected.intercept,
r_squared: selected.r_squared,
adj_r_squared: selected.adj_r_squared,
n_points: selected.n_points,
time_first: selected.start_time,
time_last: selected.end_time,
clast_pred,
})
}
fn fit_lambda_z(
profile: &Profile,
first_idx: usize,
last_idx: usize,
options: &LambdaZOptions,
) -> Option<LambdaZResult> {
let mut times = Vec::new();
let mut log_concs = Vec::new();
for i in first_idx..=last_idx {
if options.exclude_indices.contains(&i) {
continue;
}
if profile.concentrations[i] > 0.0 {
times.push(profile.times[i]);
log_concs.push(profile.concentrations[i].ln());
}
}
if times.len() < 2 {
return None;
}
let (slope, intercept, r_squared) = linear_regression(×, &log_concs)?;
let lambda_z = -slope;
if lambda_z <= 0.0 {
return None;
}
let n = times.len() as f64;
let adj_r_squared = 1.0 - (1.0 - r_squared) * (n - 1.0) / (n - 2.0);
let clast_pred = (intercept + slope * profile.times[last_idx]).exp();
Some(LambdaZResult {
lambda_z,
intercept,
r_squared,
adj_r_squared,
n_points: times.len(),
time_first: times[0],
time_last: times[times.len() - 1],
clast_pred,
})
}
fn linear_regression(x: &[f64], y: &[f64]) -> Option<(f64, f64, f64)> {
let n = x.len() as f64;
if n < 2.0 {
return None;
}
let sum_x = kahan_sum(x.iter().copied());
let sum_y = kahan_sum(y.iter().copied());
let sum_xy = kahan_sum(x.iter().zip(y.iter()).map(|(xi, yi)| xi * yi));
let sum_x2 = kahan_sum(x.iter().map(|xi| xi * xi));
let denom = n * sum_x2 - sum_x * sum_x;
if denom.abs() < 1e-15 {
return None;
}
let slope = (n * sum_xy - sum_x * sum_y) / denom;
let intercept = (sum_y - slope * sum_x) / n;
let mean_y = sum_y / n;
let ss_tot = kahan_sum(y.iter().map(|yi| (yi - mean_y).powi(2)));
let ss_res = kahan_sum(x.iter().zip(y.iter()).map(|(xi, yi)| {
let pred = intercept + slope * xi;
(yi - pred).powi(2)
}));
let r_squared = if ss_tot.abs() < 1e-15 {
1.0
} else {
1.0 - ss_res / ss_tot
};
Some((slope, intercept, r_squared))
}
#[inline]
fn kahan_sum(iter: impl Iterator<Item = f64>) -> f64 {
let mut sum = 0.0_f64;
let mut comp = 0.0_f64; for val in iter {
let y = val - comp;
let t = sum + y;
comp = (t - sum) - y;
sum = t;
}
sum
}
#[inline]
pub(crate) fn half_life(lambda_z: f64) -> f64 {
std::f64::consts::LN_2 / lambda_z
}
#[inline]
pub(crate) fn auc_inf(auc_last: f64, clast: f64, lambda_z: f64) -> f64 {
if lambda_z <= 0.0 {
return f64::NAN;
}
auc_last + clast / lambda_z
}
#[inline]
pub(crate) fn auc_extrap_pct(auc_last: f64, auc_inf: f64) -> f64 {
if auc_inf <= 0.0 || !auc_inf.is_finite() {
return f64::NAN;
}
(auc_inf - auc_last) / auc_inf * 100.0
}
pub(crate) fn aumc_inf(aumc_last: f64, clast: f64, tlast: f64, lambda_z: f64) -> f64 {
if lambda_z <= 0.0 {
return f64::NAN;
}
aumc_last + clast * tlast / lambda_z + clast / (lambda_z * lambda_z)
}
#[inline]
pub(crate) fn mrt(aumc_inf: f64, auc_inf: f64) -> f64 {
if auc_inf <= 0.0 || !auc_inf.is_finite() {
return f64::NAN;
}
aumc_inf / auc_inf
}
#[inline]
pub(crate) fn clearance(dose: f64, auc_inf: f64) -> f64 {
if auc_inf <= 0.0 || !auc_inf.is_finite() {
return f64::NAN;
}
dose / auc_inf
}
#[inline]
pub(crate) fn vz(dose: f64, lambda_z: f64, auc_inf: f64) -> f64 {
if lambda_z <= 0.0 || auc_inf <= 0.0 || !auc_inf.is_finite() {
return f64::NAN;
}
dose / (lambda_z * auc_inf)
}
use super::types::C0Method;
pub(crate) fn c0(
profile: &Profile,
methods: &[C0Method],
lambda_z: f64,
) -> (f64, Option<C0Method>) {
for m in methods {
if let Some(val) = try_c0_method(profile, *m, lambda_z) {
return (val, Some(*m));
}
}
(f64::NAN, None)
}
fn try_c0_method(profile: &Profile, method: C0Method, _lambda_z: f64) -> Option<f64> {
match method {
C0Method::Observed => {
if !profile.times.is_empty() && profile.times[0].abs() < 1e-10 {
let c = profile.concentrations[0];
if c > 0.0 {
return Some(c);
}
}
None
}
C0Method::LogSlope => c0_logslope(profile),
C0Method::FirstConc => {
profile.concentrations.iter().find(|&&c| c > 0.0).copied()
}
C0Method::Cmin => {
profile
.concentrations
.iter()
.filter(|&&c| c > 0.0)
.min_by(|a, b| a.partial_cmp(b).unwrap())
.copied()
}
C0Method::Zero => Some(0.0),
}
}
fn c0_logslope(profile: &Profile) -> Option<f64> {
if profile.concentrations.is_empty() {
return None;
}
let positive_points: Vec<(f64, f64)> = profile
.times
.iter()
.zip(profile.concentrations.iter())
.filter(|(_, &c)| c > 0.0)
.map(|(&t, &c)| (t, c))
.take(2)
.collect();
if positive_points.len() < 2 {
return None;
}
let (t1, c1) = positive_points[0];
let (t2, c2) = positive_points[1];
if c2 >= c1 || (t2 - t1).abs() < 1e-10 {
return None;
}
let slope = (c2.ln() - c1.ln()) / (t2 - t1);
Some((c1.ln() - slope * t1).exp())
}
#[inline]
pub(crate) fn vd_bolus(dose: f64, c0: f64) -> f64 {
if c0 <= 0.0 || !c0.is_finite() {
return f64::NAN;
}
dose / c0
}
pub(crate) fn vss(dose: f64, aumc_inf: f64, auc_inf: f64) -> f64 {
if auc_inf <= 0.0 || !auc_inf.is_finite() {
return f64::NAN;
}
dose * aumc_inf / (auc_inf * auc_inf)
}
#[inline]
pub(crate) fn mrt_infusion(mrt: f64, duration: f64) -> f64 {
mrt - duration / 2.0
}
pub(crate) fn tlag_from_raw(
times: &[f64],
concentrations: &[f64],
censoring: &[crate::Censor],
) -> Option<f64> {
if times.len() < 2 || concentrations.len() < 2 {
return None;
}
let conc_iter = concentrations
.iter()
.zip(censoring.iter())
.map(|(&c, censor)| {
if matches!(censor, crate::Censor::BLOQ) {
0.0
} else {
c
}
});
let mut prev = None;
for (i, c) in conc_iter.enumerate() {
if let Some(prev_c) = prev {
if c > prev_c {
return Some(times[i - 1]);
}
}
prev = Some(c);
}
None
}
pub(crate) fn cmin(profile: &Profile) -> f64 {
profile
.concentrations
.iter()
.copied()
.filter(|&c| c > 0.0)
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(0.0)
}
#[inline]
pub(crate) fn cavg(auc_tau: f64, tau: f64) -> f64 {
if tau <= 0.0 {
return f64::NAN;
}
auc_tau / tau
}
pub(crate) fn fluctuation(cmax: f64, cmin: f64, cavg: f64) -> f64 {
if cavg <= 0.0 {
return f64::NAN;
}
(cmax - cmin) / cavg * 100.0
}
pub(crate) fn swing(cmax: f64, cmin: f64) -> f64 {
if cmin <= 0.0 {
return f64::NAN;
}
(cmax - cmin) / cmin
}
#[inline]
pub(crate) fn effective_half_life(mrt: f64) -> f64 {
if !mrt.is_finite() || mrt <= 0.0 {
return f64::NAN;
}
std::f64::consts::LN_2 * mrt
}
#[inline]
pub(crate) fn kel(mrt: f64) -> f64 {
if !mrt.is_finite() || mrt <= 0.0 {
return f64::NAN;
}
1.0 / mrt
}
#[inline]
pub(crate) fn peak_trough_ratio(cmax: f64, cmin: f64) -> f64 {
if cmin <= 0.0 || !cmin.is_finite() {
return f64::NAN;
}
cmax / cmin
}
pub(crate) fn time_above_concentration(
times: &[f64],
concentrations: &[f64],
threshold: f64,
) -> Result<f64, ObservationError> {
if times.len() != concentrations.len() {
return Err(ObservationError::ArrayLengthMismatch {
description: format!(
"times ({}) and concentrations ({})",
times.len(),
concentrations.len()
),
});
}
if times.len() < 2 {
return Err(ObservationError::InsufficientData {
n: times.len(),
required: 2,
});
}
let mut total_time = 0.0;
for i in 0..times.len() - 1 {
let (t1, c1) = (times[i], concentrations[i]);
let (t2, c2) = (times[i + 1], concentrations[i + 1]);
let dt = t2 - t1;
if c1 >= threshold && c2 >= threshold {
total_time += dt;
} else if c1 >= threshold && c2 < threshold {
let t_cross = t1 + dt * (c1 - threshold) / (c1 - c2);
total_time += t_cross - t1;
} else if c1 < threshold && c2 >= threshold {
let t_cross = t1 + dt * (threshold - c1) / (c2 - c1);
total_time += t2 - t_cross;
}
}
Ok(total_time)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::auc::auc_segment;
use crate::data::builder::SubjectBuilderExt;
use crate::data::event::{AUCMethod, BLQRule};
use crate::Subject;
fn make_test_profile() -> Profile {
let subject = 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)
.build();
let occ = &subject.occasions()[0];
Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap()
}
#[test]
fn test_auc_segment_linear() {
let auc = auc_segment(0.0, 10.0, 1.0, 8.0, &AUCMethod::Linear).unwrap();
assert!((auc - 9.0).abs() < 1e-10); }
#[test]
fn test_auc_segment_log_down() {
let auc = auc_segment(0.0, 10.0, 1.0, 5.0, &AUCMethod::LinUpLogDown).unwrap();
let expected = 5.0 / (10.0_f64 / 5.0).ln(); assert!((auc - expected).abs() < 1e-10);
}
#[test]
fn test_auc_last() {
let profile = make_test_profile();
let auc = profile.auc_last(&AUCMethod::Linear).unwrap();
assert!((auc - 44.0).abs() < 1e-10);
}
#[test]
fn test_half_life() {
let hl = half_life(0.1);
assert!((hl - 6.931).abs() < 0.01); }
#[test]
fn test_clearance() {
let cl = clearance(100.0, 50.0);
assert!((cl - 2.0).abs() < 1e-10);
}
#[test]
fn test_vz() {
let v = vz(100.0, 0.1, 50.0);
assert!((v - 20.0).abs() < 1e-10); }
#[test]
fn test_linear_regression() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![2.0, 4.0, 6.0, 8.0, 10.0];
let (slope, intercept, r_squared) = linear_regression(&x, &y).unwrap();
assert!((slope - 2.0).abs() < 1e-10);
assert!(intercept.abs() < 1e-10);
assert!((r_squared - 1.0).abs() < 1e-10);
}
#[test]
fn test_fluctuation() {
let fluct = fluctuation(10.0, 2.0, 5.0);
assert!((fluct - 160.0).abs() < 1e-10); }
#[test]
fn test_swing() {
let s = swing(10.0, 2.0);
assert!((s - 4.0).abs() < 1e-10); }
#[test]
fn test_time_above_concentration_all_above() {
let times = [0.0, 1.0, 2.0, 4.0];
let concs = [10.0, 8.0, 6.0, 5.0];
let result = time_above_concentration(×, &concs, 1.0).unwrap();
assert!((result - 4.0).abs() < 1e-10, "All above: full duration");
}
#[test]
fn test_time_above_concentration_all_below() {
let times = [0.0, 1.0, 2.0];
let concs = [0.5, 0.3, 0.1];
let result = time_above_concentration(×, &concs, 1.0).unwrap();
assert!((result - 0.0).abs() < 1e-10, "All below: zero time");
}
#[test]
fn test_time_above_concentration_crossing() {
let times = [0.0, 1.0, 2.0];
let concs = [10.0, 5.0, 0.0]; let result = time_above_concentration(×, &concs, 4.0).unwrap();
let expected = 1.0 + 0.2;
assert!(
(result - expected).abs() < 1e-10,
"Crossing: {result} != {expected}"
);
}
#[test]
fn test_time_above_concentration_crosses_above() {
let times = [0.0, 1.0, 2.0];
let concs = [0.0, 10.0, 10.0];
let result = time_above_concentration(×, &concs, 5.0).unwrap();
assert!((result - 1.5).abs() < 1e-10);
}
#[test]
fn test_time_above_concentration_empty() {
assert!(time_above_concentration(&[], &[], 1.0).is_err());
assert!(time_above_concentration(&[1.0], &[5.0], 1.0).is_err());
}
#[test]
fn test_c0_logslope_normal() {
let subject = Subject::builder("test")
.bolus(0.0, 100.0, 1)
.observation(0.5, 20.0, 0)
.observation(1.0, 10.0, 0)
.observation(4.0, 1.0, 0)
.build();
let occ = &subject.occasions()[0];
let profile = Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap();
let result = c0_logslope(&profile);
assert!(result.is_some());
assert!((result.unwrap() - 40.0).abs() < 0.1);
}
#[test]
fn test_c0_logslope_first_conc_zero() {
let subject = Subject::builder("test")
.bolus(0.0, 100.0, 1)
.observation(0.0, 0.0, 0)
.observation(1.0, 10.0, 0)
.observation(2.0, 5.0, 0)
.build();
let occ = &subject.occasions()[0];
let profile = Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap();
let result = c0_logslope(&profile);
assert!(result.is_some());
}
#[test]
fn test_c0_logslope_both_equal() {
let subject = Subject::builder("test")
.bolus(0.0, 100.0, 1)
.observation(1.0, 10.0, 0)
.observation(2.0, 10.0, 0)
.build();
let occ = &subject.occasions()[0];
let profile = Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap();
let result = c0_logslope(&profile);
assert!(result.is_none());
}
#[test]
fn test_tlag_from_raw_clear_lag() {
let times = vec![0.0, 0.5, 1.0, 2.0];
let concs = vec![0.0, 0.0, 5.0, 10.0];
let censoring = vec![
crate::Censor::BLOQ,
crate::Censor::BLOQ,
crate::Censor::None,
crate::Censor::None,
];
let result = tlag_from_raw(×, &concs, &censoring);
assert_eq!(result, Some(0.5));
}
#[test]
fn test_tlag_from_raw_no_lag() {
let times = vec![0.0, 1.0, 2.0];
let concs = vec![0.0, 10.0, 8.0];
let censoring = vec![crate::Censor::None; 3];
let result = tlag_from_raw(×, &concs, &censoring);
assert_eq!(result, Some(0.0));
}
#[test]
fn test_tlag_from_raw_all_declining() {
let times = vec![0.0, 1.0, 2.0];
let concs = vec![10.0, 5.0, 2.0];
let censoring = vec![crate::Censor::None; 3];
let result = tlag_from_raw(×, &concs, &censoring);
assert!(result.is_none());
}
#[test]
fn test_c0_cascade() {
let subject = Subject::builder("test")
.bolus(0.0, 100.0, 1) .observation(0.0, 50.0, 0) .observation(0.5, 40.0, 0)
.observation(1.0, 30.0, 0)
.observation(4.0, 10.0, 0)
.build();
let occ = &subject.occasions()[0];
let profile = Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap();
let methods = vec![C0Method::Observed, C0Method::LogSlope, C0Method::FirstConc];
let lambda_z = 0.5;
let (c0_val, method) = c0(&profile, &methods, lambda_z);
assert!((c0_val - 50.0).abs() < 1e-10);
assert_eq!(method, Some(C0Method::Observed));
let methods2 = vec![C0Method::LogSlope, C0Method::FirstConc];
let (c0_val2, method2) = c0(&profile, &methods2, lambda_z);
assert!(c0_val2 > 0.0);
assert_eq!(method2, Some(C0Method::LogSlope));
}
#[test]
fn test_effective_half_life_known() {
let mrt = 10.0;
let t_half_eff = effective_half_life(mrt);
assert!((t_half_eff - std::f64::consts::LN_2 * 10.0).abs() < 1e-10);
}
#[test]
fn test_effective_half_life_invalid() {
assert!(effective_half_life(0.0).is_nan());
assert!(effective_half_life(-1.0).is_nan());
assert!(effective_half_life(f64::NAN).is_nan());
}
#[test]
fn test_kel_known() {
let mrt = 5.0;
assert!((kel(mrt) - 0.2).abs() < 1e-10);
}
#[test]
fn test_kel_invalid() {
assert!(kel(0.0).is_nan());
assert!(kel(-1.0).is_nan());
}
#[test]
fn test_peak_trough_ratio() {
assert!((peak_trough_ratio(10.0, 2.0) - 5.0).abs() < 1e-10);
assert!(peak_trough_ratio(10.0, 0.0).is_nan());
}
#[test]
fn test_cavg_known() {
assert!((cavg(100.0, 10.0) - 10.0).abs() < 1e-10);
assert!(cavg(100.0, 0.0).is_nan());
}
}