diff --git a/dodo.py b/dodo.py index e4a8cab39..95bc5990c 100644 --- a/dodo.py +++ b/dodo.py @@ -56,14 +56,15 @@ 'commands', ] +# Categories are displayed in this order on the website. CATNAME_TO_CAT_MAP = { '⭐ Featured': ['Featured'], 'Geospatial': ['Geospatial'], 'Finance and Economics': ['Finance', 'Economics'], 'Mathematics': ['Mathematics'], + 'Neuroscience': ['Neuroscience'], 'Cybersecurity and Networks': ['Cybersecurity', 'Networks'], 'Other Sciences': ['Other Sciences'], - 'Neuroscience': ['Neuroscience'], 'Sports': ['Sports'], # 'No Category':[], } diff --git a/streaming_timeseries/anaconda-project-lock.yml b/streaming_timeseries/anaconda-project-lock.yml new file mode 100644 index 000000000..39ba95a08 --- /dev/null +++ b/streaming_timeseries/anaconda-project-lock.yml @@ -0,0 +1,513 @@ +# This is an Anaconda project lock file. +# The lock file locks down exact versions of all your dependencies. +# +# In most cases, this file is automatically maintained by the `anaconda-project` command or GUI tools. +# It's best to keep this file in revision control (such as git or svn). +# The file is in YAML format, please see http://www.yaml.org/start.html for more. +# + +# +# Set to false to ignore locked versions. +# +locking_enabled: true + +# +# A key goes in here for each env spec. +# +env_specs: + default: + locked: true + env_spec_hash: 4b7be9964e8d2e304d5c02b39916b0d847a1e047 + platforms: + - linux-64 + - osx-64 + - osx-arm64 + - win-64 + packages: + all: + - anyio=4.6.2.post1=pyhd8ed1ab_0 + - argon2-cffi=23.1.0=pyhd8ed1ab_1 + - arrow=1.3.0=pyhd8ed1ab_0 + - asttokens=3.0.0=pyhd8ed1ab_1 + - async-lru=2.0.4=pyhd8ed1ab_0 + - attrs=24.2.0=pyh71513ae_0 + - babel=2.16.0=pyhd8ed1ab_1 + - beautifulsoup4=4.12.3=pyha770c72_1 + - bleach=6.2.0=pyhd8ed1ab_1 + - bokeh=3.6.2=pyhd8ed1ab_0 + - cached-property=1.5.2=hd8ed1ab_1 + - cached_property=1.5.2=pyha770c72_1 + - certifi=2024.8.30=pyhd8ed1ab_0 + - charset-normalizer=3.4.0=pyhd8ed1ab_1 + - colorama=0.4.6=pyhd8ed1ab_1 + - colorcet=3.1.0=pyhd8ed1ab_0 + - comm=0.2.2=pyhd8ed1ab_0 + - cycler=0.12.1=pyhd8ed1ab_1 + - decorator=5.1.1=pyhd8ed1ab_1 + - defusedxml=0.7.1=pyhd8ed1ab_0 + - entrypoints=0.4=pyhd8ed1ab_1 + - exceptiongroup=1.2.2=pyhd8ed1ab_1 + - executing=2.1.0=pyhd8ed1ab_0 + - fqdn=1.5.1=pyhd8ed1ab_1 + - h11=0.14.0=pyhd8ed1ab_1 + - h2=4.1.0=pyhd8ed1ab_1 + - holoviews=1.20.0=pyhd8ed1ab_0 + - hpack=4.0.0=pyhd8ed1ab_1 + - httpcore=1.0.7=pyh29332c3_1 + - httpx=0.28.0=pyhd8ed1ab_0 + - hvplot=0.11.1=pyhd8ed1ab_0 + - hyperframe=6.0.1=pyhd8ed1ab_1 + - idna=3.10=pyhd8ed1ab_1 + - importlib-metadata=8.5.0=pyha770c72_1 + - importlib_resources=6.4.5=pyhd8ed1ab_1 + - isoduration=20.11.0=pyhd8ed1ab_0 + - jedi=0.19.2=pyhd8ed1ab_1 + - jinja2=3.1.4=pyhd8ed1ab_1 + - json5=0.10.0=pyhd8ed1ab_1 + - jsonschema-specifications=2024.10.1=pyhd8ed1ab_0 + - jsonschema-with-format-nongpl=4.23.0=hd8ed1ab_0 + - jsonschema=4.23.0=pyhd8ed1ab_0 + - jupyter-lsp=2.2.5=pyhd8ed1ab_0 + - jupyter_client=8.6.3=pyhd8ed1ab_1 + - jupyter_events=0.10.0=pyhd8ed1ab_1 + - jupyter_server=2.14.2=pyhd8ed1ab_1 + - jupyter_server_terminals=0.5.3=pyhd8ed1ab_1 + - jupyterlab=4.3.2=pyhd8ed1ab_0 + - jupyterlab_pygments=0.3.0=pyhd8ed1ab_2 + - jupyterlab_server=2.27.3=pyhd8ed1ab_0 + - lazy-loader=0.4=pyhd8ed1ab_1 + - lazy_loader=0.4=pyhd8ed1ab_1 + - linkify-it-py=2.0.3=pyhd8ed1ab_0 + - markdown-it-py=3.0.0=pyhd8ed1ab_1 + - markdown=3.6=pyhd8ed1ab_0 + - matplotlib-inline=0.1.7=pyhd8ed1ab_1 + - mdit-py-plugins=0.4.2=pyhd8ed1ab_0 + - mdurl=0.1.2=pyhd8ed1ab_1 + - mistune=3.0.2=pyhd8ed1ab_1 + - mne-base=1.8.0=pyh694c41f_200 + - munkres=1.1.4=pyh9f0ad1d_0 + - nbclient=0.10.1=pyhd8ed1ab_0 + - nbconvert-core=7.16.4=pyhff2d567_2 + - nbformat=5.10.4=pyhd8ed1ab_1 + - nest-asyncio=1.6.0=pyhd8ed1ab_1 + - notebook-shim=0.2.4=pyhd8ed1ab_1 + - notebook=7.3.1=pyhd8ed1ab_0 + - overrides=7.7.0=pyhd8ed1ab_0 + - packaging=24.2=pyhd8ed1ab_2 + - pandocfilters=1.5.0=pyhd8ed1ab_0 + - panel=1.5.4=pyhd8ed1ab_0 + - param=2.1.1=pyhff2d567_0 + - parso=0.8.4=pyhd8ed1ab_1 + - pickleshare=0.7.5=pyhd8ed1ab_1004 + - pip=24.3.1=pyh8b19718_0 + - pkgutil-resolve-name=1.3.10=pyhd8ed1ab_2 + - platformdirs=4.3.6=pyhd8ed1ab_1 + - pooch=1.8.2=pyhd8ed1ab_1 + - prometheus_client=0.21.1=pyhd8ed1ab_0 + - prompt-toolkit=3.0.48=pyha770c72_1 + - pure_eval=0.2.3=pyhd8ed1ab_0 + - pycparser=2.22=pyh29332c3_1 + - pygments=2.18.0=pyhd8ed1ab_1 + - pyparsing=3.2.0=pyhd8ed1ab_2 + - pyqtgraph=0.13.7=pyhd8ed1ab_0 + - python-dateutil=2.9.0.post0=pyhff2d567_1 + - python-fastjsonschema=2.21.1=pyhd8ed1ab_0 + - python-json-logger=2.0.7=pyhd8ed1ab_0 + - python-tzdata=2024.2=pyhd8ed1ab_1 + - python_abi=3.11=5_cp311 + - pytz=2024.1=pyhd8ed1ab_0 + - pyviz_comms=3.0.3=pyhd8ed1ab_0 + - qtpy=2.4.2=pyhdecd6ff_0 + - referencing=0.35.1=pyhd8ed1ab_1 + - requests=2.32.3=pyhd8ed1ab_1 + - rfc3339-validator=0.1.4=pyhd8ed1ab_0 + - rfc3986-validator=0.1.1=pyh9f0ad1d_0 + - setuptools=75.6.0=pyhff2d567_1 + - six=1.17.0=pyhd8ed1ab_0 + - sniffio=1.3.1=pyhd8ed1ab_1 + - soupsieve=2.5=pyhd8ed1ab_1 + - stack_data=0.6.2=pyhd8ed1ab_0 + - tinycss2=1.4.0=pyhd8ed1ab_0 + - tomli=2.2.1=pyhd8ed1ab_1 + - tqdm=4.67.1=pyhd8ed1ab_0 + - traitlets=5.14.3=pyhd8ed1ab_1 + - types-python-dateutil=2.9.0.20241003=pyhd8ed1ab_1 + - typing-extensions=4.12.2=hd8ed1ab_1 + - typing_extensions=4.12.2=pyha770c72_1 + - typing_utils=0.1.0=pyhd8ed1ab_1 + - tzdata=2024b=hc8b5060_0 + - uc-micro-py=1.0.3=pyhd8ed1ab_0 + - uri-template=1.3.0=pyhd8ed1ab_1 + - urllib3=2.2.3=pyhd8ed1ab_1 + - wcwidth=0.2.13=pyhd8ed1ab_1 + - webcolors=24.11.1=pyhd8ed1ab_0 + - webencodings=0.5.1=pyhd8ed1ab_3 + - websocket-client=1.8.0=pyhd8ed1ab_1 + - wheel=0.45.1=pyhd8ed1ab_1 + - xyzservices=2024.9.0=pyhd8ed1ab_1 + - zipp=3.21.0=pyhd8ed1ab_1 + unix: + - click=8.1.7=unix_pyh707e725_1 + - ipython=8.30.0=pyh707e725_0 + - jupyter_core=5.7.2=pyh31011fe_1 + - pexpect=4.9.0=pyhd8ed1ab_1 + - ptyprocess=0.7.0=pyhd8ed1ab_1 + - pysocks=1.7.1=pyha55dd90_7 + osx: + - appnope=0.1.4=pyhd8ed1ab_1 + - ipykernel=6.29.5=pyh57ce528_0 + - send2trash=1.8.3=pyh31c8845_1 + - terminado=0.18.1=pyh31c8845_0 + linux-64: + - _libgcc_mutex=0.1=conda_forge + - _openmp_mutex=4.5=2_gnu + - argon2-cffi-bindings=21.2.0=py311h9ecbd09_5 + - brotli-bin=1.1.0=hb9d3cd8_2 + - brotli-python=1.1.0=py311hfdbb021_2 + - brotli=1.1.0=hb9d3cd8_2 + - bzip2=1.0.8=h4bc722e_7 + - c-ares=1.34.3=hb9d3cd8_1 + - ca-certificates=2024.8.30=hbcca054_0 + - cffi=1.17.1=py311hf29c0ef_0 + - contourpy=1.3.1=py311hd18a35c_0 + - debugpy=1.8.9=py311hfdbb021_0 + - fonttools=4.55.1=py311h2dc5d0c_0 + - freetype=2.12.1=h267a509_2 + - h5py=3.12.1=nompi_py311hb639ac4_102 + - hdf5=1.14.3=nompi_h2d575fe_107 + - ipykernel=6.29.5=pyh3099207_0 + - jsonpointer=3.0.0=py311h38be061_1 + - keyutils=1.6.1=h166bdaf_0 + - kiwisolver=1.4.7=py311hd18a35c_0 + - krb5=1.21.3=h659f571_0 + - lcms2=2.16=hb7c19ff_0 + - ld_impl_linux-64=2.43=h712a8e2_2 + - lerc=4.0.0=h27087fc_0 + - libaec=1.1.3=h59595ed_0 + - libblas=3.9.0=25_linux64_openblas + - libbrotlicommon=1.1.0=hb9d3cd8_2 + - libbrotlidec=1.1.0=hb9d3cd8_2 + - libbrotlienc=1.1.0=hb9d3cd8_2 + - libcblas=3.9.0=25_linux64_openblas + - libcurl=8.10.1=hbbe4b11_0 + - libdeflate=1.22=hb9d3cd8_0 + - libedit=3.1.20191231=he28a2e2_2 + - libev=4.33=hd590300_2 + - libexpat=2.6.4=h5888daf_0 + - libffi=3.4.2=h7f98852_5 + - libgcc-ng=14.2.0=h69a702a_1 + - libgcc=14.2.0=h77fa898_1 + - libgfortran5=14.2.0=hd5240d6_1 + - libgfortran=14.2.0=h69a702a_1 + - libgomp=14.2.0=h77fa898_1 + - libjpeg-turbo=3.0.0=hd590300_1 + - liblapack=3.9.0=25_linux64_openblas + - liblzma=5.6.3=hb9d3cd8_1 + - libnghttp2=1.64.0=h161d5f1_0 + - libnsl=2.0.1=hd590300_0 + - libopenblas=0.3.28=pthreads_h94d23a6_1 + - libpng=1.6.44=hadc24fc_0 + - libsodium=1.0.20=h4ab18f5_0 + - libsqlite=3.47.0=hadc24fc_1 + - libssh2=1.11.1=hf672d98_0 + - libstdcxx-ng=14.2.0=h4852527_1 + - libstdcxx=14.2.0=hc0a3c3a_1 + - libtiff=4.7.0=hc4654cb_2 + - libuuid=2.38.1=h0b41bf4_0 + - libwebp-base=1.4.0=hd590300_0 + - libxcb=1.17.0=h8a09558_0 + - libxcrypt=4.4.36=hd590300_1 + - libzlib=1.3.1=hb9d3cd8_2 + - markupsafe=3.0.2=py311h2dc5d0c_1 + - matplotlib-base=3.9.3=py311h2b939e6_0 + - mne-lsl=1.8.0=py311hd18a35c_0 + - ncurses=6.5=he02047a_1 + - numpy=2.1.3=py311h71ddf71_0 + - openjpeg=2.5.2=h488ebb8_0 + - openssl=3.4.0=hb9d3cd8_0 + - pandas=2.2.3=py311h7db5c69_1 + - pillow=11.0.0=py311h49e9ac3_0 + - proj=9.5.1=h0054346_0 + - psutil=6.1.0=py311h9ecbd09_0 + - pthread-stubs=0.4=hb9d3cd8_1002 + - pyproj=3.6.1=py311h0f98d5a_10 + - python=3.11.11=h9e4cc4f_1_cpython + - pyyaml=6.0.2=py311h9ecbd09_1 + - pyzmq=26.2.0=py311h7deb3e3_3 + - qhull=2020.2=h434a139_5 + - readline=8.2=h8228510_1 + - rpds-py=0.22.3=py311h9e33e62_0 + - scipy=1.14.1=py311he9a78e4_1 + - send2trash=1.8.3=pyh0d859eb_1 + - sqlite=3.47.0=h9eae976_1 + - terminado=0.18.1=pyh0d859eb_0 + - tk=8.6.13=noxft_h4845f30_101 + - tornado=6.4.2=py311h9ecbd09_0 + - unicodedata2=15.1.0=py311h9ecbd09_1 + - xorg-libxau=1.0.11=hb9d3cd8_1 + - xorg-libxdmcp=1.1.5=hb9d3cd8_0 + - yaml=0.2.5=h7f98852_2 + - zeromq=4.3.5=h3b0a872_7 + - zstandard=0.23.0=py311hbc35293_1 + - zstd=1.5.6=ha6fb4c9_0 + osx-64: + - argon2-cffi-bindings=21.2.0=py311h3336109_5 + - brotli-bin=1.1.0=h00291cd_2 + - brotli-python=1.1.0=py311hd89902b_2 + - brotli=1.1.0=h00291cd_2 + - bzip2=1.0.8=hfdf4475_7 + - c-ares=1.34.3=hf13058a_1 + - ca-certificates=2024.8.30=h8857fd0_0 + - cffi=1.17.1=py311h137bacd_0 + - contourpy=1.3.1=py311h4e34fa0_0 + - debugpy=1.8.9=py311hc356e98_0 + - fonttools=4.55.1=py311ha3cf9ac_0 + - freetype=2.12.1=h60636b9_2 + - h5py=3.12.1=nompi_py311h82f1de1_102 + - hdf5=1.14.3=nompi_h1607680_107 + - jsonpointer=3.0.0=py311h6eed73b_1 + - kiwisolver=1.4.7=py311hf2f7c97_0 + - krb5=1.21.3=h37d8d59_0 + - lcms2=2.16=ha2f27b4_0 + - lerc=4.0.0=hb486fe8_0 + - libaec=1.1.3=h73e2aa4_0 + - libblas=3.9.0=25_osx64_openblas + - libbrotlicommon=1.1.0=h00291cd_2 + - libbrotlidec=1.1.0=h00291cd_2 + - libbrotlienc=1.1.0=h00291cd_2 + - libcblas=3.9.0=25_osx64_openblas + - libcurl=8.10.1=h58e7537_0 + - libcxx=19.1.5=hf95d169_0 + - libdeflate=1.22=h00291cd_0 + - libedit=3.1.20191231=h0678c8f_2 + - libev=4.33=h10d778d_2 + - libexpat=2.6.4=h240833e_0 + - libffi=3.4.2=h0d85af4_5 + - libgfortran5=13.2.0=h2873a65_3 + - libgfortran=5.0.0=13_2_0_h97931a8_3 + - libjpeg-turbo=3.0.0=h0dc2134_1 + - liblapack=3.9.0=25_osx64_openblas + - liblzma=5.6.3=hd471939_1 + - libnghttp2=1.64.0=hc7306c3_0 + - libopenblas=0.3.28=openmp_hbf64a52_1 + - libpng=1.6.44=h4b8f8c9_0 + - libsodium=1.0.20=hfdf4475_0 + - libsqlite=3.47.0=h2f8c449_1 + - libssh2=1.11.1=h3dc7d44_0 + - libtiff=4.7.0=hf4bdac2_2 + - libwebp-base=1.4.0=h10d778d_0 + - libxcb=1.17.0=hf1f96e2_0 + - libzlib=1.3.1=hd23fc13_2 + - llvm-openmp=19.1.5=ha54dae1_0 + - markupsafe=3.0.2=py311ha3cf9ac_1 + - matplotlib-base=3.9.3=py311h19a4563_0 + - mne-lsl=1.8.0=py311h4e34fa0_0 + - ncurses=6.5=hf036a51_1 + - numpy=2.1.3=py311h14ed71f_0 + - openjpeg=2.5.2=h7310d3a_0 + - openssl=3.4.0=hd471939_0 + - pandas=2.2.3=py311haeb46be_1 + - pillow=11.0.0=py311h1f68098_0 + - proj=9.5.1=h5273da6_0 + - psutil=6.1.0=py311h1314207_0 + - pthread-stubs=0.4=h00291cd_1002 + - pyobjc-core=10.3.2=py311hfbc4093_0 + - pyobjc-framework-cocoa=10.3.2=py311hfbc4093_0 + - pyproj=3.6.1=py311h50e4d0a_10 + - python=3.11.11=h9ccd52b_1_cpython + - pyyaml=6.0.2=py311h3336109_1 + - pyzmq=26.2.0=py311h4d3da15_3 + - qhull=2020.2=h3c5361c_5 + - readline=8.2=h9e318b2_1 + - rpds-py=0.22.3=py311h3b9c2be_0 + - scipy=1.14.1=py311hed734c1_1 + - sqlite=3.47.0=h6285a30_1 + - tk=8.6.13=h1abcd95_1 + - tornado=6.4.2=py311h4d7f069_0 + - unicodedata2=15.1.0=py311h1314207_1 + - xorg-libxau=1.0.11=h00291cd_1 + - xorg-libxdmcp=1.1.5=h00291cd_0 + - yaml=0.2.5=h0d85af4_2 + - zeromq=4.3.5=h7130eaa_7 + - zstandard=0.23.0=py311hdf6fcd6_1 + - zstd=1.5.6=h915ae27_0 + osx-arm64: + - argon2-cffi-bindings=21.2.0=py311h460d6c5_5 + - brotli-bin=1.1.0=hd74edd7_2 + - brotli-python=1.1.0=py311h3f08180_2 + - brotli=1.1.0=hd74edd7_2 + - bzip2=1.0.8=h99b78c6_7 + - c-ares=1.34.3=h5505292_1 + - ca-certificates=2024.8.30=hf0a4a13_0 + - cffi=1.17.1=py311h3a79f62_0 + - contourpy=1.3.1=py311h210dab8_0 + - debugpy=1.8.9=py311h155a34a_0 + - fonttools=4.55.1=py311h4921393_0 + - freetype=2.12.1=hadb7bae_2 + - h5py=3.12.1=nompi_py311h0fa3d65_102 + - hdf5=1.14.3=nompi_ha698983_107 + - jsonpointer=3.0.0=py311h267d04e_1 + - kiwisolver=1.4.7=py311h2c37856_0 + - krb5=1.21.3=h237132a_0 + - lcms2=2.16=ha0e7c42_0 + - lerc=4.0.0=h9a09cb3_0 + - libaec=1.1.3=hebf3989_0 + - libblas=3.9.0=25_osxarm64_openblas + - libbrotlicommon=1.1.0=hd74edd7_2 + - libbrotlidec=1.1.0=hd74edd7_2 + - libbrotlienc=1.1.0=hd74edd7_2 + - libcblas=3.9.0=25_osxarm64_openblas + - libcurl=8.10.1=h13a7ad3_0 + - libcxx=19.1.5=ha82da77_0 + - libdeflate=1.22=hd74edd7_0 + - libedit=3.1.20191231=hc8eb9b7_2 + - libev=4.33=h93a5062_2 + - libexpat=2.6.4=h286801f_0 + - libffi=3.4.2=h3422bc3_5 + - libgfortran5=13.2.0=hf226fd6_3 + - libgfortran=5.0.0=13_2_0_hd922786_3 + - libjpeg-turbo=3.0.0=hb547adb_1 + - liblapack=3.9.0=25_osxarm64_openblas + - liblzma=5.6.3=h39f12f2_1 + - libnghttp2=1.64.0=h6d7220d_0 + - libopenblas=0.3.28=openmp_hf332438_1 + - libpng=1.6.44=hc14010f_0 + - libsodium=1.0.20=h99b78c6_0 + - libsqlite=3.47.0=hbaaea75_1 + - libssh2=1.11.1=h9cc3647_0 + - libtiff=4.7.0=ha962b0a_2 + - libwebp-base=1.4.0=h93a5062_0 + - libxcb=1.17.0=hdb1d25a_0 + - libzlib=1.3.1=h8359307_2 + - llvm-openmp=19.1.5=hdb05f8b_0 + - markupsafe=3.0.2=py311h4921393_1 + - matplotlib-base=3.9.3=py311h031da69_0 + - mne-lsl=1.8.0=py311h210dab8_0 + - ncurses=6.5=h7bae524_1 + - numpy=2.1.3=py311h649a571_0 + - openjpeg=2.5.2=h9f1df11_0 + - openssl=3.4.0=h39f12f2_0 + - pandas=2.2.3=py311h9cb3ce9_1 + - pillow=11.0.0=py311h3894ae9_0 + - proj=9.5.1=h1318a7e_0 + - psutil=6.1.0=py311hae2e1ce_0 + - pthread-stubs=0.4=hd74edd7_1002 + - pyobjc-core=10.3.2=py311hab620ed_0 + - pyobjc-framework-cocoa=10.3.2=py311hab620ed_0 + - pyproj=3.6.1=py311hb4b81e0_10 + - python=3.11.11=hc22306f_1_cpython + - pyyaml=6.0.2=py311h460d6c5_1 + - pyzmq=26.2.0=py311h730b646_3 + - qhull=2020.2=h420ef59_5 + - readline=8.2=h92ec313_1 + - rpds-py=0.22.3=py311h3ff9189_0 + - scipy=1.14.1=py311hf1db568_1 + - sqlite=3.47.0=hcd14bea_1 + - tk=8.6.13=h5083fa2_1 + - tornado=6.4.2=py311h917b07b_0 + - unicodedata2=15.1.0=py311hae2e1ce_1 + - xorg-libxau=1.0.11=hd74edd7_1 + - xorg-libxdmcp=1.1.5=hd74edd7_0 + - yaml=0.2.5=h3422bc3_2 + - zeromq=4.3.5=hc1bb282_7 + - zstandard=0.23.0=py311ha60cc69_1 + - zstd=1.5.6=hb46c0d2_0 + win-64: + - _openmp_mutex=4.5=2_gnu + - argon2-cffi-bindings=21.2.0=py311he736701_5 + - brotli-bin=1.1.0=h2466b09_2 + - brotli-python=1.1.0=py311hda3d55a_2 + - brotli=1.1.0=h2466b09_2 + - bzip2=1.0.8=h2466b09_7 + - ca-certificates=2024.8.30=h56e8100_0 + - cffi=1.17.1=py311he736701_0 + - click=8.1.7=win_pyh7428d3b_1 + - contourpy=1.3.1=py311h3257749_0 + - cpython=3.11.11=py311hd8ed1ab_1 + - debugpy=1.8.9=py311hda3d55a_0 + - fonttools=4.55.1=py311h5082efb_0 + - freetype=2.12.1=hdaf720e_2 + - h5py=3.12.1=nompi_py311h67016bb_102 + - hdf5=1.14.3=nompi_hd5d9e70_107 + - intel-openmp=2024.2.1=h57928b3_1083 + - ipykernel=6.29.5=pyh4bbf305_0 + - ipython=8.30.0=pyh7428d3b_0 + - jsonpointer=3.0.0=py311h1ea47a8_1 + - jupyter_core=5.7.2=pyh5737063_1 + - kiwisolver=1.4.7=py311h3257749_0 + - krb5=1.21.3=hdf4eb48_0 + - lcms2=2.16=h67d730c_0 + - lerc=4.0.0=h63175ca_0 + - libaec=1.1.3=h63175ca_0 + - libblas=3.9.0=25_win64_mkl + - libbrotlicommon=1.1.0=h2466b09_2 + - libbrotlidec=1.1.0=h2466b09_2 + - libbrotlienc=1.1.0=h2466b09_2 + - libcblas=3.9.0=25_win64_mkl + - libcurl=8.10.1=h1ee3ff0_0 + - libdeflate=1.22=h2466b09_0 + - libexpat=2.6.4=he0c23c2_0 + - libffi=3.4.2=h8ffe710_5 + - libgcc=14.2.0=h1383e82_1 + - libgomp=14.2.0=h1383e82_1 + - libhwloc=2.11.2=default_ha69328c_1001 + - libiconv=1.17=hcfcfb64_2 + - libjpeg-turbo=3.0.0=hcfcfb64_1 + - liblapack=3.9.0=25_win64_mkl + - liblzma=5.6.3=h2466b09_1 + - libpng=1.6.44=h3ca93ac_0 + - libsodium=1.0.20=hc70643c_0 + - libsqlite=3.47.0=h2466b09_1 + - libssh2=1.11.1=he619c9f_0 + - libtiff=4.7.0=hdefb170_2 + - libwebp-base=1.4.0=hcfcfb64_0 + - libwinpthread=12.0.0.r4.gg4f2fc60ca=h57928b3_8 + - libxcb=1.17.0=h0e4246c_0 + - libxml2=2.13.5=he286e8c_1 + - libzlib=1.3.1=h2466b09_2 + - markupsafe=3.0.2=py311h5082efb_1 + - matplotlib-base=3.9.3=py311h8f1b1e4_0 + - mkl=2024.2.2=h66d3029_14 + - mne-lsl=1.8.0=py311h3257749_0 + - numpy=2.1.3=py311h35ffc71_0 + - openjpeg=2.5.2=h3d672ee_0 + - openssl=3.4.0=h2466b09_0 + - pandas=2.2.3=py311hcf9f919_1 + - pillow=11.0.0=py311h4fbf6a9_0 + - proj=9.5.1=h4f671f6_0 + - psutil=6.1.0=py311he736701_0 + - pthread-stubs=0.4=h0e40799_1002 + - pyproj=3.6.1=py311h90dcb63_10 + - pysocks=1.7.1=pyh09c184e_7 + - python=3.11.11=h3f84c4b_1_cpython + - pywin32=307=py311hda3d55a_3 + - pywinpty=2.0.14=py311hda3d55a_0 + - pyyaml=6.0.2=py311he736701_1 + - pyzmq=26.2.0=py311h484c95c_3 + - qhull=2020.2=hc790b64_5 + - rpds-py=0.22.3=py311h533ab2d_0 + - scipy=1.14.1=py311hf16d85f_1 + - send2trash=1.8.3=pyh5737063_1 + - sqlite=3.47.0=h2466b09_1 + - tbb=2021.13.0=h62715c5_1 + - terminado=0.18.1=pyh5737063_0 + - tk=8.6.13=h5226925_1 + - tornado=6.4.2=py311he736701_0 + - ucrt=10.0.22621.0=h57928b3_1 + - unicodedata2=15.1.0=py311he736701_1 + - vc14_runtime=14.42.34433=he29a5d6_23 + - vc=14.3=ha32ba9b_23 + - vs2015_runtime=14.42.34433=hdffcdeb_23 + - win_inet_pton=1.1.0=pyh7428d3b_8 + - winpty=0.4.3=4 + - xorg-libxau=1.0.11=h0e40799_1 + - xorg-libxdmcp=1.1.5=h0e40799_0 + - yaml=0.2.5=h8ffe710_2 + - zeromq=4.3.5=ha9f60a1_7 + - zstandard=0.23.0=py311h53056dc_1 + - zstd=1.5.6=h0ea2cb4_0 + pip: + - h5io==0.2.4 + - pymatreader==1.0.0 + - tsdownsample==0.1.3 + - xmltodict==0.14.2 diff --git a/streaming_timeseries/anaconda-project.yml b/streaming_timeseries/anaconda-project.yml new file mode 100644 index 000000000..44062765f --- /dev/null +++ b/streaming_timeseries/anaconda-project.yml @@ -0,0 +1,68 @@ +name: streaming_timeseries +description: Streaming display of multichannel timeseries data. + +examples_config: + created: 2024-12-06 + maintainers: + - "droumis" + labels: + - "panel" + - "holoviews" + - "bokeh" + categories: + - Neuroscience + title: "Streaming Timeseries" + deployments: + # - command: notebook + - command: dashboard + +user_fields: [examples_config] + +channels: +- conda-forge +- nodefaults + +packages: &pkgs +- notebook>=6.5.2 # required +- python=3.11 +- numpy>=2.1.3 # auto min pinned 2024-12-06 +- panel>=1.4.2 +- hvplot>=0.10.0 +- pandas>=2.2.1 +- holoviews>=1.19.0 # 1.19: wide df handling, scalebars +- h5py>=3.12.1 # auto min pinned 2024-12-06 +- mne-lsl>=1.8.0 # auto min pinned 2024-12-06 +- jupyterlab>=4.3.2 # auto min pinned 2024-12-06 +- pyproj==3.6.1 +- bokeh>=3.6.2 # 3.6.2: scalebar fixes +- pip +- pip: + - tsdownsample>=0.1.3 + - mne[hdf5]>=1.8.0 # auto min pinned 2024-12-06 + +dependencies: *pkgs + +commands: + lab: + unix: jupyter lab streaming_timeseries.ipynb + windows: jupyter lab streaming_timeseries.ipynb + notebook: + notebook: streaming_timeseries.ipynb + dashboard: + unix: panel serve --rest-session-info --session-history -1 streaming_timeseries.ipynb --show + supports_http_options: true + +variables: {} + +downloads: + EEG_ANT_data: + url: https://datasets.holoviz.org/eeg_ant/v1/sample-ant-raw.fif + description: | + EEG ANT Dataset + filename: data/sample-ant-raw.fif + +platforms: +- linux-64 +- osx-64 +- win-64 +- osx-arm64 diff --git a/streaming_timeseries/assets/streaming_aux.png b/streaming_timeseries/assets/streaming_aux.png new file mode 100644 index 000000000..3ce944b1e Binary files /dev/null and b/streaming_timeseries/assets/streaming_aux.png differ diff --git a/streaming_timeseries/assets/streaming_header.png b/streaming_timeseries/assets/streaming_header.png new file mode 100644 index 000000000..15ba2abd5 Binary files /dev/null and b/streaming_timeseries/assets/streaming_header.png differ diff --git a/streaming_timeseries/assets/streaming_nb.png b/streaming_timeseries/assets/streaming_nb.png new file mode 100644 index 000000000..81c32af27 Binary files /dev/null and b/streaming_timeseries/assets/streaming_nb.png differ diff --git a/streaming_timeseries/assets/streaming_standalone.png b/streaming_timeseries/assets/streaming_standalone.png new file mode 100644 index 000000000..d2dba6611 Binary files /dev/null and b/streaming_timeseries/assets/streaming_standalone.png differ diff --git a/streaming_timeseries/streaming_timeseries.ipynb b/streaming_timeseries/streaming_timeseries.ipynb new file mode 100644 index 000000000..0e1296129 --- /dev/null +++ b/streaming_timeseries/streaming_timeseries.ipynb @@ -0,0 +1,1074 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "81d244e6-c220-46b7-b1f7-ceeea54e2c71", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "# Streaming Timeseries\n", + "\n", + "\"Streaming\n", + " " + ] + }, + { + "cell_type": "markdown", + "id": "88116414-ca1d-43c4-9882-35df33f82cb9", + "metadata": {}, + "source": [ + "___" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "70ef4be5-02e5-455e-aefa-e1b3eda9dfaa", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [ + "remove-input" + ] + }, + "outputs": [], + "source": [ + "# This cell has a remove-input tag to prevent code display on holoviz examples website\n", + "\n", + "from IPython.display import HTML\n", + "\n", + "HTML(\n", + " \"\"\"\n", + "
\n", + " \n", + "
\n", + " \"\"\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "aa9d3a69-5d7d-4d26-96f3-f354c69a0189", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "id": "077ce6ef-935b-43c7-8964-28f13fc2723e", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "| What? | Why? |\n", + "| ----------------------------------- | ----------------------------------------------------- |\n", + "| [Intro to Pre-Recorded Multichannel Timeseries](../multichannel_timeseries/index) | Provides background and motivation for using browser-based, interactive multichannel timeseries applications in neuroscience, focusing on pre-recorded dataset scenarios. |\n", + "\n", + "## Overview\n", + "\n", + "This workflow tutorial guides you through building a multichannel timeseries **live streaming** application, using a demo electroencephalogram (EEG) dataset. EEG is a technique for measuring electrical brain activity via electrodes placed on the scalp. It captures the electrical impulses produced by neural cells during communication, which appear as time-varying waveforms on an EEG recording. This application serves as a proof-of-concept demonstration for live streaming and does not cover saving streams to disk.\n", + "\n", + "You'll start by visualizing live CPU usage to validate and preview the streaming display interface components. Next, you'll set up a demo EEG data stream using the [Lab Streaming Layer (LSL)](https://labstreaminglayer.org/#/) framework. LSL enables real-time streaming, synchronization, and analysis of neural and physiological signals, with broad interoperability for many physiological and environmental sensing devices.\n", + "\n", + "The final application features:\n", + "- **Data Source Flexibility:** Stream data from LSL-compatible neural and physiological sensors.\n", + "- **Interactive Controls:** Buttons with state-based styling to start, pause, and stop data streams.\n", + "- **Real-Time Visualization:** Periodic updates and dynamic axis adjustments to preserve amplitude context during monitoring.\n", + "- **Return to Stream Follow:** Zooming or panning temporarily pauses the viewport's displayed range; clicking 'reset' restores the viewport range to follow the latest stream updates.\n", + "- **Sensor Position Display:** Additional plots linked to the data source for visualizing relative sensor positions or other data/metadata.\n", + "- **Extensibility:** Easily add real-time analytics, such as event detection, to the workflow.\n", + "\n", + "### Key Software\n", + "\n", + "- **[LSL](https://github.com/sccn/labstreaminglayer)** and **[MNE-LSL](https://mne.tools/mne-lsl/stable/index.html)**: These tools allow you to set up a buffered live stream for research experiments, including streaming data from a file on disk.\n" + ] + }, + { + "cell_type": "markdown", + "id": "93e210fa-0743-4d0c-af2b-5ea54646ba3c", + "metadata": {}, + "source": [ + "## Imports and Configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ae5acd0e-99fb-4e26-a595-320fef0b732a", + "metadata": {}, + "outputs": [], + "source": [ + "import abc\n", + "import time\n", + "import uuid\n", + "from pathlib import Path\n", + "\n", + "import pandas as pd\n", + "import numpy as np\n", + "import pooch\n", + "import psutil\n", + "from bokeh.palettes import Category20\n", + "from holoviews.streams import Buffer\n", + "import holoviews as hv\n", + "import panel as pn\n", + "import param\n", + "import mne\n", + "from mne_lsl.player import PlayerLSL\n", + "from mne_lsl.stream import StreamLSL\n", + "\n", + "hv.extension(\"bokeh\")\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "id": "a33a7773-799f-4466-91b8-9c077d306643", + "metadata": {}, + "source": [ + "## Loading and Inspecting the Data\n", + "\n", + "Let's get some data! This section walks through obtaining an EEG dataset (40 MB).\n", + "\n", + "
\n", + "

