Spaces:
Build error
Build error
Yann Bouteiller
commited on
Commit
Β·
a64c5cc
1
Parent(s):
39c06fc
A lot of reorg and development of phase detection
Browse files- portiloop/{nn β demo}/demo_net.py +0 -0
- portiloop/notebooks/tests.ipynb +45 -5
- portiloop/{capture.py β src/capture.py} +126 -496
- portiloop/src/config.py +136 -0
- portiloop/{detection.py β src/detection.py} +1 -1
- portiloop/{hardware β src/hardware}/__init__.py +0 -0
- portiloop/{hardware β src/hardware}/frontend.py +0 -0
- portiloop/{hardware β src/hardware}/leds.py +0 -0
- portiloop/src/processing.py +141 -0
- portiloop/{stimulation.py β src/stimulation.py} +114 -32
- portiloop/src/utils.py +108 -0
portiloop/{nn β demo}/demo_net.py
RENAMED
|
File without changes
|
portiloop/notebooks/tests.ipynb
CHANGED
|
@@ -2,16 +2,56 @@
|
|
| 2 |
"cells": [
|
| 3 |
{
|
| 4 |
"cell_type": "code",
|
| 5 |
-
"execution_count":
|
| 6 |
"id": "16651843",
|
| 7 |
"metadata": {
|
| 8 |
"scrolled": false
|
| 9 |
},
|
| 10 |
-
"outputs": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"source": [
|
| 12 |
-
"from portiloop.capture import Capture\n",
|
| 13 |
-
"from portiloop.detection import SleepSpindleRealTimeDetector\n",
|
| 14 |
-
"from portiloop.stimulation import SleepSpindleRealTimeStimulator\n",
|
| 15 |
"\n",
|
| 16 |
"my_detector_class = SleepSpindleRealTimeDetector # you may want to implement yours\n",
|
| 17 |
"my_stimulator_class = SleepSpindleRealTimeStimulator # you may also want to implement yours\n",
|
|
|
|
| 2 |
"cells": [
|
| 3 |
{
|
| 4 |
"cell_type": "code",
|
| 5 |
+
"execution_count": 1,
|
| 6 |
"id": "16651843",
|
| 7 |
"metadata": {
|
| 8 |
"scrolled": false
|
| 9 |
},
|
| 10 |
+
"outputs": [
|
| 11 |
+
{
|
| 12 |
+
"data": {
|
| 13 |
+
"application/vnd.jupyter.widget-view+json": {
|
| 14 |
+
"model_id": "573c77a0af1944859cce1eeb2c6eadea",
|
| 15 |
+
"version_major": 2,
|
| 16 |
+
"version_minor": 0
|
| 17 |
+
},
|
| 18 |
+
"text/plain": [
|
| 19 |
+
"VBox(children=(Accordion(children=(GridBox(children=(Label(value='CH2'), Label(value='CH3'), Label(value='CH4'β¦"
|
| 20 |
+
]
|
| 21 |
+
},
|
| 22 |
+
"metadata": {},
|
| 23 |
+
"output_type": "display_data"
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"name": "stdout",
|
| 27 |
+
"output_type": "stream",
|
| 28 |
+
"text": [
|
| 29 |
+
"DEBUG:/home/mendel/portiloop-software/portiloop/sounds/stimulus.wav\n"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"name": "stderr",
|
| 34 |
+
"output_type": "stream",
|
| 35 |
+
"text": [
|
| 36 |
+
"Exception in thread Thread-5:\n",
|
| 37 |
+
"Traceback (most recent call last):\n",
|
| 38 |
+
" File \"/usr/lib/python3.7/threading.py\", line 917, in _bootstrap_inner\n",
|
| 39 |
+
" self.run()\n",
|
| 40 |
+
" File \"/usr/lib/python3.7/threading.py\", line 865, in run\n",
|
| 41 |
+
" self._target(*self._args, **self._kwargs)\n",
|
| 42 |
+
" File \"/home/mendel/portiloop-software/portiloop/src/capture.py\", line 920, in start_capture\n",
|
| 43 |
+
" stimulator = stimulator_cls() if stimulator_cls is not None else None\n",
|
| 44 |
+
" File \"/home/mendel/portiloop-software/portiloop/src/stimulation.py\", line 81, in __init__\n",
|
| 45 |
+
" self.pcm = alsaaudio.PCM(channels=f.getnchannels(), rate=f.getframerate(), format=format, periodsize=self.periodsize, device=device)\n",
|
| 46 |
+
"alsaaudio.ALSAAudioError: Device or resource busy [default]\n",
|
| 47 |
+
"\n"
|
| 48 |
+
]
|
| 49 |
+
}
|
| 50 |
+
],
|
| 51 |
"source": [
|
| 52 |
+
"from portiloop.src.capture import Capture\n",
|
| 53 |
+
"from portiloop.src.detection import SleepSpindleRealTimeDetector\n",
|
| 54 |
+
"from portiloop.src.stimulation import SleepSpindleRealTimeStimulator\n",
|
| 55 |
"\n",
|
| 56 |
"my_detector_class = SleepSpindleRealTimeDetector # you may want to implement yours\n",
|
| 57 |
"my_stimulator_class = SleepSpindleRealTimeStimulator # you may also want to implement yours\n",
|
portiloop/{capture.py β src/capture.py}
RENAMED
|
@@ -1,345 +1,27 @@
|
|
| 1 |
-
|
| 2 |
-
import sys
|
| 3 |
|
| 4 |
from time import sleep
|
| 5 |
import time
|
| 6 |
import numpy as np
|
| 7 |
-
import os
|
| 8 |
from copy import deepcopy
|
| 9 |
-
from
|
| 10 |
-
from datetime import datetime, timedelta
|
| 11 |
import multiprocessing as mp
|
| 12 |
import warnings
|
| 13 |
-
import shutil
|
| 14 |
from threading import Thread, Lock
|
| 15 |
import alsaaudio
|
| 16 |
|
| 17 |
-
from
|
| 18 |
-
from scipy.signal import firwin
|
| 19 |
-
|
| 20 |
-
from portilooplot.jupyter_plot import ProgressPlot
|
| 21 |
-
from portiloop.hardware.frontend import Frontend
|
| 22 |
-
from portiloop.hardware.leds import LEDs, Color
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
from IPython.display import clear_output, display
|
| 25 |
import ipywidgets as widgets
|
| 26 |
|
| 27 |
|
| 28 |
-
|
| 29 |
-
# nomenclature: name [default setting] [bits 7-0] : description
|
| 30 |
-
# Read only ID:
|
| 31 |
-
0x3E, # ID [xx] [REV_ID[2:0], 1, DEV_ID[1:0], NU_CH[1:0]] : (RO)
|
| 32 |
-
# Global Settings Across Channels:
|
| 33 |
-
0x96, # CONFIG1 [96] [1, DAISY_EN(bar), CLK_EN, 1, 0, DR[2:0]] : Datarate = 250 SPS
|
| 34 |
-
0xC0, # CONFIG2 [C0] [1, 1, 0, INT_CAL, 0, CAL_AMP0, CAL_FREQ[1:0]] : No tests
|
| 35 |
-
0x60, # CONFIG3 [60] [PD_REFBUF(bar), 1, 1, BIAS_MEAS, BIASREF_INT, PD_BIAS(bar), BIAS_LOFF_SENS, BIAS_STAT] : Power-down reference buffer, no bias
|
| 36 |
-
0x00, # LOFF [00] [COMP_TH[2:0], 0, ILEAD_OFF[1:0], FLEAD_OFF[1:0]] : No lead-off
|
| 37 |
-
# Channel-Specific Settings:
|
| 38 |
-
0x61, # CH1SET [61] [PD1, GAIN1[2:0], SRB2, MUX1[2:0]] : Channel 1 active, 24 gain, no SRB2 & input shorted
|
| 39 |
-
0x61, # CH2SET [61] [PD2, GAIN2[2:0], SRB2, MUX2[2:0]] : Channel 2 active, 24 gain, no SRB2 & input shorted
|
| 40 |
-
0x61, # CH3SET [61] [PD3, GAIN3[2:0], SRB2, MUX3[2:0]] : Channel 3 active, 24 gain, no SRB2 & input shorted
|
| 41 |
-
0x61, # CH4SET [61] [PD4, GAIN4[2:0], SRB2, MUX4[2:0]] : Channel 4 active, 24 gain, no SRB2 & input shorted
|
| 42 |
-
0x61, # CH5SET [61] [PD5, GAIN5[2:0], SRB2, MUX5[2:0]] : Channel 5 active, 24 gain, no SRB2 & input shorted
|
| 43 |
-
0x61, # CH6SET [61] [PD6, GAIN6[2:0], SRB2, MUX6[2:0]] : Channel 6 active, 24 gain, no SRB2 & input shorted
|
| 44 |
-
0x61, # CH7SET [61] [PD7, GAIN7[2:0], SRB2, MUX7[2:0]] : Channel 7 active, 24 gain, no SRB2 & input shorted
|
| 45 |
-
0x61, # CH8SET [61] [PD8, GAIN8[2:0], SRB2, MUX8[2:0]] : Channel 8 active, 24 gain, no SRB2 & input shorted
|
| 46 |
-
0x00, # BIAS_SENSP [00] [BIASP8, BIASP7, BIASP6, BIASP5, BIASP4, BIASP3, BIASP2, BIASP1] : No bias
|
| 47 |
-
0x00, # BIAS_SENSN [00] [BIASN8, BIASN7, BIASN6, BIASN5, BIASN4, BIASN3, BIASN2, BIASN1] No bias
|
| 48 |
-
0x00, # LOFF_SENSP [00] [LOFFP8, LOFFP7, LOFFP6, LOFFP5, LOFFP4, LOFFP3, LOFFP2, LOFFP1] : No lead-off
|
| 49 |
-
0x00, # LOFF_SENSN [00] [LOFFM8, LOFFM7, LOFFM6, LOFFM5, LOFFM4, LOFFM3, LOFFM2, LOFFM1] : No lead-off
|
| 50 |
-
0x00, # LOFF_FLIP [00] [LOFF_FLIP8, LOFF_FLIP7, LOFF_FLIP6, LOFF_FLIP5, LOFF_FLIP4, LOFF_FLIP3, LOFF_FLIP2, LOFF_FLIP1] : No lead-off flip
|
| 51 |
-
# Lead-Off Status Registers (Read-Only Registers):
|
| 52 |
-
0x00, # LOFF_STATP [00] [IN8P_OFF, IN7P_OFF, IN6P_OFF, IN5P_OFF, IN4P_OFF, IN3P_OFF, IN2P_OFF, IN1P_OFF] : Lead-off positive status (RO)
|
| 53 |
-
0x00, # LOFF_STATN [00] [IN8M_OFF, IN7M_OFF, IN6M_OFF, IN5M_OFF, IN4M_OFF, IN3M_OFF, IN2M_OFF, IN1M_OFF] : Laed-off negative status (RO)
|
| 54 |
-
# GPIO and OTHER Registers:
|
| 55 |
-
0x0F, # GPIO [0F] [GPIOD[4:1], GPIOC[4:1]] : All GPIOs as inputs
|
| 56 |
-
0x00, # MISC1 [00] [0, 0, SRB1, 0, 0, 0, 0, 0] : Disable SRBM
|
| 57 |
-
0x00, # MISC2 [00] [00] : Unused
|
| 58 |
-
0x00, # CONFIG4 [00] [0, 0, 0, 0, SINGLE_SHOT, 0, PD_LOFF_COMP(bar), 0] : Single-shot, lead-off comparator disabled
|
| 59 |
-
]
|
| 60 |
-
|
| 61 |
-
FRONTEND_CONFIG = [
|
| 62 |
-
0x3E, # ID (RO)
|
| 63 |
-
0x95, # CONFIG1 [95] [1, DAISY_EN(bar), CLK_EN, 1, 0, DR[2:0]] : Datarate = 500 SPS
|
| 64 |
-
0xD0, # CONFIG2 [C0] [1, 1, 0, INT_CAL, 0, CAL_AMP0, CAL_FREQ[1:0]]
|
| 65 |
-
0xFC, # CONFIG3 [E0] [PD_REFBUF(bar), 1, 1, BIAS_MEAS, BIASREF_INT, PD_BIAS(bar), BIAS_LOFF_SENS, BIAS_STAT] : Power-down reference buffer, no bias
|
| 66 |
-
0x00, # No lead-off
|
| 67 |
-
0x62, # CH1SET [60] [PD1, GAIN1[2:0], SRB2, MUX1[2:0]] set to measure BIAS signal
|
| 68 |
-
0x60, # CH2SET
|
| 69 |
-
0x60, # CH3SET
|
| 70 |
-
0x60, # CH4SET
|
| 71 |
-
0x60, # CH5SET
|
| 72 |
-
0x60, # CH6SET
|
| 73 |
-
0x60, # CH7SET
|
| 74 |
-
0x60, # CH8SET
|
| 75 |
-
0x00, # BIAS_SENSP 00
|
| 76 |
-
0x00, # BIAS_SENSN 00
|
| 77 |
-
0x00, # LOFF_SENSP Lead-off on all positive pins?
|
| 78 |
-
0x00, # LOFF_SENSN Lead-off on all negative pins?
|
| 79 |
-
0x00, # Normal lead-off
|
| 80 |
-
0x00, # Lead-off positive status (RO)
|
| 81 |
-
0x00, # Lead-off negative status (RO)
|
| 82 |
-
0x00, # All GPIOs as output ?
|
| 83 |
-
0x20, # Enable SRB1
|
| 84 |
-
]
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
LEADOFF_CONFIG = [
|
| 88 |
-
0x3E, # ID (RO)
|
| 89 |
-
0x95, # CONFIG1 [95] [1, DAISY_EN(bar), CLK_EN, 1, 0, DR[2:0]] : Datarate = 500 SPS
|
| 90 |
-
0xC0, # CONFIG2 [C0] [1, 1, 0, INT_CAL, 0, CAL_AMP0, CAL_FREQ[1:0]]
|
| 91 |
-
0xFC, # CONFIG3 [E0] [PD_REFBUF(bar), 1, 1, BIAS_MEAS, BIASREF_INT, PD_BIAS(bar), BIAS_LOFF_SENS, BIAS_STAT] : Power-down reference buffer, no bias
|
| 92 |
-
0x00, # No lead-off
|
| 93 |
-
0x60, # CH1SET [60] [PD1, GAIN1[2:0], SRB2, MUX1[2:0]] set to measure BIAS signal
|
| 94 |
-
0x60, # CH2SET
|
| 95 |
-
0x60, # CH3SET
|
| 96 |
-
0x60, # CH4SET
|
| 97 |
-
0x60, # CH5SET
|
| 98 |
-
0x60, # CH6SET
|
| 99 |
-
0x60, # CH7SET
|
| 100 |
-
0x60, # CH8SET
|
| 101 |
-
0x00, # BIAS_SENSP 00
|
| 102 |
-
0x00, # BIAS_SENSN 00
|
| 103 |
-
0xFF, # LOFF_SENSP Lead-off on all positive pins?
|
| 104 |
-
0xFF, # LOFF_SENSN Lead-off on all negative pins?
|
| 105 |
-
0x00, # Normal lead-off
|
| 106 |
-
0x00, # Lead-off positive status (RO)
|
| 107 |
-
0x00, # Lead-off negative status (RO)
|
| 108 |
-
0x00, # All GPIOs as output ?
|
| 109 |
-
0x20, # Enable SRB1
|
| 110 |
-
0x00,
|
| 111 |
-
0x02,
|
| 112 |
-
]
|
| 113 |
-
|
| 114 |
-
EDF_PATH = Path.home() / 'workspace' / 'edf_recording'
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
def to_ads_frequency(frequency):
|
| 118 |
-
possible_datarates = [250, 500, 1000, 2000, 4000, 8000, 16000]
|
| 119 |
-
dr = 16000
|
| 120 |
-
for i in possible_datarates:
|
| 121 |
-
if i >= frequency:
|
| 122 |
-
dr = i
|
| 123 |
-
break
|
| 124 |
-
return dr
|
| 125 |
-
|
| 126 |
-
def mod_config(config, datarate, channel_modes):
|
| 127 |
-
|
| 128 |
-
# datarate:
|
| 129 |
-
|
| 130 |
-
possible_datarates = [(250, 0x06),
|
| 131 |
-
(500, 0x05),
|
| 132 |
-
(1000, 0x04),
|
| 133 |
-
(2000, 0x03),
|
| 134 |
-
(4000, 0x02),
|
| 135 |
-
(8000, 0x01),
|
| 136 |
-
(16000, 0x00)]
|
| 137 |
-
mod_dr = 0x00
|
| 138 |
-
for i, j in possible_datarates:
|
| 139 |
-
if i >= datarate:
|
| 140 |
-
mod_dr = j
|
| 141 |
-
break
|
| 142 |
-
|
| 143 |
-
new_cf1 = config[1] & 0xF8
|
| 144 |
-
new_cf1 = new_cf1 | mod_dr
|
| 145 |
-
config[1] = new_cf1
|
| 146 |
-
|
| 147 |
-
# bias:
|
| 148 |
-
assert len(channel_modes) == 7
|
| 149 |
-
config[13] = 0x00 # clear BIAS_SENSP
|
| 150 |
-
config[14] = 0x00 # clear BIAS_SENSN
|
| 151 |
-
for chan_i, chan_mode in enumerate(channel_modes):
|
| 152 |
-
n = 6 + chan_i
|
| 153 |
-
mod = config[n] & 0x78 # clear PDn and MUX[2:0]
|
| 154 |
-
if chan_mode == 'simple':
|
| 155 |
-
# If channel is activated, we send the channel's output to the BIAS mechanism
|
| 156 |
-
bit_i = 1 << chan_i + 1
|
| 157 |
-
config[13] = config[13] | bit_i
|
| 158 |
-
config[14] = config[14] | bit_i
|
| 159 |
-
elif chan_mode == 'disabled':
|
| 160 |
-
mod = mod | 0x81 # PDn = 1 and input shorted (001)
|
| 161 |
-
else:
|
| 162 |
-
assert False, f"Wrong key: {chan_mode}."
|
| 163 |
-
config[n] = mod
|
| 164 |
-
for n, c in enumerate(config): # print ADS1299 configuration registers
|
| 165 |
-
print(f"config[{n}]:\t{c:08b}\t({hex(c)})")
|
| 166 |
-
return config
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def filter_24(value):
|
| 170 |
-
return (value * 4.5) / (2**23 - 1) / 24.0 * 1e6 # 23 because 1 bit is lost for sign
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def filter_2scomplement_np(value):
|
| 174 |
-
return np.where((value & (1 << 23)) != 0, value - (1 << 24), value)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
def filter_np(value):
|
| 178 |
-
return filter_24(filter_2scomplement_np(value))
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
def shift_numpy(arr, num, fill_value=np.nan):
|
| 182 |
-
result = np.empty_like(arr)
|
| 183 |
-
if num > 0:
|
| 184 |
-
result[:num] = fill_value
|
| 185 |
-
result[num:] = arr[:-num]
|
| 186 |
-
elif num < 0:
|
| 187 |
-
result[num:] = fill_value
|
| 188 |
-
result[:num] = arr[-num:]
|
| 189 |
-
else:
|
| 190 |
-
result[:] = arr
|
| 191 |
-
return result
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
class FIR:
|
| 195 |
-
def __init__(self, nb_channels, coefficients, buffer=None):
|
| 196 |
-
|
| 197 |
-
self.coefficients = np.expand_dims(np.array(coefficients), axis=1)
|
| 198 |
-
self.taps = len(self.coefficients)
|
| 199 |
-
self.nb_channels = nb_channels
|
| 200 |
-
self.buffer = np.array(z) if buffer is not None else np.zeros((self.taps, self.nb_channels))
|
| 201 |
-
|
| 202 |
-
def filter(self, x):
|
| 203 |
-
self.buffer = shift_numpy(self.buffer, 1, x)
|
| 204 |
-
filtered = np.sum(self.buffer * self.coefficients, axis=0)
|
| 205 |
-
return filtered
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
class FilterPipeline:
|
| 209 |
-
def __init__(self,
|
| 210 |
-
nb_channels,
|
| 211 |
-
sampling_rate,
|
| 212 |
-
power_line_fq=60,
|
| 213 |
-
use_custom_fir=False,
|
| 214 |
-
custom_fir_order=20,
|
| 215 |
-
custom_fir_cutoff=30,
|
| 216 |
-
alpha_avg=0.1,
|
| 217 |
-
alpha_std=0.001,
|
| 218 |
-
epsilon=0.000001,
|
| 219 |
-
filter_args=[]):
|
| 220 |
-
if len(filter_args) > 0:
|
| 221 |
-
use_fir, use_notch, use_std = filter_args
|
| 222 |
-
else:
|
| 223 |
-
use_fir=True,
|
| 224 |
-
use_notch=True,
|
| 225 |
-
use_std=True
|
| 226 |
-
self.use_fir = use_fir
|
| 227 |
-
self.use_notch = use_notch
|
| 228 |
-
self.use_std = use_std
|
| 229 |
-
self.nb_channels = nb_channels
|
| 230 |
-
assert power_line_fq in [50, 60], f"The only supported power line frequencies are 50 Hz and 60 Hz"
|
| 231 |
-
if power_line_fq == 60:
|
| 232 |
-
self.notch_coeff1 = -0.12478308884588535
|
| 233 |
-
self.notch_coeff2 = 0.98729186796473023
|
| 234 |
-
self.notch_coeff3 = 0.99364593398236511
|
| 235 |
-
self.notch_coeff4 = -0.12478308884588535
|
| 236 |
-
self.notch_coeff5 = 0.99364593398236511
|
| 237 |
-
else:
|
| 238 |
-
self.notch_coeff1 = -0.61410695998423581
|
| 239 |
-
self.notch_coeff2 = 0.98729186796473023
|
| 240 |
-
self.notch_coeff3 = 0.99364593398236511
|
| 241 |
-
self.notch_coeff4 = -0.61410695998423581
|
| 242 |
-
self.notch_coeff5 = 0.99364593398236511
|
| 243 |
-
self.dfs = [np.zeros(self.nb_channels), np.zeros(self.nb_channels)]
|
| 244 |
-
|
| 245 |
-
self.moving_average = None
|
| 246 |
-
self.moving_variance = np.zeros(self.nb_channels)
|
| 247 |
-
self.ALPHA_AVG = alpha_avg
|
| 248 |
-
self.ALPHA_STD = alpha_std
|
| 249 |
-
self.EPSILON = epsilon
|
| 250 |
-
|
| 251 |
-
if use_custom_fir:
|
| 252 |
-
self.fir_coef = firwin(numtaps=custom_fir_order+1, cutoff=custom_fir_cutoff, fs=sampling_rate)
|
| 253 |
-
else:
|
| 254 |
-
self.fir_coef = [
|
| 255 |
-
0.001623780150148094927192721215192250384,
|
| 256 |
-
0.014988684599373741992978104065059596905,
|
| 257 |
-
0.021287595318265635502275046064823982306,
|
| 258 |
-
0.007349500393709578957568417933998716762,
|
| 259 |
-
-0.025127515717112181709014251396183681209,
|
| 260 |
-
-0.052210507359822452833064687638398027048,
|
| 261 |
-
-0.039273839505489904766477593511808663607,
|
| 262 |
-
0.033021568427940004020193498490698402748,
|
| 263 |
-
0.147606943281569008563636202779889572412,
|
| 264 |
-
0.254000252034505602516389899392379447818,
|
| 265 |
-
0.297330876398883392486283128164359368384,
|
| 266 |
-
0.254000252034505602516389899392379447818,
|
| 267 |
-
0.147606943281569008563636202779889572412,
|
| 268 |
-
0.033021568427940004020193498490698402748,
|
| 269 |
-
-0.039273839505489904766477593511808663607,
|
| 270 |
-
-0.052210507359822452833064687638398027048,
|
| 271 |
-
-0.025127515717112181709014251396183681209,
|
| 272 |
-
0.007349500393709578957568417933998716762,
|
| 273 |
-
0.021287595318265635502275046064823982306,
|
| 274 |
-
0.014988684599373741992978104065059596905,
|
| 275 |
-
0.001623780150148094927192721215192250384]
|
| 276 |
-
self.fir = FIR(self.nb_channels, self.fir_coef)
|
| 277 |
-
|
| 278 |
-
def filter(self, value):
|
| 279 |
-
"""
|
| 280 |
-
value: a numpy array of shape (data series, channels)
|
| 281 |
-
"""
|
| 282 |
-
for i, x in enumerate(value): # loop over the data series
|
| 283 |
-
# FIR:
|
| 284 |
-
if self.use_fir:
|
| 285 |
-
x = self.fir.filter(x)
|
| 286 |
-
# notch:
|
| 287 |
-
if self.use_notch:
|
| 288 |
-
denAccum = (x - self.notch_coeff1 * self.dfs[0]) - self.notch_coeff2 * self.dfs[1]
|
| 289 |
-
x = (self.notch_coeff3 * denAccum + self.notch_coeff4 * self.dfs[0]) + self.notch_coeff5 * self.dfs[1]
|
| 290 |
-
self.dfs[1] = self.dfs[0]
|
| 291 |
-
self.dfs[0] = denAccum
|
| 292 |
-
# standardization:
|
| 293 |
-
if self.use_std:
|
| 294 |
-
if self.moving_average is not None:
|
| 295 |
-
delta = x - self.moving_average
|
| 296 |
-
self.moving_average = self.moving_average + self.ALPHA_AVG * delta
|
| 297 |
-
self.moving_variance = (1 - self.ALPHA_STD) * (self.moving_variance + self.ALPHA_STD * delta**2)
|
| 298 |
-
moving_std = np.sqrt(self.moving_variance)
|
| 299 |
-
x = (x - self.moving_average) / (moving_std + self.EPSILON)
|
| 300 |
-
else:
|
| 301 |
-
self.moving_average = x
|
| 302 |
-
value[i] = x
|
| 303 |
-
return value
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
class LiveDisplay():
|
| 307 |
-
def __init__(self, channel_names, window_len=100):
|
| 308 |
-
self.datapoint_dim = len(channel_names)
|
| 309 |
-
self.history = []
|
| 310 |
-
self.pp = ProgressPlot(plot_names=channel_names, max_window_len=window_len)
|
| 311 |
-
self.matplotlib = False
|
| 312 |
-
|
| 313 |
-
def add_datapoints(self, datapoints):
|
| 314 |
-
"""
|
| 315 |
-
Adds 8 lists of datapoints to the plot
|
| 316 |
-
|
| 317 |
-
Args:
|
| 318 |
-
datapoints: list of 8 lists of floats (or list of 8 floats)
|
| 319 |
-
"""
|
| 320 |
-
if self.matplotlib:
|
| 321 |
-
import matplotlib.pyplot as plt
|
| 322 |
-
disp_list = []
|
| 323 |
-
for datapoint in datapoints:
|
| 324 |
-
d = [[elt] for elt in datapoint]
|
| 325 |
-
disp_list.append(d)
|
| 326 |
-
|
| 327 |
-
if self.matplotlib:
|
| 328 |
-
self.history += d[1]
|
| 329 |
-
|
| 330 |
-
if not self.matplotlib:
|
| 331 |
-
self.pp.update_with_datapoints(disp_list)
|
| 332 |
-
elif len(self.history) == 1000:
|
| 333 |
-
plt.plot(self.history)
|
| 334 |
-
plt.show()
|
| 335 |
-
self.history = []
|
| 336 |
-
|
| 337 |
-
def add_datapoint(self, datapoint):
|
| 338 |
-
disp_list = [[elt] for elt in datapoint]
|
| 339 |
-
self.pp.update(disp_list)
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
def _capture_process(p_data_o, p_msg_io, duration, frequency, python_clock, time_msg_in, channel_states):
|
| 343 |
"""
|
| 344 |
Args:
|
| 345 |
p_data_o: multiprocessing.Pipe: captured datapoints are put here
|
|
@@ -434,69 +116,8 @@ def _capture_process(p_data_o, p_msg_io, duration, frequency, python_clock, time
|
|
| 434 |
p_msg_io.send('STOP')
|
| 435 |
p_msg_io.close()
|
| 436 |
p_data_o.close()
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
class DummyAlsaMixer:
|
| 440 |
-
def __init__(self):
|
| 441 |
-
self.volume = 50
|
| 442 |
|
| 443 |
-
def getvolume(self):
|
| 444 |
-
return [self.volume]
|
| 445 |
-
|
| 446 |
-
def setvolume(self, volume):
|
| 447 |
-
self.volume = volume
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
class UpStateDelayer:
|
| 451 |
-
def __init__(self, sample_freq, spindle_freq, peak):
|
| 452 |
-
'''
|
| 453 |
-
args:
|
| 454 |
-
buffer_size: int -> Size of desired buffer in length
|
| 455 |
-
sample_freq: int -> Sampling frequency of signal in Hz
|
| 456 |
-
'''
|
| 457 |
-
# Get number of timesteps for a whole spindle
|
| 458 |
-
self.spindle_timesteps = (1/spindle_freq) * sample_freq # s *
|
| 459 |
-
self.sample_freq = sample_freq
|
| 460 |
-
self.buffer_size = 1.5 * self.spindle_timesteps
|
| 461 |
-
self.peak = peak
|
| 462 |
-
self.buffer = []
|
| 463 |
|
| 464 |
-
def add_point(self, point):
|
| 465 |
-
'''
|
| 466 |
-
Adds a point to the buffer to be able to keep track of peaks
|
| 467 |
-
'''
|
| 468 |
-
self.buffer.append(point)
|
| 469 |
-
if len(self.buffer) > self.buffer_size:
|
| 470 |
-
self.buffer.pop(0)
|
| 471 |
-
|
| 472 |
-
def stimulate(self):
|
| 473 |
-
# Calculate how far away is last peak
|
| 474 |
-
last_peak = -1
|
| 475 |
-
count = 0
|
| 476 |
-
for idx, point in reversed(list(enumerate(self.buffer))):
|
| 477 |
-
if self.peak:
|
| 478 |
-
try:
|
| 479 |
-
sup = point >= self.buffer[idx+1]
|
| 480 |
-
except IndexError:
|
| 481 |
-
sup = False
|
| 482 |
-
try:
|
| 483 |
-
inf = point >= self.buffer[idx-1]
|
| 484 |
-
except IndexError:
|
| 485 |
-
inf = False
|
| 486 |
-
else:
|
| 487 |
-
try:
|
| 488 |
-
sup = point <= self.buffer[idx+1]
|
| 489 |
-
except IndexError:
|
| 490 |
-
sup = False
|
| 491 |
-
try:
|
| 492 |
-
inf = point <= self.buffer[idx-1]
|
| 493 |
-
except IndexError:
|
| 494 |
-
inf = False
|
| 495 |
-
if sup and inf:
|
| 496 |
-
last_peak = count
|
| 497 |
-
return self.spindle_timesteps - last_peak
|
| 498 |
-
count += 1
|
| 499 |
-
return -1
|
| 500 |
|
| 501 |
class Capture:
|
| 502 |
def __init__(self, detector_cls=None, stimulator_cls=None):
|
|
@@ -521,13 +142,10 @@ class Capture:
|
|
| 521 |
self.threshold = 0.5
|
| 522 |
self.lsl = False
|
| 523 |
self.display = False
|
|
|
|
| 524 |
self.python_clock = True
|
| 525 |
self.edf_writer = None
|
| 526 |
self.edf_buffer = []
|
| 527 |
-
self.nb_signals = 8
|
| 528 |
-
self.samples_per_datarecord_array = self.frequency
|
| 529 |
-
self.physical_max = 5
|
| 530 |
-
self.physical_min = -5
|
| 531 |
self.signal_labels = ['Common Mode', 'ch2', 'ch3', 'ch4', 'ch5', 'ch6', 'ch7', 'ch8']
|
| 532 |
self._lock_msg_out = Lock()
|
| 533 |
self._msg_out = None
|
|
@@ -678,7 +296,7 @@ class Capture:
|
|
| 678 |
)
|
| 679 |
|
| 680 |
self.b_clock = widgets.ToggleButtons(
|
| 681 |
-
options=['
|
| 682 |
description='Clock:',
|
| 683 |
disabled=False,
|
| 684 |
button_style='', # 'success', 'info', 'warning', 'danger' or ''
|
|
@@ -694,6 +312,15 @@ class Capture:
|
|
| 694 |
tooltips=['North America 60 Hz',
|
| 695 |
'Europe 50 Hz'],
|
| 696 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
|
| 698 |
self.b_custom_fir = widgets.ToggleButtons(
|
| 699 |
options=['Default', 'Custom'],
|
|
@@ -890,6 +517,7 @@ class Capture:
|
|
| 890 |
self.b_spindle_mode.observe(self.on_b_spindle_mode, 'value')
|
| 891 |
self.b_spindle_freq.observe(self.on_b_spindle_freq, 'value')
|
| 892 |
self.b_power_line.observe(self.on_b_power_line, 'value')
|
|
|
|
| 893 |
self.b_custom_fir.observe(self.on_b_custom_fir, 'value')
|
| 894 |
self.b_custom_fir_order.observe(self.on_b_custom_fir_order, 'value')
|
| 895 |
self.b_custom_fir_cutoff.observe(self.on_b_custom_fir_cutoff, 'value')
|
|
@@ -912,6 +540,7 @@ class Capture:
|
|
| 912 |
self.b_frequency,
|
| 913 |
self.b_duration,
|
| 914 |
self.b_filename,
|
|
|
|
| 915 |
self.b_power_line,
|
| 916 |
self.b_clock,
|
| 917 |
widgets.HBox([self.b_filter, self.b_detect, self.b_stimulate, self.b_record, self.b_lsl, self.b_display]),
|
|
@@ -941,6 +570,7 @@ class Capture:
|
|
| 941 |
self.b_radio_ch7.disabled = False
|
| 942 |
self.b_radio_ch8.disabled = False
|
| 943 |
self.b_power_line.disabled = False
|
|
|
|
| 944 |
self.b_channel_detect.disabled = False
|
| 945 |
self.b_spindle_freq.disabled = False
|
| 946 |
self.b_spindle_mode.disabled = False
|
|
@@ -981,6 +611,7 @@ class Capture:
|
|
| 981 |
self.b_channel_detect.disabled = True
|
| 982 |
self.b_spindle_freq.disabled = True
|
| 983 |
self.b_spindle_mode.disabled = True
|
|
|
|
| 984 |
self.b_power_line.disabled = True
|
| 985 |
self.b_polyak_mean.disabled = True
|
| 986 |
self.b_polyak_std.disabled = True
|
|
@@ -1067,8 +698,6 @@ class Capture:
|
|
| 1067 |
self._t_capture = None
|
| 1068 |
self.enable_buttons()
|
| 1069 |
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
def on_b_custom_fir(self, value):
|
| 1073 |
val = value['new']
|
| 1074 |
if val == 'Default':
|
|
@@ -1083,13 +712,20 @@ class Capture:
|
|
| 1083 |
self.python_clock = True
|
| 1084 |
elif val == 'ADS':
|
| 1085 |
self.python_clock = False
|
| 1086 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1087 |
def on_b_power_line(self, value):
|
| 1088 |
val = value['new']
|
| 1089 |
if val == '60 Hz':
|
| 1090 |
self.power_line = 60
|
| 1091 |
elif val == '50 Hz':
|
| 1092 |
-
self.
|
| 1093 |
|
| 1094 |
def on_b_frequency(self, value):
|
| 1095 |
val = value['new']
|
|
@@ -1243,42 +879,6 @@ class Capture:
|
|
| 1243 |
elif val == 'Paused':
|
| 1244 |
with self._pause_detect_lock:
|
| 1245 |
self._pause_detect = True
|
| 1246 |
-
|
| 1247 |
-
def open_recording_file(self):
|
| 1248 |
-
nb_signals = self.nb_signals
|
| 1249 |
-
samples_per_datarecord_array = self.samples_per_datarecord_array
|
| 1250 |
-
physical_max = self.physical_max
|
| 1251 |
-
physical_min = self.physical_min
|
| 1252 |
-
signal_labels = self.signal_labels
|
| 1253 |
-
|
| 1254 |
-
print(f"Will store edf recording in {self.filename}")
|
| 1255 |
-
|
| 1256 |
-
self.edf_writer = EDFwriter(p_path=str(self.filename),
|
| 1257 |
-
f_file_type=EDFwriter.EDFLIB_FILETYPE_EDFPLUS,
|
| 1258 |
-
number_of_signals=nb_signals)
|
| 1259 |
-
|
| 1260 |
-
for signal in range(nb_signals):
|
| 1261 |
-
assert self.edf_writer.setSampleFrequency(signal, samples_per_datarecord_array) == 0
|
| 1262 |
-
assert self.edf_writer.setPhysicalMaximum(signal, physical_max) == 0
|
| 1263 |
-
assert self.edf_writer.setPhysicalMinimum(signal, physical_min) == 0
|
| 1264 |
-
assert self.edf_writer.setDigitalMaximum(signal, 32767) == 0
|
| 1265 |
-
assert self.edf_writer.setDigitalMinimum(signal, -32768) == 0
|
| 1266 |
-
assert self.edf_writer.setSignalLabel(signal, signal_labels[signal]) == 0
|
| 1267 |
-
assert self.edf_writer.setPhysicalDimension(signal, 'V') == 0
|
| 1268 |
-
|
| 1269 |
-
def close_recording_file(self):
|
| 1270 |
-
assert self.edf_writer.close() == 0
|
| 1271 |
-
|
| 1272 |
-
def add_recording_data(self, data):
|
| 1273 |
-
self.edf_buffer += data
|
| 1274 |
-
if len(self.edf_buffer) >= self.samples_per_datarecord_array:
|
| 1275 |
-
datarecord_array = self.edf_buffer[:self.samples_per_datarecord_array]
|
| 1276 |
-
self.edf_buffer = self.edf_buffer[self.samples_per_datarecord_array:]
|
| 1277 |
-
datarecord_array = np.array(datarecord_array).transpose()
|
| 1278 |
-
assert len(datarecord_array) == self.nb_signals, f"len(data)={len(data)}!={self.nb_signals}"
|
| 1279 |
-
for d in datarecord_array:
|
| 1280 |
-
assert len(d) == self.samples_per_datarecord_array, f"{len(d)}!={self.samples_per_datarecord_array}"
|
| 1281 |
-
assert self.edf_writer.writeSamples(d) == 0
|
| 1282 |
|
| 1283 |
def start_capture(self,
|
| 1284 |
filter,
|
|
@@ -1292,15 +892,17 @@ class Capture:
|
|
| 1292 |
viz,
|
| 1293 |
width,
|
| 1294 |
python_clock):
|
| 1295 |
-
if self.__capture_on:
|
| 1296 |
-
warnings.warn("Capture is already ongoing, ignoring command.")
|
| 1297 |
-
return
|
| 1298 |
-
else:
|
| 1299 |
-
self.__capture_on = True
|
| 1300 |
-
p_msg_io, p_msg_io_2 = mp.Pipe()
|
| 1301 |
-
p_data_i, p_data_o = mp.Pipe(duplex=False)
|
| 1302 |
-
SAMPLE_TIME = 1 / self.frequency
|
| 1303 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1304 |
if filter:
|
| 1305 |
fp = FilterPipeline(nb_channels=8,
|
| 1306 |
sampling_rate=self.frequency,
|
|
@@ -1312,28 +914,37 @@ class Capture:
|
|
| 1312 |
alpha_std=self.polyak_std,
|
| 1313 |
epsilon=self.epsilon,
|
| 1314 |
filter_args=filter_args)
|
| 1315 |
-
|
|
|
|
| 1316 |
detector = detector_cls(threshold, channel=channel) if detector_cls is not None else None
|
| 1317 |
stimulator = stimulator_cls() if stimulator_cls is not None else None
|
| 1318 |
|
| 1319 |
-
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
|
| 1323 |
-
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1330 |
|
|
|
|
| 1331 |
if viz:
|
| 1332 |
live_disp = LiveDisplay(channel_names = self.signal_labels, window_len=width)
|
| 1333 |
|
|
|
|
| 1334 |
if record:
|
| 1335 |
-
self.
|
| 1336 |
|
|
|
|
| 1337 |
if lsl:
|
| 1338 |
from pylsl import StreamInfo, StreamOutlet
|
| 1339 |
lsl_info = StreamInfo(name='Portiloop Filtered',
|
|
@@ -1353,53 +964,71 @@ class Capture:
|
|
| 1353 |
|
| 1354 |
buffer = []
|
| 1355 |
|
|
|
|
| 1356 |
if not self.spindle_detection_mode == 'Fast' and stimulator is not None:
|
| 1357 |
-
stimulation_delayer = UpStateDelayer(self.frequency, self.spindle_freq, self.spindle_detection_mode == 'Peak')
|
| 1358 |
stimulator.add_delayer(stimulation_delayer)
|
| 1359 |
else:
|
| 1360 |
stimulation_delayer = None
|
| 1361 |
|
|
|
|
| 1362 |
while True:
|
| 1363 |
-
|
| 1364 |
-
if
|
| 1365 |
-
|
| 1366 |
-
self._msg_out
|
| 1367 |
-
|
| 1368 |
-
|
| 1369 |
-
|
| 1370 |
-
|
| 1371 |
-
|
| 1372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1373 |
|
| 1374 |
-
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
else:
|
| 1379 |
-
continue
|
| 1380 |
-
|
| 1381 |
-
n_array = np.array([point])
|
| 1382 |
-
n_array_raw = filter_np(n_array)
|
| 1383 |
|
|
|
|
| 1384 |
if filter:
|
| 1385 |
n_array = fp.filter(deepcopy(n_array_raw))
|
| 1386 |
else:
|
| 1387 |
-
n_array = n_array_raw
|
| 1388 |
-
|
|
|
|
| 1389 |
filtered_point = n_array.tolist()
|
| 1390 |
|
|
|
|
| 1391 |
if lsl:
|
| 1392 |
raw_point = n_array_raw.tolist()
|
| 1393 |
lsl_outlet_raw.push_sample(raw_point[-1])
|
| 1394 |
lsl_outlet.push_sample(filtered_point[-1])
|
| 1395 |
-
|
|
|
|
| 1396 |
if stimulation_delayer is not None:
|
| 1397 |
-
stimulation_delayer.
|
| 1398 |
|
|
|
|
| 1399 |
with self._pause_detect_lock:
|
| 1400 |
pause = self._pause_detect
|
|
|
|
|
|
|
| 1401 |
if detector is not None and not pause:
|
|
|
|
| 1402 |
detection_signal = detector.detect(filtered_point)
|
|
|
|
|
|
|
| 1403 |
if stimulator is not None:
|
| 1404 |
stimulator.stimulate(detection_signal)
|
| 1405 |
with self._test_stimulus_lock:
|
|
@@ -1407,35 +1036,36 @@ class Capture:
|
|
| 1407 |
self._test_stimulus = False
|
| 1408 |
if test_stimulus:
|
| 1409 |
stimulator.test_stimulus()
|
|
|
|
|
|
|
|
|
|
| 1410 |
|
|
|
|
| 1411 |
buffer += filtered_point
|
| 1412 |
if len(buffer) >= 50:
|
| 1413 |
-
|
| 1414 |
if viz:
|
| 1415 |
live_disp.add_datapoints(buffer)
|
| 1416 |
-
|
| 1417 |
if record:
|
| 1418 |
-
|
| 1419 |
-
|
| 1420 |
buffer = []
|
| 1421 |
|
| 1422 |
-
|
| 1423 |
-
|
| 1424 |
-
|
| 1425 |
-
|
| 1426 |
-
|
| 1427 |
-
|
| 1428 |
-
|
| 1429 |
-
|
|
|
|
| 1430 |
|
| 1431 |
-
|
| 1432 |
-
|
|
|
|
|
|
|
| 1433 |
|
| 1434 |
if record:
|
| 1435 |
-
|
| 1436 |
-
|
| 1437 |
-
self._p_capture.join()
|
| 1438 |
-
self.__capture_on = False
|
| 1439 |
|
| 1440 |
|
| 1441 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
|
|
|
|
| 2 |
|
| 3 |
from time import sleep
|
| 4 |
import time
|
| 5 |
import numpy as np
|
|
|
|
| 6 |
from copy import deepcopy
|
| 7 |
+
from datetime import datetime
|
|
|
|
| 8 |
import multiprocessing as mp
|
| 9 |
import warnings
|
|
|
|
| 10 |
from threading import Thread, Lock
|
| 11 |
import alsaaudio
|
| 12 |
|
| 13 |
+
from portiloop.src.stimulation import UpStateDelayer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
from portiloop.src.hardware.frontend import Frontend
|
| 16 |
+
from portiloop.src.hardware.leds import LEDs, Color
|
| 17 |
+
from portiloop.src.processing import FilterPipeline, int_to_float
|
| 18 |
+
from portiloop.src.config import mod_config, LEADOFF_CONFIG, FRONTEND_CONFIG, to_ads_frequency
|
| 19 |
+
from portiloop.src.utils import FileReader, LiveDisplay, DummyAlsaMixer, EDFRecorder, EDF_PATH
|
| 20 |
from IPython.display import clear_output, display
|
| 21 |
import ipywidgets as widgets
|
| 22 |
|
| 23 |
|
| 24 |
+
def capture_process(p_data_o, p_msg_io, duration, frequency, python_clock, time_msg_in, channel_states):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
"""
|
| 26 |
Args:
|
| 27 |
p_data_o: multiprocessing.Pipe: captured datapoints are put here
|
|
|
|
| 116 |
p_msg_io.send('STOP')
|
| 117 |
p_msg_io.close()
|
| 118 |
p_data_o.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
class Capture:
|
| 123 |
def __init__(self, detector_cls=None, stimulator_cls=None):
|
|
|
|
| 142 |
self.threshold = 0.5
|
| 143 |
self.lsl = False
|
| 144 |
self.display = False
|
| 145 |
+
self.signal_input = "ADS"
|
| 146 |
self.python_clock = True
|
| 147 |
self.edf_writer = None
|
| 148 |
self.edf_buffer = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
self.signal_labels = ['Common Mode', 'ch2', 'ch3', 'ch4', 'ch5', 'ch6', 'ch7', 'ch8']
|
| 150 |
self._lock_msg_out = Lock()
|
| 151 |
self._msg_out = None
|
|
|
|
| 296 |
)
|
| 297 |
|
| 298 |
self.b_clock = widgets.ToggleButtons(
|
| 299 |
+
options=['ADS', 'Coral'],
|
| 300 |
description='Clock:',
|
| 301 |
disabled=False,
|
| 302 |
button_style='', # 'success', 'info', 'warning', 'danger' or ''
|
|
|
|
| 312 |
tooltips=['North America 60 Hz',
|
| 313 |
'Europe 50 Hz'],
|
| 314 |
)
|
| 315 |
+
|
| 316 |
+
self.b_signal_input = widgets.ToggleButtons(
|
| 317 |
+
options=['ADS', 'File'],
|
| 318 |
+
description='Signal Input:',
|
| 319 |
+
disabled=False,
|
| 320 |
+
button_style='', # 'success', 'info', 'warning', 'danger' or ''
|
| 321 |
+
tooltips=['Read data from ADS.',
|
| 322 |
+
'Read data from file.'],
|
| 323 |
+
)
|
| 324 |
|
| 325 |
self.b_custom_fir = widgets.ToggleButtons(
|
| 326 |
options=['Default', 'Custom'],
|
|
|
|
| 517 |
self.b_spindle_mode.observe(self.on_b_spindle_mode, 'value')
|
| 518 |
self.b_spindle_freq.observe(self.on_b_spindle_freq, 'value')
|
| 519 |
self.b_power_line.observe(self.on_b_power_line, 'value')
|
| 520 |
+
self.b_signal_input.observe(self.on_b_power_line, 'value')
|
| 521 |
self.b_custom_fir.observe(self.on_b_custom_fir, 'value')
|
| 522 |
self.b_custom_fir_order.observe(self.on_b_custom_fir_order, 'value')
|
| 523 |
self.b_custom_fir_cutoff.observe(self.on_b_custom_fir_cutoff, 'value')
|
|
|
|
| 540 |
self.b_frequency,
|
| 541 |
self.b_duration,
|
| 542 |
self.b_filename,
|
| 543 |
+
self.b_signal_input,
|
| 544 |
self.b_power_line,
|
| 545 |
self.b_clock,
|
| 546 |
widgets.HBox([self.b_filter, self.b_detect, self.b_stimulate, self.b_record, self.b_lsl, self.b_display]),
|
|
|
|
| 570 |
self.b_radio_ch7.disabled = False
|
| 571 |
self.b_radio_ch8.disabled = False
|
| 572 |
self.b_power_line.disabled = False
|
| 573 |
+
self.b_signal_input.disabled = False
|
| 574 |
self.b_channel_detect.disabled = False
|
| 575 |
self.b_spindle_freq.disabled = False
|
| 576 |
self.b_spindle_mode.disabled = False
|
|
|
|
| 611 |
self.b_channel_detect.disabled = True
|
| 612 |
self.b_spindle_freq.disabled = True
|
| 613 |
self.b_spindle_mode.disabled = True
|
| 614 |
+
self.b_signal_input.disabled = True
|
| 615 |
self.b_power_line.disabled = True
|
| 616 |
self.b_polyak_mean.disabled = True
|
| 617 |
self.b_polyak_std.disabled = True
|
|
|
|
| 698 |
self._t_capture = None
|
| 699 |
self.enable_buttons()
|
| 700 |
|
|
|
|
|
|
|
| 701 |
def on_b_custom_fir(self, value):
|
| 702 |
val = value['new']
|
| 703 |
if val == 'Default':
|
|
|
|
| 712 |
self.python_clock = True
|
| 713 |
elif val == 'ADS':
|
| 714 |
self.python_clock = False
|
| 715 |
+
|
| 716 |
+
def on_b_signal_input(self, value):
|
| 717 |
+
val = value['new']
|
| 718 |
+
if val == "ADS":
|
| 719 |
+
self.signal_input = "ADS"
|
| 720 |
+
elif val == "File":
|
| 721 |
+
self.signal_input = "File"
|
| 722 |
+
|
| 723 |
def on_b_power_line(self, value):
|
| 724 |
val = value['new']
|
| 725 |
if val == '60 Hz':
|
| 726 |
self.power_line = 60
|
| 727 |
elif val == '50 Hz':
|
| 728 |
+
self.power_line = 50
|
| 729 |
|
| 730 |
def on_b_frequency(self, value):
|
| 731 |
val = value['new']
|
|
|
|
| 879 |
elif val == 'Paused':
|
| 880 |
with self._pause_detect_lock:
|
| 881 |
self._pause_detect = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 882 |
|
| 883 |
def start_capture(self,
|
| 884 |
filter,
|
|
|
|
| 892 |
viz,
|
| 893 |
width,
|
| 894 |
python_clock):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
|
| 896 |
+
if self.signal_input == "ADS":
|
| 897 |
+
if self.__capture_on:
|
| 898 |
+
warnings.warn("Capture is already ongoing, ignoring command.")
|
| 899 |
+
return
|
| 900 |
+
else:
|
| 901 |
+
self.__capture_on = True
|
| 902 |
+
p_msg_io, p_msg_io_2 = mp.Pipe()
|
| 903 |
+
p_data_i, p_data_o = mp.Pipe(duplex=False)
|
| 904 |
+
|
| 905 |
+
# Initialize filtering pipeline
|
| 906 |
if filter:
|
| 907 |
fp = FilterPipeline(nb_channels=8,
|
| 908 |
sampling_rate=self.frequency,
|
|
|
|
| 914 |
alpha_std=self.polyak_std,
|
| 915 |
epsilon=self.epsilon,
|
| 916 |
filter_args=filter_args)
|
| 917 |
+
|
| 918 |
+
# Initialize detector and stimulator
|
| 919 |
detector = detector_cls(threshold, channel=channel) if detector_cls is not None else None
|
| 920 |
stimulator = stimulator_cls() if stimulator_cls is not None else None
|
| 921 |
|
| 922 |
+
# Launch the capture process
|
| 923 |
+
if self.signal_input == "ADS":
|
| 924 |
+
self._p_capture = mp.Process(target=capture_process,
|
| 925 |
+
args=(p_data_o,
|
| 926 |
+
p_msg_io_2,
|
| 927 |
+
self.duration,
|
| 928 |
+
self.frequency,
|
| 929 |
+
python_clock,
|
| 930 |
+
1.0,
|
| 931 |
+
self.channel_states)
|
| 932 |
+
)
|
| 933 |
+
self._p_capture.start()
|
| 934 |
+
print(f"PID capture: {self._p_capture.pid}")
|
| 935 |
+
else:
|
| 936 |
+
filename = "INSERT FILENAME" # TODO
|
| 937 |
+
file_reader = FileReader(filename)
|
| 938 |
|
| 939 |
+
# Initialize display if requested
|
| 940 |
if viz:
|
| 941 |
live_disp = LiveDisplay(channel_names = self.signal_labels, window_len=width)
|
| 942 |
|
| 943 |
+
# Initialize recording if requested
|
| 944 |
if record:
|
| 945 |
+
recorder = EDFRecorder(self.signal_label)
|
| 946 |
|
| 947 |
+
# Initialize LSL to stream if requested
|
| 948 |
if lsl:
|
| 949 |
from pylsl import StreamInfo, StreamOutlet
|
| 950 |
lsl_info = StreamInfo(name='Portiloop Filtered',
|
|
|
|
| 964 |
|
| 965 |
buffer = []
|
| 966 |
|
| 967 |
+
# Initialize stimulation delayer if requested
|
| 968 |
if not self.spindle_detection_mode == 'Fast' and stimulator is not None:
|
| 969 |
+
stimulation_delayer = UpStateDelayer(self.frequency, self.spindle_freq, self.spindle_detection_mode == 'Peak', time_to_buffer=0.1)
|
| 970 |
stimulator.add_delayer(stimulation_delayer)
|
| 971 |
else:
|
| 972 |
stimulation_delayer = None
|
| 973 |
|
| 974 |
+
# Main capture loop
|
| 975 |
while True:
|
| 976 |
+
if self.signal_input == "ADS":
|
| 977 |
+
# Send message in communication pipe if we have one
|
| 978 |
+
with self._lock_msg_out:
|
| 979 |
+
if self._msg_out is not None:
|
| 980 |
+
p_msg_io.send(self._msg_out)
|
| 981 |
+
self._msg_out = None
|
| 982 |
+
|
| 983 |
+
# Check if we have received a message in communication pipe
|
| 984 |
+
if p_msg_io.poll():
|
| 985 |
+
mess = p_msg_io.recv()
|
| 986 |
+
if mess == 'STOP':
|
| 987 |
+
break
|
| 988 |
+
elif mess[0] == 'PRT':
|
| 989 |
+
print(mess[1])
|
| 990 |
+
|
| 991 |
+
# Retrieve all data points from data pipe p_data
|
| 992 |
+
point = None
|
| 993 |
+
if p_data_i.poll(timeout=(1 / self.frequency)):
|
| 994 |
+
point = p_data_i.recv()
|
| 995 |
+
else:
|
| 996 |
+
continue
|
| 997 |
|
| 998 |
+
# Convert point from int to corresponding value in microvolts
|
| 999 |
+
n_array_raw = int_to_float(np.array([point]))
|
| 1000 |
+
elif self.signal_input == "File":
|
| 1001 |
+
n_array_raw, gt_stimulation = file_reader.get_point()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1002 |
|
| 1003 |
+
# Go through filtering pipeline
|
| 1004 |
if filter:
|
| 1005 |
n_array = fp.filter(deepcopy(n_array_raw))
|
| 1006 |
else:
|
| 1007 |
+
n_array = deepcopy(n_array_raw)
|
| 1008 |
+
|
| 1009 |
+
# Contains the filtered point (if filtering is off, contains a copy of the raw point)
|
| 1010 |
filtered_point = n_array.tolist()
|
| 1011 |
|
| 1012 |
+
# Send both raw and filtered points over LSL
|
| 1013 |
if lsl:
|
| 1014 |
raw_point = n_array_raw.tolist()
|
| 1015 |
lsl_outlet_raw.push_sample(raw_point[-1])
|
| 1016 |
lsl_outlet.push_sample(filtered_point[-1])
|
| 1017 |
+
|
| 1018 |
+
# Adds point to buffer for delayed stimulation
|
| 1019 |
if stimulation_delayer is not None:
|
| 1020 |
+
stimulation_delayer.step(filtered_point[0][channel-1])
|
| 1021 |
|
| 1022 |
+
# Check if detection is on or off
|
| 1023 |
with self._pause_detect_lock:
|
| 1024 |
pause = self._pause_detect
|
| 1025 |
+
|
| 1026 |
+
# If detection is on
|
| 1027 |
if detector is not None and not pause:
|
| 1028 |
+
# Detect using the latest point
|
| 1029 |
detection_signal = detector.detect(filtered_point)
|
| 1030 |
+
|
| 1031 |
+
# Stimulate
|
| 1032 |
if stimulator is not None:
|
| 1033 |
stimulator.stimulate(detection_signal)
|
| 1034 |
with self._test_stimulus_lock:
|
|
|
|
| 1036 |
self._test_stimulus = False
|
| 1037 |
if test_stimulus:
|
| 1038 |
stimulator.test_stimulus()
|
| 1039 |
+
|
| 1040 |
+
if self.signal_input == "File" and gt_stimulation:
|
| 1041 |
+
stimulator.send_stimulation("GROUND_TRUTH_STIM", False)
|
| 1042 |
|
| 1043 |
+
# Add point to the buffer to send to viz and recorder
|
| 1044 |
buffer += filtered_point
|
| 1045 |
if len(buffer) >= 50:
|
|
|
|
| 1046 |
if viz:
|
| 1047 |
live_disp.add_datapoints(buffer)
|
|
|
|
| 1048 |
if record:
|
| 1049 |
+
recorder.add_recording_data(buffer)
|
|
|
|
| 1050 |
buffer = []
|
| 1051 |
|
| 1052 |
+
if self.signal_input == "ADS":
|
| 1053 |
+
# Empty pipes
|
| 1054 |
+
while True:
|
| 1055 |
+
if p_data_i.poll():
|
| 1056 |
+
_ = p_data_i.recv()
|
| 1057 |
+
elif p_msg_io.poll():
|
| 1058 |
+
_ = p_msg_io.recv()
|
| 1059 |
+
else:
|
| 1060 |
+
break
|
| 1061 |
|
| 1062 |
+
p_data_i.close()
|
| 1063 |
+
p_msg_io.close()
|
| 1064 |
+
self._p_capture.join()
|
| 1065 |
+
self.__capture_on = False
|
| 1066 |
|
| 1067 |
if record:
|
| 1068 |
+
recorder.close_recording_file()
|
|
|
|
|
|
|
|
|
|
| 1069 |
|
| 1070 |
|
| 1071 |
if __name__ == "__main__":
|
portiloop/src/config.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
DEFAULT_FRONTEND_CONFIG = [
|
| 2 |
+
# nomenclature: name [default setting] [bits 7-0] : description
|
| 3 |
+
# Read only ID:
|
| 4 |
+
0x3E, # ID [xx] [REV_ID[2:0], 1, DEV_ID[1:0], NU_CH[1:0]] : (RO)
|
| 5 |
+
# Global Settings Across Channels:
|
| 6 |
+
0x96, # CONFIG1 [96] [1, DAISY_EN(bar), CLK_EN, 1, 0, DR[2:0]] : Datarate = 250 SPS
|
| 7 |
+
0xC0, # CONFIG2 [C0] [1, 1, 0, INT_CAL, 0, CAL_AMP0, CAL_FREQ[1:0]] : No tests
|
| 8 |
+
0x60, # CONFIG3 [60] [PD_REFBUF(bar), 1, 1, BIAS_MEAS, BIASREF_INT, PD_BIAS(bar), BIAS_LOFF_SENS, BIAS_STAT] : Power-down reference buffer, no bias
|
| 9 |
+
0x00, # LOFF [00] [COMP_TH[2:0], 0, ILEAD_OFF[1:0], FLEAD_OFF[1:0]] : No lead-off
|
| 10 |
+
# Channel-Specific Settings:
|
| 11 |
+
0x61, # CH1SET [61] [PD1, GAIN1[2:0], SRB2, MUX1[2:0]] : Channel 1 active, 24 gain, no SRB2 & input shorted
|
| 12 |
+
0x61, # CH2SET [61] [PD2, GAIN2[2:0], SRB2, MUX2[2:0]] : Channel 2 active, 24 gain, no SRB2 & input shorted
|
| 13 |
+
0x61, # CH3SET [61] [PD3, GAIN3[2:0], SRB2, MUX3[2:0]] : Channel 3 active, 24 gain, no SRB2 & input shorted
|
| 14 |
+
0x61, # CH4SET [61] [PD4, GAIN4[2:0], SRB2, MUX4[2:0]] : Channel 4 active, 24 gain, no SRB2 & input shorted
|
| 15 |
+
0x61, # CH5SET [61] [PD5, GAIN5[2:0], SRB2, MUX5[2:0]] : Channel 5 active, 24 gain, no SRB2 & input shorted
|
| 16 |
+
0x61, # CH6SET [61] [PD6, GAIN6[2:0], SRB2, MUX6[2:0]] : Channel 6 active, 24 gain, no SRB2 & input shorted
|
| 17 |
+
0x61, # CH7SET [61] [PD7, GAIN7[2:0], SRB2, MUX7[2:0]] : Channel 7 active, 24 gain, no SRB2 & input shorted
|
| 18 |
+
0x61, # CH8SET [61] [PD8, GAIN8[2:0], SRB2, MUX8[2:0]] : Channel 8 active, 24 gain, no SRB2 & input shorted
|
| 19 |
+
0x00, # BIAS_SENSP [00] [BIASP8, BIASP7, BIASP6, BIASP5, BIASP4, BIASP3, BIASP2, BIASP1] : No bias
|
| 20 |
+
0x00, # BIAS_SENSN [00] [BIASN8, BIASN7, BIASN6, BIASN5, BIASN4, BIASN3, BIASN2, BIASN1] No bias
|
| 21 |
+
0x00, # LOFF_SENSP [00] [LOFFP8, LOFFP7, LOFFP6, LOFFP5, LOFFP4, LOFFP3, LOFFP2, LOFFP1] : No lead-off
|
| 22 |
+
0x00, # LOFF_SENSN [00] [LOFFM8, LOFFM7, LOFFM6, LOFFM5, LOFFM4, LOFFM3, LOFFM2, LOFFM1] : No lead-off
|
| 23 |
+
0x00, # LOFF_FLIP [00] [LOFF_FLIP8, LOFF_FLIP7, LOFF_FLIP6, LOFF_FLIP5, LOFF_FLIP4, LOFF_FLIP3, LOFF_FLIP2, LOFF_FLIP1] : No lead-off flip
|
| 24 |
+
# Lead-Off Status Registers (Read-Only Registers):
|
| 25 |
+
0x00, # LOFF_STATP [00] [IN8P_OFF, IN7P_OFF, IN6P_OFF, IN5P_OFF, IN4P_OFF, IN3P_OFF, IN2P_OFF, IN1P_OFF] : Lead-off positive status (RO)
|
| 26 |
+
0x00, # LOFF_STATN [00] [IN8M_OFF, IN7M_OFF, IN6M_OFF, IN5M_OFF, IN4M_OFF, IN3M_OFF, IN2M_OFF, IN1M_OFF] : Laed-off negative status (RO)
|
| 27 |
+
# GPIO and OTHER Registers:
|
| 28 |
+
0x0F, # GPIO [0F] [GPIOD[4:1], GPIOC[4:1]] : All GPIOs as inputs
|
| 29 |
+
0x00, # MISC1 [00] [0, 0, SRB1, 0, 0, 0, 0, 0] : Disable SRBM
|
| 30 |
+
0x00, # MISC2 [00] [00] : Unused
|
| 31 |
+
0x00, # CONFIG4 [00] [0, 0, 0, 0, SINGLE_SHOT, 0, PD_LOFF_COMP(bar), 0] : Single-shot, lead-off comparator disabled
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
FRONTEND_CONFIG = [
|
| 35 |
+
0x3E, # ID (RO)
|
| 36 |
+
0x95, # CONFIG1 [95] [1, DAISY_EN(bar), CLK_EN, 1, 0, DR[2:0]] : Datarate = 500 SPS
|
| 37 |
+
0xD0, # CONFIG2 [C0] [1, 1, 0, INT_CAL, 0, CAL_AMP0, CAL_FREQ[1:0]]
|
| 38 |
+
0xFC, # CONFIG3 [E0] [PD_REFBUF(bar), 1, 1, BIAS_MEAS, BIASREF_INT, PD_BIAS(bar), BIAS_LOFF_SENS, BIAS_STAT] : Power-down reference buffer, no bias
|
| 39 |
+
0x00, # No lead-off
|
| 40 |
+
0x62, # CH1SET [60] [PD1, GAIN1[2:0], SRB2, MUX1[2:0]] set to measure BIAS signal
|
| 41 |
+
0x60, # CH2SET
|
| 42 |
+
0x60, # CH3SET
|
| 43 |
+
0x60, # CH4SET
|
| 44 |
+
0x60, # CH5SET
|
| 45 |
+
0x60, # CH6SET
|
| 46 |
+
0x60, # CH7SET
|
| 47 |
+
0x60, # CH8SET
|
| 48 |
+
0x00, # BIAS_SENSP 00
|
| 49 |
+
0x00, # BIAS_SENSN 00
|
| 50 |
+
0x00, # LOFF_SENSP Lead-off on all positive pins?
|
| 51 |
+
0x00, # LOFF_SENSN Lead-off on all negative pins?
|
| 52 |
+
0x00, # Normal lead-off
|
| 53 |
+
0x00, # Lead-off positive status (RO)
|
| 54 |
+
0x00, # Lead-off negative status (RO)
|
| 55 |
+
0x00, # All GPIOs as output ?
|
| 56 |
+
0x20, # Enable SRB1
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
LEADOFF_CONFIG = [
|
| 61 |
+
0x3E, # ID (RO)
|
| 62 |
+
0x95, # CONFIG1 [95] [1, DAISY_EN(bar), CLK_EN, 1, 0, DR[2:0]] : Datarate = 500 SPS
|
| 63 |
+
0xC0, # CONFIG2 [C0] [1, 1, 0, INT_CAL, 0, CAL_AMP0, CAL_FREQ[1:0]]
|
| 64 |
+
0xFC, # CONFIG3 [E0] [PD_REFBUF(bar), 1, 1, BIAS_MEAS, BIASREF_INT, PD_BIAS(bar), BIAS_LOFF_SENS, BIAS_STAT] : Power-down reference buffer, no bias
|
| 65 |
+
0x00, # No lead-off
|
| 66 |
+
0x60, # CH1SET [60] [PD1, GAIN1[2:0], SRB2, MUX1[2:0]] set to measure BIAS signal
|
| 67 |
+
0x60, # CH2SET
|
| 68 |
+
0x60, # CH3SET
|
| 69 |
+
0x60, # CH4SET
|
| 70 |
+
0x60, # CH5SET
|
| 71 |
+
0x60, # CH6SET
|
| 72 |
+
0x60, # CH7SET
|
| 73 |
+
0x60, # CH8SET
|
| 74 |
+
0x00, # BIAS_SENSP 00
|
| 75 |
+
0x00, # BIAS_SENSN 00
|
| 76 |
+
0xFF, # LOFF_SENSP Lead-off on all positive pins?
|
| 77 |
+
0xFF, # LOFF_SENSN Lead-off on all negative pins?
|
| 78 |
+
0x00, # Normal lead-off
|
| 79 |
+
0x00, # Lead-off positive status (RO)
|
| 80 |
+
0x00, # Lead-off negative status (RO)
|
| 81 |
+
0x00, # All GPIOs as output ?
|
| 82 |
+
0x20, # Enable SRB1
|
| 83 |
+
0x00,
|
| 84 |
+
0x02,
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
def to_ads_frequency(frequency):
|
| 88 |
+
possible_datarates = [250, 500, 1000, 2000, 4000, 8000, 16000]
|
| 89 |
+
dr = 16000
|
| 90 |
+
for i in possible_datarates:
|
| 91 |
+
if i >= frequency:
|
| 92 |
+
dr = i
|
| 93 |
+
break
|
| 94 |
+
return dr
|
| 95 |
+
|
| 96 |
+
def mod_config(config, datarate, channel_modes):
|
| 97 |
+
|
| 98 |
+
# datarate:
|
| 99 |
+
|
| 100 |
+
possible_datarates = [(250, 0x06),
|
| 101 |
+
(500, 0x05),
|
| 102 |
+
(1000, 0x04),
|
| 103 |
+
(2000, 0x03),
|
| 104 |
+
(4000, 0x02),
|
| 105 |
+
(8000, 0x01),
|
| 106 |
+
(16000, 0x00)]
|
| 107 |
+
mod_dr = 0x00
|
| 108 |
+
for i, j in possible_datarates:
|
| 109 |
+
if i >= datarate:
|
| 110 |
+
mod_dr = j
|
| 111 |
+
break
|
| 112 |
+
|
| 113 |
+
new_cf1 = config[1] & 0xF8
|
| 114 |
+
new_cf1 = new_cf1 | mod_dr
|
| 115 |
+
config[1] = new_cf1
|
| 116 |
+
|
| 117 |
+
# bias:
|
| 118 |
+
assert len(channel_modes) == 7
|
| 119 |
+
config[13] = 0x00 # clear BIAS_SENSP
|
| 120 |
+
config[14] = 0x00 # clear BIAS_SENSN
|
| 121 |
+
for chan_i, chan_mode in enumerate(channel_modes):
|
| 122 |
+
n = 6 + chan_i
|
| 123 |
+
mod = config[n] & 0x78 # clear PDn and MUX[2:0]
|
| 124 |
+
if chan_mode == 'simple':
|
| 125 |
+
# If channel is activated, we send the channel's output to the BIAS mechanism
|
| 126 |
+
bit_i = 1 << chan_i + 1
|
| 127 |
+
config[13] = config[13] | bit_i
|
| 128 |
+
config[14] = config[14] | bit_i
|
| 129 |
+
elif chan_mode == 'disabled':
|
| 130 |
+
mod = mod | 0x81 # PDn = 1 and input shorted (001)
|
| 131 |
+
else:
|
| 132 |
+
assert False, f"Wrong key: {chan_mode}."
|
| 133 |
+
config[n] = mod
|
| 134 |
+
# for n, c in enumerate(config): # print ADS1299 configuration registers
|
| 135 |
+
# print(f"config[{n}]:\t{c:08b}\t({hex(c)})")
|
| 136 |
+
return config
|
portiloop/{detection.py β src/detection.py}
RENAMED
|
@@ -39,7 +39,7 @@ class Detector(ABC):
|
|
| 39 |
|
| 40 |
# Example implementation for sleep spindles:
|
| 41 |
|
| 42 |
-
DEFAULT_MODEL_PATH = str(Path(__file__).parent / "models/portiloop_model_quant.tflite")
|
| 43 |
# print(DEFAULT_MODEL_PATH)
|
| 44 |
|
| 45 |
class SleepSpindleRealTimeDetector(Detector):
|
|
|
|
| 39 |
|
| 40 |
# Example implementation for sleep spindles:
|
| 41 |
|
| 42 |
+
DEFAULT_MODEL_PATH = str(Path(__file__).parent.parent / "models/portiloop_model_quant.tflite")
|
| 43 |
# print(DEFAULT_MODEL_PATH)
|
| 44 |
|
| 45 |
class SleepSpindleRealTimeDetector(Detector):
|
portiloop/{hardware β src/hardware}/__init__.py
RENAMED
|
File without changes
|
portiloop/{hardware β src/hardware}/frontend.py
RENAMED
|
File without changes
|
portiloop/{hardware β src/hardware}/leds.py
RENAMED
|
File without changes
|
portiloop/src/processing.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from scipy.signal import firwin
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def filter_24(value):
|
| 6 |
+
return (value * 4.5) / (2**23 - 1) / 24.0 * 1e6 # 23 because 1 bit is lost for sign
|
| 7 |
+
|
| 8 |
+
def filter_2scomplement_np(value):
|
| 9 |
+
return np.where((value & (1 << 23)) != 0, value - (1 << 24), value)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def int_to_float(value):
|
| 13 |
+
"""
|
| 14 |
+
Convert the int value out of the ADS into a value in microvolts
|
| 15 |
+
"""
|
| 16 |
+
return filter_24(filter_2scomplement_np(value))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def shift_numpy(arr, num, fill_value=np.nan):
|
| 20 |
+
result = np.empty_like(arr)
|
| 21 |
+
if num > 0:
|
| 22 |
+
result[:num] = fill_value
|
| 23 |
+
result[num:] = arr[:-num]
|
| 24 |
+
elif num < 0:
|
| 25 |
+
result[num:] = fill_value
|
| 26 |
+
result[:num] = arr[-num:]
|
| 27 |
+
else:
|
| 28 |
+
result[:] = arr
|
| 29 |
+
return result
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class FIR:
|
| 33 |
+
def __init__(self, nb_channels, coefficients, buffer=None):
|
| 34 |
+
|
| 35 |
+
self.coefficients = np.expand_dims(np.array(coefficients), axis=1)
|
| 36 |
+
self.taps = len(self.coefficients)
|
| 37 |
+
self.nb_channels = nb_channels
|
| 38 |
+
self.buffer = np.array(buffer) if buffer is not None else np.zeros((self.taps, self.nb_channels))
|
| 39 |
+
|
| 40 |
+
def filter(self, x):
|
| 41 |
+
self.buffer = shift_numpy(self.buffer, 1, x)
|
| 42 |
+
filtered = np.sum(self.buffer * self.coefficients, axis=0)
|
| 43 |
+
return filtered
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class FilterPipeline:
|
| 47 |
+
def __init__(self,
|
| 48 |
+
nb_channels,
|
| 49 |
+
sampling_rate,
|
| 50 |
+
power_line_fq=60,
|
| 51 |
+
use_custom_fir=False,
|
| 52 |
+
custom_fir_order=20,
|
| 53 |
+
custom_fir_cutoff=30,
|
| 54 |
+
alpha_avg=0.1,
|
| 55 |
+
alpha_std=0.001,
|
| 56 |
+
epsilon=0.000001,
|
| 57 |
+
filter_args=[]):
|
| 58 |
+
if len(filter_args) > 0:
|
| 59 |
+
use_fir, use_notch, use_std = filter_args
|
| 60 |
+
else:
|
| 61 |
+
use_fir=True,
|
| 62 |
+
use_notch=True,
|
| 63 |
+
use_std=True
|
| 64 |
+
self.use_fir = use_fir
|
| 65 |
+
self.use_notch = use_notch
|
| 66 |
+
self.use_std = use_std
|
| 67 |
+
self.nb_channels = nb_channels
|
| 68 |
+
assert power_line_fq in [50, 60], f"The only supported power line frequencies are 50 Hz and 60 Hz"
|
| 69 |
+
if power_line_fq == 60:
|
| 70 |
+
self.notch_coeff1 = -0.12478308884588535
|
| 71 |
+
self.notch_coeff2 = 0.98729186796473023
|
| 72 |
+
self.notch_coeff3 = 0.99364593398236511
|
| 73 |
+
self.notch_coeff4 = -0.12478308884588535
|
| 74 |
+
self.notch_coeff5 = 0.99364593398236511
|
| 75 |
+
else:
|
| 76 |
+
self.notch_coeff1 = -0.61410695998423581
|
| 77 |
+
self.notch_coeff2 = 0.98729186796473023
|
| 78 |
+
self.notch_coeff3 = 0.99364593398236511
|
| 79 |
+
self.notch_coeff4 = -0.61410695998423581
|
| 80 |
+
self.notch_coeff5 = 0.99364593398236511
|
| 81 |
+
self.dfs = [np.zeros(self.nb_channels), np.zeros(self.nb_channels)]
|
| 82 |
+
|
| 83 |
+
self.moving_average = None
|
| 84 |
+
self.moving_variance = np.zeros(self.nb_channels)
|
| 85 |
+
self.ALPHA_AVG = alpha_avg
|
| 86 |
+
self.ALPHA_STD = alpha_std
|
| 87 |
+
self.EPSILON = epsilon
|
| 88 |
+
|
| 89 |
+
if use_custom_fir:
|
| 90 |
+
self.fir_coef = firwin(numtaps=custom_fir_order+1, cutoff=custom_fir_cutoff, fs=sampling_rate)
|
| 91 |
+
else:
|
| 92 |
+
self.fir_coef = [
|
| 93 |
+
0.001623780150148094927192721215192250384,
|
| 94 |
+
0.014988684599373741992978104065059596905,
|
| 95 |
+
0.021287595318265635502275046064823982306,
|
| 96 |
+
0.007349500393709578957568417933998716762,
|
| 97 |
+
-0.025127515717112181709014251396183681209,
|
| 98 |
+
-0.052210507359822452833064687638398027048,
|
| 99 |
+
-0.039273839505489904766477593511808663607,
|
| 100 |
+
0.033021568427940004020193498490698402748,
|
| 101 |
+
0.147606943281569008563636202779889572412,
|
| 102 |
+
0.254000252034505602516389899392379447818,
|
| 103 |
+
0.297330876398883392486283128164359368384,
|
| 104 |
+
0.254000252034505602516389899392379447818,
|
| 105 |
+
0.147606943281569008563636202779889572412,
|
| 106 |
+
0.033021568427940004020193498490698402748,
|
| 107 |
+
-0.039273839505489904766477593511808663607,
|
| 108 |
+
-0.052210507359822452833064687638398027048,
|
| 109 |
+
-0.025127515717112181709014251396183681209,
|
| 110 |
+
0.007349500393709578957568417933998716762,
|
| 111 |
+
0.021287595318265635502275046064823982306,
|
| 112 |
+
0.014988684599373741992978104065059596905,
|
| 113 |
+
0.001623780150148094927192721215192250384]
|
| 114 |
+
self.fir = FIR(self.nb_channels, self.fir_coef)
|
| 115 |
+
|
| 116 |
+
def filter(self, value):
|
| 117 |
+
"""
|
| 118 |
+
value: a numpy array of shape (data series, channels)
|
| 119 |
+
"""
|
| 120 |
+
for i, x in enumerate(value): # loop over the data series
|
| 121 |
+
# FIR:
|
| 122 |
+
if self.use_fir:
|
| 123 |
+
x = self.fir.filter(x)
|
| 124 |
+
# notch:
|
| 125 |
+
if self.use_notch:
|
| 126 |
+
denAccum = (x - self.notch_coeff1 * self.dfs[0]) - self.notch_coeff2 * self.dfs[1]
|
| 127 |
+
x = (self.notch_coeff3 * denAccum + self.notch_coeff4 * self.dfs[0]) + self.notch_coeff5 * self.dfs[1]
|
| 128 |
+
self.dfs[1] = self.dfs[0]
|
| 129 |
+
self.dfs[0] = denAccum
|
| 130 |
+
# standardization:
|
| 131 |
+
if self.use_std:
|
| 132 |
+
if self.moving_average is not None:
|
| 133 |
+
delta = x - self.moving_average
|
| 134 |
+
self.moving_average = self.moving_average + self.ALPHA_AVG * delta
|
| 135 |
+
self.moving_variance = (1 - self.ALPHA_STD) * (self.moving_variance + self.ALPHA_STD * delta**2)
|
| 136 |
+
moving_std = np.sqrt(self.moving_variance)
|
| 137 |
+
x = (x - self.moving_average) / (moving_std + self.EPSILON)
|
| 138 |
+
else:
|
| 139 |
+
self.moving_average = x
|
| 140 |
+
value[i] = x
|
| 141 |
+
return value
|
portiloop/{stimulation.py β src/stimulation.py}
RENAMED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
from abc import ABC, abstractmethod
|
|
|
|
| 2 |
import time
|
| 3 |
from threading import Thread, Lock
|
| 4 |
from pathlib import Path
|
| 5 |
import alsaaudio
|
| 6 |
import wave
|
| 7 |
import pylsl
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
# Abstract interface for developers:
|
|
@@ -32,13 +34,11 @@ class Stimulator(ABC):
|
|
| 32 |
|
| 33 |
class SleepSpindleRealTimeStimulator(Stimulator):
|
| 34 |
def __init__(self):
|
| 35 |
-
self._sound = Path(__file__).parent / 'sounds' / 'stimulus.wav'
|
| 36 |
print(f"DEBUG:{self._sound}")
|
| 37 |
self._thread = None
|
| 38 |
self._lock = Lock()
|
| 39 |
self.last_detected_ts = time.time()
|
| 40 |
-
self.wait_counter = 0
|
| 41 |
-
self.delayed = False
|
| 42 |
self.wait_t = 0.4 # 400 ms
|
| 43 |
|
| 44 |
lsl_markers_info = pylsl.StreamInfo(name='Portiloop_stimuli',
|
|
@@ -55,8 +55,6 @@ class SleepSpindleRealTimeStimulator(Stimulator):
|
|
| 55 |
|
| 56 |
self.lsl_outlet_markers = pylsl.StreamOutlet(lsl_markers_info)
|
| 57 |
self.lsl_outlet_markers_fast = pylsl.StreamOutlet(lsl_markers_info_fast)
|
| 58 |
-
|
| 59 |
-
self.delayer = None
|
| 60 |
|
| 61 |
# Initialize Alsa stuff
|
| 62 |
# Open WAV file and set PCM device
|
|
@@ -88,7 +86,7 @@ class SleepSpindleRealTimeStimulator(Stimulator):
|
|
| 88 |
while data:
|
| 89 |
self.wav_list.append(data)
|
| 90 |
data = f.readframes(self.periodsize)
|
| 91 |
-
|
| 92 |
def play_sound(self):
|
| 93 |
'''
|
| 94 |
Open the wav file and play a sound
|
|
@@ -98,39 +96,35 @@ class SleepSpindleRealTimeStimulator(Stimulator):
|
|
| 98 |
|
| 99 |
def stimulate(self, detection_signal):
|
| 100 |
for sig in detection_signal:
|
| 101 |
-
# We are waiting for a delayed stimulation
|
| 102 |
-
if self.delayed:
|
| 103 |
-
if self.wait_counter >= self.wait_time:
|
| 104 |
-
with self._lock:
|
| 105 |
-
if self._thread is None:
|
| 106 |
-
self._thread = Thread(target=self._t_sound, daemon=True)
|
| 107 |
-
self._thread.start()
|
| 108 |
-
self.delayed = False
|
| 109 |
-
else:
|
| 110 |
-
self.wait_counter += 1
|
| 111 |
# We detect a stimulation
|
| 112 |
-
|
| 113 |
# Record time of stimulation
|
| 114 |
-
self.lsl_outlet_markers_fast.push_sample(['FASTSTIM'])
|
| 115 |
ts = time.time()
|
| 116 |
|
| 117 |
-
#
|
| 118 |
-
if self.delayer is not None:
|
| 119 |
-
self.wait_time = self.delayer.stimulate()
|
| 120 |
-
self.delayed = True
|
| 121 |
-
self.wait_counter = 0
|
| 122 |
-
continue
|
| 123 |
-
|
| 124 |
-
# Stimulate if allowed
|
| 125 |
if ts - self.last_detected_ts > self.wait_t:
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
self.last_detected_ts = ts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
def _t_sound(self):
|
| 133 |
-
self.lsl_outlet_markers.push_sample(['STIM'])
|
| 134 |
self.play_sound()
|
| 135 |
with self._lock:
|
| 136 |
self._thread = None
|
|
@@ -140,6 +134,94 @@ class SleepSpindleRealTimeStimulator(Stimulator):
|
|
| 140 |
if self._thread is None:
|
| 141 |
self._thread = Thread(target=self._t_sound, daemon=True)
|
| 142 |
self._thread.start()
|
| 143 |
-
|
| 144 |
def add_delayer(self, delayer):
|
| 145 |
self.delayer = delayer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from abc import ABC, abstractmethod
|
| 2 |
+
from enum import Enum
|
| 3 |
import time
|
| 4 |
from threading import Thread, Lock
|
| 5 |
from pathlib import Path
|
| 6 |
import alsaaudio
|
| 7 |
import wave
|
| 8 |
import pylsl
|
| 9 |
+
from scipy.signal import find_peaks
|
| 10 |
|
| 11 |
|
| 12 |
# Abstract interface for developers:
|
|
|
|
| 34 |
|
| 35 |
class SleepSpindleRealTimeStimulator(Stimulator):
|
| 36 |
def __init__(self):
|
| 37 |
+
self._sound = Path(__file__).parent.parent / 'sounds' / 'stimulus.wav'
|
| 38 |
print(f"DEBUG:{self._sound}")
|
| 39 |
self._thread = None
|
| 40 |
self._lock = Lock()
|
| 41 |
self.last_detected_ts = time.time()
|
|
|
|
|
|
|
| 42 |
self.wait_t = 0.4 # 400 ms
|
| 43 |
|
| 44 |
lsl_markers_info = pylsl.StreamInfo(name='Portiloop_stimuli',
|
|
|
|
| 55 |
|
| 56 |
self.lsl_outlet_markers = pylsl.StreamOutlet(lsl_markers_info)
|
| 57 |
self.lsl_outlet_markers_fast = pylsl.StreamOutlet(lsl_markers_info_fast)
|
|
|
|
|
|
|
| 58 |
|
| 59 |
# Initialize Alsa stuff
|
| 60 |
# Open WAV file and set PCM device
|
|
|
|
| 86 |
while data:
|
| 87 |
self.wav_list.append(data)
|
| 88 |
data = f.readframes(self.periodsize)
|
| 89 |
+
|
| 90 |
def play_sound(self):
|
| 91 |
'''
|
| 92 |
Open the wav file and play a sound
|
|
|
|
| 96 |
|
| 97 |
def stimulate(self, detection_signal):
|
| 98 |
for sig in detection_signal:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
# We detect a stimulation
|
| 100 |
+
if sig:
|
| 101 |
# Record time of stimulation
|
|
|
|
| 102 |
ts = time.time()
|
| 103 |
|
| 104 |
+
# Check if time since last stimulation is long enough
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
if ts - self.last_detected_ts > self.wait_t:
|
| 106 |
+
if self.delayer is not None:
|
| 107 |
+
# If we have a delayer, notify it
|
| 108 |
+
self.delayer.detected()
|
| 109 |
+
# Send the LSL marer for the fast stimulation
|
| 110 |
+
self.send_stimulation("FAST_STIM", False)
|
| 111 |
+
else:
|
| 112 |
+
self.send_stimulation("STIM", True)
|
| 113 |
+
|
| 114 |
self.last_detected_ts = ts
|
| 115 |
+
|
| 116 |
+
def send_stimulation(self, lsl_text, sound):
|
| 117 |
+
# Send lsl stimulation
|
| 118 |
+
self.lsl_outlet_markers.push_sample([lsl_text])
|
| 119 |
+
# Send sound to patient
|
| 120 |
+
if sound:
|
| 121 |
+
with self._lock:
|
| 122 |
+
if self._thread is None:
|
| 123 |
+
self._thread = Thread(target=self._t_sound, daemon=True)
|
| 124 |
+
self._thread.start()
|
| 125 |
+
|
| 126 |
|
| 127 |
def _t_sound(self):
|
|
|
|
| 128 |
self.play_sound()
|
| 129 |
with self._lock:
|
| 130 |
self._thread = None
|
|
|
|
| 134 |
if self._thread is None:
|
| 135 |
self._thread = Thread(target=self._t_sound, daemon=True)
|
| 136 |
self._thread.start()
|
| 137 |
+
|
| 138 |
def add_delayer(self, delayer):
|
| 139 |
self.delayer = delayer
|
| 140 |
+
self.delayer.stimulate = lambda x: self.send_stimulation("DELAY_STIM", True)
|
| 141 |
+
|
| 142 |
+
# Class that delays stimulation to always stimulate peak or through
|
| 143 |
+
class UpStateDelayer:
|
| 144 |
+
def __init__(self, sample_freq, spindle_freq, peak, time_to_buffer):
|
| 145 |
+
'''
|
| 146 |
+
args:
|
| 147 |
+
sample_freq: int -> Sampling frequency of signal in Hz
|
| 148 |
+
time_to_wait: float -> Time to wait to build buffer in seconds
|
| 149 |
+
'''
|
| 150 |
+
# Get number of timesteps for a whole spindle
|
| 151 |
+
self.spindle_timesteps = (1/spindle_freq) * sample_freq # s *
|
| 152 |
+
self.sample_freq = sample_freq
|
| 153 |
+
self.buffer_size = 1.5 * self.spindle_timesteps
|
| 154 |
+
self.peak = peak
|
| 155 |
+
self.buffer = []
|
| 156 |
+
self.time_to_buffer = time_to_buffer
|
| 157 |
+
self.stimulate = None
|
| 158 |
+
|
| 159 |
+
self.state = States.NO_SPINDLE
|
| 160 |
+
|
| 161 |
+
def step(self, point):
|
| 162 |
+
'''
|
| 163 |
+
Step the delayer, ads a point to buffer if necessary.
|
| 164 |
+
Returns True if stimulation is actually done
|
| 165 |
+
'''
|
| 166 |
+
if self.state == States.NO_SPINDLE:
|
| 167 |
+
return False
|
| 168 |
+
elif self.state == States.BUFFERING:
|
| 169 |
+
self.buffer.append(point)
|
| 170 |
+
# If we are done buffering, move on to the waiting stage
|
| 171 |
+
if time.time() - self.time_started >= self.time_to_buffer:
|
| 172 |
+
# Compute the necessary time to wait
|
| 173 |
+
self.time_to_wait = self.compute_time_to_wait()
|
| 174 |
+
self.state = States.DELAYING
|
| 175 |
+
self.buffer = []
|
| 176 |
+
self.time_started = time.time()
|
| 177 |
+
return False
|
| 178 |
+
elif self.state == States.DELAYING:
|
| 179 |
+
# Check if we are done delaying
|
| 180 |
+
if time.time() - self.time_started >= self.time_to_wait():
|
| 181 |
+
# Actually stimulate the patient after the delay
|
| 182 |
+
if self.stimulate is not None:
|
| 183 |
+
self.stimulate()
|
| 184 |
+
# Reset state
|
| 185 |
+
self.time_to_wait = -1
|
| 186 |
+
self.state = States.NO_SPINDLE
|
| 187 |
+
return True
|
| 188 |
+
return False
|
| 189 |
+
|
| 190 |
+
def detected(self):
|
| 191 |
+
if self.state == States.NO_SPINDLE:
|
| 192 |
+
self.state = States.BUFFERING
|
| 193 |
+
self.time_started = time.time()
|
| 194 |
+
|
| 195 |
+
def compute_time_to_wait(self):
|
| 196 |
+
"""
|
| 197 |
+
Computes the time we want to wait in total based on the spindle frequency and the buffer
|
| 198 |
+
"""
|
| 199 |
+
# If we want to look at the valleys, we search for peaks on the inversed signal
|
| 200 |
+
if not self.peak:
|
| 201 |
+
self.buffer = -self.buffer
|
| 202 |
+
|
| 203 |
+
# Returns the index of the last peak in the buffer
|
| 204 |
+
peaks, _ = find_peaks(self.buffer, prominence=1)
|
| 205 |
+
|
| 206 |
+
# Compute the time until next peak and return it
|
| 207 |
+
return (len(self.buffer) - peaks[-1]) * (1 / self.sample_freq)
|
| 208 |
+
|
| 209 |
+
class States(Enum):
|
| 210 |
+
NO_SPINDLE = 0
|
| 211 |
+
BUFFERING = 1
|
| 212 |
+
DELAYING = 2
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
import numpy as np
|
| 217 |
+
import matplotlib.pyplot as plt
|
| 218 |
+
|
| 219 |
+
freq = 250
|
| 220 |
+
spindle_freq = 10
|
| 221 |
+
time = 10
|
| 222 |
+
x = np.linspace(0, time * np.pi, num=time*freq)
|
| 223 |
+
n = np.random.normal(scale=1, size=x.size)
|
| 224 |
+
y = np.sin(x) + n
|
| 225 |
+
plt.plot(x, y)
|
| 226 |
+
plt.show()
|
| 227 |
+
|
portiloop/src/utils.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from EDFlib.edfwriter import EDFwriter
|
| 2 |
+
from portilooplot.jupyter_plot import ProgressPlot
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
|
| 6 |
+
EDF_PATH = Path.home() / 'workspace' / 'edf_recording'
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class DummyAlsaMixer:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.volume = 50
|
| 13 |
+
|
| 14 |
+
def getvolume(self):
|
| 15 |
+
return [self.volume]
|
| 16 |
+
|
| 17 |
+
def setvolume(self, volume):
|
| 18 |
+
self.volume = volume
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class EDFRecorder:
|
| 22 |
+
def __init__(self, signal_labels):
|
| 23 |
+
self.filename = EDF_PATH / 'recording.edf'
|
| 24 |
+
self.nb_signals = 8
|
| 25 |
+
self.samples_per_datarecord_array = self.frequency
|
| 26 |
+
self.physical_max = 5
|
| 27 |
+
self.physical_min = -5
|
| 28 |
+
self.signal_labels = signal_labels
|
| 29 |
+
|
| 30 |
+
def open_recording_file(self):
|
| 31 |
+
nb_signals = self.nb_signals
|
| 32 |
+
samples_per_datarecord_array = self.samples_per_datarecord_array
|
| 33 |
+
physical_max = self.physical_max
|
| 34 |
+
physical_min = self.physical_min
|
| 35 |
+
signal_labels = self.signal_labels
|
| 36 |
+
|
| 37 |
+
print(f"Will store edf recording in {self.filename}")
|
| 38 |
+
|
| 39 |
+
self.edf_writer = EDFwriter(p_path=str(self.filename),
|
| 40 |
+
f_file_type=EDFwriter.EDFLIB_FILETYPE_EDFPLUS,
|
| 41 |
+
number_of_signals=nb_signals)
|
| 42 |
+
|
| 43 |
+
for signal in range(nb_signals):
|
| 44 |
+
assert self.edf_writer.setSampleFrequency(signal, samples_per_datarecord_array) == 0
|
| 45 |
+
assert self.edf_writer.setPhysicalMaximum(signal, physical_max) == 0
|
| 46 |
+
assert self.edf_writer.setPhysicalMinimum(signal, physical_min) == 0
|
| 47 |
+
assert self.edf_writer.setDigitalMaximum(signal, 32767) == 0
|
| 48 |
+
assert self.edf_writer.setDigitalMinimum(signal, -32768) == 0
|
| 49 |
+
assert self.edf_writer.setSignalLabel(signal, signal_labels[signal]) == 0
|
| 50 |
+
assert self.edf_writer.setPhysicalDimension(signal, 'V') == 0
|
| 51 |
+
|
| 52 |
+
def close_recording_file(self):
|
| 53 |
+
assert self.edf_writer.close() == 0
|
| 54 |
+
|
| 55 |
+
def add_recording_data(self, data):
|
| 56 |
+
self.edf_buffer += data
|
| 57 |
+
if len(self.edf_buffer) >= self.samples_per_datarecord_array:
|
| 58 |
+
datarecord_array = self.edf_buffer[:self.samples_per_datarecord_array]
|
| 59 |
+
self.edf_buffer = self.edf_buffer[self.samples_per_datarecord_array:]
|
| 60 |
+
datarecord_array = np.array(datarecord_array).transpose()
|
| 61 |
+
assert len(datarecord_array) == self.nb_signals, f"len(data)={len(data)}!={self.nb_signals}"
|
| 62 |
+
for d in datarecord_array:
|
| 63 |
+
assert len(d) == self.samples_per_datarecord_array, f"{len(d)}!={self.samples_per_datarecord_array}"
|
| 64 |
+
assert self.edf_writer.writeSamples(d) == 0
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class LiveDisplay():
|
| 68 |
+
def __init__(self, channel_names, window_len=100):
|
| 69 |
+
self.datapoint_dim = len(channel_names)
|
| 70 |
+
self.history = []
|
| 71 |
+
self.pp = ProgressPlot(plot_names=channel_names, max_window_len=window_len)
|
| 72 |
+
self.matplotlib = False
|
| 73 |
+
|
| 74 |
+
def add_datapoints(self, datapoints):
|
| 75 |
+
"""
|
| 76 |
+
Adds 8 lists of datapoints to the plot
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
datapoints: list of 8 lists of floats (or list of 8 floats)
|
| 80 |
+
"""
|
| 81 |
+
if self.matplotlib:
|
| 82 |
+
import matplotlib.pyplot as plt
|
| 83 |
+
disp_list = []
|
| 84 |
+
for datapoint in datapoints:
|
| 85 |
+
d = [[elt] for elt in datapoint]
|
| 86 |
+
disp_list.append(d)
|
| 87 |
+
|
| 88 |
+
if self.matplotlib:
|
| 89 |
+
self.history += d[1]
|
| 90 |
+
|
| 91 |
+
if not self.matplotlib:
|
| 92 |
+
self.pp.update_with_datapoints(disp_list)
|
| 93 |
+
elif len(self.history) == 1000:
|
| 94 |
+
plt.plot(self.history)
|
| 95 |
+
plt.show()
|
| 96 |
+
self.history = []
|
| 97 |
+
|
| 98 |
+
def add_datapoint(self, datapoint):
|
| 99 |
+
disp_list = [[elt] for elt in datapoint]
|
| 100 |
+
self.pp.update(disp_list)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class FileReader:
|
| 104 |
+
def __init__(self, filename):
|
| 105 |
+
raise NotImplementedError
|
| 106 |
+
|
| 107 |
+
def get_point(self):
|
| 108 |
+
raise NotImplementedError
|