Skip to content

Language

Fred Rothganger edited this page Aug 10, 2023 · 17 revisions

This page gives an informal presentation of the Neurons to Algorithms (N2A) modeling language. It is intended to give users an understanding of the behavior they can expect from models they create.

The introduction starts in the next section. For your convenience, here are a few links to more detailed information that you can refer back to after completing the introduction.

General Concepts

A "part" is a collection of metadata and equations which describe the behavior of some object. A part can either be a "compartment" or a "connection". A compartment may be an entire neuron, a segment of a neuron, or any other kind of system component one may wish to describe. A connection may be a synapse between two neurons, a shared surface between two segments of a single neuron, or any other kind of interaction between two system components.

A given part may produce any number of instances within a system. Each instance has a separate and independent set of state variables, which evolve according to the dynamics prescribed by the part's equations. A part may be thought of as a template for stamping out instances. All actual operations during simulation occur on instances.

A "population" is the set of instances associated with a given part. The number of instances is specified by the reserved variable $n. (See the section "Instantiation and Simulation" below for details on the construction of a population.)

Parts may be composed into other parts, and they may form inheritance hierarchies. In this sense, N2A is an object-oriented language. The top-level part that goes to simulation is called a "model". A model may contain several different parts, and each part may define its own $n. This implies that there may be hierarchical production of populations. That is, each instance in a population may itself contain sub-populations, associated with sub-parts.

A set of equations defines a part, and that is the full extent of the N2A language. Every statement has the same basic form:

<variable> = <expression> @ <condition> # <comment>

For example:

a = b + 10

This equation brings the state variable "a" into existence, and describes how it changes over time. In particular, it is always 10 greater than "b".

Equations may be of roughly three basic types. A constant is known before execution starts and never changes. A regular variable receives its value from the expression, and generally changes during the simulation. A differential equation is like a regular variable, but implicitly creates an integrated value as well. Differential equations are always with respect to time. Example:

a = 10      # constant a
b = a * v   # variable b
v' = c * g  # differential equation v', which implicitly creates integrated value v

The simulator determines how much time has transpired since the last evaluation. Any differential equations are integrated over that interval, and the results become visible at the start of pending evaluation step.

Order of Evaluation

A natural question people ask when looking at a set of equations is: Which one is evaluated first? This comes up, for example, if you are writing a simulation in Matlab. There, lines of an update function are executed top-to-bottom, and there is a very clear flow of values. The sequence of evaluation is less clear in a declarative language like LEMS or N2A.

There are two general classes of variables: state and temporary. State variables are stored between simulation cycles. During evaluation they have a current value and a next value. Temporary variables are not stored between cycles. They have only a current value.

The next value of a state variable does not become visible until after the current simulation step is completed. This implies that state variables are orderless. The result is the same regardless of which equation evaluates first. Any circular dependencies are naturally broken. For example:

a =: c + 1  # The colon indicate that this must be treated as state.
b =: a + 1
c =: b + 1

Suppose all variables are initialized to zero. (Details of initialization are covered later.) Values during the first few simulation cycles:

$t a b c
0 0 0 0
1 1 1 1
2 2 2 2

Temporaries, on the other hand, do have a distinct order of evaluation. If a depends on c, then c is evaluated before a. If some variable has a cycle of dependencies that leads back to itself, then it cannot be temporary. The compiler automatically breaks cycles by choosing at least one variable in the cycle to be state. The compiler also tries to make as many variables temporary as possible, because this reduces space cost and allows values to propagate through the equation set faster.

a = c + 1
b = a + 1  # Suppose the compiler selects b as the cycle-breaker.
c = b + 1
$t a b c
0 0 0 0
1 2 0 1
2 5 3 4
3 8 6 7

You can explicitly mark a variable as state, and thus control which one breaks the cycle:

a =: c + 1
b =  a + 1
c =  b + 1
$t a b c
0 0 0 0
1 0 1 2
2 3 4 5
3 6 7 8

Certain variables are always treated as state. A derivative must be stored so it can be integrated later. An integrated value must be stored because the integration adds onto the previous value. A variable that gets referenced from outside the current equation set must be stored so that the value is available when the other equation set is evaluated. Given these rules, there generally are not any cycles left to be broken. If there are, the compiler tries to pick a variable with the largest number of cycles passing through it. This minimizes the number of variables that are forced to be state.

Conditional Equations

A variable can have a multi-part equation. Each part of the equation has a unique condition. During a given update cycle, only one of the conditional equations is applied.

sgn =
    1  @ x > 0
    -1 @ x < 0
    0   # default if no other condition is true

In this example, x is a variable somewhere else in the equation set, and we are determining its sign.

