{ "cells": [ { "cell_type": "markdown", "id": "f23a1216-71c4-4158-a77f-ad9bbcb4f30d", "metadata": {}, "source": [ "# Use Case 3: User interface to research intra-anaesthesisa hypotension\n", "\n", "
\n", "\n", "This notebook illustrates the usage of the vitabel package to visualize, annotate and process time-series data from the medical field. Please find the detailed, searchable documentation here: \n", "[![Documentation Status](https://readthedocs.org/projects/vitabel/badge/?version=latest)](https://vitabel.readthedocs.io/en/latest/?badge=latest)\n", "\n", "In this case we analyze **non-waveform data** from an anesthesia chart and add further labels to this data. This notebook in particular demonstrates how `vitabel` can be used outside of resuscitation science, for example to study **intra-operative hypotension** like in this example. In particular, this example illustrates how the interactive plotting functionality can be wrapped and extended which allows building a **user interface to validate** the data.\n", "\n", "
\n", "\n", "If you have never worked with _Jupyter Notebooks_ before, you may find this guide helpful: **[Beginners Guide to Jupyter Notebooks](https://mybinder.org/v2/gh/jupyter/notebook/HEAD?urlpath=%2Fdoc%2Ftree%2Fdocs%2Fsource%2Fexamples%2FNotebook%2FRunning+Code.ipynb)**" ] }, { "cell_type": "code", "execution_count": null, "id": "5341fa6c-1083-4daa-99a8-fadb30d89b46", "metadata": {}, "outputs": [], "source": [ "from vitabel import Vitals, Label\n", "import numpy as np\n", "import pandas as pd\n", "from IPython.display import display, Markdown\n", "import ipywidgets as widgets\n", "from matplotlib.collections import PolyCollection\n", "from datetime import datetime" ] }, { "cell_type": "markdown", "id": "34e111f4-d070-42e9-bbd2-ba9107b13c75", "metadata": {}, "source": [ "## 1. Loading Data" ] }, { "cell_type": "markdown", "id": "c822de38-334a-4033-88d8-7c1d1358a718", "metadata": {}, "source": [ "
\n", "\n", "A `Vitals` object is initialized and data which was saved previously with vitabel is loaded again.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": null, "id": "60dafd70-9638-4087-ae11-34df34ea4c03", "metadata": {}, "outputs": [], "source": [ "case = Vitals()\n", "case.metadata.update({\"case_id\": \"use_case_3\"})\n", "case.load_data(\"data/usecase_3.json\")" ] }, { "cell_type": "markdown", "id": "0428ba95-401c-4cf0-b3d1-9ea21b08034b", "metadata": {}, "source": [ "
\n", "\n", "We obtain an overview over all channels and labels in the signal.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": null, "id": "61cd9d88-37a5-4a70-9da5-9e08fc89883c", "metadata": {}, "outputs": [], "source": [ "case.info()" ] }, { "cell_type": "markdown", "id": "c9048ba5-276a-4b83-a3fe-dd9c8f42a50f", "metadata": {}, "source": [ "## 2. Processing Data\n", "\n", "
\n", "\n", "In this project, we're approaching things a little different. Our primary focus is on the mean arterial pressure (MAP) during anaesthesia. To analyze this accurately we need to validate both, the MAP recordings themselves\n", " and the defined anaesthesia interval.\n", " \n", "
" ] }, { "cell_type": "markdown", "id": "21fe2eab-022c-49bd-94f8-697055f92fd7", "metadata": {}, "source": [ "
\n", "\n", "Conceptually `vitabel` devides time-series data into `channels` and `labels`:\n", " - **channels** contain raw data recorded by a device\n", " - **labels** provide additional information—either annotated manually or derived from channel data\n", "\n", "
\n", "\n", "_This distinction can become blurred in cases where the recording device itself generates derived values. For example, if a monitor derives end-tidal COâ‚‚ from a capnography waveform, one could argue whether these values should be considered part of a channel or stored as a label._\n", "\n", "
\n", "\n", "However in the present use case, to actually remove and add MAP values in an interactive plot we have to **convert** the MAP recordings from the `channel` into a `label`.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": null, "id": "f2e84e61-5473-4851-8942-9bcc5507168e", "metadata": {}, "outputs": [], "source": [ "map_channel = case.get_channel(name='MAP')\n", "map_label = Label.from_channel(map_channel)\n", "map_label.plotstyle.update({\"lw\": 0.8, \"alpha\": 0.8, \"c\": \"#393b41\", \"ls\": \"-\", \"ms\": 10})\n", "\n", "case.get_channel(\"MAP\").attach_label(map_label)" ] }, { "cell_type": "markdown", "id": "bd897d1e-873e-4912-9603-4b73d1a2d01b", "metadata": {}, "source": [ "
\n", "\n", "For the analysis, we are interested in the time span between the _Induction_ and the _End of Anaesthesia_.\n", "\n", "We therefore try to extract the time points of interest from the _Events_ channel and generate a **new Label** called _Analsysis_. As we want to manipulate both time points independently from each other, we deliberately use a (normal) `Label` and not an `IntervalLabel`. The time span we are going to analyse will be defined by the extremes of the `time_index` of the label _Analysis_.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": null, "id": "ecced614-1b0a-43fe-916a-d8a05fa0eac9", "metadata": {}, "outputs": [], "source": [ "t_index, data, text_data = case.get_label(\"Event\").get_data()\n", "mask = np.isin(text_data, [\"Induction\", \"Anaesthesia End\"])\n", "analysis_label=Label(\n", " name=\"Analysis\",\n", " time_index=t_index[mask],\n", " data=None,\n", " text_data=None,\n", " plotstyle={\"color\": \"crimson\", \"lw\": 3, \"alpha\": 0.5},\n", " plot_type=\"vline\",\n", " vline_text_source=\"disabled\",\n", ")\n", "case.add_global_label(analysis_label)" ] }, { "cell_type": "markdown", "id": "76a8c5c2-1da1-4dba-90e7-fa5bcf810acd", "metadata": {}, "source": [ "## 3. Plotting and Labeling Data Interactively" ] }, { "cell_type": "markdown", "id": "9d5d252c-70dd-469f-8362-e59cf7f445cf", "metadata": {}, "source": [ "
\n", "\n", "As in the previous use cases we initialize our plot with `plot_interactive`. We than adapt the figure more extensively by editing the `figure` and its `axes` directly.\n", "Note that we are plotting the label MAP and on top the channel MAP (where the former is editable via the `Annotate` menu).\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": null, "id": "7d22036e-bf72-4c9e-9dcf-d73f683fcaae", "metadata": {}, "outputs": [], "source": [ "t_analysis = case.get_label(\"Event\").get_data().time_index\n", "padding_time = 2\n", "observation_start=t_analysis.min() - pd.to_timedelta(padding_time, \"m\") # adds 2 minutes before first event\n", "observation_stop=t_analysis.max() + pd.to_timedelta(padding_time, \"m\") # adds 2 minutes after the last event\n", "\n", "plot = case.plot_interactive(\n", " channels = [[0, 1, 2], [], []],\n", " labels = [[\"Event\", \"Analysis\", \"MAP\"], [\"Remifentanil\", \"Medication\"], [\"Sevofluran\"]],\n", " time_unit= \"m\",\n", " start = observation_start,\n", " stop = observation_stop,\n", " subplots_kwargs = {\"figsize\": (12.5, 8), \"gridspec_kw\": {\"height_ratios\": [5, 1, 0.5]}},\n", ")\n", "\n", "fig = plot.center.figure\n", "fig.suptitle(\"\") # remove title\n", "fig.subplots_adjust(hspace = 0)\n", "axes = fig.get_axes()\n", "axes[0].set_ylabel(\"Blood pressure (mmHg)\")\n", "axes[0].set_ylim(-5)\n", "axes[0].get_legend().remove()\n", "axes[0].set_xlabel(\"\")\n", "axes[0].xaxis.set_ticks_position('top')\n", "axes[1].set_xticks([])\n", "for ax in axes[1:]:\n", " ax.set_yticks([])\n", " ax.set_xlabel(\"\")\n", " ax.grid(False)\n", "\n", "display(plot)" ] }, { "cell_type": "markdown", "id": "16757d25-84e4-4f5f-b2b1-77d66d65767d", "metadata": {}, "source": [ "
\n", "\n", "To highlight episode of hypotension (i.e. MAP<65mmHg) we define a function to **highlight the area** in orange.\n", "\n", "
\n", "\n", "_(Running the cell below changes the appearance of the interactive plot above.)_" ] }, { "cell_type": "code", "execution_count": null, "id": "dd9c2ead-0517-4c86-9a1e-c2f250fc0988", "metadata": {}, "outputs": [], "source": [ "threshold = 65\n", "\n", "def show_auc(case, ax, threshold: int = 65):\n", " MAP = case.get_label('MAP')\n", " if MAP.is_time_absolute():\n", " reference_time = MAP.time_start - observation_start\n", " time_index = MAP.time_index + reference_time\n", " time_index /= pd.to_timedelta(1, unit=\"m\")\n", " y2 = np.array([threshold] * len(MAP))\n", " ax.fill_between(time_index, MAP.data, y2, where=(MAP.data <= threshold), interpolate=True, facecolor=\"#ff7f45\", alpha=.8)\n", "\n", "show_auc(case, axes[0], threshold=threshold)" ] }, { "cell_type": "markdown", "id": "1fe6030b-8c75-4fa2-944c-87d5dcbe4b90", "metadata": {}, "source": [ "
\n", "\n", "The `vitabel` package has an integrated function `area_under_threshold` to **quantify hypotension** as area and duration where the signal falls below a specified threshold.\n", "\n", "
\n", "\n", "(see also [10.1213/ANE.0000000000003482](https://doi.org/10.1213/ANE.0000000000003482))" ] }, { "cell_type": "code", "execution_count": null, "id": "eb0a61ed-8e53-4a15-9fcd-3e1e66c32209", "metadata": {}, "outputs": [], "source": [ "MAP = case.get_label('MAP')\n", "metrics = case.area_under_threshold(source=MAP, start_time=observation_start, stop_time=observation_stop, threshold=65)\n", "\n", "md = f\"\"\"\n", "#### Threshold Metrics for MAP under {threshold} mmHg\n", "| Metric | Value |\n", "|-----------------------------------|----------------------------------------|\n", "| **Area Under Threshold** | {metrics.area_under_threshold.value:.2f} {metrics.area_under_threshold.unit} |\n", "| **Duration Under Threshold** | {metrics.duration_under_threshold} |\n", "| **Time-Weighted Avg. Under** | {metrics.time_weighted_average_under_threshold.value:.2f} {metrics.time_weighted_average_under_threshold.unit} |\n", "| **Observation Duration** | {metrics.observational_interval_duration} |\n", "\"\"\"\n", "display(Markdown(md))" ] }, { "cell_type": "markdown", "id": "f0f207d3-51e8-43e5-8801-c554e803d51a", "metadata": {}, "source": [ "## 4. Building a Custom User Interface\n" ] }, { "cell_type": "markdown", "id": "91b2a441-d8f7-4ebb-a420-42a709329016", "metadata": {}, "source": [ "
\n", "\n", "We wrap the plot in a user interface built with widgets from [`ipywidgets`](https://ipywidgets.readthedocs.io/en/stable/).\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": null, "id": "d0e6bdf3-6122-467d-b0a3-93a597a10351", "metadata": {}, "outputs": [], "source": [ "shared_layout = widgets.Layout(width='280px')\n", "shared_style = {'description_width': '180px'}\n", "\n", "fields = [\n", " (\"twa\", \"TWA-MAP [mmHg]\", widgets.FloatText),\n", " (\"auc\", \"AUC [mmHg*min]\", widgets.FloatText),\n", " (\"hypotens_dur\", \"duration hypotension [min]\", widgets.FloatText),\n", " (\"anae_dur\", \"anaesthesia durarion [min]\", widgets.FloatText), \n", "]\n", "\n", "widget_hbox = {\n", " name: widget_type(\n", " value=None,\n", " description=desc,\n", " disabled=True,\n", " layout=shared_layout,\n", " style=shared_style, \n", " **({'step': 0.01} if widget_type is widgets.FloatText else {})\n", " ) for name, desc, widget_type in fields\n", "}\n", "\n", "remark_input = widgets.Textarea(\n", " value=case.metadata.get(\"project\", {}).get(\"first_review\", {}).get(\"comment\", \"\"),\n", " description='Notes:',\n", " placeholder='Additional remarks',\n", " layout=widgets.Layout(max_width='280px', width='100%', height='100px')\n", ")\n", "\n", "text_input = widgets.Textarea(\n", " description='Reason:',\n", " placeholder='Type your explanation here...',\n", " layout=widgets.Layout(max_width='280px', width='100%', height='100px')\n", ")\n", "\n", "button_next = widgets.Button( # Orange next button\n", " description='Review later',\n", " button_style='warning',\n", " layout=widgets.Layout(max_width=\"195px\",width='20%', height=\"60px\"),\n", ")\n", "button_exclude = widgets.Button( # Red exclude button\n", " description='Exclude',\n", " button_style='danger',\n", " layout=widgets.Layout(max_width=\"195px\", width='20%', height=\"60px\"),\n", ")\n", "button_save = widgets.Button( # Green save button (full width below)\n", " description='Save & Next',\n", " button_style='success',\n", " layout=widgets.Layout(max_width=\"800px\", width='95%', height=\"60px\"),\n", ")\n", "\n", "text_case_id = widgets.Text(\n", " value=case.metadata.get(\"case_id\",\"\"),\n", " description='Case ID:',\n", " disabled=True,\n", " layout=widgets.Layout(max_width='220px', width='100%'),\n", ")\n", "flag_check=widgets.Checkbox(\n", " value=False,\n", " description='Flag for Revision',\n", " disabled=False,\n", ")\n", "\n", "def save_callback(b):\n", " global endpoints\n", "\n", " case.metadata.setdefault(\"project_vitabel\", {})\n", " case.metadata[\"project_vitabel\"][\"remarks\"] = {\n", " \"investigator\": \"YOUR_NAME_HERE\",\n", " \"comment\": remark_input.value,\n", " \"date\": str(datetime.now()),\n", " }\n", " case.metadata[\"project_vitabel\"][\"flagged\"] = {\"revision\": flag_check.value}\n", " serializable_dict = {\n", " k: str(v) if not isinstance(v, (str, int, float, bool, list, dict, type(None))) else v\n", " for k, v in endpoints.__dict__.items()\n", " }\n", " case.metadata[\"project_vitabel\"][\"endpoints\"] = serializable_dict\n", " case.save_data(\"case_3_reviewed.json\")\n", "\n", " mockup_callback(\"b\")\n", "\n", "def mockup_callback(b):\n", " # Insert your Code here\n", " fig.clear()\n", "\n", "# attach callback functions to click events of save / exclude / next buttons\n", "button_save.on_click(save_callback)\n", "button_exclude.on_click(mockup_callback)\n", "button_next.on_click(mockup_callback)\n", "\n", "# Message Output Widget\n", "message_output = widgets.Output()\n", "\n", "value_col = widgets.VBox(\n", " [widget_hbox[name] for name, *_ in fields]\n", " + [widgets.Box(layout=widgets.Layout(height='30px')), remark_input, text_input]\n", ")\n", "top_row = plot.children[0]\n", "middle_row = widgets.HBox([plot.children[1], value_col])\n", "button_row = widgets.HBox([button_save, button_next, button_exclude, flag_check, text_case_id,])\n", "ui = widgets.VBox([top_row,middle_row, button_row, message_output])" ] }, { "cell_type": "markdown", "id": "cab07b55-92f2-4c84-b963-e57c25c41246", "metadata": {}, "source": [ "Until now all adaptions and calculations are static. To make them responsive to alterations in the label _MAP_ or _Analysis_ we define the method on_draw and bind it to the event handling of matplotlib. " ] }, { "cell_type": "code", "execution_count": null, "id": "5314a786-8014-41c1-8f48-3261c5eb49b3", "metadata": {}, "outputs": [], "source": [ "def on_draw(event):\n", " global axes, widget_hbox, endpoints\n", "\n", " # check wether legend was redrawn\n", " if axes[0].get_legend():\n", " axes[0].get_legend().remove()\n", " for ax in axes[:-1]: \n", " ax.grid(False) # remove grid\n", " ax.set_xlabel(\"\") # remove the x-axis label \n", "\n", " axes[0].grid(axis='y', visible=True) # optional: keep y-axis grid\n", "\n", " for ax in axes[1:-2]:\n", " ax.set_yticks([]) \n", " \n", " # align labels to the left so they line up with the right axis line\n", " for label in axes[2].get_yticklabels():\n", " label.set_horizontalalignment('right')\n", "\n", " # check if area under the threshold curve (AUC) is marked\n", " has_fill_between = any(isinstance(col, PolyCollection) for col in axes[0].collections)\n", " if not has_fill_between:\n", " show_auc(case, axes[0], threshold=65)\n", "\n", " # calculate edpoints\n", " t_analysis = case.get_label(\"Analysis\").get_data().time_index\n", " if len(t_analysis) > 1:\n", " analysis_start = min(t_analysis) if min(t_analysis) > observation_start else observation_start\n", " analysis_stop = max(t_analysis) if max(t_analysis) < observation_stop else observation_stop\n", " else:\n", " analysis_start = observation_start\n", " analysis_stop = observation_stop\n", " endpoints = case.area_under_threshold(source=MAP, start_time=analysis_start, stop_time=analysis_stop, threshold=65)\n", "\n", " # Display Results\n", " widget_hbox[\"twa\"].value = round(endpoints.time_weighted_average_under_threshold.value,2)\n", " widget_hbox[\"auc\"].value = round(endpoints.area_under_threshold.value,0)\n", " widget_hbox[\"hypotens_dur\"].value = round(endpoints.duration_under_threshold.total_seconds()/60,1)\n", " widget_hbox[\"anae_dur\"].value = round(endpoints.observational_interval_duration.total_seconds()/60,1)\n", "\n", "\n", "_ = fig.canvas.mpl_connect('draw_event', on_draw)" ] }, { "cell_type": "markdown", "id": "b4a42c1c-3cbd-460b-8f7b-822f3a36b03f", "metadata": {}, "source": [ "Finally we can show our fully responsive user interface.
\n", "Now try to:
\n", "- remove the erroneaus MAP redings around minute 70\n", "- define the Analysis interval.\n", "\n", "Keep an eye to the numbers right to the plot.\n", "" ] }, { "cell_type": "code", "execution_count": null, "id": "d814d5fe-fc0b-4a7c-a5c8-d873e3e323f5", "metadata": {}, "outputs": [], "source": [ "display(ui)" ] }, { "cell_type": "markdown", "id": "7fe10c59-5554-43dc-ae43-901709774dfb", "metadata": {}, "source": [ "**Note:** This example is intended to demonstrate how the interactive plotting\n", "function can be integrated into a user interface. We deliberately chose \n", "to use only a single case and did not implement functionality to load \n", "additional cases via the buttons.\n", "\n", "To support multiple cases, you would initialize a placeholder widget that is\n", "displayed by default. The plotting and UI logic should be wrapped in a function\n", "that iterates over the cases. This function would then assign the generated\n", "user interface (including the embedded plot) to the placeholder widget for display.\n", "\n", "This workflow makes it especially suitable for inclusion in a Python package, allowing\n", "end users to interact with a clean notebook interface that focuses primarily on\n", "graphical data presentation, with minimal visible code." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3.13 (vitabel)", "language": "python", "name": "vitabel" }, "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.13.5" } }, "nbformat": 4, "nbformat_minor": 5 }