Model Construction

This tutorial illustrates model construction and the relation between models and graphs. A model consists of components and connections. These components and connections can be associated with a signal-flow graph signifying the topology of the model. In the realm of graph theory, components and connections of a model are associated with nodes and branches of the signal-flow graph. As the model is modified by adding or deleting components or connections, the signal-flow graph of the model is modified accordingly to keep track of topological modifications. By associating a signal-flow graph to a model, any graph-theoretical analysis can be performed. An example to such an analysis is the determination and braking of algebraic loops.

In Causal, a model can be constructed either by describing it in one-shot or by gradually modifying it by adding new nodes and branches. To show the relation between models and graphs, we start with the latter.

Modifying Models

In this tutorial, we construct the model with the following block diagram

model

and with the following signal-flow graph

model

Let's start with an empty Model.

julia> using Causal # hide

julia> model = Model()
Model(numnodes:0, numedges:0)

We constructed an empty model, i.e., the model has no components and connections. To modify the model, we need to add components and connections to the model. As the model is grown by adding components and connections, the components and connections are added into the model as nodes and branches (see Node, Branch). Let's add our first component, a SinewaveGenerator to the model.

julia> addnode!(model, SinewaveGenerator(), label=:gen)
Node(component:SinewaveGenerator(amp:1.0, freq:1.0, phase:0.0, offset:0.0, delay:0.0), idx:1, label:gen)

To add components to the model, we use addnode! function. As seen, our node consists of a component, an index, and a label.

julia> node1 = model.nodes[1]
Node(component:SinewaveGenerator(amp:1.0, freq:1.0, phase:0.0, offset:0.0, delay:0.0), idx:1, label:gen)

julia> node1.component
SinewaveGenerator(amp:1.0, freq:1.0, phase:0.0, offset:0.0, delay:0.0)

julia> node1.idx
1

julia> node1.label
:gen

Let us add another component, a Adder, to the model,

julia> addnode!(model, Adder(signs=(+,-)), label=:adder)
Node(component:Adder(signs:(+, -), input:Inport(numpins:2, eltype:Inpin{Float64}), output:Outport(numpins:1, eltype:Outpin{Float64})), idx:2, label:adder)

and investigate our new node.

julia> node2 = model.nodes[2]
Node(component:Adder(signs:(+, -), input:Inport(numpins:2, eltype:Inpin{Float64}), output:Outport(numpins:1, eltype:Outpin{Float64})), idx:2, label:adder)

julia> node2.component
Adder(signs:(+, -), input:Inport(numpins:2, eltype:Inpin{Float64}), output:Outport(numpins:1, eltype:Outpin{Float64}))

julia> node2.idx
2

julia> node2.label
:adder

Note that as new nodes are added to the model, they are given an index idx and a label label. The label is not mandatory, if not specified explicitly, nothing is assigned as label. The reason to add components as nodes is to access them through their node index idx or labels. For instance, we can access our first node by using its node index idx or node label label.

julia> getnode(model, :gen)    # Access by label
Node(component:SinewaveGenerator(amp:1.0, freq:1.0, phase:0.0, offset:0.0, delay:0.0), idx:1, label:gen)

julia> getnode(model, 1)       # Access by index
Node(component:SinewaveGenerator(amp:1.0, freq:1.0, phase:0.0, offset:0.0, delay:0.0), idx:1, label:gen)

At this point, we have two nodes in our model. Let's add two more nodes, a Gain and a Writer

julia> addnode!(model, Gain(), label=:gain)
Node(component:Gain(gain:1.0, input:Inport(numpins:1, eltype:Inpin{Float64}), output:Outport(numpins:1, eltype:Outpin{Float64})), idx:3, label:gain)

julia> addnode!(model, Writer(), label=:writer)
Node(component:Writer(path:/tmp/a97351ea-500a-4576-8f08-38a6ae49cc51.jld2, nin:1), idx:4, label:writer)

As the nodes are added to the model, its graph is modified accordingly.

julia> model.graph
{4, 0} directed simple Int64 graph

model has no connections. Let's add our first connection by connecting the first pin of the output port of the node 1 (which is labelled as :gen) to the first input pin of input port of node 2 (which is labelled as :adder).

julia> addbranch!(model, :gen => :adder, 1 => 1)
Branch(nodepair:1 => 2, indexpair:1 => 1, links:Link{Float64}[Link(state:open, eltype:Float64, isreadable:false, iswritable:false)])

The node labelled with :gen has an output port having one pin, and the node labelled with :adder has an input port of two pins. In our first connection, we connected the first(and the only) pin of the output port of the node labelled with :gen to the first pin of the input port of the node labelled with :adder. The connections are added to model as branches,