Note

\n", + " If you are viewing this notebook as a result of using the `anaconda-project run` command, the\n", + " data has already been downloaded, as configured in the associated yaml file. Running the\n", + " following cell should find that data and skip any further download.\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e9ee827-ca60-4d54-befc-026268304cf6", + "metadata": {}, + "outputs": [], + "source": [ + "DATA_URL = \"https://datasets.holoviz.org/eeg_ant/v1/sample-ant-raw.fif\"\n", + "DATA_DIR = Path(\"./data\")\n", + "DATA_FILENAME = Path(DATA_URL).name\n", + "DATA_PATH = DATA_DIR / DATA_FILENAME\n", + "DATA_DIR.mkdir(parents=True, exist_ok=True)\n", + "\n", + "# Download the data if it doesn't exist\n", + "if not DATA_PATH.exists():\n", + " print(f\"Downloading data to: {DATA_PATH}\")\n", + " pooch.retrieve(\n", + " url=DATA_URL,\n", + " known_hash=None,\n", + " fname=DATA_FILENAME,\n", + " path=DATA_DIR,\n", + " progressbar=True,\n", + " )\n", + "else:\n", + " print(f\"Data exists at: {DATA_PATH}\")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "e7c40b16-2e5a-4d53-be9b-98ef8c4b1c15", + "metadata": {}, + "source": [ + "## Minimal Example: Streaming Random Data\n", + "\n", + "Before diving into the development of a more advanced streaming application, let’s start with a minimal example using random data. This will help us understand the core building blocks of real-time streaming in Panel and HoloViews.\n", + "\n", + "- **`Buffer`:** Holds the most recent data points for real-time plotting.\n", + "- **`generate_data`**: Generates a random value paired with the current timestamp. Uses `pn.io.unlocked` to ensure updates don’t block the user interface (avoiding deadlocks).\n", + "- **`DynamicMap`:** Links the `buffer` stream to the `create_plot` function. Automatically refreshes the plot when new data arrives.\n", + "- **`add_periodic_callback`:** Schedules the `generate_data` function to run repeatedly based on a specified interval. We can then toggle the callback on and off simply by setting the `running` parameter." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2391895e-015c-47e1-93af-f2cd6b4b12b0", + "metadata": {}, + "outputs": [], + "source": [ + "nchannels = 3\n", + "stream_seconds = 10\n", + "buffer_length = 1000\n", + "\n", + "# Buffer: Initialize with some random data\n", + "buffer = hv.streams.Buffer(\n", + " data=pd.DataFrame(np.random.randn(10, nchannels).cumsum(axis=1),\n", + " columns=[chr(65 + i) for i in range(nchannels)]),\n", + " length=buffer_length\n", + ")\n", + "\n", + "# DynamicMap: Link the buffer to the visualization\n", + "def out(data):\n", + " return hv.NdOverlay(\n", + " {chr(65+i): hv.Curve(data, 'index', (chr(65+i), 'Amplitude')).opts(\n", + " subcoordinate_y=True, xlabel='Sample')\n", + " for i in range(nchannels)}\n", + " )\n", + "\n", + "dmap = hv.DynamicMap(out, streams=[buffer]).opts(\n", + " legend_position='right', responsive=True, height=400,\n", + " title='Minimal Example: Streaming Random Data',\n", + ")\n", + "\n", + "# Periodic Callback: Generate and stream new data\n", + "def generate_data():\n", + " index_start = buffer.data.index[-1] + 1 if not buffer.data.empty else 0\n", + " new_data = pd.DataFrame(\n", + " np.random.randn(10, nchannels).cumsum(axis=1),\n", + " columns=[chr(65 + i) for i in range(nchannels)],\n", + " index=np.arange(index_start, index_start + 10)\n", + " )\n", + " # Unlock: Use unlocked to ensure UI responsiveness\n", + " with pn.io.unlocked():\n", + " buffer.send(new_data)\n", + "\n", + "# Run the stream for certain duration (timeout in ms)\n", + "periodic_cb = pn.state.add_periodic_callback(generate_data, period=100, timeout=stream_seconds*1000)\n", + "\n", + "# Add a stream toggle button for the periodic callback\n", + "toggle_button = pn.widgets.Toggle(name='Toggle Stream', value=True, button_type='primary')\n", + "# Sync button and callback using `.link` with bidirectional=True and value='running'\n", + "toggle_button.link(periodic_cb, bidirectional=True, value='running')\n", + "\n", + "pn.Row(toggle_button, pn.pane.HoloViews(dmap))\n" + ] + }, + { + "cell_type": "markdown", + "id": "037c3972-8a16-4ad0-b02a-ab1170c7b184", + "metadata": {}, + "source": [ + "## Creating Data Sources\n", + "\n", + "Now that we've explored a minimal streaming application with random data, it's time to take the next step toward building a more advanced streaming application. Instead of random values, we’ll introduce real-world data sources to simulate meaningful streams.\n", + "\n", + "We will create two data sources, and a python class to handle the management of each:\n", + "\n", + "1. **CPU Usage:** Streams CPU usage percentages per core.\n", + "2. **EEG Usage:** Streams EEG data from a sample dataset.\n", + "\n", + "Let's create a sort of recipe (abstract base class) to ensure that each data source class contains\n", + "certain methods. We want the class to tell us its channel names, positions, sampling interval, as\n", + "well as to start up the stream, generate some data, and stop the stream." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7813e80f-3031-49f5-988d-e64f92ff9977", + "metadata": {}, + "outputs": [], + "source": [ + "class DataSource(abc.ABC):\n", + " @abc.abstractmethod\n", + " def get_channel_names(self) -> list[str]:\n", + " pass\n", + "\n", + " @abc.abstractmethod\n", + " def get_channel_positions(self) -> list[dict] | None:\n", + " pass\n", + "\n", + " @property\n", + " @abc.abstractmethod\n", + " def sampling_interval(self) -> float:\n", + " pass\n", + "\n", + " @abc.abstractmethod\n", + " def generate_data(self) -> pd.DataFrame:\n", + " pass\n", + "\n", + " @abc.abstractmethod\n", + " def start(self) -> None:\n", + " pass\n", + "\n", + " @abc.abstractmethod\n", + " def stop(self) -> None:\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "id": "c5eda1b0-60be-4a70-babe-7ae50446cbbc", + "metadata": {}, + "source": [ + "### CPU Usage Data Source\n", + "\n", + "The `CPU_Usage` class streams CPU usage data using the [`psutil`](https://psutil.readthedocs.io/)\n", + "library. This acts as a sort of simple test bench for us to stream some real data and make sure the\n", + "plotting application is working. Whenever `generate_data` is called, it will return a timestamped\n", + "measurement of CPU usage across your computer's cores." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a86fffe-5ffc-4612-ae81-9812b2ddc80a", + "metadata": {}, + "outputs": [], + "source": [ + "class CPU_Usage(DataSource):\n", + " def __init__(self, sampling_interval: float = 0.25, buffer_size: int = 5) -> None:\n", + " cpu_count = psutil.cpu_count(logical=True)\n", + " self.num_cores = int(cpu_count) if cpu_count is not None else 1\n", + "\n", + " self._sampling_interval = sampling_interval\n", + " self.streaming = False\n", + "\n", + " self.channel_names = [f\"CPU_{i}\" for i in range(self.num_cores)]\n", + " self._channel_positions = None # No physical positions for CPU cores\n", + " self.buffer_size = buffer_size\n", + "\n", + " @property\n", + " def sampling_interval(self) -> float:\n", + " return self._sampling_interval\n", + "\n", + " def get_channel_names(self) -> list[str]:\n", + " return self.channel_names\n", + "\n", + " def get_channel_positions(self) -> None:\n", + " return self._channel_positions\n", + "\n", + " def start(self) -> None:\n", + " self.streaming = True\n", + "\n", + " def stop(self) -> None:\n", + " self.streaming = False\n", + "\n", + " def generate_data(self) -> pd.DataFrame:\n", + " if not self.streaming:\n", + " return pd.DataFrame(columns=[\"time\"] + self.channel_names)\n", + "\n", + " cpu_percent = psutil.cpu_percent(percpu=True)\n", + " if cpu_percent:\n", + " timestamp = pd.Timestamp.now()\n", + " data = {\"time\": [timestamp]}\n", + " for ch, usage in zip(self.channel_names, cpu_percent):\n", + " if ch not in data:\n", + " data[ch] = []\n", + " data[ch].append(usage)\n", + " return pd.DataFrame(data)\n", + " else:\n", + " return pd.DataFrame(columns=[\"time\"] + self.channel_names)" + ] + }, + { + "cell_type": "markdown", + "id": "91126744-4d29-4ac4-8fe9-9c71737f6026", + "metadata": {}, + "source": [ + "### LSL File Stream Data Source\n", + "\n", + "The `LSL_EEG_File_Stream` class streams EEG data via a mock `LSL` live stream from a saved file,\n", + "using utilities from the `mne` and `mne_lsl` libraries.\n", + "\n", + "This class is a bit more involved than the last and requires us to handle the stream setup (start)\n", + "and teardown (stop) in a particular way, according to the\n", + "[PlayerLSL](https://mne.tools/mne-lsl/stable/generated/api/mne_lsl.player.PlayerLSL.html) and\n", + "[StreamLSL](https://mne.tools/mne-lsl/stable/generated/api/mne_lsl.stream.StreamLSL.html#mne_lsl.stream.StreamLSL)\n", + "docs. However, the idea is the same, when `generate_data` is called, it will return timestamped\n", + "dataframe of the next block of channel measurements." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7d83325a-e9db-4129-88e6-5eb697fecf4f", + "metadata": {}, + "outputs": [], + "source": [ + "class LSL_EEG_File_Stream(DataSource):\n", + " def __init__(self, fname: str, picks: str = \"eeg\") -> None:\n", + " # Generate unique identifier for this stream instance\n", + " self.source_id = uuid.uuid4().hex\n", + " self.fname = fname\n", + " self.name = f\"MNE-LSL-{self.source_id}\"\n", + "\n", + " self.chunk_size = 20\n", + "\n", + " self.player = PlayerLSL(\n", + " self.fname,\n", + " chunk_size=self.chunk_size,\n", + " source_id=self.source_id,\n", + " name=self.name,\n", + " )\n", + "\n", + " self.stream = StreamLSL(\n", + " bufsize=2,\n", + " name=self.name,\n", + " source_id=self.source_id,\n", + " )\n", + "\n", + " self._sampling_interval = 0.02 # 20ms update rate\n", + " self.streaming = False\n", + " self.picks = picks\n", + " self.reference = \"average\"\n", + "\n", + " # Set up channel names based on 'picks' input\n", + " if self.picks == \"eeg\":\n", + " ch_type_indices = mne.channel_indices_by_type(self.player.info)[\"eeg\"]\n", + " self.channel_names = [str(self.player.ch_names[i]) for i in ch_type_indices]\n", + " else:\n", + " self.channel_names = list(map(str, self.picks))\n", + "\n", + " # Get channel positions from standard 10-05 montage\n", + " montage = mne.channels.make_standard_montage(\"standard_1005\")\n", + " positions = montage.get_positions()[\"ch_pos\"]\n", + "\n", + " # Store positions for channels present in the montage\n", + " self.channel_positions = []\n", + " for ch in self.channel_names:\n", + " if ch in positions:\n", + " pos = positions[ch]\n", + " self.channel_positions.append(\n", + " {\n", + " \"xpos\": pos[0],\n", + " \"ypos\": pos[1],\n", + " \"ch\": ch,\n", + " }\n", + " )\n", + "\n", + " def get_channel_names(self) -> list[str]:\n", + " return self.channel_names\n", + "\n", + " def get_channel_positions(self) -> list[dict]:\n", + " return self.channel_positions\n", + "\n", + " @property\n", + " def sampling_interval(self) -> float:\n", + " return self._sampling_interval\n", + "\n", + " def start(self) -> None:\n", + " if not self.streaming:\n", + " self.player.start()\n", + " # Allow time for stream initialization\n", + " time.sleep(0.1)\n", + "\n", + " try:\n", + " self.stream.connect(timeout=5.0)\n", + " except RuntimeError as e:\n", + " print(f\"Failed to connect to LSL stream: {e}\")\n", + " self.player.stop()\n", + " raise\n", + "\n", + " self.stream.pick(self.picks)\n", + " if self.reference:\n", + " self.stream.set_eeg_reference(self.reference)\n", + " self.streaming = True\n", + "\n", + " def stop(self) -> None:\n", + " if self.streaming:\n", + " self.stream.disconnect()\n", + " self.player.stop()\n", + " self.streaming = False\n", + "\n", + " def generate_data(self) -> pd.DataFrame:\n", + " if not self.streaming:\n", + " return pd.DataFrame(columns=[\"time\"] + self.channel_names)\n", + "\n", + " # Collect all new samples since last call\n", + " data, ts = self.stream.get_data(\n", + " self.stream.n_new_samples / self.stream.info[\"sfreq\"],\n", + " picks=self.channel_names,\n", + " )\n", + "\n", + " if data.size > 0:\n", + " # Convert timestamps to pandas\n", + " ts = pd.to_datetime(ts, unit=\"s\")\n", + " new_data = pd.DataFrame({\"time\": ts})\n", + " for i, ch in enumerate(self.channel_names):\n", + " new_data[ch] = data[i]\n", + " return new_data\n", + " else:\n", + " return pd.DataFrame(columns=[\"time\"] + self.channel_names)" + ] + }, + { + "cell_type": "markdown", + "id": "e8c4104b-0e12-40e3-a647-df4b5a16d930", + "metadata": {}, + "source": [ + "## Building the Streaming Application\n", + "\n", + "The `StreamingApp` class below handles the user interface and streaming logic, integrating the data\n", + "sources with interactive controls. Comments are included for each part of the code that might need\n", + "additional context.\n", + "\n", + "Before diving into the code, let's recap and provide updated context for some key streaming implementation aspects. In short:\n", + "\n", + "1. `hv.streams.Buffer` collects and manages incoming data\n", + "2. `hv.DynamicMap` creates a dynamic visualization based on the buffer\n", + "3. `pn.state.add_periodic_callback` schedules regular data updates\n", + "4. `pn.io.unlocked` ensures these updates happen without interrupting the user experience\n", + "\n", + "### `hv.streams.Buffer`\n", + "The **hv.streams.Buffer** is a specialized stream designed for efficiently handling streaming data. It acts as a circular buffer, accumulating incoming data up to a specified length. Think of it as a sliding window for your data that automatically manages memory and keeps only the most recent information. This makes it ideal for real-time applications where you need to maintain only the most recent data while discarding older entries. In this application, the `Buffer` is initialized with an empty `DataFrame` and then stores the latest samples of streamed data, providing this data to the `hv.DynamicMap` for real-time plotting. This ensures memory efficiency and performance stability, even with continuous data streams.\n", + "- **Why it’s used here:** To manage and store recent data for real-time visualization while discarding older data to maintain performance.\n", + "\n", + "### `hv.DynamicMap`\n", + "`hv.DynamicMap` is a core feature in HoloViews that facilitates dynamic and interactive visualizations. It takes a callable (like a function or method) and a set of streams (data sources) as inputs. When the data in the streams changes, the callable is re-executed with the updated data, generating an update within the plot (rather than the entire plot). This is particularly useful for applications that require real-time updates, as it allows plots to refresh automatically as new data arrives. In our application, `hv.DynamicMap` links the streamed data from `hv.streams.Buffer` to the plotting logic, ensuring that visualizations remain current with minimal performance overhead. For more details, refer to the [HoloViews DynamicMap documentation](https://holoviews.org/user_guide/Streaming_Data.html).\n", + "- **Why it’s used here:** It enables responsive, efficient visualization of live data streams with minimal overhead.\n", + "\n", + "### `pn.state.add_periodic_callback`\n", + "`pn.state.add_periodic_callback` is a utility in Panel that allows you to schedule functions to run at regular intervals. This feature is essential for applications that need to update visualizations continuously without user interaction. In our `StreamingApp` below, this function is used within the `start_stream` method to periodically fetch new data from the active source and update the stream. The parameters include `callback`, which specifies the function to run, and `period`, which defines how often it runs (in milliseconds). This setup ensures that the application remains synchronized with live data updates. The returned `PeriodicCallback` object provides control methods like `start` and `stop` for dynamic user interaction.\n", + "- **Why it’s used here:** To automate and schedule periodic updates, ensuring that the visualization remains synchronized with the latest data.\n", + "\n", + "### `pn.io.unlocked`\n", + "The `pn.io.unlocked` context manager allows for temporary unlocking of the Bokeh Document lock, enabling updates to the UI during data processing. Without this, frequent updates might freeze the application or cause deadlocks, especially during heavy computation or rapid data updates. In this application, `pn.io.unlocked` is used in the `stream_data` method to ensure that updates to the `hv.streams.Buffer` are safely applied without blocking the main application. This ensures responsiveness and smooth operation of the user interface during streaming.\n", + "- **Why it’s used here:** To prevent application deadlocks and maintain responsiveness during frequent updates.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7845800c-84ba-461f-8f78-5e2bac9b9180", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "class StreamingApp(pn.viewable.Viewer):\n", + " data_sources = param.List(doc=\"List of available data sources.\")\n", + " notebook = param.Boolean(default=True, doc=\"Flag to determine if running in a notebook.\")\n", + " buffer_length = param.Integer(default=5000, bounds=(1, None), doc=\"Length of the streaming buffer in samples.\")\n", + " \n", + " def __init__(self, **params):\n", + " super().__init__(**params) \n", + "\n", + " # Used to normalize timestamps relative to stream start\n", + " self.initial_time = None\n", + "\n", + " # Create mappings for the data source selector dropdown\n", + " self.data_source_instances = {type(ds).__name__: ds for ds in self.data_sources}\n", + " self.data_source_names = list(self.data_source_instances.keys())\n", + " self.data_source = self.data_source_names[0]\n", + "\n", + " # Sets up channel names and their color mapping based on the selected data source\n", + " self.update_channel_names()\n", + "\n", + " # Circular buffer\n", + " self.buffer = Buffer(data=self.initial_data(), length=self.buffer_length)\n", + "\n", + " self.streaming = False\n", + " self.paused = False\n", + " self.task = None # Will store the periodic callback for data updates\n", + " self.data_generator = None # Active data source instance\n", + "\n", + " self.create_widgets()\n", + " self.create_layout()\n", + "\n", + " def update_channel_names(self) -> None:\n", + " data_source_instance = self.data_source_instances[self.data_source]\n", + " self.channel_names = data_source_instance.get_channel_names()\n", + "\n", + " palette = Category20[20]\n", + " self.color_mapping = {\n", + " channel_name: palette[i]\n", + " for i, channel_name in enumerate(self.channel_names)\n", + " }\n", + "\n", + " self.current_sampling_interval = data_source_instance.sampling_interval\n", + "\n", + " # Reset buffer when channel configuration changes\n", + " if hasattr(self, \"buffer\"):\n", + " self.buffer.clear()\n", + " self.buffer.data = self.initial_data()\n", + "\n", + " def initial_data(self) -> pd.DataFrame:\n", + " # Create empty DataFrame with time and channel columns\n", + " return pd.DataFrame({\"time\": [], **{ch: [] for ch in self.channel_names}})\n", + "\n", + " def create_widgets(self) -> None:\n", + " # Data source selection dropdown\n", + " self.data_source_widget = pn.widgets.Select(\n", + " name=\"Data Source\",\n", + " options=self.data_source_names,\n", + " value=self.data_source,\n", + " )\n", + " self.data_source_widget.param.watch(self.on_data_source_change, \"value\")\n", + "\n", + " # Stream control radio buttons with custom styling for active state\n", + " self.radio_group = pn.widgets.RadioButtonGroup(\n", + " name=\"Stream Control\",\n", + " options=[\"Start\", \"Pause\", \"Stop\"],\n", + " value=\"Stop\",\n", + " button_type=\"default\",\n", + " sizing_mode=\"stretch_width\",\n", + " stylesheets=[\n", + " \"\"\"\n", + " :host(.solid) .bk-btn.bk-btn-default.bk-active {\n", + " background-color: #b23c3c;\n", + " }\n", + " \"\"\"\n", + " ],\n", + " )\n", + " self.radio_group.param.watch(self.handle_state_change, \"value\")\n", + "\n", + " def on_data_source_change(self, event) -> None:\n", + " # Stop any existing stream before switching data sources\n", + " time.sleep(0.1)\n", + " self.stop_stream()\n", + " self.data_source = event.new\n", + " self.update_channel_names()\n", + "\n", + " # Reset visualization with empty plots\n", + " self.main_streaming_pane.object = self.bare_stream_plot()\n", + " self.position_pane.object = self.bare_pos_plot()\n", + "\n", + " def handle_state_change(self, event) -> None:\n", + " # Route radio button selections to appropriate stream control methods\n", + " if event.new == \"Start\":\n", + " self.start_stream()\n", + " elif event.new == \"Pause\":\n", + " self.pause_stream()\n", + " elif event.new == \"Stop\":\n", + " self.stop_stream()\n", + "\n", + " def create_layout(self) -> None:\n", + " # Initialize main visualization panes with empty plots\n", + " self.position_pane = pn.pane.HoloViews(self.bare_pos_plot())\n", + " self.main_streaming_pane = pn.pane.HoloViews(self.bare_stream_plot())\n", + "\n", + " if self.notebook:\n", + " # Notebook layout: Sidebar and main plot side by side\n", + " self.layout = pn.Row(\n", + " pn.Column(\n", + " self.data_source_widget,\n", + " self.radio_group,\n", + " self.position_pane,\n", + " width=300,\n", + " ),\n", + " self.main_streaming_pane,\n", + " align=\"start\",\n", + " )\n", + " else:\n", + " # Standalone app layout: widgets in sidebar\n", + " sidebar = pn.Column(\n", + " pn.WidgetBox(\n", + " self.data_source_widget,\n", + " self.radio_group,\n", + " ),\n", + " self.position_pane,\n", + " sizing_mode=\"stretch_width\",\n", + " )\n", + " self.template = pn.template.FastListTemplate(\n", + " main=[self.main_streaming_pane],\n", + " sidebar=[sidebar],\n", + " title=\"Multi-Channel Streaming App\",\n", + " theme=\"dark\",\n", + " accent=\"#2e008b\",\n", + " )\n", + "\n", + " def start_stream(self) -> None:\n", + " if not self.streaming:\n", + " # Use context manager to show loading state while setting up stream\n", + " with self.main_streaming_pane.param.update(loading=True):\n", + " self.streaming = True\n", + " self.paused = False\n", + "\n", + " # Initialize data generator and viz\n", + " self.data_generator = self.data_source_instances[self.data_source_widget.value]\n", + " self.position_pane.object = self.create_position_plot()\n", + " self.data_generator.start()\n", + " self.buffer.clear()\n", + " self.initial_time = None # Reset initial time when starting new stream\n", + "\n", + " # Set up dynamic plotting with the buffer as the data stream\n", + " self.main_streaming_pane.object = hv.DynamicMap(\n", + " self.create_streaming_plot,\n", + " streams=[self.buffer],\n", + " )\n", + "\n", + " # Start periodic data collection\n", + " sampling_interval_ms = int(self.data_generator.sampling_interval * 100)\n", + " self.task = pn.state.add_periodic_callback(\n", + " self.stream_data,\n", + " period=sampling_interval_ms,\n", + " count=None,\n", + " )\n", + "\n", + " # Resume from paused state\n", + " elif self.streaming and self.paused:\n", + " self.paused = False\n", + " if self.task is None:\n", + " sampling_interval = 1 / 60\n", + " if self.data_generator is not None:\n", + " sampling_interval = self.data_generator.sampling_interval\n", + " sampling_interval_ms = int(sampling_interval * 100)\n", + " self.task = pn.state.add_periodic_callback(\n", + " self.stream_data,\n", + " period=sampling_interval_ms,\n", + " count=None,\n", + " )\n", + "\n", + " def pause_stream(self) -> None:\n", + " if self.task:\n", + " self.task.stop()\n", + " self.task = None\n", + " self.paused = True\n", + "\n", + " def stop_stream(self) -> None:\n", + " if self.streaming:\n", + " # Clean up streaming resources\n", + " self.streaming = False\n", + " self.paused = False\n", + " if self.task:\n", + " self.task.stop()\n", + " self.task = None\n", + " if self.data_generator is not None:\n", + " self.data_generator.stop()\n", + "\n", + " # Reset UI and internal state\n", + " self.radio_group.value = \"Stop\"\n", + " self.main_streaming_pane.object = self.bare_stream_plot()\n", + " self.buffer.clear()\n", + " self.buffer.data = self.initial_data()\n", + " self.data_generator = None\n", + " self.initial_time = None\n", + "\n", + " def stream_data(self) -> None:\n", + " if not self.streaming or self.paused:\n", + " return\n", + " # Panel's unlocked context manager prevents callback deadlocks\n", + " with pn.io.unlocked():\n", + " new_data_df = pd.DataFrame()\n", + " if self.data_generator is not None:\n", + " new_data_df = self.data_generator.generate_data()\n", + " if not new_data_df.empty:\n", + " self.buffer.send(new_data_df)\n", + "\n", + " def create_streaming_plot(self, data) -> hv.NdOverlay:\n", + " overlays = {}\n", + "\n", + " if not data.empty and \"time\" in data.columns:\n", + " # Store first timestamp to normalize all times relative to stream start\n", + " if self.initial_time is None:\n", + " self.initial_time = data[\"time\"].iloc[0]\n", + "\n", + " data = data.copy() # Create copy to avoid modifying the buffer's data\n", + "\n", + " # Convert 'time' to numerical seconds\n", + " data[\"time\"] = (data[\"time\"] - self.initial_time).dt.total_seconds()\n", + "\n", + " # Create a curve for each channel's data\n", + " for ch in self.channel_names:\n", + " if ch in data.columns and not data[ch].dropna().empty:\n", + " curve = hv.Curve(\n", + " (data[\"time\"], data[ch]),\n", + " \"Time (s)\",\n", + " \"Amplitude\",\n", + " label=ch,\n", + " # ylim=(0, 100),\n", + " ).opts(\n", + " line_width=2,\n", + " color=self.color_mapping.get(ch, None),\n", + " subcoordinate_y=True,\n", + " )\n", + " overlays[ch] = curve\n", + "\n", + " if overlays:\n", + " ndoverlay = hv.NdOverlay(overlays).opts(\n", + " show_legend=False,\n", + " responsive=True,\n", + " min_height=600,\n", + " framewise=True,\n", + " title=\"Data Stream\",\n", + " xlabel=\"Time (s)\",\n", + " ylabel=\"Amplitude\",\n", + " )\n", + " if \"cpu\" in self.data_source.lower():\n", + " ndoverlay.opts(\"Curve\", ylim=(0, 100))\n", + " return ndoverlay\n", + " else:\n", + " return self.empty_stream_plot()\n", + "\n", + " def create_position_plot(self) -> hv.Points | hv.Overlay:\n", + " \"\"\"\n", + " Creates a position plot for the EEG data source.\n", + "\n", + " This plot represents the spatial positions of electrode sensors on the \n", + " subject's scalp. Each labeled electrode is displayed at its corresponding \n", + " position, providing a visual reference.\n", + " \"\"\"\n", + " channel_positions = []\n", + " if self.data_generator is not None:\n", + " channel_positions = self.data_generator.get_channel_positions()\n", + " if channel_positions:\n", + " # channel positions and colors\n", + " df = pd.DataFrame()\n", + " if self.data_generator is not None:\n", + " df = pd.DataFrame(self.data_generator.channel_positions)\n", + " df[\"clr\"] = df[\"ch\"].map(self.color_mapping)\n", + "\n", + " points = hv.Points(df, [\"xpos\", \"ypos\"], vdims=[\"ch\", \"clr\"]).opts(\n", + " color=\"clr\",\n", + " size=20,\n", + " alpha=0.5,\n", + " tools=[\"hover\"],\n", + " marker=\"circle\",\n", + " )\n", + "\n", + " labels = hv.Labels(df, [\"xpos\", \"ypos\"], \"ch\").opts(\n", + " text_color=\"black\",\n", + " text_font_size=\"8pt\",\n", + " )\n", + "\n", + " plot = (points * labels).opts(\n", + " xaxis=None,\n", + " yaxis=None,\n", + " axiswise=True,\n", + " height=300,\n", + " responsive=True,\n", + " shared_axes=False,\n", + " title=\"Channel Position\",\n", + " )\n", + " return plot\n", + " return self.bare_pos_plot()\n", + "\n", + " def bare_stream_plot(self, min_height: int = 600) -> hv.Curve:\n", + " curve = hv.Curve([]).opts(\n", + " yaxis=\"bare\",\n", + " xaxis=\"bare\",\n", + " min_height=min_height,\n", + " responsive=True,\n", + " )\n", + " return curve\n", + "\n", + " def empty_stream_plot(self) -> hv.NdOverlay:\n", + " empty_curves = {\n", + " ch: hv.Curve([]).relabel(ch).opts(\n", + " subcoordinate_y=True,\n", + " color=self.color_mapping.get(ch, None),\n", + " )\n", + " for ch in self.channel_names\n", + " }\n", + " ndoverlay = hv.NdOverlay(empty_curves).opts(\n", + " legend_position=\"right\",\n", + " responsive=True,\n", + " min_height=600,\n", + " title=\"\",\n", + " show_legend=False,\n", + " )\n", + " ndoverlay.opts(\"Curve\", tools=[\"hover\"])\n", + " return ndoverlay\n", + "\n", + " def bare_pos_plot(self) -> hv.Points:\n", + " points = hv.Points([]).opts(\n", + " xaxis=None,\n", + " yaxis=None,\n", + " axiswise=True,\n", + " height=300,\n", + " responsive=True,\n", + " shared_axes=False,\n", + " title=\"Channel Position\",\n", + " )\n", + " return points\n", + "\n", + " def __panel__(self) -> pn.Row | pn.Column:\n", + " if self.notebook:\n", + " return self.layout\n", + " else:\n", + " return self.template" + ] + }, + { + "cell_type": "markdown", + "id": "4915afc7-61b4-45a6-ae07-30e40d5a676b", + "metadata": {}, + "source": [ + "### Running the Application\n", + "\n", + "Instantiate the data sources and create the application." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9455b513-fc28-479d-b784-6be45c1dc693", + "metadata": {}, + "outputs": [], + "source": [ + "data_source_cpu_usage = CPU_Usage()\n", + "\n", + "# display a subset of eeg channels with 'picks'\n", + "picks = [\"F4\", \"F7\", \"FC5\", \"FC2\", \"T7\", \"CP2\", \"CP6\", \"P4\", \"P7\", \"O1\", \"O2\"]\n", + "data_source_eeg_usage = LSL_EEG_File_Stream(\n", + " \"data/sample-ant-raw.fif\",\n", + " picks=picks,\n", + ")\n", + "\n", + "nb_app = StreamingApp(data_sources=[data_source_cpu_usage, data_source_eeg_usage])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d8d3d847-b6aa-464c-8583-80d664fc8f79", + "metadata": {}, + "outputs": [], + "source": [ + "nb_app" + ] + }, + { + "cell_type": "markdown", + "id": "8ae343f3-9f45-4d32-9127-e2a5fc05a252", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "\n", + "\n", + "**Here's a static snapshot of what the previous cell produces in the notebook when streaming from LSL data source. 👉**\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "99b54465-17b0-4547-bd17-3bb67db8394a", + "metadata": {}, + "source": [ + "## Using the Application\n", + "\n", + "- **Select Data Source:** Use the dropdown to select data source.\n", + "- **Control Streaming:**\n", + " - Click **Start** to begin streaming data from the selected source.\n", + " - Click **Pause** to temporarily halt data updates without stopping the stream.\n", + " - Click **Stop** to stop data updates and the data source itself.\n", + "- **Switching Data Sources:**\n", + " - When you select a different data source, the app automatically stops the current data source\n", + " before starting the new one.\n", + "\n", + "## Standalone App Extension\n", + "\n", + "HoloViz Panel allows for the deployment of this complex visualization as a standalone,\n", + "template-styled, interactive web application (outside of a Jupyter Notebook). Read more about Panel\n", + "[here](https://panel.holoviz.org/).\n", + "\n", + "In the class above, we created a condition when `notebook=False` that adds our plot to the `main`\n", + "area of a Panel Template component and puts the widgets in the template's `sidebar`. All we need to\n", + "do now is create an instance and mark it as `servable`.\n", + "\n", + "To launch the standalone app, activate the same conda environment and run\n", + "`panel serve --show` in the command line. This will open the application in a\n", + "browser window.\n", + "\n", + "
\n", + "

