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:
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.