use super::observation::ObservationProfile;
use crate::data::event::BLQRule;
use crate::nca::error::NCAError;
use crate::nca::traits::NCA;
use crate::nca::types::{NCAOptions, NCAResult};
use crate::{Occasion, Subject};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuperpositionResult {
pub times: Vec<f64>,
pub concentrations: Vec<f64>,
pub cmax_ss: f64,
pub tmax_ss: f64,
pub cmin_ss: f64,
pub auc_tau_ss: f64,
pub cavg_ss: f64,
pub n_doses: usize,
pub accumulation_ratio: f64,
}
pub(crate) fn predict(
profile: &ObservationProfile,
lambda_z: f64,
tau: f64,
n_eval_points: Option<usize>,
) -> Option<SuperpositionResult> {
if lambda_z <= 0.0 || !lambda_z.is_finite() || tau <= 0.0 || profile.is_empty() {
return None;
}
let clast = profile.clast();
let tlast = profile.tlast();
let eval_times: Vec<f64> = match n_eval_points {
Some(n) if n >= 2 => (0..n).map(|i| i as f64 * tau / (n - 1) as f64).collect(),
_ => {
let mut times: Vec<f64> = profile
.times
.iter()
.copied()
.filter(|&t| t >= 0.0 && t <= tau)
.collect();
if times.is_empty() || (times.last().unwrap() - tau).abs() > 1e-10 {
times.push(tau);
}
if times[0] > 0.0 {
times.insert(0, 0.0);
}
times
}
};
let tolerance = 1e-10;
let max_doses = 1000;
let mut ss_concentrations = vec![0.0_f64; eval_times.len()];
let mut n_doses = 0;
for dose_k in 0..max_doses {
let mut max_contribution = 0.0_f64;
for (i, &t) in eval_times.iter().enumerate() {
let t_since_dose = t + dose_k as f64 * tau;
let conc = concentration_at_time(profile, clast, tlast, lambda_z, t_since_dose);
ss_concentrations[i] += conc;
max_contribution = max_contribution.max(conc);
}
n_doses = dose_k + 1;
if dose_k > 0
&& max_contribution
< tolerance * ss_concentrations.iter().cloned().fold(0.0_f64, f64::max)
{
break;
}
}
let (cmax_idx, cmax_ss) = ss_concentrations
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, &v)| (i, v))
.unwrap_or((0, 0.0));
let tmax_ss = eval_times[cmax_idx];
let cmin_ss = ss_concentrations
.iter()
.copied()
.filter(|&c| c > 0.0)
.min_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap_or(0.0);
let auc_tau_ss = trapezoidal_auc(&eval_times, &ss_concentrations);
let cavg_ss = if tau > 0.0 { auc_tau_ss / tau } else { 0.0 };
let single_dose_auc_tau =
trapezoidal_auc_from_profile(profile, clast, tlast, lambda_z, tau, &eval_times);
let accumulation_ratio = if single_dose_auc_tau > 0.0 {
auc_tau_ss / single_dose_auc_tau
} else {
f64::NAN
};
Some(SuperpositionResult {
times: eval_times,
concentrations: ss_concentrations,
cmax_ss,
tmax_ss,
cmin_ss,
auc_tau_ss,
cavg_ss,
n_doses,
accumulation_ratio,
})
}
fn concentration_at_time(
profile: &ObservationProfile,
clast: f64,
tlast: f64,
lambda_z: f64,
time: f64,
) -> f64 {
if time < 0.0 {
return 0.0;
}
if time <= tlast {
profile.interpolate(time).unwrap_or(0.0)
} else {
clast * (-lambda_z * (time - tlast)).exp()
}
}
fn trapezoidal_auc(times: &[f64], concentrations: &[f64]) -> f64 {
times
.windows(2)
.zip(concentrations.windows(2))
.map(|(t, c)| (c[0] + c[1]) / 2.0 * (t[1] - t[0]))
.sum()
}
fn trapezoidal_auc_from_profile(
profile: &ObservationProfile,
clast: f64,
tlast: f64,
lambda_z: f64,
tau: f64,
eval_times: &[f64],
) -> f64 {
let concs: Vec<f64> = eval_times
.iter()
.map(|&t| concentration_at_time(profile, clast, tlast, lambda_z, t.min(tau)))
.collect();
trapezoidal_auc(eval_times, &concs)
}
pub(crate) fn predict_from_nca(
profile: &ObservationProfile,
nca_result: &NCAResult,
tau: f64,
n_eval_points: Option<usize>,
) -> Result<SuperpositionResult, NCAError> {
let lambda_z = nca_result
.terminal
.as_ref()
.map(|t| t.lambda_z)
.ok_or_else(|| NCAError::LambdaZFailed {
reason: "λz not estimable; cannot perform superposition".to_string(),
})?;
predict(profile, lambda_z, tau, n_eval_points).ok_or_else(|| NCAError::InvalidParameter {
param: "superposition".to_string(),
value: "prediction returned None (check lambda_z and tau)".to_string(),
})
}
pub trait Superposition {
fn superposition(
&self,
tau: f64,
options: &NCAOptions,
n_eval_points: Option<usize>,
) -> Result<SuperpositionResult, NCAError>;
fn superposition_from_nca(
&self,
nca_result: &NCAResult,
tau: f64,
n_eval_points: Option<usize>,
) -> Result<SuperpositionResult, NCAError>;
}
impl Superposition for Subject {
fn superposition(
&self,
tau: f64,
options: &NCAOptions,
n_eval_points: Option<usize>,
) -> Result<SuperpositionResult, NCAError> {
let nca_result = self.nca(options)?;
self.superposition_from_nca(&nca_result, tau, n_eval_points)
}
fn superposition_from_nca(
&self,
nca_result: &NCAResult,
tau: f64,
n_eval_points: Option<usize>,
) -> Result<SuperpositionResult, NCAError> {
let occ = self
.occasions()
.first()
.ok_or_else(|| NCAError::InvalidParameter {
param: "occasion".to_string(),
value: "no occasions found".to_string(),
})?;
occ.superposition_from_nca(nca_result, tau, n_eval_points)
}
}
impl Superposition for Occasion {
fn superposition(
&self,
tau: f64,
options: &NCAOptions,
n_eval_points: Option<usize>,
) -> Result<SuperpositionResult, NCAError> {
use crate::nca::traits::NCA;
let nca_result = self.nca(options)?;
self.superposition_from_nca(&nca_result, tau, n_eval_points)
}
fn superposition_from_nca(
&self,
nca_result: &NCAResult,
tau: f64,
n_eval_points: Option<usize>,
) -> Result<SuperpositionResult, NCAError> {
let profile = ObservationProfile::from_occasion(self, 0, &BLQRule::Exclude)?;
predict_from_nca(&profile, nca_result, tau, n_eval_points)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::builder::SubjectBuilderExt;
use crate::data::event::BLQRule;
use crate::Subject;
#[test]
fn test_superposition_basic() {
let subject = Subject::builder("test")
.bolus(0.0, 100.0, 0)
.observation(0.0, 10.0, 0)
.observation(1.0, 9.048, 0) .observation(2.0, 8.187, 0) .observation(4.0, 6.703, 0) .observation(8.0, 4.493, 0) .observation(12.0, 3.012, 0) .observation(24.0, 0.907, 0) .build();
let occ = &subject.occasions()[0];
let profile = ObservationProfile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap();
let lambda_z = 0.1;
let tau = 12.0;
let result = predict(&profile, lambda_z, tau, Some(25)).unwrap();
assert!(
result.cmax_ss > 10.0,
"SS Cmax should be > single dose Cmax due to accumulation"
);
assert!(result.cmin_ss > 0.0, "SS Cmin should be positive");
assert!(
result.accumulation_ratio > 1.0,
"Accumulation ratio should be > 1"
);
assert!(
result.n_doses > 1,
"Should require multiple doses to converge"
);
}
#[test]
fn test_superposition_invalid_inputs() {
let subject = Subject::builder("test")
.bolus(0.0, 100.0, 0)
.observation(0.0, 10.0, 0)
.observation(1.0, 5.0, 0)
.build();
let occ = &subject.occasions()[0];
let profile = ObservationProfile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap();
assert!(predict(&profile, -0.1, 12.0, None).is_none());
assert!(predict(&profile, 0.1, 0.0, None).is_none());
assert!(predict(&profile, 0.0, 12.0, None).is_none());
}
#[test]
fn test_superposition_theoretical_accumulation() {
let lambda_z: f64 = 0.1;
let tau: f64 = 8.0;
let theoretical_af = 1.0 / (1.0 - (-lambda_z * tau).exp());
let subject = Subject::builder("test")
.bolus(0.0, 100.0, 0)
.observation(0.0, 10.0, 0)
.observation(1.0, 9.048, 0)
.observation(2.0, 8.187, 0)
.observation(4.0, 6.703, 0)
.observation(8.0, 4.493, 0)
.observation(12.0, 3.012, 0)
.observation(24.0, 0.907, 0)
.build();
let occ = &subject.occasions()[0];
let profile = ObservationProfile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap();
let result = predict(&profile, lambda_z, tau, Some(50)).unwrap();
let tol = 0.05; assert!(
(result.accumulation_ratio - theoretical_af).abs() / theoretical_af < tol,
"Accumulation ratio {:.3} should be close to theoretical {:.3}",
result.accumulation_ratio,
theoretical_af
);
}
}