All-optical control#

In this tutorial we’ll see how to

  • configure and inject a microscope for two-photon imaging and stimulation

  • inject a calcium indicator for neurons visible to the microscope

  • set up two-photon optogenetics by

    • injecting an opsin with an extended action spectrum and

    • injecting a 2P light source targeting neurons imaged by the microscope

Preamble#

import brian2 as b2
from brian2 import np
import cleo
from cleo import opto, imaging, light
import matplotlib.pyplot as plt

# for reproducibility
rng = np.random.default_rng(92)
np.random.seed(92)

cleo.utilities.style_plots_for_docs()

Brian network setup#

All we need are LIF neurons that will spike in response to photostimulation for us to see the resulting fluorescence traces.

ng = b2.NeuronGroup(
    200,
    """dv/dt = (-(v - E_L) + Rm*Iopto) / tau_m : volt
    Iopto : amp""",
    threshold="v > -50*mV",
    reset="v=E_L",
    namespace={
        "tau_m": 20 * b2.ms,
        "Rm": 500 * b2.Mohm,
        "E_L": -70 * b2.mV,
    },
)
ng.v = -70 * b2.mV
cleo.coords.assign_coords_rand_rect_prism(ng, [-0.2, 0.2], [-0.2, 0.2], [0, 0.4])
sim = cleo.CLSimulator(b2.Network(ng))

Microscope configuration#

By default a scope selects neurons based on focus depth and assigns them a signal-to-noise ratio (SNR) based on soma size. (Larger cells’ SNR decays more slowly with distance from the focal plane.) focus_depth and soma_radius are taken from scope parameters but can be overridden on injection.

We’ll use GCaMP6f as our indicator and leave the default parameters, though those can be changed in the function call gcamp6f().

scope = imaging.Scope(
    focus_depth=100 * b2.um,
    img_width=500 * b2.um,
    sensor=imaging.gcamp6f(),
)

Heterogeneous expression is simulated by providing a rho_rel_generator function that takes the number of neurons as an argument and returns a vector of relative expression levels. This is done on scope injection (as opposed to sensor injection afterward) because the scope needs this information to select ROIs with sufficiently high SNR.

expr_level_gen = lambda n: rng.lognormal(0, 0.2, n)
sim.inject(scope, ng, rho_rel_generator=expr_level_gen)  # uses scope's parameters
# optional overrides (we'll use a small soma to intentionally get few targets)
sim.inject(scope, ng, focus_depth=200 * b2.um, soma_radius=5 * b2.um, rho_rel_generator=expr_level_gen)
CLSimulator(io_processor=None, devices={Scope(sim=..., name='Scope', save_history=True, sensor=GECI(sim=None, name='gcamp6f', save_history=True, model='\n            dCa/dt = -gamma * (Ca - Ca_rest) / (1 + kappa_S + kappa_B) : mmolar (clock-driven)\n            kappa_B = B_T * K_d / (Ca + K_d)**2 : 1\n\n            CaB_active = Ca_rest + b : mmolar  # add tiny bit to avoid /0\n            db/dt = beta : mmolar (clock-driven)\n            lam = 1/tau_off + 1/tau_on : 1/second\n            kap = 1/tau_off : 1/second\n            dbeta/dt = (                    # should be M/s/s\n                A * (lam - kap) * (Ca - Ca_rest)  # M/s/s\n                - (kap + lam) * beta        # M/s/s\n                - kap * lam * b    # M/s/s\n            ) : mmolar/second (clock-driven)\n            \nexc_factor = 1 : 1\n\n            dFF_baseline = 1 / (1 + (K_d / Ca_rest) ** n_H) : 1\n            dFF = exc_factor * rho_rel * dFF_max  * (\n                1 / (1 + (K_d / CaB_active) ** n_H)\n                - dFF_baseline\n            ) : 1\n            rho_rel : 1\n        ', on_pre='Ca += dCa_T / (1 + kappa_S + kappa_B)', sigma_noise=0.03748181818181818, dFF_1AP=0.09775500000000001, location='cytoplasm', cal_model=DynamicCalcium(on_pre='Ca += dCa_T / (1 + kappa_S + kappa_B)', model='\n            dCa/dt = -gamma * (Ca - Ca_rest) / (1 + kappa_S + kappa_B) : mmolar (clock-driven)\n            kappa_B = B_T * K_d / (Ca + K_d)**2 : 1', Ca_rest=50. * nmolar, gamma=292.3 * hertz, B_T=200. * umolar, kappa_S=110, dCa_T=7.6 * umolar), bind_act_model=DoubExpCalBindingActivation(model='\n            CaB_active = Ca_rest + b : mmolar  # add tiny bit to avoid /0\n            db/dt = beta : mmolar (clock-driven)\n            lam = 1/tau_off + 1/tau_on : 1/second\n            kap = 1/tau_off : 1/second\n            dbeta/dt = (                    # should be M/s/s\n                A * (lam - kap) * (Ca - Ca_rest)  # M/s/s\n                - (kap + lam) * beta        # M/s/s\n                - kap * lam * b    # M/s/s\n            ) : mmolar/second (clock-driven)\n            ', A=7.61251 * khertz, tau_on=1.17164616 * second, tau_off=10.14020867 * msecond, Ca_rest=50. * nmolar), exc_model=NullExcitation(model='exc_factor = 1 : 1'), fluor_model='\n            dFF_baseline = 1 / (1 + (K_d / Ca_rest) ** n_H) : 1\n            dFF = exc_factor * rho_rel * dFF_max  * (\n                1 / (1 + (K_d / CaB_active) ** n_H)\n                - dFF_baseline\n            ) : 1\n            rho_rel : 1\n        ', K_d=290. * nmolar, n_H=2.7, dFF_max=25.2), img_width=0.5 * mmetre, focus_depth=100. * umetre, location=array([0., 0., 0.]) * metre, direction=array([0., 0., 1.]), soma_radius=10. * umetre, snr_cutoff=1)})

