Use Case 3: User interface to research intra-anaesthesisa hypotension

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: Documentation Status

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.

If you have never worked with Jupyter Notebooks before, you may find this guide helpful: Beginners Guide to Jupyter Notebooks

from vitabel import Vitals, Label
import numpy as np
import pandas as pd
from IPython.display import display, Markdown
import ipywidgets as widgets
from matplotlib.collections import PolyCollection
from datetime import datetime

1. Loading Data

A Vitals object is initialized and data which was saved previously with vitabel is loaded again.

case = Vitals()
case.metadata.update({"case_id": "use_case_3"})
case.load_data("data/usecase_3.json")

We obtain an overview over all channels and labels in the signal.

case.info()

2. Processing Data

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 and the defined anaesthesia interval.

Conceptually vitabel devides time-series data into channels and labels:

  • channels contain raw data recorded by a device

  • labels provide additional information—either annotated manually or derived from channel data

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.

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.

map_channel = case.get_channel(name='MAP')
map_label = Label.from_channel(map_channel)
map_label.plotstyle.update({"lw": 0.8, "alpha": 0.8, "c": "#393b41", "ls": "-", "ms": 10})

case.get_channel("MAP").attach_label(map_label)

For the analysis, we are interested in the time span between the Induction and the End of Anaesthesia.

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.

t_index, data, text_data = case.get_label("Event").get_data()
mask = np.isin(text_data, ["Induction", "Anaesthesia End"])
analysis_label=Label(
    name="Analysis",
    time_index=t_index[mask],
    data=None,
    text_data=None,
    plotstyle={"color": "crimson", "lw": 3, "alpha": 0.5},
    plot_type="vline",
    vline_text_source="disabled",
)
case.add_global_label(analysis_label)

3. Plotting and Labeling Data Interactively

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. Note that we are plotting the label MAP and on top the channel MAP (where the former is editable via the Annotate menu).

t_analysis = case.get_label("Event").get_data().time_index
padding_time = 2
observation_start=t_analysis.min() - pd.to_timedelta(padding_time, "m")  # adds 2 minutes before first event
observation_stop=t_analysis.max() + pd.to_timedelta(padding_time, "m")  # adds 2 minutes after the last event

plot = case.plot_interactive(
    channels = [[0, 1, 2], [], []],
    labels = [["Event", "Analysis", "MAP"], ["Remifentanil", "Medication"], ["Sevofluran"]],
    time_unit= "m",
    start = observation_start,
    stop = observation_stop,
    subplots_kwargs = {"figsize": (12.5, 8), "gridspec_kw": {"height_ratios": [5, 1, 0.5]}},
)

fig = plot.center.figure
fig.suptitle("")  # remove title
fig.subplots_adjust(hspace = 0)
axes = fig.get_axes()
axes[0].set_ylabel("Blood pressure (mmHg)")
axes[0].set_ylim(-5)
axes[0].get_legend().remove()
axes[0].set_xlabel("")
axes[0].xaxis.set_ticks_position('top')
axes[1].set_xticks([])
for ax in axes[1:]:
    ax.set_yticks([])
    ax.set_xlabel("")
    ax.grid(False)

display(plot)

To highlight episode of hypotension (i.e. MAP<65mmHg) we define a function to highlight the area in orange.

(Running the cell below changes the appearance of the interactive plot above.)

threshold = 65

def show_auc(case, ax, threshold: int = 65):
    MAP = case.get_label('MAP')
    if MAP.is_time_absolute():
        reference_time = MAP.time_start - observation_start
        time_index = MAP.time_index + reference_time
    time_index /= pd.to_timedelta(1, unit="m")
    y2 = np.array([threshold] * len(MAP))
    ax.fill_between(time_index, MAP.data, y2, where=(MAP.data <= threshold), interpolate=True, facecolor="#ff7f45", alpha=.8)

show_auc(case, axes[0], threshold=threshold)

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.

(see also 10.1213/ANE.0000000000003482)

MAP = case.get_label('MAP')
metrics = case.area_under_threshold(source=MAP, start_time=observation_start, stop_time=observation_stop, threshold=65)

md = f"""
#### Threshold Metrics for MAP under {threshold} mmHg
| Metric                            | Value                                  |
|-----------------------------------|----------------------------------------|
| **Area Under Threshold**          | {metrics.area_under_threshold.value:.2f} {metrics.area_under_threshold.unit} |
| **Duration Under Threshold**      | {metrics.duration_under_threshold}     |
| **Time-Weighted Avg. Under**      | {metrics.time_weighted_average_under_threshold.value:.2f} {metrics.time_weighted_average_under_threshold.unit} |
| **Observation Duration**          | {metrics.observational_interval_duration} |
"""
display(Markdown(md))

4. Building a Custom User Interface

We wrap the plot in a user interface built with widgets from ipywidgets.

shared_layout = widgets.Layout(width='280px')
shared_style = {'description_width': '180px'}

fields = [
    ("twa", "TWA-MAP [mmHg]", widgets.FloatText),
    ("auc", "AUC [mmHg*min]", widgets.FloatText),
    ("hypotens_dur", "duration hypotension [min]", widgets.FloatText),
    ("anae_dur", "anaesthesia durarion [min]", widgets.FloatText),  
]

widget_hbox = {
    name: widget_type(
        value=None,
        description=desc,
        disabled=True,
        layout=shared_layout,
        style=shared_style,      
        **({'step': 0.01} if widget_type is widgets.FloatText else {})
    ) for name, desc, widget_type in fields
}

