diff --git a/.rustfmt.toml b/.rustfmt.toml index 5a59df903..2aaa9b92b 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,3 +1,4 @@ array_width = 80 chain_width = 80 fn_call_width = 80 +max_width = 100 \ No newline at end of file diff --git a/math-docs/trees/dct-000C.tree b/math-docs/trees/dct-000C.tree new file mode 100644 index 000000000..4fb5c6ff3 --- /dev/null +++ b/math-docs/trees/dct-000C.tree @@ -0,0 +1,19 @@ +\title{Degree-delay signed categories} +\taxon{doctrine} +\import{macros} + +\p{Degree-delay signed categories are categories graded in the monoid #{\mathbb{N} \times \mathbb{N} \times \mathbb{Z}_2}. The two copies of #{\mathbb{N}} label edges by the \em{differential degree} and \em{order of delay}. The order of the differential degree is meant to indicate which derivative of the target is affected (linearly) by the source. For example, an edge #{A \to B} with degree 2 indicates a contribution of the form #{\ddot{B} \mathrel{+}= kA} for some #{k \in \mathbb{R}}. The \em{order of delay} only has meaning with the assumption that the delays are modelled stochastically by Erlang distributions with the same time-scale parameter (see, for example, [here](https://en.wikipedia.org/wiki/Erlang_distribution)). The labelling in #{\mathbb{Z}_2} indicates the "sign" of the influence, as described in \ref{dct-0002}.} + +\subtree{ +\title{Degree-delay signed category} +\taxon{definition} + +\p{A \define{degree-delay signed category} is a category sliced over the product +monoid #{\mathbb{N} \times \mathbb{N} \times \mathbb{Z}_2} with the monoidal product given by addition.} + +} + +\transclude{thy-000C} + +\p{A free model of this double theory a causal loop diagram in which edges can +are additionally marked with two natural numbers indicating the \em{degree} and \em{delay} of the influence, called an \em{extended causal loop diagram}.} diff --git a/math-docs/trees/mat-0001.tree b/math-docs/trees/mat-0001.tree index a3fb56b48..1974602f3 100644 --- a/math-docs/trees/mat-0001.tree +++ b/math-docs/trees/mat-0001.tree @@ -46,6 +46,7 @@ systems interpretation:} \tr{\td{[[dct-0002]]} \td{Signed graphs} \td{Regulatory networks/causal loop diagrams}} \tr{\td{[[dct-000B]]} \td{Signed graphs with delays} \td{Variant of causal loop diagram}} \tr{\td{[[dct-0007]]} \td{Nullable signed graphs} \td{Variant of causal loop diagram}} + \tr{\td{[[dct-000C]]} \td{Delay-degree signed graphs} \td{Extension of causal loop diagram}} \tr{\td{[[dct-0003]]} \td{Graphs with links} \td{Stock-flow diagrams}} \tr{\td{Symmetric monoidal categories} \td{Petri nets} \td{Reaction networks}} \tr{\td{[[dct-0004]]} \td{Sets} \td{Labels}} diff --git a/math-docs/trees/thy-000C.tree b/math-docs/trees/thy-000C.tree new file mode 100644 index 000000000..0af0372f2 --- /dev/null +++ b/math-docs/trees/thy-000C.tree @@ -0,0 +1,7 @@ +\title{Theory of degree-delay signed categories} +\taxon{theory} +\import{macros} + +\p{The \define{theory of degree-delay signed categories} is the discrete double +theory with a single object and with proarrows given by the elements of the +monoid #{\mathbb{N} \times \mathbb{N} \times \mathbb{Z}_2}.} diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index f4239e9ac..706af5188 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -254,6 +254,23 @@ impl ThNullableSignedCategory { } } +/// The theory of degree delay signed categories (for ECLDs). +#[wasm_bindgen] +pub struct ThDegDelSignedCategory(Rc); + +#[wasm_bindgen] +impl ThDegDelSignedCategory { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self(Rc::new(theories::th_deg_del_signed_category())) + } + + #[wasm_bindgen] + pub fn theory(&self) -> DblTheory { + DblTheory(self.0.clone().into()) + } +} + /// The theory of categories with scalars. #[wasm_bindgen] pub struct ThCategoryWithScalars(Rc); diff --git a/packages/catlog/src/stdlib/analyses/ecld/atomisations.rs b/packages/catlog/src/stdlib/analyses/ecld/atomisations.rs new file mode 100644 index 000000000..0e7792a09 --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ecld/atomisations.rs @@ -0,0 +1,292 @@ +/*! Atomisations for extended causal loop diagrams (ECLDs) + +ECLDs have arrows labelled by two natural numbers, for degree and delay. In the +intended linear ODE semantics, both of these behave additively under composition +of paths. It is useful to have a rewrite rule that "atomises" any arrow, i.e. +replacing an arrow X -> Y of degree n (which corresponds to the equation +(d/dt)^n(Y) += kX) by n-many arrows of degree 1, thus also introducing (n-1)-many +new objects X -> Y_{n-1} -> ... -> Y_2 -> Y_1 -> Y. The idea to keep in mind is +that a degree-n differential equation of the form (d/dt)^n(Y) = X can +equivalently be written as a system of degree-1 differential equations, namely +(d/dt)(Y) = Y_1, (d/dt)(Y_1) = Y_2, ..., (d/dt)(Y_{n-1}) = X. An analogous story +holds for order of delay, though this is formally dual: an arrow X -> Y of +order m corresponds to (something like) a morphism Y += k*E(m)*X, i.e. the +*source* is modified, not the target. + +We call the objects Y_i the *formal derivatives* (resp. *formal delays*) of Y, +and the list [Y_0 = Y, Y_1, ..., Y_{n-1}] the *tower* over Y (resp. under Y), +and call Y the *base* of this tower; we call the length n of the list +[Y_0, ..., Y_{n-1}] the *height* of the tower. + */ + +use crate::dbl::model::{DiscreteDblModel, FgDblModel, MutDblModel}; +use crate::one::{category::FgCategory, Path}; +use crate::stdlib::theories; +use crate::zero::{name, QualifiedName}; +use std::{collections::HashMap, rc::Rc}; + +// Some helpful functions +fn deg_of_mor(model: &DiscreteDblModel, f: &QualifiedName) -> usize { + model.mor_generator_type(f).into_iter().filter(|t| *t == name("Degree")).count() +} + +fn is_mor_pos(model: &DiscreteDblModel, f: &QualifiedName) -> bool { + // TO-DO: use th_deg_del_signed_category_to_signed_category() + 0 == model + .mor_generator_type(f) + .into_iter() + .filter(|t| *t == name("Negative")) + .count() + % 2 +} + +/** Atomisiation of an ECLD by degree: replace every degree-n arrow by a path + * of n-many degree-1 arrows, going via (n-1)-many new objects; all the + * degree-0 arrows are kept unchanged. Returns the derivative towers to keep + * track of the relation between the formal derivatives and the original objects + */ +pub fn degree_atomisation( + model: DiscreteDblModel, +) -> (DiscreteDblModel, HashMap>) { + let mut atomised_model: DiscreteDblModel = + DiscreteDblModel::new(Rc::new(theories::th_deg_del_signed_category())); + + // height: the total height of the tower + // in_arrows_pos_deg: all incoming arrows of (strictly) positive degree + // in_arrows_zero_deg: all incoming arrows of zero degree + struct TowerConstructor { + height: usize, + in_arrows_pos_deg: Vec, + in_arrows_zero_deg: Vec, + } + let mut tower_constructors: HashMap = HashMap::new(); + + // Every tower will be of height at least 1, and will have at the very least + // an empty list of positive-degree (resp. zero-degree) incoming arrows + for x in model.ob_generators() { + tower_constructors.insert( + x, + TowerConstructor { + height: 1, + in_arrows_pos_deg: Vec::new(), + in_arrows_zero_deg: Vec::new(), + }, + ); + } + + // ------------------------------------------------------------------------- + // 1. For each base, calculate the maximum degree over all incoming + // arrows. Note that this is most easily done by actually iterating + // over the *morphisms* instead. + + for f in model.mor_generators() { + let f_cod = model.mor_generator_cod(&f); + let f_degree = deg_of_mor(&model, &f); + + if f_degree != 0 { + let tower_cons = tower_constructors.get_mut(&f_cod).unwrap(); + let new_degree = std::cmp::max(tower_cons.height, f_degree); + tower_cons.height = new_degree; + tower_cons.in_arrows_pos_deg.push(f.clone()); + } else { + let tower_cons = tower_constructors.get_mut(&f_cod).unwrap(); + tower_cons.in_arrows_zero_deg.push(f.clone()); + } + } + + // ------------------------------------------------------------------------- + // 2. Iterate over all unchecked bases, starting with (any one of) the + // one(s) with greatest current height. For each one, ensure that all + // the incoming arrows can be lifted so that their target is the + // highest floor of the tower, i.e. that the tower over Y has enough + // floors so that *every* arrow into Y can have the same target Y_max. + // But note that if we lift an arrow of degree n from X to Y then we + // need to know that X_n exists, i.e. we need to (potentially) add + // more floors to the *source* of every arrow whose target is Y. + + // We have yet to build any of the towers so, right now, every base is + // "unchecked". + let mut unchecked_bases: Vec = model.ob_generators().collect::>(); + + while !unchecked_bases.is_empty() { + // Since heights will change as we go, we start by resorting the list + unchecked_bases.sort_by(|x, y| { + let height = |base| tower_constructors.get(base).unwrap().height; + // Sort from smallest to largest so that we can pop from this stack + height(y).cmp(&height(x)) + }); + + // Work on the base of (any one of) the tallest tower(s) + let target = unchecked_bases.pop().unwrap(); + + // Ensure that every incoming arrow can be lifted high enough in the + // tower over its source + let target_in_arrows = &tower_constructors.get(&target).unwrap().in_arrows_pos_deg.clone(); + for f in target_in_arrows { + let source = model.mor_generator_dom(f); + let required_height = + tower_constructors.get(&target).unwrap().height - deg_of_mor(&model, f) + 1; + tower_constructors.entry(source.clone()).and_modify(|tower_cons| { + if tower_cons.height < required_height { + tower_cons.height = required_height + } + }); + } + } + + // ------------------------------------------------------------------------- + // 3. Now that we know the required height of all the towers, we can + // actually build them: create the formal derivatives and the morphisms + // between them, resulting in Y_{n-1} -> ... -> Y_1 -> Y. Once we have + // these, we add them to our final model, resulting in a "discrete" + // copy of our original model, where each object has been extruded out + // to a tower of formal derivatives, but where there are no arrows + // between distinct towers. + + // The hash map of towers will be useful when we later come to lifting all + // positive-degree arrows, so we build this at the same time as adding all + // these formal derivatives (and their morphisms) to the final model. + let mut towers: HashMap> = HashMap::new(); + + for (base, tower_cons) in tower_constructors.iter_mut() { + // Firstly, add the base object itself + towers.insert(base.clone(), vec![base.clone()]); + atomised_model.add_ob(base.clone(), name("Object")); + // Then add all the formal derivatives Y_i, along with the morphisms + // Y_i -> Y_{i-1} + for i in 1..tower_cons.height { + let suffix = format!("_d[{}]", i); + let formal_der_i = base.clone().append(suffix.as_str().into()); + atomised_model.add_ob(formal_der_i.clone(), name("Object")); + let formal_der_i_minus_1 = towers.get(base).unwrap().last().unwrap(); + atomised_model.add_mor( + format!("d_({})^({})", base, i).as_str().into(), + formal_der_i.clone(), + formal_der_i_minus_1.clone(), + name("Degree").into(), + ); + towers.get_mut(base).unwrap().push(formal_der_i); + } + } + + // ------------------------------------------------------------------------- + // 4. Finally, we add all the arrows from our original model into the new + // model: + // - we lift all positive-degree morphisms to have their new + // target be the top floor of the tower over their old target (i.e. + // a degree-n arrow X -> Y corresponds to a degree-1 arrow + // X -> Y_{n-1}, which should then be lifted to a (degree-1) arrow + // X_{h - (n-1)} -> Y_h, where h is the height of the tower over Y) + // - we simply copy over all the degree-zero morphisms. + + for (base, tower) in towers.iter() { + let tower_cons = tower_constructors.get(base).unwrap(); + for f in &tower_cons.in_arrows_pos_deg { + let deg = deg_of_mor(&model, f); + let source = model.mor_generator_dom(f); + let source_tower = towers.get(&source).unwrap(); + // Note that we could alternatively take height to be the length of + // towers.get(source), which is equal by construction/definition + let height = tower_cons.height; + let new_source = &source_tower[height - deg]; + let new_target = tower.last().unwrap(); + if is_mor_pos(&model, f) { + atomised_model.add_mor( + f.clone(), + new_source.clone(), + new_target.clone(), + name("Degree").into(), + ) + } else { + atomised_model.add_mor( + f.clone(), + new_source.clone(), + new_target.clone(), + Path::pair(name("Negative"), name("Degree")), + ) + } + } + + for f in &tower_cons.in_arrows_zero_deg { + atomised_model.add_mor( + f.clone(), + model.mor_generator_dom(f).clone(), + model.mor_generator_cod(f).clone(), + Path::Id(name("Object")), + ); + } + } + (atomised_model, towers) +} + +#[cfg(test)] +mod tests { + use super::degree_atomisation; + + use crate::one::category::FgCategory; + use crate::stdlib::models::sample_ecld; + use crate::zero::{name, QualifiedName}; + use std::collections::HashMap; + + // Makes a hash map from objects in sample_ecld to tower heights + fn correct_heights() -> HashMap { + let mut heights = HashMap::new(); + heights.insert(name("a"), 1); + heights.insert(name("b"), 3); + heights.insert(name("c"), 3); + heights.insert(name("d"), 2); + heights + // TO-DO: make the following work + // [(name("a"), 1), (name("b"), 3), (name("c"), 3), (name("d"), 2)] + // .collect::>() + } + + // Makes a hash map from morphisms in sample_ecld to the correct index of + // the domain in the atomised tower + fn correct_domain_indices() -> HashMap { + let mut domains = HashMap::new(); + domains.insert(name("f"), 0); + domains.insert(name("g"), 2); + domains.insert(name("l"), 0); + domains + } + + #[test] + fn ecld_atomisation_test_tower_heights() { + let model = &sample_ecld(); + let correct_heights = correct_heights(); + let (atomised_model, towers) = degree_atomisation(model.clone()); + for x in model.ob_generators() { + let height_at_x = towers.get(&x).unwrap().len(); + let correct_height = correct_heights.get(&x).unwrap(); + assert_eq!( + height_at_x, *correct_height, + "The tower over the object {} has the wrong height", + x + ); + } + let correct_domain_indices = correct_domain_indices(); + for f in correct_domain_indices.keys() { + let atomised_dom = atomised_model.mor_generator_dom(f); + let atomised_cod = atomised_model.mor_generator_cod(f); + let base_dom = model.mor_generator_dom(f); + let base_cod = model.mor_generator_cod(f); + let dom_index = *correct_domain_indices.get(f).unwrap(); + let cod_index = *correct_heights.get(&base_cod).unwrap() - 1; + let correct_dom = &towers.get(&base_dom).unwrap()[dom_index].clone(); + let correct_cod = &towers.get(&base_cod).unwrap()[cod_index].clone(); + assert_eq!( + atomised_dom, + correct_dom.clone(), + "The morphism {} has the wrong domain", + *f + ); + assert_eq!( + atomised_cod, + correct_cod.clone(), + "The morphism {} has the wrong codomain", + *f + ); + } + } +} diff --git a/packages/catlog/src/stdlib/analyses/ecld/mod.rs b/packages/catlog/src/stdlib/analyses/ecld/mod.rs new file mode 100644 index 000000000..ac2d62a9f --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ecld/mod.rs @@ -0,0 +1,4 @@ +//! Utilities for analyses of ECLDs + +pub mod atomisations; +pub use atomisations::*; diff --git a/packages/catlog/src/stdlib/analyses/mod.rs b/packages/catlog/src/stdlib/analyses/mod.rs index c18813887..42ddd44e1 100644 --- a/packages/catlog/src/stdlib/analyses/mod.rs +++ b/packages/catlog/src/stdlib/analyses/mod.rs @@ -1,4 +1,5 @@ //! Various analyses that can be performed on models. +pub mod ecld; #[cfg(feature = "ode")] pub mod ode; diff --git a/packages/catlog/src/stdlib/models.rs b/packages/catlog/src/stdlib/models.rs index e73b0a1cf..50bfb4013 100644 --- a/packages/catlog/src/stdlib/models.rs +++ b/packages/catlog/src/stdlib/models.rs @@ -136,6 +136,36 @@ pub fn catalyzed_reaction(th: Rc) -> ModalDblModel { model } +/** A small example of an ECLD to use for testing purposes + + 0,+ 0,+ 2,- +┌───────┐ ┌───────┐ ╔═══╗ +▼ │ │ ▼ ▼ ║ +a b c d ║ +║ ▲ ▲ ║ ║ ║ +╚═══════╝ ╚═══════╝ ╚═══╝ + 3,- 1,+ +*/ +pub fn sample_ecld() -> DiscreteDblModel { + use nonempty::nonempty; + let th = super::theories::th_deg_del_signed_category(); + let mut model = DiscreteDblModel::new(Rc::new(th).clone()); + let (a, b, c, d) = (|| name("a"), || name("b"), || name("c"), || name("d")); + let (f, g, h, k, l) = (|| name("f"), || name("g"), || name("h"), || name("k"), || name("l")); + let ob_type = || name("Object"); + for x in vec![a, b, c, d].into_iter() { + model.add_ob(x(), ob_type()) + } + let neg = || name("Negative"); + let deg = || name("Degree"); + model.add_mor(f(), a(), b(), Path::Seq(nonempty![neg(), deg(), deg(), deg()])); + model.add_mor(g(), c(), b(), Path::Seq(nonempty![deg()])); + model.add_mor(h(), b(), c(), Path::Id(ob_type())); + model.add_mor(k(), b(), a(), Path::Id(ob_type())); + model.add_mor(l(), d(), d(), Path::Seq(nonempty![deg(), deg(), neg()])); + model +} + #[cfg(test)] mod tests { use super::super::theories::*; diff --git a/packages/catlog/src/stdlib/theories.rs b/packages/catlog/src/stdlib/theories.rs index efec8a73f..8dc679689 100644 --- a/packages/catlog/src/stdlib/theories.rs +++ b/packages/catlog/src/stdlib/theories.rs @@ -93,6 +93,32 @@ pub fn th_nullable_signed_category() -> DiscreteDblTheory { sgn.into() } +/** The theory of delayable signed categories with differential degree (degree-delay signed categories) + +A *degree-delay signed category* is a category sliced over the monoid (N x N x sgn) + */ +pub fn th_deg_del_signed_category() -> DiscreteDblTheory { + let mut dds = FpCategory::new(); + dds.add_ob_generator(name("Object")); + dds.add_mor_generator(name("Negative"), name("Object"), name("Object")); + dds.add_mor_generator(name("Degree"), name("Object"), name("Object")); + dds.add_mor_generator(name("Delay"), name("Object"), name("Object")); + dds.equate(Path::pair(name("Negative"), name("Negative")), Path::empty(name("Object"))); + dds.equate( + Path::pair(name("Negative"), name("Delay")), + Path::pair(name("Delay"), name("Negative")), + ); + dds.equate( + Path::pair(name("Negative"), name("Degree")), + Path::pair(name("Degree"), name("Negative")), + ); + dds.equate( + Path::pair(name("Delay"), name("Degree")), + Path::pair(name("Degree"), name("Delay")), + ); + DiscreteDblTheory::from(dds) +} + /** The theory of categories with scalars. A *category with scalars* is a category sliced over the monoid representing a walking diff --git a/packages/catlog/src/stdlib/theory_morphisms.rs b/packages/catlog/src/stdlib/theory_morphisms.rs index 5111643b8..e44927cf6 100644 --- a/packages/catlog/src/stdlib/theory_morphisms.rs +++ b/packages/catlog/src/stdlib/theory_morphisms.rs @@ -4,7 +4,7 @@ These can be used to migrate models from one theory to another. */ use crate::one::{FpFunctorData, Path, QualifiedPath}; -use crate::zero::{HashColumn, QualifiedName, name}; +use crate::zero::{name, HashColumn, QualifiedName}; type DiscreteDblTheoryMap = FpFunctorData< HashColumn, @@ -57,6 +57,24 @@ pub fn th_delayable_signed_category_to_signed_category() -> DiscreteDblTheoryMap ) } +/** Projection from theory of degree-delay signed categories. + +Sigma migration along this map forgets about the degrees and delays. + */ +pub fn th_deg_del_signed_category_to_signed_category() -> DiscreteDblTheoryMap { + FpFunctorData::new( + HashColumn::new([(name("Object"), name("Object"))].into()), + HashColumn::new( + [ + (name("Negative"), name("Negative").into()), + (name("Degree"), Path::Id(name("Object"))), + (name("Delay"), Path::Id(name("Object"))), + ] + .into(), + ), + ) +} + #[cfg(test)] mod tests { use super::super::theories::*; @@ -68,11 +86,9 @@ mod tests { assert!(th_category_to_schema().functor_into(&th_sch).validate_on(&th_cat).is_ok()); assert!(th_schema_to_category().functor_into(&th_cat).validate_on(&th_sch).is_ok()); - assert!( - th_delayable_signed_category_to_signed_category() - .functor_into(&th_signed_category().0) - .validate_on(&th_delayable_signed_category().0) - .is_ok() - ); + assert!(th_delayable_signed_category_to_signed_category() + .functor_into(&th_signed_category().0) + .validate_on(&th_delayable_signed_category().0) + .is_ok()); } } diff --git a/packages/catlog/src/zero/qualified.rs b/packages/catlog/src/zero/qualified.rs index d23a3c499..7fce976cf 100644 --- a/packages/catlog/src/zero/qualified.rs +++ b/packages/catlog/src/zero/qualified.rs @@ -236,6 +236,11 @@ impl QualifiedName { } } + /// Constructs a new qualified name by appending a new segment. + pub fn append(&mut self, id: NameSegment) -> Self { + Self([self.0.clone(), vec![id]].concat()) + } + /// Serializes the qualified name into a string. pub fn serialize_string(&self) -> String { self.segments().map(|segment| segment.serialize_string()).join(".") diff --git a/packages/frontend/src/help/logics/extended-causal-loop.mdx b/packages/frontend/src/help/logics/extended-causal-loop.mdx new file mode 100644 index 000000000..a61f0a08d --- /dev/null +++ b/packages/frontend/src/help/logics/extended-causal-loop.mdx @@ -0,0 +1,22 @@ +import { A } from "@solidjs/router"; +import { HelpAnalysisById } from "../logic_help_detail" + +## Description + +**Extended causal loop diagrams** (ECLDs) are an extension of causal loop diagrams that allow causalities (which are either positive or negative) to be labelled by two natural numbers: one corresponding to the *degree* of the effect, and the other to the *delay*. + +For example, in the intended linear ODE semantics for ECLDs, an arrow $A\xrightarrow{(+,d,k)} B$ means that $A$ positively affects the $d$-th derivative of $B$ with a delay following an Erlang distribution of shape $k$. + +Note that, although causal loop diagrams with delays also have a notion of "delayed" causality, there is not *one* single way of translating between these logics: given an ECLD we might wish to consider all causalities of delay order $ diff --git a/packages/frontend/src/stdlib/arrow_styles.module.css b/packages/frontend/src/stdlib/arrow_styles.module.css index a5ad4d350..94022b6dc 100644 --- a/packages/frontend/src/stdlib/arrow_styles.module.css +++ b/packages/frontend/src/stdlib/arrow_styles.module.css @@ -25,6 +25,8 @@ .arrow.indeterminate, .arrow.plusCaesura, .arrow.minusCaesura, +.arrow.minusDelay, +.arrow.plusDelay, .arrow.scalar { &:before, &:after { @@ -49,8 +51,42 @@ } } +.arrow.minusDeg, +.arrow.plusDeg, +.arrow.minusDegDelay, +.arrow.plusDegDelay { + background: transparent; + height: 4px; + border-top: 2px solid var(--main-color); + border-bottom: 2px solid var(--main-color); + border-radius: 1px; + + &:before, + &:after { + content: ""; + background: var(--main-color); + position: absolute; + height: 2px; + width: 12px; + border-radius: 3px; + } + + &:before { + right: -5px; + bottom: -0.5px; + transform: rotate(-45deg); + } + + &:after { + right: -5px; + top: -0.5px; + transform: rotate(45deg); + } +} + .arrowContainer.plus, -.arrowContainer.plusCaesura { +.arrowContainer.plusCaesura, +.arrowContainer.plusDelay { &:after { content: "+"; transform: none; @@ -60,8 +96,20 @@ } } +.arrowContainer.plusDeg, +.arrowContainer.plusDegDelay { + &:after { + content: "+"; + transform: none; + position: absolute; + right: -11px; + bottom: 3px; + } +} + .arrowContainer.minus, -.arrowContainer.minusCaesura { +.arrowContainer.minusCaesura, +.arrowContainer.minusDelay { &:after { content: "-"; transform: none; @@ -71,6 +119,17 @@ } } +.arrowContainer.minusDeg, +.arrowContainer.minusDegDelay { + &:after { + content: "-"; + transform: none; + position: absolute; + right: -11px; + bottom: 3px; + } +} + .arrowContainer.indeterminate { &:after { content: "?"; @@ -92,6 +151,41 @@ } } +.arrowContainer.plusDelay, +.arrowContainer.minusDelay { + &:before { + content: "‖"; + font-size: 0.75em; + position: absolute; + transform: translate(-50%, -55%); + top: 0; + left: 50%; + } +} + +.arrowContainer.plusDegDelay, +.arrowContainer.minusDegDelay { + &:before { + content: "‖"; + font-size: 0.85em; + position: absolute; + transform: translate(-50%, -40%); + top: 0; + left: 50%; + } +} + +.arrow.flat:after { + content: ""; + background: var(--main-color); + position: absolute; + border-radius: 3px; + height: 14px; + width: 2px; + bottom: -6px; + right: 0; +} + .arrowContainer.scalar { &:after { content: "∝"; diff --git a/packages/frontend/src/stdlib/arrow_styles.module.css.d.ts b/packages/frontend/src/stdlib/arrow_styles.module.css.d.ts index 83c43f99e..240b07532 100644 --- a/packages/frontend/src/stdlib/arrow_styles.module.css.d.ts +++ b/packages/frontend/src/stdlib/arrow_styles.module.css.d.ts @@ -9,8 +9,15 @@ declare const styles: { readonly indeterminate: string; readonly minus: string; readonly minusCaesura: string; + readonly minusDeg: string; + readonly minusDegDelay: string; + readonly minusDelay: string; readonly plus: string; readonly plusCaesura: string; + readonly plusDeg: string; + readonly plusDegDelay: string; + readonly plusDelay: string; readonly scalar: string; }; + export = styles; diff --git a/packages/frontend/src/stdlib/theories.ts b/packages/frontend/src/stdlib/theories.ts index 216246196..c1fd1e67c 100644 --- a/packages/frontend/src/stdlib/theories.ts +++ b/packages/frontend/src/stdlib/theories.ts @@ -74,6 +74,16 @@ stdTheories.add( async () => (await import("./theories/indeterminate-causal-loop")).default, ); +stdTheories.add( + { + id: "extended-causal-loop", + name: "Extended causal loop diagram", + description: "Causal relationships: positive or negative, with explicit degree and delay", + group: "System Dynamics", + }, + async () => (await import("./theories/extended-causal-loop")).default, +); + stdTheories.add( { id: "primitive-stock-flow", diff --git a/packages/frontend/src/stdlib/theories/extended-causal-loop.ts b/packages/frontend/src/stdlib/theories/extended-causal-loop.ts new file mode 100644 index 000000000..559fe6472 --- /dev/null +++ b/packages/frontend/src/stdlib/theories/extended-causal-loop.ts @@ -0,0 +1,104 @@ +import { ThDegDelSignedCategory } from "catlog-wasm"; + +import { Theory } from "../../theory"; +import * as analyses from "../analyses"; +import type { TheoryMeta } from "../types"; + +export default function createExtendedCausalLoopTheory(theoryMeta: TheoryMeta): Theory { + const thDegDelSignedCategory = new ThDegDelSignedCategory(); + + return new Theory({ + ...theoryMeta, + theory: thDegDelSignedCategory.theory(), + onlyFreeModels: true, + modelTypes: [ + { + tag: "ObType", + obType: { tag: "Basic", content: "Object" }, + name: "Variable", + shortcut: ["V"], + description: "Variable quantity", + }, + { + tag: "MorType", + morType: { + tag: "Hom", + content: { tag: "Basic", content: "Object" }, + }, + name: "Positive degree 0", + shortcut: ["P"], + description: "Positive influence", + arrowStyle: "plus", + preferUnnamed: true, + }, + { + tag: "MorType", + morType: { tag: "Basic", content: "Negative" }, + name: "Negative degree 0", + shortcut: ["N"], + description: "Negative influence", + arrowStyle: "minus", + preferUnnamed: true, + }, + { + tag: "MorType", + morType: { tag: "Basic", content: "Degree" }, + name: "Positive degree 1", + description: "Positive influence on the derivative", + arrowStyle: "plusDeg", + preferUnnamed: true, + }, + { + tag: "MorType", + morType: { + tag: "Composite", + content: [ + { tag: "Basic", content: "Negative" }, + { tag: "Basic", content: "Degree" }, + ], + }, + name: "Negative degree 1", + description: "Negative influence on the derivative", + arrowStyle: "minusDeg", + preferUnnamed: true, + }, + { + tag: "MorType", + morType: { + tag: "Composite", + content: [ + { tag: "Basic", content: "Degree" }, + { tag: "Basic", content: "Degree" }, + ], + }, + name: "Positive degree 2", + description: "Positive influence on the second derivative", + arrowStyle: "plusDeg", + preferUnnamed: true, + }, + { + tag: "MorType", + morType: { + tag: "Composite", + content: [ + { tag: "Basic", content: "Negative" }, + { tag: "Basic", content: "Degree" }, + { tag: "Basic", content: "Degree" }, + ], + }, + name: "Negative degree 2", + description: "Negative influence on the second derivative", + arrowStyle: "minusDeg", + preferUnnamed: true, + }, + ], + modelAnalyses: [ + analyses.configureModelGraph({ + id: "diagram", + name: "Visualization", + description: "Visualize the extended causal loop diagram", + help: "visualization", + }), + ], + }); +} diff --git a/packages/frontend/src/visualization/graph_svg.tsx b/packages/frontend/src/visualization/graph_svg.tsx index 05e3f4e97..14a3955ce 100644 --- a/packages/frontend/src/visualization/graph_svg.tsx +++ b/packages/frontend/src/visualization/graph_svg.tsx @@ -73,6 +73,9 @@ export function EdgeSVG(props: { edge: GraphLayout.Edge }) { const componentId = createUniqueId(); const pathId = () => `edge-path-${componentId}`; const defaultPath = () => ; + const invisiblePath = () => ( + + ); const tgtLabel = (text: string) => { // Place the target label offset from the target in the direction @@ -126,6 +129,60 @@ export function EdgeSVG(props: { edge: GraphLayout.Edge }) { + + + + + {tgtLabel("+")} + + + + + + {tgtLabel("-")} + + + {defaultPath()} + {tgtLabel("+")} + + + {"‖"} + + + + + {defaultPath()} + {tgtLabel("-")} + + + {"‖"} + + + + + + + + {tgtLabel("+")} + {invisiblePath()} + + + {"‖"} + + + + + + + + {tgtLabel("-")} + {invisiblePath()} + + + {"‖"} + + + {defaultPath()} {tgtLabel("∝")} @@ -204,11 +261,17 @@ const styleToMarker: Record = { default: "vee", double: "double", flat: "flat", - plus: "triangle", - minus: "triangle", + plus: "vee", + minus: "vee", indeterminate: "triangle", plusCaesura: "triangle", minusCaesura: "triangle", + plusDeg: "double", + minusDeg: "double", + plusDelay: "vee", + minusDelay: "vee", + plusDegDelay: "double", + minusDegDelay: "double", scalar: "triangle", }; diff --git a/packages/frontend/src/visualization/types.ts b/packages/frontend/src/visualization/types.ts index cec52157b..8918c71d4 100644 --- a/packages/frontend/src/visualization/types.ts +++ b/packages/frontend/src/visualization/types.ts @@ -13,6 +13,12 @@ export type ArrowStyle = | "indeterminate" | "plusCaesura" | "minusCaesura" + | "plusDeg" + | "minusDeg" + | "plusDelay" + | "minusDelay" + | "plusDegDelay" + | "minusDegDelay" | "scalar"; /** Prop for forwarding a ref to an `` element.