Info

\n", + "

\n", + " In some rare cases we have observed issues running silmutaneously the notebook and\n", + " the served versions of the application. If you observe such an issue, prior to serving\n", + " the standalone application, restart the notebook kernel.\n", + "

\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "abac38e5-ac5d-475c-98d9-5d8d1a26929f", + "metadata": {}, + "outputs": [], + "source": [ + "standalone_app = StreamingApp(\n", + " data_sources=[data_source_eeg_usage, data_source_cpu_usage],\n", + " notebook=False,\n", + ")\n", + "\n", + "\n", + "standalone_app.servable(); # semi-colon to suppress output in notebook" + ] + }, + { + "cell_type": "markdown", + "id": "4c0c2a8d-dda0-47ce-a94c-e46dd5fe71d6", + "metadata": {}, + "source": [ + "\n", + "\n", + "**Here's a static snapshot of what the previous cell produces in a browser window when you serve the\n", + "standalone app (a templated Panel application). 👉**\n", + "\n", + "
\n", + "\n", + "## Conclusion\n", + "\n", + "We've built a real-time multichannel streaming application that can handle different data sources,\n", + "including EEG data and CPU usage and that can be extended and customized for various real-time data\n", + "streaming needs.\n", + "\n", + "## What Next?\n", + "\n", + "- Customization: Modify the application to include additional data sources or customize the\n", + " visualization options.\n", + "- Data Analysis: Extend the application to include data analysis features such as filtering, feature\n", + " extraction, or event detection.\n", + "\n", + "## Related Resources\n", + "\n", + "| What? | Why? |\n", + "| ---------------------------------------------------- | ------------------------------------------------------ |\n", + "|[MNE-Python Docs](https://mne.tools/stable/index.html)| For more information on EEG data handling and analysis |" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/streaming_timeseries/thumbnails/streaming_timeseries.png b/streaming_timeseries/thumbnails/streaming_timeseries.png new file mode 100644 index 000000000..a83748938 Binary files /dev/null and b/streaming_timeseries/thumbnails/streaming_timeseries.png differ diff --git a/test_data/streaming_timeseries/sample-ant-raw.fif b/test_data/streaming_timeseries/sample-ant-raw.fif new file mode 100644 index 000000000..5e0d09b95 Binary files /dev/null and b/test_data/streaming_timeseries/sample-ant-raw.fif differ