catlog/stdlib/analyses/ode/
mass_action.rs1use std::collections::{BTreeMap, HashMap};
7
8use nalgebra::DVector;
9use num_traits::Zero;
10use rebop::gillespie;
11
12#[cfg(feature = "serde")]
13use serde::{Deserialize, Serialize};
14#[cfg(feature = "serde-wasm")]
15use tsify::Tsify;
16
17use super::{ODEAnalysis, ODESolution};
18use crate::dbl::{
19 modal::model::ModalOb,
20 model::{DiscreteTabModel, FgDblModel, ModalDblModel, 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#[derive(Clone)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30#[cfg_attr(feature = "serde-wasm", derive(Tsify))]
31#[cfg_attr(
32 feature = "serde-wasm",
33 tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object)
34)]
35pub struct MassActionProblemData {
36 rates: HashMap<QualifiedName, f32>,
38
39 #[cfg_attr(feature = "serde", serde(rename = "initialValues"))]
41 pub initial_values: HashMap<QualifiedName, f32>,
42
43 pub duration: f32,
45}
46
47pub struct StochasticMassActionAnalysis {
49 pub problem: rebop::gillespie::Gillespie,
51
52 pub variable_index: BTreeMap<QualifiedName, usize>,
54
55 pub initial_values: HashMap<QualifiedName, f32>,
57
58 pub duration: f32,
60}
61
62impl StochasticMassActionAnalysis {
63 pub fn simulate(&mut self) -> ODESolution {
65 let mut time = vec![0.0];
66 let mut states: HashMap<_, _> = self
67 .variable_index
68 .keys()
69 .map(|id| {
70 let initial = self.initial_values.get(id).copied().unwrap_or_default();
71 (id.clone(), vec![initial])
72 })
73 .collect();
74 for t in 0..(self.duration as usize) {
75 self.problem.advance_until(t as f64);
76 time.push(self.problem.get_time() as f32);
77 for (id, idx) in self.variable_index.iter() {
78 states.get_mut(id).unwrap().push(self.problem.get_species(*idx) as f32)
79 }
80 }
81 ODESolution { time, states }
82 }
83}
84
85type Parameter<Id> = Polynomial<Id, f32, u8>;
87
88pub struct PetriNetMassActionAnalysis {
93 pub place_ob_type: ModalObType,
95 pub transition_mor_type: ModalMorType,
97}
98
99impl Default for PetriNetMassActionAnalysis {
100 fn default() -> Self {
101 let ob_type = ModalObType::new(name("Object"));
102 Self {
103 place_ob_type: ob_type.clone(),
104 transition_mor_type: ModalMorType::Zero(ob_type),
105 }
106 }
107}
108
109impl PetriNetMassActionAnalysis {
110 pub fn build_system(
112 &self,
113 model: &ModalDblModel,
114 ) -> PolynomialSystem<QualifiedName, Parameter<QualifiedName>, u8> {
115 let mut sys = PolynomialSystem::new();
116 for ob in model.ob_generators_with_type(&self.place_ob_type) {
117 sys.add_term(ob, Polynomial::zero());
118 }
119 for mor in model.mor_generators_with_type(&self.transition_mor_type) {
120 let inputs = model
121 .get_dom(&mor)
122 .and_then(|ob| ob.clone().collect_product(None))
123 .unwrap_or_default();
124 let outputs = model
125 .get_cod(&mor)
126 .and_then(|ob| ob.clone().collect_product(None))
127 .unwrap_or_default();
128
129 let term: Monomial<_, _> =
130 inputs.iter().map(|ob| (ob.clone().unwrap_generator(), 1)).collect();
131 let term: Polynomial<_, _, _> =
132 [(Parameter::generator(mor), term)].into_iter().collect();
133 for input in inputs {
134 sys.add_term(input.unwrap_generator(), -term.clone());
135 }
136 for output in outputs {
137 sys.add_term(output.unwrap_generator(), term.clone());
138 }
139 }
140
141 sys.normalize()
143 }
144
145 pub fn build_numerical_system(
147 &self,
148 model: &ModalDblModel,
149 data: MassActionProblemData,
150 ) -> ODEAnalysis<NumericalPolynomialSystem<u8>> {
151 into_numerical_system(self.build_system(model), data)
152 }
153
154 pub fn build_stochastic_system(
156 &self,
157 model: &ModalDblModel,
158 data: MassActionProblemData,
159 ) -> StochasticMassActionAnalysis {
160 let ob_generators: Vec<_> = model.ob_generators_with_type(&self.place_ob_type).collect();
161
162 let initial: Vec<_> = ob_generators
163 .iter()
164 .map(|id| data.initial_values.get(id).copied().unwrap_or_default() as isize)
165 .collect();
166 let mut problem = gillespie::Gillespie::new(initial, false);
167
168 for mor in model.mor_generators_with_type(&self.transition_mor_type) {
169 let inputs = model
170 .get_dom(&mor)
171 .and_then(|ob| ob.clone().collect_product(None))
172 .unwrap_or_default();
173 let outputs = model
174 .get_cod(&mor)
175 .and_then(|ob| ob.clone().collect_product(None))
176 .unwrap_or_default();
177
178 let input_vec = ob_generators.iter().map(|id| {
180 inputs
181 .iter()
182 .filter(|&ob| matches!(ob, ModalOb::Generator(id2) if id2 == id))
183 .count() as u32
184 });
185 let output_vec = ob_generators.iter().map(|id| {
186 outputs
187 .iter()
188 .filter(|&ob| matches!(ob, ModalOb::Generator(id2) if id2 == id))
189 .count() as isize
190 });
191
192 let input_vec: Vec<_> = input_vec.collect();
194 let output_vec: Vec<_> = output_vec
195 .zip(input_vec.iter().copied())
196 .map(|(o, i)| o - (i as isize))
197 .collect();
198 if let Some(rate) = data.rates.get(&mor) {
199 problem.add_reaction(gillespie::Rate::lma(*rate as f64, input_vec), output_vec)
200 }
201 }
202
203 let variable_index: BTreeMap<_, _> =
204 ob_generators.into_iter().enumerate().map(|(i, x)| (x, i)).collect();
205
206 StochasticMassActionAnalysis {
207 problem,
208 variable_index,
209 initial_values: data.initial_values,
210 duration: data.duration,
211 }
212 }
213}
214
215pub struct StockFlowMassActionAnalysis {
217 pub stock_ob_type: TabObType,
219 pub flow_mor_type: TabMorType,
221 pub link_mor_type: TabMorType,
223}
224
225impl Default for StockFlowMassActionAnalysis {
226 fn default() -> Self {
227 let stock_ob_type = TabObType::Basic(name("Object"));
228 let flow_mor_type = TabMorType::Hom(Box::new(stock_ob_type.clone()));
229 Self {
230 stock_ob_type,
231 flow_mor_type,
232 link_mor_type: TabMorType::Basic(name("Link")),
233 }
234 }
235}
236
237impl StockFlowMassActionAnalysis {
238 pub fn build_system(
240 &self,
241 model: &DiscreteTabModel,
242 ) -> PolynomialSystem<QualifiedName, Parameter<QualifiedName>, u8> {
243 let mut terms: HashMap<QualifiedName, Monomial<QualifiedName, u8>> = model
244 .mor_generators_with_type(&self.flow_mor_type)
245 .map(|flow| {
246 let dom = model.mor_generator_dom(&flow).unwrap_basic();
247 (flow, Monomial::generator(dom))
248 })
249 .collect();
250
251 for link in model.mor_generators_with_type(&self.link_mor_type) {
252 let dom = model.mor_generator_dom(&link).unwrap_basic();
253 let path = model.mor_generator_cod(&link).unwrap_tabulated();
254 let Some(TabEdge::Basic(cod)) = path.only() else {
255 panic!("Codomain of link should be basic morphism");
256 };
257 if let Some(term) = terms.get_mut(&cod) {
258 *term = std::mem::take(term) * Monomial::generator(dom);
259 } else {
260 panic!("Codomain of link does not belong to model");
261 };
262 }
263
264 let terms: Vec<_> = terms
265 .into_iter()
266 .map(|(flow, term)| {
267 let param = Parameter::generator(flow.clone());
268 let poly: Polynomial<_, _, _> = [(param, term)].into_iter().collect();
269 (flow, poly)
270 })
271 .collect();
272
273 let mut sys = PolynomialSystem::new();
274 for ob in model.ob_generators_with_type(&self.stock_ob_type) {
275 sys.add_term(ob, Polynomial::zero());
276 }
277 for (flow, term) in terms.iter() {
278 let dom = model.mor_generator_dom(flow).unwrap_basic();
279 sys.add_term(dom, -term.clone());
280 }
281 for (flow, term) in terms {
282 let cod = model.mor_generator_cod(&flow).unwrap_basic();
283 sys.add_term(cod, term);
284 }
285 sys
286 }
287
288 pub fn build_numerical_system(
290 &self,
291 model: &DiscreteTabModel,
292 data: MassActionProblemData,
293 ) -> ODEAnalysis<NumericalPolynomialSystem<u8>> {
294 into_numerical_system(self.build_system(model), data)
295 }
296}
297
298fn into_numerical_system(
299 sys: PolynomialSystem<QualifiedName, Parameter<QualifiedName>, u8>,
300 data: MassActionProblemData,
301) -> ODEAnalysis<NumericalPolynomialSystem<u8>> {
302 let ob_index: BTreeMap<_, _> =
303 sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect();
304 let n = ob_index.len();
305
306 let initial_values = ob_index
307 .keys()
308 .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default());
309 let x0 = DVector::from_iterator(n, initial_values);
310
311 let sys = sys
312 .extend_scalars(|poly| poly.eval(|flow| data.rates.get(flow).copied().unwrap_or_default()))
313 .to_numerical();
314
315 let problem = ODEProblem::new(sys, x0).end_time(data.duration);
316 ODEAnalysis::new(problem, ob_index)
317}
318
319#[cfg(test)]
320mod tests {
321 use expect_test::expect;
322 use std::rc::Rc;
323
324 use super::*;
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 = ((-1) f) x y
334 dy = f x y
335 "#]);
336 expected.assert_eq(&sys.to_string());
337 }
338
339 #[test]
340 fn catalysis_dynamics() {
341 let th = Rc::new(th_sym_monoidal_category());
342 let model = catalyzed_reaction(th);
343 let sys = PetriNetMassActionAnalysis::default().build_system(&model);
344 let expected = expect!([r#"
345 dc = 0
346 dx = ((-1) f) c x
347 dy = f c x
348 "#]);
349 expected.assert_eq(&sys.to_string());
350 }
351
352 #[test]
353 fn sir_petri_stochastic_dynamics() {
354 let th = Rc::new(th_sym_monoidal_category());
355 let model = sir_petri(th);
356 let data = MassActionProblemData {
357 rates: HashMap::from_iter([(name("infection"), 1e-5f32), (name("recovery"), 1e-2f32)]),
358 initial_values: HashMap::from_iter([
359 (name("S"), 1e5f32),
360 (name("I"), 1f32),
361 (name("R"), 0f32),
362 ]),
363 duration: 10f32,
364 };
365 let sys = PetriNetMassActionAnalysis::default().build_stochastic_system(&model, data);
366 assert_eq!(2, sys.problem.nb_reactions());
367 assert_eq!(3, sys.problem.nb_species());
368 }
369}