Skip to content

Commit 5579937

Browse files
ENH: Stock-flow diagrams with signed links (#905)
Defines the theory plus a generalized mass-action ODE semantics accounting for positive and negative links. --------- Co-authored-by: Evan Patterson <[email protected]>
1 parent 3082644 commit 5579937

File tree

7 files changed

+220
-26
lines changed

7 files changed

+220
-26
lines changed

packages/catlog-wasm/src/theories.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,39 @@ impl ThCategoryLinks {
304304
}
305305
}
306306

307+
/// The theory of categories with signed links.
308+
#[wasm_bindgen]
309+
pub struct ThCategorySignedLinks(Rc<theory::DiscreteTabTheory>);
310+
311+
#[wasm_bindgen]
312+
impl ThCategorySignedLinks {
313+
#[wasm_bindgen(constructor)]
314+
pub fn new() -> Self {
315+
Self(Rc::new(theories::th_category_signed_links()))
316+
}
317+
318+
#[wasm_bindgen]
319+
pub fn theory(&self) -> DblTheory {
320+
DblTheory(self.0.clone().into())
321+
}
322+
323+
/// Simulates the mass-action ODE system derived from a model.
324+
#[wasm_bindgen(js_name = "massAction")]
325+
pub fn mass_action(
326+
&self,
327+
model: &DblModel,
328+
data: analyses::ode::MassActionProblemData,
329+
) -> Result<ODEResult, String> {
330+
Ok(ODEResult(
331+
analyses::ode::StockFlowMassActionAnalysis::default()
332+
.build_numerical_system(model.discrete_tab()?, data)
333+
.solve_with_defaults()
334+
.map_err(|err| format!("{err:?}"))
335+
.into(),
336+
))
337+
}
338+
}
339+
307340
/// The theory of strict symmetric monoidal categories.
308341
#[wasm_bindgen]
309342
pub struct ThSymMonoidalCategory(Rc<theory::ModalDblTheory>);

packages/catlog/src/stdlib/analyses/ode/mass_action.rs

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ impl StochasticMassActionAnalysis {
8282
}
8383

8484
/// Symbolic parameter in mass-action polynomial system.
85-
type Parameter<Id> = Polynomial<Id, f32, u8>;
85+
type Parameter<Id> = Polynomial<Id, f32, i8>;
8686