Signal strength (ΔF/F for 1 spike) per ROI is thus defined as dFF_1AP * rho_rel.

While Cleo adjusts noise levels according to distance from the focal plane automatically, other adjustments can be made manually by calling target_neurons_in_plane() to reflect factors such as cell heterogeneity or indicator expression or increased noise with depth.

Neurons with SNR (defined as dFF_1AP / sigma_noise) below the scope’s snr_cutoff parameter are discarded.

i_targets, noise_focus_factor, focus_coords = scope.target_neurons_in_plane(ng, focus_depth=300 * b2.um, soma_radius=5 * b2.um)
# scale noise std σ randomly to simulate biological variability
std_noise = scope.sensor.sigma_noise * noise_focus_factor * rng.normal(1, .2, len(i_targets))
sim.inject(scope, ng, focus_depth=None, i_targets=i_targets, sigma_noise=std_noise, rho_rel_generator=expr_level_gen)

cleo.viz.plot(ng, colors=['#c500cc'], sim=sim)
(<Figure size 640x480 with 1 Axes>,
 <Axes3D: xlabel='x (um)', ylabel='y (um)', zlabel='z (um)'>)
../_images/afd41f8b55be6bfce0bd3f69cd2b1f8462846c3a501d3bed56304dc237e52c54.png

We can see targets off the visualized plane, resulting from our injections at 200 and 300 μm depths, resembling a multi-plane imaging experiment.

When focus_depth is set to None, corresponding to sculpted holographic imaging, the scope will select all neurons in the volume, or the user can specify a list of neurons to select via i_targets on injection.

After all targets are specified, the sensor protein must also be injected. Doing it this way allows for efficiency: the sensor protein is only simulated for the imaged neurons. Expression levels are as sampled previously on scope injection.

scope.inject_sensor_for_targets()

2p stimulation configuration#

Future work will address how to better quantify the relationship between 2P and the 1P action spectra, but for now we’ll pretend that Vf-Chrimson activation at 1060 nm is 1/100th that of peak 1P activation for the same power density.