The expression after the @ is evaluated for each equation. If several are non-zero, then one will be chosen in a simulator-specific fashion. Examples of choice methods include picking a different one at random during each cycle, or always executing the first one. Avoid relying on any particular choice method. It is best to ensure that the conditions are mutually exclusive. There are three weak guarantees about order:

  • Default -- At most one line may have an empty condition. The unconditional line will be evaluated last. If no such line exists, then it is possible for no equation to execute for the given variable during the current cycle. In that case, the previous value is copied forward as next value.
  • $init -- Any line containing $init in the @ expression will evaluate ahead of any line that does not. A line with "@$init" as the only condition will evaluate last of all the $init lines, effectively the default equation during the init cycle.
  • $connect -- Lines conditioned on $connect will evaluated ahead of lines conditioned on $init. A line conditioned only on $connect will be the default equation during connection tests.

The reserved variable $init is set to 1 when a part is instantiated, and becomes 0 after its equation set is first evaluated. This allows initial values to be expressed as part of the equation set. Example:

V’ = g / C
V  = -72  @ $init  # V is integrated V’, except on first cycle

Another use of @ is to evaluate equations at specific places or times in the simulation. This can provide a simple way to create input patterns. Examples:

# Note: $t is current simulated time
a = 10 @ 0.9 < $t && $t < 1  # only during a certain period
b = 5  @ $xyz == [5;5;0]     # only at a certain place

# Initialize c to 5, except at one place in the population.
c =
    5  @ $init                     # treated as default during init
    10 @ $init && $xyz == [2;1;0]  # if true, takes precedence over the above line

Reductions

A variable in a particular instance can be modified by equations in other instances, and there can be any number of these. For example, this can be used to sum the total amount of current coming into a neuron. The neuron instance holds a variable I, while all the synapse instances contribute some amount of current to I. Reductions are marked by placing the appropriate combination type after the equals sign. Valid combiners are: sum (=+), product (=*), quotient (=/), minimum (=<) and maximum (=>). Note that the meaning of =+ is different from += in C-like programming languages. It cannot be used as a shortcut for "a = a + b"!

A reduction is always a state variable. Values are accumulated asynchronously. Specifically, at the end of a simulation cycle, the "next" value of an accumulator variable is set to an appropriate identity for the type of combiner. If the local instance sets the value of the accumulator, that value is treated as the first contribution during the coming time interval. Then other parts have an opportunity to combine their values into the accumulator. At the start of the next cycle, the accumulated value becomes visible.

Combining Parts

A model may incorporate other models. When it does so, all the equations from the source model must be combined into a single namespace within the destination model. This may happen in two ways: inheritance and inclusion.

When a model is inherited, all its equations are appended to the child model directly. If a variable is defined in both the parent and the child, then the child equation overrides the parent. If multiple models are inherited that define the same variable, and the child does not otherwise override it, the first parent to define the equation dominates. In the case of triangle inheritance (where C inherits from A and from B, both of which inherit from P), the equations from P will appear only once in C.

A model may have sub-parts, each of which can define a distinct population at run-time. Each sub-part lives in a separate namespace under the containing model. (See the model "Example Hodgkin-Huxley Cable" that comes with the software.) A sub-part may itself inherit from other models. Since these append equations only to the sub-part, this is called inclusion.

If a sub-part references a variable but does not define it, then the containing part must provide it. The simulator will attempt to resolve a name in the local namespace first. If that fails, it will search in the containing namespace, and on up the hierarchy until the name is found.

In some cases, a variable may be defined in both the included set and the containing set. If you wish to force resolution up to the containing set, prepend the special namespace "$up" to the variable name. Alternately, you can explicitly prepend the containing namespace to the variable, but this is only possible when that namespace is known ahead of time. Some parts (such as ion channels) may be included in many different containers with many different names. The special namespace $up makes it possible to force resolution up the containment hierarchy without knowing those names.

A model can override one or all the conditional equations associated with a given variable in a part that is inherited or included. Example

# In model "Bob":
a = 1
b = 2
sgn =
    1  @ x > 0
    -1 @ x < 0
    0

# In model "Sue":
$inherit=Bob
b = 3
c = 4
sgn = 22 @ x > 0

# Sue's effective equation set after applying inheritance
$inherit=Bob
a = 1
b = 3           # override
c = 4
sgn =
    22 @ x > 0  # override
    -1 @ x < 0
    0

Inheritance and inclusion make the construction of large models easy. The N2A software comes bundled with "Example Hodgkin-Huxley Cable", which illustrates these concepts. "Cell Hodgkin-Huxley" functions as both a point neuron and a Hodgkin-Huxley (HH) compartment. It inherits the base model "Compartment", which defines passive compartment equations. HH then includes two ion channels to give full dynamics. The example cable model includes the HH compartment and a voltage-coupling connection to make a complete model.

