use serde::{Deserialize, Serialize};
use crate::error::{Result, validate_non_negative, validate_positive};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AntibioticClass {
BetaLactam,
Aminoglycoside,
Fluoroquinolone,
Macrolide,
Tetracycline,
Glycopeptide,
}
#[inline]
#[must_use = "returns the survival fraction without side effects"]
pub fn kill_curve(concentration: f64, mic: f64, kill_rate: f64) -> Result<f64> {
validate_non_negative(concentration, "concentration")?;
validate_positive(mic, "mic")?;
validate_positive(kill_rate, "kill_rate")?;
if concentration <= mic {
return Ok(1.0); }
let excess = concentration / mic - 1.0;
Ok((-kill_rate * excess).exp())
}
#[inline]
#[must_use = "returns the transfer rate without side effects"]
pub fn resistance_transfer_rate(
donor_freq: f64,
contact_rate: f64,
transfer_efficiency: f64,
) -> Result<f64> {
validate_non_negative(donor_freq, "donor_freq")?;
validate_non_negative(contact_rate, "contact_rate")?;
validate_non_negative(transfer_efficiency, "transfer_efficiency")?;
Ok(donor_freq * contact_rate * transfer_efficiency)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum DrugInteraction {
Synergy,
Additive,
Indifferent,
Antagonism,
}
#[inline]
#[must_use = "returns the FIC index without side effects"]
pub fn fic_index(
mic_a_combo: f64,
mic_a_alone: f64,
mic_b_combo: f64,
mic_b_alone: f64,
) -> Result<f64> {
validate_positive(mic_a_combo, "mic_a_combo")?;
validate_positive(mic_a_alone, "mic_a_alone")?;
validate_positive(mic_b_combo, "mic_b_combo")?;
validate_positive(mic_b_alone, "mic_b_alone")?;
Ok(mic_a_combo / mic_a_alone + mic_b_combo / mic_b_alone)
}
#[inline]
#[must_use = "returns the interaction classification without side effects"]
pub fn classify_interaction(fic: f64) -> Result<DrugInteraction> {
validate_positive(fic, "fic")?;
if fic <= 0.5 {
Ok(DrugInteraction::Synergy)
} else if fic <= 1.0 {
Ok(DrugInteraction::Additive)
} else if fic <= 4.0 {
Ok(DrugInteraction::Indifferent)
} else {
Ok(DrugInteraction::Antagonism)
}
}
#[must_use = "returns the FIC index and interaction without side effects"]
pub fn fic_interaction(
mic_a_combo: f64,
mic_a_alone: f64,
mic_b_combo: f64,
mic_b_alone: f64,
) -> Result<(f64, DrugInteraction)> {
let fic = fic_index(mic_a_combo, mic_a_alone, mic_b_combo, mic_b_alone)?;
let interaction = classify_interaction(fic)?;
Ok((fic, interaction))
}
#[inline]
#[must_use = "returns the combination survival fraction without side effects"]
pub fn combination_kill_curve(
conc_a: f64,
mic_a: f64,
kill_rate_a: f64,
conc_b: f64,
mic_b: f64,
kill_rate_b: f64,
) -> Result<f64> {
let surv_a = kill_curve(conc_a, mic_a, kill_rate_a)?;
let surv_b = kill_curve(conc_b, mic_b, kill_rate_b)?;
Ok(surv_a * surv_b)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckerboardResult {
pub fic_grid: Vec<Vec<f64>>,
pub min_fic: f64,
pub interaction: DrugInteraction,
}
#[must_use = "returns the checkerboard result without side effects"]
pub fn checkerboard(
conc_a: &[f64],
conc_b: &[f64],
mic_a: f64,
mic_b: f64,
) -> Result<CheckerboardResult> {
validate_positive(mic_a, "mic_a")?;
validate_positive(mic_b, "mic_b")?;
if conc_a.is_empty() || conc_b.is_empty() {
return Err(crate::error::JivanuError::ComputationError(
"concentration arrays must not be empty".into(),
));
}
let mut min_fic = f64::MAX;
let mut fic_grid = Vec::with_capacity(conc_a.len());
for &ca in conc_a {
validate_non_negative(ca, "conc_a element")?;
let mut row = Vec::with_capacity(conc_b.len());
for &cb in conc_b {
validate_non_negative(cb, "conc_b element")?;
let fic = ca / mic_a + cb / mic_b;
if fic < min_fic {
min_fic = fic;
}
row.push(fic);
}
fic_grid.push(row);
}
let interaction = if min_fic > 0.0 {
classify_interaction(min_fic)?
} else {
DrugInteraction::Synergy
};
Ok(CheckerboardResult {
fic_grid,
min_fic,
interaction,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kill_curve_below_mic() {
let survival = kill_curve(0.5, 1.0, 2.0).unwrap();
assert!((survival - 1.0).abs() < 1e-10);
}
#[test]
fn test_kill_curve_at_mic() {
let survival = kill_curve(1.0, 1.0, 2.0).unwrap();
assert!((survival - 1.0).abs() < 1e-10);
}
#[test]
fn test_kill_curve_above_mic() {
let survival = kill_curve(2.0, 1.0, 2.0).unwrap();
assert!(survival < 1.0);
assert!(survival > 0.0);
}
#[test]
fn test_kill_curve_high_concentration() {
let survival = kill_curve(10.0, 1.0, 1.0).unwrap();
assert!(survival < 0.001);
}
#[test]
fn test_resistance_transfer_rate() {
let rate = resistance_transfer_rate(0.1, 0.5, 0.01).unwrap();
assert!((rate - 0.0005).abs() < 1e-10);
}
#[test]
fn test_antibiotic_class_serde_roundtrip() {
let cls = AntibioticClass::BetaLactam;
let json = serde_json::to_string(&cls).unwrap();
let back: AntibioticClass = serde_json::from_str(&json).unwrap();
assert_eq!(cls, back);
}
#[test]
fn test_fic_index_synergy() {
let fic = fic_index(0.25, 1.0, 0.25, 1.0).unwrap();
assert!((fic - 0.5).abs() < 1e-10);
}
#[test]
fn test_fic_index_additive() {
let fic = fic_index(0.5, 1.0, 0.5, 1.0).unwrap();
assert!((fic - 1.0).abs() < 1e-10);
}
#[test]
fn test_fic_index_antagonism() {
let fic = fic_index(4.0, 1.0, 2.0, 1.0).unwrap();
assert!((fic - 6.0).abs() < 1e-10);
}
#[test]
fn test_fic_index_invalid() {
assert!(fic_index(0.0, 1.0, 0.5, 1.0).is_err());
assert!(fic_index(0.5, 0.0, 0.5, 1.0).is_err());
}
#[test]
fn test_classify_interaction_boundaries() {
assert_eq!(
classify_interaction(0.25).unwrap(),
DrugInteraction::Synergy
);
assert_eq!(classify_interaction(0.5).unwrap(), DrugInteraction::Synergy);
assert_eq!(
classify_interaction(0.75).unwrap(),
DrugInteraction::Additive
);
assert_eq!(
classify_interaction(1.0).unwrap(),
DrugInteraction::Additive
);
assert_eq!(
classify_interaction(2.0).unwrap(),
DrugInteraction::Indifferent
);
assert_eq!(
classify_interaction(4.0).unwrap(),
DrugInteraction::Indifferent
);
assert_eq!(
classify_interaction(4.1).unwrap(),
DrugInteraction::Antagonism
);
}
#[test]
fn test_fic_interaction_combined() {
let (fic, interaction) = fic_interaction(0.125, 1.0, 0.125, 1.0).unwrap();
assert!((fic - 0.25).abs() < 1e-10);
assert_eq!(interaction, DrugInteraction::Synergy);
}
#[test]
fn test_drug_interaction_serde_roundtrip() {
let di = DrugInteraction::Synergy;
let json = serde_json::to_string(&di).unwrap();
let back: DrugInteraction = serde_json::from_str(&json).unwrap();
assert_eq!(di, back);
}
#[test]
fn test_combination_kill_curve_both_below_mic() {
let surv = combination_kill_curve(0.5, 1.0, 2.0, 0.5, 1.0, 2.0).unwrap();
assert!((surv - 1.0).abs() < 1e-10);
}
#[test]
fn test_combination_kill_curve_one_above() {
let surv_combo = combination_kill_curve(2.0, 1.0, 2.0, 0.5, 1.0, 2.0).unwrap();
let surv_a = kill_curve(2.0, 1.0, 2.0).unwrap();
assert!((surv_combo - surv_a).abs() < 1e-10);
}
#[test]
fn test_combination_kill_curve_both_above() {
let surv_a = kill_curve(2.0, 1.0, 1.0).unwrap();
let surv_b = kill_curve(2.0, 1.0, 1.0).unwrap();
let surv_combo = combination_kill_curve(2.0, 1.0, 1.0, 2.0, 1.0, 1.0).unwrap();
assert!(surv_combo < surv_a);
assert!((surv_combo - surv_a * surv_b).abs() < 1e-10);
}
#[test]
fn test_checkerboard_basic() {
let conc_a = [0.0, 0.25, 0.5, 1.0];
let conc_b = [0.0, 0.25, 0.5, 1.0];
let result = checkerboard(&conc_a, &conc_b, 1.0, 1.0).unwrap();
assert_eq!(result.fic_grid.len(), 4);
assert_eq!(result.fic_grid[0].len(), 4);
assert!((result.fic_grid[0][0] - 0.0).abs() < 1e-10);
assert!((result.fic_grid[3][3] - 2.0).abs() < 1e-10);
assert!((result.fic_grid[1][1] - 0.5).abs() < 1e-10);
}
#[test]
fn test_checkerboard_min_fic() {
let conc_a = [0.125, 0.25, 0.5];
let conc_b = [0.125, 0.25, 0.5];
let result = checkerboard(&conc_a, &conc_b, 1.0, 1.0).unwrap();
assert!((result.min_fic - 0.25).abs() < 1e-10);
assert_eq!(result.interaction, DrugInteraction::Synergy);
}
#[test]
fn test_checkerboard_empty_error() {
assert!(checkerboard(&[], &[0.5], 1.0, 1.0).is_err());
assert!(checkerboard(&[0.5], &[], 1.0, 1.0).is_err());
}
#[test]
fn test_checkerboard_invalid_mic() {
assert!(checkerboard(&[0.5], &[0.5], 0.0, 1.0).is_err());
}
#[test]
fn test_checkerboard_serde_roundtrip() {
let result = checkerboard(&[0.25, 0.5], &[0.25, 0.5], 1.0, 1.0).unwrap();
let json = serde_json::to_string(&result).unwrap();
let back: CheckerboardResult = serde_json::from_str(&json).unwrap();
assert!((result.min_fic - back.min_fic).abs() < 1e-10);
assert_eq!(result.interaction, back.interaction);
}
}