opsin = opto.vfchrimson_4s()
opsin.spectrum.append((1060, .01))
sim.inject(opsin, ng)
CLSimulator(io_processor=None, devices={GECI(sim=..., name='gcamp6f', save_history=True, model='\n            dCa/dt = -gamma * (Ca - Ca_rest) / (1 + kappa_S + kappa_B) : mmolar (clock-driven)\n            kappa_B = B_T * K_d / (Ca + K_d)**2 : 1\n\n            CaB_active = Ca_rest + b : mmolar  # add tiny bit to avoid /0\n            db/dt = beta : mmolar (clock-driven)\n            lam = 1/tau_off + 1/tau_on : 1/second\n            kap = 1/tau_off : 1/second\n            dbeta/dt = (                    # should be M/s/s\n                A * (lam - kap) * (Ca - Ca_rest)  # M/s/s\n                - (kap + lam) * beta        # M/s/s\n                - kap * lam * b    # M/s/s\n            ) : mmolar/second (clock-driven)\n            \nexc_factor = 1 : 1\n\n            dFF_baseline = 1 / (1 + (K_d / Ca_rest) ** n_H) : 1\n            dFF = exc_factor * rho_rel * dFF_max  * (\n                1 / (1 + (K_d / CaB_active) ** n_H)\n                - dFF_baseline\n            ) : 1\n            rho_rel : 1\n        ', on_pre='Ca += dCa_T / (1 + kappa_S + kappa_B)', sigma_noise=0.03748181818181818, dFF_1AP=0.09775500000000001, location='cytoplasm', cal_model=DynamicCalcium(on_pre='Ca += dCa_T / (1 + kappa_S + kappa_B)', model='\n            dCa/dt = -gamma * (Ca - Ca_rest) / (1 + kappa_S + kappa_B) : mmolar (clock-driven)\n            kappa_B = B_T * K_d / (Ca + K_d)**2 : 1', Ca_rest=50. * nmolar, gamma=292.3 * hertz, B_T=200. * umolar, kappa_S=110, dCa_T=7.6 * umolar), bind_act_model=DoubExpCalBindingActivation(model='\n            CaB_active = Ca_rest + b : mmolar  # add tiny bit to avoid /0\n            db/dt = beta : mmolar (clock-driven)\n            lam = 1/tau_off + 1/tau_on : 1/second\n            kap = 1/tau_off : 1/second\n            dbeta/dt = (                    # should be M/s/s\n                A * (lam - kap) * (Ca - Ca_rest)  # M/s/s\n                - (kap + lam) * beta        # M/s/s\n                - kap * lam * b    # M/s/s\n            ) : mmolar/second (clock-driven)\n            ', A=7.61251 * khertz, tau_on=1.17164616 * second, tau_off=10.14020867 * msecond, Ca_rest=50. * nmolar), exc_model=NullExcitation(model='exc_factor = 1 : 1'), fluor_model='\n            dFF_baseline = 1 / (1 + (K_d / Ca_rest) ** n_H) : 1\n            dFF = exc_factor * rho_rel * dFF_max  * (\n                1 / (1 + (K_d / CaB_active) ** n_H)\n                - dFF_baseline\n            ) : 1\n            rho_rel : 1\n        ', K_d=290. * nmolar, n_H=2.7, dFF_max=25.2), Scope(sim=..., name='Scope', save_history=True, sensor=GECI(sim=..., name='gcamp6f', save_history=True, model='\n            dCa/dt = -gamma * (Ca - Ca_rest) / (1 + kappa_S + kappa_B) : mmolar (clock-driven)\n            kappa_B = B_T * K_d / (Ca + K_d)**2 : 1\n\n            CaB_active = Ca_rest + b : mmolar  # add tiny bit to avoid /0\n            db/dt = beta : mmolar (clock-driven)\n            lam = 1/tau_off + 1/tau_on : 1/second\n            kap = 1/tau_off : 1/second\n            dbeta/dt = (                    # should be M/s/s\n                A * (lam - kap) * (Ca - Ca_rest)  # M/s/s\n                - (kap + lam) * beta        # M/s/s\n                - kap * lam * b    # M/s/s\n            ) : mmolar/second (clock-driven)\n            \nexc_factor = 1 : 1\n\n            dFF_baseline = 1 / (1 + (K_d / Ca_rest) ** n_H) : 1\n            dFF = exc_factor * rho_rel * dFF_max  * (\n                1 / (1 + (K_d / CaB_active) ** n_H)\n                - dFF_baseline\n            ) : 1\n            rho_rel : 1\n        ', on_pre='Ca += dCa_T / (1 + kappa_S + kappa_B)', sigma_noise=0.03748181818181818, dFF_1AP=0.09775500000000001, location='cytoplasm', cal_model=DynamicCalcium(on_pre='Ca += dCa_T / (1 + kappa_S + kappa_B)', model='\n            dCa/dt = -gamma * (Ca - Ca_rest) / (1 + kappa_S + kappa_B) : mmolar (clock-driven)\n            kappa_B = B_T * K_d / (Ca + K_d)**2 : 1', Ca_rest=50. * nmolar, gamma=292.3 * hertz, B_T=200. * umolar, kappa_S=110, dCa_T=7.6 * umolar), bind_act_model=DoubExpCalBindingActivation(model='\n            CaB_active = Ca_rest + b : mmolar  # add tiny bit to avoid /0\n            db/dt = beta : mmolar (clock-driven)\n            lam = 1/tau_off + 1/tau_on : 1/second\n            kap = 1/tau_off : 1/second\n            dbeta/dt = (                    # should be M/s/s\n                A * (lam - kap) * (Ca - Ca_rest)  # M/s/s\n                - (kap + lam) * beta        # M/s/s\n                - kap * lam * b    # M/s/s\n            ) : mmolar/second (clock-driven)\n            ', A=7.61251 * khertz, tau_on=1.17164616 * second, tau_off=10.14020867 * msecond, Ca_rest=50. * nmolar), exc_model=NullExcitation(model='exc_factor = 1 : 1'), fluor_model='\n            dFF_baseline = 1 / (1 + (K_d / Ca_rest) ** n_H) : 1\n            dFF = exc_factor * rho_rel * dFF_max  * (\n                1 / (1 + (K_d / CaB_active) ** n_H)\n                - dFF_baseline\n            ) : 1\n            rho_rel : 1\n        ', K_d=290. * nmolar, n_H=2.7, dFF_max=25.2), img_width=0.5 * mmetre, focus_depth=100. * umetre, location=array([0., 0., 0.]) * metre, direction=array([0., 0., 1.]), soma_radius=10. * umetre, snr_cutoff=1), BansalFourStateOpsin(sim=..., name='VfChrimson', save_history=True, on_pre='', spectrum=[(470.0, 0.4123404255319149), (490.0, 0.593265306122449), (510.0, 0.7935294117647058), (530.0, 0.8066037735849055), (550.0, 0.8912727272727272), (570.0, 1.0), (590.0, 0.9661016949152542), (610.0, 0.7475409836065574), (630.0, 0.4342857142857143), (1060, 0.01)], required_vars=[('Iopto', amp), ('v', volt)], Gd1=0.37 * khertz, Gd2=175. * hertz, Gr0=0.667 * mhertz, g0=17.5 * nsiemens, phim=1.5e+22 * (second ** -1) / (meter ** 2), k1=3. * khertz, k2=200. * hertz, Gf0=20. * hertz, Gb0=3.2 * hertz, kf=10. * hertz, kb=10. * hertz, gamma=0.05, p=1, q=1, E=0. * volt, model='\n        dC1/dt = Gd1*O1 + Gr0*C2 - Ga1*C1 : 1 (clock-driven)\n        dO1/dt = Ga1*C1 + Gb*O2 - (Gd1+Gf)*O1 : 1 (clock-driven)\n        dO2/dt = Ga2*C2 + Gf*O1 - (Gd2+Gb)*O2 : 1 (clock-driven)\n        C2 = 1 - C1 - O1 - O2 : 1\n\n        Theta = int(phi_pre > 0*phi_pre) : 1\n        Hp = Theta * phi_pre**p/(phi_pre**p + phim**p) : 1\n        Ga1 = k1*Hp : hertz\n        Ga2 = k2*Hp : hertz\n        Hq = Theta * phi_pre**q/(phi_pre**q + phim**q) : 1\n        Gf = kf*Hq + Gf0 : hertz\n        Gb = kb*Hq + Gb0 : hertz\n\n        fphi = O1 + gamma*O2 : 1\n\n        IOPTO_VAR_NAME_post = -g0*fphi*(V_VAR_NAME_post-E)*rho_rel : ampere (summed)\n        rho_rel : 1')})