Connections

A connection is a special kind of part which makes references to other parts. Each reference has an alias in the local equation set that appears similar to a variable. These are analogous to pointer variables in C-like languages. A connection is able to read and write variables in the referenced parts. This allows it to move values between them and perform calculations. For example, a synapse is generally implemented as a connection. It has a reference to each neuron. It might compute current based on spike events in the pre-synaptic neuron, and apply those currents (via a reduction) to the post-synaptic neuron. The connection can have its own dynamics, so it can implement an arbitrarily complex synapse model.

Connections are instantiated automatically by the simulator. Abstractly, the simulator iterates over all combinations of the respective populations and applies a predicate, expressed in the variable $p, to determine whether a given combination is actually connected. In practice, there are numerous optimizations to avoid exhaustively exploring the entire combinatorial space. For example, connection probability can be based on distance in space. Or the connections may be drawn from a sparse matrix, in which case only non-zero elements are even examined.

Connection aliases are typically named capital letters from the start of the alphabet: A, B, and so on. This is only a convention. An alias can be any valid variable name. A connection can hold any number of aliases, making it unary, binary, ternary, etc. An endpoint is the part that an alias refers to. At run-time, the endpoint is a specific instance of that part. Suppose there are three parts, "Synapse", "PreSynaptic" and "PostSynaptic". The aliases might look like this:

# in "Synapse"
A        =  PreSynaptic                           # alias, bound using the name of the part as it appears in the model
B        =  PostSynaptic                          # alias
$p       =  exp(-norm(A.$xyz-B.$xyz)) @ $connect  # Gaussian connectivity
B.I      =+ current                               # reduction that accumulates current in an instance of "PostSynaptic"
current' =  leak_rate                             # local dynamics
current  =  peak_current @ event(A.spike)
# and so on ...

Sometimes a connection has the same population for both of its endpoints. This raises the question of whether an individual instance can be connected to itself (an autapse), or only to its neighbors. To test whether both ends of a connection are the same instance, compare their aliases. For example:

$p = A!=B  # only true if A and B refer to different instances

Aliases are ordered, so you can also write:

$p = A>B   # uni-directional connectivity with no self-connections

Instantiation and Simulation

This section describes a fairly specific procedure for simulation which is true mainly of the reference backends. The purpose is to make the intended semantics clear, rather than to overly constrain how other backends behave. Simulation is on a "best effort" basis. A simulator should always try to run its closest approximation of the written model. It should provide warnings when the model contains elements that can’t be simulated according to these expectations. A simulator should only terminate with an error if the model is truly impossible to run, even in some limited form.

N2A reserves some variables to have special meaning during the instantiation of parts. All reserved variables start with the dollar-sign ($). These variables never resolve up the containment hierarchy, but instead have well-defined default values.

Before simulation, N2A fully expands the equation set for a model, processing all $inherit values.

The equations of an instance are evaluated immediately when it is first created. Then a full time-step will go by before another evaluation. During the very first cycle of a simulation, parts are instantiated while $t is still 0. Thus most instances will execute their init cycle when $t is 0, and their first non-init cycle after $t advances by $t'. Some instances may be created later, in which case $t will contain the current time. In either case, $t' will contain the default time-step size for the new instance. During the init cycle, all other variables contain 0 except for $index and $init.

The time-sequence for a simulation is:

$t = 0
    instantiate new parts based on $n
        evaluate each equation set with $init=1
    instantiate connections induced by new parts, and evaluate each equation set as above
$t += $t'
    evaluate all current equation sets with $init=0
    if any additional parts are created, evaluate them with $init=1
$t += $t' ...

The detailed procedure for generating new instances in a population is:

for i = (previous value of $n) to (new value of $n-1)
    Create a new instance of the part
    Set all variables and implicit integrands to zero
    Assign $index from pool of available numbers, else set $index = i
    Evaluate equations with $init=1

At init time, the order of evaluation takes all dependencies into account, not just those of temporaries. The compiler automatically sequences equations to maximize spread of information through the set. The goal is to fully initialize the set in a single pass. However, cyclic dependencies may still require more than one step to fully initialize.

The procedure for creating connections is:

Generate all possible tuples of endpoints for connection C in which at least one endpoint is new
    Determine C.$xyz, the spatial position of the connection object itself
    Filter each endpoint population A, using distance |C.$xyz - A.$project|
        Select $k nearest parts
        Select parts within $radius of projected position
For each generated tuple
    For each endpoint instance A, if (connections of this type to A) >= A.$max, then skip remaining tuples that include A
    if $p > random draw with uniform distribution in [0,1)
        Create new instance of connection

The procedure repeats as necessary until every A has connections >= A.$min.