remark_input = widgets.Textarea(
    value=case.metadata.get("project", {}).get("first_review", {}).get("comment", ""),
    description='Notes:',
    placeholder='Additional remarks',
    layout=widgets.Layout(max_width='280px', width='100%', height='100px')
)

text_input = widgets.Textarea(
    description='Reason:',
    placeholder='Type your explanation here...',
    layout=widgets.Layout(max_width='280px', width='100%', height='100px')
)

button_next = widgets.Button(  # Orange next button
    description='Review later',
    button_style='warning',
    layout=widgets.Layout(max_width="195px",width='20%', height="60px"),
)
button_exclude = widgets.Button(  # Red exclude button
    description='Exclude',
    button_style='danger',
    layout=widgets.Layout(max_width="195px", width='20%', height="60px"),
)
button_save = widgets.Button(  # Green save button (full width below)
    description='Save & Next',
    button_style='success',
    layout=widgets.Layout(max_width="800px", width='95%', height="60px"),
)

text_case_id = widgets.Text(
    value=case.metadata.get("case_id",""),
    description='Case ID:',
    disabled=True,
    layout=widgets.Layout(max_width='220px', width='100%'),
)
flag_check=widgets.Checkbox(
    value=False,
    description='Flag for Revision',
    disabled=False,
)

def save_callback(b):
    global endpoints

    case.metadata.setdefault("project_vitabel", {})
    case.metadata["project_vitabel"]["remarks"] = {
        "investigator": "YOUR_NAME_HERE",
        "comment": remark_input.value,
        "date": str(datetime.now()),
    }
    case.metadata["project_vitabel"]["flagged"] = {"revision": flag_check.value}
    serializable_dict = {
        k: str(v) if not isinstance(v, (str, int, float, bool, list, dict, type(None))) else v
        for k, v in endpoints.__dict__.items()
    }
    case.metadata["project_vitabel"]["endpoints"] = serializable_dict
    case.save_data("case_3_reviewed.json")

    mockup_callback("b")

def mockup_callback(b):
    # Insert your Code here
    fig.clear()

# attach callback functions to click events of save / exclude / next buttons
button_save.on_click(save_callback)
button_exclude.on_click(mockup_callback)
button_next.on_click(mockup_callback)

# Message Output Widget
message_output = widgets.Output()

value_col = widgets.VBox(
    [widget_hbox[name] for name, *_ in fields]
    + [widgets.Box(layout=widgets.Layout(height='30px')), remark_input, text_input]
)
top_row = plot.children[0]
middle_row = widgets.HBox([plot.children[1], value_col])
button_row = widgets.HBox([button_save, button_next, button_exclude, flag_check, text_case_id,])
ui = widgets.VBox([top_row,middle_row, button_row, message_output])

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.

def on_draw(event):
    global axes, widget_hbox, endpoints

    # check wether legend was redrawn
    if axes[0].get_legend():
        axes[0].get_legend().remove()
        for ax in axes[:-1]:  
            ax.grid(False)  # remove grid
            ax.set_xlabel("")  # remove the x-axis label      

        axes[0].grid(axis='y', visible=True)  # optional: keep y-axis grid

        for ax in axes[1:-2]:
            ax.set_yticks([])  
        
        # align labels to the left so they line up with the right axis line
        for label in axes[2].get_yticklabels():
            label.set_horizontalalignment('right')

    # check if area under the threshold curve (AUC) is marked
    has_fill_between = any(isinstance(col, PolyCollection) for col in axes[0].collections)
    if not has_fill_between:
        show_auc(case, axes[0], threshold=65)

    # calculate edpoints
    t_analysis = case.get_label("Analysis").get_data().time_index
    if len(t_analysis) > 1:
        analysis_start = min(t_analysis) if min(t_analysis) > observation_start else observation_start
        analysis_stop = max(t_analysis) if max(t_analysis) < observation_stop else observation_stop
    else:
        analysis_start = observation_start
        analysis_stop = observation_stop
    endpoints = case.area_under_threshold(source=MAP, start_time=analysis_start, stop_time=analysis_stop, threshold=65)

    # Display Results
    widget_hbox["twa"].value = round(endpoints.time_weighted_average_under_threshold.value,2)
    widget_hbox["auc"].value = round(endpoints.area_under_threshold.value,0)
    widget_hbox["hypotens_dur"].value = round(endpoints.duration_under_threshold.total_seconds()/60,1)
    widget_hbox["anae_dur"].value = round(endpoints.observational_interval_duration.total_seconds()/60,1)


_ = fig.canvas.mpl_connect('draw_event', on_draw)

Finally we can show our fully responsive user interface.
Now try to:

  • remove the erroneaus MAP redings around minute 70

  • define the Analysis interval.

Keep an eye to the numbers right to the plot.

display(ui)

Note: This example is intended to demonstrate how the interactive plotting function can be integrated into a user interface. We deliberately chose to use only a single case and did not implement functionality to load additional cases via the buttons.

To support multiple cases, you would initialize a placeholder widget that is displayed by default. The plotting and UI logic should be wrapped in a function that iterates over the cases. This function would then assign the generated user interface (including the embedded plot) to the placeholder widget for display.

This workflow makes it especially suitable for inclusion in a Python package, allowing end users to interact with a clean notebook interface that focuses primarily on graphical data presentation, with minimal visible code.