Calibration: Characterising and Measuring Analog Behaviour
The goal of the exercises assembled in this task is to familiarise yourself with how we characterise and measure the analog behaviour of the chip. At the end you will understand how to convert raw hardware measurements into physical units and how to relate digital calibration values to measured membrane traces.
Note
This print-friendly version of the instructions only lists the most central functions and code blocks. The interactive variant provides additional helpers.
Characterizing the MADC
Before we continue our investigations of analog neurons, we need to characterize the readout chain used for the neurons’ signals. Since HICANN-X has digital communication interfaces, the analog voltage recordings of a neuron need to be digitized on chip.
Characterizing the analog-to-digital-converter (ADC) means applying a known voltage externally and recording the value returned by the ADC. Later, we will reverse this relation in order to convert measured data back to SI units.
In the following, we define a function which applies an external voltage and returns samples from the MADC. Your task is to sweep the external voltage and analyze the returned reads. Plot the characteristic and perform a linear fit to a suitable range. You will use the parameters of your fit in the following notebooks.
Low Level Hardware Access
You will not need to make changes to the function imported in the next cell. If you are curious how it is implemented you can take a look at it.
from _static.common.measure_voltage import measure_voltage
We first test the function above by setting a medium voltage, say 0.5 V. Look at the printed samples and their data type - notice the sampled value is the first entry in each tuple, accessible by the named field “value”.
from dlens_vx_v3 import hxcomm
# since we're not using the PyNN frontend here, we need to define our own
# connection to the hardware. It is available as a context manager:
with hxcomm.ManagedConnection() as connection:
result = measure_voltage(connection, 0.5)
print(result[:20])
print(result.dtype)
Exercises
Measure the sampled value for voltages between 0 and 1.2 V. We recommend steps of 0.05 V. For each data point, save a mean ADC value. Hint: Use something like
result["value"]
for calculating your mean ADC values. Complete the cell below to do so:
Hint: You can use the measure_voltage function.
import numpy as np
# Measure characteristic
voltages = ... # volt
results = np.zeros_like(voltages) # mean ADC values in LSB
errors = np.zeros_like(voltages) # std deviation of ADC values in LSB
with hxcomm.ManagedConnection() as connection:
for voltage_id, voltage in enumerate(voltages):
samples = ...
results[voltage_id] = ...
errors[voltage_id] = ...
...
Plot the characteristic, i.e. plot the acquired value over the external voltage.
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 8))
# TODO
...
plt.xlabel("Voltage [V]")
plt.ylabel("MADC value [LSB]")
Perform a linear fit in a suitable range (exclude possibly saturated values on top and bottom). Hint: In order to convert sample values to voltage later, you can use the results as “x data” and the voltages as “y data” for your fit function.
# fit linear function to data
from scipy.optimize import curve_fit
# TODO
Plot your linear fit and save the plot.
Save the fit parameters for later, so you can convert ADC values to Volt.
You can use the fit parameters you obtained to implement a madc value to voltage conversion routine like so:
def madc_to_voltage_conversion(m, b):
def convert(value):
return m * value + b
return convert
madc_to_v = madc_to_voltage_conversion(*popt)
Characterizing Neuron Parameters
We now move on to a characterisation of Neuron Parameters, the goal is to relate the parameter values, which are specified in arbitrary units to the MADC measurement values. That way you will establish a correspondence between the values you specify and the membrane measurement values send back to you by the chip.
We begin by importing some commonly used calibration code
A default calibration is generated for every setup every night. We save the nightly calibration in two variables such that we can use it later when we define our neuronal network.
from _static.common.helpers import get_nightly_calibration
calib = get_nightly_calibration()
and define plotting functions
import pynn_brainscales.brainscales2 as pynn
from pynn_brainscales.brainscales2 import Population
from pynn_brainscales.brainscales2.standardmodels.cells import SpikeSourceArray
from pynn_brainscales.brainscales2.standardmodels.synapses import StaticSynapse
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
import time
plt.style.use("_static/matplotlibrc")
def plot_membrane_dynamics(population: Population, segment_id=-1, ylim=None):
"""
Plot the membrane potential of the neuron in a given population view. Only
population views of size 1 are supported.
:param population: Population, membrane traces and spikes are plotted for.
:param segment_id: Index of the neo segment to be plotted. Defaults to
-1, encoding the last recorded segment.
:param ylim: y-axis limits for the plot.
"""
if len(population) != 1:
raise ValueError("Plotting is supported for populations of size 1.")
# Experimental results are given in the 'neo' data format
mem_v = population.get_data("v").segments[segment_id].irregularlysampledsignals[0]
spikes = population.get_data("spikes").segments[-1].spiketrains[0]
plt.plot(mem_v.times, mem_v, alpha=0.5, color='black')
# indicate the spikes as red dots
plt.scatter(spikes, np.max(mem_v)*np.ones_like(spikes), color='red')
plt.xlabel("Wall clock time [ms]")
plt.ylabel("ADC readout [a.u.]")
if ylim:
plt.ylim(ylim)
The following experiment code let’s you interactively explore the measurement we are about to perform:
from ipywidgets import interact, IntSlider
from functools import partial
IntSlider = partial(IntSlider, continuous_update=False)
@interact(
neuron_idx=IntSlider(min=0, max=511, step=1, value=0),
v_leak=IntSlider(min=0, max=1022, step=1, value=1000),
v_threshold=IntSlider(min=0, max=500, step=1, value=500),
v_reset=IntSlider(min=0, max=1022, step=1, value=400),
i_bias_leak=IntSlider(min=0, max=1022, step=1, value=150),
)
def experiment(neuron_idx, v_leak, v_threshold, v_reset, i_bias_leak):
"""
Set up a leak over threshold neuron.
:param v_leak: Leak potential.
:param v_threshold: Spike threshold potential.
:param v_reset: Reset potential.
:param i_bias_leak: Controls the leak conductance (membrane time constant).
:param savefig: Save the experiment figure.
"""
plt.figure()
# everything between pynn.setup() and pynn.end()
# below is part of one hardware run.
pynn.setup(initial_config=calib, neuronPermutation=[neuron_idx])
# a pynn.Population corresponds to a certain number of
# neuron circuits on the chip
pop = pynn.Population(1, pynn.cells.HXNeuron(
# Leak potential, range: 400-1000
leak_v_leak=v_leak,
# Leak conductance, range: 0-1022
leak_i_bias=i_bias_leak,
# Threshold potential, range: 0-500
threshold_v_threshold=v_threshold,
# Reset potential, range: 300-1000
reset_v_reset=v_reset,
# Membrane capacitance, range: 0-63
membrane_capacitance_capacitance=63,
# Refractory time (counter), range: 0-255
refractory_period_refractory_time=255,
# Enable reset on threshold crossing
threshold_enable=True,
# Reset conductance, range: 0-1022
reset_i_bias=1022,
# Increase reset conductance
reset_enable_multiplication=True))
pop.record(["v", "spikes"])
# this triggers a hardware execution
# with a duration of 0.2 ms
pynn.run(0.2)
plot_membrane_dynamics(pop, ylim=(100, 800))
plt.show()
mem = pop.get_data("v").segments[-1].irregularlysampledsignals[0]
spikes = pop.get_data("spikes").segments[-1].spiketrains[0]
pynn.end()
return mem, spikes
We now define a function which returns the measured membrane voltage trace for a given set of parameters
def get_v_mem(neuron_params: dict, neuron_idx: int) -> np.ndarray:
pynn.setup(initial_config=calib, neuronPermutation=[neuron_idx])
pop = pynn.Population(1, pynn.cells.HXNeuron(**neuron_params))
pop.record(["v"])
pynn.run(0.2)
mem_v = pop.get_data("v").segments[0].irregularlysampledsignals[0]
pynn.end()
return mem_v.base
We want to perform this experiment over potentially a larger number of neurons and for specific neuron ranges
def get_data(number_of_neurons, sweep_range, sweep_neuron_param,
filter_function, neuron_params):
y_measured = np.zeros(shape=(number_of_neurons, len(sweep_range)))
for neuron_idx in range(number_of_neurons):
y_measured_local = []
for dac_idx, dac_value in enumerate(sweep_range):
neuron_params.update({sweep_neuron_param: dac_value})
membrane = get_v_mem(neuron_params, neuron_idx)
print(f"Measuring neuron {neuron_idx} for "
f"{sweep_neuron_param} with value: {dac_value}")
y_measured_local.append(filter_function(membrane.magnitude))
y_measured[neuron_idx][dac_idx] = filter_function(membrane.magnitude)
return y_measured
This defines the main function for the parameter sweep
def main(number_of_neurons, sweep_range, param_to_be_determined):
if param_to_be_determined == "threshold":
neuron_params = {"leak_v_leak": 1022,
"reset_v_reset": 50}
sweep_neuron_param = "threshold_v_threshold"
filter_function = np.max
if param_to_be_determined == "reset":
neuron_params = {"threshold_v_threshold": 150,
"leak_v_leak": 1022}
sweep_neuron_param = "reset_v_reset"
filter_function = np.min
if param_to_be_determined == "leak":
neuron_params = {"threshold_v_threshold": 1022,
"reset_v_reset": 50}
sweep_neuron_param = "leak_v_leak"
filter_function = np.mean
y_measured = get_data(number_of_neurons, sweep_range, sweep_neuron_param,
filter_function, neuron_params)
#
measured_mean = np.mean(y_measured, axis=0)
measured_err = np.std(y_measured, axis=0)
return sweep_range, y_measured, measured_mean, measured_err
def plot_and_fit(ax, data, label):
sweep_range, y_measured, measured_mean, measured_err = data
number_of_neurons = y_measured.shape[0]
ax.set_xlabel(f"set $V_{{{label}}}$ [DAC value]")
ax.set_ylabel(f"measured $V_{{{label}}}$ [ADC value]")
for k in range(number_of_neurons):
ax.plot(sweep_range, y_measured[k], '-',
color='darkgray', linewidth=0.25, zorder=-1)
ax.errorbar(sweep_range, measured_mean, yerr=measured_err,
color='red', linestyle='-', marker='None', zorder=0)
linear_fit = np.polyfit(sweep_range, measured_mean, 1)
ax.plot(sweep_range, linear_fit[0] * sweep_range + linear_fit[1],
'b--', zorder=1)
return linear_fit
The general idea is to modify the sweep range to find a suitable range for the fit for respective parameters. Start with full range and narrow down.
# for how many neurons should an average characterisation be done
num_neurons = 1
# valid range [0, 1022]
adc_sweep_range = np.arange(0, 1022, 50)
# parameter to be determined, valid "threshold", "reset" or "leak"
adc_sweep_range = np.arange(50, 301, 50)
param_name = "threshold"
threshold_data = main(num_neurons, adc_sweep_range, param_name)
adc_sweep_range = np.arange(500, 901, 100)
param_name = "leak"
leak_data = main(num_neurons, adc_sweep_range, param_name)
adc_sweep_range = np.arange(400, 901, 100)
param_name = "reset"
reset_data = main(num_neurons, adc_sweep_range, param_name)
Excercises
fig, ax = plt.subplots(1,3, sharey=True)
v_th_fit = plot_and_fit(ax[0], threshold_data, "th")
v_leak_fit = plot_and_fit(ax[1], leak_data, "leak")
v_reset_fit = plot_and_fit(ax[2], reset_data, "reset")
def linear(param):
def fun(x):
return param[0] * x + param[1]
def inverse(x):
return (x - param[1]) / param[0]
return fun, inverse
v_th_s2m, v_th_m2s = linear(v_th_fit)
v_leak_s2m, v_leak_m2s = linear(v_leak_fit)
v_reset_s2m, v_reset_m2s = linear(v_reset_fit)
@interact(
neuron_idx=IntSlider(min=0, max=511, step=1, value=0),
v_leak=IntSlider(min=v_leak_s2m(0), max=v_leak_s2m(1022), step=1, value=v_leak_s2m(1000)),
v_threshold=IntSlider(min=v_leak_s2m(0), max=v_th_s2m(500), step=1, value=v_th_s2m(500)),
v_reset=IntSlider(min=v_reset_s2m(0), max=v_reset_s2m(1022), step=1, value=v_reset_s2m(400)),
i_bias_leak=IntSlider(min=0, max=1022, step=1, value=150),
)
def experiment(neuron_idx, v_leak, v_threshold, v_reset, i_bias_leak):
"""
Set up a leak over threshold neuron.
:param v_leak: Leak potential.
:param v_threshold: Spike threshold potential.
:param v_reset: Reset potential.
:param i_bias_leak: Controls the leak conductance (membrane time constant).
:param savefig: Save the experiment figure.
"""
v_leak = v_leak_m2s(v_leak)
v_threshold = v_th_m2s(v_threshold)
v_reset = v_reset_m2s(v_reset)
plt.figure()
# everything between pynn.setup() and pynn.end()
# below is part of one hardware run.
pynn.setup(initial_config=calib, neuronPermutation=[neuron_idx])
# a pynn.Population corresponds to a certain number of
# neuron circuits on the chip
pop = pynn.Population(1, pynn.cells.HXNeuron(
# Leak potential, range: 400-1000
leak_v_leak=v_leak,
# Leak conductance, range: 0-1022
leak_i_bias=i_bias_leak,
# Threshold potential, range: 0-500
threshold_v_threshold=v_threshold,
# Reset potential, range: 300-1000
reset_v_reset=v_reset,
# Membrane capacitance, range: 0-63
membrane_capacitance_capacitance=63,
# Refractory time (counter), range: 0-255
refractory_period_refractory_time=255,
# Enable reset on threshold crossing
threshold_enable=True,
# Reset conductance, range: 0-1022
reset_i_bias=1022,
# Increase reset conductance
reset_enable_multiplication=True))
pop.record(["v", "spikes"])
# this triggers a hardware execution
# with a duration of 0.2 ms
pynn.run(0.2)
plot_membrane_dynamics(pop, ylim=(100, 800))
plt.axhline(v_th_s2m(v_threshold))
plt.axhline(v_reset_s2m(v_reset))
plt.axhline(v_leak_s2m(v_leak), color='green')
plt.show()
mem = pop.get_data("v").segments[-1].irregularlysampledsignals[0]
spikes = pop.get_data("spikes").segments[-1].spiketrains[0]
pynn.end()
return mem, spikes
Calibrating Neuron Parameters
def membrane2dac(voltage, slope, offset):
return int((voltage - offset) / slope)
# set voltages
# threshold must be according to:
v_leak = 350
v_reset = 200
v_th = v_leak - (v_leak - v_reset) / np.exp(1)
# enter your fit results from task a) into the membrane2dac calls
neuron_params = {"leak_i_bias": 400,
"reset_i_bias": 1022,
"leak_enable_division": True,
"threshold_enable": True,
"reset_enable_multiplication": True,
"membrane_capacitance_capacitance": 63,
"refractory_period_refractory_time": 150,
"leak_v_leak": membrane2dac(v_leak, 0.79, -180),
"reset_v_reset": membrane2dac(v_reset, 0.88, -250),
"threshold_v_threshold": membrane2dac(v_th, 0.99, 60)
}
# the leak conductance g_leak is set by I_bias
# each entry refers to a different neuron
# must be adjusted for identical firing rates!
I_bias = [400, 400, 400, 400]
assert len(I_bias) > 3, "You need to look at least on 4 neurons."
Gather spikes an membrane trace for different neurons
# as we can only readout the membrane of one neuron at a time we need to
# repeat the measurement for each neuron
result = []
for nrnidx, bias in enumerate(I_bias):
pynn.setup()
pop = pynn.Population(len(I_bias),
pynn.cells.HXNeuron(**neuron_params))
pop.set(leak_i_bias=bias)
# a population view is a subset of a population
p_view = pynn.PopulationView(pop, [nrnidx])
p_view.record(["v", "spikes"])
pynn.run(2)
spikes = p_view.get_data("spikes").segments[0].spiketrains[0]
# # FIXME spike readout fails some times
# if len(spikes) <= 1:
# raise RuntimeError("not enough spikes recorded")
mem_v = p_view.get_data("v").segments[0].irregularlysampledsignals[0]
pynn.end()
result.append((spikes, mem_v))
Analyse and plot neuron output
membrane = []
spikes = []
isi = []
for nrnidx, (spike, v) in enumerate(result):
spikes.append(spike)
membrane.append(v)
isi.append(np.diff(spikes[nrnidx]))
num_plots = len(I_bias)
# -> play around with figsize to modify plot resolution
_, axes = plt.subplots(
num_plots, 1, sharex=True, sharey=True, figsize=(8, 5))
axes = axes.flatten()
for nrnidx in range(num_plots):
max_v = np.max(membrane[nrnidx])
print(f'neuron {nrnidx}')
print(f"max. membrane potential: {max_v:.3f} V")
print(f"firing rate: {np.mean(1 / isi[nrnidx]):.4} "
f"+- {np.std(1 / isi[nrnidx]):.2f} MHz")
axes[nrnidx].plot(membrane[nrnidx].times, membrane[nrnidx])
axes[nrnidx].plot(
spikes[nrnidx],
[max_v for i in range(len(spikes[nrnidx]))],
'.', color='red')
axes[0].set_xlim(0, 0.2)
axes[2].set_ylabel("V [V]", fontsize=12)
axes[-1].set_xlabel("t [ms]", fontsize=12)
figname = f'fp_task2b_4membranes_{time.strftime("%Y%m%d-%H%M%S")}.png'
plt.savefig(figname)
print(f"plot saved {figname}")
Calibrating Membrane Time Constant
In this optional task you create a calibration routine to calibrate the leak potential of all neurons.
def membrane2dac(voltage, slope, offset): return int((voltage - offset) / slope) # set voltages in volt # threshold must be according to: v_leak = 350 v_reset = 200 v_th = v_leak - (v_leak - v_reset) / np.exp(1) # enter your fit results from task a) into the voltage2dac calls neuron_params = {"leak_i_bias": 400, "reset_i_bias": 38, "leak_enable_division": True, # set False for freq > 40 "threshold_enable": True, "reset_enable_multiplication": True, "membrane_capacitance_capacitance": 63, "refractory_period_refractory_time": 150, "leak_v_leak": membrane2dac(v_leak, 0.79, -180), "reset_v_reset": membrane2dac(v_reset, 0.88, -250), "threshold_v_threshold": membrane2dac(v_th, 0.99, 60) } targetrate = 30.0 # in kHz # strength of calibration # choose value between 0 and 1 calib_param = 0.7def get_data(i_bias, iteration): rates = [] gleaks = [] # capacitance from DAC value (63 equals 2.2pF) c_m = 2.2 * 10e-12 * neuron_params["membrane_capacitance_capacitance"] / 63 # tau_ref from DAC value with f_clock = 10MHz tau_ref = neuron_params["refractory_period_refractory_time"] / (10e6) * 1e3 pynn.setup() pop = pynn.Population(len(i_bias), pynn.cells.HXNeuron(**neuron_params)) pop.set(leak_i_bias=i_bias) pop.record(["spikes"]) pynn.run(2) all_spikes = pop.get_data("spikes").segments[0].spiketrains pynn.end() for nrnidx, spikes in enumerate(all_spikes): # if not enough spikes are recorded, the neuron is skipped if len(spikes) <= 1: rates.append(-1) gleaks.append(-1) print("not enough spikes recorded for neuron ", nrnidx) else: # calculate the firing rate from the spike array isi = np.array(np.diff(spikes)) rates.append(1 / np.mean(isi)) # calculate gleak from the interspike interval tau_m = np.mean(isi) - tau_ref gleaks.append(c_m / tau_m) # print(f"data obtained for neuron {nrnidx}") print(f"The average firing rate is {np.mean(rates):.4} " f"+- {np.std(rates):.3} kHz") print(f"The average leak conductance is {np.mean(gleaks):.4} " f"+- {np.std(gleaks):.3} S") # save results for plotting np.savetxt(f"fp_task2c_rates_iteration{iteration}.txt", rates) np.savetxt(f"fp_task2c_gleaks_iteration{iteration}.txt", gleaks)def calibrate_neurons(i_bias, num_iterations): for iteration in range(num_iterations): get_data(i_bias, iteration) rates = np.loadtxt(f"fp_task2c_rates_iteration{iteration}.txt") for nrnidx, _ in enumerate(i_bias): if rates[nrnidx] != -1: # TODO: Check code here scaling = (calib_param * (targetrate / rates[nrnidx] - 1) + 1) i_bias[nrnidx] = int(scaling * i_bias[nrnidx]) if i_bias[nrnidx] > 1022: print(f"Reached max i_bias for neuron {nrnidx}") i_bias[nrnidx] = 1022 if i_bias[nrnidx] < 0: print(f"Reached min i_bias for neuron {nrnidx}") i_bias[nrnidx] = 0Definition of visualization
def plot_histogram(num_iterations): # read data and filter failed runs rates_pre = np.loadtxt("fp_task2c_rates_iteration0.txt") rates_pre = rates_pre[rates_pre >= 0] gleaks_pre = np.loadtxt("fp_task2c_gleaks_iteration0.txt") gleaks_pre = gleaks_pre[gleaks_pre >= 0] rates_post = np.loadtxt(f"fp_task2c_rates_iteration{num_iterations-1}.txt") rates_post = rates_post[rates_post >= 0] gleaks_post = np.loadtxt( f"fp_task2c_gleaks_iteration{num_iterations-1}.txt") gleaks_post = gleaks_post[gleaks_post >= 0] bins = min(len(rates_pre), 20) # plot two histograms fig = plt.figure() ax_rate = fig.add_subplot(221) ax_rate.set_title("Uncalibrated firing rates") ax_rate.set_xlabel("rates [kHz]") ax_rate.set_ylabel("number of neurons") ax_rate.hist(rates_pre, bins=bins) ax_gleak = fig.add_subplot(222) ax_gleak.set_title("Uncalibrated leak conductance") ax_gleak.set_xlabel("$g_{leak}$ [S]") ax_gleak.set_ylabel("number of neurons") ax_gleak.yaxis.tick_right() ax_gleak.yaxis.set_label_position("right") ax_gleak.hist(gleaks_pre, bins=bins) rates_range = [min(np.min(rates_pre), np.min(rates_post)), max(np.max(rates_pre), np.max(rates_post))] ax_rate = fig.add_subplot(223, sharex=ax_rate) ax_rate.set_title("Calibrated firing rates") ax_rate.set_xlabel("rates [kHz]") ax_rate.set_ylabel("number of neurons") ax_rate.hist(rates_post, bins=bins, range=rates_range) gleak_ranges = [min(np.min(gleaks_pre, initial=0.), np.min(gleaks_post, initial=0.)), max(np.max(gleaks_pre, initial=0.), np.max(gleaks_post, initial=0.))] ax_gleak = fig.add_subplot(224, sharex=ax_gleak) ax_gleak.set_title("Calibrated leak conductance") ax_gleak.set_xlabel("$g_{leak}$ [S]") ax_gleak.set_ylabel("number of neurons") ax_gleak.yaxis.tick_right() ax_gleak.yaxis.set_label_position("right") ax_gleak.hist(gleaks_post, bins=bins, range=gleak_ranges) plt.subplots_adjust(left=0.1, right=0.9, bottom=0.1, top=0.9, wspace=0.2, hspace=0.5) figname = f'fp_task2c_histo_{time.strftime("%Y%m%d-%H%M%S")}.png' plt.savefig(figname) print(f"plots saved {figname}")Execution
# choose neurons you want to observe # the default is range(0,128) num_neurons = 100 # number of iterations for calibration num_iterations = 20 # the leak conductance g_leak is set by I_bias # will be adjusted for identical firing rates i_bias = [400] * num_neurons # calibrate neurons for same firing rate calibrate_neurons(i_bias, num_iterations) # plot histograms for uncalibrated and calibrated neurons plot_histogram(num_iterations)
Exercises
Please complete the missing code in the cells above
Execute all cells, determine sensible parameter characterization ranges
Document the results that you have obtained and the plots that you have created below
Solution
Please insert your results here…