use crate::{
Equation, Expr, ExpressionNode, InlineValue, Model, ModelVariable, ReactionSystem, Species,
VariableType,
};
use indexmap::IndexMap;
use std::collections::HashMap;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DeriveError {
#[error("Invalid stoichiometry: {0}")]
InvalidStoichiometry(String),
}
pub fn lower_reactions_to_equations(
reactions: &[crate::Reaction],
species: &IndexMap<String, Species>,
) -> Result<Vec<Equation>, DeriveError> {
if species.is_empty() && !reactions.is_empty() {
return Err(DeriveError::InvalidStoichiometry(
"Reaction system has reactions but no species defined".to_string(),
));
}
for (reaction_idx, reaction) in reactions.iter().enumerate() {
for substrate in reaction.substrates.iter().flatten() {
if !species.contains_key(&substrate.species) {
return Err(DeriveError::InvalidStoichiometry(format!(
"Unknown substrate species '{}' in reaction {}",
substrate.species, reaction_idx
)));
}
}
for product in reaction.products.iter().flatten() {
if !species.contains_key(&product.species) {
return Err(DeriveError::InvalidStoichiometry(format!(
"Unknown product species '{}' in reaction {}",
product.species, reaction_idx
)));
}
}
let no_substrates = reaction.substrates.as_ref().is_none_or(|v| v.is_empty());
let no_products = reaction.products.as_ref().is_none_or(|v| v.is_empty());
if no_substrates && no_products {
return Err(DeriveError::InvalidStoichiometry(format!(
"Reaction {reaction_idx} has no substrates or products"
)));
}
}
let mut species_terms: Vec<Vec<Expr>> = vec![Vec::new(); species.len()];
for reaction in reactions {
let mut sums: Vec<(usize, f64, f64)> = Vec::new();
let sides = [
(false, reaction.substrates.iter().flatten()),
(true, reaction.products.iter().flatten()),
];
for (is_product, entries) in sides {
for entry in entries {
let Some(idx) = species.get_index_of(&entry.species) else {
continue;
};
let slot = match sums.iter().position(|(i, _, _)| *i == idx) {
Some(pos) => &mut sums[pos],
None => {
sums.push((idx, 0.0, 0.0));
sums.last_mut().unwrap()
}
};
if is_product {
slot.1 += entry.coefficient;
} else {
slot.2 += entry.coefficient;
}
}
}
let mut rate_law: Option<Expr> = None;
for (idx, produced, consumed) in sums {
if species[idx].constant == Some(true) {
continue;
}
let net_stoichiometry = produced - consumed;
if net_stoichiometry == 0.0 {
continue;
}
let enhanced_rate = rate_law
.get_or_insert_with(|| {
enhance_rate_with_mass_action(
&reaction.rate,
reaction.substrates.as_deref().unwrap_or(&[]),
)
})
.clone();
species_terms[idx].push(if net_stoichiometry == 1.0 {
enhanced_rate
} else {
Expr::operator(ExpressionNode {
op: "*".to_string(),
args: vec![stoich_literal(net_stoichiometry), enhanced_rate],
wrt: None,
dim: None,
..Default::default()
})
});
}
}
let mut equations = Vec::with_capacity(species.len());
for (sp_name, rate_terms) in species.keys().zip(species_terms) {
if rate_terms.is_empty() {
continue;
}
let rhs = if rate_terms.len() == 1 {
rate_terms.into_iter().next().unwrap()
} else {
Expr::operator(ExpressionNode {
op: "+".to_string(),
args: rate_terms,
wrt: None,
dim: None,
..Default::default()
})
};
let lhs = Expr::operator(ExpressionNode {
op: "D".to_string(),
args: vec![Expr::Variable(sp_name.clone())],
wrt: Some("t".to_string()),
dim: None,
..Default::default()
});
equations.push(Equation {
comment: None,
lhs,
rhs,
});
}
Ok(equations)
}
fn stoich_literal(value: f64) -> Expr {
if value.fract() == 0.0 && (i64::MIN as f64..i64::MAX as f64).contains(&value) {
Expr::Integer(value as i64)
} else {
Expr::Number(value)
}
}
pub fn derive_odes(system: &ReactionSystem) -> Result<Model, DeriveError> {
let mut variables = IndexMap::new();
for (species_name, species) in &system.species {
let var_type = if species.constant == Some(true) {
VariableType::Parameter
} else {
VariableType::Unknown
};
variables.insert(
species_name.clone(),
ModelVariable {
var_type,
units: species.units.clone(),
default: species.default.map(InlineValue::Scalar),
description: species.description.clone(),
..Default::default()
},
);
}
for (param_name, param) in &system.parameters {
variables.insert(
param_name.clone(),
ModelVariable {
var_type: VariableType::Parameter,
units: param.units.clone(),
default: param.default.map(InlineValue::Scalar),
description: param.description.clone(),
default_units: param.default_units.clone(),
shape: param.shape.clone(),
distribution: param.distribution.clone(),
update: param.update.clone(),
..Default::default()
},
);
}
let equations = lower_reactions_to_equations(&system.reactions, &system.species)?;
Ok(Model {
reference: system.reference.clone(),
variables,
equations,
discrete_events: system.discrete_events.clone(),
continuous_events: system.continuous_events.clone(),
..Default::default()
})
}
fn enhance_rate_with_mass_action(rate: &Expr, substrates: &[crate::StoichiometricEntry]) -> Expr {
if substrates.is_empty() {
return rate.clone();
}
let mut concentration_factors = Vec::new();
for substrate in substrates {
let coeff = substrate.coefficient;
let species_var = Expr::Variable(substrate.species.clone());
if coeff == 1.0 {
concentration_factors.push(species_var);
} else {
let exponent = stoich_literal(coeff);
concentration_factors.push(Expr::operator(ExpressionNode {
op: "^".to_string(),
args: vec![species_var, exponent],
wrt: None,
dim: None,
..Default::default()
}));
}
}
let mut all_factors = vec![rate.clone()];
all_factors.extend(concentration_factors);
if all_factors.len() == 1 {
all_factors.into_iter().next().unwrap()
} else {
Expr::operator(ExpressionNode {
op: "*".to_string(),
args: all_factors,
wrt: None,
dim: None,
..Default::default()
})
}
}
pub fn stoichiometric_matrix(system: &ReactionSystem) -> Vec<Vec<f64>> {
let num_species = system.species.len();
let num_reactions = system.reactions.len();
let mut matrix = vec![vec![0.0f64; num_reactions]; num_species];
let species_index: HashMap<String, usize> = system
.species
.keys()
.enumerate()
.map(|(idx, name)| (name.clone(), idx))
.collect();
for (reaction_idx, reaction) in system.reactions.iter().enumerate() {
for substrate in reaction.substrates.iter().flatten() {
if let Some(&species_idx) = species_index.get(&substrate.species) {
matrix[species_idx][reaction_idx] -= substrate.coefficient;
}
}
for product in reaction.products.iter().flatten() {
if let Some(&species_idx) = species_index.get(&product.species) {
matrix[species_idx][reaction_idx] += product.coefficient;
}
}
}
matrix
}
#[cfg(feature = "parallel")]
pub fn stoichiometric_matrix_parallel(
system: &ReactionSystem,
) -> Result<Vec<Vec<f64>>, crate::performance::PerformanceError> {
use crate::performance::ParallelEvaluator;
let evaluator = ParallelEvaluator::new(None)?;
evaluator.compute_stoichiometric_matrix_parallel(system)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Reaction, Species, StoichiometricEntry, VariableType};
use indexmap::IndexMap;
#[test]
fn test_stoich_literal_int64_bounds() {
let two_63 = 2.0_f64.powi(63);
assert!(matches!(stoich_literal(two_63), Expr::Number(_)));
assert!(matches!(stoich_literal(-two_63), Expr::Integer(i64::MIN)));
assert!(matches!(
stoich_literal(1e17),
Expr::Integer(100_000_000_000_000_000)
));
assert!(matches!(stoich_literal(-2.0), Expr::Integer(-2)));
assert!(matches!(stoich_literal(1.5), Expr::Number(_)));
}
fn create_test_species(name: &str) -> (String, Species) {
(
name.to_string(),
Species {
default_units: None,
units: Some("mol/L".to_string()),
default: Some(0.0),
description: None,
constant: None,
},
)
}
fn create_test_reaction(
substrates: Vec<(&str, f64)>,
products: Vec<(&str, f64)>,
rate: Expr,
) -> Reaction {
Reaction {
id: None,
name: None,
substrates: Some(
substrates
.into_iter()
.map(|(species, coeff)| StoichiometricEntry {
species: species.to_string(),
coefficient: coeff,
})
.collect(),
),
products: Some(
products
.into_iter()
.map(|(species, coeff)| StoichiometricEntry {
species: species.to_string(),
coefficient: coeff,
})
.collect(),
),
rate,
reference: None,
}
}
#[test]
fn derive_odes_declares_rate_parameters() {
let mut species = IndexMap::new();
species.insert(
"A".to_string(),
Species {
default_units: None,
default: Some(1.0),
units: Some("molec/cm3".to_string()),
description: None,
constant: None,
},
);
let mut parameters = IndexMap::new();
parameters.insert(
"k1".to_string(),
crate::types::Parameter {
default_units: None,
shape: None,
distribution: None,
update: None,
default: Some(1.8e-12),
units: Some("cm3/molec/s".to_string()),
description: Some("Rate constant".to_string()),
},
);
let system = ReactionSystem {
species,
parameters,
reactions: vec![Reaction {
id: Some("R1".to_string()),
name: None,
substrates: Some(vec![StoichiometricEntry {
species: "A".to_string(),
coefficient: 1.0,
}]),
products: None,
rate: Expr::Variable("k1".to_string()),
reference: None,
}],
..Default::default()
};
let model = derive_odes(&system).expect("derives");
let k1 = model.variables.get("k1").expect("k1 is declared");
assert!(matches!(k1.var_type, VariableType::Parameter));
assert_eq!(k1.default_scalar(), Some(1.8e-12));
assert_eq!(k1.units.as_deref(), Some("cm3/molec/s"));
let names: Vec<&str> = model.variables.keys().map(String::as_str).collect();
assert_eq!(names, vec!["A", "k1"]);
}
#[test]
fn test_derive_odes_simple() {
let system = ReactionSystem {
species: [create_test_species("A"), create_test_species("B")]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![
create_test_reaction(
vec![("A", 1.0)],
vec![("B", 1.0)],
Expr::operator(ExpressionNode {
op: "*".to_string(),
args: vec![
Expr::Variable("k1".to_string()),
Expr::Variable("A".to_string()),
],
wrt: None,
dim: None,
..Default::default()
}),
),
],
..Default::default()
};
let model = derive_odes(&system).expect("Should derive ODEs successfully");
assert_eq!(model.variables.len(), 2);
assert!(model.variables.contains_key("A"));
assert!(model.variables.contains_key("B"));
assert_eq!(model.equations.len(), 2);
let var_names: Vec<String> = model
.equations
.iter()
.map(|eq| match &eq.lhs {
Expr::Operator(node) if node.op == "D" => match &node.args[0] {
Expr::Variable(name) => name.clone(),
_ => "unknown".to_string(),
},
_ => "unknown".to_string(),
})
.collect();
assert!(var_names.contains(&"A".to_string()));
assert!(var_names.contains(&"B".to_string()));
}
#[test]
fn test_derive_odes_reservoir_constant_species() {
let mut species: IndexMap<String, Species> =
[create_test_species("A"), create_test_species("B")]
.into_iter()
.collect();
species.insert(
"R".to_string(),
Species {
default_units: None,
units: Some("1".to_string()),
default: Some(2.0),
description: None,
constant: Some(true),
},
);
let system = ReactionSystem {
species,
parameters: IndexMap::new(),
reactions: vec![create_test_reaction(
vec![("R", 1.0), ("A", 1.0)],
vec![("B", 1.0)],
Expr::Variable("k".to_string()),
)],
..Default::default()
};
let model = derive_odes(&system).expect("Should derive ODEs successfully");
assert!(
matches!(model.variables["R"].var_type, VariableType::Parameter),
"reservoir R must lower to a parameter"
);
assert_eq!(model.variables["R"].default_scalar(), Some(2.0));
assert!(matches!(
model.variables["A"].var_type,
VariableType::Unknown
));
assert!(matches!(
model.variables["B"].var_type,
VariableType::Unknown
));
let eq_species: Vec<String> = model
.equations
.iter()
.filter_map(|eq| match &eq.lhs {
Expr::Operator(node) if node.op == "D" => match &node.args[0] {
Expr::Variable(name) => Some(name.clone()),
_ => None,
},
_ => None,
})
.collect();
assert!(
!eq_species.contains(&"R".to_string()),
"reservoir R must get no ODE, got equations for {eq_species:?}"
);
assert!(eq_species.contains(&"A".to_string()));
assert!(eq_species.contains(&"B".to_string()));
assert_eq!(model.equations.len(), 2);
let a_eq = model
.equations
.iter()
.find(|eq| match &eq.lhs {
Expr::Operator(node) if node.op == "D" => {
matches!(&node.args[0], Expr::Variable(name) if name == "A")
}
_ => false,
})
.expect("Should find A equation");
let rhs_dbg = format!("{:?}", a_eq.rhs);
assert!(
rhs_dbg.contains("\"R\""),
"reservoir R must remain a mass-action factor in dA/dt: {rhs_dbg}"
);
}
#[test]
fn test_stoichiometric_matrix() {
let system = ReactionSystem {
species: [
create_test_species("A"),
create_test_species("B"),
create_test_species("C"),
]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![
create_test_reaction(
vec![("A", 1.0)],
vec![("B", 1.0)],
Expr::Variable("k1".to_string()),
),
create_test_reaction(
vec![("B", 1.0)],
vec![("C", 1.0)],
Expr::Variable("k2".to_string()),
),
create_test_reaction(
vec![("A", 2.0)],
vec![("C", 1.0)],
Expr::Variable("k3".to_string()),
),
],
..Default::default()
};
let matrix = stoichiometric_matrix(&system);
assert_eq!(matrix.len(), 3);
assert_eq!(matrix[0].len(), 3);
assert_eq!(matrix[1].len(), 3);
assert_eq!(matrix[2].len(), 3);
assert_eq!(matrix[0], vec![-1.0, 0.0, -2.0]);
assert_eq!(matrix[1], vec![1.0, -1.0, 0.0]);
assert_eq!(matrix[2], vec![0.0, 1.0, 1.0]);
}
#[test]
fn test_stoichiometric_matrix_empty() {
let system = ReactionSystem {
species: indexmap::IndexMap::new(),
parameters: IndexMap::new(),
reactions: vec![],
..Default::default()
};
let matrix = stoichiometric_matrix(&system);
assert_eq!(matrix.len(), 0);
}
#[test]
fn test_derive_odes_empty_system() {
let system = ReactionSystem {
species: indexmap::IndexMap::new(),
parameters: IndexMap::new(),
reactions: vec![],
..Default::default()
};
let model = derive_odes(&system).expect("Should handle empty system");
assert_eq!(model.variables.len(), 0);
assert_eq!(model.equations.len(), 0);
}
#[test]
fn test_derive_odes_unknown_species_error() {
let system = ReactionSystem {
species: [create_test_species("A")]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![create_test_reaction(
vec![("B", 1.0)], vec![("A", 1.0)],
Expr::Variable("k1".to_string()),
)],
..Default::default()
};
let result = derive_odes(&system);
assert!(result.is_err());
match result {
Err(DeriveError::InvalidStoichiometry(msg)) => {
assert!(msg.contains("Unknown substrate species 'B'"));
}
_ => panic!("Expected InvalidStoichiometry error"),
}
}
#[test]
fn test_derive_odes_mass_action_kinetics() {
let system = ReactionSystem {
species: [
create_test_species("A"),
create_test_species("B"),
create_test_species("C"),
]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![
create_test_reaction(
vec![("A", 1.0), ("B", 1.0)],
vec![("C", 1.0)],
Expr::Variable("k1".to_string()),
),
],
..Default::default()
};
let model = derive_odes(&system).expect("Should derive ODEs successfully");
assert_eq!(model.variables.len(), 3);
assert_eq!(model.equations.len(), 3);
let c_equation = model
.equations
.iter()
.find(|eq| match &eq.lhs {
Expr::Operator(node) if node.op == "D" => match &node.args[0] {
Expr::Variable(name) => name == "C",
_ => false,
},
_ => false,
})
.expect("Should find C equation");
match &c_equation.rhs {
Expr::Operator(node) if node.op == "*" => {
assert!(node.args.len() >= 2);
}
_ => panic!("Expected multiplication for mass action kinetics"),
}
}
#[test]
fn test_derive_odes_source_reaction() {
let system = ReactionSystem {
species: [create_test_species("A")]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![
create_test_reaction(vec![], vec![("A", 1.0)], Expr::Variable("k0".to_string())),
],
..Default::default()
};
let model = derive_odes(&system).expect("Should handle source reactions");
assert_eq!(model.variables.len(), 1);
assert_eq!(model.equations.len(), 1);
let a_equation = &model.equations[0];
match &a_equation.rhs {
Expr::Variable(name) => assert_eq!(name, "k0"),
_ => panic!("Expected simple rate constant for source reaction"),
}
}
#[test]
fn test_derive_odes_sink_reaction() {
let system = ReactionSystem {
species: [create_test_species("A")]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![
create_test_reaction(
vec![("A", 1.0)],
vec![],
Expr::Variable("k_deg".to_string()),
),
],
..Default::default()
};
let model = derive_odes(&system).expect("Should handle sink reactions");
assert_eq!(model.variables.len(), 1);
assert_eq!(model.equations.len(), 1);
let a_equation = &model.equations[0];
match &a_equation.rhs {
Expr::Operator(node) if node.op == "*" => {
assert_eq!(node.args.len(), 2);
match &node.args[0] {
Expr::Integer(n) => assert_eq!(*n, -1),
_ => panic!("Expected the integer literal -1 as first argument"),
}
}
_ => panic!(
"Expected multiplication for sink reaction kinetics, got: {:?}",
a_equation.rhs
),
}
}
#[test]
fn test_derive_odes_higher_order_reaction() {
let system = ReactionSystem {
species: [create_test_species("A"), create_test_species("B")]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![
create_test_reaction(
vec![("A", 2.0)],
vec![("B", 1.0)],
Expr::Variable("k1".to_string()),
),
],
..Default::default()
};
let model = derive_odes(&system).expect("Should handle higher order reactions");
assert_eq!(model.variables.len(), 2);
assert_eq!(model.equations.len(), 2);
let b_equation = model
.equations
.iter()
.find(|eq| match &eq.lhs {
Expr::Operator(node) if node.op == "D" => match &node.args[0] {
Expr::Variable(name) => name == "B",
_ => false,
},
_ => false,
})
.expect("Should find B equation");
match &b_equation.rhs {
Expr::Operator(node) if node.op == "*" => {
assert!(node.args.len() >= 2);
}
_ => panic!("Expected multiplication for higher order kinetics"),
}
}
#[test]
fn test_derive_odes_reactions_with_no_substrates_and_products() {
let system = ReactionSystem {
species: [create_test_species("A")]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![create_test_reaction(
vec![], vec![], Expr::Variable("k1".to_string()),
)],
..Default::default()
};
let result = derive_odes(&system);
assert!(result.is_err());
match result {
Err(DeriveError::InvalidStoichiometry(msg)) => {
assert!(msg.contains("has no substrates or products"));
}
_ => panic!("Expected InvalidStoichiometry error"),
}
}
#[test]
fn test_derive_odes_complex_reaction_network() {
let system = ReactionSystem {
species: [
create_test_species("A"),
create_test_species("B"),
create_test_species("C"),
create_test_species("D"),
]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![
create_test_reaction(
vec![("A", 1.0), ("B", 1.0)],
vec![("C", 1.0), ("D", 1.0)],
Expr::Variable("k1".to_string()),
),
create_test_reaction(
vec![("C", 1.0)],
vec![("A", 1.0)],
Expr::Variable("k2".to_string()),
),
create_test_reaction(
vec![("D", 1.0)],
vec![("B", 1.0)],
Expr::Variable("k3".to_string()),
),
],
..Default::default()
};
let model = derive_odes(&system).expect("Should handle complex networks");
assert_eq!(model.variables.len(), 4);
assert_eq!(model.equations.len(), 4);
for species_name in &["A", "B", "C", "D"] {
let found = model.equations.iter().any(|eq| match &eq.lhs {
Expr::Operator(node) if node.op == "D" => match &node.args[0] {
Expr::Variable(name) => name == species_name,
_ => false,
},
_ => false,
});
assert!(found, "Should have equation for species {species_name}");
}
}
#[test]
#[cfg(feature = "parallel")]
fn test_stoichiometric_matrix_parallel() {
let system = ReactionSystem {
species: [
create_test_species("A"),
create_test_species("B"),
create_test_species("C"),
]
.into_iter()
.collect::<IndexMap<_, _>>(),
parameters: IndexMap::new(),
reactions: vec![
create_test_reaction(
vec![("A", 1.0)],
vec![("B", 1.0)],
Expr::Variable("k1".to_string()),
),
create_test_reaction(
vec![("B", 1.0)],
vec![("C", 1.0)],
Expr::Variable("k2".to_string()),
),
],
..Default::default()
};
let parallel_matrix = crate::reactions::stoichiometric_matrix_parallel(&system)
.expect("Parallel stoichiometric matrix should work");
let sequential_matrix = stoichiometric_matrix(&system);
assert_eq!(parallel_matrix.len(), sequential_matrix.len());
assert_eq!(parallel_matrix[0].len(), sequential_matrix[0].len());
for (i, row) in parallel_matrix.iter().enumerate() {
for (j, &val) in row.iter().enumerate() {
assert_eq!(
val, sequential_matrix[i][j],
"Parallel and sequential results should match at position [{i}, {j}]"
);
}
}
assert_eq!(parallel_matrix[0][0], -1.0, "A consumed in reaction 1");
assert_eq!(parallel_matrix[1][0], 1.0, "B produced in reaction 1");
assert_eq!(parallel_matrix[1][1], -1.0, "B consumed in reaction 2");
assert_eq!(parallel_matrix[2][1], 1.0, "C produced in reaction 2");
}
}