catlog/stdlib/analyses/ode/
mass_action.rs1use std::collections::HashMap;
7
8use indexmap::IndexMap;
9use nalgebra::DVector;
10use num_traits::Zero;
11use rebop::gillespie;
12
13#[cfg(feature = "serde")]
14use serde::{Deserialize, Serialize};
15#[cfg(feature = "serde-wasm")]
16use tsify::Tsify;
17
18use super::{ODEAnalysis, ODESolution};
19use crate::dbl::{
20 model::{DiscreteTabModel, FgDblModel, ModalDblModel, ModalOb, MutDblModel, TabEdge},
21 theory::{ModalMorType, ModalObType, TabMorType, TabObType},
22};
23use crate::one::FgCategory;
24use crate::simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem};
25use crate::zero::{QualifiedName, alg::Polynomial, name, rig::Monomial};
26
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
29#[cfg_attr(feature = "serde-wasm", derive(Tsify))]
30#[cfg_attr(
31 feature = "serde-wasm",
32 tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object)
33)]
34pub struct MassActionProblemData {
35 rates: HashMap<QualifiedName, f32>,
37
38 #[cfg_attr(feature = "serde", serde(rename = "initialValues"))]
40 pub initial_values: HashMap<QualifiedName, f32>,
41
42 pub duration: f32,
44}
45
46pub struct StochasticMassActionAnalysis {
48 pub problem: rebop::gillespie::Gillespie,
50
51 pub variable_index: IndexMap<QualifiedName, usize>,
53
54 pub initial_values: HashMap<QualifiedName, f32>,
56
57 pub duration: f32,
59}
60
61impl StochasticMassActionAnalysis {
62 pub fn simulate(&mut self) -> ODESolution {
64 let mut time = vec![0.0];
65 let mut states: HashMap<_, _> = self
66 .variable_index
67 .keys()
68 .map(|id| {
69 let initial = self.initial_values.get(id).copied().unwrap_or_default();
70 (id.clone(), vec![initial])
71 })
72 .collect();
73 for t in 0..(self.duration as usize) {
74 self.problem.advance_until(t as f64);
75 time.push(self.problem.get_time() as f32);
76 for (id, idx) in self.variable_index.iter() {
77 states.get_mut(id).unwrap().push(self.problem.get_species(*idx) as f32)
78 }
79 }
80 ODESolution { time, states }
81 }
82}
83
84type Parameter<Id> = Polynomial<Id, f32, i8>;
86
87pub struct PetriNetMassActionAnalysis {
92 pub place_ob_type: ModalObType,
94 pub transition_mor_type: ModalMorType,
96}
97
98impl Default for PetriNetMassActionAnalysis {
99 fn default() -> Self {
100 let ob_type = ModalObType::new(name("Object"));
101 Self {
102 place_ob_type: ob_type.clone(),
103 transition_mor_type: ModalMorType::Zero(ob_type),
104 }
105 }
106}
107
108impl PetriNetMassActionAnalysis {
109 pub fn build_system(
111 &self,
112 model: &ModalDblModel,
113 ) -> PolynomialSystem<QualifiedName, Parameter<QualifiedName>, i8> {
114 let mut sys = PolynomialSystem::new();
115 for ob in model.ob_generators_with_type(&self.place_ob_type) {
116 sys.add_term(ob, Polynomial::zero());
117 }
118 for mor in model.mor_generators_with_type(&self.transition_mor_type) {
119 let inputs = model
120 .get_dom(&mor)
121 .and_then(|ob| ob.clone().collect_product(None))
122 .unwrap_or_default();
123 let outputs = model
124 .get_cod(&mor)
125 .and_then(|ob| ob.clone().collect_product(None))
126 .unwrap_or_default();
127
128 let term: Monomial<_, _> =
129 inputs.iter().map(|ob| (ob.clone().unwrap_generator(), 1)).collect();
130 let term: Polynomial<_, _, _> =
131 [(Parameter::generator(mor), term)].into_iter().collect();
132 for input in inputs {
133 sys.add_term(input.unwrap_generator(), -term.clone());
134 }
135 for output in outputs {
136 sys.add_term(output.unwrap_generator(), term.clone());
137 }
138 }
139
140 sys.normalize()
142 }
143
144 pub fn build_stochastic_system(
146 &self,
147 model: &ModalDblModel,
148 data: MassActionProblemData,
149 ) -> StochasticMassActionAnalysis {
150 let ob_generators: Vec<_> = model.ob_generators_with_type(&self.place_ob_type).collect();
151
152 let initial: Vec<_> = ob_generators
153 .iter()
154 .map(|id| data.initial_values.get(id).copied().unwrap_or_default() as isize)
155 .collect();
156 let mut problem = gillespie::Gillespie::new(initial, false);
157
158 for mor in model.mor_generators_with_type(&self.transition_mor_type) {
159 let inputs = model
160 .get_dom(&mor)
161 .and_then(|ob| ob.clone().collect_product(None))
162 .unwrap_or_default();
163 let outputs = model
164 .get_cod(&mor)
165 .and_then(|ob| ob.clone().collect_product(None))
166 .unwrap_or_default();
167
168 let input_vec = ob_generators.iter().map(|id| {
170 inputs
171 .iter()
172 .filter(|&ob| matches!(ob, ModalOb::Generator(id2) if id2 == id))
173 .count() as u32
174 });
175 let output_vec = ob_generators.iter().map(|id| {
176 outputs
177 .iter()
178 .filter(|&ob| matches!(ob, ModalOb::Generator(id2) if id2 == id))
179 .count() as isize
180 });
181
182 let input_vec: Vec<_> = input_vec.collect();
184 let output_vec: Vec<_> = output_vec
185 .zip(input_vec.iter().copied())
186 .map(|(o, i)| o - (i as isize))
187 .collect();
188 if let Some(rate) = data.rates.get(&mor) {
189 problem.add_reaction(gillespie::Rate::lma(*rate as f64, input_vec), output_vec)
190 }
191 }
192
193 let variable_index: IndexMap<_, _> =
194 ob_generators.into_iter().enumerate().map(|(i, x)| (x, i)).collect();
195
196 StochasticMassActionAnalysis {
197 problem,
198 variable_index,
199 initial_values: data.initial_values,
200 duration: data.duration,
201 }
202 }
203}
204
205pub struct StockFlowMassActionAnalysis {
207 pub stock_ob_type: TabObType,
209 pub flow_mor_type: TabMorType,
211 pub pos_link_mor_type: TabMorType,
213 pub neg_link_mor_type: TabMorType,
215}
216
217impl Default for StockFlowMassActionAnalysis {
218 fn default() -> Self {
219 let stock_ob_type = TabObType::Basic(name("Object"));
220 let flow_mor_type = TabMorType::Hom(Box::new(stock_ob_type.clone()));
221 Self {
222 stock_ob_type,
223 flow_mor_type,
224 pos_link_mor_type: TabMorType::Basic(name("Link")),
225 neg_link_mor_type: TabMorType::Basic(name("NegativeLink")),
226 }
227 }
228}
229
230impl StockFlowMassActionAnalysis {
231 pub fn build_system(
233 &self,
234 model: &DiscreteTabModel,
235 ) -> PolynomialSystem<QualifiedName, Parameter<QualifiedName>, i8> {
236 let mut terms: HashMap<QualifiedName, Monomial<QualifiedName, i8>> = model
237 .mor_generators_with_type(&self.flow_mor_type)
238 .map(|flow| {
239 let dom = model.mor_generator_dom(&flow).unwrap_basic();
240 (flow, Monomial::generator(dom))
241 })
242 .collect();
243
244 let mut multiply_for_link = |link: QualifiedName, exponent: i8| {
245 let dom = model.mor_generator_dom(&link).unwrap_basic();
246 let path = model.mor_generator_cod(&link).unwrap_tabulated();
247 let Some(TabEdge::Basic(cod)) = path.only() else {
248 panic!("Codomain of link should be basic morphism");
249 };
250 if let Some(term) = terms.get_mut(&cod) {
251 let mon: Monomial<_, i8> = [(dom, exponent)].into_iter().collect();
252 *term = std::mem::take(term) * mon;
253 } else {
254 panic!("Codomain of link does not belong to model");
255 };
256 };
257
258 for link in model.mor_generators_with_type(&self.pos_link_mor_type) {
259 multiply_for_link(link, 1);
260 }
261 for link in model.mor_generators_with_type(&self.neg_link_mor_type) {
262 multiply_for_link(link, -1);
263 }
264
265 let terms: Vec<_> = terms
266 .into_iter()
267 .map(|(flow, term)| {
268 let param = Parameter::generator(flow.clone());
269 let poly: Polynomial<_, _, _> = [(param, term)].into_iter().collect();
270 (flow, poly)
271 })
272 .collect();
273
274 let mut sys = PolynomialSystem::new();
275 for ob in model.ob_generators_with_type(&self.stock_ob_type) {
276 sys.add_term(ob, Polynomial::zero());
277 }
278 for (flow, term) in terms.iter() {
279 let dom = model.mor_generator_dom(flow).unwrap_basic();
280 sys.add_term(dom, -term.clone());
281 }
282 for (flow, term) in terms {
283 let cod = model.mor_generator_cod(&flow).unwrap_basic();
284 sys.add_term(cod, term);
285 }
286 sys
287 }
288}
289
290pub fn extend_mass_action_scalars(
292 sys: PolynomialSystem<QualifiedName, Parameter<QualifiedName>, i8>,
293 data: &MassActionProblemData,
294) -> PolynomialSystem<QualifiedName, f32, i8> {
295 sys.extend_scalars(|poly| poly.eval(|flow| data.rates.get(flow).copied().unwrap_or_default()))
296}
297
298pub fn into_mass_action_analysis(
300 sys: PolynomialSystem<QualifiedName, f32, i8>,
301 data: MassActionProblemData,
302) -> ODEAnalysis<NumericalPolynomialSystem<i8>> {
303 let ob_index: IndexMap<_, _> =
304 sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect();
305 let n = ob_index.len();
306
307 let initial_values = ob_index
308 .keys()
309 .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default());
310 let x0 = DVector::from_iterator(n, initial_values);
311
312 let num_sys = sys.to_numerical();
313 let problem = ODEProblem::new(num_sys, x0).end_time(data.duration);
314
315 ODEAnalysis::new(problem, ob_index)
316}
317
318#[cfg(test)]
319mod tests {
320 use expect_test::expect;
321 use std::rc::Rc;
322
323 use super::*;
324 use crate::simulate::ode::LatexEquation;
325 use crate::stdlib::{models::*, theories::*};
326
327 #[test]
328 fn backward_link_dynamics() {
329 let th = Rc::new(th_category_links());
330 let model = backward_link(th);
331 let sys = StockFlowMassActionAnalysis::default().build_system(&model);
332 let expected = expect!([r#"
333 dx = (-f) x y
334 dy = f x y
335 "#]);
336 expected.assert_eq(&sys.to_string());
337 }
338
339 #[test]
340 fn positive_backward_link_dynamics() {
341 let th = Rc::new(th_category_signed_links());
342 let model = positive_backward_link(th);
343 let sys = StockFlowMassActionAnalysis::default().build_system(&model);
344 let expected = expect!([r#"
345 dx = (-f) x y
346 dy = f x y
347 "#]);
348 expected.assert_eq(&sys.to_string());
349 }
350
351 #[test]
352 fn negative_backward_link_dynamics() {
353 let th = Rc::new(th_category_signed_links());
354 let model = negative_backward_link(th);
355 let sys = StockFlowMassActionAnalysis::default().build_system(&model);
356 let expected = expect!([r#"
357 dx = (-f) x y^{-1}
358 dy = f x y^{-1}
359 "#]);
360 expected.assert_eq(&sys.to_string());
361 }
362
363 #[test]
364 fn catalysis_dynamics() {
365 let th = Rc::new(th_sym_monoidal_category());
366 let model = catalyzed_reaction(th);
367 let sys = PetriNetMassActionAnalysis::default().build_system(&model);
368 let expected = expect!([r#"
369 dx = (-f) c x
370 dy = f c x
371 dc = 0
372 "#]);
373 expected.assert_eq(&sys.to_string());
374 }
375
376 #[test]
377 fn sir_petri_stochastic_dynamics() {
378 let th = Rc::new(th_sym_monoidal_category());
379 let model = sir_petri(th);
380 let data = MassActionProblemData {
381 rates: HashMap::from_iter([(name("infect"), 1e-5f32), (name("recover"), 1e-2f32)]),
382 initial_values: HashMap::from_iter([
383 (name("S"), 1e5f32),
384 (name("I"), 1f32),
385 (name("R"), 0f32),
386 ]),
387 duration: 10f32,
388 };
389 let sys = PetriNetMassActionAnalysis::default().build_stochastic_system(&model, data);
390 assert_eq!(2, sys.problem.nb_reactions());
391 assert_eq!(3, sys.problem.nb_species());
392 }
393
394 #[test]
395 fn to_latex() {
396 let th = Rc::new(th_category_links());
397 let model = backward_link(th);
398 let sys = StockFlowMassActionAnalysis::default().build_system(&model);
399 let expected = vec![
400 LatexEquation {
401 lhs: "\\dot{x}".to_string(),
402 rhs: "(-f) x y".to_string(),
403 },
404 LatexEquation {
405 lhs: "\\dot{y}".to_string(),
406 rhs: "f x y".to_string(),
407 },
408 ];
409 assert_eq!(expected, sys.to_latex_equations());
410 }
411}