Now, to target these neurons with 2P laser power, we use tp_light_from_scope() to create a Light object with a 2P laser GaussianEllipsoid light profile centered on each of the scope’s targets:

laser = light.tp_light_from_scope(scope, wavelength=1060*b2.nmeter, name='laser')
sim.inject(laser, ng)
cleo.viz.plot(ng, colors=['#c500cc'], sim=sim)
(<Figure size 640x480 with 1 Axes>,
 <Axes3D: xlabel='x (um)', ylabel='y (um)', zlabel='z (um)'>)
../_images/13217b4165a8981ccec55745d5eb0cc3716f75f98f2a3ccf718f821b20c687d2.png

Simulating an all-optical experiment#

We’ll stimulate each neuron with a different number of laser pulses and see the resulting calcium traces.

from cleo.ioproc import LatencyIOProcessor

# for seeing ground-truth spikes
i_all_targets = scope.i_targets_for_neuron_group(ng)
smon = b2.SpikeMonitor(ng, record=i_all_targets)
sim.network.add(smon)

amplitude_mW = 2.5
pulse_width_ms = 2
interpulse_ms = 20

num_pulses = np.arange(1, scope.n + 1)
train_ends_ms = interpulse_ms * num_pulses


class IOProc(LatencyIOProcessor):
    def process(self, state_dict, time_ms):
        t_in_cycle = time_ms % interpulse_ms
        on = (0 <= t_in_cycle) & (t_in_cycle < pulse_width_ms) & (time_ms < (interpulse_ms * num_pulses))

        return {"laser": on * amplitude_mW}, time_ms


