use serde::{Deserialize, Serialize};
use crate::error::{Result, validate_non_negative, validate_positive};
#[inline]
#[must_use = "returns the reaction rate without side effects"]
pub fn michaelis_menten(substrate: f64, v_max: f64, k_m: f64) -> Result<f64> {
validate_non_negative(substrate, "substrate")?;
validate_positive(v_max, "v_max")?;
validate_positive(k_m, "k_m")?;
Ok(v_max * substrate / (k_m + substrate))
}
#[inline]
#[must_use = "returns the plot parameters without side effects"]
pub fn lineweaver_burk(v: f64, s: f64) -> Result<(f64, f64)> {
validate_positive(v, "v")?;
validate_positive(s, "s")?;
Ok((1.0 / s, 1.0 / v))
}
#[inline]
#[must_use]
pub const fn glycolysis_atp() -> u32 {
2
}
#[inline]
#[must_use]
pub const fn oxidative_phosphorylation_atp() -> u32 {
34
}
#[inline]
#[must_use]
pub const fn total_aerobic_atp() -> u32 {
38
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum InhibitionType {
Competitive,
Uncompetitive,
Noncompetitive,
}
#[inline]
#[must_use = "returns the inhibited rate without side effects"]
pub fn competitive_inhibition(
substrate: f64,
v_max: f64,
k_m: f64,
inhibitor: f64,
k_i: f64,
) -> Result<f64> {
validate_non_negative(substrate, "substrate")?;
validate_positive(v_max, "v_max")?;
validate_positive(k_m, "k_m")?;
validate_non_negative(inhibitor, "inhibitor")?;
validate_positive(k_i, "k_i")?;
let apparent_km = k_m * (1.0 + inhibitor / k_i);
Ok(v_max * substrate / (apparent_km + substrate))
}
#[inline]
#[must_use = "returns the inhibited rate without side effects"]
pub fn uncompetitive_inhibition(
substrate: f64,
v_max: f64,
k_m: f64,
inhibitor: f64,
k_i: f64,
) -> Result<f64> {
validate_non_negative(substrate, "substrate")?;
validate_positive(v_max, "v_max")?;
validate_positive(k_m, "k_m")?;
validate_non_negative(inhibitor, "inhibitor")?;
validate_positive(k_i, "k_i")?;
let alpha_prime = 1.0 + inhibitor / k_i;
Ok(v_max * substrate / (k_m + substrate * alpha_prime))
}
#[inline]
#[must_use = "returns the inhibited rate without side effects"]
pub fn noncompetitive_inhibition(
substrate: f64,
v_max: f64,
k_m: f64,
inhibitor: f64,
k_i: f64,
) -> Result<f64> {
validate_non_negative(substrate, "substrate")?;
validate_positive(v_max, "v_max")?;
validate_positive(k_m, "k_m")?;
validate_non_negative(inhibitor, "inhibitor")?;
validate_positive(k_i, "k_i")?;
let alpha = 1.0 + inhibitor / k_i;
Ok(v_max * substrate / ((k_m + substrate) * alpha))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum FermentationType {
Alcoholic,
Lactic,
}
impl FermentationType {
#[inline]
#[must_use]
pub const fn atp_yield(self) -> u32 {
2
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reaction {
pub id: String,
pub stoichiometry: Vec<(String, f64)>,
pub reversible: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetabolicNetwork {
pub metabolites: Vec<String>,
pub reactions: Vec<Reaction>,
pub s_matrix: Vec<Vec<f64>>,
}
impl MetabolicNetwork {
#[must_use]
pub fn from_reactions(reactions: Vec<Reaction>) -> Self {
let mut metabolites: Vec<String> = Vec::new();
let mut met_index = std::collections::HashMap::new();
for rxn in &reactions {
for (met, _) in &rxn.stoichiometry {
if !met_index.contains_key(met) {
met_index.insert(met.clone(), metabolites.len());
metabolites.push(met.clone());
}
}
}
let n_met = metabolites.len();
let n_rxn = reactions.len();
let mut s_matrix = vec![vec![0.0; n_rxn]; n_met];
for (j, rxn) in reactions.iter().enumerate() {
for (met, coeff) in &rxn.stoichiometry {
if let Some(&i) = met_index.get(met) {
s_matrix[i][j] += *coeff;
}
}
}
Self {
metabolites,
reactions,
s_matrix,
}
}
#[inline]
#[must_use]
pub fn n_metabolites(&self) -> usize {
self.metabolites.len()
}
#[inline]
#[must_use]
pub fn n_reactions(&self) -> usize {
self.reactions.len()
}
#[must_use = "returns the net production rates without side effects"]
pub fn net_production(&self, fluxes: &[f64]) -> Result<Vec<f64>> {
if fluxes.len() != self.n_reactions() {
return Err(crate::error::JivanuError::ComputationError(format!(
"flux vector length {} != {} reactions",
fluxes.len(),
self.n_reactions()
)));
}
let mut result = vec![0.0; self.n_metabolites()];
for (i, row) in self.s_matrix.iter().enumerate() {
for (j, &coeff) in row.iter().enumerate() {
result[i] += coeff * fluxes[j];
}
}
Ok(result)
}
#[must_use = "returns whether steady state holds without side effects"]
pub fn is_steady_state(&self, fluxes: &[f64], tolerance: f64) -> Result<bool> {
let sv = self.net_production(fluxes)?;
Ok(sv.iter().all(|&v| v.abs() < tolerance))
}
#[must_use = "returns the net ATP yield without side effects"]
pub fn net_atp(&self, fluxes: &[f64], atp_metabolite: &str) -> Result<f64> {
if fluxes.len() != self.n_reactions() {
return Err(crate::error::JivanuError::ComputationError(format!(
"flux vector length {} != {} reactions",
fluxes.len(),
self.n_reactions()
)));
}
let atp_row = self.metabolites.iter().position(|m| m == atp_metabolite);
match atp_row {
Some(i) => {
let mut total = 0.0;
for (j, &coeff) in self.s_matrix[i].iter().enumerate() {
total += coeff * fluxes[j];
}
Ok(total)
}
None => Ok(0.0), }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FbaResult {
pub fluxes: Vec<f64>,
pub objective_value: f64,
pub feasible: bool,
}
#[must_use = "returns the FBA result without side effects"]
pub fn flux_balance_analysis(
network: &MetabolicNetwork,
objective: &[f64],
lower_bounds: &[f64],
upper_bounds: &[f64],
) -> Result<FbaResult> {
let n_rxn = network.n_reactions();
if objective.len() != n_rxn || lower_bounds.len() != n_rxn || upper_bounds.len() != n_rxn {
return Err(crate::error::JivanuError::ComputationError(
"objective, lower_bounds, upper_bounds must match reaction count".into(),
));
}
let mut v: Vec<f64> = (0..n_rxn)
.map(|i| {
let mid = (lower_bounds[i] + upper_bounds[i]) / 2.0;
mid.clamp(lower_bounds[i], upper_bounds[i])
})
.collect();
let max_iter = 1000;
let step_size = 0.01;
for _ in 0..max_iter {
for j in 0..n_rxn {
v[j] += step_size * objective[j];
}
for _ in 0..10 {
let sv = network.net_production(&v)?;
let residual_norm: f64 = sv.iter().map(|x| x * x).sum();
if residual_norm < 1e-20 {
break;
}
for (i, &sv_i) in sv.iter().enumerate() {
for (j, v_j) in v.iter_mut().enumerate() {
*v_j -= 0.1 * network.s_matrix[i][j] * sv_i;
}
}
}
for j in 0..n_rxn {
v[j] = v[j].clamp(lower_bounds[j], upper_bounds[j]);
}
}
let sv = network.net_production(&v)?;
let feasible = sv.iter().all(|&x| x.abs() < 0.01);
let objective_value: f64 = v.iter().zip(objective).map(|(vi, ci)| vi * ci).sum();
Ok(FbaResult {
fluxes: v,
objective_value,
feasible,
})
}
#[must_use = "returns the flux ranges without side effects"]
pub fn flux_variability_analysis(
network: &MetabolicNetwork,
objective: &[f64],
lower_bounds: &[f64],
upper_bounds: &[f64],
fraction: f64,
) -> Result<Vec<(f64, f64)>> {
let fba = flux_balance_analysis(network, objective, lower_bounds, upper_bounds)?;
let n_rxn = network.n_reactions();
let opt_val = fba.objective_value;
let mut ranges = Vec::with_capacity(n_rxn);
for j in 0..n_rxn {
let min_v = lower_bounds[j];
let max_v = upper_bounds[j];
let fba_v = fba.fluxes[j];
ranges.push((
min_v.max(fba_v - opt_val.abs()),
max_v.min(fba_v + opt_val.abs()),
));
}
let _ = fraction; Ok(ranges)
}
#[inline]
#[must_use = "returns the response without side effects"]
pub fn hill_equation(substrate: f64, v_max: f64, k: f64, n: f64) -> Result<f64> {
validate_non_negative(substrate, "substrate")?;
validate_positive(v_max, "v_max")?;
validate_positive(k, "k")?;
validate_positive(n, "n")?;
let s_n = substrate.powf(n);
let k_n = k.powf(n);
Ok(v_max * s_n / (k_n + s_n))
}
#[inline]
#[must_use = "returns the drug effect without side effects"]
pub fn emax_model(concentration: f64, e_max: f64, ec50: f64, n: f64) -> Result<f64> {
validate_non_negative(concentration, "concentration")?;
validate_positive(e_max, "e_max")?;
validate_positive(ec50, "ec50")?;
validate_positive(n, "n")?;
let c_n = concentration.powf(n);
let ec_n = ec50.powf(n);
Ok(e_max * c_n / (ec_n + c_n))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_michaelis_menten_at_km() {
let v = michaelis_menten(1.0, 10.0, 1.0).unwrap();
assert!((v - 5.0).abs() < 1e-10);
}
#[test]
fn test_michaelis_menten_high_substrate() {
let v = michaelis_menten(1000.0, 10.0, 1.0).unwrap();
assert!((v - 10.0).abs() < 0.1);
}
#[test]
fn test_michaelis_menten_zero_substrate() {
let v = michaelis_menten(0.0, 10.0, 1.0).unwrap();
assert!((v - 0.0).abs() < 1e-10);
}
#[test]
fn test_lineweaver_burk() {
let (inv_s, inv_v) = lineweaver_burk(5.0, 2.0).unwrap();
assert!((inv_s - 0.5).abs() < 1e-10);
assert!((inv_v - 0.2).abs() < 1e-10);
}
#[test]
fn test_glycolysis_atp() {
assert_eq!(glycolysis_atp(), 2);
}
#[test]
fn test_oxidative_phosphorylation_atp() {
assert_eq!(oxidative_phosphorylation_atp(), 34);
}
#[test]
fn test_total_aerobic_atp() {
assert_eq!(total_aerobic_atp(), 38);
}
#[test]
fn test_competitive_inhibition() {
let v_no_inhib = michaelis_menten(1.0, 10.0, 1.0).unwrap();
let v_inhib = competitive_inhibition(1.0, 10.0, 1.0, 1.0, 1.0).unwrap();
assert!(v_inhib < v_no_inhib);
}
#[test]
fn test_competitive_inhibition_zero_inhibitor() {
let v = competitive_inhibition(1.0, 10.0, 1.0, 0.0, 1.0).unwrap();
let v_mm = michaelis_menten(1.0, 10.0, 1.0).unwrap();
assert!((v - v_mm).abs() < 1e-10);
}
#[test]
fn test_uncompetitive_inhibition_reduces_rate() {
let v_no = michaelis_menten(1.0, 10.0, 1.0).unwrap();
let v_inh = uncompetitive_inhibition(1.0, 10.0, 1.0, 1.0, 1.0).unwrap();
assert!(v_inh < v_no);
}
#[test]
fn test_uncompetitive_inhibition_zero_inhibitor() {
let v = uncompetitive_inhibition(1.0, 10.0, 1.0, 0.0, 1.0).unwrap();
let v_mm = michaelis_menten(1.0, 10.0, 1.0).unwrap();
assert!((v - v_mm).abs() < 1e-10);
}
#[test]
fn test_noncompetitive_inhibition_reduces_rate() {
let v_no = michaelis_menten(1.0, 10.0, 1.0).unwrap();
let v_inh = noncompetitive_inhibition(1.0, 10.0, 1.0, 1.0, 1.0).unwrap();
assert!(v_inh < v_no);
}
#[test]
fn test_noncompetitive_inhibition_zero_inhibitor() {
let v = noncompetitive_inhibition(1.0, 10.0, 1.0, 0.0, 1.0).unwrap();
let v_mm = michaelis_menten(1.0, 10.0, 1.0).unwrap();
assert!((v - v_mm).abs() < 1e-10);
}
#[test]
fn test_noncompetitive_does_not_change_km() {
let v = noncompetitive_inhibition(1.0, 10.0, 1.0, 1.0, 1.0).unwrap();
assert!((v - 2.5).abs() < 1e-10);
}
#[test]
fn test_inhibition_types_differ() {
let v_comp = competitive_inhibition(2.0, 10.0, 1.0, 1.0, 1.0).unwrap();
let v_uncomp = uncompetitive_inhibition(2.0, 10.0, 1.0, 1.0, 1.0).unwrap();
let v_noncomp = noncompetitive_inhibition(2.0, 10.0, 1.0, 1.0, 1.0).unwrap();
let v_mm = michaelis_menten(2.0, 10.0, 1.0).unwrap();
assert!(v_comp < v_mm);
assert!(v_uncomp < v_mm);
assert!(v_noncomp < v_mm);
assert!((v_comp - v_uncomp).abs() > 0.01);
assert!((v_comp - v_noncomp).abs() > 0.01);
}
#[test]
fn test_fermentation_atp() {
assert_eq!(FermentationType::Alcoholic.atp_yield(), 2);
assert_eq!(FermentationType::Lactic.atp_yield(), 2);
}
#[test]
fn test_inhibition_type_serde_roundtrip() {
let it = InhibitionType::Competitive;
let json = serde_json::to_string(&it).unwrap();
let back: InhibitionType = serde_json::from_str(&json).unwrap();
assert_eq!(it, back);
}
#[test]
fn test_fermentation_serde_roundtrip() {
let ft = FermentationType::Alcoholic;
let json = serde_json::to_string(&ft).unwrap();
let back: FermentationType = serde_json::from_str(&json).unwrap();
assert_eq!(ft, back);
}
fn toy_network() -> MetabolicNetwork {
MetabolicNetwork::from_reactions(vec![
Reaction {
id: "glycolysis".into(),
stoichiometry: vec![
("Glucose".into(), -1.0),
("Pyruvate".into(), 2.0),
("ATP".into(), 2.0),
],
reversible: false,
},
Reaction {
id: "pyruvate_decarboxylation".into(),
stoichiometry: vec![
("Pyruvate".into(), -1.0),
("AcetylCoA".into(), 1.0),
("CO2".into(), 1.0),
],
reversible: false,
},
])
}
#[test]
fn test_network_dimensions() {
let net = toy_network();
assert_eq!(net.n_metabolites(), 5); assert_eq!(net.n_reactions(), 2);
assert_eq!(net.s_matrix.len(), 5);
assert_eq!(net.s_matrix[0].len(), 2);
}
#[test]
fn test_stoichiometric_matrix_values() {
let net = toy_network();
let glucose_idx = net.metabolites.iter().position(|m| m == "Glucose").unwrap();
let pyruvate_idx = net
.metabolites
.iter()
.position(|m| m == "Pyruvate")
.unwrap();
let atp_idx = net.metabolites.iter().position(|m| m == "ATP").unwrap();
assert!((net.s_matrix[glucose_idx][0] - (-1.0)).abs() < 1e-10);
assert!((net.s_matrix[pyruvate_idx][0] - 2.0).abs() < 1e-10);
assert!((net.s_matrix[atp_idx][0] - 2.0).abs() < 1e-10);
assert!((net.s_matrix[pyruvate_idx][1] - (-1.0)).abs() < 1e-10);
}
#[test]
fn test_net_production() {
let net = toy_network();
let sv = net.net_production(&[1.0, 2.0]).unwrap();
let glucose_idx = net.metabolites.iter().position(|m| m == "Glucose").unwrap();
let pyruvate_idx = net
.metabolites
.iter()
.position(|m| m == "Pyruvate")
.unwrap();
let atp_idx = net.metabolites.iter().position(|m| m == "ATP").unwrap();
assert!((sv[glucose_idx] - (-1.0)).abs() < 1e-10);
assert!((sv[pyruvate_idx] - 0.0).abs() < 1e-10);
assert!((sv[atp_idx] - 2.0).abs() < 1e-10);
}
#[test]
fn test_steady_state_balanced() {
let net = toy_network();
assert!(!net.is_steady_state(&[1.0, 2.0], 1e-10).unwrap());
}
#[test]
fn test_steady_state_zero_flux() {
let net = toy_network();
assert!(net.is_steady_state(&[0.0, 0.0], 1e-10).unwrap());
}
#[test]
fn test_net_atp() {
let net = toy_network();
let atp = net.net_atp(&[1.0, 2.0], "ATP").unwrap();
assert!((atp - 2.0).abs() < 1e-10);
}
#[test]
fn test_net_atp_missing_metabolite() {
let net = toy_network();
let atp = net.net_atp(&[1.0, 2.0], "NADH").unwrap();
assert!((atp - 0.0).abs() < 1e-10);
}
#[test]
fn test_net_production_wrong_length() {
let net = toy_network();
assert!(net.net_production(&[1.0]).is_err());
}
#[test]
fn test_network_serde_roundtrip() {
let net = toy_network();
let json = serde_json::to_string(&net).unwrap();
let back: MetabolicNetwork = serde_json::from_str(&json).unwrap();
assert_eq!(back.n_metabolites(), net.n_metabolites());
assert_eq!(back.n_reactions(), net.n_reactions());
assert!((back.s_matrix[0][0] - net.s_matrix[0][0]).abs() < 1e-10);
}
#[test]
fn test_hill_n1_equals_michaelis_menten() {
let hill = hill_equation(1.0, 10.0, 1.0, 1.0).unwrap();
let mm = michaelis_menten(1.0, 10.0, 1.0).unwrap();
assert!((hill - mm).abs() < 1e-10);
}
#[test]
fn test_hill_at_k() {
let v = hill_equation(5.0, 10.0, 5.0, 3.0).unwrap();
assert!((v - 5.0).abs() < 1e-10);
}
#[test]
fn test_hill_cooperativity() {
let v_n1 = hill_equation(0.5, 10.0, 1.0, 1.0).unwrap();
let v_n4 = hill_equation(0.5, 10.0, 1.0, 4.0).unwrap();
assert!(v_n4 < v_n1, "higher n should give lower response below K");
}
#[test]
fn test_emax_at_ec50() {
let e = emax_model(5.0, 100.0, 5.0, 2.0).unwrap();
assert!((e - 50.0).abs() < 1e-10);
}
#[test]
fn test_emax_high_concentration() {
let e = emax_model(1e6, 100.0, 5.0, 2.0).unwrap();
assert!((e - 100.0).abs() < 0.01);
}
#[test]
fn test_emax_zero_concentration() {
let e = emax_model(0.0, 100.0, 5.0, 2.0).unwrap();
assert!((e - 0.0).abs() < 1e-10);
}
#[test]
fn test_duplicate_metabolite_in_reaction() {
let net = MetabolicNetwork::from_reactions(vec![Reaction {
id: "test".into(),
stoichiometry: vec![("ATP".into(), 2.0), ("ATP".into(), 1.0)],
reversible: false,
}]);
let atp_idx = net.metabolites.iter().position(|m| m == "ATP").unwrap();
assert!((net.s_matrix[atp_idx][0] - 3.0).abs() < 1e-10);
}
#[test]
fn test_reaction_serde_roundtrip() {
let rxn = Reaction {
id: "test".into(),
stoichiometry: vec![("A".into(), -1.0), ("B".into(), 1.0)],
reversible: true,
};
let json = serde_json::to_string(&rxn).unwrap();
let back: Reaction = serde_json::from_str(&json).unwrap();
assert_eq!(rxn.id, back.id);
assert_eq!(rxn.reversible, back.reversible);
}
#[test]
fn test_fba_simple_linear() {
let net = MetabolicNetwork::from_reactions(vec![
Reaction {
id: "r1".into(),
stoichiometry: vec![("A".into(), -1.0), ("B".into(), 1.0)],
reversible: false,
},
Reaction {
id: "r2".into(),
stoichiometry: vec![("B".into(), -1.0), ("C".into(), 1.0)],
reversible: false,
},
]);
let result = flux_balance_analysis(
&net,
&[0.0, 1.0], &[0.0, 0.0], &[10.0, 10.0], )
.unwrap();
assert!(result.objective_value > 0.0);
}
#[test]
fn test_fba_dimension_mismatch() {
let net = MetabolicNetwork::from_reactions(vec![Reaction {
id: "r1".into(),
stoichiometry: vec![("A".into(), -1.0)],
reversible: false,
}]);
assert!(flux_balance_analysis(&net, &[1.0, 2.0], &[0.0], &[10.0]).is_err());
}
#[test]
fn test_fba_result_serde_roundtrip() {
let result = FbaResult {
fluxes: vec![1.0, 2.0],
objective_value: 5.0,
feasible: true,
};
let json = serde_json::to_string(&result).unwrap();
let back: FbaResult = serde_json::from_str(&json).unwrap();
assert!((result.objective_value - back.objective_value).abs() < 1e-10);
assert_eq!(result.feasible, back.feasible);
}
#[test]
fn test_fva_returns_ranges() {
let net = MetabolicNetwork::from_reactions(vec![
Reaction {
id: "r1".into(),
stoichiometry: vec![("A".into(), -1.0), ("B".into(), 1.0)],
reversible: false,
},
Reaction {
id: "r2".into(),
stoichiometry: vec![("B".into(), -1.0), ("C".into(), 1.0)],
reversible: false,
},
]);
let ranges =
flux_variability_analysis(&net, &[0.0, 1.0], &[0.0, 0.0], &[10.0, 10.0], 1.0).unwrap();
assert_eq!(ranges.len(), 2);
for (lo, hi) in &ranges {
assert!(lo <= hi);
}
}
}