{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Lesson 31. Apps for controlling external devices\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [ { "data": { "text/html": [ "\n", "
\n", " \n", " Loading BokehJS ...\n", "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/javascript": [ "\n", "(function(root) {\n", " function now() {\n", " return new Date();\n", " }\n", "\n", " const force = true;\n", "\n", " if (typeof root._bokeh_onload_callbacks === \"undefined\" || force === true) {\n", " root._bokeh_onload_callbacks = [];\n", " root._bokeh_is_loading = undefined;\n", " }\n", "\n", " const JS_MIME_TYPE = 'application/javascript';\n", " const HTML_MIME_TYPE = 'text/html';\n", " const EXEC_MIME_TYPE = 'application/vnd.bokehjs_exec.v0+json';\n", " const CLASS_NAME = 'output_bokeh rendered_html';\n", "\n", " /**\n", " * Render data to the DOM node\n", " */\n", " function render(props, node) {\n", " const script = document.createElement(\"script\");\n", " node.appendChild(script);\n", " }\n", "\n", " /**\n", " * Handle when an output is cleared or removed\n", " */\n", " function handleClearOutput(event, handle) {\n", " const cell = handle.cell;\n", "\n", " const id = cell.output_area._bokeh_element_id;\n", " const server_id = cell.output_area._bokeh_server_id;\n", " // Clean up Bokeh references\n", " if (id != null && id in Bokeh.index) {\n", " Bokeh.index[id].model.document.clear();\n", " delete Bokeh.index[id];\n", " }\n", "\n", " if (server_id !== undefined) {\n", " // Clean up Bokeh references\n", " const cmd_clean = \"from bokeh.io.state import curstate; print(curstate().uuid_to_server['\" + server_id + \"'].get_sessions()[0].document.roots[0]._id)\";\n", " cell.notebook.kernel.execute(cmd_clean, {\n", " iopub: {\n", " output: function(msg) {\n", " const id = msg.content.text.trim();\n", " if (id in Bokeh.index) {\n", " Bokeh.index[id].model.document.clear();\n", " delete Bokeh.index[id];\n", " }\n", " }\n", " }\n", " });\n", " // Destroy server and session\n", " const cmd_destroy = \"import bokeh.io.notebook as ion; ion.destroy_server('\" + server_id + \"')\";\n", " cell.notebook.kernel.execute(cmd_destroy);\n", " }\n", " }\n", "\n", " /**\n", " * Handle when a new output is added\n", " */\n", " function handleAddOutput(event, handle) {\n", " const output_area = handle.output_area;\n", " const output = handle.output;\n", "\n", " // limit handleAddOutput to display_data with EXEC_MIME_TYPE content only\n", " if ((output.output_type != \"display_data\") || (!Object.prototype.hasOwnProperty.call(output.data, EXEC_MIME_TYPE))) {\n", " return\n", " }\n", "\n", " const toinsert = output_area.element.find(\".\" + CLASS_NAME.split(' ')[0]);\n", "\n", " if (output.metadata[EXEC_MIME_TYPE][\"id\"] !== undefined) {\n", " toinsert[toinsert.length - 1].firstChild.textContent = output.data[JS_MIME_TYPE];\n", " // store reference to embed id on output_area\n", " output_area._bokeh_element_id = output.metadata[EXEC_MIME_TYPE][\"id\"];\n", " }\n", " if (output.metadata[EXEC_MIME_TYPE][\"server_id\"] !== undefined) {\n", " const bk_div = document.createElement(\"div\");\n", " bk_div.innerHTML = output.data[HTML_MIME_TYPE];\n", " const script_attrs = bk_div.children[0].attributes;\n", " for (let i = 0; i < script_attrs.length; i++) {\n", " toinsert[toinsert.length - 1].firstChild.setAttribute(script_attrs[i].name, script_attrs[i].value);\n", " toinsert[toinsert.length - 1].firstChild.textContent = bk_div.children[0].textContent\n", " }\n", " // store reference to server id on output_area\n", " output_area._bokeh_server_id = output.metadata[EXEC_MIME_TYPE][\"server_id\"];\n", " }\n", " }\n", "\n", " function register_renderer(events, OutputArea) {\n", "\n", " function append_mime(data, metadata, element) {\n", " // create a DOM node to render to\n", " const toinsert = this.create_output_subarea(\n", " metadata,\n", " CLASS_NAME,\n", " EXEC_MIME_TYPE\n", " );\n", " this.keyboard_manager.register_events(toinsert);\n", " // Render to node\n", " const props = {data: data, metadata: metadata[EXEC_MIME_TYPE]};\n", " render(props, toinsert[toinsert.length - 1]);\n", " element.append(toinsert);\n", " return toinsert\n", " }\n", "\n", " /* Handle when an output is cleared or removed */\n", " events.on('clear_output.CodeCell', handleClearOutput);\n", " events.on('delete.Cell', handleClearOutput);\n", "\n", " /* Handle when a new output is added */\n", " events.on('output_added.OutputArea', handleAddOutput);\n", "\n", " /**\n", " * Register the mime type and append_mime function with output_area\n", " */\n", " OutputArea.prototype.register_mime_type(EXEC_MIME_TYPE, append_mime, {\n", " /* Is output safe? */\n", " safe: true,\n", " /* Index of renderer in `output_area.display_order` */\n", " index: 0\n", " });\n", " }\n", "\n", " // register the mime type if in Jupyter Notebook environment and previously unregistered\n", " if (root.Jupyter !== undefined) {\n", " const events = require('base/js/events');\n", " const OutputArea = require('notebook/js/outputarea').OutputArea;\n", "\n", " if (OutputArea.prototype.mime_types().indexOf(EXEC_MIME_TYPE) == -1) {\n", " register_renderer(events, OutputArea);\n", " }\n", " }\n", "\n", " \n", " if (typeof (root._bokeh_timeout) === \"undefined\" || force === true) {\n", " root._bokeh_timeout = Date.now() + 5000;\n", " root._bokeh_failed_load = false;\n", " }\n", "\n", " const NB_LOAD_WARNING = {'data': {'text/html':\n", " \"
\\n\"+\n", " \"

\\n\"+\n", " \"BokehJS does not appear to have successfully loaded. If loading BokehJS from CDN, this \\n\"+\n", " \"may be due to a slow or bad network connection. Possible fixes:\\n\"+\n", " \"

\\n\"+\n", " \"\\n\"+\n", " \"\\n\"+\n", " \"from bokeh.resources import INLINE\\n\"+\n", " \"output_notebook(resources=INLINE)\\n\"+\n", " \"\\n\"+\n", " \"
\"}};\n", "\n", " function display_loaded() {\n", " const el = document.getElementById(\"1002\");\n", " if (el != null) {\n", " el.textContent = \"BokehJS is loading...\";\n", " }\n", " if (root.Bokeh !== undefined) {\n", " if (el != null) {\n", " el.textContent = \"BokehJS \" + root.Bokeh.version + \" successfully loaded.\";\n", " }\n", " } else if (Date.now() < root._bokeh_timeout) {\n", " setTimeout(display_loaded, 100)\n", " }\n", " }\n", "\n", "\n", " function run_callbacks() {\n", " try {\n", " root._bokeh_onload_callbacks.forEach(function(callback) {\n", " if (callback != null)\n", " callback();\n", " });\n", " } finally {\n", " delete root._bokeh_onload_callbacks\n", " }\n", " console.debug(\"Bokeh: all callbacks have finished\");\n", " }\n", "\n", " function load_libs(css_urls, js_urls, callback) {\n", " if (css_urls == null) css_urls = [];\n", " if (js_urls == null) js_urls = [];\n", "\n", " root._bokeh_onload_callbacks.push(callback);\n", " if (root._bokeh_is_loading > 0) {\n", " console.debug(\"Bokeh: BokehJS is being loaded, scheduling callback at\", now());\n", " return null;\n", " }\n", " if (js_urls == null || js_urls.length === 0) {\n", " run_callbacks();\n", " return null;\n", " }\n", " console.debug(\"Bokeh: BokehJS not loaded, scheduling load and callback at\", now());\n", " root._bokeh_is_loading = css_urls.length + js_urls.length;\n", "\n", " function on_load() {\n", " root._bokeh_is_loading--;\n", " if (root._bokeh_is_loading === 0) {\n", " console.debug(\"Bokeh: all BokehJS libraries/stylesheets loaded\");\n", " run_callbacks()\n", " }\n", " }\n", "\n", " function on_error(url) {\n", " console.error(\"failed to load \" + url);\n", " }\n", "\n", " for (let i = 0; i < css_urls.length; i++) {\n", " const url = css_urls[i];\n", " const element = document.createElement(\"link\");\n", " element.onload = on_load;\n", " element.onerror = on_error.bind(null, url);\n", " element.rel = \"stylesheet\";\n", " element.type = \"text/css\";\n", " element.href = url;\n", " console.debug(\"Bokeh: injecting link tag for BokehJS stylesheet: \", url);\n", " document.body.appendChild(element);\n", " }\n", "\n", " for (let i = 0; i < js_urls.length; i++) {\n", " const url = js_urls[i];\n", " const element = document.createElement('script');\n", " element.onload = on_load;\n", " element.onerror = on_error.bind(null, url);\n", " element.async = false;\n", " element.src = url;\n", " console.debug(\"Bokeh: injecting script tag for BokehJS library: \", url);\n", " document.head.appendChild(element);\n", " }\n", " };\n", "\n", " function inject_raw_css(css) {\n", " const element = document.createElement(\"style\");\n", " element.appendChild(document.createTextNode(css));\n", " document.body.appendChild(element);\n", " }\n", "\n", " \n", " const js_urls = [\"https://cdn.bokeh.org/bokeh/release/bokeh-2.4.2.min.js\", \"https://cdn.bokeh.org/bokeh/release/bokeh-gl-2.4.2.min.js\", \"https://cdn.bokeh.org/bokeh/release/bokeh-widgets-2.4.2.min.js\", \"https://cdn.bokeh.org/bokeh/release/bokeh-tables-2.4.2.min.js\", \"https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-2.4.2.min.js\"];\n", " const css_urls = [];\n", " \n", "\n", " const inline_js = [\n", " function(Bokeh) {\n", " Bokeh.set_log_level(\"info\");\n", " },\n", " function(Bokeh) {\n", " \n", " \n", " }\n", " ];\n", "\n", " function run_inline_js() {\n", " \n", " if (root.Bokeh !== undefined || force === true) {\n", " \n", " for (let i = 0; i < inline_js.length; i++) {\n", " inline_js[i].call(root, root.Bokeh);\n", " }\n", " if (force === true) {\n", " display_loaded();\n", " }} else if (Date.now() < root._bokeh_timeout) {\n", " setTimeout(run_inline_js, 100);\n", " } else if (!root._bokeh_failed_load) {\n", " console.log(\"Bokeh: BokehJS failed to load within specified timeout.\");\n", " root._bokeh_failed_load = true;\n", " } else if (force !== true) {\n", " const cell = $(document.getElementById(\"1002\")).parents('.cell').data().cell;\n", " cell.output_area.append_execute_result(NB_LOAD_WARNING)\n", " }\n", "\n", " }\n", "\n", " if (root._bokeh_is_loading === 0) {\n", " console.debug(\"Bokeh: BokehJS loaded, going straight to plotting\");\n", " run_inline_js();\n", " } else {\n", " load_libs(css_urls, js_urls, function() {\n", " console.debug(\"Bokeh: BokehJS plotting callback run at\", now());\n", " run_inline_js();\n", " });\n", " }\n", "}(window));" ], "application/vnd.bokehjs_load.v0+json": "\n(function(root) {\n function now() {\n return new Date();\n }\n\n const force = true;\n\n if (typeof root._bokeh_onload_callbacks === \"undefined\" || force === true) {\n root._bokeh_onload_callbacks = [];\n root._bokeh_is_loading = undefined;\n }\n\n \n\n \n if (typeof (root._bokeh_timeout) === \"undefined\" || force === true) {\n root._bokeh_timeout = Date.now() + 5000;\n root._bokeh_failed_load = false;\n }\n\n const NB_LOAD_WARNING = {'data': {'text/html':\n \"
\\n\"+\n \"

\\n\"+\n \"BokehJS does not appear to have successfully loaded. If loading BokehJS from CDN, this \\n\"+\n \"may be due to a slow or bad network connection. Possible fixes:\\n\"+\n \"

\\n\"+\n \"\\n\"+\n \"\\n\"+\n \"from bokeh.resources import INLINE\\n\"+\n \"output_notebook(resources=INLINE)\\n\"+\n \"\\n\"+\n \"
\"}};\n\n function display_loaded() {\n const el = document.getElementById(\"1002\");\n if (el != null) {\n el.textContent = \"BokehJS is loading...\";\n }\n if (root.Bokeh !== undefined) {\n if (el != null) {\n el.textContent = \"BokehJS \" + root.Bokeh.version + \" successfully loaded.\";\n }\n } else if (Date.now() < root._bokeh_timeout) {\n setTimeout(display_loaded, 100)\n }\n }\n\n\n function run_callbacks() {\n try {\n root._bokeh_onload_callbacks.forEach(function(callback) {\n if (callback != null)\n callback();\n });\n } finally {\n delete root._bokeh_onload_callbacks\n }\n console.debug(\"Bokeh: all callbacks have finished\");\n }\n\n function load_libs(css_urls, js_urls, callback) {\n if (css_urls == null) css_urls = [];\n if (js_urls == null) js_urls = [];\n\n root._bokeh_onload_callbacks.push(callback);\n if (root._bokeh_is_loading > 0) {\n console.debug(\"Bokeh: BokehJS is being loaded, scheduling callback at\", now());\n return null;\n }\n if (js_urls == null || js_urls.length === 0) {\n run_callbacks();\n return null;\n }\n console.debug(\"Bokeh: BokehJS not loaded, scheduling load and callback at\", now());\n root._bokeh_is_loading = css_urls.length + js_urls.length;\n\n function on_load() {\n root._bokeh_is_loading--;\n if (root._bokeh_is_loading === 0) {\n console.debug(\"Bokeh: all BokehJS libraries/stylesheets loaded\");\n run_callbacks()\n }\n }\n\n function on_error(url) {\n console.error(\"failed to load \" + url);\n }\n\n for (let i = 0; i < css_urls.length; i++) {\n const url = css_urls[i];\n const element = document.createElement(\"link\");\n element.onload = on_load;\n element.onerror = on_error.bind(null, url);\n element.rel = \"stylesheet\";\n element.type = \"text/css\";\n element.href = url;\n console.debug(\"Bokeh: injecting link tag for BokehJS stylesheet: \", url);\n document.body.appendChild(element);\n }\n\n for (let i = 0; i < js_urls.length; i++) {\n const url = js_urls[i];\n const element = document.createElement('script');\n element.onload = on_load;\n element.onerror = on_error.bind(null, url);\n element.async = false;\n element.src = url;\n console.debug(\"Bokeh: injecting script tag for BokehJS library: \", url);\n document.head.appendChild(element);\n }\n };\n\n function inject_raw_css(css) {\n const element = document.createElement(\"style\");\n element.appendChild(document.createTextNode(css));\n document.body.appendChild(element);\n }\n\n \n const js_urls = [\"https://cdn.bokeh.org/bokeh/release/bokeh-2.4.2.min.js\", \"https://cdn.bokeh.org/bokeh/release/bokeh-gl-2.4.2.min.js\", \"https://cdn.bokeh.org/bokeh/release/bokeh-widgets-2.4.2.min.js\", \"https://cdn.bokeh.org/bokeh/release/bokeh-tables-2.4.2.min.js\", \"https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-2.4.2.min.js\"];\n const css_urls = [];\n \n\n const inline_js = [\n function(Bokeh) {\n Bokeh.set_log_level(\"info\");\n },\n function(Bokeh) {\n \n \n }\n ];\n\n function run_inline_js() {\n \n if (root.Bokeh !== undefined || force === true) {\n \n for (let i = 0; i < inline_js.length; i++) {\n inline_js[i].call(root, root.Bokeh);\n }\n if (force === true) {\n display_loaded();\n }} else if (Date.now() < root._bokeh_timeout) {\n setTimeout(run_inline_js, 100);\n } else if (!root._bokeh_failed_load) {\n console.log(\"Bokeh: BokehJS failed to load within specified timeout.\");\n root._bokeh_failed_load = true;\n } else if (force !== true) {\n const cell = $(document.getElementById(\"1002\")).parents('.cell').data().cell;\n cell.output_area.append_execute_result(NB_LOAD_WARNING)\n }\n\n }\n\n if (root._bokeh_is_loading === 0) {\n console.debug(\"Bokeh: BokehJS loaded, going straight to plotting\");\n run_inline_js();\n } else {\n load_libs(css_urls, js_urls, function() {\n console.debug(\"Bokeh: BokehJS plotting callback run at\", now());\n run_inline_js();\n });\n }\n}(window));" }, "metadata": {}, "output_type": "display_data" } ], "source": [ "import re\n", "import asyncio\n", "import time\n", "\n", "import numpy as np\n", "import pandas as pd\n", "\n", "import serial\n", "import serial.tools.list_ports\n", "\n", "import bokeh.plotting\n", "import bokeh.io\n", "import bokeh.driving\n", "\n", "notebook_url = 'localhost:8888'\n", "bokeh.io.output_notebook()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "
\n", "\n", "In this lesson, you will learn how to use Python's built-in asynchronous capabilities to constantly receive data from Arduino without blocking so that you can use the Python interpreter to do other tasks." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Setup\n", " \n", "The setup for this lesson is the same as the previous one. As a reminder, you should have an Arduino Uno board with the setup below connected to a USB port on your computer.\n", "\n", "
\n", "\n", "![Arduino](arduino_setup.svg)\n", " \n", "
\n", "\n", "You should also have the following code uploaded onto your Arduino.\n", "\n", "```C++\n", "const int voltagePin = A0;\n", "\n", "const int redLEDPin = 6;\n", "const int yellowLEDPin = 2;\n", "\n", "const int HANDSHAKE = 0;\n", "const int VOLTAGE_REQUEST = 1;\n", "const int RED_LED_ON = 2;\n", "const int RED_LED_OFF = 3;\n", "const int YELLOW_LED_ON = 4;\n", "const int YELLOW_LED_OFF = 5;\n", "\n", "const int ON_REQUEST = 6;\n", "const int STREAM = 7;\n", "const int READ_DAQ_DELAY = 8;\n", "\n", "String daqDelayStr;\n", "\n", "int inByte = 0;\n", "int daqMode = ON_REQUEST;\n", "int daqDelay = 100; // delay between acquisitions in milliseconds\n", "\n", "int value;\n", "unsigned long time_ms;\n", "\n", "\n", "void printVoltage() {\n", " // read value from analog pin\n", " value = analogRead(voltagePin);\n", " time_ms = millis();\n", " \n", " // Write the result\n", " if (Serial.availableForWrite()) {\n", " String outstr = String(String(time_ms, DEC) + \",\" + String(value, DEC));\n", " Serial.println(outstr);\n", " }\n", "}\n", "\n", "\n", "void setup() {\n", " // Set LEDs to off\n", " pinMode(redLEDPin, OUTPUT);\n", " pinMode(yellowLEDPin, OUTPUT);\n", " digitalWrite(redLEDPin, LOW);\n", " digitalWrite(yellowLEDPin, LOW);\n", "\n", " // initialize serial communication\n", " Serial.begin(115200);\n", "}\n", "\n", "\n", "void loop() {\n", " // If we're auto-transferring data (streaming mode)\n", " if (daqMode == STREAM) {\n", " printVoltage();\n", " delay(daqDelay); \n", " }\n", "\n", " // Check if data has been sent to Arduino and respond accordingly\n", " if (Serial.available() > 0) {\n", " // Read in request\n", " inByte = Serial.read();\n", " \n", " // Handshake\n", " if (inByte == HANDSHAKE){\n", " if (Serial.availableForWrite()) {\n", " Serial.println(\"Handshake message received.\");\n", " }\n", " }\n", " \n", " // If data is requested, fetch it and write it\n", " else if (inByte == VOLTAGE_REQUEST) printVoltage();\n", "\n", " // Switch daqMode\n", " else if (inByte == ON_REQUEST) daqMode = ON_REQUEST;\n", " else if (inByte == STREAM) daqMode = STREAM;\n", "\n", " // Read in DAQ delay\n", " else if (inByte == READ_DAQ_DELAY) {\n", " while (Serial.available() == 0) ;\n", " daqDelayStr = Serial.readStringUntil('x');\n", " daqDelay = daqDelayStr.toInt();\n", " }\n", "\n", " // else, turn LEDs on or off\n", " else if (inByte == RED_LED_ON) digitalWrite(redLEDPin, HIGH);\n", " else if (inByte == RED_LED_OFF) digitalWrite(redLEDPin, LOW);\n", " else if (inByte == YELLOW_LED_ON) digitalWrite(yellowLEDPin, HIGH);\n", " else if (inByte == YELLOW_LED_OFF) digitalWrite(yellowLEDPin, LOW);\n", " }\n", "}\n", "```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The necessary Python code is in the code cell below." ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "def find_arduino(port=None):\n", " \"\"\"Get the name of the port that is connected to Arduino.\"\"\"\n", " if port is None:\n", " ports = serial.tools.list_ports.comports()\n", " for p in ports:\n", " if p.manufacturer is not None and \"Arduino\" in p.manufacturer:\n", " port = p.device\n", " return port\n", "\n", "\n", "def handshake_arduino(\n", " arduino, sleep_time=1, print_handshake_message=False, handshake_code=0\n", "):\n", " \"\"\"Make sure connection is established by sending\n", " and receiving bytes.\"\"\"\n", " # Close and reopen\n", " arduino.close()\n", " arduino.open()\n", "\n", " # Chill out while everything gets set\n", " time.sleep(sleep_time)\n", "\n", " # Set a long timeout to complete handshake\n", " timeout = arduino.timeout\n", " arduino.timeout = 2\n", "\n", " # Read and discard everything that may be in the input buffer\n", " _ = arduino.read_all()\n", "\n", " # Send request to Arduino\n", " arduino.write(bytes([handshake_code]))\n", "\n", " # Read in what Arduino sent\n", " handshake_message = arduino.read_until()\n", "\n", " # Send and receive request again\n", " arduino.write(bytes([handshake_code]))\n", " handshake_message = arduino.read_until()\n", "\n", " # Print the handshake message, if desired\n", " if print_handshake_message:\n", " print(\"Handshake message: \" + handshake_message.decode())\n", "\n", " # Reset the timeout\n", " arduino.timeout = timeout\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "With these in hand, we can proceed to step up capability for controlling an external device and streaming data from it." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Why do we need asynchrony?\n", "\n", "When you communicated with a device, there are plenty of other tasks you want the Python interpreter to be doing while it is acquiring data. At the very least, you may want it to be listening for more user input to *stop* acquiring data. But you may also want to perform calculations on the incoming data (such as digital filtering), control and/or receive data from other connected devices, or even just mess around in your Jupyter notebook.\n", "\n", "In order to do these things, you want the data acquisition to happen asynchronously. You want the interpreter to occasionally read and parse data, but be free to do whatever else you want it to do when it is not reading and parsing data. That is, you do not want to tie up the interpreter all the time waiting for data to come in." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Reading data in chunks\n", "\n", "As a first step toward asynchrony, we will write a function to read data in **chunks**. Instead of constantly monitoring the data coming in over the serial connection, we would rather occasionally check the serial connection to see if there are any data in the input buffer. If there is, we read in whatever is in the input buffer to clear it, go off and process that, and then wait a while before checking again. During that waiting time, you can have the interpreter do other tasks.\n", "\n", "**Warning**: Don't wait too long to read, though! You do not want to overrun the USB input buffer size on your computer. Arduino's output buffer is 64 bytes, and computers can have default input buffer sizes as low as 64 bytes as well. (I think most computers these days have input buffer sizes around 1024 bytes, but it does vary from machine to machine.)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Chunk reading for non-corrupted data\n", "\n", "The function below reads in all of the data that is in the input buffer and returns the data as a byte string. We should specify a short timeout so that reading will stop before the buffer starts filling up again." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "def read_all(ser, read_buffer=b\"\", **args):\n", " \"\"\"Read all available bytes from the serial port\n", " and append to the read buffer.\n", "\n", " Parameters\n", " ----------\n", " ser : serial.Serial() instance\n", " The device we are reading from.\n", " read_buffer : bytes, default b''\n", " Previous read buffer that is appended to.\n", "\n", " Returns\n", " -------\n", " output : bytes\n", " Bytes object that contains read_buffer + read.\n", " \n", " Notes\n", " -----\n", " .. `**args` appears, but is never used. This is for \n", " compatibility with `read_all_newlines()` as a \n", " drop-in replacement for this function.\n", " \"\"\"\n", " # Set timeout to None to make sure we read all bytes\n", " previous_timeout = ser.timeout\n", " ser.timeout = None\n", "\n", " in_waiting = ser.in_waiting\n", " read = ser.read(size=in_waiting)\n", "\n", " # Reset to previous timeout\n", " ser.timeout = previous_timeout\n", "\n", " return read_buffer + read" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "For our present application, in which we read in comma-delimited time-voltage data, the byte string returned from this function might look like this:\n", "\n", " b'1032,541\\r\\n1052,542\\r\\n1073,554\\r\\n1093,5'\n", " \n", "Note that it does not end in a carriage return and newline. Those characters might not be in the read buffer yet, and since we are not using `read_until()`, we will not keep reading until we get those terminating characters. So, if we are parsing the output of this function, we should keep the last incomplete part of the data (in this case, `b'1093,5'` around for the next read.\n", "\n", "Here is a parser that returns both the times and voltages as lists, as well as the remaining bytes that we will pass as the `read_buffer` kwarg in the `read_all()` function. There is some error checking. The only allowed characters are carriage returns, new lines, commas, and digits. Any message having other characters is discarded." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "def parse_read(read):\n", " \"\"\"Parse a read with time, voltage data\n", "\n", " Parameters\n", " ----------\n", " read : byte string\n", " Byte string with comma delimited time/voltage\n", " measurements.\n", "\n", " Returns\n", " -------\n", " time_ms : list of ints\n", " Time points in milliseconds.\n", " voltage : list of floats\n", " Voltages in volts.\n", " remaining_bytes : byte string\n", " Remaining, unparsed bytes.\n", " \"\"\"\n", " time_ms = []\n", " voltage = []\n", "\n", " # Separate independent time/voltage measurements\n", " pattern = re.compile(b\"\\d+|,\")\n", " raw_list = [\n", " b\"\".join(pattern.findall(raw)).decode() \n", " for raw in read.split(b\"\\r\\n\")\n", " ]\n", " \n", " for raw in raw_list[:-1]:\n", " try:\n", " t, V = raw.split(\",\")\n", " time_ms.append(int(t))\n", " voltage.append(int(V) * 5 / 1023)\n", " except:\n", " pass\n", "\n", " if len(raw_list) == 0:\n", " return time_ms, voltage, b\"\"\n", " else:\n", " return time_ms, voltage, raw_list[-1].encode()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Chunk reading with corrupted data\n", "\n", "We discovered that on Windows machines, reads made with pySerial can sometimes result in corrupted bytes. This makes the read-in string unusable, and in many cases un-parsable because the resulting bytes do not correspond to any characters in ASCII. I am not sure exactly why this happens, but I suspect it is due to the read of a given byte being incomplete, with the read being interrupted before the stop bit. To counteract this, we can instead read chunks that *must* terminate in a newline using `read_until()`. This blocks all other processes until the complete newline byte is read. This also ensures that all bytes preceding the newline are read in their entirety as well." ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "def read_all_newlines(ser, read_buffer=b\"\", n_reads=4):\n", " \"\"\"Read data in until encountering newlines.\n", "\n", " Parameters\n", " ----------\n", " ser : serial.Serial() instance\n", " The device we are reading from.\n", " n_reads : int\n", " The number of reads up to newlines\n", " read_buffer : bytes, default b''\n", " Previous read buffer that is appended to.\n", " \n", " Returns\n", " -------\n", " output : bytes\n", " Bytes object that contains read_buffer + read.\n", " \n", " Notes\n", " -----\n", " .. This is a drop-in replacement for read_all().\n", " \"\"\"\n", " raw = read_buffer\n", " for _ in range(n_reads):\n", " raw += ser.read_until()\n", " \n", " return raw" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## asyncio\n", "\n", "Python has handy built-in asynchronous capabilities using the `asyncio` module from the standard library. It was introduced recently, in Python 3.5, and has had changes and deprecations since. The version in Python 3.8 has nice high-level functionality and has a stable API, so it is important that you are using Python 3.8.\n", "\n", "I will give a brief overview of how it works here, but you would be well-served to [read the documentation](https://docs.python.org/3/library/asyncio.html), most importantly the [coroutines and tasks section](https://docs.python.org/3/library/asyncio-task.html).\n", "\n", "At the center of asyncio's high-level functionality are **awaitables**. An awaitable is a process that the interpreter can suspend such that it is not blocking the interpreter from doing other tasks. A very important awaitable is `asyncio.sleep()`, which is one we will put to use.\n", "\n", "Aside from sleeping, the awaitables we will use are **coroutines** and **tasks**. You can think of a coroutine as a function that you can start and stop and start again. A task runs a coroutine. As usual, this is best seen by example.\n", "\n", "We will start by making a coroutine that is a greeting in English. It says \"hello\" and then waits one second to say \"world.\" It returns a string describing what the message was. We would do this in a synchronous way (so it is a function and *not* a coroutine) like this:" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [], "source": [ "def english(exclaim=False):\n", " print(\"Hello, \")\n", " time.sleep(1)\n", " print(\"world\" + (\"!\" if exclaim else \".\"))\n", " \n", " return \"The message was a greeting to the world.\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can run this function, and it works as expected." ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Hello, \n", "world!\n" ] } ], "source": [ "message = english(exclaim=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The problem is that the function, like all functions in Python, blocked. While waiting for a second to see \"world,\" the Python interpreter was busy (it was busy sleeping!). When a function of piece of code is running and prevents the interpreter from doing anything else, it is said to be **blocking**.\n", "\n", "Now, let's write an asynchronous version, that is a coroutine." ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [], "source": [ "async def english_async(exclaim=False):\n", " print(\"Hello, \")\n", " await asyncio.sleep(1)\n", " print(\"world\" + (\"!\" if exclaim else \".\"))\n", " \n", " return \"The message was a greeting to the world.\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The `async def` keyword signifies that this is not a function, but a coroutine. That means the interpreter can start running the coroutine, leave it and do something else, and then run it again. It can only leave the coroutine where an awaitable is run. To run an awaitable within a coroutine, we use the `await` keyword. So, when we run `await asyncio.sleep(1)`, the Python interpreter turns its attention away from the `english_async()` coroutine until `asyncio.sleep()` returns, which will happen after one second.\n", "\n", "We cannot just run a coroutine like it is a function. Look:" ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "" ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "english_async(exclaim=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We get back a coroutine. To run it, we need a running [event loop](https://en.wikipedia.org/wiki/Event_loop). An event loop enables asynchronous computing by listening for requests to do something, and then dispatching resources to do the requested calculation. Each thread (which you can think of for our purposes as one core of your CPU) can have either zero or one event loops. If you are running a Jupyter notebook, there is an active event loop; that is how JupyterLab runs, waiting for you to execute a cell. If you are not in a Jupyter notebook, you probably do not have an event loop running, so you need to start one. We will discuss how to start and run an event loop outside of JupyterLab [later in this lesson](#Running-without-an-existing-event-loop). For now, we will assume you have a running event loop, as you do in a Jupyter notebook.\n", "\n", "To run the coroutine, you can create a task using `asyncio.create_task()`. Note that \"calling\" a coroutine like a function returns a coroutine, so the argument you pass into `asyncio.create_task()` is how you would call the coroutine, including all arguments and keyword arguments. Upon creation, the coroutine is run." ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Hello, \n", "world!\n" ] } ], "source": [ "task_english = asyncio.create_task(english_async(exclaim=True))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can access the return value of the coroutine using the `result()` method of the task. Of course, you should first check the `done()` method of the task to see if it has completed." ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "True" ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "task_english.done()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "And we can safely retrieve the result." ] }, { "cell_type": "code", "execution_count": 12, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "'The message was a greeting to the world.'" ] }, "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ "task_english.result()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now, let's make another coroutine that says the same greeting in Spanish. For demonstration purposes, this function will only wait a half second between the two words." ] }, { "cell_type": "code", "execution_count": 13, "metadata": {}, "outputs": [], "source": [ "async def spanish_async(exclaim=False):\n", " print((\" ¡\" if exclaim else \" \") + \"Hola, \")\n", " await asyncio.sleep(0.5)\n", " print(\" mundo\" + (\"!\" if exclaim else \".\"))\n", "\n", " return(\"El mensaje fue un saludo al mundo.\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can run this coroutine as we did for the English one." ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ " ¡Hola, \n", " mundo!\n" ] } ], "source": [ "task_spanish = asyncio.create_task(spanish_async(exclaim=True))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "With asynchronous computing, we can run the two coroutines *concurrently*! There are several ways to do this. First, we can create tasks one after another. The first task is created, \"Hello,\" is printed, and then the second task is created. (This time, we won't exclaim.)" ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Hello, \n", " Hola, \n", " mundo.\n", "world.\n" ] } ], "source": [ "task_english = asyncio.create_task(english_async())\n", "task_spanish = asyncio.create_task(spanish_async())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Because the delay is shorter for the Spanish version, the entire message gets printed before the English message is complete.\n", "\n", "As another option, we can gather the coroutines together using `asyncio.gather()`." ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Hello, \n", " Hola, \n", " mundo.\n", "world.\n" ] } ], "source": [ "task_english_spanish = asyncio.gather(english_async(), spanish_async())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To get the return values, we gain use `task_english_spanish.result()`." ] }, { "cell_type": "code", "execution_count": 17, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "['The message was a greeting to the world.',\n", " 'El mensaje fue un saludo al mundo.']" ] }, "execution_count": 17, "metadata": {}, "output_type": "execute_result" } ], "source": [ "task_english_spanish.result()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Note that the result is the return values from the two coroutines as a list." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Canceling a task\n", "\n", "Once a task is created, it may be interrupted and canceled using the `cancel()` method of the task. For example, we can cancel the English greeting before the second word comes out." ] }, { "cell_type": "code", "execution_count": 18, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Hello, \n" ] } ], "source": [ "# Create the task\n", "task_english = asyncio.create_task(english_async())\n", "\n", "# Wait a half second\n", "await asyncio.sleep(0.5)\n", "\n", "# Cancel the task\n", "successfully_canceled = task_english.cancel()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Note that the `cancel()` method requests a cancellation, but cancellation is not guaranteed. You should read the asyncio documentation for more information.\n", "\n", "A canceled job will both be marked as done and canceled." ] }, { "cell_type": "code", "execution_count": 19, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "(True, True)" ] }, "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "task_english.done(), task_english.cancelled()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Since it was not allowed to return, though, the result will be a `CancelledError`." ] }, { "cell_type": "code", "execution_count": 20, "metadata": {}, "outputs": [ { "ename": "CancelledError", "evalue": "", "output_type": "error", "traceback": [ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", "\u001b[0;31mCancelledError\u001b[0m Traceback (most recent call last)", "Input \u001b[0;32mIn [8]\u001b[0m, in \u001b[0;36menglish_async\u001b[0;34m(exclaim)\u001b[0m\n\u001b[1;32m 2\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mHello, \u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[0;32m----> 3\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39msleep(\u001b[38;5;241m1\u001b[39m)\n\u001b[1;32m 4\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mworld\u001b[39m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;241m+\u001b[39m (\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m!\u001b[39m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m exclaim \u001b[38;5;28;01melse\u001b[39;00m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m.\u001b[39m\u001b[38;5;124m\"\u001b[39m))\n", "File \u001b[0;32m~/opt/anaconda3/lib/python3.9/asyncio/tasks.py:652\u001b[0m, in \u001b[0;36msleep\u001b[0;34m(delay, result, loop)\u001b[0m\n\u001b[1;32m 651\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m--> 652\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m future\n\u001b[1;32m 653\u001b[0m \u001b[38;5;28;01mfinally\u001b[39;00m:\n", "\u001b[0;31mCancelledError\u001b[0m: ", "\nDuring handling of the above exception, another exception occurred:\n", "\u001b[0;31mCancelledError\u001b[0m Traceback (most recent call last)", "Input \u001b[0;32mIn [20]\u001b[0m, in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mtask_english\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mresult\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n", "\u001b[0;31mCancelledError\u001b[0m: " ] } ], "source": [ "task_english.result()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Running without an existing event loop\n", "\n", "If you do not have a running event loop on your thread, which will typically be the case if you are running outside of JupyterLab, you need to start an event loop. Fortunately, `asynchio` provides a convenient way to start (and automatically terminate upon completion of all coroutines) with its `asyncio.run()` function. To use it, define a coroutine that awaits all of the tasks you want to run and then pass that coroutine as an argument to `asyncio.run()`. For example, to run the English and Spanish greetings concurrently, do the following.\n", "\n", "```python\n", "async def main():\n", " gathered = asyncio.gather(english_async(), spanish_async())\n", " await gathered\n", " \n", " return gathered.result()\n", "\n", "\n", "asyncio.run(main())\n", "```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Receiving data asynchronously\n", "\n", "Now that we understand how asynchrony works in Python, let's receive some data! We'll of course start by shaking hands with Arduino." ] }, { "cell_type": "code", "execution_count": 21, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Handshake message: Handshake message received.\n", "\n" ] } ], "source": [ "HANDSHAKE = 0\n", "VOLTAGE_REQUEST = 1\n", "RED_LED_ON = 2\n", "RED_LED_OFF = 3\n", "YELLOW_LED_ON = 4\n", "YELLOW_LED_OFF = 5\n", "ON_REQUEST = 6\n", "STREAM = 7\n", "READ_DAQ_DELAY = 8\n", "\n", "# Windows users may need to give COM port for find_arduino()\n", "port = find_arduino()\n", "\n", "# Connect and handshake\n", "arduino = serial.Serial(port, baudrate=115200)\n", "handshake_arduino(arduino, print_handshake_message=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now we can write a coroutine to acquire data. A few comments on how it works.\n", "\n", "1. We will read the data in chunks using the functions we wrote at the beginning of this lesson.\n", "2. I read in the first few messages sent from Arduino after turning on the stream and discard them, just to ensure the input buffer of my computer is cleared and we're getting good clean reads. \n", "3. We sleep between acquisitions. I choose to sleep about 80% of the time of the acquisitions. This ensures that I will never have too many bytes in the input buffer, but I am still not checking as often as I could be. (Note that the `read_all_newlines()` function will take longer to run than the `read_all()` function because it has to wait until Arduino sends its final newline. It is blocking while it is waiting. This should not be a major slowdown, though.)\n", "4. The function takes an input `reader`, which specifies which function we want to use to read in the serial data. By default, we use `read_all_newlines()` because it does not have the aforementioned issues on Windows.\n", "5. I set up a dictionary to hold the data that gets updated with data as it is read." ] }, { "cell_type": "code", "execution_count": 22, "metadata": {}, "outputs": [], "source": [ "# Set up data dictionary\n", "data = dict(time_ms=[], voltage=[])\n", "\n", "\n", "async def daq_stream_async(\n", " arduino,\n", " data,\n", " n_data=100,\n", " delay=20,\n", " n_trash_reads=5,\n", " n_reads_per_chunk=4,\n", " reader=read_all_newlines,\n", "):\n", " \"\"\"Obtain `n_data` data points from an Arduino stream\n", " with a delay of `delay` milliseconds between each.\"\"\"\n", " # Specify delay\n", " arduino.write(bytes([READ_DAQ_DELAY]) + (str(delay) + \"x\").encode())\n", "\n", " # Turn on the stream\n", " arduino.write(bytes([STREAM]))\n", "\n", " # Read and throw out first few reads\n", " i = 0\n", " while i < n_trash_reads:\n", " _ = arduino.read_until()\n", " i += 1\n", "\n", " # Receive data\n", " read_buffer = [b\"\"]\n", " while len(data[\"time_ms\"]) < n_data:\n", " # Read in chunk of data\n", " raw = reader(arduino, read_buffer=read_buffer[0], n_reads=n_reads_per_chunk)\n", "\n", " # Parse it, passing if it is gibberish\n", " try:\n", " t, V, read_buffer[0] = parse_read(raw)\n", "\n", " # Update data dictionary\n", " data[\"time_ms\"] += t\n", " data[\"voltage\"] += V\n", " except:\n", " pass\n", "\n", " # Sleep 80% of the time before we need to start reading chunks\n", " await asyncio.sleep(0.8 * n_reads_per_chunk * delay / 1000)\n", "\n", " # Turn off the stream\n", " arduino.write(bytes([ON_REQUEST]))\n", "\n", " return pd.DataFrame(\n", " {\"time (ms)\": data[\"time_ms\"][:n_data], \"voltage (V)\": data[\"voltage\"][:n_data]}\n", " )" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To acquire data using this coroutine, we create a task." ] }, { "cell_type": "code", "execution_count": 23, "metadata": {}, "outputs": [], "source": [ "daq_task = asyncio.create_task(daq_stream_async(arduino, data, n_data=1000, delay=20))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can retrieve the data frame from the task's result and make a plot." ] }, { "cell_type": "code", "execution_count": 24, "metadata": {}, "outputs": [ { "data": { "text/html": [ "\n", "\n", "\n", "\n", "\n", "\n", "
\n" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/javascript": [ "(function(root) {\n", " function embed_document(root) {\n", " \n", " const docs_json = {\"61b668bd-ed20-4821-aca3-cdea05bf9eaf\":{\"defs\":[],\"roots\":{\"references\":[{\"attributes\":{\"below\":[{\"id\":\"1012\"}],\"center\":[{\"id\":\"1015\"},{\"id\":\"1019\"}],\"frame_height\":175,\"frame_width\":500,\"left\":[{\"id\":\"1016\"}],\"renderers\":[{\"id\":\"1039\"}],\"title\":{\"id\":\"1041\"},\"toolbar\":{\"id\":\"1027\"},\"x_range\":{\"id\":\"1004\"},\"x_scale\":{\"id\":\"1008\"},\"y_range\":{\"id\":\"1006\"},\"y_scale\":{\"id\":\"1010\"}},\"id\":\"1003\",\"subtype\":\"Figure\",\"type\":\"Plot\"},{\"attributes\":{\"data\":{\"index\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999],\"time (ms)\":[17033,17053,17074,17094,17115,17135,17156,17176,17197,17218,17239,17259,17280,17300,17320,17341,17361,17382,17402,17423,17443,17464,17484,17505,17525,17546,17567,17588,17608,17629,17649,17670,17690,17711,17731,17752,17772,17793,17813,17833,17854,17874,17895,17916,17937,17957,17978,17998,18019,18039,18060,18080,18101,18121,18142,18162,18183,18203,18224,18244,18265,18286,18307,18327,18348,18368,18388,18409,18429,18450,18470,18491,18511,18532,18552,18573,18593,18614,18635,18656,18676,18697,18717,18738,18758,18779,18799,18820,18840,18861,18881,18902,18922,18942,18963,18984,19005,19025,19046,19066,19087,19107,19128,19148,19169,19189,19210,19230,19251,19271,19292,19312,19333,19354,19375,19395,19416,19436,19457,19477,19497,19518,19538,19559,19579,19600,19620,19641,19661,19682,19703,19724,19744,19765,19785,19806,19826,19847,19867,19888,19908,19929,19949,19970,19990,20011,20031,20052,20073,20093,20114,20134,20155,20175,20196,20216,20237,20257,20278,20298,20319,20339,20360,20380,20402,20422,20443,20463,20484,20504,20525,20545,20566,20586,20606,20627,20647,20668,20688,20709,20729,20750,20771,20792,20812,20833,20853,20874,20894,20915,20935,20956,20976,20997,21017,21038,21058,21079,21099,21121,21141,21161,21182,21202,21223,21243,21264,21284,21305,21325,21346,21366,21387,21407,21428,21448,21470,21490,21511,21531,21552,21572,21593,21613,21634,21654,21675,21695,21715,21736,21756,21777,21797,21819,21839,21860,21880,21901,21921,21942,21962,21983,22003,22024,22044,22065,22086,22107,22127,22148,22168,22189,22209,22230,22250,22270,22291,22311,22332,22352,22374,22394,22415,22435,22456,22476,22497,22517,22538,22558,22579,22599,22620,22641,22662,22682,22703,22723,22744,22764,22785,22805,22825,22846,22866,22887,22907,22928,22948,22970,22990,23011,23031,23052,23072,23093,23113,23134,23154,23175,23195,23216,23236,23257,23277,23298,23318,23340,23360,23380,23401,23421,23442,23462,23483,23503,23524,23544,23565,23585,23606,23626,23647,23667,23689,23709,23730,23750,23771,23791,23812,23832,23853,23873,23894,23914,23934,23955,23975,23996,24016,24038,24058,24079,24099,24120,24140,24161,24181,24202,24222,24243,24263,24284,24304,24325,24345,24366,24386,24408,24428,24449,24469,24489,24510,24530,24551,24571,24592,24612,24633,24653,24674,24694,24715,24735,24757,24777,24798,24818,24839,24859,24880,24900,24921,24941,24962,24982,25003,25023,25043,25064,25084,25106,25126,25147,25167,25188,25208,25229,25249,25270,25290,25311,25331,25352,25372,25393,25413,25434,25454,25476,25496,25517,25537,25558,25578,25598,25619,25639,25660,25680,25701,25721,25742,25762,25783,25803,25825,25845,25866,25886,25907,25927,25948,25968,25989,26009,26030,26050,26071,26091,26112,26132,26152,26173,26193,26214,26234,26255,26274,26295,26315,26336,26356,26377,26397,26418,26438,26459,26479,26500,26520,26541,26561,26582,26602,26622,26643,26663,26684,26704,26726,26746,26767,26787,26808,26828,26849,26869,26890,26910,26931,26951,26972,26992,27013,27033,27054,27075,27096,27116,27137,27157,27177,27198,27218,27239,27259,27280,27300,27321,27341,27362,27382,27403,27423,27445,27465,27486,27506,27527,27547,27568,27588,27609,27629,27650,27670,27691,27711,27731,27752,27772,27793,27813,27834,27854,27875,27896,27917,27937,27958,27978,27999,28019,28040,28060,28081,28101,28122,28142,28163,28183,28204,28224,28244,28266,28286,28307,28327,28348,28368,28389,28409,28430,28450,28471,28491,28512,28532,28553,28573,28594,28615,28636,28656,28677,28697,28718,28738,28759,28779,28800,28820,28840,28861,28881,28902,28922,28943,28964,28985,29005,29026,29046,29067,29087,29108,29128,29149,29169,29190,29210,29231,29251,29272,29292,29313,29334,29355,29375,29395,29416,29436,29457,29477,29498,29518,29539,29559,29580,29600,29621,29641,29662,29683,29704,29724,29745,29765,29786,29806,29827,29847,29868,29888,29908,29929,29949,29970,29990,30011,30032,30053,30073,30094,30114,30135,30155,30176,30196,30217,30237,30258,30278,30299,30319,30340,30360,30381,30402,30423,30443,30464,30484,30504,30525,30545,30566,30586,30607,30627,30648,30668,30689,30709,30730,30751,30772,30792,30813,30833,30854,30874,30895,30915,30936,30956,30977,30997,31017,31038,31058,31079,31100,31121,31141,31162,31182,31203,31223,31244,31264,31285,31305,31326,31346,31367,31387,31408,31428,31449,31470,31491,31511,31532,31552,31572,31593,31613,31634,31654,31675,31695,31716,31736,31757,31777,31798,31819,31840,31860,31881,31901,31922,31942,31963,31983,32004,32024,32045,32065,32086,32106,32126,32147,32168,32189,32209,32230,32250,32271,32291,32312,32332,32353,32373,32394,32414,32435,32455,32476,32496,32518,32538,32559,32579,32600,32620,32641,32661,32681,32702,32722,32743,32763,32784,32804,32825,32845,32866,32887,32908,32928,32949,32969,32990,33010,33031,33051,33072,33092,33113,33133,33154,33174,33195,33215,33235,33256,33276,33297,33318,33339,33359,33380,33400,33421,33441,33462,33482,33503,33523,33544,33564,33585,33605,33626,33646,33667,33688,33709,33729,33750,33770,33790,33811,33831,33852,33872,33893,33913,33934,33954,33975,33995,34016,34037,34058,34078,34099,34119,34140,34160,34181,34201,34222,34242,34263,34283,34304,34324,34344,34365,34386,34407,34427,34448,34468,34489,34509,34530,34550,34571,34591,34612,34632,34653,34673,34694,34714,34735,34756,34777,34797,34818,34838,34859,34879,34899,34920,34940,34961,34981,35002,35022,35043,35063,35084,35105,35126,35146,35167,35187,35208,35228,35249,35269,35290,35310,35331,35351,35372,35392,35412,35433,35454,35475,35495,35516,35536,35557,35577,35598,35618,35639,35659,35680,35700,35721,35741,35762,35782,35803,35824,35845,35865,35886,35906,35927,35947,35968,35988,36008,36029,36049,36070,36090,36111,36131,36152,36173,36194,36214,36235,36255,36276,36296,36317,36337,36358,36378,36399,36419,36440,36460,36481,36501,36523,36543,36563,36584,36604,36625,36645,36666,36686,36707,36727,36748,36768,36789,36809,36830,36850,36872,36892,36913,36933,36954,36974,36995,37015,37036,37056,37076,37097,37117,37138,37158,37179,37199,37220,37241,37262,37282,37303,37323,37344,37364,37385,37405,37426,37446,37467,37487,37508,37528,37549],\"time (sec)\":{\"__ndarray__\":\"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\",\"dtype\":\"float64\",\"order\":\"little\",\"shape\":[1000]},\"voltage (V)\":{\"__ndarray__\":\"L7300ksvEUAvvfTSSy8RQC+99NJLLxFAL7300ksvEUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQC+99NJLLxFANNFEE000EUAvvfTSSy8RQC+99NJLLxFAOeWUU045EUAvvfTSSy8RQDTRRBNNNBFAOeWUU045EUA00UQTTTQRQC+99NJLLxFANNFEE000EUA00UQTTTQRQDTRRBNNNBFAL7300ksvEUA00UQTTTQRQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDnllFNOORFAOeWUU045EUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUA00UQTTTQRQC+99NJLLxFAL7300ksvEUA00UQTTTQRQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUA00UQTTTQRQC+99NJLLxFAL7300ksvEUA00UQTTTQRQDnllFNOORFANNFEE000EUAvvfTSSy8RQDnllFNOORFANNFEE000EUA00UQTTTQRQC+99NJLLxFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUA00UQTTTQRQDTRRBNNNBFAL7300ksvEUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUA55ZRTTjkRQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFAL7300ksvEUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUA55ZRTTjkRQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDnllFNOORFAOeWUU045EUA00UQTTTQRQDTRRBNNNBFAL7300ksvEUAvvfTSSy8RQC+99NJLLxFAL7300ksvEUAvvfTSSy8RQC+99NJLLxFAL7300ksvEUAvvfTSSy8RQC+99NJLLxFANNFEE000EUA00UQTTTQRQDTRRBNNNBFAL7300ksvEUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUA00UQTTTQRQC+99NJLLxFAOeWUU045EUA00UQTTTQRQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDnllFNOORFANNFEE000EUAvvfTSSy8RQC+99NJLLxFANNFEE000EUAvvfTSSy8RQDnllFNOORFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFAL7300ksvEUAvvfTSSy8RQDnllFNOORFANNFEE000EUA55ZRTTjkRQDTRRBNNNBFAL7300ksvEUA00UQTTTQRQC+99NJLLxFAOeWUU045EUA00UQTTTQRQC+99NJLLxFANNFEE000EUAvvfTSSy8RQC+99NJLLxFANNFEE000EUAvvfTSSy8RQC+99NJLLxFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUA00UQTTTQRQDnllFNOORFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFANNFEE000EUA00UQTTTQRQC+99NJLLxFAL7300ksvEUA00UQTTTQRQC+99NJLLxFANNFEE000EUA00UQTTTQRQC+99NJLLxFANNFEE000EUA00UQTTTQRQC+99NJLLxFANNFEE000EUA00UQTTTQRQC+99NJLLxFAL7300ksvEUAvvfTSSy8RQC+99NJLLxFAL7300ksvEUA00UQTTTQRQC+99NJLLxFAL7300ksvEUA00UQTTTQRQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFAL7300ksvEUA00UQTTTQRQDnllFNOORFANNFEE000EUA00UQTTTQRQDTRRBNNNBFANNFEE000EUAvvfTSSy8RQC+99NJLLxFAOeWUU045EUA00UQTTTQRQC+99NJLLxFANNFEE000EUAvvfTSSy8RQDTRRBNNNBFAOeWUU045EUA00UQTTTQRQC+99NJLLxFANNFEE000EUA00UQTTTQRQDnllFNOORFAOeWUU045EUA++eSTTz4RQD755JNPPhFAOeWUU045EUA55ZRTTjkRQDnllFNOORFANNFEE000EUA55ZRTTjkRQDnllFNOORFAPvnkk08+EUA++eSTTz4RQDnllFNOORFAPvnkk08+EUA++eSTTz4RQD755JNPPhFASCGFFFJIEUBDDTXUUEMRQEMNNdRQQxFAV1111VVXEUBcccUVV1wRQHDBBRdccBFAwAEHHHDAEUBSRhlllFESQJ1yyimnnBJA44orrrjiEkApo4wyyigTQCmjjDLKKBNALrfccsstE0A988wzzzwTQGWTTTbZZBNAuueee+65E0D2119//fUTQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEAAAAAAAAAUQAAAAAAAABRAAAAAAAAAFEC655577rkTQMD++uuvvxJAzz333HPPEUAHHXTQQQcRQLLIIossshBAhRRSSCGFEEBnnHHGGWcQQFNMMcUUUxBASSSRRBJJEEBOOOGEE04QQE444YQTThBATjjhhBNOEEBOOOGEE04QQEkkkUQSSRBATjjhhBNOEEBOOOGEE04QQE444YQTThBASSSRRBJJEEBOOOGEE04QQE444YQTThBATjjhhBNOEEBOOOGEE04QQEkkkUQSSRBAU0wxxRRTEEA//PDDDz8QQCussMIKKxBADTTQQAMNEECsr7766qsPQIQPPvjggw9AhA8++OCDD0CEDz744IMPQHrnnXfeeQ9Aeuedd955D0B655133nkPQIQPPvjggw9Aeuedd955D0B655133nkPQHrnnXfeeQ9Aeuedd955D0B655133nkPQIQPPvjggw9AhA8++OCDD0CON954440PQHrnnXfeeQ9AcL/99ttvD0BIH3300UcPQCB//PHHHw9AAgcccMABD0ConnrqqacOQDC++OKLLw5AwQUXXHDBDUBJJZVUUkkNQPnkk08++QxAs8wyyyyzDEB33HHHHXcMQP/777///gtAaaONNtpoC0Ddcsstt9wKQD3yyCOPPApAYIEFFlhgCUDUUEMNNdQIQD744IMPPghA5I8//vjjB0Cyxx577LEHQLLHHnvssQdAsscee+yxB0C877777rsHQMYXX3zxxQdAvO++++67B0C877777rsHQLzvvvvuuwdAvO++++67B0Cyxx577LEHQLLHHnvssQdAsscee+yxB0Cyxx577LEHQLLHHnvssQdAvO++++67B0C877777rsHQLLHHnvssQdAdtddd911B0DCBhtssMEGQL311ltvvQVAm2yyySabBEBHG2200UYDQEMKKaSQQgJAcMEFF1xwAUAMMcQQQwwBQOSQQw455ABA2mijjTbaAEDaaKONNtoAQO6444477gBA7rjjjjvuAEDkkEMOOeQAQOSQQw455ABA5JBDDjnkAEDkkEMOOeQAQO6444477gBA5JBDDjnkAEDkkEMOOeQAQOSQQw455ABA2mijjTbaAEDaaKONNtoAQKigggoqqABAHHDAAQccAEConnrqqaf+PyuttNJKK/0/00orrbTS+j8WWGCBBRb4P3LGGWeccfY/0UUXXXTR9T/RRRdddNH1P9FFF1100fU/0UUXXXTR9T+99dZbb731P6mlllpqqfU/vfXWW2+99T+99dZbb731P7311ltvvfU/vfXWW2+99T+99dZbb731P6mlllpqqfU/vfXWW2+99T+99dZbb731P7311ltvvfU/vfXWW2+99T+ppZZaaqn1PzHFFFNMMfU/GWSQQQYZ9D+dcsopp5zyP7zwwgsvvPA/P/30008/7T9mmGGGGWboP5VVVlllleU/pZRSSiml5D999NFHH33kP6WUUkoppeQ/pZRSSiml5D999NFHH33kPwUUUEABBeQ/BRRQQAEF5D8FFFBAAQXkPwUUUEABBeQ/BRRQQAEF5D/dc88999zjP7XTTjvttOM/jTPOOOOM4z+100477bTjP91zzz333OM/tdNOO+204z9VVFFFFVXkP1VUUUUVVeQ/ddJJJ5104j+onnrqqafePwUUUEABBdQ/5JBDDjnkwD8FFFBAAQV0PwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUUUEABBZQ/J5xwwgknzD+HG2644YbbP4QRRhhhhOE/7bLLLrvs4j+NM84444zjP40zzjjjjOM/PfPMM8884z/FEkssscTiP51yyimnnOI/nXLKKaec4j+dcsopp5ziP8USSyyxxOI/nXLKKaec4j+dcsopp5ziP8USSyyxxOI/xRJLLLHE4j+dcsopp5ziP51yyimnnOI/FVNMMcUU4z+NM84444zjP5VVVlllleU/7rfffvvt5z+/+uqrr77qPz/99NNPP+0/WGCBBRZY8D/AAQcccMDxP51yyimnnPI/AQMMMMAA8z8988wzzzzzPz3zzDPPPPM/PfPMM8888z8po4wyyijzPymjjDLKKPM/FVNMMcUU8z8VU0wxxRTzPxVTTDHFFPM/AQMMMMAA8z+JIooooojyP9RRRx111PE/lFBCCSWU8D/vvPPOO+/sP3bXXXfddec/PfPMM8884z8ccMABBxzgP8ccc8wxx9w/VllllVVW2T8WWGCBBRbYPyaXXHLJJdc/hhZaaKGF1j+llFJKKaXUP6WUUkoppdQ/pZRSSiml1D+llFJKKaXUP0UVVVRRRdU/lVVWWWWV1T/GF1988cXXP5daaqmllto/6J9//vnn3z9lk0022WTjP+6333777ec/n3zyySef7D9ssMEGG2zwPyWSSCKJJPI/eeONN9548z8ZZJBBBhn0P0EEEUQQQfQ/ffTRRx999D+RRBJJJJH0P2mkkUYaafQ/aaSRRhpp9D999NFHH330P3300UcfffQ/ffTRRx999D999NFHH330P3300UcfffQ/ffTRRx999D999NFHH330P3300UcfffQ/ffTRRx999D+RRBJJJJH0P80000wzzfQ/CSWUUEIJ9T/55Zdffvn1P/7222+//fY/AggggAAC+D9qqaWWWmr5P5daaqmllvo/r7vuuuuu+z+LLLLIIov8P9tss8022/w/F1100UUX/T8DDTTQQAP9PwMNNNBAA/0/F1100UUX/T8XXXTRRRf9PxdddNFFF/0/F1100UUX/T8XXXTRRRf9PxdddNFFF/0/K6200kor/T8rrbTSSiv9PxdddNFFF/0/K6200kor/T8rrbTSSiv9PxdddNFFF/0/F1100UUX/T8XXXTRRRf9PyuttNJKK/0/F1100UUX/T8XXXTRRRf9PxdddNFFF/0/P/30008//T9TTTXVVFP9PwgeeOCBB/4/SB999NFH/z9iiCGGGGIAQCCBBBJIIAFAEUIIIYQQAkC76qqrrroCQCmjjDLKKANAtdNOO+20A0APPPDAAw8EQH300UcffQRAr7zyyiuvBEDXXHPNNdcEQDvttNNOOwVAlVVWWWWVBUDllVdeeeUFQOWVV1555QVA0UUXXXTRBUDRRRdddNEFQO+999577wVADjbYYIMNBkAihhhiiCEGQBheeOGFFwZABA444IADBkDvvffee+8FQNFFF1100QVAs80222yzBUB33XXXXXcFQE899dRTTwVA//zzzz//BEDDDDPMMMMEQJFEEkkkkQRASyyxxBJLBEAFFFBAAQUEQMkjjzzyyANAjTPOOOOMA0Blk0022WQDQEcbbbTRRgNA99prr732AkB/+umnn34CQC+66KKLLgJA1FFHHXXUAUBwwQUXXHABQAIJJJBAAgFAnnjiiSeeAEBOOOGEE04AQKyvvvrqq/8/0D777LPP/j/zzTfffPP9PyuttNJKK/0/Y4wxxhhj/D+ba6655pr7PyOLLLLIIvs/v/rqq6+++j9HGmmkkUb6P6aZZppppvk/Lrnkkksu+T+iiCKKKKL4PxZYYIEFFvg/nnfeeeed9z/+9ttvv/32P5pmmmmmmfY/SiaZZJJJ9j/RRRdddNH1P5VVVllllfU/RRVVVFFF9T8dddRRRx31PwkllFBCCfU/CSWUUEIJ9T/hhBNOOOH0P80000wzzfQ/pZRSSiml9D9VVFFFFVX0P0EEEUQQQfQ/LbTQQgst9D/dc88999zzP3njjTfeePM/eeONN9548z9lk0022WTzP40zzjjjjPM/oYMOOuig8z8FFFBAAQX0PxlkkEEGGfQ/pZRSSiml9D9FFVVUUUX1Pw422GCDDfY/1lZbbbXV9j/GF1988cX3P3755Zdffvk/q6qqqqqq+j+vu+666677P++888477/w/WF555ZVX/j/UTz/99NP/P2KIIYYYYgBAxhhjjDHGAEA00UQTTTQBQKKJJppoogFAOeKII444AkCdcsopp5wCQAsrrLDCCgNAW2uttdZaA0CNM84444wDQJdbbrnllgNAl1tuueWWA0CXW2655ZYDQKGDDjrooANAl1tuueWWA0CXW2655ZYDQJdbbrnllgNAl1tuueWWA0CNM84444wDQJdbbrnllgNAgwsuuOCCA0Blk0022WQDQFFDDTXUUANAM8sss8wyA0AVU0wxxRQDQO2yyy677AJAzzrrrLPOAkCJIooooogCQIkiiiiiiAJAddJJJ510AkB10kknnXQCQHXSSSeddAJAa6qppppqAkBDCimkkEICQPzxxx9//AFAjjnmmGOOAUAggQQSSCABQLLIIosssgBAMMAAAwwwAEAgf/zxxx//PzC++OKLL/4/e+211157/T+ffPLJJ5/8P4cbbrjhhvs/++qrr776+j/nmmuuueb6P9NKK6200vo/00orrbTS+j/nmmuuueb6P+eaa6655vo/55prrrnm+j/nmmuuueb6P9NKK6200vo/00orrbTS+j/nmmuuueb6P9NKK6200vo/55prrrnm+j/TSiuttNL6P9NKK6200vo/00orrbTS+j/nmmuuueb6P+eaa6655vo/00orrbTS+j+/+uqrr776P5daaqmllvo/b7rppptu+j9HGmmkkUb6PwsqqKCCCvo/kkkmmWSS+T8GGWSQQQb5PyqooIIKKvg/Yocddthh9z8ONthggw32P80000wzzfQ/jTPOOOOM8z8BAwwwwADzP51yyimnnPI/iSKKKKKI8j854ogjjjjyP+ihhx566PE/hBFGGGGE8T9wwQUXXHDxPzTRRBNNNPE/DDHEEEMM8T+ooIIKKqjwP0QQQQQRRPA/wP/++++/7z+onnrqqafuP99999133+0/77zzzjvv7D//+++///7rP5daaqmlluo/zjnnnHPO6T+OOOKII47oP/7222+//eY/DjbYYIMN5j+VVVZZZZXlP80000wzzeQ/3XPPPffc4z+dcsopp5ziP1xxxRVXXOE/RBBBBBFE4D+YX3755ZffP6ieeuqpp94/CB544IEH3j+33XbbbbfdP2edddZZZ90/F1100UUX3T9nnXXWWWfdP2edddZZZ90/Z5111lln3T8IHnjggQfeP6ieeuqpp94/6J9//vnn3z/UUUcdddThP40zzjjjjOM/HXXUUUcd5T9edtlll13mPxZYYIEFFug/v/rqq6++6j+33XbbbbftP9BAAw000PA//PHHH3/88T8BAwwwwADzP8kjjzzyyPM/pZRSSiml9D+VVVZZZZX1PyKGGGKIIfY/Jpdccskl9z/GF1988cX3P7bYYosttvg/uummm266+T+XWmqppZb6P8MLL7zwwvs/F1100UUX/T/kjjvuuOP+PzDAAAMMMABAxhhjjDHGAEAMMcQQQwwBQHDBBRdccAFAookmmmiiAUC22WabbbYBQKyxxhprrAFAttlmm222AUDAAQcccMABQLbZZptttgFAttlmm222AUC22WabbbYBQLbZZptttgFAttlmm222AUC22WabbbYBQMABBxxwwAFAwAEHHHDAAUC22WabbbYBQMABBxxwwAFAyimnnHLKAUDKKaeccsoBQMopp5xyygFAwAEHHHDAAUDKKaeccsoBQMopp5xyygFAyimnnHLKAUDKKaeccsoBQNRRRx111AFA6KGHHnroAUD88ccff/wBQBtqqKGGGgJAYYIJJphgAkDFEkssscQCQCmjjDLKKANAq6uuuuqqA0APPPDAAw8EQGmkkUYaaQRAzTTTTDPNBEBFFVVUUUUFQLPNNttsswVADjbYYIMNBkBonnnmmWcGQJpmmmmmmQZA4H777bffBkAml1xyySUHQGKHHXbYYQdAlE8++eSTB0DGF1988cUHQPjff//99wdAPvjggw8+CEBccMEFF1wIQEgggQQSSAhAPvjggw8+CEA++OCDDz4IQD744IMPPghAPvjggw8+CEBIIIEEEkgIQD744IMPPghAPvjggw8+CEA++OCDDz4IQEgggQQSSAhAPvjggw8+CEA++OCDDz4IQEgggQQSSAhAPvjggw8+CEBSSCGFFFIIQGaYYYYZZghAZphhhhlmCEB66KGHHnoIQHrooYceeghAhBBCCCGECECiiCKKKKIIQMAAAwwwwAhALrnkkksuCUCmmWaaaaYJQDPKKKOMMgpAoYIKKqigCkAts8wyyywLQH3zzTfffAtAueOOO+64C0D/+++///4LQFlkkUUWWQxAiyyyyCKLDEANNdRQQw0NQKONNtpoow1AJpZYYoklDkCKJppoookOQNpmm2222Q5ADC+88MILD0ACBxxwwAEPQAwvvPDCCw9AFldcccUVD0A0zzzzzDMPQDTPPPPMMw9ASB999NFHD0Bwv/32228PQLbXXnvttQ9AHHDAAQccEEBOOOGEE04QQHbYYYcddhBAlFBCCSWUEECttNJKK60QQMEEE0wwwRBA0EADDTTQEEDuuOOOO+4QQBZZZJFFFhFANNFEE000EUA00UQTTTQRQEghhRRSSBFAddVVV111EUCiiSaaaKIRQMUVV1xxxRFA7bXXXnvtEUAMLrjgggsSQBtqqKGGGhJAKqaYYoopEkA0zjjjjDMSQD722GOPPRJAZpZZZpllEkCYXnrppZcSQLbWWmuttRJAwP7666+/EkC21lprrbUSQLHCCiussBJAscIKK6ywEkCxwgorrLASQKeaaqqpphJAp5pqqqmmEkCdcsopp5wSQJNKKqmkkhJAZpZZZpllEkBIHnnkkUcSQCWSSCKJJBJADC644IILEkDyySeffPIRQM8999xzzxFAscUWW2yxEUCOOeaYY44RQGGFFVZYYRFAQw011FBDEUD44IMPPvgQQK200korrRBAWGCBBRZYEEAhhBBCCCEQQAMMMMAAAxBA8scff/zxD0DUTz/99NMPQKyvvvrqqw9AhA8++OCDD0BIH3300UcPQAwvvPDCCw9AxhZbbLHFDkBYXnnllVcOQOmll1566Q1Ae+211157DUA//fTTTz8NQOWUU0455QxAlVRSSSWVDEBFFFFEEUUMQP/777///gtA11tvvfXWC0C544477rgLQLnjjjvuuAtAr7vuuuuuC0Cvu+66664LQLnjjjvuuAtAr7vuuuuuC0Cvu+66664LQLnjjjvuuAtAueOOO+64C0Cvu+66664LQK+77rrrrgtAr7vuuuuuC0C544477rgLQLnjjjvuuAtAueOOO+64C0DNM88888wLQLnjjjvuuAtAueOOO+64C0C544477rgLQLnjjjvuuAtAwwsvvPDCC0DDCy+88MILQMMLL7zwwgtAueOOO+64C0DDCy+88MILQLnjjjvuuAtAueOOO+64C0C544477rgLQLnjjjvuuAtAzTPPPPPMC0A=\",\"dtype\":\"float64\",\"order\":\"little\",\"shape\":[1000]}},\"selected\":{\"id\":\"1050\"},\"selection_policy\":{\"id\":\"1049\"}},\"id\":\"1034\",\"type\":\"ColumnDataSource\"},{\"attributes\":{},\"id\":\"1010\",\"type\":\"LinearScale\"},{\"attributes\":{},\"id\":\"1025\",\"type\":\"HelpTool\"},{\"attributes\":{},\"id\":\"1008\",\"type\":\"LinearScale\"},{\"attributes\":{\"coordinates\":null,\"group\":null},\"id\":\"1041\",\"type\":\"Title\"},{\"attributes\":{\"coordinates\":null,\"data_source\":{\"id\":\"1034\"},\"glyph\":{\"id\":\"1036\"},\"group\":null,\"hover_glyph\":null,\"muted_glyph\":{\"id\":\"1038\"},\"nonselection_glyph\":{\"id\":\"1037\"},\"view\":{\"id\":\"1040\"}},\"id\":\"1039\",\"type\":\"GlyphRenderer\"},{\"attributes\":{},\"id\":\"1049\",\"type\":\"UnionRenderers\"},{\"attributes\":{\"axis_label\":\"time (s)\",\"coordinates\":null,\"formatter\":{\"id\":\"1047\"},\"group\":null,\"major_label_policy\":{\"id\":\"1048\"},\"ticker\":{\"id\":\"1013\"}},\"id\":\"1012\",\"type\":\"LinearAxis\"},{\"attributes\":{},\"id\":\"1006\",\"type\":\"DataRange1d\"},{\"attributes\":{\"axis_label\":\"voltage (V)\",\"coordinates\":null,\"formatter\":{\"id\":\"1044\"},\"group\":null,\"major_label_policy\":{\"id\":\"1045\"},\"ticker\":{\"id\":\"1017\"}},\"id\":\"1016\",\"type\":\"LinearAxis\"},{\"attributes\":{},\"id\":\"1050\",\"type\":\"Selection\"},{\"attributes\":{\"end\":37.549,\"start\":17.033},\"id\":\"1004\",\"type\":\"Range1d\"},{\"attributes\":{},\"id\":\"1020\",\"type\":\"PanTool\"},{\"attributes\":{\"overlay\":{\"id\":\"1026\"}},\"id\":\"1022\",\"type\":\"BoxZoomTool\"},{\"attributes\":{},\"id\":\"1044\",\"type\":\"BasicTickFormatter\"},{\"attributes\":{},\"id\":\"1013\",\"type\":\"BasicTicker\"},{\"attributes\":{},\"id\":\"1023\",\"type\":\"SaveTool\"},{\"attributes\":{\"axis\":{\"id\":\"1016\"},\"coordinates\":null,\"dimension\":1,\"group\":null,\"ticker\":null},\"id\":\"1019\",\"type\":\"Grid\"},{\"attributes\":{\"line_alpha\":0.1,\"line_color\":\"#1f77b4\",\"x\":{\"field\":\"time (sec)\"},\"y\":{\"field\":\"voltage (V)\"}},\"id\":\"1037\",\"type\":\"Line\"},{\"attributes\":{\"line_color\":\"#1f77b4\",\"x\":{\"field\":\"time (sec)\"},\"y\":{\"field\":\"voltage (V)\"}},\"id\":\"1036\",\"type\":\"Line\"},{\"attributes\":{},\"id\":\"1024\",\"type\":\"ResetTool\"},{\"attributes\":{},\"id\":\"1017\",\"type\":\"BasicTicker\"},{\"attributes\":{\"tools\":[{\"id\":\"1020\"},{\"id\":\"1021\"},{\"id\":\"1022\"},{\"id\":\"1023\"},{\"id\":\"1024\"},{\"id\":\"1025\"}]},\"id\":\"1027\",\"type\":\"Toolbar\"},{\"attributes\":{\"line_alpha\":0.2,\"line_color\":\"#1f77b4\",\"x\":{\"field\":\"time (sec)\"},\"y\":{\"field\":\"voltage (V)\"}},\"id\":\"1038\",\"type\":\"Line\"},{\"attributes\":{},\"id\":\"1045\",\"type\":\"AllLabels\"},{\"attributes\":{},\"id\":\"1047\",\"type\":\"BasicTickFormatter\"},{\"attributes\":{\"source\":{\"id\":\"1034\"}},\"id\":\"1040\",\"type\":\"CDSView\"},{\"attributes\":{\"bottom_units\":\"screen\",\"coordinates\":null,\"fill_alpha\":0.5,\"fill_color\":\"lightgrey\",\"group\":null,\"left_units\":\"screen\",\"level\":\"overlay\",\"line_alpha\":1.0,\"line_color\":\"black\",\"line_dash\":[4,4],\"line_width\":2,\"right_units\":\"screen\",\"syncable\":false,\"top_units\":\"screen\"},\"id\":\"1026\",\"type\":\"BoxAnnotation\"},{\"attributes\":{},\"id\":\"1048\",\"type\":\"AllLabels\"},{\"attributes\":{},\"id\":\"1021\",\"type\":\"WheelZoomTool\"},{\"attributes\":{\"axis\":{\"id\":\"1012\"},\"coordinates\":null,\"group\":null,\"ticker\":null},\"id\":\"1015\",\"type\":\"Grid\"}],\"root_ids\":[\"1003\"]},\"title\":\"Bokeh Application\",\"version\":\"2.4.2\"}};\n", " const render_items = [{\"docid\":\"61b668bd-ed20-4821-aca3-cdea05bf9eaf\",\"root_ids\":[\"1003\"],\"roots\":{\"1003\":\"bd1e6902-454e-4a03-ac68-13336779b8ac\"}}];\n", " root.Bokeh.embed.embed_items_notebook(docs_json, render_items);\n", "\n", " }\n", " if (root.Bokeh !== undefined) {\n", " embed_document(root);\n", " } else {\n", " let attempts = 0;\n", " const timer = setInterval(function(root) {\n", " if (root.Bokeh !== undefined) {\n", " clearInterval(timer);\n", " embed_document(root);\n", " } else {\n", " attempts++;\n", " if (attempts > 100) {\n", " clearInterval(timer);\n", " console.log(\"Bokeh: ERROR: Unable to run BokehJS code because BokehJS library is missing\");\n", " }\n", " }\n", " }, 10, root)\n", " }\n", "})(window);" ], "application/vnd.bokehjs_exec.v0+json": "" }, "metadata": { "application/vnd.bokehjs_exec.v0+json": { "id": "1003" } }, "output_type": "display_data" } ], "source": [ "# Get the data from from the result\n", "df = daq_task.result()\n", "\n", "# Convert milliseconds to seconds\n", "df['time (sec)'] = df['time (ms)'] / 1000\n", "\n", "# Plot!\n", "p = bokeh.plotting.figure(\n", " x_axis_label='time (s)',\n", " y_axis_label='voltage (V)',\n", " frame_height=175,\n", " frame_width=500,\n", " x_range=[df['time (sec)'].min(), df['time (sec)'].max()],\n", ")\n", "p.line(source=df, x='time (sec)', y='voltage (V)')\n", "\n", "bokeh.io.show(p)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## App with live updates\n", "\n", "While the above approach is useful, it leaves a bit to be desired, since we are not updating the plot in real time. Only after data acquisition is finished can we make a plot. We would rather have the plot update in real time, together with a button to start and stop data acquisition. In order to do that, we need to connect a Bokeh plot to a **periodic callback**, which constantly checks the dictionary of acquired data and updates the plot accordingly.\n", "\n", "As an example of how a Bokeh app can use a periodic callback for streaming, I build one below to dynamically plot a random walk. The walk will proceed with a dot doing the walk and the trail behind it represented as a line. To build a Bokeh app, we need to write a function the controls the app. The function for the random walker is shown below with an explanation following immediately." ] }, { "cell_type": "code", "execution_count": 25, "metadata": {}, "outputs": [], "source": [ "def random_walk(doc):\n", " \"\"\"Bokeh app for a dynamic random walk of 1000 steps.\"\"\"\n", " rg = np.random.default_rng(3252)\n", "\n", " p = bokeh.plotting.figure(\n", " frame_width=200,\n", " frame_height=200,\n", " x_range=[-20, 20],\n", " y_range=[-20, 20],\n", " )\n", "\n", " # Use ColumnDataSources for data for populating glyphs\n", " source_line = bokeh.models.ColumnDataSource({\"x\": [0], \"y\": [0]})\n", " source_dot = bokeh.models.ColumnDataSource({\"x\": [0], \"y\": [0]})\n", " line = p.line(source=source_line, x=\"x\", y=\"y\")\n", " dot = p.circle(source=source_dot, x=\"x\", y=\"y\", color=\"tomato\", size=7)\n", "\n", " @bokeh.driving.linear()\n", " def update(step):\n", " if step > 1000:\n", " doc.remove_periodic_callback(pc)\n", " else:\n", " theta = rg.uniform(0, 2 * np.pi)\n", " new_position = {\n", " \"x\": [source_dot.data[\"x\"][0] + np.cos(theta)],\n", " \"y\": [source_dot.data[\"y\"][0] + np.sin(theta)],\n", " }\n", " source_line.stream(new_position)\n", " source_dot.data = new_position\n", "\n", " doc.add_root(p)\n", "\n", " # Add a periodic callback to be run every 20 milliseconds\n", " pc = doc.add_periodic_callback(update, 20)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The argument of the function (traditionally called `doc`) is accessed to add any plots (or other Bokeh) to app and to add the callbacks. We first set up the figure and set up the data sources for the dot and for the line. Next, we set up the data sources using Bokeh's `ColumnDataSource`. So far in the bootcamp, we have use Pandas data frames as the source for plots. Under the hood, Bokeh automatically converts those to its `ColumnDataSource` data type. This data type may be dynamically updated in a Bokeh app, which is exactly what we want. After the data sources are set up, we set up an update function (we call it `update()` here, but it could have any name). This is what will be called each time the periodic callback triggers. In this case, we decorate the function with `@bokeh.driving.linear()`. This results in the argument of `update`, `step`, being advanced by one every time the function is called. This way we can keep track of how many steps were taken. In the update function, if we have exceeded the number of desired steps, we cancel the periodic callbacks. Otherwise, we compute the next step of the random walk by computing a random angle for the step. We update the position of the walker by adding the step to it. Finally, we update the data sources for the dot and line. For the line, we use the `stream()` method. This results in Bokeh only appending new data to the data source instead of pushing through the whole data set for the plot each time. For the dot, since it is only plotted as a single position, we update the source data to be the dot position.\n", "\n", "To run our app, we use `bokeh.io.show()`. We should also include the URL of the notebook (specified above in the input cell; you can see the URL by looking at the top of your browser)." ] }, { "cell_type": "code", "execution_count": 26, "metadata": {}, "outputs": [ { "data": { "application/vnd.bokehjs_exec.v0+json": "", "text/html": [ "\n", "" ] }, "metadata": { "application/vnd.bokehjs_exec.v0+json": { "server_id": "b2a41e69c2cc4c02b315ddb1e5124b69" } }, "output_type": "display_data" } ], "source": [ "bokeh.io.show(random_walk, notebook_url=notebook_url)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can use this technique to build an app for acquiring voltages coming out of the potentiometer. Our strategy for building our app is this:\n", "\n", "- Set up a dictionary containing lists of data\n", "- Asynchronously collect data from Arduino that updates the data dictionary\n", "- Set up a periodic callback so Bokeh updates the plot from the data dictionary\n", "\n", "To do this, we need to keep track of which data are included on the plot and which are new. Therefore, the data dictionary also contains a variable to remember how long the time point and voltage lists were the last time the plot was rendered." ] }, { "cell_type": "code", "execution_count": 27, "metadata": {}, "outputs": [], "source": [ "# Set up data dictionary\n", "data = dict(prev_array_length=0, time_ms=[], voltage=[])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Next, we build the plotting app. Because the app must have a call signature `app(doc)`, I like to write a function that returns an app. This allows me to have a more convenient API for specifying properties of the app. This app is essentially like the random walk app, except that we pull data out of the data dictionary as needed. We also have a **rollover** parameter, which specifies the maximum number of data points to be displayed on the plot. Only the most recent data points are displayed. For time series data, like we're plotting here, this results in a \"scroll\" across the plot, kind of like a stock ticker.\n", "\n", "I have also included a keyword argument for the delay between plot updates. If the delay is too short, your computer will struggle trying to render the Bokeh plot at a high rate. In my experience, plots that are updated every 100 ms or less look like essentially continuous updates to the eye, so I use a plot delay of 90 ms." ] }, { "cell_type": "code", "execution_count": 28, "metadata": {}, "outputs": [], "source": [ "def potentiometer_app(data, n_data=100, rollover=400, plot_update_delay=90):\n", " \"\"\"Return a function defining a Bokeh app for streaming\n", " data up to `n_data` data points. A maximum of `rollover`\n", " data points are shown at a time.\n", " \"\"\"\n", " def _app(doc):\n", " # Instatiate figures\n", " p = bokeh.plotting.figure(\n", " frame_width=500,\n", " frame_height=175,\n", " x_axis_label=\"time (s)\",\n", " y_axis_label=\"voltage (V)\",\n", " y_range=[-0.2, 5.2],\n", " )\n", "\n", " # No padding on x_range makes data flush with end of plot\n", " p.x_range.range_padding = 0\n", "\n", " # Start with an empty column data source with time and voltage\n", " source = bokeh.models.ColumnDataSource({\"t\": [], \"V\": []})\n", "\n", " # Put a line glyph\n", " r = p.line(source=source, x=\"t\", y=\"V\")\n", "\n", " @bokeh.driving.linear()\n", " def update(step):\n", " # Shut off periodic callback if we have plotted all of the data\n", " if step > n_data:\n", " doc.remove_periodic_callback(pc)\n", " else:\n", " # Update plot by streaming in data\n", " source.stream(\n", " {\n", " \"t\": np.array(data['time_ms'][data['prev_array_length']:]) / 1000,\n", " \"V\": data['voltage'][data['prev_array_length']:],\n", " },\n", " rollover,\n", " )\n", " data['prev_array_length'] = len(data['time_ms'])\n", "\n", " doc.add_root(p)\n", " pc = doc.add_periodic_callback(update, plot_update_delay)\n", "\n", "\n", " return _app" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now, to put the app to use! We need to show the app, and then create a task to acquire the data. The plot is then updated live! (Note that this is not viewable in the static HTML version of this lesson.)" ] }, { "cell_type": "code", "execution_count": 29, "metadata": {}, "outputs": [ { "data": { "application/vnd.bokehjs_exec.v0+json": "", "text/html": [ "\n", "" ] }, "metadata": { "application/vnd.bokehjs_exec.v0+json": { "server_id": "9bd8a066f1654ecc865b60af8dfc5c47" } }, "output_type": "display_data" } ], "source": [ "n_data = 1000\n", "\n", "bokeh.io.show(potentiometer_app(data, n_data=n_data), notebook_url=notebook_url)\n", "daq_task = asyncio.create_task(daq_stream_async(arduino, data, n_data=n_data, delay=20))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can now watch data acquisition in real time! In the next lesson, we will go a few steps further: We will make an app with controls for starting, stopping, and saving acquisition that can stand alone on its own page in a browser (outside of a Jupyter notebook).\n", "\n", "Before we end this lesson, we have to do the all-important closing of the serial connection!" ] }, { "cell_type": "code", "execution_count": 30, "metadata": {}, "outputs": [], "source": [ "arduino.close()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Computing environment" ] }, { "cell_type": "code", "execution_count": 31, "metadata": { "tags": [ "hide-input" ] }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Python implementation: CPython\n", "Python version : 3.9.12\n", "IPython version : 8.3.0\n", "\n", "numpy : 1.21.5\n", "pandas : 1.4.2\n", "serial : 3.5\n", "bokeh : 2.4.2\n", "jupyterlab: 3.3.2\n", "\n" ] } ], "source": [ "%load_ext watermark\n", "%watermark -v -p numpy,pandas,serial,bokeh,jupyterlab" ] } ], "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.9.12" } }, "nbformat": 4, "nbformat_minor": 4 }