Creating multidimensional SD Models
system dynamics, systemdynamics, sd dsl, bptk, bptk-py, python, business simulation
Multidimensional SD Models
This document is the reference for arrays in the SD DSL: how to give an element a shape, which operators work on it, what each one returns, and what is deliberately not supported. Every section runs its own small example, so you can read it as a catalogue and copy from it.
For arrays in a model that does something, the Model Library has two worked examples: a workforce aging chain over a vector of seniority levels, and a regional product portfolio built on a matrix of products across regions.
We start with some boilerplate to get a BPTK project up and running:
This is already enough to define arrayed components.
How to define arrayed components
There are two options for arrayed components:
- Vectors (one dimensional arrays)
- Matrices (two dimensional arrays)
Moreover, both types of arrays - Vectors and Matrices - can be setup:
- using numerical indices
- using string-valued indices (named arrays)
Lets have a look at some examples:
As can be seen, we need two parameters for setting up a Vector using numerical indices. Moreover, there is one optional parameter.
| Parameter | Type | Meaning |
|---|---|---|
| size | Integer | Defines the length of the Vector |
| values | List of Float/Integer | Defines the values of the Vector elements |
| set_stack_equation | Boolean | (optional) If the element is a stock, the initial value is set (False) or the equation is set (True). Default is False. |
And we need one parameter (+ one optional parameter) for setting up a Vector using string indices:
| Parameter | Type | Meaning |
|---|---|---|
| values | Dictionary | Defines the string-values indices and their values |
| set_stack_equation | Boolean | (optional) If the element is a stock, the initial value is set (False) or the equation is set (True). Default is False. |
values is either one value per index, or a single value that every index gets - setup_vector(2, 3.0) above is the short form of setup_vector(2, [3.0, 3.0]). set_stack_equation has a section of its own below.
For matrices, we can proceed completely similar.
As can be seen, we need two parameters (+ one optional parameter) for setting up a Matrix using numerical indices:
| Parameter | Type | Meaning |
|---|---|---|
| size | List (tuple) of Integer | Defines the size of the Matrix |
| values | List of Lists of Float/Integer | Defines the values of the Matrix elements |
| set_stack_equation | Boolean | (optional) If the element is a stock, the initial value is set (False) or the equation is set (True). Default is False. |
And we need one parameter (+ one optional parameter) for setting up a Matrix using string-valued indices:
| Parameter | Type | Meaning |
|---|---|---|
| values | Dictionary | Defines the string-values indices and their values |
| set_stack_equation | Boolean | (optional) If the element is a stock, the initial value is set (False) or the equation is set (True). Default is False. |
A matrix takes a single value as well: setup_matrix([2, 2], 3.0) gives a 2x2 matrix whose four cells all hold 3.0.
What set_stack_equation does
For every element except a stock, the values handed to setup_vector and its three siblings are simply the sub-elements’ values, and set_stack_equation changes nothing.
A stock is the one case where those values could mean two different things, and this is the flag that decides which:
False, the default: each value is the initial value of its sub-stock. The sub-stock starts there and then accumulates whatever flows you give it.True: each value becomes the sub-stock’s equation, that is its net change per unit of time. The sub-stock starts at 0.0 and adds that value at every step.
So the same call means “start at 10 and 20” or “grow by 10 and 20 per period”:
set_stack_equation=False (initial values, plus a flow of 1.0 and 2.0): index 0: [10.0, 11.0, 12.0, 13.0] index 1: [20.0, 22.0, 24.0, 26.0] set_stack_equation=True (the values are the stock's equation): index 0: [0.0, 10.0, 20.0, 30.0] index 1: [0.0, 20.0, 40.0, 60.0]
Math-Operations for arrayed Components
Arrays are not a special case in the SD DSL: every operator and function that takes an operand works on an arrayed element, element by element. That covers the four arithmetic operations, powers and modulo, the math functions, comparisons and conditionals, max and min, the table and time functions, and the stateful smooth, trend and delay.
On top of that there are the array-specific operations, which are the ones that do something an element-wise operator cannot: they aggregate a whole array into a single value, or multiply arrays in the linear-algebra sense.
Standard Operations
The arithmetic operations \(+\), \(-\), \(*\), \(/\) as well as \(**\) (power) and \(\%\) (modulo) accept any of these operand pairings:
| Operand 1 | Operand 2 |
|---|---|
| Arrayed Element | Arrayed Element |
| Arrayed Element | Scalar Element |
| Scalar Element | Arrayed Element |
| Arrayed Element | Float/Integer |
| Float/Integer | Arrayed Element |
Scalar Element and Float/Integer are not the same thing. A scalar element is a model element without a shape - a constant, converter, stock or flow - so it has an equation of its own and its value can change over the course of a simulation, and changing it changes every index that reads it. A Float or Integer is a plain Python number written into the equation, fixed for the whole run. Both are allowed on either side of the operator; v * 2.0 and v * some_constant differ only in whether the factor can still move.
⚠️ If both operands are arrayed elements, they must have the same numerical or string-valued indices.
That means it is not possible to have operand 1 = vector with numerical indices and operand 2 = vector with string-valued indices, even if they have the same size.
It is also not possible to have operand 1 = vector and operand 2 = matrix or vice versa - there is no broadcasting. A mismatch raises an exception when the equation is assigned rather than guessing what was meant: mixing a vector and a matrix gives Attempted invalid array addition, two vectors of different length Cannot perform binary operation on arrays with different sizes, and a numerical against a named vector Cannot perform binary operation on arrays with different indices.
Every one of these operations is performed element-wise: index by index, never across indices. Let’s have a look at some examples:
Addition (\(+\))
\[\begin{equation*} \begin{pmatrix} 1.1 \\ 2.2 \end{pmatrix} + \begin{pmatrix} 3.1 \\ 4.2 \end{pmatrix} = \begin{pmatrix} 4.2 \\ 6.4 \end{pmatrix} \end{equation*}\]
[ 4.2 , 6.4 ]
\[\begin{equation*} \begin{pmatrix} 3.1 \\ 4.2 \end{pmatrix} +1.0 = \begin{pmatrix} 4.1 \\ 5.2 \end{pmatrix} \end{equation*}\]
[ 4.1 , 5.2 ]
Subtraction (\(-\))
\[\begin{equation*} \begin{pmatrix} 1.1 & 2.2 \\ 3.3 & 4.4 \end{pmatrix} - \begin{pmatrix} 5.5 & 7.7 \\ 3.3 & 14.4 \end{pmatrix} = \begin{pmatrix} -4.4 & -5.5\\ 0.0 & -10.0 \end{pmatrix} \end{equation*}\]
[ [-4.4 , -5.5] [0.0 , -10.0] ]
\[\begin{equation*} \begin{pmatrix} 5.5 & 7.7 \\ 3.3 & 14.4 \end{pmatrix} -1.0 = \begin{pmatrix} 4.5 & 6.7\\ 2.3 & 13.4 \end{pmatrix} \end{equation*}\]
[ [4.5 , 6.7] [2.3 , 13.4] ]
Multiplication (\(*\))
\[\begin{equation*} \begin{pmatrix} 4.0\\ 5.0 \end{pmatrix} \odot \begin{pmatrix} 6.0\\ 7.0 \end{pmatrix} = \begin{pmatrix} 24.0\\ 35.0 \end{pmatrix} \end{equation*}\]
[ 24.0 , 35.0 ]
\[\begin{equation*} \begin{pmatrix} 6.0\\ 7.0 \end{pmatrix} \cdot 3.0 = \begin{pmatrix} 18.0\\ 21.0 \end{pmatrix} \end{equation*}\]
[ 18.0 , 21.0 ]
The case “- arrayed element” is a special case since it is interpreted as “(-1) \(\cdot\) element”:
\[\begin{equation*} - \begin{pmatrix} 6.0\\ 7.0 \end{pmatrix} = \begin{pmatrix} -6.0\\ -7.0 \end{pmatrix} \end{equation*}\]
[ -6.0 , -7.0 ]
Division (\(/\))
\[\begin{equation*} \begin{pmatrix} 2.0 & 4.0\\ 8.0 & 16.0 \end{pmatrix} \oslash \begin{pmatrix} 2.0 & 1.0\\ 0.5 & 0.25 \end{pmatrix} = \begin{pmatrix} 1.0 & 4.0\\ 16.0 & 64.0 \end{pmatrix} \end{equation*}\]
[ [1.0 , 4.0] [16.0 , 64.0] ]
\[\begin{equation*} \begin{pmatrix} 2.0 & 1.0\\ 0.5 & 0.25 \end{pmatrix} / \text{ } 5.0 = \begin{pmatrix} 0.4 & 0.2\\ 0.1 & 0.05 \end{pmatrix} \end{equation*}\]
[ [0.4 , 0.2] [0.1 , 0.05] ]
Power (\(**\)) and Modulo (\(\%\))
This section and the ones that follow all use the same little named vector, so the results are easy to compare.
\[\begin{equation*} \begin{pmatrix} 4.0 \\ 9.0 \end{pmatrix} ^{2} = \begin{pmatrix} 16.0 \\ 81.0 \end{pmatrix} \end{equation*}\]
\[\begin{equation*} \begin{pmatrix} 4.0 \\ 9.0 \end{pmatrix} \bmod 5 = \begin{pmatrix} 4.0 \\ 4.0 \end{pmatrix} \end{equation*}\]
v ** 2: [ 16.0 , 81.0 ] v % 5: [ 4.0 , 4.0 ]
Math Functions
Every function in sd_functions that takes a value works on an array and returns an array of the same shape: sqrt, exp, ln, log10, sin, cos, tan, arcsin, arccos, arctan, floor, ceil, round, abs, sinwave, coswave.
\[\begin{equation*} \sqrt{ \begin{pmatrix} 4.0 \\ 9.0 \end{pmatrix} } = \begin{pmatrix} 2.0 \\ 3.0 \end{pmatrix} \end{equation*}\]
\[\begin{equation*} 2 \cdot \ln \begin{pmatrix} 4.0 \\ 9.0 \end{pmatrix} = \begin{pmatrix} 2.7726 \\ 4.3944 \end{pmatrix} \end{equation*}\]
sqrt(v): [ 2.0 , 3.0 ] 2 * ln(v): [ 2.7726 , 4.3944 ]
Comparisons, If, And, Or, Not
A comparison over an array gives one boolean per index, and If then chooses per index as well. The condition, the then branch and the else branch may each be arrayed or scalar in any combination, as long as the arrayed ones agree on shape.
\[\begin{equation*} \mathrm{If}\left( \begin{pmatrix} 4.0 \\ 9.0 \end{pmatrix} > 6, \begin{pmatrix} 4.0 \\ 9.0 \end{pmatrix} , 0 \right) = \begin{pmatrix} 0.0 \\ 9.0 \end{pmatrix} \end{equation*}\]
If(v > 6, v, 0): [ 0.0 , 9.0 ] And(v > 3, v < 6): [ True , False ]
max and min
sd.max and sd.min compare two operands, element by element. Not to be confused with arr_max and arr_min further down, which take the largest or the smallest value within one array.
\[\begin{equation*} \max\left( \begin{pmatrix} 4.0 \\ 9.0 \end{pmatrix} , 6 \right) = \begin{pmatrix} 6.0 \\ 9.0 \end{pmatrix} \end{equation*}\]
max(v, 6): [ 6.0 , 9.0 ]
smooth, trend and delay
The stateful functions work over arrays as well, and each index carries its own history: smoothing a vector is not one smoothed value copied across the indices, it is one smoothing chain per index. Below, both indices start at the initial value 1.0 and each converges towards its own input - the recurrence \(s_{t+1} = s_t + \frac{dt}{\tau}(x - s_t)\) runs once per index:
\[\begin{equation*} \mathrm{smooth}\left( \begin{pmatrix} 4.0 \\ 9.0 \end{pmatrix} , \tau = 3, s_0 = 1 \right) \Bigr|_{t=4} = \begin{pmatrix} 3.4074 \\ 7.4198 \end{pmatrix} \end{equation*}\]
t= 0: [ 1.0 , 1.0 ] t= 1: [ 2.0 , 3.6667 ] t= 4: [ 3.4074 , 7.4198 ] t=10: [ 3.948 , 8.8613 ]
Array-specific Operations
Array Sum
Calculates the element-wise sum of an array.
\[\begin{equation*} \text{sum} \begin{pmatrix} 1.0 \\ 2.0\\ 3.0 \end{pmatrix} = 1.0 + 2.0 + 3.0 = 6.0 \end{equation*}\]
6.0
Array Product
Calculates the element-wise product of an array.
\[\begin{equation*} \text{prod} \begin{pmatrix} 2.0 & 3.0 & 4.0 \\ 5.0 & 6.0 & 7.0 \end{pmatrix} = 2.0 \cdot 3.0 \cdot 4.0 \cdot 5.0 \cdot 6.0 \cdot 7.0 = 5040.0 \end{equation*}\]
5040.0
Array Rank
Calculates the \(n\)-th highest element of an array. \(n\) is given by a parameter.
For \(n=-1\) the lowest element of the array will be returned.
\[\begin{equation*} \text{rank} \left( \begin{pmatrix} -2.0\\ -0.1\\ 3.1\\ 5.2\\ 11.1 \end{pmatrix} , 1 \right) = 11.1 \end{equation*}\]
11.1
\[\begin{equation*} \text{rank} \left( \begin{pmatrix} -2.0\\ -0.1\\ 3.1\\ 5.2\\ 11.1 \end{pmatrix} , 4 \right) = -0.1 \end{equation*}\]
-0.1
\[\begin{equation*} \text{rank} \left( \begin{pmatrix} -2.0\\ -0.1\\ 3.1\\ 5.2\\ 11.1 \end{pmatrix} , -1 \right) = -2.0 \end{equation*}\]
-2.0
Array Mean
Calculates the element-wise mean of an array.
\[\begin{equation*} \text{mean} \begin{pmatrix} 2.0 & 4.0\\ 6.0 & 8.0\\ 10.0 & 12.0 \end{pmatrix} = \frac{2.0 + 4.0 + 6.0 + 8.0 + 10.0 +12.0}{6} = 7.0 \end{equation*}\]
7.0
Array Median
Calculates the element-wise median of an array.
\[\begin{equation*} \text{median} \begin{pmatrix} -2.0\\ -0.1\\ 3.1\\ 5.2\\ 11.1 \end{pmatrix} = 3.1 \end{equation*}\]
3.1
\[\begin{equation*} \text{median} \begin{pmatrix} -2.0\\ -0.1\\ 3.1\\ 5.2\\ \end{pmatrix} = \frac{-0.1+3.1}{2}=1.5 \end{equation*}\]
1.5
Array Standard Deviation
Calculates the element-wise standard deviation of an array.
\[\begin{equation*} \sigma \begin{pmatrix} 1.0 & 3.0\\ 3.0 & 1.0 \end{pmatrix} = \sqrt{ \frac{1}{4} \cdot \left( (1-2)^2 + (3-2)^2 + (3-2)^2 + (1-2)^2 \right) } = 1 \end{equation*}\]
1.0
Array Maximum and Minimum
arr_max returns the largest value of an array, arr_min the smallest - over every element of a vector or every cell of a matrix.
Both take no arguments, and both return a single value however arrayed the input is. On an element with no sub-elements they return 0.0, as the other aggregations do.
\[\begin{equation*} \mathrm{arr\_max} \begin{pmatrix} 3.0 & 8.0 \\ 1.0 & 5.0 \end{pmatrix} = 8.0 \qquad \mathrm{arr\_min} \begin{pmatrix} 3.0 & 8.0 \\ 1.0 & 5.0 \end{pmatrix} = 1.0 \end{equation*}\]
arr_max: 8.0 arr_min: 1.0
Array Size
Calculates the size of an array.
For a vector, the length will be returned. For a matrix, the size of the highest level will be returned (for example 2 for a \(2 \times 3\) matrix).
\[\begin{equation*} \text{len} \begin{pmatrix} 1.0\\ 1.0\\ 1.0\\ 1.0\\ 1.0\\ 1.0\\ \end{pmatrix} = 6 \end{equation*}\]
6
\[\begin{equation*} \text{len} \begin{pmatrix} 1.0 & 1.0 & 1.0\\ 1.0 & 1.0 & 1.0 \end{pmatrix} = 2 \end{equation*}\]
2
\[\begin{equation*} \text{len} \begin{pmatrix} 1.0 & 1.0 & 1.0\\ 1.0 & 1.0 & 1.0\\ 1.0 & 1.0 & 1.0\\ 1.0 & 1.0 & 1.0 \end{pmatrix} = 4 \end{equation*}\]
4
Array Dot
The Dot function provides the classical vector/matrix-multiplication logic. That means, the following can be calculated:
| Factor 1 | Factor 2 | Result |
|---|---|---|
| Vector of size \(m\) | Constant | Vector of size \(m\) |
| Constant | Vector of size \(m\) | Vector of size \(m\) |
| Matrix of size \(m \times n\) | Constant | Matrix of size \(m \times n\) |
| Constant | Matrix of size \(m \times n\) | Matrix of size \(m \times n\) |
| Vector of size \(m\) | Vector of size \(m\) | Value (Scalar Product) |
| Vector of size \(m\) | Matrix of size \(m \times n\) | Vector of size \(n\) |
| Matrix of size \(m \times n\) | Vector of size \(n\) | Vector of size \(m\) |
| Matrix of size \(m \times n\) | Matrix of size \(n \times p\) | Matrix of size \(m \times p\) |
❗ Using the Dot function for an array and a constant yields the same result as using the \(*\)-Operator for the array and the value of the constant.
If the dimensions of the arrays to which the dot function is applied do not allow for a valid array multiplication, an exception is thrown.
⚠️ The Dot function is currently supported for not-named arrays only!
Lets have a look at some examples:
\[\begin{equation*} 2.0 \cdot \begin{pmatrix} 1.0 \\ 2.0 \\ 3.0 \end{pmatrix} = \begin{pmatrix} 2.0 \\ 4.0 \\ 6.0 \end{pmatrix}\end{equation*}\]
[2.0 , 4.0 , 6.0]
\[\begin{equation*} \begin{pmatrix} 4.0 \\ 5.0 \\ 6.0 \end{pmatrix} \cdot 2.0 = \begin{pmatrix} 8.0 \\ 10.0 \\ 12.0 \end{pmatrix}\end{equation*}\]
[8.0 , 10.0 , 12.0]
\[\begin{equation*} \begin{pmatrix} 1.0 & 2.0 \\ 3.0 & 4.0 \\ 5.0 & 6.0 \end{pmatrix} \cdot 2.0 = \begin{pmatrix} 2.0 & 4.0 \\ 6.0 & 8.0 \\ 10.0 & 12.0 \end{pmatrix}\end{equation*}\]
[ [2.0 , 4.0] [6.0 , 8.0] [10.0 , 12.0] ]
\[\begin{equation*} 2.0 \cdot \begin{pmatrix} -1.0 & -2.0 & -3.0 \\ -4.0 & -5.0 & -6.0 \\ \end{pmatrix} = \begin{pmatrix} -2.0 & -4.0 & -6.0 \\ -8.0 & -10.0 & -12.0 \\ \end{pmatrix}\end{equation*}\]
[ [-2.0 , -4.0 , -6.0] [-8.0 , -10.0 , -12.0] ]
\[\begin{equation*} \left\langle \begin{pmatrix} 1.0 \\ 2.0 \\ 3.0 \end{pmatrix}, \begin{pmatrix} 4.0 \\ 5.0 \\ 6.0 \end{pmatrix} \right\rangle = 1.0 \cdot 4.0 + 2.0 \cdot 5.0 + 3.0 \cdot 6.0 = 32.0 \end{equation*}\]
32.0
\[\begin{equation*} \begin{pmatrix} 1.0 & 2.0 & 3.0 \\ \end{pmatrix} \cdot \begin{pmatrix} 1.0 & 2.0 \\ 3.0 & 4.0 \\ 5.0 & 6.0 \\ \end{pmatrix} = \begin{pmatrix} 1.0 \cdot 1.0 + 2.0 \cdot 3.0 + 3.0 \cdot 5.0 \\ 1.0 \cdot 2.0 + 2.0 \cdot 4.0 + 3.0 \cdot 6.0 \\ \end{pmatrix} = \begin{pmatrix} 22.0 & 28.0 \end{pmatrix} \end{equation*}\]
[22.0 , 28.0]
\[\begin{equation*} \begin{pmatrix} -1.0 & -2.0 & -3.0 \\ -4.0 & -5.0 & -6.0 \\ \end{pmatrix} \cdot \begin{pmatrix} 4.0 \\ 5.0 \\ 6.0 \\ \end{pmatrix} = \begin{pmatrix} -1.0 \cdot 4.0 + (-2.0) \cdot 5.0 + (-3.0) \cdot 6.0 \\ -4.0 \cdot 4.0 + (-5.0) \cdot 5.0 + (-6.0) \cdot 6.0 \\ \end{pmatrix} = \begin{pmatrix} -32.0 \\ -77.0 \end{pmatrix} \end{equation*}\]
[-32.0 , -77.0]
\[\begin{equation*} \begin{aligned} \begin{pmatrix} 1.0 & 2.0 \\ 3.0 & 4.0 \\ \end{pmatrix} \cdot \begin{pmatrix} -1.0 & -2.0 \\ -4.0 & -5.0 \\ \end{pmatrix} &= \begin{pmatrix} 1.0 \cdot (-1.0) + 2.0 \cdot (-4.0) & 1.0 \cdot (-2.0) + 2.0 \cdot (-5.0) \\ 3.0 \cdot (-1.0) + 4.0 \cdot (-4.0) & 3.0 \cdot (-2.0) + 4.0 \cdot (-5.0) \\ \end{pmatrix}\\ &= \begin{pmatrix} -9 & -12 \\ -19 & -26\\ \end{pmatrix} \end{aligned} \end{equation*}\]
[ [-9.0 , -12.0] [-19.0 , -26.0] ]
Plotting arrayed Components
Similar to one-dimensional SD DSL elements, we can also plot these elements. Lets have a look:
As can be seen, both elements of the Vector are plotted.
If you want to plot the values of the Matrix, you need to specify the first index.
A simple Example
Lets have a look on a concrete example, how a multidimensional SD Model can look like.
Consider an investment depot with two accounts:
- bank account
- depot account
Both accounts will have different deposit rates and different interest rates each year. We want to investigate the value development of the bank account, the depot account and the whole investment depot.
Lets set up the model:
As always we define a scenario manager and scenarios:
And plot the results:
As always we can compare different scenarios with each other by plotting them simultaneously:
What Is Not Supported
Worth knowing before you build on arrays. None of these fails silently - each one raises with a message that says what happened.
- Two dimensions is the limit.
setup_matrixtakes exactly two sizes, and there is no three-dimensional array. dotworks on unnamed arrays only. A named operand raises rather than guessing how to line up the labels.- There is no aggregation over a single dimension.
arr_sumandarr_prodtake adimensionsargument, but the only values it accepts are"*"(the default) and an integer equal to the array’s depth - both of which aggregate every cell. Aggregating along the rows of a matrix would have to return a vector, which is not supported; where you need it, address the rows yourself. A row of a named matrix is a named vector in its own right, somatrix['north'].arr_sum()is the total of that row. arr_sizecounts the first dimension, not the number of cells: 2 for a \(2 \times 3\) matrix.- There is no broadcasting. Both operands of an element-wise operation must have the same shape and the same indices; a vector and a matrix is an error.
- No transpose, and no dimension-position operator. The XMILE standard has both - a transpose, and
@to name a position within a dimension - and the SD DSL has never implemented either, because nothing in the model library or the test corpus has needed one. Where you would reach for a transpose, address the cells the other way round when you set the matrix up; a row of a named matrix is a named vector, somatrix['north']is that row and there is no need to turn the matrix around to get at it. - A flow never goes negative, arrayed or not: every flow equation is wrapped in
max(0, ...). A quantity that has to move both ways belongs in a biflow, which takes the samesetup_vectorandsetup_named_vectoras any other element.