Domain Specific Languages
using StockFlow
using StockFlow.Syntax # stock_and_flow, foot, feet
using StockFlow.Syntax.Stratification # stratify, n_stratify
using StockFlow.Syntax.Composition # compose
using StockFlow.Syntax.Rewrite # rewrite
using StockFlow.Syntax.Homomorphism # hom
Syntax
@stock_and_flow
Creates a StockAndFlowF based upon the provided block expression. Block has five headers you can use in it:
@stock_and_flow begin
# to specify stock names
:stocks
S
I
R
# to specify parameter names, which will be given values from outside
# the model when evaluated
:parameters
β
λ
δ
# to specify sum variables, which have at any given time have value
# equal to the sum of all their linked stocks
:sums
N = [S, I, R]
NI = [I]
None = [] # 0
# to specify how stocks, parameters, sums and other dynamic variables
# relate to each other. We use these to determine flow rates.
# If we specify a dynamic variable with multiple operators, it'll
# automatically be split into multiple.
:dynamic_variables
v₁ = S + I / N # this will be split into two dyvars
v₂ = *(β, I)
v₃ = v₂ + 1
v₄ = log(S)
# to specify the rate at which members of one population will
# leave to become members of another population.
# Use CLOUD or ☁ (\:cloud:) to indicate entering or leaving model.
# You can also use the dynamic variable format inside a flow to create
# a dynamic variable
:flows
CLOUD => f1(v1) => S
S => Deaths_S(δ * S) => CLOUD
S => infections(+λ) => I
I => recoveries(v₄ + v₃ / v₂) => R
end
The specific values in the above example are chosen to show the capabilities of the syntax. For more realistic examples, see src/PremadeModels.jl.
At present, any julia function is allowed to act as an operator for a dynamic variable, but in the future it'll be restricted to the below list:
Binary: :+, :-, :*, :/, :÷, :^, :%, :log
Unary: :log, :exp, :sqrt, :+, :- :plus_one, :minus_one, :reciprocal, :one_minus, :plus_two, :minus_two
The unary functions can be expressed as plus_one(X), or how you'd expect, as X + 1 or 1 + X.
@foot
Creates a StockAndFlow0 with stocks, sums, and links between them. Takes the form @foot mystock => Y, B => Y, ..., where in each pair, the first symbol is a stock and the second is a sum. Multiple occurences of the same symbol will be interpreted as the same instance of that sum or stock. If you have the same link multiple times, it will not be deduplicated. Use () => Y to indicate no stock, X => () to indicate no sum, or () => () to indicate an empty foot.
(@foot () => ()) == StockAndFlow0([],[],[])
(@foot () => X) == StockAndFlow0([],[:X],[])
(@foot Y => ()) == StockAndFlow0([:Y],[],[])
(@foot Y => X) == StockAndFlow0([:Y],[:X],[:Y => :X])
(@foot Y => X, () => ()) == StockAndFlow0([:Y],[:X],[:Y => :X])
(@foot Y => X, Y => X) == StockAndFlow0([:Y],[:X],[:Y => :X, :Y => :X])
(@foot Y => X, A => X) == StockAndFlow0([:Y, :A],[:X],[:Y => :X, :A => :X])
(@foot Y => X, Y => Z) == StockAndFlow0([:Y],[:X, :Z],[:Y => :X, :Y => :Z])
@feet
Given a block expression, produces an array of feet for each line. An empty block produces an empty array.
(@feet begin end) == Vector{StockAndFlow0}()
(@feet begin
() => ()
end) == [StockAndFlow0([],[],[])]
(@feet begin
A => B, AA => B
X => Y, X => B, Z => ()
end) == ([StockAndFlow0([:A, :AA],[:B],[:A => :B, :AA => :B]),
StockAndFlow0([:X, :Z],[:Y, :B],[:X => :Y, :X => :B])])
@causal_loop
Create a causal loop, with or without polarities. X => +Y indicates a positive polarity, X => -Y indicates a negative polarity, and if there are any edges X => Y without polarities, the ultimate causal loop diagram will be without polarities.
Use :nodes to indicate nodes, and :edges to indicate edges.
If there are no edges, will default to a causal loop with polarities.
Note, for functions which return indices for edges, negative edges come after positive edges.
Avoid using nodes with the same name.
# Triangle, without polarities.
@causal_loop begin
:nodes
A
B
C
:edges
A => B
B => C
C => A
end
# Triangle, with polarities, creating a balancing loop.
@causal_loop begin
:nodes
A
B
C
:edges
A => -B
B => +C
C => +A
end
# Causal loop with two nodes and three edges;
# A has a positive self edge, and A has two edges to B, one positive, one negative.
@causal_loop begin
:nodes
A
B
:edges
A => +A
A => +B
A => -B
end
# Empty causal loop, treated with polarities.
@causal_loop begin end
@cl
Compressed syntax for creating a causal loop diagram. Similar to above.
Separate statements with commas, and use a symbol on its own to indicate a node without an edge. Nodes will be ordered in the order they're encountered.
# Triangle, without polarities.
(@cl A => B, B => C, C => A)
# Triangle, with polarities, creating a balancing loop.
(@cl A => -B, B => +C C => +A)
# No edges between nodes, treated as a causal loop with polarities.
(@cl A, B, C)
# Same as first, but now C is node 1, B is node 2, and A is node 3.
(@cl C, B, A => B, B => C, C => A)
# Empty causal loop, treated as with polarities.
(@cl)
Stratification
@stratify
Given three stockflows X, Z, Y and an expression block, create a new stockflow representing the pullback of X -> Z and Y -> Z.
Equivalent to explicitly defining the StockFlow homomorphisms X -> Z and Y -> Z and taking their pullback.
The particular morphisms can be inferred based on what each object in X and Z maps to in Y.
For :stocks, :flows, :dynamic_variables, :parameters and :sums in X and Y, indicate what each element of X maps to on the left and Y on the right. So, if stocks x1, x2 in X and z in Z map to y in Y, you write :stocks x1, x2 => y <= z
Prefix ~ to indicate a substring match and _ to match everything else.
If there only exists one of a particular type of object which can be mapped to, the maps don't need to be made explicit. EG, if there only exists one sum variable in Y, you don't need to have a :sums header.
If there exist multiple matches for an object (eg, you have A twice, or you match A then have an _), then only the first will be used.
Using stockflows with duplicate names could lead to unpredictable results and is strongly recommended against.
@stratify WeightModel l_type ageWeightModel begin
# Don't need stocks header, because there's only one
:stocks
_ => pop <= _
:flows
~Death => f_death <= ~Death
~id => f_aging <= ~aging
~Becoming => f_fstOrder <= ~id
_ => f_birth <= f_NB
:dynamic_variables
v_NewBorn => v_birth <= v_NB
~Death => v_death <= ~Death
~id => v_aging <= v_agingCA, v_agingAS
# Could match the right side with ~id
v_BecomingOverWeight, v_BecomingObese => v_fstOrder <= v_idC, v_idA, v_idS
:parameters
μ => μ <= μ
δw, δo => δ <= δC, δA, δS
rw, ro => rFstOrder <= r
rage => rage <= rageCA, rageAS
# Similar to stocks, don't need a sums header.
:sums
N => N <= N
end
Given n stockflows A_1, ..., A_{n-1}, Z and an expression block, create a new stockflow representing the pullback of A_1 -> Z, ..., A_{n-1} -> Z.
Use an ordered list of tuples to indicate the ith stockflow. If there is a single object in A_i mapping to an object in Z, the tuple isn't necessary. If 0 map to it, use an empty tuple (though, in that case, why does Z have it at all?)
_ still acts as a default match, and ~ as a subtring match. They each act on their particular stockflow.
@n_stratify WeightModel ageWeightModel l_type begin
# Once again, stocks header isn't necessary
:stocks
[_, _] => pop
:flows
[~Death, ~Death] => f_death
[~id, ~aging] => f_aging
[~Becoming, ~id] => f_fstOrder
[_, f_NB] => f_birth
:dynamic_variables
[v_NewBorn, v_NB] => v_birth
[~Death, ~Death] => v_death
[~id, (v_agingCA, v_agingAS)] => v_aging
[(v_BecomingOverWeight, v_BecomingObese), (v_idC, v_idA, v_idS)] => v_fstOrder
:parameters
[μ, μ] => μ
[(δw, δo), (δC, δA, δS)] => δ
[(rw, ro), r] => rFstOrder
[rage, (rageCA, rageAS)] => rage
# Nor is sums header necessary
:sums
[N,N] => N
end
Composition
@compose
Given n stockflows A_1, ..., A_n and an expression block, return a new stockflow such that specified shared stocks, sums and links between them are treated as the same.
First line of the expression block must be the aliases used for the corresponding stockflow in the block. Every alias must be unique.
Use the same syntax for @foot to specify feet to be composed on. Each line takes the form (X, Y) ^ A => B, C => (), where the left side of ^ are stockflows, the right are feet. Right side feet can optionally be in a tuple. If only one stockflow is on the left, it doesn't need to be in a tuple.
Cannot compose on an empty foot. Cannot use a foot which has been used on a previous line. Changing the order of stock-sum links - eg, A => B, C => () and C => (), A => B - will be treated as different feet. Each stockflow involved in a particular composition must have all stocks and sums which are in the corresponding foot.
Composition with no stockflows given as argument returns an empty stockflow.
Composition also works with causal loop diagrams. If using causal loop with polarities, the edges you compose on must have the same polarities; you cannot compose A => +B with A => -B.
(@compose begin
()
end) == (@compose begin end) == StockAndFlow()
sirv = @compose sir svi begin
(sir, svi)
(sir, svi) ^ S => N, I => N
end
XAY_model = @compose X SIS_A SIS_Y begin
(X, A, Y)
(X, A, Y) ^ X => N
(A, Y) ^ () => NI
end
Diabetes_Model = @compose Model_Normoglycemic Model_Hyperglycemic Model_Norm_Hyper begin
(Normo, Hyper, NH)
(Normo, NH) ^ NormalWeight => N
(Normo, NH) ^ OverWeight => N
(Normo, NH) ^ Obese => N
(Hyper, NH) ^ Prediabetic_U => N
(Hyper, NH) ^ Prediabetic_D => N
end
ABC = (@cl A => B, B => C)
BCD = (@cl B => C, C => D)
ABCD = @compose ABC BCD begin
(ABC, BCD)
(ABC, BCD) ^ B => C
end
Rewrite
@rewrite
Given a stockflow sf and an expression block, create a new stockflow with edits made based on the block. The expression block will be used to create three stockflows L, I, R such that sf ⊇ L ⊇ I and R ⊇ I, then apply a rewrite rule to replace L with R in sf.
Every object in sf must have a unique name.
Rewrite has 8 headers, :stocks, :flows, :sums, :dynamic_variables, :redefs, :removes, and :dyvar_swaps
The first five are used to indicate if an instance of that type is being added. Use the same definition syntax as in @stock_and_flow.
:redefs is used to change the definition of a dynamic variable, flow or sum. Again, use the same syntax as in @stock_and_flow.
:removes indicates that an object should be deleted. You just need the name of it in the original.
:dyvar_swaps is used to swap all instances of an object inside dynamic variables with another object. Use the notation A => B to indicate every A in a dynamic variable should now instead be B.
For this to work, there must exist homomorphisms I => L and I => R, and every name in the original stockflow must be unique.
aged_sir_rewritten = @rewrite aged_sir begin
:redefs
v_meanInfectiousContactsPerSv_cINC = cc_C * v_prevalencev_INC_post
v_meanInfectiousContactsPerSv_cINA = cc_A * v_prevalencev_INA_post
:parameters
fcc
fca
fac
faa
:dynamic_variables
v_CCContacts = fcc * v_prevalencev_INC
v_CAContacts = fca * v_prevalencev_INA
v_ACContacts = fac * v_prevalencev_INC
v_AAContacts = faa * v_prevalencev_INA
v_prevalencev_INC_post = v_CCContacts + v_CAContacts
v_prevalencev_INA_post = v_ACContacts + v_AAContacts
end
Covid19_rewritten = @rewrite COVID19 begin
:dyvar_swaps
λ => v_NewIncidence₂
rw_v => rw
:removes
λ
rw_v
end
sirv_rewritten = @rewrite sirv begin
:dyvar_swaps
lambda => v_inf₂
rdeath_svi => rdeath
:removes
lambda
rdeath_svi
end