{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Lesson 32: Control panels\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 asyncio\n", "import re\n", "import sys\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.layouts\n", "import bokeh.driving\n", "bokeh.io.output_notebook()\n", "\n", "notebook_url = \"localhost:8888\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "
\n", "\n", "We have seen how to create buttons for controlling data acquisition, how to asynchronously acquire streaming data, and how to have automatically updating plots. Taken together, you now have the tools to build a *control panel* for a device. When building a device, it is important to have intuitive controls and displays to get the most out of your device. Not only does this speed up your work, it also enables other researchers to easily use your device.\n", "\n", "You are of course thoughtful about the physical design of any device you make. There's no option; it won't work if you don't build it well. At the same time, you should also be thoughtful about how you *interface* with the device. This is often overlooked. I view it as similar to software that has a bad, poorly documented API. No one, not even future you, will know how to operate your device unless you are careful about its interface and documentation.\n", "\n", "We will again use the setup of the last few lessons with the same utility functions." ] }, { "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", "\n", "\n", "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\n", "\n", "\n", "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\n", "\n", "\n", "def parse_read(read):\n", " \"\"\"Parse a read with time, volage 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()\n", "\n", "\n", "def parse_raw(raw):\n", " \"\"\"Parse bytes output from Arduino.\"\"\"\n", " raw = raw.decode()\n", " if raw[-1] != \"\\n\":\n", " raise ValueError(\n", " \"Input must end with newline, otherwise message is incomplete.\"\n", " )\n", "\n", " t, V = raw.rstrip().split(\",\")\n", "\n", " return int(t), int(V) * 5 / 1023\n", "\n", "\n", "def request_single_voltage(arduino):\n", " \"\"\"Ask Arduino for a single data point\"\"\"\n", " # Ask Arduino for data\n", " arduino.write(bytes([VOLTAGE_REQUEST]))\n", "\n", " # Read in the data\n", " raw = arduino.read_until()\n", "\n", " # Parse and return\n", " return parse_raw(raw)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The physical setup is shown below.\n", "\n", "
\n", "\n", "![Arduino](arduino_setup.svg)\n", " \n", "
\n", "\n", "And here is the code to upload to Arduino.\n", "\n", "```arduino\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": [ "## Sketching a dashboard\n", "\n", "I find it is always helpful to first sketch how I would like my dashboard to look. The first version I came up with is this:\n", "\n", "
\n", " \n", "![Dashboard sketch v. 1](dashboard_sketch_1.png)\n", " \n", "
\n", "\n", "\n", "The idea is that I have a tabbed plot (which is possible in Bokeh). The user chooses either to be in streaming mode or to be in on-demand mode by selecting the tab. Streaming starts by pressing the start button (it is \"Acquire\" in on-demand mode), and stops by pressing the stop button. Streaming is allowed to begin again by again pressing start. To save the data, the user can enter a file name and click the save button.\n", "\n", "I built this app, and then realized some problems with it.\n", "\n", "1. It is ambiguous whether to save streaming or on-demand data in the event that both are happening at the same time.\n", "2. There actually is no stop button for on-demand. (Duh!)\n", "3. If data are streaming and the user switches to on-demand mode, the user cannot see the streaming data anymore.\n", "\n", "I scrapped this design and moved on to another.\n", "\n", "
\n", " \n", "![Dashboard sketch v. 2](dashboard_sketch_2.png)\n", " \n", "
\n", "\n", "This version alleviates the problems mentioned above. The \"stream\" button on the stream box of the app is a toggle button, meaning that once pressed, it stays on until pressed again. Thus, one button controls whether streaming is on or off.\n", "\n", "I also added reset buttons to both. Clicking the reset button clears the data (and stops streaming if streaming is still active) allowing fresh data to be collected.\n", "\n", "After considering this design, I decided I wanted to add two more features.\n", "\n", "1. An indicator as if data had been saved to a file under the \"save\" button.\n", "2. An option to shut down the app. In the shutdown procedure, the asynchronous data acquisition is halted and Arduino is disconnected. In the stand-alone app (that is, outside of a Jupyter notebook), the server is also shut down.\n", "\n", "The final design I sketched is show below.\n", "\n", "
\n", " \n", "![Dashboard sketch v. 3](dashboard_sketch_3.png)\n", " \n", "
\n", "\n", "In a moment, we'll build the components of the app: the buttons, the text inputs, the plots. First, though, we need to connect to Arduino and set up the data acquisition." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Connecting to Arduino and opening DAQ\n", "\n", "First, we'll open connection to Arduino in the same way we have been in the past several lessons." ] }, { "cell_type": "code", "execution_count": 3, "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", "GREEN_LED_ON = 4\n", "GREEN_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": [ "Next, we need to have variables to store data coming in from streaming and on-demand. These need to be mutable objects that we can pass into data collection functions and into the function defining the Bokeh app. We will therefore use dictionaries of lists, one for streaming data and one for on-demand data. For the streaming dictionary, we should also keep two other variables, the mode of data acquisition (either `'stream'` or `'on demand'`), and a variable to keep track of the array length of the data currently on the streaming plot. We need to keep that reference so we know which data points to add to the plot after each acquisition, since we acquire more than one data point at a time." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "# Set up data dictionaries\n", "stream_data = dict(prev_array_length=0, t=[], V=[], mode='on demand')\n", "on_demand_data = dict(t=[], V=[])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Finally, we'll set up the data stream. We have to have another layer of logic here. If the `mode` entry in the `steam_data` dictionary changes to `'stream'`, we need to set Arduino to stream mode. We only want to do this when it changes to `'stream'`, not on every data acquisition, so we also have a local variable `stream_on` that keeps track of whether or not Arduino has been instructed to stream data." ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "async def daq_stream_async(\n", " arduino, data, delay=20, n_trash_reads=5, n_reads_per_chunk=4, reader=read_all_newlines\n", "):\n", " \"\"\"Obtain streaming data\"\"\"\n", " # Specify delay\n", " arduino.write(bytes([READ_DAQ_DELAY]) + (str(delay) + \"x\").encode())\n", "\n", " # Current streaming state\n", " stream_on = False\n", "\n", " # Receive data\n", " read_buffer = [b\"\"]\n", " while True:\n", " if data[\"mode\"] == \"stream\":\n", " # Turn on the stream if need be\n", " if not stream_on:\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", " stream_on = True\n", "\n", " # Read in chunk of data\n", " raw = reader(\n", " arduino, read_buffer=read_buffer[0], n_reads=n_reads_per_chunk\n", " )\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[\"t\"] += t\n", " data[\"V\"] += V\n", " except:\n", " pass\n", " else:\n", " # Make sure stream is off\n", " stream_on = False\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", " \n", "daq_task = asyncio.create_task(daq_stream_async(arduino, stream_data))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Building the app\n", "\n", "The completed app needs to be contained in a single function that Bokeh can use to create the app. To practice modular programming, we will write functions to build the parts of the app one by one. These functions can then be used within the function defining the app. \n", "\n", "We'll start by defining the plots. When we build the plot, as before, we have to be sure to specify a `ColumnDataSource`.\n", "\n", "We also need to play a little trick. With no glyphs on a plot, Bokeh does not know how to set the axes. So, we need to provide \"phantom\" data to the plot. We specify a phantom data source that give coordinates for a single glyph, an invisible circle." ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [], "source": [ "def plot(mode):\n", " \"\"\"Build a plot of voltage vs time data\"\"\"\n", " # Set up plot area\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", " title=\"streaming data\",\n", " y_range=[-0.2, 5.2],\n", " toolbar_location=\"above\",\n", " )\n", " \n", " # No range padding on x: signal spans whole plot\n", " p.x_range.range_padding = 0\n", "\n", " # We'll sue whitesmoke backgrounds\n", " p.border_fill_color = \"whitesmoke\"\n", "\n", " # Defined the data source\n", " source = bokeh.models.ColumnDataSource(data=dict(t=[], V=[]))\n", "\n", " # If we are in streaming mode, use a line, dots for on-demand\n", " if mode == 'stream':\n", " p.line(source=source, x=\"t\", y=\"V\")\n", " else:\n", " p.circle(source=source, x=\"t\", y=\"V\")\n", "\n", " # Put a phantom circle so axis labels show before data arrive\n", " phantom_source = bokeh.models.ColumnDataSource(data=dict(t=[0], V=[0]))\n", " p.circle(source=phantom_source, x=\"t\", y=\"V\", visible=False)\n", " \n", " return p, source, phantom_source" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Next, we need to build the controls and indicators. Each has a green button for acquiring data, an orange button to reset, a blue button to save, and a text window for entering the file name to save the data. There is also the text to indicate in which file the last data set was saved.\n", "\n", "The stream control is a toggle, which stays on when clicked, as opposed to button which responds only to a single click. To instantiate a toggle, we use `bokeh.models.Toggle`. Similarly, to instantiate a button, we use `bokeh.models.Button`. Finally, we can always include an HTMV div using `bokeh.models.Div`, in this case containing text.\n", "\n", "For convenience, we store the controls in a dictionary." ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [], "source": [ "def controls(mode):\n", " if mode == \"stream\":\n", " acquire = bokeh.models.Toggle(label=\"stream\", button_type=\"success\", width=100)\n", " save_notice = bokeh.models.Div(\n", " text=\"

No streaming data saved.

\", width=165\n", " )\n", " else:\n", " acquire = bokeh.models.Button(label=\"acquire\", button_type=\"success\", width=100)\n", " save_notice = bokeh.models.Div(\n", " text=\"

No on-demand data saved.

\", width=165\n", " )\n", "\n", " save = bokeh.models.Button(label=\"save\", button_type=\"primary\", width=100)\n", " reset = bokeh.models.Button(label=\"reset\", button_type=\"warning\", width=100)\n", " file_input = bokeh.models.TextInput(\n", " title=\"file name\", value=f\"{mode}.csv\", width=165\n", " )\n", "\n", " return dict(\n", " acquire=acquire,\n", " reset=reset,\n", " save=save,\n", " file_input=file_input,\n", " save_notice=save_notice,\n", " )" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Next, we need to lay out the plot and controls. The three main Bokeh features we will use are:\n", "\n", "- `bokeh.layouts.row()`: Place elements in a row.\n", "- `bokeh.layouts.column()`: Place elements in a column.\n", "- `bokeh.models.Spacer`: Inset space (in units of pixels) between elements." ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [], "source": [ "def layout(p, ctrls):\n", " buttons = bokeh.layouts.row(\n", " bokeh.models.Spacer(width=30),\n", " ctrls[\"acquire\"],\n", " bokeh.models.Spacer(width=295),\n", " ctrls[\"reset\"],\n", " )\n", " left = bokeh.layouts.column(p, buttons, spacing=15)\n", " right = bokeh.layouts.column(\n", " bokeh.models.Spacer(height=50),\n", " ctrls[\"file_input\"],\n", " ctrls[\"save\"],\n", " ctrls[\"save_notice\"],\n", " )\n", " return bokeh.layouts.row(\n", " left, right, spacing=30, margin=(30, 30, 30, 30), background=\"whitesmoke\",\n", " )" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To see how this layout will looks, let's make a plot and some controls and lay them out." ] }, { "cell_type": "code", "execution_count": 9, "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 = {\"d87d92b1-6246-458a-8c7f-8e6a27f57b7a\":{\"defs\":[],\"roots\":{\"references\":[{\"attributes\":{\"background\":\"whitesmoke\",\"children\":[{\"id\":\"1058\"},{\"id\":\"1060\"}],\"margin\":[30,30,30,30],\"spacing\":30},\"id\":\"1061\",\"type\":\"Row\"},{\"attributes\":{\"line_alpha\":0.2,\"line_color\":\"#1f77b4\",\"x\":{\"field\":\"t\"},\"y\":{\"field\":\"V\"}},\"id\":\"1040\",\"type\":\"Line\"},{\"attributes\":{\"source\":{\"id\":\"1036\"}},\"id\":\"1042\",\"type\":\"CDSView\"},{\"attributes\":{},\"id\":\"1070\",\"type\":\"Selection\"},{\"attributes\":{\"axis_label\":\"time (s)\",\"coordinates\":null,\"formatter\":{\"id\":\"1067\"},\"group\":null,\"major_label_policy\":{\"id\":\"1068\"},\"ticker\":{\"id\":\"1015\"}},\"id\":\"1014\",\"type\":\"LinearAxis\"},{\"attributes\":{\"button_type\":\"warning\",\"icon\":null,\"label\":\"reset\",\"width\":100},\"id\":\"1053\",\"type\":\"Button\"},{\"attributes\":{},\"id\":\"1025\",\"type\":\"SaveTool\"},{\"attributes\":{},\"id\":\"1071\",\"type\":\"UnionRenderers\"},{\"attributes\":{},\"id\":\"1026\",\"type\":\"ResetTool\"},{\"attributes\":{\"fill_alpha\":{\"value\":0.2},\"fill_color\":{\"value\":\"#1f77b4\"},\"hatch_alpha\":{\"value\":0.2},\"line_alpha\":{\"value\":0.2},\"line_color\":{\"value\":\"#1f77b4\"},\"x\":{\"field\":\"t\"},\"y\":{\"field\":\"V\"}},\"id\":\"1047\",\"type\":\"Circle\"},{\"attributes\":{\"axis_label\":\"voltage (V)\",\"coordinates\":null,\"formatter\":{\"id\":\"1064\"},\"group\":null,\"major_label_policy\":{\"id\":\"1065\"},\"ticker\":{\"id\":\"1019\"}},\"id\":\"1018\",\"type\":\"LinearAxis\"},{\"attributes\":{\"line_alpha\":0.1,\"line_color\":\"#1f77b4\",\"x\":{\"field\":\"t\"},\"y\":{\"field\":\"V\"}},\"id\":\"1039\",\"type\":\"Line\"},{\"attributes\":{},\"id\":\"1072\",\"type\":\"Selection\"},{\"attributes\":{\"coordinates\":null,\"data_source\":{\"id\":\"1036\"},\"glyph\":{\"id\":\"1038\"},\"group\":null,\"hover_glyph\":null,\"muted_glyph\":{\"id\":\"1040\"},\"nonselection_glyph\":{\"id\":\"1039\"},\"view\":{\"id\":\"1042\"}},\"id\":\"1041\",\"type\":\"GlyphRenderer\"},{\"attributes\":{},\"id\":\"1027\",\"type\":\"HelpTool\"},{\"attributes\":{},\"id\":\"1068\",\"type\":\"AllLabels\"},{\"attributes\":{},\"id\":\"1065\",\"type\":\"AllLabels\"},{\"attributes\":{\"button_type\":\"primary\",\"icon\":null,\"label\":\"save\",\"width\":100},\"id\":\"1052\",\"type\":\"Button\"},{\"attributes\":{\"fill_alpha\":{\"value\":0.1},\"fill_color\":{\"value\":\"#1f77b4\"},\"hatch_alpha\":{\"value\":0.1},\"line_alpha\":{\"value\":0.1},\"line_color\":{\"value\":\"#1f77b4\"},\"x\":{\"field\":\"t\"},\"y\":{\"field\":\"V\"}},\"id\":\"1046\",\"type\":\"Circle\"},{\"attributes\":{\"axis\":{\"id\":\"1018\"},\"coordinates\":null,\"dimension\":1,\"group\":null,\"ticker\":null},\"id\":\"1021\",\"type\":\"Grid\"},{\"attributes\":{},\"id\":\"1064\",\"type\":\"BasicTickFormatter\"},{\"attributes\":{\"coordinates\":null,\"group\":null,\"text\":\"streaming data\"},\"id\":\"1004\",\"type\":\"Title\"},{\"attributes\":{},\"id\":\"1069\",\"type\":\"UnionRenderers\"},{\"attributes\":{\"end\":5.2,\"start\":-0.2},\"id\":\"1008\",\"type\":\"Range1d\"},{\"attributes\":{\"fill_color\":{\"value\":\"#1f77b4\"},\"line_color\":{\"value\":\"#1f77b4\"},\"x\":{\"field\":\"t\"},\"y\":{\"field\":\"V\"}},\"id\":\"1045\",\"type\":\"Circle\"},{\"attributes\":{\"overlay\":{\"id\":\"1028\"}},\"id\":\"1024\",\"type\":\"BoxZoomTool\"},{\"attributes\":{\"source\":{\"id\":\"1043\"}},\"id\":\"1049\",\"type\":\"CDSView\"},{\"attributes\":{\"button_type\":\"success\",\"icon\":null,\"label\":\"stream\",\"width\":100},\"id\":\"1050\",\"type\":\"Toggle\"},{\"attributes\":{\"tools\":[{\"id\":\"1022\"},{\"id\":\"1023\"},{\"id\":\"1024\"},{\"id\":\"1025\"},{\"id\":\"1026\"},{\"id\":\"1027\"}]},\"id\":\"1029\",\"type\":\"Toolbar\"},{\"attributes\":{\"data\":{\"V\":[],\"t\":[]},\"selected\":{\"id\":\"1070\"},\"selection_policy\":{\"id\":\"1069\"}},\"id\":\"1036\",\"type\":\"ColumnDataSource\"},{\"attributes\":{\"text\":\"

No streaming data saved.

\",\"width\":165},\"id\":\"1051\",\"type\":\"Div\"},{\"attributes\":{},\"id\":\"1023\",\"type\":\"WheelZoomTool\"},{\"attributes\":{\"axis\":{\"id\":\"1014\"},\"coordinates\":null,\"group\":null,\"ticker\":null},\"id\":\"1017\",\"type\":\"Grid\"},{\"attributes\":{},\"id\":\"1012\",\"type\":\"LinearScale\"},{\"attributes\":{\"range_padding\":0},\"id\":\"1006\",\"type\":\"DataRange1d\"},{\"attributes\":{\"children\":[{\"id\":\"1055\"},{\"id\":\"1050\"},{\"id\":\"1056\"},{\"id\":\"1053\"}]},\"id\":\"1057\",\"type\":\"Row\"},{\"attributes\":{\"below\":[{\"id\":\"1014\"}],\"border_fill_color\":\"whitesmoke\",\"center\":[{\"id\":\"1017\"},{\"id\":\"1021\"}],\"frame_height\":175,\"frame_width\":500,\"left\":[{\"id\":\"1018\"}],\"renderers\":[{\"id\":\"1041\"},{\"id\":\"1048\"}],\"title\":{\"id\":\"1004\"},\"toolbar\":{\"id\":\"1029\"},\"toolbar_location\":\"above\",\"x_range\":{\"id\":\"1006\"},\"x_scale\":{\"id\":\"1010\"},\"y_range\":{\"id\":\"1008\"},\"y_scale\":{\"id\":\"1012\"}},\"id\":\"1003\",\"subtype\":\"Figure\",\"type\":\"Plot\"},{\"attributes\":{\"children\":[{\"id\":\"1003\"},{\"id\":\"1057\"}],\"spacing\":15},\"id\":\"1058\",\"type\":\"Column\"},{\"attributes\":{},\"id\":\"1015\",\"type\":\"BasicTicker\"},{\"attributes\":{},\"id\":\"1019\",\"type\":\"BasicTicker\"},{\"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\":\"1028\",\"type\":\"BoxAnnotation\"},{\"attributes\":{\"line_color\":\"#1f77b4\",\"x\":{\"field\":\"t\"},\"y\":{\"field\":\"V\"}},\"id\":\"1038\",\"type\":\"Line\"},{\"attributes\":{\"height\":50},\"id\":\"1059\",\"type\":\"Spacer\"},{\"attributes\":{\"data\":{\"V\":[0],\"t\":[0]},\"selected\":{\"id\":\"1072\"},\"selection_policy\":{\"id\":\"1071\"}},\"id\":\"1043\",\"type\":\"ColumnDataSource\"},{\"attributes\":{\"coordinates\":null,\"data_source\":{\"id\":\"1043\"},\"glyph\":{\"id\":\"1045\"},\"group\":null,\"hover_glyph\":null,\"muted_glyph\":{\"id\":\"1047\"},\"nonselection_glyph\":{\"id\":\"1046\"},\"view\":{\"id\":\"1049\"},\"visible\":false},\"id\":\"1048\",\"type\":\"GlyphRenderer\"},{\"attributes\":{\"title\":\"file name\",\"value\":\"stream.csv\",\"width\":165},\"id\":\"1054\",\"type\":\"TextInput\"},{\"attributes\":{\"width\":30},\"id\":\"1055\",\"type\":\"Spacer\"},{\"attributes\":{},\"id\":\"1067\",\"type\":\"BasicTickFormatter\"},{\"attributes\":{\"width\":295},\"id\":\"1056\",\"type\":\"Spacer\"},{\"attributes\":{},\"id\":\"1010\",\"type\":\"LinearScale\"},{\"attributes\":{},\"id\":\"1022\",\"type\":\"PanTool\"},{\"attributes\":{\"children\":[{\"id\":\"1059\"},{\"id\":\"1054\"},{\"id\":\"1052\"},{\"id\":\"1051\"}]},\"id\":\"1060\",\"type\":\"Column\"}],\"root_ids\":[\"1061\"]},\"title\":\"Bokeh Application\",\"version\":\"2.4.2\"}};\n", " const render_items = [{\"docid\":\"d87d92b1-6246-458a-8c7f-8e6a27f57b7a\",\"root_ids\":[\"1061\"],\"roots\":{\"1061\":\"6ccf2f74-5eec-4afe-9dbe-017d536b9838\"}}];\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": "1061" } }, "output_type": "display_data" } ], "source": [ "p, source, phantom_source = plot('stream')\n", "ctrls = controls('stream')\n", "bokeh.io.show(layout(p, ctrls))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Looks good!\n", "\n", "Clicking any of the buttons above will not do anything. This is because they are not connected to any callbacks, or functions that are executed when the button or toggle is clicked. We need to write the callback functions for each.\n", "\n", "We'll start with the callback for pressing the \"acquire\" button to get a single on-demand data point. When we ask for data while steaming is happening, we can just pick off the last streamed data point. Otherwise, we ask for a single voltage. In either case, we append the new data point to our on-demand data dictionary. We also updated the data source to include the new data point. To do this, we use the `stream()` method of a Bokeh `ColumnDataSource`. Using this function allows for rapid update of the plot. Only the new data are added; the plot is not re-rendered.\n", "\n", "Finally, we need to update the phantom data point to be the new data point to keep the ranges of the x-axis reasonable." ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [], "source": [ "def acquire_callback(arduino, stream_data, source, phantom_source, rollover):\n", " # Pull t and V values from stream or request from Arduino\n", " if stream_data[\"mode\"] == \"stream\":\n", " t = stream_data[\"t\"][-1]\n", " V = stream_data[\"V\"][-1]\n", " else:\n", " t, V = request_single_voltage(arduino)\n", "\n", " # Add to on-demand data dictionary\n", " on_demand_data[\"t\"].append(t)\n", " on_demand_data[\"V\"].append(V)\n", "\n", " # Send new data to plot\n", " new_data = dict(t=[t / 1000], V=[V])\n", " source.stream(new_data, rollover=rollover)\n", "\n", " # Update the phantom source to keep the x_range on plot ok\n", " phantom_source.data = new_data" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Next, we'll write the callback for clicking the \"stream\" toggle. If we turn the stream on, that is if the new value of the toggle is `True`, we need to switch to streaming mode. (We do not need to tell Arduino to turn streaming on here; we already did that in our asynchronous DAQ function.) If we turn the toggle off, though (`new` is `False`), then we switch the mode to on-demand and tell Arduino to wait for requests to send data. Finally, just in case Arduino sent any incomplete messages while we were trying to tell it what to do, we should clear the input buffer on the Python side." ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [], "source": [ "def stream_callback(arduino, stream_data, new):\n", " if new:\n", " stream_data[\"mode\"] = \"stream\"\n", " else:\n", " stream_data[\"mode\"] = \"on-demand\"\n", " arduino.write(bytes([ON_REQUEST]))\n", "\n", " arduino.reset_input_buffer()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Next, we'll code up the callback of the reset buttons. If we're in streaming mode, we turn off the stream. Then, we clear out all of the arrays and sources holding data." ] }, { "cell_type": "code", "execution_count": 12, "metadata": {}, "outputs": [], "source": [ "def reset_callback(mode, data, source, phantom_source, controls):\n", " # Turn off the stream\n", " if mode == \"stream\":\n", " controls[\"acquire\"].active = False\n", "\n", " # Black out the data dictionaries\n", " data[\"t\"] = []\n", " data[\"V\"] = []\n", "\n", " # Reset the sources\n", " source.data = dict(t=[], V=[])\n", " phantom_source.data = dict(t=[0], V=[0])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Next is the save callback. In this case, we want to take the data in the data dictionary, put them in a Pandas data frame, and then write the results to the CSV file specified in the input window. We also need to update the notice text below the \"save\" button to indicate what we did." ] }, { "cell_type": "code", "execution_count": 13, "metadata": {}, "outputs": [], "source": [ "def save_callback(mode, data, controls):\n", " # Convert data to data frame and save\n", " df = pd.DataFrame(data={\"time (ms)\": data[\"t\"], \"voltage (V)\": data[\"V\"]})\n", " df.to_csv(controls[\"file_input\"].value, index=False)\n", "\n", " # Update notice text\n", " notice_text = \"

\" + (\"Streaming\" if mode == \"stream\" else \"On-demand\")\n", " notice_text += f\" data was last saved to {controls['file_input'].value}.

\"\n", " controls[\"save_notice\"].text = notice_text" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We're almost there. For the shutdown callback, we need to disable all of the controls. We do this by setting their `disabled` attribute to `True`. We also want to turn off the data stream, cancel the asynchronous data acquisition task, and close the connection to Arduino. Upon shutdown, the app is dead." ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [], "source": [ "def disable_controls(controls):\n", " \"\"\"Disable all controls.\"\"\"\n", " for key in controls:\n", " controls[key].disabled = True\n", "\n", "\n", "def shutdown_callback(\n", " arduino, daq_task, stream_data, stream_controls, on_demand_controls\n", "):\n", " # Disable controls\n", " disable_controls(stream_controls)\n", " disable_controls(on_demand_controls)\n", "\n", " # Strop streaming\n", " stream_data[\"mode\"] = \"on-demand\"\n", " arduino.write(bytes([ON_REQUEST]))\n", "\n", " # Stop DAQ async task\n", " daq_task.cancel()\n", " \n", " # Disconnect from Arduino\n", " arduino.close()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "And now for our final callback. This callback is called automatically by Bokeh on a regular time interval by adding it as a periodic callback. We stream the data to the source and also adjust the phantom data. Finally, it is important to keep track of the previous array length, as in the previous lesson." ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [], "source": [ "def stream_update(data, source, phantom_source, rollover):\n", " # Update plot by streaming in data\n", " new_data = {\n", " \"t\": np.array(data[\"t\"][data[\"prev_array_length\"] :]) / 1000,\n", " \"V\": data[\"V\"][data[\"prev_array_length\"] :],\n", " }\n", " source.stream(new_data, rollover)\n", "\n", " # Adjust new phantom data point if new data arrived\n", " if len(new_data[\"t\"] > 0):\n", " phantom_source.data = dict(t=[new_data[\"t\"][-1]], V=[new_data[\"V\"][-1]])\n", " data[\"prev_array_length\"] = len(data[\"t\"])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We now have the functions we need to build the app. As we build the app, there are a few considerations to keep in mind.\n", "\n", "1. The callbacks within the app must have a specific call signature. A button must have a callback with signature `callback(event=None)` and a toggle must have a callback with signature `callback(attr, old, new)`. So, within the app, we define callbacks with these signatures that call the functions we have defined above.\n", "2. If we are running the app outside of JupyterLab, the shut down button should also stop the Bokeh server. We can accomplish this by checking if the connection to Arduino is open in the periodic callback, and if it is closed, we terminate the app using `sys.exit()`.\n", "3. The callbacks we wrote need to be **linked** to the appropriate buttons and toggles. We do this using the `on_click()` and `on_change()` methods of the buttons and toggles.\n", "\n", "With these in mind, we can construct our app." ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, "outputs": [], "source": [ "def potentiometer_app(\n", " arduino, stream_data, on_demand_data, daq_task, rollover=400, stream_plot_delay=90,\n", "):\n", " def _app(doc):\n", " # Plots\n", " p_stream, stream_source, stream_phantom_source = plot(\"stream\")\n", " p_on_demand, on_demand_source, on_demand_phantom_source = plot(\"on demand\")\n", "\n", " # Controls\n", " stream_controls = controls(\"stream\")\n", " on_demand_controls = controls(\"on_demand\")\n", "\n", " # Shut down\n", " shutdown_button = bokeh.models.Button(\n", " label=\"shut down\", button_type=\"danger\", width=100\n", " )\n", "\n", " # Layouts\n", " stream_layout = layout(p_stream, stream_controls)\n", " on_demand_layout = layout(p_on_demand, on_demand_controls)\n", "\n", " # Shut down layout\n", " shutdown_layout = bokeh.layouts.row(\n", " bokeh.models.Spacer(width=675), shutdown_button\n", " )\n", "\n", " app_layout = bokeh.layouts.column(\n", " stream_layout, on_demand_layout, shutdown_layout\n", " )\n", "\n", " def _acquire_callback(event=None):\n", " acquire_callback(\n", " arduino,\n", " stream_data,\n", " on_demand_source,\n", " on_demand_phantom_source,\n", " rollover,\n", " )\n", "\n", " def _stream_callback(attr, old, new):\n", " stream_callback(arduino, stream_data, new)\n", "\n", " def _stream_reset_callback(event=None):\n", " reset_callback(\n", " \"stream\",\n", " stream_data,\n", " stream_source,\n", " stream_phantom_source,\n", " stream_controls,\n", " )\n", "\n", " def _on_demand_reset_callback(event=None):\n", " reset_callback(\n", " \"on demand\",\n", " on_demand_data,\n", " on_demand_source,\n", " on_demand_phantom_source,\n", " on_demand_controls,\n", " )\n", "\n", " def _stream_save_callback(event=None):\n", " save_callback(\"stream\", stream_data, stream_controls)\n", "\n", " def _on_demand_save_callback(event=None):\n", " save_callback(\"on demand\", on_demand_data, on_demand_controls)\n", "\n", " def _shutdown_callback(event=None):\n", " shutdown_callback(\n", " arduino, daq_task, stream_data, stream_controls, on_demand_controls\n", " )\n", "\n", " @bokeh.driving.linear()\n", " def _stream_update(step):\n", " stream_update(stream_data, stream_source, stream_phantom_source, rollover)\n", "\n", " # Shut down server if Arduino disconnects (commented out in Jupyter notebook)\n", " if not arduino.is_open:\n", " sys.exit()\n", "\n", " # Link callbacks\n", " stream_controls[\"acquire\"].on_change(\"active\", _stream_callback)\n", " stream_controls[\"reset\"].on_click(_stream_reset_callback)\n", " stream_controls[\"save\"].on_click(_stream_save_callback)\n", " on_demand_controls[\"acquire\"].on_click(_acquire_callback)\n", " on_demand_controls[\"reset\"].on_click(_on_demand_reset_callback)\n", " on_demand_controls[\"save\"].on_click(_on_demand_save_callback)\n", " shutdown_button.on_click(_shutdown_callback)\n", "\n", " # Add the layout to the app\n", " doc.add_root(app_layout)\n", "\n", " # Add a periodic callback, monitor changes in stream data\n", " pc = doc.add_periodic_callback(_stream_update, stream_plot_delay)\n", "\n", " return _app" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Great! Now, let's run it. (Unfortunately, the beautiful app will not be displayed in the static HTML rendering of this lesson.)" ] }, { "cell_type": "code", "execution_count": 18, "metadata": {}, "outputs": [ { "data": { "application/vnd.bokehjs_exec.v0+json": "", "text/html": [ "\n", "" ] }, "metadata": { "application/vnd.bokehjs_exec.v0+json": { "server_id": "7706ca5d05f246b2b90f8b107173194a" } }, "output_type": "display_data" } ], "source": [ "bokeh.io.show(\n", " potentiometer_app(arduino, stream_data, on_demand_data, daq_task),\n", " notebook_url=notebook_url,\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "If you are using this app in a running Jupyter notebook, be sure to click the `shut down` button to shut close the connection to Arduino." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Aside: error checking\n", "\n", "This app does not have much error checking. What kinds of features and checks would you add to this app to ensure that a careless user does not end up messing things up? Here are some safeguards to think about.\n", "\n", "1. The user can overwrite files. There is no check to see if the file name given in for saving the data corresponds to a file that already exists.\n", "2. There is also no check to see if the user enters a valid path for the file name.\n", "3. The data set could get very large is the app is left on and streaming. This could overrun the available RAM.\n", "4. The app assumed all connections with Arduino are working and does not give the user an indication that that is the case. I think adding a connection status div to the app would be a good idea." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## A stand-alone app\n", "\n", "While running the app in a Jupyter notebook is nice, for day-to-day use of our instrument and the associated app, we would like it to stand alone. That is, we would like to launch the app, have our own tab in a browser containing only the app, and then use it like a control panel for our instrument.\n", "\n", "To do this, we need to create a `.py` file with all of the above code for our app, and then **serve** it using Bokeh. If you save the code below in a `.py` files called `potentiometer_app.py`, you can then serve it using\n", "\n", " bokeh serve --show potentiometer_app.py\n", " \n", "The code looks intimidating, but it is simply a copy of everything we have above, with the only exception being that we are choosing not to print the handshake message." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "```\n", "\"\"\"\n", "App to read in varying voltages from Arduino.\n", "\n", "To serve the app, run\n", "\n", " bokeh serve --show potentiometer_app.py\n", "\n", "on the command line.\n", "\"\"\"\n", "\n", "import asyncio\n", "import re\n", "import sys\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.layouts\n", "import bokeh.driving\n", "\n", "\n", "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", "\n", "\n", "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\n", "\n", "\n", "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\n", "\n", "\n", "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 = [b\"\".join(pattern.findall(raw)).decode() for raw in read.split(b\"\\r\\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()\n", "\n", "\n", "def parse_raw(raw):\n", " \"\"\"Parse bytes output from Arduino.\"\"\"\n", " raw = raw.decode()\n", " if raw[-1] != \"\\n\":\n", " raise ValueError(\n", " \"Input must end with newline, otherwise message is incomplete.\"\n", " )\n", "\n", " t, V = raw.rstrip().split(\",\")\n", "\n", " return int(t), int(V) * 5 / 1023\n", "\n", "\n", "def request_single_voltage(arduino):\n", " \"\"\"Ask Arduino for a single data point\"\"\"\n", " # Ask Arduino for data\n", " arduino.write(bytes([VOLTAGE_REQUEST]))\n", "\n", " # Read in the data\n", " raw = arduino.read_until()\n", "\n", " # Parse and return\n", " return parse_raw(raw)\n", "\n", "\n", "def plot(mode):\n", " \"\"\"Build a plot of voltage vs time data\"\"\"\n", " # Set up plot area\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", " title=\"streaming data\" if mode == \"stream\" else \"on-demand data\",\n", " y_range=[-0.2, 5.2],\n", " toolbar_location=\"above\",\n", " )\n", "\n", " # No range padding on x: signal spans whole plot\n", " p.x_range.range_padding = 0\n", "\n", " # We'll sue whitesmoke backgrounds\n", " p.border_fill_color = \"whitesmoke\"\n", "\n", " # Defined the data source\n", " source = bokeh.models.ColumnDataSource(data=dict(t=[], V=[]))\n", "\n", " # If we are in streaming mode, use a line, dots for on-demand\n", " if mode == \"stream\":\n", " p.line(source=source, x=\"t\", y=\"V\")\n", " else:\n", " p.circle(source=source, x=\"t\", y=\"V\")\n", "\n", " # Put a phantom circle so axis labels show before data arrive\n", " phantom_source = bokeh.models.ColumnDataSource(data=dict(t=[0], V=[0]))\n", " p.circle(source=phantom_source, x=\"t\", y=\"V\", visible=False)\n", "\n", " return p, source, phantom_source\n", "\n", "\n", "def controls(mode):\n", " if mode == \"stream\":\n", " acquire = bokeh.models.Toggle(label=\"stream\", button_type=\"success\", width=100)\n", " save_notice = bokeh.models.Div(\n", " text=\"

No streaming data saved.

\", width=165\n", " )\n", " else:\n", " acquire = bokeh.models.Button(label=\"acquire\", button_type=\"success\", width=100)\n", " save_notice = bokeh.models.Div(\n", " text=\"

No on-demand data saved.

\", width=165\n", " )\n", "\n", " save = bokeh.models.Button(label=\"save\", button_type=\"primary\", width=100)\n", " reset = bokeh.models.Button(label=\"reset\", button_type=\"warning\", width=100)\n", " file_input = bokeh.models.TextInput(\n", " title=\"file name\", value=f\"{mode}.csv\", width=165\n", " )\n", "\n", " return dict(\n", " acquire=acquire,\n", " reset=reset,\n", " save=save,\n", " file_input=file_input,\n", " save_notice=save_notice,\n", " )\n", "\n", "\n", "def layout(p, ctrls):\n", " buttons = bokeh.layouts.row(\n", " bokeh.models.Spacer(width=30),\n", " ctrls[\"acquire\"],\n", " bokeh.models.Spacer(width=295),\n", " ctrls[\"reset\"],\n", " )\n", " left = bokeh.layouts.column(p, buttons, spacing=15)\n", " right = bokeh.layouts.column(\n", " bokeh.models.Spacer(height=50),\n", " ctrls[\"file_input\"],\n", " ctrls[\"save\"],\n", " ctrls[\"save_notice\"],\n", " )\n", " return bokeh.layouts.row(\n", " left, right, spacing=30, margin=(30, 30, 30, 30), background=\"whitesmoke\",\n", " )\n", "\n", "\n", "def acquire_callback(arduino, stream_data, source, phantom_source, rollover):\n", " # Pull t and V values from stream or request from Arduino\n", " if stream_data[\"mode\"] == \"stream\":\n", " t = stream_data[\"t\"][-1]\n", " V = stream_data[\"V\"][-1]\n", " else:\n", " t, V = request_single_voltage(arduino)\n", "\n", " # Add to on-demand data dictionary\n", " on_demand_data[\"t\"].append(t)\n", " on_demand_data[\"V\"].append(V)\n", "\n", " # Send new data to plot\n", " new_data = dict(t=[t / 1000], V=[V])\n", " source.stream(new_data, rollover=rollover)\n", "\n", " # Update the phantom source to keep the x_range on plot ok\n", " phantom_source.data = new_data\n", "\n", "\n", "def stream_callback(arduino, stream_data, new):\n", " if new:\n", " stream_data[\"mode\"] = \"stream\"\n", " else:\n", " stream_data[\"mode\"] = \"on-demand\"\n", " arduino.write(bytes([ON_REQUEST]))\n", "\n", " arduino.reset_input_buffer()\n", "\n", "\n", "def reset_callback(mode, data, source, phantom_source, controls):\n", " # Turn off the stream\n", " if mode == \"stream\":\n", " controls[\"acquire\"].active = False\n", "\n", " # Black out the data dictionaries\n", " data[\"t\"] = []\n", " data[\"V\"] = []\n", "\n", " # Reset the sources\n", " source.data = dict(t=[], V=[])\n", " phantom_source.data = dict(t=[0], V=[0])\n", "\n", "\n", "def save_callback(mode, data, controls):\n", " # Convert data to data frame and save\n", " df = pd.DataFrame(data={\"time (ms)\": data[\"t\"], \"voltage (V)\": data[\"V\"]})\n", " df.to_csv(controls[\"file_input\"].value, index=False)\n", "\n", " # Update notice text\n", " notice_text = \"

\" + (\"Streaming\" if mode == \"stream\" else \"On-demand\")\n", " notice_text += f\" data was last saved to {controls['file_input'].value}.

\"\n", " controls[\"save_notice\"].text = notice_text\n", "\n", "\n", "def disable_controls(controls):\n", " \"\"\"Disable all controls.\"\"\"\n", " for key in controls:\n", " controls[key].disabled = True\n", "\n", "\n", "def shutdown_callback(\n", " arduino, daq_task, stream_data, stream_controls, on_demand_controls\n", "):\n", " # Disable controls\n", " disable_controls(stream_controls)\n", " disable_controls(on_demand_controls)\n", "\n", " # Strop streaming\n", " stream_data[\"mode\"] = \"on-demand\"\n", " arduino.write(bytes([ON_REQUEST]))\n", "\n", " # Stop DAQ async task\n", " daq_task.cancel()\n", "\n", " # Disconnect from Arduino\n", " arduino.close()\n", "\n", "\n", "def stream_update(data, source, phantom_source, rollover):\n", " # Update plot by streaming in data\n", " new_data = {\n", " \"t\": np.array(data[\"t\"][data[\"prev_array_length\"] :]) / 1000,\n", " \"V\": data[\"V\"][data[\"prev_array_length\"] :],\n", " }\n", " source.stream(new_data, rollover)\n", "\n", " # Adjust new phantom data point if new data arrived\n", " if len(new_data[\"t\"] > 0):\n", " phantom_source.data = dict(t=[new_data[\"t\"][-1]], V=[new_data[\"V\"][-1]])\n", " data[\"prev_array_length\"] = len(data[\"t\"])\n", "\n", "\n", "def potentiometer_app(\n", " arduino, stream_data, on_demand_data, daq_task, rollover=400, stream_plot_delay=90,\n", "):\n", " def _app(doc):\n", " # Plots\n", " p_stream, stream_source, stream_phantom_source = plot(\"stream\")\n", " p_on_demand, on_demand_source, on_demand_phantom_source = plot(\"on demand\")\n", "\n", " # Controls\n", " stream_controls = controls(\"stream\")\n", " on_demand_controls = controls(\"on_demand\")\n", "\n", " # Shut down\n", " shutdown_button = bokeh.models.Button(\n", " label=\"shut down\", button_type=\"danger\", width=100\n", " )\n", "\n", " # Layouts\n", " stream_layout = layout(p_stream, stream_controls)\n", " on_demand_layout = layout(p_on_demand, on_demand_controls)\n", "\n", " # Shut down layout\n", " shutdown_layout = bokeh.layouts.row(\n", " bokeh.models.Spacer(width=675), shutdown_button\n", " )\n", "\n", " app_layout = bokeh.layouts.column(\n", " stream_layout, on_demand_layout, shutdown_layout\n", " )\n", "\n", " def _acquire_callback(event=None):\n", " acquire_callback(\n", " arduino,\n", " stream_data,\n", " on_demand_source,\n", " on_demand_phantom_source,\n", " rollover,\n", " )\n", "\n", " def _stream_callback(attr, old, new):\n", " stream_callback(arduino, stream_data, new)\n", "\n", " def _stream_reset_callback(event=None):\n", " reset_callback(\n", " \"stream\",\n", " stream_data,\n", " stream_source,\n", " stream_phantom_source,\n", " stream_controls,\n", " )\n", "\n", " def _on_demand_reset_callback(event=None):\n", " reset_callback(\n", " \"on demand\",\n", " on_demand_data,\n", " on_demand_source,\n", " on_demand_phantom_source,\n", " on_demand_controls,\n", " )\n", "\n", " def _stream_save_callback(event=None):\n", " save_callback(\"stream\", stream_data, stream_controls)\n", "\n", " def _on_demand_save_callback(event=None):\n", " save_callback(\"on demand\", on_demand_data, on_demand_controls)\n", "\n", " def _shutdown_callback(event=None):\n", " shutdown_callback(\n", " arduino, daq_task, stream_data, stream_controls, on_demand_controls\n", " )\n", "\n", " @bokeh.driving.linear()\n", " def _stream_update(step):\n", " stream_update(stream_data, stream_source, stream_phantom_source, rollover)\n", "\n", " # Shut down server if Arduino disconnects (commented out in Jupyter notebook)\n", " if not arduino.is_open:\n", " sys.exit()\n", "\n", " # Link callbacks\n", " stream_controls[\"acquire\"].on_change(\"active\", _stream_callback)\n", " stream_controls[\"reset\"].on_click(_stream_reset_callback)\n", " stream_controls[\"save\"].on_click(_stream_save_callback)\n", " on_demand_controls[\"acquire\"].on_click(_acquire_callback)\n", " on_demand_controls[\"reset\"].on_click(_on_demand_reset_callback)\n", " on_demand_controls[\"save\"].on_click(_on_demand_save_callback)\n", " shutdown_button.on_click(_shutdown_callback)\n", "\n", " # Add the layout to the app\n", " doc.add_root(app_layout)\n", "\n", " # Add a periodic callback, monitor changes in stream data\n", " pc = doc.add_periodic_callback(_stream_update, stream_plot_delay)\n", "\n", " return _app\n", "\n", "\n", "# Set up connection\n", "HANDSHAKE = 0\n", "VOLTAGE_REQUEST = 1\n", "RED_LED_ON = 2\n", "RED_LED_OFF = 3\n", "GREEN_LED_ON = 4\n", "GREEN_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)\n", "\n", "# Set up data dictionaries\n", "stream_data = dict(prev_array_length=0, t=[], V=[], mode=\"on demand\")\n", "on_demand_data = dict(t=[], V=[])\n", "\n", "\n", "async def daq_stream_async(\n", " arduino,\n", " data,\n", " delay=20,\n", " n_trash_reads=5,\n", " n_reads_per_chunk=4,\n", " reader=read_all_newlines,\n", "):\n", " \"\"\"Obtain streaming data\"\"\"\n", " # Specify delay\n", " arduino.write(bytes([READ_DAQ_DELAY]) + (str(delay) + \"x\").encode())\n", "\n", " # Current streaming state\n", " stream_on = False\n", "\n", " # Receive data\n", " read_buffer = [b\"\"]\n", " while True:\n", " if data[\"mode\"] == \"stream\":\n", " # Turn on the stream if need be\n", " if not stream_on:\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", " stream_on = True\n", "\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[\"t\"] += t\n", " data[\"V\"] += V\n", " except:\n", " pass\n", " else:\n", " # Make sure stream is off\n", " stream_on = False\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", "\n", "# Set up asynchronous DAQ task\n", "daq_task = asyncio.create_task(daq_stream_async(arduino, stream_data))\n", "\n", "# Build app\n", "app = potentiometer_app(\n", " arduino, stream_data, on_demand_data, daq_task, rollover=400, stream_plot_delay=90\n", ")\n", "\n", "# Build it with curdoc\n", "app(bokeh.plotting.curdoc())\n", "\n", "```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Computing environment" ] }, { "cell_type": "code", "execution_count": 19, "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 }