8787
/// Mass-action ODE analysis for Petri nets.
8888
///
@@ -110,7 +110,7 @@ impl PetriNetMassActionAnalysis {
110110
pub fn build_system(
111111
&self,
112112
model: &ModalDblModel,
113-
) -> PolynomialSystem<QualifiedName, Parameter<QualifiedName>, u8> {
113+
) -> PolynomialSystem<QualifiedName, Parameter<QualifiedName>, i8> {
114114
let mut sys = PolynomialSystem::new();
115115
for ob in model.ob_generators_with_type(&self.place_ob_type) {
116116
sys.add_term(ob, Polynomial::zero());
@@ -146,7 +146,7 @@ impl PetriNetMassActionAnalysis {
146146
&self,
147147
model: &ModalDblModel,
148148
data: MassActionProblemData,
149-
) -> ODEAnalysis<NumericalPolynomialSystem<u8>> {
149+
) -> ODEAnalysis<NumericalPolynomialSystem<i8>> {
150150
into_numerical_system(self.build_system(model), data)
151151
}
152152

@@ -215,10 +215,12 @@ impl PetriNetMassActionAnalysis {
215215
pub struct StockFlowMassActionAnalysis {
216216
/// Object type for stocks.
217217
pub stock_ob_type: TabObType,
218-
/// Morphism types for flows between stocks.
218+
/// Morphism type for flows between stocks.
219219
pub flow_mor_type: TabMorType,
220-
/// Morphism types for links for stocks to flows.
221-
pub link_mor_type: TabMorType,
220+
/// Morphism type for positive links from stocks to flows.
221+
pub pos_link_mor_type: TabMorType,
222+
/// Morphism type for negative links from stocks to flows.
223+
pub neg_link_mor_type: TabMorType,
222224
}
223225

224226
impl Default for StockFlowMassActionAnalysis {
@@ -228,7 +230,8 @@ impl Default for StockFlowMassActionAnalysis {
228230
Self {
229231
stock_ob_type,
230232
flow_mor_type,
231-
link_mor_type: TabMorType::Basic(name("Link")),
233+
pos_link_mor_type: TabMorType::Basic(name("Link")),
234+
neg_link_mor_type: TabMorType::Basic(name("NegativeLink")),
232235
}
233236
}
234237
}
@@ -238,26 +241,34 @@ impl StockFlowMassActionAnalysis {
238241
pub fn build_system(
239242
&self,
240243
model: &DiscreteTabModel,
241-
) -> PolynomialSystem<QualifiedName, Parameter<QualifiedName>, u8> {
242-
let mut terms: HashMap<QualifiedName, Monomial<QualifiedName, u8>> = model
244+
) -> PolynomialSystem<QualifiedName, Parameter<QualifiedName>, i8> {
245+
let mut terms: HashMap<QualifiedName, Monomial<QualifiedName, i8>> = model
243246
.mor_generators_with_type(&self.flow_mor_type)
244247
.map(|flow| {
245248
let dom = model.mor_generator_dom(&flow).unwrap_basic();
246249
(flow, Monomial::generator(dom))
247250
})
248251
.collect();
249252

250-
for link in model.mor_generators_with_type(&self.link_mor_type) {
253+
let mut multiply_for_link = |link: QualifiedName, exponent: i8| {
251254
let dom = model.mor_generator_dom(&link).unwrap_basic();
252255
let path = model.mor_generator_cod(&link).unwrap_tabulated();
253256
let Some(TabEdge::Basic(cod)) = path.only() else {
254257
panic!("Codomain of link should be basic morphism");
255258
};
256259
if let Some(term) = terms.get_mut(&cod) {
257-
*term = std::mem::take(term) * Monomial::generator(dom);
260+
let mon: Monomial<_, i8> = [(dom, exponent)].into_iter().collect();
261+
*term = std::mem::take(term) * mon;
258262
} else {
259263
panic!("Codomain of link does not belong to model");
260264
};
265+
};
266+
267+
for link in model.mor_generators_with_type(&self.pos_link_mor_type) {
268+
multiply_for_link(link, 1);
269+
}
270+
for link in model.mor_generators_with_type(&self.neg_link_mor_type) {
271+
multiply_for_link(link, -1);
261272
}
262273

263274
let terms: Vec<_> = terms
@@ -289,15 +300,15 @@ impl StockFlowMassActionAnalysis {
289300
&self,
290301
model: &DiscreteTabModel,
291302
data: MassActionProblemData,
292-
) -> ODEAnalysis<NumericalPolynomialSystem<u8>> {
303+
) -> ODEAnalysis<NumericalPolynomialSystem<i8>> {
293304
into_numerical_system(self.build_system(model), data)
294305
}
295306
}
296307

297308
fn into_numerical_system(
298-
sys: PolynomialSystem<QualifiedName, Parameter<QualifiedName>, u8>,
309+
sys: PolynomialSystem<QualifiedName, Parameter<QualifiedName>, i8>,
299310
data: MassActionProblemData,
300-
) -> ODEAnalysis<NumericalPolynomialSystem<u8>> {
311+
) -> ODEAnalysis<NumericalPolynomialSystem<i8>> {
301312
let ob_index: IndexMap<_, _> =
302313
sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect();
303314
let n = ob_index.len();
@@ -335,6 +346,30 @@ mod tests {
335346
expected.assert_eq(&sys.to_string());
336347
}
337348

349+
#[test]
350+
fn positive_backward_link_dynamics() {
351+
let th = Rc::new(th_category_signed_links());
352+
let model = positive_backward_link(th);
353+
let sys = StockFlowMassActionAnalysis::default().build_system(&model);
354+
let expected = expect!([r#"
355+
dx = ((-1) f) x y
356+
dy = f x y
357+
"#]);
358+
expected.assert_eq(&sys.to_string());
359+
}
360+
361+
#[test]
362+
fn negative_backward_link_dynamics() {
363+
let th = Rc::new(th_category_signed_links());
364+
let model = negative_backward_link(th);
365+
let sys = StockFlowMassActionAnalysis::default().build_system(&model);
366+
let expected = expect!([r#"
367+
dx = ((-1) f) x y^{-1}
368+
dy = f x y^{-1}
369+
"#]);
370+
expected.assert_eq(&sys.to_string());
371+
}
372+
338373
#[test]
339374
fn catalysis_dynamics() {
340375
let th = Rc::new(th_sym_monoidal_category());

packages/catlog/src/stdlib/models.rs

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,38 @@ pub fn walking_attr(th: Rc<DiscreteDblTheory>) -> DiscreteDblModel {
8989
/// morphism back to the morphism itself.
9090
///
9191
/// In system dynamics jargon, a backward link defines a "reinforcing loop,"
92-
/// assuming the link has a positive effect on the flow. An example is an infection
93-
/// flow in a model of an infectious disease, where increasing the number of
94-
/// infectives increases the rate of infection of the remaining susceptibles (other
95-
/// things equal).
92+
/// assuming the link has a positive effect on the flow. An example is an
93+
/// infection flow an infectious disease model, where increasing the number of
94+
/// infectives increases the rate of infection of the remaining susceptibles
95+
/// (other things equal).
9696
pub fn backward_link(th: Rc<DiscreteTabTheory>) -> DiscreteTabModel {
97+
backward_link_of_type(th, TabMorType::Basic(name("Link")))
98+
}
99+
100+
/// The "walking" backward positive link.
101+
///
102+
/// This is the free category with signed links that has a positive link from
103+
/// the codomain of a morphism back to the morphism itself.
104+
pub fn positive_backward_link(th: Rc<DiscreteTabTheory>) -> DiscreteTabModel {
105+
// The type for positive links is just `Link`.
106+
backward_link_of_type(th, TabMorType::Basic(name("Link")))
107+
}
108+
109+
/// The "walking" backward negative link.
110+
///
111+
/// This is the free category with signed links that has a negative link from
112+
/// the codomain of a morphism back to the morphism itself.
113+
pub fn negative_backward_link(th: Rc<DiscreteTabTheory>) -> DiscreteTabModel {
114+
backward_link_of_type(th, TabMorType::Basic(name("NegativeLink")))
115+
}
116+
117+
fn backward_link_of_type(th: Rc<DiscreteTabTheory>, link_type: TabMorType) -> DiscreteTabModel {
97118
let ob_type = TabObType::Basic(name("Object"));
98119
let mut model = DiscreteTabModel::new(th.clone());
99120
model.add_ob(name("x"), ob_type.clone());
100121
model.add_ob(name("y"), ob_type.clone());
101122
model.add_mor(name("f"), name("x").into(), name("y").into(), th.hom_type(ob_type));
102-
model.add_mor(
103-
name("link"),
104-
name("y").into(),
105-
model.tabulated_gen(name("f")),
106-
TabMorType::Basic(name("Link")),
107-
);
123+
model.add_mor(name("link"), name("y").into(), model.tabulated_gen(name("f")), link_type);
108124
model
109125
}
110126

@@ -192,6 +208,13 @@ mod tests {
192208
assert!(backward_link(th).validate().is_ok());
193209
}
194210

211+
#[test]
212+
fn categories_with_signed_links() {
213+
let th = Rc::new(th_category_signed_links());
214+
assert!(positive_backward_link(th.clone()).validate().is_ok());
215+
assert!(negative_backward_link(th.clone()).validate().is_ok());
216+
}
217+
195218
#[test]
196219
fn sym_monoidal_categories() {
197220
let th = Rc::new(th_sym_monoidal_category());

packages/catlog/src/stdlib/theories.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ pub fn th_category_links() -> DiscreteTabTheory {
119119
th
120120
}
121121

122+
/// The theory of categories with signed links.
123+
///
124+
/// It can be useful to consider a version of stock and flow diagrams where the
125+
/// links are labelled with a sign: positive or negative
126+
pub fn th_category_signed_links() -> DiscreteTabTheory {
127+
let mut th = DiscreteTabTheory::new();
128+
th.add_ob_type(name("Object"));
129+
let ob_type = TabObType::Basic(name("Object"));
130+
th.add_mor_type(name("Link"), ob_type.clone(), th.tabulator(th.hom_type(ob_type.clone())));
131+
th.add_mor_type(
132+
name("NegativeLink"),
133+
ob_type.clone(),
134+
th.tabulator(th.hom_type(ob_type.clone())),
135+
);
136+
th
137+
}
138+
122139
/// The theory of strict monoidal categories.
123140
pub fn th_monoidal_category() -> ModalDblTheory {
124141
th_list_algebra(List::Plain)

packages/catlog/src/zero/rig.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,5 +633,8 @@ mod tests {
633633

634634
let monomial: Monomial<_, u32> = [('x', 1), ('y', 0), ('x', 2)].into_iter().collect();
635635
assert_eq!(monomial.normalize().to_string(), "x^3");
636+
637+
let monomial: Monomial<_, i32> = [('x', -1), ('y', -2), ('x', 2)].into_iter().collect();
638+
assert_eq!(monomial.normalize().to_string(), "x y^{-2}");
636639
}
637640
}

packages/frontend/src/stdlib/theories.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,24 @@ stdTheories.add(
8585
{
8686
id: "primitive-stock-flow",
8787
name: "Stock and flow",
88-
description: "Model accumulation (stocks) and change (flows)",
89-
iconLetters: ["S", "f"],
88+
description: "Accumulation (stocks) and change (flows)",
89+
iconLetters: ["S", "F"],
9090
group: "System Dynamics",
9191
},
9292
async () => (await import("./theories/primitive-stock-flow")).default,
9393
);
9494

95+
stdTheories.add(
96+
{
97+
id: "primitive-signed-stock-flow",
98+
name: "Stock and flow with signed links",
99+
description: "Accumulation (stocks) and change (flows), with signed links",
100+
iconLetters: ["S", "F"],
101+
group: "System Dynamics",
102+
},
103+
async () => (await import("./theories/primitive-signed-stock-flow")).default,
104+
);
105+
95106
stdTheories.add(
96107
{
97108
id: "reg-net",
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { ThCategorySignedLinks } from "catlog-wasm";
2+
import { Theory, type TheoryMeta } from "../../theory";
3+
import * as analyses from "../analyses";
4+
import styles from "../styles.module.css";
5+
import svgStyles from "../svg_styles.module.css";
6+
7+
export default function createPrimitiveSignedStockFlowTheory(theoryMeta: TheoryMeta): Theory {
8+
const thCategorySignedLinks = new ThCategorySignedLinks();
9+
10+
return new Theory({
11+
...theoryMeta,
12+
theory: thCategorySignedLinks.theory(),
13+
onlyFreeModels: true,
14+
modelTypes: [
15+
{
16+
tag: "ObType",
17+
obType: { tag: "Basic", content: "Object" },
18+
name: "Stock",
19+
description: "Thing with an amount",
20+
shortcut: ["S"],
21+
cssClasses: [styles.box],
22+
svgClasses: [svgStyles.box],
23+
},
24+
{
25+
tag: "MorType",
26+
morType: {
27+
tag: "Hom",
28+
content: { tag: "Basic", content: "Object" },
29+
},
30+
name: "Flow",
31+
description: "Flow from one stock to another",
32+
shortcut: ["F"],
33+
arrowStyle: "double",
34+
},
35+
{
36+
tag: "MorType",
37+
morType: { tag: "Basic", content: "Link" },
38+
name: "Positive link",
39+
description: "Positive influence of a stock on a flow",
40+
arrowStyle: "plus",
41+
preferUnnamed: true,
42+
shortcut: ["P"],
43+
},
44+
{
45+
tag: "MorType",
46+
morType: { tag: "Basic", content: "NegativeLink" },
47+
name: "Negative link",
48+
description: "Negative influence of a stock on a flow",
49+
arrowStyle: "minus",
50+
preferUnnamed: true,
51+
shortcut: ["N"],
52+
},
53+
],
54+
modelAnalyses: [
55+
analyses.stockFlowDiagram({
56+
id: "diagram",
57+
name: "Visualization",
58+
description: "Visualize the stock and flow diagram",
59+
help: "visualization",
60+
}),
61+
analyses.massAction({
62+
simulate(model, data) {
63+
return thCategorySignedLinks.massAction(model, data);
64+
},
65+
transitionType: {
66+
tag: "Hom",
67+
content: { tag: "Basic", content: "Object" },
68+
},
69+
}),
70+
],
71+
});
72+
}

0 commit comments

Comments
 (0)