use serde::{Deserialize, Serialize};
use crate::enzyme;
use crate::error::RasayanError;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BetaOxState {
pub acyl_coa: f64,
pub free_fatty_acid: f64,
}
impl Default for BetaOxState {
fn default() -> Self {
Self {
acyl_coa: 0.02,
free_fatty_acid: 0.3,
}
}
}
impl BetaOxState {
#[must_use = "validation errors should be handled"]
pub fn validate(&self) -> Result<(), RasayanError> {
for (name, value) in [
("acyl_coa", self.acyl_coa),
("free_fatty_acid", self.free_fatty_acid),
] {
if value < 0.0 {
return Err(RasayanError::NegativeConcentration {
name: name.into(),
value,
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BetaOxConfig {
pub chain_length: u32,
pub activation_vmax: f64,
pub activation_km: f64,
pub cycle_vmax: f64,
pub cycle_km: f64,
pub cpt1_ki_malonyl_coa: f64,
}
impl Default for BetaOxConfig {
fn default() -> Self {
Self {
chain_length: 16,
activation_vmax: 0.05,
activation_km: 0.1,
cycle_vmax: 0.03,
cycle_km: 0.01,
cpt1_ki_malonyl_coa: 0.01,
}
}
}
impl BetaOxConfig {
#[must_use]
pub fn cycles(&self) -> u32 {
if self.chain_length >= 4 {
self.chain_length / 2 - 1
} else {
0
}
}
#[must_use]
pub fn acetyl_coa_per_fa(&self) -> u32 {
if self.chain_length >= 4 {
self.chain_length / 2
} else {
0
}
}
#[must_use = "validation errors should be handled"]
pub fn validate(&self) -> Result<(), RasayanError> {
if self.chain_length < 4 || !self.chain_length.is_multiple_of(2) {
return Err(RasayanError::InvalidParameter {
name: "chain_length".into(),
value: self.chain_length as f64,
reason: "must be even and >= 4".into(),
});
}
for (name, value) in [
("activation_vmax", self.activation_vmax),
("activation_km", self.activation_km),
("cycle_vmax", self.cycle_vmax),
("cycle_km", self.cycle_km),
("cpt1_ki_malonyl_coa", self.cpt1_ki_malonyl_coa),
] {
if value < 0.0 {
return Err(RasayanError::InvalidParameter {
name: name.into(),
value,
reason: "must be non-negative".into(),
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[must_use]
pub struct BetaOxFlux {
pub ffa_consumed: f64,
pub acyl_coa_consumed: f64,
pub acetyl_coa_produced: f64,
pub nadh_produced: f64,
pub fadh2_produced: f64,
pub atp_consumed: f64,
pub oxidation_rate: f64,
pub activation_rate: f64,
}
impl BetaOxState {
#[must_use = "flux contains acetyl-CoA/NADH/FADH2 accounting that should be applied"]
pub fn tick(
&mut self,
config: &BetaOxConfig,
malonyl_coa: f64,
nad_ratio: f64,
dt: f64,
) -> BetaOxFlux {
tracing::trace!(
dt,
acyl_coa = self.acyl_coa,
ffa = self.free_fatty_acid,
"beta_oxidation_tick"
);
let nad_factor =
(nad_ratio / crate::constants::RESTING_NAD_RATIO).min(crate::constants::MAX_NAD_FACTOR);
let cycles = config.cycles() as f64;
let accoa_per_fa = config.acetyl_coa_per_fa() as f64;
let v_activation = enzyme::michaelis_menten(
self.free_fatty_acid,
config.activation_vmax,
config.activation_km,
);
let cpt1_factor = if config.cpt1_ki_malonyl_coa > 0.0 {
1.0 / (1.0 + malonyl_coa / config.cpt1_ki_malonyl_coa)
} else {
1.0
};
let v_oxidation = enzyme::michaelis_menten(
self.acyl_coa,
config.cycle_vmax * cpt1_factor * nad_factor,
config.cycle_km,
);
let activated = v_activation * dt;
let oxidized = v_oxidation * dt;
self.free_fatty_acid -= activated;
self.acyl_coa += activated - oxidized;
self.free_fatty_acid = self.free_fatty_acid.max(0.0);
self.acyl_coa = self.acyl_coa.max(0.0);
BetaOxFlux {
ffa_consumed: activated,
acyl_coa_consumed: oxidized,
acetyl_coa_produced: oxidized * accoa_per_fa,
nadh_produced: oxidized * cycles,
fadh2_produced: oxidized * cycles,
atp_consumed: activated * 2.0,
oxidation_rate: v_oxidation,
activation_rate: v_activation,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run_beta_ox(steps: usize, dt: f64) -> (BetaOxState, BetaOxFlux) {
let mut state = BetaOxState::default();
let config = BetaOxConfig::default();
let mut total = BetaOxFlux {
ffa_consumed: 0.0,
acyl_coa_consumed: 0.0,
acetyl_coa_produced: 0.0,
nadh_produced: 0.0,
fadh2_produced: 0.0,
atp_consumed: 0.0,
oxidation_rate: 0.0,
activation_rate: 0.0,
};
for _ in 0..steps {
let flux = state.tick(&config, 0.0, 700.0, dt);
total.ffa_consumed += flux.ffa_consumed;
total.acyl_coa_consumed += flux.acyl_coa_consumed;
total.acetyl_coa_produced += flux.acetyl_coa_produced;
total.nadh_produced += flux.nadh_produced;
total.fadh2_produced += flux.fadh2_produced;
total.atp_consumed += flux.atp_consumed;
total.oxidation_rate = flux.oxidation_rate;
total.activation_rate = flux.activation_rate;
}
(state, total)
}
#[test]
fn test_default_state_valid() {
assert!(BetaOxState::default().validate().is_ok());
}
#[test]
fn test_default_config_valid() {
assert!(BetaOxConfig::default().validate().is_ok());
}
#[test]
fn test_palmitate_stoichiometry() {
let config = BetaOxConfig::default();
assert_eq!(config.chain_length, 16);
assert_eq!(config.cycles(), 7);
assert_eq!(config.acetyl_coa_per_fa(), 8);
}
#[test]
fn test_other_chain_lengths() {
let c12 = BetaOxConfig {
chain_length: 12,
..BetaOxConfig::default()
};
assert_eq!(c12.cycles(), 5);
assert_eq!(c12.acetyl_coa_per_fa(), 6);
let c4 = BetaOxConfig {
chain_length: 4,
..BetaOxConfig::default()
};
assert_eq!(c4.cycles(), 1);
assert_eq!(c4.acetyl_coa_per_fa(), 2);
}
#[test]
fn test_invalid_chain_length() {
let odd = BetaOxConfig {
chain_length: 15,
..BetaOxConfig::default()
};
assert!(odd.validate().is_err());
let too_short = BetaOxConfig {
chain_length: 2,
..BetaOxConfig::default()
};
assert!(too_short.validate().is_err());
}
#[test]
fn test_acetyl_coa_produced() {
let (_, flux) = run_beta_ox(100, 0.1);
assert!(
flux.acetyl_coa_produced > 0.0,
"Acetyl-CoA should be produced"
);
}
#[test]
fn test_nadh_and_fadh2_produced() {
let (_, flux) = run_beta_ox(100, 0.1);
assert!(flux.nadh_produced > 0.0, "NADH should be produced");
assert!(flux.fadh2_produced > 0.0, "FADH2 should be produced");
assert!(
(flux.nadh_produced - flux.fadh2_produced).abs() < 1e-10,
"NADH and FADH2 should be equal"
);
}
#[test]
fn test_activation_costs_atp() {
let (_, flux) = run_beta_ox(100, 0.1);
assert!(flux.atp_consumed > 0.0, "Activation should cost ATP");
assert!(
(flux.atp_consumed - flux.ffa_consumed * 2.0).abs() < 1e-10,
"Should cost exactly 2 ATP per FFA"
);
}
#[test]
fn test_malonyl_coa_inhibits() {
let config = BetaOxConfig::default();
let mut state1 = BetaOxState::default();
let flux_uninhibited = state1.tick(&config, 0.0, 700.0, 0.1);
let mut state2 = BetaOxState::default();
let flux_inhibited = state2.tick(&config, 0.1, 700.0, 0.1);
assert!(
flux_inhibited.oxidation_rate < flux_uninhibited.oxidation_rate,
"Malonyl-CoA should inhibit beta-oxidation"
);
}
#[test]
fn test_high_malonyl_coa_strong_inhibition() {
let config = BetaOxConfig::default();
let mut state = BetaOxState::default();
let flux = state.tick(&config, 1.0, 700.0, 0.1);
let mut state2 = BetaOxState::default();
let flux_base = state2.tick(&config, 0.0, 700.0, 0.1);
let inhibition = 1.0 - flux.oxidation_rate / flux_base.oxidation_rate;
assert!(
inhibition > 0.9,
"High malonyl-CoA should inhibit >90%, got {:.0}%",
inhibition * 100.0
);
}
#[test]
fn test_no_ffa_no_activation() {
let mut state = BetaOxState {
free_fatty_acid: 0.0,
..BetaOxState::default()
};
let config = BetaOxConfig::default();
let flux = state.tick(&config, 0.0, 700.0, 0.1);
assert!(flux.activation_rate < 1e-10);
}
#[test]
fn test_concentrations_non_negative() {
let (state, _) = run_beta_ox(1000, 0.01);
assert!(state.validate().is_ok());
}
#[test]
fn test_ffa_consumed_over_time() {
let (state, _) = run_beta_ox(100, 0.1);
assert!(
state.free_fatty_acid < BetaOxState::default().free_fatty_acid,
"FFA should be consumed"
);
}
#[test]
fn test_serde_roundtrip_state() {
let state = BetaOxState::default();
let json = serde_json::to_string(&state).unwrap();
let state2: BetaOxState = serde_json::from_str(&json).unwrap();
assert_eq!(state, state2);
}
#[test]
fn test_serde_roundtrip_config() {
let config = BetaOxConfig::default();
let json = serde_json::to_string(&config).unwrap();
let config2: BetaOxConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, config2);
}
}