From 8be9ab9767e9acac1f86e1339660d2d9c2e46de0 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 14 May 2026 16:57:38 +0100 Subject: [PATCH 1/7] WIP: Starting on Lotka-Volterra --- .../src/stdlib/analyses/ode/lotka_volterra.rs | 421 ++++++++++++------ packages/catlog/src/stdlib/models.rs | 6 +- 2 files changed, 281 insertions(+), 146 deletions(-) diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 9a1853f16..c00fda5cb 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -4,27 +4,109 @@ //! [`lotka_volterra_analysis`](SignedCoefficientBuilder::lotka_volterra_analysis). use std::collections::HashMap; -use std::hash::Hash; -use std::ops::Add; - -use indexmap::IndexMap; -use itertools::Itertools; -use nalgebra::{DMatrix, DVector, Scalar}; -use num_traits::{One, Zero}; +use std::rc::Rc; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -use super::{ODEAnalysis, Parameter, SignedCoefficientBuilder}; -use crate::simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}; +use super::Parameter; +use crate::dbl::modal::List; +use crate::dbl::model::{FpDblModel, ModalDblModel, ModalOb, MutDblModel}; +use crate::one::Path; +use crate::simulate::ode::PolynomialSystem; +use crate::stdlib::analyses::ode::PolynomialODEAnalysis; +use crate::stdlib::th_signed_polynomial_ode_system; +use crate::zero::{name, name_seg}; use crate::{ dbl::model::DiscreteDblModel, one::QualifiedPath, - zero::{QualifiedName, alg::Polynomial, rig::Monomial}, + zero::{QualifiedName}, }; +/// TODO: documentation +pub struct CLDLotkaVolterraAnalysis { + /// Object type for variables. + pub var_ob_type: QualifiedName, + /// Morphism type for positive links. + pub pos_link_type: QualifiedPath, + /// Morphism type for negative links. + pub neg_link_type: QualifiedPath, +} + +impl Default for CLDLotkaVolterraAnalysis { + fn default() -> Self { + let ob_type = name("Object"); + Self { + var_ob_type: ob_type.clone(), + pos_link_type: Path::Id(ob_type.clone()), + neg_link_type: Path::single(name("Negative")), + } + } +} + +impl CLDLotkaVolterraAnalysis { + /// Creates a Lotka-Volterra system with symbolic rate coefficients. + /// + /// A system of ODEs that is affine in its *logarithmic* derivative. These are + /// sometimes called the "generalized Lotka-Volterra equations." For more, see + /// [Wikipedia](https://en.wikipedia.org/wiki/Generalized_Lotka%E2%80%93Volterra_equation) + /// and [our paper on regulatory networks](crate::refs::RegNets). + pub fn build_system( + &self, + model: &DiscreteDblModel, + ) -> PolynomialSystem, i8> { + let ode_theory = Rc::new(th_signed_polynomial_ode_system()); + let mut ode_model = ModalDblModel::new(ode_theory); + + let ode_analysis = PolynomialODEAnalysis::default(); + let ode_ob_type = ode_analysis.variable_ob_type; + let ode_pos_cont_type = ode_analysis.positive_contribution_mor_type; + let ode_neg_cont_type = ode_analysis.negative_contribution_mor_type; + + // Each variable in the CLD gives a variable in the ODE system *as well as* + // its growth contribution: (d/dt)x += x. + for var in model.ob_generators_with_type(&self.var_ob_type) { + // Add the variable to the ODE system. + ode_model.add_ob(var.clone(), ode_ob_type.clone()); + + // Add the growth contribution to the ODE system. + let var_object = ModalOb::Generator(var.clone()); + let var_name = var.clone().snoc(name_seg("Growth")); + ode_model.add_mor(var_name, var_object.clone(), var_object, ode_pos_cont_type.clone()); + } + + // Links in the CLD give contributions to the ODEs governing their *codomain*, namely + // x -> y gives (d/dt)y += xy. Each positive link in the CLD gives a positive contribution + // and each negative link a negative contribution. + for link in model.mor_generators_with_type(&self.pos_link_type) { + let dom = model.get_dom(&link).unwrap(); + let cod = model.get_cod(&link).unwrap(); + let dom_object = ModalOb::Generator(dom.clone()); + let cod_object = ModalOb::Generator(cod.clone()); + + let term = ModalOb::List(List::Symmetric, vec![dom_object.clone(), cod_object.clone()]); + let interaction_name = + dom.clone().snoc(name_seg("Increases")).snoc(cod.clone().only().unwrap()); + ode_model.add_mor(interaction_name, term, cod_object, ode_pos_cont_type.clone()); + } + for link in model.mor_generators_with_type(&self.neg_link_type) { + let dom = model.get_dom(&link).unwrap(); + let cod = model.get_cod(&link).unwrap(); + let dom_object = ModalOb::Generator(dom.clone()); + let cod_object = ModalOb::Generator(cod.clone()); + + let term = ModalOb::List(List::Symmetric, vec![dom_object.clone(), cod_object.clone()]); + let interaction_name = + dom.clone().snoc(name_seg("Decreases")).snoc(cod.clone().only().unwrap()); + ode_model.add_mor(interaction_name, term, cod_object, ode_neg_cont_type.clone()); + } + + PolynomialODEAnalysis::default().build_system(&ode_model) + } +} + /// Data defining a Lotka-Volterra ODE problem for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] @@ -49,144 +131,195 @@ pub struct LotkaVolterraProblemData { duration: f32, } -/// Construct a Lotka-Volterra dynamical system. -/// -/// A system of ODEs that is affine in its *logarithmic* derivative. These are -/// sometimes called the "generalized Lotka-Volterra equations." For more, see -/// [Wikipedia](https://en.wikipedia.org/wiki/Generalized_Lotka%E2%80%93Volterra_equation). -pub fn lotka_volterra_system( - vars: &[Var], - interaction_coeffs: DMatrix, - growth_rates: DVector, -) -> PolynomialSystem -where - Var: Clone + Hash + Ord, - Coef: Clone + Add + One + Scalar + Zero, -{ - let system = PolynomialSystem { - components: interaction_coeffs - .row_iter() - .zip(vars) - .zip(&growth_rates) - .map(|((row, i), r)| { - ( - i.clone(), - Polynomial::<_, Coef, _>::generator(i.clone()) - * (row - .iter() - .zip(vars) - .map(|(a, j)| (a.clone(), Monomial::generator(j.clone()))) - .collect::>() - + r.clone()), - ) - }) - .collect(), - }; - system.normalize() -} +// /// Substitutes numerical rate coefficients into a symbolic mass-action system. +// pub fn extend_mass_action_scalars( +// sys: PolynomialSystem, i8>, +// data: &MassActionProblemData, +// ) -> PolynomialSystem { +// let sys = sys.extend_scalars(|poly| { +// poly.eval(|flow| match flow { +// FlowParameter::Balanced { transition } => { +// data.transition_rates.get(transition).cloned().unwrap_or_default() +// } +// FlowParameter::Unbalanced { direction, parameter } => match (direction, parameter) { +// (Direction::IncomingFlow, RateParameter::PerTransition { transition }) => { +// data.transition_production_rates.get(transition).cloned().unwrap_or_default() +// } +// (Direction::OutgoingFlow, RateParameter::PerTransition { transition }) => { +// data.transition_consumption_rates.get(transition).cloned().unwrap_or_default() +// } +// (Direction::IncomingFlow, RateParameter::PerPlace { transition, place }) => data +// .place_production_rates +// .get(transition) +// .and_then(|rate| rate.get(place)) +// .copied() +// .unwrap_or_default(), +// (Direction::OutgoingFlow, RateParameter::PerPlace { transition, place }) => data +// .place_consumption_rates +// .get(transition) +// .and_then(|rate| rate.get(place)) +// .copied() +// .unwrap_or_default(), +// }, +// }) +// }); -impl SignedCoefficientBuilder { - /// Lotka-Volterra ODE analysis for a model of a double theory. - /// - /// The main application we have in mind is the Lotka-Volterra ODE semantics for - /// signed graphs described in our [paper on regulatory - /// networks](crate::refs::RegNets). - pub fn lotka_volterra_analysis( - &self, - model: &DiscreteDblModel, - data: LotkaVolterraProblemData, - ) -> ODEAnalysis> { - let (system, ob_index) = self.lotka_volterra_system(model); - let n = ob_index.len(); - - let initial_values = ob_index - .keys() - .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); - let x0 = DVector::from_iterator(n, initial_values); - - let system = system - .extend_scalars(|poly| { - poly.eval(|id| { - data.interaction_coeffs - .get(id) - .or(data.growth_rates.get(id)) - .copied() - .unwrap_or_default() - }) - }) - .to_numerical(); - let problem = ODEProblem::new(system, x0).end_time(data.duration); - ODEAnalysis::new(problem, ob_index) - } +// sys.normalize() +// } - /// Lotka-Volterra ODE system for an model of a double theory. - pub fn lotka_volterra_system( - &self, - model: &DiscreteDblModel, - ) -> ( - PolynomialSystem, u8>, - IndexMap, - ) { - let (matrix, ob_index) = self.build_matrix(model); - let n = ob_index.len(); - - let growth_rate_params = ob_index - .keys() - .map(|ob| [(1.0, Monomial::generator(ob.clone()))].into_iter().collect()); - let b = DVector::from_iterator(n, growth_rate_params); - - let system = lotka_volterra_system(&ob_index.keys().cloned().collect_vec(), matrix, b); - (system, ob_index) - } -} +// /// Builds the numerical ODE analysis for a mass-action system whose scalars have been substituted. +// pub fn into_mass_action_analysis( +// sys: PolynomialSystem, +// data: MassActionProblemData, +// ) -> ODEAnalysis> { +// let ob_index: IndexMap<_, _> = +// sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect(); +// let n = ob_index.len(); -#[cfg(test)] -mod test { - use expect_test::expect; - use std::rc::Rc; +// let initial_values = ob_index +// .keys() +// .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); +// let x0 = DVector::from_iterator(n, initial_values); - use super::*; - use crate::stdlib; - use crate::{one::Path, zero::name}; +// let num_sys = sys.to_numerical(); +// let problem = ODEProblem::new(num_sys, x0).end_time(data.duration); - fn builder() -> SignedCoefficientBuilder { - SignedCoefficientBuilder::new(name("Object")) - .add_positive(Path::Id(name("Object"))) - .add_negative(Path::single(name("Negative"))) - } +// ODEAnalysis::new(problem, ob_index) +// } - #[test] - fn predator_prey_symbolic() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); - let (sys, _) = builder().lotka_volterra_system(&neg_feedback); - let sys = sys.extend_scalars(|coef| coef.map_variables(|name| format!("Param({name})"))); - let expected = expect!([r#" - dx = Param(x) x - Param(negative) x y - dy = Param(positive) x y + Param(y) y - "#]); - expected.assert_eq(&sys.to_string()); - } +// pub fn lotka_volterra_system( +// vars: &[Var], +// interaction_coeffs: DMatrix, +// growth_rates: DVector, +// ) -> PolynomialSystem +// where +// Var: Clone + Hash + Ord, +// Coef: Clone + Add + One + Scalar + Zero, +// { +// let system = PolynomialSystem { +// components: interaction_coeffs +// .row_iter() +// .zip(vars) +// .zip(&growth_rates) +// .map(|((row, i), r)| { +// ( +// i.clone(), +// Polynomial::<_, Coef, _>::generator(i.clone()) +// * (row +// .iter() +// .zip(vars) +// .map(|(a, j)| (a.clone(), Monomial::generator(j.clone()))) +// .collect::>() +// + r.clone()), +// ) +// }) +// .collect(), +// }; +// system.normalize() +// } - #[test] - fn predator_prey_numerical() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); - - let data = LotkaVolterraProblemData { - interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] - .into_iter() - .collect(), - growth_rates: [(name("x"), 2.0), (name("y"), -1.0)].into_iter().collect(), - initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), - duration: 10.0, - }; - - let sys = builder().lotka_volterra_analysis(&neg_feedback, data).problem.system; - let expected = expect!([r#" - dx0 = 2 x0 - x0 x1 - dx1 = x0 x1 - x1 - "#]); - expected.assert_eq(&sys.to_string()); - } -} +// impl SignedCoefficientBuilder { +// /// Lotka-Volterra ODE analysis for a model of a double theory. +// /// +// /// The main application we have in mind is the Lotka-Volterra ODE semantics for +// /// signed graphs described in our [paper on regulatory +// /// networks](crate::refs::RegNets). +// pub fn lotka_volterra_analysis( +// &self, +// model: &DiscreteDblModel, +// data: LotkaVolterraProblemData, +// ) -> ODEAnalysis> { +// let (system, ob_index) = self.lotka_volterra_system(model); +// let n = ob_index.len(); + +// let initial_values = ob_index +// .keys() +// .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); +// let x0 = DVector::from_iterator(n, initial_values); + +// let system = system +// .extend_scalars(|poly| { +// poly.eval(|id| { +// data.interaction_coeffs +// .get(id) +// .or(data.growth_rates.get(id)) +// .copied() +// .unwrap_or_default() +// }) +// }) +// .to_numerical(); +// let problem = ODEProblem::new(system, x0).end_time(data.duration); +// ODEAnalysis::new(problem, ob_index) +// } + +// /// Lotka-Volterra ODE system for an model of a double theory. +// pub fn lotka_volterra_system( +// &self, +// model: &DiscreteDblModel, +// ) -> ( +// PolynomialSystem, u8>, +// IndexMap, +// ) { +// let (matrix, ob_index) = self.build_matrix(model); +// let n = ob_index.len(); + +// let growth_rate_params = ob_index +// .keys() +// .map(|ob| [(1.0, Monomial::generator(ob.clone()))].into_iter().collect()); +// let b = DVector::from_iterator(n, growth_rate_params); + +// let system = lotka_volterra_system(&ob_index.keys().cloned().collect_vec(), matrix, b); +// (system, ob_index) +// } +// } + +// #[cfg(test)] +// mod test { +// use expect_test::expect; +// use std::rc::Rc; + +// use super::*; +// use crate::stdlib; +// use crate::{one::Path, zero::name}; + +// fn builder() -> SignedCoefficientBuilder { +// SignedCoefficientBuilder::new(name("Object")) +// .add_positive(Path::Id(name("Object"))) +// .add_negative(Path::single(name("Negative"))) +// } + +// #[test] +// fn predator_prey_symbolic() { +// let th = Rc::new(stdlib::theories::th_signed_category()); +// let neg_feedback = stdlib::models::negative_feedback(th); +// let (sys, _) = builder().lotka_volterra_system(&neg_feedback); +// let sys = sys.extend_scalars(|coef| coef.map_variables(|name| format!("Param({name})"))); +// let expected = expect!([r#" +// dx = Param(x) x - Param(negative) x y +// dy = Param(positive) x y + Param(y) y +// "#]); +// expected.assert_eq(&sys.to_string()); +// } + +// #[test] +// fn predator_prey_numerical() { +// let th = Rc::new(stdlib::theories::th_signed_category()); +// let neg_feedback = stdlib::models::negative_feedback(th); + +// let data = LotkaVolterraProblemData { +// interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] +// .into_iter() +// .collect(), +// growth_rates: [(name("x"), 2.0), (name("y"), -1.0)].into_iter().collect(), +// initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), +// duration: 10.0, +// }; + +// let sys = builder().lotka_volterra_analysis(&neg_feedback, data).problem.system; +// let expected = expect!([r#" +// dx0 = 2 x0 - x0 x1 +// dx1 = x0 x1 - x1 +// "#]); +// expected.assert_eq(&sys.to_string()); +// } +// } diff --git a/packages/catlog/src/stdlib/models.rs b/packages/catlog/src/stdlib/models.rs index a75bc342c..49d4dfdd4 100644 --- a/packages/catlog/src/stdlib/models.rs +++ b/packages/catlog/src/stdlib/models.rs @@ -167,7 +167,8 @@ pub fn sir_petri(th: Rc>) -> ModalDblModel { model } -/// An example of (unsigned) Lotka–Volterra dynamics viewed as a non-unital theory for a symmetric multicategory. +/// An example of (unsigned) Lotka–Volterra dynamics viewed as a non-unital theory for +/// a symmetric multicategory. pub fn unsigned_lotka_volterra_dynamics( th: Rc>, ) -> ModalDblModel { @@ -175,7 +176,8 @@ pub fn unsigned_lotka_volterra_dynamics( let mor_type: ModalMorType = ModeApp::new(name("Contribution")).into(); let mut model = ModalDblModel::new(th); - // A two-level predator-prey model, but where (in absence of signed arrows) all interactions have positive coefficients. + // A two-level predator-prey model, but where (in absence of signed arrows) all + // interactions have positive coefficients. let (a, b, c) = (name("A"), name("B"), name("C")); model.add_ob(a.clone(), ob_type.clone()); From 5403fc71476a8b7885bf77ebaecb8a86d65b980a Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 14 May 2026 19:17:57 +0100 Subject: [PATCH 2/7] WIP: Failing tests --- packages/catlog-wasm/src/analyses.rs | 44 ++- packages/catlog-wasm/src/latex.rs | 32 ++ packages/catlog-wasm/src/theories.rs | 21 +- .../src/stdlib/analyses/ode/lotka_volterra.rs | 332 +++++++----------- .../src/stdlib/analyses/ode/mass_action.rs | 12 +- 5 files changed, 223 insertions(+), 218 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 57fee9811..46ef4dde8 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -7,6 +7,8 @@ use catlog::simulate::ode::PolynomialSystem; use catlog::stdlib::analyses::ode; use catlog::zero::QualifiedName; +use crate::latex::latex_mor_names_lotka_volterra; + use super::latex::{LatexEquations, latex_mor_names, latex_mor_names_mass_action, latex_ob_names}; use super::model::DblModel; use super::result::JsResult; @@ -74,8 +76,8 @@ pub(crate) fn polynomial_ode_simulation( }) } -/// The mass-action analysis is currently implemented for Petri nets and stock-flow -/// diagrams, and we can avoid some code reduplication by making this explicit. +/// Mass-action analysis is currently implemented for Petri nets and stock-flow diagrams +/// and we can avoid some code reduplication by making this explicit. pub enum MassActionAnalysisLogic { /// The modal theory of Petri nets. PetriNet, @@ -143,3 +145,41 @@ pub(crate) fn mass_action_simulation( latex_equations: LatexEquations(latex_equations), }) } + +/// Generates the PolynomialSystem for Lotka-Volterra dynamics. +fn lotka_volterra_system( + model: &DblModel, +) -> Result, i8>, String> { + let realised_model = model.discrete()?; + let analysis = ode::CLDLotkaVolterraAnalysis::default(); + Ok(analysis.build_system(realised_model)) +} + +/// Generates Lotka-Volterra equations for the system. +pub(crate) fn lotka_volterra_equations( + model: &DblModel, +) -> Result { + let sys = lotka_volterra_system(model); + let equations = sys? + .map_variables(latex_ob_names(model)) + .extend_scalars(|param| param.map_variables(latex_mor_names_lotka_volterra(model))) + .to_latex_equations(); + Ok(LatexEquations(equations)) +} + +/// Simulates Lotka-Volterra ODEs. +pub(crate) fn lotka_volterra_simulation( + model: &DblModel, + data: ode::LotkaVolterraProblemData, +) -> Result { + let sys = lotka_volterra_system(model); + let sys_extended_scalars = ode::extend_lotka_volterra_scalars(sys?, &data); + let latex_equations = + sys_extended_scalars.map_variables(latex_ob_names(model)).to_latex_equations(); + let analysis = ode::into_lotka_volterra_analysis(sys_extended_scalars, data); + let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); + Ok(ODEResultWithEquations { + solution: ODEResult(solution.into()), + latex_equations: LatexEquations(latex_equations), + }) +} diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 848494f22..e21f2d92a 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -100,6 +100,38 @@ pub(crate) fn latex_mor_names_mass_action( } } +/// Creates a closure that formats morphism names for mass-action LaTeX output. +/// +/// When a morphism has a label, it is used directly. When unnamed, the label +/// falls back to the domain→codomain format (e.g., `X \to Y`). +pub(crate) fn latex_mor_names_lotka_volterra( + model: &DblModel, +) -> impl Fn(&ode::LotkaVolterraParameter) -> String { + // Returns a LaTeX fragment for a transition, suitable for use as a subscript. + // Named morphisms produce `\text{name}`, unnamed ones produce + // `\text{dom} \to \text{cod}` so that `\to` is in math mode. + let transition_subscript = |transition: &QualifiedName| -> String { + if let Some(label) = model.mor_namespace.label(transition) { + format!("\\text{{{label}}}") + } else { + let (dom, cod) = model + .mor_generator_dom_cod_label_strings(transition) + .expect("Morphism in equation system should have domain and codomain"); + format!("\\text{{{dom}}} \\to \\text{{{cod}}}") + } + }; + + move |id: &ode::LotkaVolterraParameter| match id { + ode::LotkaVolterraParameter::Growth { variable } => { + format!("g_{{{variable}}}") + }, + ode::LotkaVolterraParameter::Interaction { source: _, link, target: _ } => { + let sub = transition_subscript(link); + format!("k_{{{sub}}}") + }, + } +} + #[cfg(test)] mod tests { use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index d7af351a1..0d81e76eb 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -156,16 +156,17 @@ impl ThSignedCategory { &self, model: &DblModel, data: analyses::ode::LotkaVolterraProblemData, - ) -> Result { - Ok(ODEResult( - analyses::ode::SignedCoefficientBuilder::new(name("Object")) - .add_positive(Path::Id(name("Object"))) - .add_negative(name("Negative").into()) - .lotka_volterra_analysis(model.discrete()?, data) - .solve_with_defaults() - .map_err(|err| format!("{err:?}")) - .into(), - )) + ) -> Result { + lotka_volterra_simulation(model, data) + } + + /// Simulate the Lotka-Volterra system derived from a model. + #[wasm_bindgen(js_name = "lotkaVolterraEquations")] + pub fn lotka_volterra_equations( + &self, + model: &DblModel, + ) -> Result { + lotka_volterra_equations(model) } /// Simulate the linear ODE system derived from a model. diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index c00fda5cb..78a2d7afb 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -4,8 +4,11 @@ //! [`lotka_volterra_analysis`](SignedCoefficientBuilder::lotka_volterra_analysis). use std::collections::HashMap; +use std::fmt; use std::rc::Rc; +use indexmap::IndexMap; +use nalgebra::DVector; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] @@ -15,15 +18,37 @@ use super::Parameter; use crate::dbl::modal::List; use crate::dbl::model::{FpDblModel, ModalDblModel, ModalOb, MutDblModel}; use crate::one::Path; -use crate::simulate::ode::PolynomialSystem; -use crate::stdlib::analyses::ode::PolynomialODEAnalysis; +use crate::simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}; +use crate::stdlib::analyses::ode::{ODEAnalysis, PolynomialODEAnalysis}; use crate::stdlib::th_signed_polynomial_ode_system; use crate::zero::{name, name_seg}; -use crate::{ - dbl::model::DiscreteDblModel, - one::QualifiedPath, - zero::{QualifiedName}, -}; +use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; + +/// TODO: documentation +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum LotkaVolterraParameter { + Growth { + variable: QualifiedName, + }, + Interaction { + source: QualifiedName, + link: QualifiedName, + target: QualifiedName, + }, +} + +impl fmt::Display for LotkaVolterraParameter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + Self::Growth { variable } => { + write!(f, "Growth({})", variable) + } + Self::Interaction { source, link, target } => { + write!(f, "Interaction({}: {} -> {})", link, source, target) + } + } + } +} /// TODO: documentation pub struct CLDLotkaVolterraAnalysis { @@ -56,7 +81,7 @@ impl CLDLotkaVolterraAnalysis { pub fn build_system( &self, model: &DiscreteDblModel, - ) -> PolynomialSystem, i8> { + ) -> PolynomialSystem, i8> { let ode_theory = Rc::new(th_signed_polynomial_ode_system()); let mut ode_model = ModalDblModel::new(ode_theory); @@ -65,6 +90,9 @@ impl CLDLotkaVolterraAnalysis { let ode_pos_cont_type = ode_analysis.positive_contribution_mor_type; let ode_neg_cont_type = ode_analysis.negative_contribution_mor_type; + let mut associated_parameters: HashMap = + HashMap::new(); + // Each variable in the CLD gives a variable in the ODE system *as well as* // its growth contribution: (d/dt)x += x. for var in model.ob_generators_with_type(&self.var_ob_type) { @@ -73,7 +101,10 @@ impl CLDLotkaVolterraAnalysis { // Add the growth contribution to the ODE system. let var_object = ModalOb::Generator(var.clone()); + let var_parameter = LotkaVolterraParameter::Growth { variable: var.clone() }; let var_name = var.clone().snoc(name_seg("Growth")); + + associated_parameters.insert(var_name.clone(), var_parameter); ode_model.add_mor(var_name, var_object.clone(), var_object, ode_pos_cont_type.clone()); } @@ -87,8 +118,12 @@ impl CLDLotkaVolterraAnalysis { let cod_object = ModalOb::Generator(cod.clone()); let term = ModalOb::List(List::Symmetric, vec![dom_object.clone(), cod_object.clone()]); + let interaction_parameter = + LotkaVolterraParameter::Interaction { source: dom.clone(), link, target: cod.clone() }; let interaction_name = dom.clone().snoc(name_seg("Increases")).snoc(cod.clone().only().unwrap()); + + associated_parameters.insert(interaction_name.clone(), interaction_parameter); ode_model.add_mor(interaction_name, term, cod_object, ode_pos_cont_type.clone()); } for link in model.mor_generators_with_type(&self.neg_link_type) { @@ -98,12 +133,17 @@ impl CLDLotkaVolterraAnalysis { let cod_object = ModalOb::Generator(cod.clone()); let term = ModalOb::List(List::Symmetric, vec![dom_object.clone(), cod_object.clone()]); + let interaction_parameter = + LotkaVolterraParameter::Interaction { source: dom.clone(), link, target: cod.clone() }; let interaction_name = dom.clone().snoc(name_seg("Decreases")).snoc(cod.clone().only().unwrap()); + + associated_parameters.insert(interaction_name.clone(), interaction_parameter); ode_model.add_mor(interaction_name, term, cod_object, ode_neg_cont_type.clone()); } - PolynomialODEAnalysis::default().build_system(&ode_model) + PolynomialODEAnalysis::default() + .build_system_custom_parameters(&ode_model, associated_parameters) } } @@ -131,195 +171,85 @@ pub struct LotkaVolterraProblemData { duration: f32, } -// /// Substitutes numerical rate coefficients into a symbolic mass-action system. -// pub fn extend_mass_action_scalars( -// sys: PolynomialSystem, i8>, -// data: &MassActionProblemData, -// ) -> PolynomialSystem { -// let sys = sys.extend_scalars(|poly| { -// poly.eval(|flow| match flow { -// FlowParameter::Balanced { transition } => { -// data.transition_rates.get(transition).cloned().unwrap_or_default() -// } -// FlowParameter::Unbalanced { direction, parameter } => match (direction, parameter) { -// (Direction::IncomingFlow, RateParameter::PerTransition { transition }) => { -// data.transition_production_rates.get(transition).cloned().unwrap_or_default() -// } -// (Direction::OutgoingFlow, RateParameter::PerTransition { transition }) => { -// data.transition_consumption_rates.get(transition).cloned().unwrap_or_default() -// } -// (Direction::IncomingFlow, RateParameter::PerPlace { transition, place }) => data -// .place_production_rates -// .get(transition) -// .and_then(|rate| rate.get(place)) -// .copied() -// .unwrap_or_default(), -// (Direction::OutgoingFlow, RateParameter::PerPlace { transition, place }) => data -// .place_consumption_rates -// .get(transition) -// .and_then(|rate| rate.get(place)) -// .copied() -// .unwrap_or_default(), -// }, -// }) -// }); - -// sys.normalize() -// } - -// /// Builds the numerical ODE analysis for a mass-action system whose scalars have been substituted. -// pub fn into_mass_action_analysis( -// sys: PolynomialSystem, -// data: MassActionProblemData, -// ) -> ODEAnalysis> { -// let ob_index: IndexMap<_, _> = -// sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect(); -// let n = ob_index.len(); - -// let initial_values = ob_index -// .keys() -// .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); -// let x0 = DVector::from_iterator(n, initial_values); - -// let num_sys = sys.to_numerical(); -// let problem = ODEProblem::new(num_sys, x0).end_time(data.duration); - -// ODEAnalysis::new(problem, ob_index) -// } - -// pub fn lotka_volterra_system( -// vars: &[Var], -// interaction_coeffs: DMatrix, -// growth_rates: DVector, -// ) -> PolynomialSystem -// where -// Var: Clone + Hash + Ord, -// Coef: Clone + Add + One + Scalar + Zero, -// { -// let system = PolynomialSystem { -// components: interaction_coeffs -// .row_iter() -// .zip(vars) -// .zip(&growth_rates) -// .map(|((row, i), r)| { -// ( -// i.clone(), -// Polynomial::<_, Coef, _>::generator(i.clone()) -// * (row -// .iter() -// .zip(vars) -// .map(|(a, j)| (a.clone(), Monomial::generator(j.clone()))) -// .collect::>() -// + r.clone()), -// ) -// }) -// .collect(), -// }; -// system.normalize() -// } - -// impl SignedCoefficientBuilder { -// /// Lotka-Volterra ODE analysis for a model of a double theory. -// /// -// /// The main application we have in mind is the Lotka-Volterra ODE semantics for -// /// signed graphs described in our [paper on regulatory -// /// networks](crate::refs::RegNets). -// pub fn lotka_volterra_analysis( -// &self, -// model: &DiscreteDblModel, -// data: LotkaVolterraProblemData, -// ) -> ODEAnalysis> { -// let (system, ob_index) = self.lotka_volterra_system(model); -// let n = ob_index.len(); - -// let initial_values = ob_index -// .keys() -// .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); -// let x0 = DVector::from_iterator(n, initial_values); - -// let system = system -// .extend_scalars(|poly| { -// poly.eval(|id| { -// data.interaction_coeffs -// .get(id) -// .or(data.growth_rates.get(id)) -// .copied() -// .unwrap_or_default() -// }) -// }) -// .to_numerical(); -// let problem = ODEProblem::new(system, x0).end_time(data.duration); -// ODEAnalysis::new(problem, ob_index) -// } - -// /// Lotka-Volterra ODE system for an model of a double theory. -// pub fn lotka_volterra_system( -// &self, -// model: &DiscreteDblModel, -// ) -> ( -// PolynomialSystem, u8>, -// IndexMap, -// ) { -// let (matrix, ob_index) = self.build_matrix(model); -// let n = ob_index.len(); - -// let growth_rate_params = ob_index -// .keys() -// .map(|ob| [(1.0, Monomial::generator(ob.clone()))].into_iter().collect()); -// let b = DVector::from_iterator(n, growth_rate_params); - -// let system = lotka_volterra_system(&ob_index.keys().cloned().collect_vec(), matrix, b); -// (system, ob_index) -// } -// } - -// #[cfg(test)] -// mod test { -// use expect_test::expect; -// use std::rc::Rc; - -// use super::*; -// use crate::stdlib; -// use crate::{one::Path, zero::name}; - -// fn builder() -> SignedCoefficientBuilder { -// SignedCoefficientBuilder::new(name("Object")) -// .add_positive(Path::Id(name("Object"))) -// .add_negative(Path::single(name("Negative"))) -// } - -// #[test] -// fn predator_prey_symbolic() { -// let th = Rc::new(stdlib::theories::th_signed_category()); -// let neg_feedback = stdlib::models::negative_feedback(th); -// let (sys, _) = builder().lotka_volterra_system(&neg_feedback); -// let sys = sys.extend_scalars(|coef| coef.map_variables(|name| format!("Param({name})"))); -// let expected = expect!([r#" -// dx = Param(x) x - Param(negative) x y -// dy = Param(positive) x y + Param(y) y -// "#]); -// expected.assert_eq(&sys.to_string()); -// } - -// #[test] -// fn predator_prey_numerical() { -// let th = Rc::new(stdlib::theories::th_signed_category()); -// let neg_feedback = stdlib::models::negative_feedback(th); - -// let data = LotkaVolterraProblemData { -// interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] -// .into_iter() -// .collect(), -// growth_rates: [(name("x"), 2.0), (name("y"), -1.0)].into_iter().collect(), -// initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), -// duration: 10.0, -// }; - -// let sys = builder().lotka_volterra_analysis(&neg_feedback, data).problem.system; -// let expected = expect!([r#" -// dx0 = 2 x0 - x0 x1 -// dx1 = x0 x1 - x1 -// "#]); -// expected.assert_eq(&sys.to_string()); -// } -// } +/// Substitutes numerical rate coefficients into a symbolic Lotka-Volterra system. +pub fn extend_lotka_volterra_scalars( + sys: PolynomialSystem, i8>, + data: &LotkaVolterraProblemData, +) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|param| match param { + LotkaVolterraParameter::Growth { variable } => { + data.growth_rates.get(variable).cloned().unwrap_or_default() + } + LotkaVolterraParameter::Interaction { source: _, link, target: _ } => { + data.interaction_coeffs.get(link).cloned().unwrap_or_default() + }, + }) + }); + + sys.normalize() +} + +/// Builds the numerical ODE analysis for a Lotka-Volterra system whose scalars have been substituted. +pub fn into_lotka_volterra_analysis( + sys: PolynomialSystem, + data: LotkaVolterraProblemData, +) -> ODEAnalysis> { + let ob_index: IndexMap<_, _> = + sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect(); + let n = ob_index.len(); + + let initial_values = ob_index + .keys() + .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); + let x0 = DVector::from_iterator(n, initial_values); + + let num_sys = sys.to_numerical(); + let problem = ODEProblem::new(num_sys, x0).end_time(data.duration); + + ODEAnalysis::new(problem, ob_index) +} + +#[cfg(test)] +mod test { + use expect_test::expect; + use std::rc::Rc; + + use super::*; + use crate::stdlib::{models::*, theories::*}; + + #[test] + fn predator_prey_symbolic() { + let th = Rc::new(th_signed_category()); + let neg_feedback = negative_feedback(th); + let sys = CLDLotkaVolterraAnalysis::default().build_system(&neg_feedback); + let expected = expect!([r#" + dx = Growth(x) x - Interaction(negative: y -> x) x y + dy = Interaction(positive: x -> y) x y + Growth(y) y + "#]); + expected.assert_eq(&sys.to_string()); + } + + #[test] + fn predator_prey_numerical() { + let th = Rc::new(th_signed_category()); + let neg_feedback = negative_feedback(th); + + let data = LotkaVolterraProblemData { + interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] + .into_iter() + .collect(), + growth_rates: [(name("x"), 2.0), (name("y"), -1.0)].into_iter().collect(), + initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), + duration: 10.0, + }; + + let sys = CLDLotkaVolterraAnalysis::default().build_system(&neg_feedback); + let analysis = extend_lotka_volterra_scalars(sys, &data); + let expected = expect!([r#" + dx0 = 2 x0 - x0 x1 + dx1 = x0 x1 - x1 + "#]); + expected.assert_eq(&analysis.to_string()); + } +} diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 00a9987cd..ad43f660b 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -114,28 +114,28 @@ pub enum Direction { impl fmt::Display for FlowParameter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self { - FlowParameter::Balanced { transition: trans } => { + Self::Balanced { transition: trans } => { write!(f, "{}", trans) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::IncomingFlow, parameter: RateParameter::PerTransition { transition: trans }, } => { write!(f, "Incoming({})", trans) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::IncomingFlow, parameter: RateParameter::PerPlace { transition: trans, place: output }, } => { write!(f, "([{}]->{})", trans, output) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::OutgoingFlow, parameter: RateParameter::PerTransition { transition: trans }, } => { write!(f, "Outgoing({})", trans) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::OutgoingFlow, parameter: RateParameter::PerPlace { transition: trans, place: input }, } => { @@ -183,6 +183,8 @@ impl PetriNetMassActionAnalysis { let ode_ob_type = ode_analysis.variable_ob_type; let ode_pos_cont_type = ode_analysis.positive_contribution_mor_type; let ode_neg_cont_type = ode_analysis.negative_contribution_mor_type; + + // The parameters for terms will be bespoke, according to the `mass_conservation_type`. let mut associated_parameters: HashMap = HashMap::new(); // For every object in our Petri net (i.e. of type `place_ob_type`) we want to create From e11c940b81b1d8b9bf96f9989da8ef07443bc6ab Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 14 May 2026 19:56:24 +0100 Subject: [PATCH 3/7] FIX: Lotka-Volterra tests passing --- packages/catlog-wasm/src/analyses.rs | 7 +-- packages/catlog-wasm/src/latex.rs | 6 +- packages/catlog-wasm/src/theories.rs | 5 +- .../src/stdlib/analyses/ode/lotka_volterra.rs | 62 ++++++++++--------- 4 files changed, 41 insertions(+), 39 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 46ef4dde8..3a2791d8a 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -149,16 +149,15 @@ pub(crate) fn mass_action_simulation( /// Generates the PolynomialSystem for Lotka-Volterra dynamics. fn lotka_volterra_system( model: &DblModel, -) -> Result, i8>, String> { +) -> Result, i8>, String> +{ let realised_model = model.discrete()?; let analysis = ode::CLDLotkaVolterraAnalysis::default(); Ok(analysis.build_system(realised_model)) } /// Generates Lotka-Volterra equations for the system. -pub(crate) fn lotka_volterra_equations( - model: &DblModel, -) -> Result { +pub(crate) fn lotka_volterra_equations(model: &DblModel) -> Result { let sys = lotka_volterra_system(model); let equations = sys? .map_variables(latex_ob_names(model)) diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index e21f2d92a..f87b43d4b 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -124,11 +124,11 @@ pub(crate) fn latex_mor_names_lotka_volterra( move |id: &ode::LotkaVolterraParameter| match id { ode::LotkaVolterraParameter::Growth { variable } => { format!("g_{{{variable}}}") - }, - ode::LotkaVolterraParameter::Interaction { source: _, link, target: _ } => { + } + ode::LotkaVolterraParameter::Interaction { link } => { let sub = transition_subscript(link); format!("k_{{{sub}}}") - }, + } } } diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index 0d81e76eb..cf7b0299b 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -162,10 +162,7 @@ impl ThSignedCategory { /// Simulate the Lotka-Volterra system derived from a model. #[wasm_bindgen(js_name = "lotkaVolterraEquations")] - pub fn lotka_volterra_equations( - &self, - model: &DblModel, - ) -> Result { + pub fn lotka_volterra_equations(&self, model: &DblModel) -> Result { lotka_volterra_equations(model) } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 78a2d7afb..c0781c7b2 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -3,10 +3,10 @@ //! The main entry point for this module is //! [`lotka_volterra_analysis`](SignedCoefficientBuilder::lotka_volterra_analysis). +use indexmap::IndexMap; use std::collections::HashMap; use std::fmt; use std::rc::Rc; -use indexmap::IndexMap; use nalgebra::DVector; #[cfg(feature = "serde")] @@ -21,19 +21,22 @@ use crate::one::Path; use crate::simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}; use crate::stdlib::analyses::ode::{ODEAnalysis, PolynomialODEAnalysis}; use crate::stdlib::th_signed_polynomial_ode_system; -use crate::zero::{name, name_seg}; +use crate::zero::name; use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; -/// TODO: documentation +/// Parameters in the Lotka-Volterra equations come in two flavours, corresponding to +/// either variables or links. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] pub enum LotkaVolterraParameter { + /// The parameter associated to a variable. Growth { + /// The variable. variable: QualifiedName, }, + /// The parameter associated to a link. Interaction { - source: QualifiedName, + /// The link. link: QualifiedName, - target: QualifiedName, }, } @@ -43,8 +46,8 @@ impl fmt::Display for LotkaVolterraParameter { Self::Growth { variable } => { write!(f, "Growth({})", variable) } - Self::Interaction { source, link, target } => { - write!(f, "Interaction({}: {} -> {})", link, source, target) + Self::Interaction { link } => { + write!(f, "Interaction({})", link) } } } @@ -96,16 +99,18 @@ impl CLDLotkaVolterraAnalysis { // Each variable in the CLD gives a variable in the ODE system *as well as* // its growth contribution: (d/dt)x += x. for var in model.ob_generators_with_type(&self.var_ob_type) { + // for var in model.ob_generators_with_type(&self.var_ob_type) { // Add the variable to the ODE system. ode_model.add_ob(var.clone(), ode_ob_type.clone()); // Add the growth contribution to the ODE system. let var_object = ModalOb::Generator(var.clone()); + let var_term = ModalOb::List(List::Symmetric, vec![var_object.clone()]); let var_parameter = LotkaVolterraParameter::Growth { variable: var.clone() }; - let var_name = var.clone().snoc(name_seg("Growth")); + let var_name = var; associated_parameters.insert(var_name.clone(), var_parameter); - ode_model.add_mor(var_name, var_object.clone(), var_object, ode_pos_cont_type.clone()); + ode_model.add_mor(var_name, var_term.clone(), var_object, ode_pos_cont_type.clone()); } // Links in the CLD give contributions to the ODEs governing their *codomain*, namely @@ -118,11 +123,9 @@ impl CLDLotkaVolterraAnalysis { let cod_object = ModalOb::Generator(cod.clone()); let term = ModalOb::List(List::Symmetric, vec![dom_object.clone(), cod_object.clone()]); - let interaction_parameter = - LotkaVolterraParameter::Interaction { source: dom.clone(), link, target: cod.clone() }; - let interaction_name = - dom.clone().snoc(name_seg("Increases")).snoc(cod.clone().only().unwrap()); - + let interaction_parameter = LotkaVolterraParameter::Interaction { link: link.clone() }; + let interaction_name = link; + associated_parameters.insert(interaction_name.clone(), interaction_parameter); ode_model.add_mor(interaction_name, term, cod_object, ode_pos_cont_type.clone()); } @@ -133,10 +136,8 @@ impl CLDLotkaVolterraAnalysis { let cod_object = ModalOb::Generator(cod.clone()); let term = ModalOb::List(List::Symmetric, vec![dom_object.clone(), cod_object.clone()]); - let interaction_parameter = - LotkaVolterraParameter::Interaction { source: dom.clone(), link, target: cod.clone() }; - let interaction_name = - dom.clone().snoc(name_seg("Decreases")).snoc(cod.clone().only().unwrap()); + let interaction_parameter = LotkaVolterraParameter::Interaction { link: link.clone() }; + let interaction_name = link; associated_parameters.insert(interaction_name.clone(), interaction_parameter); ode_model.add_mor(interaction_name, term, cod_object, ode_neg_cont_type.clone()); @@ -181,9 +182,9 @@ pub fn extend_lotka_volterra_scalars( LotkaVolterraParameter::Growth { variable } => { data.growth_rates.get(variable).cloned().unwrap_or_default() } - LotkaVolterraParameter::Interaction { source: _, link, target: _ } => { + LotkaVolterraParameter::Interaction { link } => { data.interaction_coeffs.get(link).cloned().unwrap_or_default() - }, + } }) }); @@ -221,11 +222,16 @@ mod test { #[test] fn predator_prey_symbolic() { let th = Rc::new(th_signed_category()); - let neg_feedback = negative_feedback(th); - let sys = CLDLotkaVolterraAnalysis::default().build_system(&neg_feedback); + // let model = negative_feedback(th); + let mut model = DiscreteDblModel::new(th); + model.add_ob(name("x"), name("Object")); + model.add_ob(name("y"), name("Object")); + model.add_mor(name("positive"), name("x"), name("y"), Path::Id(name("Object"))); + model.add_mor(name("negative"), name("y"), name("x"), name("Negative").into()); + let sys = CLDLotkaVolterraAnalysis::default().build_system(&model); let expected = expect!([r#" - dx = Growth(x) x - Interaction(negative: y -> x) x y - dy = Interaction(positive: x -> y) x y + Growth(y) y + dx = Growth(x) x - Interaction(negative) x y + dy = Interaction(positive) x y + Growth(y) y "#]); expected.assert_eq(&sys.to_string()); } @@ -233,7 +239,7 @@ mod test { #[test] fn predator_prey_numerical() { let th = Rc::new(th_signed_category()); - let neg_feedback = negative_feedback(th); + let model = negative_feedback(th); let data = LotkaVolterraProblemData { interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] @@ -244,11 +250,11 @@ mod test { duration: 10.0, }; - let sys = CLDLotkaVolterraAnalysis::default().build_system(&neg_feedback); + let sys = CLDLotkaVolterraAnalysis::default().build_system(&model); let analysis = extend_lotka_volterra_scalars(sys, &data); let expected = expect!([r#" - dx0 = 2 x0 - x0 x1 - dx1 = x0 x1 - x1 + dx = 2 x - x y + dy = x y - y "#]); expected.assert_eq(&analysis.to_string()); } From 2f556eabd36745bc8ddd10e2254a72fbfa46bb3c Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 15 May 2026 14:10:46 +0100 Subject: [PATCH 4/7] FIX: Working analysis (frontend) --- .../src/stdlib/analyses/lotka_volterra.tsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx index 9d6006800..34ed375b4 100644 --- a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx +++ b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx @@ -4,12 +4,14 @@ import { createNumericalColumn, FixedTableEditor, Foldable, + ExpandableTable, + KatexDisplay, } from "catcolab-ui-components"; import type { DblModel, LotkaVolterraProblemData, QualifiedName } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; -import { createModelODEPlot } from "./model_ode_plot"; +import { createModelODEPlot, createModelODEPlotWithEquations } from "./model_ode_plot"; import type { LotkaVolterraSimulator } from "./simulator_types"; import "./simulation.css"; @@ -78,11 +80,14 @@ export default function LotkaVolterra( }), ]; - const plotResult = createModelODEPlot( + const result = createModelODEPlotWithEquations( () => props.liveModel.validatedModel(), - (model: DblModel) => props.simulate(model, props.content), + (model) => props.simulate(model, props.content), ); + const plotResult = () => result()?.plotData; + const latexEquations = () => result()?.latexEquations ?? []; + return (
@@ -99,7 +104,20 @@ export default function LotkaVolterra(
- + + }, + { cell: () => }, + { cell: (row) => }, + ]} + /> + + + + ); } From bb5b4b6a80faa6894e56e230e2dec56f53c57bd8 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 15 May 2026 14:39:17 +0100 Subject: [PATCH 5/7] ENH: Lotka-Volterra equations --- packages/catlog-wasm/src/analyses.rs | 8 +++ .../src/stdlib/analyses/ode/lotka_volterra.rs | 62 ++++++++++++++++--- packages/frontend/src/stdlib/analyses.tsx | 32 +++++++++- .../src/stdlib/analyses/lotka_volterra.tsx | 4 +- .../analyses/lotka_volterra_equations.tsx | 36 +++++++++++ .../src/stdlib/analyses/simulator_types.ts | 18 ++++-- .../src/stdlib/theories/causal-loop.ts | 5 ++ .../frontend/src/stdlib/theories/reg-net.ts | 5 ++ 8 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 3a2791d8a..1f96a3166 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -156,6 +156,14 @@ fn lotka_volterra_system( Ok(analysis.build_system(realised_model)) } +/// The analysis data for polynomial ODE equations. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct LotkaVolterraEquationsData { + #[serde(rename = "trivialData")] + trivial_data: bool, +} + /// Generates Lotka-Volterra equations for the system. pub(crate) fn lotka_volterra_equations(model: &DblModel) -> Result { let sys = lotka_volterra_system(model); diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index c0781c7b2..8abb9dc51 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -53,7 +53,7 @@ impl fmt::Display for LotkaVolterraParameter { } } -/// TODO: documentation +/// Lotka-Volterra ODE analysis for causal loop diagrams (CLDs). pub struct CLDLotkaVolterraAnalysis { /// Object type for variables. pub var_ob_type: QualifiedName, @@ -217,17 +217,17 @@ mod test { use std::rc::Rc; use super::*; - use crate::stdlib::{models::*, theories::*}; + use crate::{ + simulate::ode::LatexEquation, + stdlib::{models::*, theories::*}, + }; + + // Symbolic tests. #[test] fn predator_prey_symbolic() { let th = Rc::new(th_signed_category()); - // let model = negative_feedback(th); - let mut model = DiscreteDblModel::new(th); - model.add_ob(name("x"), name("Object")); - model.add_ob(name("y"), name("Object")); - model.add_mor(name("positive"), name("x"), name("y"), Path::Id(name("Object"))); - model.add_mor(name("negative"), name("y"), name("x"), name("Negative").into()); + let model = negative_feedback(th); let sys = CLDLotkaVolterraAnalysis::default().build_system(&model); let expected = expect!([r#" dx = Growth(x) x - Interaction(negative) x y @@ -236,6 +236,52 @@ mod test { expected.assert_eq(&sys.to_string()); } + #[test] + fn complicated_symbolic() { + let th = Rc::new(th_signed_category()); + let mut model = DiscreteDblModel::new(th); + model.add_ob(name("a"), name("Object")); + model.add_ob(name("b"), name("Object")); + model.add_ob(name("c"), name("Object")); + model.add_ob(name("d"), name("Object")); + model.add_mor(name("f"), name("a"), name("b"), Path::Id(name("Object"))); + model.add_mor(name("g"), name("b"), name("a"), Path::Id(name("Object"))); + model.add_mor(name("h"), name("b"), name("a"), name("Negative").into()); + model.add_mor(name("i"), name("a"), name("c"), name("Negative").into()); + model.add_mor(name("j"), name("c"), name("d"), Path::Id(name("Object"))); + model.add_mor(name("k"), name("d"), name("b"), name("Negative").into()); + let sys = CLDLotkaVolterraAnalysis::default().build_system(&model); + let expected = expect!([r#" + da = Growth(a) a + (Interaction(g) - Interaction(h)) a b + db = Interaction(f) a b + Growth(b) b - Interaction(k) b d + dc = -Interaction(i) a c + Growth(c) c + dd = Interaction(j) c d + Growth(d) d + "#]); + expected.assert_eq(&sys.to_string()); + } + + // Test for LaTeX. + + #[test] + fn to_latex() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + let sys = CLDLotkaVolterraAnalysis::default().build_system(&model); + let expected = vec![ + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), + rhs: "Growth(x) \\cdot x - Interaction(negative) \\cdot x \\cdot y".to_string(), + }, + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), + rhs: "Interaction(positive) \\cdot x \\cdot y + Growth(y) \\cdot y".to_string(), + }, + ]; + assert_eq!(expected, sys.to_latex_equations()); + } + + // Numerical test. + #[test] fn predator_prey_numerical() { let th = Rc::new(th_signed_category()); diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 38b19a6a0..444bfc0b0 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -1,6 +1,7 @@ import { lazy } from "solid-js"; import type { + LotkaVolterraEquationsData, MassActionEquationsData, MorType, ObType, @@ -140,8 +141,8 @@ export function lotkaVolterra( ): ModelAnalysisMeta { const { id = "lotka-volterra", - name = "Lotka-Volterra dynamics", - description = "Simulate the system using a Lotka-Volterra ODE", + name = "Lotka–Volterra dynamics", + description = "Simulate the system using a Lotka–Volterra ODE", help = "lotka-volterra", simulate, } = options; @@ -162,6 +163,33 @@ export function lotkaVolterra( const LotkaVolterra = lazy(() => import("./analyses/lotka_volterra")); +export function lotkaVolterraEquations( + options: Partial & { + getEquations: Simulators.LotkaVolterraEquations; + }, +): ModelAnalysisMeta { + const { + id = "lotka-volterra-equations", + name = "Lotka–Volterra equations", + description = "Display the symbolic Lotka–Volterra dynamics equations", + help = "lotka-volterra-equations", + ...otherOptions + } = options; + return { + id, + name, + description, + help, + component: (props) => ( + + ), + initialContent: () => ({ + trivialData: true, + }), + }; +} +const LotkaVolterraEquationsDisplay = lazy(() => import("./analyses/lotka_volterra_equations")); + export function massAction( options: Partial & { ratesHaveGranularity: boolean; diff --git a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx index 34ed375b4..062f28189 100644 --- a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx +++ b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx @@ -7,11 +7,11 @@ import { ExpandableTable, KatexDisplay, } from "catcolab-ui-components"; -import type { DblModel, LotkaVolterraProblemData, QualifiedName } from "catlog-wasm"; +import type { LotkaVolterraProblemData, QualifiedName } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; -import { createModelODEPlot, createModelODEPlotWithEquations } from "./model_ode_plot"; +import { createModelODEPlotWithEquations } from "./model_ode_plot"; import type { LotkaVolterraSimulator } from "./simulator_types"; import "./simulation.css"; diff --git a/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx new file mode 100644 index 000000000..dcb6271d4 --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx @@ -0,0 +1,36 @@ +import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; +import { LotkaVolterraEquationsData } from "catlog-wasm"; +import type { ModelAnalysisProps } from "../../analysis"; +import { createModelODELatex } from "./model_ode_plot"; +import type { LotkaVolterraEquations } from "./simulator_types"; + +import "./simulation.css"; + +/** Display the symbolic mass-action dynamics equations for a model. */ +export default function LotkaVolterraEquationsDisplay( + props: ModelAnalysisProps & { + content: LotkaVolterraEquationsData; + getEquations: LotkaVolterraEquations; + title?: string; + }, +) { + const latexEquations = createModelODELatex( + () => props.liveModel.validatedModel(), + (model) => props.getEquations(model, props.content), + ); + + return ( +
+ + }, + { cell: () => }, + { cell: (row) => }, + ]} + /> +
+ ); +} diff --git a/packages/frontend/src/stdlib/analyses/simulator_types.ts b/packages/frontend/src/stdlib/analyses/simulator_types.ts index e5ac07d98..941442abc 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -4,6 +4,7 @@ import type { LatexEquations, LinearODEProblemData, LotkaVolterraProblemData, + LotkaVolterraEquationsData, MassActionEquationsData, MassActionProblemData, ODEResult, @@ -23,19 +24,26 @@ export type { export type KuramotoSimulator = (model: DblModel, data: KuramotoProblemData) => ODEResult; export type LinearODESimulator = (model: DblModel, data: LinearODEProblemData) => ODEResult; -export type LotkaVolterraSimulator = (model: DblModel, data: LotkaVolterraProblemData) => ODEResult; +export type LotkaVolterraSimulator = ( + model: DblModel, + data: LotkaVolterraProblemData, +) => ODEResultWithEquations; +export type LotkaVolterraEquations = ( + model: DblModel, + data: LotkaVolterraEquationsData, +) => LatexEquations; export type MassActionSimulator = ( model: DblModel, data: MassActionProblemData, ) => ODEResultWithEquations; -export type StochasticMassActionSimulator = ( - model: DblModel, - data: StochasticMassActionProblemData, -) => ODEResult; export type MassActionEquations = ( model: DblModel, data: MassActionEquationsData, ) => LatexEquations; +export type StochasticMassActionSimulator = ( + model: DblModel, + data: StochasticMassActionProblemData, +) => ODEResult; export type PolynomialODESimulator = ( model: DblModel, data: PolynomialODEProblemData, diff --git a/packages/frontend/src/stdlib/theories/causal-loop.ts b/packages/frontend/src/stdlib/theories/causal-loop.ts index 174d40108..b274b7661 100644 --- a/packages/frontend/src/stdlib/theories/causal-loop.ts +++ b/packages/frontend/src/stdlib/theories/causal-loop.ts @@ -79,6 +79,11 @@ export default function createCausalLoopTheory(theoryMeta: TheoryMeta): Theory { analyses.lotkaVolterra({ simulate: (model, data) => thSignedCategory.lotkaVolterra(model, data), }), + analyses.lotkaVolterraEquations({ + getEquations(model, data) { + return thSignedCategory.lotkaVolterraEquations(model, data); + }, + }), ], }); } diff --git a/packages/frontend/src/stdlib/theories/reg-net.ts b/packages/frontend/src/stdlib/theories/reg-net.ts index ff6751faf..d2f0f10b5 100644 --- a/packages/frontend/src/stdlib/theories/reg-net.ts +++ b/packages/frontend/src/stdlib/theories/reg-net.ts @@ -80,6 +80,11 @@ export default function createRegulatoryNetworkTheory(theoryMeta: TheoryMeta): T return thSignedCategory.lotkaVolterra(model, data); }, }), + analyses.lotkaVolterraEquations({ + getEquations(model, data) { + return thSignedCategory.lotkaVolterraEquations(model, data); + }, + }), ], }); } From 6024f581486581401510cc3dee813f9582dd702c Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 15 May 2026 16:39:25 +0100 Subject: [PATCH 6/7] ENH: Linear ODE refactor --- packages/catlog-wasm/src/analyses.rs | 46 ++- packages/catlog-wasm/src/latex.rs | 31 +- packages/catlog-wasm/src/theories.rs | 20 +- .../src/stdlib/analyses/ode/linear_ode.rs | 314 ++++++++++++------ .../src/stdlib/analyses/ode/lotka_volterra.rs | 4 - .../src/stdlib/analyses/linear_ode.tsx | 28 +- .../src/stdlib/analyses/simulator_types.ts | 10 +- .../src/stdlib/theories/causal-loop.ts | 5 + .../frontend/src/stdlib/theories/reg-net.ts | 9 +- 9 files changed, 338 insertions(+), 129 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 1f96a3166..283a8a438 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -7,7 +7,7 @@ use catlog::simulate::ode::PolynomialSystem; use catlog::stdlib::analyses::ode; use catlog::zero::QualifiedName; -use crate::latex::latex_mor_names_lotka_volterra; +use crate::latex::{latex_mor_names_linear_ode, latex_mor_names_lotka_volterra}; use super::latex::{LatexEquations, latex_mor_names, latex_mor_names_mass_action, latex_ob_names}; use super::model::DblModel; @@ -190,3 +190,47 @@ pub(crate) fn lotka_volterra_simulation( latex_equations: LatexEquations(latex_equations), }) } + +/// Generates the PolynomialSystem for linear ODE dynamics. +fn linear_ode_system( + model: &DblModel, +) -> Result, i8>, String> { + let realised_model = model.discrete()?; + let analysis = ode::CLDLinearODEAnalysis::default(); + Ok(analysis.build_system(realised_model)) +} + +/// The analysis data for polynomial ODE equations. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct LinearODEEquationsData { + #[serde(rename = "trivialData")] + trivial_data: bool, +} + +/// Generates linear ODE equations for the system. +pub(crate) fn linear_ode_equations(model: &DblModel) -> Result { + let sys = linear_ode_system(model); + let equations = sys? + .map_variables(latex_ob_names(model)) + .extend_scalars(|param| param.map_variables(latex_mor_names_linear_ode(model))) + .to_latex_equations(); + Ok(LatexEquations(equations)) +} + +/// Simulates linear ODE equations. +pub(crate) fn linear_ode_simulation( + model: &DblModel, + data: ode::LinearODEProblemData, +) -> Result { + let sys = linear_ode_system(model); + let sys_extended_scalars = ode::extend_linear_ode_scalars(sys?, &data); + let latex_equations = + sys_extended_scalars.map_variables(latex_ob_names(model)).to_latex_equations(); + let analysis = ode::into_linear_ode_analysis(sys_extended_scalars, data); + let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); + Ok(ODEResultWithEquations { + solution: ODEResult(solution.into()), + latex_equations: LatexEquations(latex_equations), + }) +} diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index f87b43d4b..81bd03c07 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -100,7 +100,7 @@ pub(crate) fn latex_mor_names_mass_action( } } -/// Creates a closure that formats morphism names for mass-action LaTeX output. +/// Creates a closure that formats morphism names for Lotka-Volterra LaTeX output. /// /// When a morphism has a label, it is used directly. When unnamed, the label /// falls back to the domain→codomain format (e.g., `X \to Y`). @@ -132,6 +132,35 @@ pub(crate) fn latex_mor_names_lotka_volterra( } } +/// Creates a closure that formats morphism names for mass-action LaTeX output. +/// +/// When a morphism has a label, it is used directly. When unnamed, the label +/// falls back to the domain→codomain format (e.g., `X \to Y`). +pub(crate) fn latex_mor_names_linear_ode( + model: &DblModel, +) -> impl Fn(&ode::LinearODEParameter) -> String { + // Returns a LaTeX fragment for a transition, suitable for use as a subscript. + // Named morphisms produce `\text{name}`, unnamed ones produce + // `\text{dom} \to \text{cod}` so that `\to` is in math mode. + let transition_subscript = |transition: &QualifiedName| -> String { + if let Some(label) = model.mor_namespace.label(transition) { + format!("\\text{{{label}}}") + } else { + let (dom, cod) = model + .mor_generator_dom_cod_label_strings(transition) + .expect("Morphism in equation system should have domain and codomain"); + format!("\\text{{{dom}}} \\to \\text{{{cod}}}") + } + }; + + move |id: &ode::LinearODEParameter| match id { + ode::LinearODEParameter::Parameter { morphism } => { + let sub = transition_subscript(morphism); + format!("\\lambda_{{{sub}}}") + } + } +} + #[cfg(test)] mod tests { use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index cf7b0299b..4c3bf41fb 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -160,7 +160,7 @@ impl ThSignedCategory { lotka_volterra_simulation(model, data) } - /// Simulate the Lotka-Volterra system derived from a model. + /// Show the equations of the Lotka-Volterra system derived from a model. #[wasm_bindgen(js_name = "lotkaVolterraEquations")] pub fn lotka_volterra_equations(&self, model: &DblModel) -> Result { lotka_volterra_equations(model) @@ -172,16 +172,14 @@ impl ThSignedCategory { &self, model: &DblModel, data: analyses::ode::LinearODEProblemData, - ) -> Result { - Ok(ODEResult( - analyses::ode::SignedCoefficientBuilder::new(name("Object")) - .add_positive(Path::Id(name("Object"))) - .add_negative(name("Negative").into()) - .linear_ode_analysis(model.discrete()?, data) - .solve_with_defaults() - .map_err(|err| format!("{err:?}")) - .into(), - )) + ) -> Result { + linear_ode_simulation(model, data) + } + + /// Show the equations of the linear ODE system derived from a model. + #[wasm_bindgen(js_name = "linearODEEquations")] + pub fn linear_ode_equations(&self, model: &DblModel) -> Result { + linear_ode_equations(model) } } diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 83f4a6c22..c1480a114 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -1,29 +1,126 @@ //! Constant-coefficient linear first-order ODE analysis of models. -//! -//! The main entry point for this module is -//! [`linear_ode_analysis`](SignedCoefficientBuilder::linear_ode_analysis). use std::collections::HashMap; -use std::hash::Hash; -use std::ops::Add; +use std::fmt; +use std::rc::Rc; use indexmap::IndexMap; -use itertools::Itertools; -use nalgebra::{DMatrix, DVector}; -use num_traits::Zero; +use nalgebra::DVector; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -use super::{ODEAnalysis, Parameter, SignedCoefficientBuilder}; +use super::{ODEAnalysis, Parameter}; +use crate::dbl::modal::List; +use crate::dbl::model::{FpDblModel, ModalDblModel, ModalOb, MutDblModel}; +use crate::one::Path; use crate::simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}; -use crate::{ - dbl::model::DiscreteDblModel, - one::QualifiedPath, - zero::{QualifiedName, rig::Monomial}, -}; +use crate::stdlib::analyses::ode::PolynomialODEAnalysis; +use crate::stdlib::th_signed_polynomial_ode_system; +use crate::zero::name; +use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; + +/// Parameters in the linear equations correspond only to morphisms. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum LinearODEParameter { + /// The parameter associated to a morphism. + Parameter { + /// The morphism. + morphism: QualifiedName, + }, +} + +impl fmt::Display for LinearODEParameter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parameter { morphism } => { + write!(f, "Parameter({})", morphism) + } + } + } +} + +/// Linear ODE analysis for causal loop diagrams (CLDs). +pub struct CLDLinearODEAnalysis { + /// Object type for variables. + pub var_ob_type: QualifiedName, + /// Morphism type for positive links. + pub pos_link_type: QualifiedPath, + /// Morphism type for negative links. + pub neg_link_type: QualifiedPath, +} + +impl Default for CLDLinearODEAnalysis { + fn default() -> Self { + let ob_type = name("Object"); + Self { + var_ob_type: ob_type.clone(), + pos_link_type: Path::Id(ob_type.clone()), + neg_link_type: Path::single(name("Negative")), + } + } +} + +impl CLDLinearODEAnalysis { + /// Creates a linear system with symbolic rate coefficients. + /// + /// A system of ODEs for building arbitrary linear ODEs from CLDs. + pub fn build_system( + &self, + model: &DiscreteDblModel, + ) -> PolynomialSystem, i8> { + let ode_theory = Rc::new(th_signed_polynomial_ode_system()); + let mut ode_model = ModalDblModel::new(ode_theory); + + let ode_analysis = PolynomialODEAnalysis::default(); + let ode_ob_type = ode_analysis.variable_ob_type; + let ode_pos_cont_type = ode_analysis.positive_contribution_mor_type; + let ode_neg_cont_type = ode_analysis.negative_contribution_mor_type; + + let mut associated_parameters: HashMap = HashMap::new(); + + // Each variable in the CLD gives a variable in the ODE system. + for var in model.ob_generators_with_type(&self.var_ob_type) { + // Add the variable to the ODE system. + ode_model.add_ob(var.clone(), ode_ob_type.clone()); + } + + // Links in the CLD give contributions to the ODEs governing their *codomain*, namely + // x -> y gives (d/dt)y += x. Each positive link in the CLD gives a positive contribution + // and each negative link a negative contribution. + for link in model.mor_generators_with_type(&self.pos_link_type) { + let dom = model.get_dom(&link).unwrap(); + let cod = model.get_cod(&link).unwrap(); + let dom_object = ModalOb::Generator(dom.clone()); + let cod_object = ModalOb::Generator(cod.clone()); + + let term = ModalOb::List(List::Symmetric, vec![dom_object.clone()]); + let interaction_parameter = LinearODEParameter::Parameter { morphism: link.clone() }; + let interaction_name = link; + + associated_parameters.insert(interaction_name.clone(), interaction_parameter); + ode_model.add_mor(interaction_name, term, cod_object, ode_pos_cont_type.clone()); + } + for link in model.mor_generators_with_type(&self.neg_link_type) { + let dom = model.get_dom(&link).unwrap(); + let cod = model.get_cod(&link).unwrap(); + let dom_object = ModalOb::Generator(dom.clone()); + let cod_object = ModalOb::Generator(cod.clone()); + + let term = ModalOb::List(List::Symmetric, vec![dom_object.clone()]); + let interaction_parameter = LinearODEParameter::Parameter { morphism: link.clone() }; + let interaction_name = link; + + associated_parameters.insert(interaction_name.clone(), interaction_parameter); + ode_model.add_mor(interaction_name, term, cod_object, ode_neg_cont_type.clone()); + } + + PolynomialODEAnalysis::default() + .build_system_custom_parameters(&ode_model, associated_parameters) + } +} /// Data defining a linear ODE problem for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -45,74 +142,40 @@ pub struct LinearODEProblemData { duration: f32, } -/// Construct a linear (first-order) dynamical system; -/// a semantics for causal loop diagrams. -pub fn linear_polynomial_system( - vars: &[Var], - coefficients: DMatrix, -) -> PolynomialSystem -where - Var: Clone + Hash + Ord, - Coef: Clone + Add + Zero, -{ - let system = PolynomialSystem { - components: coefficients - .row_iter() - .zip(vars) - .map(|(row, i)| { - ( - i.clone(), - row.iter() - .zip(vars) - .map(|(a, j)| (a.clone(), Monomial::generator(j.clone()))) - .collect(), - ) - }) - .collect(), - }; - system.normalize() +/// Substitutes numerical rate coefficients into a symbolic linear system. +pub fn extend_linear_ode_scalars( + sys: PolynomialSystem, i8>, + data: &LinearODEProblemData, +) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|param| match param { + LinearODEParameter::Parameter { morphism } => { + data.coefficients.get(morphism).cloned().unwrap_or_default() + } + }) + }); + + sys.normalize() } -impl SignedCoefficientBuilder { - /// Linear ODE analysis for a model of a double theory. - /// - /// This analysis is a special case of linear ODE analysis for *extended* causal - /// loop diagrams but can serve as a simple/naive semantics for causal loop - /// diagrams, hopefully useful for toy models and demonstration purposes. - pub fn linear_ode_analysis( - &self, - model: &DiscreteDblModel, - data: LinearODEProblemData, - ) -> ODEAnalysis> { - let (system, ob_index) = self.linear_ode_system(model); - let n = ob_index.len(); - - let initial_values = ob_index - .keys() - .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); - let x0 = DVector::from_iterator(n, initial_values); - - let system = system - .extend_scalars(|poly| { - poly.eval(|id| data.coefficients.get(id).copied().unwrap_or_default()) - }) - .to_numerical(); - let problem = ODEProblem::new(system, x0).end_time(data.duration); - ODEAnalysis::new(problem, ob_index) - } +/// Builds the numerical ODE analysis for a linear system whose scalars have been substituted. +pub fn into_linear_ode_analysis( + sys: PolynomialSystem, + data: LinearODEProblemData, +) -> ODEAnalysis> { + let ob_index: IndexMap<_, _> = + sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect(); + let n = ob_index.len(); - /// Linear ODE system for a model of a double theory. - pub fn linear_ode_system( - &self, - model: &DiscreteDblModel, - ) -> ( - PolynomialSystem, u8>, - IndexMap, - ) { - let (matrix, ob_index) = self.build_matrix(model); - let system = linear_polynomial_system(&ob_index.keys().cloned().collect_vec(), matrix); - (system, ob_index) - } + let initial_values = ob_index + .keys() + .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); + let x0 = DVector::from_iterator(n, initial_values); + + let num_sys = sys.to_numerical(); + let problem = ODEProblem::new(num_sys, x0).end_time(data.duration); + + ODEAnalysis::new(problem, ob_index) } #[cfg(test)] @@ -121,43 +184,88 @@ mod test { use std::rc::Rc; use super::*; - use crate::stdlib; - use crate::{one::Path, zero::name}; + use crate::{ + simulate::ode::LatexEquation, + stdlib::{models::*, theories::*}, + }; + + // Symbolic tests. - fn builder() -> SignedCoefficientBuilder { - SignedCoefficientBuilder::new(name("Object")) - .add_positive(Path::Id(name("Object"))) - .add_negative(Path::single(name("Negative"))) + #[test] + fn predator_prey_symbolic() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + let sys = CLDLinearODEAnalysis::default().build_system(&model); + let expected = expect!([r#" + dx = -Parameter(negative) y + dy = Parameter(positive) x + "#]); + expected.assert_eq(&sys.to_string()); } #[test] - fn negative_feedback_symbolic() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); - let (sys, _) = builder().linear_ode_system(&neg_feedback); - let expected = expect![[r#" - dx = -negative y - dy = positive x - "#]]; + fn complicated_symbolic() { + let th = Rc::new(th_signed_category()); + let mut model = DiscreteDblModel::new(th); + model.add_ob(name("a"), name("Object")); + model.add_ob(name("b"), name("Object")); + model.add_ob(name("c"), name("Object")); + model.add_ob(name("d"), name("Object")); + model.add_mor(name("f"), name("a"), name("b"), Path::Id(name("Object"))); + model.add_mor(name("g"), name("b"), name("a"), Path::Id(name("Object"))); + model.add_mor(name("h"), name("b"), name("a"), name("Negative").into()); + model.add_mor(name("i"), name("a"), name("c"), name("Negative").into()); + model.add_mor(name("j"), name("c"), name("d"), Path::Id(name("Object"))); + model.add_mor(name("k"), name("d"), name("b"), name("Negative").into()); + let sys = CLDLinearODEAnalysis::default().build_system(&model); + let expected = expect!([r#" + da = (Parameter(g) - Parameter(h)) b + db = Parameter(f) a - Parameter(k) d + dc = -Parameter(i) a + dd = Parameter(j) c + "#]); expected.assert_eq(&sys.to_string()); } + // Test for LaTeX. + #[test] - fn negative_feedback_numerical() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); + fn to_latex() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + let sys = CLDLinearODEAnalysis::default().build_system(&model); + let expected = vec![ + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), + rhs: "-Parameter(negative) \\cdot y".to_string(), + }, + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), + rhs: "Parameter(positive) \\cdot x".to_string(), + }, + ]; + assert_eq!(expected, sys.to_latex_equations()); + } + + // Numerical test. + + #[test] + fn predator_prey_numerical() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); let data = LinearODEProblemData { - coefficients: [(name("positive"), 2.0), (name("negative"), 1.0)].into_iter().collect(), + coefficients: [(name("positive"), 3.0), (name("negative"), 2.0)].into_iter().collect(), initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, }; - let sys = builder().linear_ode_analysis(&neg_feedback, data).problem.system; - let expected = expect![[r#" - dx0 = -x1 - dx1 = 2 x0 - "#]]; - expected.assert_eq(&sys.to_string()); + let sys = CLDLinearODEAnalysis::default().build_system(&model); + let analysis = extend_linear_ode_scalars(sys, &data); + let expected = expect!([r#" + dx = -2 y + dy = 3 x + "#]); + expected.assert_eq(&analysis.to_string()); } } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 8abb9dc51..813a8fe1d 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -1,7 +1,4 @@ //! Lotka-Volterra ODE analysis of models. -//! -//! The main entry point for this module is -//! [`lotka_volterra_analysis`](SignedCoefficientBuilder::lotka_volterra_analysis). use indexmap::IndexMap; use std::collections::HashMap; @@ -99,7 +96,6 @@ impl CLDLotkaVolterraAnalysis { // Each variable in the CLD gives a variable in the ODE system *as well as* // its growth contribution: (d/dt)x += x. for var in model.ob_generators_with_type(&self.var_ob_type) { - // for var in model.ob_generators_with_type(&self.var_ob_type) { // Add the variable to the ODE system. ode_model.add_ob(var.clone(), ode_ob_type.clone()); diff --git a/packages/frontend/src/stdlib/analyses/linear_ode.tsx b/packages/frontend/src/stdlib/analyses/linear_ode.tsx index 946a156ca..94c42538b 100644 --- a/packages/frontend/src/stdlib/analyses/linear_ode.tsx +++ b/packages/frontend/src/stdlib/analyses/linear_ode.tsx @@ -4,12 +4,14 @@ import { createNumericalColumn, FixedTableEditor, Foldable, + ExpandableTable, + KatexDisplay, } from "catcolab-ui-components"; -import type { DblModel, LinearODEProblemData, QualifiedName } from "catlog-wasm"; +import type { LinearODEProblemData, QualifiedName } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; -import { createModelODEPlot } from "./model_ode_plot"; +import { createModelODEPlotWithEquations } from "./model_ode_plot"; import type { LinearODESimulator } from "./simulator_types"; import "./simulation.css"; @@ -70,11 +72,14 @@ export default function LinearODE( }), ]; - const plotResult = createModelODEPlot( + const result = createModelODEPlotWithEquations( () => props.liveModel.validatedModel(), - (model: DblModel) => props.simulate(model, props.content), + (model) => props.simulate(model, props.content), ); + const plotResult = () => result()?.plotData; + const latexEquations = () => result()?.latexEquations ?? []; + return (
@@ -91,7 +96,20 @@ export default function LinearODE(
- + + }, + { cell: () => }, + { cell: (row) => }, + ]} + /> + + + + ); } diff --git a/packages/frontend/src/stdlib/analyses/simulator_types.ts b/packages/frontend/src/stdlib/analyses/simulator_types.ts index 941442abc..e07ac6322 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -3,6 +3,7 @@ import type { KuramotoProblemData, LatexEquations, LinearODEProblemData, + LinearODEEquationsData, LotkaVolterraProblemData, LotkaVolterraEquationsData, MassActionEquationsData, @@ -23,7 +24,14 @@ export type { }; export type KuramotoSimulator = (model: DblModel, data: KuramotoProblemData) => ODEResult; -export type LinearODESimulator = (model: DblModel, data: LinearODEProblemData) => ODEResult; +export type LinearODESimulator = ( + model: DblModel, + data: LinearODEProblemData, +) => ODEResultWithEquations; +export type LinearODEEquations = ( + model: DblModel, + data: LinearODEEquationsData, +) => LatexEquations; export type LotkaVolterraSimulator = ( model: DblModel, data: LotkaVolterraProblemData, diff --git a/packages/frontend/src/stdlib/theories/causal-loop.ts b/packages/frontend/src/stdlib/theories/causal-loop.ts index b274b7661..43fa228fc 100644 --- a/packages/frontend/src/stdlib/theories/causal-loop.ts +++ b/packages/frontend/src/stdlib/theories/causal-loop.ts @@ -76,6 +76,11 @@ export default function createCausalLoopTheory(theoryMeta: TheoryMeta): Theory { analyses.linearODE({ simulate: (model, data) => thSignedCategory.linearODE(model, data), }), + analyses.linearODEEquations({ + getEquations(model, data) { + return thSignedCategory.linearODEEquations(model, data); + }, + }), analyses.lotkaVolterra({ simulate: (model, data) => thSignedCategory.lotkaVolterra(model, data), }), diff --git a/packages/frontend/src/stdlib/theories/reg-net.ts b/packages/frontend/src/stdlib/theories/reg-net.ts index d2f0f10b5..859099f36 100644 --- a/packages/frontend/src/stdlib/theories/reg-net.ts +++ b/packages/frontend/src/stdlib/theories/reg-net.ts @@ -75,11 +75,14 @@ export default function createRegulatoryNetworkTheory(theoryMeta: TheoryMeta): T analyses.linearODE({ simulate: (model, data) => thSignedCategory.linearODE(model, data), }), - analyses.lotkaVolterra({ - simulate(model, data) { - return thSignedCategory.lotkaVolterra(model, data); + analyses.linearODEEquations({ + getEquations(model, data) { + return thSignedCategory.linearODEEquations(model, data); }, }), + analyses.lotkaVolterra({ + simulate: (model, data) => thSignedCategory.lotkaVolterra(model, data), + }), analyses.lotkaVolterraEquations({ getEquations(model, data) { return thSignedCategory.lotkaVolterraEquations(model, data); From 00ea66c22df9f5d8b8929f83164e354bd1b09601 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 15 May 2026 16:51:10 +0100 Subject: [PATCH 7/7] ENH: Linear ODE equations --- .../catlog/src/stdlib/analyses/ode/mod.rs | 2 - .../analyses/ode/signed_coefficients.rs | 84 ------------------- packages/frontend/src/stdlib/analyses.tsx | 28 +++++++ .../stdlib/analyses/linear_ode_equations.tsx | 36 ++++++++ .../src/stdlib/analyses/simulator_types.ts | 5 +- 5 files changed, 65 insertions(+), 90 deletions(-) delete mode 100644 packages/catlog/src/stdlib/analyses/ode/signed_coefficients.rs create mode 100644 packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx diff --git a/packages/catlog/src/stdlib/analyses/ode/mod.rs b/packages/catlog/src/stdlib/analyses/ode/mod.rs index 4c9b2a862..8918adfaa 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mod.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mod.rs @@ -74,11 +74,9 @@ pub mod linear_ode; pub mod lotka_volterra; pub mod mass_action; pub mod polynomial_ode; -pub mod signed_coefficients; pub use kuramoto::*; pub use linear_ode::*; pub use lotka_volterra::*; pub use mass_action::*; pub use polynomial_ode::*; -pub use signed_coefficients::*; diff --git a/packages/catlog/src/stdlib/analyses/ode/signed_coefficients.rs b/packages/catlog/src/stdlib/analyses/ode/signed_coefficients.rs deleted file mode 100644 index ce106e909..000000000 --- a/packages/catlog/src/stdlib/analyses/ode/signed_coefficients.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Helper module to build analyses based on signed coefficient matrices. - -use indexmap::IndexMap; -use nalgebra::DMatrix; -use num_traits::zero; - -use super::Parameter; -use crate::{ - dbl::model::FpDblModel, - zero::{QualifiedName, rig::Monomial}, -}; - -/// Builder for signed coefficient matrices and analyses based on them. -/// -/// Used to construct the [linear](Self::linear_ode_analysis) and -/// [Lotka-Volterra](Self::lotka_volterra_analysis) ODE analyses. -pub struct SignedCoefficientBuilder { - var_ob_type: ObType, - positive_mor_types: Vec, - negative_mor_types: Vec, -} - -impl SignedCoefficientBuilder { - /// Creates a new builder for the given object type. - pub fn new(var_ob_type: ObType) -> Self { - Self { - var_ob_type, - positive_mor_types: Vec::new(), - negative_mor_types: Vec::new(), - } - } - - /// Adds a morphism type defining a positive interaction between objects. - pub fn add_positive(mut self, mor_type: MorType) -> Self { - self.positive_mor_types.push(mor_type); - self - } - - /// Adds a morphism type defining a negative interaction between objects. - pub fn add_negative(mut self, mor_type: MorType) -> Self { - self.negative_mor_types.push(mor_type); - self - } - - /// Builds the matrix of symbolic coefficients for the given model. - /// - /// Returns the coefficient matrix along with an ordered map from object - /// generators to integer indices. - pub fn build_matrix( - &self, - model: &impl FpDblModel< - ObType = ObType, - MorType = MorType, - Ob = QualifiedName, - ObGen = QualifiedName, - MorGen = QualifiedName, - >, - ) -> (DMatrix>, IndexMap) { - let ob_index: IndexMap<_, _> = model - .ob_generators_with_type(&self.var_ob_type) - .enumerate() - .map(|(i, x)| (x, i)) - .collect(); - - let n = ob_index.len(); - let mut mat = DMatrix::from_element(n, n, zero()); - for mor_type in self.positive_mor_types.iter() { - for mor in model.mor_generators_with_type(mor_type) { - let i = *ob_index.get(&model.mor_generator_dom(&mor)).unwrap(); - let j = *ob_index.get(&model.mor_generator_cod(&mor)).unwrap(); - mat[(j, i)] += (1.0, Monomial::generator(mor)); - } - } - for mor_type in self.negative_mor_types.iter() { - for mor in model.mor_generators_with_type(mor_type) { - let i = *ob_index.get(&model.mor_generator_dom(&mor)).unwrap(); - let j = *ob_index.get(&model.mor_generator_cod(&mor)).unwrap(); - mat[(j, i)] += (-1.0, Monomial::generator(mor)); - } - } - - (mat, ob_index) - } -} diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 444bfc0b0..7a2ca468a 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -1,6 +1,7 @@ import { lazy } from "solid-js"; import type { + LinearODEEquationsData, LotkaVolterraEquationsData, MassActionEquationsData, MorType, @@ -134,6 +135,33 @@ export function linearODE( const LinearODE = lazy(() => import("./analyses/linear_ode")); +export function linearODEEquations( + options: Partial & { + getEquations: Simulators.LinearODEEquations; + }, +): ModelAnalysisMeta { + const { + id = "linear-ode-equations", + name = "Linear ODE equations", + description = "Display the symbolic linear ODE dynamics equations", + help = "linear-ode-equations", + ...otherOptions + } = options; + return { + id, + name, + description, + help, + component: (props) => ( + + ), + initialContent: () => ({ + trivialData: true, + }), + }; +} +const LinearODEEquationsDisplay = lazy(() => import("./analyses/linear_ode_equations")); + export function lotkaVolterra( options: Partial & { simulate: Simulators.LotkaVolterraSimulator; diff --git a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx b/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx new file mode 100644 index 000000000..d0e65d2db --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx @@ -0,0 +1,36 @@ +import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; +import { LinearODEEquationsData } from "catlog-wasm"; +import type { ModelAnalysisProps } from "../../analysis"; +import { createModelODELatex } from "./model_ode_plot"; +import type { LinearODEEquations } from "./simulator_types"; + +import "./simulation.css"; + +/** Display the symbolic mass-action dynamics equations for a model. */ +export default function LinearODEEquationsDisplay( + props: ModelAnalysisProps & { + content: LinearODEEquationsData; + getEquations: LinearODEEquations; + title?: string; + }, +) { + const latexEquations = createModelODELatex( + () => props.liveModel.validatedModel(), + (model) => props.getEquations(model, props.content), + ); + + return ( +
+ + }, + { cell: () => }, + { cell: (row) => }, + ]} + /> +
+ ); +} diff --git a/packages/frontend/src/stdlib/analyses/simulator_types.ts b/packages/frontend/src/stdlib/analyses/simulator_types.ts index e07ac6322..335b5e7ed 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -28,10 +28,7 @@ export type LinearODESimulator = ( model: DblModel, data: LinearODEProblemData, ) => ODEResultWithEquations; -export type LinearODEEquations = ( - model: DblModel, - data: LinearODEEquationsData, -) => LatexEquations; +export type LinearODEEquations = (model: DblModel, data: LinearODEEquationsData) => LatexEquations; export type LotkaVolterraSimulator = ( model: DblModel, data: LotkaVolterraProblemData,