julia> model.branches
1-element Array{Any,1}:
 Branch(nodepair:1 => 2, indexpair:1 => 1, links:Link{Float64}[Link(state:open, eltype:Float64, isreadable:false, iswritable:false)])

A branch between any pair of nodes can be accessed through the indexes or labels of nodes.

julia> br = getbranch(model, :gen => :adder)
Branch(nodepair:1 => 2, indexpair:1 => 1, links:Link{Float64}[Link(state:open, eltype:Float64, isreadable:false, iswritable:false)])

julia> br.nodepair
1 => 2

julia> br.indexpair
1 => 1

julia> br.links
1-element Array{Link{Float64},1}:
 Link(state:open, eltype:Float64, isreadable:false, iswritable:false)

Note the branch br has one link(see Link). This is because we connected one pin to another pin. The branch that connects $n$ pins to each other has n links. Let us complete the construction of the model by adding other connections.

julia> addbranch!(model, :adder => :gain, 1 => 1)
Branch(nodepair:2 => 3, indexpair:1 => 1, links:Link{Float64}[Link(state:open, eltype:Float64, isreadable:false, iswritable:false)])

julia> addbranch!(model, :gain => :adder, 1 => 2)
Branch(nodepair:3 => 2, indexpair:1 => 2, links:Link{Float64}[Link(state:open, eltype:Float64, isreadable:false, iswritable:false)])

julia> addbranch!(model, :gain => :writer, 1 => 1)
Branch(nodepair:3 => 4, indexpair:1 => 1, links:Link{Float64}[Link(state:open, eltype:Float64, isreadable:false, iswritable:false)])

Describing Models

The second approach is to describe the whole model. In this approach the model is constructed in single-shot. The syntax here is

@defmodel modelname begin 
    @nodes begin 
        label1 = Component1(args...; kwargs...)     # Node 1
        label2 = Component2(args...; kwargs...)     # Node 2
                ⋮                                       ⋮
        
        labelN = ComponentN(args...; kwargs...)     # Node N
    end 
    @branches begin 
        src_label1[src_index1] = dst_label1[dst_index1]     # Branch 1
        src_label2[src_index2] = dst_label1[dst_index2]     # Branch 2 
            ⋮                                                   ⋮
        src_labelM[src_indexM] = dst_labelM[dst_indexM]     # Branch M
    end
end 

Note that modelname is the name of the model to be compiled. The nodes of the model is defined in @nodes begin ... end block and the branches of the model is defined in @branches begin ... end. The syntax src_label1[src_index1] = dst_label1[dst_index1] means that there is a branch between the node labelled with src_label1 and the node labelled with dst_label1.And, this branch connects the pins indexed by src_index1 of the output port of src_label1 to the pins indexed by dst_index1 of the input port of dst_label1. The indexing of the pins here is just like any one dimensional array indexing. That is src_index1( or dst_index1) may be integer, vector of integers, vector of booleans, range, etc.

For example, the model given above can also be constructed as follows

julia> using Causal # hide

julia> @defmodel model begin
           @nodes begin
               gen = SinewaveGenerator()
               adder = Adder(signs=(+,-))
               gain = Gain()
               writer = Writer()
           end
           @branches begin
               gen[1]      =>      adder[1]
               adder[1]    =>      gain[1]
               gain[1]     =>      adder[2]
               gain[1]     =>      writer[1]
           end
       end

This macro is expanded to construct the model.

Usage of Signal-Flow Graph

The signal-flow graph constructed alongside of the construction of the model can be used to perform any topological analysis. An example to such an analysis is the detection of algebraic loops. For instance, our model in this tutorial has an algebraic loop consisting of the nodes labelled with :gen and gain. This loop can be detected using the signal-flow graph of the node

julia> loops = getloops(model)
1-element Array{Array{Int64,1},1}:
 [2, 3]

We have one loop consisting the nodes with indexes 2 and 3.

For further analysis on model graph, we use LightGraphs package.

julia> using LightGraphs

julia> graph = model.graph
{4, 4} directed simple Int64 graph

For example, the adjacency matrix of model graph can be obtained.

julia> adjacency_matrix(model.graph)
4×4 SparseArrays.SparseMatrixCSC{Int64,Int64} with 4 stored entries:
  [1, 2]  =  1
  [3, 2]  =  1
  [2, 3]  =  1
  [3, 4]  =  1

or inneighbors or outneighbors of a node can be obtained.

julia> inneighbors(model.graph, getnode(model, :adder).idx)
2-element Array{Int64,1}:
 1
 3

julia> outneighbors(model.graph, getnode(model, :adder).idx)
1-element Array{Int64,1}:
 3