Define experiments using @experiment

Say you have a model of a brain circuit, and you would like to see its outputs with various sets of parameters. The @experiment macro provides an easy interface for iterating on a problem by changing its parameters, initial conditions, and timespan.

First let's create a circuit and then a simulation.

@graph g begin
    @nodes begin
        Brainstem = NGNMM_theta()
        Cortex = Cortex(
            N_wta=20, 
            N_exci=5, 
            density=0.05, 
            weight=1.0, 
            I_bg=4, 
            G_syn_inhi=4.0, 
            τ_inhi=70, 
            G_syn_ff_inhi=1.5
        )
    end
    @connections begin
        Brainstem => Cortex, (weight = 20)
    end
end

tspan = (0., 500.)
prob = ODEProblem(g, [], tspan)

Modifying parameters

Let's say we apply a drug whose known effect is to scale the timescale of the GABA synapses. Let's also say that this effect is limited to the cortex. Then we can write:

prob_drugged = @experiment prob begin
    @setup begin
        scale = 2.0
    end

    GABA_A_Synapse in Cortex, τ₂ -> τ₂ * scale
end

Inside the experiment macro, the @setup block is optionally used to define variables.

The next line gives a rule for changing the parameters of the problem. This has two parts:

  1. Blox selection: GABA_A_Synapse in Cortex is used to select all the GABA_A_Synapse inside the graph for Cortex (reference the API to see the full list of recognized selections)
  2. Parameter modification: τ₂ -> τ₂ * scale sets the τ₂ parameter of all of these synapses to its original value times scale. This should be read as a function.

Any number of these parameter modification lines may be included inside the macro.

Note also that prob_drugged is a copy of the original prob; if we wanted to simulate the original prob, we could.

Modifying connection weights and synapse parameters

You can also target connections directly using a => pair. This updates both the connection weight property (if the argument name matches, e.g. weight) and any matching parameter on all intermediate synapse/receptor blox involved in that connection:

prob_scaled = @experiment prob begin
    @setup begin
        scale = 2.0
    end

    # Scale the weight of every connection from Brainstem to Cortex,
    # and also any matching parameter on intermediate synapses:
    Brainstem => Cortex, weight -> weight * scale
end

To restrict the update to a specific synapse type only:

prob_drugged = @experiment prob begin
    @setup begin
        scale = 2.0
    end

    (GABA_A_Synapse ∈ (Brainstem => Cortex)), τ₂ -> τ₂ * scale
end

Both Brainstem and Cortex here may be composite blox (brain regions) or simple blox. The macro traverses the flat graph to find all connections between their constituent neurons.

Selecting blox by tag

Every blox has a tags::Set{Symbol} field, which marks a subset of blox of one type without defining a new type. Set it with the tags keyword argument when constructing a blox. Connection rules forward tags to the synapse, receptor and transmitter blox they create, so you can tag specific routes of a circuit:

@graph g begin
    @nodes begin
        itcvm = ITCvm()
        itcdm = ITCdm()
        cem   = CeM()
    end
    @connections begin
        # these GABA-A synapses are γ1-subunit-containing
        itcvm => cem,   HypergeometricRule(; density=0.07, weight=0.1, tags=:gamma1)
        # an ordinary GABA-A projection from the same neurons
        itcvm => itcdm, HypergeometricRule(; density=0.1, weight=0.05)
    end
end

Any blox selection except a bare src => dst pair can be restricted with a where clause. The clause is a Bool expression of tags, the tag set of each candidate blox. For example, a γ1 positive allosteric modulator slows the decay of only the tagged synapses:

prob_pam = @experiment prob begin
    (GABA_A_Synapse where :gamma1 ∈ tags), τ₂ -> τ₂ * 2
end

Because the predicate is ordinary Julia, you get any/all/exclusion for free, and variables from the surrounding scope can be used:

subunit = :gamma1
prob_2 = @experiment prob begin
    (AbstractReceptor where subunit ∈ tags || :gamma2 ∈ tags), τ₂ -> τ₂ * 2  # either tag
    (GABA_A_Synapse where :gamma1 ∉ tags), G_syn -> G_syn * 0.5              # untagged ones
end

A where clause also composes with the composite ((T ∈ composite)) and typed-connection ((T ∈ (src => dst))) selections. Parenthesise the selection before attaching where:

prob_3 = @experiment prob begin
    ((GABA_A_Synapse ∈ (itcvm => cem)) where :gamma1 ∈ tags), τ₂ -> τ₂ * 2
end

The synapses of a connection between two composites belong to the enclosing graph, not to either composite. So select them with the connection form above, not with (GABA_A_Synapse ∈ CeM).

A where line that selects no blox raises an error, so a misspelled tag can't silently turn an intervention into a no-op. Tags on a composite blox are stored on the composite only. They are not copied onto its components, so a where clause, which looks at the simple blox of the flat graph, does not see them.

Modifying initial conditions and timespan

Let's see how to change initial conditions and timespans for the problem. Let's say we want to dose the drug only after an initial warmup period (that is, simulate the undrugged problem for some time t, then apply the drug effect). We also don't want the drugged simulation to run quite as long.

sol_undrugged = sol(prob, Vern7())

prob_drugged = @experiment prob begin
    @initial_conditions sol_undrugged.u[end]
    @tspan (0., 300.)
    @setup begin
        scale = 2.0
    end

    GABA_A_Synapse in Cortex, τ₂ -> τ₂ * scale
end