{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Lesson 32: Control panels\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",
" \"from bokeh.resources import INLINE\\n\"+\n",
" \"output_notebook(resources=INLINE)\\n\"+\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 \"from bokeh.resources import INLINE\\n\"+\n \"output_notebook(resources=INLINE)\\n\"+\n \"
\\n\"+\n \"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 }