sim.set_io_processor(IOProc(1))

sim.reset()
tmax = 1000
sim.run(tmax * b2.ms)
INFO       No numerical integration method specified for group 'neurongroup', using method 'exact' (took 0.18s). [brian2.stateupdaters.base.method_choice]
INFO       No numerical integration method specified for group 'syn_VfChrimson_neurongroup', using method 'euler' (took 0.02s, trying other methods took 0.06s). [brian2.stateupdaters.base.method_choice]
INFO       No numerical integration method specified for group 'syn_gcamp6f_neurongroup', using method 'euler' (took 0.02s, trying other methods took 0.05s). [brian2.stateupdaters.base.method_choice]

Now we’ll plot the result:

import matplotlib.pyplot as plt
sep = .5
n2plot = len(i_all_targets)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6), sharex=True)

spt = np.empty((0,))
spi = np.empty((0,))
for i_target, i_ng in enumerate(i_all_targets):
    single_spt = smon.t[smon.i == i_ng]
    spt = np.concatenate((spt, single_spt))
    spi = np.concatenate((spi, np.full_like(single_spt, i_target)))
ax1.scatter(spt / b2.ms, spi, marker='|', label='spikes')
t_pulse_ms = np.arange(0, num_pulses.max() * interpulse_ms, interpulse_ms)
ax1.scatter(t_pulse_ms, np.full_like(t_pulse_ms, -1), marker='|', color='r', label='pulses')
ax1.set(ylabel='neuron index', title='ground-truth spikes', xlim=(0, tmax))
ax1.legend()

ax2.plot(scope.t_ms[:], np.array(scope.dFF)[:, :n2plot] + sep*np.arange(n2plot), lw=.2, c='w')
y_scale = 2
y0 = 0
x0 = -20
ax2.set(xlabel='time (ms)', title='ΔF/F₀ per ROI', xlim=(x0, tmax))
ax2.annotate('', xy=(x0, y0), xytext=(x0, y0 + y_scale), arrowprops=dict(arrowstyle='-'))
ax2.annotate(f'{y_scale * 100}% ΔF/F', xy=(x0, y0), va='bottom', ha='right', rotation=90)
ax2.yaxis.set_visible(False)
ax2.spines['left'].set_visible(False)
../_images/92552200c99940d3debea3b8002d4b94191f50964a2d0d3f7bbcb1aa35b8db26.png

Results are as expected:

  • We see one spike for every pulse, though for longer trains we might start to see missed spikes due to the opsin activation plateauing.

  • If neurons were closer together, we’d expect to see some off-target effects; if there are any here, they are apparently subthreshold.

  • Signal strength is proportional to the number of spikes, as expected, but varies somewhat due to heterogeneous expression.

  • We also see that noise levels vary from ROI to ROI due to varying distances from the focal plane.