diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 57fee9811..283a8a438 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_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; 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,92 @@ 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)) +} + +/// 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); + 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), + }) +} + +/// 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 848494f22..81bd03c07 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -100,6 +100,67 @@ pub(crate) fn latex_mor_names_mass_action( } } +/// 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`). +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 { link } => { + let sub = transition_subscript(link); + format!("k_{{{sub}}}") + } + } +} + +/// 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 d7af351a1..4c3bf41fb 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -156,16 +156,14 @@ 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) + } + + /// 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) } /// Simulate the linear ODE system derived from a model. @@ -174,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 9a1853f16..813a8fe1d 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -1,29 +1,148 @@ //! Lotka-Volterra ODE analysis of models. -//! -//! The main entry point for this module is -//! [`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::collections::HashMap; +use std::fmt; +use std::rc::Rc; +use nalgebra::DVector; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -use super::{ODEAnalysis, Parameter, SignedCoefficientBuilder}; +use super::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, alg::Polynomial, rig::Monomial}, -}; +use crate::stdlib::analyses::ode::{ODEAnalysis, 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 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 { + /// The link. + link: 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 { link } => { + write!(f, "Interaction({})", link) + } + } + } +} + +/// Lotka-Volterra ODE analysis for causal loop diagrams (CLDs). +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; + + 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) { + // 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; + + associated_parameters.insert(var_name.clone(), var_parameter); + 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 + // 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_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()); + } + 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_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()); + } + + PolynomialODEAnalysis::default() + .build_system_custom_parameters(&ode_model, associated_parameters) + } +} /// Data defining a Lotka-Volterra ODE problem for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -49,95 +168,43 @@ 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 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 { link } => { + data.interaction_coeffs.get(link).cloned().unwrap_or_default() + } + }) + }); + + sys.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) - } +/// 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(); - /// 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) - } + 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)] @@ -146,32 +213,75 @@ 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::*}, + }; - fn builder() -> SignedCoefficientBuilder { - SignedCoefficientBuilder::new(name("Object")) - .add_positive(Path::Id(name("Object"))) - .add_negative(Path::single(name("Negative"))) - } + // Symbolic tests. #[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 th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + let sys = CLDLotkaVolterraAnalysis::default().build_system(&model); let expected = expect!([r#" - dx = Param(x) x - Param(negative) x y - dy = Param(positive) x y + Param(y) y + dx = Growth(x) x - Interaction(negative) x y + dy = Interaction(positive) x y + Growth(y) y "#]); 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(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); let data = LotkaVolterraProblemData { interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] @@ -182,11 +292,12 @@ mod test { duration: 10.0, }; - let sys = builder().lotka_volterra_analysis(&neg_feedback, data).problem.system; + 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(&sys.to_string()); + 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 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/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()); diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 38b19a6a0..7a2ca468a 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -1,6 +1,8 @@ import { lazy } from "solid-js"; import type { + LinearODEEquationsData, + LotkaVolterraEquationsData, MassActionEquationsData, MorType, ObType, @@ -133,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; @@ -140,8 +169,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 +191,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/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/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/lotka_volterra.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx index 9d6006800..062f28189 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 { 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 { 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) => }, + ]} + /> + + + + ); } 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..335b5e7ed 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -3,7 +3,9 @@ import type { KuramotoProblemData, LatexEquations, LinearODEProblemData, + LinearODEEquationsData, LotkaVolterraProblemData, + LotkaVolterraEquationsData, MassActionEquationsData, MassActionProblemData, ODEResult, @@ -22,20 +24,31 @@ 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 LinearODESimulator = ( + model: DblModel, + data: LinearODEProblemData, +) => ODEResultWithEquations; +export type LinearODEEquations = (model: DblModel, data: LinearODEEquationsData) => LatexEquations; +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..43fa228fc 100644 --- a/packages/frontend/src/stdlib/theories/causal-loop.ts +++ b/packages/frontend/src/stdlib/theories/causal-loop.ts @@ -76,9 +76,19 @@ 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), }), + 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..859099f36 100644 --- a/packages/frontend/src/stdlib/theories/reg-net.ts +++ b/packages/frontend/src/stdlib/theories/reg-net.ts @@ -75,9 +75,17 @@ export default function createRegulatoryNetworkTheory(theoryMeta: TheoryMeta): T analyses.linearODE({ simulate: (model, data) => thSignedCategory.linearODE(model, data), }), + analyses.linearODEEquations({ + getEquations(model, data) { + return thSignedCategory.linearODEEquations(model, data); + }, + }), analyses.lotkaVolterra({ - simulate(model, data) { - return thSignedCategory.lotkaVolterra(model, data); + simulate: (model, data) => thSignedCategory.lotkaVolterra(model, data), + }), + analyses.lotkaVolterraEquations({ + getEquations(model, data) { + return thSignedCategory.lotkaVolterraEquations(model, data); }, }), ],