{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "import panel as pn\n",
    "import numpy as np\n",
    "import holoviews as hv\n",
    "from holoviews.streams import Buffer\n",
    "from bokeh.models import Button, Slider, Spinner\n",
    "import time\n",
    "import asyncio\n",
    "\n",
    "pn.extension(sizing_mode=\"stretch_width\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "This app provides a simple example of a graphical interface for scientific instrument control using Panel for layout/interaction and [Holoviews](http://holoviews.org) for buffering and plotting data from the instrument.\n",
    "\n",
    "First we make a mock instrument for this standalone example. The non-mock version of this class would communicate with the instrument (via serial/USB or NI-VISA, etc.)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class FakeInstrument(object):\n",
    "    def __init__(self, offset=0.0):\n",
    "        self.offset = offset\n",
    "\n",
    "    def set_offset(self, value):\n",
    "        self.offset = value\n",
    "\n",
    "    def read_data(self):\n",
    "        return np.random.random() + self.offset\n",
    "\n",
    "instrument = FakeInstrument()  # Instantiate your instrument"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now set up the buffer and plot to handle the streaming data. You could get by without making a Pandas Dataframe, but it does a good job of writing to a csv file. See [Working with Streaming Data](http://holoviews.org/user_guide/Streaming_Data.html) in the Holoviews documentation for other options.\n",
    "\n",
    "Here we're only plotting one line of data, so we can create the DynamicMap simply by passing it hv.Curve. The Curve function is going to assume we want to plot the \"Temperature (°C)\" column versus the \"Time (s)\" column and generate the plot accordingly. If we wanted some other behavior, or if we had another column in our dataset and wanted to plot two lines at once, we could instead use functools.partial or define our own function that uses hv.Curve to plot the lines the way we want."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def make_df(time_sec=0.0, temperature_degC=0.0):\n",
    "    return pd.DataFrame({'Time (s)': time_sec, 'Temperature (°C)': temperature_degC}, index=[0])\n",
    "\n",
    "example_df = pd.DataFrame(columns=make_df().columns)\n",
    "buffer = Buffer(example_df, length=1000, index=False)\n",
    "plot = hv.DynamicMap(hv.Curve, streams=[buffer]).opts(padding=0.1, height=600, xlim=(0, None), responsive=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Next we make our GUI components."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "LABEL_START = 'Start'\n",
    "LABEL_STOP = 'Stop'\n",
    "LABEL_CSV_START = \"Save to csv\"\n",
    "LABEL_CSV_STOP = \"Stop save\"\n",
    "CSV_FILENAME = 'tmp.csv'\n",
    "\n",
    "button_startstop = Button(label=LABEL_START, button_type=\"primary\")\n",
    "button_csv = Button(label=LABEL_CSV_START, button_type=\"success\")\n",
    "offset = Slider(title='Offset', start=-10.0, end=10.0, value=0.0, step=0.1)\n",
    "interval = Spinner(title=\"Interval (sec)\", value=0.1, step=0.01)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now we define the functionality. As in the Holoviews documentation on [Working with Streaming Data](http://holoviews.org/user_guide/Streaming_Data.html), here we're using a coroutine to handle getting and plotting the data without blocking the GUI (although here we're using async/await rather than a decorator). This asychronous approach works fine if you are only trying to get data from your instrument once every ~50 ms or so. If you need to communicate with your instrument more frequently than that, then you'll want a separate thread (and maybe even separate hardware) to handle the communication, and you will want to update the plot with blocks of data points rather than with every individual point."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "acquisition_task = None\n",
    "save_to_csv = False\n",
    "\n",
    "async def acquire_data(interval_sec=0.1):\n",
    "    global save_to_csv\n",
    "    t0 = time.time()\n",
    "    while True:\n",
    "        instrument.set_offset(offset.value)\n",
    "        time_elapsed = time.time() - t0\n",
    "        value = instrument.read_data()\n",
    "        b = make_df(time_elapsed, value)\n",
    "        buffer.send(b)\n",
    "\n",
    "        if save_to_csv:\n",
    "            b.to_csv(CSV_FILENAME, header=False, index=False, mode='a')\n",
    "\n",
    "        time_spent_buffering = time.time() - t0 - time_elapsed\n",
    "        if interval_sec > time_spent_buffering:\n",
    "            await asyncio.sleep(interval_sec - time_spent_buffering)\n",
    "\n",
    "\n",
    "def toggle_csv(*events):\n",
    "    global save_to_csv\n",
    "    if button_csv.label == LABEL_CSV_START:\n",
    "        button_csv.label = LABEL_CSV_STOP\n",
    "        example_df.to_csv(CSV_FILENAME, index=False)  # example_df is empty, so this just writes the header\n",
    "        save_to_csv = True\n",
    "    else:\n",
    "        save_to_csv = False\n",
    "        button_csv.label = LABEL_CSV_START\n",
    "\n",
    "\n",
    "def start_stop(*events):\n",
    "    global acquisition_task, save_to_csv\n",
    "    if button_startstop.label == LABEL_START:\n",
    "        button_startstop.label = LABEL_STOP\n",
    "        buffer.clear()\n",
    "        acquisition_task = asyncio.get_running_loop().create_task(acquire_data(interval_sec=interval.value))\n",
    "    else:\n",
    "        acquisition_task.cancel()\n",
    "        button_startstop.label = LABEL_START\n",
    "        if save_to_csv:\n",
    "            toggle_csv()\n",
    "\n",
    "\n",
    "button_startstop.on_click(start_stop)\n",
    "button_csv.on_click(toggle_csv)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Finally, layout the GUI and start it. To run this in a notebook, we are using the .show method on a Panel object to start a Bokeh server and open the GUI in a new browser window. See [Depolying Bokeh Apps](http://holoviews.org/user_guide/Deploying_Bokeh_Apps.html) for more info and other options."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "hv.extension('bokeh')\n",
    "hv.renderer('bokeh').theme = 'caliber'\n",
    "controls = pn.WidgetBox('# Controls',\n",
    "                        button_startstop,\n",
    "                        button_csv,\n",
    "                        interval,\n",
    "                        offset,\n",
    "                        )\n",
    "\n",
    "pn.Row(plot, controls)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## App\n",
    "\n",
    "Lets wrap it into nice template that can be served via `panel serve hardware_automation.ipynb`"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pn.template.FastListTemplate(\n",
    "    site=\"Panel\", \n",
    "    title=\"Hardware Automation, IoT, Streaming and Async\", \n",
    "    sidebar=[*controls], \n",
    "    main=[\n",
    "        \"This app provides a simple example of a graphical interface for **scientific instrument control** using Panel for layout/interaction and [Holoviews](http://holoviews.org) for buffering and plotting data from the instrument.\",\n",
    "        plot,\n",
    "    ]\n",
    ").servable();"
   ]
  }
 ],
 "metadata": {
  "language_info": {
   "name": "python",
   "pygments_lexer": "ipython3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
